193 lines
11 KiB
Go
193 lines
11 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"
|
||
)
|
||
|
||
// 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}
|
||
}
|