diff --git a/.doc/audit/2026-09-13-document-revision-quality-status-false-positive.md b/.doc/audit/2026-09-13-document-revision-quality-status-false-positive.md new file mode 100644 index 00000000..0943c976 --- /dev/null +++ b/.doc/audit/2026-09-13-document-revision-quality-status-false-positive.md @@ -0,0 +1,50 @@ +--- +title: "行动计划表状态列「待续」被质量门禁误判为敷衍" +status: in_progress +created: 2026-09-13 +updated: 2026-09-13 +severity: high +--- + +# 行动计划表状态列「待续」被质量门禁误判为敷衍 + +## 现象 + +第三轮真实 UI(新用户 `mtzzliz5`,新项目,真实 MiniMax): + +- UI-00~02 PASS;**UI-03** 两轮 auto-repair 后 **`quality_failed` 80/100** +- workspace=`intake_1789314411395330821` +- `manifest.prohibited_phrases` 仅 **「待续」** +- `output.md` 第163–164 行与 `repair_1` / `repair_2` 对应行均为行动计划表状态单元格,而非省略正文: + +```text +| **待续** | 合规专员基于用户补充材料... | 合规专员 | D-01至D-07全部确认后 | +| **待续** | 组织专员编制里程碑规划与检测排期方案... | 组织专员 | 合规评估完成后 | +``` + +表头为 `| 阶段 | 行动项 | 负责方 | 触发条件 |`,节标题为「下一步行动建议」。泛化修复提示未能让模型改掉结构化状态词,造成真实假阳性。 + +## 根因分析 + +`containsProhibitedUsage` 对禁词「待续」做全文命中;既有豁免只覆盖自检/规则行的否定或引号引用。行动计划表把「待续」用作**独立阶段状态**(与「立即」并列),不是「后续章节待续」式正文省略。门禁无法区分结构,扣 20 分后触发无效 auto-repair。 + +审查补强:豁免必须按表头把命中列对齐到「阶段/状态/进度」;动作/负责方/触发条件按表头索引取值。负责方或触发条件单元格为「待续」不得豁免。 + +## 严重度 + +`high` + +## 关联整改 + +- Short-term: [../ops/short-term/2026-09-13-document-revision-quality-context.md](../ops/short-term/2026-09-13-document-revision-quality-context.md) +- 分支: `fix/document-revision-quality-context` +- 前序自污染豁免不得回退:[./2026-09-13-document-revision-quality-gate-self-pollution.md](./2026-09-13-document-revision-quality-gate-self-pollution.md) + +## 证据 + +- Evidence root(勿删、勿提交本轮):`.doc/test-reports/assets/2026-09-13-document-revision-runtime-ui/schema30-ui03-quality-status-false-positive` +- 只读 workspace:`data/dev/workspaces/bizdev/intake_1789314411395330821` + +## 开放问题 + +- 占槽后真实 UI-03 重跑;本轮不操作容器、不改 E2E diff --git a/.doc/ops/CURRENT.md b/.doc/ops/CURRENT.md index c04e83c5..7c0db2cd 100644 --- a/.doc/ops/CURRENT.md +++ b/.doc/ops/CURRENT.md @@ -1,11 +1,11 @@ # 当前焦点 **更新**: 2026-09-13 -**production_ready**: no(DOCREV:schema **30**;当前受控文档指示语路由在 `fix/document-revision-active-doc-routing`;不操作容器) +**production_ready**: no(DOCREV:schema **30**;行动表状态「待续」假阳性在 `fix/document-revision-quality-context`;不操作容器) ## 焦点 -1. **DOCREV-RT**: [当前受控文档指示语路由](short-term/2026-09-13-document-revision-active-doc-routing.md)(`fix/document-revision-active-doc-routing`:UI-05 不得被法规关键词改路由;不 push / 不操作容器) +1. **DOCREV-RT**: [行动计划表状态「待续」窄豁免](short-term/2026-09-13-document-revision-quality-context.md)(`fix/document-revision-quality-context`:不删禁词、不放宽正文;不 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 @@ -18,7 +18,8 @@ | Cursor | document-revision-wave1-6-implementation | feature/document-revision-management | - | docgov | done | | Cursor-Auto | document-revision-runtime-ui03 | fix/document-revision-runtime-ui03 | - | docgov | done | | Cursor-Auto | document-revision-sse-idempotency | fix/document-revision-sse-idempotency | - | docgov | done | -| Cursor-Auto | document-revision-active-doc-routing | fix/document-revision-active-doc-routing | - | docgov | in_progress | +| Cursor-Auto | document-revision-active-doc-routing | fix/document-revision-active-doc-routing | - | docgov | done | +| Cursor-Auto | document-revision-quality-context | fix/document-revision-quality-context | - | docgov | in_progress | > 新任务开始前登记。`branch` 必须为 `feature/` 或 `fix/`,**禁止** `dev` / `test` / `main`。同一 `mutex` 同时仅一个 `in_progress`。 @@ -54,3 +55,4 @@ | SSE/幂等审计 | [../audit/2026-09-13-document-revision-sse-idempotency.md](../audit/2026-09-13-document-revision-sse-idempotency.md) | | 质量门禁自污染审计 | [../audit/2026-09-13-document-revision-quality-gate-self-pollution.md](../audit/2026-09-13-document-revision-quality-gate-self-pollution.md) | | 当前文档路由审计 | [../audit/2026-09-13-document-revision-active-doc-routing.md](../audit/2026-09-13-document-revision-active-doc-routing.md) | +| 行动表状态假阳性审计 | [../audit/2026-09-13-document-revision-quality-status-false-positive.md](../audit/2026-09-13-document-revision-quality-status-false-positive.md) | diff --git a/.doc/ops/short-term/2026-09-13-document-revision-quality-context.md b/.doc/ops/short-term/2026-09-13-document-revision-quality-context.md new file mode 100644 index 00000000..5c878f10 --- /dev/null +++ b/.doc/ops/short-term/2026-09-13-document-revision-quality-context.md @@ -0,0 +1,46 @@ +--- +title: "质量门禁:行动计划表状态「待续」窄豁免" +status: in_progress +created: 2026-09-13 +updated: 2026-09-13 +owner: Cursor-Auto +branch: fix/document-revision-quality-context +worktree: "-" +mutex: docgov +--- + +# 质量门禁:行动计划表状态「待续」窄豁免 + +## 目标 + +结构化行动计划表中独立状态单元格「待续」不再误报;正文/标题/列表/普通表中的「待续」仍 FAIL。禁词表不得删除「待续」。 + +## 范围 + +- `internal/squad/quality_gate.go` 窄 Markdown 表结构识别 +- 敌意单测 + 既有自检豁免 / 投影 / repair 隔离不回归 +- 文档:audit / gap-matrix / CURRENT / FEATURE-MATRIX + +## 非目标 + +- 不删除「待续」禁词;不放宽普通正文/任意同行表格 +- 不触碰 assets / E2E;不操作容器 + +## 步骤 + +1. [x] 认领 CURRENT;写 audit/short-term +2. [x] 仅豁免行动计划表「阶段/状态/进度」列独立单元格(去粗体精确等于「待续」+ 表头列索引对齐 + 实质行动 + 负责/触发完整) +3. [x] 敌意用例:正文「后续章节待续」、`| 内容 | 待续 |`、待续说明、残行/空动作、负责方/触发=待续仍 FAIL;列重排仅状态列可 PASS +4. [x] 门禁 + 原子提交 + +## 验收标准 + +- [x] 现场同构行动表 PASS,且不触发 repair +- [x] 敌意上下文仍记录 `待续` +- [x] `ProhibitedPhrases` 仍含「待续」 +- [x] targeted / `go vet` / `go test -short` / `go test -race ./internal/taskrun` / web test+build / `git diff --check` + +## 相关链接 + +- Audit: [../../audit/2026-09-13-document-revision-quality-status-false-positive.md](../../audit/2026-09-13-document-revision-quality-status-false-positive.md) +- Evidence: [../../test-reports/assets/2026-09-13-document-revision-runtime-ui/schema30-ui03-quality-status-false-positive](../../test-reports/assets/2026-09-13-document-revision-runtime-ui/schema30-ui03-quality-status-false-positive) diff --git a/.doc/remediation/gap-matrix.md b/.doc/remediation/gap-matrix.md index 87c87636..b5b223f2 100644 --- a/.doc/remediation/gap-matrix.md +++ b/.doc/remediation/gap-matrix.md @@ -65,6 +65,7 @@ | DOCREV-SSE-IDEM-BUSY | SSE WriteTimeout 断线后同 turn 再 POST 误 409 DOC_BUSY + 重复聊天 | **FIX-LANDED**(工程) | [audit](../audit/2026-09-13-document-revision-sse-idempotency.md);证据 [assets/2026-09-13-document-revision-runtime-ui](../test-reports/assets/2026-09-13-document-revision-runtime-ui/);`fix/document-revision-sse-idempotency` | 占槽真实 UI-03 重跑;不删证据 | | DOCREV-QUALITY-SELF-POLLUTE | 自检表引用禁词致 score=40 quality_failed;auto_repair 未启用;UI 映射 completed | **FIX-LANDED**(工程) | [audit](../audit/2026-09-13-document-revision-quality-gate-self-pollution.md);workspace `intake_1789308825666278807` output.md:204;同分支 | 占槽真实重跑;不删证据 | | DOCREV-ACTIVE-DOC-ROUTING | UI-05「当前受控文档」被法规关键词改路由到 `feasibility_reg`,intake 无 pending | **FIX-LANDED**(工程) | [audit](../audit/2026-09-13-document-revision-active-doc-routing.md);证据 `schema30-ui05-active-doc-routing-failure`;`fix/document-revision-active-doc-routing` | 占槽真实 UI-05 重跑;**不改弱 E2E**;不删证据 | +| DOCREV-QUALITY-STATUS-FP | 行动计划表状态列「待续」被门禁当敷衍,两轮 repair 后 quality_failed 80 | **FIX-LANDED**(工程) | [audit](../audit/2026-09-13-document-revision-quality-status-false-positive.md);证据 `schema30-ui03-quality-status-false-positive`;`fix/document-revision-quality-context` | 占槽真实 UI-03 重跑;**不删禁词、不放宽正文**;不删证据 | --- diff --git a/.doc/status/FEATURE-MATRIX.md b/.doc/status/FEATURE-MATRIX.md index b63c149a..fa9f73d6 100644 --- a/.doc/status/FEATURE-MATRIX.md +++ b/.doc/status/FEATURE-MATRIX.md @@ -21,7 +21,7 @@ | 工作流治理 | 三正式工作流 + 阶段门禁 | done | [remediation/three-workflows](../remediation/2026-09-12-three-workflows-strict-governance-remediation.md);[test 2026-09-12](../test-reports/2026-09-12-three-workflows-governance-remediation-test-report.md) | Phase2 设计器 partial | `workflow` | | IAM / 鉴权 | 项目级 API 鉴权 + RBAC | partial | [audit/authorization](../audit/authorization-matrix-v2.md);[刷新恢复专项审计](../audit/2026-09-13-chat-history-refresh-disappears.md) | 历史读取端点补统一会话 owner + 阶段授权;旧 API 410 标注(BUG-11 partial) | `authz` | | 文档治理 | 法定文档生命周期 + 租约 | done | [test docgov](../test-reports/2026-09-08-docgov-lifecycle-and-claiming-framework-test-report.md) | — | `docgov` | -| 文档版本与修改认知 | 智能体版本边界、人工修订标记、任意版本比较、变更报告与负责人通知 | partial | [test 2026-09-13](../test-reports/2026-09-13-document-revision-management-test-report.md);[运行时审计](../audit/2026-09-13-document-revision-runtime-404.md);[当前文档路由](../audit/2026-09-13-document-revision-active-doc-routing.md);[ADR](../adr/2026-09-13-document-version-boundary.md) | **运行时 blocker**:PG 未接线→404/重启丢失;overlay/freeform 保存缺口;PI 叙事报告待集成;见 [runtime remediation](../remediation/2026-09-13-document-revision-runtime-remediation.md)。指示语钉住 active `doc_proj` 工程已落地,待占槽重跑 UI-05 | `docgov` | +| 文档版本与修改认知 | 智能体版本边界、人工修订标记、任意版本比较、变更报告与负责人通知 | partial | [test 2026-09-13](../test-reports/2026-09-13-document-revision-management-test-report.md);[运行时审计](../audit/2026-09-13-document-revision-runtime-404.md);[当前文档路由](../audit/2026-09-13-document-revision-active-doc-routing.md);[行动表状态假阳性](../audit/2026-09-13-document-revision-quality-status-false-positive.md);[ADR](../adr/2026-09-13-document-version-boundary.md) | **运行时 blocker**:PG 未接线→404/重启丢失;overlay/freeform 保存缺口;PI 叙事报告待集成;见 [runtime remediation](../remediation/2026-09-13-document-revision-runtime-remediation.md)。行动表状态「待续」窄豁免已落地,待占槽重跑 UI-03 | `docgov` | | 前端工作台 | 三栏 + 块文档 + SSE | partial | [test workbench](../test-reports/2026-09-08-three-column-workbench-and-block-document-test-report.md);[现场专项审计](../audit/2026-09-13-conversation-intent-runtime-state.md) | 错误卡片/任务进度/文档 busy 状态刷新恢复与人工抽检待完成 | `web` | | PI Runner / 配置链 | pi-agent + MiniMax 真实链路 | done | [test piagent](../test-reports/2026-09-13-codex-audit-p0-remediation-test-report.md);[container-env](../test-reports/2026-09-13-container-env-configuration-test-report.md) | — | `pi-agent` | | 三环境运维 | dev/test/main + 共享 KB | done | [PROJECT.md](../../PROJECT.md) §4;四环境 healthy | — | `infra` | diff --git a/internal/squad/quality_gate.go b/internal/squad/quality_gate.go index a944ba22..6beae229 100644 --- a/internal/squad/quality_gate.go +++ b/internal/squad/quality_gate.go @@ -11,6 +11,8 @@ import ( "path/filepath" "strings" "time" + "unicode" + "unicode/utf8" "chat-agent-system/internal/agent" "chat-agent-system/internal/stream" @@ -180,10 +182,12 @@ func containsProhibitedUsage(content, phrase string) bool { return false } abs := searchFrom + rel - if !isNegatedOrMetaProhibitedCitation(content, abs, phrase) { - return true + if isNegatedOrMetaProhibitedCitation(content, abs, phrase) || + isActionPlanStatusCell(content, abs, phrase) { + searchFrom = abs + len(phrase) + continue } - searchFrom = abs + len(phrase) + return true } } @@ -241,6 +245,213 @@ func phraseQuotedOnLine(line, phrase string) bool { return false } +var ( + actionPlanStatusHeaders = []string{"阶段", "状态", "进度"} + actionPlanActionHeaders = []string{"行动项", "行动", "动作", "后续动作", "后续行动", "工作内容"} + actionPlanOwnerHeaders = []string{"负责方", "责任人", "负责人"} + actionPlanTriggerHeaders = []string{"触发条件", "触发", "完成条件", "前置条件"} +) + +// isActionPlanStatusCell 仅豁免行动计划/后续动作表中「阶段/状态/进度」列的独立单元格「待续」。 +// 命中列必须与表头状态列索引一致;动作/负责/触发按表头取值。不得凭同行任意列或邻近词放宽。 +// 只接受同一连续表格:表头后须紧跟合法 separator,且数据行列数与表头相等。 +func isActionPlanStatusCell(content string, idx int, phrase string) bool { + if phrase != "待续" || idx < 0 || idx >= len(content) { + return false + } + 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] + cells := markdownTableCells(line) + if len(cells) < 4 || isMarkdownTableSeparator(cells) { + return false + } + header := findPrecedingTableHeader(content, lineStart) + if len(header) == 0 || len(cells) != len(header) { + return false + } + cols, ok := parseActionPlanColumns(header) + if !ok { + return false + } + cell, hitIdx, ok := tableCellAt(line, idx-lineStart) + if !ok || hitIdx != cols.status || stripMarkdownEmphasis(cell) != "待续" { + return false + } + if cols.action >= len(cells) || cols.owner >= len(cells) || cols.trigger >= len(cells) || cols.status >= len(cells) { + return false + } + if strings.TrimSpace(stripMarkdownEmphasis(cells[cols.owner])) == "" || + strings.TrimSpace(stripMarkdownEmphasis(cells[cols.trigger])) == "" { + return false + } + return hasSubstantialActionText(stripMarkdownEmphasis(cells[cols.action])) +} + +func markdownTableCells(line string) []string { + trimmed := strings.TrimSpace(line) + if !strings.HasPrefix(trimmed, "|") { + return nil + } + parts := strings.Split(trimmed, "|") + if len(parts) > 0 && parts[0] == "" { + parts = parts[1:] + } + if len(parts) > 0 && strings.TrimSpace(parts[len(parts)-1]) == "" { + parts = parts[:len(parts)-1] + } + if len(parts) == 0 { + return nil + } + return parts +} + +func tableCellAt(line string, localIdx int) (string, int, bool) { + if localIdx < 0 || localIdx >= len(line) { + return "", -1, false + } + var pipes []int + for i := 0; i < len(line); i++ { + if line[i] == '|' { + pipes = append(pipes, i) + } + } + if len(pipes) < 2 { + return "", -1, false + } + for i := 0; i < len(pipes)-1; i++ { + start, end := pipes[i], pipes[i+1] + if localIdx > start && localIdx < end { + return line[start+1 : end], i, true + } + } + return "", -1, false +} + +func isMarkdownTableSeparator(cells []string) bool { + if len(cells) == 0 { + return false + } + for _, c := range cells { + s := strings.Trim(strings.TrimSpace(c), ":") + s = strings.TrimSpace(s) + if s == "" || strings.Trim(s, "-") != "" { + return false + } + } + return true +} + +func stripMarkdownEmphasis(s string) string { + s = strings.TrimSpace(s) + s = strings.ReplaceAll(s, "**", "") + s = strings.ReplaceAll(s, "__", "") + s = strings.TrimSpace(s) + if len(s) >= 2 && strings.HasPrefix(s, "*") && strings.HasSuffix(s, "*") { + s = strings.TrimSpace(s[1 : len(s)-1]) + } + return strings.TrimSpace(s) +} + +func hasSubstantialActionText(s string) bool { + s = strings.TrimSpace(s) + if s == "" || strings.Contains(s, "待续") { + return false + } + if utf8.RuneCountInString(s) < 12 { + return false + } + letters := 0 + for _, r := range s { + if unicode.Is(unicode.Han, r) || unicode.IsLetter(r) { + letters++ + } + } + return letters >= 10 +} + +func findPrecedingTableHeader(content string, lineStart int) []string { + var above []string + prevEnd := lineStart - 1 + for prevEnd >= 0 { + prevStart := 0 + if prevEnd > 0 { + if i := strings.LastIndex(content[:prevEnd], "\n"); i >= 0 { + prevStart = i + 1 + } + } + line := strings.TrimSpace(content[prevStart:prevEnd]) + if line == "" { + break + } + cells := markdownTableCells(line) + if cells == nil { + break + } + above = append(above, line) + prevEnd = prevStart - 1 + } + if len(above) < 2 { + return nil + } + header := markdownTableCells(above[len(above)-1]) + sep := markdownTableCells(above[len(above)-2]) + if len(header) == 0 || isMarkdownTableSeparator(header) { + return nil + } + if !isMarkdownTableSeparator(sep) || len(sep) != len(header) { + return nil + } + return header +} + +type actionPlanColumns struct { + status int + action int + owner int + trigger int +} + +func headerColumnIndex(header []string, names []string) int { + want := make(map[string]struct{}, len(names)) + for _, n := range names { + want[n] = struct{}{} + } + for i, c := range header { + if _, ok := want[stripMarkdownEmphasis(c)]; ok { + return i + } + } + return -1 +} + +func parseActionPlanColumns(header []string) (actionPlanColumns, bool) { + cols := actionPlanColumns{ + status: headerColumnIndex(header, actionPlanStatusHeaders), + action: headerColumnIndex(header, actionPlanActionHeaders), + owner: headerColumnIndex(header, actionPlanOwnerHeaders), + trigger: headerColumnIndex(header, actionPlanTriggerHeaders), + } + if cols.status < 0 || cols.action < 0 || cols.owner < 0 || cols.trigger < 0 { + return actionPlanColumns{}, false + } + seen := map[int]struct{}{cols.status: {}, cols.action: {}, cols.owner: {}, cols.trigger: {}} + if len(seen) != 4 { + return actionPlanColumns{}, false + } + return cols, true +} + +func isActionPlanTableHeader(cells []string) bool { + _, ok := parseActionPlanColumns(cells) + return ok +} + func negationAdjacentToPhrase(line string, phraseIdx int) bool { if phraseIdx <= 0 || phraseIdx > len(line) { return false diff --git a/internal/squad/quality_gate_test.go b/internal/squad/quality_gate_test.go index 530d4c5a..c5dfef12 100644 --- a/internal/squad/quality_gate_test.go +++ b/internal/squad/quality_gate_test.go @@ -288,3 +288,195 @@ func TestQualityGate_NegatedSelfCheckCitation_NoFalsePositive(t *testing.T) { t.Fatalf("meta heading plus real hedging must fail; passed=%v phrases=%v", mo.Passed, mo.ProhibitedPhrases) } } + +func intakeTemplate(t *testing.T) (workDir string, gate *squad.QualityGate) { + t.Helper() + engine := squad.NewWorkspaceEngine() + ws, err := engine.PrepareWorkspace(squad.WorkspacePrepareRequest{ + WorkDir: t.TempDir(), + TaskKey: "intake", + StageTitle: "用户初始需求收集表", + ProjectName: "医疗设备", + TemplateContent: "# 用户初始需求收集表\n\n## 1. 客户与项目画像\n\n## 2. 预期用途\n\n## 3. 待确认清单\n", + }) + if err != nil { + t.Fatalf("PrepareWorkspace: %v", err) + } + return ws.WorkDir, squad.NewQualityGate() +} + +func wrapIntakeBody(body string) string { + return `# 用户初始需求收集表 + +## 1. 客户与项目画像 +本项目客户为专业医疗机构。四段式展开客户背景、项目边界与采集依据。 + +## 2. 预期用途 +用于临床辅助诊断与生理信号监测,覆盖病房与门诊场景。 + +## 3. 待确认清单 +- 供电模块认证周期待窗口确认。 + +` + body +} + +// 现场 intake_1789314411395330821 output.md:163-164 — 行动计划表独立状态「待续」不得误报。 +func TestQualityGate_ActionPlanStatusCell_PendingNotHedging(t *testing.T) { + workDir, gate := intakeTemplate(t) + + stillListed := false + for _, p := range squad.ProhibitedPhrases { + if p == "待续" { + stillListed = true + break + } + } + if !stillListed { + t.Fatal("待续 must remain in ProhibitedPhrases") + } + + // 与 workspace 第163-164 行同构(下一步行动建议 / 阶段·行动项·负责方·触发条件) + actionPlan := `### 下一步行动建议 + +| 阶段 | 行动项 | 负责方 | 触发条件 | +|---|---|---|---| +| **立即** | 用户补充D-01(注册分类)、D-02(预期用途)两项高优先级缺口,提供分类依据或产品技术要求初稿。 | 北京水木东方 | 收到用户反馈后 | +| **待续** | 合规专员基于用户补充材料,评估GB 9706.1-2020符合性与软件合规要求,出具初步合规评估意见。 | 合规专员 | D-01至D-07全部确认后 | +| **待续** | 组织专员编制里程碑规划与检测排期方案,明确各阶段时间节点、责任部门及交付物要求。 | 组织专员 | 合规评估完成后 | +` + report, err := gate.Validate(workDir, "intake", wrapIntakeBody(actionPlan)) + if err != nil { + t.Fatalf("Validate: %v", err) + } + if !report.Passed { + t.Fatalf("action-plan status cell must pass; violations=%v phrases=%v score=%.0f", + report.Violations, report.ProhibitedPhrases, report.Score) + } + if len(report.ProhibitedPhrases) != 0 { + t.Fatalf("expected no prohibited hits on action-plan status, got %v", report.ProhibitedPhrases) + } +} + +func TestQualityGate_ActionPlanStatus_ReorderedColumnsPass(t *testing.T) { + workDir, gate := intakeTemplate(t) + reordered := `### 下一步行动建议 + +| 行动项 | 负责方 | 触发条件 | 阶段 | +|---|---|---|---| +| 合规专员基于用户补充材料评估GB 9706.1符合性并出具初步意见。 | 合规专员 | D-01至D-07全部确认后 | **待续** | +` + report, err := gate.Validate(workDir, "intake", wrapIntakeBody(reordered)) + if err != nil { + t.Fatalf("Validate: %v", err) + } + if !report.Passed || len(report.ProhibitedPhrases) != 0 { + t.Fatalf("reordered status column must pass; violations=%v phrases=%v", + report.Violations, report.ProhibitedPhrases) + } +} + +func TestQualityGate_PendingPhrase_HostileContextsStillFail(t *testing.T) { + workDir, gate := intakeTemplate(t) + + cases := []struct { + name string + body string + }{ + {"body_hedging", "后续章节待续,本节仅列框架。\n"}, + {"heading", "## 后续章节待续\n正文已展开风险与电气安全条款适用性。\n"}, + {"list", "- 后续章节待续\n"}, + {"plain_two_col_table", "| 项目 | 内容 |\n|---|---|\n| 风险管理 | 待续 |\n"}, + {"four_col_non_action_table", "| 章节 | 摘要 | 作者 | 备注 |\n|---|---|---|---|\n| 风险 | 待续 | 张三 | 无 |\n"}, + {"labeled_pending_note", `### 下一步行动建议 + +| 阶段 | 行动项 | 负责方 | 触发条件 | +|---|---|---|---| +| 待续说明 | 合规专员基于用户补充材料评估符合性并出具初步意见。 | 合规专员 | D-01确认后 | +`}, + {"incomplete_row", `### 下一步行动建议 + +| 阶段 | 行动项 | 负责方 | 触发条件 | +|---|---|---|---| +| **待续** | 合规专员基于用户补充材料评估符合性并出具初步意见。 | 合规专员 | | +`}, + {"thin_action", `### 下一步行动建议 + +| 阶段 | 行动项 | 负责方 | 触发条件 | +|---|---|---|---| +| **待续** | 再议 | 合规专员 | 完成后 | +`}, + {"owner_cell_pending", `### 下一步行动建议 + +| 阶段 | 行动项 | 负责方 | 触发条件 | +|---|---|---|---| +| 立即 | 合规专员基于用户补充材料评估符合性并出具初步意见。 | 待续 | D-01确认后 | +`}, + {"trigger_cell_pending", `### 下一步行动建议 + +| 阶段 | 行动项 | 负责方 | 触发条件 | +|---|---|---|---| +| 立即 | 合规专员基于用户补充材料评估符合性并出具初步意见。 | 合规专员 | 待续 | +`}, + {"no_header_separator", `### 下一步行动建议 + +| 阶段 | 行动项 | 负责方 | 触发条件 | +| **待续** | 合规专员基于用户补充材料评估符合性并出具初步意见。 | 合规专员 | D-01确认后 | +`}, + {"header_4_data_5", `### 下一步行动建议 + +| 阶段 | 行动项 | 负责方 | 触发条件 | +|---|---|---|---| +| **待续** | 合规专员基于用户补充材料评估符合性并出具初步意见。 | 合规专员 | D-01确认后 | 额外列 | +`}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + rep, err := gate.Validate(workDir, "intake", wrapIntakeBody(tc.body)) + if err != nil { + t.Fatalf("Validate: %v", err) + } + if rep.Passed || len(rep.ProhibitedPhrases) == 0 { + t.Fatalf("must still fail; passed=%v phrases=%v", rep.Passed, rep.ProhibitedPhrases) + } + hit := false + for _, p := range rep.ProhibitedPhrases { + if p == "待续" { + hit = true + } + } + if !hit { + t.Fatalf("must record 待续, got %v", rep.ProhibitedPhrases) + } + }) + } +} + +func TestQualityGate_ActionPlanStatus_DoesNotTriggerRepair(t *testing.T) { + workDir, gate := intakeTemplate(t) + content := wrapIntakeBody(`### 下一步行动建议 + +| 阶段 | 行动项 | 负责方 | 触发条件 | +|---|---|---|---| +| **待续** | 合规专员基于用户补充材料评估电气安全符合性并出具意见。 | 合规专员 | D-01至D-07全部确认后 | +`) + if err := os.WriteFile(filepath.Join(workDir, "output.md"), []byte(content), 0o644); err != nil { + t.Fatal(err) + } + driver := squad.NewWorkflowDriver(squad.NewWorkspaceEngine()) + report, err := gate.EnsureQualityOrRepair(context.Background(), workDir, "intake", driver, &agent.StubBackend{}, 1, squad.ExecuteOptions{ + Timeout: 5 * time.Second, + }) + if err != nil { + t.Fatalf("EnsureQualityOrRepair: %v", err) + } + if report == nil || !report.Passed { + t.Fatalf("action-plan pending must pass without repair; report=%+v", report) + } + wf, _ := driver.LoadWorkflow(workDir) + for _, s := range wf.Steps { + if strings.HasPrefix(s.ID, "step_repair_") { + t.Fatalf("must not append repair for status-cell false positive, got %q", s.ID) + } + } +}