/** * 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 { 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 }; } } /** * 发送 SSE 对话请求,收集所有事件 */ async function chatSSE(sid: string, content: string, metadata?: Record, 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 { 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 { 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_user(PICO 未推断),也是合理的 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); });