//go:build integration package integration import ( "context" "strings" "testing" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/missionworkforce/missionmatch/internal/modules/ai" "github.com/missionworkforce/missionmatch/internal/platform/audit" ) // TestGraphRouteStructuredCompetitorFieldGated is the live proof of the structured graph path: a person // invokes the brain with the FIXED graph intent segment_competitor_field and bound args over a small seeded // competitor field, with a NIL inference client (the no plane posture), and gets back a cited DETERMINISTIC // graph answer, gated, never a hang. It proves the whole RouteGraph surface end to end against real Postgres: // // - the named graph intent dispatches to AnswerGraph (not AnswerTAG), so the answer carries route graph, // - with a nil client the faithfulness gate degrades to the deterministic rendering of the cited evidence // (rationale source deterministic), the no plane posture, a 200, never a hang, // - the deterministic answer cites the real evidence path (the dominant recipient UEI and its share from the // seeded segment), so the answer is grounded in the database, not invented, // - the answer is byte stable on a re run (the determinism mandate carried up from the SQL), // - the aedt.ai.answered audit carries route, rationale source, and the evidence COUNT only, and holds NO // PII: no UEI, no candidate id, no score, no answer text appears in the audit payload. // // The graph NEVER scores: this reads market structure (the recency weighted dollar share) and narrates it; the // frozen engine stays the only scorer and is not called. func TestGraphRouteStructuredCompetitorFieldGated(t *testing.T) { org := seedOrg(t) run := uuid.NewString()[:8] agency := "DEPT OF THE AIR FORCE " + run // unique per run so the segment is isolated naics := "541512" recent := time.Now().UTC().AddDate(0, -3, 0) // inside the lookback window // A small competitor field: a dominant incumbent and two challengers in the same (agency, naics) segment, // the only keys usaspending_awards carries. seedMarketAward is defined in graph_market_integration_test.go. seedMarketAward(t, "GR-CF-"+run+"-1", "PIID-1", agency, naics, "UEIDOMINANT01", "Dominant Defense LLC", "8000000.00", recent, false) seedMarketAward(t, "GR-CF-"+run+"-2", "PIID-2", agency, naics, "UEICHALLENG02", "Challenger Two LLC", "1500000.00", recent, false) seedMarketAward(t, "GR-CF-"+run+"-3", "PIID-3", agency, naics, "UEICHALLENG03", "Challenger Three LLC", "500000.00", recent, false) // A nil inference client is the no plane posture: every answer falls back to the deterministic rendering, // gated, never a hang. The audit writer is real so we can prove the audit holds no PII. orch := ai.NewOrchestrator(appDB, nil, audit.New(appDB)) // Invoke the structured graph path: the named graph intent plus bound args dispatches to AnswerGraph. resp, err := orch.Answer(ctxOrg(org), "", ai.IntentSegmentCompetitorField, []any{agency, naics, ai.DefaultCompetitorLookbackYears}) if err != nil { t.Fatalf("structured graph answer: %v", err) } if resp.Refused { t.Fatalf("a safe capture question must not be refused, got %+v", resp) } if resp.Route != ai.RouteGraph { t.Errorf("the named graph intent must dispatch to the graph route, got %q", resp.Route) } // With a nil client the answer is the deterministic rendering of the cited evidence, gated, a 200. if resp.RationaleSource != ai.SourceDeterministic { t.Errorf("a nil client must fall back to the deterministic rendering, got rationale source %q", resp.RationaleSource) } if resp.Answer == "" { t.Fatal("the deterministic graph answer must not be empty") } if len(resp.Citations) == 0 || resp.EvidenceCount == 0 { t.Fatalf("the graph answer must be grounded on cited evidence, got citations %v count %d", resp.Citations, resp.EvidenceCount) } // The cited deterministic answer names the real evidence path: the dominant recipient UEI from the seeded // segment appears verbatim, so the answer is grounded in the database, not invented. if !strings.Contains(resp.Answer, "UEIDOMINANT01") { t.Errorf("the deterministic graph answer must cite the dominant recipient from the seeded field, got %q", resp.Answer) } // Byte stable on a re run: the same input yields the same gated answer (the determinism mandate). again, err := orch.Answer(ctxOrg(org), "", ai.IntentSegmentCompetitorField, []any{agency, naics, ai.DefaultCompetitorLookbackYears}) if err != nil { t.Fatalf("structured graph answer re run: %v", err) } if again.Answer != resp.Answer { t.Errorf("the deterministic graph answer must be byte stable on a re run.\nfirst: %s\nsecond: %s", resp.Answer, again.Answer) } // The audit holds NO PII. The aedt.ai.answered payload carries route, rationale source, and the evidence // count only; no UEI, no candidate id, no score, and no answer text may appear in it. var ( auditCount int payloads []string ) if err := appDB.RunInTenantTx(ctxOrg(org), func(ctx context.Context, tx pgx.Tx) error { rows, qerr := tx.Query(ctx, "SELECT new_values::text FROM audit_logs WHERE organization_id=$1 AND action='aedt.ai.answered'", org) if qerr != nil { return qerr } defer rows.Close() for rows.Next() { var p string if serr := rows.Scan(&p); serr != nil { return serr } payloads = append(payloads, p) auditCount++ } return rows.Err() }); err != nil { t.Fatalf("read audit payloads: %v", err) } if auditCount == 0 { t.Fatal("the structured graph answer must emit an aedt.ai.answered audit event") } for _, p := range payloads { // The route is recorded so a compliance reviewer can see the graph path served the answer. if !strings.Contains(p, "graph") { t.Errorf("the audit payload must record the route, got %q", p) } // NO PII: no UEI, no recipient name, no candidate id, no answer text leaks into the audit. for _, leak := range []string{"UEIDOMINANT01", "UEICHALLENG02", "UEICHALLENG03", "Dominant Defense", "Challenger", agency} { if strings.Contains(p, leak) { t.Errorf("the audit payload must hold NO PII; it leaked %q in %q", leak, p) } } } } // TestGraphRouteRefusesEEOLacedGraphQuestion proves the structured graph path runs the EEO and protected // attribute guards FIRST: an EEO laced question that names a graph intent is refused, never answered, and the // refusal carries route graph. The graph path can never group, filter, or rank by a protected attribute, even // with a graph intent and valid market args bound. func TestGraphRouteRefusesEEOLacedGraphQuestion(t *testing.T) { org := seedOrg(t) orch := ai.NewOrchestrator(appDB, nil, audit.New(appDB)) resp, err := orch.Answer(ctxOrg(org), "the competitor field broken down by the veteran status of each recipient's staff", ai.IntentSegmentCompetitorField, []any{"DEPT OF THE AIR FORCE", "541512", ai.DefaultCompetitorLookbackYears}) if err != nil { t.Fatalf("an eeo laced graph question must refuse cleanly, got error %v", err) } if !resp.Refused { t.Fatalf("an eeo laced graph question must be refused, got %+v", resp) } if resp.Route != ai.RouteGraph { t.Errorf("the refusal must carry route graph, got %q", resp.Route) } if resp.Answer != "" { t.Errorf("a refusal must ship no answer, got %q", resp.Answer) } }