merge: fix/document-revision-sse-idempotency
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
---
|
||||
title: "质量门禁自污染与 auto_repair 未启用导致 quality_failed"
|
||||
status: in_progress
|
||||
created: 2026-09-13
|
||||
updated: 2026-09-13
|
||||
severity: high
|
||||
---
|
||||
|
||||
# 质量门禁自污染与 auto_repair 未启用导致 quality_failed
|
||||
|
||||
## 现象
|
||||
|
||||
同一整改范围真实 run(与 SSE/幂等现场同源):
|
||||
|
||||
- workspace=`data/dev/workspaces/bizdev/intake_1789308825666278807`
|
||||
- run 于 **14:17:51** 结束为 **`quality_failed`**(仅 1 个 `task_runs`)
|
||||
- 质量门禁事件:**score=40**,命中 `不做展开` / `篇幅有限` / `从略`
|
||||
- `output.md` **第204行**终稿自检表:`无"不做展开""从略""篇幅有限"等敷衍套话` → 门禁 `strings.Contains` **自污染误报**
|
||||
- 请求体未带 `auto_repair`;虽存在 `EnsureQualityOrRepair`,实际 **maxRetries=0**
|
||||
- chat_messages 同 `client_turn` 两对(1131/1132 正常;1133/1134 重连误写)——与 SSE 幂等缺陷同源,另案处理
|
||||
|
||||
## 根因
|
||||
|
||||
| 项 | 说明 |
|
||||
|---|---|
|
||||
| A | 修复预算依赖客户端 `auto_repair`(浏览器可省略,默认 false) |
|
||||
| B | 生成指令与自检表原样引用禁词;检测无否定/元语境豁免 |
|
||||
| C | FE 将 `quality_failed` 映射为 `completed`,并在断线 catch 路径同步受控文档 |
|
||||
| D | 缺真实失败形态单测 |
|
||||
|
||||
## 整改(同分支 `fix/document-revision-sse-idempotency`)
|
||||
|
||||
- A:小队 `repairBudget` 只认 `AutoRepair`;bizdev 在解析出 `docID`+`docRec` 后强制开启;受控 HTTP `task_run_handlers` 仍设 `true`。小队 `Success` 仅表示执行完成,投影/阶段停链看 `QualityReport`
|
||||
- B:prompt 去原词;豁免仅限同一自检/规则行且同时具备 meta marker + 否定紧邻或引号引用(禁止 48 字窗口遇「无」即放过)
|
||||
- C:`TerminalStatus` 优先 quality;Materialize 仅 `Passed`;FE `quality_failed` 独立失败态且不同步激活
|
||||
- D:现场 L204 / repair budget / TerminalStatus / FE 映射单测
|
||||
- **E(投影边界)**:`finalizeSquadDeliverable` 在任何 `SetDocumentWorkDir` / `Materialize` / `MarkDocumentContentUpdated` / 完成活动之前按 `QualityReport.Passed=false` 短路;保留隔离工作区与失败摘要;正式 `work_dir`/head/hash/候选不变。单测:`TestFinalizeSquadDeliverable_QualityFailed_NoDocProjectionSideEffects`(敌意 `Success=true`)
|
||||
- **F(阶段链)**:`ExecuteStage` 在 `err=nil` 后经 `classifyStageTaskHalt` 检查质量/`Success`;失败立即停链,保留失败 artifact,不吸收 ExtractedData、不追加 PriorDoc、不写 completed workspace / 阶段跃迁。单测:`TestExecuteStage_QualityFailed_StopsChainWithoutAbsorbingFactsOrPriorDoc`
|
||||
|
||||
## 证据
|
||||
|
||||
- workspace path 如上;events seq10;output.md:204
|
||||
- assets:[../test-reports/assets/2026-09-13-document-revision-runtime-ui/](../test-reports/assets/2026-09-13-document-revision-runtime-ui/)(勿删)
|
||||
|
||||
## 关联
|
||||
|
||||
- Short-term 扩写:[../ops/short-term/2026-09-13-document-revision-sse-idempotency.md](../ops/short-term/2026-09-13-document-revision-sse-idempotency.md)
|
||||
- SSE 幂等审计:[./2026-09-13-document-revision-sse-idempotency.md](./2026-09-13-document-revision-sse-idempotency.md)
|
||||
@@ -0,0 +1,46 @@
|
||||
---
|
||||
title: "任务 run SSE 超时断线后同 turn 误击 DOC_BUSY"
|
||||
status: in_progress
|
||||
created: 2026-09-13
|
||||
updated: 2026-09-13
|
||||
severity: critical
|
||||
---
|
||||
|
||||
# 任务 run SSE 超时断线后同 turn 误击 DOC_BUSY
|
||||
|
||||
## 现象
|
||||
|
||||
真实 Antigravity UI-03(dev schema 30):
|
||||
|
||||
1. 初始 `POST /api/v1/tasks/runs` → **202** `run_1789308825655387640`
|
||||
2. `GET .../events` 约 **157s** 后以 `ERR_INCOMPLETE_CHUNKED_ENCODING` 断开(对齐服务端默认 `WriteTimeout=120s`)
|
||||
3. 浏览器用**完全相同** `session=sess-p6098-u107`、`client_turn_id=turn_1789308825646`、`doc_id=doc_proj6098_intake` 再 POST
|
||||
4. `handleCreateTaskRun` 在幂等 `ClaimRun` **之前**执行 `preflightTaskDocumentBusy` → **409 DOC_BUSY**,并 `recordBusyPreflightMessages` 写入**重复**用户/失败助手消息
|
||||
|
||||
## 根因分析
|
||||
|
||||
| 层 | 问题 |
|
||||
|---|---|
|
||||
| HTTP | 全局 `WriteTimeout: 120s` 切断长任务 SSE 响应体,客户端见 incomplete chunked encoding |
|
||||
| Handler | 同 turn 重试仍走 scope/preflight;活跃小队租约被当成「别人在编」 |
|
||||
| FE | `subscribeTaskRunEvents` 遇意外 EOF 直接结束,上层可能再次 `createTaskRun` |
|
||||
|
||||
## 严重度
|
||||
|
||||
`critical`
|
||||
|
||||
## 关联整改
|
||||
|
||||
- Short-term: [../ops/short-term/2026-09-13-document-revision-sse-idempotency.md](../ops/short-term/2026-09-13-document-revision-sse-idempotency.md)
|
||||
- 分支: `fix/document-revision-sse-idempotency`
|
||||
|
||||
## 证据
|
||||
|
||||
- 运行产物目录(勿删):[../test-reports/assets/2026-09-13-document-revision-runtime-ui/](../test-reports/assets/2026-09-13-document-revision-runtime-ui/)
|
||||
- 前序 UI-03 客户端同步审计:[./2026-09-13-controlled-doc-client-sync-ui03.md](./2026-09-13-controlled-doc-client-sync-ui03.md)
|
||||
- 现场键:`run_1789308825655387640` / `sess-p6098-u107` / `turn_1789308825646` / `doc_proj6098_intake`
|
||||
|
||||
## 开放问题
|
||||
|
||||
- 合入前占槽真实 UI 重跑 UI-03(本轮不 rebuild Docker)
|
||||
- chat `/stream` 与 `tasks/run/stream` 是否需同样 SSE write-deadline 扩展(本轮优先 `/tasks/runs/{id}/events`)
|
||||
+8
-5
@@ -1,11 +1,11 @@
|
||||
# 当前焦点
|
||||
|
||||
**更新**: 2026-09-13
|
||||
**production_ready**: no(DOCREV 集成槽 e2e;UI-00~02 PASS;UI-03 工程修复在 `fix/document-revision-runtime-ui03`:post-task sync + goose 00030 + Claim 幂等;schema30 卷迁移与真实 UI-03 待合入前验证)
|
||||
**production_ready**: no(DOCREV:schema **30**;SSE 幂等 + 质量门禁自污染/auto_repair 整改在 `fix/document-revision-sse-idempotency`)
|
||||
|
||||
## 焦点
|
||||
|
||||
1. **DOCREV-RT**: [运行时整改收口](short-term/2026-09-13-document-revision-runtime.md)(`fix/document-revision-runtime-ui03`:UI-03 客户端同步/幂等/chk;占槽真实 UI 重跑;不 push)
|
||||
1. **DOCREV-RT**: [SSE 幂等 + 质量门禁](short-term/2026-09-13-document-revision-sse-idempotency.md)(`fix/document-revision-sse-idempotency`:幂等/SSE + A–D 质量契约;不 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
|
||||
|
||||
@@ -16,7 +16,8 @@
|
||||
| Cursor | chat-history-refresh-remediation | fix/chat-history-refresh | - | chat | 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-Auto | document-revision-runtime-ui03 | fix/document-revision-runtime-ui03 | - | docgov | in_progress |
|
||||
| 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 |
|
||||
|
||||
> 新任务开始前登记。`branch` 必须为 `feature/<slug>` 或 `fix/<slug>`,**禁止** `dev` / `test` / `main`。同一 `mutex` 同时仅一个 `in_progress`。
|
||||
|
||||
@@ -24,7 +25,7 @@
|
||||
|
||||
| holder | phase | since | scope | queue |
|
||||
|--------|-------|-------|-------|-------|
|
||||
| Codex/Antigravity | e2e | 2026-09-13 11:16 UTC | DOCREV-RT real browser UI + real PI E2E on schema 29 | - |
|
||||
| Codex/Antigravity | e2e | 2026-09-13 | DOCREV-RT real UI on **schema 30**;SSE 断线/同 turn 409 现场保留 | Cursor-Auto (sse-idempotency 编码,待占槽冒烟) |
|
||||
|
||||
**phase**: `idle` | `merging` | `smoke` | `e2e`
|
||||
|
||||
@@ -34,7 +35,7 @@
|
||||
|
||||
| 环境 | 端口 | schema | 状态 |
|
||||
|------|------|--------|------|
|
||||
| dev | 8080 | 28 | healthy |
|
||||
| dev | 8080 | **30** | healthy(UI-03 现场) |
|
||||
| test | 8081 | 25→27* | healthy |
|
||||
| main | 8000 | 25→27* | healthy(app + runner + DB) |
|
||||
|
||||
@@ -49,3 +50,5 @@
|
||||
| Agent 手册 | [AGENTS.md](../../AGENTS.md) |
|
||||
| DOCREV ADR | [../adr/2026-09-13-document-version-boundary.md](../adr/2026-09-13-document-version-boundary.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) |
|
||||
| 质量门禁自污染审计 | [../audit/2026-09-13-document-revision-quality-gate-self-pollution.md](../audit/2026-09-13-document-revision-quality-gate-self-pollution.md) |
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
---
|
||||
title: "任务 SSE 幂等 + 质量门禁自污染与 auto_repair 产品契约"
|
||||
status: in_progress
|
||||
created: 2026-09-13
|
||||
updated: 2026-09-13
|
||||
owner: Cursor-Auto
|
||||
branch: fix/document-revision-sse-idempotency
|
||||
worktree: "-"
|
||||
mutex: docgov
|
||||
---
|
||||
|
||||
# 任务 SSE 幂等 + 质量门禁自污染与 auto_repair 产品契约
|
||||
|
||||
## 目标
|
||||
|
||||
1. 同 turn 在 SSE 断线重连时返回既有 run 202、不重复聊天、不误 409 DOC_BUSY;长任务 events SSE 不被 120s WriteTimeout 切断;前端 EOF 按 after_seq 重订阅同一 run。
|
||||
2. 受控文档小队任务质量门禁:**服务端启用修复预算**;否定性自检引用不误报;`quality_failed` UI 保持失败且不激活未过门禁稿。
|
||||
|
||||
## 范围
|
||||
|
||||
- SSE/幂等:`task_run_handlers`、SSE write deadline、FE subscribe 重连
|
||||
- 质量 A–D:`repairBudgetForSquadTask`、`quality_gate` 语境检测、prompt 去原词、Materialize/`TerminalStatus`、FE `quality_failed`、单测
|
||||
- 证据:`data/dev/workspaces/bizdev/intake_1789308825666278807`(output.md:204;events seq10)
|
||||
|
||||
## 非目标
|
||||
|
||||
- 不改全局 HTTP 超时为「无超时」;不 rebuild Docker;不 push/merge;不删证据 assets
|
||||
|
||||
## 步骤
|
||||
|
||||
1. [x] 从 `dev` 开 `fix/document-revision-sse-idempotency`;CURRENT 认领
|
||||
2. [x] 幂等先查 + 属主校验
|
||||
3. [x] SSE write deadline + heartbeat
|
||||
4. [x] FE subscribe 重连
|
||||
5. [x] A 服务端修复预算产品契约
|
||||
6. [x] B 门禁否定/元语境 + prompt 去原词
|
||||
7. [x] C quality_failed UI/Materialize 契约
|
||||
8. [x] D 真实问题单测 + 门禁回归
|
||||
9. [x] E 质量失败在 SetDocumentWorkDir/Materialize 前短路(无正式投影副作用)
|
||||
10. [x] 豁免收窄为同行 meta+否定紧邻/引号;handlers 阶段请求字段边界单测锁定
|
||||
11. [x] ExecuteStage 质量失败停链:不吸收事实、不追加 PriorDoc
|
||||
12. [x] 小队 AutoRepair/Success 语义恢复;受控修复仅在 bizdev docRec 与 HTTP 边界强制
|
||||
13. [x] 补强:quality projection / 阶段停链 / 非受控不强制 repair 单测;全量 Go+Web 门禁 PASS
|
||||
|
||||
## 验收标准
|
||||
|
||||
- [x] 同 turn + 活跃租约 → 同 run 202、仅一对消息;不同 turn → 仍 409
|
||||
- [x] SSE 心跳/写期限可测;FE EOF/after_seq 单测
|
||||
- [x] TaskKey 任务 AutoRepair=false 仍有 DefaultMaxRepairRounds;L204 自检引用不误报;真敷衍仍 FAIL
|
||||
- [x] quality_failed ≠ completed;Materialize 跳过未过门禁稿
|
||||
- [x] `go test -short ./...`、`go vet`、`npm test`/`build`、`git diff --check`
|
||||
|
||||
## 相关链接
|
||||
|
||||
- Audit SSE: [../../audit/2026-09-13-document-revision-sse-idempotency.md](../../audit/2026-09-13-document-revision-sse-idempotency.md)
|
||||
- Audit 质量: [../../audit/2026-09-13-document-revision-quality-gate-self-pollution.md](../../audit/2026-09-13-document-revision-quality-gate-self-pollution.md)
|
||||
- Evidence: [../../test-reports/assets/2026-09-13-document-revision-runtime-ui/](../../test-reports/assets/2026-09-13-document-revision-runtime-ui/)
|
||||
@@ -62,6 +62,8 @@
|
||||
| ENV-RESTART-SOURCE | `docker compose restart` 只重启旧镜像,健康检查无法证明合入代码已部署 | **PASS** | `env-service.sh rebuild` + 真实摘要重测 | 集成冒烟统一使用 `dev rebuild` |
|
||||
| DOCREV-RUNTIME-404 | revision-state 404;账本内存丢失;块保存/overlay 无效;agent 假报告 | **FAIL** | [audit](../audit/2026-09-13-document-revision-runtime-404.md);`internal/docrevision/service.go` 仅 map | [remediation](./2026-09-13-document-revision-runtime-remediation.md) |
|
||||
| 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;同分支 | 占槽真实重跑;不删证据 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
- 先立框架:读者是谁、要他们做什么决定。
|
||||
- 按本阶段模板撰写,把专员输入填进去;模板是下限,按共用写作质量把每一节写完整。
|
||||
- **正文优先**:文档主题章节写在前面且详尽;`阻塞信息`/`输入摘要`/`本阶段材料` 等字段快照放文末,不得用字段表顶替正文。
|
||||
- **禁止省略**:不得写「不做展开」「从略」「不赘述」等;缺信息标「待确认」并说明缺什么。
|
||||
- **禁止省略**:各节须实质展开,不得以篇幅借口或省略写法跳过应写细节;缺信息标「待确认」并说明缺什么(自检勿复述禁用原词)。
|
||||
- 数字标注来源;材料没有的数字不许填。涉及法规条款用〔标准号 §条款 p.页〕。
|
||||
- 缺口显式化,文末汇总「待确认」。没有出处的判断可保留,写「(分析判断,无直接条文依据)」。
|
||||
- 按 TASK.md 指定文件落盘:普通/执行步写 `output.md`;计划步(plan)只写 `output_plan.md`,**不要**写 `output.md`。
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
package bizdev
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"chat-agent-system/internal/config"
|
||||
"chat-agent-system/internal/docgov"
|
||||
"chat-agent-system/internal/docrevision"
|
||||
"chat-agent-system/internal/squad"
|
||||
)
|
||||
|
||||
func TestAutoRepairForControlledSquad(t *testing.T) {
|
||||
if autoRepairForControlledSquad(false, "", nil) {
|
||||
t.Fatal("uncontrolled must respect AutoRepair=false")
|
||||
}
|
||||
if !autoRepairForControlledSquad(true, "", nil) {
|
||||
t.Fatal("uncontrolled AutoRepair=true must stay true")
|
||||
}
|
||||
if !autoRepairForControlledSquad(false, "doc_proj1_intake", &docgov.DocumentRecord{DocID: "doc_proj1_intake"}) {
|
||||
t.Fatal("resolved controlled doc must force AutoRepair")
|
||||
}
|
||||
if autoRepairForControlledSquad(false, "doc_proj1_intake", nil) {
|
||||
t.Fatal("docID without docRec must not force AutoRepair")
|
||||
}
|
||||
}
|
||||
|
||||
func TestQualityBlocksControlledProjection(t *testing.T) {
|
||||
if qualityBlocksControlledProjection(nil) {
|
||||
t.Fatal("nil report must not block")
|
||||
}
|
||||
if qualityBlocksControlledProjection(&squad.QualityReport{Passed: true}) {
|
||||
t.Fatal("passed report must not block")
|
||||
}
|
||||
if !qualityBlocksControlledProjection(&squad.QualityReport{Passed: false, Score: 40}) {
|
||||
t.Fatal("failed report must block")
|
||||
}
|
||||
}
|
||||
|
||||
// 敌意输入:Success=true 但 QualityReport.Passed=false —— 仍不得投影到正式受控事实源。
|
||||
func TestFinalizeSquadDeliverable_QualityFailed_NoDocProjectionSideEffects(t *testing.T) {
|
||||
dataDir := t.TempDir()
|
||||
svc := MustNewService(t, &config.Config{DataDir: dataDir, AgentBackend: "stub"})
|
||||
ctx := context.Background()
|
||||
projectID := int64(6098)
|
||||
docID := "doc_proj6098_intake"
|
||||
|
||||
officialWD := filepath.Join(dataDir, "official-head")
|
||||
if err := os.MkdirAll(officialWD, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
base := "# 正式受控 head\n\n本正文为账本事实源,质量失败后不得被未过门禁稿覆盖。\n"
|
||||
if _, err := svc.DocRevision().OpenTemplateVersion(ctx, docID, base, docrevision.ActorSnapshot{DisplayName: "baseline"}, projectID, officialWD); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
docRec, err := svc.DocGov().CreateOrGetDocument(ctx, docID, projectID, "intake", "用户初始需求收集表", "V0.5", officialWD, 1, "a", 2, "r", 3, "p")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stBefore, err := svc.DocRevision().GetRevisionState(ctx, docID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
versBefore, err := svc.DocRevision().ListVersions(ctx, docID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
beforeSHA := docRec.ContentSHA256
|
||||
beforeWD := docRec.WorkDir
|
||||
beforeHead := stBefore.HeadSHA256
|
||||
beforeRev := stBefore.HeadRevisionNo
|
||||
beforeVer := stBefore.CurrentVersionNo
|
||||
if stBefore.PendingCandidateID != nil {
|
||||
t.Fatalf("baseline must not already have pending candidate: %v", *stBefore.PendingCandidateID)
|
||||
}
|
||||
isolatedWD := filepath.Join(dataDir, "isolated-quality-fail")
|
||||
if err := os.MkdirAll(isolatedWD, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
failBody := "# 未过门禁草稿\n\n此处篇幅有限,不做展开。\n"
|
||||
|
||||
out, err := svc.finalizeSquadDeliverable(ctx, TaskRunRequest{
|
||||
TaskKey: "intake", ProjectID: projectID, ProjectName: "P",
|
||||
CustomerName: "C", DeviceType: "医疗器械", UserID: 1, DocID: docID,
|
||||
}, &squad.SquadRunResult{
|
||||
TaskKey: "intake",
|
||||
ArtifactTitle: "用户初始需求收集表",
|
||||
MarkdownBody: failBody,
|
||||
WorkDir: isolatedWD,
|
||||
BackendUsed: "stub",
|
||||
Duration: time.Second,
|
||||
// 敌意:Success 误为 true,旧路径若只看 Success 会 Materialize
|
||||
Success: true,
|
||||
QualityReport: &squad.QualityReport{
|
||||
Passed: false,
|
||||
Score: 40,
|
||||
ProhibitedPhrases: []string{"不做展开", "篇幅有限"},
|
||||
Violations: []string{"检测到正文中的省略/敷衍写法(须实质展开;自检勿复述禁用原词)"},
|
||||
},
|
||||
}, docID, docRec, "", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("quality fail must return nil error, got %v", err)
|
||||
}
|
||||
if out == nil || out.Success {
|
||||
t.Fatalf("want Success=false, got %+v", out)
|
||||
}
|
||||
if out.TerminalStatus() != TaskStatusQualityFailed {
|
||||
t.Fatalf("status=%q want quality_failed", out.TerminalStatus())
|
||||
}
|
||||
if out.WorkDir != isolatedWD {
|
||||
t.Fatalf("must keep isolated workspace, got %q", out.WorkDir)
|
||||
}
|
||||
if !strings.Contains(out.ErrorReason, "质量门禁未通过") {
|
||||
t.Fatalf("ErrorReason=%q", out.ErrorReason)
|
||||
}
|
||||
if out.MarkdownBody != failBody {
|
||||
t.Fatal("failure summary body must remain visible")
|
||||
}
|
||||
|
||||
afterDoc, err := svc.DocGov().GetDocument(ctx, docID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if afterDoc.WorkDir != beforeWD {
|
||||
t.Fatalf("formal work_dir mutated: before=%q after=%q", beforeWD, afterDoc.WorkDir)
|
||||
}
|
||||
if afterDoc.ContentSHA256 != beforeSHA {
|
||||
t.Fatalf("formal content hash mutated: before=%q after=%q", beforeSHA, afterDoc.ContentSHA256)
|
||||
}
|
||||
stAfter, err := svc.DocRevision().GetRevisionState(ctx, docID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stAfter.HeadSHA256 != beforeHead || stAfter.HeadRevisionNo != beforeRev || stAfter.CurrentVersionNo != beforeVer {
|
||||
t.Fatalf("ledger head mutated: before=%s rev=%d ver=%d after=%s rev=%d ver=%d",
|
||||
beforeHead, beforeRev, beforeVer, stAfter.HeadSHA256, stAfter.HeadRevisionNo, stAfter.CurrentVersionNo)
|
||||
}
|
||||
if stAfter.PendingCandidateID != nil {
|
||||
t.Fatalf("quality_failed must not create pending candidate: %v", *stAfter.PendingCandidateID)
|
||||
}
|
||||
versAfter, err := svc.DocRevision().ListVersions(ctx, docID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(versAfter) != len(versBefore) {
|
||||
t.Fatalf("version count changed: before=%d after=%d", len(versBefore), len(versAfter))
|
||||
}
|
||||
for _, title := range activityTitles(t, svc, projectID) {
|
||||
if strings.Contains(title, "完成交付物") || strings.Contains(title, "待编制人确认") {
|
||||
t.Fatalf("quality_failed must not record delivery activity: %q", title)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteTask_Uncontrolled_DoesNotForceAutoRepair(t *testing.T) {
|
||||
dataDir := t.TempDir()
|
||||
svc := MustNewService(t, &config.Config{DataDir: dataDir, AgentBackend: "stub"})
|
||||
res, err := svc.ExecuteTask(context.Background(), TaskRunRequest{
|
||||
TaskKey: "intake",
|
||||
ProjectID: 0,
|
||||
DocID: "",
|
||||
AutoRepair: false,
|
||||
ProjectName: "CLI非受控",
|
||||
CustomerName: "C",
|
||||
DeviceType: "医疗器械",
|
||||
Backend: "stub",
|
||||
Timeout: 10 * time.Second,
|
||||
})
|
||||
if err != nil || res == nil || !res.Success {
|
||||
t.Fatalf("uncontrolled ExecuteTask should succeed, err=%v res=%+v", err, res)
|
||||
}
|
||||
if res.DocRecord != nil {
|
||||
t.Fatal("uncontrolled path must not create DocRecord")
|
||||
}
|
||||
raw, readErr := os.ReadFile(filepath.Join(res.WorkDir, "workflow.json"))
|
||||
if readErr != nil {
|
||||
t.Fatalf("read workflow.json: %v", readErr)
|
||||
}
|
||||
var wf struct {
|
||||
Steps []struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"steps"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &wf); err != nil {
|
||||
t.Fatalf("workflow.json: %v", err)
|
||||
}
|
||||
for _, s := range wf.Steps {
|
||||
if strings.HasPrefix(s.ID, "step_repair_") {
|
||||
t.Fatalf("AutoRepair=false uncontrolled must not append repair step %q", s.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
+83
-39
@@ -43,6 +43,8 @@ type Service struct {
|
||||
pool *pgxpool.Pool
|
||||
schemaVersion int64
|
||||
stageGate StageAccessGate
|
||||
// stageTaskHook 仅测试注入:覆盖 ExecuteStage 子任务执行,验证链中止边界。
|
||||
stageTaskHook func(context.Context, TaskRunRequest) (*TaskRunResult, error)
|
||||
}
|
||||
|
||||
// NewService 构造业务服务;生产/test 环境数据库不可用且未显式 ALLOW_IN_MEMORY 时返回 error。
|
||||
@@ -696,7 +698,7 @@ func (s *Service) ExecuteTask(ctx context.Context, req TaskRunRequest) (*TaskRun
|
||||
Timeout: req.Timeout,
|
||||
WorkDir: req.WorkDir,
|
||||
CustomSteps: req.CustomSteps,
|
||||
AutoRepair: req.AutoRepair,
|
||||
AutoRepair: autoRepairForControlledSquad(req.AutoRepair, docID, docRec),
|
||||
KnowledgeHits: hits,
|
||||
PriorDocs: priorDocs,
|
||||
UserContext: userCtxStr,
|
||||
@@ -724,6 +726,68 @@ func (s *Service) ExecuteTask(ctx context.Context, req TaskRunRequest) (*TaskRun
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return s.finalizeSquadDeliverable(ctx, req, res, docID, docRec, appliedProposalID, upstreamSRDoc)
|
||||
}
|
||||
|
||||
// qualityBlocksControlledProjection 质量门禁未过时禁止任何正式受控投影。
|
||||
// 独立于 Success,避免 Success 误为 true 时仍 Materialize。
|
||||
func qualityBlocksControlledProjection(report *squad.QualityReport) bool {
|
||||
return report != nil && !report.Passed
|
||||
}
|
||||
|
||||
// autoRepairForControlledSquad 已解析出正式受控文档后强制开启修复;非受控调用仍尊重请求值。
|
||||
func autoRepairForControlledSquad(reqAutoRepair bool, docID string, docRec *docgov.DocumentRecord) bool {
|
||||
if strings.TrimSpace(docID) != "" && docRec != nil {
|
||||
return true
|
||||
}
|
||||
return reqAutoRepair
|
||||
}
|
||||
|
||||
func qualityFailedErrorReason(report *squad.QualityReport) string {
|
||||
if report == nil {
|
||||
return "质量门禁未通过"
|
||||
}
|
||||
if len(report.Violations) > 0 {
|
||||
return "质量门禁未通过:" + strings.Join(report.Violations, ";")
|
||||
}
|
||||
return fmt.Sprintf("质量门禁未通过(评分 %.0f/100)", report.Score)
|
||||
}
|
||||
|
||||
// finalizeSquadDeliverable 处理小队返回后的受控文档投影边界。
|
||||
// 质量失败必须在 SetDocumentWorkDir / Materialize / MarkDocumentContentUpdated / 完成活动之前短路。
|
||||
func (s *Service) finalizeSquadDeliverable(
|
||||
ctx context.Context,
|
||||
req TaskRunRequest,
|
||||
res *squad.SquadRunResult,
|
||||
docID string,
|
||||
docRec *docgov.DocumentRecord,
|
||||
appliedProposalID string,
|
||||
upstreamSRDoc *docgov.DocumentRecord,
|
||||
) (*TaskRunResult, error) {
|
||||
if res == nil {
|
||||
return &TaskRunResult{TaskKey: req.TaskKey, Success: false, ErrorReason: "小队无返回结果"}, nil
|
||||
}
|
||||
|
||||
// 质量门禁短路:保留隔离工作区与可见失败摘要,不改正式 doc work_dir/head/hash,不激活账本版本。
|
||||
if qualityBlocksControlledProjection(res.QualityReport) {
|
||||
return &TaskRunResult{
|
||||
TaskKey: res.TaskKey,
|
||||
ArtifactTitle: res.ArtifactTitle,
|
||||
MarkdownBody: res.MarkdownBody,
|
||||
ExtractedData: res.ExtractedData,
|
||||
Attachments: res.Attachments,
|
||||
WorkDir: res.WorkDir,
|
||||
BackendUsed: res.BackendUsed,
|
||||
Duration: res.Duration,
|
||||
Success: false,
|
||||
ErrorReason: qualityFailedErrorReason(res.QualityReport),
|
||||
QualityReport: res.QualityReport,
|
||||
Manifest: res.Manifest,
|
||||
AdvisorBrief: res.AdvisorBrief,
|
||||
DocRecord: docRec,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// RecordActivity for "完成交付物" must NOT run here: Materialize may still fail or
|
||||
// leave needs_confirmation. Activity is recorded only after a committed projection
|
||||
// (or a pending-awaiting note that is explicitly not completion).
|
||||
@@ -1077,7 +1141,7 @@ func (s *Service) executeStageInternal(ctx context.Context, req StageRunRequest)
|
||||
ForceDirectEdit: req.ForceDirectEdit,
|
||||
}
|
||||
|
||||
res, err := s.ExecuteTask(ctx, tReq)
|
||||
res, err := s.executeStageTask(ctx, tReq)
|
||||
if err != nil {
|
||||
return &StageRunResult{
|
||||
Stage: req.Stage,
|
||||
@@ -1090,48 +1154,28 @@ func (s *Service) executeStageInternal(ctx context.Context, req StageRunRequest)
|
||||
}, err
|
||||
}
|
||||
|
||||
// DocGov claimed-doc proposal intercept — stop stage; do not absorb facts.
|
||||
if res != nil && res.IsProposalIntercept() {
|
||||
return &StageRunResult{
|
||||
Stage: req.Stage,
|
||||
StageTitle: targetMeta.Title,
|
||||
Success: false,
|
||||
WorkDir: workDir,
|
||||
Duration: time.Since(start),
|
||||
ErrorReason: fmt.Sprintf("任务【%s】对应文档已被编制负责人认领,系统已拦截直接修改并生成变更提案,需编制人拍板授权", tKey),
|
||||
SubArtifacts: append(executedArtifacts, res),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// High-risk revision awaiting author confirmation — stop stage chain.
|
||||
// Do NOT absorb ExtractedData or MarkdownBody as PriorDoc (would indirectly apply unconfirmed content).
|
||||
if res != nil && res.AwaitsAgentConfirmation() {
|
||||
risk := res.PendingRiskLevel
|
||||
if risk == "" {
|
||||
risk = "high"
|
||||
if halt := classifyStageTaskHalt(res, tKey); halt != nil {
|
||||
out := &StageRunResult{
|
||||
Stage: req.Stage,
|
||||
StageTitle: targetMeta.Title,
|
||||
Success: false,
|
||||
WorkDir: workDir,
|
||||
Duration: time.Since(start),
|
||||
ErrorReason: halt.reason,
|
||||
PrimaryArtifact: res,
|
||||
SubArtifacts: append(executedArtifacts, res),
|
||||
}
|
||||
reason := fmt.Sprintf(
|
||||
"任务【%s】已生成高风险文档候选(风险:%s,候选 #%d),待编制人确认。本阶段已暂停,未将候选正文或提取事实注入后续任务;请在页面打开「待确认报告」确认或拒绝后,再重新发起本阶段。",
|
||||
tKey, risk, res.PendingAgentCandidateID,
|
||||
)
|
||||
return &StageRunResult{
|
||||
Stage: req.Stage,
|
||||
StageTitle: targetMeta.Title,
|
||||
Success: false,
|
||||
WorkDir: workDir,
|
||||
Duration: time.Since(start),
|
||||
ErrorReason: reason,
|
||||
PrimaryArtifact: res,
|
||||
SubArtifacts: append(executedArtifacts, res),
|
||||
PendingAgentCandidateID: res.PendingAgentCandidateID,
|
||||
PendingChangeReportID: res.PendingChangeReportID,
|
||||
PendingRiskLevel: res.PendingRiskLevel,
|
||||
}, nil
|
||||
if halt.pending {
|
||||
out.PendingAgentCandidateID = res.PendingAgentCandidateID
|
||||
out.PendingChangeReportID = res.PendingChangeReportID
|
||||
out.PendingRiskLevel = res.PendingRiskLevel
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
executedArtifacts = append(executedArtifacts, res)
|
||||
|
||||
// 吸收回填事实(仅已提交 / 非 pending 产物)
|
||||
// 吸收回填事实(仅已提交且过门禁的产物)
|
||||
if res.ExtractedData != nil {
|
||||
for k, v := range res.ExtractedData {
|
||||
if v != "" {
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package bizdev
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type stageTaskHalt struct {
|
||||
reason string
|
||||
pending bool
|
||||
quality bool
|
||||
}
|
||||
|
||||
func (s *Service) executeStageTask(ctx context.Context, req TaskRunRequest) (*TaskRunResult, error) {
|
||||
if s != nil && s.stageTaskHook != nil {
|
||||
return s.stageTaskHook(ctx, req)
|
||||
}
|
||||
return s.ExecuteTask(ctx, req)
|
||||
}
|
||||
|
||||
// classifyStageTaskHalt 阶段链在 ExecuteTask err=nil 后的中止判定。
|
||||
// 质量失败以 QualityReport 为准(小队 Success 仍可表示执行完成),必须停链:
|
||||
// 不吸收 ExtractedData、不追加 PriorDoc、不宣称阶段成功。
|
||||
func classifyStageTaskHalt(res *TaskRunResult, tKey string) *stageTaskHalt {
|
||||
if res == nil {
|
||||
return &stageTaskHalt{reason: fmt.Sprintf("任务【%s】无返回结果", tKey)}
|
||||
}
|
||||
if res.IsProposalIntercept() {
|
||||
return &stageTaskHalt{
|
||||
reason: fmt.Sprintf("任务【%s】对应文档已被编制负责人认领,系统已拦截直接修改并生成变更提案,需编制人拍板授权", tKey),
|
||||
}
|
||||
}
|
||||
if res.AwaitsAgentConfirmation() {
|
||||
risk := res.PendingRiskLevel
|
||||
if risk == "" {
|
||||
risk = "high"
|
||||
}
|
||||
return &stageTaskHalt{
|
||||
pending: true,
|
||||
reason: fmt.Sprintf(
|
||||
"任务【%s】已生成高风险文档候选(风险:%s,候选 #%d),待编制人确认。本阶段已暂停,未将候选正文或提取事实注入后续任务;请在页面打开「待确认报告」确认或拒绝后,再重新发起本阶段。",
|
||||
tKey, risk, res.PendingAgentCandidateID,
|
||||
),
|
||||
}
|
||||
}
|
||||
if qualityBlocksControlledProjection(res.QualityReport) || res.TerminalStatus() == TaskStatusQualityFailed {
|
||||
detail := qualityFailedErrorReason(res.QualityReport)
|
||||
if res.ErrorReason != "" && !strings.Contains(detail, res.ErrorReason) {
|
||||
detail = res.ErrorReason
|
||||
}
|
||||
return &stageTaskHalt{
|
||||
quality: true,
|
||||
reason: fmt.Sprintf(
|
||||
"任务【%s】质量门禁未通过。%s。本阶段已停止,未吸收提取事实、未将未过门禁正文作为后续 PriorDoc。",
|
||||
tKey, strings.TrimPrefix(detail, "质量门禁未通过:"),
|
||||
),
|
||||
}
|
||||
}
|
||||
if !res.Success {
|
||||
reason := res.ErrorReason
|
||||
if strings.TrimSpace(reason) == "" {
|
||||
reason = "任务未成功"
|
||||
}
|
||||
return &stageTaskHalt{
|
||||
reason: fmt.Sprintf("任务【%s】未成功:%s。本阶段已停止,未吸收提取事实、未追加 PriorDoc。", tKey, reason),
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package bizdev
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"chat-agent-system/internal/config"
|
||||
"chat-agent-system/internal/squad"
|
||||
)
|
||||
|
||||
func TestClassifyStageTaskHalt_QualityFailedStopsWithoutAbsorbing(t *testing.T) {
|
||||
res := &TaskRunResult{
|
||||
Success: false,
|
||||
TaskKey: "research",
|
||||
ArtifactTitle: "调研报告",
|
||||
MarkdownBody: "此处篇幅有限,不做展开。",
|
||||
ExtractedData: map[string]string{"device_type": "poison_from_failed_draft"},
|
||||
QualityReport: &squad.QualityReport{Passed: false, Score: 40, Violations: []string{"省略/敷衍写法"}},
|
||||
ErrorReason: "质量门禁未通过:省略/敷衍写法",
|
||||
}
|
||||
halt := classifyStageTaskHalt(res, "research")
|
||||
if halt == nil || !halt.quality {
|
||||
t.Fatalf("quality fail must halt stage, got %+v", halt)
|
||||
}
|
||||
if !strings.Contains(halt.reason, "质量门禁未通过") {
|
||||
t.Fatalf("reason=%q", halt.reason)
|
||||
}
|
||||
if !strings.Contains(halt.reason, "未吸收") || !strings.Contains(halt.reason, "PriorDoc") {
|
||||
t.Fatalf("reason must state no absorb / no PriorDoc: %q", halt.reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyStageTaskHalt_QualityFailedEvenWhenSquadSuccess(t *testing.T) {
|
||||
halt := classifyStageTaskHalt(&TaskRunResult{
|
||||
Success: true,
|
||||
MarkdownBody: "未过门禁草稿",
|
||||
ExtractedData: map[string]string{"device_type": "poison"},
|
||||
QualityReport: &squad.QualityReport{Passed: false, Score: 40},
|
||||
}, "research")
|
||||
if halt == nil || !halt.quality {
|
||||
t.Fatalf("QualityReport.Passed=false must halt even if Success=true, got %+v", halt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyStageTaskHalt_SuccessFalseWithoutQualityAlsoStops(t *testing.T) {
|
||||
halt := classifyStageTaskHalt(&TaskRunResult{Success: false, ErrorReason: "落盘失败"}, "intake")
|
||||
if halt == nil || halt.quality || halt.pending {
|
||||
t.Fatalf("generic failure must halt (not as quality/pending): %+v", halt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyStageTaskHalt_PassedSuccessContinues(t *testing.T) {
|
||||
if halt := classifyStageTaskHalt(&TaskRunResult{
|
||||
Success: true,
|
||||
QualityReport: &squad.QualityReport{Passed: true, Score: 90},
|
||||
}, "intake"); halt != nil {
|
||||
t.Fatalf("passed task must continue: %+v", halt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteStage_QualityFailed_StopsChainWithoutAbsorbingFactsOrPriorDoc(t *testing.T) {
|
||||
dataDir := t.TempDir()
|
||||
svc := MustNewService(t, &config.Config{DataDir: dataDir, AgentBackend: "stub"})
|
||||
ctx := context.Background()
|
||||
projectID := int64(6099)
|
||||
stageWork := filepath.Join(dataDir, "ws-stage-quality")
|
||||
if err := os.MkdirAll(stageWork, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
sentinelKey := "quality_must_not_overwrite"
|
||||
sentinelVal := "pre_stage_sentinel"
|
||||
var seenKeys []string
|
||||
svc.stageTaskHook = func(_ context.Context, req TaskRunRequest) (*TaskRunResult, error) {
|
||||
seenKeys = append(seenKeys, req.TaskKey)
|
||||
if req.TaskKey != "research" {
|
||||
t.Fatalf("quality fail must stop before subsequent task %q; seen=%v priors=%d",
|
||||
req.TaskKey, seenKeys, len(req.PriorDocs))
|
||||
}
|
||||
for _, p := range req.PriorDocs {
|
||||
if p.DocKey == "research" || strings.Contains(p.Content, "未过门禁") {
|
||||
t.Fatalf("must not feed failed markdown as PriorDoc: %+v", p)
|
||||
}
|
||||
}
|
||||
return &TaskRunResult{
|
||||
TaskKey: "research",
|
||||
ArtifactTitle: "调研报告",
|
||||
MarkdownBody: "# 未过门禁草稿\n此处篇幅有限,不做展开。",
|
||||
ExtractedData: map[string]string{
|
||||
sentinelKey: "poison_from_quality_fail",
|
||||
"device_type": "should_not_land_on_blackboard",
|
||||
},
|
||||
WorkDir: filepath.Join(stageWork, "research"),
|
||||
// 小队执行完成可为 true;阶段停链看 QualityReport,不依赖 Success=false
|
||||
Success: true,
|
||||
ErrorReason: "质量门禁未通过:省略/敷衍写法",
|
||||
QualityReport: &squad.QualityReport{
|
||||
Passed: false,
|
||||
Score: 40,
|
||||
Violations: []string{"省略/敷衍写法"},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
stageRes, err := svc.ExecuteStage(ctx, StageRunRequest{
|
||||
Stage: StageV05_Feasibility,
|
||||
ProjectName: "阶段链质量失败测试",
|
||||
CustomerName: "C",
|
||||
DeviceType: "医疗器械",
|
||||
IntendedUse: "诊断",
|
||||
TargetMarket: "国内",
|
||||
Fields: map[string]string{sentinelKey: sentinelVal},
|
||||
Backend: "stub",
|
||||
Timeout: 15 * time.Second,
|
||||
WorkDir: stageWork,
|
||||
ProjectID: projectID,
|
||||
UserID: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ExecuteStage unexpected hard error: %v", err)
|
||||
}
|
||||
if stageRes == nil || stageRes.Success {
|
||||
t.Fatalf("stage must fail on quality gate, got %+v", stageRes)
|
||||
}
|
||||
if !strings.Contains(stageRes.ErrorReason, "质量门禁未通过") {
|
||||
t.Fatalf("ErrorReason=%q", stageRes.ErrorReason)
|
||||
}
|
||||
if stageRes.PrimaryArtifact == nil || stageRes.PrimaryArtifact.MarkdownBody == "" {
|
||||
t.Fatal("must retain failed artifact body")
|
||||
}
|
||||
if stageRes.PrimaryArtifact.TerminalStatus() != TaskStatusQualityFailed {
|
||||
t.Fatalf("primary status=%q", stageRes.PrimaryArtifact.TerminalStatus())
|
||||
}
|
||||
if len(stageRes.SubArtifacts) != 1 || seenKeys[0] != "research" || len(seenKeys) != 1 {
|
||||
t.Fatalf("must stop after first task; sub=%d seen=%v", len(stageRes.SubArtifacts), seenKeys)
|
||||
}
|
||||
for _, next := range []string{"feasibility_dev", "feasibility_reg", "user_requirements", "prelim_design"} {
|
||||
if _, err := os.Stat(filepath.Join(stageWork, next, "output.md")); err == nil {
|
||||
t.Fatalf("subsequent task %s must not run", next)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(stageWork, "workspace.json")); err == nil {
|
||||
t.Fatal("quality stop must not write completed stage workspace.json")
|
||||
}
|
||||
|
||||
pCtx, err := svc.ContextManager().GetProjectContext(ctx, projectID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetProjectContext: %v", err)
|
||||
}
|
||||
if got := pCtx.BlackboardFacts[sentinelKey]; got != "" && got != sentinelVal {
|
||||
t.Fatalf("must not absorb failed ExtractedData onto blackboard: %q", got)
|
||||
}
|
||||
if pCtx.BlackboardFacts["device_type"] == "should_not_land_on_blackboard" {
|
||||
t.Fatal("poison extracted field landed on project blackboard")
|
||||
}
|
||||
for _, act := range pCtx.AllActivities {
|
||||
if strings.Contains(act.Title, "阶段跃迁") {
|
||||
t.Fatalf("must not record stage_advance: %q", act.Title)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -28,16 +28,20 @@ func (r *TaskRunResult) IsProposalIntercept() bool {
|
||||
|
||||
// TerminalStatus maps TaskRunResult to a conversation/workflow terminal status.
|
||||
// Pending agent confirmation is never "completed" until commit+projection.
|
||||
// Quality failure stays quality_failed even when Success was cleared to block Materialize.
|
||||
func (r *TaskRunResult) TerminalStatus() string {
|
||||
if r == nil || !r.Success {
|
||||
if r == nil {
|
||||
return TaskStatusFailed
|
||||
}
|
||||
if r.QualityReport != nil && !r.QualityReport.Passed {
|
||||
return TaskStatusQualityFailed
|
||||
}
|
||||
if !r.Success {
|
||||
return TaskStatusFailed
|
||||
}
|
||||
if r.AwaitsAgentConfirmation() {
|
||||
return TaskStatusPendingAuthorConfirmation
|
||||
}
|
||||
if r.QualityReport != nil && !r.QualityReport.Passed {
|
||||
return TaskStatusQualityFailed
|
||||
}
|
||||
return TaskStatusCompleted
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
|
||||
"chat-agent-system/internal/bizdev"
|
||||
"chat-agent-system/internal/docgov"
|
||||
"chat-agent-system/internal/squad"
|
||||
)
|
||||
|
||||
func TestTaskRunResult_PendingConfirmation_NotCompleted(t *testing.T) {
|
||||
@@ -79,3 +80,33 @@ func TestTaskRunResult_CompletedOnlyWhenCommitted(t *testing.T) {
|
||||
t.Fatalf("completed summary: %s", sum)
|
||||
}
|
||||
}
|
||||
|
||||
// 现场:score=40 / quality_failed 不得映射 completed;Success 已清仍保持 quality_failed。
|
||||
func TestTaskRunResult_QualityFailed_NotCompleted(t *testing.T) {
|
||||
res := &bizdev.TaskRunResult{
|
||||
Success: false,
|
||||
ArtifactTitle: "用户初始需求收集表",
|
||||
TaskKey: "intake",
|
||||
QualityReport: &squad.QualityReport{
|
||||
Passed: false,
|
||||
Score: 40,
|
||||
ProhibitedPhrases: []string{"不做展开", "从略", "篇幅有限"},
|
||||
Violations: []string{"检测到正文中的省略/敷衍写法(须实质展开;自检勿复述禁用原词)"},
|
||||
},
|
||||
DocRecord: &docgov.DocumentRecord{DocID: "doc_proj6098_intake"},
|
||||
}
|
||||
if res.TerminalStatus() != bizdev.TaskStatusQualityFailed {
|
||||
t.Fatalf("status=%q want quality_failed", res.TerminalStatus())
|
||||
}
|
||||
sum := res.CompletionSummary()
|
||||
if strings.Contains(sum, "已完成") {
|
||||
t.Fatalf("quality_failed summary must not claim completed: %s", sum)
|
||||
}
|
||||
if !strings.Contains(sum, "质量检查未通过") {
|
||||
t.Fatalf("summary missing quality fail wording: %s", sum)
|
||||
}
|
||||
art := res.ArtifactMetadata()
|
||||
if art["quality_passed"] != false {
|
||||
t.Fatalf("quality_passed=%v", art["quality_passed"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,26 @@ type StreamChatRequest struct {
|
||||
ClientTurnID string `json:"client_turn_id,omitempty"`
|
||||
}
|
||||
|
||||
// 长任务 SSE:不取消全局 WriteTimeout,仅在流式响应上周期性延长写期限并发心跳。
|
||||
const (
|
||||
sseWriteDeadlineExtend = 45 * time.Second
|
||||
sseHeartbeatInterval = 15 * time.Second
|
||||
)
|
||||
|
||||
func extendSSEWriteDeadline(w http.ResponseWriter) {
|
||||
rc := http.NewResponseController(w)
|
||||
_ = rc.SetWriteDeadline(time.Now().Add(sseWriteDeadlineExtend))
|
||||
}
|
||||
|
||||
func writeSSEHeartbeat(w http.ResponseWriter, flusher http.Flusher) error {
|
||||
extendSSEWriteDeadline(w)
|
||||
if _, err := fmt.Fprintf(w, ": heartbeat %d\n\n", time.Now().Unix()); err != nil {
|
||||
return err
|
||||
}
|
||||
flusher.Flush()
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeSSE(w http.ResponseWriter, flusher http.Flusher, ev stream.StreamEvent) {
|
||||
data, err := json.Marshal(ev)
|
||||
if err != nil {
|
||||
|
||||
@@ -76,6 +76,29 @@ func (s *Server) handleCreateTaskRun(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
req.WorkDir = ""
|
||||
|
||||
// 授权后、scope/preflight 前:稳定幂等键命中则直接 202,避免 SSE 断线重试误击 DOC_BUSY / 重复写聊天。
|
||||
idemKey := taskrun.BuildTaskRunIdempotencyKey(req.SessionID, req.ClientTurnID)
|
||||
if idemKey != "" && s.taskRuns != nil {
|
||||
if existing, findErr := s.taskRuns.FindByIdempotencyKey(r.Context(), idemKey); findErr != nil {
|
||||
http.Error(w, findErr.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
} else if existing != nil {
|
||||
if !taskRunMatchesIdempotentOwner(existing, req) {
|
||||
writeJSONResponse(w, http.StatusForbidden, map[string]string{
|
||||
"error_code": "ERR_FORBIDDEN",
|
||||
"message": "幂等键已存在但不属于当前用户/会话/项目",
|
||||
})
|
||||
return
|
||||
}
|
||||
writeJSONResponse(w, http.StatusAccepted, createTaskRunResponse{
|
||||
RunID: existing.RunID,
|
||||
WorkspaceID: existing.WorkspaceID,
|
||||
Status: existing.Status,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
ctxMgr := s.svc.ContextManager()
|
||||
if ctxMgr != nil {
|
||||
_, _ = ctxMgr.EnsureChatSession(r.Context(), req.SessionID, req.UserID, req.ProjectID, req.ProjectName)
|
||||
@@ -104,7 +127,6 @@ func (s *Server) handleCreateTaskRun(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// 幂等两阶段:先 Claim 占位,仅 owner 写聊天并 Start;loser 不得写消息。
|
||||
idemKey := taskrun.BuildTaskRunIdempotencyKey(req.SessionID, req.ClientTurnID)
|
||||
runID := fmt.Sprintf("run_%d", time.Now().UnixNano())
|
||||
record := &taskrun.RunRecord{
|
||||
RunID: runID,
|
||||
@@ -184,6 +206,16 @@ func (s *Server) handleCreateTaskRun(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
func taskRunMatchesIdempotentOwner(existing *taskrun.RunRecord, req createTaskRunRequest) bool {
|
||||
if existing == nil {
|
||||
return false
|
||||
}
|
||||
return existing.UserID == req.UserID &&
|
||||
existing.SessionID == req.SessionID &&
|
||||
existing.ProjectID == req.ProjectID &&
|
||||
existing.ClientTurnID == req.ClientTurnID
|
||||
}
|
||||
|
||||
func (s *Server) recordBusyPreflightMessages(
|
||||
ctx context.Context,
|
||||
ctxMgr *ctxmgr.ContextManager,
|
||||
@@ -310,6 +342,7 @@ func (s *Server) handleTaskRunEvents(w http.ResponseWriter, r *http.Request) {
|
||||
s.writeCORS(w, r)
|
||||
w.Header().Set("X-Accel-Buffering", "no")
|
||||
flusher.Flush()
|
||||
extendSSEWriteDeadline(w)
|
||||
|
||||
ch, replay, err := s.taskRuns.Subscribe(runID, afterSeq)
|
||||
if err != nil {
|
||||
@@ -318,26 +351,48 @@ func (s *Server) handleTaskRunEvents(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
defer s.taskRuns.Unsubscribe(runID, ch)
|
||||
|
||||
writeStoredEvents := func(events []taskrun.StoredEvent) {
|
||||
writeStoredEvents := func(events []taskrun.StoredEvent) error {
|
||||
for _, stored := range events {
|
||||
data, _ := json.Marshal(stored.Event)
|
||||
fmt.Fprintf(w, "id: %d\n", stored.Seq)
|
||||
fmt.Fprintf(w, "event: %s\ndata: %s\n\n", stored.Event.Type, string(data))
|
||||
if _, err := fmt.Fprintf(w, "id: %d\n", stored.Seq); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintf(w, "event: %s\ndata: %s\n\n", stored.Event.Type, string(data)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
flusher.Flush()
|
||||
extendSSEWriteDeadline(w)
|
||||
return nil
|
||||
}
|
||||
|
||||
writeStoredEvents(replay)
|
||||
if err := writeStoredEvents(replay); err != nil {
|
||||
return
|
||||
}
|
||||
for _, stored := range replay {
|
||||
if stored.Event.Type == stream.EventDone || stored.Event.Type == stream.EventError {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
heartbeat := time.NewTicker(sseHeartbeatInterval)
|
||||
defer heartbeat.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-r.Context().Done():
|
||||
return
|
||||
case <-heartbeat.C:
|
||||
if err := writeSSEHeartbeat(w, flusher); err != nil {
|
||||
return
|
||||
}
|
||||
case stored, ok := <-ch:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
writeStoredEvents([]taskrun.StoredEvent{stored})
|
||||
if err := writeStoredEvents([]taskrun.StoredEvent{stored}); err != nil {
|
||||
return
|
||||
}
|
||||
if stored.Event.Type == stream.EventDone || stored.Event.Type == stream.EventError {
|
||||
return
|
||||
}
|
||||
@@ -523,7 +578,7 @@ func (s *Server) buildBizdevTaskRequest(dto TaskRunRequestDTO, sessionID, client
|
||||
Fields: dto.Fields,
|
||||
Materials: dto.Materials,
|
||||
Backend: dto.Backend,
|
||||
AutoRepair: dto.AutoRepair,
|
||||
AutoRepair: true, // 产品契约:受控小队任务服务端启用修复,不依赖浏览器字段
|
||||
SessionID: sessionID,
|
||||
UserID: dto.UserID,
|
||||
ProjectID: dto.ProjectID,
|
||||
@@ -552,7 +607,7 @@ func (s *Server) buildBizdevStageRequest(dto TaskRunRequestDTO, sessionID, clien
|
||||
Fields: dto.Fields,
|
||||
Materials: dto.Materials,
|
||||
Backend: dto.Backend,
|
||||
AutoRepair: dto.AutoRepair,
|
||||
AutoRepair: true, // 产品契约:阶段任务同样启用修复预算
|
||||
SessionID: sessionID,
|
||||
UserID: dto.UserID,
|
||||
ProjectID: dto.ProjectID,
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"chat-agent-system/internal/bizdev"
|
||||
"chat-agent-system/internal/docgov"
|
||||
"chat-agent-system/internal/stream"
|
||||
)
|
||||
|
||||
func TestCreateTaskRun_SameTurnIdempotentReturnsExistingDespiteBusyLease(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
ctx := t.Context()
|
||||
token := loginTestAdmin(t, srv)
|
||||
userID := int64(1)
|
||||
|
||||
// 慢执行器:保持 run 在内存中可被 FindByIdempotencyKey 命中
|
||||
block := make(chan struct{})
|
||||
t.Cleanup(func() { close(block) })
|
||||
srv.taskRuns.SetExecutor(
|
||||
func(ctx context.Context, _ string, _ bizdev.TaskRunRequest, _ *stream.Streamer) (*bizdev.TaskRunResult, error) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return &bizdev.TaskRunResult{Success: false}, ctx.Err()
|
||||
case <-block:
|
||||
return &bizdev.TaskRunResult{Success: true}, nil
|
||||
}
|
||||
},
|
||||
nil,
|
||||
func(ctx context.Context, _ string, _ bizdev.StageRunRequest, _ *stream.Streamer) (*bizdev.TaskRunResult, error) {
|
||||
return &bizdev.TaskRunResult{Success: true}, nil
|
||||
},
|
||||
)
|
||||
|
||||
docID := "doc_proj143_intake"
|
||||
if _, err := srv.svc.DocGov().CreateOrGetDocument(
|
||||
ctx, docID, 143, "intake", "用户初始需求收集表", "V0.1_triage", "",
|
||||
userID, "编制人", userID, "审核人", userID, "批准人",
|
||||
); err != nil {
|
||||
t.Fatalf("create document: %v", err)
|
||||
}
|
||||
|
||||
body1, _ := json.Marshal(createTaskRunRequest{
|
||||
TaskRunRequestDTO: TaskRunRequestDTO{TaskKey: "intake", ProjectID: 143, ProjectName: "幂等项目", DocID: docID},
|
||||
SessionID: "sess-idem-busy",
|
||||
ClientTurnID: "turn-same-1",
|
||||
UserMessage: "请编制首轮摸底",
|
||||
})
|
||||
req1 := httptest.NewRequest(http.MethodPost, "/api/v1/tasks/runs", bytes.NewReader(body1))
|
||||
withBearerAuth(req1, token)
|
||||
w1 := httptest.NewRecorder()
|
||||
srv.Routes().ServeHTTP(w1, req1)
|
||||
if w1.Code != http.StatusAccepted {
|
||||
t.Fatalf("first create: %d %s", w1.Code, w1.Body.String())
|
||||
}
|
||||
var first createTaskRunResponse
|
||||
if err := json.Unmarshal(w1.Body.Bytes(), &first); err != nil {
|
||||
t.Fatalf("decode first: %v", err)
|
||||
}
|
||||
if first.RunID == "" {
|
||||
t.Fatal("missing run_id")
|
||||
}
|
||||
|
||||
hist1, err := srv.svc.ContextManager().GetChatHistory(ctx, "sess-idem-busy", 50, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("history1: %v", err)
|
||||
}
|
||||
if len(hist1) != 2 {
|
||||
t.Fatalf("after first create history=%d want 2", len(hist1))
|
||||
}
|
||||
|
||||
// 活跃小队租约:模拟长任务仍占文档(旧路径会 409)
|
||||
if _, err := srv.svc.DocGov().AcquireLease(ctx, docgov.LeaseAcquireRequest{
|
||||
DocID: docID, HolderType: docgov.LeaseHolderSquad, HolderID: first.RunID, TTL: time.Minute,
|
||||
}); err != nil {
|
||||
t.Fatalf("acquire squad lease: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = srv.svc.DocGov().ReleaseLease(context.Background(), docID, string(docgov.LeaseHolderSquad), first.RunID)
|
||||
})
|
||||
|
||||
body2, _ := json.Marshal(createTaskRunRequest{
|
||||
TaskRunRequestDTO: TaskRunRequestDTO{TaskKey: "intake", ProjectID: 143, ProjectName: "幂等项目", DocID: docID},
|
||||
SessionID: "sess-idem-busy",
|
||||
ClientTurnID: "turn-same-1",
|
||||
UserMessage: "请编制首轮摸底",
|
||||
})
|
||||
req2 := httptest.NewRequest(http.MethodPost, "/api/v1/tasks/runs", bytes.NewReader(body2))
|
||||
withBearerAuth(req2, token)
|
||||
w2 := httptest.NewRecorder()
|
||||
srv.Routes().ServeHTTP(w2, req2)
|
||||
if w2.Code != http.StatusAccepted {
|
||||
t.Fatalf("same-turn retry want 202, got %d %s", w2.Code, w2.Body.String())
|
||||
}
|
||||
var second createTaskRunResponse
|
||||
_ = json.Unmarshal(w2.Body.Bytes(), &second)
|
||||
if second.RunID != first.RunID {
|
||||
t.Fatalf("want same run %s, got %s", first.RunID, second.RunID)
|
||||
}
|
||||
|
||||
hist2, err := srv.svc.ContextManager().GetChatHistory(ctx, "sess-idem-busy", 50, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("history2: %v", err)
|
||||
}
|
||||
if len(hist2) != 2 {
|
||||
t.Fatalf("idempotent retry must not append chat; history=%d", len(hist2))
|
||||
}
|
||||
|
||||
// 不同 turn:仍应 DOC_BUSY 409
|
||||
body3, _ := json.Marshal(createTaskRunRequest{
|
||||
TaskRunRequestDTO: TaskRunRequestDTO{TaskKey: "intake", ProjectID: 143, ProjectName: "幂等项目", DocID: docID},
|
||||
SessionID: "sess-idem-busy",
|
||||
ClientTurnID: "turn-other-2",
|
||||
UserMessage: "另一轮摸底",
|
||||
})
|
||||
req3 := httptest.NewRequest(http.MethodPost, "/api/v1/tasks/runs", bytes.NewReader(body3))
|
||||
withBearerAuth(req3, token)
|
||||
w3 := httptest.NewRecorder()
|
||||
srv.Routes().ServeHTTP(w3, req3)
|
||||
if w3.Code != http.StatusConflict {
|
||||
t.Fatalf("other turn want 409, got %d %s", w3.Code, w3.Body.String())
|
||||
}
|
||||
var busy map[string]any
|
||||
_ = json.Unmarshal(w3.Body.Bytes(), &busy)
|
||||
if busy["error_code"] != "DOC_BUSY" {
|
||||
t.Fatalf("want DOC_BUSY, got %+v", busy)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSEHeartbeat_ExtendsPastServerWriteTimeout(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/sse", func(w http.ResponseWriter, r *http.Request) {
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
http.Error(w, "no flush", 500)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
flusher.Flush()
|
||||
extendSSEWriteDeadline(w)
|
||||
tick := time.NewTicker(60 * time.Millisecond)
|
||||
defer tick.Stop()
|
||||
end := time.After(700 * time.Millisecond)
|
||||
for {
|
||||
select {
|
||||
case <-end:
|
||||
_, _ = fmt.Fprintf(w, "event: done\ndata: {\"type\":\"done\"}\n\n")
|
||||
flusher.Flush()
|
||||
return
|
||||
case <-tick.C:
|
||||
if err := writeSSEHeartbeat(w, flusher); err != nil {
|
||||
return
|
||||
}
|
||||
case <-r.Context().Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srv := &http.Server{
|
||||
Handler: mux,
|
||||
WriteTimeout: 200 * time.Millisecond, // 短于流时长;无 extend 会断
|
||||
}
|
||||
go func() { _ = srv.Serve(ln) }()
|
||||
t.Cleanup(func() { _ = srv.Close() })
|
||||
|
||||
resp, err := http.Get("http://" + ln.Addr().String() + "/sse")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read body: %v", err)
|
||||
}
|
||||
body := string(raw)
|
||||
if !strings.Contains(body, "heartbeat") {
|
||||
t.Fatalf("expected heartbeats, body=%q", body)
|
||||
}
|
||||
if !strings.Contains(body, "event: done") {
|
||||
t.Fatalf("stream should survive WriteTimeout via deadline extend; body=%q", body)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"chat-agent-system/internal/bizdev"
|
||||
)
|
||||
|
||||
func TestBuildBizdevStageRequest_PreservesIdentityAndRoles(t *testing.T) {
|
||||
s := &Server{}
|
||||
dto := TaskRunRequestDTO{
|
||||
Stage: "v0.5_feasibility",
|
||||
ProjectName: "P",
|
||||
CustomerName: "C",
|
||||
DeviceType: "D",
|
||||
IntendedUse: "U",
|
||||
TargetMarket: "M",
|
||||
Backend: "stub",
|
||||
AutoRepair: false,
|
||||
UserID: 107,
|
||||
ProjectID: 6098,
|
||||
DocID: "doc_proj6098_intake",
|
||||
AuthorID: 1,
|
||||
AuthorName: "编制人",
|
||||
ReviewerID: 2,
|
||||
ReviewerName: "审核人",
|
||||
ApproverID: 3,
|
||||
ApproverName: "批准人",
|
||||
ForceDirectEdit: true,
|
||||
}
|
||||
got := s.buildBizdevStageRequest(dto, "sess-p6098-u107", "turn_x", "run_y")
|
||||
if !got.AutoRepair {
|
||||
t.Fatal("product contract: AutoRepair must be forced true")
|
||||
}
|
||||
if got.SessionID != "sess-p6098-u107" || got.ClientTurnID != "turn_x" || got.RunID != "run_y" {
|
||||
t.Fatalf("session/turn/run dropped: %+v", got)
|
||||
}
|
||||
if got.UserID != 107 || got.ProjectID != 6098 || got.DocID != "doc_proj6098_intake" {
|
||||
t.Fatalf("identity fields dropped: %+v", got)
|
||||
}
|
||||
if got.AuthorID != 1 || got.ReviewerID != 2 || got.ApproverID != 3 || !got.ForceDirectEdit {
|
||||
t.Fatalf("role fields dropped: %+v", got)
|
||||
}
|
||||
if got.Stage != bizdev.MaturityStage("v0.5_feasibility") {
|
||||
t.Fatalf("stage=%q", got.Stage)
|
||||
}
|
||||
}
|
||||
@@ -77,11 +77,12 @@ func (q *QualityGate) Validate(wsDir string, taskKey string, content string) (*Q
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 检查敷衍违规套话
|
||||
// 2. 检查敷衍违规套话(否定性自检/禁令复述不计入,避免门禁自污染)
|
||||
for _, phrase := range ProhibitedPhrases {
|
||||
if strings.Contains(content, phrase) {
|
||||
if containsProhibitedUsage(content, phrase) {
|
||||
report.ProhibitedPhrases = append(report.ProhibitedPhrases, phrase)
|
||||
report.Violations = append(report.Violations, fmt.Sprintf("检测到敷衍违规词: '%s'", phrase))
|
||||
// 违规说明勿回写禁用原词,避免修复指令/自检再次自污染
|
||||
report.Violations = append(report.Violations, "检测到正文中的省略/敷衍写法(须实质展开;自检勿复述禁用原词)")
|
||||
report.Score -= 20.0
|
||||
report.Passed = false
|
||||
}
|
||||
@@ -155,6 +156,105 @@ func (q *QualityGate) Validate(wsDir string, taskKey string, content string) (*Q
|
||||
return report, nil
|
||||
}
|
||||
|
||||
// sanitizeViolationForAgent 修复指令中不得原样回写禁用套话,避免二次自污染。
|
||||
func sanitizeViolationForAgent(v string) string {
|
||||
out := v
|
||||
for _, phrase := range ProhibitedPhrases {
|
||||
if phrase != "" && strings.Contains(out, phrase) {
|
||||
out = strings.ReplaceAll(out, phrase, "「省略/敷衍写法」")
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// containsProhibitedUsage 仅当禁词被用作正文省略/敷衍时命中。
|
||||
// 否定性自检、写作禁令复述(如:无"…"等敷衍套话)不得误报。
|
||||
func containsProhibitedUsage(content, phrase string) bool {
|
||||
if phrase == "" || content == "" {
|
||||
return false
|
||||
}
|
||||
searchFrom := 0
|
||||
for {
|
||||
rel := strings.Index(content[searchFrom:], phrase)
|
||||
if rel < 0 {
|
||||
return false
|
||||
}
|
||||
abs := searchFrom + rel
|
||||
if !isNegatedOrMetaProhibitedCitation(content, abs, phrase) {
|
||||
return true
|
||||
}
|
||||
searchFrom = abs + len(phrase)
|
||||
}
|
||||
}
|
||||
|
||||
func isNegatedOrMetaProhibitedCitation(content string, idx int, phrase string) bool {
|
||||
lineStart := strings.LastIndex(content[:idx], "\n") + 1
|
||||
lineEnd := strings.Index(content[idx:], "\n")
|
||||
if lineEnd < 0 {
|
||||
lineEnd = len(content)
|
||||
} else {
|
||||
lineEnd = idx + lineEnd
|
||||
}
|
||||
line := content[lineStart:lineEnd]
|
||||
localIdx := idx - lineStart
|
||||
if localIdx < 0 || localIdx > len(line) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 仅同一自检/规则行,且须同时具备元标注 + 否定词紧邻或引号引用结构。
|
||||
// 禁止用「48 字窗口出现 无」豁免:正文「此处无更多数据,不做展开」必须仍命中。
|
||||
if !lineHasProhibitedMetaMarker(line) {
|
||||
return false
|
||||
}
|
||||
return phraseQuotedOnLine(line, phrase) || negationAdjacentToPhrase(line, localIdx)
|
||||
}
|
||||
|
||||
func lineHasProhibitedMetaMarker(line string) bool {
|
||||
metaMarkers := []string{
|
||||
"写作禁令", "敷衍套话", "省略套话", "省略敷衍", "禁止使用", "严禁写",
|
||||
"负向红线", "审核结论", "遵守情况", "交付契约", "写作纪律", "验收标准",
|
||||
}
|
||||
for _, m := range metaMarkers {
|
||||
if strings.Contains(line, m) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func phraseQuotedOnLine(line, phrase string) bool {
|
||||
if phrase == "" {
|
||||
return false
|
||||
}
|
||||
wrappers := [][2]string{
|
||||
{`"`, `"`},
|
||||
{"「", "」"},
|
||||
{`'`, `'`},
|
||||
{"“", "”"},
|
||||
{"‘", "’"},
|
||||
}
|
||||
for _, w := range wrappers {
|
||||
if strings.Contains(line, w[0]+phrase+w[1]) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func negationAdjacentToPhrase(line string, phraseIdx int) bool {
|
||||
if phraseIdx <= 0 || phraseIdx > len(line) {
|
||||
return false
|
||||
}
|
||||
prefix := strings.TrimRight(line[:phraseIdx], " \t\"'「“‘")
|
||||
negMarkers := []string{"无", "禁止", "严禁", "不得", "勿", "杜绝", "没有", "不含", "未使用", "不出现", "不应出现"}
|
||||
for _, n := range negMarkers {
|
||||
if strings.HasSuffix(prefix, n) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// GenerateManifest 扫描工作区核心文件生成 SHA-256 指纹与防篡改审计清单
|
||||
func (q *QualityGate) GenerateManifest(wsDir string, docKey string, report QualityReport) (*Manifest, error) {
|
||||
now := time.Now()
|
||||
@@ -276,7 +376,7 @@ func (q *QualityGate) EnsureQualityOrRepair(
|
||||
acceptCriteria = append(acceptCriteria, "补齐缺失章节: "+strings.Join(report.MissingSections, ", "))
|
||||
}
|
||||
if len(report.ProhibitedPhrases) > 0 {
|
||||
acceptCriteria = append(acceptCriteria, "清除敷衍违规词: "+strings.Join(report.ProhibitedPhrases, ", "))
|
||||
acceptCriteria = append(acceptCriteria, "清除正文中实际使用的省略/敷衍写法;自检表勿复述禁用原词")
|
||||
}
|
||||
for _, v := range report.Violations {
|
||||
acceptCriteria = append(acceptCriteria, "解决违规点: "+v)
|
||||
@@ -302,7 +402,7 @@ func (q *QualityGate) EnsureQualityOrRepair(
|
||||
b.WriteString(fmt.Sprintf("# 质量门禁修复任务 (第 %d 次修正)\n\n", attempt))
|
||||
b.WriteString("前序提交未通过自动化质量门禁,检测到的问题如下,请针对性修复:\n")
|
||||
for _, v := range report.Violations {
|
||||
b.WriteString(fmt.Sprintf("- ❌ %s\n", v))
|
||||
b.WriteString(fmt.Sprintf("- ❌ %s\n", sanitizeViolationForAgent(v)))
|
||||
}
|
||||
// 治理:停滞升级——上轮修复产物与再上轮几乎相同,注入反重复指令换策略
|
||||
if stallHinted {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -201,3 +202,89 @@ func TestQualityGate_SystemReqsAndTestManager(t *testing.T) {
|
||||
t.Errorf("expected compliant test_manager to pass, got violations: %v", rep4.Violations)
|
||||
}
|
||||
}
|
||||
|
||||
// 现场证据 output.md:204 — 终稿自检表否定性引用禁词,不得误报为敷衍违规。
|
||||
func TestQualityGate_NegatedSelfCheckCitation_NoFalsePositive(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
engine := squad.NewWorkspaceEngine()
|
||||
ws, err := engine.PrepareWorkspace(squad.WorkspacePrepareRequest{
|
||||
WorkDir: tmpDir,
|
||||
TaskKey: "intake",
|
||||
StageTitle: "用户初始需求收集表",
|
||||
ProjectName: "医疗设备",
|
||||
TemplateContent: "# 用户初始需求收集表\n\n## 1. 客户与项目画像\n\n## 2. 预期用途\n\n## 3. 待确认清单\n",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("PrepareWorkspace: %v", err)
|
||||
}
|
||||
gate := squad.NewQualityGate()
|
||||
|
||||
// 与 data/dev/workspaces/bizdev/intake_1789308825666278807/output.md 第204行同构
|
||||
realChecklistLine := `| 写作禁令遵守情况 | ✅ 通过 | 无"不做展开""从略""篇幅有限"等敷衍套话 |`
|
||||
content := `# 用户初始需求收集表
|
||||
|
||||
## 1. 客户与项目画像
|
||||
本项目客户为专业医疗机构〔材料: 需求表 §1〕。四段式展开详细论述客户背景与项目边界。
|
||||
|
||||
## 2. 预期用途
|
||||
用于临床辅助诊断与生理信号监测,覆盖病房与门诊场景。
|
||||
|
||||
## 3. 待确认清单
|
||||
- 供电模块认证周期待窗口确认。
|
||||
|
||||
### 附录C:审核记录(Review Record)
|
||||
|
||||
| 审核项 | 审核结论 | 说明 |
|
||||
|-------|---------|------|
|
||||
| 全文逻辑一致性 | ✅ 通过 | 结论→正文→解读完整 |
|
||||
` + realChecklistLine + "\n"
|
||||
|
||||
report, err := gate.Validate(ws.WorkDir, "intake", content)
|
||||
if err != nil {
|
||||
t.Fatalf("Validate: %v", err)
|
||||
}
|
||||
if !report.Passed {
|
||||
t.Fatalf("negated self-check must not fail gate; violations=%v phrases=%v score=%.0f",
|
||||
report.Violations, report.ProhibitedPhrases, report.Score)
|
||||
}
|
||||
if len(report.ProhibitedPhrases) != 0 {
|
||||
t.Fatalf("expected no prohibited hits on meta citation, got %v", report.ProhibitedPhrases)
|
||||
}
|
||||
|
||||
// 真敷衍仍须命中
|
||||
hedging := "# 用户初始需求收集表\n\n## 1. 客户与项目画像\n此处篇幅有限,不做展开。\n\n## 2. 预期用途\nok\n\n## 3. 待确认清单\n- x\n"
|
||||
bad, err := gate.Validate(ws.WorkDir, "intake", hedging)
|
||||
if err != nil {
|
||||
t.Fatalf("Validate hedging: %v", err)
|
||||
}
|
||||
if bad.Passed || len(bad.ProhibitedPhrases) == 0 {
|
||||
t.Fatalf("real hedging must fail; passed=%v phrases=%v", bad.Passed, bad.ProhibitedPhrases)
|
||||
}
|
||||
for _, v := range bad.Violations {
|
||||
for _, p := range squad.ProhibitedPhrases {
|
||||
if strings.Contains(v, p) {
|
||||
t.Fatalf("violation text must not echo banned phrase %q: %s", p, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 正文出现「无」但非自检引用:不得因 48 字窗口豁免
|
||||
falseNeg := "# 用户初始需求收集表\n\n## 1. 客户与项目画像\n此处无更多数据,不做展开。\n\n## 2. 预期用途\nok\n\n## 3. 待确认清单\n- x\n"
|
||||
fn, err := gate.Validate(ws.WorkDir, "intake", falseNeg)
|
||||
if err != nil {
|
||||
t.Fatalf("Validate false-neg: %v", err)
|
||||
}
|
||||
if fn.Passed || len(fn.ProhibitedPhrases) == 0 {
|
||||
t.Fatalf("body hedging with 无 must still fail; passed=%v phrases=%v", fn.Passed, fn.ProhibitedPhrases)
|
||||
}
|
||||
|
||||
// 仅有审核栏标题、禁词作正文省略:不得豁免
|
||||
metaOnly := "# 用户初始需求收集表\n\n## 1. 客户与项目画像\n审核结论:本节不做展开。\n\n## 2. 预期用途\nok\n\n## 3. 待确认清单\n- x\n"
|
||||
mo, err := gate.Validate(ws.WorkDir, "intake", metaOnly)
|
||||
if err != nil {
|
||||
t.Fatalf("Validate meta-only: %v", err)
|
||||
}
|
||||
if mo.Passed || len(mo.ProhibitedPhrases) == 0 {
|
||||
t.Fatalf("meta heading plus real hedging must fail; passed=%v phrases=%v", mo.Passed, mo.ProhibitedPhrases)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package squad
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestRepairBudgetForSquadTask_RespectsAutoRepairOnly(t *testing.T) {
|
||||
got := repairBudgetForSquadTask(SquadRunRequest{TaskKey: "intake", AutoRepair: false})
|
||||
if got != 0 {
|
||||
t.Fatalf("TaskKey alone must not enable repair: budget=%d", got)
|
||||
}
|
||||
got = repairBudgetForSquadTask(SquadRunRequest{TaskKey: "", AutoRepair: false})
|
||||
if got != 0 {
|
||||
t.Fatalf("empty TaskKey without AutoRepair budget=%d want 0", got)
|
||||
}
|
||||
got = repairBudgetForSquadTask(SquadRunRequest{TaskKey: "intake", AutoRepair: true})
|
||||
if got != DefaultMaxRepairRounds {
|
||||
t.Fatalf("AutoRepair=true budget=%d want %d", got, DefaultMaxRepairRounds)
|
||||
}
|
||||
if DefaultMaxRepairRounds < 1 {
|
||||
t.Fatalf("DefaultMaxRepairRounds must be >=1, got %d", DefaultMaxRepairRounds)
|
||||
}
|
||||
}
|
||||
@@ -107,7 +107,7 @@ func (r *RoleRegistry) registerDefaults() {
|
||||
Tools: []string{"read", "write", "edit"},
|
||||
BashPermission: "deny",
|
||||
Responsibilities: []string{"严格四段式成文", "依据材料出处引用", "模板各章节写满", "撰写正式交付文档正文终稿"},
|
||||
ForbiddenActions: []string{"严禁写不做展开或从略", "严禁随意篡改前序事实"},
|
||||
ForbiddenActions: []string{"严禁以省略写法跳过应写章节", "严禁随意篡改前序事实"},
|
||||
InstructionsFile: "configs/agents/writer.md",
|
||||
},
|
||||
{
|
||||
|
||||
+31
-20
@@ -32,22 +32,22 @@ type Attachment struct {
|
||||
|
||||
// SquadRunRequest 运行请求
|
||||
type SquadRunRequest struct {
|
||||
TaskKey string `json:"task_key"`
|
||||
Title string `json:"title,omitempty"` // 自由任务/非标任务的人类可读标题(缺省回落 TaskArtifactTitle)
|
||||
ProjectName string `json:"project_name"`
|
||||
CustomerName string `json:"customer_name"`
|
||||
DeviceType string `json:"device_type"`
|
||||
IntendedUse string `json:"intended_use"`
|
||||
TargetMarket string `json:"target_market"`
|
||||
Fields map[string]string `json:"fields"`
|
||||
Materials []MaterialItem `json:"materials"`
|
||||
Backend string `json:"backend"`
|
||||
Timeout time.Duration `json:"timeout"`
|
||||
WorkDir string `json:"work_dir"`
|
||||
CustomSteps []StepSpec `json:"custom_steps"`
|
||||
AutoRepair bool `json:"auto_repair"`
|
||||
KnowledgeHits []KnowledgeHit `json:"knowledge_hits"`
|
||||
PriorDocs []PriorDocItem `json:"prior_docs"`
|
||||
TaskKey string `json:"task_key"`
|
||||
Title string `json:"title,omitempty"` // 自由任务/非标任务的人类可读标题(缺省回落 TaskArtifactTitle)
|
||||
ProjectName string `json:"project_name"`
|
||||
CustomerName string `json:"customer_name"`
|
||||
DeviceType string `json:"device_type"`
|
||||
IntendedUse string `json:"intended_use"`
|
||||
TargetMarket string `json:"target_market"`
|
||||
Fields map[string]string `json:"fields"`
|
||||
Materials []MaterialItem `json:"materials"`
|
||||
Backend string `json:"backend"`
|
||||
Timeout time.Duration `json:"timeout"`
|
||||
WorkDir string `json:"work_dir"`
|
||||
CustomSteps []StepSpec `json:"custom_steps"`
|
||||
AutoRepair bool `json:"auto_repair"`
|
||||
KnowledgeHits []KnowledgeHit `json:"knowledge_hits"`
|
||||
PriorDocs []PriorDocItem `json:"prior_docs"`
|
||||
UserContext string `json:"user_context,omitempty"`
|
||||
MemoryBankFiles map[string]string `json:"memory_bank_files,omitempty"`
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
@@ -267,10 +267,7 @@ func (s *Service) ExecuteTask(ctx context.Context, req SquadRunRequest) (*SquadR
|
||||
if streamer != nil {
|
||||
streamer.EmitPhase("quality_gate", "审核主管正在核验交付物规范并执行质量门禁把关...", "leader", 0, 0, 85)
|
||||
}
|
||||
maxRetries := 0
|
||||
if req.AutoRepair {
|
||||
maxRetries = 2
|
||||
}
|
||||
maxRetries := repairBudgetForSquadTask(req)
|
||||
report, err := s.gate.EnsureQualityOrRepair(ctx, workDir, req.TaskKey, s.driver, backend, maxRetries, execOpts)
|
||||
if err != nil {
|
||||
// 记录门禁错误但容错返回当前正文
|
||||
@@ -298,6 +295,11 @@ func (s *Service) ExecuteTask(ctx context.Context, req SquadRunRequest) (*SquadR
|
||||
if backendName == "stub" {
|
||||
finalBytes, _ = os.ReadFile(filepath.Join(workDir, "template.md"))
|
||||
_ = os.WriteFile(finalOutPath, finalBytes, 0o644)
|
||||
// stub 终稿以 template 为准:QualityReport 对齐最终交付物(Success 仍只表示执行完成)
|
||||
if reReport, reErr := s.gate.Validate(workDir, req.TaskKey, string(finalBytes)); reErr == nil {
|
||||
report = reReport
|
||||
manifest, _ = s.gate.GenerateManifest(workDir, req.TaskKey, *report)
|
||||
}
|
||||
}
|
||||
|
||||
bb, _ := s.driver.LoadBlackboard(workDir)
|
||||
@@ -361,6 +363,15 @@ func (s *Service) ExecuteTask(ctx context.Context, req SquadRunRequest) (*SquadR
|
||||
}, nil
|
||||
}
|
||||
|
||||
// repairBudgetForSquadTask 仅尊重请求方 AutoRepair,不因 TaskKey 非空而改写 CLI/桩/非受控语义。
|
||||
// 受控文档任务的修复预算由 bizdev/HTTP 在调用前强制 AutoRepair=true。
|
||||
func repairBudgetForSquadTask(req SquadRunRequest) int {
|
||||
if req.AutoRepair {
|
||||
return DefaultMaxRepairRounds
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// ExecuteWorkflow 执行既有工作区工作流
|
||||
func (s *Service) ExecuteWorkflow(ctx context.Context, wsDir string, backendName string, opts ExecuteOptions) (*SquadRunResult, error) {
|
||||
start := time.Now()
|
||||
|
||||
@@ -299,7 +299,7 @@ func (e *WorkspaceEngine) buildContextSystem(workDir string, req WorkspacePrepar
|
||||
// 3. agent.md
|
||||
agentContent := req.AgentContext
|
||||
if strings.TrimSpace(agentContent) == "" {
|
||||
agentContent = "# 智能体小队协同通用规范\n\n- 事实闭环: 严禁凭空捏造未提供之参数或法规\n- 引用规范: 引用必须标注出处〔材料:...〕或〔法规:...〕\n- 负向红线: 禁止敷衍写作、禁止'不做展开'、'从略'\n"
|
||||
agentContent = "# 智能体小队协同通用规范\n\n- 事实闭环: 严禁凭空捏造未提供之参数或法规\n- 引用规范: 引用必须标注出处〔材料:...〕或〔法规:...〕\n- 负向红线: 禁止敷衍写作;各章节须实质展开,不得以篇幅或省略写法跳过应写内容\n"
|
||||
}
|
||||
_ = os.WriteFile(filepath.Join(ctxDir, "agent.md"), []byte(agentContent), 0o644)
|
||||
contextItems = append(contextItems, ContextItem{
|
||||
@@ -538,7 +538,7 @@ func (e *WorkspaceEngine) buildSubWorkspaces(workDir string, wf WorkflowPlan, sq
|
||||
taskBuilder.WriteString("## 交付契约与写作纪律\n")
|
||||
taskBuilder.WriteString(fmt.Sprintf("请在当前目录下生成完整交付物 `%s`:\n", deliverableName))
|
||||
taskBuilder.WriteString("1. 严格四段式展开(结论句 → 表格/列表 → 解读 → 支撑出处)。\n")
|
||||
taskBuilder.WriteString("2. 禁止使用'不做展开'、'从略'、'篇幅有限'等省略敷衍套话。\n")
|
||||
taskBuilder.WriteString("2. 各章节必须实质展开;禁止以篇幅借口或省略写法跳过应写细节(自检时勿复述禁用原词)。\n")
|
||||
taskBuilder.WriteString("3. 关键事实必须标注出处,未知项列入待确认清单。\n")
|
||||
if step.Role == "organizer" {
|
||||
taskBuilder.WriteString("4. 从材料提取的客观字段,请在当前工作目录下输出 `fields.json`。\n")
|
||||
@@ -585,7 +585,7 @@ func DefaultSquadAgents(taskKey string) []AgentSpec {
|
||||
Tools: []string{"read", "write", "edit"},
|
||||
BashPermission: "deny",
|
||||
Responsibilities: []string{"严格四段式成文", "依据材料出处引用", "模板各章节写满"},
|
||||
ForbiddenActions: []string{"严禁写不做展开或从略", "严禁随意篡改前序事实"},
|
||||
ForbiddenActions: []string{"严禁以省略写法跳过应写章节", "严禁随意篡改前序事实"},
|
||||
},
|
||||
{
|
||||
Role: "leader",
|
||||
@@ -633,7 +633,7 @@ func DefaultSquadAgents(taskKey string) []AgentSpec {
|
||||
Tools: []string{"read", "write", "edit"},
|
||||
BashPermission: "deny",
|
||||
Responsibilities: []string{"按计划合成调研报告", "全面四段式展开"},
|
||||
ForbiddenActions: []string{"严禁写不做展开或从略"},
|
||||
ForbiddenActions: []string{"严禁以省略写法跳过应写章节"},
|
||||
},
|
||||
{
|
||||
Role: "leader",
|
||||
@@ -681,7 +681,7 @@ func DefaultSquadAgents(taskKey string) []AgentSpec {
|
||||
Tools: []string{"read", "write", "edit"},
|
||||
BashPermission: "deny",
|
||||
Responsibilities: []string{"严格按 system_reqs.md 模板成文", "正文结构完整", "填满追溯表"},
|
||||
ForbiddenActions: []string{"严禁残留占位符", "严禁写不做展开"},
|
||||
ForbiddenActions: []string{"严禁残留占位符", "严禁以省略写法跳过应写章节"},
|
||||
},
|
||||
{
|
||||
Role: "leader",
|
||||
@@ -729,7 +729,7 @@ func DefaultSquadAgents(taskKey string) []AgentSpec {
|
||||
Tools: []string{"read", "write", "edit"},
|
||||
BashPermission: "deny",
|
||||
Responsibilities: []string{"严格按 test_manager.md 模板成文", "用例主表翔实完整"},
|
||||
ForbiddenActions: []string{"严禁残留占位符", "严禁写不做展开"},
|
||||
ForbiddenActions: []string{"严禁残留占位符", "严禁以省略写法跳过应写章节"},
|
||||
},
|
||||
{
|
||||
Role: "leader",
|
||||
@@ -797,7 +797,7 @@ func DefaultWorkflowSteps(taskKey string) []StepSpec {
|
||||
Needs: []string{"tasks/01_organize/output.md"},
|
||||
Accept: []string{
|
||||
"按 template.md 各章节严格四段式展开",
|
||||
"正文翔实,禁止写'不做展开'",
|
||||
"正文翔实,各节须实质展开",
|
||||
"文末集中附录字段快照与待确认清单",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -58,7 +58,7 @@ func TestWorkspaceEngine_PrepareWorkspace(t *testing.T) {
|
||||
Tools: []string{"read", "write", "edit"},
|
||||
BashPermission: "deny",
|
||||
Responsibilities: []string{"撰写正文"},
|
||||
ForbiddenActions: []string{"严禁写不做展开"},
|
||||
ForbiddenActions: []string{"严禁以省略写法跳过应写章节"},
|
||||
},
|
||||
{
|
||||
Role: "leader",
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
## 结论
|
||||
|
||||
(技术可行性判断、最关键工程风险、Go / 有条件 Go / No-Go 建议及否决条件。不替用户拍板。后文工程化差距与风险表必须支撑本结论。禁止「不做展开」。)
|
||||
(技术可行性判断、最关键工程风险、Go / 有条件 Go / No-Go 建议及否决条件。不替用户拍板。后文工程化差距与风险表必须支撑本结论。禁止以省略写法跳过应写内容。)
|
||||
|
||||
## 技术说明
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
## 结论
|
||||
|
||||
(推荐路径、最关键不确定项、该路径的失效条件。明确写「需向审评窗口或第三方法规顾问确认」。不承诺取证时间。后文分类、路径对比、临床策略必须支撑本结论。禁止「不做展开」。)
|
||||
(推荐路径、最关键不确定项、该路径的失效条件。明确写「需向审评窗口或第三方法规顾问确认」。不承诺取证时间。后文分类、路径对比、临床策略必须支撑本结论。禁止以省略写法跳过应写内容。)
|
||||
|
||||
## 分类判定
|
||||
|
||||
|
||||
@@ -6,11 +6,11 @@
|
||||
|
||||
## 结论
|
||||
|
||||
(一段完整结论:客户是谁、产品是什么、预期用途与目标市场、当前材料是否足以开跑下一阶段。信息不足处写「待确认」及缺什么;禁止只列字段名或写「不做展开」。)
|
||||
(一段完整结论:客户是谁、产品是什么、预期用途与目标市场、当前材料是否足以开跑下一阶段。信息不足处写「待确认」及缺什么;禁止只列字段名或以省略写法跳过应写内容。)
|
||||
|
||||
## 产品与使用场景
|
||||
|
||||
(写满四段式:结论句 → 列表/要点 → 解读 → 出处。覆盖适应症/目标用户/使用环境/操作流程/痛点。材料未写的标「待确认」。不做疗效宣称。禁止「本节不做展开」「略」等省略套话。)
|
||||
(写满四段式:结论句 → 列表/要点 → 解读 → 出处。覆盖适应症/目标用户/使用环境/操作流程/痛点。材料未写的标「待确认」。不做疗效宣称。禁止用省略套话跳过本节。)
|
||||
|
||||
## 客户诉求与约束
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
## 结论
|
||||
|
||||
(先写对项目的直接含义,再点市场格局、关键竞品、法规要点。每条含义对应后文一节,不要与后文打架。找不到的写「未检索到公开信息」及已尝试路径。禁止「不做展开」「略」。)
|
||||
(先写对项目的直接含义,再点市场格局、关键竞品、法规要点。每条含义对应后文一节,不要与后文打架。找不到的写「未检索到公开信息」及已尝试路径。禁止以省略写法跳过应写内容。)
|
||||
|
||||
## 市场
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
|
||||
## 竞品对比
|
||||
|
||||
(按技术路线分组。表格至少填已知行;空表须说明为何为空。性能列标注厂商宣称或文献报道。正文解读:谁接近本产品、差异在哪、对定价和注册的含义。禁止「竞品不做展开」。)
|
||||
(按技术路线分组。表格至少填已知行;空表须说明为何为空。性能列标注厂商宣称或文献报道。正文解读:谁接近本产品、差异在哪、对定价和注册的含义。禁止对竞品对比一笔带过。)
|
||||
|
||||
| 产品/厂商 | 技术路线 | 关键性能(厂商宣称) | 获批状态 | 备注 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
|
||||
+20
-4
@@ -2662,7 +2662,10 @@ export default function App() {
|
||||
taskWorkspaceId: ev.done?.workspace_id,
|
||||
...(finishedRunId ? { taskRunId: finishedRunId } : {}),
|
||||
});
|
||||
await syncControlledDocsAfterTask(task.taskKey || 'intake', preferredDocId);
|
||||
// quality_failed:未过门禁稿不得当作正式受控版本激活/同步切换
|
||||
if (nextStatus === 'completed' || nextStatus === 'pending_author_confirmation') {
|
||||
await syncControlledDocsAfterTask(task.taskKey || 'intake', preferredDocId);
|
||||
}
|
||||
await chatWindow.refresh().catch(() => undefined);
|
||||
})();
|
||||
break;
|
||||
@@ -2695,7 +2698,7 @@ export default function App() {
|
||||
}
|
||||
if (remote && isRemoteTaskTerminal(remote.status)) {
|
||||
settled = true;
|
||||
const mapped = mapTaskStatusToMessageStatus(remote.status, 'assistant') || 'completed';
|
||||
const mapped = mapTaskStatusToMessageStatus(remote.status, 'assistant') || 'failed';
|
||||
patchAsst({
|
||||
status: mapped,
|
||||
livePhaseLabel: undefined,
|
||||
@@ -2703,7 +2706,9 @@ export default function App() {
|
||||
taskRunId: existingRunId,
|
||||
});
|
||||
taskRunStream.setThinking(sessionId, false);
|
||||
await syncControlledDocsAfterTask(task.taskKey || 'intake');
|
||||
if (mapped === 'completed' || mapped === 'pending_author_confirmation') {
|
||||
await syncControlledDocsAfterTask(task.taskKey || 'intake');
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -2737,7 +2742,18 @@ export default function App() {
|
||||
if (runId) {
|
||||
try {
|
||||
const remote = await getTaskRun(runId);
|
||||
if (remote.status === 'completed' || remote.status === 'quality_failed') {
|
||||
if (remote.status === 'quality_failed') {
|
||||
settled = true;
|
||||
patchAsst({
|
||||
status: 'quality_failed',
|
||||
livePhaseLabel: undefined,
|
||||
taskWorkspaceId: remote.workspace_id,
|
||||
taskRunId: runId,
|
||||
});
|
||||
taskRunStream.setThinking(sessionId, false);
|
||||
return;
|
||||
}
|
||||
if (remote.status === 'completed') {
|
||||
settled = true;
|
||||
patchAsst({
|
||||
status: 'completed',
|
||||
|
||||
@@ -26,3 +26,10 @@ test('parseSSEChunk: flush parses final frame without trailing delimiter', () =>
|
||||
assert.equal(events[0].type, 'done');
|
||||
assert.equal(events[0].done?.session_id, 's1');
|
||||
});
|
||||
|
||||
test('parseSSEChunk: ignores heartbeat comments and keeps last id', () => {
|
||||
const raw = 'id: 1\nevent: status\ndata: {"type":"status"}\n\n: heartbeat 9\n\nid: 2\nevent: delta\ndata: {"type":"delta","delta":"z"}\n\n';
|
||||
const { events, lastSeq } = parseSSEChunk(raw);
|
||||
assert.equal(events.length, 2);
|
||||
assert.equal(lastSeq, 2);
|
||||
});
|
||||
|
||||
@@ -102,24 +102,30 @@ function parseActions(raw?: string[]): ChatErrorInfo['actions'] {
|
||||
return raw.filter((a): a is ErrorAction => (VALID_ACTIONS as readonly string[]).includes(a));
|
||||
}
|
||||
|
||||
function parseFrame(frame: string): StreamEvent | null {
|
||||
if (!frame.trim()) return null;
|
||||
function parseFrame(frame: string): { event: StreamEvent | null; seq?: number } {
|
||||
if (!frame.trim()) return { event: null };
|
||||
|
||||
let dataLine: string | undefined;
|
||||
let seq: number | undefined;
|
||||
for (const line of frame.split('\n')) {
|
||||
if (line.startsWith('id:')) {
|
||||
const n = Number(line.slice(3).trim());
|
||||
if (Number.isFinite(n)) seq = n;
|
||||
}
|
||||
if (line.startsWith('data:')) {
|
||||
dataLine = line.slice(5).trim();
|
||||
}
|
||||
}
|
||||
if (!dataLine) return null;
|
||||
// SSE comment / heartbeat frames (": ...") have no data — ignore.
|
||||
if (!dataLine) return { event: null, seq };
|
||||
|
||||
return JSON.parse(dataLine) as StreamEvent;
|
||||
return { event: JSON.parse(dataLine) as StreamEvent, seq };
|
||||
}
|
||||
|
||||
export function parseSSEChunk(
|
||||
buffer: string,
|
||||
options?: { flush?: boolean },
|
||||
): { events: StreamEvent[]; rest: string } {
|
||||
): { events: StreamEvent[]; rest: string; lastSeq?: number } {
|
||||
const events: StreamEvent[] = [];
|
||||
if (!buffer) return { events, rest: '' };
|
||||
|
||||
@@ -131,13 +137,15 @@ export function parseSSEChunk(
|
||||
processCount = frames.length;
|
||||
}
|
||||
|
||||
let lastSeq: number | undefined;
|
||||
for (let i = 0; i < processCount; i++) {
|
||||
const ev = parseFrame(frames[i]);
|
||||
if (ev) events.push(ev);
|
||||
const parsed = parseFrame(frames[i]);
|
||||
if (parsed.seq !== undefined) lastSeq = parsed.seq;
|
||||
if (parsed.event) events.push(parsed.event);
|
||||
}
|
||||
|
||||
const rest = hasTrailingDelimiter || options?.flush ? '' : (frames[frames.length - 1] ?? '');
|
||||
return { events, rest };
|
||||
return { events, rest, lastSeq };
|
||||
}
|
||||
|
||||
export async function streamChat(
|
||||
|
||||
@@ -26,6 +26,8 @@ export function mapTaskStatusToMessageStatus(
|
||||
case 'pending_author_confirmation':
|
||||
return 'pending_author_confirmation';
|
||||
case 'quality_failed':
|
||||
// 门禁未过必须保持失败态,不得映射 completed
|
||||
return 'quality_failed';
|
||||
case 'completed':
|
||||
case '':
|
||||
return 'completed';
|
||||
@@ -43,6 +45,8 @@ export function assistantTaskStatusLabel(status: AssistantMessageStatus | undefi
|
||||
switch (status) {
|
||||
case 'pending_author_confirmation':
|
||||
return '待编制人确认';
|
||||
case 'quality_failed':
|
||||
return '质量未通过';
|
||||
case 'failed':
|
||||
return '执行失败';
|
||||
case 'cancelled':
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createTaskRun } from './taskRuns';
|
||||
import { createTaskRun, readTaskRunSSEOnce, subscribeTaskRunEvents } from './taskRuns';
|
||||
import { parseSSEChunk } from './chatStream';
|
||||
|
||||
test('createTaskRun exposes DOC_BUSY detail without a retryable run', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
@@ -29,3 +30,73 @@ test('createTaskRun exposes DOC_BUSY detail without a retryable run', async () =
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test('parseSSEChunk tracks id seq for after_seq reconnect', () => {
|
||||
const raw =
|
||||
'id: 7\nevent: phase\ndata: {"type":"phase","phase":{"phase_id":"x","message":"m"}}\n\n' +
|
||||
': heartbeat 1\n\n' +
|
||||
'id: 8\nevent: delta\ndata: {"type":"delta","delta":"a"}\n\n';
|
||||
const { events, lastSeq } = parseSSEChunk(raw);
|
||||
assert.equal(events.length, 2);
|
||||
assert.equal(lastSeq, 8);
|
||||
});
|
||||
|
||||
test('subscribeTaskRunEvents reconnects on EOF with advancing after_seq', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const calls: string[] = [];
|
||||
let round = 0;
|
||||
globalThis.fetch = async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
calls.push(url);
|
||||
round += 1;
|
||||
if (round === 1) {
|
||||
// Unexpected EOF after seq 3 (no done/error)
|
||||
const body = 'id: 3\nevent: phase\ndata: {"type":"phase","phase":{"phase_id":"a","message":"m"}}\n\n';
|
||||
return new Response(body, {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/event-stream' },
|
||||
});
|
||||
}
|
||||
assert.match(url, /after_seq=3/);
|
||||
const body =
|
||||
'id: 4\nevent: done\ndata: {"type":"done","done":{"session_id":"s","workspace_id":"w"}}\n\n';
|
||||
return new Response(body, {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/event-stream' },
|
||||
});
|
||||
};
|
||||
try {
|
||||
const seen: string[] = [];
|
||||
await subscribeTaskRunEvents(
|
||||
'run_reconnect',
|
||||
{
|
||||
onEvent: (ev) => seen.push(String(ev.type)),
|
||||
maxReconnects: 3,
|
||||
reconnectDelayMs: 1,
|
||||
sleep: async () => undefined,
|
||||
},
|
||||
);
|
||||
assert.deepEqual(seen, ['phase', 'done']);
|
||||
assert.equal(calls.length, 2);
|
||||
assert.match(calls[0], /after_seq=0/);
|
||||
assert.match(calls[1], /after_seq=3/);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test('readTaskRunSSEOnce marks non-terminal EOF for reconnect', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = async () =>
|
||||
new Response('id: 2\nevent: status\ndata: {"type":"status","status":{"message":"x"}}\n\n', {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/event-stream' },
|
||||
});
|
||||
try {
|
||||
const { terminal, lastSeq } = await readTaskRunSSEOnce('run_x', 0, () => undefined);
|
||||
assert.equal(terminal, false);
|
||||
assert.equal(lastSeq, 2);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
+68
-7
@@ -46,13 +46,22 @@ export async function createTaskRun(body: CreateTaskRunBody): Promise<CreateTask
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function subscribeTaskRunEvents(
|
||||
function isTerminalTaskEvent(ev: StreamEvent): boolean {
|
||||
return ev.type === 'done' || ev.type === 'error';
|
||||
}
|
||||
|
||||
export function sleepMs(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/** 单次 SSE 读取;意外 EOF 时返回 { terminal:false, lastSeq } 供上层重连。 */
|
||||
export async function readTaskRunSSEOnce(
|
||||
runId: string,
|
||||
handlers: { onEvent: (ev: StreamEvent) => void; afterSeq?: number },
|
||||
afterSeq: number,
|
||||
onEvent: (ev: StreamEvent) => void,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
const after = handlers.afterSeq ?? 0;
|
||||
const url = `/api/v1/tasks/runs/${encodeURIComponent(runId)}/events?after_seq=${after}`;
|
||||
): Promise<{ terminal: boolean; lastSeq: number }> {
|
||||
const url = `/api/v1/tasks/runs/${encodeURIComponent(runId)}/events?after_seq=${afterSeq}`;
|
||||
const resp = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: mergeAuthHeaders({ Accept: 'text/event-stream' }),
|
||||
@@ -64,17 +73,69 @@ export async function subscribeTaskRunEvents(
|
||||
const reader = resp.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buf = '';
|
||||
let lastSeq = afterSeq;
|
||||
let terminal = false;
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buf += decoder.decode(value, { stream: true });
|
||||
const parsed = parseSSEChunk(buf);
|
||||
buf = parsed.rest;
|
||||
for (const ev of parsed.events) handlers.onEvent(ev);
|
||||
if (parsed.lastSeq !== undefined) {
|
||||
lastSeq = Math.max(lastSeq, parsed.lastSeq);
|
||||
}
|
||||
for (const ev of parsed.events) {
|
||||
onEvent(ev);
|
||||
if (isTerminalTaskEvent(ev)) terminal = true;
|
||||
}
|
||||
}
|
||||
if (buf) {
|
||||
const flushed = parseSSEChunk(buf, { flush: true });
|
||||
for (const ev of flushed.events) handlers.onEvent(ev);
|
||||
if (flushed.lastSeq !== undefined) {
|
||||
lastSeq = Math.max(lastSeq, flushed.lastSeq);
|
||||
}
|
||||
for (const ev of flushed.events) {
|
||||
onEvent(ev);
|
||||
if (isTerminalTaskEvent(ev)) terminal = true;
|
||||
}
|
||||
}
|
||||
return { terminal, lastSeq };
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅任务 SSE:意外 EOF / 不完整 chunked 时按最后 seq 重订阅同一 run,禁止调用方据此 POST 新 run。
|
||||
*/
|
||||
export async function subscribeTaskRunEvents(
|
||||
runId: string,
|
||||
handlers: {
|
||||
onEvent: (ev: StreamEvent) => void;
|
||||
afterSeq?: number;
|
||||
maxReconnects?: number;
|
||||
reconnectDelayMs?: number;
|
||||
sleep?: (ms: number) => Promise<void>;
|
||||
},
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
let afterSeq = handlers.afterSeq ?? 0;
|
||||
const maxReconnects = handlers.maxReconnects ?? 40;
|
||||
const baseDelay = handlers.reconnectDelayMs ?? 500;
|
||||
const sleep = handlers.sleep ?? sleepMs;
|
||||
|
||||
for (let attempt = 0; attempt <= maxReconnects; attempt++) {
|
||||
if (signal?.aborted) return;
|
||||
try {
|
||||
const { terminal, lastSeq } = await readTaskRunSSEOnce(runId, afterSeq, handlers.onEvent, signal);
|
||||
afterSeq = Math.max(afterSeq, lastSeq);
|
||||
if (terminal) return;
|
||||
// 意外 EOF:未见到 done/error,按 after_seq 重连
|
||||
} catch (err) {
|
||||
if (signal?.aborted) return;
|
||||
if (attempt >= maxReconnects) throw err;
|
||||
}
|
||||
if (attempt >= maxReconnects) {
|
||||
throw new Error(`task run SSE disconnected after ${maxReconnects} reconnects (after_seq=${afterSeq})`);
|
||||
}
|
||||
await sleep(Math.min(baseDelay * (attempt + 1), 5000));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -30,7 +30,9 @@ describe('mapRecordToMessage artifact projection', () => {
|
||||
|
||||
expect(msg.content).not.toBe('任务执行中…');
|
||||
expect(msg.taskArtifact?.doc_id).toBe('doc_proj56_intake');
|
||||
expect(msg.status).toBe('completed');
|
||||
expect(msg.status).toBe('quality_failed');
|
||||
expect(msg.status).not.toBe('completed');
|
||||
expect(assistantTaskStatusLabel(msg.status)).toBe('质量未通过');
|
||||
});
|
||||
|
||||
it('maps pending_author_confirmation after history refresh (not completed)', () => {
|
||||
@@ -101,7 +103,7 @@ describe('mapRecordToMessage artifact projection', () => {
|
||||
'pending_author_confirmation',
|
||||
);
|
||||
expect(mapTaskStatusToMessageStatus('completed', 'assistant')).toBe('completed');
|
||||
expect(mapTaskStatusToMessageStatus('quality_failed', 'assistant')).toBe('completed');
|
||||
expect(mapTaskStatusToMessageStatus('quality_failed', 'assistant')).toBe('quality_failed');
|
||||
expect(mapTaskStatusToMessageStatus('failed', 'assistant')).toBe('failed');
|
||||
expect(mapTaskStatusToMessageStatus('running', 'assistant')).toBe('streaming_status');
|
||||
expect(mapTaskStatusToMessageStatus('pending_author_confirmation', 'user')).toBeUndefined();
|
||||
|
||||
@@ -116,6 +116,7 @@ export type AssistantMessageStatus =
|
||||
| 'completed'
|
||||
| 'pending_author_confirmation'
|
||||
| 'failed'
|
||||
| 'quality_failed'
|
||||
| 'cancelled';
|
||||
|
||||
export interface ChatErrorInfo {
|
||||
|
||||
Reference in New Issue
Block a user