diff --git a/.doc/ops/CURRENT.md b/.doc/ops/CURRENT.md index d5f3264a..2fd6ecbd 100644 --- a/.doc/ops/CURRENT.md +++ b/.doc/ops/CURRENT.md @@ -15,6 +15,7 @@ | owner | task | branch | worktree | mutex | status | |-------|------|--------|----------|-------|--------| +| Cursor-Auto | 2026-09-20-pi-narrative-clipped-diff | feature/capability-enablement-impact-plan | - | docgov | done | | Cursor-Auto | 2026-09-20-pi-reasoning-profile | feature/capability-enablement-impact-plan | - | pi-agent | done | | Cursor-Auto | 2026-09-20-semantic-summary-persistence | feature/capability-enablement-impact-plan | - | chat+lineage-version | done | | Cursor-Auto | 2026-09-20-feature-readiness-and-lineage-authz | feature/capability-enablement-impact-plan | - | sysadmin+lineage | done | diff --git a/.doc/ops/short-term/2026-09-20-pi-narrative-clipped-diff.md b/.doc/ops/short-term/2026-09-20-pi-narrative-clipped-diff.md new file mode 100644 index 00000000..cf5a3e26 --- /dev/null +++ b/.doc/ops/short-term/2026-09-20-pi-narrative-clipped-diff.md @@ -0,0 +1,51 @@ +--- +title: "PI 变更叙事只读事实带上截断差异" +status: done +created: 2026-09-20 +updated: 2026-09-20 +owner: Cursor-Auto +branch: feature/capability-enablement-impact-plan +worktree: "-" +mutex: docgov +--- + +# PI 变更叙事只读事实带上截断差异 + +## 目标 + +`PI_CHANGE_REPORT_NARRATIVE` 仍默认关闭时,叙事请求的 `facts.json` 已包含有长度上限的差异片段和影响说明,而不是只有 SHA、风险和计数。 + +## 范围 + +- `NarrativeModelInput`:最多 8 条 hunk、8 条影响,前后文/原因各 240 个字符,超出记 `truncated` 与 `hunks_omitted` / `impacts_omitted`,同时保留完整计数。 +- `PiChangeReportNarrator.Fetch` 改写这份最小化输入。 +- 成熟度说明与 FEATURE-MATRIX 改为「输入已含截断差异,开关与运行中 runner 仍未就绪」。 + +## 非目标 + +- 不打开 `PI_CHANGE_REPORT_NARRATIVE`。 +- 不把整篇 Markdown 或未截断正文放进模型输入。 +- 不改变 `NarrativeInputDigest`(摘要仍只绑 hunk id/kind/section 与影响 doc/type/reason)。 +- 不重启容器,不占集成槽位,不实现 trace_reviewer 或 T2 物理删除。 + +## 步骤 + +1. [x] 增加截断后的模型输入,并让 Fetch 使用它 +2. [x] 单测覆盖正文出现、超长截断、digest 原样保留 +3. [x] 同步成熟度说明、FEATURE-MATRIX、CURRENT + +## 验收标准 + +- [x] `go test` 覆盖 `TestNarrativeModelInput_IncludesClippedChangeText` 与 `TestPiChangeReportNarrator_NoTools` +- [x] 开关默认仍关闭;成熟度仍是 shadow / degraded + +## 相关链接 + +- Checklist: +- Handoff: +- Test report: 本切片为契约单测,未做真实 PI 容器验收 + +## 证据 / 备注 + +- `internal/docrevision/narrative.go` `NarrativeModelInput` +- `internal/app/pi_narrative.go` Fetch diff --git a/.doc/status/FEATURE-MATRIX.md b/.doc/status/FEATURE-MATRIX.md index 0ceb3c82..31f25203 100644 --- a/.doc/status/FEATURE-MATRIX.md +++ b/.doc/status/FEATURE-MATRIX.md @@ -62,7 +62,7 @@ | IAM / 鉴权 | 项目级 API 鉴权 + RBAC;会话/阶段授权 | done | 加固 | [v3 full inventory](../audit/authorization-matrix-v3-full.md);[session 中断](../test-reports/2026-09-16-iam-route-inventory-test-report.md);[fail-closed](../test-reports/2026-09-16-wave1-iam-fail-closed-test-report.md);[410 策略](../audit/2026-09-16-bug11-410-retire-decision.md);[R13](../audit/2026-09-16-r13-data-safety-checklist.md) | 全路由 inventory + session 中断负例 + 410 冻结 + R13 基线已签 | `authz` | | 文档治理 | 法定文档生命周期 + 租约 + 受控正文读取 | done | non_blocker | [docgov](../test-reports/2026-09-08-docgov-lifecycle-and-claiming-framework-test-report.md);[正文 UI](../test-reports/2026-09-14-controlled-content-ui-acceptance.md);[canonical hash](../remediation/2026-09-14-controlled-content-canonical-hash-remediation.md) | 项目 143 只读;不终止 | `docgov` | | 文档版本 baseline | 账本、人工修订、确定性 diff+lineage、运行时 UI、负责人通知 | done | non_blocker | [管理](../test-reports/2026-09-13-document-revision-management-test-report.md);[Round20 UI](../test-reports/2026-09-14-document-revision-runtime-ui-test-report.md);[ADR](../adr/2026-09-13-document-version-boundary.md) | **before**:完整 PI narrative 混入 → **after**:迁「PI change-report 叙事」。fallback 报告是本行内显式行为,不是缺口。lineage GET 已按 URL 项目和文档可见性裁剪。test_manager 血缘的下游/上游版本改读文档版本,不再写死 1。项目追踪索引和影响报告仍见整改方案 Track T | `docgov` | -| PI change-report 叙事 | 只读消费 diff+lineage 的 PI 叙事;失败全量降级 fallback | partial | deferred_enhancement | [Round20](../test-reports/2026-09-14-document-revision-runtime-ui-test-report.md);[go/no-go GO](../ops/research/2026-09-16-wave3-pu6-pu8-go-no-go.md);[pu7](../ops/long-term/2026-09-16-pu7-pi-narrative-contract.md);[脚手架](../test-reports/2026-09-16-pu7-pi-narrative-test-report.md);[W3 真 PI](../test-reports/2026-09-17-w3-pi-change-report-narrative-test-report.md);[2026-09-20 复核](../ops/research/2026-09-20-capability-enablement-impact-research.md) | flag 默认 off。叙事请求已带 `tool_profile=reasoning`。生产输入仍只有 SHA、风险和 hunk/impact 数量,没有具体差异。运行中的 runner 尚未切到 `--no-tools`。补差异内容并更新 runner 后再灰度 | `docgov` | +| PI change-report 叙事 | 只读消费 diff+lineage 的 PI 叙事;失败全量降级 fallback | partial | deferred_enhancement | [Round20](../test-reports/2026-09-14-document-revision-runtime-ui-test-report.md);[go/no-go GO](../ops/research/2026-09-16-wave3-pu6-pu8-go-no-go.md);[pu7](../ops/long-term/2026-09-16-pu7-pi-narrative-contract.md);[脚手架](../test-reports/2026-09-16-pu7-pi-narrative-test-report.md);[W3 真 PI](../test-reports/2026-09-17-w3-pi-change-report-narrative-test-report.md);[2026-09-20 复核](../ops/research/2026-09-20-capability-enablement-impact-research.md);[最小化输入](../ops/short-term/2026-09-20-pi-narrative-clipped-diff.md) | flag 默认 off。`facts.json` 含截断后的 hunk 前后文和影响说明,以及完整 hunk/impact 计数与省略数;摘要 digest 仍不含正文。叙事请求已带 `tool_profile=reasoning`。运行中的 runner 尚未切到 `--no-tools`。不打开开关 | `docgov` | | 前端工作台 | 三栏 + 块文档 + SSE;错误卡/进度/busy;版本冲突信封;demo seed 默认关;终止 preview | done | non_blocker | [workbench](../test-reports/2026-09-08-three-column-workbench-and-block-document-test-report.md);[双标签](../test-reports/assets/2026-09-15-conversation-intent-dual-tab/screenshots/tab-b-busy.png);[demo](../test-reports/2026-09-16-demo-seed-pollution-test-report.md);[B4](../test-reports/2026-09-16-frontend-gate-b4-test-report.md)(项目 6363) | **before**:空「缺口」却 partial → **after**:本行 done。其他 409 消费方信封复用另列 hardening backlog | `web` | | PI Runner / 配置链 | pi-agent + MiniMax + typed-result-v1 | done | non_blocker | [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);[typed-result](../test-reports/2026-09-15-pi-typed-result-v1-test-report.md) | 文档生成仍使用读写和搜索工具。`tool_profile=reasoning` 的 argv 是 `--no-tools` 且不加载搜索扩展;未知 profile 拒绝启动。dev 挂载了 `pi-args.mjs`,但本轮未重启容器 | `pi-agent` | | 三环境运维 | dev/test/main + 共享 KB;对话/业务库隔离 | done | non_blocker | [PROJECT.md](../../PROJECT.md) §4;CURRENT 环境快照 dev/test schema **48**、main **47** | — | `infra` | diff --git a/internal/app/pi_narrative.go b/internal/app/pi_narrative.go index d4a84fdc..0d8c08f9 100644 --- a/internal/app/pi_narrative.go +++ b/internal/app/pi_narrative.go @@ -37,15 +37,7 @@ func (p *PiChangeReportNarrator) Fetch(ctx context.Context, spec docrevision.Nar } defer cleanup() - facts, err := json.Marshal(map[string]any{ - "schema_kind": docrevision.NarrativeSchemaKind, - "input_digest": spec.InputDigest, - "from_sha": spec.FromSHA, - "to_sha": spec.ToSHA, - "rule_risk": spec.RuleRisk, - "hunk_count": len(spec.Hunks), - "impact_count": len(spec.Impacts), - }) + facts, err := json.Marshal(docrevision.NarrativeModelInput(spec)) if err != nil { return "", err } diff --git a/internal/app/pi_narrative_test.go b/internal/app/pi_narrative_test.go index 7f5c9fcb..aa28267d 100644 --- a/internal/app/pi_narrative_test.go +++ b/internal/app/pi_narrative_test.go @@ -41,7 +41,10 @@ func (r *recordingNarrativeBackend) Execute(_ context.Context, req agent.Request func TestPiChangeReportNarrator_NoTools(t *testing.T) { rec := &recordingNarrativeBackend{} n := &PiChangeReportNarrator{Backend: rec, Model: "test"} - raw, err := n.Fetch(context.Background(), docrevision.NarrativeFetchSpec{InputDigest: "abc", FromSHA: "f", ToSHA: "t"}) + raw, err := n.Fetch(context.Background(), docrevision.NarrativeFetchSpec{ + InputDigest: "abc", FromSHA: "f", ToSHA: "t", + Hunks: []docrevision.DiffHunk{{ID: "h1", Kind: "modify", BeforeText: "1.0", AfterText: "新阈值 2.5"}}, + }) if err != nil { t.Fatal(err) } @@ -55,9 +58,17 @@ func TestPiChangeReportNarrator_NoTools(t *testing.T) { if err := json.Unmarshal(rec.payload, &facts); err != nil { t.Fatal(err) } - if facts["input_digest"] != "abc" { + if facts["input_digest"] != "abc" || facts["hunk_count"] != float64(1) { t.Fatalf("facts=%v", facts) } + hunks, _ := facts["hunks"].([]any) + if len(hunks) != 1 { + t.Fatalf("hunks=%v", facts["hunks"]) + } + hunk, _ := hunks[0].(map[string]any) + if hunk["after_text"] != "新阈值 2.5" || rec.last.ToolProfile != agent.ToolProfileReasoning { + t.Fatalf("hunk=%v profile=%s", hunk, rec.last.ToolProfile) + } } func TestPiChangeReportNarrator_LiveMiniMax(t *testing.T) { diff --git a/internal/docrevision/narrative.go b/internal/docrevision/narrative.go index 5153e8af..5ab3166f 100644 --- a/internal/docrevision/narrative.go +++ b/internal/docrevision/narrative.go @@ -4,7 +4,6 @@ import ( "crypto/sha256" "encoding/hex" "encoding/json" - "fmt" "sort" "strings" ) @@ -271,22 +270,96 @@ func filterNonEmpty(in []string) []string { return out } -// BuildNarrativePromptInput is a read-only bundle for PI (diff + lineage + risk floor). -func BuildNarrativePromptInput(fromSHA, toSHA, ruleRisk string, impacts []DocImpact, diff DiffResult) map[string]any { - digest := NarrativeInputDigest(fromSHA, toSHA, ruleRisk, impacts, diff.Hunks) +const ( + narrativeMaxHunks = 8 + narrativeMaxImpacts = 8 + narrativeMaxRunes = 240 +) + +type narrativeHunkView struct { + ID string `json:"id"` + Kind string `json:"kind"` + Section string `json:"section,omitempty"` + Before string `json:"before_text,omitempty"` + After string `json:"after_text,omitempty"` + Truncated bool `json:"truncated,omitempty"` +} + +type narrativeImpactView struct { + DocID string `json:"doc_id"` + ImpactType string `json:"impact_type"` + Reason string `json:"reason,omitempty"` + Truncated bool `json:"truncated,omitempty"` +} + +// NarrativeModelInput is the minimized read-only bundle sent to the PI narrator. +// It includes change text, not only counts, and never the full document. +func NarrativeModelInput(spec NarrativeFetchSpec) map[string]any { + hunks, hunksOmitted := clipHunks(spec.Hunks) + impacts, impactsOmitted := clipImpacts(spec.Impacts) return map[string]any{ "schema_kind": NarrativeSchemaKind, "schema_version": NarrativeSchemaVersion, - "input_digest": digest, - "from_sha": fromSHA, - "to_sha": toSHA, - "rule_risk": ruleRisk, - "diff_summary": diff.Summary, - "hunks": diff.Hunks, + "input_digest": spec.InputDigest, + "from_sha": spec.FromSHA, + "to_sha": spec.ToSHA, + "rule_risk": spec.RuleRisk, + "hunk_count": len(spec.Hunks), + "hunks_omitted": hunksOmitted, + "hunks": hunks, + "impact_count": len(spec.Impacts), + "impacts_omitted": impactsOmitted, "document_impacts": impacts, - "instructions": fmt.Sprintf( - "Return JSON schema_kind=%s schema_version=%d with matching input_digest; do not set risk.level below rule_risk=%s; document_impacts are server-authoritative.", - NarrativeSchemaKind, NarrativeSchemaVersion, ruleRisk, - ), } } + +func clipHunks(hunks []DiffHunk) ([]narrativeHunkView, int) { + omitted := 0 + if len(hunks) > narrativeMaxHunks { + omitted = len(hunks) - narrativeMaxHunks + hunks = hunks[:narrativeMaxHunks] + } + out := make([]narrativeHunkView, 0, len(hunks)) + for _, h := range hunks { + before, beforeCut := clipRunes(h.BeforeText, narrativeMaxRunes) + after, afterCut := clipRunes(h.AfterText, narrativeMaxRunes) + out = append(out, narrativeHunkView{ + ID: h.ID, Kind: h.Kind, Section: h.Section, + Before: before, After: after, Truncated: beforeCut || afterCut, + }) + } + return out, omitted +} + +func clipImpacts(impacts []DocImpact) ([]narrativeImpactView, int) { + omitted := 0 + if len(impacts) > narrativeMaxImpacts { + omitted = len(impacts) - narrativeMaxImpacts + impacts = impacts[:narrativeMaxImpacts] + } + out := make([]narrativeImpactView, 0, len(impacts)) + for _, imp := range impacts { + reason, cut := clipRunes(imp.Reason, narrativeMaxRunes) + out = append(out, narrativeImpactView{ + DocID: imp.DocID, ImpactType: imp.ImpactType, Reason: reason, Truncated: cut, + }) + } + return out, omitted +} + +func clipRunes(s string, max int) (string, bool) { + r := []rune(s) + if len(r) <= max { + return s, false + } + return string(r[:max]) + "…", true +} + +// BuildNarrativePromptInput is a read-only bundle for PI (diff + lineage + risk floor). +func BuildNarrativePromptInput(fromSHA, toSHA, ruleRisk string, impacts []DocImpact, diff DiffResult) map[string]any { + return NarrativeModelInput(NarrativeFetchSpec{ + FromSHA: fromSHA, ToSHA: toSHA, RuleRisk: ruleRisk, + InputDigest: NarrativeInputDigest(fromSHA, toSHA, ruleRisk, impacts, diff.Hunks), + Impacts: impacts, Hunks: diff.Hunks, + }) +} diff --git a/internal/docrevision/narrative_test.go b/internal/docrevision/narrative_test.go index e10221f5..8b6111f0 100644 --- a/internal/docrevision/narrative_test.go +++ b/internal/docrevision/narrative_test.go @@ -3,6 +3,7 @@ package docrevision_test import ( "context" "encoding/json" + "strings" "testing" "chat-agent-system/internal/docrevision" @@ -287,3 +288,44 @@ func TestCreateAgentCandidate_FlagOnFetcherErrorFallsBack(t *testing.T) { t.Fatalf("want fallback candidate, got cand=%v rep=%+v", cand, rep) } } + +func TestNarrativeModelInput_IncludesClippedChangeText(t *testing.T) { + long := strings.Repeat("甲", 280) + spec := docrevision.NarrativeFetchSpec{ + FromSHA: "from", ToSHA: "to", RuleRisk: "high", InputDigest: "digest-1", + Hunks: []docrevision.DiffHunk{{ + ID: "h1", Kind: "modify", Section: "需求", + BeforeText: "旧阈值 1.0", AfterText: long, + }}, + Impacts: []docrevision.DocImpact{{ + DocID: "doc_proj1_test_manager", ImpactType: "stale_candidate", Reason: "验证方案引用了该阈值", + }}, + } + for i := 0; i < 10; i++ { + spec.Hunks = append(spec.Hunks, docrevision.DiffHunk{ID: "extra", Kind: "insert", AfterText: "x"}) + } + raw, err := json.Marshal(docrevision.NarrativeModelInput(spec)) + if err != nil { + t.Fatal(err) + } + body := string(raw) + if !strings.Contains(body, "旧阈值 1.0") || !strings.Contains(body, "验证方案引用了该阈值") { + t.Fatalf("model input dropped the change or impact: %s", body) + } + if !strings.Contains(body, `"hunk_count":11`) || !strings.Contains(body, `"hunks_omitted":3`) { + t.Fatalf("counts must show truncation: %s", body) + } + if strings.Contains(body, long) { + t.Fatal("full hunk text must not be sent") + } + if !strings.Contains(body, "…") { + t.Fatal("truncated text should be marked") + } + var facts map[string]any + if err := json.Unmarshal(raw, &facts); err != nil { + t.Fatal(err) + } + if facts["input_digest"] != "digest-1" { + t.Fatalf("digest = %v", facts["input_digest"]) + } +} diff --git a/internal/sysadmin/features.go b/internal/sysadmin/features.go index 9d836a97..9d9e2cc4 100644 --- a/internal/sysadmin/features.go +++ b/internal/sysadmin/features.go @@ -80,7 +80,7 @@ var featureMaturityCatalog = map[string]featureMaturity{ }, "PI_CHANGE_REPORT_NARRATIVE": { state: implShadow, adapter: adapterDegraded, - reason: "生产输入目前只有 SHA、风险和变更数量,没有具体差异。叙事请求已使用 reasoning profile,运行中的容器尚未切到 --no-tools", + reason: "只读事实已含截断后的差异片段和影响说明,不含整篇文档。开关默认关闭。叙事请求已使用 reasoning profile,运行中的容器尚未切到 --no-tools", }, } diff --git a/internal/sysadmin/service_test.go b/internal/sysadmin/service_test.go index a8e20c39..95f8087a 100644 --- a/internal/sysadmin/service_test.go +++ b/internal/sysadmin/service_test.go @@ -149,7 +149,7 @@ func TestFeatures_CoversCatalogAndInjectedValues(t *testing.T) { } } if byKey["PI_CHANGE_REPORT_NARRATIVE"].ImplementationState != "shadow" || byKey["PI_CHANGE_REPORT_NARRATIVE"].AdapterReadiness != "degraded" { - t.Fatalf("narrative readiness must stay degraded while input is incomplete: %+v", byKey["PI_CHANGE_REPORT_NARRATIVE"]) + t.Fatalf("narrative readiness must stay degraded while the flag is off and the live runner is not --no-tools: %+v", byKey["PI_CHANGE_REPORT_NARRATIVE"]) } }