// 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" ) // 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"` } // 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))`) ) // 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) if freshnessRe.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: // // - freshnessRe (WebForce) is a HARD web signal, always honoured (it survives the // classifier being down). // - 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 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} }