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 <cursoragent@cursor.com>
This commit is contained in:
weichen
2026-09-16 15:39:54 +00:00
co-authored by Cursor
parent 776b2b5cae
commit 18f2729472
4 changed files with 564 additions and 8 deletions
+45
View File
@@ -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()
}
+292
View File
@@ -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,
),
}
}
+225
View File
@@ -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)
}
}
+2 -8
View File
@@ -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
}