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/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
|
||||
|
||||
@@ -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"
|
||||
|
||||
"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 == "" {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user