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:
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -61,12 +61,16 @@ import (
|
|||||||
"github.com/kami/maven/internal/delivery/voicesink"
|
"github.com/kami/maven/internal/delivery/voicesink"
|
||||||
"github.com/kami/maven/internal/dialogue"
|
"github.com/kami/maven/internal/dialogue"
|
||||||
"github.com/kami/maven/internal/ipc"
|
"github.com/kami/maven/internal/ipc"
|
||||||
|
"github.com/kami/maven/internal/llm"
|
||||||
"github.com/kami/maven/internal/memory"
|
"github.com/kami/maven/internal/memory"
|
||||||
|
"github.com/kami/maven/internal/pattern"
|
||||||
"github.com/kami/maven/internal/phraser"
|
"github.com/kami/maven/internal/phraser"
|
||||||
"github.com/kami/maven/internal/router"
|
"github.com/kami/maven/internal/router"
|
||||||
|
"github.com/kami/maven/internal/store"
|
||||||
"github.com/kami/maven/internal/stt"
|
"github.com/kami/maven/internal/stt"
|
||||||
"github.com/kami/maven/internal/tool"
|
"github.com/kami/maven/internal/tool"
|
||||||
"github.com/kami/maven/internal/tts"
|
"github.com/kami/maven/internal/tts"
|
||||||
|
"github.com/kami/maven/internal/ttsnorm"
|
||||||
"github.com/kami/maven/internal/voice"
|
"github.com/kami/maven/internal/voice"
|
||||||
"github.com/kami/maven/internal/weather"
|
"github.com/kami/maven/internal/weather"
|
||||||
"github.com/kami/maven/internal/worker"
|
"github.com/kami/maven/internal/worker"
|
||||||
@@ -79,6 +83,7 @@ type voiceWiring struct {
|
|||||||
sessions *voice.Sessions
|
sessions *voice.Sessions
|
||||||
voiceSink delivery.Sink
|
voiceSink delivery.Sink
|
||||||
embedder router.Embedder
|
embedder router.Embedder
|
||||||
|
handler *reactiveHandler // the reactive handler for IPC Chat
|
||||||
// worker clients (set when configured as Remote): closed on shutdown so
|
// worker clients (set when configured as Remote): closed on shutdown so
|
||||||
// mavsttd / mavttsd don't keep a stale conn into a restarting daemon.
|
// mavsttd / mavttsd don't keep a stale conn into a restarting daemon.
|
||||||
sttClient *worker.Client
|
sttClient *worker.Client
|
||||||
@@ -111,7 +116,7 @@ func (w *voiceWiring) close() {
|
|||||||
//
|
//
|
||||||
// When voice is enabled, MUST wire a voicesink into the dispatcher's Voice
|
// When voice is enabled, MUST wire a voicesink into the dispatcher's Voice
|
||||||
// slot using w.sessions (the caller does that — see main.go).
|
// 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 {
|
if cfg.Voice == nil || !cfg.Voice.Enabled {
|
||||||
return nil, nil
|
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)")
|
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) -----
|
// ----- router (the cascade; floor examples seed the classifier) -----
|
||||||
// The act matcher's allowlist is exactly the enabled tool names — the
|
// The act matcher's allowlist is exactly the enabled tool names — the
|
||||||
// router only matches acts the executor can run (one source of truth).
|
// 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 {
|
if threshold <= 0 {
|
||||||
threshold = config.DefaultRouterThreshold
|
threshold = config.DefaultRouterThreshold
|
||||||
}
|
}
|
||||||
rtr := buildRouter(emb, matcher, threshold)
|
rtr := buildRouter(emb, matcher, threshold, llmRouter)
|
||||||
|
|
||||||
// ----- sessions registry (shared with voicesink) -----
|
// ----- sessions registry (shared with voicesink) -----
|
||||||
sessions := voice.NewSessions()
|
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) -----
|
// ----- dialogue (multi-turn slot carry-over; 2-min follow-up window) -----
|
||||||
dialogueSessions := dialogue.NewSessionStore(2 * time.Minute)
|
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) -----
|
// ----- the handler (the reactive path; closes over stt / tts / router / coreAPI / memory) -----
|
||||||
h := &reactiveHandler{
|
h := &reactiveHandler{
|
||||||
stt: transcriber,
|
stt: transcriber,
|
||||||
@@ -221,12 +242,14 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem
|
|||||||
embedder: emb,
|
embedder: emb,
|
||||||
api: coreAPI,
|
api: coreAPI,
|
||||||
tools: exec,
|
tools: exec,
|
||||||
|
matcher: matcher,
|
||||||
|
replier: replier,
|
||||||
phraser: phr,
|
phraser: phr,
|
||||||
replier: voice.NewStubReplier(),
|
|
||||||
now: time.Now,
|
now: time.Now,
|
||||||
weatherProvider: weatherProvider,
|
weatherProvider: weatherProvider,
|
||||||
weatherLocation: weatherLocation,
|
weatherLocation: weatherLocation,
|
||||||
memStore: memStore,
|
memStore: memStore,
|
||||||
|
dataStore: dataStore,
|
||||||
dialogueSessions: dialogueSessions,
|
dialogueSessions: dialogueSessions,
|
||||||
queryMinScore: cfg.Voice.QueryMinScore,
|
queryMinScore: cfg.Voice.QueryMinScore,
|
||||||
timeParser: router.NewPythonDateParser(),
|
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)
|
return nil, fmt.Errorf("voice listen: %w", err)
|
||||||
}
|
}
|
||||||
w.server = srv
|
w.server = srv
|
||||||
|
w.handler = h
|
||||||
|
|
||||||
return w, nil
|
return w, nil
|
||||||
}
|
}
|
||||||
@@ -254,6 +278,7 @@ type reactiveHandler struct {
|
|||||||
embedder router.Embedder // reused for note write/query (same model as the classifier)
|
embedder router.Embedder // reused for note write/query (same model as the classifier)
|
||||||
api ipc.CoreAPI
|
api ipc.CoreAPI
|
||||||
tools *tool.Executor
|
tools *tool.Executor
|
||||||
|
matcher *tool.Matcher
|
||||||
phraser phraser.Phraser
|
phraser phraser.Phraser
|
||||||
replier voice.Replier
|
replier voice.Replier
|
||||||
now func() time.Time
|
now func() time.Time
|
||||||
@@ -261,7 +286,8 @@ type reactiveHandler struct {
|
|||||||
weatherProvider weather.Provider
|
weatherProvider weather.Provider
|
||||||
weatherLocation string // default location for weather queries
|
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 ⇒
|
// 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
|
// "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
|
// "выполнить X? да/нет" prompt and parks here; the NEXT utterance is read as
|
||||||
// the y/n answer. ponytail: single slot, single-user box — a second act
|
// the y/n answer. ponytail: single slot, single-user box — a second act
|
||||||
// while one waits overwrites it (last-asked wins); expires after confirmTTL.
|
// while one waits overwrites it (last-asked wins); expires after confirmTTL.
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
pending *pendingAct
|
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.
|
// 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]...)
|
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{
|
h.dialogueSessions.Put(voiceDialogueID, &dialogue.Session{
|
||||||
Intent: dialogue.Intent(dec.Intent),
|
Intent: dialogue.Intent(dec.Intent),
|
||||||
Slots: toDialogueSlots(dec.Slots),
|
Slots: toDialogueSlots(dec.Slots),
|
||||||
Timestamp: now,
|
Timestamp: now,
|
||||||
|
TTL: ttl,
|
||||||
History: history,
|
History: history,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -395,6 +438,70 @@ func (h *reactiveHandler) HandlePushToTalk(ctx context.Context, req voice.PushTo
|
|||||||
return h.reply(ctx, replyText, nil)
|
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:
|
// applyAction — executes the router's Decision. Intent-by-intent:
|
||||||
//
|
//
|
||||||
// - IntentFact: WriteFact via CoreAPI. Source = "tap:voice" (a voice
|
// - 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",
|
Source: "tap:voice",
|
||||||
Confidence: 1.0,
|
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)
|
log.Printf("voice: write fact: %v", err)
|
||||||
return "не получилось сохранить факт."
|
return "не получилось сохранить факт."
|
||||||
}
|
}
|
||||||
@@ -445,6 +553,15 @@ func (h *reactiveHandler) applyAction(ctx context.Context, dec router.Decision)
|
|||||||
log.Printf("voice: memory insert fact: %v", err)
|
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
|
return "" // replier phrases the success reply
|
||||||
|
|
||||||
case router.IntentReminder:
|
case router.IntentReminder:
|
||||||
@@ -471,8 +588,15 @@ func (h *reactiveHandler) applyAction(ctx context.Context, dec router.Decision)
|
|||||||
|
|
||||||
case router.IntentAct:
|
case router.IntentAct:
|
||||||
// tool executor: run the matched fn against the enabled allowlist.
|
// tool executor: run the matched fn against the enabled allowlist.
|
||||||
// HasFn=false ⇒ no allowlist match: scaffold a 'proposed' tool the user
|
// HasFn=false ⇒ try the matcher (for LLM-routed acts where the verb
|
||||||
// can enable on the authed surface ("earn the right to ask").
|
// 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 {
|
if !dec.Slots.HasFn {
|
||||||
return h.proposeGap(ctx, dec)
|
return h.proposeGap(ctx, dec)
|
||||||
}
|
}
|
||||||
@@ -498,9 +622,18 @@ func (h *reactiveHandler) applyAction(ctx context.Context, dec router.Decision)
|
|||||||
}
|
}
|
||||||
return "готово."
|
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:
|
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)
|
return h.replySystem(ctx, dec)
|
||||||
|
|
||||||
case router.IntentNote:
|
case router.IntentNote:
|
||||||
@@ -633,6 +766,74 @@ func (h *reactiveHandler) applyAction(ctx context.Context, dec router.Decision)
|
|||||||
return ""
|
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{
|
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
|
// hasDurationWords checks whether u is asking about elapsed/remaining time
|
||||||
// rather than the current clock — guards replySystem from replying "сейчас
|
// rather than the current clock — guards replySystem from replying "сейчас
|
||||||
// X часов" to "сколько времени прошло". Mirrors the stage0.go build filter.
|
// 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
|
// reply (today the reactive path doesn't dispatch nudges; that's the loop
|
||||||
// tick's job).
|
// tick's job).
|
||||||
func (h *reactiveHandler) reply(ctx context.Context, text string, _ []string) (voice.PushToTalkResp, error) {
|
func (h *reactiveHandler) reply(ctx context.Context, text string, _ []string) (voice.PushToTalkResp, error) {
|
||||||
|
spoken := ttsnorm.Speakable(text)
|
||||||
log.Printf("voice: reply → %q", text)
|
log.Printf("voice: reply → %q", text)
|
||||||
audioOut, err := h.tts.Synthesize(ctx, text)
|
audioOut, err := h.tts.Synthesize(ctx, spoken)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("voice: tts error: %v", err)
|
log.Printf("voice: tts error: %v", err)
|
||||||
return voice.PushToTalkResp{ReplyText: text, ReplyAudio: audio.Audio{}}, nil
|
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
|
// placeholder. Spec calls for ~10 per intent at production; this is the
|
||||||
// bootstrapping floor swapped by tuning the seed set later.
|
// bootstrapping floor swapped by tuning the seed set later.
|
||||||
// - Threshold is from voice.router_threshold config (default 0.55).
|
// - 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)
|
cls := router.NewClassifier(emb)
|
||||||
seedClassifier(cls)
|
seedClassifier(cls)
|
||||||
grammars := router.DefaultGrammars(acts)
|
grammars := router.DefaultGrammars(acts)
|
||||||
@@ -808,6 +1034,7 @@ func buildRouter(emb router.Embedder, acts router.ActMatcher, threshold float64)
|
|||||||
Facts: router.DefaultFactParser{},
|
Facts: router.DefaultFactParser{},
|
||||||
},
|
},
|
||||||
Threshold: threshold,
|
Threshold: threshold,
|
||||||
|
LLM: llmR,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -828,6 +1055,7 @@ func seedClassifier(c *router.Classifier) {
|
|||||||
router.IntentFact,
|
router.IntentFact,
|
||||||
router.IntentNote,
|
router.IntentNote,
|
||||||
router.IntentQuery,
|
router.IntentQuery,
|
||||||
|
router.IntentChat,
|
||||||
router.IntentSystem,
|
router.IntentSystem,
|
||||||
}
|
}
|
||||||
total := 0
|
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
|
// 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;
|
// act OR a parked routine proposal. Returns (reply, true) when it consumed the
|
||||||
// (\"\", false) when there's nothing pending (or the parked act expired), so the
|
// utterance as a y/n answer; ("", false) when there's nothing pending (or the
|
||||||
// caller routes the utterance normally. An unrecognised answer cancels the
|
// parked act expired), so the caller routes the utterance normally. An
|
||||||
// pending act and routes normally — a confirm that can't be answered clearly is
|
// unrecognised answer cancels the pending and routes normally — a confirm that
|
||||||
// safer abandoned than left armed.
|
// can't be answered clearly is safer abandoned than left armed.
|
||||||
func (h *reactiveHandler) resolveConfirm(ctx context.Context, text string) (string, bool) {
|
func (h *reactiveHandler) resolveConfirm(ctx context.Context, text string) (string, bool) {
|
||||||
h.mu.Lock()
|
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
|
p := h.pending
|
||||||
if p == nil {
|
if p == nil {
|
||||||
h.mu.Unlock()
|
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
if h.now().After(p.expiry) {
|
if h.now().After(p.expiry) {
|
||||||
h.pending = nil
|
h.pending = nil
|
||||||
h.mu.Unlock()
|
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
switch classifyConfirm(text) {
|
switch classifyConfirm(text) {
|
||||||
case confirmYes:
|
case confirmYes:
|
||||||
h.pending = nil
|
h.pending = nil
|
||||||
h.mu.Unlock()
|
|
||||||
out, err := h.tools.Exec(ctx, p.fn, p.args, true) // confirmed
|
out, err := h.tools.Exec(ctx, p.fn, p.args, true) // confirmed
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("voice: tool %s (confirmed): %v", p.fn, err)
|
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
|
return "готово.", true
|
||||||
case confirmNo:
|
case confirmNo:
|
||||||
h.pending = nil
|
h.pending = nil
|
||||||
h.mu.Unlock()
|
|
||||||
return "отменила.", true
|
return "отменила.", true
|
||||||
default:
|
default:
|
||||||
// unclear answer: abandon the confirm, route this utterance normally.
|
// unclear answer: abandon the confirm, route this utterance normally.
|
||||||
h.pending = nil
|
h.pending = nil
|
||||||
h.mu.Unlock()
|
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -986,15 +1255,14 @@ func actPhrase(fn string, args []string) string {
|
|||||||
return fn + " " + strings.Join(args, " ")
|
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 {
|
func stripWake(u string) string {
|
||||||
u = strings.TrimSpace(u)
|
stripped, had := router.StripWakeToken(u)
|
||||||
low := strings.ToLower(u)
|
if !had {
|
||||||
if strings.HasPrefix(low, "maven") {
|
return strings.TrimSpace(u)
|
||||||
u = strings.TrimSpace(u[len("maven"):])
|
|
||||||
u = strings.TrimLeft(u, ",:; ")
|
|
||||||
}
|
}
|
||||||
return u
|
return stripped
|
||||||
}
|
}
|
||||||
|
|
||||||
// firstWord returns the first whitespace-delimited token (lowercased) — the
|
// firstWord returns the first whitespace-delimited token (lowercased) — the
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/kami/maven/internal/delivery"
|
"github.com/kami/maven/internal/delivery"
|
||||||
|
"github.com/kami/maven/internal/dialogue"
|
||||||
"github.com/kami/maven/internal/loop"
|
"github.com/kami/maven/internal/loop"
|
||||||
"github.com/kami/maven/internal/router"
|
"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 {
|
func (p *LLMPhraser) Close() error {
|
||||||
p.cancel()
|
p.cancel()
|
||||||
if p.cmd != nil && p.cmd.Process != nil {
|
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
|
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) {
|
func (p *LLMPhraser) PhraseReminder(ctx context.Context, d loop.ReminderDecision) (delivery.PhrasedReminder, error) {
|
||||||
text := extractReminderText(d.Reminder.Payload)
|
text := extractReminderText(d.Reminder.Payload)
|
||||||
if text == "" {
|
if text == "" {
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/kami/maven/internal/delivery"
|
"github.com/kami/maven/internal/delivery"
|
||||||
|
"github.com/kami/maven/internal/dialogue"
|
||||||
"github.com/kami/maven/internal/loop"
|
"github.com/kami/maven/internal/loop"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -38,12 +39,14 @@ import (
|
|||||||
// the daemon calls PhraseNudge with the loop's *Candidate (Rule + Severity +
|
// the daemon calls PhraseNudge with the loop's *Candidate (Rule + Severity +
|
||||||
// the State snapshot at evaluation time — exactly the (rule, severity,
|
// the State snapshot at evaluation time — exactly the (rule, severity,
|
||||||
// context) input the spec names). PhraseReminder with the ReminderDecision
|
// context) input the spec names). PhraseReminder with the ReminderDecision
|
||||||
// (Reminder + State). the phraser reads the State for context ("you haven't
|
// (Reminder + State). PhraseChat with a conversational utterance + dialogue
|
||||||
// had water in 4h, you're at your desk, it's 2pm") — never touches the store.
|
// 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 {
|
type Phraser interface {
|
||||||
PhraseNudge(ctx context.Context, c loop.Candidate) (delivery.PhrasedNudge, error)
|
PhraseNudge(ctx context.Context, c loop.Candidate) (delivery.PhrasedNudge, error)
|
||||||
PhraseReminder(ctx context.Context, d loop.ReminderDecision) (delivery.PhrasedReminder, error)
|
PhraseReminder(ctx context.Context, d loop.ReminderDecision) (delivery.PhrasedReminder, error)
|
||||||
PhraseQuery(ctx context.Context, utterance string, notes []string) (string, error)
|
PhraseQuery(ctx context.Context, utterance string, notes []string) (string, error)
|
||||||
|
PhraseChat(ctx context.Context, utterance string, history []dialogue.Turn) (string, error)
|
||||||
Close() error
|
Close() error
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,6 +63,13 @@ type Stub struct{}
|
|||||||
// NewStub builds the floor phraser. no config — the Stub is stateless.
|
// NewStub builds the floor phraser. no config — the Stub is stateless.
|
||||||
func NewStub() *Stub { return &Stub{} }
|
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.
|
// PhraseQuery returns a deterministic summary of the best matching notes.
|
||||||
func (s *Stub) PhraseQuery(_ context.Context, _ string, notes []string) (string, error) {
|
func (s *Stub) PhraseQuery(_ context.Context, _ string, notes []string) (string, error) {
|
||||||
if len(notes) == 0 {
|
if len(notes) == 0 {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/kami/maven/internal/delivery"
|
"github.com/kami/maven/internal/delivery"
|
||||||
|
"github.com/kami/maven/internal/dialogue"
|
||||||
"github.com/kami/maven/internal/loop"
|
"github.com/kami/maven/internal/loop"
|
||||||
"github.com/kami/maven/internal/store"
|
"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 ------------------------------
|
// ----------------------------- interface guard ------------------------------
|
||||||
|
|
||||||
func TestStubSatisfiesPhraser(t *testing.T) {
|
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 {
|
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)
|
t.Fatalf("PhraseReminder: %v", err)
|
||||||
}
|
}
|
||||||
|
if _, err := p.PhraseChat(context.Background(), "как дела", nil); err != nil {
|
||||||
|
t.Fatalf("PhraseChat: %v", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestStubProducesDeliveryTypes(t *testing.T) {
|
func TestStubProducesDeliveryTypes(t *testing.T) {
|
||||||
|
|||||||
Reference in New Issue
Block a user