Source identity
| Field | Recorded value |
|---|---|
| Source type | GitHub source |
| Original record | Janady13/missionmatch / internal/modules/ai/planner.go |
| Date | Not specified in source |
| Repository | Janady13/missionmatch |
| Revision | b0ddeb9f633fd138d09309d1a17b7d13bc3a67f7 |
| Branch | graphrag-foundation |
| Path or archive member | internal/modules/ai/planner.go |
| Original SHA-256 | 15371f4042284d6dd2c559da569f5c5913bc3d25543607b31fa4f085ed2a365f |
| Git blob SHA | 19913c1197068bcf2e6ecd7c7f9a2fa9a5574aed |
| Reading-copy SHA-256 | 15371f4042284d6dd2c559da569f5c5913bc3d25543607b31fa4f085ed2a365f |
Recovered source transcript
Download complete source text ↓600 lines · 26.9 KB
package ai
// THE PLANNER. This file generalizes the G6 loop (the one hardcoded fan out over four fixed intents in
// graph_loop.go) into a data driven planner that answers a COMPOUND cross plane question by SELECTING and
// running a set of EXISTING closed Registry intents and composing their evidence behind the faithfulness
// gate. It is the biggest unlock on the ANI roadmap: it turns the single shot keyword router (which drops
// every compound capture question to RAG narration) into a true multi step reasoner, while the model
// authors ZERO SQL and ZERO param values.
//
// Why this is safe by construction. The model NEVER authors SQL and NEVER supplies a param value. It
// selects from a CLOSED enum of intent names; the validator asserts every name is in Registry and drops
// anything else. Every param value comes from the same deterministic extractors graphClassify already uses
// (bareNAICS, quotedAgency, quotedUUID, heldClearanceCode, quotedUEI, windowMonths, quotedCompartmentList),
// so the read only guard, the EEO and protected attribute guards, bench suppression, RLS RunInTenantTx, and
// the frozen engine boundary all apply UNCHANGED on every node (each node runs its existing guarded path).
// A bench node runs only through the suppressing executor (runBenchIntent), never the raw ExecuteIntent
// path (which already hard rejects bench intents). The G6 capture composition runs through
// answerCapturePicture. Every other graph or TAG intent runs through ExecuteIntent. The planner adds NO new
// query and NO new SQL. Hard caps (maxPlanNodes, no recursion) bound the work; the surviving node set runs
// in a deterministic order (Registry order, then name) so the composed answer is byte stable. Self
// consistency samples the plan K times and runs only the stable intent set. The plan trace audit carries
// only names, kinds, counts, and verdicts, never a value and never PII.
//
// Iteration 1 (this file) is planned SELECTION with deterministic binding. Data dependency edges (one
// node's scalar output binding a downstream node's arg) are a documented iteration 2, explicitly out of
// scope here so the first planner is maximally safe.
import (
"context"
"encoding/json"
"sort"
"strings"
"github.com/missionworkforce/missionmatch/internal/platform/inference"
"github.com/missionworkforce/missionmatch/internal/platform/tenant"
)
// PlanNode is one selected intent in a plan: a name that MUST be in the closed Registry, plus the args the
// validator bound for it from the available params. The model supplies only the Intent name; Args are bound
// deterministically by the orchestrator, never by the model.
type PlanNode struct {
Intent string
Args []any
}
// Plan is the validated, bounded set of intents to run for one compound question. It carries no SQL and no
// model authored value; every Intent is a Registry name and every Arg came from a deterministic extractor.
type Plan struct {
Nodes []PlanNode
}
// maxPlanNodes is the hard cap on how many intents one plan may run. It bounds the work the same way a
// recursive CTE's depth cap bounds its walk; there is no recursion in the planner.
const maxPlanNodes = 6
// planSelfConsistencyK is the number of times the plan is sampled before only the stable (majority) intent
// set runs. It mirrors the low temperature the faithfulness gate already uses, three samples.
const planSelfConsistencyK = 3
// availableParams is the typed bag of what the question unambiguously carries, extracted by the same
// deterministic extractors graphClassify uses. The model supplies none of these; every value here came
// from a regex over the original question. A node binds only from this bag, so the model can never inject a
// param value.
type availableParams struct {
Agency string
AgencyOK bool
NAICS string
NAICSOK bool
UUID string
UUIDOK bool
HeldClearance string
HeldOK bool
UEI string
UEIOK bool
WindowMonths int
Compartments string
CompartmentsOK bool
}
// extractAvailableParams runs the existing deterministic extractors and returns the typed bag of what the
// question unambiguously carries. It is pure, deterministic, and touches no database. Every value comes from
// a regex over the question text, never from the model. WindowMonths defaults to zero so an intent that
// takes a window applies its own clamp when the question states no months.
func extractAvailableParams(question string) availableParams {
lower := strings.ToLower(question)
var a availableParams
if agency, ok := quotedAgency(question); ok {
a.Agency, a.AgencyOK = agency, true
}
if naics, ok := bareNAICS(question); ok {
a.NAICS, a.NAICSOK = naics, true
}
if id, ok := quotedUUID(question); ok {
a.UUID, a.UUIDOK = id, true
}
if code, ok := heldClearanceCode(lower); ok {
a.HeldClearance, a.HeldOK = code, true
}
if uei, ok := quotedUEI(question); ok {
a.UEI, a.UEIOK = uei, true
}
a.WindowMonths = windowMonths(question)
if list, ok := quotedCompartmentList(question); ok {
a.Compartments, a.CompartmentsOK = list, true
}
return a
}
// paramSource names the available bag field a Registry param binds from. It is the FIXED lookup table keyed
// on the EXACT param names the Registry uses (read from tag.go and graph_*.go), so a param binds by its
// declared name, never by a guess. A param name not in this table never binds, which means its intent is
// simply never selectable by the planner, the safe default.
type paramSource string
const (
srcUUID paramSource = "uuid" // a ParamUUID id binds from avail.UUID
srcAgency paramSource = "agency" // an agency or agency_prefix ParamText binds from avail.Agency
srcNAICS paramSource = "naics" // a naics ParamText binds from avail.NAICS
srcHeldClearance paramSource = "held" // a clearance ParamEnum binds from avail.HeldClearance
srcUEI paramSource = "uei" // a partner_uei ParamText binds from avail.UEI
srcCompartments paramSource = "compartments" // a compartment list ParamText binds from avail.Compartments
srcWindowMonths paramSource = "window_months" // a window ParamInt binds from avail.WindowMonths
srcClampZero paramSource = "clamp_zero" // a bounded ParamInt the question never states binds zero, the intent's own clamp
)
// paramBindings maps every Registry param NAME to the available bag field it binds from. The keys are the
// EXACT names declared in the Registry. A required param whose name is absent here cannot bind, so its
// intent drops, which is safe (the planner simply never selects an intent it cannot bind honestly). The
// window and clamp sources differ: a window_months reads the stated window (zero applies the intent clamp),
// while max_depth and lookback_years always bind zero so the intent applies its own clamp and the planner
// never invents a recursion depth or a lookback the user did not state.
var paramBindings = map[string]paramSource{
// UUID id params across the planes.
"requirement_id": srcUUID,
"slate_id": srcUUID,
"start_skill_id": srcUUID,
"required_skill_id": srcUUID,
"opportunity_id": srcUUID,
"skill_id": srcUUID,
// Agency text params (the buyer key the market and TAG reads use).
"agency": srcAgency,
"agency_prefix": srcAgency,
"customer_agency": srcAgency,
// NAICS text param.
"naics": srcNAICS,
// Clearance enum params (both bind the held clearance code; the executor re checks each declared allowed
// set, so a value out of one intent's vocabulary is rejected at execution, not bound blindly).
"held_code": srcHeldClearance,
"clearance_level": srcHeldClearance,
// Partner UEI text param.
"partner_uei": srcUEI,
// Compartment list text param.
"required_compartments": srcCompartments,
// Window and clamp integer params.
"window_months": srcWindowMonths,
"max_depth": srcClampZero,
"lookback_years": srcClampZero,
}
// bindIntentArgs supplies, for each of the intent's Params in order, the matching available value by the
// param NAME through the fixed paramBindings table. It returns (args, true) ONLY when every required param
// is available, else (nil, false) so the node is dropped. The model supplies no value here: each arg comes
// from the available bag (a deterministic extractor) or, for a bounded integer the question never states,
// the intent's own clamp (zero). An intent with no params binds an empty arg list and always succeeds.
func bindIntentArgs(intent QueryIntent, avail availableParams) ([]any, bool) {
if len(intent.Params) == 0 {
return nil, true
}
args := make([]any, 0, len(intent.Params))
for _, p := range intent.Params {
src, known := paramBindings[p.Name]
if !known {
return nil, false // an unmapped param name can never bind: the intent drops, the safe default
}
switch src {
case srcUUID:
if !avail.UUIDOK {
return nil, false
}
args = append(args, avail.UUID)
case srcAgency:
if !avail.AgencyOK {
return nil, false
}
args = append(args, avail.Agency)
case srcNAICS:
if !avail.NAICSOK {
return nil, false
}
args = append(args, avail.NAICS)
case srcHeldClearance:
if !avail.HeldOK {
return nil, false
}
args = append(args, avail.HeldClearance)
case srcUEI:
if !avail.UEIOK {
return nil, false
}
args = append(args, avail.UEI)
case srcCompartments:
if !avail.CompartmentsOK {
return nil, false
}
args = append(args, avail.Compartments)
case srcWindowMonths:
// A stated window binds; an unstated window binds zero so the intent applies its own clamp. Either
// way the value is the deterministic extractor's, never the model's.
args = append(args, avail.WindowMonths)
case srcClampZero:
// A bounded recursion depth or lookback the question never states binds zero, the intent's own
// clamp, so the planner never invents a depth or a lookback.
args = append(args, 0)
default:
return nil, false
}
}
return args, true
}
// proposePlan asks the inference plane to SELECT the relevant intent names for a compound question. The
// prompt lists every Registry intent NAME with its one line Description and required param names, states
// which params are AVAILABLE for this question, and instructs the model to return ONLY a JSON array of
// intent names from that list, at most maxPlanNodes, no prose, no SQL, no new names. It parses the JSON
// array and DISCARDS any element that is not an exact IntentByName hit (the closed enum check). On any plane
// error or unparseable output it returns an EMPTY selection (the caller falls back to RAG), never a guess.
// With a nil client (the no plane posture) it returns empty so the planner degrades to RAG.
func (o *Orchestrator) proposePlan(ctx context.Context, question string, avail availableParams) ([]string, error) {
if o.client == nil {
return nil, nil // no plane: the planner degrades to RAG, never a guess
}
system := "You are a query PLANNER for a cleared workforce intelligence platform. You select which of a " +
"FIXED set of named, read only intents to run to answer a compound question. You author NO SQL and " +
"supply NO parameter values: you return ONLY intent names from the list below, the ones that are both " +
"relevant to the question AND bindable from the AVAILABLE parameters. Return a JSON array of intent " +
"name strings, at most " + itoa(maxPlanNodes) + ", no prose, no SQL, no names that are not in the list. " +
"If no intent is relevant and bindable, return an empty array."
prompt := "AVAILABLE PARAMETERS: " + describeAvail(avail) + "\n\nINTENTS:\n" + registryCatalog() +
"\n\nQUESTION: " + question + "\n\nReturn ONLY the JSON array of intent names."
out, err := o.client.Generate(ctx, inference.GenerateRequest{System: system, Prompt: prompt, MaxTokens: 200, Temperature: 0.1})
if err != nil {
return nil, nil // a plane error returns an empty selection so the caller falls back to RAG, never a guess
}
names := parseIntentNames(out.Text)
// The closed enum check: keep only names that are an exact registered intent, cap at maxPlanNodes. The
// model can never introduce an intent the platform did not author.
kept := make([]string, 0, len(names))
seen := map[string]bool{}
for _, n := range names {
n = strings.TrimSpace(n)
if n == "" || seen[n] {
continue
}
if _, ok := IntentByName(n); !ok {
continue // not a Registry name: dropped (the closed enum check)
}
seen[n] = true
kept = append(kept, n)
if len(kept) >= maxPlanNodes {
break
}
}
return kept, nil
}
// validatePlan turns a proposed name list into a bounded, ordered, fully bound Plan. For each registered
// name (in deterministic Registry order, then name) it looks up the intent, binds its args, and keeps the
// node ONLY if it binds. A name not in the Registry, or one whose required params are not available, never
// reaches the runner. The result is byte stable on a re run: the same proposed set yields the same ordered
// Plan.
func validatePlan(names []string, avail availableParams) Plan {
want := map[string]bool{}
for _, n := range names {
want[n] = true
}
var nodes []PlanNode
// Walk the Registry in its declared order so the surviving set is deterministically ordered, then sort
// the survivors by name as the documented tiebreaker (Registry order is already total, so the name sort is
// a stable, explicit secondary key the plan can be reasoned about by).
for _, intent := range Registry {
if !want[intent.Name] {
continue
}
args, ok := bindIntentArgs(intent, avail)
if !ok {
continue // unbindable: dropped, never reaches the runner
}
nodes = append(nodes, PlanNode{Intent: intent.Name, Args: args})
if len(nodes) >= maxPlanNodes {
break
}
}
sort.SliceStable(nodes, func(i, j int) bool { return nodes[i].Intent < nodes[j].Intent })
return Plan{Nodes: nodes}
}
// proposeFn is the proposer signature, injected so the unit tests can drive self consistency with a stub and
// no live plane. proposeStablePlan calls it k times.
type proposeFn func(ctx context.Context, question string, avail availableParams) ([]string, error)
// proposeStablePlan samples the plan k times and runs only the STABLE intent set: the nodes that appear in a
// MAJORITY of the k validated plans, in the deterministic order. If the plans disagree entirely (no majority
// node), it returns an empty plan so the caller falls back to the deterministic single intent or RAG path.
// The proposer is injected so the tests drive it with a stub and no live plane.
func proposeStablePlan(ctx context.Context, question string, avail availableParams, k int, propose proposeFn) Plan {
if k < 1 {
k = 1
}
counts := map[string]int{}
for i := 0; i < k; i++ {
names, err := propose(ctx, question, avail)
if err != nil {
continue
}
plan := validatePlan(names, avail)
// Count each node once per validated plan (the plan already deduplicates names).
for _, node := range plan.Nodes {
counts[node.Intent]++
}
}
majority := (k / 2) + 1
stable := make([]string, 0, len(counts))
for name, c := range counts {
if c >= majority {
stable = append(stable, name)
}
}
if len(stable) == 0 {
return Plan{} // no majority node: fall back to the deterministic single intent or RAG path
}
return validatePlan(stable, avail)
}
// describeAvail renders the available parameter KINDS (never the values) for the proposer prompt and is also
// the shape the audit records. It tells the model which slots can bind without ever revealing a value (a
// requirement id, an agency name, a UEI), so a value can never leak into the prompt or the audit.
func describeAvail(avail availableParams) string {
kinds := availableKinds(avail)
if len(kinds) == 0 {
return "none"
}
return strings.Join(kinds, ", ")
}
// availableKinds returns the sorted list of param KIND names the question carries (never the values). It is
// the proposer prompt's available list and the audit's available_kinds field; it holds no value and no PII.
func availableKinds(avail availableParams) []string {
var kinds []string
if avail.UUIDOK {
kinds = append(kinds, "uuid")
}
if avail.AgencyOK {
kinds = append(kinds, "agency")
}
if avail.NAICSOK {
kinds = append(kinds, "naics")
}
if avail.HeldOK {
kinds = append(kinds, "held_clearance")
}
if avail.UEIOK {
kinds = append(kinds, "uei")
}
if avail.CompartmentsOK {
kinds = append(kinds, "compartments")
}
if avail.WindowMonths > 0 {
kinds = append(kinds, "window_months")
}
sort.Strings(kinds)
return kinds
}
// registryCatalog lists every Registry intent NAME, its one line Description, and its required param names
// for the proposer prompt, in Registry order. The model sees only names, descriptions, and param names, the
// closed menu it selects from; it never sees SQL.
func registryCatalog() string {
var b strings.Builder
for _, it := range Registry {
b.WriteString("- ")
b.WriteString(it.Name)
if len(it.Params) > 0 {
names := make([]string, len(it.Params))
for i, p := range it.Params {
names[i] = p.Name
}
b.WriteString(" (params: ")
b.WriteString(strings.Join(names, ", "))
b.WriteString(")")
} else {
b.WriteString(" (no params)")
}
b.WriteString(": ")
b.WriteString(it.Description)
b.WriteString("\n")
}
return b.String()
}
// parseIntentNames parses the model's output into a list of candidate intent names. It accepts a bare JSON
// array of strings (the instructed shape) and tolerates surrounding prose by extracting the first bracketed
// array. On any parse failure it returns nil, so proposePlan returns an empty selection and the caller falls
// back to RAG.
func parseIntentNames(text string) []string {
text = strings.TrimSpace(text)
start := strings.IndexByte(text, '[')
end := strings.LastIndexByte(text, ']')
if start < 0 || end < 0 || end <= start {
return nil
}
var names []string
if err := json.Unmarshal([]byte(text[start:end+1]), &names); err != nil {
return nil
}
return names
}
// itoa is a tiny no dependency integer to string for the prompt; strconv would do but this keeps the prompt
// builder self contained and avoids an import for one cap value.
func itoa(n int) string {
if n == 0 {
return "0"
}
neg := n < 0
if neg {
n = -n
}
var buf [20]byte
i := len(buf)
for n > 0 {
i--
buf[i] = byte('0' + n%10)
n /= 10
}
if neg {
i--
buf[i] = '-'
}
return string(buf[i:])
}
// runPlan executes each surviving node through its CORRECT existing path, collects the union of rendered
// evidence, and returns the union, the suppressed cell count across bench nodes, and the ordered names that
// actually ran. A bench intent runs through the suppressing executor (runBenchIntent) rendered with
// RenderCount, so a suppressed cell is the sentinel and never a raw sub k number; the G6 capture composition
// runs through answerCapturePicture; every other graph or TAG intent runs through ExecuteIntent rendered
// with renderTable. A node that errors is dropped from the union with a logged note (the plan degrades,
// never hangs). The planner adds no new query: every node is an existing guarded path.
func (o *Orchestrator) runPlan(ctx context.Context, plan Plan) (evidence []string, suppressed int, ran []string, err error) {
k := ConfiguredSuppressionThreshold()
for _, node := range plan.Nodes {
switch {
case node.Intent == IntentCapturePictureForRequirement:
// The G6 capture composition is itself a person touching bench intent and already composed. Run it
// through its existing path and fold its deterministic answer into the union as one evidence line.
id, idErr := benchIDArg(node.Args)
if idErr != nil {
continue // a malformed id drops the node, the plan degrades
}
resp, cErr := o.answerCapturePicture(ctx, id)
if cErr != nil || resp == nil || resp.Refused {
continue
}
evidence = append(evidence, "capture_picture_for_requirement: "+resp.Answer)
ran = append(ran, node.Intent)
case IsBenchIntent(node.Intent):
// A bench intent runs through the suppressing executor. The bench plane reads RLS protected person
// data, so it REQUIRES a resolved authenticated tenant context (the same gate answerBench enforces);
// without one the node drops rather than relying only on the RLS empty result fail safe. Every cell
// renders through RenderCount, so a suppressed cell is the sentinel and no sub k number reaches the
// union.
if !guardTenantForBench(ctx) {
continue
}
labels, cells, bErr := o.runBenchIntent(ctx, node.Intent, node.Args, k)
if bErr != nil {
continue // the node drops, the plan degrades, never hangs
}
columns := []string{"bucket", "headcount"}
rows := make([][]any, len(cells))
for i, c := range cells {
if c.Suppressed {
suppressed++
}
rows[i] = []any{labels[i], RenderCount(c)}
}
evidence = append(evidence, node.Intent+": "+renderTable(node.Intent, columns, rows))
ran = append(ran, node.Intent)
default:
// Every other graph or TAG intent runs through ExecuteIntent (re guarded, RLS scoped) and renders
// with the existing deterministic table rendering.
intent, ok := IntentByName(node.Intent)
if !ok {
continue
}
res, eErr := ExecuteIntent(ctx, o.db, intent, node.Args)
if eErr != nil {
continue // the node drops, the plan degrades, never hangs
}
evidence = append(evidence, node.Intent+": "+renderTable(intent.Description, res.Columns, res.Rows))
ran = append(ran, node.Intent)
}
}
return evidence, suppressed, ran, nil
}
// AnswerPlan answers a COMPOUND cross plane question by planning over the closed Registry. It runs the EEO
// and protected attribute guards FIRST (refuse on either), extracts the available params, gets the stable
// plan (sampled planSelfConsistencyK times, only the majority intent set), and falls back to AnswerRAG
// unchanged when the plan is empty (so a non compound or unbindable question is byte for byte unchanged).
// Otherwise it runs the plan, narrates ONE answer over the union of node evidence through o.generate (state
// only what the union supports, report a suppressed bench count as withheld and never guess a number for it,
// never compute a match score), and o.finalize behind the faithfulness gate. It emits the counts only
// aedt.ai.plan_answered audit: the ordered ran intent NAMES, the available param KINDS (never values), the
// node count, the suppressed count, the faithfulness verdict, and the self consistency outcome. The audit
// carries NO param value, NO candidate id, NO raw count, NO requirement id, NO answer text, and NO question.
func (o *Orchestrator) AnswerPlan(ctx context.Context, question string) (*Response, error) {
if ForbidEEO(question) != nil || MentionsProtectedAttribute(question) {
return o.refuse(ctx, RoutePlan, "the question references prohibited demographic data"), nil
}
avail := extractAvailableParams(question)
plan := proposeStablePlan(ctx, question, avail, planSelfConsistencyK, o.proposePlan)
consistency := "stable"
if len(plan.Nodes) == 0 {
// No stable plan: a non compound or unbindable question is unchanged, it falls back to RAG.
return o.AnswerRAG(ctx, question, 8)
}
evidence, suppressed, ran, err := o.runPlan(ctx, plan)
if err != nil {
return nil, err
}
if len(evidence) == 0 {
// Every node dropped (errors or empty results): degrade to RAG rather than narrate over nothing.
return o.AnswerRAG(ctx, question, 8)
}
deterministic := renderPlanUnion(ran, evidence)
model, genErr := o.generate(ctx,
"State ONE integrated answer using ONLY the listed evidence lines as evidence, one line per intent that ran. "+
"State only what the union of the evidence supports. 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 and never assign a band. Do not invent an agency, a UEI, a dollar figure, a "+
"recompete, or a count the evidence does not contain.",
"QUESTION: "+question+"\n\nEVIDENCE:\n"+strings.Join(evidence, "\n"))
resp := o.finalize(ctx, RoutePlan, model, genErr, deterministic, evidence)
// Counts only plan trace audit: the ordered ran names (the closed set, not PII), the available param KINDS
// (not values), the node count, the suppressed cell count, the faithfulness verdict, and the self
// consistency outcome. NO param value, NO candidate id, NO raw count, NO requirement id, NO answer text,
// NO question.
o.audit(ctx, "aedt.ai.plan_answered", map[string]any{
"route": RoutePlan,
"intents": ran,
"available_kinds": availableKinds(avail),
"nodes": len(plan.Nodes),
"suppressed": suppressed,
"faithful": resp.Faithful,
"rationale_source": resp.RationaleSource,
"self_consistency": consistency,
})
return resp, nil
}
// renderPlanUnion builds the deterministic union answer the planner ships when the model prose is withheld:
// one line per intent that ran, in the deterministic plan order, each line already its node's suppressed
// safe rendering. It names no candidate and computes no score; every bench count it contains already came
// through RenderCount, so the deterministic fallback is itself suppression safe.
func renderPlanUnion(ran []string, evidence []string) string {
var b strings.Builder
b.WriteString("Integrated plan answer across the planes. ")
b.WriteString(strings.Join(evidence, " "))
return b.String()
}
// benchPlaneWords are the conservative bench plane signal: a question that names one of these is asking about
// the caller's own bench (the suppressed person touching plane). Pairing a bench word with a market word is
// the compound signal that routes to the planner.
var benchPlaneWords = []string{"bench", "our staff", "our people", "who can staff", "clearance coverage", "structural gap"}
// marketPlaneWords are the conservative market plane signal: a question that names one of these is asking
// about the public market plane (agencies, awards, incumbents, recompetes, competitors, forecasts).
var marketPlaneWords = []string{"market", "incumbent", "competitor", "recompete", "award", "agency", "forecast", "vendor", "teaming", "buying pattern", "spend"}
// isCompoundQuestion is the CONSERVATIVE compound signal: a question is compound when it names a bench plane
// word AND a market plane word (it spans two planes), so the single shot keyword router cannot answer it with
// one intent. It is deliberately narrow: when unsure it returns false so the question falls through to the
// existing single shot path and a working route is never regressed. It never refuses; the EEO guards run in
// AnswerPlan.
func isCompoundQuestion(question string) bool {
q := strings.ToLower(question)
return containsAny(q, benchPlaneWords) && containsAny(q, marketPlaneWords)
}
// guardTenantForBench reports whether the context carries a resolved authenticated tenant, the gate a bench
// node needs before it runs a person touching read. The runner drops a bench node without one so the plan
// path keeps the same auth posture answerBench enforces, rather than relying only on the RLS empty result
// fail safe. The G6 capture composition checks the same gate inside answerCapturePicture.
func guardTenantForBench(ctx context.Context) bool {
_, ok := tenant.OrgFromContext(ctx)
return ok
}