From 6f0057c014a04decb09476c04c9f29543bb4bdb1 Mon Sep 17 00:00:00 2001 From: weichen Date: Sun, 13 Sep 2026 15:43:42 +0000 Subject: [PATCH] fix(docgov): pin task routing to active controlled doc on current-doc deixis When the user refers to the current document, keep task_key/doc_id on the selected registered doc_proj instead of rerouting by domain keywords. Co-authored-by: Cursor --- ...13-document-revision-active-doc-routing.md | 48 +++++++ .doc/ops/CURRENT.md | 8 +- ...13-document-revision-active-doc-routing.md | 47 +++++++ .doc/remediation/gap-matrix.md | 1 + .doc/status/FEATURE-MATRIX.md | 2 +- internal/bizdev/doc_id.go | 1 + internal/bizdev/doc_id_test.go | 36 ++++++ web/src/App.tsx | 46 +++++-- web/src/api/taskStream.test.ts | 16 +++ web/src/engine/templateMatch.test.ts | 107 ++++++++++++++++ web/src/engine/templateMatch.ts | 117 +++++++++++++++++- 11 files changed, 407 insertions(+), 22 deletions(-) create mode 100644 .doc/audit/2026-09-13-document-revision-active-doc-routing.md create mode 100644 .doc/ops/short-term/2026-09-13-document-revision-active-doc-routing.md diff --git a/.doc/audit/2026-09-13-document-revision-active-doc-routing.md b/.doc/audit/2026-09-13-document-revision-active-doc-routing.md new file mode 100644 index 00000000..d58d7ad5 --- /dev/null +++ b/.doc/audit/2026-09-13-document-revision-active-doc-routing.md @@ -0,0 +1,48 @@ +--- +title: "当前受控文档改写被领域关键词改路由到另一交付物" +status: in_progress +created: 2026-09-13 +updated: 2026-09-13 +severity: high +--- + +# 当前受控文档改写被领域关键词改路由到另一交付物 + +## 现象 + +第二轮真实 UI(schema 30)**UI-05 首个失败**: + +- 页面打开 `doc_proj6109_intake`(用户初始需求收集表,V2/r0) +- 用户输入含「当前受控文档」的高风险改写提示(YY/T、GB 9706.1、NMPA 注册法规) +- 前端按法规关键词路由为 `feasibility_reg` +- `POST` 启动 `run_1789313290400899325`,生成 `doc_proj6109_feasibility_reg` 并 `completed` +- 当前 intake **无 pending badge**;E2E 等待 60s 失败 + +## 根因分析 + +| 层 | 问题 | +|---|---| +| `resolveTaskKeyFromMessage` | 写动词「改写」通过后,正文「注册」命中 `feasibility_reg`,无视当前打开文档 | +| `handleSendMessage` | 只把文案+模板池交给 `resolveTaskTemplateForMessage`,未传入 active `doc_proj` | +| 产品 | 「当前受控文档/本文件/这份文档」是指示语,领域词是改写内容,不是切换交付物 | + +服务端 `ResolveAndValidateDocID` 已校验 project + task_key 与已注册文档一致,**不得**用客户端 `doc_id` 覆盖 `task_key`。本轮补了 `feasibility_reg` + `doc_proj6109_intake` mismatch 拒绝用例:攻击或误路由不得借 intake 文档改写另一任务键。 + +## 严重度 + +`high` + +## 关联整改 + +- Short-term: [../ops/short-term/2026-09-13-document-revision-active-doc-routing.md](../ops/short-term/2026-09-13-document-revision-active-doc-routing.md) +- 分支: `fix/document-revision-active-doc-routing` + +## 证据 + +- Evidence root(勿删、勿提交本轮):`.doc/test-reports/assets/2026-09-13-document-revision-runtime-ui/schema30-ui05-active-doc-routing-failure` +- E2E:`web/e2e/document-revision-runtime.spec.ts` UI-05(断言不改弱) +- 审查约束:E2E/用例提示词只留在 test 文件,生产 `templateMatch.ts` 不导出用例编号常量 + +## 开放问题 + +- 占槽后真实 UI-05 重跑;本轮不操作容器 diff --git a/.doc/ops/CURRENT.md b/.doc/ops/CURRENT.md index f1ae21ee..c04e83c5 100644 --- a/.doc/ops/CURRENT.md +++ b/.doc/ops/CURRENT.md @@ -1,11 +1,11 @@ # 当前焦点 **更新**: 2026-09-13 -**production_ready**: no(DOCREV:schema **30**;SSE 幂等 + 质量门禁自污染/auto_repair 整改在 `fix/document-revision-sse-idempotency`) +**production_ready**: no(DOCREV:schema **30**;当前受控文档指示语路由在 `fix/document-revision-active-doc-routing`;不操作容器) ## 焦点 -1. **DOCREV-RT**: [SSE 幂等 + 质量门禁](short-term/2026-09-13-document-revision-sse-idempotency.md)(`fix/document-revision-sse-idempotency`:幂等/SSE + A–D 质量契约;不 push) +1. **DOCREV-RT**: [当前受控文档指示语路由](short-term/2026-09-13-document-revision-active-doc-routing.md)(`fix/document-revision-active-doc-routing`:UI-05 不得被法规关键词改路由;不 push / 不操作容器) 2. **CHAT**: [刷新恢复整改已实施](short-term/2026-09-13-chat-history-refresh-remediation.md)(待合 dev + PI E2E 补录) 3. **CI**: `TEST_DATABASE_URL` + 真实 PI E2E 实录;INTENT 发布 blocker 见 conversation-intent short-term @@ -17,7 +17,8 @@ | Codex | conversation-intent-runtime-state | fix/conversation-intent-state | .worktrees/fix-conversation-intent-state | intent | in_progress | | Cursor | document-revision-wave1-6-implementation | feature/document-revision-management | - | docgov | done | | Cursor-Auto | document-revision-runtime-ui03 | fix/document-revision-runtime-ui03 | - | docgov | done | -| Cursor-Auto | document-revision-sse-idempotency | fix/document-revision-sse-idempotency | - | docgov | in_progress | +| Cursor-Auto | document-revision-sse-idempotency | fix/document-revision-sse-idempotency | - | docgov | done | +| Cursor-Auto | document-revision-active-doc-routing | fix/document-revision-active-doc-routing | - | docgov | in_progress | > 新任务开始前登记。`branch` 必须为 `feature/` 或 `fix/`,**禁止** `dev` / `test` / `main`。同一 `mutex` 同时仅一个 `in_progress`。 @@ -52,3 +53,4 @@ | DOCREV 测试 | [../test-reports/2026-09-13-document-revision-management-test-report.md](../test-reports/2026-09-13-document-revision-management-test-report.md) | | SSE/幂等审计 | [../audit/2026-09-13-document-revision-sse-idempotency.md](../audit/2026-09-13-document-revision-sse-idempotency.md) | | 质量门禁自污染审计 | [../audit/2026-09-13-document-revision-quality-gate-self-pollution.md](../audit/2026-09-13-document-revision-quality-gate-self-pollution.md) | +| 当前文档路由审计 | [../audit/2026-09-13-document-revision-active-doc-routing.md](../audit/2026-09-13-document-revision-active-doc-routing.md) | diff --git a/.doc/ops/short-term/2026-09-13-document-revision-active-doc-routing.md b/.doc/ops/short-term/2026-09-13-document-revision-active-doc-routing.md new file mode 100644 index 00000000..7ab747b6 --- /dev/null +++ b/.doc/ops/short-term/2026-09-13-document-revision-active-doc-routing.md @@ -0,0 +1,47 @@ +--- +title: "当前受控文档指示语优先于领域关键词路由" +status: in_progress +created: 2026-09-13 +updated: 2026-09-13 +owner: Cursor-Auto +branch: fix/document-revision-active-doc-routing +worktree: "-" +mutex: docgov +--- + +# 当前受控文档指示语优先于领域关键词路由 + +## 目标 + +用户说「当前受控文档/当前文档/本文件/这份文档」且已选中服务端注册的 `doc_proj*` 时,任务 payload 为该文档 `taskKey`+`docId`;UI-05 提示须为 `intake` + `doc_proj6109_intake`。 + +## 范围 + +- `web/src/engine/templateMatch.ts` + App 发送/重试链 +- 单测与边界;服务端 mismatch 复核(不信任客户端 doc_id 改 task) +- 文档:audit / gap-matrix / CURRENT + +## 非目标 + +- 不改弱 E2E UI-05 断言;不操作容器;不触碰/提交 test-report assets +- 生产 `templateMatch.ts` 不导出用例编号/E2E 专属提示词;测试提示词留在 test 文件 + +## 步骤 + +1. [x] 认领 CURRENT;写 audit/short-term +2. [x] 指示语钉住 active 受控文档;显式点名可切换;只读不启动;模板槽不钉 +3. [x] App 传入 activeDoc;payload 用一致 taskKey/docId +4. [x] 单测 + 门禁 + 原子提交 + +## 验收标准 + +- [x] UI-05 原文 + active intake → `intake` / `doc_proj6109_intake` +- [x] 只读「查看当前受控文档」不启动任务 +- [x] 显式点名另一交付物可切换;模板-only 不钉住 +- [x] 服务端 task_key 与已注册 doc 不一致仍拒绝 +- [x] `go vet` / `go test -short` / web 242+27 / build / `git diff --check` + +## 相关链接 + +- Audit: [../../audit/2026-09-13-document-revision-active-doc-routing.md](../../audit/2026-09-13-document-revision-active-doc-routing.md) +- Evidence: [../../test-reports/assets/2026-09-13-document-revision-runtime-ui/schema30-ui05-active-doc-routing-failure](../../test-reports/assets/2026-09-13-document-revision-runtime-ui/schema30-ui05-active-doc-routing-failure) diff --git a/.doc/remediation/gap-matrix.md b/.doc/remediation/gap-matrix.md index fb6c47bc..87c87636 100644 --- a/.doc/remediation/gap-matrix.md +++ b/.doc/remediation/gap-matrix.md @@ -64,6 +64,7 @@ | DOCREV-UI03-CLIENT-SYNC | 小队完成后右栏仍模板;UI-03 选择器误命中 Banner;`pending_author_confirmation` 撞 chk;重试重复建 run | **FIX-LANDED**(工程) | [audit](../audit/2026-09-13-controlled-doc-client-sync-ui03.md);分支 `fix/document-revision-runtime-ui03`:post-task sync、templateMatch intake 优先、goose 00030、Claim+idempotency | 合入前:schema30 **真实现卷**迁移独立验证;占槽真实 UI-03 重跑;勿把 stub 当验收 | | DOCREV-SSE-IDEM-BUSY | SSE WriteTimeout 断线后同 turn 再 POST 误 409 DOC_BUSY + 重复聊天 | **FIX-LANDED**(工程) | [audit](../audit/2026-09-13-document-revision-sse-idempotency.md);证据 [assets/2026-09-13-document-revision-runtime-ui](../test-reports/assets/2026-09-13-document-revision-runtime-ui/);`fix/document-revision-sse-idempotency` | 占槽真实 UI-03 重跑;不删证据 | | DOCREV-QUALITY-SELF-POLLUTE | 自检表引用禁词致 score=40 quality_failed;auto_repair 未启用;UI 映射 completed | **FIX-LANDED**(工程) | [audit](../audit/2026-09-13-document-revision-quality-gate-self-pollution.md);workspace `intake_1789308825666278807` output.md:204;同分支 | 占槽真实重跑;不删证据 | +| DOCREV-ACTIVE-DOC-ROUTING | UI-05「当前受控文档」被法规关键词改路由到 `feasibility_reg`,intake 无 pending | **FIX-LANDED**(工程) | [audit](../audit/2026-09-13-document-revision-active-doc-routing.md);证据 `schema30-ui05-active-doc-routing-failure`;`fix/document-revision-active-doc-routing` | 占槽真实 UI-05 重跑;**不改弱 E2E**;不删证据 | --- diff --git a/.doc/status/FEATURE-MATRIX.md b/.doc/status/FEATURE-MATRIX.md index 48e6b550..b63c149a 100644 --- a/.doc/status/FEATURE-MATRIX.md +++ b/.doc/status/FEATURE-MATRIX.md @@ -21,7 +21,7 @@ | 工作流治理 | 三正式工作流 + 阶段门禁 | done | [remediation/three-workflows](../remediation/2026-09-12-three-workflows-strict-governance-remediation.md);[test 2026-09-12](../test-reports/2026-09-12-three-workflows-governance-remediation-test-report.md) | Phase2 设计器 partial | `workflow` | | IAM / 鉴权 | 项目级 API 鉴权 + RBAC | partial | [audit/authorization](../audit/authorization-matrix-v2.md);[刷新恢复专项审计](../audit/2026-09-13-chat-history-refresh-disappears.md) | 历史读取端点补统一会话 owner + 阶段授权;旧 API 410 标注(BUG-11 partial) | `authz` | | 文档治理 | 法定文档生命周期 + 租约 | done | [test docgov](../test-reports/2026-09-08-docgov-lifecycle-and-claiming-framework-test-report.md) | — | `docgov` | -| 文档版本与修改认知 | 智能体版本边界、人工修订标记、任意版本比较、变更报告与负责人通知 | partial | [test 2026-09-13](../test-reports/2026-09-13-document-revision-management-test-report.md);[运行时审计](../audit/2026-09-13-document-revision-runtime-404.md);[ADR](../adr/2026-09-13-document-version-boundary.md) | **运行时 blocker**:PG 未接线→404/重启丢失;overlay/freeform 保存缺口;PI 叙事报告待集成;见 [runtime remediation](../remediation/2026-09-13-document-revision-runtime-remediation.md) | `docgov` | +| 文档版本与修改认知 | 智能体版本边界、人工修订标记、任意版本比较、变更报告与负责人通知 | partial | [test 2026-09-13](../test-reports/2026-09-13-document-revision-management-test-report.md);[运行时审计](../audit/2026-09-13-document-revision-runtime-404.md);[当前文档路由](../audit/2026-09-13-document-revision-active-doc-routing.md);[ADR](../adr/2026-09-13-document-version-boundary.md) | **运行时 blocker**:PG 未接线→404/重启丢失;overlay/freeform 保存缺口;PI 叙事报告待集成;见 [runtime remediation](../remediation/2026-09-13-document-revision-runtime-remediation.md)。指示语钉住 active `doc_proj` 工程已落地,待占槽重跑 UI-05 | `docgov` | | 前端工作台 | 三栏 + 块文档 + SSE | partial | [test workbench](../test-reports/2026-09-08-three-column-workbench-and-block-document-test-report.md);[现场专项审计](../audit/2026-09-13-conversation-intent-runtime-state.md) | 错误卡片/任务进度/文档 busy 状态刷新恢复与人工抽检待完成 | `web` | | PI Runner / 配置链 | pi-agent + MiniMax 真实链路 | done | [test piagent](../test-reports/2026-09-13-codex-audit-p0-remediation-test-report.md);[container-env](../test-reports/2026-09-13-container-env-configuration-test-report.md) | — | `pi-agent` | | 三环境运维 | dev/test/main + 共享 KB | done | [PROJECT.md](../../PROJECT.md) §4;四环境 healthy | — | `infra` | diff --git a/internal/bizdev/doc_id.go b/internal/bizdev/doc_id.go index 3143cd34..64f10b32 100644 --- a/internal/bizdev/doc_id.go +++ b/internal/bizdev/doc_id.go @@ -61,6 +61,7 @@ func (s *Service) ResolveAndValidateDocID(ctx context.Context, req TaskRunReques if req.ProjectID > 0 && doc.ProjectID != req.ProjectID { return "", fmt.Errorf("doc_id %s does not belong to project %d", docID, req.ProjectID) } + // 已注册文档的 task_key 以账本为准;客户端 doc_id 不得改写或覆盖请求 task_key。 if req.TaskKey != "" && doc.TaskKey != "" && doc.TaskKey != req.TaskKey { return "", fmt.Errorf("doc_id %s task_key mismatch: expected %s got %s", docID, req.TaskKey, doc.TaskKey) } diff --git a/internal/bizdev/doc_id_test.go b/internal/bizdev/doc_id_test.go index 5c30940e..68296628 100644 --- a/internal/bizdev/doc_id_test.go +++ b/internal/bizdev/doc_id_test.go @@ -79,6 +79,42 @@ func TestResolveAndValidateDocID_ExistingDocMismatchRejected(t *testing.T) { } } +// 客户端不得用 doc_id 改写 task_key:UI-05 误路由若仍带 intake 文档 + feasibility_reg,必须拒绝。 +func TestResolveAndValidateDocID_ClientDocIDCannotOverrideTaskKey(t *testing.T) { + svc := MustNewService(t, &config.Config{DataDir: t.TempDir()}) + ctx := context.Background() + + docID := "doc_proj6109_intake" + _, err := svc.DocGov().CreateOrGetDocument( + ctx, docID, 6109, "intake", "用户初始需求收集表", "V0.1", "", + 1, "张工", 2, "李主管", 3, "王总监", + ) + if err != nil { + t.Fatalf("CreateOrGetDocument: %v", err) + } + + _, err = svc.ResolveAndValidateDocID(ctx, TaskRunRequest{ + DocID: docID, + ProjectID: 6109, + TaskKey: "feasibility_reg", + }) + if err == nil || !strings.Contains(err.Error(), "task_key mismatch") { + t.Fatalf("expected task_key mismatch, got %v", err) + } + + got, err := svc.ResolveAndValidateDocID(ctx, TaskRunRequest{ + DocID: docID, + ProjectID: 6109, + TaskKey: "intake", + }) + if err != nil { + t.Fatalf("matching task_key must be accepted: %v", err) + } + if got != docID { + t.Fatalf("got %q, want %q", got, docID) + } +} + func TestResolveAndValidateDocID_ExistingDocAccepted(t *testing.T) { svc := MustNewService(t, &config.Config{DataDir: t.TempDir()}) ctx := context.Background() diff --git a/web/src/App.tsx b/web/src/App.tsx index ee4381b9..5c8e9de6 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -2817,9 +2817,20 @@ export default function App() { if (target.taskRequest) { if (action === 'retry') { const userText = target.taskRequest.userMessage || ''; - const reResolved = resolveTaskTemplateForMessage(userText, currentProjectDeliverables); + const retryActive = + deliverables.find((d) => d.docId === activeDocId) || + visibleDeliverables.find((d) => d.docId === activeDocId); + const reResolved = resolveTaskTemplateForMessage( + userText, + currentProjectDeliverables, + retryActive, + ); const retryTask = reResolved - ? { ...target.taskRequest, taskKey: reResolved.taskKey } + ? { + ...target.taskRequest, + taskKey: reResolved.taskKey, + docId: reResolved.docId || target.taskRequest.docId, + } : target.taskRequest; runTaskAttempt(retryTask, messageId, 'run', 0); } else if (action === 'continue') { @@ -2987,7 +2998,14 @@ export default function App() { (text.includes('通过') || text.includes('同意')) && ((docAST.lifecycleState === 'reviewing' && currentDocRole === 'reviewer') || (docAST.lifecycleState === 'approving' && currentDocRole === 'approver')); - const resolvedTask = resolveTaskTemplateForMessage(text, currentProjectDeliverables); + const sendActiveDoc = + deliverables.find((d) => d.docId === activeDocId) || + visibleDeliverables.find((d) => d.docId === activeDocId); + const resolvedTask = resolveTaskTemplateForMessage( + text, + currentProjectDeliverables, + sendActiveDoc, + ); const isMockShortcut = isGenDocShortcut || isRejectShortcut || isModifyShortcut || isReviewPassShortcut; // P2:模板命中的长任务走真实后端 task stream(等待态 + PhaseEventStrip live + 分型恢复) @@ -3002,9 +3020,11 @@ export default function App() { return { accepted: false }; } const taskKey = resolvedTask.taskKey; - const matchedDeliverable = deliverables.find( - (d) => d.projectId === projId && d.taskKey === taskKey, - ); + const matchedDeliverable = deliverables.find((d) => { + if (d.projectId !== projId) return false; + if (resolvedTask.docId) return d.docId === resolvedTask.docId; + return d.taskKey === taskKey; + }); const activeLease = matchedDeliverable?.editLease; const leaseIsActive = !!activeLease?.holderType && @@ -3034,12 +3054,14 @@ export default function App() { projectId: projId, clientTurnId, userMessage: text, - docId: canonicalDocIdForTask( - projId, - taskKey, - deliverables, - matchedDeliverable?.docId, - ), + docId: + resolvedTask.docId || + canonicalDocIdForTask( + projId, + taskKey, + deliverables, + matchedDeliverable?.docId, + ), }; const placeholder: ChatMessage = { id: asstId, diff --git a/web/src/api/taskStream.test.ts b/web/src/api/taskStream.test.ts index 158678f0..b4f0ea11 100644 --- a/web/src/api/taskStream.test.ts +++ b/web/src/api/taskStream.test.ts @@ -75,6 +75,22 @@ test('buildTaskRequest: canonicalizes legacy doc-p doc_id', () => { assert.equal(req.doc_id, 'doc_proj56_research'); }); +test('buildTaskRequest: UI-05 pinned intake keeps task_key + doc_proj6109_intake', () => { + const req = buildTaskRequest({ + taskKey: 'intake', + projectName: 'P', + customerName: 'C', + deviceType: 'D', + projectId: 6109, + docId: 'doc_proj6109_intake', + userMessage: + '请按 YY/T、GB 9706.1 与 NMPA 注册法规要求,大幅改写当前受控文档中的风险管理与电气安全章节,引用具体条款并重写适用性结论(二轮高风险候选-确认用例)。', + }); + assert.equal(req.task_key, 'intake'); + assert.equal(req.doc_id, 'doc_proj6109_intake'); + assert.equal(req.project_id, 6109); +}); + test('buildResumeRequest: forwards add_steps when provided', () => { const req = buildResumeRequest( { taskKey: 'intake', projectName: 'P', customerName: 'C', deviceType: 'D' }, diff --git a/web/src/engine/templateMatch.test.ts b/web/src/engine/templateMatch.test.ts index 221e343b..63166daa 100644 --- a/web/src/engine/templateMatch.test.ts +++ b/web/src/engine/templateMatch.test.ts @@ -1,8 +1,13 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { + consistentTaskKeyForControlledDoc, + hasDocumentWriteIntent, + isServerRegisteredControlledDoc, matchTemplateDeliverable, + refersToCurrentControlledDocument, resolveRequirementsTaskKey, + resolveTaskKeyFromMessage, resolveTaskTemplateForMessage, } from './templateMatch'; import { DeliverableItem } from '../types/workbench'; @@ -47,6 +52,10 @@ test('resolveRequirementsTaskKey selects user_requirements for explicit URS', () const UI02_FIRST_ROUND_PROMPT = '请基于本项目编制首轮《用户需求》摸底文档,覆盖预期用途、关键性能与适用法规框架,直接生成受控交付物。'; +// 与 E2E UI-05 原文对齐,仅供单测;不得放入生产模块。 +const CURRENT_DOC_HIGH_RISK_REWRITE_PROMPT = + '请按 YY/T、GB 9706.1 与 NMPA 注册法规要求,大幅改写当前受控文档中的风险管理与电气安全章节,引用具体条款并重写适用性结论(二轮高风险候选-确认用例)。'; + test('UI-02 first-round prompt with 首轮+用户需求+摸底 must be intake not user_requirements', () => { assert.equal(resolveRequirementsTaskKey(UI02_FIRST_ROUND_PROMPT), 'intake'); assert.equal(resolveTaskTemplateForMessage(UI02_FIRST_ROUND_PROMPT, bothPool)?.taskKey, 'intake'); @@ -96,3 +105,101 @@ test('explicit write action still starts intake', () => { test('explicit update action still starts URS', () => { assert.equal(resolveTaskTemplateForMessage('请更新用户需求表 URS', bothPool)?.taskKey, 'user_requirements'); }); + +const activeIntake: DeliverableItem = { + ...base, + docId: 'doc_proj6109_intake', + taskKey: 'intake', + title: '用户初始需求收集表', + isTemplateOnly: false, +}; + +const activeReg: DeliverableItem = { + ...base, + docId: 'doc_proj6109_feasibility_reg', + taskKey: 'feasibility_reg', + title: '注册路径可行性评估报告', + isTemplateOnly: false, +}; + +const templateIntakeSlot: DeliverableItem = { + ...base, + docId: 'doc-p6109-intake', + taskKey: 'intake', + title: '用户初始需求收集表', + isTemplateOnly: true, +}; + +test('UI-05 prompt without active doc still keyword-routes to feasibility_reg', () => { + assert.equal(resolveTaskKeyFromMessage(CURRENT_DOC_HIGH_RISK_REWRITE_PROMPT), 'feasibility_reg'); + assert.equal(resolveTaskTemplateForMessage(CURRENT_DOC_HIGH_RISK_REWRITE_PROMPT, bothPool)?.taskKey, 'feasibility_reg'); +}); + +test('UI-05 prompt + registered intake pins intake and doc_proj6109_intake', () => { + assert.equal(refersToCurrentControlledDocument(CURRENT_DOC_HIGH_RISK_REWRITE_PROMPT), true); + assert.equal(isServerRegisteredControlledDoc(activeIntake), true); + const resolved = resolveTaskTemplateForMessage(CURRENT_DOC_HIGH_RISK_REWRITE_PROMPT, bothPool, activeIntake); + assert.equal(resolved?.taskKey, 'intake'); + assert.equal(resolved?.docId, 'doc_proj6109_intake'); + assert.equal(resolved?.pinnedToActiveDoc, true); +}); + +test('read-only current-doc request does not start a task', () => { + assert.equal(hasDocumentWriteIntent('查看当前受控文档的风险管理章节'), false); + assert.equal( + resolveTaskTemplateForMessage('查看当前受控文档的风险管理章节', bothPool, activeIntake), + undefined, + ); +}); + +test('template-only active slot is not pinned; keywords still apply', () => { + assert.equal(isServerRegisteredControlledDoc(templateIntakeSlot), false); + const resolved = resolveTaskTemplateForMessage( + CURRENT_DOC_HIGH_RISK_REWRITE_PROMPT, + bothPool, + templateIntakeSlot, + ); + assert.equal(resolved?.taskKey, 'feasibility_reg'); + assert.equal(resolved?.pinnedToActiveDoc, undefined); +}); + +test('explicit named deliverable can switch away from active intake', () => { + const text = '请改写当前受控文档,同时编制注册路径可行性评估报告'; + const resolved = resolveTaskTemplateForMessage(text, [...bothPool, activeReg], activeIntake); + assert.equal(resolved?.taskKey, 'feasibility_reg'); + assert.notEqual(resolved?.docId, 'doc_proj6109_intake'); +}); + +test('domain 注册法规 without current-doc deixis still routes feasibility_reg', () => { + assert.equal( + resolveTaskTemplateForMessage('请编制注册路径可行性评估并引用 NMPA 法规', bothPool, activeIntake) + ?.taskKey, + 'feasibility_reg', + ); +}); + +test('mismatched title vs docId suffix prefers docId taskKey', () => { + const mismatch = { ...activeIntake, taskKey: 'feasibility_reg' }; + assert.equal(consistentTaskKeyForControlledDoc(mismatch), 'intake'); + const resolved = resolveTaskTemplateForMessage(CURRENT_DOC_HIGH_RISK_REWRITE_PROMPT, bothPool, mismatch); + assert.equal(resolved?.taskKey, 'intake'); + assert.equal(resolved?.docId, 'doc_proj6109_intake'); +}); + +for (const phrase of ['当前文档', '本文件', '这份文档', '本文档', '本受控文档'] as const) { + test(`deixis ${phrase} pins registered intake`, () => { + const text = `请改写${phrase}中的风险管理章节`; + assert.equal(refersToCurrentControlledDocument(text), true); + const resolved = resolveTaskTemplateForMessage(text, bothPool, activeIntake); + assert.equal(resolved?.taskKey, 'intake'); + assert.equal(resolved?.docId, 'doc_proj6109_intake'); + assert.equal(resolved?.pinnedToActiveDoc, true); + }); +} + +test('no active doc does not invent a pin', () => { + const resolved = resolveTaskTemplateForMessage(CURRENT_DOC_HIGH_RISK_REWRITE_PROMPT, bothPool, undefined); + assert.equal(resolved?.taskKey, 'feasibility_reg'); + assert.equal(resolved?.docId, undefined); + assert.equal(resolved?.pinnedToActiveDoc, undefined); +}); diff --git a/web/src/engine/templateMatch.ts b/web/src/engine/templateMatch.ts index 209b4857..3b21c60d 100644 --- a/web/src/engine/templateMatch.ts +++ b/web/src/engine/templateMatch.ts @@ -1,9 +1,78 @@ import { DeliverableItem } from '../types/workbench'; +const READ_ONLY_INTENT = + /(?:总结|概括|查看|看看|展示|读取|介绍|解释|分析|评价|检查|查询|当前状态|进度|谁在编辑|是否在编辑)/; +const WRITE_INTENT = + /(?:开始|编写|编制|生成|起草|创建|撰写|重写|更新|修改|完善|补充|刷新|重做|改写)/; +const CURRENT_DOC_DEIXIS = /当前受控文档|当前文档|本文件|这份文档|本文档|本受控文档/; + +const NAMED_DELIVERABLE_ALIASES: { taskKey: string; patterns: RegExp[] }[] = [ + { taskKey: 'intake', patterns: [/用户初始需求收集表/, /初始需求表/, /需求摸底表/] }, + { taskKey: 'user_requirements', patterns: [/用户需求表/, /\bURS\b/, /用户需求规格/] }, + { taskKey: 'research', patterns: [/调研报告/, /竞品调研/] }, + { taskKey: 'feasibility_reg', patterns: [/注册路径可行性/, /法规可行性评估/, /注册可行性评估/] }, + { taskKey: 'feasibility_dev', patterns: [/设计开发可行性/, /研发可行性评估/] }, + { taskKey: 'prelim_design', patterns: [/初步设计说明书/, /系统设计规格/] }, + { taskKey: 'cdo_quote', patterns: [/CDO报价/, /报价核算表/] }, +]; + +export type ResolvedDocumentTask = { + taskKey: string; + template?: DeliverableItem; + docId?: string; + pinnedToActiveDoc?: boolean; +}; + function templatePool(deliverables: DeliverableItem[]): DeliverableItem[] { return deliverables.filter((d) => d.isTemplateOnly); } +/** 只读动词优先;无明确写动作则不启动文档任务。 */ +export function hasDocumentWriteIntent(text: string): boolean { + const normalized = text.trim(); + if (READ_ONLY_INTENT.test(normalized)) return false; + return WRITE_INTENT.test(normalized); +} + +export function refersToCurrentControlledDocument(text: string): boolean { + return CURRENT_DOC_DEIXIS.test(text.trim()); +} + +export function taskKeyFromControlledDocId(docId: string): string | undefined { + const m = docId.trim().match(/^doc_proj\d+_(.+)$/); + return m?.[1] || undefined; +} + +/** 服务端已注册的受控文档:doc_proj* 且非模板槽。 */ +export function isServerRegisteredControlledDoc(doc?: DeliverableItem | null): boolean { + if (!doc || doc.isTemplateOnly) return false; + if (!/^doc_proj\d+_/.test(doc.docId)) return false; + return Boolean(doc.taskKey || taskKeyFromControlledDocId(doc.docId)); +} + +/** 路由用 taskKey:docId 后缀与声明不一致时以 ID 为准(与服务端权威 ID 对齐)。 */ +export function consistentTaskKeyForControlledDoc(doc: DeliverableItem): string | undefined { + return taskKeyFromControlledDocId(doc.docId) || doc.taskKey || undefined; +} + +function explicitNamedDeliverableTaskKey( + text: string, + deliverables: DeliverableItem[], + excludeTaskKey?: string, +): string | undefined { + const titled = deliverables + .filter((d) => d.taskKey && d.taskKey !== excludeTaskKey && d.title?.trim()) + .sort((a, b) => (b.title?.length || 0) - (a.title?.length || 0)); + for (const d of titled) { + if (text.includes(d.title)) return d.taskKey; + } + for (const alias of NAMED_DELIVERABLE_ALIASES) { + if (alias.taskKey === excludeTaskKey) continue; + if (alias.patterns.some((p) => p.test(text))) return alias.taskKey; + } + return undefined; +} + /** * 从文案判定 intake(V0.1)还是 user_requirements(V0.5),与交付物池无关。 * 含「首轮 / 初始 / 需求摸底 / 摸底」的明确 V0.1 文案必须 intake,即使同时含「用户需求」。 @@ -52,11 +121,8 @@ function pickRequirementsTask(text: string, pool: DeliverableItem[]): Deliverabl /** 根据用户输入解析应触发的文档任务键(不依赖交付物池顺序)。 */ export function resolveTaskKeyFromMessage(text: string): string | undefined { const normalized = text.trim(); - const readOnlyIntent = /(?:总结|概括|查看|看看|展示|读取|介绍|解释|分析|评价|检查|查询|当前状态|进度|谁在编辑|是否在编辑)/; - const writeIntent = /(?:开始|编写|编制|生成|起草|创建|撰写|重写|更新|修改|完善|补充|刷新|重做)/; - // 模板名只是操作对象,不代表用户授权写入。只读动词优先;只有明确写动作才走任务 API。 - if (readOnlyIntent.test(normalized) || !writeIntent.test(normalized)) return undefined; + if (!hasDocumentWriteIntent(normalized)) return undefined; if (normalized.includes('需求') || normalized.includes('摸底') || normalized.includes('用户') || normalized.includes('首轮')) { return resolveRequirementsTaskKey(normalized); @@ -89,13 +155,52 @@ export function matchTemplateDeliverable( return undefined; } -/** 合并文案解析与模板匹配,保证 API 使用正确 task_key。 */ +/** + * 合并文案解析与模板匹配。 + * 指示「当前受控文档」且 active 为已注册 doc_proj 时,钉住该文档 taskKey/docId, + * 不被正文领域关键词改路由;显式点名另一交付物仍可切换。 + */ export function resolveTaskTemplateForMessage( text: string, deliverables: DeliverableItem[], -): { taskKey: string; template?: DeliverableItem } | undefined { + activeDoc?: DeliverableItem | null, +): ResolvedDocumentTask | undefined { + if (!hasDocumentWriteIntent(text)) return undefined; + + if (refersToCurrentControlledDocument(text) && isServerRegisteredControlledDoc(activeDoc)) { + const pinnedKey = consistentTaskKeyForControlledDoc(activeDoc!); + const named = explicitNamedDeliverableTaskKey(text, deliverables, pinnedKey); + if (named) { + const template = matchTemplateDeliverableByKey(named, deliverables, text); + return { taskKey: named, template }; + } + if (pinnedKey) { + const template = matchTemplateDeliverableByKey(pinnedKey, deliverables, text); + return { + taskKey: pinnedKey, + template, + docId: activeDoc!.docId, + pinnedToActiveDoc: true, + }; + } + } + const taskKey = resolveTaskKeyFromMessage(text); if (!taskKey) return undefined; const template = matchTemplateDeliverable(text, deliverables); return { taskKey, template }; } + +function matchTemplateDeliverableByKey( + taskKey: string, + deliverables: DeliverableItem[], + text: string, +): DeliverableItem | undefined { + const pool = templatePool(deliverables); + const hit = findTemplateByTaskKey(pool, taskKey); + if (hit) return hit; + if (taskKey === 'intake' || taskKey === 'user_requirements') { + return pickRequirementsTask(text, pool); + } + return undefined; +}