Files
AIclinicalresearch/backend/scripts/test-ssa-phase4-e2e.ts
HaHafeng 3446909ff7 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>
2026-02-22 18:53:39 +08:00

347 lines
12 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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);
});