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

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