router: classify_model heuristic + 31-case corpus (Phase 5 commit #1)
Phase 5 commit #1 per docs/PHASE5.md §11. Pure-Lua per-request model routing — no IO, no LLM probe in v1. router.classify_model(text, cfg) -> (model_name | nil, class_label): 1. classify_class(text) walks heuristics in priority order: code class: - triple-backtick fence anywhere - "traceback" / "stacktrace" / "stack trace" (ci) - "error:" / "exception:" in first 60 chars (ci) - path-with-code-extension token (.py/.lua/.c/.js/.go/.rs/.cpp/.h/.ts) - 5+ lines with indented content (looks like a paste) reasoning class (requires text >= 15 chars to skip bare keywords): - "explain" / "why " / "how does" / "compare" (ci) - "?" + length > 100 chars default class: everything else 2. Map class via cfg.routing.classes[class] → model name (or nil = keep current). 3. Return (model_name_or_nil, class_label). ALWAYS evaluates regardless of cfg.routing.auto — caller (repl.ask_ai in commit #3) gates on the flag. This separation lets `:route check` introspect the heuristic even when routing is off (N1). M._classify_class exposed for testing. Test corpus (test_router_model.lua, 31 cases): - 13 code-class positives (fence, traceback, paths, multi-line paste) - 6 reasoning-class positives (explain/why/how does/compare/?+length) - 8 default-class (short queries, bare keywords below 15-char threshold, non-code paths like .md/.txt) - 3 model-mapping cases (code→"deep", reasoning→"cloud", default→nil) - 1 R-N2 default test: classes.reasoning=nil → reasoning text yields nil model override (heuristic still fires, no swap) - All 31 pass; 15-char threshold catches "how does ASLR work?" without false-positive on bare "explain". Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+81
-8
@@ -1,12 +1,9 @@
|
||||
-- router.lua — task classifier: meta / shell / AI.
|
||||
-- See docs/PHASE0.md §5.
|
||||
-- router.lua — task classifier: meta / shell / AI / model-routing.
|
||||
-- See docs/PHASE0.md §5 and docs/PHASE5.md §4 for Phase 5 additions.
|
||||
--
|
||||
-- Pure function. Takes (line, config). Returns (kind, payload).
|
||||
-- kind : "meta" | "shell" | "ai"
|
||||
-- payload: the (possibly stripped) line that the dispatcher should act on.
|
||||
--
|
||||
-- Empty / whitespace-only lines are returned as ("ai", ""); the repl loop
|
||||
-- skips them before dispatching.
|
||||
-- M.classify(line, config) → (kind, payload) for input dispatch (Phase 0).
|
||||
-- M.classify_model(text, cfg) → name | nil for per-request model routing
|
||||
-- (Phase 5; pure-Lua heuristics, no IO).
|
||||
|
||||
local M = {}
|
||||
|
||||
@@ -63,4 +60,80 @@ function M.classify(line, config)
|
||||
return "ai", line
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------- classify_model
|
||||
-- Phase 5: per-request model routing heuristic. Pure-Lua, no IO.
|
||||
-- Returns the NAME of a model preset (string) to switch to for this
|
||||
-- request, or nil to keep the active model unchanged.
|
||||
--
|
||||
-- The mapping from class to model name lives in `cfg.routing.classes`.
|
||||
-- A class with value `nil` means "keep current" — even though the
|
||||
-- heuristic fires, no override happens (used by default for the
|
||||
-- `reasoning` class per R-N2 cost-safety policy).
|
||||
--
|
||||
-- This function ALWAYS evaluates the heuristic regardless of
|
||||
-- `cfg.routing.auto` — the caller (repl.ask_ai) gates on the flag.
|
||||
-- This separation lets `:route check <text>` introspect the heuristic
|
||||
-- even when routing is disabled (N1).
|
||||
|
||||
local function classify_class(text)
|
||||
if not text or text == "" then return "default" end
|
||||
|
||||
-- ── Code class — looks like a paste or contains code markers
|
||||
if text:find("```", 1, true) then return "code" end
|
||||
local lower = text:lower()
|
||||
if lower:find("traceback", 1, true)
|
||||
or lower:find("stacktrace", 1, true)
|
||||
or lower:find("stack trace", 1, true) then
|
||||
return "code"
|
||||
end
|
||||
-- exception/error markers near beginning (first 60 chars)
|
||||
if lower:sub(1, 60):find("error:", 1, true)
|
||||
or lower:sub(1, 60):find("exception:", 1, true) then
|
||||
return "code"
|
||||
end
|
||||
-- path with code-extension token
|
||||
if text:match("[%./~][%w%-_/.]+%.([%w]+)") then
|
||||
local ext = text:match("[%./~][%w%-_/.]+%.([%w]+)")
|
||||
if ext == "py" or ext == "lua" or ext == "c"
|
||||
or ext == "js" or ext == "go" or ext == "rs"
|
||||
or ext == "cpp" or ext == "h" or ext == "ts" then
|
||||
return "code"
|
||||
end
|
||||
end
|
||||
-- multi-line + indented (looks like a code paste)
|
||||
local nlines = 0
|
||||
for _ in (text .. "\n"):gmatch("[^\n]*\n") do nlines = nlines + 1 end
|
||||
if nlines > 4 and text:find("\n%s+%S") then return "code" end
|
||||
|
||||
-- ── Reasoning class
|
||||
-- Min length 15 — catches "how does X work" but excludes bare "why" / "explain"
|
||||
if #text >= 15 then
|
||||
if lower:find("explain", 1, true)
|
||||
or lower:find("why ", 1, true) -- trailing space (not "whyever")
|
||||
or lower:find("how does", 1, true)
|
||||
or lower:find("compare", 1, true) then
|
||||
return "reasoning"
|
||||
end
|
||||
end
|
||||
if text:find("?", 1, true) and #text > 100 then
|
||||
return "reasoning"
|
||||
end
|
||||
|
||||
return "default"
|
||||
end
|
||||
|
||||
-- Public API.
|
||||
function M.classify_model(text, cfg)
|
||||
local class = classify_class(text)
|
||||
local classes = (cfg and cfg.routing and cfg.routing.classes) or {}
|
||||
local target = classes[class]
|
||||
-- nil target = keep current (this is the R-N2 default for "reasoning")
|
||||
if target == nil then return nil, class end
|
||||
-- Caller may want the class label for the status line; return both.
|
||||
return target, class
|
||||
end
|
||||
|
||||
-- Exposed for `:route check` introspection (N1).
|
||||
M._classify_class = classify_class
|
||||
|
||||
return M
|
||||
|
||||
Reference in New Issue
Block a user