/** * 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 { const h: Record = { Authorization: `Bearer ${token}` }; if (contentType) h['Content-Type'] = contentType; return h; } async function apiPost(path: string, body: any, headers?: Record): Promise { 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 { 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, 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); });