5fe8f228c1
Add a read-only /ecosystem page that consumes the sibling services' JSON APIs (Nexus entities, Praxis attention, Hexis capabilities), fetched concurrently with honest per-panel error states. Siblings stay headless — mavweb is their human surface (arch §16). Wired via mavweb -nexus/-praxis/-hexis flags; mavweb joins the ecosystem compose network. Fix mobile horizontal overflow across all pages: .content is a flex child with default min-width:auto, so it refused to shrink below the tables' intrinsic width. min-width:0 lets wide tables pan inside .scroll instead of dragging the page sideways. Verified via CDP geometry check (scrollWidth === clientWidth at 430px). Also includes in-progress Ethos UI redesign, ecosystem deploy compose, and planning docs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
140 lines
5.9 KiB
Go
140 lines
5.9 KiB
Go
package router
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/kami/maven/internal/llm"
|
|
)
|
|
|
|
// Completer — the LLM seam (mockable). *llm.Client satisfies it.
|
|
type Completer interface {
|
|
Complete(ctx context.Context, r llm.Req) (string, error)
|
|
}
|
|
|
|
// LLMRouter — the agentic router. One grammar-constrained call classifies the
|
|
// utterance and pulls raw slots; deterministic parsers (time) refine downstream.
|
|
type LLMRouter struct{ c Completer }
|
|
|
|
func NewLLMRouter(c Completer) *LLMRouter { return &LLMRouter{c: c} }
|
|
|
|
// routeGrammar — GBNF constraining the model to a JSON ARRAY of fixed-shape
|
|
// action objects (one per ask; compound utterances → multiple). Enum + key set
|
|
// prevent free-form drift from a sub-1B model.
|
|
const routeGrammar = `
|
|
root ::= "[" ws action ("," ws action)* ws "]"
|
|
action ::= "{" ws "\"intent\"" ws ":" ws intent ("," ws field)* ws "}"
|
|
intent ::= "\"fact\"" | "\"reminder\"" | "\"note\"" | "\"query\"" | "\"act\"" | "\"chat\"" | "\"system\""
|
|
field ::= key ws ":" ws string
|
|
key ::= "\"key\"" | "\"value\"" | "\"text\"" | "\"verb\""
|
|
string ::= "\"" ([^"\\] | "\\" .)* "\""
|
|
ws ::= [ \t\n]*
|
|
`
|
|
|
|
const routeSystem = `Классифицируй ровно одно сообщение пользователя. Верни ОДИН JSON-массив действий.
|
|
|
|
Ровно одно намерение: fact, reminder, note, query, act, chat, system.
|
|
|
|
Классифицируй по цели пользователя. Порядок решения:
|
|
1. Хочет напоминание в будущем → reminder
|
|
2. Явно просит сохранить информацию → note
|
|
3. Сообщает или обновляет текущее состояние/событие → fact
|
|
4. Хочет получить информацию → query
|
|
5. Просит выполнить работу → act
|
|
6. Про ассистента, настройки или память → system
|
|
7. Иначе → chat
|
|
|
|
Различия:
|
|
- note — сохранить информацию, без напоминания. text = суть.
|
|
- reminder — уведомить позже. text = что напомнить.
|
|
- fact — неявное обновление: пользователь сообщает, что что-то в мире изменилось (текущее/изменённое состояние, случившееся событие). key/value.
|
|
|
|
Примеры:
|
|
"запиши пароль" → {"intent":"note","text":"пароль"}
|
|
"напомни купить молоко" → {"intent":"reminder","text":"купить молоко"}
|
|
"запиши купить молоко" → {"intent":"note","text":"купить молоко"}
|
|
"я выпил воду" → {"intent":"fact","key":"water","value":"выпил"}
|
|
"мой любимый фильм — Интерстеллар" → {"intent":"note","text":"любимый фильм — Интерстеллар"}
|
|
"что такое docker?" → {"intent":"query","text":"что такое docker"}
|
|
"напиши письмо" → {"intent":"act","verb":"написать письмо"}
|
|
"очисти память" → {"intent":"system"}
|
|
"привет" → {"intent":"chat","text":"привет"}
|
|
|
|
Ответ — JSON-массив: по одному объекту на каждую просьбу. Обычно один. Если в реплике несколько просьб — по объекту на каждую. "напомни купить молоко, и запиши что кофе кончился" → [{"intent":"reminder","text":"купить молоко"},{"intent":"note","text":"кофе кончился"}]. Только JSON, без пояснений.`
|
|
|
|
type routeAction struct {
|
|
Intent string `json:"intent"`
|
|
Key string `json:"key"`
|
|
Value string `json:"value"`
|
|
Text string `json:"text"`
|
|
Verb string `json:"verb"`
|
|
}
|
|
|
|
func (lr *LLMRouter) Route(ctx context.Context, utterance string, now time.Time) (Decision, bool, error) {
|
|
raw, err := lr.c.Complete(ctx, llm.Req{System: routeSystem, User: utterance, Grammar: routeGrammar, MaxTokens: 128})
|
|
if err != nil {
|
|
return Decision{}, false, err
|
|
}
|
|
acts, err := parseActions(strings.TrimSpace(raw))
|
|
if err != nil {
|
|
return Decision{}, false, fmt.Errorf("llmrouter: parse %q: %w", raw, err)
|
|
}
|
|
if len(acts) == 0 {
|
|
return Decision{}, false, fmt.Errorf("llmrouter: empty action list %q", raw)
|
|
}
|
|
// ponytail: contract is an array (compound utterances → N actions), but the
|
|
// Router cascade still returns one Decision. Dispatch of all actions lands
|
|
// with the engine turn-on (Router.Route → []Decision, both voice.go handlers
|
|
// loop). Until then only the first ask is honored.
|
|
a := acts[0]
|
|
d := Decision{Utterance: utterance, Stage: 1, Confidence: 1.0}
|
|
switch Intent(a.Intent) {
|
|
case IntentFact:
|
|
d.Intent = IntentFact
|
|
d.Slots.Key, d.Slots.Value = a.Key, a.Value
|
|
d.Slots.HasKey = a.Key != ""
|
|
case IntentReminder:
|
|
d.Intent = IntentReminder
|
|
d.Slots.Text = firstNonEmpty(a.Text, utterance)
|
|
case IntentNote:
|
|
d.Intent = IntentNote
|
|
d.Slots.Text = firstNonEmpty(a.Text, utterance)
|
|
case IntentQuery:
|
|
d.Intent = IntentQuery
|
|
d.Slots.Text = firstNonEmpty(a.Text, utterance)
|
|
case IntentAct:
|
|
d.Intent = IntentAct
|
|
d.Slots.Text = firstNonEmpty(a.Verb, utterance)
|
|
case IntentSystem:
|
|
d.Intent = IntentSystem
|
|
default:
|
|
d.Intent = IntentChat
|
|
d.Slots.Text = firstNonEmpty(a.Text, utterance)
|
|
}
|
|
return d, true, nil
|
|
}
|
|
|
|
// parseActions accepts the array contract (`[{...},...]`) or a bare object
|
|
// (`{...}`) for robustness against a model that drops the wrapper.
|
|
func parseActions(raw string) ([]routeAction, error) {
|
|
if strings.HasPrefix(raw, "[") {
|
|
var as []routeAction
|
|
return as, json.Unmarshal([]byte(raw), &as)
|
|
}
|
|
var a routeAction
|
|
if err := json.Unmarshal([]byte(raw), &a); err != nil {
|
|
return nil, err
|
|
}
|
|
return []routeAction{a}, nil
|
|
}
|
|
|
|
func firstNonEmpty(a, b string) string {
|
|
if strings.TrimSpace(a) != "" {
|
|
return a
|
|
}
|
|
return b
|
|
}
|