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:
@@ -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
@@ -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/<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) |
|
||||
| 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) |
|
||||
|
||||
@@ -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)
|
||||
@@ -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**;不删证据 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
+29
-7
@@ -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,7 +3054,9 @@ export default function App() {
|
||||
projectId: projId,
|
||||
clientTurnId,
|
||||
userMessage: text,
|
||||
docId: canonicalDocIdForTask(
|
||||
docId:
|
||||
resolvedTask.docId ||
|
||||
canonicalDocIdForTask(
|
||||
projId,
|
||||
taskKey,
|
||||
deliverables,
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user