Source identity
| Field | Recorded value |
|---|---|
| Source type | GitHub source |
| Original record | Janady13/missionmatch / internal/modules/ai/orchestrator.go |
| Date | Not specified in source |
| Repository | Janady13/missionmatch |
| Revision | 41000ad30fff4129fdaf5ec84e15ba14c27d4890 |
| Branch | m5-price-to-win |
| Path or archive member | internal/modules/ai/orchestrator.go |
| Original SHA-256 | 39103191bb5405214c11841d29df10606ee0f77132354eea4f676c1c1d4ff147 |
| Git blob SHA | 24fb4b067171cd31f16bb892e0f6a073a25e2f96 |
| Reading-copy SHA-256 | 39103191bb5405214c11841d29df10606ee0f77132354eea4f676c1c1d4ff147 |
Every recorded occurrence
These 17 records contain identical original source bytes. Their paths and revisions remain part of the archive.
| Original record | Branch or archive | Revision / identifier | Original location |
|---|---|---|---|
| Janady13/missionmatch / internal/modules/ai/orchestrator.go | m5-price-to-win | 41000ad30fff4129fdaf5ec84e15ba14c27d4890 | Open original |
| Janady13/missionmatch / internal/modules/ai/orchestrator.go | collision-deconfliction | 4ee5a09972608ed89f8377c6665542d5f450f0a6 | Open original |
| Janady13/missionmatch / internal/modules/ai/orchestrator.go | graphrag-foundation | b0ddeb9f633fd138d09309d1a17b7d13bc3a67f7 | Open original |
| Janady13/missionmatch / internal/modules/ai/orchestrator.go | time-to-fill-survival | b66e1e505863d4f17741a4b9ede73509e45bd6c6 | Open original |
| Janady13/missionmatch / internal/modules/ai/orchestrator.go | mag-substrate | b82c7100a492dec67dfd1eda6b3f53def859be35 | Open original |
| Janady13/missionmatch / internal/modules/ai/orchestrator.go | capture-readiness | bae6af7cb2997e819174a9e9cfff5e16067a1975 | Open original |
| Janady13/missionmatch / internal/modules/ai/orchestrator.go | phase2-connectors | e038d4c687aec16f8bd9bc4d907eea14d91f8704 | Open original |
| Janady13/missionmatch / internal/modules/ai/orchestrator.go | presentation-readiness | eb574acaa29d71ce4d0f852572e87c275c79fdb7 | Open original |
| Janady13/missionmatch / internal/modules/ai/orchestrator.go | vendor-intelligence | fe9796a19c261951d3f2565206c0c1b5ac294465 | Open original |
| Janady13/missionmatch / internal/modules/ai/orchestrator.go | collision-deconfliction | 4ee5a09972608ed89f8377c6665542d5f450f0a6 | Open original |
| Janady13/missionmatch / internal/modules/ai/orchestrator.go | m5-price-to-win | 41000ad30fff4129fdaf5ec84e15ba14c27d4890 | Open original |
| Janady13/missionmatch / internal/modules/ai/orchestrator.go | mag-substrate | b82c7100a492dec67dfd1eda6b3f53def859be35 | Open original |
| Janady13/missionmatch / internal/modules/ai/orchestrator.go | time-to-fill-survival | b66e1e505863d4f17741a4b9ede73509e45bd6c6 | Open original |
| Janady13/missionmatch / internal/modules/ai/orchestrator.go | graphrag-foundation | b0ddeb9f633fd138d09309d1a17b7d13bc3a67f7 | Open original |
| Janady13/missionmatch / internal/modules/ai/orchestrator.go | presentation-readiness | eb574acaa29d71ce4d0f852572e87c275c79fdb7 | Open original |
| Janady13/missionmatch / internal/modules/ai/orchestrator.go | phase2-connectors | e038d4c687aec16f8bd9bc4d907eea14d91f8704 | Open original |
| Janady13/missionmatch / internal/modules/ai/orchestrator.go | capture-readiness | bae6af7cb2997e819174a9e9cfff5e16067a1975 | Open original |
Recovered source transcript
Download complete source text ↓340 lines · 16.1 KB
package ai
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"github.com/jackc/pgx/v5"
"github.com/missionworkforce/missionmatch/internal/platform/audit"
"github.com/missionworkforce/missionmatch/internal/platform/inference"
"github.com/missionworkforce/missionmatch/internal/platform/tenant"
)
// Route is the strategy the router picks for a question. The research recommends choosing the cheapest
// sufficient strategy per query (adaptive RAG), and routing aggregation questions to the database rather
// than to vector RAG (docs/AI_RESEARCH.md, routing + TAG).
type Route string
const (
RouteRefuse Route = "refuse" // unsafe input (eeo, injection)
RouteTAG Route = "tag" // the database must compute (count, sum, aggregate)
RouteRAG Route = "rag" // retrieve the tenant's candidates and narrate
RouteDirect Route = "direct" // answer from stable rules in context (CAG), no retrieval
RouteGraph Route = "graph" // walk a cited graph evidence path (G3 skill adjacency, G1 market backbone)
RoutePlan Route = "plan" // bounded plan and execute over a set of closed Registry intents (the planner)
)
// RationaleSource records whether the answer prose was confirmed by the faithfulness gate (ai_faithful) or
// fell back to the deterministic rendering of the evidence (deterministic).
const (
SourceAIFaithful = "ai_faithful"
SourceDeterministic = "deterministic"
)
// ErrNoModel marks that no inference client is configured (or it was not needed). Generation then falls
// back to the deterministic answer; it is never a hang and never a panic.
var ErrNoModel = errors.New("ai: no inference client configured")
// ErrUnknownIntent marks a TAG request for an intent that is not in the curated registry. The handler maps
// it to a 422, never a raw SQL error.
var ErrUnknownIntent = errors.New("ai: unknown query intent")
// Response is an answer plus the provenance the self correction gate and the audit need. The answer is
// ALWAYS grounded: it is the model's prose only when the faithfulness gate confirms it, otherwise the
// deterministic rendering of the same evidence. A refusal (EEO, unanswerable) carries no answer.
type Response struct {
Answer string `json:"answer"`
Route Route `json:"route"`
RationaleSource string `json:"rationale_source"` // ai_faithful | deterministic
Citations []string `json:"citations"` // the evidence the answer was grounded on
Faithful bool `json:"faithful"`
FaithScore float64 `json:"faithfulness_score"`
EvidenceCount int `json:"evidence_count"`
Refused bool `json:"refused"`
RefusalReason string `json:"refusal_reason,omitempty"`
FallbackReason string `json:"fallback_reason,omitempty"` // why the deterministic fallback was used; NEVER in the audit payload
MissionScope string `json:"mission_scope,omitempty"` // the mission area retrieval was scoped to (WS6), empty when unscoped
}
// Orchestrator routes a question across the augmented generation strategies, runs the faithfulness gate
// before any model prose ships, and audits every action. It holds the tenant database (RLS scoped) and the
// stateless inference client; it stores nothing itself.
type Orchestrator struct {
db *tenant.DB
client *inference.Client
aud *audit.Writer
mem *Memory
}
// NewOrchestrator builds the orchestrator. client may be nil (every answer is then deterministic, the
// graceful no plane posture); aud may be nil in tests.
func NewOrchestrator(db *tenant.DB, client *inference.Client, aud *audit.Writer) *Orchestrator {
return &Orchestrator{db: db, client: client, aud: aud}
}
// WithMemory wires the per tenant memory so RAG answers are memory augmented: the tenant's own recalled
// craft notes join the retrieved candidates as evidence. Memory is RLS scoped, so one tenant's memory can
// never surface in another's answer. Returns the orchestrator for chaining.
func (o *Orchestrator) WithMemory(m *Memory) *Orchestrator {
o.mem = m
return o
}
// Answer is the top level dispatcher: a structured intent runs the safe TAG path, or the structured GRAPH
// path when the named intent is one of the closed graph traversals; otherwise the rule based router picks the
// conservative GraphRAG path (a free form capture or skill question whose params can be bound unambiguously),
// CAG (stable rules), RAG (retrieve and narrate), or a refusal. KAG skill adjacency is a live capability
// reached directly (ai.SkillAdjacency). A free form aggregation question (RouteTAG with no intent) is served
// best effort by RAG, because the brain never authors free form SQL; an exact aggregation over tenant data
// uses the structured intent path. The model never authors SQL or params on any path: a graph intent and its
// arg slots are fixed, and a free form graph question whose required params cannot be extracted falls back to
// RAG rather than guessing.
func (o *Orchestrator) Answer(ctx context.Context, question, intentName string, args []any) (*Response, error) {
if strings.TrimSpace(intentName) != "" {
// A named GRAPH intent runs the structured graph path (the cited evidence walk, gated and audited as
// route graph); every other named intent runs the existing TAG path byte for byte as before.
if IsGraphIntent(intentName) {
return o.AnswerGraph(ctx, question, intentName, args)
}
return o.AnswerTAG(ctx, intentName, args)
}
// The conservative natural language graph router runs BEFORE the general Classify: a free form question
// that clearly names a capture or skill graph traversal AND carries the params to bind it unambiguously
// routes to the structured graph path; everything else (including a graph shaped question whose params
// could not be bound) falls through to the existing classification, default path invariance.
if gr := graphClassify(question); gr != nil {
return o.AnswerGraph(ctx, question, gr.intent, gr.args)
}
// THE PLANNER. A COMPOUND cross plane question (one that names more than one plane, for example a bench
// word AND a market word) that the single shot routes above could not bind to one intent routes to the
// planner, which selects and runs a SET of closed Registry intents and composes their evidence. This runs
// AFTER the single intent fast paths and BEFORE the Classify RAG fallback. The signal is conservative: a
// non compound question is byte for byte unchanged (isCompoundQuestion returns false), so a working single
// shot route is never regressed. AnswerPlan itself runs the EEO guards first and falls back to RAG when no
// stable plan binds, so an unbindable compound question is also unchanged.
if isCompoundQuestion(question) {
return o.AnswerPlan(ctx, question)
}
switch Classify(question) {
case RouteRefuse:
return o.refuse(ctx, RouteRefuse, "the question references prohibited demographic data"), nil
case RouteRAG, RouteTAG:
return o.AnswerRAG(ctx, question, 8)
default:
return o.AnswerDirect(ctx, question)
}
}
// Classify is the lightweight router. A trained classifier is the documented upgrade; rule based routing
// over question shape is competitive at this scale and is transparent (docs/AI_RESEARCH.md, routing).
func Classify(question string) Route {
q := strings.ToLower(question)
// Refuse a question that names the eeo schema OR that asks about a protected attribute in plain words.
if ForbidEEO(q) != nil || MentionsProtectedAttribute(q) {
return RouteRefuse
}
for _, kw := range []string{"how many", "count", "number of", "total", "sum", "average", "percentage", "ratio", "mix", "breakdown"} {
if strings.Contains(q, kw) {
return RouteTAG
}
}
for _, kw := range []string{"who", "which candidate", "find", "show me", "list", "top ", "best ", "match", "search"} {
if strings.Contains(q, kw) {
return RouteRAG
}
}
return RouteDirect
}
// AnswerRAG retrieves the tenant's candidates for the question, narrates strictly from that evidence, and
// runs the faithfulness gate before returning. The deterministic engine remains the floor: this narrates
// over retrieved rows, it never scores. Retrieval needs the plane to embed the query, so a plane outage
// surfaces as a typed error (through the circuit breaker), never a hang.
func (o *Orchestrator) AnswerRAG(ctx context.Context, question string, limit int) (*Response, error) {
if ForbidEEO(question) != nil || MentionsProtectedAttribute(question) {
return o.refuse(ctx, RouteRAG, "the question references prohibited demographic data"), nil
}
if o.client == nil {
return nil, ErrNoModel // retrieval cannot embed the query without the plane
}
// Mission scoping (WS6 Option A): if the question names a mission area from the curated mission_spine
// vocabulary, constrain retrieval to candidates whose full_name, headline, or location text matches it.
// An unnamed mission is "", which runs the unscoped retrieval byte for byte as before.
mission := MissionFromQuestion(question)
hits, err := HybridSearch(ctx, o.db, o.client, question, mission, limit)
if err != nil {
return nil, err
}
if len(hits) == 0 {
reason := "no candidates matched the question"
if mission != "" {
reason = "no candidates matched the question within the " + mission + " mission scope"
}
return o.refuse(ctx, RouteRAG, reason), nil
}
evidence := make([]string, len(hits))
names := make([]string, len(hits))
for i, h := range hits {
evidence[i] = fmt.Sprintf("%s (relevance %.4f)", h.FullName, h.Score)
names[i] = h.FullName
}
// Memory augmented generation: the tenant's own recalled craft notes join the evidence (RLS scoped, so
// never another tenant's). Best effort, never fatal: a recall error or no client just omits the notes.
if o.mem != nil {
if notes, mErr := o.mem.Recall(ctx, question, 3); mErr == nil {
for _, n := range notes {
evidence = append(evidence, "memory note: "+n)
}
}
}
deterministic := "Top matching candidates: " + strings.Join(names, ", ") + "."
model, genErr := o.generate(ctx,
"Answer the question using ONLY the listed candidates as evidence. Cite candidates by name. If the evidence does not answer the question, say so.",
"QUESTION: "+question+"\n\nCANDIDATES:\n"+strings.Join(evidence, "\n"))
resp := o.finalize(ctx, RouteRAG, model, genErr, deterministic, evidence)
resp.MissionScope = mission
return resp, nil
}
// AnswerTAG runs a curated, parameterized, read only intent and narrates the database's own result. This
// is the path for aggregation questions; the model never authors SQL. The data is the database's, so a
// plane outage degrades only the prose: the deterministic rendering of the rows is always returned.
func (o *Orchestrator) AnswerTAG(ctx context.Context, intentName string, args []any) (*Response, error) {
intent, ok := IntentByName(intentName)
if !ok {
return nil, fmt.Errorf("%w: %q", ErrUnknownIntent, intentName)
}
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 := renderTable(intent.Description, res.Columns, res.Rows)
model, genErr := o.generate(ctx,
"State the result plainly from the table data. Do not invent numbers; report exactly what the rows say.",
"INTENT: "+intent.Description+"\n\nRESULT:\n"+string(rendered))
return o.finalize(ctx, RouteTAG, model, genErr, deterministic, evidence), nil
}
// AnswerDirect answers from the stable public rules in context (CAG): no retrieval, no tenant data. The
// rules are contents free, so this never touches tenant rows; a plane outage returns the rules themselves.
func (o *Orchestrator) AnswerDirect(ctx context.Context, question string) (*Response, error) {
if ForbidEEO(question) != nil || MentionsProtectedAttribute(question) {
return o.refuse(ctx, RouteDirect, "the question references prohibited demographic data"), nil
}
rules := PublicRules()
evidence := []string{rules}
model, genErr := o.generate(ctx,
"Answer using ONLY these platform rules as evidence. If the rules do not answer the question, say so. Do not invent a number or a weight.",
"QUESTION: "+question+"\n\nRULES:\n"+rules)
return o.finalize(ctx, RouteDirect, model, genErr, rules, evidence), nil
}
// generate calls the plane (the injection guard is wrapped on the gateway side). A nil client is not an
// error to the caller: it returns ErrNoModel so finalize takes the deterministic path.
func (o *Orchestrator) generate(ctx context.Context, system, prompt string) (string, error) {
if o.client == nil {
return "", ErrNoModel
}
out, err := o.client.Generate(ctx, inference.GenerateRequest{System: system, Prompt: prompt, MaxTokens: 400, Temperature: 0.1})
if err != nil {
return "", err
}
return strings.TrimSpace(out.Text), nil
}
// finalize is the self correction step. It returns the model prose ONLY when the faithfulness gate confirms
// it is supported by the evidence; otherwise it returns the deterministic rendering of the same evidence
// (a 200, rationale_source deterministic), recording why. The audit records the route, the rationale source,
// and the evidence count, never the answer text, the question, the fallback reason, or any candidate.
func (o *Orchestrator) finalize(ctx context.Context, route Route, modelAnswer string, genErr error, deterministic string, citations []string) *Response {
resp := &Response{Route: route, Citations: citations, EvidenceCount: len(citations)}
if genErr != nil || strings.TrimSpace(modelAnswer) == "" {
resp.Answer = deterministic
resp.RationaleSource = SourceDeterministic
resp.FallbackReason = fallbackReason(genErr)
o.audit(ctx, "aedt.ai.answered", map[string]any{"route": route, "rationale_source": SourceDeterministic, "evidence": len(citations)})
return resp
}
verdict, err := o.client.CheckFaithfulness(ctx, modelAnswer, citations)
if err == nil && verdict.Supported {
resp.Answer = modelAnswer
resp.RationaleSource = SourceAIFaithful
resp.Faithful = true
resp.FaithScore = verdict.Score
} else {
resp.Answer = deterministic
resp.RationaleSource = SourceDeterministic
if err != nil {
resp.FallbackReason = fallbackReason(err)
} else {
resp.FaithScore = verdict.Score
resp.FallbackReason = "could_not_confirm"
}
}
o.audit(ctx, "aedt.ai.answered", map[string]any{"route": route, "rationale_source": resp.RationaleSource, "faithful": resp.Faithful, "evidence": len(citations)})
return resp
}
func fallbackReason(err error) string {
switch {
case err == nil:
return "could_not_confirm"
case errors.Is(err, ErrNoModel):
return "no_model"
case errors.Is(err, inference.ErrCircuitOpen):
return "circuit_open"
case errors.Is(err, context.DeadlineExceeded):
return "plane_timeout"
default:
return "plane_error"
}
}
// renderTable builds a deterministic, readable answer from a TAG result, so the aggregation question is
// answered exactly from the database even when the model prose is withheld.
func renderTable(description string, columns []string, rows [][]any) string {
if len(rows) == 0 {
return description + ": no rows."
}
var b strings.Builder
b.WriteString(description + ": ")
parts := make([]string, 0, len(rows))
for _, row := range rows {
cells := make([]string, 0, len(row))
for i, v := range row {
col := ""
if i < len(columns) {
col = columns[i] + "="
}
cells = append(cells, fmt.Sprintf("%s%v", col, v))
}
parts = append(parts, strings.Join(cells, " "))
}
b.WriteString(strings.Join(parts, "; "))
b.WriteString(".")
return b.String()
}
func (o *Orchestrator) refuse(ctx context.Context, route Route, reason string) *Response {
o.audit(ctx, "aedt.ai.refused", map[string]any{"route": route, "reason": reason})
return &Response{Route: route, Refused: true, RefusalReason: reason}
}
// audit writes one append only AEDT event for an AI action. Payloads carry the route and verdict only,
// never the answer text, the question, the fallback reason, or any candidate, so the audit holds no PII.
func (o *Orchestrator) audit(ctx context.Context, action string, payload map[string]any) {
if o.aud == nil || o.db == nil {
return
}
body, _ := json.Marshal(payload)
_ = o.db.RunInTenantTx(ctx, func(ctx context.Context, tx pgx.Tx) error {
return o.aud.OnTx(ctx, tx, audit.Event{Action: action, EntityType: "ai", NewValues: body})
})
}