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`。