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:
@@ -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()
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user