From 18f27294722a8fc8ba4c83eb3a5d5d11668377c3 Mon Sep 17 00:00:00 2001
From: weichen
Date: Wed, 16 Sep 2026 15:39:54 +0000
Subject: [PATCH 1/3] feat(docrevision): add PI change-report narrative with
flag-gated fallback
Introduce PI_CHANGE_REPORT_NARRATIVE (default off). When on, consume typed
narrative bound to input digest only; malformed/timeout paths fully fall back
without mixing fields or lowering server risk.
Co-authored-by: Cursor
---
internal/docrevision/flags.go | 45 ++++
internal/docrevision/narrative.go | 292 +++++++++++++++++++++++++
internal/docrevision/narrative_test.go | 225 +++++++++++++++++++
internal/docrevision/service.go | 10 +-
4 files changed, 564 insertions(+), 8 deletions(-)
create mode 100644 internal/docrevision/flags.go
create mode 100644 internal/docrevision/narrative.go
create mode 100644 internal/docrevision/narrative_test.go
diff --git a/internal/docrevision/flags.go b/internal/docrevision/flags.go
new file mode 100644
index 00000000..1f1bc58c
--- /dev/null
+++ b/internal/docrevision/flags.go
@@ -0,0 +1,45 @@
+package docrevision
+
+import (
+ "os"
+ "strings"
+ "sync"
+)
+
+// EnvPIChangeReportNarrative gates optional PI change-report narrative enrichment.
+// Default off: deterministic fallback only.
+const EnvPIChangeReportNarrative = "PI_CHANGE_REPORT_NARRATIVE"
+
+var (
+ flagsMu sync.RWMutex
+ narrativeOn = envBool(EnvPIChangeReportNarrative, false)
+)
+
+func envBool(key string, def bool) bool {
+ v := strings.TrimSpace(os.Getenv(key))
+ if v == "" {
+ return def
+ }
+ return v == "1" || strings.EqualFold(v, "true") || strings.EqualFold(v, "yes")
+}
+
+// PIChangeReportNarrativeEnabled reports whether PI narrative enrichment is on.
+func PIChangeReportNarrativeEnabled() bool {
+ flagsMu.RLock()
+ defer flagsMu.RUnlock()
+ return narrativeOn
+}
+
+// SetPIChangeReportNarrativeForTest overrides the flag in tests.
+func SetPIChangeReportNarrativeForTest(on bool) {
+ flagsMu.Lock()
+ narrativeOn = on
+ flagsMu.Unlock()
+}
+
+// ResetPIChangeReportNarrativeForTest restores the flag from the process environment.
+func ResetPIChangeReportNarrativeForTest() {
+ flagsMu.Lock()
+ narrativeOn = envBool(EnvPIChangeReportNarrative, false)
+ flagsMu.Unlock()
+}
diff --git a/internal/docrevision/narrative.go b/internal/docrevision/narrative.go
new file mode 100644
index 00000000..5153e8af
--- /dev/null
+++ b/internal/docrevision/narrative.go
@@ -0,0 +1,292 @@
+package docrevision
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "sort"
+ "strings"
+)
+
+const (
+ // NarrativeSchemaKind identifies the typed PI change-report narrative payload.
+ NarrativeSchemaKind = "change_report_narrative_v1"
+ // NarrativeSchemaVersion is the only accepted schema_version for narrative payloads.
+ NarrativeSchemaVersion = 1
+)
+
+// NarrativeInputDigest binds the PI narrative to immutable server facts (diff + lineage + risk floor).
+// Callers must recompute this digest and reject any payload whose input_digest differs.
+func NarrativeInputDigest(fromSHA, toSHA, ruleRisk string, impacts []DocImpact, hunks []DiffHunk) string {
+ type hunkRef struct {
+ ID string `json:"id"`
+ Kind string `json:"kind"`
+ Section string `json:"section,omitempty"`
+ }
+ type impactRef struct {
+ DocID string `json:"doc_id"`
+ ImpactType string `json:"impact_type"`
+ Reason string `json:"reason"`
+ }
+ hrefs := make([]hunkRef, 0, len(hunks))
+ for _, h := range hunks {
+ hrefs = append(hrefs, hunkRef{ID: h.ID, Kind: h.Kind, Section: h.Section})
+ }
+ irefs := make([]impactRef, 0, len(impacts))
+ for _, imp := range impacts {
+ irefs = append(irefs, impactRef{DocID: imp.DocID, ImpactType: imp.ImpactType, Reason: imp.Reason})
+ }
+ sort.Slice(irefs, func(i, j int) bool {
+ if irefs[i].DocID != irefs[j].DocID {
+ return irefs[i].DocID < irefs[j].DocID
+ }
+ return irefs[i].ImpactType < irefs[j].ImpactType
+ })
+ payload, _ := json.Marshal(map[string]any{
+ "from_sha": fromSHA,
+ "to_sha": toSHA,
+ "rule_risk": ruleRisk,
+ "impacts": irefs,
+ "hunks": hrefs,
+ })
+ sum := sha256.Sum256(payload)
+ return hex.EncodeToString(sum[:])
+}
+
+// ResolveChangeReport selects deterministic fallback and optionally overlays a typed PI narrative.
+//
+// Flag OFF (default): empty ReportJSON or narrative-shaped payload → deterministic fallback;
+// legacy caller-supplied change_report.json still validates (may return ErrReportInvalid).
+//
+// Flag ON: empty / malformed / timeout / digest mismatch / partial → full deterministic fallback
+// only (no mixed PI fields, no ErrReportInvalid). Valid narrative overlays summary/open_questions
+// etc. but never overrides server risk level, document impacts, or confirmation policy.
+func ResolveChangeReport(raw string, fromSHA, toSHA, ruleRisk string, impacts []DocImpact, diff DiffResult) (*ChangeReport, error) {
+ det := mustDeterministicReport(fromSHA, toSHA, ruleRisk, impacts, diff)
+
+ if !PIChangeReportNarrativeEnabled() {
+ if strings.TrimSpace(raw) == "" || looksLikeNarrativePayload(raw) {
+ return det, nil
+ }
+ rep, err := ValidateChangeReportJSON(raw, fromSHA, toSHA, ruleRisk, impacts, diff.Hunks)
+ return rep, err
+ }
+
+ if strings.TrimSpace(raw) == "" {
+ return det, nil
+ }
+ expected := NarrativeInputDigest(fromSHA, toSHA, ruleRisk, impacts, diff.Hunks)
+ narr, ok := parseNarrativePayload(raw, expected, fromSHA, toSHA)
+ if !ok {
+ return det, nil
+ }
+ return mergeNarrativeOntoDeterministic(det, narr, ruleRisk, impacts, fromSHA, toSHA, diff), nil
+}
+
+func mustDeterministicReport(fromSHA, toSHA, ruleRisk string, impacts []DocImpact, diff DiffResult) *ChangeReport {
+ raw := BuildDeterministicChangeReportJSON(fromSHA, toSHA, ruleRisk, impacts, diff)
+ rep, err := ValidateChangeReportJSON(raw, fromSHA, toSHA, ruleRisk, impacts, diff.Hunks)
+ if err != nil || rep == nil {
+ fb := fallbackReport(fromSHA, toSHA, ruleRisk, impacts, diff.Hunks, "deterministic build")
+ fb.AnalysisStatus = AnalysisFallback
+ return fb
+ }
+ rep.AnalysisStatus = AnalysisFallback
+ if rep.DeterministicFacts == nil {
+ rep.DeterministicFacts = map[string]any{}
+ }
+ rep.DeterministicFacts["input_digest"] = NarrativeInputDigest(fromSHA, toSHA, ruleRisk, impacts, diff.Hunks)
+ rep.DeterministicFacts["narrative_flag"] = PIChangeReportNarrativeEnabled()
+ return rep
+}
+
+type narrativePayload struct {
+ SchemaVersion int `json:"schema_version"`
+ SchemaKind string `json:"schema_kind"`
+ InputDigest string `json:"input_digest"`
+ FromSHA string `json:"from_sha"`
+ ToSHA string `json:"to_sha"`
+ Summary []string `json:"summary"`
+ RiskItems []RiskItem `json:"risk_items"`
+ ProjectImpact map[string]any `json:"project_impact"`
+ OpenQuestions []string `json:"open_questions"`
+ ErrorCode string `json:"error_code"`
+ Outcome string `json:"outcome"`
+ Partial bool `json:"partial"`
+ DocumentImpacts []DocImpact `json:"document_impacts"` // ignored for authority
+ Risk *struct {
+ Level string `json:"level"`
+ Items []RiskItem `json:"items"`
+ } `json:"risk"` // level ignored; items may feed narrative annotations
+}
+
+func looksLikeNarrativePayload(raw string) bool {
+ var probe struct {
+ SchemaKind string `json:"schema_kind"`
+ Narrative json.RawMessage `json:"narrative"`
+ CRN json.RawMessage `json:"change_report_narrative"`
+ ResultV1 json.RawMessage `json:"result_v1"`
+ }
+ if json.Unmarshal([]byte(raw), &probe) != nil {
+ return false
+ }
+ if probe.SchemaKind == NarrativeSchemaKind {
+ return true
+ }
+ if len(probe.Narrative) > 0 || len(probe.CRN) > 0 {
+ return true
+ }
+ if len(probe.ResultV1) > 0 {
+ var meta struct {
+ ErrorCode string `json:"error_code"`
+ Outcome string `json:"outcome"`
+ }
+ if json.Unmarshal(probe.ResultV1, &meta) == nil && (meta.ErrorCode != "" || strings.EqualFold(meta.Outcome, "error")) {
+ return true
+ }
+ }
+ return false
+}
+
+func parseNarrativePayload(raw, expectedDigest, fromSHA, toSHA string) (*narrativePayload, bool) {
+ trimmed := strings.TrimSpace(raw)
+ if trimmed == "" {
+ return nil, false
+ }
+ var envelope map[string]json.RawMessage
+ if err := json.Unmarshal([]byte(trimmed), &envelope); err != nil {
+ return nil, false
+ }
+ if rv, ok := envelope["result_v1"]; ok {
+ var meta struct {
+ Outcome string `json:"outcome"`
+ ErrorCode string `json:"error_code"`
+ }
+ if json.Unmarshal(rv, &meta) == nil {
+ if strings.EqualFold(meta.Outcome, "error") || strings.TrimSpace(meta.ErrorCode) != "" {
+ return nil, false
+ }
+ }
+ }
+ body := []byte(trimmed)
+ if nraw, ok := envelope["narrative"]; ok {
+ body = nraw
+ } else if nraw, ok := envelope["change_report_narrative"]; ok {
+ body = nraw
+ }
+
+ var p narrativePayload
+ if err := json.Unmarshal(body, &p); err != nil {
+ return nil, false
+ }
+ if p.Partial {
+ return nil, false
+ }
+ if code := strings.TrimSpace(p.ErrorCode); code != "" {
+ return nil, false
+ }
+ if outcome := strings.ToLower(strings.TrimSpace(p.Outcome)); outcome == "error" || outcome == "timeout" || outcome == "cancelled" {
+ return nil, false
+ }
+ if p.SchemaVersion != NarrativeSchemaVersion {
+ return nil, false
+ }
+ if p.SchemaKind != NarrativeSchemaKind {
+ return nil, false
+ }
+ if strings.TrimSpace(p.InputDigest) == "" || !strings.EqualFold(p.InputDigest, expectedDigest) {
+ return nil, false
+ }
+ if strings.TrimSpace(p.FromSHA) != "" && !strings.EqualFold(p.FromSHA, fromSHA) {
+ return nil, false
+ }
+ if strings.TrimSpace(p.ToSHA) != "" && !strings.EqualFold(p.ToSHA, toSHA) {
+ return nil, false
+ }
+ if fromSHA != toSHA {
+ if len(filterNonEmpty(p.Summary)) == 0 {
+ return nil, false
+ }
+ if strings.EqualFold(strings.Join(p.Summary, " "), "未修改") {
+ return nil, false
+ }
+ }
+ return &p, true
+}
+
+func mergeNarrativeOntoDeterministic(det *ChangeReport, narr *narrativePayload, ruleRisk string, impacts []DocImpact, fromSHA, toSHA string, diff DiffResult) *ChangeReport {
+ if det == nil {
+ det = fallbackReport(fromSHA, toSHA, ruleRisk, impacts, diff.Hunks, "missing deterministic base")
+ }
+ riskItems := narr.RiskItems
+ if len(riskItems) == 0 && narr.Risk != nil {
+ riskItems = narr.Risk.Items
+ }
+ if len(riskItems) == 0 && (ruleRisk == RiskHigh || ruleRisk == RiskCritical) {
+ riskItems = det.RiskItems
+ }
+
+ facts := map[string]any{}
+ for k, v := range det.DeterministicFacts {
+ facts[k] = v
+ }
+ facts["from_sha"] = fromSHA
+ facts["to_sha"] = toSHA
+ facts["rule_risk"] = ruleRisk
+ facts["hunk_count"] = len(diff.Hunks)
+ facts["impact_count"] = len(impacts)
+ facts["input_digest"] = NarrativeInputDigest(fromSHA, toSHA, ruleRisk, impacts, diff.Hunks)
+ facts["narrative_applied"] = true
+ if narr.Risk != nil && strings.TrimSpace(narr.Risk.Level) != "" {
+ facts["pi_risk_claimed"] = narr.Risk.Level
+ }
+
+ proj := narr.ProjectImpact
+ if proj == nil {
+ proj = det.ProjectImpacts
+ }
+
+ return &ChangeReport{
+ SchemaVersion: NarrativeSchemaVersion,
+ Summary: filterNonEmpty(narr.Summary),
+ RiskLevel: ruleRisk,
+ RiskItems: riskItems,
+ ProjectImpacts: proj,
+ DocumentImpacts: impacts,
+ OpenQuestions: filterNonEmpty(narr.OpenQuestions),
+ AnalysisStatus: AnalysisComplete,
+ DeterministicFacts: facts,
+ RawJSON: "",
+ }
+}
+
+func filterNonEmpty(in []string) []string {
+ out := make([]string, 0, len(in))
+ for _, s := range in {
+ if strings.TrimSpace(s) != "" {
+ out = append(out, s)
+ }
+ }
+ 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)
+ 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,
+ "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,
+ ),
+ }
+}
diff --git a/internal/docrevision/narrative_test.go b/internal/docrevision/narrative_test.go
new file mode 100644
index 00000000..452f224c
--- /dev/null
+++ b/internal/docrevision/narrative_test.go
@@ -0,0 +1,225 @@
+package docrevision_test
+
+import (
+ "context"
+ "encoding/json"
+ "testing"
+
+ "chat-agent-system/internal/docrevision"
+)
+
+func sampleDiff(t *testing.T) (fromSHA, toSHA, ruleRisk string, impacts []docrevision.DocImpact, diff docrevision.DiffResult) {
+ t.Helper()
+ before := docrevision.CanonicalizeMarkdown("# A\n\n旧文\n")
+ after := docrevision.CanonicalizeMarkdown("# A\n\n新文 REQ-1 验收标准\n")
+ fb := docrevision.BuildBlockMap(before, nil)
+ ab := docrevision.BuildBlockMap(after, fb)
+ diff = docrevision.ComputeDiff(before, after, fb, ab)
+ fromSHA = docrevision.SHA256Hex(before)
+ toSHA = docrevision.SHA256Hex(after)
+ impacts = []docrevision.DocImpact{{
+ DocID: "doc_down", ImpactType: "stale_candidate", Reason: "上游更新",
+ RecommendedAction: "复核",
+ }}
+ ruleRisk = docrevision.RuleRiskFloor(diff.Hunks, impacts)
+ return fromSHA, toSHA, ruleRisk, impacts, diff
+}
+
+func TestPIChangeReportNarrativeFlagDefaultOff(t *testing.T) {
+ docrevision.ResetPIChangeReportNarrativeForTest()
+ t.Cleanup(docrevision.ResetPIChangeReportNarrativeForTest)
+ if docrevision.PIChangeReportNarrativeEnabled() {
+ t.Fatal("PI_CHANGE_REPORT_NARRATIVE must default off")
+ }
+}
+
+func TestResolveChangeReport_FlagOffUsesDeterministic(t *testing.T) {
+ docrevision.SetPIChangeReportNarrativeForTest(false)
+ t.Cleanup(docrevision.ResetPIChangeReportNarrativeForTest)
+
+ fromSHA, toSHA, ruleRisk, impacts, diff := sampleDiff(t)
+ digest := docrevision.NarrativeInputDigest(fromSHA, toSHA, ruleRisk, impacts, diff.Hunks)
+ piRaw, _ := json.Marshal(map[string]any{
+ "schema_version": 1,
+ "schema_kind": docrevision.NarrativeSchemaKind,
+ "input_digest": digest,
+ "from_sha": fromSHA,
+ "to_sha": toSHA,
+ "summary": []string{"PI 不应在 flag off 时生效"},
+ "open_questions": []string{"q"},
+ "risk": map[string]any{"level": "low", "items": []any{}},
+ })
+
+ rep, err := docrevision.ResolveChangeReport(string(piRaw), fromSHA, toSHA, ruleRisk, impacts, diff)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if rep.AnalysisStatus != docrevision.AnalysisFallback {
+ t.Fatalf("flag off must ignore narrative payload, want fallback got %s", rep.AnalysisStatus)
+ }
+ for _, s := range rep.Summary {
+ if s == "PI 不应在 flag off 时生效" {
+ t.Fatal("PI narrative must not apply when flag off")
+ }
+ }
+
+ rep2, err := docrevision.ResolveChangeReport("", fromSHA, toSHA, ruleRisk, impacts, diff)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if rep2.AnalysisStatus != docrevision.AnalysisFallback {
+ t.Fatalf("flag off empty raw want fallback got %s", rep2.AnalysisStatus)
+ }
+}
+
+func TestResolveChangeReport_FlagOnValidNarrativeKeepsServerRisk(t *testing.T) {
+ docrevision.SetPIChangeReportNarrativeForTest(true)
+ t.Cleanup(docrevision.ResetPIChangeReportNarrativeForTest)
+
+ fromSHA, toSHA, ruleRisk, impacts, diff := sampleDiff(t)
+ if ruleRisk != docrevision.RiskHigh && ruleRisk != docrevision.RiskCritical {
+ t.Fatalf("fixture should elevate rule risk, got %s", ruleRisk)
+ }
+ digest := docrevision.NarrativeInputDigest(fromSHA, toSHA, ruleRisk, impacts, diff.Hunks)
+ piRaw, _ := json.Marshal(map[string]any{
+ "schema_version": 1,
+ "schema_kind": docrevision.NarrativeSchemaKind,
+ "input_digest": digest,
+ "from_sha": fromSHA,
+ "to_sha": toSHA,
+ "summary": []string{"补充验收阈值说明"},
+ "open_questions": []string{"硬件是否已确认"},
+ "project_impact": map[string]any{"summary": "叙事:验证范围扩大"},
+ "risk": map[string]any{"level": "low", "items": []map[string]any{{"category": "pi", "description": "试图降风险"}}},
+ "document_impacts": []map[string]any{{
+ "doc_id": "forged_doc", "impact_type": "none", "reason": "PI 伪造影响",
+ }},
+ })
+
+ rep, err := docrevision.ResolveChangeReport(string(piRaw), fromSHA, toSHA, ruleRisk, impacts, diff)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if rep.AnalysisStatus != docrevision.AnalysisComplete {
+ t.Fatalf("want complete got %s", rep.AnalysisStatus)
+ }
+ if rep.RiskLevel != ruleRisk {
+ t.Fatalf("PI must not override server risk: want %s got %s", ruleRisk, rep.RiskLevel)
+ }
+ if len(rep.Summary) != 1 || rep.Summary[0] != "补充验收阈值说明" {
+ t.Fatalf("narrative summary: %+v", rep.Summary)
+ }
+ if len(rep.DocumentImpacts) != 1 || rep.DocumentImpacts[0].DocID != "doc_down" {
+ t.Fatalf("server lineage must win, got %+v", rep.DocumentImpacts)
+ }
+ if rep.DeterministicFacts["narrative_applied"] != true {
+ t.Fatalf("facts: %+v", rep.DeterministicFacts)
+ }
+}
+
+func TestResolveChangeReport_FlagOnMalformedFullFallback(t *testing.T) {
+ docrevision.SetPIChangeReportNarrativeForTest(true)
+ t.Cleanup(docrevision.ResetPIChangeReportNarrativeForTest)
+
+ fromSHA, toSHA, ruleRisk, impacts, diff := sampleDiff(t)
+ cases := []struct {
+ name string
+ raw string
+ }{
+ {"bad json", `{not-json`},
+ {"bad schema version", `{"schema_version":99,"schema_kind":"change_report_narrative_v1","input_digest":"x","summary":["a"]}`},
+ {"digest mismatch", mustNarrativeJSON(t, fromSHA, toSHA, "deadbeef", []string{"ok"})},
+ {"timeout envelope", `{"result_v1":{"outcome":"error","error_code":"TIMEOUT"},"narrative":{"schema_version":1,"schema_kind":"change_report_narrative_v1"}}`},
+ {"partial flag", `{"schema_version":1,"schema_kind":"change_report_narrative_v1","input_digest":"x","partial":true,"summary":["a"]}`},
+ {"empty summary", mustNarrativeJSON(t, fromSHA, toSHA, docrevision.NarrativeInputDigest(fromSHA, toSHA, ruleRisk, impacts, diff.Hunks), nil)},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ rep, err := docrevision.ResolveChangeReport(tc.raw, fromSHA, toSHA, ruleRisk, impacts, diff)
+ if err != nil {
+ t.Fatalf("flag-on fallback must not error: %v", err)
+ }
+ if rep.AnalysisStatus != docrevision.AnalysisFallback {
+ t.Fatalf("want full fallback got %s", rep.AnalysisStatus)
+ }
+ if rep.RiskLevel != ruleRisk {
+ t.Fatalf("fallback risk want %s got %s", ruleRisk, rep.RiskLevel)
+ }
+ if len(rep.DocumentImpacts) != len(impacts) {
+ t.Fatalf("fallback must keep server impacts")
+ }
+ for _, s := range rep.Summary {
+ if s == "补充验收阈值说明" || s == "PI only" {
+ t.Fatalf("mixed narrative field leaked: %q", s)
+ }
+ }
+ })
+ }
+}
+
+func mustNarrativeJSON(t *testing.T, fromSHA, toSHA, digest string, summary []string) string {
+ t.Helper()
+ raw, err := json.Marshal(map[string]any{
+ "schema_version": 1,
+ "schema_kind": docrevision.NarrativeSchemaKind,
+ "input_digest": digest,
+ "from_sha": fromSHA,
+ "to_sha": toSHA,
+ "summary": summary,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ return string(raw)
+}
+
+func TestCreateAgentCandidate_FlagOffIgnoresNarrativeEnrichment(t *testing.T) {
+ docrevision.SetPIChangeReportNarrativeForTest(false)
+ t.Cleanup(docrevision.ResetPIChangeReportNarrativeForTest)
+
+ svc := docrevision.NewService(nil)
+ ctx := context.Background()
+ st, err := svc.OpenTemplateVersion(ctx, "doc_flag_off", "# base\n\nbody\n", docrevision.ActorSnapshot{DisplayName: "t"}, 1, "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ cand, rep, err := svc.CreateAgentCandidate(ctx, docrevision.AgentCandidateRequest{
+ DocID: "doc_flag_off", RunID: "r1", InputSnapshotID: st.HeadSnapshotID,
+ OutputMarkdown: "# base\n\nbody changed\n",
+ ReportJSON: "",
+ ActorKind: docrevision.ActorAgent,
+ })
+ if err != nil && cand == nil {
+ t.Fatal(err)
+ }
+ if rep.AnalysisStatus != docrevision.AnalysisFallback {
+ t.Fatalf("flag off empty report want fallback got %s", rep.AnalysisStatus)
+ }
+}
+
+func TestCreateAgentCandidate_FlagOnTimeoutFallsBackWithoutError(t *testing.T) {
+ docrevision.SetPIChangeReportNarrativeForTest(true)
+ t.Cleanup(docrevision.ResetPIChangeReportNarrativeForTest)
+
+ svc := docrevision.NewService(nil)
+ ctx := context.Background()
+ st, err := svc.OpenTemplateVersion(ctx, "doc_flag_on_to", "# base\n\nbody\n", docrevision.ActorSnapshot{DisplayName: "t"}, 1, "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ cand, rep, err := svc.CreateAgentCandidate(ctx, docrevision.AgentCandidateRequest{
+ DocID: "doc_flag_on_to", RunID: "r-timeout", InputSnapshotID: st.HeadSnapshotID,
+ OutputMarkdown: "# base\n\nbody changed\n",
+ ReportJSON: `{"result_v1":{"outcome":"error","error_code":"TIMEOUT"}}`,
+ ActorKind: docrevision.ActorAgent,
+ })
+ if err != nil {
+ t.Fatalf("timeout under flag on must full-fallback without error, got %v", err)
+ }
+ if cand == nil || rep == nil {
+ t.Fatal("expected candidate+report")
+ }
+ if rep.AnalysisStatus != docrevision.AnalysisFallback {
+ t.Fatalf("want fallback got %s", rep.AnalysisStatus)
+ }
+}
diff --git a/internal/docrevision/service.go b/internal/docrevision/service.go
index 6c637c2d..3dc7d3f2 100644
--- a/internal/docrevision/service.go
+++ b/internal/docrevision/service.go
@@ -492,22 +492,16 @@ func (s *Service) CreateAgentCandidate(ctx context.Context, req AgentCandidateRe
diff := ComputeDiff(inMD, canon, inSnap.BlockMap, outSnap.BlockMap)
impacts := s.impactLocked(ctx, req.DocID)
ruleRisk := RuleRiskFloor(diff.Hunks, impacts)
- reportJSON := req.ReportJSON
- if strings.TrimSpace(reportJSON) == "" {
- reportJSON = BuildDeterministicChangeReportJSON(inSnap.ContentSHA256, outSHA, ruleRisk, impacts, diff)
- }
- rep, err := ValidateChangeReportJSON(reportJSON, inSnap.ContentSHA256, outSHA, ruleRisk, impacts, diff.Hunks)
+ rep, err := ResolveChangeReport(req.ReportJSON, inSnap.ContentSHA256, outSHA, ruleRisk, impacts, diff)
status := CandidateReady
if err != nil {
// Invalid reports stay visible as candidates but must not look "ready" for auto-commit.
+ // (Flag-off legacy path only; flag-on narrative failures already full-fallback with err=nil.)
status = CandidateNeedsConfirmation
if rep != nil {
rep.AnalysisStatus = AnalysisUnassessed
}
}
- if strings.TrimSpace(req.ReportJSON) == "" && rep != nil && rep.AnalysisStatus == AnalysisComplete {
- rep.AnalysisStatus = AnalysisFallback
- }
if NeedsConfirmation(rep.RiskLevel) {
status = CandidateNeedsConfirmation
}
From 51568ea080c324e4e6efbc4d50640c55d9a11904 Mon Sep 17 00:00:00 2001
From: weichen
Date: Wed, 16 Sep 2026 15:39:54 +0000
Subject: [PATCH 2/3] feat(web): distinguish change-report narrative vs factual
fields
Panel and view-model label server risk/lineage separately from PI narrative;
add change_report_narrative_v1 helper aligned with typed-result patterns.
Co-authored-by: Cursor
---
.../pi-agent/change-report-narrative-v1.mjs | 69 ++++++++++
.../change-report-narrative-v1.test.mjs | 32 +++++
web/src/components/doc/ChangeReportPanel.tsx | 123 +++++++++++-------
.../documentRevision/changeReportView.ts | 31 ++++-
.../documentRevision/overlayMode.test.ts | 23 ++++
web/src/features/documentRevision/types.ts | 2 +
6 files changed, 232 insertions(+), 48 deletions(-)
create mode 100644 deploy/pi-agent/change-report-narrative-v1.mjs
create mode 100644 deploy/pi-agent/change-report-narrative-v1.test.mjs
diff --git a/deploy/pi-agent/change-report-narrative-v1.mjs b/deploy/pi-agent/change-report-narrative-v1.mjs
new file mode 100644
index 00000000..6775ca2b
--- /dev/null
+++ b/deploy/pi-agent/change-report-narrative-v1.mjs
@@ -0,0 +1,69 @@
+/**
+ * change_report_narrative_v1 — typed schema helper aligned with Go docrevision.NarrativeSchemaKind.
+ * Consumed only when PI_CHANGE_REPORT_NARRATIVE is on; server never trusts risk.level / document_impacts.
+ */
+
+export const NARRATIVE_SCHEMA_KIND = "change_report_narrative_v1";
+export const NARRATIVE_SCHEMA_VERSION = 1;
+
+/**
+ * Validate a PI narrative object. Returns { ok, reason }.
+ * Does not authorize risk or impacts — server recomputes those.
+ */
+export function validateChangeReportNarrative(payload, expected = {}) {
+ if (!payload || typeof payload !== "object") {
+ return { ok: false, reason: "empty" };
+ }
+ if (payload.partial === true) {
+ return { ok: false, reason: "partial" };
+ }
+ if (payload.error_code || String(payload.outcome || "").toLowerCase() === "error") {
+ return { ok: false, reason: "error_envelope" };
+ }
+ if (payload.schema_version !== NARRATIVE_SCHEMA_VERSION) {
+ return { ok: false, reason: "schema_version" };
+ }
+ if (payload.schema_kind !== NARRATIVE_SCHEMA_KIND) {
+ return { ok: false, reason: "schema_kind" };
+ }
+ if (!payload.input_digest || typeof payload.input_digest !== "string") {
+ return { ok: false, reason: "input_digest_missing" };
+ }
+ if (expected.input_digest && payload.input_digest !== expected.input_digest) {
+ return { ok: false, reason: "input_digest_mismatch" };
+ }
+ if (expected.from_sha && payload.from_sha && payload.from_sha !== expected.from_sha) {
+ return { ok: false, reason: "from_sha_mismatch" };
+ }
+ if (expected.to_sha && payload.to_sha && payload.to_sha !== expected.to_sha) {
+ return { ok: false, reason: "to_sha_mismatch" };
+ }
+ const summary = Array.isArray(payload.summary) ? payload.summary.filter(Boolean) : [];
+ if (expected.from_sha && expected.to_sha && expected.from_sha !== expected.to_sha && summary.length === 0) {
+ return { ok: false, reason: "summary_required" };
+ }
+ return { ok: true, reason: "" };
+}
+
+/** Build a success narrative skeleton for tests / stubs. */
+export function buildChangeReportNarrative({
+ inputDigest,
+ fromSha,
+ toSha,
+ summary = [],
+ openQuestions = [],
+ projectImpact = {},
+ riskItems = [],
+} = {}) {
+ return {
+ schema_version: NARRATIVE_SCHEMA_VERSION,
+ schema_kind: NARRATIVE_SCHEMA_KIND,
+ input_digest: String(inputDigest || ""),
+ from_sha: String(fromSha || ""),
+ to_sha: String(toSha || ""),
+ summary,
+ open_questions: openQuestions,
+ project_impact: projectImpact,
+ risk_items: riskItems,
+ };
+}
diff --git a/deploy/pi-agent/change-report-narrative-v1.test.mjs b/deploy/pi-agent/change-report-narrative-v1.test.mjs
new file mode 100644
index 00000000..7d82972a
--- /dev/null
+++ b/deploy/pi-agent/change-report-narrative-v1.test.mjs
@@ -0,0 +1,32 @@
+import assert from "node:assert/strict";
+import {
+ NARRATIVE_SCHEMA_KIND,
+ buildChangeReportNarrative,
+ validateChangeReportNarrative,
+} from "./change-report-narrative-v1.mjs";
+
+const digest = "abc123";
+const good = buildChangeReportNarrative({
+ inputDigest: digest,
+ fromSha: "f1",
+ toSha: "t1",
+ summary: ["阈值调整"],
+});
+assert.equal(good.schema_kind, NARRATIVE_SCHEMA_KIND);
+assert.equal(validateChangeReportNarrative(good, { input_digest: digest, from_sha: "f1", to_sha: "t1" }).ok, true);
+
+assert.equal(validateChangeReportNarrative({ ...good, partial: true }, { input_digest: digest }).ok, false);
+assert.equal(validateChangeReportNarrative({ ...good, input_digest: "other" }, { input_digest: digest }).ok, false);
+assert.equal(
+ validateChangeReportNarrative(
+ { ...good, summary: [] },
+ { input_digest: digest, from_sha: "f1", to_sha: "t1" },
+ ).ok,
+ false,
+);
+assert.equal(
+ validateChangeReportNarrative({ error_code: "TIMEOUT", schema_kind: NARRATIVE_SCHEMA_KIND }).ok,
+ false,
+);
+
+console.log("change-report-narrative-v1.test.mjs: ok");
diff --git a/web/src/components/doc/ChangeReportPanel.tsx b/web/src/components/doc/ChangeReportPanel.tsx
index 1d68f13d..0665067d 100644
--- a/web/src/components/doc/ChangeReportPanel.tsx
+++ b/web/src/components/doc/ChangeReportPanel.tsx
@@ -89,53 +89,82 @@ export function ChangeReportPanel(props: {
({vm.analysisNote})
) : null}
- 摘要
-
- {vm.summary.length === 0 ? - (无摘要)
: null}
- {vm.summary.map((s) => (
- - {s}
- ))}
-
- 风险条目
-
- {vm.riskItems.length === 0 ? - (无风险条目)
: null}
- {vm.riskItems.map((it, i) => (
- -
- {it.category || '风险'}:{it.description}
- {it.mitigation ?
缓解:{it.mitigation}
: null}
-
- ))}
-
- 项目影响
-
- {vm.projectImpactLines.length === 0 ? - (无项目影响说明)
: null}
- {vm.projectImpactLines.map((line) => (
- - {line}
- ))}
-
- 文档影响
-
- {vm.documentImpacts.length === 0 ? (
- - 无(或尚未登记下游 lineage)
- ) : null}
- {vm.documentImpacts.map((d) => (
- -
- {d.docId}
- {d.impactType ? ` [${d.impactType}]` : ''}
- {d.reason ? `:${d.reason}` : ''}
- {d.recommendedAction ? (
-
建议:{d.recommendedAction}
- ) : null}
-
- ))}
-
- 开放问题
-
- {vm.openQuestions.length === 0 ? - (无开放问题)
: null}
- {vm.openQuestions.map((q) => (
- - {q}
- ))}
-
+
+
+ 事实字段(服务端)
+
+ 风险等级与文档影响由服务端 diff + lineage 计算,PI 不可覆盖审批结论。
+
+
+ {vm.factualNotes.length === 0 ? (
+ - 风险:{vm.riskLevel || '(未评估)'}
+ ) : null}
+ {vm.factualNotes.map((n) => (
+ - {n}
+ ))}
+
+ 文档影响
+
+ {vm.documentImpacts.length === 0 ? (
+ - 无(或尚未登记下游 lineage)
+ ) : null}
+ {vm.documentImpacts.map((d) => (
+ -
+ {d.docId}
+ {d.impactType ? ` [${d.impactType}]` : ''}
+ {d.reason ? `:${d.reason}` : ''}
+ {d.recommendedAction ? (
+
建议:{d.recommendedAction}
+ ) : null}
+
+ ))}
+
+
+
+
+
+ 叙事字段({vm.narrativeSourceLabel})
+
+ {vm.isNarrativeComplete ? 'PI' : 'fallback'}
+
+
+ 摘要
+
+ {vm.summary.length === 0 ? - (无摘要)
: null}
+ {vm.summary.map((s) => (
+ - {s}
+ ))}
+
+ 风险条目(说明性)
+
+ {vm.riskItems.length === 0 ? - (无风险条目)
: null}
+ {vm.riskItems.map((it, i) => (
+ -
+ {it.category || '风险'}:{it.description}
+ {it.mitigation ?
缓解:{it.mitigation}
: null}
+
+ ))}
+
+ 项目影响
+
+ {vm.projectImpactLines.length === 0 ? (
+ - (无项目影响说明)
+ ) : null}
+ {vm.projectImpactLines.map((line) => (
+ - {line}
+ ))}
+
+ 开放问题
+
+ {vm.openQuestions.length === 0 ? - (无开放问题)
: null}
+ {vm.openQuestions.map((q) => (
+ - {q}
+ ))}
+
+
>
) : null}
{props.pendingDecision ? (
diff --git a/web/src/features/documentRevision/changeReportView.ts b/web/src/features/documentRevision/changeReportView.ts
index 798f001c..01a646ca 100644
--- a/web/src/features/documentRevision/changeReportView.ts
+++ b/web/src/features/documentRevision/changeReportView.ts
@@ -4,6 +4,10 @@ export type ChangeReportViewModel = {
riskLevel: string;
analysisStatus: string;
analysisNote: string | null;
+ /** Whether analysis_status is complete PI narrative (vs deterministic fallback). */
+ isNarrativeComplete: boolean;
+ /** Label for the narrative section source. */
+ narrativeSourceLabel: string;
summary: string[];
riskItems: { category: string; description: string; mitigation?: string }[];
projectImpactLines: string[];
@@ -14,6 +18,8 @@ export type ChangeReportViewModel = {
recommendedAction: string;
}[];
openQuestions: string[];
+ /** Server-bound factual keys from deterministic_facts when present. */
+ factualNotes: string[];
};
/** Pure projection of ChangeReport for readable UI (testable without DOM). */
@@ -39,15 +45,37 @@ export function buildChangeReportViewModel(report: ChangeReport): ChangeReportVi
}
}
+ const isNarrativeComplete = report.analysis_status === 'complete';
const analysisNote =
report.analysis_status === 'fallback' || report.analysis_status === 'unassessed'
? '确定性分析,非完整 PI 叙事'
- : null;
+ : isNarrativeComplete
+ ? '含 PI 叙事;风险与文档影响仍以服务端为准'
+ : null;
+
+ const factualNotes: string[] = [];
+ const facts = report.deterministic_facts;
+ if (facts && typeof facts === 'object') {
+ if (typeof facts.rule_risk === 'string' && facts.rule_risk) {
+ factualNotes.push(`规则风险下限:${facts.rule_risk}`);
+ }
+ if (typeof facts.hunk_count === 'number') {
+ factualNotes.push(`差异块数:${facts.hunk_count}`);
+ }
+ if (typeof facts.impact_count === 'number') {
+ factualNotes.push(`下游影响数:${facts.impact_count}`);
+ }
+ if (typeof facts.input_digest === 'string' && facts.input_digest) {
+ factualNotes.push(`输入摘要:${String(facts.input_digest).slice(0, 12)}…`);
+ }
+ }
return {
riskLevel: report.risk_level || '',
analysisStatus: report.analysis_status || '',
analysisNote,
+ isNarrativeComplete,
+ narrativeSourceLabel: isNarrativeComplete ? 'PI 叙事' : '确定性 fallback',
summary: Array.isArray(report.summary) ? report.summary.filter(Boolean) : [],
riskItems: Array.isArray(report.risk_items) ? report.risk_items : [],
projectImpactLines,
@@ -58,6 +86,7 @@ export function buildChangeReportViewModel(report: ChangeReport): ChangeReportVi
recommendedAction: d.recommended_action || '',
})),
openQuestions: Array.isArray(report.open_questions) ? report.open_questions.filter(Boolean) : [],
+ factualNotes,
};
}
diff --git a/web/src/features/documentRevision/overlayMode.test.ts b/web/src/features/documentRevision/overlayMode.test.ts
index aec05a16..f584dd78 100644
--- a/web/src/features/documentRevision/overlayMode.test.ts
+++ b/web/src/features/documentRevision/overlayMode.test.ts
@@ -155,6 +155,8 @@ describe('change report view model', () => {
});
assert.equal(vm.riskLevel, 'high');
assert.equal(vm.analysisNote, '确定性分析,非完整 PI 叙事');
+ assert.equal(vm.isNarrativeComplete, false);
+ assert.equal(vm.narrativeSourceLabel, '确定性 fallback');
assert.equal(vm.riskItems.length, 1);
assert.match(vm.riskItems[0].description, /敏感删除/);
assert.ok(vm.projectImpactLines.some((l) => l.includes('下游受影响')));
@@ -163,6 +165,27 @@ describe('change report view model', () => {
assert.equal(vm.openQuestions[0], '业务叙事待 PI 补齐');
});
+ it('marks complete analysis as PI narrative vs factual fields', () => {
+ const vm = buildChangeReportViewModel({
+ id: 2,
+ doc_id: 'doc_x',
+ schema_version: 1,
+ summary: ['PI 叙事摘要'],
+ risk_level: 'high',
+ risk_items: [{ category: 'pi', description: '说明性条目' }],
+ project_impacts: { summary: '叙事项目影响' },
+ document_impacts: [{ doc_id: 'doc_down', impact_type: 'stale_candidate', reason: '上游更新' }],
+ open_questions: ['确认阈值'],
+ analysis_status: 'complete',
+ deterministic_facts: { rule_risk: 'high', hunk_count: 3, impact_count: 1, input_digest: 'abcdef0123456789' },
+ });
+ assert.equal(vm.isNarrativeComplete, true);
+ assert.equal(vm.narrativeSourceLabel, 'PI 叙事');
+ assert.match(vm.analysisNote || '', /服务端为准/);
+ assert.ok(vm.factualNotes.some((n) => n.includes('规则风险下限')));
+ assert.equal(vm.summary[0], 'PI 叙事摘要');
+ });
+
it('preferReportId prioritizes pending over committed latest', () => {
assert.equal(preferReportId(55, 20), 55);
assert.equal(preferReportId(null, 20), 20);
diff --git a/web/src/features/documentRevision/types.ts b/web/src/features/documentRevision/types.ts
index 594a213f..118612fb 100644
--- a/web/src/features/documentRevision/types.ts
+++ b/web/src/features/documentRevision/types.ts
@@ -92,6 +92,8 @@ export interface ChangeReport {
document_impacts: { doc_id: string; impact_type: string; reason: string; recommended_action?: string }[];
open_questions: string[];
analysis_status: string;
+ /** Server-bound facts (risk floor, digests, hunk counts). Never treated as PI narrative. */
+ deterministic_facts?: Record;
}
export interface ConflictInfo {
From 56f6a66f2e0c96aa6b065a25bd662e3b5fb641b7 Mon Sep 17 00:00:00 2001
From: weichen
Date: Wed, 16 Sep 2026 15:39:54 +0000
Subject: [PATCH 3/3] docs(test): archive PU7 PI narrative acceptance report
Record flag-off and full-fallback evidence; sync CURRENT, matrix, and contract.
Co-authored-by: Cursor
---
.doc/ops/CURRENT.md | 1 +
.../2026-09-16-pu7-pi-narrative-contract.md | 20 +--
.../short-term/2026-09-16-pu7-pi-narrative.md | 48 +++++++
.doc/status/FEATURE-MATRIX.md | 2 +-
...2026-09-16-pu7-pi-narrative-test-report.md | 120 ++++++++++++++++++
5 files changed, 182 insertions(+), 9 deletions(-)
create mode 100644 .doc/ops/short-term/2026-09-16-pu7-pi-narrative.md
create mode 100644 .doc/test-reports/2026-09-16-pu7-pi-narrative-test-report.md
diff --git a/.doc/ops/CURRENT.md b/.doc/ops/CURRENT.md
index 99ea1d3d..09d1c2a2 100644
--- a/.doc/ops/CURRENT.md
+++ b/.doc/ops/CURRENT.md
@@ -13,6 +13,7 @@
| owner | task | branch | worktree | mutex | status |
|-------|------|--------|----------|-------|--------|
+| Cursor-Auto | 2026-09-16-pu7-pi-narrative | feature/pu7-pi-narrative | .worktrees/feature-pu7-pi-narrative | docgov | done |
| Cursor | 2026-09-16-full-backlog-override | docs/override-nogo-full-backlog | - | architecture-docs | in_progress |
| Cursor | 2026-09-16-gitea-nightly-docker-datadir | fix/gitea-nightly-docker-datadir | - | ci | done |
| Cursor | 2026-09-16-gitea-nightly-host-tools | fix/gitea-nightly-host-tools | - | ci | done |
diff --git a/.doc/ops/long-term/2026-09-16-pu7-pi-narrative-contract.md b/.doc/ops/long-term/2026-09-16-pu7-pi-narrative-contract.md
index e0c2bfc0..690a035e 100644
--- a/.doc/ops/long-term/2026-09-16-pu7-pi-narrative-contract.md
+++ b/.doc/ops/long-term/2026-09-16-pu7-pi-narrative-contract.md
@@ -1,9 +1,9 @@
---
title: "PU7 PI change-report 叙事 — 获批后任务契约"
-status: planned
+status: done
created: 2026-09-16
updated: 2026-09-16
-owner: TBD
+owner: Cursor-Auto
branch: feature/pu7-pi-narrative
mutex: docgov
go_no_go: go
@@ -11,22 +11,26 @@ go_no_go: go
# PU7 PI change-report 叙事 — 获批后任务契约
-**go/no-go**:no-go。未获批禁止实现。
+**go/no-go**:GO(用户覆盖授权,见 Wave3 research)。已在 `feature/pu7-pi-narrative` 实现;默认 flag off。
-## 目标(获批后)
+## 目标
只读消费 immutable diff+lineage+risk floor 的 PI 叙事;任意 schema/超时/注入 → 全量 fallback。
## 预注册门
-- flag:`PI_CHANGE_REPORT_NARRATIVE`
+- flag:`PI_CHANGE_REPORT_NARRATIVE`(默认 off)
- 禁止覆盖服务端风险/审批结论
-- typed schema + 文档版本 + input digest 绑定
+- typed schema + input digest 绑定(`change_report_narrative_v1`)
- UI 明确区分 narrative vs 事实字段
-## 路径提示
+## 路径
-`internal/docrevision/`、`deploy/pi-agent/` typed result、`ChangeReportPanel.tsx`
+- `internal/docrevision/flags.go` / `narrative.go` / `service.go`
+- `deploy/pi-agent/change-report-narrative-v1.mjs`
+- `web/.../ChangeReportPanel.tsx` / `changeReportView.ts`
+- 报告:[../../test-reports/2026-09-16-pu7-pi-narrative-test-report.md](../../test-reports/2026-09-16-pu7-pi-narrative-test-report.md)
+- short-term:[../short-term/2026-09-16-pu7-pi-narrative.md](../short-term/2026-09-16-pu7-pi-narrative.md)
## 回滚
diff --git a/.doc/ops/short-term/2026-09-16-pu7-pi-narrative.md b/.doc/ops/short-term/2026-09-16-pu7-pi-narrative.md
new file mode 100644
index 00000000..ed1fbdd9
--- /dev/null
+++ b/.doc/ops/short-term/2026-09-16-pu7-pi-narrative.md
@@ -0,0 +1,48 @@
+---
+title: "PU7 PI change-report 叙事"
+status: done
+created: 2026-09-16
+updated: 2026-09-16
+owner: Cursor-Auto
+branch: feature/pu7-pi-narrative
+worktree: .worktrees/feature-pu7-pi-narrative
+mutex: docgov
+---
+
+# PU7 PI change-report 叙事
+
+## 目标
+
+`PI_CHANGE_REPORT_NARRATIVE` 默认 off;开启后只读消费 immutable diff+lineage 的 typed 叙事,失败全量 deterministic fallback。
+
+## 范围
+
+- `internal/docrevision/` flag + ResolveChangeReport
+- `deploy/pi-agent/change-report-narrative-v1.mjs`
+- `ChangeReportPanel` / changeReportView 区分事实 vs 叙事
+
+## 非目标
+
+- 默认开启 flag
+- 覆盖服务端 risk / approval
+- 合入 `dev`(本任务仅 feature 分支)
+
+## 步骤
+
+1. [x] flag 默认 off
+2. [x] typed narrative + input digest;失败全量 fallback
+3. [x] UI 区分 narrative / 事实字段
+4. [x] 单测 flag off + fallback
+5. [x] test-report + push feature 分支
+
+## 验收标准
+
+- [x] 默认 off 行为 = deterministic fallback
+- [x] 畸形/超时/digest 不匹配 → 无混合字段
+- [x] PI 声称低风险不影响服务端 risk floor
+- [x] 报告归档
+
+## 相关链接
+
+- Contract: [../long-term/2026-09-16-pu7-pi-narrative-contract.md](../long-term/2026-09-16-pu7-pi-narrative-contract.md)
+- Test report: [../../test-reports/2026-09-16-pu7-pi-narrative-test-report.md](../../test-reports/2026-09-16-pu7-pi-narrative-test-report.md)
diff --git a/.doc/status/FEATURE-MATRIX.md b/.doc/status/FEATURE-MATRIX.md
index 61987c24..8454b33b 100644
--- a/.doc/status/FEATURE-MATRIX.md
+++ b/.doc/status/FEATURE-MATRIX.md
@@ -59,7 +59,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 报告是本行内显式行为,不是缺口 | `docgov` |
-| PI change-report 叙事 | 只读消费 diff+lineage 的 PI 叙事;失败全量降级 fallback | planned | 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) | **覆盖授权 GO**:`PI_CHANGE_REPORT_NARRATIVE` 默认 off | `docgov` |
+| PI change-report 叙事 | 只读消费 diff+lineage 的 PI 叙事;失败全量降级 fallback | partial | deferred_enhancement | [测试](../test-reports/2026-09-16-pu7-pi-narrative-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) | **已实现(feature 分支)**:`PI_CHANGE_REPORT_NARRATIVE` 默认 off;flag-on 真实 PI 联调另开 | `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) | — | `pi-agent` |
| 三环境运维 | dev/test/main + 共享 KB;对话/业务库隔离 | done | non_blocker | [PROJECT.md](../../PROJECT.md) §4;CURRENT 环境快照 schema **47** | — | `infra` |
diff --git a/.doc/test-reports/2026-09-16-pu7-pi-narrative-test-report.md b/.doc/test-reports/2026-09-16-pu7-pi-narrative-test-report.md
new file mode 100644
index 00000000..ddd245c3
--- /dev/null
+++ b/.doc/test-reports/2026-09-16-pu7-pi-narrative-test-report.md
@@ -0,0 +1,120 @@
+# PU7 PI change-report 叙事 测试报告
+
+## 一、 测试背景 (Background)
+
+文档版本 baseline 已交付确定性 diff + lineage + fallback change report。覆盖授权后需落地可选 PI 叙事增强:默认 flag off,开启时只读消费 immutable 输入,任意失败全量降级,且不得覆盖服务端风险/审批。
+
+## 二、 测试目标 (Test Objectives)
+
+- `PI_CHANGE_REPORT_NARRATIVE` 默认 off
+- Flag on 合法叙事:保留服务端 `rule_risk` 与 lineage,仅叠加叙事字段
+- Flag on 畸形/超时/digest 不匹配/partial:全量 deterministic fallback,无混合字段
+- UI view-model 区分事实字段与叙事来源
+
+## 三、 测试方案与环境 (Test Plan & Environment)
+
+- OS:Linux;Go:模块内 `go test`
+- 层级:`internal/docrevision` 单元/服务路径;`deploy/pi-agent` schema 单测;前端 `overlayMode.test.ts` view-model
+- 本报告为 feature 分支验收(**未**合入 `dev`,**未**开启生产 flag)。真实容器 PI 叙事联调留待 flag-on 晋级任务
+
+## 四、 预期结果 (Expected Results)
+
+| 用例 | 预期 |
+|------|------|
+| Flag 默认 | `PIChangeReportNarrativeEnabled()==false` |
+| Flag off + 叙事 payload | 忽略叙事 → `analysis_status=fallback` |
+| Flag on + 合法 narrative | `complete`;`risk_level==rule_risk`;lineage 不被 PI 伪造覆盖 |
+| Flag on + TIMEOUT/malformed | `fallback`;`err==nil`;无 PI 摘要泄漏 |
+| UI complete | `isNarrativeComplete=true`;事实 notes 含 rule_risk |
+
+## 五、 实际测试结果与判定 (Actual Results & Evaluation)
+
+### 5.1 Flag 默认 off
+
+- **Input**:进程未设置 `PI_CHANGE_REPORT_NARRATIVE`
+- **Actual Output**:
+
+```text
+=== RUN TestPIChangeReportNarrativeFlagDefaultOff
+--- PASS: TestPIChangeReportNarrativeFlagDefaultOff (0.00s)
+=== RUN TestResolveChangeReport_FlagOffUsesDeterministic
+--- PASS: TestResolveChangeReport_FlagOffUsesDeterministic (0.00s)
+=== RUN TestCreateAgentCandidate_FlagOffIgnoresNarrativeEnrichment
+--- PASS: TestCreateAgentCandidate_FlagOffIgnoresNarrativeEnrichment (0.00s)
+```
+
+- **Verdict**:PASS
+- **Duration**:<10ms
+
+### 5.2 Flag on — 合法叙事不覆盖服务端风险
+
+- **Input**:`schema_kind=change_report_narrative_v1` + 匹配 `input_digest`;PI `risk.level=low`;伪造 `document_impacts`
+- **Actual Output**:
+
+```text
+=== RUN TestResolveChangeReport_FlagOnValidNarrativeKeepsServerRisk
+--- PASS: TestResolveChangeReport_FlagOnValidNarrativeKeepsServerRisk (0.00s)
+```
+
+- **Verdict**:PASS(服务端 high/critical floor 保留;lineage=`doc_down`)
+
+### 5.3 Flag on — 全量 fallback(无混合字段)
+
+- **Input**:bad json / schema / digest / TIMEOUT envelope / partial / empty summary
+- **Actual Output**:
+
+```text
+=== RUN TestResolveChangeReport_FlagOnMalformedFullFallback
+=== RUN TestResolveChangeReport_FlagOnMalformedFullFallback/bad_json
+=== RUN TestResolveChangeReport_FlagOnMalformedFullFallback/bad_schema_version
+=== RUN TestResolveChangeReport_FlagOnMalformedFullFallback/digest_mismatch
+=== RUN TestResolveChangeReport_FlagOnMalformedFullFallback/timeout_envelope
+=== RUN TestResolveChangeReport_FlagOnMalformedFullFallback/partial_flag
+=== RUN TestResolveChangeReport_FlagOnMalformedFullFallback/empty_summary
+--- PASS: TestResolveChangeReport_FlagOnMalformedFullFallback (0.00s)
+=== RUN TestCreateAgentCandidate_FlagOnTimeoutFallsBackWithoutError
+--- PASS: TestCreateAgentCandidate_FlagOnTimeoutFallsBackWithoutError (0.00s)
+```
+
+- **Verdict**:PASS
+
+### 5.4 前端叙事 vs 事实
+
+- **Input**:`analysis_status=complete` + `deterministic_facts`
+- **Actual Output**:`marks complete analysis as PI narrative vs factual fields` PASS(14/14 overlayMode 相关套件)
+- **Verdict**:PASS
+
+### 5.5 pi-agent typed schema helper
+
+```text
+change-report-narrative-v1.test.mjs: ok
+```
+
+- **Verdict**:PASS
+
+## 六、 自动化测试套件汇总 (Test Suite Execution)
+
+```text
+$ go test -v -count=1 ./internal/docrevision/ -run 'Narrative|FlagOff|FlagOn|DeterministicChangeReport'
+PASS
+ok chat-agent-system/internal/docrevision 0.004s
+
+$ go test -count=1 -short ./internal/docrevision/
+PASS
+ok chat-agent-system/internal/docrevision 0.007s
+
+$ node deploy/pi-agent/change-report-narrative-v1.test.mjs
+change-report-narrative-v1.test.mjs: ok
+
+$ tsx --test src/features/documentRevision/overlayMode.test.ts
+ℹ tests 14
+ℹ pass 14
+ℹ fail 0
+```
+
+通过率:上述套件 **100%**。
+
+## 七、 测试结论与后续优化建议 (Conclusion & Next Steps)
+
+- **结论**:PU7 在 `feature/pu7-pi-narrative` 达到契约要求;默认 off,回滚=关 flag;可合入 `dev` 前占集成槽位冒烟。
+- **后续**:flag-on 真实 PI 容器联调与 browser 验收另开任务;勿默认开启 `PI_CHANGE_REPORT_NARRATIVE`。