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:
2026-02-21 18:15:53 +08:00
parent 428a22adf2
commit 371e1c069c
73 changed files with 9242 additions and 706 deletions

View 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;
}
}

View 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"
}
]

View 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}}" }
}
]
}
]
}

View 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';

View 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>;

View 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"
}
]
}

View File

@@ -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,
});
}
});
// 校验配置文件

View File

@@ -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 });
}
}
);
}
/**

View File

@@ -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', {
/**
* 生成新版 ConclusionReportPhase 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;

View File

@@ -46,6 +46,8 @@ export interface ColumnProfile {
minDate?: string;
maxDate?: string;
dateRange?: string;
// Phase Q: 非分析列标记(由 Python DataProfiler 生成)
isIdLike?: boolean;
}
export interface DataSummary {

View 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();

View 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 };
}
// 计算 EPVmin(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();

View File

@@ -0,0 +1,457 @@
/**
* SSA QueryService — Phase Q 核心服务
*
* 职责:用户自然语言 → LLM 意图解析 → 结构化 ParsedQuery
*
* 三层防御:
* 1. LLM 调用 + jsonrepair 容错
* 2. 动态 Zod Schema验证列名真实性
* 3. Confidence 二次验证(不信 LLM 自评)
*
* FallbackLLM 失败 → 旧正则匹配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();

View 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();

View File

@@ -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
};

View File

@@ -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();

View 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);
}
}
// 规则 2goal 是 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;
}

View 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;
}