merge: fix/document-revision-second-doc-routing-lease
This commit is contained in:
@@ -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` 打开
|
||||
+4
-2
@@ -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/<slug>` 或 `fix/<slug>`,**禁止** `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) |
|
||||
|
||||
@@ -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`),禁止 `/设计输入|系统设计/`:既匹配不到真实标题,也容易误命中其他设计类文案或同标题模板槽。
|
||||
@@ -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)
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
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);
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user