the query source that claimed a turn is readable on /chat (V-539)

V-539 said SearXNG claims every world question, including invented terms,
so Kiwix is never reached. Measured today against the configured instance:
seven of eight invented Russian questions now return zero results, and
Response.Empty() already passes those to the ZIM. The premise moved with the
upstream engine set in three days.

The three quality signals the task named were recorded per query and none
separate the sets. Token overlap is zero for the one bad claim and also zero
for "столица Франции", whose answer is Париж. Empty snippets never fire,
because ParseResponse already drops a hit with no text. SearXNG returned no
corrections or suggestions even for the query it silently respelled. So no
threshold is built: it would cost a real answer to save one invented word.

What ships is the second half. The claiming query source crosses the IPC seam
on ipc.ChatReply.Source and renders as a badge beside the reply on /chat. It
rides the context rather than a return value, because handleText answers every
reach through one string and the mic, telegram and the web all share it.
Chat now returns ChatReply instead of a bare string.

Full -race suite green.
This commit is contained in:
2026-08-05 15:28:26 +04:00
parent 7cacbc8b21
commit 888c1c6768
17 changed files with 286 additions and 35 deletions
+4 -4
View File
@@ -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,
+17 -4
View File
@@ -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 {
+3 -3
View File
@@ -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) {
+1 -1
View File
@@ -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:
+14 -5
View File
@@ -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
+1 -1
View File
@@ -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)
+2 -2
View File
@@ -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) {
+2 -2
View File
@@ -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
}