93987f2dfc
Seventeen markdown files at the repo root, twelve of them dated one-shot reports sitting next to CLAUDE.md. That is why stale docs read as current: nothing in the path said which was which. Root now keeps CLAUDE.md and AGENTS.md. Living docs move under docs/ and carry a Last verified line. Dated measurements move to docs/evals/ ISO-prefixed, and are never edited after the day, so a newer number is a new file. The senior review moves to docs/archive/. Every reference was rewritten across markdown, Go comments, the Makefile and the recall fixture. The touched Go packages still build. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
723 lines
29 KiB
Markdown
723 lines
29 KiB
Markdown
# Plan — Sub-project 1: Router-as-LFM + Foundation
|
||
|
||
> **Historical/completed foundation.** The shared llama-server client, router,
|
||
> fallback and replier described here were implemented. The resident-model
|
||
> decision changed on 2026-07-18 from LFM to locally trained Qwen3-1.7B. Do not
|
||
> use the embedded LFM model paths or old single-object examples as current ops
|
||
> guidance; see `2026-07-18-qwen3-resident-training-eval.md`.
|
||
|
||
> Scope from `docs/rearchitecture.md`. Make Maven trustworthy: the LFM becomes the router
|
||
> (fixes "messes up queries" / "doesn't take notes"), the engine actually runs
|
||
> (fixes stub replies), dates stop being read as "number dot number dot number",
|
||
> and telegram becomes a reach channel. NOT in scope: on-demand 4B reasoner,
|
||
> digestion worker, voice-quality/custom-TTS.
|
||
|
||
## Findings that shape the plan
|
||
|
||
- **Notes + reminders already work in code** (`applyAction` → `WriteNote` /
|
||
`CreateReminder`, `cmd/mavend/voice.go:613,548`). The user's "she doesn't take
|
||
notes / remind" is (a) the embedder-classifier misrouting the utterance away
|
||
from `IntentNote`/`IntentReminder`, and (b) `StubReplier` giving canned
|
||
confirmations. The LFM router + LLM replier fix both.
|
||
- **Telegram sink already wired** (`cmd/mavend/main.go:306-321`,
|
||
`delivery.Config.Telegram`). Only the deployed config lacks a `telegram` block.
|
||
- **Phraser runs llama-server** (`internal/phraser/llmphraser.go`) over
|
||
`/v1/chat/completions` at base URL `p.port`. Deployed `mavend.json` has **no
|
||
`phraser` block** → `phraser.NewStub()` (`main.go:261`). Engine is off.
|
||
- **Import direction:** `phraser → router`. So the LLM router lives *in*
|
||
`router`, using a new dependency-free `internal/llm` client that both the
|
||
phraser and router share (one llama-server, two callers). `router → llm` is
|
||
acyclic; `phraser → llm` is acyclic.
|
||
|
||
## File structure
|
||
|
||
| File | Create/Modify | Responsibility |
|
||
|---|---|---|
|
||
| `internal/ttsnorm/ttsnorm.go` | create | RU date/number → speakable text, before TTS |
|
||
| `internal/ttsnorm/ttsnorm_test.go` | create | table tests for date/time/number forms |
|
||
| `cmd/mavend/voice.go` | modify | call `ttsnorm.Speakable` in `reply()`; wire LLM router + LLM replier |
|
||
| `internal/delivery/voicesink/voicesink.go` | modify | normalize nudge text before synth |
|
||
| `cmd/mavend/reactive_notes_test.go` | create | notes+reminders round-trip integration test |
|
||
| `internal/llm/client.go` | create | llama-server `/v1/chat/completions` client w/ GBNF grammar |
|
||
| `internal/llm/client_test.go` | create | httptest server round-trip + grammar passthrough |
|
||
| `internal/phraser/llmphraser.go` | modify | add `BaseURL()` accessor |
|
||
| `internal/router/llmrouter.go` | create | `Action`, GBNF grammar, prompt, parse, `LLMRouter` |
|
||
| `internal/router/llmrouter_test.go` | create | mock client: intent+slot mapping, fallback |
|
||
| `internal/router/router.go` | modify | `Config.LLM`; `Route` consults LLM after stage-0 miss |
|
||
| `cmd/mavend/replier_llm.go` | create | `llmReplier` (voice.Replier) — natural reactive replies |
|
||
| `cmd/mavend/replier_llm_test.go` | create | mock client: reply per intent, stub fallback |
|
||
| `deploy/mavend.json` | modify | add `phraser` + `telegram` blocks |
|
||
| `docker-compose.yml` | modify | mount LFM model + telegram env |
|
||
| `AGENTS.md` | modify | document LFM model download + router-LFM |
|
||
|
||
---
|
||
|
||
## Task 1 — RU date/number TTS normalizer
|
||
|
||
**Goal:** dates/times/bare number-dot-number strings are spoken as words, never
|
||
"number dot number dot number".
|
||
|
||
**Files:** create `internal/ttsnorm/ttsnorm.go`, `internal/ttsnorm/ttsnorm_test.go`;
|
||
modify `cmd/mavend/voice.go` (`reply`), `internal/delivery/voicesink/voicesink.go`.
|
||
|
||
**Acceptance criteria:**
|
||
- `Speakable("напомню 10.07.2026")` → `"напомню 10 июля 2026"`.
|
||
- `Speakable("встреча в 14:00")` → `"встреча в 14 часов 00 минут"`.
|
||
- `Speakable("это 3.2.1 версия")` → digits joined with " точка " (`"3 точка 2 точка 1"`) — the generic fallback so no `.` is voiced as a symbol.
|
||
- Idempotent: `Speakable(Speakable(x)) == Speakable(x)`.
|
||
- Pure, no deps beyond stdlib; RU month table.
|
||
|
||
**Verify:** `go test ./internal/ttsnorm/` → `ok`.
|
||
|
||
**Steps (TDD):**
|
||
|
||
1. Write `ttsnorm_test.go`:
|
||
```go
|
||
package ttsnorm
|
||
|
||
import "testing"
|
||
|
||
func TestSpeakable(t *testing.T) {
|
||
cases := []struct{ in, want string }{
|
||
{"напомню 10.07.2026", "напомню 10 июля 2026"},
|
||
{"срок 01.01", "срок 1 января"},
|
||
{"встреча в 14:00", "встреча в 14 часов 00 минут"},
|
||
{"в 9:05 подъём", "в 9 часов 05 минут подъём"},
|
||
{"это 3.2.1 версия", "это 3 точка 2 точка 1 версия"},
|
||
{"без чисел", "без чисел"},
|
||
}
|
||
for _, c := range cases {
|
||
if got := Speakable(c.in); got != c.want {
|
||
t.Errorf("Speakable(%q) = %q, want %q", c.in, got, c.want)
|
||
}
|
||
if got := Speakable(Speakable(c.in)); got != Speakable(c.in) {
|
||
t.Errorf("not idempotent for %q: %q", c.in, got)
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
2. Implement `ttsnorm.go`. Order matters: dates (with year, then without) →
|
||
times → generic numeric-dot fallback.
|
||
```go
|
||
// Package ttsnorm rewrites machine-formatted dates/times/numbers into RU text
|
||
// a TTS voice speaks naturally — so "10.07.2026" is not read as "number dot
|
||
// number dot number". Pure, deterministic; runs on reply/nudge text before synth.
|
||
package ttsnorm
|
||
|
||
import (
|
||
"regexp"
|
||
"strconv"
|
||
"strings"
|
||
)
|
||
|
||
var months = [...]string{"", "января", "февраля", "марта", "апреля", "мая",
|
||
"июня", "июля", "августа", "сентября", "октября", "ноября", "декабря"}
|
||
|
||
var (
|
||
reDateY = regexp.MustCompile(`\b(\d{1,2})\.(\d{1,2})\.(\d{4})\b`)
|
||
reDate = regexp.MustCompile(`\b(\d{1,2})\.(\d{1,2})\b`)
|
||
reTime = regexp.MustCompile(`\b(\d{1,2}):(\d{2})\b`)
|
||
reDots = regexp.MustCompile(`\b\d+(?:\.\d+)+\b`)
|
||
)
|
||
|
||
// Speakable rewrites d.m.y, d.m, h:mm, and residual dotted-number runs.
|
||
func Speakable(s string) string {
|
||
s = reDateY.ReplaceAllStringFunc(s, func(m string) string {
|
||
p := reDateY.FindStringSubmatch(m)
|
||
return spokenDate(p[1], p[2], p[3])
|
||
})
|
||
s = reDate.ReplaceAllStringFunc(s, func(m string) string {
|
||
p := reDate.FindStringSubmatch(m)
|
||
return spokenDate(p[1], p[2], "")
|
||
})
|
||
s = reTime.ReplaceAllStringFunc(s, func(m string) string {
|
||
p := reTime.FindStringSubmatch(m)
|
||
return p[1] + " часов " + p[2] + " минут"
|
||
})
|
||
s = reDots.ReplaceAllStringFunc(s, func(m string) string {
|
||
return strings.Join(strings.Split(m, "."), " точка ")
|
||
})
|
||
return s
|
||
}
|
||
|
||
func spokenDate(dd, mm, yyyy string) string {
|
||
mi, _ := strconv.Atoi(mm)
|
||
if mi < 1 || mi > 12 {
|
||
return dd + " " + mm + gap(yyyy) // out-of-range: leave numbers, drop dot
|
||
}
|
||
day := strconv.Itoa(mustInt(dd)) // strip leading zero: "01" → "1"
|
||
out := day + " " + months[mi]
|
||
if yyyy != "" {
|
||
out += " " + yyyy
|
||
}
|
||
return out
|
||
}
|
||
|
||
func mustInt(s string) int { n, _ := strconv.Atoi(s); return n }
|
||
func gap(y string) string { if y == "" { return "" }; return " " + y }
|
||
```
|
||
|
||
3. Wire into `reply()` (`cmd/mavend/voice.go:972`), before synth:
|
||
```go
|
||
func (h *reactiveHandler) reply(ctx context.Context, text string, _ []string) (voice.PushToTalkResp, error) {
|
||
spoken := ttsnorm.Speakable(text)
|
||
log.Printf("voice: reply → %q", text)
|
||
audioOut, err := h.tts.Synthesize(ctx, spoken)
|
||
if err != nil {
|
||
log.Printf("voice: tts error: %v", err)
|
||
return voice.PushToTalkResp{ReplyText: text, ReplyAudio: audio.Audio{}}, nil
|
||
}
|
||
return voice.PushToTalkResp{ReplyText: text, ReplyAudio: audioOut}, nil
|
||
}
|
||
```
|
||
(`ReplyText` stays raw for on-screen display; only audio is normalized.) Add the
|
||
`ttsnorm` import.
|
||
|
||
4. Wire into `voicesink.go` before its `Synthesize` call (same one-line
|
||
`ttsnorm.Speakable` on the nudge body). Add import.
|
||
|
||
5. `go test ./internal/ttsnorm/ ./cmd/mavend/ ./internal/delivery/...` green.
|
||
|
||
---
|
||
|
||
## Task 2 — Notes + reminders round-trip test (+ fix any real break)
|
||
|
||
**Goal:** prove — and lock with a test — that a note utterance persists a note
|
||
and a reminder utterance persists a reminder through the reactive handler.
|
||
|
||
**Files:** create `cmd/mavend/reactive_notes_test.go`. Modify `applyAction` /
|
||
seeds ONLY if the test exposes a real break.
|
||
|
||
**Acceptance criteria:**
|
||
- Driving `handleText` with a note utterance results in a `notes` row whose text
|
||
matches; with a reminder utterance, a `reminders` row with a future `fire_ts`.
|
||
- Test uses the real `store.Store` (in-tmpfs or file), `ipc.NewStoreAPI(st)`, a
|
||
`HashEmbedder`, and `StubReplier` — no LLM required.
|
||
- If routing (not the write) is what fails, the test forces intent by using a
|
||
stage-0 grammar utterance (`"напомни завтра позвонить маме"`) and a directly
|
||
constructed `IntentNote` decision path, isolating capture from routing.
|
||
|
||
**Verify:** `go test ./cmd/mavend/ -run TestReactiveNotesReminders -v` → PASS.
|
||
|
||
**Steps (TDD):**
|
||
|
||
1. Write the test: open a temp `store.Store`, build a minimal `reactiveHandler`
|
||
(fields: `api: ipc.NewStoreAPI(st)`, `embedder: router.NewHashEmbedder(1024)`,
|
||
`router: buildRouter(emb, matcher, thr)`, `replier: voice.NewStubReplier()`,
|
||
`now: time.Now`, `memStore: memory.NewInMemoryStore()`, `dataStore: st`,
|
||
`timeParser: router.NewPythonDateParser()`). Call
|
||
`h.handleText(ctx, "напомни завтра в 9:00 позвонить маме")`; assert
|
||
`st.DueReminders`/`ListReminders` returns one row with `fire_ts > now`.
|
||
Then a note: construct `dec := router.Decision{Intent: router.IntentNote,
|
||
Utterance: "запомни что кофе закончился"}`, call `h.applyAction(ctx, dec)`;
|
||
assert `st.RecentNotes(ctx, 10)` contains the text.
|
||
2. Run it. If it fails at the **write**, fix the write in `applyAction`. If it
|
||
fails only because routing sent the utterance elsewhere, that is Task 4/5's
|
||
job — this test pins the *capture* contract, so force the intent as above and
|
||
leave a `// routing covered by llmrouter_test` note.
|
||
3. Keep the test deterministic (no network: `NewPythonDateParser` shells out —
|
||
if unavailable in CI, use a fixed `router.Slots{Time: now.Add(24h), HasTime:true}`
|
||
decision for the reminder capture assertion instead of the parser path).
|
||
|
||
---
|
||
|
||
## Task 3 — `internal/llm` shared llama-server client
|
||
|
||
**Goal:** one client type both the phraser and the LFM router use to call the
|
||
already-running llama-server, with optional GBNF grammar for constrained output.
|
||
|
||
**Files:** create `internal/llm/client.go`, `internal/llm/client_test.go`;
|
||
modify `internal/phraser/llmphraser.go` (add `BaseURL()`).
|
||
|
||
**Acceptance criteria:**
|
||
- `Client.Complete(ctx, Req{System, User, Grammar, MaxTokens})` POSTs to
|
||
`<base>/v1/chat/completions`, returns the assistant message content.
|
||
- When `Req.Grammar != ""`, the request body carries a top-level `"grammar"`
|
||
field (llama.cpp extension) verbatim.
|
||
- `LLMPhraser.BaseURL()` returns the spawned server's base URL (`p.port`).
|
||
|
||
**Verify:** `go test ./internal/llm/` → `ok`.
|
||
|
||
**Steps (TDD):**
|
||
|
||
1. `client_test.go`: stand up `httptest.NewServer` that (a) asserts the decoded
|
||
body has `messages[0].role=="system"`, `messages[1].content=="hi"`, and
|
||
`grammar=="root ::= \"x\""`, and (b) replies
|
||
`{"choices":[{"message":{"content":"ok"}}]}`. Assert `Complete` returns `"ok"`.
|
||
2. Implement `client.go`:
|
||
```go
|
||
// Package llm is the shared llama-server completion client — one seam both the
|
||
// phraser (talking back) and the router (routing) call. It does NOT spawn the
|
||
// server; the daemon owns one llama-server (spawned by the phraser) and hands
|
||
// its base URL here, so a single resident model serves both callers.
|
||
package llm
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"net/http"
|
||
"time"
|
||
)
|
||
|
||
type Client struct {
|
||
base string
|
||
http *http.Client
|
||
}
|
||
|
||
func New(baseURL string, timeout time.Duration) *Client {
|
||
return &Client{base: baseURL, http: &http.Client{Timeout: timeout}}
|
||
}
|
||
|
||
type Req struct {
|
||
System string
|
||
User string
|
||
Grammar string // GBNF; empty ⇒ unconstrained
|
||
MaxTokens int
|
||
}
|
||
|
||
type msg struct {
|
||
Role string `json:"role"`
|
||
Content string `json:"content"`
|
||
}
|
||
type body struct {
|
||
Messages []msg `json:"messages"`
|
||
MaxTokens int `json:"max_tokens,omitempty"`
|
||
Grammar string `json:"grammar,omitempty"`
|
||
Temp float64 `json:"temperature"`
|
||
}
|
||
type resp struct {
|
||
Choices []struct {
|
||
Message msg `json:"message"`
|
||
} `json:"choices"`
|
||
}
|
||
|
||
func (c *Client) Complete(ctx context.Context, r Req) (string, error) {
|
||
b, _ := json.Marshal(body{
|
||
Messages: []msg{{"system", r.System}, {"user", r.User}},
|
||
MaxTokens: r.MaxTokens,
|
||
Grammar: r.Grammar,
|
||
Temp: 0,
|
||
})
|
||
req, err := http.NewRequestWithContext(ctx, "POST", c.base+"/v1/chat/completions", bytes.NewReader(b))
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
req.Header.Set("Content-Type", "application/json")
|
||
httpResp, err := c.http.Do(req)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
defer httpResp.Body.Close()
|
||
if httpResp.StatusCode != 200 {
|
||
return "", fmt.Errorf("llm: status %d", httpResp.StatusCode)
|
||
}
|
||
var out resp
|
||
if err := json.NewDecoder(httpResp.Body).Decode(&out); err != nil {
|
||
return "", err
|
||
}
|
||
if len(out.Choices) == 0 {
|
||
return "", fmt.Errorf("llm: no choices")
|
||
}
|
||
return out.Choices[0].Message.Content, nil
|
||
}
|
||
```
|
||
3. Add to `llmphraser.go`: `func (p *LLMPhraser) BaseURL() string { return p.port }`
|
||
(the field already holds the full base URL incl. scheme+host+port).
|
||
4. `go test ./internal/llm/ ./internal/phraser/` green.
|
||
|
||
---
|
||
|
||
## Task 4 — LFM router core (in `internal/router`)
|
||
|
||
**Goal:** an agentic router that turns an utterance into a structured `Action`
|
||
(intent + raw slots + escalate flag) via one grammar-constrained LFM call, with
|
||
deterministic mapping to a `Decision`. Classifier stays as the fallback.
|
||
|
||
**Files:** create `internal/router/llmrouter.go`, `internal/router/llmrouter_test.go`;
|
||
modify `internal/router/router.go`.
|
||
|
||
**Acceptance criteria:**
|
||
- `LLMRouter.Route(ctx, utterance, now)` returns a `router.Decision` with
|
||
`Intent` ∈ the existing enum, `Slots` populated (`Key/Value` for fact,
|
||
`Text` for note/query/reminder, `Fn` left for the act matcher), and
|
||
`Stage = 1`.
|
||
- Output is constrained by a GBNF grammar to a JSON object with an `intent`
|
||
enum and optional string fields — malformed model output cannot escape the
|
||
enum.
|
||
- On any error (LLM down, parse fail) `Route` returns `(Decision{}, false, err)`
|
||
and the caller falls back to the classifier cascade.
|
||
- Time is NOT parsed here (kept deterministic): reminder actions carry the raw
|
||
text in `Slots.Text`; the daemon's existing `timeParser` fallback
|
||
(`voice.go:552`) resolves it.
|
||
|
||
**Verify:** `go test ./internal/router/ -run TestLLMRouter -v` → PASS.
|
||
|
||
**Steps (TDD):**
|
||
|
||
1. `llmrouter_test.go` with a mock completer (interface, not `*llm.Client`):
|
||
```go
|
||
type mockLLM struct{ out string; err error }
|
||
func (m mockLLM) Complete(ctx context.Context, r llm.Req) (string, error) { return m.out, m.err }
|
||
|
||
func TestLLMRouterFactMapping(t *testing.T) {
|
||
lr := NewLLMRouter(mockLLM{out: `{"intent":"fact","key":"water","value":"выпил"}`})
|
||
d, ok, err := lr.Route(context.Background(), "я выпил воду", time.Now())
|
||
if err != nil || !ok { t.Fatalf("ok=%v err=%v", ok, err) }
|
||
if d.Intent != IntentFact || d.Slots.Key != "water" || !d.Slots.HasKey {
|
||
t.Fatalf("bad decision %+v", d)
|
||
}
|
||
}
|
||
func TestLLMRouterNoteMapping(t *testing.T) { /* intent:"note" → Slots.Text set */ }
|
||
func TestLLMRouterBadJSONFallsBack(t *testing.T) {
|
||
lr := NewLLMRouter(mockLLM{out: `garbage`})
|
||
if _, ok, err := lr.Route(context.Background(), "x", time.Now()); ok || err == nil {
|
||
t.Fatal("want ok=false, err!=nil on bad json")
|
||
}
|
||
}
|
||
```
|
||
|
||
2. `llmrouter.go`:
|
||
```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 fixed-shape JSON object with
|
||
// an intent enum. Prevents free-form drift from a sub-1B model.
|
||
const routeGrammar = `
|
||
root ::= "{" 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 = `Ты — маршрутизатор Maven. По реплике пользователя верни ОДИН JSON-объект:
|
||
{"intent": one of fact|reminder|note|query|act|chat|system, ...}
|
||
Правила: fact — состояние которое надо запомнить и отслеживать ("я выпил воду" → key=water, value=выпил); reminder — просьба напомнить в будущем (text = что напомнить); note — заметка/предпочтение без отслеживания (text = текст); query — вопрос (text = вопрос); act — команда выполнить действие на сервере (verb = глагол); chat — свободный разговор (text); system — время/дата/день. Только 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
|
||
}
|
||
var a routeAction
|
||
if err := json.Unmarshal([]byte(strings.TrimSpace(raw)), &a); err != nil {
|
||
return Decision{}, false, fmt.Errorf("llmrouter: parse %q: %w", raw, err)
|
||
}
|
||
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) // time parsed downstream
|
||
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 // Fn resolved by the act matcher in the daemon
|
||
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
|
||
}
|
||
|
||
func firstNonEmpty(a, b string) string { if strings.TrimSpace(a) != "" { return a }; return b }
|
||
```
|
||
|
||
3. `router.go`: add `LLM *LLMRouter` to `Config` and the `Router` struct; in
|
||
`Route`, after the stage-0 loop and before `r.classifier.Classify`:
|
||
```go
|
||
// stage 1a — LLM router (when wired). It reasons over the utterance instead
|
||
// of nearest-centroid guessing. On any error/parse-fail, fall through to the
|
||
// classifier cascade (never fail the turn on the model).
|
||
if r.llm != nil {
|
||
if d, ok, err := r.llm.Route(ctx, utterance, now); err == nil && ok {
|
||
d.Utterance = utterance
|
||
return d, nil
|
||
} else if err != nil {
|
||
log.Printf("router: llm route fell back to classifier: %v", err)
|
||
}
|
||
}
|
||
```
|
||
Add `"log"` import; set `llm: cfg.LLM` in `New`.
|
||
|
||
4. `go test ./internal/router/` green (existing classifier tests still pass —
|
||
`LLM` nil by default preserves the cascade).
|
||
|
||
---
|
||
|
||
## Task 5 — Wire the LFM router into the daemon
|
||
|
||
**Goal:** the daemon builds one `llm.Client` from the running phraser and injects
|
||
it into the router, so production routing goes through the LFM (with classifier
|
||
fallback). Act intents still resolve `Fn` via the existing matcher.
|
||
|
||
**Files:** modify `cmd/mavend/voice.go` (`wireVoice`, `buildRouter`), and pass
|
||
the phraser through (already a `wireVoice` param `phr phraser.Phraser`).
|
||
|
||
**Acceptance criteria:**
|
||
- When `phr` is an `*phraser.LLMPhraser`, `buildRouter` receives a non-nil
|
||
`Completer` and the router's `Config.LLM` is set; when `phr` is the Stub,
|
||
`LLM` is nil and routing uses the classifier (unchanged behavior / tests).
|
||
- Act `Fn` resolution: after an LLM `IntentAct` decision, the handler runs the
|
||
utterance/verb through the existing `matcher.Match` (as stage-0 does) so
|
||
`HasFn`/`Args` are filled before `applyAction`.
|
||
|
||
**Verify:** `go build ./cmd/mavend/ && go test ./cmd/mavend/` → ok; manual:
|
||
`handleText` on a paraphrased note/reminder (not matching any seed) routes
|
||
correctly (covered by an added `cmd/mavend` test using a mock completer).
|
||
|
||
**Steps:**
|
||
|
||
1. In `wireVoice`, after the phraser is known, build the optional client:
|
||
```go
|
||
var llmRouter *router.LLMRouter
|
||
if lp, ok := phr.(*phraser.LLMPhraser); ok {
|
||
cli := llm.New(lp.BaseURL(), 20*time.Second)
|
||
llmRouter = router.NewLLMRouter(cli)
|
||
}
|
||
rtr := buildRouter(emb, matcher, threshold, llmRouter)
|
||
```
|
||
Add `internal/llm` import.
|
||
|
||
2. Extend `buildRouter` signature with `llmR *router.LLMRouter` and set
|
||
`LLM: llmR` in `router.Config`.
|
||
|
||
3. Act `Fn` fill: in `applyAction`'s `IntentAct` branch, when `!dec.Slots.HasFn`
|
||
and `dec.Slots.Text != ""`, try `h.tools`/matcher resolution before
|
||
`proposeGap` (mirror stage-0's `actMatcher.Match`). Add a `matcher` field to
|
||
`reactiveHandler` (set in `wireVoice`) and:
|
||
```go
|
||
case router.IntentAct:
|
||
if !dec.Slots.HasFn && dec.Slots.Text != "" && h.matcher != nil {
|
||
if fn, args, ok := h.matcher.Match(dec.Slots.Text); ok {
|
||
dec.Slots.Fn, dec.Slots.Args, dec.Slots.HasFn = fn, args, true
|
||
}
|
||
}
|
||
if !dec.Slots.HasFn { return h.proposeGap(ctx, dec) }
|
||
// ... unchanged executor path
|
||
```
|
||
|
||
4. Add a `cmd/mavend` test: build a handler with a mock-completer-backed
|
||
`router.LLMRouter`, drive `handleText("надо не забыть купить молоко")`,
|
||
assert it routes to `IntentNote`/`IntentReminder` (per mock) and persists.
|
||
|
||
5. `go test ./cmd/mavend/ ./internal/router/` green.
|
||
|
||
---
|
||
|
||
## Task 6 — LLM-backed reactive Replier (talking back)
|
||
|
||
**Goal:** reactive confirmations are phrased by the LFM ("записала: кофе
|
||
закончился" in Maven's voice), not `StubReplier` canned strings — the visible
|
||
payoff of the engine being on. Falls back to the Stub on any LLM error.
|
||
|
||
**Files:** create `cmd/mavend/replier_llm.go`, `cmd/mavend/replier_llm_test.go`;
|
||
modify `cmd/mavend/voice.go` (set `replier` to the LLM impl when the engine is on).
|
||
|
||
**Acceptance criteria:**
|
||
- `llmReplier.Reply(dec)` returns a short RU confirmation generated from the
|
||
decision (intent + slots), feminine self-reference, ≤ ~120 chars.
|
||
- On LLM error/empty, returns `StubReplier.Reply(dec)` (never blank, never a
|
||
wire error).
|
||
- Clarify decisions still yield a clarify prompt.
|
||
|
||
**Verify:** `go test ./cmd/mavend/ -run TestLLMReplier -v` → PASS.
|
||
|
||
**Steps (TDD):**
|
||
|
||
1. `replier_llm_test.go`: mock completer returning `"записала, кофе закончился"`
|
||
→ assert `Reply(noteDecision)` returns it; mock returning error → assert it
|
||
equals `voice.NewStubReplier().Reply(dec)`.
|
||
2. `replier_llm.go`:
|
||
```go
|
||
package main
|
||
|
||
import (
|
||
"context"
|
||
"time"
|
||
|
||
"github.com/kami/maven/internal/llm"
|
||
"github.com/kami/maven/internal/router"
|
||
"github.com/kami/maven/internal/voice"
|
||
)
|
||
|
||
type completer interface {
|
||
Complete(ctx context.Context, r llm.Req) (string, error)
|
||
}
|
||
|
||
// llmReplier phrases reactive confirmations with the resident LFM. Stub is the
|
||
// floor on any error (offline-safe). Maven speaks as "she", feminine RU.
|
||
type llmReplier struct {
|
||
c completer
|
||
stub *voice.StubReplier
|
||
}
|
||
|
||
func newLLMReplier(c completer) *llmReplier { return &llmReplier{c: c, stub: voice.NewStubReplier()} }
|
||
|
||
const replySystem = `Ты — Maven, домашняя ассистентка (о себе — в женском роде). Одной короткой фразой (≤120 симв) подтверди действие пользователю тепло и по-русски. Без кавычек и пояснений.`
|
||
|
||
func (r *llmReplier) Reply(d router.Decision) string {
|
||
if d.Clarify {
|
||
return r.stub.Reply(d)
|
||
}
|
||
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
|
||
defer cancel()
|
||
out, err := r.c.Complete(ctx, llm.Req{System: replySystem, User: replyContext(d), MaxTokens: 64})
|
||
if err != nil || out == "" {
|
||
return r.stub.Reply(d)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// replyContext renders the decision into a compact RU description for the model.
|
||
func replyContext(d router.Decision) string {
|
||
switch d.Intent {
|
||
case router.IntentFact:
|
||
return "записала факт: " + d.Slots.Key + " " + d.Slots.Value
|
||
case router.IntentNote:
|
||
return "сохранила заметку: " + d.Slots.Text
|
||
case router.IntentReminder:
|
||
return "поставила напоминание: " + d.Slots.Text
|
||
default:
|
||
return string(d.Intent) + ": " + d.Slots.Text
|
||
}
|
||
}
|
||
```
|
||
Note: `Reply` satisfies `voice.Replier` (sync). The 8s timeout bounds a slow
|
||
sub-1B turn; the Stub floor keeps the round-trip alive if it lapses.
|
||
3. In `wireVoice`, when `llmRouter != nil` (engine on), set
|
||
`replier: newLLMReplier(cli)` instead of `voice.NewStubReplier()` (reuse the
|
||
same `cli`). Keep Stub when the engine is off.
|
||
4. `go test ./cmd/mavend/` green.
|
||
|
||
---
|
||
|
||
## Task 7 — Turn the engine on + telegram reach (deploy config)
|
||
|
||
**Goal:** homesrv actually runs the LFM (router + phraser + replier live) and can
|
||
reach the user over telegram. This is the commit that flips Maven from stub to
|
||
real on the deploy box.
|
||
|
||
**Files:** modify `deploy/mavend.json`, `docker-compose.yml`, `AGENTS.md`.
|
||
Ops: place the LFM gguf on homesrv; `docker compose up -d`.
|
||
|
||
**Acceptance criteria:**
|
||
- `deploy/mavend.json` has a `phraser` block pointing at the LFM gguf
|
||
(`LFM2.5-1.2B-Instruct-Q4_K_M.gguf`) with `bin_path: llama-server`,
|
||
`n_gpu_layers` tuned for the Vega iGPU (start `0` = CPU; the 1.2B is real-time
|
||
on CPU, avoids fragile ROCm), `n_ctx: 2048`.
|
||
- `deploy/mavend.json` has a `telegram` block (`bot_token`, `chat_id`) — token
|
||
from env, not committed.
|
||
- On homesrv: `docker logs maven-mavend-1` shows a phraser/llama line and
|
||
`voice listening`, and no fall-through to Stub.
|
||
- A push-to-talk (or `/api/chat`) "запомни что кофе закончился" returns an LFM
|
||
phrased confirmation AND persists a note (check `/dash` recent notes).
|
||
- A sev4 nudge with no live voice session lands in telegram.
|
||
|
||
**Verify:**
|
||
```sh
|
||
ssh kami@192.168.1.104 'docker logs maven-mavend-1 2>&1 | grep -iE "llama|phraser|listening"'
|
||
# expect a llama-server spawn line + "voice listening on ..."
|
||
ssh kami@192.168.1.104 'curl -s localhost:9201/api/chat -d "{\"text\":\"запомни что кофе закончился\"}"'
|
||
# expect a non-stub RU confirmation; then /dash shows the note
|
||
```
|
||
|
||
**Steps:**
|
||
|
||
1. Add to `deploy/mavend.json`:
|
||
```json
|
||
"phraser": {
|
||
"model_path": "/opt/maven/models/llm/LFM2.5-1.2B-Instruct-Q4_K_M.gguf",
|
||
"bin_path": "llama-server",
|
||
"n_gpu_layers": 0,
|
||
"n_ctx": 2048,
|
||
"timeout": "20s"
|
||
},
|
||
"telegram": { "bot_token": "${TELEGRAM_BOT_TOKEN}", "chat_id": "${TELEGRAM_CHAT_ID}" }
|
||
```
|
||
(Match the existing config's env-substitution convention; if it doesn't
|
||
support `${...}`, wire the two telegram values via compose env + a small
|
||
config env-read, consistent with `db_key_env`.)
|
||
2. `docker-compose.yml`: mount `./models/llm:/opt/maven/models/llm:ro` on
|
||
`mavend`; add `TELEGRAM_BOT_TOKEN` / `TELEGRAM_CHAT_ID` to its env (from a
|
||
gitignored `deploy/telegram.env`, mirroring `deploy/db_key.env`). Ensure
|
||
`llama-server` is on PATH in the image (it's already used by design; confirm
|
||
the Dockerfile installs it — if not, add it).
|
||
3. Ops: copy `models/llm/LFM2.5-1.2B-Instruct-Q4_K_M.gguf` to
|
||
homesrv `/opt/maven/models/llm/` (the box already has `/usr/local/bin/llama-server`).
|
||
If the LFM gguf isn't on disk yet, download it (HF, sha-checked) — document in
|
||
AGENTS.md next to the embedder section.
|
||
4. `docker compose up -d mavend && docker logs -f maven-mavend-1` — confirm the
|
||
phraser spawns and no `phraser: NewStub` path. Run the two verify curls.
|
||
5. Update `AGENTS.md`: LFM model download + note that routing is now LFM-first
|
||
with classifier fallback (`docs/rearchitecture.md` is the design of record).
|
||
|
||
---
|
||
|
||
## Self-review
|
||
|
||
- **Placeholders:** none — every code step carries real code. Deploy steps (Task
|
||
7) are concrete commands, not "configure somehow".
|
||
- **Type consistency:** `Completer` interface (`Complete(ctx, llm.Req) (string,
|
||
error)`) is used identically in Task 4 (router), Task 5 (wiring), Task 6
|
||
(replier); `*llm.Client` satisfies it. `router.Decision`/`Slots` field names
|
||
match the real structs (`HasKey`, `Text`, `Fn`, `Args`). `LLMRouter.Route`
|
||
returns `(Decision, bool, error)` consistently across Task 4 def and Task 4
|
||
router.go call site.
|
||
- **Fallback discipline:** every LLM seam (router, replier) falls back to the
|
||
existing deterministic floor on error — a slow/absent model degrades to today's
|
||
behavior, never a broken turn. Engine-off path is byte-for-byte unchanged
|
||
(LLM nil, Stub replier), so all existing tests hold.
|
||
- **Scope:** no 4B reasoner, no digestion worker, no custom TTS. `escalate` is
|
||
intentionally omitted from the grammar this sub-project (the reasoner it would
|
||
escalate to doesn't exist yet) — added in sub-project 2.
|
||
|
||
## Ordering & deps
|
||
|
||
1 (ttsnorm) and 2 (notes/reminders test) are independent — do first, any order.
|
||
3 (llm client) → 4 (llm router) → 5 (wire) is the critical chain. 6 (replier)
|
||
depends on 3. 7 (deploy) depends on 3–6 landed. Each task = one commit.
|