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}}
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
}