2bf11f052d
# Conflicts: # internal/ipc/api.go # internal/ipc/client.go # internal/llm/client.go
511 lines
22 KiB
Go
511 lines
22 KiB
Go
package main
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"log"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/kami/maven/internal/crawl"
|
||
"github.com/kami/maven/internal/ipc"
|
||
"github.com/kami/maven/internal/memory"
|
||
"github.com/kami/maven/internal/morning"
|
||
"github.com/kami/maven/internal/router"
|
||
"github.com/kami/maven/internal/rss"
|
||
"github.com/kami/maven/internal/store"
|
||
"github.com/kami/maven/internal/weather"
|
||
)
|
||
|
||
// queryTurn is the per-turn scratch a chain of query sources shares: the
|
||
// decision being answered plus the work an earlier source already paid for
|
||
// (the query embedding, the notes it pulled). Sources read and fill it in
|
||
// order, so a later source never re-embeds.
|
||
type queryTurn struct {
|
||
dec router.Decision
|
||
vec []float32
|
||
notes []ipc.Note
|
||
}
|
||
|
||
// querySource — one answer source in the chain actionQuery walks. answer
|
||
// returns (reply, true) when this source claims the question, ("", false)
|
||
// when it passes to the next one. name is for reading the table, not logged.
|
||
//
|
||
// A struct of one func rather than an interface: every source is a plain
|
||
// method on *reactiveHandler with no state of its own (what state a turn has
|
||
// lives in queryTurn), so an interface would mean one empty type per source
|
||
// to satisfy it — ceremony for nothing. Same reasoning as confirmResolver in
|
||
// confirm.go, and the table then reads like actionHandlers: a flat list of
|
||
// method expressions you extend with one line.
|
||
type querySource struct {
|
||
name string
|
||
answer func(*reactiveHandler, context.Context, *queryTurn) (string, bool)
|
||
}
|
||
|
||
// querySources is the ordered chain actionQuery walks; first source to claim
|
||
// answers the turn. THE ORDER IS LOAD-BEARING — see the memory-before-notes
|
||
// comment on queryMemory: running the notes-only pass first was #373, and the
|
||
// gate was never the bug. Adding a source (Kiwix, RSS, crawler, email) is one
|
||
// line here plus its method; where you put the line is the whole decision.
|
||
var querySources = []querySource{
|
||
{"fact-by-key", (*reactiveHandler).queryFactByKey},
|
||
// Before "calendar" on purpose: both match "…на сегодня", and the plan is
|
||
// the more specific ask (its matcher requires a plan word), so the calendar
|
||
// listing would otherwise swallow it.
|
||
{"day-plan", (*reactiveHandler).queryDayPlan},
|
||
// Also before "calendar": "что я обычно делаю по средам?" names a weekday,
|
||
// and the habit question is the more specific one. Its matcher requires a
|
||
// habit marker ("обычно", "каждый", …), so a question about this coming
|
||
// Wednesday still reaches the calendar.
|
||
{"habits", (*reactiveHandler).queryHabits},
|
||
// Before "calendar" and before the recall sources: "что мне нужно
|
||
// сделать?" is a question about the task list, and the notes pass would
|
||
// otherwise answer it with whatever note happens to be nearest. Its
|
||
// matcher requires a task noun or an explicit "что … сделать", so a
|
||
// date-bearing question still reaches the calendar.
|
||
{"tasks", (*reactiveHandler).queryTasks},
|
||
// Before the recall sources too: "сколько я потратил?" is a question about
|
||
// the money facts the poller wrote, and the notes pass would otherwise
|
||
// answer it from whatever he once said about spending. Its matcher needs a
|
||
// money noun plus an actual ask, so "я потратил весь день" is untouched.
|
||
{"money", (*reactiveHandler).queryMoney},
|
||
// Before the recall sources and before general knowledge: "что нового?" is
|
||
// a question about the feeds she reads, and general knowledge would answer
|
||
// it by inventing news. Its matcher needs a feed noun plus an ask, so
|
||
// "у меня новая лента в инстаграме" is untouched.
|
||
{"feeds", (*reactiveHandler).queryFeeds},
|
||
// Before "calendar" and before the recall sources: "что включено дома?" is
|
||
// a question about the house, and the notes pass would otherwise answer it
|
||
// from whatever he once said about the lights. Its matcher needs a house
|
||
// marker plus an ask plus a device word, and it bails out on weather
|
||
// wording, so "какая температура на улице?" still reaches the weather
|
||
// source.
|
||
{"home", (*reactiveHandler).queryHome},
|
||
// Next to "home" and for the same reason: "какие устройства в сети?" is a
|
||
// question about the LAN, and the recall pass would otherwise answer it
|
||
// from an old note about the router. Its matcher needs a network word plus
|
||
// an ask plus a device noun, so "интернет не работает" is untouched.
|
||
{"network", (*reactiveHandler).queryNetwork},
|
||
{"calendar", (*reactiveHandler).queryCalendar},
|
||
{"weather", (*reactiveHandler).queryWeather},
|
||
{"embed", (*reactiveHandler).queryEmbed},
|
||
{"memory", (*reactiveHandler).queryMemory},
|
||
{"notes", (*reactiveHandler).queryNotes},
|
||
// LAST before the model answers from memory, and that position is the whole
|
||
// design (Vikunja #259): local sources first. His memory, his notes and —
|
||
// once internal/kiwix is wired into this chain — the offline ZIMs all get
|
||
// their turn before anything touches the network. The model does NOT: it
|
||
// answers after this, because a URL he said out loud is an instruction and
|
||
// a 1.7B guessing at a page it cannot read is how contents get invented.
|
||
// This source only claims a turn where he named a URL, so it never competes
|
||
// with a local answer.
|
||
{"web", (*reactiveHandler).queryWeb},
|
||
{"general-knowledge", (*reactiveHandler).queryGeneral},
|
||
}
|
||
|
||
func (h *reactiveHandler) actionQuery(ctx context.Context, dec router.Decision) string {
|
||
t := &queryTurn{dec: dec}
|
||
for _, src := range querySources {
|
||
if reply, ok := src.answer(h, ctx, t); ok {
|
||
return reply
|
||
}
|
||
}
|
||
return "не знаю."
|
||
}
|
||
|
||
// queryFactByKey — when the dialogue layer resolved an anaphoric reference to
|
||
// a prior fact's key (e.g. "когда я это сделал?" after "запиши что я пил
|
||
// воду"), look up the fact's value directly.
|
||
func (h *reactiveHandler) queryFactByKey(ctx context.Context, t *queryTurn) (string, bool) {
|
||
dec := t.dec
|
||
if !dec.Slots.HasKey || dec.Slots.Key == "" {
|
||
return "", false
|
||
}
|
||
f, err := h.api.LatestFact(ctx, dec.Slots.Key)
|
||
if err != nil {
|
||
return "", false
|
||
}
|
||
if dec.Slots.HasTime {
|
||
// The query asks about timing — the fact's own timestamp is the
|
||
// answer it's looking for. Format as a natural reply.
|
||
return fmt.Sprintf("я записала это %s", formatTime(f.Ts)), true
|
||
}
|
||
// General fact reference: describe what we know.
|
||
if dec.Utterance == "" {
|
||
return fmt.Sprintf("вот что я знаю: %s — %s", dec.Slots.Key, f.Value), true
|
||
}
|
||
// The utterance still carries the question; fall through to normal RAG
|
||
// with the resolved key in context.
|
||
return "", false
|
||
}
|
||
|
||
// queryDayPlan — "какие планы на сегодня?", "что у меня по плану?", "что
|
||
// дальше?" (Vikunja #128). Recites the day: calendar events, pending
|
||
// reminders, and every morning checklist item today still has no evidence for,
|
||
// including the ones whose window has closed.
|
||
//
|
||
// Read-only by construction — the plan is assembled and rendered core-side and
|
||
// nothing here schedules or announces. "что дальше?" asks for the rest of the
|
||
// day, so that phrasing trims what has already passed.
|
||
//
|
||
// What surface this belongs on is still open, tracked as Vikunja #431 ("Board
|
||
// surface: Maven holds the work board, runs the intake form, never argues").
|
||
// The spoken recital here is the current answer, not the decided one.
|
||
func (h *reactiveHandler) queryDayPlan(ctx context.Context, t *queryTurn) (string, bool) {
|
||
if !router.IsDayPlanQuery(t.dec.Utterance) {
|
||
return "", false
|
||
}
|
||
plan, err := h.api.DayPlan(ctx)
|
||
if err != nil {
|
||
log.Printf("voice: day plan: %v", err)
|
||
return "не получилось собрать план.", true
|
||
}
|
||
if !router.IsRestOfDayQuery(t.dec.Utterance) {
|
||
return plan.Spoken, true
|
||
}
|
||
// Rebuild the pure plan so the rest-of-day rendering is the same code that
|
||
// rendered the whole day — one formatter, one persona.
|
||
p := morning.Plan{Date: plan.Date}
|
||
for _, it := range plan.Items {
|
||
p.Items = append(p.Items, morning.PlanEntry{
|
||
At: it.At,
|
||
Text: it.Text,
|
||
Kind: morning.PlanKind(it.Kind),
|
||
Uncertain: it.Uncertain,
|
||
})
|
||
}
|
||
return p.After(h.now()).FormatRU(), true
|
||
}
|
||
|
||
// habitFactWindow — how many recent SELF facts the behaviour profile is counted
|
||
// over. Enough for a season of habits without scanning the whole store on every
|
||
// question; the profile is recomputed on read, so the bound is the cost control.
|
||
//
|
||
// The read is kind-filtered in SQL, and that is the load-bearing part. When this
|
||
// was a plain recent-facts read the window was a row budget over every writer,
|
||
// and the machine writers dwarf the taps: mavpoll writes a wg_handshake row
|
||
// whenever a peer rehandshakes, which is roughly every two minutes per peer, so
|
||
// 2000 rows was under three days of history. A weekday habit needs
|
||
// memory.MinHabitDays distinct Tuesdays, which such a window can never hold, so
|
||
// she answered "по вторникам у меня пока нет ничего постоянного" forever on a
|
||
// store with a year of taps in it. Self facts come from voice taps, and he does
|
||
// not tap seven hundred times a day.
|
||
const habitFactWindow = 2000
|
||
|
||
// queryHabits — "что я обычно делаю по вторникам?" (Vikunja #254). Counts the
|
||
// answer out of the fact log rather than asking the model to summarise a life:
|
||
// see internal/memory/behavior.go for why nothing here is generated.
|
||
func (h *reactiveHandler) queryHabits(ctx context.Context, t *queryTurn) (string, bool) {
|
||
q, ok := router.ParseHabitQuery(t.dec.Utterance)
|
||
if !ok {
|
||
return "", false
|
||
}
|
||
facts, err := h.api.RecentActiveFactsByKind(ctx, string(store.KindSelf), habitFactWindow)
|
||
if err != nil {
|
||
log.Printf("voice: habits: recent facts: %v", err)
|
||
return "не получилось посмотреть записи.", true
|
||
}
|
||
obs := make([]memory.Observation, 0, len(facts))
|
||
for _, f := range facts {
|
||
obs = append(obs, memory.Observation{At: f.Ts, Key: f.Key, Kind: f.Kind})
|
||
}
|
||
profile := memory.BuildProfile(obs, h.now())
|
||
if q.HasWeekday {
|
||
return profile.FormatWeekdayRU(q.Weekday), true
|
||
}
|
||
if q.Weekend {
|
||
return profile.FormatWeekendRU(), true
|
||
}
|
||
return profile.FormatOverallRU(), true
|
||
}
|
||
|
||
// feedNoteWindow — how many recent FEED notes are scanned, and
|
||
// feedReadOut — how many headlines she actually reads back. She summarises the
|
||
// top of the pile, she does not recite a river.
|
||
const (
|
||
feedNoteWindow = 200
|
||
feedReadOut = 3
|
||
)
|
||
|
||
// queryFeeds — "что нового в лентах?", "что нового по технологиям?"
|
||
// (Vikunja #258).
|
||
//
|
||
// This is the ONLY way a feed item reaches him. The poller writes notes and
|
||
// never speaks; asking is the trigger. If that ever changes, the thing that
|
||
// changed is "Maven is not a nag", not a detail of this file.
|
||
func (h *reactiveHandler) queryFeeds(ctx context.Context, t *queryTurn) (string, bool) {
|
||
q, ok := router.ParseFeedQuery(t.dec.Utterance)
|
||
if !ok {
|
||
return "", false
|
||
}
|
||
if !h.feedsOn {
|
||
// Claim the turn rather than fall through: "не читаю ленты" is true, and
|
||
// letting general knowledge answer "что нового?" would be an invented
|
||
// news bulletin.
|
||
return "я пока не читаю ленты — они не настроены.", true
|
||
}
|
||
// By source, not the last 200 notes of any kind: a busy day of voice notes
|
||
// used to push the newest headline out of the window, and she answered "в
|
||
// лентах пока ничего нового" while the poller was working fine.
|
||
notes, err := h.api.RecentNotesFromSource(ctx, rss.SourcePrefix, feedNoteWindow)
|
||
if err != nil {
|
||
log.Printf("voice: feeds: recent notes: %v", err)
|
||
return "не получилось посмотреть ленты.", true
|
||
}
|
||
var picked []string
|
||
for _, n := range notes {
|
||
if !router.CategoryMatches(rss.NoteCategory(n.Text), q.Category) {
|
||
continue
|
||
}
|
||
// The note carries title, summary, category tag and link; she reads the
|
||
// title alone. The tag is for the match above, and piper reads brackets
|
||
// out loud.
|
||
picked = append(picked, rss.NoteHeadline(n.Text))
|
||
if len(picked) == feedReadOut {
|
||
break
|
||
}
|
||
}
|
||
if len(picked) == 0 {
|
||
if q.Category != "" {
|
||
return "по этой теме в лентах пока ничего.", true
|
||
}
|
||
return "в лентах пока ничего нового.", true
|
||
}
|
||
return "вот что нового: " + strings.Join(picked, "; "), true
|
||
}
|
||
|
||
// queryCalendar — "что у меня сегодня?", "планы на завтра?"
|
||
// h.now(), not time.Now(): the handler's clock is the injected one, so this
|
||
// source can be tested at a fixed time like the rest.
|
||
func (h *reactiveHandler) queryCalendar(ctx context.Context, t *queryTurn) (string, bool) {
|
||
date, ok := router.ParseCalendarDate(t.dec.Utterance, h.now())
|
||
if !ok {
|
||
return "", false
|
||
}
|
||
events, err := h.api.CalendarEvents(ctx, date, date.Add(24*time.Hour))
|
||
if err != nil {
|
||
log.Printf("voice: calendar events: %v", err)
|
||
return "не получилось проверить календарь.", true
|
||
}
|
||
// Provenance travels with each event. A work meeting relayed off a phone
|
||
// notification (source ambient:notif, #126) is stored below full confidence
|
||
// and gets hedged; a CalDAV read is recited plainly.
|
||
entries := make([]router.CalendarEntry, len(events))
|
||
for i, e := range events {
|
||
entries[i] = router.CalendarEntry{Text: e.Value, Uncertain: e.Confidence < 1.0}
|
||
}
|
||
var f router.CalendarEventFormatter
|
||
return f.FormatEntries(entries, date), true
|
||
}
|
||
|
||
// queryHome answers a question about the house. Read-only by construction: it
|
||
// calls States and nothing else, so there is no confirm turn here — the only
|
||
// way to CHANGE something is an enabled allowlist row through tool.Executor.
|
||
func (h *reactiveHandler) queryHome(ctx context.Context, t *queryTurn) (string, bool) {
|
||
if !isHomeQuery(t.dec.Utterance) {
|
||
return "", false
|
||
}
|
||
if h.home == nil {
|
||
// Fall through rather than claim the turn. A capability that is off
|
||
// must not change what an unconfigured box answers: "какая температура
|
||
// в доме?" on a Maven with no smarthome block reached recall before
|
||
// this source existed, and a stored fact is a better answer than
|
||
// "дом не подключён" from a house that was never configured. The
|
||
// unreachable case is different and homeSummary covers it.
|
||
return "", false
|
||
}
|
||
ctxH, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||
defer cancel()
|
||
return h.home.homeSummary(ctxH)
|
||
}
|
||
|
||
// queryNetwork answers a question about the LAN with a bounded scan. There is
|
||
// no confirm turn because nothing is changed, and no way to widen the range
|
||
// because Scan takes no target — the utterance selects the question, never the
|
||
// subnet.
|
||
func (h *reactiveHandler) queryNetwork(ctx context.Context, t *queryTurn) (string, bool) {
|
||
if !isNetworkQuery(t.dec.Utterance) {
|
||
return "", false
|
||
}
|
||
if h.netscan == nil {
|
||
// Fall through, same as queryHome: an unconfigured scanner must not
|
||
// swallow "сколько устройств в сети?" before recall has looked.
|
||
return "", false
|
||
}
|
||
return h.netscan.scanSummary(ctx)
|
||
}
|
||
|
||
func (h *reactiveHandler) queryWeather(ctx context.Context, t *queryTurn) (string, bool) {
|
||
if !isWeatherQuery(t.dec.Utterance) {
|
||
return "", false
|
||
}
|
||
loc := extractWeatherLocation(t.dec.Utterance, h.weatherLocation)
|
||
if loc == "" {
|
||
// He named no city and voice.weather.default_location is unset. Saying
|
||
// so is the only honest answer; picking a city would be inventing one.
|
||
return "не знаю, для какого города — задай voice.weather.default_location или назови город.", true
|
||
}
|
||
ctxWT, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||
defer cancel()
|
||
w, err := h.weatherProvider.CurrentWeather(ctxWT, loc)
|
||
if errors.Is(err, weather.ErrNotConfigured) {
|
||
return "погода не настроена.", true
|
||
}
|
||
if err != nil {
|
||
log.Printf("voice: weather: %v", err)
|
||
return "не получилось узнать погоду.", true
|
||
}
|
||
return fmt.Sprintf("в %s сейчас %.0f градусов, %s.", w.Location, w.Temperature, w.Condition), true
|
||
}
|
||
|
||
// queryEmbed isn't an answer source — it's the shared cost the two recall
|
||
// sources below both need, run once, in the position it always ran in. It
|
||
// only claims the turn when the embedder fails.
|
||
func (h *reactiveHandler) queryEmbed(ctx context.Context, t *queryTurn) (string, bool) {
|
||
vec, err := router.EmbedQuery(ctx, h.embedder, t.dec.Utterance)
|
||
if err != nil {
|
||
log.Printf("voice: embed query: %v", err)
|
||
return "не получилось найти ответ.", true
|
||
}
|
||
t.vec = vec
|
||
return "", false
|
||
}
|
||
|
||
// queryMemory — long-term memory first: ONE search over everything Maven
|
||
// remembers (notes and facts share this index) and ONE confidence gate, so
|
||
// the memory that is clearly the best match answers — a note just as much as
|
||
// a fact.
|
||
//
|
||
// This used to run only after the notes-only source below had already
|
||
// rejected the same note at the same score, which no note could ever survive
|
||
// a second time: the branch could only return a fact (#373). Order, not the
|
||
// gate, was the bug — the set of questions Maven answers is unchanged, only
|
||
// which memory gets to answer them.
|
||
func (h *reactiveHandler) queryMemory(ctx context.Context, t *queryTurn) (string, bool) {
|
||
if h.memStore == nil {
|
||
return "", false
|
||
}
|
||
hits, herr := h.memStore.Search(ctx, t.vec, 3)
|
||
if herr != nil {
|
||
log.Printf("voice: memory search: %v", herr)
|
||
return "", false
|
||
}
|
||
hit, ok := bestRecall(hits, h.queryMinScore, h.queryMinMargin)
|
||
if !ok {
|
||
return "", false
|
||
}
|
||
text := hit.Meta["text"]
|
||
// A note is phrased in Maven's voice; a fact is read back as it was
|
||
// stored.
|
||
if hit.Meta["type"] == "note" {
|
||
if reply, perr := h.phraser.PhraseQuery(ctx, t.dec.Utterance, []string{text}); perr == nil && reply != "" {
|
||
return reply, true
|
||
}
|
||
}
|
||
return text, true
|
||
}
|
||
|
||
// queryNotes — notes-only pass, for notes the vector index above does not
|
||
// hold (an older note written before it existed). Same gate, notes-only
|
||
// candidates.
|
||
//
|
||
// Confidence gate: below it, say "I don't know" rather than read back the
|
||
// least-unrelated note — a confident wrong recall is worse than a gap (spec's
|
||
// "not a guesser-of-truth"). Same instinct as the loop's since(key)==null →
|
||
// don't fire. Two parts: an absolute cosine floor, and a margin over the
|
||
// runner-up, which is the part that works with the e5 embedder's narrow score
|
||
// band. See memory.Confident. Failing the gate passes the turn on to general
|
||
// knowledge, which is what "don't read back the runner-up" means here.
|
||
func (h *reactiveHandler) queryNotes(ctx context.Context, t *queryTurn) (string, bool) {
|
||
notes, err := h.api.QueryNotes(ctx, t.vec, 5)
|
||
if err != nil {
|
||
log.Printf("voice: query notes: %v", err)
|
||
return "не получилось найти ответ.", true
|
||
}
|
||
t.notes = notes
|
||
noteScores := make([]float64, len(notes))
|
||
for i, n := range notes {
|
||
noteScores[i] = n.Score
|
||
}
|
||
if !memory.ConfidentScores(noteScores, h.queryMinScore, h.queryMinMargin) {
|
||
return "", false
|
||
}
|
||
texts := make([]string, len(notes))
|
||
for i, n := range notes {
|
||
texts[i] = n.Text
|
||
}
|
||
reply, err := h.phraser.PhraseQuery(ctx, t.dec.Utterance, texts)
|
||
if err != nil {
|
||
log.Printf("voice: phrase query: %v", err)
|
||
}
|
||
if reply == "" {
|
||
reply = "вот что я нашла: " + texts[0]
|
||
}
|
||
return reply, true
|
||
}
|
||
|
||
// webPageContextRunes — how much of a fetched page is handed to the phraser.
|
||
// Less than the crawler keeps: the rest of the 4096-token window belongs to the
|
||
// prompt, the persona block and the reply.
|
||
const webPageContextRunes = 1500
|
||
|
||
// queryWeb — "посмотри https://example.org/x — что там?" (Vikunja #259).
|
||
//
|
||
// It claims a turn ONLY when he named a URL, which is what keeps a fallback from
|
||
// becoming a habit: no URL, no fetch, and the model answers from what is local.
|
||
// What leaves the box is the URL and nothing else — no note, no fact, no history
|
||
// travels with it.
|
||
func (h *reactiveHandler) queryWeb(ctx context.Context, t *queryTurn) (string, bool) {
|
||
link, ok := router.FirstURL(t.dec.Utterance)
|
||
if !ok {
|
||
return "", false
|
||
}
|
||
if h.crawler == nil {
|
||
// Fall through. Reading pages is off unless configured, and on a daemon
|
||
// where it was never turned on the older behaviour is right: the model
|
||
// answers the question as if the URL had not been said. Announcing a
|
||
// configuration status is for a capability that exists and failed, not
|
||
// for one he never asked for.
|
||
return "", false
|
||
}
|
||
ctxFetch, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||
defer cancel()
|
||
page, err := h.crawler.Page(ctxFetch, link)
|
||
if err != nil {
|
||
if errors.Is(err, crawl.ErrRobots) {
|
||
return "эта страница закрыта для чтения — robots.txt не разрешает.", true
|
||
}
|
||
log.Printf("voice: web: %v", err)
|
||
return "не получилось прочитать страницу.", true
|
||
}
|
||
if page.Text == "" {
|
||
return "страница открылась, но читать там нечего.", true
|
||
}
|
||
// The page is handed to the phraser the same way a note is: as context for
|
||
// the question he actually asked. She answers the question, she does not
|
||
// recite the page.
|
||
snippet := page.Title + "\n" + crawl.TrimRunes(page.Text, webPageContextRunes)
|
||
reply, perr := h.phraser.PhraseQuery(ctx, t.dec.Utterance, []string{snippet})
|
||
if perr != nil {
|
||
log.Printf("voice: web: phrase: %v", perr)
|
||
}
|
||
if reply == "" {
|
||
// No phraser (or it failed): read back the top of the page rather than
|
||
// pretend the fetch did not happen.
|
||
return "вот что на странице: " + crawl.TrimRunes(page.Text, 300), true
|
||
}
|
||
return reply, true
|
||
}
|
||
|
||
// queryGeneral — general knowledge from the phraser, the last source before
|
||
// giving up. It always claims: either the model answers or Maven says she
|
||
// doesn't know.
|
||
func (h *reactiveHandler) queryGeneral(ctx context.Context, t *queryTurn) (string, bool) {
|
||
reply, err := h.phraser.PhraseQuery(ctx, t.dec.Utterance, nil)
|
||
if err != nil || reply == "" {
|
||
return "не знаю.", true
|
||
}
|
||
return reply, true
|
||
}
|