package ai import ( "encoding/json" "errors" "net/http" "strings" "github.com/go-chi/chi/v5" "github.com/missionworkforce/missionmatch/internal/platform/inference" "github.com/missionworkforce/missionmatch/internal/platform/security" "github.com/missionworkforce/missionmatch/internal/platform/tenant" ) // Handler mounts the AI brain under the tenant scoped group, behind the session resolver, CSRF, RBAC, rate // limit, and audit (the same chain as every other module). It exposes one endpoint that routes a natural // language question across the augmented generation strategies and returns an explainable, faithfulness // gated, tenant isolated answer. The deterministic engine remains the floor; the brain narrates, it never // scores. type Handler struct { orch *Orchestrator gate *VectorGate // optional enforced re embed gate; nil means open (no vector store to verify) } // NewHandler builds the handler. orch may be nil (the brain is then reported unavailable rather than // panicking, the graceful posture when the inference plane is unconfigured). func NewHandler(orch *Orchestrator) *Handler { return &Handler{orch: orch} } // WithVectorGate attaches the enforced re embed gate. When set, /ai/query refuses to serve until the // gate confirms the stored vectors were produced by the configured embed model, so a half re embedded or // wrong model store cannot silently mis rank a retrieval. func (h *Handler) WithVectorGate(g *VectorGate) *Handler { h.gate = g; return h } // Routes mounts the brain endpoint gated on the brain.query capability (the same tier as search.run). func (h *Handler) Routes(r chi.Router) { r.With(security.RequireCapability(security.CapBrainQuery)).Post("/ai/query", h.query) } // PublicRoutes mounts the unauthenticated federation read, GET or POST /mm-recall. It is deliberately NOT // behind the session resolver or CSRF: it takes no cookie, touches no database, no tenant, and no // orchestrator, and can only ever return the static, public, contents free knowledge base (the curated CAG // cards, each already cited and demographic free). There is nothing to protect and nothing to forge. It is // the World to missionMATCH federation edge: the world brain retrieves relevance ranked public knowledge, // and the caller's own relevance gate decides what to keep. A protected attribute query returns no cards. func (h *Handler) PublicRoutes(r chi.Router) { r.Get("/mm-recall", h.mmRecall) r.Post("/mm-recall", h.mmRecall) } // MMRecallResponse is the public federation read result: relevance ranked PUBLIC knowledge cards and // nothing else. No tenant data, no candidate data, no demographic field ever appears here. type MMRecallResponse struct { Results []MMRecallItem `json:"results"` Count int `json:"count"` } // MMRecallItem is one public knowledge card rendered for federation: the taught content with its cited // source. The text field name matches what a federated caller reads. type MMRecallItem struct { Text string `json:"text"` Topic string `json:"topic"` Source string `json:"source"` } func (h *Handler) mmRecall(w http.ResponseWriter, r *http.Request) { q := strings.TrimSpace(r.URL.Query().Get("q")) k := 4 if r.Method == http.MethodPost { var in struct { Query string `json:"query"` Q string `json:"q"` Question string `json:"question"` K int `json:"k"` } dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16)) if err := dec.Decode(&in); err == nil { for _, s := range []string{in.Query, in.Q, in.Question} { if t := strings.TrimSpace(s); t != "" { q = t break } } if in.K > 0 { k = in.K } } } if k < 1 || k > 8 { k = 4 } // Defense in depth: the knowledge base holds no demographic data, but a protected attribute or eeo // question still returns nothing, so this edge can never be turned into a probe for one. The guard runs // on the FULL query, before any truncation, so a protected word pushed past the length cap can never be // scrubbed from the guard's view while still triggering a card. lq := strings.ToLower(q) if q == "" || MentionsProtectedAttribute(lq) || ForbidEEO(lq) != nil { writeJSON(w, http.StatusOK, MMRecallResponse{Results: []MMRecallItem{}, Count: 0}) return } sq := q if len(sq) > 400 { sq = sq[:400] } cards := SelectKnowledge(sq, k) items := make([]MMRecallItem, 0, len(cards)) for _, c := range cards { items = append(items, MMRecallItem{Text: c.Content + " (source: " + c.Source + ")", Topic: c.Topic, Source: c.Source}) } writeJSON(w, http.StatusOK, MMRecallResponse{Results: items, Count: len(items)}) } // QueryRequest is the brain query. A free form question routes across CAG, RAG, and a refusal; an explicit // intent runs the curated, parameterized, read only TAG path over the database of record. The brain never // authors free form SQL. type QueryRequest struct { Question string `json:"question"` Intent string `json:"intent,omitempty"` Args []any `json:"args,omitempty"` } // AnswerResponse is the brain's answer plus its provenance: which augmented path served it, whether the // prose was confirmed faithful or fell back to the deterministic rendering, and the evidence it was // grounded on. A faithfulness fallback is a normal 200 with rationale_source deterministic. type AnswerResponse struct { Answer string `json:"answer"` Path string `json:"path"` // cag | tag | rag | graph RationaleSource string `json:"rationale_source"` Citations []string `json:"citations"` Faithful bool `json:"faithful"` EvidenceCount int `json:"evidence_count"` FallbackReason string `json:"fallback_reason,omitempty"` } func (h *Handler) query(w http.ResponseWriter, r *http.Request) { if h.orch == nil { writeErr(w, http.StatusServiceUnavailable, "brain_unavailable", "the brain is not configured") return } // The enforced re embed gate: refuse to serve a query over a vector store that does not match the // configured embed model, before any work, so a wrong model store never silently mis ranks. if h.gate != nil { if err := h.gate.Check(r.Context()); err != nil { mapErr(w, err) return } } var in QueryRequest dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)) dec.DisallowUnknownFields() if err := dec.Decode(&in); err != nil { writeErr(w, http.StatusBadRequest, "bad_request", "invalid request body") return } if strings.TrimSpace(in.Question) == "" && strings.TrimSpace(in.Intent) == "" { writeErr(w, http.StatusUnprocessableEntity, "validation", "a question or an intent is required") return } resp, err := h.orch.Answer(r.Context(), in.Question, in.Intent, in.Args) if err != nil { mapErr(w, err) return } if resp.Refused { // An unanswerable, unsafe, or out of allowlist question is a typed 4xx, never a raw error. writeErr(w, http.StatusUnprocessableEntity, "refused", resp.RefusalReason) return } writeJSON(w, http.StatusOK, AnswerResponse{ Answer: resp.Answer, Path: pathOf(resp.Route), RationaleSource: resp.RationaleSource, Citations: resp.Citations, Faithful: resp.Faithful, EvidenceCount: resp.EvidenceCount, FallbackReason: resp.FallbackReason, }) } // pathOf maps the internal route to the augmented path name the caller sees. func pathOf(route Route) string { switch route { case RouteTAG: return "tag" case RouteRAG: return "rag" case RouteGraph: return "graph" default: return "cag" } } // mapErr maps a brain error to the typed envelope. It NEVER returns a raw SQL or database error string. func mapErr(w http.ResponseWriter, err error) { switch { case errors.Is(err, ErrNoModel): writeErr(w, http.StatusServiceUnavailable, "brain_unavailable", "the inference plane is not configured") case errors.Is(err, ErrVectorModelMismatch): writeErr(w, http.StatusServiceUnavailable, "vector_model_mismatch", "the stored vectors do not match the configured embed model; the brain is disabled until the store is re embedded") case errors.Is(err, inference.ErrCircuitOpen), errors.Is(err, inference.ErrPlaneUnavailable): writeErr(w, http.StatusServiceUnavailable, "plane_unavailable", "the inference plane is temporarily unavailable") case errors.Is(err, ErrUnknownIntent): writeErr(w, http.StatusUnprocessableEntity, "unknown_intent", "no such query intent") case errors.Is(err, ErrEEOForbidden): writeErr(w, http.StatusUnprocessableEntity, "refused", "the question references prohibited demographic data") case errors.Is(err, tenant.ErrNoTenant): writeErr(w, http.StatusForbidden, "no_tenant", "no validated tenant") default: // Any other failure (a bad intent argument, a database error) is reported generically so no raw SQL // or internal detail leaks to the caller. writeErr(w, http.StatusBadRequest, "brain_error", "the brain could not answer the question") } } type errorBody struct { Error errorEnvelope `json:"error"` } type errorEnvelope struct { Code string `json:"code"` Message string `json:"message"` } func writeErr(w http.ResponseWriter, status int, code, msg string) { writeJSON(w, status, errorBody{errorEnvelope{Code: code, Message: msg}}) } func writeJSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(v) }