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 <cursoragent@cursor.com>
This commit is contained in:
weichen
2026-09-13 15:43:42 +00:00
co-authored by Cursor
parent 86598e7d0e
commit 6f0057c014
11 changed files with 407 additions and 22 deletions
@@ -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 重跑;本轮不操作容器
+5 -3
View File
@@ -1,11 +1,11 @@
# 当前焦点 # 当前焦点
**更新**: 2026-09-13 **更新**: 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 补录) 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 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 | | 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 | 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-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/<slug>` 或 `fix/<slug>`,**禁止** `dev` / `test` / `main`。同一 `mutex` 同时仅一个 `in_progress`。 > 新任务开始前登记。`branch` 必须为 `feature/<slug>` 或 `fix/<slug>`,**禁止** `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) | | 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) | | 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-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) |
@@ -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)
+1
View File
@@ -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-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-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-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**;不删证据 |
--- ---
+1 -1
View File
@@ -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` | | 工作流治理 | 三正式工作流 + 阶段门禁 | 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` | | 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` | | 文档治理 | 法定文档生命周期 + 租约 | 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` | | 前端工作台 | 三栏 + 块文档 + 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` | | 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` | | 三环境运维 | dev/test/main + 共享 KB | done | [PROJECT.md](../../PROJECT.md) §4;四环境 healthy | — | `infra` |
+1
View File
@@ -61,6 +61,7 @@ func (s *Service) ResolveAndValidateDocID(ctx context.Context, req TaskRunReques
if req.ProjectID > 0 && doc.ProjectID != req.ProjectID { if req.ProjectID > 0 && doc.ProjectID != req.ProjectID {
return "", fmt.Errorf("doc_id %s does not belong to project %d", docID, 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 { 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) return "", fmt.Errorf("doc_id %s task_key mismatch: expected %s got %s", docID, req.TaskKey, doc.TaskKey)
} }
+36
View File
@@ -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) { func TestResolveAndValidateDocID_ExistingDocAccepted(t *testing.T) {
svc := MustNewService(t, &config.Config{DataDir: t.TempDir()}) svc := MustNewService(t, &config.Config{DataDir: t.TempDir()})
ctx := context.Background() ctx := context.Background()
+34 -12
View File
@@ -2817,9 +2817,20 @@ export default function App() {
if (target.taskRequest) { if (target.taskRequest) {
if (action === 'retry') { if (action === 'retry') {
const userText = target.taskRequest.userMessage || ''; 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 const retryTask = reResolved
? { ...target.taskRequest, taskKey: reResolved.taskKey } ? {
...target.taskRequest,
taskKey: reResolved.taskKey,
docId: reResolved.docId || target.taskRequest.docId,
}
: target.taskRequest; : target.taskRequest;
runTaskAttempt(retryTask, messageId, 'run', 0); runTaskAttempt(retryTask, messageId, 'run', 0);
} else if (action === 'continue') { } else if (action === 'continue') {
@@ -2987,7 +2998,14 @@ export default function App() {
(text.includes('通过') || text.includes('同意')) && (text.includes('通过') || text.includes('同意')) &&
((docAST.lifecycleState === 'reviewing' && currentDocRole === 'reviewer') || ((docAST.lifecycleState === 'reviewing' && currentDocRole === 'reviewer') ||
(docAST.lifecycleState === 'approving' && currentDocRole === 'approver')); (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; const isMockShortcut = isGenDocShortcut || isRejectShortcut || isModifyShortcut || isReviewPassShortcut;
// P2:模板命中的长任务走真实后端 task stream(等待态 + PhaseEventStrip live + 分型恢复) // P2:模板命中的长任务走真实后端 task stream(等待态 + PhaseEventStrip live + 分型恢复)
@@ -3002,9 +3020,11 @@ export default function App() {
return { accepted: false }; return { accepted: false };
} }
const taskKey = resolvedTask.taskKey; const taskKey = resolvedTask.taskKey;
const matchedDeliverable = deliverables.find( const matchedDeliverable = deliverables.find((d) => {
(d) => d.projectId === projId && d.taskKey === taskKey, if (d.projectId !== projId) return false;
); if (resolvedTask.docId) return d.docId === resolvedTask.docId;
return d.taskKey === taskKey;
});
const activeLease = matchedDeliverable?.editLease; const activeLease = matchedDeliverable?.editLease;
const leaseIsActive = const leaseIsActive =
!!activeLease?.holderType && !!activeLease?.holderType &&
@@ -3034,12 +3054,14 @@ export default function App() {
projectId: projId, projectId: projId,
clientTurnId, clientTurnId,
userMessage: text, userMessage: text,
docId: canonicalDocIdForTask( docId:
projId, resolvedTask.docId ||
taskKey, canonicalDocIdForTask(
deliverables, projId,
matchedDeliverable?.docId, taskKey,
), deliverables,
matchedDeliverable?.docId,
),
}; };
const placeholder: ChatMessage = { const placeholder: ChatMessage = {
id: asstId, id: asstId,
+16
View File
@@ -75,6 +75,22 @@ test('buildTaskRequest: canonicalizes legacy doc-p doc_id', () => {
assert.equal(req.doc_id, 'doc_proj56_research'); 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', () => { test('buildResumeRequest: forwards add_steps when provided', () => {
const req = buildResumeRequest( const req = buildResumeRequest(
{ taskKey: 'intake', projectName: 'P', customerName: 'C', deviceType: 'D' }, { taskKey: 'intake', projectName: 'P', customerName: 'C', deviceType: 'D' },
+107
View File
@@ -1,8 +1,13 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import { import {
consistentTaskKeyForControlledDoc,
hasDocumentWriteIntent,
isServerRegisteredControlledDoc,
matchTemplateDeliverable, matchTemplateDeliverable,
refersToCurrentControlledDocument,
resolveRequirementsTaskKey, resolveRequirementsTaskKey,
resolveTaskKeyFromMessage,
resolveTaskTemplateForMessage, resolveTaskTemplateForMessage,
} from './templateMatch'; } from './templateMatch';
import { DeliverableItem } from '../types/workbench'; import { DeliverableItem } from '../types/workbench';
@@ -47,6 +52,10 @@ test('resolveRequirementsTaskKey selects user_requirements for explicit URS', ()
const UI02_FIRST_ROUND_PROMPT = 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', () => { test('UI-02 first-round prompt with 首轮+用户需求+摸底 must be intake not user_requirements', () => {
assert.equal(resolveRequirementsTaskKey(UI02_FIRST_ROUND_PROMPT), 'intake'); assert.equal(resolveRequirementsTaskKey(UI02_FIRST_ROUND_PROMPT), 'intake');
assert.equal(resolveTaskTemplateForMessage(UI02_FIRST_ROUND_PROMPT, bothPool)?.taskKey, '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', () => { test('explicit update action still starts URS', () => {
assert.equal(resolveTaskTemplateForMessage('请更新用户需求表 URS', bothPool)?.taskKey, 'user_requirements'); 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);
});
+111 -6
View File
@@ -1,9 +1,78 @@
import { DeliverableItem } from '../types/workbench'; 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[] { function templatePool(deliverables: DeliverableItem[]): DeliverableItem[] {
return deliverables.filter((d) => d.isTemplateOnly); 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),与交付物池无关。 * 从文案判定 intake(V0.1)还是 user_requirements(V0.5),与交付物池无关。
* 含「首轮 / 初始 / 需求摸底 / 摸底」的明确 V0.1 文案必须 intake,即使同时含「用户需求」。 * 含「首轮 / 初始 / 需求摸底 / 摸底」的明确 V0.1 文案必须 intake,即使同时含「用户需求」。
@@ -52,11 +121,8 @@ function pickRequirementsTask(text: string, pool: DeliverableItem[]): Deliverabl
/** 根据用户输入解析应触发的文档任务键(不依赖交付物池顺序)。 */ /** 根据用户输入解析应触发的文档任务键(不依赖交付物池顺序)。 */
export function resolveTaskKeyFromMessage(text: string): string | undefined { export function resolveTaskKeyFromMessage(text: string): string | undefined {
const normalized = text.trim(); const normalized = text.trim();
const readOnlyIntent = /(?:总结|概括|查看|看看|展示|读取|介绍|解释|分析|评价|检查|查询|当前状态|进度|谁在编辑|是否在编辑)/;
const writeIntent = /(?:开始|编写|编制|生成|起草|创建|撰写|重写|更新|修改|完善|补充|刷新|重做)/;
// 模板名只是操作对象,不代表用户授权写入。只读动词优先;只有明确写动作才走任务 API。 // 模板名只是操作对象,不代表用户授权写入。只读动词优先;只有明确写动作才走任务 API。
if (readOnlyIntent.test(normalized) || !writeIntent.test(normalized)) return undefined; if (!hasDocumentWriteIntent(normalized)) return undefined;
if (normalized.includes('需求') || normalized.includes('摸底') || normalized.includes('用户') || normalized.includes('首轮')) { if (normalized.includes('需求') || normalized.includes('摸底') || normalized.includes('用户') || normalized.includes('首轮')) {
return resolveRequirementsTaskKey(normalized); return resolveRequirementsTaskKey(normalized);
@@ -89,13 +155,52 @@ export function matchTemplateDeliverable(
return undefined; return undefined;
} }
/** 合并文案解析与模板匹配,保证 API 使用正确 task_key。 */ /**
* 合并文案解析与模板匹配。
* 指示「当前受控文档」且 active 为已注册 doc_proj 时,钉住该文档 taskKey/docId,
* 不被正文领域关键词改路由;显式点名另一交付物仍可切换。
*/
export function resolveTaskTemplateForMessage( export function resolveTaskTemplateForMessage(
text: string, text: string,
deliverables: DeliverableItem[], 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); const taskKey = resolveTaskKeyFromMessage(text);
if (!taskKey) return undefined; if (!taskKey) return undefined;
const template = matchTemplateDeliverable(text, deliverables); const template = matchTemplateDeliverable(text, deliverables);
return { taskKey, template }; 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;
}