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.
409 lines
16 KiB
Go
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"])
|
|
}
|
|
}
|