From 85fda830c262a48a44ed8c3bf324a2064ed566b0 Mon Sep 17 00:00:00 2001 From: HaHafeng Date: Tue, 24 Feb 2026 13:08:29 +0800 Subject: [PATCH] feat(ssa): Complete Phase V-A editable analysis plan variables Features: - Add editable variable selection in workflow plan (SingleVarSelect + MultiVarTags) - Implement 3-layer flexible interception (warning bar + icon + blocking dialog) - Add tool_param_constraints.json for 12 statistical tools parameter validation - Add PATCH /workflow/:id/params API with Zod structural validation - Implement synchronous parameter sync before execution (Promise chaining) - Fix LLM hallucination by strict system prompt constraints - Fix DynamicReport object-based rows compatibility (R baseline_table) - Fix Word export row.map error with same normalization logic - Restore inferGroupingVar for smart default variable selection - Add ReactMarkdown rendering in SSAChatPane - Update SSA module status document to v3.5 Modified files: - backend: workflow.routes, ChatHandlerService, SystemPromptService, FlowTemplateService - frontend: WorkflowTimeline, SSAWorkspacePane, DynamicReport, SSAChatPane, ssaStore, ssa.css - config: tool_param_constraints.json (new) - docs: SSA status doc, team review reports Tested: Cohort study end-to-end execution + report export verified Co-authored-by: Cursor --- backend/scripts/seed-ssa-phase2-prompts.ts | 56 +- .../ssa/config/tool_param_constraints.json | 52 ++ .../src/modules/ssa/routes/workflow.routes.ts | 138 +++++ .../ssa/services/ChatHandlerService.ts | 27 +- .../ssa/services/FlowTemplateService.ts | 47 +- .../ssa/services/SystemPromptService.ts | 41 +- .../分布式Fan-out任务模式开发指南.md | 157 ++++- .../工具3批量提取技术架构设计(散装与轮询版).md | 250 ++++++++ .../02-技术设计/MinerU API 文档.docx | Bin 0 -> 49600 bytes .../04-开发计划/08a-工具3-M1-骨架管线冲刺清单.md | 22 +- .../04-开发计划/08d-工具3-代码模式与技术规范.md | 119 +++- .../SSA-智能统计分析/00-模块当前状态与开发指南.md | 43 +- .../架构与统计双重视角审查报告:变量可编辑化.md | 91 +++ .../架构与统计双重视角审查报告:变量可编辑化V2修订版.md | 84 +++ .../分布式Fan-out指南破壁级审查报告.md | 127 ++++ .../分布式Fan-out指南逐行级审查报告.md | 154 +++++ .../工具3全量代码级同步审计与修正清单.md | 148 +++++ .../src/modules/aia/styles/chat-workspace.css | 2 +- .../modules/ssa/components/DynamicReport.tsx | 33 +- .../modules/ssa/components/SSAChatPane.tsx | 20 +- .../ssa/components/SSAWorkspacePane.tsx | 129 +++- .../ssa/components/WorkflowTimeline.tsx | 524 ++++++++++++++++- .../src/modules/ssa/hooks/useSSAChat.ts | 37 +- frontend-v2/src/modules/ssa/index.tsx | 1 + .../src/modules/ssa/stores/ssaStore.ts | 20 + frontend-v2/src/modules/ssa/styles/ssa.css | 549 ++++++++++++++++++ .../modules/ssa/utils/exportBlocksToWord.ts | 15 +- 27 files changed, 2732 insertions(+), 154 deletions(-) create mode 100644 backend/src/modules/ssa/config/tool_param_constraints.json create mode 100644 docs/03-业务模块/ASL-AI智能文献/00-系统设计/证据整合V2.0/工具3批量提取技术架构设计(散装与轮询版).md create mode 100644 docs/03-业务模块/ASL-AI智能文献/02-技术设计/MinerU API 文档.docx create mode 100644 docs/03-业务模块/SSA-智能统计分析/06-开发记录/J技术报告审核评估与建议/架构与统计双重视角审查报告:变量可编辑化.md create mode 100644 docs/03-业务模块/SSA-智能统计分析/06-开发记录/J技术报告审核评估与建议/架构与统计双重视角审查报告:变量可编辑化V2修订版.md create mode 100644 docs/09-架构实施/分布式Fan-out指南破壁级审查报告.md create mode 100644 docs/09-架构实施/分布式Fan-out指南逐行级审查报告.md create mode 100644 docs/09-架构实施/工具3全量代码级同步审计与修正清单.md 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 0000000000000000000000000000000000000000..b7d7c793bef7cdf035f040f4d8f3545eca180275 GIT binary patch literal 49600 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 **所属:** 工具 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({