feat(ssa): Complete QPER architecture - Query, Planner, Execute, Reflection layers
Implement the full QPER intelligent analysis pipeline: - Phase E+: Block-based standardization for all 7 R tools, DynamicReport renderer, Word export enhancement - Phase Q: LLM intent parsing with dynamic Zod validation against real column names, ClarificationCard component, DataProfile is_id_like tagging - Phase P: ConfigLoader with Zod schema validation and hot-reload API, DecisionTableService (4-dimension matching), FlowTemplateService with EPV protection, PlannedTrace audit output - Phase R: ReflectionService with statistical slot injection, sensitivity analysis conflict rules, ConclusionReport with section reveal animation, conclusion caching API, graceful R error classification End-to-end test: 40/40 passed across two complete analysis scenarios. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
172
backend/scripts/seed-ssa-intent-prompt.ts
Normal file
172
backend/scripts/seed-ssa-intent-prompt.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* SSA Intent Prompt Seed 脚本
|
||||
*
|
||||
* 将 SSA_QUERY_INTENT prompt 写入 capability_schema.prompt_templates
|
||||
* 运行: npx tsx scripts/seed-ssa-intent-prompt.ts
|
||||
*/
|
||||
|
||||
import { PrismaClient, PromptStatus } from '@prisma/client';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
const SSA_INTENT_PROMPT = `你是一个临床统计分析意图理解引擎。你的任务是根据用户的自然语言描述和数据画像,解析出结构化的分析意图。
|
||||
|
||||
## 输入信息
|
||||
|
||||
### 用户请求
|
||||
{{userQuery}}
|
||||
|
||||
### 数据画像
|
||||
{{dataProfile}}
|
||||
|
||||
### 可用统计工具
|
||||
{{availableTools}}
|
||||
|
||||
## 你的任务
|
||||
|
||||
请分析用户的请求,输出一个 JSON 对象(不要输出任何其他内容,只输出 JSON):
|
||||
|
||||
\`\`\`json
|
||||
{
|
||||
"goal": "comparison | correlation | regression | descriptive | cohort_study",
|
||||
"outcome_var": "结局变量名(Y),必须是数据画像中存在的列名,如果无法确定则为 null",
|
||||
"outcome_type": "continuous | binary | categorical | ordinal | datetime | null",
|
||||
"predictor_vars": ["自变量名列表(X),必须是数据画像中存在的列名"],
|
||||
"predictor_types": ["对应每个自变量的类型"],
|
||||
"grouping_var": "分组变量名,必须是数据画像中存在的列名,如果无法确定则为 null",
|
||||
"design": "independent | paired | longitudinal | cross_sectional",
|
||||
"confidence": 0.0到1.0之间的数字,
|
||||
"reasoning": "你的推理过程,用1-2句话说明为什么这样解析"
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
## 关键规则
|
||||
|
||||
1. **变量名必须精确匹配数据画像中的列名**,不要翻译、缩写或改写。如果数据里是 "Blood_Pressure",你就输出 "Blood_Pressure",不要输出 "BP"。
|
||||
2. 如果用户没有明确指出变量,请根据数据画像中的变量类型合理推断,但 confidence 应相应降低。
|
||||
3. goal 为 "descriptive" 时,不需要 outcome_var 和 predictor_vars。
|
||||
|
||||
## Confidence 评分准则(严格按此打分)
|
||||
|
||||
- **0.9 - 1.0**: 用户的原话中明确指定了结局变量(Y)和至少一个自变量(X),且这些变量在数据画像中存在。
|
||||
- **0.7 - 0.8**: 用户指出了 Y 变量,但 X 需要根据数据类型推断;或用户的意图清晰但有轻微歧义。
|
||||
- **0.5 - 0.6**: 用户意图大致清楚(如"帮我比较一下"),但没有具体指出任何变量名。
|
||||
- **< 0.5**: 用户只说了"帮我分析一下"这样的模糊表达,既没有明确 Y 也没有明确 X,必须追问。
|
||||
|
||||
## Few-Shot 示例
|
||||
|
||||
### 示例 1:明确的差异比较
|
||||
用户: "帮我比较 Treatment 组和 Control 组的 SBP 有没有差异"
|
||||
数据画像中有: Group [categorical, 2个水平: Treatment/Control], SBP [numeric]
|
||||
输出:
|
||||
\`\`\`json
|
||||
{"goal":"comparison","outcome_var":"SBP","outcome_type":"continuous","predictor_vars":["Group"],"predictor_types":["binary"],"grouping_var":"Group","design":"independent","confidence":0.95,"reasoning":"用户明确指定了分组变量Group和结局变量SBP,要求比较两组差异"}
|
||||
\`\`\`
|
||||
|
||||
### 示例 2:相关分析
|
||||
用户: "年龄和血压有关系吗?"
|
||||
数据画像中有: Age [numeric], Blood_Pressure [numeric], Gender [categorical]
|
||||
输出:
|
||||
\`\`\`json
|
||||
{"goal":"correlation","outcome_var":"Blood_Pressure","outcome_type":"continuous","predictor_vars":["Age"],"predictor_types":["continuous"],"grouping_var":null,"design":"independent","confidence":0.85,"reasoning":"用户想了解Age和Blood_Pressure的关系,两者都是连续变量,适合相关分析"}
|
||||
\`\`\`
|
||||
|
||||
### 示例 3:多因素回归
|
||||
用户: "什么因素影响患者的死亡率?"
|
||||
数据画像中有: Death [categorical, 2个水平: 0/1], Age [numeric], BMI [numeric], Smoking [categorical, 2个水平: Yes/No], Stage [categorical, 4个水平]
|
||||
输出:
|
||||
\`\`\`json
|
||||
{"goal":"regression","outcome_var":"Death","outcome_type":"binary","predictor_vars":["Age","BMI","Smoking","Stage"],"predictor_types":["continuous","continuous","binary","categorical"],"grouping_var":null,"design":"independent","confidence":0.8,"reasoning":"用户想分析影响死亡率的因素,Death是二分类结局,其余变量作为预测因素纳入logistic回归"}
|
||||
\`\`\`
|
||||
|
||||
### 示例 4:模糊表达 — 需要追问
|
||||
用户: "帮我分析一下这份数据"
|
||||
数据画像中有: 10个变量
|
||||
输出:
|
||||
\`\`\`json
|
||||
{"goal":"descriptive","outcome_var":null,"outcome_type":null,"predictor_vars":[],"predictor_types":[],"grouping_var":null,"design":"independent","confidence":0.35,"reasoning":"用户没有指定任何分析目标和变量,只能先做描述性统计,建议追问具体分析目的"}
|
||||
\`\`\`
|
||||
|
||||
### 示例 5:队列研究
|
||||
用户: "我想做一个完整的队列研究分析,看看新药对预后的影响"
|
||||
数据画像中有: Drug [categorical, 2个水平], Outcome [categorical, 2个水平: 0/1], Age [numeric], Gender [categorical], BMI [numeric], Comorbidity [categorical]
|
||||
输出:
|
||||
\`\`\`json
|
||||
{"goal":"cohort_study","outcome_var":"Outcome","outcome_type":"binary","predictor_vars":["Drug","Age","Gender","BMI","Comorbidity"],"predictor_types":["binary","continuous","binary","continuous","categorical"],"grouping_var":"Drug","design":"independent","confidence":0.85,"reasoning":"用户明确要做队列研究分析,Drug是暴露因素/分组变量,Outcome是结局,其余为协变量"}
|
||||
\`\`\`
|
||||
|
||||
请只输出 JSON,不要输出其他内容。`;
|
||||
|
||||
async function main() {
|
||||
console.log('🚀 开始写入 SSA Intent Prompt...\n');
|
||||
|
||||
const existing = await prisma.prompt_templates.findUnique({
|
||||
where: { code: 'SSA_QUERY_INTENT' }
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
console.log('⚠️ SSA_QUERY_INTENT 已存在 (id=%d),创建新版本...', existing.id);
|
||||
|
||||
const latestVersion = await prisma.prompt_versions.findFirst({
|
||||
where: { template_id: existing.id },
|
||||
orderBy: { version: 'desc' }
|
||||
});
|
||||
|
||||
const newVersion = (latestVersion?.version ?? 0) + 1;
|
||||
|
||||
// 归档旧的 ACTIVE 版本
|
||||
await prisma.prompt_versions.updateMany({
|
||||
where: { template_id: existing.id, status: 'ACTIVE' },
|
||||
data: { status: 'ARCHIVED' }
|
||||
});
|
||||
|
||||
await prisma.prompt_versions.create({
|
||||
data: {
|
||||
template_id: existing.id,
|
||||
version: newVersion,
|
||||
content: SSA_INTENT_PROMPT,
|
||||
model_config: { model: 'deepseek-v3', temperature: 0.3, maxTokens: 2048 },
|
||||
status: 'ACTIVE',
|
||||
changelog: `Phase Q v1.0: 5 组 Few-Shot + Confidence Rubric 客观化`,
|
||||
created_by: 'system-seed',
|
||||
}
|
||||
});
|
||||
|
||||
console.log(' ✅ 新版本 v%d 已创建并设为 ACTIVE', newVersion);
|
||||
} else {
|
||||
console.log('📝 创建 SSA_QUERY_INTENT 模板...');
|
||||
|
||||
const template = await prisma.prompt_templates.create({
|
||||
data: {
|
||||
code: 'SSA_QUERY_INTENT',
|
||||
name: 'SSA 意图理解 Prompt',
|
||||
module: 'SSA',
|
||||
description: 'Phase Q — 将用户自然语言转化为结构化的统计分析意图 (ParsedQuery)',
|
||||
variables: ['userQuery', 'dataProfile', 'availableTools'],
|
||||
}
|
||||
});
|
||||
|
||||
await prisma.prompt_versions.create({
|
||||
data: {
|
||||
template_id: template.id,
|
||||
version: 1,
|
||||
content: SSA_INTENT_PROMPT,
|
||||
model_config: { model: 'deepseek-v3', temperature: 0.3, maxTokens: 2048 },
|
||||
status: 'ACTIVE',
|
||||
changelog: 'Phase Q v1.0: 初始版本,5 组 Few-Shot + Confidence Rubric',
|
||||
created_by: 'system-seed',
|
||||
}
|
||||
});
|
||||
|
||||
console.log(' ✅ 模板 id=%d + 版本 v1 已创建', template.id);
|
||||
}
|
||||
|
||||
console.log('\n✅ SSA Intent Prompt 写入完成!');
|
||||
}
|
||||
|
||||
main()
|
||||
.catch(e => {
|
||||
console.error('❌ 写入失败:', e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(() => prisma.$disconnect());
|
||||
174
backend/scripts/seed-ssa-reflection-prompt.ts
Normal file
174
backend/scripts/seed-ssa-reflection-prompt.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* SSA Reflection Prompt Seed 脚本
|
||||
*
|
||||
* 将 SSA_REFLECTION prompt 写入 capability_schema.prompt_templates
|
||||
* 运行: npx tsx scripts/seed-ssa-reflection-prompt.ts
|
||||
*
|
||||
* Phase R — 论文级结论生成 Prompt
|
||||
* 特性:
|
||||
* - 统计量槽位注入(反幻觉)
|
||||
* - 敏感性分析冲突处理准则
|
||||
* - 6 要素结构化 JSON 输出
|
||||
* - 基于 decision_trace 的方法学说明
|
||||
*/
|
||||
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
const SSA_REFLECTION_PROMPT = `你是一位高级生物统计师,请基于以下分析结果生成论文级结论。
|
||||
|
||||
## 分析背景
|
||||
|
||||
分析目标:{{goal}}
|
||||
分析标题:{{title}}
|
||||
采用方法:{{methodology}}
|
||||
样本信息:{{sampleInfo}}
|
||||
|
||||
## 方法选择的决策轨迹
|
||||
|
||||
请据此撰写方法学说明,不得臆造选择理由:
|
||||
|
||||
匹配规则:{{decision_trace.matched_rule}}
|
||||
主要工具:{{decision_trace.primary_tool}}
|
||||
{{#if decision_trace.fallback_tool}}备选工具:{{decision_trace.fallback_tool}}(触发条件:{{decision_trace.switch_condition}}){{/if}}
|
||||
决策推理:{{decision_trace.reasoning}}
|
||||
{{#if decision_trace.epv_warning}}⚠️ EPV 警告:{{decision_trace.epv_warning}}{{/if}}
|
||||
|
||||
## 各步骤统计结果
|
||||
|
||||
⚠️ 以下数值由系统自动注入,你必须原样引用,不得修改、四舍五入或重新表述任何数字。
|
||||
|
||||
{{#each findings}}
|
||||
### 步骤 {{step_number}}:{{tool_name}}({{tool_code}})
|
||||
- 使用方法:{{method}}
|
||||
{{#if statistic}}- 统计量({{statistic_name}}):{{statistic}}{{/if}}
|
||||
{{#if p_value}}- P 值:{{p_value}}{{/if}}
|
||||
{{#if effect_size}}- 效应量({{effect_size_name}}):{{effect_size}}{{/if}}
|
||||
{{#if ci_lower}}- 95% 置信区间:{{ci_lower}} ~ {{ci_upper}}{{/if}}
|
||||
- 显著性:{{#if is_significant}}显著(P < 0.05){{else}}不显著(P ≥ 0.05){{/if}}
|
||||
{{#if group_stats}}
|
||||
- 各组统计:
|
||||
{{#each group_stats}} · {{group}} 组:n={{n}}{{#if mean}}, 均值={{mean}}, SD={{sd}}{{/if}}{{#if median}}, 中位数={{median}}{{/if}}
|
||||
{{/each}}
|
||||
{{/if}}
|
||||
|
||||
{{/each}}
|
||||
|
||||
## 冲突处理准则(强制执行)
|
||||
|
||||
当主分析与敏感性分析的显著性结论不一致时:
|
||||
1. 在 limitations 数组中必须包含:"敏感性分析未得到一致结论,结果的稳健性(Robustness)较弱,需谨慎解释临床意义"
|
||||
2. 在 key_findings 中以主分析结果为基准报告,但需加注"(注:敏感性分析未验证此结论)"
|
||||
3. 严禁选择性报告、强行拼凑显著性
|
||||
4. 当所有分析方向一致时,在 key_findings 中可强调"敏感性分析进一步验证了结论的稳健性"
|
||||
|
||||
## 输出要求
|
||||
|
||||
请输出一个严格的 JSON 对象(不要输出任何其他内容,只输出 JSON),包含以下字段:
|
||||
|
||||
\`\`\`json
|
||||
{
|
||||
"executive_summary": "200-500字的综合摘要,涵盖研究目的、主要发现和临床意义。使用论文'结果'章节的行文风格,可直接复制到论文中。引用统计量时必须与上方数值完全一致。",
|
||||
"key_findings": [
|
||||
"发现1:具体描述,含统计量和P值(必须与上方数值一致)",
|
||||
"发现2:..."
|
||||
],
|
||||
"statistical_summary": {
|
||||
"total_tests": 3,
|
||||
"significant_results": 2,
|
||||
"methods_used": ["独立样本T检验", "Mann-Whitney U检验"]
|
||||
},
|
||||
"methodology": "方法学说明段落:基于上方决策轨迹撰写,解释为什么选择此方法、是否发生降级、数据满足了哪些假设。使用论文'方法'章节的行文风格。",
|
||||
"limitations": [
|
||||
"局限性1",
|
||||
"局限性2"
|
||||
],
|
||||
"recommendations": [
|
||||
"建议1:基于分析结果的后续研究建议",
|
||||
"建议2:..."
|
||||
]
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
## 关键规则
|
||||
|
||||
1. **所有数值必须与上方统计结果完全一致**,不得修改、四舍五入、近似或重新表述
|
||||
2. executive_summary 必须是完整的论文段落,不是要点列表
|
||||
3. key_findings 每条必须包含具体的统计量和 P 值
|
||||
4. methodology 必须基于决策轨迹撰写,说明方法选择的理由
|
||||
5. limitations 至少包含 1 条,如果数据有局限则如实报告
|
||||
6. 请只输出 JSON,不要输出其他内容`;
|
||||
|
||||
async function main() {
|
||||
console.log('🚀 开始写入 SSA Reflection Prompt...\n');
|
||||
|
||||
const existing = await prisma.prompt_templates.findUnique({
|
||||
where: { code: 'SSA_REFLECTION' }
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
console.log('⚠️ SSA_REFLECTION 已存在 (id=%d),创建新版本...', existing.id);
|
||||
|
||||
const latestVersion = await prisma.prompt_versions.findFirst({
|
||||
where: { template_id: existing.id },
|
||||
orderBy: { version: 'desc' }
|
||||
});
|
||||
|
||||
const newVersion = (latestVersion?.version ?? 0) + 1;
|
||||
|
||||
await prisma.prompt_versions.updateMany({
|
||||
where: { template_id: existing.id, status: 'ACTIVE' },
|
||||
data: { status: 'ARCHIVED' }
|
||||
});
|
||||
|
||||
await prisma.prompt_versions.create({
|
||||
data: {
|
||||
template_id: existing.id,
|
||||
version: newVersion,
|
||||
content: SSA_REFLECTION_PROMPT,
|
||||
model_config: { model: 'deepseek-v3', temperature: 0.3, maxTokens: 4096 },
|
||||
status: 'ACTIVE',
|
||||
changelog: `Phase R v1.0: 6要素结构化JSON + 槽位注入 + 冲突处理准则`,
|
||||
created_by: 'system-seed',
|
||||
}
|
||||
});
|
||||
|
||||
console.log(' ✅ 新版本 v%d 已创建并设为 ACTIVE', newVersion);
|
||||
} else {
|
||||
console.log('📝 创建 SSA_REFLECTION 模板...');
|
||||
|
||||
const template = await prisma.prompt_templates.create({
|
||||
data: {
|
||||
code: 'SSA_REFLECTION',
|
||||
name: 'SSA 论文级结论生成 Prompt',
|
||||
module: 'SSA',
|
||||
description: 'Phase R — 将 StepResult[] 转化为论文级结论(6要素结构化JSON,含槽位注入反幻觉 + 敏感性冲突准则)',
|
||||
variables: ['goal', 'title', 'methodology', 'sampleInfo', 'decision_trace', 'findings'],
|
||||
}
|
||||
});
|
||||
|
||||
await prisma.prompt_versions.create({
|
||||
data: {
|
||||
template_id: template.id,
|
||||
version: 1,
|
||||
content: SSA_REFLECTION_PROMPT,
|
||||
model_config: { model: 'deepseek-v3', temperature: 0.3, maxTokens: 4096 },
|
||||
status: 'ACTIVE',
|
||||
changelog: 'Phase R v1.0: 初始版本,6要素JSON + 槽位注入 + 冲突处理准则',
|
||||
created_by: 'system-seed',
|
||||
}
|
||||
});
|
||||
|
||||
console.log(' ✅ 模板 id=%d + 版本 v1 已创建', template.id);
|
||||
}
|
||||
|
||||
console.log('\n✅ SSA Reflection Prompt 写入完成!');
|
||||
}
|
||||
|
||||
main()
|
||||
.catch(e => {
|
||||
console.error('❌ 写入失败:', e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(() => prisma.$disconnect());
|
||||
494
backend/scripts/test-ssa-phase-q-e2e.ts
Normal file
494
backend/scripts/test-ssa-phase-q-e2e.ts
Normal file
@@ -0,0 +1,494 @@
|
||||
/**
|
||||
* SSA Phase Q — 端到端集成测试
|
||||
*
|
||||
* 完整链路:登录 → 创建会话+上传文件 → 数据画像 → LLM 意图解析 → 追问 → Q→P 规划
|
||||
*
|
||||
* 依赖:Node.js 后端 + PostgreSQL + Python extraction_service + LLM 服务
|
||||
* 运行方式:npx tsx scripts/test-ssa-phase-q-e2e.ts
|
||||
*
|
||||
* 测试数据:docs/03-业务模块/SSA-智能统计分析/05-测试文档/test.csv
|
||||
* 测试用户:13800000001 / 123456
|
||||
*/
|
||||
|
||||
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 = '';
|
||||
|
||||
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 headers: Record<string, string> = {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
};
|
||||
if (contentType) {
|
||||
headers['Content-Type'] = contentType;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────
|
||||
// 测试 1: 登录获取 Token
|
||||
// ────────────────────────────────────────────
|
||||
|
||||
async function testLogin() {
|
||||
section('测试 1: 登录认证');
|
||||
|
||||
try {
|
||||
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})`, JSON.stringify(res.data).substring(0, 200));
|
||||
|
||||
if (res.status === 200 && res.data) {
|
||||
token = res.data?.data?.tokens?.accessToken || res.data?.accessToken || res.data?.token || '';
|
||||
assert(token.length > 0, '获取到 JWT Token', `token 长度: ${token.length}`);
|
||||
|
||||
if (res.data?.data?.user) {
|
||||
const user = res.data.data.user;
|
||||
console.log(` 用户信息: ${user.name || user.phone || 'N/A'}, 角色: ${user.role}`);
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
assert(false, '登录请求失败', e.message);
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
console.log('\n ⚠️ Token 获取失败,后续测试无法继续');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────
|
||||
// 测试 2: 创建会话 + 上传 test.csv
|
||||
// ────────────────────────────────────────────
|
||||
|
||||
async function testCreateSessionWithUpload() {
|
||||
section('测试 2: 创建会话 + 上传 test.csv');
|
||||
|
||||
try {
|
||||
const csvBuffer = readFileSync(TEST_CSV_PATH);
|
||||
assert(csvBuffer.length > 0, `test.csv 文件读取成功(${csvBuffer.length} bytes)`);
|
||||
|
||||
// 构建 multipart/form-data
|
||||
const formData = new FormData();
|
||||
const blob = new Blob([csvBuffer], { type: 'text/csv' });
|
||||
formData.append('file', blob, 'test.csv');
|
||||
|
||||
const res = await fetch(`${BASE_URL}/api/v1/ssa/sessions/`, {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${token}` },
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
assert(res.status === 200, `创建会话返回 200(实际 ${res.status})`, JSON.stringify(data).substring(0, 300));
|
||||
|
||||
if (data.sessionId) {
|
||||
sessionId = data.sessionId;
|
||||
assert(true, `会话 ID: ${sessionId}`);
|
||||
} else {
|
||||
assert(false, '未返回 sessionId', JSON.stringify(data).substring(0, 200));
|
||||
}
|
||||
|
||||
if (data.schema) {
|
||||
const schema = data.schema;
|
||||
assert(schema.columns?.length > 0, `数据 Schema 解析成功(${schema.columns?.length} 列)`);
|
||||
assert(schema.rowCount > 0, `行数: ${schema.rowCount}`);
|
||||
console.log(` 列名: ${schema.columns?.slice(0, 8).map((c: any) => c.name).join(', ')}...`);
|
||||
}
|
||||
} catch (e: any) {
|
||||
assert(false, '创建会话失败', e.message);
|
||||
}
|
||||
|
||||
if (!sessionId) {
|
||||
console.log('\n ⚠️ SessionId 获取失败,后续测试无法继续');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────
|
||||
// 测试 3: 数据画像(Python DataProfiler)
|
||||
// ────────────────────────────────────────────
|
||||
|
||||
async function testDataProfile() {
|
||||
section('测试 3: 数据画像(Python DataProfiler)');
|
||||
|
||||
try {
|
||||
const res = await apiPost('/api/v1/ssa/workflow/profile', { sessionId });
|
||||
|
||||
assert(res.status === 200, `画像请求返回 200(实际 ${res.status})`);
|
||||
|
||||
if (res.data?.success) {
|
||||
const profile = res.data.profile;
|
||||
assert(!!profile, '画像数据非空');
|
||||
|
||||
if (profile) {
|
||||
assert(profile.row_count > 0 || profile.totalRows > 0,
|
||||
`行数: ${profile.row_count || profile.totalRows}`);
|
||||
assert(profile.column_count > 0 || profile.totalColumns > 0,
|
||||
`列数: ${profile.column_count || profile.totalColumns}`);
|
||||
|
||||
const cols = profile.columns || [];
|
||||
if (cols.length > 0) {
|
||||
console.log(` 前 5 列类型:`);
|
||||
cols.slice(0, 5).forEach((c: any) => {
|
||||
console.log(` ${c.name || c.column_name}: ${c.type || c.dtype} (missing: ${c.missing_ratio ?? c.missingPercent ?? 'N/A'})`);
|
||||
});
|
||||
|
||||
// 检查 is_id_like 标记(Phase Q 防御性优化)
|
||||
const idLikeCols = cols.filter((c: any) => c.is_id_like === true);
|
||||
if (idLikeCols.length > 0) {
|
||||
assert(true, `检测到 ${idLikeCols.length} 个 ID-like 列: ${idLikeCols.map((c: any) => c.name || c.column_name).join(', ')}`);
|
||||
} else {
|
||||
console.log(' ℹ️ 未检测到 ID-like 列(test.csv 无 ID 列,符合预期)');
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
assert(false, '画像生成失败', res.data?.error || JSON.stringify(res.data).substring(0, 200));
|
||||
}
|
||||
} catch (e: any) {
|
||||
assert(false, '画像请求异常', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────
|
||||
// 测试 4: LLM 意图解析(Phase Q 核心)
|
||||
// ────────────────────────────────────────────
|
||||
|
||||
async function testIntentParsing() {
|
||||
section('测试 4: LLM 意图理解(Phase Q 核心)');
|
||||
|
||||
const testQueries = [
|
||||
{
|
||||
name: '场景 A — 明确的差异比较',
|
||||
query: '比较 sex 不同组的 Yqol 有没有差别',
|
||||
expectGoal: 'comparison',
|
||||
expectHighConfidence: true,
|
||||
},
|
||||
{
|
||||
name: '场景 B — 相关分析',
|
||||
query: '分析 age 和 bmi 的相关性',
|
||||
expectGoal: 'correlation',
|
||||
expectHighConfidence: true,
|
||||
},
|
||||
{
|
||||
name: '场景 C — 回归分析',
|
||||
query: 'age、smoke、bmi 对 Yqol 的影响,做个多因素分析',
|
||||
expectGoal: 'regression',
|
||||
expectHighConfidence: true,
|
||||
},
|
||||
{
|
||||
name: '场景 D — 模糊意图(应触发追问)',
|
||||
query: '帮我分析一下这个数据',
|
||||
expectGoal: null, // 不确定
|
||||
expectHighConfidence: false,
|
||||
},
|
||||
{
|
||||
name: '场景 E — 描述统计',
|
||||
query: '描述一下数据的基本情况',
|
||||
expectGoal: 'descriptive',
|
||||
expectHighConfidence: true,
|
||||
},
|
||||
];
|
||||
|
||||
for (const tc of testQueries) {
|
||||
console.log(`\n 🔬 ${tc.name}`);
|
||||
console.log(` Query: "${tc.query}"`);
|
||||
|
||||
try {
|
||||
const startTime = Date.now();
|
||||
const res = await apiPost('/api/v1/ssa/workflow/intent', {
|
||||
sessionId,
|
||||
userQuery: tc.query,
|
||||
});
|
||||
const elapsed = Date.now() - startTime;
|
||||
|
||||
assert(res.status === 200, ` 返回 200(实际 ${res.status})`, res.data?.error);
|
||||
|
||||
if (res.data?.success) {
|
||||
const intent = res.data.intent;
|
||||
console.log(` 耗时: ${elapsed}ms`);
|
||||
console.log(` Goal: ${intent?.goal}, Confidence: ${intent?.confidence}`);
|
||||
console.log(` Y: ${intent?.outcome_var || 'null'}, X: ${JSON.stringify(intent?.predictor_vars || [])}`);
|
||||
console.log(` Design: ${intent?.design}, needsClarification: ${intent?.needsClarification}`);
|
||||
|
||||
if (intent) {
|
||||
// 检查 goal 是否符合预期
|
||||
if (tc.expectGoal) {
|
||||
assert(intent.goal === tc.expectGoal,
|
||||
` Goal = ${tc.expectGoal}(实际 ${intent.goal})`);
|
||||
}
|
||||
|
||||
// 检查置信度
|
||||
if (tc.expectHighConfidence) {
|
||||
assert(intent.confidence >= 0.7,
|
||||
` 高置信度 >= 0.7(实际 ${intent.confidence})`);
|
||||
assert(!intent.needsClarification,
|
||||
` 无需追问(实际 needsClarification=${intent.needsClarification})`);
|
||||
} else {
|
||||
// 模糊意图应该低置信度或触发追问
|
||||
const isLowConfOrClarify = intent.confidence < 0.7 || intent.needsClarification;
|
||||
assert(isLowConfOrClarify,
|
||||
` 低置信度或需追问(confidence=${intent.confidence}, needsClarification=${intent.needsClarification})`);
|
||||
}
|
||||
|
||||
// 检查变量名是否来自真实数据(防幻觉校验)
|
||||
const realColumns = ['sex', 'smoke', 'age', 'bmi', 'mouth_open', 'bucal_relax',
|
||||
'toot_morph', 'root_number', 'root_curve', 'lenspace', 'denseratio',
|
||||
'Pglevel', 'Pgverti', 'Winter', 'presyp', 'flap', 'operation',
|
||||
'time', 'surgage', 'Yqol', 'times'];
|
||||
const realColumnsLower = realColumns.map(c => c.toLowerCase());
|
||||
|
||||
if (intent.outcome_var) {
|
||||
const isReal = realColumnsLower.includes(intent.outcome_var.toLowerCase());
|
||||
assert(isReal,
|
||||
` Y 变量 "${intent.outcome_var}" 存在于数据中`,
|
||||
`变量 "${intent.outcome_var}" 不在数据列名中(可能是 LLM 幻觉)`);
|
||||
}
|
||||
|
||||
if (intent.predictor_vars?.length > 0) {
|
||||
const allReal = intent.predictor_vars.every(
|
||||
(v: string) => realColumnsLower.includes(v.toLowerCase())
|
||||
);
|
||||
assert(allReal,
|
||||
` X 变量 ${JSON.stringify(intent.predictor_vars)} 全部存在于数据中`,
|
||||
`部分变量可能为 LLM 幻觉`);
|
||||
}
|
||||
|
||||
// 检查追问卡片(模糊意图时)
|
||||
if (intent.needsClarification && res.data.clarificationCards?.length > 0) {
|
||||
const cards = res.data.clarificationCards;
|
||||
console.log(` 追问卡片: ${cards.length} 张`);
|
||||
cards.forEach((card: any, i: number) => {
|
||||
console.log(` 卡片 ${i + 1}: ${card.question}`);
|
||||
card.options?.slice(0, 3).forEach((opt: any) => {
|
||||
console.log(` - ${opt.label}`);
|
||||
});
|
||||
});
|
||||
assert(cards[0].options?.length >= 2, ` 追问卡片有 >= 2 个选项`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
assert(false, ` Intent 解析失败`, res.data?.error || JSON.stringify(res.data).substring(0, 200));
|
||||
}
|
||||
} catch (e: any) {
|
||||
assert(false, ` ${tc.name} 请求异常`, e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────
|
||||
// 测试 5: Q→P 全链路(Intent → Plan)
|
||||
// ────────────────────────────────────────────
|
||||
|
||||
async function testQtoPPipeline() {
|
||||
section('测试 5: Q→P 全链路(Intent → WorkflowPlan)');
|
||||
|
||||
const testCases = [
|
||||
{
|
||||
name: '差异比较 → T 检验流程',
|
||||
query: '比较 sex 不同组的 Yqol 有没有差别',
|
||||
expectSteps: 2, // 描述统计 + 主分析(至少)
|
||||
expectTool: 'ST_',
|
||||
},
|
||||
{
|
||||
name: '回归分析 → Logistic 流程',
|
||||
query: 'age、smoke、bmi 对 Yqol 的预测作用,做个 Logistic 回归',
|
||||
expectSteps: 2,
|
||||
expectTool: 'ST_LOGISTIC',
|
||||
},
|
||||
];
|
||||
|
||||
for (const tc of testCases) {
|
||||
console.log(`\n 🔬 ${tc.name}`);
|
||||
console.log(` Query: "${tc.query}"`);
|
||||
|
||||
try {
|
||||
const startTime = Date.now();
|
||||
const res = await apiPost('/api/v1/ssa/workflow/plan', {
|
||||
sessionId,
|
||||
userQuery: tc.query,
|
||||
});
|
||||
const elapsed = Date.now() - startTime;
|
||||
|
||||
assert(res.status === 200, ` 返回 200(实际 ${res.status})`, res.data?.error);
|
||||
|
||||
if (res.data?.success && res.data.plan) {
|
||||
const plan = res.data.plan;
|
||||
console.log(` 耗时: ${elapsed}ms`);
|
||||
console.log(` 标题: ${plan.title}`);
|
||||
console.log(` 步骤数: ${plan.total_steps}`);
|
||||
|
||||
assert(plan.total_steps >= tc.expectSteps,
|
||||
` 步骤数 >= ${tc.expectSteps}(实际 ${plan.total_steps})`);
|
||||
|
||||
// 打印每步信息
|
||||
plan.steps?.forEach((step: any, i: number) => {
|
||||
const sensitivity = step.is_sensitivity ? ' [敏感性分析]' : '';
|
||||
const guardrail = step.switch_condition ? ` 🛡️${step.switch_condition}` : '';
|
||||
console.log(` 步骤 ${i + 1}: ${step.tool_name} (${step.tool_code})${sensitivity}${guardrail}`);
|
||||
});
|
||||
|
||||
// 检查是否包含期望的工具
|
||||
const hasExpectedTool = plan.steps?.some(
|
||||
(s: any) => s.tool_code?.startsWith(tc.expectTool)
|
||||
);
|
||||
assert(hasExpectedTool,
|
||||
` 包含 ${tc.expectTool}* 工具`,
|
||||
`工具列表: ${plan.steps?.map((s: any) => s.tool_code).join(', ')}`);
|
||||
|
||||
// 检查 PlannedTrace
|
||||
if (plan.planned_trace) {
|
||||
const trace = plan.planned_trace;
|
||||
console.log(` PlannedTrace:`);
|
||||
console.log(` Primary: ${trace.primaryTool}`);
|
||||
console.log(` Fallback: ${trace.fallbackTool || 'null'}`);
|
||||
console.log(` SwitchCondition: ${trace.switchCondition || 'null'}`);
|
||||
console.log(` Template: ${trace.templateUsed}`);
|
||||
assert(!!trace.primaryTool, ` PlannedTrace 包含 primaryTool`);
|
||||
assert(!!trace.templateUsed, ` PlannedTrace 包含 templateUsed`);
|
||||
} else {
|
||||
skip('PlannedTrace 检查', '计划中未返回 planned_trace');
|
||||
}
|
||||
|
||||
// EPV 警告检查
|
||||
if (plan.epv_warning) {
|
||||
console.log(` ⚠️ EPV Warning: ${plan.epv_warning}`);
|
||||
}
|
||||
|
||||
// 描述文字检查
|
||||
if (plan.description) {
|
||||
assert(plan.description.length > 10, ` 规划描述非空(${plan.description.length} 字符)`);
|
||||
console.log(` 描述: ${plan.description.substring(0, 100)}...`);
|
||||
}
|
||||
} else {
|
||||
assert(false, ` 规划失败`, res.data?.error || JSON.stringify(res.data).substring(0, 200));
|
||||
}
|
||||
} catch (e: any) {
|
||||
assert(false, ` ${tc.name} 请求异常`, e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────
|
||||
// 运行所有测试
|
||||
// ────────────────────────────────────────────
|
||||
|
||||
async function main() {
|
||||
console.log('\n🧪 SSA Phase Q+P — 端到端集成测试\n');
|
||||
console.log('测试链路:登录 → 上传 CSV → 数据画像 → LLM Intent → Q→P Plan');
|
||||
console.log(`测试用户:${TEST_PHONE}`);
|
||||
console.log(`后端地址:${BASE_URL}`);
|
||||
console.log(`测试文件:${TEST_CSV_PATH}\n`);
|
||||
|
||||
// 前置检查
|
||||
try {
|
||||
readFileSync(TEST_CSV_PATH);
|
||||
} catch {
|
||||
console.error('❌ test.csv 文件不存在,请检查路径');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
const healthCheck = await fetch(`${BASE_URL}/health`).catch(() => null);
|
||||
if (!healthCheck || healthCheck.status !== 200) {
|
||||
console.error('❌ 后端服务未启动或不可达');
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('✅ 后端服务可达\n');
|
||||
} catch {
|
||||
console.error('❌ 后端服务未启动或不可达');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 顺序执行测试
|
||||
const loginOk = await testLogin();
|
||||
if (!loginOk) {
|
||||
console.log('\n⛔ 登录失败,终止测试');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const sessionOk = await testCreateSessionWithUpload();
|
||||
if (!sessionOk) {
|
||||
console.log('\n⛔ 会话创建失败,终止测试');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
await testDataProfile();
|
||||
await testIntentParsing();
|
||||
await testQtoPPipeline();
|
||||
|
||||
// 汇总
|
||||
console.log(`\n${'═'.repeat(60)}`);
|
||||
console.log(`📊 测试结果汇总:${passed} 通过 / ${failed} 失败 / ${skipped} 跳过 / ${passed + failed + skipped} 总计`);
|
||||
if (failed === 0) {
|
||||
console.log('🎉 全部通过!Phase Q+P 端到端验证成功。');
|
||||
} else {
|
||||
console.log(`⚠️ 有 ${failed} 个测试失败,请检查上方输出。`);
|
||||
}
|
||||
console.log(`\n📝 测试会话 ID: ${sessionId}(可在数据库中查询详情)`);
|
||||
console.log('═'.repeat(60));
|
||||
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
main().catch(e => {
|
||||
console.error('💥 测试脚本异常:', e);
|
||||
process.exit(1);
|
||||
});
|
||||
395
backend/scripts/test-ssa-planner-pipeline.ts
Normal file
395
backend/scripts/test-ssa-planner-pipeline.ts
Normal file
@@ -0,0 +1,395 @@
|
||||
/**
|
||||
* SSA Phase P — Tracer Bullet 测试脚本
|
||||
*
|
||||
* 验证范围:
|
||||
* 1. ConfigLoader 加载 + Zod 校验 3 个 JSON 配置文件
|
||||
* 2. DecisionTableService 四维匹配(6 种场景)
|
||||
* 3. FlowTemplateService 模板填充 + EPV 截断
|
||||
* 4. Q→P 集成:mock ParsedQuery → WorkflowPlan
|
||||
*
|
||||
* 运行方式:npx tsx scripts/test-ssa-planner-pipeline.ts
|
||||
* 不依赖:数据库、LLM、R 引擎
|
||||
*/
|
||||
|
||||
import { toolsRegistryLoader, decisionTablesLoader, flowTemplatesLoader, reloadAllConfigs } from '../src/modules/ssa/config/index.js';
|
||||
import { decisionTableService } from '../src/modules/ssa/services/DecisionTableService.js';
|
||||
import { flowTemplateService } from '../src/modules/ssa/services/FlowTemplateService.js';
|
||||
import type { ParsedQuery } from '../src/modules/ssa/types/query.types.js';
|
||||
import type { DataProfile } from '../src/modules/ssa/services/DataProfileService.js';
|
||||
|
||||
// ────────────────────────────────────────────
|
||||
// 工具函数
|
||||
// ────────────────────────────────────────────
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function assert(condition: boolean, testName: string, detail?: string) {
|
||||
if (condition) {
|
||||
console.log(` ✅ ${testName}`);
|
||||
passed++;
|
||||
} else {
|
||||
console.log(` ❌ ${testName}${detail ? ` — ${detail}` : ''}`);
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
function section(title: string) {
|
||||
console.log(`\n${'─'.repeat(60)}`);
|
||||
console.log(`📋 ${title}`);
|
||||
console.log('─'.repeat(60));
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────
|
||||
// Mock 数据
|
||||
// ────────────────────────────────────────────
|
||||
|
||||
function makeParsedQuery(overrides: Partial<ParsedQuery>): ParsedQuery {
|
||||
return {
|
||||
goal: 'comparison',
|
||||
outcome_var: 'BP',
|
||||
outcome_type: 'continuous',
|
||||
predictor_vars: ['Drug'],
|
||||
predictor_types: ['binary'],
|
||||
grouping_var: 'Drug',
|
||||
design: 'independent',
|
||||
confidence: 0.9,
|
||||
reasoning: 'test',
|
||||
needsClarification: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeMockProfile(outcomeVar: string, minEventCount: number): DataProfile {
|
||||
return {
|
||||
totalRows: 200,
|
||||
totalColumns: 10,
|
||||
columns: [
|
||||
{
|
||||
name: outcomeVar,
|
||||
type: 'categorical',
|
||||
missing: 0,
|
||||
missingPercent: 0,
|
||||
unique: 2,
|
||||
topValues: [
|
||||
{ value: '0', count: 200 - minEventCount },
|
||||
{ value: '1', count: minEventCount },
|
||||
],
|
||||
},
|
||||
{ name: 'Age', type: 'numeric', missing: 0, missingPercent: 0, unique: 50 },
|
||||
{ name: 'Sex', type: 'categorical', missing: 0, missingPercent: 0, unique: 2 },
|
||||
{ name: 'BMI', type: 'numeric', missing: 5, missingPercent: 2.5, unique: 80 },
|
||||
{ name: 'Smoking', type: 'categorical', missing: 0, missingPercent: 0, unique: 2 },
|
||||
{ name: 'SBP', type: 'numeric', missing: 0, missingPercent: 0, unique: 100 },
|
||||
],
|
||||
} as any;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────
|
||||
// 测试 1: ConfigLoader + Zod 校验
|
||||
// ────────────────────────────────────────────
|
||||
|
||||
function testConfigLoading() {
|
||||
section('测试 1: ConfigLoader 加载 + Zod 校验');
|
||||
|
||||
try {
|
||||
const tools = toolsRegistryLoader.get();
|
||||
assert(!!tools, '工具注册表加载成功');
|
||||
assert(tools.tools.length >= 7, `工具数量 >= 7(实际 ${tools.tools.length})`);
|
||||
assert(tools.tools.every(t => /^ST_[A-Z_]+$/.test(t.code)), '所有工具 code 格式正确 (ST_XXX)');
|
||||
|
||||
const toolCodes = tools.tools.map(t => t.code);
|
||||
assert(toolCodes.includes('ST_DESCRIPTIVE'), '包含 ST_DESCRIPTIVE');
|
||||
assert(toolCodes.includes('ST_T_TEST_IND'), '包含 ST_T_TEST_IND');
|
||||
assert(toolCodes.includes('ST_LOGISTIC_BINARY'), '包含 ST_LOGISTIC_BINARY');
|
||||
} catch (e: any) {
|
||||
assert(false, '工具注册表加载失败', e.message);
|
||||
}
|
||||
|
||||
try {
|
||||
const rules = decisionTablesLoader.get();
|
||||
assert(!!rules, '决策表加载成功');
|
||||
assert(rules.length >= 9, `规则数量 >= 9(实际 ${rules.length})`);
|
||||
assert(rules.every(r => r.id && r.goal && r.primaryTool), '所有规则含必填字段');
|
||||
|
||||
const ids = rules.map(r => r.id);
|
||||
assert(ids.includes('DESC_ANY'), '包含 DESC_ANY 兜底规则');
|
||||
assert(ids.includes('COHORT_STUDY'), '包含 COHORT_STUDY 队列研究规则');
|
||||
} catch (e: any) {
|
||||
assert(false, '决策表加载失败', e.message);
|
||||
}
|
||||
|
||||
try {
|
||||
const templates = flowTemplatesLoader.get();
|
||||
assert(!!templates, '流程模板加载成功');
|
||||
assert(templates.templates.length >= 5, `模板数量 >= 5(实际 ${templates.templates.length})`);
|
||||
|
||||
const ids = templates.templates.map(t => t.id);
|
||||
assert(ids.includes('standard_analysis'), '包含 standard_analysis 模板');
|
||||
assert(ids.includes('cohort_study_standard'), '包含 cohort_study_standard 模板');
|
||||
assert(ids.includes('descriptive_only'), '包含 descriptive_only 模板');
|
||||
} catch (e: any) {
|
||||
assert(false, '流程模板加载失败', e.message);
|
||||
}
|
||||
|
||||
// 热更新测试
|
||||
try {
|
||||
const results = reloadAllConfigs();
|
||||
assert(results.every(r => r.success), `热更新全部成功(${results.length} 个文件)`);
|
||||
} catch (e: any) {
|
||||
assert(false, '热更新失败', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────
|
||||
// 测试 2: DecisionTableService 四维匹配
|
||||
// ────────────────────────────────────────────
|
||||
|
||||
function testDecisionTableMatching() {
|
||||
section('测试 2: DecisionTableService 四维匹配');
|
||||
|
||||
// 场景 A: 两组连续变量差异比较(独立样本)→ T 检验 + Mann-Whitney fallback
|
||||
const queryA = makeParsedQuery({
|
||||
goal: 'comparison',
|
||||
outcome_type: 'continuous',
|
||||
predictor_types: ['binary'],
|
||||
design: 'independent',
|
||||
});
|
||||
const matchA = decisionTableService.match(queryA);
|
||||
assert(matchA.primaryTool === 'ST_T_TEST_IND', `场景 A: Primary = ST_T_TEST_IND(实际 ${matchA.primaryTool})`);
|
||||
assert(matchA.fallbackTool === 'ST_MANN_WHITNEY', `场景 A: Fallback = ST_MANN_WHITNEY(实际 ${matchA.fallbackTool})`);
|
||||
assert(matchA.switchCondition !== null, '场景 A: 有 switchCondition(正态性检验)');
|
||||
assert(matchA.templateId === 'standard_analysis', `场景 A: Template = standard_analysis(实际 ${matchA.templateId})`);
|
||||
|
||||
// 场景 B: 配对设计
|
||||
const queryB = makeParsedQuery({
|
||||
goal: 'comparison',
|
||||
outcome_type: 'continuous',
|
||||
predictor_types: ['binary'],
|
||||
design: 'paired',
|
||||
});
|
||||
const matchB = decisionTableService.match(queryB);
|
||||
assert(matchB.primaryTool === 'ST_T_TEST_PAIRED', `场景 B: Primary = ST_T_TEST_PAIRED(实际 ${matchB.primaryTool})`);
|
||||
assert(matchB.templateId === 'paired_analysis', `场景 B: Template = paired_analysis(实际 ${matchB.templateId})`);
|
||||
|
||||
// 场景 C: 分类 vs 分类 → 卡方检验
|
||||
const queryC = makeParsedQuery({
|
||||
goal: 'comparison',
|
||||
outcome_type: 'categorical',
|
||||
predictor_types: ['categorical'],
|
||||
design: 'independent',
|
||||
});
|
||||
const matchC = decisionTableService.match(queryC);
|
||||
assert(matchC.primaryTool === 'ST_CHI_SQUARE', `场景 C: Primary = ST_CHI_SQUARE(实际 ${matchC.primaryTool})`);
|
||||
|
||||
// 场景 D: 相关分析(连续 vs 连续)
|
||||
const queryD = makeParsedQuery({
|
||||
goal: 'correlation',
|
||||
outcome_type: 'continuous',
|
||||
predictor_types: ['continuous'],
|
||||
design: 'independent',
|
||||
});
|
||||
const matchD = decisionTableService.match(queryD);
|
||||
assert(matchD.primaryTool === 'ST_CORRELATION', `场景 D: Primary = ST_CORRELATION(实际 ${matchD.primaryTool})`);
|
||||
|
||||
// 场景 E: Logistic 回归
|
||||
const queryE = makeParsedQuery({
|
||||
goal: 'regression',
|
||||
outcome_type: 'binary',
|
||||
predictor_types: ['continuous'],
|
||||
design: 'independent',
|
||||
});
|
||||
const matchE = decisionTableService.match(queryE);
|
||||
assert(matchE.primaryTool === 'ST_LOGISTIC_BINARY', `场景 E: Primary = ST_LOGISTIC_BINARY(实际 ${matchE.primaryTool})`);
|
||||
|
||||
// 场景 F: 描述统计 fallback
|
||||
const queryF = makeParsedQuery({
|
||||
goal: 'descriptive',
|
||||
outcome_type: null,
|
||||
predictor_types: [],
|
||||
});
|
||||
const matchF = decisionTableService.match(queryF);
|
||||
assert(matchF.primaryTool === 'ST_DESCRIPTIVE', `场景 F: Primary = ST_DESCRIPTIVE(实际 ${matchF.primaryTool})`);
|
||||
|
||||
// 场景 G: 队列研究
|
||||
const queryG = makeParsedQuery({
|
||||
goal: 'cohort_study',
|
||||
outcome_type: 'binary',
|
||||
predictor_types: ['categorical'],
|
||||
design: 'independent',
|
||||
});
|
||||
const matchG = decisionTableService.match(queryG);
|
||||
assert(matchG.templateId === 'cohort_study_standard', `场景 G: Template = cohort_study_standard(实际 ${matchG.templateId})`);
|
||||
|
||||
// 场景 H: 未知 goal → 应该 fallback 到描述统计
|
||||
const queryH = makeParsedQuery({
|
||||
goal: 'descriptive' as any, // 模拟未匹配场景
|
||||
outcome_type: 'datetime' as any,
|
||||
predictor_types: ['datetime' as any],
|
||||
});
|
||||
const matchH = decisionTableService.match(queryH);
|
||||
assert(matchH.primaryTool === 'ST_DESCRIPTIVE', `场景 H: 无精确匹配 → Fallback ST_DESCRIPTIVE(实际 ${matchH.primaryTool})`);
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────
|
||||
// 测试 3: FlowTemplateService 模板填充
|
||||
// ────────────────────────────────────────────
|
||||
|
||||
function testFlowTemplateFilling() {
|
||||
section('测试 3: FlowTemplateService 模板填充');
|
||||
|
||||
// 场景 A: standard_analysis(有 fallback → 3 步)
|
||||
const queryA = makeParsedQuery({
|
||||
goal: 'comparison',
|
||||
outcome_type: 'continuous',
|
||||
predictor_types: ['binary'],
|
||||
outcome_var: 'BP',
|
||||
predictor_vars: ['Drug'],
|
||||
grouping_var: 'Drug',
|
||||
});
|
||||
const matchA = decisionTableService.match(queryA);
|
||||
const fillA = flowTemplateService.fill(matchA, queryA);
|
||||
assert(fillA.steps.length === 3, `场景 A: 3 步流程(实际 ${fillA.steps.length})`);
|
||||
assert(fillA.steps[0].toolCode === 'ST_DESCRIPTIVE', `场景 A 步骤 1: ST_DESCRIPTIVE(实际 ${fillA.steps[0].toolCode})`);
|
||||
assert(fillA.steps[1].toolCode === 'ST_T_TEST_IND', `场景 A 步骤 2: ST_T_TEST_IND(实际 ${fillA.steps[1].toolCode})`);
|
||||
assert(fillA.steps[2].toolCode === 'ST_MANN_WHITNEY', `场景 A 步骤 3: ST_MANN_WHITNEY(实际 ${fillA.steps[2].toolCode})`);
|
||||
assert(fillA.steps[2].isSensitivity === true, '场景 A 步骤 3: isSensitivity = true');
|
||||
assert(fillA.epvWarning === null, '场景 A: 无 EPV 警告');
|
||||
|
||||
// 场景 B: descriptive_only(无 fallback → 1 步)
|
||||
const queryB = makeParsedQuery({
|
||||
goal: 'descriptive',
|
||||
outcome_type: null,
|
||||
predictor_types: [],
|
||||
});
|
||||
const matchB = decisionTableService.match(queryB);
|
||||
const fillB = flowTemplateService.fill(matchB, queryB);
|
||||
assert(fillB.steps.length === 1, `场景 B: 1 步流程(实际 ${fillB.steps.length})`);
|
||||
assert(fillB.steps[0].toolCode === 'ST_DESCRIPTIVE', '场景 B: ST_DESCRIPTIVE');
|
||||
|
||||
// 场景 C: 队列研究 → 3 步 (Table 1/2/3)
|
||||
const queryC = makeParsedQuery({
|
||||
goal: 'cohort_study',
|
||||
outcome_var: 'Event',
|
||||
outcome_type: 'binary',
|
||||
predictor_vars: ['Age', 'Sex', 'BMI', 'Smoking', 'SBP'],
|
||||
predictor_types: ['continuous', 'binary', 'continuous', 'binary', 'continuous'],
|
||||
grouping_var: 'Drug',
|
||||
design: 'independent',
|
||||
});
|
||||
const matchC = decisionTableService.match(queryC);
|
||||
const fillC = flowTemplateService.fill(matchC, queryC);
|
||||
assert(fillC.steps.length === 3, `场景 C: 队列研究 3 步(实际 ${fillC.steps.length})`);
|
||||
assert(fillC.steps.length > 0 && fillC.steps[0].name.includes('表1'), `场景 C 步骤 1: 表1(实际 "${fillC.steps[0]?.name ?? 'N/A'}")`);
|
||||
assert(fillC.steps.length > 1 && fillC.steps[1].name.includes('表2'), `场景 C 步骤 2: 表2(实际 "${fillC.steps[1]?.name ?? 'N/A'}")`);
|
||||
assert(fillC.steps.length > 2 && fillC.steps[2].name.includes('表3'), `场景 C 步骤 3: 表3(实际 "${fillC.steps[2]?.name ?? 'N/A'}")`);
|
||||
|
||||
// 场景 D: EPV 截断 — 30 个事件 / 10 = 最多 3 个变量
|
||||
const queryD = makeParsedQuery({
|
||||
goal: 'cohort_study',
|
||||
outcome_var: 'Event',
|
||||
outcome_type: 'binary',
|
||||
predictor_vars: ['Age', 'Sex', 'BMI', 'Smoking', 'SBP', 'HR', 'Chol', 'LDL'],
|
||||
predictor_types: ['continuous', 'binary', 'continuous', 'binary', 'continuous', 'continuous', 'continuous', 'continuous'],
|
||||
grouping_var: 'Drug',
|
||||
design: 'independent',
|
||||
});
|
||||
const profileD = makeMockProfile('Event', 30); // 只有 30 个 event → max 3 vars
|
||||
const matchD = decisionTableService.match(queryD);
|
||||
const fillD = flowTemplateService.fill(matchD, queryD, profileD);
|
||||
|
||||
const table3Step = fillD.steps.find(s => s.name.includes('表3'));
|
||||
if (table3Step) {
|
||||
const predictors = table3Step.params.predictors as string[] | undefined;
|
||||
if (predictors) {
|
||||
assert(predictors.length <= 3, `场景 D EPV 截断: 自变量 <= 3(实际 ${predictors.length},原始 8)`);
|
||||
} else {
|
||||
assert(false, '场景 D EPV 截断: 未找到 predictors 参数');
|
||||
}
|
||||
} else {
|
||||
assert(false, '场景 D: 未找到表3 步骤');
|
||||
}
|
||||
assert(fillD.epvWarning !== null, `场景 D: 有 EPV 警告(${fillD.epvWarning?.substring(0, 40)}...)`);
|
||||
|
||||
// 场景 E: 配对分析 → 2 步(无 sensitivity)
|
||||
const queryE = makeParsedQuery({
|
||||
goal: 'comparison',
|
||||
outcome_type: 'continuous',
|
||||
predictor_types: ['binary'],
|
||||
design: 'paired',
|
||||
outcome_var: 'BP_after',
|
||||
predictor_vars: ['BP_before'],
|
||||
});
|
||||
const matchE = decisionTableService.match(queryE);
|
||||
const fillE = flowTemplateService.fill(matchE, queryE);
|
||||
assert(fillE.steps.length === 2, `场景 E: 配对分析 2 步(实际 ${fillE.steps.length})`);
|
||||
assert(fillE.steps.every(s => !s.isSensitivity), '场景 E: 无敏感性分析步骤');
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────
|
||||
// 测试 4: PlannedTrace 完整性
|
||||
// ────────────────────────────────────────────
|
||||
|
||||
function testPlannedTrace() {
|
||||
section('测试 4: PlannedTrace 数据完整性');
|
||||
|
||||
const query = makeParsedQuery({
|
||||
goal: 'comparison',
|
||||
outcome_type: 'continuous',
|
||||
predictor_types: ['binary'],
|
||||
design: 'independent',
|
||||
outcome_var: 'BP',
|
||||
predictor_vars: ['Drug'],
|
||||
grouping_var: 'Drug',
|
||||
});
|
||||
|
||||
const match = decisionTableService.match(query);
|
||||
const fill = flowTemplateService.fill(match, query);
|
||||
|
||||
// PlannedTrace 应具备的信息
|
||||
assert(match.rule.id !== '', 'PlannedTrace: matchedRule 非空');
|
||||
assert(match.primaryTool === 'ST_T_TEST_IND', `PlannedTrace: primaryTool = ST_T_TEST_IND`);
|
||||
assert(match.fallbackTool === 'ST_MANN_WHITNEY', `PlannedTrace: fallbackTool = ST_MANN_WHITNEY`);
|
||||
assert(match.switchCondition !== null, 'PlannedTrace: switchCondition 非空');
|
||||
assert(fill.templateId === 'standard_analysis', 'PlannedTrace: templateUsed = standard_analysis');
|
||||
assert(match.matchScore > 0, `PlannedTrace: matchScore > 0(实际 ${match.matchScore})`);
|
||||
|
||||
// 确认参数正确传递
|
||||
const primaryStep = fill.steps.find(s => s.role === 'primary_test');
|
||||
assert(!!primaryStep, 'Primary step 存在');
|
||||
if (primaryStep) {
|
||||
assert(primaryStep.params.group_var === 'Drug' || primaryStep.params.value_var === 'BP',
|
||||
`Primary step 参数包含正确变量`);
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────
|
||||
// 运行所有测试
|
||||
// ────────────────────────────────────────────
|
||||
|
||||
console.log('\n🧪 SSA Phase P — Tracer Bullet 测试\n');
|
||||
console.log('测试范围:ConfigLoader → DecisionTable → FlowTemplate → PlannedTrace');
|
||||
console.log('依赖项:无(不需要数据库、LLM、R 引擎)\n');
|
||||
|
||||
try {
|
||||
testConfigLoading();
|
||||
testDecisionTableMatching();
|
||||
testFlowTemplateFilling();
|
||||
testPlannedTrace();
|
||||
} catch (e: any) {
|
||||
console.error(`\n💥 测试过程中发生未捕获异常:${e.message}`);
|
||||
console.error(e.stack);
|
||||
failed++;
|
||||
}
|
||||
|
||||
// 汇总
|
||||
console.log(`\n${'═'.repeat(60)}`);
|
||||
console.log(`📊 测试结果汇总:${passed} 通过 / ${failed} 失败 / ${passed + failed} 总计`);
|
||||
if (failed === 0) {
|
||||
console.log('🎉 全部通过!P 层 Pipeline 验证成功。');
|
||||
} else {
|
||||
console.log(`⚠️ 有 ${failed} 个测试失败,请检查上方输出。`);
|
||||
}
|
||||
console.log('═'.repeat(60));
|
||||
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
663
backend/scripts/test-ssa-qper-e2e.ts
Normal file
663
backend/scripts/test-ssa-qper-e2e.ts
Normal file
@@ -0,0 +1,663 @@
|
||||
/**
|
||||
* SSA Q→P→E→R — 完整 QPER 链路端到端集成测试
|
||||
*
|
||||
* 测试链路:
|
||||
* 登录 → 创建会话+上传 CSV → 数据画像
|
||||
* → Q 层(LLM Intent)→ P 层(Plan)
|
||||
* → E 层(R 引擎执行)→ R 层(LLM 结论生成)
|
||||
* → 结论 API 缓存验证
|
||||
*
|
||||
* 依赖:Node.js 后端 + PostgreSQL + Python extraction_service + R 引擎 + LLM 服务
|
||||
* 运行方式:npx tsx scripts/test-ssa-qper-e2e.ts
|
||||
*
|
||||
* 测试数据:docs/03-业务模块/SSA-智能统计分析/05-测试文档/test.csv
|
||||
* 测试用户:13800000001 / 123456
|
||||
*/
|
||||
|
||||
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 = '';
|
||||
|
||||
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 headers: Record<string, string> = {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
};
|
||||
if (contentType) {
|
||||
headers['Content-Type'] = contentType;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────
|
||||
// 测试 1: 登录获取 Token
|
||||
// ────────────────────────────────────────────
|
||||
|
||||
async function testLogin(): Promise<boolean> {
|
||||
section('测试 1: 登录认证');
|
||||
|
||||
try {
|
||||
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 || res.data?.token || '';
|
||||
assert(token.length > 0, '获取到 JWT Token', `token 长度: ${token.length}`);
|
||||
}
|
||||
} catch (e: any) {
|
||||
assert(false, '登录请求失败', e.message);
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
console.log('\n ⚠️ Token 获取失败,后续测试无法继续');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────
|
||||
// 测试 2: 创建会话 + 上传 test.csv
|
||||
// ────────────────────────────────────────────
|
||||
|
||||
async function testCreateSession(): Promise<boolean> {
|
||||
section('测试 2: 创建会话 + 上传 test.csv');
|
||||
|
||||
try {
|
||||
const csvBuffer = readFileSync(TEST_CSV_PATH);
|
||||
assert(csvBuffer.length > 0, `test.csv 读取成功(${csvBuffer.length} bytes)`);
|
||||
|
||||
const formData = new FormData();
|
||||
const blob = new Blob([csvBuffer], { type: 'text/csv' });
|
||||
formData.append('file', blob, 'test.csv');
|
||||
|
||||
const res = await fetch(`${BASE_URL}/api/v1/ssa/sessions/`, {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${token}` },
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
assert(res.status === 200, `创建会话返回 200(实际 ${res.status})`);
|
||||
|
||||
if (data.sessionId) {
|
||||
sessionId = data.sessionId;
|
||||
assert(true, `会话 ID: ${sessionId}`);
|
||||
} else {
|
||||
assert(false, '未返回 sessionId');
|
||||
}
|
||||
|
||||
if (data.schema) {
|
||||
assert(data.schema.columns?.length > 0, `Schema 解析成功(${data.schema.columns?.length} 列, ${data.schema.rowCount} 行)`);
|
||||
}
|
||||
} catch (e: any) {
|
||||
assert(false, '创建会话失败', e.message);
|
||||
}
|
||||
|
||||
return !!sessionId;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────
|
||||
// 测试 3: 数据画像(Python DataProfiler)
|
||||
// ────────────────────────────────────────────
|
||||
|
||||
async function testDataProfile() {
|
||||
section('测试 3: 数据画像(Python DataProfiler)');
|
||||
|
||||
try {
|
||||
const res = await apiPost('/api/v1/ssa/workflow/profile', { sessionId });
|
||||
assert(res.status === 200, `画像请求返回 200(实际 ${res.status})`);
|
||||
|
||||
if (res.data?.success) {
|
||||
const profile = res.data.profile;
|
||||
assert(!!profile, '画像数据非空');
|
||||
if (profile) {
|
||||
const rows = profile.row_count || profile.totalRows || 0;
|
||||
const cols = profile.column_count || profile.totalColumns || 0;
|
||||
assert(rows > 0, `行数: ${rows}`);
|
||||
assert(cols > 0, `列数: ${cols}`);
|
||||
}
|
||||
} else {
|
||||
assert(false, '画像生成失败', res.data?.error);
|
||||
}
|
||||
} catch (e: any) {
|
||||
assert(false, '画像请求异常', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────
|
||||
// 测试 4: Q 层 — LLM 意图解析
|
||||
// ────────────────────────────────────────────
|
||||
|
||||
async function testQLayer(): Promise<string | null> {
|
||||
section('测试 4: Q 层 — LLM 意图理解');
|
||||
|
||||
const query = '比较 sex 不同组的 Yqol 有没有差别';
|
||||
console.log(` Query: "${query}"`);
|
||||
|
||||
try {
|
||||
const start = Date.now();
|
||||
const res = await apiPost('/api/v1/ssa/workflow/intent', {
|
||||
sessionId,
|
||||
userQuery: query,
|
||||
});
|
||||
const elapsed = Date.now() - start;
|
||||
|
||||
assert(res.status === 200, `返回 200(实际 ${res.status})`);
|
||||
|
||||
if (res.data?.success && res.data.intent) {
|
||||
const intent = res.data.intent;
|
||||
console.log(` 耗时: ${elapsed}ms`);
|
||||
console.log(` Goal: ${intent.goal}, Confidence: ${intent.confidence}`);
|
||||
console.log(` Y: ${intent.outcome_var}, X: ${JSON.stringify(intent.predictor_vars)}`);
|
||||
console.log(` Design: ${intent.design}, needsClarification: ${intent.needsClarification}`);
|
||||
|
||||
assert(intent.goal === 'comparison', `Goal = comparison(实际 ${intent.goal})`);
|
||||
assert(intent.confidence >= 0.7, `高置信度 >= 0.7(实际 ${intent.confidence})`);
|
||||
assert(!intent.needsClarification, '无需追问');
|
||||
|
||||
return intent.goal;
|
||||
} else {
|
||||
assert(false, 'Intent 解析失败', res.data?.error);
|
||||
}
|
||||
} catch (e: any) {
|
||||
assert(false, 'Q 层请求异常', e.message);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────
|
||||
// 测试 5: P 层 — 工作流规划
|
||||
// ────────────────────────────────────────────
|
||||
|
||||
let workflowId = '';
|
||||
|
||||
async function testPLayer(): Promise<boolean> {
|
||||
section('测试 5: P 层 — 工作流规划');
|
||||
|
||||
const query = '比较 sex 不同组的 Yqol 有没有差别';
|
||||
console.log(` Query: "${query}"`);
|
||||
|
||||
try {
|
||||
const start = Date.now();
|
||||
const res = await apiPost('/api/v1/ssa/workflow/plan', {
|
||||
sessionId,
|
||||
userQuery: query,
|
||||
});
|
||||
const elapsed = Date.now() - start;
|
||||
|
||||
assert(res.status === 200, `返回 200(实际 ${res.status})`);
|
||||
|
||||
if (res.data?.success && res.data.plan) {
|
||||
const plan = res.data.plan;
|
||||
console.log(` 耗时: ${elapsed}ms`);
|
||||
console.log(` 标题: ${plan.title}`);
|
||||
console.log(` 步骤数: ${plan.total_steps}`);
|
||||
|
||||
workflowId = plan.workflow_id;
|
||||
assert(!!workflowId, `Workflow ID: ${workflowId}`);
|
||||
assert(plan.total_steps >= 2, `步骤数 >= 2(实际 ${plan.total_steps})`);
|
||||
|
||||
plan.steps?.forEach((step: any, i: number) => {
|
||||
const sensitivity = step.is_sensitivity ? ' [敏感性]' : '';
|
||||
const guardrail = step.switch_condition ? ` | 护栏:${step.switch_condition}` : '';
|
||||
console.log(` 步骤 ${i + 1}: ${step.tool_name} (${step.tool_code})${sensitivity}${guardrail}`);
|
||||
});
|
||||
|
||||
if (plan.planned_trace) {
|
||||
console.log(` PlannedTrace: Primary=${plan.planned_trace.primaryTool}, Fallback=${plan.planned_trace.fallbackTool || 'null'}`);
|
||||
assert(!!plan.planned_trace.primaryTool, 'PlannedTrace 包含 primaryTool');
|
||||
}
|
||||
|
||||
return true;
|
||||
} else {
|
||||
assert(false, '规划失败', res.data?.error);
|
||||
}
|
||||
} catch (e: any) {
|
||||
assert(false, 'P 层请求异常', e.message);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────
|
||||
// 测试 6: E 层 — R 引擎执行
|
||||
// ────────────────────────────────────────────
|
||||
|
||||
async function testELayer(): Promise<boolean> {
|
||||
section('测试 6: E 层 — R 引擎执行(含 R 层结论生成)');
|
||||
|
||||
if (!workflowId) {
|
||||
skip('E 层执行', '无 workflowId');
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log(` Workflow ID: ${workflowId}`);
|
||||
console.log(` Session ID: ${sessionId}`);
|
||||
|
||||
try {
|
||||
const start = Date.now();
|
||||
const res = await apiPost(`/api/v1/ssa/workflow/${workflowId}/execute`, {
|
||||
sessionId,
|
||||
});
|
||||
const elapsed = Date.now() - start;
|
||||
|
||||
assert(res.status === 200, `返回 200(实际 ${res.status})`);
|
||||
|
||||
if (res.data?.success && res.data.result) {
|
||||
const result = res.data.result;
|
||||
console.log(` 耗时: ${elapsed}ms`);
|
||||
console.log(` 状态: ${result.status}`);
|
||||
console.log(` 总步骤: ${result.totalSteps}, 成功: ${result.successSteps}, 完成: ${result.completedSteps}`);
|
||||
|
||||
assert(
|
||||
result.status === 'completed' || result.status === 'partial',
|
||||
`执行状态正常(${result.status})`,
|
||||
result.status === 'error' ? '全部步骤失败' : undefined,
|
||||
);
|
||||
|
||||
assert(result.successSteps > 0, `至少 1 个步骤成功(实际 ${result.successSteps})`);
|
||||
|
||||
// 逐步骤检查
|
||||
if (result.results && Array.isArray(result.results)) {
|
||||
for (const step of result.results) {
|
||||
const icon = step.status === 'success' || step.status === 'warning' ? '✅' : '❌';
|
||||
const pVal = step.result?.p_value != null ? `, P=${step.result.p_value_fmt || step.result.p_value}` : '';
|
||||
const blocks = step.reportBlocks?.length || 0;
|
||||
const errMsg = step.error ? ` | 错误: ${step.error.userHint || step.error.message}` : '';
|
||||
console.log(` ${icon} 步骤 ${step.stepOrder}: ${step.toolName} [${step.status}] (${step.executionMs}ms${pVal}, ${blocks} blocks${errMsg})`);
|
||||
}
|
||||
}
|
||||
|
||||
// 检查 report_blocks
|
||||
if (result.reportBlocks && result.reportBlocks.length > 0) {
|
||||
assert(true, `聚合 reportBlocks: ${result.reportBlocks.length} 个`);
|
||||
const types = result.reportBlocks.map((b: any) => b.type);
|
||||
const uniqueTypes = [...new Set(types)];
|
||||
console.log(` Block 类型分布: ${uniqueTypes.join(', ')}`);
|
||||
}
|
||||
|
||||
// 检查 R 层结论
|
||||
if (result.conclusion) {
|
||||
console.log('\n ── R 层结论验证 ──');
|
||||
const c = result.conclusion;
|
||||
|
||||
assert(!!c.executive_summary, `executive_summary 非空(${c.executive_summary?.length || 0} 字)`);
|
||||
assert(Array.isArray(c.key_findings) && c.key_findings.length > 0,
|
||||
`key_findings 非空(${c.key_findings?.length || 0} 条)`);
|
||||
assert(!!c.statistical_summary, 'statistical_summary 存在');
|
||||
assert(Array.isArray(c.limitations) && c.limitations.length > 0,
|
||||
`limitations 非空(${c.limitations?.length || 0} 条)`);
|
||||
assert(!!c.generated_at, `generated_at: ${c.generated_at}`);
|
||||
assert(!!c.source, `source: ${c.source}`);
|
||||
|
||||
// 打印结论内容摘要
|
||||
console.log(` 结论来源: ${c.source === 'llm' ? 'AI 智能生成' : '规则引擎'}`);
|
||||
console.log(` 摘要前 200 字: ${c.executive_summary?.substring(0, 200)}...`);
|
||||
|
||||
if (c.key_findings?.length > 0) {
|
||||
console.log(' 主要发现:');
|
||||
c.key_findings.slice(0, 3).forEach((f: string, i: number) => {
|
||||
console.log(` ${i + 1}. ${f.substring(0, 120)}`);
|
||||
});
|
||||
}
|
||||
|
||||
if (c.statistical_summary) {
|
||||
console.log(` 统计概览: ${c.statistical_summary.total_tests} 项检验, ${c.statistical_summary.significant_results} 项显著`);
|
||||
console.log(` 使用方法: ${c.statistical_summary.methods_used?.join(', ')}`);
|
||||
}
|
||||
|
||||
if (c.step_summaries?.length > 0) {
|
||||
console.log(' 步骤摘要:');
|
||||
c.step_summaries.forEach((s: any) => {
|
||||
const sig = s.is_significant ? ' (显著*)' : '';
|
||||
console.log(` 步骤${s.step_number} ${s.tool_name}: ${s.summary?.substring(0, 100)}${sig}`);
|
||||
});
|
||||
}
|
||||
|
||||
if (c.limitations?.length > 0) {
|
||||
console.log(' 局限性:');
|
||||
c.limitations.slice(0, 3).forEach((l: string, i: number) => {
|
||||
console.log(` ${i + 1}. ${l.substring(0, 120)}`);
|
||||
});
|
||||
}
|
||||
|
||||
if (c.recommendations?.length > 0) {
|
||||
console.log(' 建议:');
|
||||
c.recommendations.slice(0, 2).forEach((r: string, i: number) => {
|
||||
console.log(` ${i + 1}. ${r.substring(0, 120)}`);
|
||||
});
|
||||
}
|
||||
|
||||
// 验证 workflow_id 一致
|
||||
if (c.workflow_id) {
|
||||
assert(c.workflow_id === workflowId, `conclusion.workflow_id 与 workflowId 一致`);
|
||||
}
|
||||
} else {
|
||||
assert(false, 'R 层未返回 conclusion');
|
||||
}
|
||||
|
||||
return result.successSteps > 0;
|
||||
} else {
|
||||
assert(false, '执行失败', res.data?.error);
|
||||
}
|
||||
} catch (e: any) {
|
||||
assert(false, 'E 层请求异常', e.message);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────
|
||||
// 测试 7: 结论 API 缓存验证
|
||||
// ────────────────────────────────────────────
|
||||
|
||||
async function testConclusionAPI() {
|
||||
section('测试 7: 结论 API + 缓存验证');
|
||||
|
||||
if (!sessionId) {
|
||||
skip('结论 API', '无 sessionId');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const start = Date.now();
|
||||
const res = await apiGet(`/api/v1/ssa/workflow/sessions/${sessionId}/conclusion`);
|
||||
const elapsed = Date.now() - start;
|
||||
|
||||
assert(res.status === 200, `返回 200(实际 ${res.status})`);
|
||||
|
||||
if (res.data?.success && res.data.conclusion) {
|
||||
const c = res.data.conclusion;
|
||||
console.log(` 耗时: ${elapsed}ms`);
|
||||
console.log(` 来源: ${res.data.source}`);
|
||||
|
||||
assert(!!c.executive_summary, 'executive_summary 非空');
|
||||
assert(Array.isArray(c.key_findings), 'key_findings 是数组');
|
||||
assert(!!c.generated_at, `generated_at: ${c.generated_at}`);
|
||||
|
||||
// 二次调用验证缓存
|
||||
console.log('\n ── 缓存验证(二次调用) ──');
|
||||
const start2 = Date.now();
|
||||
const res2 = await apiGet(`/api/v1/ssa/workflow/sessions/${sessionId}/conclusion`);
|
||||
const elapsed2 = Date.now() - start2;
|
||||
|
||||
assert(res2.status === 200, '二次调用返回 200');
|
||||
console.log(` 二次调用耗时: ${elapsed2}ms`);
|
||||
|
||||
if (elapsed2 < elapsed && res.data.source === 'cache') {
|
||||
assert(true, `缓存命中(${elapsed2}ms << ${elapsed}ms)`);
|
||||
} else {
|
||||
console.log(` ℹ️ 首次 ${elapsed}ms, 二次 ${elapsed2}ms(缓存效果取决于实现)`);
|
||||
}
|
||||
} else if (res.status === 404) {
|
||||
skip('结论 API', '未找到已完成的 workflow(可能是 E 层全部失败)');
|
||||
} else {
|
||||
assert(false, '获取结论失败', res.data?.error || JSON.stringify(res.data).substring(0, 200));
|
||||
}
|
||||
} catch (e: any) {
|
||||
assert(false, '结论 API 异常', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────
|
||||
// 测试 8: 第二条链路(相关分析 Q→P→E→R)
|
||||
// ────────────────────────────────────────────
|
||||
|
||||
async function testSecondScenario() {
|
||||
section('测试 8: 第二条完整链路(相关分析 age vs bmi)');
|
||||
|
||||
const query = '分析 age 和 bmi 的相关性';
|
||||
console.log(` Query: "${query}"`);
|
||||
|
||||
try {
|
||||
// Q → P: Plan
|
||||
const planRes = await apiPost('/api/v1/ssa/workflow/plan', {
|
||||
sessionId,
|
||||
userQuery: query,
|
||||
});
|
||||
|
||||
assert(planRes.status === 200, 'Plan 返回 200');
|
||||
|
||||
if (!planRes.data?.success || !planRes.data.plan) {
|
||||
assert(false, 'Plan 失败', planRes.data?.error);
|
||||
return;
|
||||
}
|
||||
|
||||
const plan = planRes.data.plan;
|
||||
const wfId = plan.workflow_id;
|
||||
console.log(` Workflow: ${wfId}, 步骤数: ${plan.total_steps}`);
|
||||
plan.steps?.forEach((s: any, i: number) => {
|
||||
console.log(` 步骤 ${i + 1}: ${s.tool_name} (${s.tool_code})`);
|
||||
});
|
||||
|
||||
// P → E → R: Execute
|
||||
const start = Date.now();
|
||||
const execRes = await apiPost(`/api/v1/ssa/workflow/${wfId}/execute`, { sessionId });
|
||||
const elapsed = Date.now() - start;
|
||||
|
||||
assert(execRes.status === 200, 'Execute 返回 200');
|
||||
|
||||
if (execRes.data?.success && execRes.data.result) {
|
||||
const result = execRes.data.result;
|
||||
console.log(` 执行耗时: ${elapsed}ms, 状态: ${result.status}, 成功步骤: ${result.successSteps}/${result.totalSteps}`);
|
||||
|
||||
assert(result.successSteps > 0, `至少 1 步成功(实际 ${result.successSteps})`);
|
||||
|
||||
for (const step of (result.results || [])) {
|
||||
const icon = step.status === 'success' || step.status === 'warning' ? '✅' : '❌';
|
||||
const pVal = step.result?.p_value != null ? `, P=${step.result.p_value_fmt || step.result.p_value}` : '';
|
||||
console.log(` ${icon} 步骤 ${step.stepOrder}: ${step.toolName} [${step.status}] (${step.executionMs}ms${pVal})`);
|
||||
}
|
||||
|
||||
// 验证 R 层结论
|
||||
if (result.conclusion) {
|
||||
const c = result.conclusion;
|
||||
assert(!!c.executive_summary, `R 层结论存在(来源: ${c.source})`);
|
||||
console.log(` 结论摘要: ${c.executive_summary?.substring(0, 150)}...`);
|
||||
|
||||
// 相关分析应该提到相关系数
|
||||
const mentionsCorrelation =
|
||||
c.executive_summary?.includes('相关') ||
|
||||
c.executive_summary?.includes('correlation') ||
|
||||
c.executive_summary?.includes('r =') ||
|
||||
c.executive_summary?.includes('r=');
|
||||
if (mentionsCorrelation) {
|
||||
assert(true, '结论中提到了相关性分析');
|
||||
} else {
|
||||
console.log(' ℹ️ 结论未明确提到"相关"(可能是 fallback 结论)');
|
||||
}
|
||||
} else {
|
||||
skip('R 层结论', '未返回 conclusion');
|
||||
}
|
||||
} else {
|
||||
assert(false, '执行失败', execRes.data?.error);
|
||||
}
|
||||
} catch (e: any) {
|
||||
assert(false, '第二条链路异常', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────
|
||||
// 测试 9: 错误分类验证(E_COLUMN_NOT_FOUND 等)
|
||||
// ────────────────────────────────────────────
|
||||
|
||||
async function testErrorClassification() {
|
||||
section('测试 9: E 层错误分类验证(构造异常查询)');
|
||||
|
||||
const query = '比较 NONEXISTENT_GROUP 不同组的 FAKE_OUTCOME';
|
||||
console.log(` 构造异常 Query: "${query}"`);
|
||||
console.log(' ℹ️ 此测试验证 LLM 面对不存在的变量名时的行为');
|
||||
|
||||
try {
|
||||
const res = await apiPost('/api/v1/ssa/workflow/intent', {
|
||||
sessionId,
|
||||
userQuery: query,
|
||||
});
|
||||
|
||||
if (res.data?.success && res.data.intent) {
|
||||
const intent = res.data.intent;
|
||||
console.log(` LLM 返回: goal=${intent.goal}, confidence=${intent.confidence}`);
|
||||
console.log(` Y=${intent.outcome_var}, X=${JSON.stringify(intent.predictor_vars)}`);
|
||||
|
||||
// Zod 动态校验应该拦截不存在的变量名
|
||||
// 或者 LLM 会给出低置信度
|
||||
if (intent.confidence < 0.7 || intent.needsClarification) {
|
||||
assert(true, `LLM 识别到异常(confidence=${intent.confidence})或触发追问`);
|
||||
} else {
|
||||
console.log(' ℹ️ LLM 未识别到异常变量,可能猜测了现有变量作为替代');
|
||||
}
|
||||
} else {
|
||||
// Intent 解析失败也是可以接受的(Zod 拦截了幻觉变量)
|
||||
console.log(` Intent 解析结果: ${res.data?.error || '失败/降级'}`);
|
||||
assert(true, '异常输入被处理(未崩溃)');
|
||||
}
|
||||
} catch (e: any) {
|
||||
assert(false, '异常查询处理失败(不应崩溃)', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────
|
||||
// 运行所有测试
|
||||
// ────────────────────────────────────────────
|
||||
|
||||
async function main() {
|
||||
console.log('\n🧪 SSA QPER — 完整链路端到端集成测试(Q→P→E→R)\n');
|
||||
console.log('测试链路:登录 → 上传 CSV → 画像 → Q(Intent) → P(Plan) → E(Execute) → R(Conclusion)');
|
||||
console.log(`测试用户:${TEST_PHONE}`);
|
||||
console.log(`后端地址:${BASE_URL}`);
|
||||
console.log(`测试文件:${TEST_CSV_PATH}\n`);
|
||||
|
||||
// 前置检查
|
||||
try {
|
||||
readFileSync(TEST_CSV_PATH);
|
||||
} catch {
|
||||
console.error('❌ test.csv 文件不存在,请检查路径');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
const health = await fetch(`${BASE_URL}/health`).catch(() => null);
|
||||
if (!health || health.status !== 200) {
|
||||
console.error('❌ 后端服务未启动');
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('✅ 后端服务可达');
|
||||
} catch {
|
||||
console.error('❌ 后端服务不可达');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 顺序执行
|
||||
const loginOk = await testLogin();
|
||||
if (!loginOk) { console.log('\n⛔ 登录失败,终止'); process.exit(1); }
|
||||
|
||||
const sessionOk = await testCreateSession();
|
||||
if (!sessionOk) { console.log('\n⛔ 会话创建失败,终止'); process.exit(1); }
|
||||
|
||||
await testDataProfile();
|
||||
|
||||
const goal = await testQLayer();
|
||||
if (!goal) { console.log('\n⚠️ Q 层失败,继续后续测试...'); }
|
||||
|
||||
const planOk = await testPLayer();
|
||||
if (!planOk) { console.log('\n⚠️ P 层失败,E/R 层将跳过'); }
|
||||
|
||||
const execOk = planOk ? await testELayer() : false;
|
||||
|
||||
if (execOk) {
|
||||
await testConclusionAPI();
|
||||
} else if (planOk) {
|
||||
console.log('\n⚠️ E 层失败,跳过结论 API 测试');
|
||||
}
|
||||
|
||||
await testSecondScenario();
|
||||
await testErrorClassification();
|
||||
|
||||
// 汇总
|
||||
console.log(`\n${'═'.repeat(60)}`);
|
||||
console.log(`📊 测试结果汇总:${passed} 通过 / ${failed} 失败 / ${skipped} 跳过 / ${passed + failed + skipped} 总计`);
|
||||
if (failed === 0) {
|
||||
console.log('🎉 全部通过!QPER 四层端到端验证成功。');
|
||||
} else {
|
||||
console.log(`⚠️ 有 ${failed} 个测试失败,请检查上方输出。`);
|
||||
}
|
||||
console.log(`\n📝 测试会话 ID: ${sessionId}`);
|
||||
console.log('═'.repeat(60));
|
||||
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
main().catch(e => {
|
||||
console.error('💥 测试脚本异常:', e);
|
||||
process.exit(1);
|
||||
});
|
||||
85
backend/src/modules/ssa/config/ConfigLoader.ts
Normal file
85
backend/src/modules/ssa/config/ConfigLoader.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* SSA ConfigLoader — 配置化基础设施
|
||||
*
|
||||
* 通用基类:读 JSON 文件 → Zod Schema 校验 → 内存缓存
|
||||
* 支持热更新(reload 时重新读盘 + 重新校验,失败保留旧配置)
|
||||
*
|
||||
* 核心原则第 6 条:一切业务逻辑靠读 JSON 驱动,不写死在代码中。
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname } from 'path';
|
||||
import type { ZodType } from 'zod';
|
||||
import { logger } from '../../../common/logging/index.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
export interface ReloadResult {
|
||||
success: boolean;
|
||||
file: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export class ConfigLoader<T> {
|
||||
private cache: T | null = null;
|
||||
private readonly filePath: string;
|
||||
private readonly schema: ZodType<T>;
|
||||
private readonly label: string;
|
||||
|
||||
constructor(fileName: string, schema: ZodType<T>, label: string) {
|
||||
this.filePath = join(__dirname, fileName);
|
||||
this.schema = schema;
|
||||
this.label = label;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取配置(带内存缓存,首次自动加载)
|
||||
*/
|
||||
get(): T {
|
||||
if (!this.cache) {
|
||||
this.loadFromDisk();
|
||||
}
|
||||
return this.cache!;
|
||||
}
|
||||
|
||||
/**
|
||||
* 热更新 — 从磁盘重新读取 + Zod 校验
|
||||
* 校验失败时保留旧配置,返回错误详情
|
||||
*/
|
||||
reload(): ReloadResult {
|
||||
try {
|
||||
this.loadFromDisk();
|
||||
logger.info(`[SSA:Config] ${this.label} reloaded successfully`);
|
||||
return { success: true, file: this.label };
|
||||
} catch (err: any) {
|
||||
logger.error(`[SSA:Config] ${this.label} reload failed, keeping old config`, {
|
||||
error: err.message,
|
||||
});
|
||||
return { success: false, file: this.label, error: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
private loadFromDisk(): void {
|
||||
const raw = readFileSync(this.filePath, 'utf-8');
|
||||
let parsed: unknown;
|
||||
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch (e: any) {
|
||||
throw new Error(`${this.label}: JSON 语法错误 — ${e.message}`);
|
||||
}
|
||||
|
||||
const result = this.schema.safeParse(parsed);
|
||||
if (!result.success) {
|
||||
const issues = result.error.issues
|
||||
.map(i => ` - ${i.path.join('.')}: ${i.message}`)
|
||||
.join('\n');
|
||||
throw new Error(`${this.label}: Schema 校验失败\n${issues}`);
|
||||
}
|
||||
|
||||
this.cache = result.data;
|
||||
}
|
||||
}
|
||||
132
backend/src/modules/ssa/config/decision_tables.json
Normal file
132
backend/src/modules/ssa/config/decision_tables.json
Normal file
@@ -0,0 +1,132 @@
|
||||
[
|
||||
{
|
||||
"id": "DIFF_CONT_BIN_IND",
|
||||
"goal": "comparison",
|
||||
"outcomeType": "continuous",
|
||||
"predictorType": "binary",
|
||||
"design": "independent",
|
||||
"primaryTool": "ST_T_TEST_IND",
|
||||
"fallbackTool": "ST_MANN_WHITNEY",
|
||||
"switchCondition": "normality_fail: Shapiro-Wilk P<0.05",
|
||||
"templateId": "standard_analysis",
|
||||
"priority": 10,
|
||||
"description": "两组连续变量比较(独立样本)"
|
||||
},
|
||||
{
|
||||
"id": "DIFF_CONT_BIN_PAIRED",
|
||||
"goal": "comparison",
|
||||
"outcomeType": "continuous",
|
||||
"predictorType": "binary",
|
||||
"design": "paired",
|
||||
"primaryTool": "ST_T_TEST_PAIRED",
|
||||
"fallbackTool": null,
|
||||
"switchCondition": null,
|
||||
"templateId": "paired_analysis",
|
||||
"priority": 10,
|
||||
"description": "配对设计前后对比"
|
||||
},
|
||||
{
|
||||
"id": "DIFF_CONT_MULTI_IND",
|
||||
"goal": "comparison",
|
||||
"outcomeType": "continuous",
|
||||
"predictorType": "categorical",
|
||||
"design": "independent",
|
||||
"primaryTool": "ST_T_TEST_IND",
|
||||
"fallbackTool": "ST_MANN_WHITNEY",
|
||||
"switchCondition": "normality_fail: Shapiro-Wilk P<0.05",
|
||||
"templateId": "standard_analysis",
|
||||
"priority": 5,
|
||||
"description": "多组连续变量比较(暂用 T 检验处理两组场景,ANOVA 待扩展)"
|
||||
},
|
||||
{
|
||||
"id": "DIFF_CAT_CAT_IND",
|
||||
"goal": "comparison",
|
||||
"outcomeType": "categorical",
|
||||
"predictorType": "categorical",
|
||||
"design": "independent",
|
||||
"primaryTool": "ST_CHI_SQUARE",
|
||||
"fallbackTool": "ST_CHI_SQUARE",
|
||||
"switchCondition": "expected_freq_low: 期望频数<5 时 R 内部自动切换 Fisher",
|
||||
"templateId": "standard_analysis",
|
||||
"priority": 10,
|
||||
"description": "两个分类变量的独立性检验"
|
||||
},
|
||||
{
|
||||
"id": "ASSOC_CONT_CONT",
|
||||
"goal": "correlation",
|
||||
"outcomeType": "continuous",
|
||||
"predictorType": "continuous",
|
||||
"design": "*",
|
||||
"primaryTool": "ST_CORRELATION",
|
||||
"fallbackTool": null,
|
||||
"switchCondition": null,
|
||||
"templateId": "standard_analysis",
|
||||
"priority": 10,
|
||||
"description": "两个连续变量的相关分析(Pearson/Spearman 自动选择)"
|
||||
},
|
||||
{
|
||||
"id": "ASSOC_CAT_ANY",
|
||||
"goal": "correlation",
|
||||
"outcomeType": "categorical",
|
||||
"predictorType": "*",
|
||||
"design": "*",
|
||||
"primaryTool": "ST_CHI_SQUARE",
|
||||
"fallbackTool": "ST_CHI_SQUARE",
|
||||
"switchCondition": "expected_freq_low: 期望频数<5 时 R 内部自动切换 Fisher",
|
||||
"templateId": "standard_analysis",
|
||||
"priority": 5,
|
||||
"description": "分类变量关联分析"
|
||||
},
|
||||
{
|
||||
"id": "PRED_BIN_ANY",
|
||||
"goal": "regression",
|
||||
"outcomeType": "binary",
|
||||
"predictorType": "*",
|
||||
"design": "*",
|
||||
"primaryTool": "ST_LOGISTIC_BINARY",
|
||||
"fallbackTool": null,
|
||||
"switchCondition": null,
|
||||
"templateId": "regression_analysis",
|
||||
"priority": 10,
|
||||
"description": "二分类结局的多因素 Logistic 回归"
|
||||
},
|
||||
{
|
||||
"id": "PRED_CONT_ANY",
|
||||
"goal": "regression",
|
||||
"outcomeType": "continuous",
|
||||
"predictorType": "*",
|
||||
"design": "*",
|
||||
"primaryTool": "ST_CORRELATION",
|
||||
"fallbackTool": null,
|
||||
"switchCondition": null,
|
||||
"templateId": "regression_analysis",
|
||||
"priority": 5,
|
||||
"description": "连续结局的回归分析(线性回归待扩展,暂用相关分析)"
|
||||
},
|
||||
{
|
||||
"id": "DESC_ANY",
|
||||
"goal": "descriptive",
|
||||
"outcomeType": "*",
|
||||
"predictorType": "*",
|
||||
"design": "*",
|
||||
"primaryTool": "ST_DESCRIPTIVE",
|
||||
"fallbackTool": null,
|
||||
"switchCondition": null,
|
||||
"templateId": "descriptive_only",
|
||||
"priority": 1,
|
||||
"description": "纯描述性统计"
|
||||
},
|
||||
{
|
||||
"id": "COHORT_STUDY",
|
||||
"goal": "cohort_study",
|
||||
"outcomeType": "binary",
|
||||
"predictorType": "*",
|
||||
"design": "*",
|
||||
"primaryTool": "ST_DESCRIPTIVE",
|
||||
"fallbackTool": null,
|
||||
"switchCondition": null,
|
||||
"templateId": "cohort_study_standard",
|
||||
"priority": 20,
|
||||
"description": "队列研究全套分析(Table 1→2→3)"
|
||||
}
|
||||
]
|
||||
69
backend/src/modules/ssa/config/flow_templates.json
Normal file
69
backend/src/modules/ssa/config/flow_templates.json
Normal file
@@ -0,0 +1,69 @@
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"templates": [
|
||||
{
|
||||
"id": "standard_analysis",
|
||||
"name": "标准分析流程",
|
||||
"description": "适用于差异比较、相关分析等场景的通用三步模板",
|
||||
"steps": [
|
||||
{ "order": 1, "role": "descriptive", "tool": "ST_DESCRIPTIVE", "name": "描述性统计" },
|
||||
{ "order": 2, "role": "primary_test", "tool": "{{primaryTool}}", "name": "主分析" },
|
||||
{ "order": 3, "role": "sensitivity", "tool": "{{fallbackTool}}", "name": "敏感性分析", "condition": "fallback_exists" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "paired_analysis",
|
||||
"name": "配对设计分析",
|
||||
"description": "配对设计的前后对比分析",
|
||||
"steps": [
|
||||
{ "order": 1, "role": "descriptive", "tool": "ST_DESCRIPTIVE", "name": "描述性统计" },
|
||||
{ "order": 2, "role": "primary_test", "tool": "{{primaryTool}}", "name": "配对检验" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "regression_analysis",
|
||||
"name": "回归建模",
|
||||
"description": "描述统计 + 多因素回归分析",
|
||||
"steps": [
|
||||
{ "order": 1, "role": "descriptive", "tool": "ST_DESCRIPTIVE", "name": "描述性统计" },
|
||||
{ "order": 2, "role": "primary_test", "tool": "{{primaryTool}}", "name": "多因素回归" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "descriptive_only",
|
||||
"name": "描述性统计",
|
||||
"description": "仅做数据概况分析",
|
||||
"steps": [
|
||||
{ "order": 1, "role": "descriptive", "tool": "ST_DESCRIPTIVE", "name": "描述性统计" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "cohort_study_standard",
|
||||
"name": "经典队列研究全套分析",
|
||||
"description": "覆盖 Table 1(基线比较)→ Table 2(单因素筛选)→ Table 3(多因素回归)",
|
||||
"steps": [
|
||||
{
|
||||
"order": 1,
|
||||
"role": "baseline_table",
|
||||
"tool": "ST_DESCRIPTIVE",
|
||||
"name": "表1: 组间基线特征比较",
|
||||
"paramsMapping": { "group_var": "{{grouping_var}}", "variables": "{{all_predictors}}" }
|
||||
},
|
||||
{
|
||||
"order": 2,
|
||||
"role": "univariate_screen",
|
||||
"tool": "ST_DESCRIPTIVE",
|
||||
"name": "表2: 结局指标单因素分析",
|
||||
"paramsMapping": { "group_var": "{{outcome_var}}", "variables": "{{all_predictors}}" }
|
||||
},
|
||||
{
|
||||
"order": 3,
|
||||
"role": "multivariate_reg",
|
||||
"tool": "ST_LOGISTIC_BINARY",
|
||||
"name": "表3: 多因素 Logistic 回归",
|
||||
"paramsMapping": { "outcome_var": "{{outcome_var}}", "predictors": "{{epv_capped_predictors}}" }
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
48
backend/src/modules/ssa/config/index.ts
Normal file
48
backend/src/modules/ssa/config/index.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* SSA 配置中心 — 统一管理所有领域 JSON 配置
|
||||
*
|
||||
* 每个 ConfigLoader 实例对应一个 JSON 文件 + Zod Schema。
|
||||
* 提供 reloadAll() 供热更新 API 调用。
|
||||
*/
|
||||
|
||||
import { ConfigLoader, type ReloadResult } from './ConfigLoader.js';
|
||||
import {
|
||||
ToolsRegistrySchema,
|
||||
DecisionTablesSchema,
|
||||
FlowTemplatesSchema,
|
||||
type ToolsRegistry,
|
||||
type DecisionTable,
|
||||
type FlowTemplatesConfig,
|
||||
} from './schemas.js';
|
||||
|
||||
export const toolsRegistryLoader = new ConfigLoader<ToolsRegistry>(
|
||||
'tools_registry.json',
|
||||
ToolsRegistrySchema,
|
||||
'tools_registry'
|
||||
);
|
||||
|
||||
export const decisionTablesLoader = new ConfigLoader<DecisionTable[]>(
|
||||
'decision_tables.json',
|
||||
DecisionTablesSchema,
|
||||
'decision_tables'
|
||||
);
|
||||
|
||||
export const flowTemplatesLoader = new ConfigLoader<FlowTemplatesConfig>(
|
||||
'flow_templates.json',
|
||||
FlowTemplatesSchema,
|
||||
'flow_templates'
|
||||
);
|
||||
|
||||
/**
|
||||
* 热更新所有配置文件
|
||||
* 每个文件独立校验 — 一个失败不影响其他
|
||||
*/
|
||||
export function reloadAllConfigs(): ReloadResult[] {
|
||||
return [
|
||||
toolsRegistryLoader.reload(),
|
||||
decisionTablesLoader.reload(),
|
||||
flowTemplatesLoader.reload(),
|
||||
];
|
||||
}
|
||||
|
||||
export type { ReloadResult } from './ConfigLoader.js';
|
||||
91
backend/src/modules/ssa/config/schemas.ts
Normal file
91
backend/src/modules/ssa/config/schemas.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* SSA 领域配置 Zod Schema
|
||||
*
|
||||
* 方法学团队编辑 JSON 时的拼写/结构错误在加载时立即拦截。
|
||||
* 每个 Schema 对应一个 JSON 领域文件。
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
// ────────────────────────────────────────────
|
||||
// 1. tools_registry.json — E 层工具注册表
|
||||
// ────────────────────────────────────────────
|
||||
|
||||
const ToolParamSchema = z.object({
|
||||
name: z.string(),
|
||||
type: z.enum(['string', 'number', 'boolean', 'string[]', 'number[]']),
|
||||
required: z.boolean().default(true),
|
||||
description: z.string().optional(),
|
||||
default: z.unknown().optional(),
|
||||
});
|
||||
|
||||
const ToolDefinitionSchema = z.object({
|
||||
code: z.string().regex(/^ST_[A-Z_]+$/, 'tool code must match ST_XXX pattern'),
|
||||
name: z.string().min(1),
|
||||
category: z.string(),
|
||||
description: z.string(),
|
||||
inputParams: z.array(ToolParamSchema),
|
||||
outputType: z.string(),
|
||||
prerequisite: z.string().optional(),
|
||||
fallback: z.string().optional(),
|
||||
});
|
||||
|
||||
export const ToolsRegistrySchema = z.object({
|
||||
version: z.string().optional(),
|
||||
tools: z.array(ToolDefinitionSchema).min(1),
|
||||
});
|
||||
|
||||
export type ToolDefinition = z.infer<typeof ToolDefinitionSchema>;
|
||||
export type ToolsRegistry = z.infer<typeof ToolsRegistrySchema>;
|
||||
|
||||
// ────────────────────────────────────────────
|
||||
// 2. decision_tables.json — P 层决策表
|
||||
// ────────────────────────────────────────────
|
||||
|
||||
const DecisionRuleSchema = z.object({
|
||||
id: z.string(),
|
||||
goal: z.string(),
|
||||
outcomeType: z.string(),
|
||||
predictorType: z.string(),
|
||||
design: z.string(),
|
||||
primaryTool: z.string(),
|
||||
fallbackTool: z.string().nullable().default(null),
|
||||
switchCondition: z.string().nullable().default(null),
|
||||
templateId: z.string(),
|
||||
priority: z.number().default(0),
|
||||
description: z.string().optional(),
|
||||
});
|
||||
|
||||
export const DecisionTablesSchema = z.array(DecisionRuleSchema).min(1);
|
||||
|
||||
export type DecisionRule = z.infer<typeof DecisionRuleSchema>;
|
||||
export type DecisionTable = DecisionRule;
|
||||
|
||||
// ────────────────────────────────────────────
|
||||
// 3. flow_templates.json — P 层流程模板
|
||||
// ────────────────────────────────────────────
|
||||
|
||||
const TemplateStepSchema = z.object({
|
||||
order: z.number(),
|
||||
role: z.string(),
|
||||
tool: z.string(),
|
||||
name: z.string().optional(),
|
||||
condition: z.string().optional(),
|
||||
paramsMapping: z.record(z.string(), z.string()).optional(),
|
||||
});
|
||||
|
||||
const FlowTemplateSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
description: z.string().optional(),
|
||||
steps: z.array(TemplateStepSchema).min(1),
|
||||
});
|
||||
|
||||
export const FlowTemplatesSchema = z.object({
|
||||
version: z.string().optional(),
|
||||
templates: z.array(FlowTemplateSchema).min(1),
|
||||
});
|
||||
|
||||
export type TemplateStep = z.infer<typeof TemplateStepSchema>;
|
||||
export type FlowTemplate = z.infer<typeof FlowTemplateSchema>;
|
||||
export type FlowTemplatesConfig = z.infer<typeof FlowTemplatesSchema>;
|
||||
87
backend/src/modules/ssa/config/tools_registry.json
Normal file
87
backend/src/modules/ssa/config/tools_registry.json
Normal file
@@ -0,0 +1,87 @@
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"tools": [
|
||||
{
|
||||
"code": "ST_DESCRIPTIVE",
|
||||
"name": "描述性统计",
|
||||
"category": "basic",
|
||||
"description": "数据概况、基线特征表",
|
||||
"inputParams": [
|
||||
{ "name": "variables", "type": "string[]", "required": true, "description": "分析变量列表" },
|
||||
{ "name": "group_var", "type": "string", "required": false, "description": "分组变量" }
|
||||
],
|
||||
"outputType": "summary"
|
||||
},
|
||||
{
|
||||
"code": "ST_T_TEST_IND",
|
||||
"name": "独立样本T检验",
|
||||
"category": "parametric",
|
||||
"description": "两组连续变量比较(参数方法)",
|
||||
"inputParams": [
|
||||
{ "name": "group_var", "type": "string", "required": true, "description": "分组变量(二分类)" },
|
||||
{ "name": "value_var", "type": "string", "required": true, "description": "连续型结局变量" }
|
||||
],
|
||||
"outputType": "comparison",
|
||||
"prerequisite": "正态分布",
|
||||
"fallback": "ST_MANN_WHITNEY"
|
||||
},
|
||||
{
|
||||
"code": "ST_MANN_WHITNEY",
|
||||
"name": "Mann-Whitney U检验",
|
||||
"category": "nonparametric",
|
||||
"description": "两组连续/等级变量比较(非参数方法)",
|
||||
"inputParams": [
|
||||
{ "name": "group_var", "type": "string", "required": true, "description": "分组变量(二分类)" },
|
||||
{ "name": "value_var", "type": "string", "required": true, "description": "连续型结局变量" }
|
||||
],
|
||||
"outputType": "comparison"
|
||||
},
|
||||
{
|
||||
"code": "ST_T_TEST_PAIRED",
|
||||
"name": "配对T检验",
|
||||
"category": "parametric",
|
||||
"description": "配对设计的前后对比",
|
||||
"inputParams": [
|
||||
{ "name": "before_var", "type": "string", "required": true, "description": "前测变量" },
|
||||
{ "name": "after_var", "type": "string", "required": true, "description": "后测变量" }
|
||||
],
|
||||
"outputType": "comparison"
|
||||
},
|
||||
{
|
||||
"code": "ST_CHI_SQUARE",
|
||||
"name": "卡方检验",
|
||||
"category": "categorical",
|
||||
"description": "两个分类变量的独立性检验",
|
||||
"inputParams": [
|
||||
{ "name": "var1", "type": "string", "required": true, "description": "分类变量1" },
|
||||
{ "name": "var2", "type": "string", "required": true, "description": "分类变量2" }
|
||||
],
|
||||
"outputType": "association",
|
||||
"fallback": "ST_FISHER"
|
||||
},
|
||||
{
|
||||
"code": "ST_CORRELATION",
|
||||
"name": "相关分析",
|
||||
"category": "correlation",
|
||||
"description": "Pearson/Spearman相关系数",
|
||||
"inputParams": [
|
||||
{ "name": "var_x", "type": "string", "required": true, "description": "自变量" },
|
||||
{ "name": "var_y", "type": "string", "required": true, "description": "因变量" },
|
||||
{ "name": "method", "type": "string", "required": false, "description": "auto/pearson/spearman", "default": "auto" }
|
||||
],
|
||||
"outputType": "correlation"
|
||||
},
|
||||
{
|
||||
"code": "ST_LOGISTIC_BINARY",
|
||||
"name": "二元Logistic回归",
|
||||
"category": "regression",
|
||||
"description": "二分类结局的多因素分析",
|
||||
"inputParams": [
|
||||
{ "name": "outcome_var", "type": "string", "required": true, "description": "二分类结局变量" },
|
||||
{ "name": "predictors", "type": "string[]", "required": true, "description": "预测变量列表" },
|
||||
{ "name": "confounders", "type": "string[]", "required": false, "description": "混杂因素列表" }
|
||||
],
|
||||
"outputType": "regression"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
import { FastifyInstance, FastifyRequest } from 'fastify';
|
||||
import { prisma } from '../../../config/database.js';
|
||||
import { logger } from '../../../common/logging/index.js';
|
||||
import { reloadAllConfigs } from '../config/index.js';
|
||||
|
||||
function getUserId(request: FastifyRequest): string {
|
||||
const userId = (request as any).user?.userId;
|
||||
@@ -106,14 +107,29 @@ export default async function configRoutes(app: FastifyInstance) {
|
||||
return reply.send([]);
|
||||
});
|
||||
|
||||
// 热加载配置
|
||||
// 热加载配置 — 重新读取所有领域 JSON 文件并 Zod 校验
|
||||
app.post('/reload', async (req, reply) => {
|
||||
// TODO: 重新加载所有配置到缓存
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
logger.info('[SSA:Config] Reloading all config files...');
|
||||
|
||||
const results = reloadAllConfigs();
|
||||
const allSuccess = results.every(r => r.success);
|
||||
const failures = results.filter(r => !r.success);
|
||||
|
||||
if (allSuccess) {
|
||||
logger.info('[SSA:Config] All configs reloaded successfully');
|
||||
return reply.send({
|
||||
success: true,
|
||||
timestamp: new Date().toISOString(),
|
||||
results,
|
||||
});
|
||||
} else {
|
||||
logger.warn('[SSA:Config] Some configs failed to reload', { failures });
|
||||
return reply.status(400).send({
|
||||
success: false,
|
||||
message: `${failures.length} 个配置文件校验失败,已保留旧配置`,
|
||||
results,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 校验配置文件
|
||||
|
||||
@@ -13,6 +13,10 @@ import { logger } from '../../../common/logging/index.js';
|
||||
import { workflowPlannerService } from '../services/WorkflowPlannerService.js';
|
||||
import { workflowExecutorService } from '../services/WorkflowExecutorService.js';
|
||||
import { dataProfileService } from '../services/DataProfileService.js';
|
||||
import { queryService } from '../services/QueryService.js';
|
||||
import { reflectionService } from '../services/ReflectionService.js';
|
||||
import { prisma } from '../../../config/database.js';
|
||||
import { cache } from '../../../common/cache/index.js';
|
||||
|
||||
// 请求类型定义
|
||||
interface PlanWorkflowBody {
|
||||
@@ -74,6 +78,109 @@ export default async function workflowRoutes(app: FastifyInstance) {
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /workflow/intent
|
||||
* Phase Q: LLM 意图理解 — 解析用户自然语言为结构化 ParsedQuery
|
||||
*/
|
||||
app.post<{ Body: PlanWorkflowBody }>(
|
||||
'/intent',
|
||||
async (request, reply) => {
|
||||
const { sessionId, userQuery } = request.body;
|
||||
|
||||
if (!sessionId || !userQuery) {
|
||||
return reply.status(400).send({
|
||||
success: false,
|
||||
error: 'sessionId and userQuery are required'
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
logger.info('[SSA:API] Parsing intent', { sessionId, userQuery });
|
||||
|
||||
const parsed = await queryService.parseIntent(userQuery, sessionId);
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
intent: parsed,
|
||||
needsClarification: parsed.needsClarification,
|
||||
clarificationCards: parsed.clarificationCards || [],
|
||||
});
|
||||
|
||||
} catch (error: any) {
|
||||
logger.error('[SSA:API] Intent parsing failed', {
|
||||
sessionId,
|
||||
error: error.message
|
||||
});
|
||||
|
||||
return reply.status(500).send({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /workflow/clarify
|
||||
* Phase Q: 用户回答追问卡片后,补全 ParsedQuery 并重新规划
|
||||
*/
|
||||
app.post<{ Body: { sessionId: string; userQuery: string; selections: Record<string, string> } }>(
|
||||
'/clarify',
|
||||
async (request, reply) => {
|
||||
const { sessionId, userQuery, selections } = request.body;
|
||||
|
||||
if (!sessionId) {
|
||||
return reply.status(400).send({
|
||||
success: false,
|
||||
error: 'sessionId is required'
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
logger.info('[SSA:API] Processing clarification', { sessionId, selections });
|
||||
|
||||
// 将用户选择拼接到原始 query 中,重新走 intent 解析
|
||||
const selectionText = Object.entries(selections)
|
||||
.map(([key, value]) => `${key}: ${value}`)
|
||||
.join('; ');
|
||||
const enrichedQuery = userQuery
|
||||
? `${userQuery}(补充说明:${selectionText})`
|
||||
: selectionText;
|
||||
|
||||
const parsed = await queryService.parseIntent(enrichedQuery, sessionId);
|
||||
|
||||
// 如果这次置信度足够,直接生成工作流计划
|
||||
if (!parsed.needsClarification) {
|
||||
const plan = await workflowPlannerService.planWorkflow(sessionId, enrichedQuery);
|
||||
return reply.send({
|
||||
success: true,
|
||||
intent: parsed,
|
||||
plan,
|
||||
needsClarification: false,
|
||||
});
|
||||
}
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
intent: parsed,
|
||||
needsClarification: true,
|
||||
clarificationCards: parsed.clarificationCards || [],
|
||||
});
|
||||
|
||||
} catch (error: any) {
|
||||
logger.error('[SSA:API] Clarification failed', {
|
||||
sessionId,
|
||||
error: error.message
|
||||
});
|
||||
|
||||
return reply.status(500).send({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /workflow/:workflowId/execute
|
||||
* 执行工作流
|
||||
@@ -329,6 +436,73 @@ export default async function workflowRoutes(app: FastifyInstance) {
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /workflow/sessions/:sessionId/conclusion
|
||||
* 获取会话的分析结论(优先返回缓存,无缓存则从 workflow 结果重新生成)
|
||||
*/
|
||||
app.get<{ Params: { sessionId: string } }>(
|
||||
'/sessions/:sessionId/conclusion',
|
||||
async (request, reply) => {
|
||||
const { sessionId } = request.params;
|
||||
|
||||
try {
|
||||
// 查找该 session 最新的 completed workflow
|
||||
const workflow = await prisma.ssaWorkflow.findFirst({
|
||||
where: { sessionId: sessionId, status: { in: ['completed', 'partial'] } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
if (!workflow) {
|
||||
return reply.status(404).send({
|
||||
success: false,
|
||||
error: 'No completed workflow found for this session',
|
||||
});
|
||||
}
|
||||
|
||||
// 检查缓存
|
||||
const cacheKey = `ssa:conclusion:${workflow.id}`;
|
||||
const cached = await cache.get(cacheKey);
|
||||
if (cached) {
|
||||
return reply.send({ success: true, conclusion: cached, source: 'cache' });
|
||||
}
|
||||
|
||||
// 无缓存:获取 workflow steps 结果并重新生成
|
||||
const steps = await prisma.ssaWorkflowStep.findMany({
|
||||
where: { workflowId: workflow.id },
|
||||
orderBy: { stepOrder: 'asc' },
|
||||
});
|
||||
|
||||
const results = steps.map((s: any) => ({
|
||||
stepOrder: s.stepOrder,
|
||||
toolCode: s.toolCode,
|
||||
toolName: s.toolName,
|
||||
status: s.status,
|
||||
result: s.outputResult,
|
||||
reportBlocks: s.reportBlocks,
|
||||
executionMs: s.executionMs || 0,
|
||||
}));
|
||||
|
||||
const workflowPlan = workflow.workflowPlan as any;
|
||||
const conclusion = await reflectionService.reflect(
|
||||
{
|
||||
workflowId: workflow.id,
|
||||
goal: workflowPlan?.goal || '统计分析',
|
||||
title: workflowPlan?.title,
|
||||
methodology: workflowPlan?.methodology,
|
||||
plannedTrace: workflowPlan?.planned_trace,
|
||||
},
|
||||
results,
|
||||
);
|
||||
|
||||
return reply.send({ success: true, conclusion, source: conclusion.source });
|
||||
|
||||
} catch (error: any) {
|
||||
logger.error('[SSA:API] Get conclusion failed', { sessionId, error: error.message });
|
||||
return reply.status(500).send({ success: false, error: error.message });
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -11,9 +11,13 @@
|
||||
|
||||
import { logger } from '../../../common/logging/index.js';
|
||||
import { StepResult } from './WorkflowExecutorService.js';
|
||||
import type { ConclusionReport } from '../types/reflection.types.js';
|
||||
|
||||
// 结论报告结构
|
||||
export interface ConclusionReport {
|
||||
// Re-export for backward compatibility
|
||||
export type { ConclusionReport } from '../types/reflection.types.js';
|
||||
|
||||
// 旧版内部结论结构(ConclusionGeneratorService 内部使用)
|
||||
export interface LegacyConclusionReport {
|
||||
title: string;
|
||||
summary: string;
|
||||
sections: ConclusionSection[];
|
||||
@@ -40,8 +44,11 @@ export class ConclusionGeneratorService {
|
||||
* @param goal 分析目标
|
||||
* @returns 结论报告
|
||||
*/
|
||||
generateConclusion(results: StepResult[], goal: string): ConclusionReport {
|
||||
logger.info('[SSA:Conclusion] Generating conclusion', {
|
||||
/**
|
||||
* 生成新版 ConclusionReport(Phase R 统一格式)
|
||||
*/
|
||||
generateConclusion(results: StepResult[], goal: string, workflowId?: string): ConclusionReport {
|
||||
logger.info('[SSA:Conclusion] Generating rule-based conclusion', {
|
||||
stepCount: results.length,
|
||||
goal
|
||||
});
|
||||
@@ -60,17 +67,37 @@ export class ConclusionGeneratorService {
|
||||
const methodology = this.generateMethodology(results);
|
||||
const limitations = this.generateLimitations(results);
|
||||
|
||||
const significantCount = sections.filter(s => s.significance === 'significant').length;
|
||||
const methodsUsed = [...new Set(successResults.map(r => r.toolName))];
|
||||
|
||||
const report: ConclusionReport = {
|
||||
workflow_id: workflowId || '',
|
||||
title: `统计分析报告:${goal}`,
|
||||
summary,
|
||||
sections,
|
||||
methodology,
|
||||
limitations
|
||||
executive_summary: summary,
|
||||
key_findings: sections
|
||||
.filter(s => s.significance === 'significant' || s.significance === 'marginal')
|
||||
.map(s => `${s.toolName}:${s.interpretation}`),
|
||||
statistical_summary: {
|
||||
total_tests: sections.length,
|
||||
significant_results: significantCount,
|
||||
methods_used: methodsUsed,
|
||||
},
|
||||
step_summaries: sections.map(s => ({
|
||||
step_number: s.stepOrder,
|
||||
tool_name: s.toolName,
|
||||
summary: s.finding,
|
||||
p_value: s.details?.pValue,
|
||||
is_significant: s.significance === 'significant',
|
||||
})),
|
||||
recommendations: [],
|
||||
limitations,
|
||||
generated_at: new Date().toISOString(),
|
||||
source: 'rule_based' as const,
|
||||
};
|
||||
|
||||
logger.info('[SSA:Conclusion] Conclusion generated', {
|
||||
logger.info('[SSA:Conclusion] Rule-based conclusion generated', {
|
||||
sectionCount: sections.length,
|
||||
hasLimitations: limitations.length > 0
|
||||
significantCount,
|
||||
});
|
||||
|
||||
return report;
|
||||
|
||||
@@ -46,6 +46,8 @@ export interface ColumnProfile {
|
||||
minDate?: string;
|
||||
maxDate?: string;
|
||||
dateRange?: string;
|
||||
// Phase Q: 非分析列标记(由 Python DataProfiler 生成)
|
||||
isIdLike?: boolean;
|
||||
}
|
||||
|
||||
export interface DataSummary {
|
||||
|
||||
172
backend/src/modules/ssa/services/DecisionTableService.ts
Normal file
172
backend/src/modules/ssa/services/DecisionTableService.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* DecisionTableService — P 层决策表匹配
|
||||
*
|
||||
* 四维匹配:Goal × OutcomeType × PredictorType × Design → Primary + Fallback + Template
|
||||
*
|
||||
* Repository 模式:通过 ConfigLoader 加载 JSON,后期可切 DB。
|
||||
* 参数检验优先原则:Primary 始终为参数检验,Fallback 为非参数安全网。
|
||||
*/
|
||||
|
||||
import { logger } from '../../../common/logging/index.js';
|
||||
import { decisionTablesLoader } from '../config/index.js';
|
||||
import type { DecisionRule } from '../config/schemas.js';
|
||||
import type { ParsedQuery, AnalysisGoal, VariableType, StudyDesign } from '../types/query.types.js';
|
||||
|
||||
export interface MatchResult {
|
||||
rule: DecisionRule;
|
||||
primaryTool: string;
|
||||
fallbackTool: string | null;
|
||||
switchCondition: string | null;
|
||||
templateId: string;
|
||||
matchScore: number;
|
||||
}
|
||||
|
||||
export class DecisionTableService {
|
||||
|
||||
/**
|
||||
* 四维匹配 — 从决策表中找到最佳规则
|
||||
*/
|
||||
match(query: ParsedQuery): MatchResult {
|
||||
const rules = decisionTablesLoader.get();
|
||||
|
||||
const candidates = rules
|
||||
.map(rule => ({
|
||||
rule,
|
||||
score: this.scoreRule(rule, query),
|
||||
}))
|
||||
.filter(c => c.score > 0)
|
||||
.sort((a, b) => {
|
||||
if (b.score !== a.score) return b.score - a.score;
|
||||
return b.rule.priority - a.rule.priority;
|
||||
});
|
||||
|
||||
if (candidates.length === 0) {
|
||||
logger.warn('[SSA:DecisionTable] No matching rule, falling back to descriptive', {
|
||||
goal: query.goal,
|
||||
outcomeType: query.outcome_type,
|
||||
});
|
||||
return this.getDefaultMatch();
|
||||
}
|
||||
|
||||
const best = candidates[0];
|
||||
|
||||
logger.info('[SSA:DecisionTable] Rule matched', {
|
||||
ruleId: best.rule.id,
|
||||
score: best.score,
|
||||
primary: best.rule.primaryTool,
|
||||
fallback: best.rule.fallbackTool,
|
||||
template: best.rule.templateId,
|
||||
});
|
||||
|
||||
return {
|
||||
rule: best.rule,
|
||||
primaryTool: best.rule.primaryTool,
|
||||
fallbackTool: best.rule.fallbackTool,
|
||||
switchCondition: best.rule.switchCondition,
|
||||
templateId: best.rule.templateId,
|
||||
matchScore: best.score,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算规则匹配分数
|
||||
* 精确匹配得分 > 通配符匹配
|
||||
*/
|
||||
private scoreRule(rule: DecisionRule, query: ParsedQuery): number {
|
||||
let score = 0;
|
||||
|
||||
// Goal 匹配(必须)
|
||||
if (rule.goal !== query.goal && rule.goal !== '*') return 0;
|
||||
score += rule.goal === query.goal ? 4 : 1;
|
||||
|
||||
// Outcome Type 匹配
|
||||
const outcomeType = this.normalizeVariableType(query.outcome_type);
|
||||
if (rule.outcomeType !== '*') {
|
||||
if (!this.typeMatches(rule.outcomeType, outcomeType)) return 0;
|
||||
score += 3;
|
||||
} else {
|
||||
score += 1;
|
||||
}
|
||||
|
||||
// Predictor Type 匹配
|
||||
const predictorType = this.getPrimaryPredictorType(query);
|
||||
if (rule.predictorType !== '*') {
|
||||
if (!this.typeMatches(rule.predictorType, predictorType)) return 0;
|
||||
score += 2;
|
||||
} else {
|
||||
score += 1;
|
||||
}
|
||||
|
||||
// Design 匹配
|
||||
if (rule.design !== '*') {
|
||||
if (rule.design !== query.design) return 0;
|
||||
score += 2;
|
||||
} else {
|
||||
score += 1;
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
/**
|
||||
* 类型匹配(支持 binary ⊂ categorical 的包含关系)
|
||||
*/
|
||||
private typeMatches(ruleType: string, actualType: string | null): boolean {
|
||||
if (!actualType) return true;
|
||||
if (ruleType === actualType) return true;
|
||||
if (ruleType === 'categorical' && actualType === 'binary') return true;
|
||||
if (ruleType === 'binary' && actualType === 'categorical') return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 归一化变量类型到决策表维度
|
||||
*/
|
||||
private normalizeVariableType(type: VariableType | null): string | null {
|
||||
if (!type) return null;
|
||||
switch (type) {
|
||||
case 'continuous': return 'continuous';
|
||||
case 'binary': return 'binary';
|
||||
case 'categorical': return 'categorical';
|
||||
case 'ordinal': return 'categorical';
|
||||
case 'datetime': return null;
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取主要预测变量类型
|
||||
*/
|
||||
private getPrimaryPredictorType(query: ParsedQuery): string | null {
|
||||
if (query.predictor_types.length === 0) return null;
|
||||
return this.normalizeVariableType(query.predictor_types[0]);
|
||||
}
|
||||
|
||||
private getDefaultMatch(): MatchResult {
|
||||
const rules = decisionTablesLoader.get();
|
||||
const descRule = rules.find(r => r.id === 'DESC_ANY');
|
||||
const fallback: DecisionRule = descRule || {
|
||||
id: 'DESC_ANY',
|
||||
goal: 'descriptive',
|
||||
outcomeType: '*',
|
||||
predictorType: '*',
|
||||
design: '*',
|
||||
primaryTool: 'ST_DESCRIPTIVE',
|
||||
fallbackTool: null,
|
||||
switchCondition: null,
|
||||
templateId: 'descriptive_only',
|
||||
priority: 1,
|
||||
};
|
||||
|
||||
return {
|
||||
rule: fallback,
|
||||
primaryTool: fallback.primaryTool,
|
||||
fallbackTool: null,
|
||||
switchCondition: null,
|
||||
templateId: fallback.templateId,
|
||||
matchScore: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const decisionTableService = new DecisionTableService();
|
||||
255
backend/src/modules/ssa/services/FlowTemplateService.ts
Normal file
255
backend/src/modules/ssa/services/FlowTemplateService.ts
Normal file
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* FlowTemplateService — P 层流程模板填充
|
||||
*
|
||||
* 根据 DecisionTableService 的匹配结果,选择模板并填充参数。
|
||||
* 含 EPV 防护(队列研究 Table 3 自变量截断)。
|
||||
*/
|
||||
|
||||
import { logger } from '../../../common/logging/index.js';
|
||||
import { flowTemplatesLoader, toolsRegistryLoader } from '../config/index.js';
|
||||
import type { FlowTemplate, TemplateStep, ToolDefinition } from '../config/schemas.js';
|
||||
import type { MatchResult } from './DecisionTableService.js';
|
||||
import type { ParsedQuery } from '../types/query.types.js';
|
||||
import type { DataProfile } from './DataProfileService.js';
|
||||
|
||||
export interface FilledStep {
|
||||
order: number;
|
||||
role: string;
|
||||
toolCode: string;
|
||||
toolName: string;
|
||||
name: string;
|
||||
params: Record<string, any>;
|
||||
isSensitivity: boolean;
|
||||
switchCondition: string | null;
|
||||
}
|
||||
|
||||
export interface FillResult {
|
||||
templateId: string;
|
||||
templateName: string;
|
||||
steps: FilledStep[];
|
||||
epvWarning: string | null;
|
||||
}
|
||||
|
||||
const DEFAULT_EPV_RATIO = 10;
|
||||
|
||||
export class FlowTemplateService {
|
||||
|
||||
/**
|
||||
* 选择模板并填充参数
|
||||
*/
|
||||
fill(
|
||||
match: MatchResult,
|
||||
query: ParsedQuery,
|
||||
profile?: DataProfile | null
|
||||
): FillResult {
|
||||
const config = flowTemplatesLoader.get();
|
||||
const template = config.templates.find(t => t.id === match.templateId);
|
||||
|
||||
if (!template) {
|
||||
logger.warn('[SSA:FlowTemplate] Template not found, using descriptive_only', {
|
||||
templateId: match.templateId,
|
||||
});
|
||||
const fallback = config.templates.find(t => t.id === 'descriptive_only')!;
|
||||
return this.fillTemplate(fallback, match, query, profile);
|
||||
}
|
||||
|
||||
return this.fillTemplate(template, match, query, profile);
|
||||
}
|
||||
|
||||
private fillTemplate(
|
||||
template: FlowTemplate,
|
||||
match: MatchResult,
|
||||
query: ParsedQuery,
|
||||
profile?: DataProfile | null
|
||||
): FillResult {
|
||||
const toolsConfig = toolsRegistryLoader.get();
|
||||
let epvWarning: string | null = null;
|
||||
|
||||
const steps: FilledStep[] = [];
|
||||
|
||||
for (const step of template.steps) {
|
||||
// 条件步骤:fallback_exists — 如果没有 fallback 工具则跳过
|
||||
if (step.condition === 'fallback_exists' && !match.fallbackTool) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 解析工具代码(支持 {{primaryTool}} / {{fallbackTool}} 占位符)
|
||||
const toolCode = this.resolveToolCode(step.tool, match);
|
||||
const toolDef = toolsConfig.tools.find(t => t.code === toolCode);
|
||||
const toolName = toolDef?.name ?? toolCode;
|
||||
|
||||
// 填充参数
|
||||
let params: Record<string, any>;
|
||||
if (step.paramsMapping) {
|
||||
const result = this.resolveParams(step.paramsMapping, query, profile);
|
||||
params = result.params;
|
||||
if (result.epvWarning) epvWarning = result.epvWarning;
|
||||
} else {
|
||||
params = this.buildDefaultParams(toolCode, query);
|
||||
}
|
||||
|
||||
steps.push({
|
||||
order: step.order,
|
||||
role: step.role,
|
||||
toolCode,
|
||||
toolName,
|
||||
name: step.name ?? toolName,
|
||||
params,
|
||||
isSensitivity: step.role === 'sensitivity',
|
||||
switchCondition: step.role === 'sensitivity' ? match.switchCondition : null,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
templateId: template.id,
|
||||
templateName: template.name,
|
||||
steps,
|
||||
epvWarning,
|
||||
};
|
||||
}
|
||||
|
||||
private resolveToolCode(tool: string, match: MatchResult): string {
|
||||
if (tool === '{{primaryTool}}') return match.primaryTool;
|
||||
if (tool === '{{fallbackTool}}') return match.fallbackTool || match.primaryTool;
|
||||
return tool;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析参数映射中的占位符
|
||||
*/
|
||||
private resolveParams(
|
||||
mapping: Record<string, string>,
|
||||
query: ParsedQuery,
|
||||
profile?: DataProfile | null
|
||||
): { params: Record<string, any>; epvWarning: string | null } {
|
||||
const params: Record<string, any> = {};
|
||||
let epvWarning: string | null = null;
|
||||
|
||||
for (const [key, template] of Object.entries(mapping)) {
|
||||
switch (template) {
|
||||
case '{{outcome_var}}':
|
||||
params[key] = query.outcome_var;
|
||||
break;
|
||||
case '{{grouping_var}}':
|
||||
params[key] = query.grouping_var;
|
||||
break;
|
||||
case '{{all_predictors}}':
|
||||
params[key] = query.predictor_vars;
|
||||
break;
|
||||
case '{{epv_capped_predictors}}': {
|
||||
const result = this.applyEpvCap(query, profile);
|
||||
params[key] = result.predictors;
|
||||
epvWarning = result.warning;
|
||||
break;
|
||||
}
|
||||
default:
|
||||
params[key] = template;
|
||||
}
|
||||
}
|
||||
|
||||
return { params, epvWarning };
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建默认参数(非 paramsMapping 模板步骤使用)
|
||||
*/
|
||||
private buildDefaultParams(toolCode: string, query: ParsedQuery): Record<string, any> {
|
||||
switch (toolCode) {
|
||||
case 'ST_DESCRIPTIVE':
|
||||
return {
|
||||
variables: [
|
||||
...(query.outcome_var ? [query.outcome_var] : []),
|
||||
...query.predictor_vars,
|
||||
].slice(0, 10),
|
||||
group_var: query.grouping_var,
|
||||
};
|
||||
|
||||
case 'ST_T_TEST_IND':
|
||||
case 'ST_MANN_WHITNEY':
|
||||
return {
|
||||
group_var: query.grouping_var || query.predictor_vars[0],
|
||||
value_var: query.outcome_var,
|
||||
};
|
||||
|
||||
case 'ST_T_TEST_PAIRED':
|
||||
return {
|
||||
before_var: query.predictor_vars[0],
|
||||
after_var: query.outcome_var,
|
||||
};
|
||||
|
||||
case 'ST_CHI_SQUARE':
|
||||
return {
|
||||
var1: query.predictor_vars[0] || query.grouping_var,
|
||||
var2: query.outcome_var,
|
||||
};
|
||||
|
||||
case 'ST_CORRELATION':
|
||||
return {
|
||||
var_x: query.predictor_vars[0],
|
||||
var_y: query.outcome_var,
|
||||
method: 'auto',
|
||||
};
|
||||
|
||||
case 'ST_LOGISTIC_BINARY':
|
||||
return {
|
||||
outcome_var: query.outcome_var,
|
||||
predictors: query.predictor_vars,
|
||||
};
|
||||
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* EPV 防护 — 队列研究 Table 3 自变量截断
|
||||
* EPV = Events Per Variable,每个自变量至少需要 10 个事件
|
||||
*/
|
||||
private applyEpvCap(
|
||||
query: ParsedQuery,
|
||||
profile?: DataProfile | null
|
||||
): { predictors: string[]; warning: string | null } {
|
||||
const allPredictors = query.predictor_vars;
|
||||
|
||||
if (!profile || !query.outcome_var) {
|
||||
return { predictors: allPredictors, warning: null };
|
||||
}
|
||||
|
||||
const outcomeCol = profile.columns.find(
|
||||
c => c.name.toLowerCase() === query.outcome_var!.toLowerCase()
|
||||
);
|
||||
|
||||
if (!outcomeCol || outcomeCol.type !== 'categorical' || !outcomeCol.topValues) {
|
||||
return { predictors: allPredictors, warning: null };
|
||||
}
|
||||
|
||||
// 计算 EPV:min(outcome=0, outcome=1) / 10
|
||||
const counts = outcomeCol.topValues.map(v => v.count);
|
||||
const minEvents = Math.min(...counts);
|
||||
const maxVars = Math.floor(minEvents / DEFAULT_EPV_RATIO);
|
||||
|
||||
if (maxVars <= 0) {
|
||||
return {
|
||||
predictors: allPredictors.slice(0, 1),
|
||||
warning: `样本量不足(最少事件组仅 ${minEvents} 例),回归模型仅保留 1 个变量`,
|
||||
};
|
||||
}
|
||||
|
||||
if (allPredictors.length <= maxVars) {
|
||||
return { predictors: allPredictors, warning: null };
|
||||
}
|
||||
|
||||
const capped = allPredictors.slice(0, maxVars);
|
||||
const warning = `受样本量限制(EPV=${DEFAULT_EPV_RATIO},最少事件组 ${minEvents} 例),回归模型从 ${allPredictors.length} 个变量截断至 ${maxVars} 个`;
|
||||
|
||||
logger.info('[SSA:FlowTemplate] EPV cap applied', {
|
||||
original: allPredictors.length,
|
||||
capped: maxVars,
|
||||
minEvents,
|
||||
});
|
||||
|
||||
return { predictors: capped, warning };
|
||||
}
|
||||
}
|
||||
|
||||
export const flowTemplateService = new FlowTemplateService();
|
||||
457
backend/src/modules/ssa/services/QueryService.ts
Normal file
457
backend/src/modules/ssa/services/QueryService.ts
Normal file
@@ -0,0 +1,457 @@
|
||||
/**
|
||||
* SSA QueryService — Phase Q 核心服务
|
||||
*
|
||||
* 职责:用户自然语言 → LLM 意图解析 → 结构化 ParsedQuery
|
||||
*
|
||||
* 三层防御:
|
||||
* 1. LLM 调用 + jsonrepair 容错
|
||||
* 2. 动态 Zod Schema(验证列名真实性)
|
||||
* 3. Confidence 二次验证(不信 LLM 自评)
|
||||
*
|
||||
* Fallback:LLM 失败 → 旧正则匹配(WorkflowPlannerService.parseUserIntent)
|
||||
*/
|
||||
|
||||
import { logger } from '../../../common/logging/index.js';
|
||||
import { LLMFactory } from '../../../common/llm/adapters/LLMFactory.js';
|
||||
import { getPromptService } from '../../../common/prompt/index.js';
|
||||
import { prisma } from '../../../config/database.js';
|
||||
import { cache } from '../../../common/cache/index.js';
|
||||
import { jsonrepair } from 'jsonrepair';
|
||||
import type { Message } from '../../../common/llm/adapters/types.js';
|
||||
import type { DataProfile, ColumnProfile } from './DataProfileService.js';
|
||||
import { dataProfileService } from './DataProfileService.js';
|
||||
import {
|
||||
type ParsedQuery,
|
||||
type LLMIntentOutput,
|
||||
type ClarificationCard,
|
||||
type ClarificationOption,
|
||||
type PrunedProfile,
|
||||
LLMIntentOutputSchema,
|
||||
createDynamicIntentSchema,
|
||||
validateConfidence,
|
||||
} from '../types/query.types.js';
|
||||
import { AVAILABLE_TOOLS } from './WorkflowPlannerService.js';
|
||||
|
||||
const CONFIDENCE_THRESHOLD = 0.7;
|
||||
const MAX_LLM_RETRIES = 1;
|
||||
|
||||
export class QueryService {
|
||||
|
||||
/**
|
||||
* 解析用户意图(主入口)
|
||||
*
|
||||
* 流程:获取 profile(带缓存)→ LLM 解析 → Zod 校验 → confidence 验证 → 裁剪
|
||||
*/
|
||||
async parseIntent(
|
||||
userQuery: string,
|
||||
sessionId: string,
|
||||
profileOverride?: DataProfile | null
|
||||
): Promise<ParsedQuery> {
|
||||
logger.info('[SSA:Query] Parsing intent', { sessionId, queryLength: userQuery.length });
|
||||
|
||||
// Q5: 带缓存的 profile 获取
|
||||
const profile = profileOverride ?? await this.getProfileWithCache(sessionId);
|
||||
|
||||
try {
|
||||
const result = await this.llmParseIntent(userQuery, profile);
|
||||
|
||||
// Q4: 附加裁剪后的 profile 给 P 层
|
||||
if (profile && !result.needsClarification) {
|
||||
result.prunedProfile = this.pruneForPlanner(profile, result);
|
||||
}
|
||||
|
||||
logger.info('[SSA:Query] LLM intent parsed', {
|
||||
sessionId,
|
||||
goal: result.goal,
|
||||
confidence: result.confidence,
|
||||
needsClarification: result.needsClarification,
|
||||
outcomeVar: result.outcome_var,
|
||||
predictorCount: result.predictor_vars.length,
|
||||
});
|
||||
return result;
|
||||
} catch (error: any) {
|
||||
logger.warn('[SSA:Query] LLM parsing failed, falling back to regex', {
|
||||
sessionId,
|
||||
error: error.message,
|
||||
});
|
||||
return this.fallbackToRegex(userQuery, profile);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* LLM 意图解析(核心逻辑)
|
||||
*/
|
||||
private async llmParseIntent(
|
||||
userQuery: string,
|
||||
profile: DataProfile | null
|
||||
): Promise<ParsedQuery> {
|
||||
const promptService = getPromptService(prisma);
|
||||
|
||||
// 1. 准备 Prompt 变量
|
||||
const profileSummary = profile
|
||||
? this.buildProfileSummaryForPrompt(profile)
|
||||
: '(未上传数据文件)';
|
||||
|
||||
const toolList = Object.values(AVAILABLE_TOOLS)
|
||||
.map(t => `- ${t.code}: ${t.name} — ${t.description}`)
|
||||
.join('\n');
|
||||
|
||||
// 2. 获取渲染后的 Prompt
|
||||
const rendered = await promptService.get('SSA_QUERY_INTENT', {
|
||||
userQuery,
|
||||
dataProfile: profileSummary,
|
||||
availableTools: toolList,
|
||||
});
|
||||
|
||||
// 3. 调用 LLM
|
||||
const adapter = LLMFactory.getAdapter(
|
||||
(rendered.modelConfig?.model as any) || 'deepseek-v3'
|
||||
);
|
||||
|
||||
const messages: Message[] = [
|
||||
{ role: 'system', content: rendered.content },
|
||||
{ role: 'user', content: userQuery },
|
||||
];
|
||||
|
||||
let llmOutput: LLMIntentOutput | null = null;
|
||||
let lastError: Error | null = null;
|
||||
|
||||
for (let attempt = 0; attempt <= MAX_LLM_RETRIES; attempt++) {
|
||||
try {
|
||||
const response = await adapter.chat(messages, {
|
||||
temperature: rendered.modelConfig?.temperature ?? 0.3,
|
||||
maxTokens: rendered.modelConfig?.maxTokens ?? 2048,
|
||||
});
|
||||
|
||||
// 4. 三层 JSON 解析
|
||||
const raw = this.robustJsonParse(response.content);
|
||||
|
||||
// 5. Zod 校验(动态防幻觉)
|
||||
const validColumns = profile?.columns.map(c => c.name) ?? [];
|
||||
const schema = validColumns.length > 0
|
||||
? createDynamicIntentSchema(validColumns)
|
||||
: LLMIntentOutputSchema;
|
||||
|
||||
llmOutput = schema.parse(raw);
|
||||
break;
|
||||
|
||||
} catch (err: any) {
|
||||
lastError = err;
|
||||
logger.warn('[SSA:Query] LLM attempt failed', {
|
||||
attempt,
|
||||
error: err.message?.substring(0, 200),
|
||||
});
|
||||
|
||||
// 重试时在 messages 中追加纠错提示
|
||||
if (attempt < MAX_LLM_RETRIES && profile) {
|
||||
const cols = profile.columns.map(c => c.name).join(', ');
|
||||
messages.push({
|
||||
role: 'user',
|
||||
content: `你上次的输出有错误: ${err.message}。请注意:变量名必须是以下列名之一: ${cols}。请重新输出正确的 JSON。`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!llmOutput) {
|
||||
throw lastError || new Error('LLM intent parsing failed after retries');
|
||||
}
|
||||
|
||||
// 6. Confidence 二次验证
|
||||
const correctedConfidence = validateConfidence(llmOutput);
|
||||
|
||||
// 7. 构建 ParsedQuery
|
||||
const parsed: ParsedQuery = {
|
||||
...llmOutput,
|
||||
confidence: correctedConfidence,
|
||||
needsClarification: correctedConfidence < CONFIDENCE_THRESHOLD,
|
||||
};
|
||||
|
||||
// 8. 低置信度 → 生成追问卡片
|
||||
if (parsed.needsClarification && profile) {
|
||||
parsed.clarificationCards = this.generateClarificationCards(parsed, profile);
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* 三层 JSON 解析(容错)
|
||||
*/
|
||||
private robustJsonParse(text: string): unknown {
|
||||
// Layer 1: 直接解析
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch { /* continue */ }
|
||||
|
||||
// Layer 2: 提取 JSON 代码块后解析
|
||||
const codeBlockMatch = text.match(/```(?:json)?\s*([\s\S]*?)```/);
|
||||
if (codeBlockMatch) {
|
||||
try {
|
||||
return JSON.parse(codeBlockMatch[1].trim());
|
||||
} catch {
|
||||
try {
|
||||
return JSON.parse(jsonrepair(codeBlockMatch[1].trim()));
|
||||
} catch { /* continue */ }
|
||||
}
|
||||
}
|
||||
|
||||
// Layer 3: jsonrepair 修复
|
||||
try {
|
||||
return JSON.parse(jsonrepair(text));
|
||||
} catch { /* continue */ }
|
||||
|
||||
// Layer 4: 尝试从文本中提取 JSON 对象
|
||||
const jsonMatch = text.match(/\{[\s\S]*\}/);
|
||||
if (jsonMatch) {
|
||||
try {
|
||||
return JSON.parse(jsonrepair(jsonMatch[0]));
|
||||
} catch { /* continue */ }
|
||||
}
|
||||
|
||||
throw new Error('Failed to parse LLM output as JSON');
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成封闭式追问卡片
|
||||
* 基于 DataProfile 的真实数据,不靠 LLM 编造
|
||||
*/
|
||||
private generateClarificationCards(
|
||||
parsed: ParsedQuery,
|
||||
profile: DataProfile
|
||||
): ClarificationCard[] {
|
||||
const cards: ClarificationCard[] = [];
|
||||
const numericCols = profile.columns.filter(c => c.type === 'numeric');
|
||||
const categoricalCols = profile.columns.filter(c => c.type === 'categorical');
|
||||
const binaryCols = categoricalCols.filter(c => c.totalLevels === 2);
|
||||
|
||||
// 卡片 1:确认分析目标
|
||||
if (!parsed.goal || parsed.confidence < 0.5) {
|
||||
const goalOptions: ClarificationOption[] = [];
|
||||
|
||||
if (binaryCols.length > 0 && numericCols.length > 0) {
|
||||
goalOptions.push({
|
||||
label: '比较组间差异',
|
||||
value: 'comparison',
|
||||
description: `如: 比较 ${binaryCols[0].name} 两组的 ${numericCols[0].name} 差异`,
|
||||
});
|
||||
}
|
||||
if (numericCols.length >= 2) {
|
||||
goalOptions.push({
|
||||
label: '相关性分析',
|
||||
value: 'correlation',
|
||||
description: `如: 分析 ${numericCols[0].name} 和 ${numericCols[1].name} 的关系`,
|
||||
});
|
||||
}
|
||||
if (binaryCols.length > 0 && (numericCols.length + categoricalCols.length) >= 3) {
|
||||
goalOptions.push({
|
||||
label: '多因素回归',
|
||||
value: 'regression',
|
||||
description: `如: 分析影响 ${binaryCols[0].name} 的独立因素`,
|
||||
});
|
||||
}
|
||||
goalOptions.push({
|
||||
label: '描述性统计',
|
||||
value: 'descriptive',
|
||||
description: '先看看数据的基本特征',
|
||||
});
|
||||
|
||||
if (goalOptions.length > 1) {
|
||||
cards.push({
|
||||
question: '您想进行哪种分析?',
|
||||
options: goalOptions,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 卡片 2:确认结局变量
|
||||
if (parsed.goal && parsed.goal !== 'descriptive' && !parsed.outcome_var) {
|
||||
const candidates = parsed.goal === 'regression'
|
||||
? binaryCols
|
||||
: [...numericCols, ...binaryCols];
|
||||
|
||||
if (candidates.length > 0) {
|
||||
cards.push({
|
||||
question: '您想分析哪个结局指标?',
|
||||
options: candidates.slice(0, 5).map(c => ({
|
||||
label: c.name,
|
||||
value: c.name,
|
||||
description: `${c.type}${c.totalLevels ? `, ${c.totalLevels}个水平` : ''}`,
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return cards;
|
||||
}
|
||||
|
||||
/**
|
||||
* 为 LLM Prompt 构建数据画像摘要
|
||||
* 物理剔除 is_id_like 列(Phase Q 防御建议 2)
|
||||
*/
|
||||
private buildProfileSummaryForPrompt(profile: DataProfile): string {
|
||||
const { summary, columns } = profile;
|
||||
|
||||
const analysisColumns = columns.filter(c => !this.isIdLikeColumn(c, summary.totalRows));
|
||||
|
||||
const lines: string[] = [
|
||||
`## 数据概况`,
|
||||
`- 样本量: ${summary.totalRows} 行`,
|
||||
`- 变量数: ${analysisColumns.length} 列(已排除 ID/日期等非分析列)`,
|
||||
`- 整体缺失率: ${summary.overallMissingRate}%`,
|
||||
'',
|
||||
`## 变量清单(仅分析变量)`,
|
||||
];
|
||||
|
||||
for (const col of analysisColumns) {
|
||||
let desc = `- **${col.name}** [${col.type}]`;
|
||||
|
||||
if (col.missingRate > 0) {
|
||||
desc += ` (缺失 ${col.missingRate}%)`;
|
||||
}
|
||||
|
||||
if (col.type === 'numeric') {
|
||||
desc += `: 均值=${col.mean}, SD=${col.std}, 范围=[${col.min}, ${col.max}]`;
|
||||
} else if (col.type === 'categorical') {
|
||||
const levels = col.topValues?.slice(0, 5).map(v => v.value).join(', ');
|
||||
desc += `: ${col.totalLevels}个水平 (${levels}${col.totalLevels && col.totalLevels > 5 ? '...' : ''})`;
|
||||
}
|
||||
|
||||
lines.push(desc);
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为非分析列(ID / 日期 / 高基数字符串)
|
||||
* 优先使用 Python DataProfiler 的 isIdLike 标记,缺失时本地推断
|
||||
*/
|
||||
private isIdLikeColumn(col: ColumnProfile, totalRows: number): boolean {
|
||||
if (col.isIdLike !== undefined) return col.isIdLike;
|
||||
|
||||
const name = col.name.toLowerCase();
|
||||
|
||||
if (/(_id|_no|编号|序号|id$|^id_)/.test(name)) {
|
||||
return true;
|
||||
}
|
||||
if (col.type === 'datetime') {
|
||||
return true;
|
||||
}
|
||||
if (col.type === 'text' || (col.type === 'categorical' && col.uniqueCount > 0)) {
|
||||
if (totalRows > 0 && col.uniqueCount / totalRows > 0.95) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────
|
||||
// Q4: Context Pruning — Q→P 层最小子集
|
||||
// ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 裁剪 DataProfile 为 Planner 需要的最小 Hot Context
|
||||
* 全量列的类型信息保留(轻量),只有 Y/X 变量保留详细统计
|
||||
*/
|
||||
pruneForPlanner(fullProfile: DataProfile, parsed: ParsedQuery): PrunedProfile {
|
||||
const relevantVars = new Set<string>();
|
||||
if (parsed.outcome_var) relevantVars.add(parsed.outcome_var);
|
||||
parsed.predictor_vars.forEach(v => relevantVars.add(v));
|
||||
if (parsed.grouping_var) relevantVars.add(parsed.grouping_var);
|
||||
|
||||
return {
|
||||
schema: fullProfile.columns.map(c => ({ name: c.name, type: c.type })),
|
||||
details: fullProfile.columns.filter(c => relevantVars.has(c.name)),
|
||||
sampleSize: fullProfile.summary.totalRows,
|
||||
missingRateSummary: fullProfile.summary.overallMissingRate,
|
||||
};
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────
|
||||
// Q5: DataProfile 会话级缓存
|
||||
// ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 获取 DataProfile,带会话级缓存
|
||||
* key: ssa:profile:{sessionId}
|
||||
* 同一会话+同一文件的后续 Q 层循环直接读缓存
|
||||
*/
|
||||
async getProfileWithCache(sessionId: string): Promise<DataProfile | null> {
|
||||
const cacheKey = `ssa:profile:${sessionId}`;
|
||||
|
||||
// 1. 查内存缓存
|
||||
const cached = await cache.get<DataProfile>(cacheKey);
|
||||
if (cached) {
|
||||
logger.debug('[SSA:Query] Profile cache hit', { sessionId });
|
||||
return cached;
|
||||
}
|
||||
|
||||
// 2. 查数据库(Prisma 存储)
|
||||
const dbProfile = await dataProfileService.getCachedProfile(sessionId);
|
||||
if (dbProfile) {
|
||||
// 写入内存缓存(30 分钟 TTL)
|
||||
await cache.set(cacheKey, dbProfile, 1800);
|
||||
logger.debug('[SSA:Query] Profile loaded from DB, cached', { sessionId });
|
||||
return dbProfile;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 正则 Fallback — 复用 WorkflowPlannerService 的旧逻辑
|
||||
*/
|
||||
private fallbackToRegex(userQuery: string, profile: DataProfile | null): ParsedQuery {
|
||||
const query = userQuery.toLowerCase();
|
||||
|
||||
let goal: ParsedQuery['goal'] = 'descriptive';
|
||||
let design: ParsedQuery['design'] = 'independent';
|
||||
|
||||
if (query.includes('比较') || query.includes('差异') || query.includes('不同') || query.includes('有没有效')) {
|
||||
goal = 'comparison';
|
||||
} else if (query.includes('相关') || query.includes('关系') || query.includes('关联')) {
|
||||
goal = 'correlation';
|
||||
} else if (query.includes('影响') || query.includes('因素') || query.includes('预测') || query.includes('回归')) {
|
||||
goal = 'regression';
|
||||
} else if (query.includes('队列') || query.includes('cohort')) {
|
||||
goal = 'cohort_study';
|
||||
}
|
||||
|
||||
if (query.includes('前后') || query.includes('配对') || query.includes('变化')) {
|
||||
design = 'paired';
|
||||
}
|
||||
|
||||
// 尝试从查询中匹配列名
|
||||
let outcomeVar: string | null = null;
|
||||
const predictorVars: string[] = [];
|
||||
|
||||
if (profile) {
|
||||
for (const col of profile.columns) {
|
||||
if (query.includes(col.name.toLowerCase())) {
|
||||
if (!outcomeVar) {
|
||||
outcomeVar = col.name;
|
||||
} else {
|
||||
predictorVars.push(col.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
goal,
|
||||
outcome_var: outcomeVar,
|
||||
outcome_type: null,
|
||||
predictor_vars: predictorVars,
|
||||
predictor_types: [],
|
||||
grouping_var: profile?.columns.find(c => c.type === 'categorical' && c.totalLevels === 2)?.name ?? null,
|
||||
design,
|
||||
confidence: 0.5,
|
||||
reasoning: '(正则 fallback 模式)',
|
||||
needsClarification: !outcomeVar && goal !== 'descriptive',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const queryService = new QueryService();
|
||||
341
backend/src/modules/ssa/services/ReflectionService.ts
Normal file
341
backend/src/modules/ssa/services/ReflectionService.ts
Normal file
@@ -0,0 +1,341 @@
|
||||
/**
|
||||
* SSA ReflectionService — Phase R 核心服务
|
||||
*
|
||||
* 职责:StepResult[] → LLM 论文级结论 → ConclusionReport
|
||||
*
|
||||
* 三层防御:
|
||||
* 1. 统计量槽位注入(LLM 只生成叙述框架,数值从 R 输出渲染)
|
||||
* 2. jsonrepair + Zod 强校验 LLM 输出结构
|
||||
* 3. 降级到 ConclusionGeneratorService(规则拼接)
|
||||
*
|
||||
* 交付策略:完整 JSON 收集 + Zod 校验 → 一次性 SSE 推送(不做字符流)
|
||||
*/
|
||||
|
||||
import { logger } from '../../../common/logging/index.js';
|
||||
import { LLMFactory } from '../../../common/llm/adapters/LLMFactory.js';
|
||||
import { getPromptService } from '../../../common/prompt/index.js';
|
||||
import { prisma } from '../../../config/database.js';
|
||||
import { cache } from '../../../common/cache/index.js';
|
||||
import { jsonrepair } from 'jsonrepair';
|
||||
import type { Message } from '../../../common/llm/adapters/types.js';
|
||||
import type { StepResult } from './WorkflowExecutorService.js';
|
||||
import { conclusionGeneratorService } from './ConclusionGeneratorService.js';
|
||||
import {
|
||||
LLMConclusionSchema,
|
||||
type ConclusionReport,
|
||||
type StepFinding,
|
||||
} from '../types/reflection.types.js';
|
||||
|
||||
const CACHE_TTL = 3600; // 1 hour
|
||||
const LLM_MODEL = 'deepseek-v3';
|
||||
const LLM_TEMPERATURE = 0.3;
|
||||
const LLM_MAX_TOKENS = 4096;
|
||||
|
||||
interface PlannedTraceInput {
|
||||
matchedRule?: string;
|
||||
primaryTool?: string;
|
||||
fallbackTool?: string | null;
|
||||
switchCondition?: string | null;
|
||||
reasoning?: string;
|
||||
epvWarning?: string | null;
|
||||
}
|
||||
|
||||
interface ReflectInput {
|
||||
workflowId: string;
|
||||
goal: string;
|
||||
title?: string;
|
||||
methodology?: string;
|
||||
sampleInfo?: string;
|
||||
plannedTrace?: PlannedTraceInput;
|
||||
}
|
||||
|
||||
export class ReflectionService {
|
||||
|
||||
/**
|
||||
* 生成论文级结论(主入口)
|
||||
*
|
||||
* 流程:缓存检查 → 提取 keyFindings → 组装 Prompt → LLM 调用 → Zod 校验 → fallback
|
||||
*/
|
||||
async reflect(
|
||||
input: ReflectInput,
|
||||
results: StepResult[],
|
||||
): Promise<ConclusionReport> {
|
||||
const { workflowId, goal } = input;
|
||||
|
||||
logger.info('[SSA:Reflection] Starting reflection', {
|
||||
workflowId,
|
||||
goal,
|
||||
stepCount: results.length,
|
||||
});
|
||||
|
||||
// 0. Cache hit check
|
||||
const cacheKey = `ssa:conclusion:${workflowId}`;
|
||||
try {
|
||||
const cached = await cache.get<ConclusionReport>(cacheKey);
|
||||
if (cached) {
|
||||
logger.info('[SSA:Reflection] Cache hit', { workflowId });
|
||||
return cached;
|
||||
}
|
||||
} catch {
|
||||
// cache miss, continue
|
||||
}
|
||||
|
||||
// 1. Extract key findings from step results (slot injection)
|
||||
const findings = this.extractKeyFindings(results);
|
||||
|
||||
// 2. Build prompt via PromptService
|
||||
const prompt = await this.buildPrompt(input, findings);
|
||||
if (!prompt) {
|
||||
logger.warn('[SSA:Reflection] Failed to build prompt, falling back to rule-based');
|
||||
return this.fallback(workflowId, results, goal);
|
||||
}
|
||||
|
||||
// 3. Call LLM (full collection, no streaming)
|
||||
try {
|
||||
const llm = LLMFactory.getAdapter(LLM_MODEL);
|
||||
const messages: Message[] = [
|
||||
{ role: 'system', content: 'You are a senior biostatistician. Output only valid JSON.' },
|
||||
{ role: 'user', content: prompt },
|
||||
];
|
||||
|
||||
logger.info('[SSA:Reflection] Calling LLM', { model: LLM_MODEL });
|
||||
const response = await llm.chat(messages, {
|
||||
temperature: LLM_TEMPERATURE,
|
||||
maxTokens: LLM_MAX_TOKENS,
|
||||
});
|
||||
|
||||
const rawOutput = response.content;
|
||||
logger.info('[SSA:Reflection] LLM response received', {
|
||||
contentLength: rawOutput.length,
|
||||
usage: response.usage,
|
||||
});
|
||||
|
||||
// 4. jsonrepair + Zod validation
|
||||
const report = this.parseAndValidate(rawOutput, workflowId, input, findings, results);
|
||||
|
||||
// 5. Cache the result
|
||||
try {
|
||||
await cache.set(cacheKey, report, CACHE_TTL);
|
||||
} catch (cacheErr) {
|
||||
logger.warn('[SSA:Reflection] Cache set failed', { error: String(cacheErr) });
|
||||
}
|
||||
|
||||
logger.info('[SSA:Reflection] LLM conclusion generated successfully', {
|
||||
workflowId,
|
||||
source: 'llm',
|
||||
keyFindingsCount: report.key_findings.length,
|
||||
});
|
||||
|
||||
return report;
|
||||
|
||||
} catch (error: any) {
|
||||
logger.warn('[SSA:Reflection] LLM call failed, falling back to rule-based', {
|
||||
workflowId,
|
||||
error: error.message,
|
||||
});
|
||||
return this.fallback(workflowId, results, goal);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 StepResult[] 中提取关键统计量(槽位注入数据源)
|
||||
*/
|
||||
extractKeyFindings(results: StepResult[]): StepFinding[] {
|
||||
const findings: StepFinding[] = [];
|
||||
|
||||
for (const r of results) {
|
||||
if (r.status !== 'success' && r.status !== 'warning') continue;
|
||||
|
||||
const data = r.result || {};
|
||||
const finding: StepFinding = {
|
||||
step_number: r.stepOrder,
|
||||
tool_name: r.toolName,
|
||||
tool_code: r.toolCode,
|
||||
method: data.method || r.toolName,
|
||||
is_significant: data.p_value != null && data.p_value < 0.05,
|
||||
raw_result: data,
|
||||
};
|
||||
|
||||
// P value
|
||||
if (data.p_value != null) {
|
||||
finding.p_value_num = data.p_value;
|
||||
finding.p_value = data.p_value_fmt || this.formatPValue(data.p_value);
|
||||
}
|
||||
|
||||
// Statistic
|
||||
if (data.statistic != null) {
|
||||
finding.statistic = String(Number(data.statistic).toFixed(3));
|
||||
finding.statistic_name = this.getStatisticName(r.toolCode);
|
||||
}
|
||||
if (data.statistic_U != null) {
|
||||
finding.statistic = String(Number(data.statistic_U).toFixed(1));
|
||||
finding.statistic_name = 'U';
|
||||
}
|
||||
|
||||
// Effect size
|
||||
if (data.effect_size?.cohens_d != null) {
|
||||
finding.effect_size = String(Number(data.effect_size.cohens_d).toFixed(3));
|
||||
finding.effect_size_name = "Cohen's d";
|
||||
} else if (data.effect_size?.cramers_v != null) {
|
||||
finding.effect_size = String(Number(data.effect_size.cramers_v).toFixed(3));
|
||||
finding.effect_size_name = "Cramér's V";
|
||||
} else if (data.effect_size?.r_squared != null) {
|
||||
finding.effect_size = String(Number(data.effect_size.r_squared).toFixed(3));
|
||||
finding.effect_size_name = 'R²';
|
||||
}
|
||||
|
||||
// Confidence interval
|
||||
if (data.conf_int && Array.isArray(data.conf_int) && data.conf_int.length >= 2) {
|
||||
finding.ci_lower = String(Number(data.conf_int[0]).toFixed(3));
|
||||
finding.ci_upper = String(Number(data.conf_int[1]).toFixed(3));
|
||||
}
|
||||
|
||||
// Group stats
|
||||
if (data.group_stats && Array.isArray(data.group_stats)) {
|
||||
finding.group_stats = data.group_stats.map((g: any) => ({
|
||||
group: g.group || g.level || 'unknown',
|
||||
n: g.n || 0,
|
||||
mean: g.mean != null ? Number(Number(g.mean).toFixed(2)) : undefined,
|
||||
sd: g.sd != null ? Number(Number(g.sd).toFixed(2)) : undefined,
|
||||
median: g.median != null ? Number(Number(g.median).toFixed(2)) : undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
findings.push(finding);
|
||||
}
|
||||
|
||||
return findings;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建 Prompt(通过 PromptService 从数据库加载模板)
|
||||
*/
|
||||
private async buildPrompt(
|
||||
input: ReflectInput,
|
||||
findings: StepFinding[],
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const promptService = getPromptService(prisma);
|
||||
const rendered = await promptService.get('SSA_REFLECTION', {
|
||||
goal: input.goal,
|
||||
title: input.title || `统计分析:${input.goal}`,
|
||||
methodology: input.methodology || '系统自动选择',
|
||||
sampleInfo: input.sampleInfo || '见各步骤详情',
|
||||
decision_trace: {
|
||||
matched_rule: input.plannedTrace?.matchedRule || '默认规则',
|
||||
primary_tool: input.plannedTrace?.primaryTool || '',
|
||||
fallback_tool: input.plannedTrace?.fallbackTool || null,
|
||||
switch_condition: input.plannedTrace?.switchCondition || null,
|
||||
reasoning: input.plannedTrace?.reasoning || '',
|
||||
epv_warning: input.plannedTrace?.epvWarning || null,
|
||||
},
|
||||
findings: findings.map(f => ({
|
||||
...f,
|
||||
group_stats: f.group_stats || [],
|
||||
})),
|
||||
});
|
||||
|
||||
return rendered.content;
|
||||
} catch (error: any) {
|
||||
logger.error('[SSA:Reflection] Failed to build prompt', { error: error.message });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 LLM 输出 → jsonrepair → Zod 校验
|
||||
*/
|
||||
private parseAndValidate(
|
||||
rawOutput: string,
|
||||
workflowId: string,
|
||||
input: ReflectInput,
|
||||
findings: StepFinding[],
|
||||
results: StepResult[],
|
||||
): ConclusionReport {
|
||||
// Strip markdown code fences if present
|
||||
let cleaned = rawOutput.trim();
|
||||
if (cleaned.startsWith('```')) {
|
||||
cleaned = cleaned.replace(/^```(?:json)?\s*\n?/, '').replace(/\n?```\s*$/, '');
|
||||
}
|
||||
|
||||
// Layer 1: jsonrepair
|
||||
const repaired = jsonrepair(cleaned);
|
||||
|
||||
// Layer 2: JSON.parse
|
||||
const parsed = JSON.parse(repaired);
|
||||
|
||||
// Layer 3: Zod validation
|
||||
const validated = LLMConclusionSchema.parse(parsed);
|
||||
|
||||
// Assemble full ConclusionReport
|
||||
return {
|
||||
workflow_id: workflowId,
|
||||
title: input.title || `统计分析报告:${input.goal}`,
|
||||
executive_summary: validated.executive_summary,
|
||||
key_findings: validated.key_findings,
|
||||
statistical_summary: validated.statistical_summary,
|
||||
step_summaries: this.buildStepSummaries(findings),
|
||||
recommendations: validated.recommendations || [],
|
||||
limitations: validated.limitations,
|
||||
generated_at: new Date().toISOString(),
|
||||
source: 'llm',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 降级到规则拼接
|
||||
*/
|
||||
private fallback(
|
||||
workflowId: string,
|
||||
results: StepResult[],
|
||||
goal: string,
|
||||
): ConclusionReport {
|
||||
logger.info('[SSA:Reflection] Using rule-based fallback', { workflowId });
|
||||
return conclusionGeneratorService.generateConclusion(results, goal, workflowId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 findings 构建 step_summaries
|
||||
*/
|
||||
private buildStepSummaries(findings: StepFinding[]): ConclusionReport['step_summaries'] {
|
||||
return findings.map(f => ({
|
||||
step_number: f.step_number,
|
||||
tool_name: f.tool_name,
|
||||
summary: this.buildStepSummaryText(f),
|
||||
p_value: f.p_value_num,
|
||||
is_significant: f.is_significant,
|
||||
}));
|
||||
}
|
||||
|
||||
private buildStepSummaryText(f: StepFinding): string {
|
||||
const parts: string[] = [];
|
||||
if (f.statistic) parts.push(`${f.statistic_name || '统计量'} = ${f.statistic}`);
|
||||
if (f.p_value) parts.push(`P ${f.p_value}`);
|
||||
if (f.effect_size) parts.push(`${f.effect_size_name || '效应量'} = ${f.effect_size}`);
|
||||
return parts.length > 0 ? parts.join(', ') : `${f.tool_name} 分析完成`;
|
||||
}
|
||||
|
||||
private formatPValue(p: number): string {
|
||||
if (p < 0.001) return '< 0.001';
|
||||
if (p < 0.01) return `= ${p.toFixed(3)}`;
|
||||
return `= ${p.toFixed(3)}`;
|
||||
}
|
||||
|
||||
private getStatisticName(toolCode: string): string {
|
||||
const map: Record<string, string> = {
|
||||
'ST_T_TEST_IND': 't',
|
||||
'ST_T_TEST_PAIRED': 't',
|
||||
'ST_MANN_WHITNEY': 'U',
|
||||
'ST_WILCOXON': 'W',
|
||||
'ST_CHI_SQUARE': 'χ²',
|
||||
'ST_FISHER': 'OR',
|
||||
'ST_ANOVA_ONE': 'F',
|
||||
'ST_CORRELATION': 'r',
|
||||
'ST_LINEAR_REG': 'F',
|
||||
'ST_LOGISTIC_BINARY': 'χ²',
|
||||
};
|
||||
return map[toolCode] || '统计量';
|
||||
}
|
||||
}
|
||||
|
||||
export const reflectionService = new ReflectionService();
|
||||
@@ -17,7 +17,10 @@ import { logger } from '../../../common/logging/index.js';
|
||||
import { prisma } from '../../../config/database.js';
|
||||
import { storage } from '../../../common/storage/index.js';
|
||||
import { WorkflowStep, ToolCode, AVAILABLE_TOOLS } from './WorkflowPlannerService.js';
|
||||
import { conclusionGeneratorService, ConclusionReport } from './ConclusionGeneratorService.js';
|
||||
import { conclusionGeneratorService } from './ConclusionGeneratorService.js';
|
||||
import { reflectionService } from './ReflectionService.js';
|
||||
import type { ConclusionReport } from '../types/reflection.types.js';
|
||||
import { classifyRError } from '../types/reflection.types.js';
|
||||
|
||||
// 步骤执行结果
|
||||
export interface StepResult {
|
||||
@@ -26,6 +29,7 @@ export interface StepResult {
|
||||
toolName: string;
|
||||
status: 'success' | 'warning' | 'error' | 'skipped';
|
||||
result?: any;
|
||||
reportBlocks?: ReportBlock[];
|
||||
guardrailChecks?: GuardrailCheck[];
|
||||
error?: {
|
||||
code: string;
|
||||
@@ -35,6 +39,19 @@ export interface StepResult {
|
||||
executionMs: number;
|
||||
}
|
||||
|
||||
// Block-based 输出协议(与 R 端 block_helpers.R 对应)
|
||||
export interface ReportBlock {
|
||||
type: 'markdown' | 'table' | 'image' | 'key_value';
|
||||
title?: string;
|
||||
content?: string; // markdown
|
||||
headers?: string[]; // table
|
||||
rows?: any[][]; // table
|
||||
footnote?: string; // table
|
||||
data?: string; // image (base64 data URI)
|
||||
alt?: string; // image
|
||||
items?: { key: string; value: string }[]; // key_value
|
||||
}
|
||||
|
||||
// 护栏检查结果
|
||||
export interface GuardrailCheck {
|
||||
checkName: string;
|
||||
@@ -55,6 +72,7 @@ export interface SSEMessage {
|
||||
progress?: number;
|
||||
durationMs?: number;
|
||||
result?: any;
|
||||
reportBlocks?: ReportBlock[];
|
||||
error?: {
|
||||
code: string;
|
||||
message: string;
|
||||
@@ -71,6 +89,7 @@ export interface WorkflowExecutionResult {
|
||||
completedSteps: number;
|
||||
successSteps: number;
|
||||
results: StepResult[];
|
||||
reportBlocks?: ReportBlock[];
|
||||
conclusion?: ConclusionReport;
|
||||
executionMs: number;
|
||||
}
|
||||
@@ -175,7 +194,7 @@ export class WorkflowExecutorService extends EventEmitter {
|
||||
previousResults = stepResult.result;
|
||||
}
|
||||
|
||||
// 发送 SSE 消息
|
||||
// 发送 SSE 消息(report_blocks 同时以顶层字段推送,方便前端直接消费)
|
||||
this.emitProgress({
|
||||
type: stepResult.status === 'error' ? 'step_error' : 'step_complete',
|
||||
step: step.stepOrder,
|
||||
@@ -187,6 +206,7 @@ export class WorkflowExecutorService extends EventEmitter {
|
||||
? `${step.toolName} 执行失败: ${stepResult.error?.message}`
|
||||
: `${step.toolName} 执行完成`,
|
||||
result: stepResult.result,
|
||||
reportBlocks: stepResult.reportBlocks,
|
||||
durationMs: stepResult.executionMs,
|
||||
error: stepResult.error,
|
||||
timestamp: new Date().toISOString()
|
||||
@@ -234,14 +254,30 @@ export class WorkflowExecutorService extends EventEmitter {
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
// 生成综合结论
|
||||
// 生成综合结论(Phase R:优先 LLM,失败降级到规则拼接)
|
||||
let conclusion: ConclusionReport | undefined;
|
||||
if (successCount > 0) {
|
||||
const workflowPlan = workflow.workflowPlan as any;
|
||||
conclusion = conclusionGeneratorService.generateConclusion(
|
||||
results,
|
||||
workflowPlan?.goal || '统计分析'
|
||||
);
|
||||
const goal = workflowPlan?.goal || '统计分析';
|
||||
try {
|
||||
conclusion = await reflectionService.reflect(
|
||||
{
|
||||
workflowId,
|
||||
goal,
|
||||
title: workflowPlan?.title,
|
||||
methodology: workflowPlan?.methodology,
|
||||
sampleInfo: workflowPlan?.sampleInfo,
|
||||
plannedTrace: workflowPlan?.planned_trace,
|
||||
},
|
||||
results,
|
||||
);
|
||||
} catch (reflectErr: any) {
|
||||
logger.warn('[SSA:Executor] ReflectionService failed, using rule-based fallback', {
|
||||
workflowId,
|
||||
error: reflectErr.message,
|
||||
});
|
||||
conclusion = conclusionGeneratorService.generateConclusion(results, goal, workflowId);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('[SSA:Executor] Workflow execution finished', {
|
||||
@@ -253,6 +289,14 @@ export class WorkflowExecutorService extends EventEmitter {
|
||||
hasConclusion: !!conclusion
|
||||
});
|
||||
|
||||
// 聚合所有步骤的 reportBlocks(按步骤顺序拼接)
|
||||
const allReportBlocks = results.reduce<ReportBlock[]>((acc, r) => {
|
||||
if (r.reportBlocks?.length) {
|
||||
acc.push(...r.reportBlocks);
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
return {
|
||||
workflowId,
|
||||
status: finalStatus,
|
||||
@@ -260,6 +304,7 @@ export class WorkflowExecutorService extends EventEmitter {
|
||||
completedSteps: results.length,
|
||||
successSteps: successCount,
|
||||
results,
|
||||
reportBlocks: allReportBlocks.length > 0 ? allReportBlocks : undefined,
|
||||
conclusion,
|
||||
executionMs
|
||||
};
|
||||
@@ -363,6 +408,8 @@ export class WorkflowExecutorService extends EventEmitter {
|
||||
const executionMs = Date.now() - startTime;
|
||||
|
||||
if (response.data.status === 'error' || response.data.status === 'blocked') {
|
||||
const rMsg = response.data.message || '执行失败';
|
||||
const classified = classifyRError(rMsg);
|
||||
return {
|
||||
stepOrder: step.stepOrder,
|
||||
toolCode: step.toolCode,
|
||||
@@ -370,14 +417,18 @@ export class WorkflowExecutorService extends EventEmitter {
|
||||
status: 'error',
|
||||
guardrailChecks,
|
||||
error: {
|
||||
code: response.data.error_code || 'E100',
|
||||
message: response.data.message || '执行失败',
|
||||
userHint: response.data.user_hint || '请检查数据和参数'
|
||||
code: response.data.error_code || classified.code,
|
||||
message: rMsg,
|
||||
userHint: response.data.user_hint || classified.userHint,
|
||||
},
|
||||
executionMs
|
||||
};
|
||||
}
|
||||
|
||||
const reportBlocks: ReportBlock[] | undefined = response.data.report_blocks?.length > 0
|
||||
? response.data.report_blocks
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
stepOrder: step.stepOrder,
|
||||
toolCode: step.toolCode,
|
||||
@@ -386,22 +437,26 @@ export class WorkflowExecutorService extends EventEmitter {
|
||||
result: {
|
||||
...response.data.results,
|
||||
plots: response.data.plots,
|
||||
report_blocks: response.data.report_blocks,
|
||||
result_table: response.data.result_table,
|
||||
reproducible_code: response.data.reproducible_code,
|
||||
trace_log: response.data.trace_log,
|
||||
warnings: response.data.warnings,
|
||||
},
|
||||
reportBlocks,
|
||||
guardrailChecks,
|
||||
executionMs
|
||||
};
|
||||
|
||||
} catch (error: any) {
|
||||
const executionMs = Date.now() - startTime;
|
||||
const classified = classifyRError(error.message || '');
|
||||
|
||||
logger.error('[SSA:Executor] Step execution failed', {
|
||||
step: step.stepOrder,
|
||||
toolCode: step.toolCode,
|
||||
error: error.message
|
||||
error: error.message,
|
||||
classifiedCode: classified.code,
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -410,9 +465,9 @@ export class WorkflowExecutorService extends EventEmitter {
|
||||
toolName: step.toolName,
|
||||
status: 'error',
|
||||
error: {
|
||||
code: 'E100',
|
||||
code: classified.code,
|
||||
message: error.message,
|
||||
userHint: '执行过程中发生错误,请重试'
|
||||
userHint: classified.userHint,
|
||||
},
|
||||
executionMs
|
||||
};
|
||||
|
||||
@@ -12,6 +12,11 @@
|
||||
import { logger } from '../../../common/logging/index.js';
|
||||
import { prisma } from '../../../config/database.js';
|
||||
import { DataProfile, dataProfileService } from './DataProfileService.js';
|
||||
import { queryService } from './QueryService.js';
|
||||
import { decisionTableService, type MatchResult } from './DecisionTableService.js';
|
||||
import { flowTemplateService, type FilledStep, type FillResult } from './FlowTemplateService.js';
|
||||
import { toolsRegistryLoader } from '../config/index.js';
|
||||
import type { ParsedQuery } from '../types/query.types.js';
|
||||
|
||||
// 可用工具定义
|
||||
export const AVAILABLE_TOOLS = {
|
||||
@@ -77,6 +82,17 @@ export const AVAILABLE_TOOLS = {
|
||||
|
||||
export type ToolCode = keyof typeof AVAILABLE_TOOLS;
|
||||
|
||||
/** P 层策略日志 — 记录规划决策,供 R 层合并 E 层事实后生成方法学说明 */
|
||||
export interface PlannedTrace {
|
||||
matchedRule: string;
|
||||
primaryTool: string;
|
||||
fallbackTool: string | null;
|
||||
switchCondition: string | null;
|
||||
templateUsed: string;
|
||||
reasoning: string;
|
||||
epvWarning: string | null;
|
||||
}
|
||||
|
||||
// 工作流步骤
|
||||
export interface WorkflowStep {
|
||||
stepOrder: number;
|
||||
@@ -109,9 +125,13 @@ export interface WorkflowPlan {
|
||||
description: string;
|
||||
params: Record<string, unknown>;
|
||||
depends_on?: number[];
|
||||
is_sensitivity?: boolean;
|
||||
switch_condition?: string | null;
|
||||
}>;
|
||||
estimated_time_seconds?: number;
|
||||
created_at: string;
|
||||
planned_trace?: PlannedTrace;
|
||||
epv_warning?: string | null;
|
||||
}
|
||||
|
||||
// 用户意图解析结果
|
||||
@@ -151,53 +171,151 @@ export class WorkflowPlannerService {
|
||||
profile = await dataProfileService.getCachedProfile(sessionId) || undefined;
|
||||
}
|
||||
|
||||
// 解析用户意图
|
||||
const intent = this.parseUserIntent(userQuery, profile);
|
||||
|
||||
// 根据意图生成工作流
|
||||
const steps = this.generateSteps(intent, profile);
|
||||
|
||||
// Phase Q: LLM 意图理解
|
||||
let parsedQuery: ParsedQuery;
|
||||
try {
|
||||
parsedQuery = await queryService.parseIntent(userQuery, sessionId, profile || null);
|
||||
logger.info('[SSA:Planner] LLM intent parsed', { goal: parsedQuery.goal, confidence: parsedQuery.confidence });
|
||||
} catch (error: any) {
|
||||
logger.warn('[SSA:Planner] QueryService failed, using regex fallback', { error: error.message });
|
||||
parsedQuery = queryService['fallbackToRegex'](userQuery, profile || null);
|
||||
}
|
||||
|
||||
// Phase P: 决策表匹配 → 流程模板填充(配置驱动,不写 if-else)
|
||||
const match = decisionTableService.match(parsedQuery);
|
||||
const fillResult = flowTemplateService.fill(match, parsedQuery, profile);
|
||||
|
||||
// 构建 PlannedTrace(策略日志)
|
||||
const plannedTrace: PlannedTrace = {
|
||||
matchedRule: `Goal=${parsedQuery.goal}, Y=${parsedQuery.outcome_type || '*'}, X=${parsedQuery.predictor_types[0] || '*'}, Design=${parsedQuery.design}`,
|
||||
primaryTool: match.primaryTool,
|
||||
fallbackTool: match.fallbackTool,
|
||||
switchCondition: match.switchCondition,
|
||||
templateUsed: fillResult.templateId,
|
||||
reasoning: this.buildPlanReasoning(match, fillResult, parsedQuery),
|
||||
epvWarning: fillResult.epvWarning,
|
||||
};
|
||||
|
||||
// 转换为 WorkflowStep(兼容旧的 saveWorkflow 格式)
|
||||
const workflowSteps: WorkflowStep[] = fillResult.steps.map((s, i) => ({
|
||||
stepOrder: s.order,
|
||||
toolCode: s.toolCode as ToolCode,
|
||||
toolName: s.toolName,
|
||||
inputParams: s.params,
|
||||
purpose: s.name,
|
||||
dependsOn: i > 0 ? [fillResult.steps[i - 1].order] : undefined,
|
||||
}));
|
||||
|
||||
// 构建内部计划
|
||||
const internalPlan: WorkflowPlanInternal = {
|
||||
goal: intent.goal,
|
||||
reasoning: this.generateReasoning(intent, steps),
|
||||
steps,
|
||||
estimatedDuration: this.estimateDuration(steps)
|
||||
goal: parsedQuery.goal,
|
||||
reasoning: plannedTrace.reasoning,
|
||||
steps: workflowSteps,
|
||||
estimatedDuration: this.estimateDuration(workflowSteps),
|
||||
};
|
||||
|
||||
// 保存到数据库
|
||||
const workflowId = await this.saveWorkflow(sessionId, internalPlan);
|
||||
|
||||
logger.info('[SSA:Planner] Workflow planned', {
|
||||
logger.info('[SSA:Planner] Workflow planned (config-driven)', {
|
||||
sessionId,
|
||||
stepCount: steps.length,
|
||||
tools: steps.map(s => s.toolCode)
|
||||
stepCount: workflowSteps.length,
|
||||
tools: workflowSteps.map(s => s.toolCode),
|
||||
template: fillResult.templateId,
|
||||
rule: match.rule.id,
|
||||
});
|
||||
|
||||
// 转换为前端期望的格式
|
||||
const plan: WorkflowPlan = {
|
||||
workflow_id: workflowId,
|
||||
session_id: sessionId,
|
||||
title: intent.goal,
|
||||
description: internalPlan.reasoning,
|
||||
total_steps: steps.length,
|
||||
steps: steps.map(s => ({
|
||||
step_number: s.stepOrder,
|
||||
title: fillResult.templateName,
|
||||
description: plannedTrace.reasoning,
|
||||
total_steps: fillResult.steps.length,
|
||||
steps: fillResult.steps.map((s, i) => ({
|
||||
step_number: s.order,
|
||||
tool_code: s.toolCode,
|
||||
tool_name: s.toolName,
|
||||
description: s.purpose,
|
||||
params: s.inputParams,
|
||||
depends_on: s.dependsOn
|
||||
description: s.name,
|
||||
params: s.params,
|
||||
depends_on: i > 0 ? [fillResult.steps[i - 1].order] : undefined,
|
||||
is_sensitivity: s.isSensitivity,
|
||||
switch_condition: s.switchCondition,
|
||||
})),
|
||||
estimated_time_seconds: steps.length * 5,
|
||||
created_at: new Date().toISOString()
|
||||
estimated_time_seconds: fillResult.steps.length * 5,
|
||||
created_at: new Date().toISOString(),
|
||||
planned_trace: plannedTrace,
|
||||
epv_warning: fillResult.epvWarning,
|
||||
};
|
||||
|
||||
return plan;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成人类可读的规划理由
|
||||
*/
|
||||
private buildPlanReasoning(
|
||||
match: MatchResult,
|
||||
fill: FillResult,
|
||||
query: ParsedQuery
|
||||
): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
lines.push(`根据您的分析目标,为您规划了「${fill.templateName}」流程(${fill.steps.length} 步):`);
|
||||
|
||||
for (const step of fill.steps) {
|
||||
let desc = `${step.order}. ${step.name}(${step.toolName})`;
|
||||
if (step.isSensitivity && step.switchCondition) {
|
||||
desc += ` — 🛡️护栏:${step.switchCondition}`;
|
||||
}
|
||||
lines.push(desc);
|
||||
}
|
||||
|
||||
if (match.switchCondition) {
|
||||
lines.push(`\n说明:系统会自动检验统计前提假设。若 ${match.switchCondition},将自动降级为备选方法。`);
|
||||
}
|
||||
|
||||
if (fill.epvWarning) {
|
||||
lines.push(`\n⚠️ ${fill.epvWarning}`);
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 Phase Q 的 ParsedQuery 转换为旧版 ParsedIntent(桥接层)
|
||||
* 让下游 generateSteps 无需改动即可消费 LLM 解析结果
|
||||
*/
|
||||
private convertParsedQueryToIntent(pq: ParsedQuery, profile?: DataProfile): ParsedIntent {
|
||||
const goalMap: Record<string, ParsedIntent['analysisType']> = {
|
||||
comparison: 'comparison',
|
||||
correlation: 'correlation',
|
||||
regression: 'regression',
|
||||
descriptive: 'descriptive',
|
||||
cohort_study: 'mixed',
|
||||
};
|
||||
|
||||
return {
|
||||
goal: pq.reasoning || pq.goal,
|
||||
analysisType: goalMap[pq.goal] || 'descriptive',
|
||||
design: pq.design === 'paired' ? 'paired' : 'independent',
|
||||
variables: {
|
||||
mentioned: [
|
||||
...(pq.outcome_var ? [pq.outcome_var] : []),
|
||||
...pq.predictor_vars,
|
||||
],
|
||||
outcome: pq.outcome_var ?? undefined,
|
||||
predictors: pq.predictor_vars,
|
||||
grouping: pq.grouping_var ?? undefined,
|
||||
continuous: profile?.columns.filter(c => c.type === 'numeric').map(c => c.name) ?? [],
|
||||
categorical: profile?.columns.filter(c => c.type === 'categorical').map(c => c.name) ?? [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析用户意图(改进版:识别用户提到的变量并选择合适方法)
|
||||
* @deprecated Phase Q 后由 QueryService.parseIntent 替代,此方法保留为 fallback
|
||||
*/
|
||||
private parseUserIntent(userQuery: string, profile?: DataProfile): ParsedIntent {
|
||||
const query = userQuery.toLowerCase();
|
||||
|
||||
161
backend/src/modules/ssa/types/query.types.ts
Normal file
161
backend/src/modules/ssa/types/query.types.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* Phase Q — Query Layer 类型定义
|
||||
*
|
||||
* Q 层输出 → P 层输入的标准契约
|
||||
* LLM 意图解析结果 + Zod 动态校验 Schema
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
// ────────────────────────────────────────────
|
||||
// 1. 核心类型定义
|
||||
// ────────────────────────────────────────────
|
||||
|
||||
/** 分析目标类型 */
|
||||
export type AnalysisGoal = 'comparison' | 'correlation' | 'regression' | 'descriptive' | 'cohort_study';
|
||||
|
||||
/** 变量类型 */
|
||||
export type VariableType = 'continuous' | 'binary' | 'categorical' | 'ordinal' | 'datetime';
|
||||
|
||||
/** 研究设计类型 */
|
||||
export type StudyDesign = 'independent' | 'paired' | 'longitudinal' | 'cross_sectional';
|
||||
|
||||
/**
|
||||
* ParsedQuery — Q 层的标准输出
|
||||
* LLM 解析用户意图后生成,传递给 P 层(Planner)
|
||||
*/
|
||||
export interface ParsedQuery {
|
||||
goal: AnalysisGoal;
|
||||
outcome_var: string | null;
|
||||
outcome_type: VariableType | null;
|
||||
predictor_vars: string[];
|
||||
predictor_types: VariableType[];
|
||||
grouping_var: string | null;
|
||||
design: StudyDesign;
|
||||
confidence: number;
|
||||
reasoning: string;
|
||||
needsClarification: boolean;
|
||||
clarificationCards?: ClarificationCard[];
|
||||
dataDiagnosis?: DataDiagnosis;
|
||||
prunedProfile?: PrunedProfile;
|
||||
}
|
||||
|
||||
/** 追问卡片 — 封闭式数据驱动选项 */
|
||||
export interface ClarificationCard {
|
||||
question: string;
|
||||
options: ClarificationOption[];
|
||||
}
|
||||
|
||||
export interface ClarificationOption {
|
||||
label: string;
|
||||
value: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/** 数据诊断结果 */
|
||||
export interface DataDiagnosis {
|
||||
sampleSizeAdequate: boolean;
|
||||
sampleSize: number;
|
||||
missingRateWarnings: string[];
|
||||
outlierWarnings: string[];
|
||||
groupBalanceWarning?: string;
|
||||
recommendations: string[];
|
||||
}
|
||||
|
||||
/** 裁剪后的数据画像(Hot Context — 仅传给 P 层) */
|
||||
export interface PrunedProfile {
|
||||
schema: Array<{ name: string; type: string }>;
|
||||
details: any[];
|
||||
sampleSize: number;
|
||||
missingRateSummary: number;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────
|
||||
// 2. LLM 原始输出的 Zod Schema(静态版本)
|
||||
// ────────────────────────────────────────────
|
||||
|
||||
/** LLM 直接输出的 JSON 结构(Zod 校验用) */
|
||||
export const LLMIntentOutputSchema = z.object({
|
||||
goal: z.enum(['comparison', 'correlation', 'regression', 'descriptive', 'cohort_study']),
|
||||
outcome_var: z.string().nullable().default(null),
|
||||
outcome_type: z.enum(['continuous', 'binary', 'categorical', 'ordinal', 'datetime']).nullable().default(null),
|
||||
predictor_vars: z.array(z.string()).default([]),
|
||||
predictor_types: z.array(z.enum(['continuous', 'binary', 'categorical', 'ordinal', 'datetime'])).default([]),
|
||||
grouping_var: z.string().nullable().default(null),
|
||||
design: z.enum(['independent', 'paired', 'longitudinal', 'cross_sectional']).default('independent'),
|
||||
confidence: z.number().min(0).max(1).default(0.5),
|
||||
reasoning: z.string().default(''),
|
||||
});
|
||||
|
||||
export type LLMIntentOutput = z.infer<typeof LLMIntentOutputSchema>;
|
||||
|
||||
// ────────────────────────────────────────────
|
||||
// 3. 动态防幻觉 Schema 工厂(核心防御机制)
|
||||
// ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 基于真实列名动态生成 Zod Schema
|
||||
* 防止 LLM 捏造不存在的列名
|
||||
*
|
||||
* @param validColumns 数据中实际存在的列名列表
|
||||
*/
|
||||
export function createDynamicIntentSchema(validColumns: string[]) {
|
||||
const colSet = new Set(validColumns.map(c => c.toLowerCase()));
|
||||
|
||||
const validateColumnName = (val: string | null) => {
|
||||
if (val === null || val === '') return true;
|
||||
return colSet.has(val.toLowerCase());
|
||||
};
|
||||
|
||||
const validateColumnArray = (vals: string[]) => {
|
||||
return vals.every(v => colSet.has(v.toLowerCase()));
|
||||
};
|
||||
|
||||
return LLMIntentOutputSchema.extend({
|
||||
outcome_var: z.string().nullable().default(null).refine(
|
||||
validateColumnName,
|
||||
{ message: `LLM 输出了不存在的结局变量。有效列名: ${validColumns.join(', ')}` }
|
||||
),
|
||||
predictor_vars: z.array(z.string()).default([]).refine(
|
||||
validateColumnArray,
|
||||
{ message: `LLM 输出了不存在的自变量。有效列名: ${validColumns.join(', ')}` }
|
||||
),
|
||||
grouping_var: z.string().nullable().default(null).refine(
|
||||
validateColumnName,
|
||||
{ message: `LLM 输出了不存在的分组变量。有效列名: ${validColumns.join(', ')}` }
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────
|
||||
// 4. Confidence 二次验证
|
||||
// ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 对 LLM 自评的 confidence 做客观化二次校正
|
||||
* 规则:不信 LLM 的自评,用实际输出倒推
|
||||
*/
|
||||
export function validateConfidence(parsed: LLMIntentOutput): number {
|
||||
let confidence = parsed.confidence;
|
||||
|
||||
// 规则 1:高 confidence 但缺少关键变量 → 强制降级
|
||||
if (confidence >= 0.9) {
|
||||
if (!parsed.outcome_var && parsed.predictor_vars.length === 0) {
|
||||
confidence = 0.4;
|
||||
} else if (!parsed.outcome_var || parsed.predictor_vars.length === 0) {
|
||||
confidence = Math.min(confidence, 0.75);
|
||||
}
|
||||
}
|
||||
|
||||
// 规则 2:goal 是 descriptive 天然不需要 Y/X,允许高 confidence
|
||||
if (parsed.goal === 'descriptive') {
|
||||
confidence = Math.max(confidence, 0.7);
|
||||
}
|
||||
|
||||
// 规则 3:有完整的 Y + X + goal → 保底 0.7
|
||||
if (parsed.outcome_var && parsed.predictor_vars.length > 0 && parsed.goal !== 'descriptive') {
|
||||
confidence = Math.max(confidence, 0.7);
|
||||
}
|
||||
|
||||
return Math.round(confidence * 100) / 100;
|
||||
}
|
||||
153
backend/src/modules/ssa/types/reflection.types.ts
Normal file
153
backend/src/modules/ssa/types/reflection.types.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* SSA Reflection Layer 类型定义 (Phase R)
|
||||
*
|
||||
* 统一前后端 ConclusionReport 数据结构
|
||||
* 前端类型位于 frontend-v2/src/modules/ssa/types/index.ts
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
// ============================================
|
||||
// Zod Schema — LLM 输出强校验
|
||||
// ============================================
|
||||
|
||||
export const LLMConclusionSchema = z.object({
|
||||
executive_summary: z.string().min(10),
|
||||
key_findings: z.array(z.string()).min(1),
|
||||
statistical_summary: z.object({
|
||||
total_tests: z.number(),
|
||||
significant_results: z.number(),
|
||||
methods_used: z.array(z.string()),
|
||||
}),
|
||||
methodology: z.string().min(10),
|
||||
limitations: z.array(z.string()).min(1),
|
||||
recommendations: z.array(z.string()).optional().default([]),
|
||||
});
|
||||
|
||||
export type LLMConclusionOutput = z.infer<typeof LLMConclusionSchema>;
|
||||
|
||||
// ============================================
|
||||
// 统一的 ConclusionReport(前后端对齐)
|
||||
// ============================================
|
||||
|
||||
export interface StepSummary {
|
||||
step_number: number;
|
||||
tool_name: string;
|
||||
summary: string;
|
||||
p_value?: number;
|
||||
is_significant?: boolean;
|
||||
}
|
||||
|
||||
export interface ConclusionReport {
|
||||
workflow_id: string;
|
||||
title: string;
|
||||
executive_summary: string;
|
||||
key_findings: string[];
|
||||
statistical_summary: {
|
||||
total_tests: number;
|
||||
significant_results: number;
|
||||
methods_used: string[];
|
||||
};
|
||||
step_summaries: StepSummary[];
|
||||
recommendations: string[];
|
||||
limitations: string[];
|
||||
generated_at: string;
|
||||
source: 'llm' | 'rule_based';
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 槽位注入:从 StepResult 中提取关键统计量
|
||||
// ============================================
|
||||
|
||||
export interface StepFinding {
|
||||
step_number: number;
|
||||
tool_name: string;
|
||||
tool_code: string;
|
||||
statistic?: string;
|
||||
statistic_name?: string;
|
||||
p_value?: string;
|
||||
p_value_num?: number;
|
||||
effect_size?: string;
|
||||
effect_size_name?: string;
|
||||
ci_lower?: string;
|
||||
ci_upper?: string;
|
||||
method?: string;
|
||||
is_significant: boolean;
|
||||
group_stats?: Array<{
|
||||
group: string;
|
||||
n: number;
|
||||
mean?: number;
|
||||
sd?: number;
|
||||
median?: number;
|
||||
}>;
|
||||
raw_result?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// E 层错误分类映射
|
||||
// ============================================
|
||||
|
||||
export interface ErrorClassification {
|
||||
code: string;
|
||||
userHint: string;
|
||||
isRetryable: boolean;
|
||||
}
|
||||
|
||||
export const R_ERROR_PATTERNS: Array<{
|
||||
patterns: string[];
|
||||
code: string;
|
||||
userHint: string;
|
||||
isRetryable: boolean;
|
||||
}> = [
|
||||
{
|
||||
patterns: ['NA', 'missing values', 'incomplete cases', 'na.rm'],
|
||||
code: 'E_MISSING_DATA',
|
||||
userHint: '数据中存在缺失值,请检查数据清洗后重试',
|
||||
isRetryable: false,
|
||||
},
|
||||
{
|
||||
patterns: ['column not found', 'undefined columns', 'not found', 'object .* not found'],
|
||||
code: 'E_COLUMN_NOT_FOUND',
|
||||
userHint: '运算引擎未找到指定变量列,请检查数据源列名是否正确',
|
||||
isRetryable: false,
|
||||
},
|
||||
{
|
||||
patterns: ['system is computationally singular', 'collinear', 'singular'],
|
||||
code: 'E_COLLINEARITY',
|
||||
userHint: '数据存在严重共线性,建议排除冗余变量后重试',
|
||||
isRetryable: false,
|
||||
},
|
||||
{
|
||||
patterns: ['not enough observations', 'sample size', 'too few observations'],
|
||||
code: 'E_INSUFFICIENT_SAMPLE',
|
||||
userHint: '样本量不足以执行该统计方法,建议增加样本或选用非参数方法',
|
||||
isRetryable: false,
|
||||
},
|
||||
{
|
||||
patterns: ['contrasts can be applied only to factors with 2 or more levels', 'need at least 2'],
|
||||
code: 'E_FACTOR_LEVELS',
|
||||
userHint: '分组变量的水平数不足,请检查数据分组',
|
||||
isRetryable: false,
|
||||
},
|
||||
];
|
||||
|
||||
export const DEFAULT_ERROR: ErrorClassification = {
|
||||
code: 'E_UNKNOWN',
|
||||
userHint: '运算引擎遇到异常,请检查数据结构后重试',
|
||||
isRetryable: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* 根据 R 引擎错误消息匹配友好提示
|
||||
*/
|
||||
export function classifyRError(errorMessage: string): ErrorClassification {
|
||||
const lowerMsg = errorMessage.toLowerCase();
|
||||
for (const entry of R_ERROR_PATTERNS) {
|
||||
for (const pattern of entry.patterns) {
|
||||
if (lowerMsg.includes(pattern.toLowerCase())) {
|
||||
return { code: entry.code, userHint: entry.userHint, isRetryable: entry.isRetryable };
|
||||
}
|
||||
}
|
||||
}
|
||||
return DEFAULT_ERROR;
|
||||
}
|
||||
Reference in New Issue
Block a user