// Package routedecide is the PURE, importable core of the AI-bot router: the free // Layer-0 regex pre-classification and the Layer-0+classifier combine. It holds no // I/O, no vendor clients, no Bot/Config — only the decision math — so two callers can // share exactly one decision function: // // - package main (router.go) parses the live Gemini classifier JSON into a Verdict, // then calls Combine to resolve the route; // - cmd/routereval replays a golden set of recorded Verdicts through the same // ClassifyLayer0 + Combine to measure misroute / false-web / trivial-leak offline. // // Go forbids importing package main, so this core had to live in its own package for // the offline harness to exercise the REAL routing logic instead of a drift-prone copy. package routedecide import ( "regexp" "strings" ) // Route names — the canonical wire/log/request_log tokens. package main aliases these // (telemetry.go) so there is a single source of truth for the strings. const ( RouteTrivial = "trivial_direct" RouteGrokDirect = "grok_direct" RouteWeb = "web_then_grok" RouteReason = "reason_then_grok" // RouteProject answers a question about the Vojo product itself from a curated KB // injected into the Grok prompt (about_project gate). Like RouteWeb it grounds Grok, // but the "digest" is an operator-authored static KB, not a web fetch. RouteProject = "project_then_grok" ) // Confidence floors the combine uses. These are the values the offline eval (§11) // tunes; keeping them here lets cmd/routereval sweep them without touching main. // // - WebNeedsWebFloor: a classifier needs_web verdict must clear this to route to web // (paranoid-low — grounding is cheap, a confident wrong fact is not). // - TrivialFloor: the bar a trivial offload must clear (conservative — a false trivial // leaks a real question to the cheap model). const ( WebNeedsWebFloor = 0.55 TrivialFloor = 0.85 ) // Floors are the two confidence thresholds Combine applies, parameterised so the offline // eval (cmd/routereval) can SWEEP them over a golden set without recompiling. Production // uses DefaultFloors (the consts above). type Floors struct { WebNeedsWeb float64 Trivial float64 } // DefaultFloors is the production threshold set. func DefaultFloors() Floors { return Floors{WebNeedsWeb: WebNeedsWebFloor, Trivial: TrivialFloor} } // web_decided_by attribution tokens (request_log.web_decided_by). Stable so analytics // can GROUP BY them and tune WebNeedsWebFloor from data. const ( WebByNone = "none" WebByFreshness = "freshness" WebByNeedsWeb = "classifier_needs_web" WebByObscure = "entity_obscure" WebByTime = "time_sensitive" WebByLookupHint = "lookup_hint" ) // Verdict is the classifier's parsed JSON output (§4.1). The json tags match the // classifier schema exactly, so both routeLayer1 (live classifier reply) and // cmd/routereval (recorded golden verdicts) unmarshal straight into it. Confidence is // the model's honest certainty in needs_web; it doubles as the trivial-gate threshold // (a clear greeting is high-certainty-no-web, so the gate passes). type Verdict struct { NeedsWeb bool `json:"needs_web"` Verifiable bool `json:"verifiable"` EntityObscure bool `json:"entity_obscure"` TimeSensitive bool `json:"time_sensitive"` Trivial bool `json:"trivial"` SearchQuery string `json:"search_query"` Confidence float64 `json:"confidence"` // AboutProject is true when the user is asking about the Vojo product itself (its // features/how-to/limits/privacy/pricing). It routes to the project KB on its own — the // classifier is the context-aware judge (it sees the conversation, so it resolves // follow-ups like "Про этот" → the app) and a false positive is bounded by the // entity-scoped KB note. (An earlier design also required a Layer-0 lexical hint, but live // traffic showed that blocked correct context-resolved follow-ups — see Combine.) AboutProject bool `json:"about_project"` } // Layer0 is the free-regex pre-classification result. Route is the verdict when the // classifier is OFF; WebForce/Trivial/LookupHint feed the Combine when it is ON. type Layer0 struct { Route string // RouteWeb (freshness) | RouteTrivial | RouteGrokDirect WebForce bool // freshnessRe hit — a HARD web signal (survives the classifier being down) Trivial bool // a trivial candidate (greeting/ack/bare arithmetic) LookupHint bool // lookupIntentRe hit — a SOFT hint only (never sets the route) Freshness string // "recent" when WebForce, else "" } // Heuristic patterns. Kept deliberately tight. Freshness words route to web (a false // web-route only costs a fetch and degrades cleanly). Trivial fires only on short, // unmistakable greetings/acks or bare arithmetic. var ( greetingRe = regexp.MustCompile(`^(привет(ик)?|здравствуй(те)?|хай|прив|ку|добрый\s+(день|вечер|утро)|спасибо|спс|благодарю|пока|ок(ей)?|угу|ага|hello|hi|hey|yo|thanks|thank\s+you|thx|ty|bye|goodbye|ok|okay|cool|nice)[\s!.,)]*$`) arithmeticRe = regexp.MustCompile(`^[\s(]*\d+(\s*[-+*/×÷]\s*\d+)+[\s)=?]*$`) // Russian tokens are deliberately STEM matches (новост→новости/новостей, погод→погода…) // so they stay un-anchored. English standalone tokens are \b-anchored so they fire on // whole words only — not inside scoreboard / concurrent / weathering / newsletter (a // pre-existing false-web source; \b removes that pointless grounding spend). RE2's \b is // ASCII-word-based, so it is used only around the ASCII tokens, never the Cyrillic stems. freshnessRe = regexp.MustCompile(`(новост|сегодня|сейчас|последн|курс\s|погод|котировк|расписани|прогноз|\bbreaking\b|\btoday\b|\bright now\b|\blatest\b|\bcurrent(ly)?\b|\bnews\b|\bweather\b|\bstock price\b|\bexchange rate\b|\bscore\b)`) // lookupIntentRe — SOFT HINT ONLY (§5): raises the classifier's needs_web prior via // the lookupHint && verifiable arm; must NEVER set the route. Anchored on // interrogative + lookup-verb so it fires on lookup INTENT, not entity presence. // Deliberately leaky (false negatives are caught by the classifier, the real safety // net). Do NOT add a capitalised-word or guillemet branch — those false-positive on // greetings/idioms ("Привет, Москва!", "«Война и мир» — топ", "ну ты прям Эйнштейн"). // The leading [\s«"„(] class is only an OPTIONAL left boundary, never a trigger. lookupIntentRe_RU = regexp.MustCompile(`(?i)(^|[\s«"„(])(кто\s+(так(ой|ая|ие)|снимал(ся|ась|ись)|играл|написал|основал|изобрёл|изобрел|режисс[её]р|автор)|в\s+как(ом|ой)\s+(год[уе]|фильм[еа]|сериал[еа]|книг[еи]|игр[еы])|когда\s+(вышел|вышла|вышло|выйдет|основан[аы]?|родил(ся|ась)|умер(ла)?|состоял(ся|ась)|был[аои]?\s+выпущен)|в\s+каком\s+году|сколько\s+(лет|стоит\s+бил|серий|сезонов|эпизодов)|чем\s+(закончил|известен|знаменит))`) lookupIntentRe_EN = regexp.MustCompile(`(?i)(^|[\s"'(])(who\s+(is|are|was|were|starred|played|directed|wrote|founded|invented|created)\s|in\s+(what|which)\s+(year|film|movie|show|series|book|game)\b|when\s+(did|was|were|does|is)\b.*\b(release|released|come\s+out|came\s+out|born|die|died|found|founded|launch|launched|air|aired)\b|what\s+year\b|how\s+many\s+(seasons|episodes|films|movies|books))`) // recommendationRe — a recommendation/advice request ("посоветуй фильм", "что посмотреть", // "what to watch"). Used ONLY to suppress the freshness WebForce (see ClassifyLayer0): such // requests are answered from the model's own taste/knowledge, and force-routing them to web // is actively harmful — the web synth ("answer strictly from the digest") makes Grok parrot a // generic SEO listicle and recommend nothing (observed live: "посоветуй фильм … в этот вечер" // → a "домашний спа/почитать книгу" non-answer). Kept tight: only explicit recommend/advice // verbs and "что/чем/во что/куда + activity", never bare interrogatives, so it can't swallow a // genuine fresh lookup. Cyrillic stems unanchored (lowercased input), English \b-anchored. recommendationRe = regexp.MustCompile(`(посовету|порекоменд|что\s+(посмотреть|глянуть|почитать|приготовить|послушать|подарить|поиграть)|чем\s+(себя\s+)?заня|во\s+что\s+(поиграть|сыграть)|куда\s+(сходить|пойти)|\brecommend|\bsuggest|what\s+(to|should\s+i)\s+(watch|read|cook|do|play|listen|make|see)|what\s+(movie|film|book|show|series|game)s?\s+(to|should|do\s+you))`) ) // NOTE: the project route used to require a Layer-0 lexical hint (literal "vojo" / an // app-how-to phrase) AND the classifier's about_project. Live traffic showed that gate was // too strict: the classifier correctly flagged context-resolved follow-ups ("Про этот", // "Хочу репортнуть багу. Как?") as about_project=true, but the regex — which only sees the // bare message and cannot resolve a pronoun to "the Vojo app" — missed them, so the KB never // fired and Grok hallucinated (a dismissive "ничего особенного", an invented GitHub support // channel). The classifier is the context-aware layer and is the right judge here, and a // false positive is cheap (the entity-scoped KB note keeps Grok answering the real question). // So the route now trusts about_project alone; the regex hint was removed (it saved no money — // about_project is one field in the classifier JSON that runs on every message regardless). // ClassifyLayer0 runs the free heuristic over a message body. The result drives routing // only when the classifier is off; when it is on, WebForce/Trivial/LookupHint feed // Combine. Empty body → grok_direct (the safe floor). func ClassifyLayer0(body string) Layer0 { s := strings.ToLower(strings.TrimSpace(body)) if s == "" { return Layer0{Route: RouteGrokDirect} } lookupHint := lookupIntentRe_RU.MatchString(s) || lookupIntentRe_EN.MatchString(s) // Freshness forces web — EXCEPT for a recommendation/advice request that merely happens to // carry a freshness lexeme ("посоветуй фильм … сегодня вечером"). Those are answered from the // model's own knowledge; force-routing them to web makes the synth parrot an SEO listicle and // recommend nothing (see recommendationRe). They fall through to the classifier, which keeps // them on grok_direct and still sends genuine "новинки"/"latest" recommendations to web via // time_sensitive. A non-recommendation freshness rumination ("сегодня я думаю…") still // force-routes — the accepted, designed cheap false-web. if freshnessRe.MatchString(s) && !recommendationRe.MatchString(s) { return Layer0{Route: RouteWeb, WebForce: true, Freshness: "recent", LookupHint: lookupHint} } if IsTrivial(s) { return Layer0{Route: RouteTrivial, Trivial: true, LookupHint: lookupHint} } return Layer0{Route: RouteGrokDirect, LookupHint: lookupHint} } // IsTrivial: a short greeting/ack or a bare arithmetic expression, with no sign of a // real question. Length-bounded so "thanks, now explain quantum tunnelling" is NOT // trivial. Expects an already-lowercased/trimmed string from ClassifyLayer0; callers // passing raw input should lower/trim first (the greeting regex is lowercase-anchored). func IsTrivial(s string) bool { if arithmeticRe.MatchString(s) { return true } if len(strings.Fields(s)) <= 4 && greetingRe.MatchString(s) { return true } return false } // Combined is the resolved route plus its web attribution (for request_log). type Combined struct { Route string WebDecidedBy string } // Combine resolves the Layer-0 decision + the classifier Verdict into the final route. // It is the router's brain and it never blindly trusts the model: // // - the PROJECT arm (the classifier's AboutProject) wins above everything, including the // hard freshness arm — the curated KB is the authoritative source for product facts and // the web is the worst (it would re-introduce product hallucination). It trusts the // classifier: about_project is a context-aware judgement (it sees the conversation, so it // resolves follow-ups like "Про этот" → the app) that a bare-message regex cannot make. A // false positive is cheap — the entity-scoped KB note keeps Grok answering the real // question. Combine stays flag-agnostic: it EMITS RouteProject on AboutProject; the cascade // gates EXECUTION on PROJECT_KB_ENABLED (mirroring how WebEnabled gates the web route), so // with the flag off a RouteProject decision cleanly falls through to grok_direct. // - freshnessRe (WebForce) is a HARD web signal, always honoured (it survives the // classifier being down). The ONE carve-out is applied upstream in ClassifyLayer0: // a recommendation/advice request ("посоветуй фильм … сегодня") does NOT set WebForce, // because force-routing a recommendation to web makes the synth parrot an SEO listicle. // - Every OTHER web arm (the classifier's needs_web≥floor AND verifiable, // entity_obscure, time_sensitive, lookupHint && verifiable) is gated by `paranoid` // (WEB_PARANOID). The needs_web arm additionally requires `verifiable`: on a small // flash-lite classifier, `needs_web` over-fires on open-ended advice/explanations // (observed live: "посоветуй фильм", "объясни goroutines" → needs_web=true, // verifiable=false → a false-web). `verifiable` ("a checkable fact about a NAMED // entity") is the reliable discriminator; recency still routes via time_sensitive/ // freshness and obscurity via entity_obscure, so no genuine grounding is lost. // With paranoid off, web routing equals today's freshness-only behavior — so // enabling the classifier is web-routing-neutral and WEB_PARANOID is the single // switch that activates epistemic grounding (clean canary; cost increase behind it). // - trivial is agreement-gated: a Layer-0 trivial candidate AND classifier.trivial AND // confidence ≥ TrivialFloor. A lone signal stays on grok_direct (no voice leak). // - everything else falls to grok_direct (the safe floor: opinion/chat/advice/code). // // The switch ORDER determines web_decided_by attribution; the boolean result is the OR. func Combine(l0 Layer0, v Verdict, paranoid bool) Combined { return CombineWithFloors(l0, v, paranoid, DefaultFloors()) } // CombineWithFloors is Combine with explicit thresholds (the offline-eval sweep entry). func CombineWithFloors(l0 Layer0, v Verdict, paranoid bool, f Floors) Combined { switch { case v.AboutProject: return Combined{Route: RouteProject, WebDecidedBy: WebByNone} case l0.WebForce: return Combined{Route: RouteWeb, WebDecidedBy: WebByFreshness} case paranoid && v.NeedsWeb && v.Verifiable && v.Confidence >= f.WebNeedsWeb: return Combined{Route: RouteWeb, WebDecidedBy: WebByNeedsWeb} case paranoid && v.EntityObscure: return Combined{Route: RouteWeb, WebDecidedBy: WebByObscure} case paranoid && v.TimeSensitive: return Combined{Route: RouteWeb, WebDecidedBy: WebByTime} case paranoid && l0.LookupHint && v.Verifiable: return Combined{Route: RouteWeb, WebDecidedBy: WebByLookupHint} } if l0.Trivial && v.Trivial && v.Confidence >= f.Trivial { return Combined{Route: RouteTrivial, WebDecidedBy: WebByNone} } return Combined{Route: RouteGrokDirect, WebDecidedBy: WebByNone} }