a8fcb404be
internal/netscan/ discovers hosts on the network Maven is configured to look at:
a TCP-connect scan (net.DialTimeout, no raw sockets, no privileges) plus a read
of the kernel's ARP cache. Wired as a read-only query source, "network", so
"какие устройства в сети?" is answered by a scan instead of by whatever old note
happens to be nearest.
Scanning is a read, but an unbounded scanner on a home LAN is noisy and easy to
point somewhere it should not go, so the package is built around four bounds:
- Scan takes NO target argument. The range comes from the config block and
from nowhere else, so there is no exported way to scan an arbitrary prefix
and nothing an utterance, the router, or a scanned host says can retarget
it. That is asserted directly: the test watches every address handed to the
dialer and fails if one falls outside the configured prefix. The ARP cache —
the one input the network itself populates — is filtered to the configured
range for the same reason.
- Every configured CIDR must be private (RFC1918 / CGNAT / link-local) and no
larger than 1024 addresses. 8.8.8.0/24, 0.0.0.0/0 and 10.0.0.0/8 are refused
at config load, not after the packets have left.
- Rate-limited to a configured connections-per-second across the whole scan,
so it looks like background traffic rather than a portscan.
- Bounded in total by MaxHosts, a per-connection timeout, a 20s turn budget
and the context; a canceled scan stops dialing immediately.
Off unless configured: dark without "enabled": true, and applyDefaults
normalises a disabled block to nil. deploy/mavend.json carries it disabled.
BLUETOOTH IS NOT SHIPPED, AND IS BLOCKED, NOT SKIPPED. The plan's other half
(internal/bluetooth/, RSSI presence probes) needs a bluez stack that is not
here: bluetoothctl and hcitool are not installed, bluetoothd is not installed,
the bluetooth unit is inactive, and org.bluez is not on the system bus. hci0
exists as a kernel device and nothing can talk to it. The docker deploy is
further away still — it would need host networking, the D-Bus system socket
passed in, and CAP_NET_ADMIN. Writing an exec wrapper around a binary that does
not exist, against an output format nothing here can produce, would be a guess
dressed as a feature. It needs a decision about privileging the container before
any of it is worth writing.
Vikunja #257
488 lines
20 KiB
Go
488 lines
20 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/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. The model, his own notes and
|
||
// facts, and — once internal/kiwix is wired into this chain — the offline
|
||
// ZIMs all get their turn before anything touches the network. This source
|
||
// only claims a turn where he named a URL out loud, 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 any morning checklist still outstanding.
|
||
//
|
||
// 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.
|
||
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 !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
|
||
}
|
||
|
||
// isRestOfDayQuery — "что дальше?" and its English form, the only plan phrasing
|
||
// that means "from now on" rather than "the whole day".
|
||
func isRestOfDayQuery(text string) bool {
|
||
s := strings.ToLower(text)
|
||
return strings.Contains(s, "дальше") || strings.Contains(s, "next")
|
||
}
|
||
|
||
// habitFactWindow — how many recent 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.
|
||
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.RecentFacts(ctx, 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
|
||
}
|
||
return profile.FormatOverallRU(), true
|
||
}
|
||
|
||
// feedNoteWindow — how many recent notes are scanned for feed items, 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
|
||
}
|
||
notes, err := h.api.RecentNotes(ctx, feedNoteWindow)
|
||
if err != nil {
|
||
log.Printf("voice: feeds: recent notes: %v", err)
|
||
return "не получилось посмотреть ленты.", true
|
||
}
|
||
var picked []string
|
||
for _, n := range notes {
|
||
if !strings.HasPrefix(n.Source, rss.SourcePrefix) {
|
||
continue
|
||
}
|
||
if !router.CategoryMatches(n.Text, q.Category) {
|
||
continue
|
||
}
|
||
// The note carries title, summary and link; she reads the title.
|
||
title := n.Text
|
||
if i := strings.IndexByte(title, '\n'); i > 0 {
|
||
title = title[:i]
|
||
}
|
||
picked = append(picked, strings.TrimSpace(title))
|
||
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 {
|
||
// Claim the turn rather than fall through: "дом не подключён" is true,
|
||
// and letting general knowledge answer would be an invented house.
|
||
return "дом не подключён — я его не вижу.", true
|
||
}
|
||
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 {
|
||
// Claim the turn: "сканирование не настроено" is true, and general
|
||
// knowledge would answer with an invented list of devices.
|
||
return "сканирование сети не настроено.", true
|
||
}
|
||
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)
|
||
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 {
|
||
// Claim rather than fall through: he asked about a specific page, and
|
||
// letting the model answer from the URL's spelling alone is how a small
|
||
// model invents a page's contents.
|
||
return "я не читаю страницы — это не настроено.", true
|
||
}
|
||
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
|
||
}
|