diff --git a/.doc/audit/2026-09-13-document-revision-second-doc-routing-lease.md b/.doc/audit/2026-09-13-document-revision-second-doc-routing-lease.md new file mode 100644 index 00000000..7a6aad01 --- /dev/null +++ b/.doc/audit/2026-09-13-document-revision-second-doc-routing-lease.md @@ -0,0 +1,46 @@ +--- +title: "UI-11 第二份文档路由到 intake 且 human lease 残留" +status: in_progress +created: 2026-09-13 +updated: 2026-09-13 +severity: high +--- + +# UI-11 第二份文档路由到 intake 且 human lease 残留 + +## 现象 + +第十轮真实 UI(schema 30): + +- UI-00~UI-10 **PASS** +- UI-11 页面输入:请另行生成一份《系统设计输入》受控文档(第二份),不要覆盖上一份需求文档,并给出可打开的交付物。 +- 页面回复《用户初始需求收集表》正由其他用户编辑/生成中,**未启动任务** + +## 根因分析 + +| 层 | 问题 | +|---|---| +| 路由 | `resolveTaskKeyFromMessage` 写意图后先扫「需求\|摸底\|用户\|首轮」,否定句「不要覆盖上一份**需求**文档」压过正向点名「系统设计输入」 | +| 别名 | `NAMED_DELIVERABLE_ALIASES.prelim_design` 只有「初步设计说明书 / 系统设计规格」,缺「系统设计输入」(模板正式标题是「系统初步设计方案」) | +| 租约 | PATCH 在 `AssertHumanCanEdit` 后持 15min human lease;本 UI 无 heartbeat/token。成功 / 409 / 投影失败都不释放,intake 被锁,小队再跑 intake 得 DOC_BUSY | +| E2E | UI-11 `sendChat` 后直接 `waitForSquadCompletion`,没有 `tasks/runs` 202 门闩;同步 busy completed 可能被当成成功 | + +## 严重度 + +`high` + +## 关联整改 + +- Remediation: [../remediation/2026-09-13-document-revision-second-doc-routing-lease.md](../remediation/2026-09-13-document-revision-second-doc-routing-lease.md) +- Short-term: [../ops/short-term/2026-09-13-document-revision-second-doc-routing-lease.md](../ops/short-term/2026-09-13-document-revision-second-doc-routing-lease.md) +- 分支: `fix/document-revision-second-doc-routing-lease` + +## 证据 + +- Evidence root(已归档、本轮勿提交):`.doc/test-reports/assets/2026-09-13-document-revision-runtime-ui/schema30-ui11-second-doc-routing-human-lease-failure/` + +## 开放问题 + +- 占槽后真实 UI-11 重跑;本任务保持 `in_progress`,不得标 `done` +- 请求级租约:同用户双窗 ETag 冲突靠 If-Match,不靠租约互斥(同 `holderID` 会续租) +- 卡片展示标题是「系统初步设计方案」(非 prompt 别名「系统设计输入」);E2E 必须按 `doc_proj{n}_prelim_design` 打开 diff --git a/.doc/ops/CURRENT.md b/.doc/ops/CURRENT.md index 7bf9391b..7beda461 100644 --- a/.doc/ops/CURRENT.md +++ b/.doc/ops/CURRENT.md @@ -1,11 +1,11 @@ # 当前焦点 **更新**: 2026-09-13 -**production_ready**: no(DOCREV:schema **30**;第九轮 UI-07 transition 中间帧取样;不操作容器) +**production_ready**: no(DOCREV:schema **30**;第十轮 UI-11 路由+lease;不操作容器) ## 焦点 -1. **DOCREV-RT**: [E2E 等 overlay CSS transition 结束后再取样](short-term/2026-09-13-document-revision-e2e-overlay-transition.md)(`fix/document-revision-e2e-overlay-transition`:禁止短 sleep / 禁 transition;不重跑 E2E / 不操作容器;仍 `in_progress`) +1. **DOCREV-RT**: [UI-11 点名优先路由 + PATCH 请求级释租](short-term/2026-09-13-document-revision-second-doc-routing-lease.md)(`fix/document-revision-second-doc-routing-lease`:不改 prompt / 不重跑 E2E / 不操作容器;仍 `in_progress`) 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 @@ -26,6 +26,7 @@ | Cursor-Auto | document-revision-e2e-save-selector | fix/document-revision-e2e-save-selector | - | document-revision-save-selector | in_progress | | Cursor-Auto | document-revision-e2e-overlay-selector | fix/document-revision-e2e-overlay-selector | - | document-revision-overlay-selector | in_progress | | Cursor-Auto | document-revision-e2e-overlay-transition | fix/document-revision-e2e-overlay-transition | - | document-revision-overlay-transition | in_progress | +| Cursor-Auto | document-revision-second-doc-routing-lease | fix/document-revision-second-doc-routing-lease | - | document-revision-second-doc-routing-lease | in_progress | > 新任务开始前登记。`branch` 必须为 `feature/` 或 `fix/`,**禁止** `dev` / `test` / `main`。同一 `mutex` 同时仅一个 `in_progress`。 @@ -69,3 +70,4 @@ | UI-06 保存选择器失败审计 | [../audit/2026-09-13-document-revision-save-selector.md](../audit/2026-09-13-document-revision-save-selector.md) | | UI-07 纯净阅读取样失败审计 | [../audit/2026-09-13-document-revision-overlay-selector.md](../audit/2026-09-13-document-revision-overlay-selector.md) | | UI-07 transition 中间帧取样审计 | [../audit/2026-09-13-document-revision-overlay-transition.md](../audit/2026-09-13-document-revision-overlay-transition.md) | +| UI-11 第二份文档路由与 lease 审计 | [../audit/2026-09-13-document-revision-second-doc-routing-lease.md](../audit/2026-09-13-document-revision-second-doc-routing-lease.md) | diff --git a/.doc/ops/short-term/2026-09-13-document-revision-second-doc-routing-lease.md b/.doc/ops/short-term/2026-09-13-document-revision-second-doc-routing-lease.md new file mode 100644 index 00000000..3bebaa18 --- /dev/null +++ b/.doc/ops/short-term/2026-09-13-document-revision-second-doc-routing-lease.md @@ -0,0 +1,55 @@ +--- +title: "UI-11 点名优先路由 + PATCH 请求级释租" +status: in_progress +created: 2026-09-13 +updated: 2026-09-13 +owner: Cursor-Auto +branch: fix/document-revision-second-doc-routing-lease +worktree: "-" +mutex: document-revision-second-doc-routing-lease +--- + +# UI-11 点名优先路由 + PATCH 请求级释租 + +## 目标 + +第十轮 UI-11 首错:否定句「需求」压过「系统设计输入」,且 PATCH 残留 human lease 使小队 DOC_BUSY。点名优先 + 请求级 defer 释租 + E2E 202 门闩。不得将本任务标 `done`。 + +## 范围 + +- `web/src/engine/templateMatch.ts` + 单测 +- `internal/server/document_revision_handlers.go` + `document_revision_patch_order_test.go` +- `web/e2e/document-revision-runtime.spec.ts` UI-11 waiter +- 文档:audit / remediation / CURRENT + +## 非目标 + +- 不改 E2E prompt;不改 `SaveDocumentContent` 内部释租 +- 不触碰/提交 `.doc/test-reports/assets`;不操作容器;本轮不重跑 E2E +- 不改 FEATURE-MATRIX;不合 `dev`;不 push + +## 步骤 + +1. [x] 认领 CURRENT(mutex `document-revision-second-doc-routing-lease`) +2. [x] 点名优先 +「系统设计输入」别名 + 单测 +3. [x] PATCH 已授权后 defer 释租(成功 / 409 / 投影失败)+ 服务端测试 +4. [x] UI-11 send 前 `tasks/runs` waiter,断言 202 后再 wait completion +5. [ ] 占槽后真实 UI 重跑(本轮不做) + +## 验收标准 + +- [x] UI-11 原文 / 混合句 → `prelim_design`;当前文档钉住、UI-05 pin、首轮摸底仍 intake +- [x] PATCH 后无 human lease,squad 可获取 +- [x] UI-11 非 202 即 fail;`requireInflight` 拒绝同步 busy +- [ ] 真实 UI-11 PASS 后才可将本任务标 `done` + +## 相关链接 + +- Audit: [../../audit/2026-09-13-document-revision-second-doc-routing-lease.md](../../audit/2026-09-13-document-revision-second-doc-routing-lease.md) +- Remediation: [../../remediation/2026-09-13-document-revision-second-doc-routing-lease.md](../../remediation/2026-09-13-document-revision-second-doc-routing-lease.md) +- Evidence: [../../test-reports/assets/2026-09-13-document-revision-runtime-ui/schema30-ui11-second-doc-routing-human-lease-failure](../../test-reports/assets/2026-09-13-document-revision-runtime-ui/schema30-ui11-second-doc-routing-human-lease-failure) + +## 证据 / 备注 + +- Prompt 别名「系统设计输入」≠ 卡片标题。真实 UI / 模板 / squad 标题均为「系统初步设计方案」。 +- UI-11 打开第二份文档必须用已注册 `doc_proj{n}_prelim_design`(`data-doc-id`),禁止 `/设计输入|系统设计/`:既匹配不到真实标题,也容易误命中其他设计类文案或同标题模板槽。 diff --git a/.doc/remediation/2026-09-13-document-revision-second-doc-routing-lease.md b/.doc/remediation/2026-09-13-document-revision-second-doc-routing-lease.md new file mode 100644 index 00000000..28ceb3a2 --- /dev/null +++ b/.doc/remediation/2026-09-13-document-revision-second-doc-routing-lease.md @@ -0,0 +1,43 @@ +--- +title: "UI-11 点名优先路由 + PATCH 请求级释租" +status: in_progress +created: 2026-09-13 +updated: 2026-09-13 +--- + +# UI-11 点名优先路由 + PATCH 请求级释租 + +## 背景 + +链到 [../audit/2026-09-13-document-revision-second-doc-routing-lease.md](../audit/2026-09-13-document-revision-second-doc-routing-lease.md)。第十轮 UI-11 首错:否定句里的「需求」压过「系统设计输入」,且 PATCH 残留 15min human lease 使小队 DOC_BUSY。 + +## 目标 + +1. 明确点名交付物优先于否定背景关键词;`prelim_design` 增加「系统设计输入」别名。 +2. PATCH 在已授权获取 human lease 后 `defer` 释放(成功 / 409 / 投影失败)。 +3. UI-11 E2E 在 send 前建立 `tasks/runs` waiter,断言 HTTP 202 后再等 completion。 + +## 分波次计划 + +| 波次 | 内容 | 验收 | +|------|------|------| +| Wave 0 | `NAMED_DELIVERABLE_ALIASES` + `resolveTaskKeyFromMessage` / `resolveTaskTemplateForMessage` 点名优先 | UI-11 原文 → `prelim_design`;pin / UI-05 / 首轮摸底仍 intake | +| Wave 0 | PATCH handler `defer ReleaseLease`(不持 `e.mu`) | 成功 / 409 / 投影失败后无 human lease,squad 可 `AcquireLease` | +| Wave 0 | UI-11 send 前 waiter + 断言 202 | DOC_BUSY 不是 202;`requireInflight` 拒绝同步 busy completed | + +## 取舍 + +- **请求级租约**:本 UI 无长会话 heartbeat。同用户双窗 ETag 冲突靠 If-Match,不靠租约互斥(同 `holderID` 会续租)。持有期间他人/squad 仍 busy。`AssertHumanCanEdit` 失败路径本来就不持 lease。 +- 本轮不改 `SaveDocumentContent` 内部释租;不改 E2E prompt。 + +## 验收标准 + +- [x] 点名优先 +「系统设计输入」别名 + `templateMatch` 单测 +- [x] PATCH defer 释租 + 服务端测试(成功 / 409 / 投影失败) +- [x] UI-11 202 门闩;不改 prompt +- [ ] 占槽后真实 UI-11 PASS 才可将本任务标 `done` + +## 相关链接 + +- Audit: [../audit/2026-09-13-document-revision-second-doc-routing-lease.md](../audit/2026-09-13-document-revision-second-doc-routing-lease.md) +- Short-term: [../ops/short-term/2026-09-13-document-revision-second-doc-routing-lease.md](../ops/short-term/2026-09-13-document-revision-second-doc-routing-lease.md) diff --git a/internal/server/document_revision_handlers.go b/internal/server/document_revision_handlers.go index 916cb9e5..d7a3b1bc 100644 --- a/internal/server/document_revision_handlers.go +++ b/internal/server/document_revision_handlers.go @@ -1,8 +1,10 @@ package server import ( + "context" "encoding/json" "errors" + "fmt" "net/http" "strconv" "strings" @@ -198,11 +200,20 @@ func (s *Server) handleDocRevisionPatchContent(w http.ResponseWriter, r *http.Re } // 1) Controlled: author duty + lease WITHOUT writing body (fail-closed before ledger). + // This UI has no edit-session heartbeat: release the request-scoped human lease on every exit + // (success, 409, projection failure) so a 15m residual cannot block a later squad run. if kind == "controlled" && s.svc != nil && s.svc.DocGov() != nil { if err := s.svc.DocGov().AssertHumanCanEdit(r.Context(), docID, user.ID, docgov.RoleAuthor); err != nil { writeDocGovError(w, err) return } + holderID := fmt.Sprintf("%d", user.ID) + if user.ID <= 0 { + holderID = "human_anon" + } + defer func() { + _, _ = s.svc.DocGov().ReleaseLease(context.Background(), docID, string(docgov.LeaseHolderHuman), holderID) + }() } // 2) Bootstrap if needed, then commit ledger. No fact-source mutation yet. diff --git a/internal/server/document_revision_patch_order_test.go b/internal/server/document_revision_patch_order_test.go index 558951b2..d06bc709 100644 --- a/internal/server/document_revision_patch_order_test.go +++ b/internal/server/document_revision_patch_order_test.go @@ -12,6 +12,7 @@ import ( "testing" "time" + "chat-agent-system/internal/docgov" "chat-agent-system/internal/docrevision" ) @@ -64,6 +65,7 @@ func TestDocRevision_PatchOrder_AssertBeforeLedger_ProjectionFailureKeepsLedger( if after.HeadRevisionNo <= beforeRev && after.HeadSnapshotID == beforeSnap { t.Fatalf("ledger should commit before projection; before rev=%d snap=%d after=%+v", beforeRev, beforeSnap, after) } + assertHumanLeaseReleasedSquadCanAcquire(t, srv, docID) } func TestDocRevision_PatchOrder_LeaseDeniedDoesNotAdvanceLedger(t *testing.T) { @@ -150,4 +152,73 @@ func TestDocRevision_PatchOrder_SaveDocumentContentSetsAuthorAudit(t *testing.T) if _, err := os.Stat(filepath.Join(workDir, "output.md")); err != nil { t.Fatalf("output.md should exist after SaveDocumentContent: %v", err) } + assertHumanLeaseReleasedSquadCanAcquire(t, srv, docID) +} + +func TestDocRevision_PatchReleasesHumanLeaseOnHeadConflict(t *testing.T) { + srv := newTestServer(t) + ctx := context.Background() + workDir := filepath.Join(srv.dataRoot(), "ws-conflict-lease") + _ = os.MkdirAll(workDir, 0o755) + docID := "doc_proj1_patch_conflict_lease" + + if _, err := srv.svc.DocGov().CreateOrGetDocument(ctx, docID, 1, "intake", "摸底", "V0.1", workDir, 1, "admin", 2, "r", 3, "a"); err != nil { + t.Fatal(err) + } + st, err := srv.svc.DocRevision().OpenTemplateVersion(ctx, docID, "# base\n\nbody\n", docrevision.ActorSnapshot{DisplayName: "t"}, 1, workDir) + if err != nil { + t.Fatal(err) + } + token := loginTestAdmin(t, srv) + etag := `"` + strconv.FormatInt(st.HeadSnapshotID, 10) + ":" + strconv.Itoa(st.HeadRevisionNo) + `"` + + first := httptest.NewRequest(http.MethodPatch, "/api/v1/documents/"+docID+"/content", + bytes.NewReader([]byte(`{"markdown":"# base\n\nbody\n\nA1\n"}`))) + withBearerAuth(first, token) + first.Header.Set("Idempotency-Key", "conflict-lease-a1") + first.Header.Set("If-Match", etag) + w1 := httptest.NewRecorder() + srv.Routes().ServeHTTP(w1, first) + if w1.Code != http.StatusOK { + t.Fatalf("first save want 200 got %d %s", w1.Code, w1.Body.String()) + } + + second := httptest.NewRequest(http.MethodPatch, "/api/v1/documents/"+docID+"/content", + bytes.NewReader([]byte(`{"markdown":"# base\n\nbody\n\nA2\n"}`))) + withBearerAuth(second, token) + second.Header.Set("Idempotency-Key", "conflict-lease-a2") + second.Header.Set("If-Match", etag) + w2 := httptest.NewRecorder() + srv.Routes().ServeHTTP(w2, second) + if w2.Code != http.StatusConflict { + t.Fatalf("stale etag want 409 got %d %s", w2.Code, w2.Body.String()) + } + assertHumanLeaseReleasedSquadCanAcquire(t, srv, docID) +} + +func assertHumanLeaseReleasedSquadCanAcquire(t *testing.T, srv *Server, docID string) { + t.Helper() + ctx := context.Background() + doc, err := srv.svc.DocGov().GetDocument(ctx, docID) + if err != nil { + t.Fatal(err) + } + if doc.HasActiveLease(time.Now()) { + t.Fatalf("human lease must be released after PATCH, holder=%s/%s", doc.LeaseHolderType, doc.LeaseHolderID) + } + leased, err := srv.svc.DocGov().AcquireLease(ctx, docgov.LeaseAcquireRequest{ + DocID: docID, + HolderType: docgov.LeaseHolderSquad, + HolderID: "run_after_human_save", + TTL: time.Minute, + }) + if err != nil { + t.Fatalf("squad must acquire after human PATCH released lease: %v", err) + } + if leased.LeaseHolderType != docgov.LeaseHolderSquad { + t.Fatalf("want squad holder, got %s", leased.LeaseHolderType) + } + if _, err := srv.svc.DocGov().ReleaseLease(ctx, docID, string(docgov.LeaseHolderSquad), "run_after_human_save"); err != nil { + t.Fatal(err) + } } diff --git a/web/e2e/document-revision-runtime.spec.ts b/web/e2e/document-revision-runtime.spec.ts index ba6a2d99..5c236de6 100644 --- a/web/e2e/document-revision-runtime.spec.ts +++ b/web/e2e/document-revision-runtime.spec.ts @@ -316,6 +316,30 @@ async function openWorkflowDocByLabel( return { title: title || docId, docId }; } +/** 按已注册 doc_proj{n}_{taskKey} 打开,避开同标题模板槽与宽泛文案误命中。 */ +async function openWorkflowDocByControlledTaskKey( + page: Page, + taskKey: string, +): Promise<{ title: string; docId: string }> { + if (!/^[a-z][a-z0-9_]*$/.test(taskKey)) fail(`invalid controlled taskKey: ${taskKey}`); + const trigger = page.getByTestId('doc-selector-trigger'); + await expect(trigger).toBeVisible({ timeout: 15_000 }); + await trigger.click(); + const item = page + .locator(`.dropdown-menu button[data-doc-id^="doc_proj"][data-doc-id$="_${taskKey}"]`) + .first(); + if ((await item.count()) === 0) { + fail(`workflow menu missing registered doc_proj*_${taskKey}`); + } + const docId = (await item.getAttribute('data-doc-id')) || ''; + const title = (await item.getAttribute('data-doc-title')) || ''; + if (!new RegExp(`^doc_proj\\d+_${taskKey}$`).test(docId)) { + fail(`expected doc_proj{n}_${taskKey}, got ${JSON.stringify(docId)}`); + } + await item.click(); + return { title: title || docId, docId }; +} + async function openWorkflowDocById(page: Page, docId: string): Promise { if (!docId) fail('openWorkflowDocById requires docId'); const trigger = page.getByTestId('doc-selector-trigger'); @@ -1042,9 +1066,22 @@ test.describe.serial('document-revision-runtime UI acceptance', () => { logs = []; const prompt = '请另行生成一份《系统设计输入》受控文档(第二份),不要覆盖上一份需求文档,并给出可打开的交付物。'; + const taskRunWait = waitForTasksRunsPost(page); await sendChat(page, prompt); + const taskRes = await taskRunWait; + if (taskRes.status() !== 202) { + fail(`UI-11 tasks/runs must be HTTP 202, got ${taskRes.status()}`); + } + const taskBody = taskRes.request().postDataJSON() as { task_key?: string }; + if (taskBody.task_key !== 'prelim_design') { + fail(`UI-11 task_key must be prelim_design, got ${JSON.stringify(taskBody.task_key)}`); + } await waitForSquadCompletion(page, '系统设计输入'); - const second = await openWorkflowDocByLabel(page, /设计输入|系统设计/); + // Prompt alias is 系统设计输入; card title is 系统初步设计方案; open by registered doc_proj id. + const second = await openWorkflowDocByControlledTaskKey(page, 'prelim_design'); + if (second.title !== '系统初步设计方案') { + fail(`UI-11 prelim_design card title must be 系统初步设计方案, got ${JSON.stringify(second.title)}`); + } secondDocTitle = second.title; secondDocId = second.docId; await expect(page.getByTestId('active-document-pane')).toHaveAttribute('data-doc-id', secondDocId); diff --git a/web/src/engine/templateFactory.test.ts b/web/src/engine/templateFactory.test.ts index 947757b8..f4819f81 100644 --- a/web/src/engine/templateFactory.test.ts +++ b/web/src/engine/templateFactory.test.ts @@ -32,6 +32,14 @@ test('createProjectTemplateDeliverables: 必须精确生成 7 份全生命周期 const actualTaskKeys = result.deliverables.map((d) => d.taskKey); assert.deepStrictEqual(actualTaskKeys, expectedTaskKeys); + const prelim = result.deliverables.find((d) => d.taskKey === 'prelim_design'); + assert.ok(prelim, 'prelim_design template must exist'); + assert.strictEqual(prelim.title, '系统初步设计方案'); + assert.strictEqual( + STANDARD_WORKFLOW_TEMPLATES.find((t) => t.taskKey === 'prelim_design')?.title, + '系统初步设计方案', + ); + // 2. 验证阶段覆盖 (V0.1, V0.5, V1.0) const stages = new Set(result.deliverables.map((d) => d.stage)); assert.ok(stages.has('V0.1')); diff --git a/web/src/engine/templateMatch.test.ts b/web/src/engine/templateMatch.test.ts index 63166daa..7c396860 100644 --- a/web/src/engine/templateMatch.test.ts +++ b/web/src/engine/templateMatch.test.ts @@ -203,3 +203,23 @@ test('no active doc does not invent a pin', () => { assert.equal(resolved?.docId, undefined); assert.equal(resolved?.pinnedToActiveDoc, undefined); }); + +const UI11_SECOND_DOC_PROMPT = + '请另行生成一份《系统设计输入》受控文档(第二份),不要覆盖上一份需求文档,并给出可打开的交付物。'; + +test('UI-11 named 系统设计输入 beats negated 需求 background', () => { + assert.equal(resolveTaskKeyFromMessage(UI11_SECOND_DOC_PROMPT), 'prelim_design'); + assert.equal(resolveTaskTemplateForMessage(UI11_SECOND_DOC_PROMPT, bothPool, activeIntake)?.taskKey, 'prelim_design'); +}); + +test('mixed generate-design + do-not-cover-requirements stays prelim_design', () => { + const text = '请生成《系统设计输入》,不要覆盖上一份需求文档'; + assert.equal(resolveTaskKeyFromMessage(text), 'prelim_design'); +}); + +test('current-doc pin still wins when no other deliverable is named', () => { + const text = '请改写当前受控文档中的风险管理章节,不要覆盖上一份需求文档'; + const resolved = resolveTaskTemplateForMessage(text, bothPool, activeIntake); + assert.equal(resolved?.taskKey, 'intake'); + assert.equal(resolved?.pinnedToActiveDoc, true); +}); diff --git a/web/src/engine/templateMatch.ts b/web/src/engine/templateMatch.ts index 3b21c60d..c73b9d5c 100644 --- a/web/src/engine/templateMatch.ts +++ b/web/src/engine/templateMatch.ts @@ -12,7 +12,7 @@ const NAMED_DELIVERABLE_ALIASES: { taskKey: string; patterns: RegExp[] }[] = [ { taskKey: 'research', patterns: [/调研报告/, /竞品调研/] }, { taskKey: 'feasibility_reg', patterns: [/注册路径可行性/, /法规可行性评估/, /注册可行性评估/] }, { taskKey: 'feasibility_dev', patterns: [/设计开发可行性/, /研发可行性评估/] }, - { taskKey: 'prelim_design', patterns: [/初步设计说明书/, /系统设计规格/] }, + { taskKey: 'prelim_design', patterns: [/系统设计输入/, /初步设计说明书/, /系统设计规格/] }, { taskKey: 'cdo_quote', patterns: [/CDO报价/, /报价核算表/] }, ]; @@ -66,11 +66,7 @@ function explicitNamedDeliverableTaskKey( 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; + return namedAliasTaskKey(text, excludeTaskKey); } /** @@ -118,12 +114,24 @@ function pickRequirementsTask(text: string, pool: DeliverableItem[]): Deliverabl return undefined; } +function namedAliasTaskKey(text: string, excludeTaskKey?: string): string | undefined { + 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; +} + /** 根据用户输入解析应触发的文档任务键(不依赖交付物池顺序)。 */ export function resolveTaskKeyFromMessage(text: string): string | undefined { const normalized = text.trim(); // 模板名只是操作对象,不代表用户授权写入。只读动词优先;只有明确写动作才走任务 API。 if (!hasDocumentWriteIntent(normalized)) return undefined; + // 明确点名的交付物优先于否定背景/旧文档里的「需求」等关键词。 + const named = namedAliasTaskKey(normalized); + if (named) return named; + if (normalized.includes('需求') || normalized.includes('摸底') || normalized.includes('用户') || normalized.includes('首轮')) { return resolveRequirementsTaskKey(normalized); } @@ -185,6 +193,12 @@ export function resolveTaskTemplateForMessage( } } + const named = explicitNamedDeliverableTaskKey(text, deliverables); + if (named) { + const template = matchTemplateDeliverableByKey(named, deliverables, text); + return { taskKey: named, template }; + } + const taskKey = resolveTaskKeyFromMessage(text); if (!taskKey) return undefined; const template = matchTemplateDeliverable(text, deliverables);