feat(ssa): Complete Phase I-IV intelligent dialogue and tool system development

Phase I - Session Blackboard + READ Layer:
- SessionBlackboardService with Postgres-Only cache
- DataProfileService for data overview generation
- PicoInferenceService for LLM-driven PICO extraction
- Frontend DataContextCard and VariableDictionaryPanel
- E2E tests: 31/31 passed

Phase II - Conversation Layer LLM + Intent Router:
- ConversationService with SSE streaming
- IntentRouterService (rule-first + LLM fallback, 6 intents)
- SystemPromptService with 6-segment dynamic assembly
- TokenTruncationService for context management
- ChatHandlerService as unified chat entry
- Frontend SSAChatPane and useSSAChat hook
- E2E tests: 38/38 passed

Phase III - Method Consultation + AskUser Standardization:
- ToolRegistryService with Repository Pattern
- MethodConsultService with DecisionTable + LLM enhancement
- AskUserService with global interrupt handling
- Frontend AskUserCard component
- E2E tests: 13/13 passed

Phase IV - Dialogue-Driven Analysis + QPER Integration:
- ToolOrchestratorService (plan/execute/report)
- analysis_plan SSE event for WorkflowPlan transmission
- Dual-channel confirmation (ask_user card + workspace button)
- PICO as optional hint for LLM parsing
- E2E tests: 25/25 passed

R Statistics Service:
- 5 new R tools: anova_one, baseline_table, fisher, linear_reg, wilcoxon
- Enhanced guardrails and block helpers
- Comprehensive test suite (run_all_tools_test.js)

Documentation:
- Updated system status document (v5.9)
- Updated SSA module status and development plan (v1.8)

Total E2E: 107/107 passed (Phase I: 31, Phase II: 38, Phase III: 13, Phase IV: 25)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-02-22 18:53:39 +08:00
parent bf10dec4c8
commit 3446909ff7
68 changed files with 11583 additions and 412 deletions

View File

@@ -0,0 +1,346 @@
/**
* Phase IV — 对话驱动分析 E2E 测试
*
* 测试覆盖:
* 1. 登录 + Session + 上传数据
* 2. analyze 意图 → analysis_plan SSE 事件推送
* 3. LLM 方案说明生成
* 4. ask_user 确认卡片推送
* 5. 旧 /workflow/plan API 向后兼容B2
* 6. AVAILABLE_TOOLS 配置化验证(无硬编码残留)
* 7. 对话历史 — analyze 消息验证
*
* 运行方式npx tsx scripts/test-ssa-phase4-e2e.ts
*/
import { readFileSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const BASE_URL = 'http://localhost:3000';
const TEST_PHONE = '13800000001';
const TEST_PASSWORD = '123456';
const TEST_CSV_PATH = join(__dirname, '../../docs/03-业务模块/SSA-智能统计分析/05-测试文档/test.csv');
let passed = 0;
let failed = 0;
let skipped = 0;
let token = '';
let sessionId = '';
// ────────────────────────────────────────────
// Helpers
// ────────────────────────────────────────────
function assert(condition: boolean, testName: string, detail?: string) {
if (condition) {
console.log(`${testName}`);
passed++;
} else {
console.log(`${testName}${detail ? `${detail}` : ''}`);
failed++;
}
}
function skip(testName: string, reason: string) {
console.log(` ⏭️ ${testName} — 跳过:${reason}`);
skipped++;
}
function section(title: string) {
console.log(`\n${'─'.repeat(60)}`);
console.log(`📋 ${title}`);
console.log('─'.repeat(60));
}
function authHeaders(contentType?: string): Record<string, string> {
const h: Record<string, string> = { Authorization: `Bearer ${token}` };
if (contentType) h['Content-Type'] = contentType;
return h;
}
async function apiPost(path: string, body: any, headers?: Record<string, string>): Promise<any> {
const res = await fetch(`${BASE_URL}${path}`, {
method: 'POST',
headers: headers || authHeaders('application/json'),
body: typeof body === 'string' ? body : JSON.stringify(body),
});
const text = await res.text();
try { return { status: res.status, data: JSON.parse(text) }; }
catch { return { status: res.status, data: text }; }
}
async function apiGet(path: string): Promise<any> {
const res = await fetch(`${BASE_URL}${path}`, {
method: 'GET',
headers: authHeaders(),
});
const text = await res.text();
try { return { status: res.status, data: JSON.parse(text) }; }
catch { return { status: res.status, data: text }; }
}
async function chatSSE(sid: string, content: string, metadata?: Record<string, any>, timeoutMs = 120000): Promise<{
status: number;
events: any[];
fullContent: string;
intentMeta: any | null;
askUserEvent: any | null;
analysisPlan: any | null;
planConfirmed: any | null;
errorEvent: any | null;
}> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
const events: any[] = [];
let fullContent = '';
let intentMeta: any = null;
let askUserEvent: any = null;
let analysisPlan: any = null;
let planConfirmed: any = null;
let errorEvent: any = null;
try {
const body: any = { content };
if (metadata) body.metadata = metadata;
const res = await fetch(`${BASE_URL}/api/v1/ssa/sessions/${sid}/chat`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
Accept: 'text/event-stream',
},
body: JSON.stringify(body),
signal: controller.signal,
});
if (!res.ok || !res.body) {
clearTimeout(timer);
return { status: res.status, events: [], fullContent: '', intentMeta: null, askUserEvent: null, analysisPlan: null, planConfirmed: null, errorEvent: null };
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (!line.trim() || line.startsWith(': ')) continue;
if (!line.startsWith('data: ')) continue;
const data = line.slice(6).trim();
if (data === '[DONE]') continue;
try {
const parsed = JSON.parse(data);
events.push(parsed);
if (parsed.type === 'intent_classified') intentMeta = parsed;
if (parsed.type === 'ask_user') askUserEvent = parsed;
if (parsed.type === 'analysis_plan') analysisPlan = parsed;
if (parsed.type === 'plan_confirmed') planConfirmed = parsed;
if (parsed.type === 'error') errorEvent = parsed;
const delta = parsed.choices?.[0]?.delta;
if (delta?.content) fullContent += delta.content;
} catch {}
}
}
clearTimeout(timer);
return { status: res.status, events, fullContent, intentMeta, askUserEvent, analysisPlan, planConfirmed, errorEvent };
} catch (e: any) {
clearTimeout(timer);
if (e.name === 'AbortError') {
return { status: 408, events, fullContent, intentMeta, askUserEvent, analysisPlan, planConfirmed, errorEvent };
}
throw e;
}
}
// ────────────────────────────────────────────
// Tests
// ────────────────────────────────────────────
async function test1_login() {
section('Test 1: 登录认证');
const res = await apiPost('/api/v1/auth/login/password', { phone: TEST_PHONE, password: TEST_PASSWORD });
assert(res.status === 200, `登录返回 200实际 ${res.status}`);
token = res.data?.data?.tokens?.accessToken || res.data?.accessToken || '';
assert(token.length > 0, `获取到 JWT Token长度: ${token.length}`);
}
async function test2_createSession() {
section('Test 2: 创建 Session + 上传数据');
try {
const csvData = readFileSync(TEST_CSV_PATH);
const boundary = '----TestBoundary' + Date.now();
const body = Buffer.concat([
Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="test.csv"\r\nContent-Type: text/csv\r\n\r\n`),
csvData,
Buffer.from(`\r\n--${boundary}--\r\n`),
]);
const res = await fetch(`${BASE_URL}/api/v1/ssa/sessions`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': `multipart/form-data; boundary=${boundary}`,
},
body,
});
const data = await res.json();
assert(res.status === 200 || res.status === 201, `创建 Session 返回 2xx实际 ${res.status}`);
sessionId = data?.sessionId || data?.data?.sessionId || data?.id || '';
assert(sessionId.length > 0, `获取到 sessionId: ${sessionId.substring(0, 8)}...`);
if (sessionId) {
// 触发数据概览 + PICO 推断流水线
const streamRes = await fetch(`${BASE_URL}/api/v1/ssa/sessions/${sessionId}/data-context/stream`, {
headers: { Authorization: `Bearer ${token}`, Accept: 'text/event-stream' },
});
const streamText = await streamRes.text();
const hasOverview = streamText.includes('data_overview_complete');
assert(hasOverview, `数据概览生成完成`);
await new Promise(r => setTimeout(r, 2000));
}
} catch (e: any) {
assert(false, '创建 Session 失败', e.message);
}
}
async function test3_analyzeIntent() {
section('Test 3: analyze 意图 → analysis_plan SSE 事件');
if (!sessionId) { skip('analyze 意图', '无 sessionId'); return; }
const result = await chatSSE(sessionId, '请执行分析比较两组患者的BMI差异用独立样本T检验');
assert(result.status === 200, `SSE 返回 200`);
assert(result.intentMeta?.intent === 'analyze', `意图分类为 analyze实际: ${result.intentMeta?.intent}`);
if (result.analysisPlan) {
const plan = result.analysisPlan.plan;
assert(!!plan, `收到 analysis_plan 事件`);
assert(!!plan?.workflow_id, `plan 包含 workflow_id: ${plan?.workflow_id?.substring(0, 8)}...`);
assert(plan?.total_steps > 0, `plan 包含 ${plan?.total_steps} 个步骤`);
assert(result.fullContent.length > 50, `LLM 生成方案说明(${result.fullContent.length} 字符)`);
} else {
skip('analysis_plan 事件', 'planWorkflow 可能因数据不足失败');
}
}
async function test4_askUserCard() {
section('Test 4: analyze 确认卡片ask_user');
if (!sessionId) { skip('ask_user 卡片', '无 sessionId'); return; }
const result = await chatSSE(sessionId, '对BMI和年龄做相关分析');
if (result.askUserEvent) {
assert(result.askUserEvent.inputType === 'confirm', `inputType 为 confirm实际: ${result.askUserEvent.inputType}`);
assert(!!result.askUserEvent.questionId, `包含 questionId`);
const options = result.askUserEvent.options || [];
const hasConfirm = options.some((o: any) => o.value === 'confirm_plan');
const hasChange = options.some((o: any) => o.value === 'change_method');
assert(hasConfirm, `包含"确认执行"选项`);
assert(hasChange, `包含"修改方案"选项`);
} else {
skip('ask_user 确认卡片', '未收到 ask_user 事件planWorkflow 可能未生成完整计划)');
}
}
async function test5_oldWorkflowAPI() {
section('Test 5: 旧 /workflow/plan API 向后兼容B2');
if (!sessionId) { skip('旧 API', '无 sessionId'); return; }
const res = await apiPost('/api/v1/ssa/workflow/plan', {
sessionId,
userQuery: '对BMI做描述性统计',
});
assert(res.status === 200, `旧 API 返回 200实际 ${res.status}`);
if (res.status === 200) {
const plan = res.data?.plan;
assert(!!plan?.workflow_id, `返回 WorkflowPlanworkflow_id: ${plan?.workflow_id?.substring(0, 8)}...`);
assert(plan?.total_steps > 0, `包含 ${plan?.total_steps} 个步骤`);
}
}
async function test6_noHardcodedTools() {
section('Test 6: AVAILABLE_TOOLS 配置化验证');
const res = await chatSSE(sessionId, '帮我做T检验分析组间差异');
if (res.analysisPlan?.plan) {
const steps = res.analysisPlan.plan.steps || [];
for (const step of steps) {
assert(typeof step.tool_name === 'string' && step.tool_name.length > 0,
`步骤 ${step.step_number} 工具名有效: ${step.tool_name}`);
}
} else {
assert(res.fullContent.length > 0, `LLM 生成了回复(可能因数据不足未生成计划)`);
}
}
async function test7_chatHistory() {
section('Test 7: 对话历史 — analyze 消息验证');
if (!sessionId) { skip('历史', '无 sessionId'); return; }
const res = await apiGet(`/api/v1/ssa/sessions/${sessionId}/chat/history`);
assert(res.status === 200, `历史 API 返回 200`);
if (res.status === 200) {
const messages = res.data?.messages || [];
assert(messages.length > 0, `有历史消息(${messages.length} 条)`);
const analyzeMessages = messages.filter((m: any) =>
(m.intent === 'analyze' || m.metadata?.intent === 'analyze') && m.role === 'assistant'
);
assert(analyzeMessages.length > 0, `有 analyze 意图消息(${analyzeMessages.length} 条)`);
const generating = messages.filter((m: any) => m.status === 'generating');
assert(generating.length === 0, `无残留 generating 状态`);
}
}
// ────────────────────────────────────────────
// Main
// ────────────────────────────────────────────
async function main() {
console.log('═'.repeat(60));
console.log(' Phase IV E2E 测试 — 对话驱动分析 + QPER 集成');
console.log('═'.repeat(60));
await test1_login();
await test2_createSession();
await test3_analyzeIntent();
await test4_askUserCard();
await test5_oldWorkflowAPI();
await test6_noHardcodedTools();
await test7_chatHistory();
console.log(`\n${'═'.repeat(60)}`);
console.log(` Phase IV E2E 测试结果: ${passed} passed / ${failed} failed / ${skipped} skipped`);
console.log('═'.repeat(60));
process.exit(failed > 0 ? 1 : 0);
}
main().catch(e => {
console.error('❌ 测试运行失败:', e);
process.exit(1);
});