Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c5264deb46 | |||
| 1cb269886e | |||
| 7a9b9cc669 | |||
| 31b5093403 | |||
| 229890abd7 | |||
| 0db9ca084c | |||
| 96d97e8964 | |||
| 02f6e8ad4a | |||
| 999a5ad562 | |||
| 2ea39a3d41 | |||
| 8fb6f2154d | |||
| c938148619 | |||
| 2512d686a1 | |||
| f44abcc526 | |||
| a99932b427 | |||
| 6d5801bb1f |
@@ -70,3 +70,5 @@ coverage.out
|
||||
|
||||
# root .env — MAVEN_AMBIENT_TOKEN and friends, same class as deploy/telegram.env
|
||||
.env
|
||||
# silero-vad, downloaded (see AGENTS.md)
|
||||
/models/vad/
|
||||
|
||||
@@ -95,6 +95,22 @@ model: the code puts `query: ` in front of a question and `passage: ` in front
|
||||
of a stored note, which is how e5 was trained. The quantized file is the one
|
||||
that is downloaded, deployed and measured.
|
||||
|
||||
## Voice activity model for mavwaked
|
||||
|
||||
`mavwaked` decides an utterance has started with silero-vad when `-vad-model`
|
||||
points at it, and with an energy threshold when it does not. The model is 2.3MB
|
||||
and is not committed:
|
||||
|
||||
```sh
|
||||
mkdir -p models/vad
|
||||
curl -sL -o models/vad/silero_vad.onnx \
|
||||
https://github.com/snakers4/silero-vad/raw/master/src/silero_vad/data/silero_vad.onnx
|
||||
```
|
||||
|
||||
It needs the same `libonnxruntime.so` the embedder needs, passed as `-onnx-lib`
|
||||
or read from `MAVEN_ONNX_LIB`. The measurement is
|
||||
`docs/evals/2026-08-09-silero-vad.md`, and the tests skip without the file.
|
||||
|
||||
**Also need ONNX Runtime** (`libonnxruntime.so`):
|
||||
|
||||
```sh
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/kami/maven/internal/crawl"
|
||||
"github.com/kami/maven/internal/decision"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/kiwix"
|
||||
"github.com/kami/maven/internal/memory"
|
||||
"github.com/kami/maven/internal/morning"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
@@ -79,6 +80,15 @@ type querySource struct {
|
||||
// not named do not get to try. The lookups still run, because a named
|
||||
// destination is evidence and not a promise.
|
||||
guesses bool
|
||||
|
||||
// boundary — dropping this source widens what leaves the box, so only a
|
||||
// literal pattern may do it (V-666, owner's call of 2026-08-09).
|
||||
//
|
||||
// Every other guesser costs an answer when it is wrongly taken off a turn.
|
||||
// This one costs the rule that a question about him never reaches an
|
||||
// upstream engine. A grammar read the words to name a destination. A model
|
||||
// and a softmax both inferred one, and neither may spend that.
|
||||
boundary bool
|
||||
}
|
||||
|
||||
// querySources is the ordered chain actionQuery walks; first source to claim
|
||||
@@ -156,7 +166,7 @@ var querySources = []querySource{
|
||||
// below answers from the world's. A question about him that got this far
|
||||
// has no answer in his data, and no outside source can supply one, so this
|
||||
// stops the walk rather than let the encyclopedia and the model guess.
|
||||
{name: "personal", answer: (*reactiveHandler).queryPersonal, dest: router.SourceRecall, guesses: true},
|
||||
{name: "personal", answer: (*reactiveHandler).queryPersonal, dest: router.SourceRecall, guesses: true, boundary: true},
|
||||
// The world, read live. Owner's ruling of 2026-08-02: a metasearch hit beats
|
||||
// a frozen ZIM, so SearXNG asks before Kiwix does. Nothing of his is at
|
||||
// stake by this point — the boundary above already stopped every question
|
||||
@@ -198,12 +208,17 @@ var querySources = []querySource{
|
||||
// No destination named ⇒ the table exactly as written, which is what shipped
|
||||
// before the field existed. That is the floor. The classifier arm names
|
||||
// nothing, so a box whose model is down routes queries the way it always did.
|
||||
func queryWalk(dest router.Source) (walk, skipped []querySource) {
|
||||
// The personal boundary is the one exception, and anchored is what buys it
|
||||
// (V-666). A grammar matched a literal pattern to name the destination. The
|
||||
// routing heads and the resident model inferred one, and an inferred SourceWorld
|
||||
// takes the boundary off a question about him. That widens what is asked
|
||||
// upstream rather than costing a local answer, so those two keep it.
|
||||
func queryWalk(dest router.Source, anchored bool) (walk, skipped []querySource) {
|
||||
if dest == router.SourceUnknown {
|
||||
return querySources, nil
|
||||
}
|
||||
for _, s := range querySources {
|
||||
if s.guesses && s.dest != dest {
|
||||
if s.guesses && s.dest != dest && (anchored || !s.boundary) {
|
||||
skipped = append(skipped, s)
|
||||
continue
|
||||
}
|
||||
@@ -219,7 +234,7 @@ func (h *reactiveHandler) actionQuery(ctx context.Context, dec router.Decision)
|
||||
// (V-564). Finish names everyone below the winner.
|
||||
decision.Expect(ctx, decision.StageQuery, querySourceNames())
|
||||
rec := decision.From(ctx)
|
||||
walk, skipped := queryWalk(dec.Source)
|
||||
walk, skipped := queryWalk(dec.Source, dec.SourceAnchored)
|
||||
for _, src := range skipped {
|
||||
rec.Note(decision.Claim{
|
||||
Stage: decision.StageQuery, Claimant: src.name, Outcome: decision.NeverAsked,
|
||||
@@ -912,6 +927,28 @@ func (h *reactiveHandler) queryKiwix(ctx context.Context, t *queryTurn) (string,
|
||||
}
|
||||
}
|
||||
|
||||
// The topic, not the sentence (V-668). Kiwix ranks by keyword overlap, so
|
||||
// the question words outrank the one word that names the article: measured
|
||||
// on 2026-08-09, "что такое TCP" returns "Перехват TCP-соединения" and
|
||||
// "TCP" returns TCP. Only the verbatim path needs this. The rewriter
|
||||
// already reduces a question to English keywords, and reducing twice would
|
||||
// take the topic off the input it reads.
|
||||
if verbatim {
|
||||
if topic := kiwix.Topic(pattern); topic != "" {
|
||||
// The article named exactly, before any ranking runs. A ZIM is
|
||||
// addressable by title and a wrong title is a 404, so this either
|
||||
// answers or costs one request that says nothing.
|
||||
for _, cand := range kiwix.TitleCandidates(topic) {
|
||||
page, err := h.kiwix.client.Article(ctxK, kiwix.TitlePath(book, cand), h.kiwix.runes)
|
||||
if err == nil && page.Text != "" {
|
||||
log.Printf("voice: kiwix: %q in %q → title hit %q", topic, book, page.Title)
|
||||
return h.kiwixReply(ctx, t, page.Title, page.Text)
|
||||
}
|
||||
}
|
||||
pattern = topic
|
||||
}
|
||||
}
|
||||
|
||||
hits, err := h.kiwix.client.Search(ctxK, pattern, book, h.kiwix.max)
|
||||
if err != nil {
|
||||
log.Printf("voice: kiwix: search %q: %v", pattern, err)
|
||||
@@ -944,14 +981,18 @@ func (h *reactiveHandler) queryKiwix(ctx context.Context, t *queryTurn) (string,
|
||||
}
|
||||
page = crawl.Page{Title: top.Title, Text: top.Snippet}
|
||||
}
|
||||
// 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)
|
||||
return h.kiwixReply(ctx, t, top.Title, page.Text)
|
||||
}
|
||||
|
||||
// kiwixReply hands one article over the same way a note or a page is handed
|
||||
// over: context for the question he asked, not something to recite.
|
||||
func (h *reactiveHandler) kiwixReply(ctx context.Context, t *queryTurn, title, text string) (string, bool) {
|
||||
snippet := title + "\n" + crawl.TrimRunes(text, h.kiwix.runes)
|
||||
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.
|
||||
return readBack(top.Title + " — " + page.Text), true
|
||||
return readBack(title + " — " + text), true
|
||||
}
|
||||
return reply, true
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
// whose model is down names nothing, and naming nothing has to walk the chain
|
||||
// the way it walked before the field existed.
|
||||
func TestNoDestinationWalksTheWholeChain(t *testing.T) {
|
||||
walk, skipped := queryWalk(router.SourceUnknown)
|
||||
walk, skipped := queryWalk(router.SourceUnknown, false)
|
||||
if len(skipped) != 0 {
|
||||
t.Errorf("skipped %d sources with no destination named, want none", len(skipped))
|
||||
}
|
||||
@@ -32,15 +32,16 @@ func TestANamedDestinationSilencesTheOtherGuessers(t *testing.T) {
|
||||
dest router.Source
|
||||
utterance string
|
||||
silenced string
|
||||
anchored bool // a stage 0 grammar named the destination
|
||||
}{
|
||||
{router.SourceWorld, "что такое TCP?", "weather"},
|
||||
{router.SourceWorld, "сколько будет 17 на 23?", "weather"},
|
||||
{router.SourceWorld, "кто такой Линус Торвальдс?", "personal"},
|
||||
{router.SourceRecall, "какой у меня любимый язык?", "feeds"},
|
||||
{router.SourceCalendar, "что в календаре на завтра?", "weather"},
|
||||
{router.SourceWorld, "что такое TCP?", "weather", true},
|
||||
{router.SourceWorld, "сколько будет 17 на 23?", "weather", true},
|
||||
{router.SourceWorld, "кто такой Линус Торвальдс?", "personal", true},
|
||||
{router.SourceRecall, "какой у меня любимый язык?", "feeds", false},
|
||||
{router.SourceCalendar, "что в календаре на завтра?", "weather", true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
walk, skipped := queryWalk(c.dest)
|
||||
walk, skipped := queryWalk(c.dest, c.anchored)
|
||||
if inWalk(walk, c.silenced) {
|
||||
t.Errorf("%q named %q: %q is still asked", c.utterance, c.dest, c.silenced)
|
||||
}
|
||||
@@ -56,7 +57,7 @@ func TestANamedDestinationSilencesTheOtherGuessers(t *testing.T) {
|
||||
// data first, then the world", and a destination a model wrote must not be able
|
||||
// to reverse it.
|
||||
func TestNamingTheWorldStillReadsHisDataFirst(t *testing.T) {
|
||||
walk, _ := queryWalk(router.SourceWorld)
|
||||
walk, _ := queryWalk(router.SourceWorld, true)
|
||||
for _, look := range []string{"fact-by-key", "embed", "memory", "notes"} {
|
||||
if !inWalk(walk, look) {
|
||||
t.Errorf("%q was dropped; only the sources that guess may be dropped", look)
|
||||
@@ -74,7 +75,7 @@ func TestNamingTheWorldStillReadsHisDataFirst(t *testing.T) {
|
||||
// makes "какой у меня любимый язык?" answer "не нашла у тебя такой записи"
|
||||
// rather than reaching SearXNG once nothing local had it.
|
||||
func TestNamingRecallKeepsTheBoundary(t *testing.T) {
|
||||
walk, _ := queryWalk(router.SourceRecall)
|
||||
walk, _ := queryWalk(router.SourceRecall, true)
|
||||
if !inWalk(walk, "personal") {
|
||||
t.Fatal("the personal boundary was skipped on a turn named for his own data")
|
||||
}
|
||||
@@ -83,12 +84,33 @@ func TestNamingRecallKeepsTheBoundary(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The owner's call of 2026-08-09 (V-666): only a stage 0 grammar may take the
|
||||
// personal boundary off a turn. The routing heads and the resident model both
|
||||
// name a destination by inference, and an inferred SourceWorld would send a
|
||||
// question about him upstream. Every other guesser still goes.
|
||||
func TestOnlyAGrammarMayDropTheBoundary(t *testing.T) {
|
||||
walk, skipped := queryWalk(router.SourceWorld, false)
|
||||
if !inWalk(walk, "personal") {
|
||||
t.Error("an inferred destination took the boundary off the turn")
|
||||
}
|
||||
if !inWalk(skipped, "weather") {
|
||||
t.Error("weather is still asked; the rule covers the boundary alone")
|
||||
}
|
||||
if posOf(walk, "personal") > posOf(walk, "search") {
|
||||
t.Error("the boundary no longer sits in front of the world")
|
||||
}
|
||||
if anchored, _ := queryWalk(router.SourceWorld, true); inWalk(anchored, "personal") {
|
||||
t.Error(`a grammar named the world and the boundary stayed: ` +
|
||||
`"кто такой Линус Торвальдс?" is answered "не нашла у тебя такой записи" again`)
|
||||
}
|
||||
}
|
||||
|
||||
// Whatever the destination, the walk is a subsequence of the table. Every
|
||||
// comment on that table argues an order between two sources, and none of those
|
||||
// reasons is about this field.
|
||||
func TestTheWalkNeverReordersTheTable(t *testing.T) {
|
||||
for _, dest := range append([]router.Source{router.SourceUnknown}, router.Sources...) {
|
||||
walk, skipped := queryWalk(dest)
|
||||
walk, skipped := queryWalk(dest, true)
|
||||
if len(walk)+len(skipped) != len(querySources) {
|
||||
t.Errorf("%q: %d walked + %d skipped, want %d", dest, len(walk), len(skipped), len(querySources))
|
||||
}
|
||||
|
||||
+27
-7
@@ -5,12 +5,17 @@
|
||||
// is detected sends it as a PushToTalk frame to the voice server. The reply
|
||||
// audio is played back through aplay(1).
|
||||
//
|
||||
// No wake-word model yet (MVP uses voice-activity-only trigger). The
|
||||
// SurfaceVoice auth layer caps all commands at L0 (no destructive acts),
|
||||
// making accidental triggers safe by design. A proper wake-word engine
|
||||
// (openWakeWord / Silero VAD ONNX) is the planned upgrade — the VAD shape
|
||||
// (30ms frames, 16kHz PCM) matches silero-vad's input interface exactly, so
|
||||
// swapping energy-threshold for ONNX-inference is a local change in vad.go.
|
||||
// Voice activity is silero-vad when -vad-model points at the graph, and an
|
||||
// energy threshold when it does not. Silero declines noise the threshold
|
||||
// accepts: 0 frames against 68 to 99 on the four fixtures, measured in
|
||||
// docs/evals/2026-08-09-silero-vad.md. Note that the model window is 512
|
||||
// samples and the capture frame is 480, so silero.go re-chunks. This comment
|
||||
// used to say the two matched, which was true of silero v4.
|
||||
//
|
||||
// There is still no wake-word model, so anything spoken near the microphone
|
||||
// becomes a turn (V-487 stage two). The SurfaceVoice auth layer caps all
|
||||
// commands at L0 (no destructive acts), which is what makes an accidental
|
||||
// trigger safe rather than expensive.
|
||||
//
|
||||
// While a reply is playing the capture side is muted (half-duplex): without
|
||||
// it, Maven's own voice comes back in through the mic and she answers
|
||||
@@ -73,6 +78,9 @@ func run(args []string) error {
|
||||
bargeIn := flag.Bool("barge-in", false, "cut Maven off when he talks over her (needs a room-tuned -barge-in-rms)")
|
||||
bargeRMS := flag.Int("barge-in-rms", defaultBargeRMS, "RMS x10000 a frame must clear to count as barge-in")
|
||||
bargeFrames := flag.Int("barge-in-frames", defaultBargeFrames, "consecutive frames over -barge-in-rms before playback is cut")
|
||||
vadModel := flag.String("vad-model", "", "silero-vad onnx file; empty runs the energy threshold instead")
|
||||
vadThreshold := flag.Float64("vad-threshold", defaultSileroThreshold, "speech probability a frame must clear")
|
||||
onnxLib := flag.String("onnx-lib", os.Getenv("MAVEN_ONNX_LIB"), "libonnxruntime.so, needed with -vad-model")
|
||||
flag.CommandLine.Parse(args)
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP)
|
||||
@@ -82,8 +90,20 @@ func run(args []string) error {
|
||||
vc := voice.Dial(*addr)
|
||||
defer vc.Close()
|
||||
|
||||
// VAD engine.
|
||||
// VAD engine. A model that will not load is logged and not fatal: the
|
||||
// energy threshold is worse, and it is a great deal better than a
|
||||
// listening client that refuses to start.
|
||||
vad := NewVAD(*minRMS, *speechMs, *silenceMs, *maxMs)
|
||||
if *vadModel != "" {
|
||||
s, err := newSileroVAD(*vadModel, *onnxLib)
|
||||
if err != nil {
|
||||
log.Printf("mavwaked: silero unavailable, energy threshold unchanged: %v", err)
|
||||
} else {
|
||||
defer s.Close()
|
||||
vad.UseSilero(s, *vadThreshold)
|
||||
log.Printf("mavwaked: silero-vad from %s, threshold %.2f", *vadModel, *vadThreshold)
|
||||
}
|
||||
}
|
||||
|
||||
// Audio source.
|
||||
var src io.ReadCloser
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
package main
|
||||
|
||||
// silero-vad, the speech detector that replaces the energy threshold (V-487).
|
||||
//
|
||||
// Why an energy threshold is not a voice activity detector. It answers "is
|
||||
// this frame loud", and a fan, a door and a television are all loud. mavwaked
|
||||
// sends every utterance it accepts to speech-to-text and then to the daemon,
|
||||
// so a false trigger is a turn Maven takes on something nobody said to her.
|
||||
// Silero answers "is this frame speech", which is the question.
|
||||
//
|
||||
// It is 2.3MB of ONNX and runs on one CPU core in real time. That is not an
|
||||
// aside: this is the one model in the system that may never be offloaded or
|
||||
// gated on GPU admission, because a wake path that waits on a card is not a
|
||||
// wake path.
|
||||
//
|
||||
// Nil is a working value. Without -vad-model the daemon runs the energy VAD
|
||||
// exactly as it did before this file existed.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
ort "github.com/yalue/onnxruntime_go"
|
||||
)
|
||||
|
||||
const (
|
||||
// sileroWindow — samples per inference at 16kHz. The model is fixed at
|
||||
// 512 and does not accept another size, which is why this file
|
||||
// re-chunks rather than reusing the 480-sample capture frame. main.go
|
||||
// used to claim the two matched; that was true of silero v4.
|
||||
sileroWindow = 512
|
||||
|
||||
// sileroContext — samples of the previous window prepended to each
|
||||
// inference, as the reference implementation does. Without it the first
|
||||
// milliseconds of every window are judged with no history and speech
|
||||
// onsets score low.
|
||||
sileroContext = 64
|
||||
|
||||
// sileroState — the LSTM state carried between windows, [2][1][128].
|
||||
sileroStateDim = 128
|
||||
|
||||
// defaultSileroThreshold — probability above which a window is speech.
|
||||
// 0.5 is the reference default. Raising it costs speech onsets, which
|
||||
// are the quietest part of an utterance.
|
||||
defaultSileroThreshold = 0.5
|
||||
)
|
||||
|
||||
// sileroVAD holds one ONNX session and the streaming state around it. It is
|
||||
// fed 30ms capture frames and answers per frame, buffering across calls
|
||||
// because 480 samples never line up with a 512-sample window.
|
||||
type sileroVAD struct {
|
||||
mu sync.Mutex
|
||||
session *ort.DynamicAdvancedSession
|
||||
|
||||
pending []float32 // samples not yet part of a full window
|
||||
context [sileroContext]float32 // tail of the previous window
|
||||
state []float32 // [2][1][128], carried between windows
|
||||
last float64 // most recent probability, held between windows
|
||||
sr []int64
|
||||
}
|
||||
|
||||
// newSileroVAD loads the graph. The ONNX environment is initialised here when
|
||||
// nothing else has done it, because mavwaked has no embedder to do it first.
|
||||
func newSileroVAD(modelPath, libPath string) (*sileroVAD, error) {
|
||||
if !ort.IsInitialized() {
|
||||
if libPath != "" {
|
||||
ort.SetSharedLibraryPath(libPath)
|
||||
}
|
||||
if err := ort.InitializeEnvironment(); err != nil {
|
||||
return nil, fmt.Errorf("silero: onnx runtime: %w", err)
|
||||
}
|
||||
}
|
||||
s, err := ort.NewDynamicAdvancedSession(modelPath,
|
||||
[]string{"input", "state", "sr"}, []string{"output", "stateN"}, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("silero: load %s: %w", modelPath, err)
|
||||
}
|
||||
return &sileroVAD{
|
||||
session: s,
|
||||
state: make([]float32, 2*sileroStateDim),
|
||||
sr: []int64{16000},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Speech reports whether the frame carries speech, and the probability behind
|
||||
// that answer. A frame that completes no window inherits the previous
|
||||
// probability, so the caller sees one answer per frame either way.
|
||||
func (s *sileroVAD) Speech(frame []int16, threshold float64) (bool, float64) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
for _, v := range frame {
|
||||
s.pending = append(s.pending, float32(v)/32768.0)
|
||||
}
|
||||
for len(s.pending) >= sileroWindow {
|
||||
p, err := s.infer(s.pending[:sileroWindow])
|
||||
if err != nil {
|
||||
// A failed inference must not silence the microphone. Hold the
|
||||
// last answer and let the next window try again.
|
||||
break
|
||||
}
|
||||
s.last = p
|
||||
s.pending = s.pending[sileroWindow:]
|
||||
}
|
||||
return s.last >= threshold, s.last
|
||||
}
|
||||
|
||||
// infer runs one window and rolls the state and the context forward.
|
||||
func (s *sileroVAD) infer(window []float32) (float64, error) {
|
||||
in := make([]float32, sileroContext+sileroWindow)
|
||||
copy(in, s.context[:])
|
||||
copy(in[sileroContext:], window)
|
||||
|
||||
inT, err := ort.NewTensor(ort.NewShape(1, int64(len(in))), in)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer inT.Destroy()
|
||||
stT, err := ort.NewTensor(ort.NewShape(2, 1, sileroStateDim), s.state)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer stT.Destroy()
|
||||
srT, err := ort.NewTensor(ort.NewShape(1), s.sr)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer srT.Destroy()
|
||||
|
||||
out, err := ort.NewEmptyTensor[float32](ort.NewShape(1, 1))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer out.Destroy()
|
||||
next, err := ort.NewEmptyTensor[float32](ort.NewShape(2, 1, sileroStateDim))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer next.Destroy()
|
||||
|
||||
if err := s.session.Run(
|
||||
[]ort.Value{inT, stT, srT},
|
||||
[]ort.Value{out, next},
|
||||
); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
copy(s.state, next.GetData())
|
||||
copy(s.context[:], in[len(in)-sileroContext:])
|
||||
return float64(out.GetData()[0]), nil
|
||||
}
|
||||
|
||||
// Reset drops the streaming state. Called at every utterance boundary and
|
||||
// after barge-in, so echo-era history never scores the next sentence.
|
||||
func (s *sileroVAD) Reset() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.pending = s.pending[:0]
|
||||
s.context = [sileroContext]float32{}
|
||||
for i := range s.state {
|
||||
s.state[i] = 0
|
||||
}
|
||||
s.last = 0
|
||||
}
|
||||
|
||||
// Close releases the session.
|
||||
func (s *sileroVAD) Close() error {
|
||||
if s == nil || s.session == nil {
|
||||
return nil
|
||||
}
|
||||
return s.session.Destroy()
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package main
|
||||
|
||||
// What this measures. The energy threshold cannot tell a voice from a
|
||||
// television, and every utterance it accepts becomes a turn. So the test that
|
||||
// matters is not "does silero find speech" — it is "does it decline what the
|
||||
// energy threshold accepts".
|
||||
//
|
||||
// Speech is the four piper fixtures mavsttd already scores against. They are
|
||||
// synthesised, so nothing of the owner's voice is committed. Non-speech is
|
||||
// white noise at the same loudness, which is the cheapest thing that fools an
|
||||
// energy floor and the honest floor for this claim.
|
||||
//
|
||||
// Both halves skip without models/vad/silero_vad.onnx and MAVEN_ONNX_LIB,
|
||||
// like the TestONNX measurements in internal/router/eval.
|
||||
|
||||
import (
|
||||
"math"
|
||||
"math/rand"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const wavHeader = 44 // 16kHz mono s16le, written by piper
|
||||
|
||||
func loadSilero(t *testing.T) *sileroVAD {
|
||||
t.Helper()
|
||||
model := filepath.Join("..", "..", "models", "vad", "silero_vad.onnx")
|
||||
lib := os.Getenv("MAVEN_ONNX_LIB")
|
||||
if _, err := os.Stat(model); err != nil {
|
||||
t.Skipf("missing %s: %v", model, err)
|
||||
}
|
||||
if lib == "" {
|
||||
t.Skip("MAVEN_ONNX_LIB unset")
|
||||
}
|
||||
s, err := newSileroVAD(model, lib)
|
||||
if err != nil {
|
||||
t.Skipf("silero unavailable: %v", err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// feedAll runs a whole clip through a VAD and reports how many utterances it
|
||||
// produced and how many frames it called speech.
|
||||
func feedAll(v *VAD, pcm []int16) (utterances, speechFrames int) {
|
||||
for i := 0; i+frameSamples <= len(pcm); i += frameSamples {
|
||||
frame := pcm[i : i+frameSamples]
|
||||
utt, state := v.Feed(frame)
|
||||
if state == StateSpeech {
|
||||
speechFrames++
|
||||
}
|
||||
if utt.Bytes != nil {
|
||||
utterances++
|
||||
}
|
||||
}
|
||||
return utterances, speechFrames
|
||||
}
|
||||
|
||||
func readFixture(t *testing.T, name string) []int16 {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(filepath.Join("..", "mavsttd", "testdata", name))
|
||||
if err != nil {
|
||||
t.Skipf("missing fixture %s: %v", name, err)
|
||||
}
|
||||
if len(raw) <= wavHeader {
|
||||
t.Fatalf("%s: %d bytes, no audio", name, len(raw))
|
||||
}
|
||||
return PCMToI16(raw[wavHeader:])
|
||||
}
|
||||
|
||||
// noise returns white noise scaled to the same RMS as ref. Same loudness,
|
||||
// nothing said.
|
||||
func noise(ref []int16, seed int64) []int16 {
|
||||
target := frameRMS(ref)
|
||||
r := rand.New(rand.NewSource(seed))
|
||||
out := make([]int16, len(ref))
|
||||
for i := range out {
|
||||
out[i] = int16(r.NormFloat64() * target * 32768.0)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestSileroHearsSpeechAndDeclinesNoise(t *testing.T) {
|
||||
s := loadSilero(t)
|
||||
defer s.Close()
|
||||
|
||||
for _, name := range []string{"ru_fact.wav", "ru_query.wav", "ru_reminder.wav", "en_act.wav"} {
|
||||
pcm := readFixture(t, name)
|
||||
|
||||
v := NewVAD(0, 0, 0, 0)
|
||||
v.UseSilero(s, defaultSileroThreshold)
|
||||
_, spoke := feedAll(v, pcm)
|
||||
if spoke == 0 {
|
||||
t.Errorf("%s: silero heard no speech in a spoken clip", name)
|
||||
}
|
||||
|
||||
s.Reset()
|
||||
v2 := NewVAD(0, 0, 0, 0)
|
||||
v2.UseSilero(s, defaultSileroThreshold)
|
||||
_, heard := feedAll(v2, noise(pcm, 7))
|
||||
|
||||
energy := NewVAD(0, 0, 0, 0)
|
||||
_, energyHeard := feedAll(energy, noise(pcm, 7))
|
||||
|
||||
t.Logf("%s: speech frames — silero on speech %d, silero on noise %d, energy on noise %d",
|
||||
name, spoke, heard, energyHeard)
|
||||
if heard >= energyHeard {
|
||||
t.Errorf("%s: silero called %d noise frames speech, energy called %d — no improvement",
|
||||
name, heard, energyHeard)
|
||||
}
|
||||
s.Reset()
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkSileroFrame answers the only performance question that matters
|
||||
// here: one 30ms frame must cost far less than 30ms on one core, or the
|
||||
// detector cannot run always-on beside everything else on that machine.
|
||||
func BenchmarkSileroFrame(b *testing.B) {
|
||||
s := loadSilero(&testing.T{})
|
||||
if s == nil {
|
||||
b.Skip("silero unavailable")
|
||||
}
|
||||
defer s.Close()
|
||||
frame := make([]int16, frameSamples)
|
||||
for i := range frame {
|
||||
frame[i] = int16(i%400 - 200)
|
||||
}
|
||||
for i := 0; i < b.N; i++ {
|
||||
s.Speech(frame, defaultSileroThreshold)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSileroRechunksAcrossFrames pins the reason this file exists. The capture
|
||||
// frame is 480 samples and the model window is 512, so a detector that ran one
|
||||
// inference per frame would be feeding the model a shape it does not accept.
|
||||
func TestSileroRechunksAcrossFrames(t *testing.T) {
|
||||
s := loadSilero(t)
|
||||
defer s.Close()
|
||||
|
||||
silence := make([]int16, frameSamples)
|
||||
for i := 0; i < 20; i++ {
|
||||
if _, p := s.Speech(silence, defaultSileroThreshold); math.IsNaN(p) {
|
||||
t.Fatalf("frame %d: probability is NaN", i)
|
||||
}
|
||||
}
|
||||
if len(s.pending) >= sileroWindow {
|
||||
t.Errorf("pending grew to %d samples, so windows are not being consumed", len(s.pending))
|
||||
}
|
||||
}
|
||||
+35
-1
@@ -68,6 +68,37 @@ type VAD struct {
|
||||
// follows the room's ambient level. Initialised to minRMS; updated
|
||||
// on each silence frame.
|
||||
floorRMS float64
|
||||
|
||||
// speech is silero-vad, or nil. When it is set the energy floor decides
|
||||
// nothing: the question becomes "is this speech" rather than "is this
|
||||
// loud", and the noise floor is not even tracked. Everything after that
|
||||
// answer — the speech hold, the silence hold, the length cap, the
|
||||
// buffer — is the same state machine either way, which is why the
|
||||
// detector goes here and not around this type.
|
||||
speech *sileroVAD
|
||||
speechMin float64
|
||||
}
|
||||
|
||||
// UseSilero swaps the energy threshold for the model. Passing nil is a
|
||||
// no-op, so a caller that could not load the graph keeps a working VAD.
|
||||
func (v *VAD) UseSilero(s *sileroVAD, threshold float64) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
if threshold <= 0 {
|
||||
threshold = defaultSileroThreshold
|
||||
}
|
||||
v.speech = s
|
||||
v.speechMin = threshold
|
||||
}
|
||||
|
||||
// isSpeech answers the one question the state machine asks of a frame.
|
||||
func (v *VAD) isSpeech(frame []int16, rms float64) bool {
|
||||
if v.speech != nil {
|
||||
ok, _ := v.speech.Speech(frame, v.speechMin)
|
||||
return ok
|
||||
}
|
||||
return rms >= v.floorRMS
|
||||
}
|
||||
|
||||
// NewVAD creates a VAD with the given thresholds. Zero values use defaults.
|
||||
@@ -110,7 +141,7 @@ func (v *VAD) State() SpeechState { return v.state }
|
||||
// should send the audio to the voice server before feeding more frames.
|
||||
func (v *VAD) Feed(frame []int16) (_ audio.Audio, state SpeechState) {
|
||||
rms := frameRMS(frame)
|
||||
isSpeech := rms >= v.floorRMS
|
||||
isSpeech := v.isSpeech(frame, rms)
|
||||
|
||||
switch v.state {
|
||||
case StateSilence:
|
||||
@@ -175,6 +206,9 @@ func (v *VAD) Feed(frame []int16) (_ audio.Audio, state SpeechState) {
|
||||
func (v *VAD) Reset() { v.reset() }
|
||||
|
||||
func (v *VAD) reset() {
|
||||
if v.speech != nil {
|
||||
v.speech.Reset()
|
||||
}
|
||||
v.state = StateSilence
|
||||
v.speechFrames = 0
|
||||
v.silenceFrames = 0
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
# maven-voice-tunnel — the ssh leg that carries the voice wire to homesrv.
|
||||
#
|
||||
# Runs on workpc, as a user unit (`systemctl --user`), beside mavgpud.service.
|
||||
#
|
||||
# WHY THIS EXISTS AT ALL. internal/voice is plaintext and unauthenticated.
|
||||
# Its own server doc says production binds inside the wg tunnel, because "the
|
||||
# wg layer IS the L0 floor". workpc is not a wg peer, it sits on wlan0. So ssh
|
||||
# is the substitute floor: it authenticates with his key and encrypts the leg,
|
||||
# and mavend's published port stays on homesrv loopback (127.0.0.1:9110).
|
||||
# Nothing about this puts a Maven port on the LAN.
|
||||
#
|
||||
# Do not replace this with a LAN bind. SurfaceVoice caps acts at L0, so an
|
||||
# unauthorized speaker could not run a destructive tool. It would still hear
|
||||
# his facts, his notes and his calendar read back, and L0 does not cap reading.
|
||||
#
|
||||
# install: cp to ~/.config/systemd/user/ on workpc
|
||||
# systemctl --user enable --now maven-voice-tunnel.service
|
||||
|
||||
[Unit]
|
||||
Description=SSH tunnel to mavend's voice wire on homesrv
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
# -N: no remote command, forwarding only.
|
||||
# ExitOnForwardFailure: fail loudly rather than sit up with a dead forward,
|
||||
# which is what makes Restart meaningful.
|
||||
# ServerAlive*: a laptop that suspends drops the tunnel silently otherwise.
|
||||
ExecStart=/usr/bin/ssh -N \
|
||||
-o ExitOnForwardFailure=yes \
|
||||
-o ServerAliveInterval=30 \
|
||||
-o ServerAliveCountMax=3 \
|
||||
-o BatchMode=yes \
|
||||
-L 127.0.0.1:9100:127.0.0.1:9110 \
|
||||
kami@192.168.1.104
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
@@ -0,0 +1,53 @@
|
||||
# mavwaked — always-on listening, on workpc where the microphone is.
|
||||
#
|
||||
# User unit, beside mavgpud.service and maven-voice-tunnel.service. It is a
|
||||
# user unit because it needs his ALSA session and his ssh agent, and because
|
||||
# it should stop when he logs out.
|
||||
#
|
||||
# THERE IS NO WAKE WORD YET (V-487 stage two). Anything spoken near the fifine
|
||||
# becomes a turn. What makes that safe rather than expensive is voiceSender:
|
||||
# it sends Surface=SurfaceVoice, which caps every command at L0, so no
|
||||
# accidental trigger runs a destructive act. It does not stop her answering
|
||||
# out loud, so this unit is his to stop when the room is not his alone.
|
||||
#
|
||||
# -vad-model is passed on purpose. Silero answers "is this frame speech" where
|
||||
# the energy floor answers "is this frame loud". It declines white noise at
|
||||
# the same RMS 0 frames to 68-99, and still hears all four spoken fixtures
|
||||
# (docs/evals/2026-08-09-silero-vad.md). It costs 509us a frame, 1.7% of one
|
||||
# core, and never touches the GPU. Drop the flag and the energy floor is back.
|
||||
#
|
||||
# -barge-in is NOT passed. The threshold is room-specific and this room has no
|
||||
# number yet. Turn it on only after reading the "suppressed while speaking"
|
||||
# means out of this unit's own journal, never by guessing.
|
||||
#
|
||||
# install: cp to ~/.config/systemd/user/ on workpc
|
||||
# systemctl --user enable --now mavwaked.service
|
||||
|
||||
[Unit]
|
||||
Description=Maven always-on listening (VAD, no wake word yet)
|
||||
# The tunnel is the only path to mavend and the only thing authenticating it.
|
||||
Requires=maven-voice-tunnel.service
|
||||
After=maven-voice-tunnel.service
|
||||
|
||||
[Service]
|
||||
# card 0 is the fifine USB microphone. Named, and not "default", because the
|
||||
# default device follows whatever pipewire last decided and this daemon should
|
||||
# not change ears when he plugs in a headset.
|
||||
#
|
||||
# plughw and not hw. mavwaked asks arecord for 16kHz mono, which is what the
|
||||
# whole pipeline is canonical in. The fifine offers 2 channels at 44100 or
|
||||
# 48000 and nothing else, so bare hw:0,0 dies on "Channels count non
|
||||
# available" before a frame is read. plughw puts ALSA's downmix and resampler
|
||||
# in front. Any replacement microphone wants the same treatment.
|
||||
Environment=LD_LIBRARY_PATH=%h/.local/lib
|
||||
ExecStart=%h/.local/bin/mavwaked \
|
||||
-device plughw:0,0 \
|
||||
-addr 127.0.0.1:9100 \
|
||||
-lang ru \
|
||||
-vad-model %h/.local/share/maven/models/silero_vad.onnx \
|
||||
-onnx-lib %h/.local/lib/libonnxruntime.so
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
@@ -53,6 +53,19 @@ services:
|
||||
# the decrypted working copy lives in RAM (see db_tmpfs in mavend.json).
|
||||
tmpfs:
|
||||
- /dev/shm
|
||||
# the voice wire, for mavwaked and mavenclient on workpc (V-515).
|
||||
#
|
||||
# LOOPBACK ONLY, and that is the whole security argument. internal/voice
|
||||
# is plaintext with no auth: its own server doc says production binds
|
||||
# inside the wg tunnel, "the wg layer IS the L0 floor". workpc is not a wg
|
||||
# peer, it is on wlan0. So the tunnel is ssh instead, terminated on this
|
||||
# loopback address, and nothing new is on the LAN. Anyone who could reach
|
||||
# a LAN-bound port here could push audio and hear his facts read back.
|
||||
# SurfaceVoice caps acts at L0; it does not cap reading.
|
||||
#
|
||||
# Host 9100 is Vikunja's MCP, hence 9110. The container side stays 9100
|
||||
# so mavweb keeps reaching mavend:9100 by name.
|
||||
ports: ["127.0.0.1:9110:9100"]
|
||||
|
||||
mavsttd:
|
||||
<<: *image
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
# gemma-4-E4B on the phrasing and talk fixtures
|
||||
|
||||
Date: 2026-08-09. Box: workpc up, E4B loaded on 8080.
|
||||
`MAVEN_LLM_URL=http://192.168.1.105:8080 make eval-phrasing`.
|
||||
|
||||
This was the one unmeasured risk of the 2026-08-09 model swap. Routing was
|
||||
measured the same day and E4B lost four destination cases to the 12B. Phrasing
|
||||
was not measured at all, and phrasing is the half the owner hears.
|
||||
|
||||
## Result
|
||||
|
||||
| fixture | E4B | resident Qwen3-1.7B, 2026-08-05 |
|
||||
|---|---|---|
|
||||
| nudges | 15/15 (100%) | 15/15 (100%) |
|
||||
| talk, passes every check | **29/36 (80.6%)** | 25/36 (69.4%) |
|
||||
| lang | 36/36 | — |
|
||||
| feminine | 36/36 | 36/36 |
|
||||
| address | **36/36** | 33/36 |
|
||||
| ontopic | 29/36 | 28/36 |
|
||||
| p50 latency | **516ms** | 2.97s |
|
||||
| p95 latency | 921ms | — |
|
||||
| failed generations | 0 | 0 |
|
||||
|
||||
E4B beats the homesrv floor by four cases and answers about six times faster.
|
||||
Persona is clean: `lang`, `feminine` and `address` are perfect, and `address`
|
||||
is where the resident model still loses three. The 2026-08-05 measurement of the
|
||||
resident model is the comparison, since both ran the same 36-case fixture.
|
||||
|
||||
Every failure is `ontopic`. Nothing failed on persona, nothing failed to parse.
|
||||
|
||||
## The score is at the ceiling, not below it
|
||||
|
||||
The 2026-08-05 temperature sweep found two cases that fail at every temperature
|
||||
in every run: `reply-note-router` and `reply-fact-weight`. It named a defect in
|
||||
the reply phrasing path rather than sampling noise. It put the fixture's ceiling
|
||||
at 30/36 before persona is scored. Both cases are in E4B's failure list.
|
||||
|
||||
So 29/36 is one case off a ceiling nothing about the model can move. The swap is
|
||||
safe on phrasing. Read this next to the routing result, not instead of it. There
|
||||
E4B costs four destination cases and buys 50ms. Here it costs nothing.
|
||||
|
||||
## Two findings no check caught
|
||||
|
||||
**She says she wrote something down when she did not.** Asked what to do this
|
||||
evening, E4B writes "Я записала несколько идей!". Asked for a joke, it writes
|
||||
"Я записала одну забавную ситуацию!". Nothing was stored. No check scores it,
|
||||
because `ontopic` reads the subject and `cringe` reads pet names. A claim to
|
||||
have saved something is a claim about state, and it is wrong.
|
||||
|
||||
**Two `ontopic` failures look like check defects.** `know-dont-know` wants
|
||||
"не зна" or "не мог". It got "Я не умею знать личную информацию о твоих
|
||||
соседях", which declines correctly in words the check does not list.
|
||||
`know-hiccups` is the same shape. Neither is a model failure and both count
|
||||
against the score.
|
||||
|
||||
## Not measured here
|
||||
|
||||
A 12B control on the same fixture, which would need the card reloaded and is the
|
||||
owner's call. The talk fixture through the daemon rather than through the
|
||||
phraser directly. The CPT'd Qwen3-1.7B, which does not exist yet and is the
|
||||
reason `address` is a check at all.
|
||||
@@ -0,0 +1,113 @@
|
||||
# Kiwix answered the wrong question, and the fix was not a relevance gate
|
||||
|
||||
Date: 2026-08-09. Task: V-668. Box: homesrv, workstation off.
|
||||
Book: `wikipedia_ru_all_maxi_2026-02` on `127.0.0.1:8034`.
|
||||
|
||||
## What started it
|
||||
|
||||
Two turns on 2026-08-09 came back wrong from the offline encyclopedia.
|
||||
"почему небо голубое" was answered off the song "Город золотой". "что такое
|
||||
TCP?" was answered off "Перехват TCP-соединения". Both were phrased
|
||||
confidently, because `queryKiwix` claims a turn whenever the search returns
|
||||
anything and `len(hits) == 0` is its only gate.
|
||||
|
||||
The plan was a relevance gate. multilingual-e5-small is asymmetric and trained
|
||||
for exactly this, `query:` against `passage:`, and the query vector is already
|
||||
held on the turn. The 2026-08-05 measurement that killed a search-quality gate
|
||||
killed three lexical signals. It says in its own words that it never probed
|
||||
Kiwix.
|
||||
|
||||
## The gate does not exist
|
||||
|
||||
Fourteen Russian questions, eight the encyclopedia can answer and six it
|
||||
cannot. Each question was searched, the top article read, and the cosine of
|
||||
`EmbedQuery(question)` against `EmbedPassage(article)` recorded.
|
||||
|
||||
| set | n | min | mean | max |
|
||||
|---|---|---|---|---|
|
||||
| answerable | 8 | 0.7934 | 0.8400 | 0.9087 |
|
||||
| not answerable | 6 | 0.7480 | 0.7852 | 0.8367 |
|
||||
|
||||
Two of the six unanswerable score above the weakest answerable one. That alone
|
||||
would be a poor threshold. The log killed it outright: seven of the eight
|
||||
answerable questions got a **wrong** article back, and those wrong articles
|
||||
scored high. The TCP hijacking article scored 0.8653, above five of the six
|
||||
unanswerable rows.
|
||||
|
||||
The finding is that this cosine measures topic and not answerhood. A page about
|
||||
hijacking TCP sessions is about TCP. No threshold separates it from a page that
|
||||
defines TCP, and one that tried would take the definition with it.
|
||||
|
||||
## The defect is retrieval
|
||||
|
||||
`internal/kiwix/client.go` has said it since it was written: ranking is keyword
|
||||
based, "why is the sky blue" finds a TV episode. `queryKiwix` sends the whole
|
||||
sentence. The English path has a rewriter that reduces a question to keywords
|
||||
with a model call. The Russian path reads the book verbatim (V-508) and had
|
||||
nothing. So the question words compete with the one word that names the article.
|
||||
|
||||
Dropping the question words changes the answer:
|
||||
|
||||
| sent | first hit |
|
||||
|---|---|
|
||||
| `кто написал Войну и мир` | Радуйся, мир (Доктор Кто) |
|
||||
| `Война и мир` | Война и мир |
|
||||
| `что такое TCP` | Перехват TCP-соединения |
|
||||
| `TCP` | TCP |
|
||||
|
||||
A ZIM is also addressable by title, which nothing here used. `/A/Франция`,
|
||||
`/A/TCP` and `/A/Небо` are 200. `/A/Трюмбальная_нидроскопия` is 404. So an
|
||||
exact title is safe to try first: it either answers or costs one request that
|
||||
says nothing.
|
||||
|
||||
The title has to carry its capital. `/A/фотосинтез` is a 404 and
|
||||
`/A/Фотосинтез` is a 200. The spoken form is tried first anyway, so a title
|
||||
that begins lowercase on purpose keeps its chance.
|
||||
|
||||
## What shipped, measured
|
||||
|
||||
`kiwix.Topic` drops the narrative request, the interrogative and a verb sitting
|
||||
behind one. It keeps everything else, because a word it cannot classify is more
|
||||
likely the topic than noise. `kiwix.TitlePath` tries the exact article before
|
||||
any ranking runs. Both apply on the verbatim path only, since reducing twice
|
||||
would take the topic off the rewriter's input.
|
||||
|
||||
| question | before | after |
|
||||
|---|---|---|
|
||||
| что такое TCP? | Перехват TCP-соединения | **TCP** (by title) |
|
||||
| что такое фотосинтез | C4-фотосинтез | **Фотосинтез** (by title) |
|
||||
| кто такой Линус Торвальдс? | Tux | **Торвальдс, Линус** (by title) |
|
||||
| кто написал Войну и мир | Радуйся, мир (Доктор Кто) | **Война и мир** |
|
||||
| столица Франции | Список столиц Олимпийских игр | **Париж** (by title) |
|
||||
| что такое чёрная дыра | Чёрная дыра | Чёрная дыра (by title) |
|
||||
| почему небо голубое | Город золотой | Под небом голубым… (фильм) |
|
||||
| почему трава зелёная | Сено | Зелень |
|
||||
|
||||
Five questions reach the right article where they did not. One was already
|
||||
right and stays right. Nothing regressed.
|
||||
|
||||
"столица Франции" is the surprise. The 2026-08-05 measurement named it as the
|
||||
case a quality gate must not break, because the answer is Париж and that word
|
||||
is not in the question. The ZIM holds a title redirect, so asking for the
|
||||
article titled "Столица Франции" returns Париж. Retrieval by title reaches an
|
||||
answer that retrieval by keyword cannot.
|
||||
|
||||
## What is still wrong
|
||||
|
||||
Two of the eight are still not answered, and both are the same shape. The
|
||||
question names no article and no redirect covers it. "почему небо голубое" is
|
||||
answered by Rayleigh scattering, and nothing in the question says so. Keyword
|
||||
retrieval cannot bridge that and neither can a threshold. The candidates are a
|
||||
semantic index over titles, or asking the resident model for the article title
|
||||
rather than for keywords.
|
||||
|
||||
`Response.Empty()` is still the whole gate. A wrong article that the search
|
||||
does return is still spoken. What this change buys is that the article is
|
||||
usually right, not that a wrong one is caught.
|
||||
|
||||
## Not measured here
|
||||
|
||||
The English path, which still goes through the rewriter and was not touched.
|
||||
SearXNG, where the same question about answerhood is open and the 2026-08-05
|
||||
result stands. The cascade end to end, since the workstation is off and the
|
||||
phrasing arm is the resident model.
|
||||
@@ -0,0 +1,63 @@
|
||||
# silero-vad against the energy threshold in mavwaked
|
||||
|
||||
*Measured 2026-08-09 on homesrv. V-487, stage one of two.*
|
||||
|
||||
mavwaked decided an utterance had started by comparing frame energy to an
|
||||
adaptive floor. That answers "is this frame loud". A fan, a door and a
|
||||
television are all loud, and every utterance mavwaked accepts becomes a turn.
|
||||
|
||||
silero-vad answers "is this frame speech". It is 2.3MB of ONNX and it replaces
|
||||
the comparison and nothing else. The speech hold, the silence hold, the length
|
||||
cap and the utterance buffer are the same state machine either way.
|
||||
|
||||
## What it declines
|
||||
|
||||
Speech is the four piper fixtures `mavsttd` already scores against, so nothing
|
||||
of the owner's voice is committed. Non-speech is white noise at the same RMS as the clip beside it. That is the
|
||||
cheapest thing that fools an energy floor.
|
||||
|
||||
| clip | silero, speech frames on speech | silero on noise | energy on noise |
|
||||
|---|---|---|---|
|
||||
| ru_fact.wav | 59 | 0 | 68 |
|
||||
| ru_query.wav | 69 | 0 | 79 |
|
||||
| ru_reminder.wav | 80 | 0 | 89 |
|
||||
| en_act.wav | 90 | 0 | 99 |
|
||||
|
||||
The energy threshold accepts every noise clip as a complete utterance. Silero
|
||||
calls not one frame of any of them speech, and still hears all four spoken
|
||||
clips. `TestSileroHearsSpeechAndDeclinesNoise` is that table.
|
||||
|
||||
White noise is a floor, not a proof. It says nothing about a television, which
|
||||
is speech, or about a fan, which is narrowband. Those need room recordings and
|
||||
this box has none.
|
||||
|
||||
## What it costs
|
||||
|
||||
`BenchmarkSileroFrame` on the homesrv laptop (Ryzen 5 5600U), one 30ms frame
|
||||
through the model including the re-chunking:
|
||||
|
||||
509µs per frame
|
||||
|
||||
That is 1.7% of one core, on the slower of the two machines. The detector runs
|
||||
on the workstation beside the microphone, never on the GPU. This number is what
|
||||
says it does not need one.
|
||||
|
||||
## The window is 512 samples, not 480
|
||||
|
||||
`cmd/mavwaked/main.go` claimed the frame contract matched silero's input
|
||||
exactly. That was true of silero v4. Version 5 takes exactly 512 samples at 16kHz, plus 64 samples of context from
|
||||
the previous window. So `sileroVAD` buffers across capture frames, and a frame
|
||||
completing no window inherits the previous probability. `TestSileroRechunksAcrossFrames` pins it.
|
||||
|
||||
## Still an energy gate by default
|
||||
|
||||
`-vad-model` is empty in the code default, so a deployment that does not pass
|
||||
it runs exactly what shipped before. Barge-in is untouched and deliberately so. It reads frame energy while she is
|
||||
speaking, which is a different question from whether the frame is speech.
|
||||
|
||||
## Not done here
|
||||
|
||||
The wake word. This is stage one of the two V-487 asks for. The second needs a
|
||||
keyword model that does not exist yet. The pretrained openWakeWord keywords are
|
||||
English, and a Russian one has to be trained. Until then anything spoken near
|
||||
the microphone still becomes a turn. It is now merely required to be speech.
|
||||
+457
@@ -0,0 +1,457 @@
|
||||
# Routing
|
||||
|
||||
*Last verified: 2026-08-09 @ 31b5093*
|
||||
|
||||
How an utterance becomes a `Decision`, why each stage exists, and what every
|
||||
stage has measured. `CLAUDE.md` carries the rules an agent must not break. This
|
||||
file carries the reasoning and the history behind them.
|
||||
|
||||
Two decisions come out of a route. **Intent** is one of seven values. **Source**
|
||||
is where the answer lives, and it is read on `IntentQuery` alone. They are scored
|
||||
separately, because one number hides which one moved.
|
||||
|
||||
## The cascade
|
||||
|
||||
| Stage | What it is | Where |
|
||||
|---|---|---|
|
||||
| 0 | Deterministic grammars over the utterance | `stage0.go`, `praxis.go`, `worldquery.go` |
|
||||
| 0b | Four ONNX heads on one e5-small forward pass | `heads.go` |
|
||||
| 1 | The resident model, GBNF-constrained JSON | `llmrouter.go` |
|
||||
| 2 | Nearest neighbour over frozen seed phrases | `classifier.go`, `embedder.go` |
|
||||
|
||||
Every stage may decline, and the next one answers. Any error at stage 0b or 1
|
||||
falls through, so a turn never breaks on a model.
|
||||
|
||||
The resident model arm is wired at `voice.go:214` through
|
||||
`pickLLMRouter(cfg.Voice.UseLLMRouter(), llmClient)`. The flag is
|
||||
`voice.llm_router` in `config.go`, `DefaultLLMRouter` is on, and
|
||||
`deploy/mavend.json` sets it `true`. With no llama-server to talk to,
|
||||
`pickLLMRouter` logs that and degrades to the classifier.
|
||||
|
||||
The classifier is the floor and not dead code. It runs when the resident model
|
||||
is off, when there is no llama-server to talk to, and on any per-turn error.
|
||||
Routing by seed similarity is the known cause of weak Russian queries. Deleting
|
||||
it would make a model outage a broken turn.
|
||||
|
||||
### Why there are two engines at all
|
||||
|
||||
The original design was the classifier alone. `docs/rearchitecture.md` replaced
|
||||
it with a model that emits structured JSON. The same weights phrase the reply.
|
||||
That demoted the embedder from a routing gate to a hint for recall. The model
|
||||
became the default on 2026-07-31.
|
||||
|
||||
The gap it buys is smaller than the design assumed. Measured 2026-08-02 on the
|
||||
77-case Russian fixture, the classifier scores 68.8% full accuracy at p50 16.6µs.
|
||||
Qwen3-1.7B scores 72.7% through the cascade. Four points, not a doubling.
|
||||
|
||||
An older figure of 36.8% for the classifier stood in `CLAUDE.md` until then. It
|
||||
predates the stage 0 rules and the seed additions. Both now score inside the
|
||||
classifier baseline.
|
||||
|
||||
Latency was misreported the same way. A figure of 2.7 seconds stood for two
|
||||
days and was contention rather than the model.
|
||||
`docs/evals/2026-07-31-routing.md` line 61 measures the router at p50 825ms and
|
||||
the cascade at p50 0.80s to 1.04s.
|
||||
|
||||
## Numbers
|
||||
|
||||
Three arms answer, so three numbers are live. Judge a routing change against the
|
||||
classifier and the resident model, since those are what always answer.
|
||||
|
||||
| Arm | Intent | Destination | p50 | Measured |
|
||||
|---|---|---|---|---|
|
||||
| classifier + ONNX | 76.0% (73/96) | 36.4% (12/33) | 16.6µs | 2026-08-08 |
|
||||
| resident Qwen3-1.7B, cascade | 80.2% | not measured | 1.19s | 2026-08-05 |
|
||||
| routing heads, cascade | 96.9% | 75.8% | 27.9ms | 2026-08-08 |
|
||||
| gemma-4-12b, cascade | 84.4% | 72.7% | 329ms | 2026-08-02 |
|
||||
| gemma-4-E4B, cascade | 89.6% | 57.6% (19/33) | 294ms | 2026-08-09 |
|
||||
|
||||
The fixture grew from 77 cases to 91 to 96. So a number is comparable only to
|
||||
another number on the same fixture. Sources:
|
||||
`docs/evals/2026-08-05-routing-resident-model.md`,
|
||||
`docs/evals/2026-08-02-workstation-gemma4-12b.md`,
|
||||
`docs/evals/2026-08-09-e4b-vs-12b-routing.md`,
|
||||
`docs/evals/2026-08-08-routing-heads-in-go.md`,
|
||||
`docs/evals/2026-08-08-destination-fixture.md`.
|
||||
|
||||
The resident model alone scores 37.4% full against 61.5% intent-only. The gap is
|
||||
slots and not routing. It routes `reminder` and leaves the time to the daemon,
|
||||
which is what the contract asks.
|
||||
|
||||
To re-run the resident model as router, start a **second** llama-server on a
|
||||
fixed host port. The resident one binds `--port 0` inside the container and no
|
||||
host process can reach it.
|
||||
|
||||
### The workstation is not the better router any more
|
||||
|
||||
It was, from 2026-08-02 until the heads landed. gemma-4-12b beat everything on
|
||||
the box at 84.4% intent and 72.7% destination. The heads beat it on both at a
|
||||
twelfth of the latency. The workstation stays the better phraser.
|
||||
|
||||
E4B replaced the 12B on 2026-08-09 by the owner's call. It is a step down on
|
||||
routing. Against a same-session 12B control it costs four destination cases and
|
||||
buys 50ms. Read destination as the finding. It names nothing where the 12B names
|
||||
`recall` or `calendar`, which is safe but walks the whole chain. It has no MTP
|
||||
and cannot be given any here. The only `gemma4-assistant` draft on disk is
|
||||
trained against the 12B's hidden states.
|
||||
|
||||
## Stage 0: what the grammars claim, and why
|
||||
|
||||
A rule at this stage is a claim. Either the model gets this wrong, or it wastes a
|
||||
second getting it right. Every rule was added against a measurement.
|
||||
|
||||
- **Agenda questions** (`AgendaQueryGrammars`, 2026-08-01). "что у меня сегодня",
|
||||
"во сколько у меня встреча" and anything naming a calendar go to `IntentQuery`.
|
||||
They were going to `IntentSystem`, where `replySystem` has no agenda arm and
|
||||
answered "пока не умею". Worth 2.6 points of full accuracy and calendar 0/2 to
|
||||
2/2.
|
||||
- **Rest of day and narrative** (V-498, 2026-08-04). `rest-of-day-query` claims
|
||||
"что дальше?". `NarrativeQueryGrammar` claims "расскажи про X", "объясни X" and
|
||||
"опиши X". Neither carries a question mark or an interrogative, so the model
|
||||
called both `IntentFact`. `IsQuestionShaped` caught the write downstream, so
|
||||
this was a latency and fixture defect rather than a correctness one. The
|
||||
narrative rule declines `chatNarrativeTopics`, because the query chain has no
|
||||
source that answers a joke or a bedtime story.
|
||||
- **Praxis** (V-516, 2026-08-05). `PraxisGrammars()` fills `Slots.Fn` with a
|
||||
capability name. These grammars are the **only** path to Praxis and not a
|
||||
faster one. The model reaches Praxis 0/12 alone, the same as the classifier.
|
||||
Nothing in the router prompt names a Praxis capability, so there is no string
|
||||
for it to write. Through the cascade it is 11/12. Measured overall 16/30 to
|
||||
27/30, lifecycle 0/5 to 5/5
|
||||
(`docs/evals/2026-08-05-praxis-reach.md`,
|
||||
`docs/evals/2026-08-05-reach-llm-router.md`).
|
||||
`handlePraxisAct` compares `Slots.Fn` to a capability alias. Otherwise that
|
||||
slot is filled from the deployment's enabled tool names, and no Praxis alias
|
||||
is on that list.
|
||||
- **World questions** (`WorldQueryGrammars`, V-655, 2026-08-07). "что такое X"
|
||||
and "сколько будет 17 на 23". Wired after the agenda rules and **before** the
|
||||
feed and list rules. "что такое лента" is a definition question, and the feed
|
||||
rule would take it on the noun alone.
|
||||
|
||||
`calendar-query` and `event-time-query` name the calendar as the destination.
|
||||
The possessive agenda rules deliberately do not. "что у меня в списке покупок"
|
||||
matches `agenda-query`, and naming the calendar there would take the list source
|
||||
off the turn. That caution now costs four destination cases. See the model arm
|
||||
below.
|
||||
|
||||
Go's `\b` is ASCII-only and never fires after a Cyrillic letter. A pattern needs
|
||||
an explicit `(\s|[?!.]|$)`.
|
||||
|
||||
`baselineGrammars` in `eval_test.go` mirrors `buildRouter` and has drifted before.
|
||||
`WorldQueryGrammars` was wired into the daemon by V-655 and not into the mirror,
|
||||
so the fixture scored a grammar set nobody runs. Fixed by V-659, worth 3 points
|
||||
of destination.
|
||||
|
||||
### Praxis lifecycle rules
|
||||
|
||||
A **stative** lifecycle word ("готово", "принято") needs an item named beside it.
|
||||
A bare **imperative** ("закрывай") may ask which one. It also requires a sentence
|
||||
naming no object of its own. Otherwise "закрой шторы в комнате" goes to Praxis
|
||||
instead of the house. A demonstrative ("отметь это как сделанное") resolves
|
||||
against `h.surfacedItems` only when exactly one item was spoken. Otherwise the
|
||||
turn goes back to the cascade rather than transitioning the wrong item.
|
||||
|
||||
### Slots on a stage 0 decision
|
||||
|
||||
`fillMatchedSlots` runs the stage 2 extractor over whatever a grammar built
|
||||
(V-572, 2026-08-06). It fills only the slots the grammar left empty. A matched
|
||||
value always wins, because the rule read a literal pattern and the extractor
|
||||
guesses.
|
||||
|
||||
It did not run before. So `ReminderGrammar` handed the daemon `HasTime: false`
|
||||
for "напомни в 11:00 позвонить маме", and `missingFor` read the silence as
|
||||
absence and asked "Когда?". It is inert for every grammar but the reminder:
|
||||
`Extract` fills Time, Fn and Key and nothing else. A stage 0 query costs 3.7µs
|
||||
against 3.9µs before, benchmarked at 20000x.
|
||||
|
||||
`Slots.Text` is deliberately not filled. A grammar that left it empty meant it,
|
||||
and `agendaQueryBuild` hands the query chain the utterance itself.
|
||||
|
||||
## Stage 0b: the routing heads
|
||||
|
||||
Routing has a bounded output space, so it is classification rather than
|
||||
generation (owner's call, V-546,
|
||||
`docs/plans/18-routing-heads-on-e5-small.md`). The 118M multilingual-e5-small is
|
||||
already resident. A softmax cannot emit a value that does not exist, so no
|
||||
grammar is needed. Max softmax is a calibratable confidence, where
|
||||
`Confidence: 1.0` was a hardcode. Training costs roughly 5e15 FLOPs, so 10 to 30
|
||||
minutes on the workstation. A 100M decoder from scratch is 10 to 20 GPU hours.
|
||||
|
||||
**Fine-tune a copy of the weights.** The resident embedder backs memory recall.
|
||||
Training it in place couples routing accuracy to recall@1, with nothing in the
|
||||
suite to name the trade.
|
||||
|
||||
Four heads share one masked mean pool, trained over three days. The measurements
|
||||
are `docs/evals/2026-08-08-routing-heads-two-head.md`,
|
||||
`docs/evals/2026-08-08-slot-head-three-head.md`,
|
||||
`docs/evals/2026-08-08-clarify-head-four-head.md` and
|
||||
`docs/evals/2026-08-08-massive-warm-start.md`.
|
||||
|
||||
| Head | Score | Notes |
|
||||
|---|---|---|
|
||||
| intent | 92.8% mean over 3 seeds | fixture is the 88 cases carrying an intent |
|
||||
| destination | 80.8% mean, best 29/33 | beats the 12B teacher it was distilled from |
|
||||
| slot BIO tags | 72.4% span F1 | still climbing when epoch selection stops it |
|
||||
| clarify | catches 7.0 of 8, 2.3 false of 88 | parity with the cascade, no rules in front |
|
||||
|
||||
Read the best destination run as one seed and not a headline. One case is 3
|
||||
points on a fixture this small. Head intent accuracy is **not** comparable to the
|
||||
cascade's 76.0% and 84.4%. A softmax has no clarify class, so the head's fixture
|
||||
is 88 cases and not 96.
|
||||
|
||||
Recall is 15/15 and world is 5/5.
|
||||
|
||||
**Mood is cut, not deferred.** The enum describes her own reply state, not the
|
||||
speaker's emotion, and no dataset maps onto it.
|
||||
|
||||
### The clarify head
|
||||
|
||||
Clarify is not a value of intent, so a softmax cannot emit it. It is a second
|
||||
question over the same pooled vector: can Maven act on this at all. Accuracy is
|
||||
the wrong number here and a head that never asks scores 91.7%.
|
||||
|
||||
Confidence is the other half. Max softmax over the intent head reads 0.851 where
|
||||
it is right and 0.604 where it is wrong. It ranks right above wrong in 83.4% of
|
||||
pairs.
|
||||
|
||||
It is not free the way the slot head was. Intent, destination and slot F1 each
|
||||
move down one to four points, inside the seed spread. `поужинал` is a false
|
||||
clarify on every seed. That is the same defect `thinSingleToken` was narrowed for
|
||||
on 2026-08-01.
|
||||
|
||||
The corpus is generated, because every existing row is answerable by
|
||||
construction. The router-prompt agreement filter cannot work here. `routeGrammar`
|
||||
has no clarify value, and a generated line always agrees with itself. A gemma
|
||||
judge replaces it. The first judge called 24 of 40 answerable rows underspecified.
|
||||
It judged against a generic assistant rather than against Maven's contract.
|
||||
|
||||
### The slot head
|
||||
|
||||
BIO slot tags had no Maven-domain corpus. That was true of found corpora and
|
||||
false of made ones. `label_slots.py` distils spans out of gemma-4-12b under a
|
||||
GBNF closed over Maven's own five slots. A span survives only when it is a
|
||||
literal substring of the utterance, so the agreement filter costs no second call.
|
||||
2178 spans over 1702 rows, 37 dropped, nothing unparsed.
|
||||
|
||||
Epoch selection reads the intent dev slice alone. That costs the slot head about
|
||||
4 points.
|
||||
|
||||
### Warm start and the floor
|
||||
|
||||
The MASSIVE warm-start of step 2 is worth nothing here. Stock e5-small ties it on
|
||||
intent and leads by a third of a case on destination. Nothing argues for keeping
|
||||
that step.
|
||||
|
||||
The floor was a corpus defect and it is fixed. The first 120 floor rows carried
|
||||
one sentence shape, so the head named a destination where the fixture says walk
|
||||
the chain. Rotating six shapes took the floor 3/7 to 6/7 and destination 75.8% to
|
||||
80.8%.
|
||||
|
||||
What is left is calendar at 3/6 on every seed, which training cannot move. The
|
||||
possessive agenda rules claim those cases at stage 0 and name nothing, so no
|
||||
label reaches the head.
|
||||
|
||||
### Reading them in Go
|
||||
|
||||
`RouterHeads` in `internal/router/heads.go` loads `router_heads.onnx` (V-664,
|
||||
2026-08-08). It reads intent, destination and clarify off one forward pass.
|
||||
|
||||
Three rules around it, each measured:
|
||||
|
||||
- The **clarify head decides first**, before the intent threshold. It answers a
|
||||
different question. A thin utterance scores low intent by construction, so
|
||||
gating it cost 6 of 8 ambiguous cases.
|
||||
- The **destination head is read on `IntentQuery` only**, since no other intent
|
||||
reaches `queryWalk`.
|
||||
- `headsThreshold` is 0.6, the measured knee. Every value up to 0.85 drops right
|
||||
answers and keeps the same two wrong ones.
|
||||
|
||||
`voice.embedder.heads_path` is the whole switch. Empty, missing or unloadable
|
||||
means the heads are nil. The cascade is then byte-for-byte what shipped before
|
||||
them.
|
||||
|
||||
### The tokenizer bug the heads found
|
||||
|
||||
`encodeWord` in `onnxembedder.go` read every long word backwards until 2026-08-08.
|
||||
It cost recall@1 7.4 points and recall@3 11.1. Nothing caught it, because seeds
|
||||
and queries were mangled the same way and cosine survived. The heads found it.
|
||||
They are trained through transformers and read through this.
|
||||
|
||||
The embedder id now carries a tokenizer revision (`@384/tok2`). So fixing the
|
||||
tokenizer triggers `ReembedAll` the way swapping the model file does. Bump
|
||||
`tokenizerRev` on any change to what it emits.
|
||||
|
||||
## Clarify
|
||||
|
||||
`Confidence: 1.0` was hardcoded in `llmrouter.go`. So the model path could never
|
||||
ask for clarification, and it missed 6 of 6 refusal cases (V-359). The bug had a
|
||||
second half. The model branch never consulted `r.threshold` at all, so a correct
|
||||
low confidence would have been discarded anyway.
|
||||
|
||||
Fixed 2026-07-31 with structural signal feeding the same stage 3 gate the
|
||||
classifier path already had (`gateLLMDecision` in `router.go`). Three signals: a
|
||||
single-token utterance, a keyless fact, an act with no allowlisted fn.
|
||||
|
||||
Re-measured: missed clarify 6/6 to 1, at the cost of 3 false clarifies and 2.6
|
||||
points of full accuracy. Two of the three false clarifies are acts the model
|
||||
mis-routed and the gate caught. Asking beats wrongly executing, so the fixture
|
||||
and the daemon disagree about what is correct there.
|
||||
|
||||
The third, `поужинал`, was a real defect. The single-token rule was an English
|
||||
intuition. It does not transfer to Russian, where one word is routinely a whole
|
||||
sentence.
|
||||
|
||||
Narrowed 2026-08-01. `thinSingleToken` (`internal/router/singletoken.go`) still
|
||||
thins a bare one-word nominal. It spares two classes. One is a closed lexicon of
|
||||
social and control singles ("привет", "стоп", "yes"). The other is any token
|
||||
carrying a Russian verb ending, because a verb already contains its subject. Both
|
||||
tests are offline and cost nothing. False clarifies 3 to 2, intent-only 74.0% to
|
||||
75.3%.
|
||||
|
||||
The two remaining false clarifies are the act-with-no-allowlisted-fn arm of the
|
||||
gate, not this rule.
|
||||
|
||||
## The destination
|
||||
|
||||
`query` was a shrug. The cascade sorted an utterance into one of seven intents,
|
||||
then `IntentQuery` handed the turn to `querySources` in the daemon. That is
|
||||
twenty-two branches deciding by seed similarity in a fixed order. It had no
|
||||
fixture, no accuracy number, no model arm and no floor.
|
||||
|
||||
`Decision.Source` (`internal/router/source.go`) is the second half of the route
|
||||
(V-655, 2026-08-07). Twelve destinations, not twenty-two. The three recall passes
|
||||
plus `fact-by-key` are one destination from outside. So are search, Kiwix and the
|
||||
URL reader.
|
||||
|
||||
`queryWalk` in `cmd/mavend/actions_query.go` takes sources **out** and moves none.
|
||||
That is the safety argument. The table's order is load-bearing. Every comment on
|
||||
it argues a reason between two sources. Above all it carries "the owner's data
|
||||
first, then the world". Naming `SourceWorld` does not send the turn outside on its
|
||||
own.
|
||||
|
||||
What comes out is only the sources that **guess**. Those decide a turn is theirs
|
||||
by cosine against frozen seeds, then answer whatever they claimed. They hold no
|
||||
table that could come back empty. Weather is the pure case and has no local data
|
||||
at all. It was measured on the box 2026-08-07
|
||||
(`docs/evals/2026-08-07-week-of-usage.md` section 4). It answered both "что такое
|
||||
TCP?" and "сколько будет 17 на 23?" with "для какого города?". The feed answered
|
||||
"какой у меня любимый язык?" with kernel headlines.
|
||||
|
||||
### Who may drop the personal boundary
|
||||
|
||||
The personal boundary guesses, so naming `SourceWorld` drops it. That is what
|
||||
stops it answering "кто такой Линус Торвальдс?" with "не нашла у тебя такой
|
||||
записи", which it did on 2026-08-07.
|
||||
|
||||
Three deciders name a destination and two of them infer it: the heads and the
|
||||
resident model. An inferred `SourceWorld` on a question about him would reach
|
||||
SearXNG. That widens what is asked rather than costing a local answer. So only a
|
||||
stage 0 grammar may drop it (owner's call, V-666, 2026-08-09).
|
||||
|
||||
`Decision.SourceAnchored` carries the provenance. It is a field and not
|
||||
`Stage == 0`. Stage 0 also means confidence 1.0 and an anchored claim band, and
|
||||
one of those could stop implying the others. `definitionQueryPattern` claims "кто
|
||||
такой X", so the 2026-08-07 case is still anchored and still answered.
|
||||
|
||||
### The destination fixture
|
||||
|
||||
`want_source` on `eval.Case` is a pointer, because the destination has three
|
||||
states and a bare string has two. Absent is every intent but query. Present and
|
||||
empty is the `SourceUnknown` contract: name nothing and walk the chain. Present
|
||||
and named is a destination the route must produce. Thirty-three of ninety-six
|
||||
cases carry one.
|
||||
|
||||
A destination miss does **not** fail the case. It lands in `Outcome.SourceReason`
|
||||
and never in `Reasons`, so `Accuracy` and `IntentAccuracy` mean what they meant.
|
||||
`SourceAccuracy` is a second number over the labelled cases alone.
|
||||
A route that lost its intent scores no destination hit. Otherwise a clarify would
|
||||
satisfy an empty label for free.
|
||||
|
||||
Seven cases assert the floor and five of them are homelab operations. They cluster
|
||||
because `SourceRecall`, `SourceNetwork` and `SourceAttention` overlap on every
|
||||
question about the box. `mavpoll` writes its netdata and uptime-kuma observations
|
||||
into the fact store recall reads. That is a finding about the enum, not a gap in
|
||||
the labelling. The other two are `ru-query-005` and `ru-query-014`. No query
|
||||
source reads the reminder store, and a deadline could sit in tasks, the calendar
|
||||
or Praxis. The owner confirmed all seven floor labels on 2026-08-08.
|
||||
|
||||
### The model arm
|
||||
|
||||
`routeGrammar` carries a `source` rule closed over `router.Sources` plus the
|
||||
empty floor (V-660, 2026-08-08). So the model cannot emit a destination that does
|
||||
not exist. The prompt lists the twelve in Russian and says `""` is a normal answer
|
||||
to give often. `LLMRouter.Route` reads it back through `ValidSource` and on
|
||||
`IntentQuery` alone.
|
||||
|
||||
Against gemma-4-12b the cascade scores destination 24/33 with intent unmoved, and
|
||||
recall goes 0/15 to 14/15.
|
||||
|
||||
**Stage 0 now costs four destination points.** It did not before. The four cases
|
||||
the cascade loses and the model alone wins are all calendar. The possessive agenda
|
||||
rules claim them first and name nothing on purpose. That caution was free while
|
||||
nothing downstream could name anything either. It is not free now, and the fix is
|
||||
the owner's call (V-660 open).
|
||||
|
||||
## The decision trace
|
||||
|
||||
Arbitration between the claimants on the utterance stream is order. It is
|
||||
hardcoded in the pre-route resolver ladder, in `buildRouter` and in
|
||||
`querySources`. Nothing recorded who lost until V-564.
|
||||
|
||||
`internal/decision` records one `Record` per turn. It holds every claimant, what
|
||||
it would have made the turn, the score it reported, and how it ended. A claimant
|
||||
won, declined, lost on score, was thinned by a gate or was **never asked**.
|
||||
|
||||
The record rides the context, the same seam `querysource.go` uses. So a claim
|
||||
site cannot change a route, and a context with no record costs nothing. It is
|
||||
installed in `runTurn`, so the mic, telegram and the web leave the same trail.
|
||||
|
||||
Adding a rung to the ladder in `runTurn` means adding its name to `preRouteLadder`
|
||||
in `cmd/mavend/decisiontrace.go`. Otherwise that rung is silently missing from the
|
||||
record.
|
||||
|
||||
### Why it persists now
|
||||
|
||||
The original rule was that nothing persists, because a turn record is read minutes
|
||||
later or never. Storage was a 25-turn in-memory ring read over `ipc.TurnDecisions`
|
||||
and rendered on `/trace`.
|
||||
|
||||
The owner reversed it on 2026-08-06 (V-629,
|
||||
`docs/plans/21-persisting-the-routing-trace.md`). The routing heads cannot be
|
||||
fitted or calibrated without real utterances. And 9 of the 31 modes in
|
||||
`internal/modes` have no seed example at all.
|
||||
|
||||
The ring did not move. `cmd/mavend/routingtrace.go` is a second sink beside it,
|
||||
writing `routing_traces` (migration #23). The utterance is stored in clear. A
|
||||
384-dimension vector of a short sentence is substantially recoverable, so storing
|
||||
vectors instead would be a privacy claim we cannot support. What makes it safe is
|
||||
the same thing that makes the fact store safe. Retention is 14 days, enforced on
|
||||
write and again on start, so a box that goes quiet does not keep every row.
|
||||
Nothing reads it outward. `Store.Wipe` deletes it with everything else.
|
||||
|
||||
### Corrections
|
||||
|
||||
A correction is promoted out into a seed-shaped row in `routing_labels`
|
||||
(migration #24) and kept, because a label is not a transcript. The transcript
|
||||
still expires.
|
||||
|
||||
A turn marked wrong with no target is a usable negative, so naming the intent is
|
||||
never required. The target is one of the seven intents and never free text.
|
||||
|
||||
All three reaches offer it as of 2026-08-06:
|
||||
|
||||
- `/chat` offers two buttons beside the reply, over `ipc.CorrectTurn` and the
|
||||
trace id that rides back on `ipc.ChatReply`.
|
||||
- Voice offers the `repair` rung, which has read spoken corrections since V-455.
|
||||
It now writes the durable label beside the classifier seed it always wrote. A
|
||||
spoken negative with no target is its own rung, `repair-negative` (V-636,
|
||||
`docs/plans/22-correcting-a-turn.md`).
|
||||
- Telegram offers an inline keyboard under the reply. It needed the chat to become
|
||||
readable first (V-637, `docs/plans/23-inbound-telegram.md`). The poller is dark
|
||||
unless the `telegram` block says `intake`. It long-polls, because the box takes
|
||||
no inbound connections. It accepts `chat_id` and no other sender, and it drops
|
||||
whatever queued while the daemon was down. It reaches the daemon through
|
||||
`ipc.CoreAPI` alone.
|
||||
|
||||
The turn source is still `tap:text` for both telegram and the web. So provenance
|
||||
cannot tell a chat turn from a typed one.
|
||||
@@ -127,6 +127,22 @@ func (c *Client) Article(ctx context.Context, path string, maxRunes int) (crawl.
|
||||
return crawl.Extract(u, body, maxRunes), nil
|
||||
}
|
||||
|
||||
// TitlePath is the article path for an exact title, for Article to fetch.
|
||||
//
|
||||
// It exists because a ZIM is addressable by title and the full-text index is
|
||||
// not the only way in. "Франция", "TCP" and "Небо" resolve; "Трюмбальная
|
||||
// нидроскопия" is a 404, which is the honest answer and the reason this is
|
||||
// safe to try first. Measured on 2026-08-09, keyword search on the same terms
|
||||
// returns "Список пэров Франции" and "Список портов TCP и UDP" instead.
|
||||
//
|
||||
// A miss is normal rather than a failure. An article whose title inverts a name
|
||||
// ("Торвальдс, Линус") is a 404 here and the first hit in search, so the caller
|
||||
// falls through and loses nothing.
|
||||
func TitlePath(book, title string) string {
|
||||
t := strings.ReplaceAll(strings.TrimSpace(title), " ", "_")
|
||||
return "/content/" + url.PathEscape(book) + "/A/" + url.PathEscape(t)
|
||||
}
|
||||
|
||||
// rss mirrors just the bits of the RSS 2.0 reply we use.
|
||||
type rss struct {
|
||||
Items []struct {
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package kiwix
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestLiveTopicBeatsTheSentence — the measurement V-668 turned on, kept as a
|
||||
// test so the claim can be re-run rather than believed.
|
||||
//
|
||||
// It prints the article the old path returned and the article the new one
|
||||
// returns, for the same question. It asserts nothing about which is better,
|
||||
// because "is this the right article" is a human's call. It fails only if the
|
||||
// two paths agree on every case, which would mean the change does nothing.
|
||||
//
|
||||
// MAVEN_KIWIX_URL=http://127.0.0.1:8034 make t PKG=./internal/kiwix/ RUN=TestLive V=1
|
||||
func TestLiveTopicBeatsTheSentence(t *testing.T) {
|
||||
base := os.Getenv("MAVEN_KIWIX_URL")
|
||||
if base == "" {
|
||||
t.Skip("MAVEN_KIWIX_URL unset — point it at the kiwix-server host port")
|
||||
}
|
||||
const book = "wikipedia_ru_all_maxi_2026-02"
|
||||
c := New(base)
|
||||
questions := []string{
|
||||
"что такое TCP?",
|
||||
"что такое фотосинтез",
|
||||
"кто такой Линус Торвальдс?",
|
||||
"кто написал Войну и мир",
|
||||
"что такое чёрная дыра",
|
||||
"почему небо голубое",
|
||||
"почему трава зелёная",
|
||||
"столица Франции",
|
||||
}
|
||||
moved := 0
|
||||
for _, q := range questions {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
before := firstTitle(ctx, c, q, book)
|
||||
topic := Topic(q)
|
||||
after := ""
|
||||
for _, cand := range TitleCandidates(topic) {
|
||||
if page, err := c.Article(ctx, TitlePath(book, cand), 400); err == nil && page.Text != "" {
|
||||
after = page.Title + " (by title)"
|
||||
break
|
||||
}
|
||||
}
|
||||
if after == "" {
|
||||
after = firstTitle(ctx, c, topic, book)
|
||||
}
|
||||
cancel()
|
||||
if before != after {
|
||||
moved++
|
||||
}
|
||||
t.Logf("%-30s before=%-34q after=%q", q, before, after)
|
||||
}
|
||||
t.Logf("%d of %d questions reach a different article", moved, len(questions))
|
||||
if moved == 0 {
|
||||
t.Error("the topic path returns exactly what the sentence path returned")
|
||||
}
|
||||
}
|
||||
|
||||
func firstTitle(ctx context.Context, c *Client, pattern, book string) string {
|
||||
hits, err := c.Search(ctx, pattern, book, 3)
|
||||
if err != nil || len(hits) == 0 {
|
||||
return "(nothing)"
|
||||
}
|
||||
return hits[0].Title
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package kiwix
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/kami/maven/internal/lexicon"
|
||||
"github.com/kami/maven/internal/morph"
|
||||
)
|
||||
|
||||
// Topic reduces a question to the thing it is about, because Kiwix ranks by
|
||||
// keyword overlap and a whole sentence buries the keyword that matters.
|
||||
//
|
||||
// This package's own doc says it: "why is the sky blue" finds a TV episode.
|
||||
// Measured against the Russian ZIM on 2026-08-09, the sentence and the topic
|
||||
// return different articles for the same question. "кто написал Войну и мир"
|
||||
// returns "Радуйся, мир (Доктор Кто)"; "Войну и мир" returns the novel first.
|
||||
// "что такое TCP" returns "Перехват TCP-соединения"; "TCP" returns TCP. The
|
||||
// English path had a rewriter doing this with a model call. The Russian path
|
||||
// reads the book verbatim (V-508) and had nothing.
|
||||
//
|
||||
// It drops three things off the front and stops: the narrative request, the
|
||||
// interrogative, and a verb sitting between them and the noun. Everything else
|
||||
// is kept, because a word this cannot classify is more likely the topic than
|
||||
// noise. An empty return means the utterance was question words alone, and the
|
||||
// caller searches the sentence as before.
|
||||
func Topic(utterance string) string {
|
||||
words := strings.Fields(strings.TrimSpace(utterance))
|
||||
cut := 0
|
||||
for cut < len(words) {
|
||||
w := strings.Trim(strings.ToLower(words[cut]), ".,!?…:;\"'«»")
|
||||
if w == "" {
|
||||
cut++
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case inList(lexicon.NarrativeRequests(), w),
|
||||
inList(lexicon.Interrogatives(), w),
|
||||
inList(lexicon.FirstPerson(), w),
|
||||
// "что ТАКОЕ x", "кто ТАКОЙ x" — the copula that only ever follows
|
||||
// an interrogative, and never a topic on its own.
|
||||
cut > 0 && isCopula(w),
|
||||
// "расскажи ПРО x", "о x". One-letter and two-letter prepositions
|
||||
// are not a closed class worth a lexicon set of their own.
|
||||
cut > 0 && isLeadingPreposition(w),
|
||||
// "кто НАПИСАЛ Войну и мир". A verb here is the question's own
|
||||
// verb, not part of the title. Only after something was already
|
||||
// dropped, so "написал отчёт" as a topic survives intact.
|
||||
cut > 0 && morph.IsVerbForm(w):
|
||||
cut++
|
||||
default:
|
||||
// The question mark is the sentence's, not the title's, and Kiwix
|
||||
// carries it into the keyword match.
|
||||
topic := strings.TrimRight(strings.Join(words[cut:], " "), " .,!?…:;\"'«»")
|
||||
if !hasLetter(topic) {
|
||||
return ""
|
||||
}
|
||||
return topic
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// TitleCandidates is the topic as it might be titled, best first.
|
||||
//
|
||||
// A ZIM title is capitalized and the utterance is not: measured on 2026-08-09,
|
||||
// `/A/фотосинтез` is a 404 and `/A/Фотосинтез` is a 200. The spoken form is
|
||||
// tried first anyway, because a title that begins lowercase on purpose
|
||||
// ("iPhone") would not survive capitalizing it. Both are one request each
|
||||
// against a server on the same box, and a miss is a 404 rather than a wrong
|
||||
// article.
|
||||
func TitleCandidates(topic string) []string {
|
||||
if topic == "" {
|
||||
return nil
|
||||
}
|
||||
r := []rune(topic)
|
||||
up := unicode.ToUpper(r[0])
|
||||
if up == r[0] {
|
||||
return []string{topic}
|
||||
}
|
||||
return []string{topic, string(up) + string(r[1:])}
|
||||
}
|
||||
|
||||
func isCopula(w string) bool {
|
||||
switch w {
|
||||
case "такое", "такой", "такая", "такие", "is", "are", "was", "were":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isLeadingPreposition(w string) bool {
|
||||
switch w {
|
||||
case "про", "о", "об", "обо", "по", "about", "of", "on":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func inList(list []string, w string) bool {
|
||||
for _, x := range list {
|
||||
if x == w {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// hasLetter is the guard against a topic that reduced to punctuation or digits
|
||||
// alone, which no ZIM title matches.
|
||||
func hasLetter(s string) bool {
|
||||
for _, r := range s {
|
||||
if unicode.IsLetter(r) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package kiwix
|
||||
|
||||
import "testing"
|
||||
|
||||
// The cases the 2026-08-09 measurement turned on, plus the ones a topic must
|
||||
// not damage. Each left column returned a wrong article when it was sent whole.
|
||||
func TestTopicKeepsTheThingTheQuestionIsAbout(t *testing.T) {
|
||||
cases := []struct{ utterance, want string }{
|
||||
{"что такое TCP?", "TCP"},
|
||||
{"что такое фотосинтез", "фотосинтез"},
|
||||
{"кто такой Линус Торвальдс?", "Линус Торвальдс"},
|
||||
{"кто написал Войну и мир", "Войну и мир"},
|
||||
{"расскажи про битву при Ватерлоо", "битву при Ватерлоо"},
|
||||
{"what is photosynthesis", "photosynthesis"},
|
||||
// No question word, so there is nothing to drop. The topic is the
|
||||
// whole utterance and the search is what it was before.
|
||||
{"столица Франции", "столица Франции"},
|
||||
{"почему небо голубое", "небо голубое"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := Topic(c.utterance); got != c.want {
|
||||
t.Errorf("Topic(%q) = %q, want %q", c.utterance, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A verb only goes when a question word already went. Otherwise "написал
|
||||
// отчёт" loses the verb that names what he means.
|
||||
func TestTopicDropsAVerbOnlyBehindAQuestionWord(t *testing.T) {
|
||||
if got := Topic("написал отчёт"); got != "написал отчёт" {
|
||||
t.Errorf("Topic dropped a leading verb with no question word: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Question words alone reduce to nothing, and the caller reads that as "no
|
||||
// topic" and searches the sentence rather than searching the empty string.
|
||||
func TestTopicIsEmptyWhenNothingIsLeft(t *testing.T) {
|
||||
for _, q := range []string{"что такое?", "кто?", "почему", "???"} {
|
||||
if got := Topic(q); got != "" {
|
||||
t.Errorf("Topic(%q) = %q, want empty", q, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTitlePathEscapesAndUnderscores(t *testing.T) {
|
||||
got := TitlePath("wikipedia_ru_all_maxi_2026-02", "Чёрная дыра")
|
||||
want := "/content/wikipedia_ru_all_maxi_2026-02/A/%D0%A7%D1%91%D1%80%D0%BD%D0%B0%D1%8F_%D0%B4%D1%8B%D1%80%D0%B0"
|
||||
if got != want {
|
||||
t.Errorf("TitlePath = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// A ZIM title carries a leading capital and the utterance does not. The spoken
|
||||
// form is still tried first, so a title that begins lowercase on purpose keeps
|
||||
// its chance.
|
||||
func TestTitleCandidatesTryTheSpokenFormFirst(t *testing.T) {
|
||||
got := TitleCandidates("фотосинтез")
|
||||
if len(got) != 2 || got[0] != "фотосинтез" || got[1] != "Фотосинтез" {
|
||||
t.Errorf("TitleCandidates = %q", got)
|
||||
}
|
||||
if got := TitleCandidates("TCP"); len(got) != 1 || got[0] != "TCP" {
|
||||
t.Errorf("an already-capital topic was tried twice: %q", got)
|
||||
}
|
||||
if got := TitleCandidates(""); got != nil {
|
||||
t.Errorf("TitleCandidates(\"\") = %q, want nil", got)
|
||||
}
|
||||
}
|
||||
@@ -111,6 +111,21 @@ type Decision struct {
|
||||
// before this field existed. See source.go for why it is twelve values.
|
||||
Source Source
|
||||
|
||||
// SourceAnchored — a stage 0 grammar named that destination, matching a
|
||||
// literal pattern to do it. Only the router sets this, and only there.
|
||||
//
|
||||
// It exists because one thing downstream is not reversible by evidence
|
||||
// (V-666). Naming a destination normally takes guessing sources off a turn,
|
||||
// and one of those is the personal boundary, which is what stops a question
|
||||
// about him from reaching the world. A grammar that read "что такое X" may
|
||||
// take it off. A model or a softmax may not, because a wrong destination
|
||||
// there widens what leaves the box rather than costing an answer.
|
||||
//
|
||||
// Read Stage instead and the two decisions get coupled: stage 0 also means
|
||||
// confidence 1.0 and an anchored claim band, and a later cascade change
|
||||
// could make one true where the other is not.
|
||||
SourceAnchored bool
|
||||
|
||||
// Continued — this decision was rebuilt from the previous turn rather
|
||||
// than routed, because the utterance was an ellipsis ("а завтра?").
|
||||
// Handlers use it to know that Slots.Text is the PREVIOUS turn's topic
|
||||
|
||||
@@ -98,6 +98,10 @@ func (r *Router) Route(ctx context.Context, utterance string, now time.Time) (De
|
||||
continue // grammar matched shape but not content → fall through
|
||||
}
|
||||
d.Utterance = utterance
|
||||
// A literal pattern named that destination, which is the one provenance
|
||||
// allowed to take the personal boundary off a turn (V-666). Set here and
|
||||
// nowhere else, so no other arm of the cascade can claim it.
|
||||
d.SourceAnchored = d.Source != SourceUnknown
|
||||
// The grammar decided the intent; the extractor fills the slots it did
|
||||
// not match (V-572). See fillMatchedSlots for why every grammar gets it.
|
||||
r.fillMatchedSlots(ctx, &d, now)
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
# Raw transcript, two weeks of usage
|
||||
|
||||
YOU: что такое TCP?
|
||||
MAVEN: есть такое: Tcp/Ip — Википедия — TCP позволяет регулировать нагрузку на сеть, а также уменьшать время ожидания данных при передаче на большие расстояния. Более того, TCP гарантирует, что полученные ...
|
||||
[2.1s src=search t=666]
|
||||
|
||||
YOU: кто такой Линус Торвальдс?
|
||||
MAVEN: вот что я нашла: Торвальдс, Линус — Википедия — Ли́нус Бенедикт То́рвальдс (встречается написание Ту́рвальдс, швед. Linus Benedict Torvalds МФА: [ˈliːn.ɵs ˈtuːr.valds] о файле; род. 28 декабря 1969, Хельсинки) — финско-американский программист, создатель ядра Linux.
|
||||
[0.8s src=search t=667]
|
||||
|
||||
YOU: что такое фотосинтез
|
||||
MAVEN: нашла вот это: Фотосинтез — Фотосинтез
|
||||
Схематическое изображение процесса фотосинтеза, происходящего в растениях
|
||||
Упрощённое уравнение фотосинтеза, происходящего в растениях
|
||||
Упрощённая схема фотосинтеза
|
||||
Фотоси́нтез (от др.-греч. φῶς — « свет » и σύνθεσις — «соединённый», «складной», «связывание», « синтез ») — сл…
|
||||
[0.6s src=kiwix t=668]
|
||||
|
||||
YOU: кто написал Войну и мир
|
||||
MAVEN: вот что я нашла: Война и мир — Война и мир
|
||||
Война и мир
|
||||
Война и миръ
|
||||
Обложка издания 1873 года
|
||||
Жанр
|
||||
роман-эпопея
|
||||
Автор
|
||||
Лев Николаевич Толстой
|
||||
Язык оригинала
|
||||
русский, фрагменты на французском и немецком языках
|
||||
Дата написания
|
||||
1863—1869, 1873
|
||||
Дата первой публикации
|
||||
1865—1869
|
||||
Предыдущее
|
||||
Казаки
|
||||
Следующее
|
||||
Анна Каренина
|
||||
Тек…
|
||||
[12.4s src=kiwix t=669]
|
||||
|
||||
```
|
||||
@@ -0,0 +1,4 @@
|
||||
{"reply": "есть такое: Tcp/Ip — Википедия — TCP позволяет регулировать нагрузку на сеть, а также уменьшать время ожидания данных при передаче на большие расстояния. Более того, TCP гарантирует, что полученные ...", "source": "search", "trace": "666", "secs": 2.1235475060530007, "day": 0, "n": 1, "utterance": "что такое TCP?"}
|
||||
{"reply": "вот что я нашла: Торвальдс, Линус — Википедия — Ли́нус Бенедикт То́рвальдс (встречается написание Ту́рвальдс, швед. Linus Benedict Torvalds МФА: [ˈliːn.ɵs ˈtuːr.valds] о файле; род. 28 декабря 1969, Хельсинки) — финско-американский программист, создатель ядра Linux.", "source": "search", "trace": "667", "secs": 0.7689587379572913, "day": 0, "n": 2, "utterance": "кто такой Линус Торвальдс?"}
|
||||
{"reply": "нашла вот это: Фотосинтез — Фотосинтез\nСхематическое изображение процесса фотосинтеза, происходящего в растениях\nУпрощённое уравнение фотосинтеза, происходящего в растениях\nУпрощённая схема фотосинтеза\nФотоси́нтез (от др.-греч. φῶς — « свет » и σύνθεσις — «соединённый», «складной», «связывание», « синтез ») — сл…", "source": "kiwix", "trace": "668", "secs": 0.575586927938275, "day": 0, "n": 3, "utterance": "что такое фотосинтез"}
|
||||
{"reply": "вот что я нашла: Война и мир — Война и мир\nВойна и мир\nВойна и миръ\nОбложка издания 1873 года\nЖанр\nроман-эпопея\nАвтор\nЛев Николаевич Толстой\nЯзык оригинала\nрусский, фрагменты на французском и немецком языках\nДата написания\n1863—1869, 1873\nДата первой публикации\n1865—1869\nПредыдущее\nКазаки\nСледующее\nАнна Каренина\nТек…", "source": "kiwix", "trace": "669", "secs": 12.44599153404124, "day": 0, "n": 4, "utterance": "кто написал Войну и мир"}
|
||||
@@ -0,0 +1,30 @@
|
||||
# Raw transcript, two weeks of usage
|
||||
|
||||
YOU: столица Франции
|
||||
MAVEN: вот что я нашла: Франция — Википедия — Она отличается ... реками. На Сене в 145 км от пролива Ла-Манш расположена столица Франции Париж....
|
||||
[2.3s src=search t=670]
|
||||
|
||||
YOU: что такое фотосинтез
|
||||
MAVEN: есть такое: Фотосинтез: процесс, фазы, функции и значение фотосинтеза — Бестселлеры Фоксфорда: собрали всё, что полюбили тысячи школьников и родителей 🧡 · Фотосинтез происходит в тканях, содержащих хлоропласты, — преимущественно в листе, на который приходится большая часть процессов фотосинтеза. Такая ткань наз…
|
||||
[0.8s src=search t=671]
|
||||
|
||||
YOU: что такое TCP?
|
||||
MAVEN: нашла вот это: TCP — TCP
|
||||
TCP
|
||||
Название
|
||||
Transmission Control Protocol
|
||||
Уровень (по модели OSI )
|
||||
Транспортный
|
||||
Семейство
|
||||
TCP/IP
|
||||
Спецификация
|
||||
RFC 793 (сентябрь 1981 года) / STD 7
|
||||
Основные реализации
|
||||
UNIX , Linux , BSD , Windows
|
||||
Расширяемость
|
||||
Опции
|
||||
Медиафайлы на Викискладе
|
||||
TCP ( англ. Transmission Control Protocol — п…
|
||||
[0.3s src=kiwix t=672]
|
||||
|
||||
```
|
||||
@@ -0,0 +1,3 @@
|
||||
{"reply": "вот что я нашла: Франция — Википедия — Она отличается ... реками. На Сене в 145 км от пролива Ла-Манш расположена столица Франции Париж....", "source": "search", "trace": "670", "secs": 2.295847482047975, "day": 0, "n": 1, "utterance": "столица Франции"}
|
||||
{"reply": "есть такое: Фотосинтез: процесс, фазы, функции и значение фотосинтеза — Бестселлеры Фоксфорда: собрали всё, что полюбили тысячи школьников и родителей 🧡 · Фотосинтез происходит в тканях, содержащих хлоропласты, — преимущественно в листе, на который приходится большая часть процессов фотосинтеза. Такая ткань наз…", "source": "search", "trace": "671", "secs": 0.8353607719764113, "day": 0, "n": 2, "utterance": "что такое фотосинтез"}
|
||||
{"reply": "нашла вот это: TCP — TCP\nTCP\nНазвание\nTransmission Control Protocol\nУровень (по модели OSI )\nТранспортный\nСемейство\nTCP/IP\nСпецификация\nRFC 793 (сентябрь 1981 года) / STD 7\nОсновные реализации\nUNIX , Linux , BSD , Windows\nРасширяемость\nОпции\nМедиафайлы на Викискладе\nTCP ( англ. Transmission Control Protocol — п…", "source": "kiwix", "trace": "672", "secs": 0.29547085403464735, "day": 0, "n": 3, "utterance": "что такое TCP?"}
|
||||
@@ -0,0 +1,19 @@
|
||||
# Raw transcript, two weeks of usage
|
||||
|
||||
YOU: что такое TCP?
|
||||
MAVEN: Вот что я нашла: TCP, или Transmission Control Protocol, — это один из основных протоколов передачи данных в интернете. Он работает на транспортном уровне модели OSI и предоставляет поток данных с установкой соединения, гарантируя целостность данных, повторно запрашивая потерянные данные и устраняя дублирование.
|
||||
[2.3s src=kiwix t=673]
|
||||
|
||||
YOU: кто такой Линус Торвальдс?
|
||||
MAVEN: Вот что я нашла: Линус Торвальдс — финно-американский программист, который является создателем ядра Linux. Он швед по имени Linus Benedict Torvalds, родился 28 декабря 1969 года в Хельсинки, Финляндия, и его род деятельности включает программирование, разработку программного обеспечения, писательство, изобретательство, ведение блога, а также он инженер, бизнесмен и специалист в области информатики.
|
||||
[2.4s src=kiwix t=674]
|
||||
|
||||
YOU: столица Франции
|
||||
MAVEN: Вот что я нашла: Столица Франции — Париж.
|
||||
[2.0s src=kiwix t=675]
|
||||
|
||||
YOU: почему небо голубое
|
||||
MAVEN: Я не нашла в источниках ответа на твой вопрос, почему небо голубое.
|
||||
[1.3s src=kiwix t=676]
|
||||
|
||||
```
|
||||
@@ -0,0 +1,4 @@
|
||||
{"reply": "Вот что я нашла: TCP, или Transmission Control Protocol, — это один из основных протоколов передачи данных в интернете. Он работает на транспортном уровне модели OSI и предоставляет поток данных с установкой соединения, гарантируя целостность данных, повторно запрашивая потерянные данные и устраняя дублирование.", "source": "kiwix", "trace": "673", "secs": 2.3279738989658654, "day": 0, "n": 1, "utterance": "что такое TCP?"}
|
||||
{"reply": "Вот что я нашла: Линус Торвальдс — финно-американский программист, который является создателем ядра Linux. Он швед по имени Linus Benedict Torvalds, родился 28 декабря 1969 года в Хельсинки, Финляндия, и его род деятельности включает программирование, разработку программного обеспечения, писательство, изобретательство, ведение блога, а также он инженер, бизнесмен и специалист в области информатики.", "source": "kiwix", "trace": "674", "secs": 2.42521625792142, "day": 0, "n": 2, "utterance": "кто такой Линус Торвальдс?"}
|
||||
{"reply": "Вот что я нашла: Столица Франции — Париж.", "source": "kiwix", "trace": "675", "secs": 1.9803519028937444, "day": 0, "n": 3, "utterance": "столица Франции"}
|
||||
{"reply": "Я не нашла в источниках ответа на твой вопрос, почему небо голубое.", "source": "kiwix", "trace": "676", "secs": 1.2865088270045817, "day": 0, "n": 4, "utterance": "почему небо голубое"}
|
||||
Reference in New Issue
Block a user