Files
Maven/docs/plans/2026-07-10-router-lfm-foundation.md
T
kami 5fe8f228c1 feat(mavweb): /ecosystem page consuming Nexus/Praxis/Hexis + shell fixes
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>
2026-07-19 22:04:23 +04:00

723 lines
29 KiB
Markdown
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.
# 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 `REARCH.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 (`REARCH.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 36 landed. Each task = one commit.