Files
AIclinicalresearch/backend/scripts/test-ssa-phase3-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

469 lines
17 KiB
TypeScript
Raw 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 III — method_consult + ask_user E2E 测试
*
* 测试覆盖:
* 1. Prompt 模板入库验证SSA_METHOD_CONSULT
* 2. ToolRegistryService 查询准确性
* 3. consult 意图 → method_consult 结构化推荐
* 4. ask_user 确认卡片推送
* 5. ask_user 响应路由confirm/skip
* 6. H1: 用户无视卡片直接打字 → 全局打断 + 正常路由
* 7. H1: ask_user skip 按钮 → 正常清理 + 友好回复
*
* 依赖Node.js 后端 + PostgreSQL + Python extraction_service + LLM 服务
* 运行方式npx tsx scripts/test-ssa-phase3-e2e.ts
*
* 测试用户13800000001 / 123456
* 测试数据docs/03-业务模块/SSA-智能统计分析/05-测试文档/test.csv
*/
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 }; }
}
/**
* 发送 SSE 对话请求,收集所有事件
*/
async function chatSSE(sid: string, content: string, metadata?: Record<string, any>, timeoutMs = 90000): Promise<{
status: number;
events: any[];
fullContent: string;
intentMeta: any | null;
askUserEvent: 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 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, 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;
} else if (parsed.type === 'ask_user') {
askUserEvent = parsed;
} else if (parsed.type === 'error') {
errorEvent = parsed;
} else if (parsed.choices?.[0]?.delta?.content) {
fullContent += parsed.choices[0].delta.content;
}
} catch { /* skip non-JSON */ }
}
}
clearTimeout(timer);
return { status: res.status, events, fullContent, intentMeta, askUserEvent, errorEvent };
} catch (e: any) {
clearTimeout(timer);
if (e.name === 'AbortError') {
return { status: 0, events, fullContent, intentMeta, askUserEvent, errorEvent: { type: 'error', message: 'Timeout' } };
}
throw e;
}
}
// ────────────────────────────────────────────
// Test 1: 登录
// ────────────────────────────────────────────
async function test1Login(): Promise<boolean> {
section('Test 1: 登录认证');
const res = await apiPost('/api/v1/auth/login/password', {
phone: TEST_PHONE,
password: TEST_PASSWORD,
}, { 'Content-Type': 'application/json' });
assert(res.status === 200, `登录返回 200实际 ${res.status}`);
if (res.status === 200 && res.data) {
token = res.data?.data?.tokens?.accessToken || res.data?.accessToken || '';
assert(token.length > 0, `获取到 JWT Token长度: ${token.length}`);
return token.length > 0;
}
return false;
}
// ────────────────────────────────────────────
// Test 2: 创建 Session + 上传数据
// ────────────────────────────────────────────
async function test2CreateSession(): Promise<boolean> {
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) {
await new Promise(r => setTimeout(r, 3000));
}
return sessionId.length > 0;
} catch (e: any) {
assert(false, '创建 Session 失败', e.message);
return false;
}
}
// ────────────────────────────────────────────
// Test 3: ToolRegistryService 查询验证
// ────────────────────────────────────────────
async function test3ToolRegistry() {
section('Test 3: ToolRegistryService通过 consult 间接验证)');
// 通过 consult 意图触发 MethodConsultService → ToolRegistryService
// 我们验证 consult 返回中包含工具元数据
if (!sessionId) { skip('ToolRegistry 验证', '无 sessionId'); return; }
const result = await chatSSE(sessionId, '我想比较两组 BMI 差异,应该用什么方法?');
assert(result.status === 200, `SSE 返回 200`);
assert(result.intentMeta?.intent === 'consult', `意图分类为 consult实际: ${result.intentMeta?.intent}`);
assert(result.fullContent.length > 0, `收到方法推荐回复(${result.fullContent.length} 字符)`);
// 验证 P1 格式约束:回复应包含结构化内容
const hasStructure = result.fullContent.includes('T检验') ||
result.fullContent.includes('t检验') ||
result.fullContent.includes('比较') ||
result.fullContent.includes('推荐');
assert(hasStructure, 'LLM 回复包含方法推荐关键词');
}
// ────────────────────────────────────────────
// Test 4: consult 意图 → ask_user 卡片推送
// ────────────────────────────────────────────
async function test4ConsultAskUser() {
section('Test 4: consult 意图 → ask_user 确认卡片');
if (!sessionId) { skip('ask_user 卡片测试', '无 sessionId'); return; }
const result = await chatSSE(sessionId, '我应该用什么统计方法来分析治疗效果?');
assert(result.status === 200, `SSE 返回 200`);
if (result.askUserEvent) {
assert(result.askUserEvent.type === 'ask_user', 'ask_user 事件类型正确');
assert(typeof result.askUserEvent.questionId === 'string', `questionId: ${result.askUserEvent.questionId?.substring(0, 8)}...`);
assert(result.askUserEvent.options?.length > 0, `${result.askUserEvent.options?.length} 个选项`);
// 验证有跳过选项意味着前端可以渲染跳过按钮
const hasConfirm = result.askUserEvent.options?.some((o: any) => o.value === 'confirm');
assert(hasConfirm, '选项中包含确认选项');
} else {
// 如果没有推送 ask_userPICO 未推断),也是合理的
skip('ask_user 卡片', '无 ask_user 事件(可能 PICO 未完成)');
}
}
// ────────────────────────────────────────────
// Test 5: ask_user 响应 — confirm
// ────────────────────────────────────────────
async function test5AskUserConfirm() {
section('Test 5: ask_user 响应 — confirm');
if (!sessionId) { skip('confirm 测试', '无 sessionId'); return; }
// 先发 consult 触发 ask_user
const consultResult = await chatSSE(sessionId, '两组数据均值比较用什么方法好?');
if (!consultResult.askUserEvent) {
skip('confirm 响应', '上一步未产生 ask_user 事件');
return;
}
await new Promise(r => setTimeout(r, 2000));
// 发送 confirm 响应
const confirmResult = await chatSSE(
sessionId,
'确认使用推荐方法',
{
askUserResponse: {
questionId: consultResult.askUserEvent.questionId,
action: 'select',
selectedValues: ['confirm'],
},
},
);
assert(confirmResult.status === 200, `confirm 响应返回 200`);
assert(confirmResult.fullContent.length > 0, `收到确认回复(${confirmResult.fullContent.length} 字符)`);
}
// ────────────────────────────────────────────
// Test 6: H1 — ask_user skip 按钮
// ────────────────────────────────────────────
async function test6AskUserSkip() {
section('Test 6: H1 — ask_user skip 逃生门');
if (!sessionId) { skip('skip 测试', '无 sessionId'); return; }
// 触发 consult
const consultResult = await chatSSE(sessionId, '做相关分析需要满足什么条件?');
if (!consultResult.askUserEvent) {
skip('skip 逃生门', '未产生 ask_user 事件');
return;
}
await new Promise(r => setTimeout(r, 2000));
// 发送 skip 响应
const skipResult = await chatSSE(
sessionId,
'跳过了此问题',
{
askUserResponse: {
questionId: consultResult.askUserEvent.questionId,
action: 'skip',
},
},
);
assert(skipResult.status === 200, `skip 响应返回 200`);
assert(skipResult.fullContent.length > 0, `收到友好回复(${skipResult.fullContent.length} 字符)`);
}
// ────────────────────────────────────────────
// Test 7: H1 — 用户无视卡片直接打字
// ────────────────────────────────────────────
async function test7GlobalInterrupt() {
section('Test 7: H1 — 全局打断(用户无视卡片直接打字)');
if (!sessionId) { skip('全局打断测试', '无 sessionId'); return; }
// 触发 consult
const consultResult = await chatSSE(sessionId, '推荐一个分析两组差异的方法');
if (!consultResult.askUserEvent) {
skip('全局打断', '未产生 ask_user 事件');
return;
}
await new Promise(r => setTimeout(r, 2000));
// 无视卡片,直接打字新话题(无 askUserResponse metadata
const interruptResult = await chatSSE(sessionId, '算了,帮我看看数据有多少样本?');
assert(interruptResult.status === 200, `打断后返回 200`);
assert(interruptResult.intentMeta !== null, '收到新的 intent_classified');
// 意图应该不是 consult已被打断应该是 explore 或 chat
if (interruptResult.intentMeta) {
assert(
interruptResult.intentMeta.intent !== 'consult' || interruptResult.intentMeta.source === 'ask_user_response',
`打断后新意图: ${interruptResult.intentMeta.intent}(非 consult 残留)`,
);
}
assert(interruptResult.fullContent.length > 0, `正常收到新话题回复(${interruptResult.fullContent.length} 字符)`);
}
// ────────────────────────────────────────────
// Test 8: 对话历史 — consult 消息含 intent
// ────────────────────────────────────────────
async function test8ChatHistory() {
section('Test 8: 对话历史 — consult 消息验证');
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} 条)`);
// 检查有 consult 意图的 assistant 消息
const consultMsgs = messages.filter((m: any) => m.role === 'assistant' && m.intent === 'consult');
assert(consultMsgs.length > 0, `有 consult 意图消息(${consultMsgs.length} 条)`);
// 无残留 generating 状态
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 III E2E 测试 — method_consult + ask_user 标准化');
console.log('═'.repeat(60));
const loggedIn = await test1Login();
if (!loggedIn) {
console.log('\n⛔ 登录失败,终止测试');
process.exit(1);
}
const hasSession = await test2CreateSession();
if (hasSession) {
await test3ToolRegistry();
await new Promise(r => setTimeout(r, 2000));
await test4ConsultAskUser();
await new Promise(r => setTimeout(r, 2000));
await test5AskUserConfirm();
await new Promise(r => setTimeout(r, 2000));
await test6AskUserSkip();
await new Promise(r => setTimeout(r, 2000));
await test7GlobalInterrupt();
await new Promise(r => setTimeout(r, 2000));
await test8ChatHistory();
}
console.log('\n' + '═'.repeat(60));
console.log(` Phase III E2E 测试结果: ${passed} passed / ${failed} failed / ${skipped} skipped`);
console.log('═'.repeat(60));
if (failed > 0) process.exit(1);
}
main().catch(e => {
console.error('❌ 测试脚本异常:', e);
process.exit(1);
});