From c0c11cd36d983ab3788e566c9a224baa95dfe0cf Mon Sep 17 00:00:00 2001 From: kami Date: Fri, 10 Jul 2026 15:48:55 +0400 Subject: [PATCH] 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. --- cmd/mavend/replier_llm.go | 78 ++++++++ cmd/mavend/replier_llm_test.go | 58 ++++++ cmd/mavend/voice.go | 328 ++++++++++++++++++++++++++++--- internal/llm/client.go | 85 ++++++++ internal/llm/client_test.go | 59 ++++++ internal/phraser/llmphraser.go | 81 ++++++++ internal/phraser/phraser.go | 14 +- internal/phraser/phraser_test.go | 32 +++ 8 files changed, 703 insertions(+), 32 deletions(-) create mode 100644 cmd/mavend/replier_llm.go create mode 100644 cmd/mavend/replier_llm_test.go create mode 100644 internal/llm/client.go create mode 100644 internal/llm/client_test.go diff --git a/cmd/mavend/replier_llm.go b/cmd/mavend/replier_llm.go new file mode 100644 index 0000000..a29c013 --- /dev/null +++ b/cmd/mavend/replier_llm.go @@ -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 + } +} diff --git a/cmd/mavend/replier_llm_test.go b/cmd/mavend/replier_llm_test.go new file mode 100644 index 0000000..11d9121 --- /dev/null +++ b/cmd/mavend/replier_llm_test.go @@ -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) } diff --git a/cmd/mavend/voice.go b/cmd/mavend/voice.go index 93ae70f..d4c8a4f 100644 --- a/cmd/mavend/voice.go +++ b/cmd/mavend/voice.go @@ -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 diff --git a/internal/llm/client.go b/internal/llm/client.go new file mode 100644 index 0000000..ec19e2d --- /dev/null +++ b/internal/llm/client.go @@ -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 +} diff --git a/internal/llm/client_test.go b/internal/llm/client_test.go new file mode 100644 index 0000000..9353442 --- /dev/null +++ b/internal/llm/client_test.go @@ -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") + } +} diff --git a/internal/phraser/llmphraser.go b/internal/phraser/llmphraser.go index 4bf9c47..c79d898 100644 --- a/internal/phraser/llmphraser.go +++ b/internal/phraser/llmphraser.go @@ -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 == "" { diff --git a/internal/phraser/phraser.go b/internal/phraser/phraser.go index d3f1520..05ba026 100644 --- a/internal/phraser/phraser.go +++ b/internal/phraser/phraser.go @@ -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 { diff --git a/internal/phraser/phraser_test.go b/internal/phraser/phraser_test.go index 5097328..23cddfc 100644 --- a/internal/phraser/phraser_test.go +++ b/internal/phraser/phraser_test.go @@ -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) {