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:
@@ -0,0 +1,250 @@
|
||||
# **工具 3 批量提取技术架构设计:散装派发与轮询收口模式**
|
||||
|
||||
**文档版本:** V2.0 (Startup Agile Edition)
|
||||
|
||||
**核心架构:** 散装派发 (Scatter) \+ 独立单兵 Worker \+ 定时轮询聚合 (Polling Aggregator)
|
||||
|
||||
**业务目标:** 支撑工具 3(百篇文献并发提取),告别行锁争用与死锁,实现最高开发效率与多节点并发性能。
|
||||
|
||||
## **💡 一、 为什么选择这套架构?(The Philosophy)**
|
||||
|
||||
在处理“1 个任务包含 100 篇文献提取”的场景时,我们放弃了传统的“父子任务 Fan-out”强一致性模型,转而采用一种\*\*“最终一致性”\*\*的松耦合架构:
|
||||
|
||||
1. **极简的写入逻辑 (无并发冲突):** 100 个 Worker 抢到任务后各干各的,**只更新属于自己的那 1 行 Result 记录**。绝对不去触碰父任务(Task 表),彻底消灭了多进程对同一行的行锁竞争 (Row-Lock Contention)。
|
||||
2. **读写分离的进度感知:** 前端查询进度时,API 实时去数据库做 COUNT(Result) 聚合,读操作极快且不阻塞写操作。
|
||||
3. **单线程结账 (无死锁):** 用一个每 10 秒跑一次的全局定时任务(Aggregator)充当“包工头”,扫描所有任务,发现哪个任务下面的子项全做完了,就给它打上 Completed 标签。
|
||||
|
||||
## **🏗️ 二、 核心数据流转图 (Data Flow)**
|
||||
|
||||
\[ 前端 Client \]
|
||||
│ 1\. POST /tasks (勾选了 100 篇文献)
|
||||
▼
|
||||
\[ Node.js API (Controller) \]
|
||||
│ 2\. 创建 1 个 Task 记录
|
||||
│ 3\. 批量创建 100 个 Result 记录 (status: pending)
|
||||
│ 4\. 🚀 散装派发:for 循环 100 次 \`pgBoss.send('asl\_extract\_single', ...)\`
|
||||
└─\> 返回 TaskID 给前端 (耗时 \< 0.1秒)
|
||||
|
||||
\======================== 异步处理域 (多 SAE 实例并发) \========================
|
||||
|
||||
\[ pg-boss 队列 (Postgres) \] \<── 存放着 100 个单篇提取任务
|
||||
|
||||
\[ Pod A \] \[ Pod B \] \[ Pod C \]
|
||||
Worker 抢单 Worker 抢单 Worker 抢单
|
||||
│ │ │
|
||||
├─ 提取 文献 1 ├─ 提取 文献 2 ├─ 提取 文献 3
|
||||
│ │ │
|
||||
└─ UPDATE Result 1 └─ UPDATE Result 2 └─ UPDATE Result 3
|
||||
(status: completed) (status: error) (status: completed)
|
||||
※ 各干各的,互不干扰,不碰 Task 表!
|
||||
|
||||
\======================== 全局收口域 (单线程定时器) \========================
|
||||
|
||||
\[ pg-boss 调度器 (10秒触发一次) \]
|
||||
│
|
||||
\[ Task Aggregator (全局唯一包工头) \]
|
||||
│ 1\. 查出所有 status='processing' 的 Task
|
||||
│ 2\. GROUP BY 统计其下 Result 的状态
|
||||
│ 3\. 如果 pending=0 且 extracting=0
|
||||
└─\> UPDATE Task SET status='completed' (终点收口!)
|
||||
|
||||
## **🗄️ 三、 数据库设计微调 (Prisma Schema)**
|
||||
|
||||
采用该模式后,AslExtractionTask 表不再需要频繁更新,成为一个极其稳定的元数据表。
|
||||
|
||||
model AslExtractionTask {
|
||||
id String @id @default(uuid())
|
||||
projectId String
|
||||
templateId String
|
||||
totalCount Int // 总文献数 (前端传入,创建后不再改变)
|
||||
|
||||
// 核心状态:'processing' (进行中), 'completed' (已完成)
|
||||
// 此字段仅由 API 创建时设为 processing,由 Aggregator 统一改为 completed
|
||||
status String @default("processing")
|
||||
|
||||
// 弃用:不再需要 successCount / failedCount 字段,改由实时 COUNT 聚合得出!
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
completedAt DateTime?
|
||||
|
||||
results AslExtractionResult\[\]
|
||||
@@schema("asl\_schema")
|
||||
}
|
||||
|
||||
model AslExtractionResult {
|
||||
id String @id @default(uuid())
|
||||
taskId String
|
||||
pkbDocumentId String
|
||||
|
||||
// 子任务状态: 'pending' (排队中), 'extracting' (提取中), 'completed' (成功), 'error' (失败)
|
||||
status String @default("pending")
|
||||
extractedData Json? // 最终提取的 JSON 结果
|
||||
errorMessage String?
|
||||
|
||||
task AslExtractionTask @relation(fields: \[taskId\], references: \[id\])
|
||||
|
||||
// 添加索引:极大提升 Aggregator 聚合统计的速度
|
||||
@@index(\[taskId, status\])
|
||||
@@schema("asl\_schema")
|
||||
}
|
||||
|
||||
## **💻 四、 核心代码落地指南 (Show me the code)**
|
||||
|
||||
### **1\. API 层:极速散装派发**
|
||||
|
||||
**文件:** ExtractionController.ts
|
||||
|
||||
无需编写 Manager Worker,直接在 API 接口中进行 for 循环派发。
|
||||
|
||||
async function createTask(req: Request, reply: FastifyReply) {
|
||||
const { projectId, templateId, documentIds } \= req.body;
|
||||
|
||||
if (documentIds.length \=== 0\) throw new Error("未选择文献");
|
||||
|
||||
// 1\. 批量创建记录
|
||||
const task \= await prisma.aslExtractionTask.create({
|
||||
data: { projectId, templateId, totalCount: documentIds.length, status: 'processing' }
|
||||
});
|
||||
|
||||
const resultsData \= documentIds.map(docId \=\> ({
|
||||
taskId: task.id, pkbDocumentId: docId, status: 'pending'
|
||||
}));
|
||||
await prisma.aslExtractionResult.createMany({ data: resultsData });
|
||||
|
||||
// 查询出刚创建的 Result IDs
|
||||
const createdResults \= await prisma.aslExtractionResult.findMany({ where: { taskId: task.id } });
|
||||
|
||||
// 2\. 🚀 散装派发 (Scatter) \- 直接压入单篇队列
|
||||
// 即使 100 次循环,得益于 pg-boss 内部的批量插入优化,耗时极短
|
||||
const jobs \= createdResults.map(result \=\> ({
|
||||
name: 'asl\_extract\_single',
|
||||
data: { resultId: result.id, pkbDocumentId: result.pkbDocumentId },
|
||||
options: { retryLimit: 3, retryBackoff: true, expireInMinutes: 30 } // 单篇重试机制
|
||||
}));
|
||||
await jobQueue.insert(jobs); // 假设你们底层封装了批量 insert 方法
|
||||
|
||||
return reply.send({ success: true, taskId: task.id });
|
||||
}
|
||||
|
||||
### **2\. Worker 层:无脑单兵作战**
|
||||
|
||||
**文件:** ExtractionSingleWorker.ts
|
||||
|
||||
这里是真正调 MinerU 和 LLM 的地方。**没有任何并发锁,没有任何父任务更新。**
|
||||
|
||||
// 限制单机并发,防 OOM 和 API 熔断
|
||||
jobQueue.work('asl\_extract\_single', { teamConcurrency: 10 }, async (job) \=\> {
|
||||
const { resultId, pkbDocumentId } \= job.data;
|
||||
|
||||
// 1\. 更改自身状态为 extracting (不碰父任务!)
|
||||
await prisma.aslExtractionResult.update({
|
||||
where: { id: resultId }, data: { status: 'extracting' }
|
||||
});
|
||||
|
||||
try {
|
||||
// 2\. 执行漫长且脆弱的业务逻辑 (MinerU \+ DeepSeek-V3)
|
||||
const data \= await extractLogic(pkbDocumentId);
|
||||
|
||||
// 3\. 成功:只更新自身!(绝对安全)
|
||||
await prisma.aslExtractionResult.update({
|
||||
where: { id: resultId },
|
||||
data: { status: 'completed', extractedData: data }
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
// 错误分级判断
|
||||
if (isPermanentError(error)) {
|
||||
// 致命错误:更新自身为 error,打断重试
|
||||
await prisma.aslExtractionResult.update({
|
||||
where: { id: resultId }, data: { status: 'error', errorMessage: error.message }
|
||||
});
|
||||
return { success: false, note: 'Permanent Error' };
|
||||
} else {
|
||||
// 临时错误 (如网络波动):让出状态,抛出给 pg-boss 重试
|
||||
await prisma.aslExtractionResult.update({
|
||||
where: { id: resultId }, data: { status: 'pending' }
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
### **3\. Aggregator 层:全局包工头轮询收口**
|
||||
|
||||
**文件:** ExtractionAggregator.ts
|
||||
|
||||
**触发机制:** 使用 pg-boss 定时器,保证多 Pod 环境下同一时间只有 1 个机器执行此检查。
|
||||
|
||||
// 在后端启动时注册:每 10 秒跑一次
|
||||
await jobQueue.schedule('asl\_extraction\_aggregator', '\*/10 \* \* \* \* \*');
|
||||
|
||||
jobQueue.work('asl\_extraction\_aggregator', async () \=\> {
|
||||
// 1\. 找到所有还没结束的父任务
|
||||
const activeTasks \= await prisma.aslExtractionTask.findMany({
|
||||
where: { status: 'processing' }
|
||||
});
|
||||
|
||||
for (const task of activeTasks) {
|
||||
// 2\. 分组统计其子任务状态 (聚合查询极快)
|
||||
const stats \= await prisma.aslExtractionResult.groupBy({
|
||||
by: \['status'\],
|
||||
where: { taskId: task.id },
|
||||
\_count: true
|
||||
});
|
||||
|
||||
const pendingCount \= stats.find(s \=\> s.status \=== 'pending')?.\_count || 0;
|
||||
const extractingCount \= stats.find(s \=\> s.status \=== 'extracting')?.\_count || 0;
|
||||
|
||||
// 3\. 收口逻辑:没有任何人在排队或干活了,说明这批活彻底干完了(不论成功还是失败)
|
||||
if (pendingCount \=== 0 && extractingCount \=== 0\) {
|
||||
await prisma.aslExtractionTask.update({
|
||||
where: { id: task.id },
|
||||
data: { status: 'completed', completedAt: new Date() }
|
||||
});
|
||||
|
||||
// 可选:在这里触发全量完成的业务动作 (如发送企业微信通知)
|
||||
logger.info(\`Task ${task.id} completely finished via Aggregator\!\`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
### **4\. 前端查询 API:读写分离的进度感知**
|
||||
|
||||
**文件:** TaskStatusController.ts
|
||||
|
||||
由于父任务表没有 successCount,前端轮询调用 /tasks/:taskId/status 时,我们**实时读取计算进度**。
|
||||
|
||||
async function getTaskStatus(req, reply) {
|
||||
const { taskId } \= req.params;
|
||||
|
||||
const task \= await prisma.aslExtractionTask.findUnique({ where: { id: taskId }});
|
||||
|
||||
// 实时动态 COUNT,取代维护冗余字段 (100条数据的 count 耗时 \< 1ms,完全无感)
|
||||
const successCount \= await prisma.aslExtractionResult.count({
|
||||
where: { taskId, status: 'completed' }
|
||||
});
|
||||
const failedCount \= await prisma.aslExtractionResult.count({
|
||||
where: { taskId, status: 'error' }
|
||||
});
|
||||
|
||||
return reply.send({
|
||||
status: task.status, // processing 或 completed
|
||||
progress: {
|
||||
total: task.totalCount,
|
||||
success: successCount,
|
||||
failed: failedCount,
|
||||
percent: Math.round(((successCount \+ failedCount) / task.totalCount) \* 100\)
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
## **🛡️ 五、 方案优势与降维打击总结**
|
||||
|
||||
采用这套“散装派发 \+ 轮询聚合”模式后,您的团队获得了如下战略优势:
|
||||
|
||||
1. **彻底告别死锁 (No Deadlocks):** 不再有恶心的乐观锁和竞争态,研发人员只需要专注写“解析 PDF、调大模型、更新一条数据”的纯粹业务逻辑。
|
||||
2. **自带清道夫免疫 (Sweeper-free):** 如果某个 Node.js 进程在提取中途“猝死”(OOM),该篇文献的状态会一直卡在 extracting。pg-boss 发现它超时后会重新拉起变为 pending。只要它还在 pending/extracting,Aggregator 就不会关闭父任务。这天然规避了此前“硬崩溃导致永远卡死”的顶级漏洞。
|
||||
3. **开发提速 200%:** 架构理解成本降至最低。新人一听就懂:“打散分发,各个击破,定时结账”。
|
||||
4. **性能拉满 (Max Scale-out):** 多 SAE 实例部署时,100 个任务均匀分布在所有机器上。数据库没有任何行锁竞争,CPU 和 IO 利用率达到最完美的线性扩展。
|
||||
|
||||
**恭喜团队做出了最符合创业公司发展阶段的高可用架构决策!您可以直接将本设计文档交由后端研发开展 Sprint 1 的开发!**
|
||||
BIN
docs/03-业务模块/ASL-AI智能文献/02-技术设计/MinerU API 文档.docx
Normal file
BIN
docs/03-业务模块/ASL-AI智能文献/02-技术设计/MinerU API 文档.docx
Normal file
Binary file not shown.
@@ -3,7 +3,7 @@
|
||||
> **所属:** 工具 3 全文智能提取工作台 V2.0
|
||||
> **架构总纲:** `08-工具3-全文智能提取工作台V2.0开发计划.md`
|
||||
> **代码手册:** `08d-工具3-代码模式与技术规范.md`(所有代码模式均在此手册中,开发时按需查阅)
|
||||
> **建议时间:** Week 1(5-6 天)
|
||||
> **建议时间:** Week 1(5.5-6.5 天,含 v1.6 Sweeper 清道夫 0.5 天)
|
||||
> **核心目标:** 证明 "PKB 拿数据 → Fan-out 分发 → LLM 盲提 → 数据落库 → 前端看到 completed" 这条管线是通的。
|
||||
|
||||
---
|
||||
@@ -69,13 +69,17 @@
|
||||
- `PkbBridgeService.ts`:调用 `PkbExportService`,代理所有 PKB 数据访问
|
||||
|
||||
**Step C — Fan-out Manager + Child Worker(1 天)⚠️ 核心战役:**
|
||||
- `ExtractionManagerWorker.ts`:读取任务 → ⚠️ v1.5 批量快照 PKB 元数据(`snapshotStorageKey` + `snapshotFilename`)冻结到 `AslExtractionResult` → 为每篇文献 `pgBoss.send('asl_extraction_child', ...)` → 退出(Fire-and-forget)
|
||||
- `ExtractionManagerWorker.ts`:读取任务 → 🆕 **v1.6 空集合守卫**(`results.length === 0` → 直接 completed) → ⚠️ v1.5 批量快照 PKB 元数据(`snapshotStorageKey` + `snapshotFilename`)冻结到 `AslExtractionResult` → 为每篇文献 `pgBoss.send('asl_extraction_child', ...)` → 退出(Fire-and-forget)
|
||||
- `ExtractionChildWorker.ts` 完整逻辑:
|
||||
1. **乐观锁抢占**:`updateMany({ where: { status: 'pending' }, data: { status: 'extracting' } })`
|
||||
2. **纯文本降级提取**:从 PKB 读 `extractedText` + 写死 RCT Schema → 调用 DeepSeek
|
||||
3. **原子递增**:事务内 `update Result + increment Task counts`
|
||||
4. **Last Child Wins**:`successCount + failedCount >= totalCount` → 翻转 `status = completed`
|
||||
5. **错误分级路由**:致命错误 return / 临时错误 throw
|
||||
5. **错误分级路由**:致命错误 return / 🆕 **v1.6 临时错误 throw 前释放乐观锁(回退 status → pending)**
|
||||
|
||||
**Step D — 🆕 Sweeper 清道夫注册(0.5 天)(v1.6 新增):**
|
||||
- `asl_extraction_sweeper`:pg-boss 定时任务,每 10 分钟扫描 `processing` 且 `updatedAt > 2h` 的任务,强制标记 `failed`
|
||||
- 使用 `updatedAt`(最后活跃时间)判断卡死,禁止用 `startedAt`(防误杀健康的超大批量任务)
|
||||
|
||||
**Worker 注册(遵守队列命名规范):**
|
||||
```
|
||||
@@ -94,9 +98,14 @@ jobQueue.work('asl_extraction_child', { teamConcurrency: 10 }, handler)
|
||||
- [ ] Last Child Wins:最后一个 Child 翻转 Task status = completed
|
||||
- [ ] 致命错误(PKB 文档不存在)→ 该篇标 error + 不重试 + 不阻塞其他篇
|
||||
- [ ] 临时错误(429)→ pg-boss 指数退避重试
|
||||
- [ ] 🆕 临时错误 throw 前回退 `status → pending`:模拟 429 重试后乐观锁仍能抢占成功(v1.6 乐观锁释放验证)
|
||||
- [ ] 🆕 Manager 空集合守卫:`results.length === 0` 时 Task 直接标记 `completed`(v1.6 边界验证)
|
||||
- [ ] 🆕 Sweeper 清道夫已注册:`asl_extraction_sweeper` 定时任务在 pg-boss 中可查到(v1.6)
|
||||
- [ ] 🆕 Sweeper 判定条件为 `updatedAt > 2h`,而非 `startedAt`(v1.6 防误杀验证)
|
||||
|
||||
> 📖 Fan-out 架构图、Worker 代码模式、研发红线见架构总纲 Task 2.3
|
||||
> 📖 ACL 防腐层设计见架构总纲 Task 3.3b
|
||||
> 📖 ACL 防腐层设计见架构总纲 Task 3.3b
|
||||
> 📖 Sweeper、乐观锁释放、空集合守卫代码见 08d §4.2 / §4.3 / §4.6
|
||||
|
||||
---
|
||||
|
||||
@@ -150,6 +159,9 @@ jobQueue.work('asl_extraction_child', { teamConcurrency: 10 }, handler)
|
||||
| 5 | Job Payload 仅传 ID(< 200 bytes),禁止塞 PDF 正文 | pg-boss 阻塞 |
|
||||
| 6 | ACL 防腐层:ASL 不 import PKB 内部类型 | 模块耦合蔓延 |
|
||||
| 7 | Manager 必须快照 `snapshotStorageKey` + `snapshotFilename`,Child 禁止运行时回查 PKB 获取 storageKey(v1.5) | 提取中 PKB 删文档 → 批量崩溃 |
|
||||
| 8 | 🆕 临时错误 `throw` 前必须 `update({ status: 'pending' })` 释放乐观锁(v1.6) | 重试时被"幂等跳过",计数永远缺一票,Task 永久卡死 |
|
||||
| 9 | 🆕 Manager 必须检查 `results.length === 0` 并直接 completed(v1.6) | 空文献 → 无 Child → Last Child Wins 死锁 |
|
||||
| 10 | 🆕 必须注册 `asl_extraction_sweeper` 清道夫(`updatedAt > 2h`,禁止用 `startedAt`)(v1.6) | 进程 OOM/SIGKILL 后 Task 永久挂起 |
|
||||
|
||||
---
|
||||
|
||||
@@ -160,6 +172,8 @@ jobQueue.work('asl_extraction_child', { teamConcurrency: 10 }, handler)
|
||||
✅ PKB ACL 防腐层 → PkbExportService + PkbBridgeService
|
||||
✅ Fan-out 全链路:Manager → N × Child → Last Child Wins → completed
|
||||
✅ 乐观锁 + 原子递增 + 错误分级路由 — 所有并发 Bug 已验证
|
||||
✅ 🆕 Sweeper 清道夫注册(v1.6 防 OOM/SIGKILL 卡死)
|
||||
✅ 🆕 乐观锁释放 + 空集合守卫 + pg_notify 参数化(v1.6 全量代码级同步)
|
||||
✅ 前端三步走:选模板/选文献 → 轮询进度 → 极简结果列表
|
||||
❌ 无 MinerU(纯文本降级)
|
||||
❌ 无 SSE 日志流
|
||||
|
||||
@@ -137,13 +137,11 @@ class PdfProcessingPipeline {
|
||||
### 3.2 PKB 复用感知日志
|
||||
|
||||
```typescript
|
||||
// 🚨 v1.6:使用 broadcastLog 跨 Pod 广播(替代 sseEmitter.emit)
|
||||
if (pkbExtractedText) {
|
||||
this.sseEmitter.emit(taskId, {
|
||||
type: 'log',
|
||||
data: {
|
||||
source: 'system',
|
||||
message: `⚡ [Fast-path] Reused full-text from PKB (saved ~10s pymupdf4llm): ${filename}`,
|
||||
}
|
||||
await broadcastLog(taskId, {
|
||||
source: 'system',
|
||||
message: `⚡ [Fast-path] Reused full-text from PKB (saved ~10s pymupdf4llm): ${filename}`,
|
||||
});
|
||||
}
|
||||
```
|
||||
@@ -189,6 +187,21 @@ class ExtractionManagerWorker {
|
||||
const task = await prisma.aslExtractionTask.findUnique({ where: { id: job.data.taskId } });
|
||||
const results = await prisma.aslExtractionResult.findMany({ where: { taskId: task.id } });
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// 🚨 v1.6 空集合边界守卫
|
||||
// 如果文献被全部删除或过滤后 results 为空,无 Child 被派发,
|
||||
// Last Child Wins 永远不触发,Task 永远卡在 processing。
|
||||
// Manager 必须自己充当"收口人"直接完成任务。
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
if (results.length === 0) {
|
||||
await prisma.aslExtractionTask.update({
|
||||
where: { id: task.id },
|
||||
data: { status: 'completed', completedAt: new Date() },
|
||||
});
|
||||
await broadcastLog(task.id, { source: 'system', message: '⚠️ No documents to extract, task auto-completed.' });
|
||||
return;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// ⚠️ v1.5 PKB 数据一致性快照
|
||||
// 提取任务可能持续 50 分钟,期间用户可能在 PKB 删除/修改文档。
|
||||
@@ -284,11 +297,8 @@ class ExtractionChildWorker {
|
||||
}),
|
||||
]);
|
||||
|
||||
// SSE 推送日志
|
||||
this.sseEmitter.emit(taskId, {
|
||||
type: 'log',
|
||||
data: { source: 'system', message: `✅ ${extractResult.filename} extracted` }
|
||||
});
|
||||
// 🚨 v1.6:SSE 推送日志(跨 Pod 广播,替代原 sseEmitter.emit)
|
||||
await broadcastLog(taskId, { source: 'system', message: `✅ ${extractResult.filename} extracted` });
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// ⚠️ v1.4.2 补丁 1:"Last Child Wins" 终止器
|
||||
@@ -300,7 +310,7 @@ class ExtractionChildWorker {
|
||||
where: { id: taskId },
|
||||
data: { status: 'completed', completedAt: new Date() },
|
||||
});
|
||||
this.sseEmitter.emit(taskId, { type: 'complete' });
|
||||
await broadcastLog(taskId, { source: 'system', type: 'complete', message: '🎉 All documents extracted.' });
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
@@ -324,12 +334,23 @@ class ExtractionChildWorker {
|
||||
where: { id: taskId },
|
||||
data: { status: 'completed', completedAt: new Date() },
|
||||
});
|
||||
this.sseEmitter.emit(taskId, { type: 'complete' });
|
||||
await broadcastLog(taskId, { source: 'system', type: 'complete', message: '🎉 All documents extracted.' });
|
||||
}
|
||||
|
||||
return { success: false, reason: 'Permanent failure, aborted retry.' };
|
||||
}
|
||||
// 临时错误 (429/网络抖动):直接 throw,让 pg-boss 自动指数退避重试
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// 🚨 v1.6 补丁:临时错误 throw 前必须释放乐观锁!
|
||||
// 原因:上方 updateMany 已将 status 改为 'extracting'。
|
||||
// 如果裸 throw,pg-boss 重试时乐观锁 where: { status: 'pending' }
|
||||
// 返回 count=0 → 误判"幂等跳过" → 计数永远少一票 → Last Child Wins 永远不触发。
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
await prisma.aslExtractionResult.update({
|
||||
where: { id: resultId },
|
||||
data: { status: 'pending' },
|
||||
});
|
||||
|
||||
// 临时错误 (429/网络抖动):throw → pg-boss 自动指数退避重试
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -388,6 +409,50 @@ class ExtractionChildWorker {
|
||||
| **死信处理** | 超过 retryLimit 的 Job 进入 DLQ | pg-boss 内置 `onFail` handler 标记该篇为 `error` |
|
||||
| **进度追踪** | 不在 Job data 中存大量进度 | 进度统一走 `CheckpointService`,Job data 仅含 ID 引用 |
|
||||
|
||||
### 🆕 4.6 Sweeper 清道夫 — 进程硬崩溃兜底(v1.6)
|
||||
|
||||
> **Fan-out 指南 v1.2 强制要求:** 单兵 Worker 无法处理自身猝死(OOM/SIGKILL),
|
||||
> 必须有系统级外部定时任务兜底。否则父任务可能永远卡在 `processing`。
|
||||
|
||||
```typescript
|
||||
// ===== 工具 3 专属清道夫(模块启动时注册) =====
|
||||
async function aslExtractionSweeper() {
|
||||
const stuckTasks = await prisma.aslExtractionTask.findMany({
|
||||
where: {
|
||||
status: 'processing',
|
||||
// 🚨 使用 updatedAt(最后活跃时间),而非 startedAt!
|
||||
// 500 篇文献正常排队可能需要 3+ 小时,用 startedAt 会误杀健康任务。
|
||||
// 只要 Child 还在完成并递增计数,updatedAt 就会持续刷新。
|
||||
updatedAt: { lt: new Date(Date.now() - 2 * 60 * 60 * 1000) },
|
||||
},
|
||||
});
|
||||
|
||||
for (const task of stuckTasks) {
|
||||
await prisma.aslExtractionTask.update({
|
||||
where: { id: task.id },
|
||||
data: {
|
||||
status: 'failed',
|
||||
errorMessage: '[Sweeper] No progress for 2h — likely Child Worker OOM/SIGKILL. Force-closed.',
|
||||
completedAt: new Date(),
|
||||
},
|
||||
});
|
||||
// 广播失败事件,确保前端 SSE 能感知
|
||||
await broadcastLog(task.id, {
|
||||
source: 'system',
|
||||
type: 'complete',
|
||||
message: '❌ [Sweeper] Task force-closed after 2h inactivity.',
|
||||
});
|
||||
logger.warn(`[Sweeper] Force-closed stuck task ${task.id} (no progress for 2h)`);
|
||||
}
|
||||
}
|
||||
|
||||
// 注册为 pg-boss 定时任务(每 10 分钟扫描一次)
|
||||
await jobQueue.schedule('asl_extraction_sweeper', '*/10 * * * *');
|
||||
await jobQueue.work('asl_extraction_sweeper', aslExtractionSweeper);
|
||||
```
|
||||
|
||||
> **关键:** Sweeper 判断"卡死"基于 `updatedAt` 而非 `startedAt`,避免误杀正在排队的超大批量任务。
|
||||
|
||||
---
|
||||
|
||||
## 5. fuzzyQuoteMatch 验证算法
|
||||
@@ -624,24 +689,29 @@ function ExtractionProgress({ taskId }: { taskId: string }) {
|
||||
|
||||
```typescript
|
||||
// ===== Worker 发送端(ExtractionChildWorker 内部) =====
|
||||
// 替代原有的 this.sseEmitter.emit(),改用 NOTIFY 广播
|
||||
// 🚨 v1.6 修正:使用 pg_notify() + Prisma 参数化绑定(免疫 SQL 注入)
|
||||
// 替代原有的 this.sseEmitter.emit() 和 $executeRawUnsafe 字符串拼接
|
||||
async function broadcastLog(taskId: string, logEntry: LogEntry) {
|
||||
const payload = JSON.stringify({
|
||||
const payloadStr = JSON.stringify({
|
||||
taskId,
|
||||
type: 'log',
|
||||
type: logEntry.type ?? 'log',
|
||||
data: logEntry,
|
||||
});
|
||||
// NOTIFY payload 上限 8000 bytes,日志消息绰绰有余
|
||||
await prisma.$executeRawUnsafe(
|
||||
`NOTIFY asl_sse_channel, '${payload.replace(/'/g, "''")}'`
|
||||
);
|
||||
|
||||
// 🚨 NOTIFY payload 物理上限 ~8000 bytes,LLM 错误堆栈可能超限
|
||||
const safePayload = payloadStr.length > 7000
|
||||
? payloadStr.substring(0, 7000) + '..."}'
|
||||
: payloadStr;
|
||||
|
||||
// 参数化绑定:$executeRaw Tagged Template + pg_notify()
|
||||
// 彻底免疫 SQL 注入,无需手动 .replace 转义
|
||||
await prisma.$executeRaw`SELECT pg_notify('asl_sse_channel', ${safePayload})`;
|
||||
}
|
||||
|
||||
// 使用方式(替代 this.sseEmitter.emit)
|
||||
// 使用方式(全面替代 this.sseEmitter.emit)
|
||||
await broadcastLog(taskId, {
|
||||
source: 'system',
|
||||
message: `✅ ${filename} extracted`,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
```
|
||||
|
||||
@@ -684,7 +754,8 @@ class SseNotifyBridge {
|
||||
```
|
||||
|
||||
**关键约束:**
|
||||
- NOTIFY payload 上限 **8000 bytes**(日志消息远小于此限制)
|
||||
- NOTIFY payload 物理上限 **~8000 bytes** → 发送前必须截断至 **7000 bytes**(v1.6 强制规范)
|
||||
- **禁止 `$executeRawUnsafe` + 字符串拼接!** 必须使用 `$executeRaw` Tagged Template + `pg_notify()`(v1.6 强制规范)
|
||||
- LISTEN 连接必须**独立于 Prisma 连接池**(PgClient 单独创建)
|
||||
- NOTIFY 是 fire-and-forget(无持久化),完美匹配 v1.4 双轨制定位
|
||||
- `complete` 事件仍走 NOTIFY 广播,确保"Last Child Wins"翻转状态后所有 Pod 的 SSE 客户端都能收到
|
||||
|
||||
@@ -1,13 +1,24 @@
|
||||
# SSA智能统计分析模块 - 当前状态与开发指南
|
||||
|
||||
> **文档版本:** v3.4
|
||||
> **文档版本:** v3.5
|
||||
> **创建日期:** 2026-02-18
|
||||
> **最后更新:** 2026-02-22
|
||||
> **最后更新:** 2026-02-23
|
||||
> **维护者:** 开发团队
|
||||
> **当前状态:** 🎉 **QPER 主线闭环 + Phase I + Phase II + Phase III + Phase IV(对话驱动分析 + QPER 集成)开发完成**
|
||||
> **当前状态:** 🎉 **QPER 主线闭环 + Phase I-IV + Phase V-A(分析方案变量可编辑化)开发完成**
|
||||
> **文档目的:** 快速了解SSA模块状态,为新AI助手提供上下文
|
||||
>
|
||||
> **最新进展(2026-02-22 Phase IV 完成):**
|
||||
> **最新进展(2026-02-23 Phase V-A 变量可编辑化完成):**
|
||||
> - ✅ **分析方案变量可编辑化** — 系统默认帮选变量,医生可在方案审查阶段修改/调整变量选择
|
||||
> - ✅ **三层柔性拦截** — Layer 1 即时黄条警告 + Layer 2 步骤警告图标 + Layer 3 执行前阻断确认弹窗(Informed Consent)
|
||||
> - ✅ **变量选择器 UI** — 单选下拉(按类型分组)+ 多选标签(分类=紫色 / 连续=蓝色)+ 全选分类/连续快捷按钮 + 不适配变量 ⚠️ 标记
|
||||
> - ✅ **tool_param_constraints 配置** — 12 个统计工具参数约束表,前后端共用单一事实来源
|
||||
> - ✅ **后端 PATCH API + Zod 防火墙** — PATCH /workflow/:id/params + 结构校验(400 Bad Request)/ 统计学校验交给 R 引擎
|
||||
> - ✅ **同步阻塞执行** — 执行按钮 Promise Chaining:await PATCH -> 再触发执行 + loading 防连点
|
||||
> - ✅ **inferGroupingVar 恢复** — LLM 未识别分组变量时,自动推断二分类变量填入默认值
|
||||
> - ✅ **DynamicReport 增强** — 兼容 R 基线表对象格式 rows,Word 导出同步修复
|
||||
> - ✅ **前后端集成测试通过** — 队列研究完整执行 + 报告导出验证
|
||||
>
|
||||
> **此前进展(2026-02-22 Phase IV 完成):**
|
||||
> - ✅ **Phase IV 全 5 批次完成** — ToolOrchestratorService(PICO hint 三层降级)+ handleAnalyze 重写(plan→analysis_plan SSE→LLM 方案说明→ask_user 确认)+ AVAILABLE_TOOLS 配置化(11 处改 toolRegistryService)+ 前端 SSE 对接(analysis_plan + plan_confirmed)
|
||||
> - ✅ **团队审查 H1-H3+B1-B2 全部落地** — H1 PICO hint 注入 / H2 幽灵卡片清除 / H3 SSE 严格串行 / B1 修改建议循环 / B2 旧 API 兼容
|
||||
> - ✅ **SSA_ANALYZE_PLAN Prompt 入库** — 指导 LLM 用自然语言解释分析方案(步骤/理由/注意事项)
|
||||
@@ -57,7 +68,7 @@
|
||||
| **前端状态模型** | **Unified Record Architecture — 一次分析 = 一个 Record = N 个 Steps** |
|
||||
| **商业价值** | ⭐⭐⭐⭐⭐ 极高 |
|
||||
| **目标用户** | 临床研究人员、生物统计师 |
|
||||
| **开发状态** | 🎉 **QPER 主线闭环 + 智能对话架构设计完成,Phase Deploy 待启动** |
|
||||
| **开发状态** | 🎉 **QPER 主线闭环 + Phase I-IV + Phase V-A(变量可编辑化)完成** |
|
||||
|
||||
### 核心目标
|
||||
|
||||
@@ -159,7 +170,8 @@ AnalysisRecord {
|
||||
| **Phase II** | **对话层 LLM + 意图路由器 + 统一对话入口** | **35h** | ✅ **已完成(4 批次, 12 文件, E2E 38/38, H1-H4 落地)** | 2026-02-22 |
|
||||
| **Phase III** | **method_consult + ask_user 标准化** | **20h** | ✅ **已完成(5 批次, 12 文件, E2E 13/13+4skip, H1-H3+P1 落地)** | 2026-02-22 |
|
||||
| **Phase IV** | **对话驱动分析 + QPER 集成** | **14h** | ✅ **已完成(5 批次, 11 文件, E2E 25/25, H1-H3+B1-B2 落地)** | 2026-02-22 |
|
||||
| **Phase V** | **反思编排 + 高级特性** | **18h** | 📋 待开始 | - |
|
||||
| **Phase V-A** | **分析方案变量可编辑化** | **~6h** | ✅ **已完成(9 文件, 团队双视角审查 V2, 三层柔性拦截)** | 2026-02-23 |
|
||||
| **Phase V-B** | **反思编排 + 高级特性** | **18h** | 📋 待开始 | - |
|
||||
| **Phase VI** | **集成测试 + 可观测性** | **10h** | 📋 待开始 | - |
|
||||
|
||||
### 已完成核心功能
|
||||
@@ -181,7 +193,9 @@ AnalysisRecord {
|
||||
| **Phase III 前端** | AskUserCard(4 inputType + H1 跳过按钮)+ useSSAChat 扩展(pendingQuestion + respondToQuestion + skipQuestion) | ✅ |
|
||||
| **Phase IV 后端** | ToolOrchestratorService(plan+PICO hint 三层降级+formatPlanForLLM)+ ChatHandlerService 重写(handleAnalyze: plan→analysis_plan SSE→LLM 说明→ask_user 确认; handleAskUserResponse: confirm_plan/change_method)+ AVAILABLE_TOOLS 配置化(11 处→toolRegistryService)+ ToolRegistryService(+getVisibleTools)+ AskUserService(+metadata)+ SSA_ANALYZE_PLAN Prompt 入库 | ✅ |
|
||||
| **Phase IV 前端** | useSSAChat(analysis_plan+plan_confirmed SSE 处理+pendingPlanConfirm→executeWorkflow)+ SSAChatPane(AskUserCard 渲染+幽灵卡片清除 H2) | ✅ |
|
||||
| **测试** | QPER 端到端 40/40 + 集成测试 7 Bug 修复 + Phase I E2E 31/31 + Phase II E2E 38/38 + Phase III E2E 13/13+4skip + Phase IV E2E 25/25 | ✅ |
|
||||
| **Phase V-A 后端** | PATCH /workflow/:id/params(Zod 结构校验防火墙)+ tool_param_constraints.json(12 工具参数约束)+ inferGroupingVar 恢复(默认填充分组变量) | ✅ |
|
||||
| **Phase V-A 前端** | WorkflowTimeline 可编辑化(SingleVarSelect + MultiVarTags + 三层柔性拦截)+ ssaStore updateStepParams + SSAWorkspacePane 同步阻塞执行 + DynamicReport 对象 rows 兼容 + Word 导出修复 | ✅ |
|
||||
| **测试** | QPER 端到端 40/40 + 集成测试 7 Bug 修复 + Phase I E2E 31/31 + Phase II E2E 38/38 + Phase III E2E 13/13+4skip + Phase IV E2E 25/25 + Phase V-A 前后端集成测试通过 | ✅ |
|
||||
|
||||
---
|
||||
|
||||
@@ -210,7 +224,8 @@ backend/src/modules/ssa/
|
||||
│ ├── ConfigLoader.ts # 通用 JSON 加载 + Zod 校验
|
||||
│ ├── tools_registry.json # R 工具注册表
|
||||
│ ├── decision_tables.json # 四维匹配规则
|
||||
│ └── flow_templates.json # 流程模板
|
||||
│ ├── flow_templates.json # 流程模板
|
||||
│ └── tool_param_constraints.json # Phase V-A:12 工具参数类型约束
|
||||
├── types/
|
||||
│ ├── query.types.ts # Q 层接口
|
||||
│ ├── reflection.types.ts # R 层接口
|
||||
@@ -324,7 +339,7 @@ npx tsx scripts/seed-ssa-phase4-prompts.ts # Phase IV: SSA_ANALYZE_PLAN
|
||||
|
||||
### 近期(优先级高)
|
||||
|
||||
1. **Phase V — 反思编排 + 高级特性(18h / 3 天)**
|
||||
1. **Phase V-B — 反思编排 + 高级特性(18h / 3 天)**
|
||||
- 错误分类器实现(可自愈 vs 不可自愈)
|
||||
- 自动反思(静默重试,MAX 2 次)+ 手动反思(用户驱动,feedback 意图)
|
||||
- write_report interpret 模式 + discuss 意图处理(深度解读已有结果)
|
||||
@@ -335,7 +350,7 @@ npx tsx scripts/seed-ssa-phase4-prompts.ts # Phase IV: SSA_ANALYZE_PLAN
|
||||
|
||||
3. **Phase VI(10h)** — 集成测试 + 可观测性(含 QPER 透明化)
|
||||
|
||||
**详细计划:** `04-开发计划/11-智能对话与工具体系开发计划.md`(v1.8,Phase I-IV 完成,含架构约束 C1-C8 + 全部团队审查落地记录)
|
||||
**详细计划:** `04-开发计划/11-智能对话与工具体系开发计划.md`(v1.8,Phase I-IV + Phase V-A 完成,含架构约束 C1-C8 + 全部团队审查落地记录)
|
||||
|
||||
---
|
||||
|
||||
@@ -380,7 +395,7 @@ npx tsx scripts/seed-ssa-phase4-prompts.ts # Phase IV: SSA_ANALYZE_PLAN
|
||||
|
||||
---
|
||||
|
||||
**文档版本:** v3.4
|
||||
**最后更新:** 2026-02-22
|
||||
**当前状态:** 🎉 QPER 主线闭环 + Phase I + Phase II + Phase III + Phase IV 已完成
|
||||
**下一步:** Phase V(反思编排 + 高级特性,18h/3 天)
|
||||
**文档版本:** v3.5
|
||||
**最后更新:** 2026-02-23
|
||||
**当前状态:** 🎉 QPER 主线闭环 + Phase I-IV + Phase V-A(变量可编辑化)已完成
|
||||
**下一步:** Phase V-B(反思编排 + 高级特性,18h/3 天)
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
# **架构与统计双重视角审查报告:分析方案变量可编辑化**
|
||||
|
||||
**审查对象:** plan\_editable\_variables\_27d3a9fd.plan.md
|
||||
|
||||
**审查时间:** 2026-02-23
|
||||
|
||||
**总体评级:** 🌟 **A 级 (方向极其正确,但存在隐性逻辑冲突需补强)**
|
||||
|
||||
**核心裁决:** 批准开发。但在前端变量过滤逻辑和后端校验机制上,必须引入“基于方法 Schema 的强约束”,否则极易导致下游 R 引擎大面积崩溃。
|
||||
|
||||
## **一、 视角一:资深统计学专家的评估**
|
||||
|
||||
**“不要给用户犯错的自由。医学统计的容错率是 0。”**
|
||||
|
||||
### **1\. 极度认可的改进**
|
||||
|
||||
* **尊重临床逻辑**:AI 经常会把“住院天数”和“年龄”搞混,或者漏掉医生特别关心的某个协变量。允许医生在执行前把遗漏的变量(Tag)加回来,这才是真正懂临床的工具。
|
||||
* **按类型分组展示**:下拉面板将连续变量和分类变量分开展示,极大地降低了医生寻找变量的认知负荷。
|
||||
|
||||
### **2\. 统计学视角的致命盲区 (The Statistical Blind Spot)**
|
||||
|
||||
正如您在提问中敏锐指出的,仅仅区分“分类 (Categorical)”和“连续 (Continuous)”是远远不够的。
|
||||
|
||||
* **二元 Logistic 回归陷阱**:该方法要求结局指标(Y)**必须且只能**是二分类变量(如:死/活,0/1)。如果用户的下拉列表里显示了所有“分类变量”(包含了 3 分类的“血型”),一旦用户手抖选了“血型”,后端的 R 代码执行时将 100% 报错崩溃。
|
||||
* **T 检验陷阱**:独立样本 T 检验的分组变量(X)**必须**是二分类变量。如果是 3 分类变量,必须用 ANOVA。
|
||||
* **生存分析陷阱**:它需要两个 Y(Time 是连续,Status 是二分类 0/1)。
|
||||
|
||||
### **🛠️ 统计专家的修正建议:引入“细粒度统计类型过滤”**
|
||||
|
||||
前端的下拉框候选项(Options),不能仅仅根据 type \=== 'categorical' 来过滤,必须**与当前 Step 绑定的统计方法强关联**。
|
||||
|
||||
* **建议实现**:前端在渲染下拉框时,必须读取该统计工具的 params\_schema(在 Phase III / IV 中已定义)。
|
||||
* **UI 约束逻辑**:
|
||||
* 如果当前是 ST\_LOGISTIC,结局变量的下拉框**只能**展示 DataProfile 中推断为 categorical\_2 (唯一值为2) 的变量。
|
||||
* 对于不符合当前统计方法要求的变量,在下拉框中将其 disabled (置灰),并 hover 提示:“该变量为多分类,二元逻辑回归仅支持二分类变量”。
|
||||
|
||||
## **二、 视角二:资深架构师的评估**
|
||||
|
||||
**“用户的每一次修改,都可能打破系统原本自洽的状态机。”**
|
||||
|
||||
### **1\. 架构师高度赞赏的决策 (Brilliant Architectural Choices)**
|
||||
|
||||
* **后端 PATCH API 的设计 (方案 A)**:
|
||||
这是非常正统的 RESTful 设计。用户修改参数后,先 PATCH /params 更新数据库实体,然后再触发 executeWorkflow。这保证了数据库中的 Plan 永远是 Single Source of Truth(单一事实来源),避免了前端传来“幽灵参数”导致历史记录无法复现的灾难。
|
||||
|
||||
### **2\. 工程视角的潜在风险 (Engineering Risks)**
|
||||
|
||||
#### **🚨 风险 1:UI 状态与执行动作的竞态条件 (Race Condition)**
|
||||
|
||||
* **场景**:用户在前端刚从下拉框里切换了变量,还没等组件把 state 同步完毕,或者还没等 PATCH 接口返回 200 OK,用户就光速点击了“开始执行分析”按钮。
|
||||
* **后果**:executeWorkflow 可能会拿着数据库里**旧的参数**去执行。
|
||||
* **修正建议**:
|
||||
“开始执行分析”按钮必须绑定一个复合动作(Promise Chaining):
|
||||
async function handleExecute() {
|
||||
setExecuting(true);
|
||||
// 1\. 必须先 await 等待 PATCH 成功
|
||||
if (hasUnsavedChanges) {
|
||||
await api.patchWorkflowParams(workflowId, modifiedSteps);
|
||||
}
|
||||
// 2\. 然后再触发执行
|
||||
startSseExecution(workflowId);
|
||||
}
|
||||
|
||||
#### **🚨 风险 2:级联失效与重新规划的边界 (Cascading Invalidation)**
|
||||
|
||||
* **场景**:AI 原本规划了 \[描述统计 \-\> T检验 \-\> Mann-Whitney\]。此时,用户在“描述统计”那一步,把 analyze\_vars 里的变量全删了,换成了一批全新的变量。
|
||||
* **架构思考**:此时,下游的 T检验 步骤的参数是否还有效?
|
||||
* **修正建议 (MVP 阶段的防御性降维)**:
|
||||
在当前计划中,请严格限制:**参数的可编辑性仅限于“同类替换”**。
|
||||
如果用户想要推翻整个研究假设(比如把 Y 变量从“血压”改成了“有效/无效”),系统不应该允许他们通过修改参数来完成,因为这会触发统计方法的变更(T检验 变 卡方)。
|
||||
* **前端提示**:在卡片顶部加一行提示:“如需更改核心分析目标(如改变数据类型),请在对话框告诉 AI 重新生成方案。”
|
||||
|
||||
#### **🚨 风险 3:Zod Schema 的后端防御 (Backend Defense)**
|
||||
|
||||
* **场景**:前端即便做了限制,但网络请求是可以被篡改的,或者存在前端 Bug 漏传了非法参数。
|
||||
* **修正建议**:
|
||||
新增的 PATCH /api/v1/ssa/workflow/:workflowId/params 接口,**绝对不能盲目接收数据**。它必须使用对应 R 工具的 Zod Schema 进行强校验。如果在 T 检验的 group\_var 里接收到了一个在 DataContext 中被标记为连续数值的变量,后端必须拦截并返回 400 Bad Request。
|
||||
|
||||
## **三、 终极结论与实施调整指南 (Actionable Summary)**
|
||||
|
||||
您的计划大体方向非常优秀,不仅提升了可用性,还大幅缓解了 AI 的幻觉焦虑。为了让它完美落地,请在您的开发计划中追加以下 **3 个微小但致命的补丁**:
|
||||
|
||||
1. **细化前端过滤条件 (UI Filter Patch)**:
|
||||
* 在 WorkflowTimeline.tsx 渲染下拉框时,利用 VariableDictionary 中更精细的属性(如 unique\_values\_count)来约束选项。
|
||||
* 例如:如果是分组变量下拉框,仅高亮展示 type \=== 'categorical' && levels \<= 5 的变量。
|
||||
2. **同步阻塞执行 (Sync Block Patch)**:
|
||||
* 确保“执行按钮”的 onClick 事件中,严格包含 await patchApi(),并在进行网络请求时将按钮置为 loading 状态,防止连点。
|
||||
3. **后端的参数防火墙 (Backend Firewall Patch)**:
|
||||
* 在开发 PATCH API 时,务必对传入的 params 进行统计学常识级别的 Zod 校验,防止将脏参数写入数据库,导致后续 R 引擎因 Fatal Error 宕机。
|
||||
|
||||
**批示:完全批准按照此计划及上述修正建议执行开发!这会让 SSA-Pro 的专业度再上一个大台阶。**
|
||||
@@ -0,0 +1,84 @@
|
||||
# **架构与统计双重视角审查报告:分析方案变量可编辑化** V2 修订版
|
||||
|
||||
**审查对象:** plan\_editable\_variables\_27d3a9fd.plan.md 及 团队 UX 修正反馈 **文档状态:** V2 修订版 (采纳“柔性拦截”方案) **审查时间:** 2026-02-23
|
||||
|
||||
**总体评级:** 🌟 **A+ 级 (方向极其正确,兼顾了学术严谨与用户掌控感)** **核心裁决:** 批准开发。团队提出的“软提示 \+ 强引导”完美解决了级联失效的体验问题,但必须配合后端的“强防火墙”才能安全落地。
|
||||
|
||||
## **一、 视角一:资深统计学专家的评估**
|
||||
|
||||
**“不要替医生做决定,但要给医生最专业的警告。”**
|
||||
|
||||
### **1\. 极度认可的改进**
|
||||
|
||||
* **尊重临床逻辑**:AI 经常会把“住院天数”和“年龄”搞混,或者漏掉医生特别关心的某个协变量。允许医生在执行前把遗漏的变量(Tag)加回来,这才是真正懂临床的工具。
|
||||
* **按类型分组展示**:下拉面板将连续变量和分类变量分开展示,极大地降低了医生寻找变量的认知负荷。
|
||||
|
||||
### **2\. 统计学视角的隐形陷阱 (The Statistical Blind Spot)**
|
||||
|
||||
仅仅区分“分类 (Categorical)”和“连续 (Continuous)”是远远不够的。不同的统计方法对变量有着极其严苛的专属要求:
|
||||
|
||||
* **二元 Logistic 回归陷阱**:该方法要求结局指标(Y)**必须且只能**是二分类变量(如:死/活,0/1)。如果用户选了 3 分类的“血型”,后端的 R 代码将无法计算。
|
||||
* **T 检验陷阱**:独立样本 T 检验的分组变量(X)**必须**是二分类变量。如果是 3 分类变量,必须用 ANOVA。
|
||||
|
||||
### **🛠️ 统计专家的修正建议:引入“细粒度统计类型过滤” (Soft Filtering)**
|
||||
|
||||
前端的下拉框候选项(Options),不能仅仅根据 type \=== 'categorical' 来过滤,必须**与当前 Step 绑定的统计方法建立映射提示**。
|
||||
|
||||
* **建议实现**:前端在渲染下拉框时,读取该统计工具的 params\_schema。对于不完全符合最佳统计条件的变量,**不要禁用 (Do not disable)**,但可以在该选项旁打上一个 ⚠️ 标记,提示其可能不适配当前方法。
|
||||
|
||||
## **二、 视角二:资深架构师的评估**
|
||||
|
||||
**“前端可以极致柔性,后端必须绝对刚性。”**
|
||||
|
||||
### **1\. 架构师高度赞赏的决策 (Brilliant Architectural Choices)**
|
||||
|
||||
* **后端 PATCH API 的设计 (方案 A)**:
|
||||
这是非常正统的 RESTful 设计。用户修改参数后,先 PATCH /params 更新数据库实体,然后再触发 executeWorkflow。这保证了数据库中的 Plan 永远是 Single Source of Truth(单一事实来源),避免了前端传来“幽灵参数”导致历史记录无法复现的灾难。
|
||||
|
||||
### **2\. 工程视角的潜在风险与柔性化解 (Engineering Risks & Solutions)**
|
||||
|
||||
#### **🚨 风险 1:UI 状态与执行动作的竞态条件 (Race Condition)**
|
||||
|
||||
* **场景**:用户在前端刚从下拉框里切换了变量,还没等组件把 state 同步完毕,或者还没等 PATCH 接口返回 200 OK,用户就光速点击了“开始执行分析”按钮。
|
||||
* **后果**:executeWorkflow 可能会拿着数据库里**旧的参数**去执行。
|
||||
* **修正建议**:
|
||||
“开始执行分析”按钮必须绑定一个复合动作(Promise Chaining):
|
||||
async function handleExecute() {
|
||||
setExecuting(true);
|
||||
// 1\. 必须先 await 等待 PATCH 成功
|
||||
if (hasUnsavedChanges) {
|
||||
await api.patchWorkflowParams(workflowId, modifiedSteps);
|
||||
}
|
||||
// 2\. 然后再触发执行
|
||||
startSseExecution(workflowId);
|
||||
}
|
||||
|
||||
#### **🚨 风险 2:级联失效与重新规划的边界 (Cascading Invalidation)**
|
||||
|
||||
* **场景**:AI 原本规划了 \[描述统计 \-\> T检验 \-\> Mann-Whitney\]。此时,用户在“描述统计”那一步,强行把结局变量从“连续数值”换成了“分类文本”。此时下游的 T检验 已经彻底失去了统计学意义。
|
||||
* **团队极佳的破局方案(柔性拦截与知情同意)**: 绝对**不采用**“硬限制”锁定下拉框(这会引发极大的用户反感)。采用团队设计的\*\*“软提示 \+ 重新规划引导”\*\*机制:
|
||||
1. **即时反馈**:当检测到用户的修改导致变量类型与当前统计方法(params\_schema)失配时,在 StepCard 顶部即时显示黄色警告条:*“⚠️ 当前变量类型已变更,可能导致当前统计方法失效。”*
|
||||
2. **视觉打标**:在该步骤的卡片右上角亮起一个红/黄警告图标。
|
||||
3. **阻断与授权弹窗 (Informed Consent)**:如果用户无视警告,强行点击【开始执行分析】,系统**拦截并弹窗**:*"检测到您修改的变量类型(如:分类变量)与当前统计方法(T检验)不匹配,强制执行可能导致报错或结论无效。建议您在对话框告诉 AI 重新生成方案。是否仍要强行执行?"* \[ 取消并重新对话 \] \[ 强行执行 \]
|
||||
* **架构师点评**:这种设计堪称完美。把控制权给用户,把免责声明做足。
|
||||
|
||||
#### **🚨 风险 3:Zod Schema 的后端防御底线 (Backend Defense) \- 生死线**
|
||||
|
||||
* **场景**:既然前端允许用户点击“强行执行”,那么非法的参数就一定会穿透到后端。
|
||||
* **架构底线**:
|
||||
新增的 PATCH /api/v1/ssa/workflow/:workflowId/params 接口,**绝对不能因为接收到了脏数据而导致 Node.js 崩溃**。 必须使用对应 R 工具的 Zod Schema 进行校验。如果接收到了离谱的参数(比如把一个字符串数组传给了要求 Boolean 的字段),后端必须捕获并转化为优雅的 400 Bad Request;如果参数类型合法但统计学不合法,放行给 R 引擎,由 R 引擎内部的 tryCatch 捕获并返回给前端清晰的 Error Log 即可。
|
||||
|
||||
## **三、 终极结论与实施调整指南 (Actionable Summary)**
|
||||
|
||||
团队对于 UX 交互边界的把握非常高级,"软提示+强引导"方案完美化解了系统的刻板印象。
|
||||
|
||||
为了让计划完美落地,请在开发中落实以下 3 个关键动作:
|
||||
|
||||
1. **前端交互柔性化 (UI Soft Filter)**:
|
||||
* 实现黄条警告和“强行执行确认弹窗”。这需要前端在渲染时,将当前选择的变量类型与工具的 params\_schema 需求类型进行实时比对计算。
|
||||
2. **同步阻塞执行 (Sync Block Patch)**:
|
||||
* 确保“执行按钮”的 onClick 事件中,严格包含 await patchApi(),并在进行网络请求时将按钮置为 loading 状态。
|
||||
3. **后端的参数防火墙 (Backend Firewall Patch)**:
|
||||
* 为 PATCH API 建立坚固的 Zod 校验,确保前端传来的强行覆盖数据,最多只会导致 R 引擎的“业务计算报错”,而绝对不会导致 Node.js 服务的“系统级崩溃”。
|
||||
|
||||
**批示:完全批准按照此修订计划执行开发!在赋予用户自由的同时守住后端的安全底线,这套交互将成为医疗 SaaS 的标杆。**
|
||||
Reference in New Issue
Block a user