Files
Agent-System/internal/docrevision/narrative_test.go
T
WeiCHEN bc873f4400 docs(ops): redline evidence batch without capability release
Record SWBOOT L1–L4 (no real repo write), FAV ObserveShadow gates and
observation-window prerequisites, D7 Track E ID-only checklist, and stronger
PI narrative fallback negatives while keeping all redline flags off.
2026-09-22 21:26:27 +08:00

409 lines
16 KiB
Go

package docrevision_test
import (
"context"
"encoding/json"
"strings"
"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)
digest := docrevision.NarrativeInputDigest(fromSHA, toSHA, ruleRisk, impacts, diff.Hunks)
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"]}`},
{"wrong schema kind", `{"schema_version":1,"schema_kind":"other_v1","input_digest":"` + digest + `","from_sha":"` + fromSHA + `","to_sha":"` + toSHA + `","summary":["PI only"]}`},
{"digest mismatch", mustNarrativeJSON(t, fromSHA, toSHA, "deadbeef", []string{"ok"})},
{"from_sha mismatch", mustNarrativeJSONWithSHAs(t, "wrong-from", toSHA, digest, []string{"PI only"})},
{"to_sha mismatch", mustNarrativeJSONWithSHAs(t, fromSHA, "wrong-to", digest, []string{"PI only"})},
{"timeout envelope", `{"result_v1":{"outcome":"error","error_code":"TIMEOUT"},"narrative":{"schema_version":1,"schema_kind":"change_report_narrative_v1"}}`},
{"top-level error_code", `{"schema_version":1,"schema_kind":"change_report_narrative_v1","input_digest":"` + digest + `","from_sha":"` + fromSHA + `","to_sha":"` + toSHA + `","error_code":"RUNNER_FAILED","summary":["PI only"]}`},
{"partial flag", `{"schema_version":1,"schema_kind":"change_report_narrative_v1","input_digest":"x","partial":true,"summary":["a"]}`},
{"empty summary", mustNarrativeJSON(t, fromSHA, toSHA, digest, nil)},
{"empty raw", ""},
}
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)
}
}
if rep.DeterministicFacts["narrative_applied"] == true {
t.Fatal("fallback must not claim narrative_applied")
}
})
}
}
func mustNarrativeJSON(t *testing.T, fromSHA, toSHA, digest string, summary []string) string {
t.Helper()
return mustNarrativeJSONWithSHAs(t, fromSHA, toSHA, digest, summary)
}
func mustNarrativeJSONWithSHAs(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)
}
// TestResolveChangeReport_AuthorizedFlipPathKeepsFlagOffDefault documents the
// pre-authorization contract: default remains off; when a future owner flips the
// flag for a controlled window, malformed PI output still full-falls-back.
func TestResolveChangeReport_AuthorizedFlipPathKeepsFlagOffDefault(t *testing.T) {
docrevision.ResetPIChangeReportNarrativeForTest()
t.Cleanup(docrevision.ResetPIChangeReportNarrativeForTest)
if docrevision.PIChangeReportNarrativeEnabled() {
t.Fatal("PI_CHANGE_REPORT_NARRATIVE must remain off until separately authorized")
}
fromSHA, toSHA, ruleRisk, impacts, diff := sampleDiff(t)
digest := docrevision.NarrativeInputDigest(fromSHA, toSHA, ruleRisk, impacts, diff.Hunks)
piRaw := mustNarrativeJSON(t, fromSHA, toSHA, digest, []string{"should stay ignored while off"})
offRep, err := docrevision.ResolveChangeReport(piRaw, fromSHA, toSHA, ruleRisk, impacts, diff)
if err != nil {
t.Fatal(err)
}
if offRep.AnalysisStatus != docrevision.AnalysisFallback {
t.Fatalf("default-off must ignore PI narrative, got %s", offRep.AnalysisStatus)
}
docrevision.SetPIChangeReportNarrativeForTest(true)
onBad, err := docrevision.ResolveChangeReport(`{"schema_version":1,"schema_kind":"change_report_narrative_v1"}`, fromSHA, toSHA, ruleRisk, impacts, diff)
if err != nil {
t.Fatal(err)
}
if onBad.AnalysisStatus != docrevision.AnalysisFallback {
t.Fatalf("authorized-on malformed must still fallback, got %s", onBad.AnalysisStatus)
}
docrevision.SetPIChangeReportNarrativeForTest(false)
if docrevision.PIChangeReportNarrativeEnabled() {
t.Fatal("test toggle must restore off before exit")
}
}
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)
}
}
func TestCreateAgentCandidate_FlagOnFetcherApplies(t *testing.T) {
docrevision.SetPIChangeReportNarrativeForTest(true)
t.Cleanup(docrevision.ResetPIChangeReportNarrativeForTest)
svc := docrevision.NewService(nil)
svc.NarrativeFetcher = func(_ context.Context, spec docrevision.NarrativeFetchSpec) (string, error) {
b, _ := json.Marshal(map[string]any{
"schema_version": 1,
"schema_kind": docrevision.NarrativeSchemaKind,
"input_digest": spec.InputDigest,
"from_sha": spec.FromSHA,
"to_sha": spec.ToSHA,
"summary": []string{"PI 叙事:电气安全需复核"},
"open_questions": []string{"是否需型检"},
})
return string(b), nil
}
ctx := context.Background()
st, err := svc.OpenTemplateVersion(ctx, "doc_flag_on_ok", "# base\n\nbody\n", docrevision.ActorSnapshot{DisplayName: "t"}, 1, "")
if err != nil {
t.Fatal(err)
}
_, rep, err := svc.CreateAgentCandidate(ctx, docrevision.AgentCandidateRequest{
DocID: "doc_flag_on_ok", RunID: "r-ok", InputSnapshotID: st.HeadSnapshotID,
OutputMarkdown: "# base\n\nbody changed\n",
ActorKind: docrevision.ActorAgent,
})
if err != nil {
t.Fatalf("fetcher success must not error: %v", err)
}
if rep == nil || rep.AnalysisStatus != docrevision.AnalysisComplete {
t.Fatalf("want complete, got %+v", rep)
}
if len(rep.Summary) == 0 || rep.Summary[0] != "PI 叙事:电气安全需复核" {
t.Fatalf("summary=%v", rep.Summary)
}
}
func TestCreateAgentCandidate_FlagOnFetcherErrorFallsBack(t *testing.T) {
docrevision.SetPIChangeReportNarrativeForTest(true)
t.Cleanup(docrevision.ResetPIChangeReportNarrativeForTest)
svc := docrevision.NewService(nil)
svc.NarrativeFetcher = func(_ context.Context, _ docrevision.NarrativeFetchSpec) (string, error) {
return "", context.DeadlineExceeded
}
ctx := context.Background()
st, err := svc.OpenTemplateVersion(ctx, "doc_flag_on_err", "# 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_err", RunID: "r-err", InputSnapshotID: st.HeadSnapshotID,
OutputMarkdown: "# base\n\nbody changed\n",
ActorKind: docrevision.ActorAgent,
})
if err != nil {
t.Fatalf("fetcher error must full-fallback without error, got %v", err)
}
if cand == nil || rep == nil || rep.AnalysisStatus != docrevision.AnalysisFallback {
t.Fatalf("want fallback candidate, got cand=%v rep=%+v", cand, rep)
}
}
func TestCreateAgentCandidate_FlagOnEmptyFetcherBodyFallsBack(t *testing.T) {
docrevision.SetPIChangeReportNarrativeForTest(true)
t.Cleanup(docrevision.ResetPIChangeReportNarrativeForTest)
svc := docrevision.NewService(nil)
svc.NarrativeFetcher = func(_ context.Context, _ docrevision.NarrativeFetchSpec) (string, error) {
return " ", nil
}
ctx := context.Background()
st, err := svc.OpenTemplateVersion(ctx, "doc_flag_on_empty", "# 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_empty", RunID: "r-empty", InputSnapshotID: st.HeadSnapshotID,
OutputMarkdown: "# base\n\nbody changed\n",
ActorKind: docrevision.ActorAgent,
})
if err != nil {
t.Fatalf("empty fetcher body must full-fallback without error, got %v", err)
}
if cand == nil || rep == nil || rep.AnalysisStatus != docrevision.AnalysisFallback {
t.Fatalf("want fallback candidate, got cand=%v rep=%+v", cand, rep)
}
}
func TestNarrativeModelInput_IncludesClippedChangeText(t *testing.T) {
long := strings.Repeat("甲", 280)
spec := docrevision.NarrativeFetchSpec{
FromSHA: "from", ToSHA: "to", RuleRisk: "high", InputDigest: "digest-1",
Hunks: []docrevision.DiffHunk{{
ID: "h1", Kind: "modify", Section: "需求",
BeforeText: "旧阈值 1.0", AfterText: long,
}},
Impacts: []docrevision.DocImpact{{
DocID: "doc_proj1_test_manager", ImpactType: "stale_candidate", Reason: "验证方案引用了该阈值",
}},
}
for i := 0; i < 10; i++ {
spec.Hunks = append(spec.Hunks, docrevision.DiffHunk{ID: "extra", Kind: "insert", AfterText: "x"})
}
raw, err := json.Marshal(docrevision.NarrativeModelInput(spec))
if err != nil {
t.Fatal(err)
}
body := string(raw)
if !strings.Contains(body, "旧阈值 1.0") || !strings.Contains(body, "验证方案引用了该阈值") {
t.Fatalf("model input dropped the change or impact: %s", body)
}
if !strings.Contains(body, `"hunk_count":11`) || !strings.Contains(body, `"hunks_omitted":3`) {
t.Fatalf("counts must show truncation: %s", body)
}
if strings.Contains(body, long) {
t.Fatal("full hunk text must not be sent")
}
if !strings.Contains(body, "…") {
t.Fatal("truncated text should be marked")
}
var facts map[string]any
if err := json.Unmarshal(raw, &facts); err != nil {
t.Fatal(err)
}
if facts["input_digest"] != "digest-1" {
t.Fatalf("digest = %v", facts["input_digest"])
}
}