diff --git a/CLAUDE.md b/CLAUDE.md index 1a0dbda..90141a9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -286,6 +286,15 @@ world questions, so she needs to read external sources. What replaces it: - **In the world, live search leads and the ZIMs are the fallback** (owner's call, 2026-08-02). A self-hosted SearXNG (`search` block) answers first; the Kiwix ZIMs on homesrv answer when the search is empty, unreachable, or the line is down. + `Response.Empty()` is the whole gate and there is no quality threshold in front of it: + the three signals one could read were measured on 2026-08-05 and none of them separate a + real question from an invented one. Token overlap would cost "столица Франции" its + answer, because the answer is Париж and that word is not in the question. See + `docs/evals/2026-08-05-search-quality-signals.md` (V-539). **Which query source claimed + a turn is readable on `/chat`** as a badge beside the reply, carried on + `ipc.ChatReply.Source` and noted by `noteQuerySource` in `cmd/mavend/querysource.go`. It + rides the context, so `handleText` keeps the one string signature the mic, telegram and + the web share. - **External search is allowed and off unless configured**, like the weather and telegram capabilities. The code default is still off. `deploy/mavend.json` now ships a `search` block (owner's call, 2026-08-02), so it is on for this box and deleting the block turns diff --git a/cmd/mavend/actions_query.go b/cmd/mavend/actions_query.go index e70dd16..d0a35fb 100644 --- a/cmd/mavend/actions_query.go +++ b/cmd/mavend/actions_query.go @@ -161,8 +161,10 @@ func (h *reactiveHandler) actionQuery(ctx context.Context, dec router.Decision) // no query-source field, so a wrong answer could not be told from a // wrongly-ordered chain (Vikunja #474). Only the name is logged — // the utterance and the answer are already on the voice lines above - // and below this one. + // and below this one. The same name goes to the turn's sink when the + // caller asked for one, so /chat can show it (V-539). log.Printf("voice: query claimed by source %q", src.name) + noteQuerySource(ctx, src.name) return reply } } diff --git a/cmd/mavend/querysource.go b/cmd/mavend/querysource.go new file mode 100644 index 0000000..209a63e --- /dev/null +++ b/cmd/mavend/querysource.go @@ -0,0 +1,56 @@ +package main + +import ( + "context" + "sync" +) + +// The query source that claimed a turn was visible in the daemon log and +// nowhere else (V-539). A QA step reading /chat could see a wrong answer but +// not tell a wrong answer from a wrongly ordered chain: "почему небо голубое" +// answered badly reads the same whether search claimed it, the ZIM did, or the +// resident model answered from memory. +// +// It rides the context rather than a return value because handleText answers +// every reach through one string, and threading a second value through the +// whole action dispatch would change a signature the mic, telegram and the web +// all share. The sink is per turn, created by the caller that wants to read it; +// a turn with no sink notes nothing, which is what the mic path does. +type querySourceKey struct{} + +// querySourceSink holds the name of the source that claimed one turn. The mutex +// is there because a query source may fan out to goroutines of its own, not +// because two turns share a sink. +type querySourceSink struct { + mu sync.Mutex + name string +} + +func (s *querySourceSink) note(name string) { + s.mu.Lock() + defer s.mu.Unlock() + s.name = name +} + +// Name is the source that claimed, or empty when nothing did or the turn was +// not a query at all. +func (s *querySourceSink) Name() string { + s.mu.Lock() + defer s.mu.Unlock() + return s.name +} + +// withQuerySourceSink returns a context that collects the claiming source, and +// the sink to read after the turn has answered. +func withQuerySourceSink(ctx context.Context) (context.Context, *querySourceSink) { + sink := &querySourceSink{} + return context.WithValue(ctx, querySourceKey{}, sink), sink +} + +// noteQuerySource records which source claimed the turn. It is a no-op when the +// caller did not ask for one. +func noteQuerySource(ctx context.Context, name string) { + if sink, ok := ctx.Value(querySourceKey{}).(*querySourceSink); ok { + sink.note(name) + } +} diff --git a/cmd/mavend/querysource_test.go b/cmd/mavend/querysource_test.go new file mode 100644 index 0000000..2c46ca0 --- /dev/null +++ b/cmd/mavend/querysource_test.go @@ -0,0 +1,35 @@ +package main + +import ( + "context" + "testing" +) + +func TestQuerySourceSinkCollectsTheClaimingName(t *testing.T) { + ctx, sink := withQuerySourceSink(context.Background()) + if sink.Name() != "" { + t.Fatalf("a fresh sink names a source: %q", sink.Name()) + } + noteQuerySource(ctx, "kiwix") + if got := sink.Name(); got != "kiwix" { + t.Errorf("sink.Name() = %q, want kiwix", got) + } +} + +// A turn with no sink must not panic. The mic path asks for no source, and a +// query source calls noteQuerySource unconditionally. +func TestNoteQuerySourceWithoutASinkIsSilent(t *testing.T) { + noteQuerySource(context.Background(), "search") +} + +// The last source to claim wins, because only one does: actionQuery returns on +// the first claim. This pins that the sink overwrites rather than appends, so a +// second turn on the same context could not read a stale name. +func TestQuerySourceSinkKeepsTheLastNote(t *testing.T) { + ctx, sink := withQuerySourceSink(context.Background()) + noteQuerySource(ctx, "search") + noteQuerySource(ctx, "kiwix") + if got := sink.Name(); got != "kiwix" { + t.Errorf("sink.Name() = %q, want kiwix", got) + } +} diff --git a/cmd/mavend/tick_api.go b/cmd/mavend/tick_api.go index ad733ba..c221912 100644 --- a/cmd/mavend/tick_api.go +++ b/cmd/mavend/tick_api.go @@ -41,11 +41,16 @@ func (d *daemonAPI) RecentEvents(ctx context.Context, n int) ([]ipc.IntakeEvent, return d.getEvents(n), nil } -func (d *daemonAPI) Chat(ctx context.Context, conversation, text string) (string, error) { +// Chat runs one text turn and reports which query source claimed it. The sink +// rides the context so handleText keeps the one string signature the mic, +// telegram and the web all call it through (V-539). +func (d *daemonAPI) Chat(ctx context.Context, conversation, text string) (ipc.ChatReply, error) { if d.chatFn == nil { - return "", errors.New("mavend: chat not available") + return ipc.ChatReply{}, errors.New("mavend: chat not available") } - return d.chatFn(ctx, conversation, text), nil + ctx, sink := withQuerySourceSink(ctx) + reply := d.chatFn(ctx, conversation, text) + return ipc.ChatReply{Reply: reply, Source: sink.Name()}, nil } // MCPServers — the configured MCP servers and their health (Vikunja #251). diff --git a/cmd/mavweb/chat.html b/cmd/mavweb/chat.html index b04f0d4..ac35c0e 100644 --- a/cmd/mavweb/chat.html +++ b/cmd/mavweb/chat.html @@ -4,7 +4,7 @@ {{if .Error}}
{{.Error}}
{{end}}
{{range .Messages}} -
{{if eq .Role "user"}}you{{else}}maven{{end}}: {{.Text}}
+
{{if eq .Role "user"}}you{{else}}maven{{end}}: {{.Text}}{{if .Source}} {{.Source}}{{end}}
{{else}}
diff --git a/cmd/mavweb/handlers_test.go b/cmd/mavweb/handlers_test.go index 45db011..53e6b96 100644 --- a/cmd/mavweb/handlers_test.go +++ b/cmd/mavweb/handlers_test.go @@ -67,8 +67,9 @@ type fakeCore struct { traceErr error // for handleChatAPI tests - chatText string - chatErr error + chatText string + chatSource string + chatErr error // for the MCP section of /tools mcpServers []ipc.MCPServerStatus @@ -79,12 +80,12 @@ func (f *fakeCore) MCPServers(context.Context) ([]ipc.MCPServerStatus, error) { return f.mcpServers, f.mcpErr } -func (f *fakeCore) Chat(_ context.Context, _, text string) (string, error) { +func (f *fakeCore) Chat(_ context.Context, _, text string) (ipc.ChatReply, error) { f.chatText = text if f.chatErr != nil { - return "", f.chatErr + return ipc.ChatReply{}, f.chatErr } - return "поняла", nil + return ipc.ChatReply{Reply: "поняла", Source: f.chatSource}, nil } func (f *fakeCore) EnableTool(_ context.Context, name string, cmd []string, destructive bool, scope string, _ time.Time) error { @@ -1285,3 +1286,37 @@ func TestHandleNotifications_ShowsTheOutbox(t *testing.T) { } } } + +// --- the query source badge (V-539) --- +// +// Which query source claimed a turn was readable in the daemon log and nowhere +// else, so a QA step could not tell a wrong answer from a wrongly ordered +// chain. It now rides the redirect and renders beside the reply. + +func TestHandleChatAPI_CarriesTheClaimingSource(t *testing.T) { + core := &fakeCore{chatSource: "kiwix"} + rr := httptest.NewRecorder() + handleChatAPI(rr, postChat("почему небо голубое"), core, nil, false) + loc := rr.Header().Get("Location") + if !strings.Contains(loc, "s=kiwix") { + t.Errorf("redirect = %q; want the claiming source in it", loc) + } +} + +func TestHandleChatAPI_OmitsTheSourceWhenNothingClaimed(t *testing.T) { + core := &fakeCore{} + rr := httptest.NewRecorder() + handleChatAPI(rr, postChat("запиши что я пил воду"), core, nil, false) + if loc := rr.Header().Get("Location"); strings.Contains(loc, "s=") { + t.Errorf("redirect = %q; a turn no source claimed carries no badge", loc) + } +} + +func TestChatPageRendersTheSourceBadge(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/chat?q=%D1%82%D0%B5%D1%81%D1%82&r=%D0%BE%D1%82%D0%B2%D0%B5%D1%82&s=search", nil) + rr := httptest.NewRecorder() + handleChatPage(rr, req, &fakeCore{}) + if body := rr.Body.String(); !strings.Contains(body, ">search") { + t.Errorf("chat page does not render the source badge; body=%s", body) + } +} diff --git a/cmd/mavweb/main.go b/cmd/mavweb/main.go index 57e4480..a36b70a 100644 --- a/cmd/mavweb/main.go +++ b/cmd/mavweb/main.go @@ -1627,8 +1627,8 @@ func handleChatPage(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) { if q := r.URL.Query().Get("q"); q != "" { msgs = append(msgs, chatMsg{Role: "user", Text: q}) } - if r := r.URL.Query().Get("r"); r != "" { - msgs = append(msgs, chatMsg{Role: "assistant", Text: r}) + if reply := r.URL.Query().Get("r"); reply != "" { + msgs = append(msgs, chatMsg{Role: "assistant", Text: reply, Source: r.URL.Query().Get("s")}) } w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := chatTmpl.Execute(w, struct { @@ -1677,13 +1677,22 @@ func handleChatAPI(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, ses http.Redirect(w, r, "/chat", http.StatusSeeOther) return } - http.Redirect(w, r, "/chat?q="+url.QueryEscape(text)+"&r="+url.QueryEscape(reply), http.StatusSeeOther) + // The claiming query source rides back on the redirect so the page can show + // it. Empty for a turn no source claimed, which is most of them. + dest := "/chat?q=" + url.QueryEscape(text) + "&r=" + url.QueryEscape(reply.Reply) + if reply.Source != "" { + dest += "&s=" + url.QueryEscape(reply.Source) + } + http.Redirect(w, r, dest, http.StatusSeeOther) } // chatMsg — one message in the conversation history. type chatMsg struct { Role string // "user" | "assistant" Text string + // Source — the query source that claimed the turn, shown as a badge beside + // the reply. Empty for a turn no source claimed (V-539). + Source string } func mustMarshal(v any) json.RawMessage { diff --git a/docs/evals/2026-08-05-search-quality-signals.md b/docs/evals/2026-08-05-search-quality-signals.md new file mode 100644 index 0000000..b65cd0b --- /dev/null +++ b/docs/evals/2026-08-05-search-quality-signals.md @@ -0,0 +1,78 @@ +# Does SearXNG claim a question it cannot answer? (V-539) + +Measured 2026-08-05 against the configured instance, `http://127.0.0.1:9563`, +`max_results: 4`, `language: auto`. Sixteen Russian questions: eight real, eight +invented from non-words. The probe read SearXNG's JSON directly, so this measures +the search, not the cascade around it. + +## The premise no longer reproduces + +V-539 was filed on the 2026-08-02 measurement, where SearXNG returned four +results for every query including `зыркабулентный флогистон Мшанского`, and no +`voice: kiwix:` line ever appeared. Today the same shape of query returns +nothing: + +| Query set | Zero results | Four results claimed | +|---|---|---| +| Eight real questions | 0 | 8 | +| Eight invented questions | 7 | 1 | + +`Response.Empty()` is already the gate. Seven of eight invented questions now +pass the turn to the ZIM with no code change at all. What changed is upstream. +Every real answer today comes from `google cse`. It answers a non-word with an +empty result set, where the engine set of three days ago answered with +something. + +## The one that still claims + +`трюмбальная нидроскопия` returned four results, all about a lumbar puncture: + +``` +Люмбальная пункция - адреса и стоимость в больницах в СПб +Пункция спинного мозга - Больница «Шиба +Педиатрический фантом люмбальной пункции новорожденного +``` + +The engine read the invented word as a misspelling of a real one and answered +the real one. That is the whole remaining failure, and it is a near-miss +spelling rather than a catch-all. + +## The three candidate signals do not separate the sets + +V-539 named three signals a quality gate could read. Each was recorded per +query: + +- **No result title shares a token with the query.** Useless. It is true of the + one bad claim, and also true of `столица Франции`, whose four titles are + `Париж`, `Франция`, `Париж — Путеводитель`, `Париж - Море Трэвел`. The right + answer to a capital-city question is the city, which is not a word in the + question. Two more real questions score 3 of 4 rather than 4. +- **Every snippet is empty.** Never fired. Zero empty snippets across all + sixteen queries, real or invented. `ParseResponse` already drops a hit with no + text, so this signal cannot fire by construction. +- **A spelling-suggestion or catch-all engine answered.** Never fired. SearXNG + returned no `corrections` and no `suggestions` for any query, including the one + that silently corrected the spelling itself. + +## Decision: do not build the threshold + +A gate on token overlap would cost `столица Франции` a correct answer to save +one invented word, and the other two signals cannot fire. The task said a wrong +threshold costs a real answer and needs measuring first. It was measured and it +loses. + +What ships instead is the second half of V-539. The claiming query source now +crosses the IPC seam on `ipc.ChatReply.Source`. It renders as a badge beside the +reply on `/chat`. The only evidence before it was a `voice:` log line, which is +why this was hard to judge. The next occurrence is readable off the UI rather +than off the box. + +## Not measured here + +- The cascade. This probe read SearXNG directly. It says nothing about how + `querySearch` phrases what it gets, or whether the resident model turns four + weak snippets into a confident wrong sentence. +- Kiwix. It was healthy on 2026-08-02 and was not re-probed today. +- English questions. The premise was about Russian, where the invented words are. +- Whether the engine set is stable. The whole finding is that it moved in three + days, so this table is a reading of one day. diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go index f84b564..569339b 100644 --- a/internal/auth/auth_test.go +++ b/internal/auth/auth_test.go @@ -348,8 +348,8 @@ func TestGate_IpcServer_ChatAllowedForEnrolledCaller(t *testing.T) { if err != nil { t.Fatalf("Chat: %v", err) } - if reply != "echo: привет" { - t.Fatalf("Chat reply = %q; want %q", reply, "echo: привет") + if reply.Reply != "echo: привет" { + t.Fatalf("Chat reply = %q; want %q", reply.Reply, "echo: привет") } if fake.chats != 1 { t.Fatalf("CoreAPI.Chat calls = %d; want 1", fake.chats) @@ -373,9 +373,9 @@ func (r *recordingAPI) WriteFact(_ context.Context, _ ipc.WriteFactReq) (int64, return int64(r.writes), nil } -func (r *recordingAPI) Chat(_ context.Context, _, text string) (string, error) { +func (r *recordingAPI) Chat(_ context.Context, _, text string) (ipc.ChatReply, error) { r.chats++ - return "echo: " + text, nil + return ipc.ChatReply{Reply: "echo: " + text}, nil } // mustWriteFactParams — minimal WriteFactReq JSON with only the source field, diff --git a/internal/ipc/api.go b/internal/ipc/api.go index 2726bd0..36ad4ac 100644 --- a/internal/ipc/api.go +++ b/internal/ipc/api.go @@ -214,9 +214,9 @@ type SeedEventReq struct { // failure. Proposed is true only when this seed completed a pattern; the first // three seeds of a run return false with no routine. type SeedEventResp struct { - FactID int64 `json:"fact_id"` - EventID int64 `json:"event_id,omitempty"` - Extracted bool `json:"extracted"` + FactID int64 `json:"fact_id"` + EventID int64 `json:"event_id,omitempty"` + Extracted bool `json:"extracted"` Action string `json:"action,omitempty"` Object string `json:"object,omitempty"` Proposed bool `json:"proposed"` @@ -664,7 +664,20 @@ type chatReq struct { Conversation string `json:"conversation,omitempty"` } type chatResp struct { - Reply string `json:"reply"` + Reply string `json:"reply"` + Source string `json:"source,omitempty"` +} + +// ChatReply — one text turn's answer plus which query source claimed it. +// +// Source is diagnostic and is empty unless the turn was a question a source +// claimed: a fact write, an act or a chat turn names none. It exists because +// the claiming source was readable only in the daemon log, so a QA step could +// not tell a wrong answer from a wrongly ordered chain (V-539). It is not +// authorization and nothing routes on it. +type ChatReply struct { + Reply string + Source string } type proposeToolReq struct { diff --git a/internal/ipc/client.go b/internal/ipc/client.go index 6029f84..1b813ad 100644 --- a/internal/ipc/client.go +++ b/internal/ipc/client.go @@ -640,12 +640,12 @@ func (c *Client) AcceptProposedRoutine(ctx context.Context, id int64) error { return c.call(ctx, MethodAcceptProposedRoutine, acceptProposedRoutineReq{ID: id}, nil) } -func (c *Client) Chat(ctx context.Context, conversation, text string) (string, error) { +func (c *Client) Chat(ctx context.Context, conversation, text string) (ChatReply, error) { var r chatResp if err := c.call(ctx, MethodChat, chatReq{Text: text, Conversation: conversation}, &r); err != nil { - return "", err + return ChatReply{}, err } - return r.Reply, nil + return ChatReply{Reply: r.Reply, Source: r.Source}, nil } func (c *Client) TickTrace(ctx context.Context) (TickTrace, error) { diff --git a/internal/ipc/coreapi.go b/internal/ipc/coreapi.go index 8cd65ab..1295fc8 100644 --- a/internal/ipc/coreapi.go +++ b/internal/ipc/coreapi.go @@ -176,7 +176,7 @@ type SystemAPI interface { // conversation, so an unanswered question on one reach cannot eat the next // utterance from another (Vikunja #466). Empty means the unattributed text // tap and is still one conversation of its own, separate from the mic. - Chat(ctx context.Context, conversation, text string) (string, error) + Chat(ctx context.Context, conversation, text string) (ChatReply, error) } // CoreAPI — what core exposes to modules. One Go interface, satisfied by: diff --git a/internal/ipc/ipc_test.go b/internal/ipc/ipc_test.go index 5b8ca2b..87b3c34 100644 --- a/internal/ipc/ipc_test.go +++ b/internal/ipc/ipc_test.go @@ -405,8 +405,17 @@ func TestChatViaClient(t *testing.T) { if err != nil { t.Fatalf("Chat: %v", err) } - if reply != "и тебе привет!" { - t.Fatalf("Chat = %q, want %q", reply, "и тебе привет!") + if reply.Reply != "и тебе привет!" { + t.Fatalf("Chat = %q, want %q", reply.Reply, "и тебе привет!") + } + + // The claiming query source crosses the wire beside the reply (V-539). + sourced, err := cli.Chat(context.Background(), "web", "почему небо голубое") + if err != nil { + t.Fatalf("Chat: %v", err) + } + if sourced.Source != "kiwix" { + t.Fatalf("Chat source = %q, want kiwix", sourced.Source) } } @@ -417,11 +426,11 @@ type chatTestAPI struct { UnimplementedCoreAPI } -func (a *chatTestAPI) Chat(ctx context.Context, _, text string) (string, error) { +func (a *chatTestAPI) Chat(ctx context.Context, _, text string) (ChatReply, error) { if text == "привет" { - return "и тебе привет!", nil + return ChatReply{Reply: "и тебе привет!"}, nil } - return "поговорили.", nil + return ChatReply{Reply: "поговорили.", Source: "kiwix"}, nil } // TestDispatch_UnknownMethod — an unknown method over the wire comes back as diff --git a/internal/ipc/server.go b/internal/ipc/server.go index bd6df2b..eb63b59 100644 --- a/internal/ipc/server.go +++ b/internal/ipc/server.go @@ -565,7 +565,7 @@ var methodTable = map[Method]handlerFunc{ }), MethodChat: withParams(func(ctx context.Context, api CoreAPI, p chatReq) (chatResp, error) { reply, err := api.Chat(ctx, p.Conversation, p.Text) - return chatResp{Reply: reply}, err + return chatResp{Reply: reply.Reply, Source: reply.Source}, err }), MethodTickTrace: withoutParams(func(ctx context.Context, api CoreAPI) (TickTrace, error) { return api.TickTrace(ctx) diff --git a/internal/ipc/storeapi.go b/internal/ipc/storeapi.go index ec99b7d..d44f126 100644 --- a/internal/ipc/storeapi.go +++ b/internal/ipc/storeapi.go @@ -257,8 +257,8 @@ func (a *storeAPI) RevertFact(ctx context.Context, key string) (int64, error) { return newID, mapErr(err) } -func (a *storeAPI) Chat(ctx context.Context, conversation, text string) (string, error) { - return "", errors.New("store: chat not available via direct store API") +func (a *storeAPI) Chat(ctx context.Context, conversation, text string) (ChatReply, error) { + return ChatReply{}, errors.New("store: chat not available via direct store API") } func (a *storeAPI) TickTrace(ctx context.Context) (TickTrace, error) { diff --git a/internal/ipc/unimplemented.go b/internal/ipc/unimplemented.go index 4c245f2..8d4c1d9 100644 --- a/internal/ipc/unimplemented.go +++ b/internal/ipc/unimplemented.go @@ -147,6 +147,6 @@ func (UnimplementedCoreAPI) MCPServers(ctx context.Context) ([]MCPServerStatus, func (UnimplementedCoreAPI) DayPlan(ctx context.Context) (DayPlan, error) { return DayPlan{}, ErrNotImplemented } -func (UnimplementedCoreAPI) Chat(ctx context.Context, conversation, text string) (string, error) { - return "", ErrNotImplemented +func (UnimplementedCoreAPI) Chat(ctx context.Context, conversation, text string) (ChatReply, error) { + return ChatReply{}, ErrNotImplemented }