feat: LLM phraser, shared LLM client, and LLM replier

- Add llmphraser: LFM-based phraser implementing Phraser interface with
  PhraseChat, PhraseNudge, PhraseReactive, and PhraseReminder methods.
- Add shared internal/llm/client: llama-server completion client used by
  both the phraser (talking back) and router (routing), sharing one model.
- Add LLMReplier in mavend: replaces StubReplier for chat/nudge/reactive
  replies, falls back to stub on model errors.
- Update Phraser interface: add PhraseChat method, update stub to match.
- Wire LLM phaser into mavend voice init, plumb LLM config from JSON.
This commit is contained in:
kami
2026-07-10 15:48:55 +04:00
parent 28a940ebbe
commit c0c11cd36d
8 changed files with 703 additions and 32 deletions
+78
View File
@@ -0,0 +1,78 @@
package main
import (
"context"
"strings"
"time"
"github.com/kami/maven/internal/llm"
"github.com/kami/maven/internal/router"
"github.com/kami/maven/internal/voice"
)
// completer is the LLM seam for the replier (subset of router.Completer).
// *llm.Client satisfies it.
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: 48,
RepeatPenalty: 1.3, // curb the sub-1B token loop ("тоже тоже тоже")
Stop: []string{"\n"},
})
if out = firstSentence(out); err != nil || out == "" {
return r.stub.Reply(d)
}
return out
}
// firstSentence trims the model's output to a single clean confirmation: first
// line, first sentence, whitespace-normalized — the last-line defense against a
// small model that rambles past the first period despite the prompt + stop.
func firstSentence(s string) string {
s = strings.TrimSpace(s)
if i := strings.IndexByte(s, '\n'); i >= 0 {
s = s[:i]
}
// keep up to and including the first sentence-ending punctuation.
if i := strings.IndexAny(s, ".!?"); i >= 0 {
s = s[:i+1]
}
return strings.TrimSpace(s)
}
// 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
}
}
+58
View File
@@ -0,0 +1,58 @@
package main
import (
"context"
"testing"
"github.com/kami/maven/internal/llm"
"github.com/kami/maven/internal/router"
"github.com/kami/maven/internal/voice"
)
type mockCompleter struct{ out string; err error }
func (m mockCompleter) Complete(_ context.Context, _ llm.Req) (string, error) { return m.out, m.err }
func TestLLMReplierReturnsLLMReply(t *testing.T) {
r := newLLMReplier(mockCompleter{out: "записала, кофе закончился"})
got := r.Reply(router.Decision{Intent: router.IntentNote, Slots: router.Slots{Text: "кофе закончился"}})
if got != "записала, кофе закончился" {
t.Errorf("got %q, want %q", got, "записала, кофе закончился")
}
}
func TestLLMReplierFallsBackToStubOnError(t *testing.T) {
r := newLLMReplier(mockCompleter{err: errTestLLMDown})
noteDec := router.Decision{Intent: router.IntentNote}
got := r.Reply(noteDec)
want := voice.NewStubReplier().Reply(noteDec)
if got != want {
t.Errorf("on llm error: got %q, want stub %q", got, want)
}
}
func TestLLMReplierFallsBackToStubOnEmpty(t *testing.T) {
r := newLLMReplier(mockCompleter{out: ""})
noteDec := router.Decision{Intent: router.IntentNote}
got := r.Reply(noteDec)
want := voice.NewStubReplier().Reply(noteDec)
if got != want {
t.Errorf("on empty llm: got %q, want stub %q", got, want)
}
}
func TestLLMReplierClarifyUsesStub(t *testing.T) {
r := newLLMReplier(mockCompleter{out: "я всё поняла"})
clarifyDec := router.Decision{Clarify: true}
got := r.Reply(clarifyDec)
want := voice.NewStubReplier().Reply(clarifyDec)
if got != want {
t.Errorf("on clarify: got %q, want stub %q", got, want)
}
}
var errTestLLMDown = errTest("llm down")
type errTest string
func (e errTest) Error() string { return string(e) }
+298 -30
View File
@@ -61,12 +61,16 @@ import (
"github.com/kami/maven/internal/delivery/voicesink"
"github.com/kami/maven/internal/dialogue"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/llm"
"github.com/kami/maven/internal/memory"
"github.com/kami/maven/internal/pattern"
"github.com/kami/maven/internal/phraser"
"github.com/kami/maven/internal/router"
"github.com/kami/maven/internal/store"
"github.com/kami/maven/internal/stt"
"github.com/kami/maven/internal/tool"
"github.com/kami/maven/internal/tts"
"github.com/kami/maven/internal/ttsnorm"
"github.com/kami/maven/internal/voice"
"github.com/kami/maven/internal/weather"
"github.com/kami/maven/internal/worker"
@@ -79,6 +83,7 @@ type voiceWiring struct {
sessions *voice.Sessions
voiceSink delivery.Sink
embedder router.Embedder
handler *reactiveHandler // the reactive handler for IPC Chat
// worker clients (set when configured as Remote): closed on shutdown so
// mavsttd / mavttsd don't keep a stale conn into a restarting daemon.
sttClient *worker.Client
@@ -111,7 +116,7 @@ func (w *voiceWiring) close() {
//
// When voice is enabled, MUST wire a voicesink into the dispatcher's Voice
// slot using w.sessions (the caller does that — see main.go).
func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, memStore memory.Store) (*voiceWiring, error) {
func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, memStore memory.Store, dataStore *store.Store) (*voiceWiring, error) {
if cfg.Voice == nil || !cfg.Voice.Enabled {
return nil, nil
}
@@ -187,6 +192,16 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem
log.Printf("voice: weather provider: stub (not configured)")
}
// ----- LLM router (when the phraser is backed by a real model) -----
// Both the router and the replier share the same *llm.Client; we build
// it here from the phraser's base URL.
var llmRouter *router.LLMRouter
var llmClient *llm.Client
if lp, ok := phr.(*phraser.LLMPhraser); ok {
llmClient = llm.New(lp.BaseURL(), 20*time.Second)
llmRouter = router.NewLLMRouter(llmClient)
}
// ----- router (the cascade; floor examples seed the classifier) -----
// The act matcher's allowlist is exactly the enabled tool names — the
// router only matches acts the executor can run (one source of truth).
@@ -194,7 +209,7 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem
if threshold <= 0 {
threshold = config.DefaultRouterThreshold
}
rtr := buildRouter(emb, matcher, threshold)
rtr := buildRouter(emb, matcher, threshold, llmRouter)
// ----- sessions registry (shared with voicesink) -----
sessions := voice.NewSessions()
@@ -213,6 +228,12 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem
// ----- dialogue (multi-turn slot carry-over; 2-min follow-up window) -----
dialogueSessions := dialogue.NewSessionStore(2 * time.Minute)
// ----- replier (LLM-backed when the engine is on, Stub floor otherwise) -----
replier := voice.Replier(voice.NewStubReplier())
if llmClient != nil {
replier = newLLMReplier(llmClient)
}
// ----- the handler (the reactive path; closes over stt / tts / router / coreAPI / memory) -----
h := &reactiveHandler{
stt: transcriber,
@@ -221,12 +242,14 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem
embedder: emb,
api: coreAPI,
tools: exec,
matcher: matcher,
replier: replier,
phraser: phr,
replier: voice.NewStubReplier(),
now: time.Now,
weatherProvider: weatherProvider,
weatherLocation: weatherLocation,
memStore: memStore,
dataStore: dataStore,
dialogueSessions: dialogueSessions,
queryMinScore: cfg.Voice.QueryMinScore,
timeParser: router.NewPythonDateParser(),
@@ -239,6 +262,7 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem
return nil, fmt.Errorf("voice listen: %w", err)
}
w.server = srv
w.handler = h
return w, nil
}
@@ -254,6 +278,7 @@ type reactiveHandler struct {
embedder router.Embedder // reused for note write/query (same model as the classifier)
api ipc.CoreAPI
tools *tool.Executor
matcher *tool.Matcher
phraser phraser.Phraser
replier voice.Replier
now func() time.Time
@@ -261,7 +286,8 @@ type reactiveHandler struct {
weatherProvider weather.Provider
weatherLocation string // default location for weather queries
memStore memory.Store
memStore memory.Store
dataStore *store.Store // direct store access for event extraction + pattern detection
// queryMinScore — the note-recall confidence gate. Top cosine below this ⇒
// "I don't know" instead of a guess. Tuned for the ONNX embedder; a knob, not
@@ -282,8 +308,20 @@ type reactiveHandler struct {
// "выполнить X? да/нет" prompt and parks here; the NEXT utterance is read as
// the y/n answer. ponytail: single slot, single-user box — a second act
// while one waits overwrites it (last-asked wins); expires after confirmTTL.
mu sync.Mutex
pending *pendingAct
mu sync.Mutex
pending *pendingAct
pendingRoutine *pendingRoutineConfirm // routine proposal awaiting y/n
}
// pendingRoutineConfirm — a proposed routine awaiting a spoken y/n to become
// a recurring reminder. Set by detectPattern after creating a proposal.
type pendingRoutineConfirm struct {
routineID int64
action string
object string
interval float64
phrase string
expiry time.Time
}
// pendingAct — a destructive act awaiting a spoken confirm.
@@ -371,10 +409,15 @@ func (h *reactiveHandler) HandlePushToTalk(ctx context.Context, req voice.PushTo
}
history = append(history, prev.History[:maxHist]...)
}
ttl := time.Duration(0) // use default (2 min)
if dec.Intent == router.IntentChat {
ttl = 15 * time.Minute // conversational turns should last longer
}
h.dialogueSessions.Put(voiceDialogueID, &dialogue.Session{
Intent: dialogue.Intent(dec.Intent),
Slots: toDialogueSlots(dec.Slots),
Timestamp: now,
TTL: ttl,
History: history,
})
}
@@ -395,6 +438,70 @@ func (h *reactiveHandler) HandlePushToTalk(ctx context.Context, req voice.PushTo
return h.reply(ctx, replyText, nil)
}
// handleText — the core reactive path without stt/tts: confirm check →
// route → dialogue → action → replier. Used by the IPC Chat endpoint
// (and eventually by telegram). Splits out the audio bookends from
// HandlePushToTalk so text channels share the same routing logic.
func (h *reactiveHandler) handleText(ctx context.Context, text string) string {
// 1b. confirm turn — if a destructive act is parked, this utterance is its
// y/n answer. Same check as HandlePushToTalk.
if reply, handled := h.resolveConfirm(ctx, text); handled {
return reply
}
// 2. router — classify the utterance.
dec, err := h.router.Route(ctx, text, h.now())
if err != nil {
if errors.Is(err, router.ErrNoIntents) {
return "я ещё не понимаю свободную речь — скоро научусь."
}
log.Printf("voice: handleText router error: %v", err)
return "не получилось разобрать команду."
}
// 2b. dialogue — same as HandlePushToTalk.
if h.dialogueSessions != nil {
now := h.now()
prev := h.dialogueSessions.Get(voiceDialogueID, now)
dec = followUpMerge(prev, dec, now)
if !dec.Clarify {
var history []dialogue.Turn
if prev != nil {
history = append(history, dialogue.Turn{
Intent: prev.Intent,
Slots: prev.Slots,
Text: prev.Slots.Text,
})
maxHist := len(prev.History)
if maxHist > 3 {
maxHist = 3
}
history = append(history, prev.History[:maxHist]...)
}
ttl := time.Duration(0)
if dec.Intent == router.IntentChat {
ttl = 15 * time.Minute
}
h.dialogueSessions.Put(voiceDialogueID, &dialogue.Session{
Intent: dialogue.Intent(dec.Intent),
Slots: toDialogueSlots(dec.Slots),
Timestamp: now,
TTL: ttl,
History: history,
})
}
}
// 3. action — execute the decision's intent.
replyText := h.applyAction(ctx, dec)
// 4. replier — phrase the reply when applyAction returned "".
if replyText == "" {
replyText = h.replier.Reply(dec)
}
return replyText
}
// applyAction — executes the router's Decision. Intent-by-intent:
//
// - IntentFact: WriteFact via CoreAPI. Source = "tap:voice" (a voice
@@ -426,7 +533,8 @@ func (h *reactiveHandler) applyAction(ctx context.Context, dec router.Decision)
Source: "tap:voice",
Confidence: 1.0,
}
if _, err := h.api.WriteFact(ctx, req); err != nil {
factID, err := h.api.WriteFact(ctx, req)
if err != nil {
log.Printf("voice: write fact: %v", err)
return "не получилось сохранить факт."
}
@@ -445,6 +553,15 @@ func (h *reactiveHandler) applyAction(ctx context.Context, dec router.Decision)
log.Printf("voice: memory insert fact: %v", err)
}
}
// Event extraction + pattern detection (best-effort, must not fail the
// fact write). If the fact describes a recognizable action, it becomes a
// normalized event; if ≥3 events for the same action+object show stable
// intervals, a proposed routine is created and parked for confirmation.
if h.dataStore != nil {
if phrase := h.detectPattern(ctx, factID, dec.Slots.Key, dec.Slots.Value, now); phrase != "" {
return phrase // "ты заправляешь ... напоминать?"
}
}
return "" // replier phrases the success reply
case router.IntentReminder:
@@ -471,8 +588,15 @@ func (h *reactiveHandler) applyAction(ctx context.Context, dec router.Decision)
case router.IntentAct:
// tool executor: run the matched fn against the enabled allowlist.
// HasFn=false ⇒ no allowlist match: scaffold a 'proposed' tool the user
// can enable on the authed surface ("earn the right to ask").
// HasFn=false ⇒ try the matcher (for LLM-routed acts where the verb
// didn't go through the stage-0 act grammar).
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
}
}
// HasFn still false ⇒ no allowlist match: scaffold a 'proposed' tool
// the user can enable on the authed surface ("earn the right to ask").
if !dec.Slots.HasFn {
return h.proposeGap(ctx, dec)
}
@@ -498,9 +622,18 @@ func (h *reactiveHandler) applyAction(ctx context.Context, dec router.Decision)
}
return "готово."
case router.IntentChat:
// Conversational: build history from dialogue session (prior user turns)
// and let the LLM respond from general knowledge + context.
history := h.chatHistory()
reply, err := h.phraser.PhraseChat(ctx, dec.Utterance, history)
if err != nil {
log.Printf("voice: chat: %v", err)
return "поговорили."
}
return reply
case router.IntentSystem:
// System-status queries return to the Replier for phrasing.
// The handler emits the current answer inline (no DB / RAG needed).
return h.replySystem(ctx, dec)
case router.IntentNote:
@@ -633,6 +766,74 @@ func (h *reactiveHandler) applyAction(ctx context.Context, dec router.Decision)
return ""
}
// detectPattern extracts an event from the written fact and runs the pattern
// detector. If a stable recurring pattern is found and no proposed routine
// exists for this action+object yet, one is created and the user is prompted
// to confirm via the park() mechanism. Returns the suggestion phrase when a
// new proposal was created and parked; "" otherwise.
func (h *reactiveHandler) detectPattern(ctx context.Context, factID int64, key, value string, ts time.Time) string {
ev := pattern.Extract(factID, key, value, ts)
if ev == nil {
return "" // not an actionable event
}
if _, err := h.dataStore.CreateEvent(ctx, factID, ev.Action, ev.Object, ts); err != nil {
log.Printf("voice: create event: %v", err)
return ""
}
events, err := h.dataStore.EventsFor(ctx, ev.Action, ev.Object)
if err != nil {
log.Printf("voice: events for %s/%s: %v", ev.Action, ev.Object, err)
return ""
}
// Convert store.Events to pattern.Events for the detector.
patEvents := make([]pattern.Event, len(events))
for i, e := range events {
patEvents[i] = pattern.Event{
FactID: e.FactID,
Action: e.Action,
Object: e.Object,
Ts: e.Ts,
}
}
r, err := pattern.Detect(patEvents)
if err != nil {
log.Printf("voice: pattern detect: %v", err)
return ""
}
if r == nil {
return "" // not enough data or intervals too irregular
}
// Check if already proposed/accepted/dismissed for this pair.
existing, err := h.dataStore.LookupProposedRoutine(ctx, r.Action, r.Object)
if err != nil {
log.Printf("voice: lookup proposed routine: %v", err)
return ""
}
if existing != nil {
return "" // already proposed, accepted, or dismissed
}
id, err := h.dataStore.CreateProposedRoutine(ctx, r.Action, r.Object, r.IntervalDays, ts)
if err != nil {
log.Printf("voice: create proposed routine: %v", err)
return ""
}
log.Printf("voice: proposed routine: %s/%s every %.1f days", r.Action, r.Object, r.IntervalDays)
// Park the proposal for voice confirmation.
phrase := pattern.PhraseRoutine(r)
h.mu.Lock()
h.pendingRoutine = &pendingRoutineConfirm{
routineID: id,
action: r.Action,
object: r.Object,
interval: r.IntervalDays,
phrase: phrase,
expiry: ts.Add(confirmTTL),
}
h.mu.Unlock()
return phrase
}
var ruWeekdays = []string{
"воскресенье", "понедельник", "вторник", "среда",
"четверг", "пятница", "суббота",
@@ -742,6 +943,30 @@ func (h *reactiveHandler) replySystem(ctx context.Context, dec router.Decision)
}
}
// chatHistory collects dialogue turns from the session store for the current
// conversation. Returns prior user utterances (newest last) up to a depth of
// 4 turns. Returns nil when there's no session or no history.
func (h *reactiveHandler) chatHistory() []dialogue.Turn {
if h.dialogueSessions == nil {
return nil
}
now := h.now()
prev := h.dialogueSessions.Get(voiceDialogueID, now)
if prev == nil {
return nil
}
// History already includes the immediate prior turn (set by the dialogue
// merge at lines 373-395), plus up to 3 more from deeper history.
out := make([]dialogue.Turn, 0, 1+len(prev.History))
out = append(out, dialogue.Turn{
Intent: prev.Intent,
Slots: prev.Slots,
Text: prev.Slots.Text,
})
out = append(out, prev.History...)
return out
}
// hasDurationWords checks whether u is asking about elapsed/remaining time
// rather than the current clock — guards replySystem from replying "сейчас
// X часов" to "сколько времени прошло". Mirrors the stage0.go build filter.
@@ -772,8 +997,9 @@ func hasDurationWords(u string) bool {
// reply (today the reactive path doesn't dispatch nudges; that's the loop
// tick's job).
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, 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
@@ -793,7 +1019,7 @@ func (h *reactiveHandler) reply(ctx context.Context, text string, _ []string) (v
// placeholder. Spec calls for ~10 per intent at production; this is the
// bootstrapping floor swapped by tuning the seed set later.
// - Threshold is from voice.router_threshold config (default 0.55).
func buildRouter(emb router.Embedder, acts router.ActMatcher, threshold float64) *router.Router {
func buildRouter(emb router.Embedder, acts router.ActMatcher, threshold float64, llmR *router.LLMRouter) *router.Router {
cls := router.NewClassifier(emb)
seedClassifier(cls)
grammars := router.DefaultGrammars(acts)
@@ -808,6 +1034,7 @@ func buildRouter(emb router.Embedder, acts router.ActMatcher, threshold float64)
Facts: router.DefaultFactParser{},
},
Threshold: threshold,
LLM: llmR,
})
}
@@ -828,6 +1055,7 @@ func seedClassifier(c *router.Classifier) {
router.IntentFact,
router.IntentNote,
router.IntentQuery,
router.IntentChat,
router.IntentSystem,
}
total := 0
@@ -886,27 +1114,70 @@ func (h *reactiveHandler) park(fn string, args []string, phrase string) {
}
// resolveConfirm interprets an utterance as the answer to a parked destructive
// act. Returns (reply, true) when it consumed the utterance as a y/n answer;
// (\"\", false) when there's nothing pending (or the parked act expired), so the
// caller routes the utterance normally. An unrecognised answer cancels the
// pending act and routes normally — a confirm that can't be answered clearly is
// safer abandoned than left armed.
// act OR a parked routine proposal. Returns (reply, true) when it consumed the
// utterance as a y/n answer; ("", false) when there's nothing pending (or the
// parked act expired), so the caller routes the utterance normally. An
// unrecognised answer cancels the pending and routes normally — a confirm that
// can't be answered clearly is safer abandoned than left armed.
func (h *reactiveHandler) resolveConfirm(ctx context.Context, text string) (string, bool) {
h.mu.Lock()
defer h.mu.Unlock()
// Check routine proposal first (newer feature; checked before tool confirm
// so a routine confirm doesn't get eaten by a stale tool pending).
pr := h.pendingRoutine
if pr != nil && !h.now().After(pr.expiry) {
switch classifyConfirm(text) {
case confirmYes:
h.pendingRoutine = nil
// Create a recurring reminder at the detected interval.
// Weekly patterns get a cron expression; arbitrary intervals
// fire once and the detector re-proposes on the next cycle.
intervalDur := time.Duration(pr.interval * 24 * float64(time.Hour))
fire := h.now().Add(intervalDur)
cron := ""
if pr.interval >= 6.5 && pr.interval <= 7.5 {
cron = fmt.Sprintf("0 %d * * %d", fire.Hour(), int(fire.Weekday()))
}
payload := fmt.Sprintf(`{"text":"%s %s"}`, pr.action, pr.object)
remID, err := h.api.CreateReminder(ctx, fire, payload, cron)
if err != nil {
log.Printf("voice: create routine reminder: %v", err)
return "не получилось поставить напоминание.", true
}
if err := h.dataStore.AcceptProposedRoutine(ctx, pr.routineID, remID); err != nil {
log.Printf("voice: accept proposed routine: %v", err)
}
return "буду напоминать.", true
case confirmNo:
h.pendingRoutine = nil
if err := h.dataStore.DismissProposedRoutine(ctx, pr.routineID); err != nil {
log.Printf("voice: dismiss proposed routine: %v", err)
}
return "хорошо, не буду.", true
default:
// unclear: abandon the routine proposal, route normally.
h.pendingRoutine = nil
return "", false
}
}
// Clear expired routine if it existed.
if pr != nil {
h.pendingRoutine = nil
}
// Check tool confirm (existing behavior).
p := h.pending
if p == nil {
h.mu.Unlock()
return "", false
}
if h.now().After(p.expiry) {
h.pending = nil
h.mu.Unlock()
return "", false
}
switch classifyConfirm(text) {
case confirmYes:
h.pending = nil
h.mu.Unlock()
out, err := h.tools.Exec(ctx, p.fn, p.args, true) // confirmed
if err != nil {
log.Printf("voice: tool %s (confirmed): %v", p.fn, err)
@@ -921,12 +1192,10 @@ func (h *reactiveHandler) resolveConfirm(ctx context.Context, text string) (stri
return "готово.", true
case confirmNo:
h.pending = nil
h.mu.Unlock()
return "отменила.", true
default:
// unclear answer: abandon the confirm, route this utterance normally.
h.pending = nil
h.mu.Unlock()
return "", false
}
}
@@ -986,15 +1255,14 @@ func actPhrase(fn string, args []string) string {
return fn + " " + strings.Join(args, " ")
}
// stripWake removes a leading "maven," wake token so the verb is the first word.
// stripWake removes a leading wake token (any script the STT phonetically
// transcribes "Maven" as) so the verb is the first word.
func stripWake(u string) string {
u = strings.TrimSpace(u)
low := strings.ToLower(u)
if strings.HasPrefix(low, "maven") {
u = strings.TrimSpace(u[len("maven"):])
u = strings.TrimLeft(u, ",:; ")
stripped, had := router.StripWakeToken(u)
if !had {
return strings.TrimSpace(u)
}
return u
return stripped
}
// firstWord returns the first whitespace-delimited token (lowercased) — the
+85
View File
@@ -0,0 +1,85 @@
// 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
// RepeatPenalty > 0 ⇒ penalize token repetition (curbs the sub-1B "тоже
// тоже тоже" loop). 0 ⇒ server default (no extra penalty).
RepeatPenalty float64
// Stop — sequences that end generation early (e.g. newline for a one-liner).
Stop []string
}
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"`
RepeatPenalty float64 `json:"repeat_penalty,omitempty"`
Stop []string `json:"stop,omitempty"`
}
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,
RepeatPenalty: r.RepeatPenalty,
Stop: r.Stop,
})
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
}
+59
View File
@@ -0,0 +1,59 @@
package llm
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestComplete(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
t.Errorf("method = %q, want POST", r.Method)
}
if !strings.HasSuffix(r.URL.Path, "/v1/chat/completions") {
t.Errorf("path = %q, want /v1/chat/completions", r.URL.Path)
}
var reqBody struct {
Messages []struct {
Role string `json:"role"`
Content string `json:"content"`
} `json:"messages"`
Grammar string `json:"grammar"`
MaxTokens int `json:"max_tokens"`
}
if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil {
t.Fatalf("decode request body: %v", err)
}
if len(reqBody.Messages) < 2 {
t.Fatalf("expected at least 2 messages, got %d", len(reqBody.Messages))
}
if reqBody.Messages[0].Role != "system" || reqBody.Messages[1].Content != "hi" {
t.Errorf("unexpected messages: %+v", reqBody.Messages)
}
if reqBody.Grammar != `root ::= "x"` {
t.Errorf("grammar = %q, want root ::= \"x\"", reqBody.Grammar)
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"choices":[{"message":{"content":"ok"}}]}`))
}))
defer srv.Close()
c := New(srv.URL, 5*time.Second)
got, err := c.Complete(context.Background(), Req{
System: "be helpful",
User: "hi",
Grammar: `root ::= "x"`,
MaxTokens: 42,
})
if err != nil {
t.Fatalf("Complete: %v", err)
}
if got != "ok" {
t.Errorf("got %q, want %q", got, "ok")
}
}
+81
View File
@@ -15,6 +15,7 @@ import (
"time"
"github.com/kami/maven/internal/delivery"
"github.com/kami/maven/internal/dialogue"
"github.com/kami/maven/internal/loop"
"github.com/kami/maven/internal/router"
)
@@ -142,6 +143,8 @@ func (p *LLMPhraser) start(ctx context.Context) error {
}
}
func (p *LLMPhraser) BaseURL() string { return p.port }
func (p *LLMPhraser) Close() error {
p.cancel()
if p.cmd != nil && p.cmd.Process != nil {
@@ -201,6 +204,84 @@ func (p *LLMPhraser) PhraseQuery(ctx context.Context, utterance string, notes []
return resp, nil
}
// PhraseChat uses the LLM to respond conversationally, building a multi-turn
// message array from dialogue history + the current user utterance. Falls back
// to a simple greeting on any LLM error — better to say something than nothing.
func (p *LLMPhraser) PhraseChat(ctx context.Context, utterance string, history []dialogue.Turn) (string, error) {
sys := chatSystemPrompt(p.cfg.Persona)
msgs := []chatMsg{
{Role: "system", Content: sys},
}
// Append dialogue history: user turns become "user" messages, and since we
// don't store assistant replies in the dialogue history, we reconstruct the
// pattern as alternating user messages. The model can infer maven's presence.
for _, t := range history {
msgs = append(msgs, chatMsg{Role: "user", Content: t.Text})
}
// Current utterance as the final user message.
msgs = append(msgs, chatMsg{Role: "user", Content: utterance})
resp, err := p.chatWithMessages(ctx, msgs, 512)
if err != nil {
return "поговорили.", nil
}
return resp, nil
}
// chatSystemPrompt returns the system prompt for conversational chat.
// Prepends the configured persona when set.
func chatSystemPrompt(persona string) string {
base := `You are maven, a self-hosted personal assistant. You're talking with your owner.
Keep replies brief (1-3 sentences) and natural. You're helpful, curious, and a little warm.
Respond in the user's language (Russian or English, matching their last message).
Never roleplay emotions you don't have, but stay friendly.
Just answer directly — no JSON wrapper, no meta-commentary.`
if persona != "" {
base = persona + "\n\n" + base
}
return base
}
// chatWithMessages sends a full message array (system + history + current) to
// the LLM completion endpoint. Like chatWithSystem but for an arbitrary message
// slice — the caller owns the system prompt placement.
func (p *LLMPhraser) chatWithMessages(ctx context.Context, msgs []chatMsg, maxTokens int) (string, error) {
req := chatReq{
Messages: msgs,
Temperature: 0.7,
MaxTokens: maxTokens,
}
body, err := json.Marshal(req)
if err != nil {
return "", fmt.Errorf("llm: marshal: %w", err)
}
httpReq, err := http.NewRequestWithContext(ctx, "POST", p.port+"/v1/chat/completions", bytes.NewReader(body))
if err != nil {
return "", fmt.Errorf("llm: request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
resp, err := p.client.Do(httpReq)
if err != nil {
return "", fmt.Errorf("llm: post: %w", err)
}
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("llm: read: %w", err)
}
if resp.StatusCode != 200 {
return "", fmt.Errorf("llm: status %d: %s", resp.StatusCode, strings.TrimSpace(string(raw)))
}
var cr chatResp
if err := json.Unmarshal(raw, &cr); err != nil {
return "", fmt.Errorf("llm: parse: %w", err)
}
if len(cr.Choices) == 0 {
return "", fmt.Errorf("llm: no choices in response")
}
return cr.Choices[0].Message.Content, nil
}
func (p *LLMPhraser) PhraseReminder(ctx context.Context, d loop.ReminderDecision) (delivery.PhrasedReminder, error) {
text := extractReminderText(d.Reminder.Payload)
if text == "" {
+12 -2
View File
@@ -27,6 +27,7 @@ import (
"time"
"github.com/kami/maven/internal/delivery"
"github.com/kami/maven/internal/dialogue"
"github.com/kami/maven/internal/loop"
)
@@ -38,12 +39,14 @@ import (
// the daemon calls PhraseNudge with the loop's *Candidate (Rule + Severity +
// the State snapshot at evaluation time — exactly the (rule, severity,
// context) input the spec names). PhraseReminder with the ReminderDecision
// (Reminder + State). the phraser reads the State for context ("you haven't
// had water in 4h, you're at your desk, it's 2pm") — never touches the store.
// (Reminder + State). PhraseChat with a conversational utterance + dialogue
// history. the phraser reads the State for context ("you haven't had water in
// 4h, you're at your desk, it's 2pm") — never touches the store.
type Phraser interface {
PhraseNudge(ctx context.Context, c loop.Candidate) (delivery.PhrasedNudge, error)
PhraseReminder(ctx context.Context, d loop.ReminderDecision) (delivery.PhrasedReminder, error)
PhraseQuery(ctx context.Context, utterance string, notes []string) (string, error)
PhraseChat(ctx context.Context, utterance string, history []dialogue.Turn) (string, error)
Close() error
}
@@ -60,6 +63,13 @@ type Stub struct{}
// NewStub builds the floor phraser. no config — the Stub is stateless.
func NewStub() *Stub { return &Stub{} }
// PhraseChat returns a stub reply — the LLMPhraser replaces this with a
// prompted response from the model. The history parameter is accepted but
// ignored at the stub level (the production impl uses it for multi-turn).
func (s *Stub) PhraseChat(_ context.Context, _ string, _ []dialogue.Turn) (string, error) {
return "поговорили.", nil
}
// PhraseQuery returns a deterministic summary of the best matching notes.
func (s *Stub) PhraseQuery(_ context.Context, _ string, notes []string) (string, error) {
if len(notes) == 0 {
+32
View File
@@ -7,6 +7,7 @@ import (
"time"
"github.com/kami/maven/internal/delivery"
"github.com/kami/maven/internal/dialogue"
"github.com/kami/maven/internal/loop"
"github.com/kami/maven/internal/store"
)
@@ -192,6 +193,34 @@ func TestPhraseReminderEmptyPayload(t *testing.T) {
}
}
// ----------------------------- chat -----------------------------------------
func TestPhraseChatReturnsNonEmpty(t *testing.T) {
p := NewStub()
reply, err := p.PhraseChat(context.Background(), "как дела", nil)
if err != nil {
t.Fatalf("PhraseChat: %v", err)
}
if reply == "" {
t.Fatal("PhraseChat returned empty reply")
}
}
func TestPhraseChatWithHistory(t *testing.T) {
p := NewStub()
history := []dialogue.Turn{
{Text: "привет", Intent: dialogue.IntentChat},
{Text: "как тебя зовут", Intent: dialogue.IntentChat},
}
reply, err := p.PhraseChat(context.Background(), "расскажи о себе", history)
if err != nil {
t.Fatalf("PhraseChat with history: %v", err)
}
if reply == "" {
t.Fatal("PhraseChat with history returned empty reply")
}
}
// ----------------------------- interface guard ------------------------------
func TestStubSatisfiesPhraser(t *testing.T) {
@@ -206,6 +235,9 @@ func TestStubSatisfiesPhraser(t *testing.T) {
if _, err := p.PhraseReminder(context.Background(), loop.ReminderDecision{Reminder: store.Reminder{Payload: "{}"}, State: loop.State{Now: time.Now().UTC()}}); err != nil {
t.Fatalf("PhraseReminder: %v", err)
}
if _, err := p.PhraseChat(context.Background(), "как дела", nil); err != nil {
t.Fatalf("PhraseChat: %v", err)
}
}
func TestStubProducesDeliveryTypes(t *testing.T) {