package ai import ( "context" "errors" "fmt" "github.com/jackc/pgx/v5" "github.com/missionworkforce/missionmatch/internal/platform/tenant" ) // TAG (table augmented generation) answers questions that require the database to COMPUTE (count, sum, // filter, aggregate), which retrieval over text cannot do. The research is explicit that aggregation // questions must go to the database, not to vector RAG (docs/AI_RESEARCH.md, TAG). // // For cleared data the safe design is structured tool use, NOT free form natural language to SQL: the // model selects from a CURATED set of named, parameterized, read only intents and supplies typed // parameters. The model never authors SQL. Every intent is validated at startup against the read only // guard and the table allowlist, and references no eeo table, so a write or a demographics read is // impossible from the natural language path by construction. // ParamKind constrains an intent parameter so a model supplied value is type checked before binding. type ParamKind string const ( ParamText ParamKind = "text" ParamInt ParamKind = "int" ParamUUID ParamKind = "uuid" ParamEnum ParamKind = "enum" // value must be in Allowed ParamBoolean ParamKind = "bool" ) // Param describes one bound parameter of an intent. type Param struct { Name string Kind ParamKind Allowed []string // for ParamEnum } // QueryIntent is one safe, named, read only question the TAG layer can answer. SQL is parameterized // ($1, $2, ...) and runs RLS scoped as the application role, so it sees only the caller's tenant. type QueryIntent struct { Name string Description string // what question it answers, used for model routing SQL string Params []Param } // Registry is the closed set of TAG intents. Adding one is a reviewed security decision. var Registry = []QueryIntent{ { Name: "count_candidates_by_clearance", Description: "How many candidates hold a given clearance level (and optionally are available).", SQL: "SELECT clearance_level, count(*) AS n FROM candidates WHERE deleted_at IS NULL AND anonymized_at IS NULL AND ($1 = '' OR clearance_level = $1) GROUP BY clearance_level ORDER BY n DESC", Params: []Param{{Name: "clearance_level", Kind: ParamEnum, Allowed: []string{"", "none", "confidential", "secret", "top_secret", "ts_sci"}}}, }, { Name: "proposal_ready_for_requirement", Description: "How many persisted evaluations for a requirement are in the Green (proposal ready) band.", SQL: "SELECT band, count(*) AS n FROM candidate_evaluations WHERE requirement_id = $1 GROUP BY band ORDER BY n DESC", Params: []Param{{Name: "requirement_id", Kind: ParamUUID}}, }, { Name: "open_requirements_count", Description: "How many requirements are open (not soft deleted), optionally for one customer agency.", SQL: "SELECT count(*) AS n FROM requirements WHERE deleted_at IS NULL AND ($1 = '' OR customer_agency = $1)", Params: []Param{{Name: "customer_agency", Kind: ParamText}}, }, { Name: "slate_decision_mix", Description: "The approve/reject/pending decision mix of a slate's members.", SQL: "SELECT decision, count(*) AS n FROM slate_members WHERE slate_id = $1 GROUP BY decision ORDER BY n DESC", Params: []Param{{Name: "slate_id", Kind: ParamUUID}}, }, } // ValidateRegistry asserts every intent is safe: read only, references no eeo, and touches only // allowlisted tables. It is called at startup and by a test, so an unsafe intent cannot ship. func ValidateRegistry() error { for _, it := range Registry { if err := GuardReadOnlyQuery(it.SQL, ReadAllowlist); err != nil { return fmt.Errorf("ai: intent %q is unsafe: %w", it.Name, err) } } return nil } // IntentByName returns a registered intent. func IntentByName(name string) (QueryIntent, bool) { for _, it := range Registry { if it.Name == name { return it, true } } return QueryIntent{}, false } // TAGResult is the executed answer: the column names and the rows, ready to narrate. It carries no free // text from the model, only data the database returned. type TAGResult struct { Intent string Columns []string Rows [][]any } // ExecuteIntent binds the model supplied args (in the order of Params) and runs the intent RLS scoped as // the tenant. It re validates the SQL through the guard at execution time too (defense in depth), so even // a tampered registry cannot run a write or reach eeo. func ExecuteIntent(ctx context.Context, db *tenant.DB, intent QueryIntent, args []any) (*TAGResult, error) { if db == nil { return nil, errors.New("ai: no database") } if err := GuardReadOnlyQuery(intent.SQL, ReadAllowlist); err != nil { return nil, err } if len(args) != len(intent.Params) { return nil, fmt.Errorf("ai: intent %q expects %d params, got %d", intent.Name, len(intent.Params), len(args)) } out := &TAGResult{Intent: intent.Name} err := db.RunInTenantTx(ctx, func(ctx context.Context, tx pgx.Tx) error { rows, err := tx.Query(ctx, intent.SQL, args...) if err != nil { return err } defer rows.Close() for _, fd := range rows.FieldDescriptions() { out.Columns = append(out.Columns, string(fd.Name)) } for rows.Next() { vals, err := rows.Values() if err != nil { return err } out.Rows = append(out.Rows, vals) } return rows.Err() }) if err != nil { return nil, err } return out, nil }