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>
347 lines
12 KiB
TypeScript
347 lines
12 KiB
TypeScript
/**
|
||
* 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, `返回 WorkflowPlan(workflow_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);
|
||
});
|