package ai // RouteGraph is the deterministic graph reasoning surface. It is the routing layer that crosses the brain // from a single table lookup (TAG) to walking a cited evidence path over the relational graph already in // Postgres: the G3 curated skill adjacency and the G1 market and relationship backbone. Like every other // route it runs the EEO and protected attribute guards FIRST, executes a FIXED, named, parameterized // QueryIntent from the closed Registry through ExecuteIntent (which re guards via GuardReadOnlyQuery), and // narrates the result behind the existing per claim faithfulness gate. It NEVER scores: the frozen engine in // internal/modules/matching/engine stays the only scorer. RouteGraph reads structure (the reachable skills, // the recency weighted dollar shares, the incumbent of record) and narrates it; a dollar share or a // competitor rank is a MARKET aggregate, never a match score or an employment decision number. The model // authors no SQL and binds no params it can invent: the intent is one of a fixed set and its arg slots are // curated, and a free form question whose required params cannot be extracted unambiguously falls back to the // existing RAG path rather than guessing. import ( "context" "encoding/json" "fmt" "regexp" "strconv" "strings" "github.com/missionworkforce/missionmatch/internal/platform/tenant" ) // graphIntents is the closed set of Registry intents the dispatcher treats as GRAPH intents (the G3 skill // adjacency traversals and the G1 market and relationship traversals). It is how Answer tells a graph intent // from a TAG intent: a structured request naming one of these dispatches to AnswerGraph instead of // AnswerTAG, so the graph specific evidence rendering and the graph route audit apply. Every name here is a // FIXED entry in the closed Registry, asserted by ValidateRegistry at boot; IsGraphIntent over this set is // the single source of truth for the classification. var graphIntents = map[string]bool{ // G3 skill and role adjacency graph (graph_skill.go). IntentSkillSubstitutionClosure: true, IntentSkillGapBridge: true, // G1 market and relationship graph (graph_market.go). IntentSegmentCompetitorField: true, IntentIncumbentOfRecord: true, IntentRecompetePipeline: true, IntentAgencyBuyingPattern: true, // G9 recompete lifecycle and foresight graph (graph_lifecycle.go). IntentProcurementSuccession: true, IntentRecompeteClock: true, IntentForecastRunway: true, // G7 agency buying pattern graph (graph_agency.go). IntentAgencyBuyingPatternDetail: true, IntentAgencyVendorConcentration: true, IntentAgencySpendTrend: true, // G2 public vendor co occurrence and teaming field graph (graph_teaming.go). IntentVendorCooccurrenceField: true, IntentPartnerOrCompetitorRatio: true, IntentRecompeteTeamingField: true, // G5 clearance lattice (graph_clearance.go). IntentClearanceReachability: true, IntentCompartmentRequirement: true, IntentBenchClearanceCoverage: true, // G4 bench graph (graph_bench.go). IntentBenchSkillCoverage: true, IntentBenchReadinessDistribution: true, IntentBenchGapForRequirement: true, // G6 the loop (graph_loop.go), the composed capture picture, a person touching bench intent. IntentCapturePictureForRequirement: true, // M5 price to win (graph_wage.go), the advisory OEWS wage band, PUBLIC reference data, never suppressed. IntentLaborRateBand: true, // MAG opportunity win probability (mag.go), a model backed intent. It is registered here so the planner // and Answer route it through the graph surface, but the AnswerGraph model fork dispatches it to // answerModelIntent (NOT the raw ExecuteIntent path, which hard rejects a model intent), where it gathers // its features through the guarded feature source intents and scores a calibrated probability in decimal. IntentOpportunityWinProbability: true, // Capture Readiness (capture_readiness.go), the bid pursue decision composition. Registered here so Answer // and the planner route it through the graph surface; AnswerGraph intercepts it by name and dispatches to // answerCaptureReadiness (NOT the raw ExecuteIntent path, whose registered SQL only resolves the segment). IntentOpportunityCaptureReadiness: true, // Phase 2 Vendor Intelligence (vendor.go), the vendor quality distribution: a person derived headcount // partition, so it is ALSO a benchIntent below. It is a graph intent so Answer routes it to AnswerGraph, // whose bench fork dispatches it to the suppressing VendorQualityDistribution executor, never the raw // ExecuteIntent path. (vendor_performance_scorecard is a plain TAG aggregate, so it is deliberately NOT // here and runs the standard AnswerTAG path.) IntentVendorQualityDistribution: true, } // IsGraphIntent reports whether a registered intent name is a GRAPH intent (one of the G3 or G1 traversals), // so the Answer dispatcher routes it to AnswerGraph rather than AnswerTAG. An unknown or empty name is not a // graph intent. func IsGraphIntent(name string) bool { return graphIntents[name] } // benchIntents is the closed set of PERSON TOUCHING bench intents (the G4 bench plane plus the G5 // bench_clearance_coverage intersection): every one reads the RLS protected person tables and pipes its // partition through small cell suppression. It is the set the D1 route gate uses to REQUIRE a resolved // authenticated tenant context (an org_id and user_id from the session) before a person touching read runs; // the G5 reference reads (clearance_reachability, compartment_requirement_closure) carry no person data and // are NOT in this set. Building the gate itself is task D1; this set and IsBenchIntent are the registration // the orchestrator's gate keys on. var benchIntents = map[string]bool{ // G4 bench graph (graph_bench.go), all person touching and suppressed. IntentBenchSkillCoverage: true, IntentBenchReadinessDistribution: true, IntentBenchGapForRequirement: true, // G5 bench clearance coverage (graph_clearance.go), the one person touching read on the clearance plane. IntentBenchClearanceCoverage: true, // G6 the loop (graph_loop.go), the composed capture picture: it reads the bench, so it is person touching, // auth gated, and rejected on the raw ExecuteIntent path; the bench fork dispatches it to its composition. IntentCapturePictureForRequirement: true, // Phase 2 Vendor Intelligence (vendor.go), the vendor quality distribution: it joins the vendor's // submissions to the frozen candidate_evaluations band, a person derived per vendor per band headcount, so // it is person touching, auth gated, rejected on the raw ExecuteIntent path, and runs only through the // suppressing VendorQualityDistribution executor. IntentVendorQualityDistribution: true, } // IsBenchIntent reports whether a registered intent name is a PERSON TOUCHING bench intent, so the D1 route // gate requires an authenticated tenant context before it runs. An unknown or empty name is not a bench // intent, and the G5 reference reads are deliberately excluded (they carry no person data). func IsBenchIntent(name string) bool { return benchIntents[name] } // AnswerGraph runs a registered GRAPH traversal and narrates its cited evidence path behind the faithfulness // gate, the structured graph path. It mirrors AnswerTAG exactly: it runs ForbidEEO and // MentionsProtectedAttribute on the question FIRST and refuses on either (the orchestrator precedent, so the // graph path can never group, filter, or rank by a protected attribute), looks the intent up by name, runs // it through ExecuteIntent (which re guards via GuardReadOnlyQuery and runs RLS scoped as the tenant), builds // a DETERMINISTIC evidence rendering of the returned rows (the cited path), and narrates through o.generate // and o.finalize so the model prose ships ONLY when the faithfulness gate confirms it, else the deterministic // rendering (a 200, rationale_source deterministic). A plane outage degrades to the deterministic rendering, // never a hang, because the data is the database's and generate returns a typed error that finalize turns // into the deterministic fallback. The audit is aedt.ai.answered route graph, carrying route, rationale // source, and the evidence COUNT only, never a UEI, a candidate id, a score, or answer text. func (o *Orchestrator) AnswerGraph(ctx context.Context, question, intentName string, args []any) (*Response, error) { if ForbidEEO(question) != nil || MentionsProtectedAttribute(question) { return o.refuse(ctx, RouteGraph, "the question references prohibited demographic data"), nil } intent, ok := IntentByName(intentName) if !ok { return nil, fmt.Errorf("%w: %q", ErrUnknownIntent, intentName) } // A MAG (model backed) intent runs through answerModelIntent, NEVER the raw ExecuteIntent path (which hard // rejects a model intent). It gathers its features through the existing guarded feature source intents, // scores a calibrated probability in decimal, and narrates the contribution trace behind the faithfulness // gate. It emits an opportunity level number, never a per candidate score. if IsModelIntent(intentName) { return o.answerModelIntent(ctx, question, intentName, args) } // Capture Readiness is a graph COMPOSITION intercepted by name: its registered SQL only resolves the // opportunity's segment, and the composition (answerCaptureReadiness) gathers the factors and composes the // decimal score. It never runs as the raw query and never computes a per candidate score. if intentName == IntentOpportunityCaptureReadiness { id, err := benchIDArg(args) if err != nil { return nil, err } return o.answerCaptureReadiness(ctx, id) } // A person touching bench intent runs through its suppressing executor, NEVER the raw ExecuteIntent path // (which returns unsuppressed counts and which hard rejects bench intents). The bench path requires an // authenticated tenant context, renders only suppressed safe counts, and emits a counts only audit. The G6 // capture picture is a person touching COMPOSITION over the bench and market planes, so the bench fork // dispatches it to answerCapturePicture; the other four bench intents run their single suppressing executor // through answerBench unchanged. if IsBenchIntent(intentName) { if intentName == IntentCapturePictureForRequirement { id, err := benchIDArg(args) if err != nil { return nil, err } return o.answerCapturePicture(ctx, id) } return o.answerBench(ctx, intentName, args) } res, err := ExecuteIntent(ctx, o.db, intent, args) if err != nil { return nil, err } rendered, _ := json.Marshal(map[string]any{"columns": res.Columns, "rows": res.Rows}) evidence := []string{string(rendered)} deterministic := renderGraphEvidence(intent.Description, res.Columns, res.Rows) model, genErr := o.generate(ctx, "State the result plainly from the graph evidence path. Cite the reached skills, the recipients, the agencies, and the awards exactly as the rows give them. Do not invent a UEI, a dollar figure, a share, or a skill the rows do not contain, and never compute a match score.", "INTENT: "+intent.Description+"\n\nEVIDENCE PATH:\n"+string(rendered)) return o.finalize(ctx, RouteGraph, model, genErr, deterministic, evidence), nil } // renderGraphEvidence builds a deterministic, readable answer from a graph traversal result, the cited path // the question is answered from even when the model prose is withheld. It reuses the TAG table rendering: a // graph row is already the cited evidence (the reached skill and its evidence path for G3; the recipient UEI, // the dollar share, the matched award for G1), so rendering the columns and rows verbatim, in the byte stable // order the deterministic SQL returns, is the faithful deterministic answer. No number is recomputed and no // score is produced; the rows are reported exactly as the database returned them. func renderGraphEvidence(description string, columns []string, rows [][]any) string { return renderTable(description, columns, rows) } // answerBench runs a person touching bench intent through its suppressing executor and narrates only the // suppressed safe counts. It is the ONLY answer path for a bench intent: the raw ExecuteIntent path hard // rejects bench intents, so an unsuppressed sub k headcount can never be served. The bench plane reads RLS // protected person data, so it REQUIRES a resolved authenticated tenant context and refuses without one // rather than relying only on the RLS empty result fail safe. Every count placed into the evidence, the // model prompt, and the audit comes from RenderCount, so a suppressed cell is the sentinel and never a // number. The frozen engine stays the only scorer; this path computes no match score. func (o *Orchestrator) answerBench(ctx context.Context, intentName string, args []any) (*Response, error) { if _, ok := tenant.OrgFromContext(ctx); !ok { return o.refuse(ctx, RouteGraph, "a bench question requires an authenticated tenant context"), nil } k := ConfiguredSuppressionThreshold() labels, cells, err := o.runBenchIntent(ctx, intentName, args, k) if err != nil { return nil, err } // Build the suppressed safe rendering: each cell renders through RenderCount, so a suppressed cell is the // sentinel and no sub k number reaches the evidence, the model prompt, or the audit. columns := []string{"bucket", "headcount"} rows := make([][]any, len(cells)) suppressed := 0 for i, c := range cells { if c.Suppressed { suppressed++ } rows[i] = []any{labels[i], RenderCount(c)} } deterministic := renderBenchEvidence(intentName, columns, rows, suppressed, k) rendered, _ := json.Marshal(map[string]any{"columns": columns, "rows": rows}) evidence := []string{string(rendered)} model, genErr := o.generate(ctx, "State the bench counts plainly from the evidence. Report each bucket's headcount exactly as the rows give it. A headcount shown as "+SuppressionSentinel+" was withheld to protect individuals: say it was withheld and NEVER guess or state a number for it. Never compute a match score.", "INTENT: "+intentName+"\n\nEVIDENCE:\n"+string(rendered)) resp := o.finalize(ctx, RouteGraph, model, genErr, deterministic, evidence) // Counts only, suppression aware audit: the intent, the cell count, the suppressed count, and the // configured k. It carries NO candidate id, NO raw sub k count, and NO answer text. o.audit(ctx, "aedt.ai.bench_answered", map[string]any{"route": RouteGraph, "intent": intentName, "cells": len(cells), "suppressed": suppressed, "k": k}) return resp, nil } // runBenchIntent dispatches a bench intent name to its suppressing Go executor and returns the partition as // parallel label and Cell slices (the Cells are already small cell suppressed by the executor). It is the // single place that runs a bench intent; the generic ExecuteIntent rejects them so an unsuppressed raw count // can never be served. func (o *Orchestrator) runBenchIntent(ctx context.Context, intentName string, args []any, k int) ([]string, []Cell, error) { switch intentName { case IntentBenchSkillCoverage: id, err := benchIDArg(args) if err != nil { return nil, nil, err } out, err := BenchSkillCoverage(ctx, o.db, id, k) if err != nil { return nil, nil, err } labels := make([]string, len(out)) cells := make([]Cell, len(out)) for i, r := range out { labels[i], cells[i] = r.Bucket, r.Cell } return labels, cells, nil case IntentBenchReadinessDistribution: id, err := benchIDArg(args) if err != nil { return nil, nil, err } out, err := BenchReadinessDistribution(ctx, o.db, id, k) if err != nil { return nil, nil, err } labels := make([]string, len(out)) cells := make([]Cell, len(out)) for i, r := range out { labels[i], cells[i] = r.Band, r.Cell } return labels, cells, nil case IntentBenchGapForRequirement: id, err := benchIDArg(args) if err != nil { return nil, nil, err } out, err := BenchGapForRequirement(ctx, o.db, id, k) if err != nil { return nil, nil, err } labels := make([]string, len(out)) cells := make([]Cell, len(out)) for i, r := range out { labels[i], cells[i] = r.Bucket, r.Cell } return labels, cells, nil case IntentBenchClearanceCoverage: out, err := BenchClearanceCoverage(ctx, o.db, k) if err != nil { return nil, nil, err } labels := make([]string, len(out)) cells := make([]Cell, len(out)) for i, r := range out { labels[i], cells[i] = r.LevelCode, r.Cell } return labels, cells, nil case IntentVendorQualityDistribution: // The vendor quality distribution takes no id (it reports every vendor the caller's org has recorded), // like the bench clearance coverage. Each row's label is the vendor name and the frozen band so the // suppressed rendering names which vendor and band a cell belongs to; the Cell is already small cell // suppressed per vendor by the executor. out, err := VendorQualityDistribution(ctx, o.db, k) if err != nil { return nil, nil, err } labels := make([]string, len(out)) cells := make([]Cell, len(out)) for i, r := range out { labels[i], cells[i] = r.VendorName+" / "+r.Band, r.Cell } return labels, cells, nil default: return nil, nil, fmt.Errorf("ai: %q is not a bench intent", intentName) } } // benchIDArg extracts the single id argument a bench intent that keys on a UUID expects (a skill id or a // requirement id), accepting a string or any fmt.Stringer (so a uuid.UUID binds), and rejecting a missing, // empty, or wrong shaped argument. func benchIDArg(args []any) (string, error) { if len(args) != 1 { return "", fmt.Errorf("ai: a bench intent expects exactly one id argument, got %d", len(args)) } switch v := args[0].(type) { case string: if v == "" { return "", fmt.Errorf("ai: a bench intent id argument must be a non empty string") } return v, nil case fmt.Stringer: s := v.String() if s == "" { return "", fmt.Errorf("ai: a bench intent id argument must be a non empty string") } return s, nil default: return "", fmt.Errorf("ai: a bench intent id argument must be a string, got %T", args[0]) } } // renderBenchEvidence builds the deterministic, suppressed safe answer for a bench intent: each bucket with // its headcount (the sentinel for a suppressed cell), plus a fixed line naming how many of the buckets were // withheld to protect individuals. No raw sub k count appears, so the deterministic fallback is itself safe. func renderBenchEvidence(intentName string, columns []string, rows [][]any, suppressed, k int) string { base := renderTable(intentName, columns, rows) if suppressed > 0 { base += fmt.Sprintf(" %d of %d buckets withheld to protect individuals (fewer than %d).", suppressed, len(rows), k) } return base } // graphRoute is the chosen graph intent plus the bound params the conservative NL classifier extracted. A nil // graphRoute means the question did not clearly name a graph traversal, or it named one but the required // params could not be extracted unambiguously, so the caller falls back to the existing RAG path. type graphRoute struct { intent string args []any } // naicsRe matches a bare six digit NAICS code as a standalone token, the only NAICS shape extracted // unambiguously from free text. Anything else (a partial code, a code embedded in a larger number) is not // extracted, so the classifier never guesses a segment key. var naicsRe = regexp.MustCompile(`\b(\d{6})\b`) // quotedAgencyRe matches a single or double quoted span, the only agency name extracted unambiguously from // free text. A bare agency phrase is NOT extracted (the buyer name is ambiguous without quotes), so the // classifier never guesses an agency and never authors a param the user did not delimit. var quotedAgencyRe = regexp.MustCompile(`["']([^"']{2,})["']`) // windowMonthsRe matches an explicit forward window phrased as a number of months, so a recompete question // that names its own window binds it; an unspecified window falls to the intent's own default clamp. var windowMonthsRe = regexp.MustCompile(`(\d{1,3})\s*month`) // graphClassify is the conservative natural language router for the graph surface. It maps a free form // question that CLEARLY names a capture or skill graph traversal (a curated keyword to intent table, never an // LLM, mirroring how Classify routes count and sum to TAG) to a graphRoute, and returns nil when the question // is not a graph question OR when it is but the required structured params cannot be extracted unambiguously. // Because the graph intents need structured params (an agency, a NAICS, an opportunity id, a skill id) that // are hard to extract deterministically from free text, extraction is deliberately MINIMAL: only a bare six // digit NAICS and a quoted agency name are read, and when a matched intent's required params are missing the // caller falls back to the existing RAG path rather than guessing. The model never authors the params; the // intent and its arg slots are fixed here. func graphClassify(question string) *graphRoute { q := strings.ToLower(question) // G6 the loop, the composed capture picture for a requirement. It reads the bench (person touching), so it // keys on a requirement id (a UUID), which a user almost never types in free text; a capture picture // question routes here only when it carries an explicit canonical UUID, the same conservative posture as // the bench readiness and bench gap routes. It is checked FIRST so its distinct integrated phrasing wins // over the narrower bench gap keywords. Absent an explicit requirement id it falls back to RAG rather than // guessing a requirement. captureKeywords := []string{"capture picture", "can we staff", "staffing and market", "staff it and the market", "bench and the market for the requirement"} if containsAny(q, captureKeywords) { if id, ok := quotedUUID(question); ok { return &graphRoute{intent: IntentCapturePictureForRequirement, args: []any{id}} } return nil // a capture picture question with no explicit requirement id falls back to RAG } // MAG opportunity win probability (pwin) is the calibrated probability of winning an opportunity in a // customer agency segment. It is a MODEL backed intent, an opportunity level number, NEVER a per candidate // score. It is checked EARLY so its distinct win probability phrasing wins over the broader recompete and // competitor keywords. It binds honestly only when the question carries a quoted agency (the only // unambiguous segment key in free text); the naics is optional (the empty string means the overall segment, // the model's own default). Absent a quoted agency it falls back to RAG rather than guessing a segment. winProbabilityKeywords := []string{"win probability", "pwin", "p win", "probability of win", "probability of winning", "chance of winning"} if containsAny(q, winProbabilityKeywords) { agency, hasAgency := quotedAgency(question) if hasAgency { naics, _ := bareNAICS(question) // optional; the empty string is the model's overall segment default return &graphRoute{intent: IntentOpportunityWinProbability, args: []any{agency, naics}} } return nil // a win probability question with no quoted agency falls back to RAG } // M5 labor rate band is the advisory OEWS prevailing wage for a role in a state, the price to win rate. It // is PUBLIC reference data (bls_wages), never suppressed and never a candidate score. It binds honestly only // when the question names BOTH a role we can resolve to a SOC through the curated crosswalk (from a quoted // role span, the only unambiguous role shape in free text) AND a state we can resolve to a known OEWS area // code (HI or NM), so an unresolvable role or a state with no wage data falls back to RAG rather than // guessing a rate. It is checked here, before the market and skill routes, so its distinct wage phrasing // wins over the broader keywords. wageKeywords := []string{"labor rate", "prevailing wage", "oews wage", "price to win rate"} if containsAny(q, wageKeywords) { role, hasRole := quotedRole(question) area, hasArea := quotedStateArea(question) if hasRole && hasArea { if occ, ok := resolveSOC(role); ok { return &graphRoute{intent: IntentLaborRateBand, args: []any{occ.SOC, area}} } } return nil // a wage question whose role or state we cannot resolve unambiguously falls back to RAG } // G5 bench clearance coverage is the suppressed structural headcount of the caller's bench per clearance // level. It takes no SQL parameter (the suppression threshold is a Go argument, not a bound param), so a // question that clearly asks for the clearance breakdown of the bench routes here directly. It is checked // BEFORE the reachability route so the distinct bench coverage phrasing wins over a bare clearance keyword. benchCoverageKeywords := []string{"clearance coverage", "clearance breakdown", "clearance distribution", "how many of our bench hold", "bench by clearance", "clearance mix of our bench"} if containsAny(q, benchCoverageKeywords) { return &graphRoute{intent: IntentBenchClearanceCoverage, args: nil} } // G4 bench skill coverage is the suppressed structural count of the caller's bench that holds a given // skill versus does not. It keys on a skill id (a UUID), which a user almost never types in free text, so // a bench skill coverage question routes only when it carries an explicit canonical UUID; absent one it // falls back to RAG rather than guessing a skill. The same conservative UUID posture as the skill // adjacency walks. benchSkillKeywords := []string{"bench skill coverage", "how many of our bench hold the skill", "bench coverage for the skill", "who on the bench has the skill"} if containsAny(q, benchSkillKeywords) { if id, ok := quotedUUID(question); ok { return &graphRoute{intent: IntentBenchSkillCoverage, args: []any{id}} } return nil // a bench skill coverage question with no explicit skill id falls back to RAG } // G4 bench readiness distribution is the suppressed distribution of bands the frozen engine already // decided for one requirement, read from the audited candidate_evaluations. It keys on a requirement id // (a UUID); absent an explicit canonical UUID it falls back to RAG rather than guessing a requirement. benchReadinessKeywords := []string{"bench readiness", "readiness distribution", "band distribution", "how ready is our bench"} if containsAny(q, benchReadinessKeywords) { if id, ok := quotedUUID(question); ok { return &graphRoute{intent: IntentBenchReadinessDistribution, args: []any{id}} } return nil // a bench readiness question with no explicit requirement id falls back to RAG } // G4 bench gap for a requirement is the suppressed structural count of the bench that meets a requirement // versus does not, a count to size a gap, never a match score. It keys on a requirement id (a UUID); // absent an explicit canonical UUID it falls back to RAG rather than guessing a requirement. benchGapKeywords := []string{"bench gap", "structural gap", "how many of our bench meet", "who meets the requirement"} if containsAny(q, benchGapKeywords) { if id, ok := quotedUUID(question); ok { return &graphRoute{intent: IntentBenchGapForRequirement, args: []any{id}} } return nil // a bench gap question with no explicit requirement id falls back to RAG } // G5 clearance reachability is the lattice fact: which required levels a held clearance satisfies. It binds // the held clearance code from the six reference codes named in the question (the only unambiguous held key // in free text); absent a named held clearance it falls back to RAG rather than guessing a level. reachabilityKeywords := []string{"clearance satisfies", "what does a clearance satisfy", "which clearances satisfy", "clearance reachability", "satisfy the requirement", "clearance ladder"} if containsAny(q, reachabilityKeywords) { if code, ok := heldClearanceCode(q); ok { return &graphRoute{intent: IntentClearanceReachability, args: []any{code}} } return nil // a reachability question with no named held clearance falls back to RAG } // G5 compartment requirement closure resolves a requirement's compartment codes to their kind. It binds the // quoted comma separated compartment list (the only unambiguous compartment shape in free text); absent a // quoted list it falls back to RAG rather than guessing the compartments. compartmentKeywords := []string{"compartment required", "required compartments", "compartment closure", "which compartments", "sci compartment", "compartment gap"} if containsAny(q, compartmentKeywords) { if list, ok := quotedCompartmentList(question); ok { return &graphRoute{intent: IntentCompartmentRequirement, args: []any{list}} } return nil // a compartment question with no quoted compartment list falls back to RAG } // G9 procurement succession is the lifecycle walk of one solicitation. It keys on an opportunity id (a // UUID), which a user almost never types in free text, so a succession or lifecycle question routes only // when it carries an explicit canonical UUID; absent one it falls back to RAG rather than guessing an // opportunity. This is the same conservative posture as the skill adjacency UUID binding. successionKeywords := []string{"succession", "lifecycle", "new or recompete", "is this a recompete", "predecessor award"} if containsAny(q, successionKeywords) { if id, ok := quotedUUID(question); ok { return &graphRoute{intent: IntentProcurementSuccession, args: []any{id}} } return nil // a succession question with no explicit opportunity id falls back to RAG } // G9 recompete clock is the agency forward window read. The distinct phrase recompete clock routes here // (checked BEFORE the existing recompete pipeline keyword so the more specific phrase wins) when a quoted // agency is present, with the window defaulting to the intent clamp unless the question names a number of // months. A bare recompete with no clock phrase still routes to the existing pipeline below, byte // unchanged. clockKeywords := []string{"recompete clock", "recompete window", "pop end window", "period of performance end"} if containsAny(q, clockKeywords) { agency, hasAgency := quotedAgency(question) if hasAgency { return &graphRoute{intent: IntentRecompeteClock, args: []any{agency, windowMonths(question)}} } return nil // a recompete clock question with no quoted agency falls back to RAG } // G9 forecast runway is the forward window read of agency forecasts by estimated award date. It routes // only when the question names the forecast runway AND an explicit number of months (its only param), so // the window is unambiguous; a forecast question with no stated window falls back to RAG rather than // inventing a horizon. runwayKeywords := []string{"forecast runway", "forecast pipeline", "upcoming forecasts", "forecast lead time"} if containsAny(q, runwayKeywords) { if months := windowMonths(question); months > 0 { return &graphRoute{intent: IntentForecastRunway, args: []any{months}} } return nil // a forecast runway question with no explicit window falls back to RAG } // G7 agency vendor concentration is the recency weighted recipient dollar share for one (agency, naics) award // segment. It needs BOTH a quoted agency and a bare six digit NAICS to bind honestly (the only keys // usaspending_awards carries); it is checked BEFORE the competitor block so the distinct vendor concentration // phrase wins over the broader competitor keywords. If either param is missing the question falls back to RAG. vendorConcentrationKeywords := []string{"vendor concentration", "recipient concentration", "vendor share", "vendor concentration in", "concentration of vendors"} if containsAny(q, vendorConcentrationKeywords) { agency, hasAgency := quotedAgency(question) naics, hasNAICS := bareNAICS(question) if hasAgency && hasNAICS { return &graphRoute{intent: IntentAgencyVendorConcentration, args: []any{agency, naics}} } return nil // a vendor concentration question whose segment we cannot bind unambiguously falls back to RAG } // G7 agency spend trend is the obligation trend by fiscal period for one (agency, naics) segment. It needs // BOTH a quoted agency and a bare six digit NAICS to bind honestly; absent either it falls back to RAG. spendTrendKeywords := []string{"spend trend", "obligation trend", "spending trend", "concentrating or fragmenting", "spend over time"} if containsAny(q, spendTrendKeywords) { agency, hasAgency := quotedAgency(question) naics, hasNAICS := bareNAICS(question) if hasAgency && hasNAICS { return &graphRoute{intent: IntentAgencySpendTrend, args: []any{agency, naics}} } return nil // a spend trend question whose segment we cannot bind unambiguously falls back to RAG } // G7 agency buying pattern detail is the fuller where to position view (the vehicle mix, the set aside mix, the // NAICS and PSC distribution, and the average and median value band). It routes on the distinct detail phrasing, // checked BEFORE the existing G1 buying pattern keyword so the more specific phrase wins and the Phase 0 buying // pattern route stays byte unchanged. It needs a quoted agency; the NAICS is optional (the empty string means // every NAICS, the intent's own default). buyingDetailKeywords := []string{"buying pattern detail", "vehicle mix", "value band", "average and median", "detailed buying pattern", "how does the agency buy"} if containsAny(q, buyingDetailKeywords) { agency, hasAgency := quotedAgency(question) if hasAgency { naics, _ := bareNAICS(question) // optional; the empty string is the intent's every NAICS default return &graphRoute{intent: IntentAgencyBuyingPatternDetail, args: []any{agency, naics}} } return nil // a buying pattern detail question with no quoted agency falls back to RAG } // G2 partner or competitor ratio is the team versus compete read for one partner UEI in a core NAICS, from // public co occurrence alone. It is checked FIRST among the G2 routes so the distinct partner versus // competitor phrasing wins over the broader teaming keywords below. It needs BOTH a quoted UEI (a twelve // character partner identifier the user delimits) and a bare six digit NAICS to bind honestly; absent // either it falls back to RAG. It reads only the public is_galapagos self tag, never a tenant table. partnerRatioKeywords := []string{"partner or competitor", "team or compete", "team versus compete", "do they team or compete", "partner versus competitor"} if containsAny(q, partnerRatioKeywords) { uei, hasUEI := quotedUEI(question) naics, hasNAICS := bareNAICS(question) if hasUEI && hasNAICS { return &graphRoute{intent: IntentPartnerOrCompetitorRatio, args: []any{uei, naics}} } return nil // a partner or competitor question whose partner UEI and NAICS we cannot bind falls back to RAG } // G2 recompete teaming field is the forward window read of the incumbent's co occurring vendor // neighborhood, the likely teaming field before the RFP drops. It is checked BEFORE the broader vendor co // occurrence keywords so the distinct recompete teaming phrasing wins. It needs a quoted agency; the window // defaults to the intent clamp unless the question names a number of months. recompeteTeamingKeywords := []string{"recompete teaming", "teaming field before the rfp", "teaming field before the recompete", "who could team on the recompete"} if containsAny(q, recompeteTeamingKeywords) { agency, hasAgency := quotedAgency(question) if hasAgency { return &graphRoute{intent: IntentRecompeteTeamingField, args: []any{agency, windowMonths(question)}} } return nil // a recompete teaming question with no quoted agency falls back to RAG } // G2 vendor co occurrence field is the set of UEIs that co occur in one (agency, naics) segment, the likely // teaming and competitor field. It is checked BEFORE the broader competitor block so the distinct co // occurrence and teaming field phrasing wins. It needs BOTH a quoted agency and a bare six digit NAICS to // bind honestly (the only keys usaspending_awards carries); absent either it falls back to RAG. cooccurrenceKeywords := []string{"co occur", "cooccur", "co occurrence", "cooccurrence", "teaming field", "who teams up", "vendor field", "who appears together"} if containsAny(q, cooccurrenceKeywords) { agency, hasAgency := quotedAgency(question) naics, hasNAICS := bareNAICS(question) if hasAgency && hasNAICS { return &graphRoute{intent: IntentVendorCooccurrenceField, args: []any{agency, naics}} } return nil // a co occurrence question whose segment we cannot bind unambiguously falls back to RAG } // The competitor field and the incumbent walk are agency plus NAICS segment questions. They need both a // quoted agency and a bare six digit NAICS to bind honestly (the only keys usaspending_awards carries); if // either is missing the question falls back to RAG. competitorKeywords := []string{"competitor", "incumbent", "who teams", "teaming", "who else won", "market share", "dollar share", "who holds the work", "who has the work"} if containsAny(q, competitorKeywords) { agency, hasAgency := quotedAgency(question) naics, hasNAICS := bareNAICS(question) if hasAgency && hasNAICS { // The recency lookback is left to the intent's own default clamp (a bound zero), so the NL path never // invents a lookback the user did not state. return &graphRoute{intent: IntentSegmentCompetitorField, args: []any{agency, naics, 0}} } return nil // a competitor or incumbent question whose segment we cannot bind unambiguously falls back to RAG } // The recompete pipeline is an agency plus a forward window question. It needs a quoted agency; the window // is the intent's default clamp unless the question names an explicit number of months. recompeteKeywords := []string{"recompete", "recompete pipeline", "expiring", "coming up for", "pipeline of", "what is coming up", "takeaway"} if containsAny(q, recompeteKeywords) { agency, hasAgency := quotedAgency(question) if hasAgency { return &graphRoute{intent: IntentRecompetePipeline, args: []any{agency, windowMonths(question)}} } return nil // a recompete question with no quoted agency falls back to RAG } // The agency buying pattern is a where to position question keyed on a quoted agency or department prefix, // with an optional bare NAICS filter (the empty string means every NAICS, the intent's own default). buyingKeywords := []string{"agency buys", "agency buying", "buying pattern", "what does the agency buy", "where to position", "where should we position", "set aside mix", "set-aside mix"} if containsAny(q, buyingKeywords) { agency, hasAgency := quotedAgency(question) if hasAgency { naics, _ := bareNAICS(question) // optional; the empty string is the intent's every NAICS default return &graphRoute{intent: IntentAgencyBuyingPattern, args: []any{agency, naics}} } return nil // a buying pattern question with no quoted agency falls back to RAG } // The skill substitution and gap bridge walks key on a skill id (a UUID), which a user almost never types in // free text. A skill adjacency question (what substitutes for, skill adjacency, what is close to) that names // no skill id cannot be bound deterministically, so it falls back to RAG rather than guessing a skill. A // quoted UUID is the only unambiguous skill key; absent one, RAG. skillKeywords := []string{"what substitutes for", "substitutes for", "skill adjacency", "adjacent skill", "skill gap", "what is close to", "what bridges"} if containsAny(q, skillKeywords) { if id, ok := quotedUUID(question); ok { return &graphRoute{intent: IntentSkillSubstitutionClosure, args: []any{id, 0}} } return nil // a skill question with no explicit skill id falls back to RAG } return nil // not a graph question } // containsAny reports whether the lowercased question contains any of the keywords. func containsAny(loweredQuestion string, keywords []string) bool { for _, kw := range keywords { if strings.Contains(loweredQuestion, kw) { return true } } return false } // quotedAgency extracts a quoted agency name from the question, the only unambiguous agency shape. The match // is from the ORIGINAL cased question (the agency name in the database is cased), so the bound param preserves // the user's casing. A missing or too short quoted span is not an agency. func quotedAgency(question string) (string, bool) { m := quotedAgencyRe.FindStringSubmatch(question) if len(m) < 2 { return "", false } agency := strings.TrimSpace(m[1]) if agency == "" { return "", false } return agency, true } // quotedRole extracts a quoted role span from the question, the only unambiguous role shape in free text, so // the M5 wage route binds only an explicitly delimited role the caller means (for example "Program Manager"). // It reuses the quoted span matcher; a bare role phrase is NOT extracted (the role is ambiguous without // quotes), so the classifier never guesses a role. The match is from the ORIGINAL cased question so // resolveSOC sees the caller's casing. Absent a quoted role a wage question falls back to RAG. func quotedRole(question string) (string, bool) { m := quotedAgencyRe.FindStringSubmatch(question) if len(m) < 2 { return "", false } role := strings.TrimSpace(m[1]) if role == "" { return "", false } return role, true } // quotedStateArea resolves a state named anywhere in the question to a known OEWS area code (HI or NM) through // the same conservative matcher the G6 composition uses, so the M5 wage route binds only a state the wage // baseline covers. A question naming no known state (or a state with no wage data) yields ok false and falls // back to RAG rather than guessing a state. func quotedStateArea(question string) (string, bool) { return resolveArea(question) } // bareNAICS extracts a single bare six digit NAICS token from the question. Exactly one six digit token must // be present for an unambiguous segment key; zero matches is no NAICS, and the first match is taken when one // is present (a question rarely carries two distinct six digit codes, and the first is the segment of // interest). func bareNAICS(question string) (string, bool) { m := naicsRe.FindStringSubmatch(question) if len(m) < 2 { return "", false } return m[1], true } // windowMonths extracts an explicit forward window in months when the question states one, else returns 0 so // the recompete intent applies its own default clamp. A stated window is clamped by the intent, so an // oversized number is bounded downstream. func windowMonths(question string) int { m := windowMonthsRe.FindStringSubmatch(strings.ToLower(question)) if len(m) < 2 { return 0 } n, err := strconv.Atoi(m[1]) if err != nil { return 0 } return n } // ueiRe matches a quoted twelve character SAM UEI (the alphanumeric unique entity identifier), the only // unambiguous partner identifier a free form question can carry. The quotes delimit the partner the user // means, so the classifier never guesses a UEI from a bare token; the twelve character alphanumeric shape is // the SAM UEI format. A bare twelve character token in prose is NOT extracted (it is ambiguous), so the // partner ratio route binds only an explicitly delimited UEI. var ueiRe = regexp.MustCompile(`(?i)["']([0-9a-z]{12})["']`) // quotedUEI extracts a quoted twelve character UEI from the question, the only unambiguous partner id shape. // The match is from the ORIGINAL cased question so the bound param preserves the UEI's casing. Absent a quoted // UEI a partner or competitor question has no bindable partner and falls back to RAG. func quotedUEI(question string) (string, bool) { m := ueiRe.FindStringSubmatch(question) if len(m) < 2 { return "", false } return m[1], true } // uuidRe matches a canonical UUID, the only unambiguous skill id a free form question can carry. var uuidRe = regexp.MustCompile(`(?i)\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b`) // quotedUUID extracts a canonical UUID from the question, the only unambiguous skill id shape. Absent one, a // skill adjacency question has no bindable start skill and falls back to RAG. func quotedUUID(question string) (string, bool) { id := uuidRe.FindString(question) if id == "" { return "", false } return id, true } // heldClearanceCode extracts a held clearance code from a lowercased question by matching the natural // language phrase for each of the six reference codes, the only unambiguous held key in free text. The // longer phrases are checked first so ts sci wins over secret and top secret wins over secret. The matched // code is one of the six ParamEnum allowed values, so ExecuteIntent's enum guard accepts it; absent a named // held clearance the reachability question falls back to RAG rather than guessing a level. func heldClearanceCode(loweredQuestion string) (string, bool) { // Ordered longest phrase first so a more specific clearance wins over a shorter substring. phrases := []struct { phrase string code string }{ {"ts/sci", "ts_sci"}, {"ts sci", "ts_sci"}, {"ts_sci", "ts_sci"}, {"top secret sci", "ts_sci"}, {"sci", "ts_sci"}, {"top secret", "top_secret"}, {"top_secret", "top_secret"}, {"secret", "secret"}, {"confidential", "confidential"}, {"public trust", "none"}, {"unknown", "unknown"}, } for _, p := range phrases { if strings.Contains(loweredQuestion, p.phrase) { return p.code, true } } return "", false } // compartmentListRe matches a quoted comma separated compartment list, the only unambiguous compartment // shape a free form question can carry. The quotes delimit the codes the user means, so the classifier never // guesses a compartment from a bare token; the match is from the ORIGINAL cased question because compartment // codes are upper cased in the reference table. var compartmentListRe = regexp.MustCompile(`["']([A-Za-z0-9, ]{1,})["']`) // quotedCompartmentList extracts a quoted comma separated compartment list from the question, the only // unambiguous compartment shape. Absent a quoted list a compartment question has no bindable compartments and // falls back to RAG. func quotedCompartmentList(question string) (string, bool) { m := compartmentListRe.FindStringSubmatch(question) if len(m) < 2 { return "", false } list := strings.TrimSpace(m[1]) if list == "" { return "", false } return list, true }