mavend: world questions ask the workstation, and name the gap when it is asleep (V-490)

queryGeneral has nothing fetched to fall back on, so it is the sharp case:
with a workstation configured and asleep he is told that, rather than told
something false in a confident voice. The 1.7B answering a world question is
where "Война и мир" got Левитан as its author.

The sources that already hold a passage — a live search, a ZIM article, a
page he named — go through the world model too, but read the passage back
when it is not there instead of naming a gap. A real quote beats "не могу
сейчас", and nothing is invented on either path.

The Stub and every test double keep the Phraser interface they have.
PhraseWorld is reached by assertion, and a phraser without it is the
no-workstation case.
This commit is contained in:
2026-08-03 12:27:40 +04:00
parent 51256c4c9a
commit 12530c8a95
4 changed files with 177 additions and 24 deletions
+25 -24
View File
@@ -13,6 +13,7 @@ import (
"github.com/kami/maven/internal/ipc" "github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/memory" "github.com/kami/maven/internal/memory"
"github.com/kami/maven/internal/morning" "github.com/kami/maven/internal/morning"
"github.com/kami/maven/internal/phraser"
"github.com/kami/maven/internal/router" "github.com/kami/maven/internal/router"
"github.com/kami/maven/internal/rss" "github.com/kami/maven/internal/rss"
"github.com/kami/maven/internal/store" "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 // the question he actually asked. She answers the question, she does not
// recite the page. // recite the page.
snippet := page.Title + "\n" + crawl.TrimRunes(page.Text, webPageContextRunes) snippet := page.Title + "\n" + crawl.TrimRunes(page.Text, webPageContextRunes)
reply, perr := h.phraser.PhraseQuery(ctx, t.dec.Utterance, []string{snippet}) reply := h.phraseSource(ctx, "web", t.dec.Utterance, []string{snippet})
if perr != nil {
log.Printf("voice: web: phrase: %v", perr)
}
if reply == "" { if reply == "" {
// No phraser (or it failed): read back the top of the page rather than // No phraser (or it failed): read back the top of the page rather than
// pretend the fetch did not happen. // 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 // 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. // joined block, so a long first snippet cannot crowd out the rest.
evidence := crawl.TrimRunes(strings.Join(resp.Snippets(), "\n"), h.search.runes) evidence := crawl.TrimRunes(strings.Join(resp.Snippets(), "\n"), h.search.runes)
var reply string reply := h.phraseSource(ctx, "search", t.dec.Utterance, []string{evidence})
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)
}
}
if reply == "" { if reply == "" {
// No phraser, or it failed. Read back the best evidence rather than // No phraser, or it failed. Read back the best evidence rather than
// pretend the search did not happen. // 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 // Handed over the same way a note or a page is: context for the question he
// asked, not something to recite. // asked, not something to recite.
snippet := top.Title + "\n" + crawl.TrimRunes(page.Text, h.kiwix.runes) snippet := top.Title + "\n" + crawl.TrimRunes(page.Text, h.kiwix.runes)
var reply string reply := h.phraseSource(ctx, "kiwix", t.dec.Utterance, []string{snippet})
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)
}
}
if reply == "" { if reply == "" {
// No phraser, or it failed. Read back the best hit rather than pretend // No phraser, or it failed. Read back the best hit rather than pretend
// the search did not happen. // the search did not happen.
@@ -755,11 +739,28 @@ func isPersonalQuery(utterance string) bool {
return false return false
} }
// queryGeneral — general knowledge from the phraser, the last source before // queryGeneral — general knowledge, the last source before giving up. It always
// giving up. It always claims: either the model answers or Maven says she // claims: either a model answers, or Maven names the gap, or she says she does
// doesn't know. // 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) { 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 == "" { if err != nil || reply == "" {
return "не знаю.", true return "не знаю.", true
} }
+7
View File
@@ -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. // either the pair, or the resident client alone, or nothing at all.
hot, pair := modelSeam(cfg, llmClient) hot, pair := modelSeam(cfg, llmClient)
w.pair = pair 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) ----- // ----- router (the cascade; floor examples seed the classifier) -----
// The act matcher's allowlist is exactly the enabled tool names — the // The act matcher's allowlist is exactly the enabled tool names — the
// router only matches acts the executor can run (one source of truth). // router only matches acts the executor can run (one source of truth).
+60
View File
@@ -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
}
+85
View File
@@ -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)
}
}