diff --git a/cmd/mavend/actions_query.go b/cmd/mavend/actions_query.go index b192892..e24651a 100644 --- a/cmd/mavend/actions_query.go +++ b/cmd/mavend/actions_query.go @@ -122,6 +122,12 @@ var querySources = []querySource{ {name: "network", answer: (*reactiveHandler).queryNetwork}, {name: "calendar", answer: (*reactiveHandler).queryCalendar, dateAware: true}, {name: "weather", answer: (*reactiveHandler).queryWeather}, + // A question about her, above the three sources that search his own data + // (Vikunja #555). It has no answer anywhere else: below the boundary + // SearXNG answers about somebody else's assistant, and above it his notes + // answer by proximity — "кто ты" came back from a note of his, measured on + // the box, because the recall index has no idea the subject is her. + {name: "self", answer: (*reactiveHandler).querySelf}, {name: "embed", answer: (*reactiveHandler).queryEmbed}, {name: "memory", answer: (*reactiveHandler).queryMemory}, {name: "notes", answer: (*reactiveHandler).queryNotes}, @@ -129,11 +135,6 @@ 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. - // A question about her sits just above the boundary, because it has no - // answer below one: refusing it as his says "не нашла у тебя такой записи" - // about her own description, and letting it through asks SearXNG about - // somebody else's assistant (Vikunja #555). - {name: "self", answer: (*reactiveHandler).querySelf}, {name: "personal", answer: (*reactiveHandler).queryPersonal}, // 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 diff --git a/cmd/mavend/self.go b/cmd/mavend/self.go index 58afaa9..dbe5dec 100644 --- a/cmd/mavend/self.go +++ b/cmd/mavend/self.go @@ -104,7 +104,7 @@ func (h *reactiveHandler) querySelf(ctx context.Context, t *queryTurn) (string, var reply string if h.phraser != nil { var err error - reply, err = h.phraser.PhraseQuery(ctx, t.dec.Utterance, []string{selfDescription}) + reply, err = h.phraser.PhraseSelf(ctx, t.dec.Utterance, selfDescription) if err != nil { log.Printf("voice: phrase self: %v", err) } diff --git a/internal/phraser/llmphraser.go b/internal/phraser/llmphraser.go index 84d486b..3b364a1 100644 --- a/internal/phraser/llmphraser.go +++ b/internal/phraser/llmphraser.go @@ -941,6 +941,36 @@ func (p *LLMPhraser) knowledgePrompt(utterance string) (sys, user string) { fmt.Sprintf("Пользователь спрашивает: \"%s\".", utterance) } +// PhraseSelf answers a question about her from her own description. Same +// discipline as the evidence branch — say only what the text says — and a +// different opener, because "вот что я нашла: я — твоя помощница" says she +// looked herself up (Vikunja #555). She did not; this is the one subject she +// does not have to read about. +// +// On any error it reads the description out rather than ship a fragment. That +// is already a readable answer, which is why this needs no separate fallback. +func (p *LLMPhraser) PhraseSelf(ctx context.Context, utterance, description string) (string, error) { + sys := persona.Prepend(p.cfg.ContextBlock, + "Он спрашивает о тебе. Отвечай ТОЛЬКО по описанию, которое тебе дали: всё, что ты говоришь о себе, должно быть в нём. "+ + "Не добавляй умений, которых там нет, и не догадывайся. Не начинай с \"вот что я нашла\" — ты говоришь о себе, а не о находке. "+ + "Отвечай по-русски, коротко и своими словами. О себе — в женском роде, глаголы в прошедшем времени с окончанием -ла. "+ + "Он мужчина, обращайся к нему на \"ты\". Отвечай ТОЛЬКО одним объектом JSON: {\"response\": \"...\", \"mood\": \"neutral\"}.") + prompt := fmt.Sprintf("Он спрашивает: %q\n\nТвоё описание:\n%s\n\nОтветь ему на то, что он спросил.", utterance, description) + resp, err := p.chatWithSystem(ctx, sys, prompt, 768) + text, _, perr := parseResponseMood(resp) + if err != nil || perr != nil { + cause := err + if cause == nil { + cause = perr + } + return description, fmt.Errorf("phrase self: %w", cause) + } + if text != "" { + return text, nil + } + return description, nil +} + // evidencePrompt — the sources branch: read these, add nothing. Shared with // PhraseWorld for the same reason as knowledgePrompt. func (p *LLMPhraser) evidencePrompt(utterance string, notes []string) (sys, user string) { diff --git a/internal/phraser/phraser.go b/internal/phraser/phraser.go index 9cdb34b..df184fc 100644 --- a/internal/phraser/phraser.go +++ b/internal/phraser/phraser.go @@ -49,6 +49,9 @@ type Phraser interface { PhraseNudge(ctx context.Context, c loop.Candidate) (delivery.PhrasedNudge, error) PhraseReminder(ctx context.Context, d loop.ReminderDecision) (delivery.PhrasedReminder, error) PhraseQuery(ctx context.Context, utterance string, notes []string) (string, error) + // PhraseSelf answers a question about her from her own description, which + // is not a source she read and must not be phrased as one (Vikunja #555). + PhraseSelf(ctx context.Context, utterance, description string) (string, error) PhraseChat(ctx context.Context, utterance string, history []dialogue.Turn) (string, error) Close() error } @@ -81,6 +84,13 @@ func (s *Stub) PhraseQuery(_ context.Context, _ string, notes []string) (string, return SourcesFallback(strings.Join(notes, "; ")), nil } +// PhraseSelf reads the description out as it stands. There is nothing to fall +// back to and nothing to shorten: the text is already written in her voice, and +// that is the whole reason it is a constant rather than a prompt. +func (s *Stub) PhraseSelf(_ context.Context, _, description string) (string, error) { + return description, nil +} + // Close implements Phraser.Close (no-op for the stub). func (s *Stub) Close() error { return nil }