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>
366 lines
12 KiB
Go
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,
|
|
})
|
|
}
|