feat(docrevision): send clipped diff text to the change-report narrator
The narrator only received counts, so it could not describe the actual change. Caps keep the full document out and leave the input digest unchanged; the flag stays off. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
@@ -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` |
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user