diff --git a/cmd/mavend/actions_query.go b/cmd/mavend/actions_query.go index 9ce82f2..c8d8514 100644 --- a/cmd/mavend/actions_query.go +++ b/cmd/mavend/actions_query.go @@ -13,6 +13,7 @@ import ( "github.com/kami/maven/internal/ipc" "github.com/kami/maven/internal/memory" "github.com/kami/maven/internal/morning" + "github.com/kami/maven/internal/phraser" "github.com/kami/maven/internal/router" "github.com/kami/maven/internal/rss" "github.com/kami/maven/internal/store" @@ -523,10 +524,7 @@ func (h *reactiveHandler) queryWeb(ctx context.Context, t *queryTurn) (string, b // 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) - } + reply := h.phraseSource(ctx, "web", t.dec.Utterance, []string{snippet}) if reply == "" { // No phraser (or it failed): read back the top of the page rather than // pretend the fetch did not happen. @@ -592,14 +590,7 @@ func (h *reactiveHandler) querySearch(ctx context.Context, t *queryTurn) (string // question he asked, not something to recite. The trim is one budget over the // joined block, so a long first snippet cannot crowd out the rest. evidence := crawl.TrimRunes(strings.Join(resp.Snippets(), "\n"), h.search.runes) - var reply string - if h.phraser != nil { - var perr error - reply, perr = h.phraser.PhraseQuery(ctx, t.dec.Utterance, []string{evidence}) - if perr != nil { - log.Printf("voice: search: phrase: %v", perr) - } - } + reply := h.phraseSource(ctx, "search", t.dec.Utterance, []string{evidence}) if reply == "" { // No phraser, or it failed. Read back the best evidence rather than // pretend the search did not happen. @@ -680,14 +671,7 @@ func (h *reactiveHandler) queryKiwix(ctx context.Context, t *queryTurn) (string, // Handed over the same way a note or a page is: context for the question he // asked, not something to recite. snippet := top.Title + "\n" + crawl.TrimRunes(page.Text, h.kiwix.runes) - var reply string - if h.phraser != nil { - var perr error - reply, perr = h.phraser.PhraseQuery(ctx, t.dec.Utterance, []string{snippet}) - if perr != nil { - log.Printf("voice: kiwix: phrase: %v", perr) - } - } + reply := h.phraseSource(ctx, "kiwix", t.dec.Utterance, []string{snippet}) if reply == "" { // No phraser, or it failed. Read back the best hit rather than pretend // the search did not happen. @@ -755,11 +739,28 @@ func isPersonalQuery(utterance string) bool { return false } -// 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. +// queryGeneral — general knowledge, the last source before giving up. It always +// claims: either a model answers, or Maven names the gap, or she says she does +// not know. +// +// This is the sharpest case for the naming half. Nothing has been fetched, so +// there is no passage to fall back on and no floor under the answer except the +// model's weights — and a 1.7B's weights are where the invented answers come +// from. With a workstation configured and asleep he is told that, rather than +// told something false in a confident voice. With no workstation configured at +// all the resident model answers exactly as it does today: naming a gap requires +// a gap, and on that box the 1.7B is the whole product. func (h *reactiveHandler) queryGeneral(ctx context.Context, t *queryTurn) (string, bool) { - reply, err := h.phraser.PhraseQuery(ctx, t.dec.Utterance, nil) + if h.phraser == nil { + // No model of any size. That is not the workstation being asleep, so it + // is not that gap: it is simply not knowing. + return "не знаю.", true + } + reply, err := h.phraseWorld(ctx, t.dec.Utterance, nil) + if errors.Is(err, phraser.ErrNoWorldModel) { + log.Printf("voice: %q needs the world model and it is not available", t.dec.Utterance) + return worldGap, true + } if err != nil || reply == "" { return "не знаю.", true } diff --git a/cmd/mavend/voicewire.go b/cmd/mavend/voicewire.go index 41a6911..70cf136 100644 --- a/cmd/mavend/voicewire.go +++ b/cmd/mavend/voicewire.go @@ -200,6 +200,13 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem // either the pair, or the resident client alone, or nothing at all. hot, pair := modelSeam(cfg, llmClient) w.pair = pair + // The phraser gets the same pair, which is what carries the workstation model + // into the paths that do not go through `hot`: world questions (the naming + // half), and the digestion worker's nudge and reminder phrasing (the silent + // half). Wiring, so it happens once and before the voice server listens. + if lp, ok := phr.(*phraser.LLMPhraser); ok && pair != nil { + lp.UseRemote(pair) + } // ----- 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). diff --git a/cmd/mavend/worldmodel.go b/cmd/mavend/worldmodel.go new file mode 100644 index 0000000..c6c712f --- /dev/null +++ b/cmd/mavend/worldmodel.go @@ -0,0 +1,60 @@ +package main + +import ( + "context" + "errors" + "log" + + "github.com/kami/maven/internal/phraser" +) + +// worldPhraser — the naming half of the degradation rule (docs/offload.md), as +// the query sources see it. Only *phraser.LLMPhraser implements it, so the +// Stub and every test double stay exactly as they are. +type worldPhraser interface { + PhraseWorld(ctx context.Context, utterance string, sources []string) (string, error) +} + +// worldGap — what he hears when the question is about the world, the workstation +// model is the one configured to answer it, and that machine is not answering. +// +// It says the true thing. The resident 1.7B is not a worse answer here, it is an +// invented one: "Война и мир" came back with Левитан as its author, and a +// question about his meeting came back as a swimming competition in Nottingham. +// Naming the gap is the rule CLAUDE.md already applies to a sibling service +// being down. +const worldGap = "сейчас не могу ответить — большая модель недоступна, а придумывать не хочу." + +// phraseWorld asks the world model, or reports the gap. +// +// The three outcomes come straight from LLMPhraser.PhraseWorld: no workstation +// configured means the resident model answers as it always has, a workstation +// that is up answers, and a workstation that is down returns +// phraser.ErrNoWorldModel. A phraser that has no world seam at all — the Stub, +// and the doubles in the tests — is the first of those three. +func (h *reactiveHandler) phraseWorld(ctx context.Context, utterance string, sources []string) (string, error) { + if h.phraser == nil { + return "", phraser.ErrNoWorldModel + } + if w, ok := h.phraser.(worldPhraser); ok { + return w.PhraseWorld(ctx, utterance, sources) + } + return h.phraser.PhraseQuery(ctx, utterance, sources) +} + +// phraseSource asks the world model to answer from a passage someone already +// fetched — a live search result, a ZIM article, a page he named. It returns "" +// rather than the gap phrase, because these callers hold something better than a +// gap: the passage itself, which their own floor reads back to him. Nothing is +// invented either way, and a real quote beats "не могу сейчас". +func (h *reactiveHandler) phraseSource(ctx context.Context, name, utterance string, sources []string) string { + reply, err := h.phraseWorld(ctx, utterance, sources) + switch { + case errors.Is(err, phraser.ErrNoWorldModel): + log.Printf("voice: %s: no world model, reading the source back instead", name) + return "" + case err != nil: + log.Printf("voice: %s: phrase: %v", name, err) + } + return reply +} diff --git a/cmd/mavend/worldmodel_test.go b/cmd/mavend/worldmodel_test.go new file mode 100644 index 0000000..f8d0ad1 --- /dev/null +++ b/cmd/mavend/worldmodel_test.go @@ -0,0 +1,85 @@ +package main + +import ( + "context" + "strings" + "testing" + + "github.com/kami/maven/internal/phraser" + "github.com/kami/maven/internal/router" +) + +// gapPhraser — a phraser whose world model is configured and asleep, which is +// the state the naming half exists for. +type gapPhraser struct { + *phraser.Stub + worldCalls int +} + +func (g *gapPhraser) PhraseWorld(context.Context, string, []string) (string, error) { + g.worldCalls++ + return "", phraser.ErrNoWorldModel +} + +func worldTurn(utterance string) *queryTurn { + return &queryTurn{dec: router.Decision{Intent: router.IntentQuery, Utterance: utterance}} +} + +// A world question with the workstation asleep says so. The resident model is +// not asked, because what it produces here is an invention with no signal that +// it is one. +func TestQueryGeneralNamesTheGap(t *testing.T) { + g := &gapPhraser{Stub: phraser.NewStub()} + h := &reactiveHandler{phraser: g} + reply, ok := h.queryGeneral(context.Background(), worldTurn("почему небо голубое")) + if !ok { + t.Fatal("queryGeneral passed on the last source in the chain") + } + if reply != worldGap { + t.Fatalf("reply = %q, want the named gap", reply) + } + if g.worldCalls != 1 { + t.Fatalf("PhraseWorld called %d times, want 1", g.worldCalls) + } +} + +// A phraser with no world seam at all — the Stub, and every box with no +// `workstation` block — answers exactly as it did before this seam existed. +func TestQueryGeneralWithoutAWorldModelIsUnchanged(t *testing.T) { + h := &reactiveHandler{phraser: phraser.NewStub()} + reply, ok := h.queryGeneral(context.Background(), worldTurn("почему небо голубое")) + if !ok { + t.Fatal("queryGeneral passed on the last source in the chain") + } + if reply != "не знаю." { + t.Fatalf("reply = %q, want the Stub's answer", reply) + } +} + +// The gap is spoken aloud by a Russian voice, so it is Russian, feminine and +// informal. "не хочу" and "не могу" are her own verbs; there is no "вы" and no +// English in it. +func TestWorldGapIsInPersona(t *testing.T) { + for _, bad := range []string{"вы", "ваш", "рад ", "дорогой", "милый"} { + if strings.Contains(worldGap, bad) { + t.Errorf("the gap phrase contains %q: %s", bad, worldGap) + } + } + if strings.ContainsAny(worldGap, "abcdefghijklmnopqrstuvwxyz") { + t.Errorf("the gap phrase has Latin letters in it: %s", worldGap) + } +} + +// The sources that hold a passage read it back rather than name a gap. He gets a +// real quote instead of "не могу сейчас", and nothing is invented either way. +func TestASourceWithAPassageReadsItBackInsteadOfNamingTheGap(t *testing.T) { + g := &gapPhraser{Stub: phraser.NewStub()} + h := &reactiveHandler{phraser: g} + if got := h.phraseSource(context.Background(), "search", "почему небо голубое", + []string{"Рэлеевское рассеяние."}); got != "" { + t.Fatalf("phraseSource = %q, want \"\" so the caller's own floor reads the passage back", got) + } + if g.worldCalls != 1 { + t.Fatalf("PhraseWorld called %d times, want 1", g.worldCalls) + } +}