245 lines
16 KiB
Go
245 lines
16 KiB
Go
// 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}
|
||
}
|