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 <cursoragent@cursor.com>
This commit is contained in:
2026-02-24 13:08:29 +08:00
parent dc6b292308
commit 85fda830c2
27 changed files with 2732 additions and 154 deletions

View File

@@ -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. 指出分析的局限性和注意事项

View File

@@ -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": "选择需要分析的变量" }
}
}

View File

@@ -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<string, any> }> } }>(
'/: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<string>(
(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
* 生成数据画像

View File

@@ -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<HandleResult> {
// 清除 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(

View File

@@ -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<string>();
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 模板步骤使用)
*/

View File

@@ -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<IntentType, string> = {
chat: '请基于统计知识和用户数据直接回答用户的问题。不要主动建议执行分析,除非用户明确要求。简洁作答,分点清晰。',
explore: '用户想了解数据的特征。请基于上方的数据摘要信息,帮用户解读数据特征(缺失、分布、异常值等)。可以推断 PICO 结构。不要执行分析。',
consult: '用户在咨询统计方法。请根据数据特征和研究目的推荐合适的统计方法,给出选择理由和前提条件。不要直接执行分析。提供替代方案。',
analyze: '以下是工具执行结果。请向用户简要说明分析进展和关键发现。使用通俗语言,避免过度技术化。',
discuss: '用户想讨论分析结果。请帮助用户深入解读结果,解释统计量的含义,讨论临床意义和局限性。',
feedback: '用户对之前的分析结果不满意或有改进建议。请分析问题原因,提出改进方案(如更换统计方法、调整参数等)。',
chat: '请基于统计知识和用户数据直接回答用户的问题。不要主动建议执行分析,除非用户明确要求。简洁作答,分点清晰。禁止编造任何数值。',
explore: '用户想了解数据的特征。请基于上方的数据摘要信息,帮用户解读数据特征(缺失、分布、异常值等)。可以推断 PICO 结构。不要执行分析,不要编造统计数值。',
consult: '用户在咨询统计方法。请根据数据特征和研究目的推荐合适的统计方法,给出选择理由和前提条件。不要直接执行分析。提供替代方案。禁止给出任何假设的分析结果数值。',
analyze: '你正在协助用户进行分析规划。你的职责限于解释分析方案的思路和方法选择理由。禁止生成任何P值、统计量、均值、分析结果表格。所有数值结果只能来自 R 引擎的真实执行输出。如果 R 引擎还没有返回结果,只能说明方案状态,不能自行填充结果。',
discuss: '用户想讨论分析结果。请仅基于 R 引擎返回的真实数据帮助用户解读,解释统计量的含义,讨论临床意义和局限性。禁止补充或编造 R 引擎未返回的数值。',
feedback: '用户对之前的分析结果不满意或有改进建议。请分析问题原因,提出改进方案(如更换统计方法、调整参数等)。禁止编造数值来论证改进效果。',
};
return map[intent];
}

View File

@@ -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`
### 模式 2Last 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到数据库。大文件内容不快照通过错误分级路由兜底。
### 🆕 模式 8Manager 空集合边界守卫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,届时补充真实踩坑记录和性能数据。*

View File

@@ -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/extractingAggregator 就不会关闭父任务。这天然规避了此前“硬崩溃导致永远卡死”的顶级漏洞。
3. **开发提速 200%** 架构理解成本降至最低。新人一听就懂:“打散分发,各个击破,定时结账”。
4. **性能拉满 (Max Scale-out)** 多 SAE 实例部署时100 个任务均匀分布在所有机器上。数据库没有任何行锁竞争CPU 和 IO 利用率达到最完美的线性扩展。
**恭喜团队做出了最符合创业公司发展阶段的高可用架构决策!您可以直接将本设计文档交由后端研发开展 Sprint 1 的开发!**

View File

@@ -3,7 +3,7 @@
> **所属:** 工具 3 全文智能提取工作台 V2.0
> **架构总纲:** `08-工具3-全文智能提取工作台V2.0开发计划.md`
> **代码手册:** `08d-工具3-代码模式与技术规范.md`(所有代码模式均在此手册中,开发时按需查阅)
> **建议时间:** Week 15-6 天)
> **建议时间:** Week 15.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 Worker1 天)⚠️ 核心战役:**
- `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 获取 storageKeyv1.5 | 提取中 PKB 删文档 → 批量崩溃 |
| 8 | 🆕 临时错误 `throw` 前必须 `update({ status: 'pending' })` 释放乐观锁v1.6 | 重试时被"幂等跳过"计数永远缺一票Task 永久卡死 |
| 9 | 🆕 Manager 必须检查 `results.length === 0` 并直接 completedv1.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 日志流

View File

@@ -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.6SSE 推送日志(跨 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'。
// 如果裸 throwpg-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 bytesLLM 错误堆栈可能超限
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 客户端都能收到

View File

@@ -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 Chainingawait PATCH -> 再触发执行 + loading 防连点
> - ✅ **inferGroupingVar 恢复** — LLM 未识别分组变量时,自动推断二分类变量填入默认值
> - ✅ **DynamicReport 增强** — 兼容 R 基线表对象格式 rowsWord 导出同步修复
> - ✅ **前后端集成测试通过** — 队列研究完整执行 + 报告导出验证
>
> **此前进展2026-02-22 Phase IV 完成):**
> - ✅ **Phase IV 全 5 批次完成** — ToolOrchestratorServicePICO 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 前端** | AskUserCard4 inputType + H1 跳过按钮)+ useSSAChat 扩展pendingQuestion + respondToQuestion + skipQuestion | ✅ |
| **Phase IV 后端** | ToolOrchestratorServiceplan+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 前端** | useSSAChatanalysis_plan+plan_confirmed SSE 处理+pendingPlanConfirm→executeWorkflow+ SSAChatPaneAskUserCard 渲染+幽灵卡片清除 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/paramsZod 结构校验防火墙)+ tool_param_constraints.json12 工具参数约束)+ 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-A12 工具参数类型约束
├── 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 VI10h** — 集成测试 + 可观测性(含 QPER 透明化)
**详细计划:** `04-开发计划/11-智能对话与工具体系开发计划.md`v1.8Phase I-IV 完成,含架构约束 C1-C8 + 全部团队审查落地记录)
**详细计划:** `04-开发计划/11-智能对话与工具体系开发计划.md`v1.8Phase 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 天)

View File

@@ -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。
* **生存分析陷阱**:它需要两个 YTime 是连续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)**
#### **🚨 风险 1UI 状态与执行动作的竞态条件 (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 重新生成方案。”
#### **🚨 风险 3Zod 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 的专业度再上一个大台阶。**

View File

@@ -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)**
#### **🚨 风险 1UI 状态与执行动作的竞态条件 (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 重新生成方案。是否仍要强行执行?"* \[ 取消并重新对话 \] \[ 强行执行 \]
* **架构师点评**:这种设计堪称完美。把控制权给用户,把免责声明做足。
#### **🚨 风险 3Zod 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 的标杆。**

View File

@@ -0,0 +1,127 @@
# **🔬 分布式 Fan-out 指南 (v1.2) 破壁级审查与修订报告**
**审查人:** 资深架构师 & Node.js 底层专家
**审查对象:** 《分布式 Fan-out 任务模式开发指南 v1.2》
**审查深度:** V8 引擎内存栈、Postgres 底层驱动、pg-boss 命名空间
**核心结论:** 业务逻辑已彻底闭环!但在**JSON 字符串强截断、长期闲置连接保活、全局唯一键防重**这三个底层物理机制上,存在 3 个会直接导致进程崩溃或静默失效的高危隐患。
## **🚨 破壁级漏洞 1NOTIFY 强截断导致的 JSON.parse 爆栈崩溃**
### **❌ 逐行审查发现的问题(位于 模式 6SSE 跨实例广播)**
指南中为了防止超过 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 });
}
});
## **🚨 破壁级漏洞 2LISTEN 监听器的“静默死亡” (Silent Connection Drop)**
### **❌ 逐行审查发现的问题(位于 模式 6SSE 跨实例广播)**
指南中的 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();
## **🚨 破壁级漏洞 3singletonKey 作用域跨界的“狸猫换太子”**
### **❌ 逐行审查发现的问题(位于 五、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 定稿版)。有了这套带底层防线的文档,您的团队在面对任何高并发、高可用挑战时,都能写出真正的“大厂级”工业代码!

View File

@@ -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)**
### **❌ 逐行推演发现的问题(位于 模式 2Sweeper 清道夫)**
指南中建议这样筛选卡死的任务:
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\) },
},
});
## **🚨 致命漏洞 3Manager 的“空集合”黑洞 (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 循环派发...
## **🚨 隐患 4NOTIFY 的底层 SQL 注入与转义灾难**
### **❌ 逐行推演发现的问题(位于 模式 6SSE 跨实例广播)**
指南中使用了原生的 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)中,然后再指导其他类似任务的开发。修复这些问题后,这套系统才算真正拥有了“抗造”的底气!

View File

@@ -0,0 +1,148 @@
# **🔬 工具 3 终极代码级同步审计与修正清单 (基于 Fan-out v1.2)**
**审计背景:** 确保《工具 3 开发计划 (v1.4.2)》及其代码模式08d完全、无死角地落实了《分布式 Fan-out 开发指南 v1.2》中的所有极端场景防御策略。
**审计结论:** 理论已同步,但**代码落地存在 4 处断层**。必须修改 08d-代码模式与技术规范.md 中的具体代码片段。
## **🚨 审计点 1Child Worker 临时错误重试的“死锁穿透” (必须修改)**
**🔍 逐行审查发现:**
在 08d 文档的 §4.3 ExtractionChildWorker 的 catch 块中,针对临时错误的代码目前是:
// 当前 08d 代码
// 临时错误 (429/网络抖动):直接 throw让 pg-boss 自动指数退避重试
throw error;
**💥 业务危害:**
这直接违背了 Fan-out 指南 v1.2 的核心补丁!因为上方使用了 updateMany 乐观锁把 AslExtractionResult 的状态改为了 extracting。如果直接 throwpg-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;
}
## **🚨 审计点 2Manager 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 \=== 0Manager 会直接退出。因为没有任何 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);
## **🚨 审计点 4SSE 广播代码的安全隐患 (必须修改)**
**🔍 逐行审查发现:**
在 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% 放心闭眼放行了!祝团队开发顺利!**

View File

@@ -1018,4 +1018,4 @@
border-radius: 4px;
font-family: 'Monaco', 'Consolas', 'Courier New', monospace;
font-size: 0.9em;
}
}

View File

@@ -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) {

View File

@@ -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<HTMLDivElement>(null);
const messagesContainerRef = useRef<HTMLDivElement>(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 = () => {
<span>{msg.content}</span>
</div>
) : (
<div className="chat-msg-content">{msg.content}</div>
<div className="chat-msg-content">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{msg.content}
</ReactMarkdown>
</div>
)}
</div>
</div>

View File

@@ -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<HTMLDivElement>(null);
const resultRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(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<string, unknown>) => {
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 (
<section className={`ssa-workspace-pane ${workspaceOpen ? 'open' : ''}`}>
{/* Mismatch Informed Consent Dialog */}
{mismatchDialog && (
<div className="wt-mismatch-overlay">
<div className="wt-mismatch-dialog">
<div className="wt-mismatch-header">
<AlertTriangle size={20} />
<h3></h3>
</div>
<p className="wt-mismatch-desc">
AI
</p>
<div className="wt-mismatch-list">
{mismatchDialog.mismatches.map((m, i) => (
<div key={i} className="wt-mismatch-item">
<span className="wt-mismatch-step">{m.stepNumber} {m.toolName}</span>
<span className="wt-mismatch-param">{m.param}</span>
<span className="wt-mismatch-msg">{m.message}</span>
</div>
))}
</div>
<div className="wt-mismatch-actions">
<button className="wt-mismatch-cancel" onClick={() => setMismatchDialog(null)}>
</button>
<button className="wt-mismatch-force" onClick={handleForceExecute}>
</button>
</div>
</div>
</div>
)}
<div className="workspace-inner">
{/* Header + step bar */}
<header className="workspace-header">
@@ -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}
/>
<div className="sap-actions">
@@ -243,9 +353,18 @@ export const SSAWorkspacePane: React.FC = () => {
</button>
) : (
<button className="run-btn" onClick={handleRun} disabled={phase !== 'planning'}>
<Play size={14} />
<button className="run-btn" onClick={handleRun} disabled={phase !== 'planning' || isPatchingSaving}>
{isPatchingSaving ? (
<>
<Loader2 size={14} className="spin" />
...
</>
) : (
<>
<Play size={14} />
</>
)}
</button>
)}
</div>

View File

@@ -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<string, Record<string, ParamConstraint>>;
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, VarInfo>
): 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<string, unknown>) => void;
}
const statusStyle: Record<WorkflowStepStatus | 'pending', {
@@ -91,26 +213,249 @@ const PARAM_LABELS: Record<string, string> = {
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<string, VarInfo>;
allVars: VarInfo[];
onChange: (v: string | null) => void;
}
const SingleVarSelect: React.FC<SingleVarSelectProps> = ({ value, constraint, varsMap, allVars, onChange }) => {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(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 (
<div
key={v.name}
className={`wt-var-option ${v.name === value ? 'selected' : ''} ${warn ? 'warn' : ''}`}
onClick={() => { onChange(v.name); setOpen(false); }}
title={warn || undefined}
>
<span className={`wt-var-type-dot ${v.type}`} />
<span>{v.name}</span>
{v.totalLevels !== undefined && <span className="wt-var-levels">{v.totalLevels}</span>}
{warn && <AlertTriangle size={12} className="wt-var-warn-icon" />}
</div>
);
};
return (
<div className="wt-var-select-wrap" ref={ref}>
<button
className={`wt-var-select-btn ${mismatch ? 'mismatch' : ''}`}
onClick={() => setOpen(!open)}
title={mismatch || undefined}
>
{value ? (
<>
<span className={`wt-var-type-dot ${varsMap.get(value)?.type || 'unknown'}`} />
<span>{value}</span>
{mismatch && <AlertTriangle size={12} className="wt-var-warn-icon" />}
</>
) : (
<span className="wt-var-placeholder">...</span>
)}
<ChevronDown size={14} className={`wt-var-chevron ${open ? 'open' : ''}`} />
</button>
{value && (
<button className="wt-var-clear-btn" onClick={() => onChange(null)} title="清除">
<XIcon size={12} />
</button>
)}
{open && (
<div className="wt-var-dropdown">
{categoricalVars.length > 0 && (
<>
<div className="wt-var-type-group"></div>
{categoricalVars.map(renderOption)}
</>
)}
{numericVars.length > 0 && (
<>
<div className="wt-var-type-group"></div>
{numericVars.map(renderOption)}
</>
)}
</div>
)}
</div>
);
};
// ────────────────────────── MultiVarTags ──────────────────────────
interface MultiVarTagsProps {
values: string[];
constraint: ParamConstraint | undefined;
varsMap: Map<string, VarInfo>;
allVars: VarInfo[];
onChange: (v: string[]) => void;
}
const MultiVarTags: React.FC<MultiVarTagsProps> = ({ values, constraint, varsMap, allVars, onChange }) => {
const [showDropdown, setShowDropdown] = useState(false);
const ref = useRef<HTMLDivElement>(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 (
<div className="wt-var-tags-wrap" ref={ref}>
<div className="wt-var-tags">
{values.map(name => {
const info = varsMap.get(name);
const warn = constraint ? checkMismatch(name, constraint, varsMap) : null;
return (
<span
key={name}
className={`wt-var-tag ${info?.type || 'unknown'} ${warn ? 'warn' : ''}`}
title={warn || undefined}
>
{name}
{warn && <AlertTriangle size={10} className="wt-var-warn-icon-sm" />}
<button className="wt-var-tag-remove" onClick={() => removeVar(name)}>
<XIcon size={10} />
</button>
</span>
);
})}
<button className="wt-var-add-btn" onClick={() => setShowDropdown(!showDropdown)}>
<Plus size={12} />
<span></span>
</button>
</div>
{showDropdown && unselected.length > 0 && (
<div className="wt-var-dropdown multi">
<div className="wt-var-quick-actions">
{unselectedCat.length > 0 && (
<button className="wt-var-quick-btn" onClick={() => selectAllType('categorical')}>
</button>
)}
{unselectedNum.length > 0 && (
<button className="wt-var-quick-btn" onClick={() => selectAllType('numeric')}>
</button>
)}
</div>
{unselectedCat.length > 0 && (
<>
<div className="wt-var-type-group"></div>
{unselectedCat.map(v => (
<div key={v.name} className="wt-var-option" onClick={() => addVar(v.name)}>
<span className={`wt-var-type-dot ${v.type}`} />
<span>{v.name}</span>
{v.totalLevels !== undefined && <span className="wt-var-levels">{v.totalLevels}</span>}
</div>
))}
</>
)}
{unselectedNum.length > 0 && (
<>
<div className="wt-var-type-group"></div>
{unselectedNum.map(v => (
<div key={v.name} className="wt-var-option" onClick={() => addVar(v.name)}>
<span className={`wt-var-type-dot ${v.type}`} />
<span>{v.name}</span>
</div>
))}
</>
)}
</div>
)}
</div>
);
};
// ────────────────────────── StepCard ──────────────────────────
interface StepCardProps {
step: WorkflowStepDef;
result?: WorkflowStepResult;
isLast: boolean;
isCurrent: boolean;
editable: boolean;
varsMap: Map<string, VarInfo>;
allVars: VarInfo[];
onParamChange?: (key: string, value: unknown) => void;
}
const StepCard: React.FC<StepCardProps> = ({ step, result, isLast, isCurrent }) => {
const StepCard: React.FC<StepCardProps> = ({ 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 (
<div className={`wt-step-row ${isCurrent ? 'wt-current' : ''}`}>
{/* Left rail */}
<div className="wt-rail">
<StatusDot status={status} num={step.step_number} />
{!isLast && (
@@ -121,22 +466,31 @@ const StepCard: React.FC<StepCardProps> = ({ step, result, isLast, isCurrent })
)}
</div>
{/* Card */}
<div className="wt-card" style={{ borderLeftColor: s.borderColor }}>
{mismatches.length > 0 && (
<div className="wt-step-warning-bar">
<AlertTriangle size={13} />
<span></span>
</div>
)}
<div className="wt-card-head">
<div className="wt-card-title-row">
<span className="wt-step-badge"> {step.step_number}</span>
<span className="wt-tool-name">{step.tool_name}</span>
{step.is_sensitivity && <span className="wt-sensitivity"></span>}
{mismatches.length > 0 && (
<span className="wt-step-warning-icon" title={mismatches.join('\n')}>
<AlertTriangle size={14} />
</span>
)}
</div>
{result?.duration_ms != null && (
<span className="wt-duration">{result.duration_ms}ms</span>
)}
</div>
{step.description && (
<p className="wt-card-desc">{step.description}</p>
)}
{step.description && <p className="wt-card-desc">{step.description}</p>}
{step.switch_condition && (
<div className="wt-guardrail-inline">
@@ -147,12 +501,37 @@ const StepCard: React.FC<StepCardProps> = ({ step, result, isLast, isCurrent })
{visibleParams.length > 0 && (
<div className="wt-params-grid">
{visibleParams.slice(0, 5).map(([key, value]) => (
<div key={key} className="wt-param-item">
<span className="wt-param-label">{PARAM_LABELS[key] || key}</span>
<span className="wt-param-val">{formatValue(value)}</span>
</div>
))}
{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 (
<div key={key} className="wt-param-item">
<span className="wt-param-label">{PARAM_LABELS[key] || key}</span>
{canEdit && isVarParam && isSingle ? (
<SingleVarSelect
value={typeof value === 'string' ? value : null}
constraint={constraint}
varsMap={varsMap}
allVars={allVars}
onChange={(v) => onParamChange?.(key, v)}
/>
) : canEdit && isVarParam && isMulti ? (
<MultiVarTags
values={Array.isArray(value) ? value.map(String) : []}
constraint={constraint}
varsMap={varsMap}
allVars={allVars}
onChange={(v) => onParamChange?.(key, v)}
/>
) : (
<span className="wt-param-val">{formatValue(value)}</span>
)}
</div>
);
})}
</div>
)}
@@ -176,19 +555,70 @@ const StepCard: React.FC<StepCardProps> = ({ step, result, isLast, isCurrent })
);
};
// ────────────────────────── Main Component ──────────────────────────
export const WorkflowTimeline: React.FC<WorkflowTimelineProps> = ({
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<string, VarInfo>();
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 (
<div className="wt-root">
{/* Header */}
<div className="wt-header">
<div className="wt-header-icon">
<FlaskConical size={20} />
@@ -207,11 +637,15 @@ export const WorkflowTimeline: React.FC<WorkflowTimelineProps> = ({
{plan.estimated_time_seconds < 60 ? `${plan.estimated_time_seconds}` : `${Math.ceil(plan.estimated_time_seconds / 60)}分钟`}
</span>
)}
{editable && (
<span className="wt-meta-item wt-editable-hint">
</span>
)}
</div>
</div>
</div>
{/* EPV Warning */}
{plan.epv_warning && (
<div className="wt-banner wt-banner-warn">
<AlertTriangle size={15} />
@@ -219,7 +653,6 @@ export const WorkflowTimeline: React.FC<WorkflowTimelineProps> = ({
</div>
)}
{/* Guardrail Banner */}
{plan.planned_trace?.fallbackTool && (
<div className="wt-banner wt-banner-guard">
<Shield size={15} />
@@ -230,7 +663,6 @@ export const WorkflowTimeline: React.FC<WorkflowTimelineProps> = ({
</div>
)}
{/* Progress */}
{isExecuting && (
<div className="wt-progress-bar-wrap">
<div className="wt-progress-track">
@@ -240,7 +672,6 @@ export const WorkflowTimeline: React.FC<WorkflowTimelineProps> = ({
</div>
)}
{/* Steps */}
<div className="wt-steps">
{plan.steps.map((step, i) => (
<StepCard
@@ -249,11 +680,14 @@ export const WorkflowTimeline: React.FC<WorkflowTimelineProps> = ({
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 })}
/>
))}
</div>
{/* Footer */}
{!isExecuting && stepResults.length === 0 && (
<div className="wt-footer">
<BarChart3 size={14} />
@@ -265,3 +699,51 @@ export const WorkflowTimeline: React.FC<WorkflowTimelineProps> = ({
};
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<string, VarInfo>();
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;
}

View File

@@ -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 {

View File

@@ -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 = () => {

View File

@@ -97,6 +97,10 @@ interface SSAState {
addRecord: (query: string, plan: WorkflowPlan) => string;
updateRecord: (id: string, patch: Partial<Omit<AnalysisRecord, 'id'>>) => void;
selectRecord: (id: string) => void;
updateStepParams: (recordId: string, stepNumber: number, params: Record<string, unknown>) => 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<SSAState>((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 }),

View File

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

View File

@@ -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({