Files
weichenandCursor db1706cd24 feat(docrevision): send clipped diff text to the change-report narrator
The narrator only received counts, so it could not describe the actual change. Caps keep the full document out and leave the input digest unchanged; the flag stays off.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-20 05:03:31 +00:00

366 lines
12 KiB
Go

package docrevision
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"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
}
const (
narrativeMaxHunks = 8
narrativeMaxImpacts = 8
narrativeMaxRunes = 240
)
type narrativeHunkView struct {
ID string `json:"id"`
Kind string `json:"kind"`
Section string `json:"section,omitempty"`
Before string `json:"before_text,omitempty"`
After string `json:"after_text,omitempty"`
Truncated bool `json:"truncated,omitempty"`
}
type narrativeImpactView struct {
DocID string `json:"doc_id"`
ImpactType string `json:"impact_type"`
Reason string `json:"reason,omitempty"`
Truncated bool `json:"truncated,omitempty"`
}
// NarrativeModelInput is the minimized read-only bundle sent to the PI narrator.
// It includes change text, not only counts, and never the full document.
func NarrativeModelInput(spec NarrativeFetchSpec) map[string]any {
hunks, hunksOmitted := clipHunks(spec.Hunks)
impacts, impactsOmitted := clipImpacts(spec.Impacts)
return map[string]any{
"schema_kind": NarrativeSchemaKind,
"schema_version": NarrativeSchemaVersion,
"input_digest": spec.InputDigest,
"from_sha": spec.FromSHA,
"to_sha": spec.ToSHA,
"rule_risk": spec.RuleRisk,
"hunk_count": len(spec.Hunks),
"hunks_omitted": hunksOmitted,
"hunks": hunks,
"impact_count": len(spec.Impacts),
"impacts_omitted": impactsOmitted,
"document_impacts": impacts,
}
}
func clipHunks(hunks []DiffHunk) ([]narrativeHunkView, int) {
omitted := 0
if len(hunks) > narrativeMaxHunks {
omitted = len(hunks) - narrativeMaxHunks
hunks = hunks[:narrativeMaxHunks]
}
out := make([]narrativeHunkView, 0, len(hunks))
for _, h := range hunks {
before, beforeCut := clipRunes(h.BeforeText, narrativeMaxRunes)
after, afterCut := clipRunes(h.AfterText, narrativeMaxRunes)
out = append(out, narrativeHunkView{
ID: h.ID, Kind: h.Kind, Section: h.Section,
Before: before, After: after, Truncated: beforeCut || afterCut,
})
}
return out, omitted
}
func clipImpacts(impacts []DocImpact) ([]narrativeImpactView, int) {
omitted := 0
if len(impacts) > narrativeMaxImpacts {
omitted = len(impacts) - narrativeMaxImpacts
impacts = impacts[:narrativeMaxImpacts]
}
out := make([]narrativeImpactView, 0, len(impacts))
for _, imp := range impacts {
reason, cut := clipRunes(imp.Reason, narrativeMaxRunes)
out = append(out, narrativeImpactView{
DocID: imp.DocID, ImpactType: imp.ImpactType, Reason: reason, Truncated: cut,
})
}
return out, omitted
}
func clipRunes(s string, max int) (string, bool) {
r := []rune(s)
if len(r) <= max {
return s, false
}
return string(r[:max]) + "…", true
}
// BuildNarrativePromptInput is a read-only bundle for PI (diff + lineage + risk floor).
func BuildNarrativePromptInput(fromSHA, toSHA, ruleRisk string, impacts []DocImpact, diff DiffResult) map[string]any {
return NarrativeModelInput(NarrativeFetchSpec{
FromSHA: fromSHA, ToSHA: toSHA, RuleRisk: ruleRisk,
InputDigest: NarrativeInputDigest(fromSHA, toSHA, ruleRisk, impacts, diff.Hunks),
Impacts: impacts, Hunks: diff.Hunks,
})
}