diff --git a/backend/scripts/seed-ssa-phase2-prompts.ts b/backend/scripts/seed-ssa-phase2-prompts.ts index f1473173..22d5f605 100644 --- a/backend/scripts/seed-ssa-phase2-prompts.ts +++ b/backend/scripts/seed-ssa-phase2-prompts.ts @@ -32,25 +32,34 @@ const PROMPTS: PromptDef[] = [ name: 'SSA 基础角色定义', description: 'Phase II — 对话层 LLM 的固定角色 System Prompt,始终作为 [1] 段注入', variables: [], - content: `你是 SSA-Pro 智能统计分析助手,专注于临床研究统计分析领域。 + content: `你是 SSA-Pro 智能统计分析助手。你的职责是**规划、解释和沟通**,而非计算。 -## 你的身份 +## 你的身份与职能边界 -你是一位经验丰富的生物统计顾问,服务于临床研究人员和医学院师生。你不仅能执行统计分析,更重要的是帮助用户理解数据、选择方法、解读结果。 +你是「分析规划者」和「结果解读者」,不是「计算引擎」。 +系统后端有独立的 R 统计计算引擎(R-Engine),所有统计计算均由 R 引擎完成并返回真实结果。 -## 核心能力 +### 你可以做的: +- 理解用户的分析需求,识别意图 +- 推荐合适的统计方法,解释选择理由和前提条件 +- 制定分析方案(选择工具、参数配置) +- 解读 R 引擎返回的**真实**结果,用通俗语言解释给用户 +- 识别 PICO 结构,解读数据特征 -1. **数据理解** — 解读数据结构、变量类型、缺失模式、异常值和分布特征 -2. **方法推荐** — 根据研究设计和数据特征推荐合适的统计方法,说明前提条件和替代方案 -3. **结果解读** — 用通俗易懂的语言解释 p 值、置信区间、效应量等统计概念 -4. **PICO 识别** — 识别研究的人群、干预、对照和结局变量 +### 绝对禁止(铁律): +- **禁止编造或生成任何数值结果**(P值、均值、标准差、置信区间、检验统计量、OR、RR 等) +- **禁止生成模拟/假设的分析结果表格** +- **禁止在 R 引擎尚未执行时预测结果** +- 如果还没有 R 引擎的执行结果,只能说明方案状态(如"方案已确认,即将启动分析") + +**关键原则:没有 R 引擎的真实输出 → 绝不回答任何具体数值。违反此原则将导致临床研究的严重误导。** ## 沟通原则 - 使用中文回复 - 语言专业但不晦涩,避免不必要的术语堆砌 - 分点作答,条理清晰 -- 对不确定的内容如实说明,不编造数据或结论 +- 对不确定的内容如实说明 - 回复简洁聚焦,不要过度发散 - 当用户的问题涉及其数据时,优先引用数据上下文中的实际信息`, modelConfig: { model: 'deepseek-v3', temperature: 0.7, maxTokens: 2000 }, @@ -69,7 +78,8 @@ const PROMPTS: PromptDef[] = [ 2. 不要主动建议"帮你执行分析",除非用户明确要求 3. 如果问题与用户数据相关,引用数据上下文中的具体信息 4. 如果问题超出统计分析范围,礼貌说明并引导回统计话题 -5. 回复简洁,不超过 300 字`, +5. 回复简洁,不超过 300 字 +6. 禁止编造用户数据的具体数值(均值、P值等),只有 R 引擎返回的才是真实数据`, modelConfig: { model: 'deepseek-v3', temperature: 0.7, maxTokens: 1500 }, }, { @@ -104,7 +114,8 @@ const PROMPTS: PromptDef[] = [ 2. 必须说明:推荐方法、选择理由、前提条件(如正态性要求) 3. 提供至少一个替代方案(如非参数替代) 4. 不要直接执行分析,等待用户确认方案后再执行 -5. 如果信息不足以做出推荐,主动追问缺少的关键信息`, +5. 如果信息不足以做出推荐,主动追问缺少的关键信息 +6. 禁止给出假设的分析结果数值来论证方法优劣`, modelConfig: { model: 'deepseek-v3', temperature: 0.7, maxTokens: 2000 }, }, { @@ -112,16 +123,21 @@ const PROMPTS: PromptDef[] = [ name: 'SSA analyze 意图指令', description: 'Phase II — analyze 意图的指令段,用于播报 QPER 执行进度', variables: [], - content: `## 当前任务:分析执行播报 + content: `## 当前任务:分析规划与执行协调 -系统正在执行统计分析(通过 QPER 引擎),你的任务是向用户简要说明进展。 +你正在协助用户进行统计分析的规划和协调。 -规则: -1. 如果提供了工具执行结果,用通俗语言向用户解释关键发现 -2. 避免复制粘贴原始 R 输出,提炼核心信息(p 值、效应量、置信区间) -3. 使用用户能理解的语言,必要时解释统计术语 -4. 回复控制在 200 字以内,详细结果可在分析报告中查看 -5. 如果执行出错,简要说明原因并建议解决方案`, +### 核心规则: +1. **你的职责是解释分析方案和方法选择理由**,而非执行计算 +2. **所有数值结果只能引用 R 引擎返回的真实输出**(会以"工具执行结果"的形式提供给你) +3. 如果提供了 R 引擎的真实执行结果,用通俗语言向用户解读关键发现 +4. 如果 R 引擎尚未执行或未返回结果,**只能说明方案状态**(如"方案已确认,正在启动分析") +5. 回复控制在 200 字以内 + +### 绝对禁止: +- 禁止自行生成 P 值、均值、标准差、置信区间、检验统计量 +- 禁止生成分析结果表格(除非表格数据来自 R 引擎输出) +- 禁止在没有 R 引擎输出时编造任何数值`, modelConfig: { model: 'deepseek-v3', temperature: 0.5, maxTokens: 1000 }, }, { @@ -134,7 +150,7 @@ const PROMPTS: PromptDef[] = [ 用户想深入讨论已有的分析结果。 规则: -1. 基于上方注入的分析结果,帮助用户深入解读 +1. **仅基于 R 引擎返回的真实分析结果**进行解读,不要补充或编造 R 引擎未返回的数值 2. 解释统计量的含义(如 p 值的正确解读、置信区间的意义) 3. 讨论结果的临床意义(不仅是统计显著性) 4. 指出分析的局限性和注意事项 diff --git a/backend/src/modules/ssa/config/tool_param_constraints.json b/backend/src/modules/ssa/config/tool_param_constraints.json new file mode 100644 index 00000000..c4623dcc --- /dev/null +++ b/backend/src/modules/ssa/config/tool_param_constraints.json @@ -0,0 +1,52 @@ +{ + "ST_DESCRIPTIVE": { + "variables": { "paramType": "multi", "requiredType": "any", "hint": "选择需要描述的变量" }, + "group_var": { "paramType": "single", "requiredType": "categorical", "hint": "分组变量(可选)" } + }, + "ST_T_TEST_IND": { + "group_var": { "paramType": "single", "requiredType": "categorical", "maxLevels": 2, "hint": "T检验要求二分类分组变量" }, + "value_var": { "paramType": "single", "requiredType": "numeric", "hint": "T检验要求连续型因变量" } + }, + "ST_MANN_WHITNEY": { + "group_var": { "paramType": "single", "requiredType": "categorical", "maxLevels": 2, "hint": "Mann-Whitney检验要求二分类分组变量" }, + "value_var": { "paramType": "single", "requiredType": "numeric", "hint": "要求连续型因变量" } + }, + "ST_T_TEST_PAIRED": { + "before_var": { "paramType": "single", "requiredType": "numeric", "hint": "前测变量应为连续型" }, + "after_var": { "paramType": "single", "requiredType": "numeric", "hint": "后测变量应为连续型" } + }, + "ST_WILCOXON": { + "before_var": { "paramType": "single", "requiredType": "numeric", "hint": "前测变量应为连续型" }, + "after_var": { "paramType": "single", "requiredType": "numeric", "hint": "后测变量应为连续型" } + }, + "ST_CHI_SQUARE": { + "var1": { "paramType": "single", "requiredType": "categorical", "hint": "卡方检验要求分类变量" }, + "var2": { "paramType": "single", "requiredType": "categorical", "hint": "卡方检验要求分类变量" } + }, + "ST_FISHER": { + "var1": { "paramType": "single", "requiredType": "categorical", "hint": "Fisher检验要求分类变量" }, + "var2": { "paramType": "single", "requiredType": "categorical", "hint": "Fisher检验要求分类变量" } + }, + "ST_CORRELATION": { + "var_x": { "paramType": "single", "requiredType": "numeric", "hint": "相关分析要求连续型变量" }, + "var_y": { "paramType": "single", "requiredType": "numeric", "hint": "相关分析要求连续型变量" } + }, + "ST_LOGISTIC_BINARY": { + "outcome_var": { "paramType": "single", "requiredType": "categorical", "maxLevels": 2, "hint": "二元Logistic回归要求二分类结局变量" }, + "predictors": { "paramType": "multi", "requiredType": "any", "hint": "预测变量" }, + "confounders": { "paramType": "multi", "requiredType": "any", "hint": "混杂因素(可选)" } + }, + "ST_LINEAR_REG": { + "outcome_var": { "paramType": "single", "requiredType": "numeric", "hint": "线性回归要求连续型结局变量" }, + "predictors": { "paramType": "multi", "requiredType": "any", "hint": "预测变量" }, + "confounders": { "paramType": "multi", "requiredType": "any", "hint": "混杂因素(可选)" } + }, + "ST_ANOVA_ONE": { + "group_var": { "paramType": "single", "requiredType": "categorical", "minLevels": 3, "hint": "ANOVA要求3组及以上分组变量" }, + "value_var": { "paramType": "single", "requiredType": "numeric", "hint": "要求连续型因变量" } + }, + "ST_BASELINE_TABLE": { + "group_var": { "paramType": "single", "requiredType": "categorical", "minLevels": 2, "maxLevels": 5, "hint": "基线表需要分类分组变量" }, + "analyze_vars": { "paramType": "multi", "requiredType": "any", "hint": "选择需要分析的变量" } + } +} diff --git a/backend/src/modules/ssa/routes/workflow.routes.ts b/backend/src/modules/ssa/routes/workflow.routes.ts index 42783204..610395fd 100644 --- a/backend/src/modules/ssa/routes/workflow.routes.ts +++ b/backend/src/modules/ssa/routes/workflow.routes.ts @@ -9,6 +9,7 @@ */ import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; +import { z } from 'zod'; import { logger } from '../../../common/logging/index.js'; import { workflowPlannerService } from '../services/WorkflowPlannerService.js'; import { workflowExecutorService } from '../services/WorkflowExecutorService.js'; @@ -372,6 +373,143 @@ export default async function workflowRoutes(app: FastifyInstance) { } ); + /** + * PATCH /workflow/:workflowId/params + * Phase V: 批量更新 workflow step 参数(变量可编辑化) + * + * Zod 结构校验防火墙: + * - 结构非法(字段类型错误、必填字段缺失)→ 400 Bad Request + * - 统计学不合理但结构合法 → 放行,交给 R 引擎 tryCatch + */ + app.patch<{ Params: { workflowId: string }; Body: { steps: Array<{ stepOrder: number; params: Record }> } }>( + '/:workflowId/params', + async (request, reply) => { + const { workflowId } = request.params; + const { steps } = request.body; + + const PatchStepSchema = z.object({ + stepOrder: z.number().int().positive(), + params: z.record(z.string(), z.unknown()), + }); + const PatchBodySchema = z.object({ + steps: z.array(PatchStepSchema).min(1), + }); + + const validation = PatchBodySchema.safeParse({ steps }); + if (!validation.success) { + return reply.status(400).send({ + success: false, + error: 'Invalid request body', + details: validation.error.flatten(), + }); + } + + try { + const workflow = await prisma.ssaWorkflow.findUnique({ + where: { id: workflowId }, + select: { id: true, status: true, sessionId: true }, + }); + + if (!workflow) { + return reply.status(404).send({ success: false, error: 'Workflow not found' }); + } + + if (workflow.status !== 'planned' && workflow.status !== 'pending') { + return reply.status(409).send({ + success: false, + error: `Cannot modify params for workflow in '${workflow.status}' state`, + }); + } + + // Validate variable names exist in session data schema + const session = await prisma.ssaSession.findUnique({ + where: { id: workflow.sessionId }, + select: { dataSchema: true }, + }); + const schema = session?.dataSchema as any; + const validColumnNames = new Set( + (schema?.columns || []).map((c: any) => c.name) + ); + + for (const stepPatch of validation.data.steps) { + for (const [key, value] of Object.entries(stepPatch.params)) { + if (typeof value === 'string' && value && !key.startsWith('_')) { + if (['group_var', 'outcome_var', 'value_var', 'var_x', 'var_y', + 'before_var', 'after_var', 'var1', 'var2'].includes(key)) { + if (validColumnNames.size > 0 && !validColumnNames.has(value)) { + return reply.status(400).send({ + success: false, + error: `Variable '${value}' in step ${stepPatch.stepOrder}.${key} does not exist in the dataset`, + }); + } + } + } + if (Array.isArray(value) && ['analyze_vars', 'predictors', 'variables', 'confounders'].includes(key)) { + for (const v of value) { + if (typeof v === 'string' && validColumnNames.size > 0 && !validColumnNames.has(v)) { + return reply.status(400).send({ + success: false, + error: `Variable '${v}' in step ${stepPatch.stepOrder}.${key} does not exist in the dataset`, + }); + } + } + } + } + } + + // Update each step's inputParams in the database + const updatePromises = validation.data.steps.map((stepPatch) => + prisma.ssaWorkflowStep.updateMany({ + where: { + workflowId, + stepOrder: stepPatch.stepOrder, + }, + data: { + inputParams: stepPatch.params as any, + }, + }) + ); + + await Promise.all(updatePromises); + + // Also update the workflowPlan JSON blob's steps params + const currentPlan = await prisma.ssaWorkflow.findUnique({ + where: { id: workflowId }, + select: { workflowPlan: true }, + }); + if (currentPlan?.workflowPlan) { + const plan = currentPlan.workflowPlan as any; + if (plan.steps) { + for (const stepPatch of validation.data.steps) { + const planStep = plan.steps.find((s: any) => s.step_number === stepPatch.stepOrder); + if (planStep) { + planStep.params = stepPatch.params; + } + } + await prisma.ssaWorkflow.update({ + where: { id: workflowId }, + data: { workflowPlan: plan }, + }); + } + } + + logger.info('[SSA:API] Workflow params updated', { + workflowId, + stepsUpdated: validation.data.steps.length, + }); + + return reply.send({ success: true, stepsUpdated: validation.data.steps.length }); + + } catch (error: any) { + logger.error('[SSA:API] Patch workflow params failed', { + workflowId, + error: error.message, + }); + return reply.status(500).send({ success: false, error: error.message }); + } + } + ); + /** * POST /workflow/profile * 生成数据画像 diff --git a/backend/src/modules/ssa/services/ChatHandlerService.ts b/backend/src/modules/ssa/services/ChatHandlerService.ts index b0c8a8ee..8d1360dc 100644 --- a/backend/src/modules/ssa/services/ChatHandlerService.ts +++ b/backend/src/modules/ssa/services/ChatHandlerService.ts @@ -239,7 +239,8 @@ export class ChatHandlerService { const toolOutputs = [ guardToolOutput, planSummary, - '[系统提示] 你刚刚为用户制定了上述分析方案。请用自然语言向用户解释这个方案:包括为什么选这些方法、分析步骤的逻辑。不要重复列步骤编号和工具代码,要用用户能理解的语言说明。最后提示用户确认方案后即可执行。', + '[系统指令] 你刚刚为用户制定了上述分析方案。请用自然语言向用户解释这个方案:包括为什么选这些方法、分析步骤的逻辑。不要重复列步骤编号和工具代码,要用用户能理解的语言说明。最后提示用户确认方案后即可执行。', + '【禁止事项】不要预测、模拟或编造任何分析结果、数值或表格。方案只是计划,R 引擎尚未执行,你不知道结果是什么。', ].filter(Boolean).join('\n\n'); const messages = await conversationService.buildContext( @@ -397,7 +398,9 @@ export class ChatHandlerService { writer: StreamWriter, placeholderMessageId: string, ): Promise { - // 清除 pending 状态 + // 先读取 pending 元数据(含 workflowId),再清除 + const pending = await askUserService.getPending(sessionId); + const pendingMeta = pending?.metadata || {}; await askUserService.clearPending(sessionId); if (response.action === 'skip') { @@ -423,15 +426,21 @@ export class ChatHandlerService { const selectedValue = response.selectedValues?.[0]; if (selectedValue === 'confirm_plan') { - // Phase IV: 确认分析方案 → 前端将触发 executeWorkflow - const workflowId = response.metadata?.workflowId || ''; + // Phase IV: 确认分析方案 → 前端打开工作区,用户手动点击执行 + const workflowId = pendingMeta.workflowId || response.metadata?.workflowId || ''; const messages = await conversationService.buildContext( sessionId, conversationId, 'analyze', - `[系统提示] 用户已确认分析方案(workflow: ${workflowId})。请简要确认:"好的,方案已确认,正在准备执行分析..."。`, + [ + `[系统指令——严格遵守] 用户已确认分析方案(workflow: ${workflowId})。`, + '你只需回复一句简短的确认消息,例如:"好的,方案已确认。请在右侧工作区点击「开始执行分析」启动 R 引擎。"', + '【铁律】禁止在此回复中生成任何分析结果、表格、P值、统计量、数值。', + '你不是计算引擎,所有数值结果将由 R 统计引擎独立计算后返回。', + '你的回复不得超过 2 句话。', + ].join('\n'), ); const result = await conversationService.streamToSSE(messages, writer, { - temperature: 0.3, maxTokens: 300, + temperature: 0.1, maxTokens: 150, }); await conversationService.finalizeAssistantMessage( @@ -451,12 +460,12 @@ export class ChatHandlerService { // Phase III: 确认使用推荐方法 → 提示可以开始分析 const messages = await conversationService.buildContext( sessionId, conversationId, 'analyze', - '[系统提示] 用户已确认使用推荐的统计方法。请简要确认方案,告知用户可以在对话中说"开始分析"或在右侧面板触发执行。', + '[系统指令] 用户已确认使用推荐的统计方法。请简要确认方案,告知用户可以在对话中说"开始分析"或在右侧面板触发执行。禁止生成任何数值或假设的分析结果。', ); const result = await conversationService.streamToSSE(messages, writer, { - temperature: 0.5, - maxTokens: 800, + temperature: 0.3, + maxTokens: 500, }); await conversationService.finalizeAssistantMessage( diff --git a/backend/src/modules/ssa/services/FlowTemplateService.ts b/backend/src/modules/ssa/services/FlowTemplateService.ts index 9a3102ea..d6c6ec87 100644 --- a/backend/src/modules/ssa/services/FlowTemplateService.ts +++ b/backend/src/modules/ssa/services/FlowTemplateService.ts @@ -131,7 +131,7 @@ export class FlowTemplateService { params[key] = query.outcome_var; break; case '{{grouping_var}}': - params[key] = query.grouping_var; + params[key] = query.grouping_var || this.inferGroupingVar(query, profile); break; case '{{all_predictors}}': params[key] = query.predictor_vars; @@ -150,6 +150,51 @@ export class FlowTemplateService { return { params, epvWarning }; } + /** + * 当 LLM 未识别 grouping_var 时,从数据画像中自动推断 + * 优先选择:二分类变量(排除 outcome_var),最典型的分组/暴露变量 + */ + private inferGroupingVar(query: ParsedQuery, profile?: DataProfile | null): string | null { + if (!profile?.columns) return null; + + const excludeVars = new Set(); + if (query.outcome_var) excludeVars.add(query.outcome_var.toLowerCase()); + + const binaryCandidates = profile.columns.filter(c => + c.type === 'categorical' && + c.totalLevels === 2 && + !excludeVars.has(c.name.toLowerCase()) + ); + + if (binaryCandidates.length > 0) { + const chosen = binaryCandidates[0].name; + logger.info('[SSA:FlowTemplate] Auto-inferred grouping_var', { + chosen, + candidates: binaryCandidates.map(c => c.name), + }); + return chosen; + } + + const categoricalCandidates = profile.columns.filter(c => + c.type === 'categorical' && + c.totalLevels !== undefined && + c.totalLevels >= 2 && + c.totalLevels <= 5 && + !excludeVars.has(c.name.toLowerCase()) + ); + + if (categoricalCandidates.length > 0) { + const chosen = categoricalCandidates[0].name; + logger.info('[SSA:FlowTemplate] Auto-inferred grouping_var (categorical)', { + chosen, + }); + return chosen; + } + + logger.warn('[SSA:FlowTemplate] No suitable grouping_var found in profile'); + return null; + } + /** * 构建默认参数(非 paramsMapping 模板步骤使用) */ diff --git a/backend/src/modules/ssa/services/SystemPromptService.ts b/backend/src/modules/ssa/services/SystemPromptService.ts index caf636c3..8e56d219 100644 --- a/backend/src/modules/ssa/services/SystemPromptService.ts +++ b/backend/src/modules/ssa/services/SystemPromptService.ts @@ -123,12 +123,29 @@ export class SystemPromptService { } private fallbackBaseSystem(): string { - return `你是 SSA-Pro 智能统计分析助手,专注于临床研究统计分析。 -你具备以下能力: -- 理解临床研究数据的结构和特征 -- 推荐合适的统计分析方法 -- 解读统计分析结果 -- 用通俗易懂的语言向医学研究者解释统计概念 + return `你是 SSA-Pro 智能统计分析助手。你的职责是**规划、解释和沟通**,而非计算。 + +## 你的身份与职能边界 + +你是「分析规划者」和「结果解读者」,不是「计算引擎」。 +系统后端有独立的 R 统计计算引擎,所有统计计算均由 R 引擎完成。 + +### 你可以做的: +- 理解用户的分析需求,识别意图 +- 推荐合适的统计方法,解释选择理由 +- 制定分析方案(选择工具、参数) +- 解读 R 引擎返回的真实结果 +- 用通俗语言向研究者解释统计概念 + +### 绝对禁止: +- **禁止编造或生成任何数值结果**(P值、均值、标准差、置信区间、检验统计量等) +- **禁止模拟或假设分析结果**(即使用户催促,也不能捏造数据) +- **禁止生成结果表格**(除非表格数据来自 R 引擎的真实输出) +- 如果还没有 R 引擎的执行结果,只能说"正在等待执行"或"方案已确认,即将启动分析" + +### 关键原则: +没有 R 引擎的真实输出 → 不回答任何具体数值。 +这是铁律,违反将导致临床研究的严重错误。 沟通原则: - 使用中文回复 @@ -139,12 +156,12 @@ export class SystemPromptService { private fallbackIntentInstruction(intent: IntentType): string { const map: Record = { - chat: '请基于统计知识和用户数据直接回答用户的问题。不要主动建议执行分析,除非用户明确要求。简洁作答,分点清晰。', - explore: '用户想了解数据的特征。请基于上方的数据摘要信息,帮用户解读数据特征(缺失、分布、异常值等)。可以推断 PICO 结构。不要执行分析。', - consult: '用户在咨询统计方法。请根据数据特征和研究目的推荐合适的统计方法,给出选择理由和前提条件。不要直接执行分析。提供替代方案。', - analyze: '以下是工具执行结果。请向用户简要说明分析进展和关键发现。使用通俗语言,避免过度技术化。', - discuss: '用户想讨论分析结果。请帮助用户深入解读结果,解释统计量的含义,讨论临床意义和局限性。', - feedback: '用户对之前的分析结果不满意或有改进建议。请分析问题原因,提出改进方案(如更换统计方法、调整参数等)。', + chat: '请基于统计知识和用户数据直接回答用户的问题。不要主动建议执行分析,除非用户明确要求。简洁作答,分点清晰。禁止编造任何数值。', + explore: '用户想了解数据的特征。请基于上方的数据摘要信息,帮用户解读数据特征(缺失、分布、异常值等)。可以推断 PICO 结构。不要执行分析,不要编造统计数值。', + consult: '用户在咨询统计方法。请根据数据特征和研究目的推荐合适的统计方法,给出选择理由和前提条件。不要直接执行分析。提供替代方案。禁止给出任何假设的分析结果数值。', + analyze: '你正在协助用户进行分析规划。你的职责限于:解释分析方案的思路和方法选择理由。禁止生成任何P值、统计量、均值、分析结果表格。所有数值结果只能来自 R 引擎的真实执行输出。如果 R 引擎还没有返回结果,只能说明方案状态,不能自行填充结果。', + discuss: '用户想讨论分析结果。请仅基于 R 引擎返回的真实数据帮助用户解读,解释统计量的含义,讨论临床意义和局限性。禁止补充或编造 R 引擎未返回的数值。', + feedback: '用户对之前的分析结果不满意或有改进建议。请分析问题原因,提出改进方案(如更换统计方法、调整参数等)。禁止编造数值来论证改进效果。', }; return map[intent]; } diff --git a/docs/02-通用能力层/分布式Fan-out任务模式开发指南.md b/docs/02-通用能力层/分布式Fan-out任务模式开发指南.md index d1d6f0bf..e77c460b 100644 --- a/docs/02-通用能力层/分布式Fan-out任务模式开发指南.md +++ b/docs/02-通用能力层/分布式Fan-out任务模式开发指南.md @@ -1,6 +1,6 @@ # 分布式 Fan-out 任务模式开发指南 -> **版本:** v1.0(基于 ASL 工具 3 架构设计经验,尚未经生产验证) +> **版本:** v1.2(逐行级审查修正:乐观锁释放 + Sweeper updatedAt + 空集合守卫 + pg_notify 参数化) > **创建日期:** 2026-02-23 > **定位:** 实战 Cookbook,开发时按需查阅 > **互补文档:** `系统级异步架构风险剖析与演进技术蓝图.md`(Why)→ 本文(How) @@ -33,6 +33,7 @@ ↓ ┌─ Manager Job ─────────────────────────────┐ │ 1. 读取 N 个子项 │ +│ 1.5 🆕 if N=0 → 直接 completed + return │ │ 2. 快照外部依赖数据(防源头失踪) │ │ 3. for each → pgBoss.send(child_queue) │ │ 4. 派发完毕 → 退出(Fire-and-forget) │ @@ -51,7 +52,7 @@ --- -## 三、7 项关键设计模式 +## 三、8 项关键设计模式 ### 模式 1:原子递增(禁止 Read-then-Write) @@ -71,6 +72,17 @@ const taskAfterUpdate = await prisma.task.update({ Prisma 的 `{ increment: 1 }` 编译为 SQL `SET success_count = success_count + 1`,数据库行锁保证原子性。 +**🚨 v1.1 补充:短事务原则(防行锁争用)** + +> **场景推演:** 100 个极轻量 Child Job(缓存命中,瞬间完成)在不同 Pod 中几乎同时走到 `prisma.$transaction`。 +> 这 100 个事务都需要对同一父任务行执行 `{ increment: 1 }`,PostgreSQL 在这一行上加排他行锁(Row-Level Lock)。 +> 100 个并发请求排队等一把锁,极易触发 Lock wait timeout,大量本已成功的任务在最后一步报数据库错误。 + +**强制规范:** +- **绝不允许**在更新父任务的 `$transaction` 内发起任何网络请求或耗时操作 +- 事务必须极度纯粹:更新子项(Result) + 递增父亲(Task),确保事务在 **< 1ms** 内提交并释放行锁 +- 高并发下若仍出现行锁超时,可在 Prisma 连接串中适当调大 `pool_timeout` + ### 模式 2:Last Child Wins(终止器) **问题:** Manager 派发完就退出,没有人负责把父任务从 `processing` 翻转为 `completed`。 @@ -89,6 +101,55 @@ if (taskAfterUpdate.successCount + taskAfterUpdate.failedCount >= taskAfterUpdat **关键:** 成功路径和失败路径都必须有这段检查。漏掉任何一条路径,任务就可能永远卡在 `processing`。 +**🚨 v1.1 补充:Sweeper 清道夫 — 应对进程硬崩溃(最危险场景)** + +> **场景推演:** N=100,第 99 个 Child Worker 解析超大 PDF 触发 Node.js V8 OOM,或容器被云平台 SIGKILL。 +> 进程瞬间蒸发,代码根本没有机会走到 `catch` 块。`failedCount` 永远不会 +1。 +> pg-boss 虽然会在 `expireInMinutes` 后标记该 Job 为 failed,但业务表里 +> `successCount + failedCount = 99`,永远达不到 100。父任务永远卡在 `processing`。 +> +> **本质:** 单兵 Worker 无法处理自身猝死,必须有系统级外部兜底。 + +**解法:注册全局定时清道夫 `FanOut_Task_Sweeper`(每 10 分钟运行一次):** + +```typescript +// 全局 Cron Job — 清道夫(建议用 pg-boss 的 schedule 功能注册) +async function fanOutTaskSweeper() { + const stuckTasks = await prisma.task.findMany({ + where: { + status: 'processing', + // 🚨 v1.2 修正:使用 updatedAt(最后活跃时间)而非 startedAt! + // 原因:500 篇文献正常排队可能需要 3+ 小时,用 startedAt 会误杀健康任务。 + // 只要子任务还在完成(原子递增),updatedAt 就会持续刷新。 + // 超过 2 小时没有任何进度更新的,才是真正卡死。 + updatedAt: { lt: new Date(Date.now() - 2 * 60 * 60 * 1000) }, + }, + }); + + for (const task of stuckTasks) { + await prisma.task.update({ + where: { id: task.id }, + data: { + status: 'failed', + errorMessage: '[Sweeper] No progress update for 2h. Likely Child Worker hard crash (OOM/SIGKILL). Force-closed.', + completedAt: new Date(), + }, + }); + logger.warn(`[Sweeper] Force-closed stuck task ${task.id} (no progress for 2h)`); + } +} + +// 注册为 pg-boss 定时任务 +await pgBoss.schedule('fanout_task_sweeper', '*/10 * * * *'); // 每 10 分钟 +await pgBoss.work('fanout_task_sweeper', fanOutTaskSweeper); +``` + +**Sweeper 是 Fan-out 模式的终极保险丝。** 即使所有 Last Child Wins 逻辑都正确,进程硬崩溃仍然是不可避免的物理级异常。Sweeper 确保任何"卡死"的任务最终都会被收口。 + +> **⚠️ v1.2 关键修正:** 判断"卡死"的依据是 `updatedAt`(最后活跃时间),而非 `startedAt`(任务创建时间)。 +> 只要 Child 还在完成并递增计数,Prisma 的 `update` 会自动刷新 `updatedAt`。 +> 超大批量任务(500+ 文献)正常排队执行可能需要数小时,用 `startedAt` 会导致 Sweeper 误杀正在健康运行的任务("友军之火")。 + ### 模式 3:乐观锁抢占(Optimistic Locking) **问题:** pg-boss 的 at-least-once 语义意味着同一 Child Job 可能被投递多次。如果用 `findUnique → if (status !== 'pending') return` 做幂等检查,两个 Worker 可能同时读到 `pending` 然后同时处理。 @@ -108,6 +169,29 @@ if (lock.count === 0) return { success: true, note: 'Idempotent skip' }; `updateMany` 的 WHERE 条件充当乐观锁,数据库保证只有一个 Worker 能成功更新。 +**🚨 v1.1 补充:子任务派发防重 — singletonKey 的真正意图** + +> **场景推演:** Manager Job 在派发了 50 个 Child Job 后,进程崩溃。pg-boss 的 at-least-once 语义会 +> 重新投递 Manager Job。重试时 Manager 重新查出 100 个子项,再次循环派发 100 个 Child Job。 +> 前 50 个任务被重复派发,队列瞬间塞满垃圾数据,并导致 Child Worker 重复处理。 + +**强制规范:Manager 必须为每个 Child 赋予基于业务 ID 的 `singletonKey`:** + +```typescript +// Manager 内循环派发 +for (const item of items) { + await pgBoss.send('module_task_child', { taskId, itemId: item.id }, { + singletonKey: `child-${item.id}`, // ← 基于业务 ID 去重! + retryLimit: 3, + retryBackoff: true, + expireInMinutes: 30, + }); +} +``` + +**`singletonKey` 是保证 Manager 自身崩溃重试时不会导致子任务指数级爆炸的唯一防线。** +pg-boss 在收到重复 `singletonKey` 时自动去重(忽略重复插入),无需手动判断。新手开发**绝不可省略**此字段。 + ### 模式 4:错误分级路由 **问题:** pg-boss 默认对所有失败 Job 进行指数退避重试。但"PDF 损坏"这类永久错误重试 3 次也不会好。 @@ -122,6 +206,15 @@ try { // ⚠️ 别忘了 Last Child Wins 检查! return { success: false }; // return 而非 throw → pg-boss 视为"成功消费",停止重试 } + + // 🚨 v1.2 补丁:临时错误 throw 前必须释放乐观锁! + // 否则 pg-boss 重试时 updateMany({ where: { status: 'pending' } }) 返回 0, + // 被误判为"幂等跳过",计数永远少一票,Last Child Wins 永远无法触发。 + await prisma.result.update({ + where: { id: resultId }, + data: { status: 'pending' }, + }); + // 临时错误 (429/5xx/网络抖动):throw → pg-boss 指数退避自动重试 throw error; } @@ -157,10 +250,15 @@ jobQueue.work('module_llm_call', { teamConcurrency: 5 }, handler); **问题:** `sseEmitter.emit()` 基于内存 EventEmitter,用户连 Pod A、Worker 跑 Pod B → Pod A 收不到日志。 ```typescript -// Worker 端(发送) -await prisma.$executeRawUnsafe( - `NOTIFY sse_channel, '${JSON.stringify({ taskId, type: 'log', data: logEntry }).replace(/'/g, "''")}'` -); +// Worker 端(发送)— v1.2 使用 pg_notify + 参数化查询(免疫 SQL 注入) +const payloadStr = JSON.stringify({ taskId, type: 'log', data: logEntry }); +const safePayload = payloadStr.length > 7000 + ? payloadStr.substring(0, 7000) + '..."}' + : payloadStr; + +// 🚨 v1.2 修正:抛弃 $executeRawUnsafe + 字符串拼接! +// 使用 PostgreSQL 内置 pg_notify() 函数 + Prisma Tagged Template(参数化绑定) +await prisma.$executeRaw`SELECT pg_notify('sse_channel', ${safePayload})`; // API 端(接收)— Pod 启动时初始化 const pgClient = new Client({ connectionString: DATABASE_URL }); @@ -179,7 +277,12 @@ pgClient.on('notification', (msg) => { **约束:** - LISTEN 连接必须独立于连接池(归还后 LISTEN 失效) -- NOTIFY payload 上限 8000 bytes +- **NOTIFY payload 物理上限 ~8000 bytes**(超出直接报错,阻断业务流程!) + - **强制规范(v1.1):** 发送前必须安全截断至 7000 bytes 以内(预留 JSON 结构和转义开销) + - LLM 错误堆栈、超长乱码是最常见的超限来源 +- **🚨 v1.2 强制规范:禁止 `$executeRawUnsafe` + 字符串拼接发送 NOTIFY!** + - 必须使用 `$executeRaw` Tagged Template + `pg_notify()` 函数(参数化绑定,彻底免疫 SQL 注入) + - 不同编码、特殊换行符、反斜杠均可能绕过手动 `.replace` 转义 - fire-and-forget(无持久化),适合日志流这类"丢了不影响业务"的场景 ### 模式 7:数据一致性快照 @@ -211,6 +314,26 @@ await prisma.$transaction( **原则:** 快照轻量元数据(storageKey、filename 等 < 1KB)到数据库。大文件内容不快照,通过错误分级路由兜底。 +### 🆕 模式 8:Manager 空集合边界守卫(v1.2) + +**问题:** 如果源数据被过滤后 `items.length === 0`(空列表、数据异常等极端情况),Manager 的 `for` 循环不执行,没有任何 Child 被派发,Last Child Wins 永远不会触发,父任务永远卡在 `processing`。 + +```typescript +// Manager Worker — 派发前必须检查空集合 +if (items.length === 0) { + await prisma.task.update({ + where: { id: taskId }, + data: { status: 'completed', completedAt: new Date() }, + }); + logger.info(`[Manager] Task ${taskId}: 0 items, auto-completed`); + return; +} + +// 正常路径:继续快照 + 派发 Child Job... +``` + +**这是 Last Child Wins 的唯一盲区。** 当 N=0 时,Manager 必须自己充当"收口人"直接完成任务。 + --- ## 四、反模式速查表 @@ -227,6 +350,14 @@ await prisma.$transaction( | 不设 `expireInMinutes` | 僵尸 Job 占据队列名额 | Manager: 60min, Child: 30min | | 成功路径漏检 Last Child Wins | 任务永远卡在 processing | 成功 + 失败路径都检查 | | Child 运行时回查外部模块数据 | 源头删改导致批量崩溃 | Manager 快照元数据到子项记录 | +| 🆕 无 Sweeper 清道夫 | 进程 OOM/SIGKILL 后任务永远卡死 | 全局 Cron 扫描 processing > 2h 强制收口 | +| 🆕 事务内做网络请求 | 父表行锁长时间持有 → Lock timeout | 短事务:仅更新 Result + 递增 Task | +| 🆕 Child 派发漏 singletonKey | Manager 重试导致子任务指数级爆炸 | `singletonKey: child-${itemId}` | +| 🆕 NOTIFY payload 不截断 | 超 8000 bytes 直接报错阻断流程 | 发送前截断至 7000 bytes | +| 🆕 临时错误 throw 前不释放乐观锁 | 重试时被误判"幂等跳过",计数永远缺一票 | throw 前 `update({ status: 'pending' })` | +| 🆕 Sweeper 用 `startedAt` 判断卡死 | 误杀正在排队的健康超大批量任务 | 用 `updatedAt`(最后活跃时间) | +| 🆕 Manager 不检查空集合 | N=0 时无 Child → Last Child Wins 死锁 | `if (items.length === 0)` 直接 completed | +| 🆕 NOTIFY 用 `$executeRawUnsafe` 拼接 | SQL 注入高危 | `$executeRaw` + `pg_notify()` 参数化 | --- @@ -246,7 +377,7 @@ await pgBoss.send('module_task_child', { taskId, itemId }, { retryDelay: 10, // 10 秒后重试 retryBackoff: true, // 指数退避(10s, 20s, 40s) expireInMinutes: 30, - singletonKey: `child-${itemId}`, + singletonKey: `child-${itemId}`, // ← 派发防重!Manager 崩溃重试时 pg-boss 自动去重 }); // Worker 注册(队列名必须用下划线!) @@ -270,6 +401,14 @@ jobQueue.work('module_task_child', { teamConcurrency: 10 }, handler); - [ ] **数据快照**:Manager 是否在派发前快照了外部依赖数据? - [ ] **NOTIFY 广播**:SSE 日志推送是否经过 PostgreSQL NOTIFY(如需跨 Pod)? - [ ] **事务保障**:子项状态更新 + 父任务原子递增是否在同一事务中? +- [ ] 🆕 **Sweeper 清道夫**:是否注册了全局定时任务扫描 processing > 2h 的父任务并强制收口? +- [ ] 🆕 **短事务原则**:`$transaction` 内是否仅包含纯 DB 操作(无网络请求/无耗时计算)? +- [ ] 🆕 **派发防重**:Manager 循环派发 Child 时是否设置了 `singletonKey: child-${itemId}`? +- [ ] 🆕 **NOTIFY 截断**:NOTIFY payload 发送前是否截断至 7000 bytes 以内? +- [ ] 🆕 **乐观锁释放**:临时错误 `throw` 前是否将子项状态回退为 `pending`(防重试时被幂等跳过)? +- [ ] 🆕 **Sweeper 活跃判定**:清道夫是否基于 `updatedAt`(而非 `startedAt`)判断任务卡死? +- [ ] 🆕 **空集合守卫**:Manager 是否在 `items.length === 0` 时直接将任务标记为 `completed`? +- [ ] 🆕 **NOTIFY 参数化**:是否使用 `$executeRaw` + `pg_notify()` 而非 `$executeRawUnsafe` + 字符串拼接? --- @@ -287,4 +426,6 @@ jobQueue.work('module_task_child', { teamConcurrency: 10 }, handler); --- *本文档基于 ASL 工具 3 全文智能提取工作台开发计划(v1.5,经 6 轮架构审查)的设计经验总结。* +*v1.1 补充 4 项生产级防御策略:Sweeper 清道夫、短事务原则、singletonKey 派发防重、NOTIFY 安全截断。* +*v1.2 逐行级审查修正 4 项致命漏洞:乐观锁与重试绞杀、Sweeper 友军之火、空集合死锁、SQL 注入隐患。* *待 M1/M2 实战后升级为 v2.0,届时补充真实踩坑记录和性能数据。* diff --git a/docs/03-业务模块/ASL-AI智能文献/00-系统设计/证据整合V2.0/工具3批量提取技术架构设计(散装与轮询版).md b/docs/03-业务模块/ASL-AI智能文献/00-系统设计/证据整合V2.0/工具3批量提取技术架构设计(散装与轮询版).md new file mode 100644 index 00000000..c7fb3e26 --- /dev/null +++ b/docs/03-业务模块/ASL-AI智能文献/00-系统设计/证据整合V2.0/工具3批量提取技术架构设计(散装与轮询版).md @@ -0,0 +1,250 @@ +# **工具 3 批量提取技术架构设计:散装派发与轮询收口模式** + +**文档版本:** V2.0 (Startup Agile Edition) + +**核心架构:** 散装派发 (Scatter) \+ 独立单兵 Worker \+ 定时轮询聚合 (Polling Aggregator) + +**业务目标:** 支撑工具 3(百篇文献并发提取),告别行锁争用与死锁,实现最高开发效率与多节点并发性能。 + +## **💡 一、 为什么选择这套架构?(The Philosophy)** + +在处理“1 个任务包含 100 篇文献提取”的场景时,我们放弃了传统的“父子任务 Fan-out”强一致性模型,转而采用一种\*\*“最终一致性”\*\*的松耦合架构: + +1. **极简的写入逻辑 (无并发冲突):** 100 个 Worker 抢到任务后各干各的,**只更新属于自己的那 1 行 Result 记录**。绝对不去触碰父任务(Task 表),彻底消灭了多进程对同一行的行锁竞争 (Row-Lock Contention)。 +2. **读写分离的进度感知:** 前端查询进度时,API 实时去数据库做 COUNT(Result) 聚合,读操作极快且不阻塞写操作。 +3. **单线程结账 (无死锁):** 用一个每 10 秒跑一次的全局定时任务(Aggregator)充当“包工头”,扫描所有任务,发现哪个任务下面的子项全做完了,就给它打上 Completed 标签。 + +## **🏗️ 二、 核心数据流转图 (Data Flow)** + +\[ 前端 Client \] + │ 1\. POST /tasks (勾选了 100 篇文献) + ▼ +\[ Node.js API (Controller) \] + │ 2\. 创建 1 个 Task 记录 + │ 3\. 批量创建 100 个 Result 记录 (status: pending) + │ 4\. 🚀 散装派发:for 循环 100 次 \`pgBoss.send('asl\_extract\_single', ...)\` + └─\> 返回 TaskID 给前端 (耗时 \< 0.1秒) + +\======================== 异步处理域 (多 SAE 实例并发) \======================== + +\[ pg-boss 队列 (Postgres) \] \<── 存放着 100 个单篇提取任务 + +\[ Pod A \] \[ Pod B \] \[ Pod C \] + Worker 抢单 Worker 抢单 Worker 抢单 + │ │ │ + ├─ 提取 文献 1 ├─ 提取 文献 2 ├─ 提取 文献 3 + │ │ │ + └─ UPDATE Result 1 └─ UPDATE Result 2 └─ UPDATE Result 3 + (status: completed) (status: error) (status: completed) + ※ 各干各的,互不干扰,不碰 Task 表! + +\======================== 全局收口域 (单线程定时器) \======================== + +\[ pg-boss 调度器 (10秒触发一次) \] + │ +\[ Task Aggregator (全局唯一包工头) \] + │ 1\. 查出所有 status='processing' 的 Task + │ 2\. GROUP BY 统计其下 Result 的状态 + │ 3\. 如果 pending=0 且 extracting=0 + └─\> UPDATE Task SET status='completed' (终点收口!) + +## **🗄️ 三、 数据库设计微调 (Prisma Schema)** + +采用该模式后,AslExtractionTask 表不再需要频繁更新,成为一个极其稳定的元数据表。 + +model AslExtractionTask { + id String @id @default(uuid()) + projectId String + templateId String + totalCount Int // 总文献数 (前端传入,创建后不再改变) + + // 核心状态:'processing' (进行中), 'completed' (已完成) + // 此字段仅由 API 创建时设为 processing,由 Aggregator 统一改为 completed + status String @default("processing") + + // 弃用:不再需要 successCount / failedCount 字段,改由实时 COUNT 聚合得出! + + createdAt DateTime @default(now()) + completedAt DateTime? + + results AslExtractionResult\[\] + @@schema("asl\_schema") +} + +model AslExtractionResult { + id String @id @default(uuid()) + taskId String + pkbDocumentId String + + // 子任务状态: 'pending' (排队中), 'extracting' (提取中), 'completed' (成功), 'error' (失败) + status String @default("pending") + extractedData Json? // 最终提取的 JSON 结果 + errorMessage String? + + task AslExtractionTask @relation(fields: \[taskId\], references: \[id\]) + + // 添加索引:极大提升 Aggregator 聚合统计的速度 + @@index(\[taskId, status\]) + @@schema("asl\_schema") +} + +## **💻 四、 核心代码落地指南 (Show me the code)** + +### **1\. API 层:极速散装派发** + +**文件:** ExtractionController.ts + +无需编写 Manager Worker,直接在 API 接口中进行 for 循环派发。 + +async function createTask(req: Request, reply: FastifyReply) { + const { projectId, templateId, documentIds } \= req.body; + + if (documentIds.length \=== 0\) throw new Error("未选择文献"); + + // 1\. 批量创建记录 + const task \= await prisma.aslExtractionTask.create({ + data: { projectId, templateId, totalCount: documentIds.length, status: 'processing' } + }); + + const resultsData \= documentIds.map(docId \=\> ({ + taskId: task.id, pkbDocumentId: docId, status: 'pending' + })); + await prisma.aslExtractionResult.createMany({ data: resultsData }); + + // 查询出刚创建的 Result IDs + const createdResults \= await prisma.aslExtractionResult.findMany({ where: { taskId: task.id } }); + + // 2\. 🚀 散装派发 (Scatter) \- 直接压入单篇队列 + // 即使 100 次循环,得益于 pg-boss 内部的批量插入优化,耗时极短 + const jobs \= createdResults.map(result \=\> ({ + name: 'asl\_extract\_single', + data: { resultId: result.id, pkbDocumentId: result.pkbDocumentId }, + options: { retryLimit: 3, retryBackoff: true, expireInMinutes: 30 } // 单篇重试机制 + })); + await jobQueue.insert(jobs); // 假设你们底层封装了批量 insert 方法 + + return reply.send({ success: true, taskId: task.id }); +} + +### **2\. Worker 层:无脑单兵作战** + +**文件:** ExtractionSingleWorker.ts + +这里是真正调 MinerU 和 LLM 的地方。**没有任何并发锁,没有任何父任务更新。** + +// 限制单机并发,防 OOM 和 API 熔断 +jobQueue.work('asl\_extract\_single', { teamConcurrency: 10 }, async (job) \=\> { + const { resultId, pkbDocumentId } \= job.data; + + // 1\. 更改自身状态为 extracting (不碰父任务!) + await prisma.aslExtractionResult.update({ + where: { id: resultId }, data: { status: 'extracting' } + }); + + try { + // 2\. 执行漫长且脆弱的业务逻辑 (MinerU \+ DeepSeek-V3) + const data \= await extractLogic(pkbDocumentId); + + // 3\. 成功:只更新自身!(绝对安全) + await prisma.aslExtractionResult.update({ + where: { id: resultId }, + data: { status: 'completed', extractedData: data } + }); + + } catch (error) { + // 错误分级判断 + if (isPermanentError(error)) { + // 致命错误:更新自身为 error,打断重试 + await prisma.aslExtractionResult.update({ + where: { id: resultId }, data: { status: 'error', errorMessage: error.message } + }); + return { success: false, note: 'Permanent Error' }; + } else { + // 临时错误 (如网络波动):让出状态,抛出给 pg-boss 重试 + await prisma.aslExtractionResult.update({ + where: { id: resultId }, data: { status: 'pending' } + }); + throw error; + } + } +}); + +### **3\. Aggregator 层:全局包工头轮询收口** + +**文件:** ExtractionAggregator.ts + +**触发机制:** 使用 pg-boss 定时器,保证多 Pod 环境下同一时间只有 1 个机器执行此检查。 + +// 在后端启动时注册:每 10 秒跑一次 +await jobQueue.schedule('asl\_extraction\_aggregator', '\*/10 \* \* \* \* \*'); + +jobQueue.work('asl\_extraction\_aggregator', async () \=\> { + // 1\. 找到所有还没结束的父任务 + const activeTasks \= await prisma.aslExtractionTask.findMany({ + where: { status: 'processing' } + }); + + for (const task of activeTasks) { + // 2\. 分组统计其子任务状态 (聚合查询极快) + const stats \= await prisma.aslExtractionResult.groupBy({ + by: \['status'\], + where: { taskId: task.id }, + \_count: true + }); + + const pendingCount \= stats.find(s \=\> s.status \=== 'pending')?.\_count || 0; + const extractingCount \= stats.find(s \=\> s.status \=== 'extracting')?.\_count || 0; + + // 3\. 收口逻辑:没有任何人在排队或干活了,说明这批活彻底干完了(不论成功还是失败) + if (pendingCount \=== 0 && extractingCount \=== 0\) { + await prisma.aslExtractionTask.update({ + where: { id: task.id }, + data: { status: 'completed', completedAt: new Date() } + }); + + // 可选:在这里触发全量完成的业务动作 (如发送企业微信通知) + logger.info(\`Task ${task.id} completely finished via Aggregator\!\`); + } + } +}); + +### **4\. 前端查询 API:读写分离的进度感知** + +**文件:** TaskStatusController.ts + +由于父任务表没有 successCount,前端轮询调用 /tasks/:taskId/status 时,我们**实时读取计算进度**。 + +async function getTaskStatus(req, reply) { + const { taskId } \= req.params; + + const task \= await prisma.aslExtractionTask.findUnique({ where: { id: taskId }}); + + // 实时动态 COUNT,取代维护冗余字段 (100条数据的 count 耗时 \< 1ms,完全无感) + const successCount \= await prisma.aslExtractionResult.count({ + where: { taskId, status: 'completed' } + }); + const failedCount \= await prisma.aslExtractionResult.count({ + where: { taskId, status: 'error' } + }); + + return reply.send({ + status: task.status, // processing 或 completed + progress: { + total: task.totalCount, + success: successCount, + failed: failedCount, + percent: Math.round(((successCount \+ failedCount) / task.totalCount) \* 100\) + } + }); +} + +## **🛡️ 五、 方案优势与降维打击总结** + +采用这套“散装派发 \+ 轮询聚合”模式后,您的团队获得了如下战略优势: + +1. **彻底告别死锁 (No Deadlocks):** 不再有恶心的乐观锁和竞争态,研发人员只需要专注写“解析 PDF、调大模型、更新一条数据”的纯粹业务逻辑。 +2. **自带清道夫免疫 (Sweeper-free):** 如果某个 Node.js 进程在提取中途“猝死”(OOM),该篇文献的状态会一直卡在 extracting。pg-boss 发现它超时后会重新拉起变为 pending。只要它还在 pending/extracting,Aggregator 就不会关闭父任务。这天然规避了此前“硬崩溃导致永远卡死”的顶级漏洞。 +3. **开发提速 200%:** 架构理解成本降至最低。新人一听就懂:“打散分发,各个击破,定时结账”。 +4. **性能拉满 (Max Scale-out):** 多 SAE 实例部署时,100 个任务均匀分布在所有机器上。数据库没有任何行锁竞争,CPU 和 IO 利用率达到最完美的线性扩展。 + +**恭喜团队做出了最符合创业公司发展阶段的高可用架构决策!您可以直接将本设计文档交由后端研发开展 Sprint 1 的开发!** \ No newline at end of file diff --git a/docs/03-业务模块/ASL-AI智能文献/02-技术设计/MinerU API 文档.docx b/docs/03-业务模块/ASL-AI智能文献/02-技术设计/MinerU API 文档.docx new file mode 100644 index 00000000..b7d7c793 Binary files /dev/null and b/docs/03-业务模块/ASL-AI智能文献/02-技术设计/MinerU API 文档.docx differ diff --git a/docs/03-业务模块/ASL-AI智能文献/04-开发计划/08a-工具3-M1-骨架管线冲刺清单.md b/docs/03-业务模块/ASL-AI智能文献/04-开发计划/08a-工具3-M1-骨架管线冲刺清单.md index d34cf889..25910668 100644 --- a/docs/03-业务模块/ASL-AI智能文献/04-开发计划/08a-工具3-M1-骨架管线冲刺清单.md +++ b/docs/03-业务模块/ASL-AI智能文献/04-开发计划/08a-工具3-M1-骨架管线冲刺清单.md @@ -3,7 +3,7 @@ > **所属:** 工具 3 全文智能提取工作台 V2.0 > **架构总纲:** `08-工具3-全文智能提取工作台V2.0开发计划.md` > **代码手册:** `08d-工具3-代码模式与技术规范.md`(所有代码模式均在此手册中,开发时按需查阅) -> **建议时间:** Week 1(5-6 天) +> **建议时间:** Week 1(5.5-6.5 天,含 v1.6 Sweeper 清道夫 0.5 天) > **核心目标:** 证明 "PKB 拿数据 → Fan-out 分发 → LLM 盲提 → 数据落库 → 前端看到 completed" 这条管线是通的。 --- @@ -69,13 +69,17 @@ - `PkbBridgeService.ts`:调用 `PkbExportService`,代理所有 PKB 数据访问 **Step C — Fan-out Manager + Child Worker(1 天)⚠️ 核心战役:** -- `ExtractionManagerWorker.ts`:读取任务 → ⚠️ v1.5 批量快照 PKB 元数据(`snapshotStorageKey` + `snapshotFilename`)冻结到 `AslExtractionResult` → 为每篇文献 `pgBoss.send('asl_extraction_child', ...)` → 退出(Fire-and-forget) +- `ExtractionManagerWorker.ts`:读取任务 → 🆕 **v1.6 空集合守卫**(`results.length === 0` → 直接 completed) → ⚠️ v1.5 批量快照 PKB 元数据(`snapshotStorageKey` + `snapshotFilename`)冻结到 `AslExtractionResult` → 为每篇文献 `pgBoss.send('asl_extraction_child', ...)` → 退出(Fire-and-forget) - `ExtractionChildWorker.ts` 完整逻辑: 1. **乐观锁抢占**:`updateMany({ where: { status: 'pending' }, data: { status: 'extracting' } })` 2. **纯文本降级提取**:从 PKB 读 `extractedText` + 写死 RCT Schema → 调用 DeepSeek 3. **原子递增**:事务内 `update Result + increment Task counts` 4. **Last Child Wins**:`successCount + failedCount >= totalCount` → 翻转 `status = completed` - 5. **错误分级路由**:致命错误 return / 临时错误 throw + 5. **错误分级路由**:致命错误 return / 🆕 **v1.6 临时错误 throw 前释放乐观锁(回退 status → pending)** + +**Step D — 🆕 Sweeper 清道夫注册(0.5 天)(v1.6 新增):** +- `asl_extraction_sweeper`:pg-boss 定时任务,每 10 分钟扫描 `processing` 且 `updatedAt > 2h` 的任务,强制标记 `failed` +- 使用 `updatedAt`(最后活跃时间)判断卡死,禁止用 `startedAt`(防误杀健康的超大批量任务) **Worker 注册(遵守队列命名规范):** ``` @@ -94,9 +98,14 @@ jobQueue.work('asl_extraction_child', { teamConcurrency: 10 }, handler) - [ ] Last Child Wins:最后一个 Child 翻转 Task status = completed - [ ] 致命错误(PKB 文档不存在)→ 该篇标 error + 不重试 + 不阻塞其他篇 - [ ] 临时错误(429)→ pg-boss 指数退避重试 +- [ ] 🆕 临时错误 throw 前回退 `status → pending`:模拟 429 重试后乐观锁仍能抢占成功(v1.6 乐观锁释放验证) +- [ ] 🆕 Manager 空集合守卫:`results.length === 0` 时 Task 直接标记 `completed`(v1.6 边界验证) +- [ ] 🆕 Sweeper 清道夫已注册:`asl_extraction_sweeper` 定时任务在 pg-boss 中可查到(v1.6) +- [ ] 🆕 Sweeper 判定条件为 `updatedAt > 2h`,而非 `startedAt`(v1.6 防误杀验证) > 📖 Fan-out 架构图、Worker 代码模式、研发红线见架构总纲 Task 2.3 -> 📖 ACL 防腐层设计见架构总纲 Task 3.3b +> 📖 ACL 防腐层设计见架构总纲 Task 3.3b +> 📖 Sweeper、乐观锁释放、空集合守卫代码见 08d §4.2 / §4.3 / §4.6 --- @@ -150,6 +159,9 @@ jobQueue.work('asl_extraction_child', { teamConcurrency: 10 }, handler) | 5 | Job Payload 仅传 ID(< 200 bytes),禁止塞 PDF 正文 | pg-boss 阻塞 | | 6 | ACL 防腐层:ASL 不 import PKB 内部类型 | 模块耦合蔓延 | | 7 | Manager 必须快照 `snapshotStorageKey` + `snapshotFilename`,Child 禁止运行时回查 PKB 获取 storageKey(v1.5) | 提取中 PKB 删文档 → 批量崩溃 | +| 8 | 🆕 临时错误 `throw` 前必须 `update({ status: 'pending' })` 释放乐观锁(v1.6) | 重试时被"幂等跳过",计数永远缺一票,Task 永久卡死 | +| 9 | 🆕 Manager 必须检查 `results.length === 0` 并直接 completed(v1.6) | 空文献 → 无 Child → Last Child Wins 死锁 | +| 10 | 🆕 必须注册 `asl_extraction_sweeper` 清道夫(`updatedAt > 2h`,禁止用 `startedAt`)(v1.6) | 进程 OOM/SIGKILL 后 Task 永久挂起 | --- @@ -160,6 +172,8 @@ jobQueue.work('asl_extraction_child', { teamConcurrency: 10 }, handler) ✅ PKB ACL 防腐层 → PkbExportService + PkbBridgeService ✅ Fan-out 全链路:Manager → N × Child → Last Child Wins → completed ✅ 乐观锁 + 原子递增 + 错误分级路由 — 所有并发 Bug 已验证 +✅ 🆕 Sweeper 清道夫注册(v1.6 防 OOM/SIGKILL 卡死) +✅ 🆕 乐观锁释放 + 空集合守卫 + pg_notify 参数化(v1.6 全量代码级同步) ✅ 前端三步走:选模板/选文献 → 轮询进度 → 极简结果列表 ❌ 无 MinerU(纯文本降级) ❌ 无 SSE 日志流 diff --git a/docs/03-业务模块/ASL-AI智能文献/04-开发计划/08d-工具3-代码模式与技术规范.md b/docs/03-业务模块/ASL-AI智能文献/04-开发计划/08d-工具3-代码模式与技术规范.md index 34d0747b..5a0428e8 100644 --- a/docs/03-业务模块/ASL-AI智能文献/04-开发计划/08d-工具3-代码模式与技术规范.md +++ b/docs/03-业务模块/ASL-AI智能文献/04-开发计划/08d-工具3-代码模式与技术规范.md @@ -137,13 +137,11 @@ class PdfProcessingPipeline { ### 3.2 PKB 复用感知日志 ```typescript +// 🚨 v1.6:使用 broadcastLog 跨 Pod 广播(替代 sseEmitter.emit) if (pkbExtractedText) { - this.sseEmitter.emit(taskId, { - type: 'log', - data: { - source: 'system', - message: `⚡ [Fast-path] Reused full-text from PKB (saved ~10s pymupdf4llm): ${filename}`, - } + await broadcastLog(taskId, { + source: 'system', + message: `⚡ [Fast-path] Reused full-text from PKB (saved ~10s pymupdf4llm): ${filename}`, }); } ``` @@ -189,6 +187,21 @@ class ExtractionManagerWorker { const task = await prisma.aslExtractionTask.findUnique({ where: { id: job.data.taskId } }); const results = await prisma.aslExtractionResult.findMany({ where: { taskId: task.id } }); + // ═══════════════════════════════════════════════════════════ + // 🚨 v1.6 空集合边界守卫 + // 如果文献被全部删除或过滤后 results 为空,无 Child 被派发, + // Last Child Wins 永远不触发,Task 永远卡在 processing。 + // Manager 必须自己充当"收口人"直接完成任务。 + // ═══════════════════════════════════════════════════════════ + if (results.length === 0) { + await prisma.aslExtractionTask.update({ + where: { id: task.id }, + data: { status: 'completed', completedAt: new Date() }, + }); + await broadcastLog(task.id, { source: 'system', message: '⚠️ No documents to extract, task auto-completed.' }); + return; + } + // ═══════════════════════════════════════════════════════════ // ⚠️ v1.5 PKB 数据一致性快照 // 提取任务可能持续 50 分钟,期间用户可能在 PKB 删除/修改文档。 @@ -284,11 +297,8 @@ class ExtractionChildWorker { }), ]); - // SSE 推送日志 - this.sseEmitter.emit(taskId, { - type: 'log', - data: { source: 'system', message: `✅ ${extractResult.filename} extracted` } - }); + // 🚨 v1.6:SSE 推送日志(跨 Pod 广播,替代原 sseEmitter.emit) + await broadcastLog(taskId, { source: 'system', message: `✅ ${extractResult.filename} extracted` }); // ═══════════════════════════════════════════════════════════ // ⚠️ v1.4.2 补丁 1:"Last Child Wins" 终止器 @@ -300,7 +310,7 @@ class ExtractionChildWorker { where: { id: taskId }, data: { status: 'completed', completedAt: new Date() }, }); - this.sseEmitter.emit(taskId, { type: 'complete' }); + await broadcastLog(taskId, { source: 'system', type: 'complete', message: '🎉 All documents extracted.' }); } } catch (error) { @@ -324,12 +334,23 @@ class ExtractionChildWorker { where: { id: taskId }, data: { status: 'completed', completedAt: new Date() }, }); - this.sseEmitter.emit(taskId, { type: 'complete' }); + await broadcastLog(taskId, { source: 'system', type: 'complete', message: '🎉 All documents extracted.' }); } return { success: false, reason: 'Permanent failure, aborted retry.' }; } - // 临时错误 (429/网络抖动):直接 throw,让 pg-boss 自动指数退避重试 + // ═══════════════════════════════════════════════════════════ + // 🚨 v1.6 补丁:临时错误 throw 前必须释放乐观锁! + // 原因:上方 updateMany 已将 status 改为 'extracting'。 + // 如果裸 throw,pg-boss 重试时乐观锁 where: { status: 'pending' } + // 返回 count=0 → 误判"幂等跳过" → 计数永远少一票 → Last Child Wins 永远不触发。 + // ═══════════════════════════════════════════════════════════ + await prisma.aslExtractionResult.update({ + where: { id: resultId }, + data: { status: 'pending' }, + }); + + // 临时错误 (429/网络抖动):throw → pg-boss 自动指数退避重试 throw error; } } @@ -388,6 +409,50 @@ class ExtractionChildWorker { | **死信处理** | 超过 retryLimit 的 Job 进入 DLQ | pg-boss 内置 `onFail` handler 标记该篇为 `error` | | **进度追踪** | 不在 Job data 中存大量进度 | 进度统一走 `CheckpointService`,Job data 仅含 ID 引用 | +### 🆕 4.6 Sweeper 清道夫 — 进程硬崩溃兜底(v1.6) + +> **Fan-out 指南 v1.2 强制要求:** 单兵 Worker 无法处理自身猝死(OOM/SIGKILL), +> 必须有系统级外部定时任务兜底。否则父任务可能永远卡在 `processing`。 + +```typescript +// ===== 工具 3 专属清道夫(模块启动时注册) ===== +async function aslExtractionSweeper() { + const stuckTasks = await prisma.aslExtractionTask.findMany({ + where: { + status: 'processing', + // 🚨 使用 updatedAt(最后活跃时间),而非 startedAt! + // 500 篇文献正常排队可能需要 3+ 小时,用 startedAt 会误杀健康任务。 + // 只要 Child 还在完成并递增计数,updatedAt 就会持续刷新。 + updatedAt: { lt: new Date(Date.now() - 2 * 60 * 60 * 1000) }, + }, + }); + + for (const task of stuckTasks) { + await prisma.aslExtractionTask.update({ + where: { id: task.id }, + data: { + status: 'failed', + errorMessage: '[Sweeper] No progress for 2h — likely Child Worker OOM/SIGKILL. Force-closed.', + completedAt: new Date(), + }, + }); + // 广播失败事件,确保前端 SSE 能感知 + await broadcastLog(task.id, { + source: 'system', + type: 'complete', + message: '❌ [Sweeper] Task force-closed after 2h inactivity.', + }); + logger.warn(`[Sweeper] Force-closed stuck task ${task.id} (no progress for 2h)`); + } +} + +// 注册为 pg-boss 定时任务(每 10 分钟扫描一次) +await jobQueue.schedule('asl_extraction_sweeper', '*/10 * * * *'); +await jobQueue.work('asl_extraction_sweeper', aslExtractionSweeper); +``` + +> **关键:** Sweeper 判断"卡死"基于 `updatedAt` 而非 `startedAt`,避免误杀正在排队的超大批量任务。 + --- ## 5. fuzzyQuoteMatch 验证算法 @@ -624,24 +689,29 @@ function ExtractionProgress({ taskId }: { taskId: string }) { ```typescript // ===== Worker 发送端(ExtractionChildWorker 内部) ===== -// 替代原有的 this.sseEmitter.emit(),改用 NOTIFY 广播 +// 🚨 v1.6 修正:使用 pg_notify() + Prisma 参数化绑定(免疫 SQL 注入) +// 替代原有的 this.sseEmitter.emit() 和 $executeRawUnsafe 字符串拼接 async function broadcastLog(taskId: string, logEntry: LogEntry) { - const payload = JSON.stringify({ + const payloadStr = JSON.stringify({ taskId, - type: 'log', + type: logEntry.type ?? 'log', data: logEntry, }); - // NOTIFY payload 上限 8000 bytes,日志消息绰绰有余 - await prisma.$executeRawUnsafe( - `NOTIFY asl_sse_channel, '${payload.replace(/'/g, "''")}'` - ); + + // 🚨 NOTIFY payload 物理上限 ~8000 bytes,LLM 错误堆栈可能超限 + const safePayload = payloadStr.length > 7000 + ? payloadStr.substring(0, 7000) + '..."}' + : payloadStr; + + // 参数化绑定:$executeRaw Tagged Template + pg_notify() + // 彻底免疫 SQL 注入,无需手动 .replace 转义 + await prisma.$executeRaw`SELECT pg_notify('asl_sse_channel', ${safePayload})`; } -// 使用方式(替代 this.sseEmitter.emit) +// 使用方式(全面替代 this.sseEmitter.emit) await broadcastLog(taskId, { source: 'system', message: `✅ ${filename} extracted`, - timestamp: new Date().toISOString(), }); ``` @@ -684,7 +754,8 @@ class SseNotifyBridge { ``` **关键约束:** -- NOTIFY payload 上限 **8000 bytes**(日志消息远小于此限制) +- NOTIFY payload 物理上限 **~8000 bytes** → 发送前必须截断至 **7000 bytes**(v1.6 强制规范) +- **禁止 `$executeRawUnsafe` + 字符串拼接!** 必须使用 `$executeRaw` Tagged Template + `pg_notify()`(v1.6 强制规范) - LISTEN 连接必须**独立于 Prisma 连接池**(PgClient 单独创建) - NOTIFY 是 fire-and-forget(无持久化),完美匹配 v1.4 双轨制定位 - `complete` 事件仍走 NOTIFY 广播,确保"Last Child Wins"翻转状态后所有 Pod 的 SSE 客户端都能收到 diff --git a/docs/03-业务模块/SSA-智能统计分析/00-模块当前状态与开发指南.md b/docs/03-业务模块/SSA-智能统计分析/00-模块当前状态与开发指南.md index 21c279ac..7a71b895 100644 --- a/docs/03-业务模块/SSA-智能统计分析/00-模块当前状态与开发指南.md +++ b/docs/03-业务模块/SSA-智能统计分析/00-模块当前状态与开发指南.md @@ -1,13 +1,24 @@ # SSA智能统计分析模块 - 当前状态与开发指南 -> **文档版本:** v3.4 +> **文档版本:** v3.5 > **创建日期:** 2026-02-18 -> **最后更新:** 2026-02-22 +> **最后更新:** 2026-02-23 > **维护者:** 开发团队 -> **当前状态:** 🎉 **QPER 主线闭环 + Phase I + Phase II + Phase III + Phase IV(对话驱动分析 + QPER 集成)开发完成** +> **当前状态:** 🎉 **QPER 主线闭环 + Phase I-IV + Phase V-A(分析方案变量可编辑化)开发完成** > **文档目的:** 快速了解SSA模块状态,为新AI助手提供上下文 > -> **最新进展(2026-02-22 Phase IV 完成):** +> **最新进展(2026-02-23 Phase V-A 变量可编辑化完成):** +> - ✅ **分析方案变量可编辑化** — 系统默认帮选变量,医生可在方案审查阶段修改/调整变量选择 +> - ✅ **三层柔性拦截** — Layer 1 即时黄条警告 + Layer 2 步骤警告图标 + Layer 3 执行前阻断确认弹窗(Informed Consent) +> - ✅ **变量选择器 UI** — 单选下拉(按类型分组)+ 多选标签(分类=紫色 / 连续=蓝色)+ 全选分类/连续快捷按钮 + 不适配变量 ⚠️ 标记 +> - ✅ **tool_param_constraints 配置** — 12 个统计工具参数约束表,前后端共用单一事实来源 +> - ✅ **后端 PATCH API + Zod 防火墙** — PATCH /workflow/:id/params + 结构校验(400 Bad Request)/ 统计学校验交给 R 引擎 +> - ✅ **同步阻塞执行** — 执行按钮 Promise Chaining:await PATCH -> 再触发执行 + loading 防连点 +> - ✅ **inferGroupingVar 恢复** — LLM 未识别分组变量时,自动推断二分类变量填入默认值 +> - ✅ **DynamicReport 增强** — 兼容 R 基线表对象格式 rows,Word 导出同步修复 +> - ✅ **前后端集成测试通过** — 队列研究完整执行 + 报告导出验证 +> +> **此前进展(2026-02-22 Phase IV 完成):** > - ✅ **Phase IV 全 5 批次完成** — ToolOrchestratorService(PICO hint 三层降级)+ handleAnalyze 重写(plan→analysis_plan SSE→LLM 方案说明→ask_user 确认)+ AVAILABLE_TOOLS 配置化(11 处改 toolRegistryService)+ 前端 SSE 对接(analysis_plan + plan_confirmed) > - ✅ **团队审查 H1-H3+B1-B2 全部落地** — H1 PICO hint 注入 / H2 幽灵卡片清除 / H3 SSE 严格串行 / B1 修改建议循环 / B2 旧 API 兼容 > - ✅ **SSA_ANALYZE_PLAN Prompt 入库** — 指导 LLM 用自然语言解释分析方案(步骤/理由/注意事项) @@ -57,7 +68,7 @@ | **前端状态模型** | **Unified Record Architecture — 一次分析 = 一个 Record = N 个 Steps** | | **商业价值** | ⭐⭐⭐⭐⭐ 极高 | | **目标用户** | 临床研究人员、生物统计师 | -| **开发状态** | 🎉 **QPER 主线闭环 + 智能对话架构设计完成,Phase Deploy 待启动** | +| **开发状态** | 🎉 **QPER 主线闭环 + Phase I-IV + Phase V-A(变量可编辑化)完成** | ### 核心目标 @@ -159,7 +170,8 @@ AnalysisRecord { | **Phase II** | **对话层 LLM + 意图路由器 + 统一对话入口** | **35h** | ✅ **已完成(4 批次, 12 文件, E2E 38/38, H1-H4 落地)** | 2026-02-22 | | **Phase III** | **method_consult + ask_user 标准化** | **20h** | ✅ **已完成(5 批次, 12 文件, E2E 13/13+4skip, H1-H3+P1 落地)** | 2026-02-22 | | **Phase IV** | **对话驱动分析 + QPER 集成** | **14h** | ✅ **已完成(5 批次, 11 文件, E2E 25/25, H1-H3+B1-B2 落地)** | 2026-02-22 | -| **Phase V** | **反思编排 + 高级特性** | **18h** | 📋 待开始 | - | +| **Phase V-A** | **分析方案变量可编辑化** | **~6h** | ✅ **已完成(9 文件, 团队双视角审查 V2, 三层柔性拦截)** | 2026-02-23 | +| **Phase V-B** | **反思编排 + 高级特性** | **18h** | 📋 待开始 | - | | **Phase VI** | **集成测试 + 可观测性** | **10h** | 📋 待开始 | - | ### 已完成核心功能 @@ -181,7 +193,9 @@ AnalysisRecord { | **Phase III 前端** | AskUserCard(4 inputType + H1 跳过按钮)+ useSSAChat 扩展(pendingQuestion + respondToQuestion + skipQuestion) | ✅ | | **Phase IV 后端** | ToolOrchestratorService(plan+PICO hint 三层降级+formatPlanForLLM)+ ChatHandlerService 重写(handleAnalyze: plan→analysis_plan SSE→LLM 说明→ask_user 确认; handleAskUserResponse: confirm_plan/change_method)+ AVAILABLE_TOOLS 配置化(11 处→toolRegistryService)+ ToolRegistryService(+getVisibleTools)+ AskUserService(+metadata)+ SSA_ANALYZE_PLAN Prompt 入库 | ✅ | | **Phase IV 前端** | useSSAChat(analysis_plan+plan_confirmed SSE 处理+pendingPlanConfirm→executeWorkflow)+ SSAChatPane(AskUserCard 渲染+幽灵卡片清除 H2) | ✅ | -| **测试** | QPER 端到端 40/40 + 集成测试 7 Bug 修复 + Phase I E2E 31/31 + Phase II E2E 38/38 + Phase III E2E 13/13+4skip + Phase IV E2E 25/25 | ✅ | +| **Phase V-A 后端** | PATCH /workflow/:id/params(Zod 结构校验防火墙)+ tool_param_constraints.json(12 工具参数约束)+ inferGroupingVar 恢复(默认填充分组变量) | ✅ | +| **Phase V-A 前端** | WorkflowTimeline 可编辑化(SingleVarSelect + MultiVarTags + 三层柔性拦截)+ ssaStore updateStepParams + SSAWorkspacePane 同步阻塞执行 + DynamicReport 对象 rows 兼容 + Word 导出修复 | ✅ | +| **测试** | QPER 端到端 40/40 + 集成测试 7 Bug 修复 + Phase I E2E 31/31 + Phase II E2E 38/38 + Phase III E2E 13/13+4skip + Phase IV E2E 25/25 + Phase V-A 前后端集成测试通过 | ✅ | --- @@ -210,7 +224,8 @@ backend/src/modules/ssa/ │ ├── ConfigLoader.ts # 通用 JSON 加载 + Zod 校验 │ ├── tools_registry.json # R 工具注册表 │ ├── decision_tables.json # 四维匹配规则 -│ └── flow_templates.json # 流程模板 +│ ├── flow_templates.json # 流程模板 +│ └── tool_param_constraints.json # Phase V-A:12 工具参数类型约束 ├── types/ │ ├── query.types.ts # Q 层接口 │ ├── reflection.types.ts # R 层接口 @@ -324,7 +339,7 @@ npx tsx scripts/seed-ssa-phase4-prompts.ts # Phase IV: SSA_ANALYZE_PLAN ### 近期(优先级高) -1. **Phase V — 反思编排 + 高级特性(18h / 3 天)** +1. **Phase V-B — 反思编排 + 高级特性(18h / 3 天)** - 错误分类器实现(可自愈 vs 不可自愈) - 自动反思(静默重试,MAX 2 次)+ 手动反思(用户驱动,feedback 意图) - write_report interpret 模式 + discuss 意图处理(深度解读已有结果) @@ -335,7 +350,7 @@ npx tsx scripts/seed-ssa-phase4-prompts.ts # Phase IV: SSA_ANALYZE_PLAN 3. **Phase VI(10h)** — 集成测试 + 可观测性(含 QPER 透明化) -**详细计划:** `04-开发计划/11-智能对话与工具体系开发计划.md`(v1.8,Phase I-IV 完成,含架构约束 C1-C8 + 全部团队审查落地记录) +**详细计划:** `04-开发计划/11-智能对话与工具体系开发计划.md`(v1.8,Phase I-IV + Phase V-A 完成,含架构约束 C1-C8 + 全部团队审查落地记录) --- @@ -380,7 +395,7 @@ npx tsx scripts/seed-ssa-phase4-prompts.ts # Phase IV: SSA_ANALYZE_PLAN --- -**文档版本:** v3.4 -**最后更新:** 2026-02-22 -**当前状态:** 🎉 QPER 主线闭环 + Phase I + Phase II + Phase III + Phase IV 已完成 -**下一步:** Phase V(反思编排 + 高级特性,18h/3 天) +**文档版本:** v3.5 +**最后更新:** 2026-02-23 +**当前状态:** 🎉 QPER 主线闭环 + Phase I-IV + Phase V-A(变量可编辑化)已完成 +**下一步:** Phase V-B(反思编排 + 高级特性,18h/3 天) diff --git a/docs/03-业务模块/SSA-智能统计分析/06-开发记录/J技术报告审核评估与建议/架构与统计双重视角审查报告:变量可编辑化.md b/docs/03-业务模块/SSA-智能统计分析/06-开发记录/J技术报告审核评估与建议/架构与统计双重视角审查报告:变量可编辑化.md new file mode 100644 index 00000000..9d1cbf67 --- /dev/null +++ b/docs/03-业务模块/SSA-智能统计分析/06-开发记录/J技术报告审核评估与建议/架构与统计双重视角审查报告:变量可编辑化.md @@ -0,0 +1,91 @@ +# **架构与统计双重视角审查报告:分析方案变量可编辑化** + +**审查对象:** plan\_editable\_variables\_27d3a9fd.plan.md + +**审查时间:** 2026-02-23 + +**总体评级:** 🌟 **A 级 (方向极其正确,但存在隐性逻辑冲突需补强)** + +**核心裁决:** 批准开发。但在前端变量过滤逻辑和后端校验机制上,必须引入“基于方法 Schema 的强约束”,否则极易导致下游 R 引擎大面积崩溃。 + +## **一、 视角一:资深统计学专家的评估** + +**“不要给用户犯错的自由。医学统计的容错率是 0。”** + +### **1\. 极度认可的改进** + +* **尊重临床逻辑**:AI 经常会把“住院天数”和“年龄”搞混,或者漏掉医生特别关心的某个协变量。允许医生在执行前把遗漏的变量(Tag)加回来,这才是真正懂临床的工具。 +* **按类型分组展示**:下拉面板将连续变量和分类变量分开展示,极大地降低了医生寻找变量的认知负荷。 + +### **2\. 统计学视角的致命盲区 (The Statistical Blind Spot)** + +正如您在提问中敏锐指出的,仅仅区分“分类 (Categorical)”和“连续 (Continuous)”是远远不够的。 + +* **二元 Logistic 回归陷阱**:该方法要求结局指标(Y)**必须且只能**是二分类变量(如:死/活,0/1)。如果用户的下拉列表里显示了所有“分类变量”(包含了 3 分类的“血型”),一旦用户手抖选了“血型”,后端的 R 代码执行时将 100% 报错崩溃。 +* **T 检验陷阱**:独立样本 T 检验的分组变量(X)**必须**是二分类变量。如果是 3 分类变量,必须用 ANOVA。 +* **生存分析陷阱**:它需要两个 Y(Time 是连续,Status 是二分类 0/1)。 + +### **🛠️ 统计专家的修正建议:引入“细粒度统计类型过滤”** + +前端的下拉框候选项(Options),不能仅仅根据 type \=== 'categorical' 来过滤,必须**与当前 Step 绑定的统计方法强关联**。 + +* **建议实现**:前端在渲染下拉框时,必须读取该统计工具的 params\_schema(在 Phase III / IV 中已定义)。 +* **UI 约束逻辑**: + * 如果当前是 ST\_LOGISTIC,结局变量的下拉框**只能**展示 DataProfile 中推断为 categorical\_2 (唯一值为2) 的变量。 + * 对于不符合当前统计方法要求的变量,在下拉框中将其 disabled (置灰),并 hover 提示:“该变量为多分类,二元逻辑回归仅支持二分类变量”。 + +## **二、 视角二:资深架构师的评估** + +**“用户的每一次修改,都可能打破系统原本自洽的状态机。”** + +### **1\. 架构师高度赞赏的决策 (Brilliant Architectural Choices)** + +* **后端 PATCH API 的设计 (方案 A)**: + 这是非常正统的 RESTful 设计。用户修改参数后,先 PATCH /params 更新数据库实体,然后再触发 executeWorkflow。这保证了数据库中的 Plan 永远是 Single Source of Truth(单一事实来源),避免了前端传来“幽灵参数”导致历史记录无法复现的灾难。 + +### **2\. 工程视角的潜在风险 (Engineering Risks)** + +#### **🚨 风险 1:UI 状态与执行动作的竞态条件 (Race Condition)** + +* **场景**:用户在前端刚从下拉框里切换了变量,还没等组件把 state 同步完毕,或者还没等 PATCH 接口返回 200 OK,用户就光速点击了“开始执行分析”按钮。 +* **后果**:executeWorkflow 可能会拿着数据库里**旧的参数**去执行。 +* **修正建议**: + “开始执行分析”按钮必须绑定一个复合动作(Promise Chaining): + async function handleExecute() { + setExecuting(true); + // 1\. 必须先 await 等待 PATCH 成功 + if (hasUnsavedChanges) { + await api.patchWorkflowParams(workflowId, modifiedSteps); + } + // 2\. 然后再触发执行 + startSseExecution(workflowId); + } + +#### **🚨 风险 2:级联失效与重新规划的边界 (Cascading Invalidation)** + +* **场景**:AI 原本规划了 \[描述统计 \-\> T检验 \-\> Mann-Whitney\]。此时,用户在“描述统计”那一步,把 analyze\_vars 里的变量全删了,换成了一批全新的变量。 +* **架构思考**:此时,下游的 T检验 步骤的参数是否还有效? +* **修正建议 (MVP 阶段的防御性降维)**: + 在当前计划中,请严格限制:**参数的可编辑性仅限于“同类替换”**。 + 如果用户想要推翻整个研究假设(比如把 Y 变量从“血压”改成了“有效/无效”),系统不应该允许他们通过修改参数来完成,因为这会触发统计方法的变更(T检验 变 卡方)。 + * **前端提示**:在卡片顶部加一行提示:“如需更改核心分析目标(如改变数据类型),请在对话框告诉 AI 重新生成方案。” + +#### **🚨 风险 3:Zod Schema 的后端防御 (Backend Defense)** + +* **场景**:前端即便做了限制,但网络请求是可以被篡改的,或者存在前端 Bug 漏传了非法参数。 +* **修正建议**: + 新增的 PATCH /api/v1/ssa/workflow/:workflowId/params 接口,**绝对不能盲目接收数据**。它必须使用对应 R 工具的 Zod Schema 进行强校验。如果在 T 检验的 group\_var 里接收到了一个在 DataContext 中被标记为连续数值的变量,后端必须拦截并返回 400 Bad Request。 + +## **三、 终极结论与实施调整指南 (Actionable Summary)** + +您的计划大体方向非常优秀,不仅提升了可用性,还大幅缓解了 AI 的幻觉焦虑。为了让它完美落地,请在您的开发计划中追加以下 **3 个微小但致命的补丁**: + +1. **细化前端过滤条件 (UI Filter Patch)**: + * 在 WorkflowTimeline.tsx 渲染下拉框时,利用 VariableDictionary 中更精细的属性(如 unique\_values\_count)来约束选项。 + * 例如:如果是分组变量下拉框,仅高亮展示 type \=== 'categorical' && levels \<= 5 的变量。 +2. **同步阻塞执行 (Sync Block Patch)**: + * 确保“执行按钮”的 onClick 事件中,严格包含 await patchApi(),并在进行网络请求时将按钮置为 loading 状态,防止连点。 +3. **后端的参数防火墙 (Backend Firewall Patch)**: + * 在开发 PATCH API 时,务必对传入的 params 进行统计学常识级别的 Zod 校验,防止将脏参数写入数据库,导致后续 R 引擎因 Fatal Error 宕机。 + +**批示:完全批准按照此计划及上述修正建议执行开发!这会让 SSA-Pro 的专业度再上一个大台阶。** \ No newline at end of file diff --git a/docs/03-业务模块/SSA-智能统计分析/06-开发记录/J技术报告审核评估与建议/架构与统计双重视角审查报告:变量可编辑化V2修订版.md b/docs/03-业务模块/SSA-智能统计分析/06-开发记录/J技术报告审核评估与建议/架构与统计双重视角审查报告:变量可编辑化V2修订版.md new file mode 100644 index 00000000..0f3df923 --- /dev/null +++ b/docs/03-业务模块/SSA-智能统计分析/06-开发记录/J技术报告审核评估与建议/架构与统计双重视角审查报告:变量可编辑化V2修订版.md @@ -0,0 +1,84 @@ +# **架构与统计双重视角审查报告:分析方案变量可编辑化** V2 修订版 + +**审查对象:** plan\_editable\_variables\_27d3a9fd.plan.md 及 团队 UX 修正反馈 **文档状态:** V2 修订版 (采纳“柔性拦截”方案) **审查时间:** 2026-02-23 + +**总体评级:** 🌟 **A+ 级 (方向极其正确,兼顾了学术严谨与用户掌控感)** **核心裁决:** 批准开发。团队提出的“软提示 \+ 强引导”完美解决了级联失效的体验问题,但必须配合后端的“强防火墙”才能安全落地。 + +## **一、 视角一:资深统计学专家的评估** + +**“不要替医生做决定,但要给医生最专业的警告。”** + +### **1\. 极度认可的改进** + +* **尊重临床逻辑**:AI 经常会把“住院天数”和“年龄”搞混,或者漏掉医生特别关心的某个协变量。允许医生在执行前把遗漏的变量(Tag)加回来,这才是真正懂临床的工具。 +* **按类型分组展示**:下拉面板将连续变量和分类变量分开展示,极大地降低了医生寻找变量的认知负荷。 + +### **2\. 统计学视角的隐形陷阱 (The Statistical Blind Spot)** + +仅仅区分“分类 (Categorical)”和“连续 (Continuous)”是远远不够的。不同的统计方法对变量有着极其严苛的专属要求: + +* **二元 Logistic 回归陷阱**:该方法要求结局指标(Y)**必须且只能**是二分类变量(如:死/活,0/1)。如果用户选了 3 分类的“血型”,后端的 R 代码将无法计算。 +* **T 检验陷阱**:独立样本 T 检验的分组变量(X)**必须**是二分类变量。如果是 3 分类变量,必须用 ANOVA。 + +### **🛠️ 统计专家的修正建议:引入“细粒度统计类型过滤” (Soft Filtering)** + +前端的下拉框候选项(Options),不能仅仅根据 type \=== 'categorical' 来过滤,必须**与当前 Step 绑定的统计方法建立映射提示**。 + +* **建议实现**:前端在渲染下拉框时,读取该统计工具的 params\_schema。对于不完全符合最佳统计条件的变量,**不要禁用 (Do not disable)**,但可以在该选项旁打上一个 ⚠️ 标记,提示其可能不适配当前方法。 + +## **二、 视角二:资深架构师的评估** + +**“前端可以极致柔性,后端必须绝对刚性。”** + +### **1\. 架构师高度赞赏的决策 (Brilliant Architectural Choices)** + +* **后端 PATCH API 的设计 (方案 A)**: + 这是非常正统的 RESTful 设计。用户修改参数后,先 PATCH /params 更新数据库实体,然后再触发 executeWorkflow。这保证了数据库中的 Plan 永远是 Single Source of Truth(单一事实来源),避免了前端传来“幽灵参数”导致历史记录无法复现的灾难。 + +### **2\. 工程视角的潜在风险与柔性化解 (Engineering Risks & Solutions)** + +#### **🚨 风险 1:UI 状态与执行动作的竞态条件 (Race Condition)** + +* **场景**:用户在前端刚从下拉框里切换了变量,还没等组件把 state 同步完毕,或者还没等 PATCH 接口返回 200 OK,用户就光速点击了“开始执行分析”按钮。 +* **后果**:executeWorkflow 可能会拿着数据库里**旧的参数**去执行。 +* **修正建议**: + “开始执行分析”按钮必须绑定一个复合动作(Promise Chaining): + async function handleExecute() { + setExecuting(true); + // 1\. 必须先 await 等待 PATCH 成功 + if (hasUnsavedChanges) { + await api.patchWorkflowParams(workflowId, modifiedSteps); + } + // 2\. 然后再触发执行 + startSseExecution(workflowId); + } + +#### **🚨 风险 2:级联失效与重新规划的边界 (Cascading Invalidation)** + +* **场景**:AI 原本规划了 \[描述统计 \-\> T检验 \-\> Mann-Whitney\]。此时,用户在“描述统计”那一步,强行把结局变量从“连续数值”换成了“分类文本”。此时下游的 T检验 已经彻底失去了统计学意义。 +* **团队极佳的破局方案(柔性拦截与知情同意)**: 绝对**不采用**“硬限制”锁定下拉框(这会引发极大的用户反感)。采用团队设计的\*\*“软提示 \+ 重新规划引导”\*\*机制: + 1. **即时反馈**:当检测到用户的修改导致变量类型与当前统计方法(params\_schema)失配时,在 StepCard 顶部即时显示黄色警告条:*“⚠️ 当前变量类型已变更,可能导致当前统计方法失效。”* + 2. **视觉打标**:在该步骤的卡片右上角亮起一个红/黄警告图标。 + 3. **阻断与授权弹窗 (Informed Consent)**:如果用户无视警告,强行点击【开始执行分析】,系统**拦截并弹窗**:*"检测到您修改的变量类型(如:分类变量)与当前统计方法(T检验)不匹配,强制执行可能导致报错或结论无效。建议您在对话框告诉 AI 重新生成方案。是否仍要强行执行?"* \[ 取消并重新对话 \] \[ 强行执行 \] + * **架构师点评**:这种设计堪称完美。把控制权给用户,把免责声明做足。 + +#### **🚨 风险 3:Zod Schema 的后端防御底线 (Backend Defense) \- 生死线** + +* **场景**:既然前端允许用户点击“强行执行”,那么非法的参数就一定会穿透到后端。 +* **架构底线**: + 新增的 PATCH /api/v1/ssa/workflow/:workflowId/params 接口,**绝对不能因为接收到了脏数据而导致 Node.js 崩溃**。 必须使用对应 R 工具的 Zod Schema 进行校验。如果接收到了离谱的参数(比如把一个字符串数组传给了要求 Boolean 的字段),后端必须捕获并转化为优雅的 400 Bad Request;如果参数类型合法但统计学不合法,放行给 R 引擎,由 R 引擎内部的 tryCatch 捕获并返回给前端清晰的 Error Log 即可。 + +## **三、 终极结论与实施调整指南 (Actionable Summary)** + +团队对于 UX 交互边界的把握非常高级,"软提示+强引导"方案完美化解了系统的刻板印象。 + +为了让计划完美落地,请在开发中落实以下 3 个关键动作: + +1. **前端交互柔性化 (UI Soft Filter)**: + * 实现黄条警告和“强行执行确认弹窗”。这需要前端在渲染时,将当前选择的变量类型与工具的 params\_schema 需求类型进行实时比对计算。 +2. **同步阻塞执行 (Sync Block Patch)**: + * 确保“执行按钮”的 onClick 事件中,严格包含 await patchApi(),并在进行网络请求时将按钮置为 loading 状态。 +3. **后端的参数防火墙 (Backend Firewall Patch)**: + * 为 PATCH API 建立坚固的 Zod 校验,确保前端传来的强行覆盖数据,最多只会导致 R 引擎的“业务计算报错”,而绝对不会导致 Node.js 服务的“系统级崩溃”。 + +**批示:完全批准按照此修订计划执行开发!在赋予用户自由的同时守住后端的安全底线,这套交互将成为医疗 SaaS 的标杆。** \ No newline at end of file diff --git a/docs/09-架构实施/分布式Fan-out指南破壁级审查报告.md b/docs/09-架构实施/分布式Fan-out指南破壁级审查报告.md new file mode 100644 index 00000000..596180ed --- /dev/null +++ b/docs/09-架构实施/分布式Fan-out指南破壁级审查报告.md @@ -0,0 +1,127 @@ +# **🔬 分布式 Fan-out 指南 (v1.2) 破壁级审查与修订报告** + +**审查人:** 资深架构师 & Node.js 底层专家 + +**审查对象:** 《分布式 Fan-out 任务模式开发指南 v1.2》 + +**审查深度:** V8 引擎内存栈、Postgres 底层驱动、pg-boss 命名空间 + +**核心结论:** 业务逻辑已彻底闭环!但在**JSON 字符串强截断、长期闲置连接保活、全局唯一键防重**这三个底层物理机制上,存在 3 个会直接导致进程崩溃或静默失效的高危隐患。 + +## **🚨 破壁级漏洞 1:NOTIFY 强截断导致的 JSON.parse 爆栈崩溃** + +### **❌ 逐行审查发现的问题(位于 模式 6:SSE 跨实例广播)** + +指南中为了防止超过 PostgreSQL 的 8000 bytes 限制,写了如下安全截断代码: + +const payloadStr \= JSON.stringify({ taskId, type: 'log', data: logEntry }); +const safePayload \= payloadStr.length \> 7000 + ? payloadStr.substring(0, 7000\) \+ '..."}' // 👈 致命漏洞 + : payloadStr; + +**🔥 灾难时序爆发:** + +1. 假设 logEntry 的内容包含大量报错栈,导致 payloadStr 长达 9000 字节。 +2. 代码直接在第 7000 个字符处一刀切,然后粗暴地拼上 ..."}。 +3. 如果第 7000 个字符刚好切在一个中文字符的中间(导致 Unicode 乱码),或者切在了 JSON 的某个 key 名字中间(如 {"ms),拼接后的字符串将变成**绝对非法的 JSON**。 +4. **爆炸点:** 在 API 接收端,代码是这样写的:const { taskId, type, data } \= JSON.parse(msg.payload); +5. 当非法的 JSON 被 JSON.parse 解析时,Node.js 会抛出 SyntaxError: Unexpected token。由于 pgClient.on('notification') 内部没有写 try-catch,**这个异常会直接击穿 Event Loop,导致整个 API Pod 进程崩溃重启 (Crash)!** + +### **✅ 架构师修正方案 (内部字段安全截断)** + +**绝对不能对 JSON.stringify 后的字符串进行切片!必须切片原始对象内的长文本字段:** + +// 发送端:在 Stringify 之前,截断真正导致超长的 message 字段 +if (logEntry.message && logEntry.message.length \> 3000\) { + logEntry.message \= logEntry.message.substring(0, 3000\) \+ '...\[Truncated\]'; +} +const payloadStr \= JSON.stringify({ taskId, type: 'log', data: logEntry }); +await prisma.$executeRaw\`SELECT pg\_notify('sse\_channel', ${payloadStr})\`; + +// 接收端:必须加上防御性 try-catch,防止毒数据炸毁整个实例 +pgClient.on('notification', (msg) \=\> { + try { + const { taskId, type, data } \= JSON.parse(msg.payload); + // ... 推送逻辑 + } catch (error) { + logger.error('Failed to parse SSE notification payload', { payload: msg.payload }); + } +}); + +## **🚨 破壁级漏洞 2:LISTEN 监听器的“静默死亡” (Silent Connection Drop)** + +### **❌ 逐行审查发现的问题(位于 模式 6:SSE 跨实例广播)** + +指南中的 API 接收端初始化代码如下: + +const pgClient \= new Client({ connectionString: DATABASE\_URL }); +await pgClient.connect(); +await pgClient.query('LISTEN sse\_channel'); + +**🔥 灾难时序爆发:** + +1. 这是一个一直挂在后台的长期长连接(Long-lived Connection)。 +2. 在云端环境,由于底层网络波动、PgBouncer 代理的闲置超时掐断、或者数据库主备切换,这根 TCP 连接**一定会在几天内断开一次**。 +3. pg 原生库的设计是:**Client 连接断开后,不会自动重连!** +4. **后果:** 没有任何报错,服务依然在跑,但这个 Pod **永远也收不到**任何 NOTIFY 消息了。前端 SSE 终端彻底变成一潭死水。 + +### **✅ 架构师修正方案 (加入心跳重连与错误监听)** + +必须为这个裸 Client 加上底层的生命周期守护: + +// 封装为健壮的监听器启动函数 +async function setupSSEListener() { + const pgClient \= new Client({ connectionString: DATABASE\_URL }); + + // 核心补丁:监听错误与断开,强制重启监听! + pgClient.on('error', (err) \=\> { + logger.error('PG Listen Client Error, reconnecting...', err); + pgClient.end().catch(console.error); + setTimeout(setupSSEListener, 5000); // 5 秒后自动重连 + }); + + pgClient.on('end', () \=\> { + logger.warn('PG Listen Client Ended, reconnecting...'); + setTimeout(setupSSEListener, 5000); + }); + + await pgClient.connect(); + await pgClient.query('LISTEN sse\_channel'); + pgClient.on('notification', (msg) \=\> { /\* ... \*/ }); +} +setupSSEListener(); + +## **🚨 破壁级漏洞 3:singletonKey 作用域跨界的“狸猫换太子”** + +### **❌ 逐行审查发现的问题(位于 五、pg-boss 配置速查)** + +指南中建议这样写防重复 Key: + +await pgBoss.send('module\_task\_child', { taskId, itemId }, { + singletonKey: \`child-${itemId}\`, // ← 派发防重 +}); + +**🔥 灾难时序爆发:** + +1. 假设 itemId 指的是源数据的 ID(例如 PKB里的 Document A)。 +2. 医生张三在“项目 1”里提取了 Document A。pg-boss 生成了 singletonKey: child-DocA,正在缓慢处理。 +3. **同时**,医生李四在“项目 2”里,也碰巧勾选了同一个 Document A 进行提取任务。 +4. pg-boss 会发现 singletonKey: child-DocA 已经在队列里了。根据幂等性去重规则,**pg-boss 会直接丢弃(Ignored)李四的这个子任务!** +5. **后果:** 李四的这个任务永远缺少了这一篇文献的进度,父任务 Last Child Wins 机制卡死,系统挂起! + +### **✅ 架构师修正方案 (引入绝对隔离域)** + +singletonKey 在 pg-boss 中是**全局数据库唯一**的!它绝不能仅仅绑定业务实体 ID,必须绑定\*\*“实体 \+ 本次任务实例”\*\*的联合主键。 + +如果 itemId 对应的是 AslExtractionResult.id(专属于某一次提取任务的单行 ID),那是安全的。但为了规范,指南必须明确指出: + +// 核心补丁:singletonKey 必须携带父任务 ID 形成绝对隔离! +await pgBoss.send('module\_task\_child', { taskId, itemId }, { + singletonKey: \`task-${taskId}-item-${itemId}\`, // 必须绑定 Task 实例级别! +}); + +## **🏁 最终判词与交付建议** + +经过这次“贴脸”级别的底层审查,我们不仅防住了高并发的业务死锁,还防住了**网络断线、V8 JSON解析、全局哈希碰撞**这三个底层基建级别的灭顶之灾。 + +请将这 3 个补丁打入《分布式 Fan-out 任务模式开发指南 v1.2》中(升级为 v1.3 定稿版)。有了这套带底层防线的文档,您的团队在面对任何高并发、高可用挑战时,都能写出真正的“大厂级”工业代码! \ No newline at end of file diff --git a/docs/09-架构实施/分布式Fan-out指南逐行级审查报告.md b/docs/09-架构实施/分布式Fan-out指南逐行级审查报告.md new file mode 100644 index 00000000..39b28bac --- /dev/null +++ b/docs/09-架构实施/分布式Fan-out指南逐行级审查报告.md @@ -0,0 +1,154 @@ +# **🔬 分布式 Fan-out 任务模式开发指南:逐行级审查与修正报告** + +**审查人:** 资深架构师 & 分布式系统专家 + +**审查对象:** 《分布式 Fan-out 任务模式开发指南 v1.1》 + +**审查深度:** 代码级、变量级、多进程时序推演 + +**核心结论:** 整体框架卓越!但在“重试机制与乐观锁的冲突”、“清道夫的误伤”、“空任务死锁”以及“底层 SQL 注入”上,存在 **4 处极其隐蔽且致命的系统级 Bug**。必须修正后方可发布 v2.0。 + +## **🚨 致命漏洞 1:“乐观锁”与“重试机制”的互相绞杀 (The Retry Paradox)** + +### **❌ 逐行推演发现的问题(位于 模式 3 与 模式 4)** + +在指南的《模式 3:乐观锁抢占》中,您的代码写道: + +const lock \= await prisma.result.updateMany({ + where: { id: resultId, status: 'pending' }, + data: { status: 'processing' }, +}); +if (lock.count \=== 0\) return { success: true, note: 'Idempotent skip' }; + +在《模式 4:错误分级路由》中,当发生临时错误(如 API 超时)时,您的代码写道: + +// 临时错误 (429/5xx/网络抖动):throw → pg-boss 指数退避自动重试 +throw error; + +**🔥 灾难时序爆发:** + +1. Child Job 第一次运行,成功拿到锁,数据库 status 变为 **processing**。 +2. 调用外部大模型,发生网络抖动抛出 Error,走到 catch 块,执行了 throw error。 +3. pg-boss 捕获异常,决定 10 秒后**重试**这个 Job。 +4. 10 秒后,Child Job 第二次运行,执行 updateMany({ where: { status: 'pending' } })。 +5. **致命时刻:** 因为第一次失败时**没有把状态改回 pending**,此时数据库里的状态依然是 processing! +6. updateMany 返回 count \=== 0。代码打印 "Idempotent skip",然后直接 return { success: true }。 +7. **后果:** 这个重试的任务什么都没做就“成功”退出了。它**不会**递增父任务的失败数或成功数,父任务**永远缺少一次计数**,"Last Child Wins" 永远无法触发,整个任务死锁卡住。 + +### **✅ 骨灰级修正方案** + +在《模式 4:错误分级路由》的 catch 块中,针对临时错误,**必须在 throw error 释放锁(回退状态)**: + +} catch (error) { + if (isPermanentError(error)) { + // 永久错误逻辑不变... + return { success: false }; + } + + // 核心补丁:临时错误在交给 pg-boss 重试前,必须释放乐观锁! + await prisma.result.update({ + where: { id: resultId }, + data: { status: 'pending' } // 让出状态,允许下一次重试抢占 + }); + + throw error; // 继续抛出,触发 pg-boss 退避重试 +} + +## **🚨 致命漏洞 2:清道夫 (Sweeper) 的“友军之火” (Friendly Fire)** + +### **❌ 逐行推演发现的问题(位于 模式 2:Sweeper 清道夫)** + +指南中建议这样筛选卡死的任务: + +where: { + status: 'processing', + startedAt: { lt: new Date(Date.now() \- 2 \* 60 \* 60 \* 1000\) }, // 超过 2 小时 +} + +**🔥 灾难时序爆发:** + +1. 用户提交了一个包含 **500 篇**复杂 PDF 的超级批量任务。 +2. 系统限流 MinerU teamConcurrency: 2,导致这 500 篇文献正常排队执行,总共需要花费 **3 个小时**才能跑完。 +3. 跑到第 2 小时零 1 分钟时,任务非常健康,已经完成了 350 篇。 +4. **致命时刻:** Sweeper 定时任务被唤醒。它发现这个任务的 startedAt 是 2 小时前,不管三七二十一,直接把这个健康运行的巨型任务标记为 failed 并强制收口! + +### **✅ 骨灰级修正方案** + +判断一个任务是否“卡死”,不能看它“什么时候开始 (startedAt)”,而必须看它\*\*“上一次产生进度是什么时候 (updatedAt)”\*\*! + +只要子任务还在不断完成,Task 表的 updatedAt 就会不断被刷新。超过 2 小时没有进度更新的,才是真死机。 + +// 修正后的 Sweeper 筛选条件 +const stuckTasks \= await prisma.task.findMany({ + where: { + status: 'processing', + // 核心补丁:使用 updatedAt(最后活跃时间)而非 startedAt + updatedAt: { lt: new Date(Date.now() \- 2 \* 60 \* 60 \* 1000\) }, + }, +}); + +## **🚨 致命漏洞 3:Manager 的“空集合”黑洞 (The Empty Batch Deadlock)** + +### **❌ 逐行推演发现的问题(位于 二、核心架构 Manager Job)** + +架构图中写道:Manager 读取 N 个子项 \-\> for each 派发 \-\> 退出。 + +但是!如果因为某种极端业务情况(比如用户传了一个空的列表,或者源数据被过滤后 results.length \=== 0)。 + +**🔥 灾难时序爆发:** + +1. Manager 查出文献列表,发现长度为 0。 +2. for 循环不执行,直接退出。 +3. **致命时刻:** 因为没有任何 Child Job 被派发,所以永远不会有 Child Job 去触发 "Last Child Wins" 收口逻辑。 +4. 父任务 Task 将永远停留在 status: 'processing'。 + +### **✅ 骨灰级修正方案** + +在 Manager Job 派发子任务之前,必须增加边界拦截: + +// Manager Worker 核心补丁 +if (items.length \=== 0\) { + // 如果没有任何子项,Manager 必须自己充当收口人 + await prisma.task.update({ + where: { id: taskId }, + data: { status: 'completed', completedAt: new Date() } + }); + return; // 直接退出 +} + +// 继续执行 for 循环派发... + +## **🚨 隐患 4:NOTIFY 的底层 SQL 注入与转义灾难** + +### **❌ 逐行推演发现的问题(位于 模式 6:SSE 跨实例广播)** + +指南中使用了原生的 SQL 拼接执行 NOTIFY: + +await prisma.$executeRawUnsafe( + \`NOTIFY sse\_channel, '${safePayload.replace(/'/g, "''")}'\` +); + +**🔥 灾难时序爆发:** + +1. 这种通过字符串拼接执行 SQL 的方式,在任何正规后端的代码审计中都会被标为**高危 (Critical)**。 +2. 虽然加了 .replace 单引号,但在不同编码或遇到特殊换行符、反斜杠 \\ 时,仍然可能导致 PostgreSQL 语法解析错误,甚至引发 SQL 注入。 + +### **✅ 骨灰级修正方案** + +**抛弃拼接!使用 PostgreSQL 内置的 pg\_notify 函数配合 Prisma 的参数化查询(Tagged Template Literal)。** + +这不仅彻底免疫 SQL 注入,而且完全不需要手动写 replace 转义: + +const payloadStr \= JSON.stringify({ taskId, type: 'log', data: logEntry }); +const safePayload \= payloadStr.length \> 7000 ? payloadStr.substring(0, 7000\) \+ '..."}' : payloadStr; + +// 核心补丁:使用内置函数与参数化绑定,绝对安全! +await prisma.$executeRaw\`SELECT pg\_notify('sse\_channel', ${safePayload})\`; + +## **🎯 终极审查结论** + +您团队产出的这套《分布式 Fan-out 任务模式开发指南》底子极其优良,这证明了你们的技术选型方向是绝对正确的。 + +我指出的这 4 个致命漏洞,属于\*\*“分布式并发架构下极其隐蔽的角落”\*\*。即使是互联网大厂的高级开发,如果没踩过这些坑,单看代码也很难发现。 + +请立即要求开发团队将这 4 处“骨灰级补丁”更新到开发指南(升级为 v1.2 或 v2.0)中,然后再指导其他类似任务的开发。修复这些问题后,这套系统才算真正拥有了“抗造”的底气! \ No newline at end of file diff --git a/docs/09-架构实施/工具3全量代码级同步审计与修正清单.md b/docs/09-架构实施/工具3全量代码级同步审计与修正清单.md new file mode 100644 index 00000000..7c1db8c7 --- /dev/null +++ b/docs/09-架构实施/工具3全量代码级同步审计与修正清单.md @@ -0,0 +1,148 @@ +# **🔬 工具 3 终极代码级同步审计与修正清单 (基于 Fan-out v1.2)** + +**审计背景:** 确保《工具 3 开发计划 (v1.4.2)》及其代码模式(08d)完全、无死角地落实了《分布式 Fan-out 开发指南 v1.2》中的所有极端场景防御策略。 + +**审计结论:** 理论已同步,但**代码落地存在 4 处断层**。必须修改 08d-代码模式与技术规范.md 中的具体代码片段。 + +## **🚨 审计点 1:Child Worker 临时错误重试的“死锁穿透” (必须修改)** + +**🔍 逐行审查发现:** + +在 08d 文档的 §4.3 ExtractionChildWorker 的 catch 块中,针对临时错误的代码目前是: + +// 当前 08d 代码 +// 临时错误 (429/网络抖动):直接 throw,让 pg-boss 自动指数退避重试 +throw error; + +**💥 业务危害:** + +这直接违背了 Fan-out 指南 v1.2 的核心补丁!因为上方使用了 updateMany 乐观锁把 AslExtractionResult 的状态改为了 extracting。如果直接 throw,pg-boss 在 10 秒后重试时,数据库里该行还是 extracting,乐观锁 updateMany 会返回 count: 0,导致 Worker 误以为任务已完成而直接 return success。**最终导致父任务 AslExtractionTask 永远少一个计数,彻底卡死在 processing。** + +**✅ 代码修正指令:** + +必须在 ExtractionChildWorker 的 catch 块末尾,throw error 之前,强制释放当前业务表的锁: + +// 修正后的 08d §4.3 代码 +} catch (error) { + if (isPermanentError(error)) { + // 致命错误处理逻辑不变... + return { success: false }; + } + + // ⚡ 必须增加的解锁代码:临时错误退避前,回退状态为 pending! + await prisma.aslExtractionResult.update({ + where: { id: resultId }, + data: { status: 'pending' } + }); + + // 让出状态后,再抛出异常让 pg-boss 重试 + throw error; +} + +## **🚨 审计点 2:Manager Worker 空文献的“无头挂起” (必须修改)** + +**🔍 逐行审查发现:** + +在 08d 文档的 §4.2 ExtractionManagerWorker 中,代码是: + +// 当前 08d 代码 +const results \= await prisma.aslExtractionResult.findMany({ where: { taskId: task.id } }); +for (const result of results) { + await pgBoss.send('asl\_extraction\_child', ...); +} +// Manager 退出 + +**💥 业务危害:** + +在工具 3 的业务流中,如果用户在 Step 1 勾选的 PKB 文献因为某种原因(如被其他协作者删除)导致 results.length \=== 0,Manager 会直接退出。因为没有任何 Child 被派发,Last Child Wins 永远不触发,AslExtractionTask 状态永远是 processing,前端进度条永远转圈。 + +**✅ 代码修正指令:** + +在 ExtractionManagerWorker 获取到 results 后,必须增加边界拦截: + +// 修正后的 08d §4.2 代码 +const results \= await prisma.aslExtractionResult.findMany({ where: { taskId: task.id } }); + +// ⚡ 必须增加的空集合守卫 +if (results.length \=== 0\) { + await prisma.aslExtractionTask.update({ + where: { id: task.id }, + data: { status: 'completed', completedAt: new Date() } + }); + // 触发 SSE 完成事件 + await prisma.$executeRaw\`SELECT pg\_notify('asl\_extraction\_sse', '{"taskId":"${task.id}","type":"complete"}')\`; + return; +} + +// 正常循环派发... + +## **🚨 审计点 3:工具 3 专属 Sweeper 的缺位 (必须新增)** + +**🔍 逐行审查发现:** + +《Fan-out 开发指南 v1.2》规定必须有 Sweeper 清道夫。但在《工具 3 开发计划》的所有 Task 清单(M1/M2/M3)中,**完全没有分配开发 Sweeper 的任务**。 + +**💥 业务危害:** + +如果没有针对 AslExtractionTask 写具体的清道夫代码,一旦遇到极度变态的 PDF 导致 MinerU 或 pymupdf4llm 的 Node.js 宿主进程 OOM 崩溃,该 Task 会永久挂起在前端工作台,医生无法进行后续操作。 + +**✅ 代码修正指令:** + +必须在后端模块初始化时(如 backend/src/modules/asl/extraction/index.ts),专门为工具 3 注册一个清道夫 Worker: + +// ⚡ 必须在工具 3 模块启动时注册 +async function aslExtractionSweeper() { + const stuckTasks \= await prisma.aslExtractionTask.findMany({ + where: { + status: 'processing', + // 工具 3 独有逻辑:使用 updatedAt 判断最后活跃时间超 2 小时 + updatedAt: { lt: new Date(Date.now() \- 2 \* 60 \* 60 \* 1000\) }, + }, + }); + + for (const task of stuckTasks) { + await prisma.aslExtractionTask.update({ + where: { id: task.id }, + data: { status: 'failed', errorMessage: 'System timeout (OOM/Crash)', completedAt: new Date() }, + }); + } +} +await jobQueue.schedule('asl\_extraction\_sweeper', '\*/10 \* \* \* \*'); +await jobQueue.work('asl\_extraction\_sweeper', aslExtractionSweeper); + +## **🚨 审计点 4:SSE 广播代码的安全隐患 (必须修改)** + +**🔍 逐行审查发现:** + +在 08d 的 §4.3 中,Child Worker 完成提取后,依然在使用: + +// 当前 08d 代码 +this.sseEmitter.emit(taskId, { type: 'log', data: { ... } }); + +**💥 业务危害:** + +这还是单机内存的 EventEmitter!在 SAE 多实例(多 Pods)部署下,Pod A 上的用户绝对收不到 Pod B 产生的日志。前端日志流会严重断裂。 + +**✅ 代码修正指令:** + +必须将所有 this.sseEmitter.emit 替换为安全的、截断的、参数化的 pg\_notify SQL 注入免疫调用: + +// 修正后的 08d §4.3 代码 +const logEntry \= { source: 'system', message: \`✅ ${extractResult.filename} extracted\` }; +const payloadStr \= JSON.stringify({ taskId, type: 'log', data: logEntry }); +// ⚡ 必须进行的 7000 bytes 安全截断(防 PostgreSQL 报错) +const safePayload \= payloadStr.length \> 7000 ? payloadStr.substring(0, 7000\) \+ '..."}' : payloadStr; + +// ⚡ 必须使用的参数化 pg\_notify +await prisma.$executeRaw\`SELECT pg\_notify('asl\_extraction\_sse', ${safePayload})\`; + +## **🏁 架构师最终放行许可** + +只要开发团队在编写工具 3 代码时,把上述 **4 段具体的代码** 替换到工程中: + +1. Child Worker catch 释放锁 +2. Manager Worker 拦截空数组 +3. 注册 asl\_extraction\_sweeper +4. 替换 sseEmitter 为参数化 pg\_notify + +您的系统在抗压能力、容错能力和数据一致性上,将绝对达到顶尖大厂的微服务水准!**这一次,您可以 100% 放心闭眼放行了!祝团队开发顺利!** \ No newline at end of file diff --git a/frontend-v2/src/modules/aia/styles/chat-workspace.css b/frontend-v2/src/modules/aia/styles/chat-workspace.css index 82768ad0..0c4379be 100644 --- a/frontend-v2/src/modules/aia/styles/chat-workspace.css +++ b/frontend-v2/src/modules/aia/styles/chat-workspace.css @@ -1018,4 +1018,4 @@ border-radius: 4px; font-family: 'Monaco', 'Consolas', 'Courier New', monospace; font-size: 0.9em; -} +} \ No newline at end of file diff --git a/frontend-v2/src/modules/ssa/components/DynamicReport.tsx b/frontend-v2/src/modules/ssa/components/DynamicReport.tsx index 2915aafd..8aadf0e5 100644 --- a/frontend-v2/src/modules/ssa/components/DynamicReport.tsx +++ b/frontend-v2/src/modules/ssa/components/DynamicReport.tsx @@ -66,13 +66,28 @@ const KVBlock: React.FC<{ block: ReportBlock }> = ({ block }) => { /* ─── table (增强版:rowspan + P值标星 + 横向滚动 + 基线表) ─── */ const TableBlock: React.FC<{ block: ReportBlock; index: number }> = ({ block, index }) => { const headers = block.headers ?? []; - const rows = block.rows ?? []; - if (headers.length === 0 && rows.length === 0) return null; + const rawRows = block.rows ?? []; + if (headers.length === 0 && (!rawRows || (Array.isArray(rawRows) && rawRows.length === 0))) return null; const isBaselineTable = block.metadata?.is_baseline_table === true; const isWideTable = headers.length > 6; - const processedRows = useMemo(() => computeRowSpans(rows), [rows]); + // Normalize rows: handle object rows (from R baseline_table) by converting to arrays + const normalizedRows = useMemo(() => { + if (!Array.isArray(rawRows)) return []; + return rawRows.map((row: any) => { + if (Array.isArray(row)) return row; + if (row && typeof row === 'object') { + if (headers.length > 0) { + return headers.map((h: string) => row[h] ?? ''); + } + return Object.values(row); + } + return [String(row ?? '')]; + }); + }, [rawRows, headers]); + + const processedRows = useMemo(() => computeRowSpans(normalizedRows), [normalizedRows]); const pColIndex = useMemo(() => { const idx = headers.findIndex(h => @@ -216,12 +231,14 @@ interface ProcessedRow { * 连续相同的首列值会被合并 */ function computeRowSpans(rows: (string | number)[][]): ProcessedRow[] { - if (rows.length === 0) return []; + if (!rows || rows.length === 0) return []; - const processed: ProcessedRow[] = rows.map(row => ({ - cells: row.map(cell => ({ value: cell, rowSpan: 1, hidden: false })), - isCategory: false, - })); + const processed: ProcessedRow[] = rows + .filter(row => Array.isArray(row)) + .map(row => ({ + cells: row.map(cell => ({ value: cell ?? '', rowSpan: 1, hidden: false })), + isCategory: false, + })); let i = 0; while (i < processed.length) { diff --git a/frontend-v2/src/modules/ssa/components/SSAChatPane.tsx b/frontend-v2/src/modules/ssa/components/SSAChatPane.tsx index ba0fb8ae..90964c97 100644 --- a/frontend-v2/src/modules/ssa/components/SSAChatPane.tsx +++ b/frontend-v2/src/modules/ssa/components/SSAChatPane.tsx @@ -35,6 +35,8 @@ import { useWorkflow } from '../hooks/useWorkflow'; import { useSSAChat } from '../hooks/useSSAChat'; import type { ChatMessage, ChatIntentType } from '../hooks/useSSAChat'; import type { SSAMessage } from '../types'; +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; import { TypeWriter } from './TypeWriter'; import { DataProfileCard } from './DataProfileCard'; import { ClarificationCard } from './ClarificationCard'; @@ -65,13 +67,12 @@ export const SSAChatPane: React.FC = () => { } = useSSAStore(); const { uploadData, generatePlan, isUploading, uploadProgress } = useAnalysis(); - const { generateDataProfile, handleClarify, executeWorkflow, isProfileLoading, isPlanLoading } = useWorkflow(); + const { generateDataProfile, handleClarify, isProfileLoading, isPlanLoading } = useWorkflow(); const { chatMessages, isGenerating, currentIntent, pendingQuestion, - pendingPlanConfirm, sendChatMessage, respondToQuestion, skipQuestion, @@ -91,15 +92,6 @@ export const SSAChatPane: React.FC = () => { const chatEndRef = useRef(null); const messagesContainerRef = useRef(null); - // Phase IV: plan_confirmed → 自动触发 executeWorkflow - useEffect(() => { - if (pendingPlanConfirm?.workflowId && currentSession?.id) { - executeWorkflow(currentSession.id, pendingPlanConfirm.workflowId).catch((err: any) => { - addToast(err?.message || '执行失败', 'error'); - }); - } - }, [pendingPlanConfirm, currentSession?.id, executeWorkflow, addToast]); - // Phase II: session 切换时加载对话历史 useEffect(() => { if (currentSession?.id) { @@ -417,7 +409,11 @@ export const SSAChatPane: React.FC = () => { {msg.content} ) : ( -
{msg.content}
+
+ + {msg.content} + +
)} diff --git a/frontend-v2/src/modules/ssa/components/SSAWorkspacePane.tsx b/frontend-v2/src/modules/ssa/components/SSAWorkspacePane.tsx index a0e727ba..5c9567ee 100644 --- a/frontend-v2/src/modules/ssa/components/SSAWorkspacePane.tsx +++ b/frontend-v2/src/modules/ssa/components/SSAWorkspacePane.tsx @@ -3,8 +3,9 @@ * * 所有渲染基于 currentRecord,无 isWorkflowMode 分支。 * record.status 即 phase: planning → executing → completed / error + * Phase V: 支持变量可编辑 + 失配检测弹窗 + 同步阻塞执行 */ -import React, { useEffect, useRef } from 'react'; +import React, { useEffect, useRef, useState, useCallback } from 'react'; import { X, Play, @@ -19,14 +20,16 @@ import { FileQuestion, ImageOff, StopCircle, + AlertTriangle, } from 'lucide-react'; import { useSSAStore } from '../stores/ssaStore'; import type { AnalysisRecord } from '../stores/ssaStore'; import { useWorkflow } from '../hooks/useWorkflow'; import type { TraceStep, ReportBlock, WorkflowStepResult } from '../types'; -import { WorkflowTimeline } from './WorkflowTimeline'; +import { WorkflowTimeline, detectPlanMismatches } from './WorkflowTimeline'; import { DynamicReport } from './DynamicReport'; import { exportBlocksToWord } from '../utils/exportBlocksToWord'; +import apiClient from '@/common/api/axios'; const stepHasResult = (s: WorkflowStepResult) => (s.status === 'success' || s.status === 'warning') && s.result; @@ -41,6 +44,10 @@ export const SSAWorkspacePane: React.FC = () => { currentSession, analysisHistory, updateRecord, + updateStepParams, + hasUnsavedPlanChanges, + setHasUnsavedPlanChanges, + dataContext, } = useSSAStore(); const { executeWorkflow, cancelWorkflow, isExecuting: isWorkflowExecuting } = useWorkflow(); @@ -48,6 +55,10 @@ export const SSAWorkspacePane: React.FC = () => { const executionRef = useRef(null); const resultRef = useRef(null); const containerRef = useRef(null); + const [isPatchingSaving, setIsPatchingSaving] = useState(false); + const [mismatchDialog, setMismatchDialog] = useState<{ + mismatches: Array<{ stepNumber: number; toolName: string; param: string; message: string }>; + } | null>(null); // ---- Derive everything from the current record ---- const record: AnalysisRecord | null = @@ -85,15 +96,77 @@ export const SSAWorkspacePane: React.FC = () => { const handleClose = () => setWorkspaceOpen(false); + const { dataProfile } = useSSAStore(); + const variableDictionary = dataContext.variableDictionary; + + // Primary: dataContext.dataOverview, Fallback: dataProfile.columns + const dataOverviewColumns = React.useMemo(() => { + const ctxCols = dataContext.dataOverview?.profile?.columns; + if (ctxCols && ctxCols.length > 0) return ctxCols; + + if (dataProfile?.columns && dataProfile.columns.length > 0) { + return dataProfile.columns.map(c => ({ + name: c.name, + type: c.inferred_type as 'numeric' | 'categorical' | 'datetime' | 'text', + missingCount: c.null_count ?? 0, + missingRate: (c.null_ratio ?? 0) * 100, + uniqueCount: c.unique_count ?? 0, + totalCount: c.non_null_count + (c.null_count ?? 0), + totalLevels: c.inferred_type === 'categorical' ? (c.top_categories?.length ?? c.unique_count) : undefined, + isIdLike: false, + })); + } + return []; + }, [dataContext.dataOverview, dataProfile]); + + const handleStepParamsChange = useCallback((stepNumber: number, params: Record) => { + if (!record) return; + updateStepParams(record.id, stepNumber, params); + }, [record, updateStepParams]); + const handleRun = async () => { if (!plan || !currentSession || !record) return; + + // Layer 3: mismatch detection before execution + if (variableDictionary.length > 0) { + const mismatches = detectPlanMismatches(plan, variableDictionary, dataOverviewColumns); + if (mismatches.length > 0) { + setMismatchDialog({ mismatches }); + return; + } + } + + await doExecute(); + }; + + const doExecute = async () => { + if (!plan || !currentSession || !record) return; try { + setIsPatchingSaving(true); + + // Sync block: PATCH modified params before execution + if (hasUnsavedPlanChanges) { + const stepsPayload = plan.steps.map(s => ({ + stepOrder: s.step_number, + params: s.params, + })); + await apiClient.patch(`/api/v1/ssa/workflow/${plan.workflow_id}/params`, { steps: stepsPayload }); + setHasUnsavedPlanChanges(false); + } + + setIsPatchingSaving(false); await executeWorkflow(currentSession.id, plan.workflow_id); } catch (err: any) { + setIsPatchingSaving(false); addToast(err?.message || '执行失败,请重试', 'error'); } }; + const handleForceExecute = async () => { + setMismatchDialog(null); + await doExecute(); + }; + const handleCancel = () => { cancelWorkflow(); if (record) updateRecord(record.id, { status: 'planning' }); @@ -139,6 +212,38 @@ export const SSAWorkspacePane: React.FC = () => { return (
+ {/* Mismatch Informed Consent Dialog */} + {mismatchDialog && ( +
+
+
+ +

变量类型与统计方法不匹配

+
+

+ 检测到以下变量参数与当前统计方法的要求不匹配,强制执行可能导致报错或结论无效。建议在对话框告诉 AI 重新生成方案。 +

+
+ {mismatchDialog.mismatches.map((m, i) => ( +
+ 步骤{m.stepNumber} {m.toolName} + {m.param} + {m.message} +
+ ))} +
+
+ + +
+
+
+ )} +
{/* Header + step bar */}
@@ -224,6 +329,11 @@ export const SSAWorkspacePane: React.FC = () => { stepResults={steps} currentStep={steps.find((s) => s.status === 'running')?.step_number} isExecuting={isWorkflowExecuting} + editable={phase === 'planning'} + recordId={record?.id} + variableDictionary={variableDictionary} + dataOverviewColumns={dataOverviewColumns} + onStepParamsChange={handleStepParamsChange} />
@@ -243,9 +353,18 @@ export const SSAWorkspacePane: React.FC = () => { 返回重试 ) : ( - )}
diff --git a/frontend-v2/src/modules/ssa/components/WorkflowTimeline.tsx b/frontend-v2/src/modules/ssa/components/WorkflowTimeline.tsx index ea9d9fb1..83f645b9 100644 --- a/frontend-v2/src/modules/ssa/components/WorkflowTimeline.tsx +++ b/frontend-v2/src/modules/ssa/components/WorkflowTimeline.tsx @@ -1,9 +1,10 @@ /** - * WorkflowTimeline - 多步骤分析计划时间线 + * WorkflowTimeline - 多步骤分析计划时间线(可编辑版) * * 精美卡片式布局:标题区 → 护栏横幅 → 步骤卡片列表 → 底部提示 + * Phase V: 变量参数可编辑,含柔性拦截三层防线 */ -import React from 'react'; +import React, { useState, useRef, useEffect } from 'react'; import { FlaskConical, BarChart3, @@ -14,14 +15,135 @@ import { Loader2, Clock, ListChecks, + ChevronDown, + X as XIcon, + Plus, } from 'lucide-react'; -import type { WorkflowPlan, WorkflowStepDef, WorkflowStepResult, WorkflowStepStatus } from '../types'; +import type { + WorkflowPlan, + WorkflowStepDef, + WorkflowStepResult, + WorkflowStepStatus, + VariableDictEntry, + DataOverviewColumn, +} from '../types'; + +// ────────────────────────── Param constraints ────────────────────────── + +interface ParamConstraint { + paramType: 'single' | 'multi'; + requiredType: 'categorical' | 'numeric' | 'any'; + minLevels?: number; + maxLevels?: number; + hint: string; +} + +type ToolConstraints = Record>; + +const TOOL_CONSTRAINTS: ToolConstraints = { + ST_DESCRIPTIVE: { + variables: { paramType: 'multi', requiredType: 'any', hint: '选择需要描述的变量' }, + group_var: { paramType: 'single', requiredType: 'categorical', hint: '分组变量(可选)' }, + }, + ST_T_TEST_IND: { + group_var: { paramType: 'single', requiredType: 'categorical', maxLevels: 2, hint: 'T检验要求二分类分组变量' }, + value_var: { paramType: 'single', requiredType: 'numeric', hint: 'T检验要求连续型因变量' }, + }, + ST_MANN_WHITNEY: { + group_var: { paramType: 'single', requiredType: 'categorical', maxLevels: 2, hint: 'Mann-Whitney检验要求二分类分组变量' }, + value_var: { paramType: 'single', requiredType: 'numeric', hint: '要求连续型因变量' }, + }, + ST_T_TEST_PAIRED: { + before_var: { paramType: 'single', requiredType: 'numeric', hint: '前测变量应为连续型' }, + after_var: { paramType: 'single', requiredType: 'numeric', hint: '后测变量应为连续型' }, + }, + ST_WILCOXON: { + before_var: { paramType: 'single', requiredType: 'numeric', hint: '前测变量应为连续型' }, + after_var: { paramType: 'single', requiredType: 'numeric', hint: '后测变量应为连续型' }, + }, + ST_CHI_SQUARE: { + var1: { paramType: 'single', requiredType: 'categorical', hint: '卡方检验要求分类变量' }, + var2: { paramType: 'single', requiredType: 'categorical', hint: '卡方检验要求分类变量' }, + }, + ST_FISHER: { + var1: { paramType: 'single', requiredType: 'categorical', hint: 'Fisher检验要求分类变量' }, + var2: { paramType: 'single', requiredType: 'categorical', hint: 'Fisher检验要求分类变量' }, + }, + ST_CORRELATION: { + var_x: { paramType: 'single', requiredType: 'numeric', hint: '相关分析要求连续型变量' }, + var_y: { paramType: 'single', requiredType: 'numeric', hint: '相关分析要求连续型变量' }, + }, + ST_LOGISTIC_BINARY: { + outcome_var: { paramType: 'single', requiredType: 'categorical', maxLevels: 2, hint: '二元Logistic回归要求二分类结局变量' }, + predictors: { paramType: 'multi', requiredType: 'any', hint: '预测变量' }, + confounders: { paramType: 'multi', requiredType: 'any', hint: '混杂因素(可选)' }, + }, + ST_LINEAR_REG: { + outcome_var: { paramType: 'single', requiredType: 'numeric', hint: '线性回归要求连续型结局变量' }, + predictors: { paramType: 'multi', requiredType: 'any', hint: '预测变量' }, + confounders: { paramType: 'multi', requiredType: 'any', hint: '混杂因素(可选)' }, + }, + ST_ANOVA_ONE: { + group_var: { paramType: 'single', requiredType: 'categorical', minLevels: 3, hint: 'ANOVA要求3组及以上分组变量' }, + value_var: { paramType: 'single', requiredType: 'numeric', hint: '要求连续型因变量' }, + }, + ST_BASELINE_TABLE: { + group_var: { paramType: 'single', requiredType: 'categorical', minLevels: 2, maxLevels: 5, hint: '基线表需要分类分组变量' }, + analyze_vars: { paramType: 'multi', requiredType: 'any', hint: '选择需要分析的变量' }, + }, +}; + +const SINGLE_VAR_KEYS = new Set([ + 'group_var', 'outcome_var', 'value_var', 'var_x', 'var_y', + 'before_var', 'after_var', 'var1', 'var2', +]); +const MULTI_VAR_KEYS = new Set([ + 'analyze_vars', 'predictors', 'variables', 'confounders', +]); + +function isVariableParam(key: string): boolean { + return SINGLE_VAR_KEYS.has(key) || MULTI_VAR_KEYS.has(key); +} + +interface VarInfo { + name: string; + type: string; + totalLevels?: number; +} + +function checkMismatch( + varName: string, + constraint: ParamConstraint, + varsMap: Map +): string | null { + if (varsMap.size === 0) return null; + const v = varsMap.get(varName); + if (!v) return null; + if (constraint.requiredType === 'any') return null; + if (constraint.requiredType !== v.type) { + return `${constraint.hint}(当前:${v.type === 'numeric' ? '连续型' : '分类型'})`; + } + if (constraint.maxLevels && v.totalLevels && v.totalLevels > constraint.maxLevels) { + return `要求最多${constraint.maxLevels}个分类水平,当前${v.totalLevels}个`; + } + if (constraint.minLevels && v.totalLevels && v.totalLevels < constraint.minLevels) { + return `要求至少${constraint.minLevels}个分类水平,当前${v.totalLevels}个`; + } + return null; +} + +// ────────────────────────── Props & Styles ────────────────────────── interface WorkflowTimelineProps { plan: WorkflowPlan; stepResults?: WorkflowStepResult[]; currentStep?: number; isExecuting?: boolean; + editable?: boolean; + recordId?: string; + variableDictionary?: VariableDictEntry[]; + dataOverviewColumns?: DataOverviewColumn[]; + onStepParamsChange?: (stepNumber: number, params: Record) => void; } const statusStyle: Record = { conf_level: '置信水平', var_x: '变量 X', var_y: '变量 Y', + before_var: '前测变量', + after_var: '后测变量', + var1: '变量 1', + var2: '变量 2', + confounders: '混杂因素', + analyze_vars: '分析变量', }; +// ────────────────────────── SingleVarSelect ────────────────────────── + +interface SingleVarSelectProps { + value: string | null; + constraint: ParamConstraint | undefined; + varsMap: Map; + allVars: VarInfo[]; + onChange: (v: string | null) => void; +} + +const SingleVarSelect: React.FC = ({ value, constraint, varsMap, allVars, onChange }) => { + const [open, setOpen] = useState(false); + const ref = useRef(null); + + useEffect(() => { + const handler = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); + }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, []); + + const mismatch = value && constraint ? checkMismatch(value, constraint, varsMap) : null; + + const categoricalVars = allVars.filter(v => v.type === 'categorical'); + const numericVars = allVars.filter(v => v.type === 'numeric'); + + const renderOption = (v: VarInfo) => { + const warn = constraint ? checkMismatch(v.name, constraint, varsMap) : null; + return ( +
{ onChange(v.name); setOpen(false); }} + title={warn || undefined} + > + + {v.name} + {v.totalLevels !== undefined && {v.totalLevels}级} + {warn && } +
+ ); + }; + + return ( +
+ + {value && ( + + )} + {open && ( +
+ {categoricalVars.length > 0 && ( + <> +
分类变量
+ {categoricalVars.map(renderOption)} + + )} + {numericVars.length > 0 && ( + <> +
连续变量
+ {numericVars.map(renderOption)} + + )} +
+ )} +
+ ); +}; + +// ────────────────────────── MultiVarTags ────────────────────────── + +interface MultiVarTagsProps { + values: string[]; + constraint: ParamConstraint | undefined; + varsMap: Map; + allVars: VarInfo[]; + onChange: (v: string[]) => void; +} + +const MultiVarTags: React.FC = ({ values, constraint, varsMap, allVars, onChange }) => { + const [showDropdown, setShowDropdown] = useState(false); + const ref = useRef(null); + + useEffect(() => { + const handler = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) setShowDropdown(false); + }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, []); + + const removeVar = (name: string) => onChange(values.filter(v => v !== name)); + const addVar = (name: string) => { + if (!values.includes(name)) onChange([...values, name]); + }; + + const unselected = allVars.filter(v => !values.includes(v.name)); + const unselectedCat = unselected.filter(v => v.type === 'categorical'); + const unselectedNum = unselected.filter(v => v.type === 'numeric'); + + const selectAllType = (type: string) => { + const toAdd = allVars.filter(v => v.type === type && !values.includes(v.name)).map(v => v.name); + onChange([...values, ...toAdd]); + }; + + return ( +
+
+ {values.map(name => { + const info = varsMap.get(name); + const warn = constraint ? checkMismatch(name, constraint, varsMap) : null; + return ( + + {name} + {warn && } + + + ); + })} + +
+ {showDropdown && unselected.length > 0 && ( +
+
+ {unselectedCat.length > 0 && ( + + )} + {unselectedNum.length > 0 && ( + + )} +
+ {unselectedCat.length > 0 && ( + <> +
分类变量
+ {unselectedCat.map(v => ( +
addVar(v.name)}> + + {v.name} + {v.totalLevels !== undefined && {v.totalLevels}级} +
+ ))} + + )} + {unselectedNum.length > 0 && ( + <> +
连续变量
+ {unselectedNum.map(v => ( +
addVar(v.name)}> + + {v.name} +
+ ))} + + )} +
+ )} +
+ ); +}; + +// ────────────────────────── StepCard ────────────────────────── + interface StepCardProps { step: WorkflowStepDef; result?: WorkflowStepResult; isLast: boolean; isCurrent: boolean; + editable: boolean; + varsMap: Map; + allVars: VarInfo[]; + onParamChange?: (key: string, value: unknown) => void; } -const StepCard: React.FC = ({ step, result, isLast, isCurrent }) => { +const StepCard: React.FC = ({ step, result, isLast, isCurrent, editable, varsMap, allVars, onParamChange }) => { const status: WorkflowStepStatus | 'pending' = result?.status || 'pending'; const s = statusStyle[status]; + const toolCode = step.tool_code; + const toolConstraints = TOOL_CONSTRAINTS[toolCode]; const visibleParams = step.params ? Object.entries(step.params).filter(([k]) => !HIDDEN_PARAMS.has(k)) : []; + const mismatches: string[] = []; + if (toolConstraints && step.params) { + for (const [key, value] of Object.entries(step.params)) { + const c = toolConstraints[key]; + if (!c || !isVariableParam(key)) continue; + if (c.paramType === 'single' && typeof value === 'string' && value) { + const warn = checkMismatch(value, c, varsMap); + if (warn) mismatches.push(`${PARAM_LABELS[key] || key}: ${warn}`); + } else if (c.paramType === 'multi' && Array.isArray(value)) { + for (const v of value) { + const warn = checkMismatch(String(v), c, varsMap); + if (warn) mismatches.push(`${PARAM_LABELS[key] || key} → ${v}: ${warn}`); + } + } + } + } + + const canEdit = editable && status === 'pending'; + return (
- {/* Left rail */}
{!isLast && ( @@ -121,22 +466,31 @@ const StepCard: React.FC = ({ step, result, isLast, isCurrent }) )}
- {/* Card */}
+ {mismatches.length > 0 && ( +
+ + 当前变量类型与统计方法不完全匹配,执行时可能报错 +
+ )} +
步骤 {step.step_number} {step.tool_name} {step.is_sensitivity && 敏感性} + {mismatches.length > 0 && ( + + + + )}
{result?.duration_ms != null && ( {result.duration_ms}ms )}
- {step.description && ( -

{step.description}

- )} + {step.description &&

{step.description}

} {step.switch_condition && (
@@ -147,12 +501,37 @@ const StepCard: React.FC = ({ step, result, isLast, isCurrent }) {visibleParams.length > 0 && (
- {visibleParams.slice(0, 5).map(([key, value]) => ( -
- {PARAM_LABELS[key] || key} - {formatValue(value)} -
- ))} + {visibleParams.map(([key, value]) => { + const constraint = toolConstraints?.[key]; + const isVarParam = isVariableParam(key); + const isSingle = SINGLE_VAR_KEYS.has(key); + const isMulti = MULTI_VAR_KEYS.has(key); + + return ( +
+ {PARAM_LABELS[key] || key} + {canEdit && isVarParam && isSingle ? ( + onParamChange?.(key, v)} + /> + ) : canEdit && isVarParam && isMulti ? ( + onParamChange?.(key, v)} + /> + ) : ( + {formatValue(value)} + )} +
+ ); + })}
)} @@ -176,19 +555,70 @@ const StepCard: React.FC = ({ step, result, isLast, isCurrent }) ); }; +// ────────────────────────── Main Component ────────────────────────── + export const WorkflowTimeline: React.FC = ({ plan, stepResults = [], currentStep, isExecuting = false, + editable = false, + variableDictionary = [], + dataOverviewColumns = [], + onStepParamsChange, }) => { const getResult = (n: number) => stepResults.find(r => r.step_number === n); const done = stepResults.filter(r => r.status === 'success').length; const pct = plan.total_steps > 0 ? (done / plan.total_steps) * 100 : 0; + const varsMap = React.useMemo(() => { + const map = new Map(); + for (const v of variableDictionary) { + const col = dataOverviewColumns.find(c => c.name === v.name); + map.set(v.name, { + name: v.name, + type: v.confirmedType || v.inferredType, + totalLevels: col?.totalLevels, + }); + } + // Fallback: use dataOverviewColumns for entries not yet in variableDictionary + for (const col of dataOverviewColumns) { + if (!map.has(col.name)) { + map.set(col.name, { + name: col.name, + type: col.type, + totalLevels: col.totalLevels, + }); + } + } + return map; + }, [variableDictionary, dataOverviewColumns]); + + const allVars = React.useMemo(() => { + if (variableDictionary.length > 0) { + return variableDictionary + .filter(v => !v.isIdLike) + .map(v => { + const col = dataOverviewColumns.find(c => c.name === v.name); + return { + name: v.name, + type: v.confirmedType || v.inferredType, + totalLevels: col?.totalLevels, + }; + }); + } + // Fallback: build from dataOverviewColumns when variableDictionary is empty + return dataOverviewColumns + .filter(c => !c.isIdLike) + .map(c => ({ + name: c.name, + type: c.type, + totalLevels: c.totalLevels, + })); + }, [variableDictionary, dataOverviewColumns]); + return (
- {/* Header */}
@@ -207,11 +637,15 @@ export const WorkflowTimeline: React.FC = ({ 预计 {plan.estimated_time_seconds < 60 ? `${plan.estimated_time_seconds}秒` : `${Math.ceil(plan.estimated_time_seconds / 60)}分钟`} )} + {editable && ( + + 点击变量参数可修改 + + )}
- {/* EPV Warning */} {plan.epv_warning && (
@@ -219,7 +653,6 @@ export const WorkflowTimeline: React.FC = ({
)} - {/* Guardrail Banner */} {plan.planned_trace?.fallbackTool && (
@@ -230,7 +663,6 @@ export const WorkflowTimeline: React.FC = ({
)} - {/* Progress */} {isExecuting && (
@@ -240,7 +672,6 @@ export const WorkflowTimeline: React.FC = ({
)} - {/* Steps */}
{plan.steps.map((step, i) => ( = ({ result={getResult(step.step_number)} isLast={i === plan.steps.length - 1} isCurrent={currentStep === step.step_number} + editable={editable} + varsMap={varsMap} + allVars={allVars} + onParamChange={(key, value) => onStepParamsChange?.(step.step_number, { [key]: value })} /> ))}
- {/* Footer */} {!isExecuting && stepResults.length === 0 && (
@@ -265,3 +699,51 @@ export const WorkflowTimeline: React.FC = ({ }; export default WorkflowTimeline; + +/** + * Utility: detect param mismatches for a plan (used by SSAWorkspacePane mismatch dialog) + */ +export function detectPlanMismatches( + plan: WorkflowPlan, + variableDictionary: VariableDictEntry[], + dataOverviewColumns: DataOverviewColumn[] +): Array<{ stepNumber: number; toolName: string; param: string; message: string }> { + const varsMap = new Map(); + for (const v of variableDictionary) { + const col = dataOverviewColumns.find(c => c.name === v.name); + varsMap.set(v.name, { + name: v.name, + type: v.confirmedType || v.inferredType, + totalLevels: col?.totalLevels, + }); + } + for (const col of dataOverviewColumns) { + if (!varsMap.has(col.name)) { + varsMap.set(col.name, { name: col.name, type: col.type, totalLevels: col.totalLevels }); + } + } + + const results: Array<{ stepNumber: number; toolName: string; param: string; message: string }> = []; + + for (const step of plan.steps) { + const toolConstraints = TOOL_CONSTRAINTS[step.tool_code]; + if (!toolConstraints || !step.params) continue; + + for (const [key, value] of Object.entries(step.params)) { + const c = toolConstraints[key]; + if (!c || !isVariableParam(key)) continue; + + if (c.paramType === 'single' && typeof value === 'string' && value) { + const warn = checkMismatch(value, c, varsMap); + if (warn) results.push({ stepNumber: step.step_number, toolName: step.tool_name, param: PARAM_LABELS[key] || key, message: warn }); + } else if (c.paramType === 'multi' && Array.isArray(value)) { + for (const v of value) { + const warn = checkMismatch(String(v), c, varsMap); + if (warn) results.push({ stepNumber: step.step_number, toolName: step.tool_name, param: `${PARAM_LABELS[key] || key} → ${v}`, message: warn }); + } + } + } + } + + return results; +} diff --git a/frontend-v2/src/modules/ssa/hooks/useSSAChat.ts b/frontend-v2/src/modules/ssa/hooks/useSSAChat.ts index 4472dc97..a1738f76 100644 --- a/frontend-v2/src/modules/ssa/hooks/useSSAChat.ts +++ b/frontend-v2/src/modules/ssa/hooks/useSSAChat.ts @@ -239,18 +239,20 @@ export function useSSAChat(): UseSSAChatReturn { } // analysis_plan 事件(Phase IV: 对话驱动分析) + // 仅创建记录,不打开工作区 — 等用户确认方案后再打开 if (parsed.type === 'analysis_plan' && parsed.plan) { const plan = parsed.plan as WorkflowPlan; - const { addRecord, setActivePane, setWorkspaceOpen } = useSSAStore.getState(); + const { addRecord } = useSSAStore.getState(); addRecord(content, plan); - setActivePane('sap'); - setWorkspaceOpen(true); continue; } - // plan_confirmed 事件(Phase IV: 用户确认方案后触发执行) - if (parsed.type === 'plan_confirmed' && parsed.workflowId) { - setPendingPlanConfirm({ workflowId: parsed.workflowId }); + // plan_confirmed 事件(Phase IV: 用户确认方案后打开工作区) + // 不自动触发 executeWorkflow — 由用户在工作区手动点击「开始执行分析」 + if (parsed.type === 'plan_confirmed') { + const { setActivePane, setWorkspaceOpen } = useSSAStore.getState(); + setActivePane('sap'); + setWorkspaceOpen(true); setPendingQuestion(null); continue; } @@ -319,14 +321,27 @@ export function useSSAChat(): UseSSAChatReturn { /** * 响应 ask_user 卡片(Phase III) + * 将 value 解析为中文 label 用于显示 */ const respondToQuestion = useCallback(async (sessionId: string, response: AskUserResponseData) => { + const question = pendingQuestion; setPendingQuestion(null); - const displayText = response.action === 'select' - ? `选择了: ${response.selectedValues?.join(', ')}` - : response.freeText || '(已回复)'; + + let displayText: string; + if (response.action === 'select' && response.selectedValues) { + const labels = response.selectedValues.map(val => { + const opt = question?.options?.find(o => o.value === val); + return opt?.label || val; + }); + displayText = `选择了 ${labels.join('、')}`; + } else if (response.action === 'free_text') { + displayText = response.freeText || '(已回复)'; + } else { + displayText = '(已回复)'; + } + await sendChatMessage(sessionId, displayText, { askUserResponse: response }); - }, [sendChatMessage]); + }, [sendChatMessage, pendingQuestion]); /** * H1: 跳过 ask_user 卡片 @@ -337,7 +352,7 @@ export function useSSAChat(): UseSSAChatReturn { questionId, action: 'skip', }; - await sendChatMessage(sessionId, '跳过了此问题', { askUserResponse: skipResponse }); + await sendChatMessage(sessionId, '已跳过此问题', { askUserResponse: skipResponse }); }, [sendChatMessage]); return { diff --git a/frontend-v2/src/modules/ssa/index.tsx b/frontend-v2/src/modules/ssa/index.tsx index cb877868..37fc0dbd 100644 --- a/frontend-v2/src/modules/ssa/index.tsx +++ b/frontend-v2/src/modules/ssa/index.tsx @@ -9,6 +9,7 @@ import React, { useEffect } from 'react'; import { useSSAStore } from './stores/ssaStore'; import SSAWorkspace from './SSAWorkspace'; +import './styles/ssa.css'; import './styles/ssa-workspace.css'; const SSAModule: React.FC = () => { diff --git a/frontend-v2/src/modules/ssa/stores/ssaStore.ts b/frontend-v2/src/modules/ssa/stores/ssaStore.ts index 30db6979..90072fc8 100644 --- a/frontend-v2/src/modules/ssa/stores/ssaStore.ts +++ b/frontend-v2/src/modules/ssa/stores/ssaStore.ts @@ -97,6 +97,10 @@ interface SSAState { addRecord: (query: string, plan: WorkflowPlan) => string; updateRecord: (id: string, patch: Partial>) => void; selectRecord: (id: string) => void; + updateStepParams: (recordId: string, stepNumber: number, params: Record) => void; + + hasUnsavedPlanChanges: boolean; + setHasUnsavedPlanChanges: (v: boolean) => void; // Data profile setDataProfile: (profile: DataProfile | null) => void; @@ -136,6 +140,7 @@ const initialState = { dataProfileLoading: false, dataProfileModalVisible: false, workflowPlanLoading: false, + hasUnsavedPlanChanges: false, dataContext: { dataOverview: null, variableDictionary: [], @@ -233,6 +238,21 @@ export const useSSAStore = create((set) => ({ }); }, + updateStepParams: (recordId, stepNumber, params) => { + set((state) => ({ + analysisHistory: state.analysisHistory.map((r) => { + if (r.id !== recordId || !r.plan) return r; + const updatedSteps = r.plan.steps.map((s) => + s.step_number === stepNumber ? { ...s, params: { ...s.params, ...params } } : s + ); + return { ...r, plan: { ...r.plan, steps: updatedSteps } }; + }), + hasUnsavedPlanChanges: true, + })); + }, + + setHasUnsavedPlanChanges: (v) => set({ hasUnsavedPlanChanges: v }), + // Data profile setDataProfile: (profile) => set({ dataProfile: profile }), setDataProfileLoading: (loading) => set({ dataProfileLoading: loading }), diff --git a/frontend-v2/src/modules/ssa/styles/ssa.css b/frontend-v2/src/modules/ssa/styles/ssa.css index 2aeeb9c5..00e1b9da 100644 --- a/frontend-v2/src/modules/ssa/styles/ssa.css +++ b/frontend-v2/src/modules/ssa/styles/ssa.css @@ -671,3 +671,552 @@ color: #94a3b8; white-space: nowrap; } + +/* ============================================ */ +/* Phase III: AskUser 交互卡片 */ +/* ============================================ */ + +.ask-user-card { + background: #ffffff; + border: 1px solid #e0e7ef; + border-radius: 10px; + padding: 14px 16px; + max-width: 420px; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05); +} + +.ask-user-question { + display: flex; + align-items: flex-start; + gap: 8px; + font-size: 13.5px; + font-weight: 500; + color: #1e293b; + line-height: 1.5; + margin-bottom: 8px; +} +.ask-user-question svg { margin-top: 2px; } + +.ask-user-context { + font-size: 12.5px; + color: #64748b; + line-height: 1.5; + margin-bottom: 12px; + padding-left: 22px; +} + +.ask-user-options { + display: flex; + flex-wrap: wrap; + gap: 8px; + padding-left: 22px; +} + +.ask-user-option-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 7px 16px; + border: 1px solid #d1d5db; + border-radius: 8px; + background: #fff; + color: #374151; + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all 0.15s; +} +.ask-user-option-btn:hover { + border-color: #3b82f6; + color: #3b82f6; + background: #eff6ff; +} +.ask-user-option-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} +.ask-user-option-btn.confirm-primary { + background: #3b82f6; + color: #fff; + border-color: #3b82f6; +} +.ask-user-option-btn.confirm-primary:hover { + background: #2563eb; + border-color: #2563eb; + color: #fff; +} + +.ask-user-checkbox-label { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 12px; + border: 1px solid #e5e7eb; + border-radius: 6px; + cursor: pointer; + font-size: 13px; + color: #374151; + transition: all 0.12s; + width: 100%; +} +.ask-user-checkbox-label:hover { background: #f9fafb; } + +.ask-user-free-text { + display: flex; + flex-direction: column; + gap: 8px; + width: 100%; +} +.ask-user-textarea { + width: 100%; + border: 1px solid #d1d5db; + border-radius: 8px; + padding: 8px 12px; + font-size: 13px; + resize: vertical; + outline: none; + transition: border-color 0.15s; +} +.ask-user-textarea:focus { border-color: #3b82f6; } + +.ask-user-submit-btn { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 6px 16px; + border: none; + border-radius: 6px; + background: #3b82f6; + color: #fff; + font-size: 13px; + cursor: pointer; + align-self: flex-end; + transition: background 0.15s; +} +.ask-user-submit-btn:hover { background: #2563eb; } +.ask-user-submit-btn:disabled { opacity: 0.5; cursor: not-allowed; } + +.option-label { white-space: nowrap; } +.option-desc { font-size: 11px; color: #94a3b8; } + +.ask-user-skip { + padding-left: 22px; + margin-top: 10px; + border-top: 1px solid #f1f5f9; + padding-top: 8px; +} +.ask-user-skip-btn { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px 10px; + border: none; + border-radius: 5px; + background: transparent; + color: #94a3b8; + font-size: 12px; + cursor: pointer; + transition: all 0.15s; +} +.ask-user-skip-btn:hover { + background: #f1f5f9; + color: #64748b; +} + +/* ============================================ */ +/* Phase II: Chat Markdown 渲染样式 */ +/* ============================================ */ + +.chat-msg-content { + font-size: 14px; + line-height: 1.7; + color: #1e293b; +} +.user-bubble .chat-msg-content, +.user-bubble .chat-msg-content p, +.user-bubble .chat-msg-content strong, +.user-bubble .chat-msg-content a { + color: inherit; +} +.chat-msg-content p { margin: 0 0 8px 0; } +.chat-msg-content p:last-child { margin-bottom: 0; } +.chat-msg-content strong { font-weight: 600; color: #0f172a; } +.chat-msg-content h3 { font-size: 15px; font-weight: 600; margin: 12px 0 6px; color: #0f172a; } +.chat-msg-content h4 { font-size: 14px; font-weight: 600; margin: 10px 0 4px; color: #1e293b; } +.chat-msg-content ul, .chat-msg-content ol { + padding-left: 20px; + margin: 6px 0; +} +.chat-msg-content li { margin-bottom: 3px; } +.chat-msg-content code { + padding: 1px 5px; + background: #f1f5f9; + border-radius: 4px; + font-size: 12.5px; + font-family: 'SF Mono', 'Fira Code', monospace; + color: #e11d48; +} +.chat-msg-content pre { + background: #1e293b; + color: #e2e8f0; + padding: 12px 14px; + border-radius: 8px; + overflow-x: auto; + font-size: 12.5px; + margin: 8px 0; +} +.chat-msg-content pre code { + background: none; + color: inherit; + padding: 0; +} +.chat-msg-content table { + width: 100%; + border-collapse: collapse; + margin: 8px 0; + font-size: 13px; +} +.chat-msg-content thead th { + background: #f8fafc; + padding: 6px 10px; + text-align: left; + font-weight: 600; + color: #475569; + border-bottom: 2px solid #e2e8f0; + white-space: nowrap; +} +.chat-msg-content tbody td { + padding: 5px 10px; + border-bottom: 1px solid #f1f5f9; + color: #334155; +} +.chat-msg-content tbody tr:hover { background: #f8fafc; } +.chat-msg-content hr { + border: none; + border-top: 1px solid #e2e8f0; + margin: 12px 0; +} +.chat-msg-content blockquote { + border-left: 3px solid #3b82f6; + padding-left: 12px; + margin: 8px 0; + color: #64748b; +} + +/* ==================================================================== + Phase V: Variable Editable Controls + Warning Styles + ==================================================================== */ + +/* ── Editable hint in header ── */ +.wt-editable-hint { + color: #3b82f6; + font-size: 11px; + font-style: italic; +} + +/* ── Step Warning Bar (Layer 1) ── */ +.wt-step-warning-bar { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 10px; + background: #fef3c7; + border-bottom: 1px solid #fde68a; + border-radius: 8px 8px 0 0; + font-size: 12px; + color: #92400e; + margin: -12px -12px 8px -12px; +} + +/* ── Step Warning Icon (Layer 2) ── */ +.wt-step-warning-icon { + display: inline-flex; + color: #f59e0b; + margin-left: 6px; + cursor: help; +} + +/* ── Single Var Select ── */ +.wt-var-select-wrap { + position: relative; + display: inline-flex; + align-items: center; + gap: 2px; + flex: 1; + min-width: 0; +} + +.wt-var-select-btn { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 8px; + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 6px; + font-size: 12px; + cursor: pointer; + min-width: 120px; + transition: border-color 0.15s, box-shadow 0.15s; +} +.wt-var-select-btn:hover { + border-color: #93c5fd; + box-shadow: 0 0 0 2px rgba(59,130,246,0.08); +} +.wt-var-select-btn.mismatch { + border-color: #fbbf24; + background: #fffbeb; +} + +.wt-var-placeholder { color: #94a3b8; } +.wt-var-chevron { transition: transform 0.15s; flex-shrink: 0; color: #94a3b8; } +.wt-var-chevron.open { transform: rotate(180deg); } + +.wt-var-clear-btn { + display: flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + border: none; + background: #f1f5f9; + border-radius: 50%; + cursor: pointer; + color: #64748b; + flex-shrink: 0; +} +.wt-var-clear-btn:hover { background: #fee2e2; color: #dc2626; } + +/* ── Variable Type Dot ── */ +.wt-var-type-dot { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} +.wt-var-type-dot.categorical { background: #a855f7; } +.wt-var-type-dot.numeric { background: #3b82f6; } +.wt-var-type-dot.datetime { background: #f97316; } +.wt-var-type-dot.text { background: #64748b; } +.wt-var-type-dot.unknown { background: #cbd5e1; } + +/* ── Dropdown ── */ +.wt-var-dropdown { + position: absolute; + top: calc(100% + 4px); + left: 0; + z-index: 50; + min-width: 200px; + max-height: 280px; + overflow-y: auto; + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 8px; + box-shadow: 0 8px 24px rgba(0,0,0,0.12); + padding: 4px 0; +} +.wt-var-dropdown.multi { min-width: 240px; } + +.wt-var-type-group { + padding: 6px 12px 3px; + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: #94a3b8; +} + +.wt-var-option { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 12px; + font-size: 12px; + cursor: pointer; + transition: background 0.1s; +} +.wt-var-option:hover { background: #f1f5f9; } +.wt-var-option.selected { background: #eff6ff; font-weight: 500; } +.wt-var-option.warn { color: #92400e; } + +.wt-var-levels { + margin-left: auto; + font-size: 10px; + color: #94a3b8; + background: #f1f5f9; + padding: 1px 5px; + border-radius: 4px; +} + +.wt-var-warn-icon { color: #f59e0b; flex-shrink: 0; } +.wt-var-warn-icon-sm { color: #f59e0b; margin-left: 2px; } + +/* ── Quick Actions in dropdown ── */ +.wt-var-quick-actions { + display: flex; + gap: 6px; + padding: 6px 10px; + border-bottom: 1px solid #f1f5f9; +} +.wt-var-quick-btn { + font-size: 11px; + padding: 2px 8px; + border: 1px solid #e2e8f0; + border-radius: 4px; + background: #f8fafc; + cursor: pointer; + color: #475569; +} +.wt-var-quick-btn:hover { background: #eff6ff; border-color: #93c5fd; } + +/* ── Multi Var Tags ── */ +.wt-var-tags-wrap { + position: relative; + flex: 1; + min-width: 0; +} + +.wt-var-tags { + display: flex; + flex-wrap: wrap; + gap: 4px; + align-items: center; +} + +.wt-var-tag { + display: inline-flex; + align-items: center; + gap: 3px; + padding: 2px 6px 2px 8px; + border-radius: 4px; + font-size: 11px; + font-weight: 500; + line-height: 1.4; +} +.wt-var-tag.categorical { background: #f3e8ff; color: #7c3aed; border: 1px solid #ddd6fe; } +.wt-var-tag.numeric { background: #dbeafe; color: #1d4ed8; border: 1px solid #bfdbfe; } +.wt-var-tag.datetime { background: #ffedd5; color: #c2410c; border: 1px solid #fed7aa; } +.wt-var-tag.text { background: #f1f5f9; color: #475569; border: 1px solid #e2e8f0; } +.wt-var-tag.unknown { background: #f1f5f9; color: #64748b; border: 1px solid #e2e8f0; } +.wt-var-tag.warn { border-color: #fbbf24; background: #fffbeb; } + +.wt-var-tag-remove { + display: inline-flex; + align-items: center; + justify-content: center; + width: 14px; + height: 14px; + border: none; + background: transparent; + cursor: pointer; + border-radius: 50%; + opacity: 0.6; + padding: 0; +} +.wt-var-tag-remove:hover { opacity: 1; background: rgba(0,0,0,0.1); } + +.wt-var-add-btn { + display: inline-flex; + align-items: center; + gap: 3px; + padding: 2px 8px; + border: 1px dashed #cbd5e1; + border-radius: 4px; + background: transparent; + cursor: pointer; + font-size: 11px; + color: #64748b; +} +.wt-var-add-btn:hover { border-color: #3b82f6; color: #3b82f6; background: #eff6ff; } + +/* ── Mismatch Dialog (Layer 3) ── */ +.wt-mismatch-overlay { + position: absolute; + inset: 0; + z-index: 100; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0,0,0,0.4); + backdrop-filter: blur(2px); +} + +.wt-mismatch-dialog { + background: #fff; + border-radius: 12px; + box-shadow: 0 16px 48px rgba(0,0,0,0.2); + max-width: 500px; + width: 90%; + padding: 24px; +} + +.wt-mismatch-header { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 12px; + color: #dc2626; +} +.wt-mismatch-header h3 { + font-size: 16px; + font-weight: 600; + margin: 0; + color: #1e293b; +} + +.wt-mismatch-desc { + font-size: 13px; + color: #64748b; + line-height: 1.5; + margin: 0 0 16px; +} + +.wt-mismatch-list { + max-height: 200px; + overflow-y: auto; + margin-bottom: 20px; +} + +.wt-mismatch-item { + display: flex; + flex-direction: column; + gap: 2px; + padding: 8px 10px; + background: #fef2f2; + border-radius: 6px; + margin-bottom: 6px; + font-size: 12px; +} +.wt-mismatch-step { font-weight: 600; color: #1e293b; } +.wt-mismatch-param { color: #475569; } +.wt-mismatch-msg { color: #dc2626; } + +.wt-mismatch-actions { + display: flex; + gap: 10px; + justify-content: flex-end; +} + +.wt-mismatch-cancel { + padding: 8px 16px; + border: 1px solid #e2e8f0; + border-radius: 8px; + background: #fff; + font-size: 13px; + cursor: pointer; + color: #475569; +} +.wt-mismatch-cancel:hover { background: #f1f5f9; } + +.wt-mismatch-force { + padding: 8px 16px; + border: 1px solid #fca5a5; + border-radius: 8px; + background: #fef2f2; + font-size: 13px; + cursor: pointer; + color: #dc2626; + font-weight: 500; +} +.wt-mismatch-force:hover { background: #fee2e2; } diff --git a/frontend-v2/src/modules/ssa/utils/exportBlocksToWord.ts b/frontend-v2/src/modules/ssa/utils/exportBlocksToWord.ts index 5efe9334..c58fc2b0 100644 --- a/frontend-v2/src/modules/ssa/utils/exportBlocksToWord.ts +++ b/frontend-v2/src/modules/ssa/utils/exportBlocksToWord.ts @@ -77,8 +77,8 @@ function blockToDocxElements(block: ReportBlock, index: number): (Paragraph | Ta case 'table': { const headers = block.headers ?? []; - const rows = block.rows ?? []; - if (headers.length > 0 || rows.length > 0) { + const rawRows = block.rows ?? []; + if (headers.length > 0 || (Array.isArray(rawRows) && rawRows.length > 0)) { if (block.title) { elements.push( new Paragraph({ @@ -91,8 +91,15 @@ function blockToDocxElements(block: ReportBlock, index: number): (Paragraph | Ta if (headers.length > 0) { tableRows.push(makeRow(headers.map(String), true)); } - for (const row of rows) { - tableRows.push(makeRow(row.map(c => (c === null || c === undefined ? '-' : String(c))))); + const normalizedRows = (Array.isArray(rawRows) ? rawRows : []).map((row: any) => { + if (Array.isArray(row)) return row; + if (row && typeof row === 'object') { + return headers.length > 0 ? headers.map((h: string) => row[h] ?? '') : Object.values(row); + } + return [String(row ?? '')]; + }); + for (const row of normalizedRows) { + tableRows.push(makeRow(row.map((c: any) => (c === null || c === undefined ? '-' : String(c))))); } elements.push( new Table({