vojo/apps/ai-bot/internal/routedecide/routedecide.go

245 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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}
}