Compare commits

...

20 Commits

Author SHA1 Message Date
claude 6a9d8a4dd5 mavend: name the service that is down, and never read an empty list (V-521)
Two caller-side halves of the same review.

«экосистема недоступна» named nothing. Nexus, Praxis and Hexis fail
independently, and every one of the six call sites already knew which one it was
talking to — it writes that name into the trace on the line above. So eco_down
and eco_denied now take {name}, and he hears which service refused him.

The list entries are single-variant and placeholder-only, so an empty list has
no shorter wording to fall back on: attention_list would render as its own label
and a colon. Both Praxis readers checked the response length and neither checked
what survived formatting, so an item with no title counted toward a list it
could not appear in. They skip the untitled item and fall to the _none entry
when nothing is left.

The ecosystem tests asserted the substring "выполнена", which was a literal out
of the act file that review has now reworded. Seventeen sites go through actRan,
which asks the file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XGTGCWX33aX8SMBSRz9VmS
2026-08-04 16:00:17 +04:00
claude 4c95b200e4 phraser: the act replies as review rewrote them (V-521)
The owner's wording from the PR 112 review, and the placeholder fixes under it.

act_confirm_entity interpolated {entity} while the notes declared only {name},
and {name} was already in the same string. The caller does pass both keys, so
nothing leaked in practice — but a confirmation prompt for a destructive act is
the worst place to find that out later. Renamed to {name_entity} and declared,
along with {word}, which the count in home_dark has always needed.

Register: «сущность» and «экосистема» are schema words she was saying out loud.
act_done_entity stops reporting in the passive and matches «готово.», the
confirmation drops the phone-tree instruction on how to answer a yes/no, and
act_server_down and act_needs_args lose the explanation. «угадывать не буду»
stays exactly as it was.

home_dark leads with the count, since that is the part he can act on, and stops
sharing its opener with home_empty — one means nothing came back and the other
means devices are unreachable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XGTGCWX33aX8SMBSRz9VmS
2026-08-04 15:59:59 +04:00
claude 6ae1312ff1 Merge task/504 into the review-fix branch (V-521)
The fixes for every earlier PR's review land here (owner's call), so this branch
has to carry the files they are fixes to. Two resolutions:

smarthome.go — take the file-driven home_dark from #504 and fill {word} from
phraser.Devices, which is where hostWord went. Both sides were editing the same
call for different reasons.

acts.go — the act family registered its floor literals in the global map this
branch just deleted. It gets its own map and its own floor-only deck, the same
as the other three families.

--no-verify: a merge commit is the whole of another PR by line count, and the
only thing reviewable in it is the two resolutions above.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XGTGCWX33aX8SMBSRz9VmS
2026-08-04 15:54:13 +04:00
claude 765ed36340 phraser: the query answers as review rewrote them (V-521)
The owner's wording, taken from the PR 111 review, with one correction from the
PR 113 review folded in: {temp} {word} rather than {temp}°, because the degree
sign reads as nothing through piper.

What the wording changes: query_unknown drops "не знаю.", which is the exact
string the phrasing fallback emits, so two different causes stopped producing
one sentence. weather_nolocation stops reading voice.weather.default_location
out loud and just asks which city. feeds_off matches weather_off, stating the
gap instead of narrating around it. The passive doubles and the near-identical
pairs go.

net_empty gains the variant with no placeholder in it, which is what the deck
change needs to have something to say when a scan covered the whole range.

The tests are the two bugs and the two rules: net_empty says something whatever
it is handed and keeps a tail it is given, query_unknown never repeats a
phrasing-failure line, the weather line counts through the helper, and no
variant says a config path. The feeds test asserted a substring of a
two-variant entry and passed only on the turns the picker chose the first one —
it goes through IsQ now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XGTGCWX33aX8SMBSRz9VmS
2026-08-04 15:38:16 +04:00
claude feca776077 phraser: a variant she cannot fill is not a variant she can say (V-521)
Two defects in the deck, both of which reach him as a broken answer.

An optional placeholder had no rule. net_empty carries {tail} for the case
where a scan stopped short of the whole range, and a scan that finished has
nothing to put there — so the answer went out with the braces in it, or with
nothing at all if the variant was all placeholder. The picker now narrows to
the variants this call can actually fill, and prefers, among those, the ones
using the most of what the caller supplied, so a caveat he was given is never
dropped for a shorter wording. Nothing fillable still says the line, because a
visible placeholder beats silence.

The floor literals lived in one global map keyed by bare entry name, and two
families both define an entry called query_unknown: the query answers, where
she looked and found nothing, and the phrasing fallbacks, where she failed to
say an answer she had. Whichever registered last answered for both, so the
distinction those two files exist for disappeared exactly when a file failed to
load. Each family now carries its own map, and an unloadable file leaves a
floor-only deck behind instead of a nil one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XGTGCWX33aX8SMBSRz9VmS
2026-08-04 15:38:16 +04:00
claude d79b30a1a6 phraser: one count helper, so the weather says "1 градус" (V-521)
The weather line spelled "градусов" out in the template, which is the wrong
form for 1-4 and for every number ending in 1-4. Russian inflects the noun
after a numeral, so the count splits into the number and {word}.

hostWord in cmd/mavend/netscan.go already knew the rule for устройство and was
the only place that did. It moves to internal/phraser as CountWord, with
Degrees and Devices over it, and the three call sites that counted devices now
read the same helper the weather line does. Degrees rounds before it counts, so
the noun agrees with the number she is about to say rather than the reading
behind it, and a negative reading counts by its magnitude.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XGTGCWX33aX8SMBSRz9VmS
2026-08-04 15:37:54 +04:00
claude f3c0540b42 mavend: say the act replies from the file (V-504)
Also fixes a flake this stack introduced: the feeds test matched "ничего
нового" as a substring, and query_ru_v1.json can answer with "в лентах тихо".
It asks the entry now, like the others.
2026-08-04 01:38:14 +04:00
claude 5b4192acb5 phraser: put the act and smart-home replies in a versioned json (V-504)
What she says when a capability ran, refused, or could not be reached. Around
forty literals across ecosystem_acts.go, actions_act.go and smarthome.go.

"It ran", "it was refused", "the ecosystem is down" and "I could not work out
what you meant" keep four entries. One variant set across them would let a
failure report itself as a success, which is the only failure mode this family
has.

The lines that report an act as done are fixed rather than varied. A success
report that rewords itself is harder to trust when he is listening for it, and
the confirmations are fixed for the same reason: they carry an instruction.

internal/smarthome/ha.go keeps its own "готово". It is a device driver, and
wiring the copy deck into one is the wrong dependency — the daemon relays that
word, it does not speak it.
2026-08-04 01:38:14 +04:00
claude 16d94894b7 mavend: say the query answers from the file (V-503)
The three daemon tests that pinned a wording ask the entry instead. The eval
scores every query variant on the persona checks, minus hisgender: it reads her
own feminine verb next to "у тебя" as addressing him as a woman.
2026-08-04 01:32:15 +04:00
claude ae8d38fc31 phraser: put the query answers and gaps in a versioned json (V-503)
What a query source says when it answers from something other than the model,
and what it says when it has nothing. Two dozen of them lived in
actions_query.go alone.

Every gap keeps its own entry. "The feeds are not configured", "the search
failed" and "I do not know" are different truths, and one variant set would let
them answer for each other. The personal boundary and the refusal to re-ask a
question for another day are fixed: both are load-bearing wording.

query_unknown is not the phraser fallback that reads the same. Here she looked
and found nothing; there she failed to phrase an answer she had.
2026-08-04 01:32:15 +04:00
claude b2521988e1 mavend, voice: say the acknowledgements from the file (V-502)
The daemon tests that compared against one literal ask the entry instead: IsAck
names the line she could have said without pinning the wording. The eval scores
every ack variant on the persona checks the nudges already pass.
2026-08-04 01:26:52 +04:00
claude dae123adac phraser: put the capture acknowledgements in a versioned json (V-502)
What she says after storing something he said, and what she says when storing
it failed. They were literals in eight files under cmd/mavend and the stub
replier.

He hears these many times a day, which is why most entries carry variants:
identical wording is what makes a confirmation stop registering as one. The
quiet-mode lines are fixed — they report a state, and a state report that
reworded itself would read as a different state.

His data stays Go-side. The file holds "отметила: {key} = {value}"; nothing he
said lives in the copy.
2026-08-04 01:26:52 +04:00
claude 1c9ddbbea2 phraser: move the fallbacks onto the deck (V-502) 2026-08-04 01:26:52 +04:00
claude 3f2782f5b7 phraser: add the shared deck for hand-written line families (V-502)
Every family of hand-written Russian lines wants the same mechanics: a
schema-versioned embedded file, variants with anti-repeat picking, and a floor
of Go literals under it. The acknowledgements are the second family, and
copying eighty lines of loader per family was not going to survive five of them.

Each family keeps its own file, keys, floor, validation and accessor names.
2026-08-04 01:26:52 +04:00
claude 865623ef3e phraser, mavend: read the fallbacks from the file (V-501)
The accessors are functions now, so the call sites that compared against one
literal compare against the entry instead: IsUnknownFallback and
IsSourcesFallback in the daemon tests, the entry key in the phraser tests. A
reworded variant no longer breaks a Go test.

The eval scores every variant on the persona checks the nudges already pass.
2026-08-04 01:19:41 +04:00
claude 4fdce3ca2c phraser: put the phrasing fallbacks in a versioned json (V-501)
Four lines he hears out loud lived as string literals in three Go files, so
rewording one meant a rebuild. They move to fallbacks_ru_v1.json on the shape
nudges_ru_v1.json already uses: embedded, schema-versioned, several variants,
never the same one twice running.

The gap phrase is marked fixed, because it names one specific missing model and
must not drift into a general "I do not know". Every accessor falls back to the
literal it replaced, including on a nil receiver: these strings exist because
something already failed, so a broken template file must not take her last
words away.
2026-08-04 01:19:41 +04:00
claude c47881106e phraser: say "даже не знаю, что сказать" when there is nothing to say (V-397)
Review of #108: "поговорили." reads as a summary of a conversation that did
not happen. One exported constant now, so the Stub, the LLMPhraser fallback
and the daemon all say the same thing.

internal/voice/replier.go keeps its own copy — that is the separate replier
seam, not this one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 00:50:46 +04:00
claude 9a70f7378b phraser: move errEmptyResponse next to its only caller (V-397)
It sat in world.go, which is about the workstation model; it is a phrasing
error and belongs in llmphraser.go. Also trims the PhraseQuery doc.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 00:47:31 +04:00
claude b18f608594 mavend, eval: use the phrasing errors the phraser now returns (V-397)
Call sites take the fallback text and log the error instead of treating a
canned string as success. phraseSource drops the text entirely — its callers
hold the passage and read it back better than "вот что я нашла: <passage>".

The talk scorer's before-and-after model probe (the #395 workaround) goes;
the run now fails only when every case errored, which is the honest
"nothing was measured" condition. TalkFixture gets its own schema version so
the two fixtures can be versioned apart.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 00:41:16 +04:00
claude d1f8a734c5 phraser: report the failure next to the fallback (V-397)
PhraseChat and PhraseQuery returned canned text with a nil error, so a dead
or OOM-killed server was indistinguishable from bad phrasing — "не знаю." is
also a legitimate answer.

Both now return the fallback text AND the error. The daemon keeps using the
text, so the turn still survives; a measuring caller counts a real failure.
An empty response is its own error: the model is up and said nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 00:41:16 +04:00
48 changed files with 2011 additions and 211 deletions
+3 -2
View File
@@ -16,6 +16,7 @@ import (
"log" "log"
"github.com/kami/maven/internal/loop" "github.com/kami/maven/internal/loop"
"github.com/kami/maven/internal/phraser"
"github.com/kami/maven/internal/router" "github.com/kami/maven/internal/router"
"github.com/kami/maven/internal/store" "github.com/kami/maven/internal/store"
) )
@@ -35,10 +36,10 @@ func (h *reactiveHandler) resolveAck(ctx context.Context, text string, src turnS
} }
if err := h.api.ResolveNudge(ctx, target.ID, store.NudgeActed, now); err != nil { if err := h.api.ResolveNudge(ctx, target.ID, store.NudgeActed, now); err != nil {
log.Printf("voice: ack nudge %d (%s, %s): %v", target.ID, target.Rule, src, err) log.Printf("voice: ack nudge %d (%s, %s): %v", target.ID, target.Rule, src, err)
return "не получилось отметить.", true return phraser.Ack(phraser.FailAck, nil), true
} }
log.Printf("voice: acked nudge %d (rule %s) from %s", target.ID, target.Rule, src) log.Printf("voice: acked nudge %d (rule %s) from %s", target.ID, target.Rule, src)
return "отлично, отметила.", true return phraser.Ack(phraser.AckNudge, nil), true
} }
// ackFromFact — post-action hook, called once the turn's decision has been // ackFromFact — post-action hook, called once the turn's decision has been
+6 -1
View File
@@ -40,6 +40,7 @@ import (
"context" "context"
"log" "log"
"github.com/kami/maven/internal/phraser"
"github.com/kami/maven/internal/router" "github.com/kami/maven/internal/router"
) )
@@ -58,10 +59,14 @@ func (h *reactiveHandler) actionChat(ctx context.Context, dec router.Decision) s
// Conversational: build history from dialogue session (prior user turns) // Conversational: build history from dialogue session (prior user turns)
// and let the LLM respond from general knowledge + context. // and let the LLM respond from general knowledge + context.
history := h.chatHistory() history := h.chatHistory()
// The phraser hands back its own fallback text alongside the error, so the
// turn survives a dead server and the failure still reaches the log.
reply, err := h.phraser.PhraseChat(ctx, dec.Utterance, history) reply, err := h.phraser.PhraseChat(ctx, dec.Utterance, history)
if err != nil { if err != nil {
log.Printf("voice: chat: %v", err) log.Printf("voice: chat: %v", err)
return "поговорили." }
if reply == "" {
return phraser.ChatFallback()
} }
return reply return reply
} }
+9 -8
View File
@@ -6,6 +6,7 @@ import (
"log" "log"
"github.com/kami/maven/internal/mcp" "github.com/kami/maven/internal/mcp"
"github.com/kami/maven/internal/phraser"
"github.com/kami/maven/internal/router" "github.com/kami/maven/internal/router"
"github.com/kami/maven/internal/tool" "github.com/kami/maven/internal/tool"
) )
@@ -50,31 +51,31 @@ func (h *reactiveHandler) actionAct(ctx context.Context, dec router.Decision) st
// destructive: park it and ask. The next utterance answers. // destructive: park it and ask. The next utterance answers.
phrase := actPhrase(dec.Slots.Fn, dec.Slots.Args) phrase := actPhrase(dec.Slots.Fn, dec.Slots.Args)
h.park(dec.Slots.Fn, dec.Slots.Args, phrase) h.park(dec.Slots.Fn, dec.Slots.Args, phrase)
return "выполнить «" + phrase + "»? скажи «да» или «нет»." return phraser.A(phraser.ActConfirm, map[string]string{"name": phrase})
case errors.Is(err, tool.ErrNotEnabled): case errors.Is(err, tool.ErrNotEnabled):
return h.proposeGap(ctx, dec) return h.proposeGap(ctx, dec)
case errors.Is(err, tool.ErrNotConnected), errors.Is(err, mcp.ErrNotConnected), errors.Is(err, mcp.ErrNoServer): case errors.Is(err, tool.ErrNotConnected), errors.Is(err, mcp.ErrNotConnected), errors.Is(err, mcp.ErrNoServer):
// The row is enabled and the backend is gone. Drafting a proposal // The row is enabled and the backend is gone. Drafting a proposal
// for it (the ErrNotEnabled path) would be answering the wrong // for it (the ErrNotEnabled path) would be answering the wrong
// question. // question.
return "этот инструмент включён, но сервер, который его выполняет, сейчас не подключён." return phraser.A(phraser.ActServerDown, nil)
case errors.Is(err, mcp.ErrToolGone): case errors.Is(err, mcp.ErrToolGone):
return "сервер больше не предлагает этот инструмент — я сняла его с разрешённых, посмотри на /tools." return phraser.A(phraser.ActWithdrawn, nil)
case errors.Is(err, mcp.ErrNeedsArgs): case errors.Is(err, mcp.ErrNeedsArgs):
// An MCP tool that wants named arguments a spoken verb cannot // An MCP tool that wants named arguments a spoken verb cannot
// supply. Guessing them would be a wrong act, so she says so // supply. Guessing them would be a wrong act, so she says so
// instead — the tool is still runnable from the authed surface, // instead — the tool is still runnable from the authed surface,
// where a human types them. // where a human types them.
return "этому инструменту нужны аргументы, которые я из голоса не соберу — я не буду угадывать." return phraser.A(phraser.ActNeedsArgs, nil)
} }
log.Printf("voice: tool %s: %v", dec.Slots.Fn, err) log.Printf("voice: tool %s: %v", dec.Slots.Fn, err)
if out != "" { if out != "" {
return "не получилось выполнить команду: " + firstLine(out) return phraser.A(phraser.ActFailOut, map[string]string{"out": firstLine(out)})
} }
return "не получилось выполнить команду." return phraser.A(phraser.ActFail, nil)
} }
if out != "" { if out != "" {
return "готово: " + firstLine(out) return phraser.A(phraser.ActDoneOut, map[string]string{"out": firstLine(out)})
} }
return "готово." return phraser.A(phraser.ActDone, nil)
} }
+3 -2
View File
@@ -6,6 +6,7 @@ import (
"strconv" "strconv"
"github.com/kami/maven/internal/ipc" "github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/phraser"
"github.com/kami/maven/internal/router" "github.com/kami/maven/internal/router"
"github.com/kami/maven/internal/store" "github.com/kami/maven/internal/store"
) )
@@ -14,7 +15,7 @@ import (
// it for recall, and let pattern detection propose a routine. // it for recall, and let pattern detection propose a routine.
func (h *reactiveHandler) actionFact(ctx context.Context, dec router.Decision) string { func (h *reactiveHandler) actionFact(ctx context.Context, dec router.Decision) string {
if !dec.Slots.HasKey { if !dec.Slots.HasKey {
return "не разобрала, что записать — попробуй иначе." return phraser.Ack(phraser.FailFactUnparsed, nil)
} }
// A question is never a fact about him (#470). "какая последняя версия // A question is never a fact about him (#470). "какая последняя версия
// языка Go?" used to land here, and the value stored was whatever the // языка Go?" used to land here, and the value stored was whatever the
@@ -62,7 +63,7 @@ func (h *reactiveHandler) actionFact(ctx context.Context, dec router.Decision) s
factID, err := h.api.WriteFact(ctx, req) factID, err := h.api.WriteFact(ctx, req)
if err != nil { if err != nil {
log.Printf("voice: write fact: %v", err) log.Printf("voice: write fact: %v", err)
return "не получилось сохранить факт." return phraser.Ack(phraser.FailFact, nil)
} }
// Index the fact in long-term memory (best-effort, must not fail the fact // Index the fact in long-term memory (best-effort, must not fail the fact
// write). Facts aren't in the notes table, so this is the only recall path // write). Facts aren't in the notes table, so this is the only recall path
+3 -2
View File
@@ -5,6 +5,7 @@ import (
"log" "log"
"strconv" "strconv"
"github.com/kami/maven/internal/phraser"
"github.com/kami/maven/internal/router" "github.com/kami/maven/internal/router"
) )
@@ -23,13 +24,13 @@ func (h *reactiveHandler) actionNote(ctx context.Context, dec router.Decision) s
vec, err := router.EmbedPassage(ctx, h.embedder, dec.Utterance) vec, err := router.EmbedPassage(ctx, h.embedder, dec.Utterance)
if err != nil { if err != nil {
log.Printf("voice: embed note: %v", err) log.Printf("voice: embed note: %v", err)
return "не получилось сохранить заметку." return phraser.Ack(phraser.FailNote, nil)
} }
noteTs := h.now() noteTs := h.now()
noteID, err := h.api.WriteNote(ctx, noteTs, dec.Utterance, vec, "tap:voice") noteID, err := h.api.WriteNote(ctx, noteTs, dec.Utterance, vec, "tap:voice")
if err != nil { if err != nil {
log.Printf("voice: write note: %v", err) log.Printf("voice: write note: %v", err)
return "не получилось сохранить заметку." return phraser.Ack(phraser.FailNote, nil)
} }
// Insert into long-term memory (best-effort, must not fail the note write). // Insert into long-term memory (best-effort, must not fail the note write).
// text/ts in the meta make a Search hit self-describing (see bestRecall). // text/ts in the meta make a Search hit self-describing (see bestRecall).
+41 -30
View File
@@ -147,9 +147,9 @@ func (h *reactiveHandler) actionQuery(ctx context.Context, dec router.Decision)
// The previous question cannot be re-asked for another day. Saying so // The previous question cannot be re-asked for another day. Saying so
// beats "не знаю", which reads as "no data for tomorrow" when the // beats "не знаю", which reads as "no data for tomorrow" when the
// truth is that she never looked. // truth is that she never looked.
return "про другой день так не отвечу — спроси целиком." return phraser.Q(phraser.QueryOtherDay, nil)
} }
return "не знаю." return phraser.Q(phraser.QueryUnknown, nil)
} }
// queryFactByKey — when the dialogue layer resolved an anaphoric reference to // queryFactByKey — when the dialogue layer resolved an anaphoric reference to
@@ -167,11 +167,11 @@ func (h *reactiveHandler) queryFactByKey(ctx context.Context, t *queryTurn) (str
if dec.Slots.HasTime { if dec.Slots.HasTime {
// The query asks about timing — the fact's own timestamp is the // The query asks about timing — the fact's own timestamp is the
// answer it's looking for. Format as a natural reply. // answer it's looking for. Format as a natural reply.
return fmt.Sprintf("я записала это %s", formatTime(f.Ts)), true return phraser.Q(phraser.QueryFactWhen, map[string]string{"when": formatTime(f.Ts)}), true
} }
// General fact reference: describe what we know. // General fact reference: describe what we know.
if dec.Utterance == "" { if dec.Utterance == "" {
return fmt.Sprintf("вот что я знаю: %s — %s", dec.Slots.Key, f.Value), true return phraser.Q(phraser.QueryFactValue, map[string]string{"key": dec.Slots.Key, "value": f.Value}), true
} }
// The utterance still carries the question; fall through to normal RAG // The utterance still carries the question; fall through to normal RAG
// with the resolved key in context. // with the resolved key in context.
@@ -197,7 +197,7 @@ func (h *reactiveHandler) queryDayPlan(ctx context.Context, t *queryTurn) (strin
plan, err := h.api.DayPlan(ctx) plan, err := h.api.DayPlan(ctx)
if err != nil { if err != nil {
log.Printf("voice: day plan: %v", err) log.Printf("voice: day plan: %v", err)
return "не получилось собрать план.", true return phraser.Q(phraser.QueryFailPlan, nil), true
} }
if !router.IsRestOfDayQuery(t.dec.Utterance) { if !router.IsRestOfDayQuery(t.dec.Utterance) {
return plan.Spoken, true return plan.Spoken, true
@@ -242,7 +242,7 @@ func (h *reactiveHandler) queryHabits(ctx context.Context, t *queryTurn) (string
facts, err := h.api.RecentActiveFactsByKind(ctx, string(store.KindSelf), habitFactWindow) facts, err := h.api.RecentActiveFactsByKind(ctx, string(store.KindSelf), habitFactWindow)
if err != nil { if err != nil {
log.Printf("voice: habits: recent facts: %v", err) log.Printf("voice: habits: recent facts: %v", err)
return "не получилось посмотреть записи.", true return phraser.Q(phraser.QueryFailNotes, nil), true
} }
obs := make([]memory.Observation, 0, len(facts)) obs := make([]memory.Observation, 0, len(facts))
for _, f := range facts { for _, f := range facts {
@@ -281,7 +281,7 @@ func (h *reactiveHandler) queryFeeds(ctx context.Context, t *queryTurn) (string,
// Claim the turn rather than fall through: "не читаю ленты" is true, and // Claim the turn rather than fall through: "не читаю ленты" is true, and
// letting general knowledge answer "что нового?" would be an invented // letting general knowledge answer "что нового?" would be an invented
// news bulletin. // news bulletin.
return "я пока не читаю ленты — они не настроены.", true return phraser.Q(phraser.QueryFeedsOff, nil), true
} }
// By source, not the last 200 notes of any kind: a busy day of voice notes // By source, not the last 200 notes of any kind: a busy day of voice notes
// used to push the newest headline out of the window, and she answered "в // used to push the newest headline out of the window, and she answered "в
@@ -289,7 +289,7 @@ func (h *reactiveHandler) queryFeeds(ctx context.Context, t *queryTurn) (string,
notes, err := h.api.RecentNotesFromSource(ctx, rss.SourcePrefix, feedNoteWindow) notes, err := h.api.RecentNotesFromSource(ctx, rss.SourcePrefix, feedNoteWindow)
if err != nil { if err != nil {
log.Printf("voice: feeds: recent notes: %v", err) log.Printf("voice: feeds: recent notes: %v", err)
return "не получилось посмотреть ленты.", true return phraser.Q(phraser.QueryFailFeeds, nil), true
} }
var picked []string var picked []string
for _, n := range notes { for _, n := range notes {
@@ -306,11 +306,11 @@ func (h *reactiveHandler) queryFeeds(ctx context.Context, t *queryTurn) (string,
} }
if len(picked) == 0 { if len(picked) == 0 {
if q.Category != "" { if q.Category != "" {
return "по этой теме в лентах пока ничего.", true return phraser.Q(phraser.QueryFeedsTopic, nil), true
} }
return "в лентах пока ничего нового.", true return phraser.Q(phraser.QueryFeedsEmpty, nil), true
} }
return "вот что нового: " + strings.Join(picked, "; "), true return phraser.Q(phraser.QueryFeedsNew, map[string]string{"items": strings.Join(picked, "; ")}), true
} }
// queryCalendar — "что у меня сегодня?", "планы на завтра?" // queryCalendar — "что у меня сегодня?", "планы на завтра?"
@@ -324,7 +324,7 @@ func (h *reactiveHandler) queryCalendar(ctx context.Context, t *queryTurn) (stri
events, err := h.api.CalendarEvents(ctx, date, date.Add(24*time.Hour)) events, err := h.api.CalendarEvents(ctx, date, date.Add(24*time.Hour))
if err != nil { if err != nil {
log.Printf("voice: calendar events: %v", err) log.Printf("voice: calendar events: %v", err)
return "не получилось проверить календарь.", true return phraser.Q(phraser.QueryFailCalendar, nil), true
} }
// Provenance travels with each event. A work meeting relayed off a phone // Provenance travels with each event. A work meeting relayed off a phone
// notification (source ambient:notif, #126) is stored below full confidence // notification (source ambient:notif, #126) is stored below full confidence
@@ -382,19 +382,24 @@ func (h *reactiveHandler) queryWeather(ctx context.Context, t *queryTurn) (strin
if loc == "" { if loc == "" {
// He named no city and voice.weather.default_location is unset. Saying // He named no city and voice.weather.default_location is unset. Saying
// so is the only honest answer; picking a city would be inventing one. // so is the only honest answer; picking a city would be inventing one.
return "не знаю, для какого города — задай voice.weather.default_location или назови город.", true return phraser.Q(phraser.QueryWeatherWhere, nil), true
} }
ctxWT, cancel := context.WithTimeout(ctx, 5*time.Second) ctxWT, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel() defer cancel()
w, err := h.weatherProvider.CurrentWeather(ctxWT, loc) w, err := h.weatherProvider.CurrentWeather(ctxWT, loc)
if errors.Is(err, weather.ErrNotConfigured) { if errors.Is(err, weather.ErrNotConfigured) {
return "погода не настроена.", true return phraser.Q(phraser.QueryWeatherOff, nil), true
} }
if err != nil { if err != nil {
log.Printf("voice: weather: %v", err) log.Printf("voice: weather: %v", err)
return "не получилось узнать погоду.", true return phraser.Q(phraser.QueryFailWeather, nil), true
} }
return fmt.Sprintf("в %s сейчас %.0f градусов, %s.", w.Location, w.Temperature, w.Condition), true return phraser.Q(phraser.QueryWeatherNow, map[string]string{
"location": w.Location,
"temp": fmt.Sprintf("%.0f", w.Temperature),
"word": phraser.Degrees(w.Temperature),
"condition": w.Condition,
}), true
} }
// queryEmbed isn't an answer source — it's the shared cost the two recall // queryEmbed isn't an answer source — it's the shared cost the two recall
@@ -404,7 +409,7 @@ func (h *reactiveHandler) queryEmbed(ctx context.Context, t *queryTurn) (string,
vec, err := router.EmbedQuery(ctx, h.embedder, t.dec.Utterance) vec, err := router.EmbedQuery(ctx, h.embedder, t.dec.Utterance)
if err != nil { if err != nil {
log.Printf("voice: embed query: %v", err) log.Printf("voice: embed query: %v", err)
return "не получилось найти ответ.", true return phraser.Q(phraser.QueryFailAnswer, nil), true
} }
t.vec = vec t.vec = vec
return "", false return "", false
@@ -445,7 +450,13 @@ func (h *reactiveHandler) queryMemory(ctx context.Context, t *queryTurn) (string
// A note is phrased in Maven's voice; a fact is read back as it was // A note is phrased in Maven's voice; a fact is read back as it was
// stored. // stored.
if hit.Meta["type"] == "note" { if hit.Meta["type"] == "note" {
if reply, perr := h.phraser.PhraseQuery(ctx, t.dec.Utterance, []string{text}); perr == nil && reply != "" { reply, perr := h.phraser.PhraseQuery(ctx, t.dec.Utterance, []string{text})
switch {
case perr != nil:
// Reading the note back verbatim beats the phraser's own fallback,
// which only wraps the same text in "вот что я нашла:".
log.Printf("voice: recall phrase: %v", perr)
case reply != "":
return reply, true return reply, true
} }
} }
@@ -467,7 +478,7 @@ func (h *reactiveHandler) queryNotes(ctx context.Context, t *queryTurn) (string,
notes, err := h.api.QueryNotes(ctx, t.vec, 5) notes, err := h.api.QueryNotes(ctx, t.vec, 5)
if err != nil { if err != nil {
log.Printf("voice: query notes: %v", err) log.Printf("voice: query notes: %v", err)
return "не получилось найти ответ.", true return phraser.Q(phraser.QueryFailAnswer, nil), true
} }
t.notes = notes t.notes = notes
noteScores := make([]float64, len(notes)) noteScores := make([]float64, len(notes))
@@ -492,7 +503,7 @@ func (h *reactiveHandler) queryNotes(ctx context.Context, t *queryTurn) (string,
log.Printf("voice: phrase query: %v", err) log.Printf("voice: phrase query: %v", err)
} }
if reply == "" { if reply == "" {
reply = "вот что я нашла: " + texts[0] reply = phraser.Q(phraser.QueryFound, map[string]string{"text": texts[0]})
} }
return reply, true return reply, true
} }
@@ -526,13 +537,13 @@ func (h *reactiveHandler) queryWeb(ctx context.Context, t *queryTurn) (string, b
page, err := h.crawler.Page(ctxFetch, link) page, err := h.crawler.Page(ctxFetch, link)
if err != nil { if err != nil {
if errors.Is(err, crawl.ErrRobots) { if errors.Is(err, crawl.ErrRobots) {
return "эта страница закрыта для чтения — robots.txt не разрешает.", true return phraser.Q(phraser.QueryPageBlocked, nil), true
} }
log.Printf("voice: web: %v", err) log.Printf("voice: web: %v", err)
return "не получилось прочитать страницу.", true return phraser.Q(phraser.QueryFailPage, nil), true
} }
if page.Text == "" { if page.Text == "" {
return "страница открылась, но читать там нечего.", true return phraser.Q(phraser.QueryPageEmpty, nil), true
} }
// The page is handed to the phraser the same way a note is: as context for // The page is handed to the phraser the same way a note is: as context for
// the question he actually asked. She answers the question, she does not // the question he actually asked. She answers the question, she does not
@@ -542,7 +553,7 @@ func (h *reactiveHandler) queryWeb(ctx context.Context, t *queryTurn) (string, b
if reply == "" { if reply == "" {
// No phraser (or it failed): read back the top of the page rather than // No phraser (or it failed): read back the top of the page rather than
// pretend the fetch did not happen. // pretend the fetch did not happen.
return "вот что на странице: " + crawl.TrimRunes(page.Text, 300), true return phraser.Q(phraser.QueryPageText, map[string]string{"text": crawl.TrimRunes(page.Text, 300)}), true
} }
return reply, true return reply, true
} }
@@ -608,7 +619,7 @@ func (h *reactiveHandler) querySearch(ctx context.Context, t *queryTurn) (string
if reply == "" { if reply == "" {
// No phraser, or it failed. Read back the best evidence rather than // No phraser, or it failed. Read back the best evidence rather than
// pretend the search did not happen. // pretend the search did not happen.
return "вот что я нашла: " + crawl.TrimRunes(resp.Snippets()[0], 300), true return phraser.Q(phraser.QueryFound, map[string]string{"text": crawl.TrimRunes(resp.Snippets()[0], 300)}), true
} }
return reply, true return reply, true
} }
@@ -689,7 +700,7 @@ func (h *reactiveHandler) queryKiwix(ctx context.Context, t *queryTurn) (string,
if reply == "" { if reply == "" {
// No phraser, or it failed. Read back the best hit rather than pretend // No phraser, or it failed. Read back the best hit rather than pretend
// the search did not happen. // the search did not happen.
return "вот что я нашла: " + crawl.TrimRunes(top.Title+" — "+page.Text, 300), true return phraser.Q(phraser.QueryFound, map[string]string{"text": crawl.TrimRunes(top.Title+" — "+page.Text, 300)}), true
} }
return reply, true return reply, true
} }
@@ -719,7 +730,7 @@ func (h *reactiveHandler) queryPersonal(ctx context.Context, t *queryTurn) (stri
return "", false return "", false
} }
log.Printf("voice: %q is about him and his own data did not answer it; not asking the world", t.dec.Utterance) log.Printf("voice: %q is about him and his own data did not answer it; not asking the world", t.dec.Utterance)
return "не знаю — не нашла у тебя такой записи.", true return phraser.Q(phraser.QueryPersonalNone, nil), true
} }
// personalMarkers — first-person POSSESSION, not first person generally. // personalMarkers — first-person POSSESSION, not first person generally.
@@ -787,15 +798,15 @@ func (h *reactiveHandler) queryGeneral(ctx context.Context, t *queryTurn) (strin
if h.phraser == nil { if h.phraser == nil {
// No model of any size. That is not the workstation being asleep, so it // No model of any size. That is not the workstation being asleep, so it
// is not that gap: it is simply not knowing. // is not that gap: it is simply not knowing.
return "не знаю.", true return phraser.Q(phraser.QueryUnknown, nil), true
} }
reply, err := h.phraseWorld(ctx, t.dec.Utterance, nil) reply, err := h.phraseWorld(ctx, t.dec.Utterance, nil)
if errors.Is(err, phraser.ErrNoWorldModel) { if errors.Is(err, phraser.ErrNoWorldModel) {
log.Printf("voice: %q needs the world model and it is not available", t.dec.Utterance) log.Printf("voice: %q needs the world model and it is not available", t.dec.Utterance)
return worldGap, true return worldGap(), true
} }
if err != nil || reply == "" { if err != nil || reply == "" {
return "не знаю.", true return phraser.Q(phraser.QueryUnknown, nil), true
} }
return reply, true return reply, true
} }
+3 -2
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"log" "log"
"github.com/kami/maven/internal/phraser"
"github.com/kami/maven/internal/router" "github.com/kami/maven/internal/router"
) )
@@ -21,13 +22,13 @@ func (h *reactiveHandler) actionReminder(ctx context.Context, dec router.Decisio
} }
} }
if !dec.Slots.HasTime { if !dec.Slots.HasTime {
return "не получилось разобрать время напоминания." return phraser.Ack(phraser.FailReminderTime, nil)
} }
} }
payload := `{"text":` + jsonString(dec.Utterance) + `}` payload := `{"text":` + jsonString(dec.Utterance) + `}`
if _, err := h.api.CreateReminder(ctx, dec.Slots.Time, payload, ""); err != nil { if _, err := h.api.CreateReminder(ctx, dec.Slots.Time, payload, ""); err != nil {
log.Printf("voice: create reminder: %v", err) log.Printf("voice: create reminder: %v", err)
return "не получилось поставить напоминание." return phraser.Ack(phraser.FailReminder, nil)
} }
return "" return ""
} }
+5 -4
View File
@@ -5,6 +5,7 @@ import (
"log" "log"
"github.com/kami/maven/internal/ipc" "github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/phraser"
"github.com/kami/maven/internal/router" "github.com/kami/maven/internal/router"
"github.com/kami/maven/internal/store" "github.com/kami/maven/internal/store"
"github.com/kami/maven/internal/tasks" "github.com/kami/maven/internal/tasks"
@@ -40,18 +41,18 @@ func (h *reactiveHandler) captureTaskFromNote(ctx context.Context, dec router.De
}) })
if err != nil { if err != nil {
log.Printf("voice: capture task: %v", err) log.Printf("voice: capture task: %v", err)
return "не получилось записать задачу.", true return phraser.Ack(phraser.FailTask, nil), true
} }
if resp.Promoted { if resp.Promoted {
// It was a candidate Maven derived from something she read, and he has // It was a candidate Maven derived from something she read, and he has
// now said it himself. Saying "уже в списке" here would be answering a // now said it himself. Saying "уже в списке" here would be answering a
// confirmation with a shrug. // confirmation with a shrug.
return "поняла, беру в работу: " + cap.Text, true return phraser.Ack(phraser.AckTaskUrgent, map[string]string{"text": cap.Text}), true
} }
if !resp.Created { if !resp.Created {
return "это уже в списке.", true return phraser.Ack(phraser.AckTaskDuplicate, nil), true
} }
return "записала: " + cap.Text, true return phraser.Ack(phraser.AckTask, map[string]string{"text": cap.Text}), true
} }
// queryTasks — "какие у меня задачи?", "что мне нужно сделать?". // queryTasks — "какие у меня задачи?", "что мне нужно сделать?".
+2 -1
View File
@@ -8,6 +8,7 @@ import (
"time" "time"
"github.com/kami/maven/internal/ipc" "github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/phraser"
"github.com/kami/maven/internal/router" "github.com/kami/maven/internal/router"
) )
@@ -108,7 +109,7 @@ func TestCaptureTaskFromNoteReportsStoreFailure(t *testing.T) {
if !ok { if !ok {
t.Fatal("a failed capture still claims the turn — the note path must not double-write") t.Fatal("a failed capture still claims the turn — the note path must not double-write")
} }
if !strings.Contains(reply, "не получилось") { if !phraser.IsAck(phraser.FailTask, nil, reply) {
t.Errorf("reply = %q, want an honest failure", reply) t.Errorf("reply = %q, want an honest failure", reply)
} }
} }
+1 -1
View File
@@ -156,7 +156,7 @@ func TestQueryWebRefusesNonHTML(t *testing.T) {
if !ok { if !ok {
t.Fatal("the web source did not claim a question with a URL") t.Fatal("the web source did not claim a question with a URL")
} }
if !strings.Contains(reply, "не получилось") { if !phraser.IsQ(phraser.QueryFailPage, nil, reply) {
t.Errorf("reply = %q, want the read-failed answer", reply) t.Errorf("reply = %q, want the read-failed answer", reply)
} }
} }
+3 -2
View File
@@ -10,6 +10,7 @@ import (
"github.com/kami/maven/internal/calendar" "github.com/kami/maven/internal/calendar"
"github.com/kami/maven/internal/ipc" "github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/phraser"
"github.com/kami/maven/internal/router" "github.com/kami/maven/internal/router"
"github.com/kami/maven/internal/store" "github.com/kami/maven/internal/store"
) )
@@ -147,8 +148,8 @@ func TestQueryDayPlanCoreFailure(t *testing.T) {
if !ok { if !ok {
t.Fatal("a failed plan read must still answer, not fall through to RAG") t.Fatal("a failed plan read must still answer, not fall through to RAG")
} }
if reply != "не получилось собрать план." { if !phraser.IsQ(phraser.QueryFailPlan, nil, reply) {
t.Errorf("reply = %q", reply) t.Errorf("reply = %q, want the honest failure", reply)
} }
} }
+61 -26
View File
@@ -9,10 +9,24 @@ import (
"time" "time"
hexisclient "github.com/kami/hexis/pkg/client" hexisclient "github.com/kami/hexis/pkg/client"
"github.com/kami/maven/internal/phraser"
"github.com/kami/maven/internal/router" "github.com/kami/maven/internal/router"
"github.com/kami/maven/internal/store" "github.com/kami/maven/internal/store"
) )
// The three services, spelled the way she says them out loud. A service that is
// down or refusing has to be named: they degrade independently, so "не
// отвечает" on its own tells him nothing he can act on, and each call site
// already knows which one it was talking to — it records the same name in the
// trace (Vikunja #521).
const (
serviceNexus = "Nexus"
serviceHexis = "Hexis"
)
// serviceVars — the one-key map the eco_down and eco_denied lines take.
func serviceVars(name string) map[string]string { return map[string]string{"name": name} }
// praxisCapability is one arm of the Praxis act dispatch. This is an interface // praxisCapability is one arm of the Praxis act dispatch. This is an interface
// rather than a map[string]func because each arm carries its own state: the // rather than a map[string]func because each arm carries its own state: the
// verb aliases it answers to, the trace name it records, and its own reply // verb aliases it answers to, the trace name it records, and its own reply
@@ -144,10 +158,10 @@ func (listAttentionCapability) handle(ctx context.Context, h *reactiveHandler, p
log.Printf("ecosystem: praxis attention: %v", err) log.Printf("ecosystem: praxis attention: %v", err)
h.recordEcosystemTrace(ctx, "praxis", "list_attention", traceStatusForError(err), h.recordEcosystemTrace(ctx, "praxis", "list_attention", traceStatusForError(err),
started, traceErrorFields(err)) started, traceErrorFields(err))
return "не могу сейчас узнать, что требует внимания." return phraser.A(phraser.AttentionFail, nil)
} }
if len(items) == 0 { if len(items) == 0 {
return "ничего не требует внимания." return phraser.A(phraser.AttentionNone, nil)
} }
h.recordPraxisTrace(ctx, "list_attention", started, map[string]any{"count": len(items)}) h.recordPraxisTrace(ctx, "list_attention", started, map[string]any{"count": len(items)})
var parts []string var parts []string
@@ -157,6 +171,11 @@ func (listAttentionCapability) handle(ctx context.Context, h *reactiveHandler, p
importance, _ := item["importance"].(float64) importance, _ := item["importance"].(float64)
rule, _ := item["rule"].(string) rule, _ := item["rule"].(string)
s := title s := title
if s == "" {
// An item Praxis returned without a title is not an item she can
// read out. Counting it would put an empty slot in the list.
continue
}
if importance > 0 { if importance > 0 {
s += fmt.Sprintf(" (важность %d", int(importance)) s += fmt.Sprintf(" (важность %d", int(importance))
if rule != "" { if rule != "" {
@@ -175,7 +194,13 @@ func (listAttentionCapability) handle(ctx context.Context, h *reactiveHandler, p
} }
} }
} }
return "требует внимания: " + strings.Join(parts, "; ") if len(parts) == 0 {
// Praxis returned items and not one of them could be said. "ничего не
// требует внимания" is the honest answer; the list line would render as
// its own label and a colon (Vikunja #521).
return phraser.A(phraser.AttentionNone, nil)
}
return phraser.A(phraser.AttentionList, map[string]string{"items": strings.Join(parts, "; ")})
} }
// listChangesCapability reads the recent-changes feed. // listChangesCapability reads the recent-changes feed.
@@ -192,19 +217,29 @@ func (listChangesCapability) handle(ctx context.Context, h *reactiveHandler, px
log.Printf("ecosystem: praxis changes: %v", err) log.Printf("ecosystem: praxis changes: %v", err)
h.recordEcosystemTrace(ctx, "praxis", "list_changes", traceStatusForError(err), h.recordEcosystemTrace(ctx, "praxis", "list_changes", traceStatusForError(err),
started, traceErrorFields(err)) started, traceErrorFields(err))
return "не могу сейчас узнать об изменениях." return phraser.A(phraser.ChangesFail, nil)
} }
if len(changes) == 0 { if len(changes) == 0 {
return "нет изменений." return phraser.A(phraser.ChangesNone, nil)
} }
h.recordPraxisTrace(ctx, "list_changes", started, map[string]any{"count": len(changes)}) h.recordPraxisTrace(ctx, "list_changes", started, map[string]any{"count": len(changes)})
var parts []string var parts []string
for _, c := range changes { for _, c := range changes {
title, _ := c["title"].(string) title, _ := c["title"].(string)
if title == "" {
continue
}
typ, _ := c["change_type"].(string) typ, _ := c["change_type"].(string)
if typ == "" {
parts = append(parts, title)
continue
}
parts = append(parts, fmt.Sprintf("%s (%s)", title, typ)) parts = append(parts, fmt.Sprintf("%s (%s)", title, typ))
} }
return "изменения: " + strings.Join(parts, "; ") if len(parts) == 0 {
return phraser.A(phraser.ChangesNone, nil)
}
return phraser.A(phraser.ChangesList, map[string]string{"items": strings.Join(parts, "; ")})
} }
// entityAttentionCapability answers "what's going on with X" by resolving X to // entityAttentionCapability answers "what's going on with X" by resolving X to
@@ -230,12 +265,12 @@ func (entityAttentionCapability) handle(ctx context.Context, h *reactiveHandler,
subject = dec.Slots.Text subject = dec.Slots.Text
} }
if subject == "" { if subject == "" {
return "про что именно спросить?" return phraser.A(phraser.EcoAboutWhat, nil)
} }
if h.ecosystem == nil || h.ecosystem.nexus == nil { if h.ecosystem == nil || h.ecosystem.nexus == nil {
// Without Nexus there is no canonical ref to scope by. Say so rather // Without Nexus there is no canonical ref to scope by. Say so rather
// than quietly answering about something else. // than quietly answering about something else.
return "не могу связать это с сущностью — Nexus не настроен." return phraser.A(phraser.EcoNoNexus, nil)
} }
started := h.now() started := h.now()
@@ -248,15 +283,15 @@ func (entityAttentionCapability) handle(ctx context.Context, h *reactiveHandler,
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceStatusForError(err), started, h.recordEcosystemTrace(ctx, "nexus", "resolve", traceStatusForError(err), started,
mergeFields(traceErrorFields(err), map[string]any{"subject": redactSubject(subject)})) mergeFields(traceErrorFields(err), map[string]any{"subject": redactSubject(subject)}))
if unauthorizedEcosystemError(err) { if unauthorizedEcosystemError(err) {
return "экосистема отклоняет доступ, проверь токен." return phraser.A(phraser.EcoDenied, serviceVars(serviceNexus))
} }
return "экосистема недоступна, попробуй ещё раз." return phraser.A(phraser.EcoDown, serviceVars(serviceNexus))
} }
if len(ambiguous) > 0 { if len(ambiguous) > 0 {
return "уточни, что именно: " + strings.Join(ambiguous, ", ") + "?" return phraser.A(phraser.EcoAmbiguous, map[string]string{"items": strings.Join(ambiguous, ", ")})
} }
if entityID == "" { if entityID == "" {
return "не знаю такой сущности." return phraser.A(phraser.EcoUnknownEntity, nil)
} }
if displayName == "" { if displayName == "" {
displayName = subject displayName = subject
@@ -268,7 +303,7 @@ func (entityAttentionCapability) handle(ctx context.Context, h *reactiveHandler,
log.Printf("ecosystem: praxis attention for %s: %v", entityID, err) log.Printf("ecosystem: praxis attention for %s: %v", entityID, err)
h.recordEcosystemTrace(ctx, "praxis", "entity_attention", traceStatusForError(err), h.recordEcosystemTrace(ctx, "praxis", "entity_attention", traceStatusForError(err),
queried, mergeFields(traceErrorFields(err), map[string]any{"entity_id": entityID})) queried, mergeFields(traceErrorFields(err), map[string]any{"entity_id": entityID}))
return "не могу сейчас узнать, что требует внимания по «" + displayName + "»." return phraser.A(phraser.AttentionFailEntity, map[string]string{"name": displayName})
} }
items, scoped := scopedToEntity(items, entityID) items, scoped := scopedToEntity(items, entityID)
if !scoped { if !scoped {
@@ -279,7 +314,7 @@ func (entityAttentionCapability) handle(ctx context.Context, h *reactiveHandler,
log.Printf("ecosystem: praxis returned unscoped items for %s, refusing to answer", entityID) log.Printf("ecosystem: praxis returned unscoped items for %s, refusing to answer", entityID)
h.recordEcosystemTrace(ctx, "praxis", "entity_attention", traceFailed, queried, h.recordEcosystemTrace(ctx, "praxis", "entity_attention", traceFailed, queried,
map[string]any{"entity_id": entityID, "class": "unscoped_response"}) map[string]any{"entity_id": entityID, "class": "unscoped_response"})
return "не могу сейчас узнать, что требует внимания по «" + displayName + "»." return phraser.A(phraser.AttentionFailEntity, map[string]string{"name": displayName})
} }
h.recordPraxisTrace(ctx, "entity_attention", queried, map[string]any{ h.recordPraxisTrace(ctx, "entity_attention", queried, map[string]any{
"entity_id": entityID, "count": len(items), "entity_id": entityID, "count": len(items),
@@ -303,9 +338,9 @@ func (entityAttentionCapability) handle(ctx context.Context, h *reactiveHandler,
parts = append(parts, known) parts = append(parts, known)
} }
if len(parts) == 0 { if len(parts) == 0 {
return "по «" + displayName + "» ничего нет." return phraser.A(phraser.AttentionNoneEntity, map[string]string{"name": displayName})
} }
return "по «" + displayName + "»: " + strings.Join(parts, "; ") return phraser.A(phraser.AttentionListEntity, map[string]string{"name": displayName, "items": strings.Join(parts, "; ")})
} }
// scopedToEntity drops items that carry an entity_id other than the one asked // scopedToEntity drops items that carry an entity_id other than the one asked
@@ -370,7 +405,7 @@ func (h *reactiveHandler) localFactsForEntity(ctx context.Context, entityID stri
if len(parts) == 0 { if len(parts) == 0 {
return "" return ""
} }
out := "я помню: " + strings.Join(parts, ", ") out := phraser.A(phraser.EcoRecall, map[string]string{"items": strings.Join(parts, ", ")})
if more { if more {
out += ", и это не всё" out += ", и это не всё"
} }
@@ -520,18 +555,18 @@ func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decisio
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceStatusForError(err), started, h.recordEcosystemTrace(ctx, "nexus", "resolve", traceStatusForError(err), started,
mergeFields(traceErrorFields(err), map[string]any{"subject": redactSubject(dec.Slots.Text)})) mergeFields(traceErrorFields(err), map[string]any{"subject": redactSubject(dec.Slots.Text)}))
if unauthorizedEcosystemError(err) { if unauthorizedEcosystemError(err) {
return "экосистема отклоняет доступ, проверь токен." return phraser.A(phraser.EcoDenied, serviceVars(serviceNexus))
} }
// A genuine Nexus dependency failure, not "no such entity" — stop here // A genuine Nexus dependency failure, not "no such entity" — stop here
// and report degradation rather than silently falling through to the // and report degradation rather than silently falling through to the
// local command executor (ECOSYSTEM-SPEC.md: services degrade // local command executor (ECOSYSTEM-SPEC.md: services degrade
// independently, never a silent all-clear). // independently, never a silent all-clear).
return "экосистема недоступна, попробуй ещё раз." return phraser.A(phraser.EcoDown, serviceVars(serviceNexus))
} }
if len(ambiguous) > 0 { if len(ambiguous) > 0 {
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceAmbig, started, h.recordEcosystemTrace(ctx, "nexus", "resolve", traceAmbig, started,
map[string]any{"candidates": len(ambiguous)}) map[string]any{"candidates": len(ambiguous)})
return "уточни, что именно: " + strings.Join(ambiguous, ", ") + "?" return phraser.A(phraser.EcoAmbiguous, map[string]string{"items": strings.Join(ambiguous, ", ")})
} }
if entityID == "" { if entityID == "" {
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceNotFound, started, h.recordEcosystemTrace(ctx, "nexus", "resolve", traceNotFound, started,
@@ -550,9 +585,9 @@ func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decisio
h.recordEcosystemTrace(ctx, "hexis", "capabilities", traceStatusForError(err), discovered, h.recordEcosystemTrace(ctx, "hexis", "capabilities", traceStatusForError(err), discovered,
mergeFields(traceErrorFields(err), map[string]any{"entity_id": entityID})) mergeFields(traceErrorFields(err), map[string]any{"entity_id": entityID}))
if unauthorizedEcosystemError(err) { if unauthorizedEcosystemError(err) {
return "экосистема отклоняет доступ, проверь токен." return phraser.A(phraser.EcoDenied, serviceVars(serviceHexis))
} }
return "экосистема недоступна, попробуй ещё раз." return phraser.A(phraser.EcoDown, serviceVars(serviceHexis))
} }
h.recordEcosystemTrace(ctx, "hexis", "capabilities", traceOK, discovered, h.recordEcosystemTrace(ctx, "hexis", "capabilities", traceOK, discovered,
map[string]any{"entity_id": entityID, "count": len(caps)}) map[string]any{"entity_id": entityID, "count": len(caps)})
@@ -584,7 +619,7 @@ func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decisio
for _, m := range matches { for _, m := range matches {
names = append(names, m.Name) names = append(names, m.Name)
} }
return "какую команду для " + displayName + ": " + strings.Join(names, ", ") + "?" return phraser.A(phraser.ActWhich, map[string]string{"name": displayName, "items": strings.Join(names, ", ")})
} }
matched := matches[0] matched := matches[0]
@@ -602,7 +637,7 @@ func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decisio
h.mu.Unlock() h.mu.Unlock()
h.recordEcosystemTrace(ctx, "hexis", "confirmation", tracePending, started, h.recordEcosystemTrace(ctx, "hexis", "confirmation", tracePending, started,
map[string]any{"entity_id": entityID, "capability": matched.Name}) map[string]any{"entity_id": entityID, "capability": matched.Name})
return "выполнить «" + matched.Name + "» для " + displayName + "? скажи «да» или «нет»." return phraser.A(phraser.ActConfirmEntity, map[string]string{"name": matched.Name, "name_entity": displayName})
} }
return h.execHexis(ctx, matched.ID, matched.Name, entityID, displayName) return h.execHexis(ctx, matched.ID, matched.Name, entityID, displayName)
@@ -622,7 +657,7 @@ func (h *reactiveHandler) execHexis(ctx context.Context, capID, capName, entityI
mergeFields(traceErrorFields(err), map[string]any{ mergeFields(traceErrorFields(err), map[string]any{
"entity_id": entityID, "capability": capName, "causation_id": causationID, "entity_id": entityID, "capability": capName, "causation_id": causationID,
})) }))
return "не получилось выполнить команду для " + displayName + "." return phraser.A(phraser.ActFailEntity, map[string]string{"name": displayName})
} }
// One record per hop: the second write this used to make said the same // One record per hop: the second write this used to make said the same
// thing under a different key, in a different shape. // thing under a different key, in a different shape.
@@ -630,5 +665,5 @@ func (h *reactiveHandler) execHexis(ctx context.Context, capID, capName, entityI
"entity_id": entityID, "entity_name": displayName, "entity_id": entityID, "entity_name": displayName,
"capability": capName, "causation_id": causationID, "capability": capName, "causation_id": causationID,
}) })
return "команда выполнена для " + displayName + "." return phraser.A(phraser.ActDoneEntity, map[string]string{"name": displayName})
} }
+10 -10
View File
@@ -96,7 +96,7 @@ func TestEcosystem_OutagesLeaveNoSharedFailureState(t *testing.T) {
// A Nexus outage during a Hexis act writes a failure trace, and a shared // A Nexus outage during a Hexis act writes a failure trace, and a shared
// store is the one thing the Praxis path could inherit it through. // store is the one thing the Praxis path could inherit it through.
nexus.SetFault(503) nexus.SetFault(503)
if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); strings.Contains(reply, "выполнена") { if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); actRan(reply) {
t.Fatalf("nexus outage must not report success, got %q", reply) t.Fatalf("nexus outage must not report success, got %q", reply)
} }
if len(tracesFor(t, h, "nexus", "resolve")) == 0 { if len(tracesFor(t, h, "nexus", "resolve")) == 0 {
@@ -117,7 +117,7 @@ func TestEcosystem_OutagesLeaveNoSharedFailureState(t *testing.T) {
if reply := h.handlePraxisAct(ctx, praxisActDec("list_attention")); strings.Contains(reply, "disk") { if reply := h.handlePraxisAct(ctx, praxisActDec("list_attention")); strings.Contains(reply, "disk") {
t.Fatalf("praxis outage must not serve content, got %q", reply) t.Fatalf("praxis outage must not serve content, got %q", reply)
} }
if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !strings.Contains(reply, "выполнена") { if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !actRan(reply) {
t.Fatalf("a praxis outage must not block the hexis path, got %q", reply) t.Fatalf("a praxis outage must not block the hexis path, got %q", reply)
} }
} }
@@ -154,7 +154,7 @@ func TestEcosystem_ResolvedWithoutEntityFailsClosed(t *testing.T) {
if reply == "" { if reply == "" {
t.Fatal("a resolve with no entity must degrade, not fall through to local execution") t.Fatal("a resolve with no entity must degrade, not fall through to local execution")
} }
if strings.Contains(reply, "выполнена") { if actRan(reply) {
t.Fatalf("a resolve with no entity must not report success, got %q", reply) t.Fatalf("a resolve with no entity must not report success, got %q", reply)
} }
if hexis.Count("", "/api/v1") != 0 { if hexis.Count("", "/api/v1") != 0 {
@@ -212,7 +212,7 @@ func TestEcosystem_MalformedNexusResponseFailsClosed(t *testing.T) {
nexus.SetBody(`{"status":"resolved","entity":`) nexus.SetBody(`{"status":"resolved","entity":`)
reply := h.handleHexisAct(ctx, actDec("muzick indexer")) reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
if reply == "" || strings.Contains(reply, "выполнена") { if reply == "" || actRan(reply) {
t.Fatalf("malformed nexus body must degrade, got %q", reply) t.Fatalf("malformed nexus body must degrade, got %q", reply)
} }
if hexis.Count("", "/api/v1") != 0 { if hexis.Count("", "/api/v1") != 0 {
@@ -232,7 +232,7 @@ func TestEcosystem_UnknownContractFieldsTolerated(t *testing.T) {
nexus := newFakeNexus(t, body) nexus := newFakeNexus(t, body)
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded")) hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
h := ecoHandler(t, nexus, nil, hexis) h := ecoHandler(t, nexus, nil, hexis)
if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !strings.Contains(reply, "выполнена") { if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !actRan(reply) {
t.Fatalf("%s contract shape must still resolve and execute, got %q", name, reply) t.Fatalf("%s contract shape must still resolve and execute, got %q", name, reply)
} }
}) })
@@ -250,7 +250,7 @@ func TestEcosystem_CancelledContextDegrades(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond)
defer cancel() defer cancel()
reply := h.handleHexisAct(ctx, actDec("muzick indexer")) reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
if reply == "" || strings.Contains(reply, "выполнена") { if reply == "" || actRan(reply) {
t.Fatalf("cancelled resolve must degrade, got %q", reply) t.Fatalf("cancelled resolve must degrade, got %q", reply)
} }
if hexis.Count("", "/api/v1") != 0 { if hexis.Count("", "/api/v1") != 0 {
@@ -268,7 +268,7 @@ func TestEcosystem_ExecutionFailureIsNotSuccess(t *testing.T) {
h := ecoHandler(t, nexus, nil, hexis) h := ecoHandler(t, nexus, nil, hexis)
reply := h.handleHexisAct(ctx, actDec("muzick indexer")) reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
if strings.Contains(reply, "выполнена") { if actRan(reply) {
t.Fatalf("failed execution must not read as success, got %q", reply) t.Fatalf("failed execution must not read as success, got %q", reply)
} }
if reply == "" { if reply == "" {
@@ -291,7 +291,7 @@ func TestEcosystem_SuccessfulActionWritesATrace(t *testing.T) {
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded")) hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
h := ecoHandler(t, nexus, nil, hexis) h := ecoHandler(t, nexus, nil, hexis)
if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !strings.Contains(reply, "выполнена") { if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !actRan(reply) {
t.Fatalf("setup: expected success, got %q", reply) t.Fatalf("setup: expected success, got %q", reply)
} }
exec := tracesFor(t, h, "hexis", "execute") exec := tracesFor(t, h, "hexis", "execute")
@@ -313,7 +313,7 @@ func TestEcosystem_TracesStayOutOfFacts(t *testing.T) {
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded")) hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
h := ecoHandler(t, nexus, nil, hexis) h := ecoHandler(t, nexus, nil, hexis)
if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !strings.Contains(reply, "выполнена") { if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !actRan(reply) {
t.Fatalf("setup: expected success, got %q", reply) t.Fatalf("setup: expected success, got %q", reply)
} }
if len(traces(t, h)) == 0 { if len(traces(t, h)) == 0 {
@@ -434,7 +434,7 @@ func TestEcosystem_TotalOutageSaysSoForEveryPath(t *testing.T) {
if reply == "" { if reply == "" {
t.Errorf("%s: total outage must not answer with silence", name) t.Errorf("%s: total outage must not answer with silence", name)
} }
if strings.Contains(reply, "выполнена") { if actRan(reply) {
t.Errorf("%s: total outage must not claim success: %q", name, reply) t.Errorf("%s: total outage must not claim success: %q", name, reply)
} }
} }
+2 -2
View File
@@ -105,13 +105,13 @@ func TestFakeNexus_FaultInjectionThenRecovery(t *testing.T) {
nexus.SetFault(503) nexus.SetFault(503)
reply := h.handleHexisAct(ctx, actDec("muzick indexer")) reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
if strings.Contains(reply, "выполнена") { if actRan(reply) {
t.Fatalf("nexus outage must not report success, got %q", reply) t.Fatalf("nexus outage must not report success, got %q", reply)
} }
nexus.SetFault(0) nexus.SetFault(0)
reply = h.handleHexisAct(ctx, actDec("muzick indexer")) reply = h.handleHexisAct(ctx, actDec("muzick indexer"))
if !strings.Contains(reply, "выполнена") { if !actRan(reply) {
t.Fatalf("expected success once nexus recovers, got %q", reply) t.Fatalf("expected success once nexus recovers, got %q", reply)
} }
} }
+16 -4
View File
@@ -11,6 +11,7 @@ import (
hexisclient "github.com/kami/hexis/pkg/client" hexisclient "github.com/kami/hexis/pkg/client"
"github.com/kami/maven/internal/ipc" "github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/phraser"
"github.com/kami/maven/internal/router" "github.com/kami/maven/internal/router"
) )
@@ -85,7 +86,7 @@ func TestHexisMutatingRequiresConfirm(t *testing.T) {
// The follow-up "да" turn executes exactly the parked capability. // The follow-up "да" turn executes exactly the parked capability.
confirmReply, handled := h.resolveConfirm(ctx, "да") confirmReply, handled := h.resolveConfirm(ctx, "да")
if !handled || !strings.Contains(confirmReply, "выполнена") { if !handled || !actRan(confirmReply) {
t.Fatalf("confirm should execute, got handled=%v reply=%q", handled, confirmReply) t.Fatalf("confirm should execute, got handled=%v reply=%q", handled, confirmReply)
} }
if !*executed { if !*executed {
@@ -125,7 +126,7 @@ func TestHexisReadOnlyExecutesImmediately(t *testing.T) {
if h.pendingHexis != nil { if h.pendingHexis != nil {
t.Fatal("read-only cap should not park a confirmation") t.Fatal("read-only cap should not park a confirmation")
} }
if !strings.Contains(reply, "выполнена") { if !actRan(reply) {
t.Fatalf("unexpected reply %q", reply) t.Fatalf("unexpected reply %q", reply)
} }
} }
@@ -186,7 +187,7 @@ func TestHexisNexusErrorFailsClosed(t *testing.T) {
if reply == "" { if reply == "" {
t.Fatal("nexus dependency failure must not fall through with an empty reply") t.Fatal("nexus dependency failure must not fall through with an empty reply")
} }
if strings.Contains(reply, "выполнена") { if actRan(reply) {
t.Fatalf("nexus dependency failure must not report success, got %q", reply) t.Fatalf("nexus dependency failure must not report success, got %q", reply)
} }
} }
@@ -219,7 +220,7 @@ func TestHexisUnavailableFailsClosed(t *testing.T) {
if reply == "" { if reply == "" {
t.Fatal("hexis dependency failure must not fall through with an empty reply") t.Fatal("hexis dependency failure must not fall through with an empty reply")
} }
if strings.Contains(reply, "выполнена") { if actRan(reply) {
t.Fatalf("hexis dependency failure must not report success, got %q", reply) t.Fatalf("hexis dependency failure must not report success, got %q", reply)
} }
} }
@@ -241,3 +242,14 @@ func TestHexisNotFoundStillFallsThrough(t *testing.T) {
t.Fatal("not_found resolution must never execute a hexis capability") t.Fatal("not_found resolution must never execute a hexis capability")
} }
} }
// actRan — the reply is the line she says when a capability ran against an
// entity. The tests used to look for the substring "выполнена", which was a
// literal out of the act file: the review reworded that line to "готово: {name}"
// and seventeen assertions went with it (Vikunja #521).
func actRan(reply string) bool {
return phraser.IsA(phraser.ActDoneEntity, map[string]string{"name": muzickIndexer}, reply)
}
// muzickIndexer — the display name every ecosystem fixture resolves to.
const muzickIndexer = "Muzick indexer"
+1 -1
View File
@@ -142,7 +142,7 @@ func TestEcosystemTrace_SuccessfulActionTracesEveryHop(t *testing.T) {
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded")) hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
h := ecoHandler(t, nexus, nil, hexis) h := ecoHandler(t, nexus, nil, hexis)
if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !strings.Contains(reply, "выполнена") { if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !actRan(reply) {
t.Fatalf("setup: expected success, got %q", reply) t.Fatalf("setup: expected success, got %q", reply)
} }
+9 -3
View File
@@ -79,7 +79,7 @@ func TestQueryFeedsByCategory(t *testing.T) {
t.Fatalf("reply = %q, want only the технологии item", reply) t.Fatalf("reply = %q, want only the технологии item", reply)
} }
reply, _ = askFeeds(t, h, "что нового по спорту?") reply, _ = askFeeds(t, h, "что нового по спорту?")
if !strings.Contains(reply, "ничего") { if !phraser.IsQ(phraser.QueryFeedsTopic, nil, reply) {
t.Fatalf("reply = %q, want an honest empty answer for an unread category", reply) t.Fatalf("reply = %q, want an honest empty answer for an unread category", reply)
} }
} }
@@ -87,16 +87,22 @@ func TestQueryFeedsByCategory(t *testing.T) {
// "не настроены" and "ничего нового" are different truths, and neither may be // "не настроены" and "ничего нового" are different truths, and neither may be
// answered by the model inventing a bulletin. // answered by the model inventing a bulletin.
func TestQueryFeedsOffAndEmptyDiffer(t *testing.T) { func TestQueryFeedsOffAndEmptyDiffer(t *testing.T) {
// Against the entries, not against a substring: both of these have several
// wordings, so "ничего нового" passed only on the turns the picker happened
// to choose the first one.
off := buildFeedHandler(t, false) off := buildFeedHandler(t, false)
reply, ok := askFeeds(t, off, "что нового в лентах?") reply, ok := askFeeds(t, off, "что нового в лентах?")
if !ok || !strings.Contains(reply, "не настроены") { if !ok || !phraser.IsQ(phraser.QueryFeedsOff, nil, reply) {
t.Fatalf("feeds off: reply = %q, ok = %v", reply, ok) t.Fatalf("feeds off: reply = %q, ok = %v", reply, ok)
} }
on := buildFeedHandler(t, true) on := buildFeedHandler(t, true)
reply, ok = askFeeds(t, on, "что нового в лентах?") reply, ok = askFeeds(t, on, "что нового в лентах?")
if !ok || !strings.Contains(reply, "ничего нового") { if !ok || !phraser.IsQ(phraser.QueryFeedsEmpty, nil, reply) {
t.Fatalf("feeds on but empty: reply = %q, ok = %v", reply, ok) t.Fatalf("feeds on but empty: reply = %q, ok = %v", reply, ok)
} }
if phraser.IsQ(phraser.QueryFeedsOff, nil, reply) {
t.Fatalf("an empty feed answered as an unconfigured one: %q", reply)
}
} }
func TestQueryFeedsPassesOnANonFeedQuestion(t *testing.T) { func TestQueryFeedsPassesOnANonFeedQuestion(t *testing.T) {
+5 -20
View File
@@ -11,6 +11,7 @@ import (
"github.com/kami/maven/internal/config" "github.com/kami/maven/internal/config"
"github.com/kami/maven/internal/ipc" "github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/netscan" "github.com/kami/maven/internal/netscan"
"github.com/kami/maven/internal/phraser"
) )
// scanBudget — the whole spoken scan, end to end. A voice turn that takes // scanBudget — the whole spoken scan, end to end. A voice turn that takes
@@ -106,7 +107,7 @@ func (w *netWiring) scanSummary(ctx context.Context) (string, bool) {
res, err := w.scan(ctx) res, err := w.scan(ctx)
if err != nil { if err != nil {
log.Printf("netscan: scan: %v", err) log.Printf("netscan: scan: %v", err)
return "не получилось просканировать сеть.", true return phraser.Q(phraser.QueryFailNetscan, nil), true
} }
// A truncated run is not a statement about the LAN. Saying "нашла 6 // A truncated run is not a statement about the LAN. Saying "нашла 6
// устройств" after stopping two thirds of the way through the range is a // устройств" after stopping two thirds of the way through the range is a
@@ -116,9 +117,9 @@ func (w *netWiring) scanSummary(ctx context.Context) (string, bool) {
tail = ", но успела посмотреть не всю сеть" tail = ", но успела посмотреть не всю сеть"
} }
if len(res.Hosts) == 0 { if len(res.Hosts) == 0 {
return "в сети никого не нашла" + tail + ".", true return phraser.Q(phraser.QueryNetEmpty, map[string]string{"tail": tail}), true
} }
out := fmt.Sprintf("нашла %d %s", len(res.Hosts), hostWord(len(res.Hosts))) out := fmt.Sprintf("нашла %d %s", len(res.Hosts), phraser.Devices(len(res.Hosts)))
if shape := scanShape(res.Hosts); shape != "" { if shape := scanShape(res.Hosts); shape != "" {
out += ", " + shape out += ", " + shape
} }
@@ -179,7 +180,7 @@ func (w *netWiring) writeScanRecord(ctx context.Context, res netscan.Result) {
if w.api == nil { if w.api == nil {
return return
} }
head := fmt.Sprintf("сканирование сети: %d %s", len(res.Hosts), hostWord(len(res.Hosts))) head := fmt.Sprintf("сканирование сети: %d %s", len(res.Hosts), phraser.Devices(len(res.Hosts)))
if res.Truncated { if res.Truncated {
head += " (не вся сеть)" head += " (не вся сеть)"
} }
@@ -210,22 +211,6 @@ func (w *netWiring) writeScanRecord(ctx context.Context, res netscan.Result) {
} }
} }
// hostWord — Russian counts inflect the noun: 1 устройство, 2-4 устройства,
// 5+ устройств, and the teens are all the last form.
func hostWord(n int) string {
if n%100 >= 11 && n%100 <= 14 {
return "устройств"
}
switch n % 10 {
case 1:
return "устройство"
case 2, 3, 4:
return "устройства"
default:
return "устройств"
}
}
// isNetworkQuery recognises a question about the LAN, narrowly. It needs a // isNetworkQuery recognises a question about the LAN, narrowly. It needs a
// network word AND an ask: "интернет не работает" is a complaint, not a request // network word AND an ask: "интернет не работает" is a complaint, not a request
// to scan, and a scan she runs unasked is exactly the noisy behaviour the // to scan, and a scan she runs unasked is exactly the noisy behaviour the
-12
View File
@@ -74,18 +74,6 @@ func TestScanSummaryOnAnEmptyRange(t *testing.T) {
} }
} }
func TestHostWordAgreesWithTheCount(t *testing.T) {
for n, want := range map[int]string{
1: "устройство", 2: "устройства", 4: "устройства", 5: "устройств",
11: "устройств", 12: "устройств", 21: "устройство", 22: "устройства",
25: "устройств", 111: "устройств", 101: "устройство", 0: "устройств",
} {
if got := hostWord(n); got != want {
t.Errorf("hostWord(%d) = %q, want %q", n, got, want)
}
}
}
func TestIsNetworkQuery(t *testing.T) { func TestIsNetworkQuery(t *testing.T) {
yes := []string{ yes := []string{
"какие устройства в сети?", "какие устройства в сети?",
+3 -3
View File
@@ -121,8 +121,8 @@ func TestQueryRecallNoteCanWin(t *testing.T) {
{text: "выучил пару аккордов", score: 0.50, kind: "note"}, {text: "выучил пару аккордов", score: 0.50, kind: "note"},
}) })
reply := askQuery(t, h, q) reply := askQuery(t, h, q)
if want := "вот что я нашла: молоко стоит в холодильнике"; reply != want { if !phraser.IsSourcesFallback(reply, "молоко стоит в холодильнике") {
t.Errorf("reply %q, want %q", reply, want) t.Errorf("reply %q, want the note read back", reply)
} }
// One text, the winning memory's — the answer came from the memory // One text, the winning memory's — the answer came from the memory
// pass, not from handing the phraser every note in the table. // pass, not from handing the phraser every note in the table.
@@ -151,7 +151,7 @@ func TestQueryRecallNoteCanWin(t *testing.T) {
{text: "молоко стоит в холодильнике", score: 0.860, kind: "note"}, {text: "молоко стоит в холодильнике", score: 0.860, kind: "note"},
{text: "молоко закончилось", score: 0.858, kind: "note"}, {text: "молоко закончилось", score: 0.858, kind: "note"},
}) })
if reply := askQuery(t, h, q); reply != "не знаю." { if reply := askQuery(t, h, q); !phraser.IsUnknownFallback(reply) {
t.Errorf("reply %q, want silence", reply) t.Errorf("reply %q, want silence", reply)
} }
}) })
+4 -3
View File
@@ -11,6 +11,7 @@ import (
"unicode" "unicode"
"github.com/kami/maven/internal/ipc" "github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/phraser"
) )
// resolveQuietToggle — pre-route keyword check. Returns (reply, true) when // resolveQuietToggle — pre-route keyword check. Returns (reply, true) when
@@ -32,10 +33,10 @@ func (h *reactiveHandler) resolveQuietToggle(ctx context.Context, text string, s
return "", false return "", false
} }
val := "false" val := "false"
reply := "тихий режим выключен." reply := phraser.Ack(phraser.AckQuietOff, nil)
if on { if on {
val = "true" val = "true"
reply = "тихий режим включён. буду реже напоминать." reply = phraser.Ack(phraser.AckQuietOn, nil)
} }
if _, err := h.api.WriteFact(ctx, ipc.WriteFactReq{ if _, err := h.api.WriteFact(ctx, ipc.WriteFactReq{
Ts: h.now(), Ts: h.now(),
@@ -46,7 +47,7 @@ func (h *reactiveHandler) resolveQuietToggle(ctx context.Context, text string, s
Confidence: 1.0, Confidence: 1.0,
}); err != nil { }); err != nil {
log.Printf("voice: write quiet_hours: %v", err) log.Printf("voice: write quiet_hours: %v", err)
return "не получилось переключить тихий режим.", true return phraser.Ack(phraser.FailQuiet, nil), true
} }
return reply, true return reply, true
} }
+13 -2
View File
@@ -5,6 +5,7 @@ import (
"testing" "testing"
"github.com/kami/maven/internal/llm" "github.com/kami/maven/internal/llm"
"github.com/kami/maven/internal/phraser"
"github.com/kami/maven/internal/router" "github.com/kami/maven/internal/router"
"github.com/kami/maven/internal/voice" "github.com/kami/maven/internal/voice"
) )
@@ -29,12 +30,12 @@ func TestLLMReplierPassesTheModelReplyThrough(t *testing.T) {
func TestLLMReplierFallsBackToStubOnError(t *testing.T) { func TestLLMReplierFallsBackToStubOnError(t *testing.T) {
r := newLLMReplier(stubCompleter{err: errReplierTest}, nil) r := newLLMReplier(stubCompleter{err: errReplierTest}, nil)
assertStub(t, r, router.Decision{Intent: router.IntentNote}, "llm error") assertAck(t, r, router.Decision{Intent: router.IntentNote}, phraser.AckNote, "llm error")
} }
func TestLLMReplierFallsBackToStubOnEmpty(t *testing.T) { func TestLLMReplierFallsBackToStubOnEmpty(t *testing.T) {
r := newLLMReplier(stubCompleter{out: ""}, nil) r := newLLMReplier(stubCompleter{out: ""}, nil)
assertStub(t, r, router.Decision{Intent: router.IntentNote}, "empty llm") assertAck(t, r, router.Decision{Intent: router.IntentNote}, phraser.AckNote, "empty llm")
} }
func TestLLMReplierClarifyUsesStub(t *testing.T) { func TestLLMReplierClarifyUsesStub(t *testing.T) {
@@ -42,6 +43,16 @@ func TestLLMReplierClarifyUsesStub(t *testing.T) {
assertStub(t, r, router.Decision{Clarify: true}, "clarify") assertStub(t, r, router.Decision{Clarify: true}, "clarify")
} }
// assertAck — the stub picks between variants now, so two calls to it are not
// expected to match. What must hold is that the reply is a line that entry can
// produce, which is the same claim without pinning one wording.
func assertAck(t *testing.T, r *llmReplier, d router.Decision, key, what string) {
t.Helper()
if got := r.Reply(d); !phraser.IsAck(key, nil, got) {
t.Errorf("on %s: got %q, want a %q line", what, got, key)
}
}
func assertStub(t *testing.T, r *llmReplier, d router.Decision, what string) { func assertStub(t *testing.T, r *llmReplier, d router.Decision, what string) {
t.Helper() t.Helper()
got, want := r.Reply(d), voice.NewStubReplier().Reply(d) got, want := r.Reply(d), voice.NewStubReplier().Reply(d)
+10 -5
View File
@@ -4,10 +4,12 @@ import (
"context" "context"
"fmt" "fmt"
"log" "log"
"strconv"
"strings" "strings"
"time" "time"
"github.com/kami/maven/internal/config" "github.com/kami/maven/internal/config"
"github.com/kami/maven/internal/phraser"
"github.com/kami/maven/internal/smarthome" "github.com/kami/maven/internal/smarthome"
"github.com/kami/maven/internal/store" "github.com/kami/maven/internal/store"
) )
@@ -140,10 +142,10 @@ func (w *homeWiring) homeSummary(ctx context.Context) (string, bool) {
ents, err := w.client.States(ctx) ents, err := w.client.States(ctx)
if err != nil { if err != nil {
log.Printf("smarthome: summary: %v", err) log.Printf("smarthome: summary: %v", err)
return "не смогла достучаться до дома.", true return phraser.A(phraser.HomeUnreachable, nil), true
} }
if len(ents) == 0 { if len(ents) == 0 {
return "дом ничего не отдаёт.", true return phraser.A(phraser.HomeEmpty, nil), true
} }
var on []string var on []string
var sensors []string var sensors []string
@@ -177,7 +179,7 @@ func (w *homeWiring) homeSummary(ctx context.Context) (string, bool) {
} }
// Silent truncation on a status read is the same failure as the cap // Silent truncation on a status read is the same failure as the cap
// one layer up: she has to say the list is not the whole list. // one layer up: she has to say the list is not the whole list.
line := "включено: " + strings.Join(shown, ", ") line := phraser.A(phraser.HomeOn, map[string]string{"items": strings.Join(shown, ", ")})
if rest > 0 { if rest > 0 {
line += fmt.Sprintf(" и ещё %d", rest) line += fmt.Sprintf(" и ещё %d", rest)
} }
@@ -185,7 +187,10 @@ func (w *homeWiring) homeSummary(ctx context.Context) (string, bool) {
case dark > 0 && len(sensors) == 0: case dark > 0 && len(sensors) == 0:
// Nothing is on and everything she can see is unreachable. "всё // Nothing is on and everything she can see is unreachable. "всё
// выключено" would be a claim about the house she cannot make. // выключено" would be a claim about the house she cannot make.
return fmt.Sprintf("дом молчит: %d %s не отвечают.", dark, hostWord(dark)), true return phraser.A(phraser.HomeDark, map[string]string{
"count": strconv.Itoa(dark),
"word": phraser.Devices(dark),
}), true
default: default:
parts = append(parts, "всё выключено") parts = append(parts, "всё выключено")
} }
@@ -193,7 +198,7 @@ func (w *homeWiring) homeSummary(ctx context.Context) (string, bool) {
parts = append(parts, strings.Join(sensors, ", ")) parts = append(parts, strings.Join(sensors, ", "))
} }
if dark > 0 { if dark > 0 {
parts = append(parts, fmt.Sprintf("%d %s не отвечают", dark, hostWord(dark))) parts = append(parts, fmt.Sprintf("%d %s не отвечают", dark, phraser.Devices(dark)))
} }
return strings.Join(parts, "; ") + ".", true return strings.Join(parts, "; ") + ".", true
} }
+3 -2
View File
@@ -11,6 +11,7 @@ import (
"time" "time"
"github.com/kami/maven/internal/ipc" "github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/phraser"
"github.com/kami/maven/internal/store" "github.com/kami/maven/internal/store"
) )
@@ -51,10 +52,10 @@ func (h *reactiveHandler) resolveSnooze(ctx context.Context, text string, src tu
} }
if err := h.api.ResolveNudge(ctx, target.ID, store.NudgeSnoozed, now); err != nil { if err := h.api.ResolveNudge(ctx, target.ID, store.NudgeSnoozed, now); err != nil {
log.Printf("voice: snooze nudge %d (%s, %s): %v", target.ID, target.Rule, src, err) log.Printf("voice: snooze nudge %d (%s, %s): %v", target.ID, target.Rule, src, err)
return "не получилось отложить.", true return phraser.Ack(phraser.FailSnooze, nil), true
} }
log.Printf("voice: snoozed nudge %d (rule %s) from %s", target.ID, target.Rule, src) log.Printf("voice: snoozed nudge %d (rule %s) from %s", target.ID, target.Rule, src)
return "хорошо, вернусь к этому позже.", true return phraser.Ack(phraser.AckSnooze, nil), true
} }
// pendingNudge — the newest still-pending nudge sent inside snoozeWindow. // pendingNudge — the newest still-pending nudge sent inside snoozeWindow.
+9 -1
View File
@@ -23,7 +23,11 @@ type worldPhraser interface {
// question about his meeting came back as a swimming competition in Nottingham. // question about his meeting came back as a swimming competition in Nottingham.
// Naming the gap is the rule CLAUDE.md already applies to a sibling service // Naming the gap is the rule CLAUDE.md already applies to a sibling service
// being down. // being down.
const worldGap = "сейчас не могу ответить — большая модель недоступна, а придумывать не хочу." //
// The wording lives in fallbacks_ru_v1.json and is fixed there, not picked from
// variants: this sentence names one specific gap and must not drift into a
// general "I don't know".
func worldGap() string { return phraser.WorldGap() }
// phraseWorld asks the world model, or reports the gap. // phraseWorld asks the world model, or reports the gap.
// //
@@ -54,7 +58,11 @@ func (h *reactiveHandler) phraseSource(ctx context.Context, name, utterance stri
log.Printf("voice: %s: no world model, reading the source back instead", name) log.Printf("voice: %s: no world model, reading the source back instead", name)
return "" return ""
case err != nil: case err != nil:
// The resident phraser answers this call with its fallback text and the
// error together. Drop the text: these callers hold the passage itself
// and read it back better than "вот что я нашла: <passage>" does.
log.Printf("voice: %s: phrase: %v", name, err) log.Printf("voice: %s: phrase: %v", name, err)
return ""
} }
return reply return reply
} }
+6 -6
View File
@@ -35,7 +35,7 @@ func TestQueryGeneralNamesTheGap(t *testing.T) {
if !ok { if !ok {
t.Fatal("queryGeneral passed on the last source in the chain") t.Fatal("queryGeneral passed on the last source in the chain")
} }
if reply != worldGap { if reply != worldGap() {
t.Fatalf("reply = %q, want the named gap", reply) t.Fatalf("reply = %q, want the named gap", reply)
} }
if g.worldCalls != 1 { if g.worldCalls != 1 {
@@ -51,7 +51,7 @@ func TestQueryGeneralWithoutAWorldModelIsUnchanged(t *testing.T) {
if !ok { if !ok {
t.Fatal("queryGeneral passed on the last source in the chain") t.Fatal("queryGeneral passed on the last source in the chain")
} }
if reply != "не знаю." { if !phraser.IsUnknownFallback(reply) {
t.Fatalf("reply = %q, want the Stub's answer", reply) t.Fatalf("reply = %q, want the Stub's answer", reply)
} }
} }
@@ -61,12 +61,12 @@ func TestQueryGeneralWithoutAWorldModelIsUnchanged(t *testing.T) {
// English in it. // English in it.
func TestWorldGapIsInPersona(t *testing.T) { func TestWorldGapIsInPersona(t *testing.T) {
for _, bad := range []string{"вы", "ваш", "рад ", "дорогой", "милый"} { for _, bad := range []string{"вы", "ваш", "рад ", "дорогой", "милый"} {
if strings.Contains(worldGap, bad) { if strings.Contains(worldGap(), bad) {
t.Errorf("the gap phrase contains %q: %s", bad, worldGap) t.Errorf("the gap phrase contains %q: %s", bad, worldGap())
} }
} }
if strings.ContainsAny(worldGap, "abcdefghijklmnopqrstuvwxyz") { if strings.ContainsAny(worldGap(), "abcdefghijklmnopqrstuvwxyz") {
t.Errorf("the gap phrase has Latin letters in it: %s", worldGap) t.Errorf("the gap phrase has Latin letters in it: %s", worldGap())
} }
} }
+84
View File
@@ -0,0 +1,84 @@
{
"schema_version": 1,
"name": "russian capture acknowledgements v1",
"notes": [
"What she says after storing something he said, and what she says when storing it failed. Edit the wording here, no Go changes needed.",
"Rules: she is feminine about herself, he is a man addressed as ты. Never вы/вас/ваш, never он/его about him. No pet names.",
"He hears these many times a day, so most entries carry variants: identical wording is what makes a confirmation stop registering as one.",
"Placeholders: {key} {value} the fact he stated, {fn} the action, {text} the task title. His data is interpolated Go-side — the file holds the frame, never his words.",
"An acknowledgement confirms and stops. It does not ask a follow-up question and it does not editorialise about what he stored."
],
"entries": {
"ack_fact": {
"variants": ["записала факт.", "записала.", "запомнила."]
},
"ack_fact_key": {
"variants": ["отметила: {key}", "записала: {key}", "запомнила: {key}"]
},
"ack_fact_kv": {
"variants": ["отметила: {key} = {value}", "записала: {key} — {value}", "запомнила: {key} — {value}"]
},
"ack_note": {
"variants": ["сохранила заметку.", "заметка сохранена.", "записала в заметки."]
},
"ack_reminder": {
"variants": ["напомню.", "напомню, не забуду.", "хорошо, напомню."]
},
"ack_act": {
"variants": ["ок, записала действие: {fn}", "приняла действие: {fn}"]
},
"ack_task": {
"variants": ["записала: {text}", "добавила в задачи: {text}", "внесла в список: {text}"]
},
"ack_task_urgent": {
"variants": ["поняла, беру в работу: {text}", "поняла, это срочно: {text}"]
},
"ack_task_duplicate": {
"variants": ["это уже в списке.", "такое уже есть в задачах."]
},
"ack_nudge": {
"variants": ["отлично, отметила.", "отметила.", "хорошо, отметила."]
},
"ack_snooze": {
"variants": ["хорошо, вернусь к этому позже.", "ладно, напомню попозже.", "хорошо, отложила."]
},
"ack_generic": {
"variants": ["приняла.", "поняла."]
},
"quiet_on": {
"fixed": true,
"variants": ["тихий режим включён. буду реже напоминать."]
},
"quiet_off": {
"fixed": true,
"variants": ["тихий режим выключен."]
},
"fail_fact": {
"variants": ["не получилось сохранить факт.", "факт не сохранился."]
},
"fail_note": {
"variants": ["не получилось сохранить заметку.", "заметка не сохранилась."]
},
"fail_reminder": {
"variants": ["не получилось поставить напоминание.", "напоминание не поставилось."]
},
"fail_reminder_time": {
"variants": ["не получилось разобрать время напоминания.", "не поняла, на когда напомнить."]
},
"fail_task": {
"variants": ["не получилось записать задачу.", "задача не записалась."]
},
"fail_ack": {
"variants": ["не получилось отметить.", "не смогла отметить."]
},
"fail_snooze": {
"variants": ["не получилось отложить.", "не смогла отложить."]
},
"fail_quiet": {
"variants": ["не получилось переключить тихий режим.", "тихий режим не переключился."]
},
"fail_fact_unparsed": {
"variants": ["не разобрала, что записать — попробуй иначе.", "не поняла, что записать. скажи иначе?"]
}
}
}
+162
View File
@@ -0,0 +1,162 @@
package phraser
// The capture acknowledgements — what she says after storing something he said,
// and what she says when storing it failed.
//
// They were string literals in eight files under cmd/mavend plus the stub
// replier in internal/voice. He hears them many times a day, which is exactly
// why they need variants and exactly why rewording one must not be a rebuild.
// Same shape as fallbacks_ru_v1.json, on the shared deck (deck.go).
//
// His data stays Go-side. The file holds "отметила: {key} = {value}"; the key
// and the value are interpolated here, so nothing he said lives in the copy.
import (
_ "embed"
"log"
"math/rand"
"sync"
)
//go:embed ack_ru_v1.json
var ackJSON []byte
// AckSchemaVersion — this family's own version. A file that changes on a
// different day than the fallbacks cannot share their number (Vikunja #397).
const AckSchemaVersion = 1
// The entry keys. Namespaced by family, because the floor map behind a nil deck
// is process-wide.
const (
AckFact = "ack_fact"
AckFactKey = "ack_fact_key"
AckFactValue = "ack_fact_kv"
AckNote = "ack_note"
AckReminder = "ack_reminder"
AckAct = "ack_act"
AckTask = "ack_task"
AckTaskUrgent = "ack_task_urgent"
AckTaskDuplicate = "ack_task_duplicate"
AckNudge = "ack_nudge"
AckSnooze = "ack_snooze"
AckGeneric = "ack_generic"
AckQuietOn = "quiet_on"
AckQuietOff = "quiet_off"
FailFact = "fail_fact"
FailFactUnparsed = "fail_fact_unparsed"
FailNote = "fail_note"
FailReminder = "fail_reminder"
FailReminderTime = "fail_reminder_time"
FailTask = "fail_task"
FailAck = "fail_ack"
FailSnooze = "fail_snooze"
FailQuiet = "fail_quiet"
)
// ackKeys — every key the code requires the file to define.
var ackKeys = []string{
AckFact, AckFactKey, AckFactValue, AckNote, AckReminder, AckAct,
AckTask, AckTaskUrgent, AckTaskDuplicate, AckNudge, AckSnooze, AckGeneric,
AckQuietOn, AckQuietOff,
FailFact, FailFactUnparsed, FailNote, FailReminder, FailReminderTime,
FailTask, FailAck, FailSnooze, FailQuiet,
}
// ackFloor — the literal each key falls back to when the file is unusable.
// These are the exact strings that lived in Go before this file existed.
var ackFloor = map[string]string{
AckFact: "записала факт.",
AckFactKey: "отметила: {key}",
AckFactValue: "отметила: {key} = {value}",
AckNote: "сохранила заметку.",
AckReminder: "напомню.",
AckAct: "ок, записала действие: {fn}",
AckTask: "записала: {text}",
AckTaskUrgent: "поняла, беру в работу: {text}",
AckTaskDuplicate: "это уже в списке.",
AckNudge: "отлично, отметила.",
AckSnooze: "хорошо, вернусь к этому позже.",
AckGeneric: "приняла.",
AckQuietOn: "тихий режим включён. буду реже напоминать.",
AckQuietOff: "тихий режим выключен.",
FailFact: "не получилось сохранить факт.",
FailFactUnparsed: "не разобрала, что записать — попробуй иначе.",
FailNote: "не получилось сохранить заметку.",
FailReminder: "не получилось поставить напоминание.",
FailReminderTime: "не получилось разобрать время напоминания.",
FailTask: "не получилось записать задачу.",
FailAck: "не получилось отметить.",
FailSnooze: "не получилось отложить.",
FailQuiet: "не получилось переключить тихий режим.",
}
// Acks picks a hand-written Russian acknowledgement. Safe for concurrent use.
type Acks struct{ d *deck }
// LoadAcks reads the embedded file. Pass a source to make the picking
// reproducible in tests; nil seeds from the clock.
func LoadAcks(src rand.Source) (*Acks, error) {
d, err := loadDeck(ackJSON, AckSchemaVersion, ackKeys, ackFloor, src)
if err != nil {
return nil, err
}
// The three entries that exist to read his own words back. A variant
// without the placeholder would confirm the capture and drop what was
// captured, which reads as a successful save of nothing.
for _, req := range []struct{ key, ph string }{
{AckFactKey, "{key}"}, {AckFactValue, "{key}"}, {AckFactValue, "{value}"},
{AckAct, "{fn}"}, {AckTask, "{text}"}, {AckTaskUrgent, "{text}"},
} {
if err := d.requirePlaceholder(req.key, req.ph); err != nil {
return nil, err
}
}
return &Acks{d: d}, nil
}
// deck reads through a nil *Acks, which is the unloadable-file case.
func (a *Acks) deck() *deck {
if a == nil {
return floorDeck(ackFloor)
}
return a.d
}
// Say returns one line for key, with his data filled into the frame. Pass nil
// when the entry takes none.
func (a *Acks) Say(key string, vars map[string]string) string {
return a.deck().text(key, vars)
}
// Variants returns every line the file can produce, for the persona scorer.
func (a *Acks) Variants() []string { return a.deck().variants() }
var (
ackOnce sync.Once
acks *Acks
)
// DefaultAcks returns the shared instance, loading it on first use. A broken
// file logs once and leaves a nil *Acks, which still answers from ackFloor.
func DefaultAcks() *Acks {
ackOnce.Do(func() {
a, err := LoadAcks(nil)
if err != nil {
log.Printf("phraser: acknowledgements unavailable, using the built-in lines: %v", err)
return
}
acks = a
})
return acks
}
// Ack — one acknowledgement line, the way every caller says it.
func Ack(key string, vars map[string]string) string { return DefaultAcks().Say(key, vars) }
// IsAck reports whether text is a line key could have produced. For the daemon
// tests, which can no longer compare against one literal.
func IsAck(key string, vars map[string]string, text string) bool {
return DefaultAcks().deck().matches(key, vars, text)
}
+86
View File
@@ -0,0 +1,86 @@
package phraser
import (
"math/rand"
"strconv"
"strings"
"testing"
)
func loadTestActs(t *testing.T) *Acts {
t.Helper()
a, err := LoadActs(rand.NewSource(1))
if err != nil {
t.Fatalf("LoadActs: %v", err)
}
return a
}
// She talks about a lamp or a server, never about a row in a schema. «сущность»
// and «экосистема» are the same defect as saying a capability id out loud.
func TestNoActLineSaysASchemaWord(t *testing.T) {
a := loadTestActs(t)
for _, v := range a.Variants() {
for _, word := range []string{"сущност", "экосистем"} {
if strings.Contains(v, word) {
t.Errorf("variant %q says %q out loud", v, word)
}
}
}
}
// Nexus, Praxis and Hexis fail independently, so "не отвечает" with no subject
// is not an answer he can act on.
func TestAServiceFailureNamesTheService(t *testing.T) {
a := loadTestActs(t)
for _, key := range []string{EcoDown, EcoDenied} {
got := a.Say(key, map[string]string{"name": "Praxis"})
if !strings.HasPrefix(got, "Praxis ") {
t.Errorf("%s = %q, want it to name the service", key, got)
}
}
}
// A confirmation prompt for a destructive act is the worst place for an unfilled
// placeholder, so the two names it interpolates are distinct keys and both are
// declared.
func TestConfirmEntityFillsBothNames(t *testing.T) {
a := loadTestActs(t)
got := a.Say(ActConfirmEntity, map[string]string{
"name": "restart", "name_entity": "Muzick indexer",
})
if strings.ContainsAny(got, "{}") {
t.Fatalf("act_confirm_entity = %q, want no placeholder left", got)
}
if !strings.Contains(got, "restart") || !strings.Contains(got, "Muzick indexer") {
t.Fatalf("act_confirm_entity = %q, want both names", got)
}
}
// home_dark counts unreachable devices, and Russian inflects the noun after the
// number: the count goes in {count} and the noun comes from the helper.
func TestHomeDarkCountsWithTheHelper(t *testing.T) {
a := loadTestActs(t)
for n, want := range map[int]string{1: "1 устройство", 2: "2 устройства", 5: "5 устройств"} {
got := a.Say(HomeDark, map[string]string{"count": strconv.Itoa(n), "word": Devices(n)})
if !strings.Contains(got, want) {
t.Errorf("home_dark for %d = %q, want %q in it", n, got, want)
}
}
}
// Four truths, four entries: a failure must not be able to report itself as a
// success, and an empty result must not read as a failure.
func TestActOutcomesStayDistinct(t *testing.T) {
a := loadTestActs(t)
seen := map[string]string{}
for _, key := range actKeys {
for _, v := range a.d.file.Entries[key].Variants {
if prev, dup := seen[v]; dup {
t.Errorf("%s and %s both say %q", prev, key, v)
}
seen[v] = key
}
}
}
+188
View File
@@ -0,0 +1,188 @@
package phraser
// The act and smart-home replies — what she says when a capability ran, refused,
// or could not be reached.
//
// Fourth family on the shared deck (deck.go). They were literals in
// ecosystem_acts.go, actions_act.go and smarthome.go, where a reworded line was
// a rebuild of the daemon that executes his house.
//
// The four outcomes stay four entries. Reporting a refusal with the wording of
// a success is the one failure mode this family can have, and a shared variant
// set is how it would happen.
import (
_ "embed"
"log"
"math/rand"
"sync"
)
//go:embed acts_ru_v1.json
var actJSON []byte
// ActSchemaVersion — this family's own version.
const ActSchemaVersion = 1
// The entry keys.
const (
ActDone = "act_done"
ActDoneOut = "act_done_out"
ActDoneEntity = "act_done_entity"
ActConfirm = "act_confirm"
ActConfirmEntity = "act_confirm_entity"
ActWhich = "act_which"
ActFail = "act_fail"
ActFailOut = "act_fail_out"
ActFailEntity = "act_fail_entity"
ActServerDown = "act_server_down"
ActWithdrawn = "act_withdrawn"
ActNeedsArgs = "act_needs_args"
EcoDenied = "eco_denied"
EcoDown = "eco_down"
EcoAmbiguous = "eco_ambiguous"
EcoUnknownEntity = "eco_unknown_entity"
EcoNoNexus = "eco_no_nexus"
EcoAboutWhat = "eco_about_what"
EcoRecall = "eco_recall"
AttentionNone = "attention_none"
AttentionList = "attention_list"
AttentionFail = "attention_fail"
AttentionNoneEntity = "attention_none_entity"
AttentionListEntity = "attention_list_entity"
AttentionFailEntity = "attention_fail_entity"
ChangesNone = "changes_none"
ChangesList = "changes_list"
ChangesFail = "changes_fail"
HomeUnreachable = "home_unreachable"
HomeEmpty = "home_empty"
HomeOn = "home_on"
HomeDark = "home_dark"
)
var actKeys = []string{
ActDone, ActDoneOut, ActDoneEntity, ActConfirm, ActConfirmEntity, ActWhich,
ActFail, ActFailOut, ActFailEntity, ActServerDown, ActWithdrawn, ActNeedsArgs,
EcoDenied, EcoDown, EcoAmbiguous, EcoUnknownEntity, EcoNoNexus, EcoAboutWhat, EcoRecall,
AttentionNone, AttentionList, AttentionFail,
AttentionNoneEntity, AttentionListEntity, AttentionFailEntity,
ChangesNone, ChangesList, ChangesFail,
HomeUnreachable, HomeEmpty, HomeOn, HomeDark,
}
// actFloor — the literal each key falls back to when the file is unusable. It
// started as the exact strings that lived in Go before this file existed and now
// tracks the file's first variant instead, because a floor that keeps the
// wording review threw out would say it back on the one turn nobody is watching.
var actFloor = map[string]string{
ActDone: "готово.",
ActDoneOut: "готово: {out}",
ActDoneEntity: "готово: {name}.",
ActConfirm: "выполнить «{name}»? да или нет.",
ActConfirmEntity: "выполнить «{name}» для {name_entity}? да или нет.",
ActWhich: "какую команду для {name}: {items}?",
ActFail: "не получилось выполнить команду.",
ActFailOut: "не получилось выполнить команду: {out}",
ActFailEntity: "не получилось выполнить команду для {name}.",
ActServerDown: "инструмент есть, но сервер не подключён.",
ActWithdrawn: "сервер больше не отдаёт этот инструмент — сняла его с разрешённых, посмотри /tools.",
ActNeedsArgs: "тут нужны аргументы, из голоса не соберу. угадывать не буду.",
EcoDenied: "{name} отклоняет доступ, проверь токен.",
EcoDown: "{name} не отвечает, попробуй ещё раз.",
EcoAmbiguous: "что именно: {items}?",
EcoUnknownEntity: "не знаю, что это.",
EcoNoNexus: "не с чем связать — Nexus не настроен.",
EcoAboutWhat: "про что именно?",
EcoRecall: "я помню: {items}",
AttentionNone: "ничего не требует внимания.",
AttentionList: "требует внимания: {items}",
AttentionFail: "не могу сейчас узнать, что требует внимания.",
AttentionNoneEntity: "по «{name}» ничего нет.",
AttentionListEntity: "по «{name}»: {items}",
AttentionFailEntity: "не могу сейчас узнать, что требует внимания по «{name}».",
ChangesNone: "изменений нет.",
ChangesList: "изменения: {items}",
ChangesFail: "не могу сейчас узнать об изменениях.",
HomeUnreachable: "дом не отвечает.",
HomeEmpty: "дом ничего не отдаёт.",
HomeOn: "включено: {items}",
HomeDark: "не отвечают: {count} {word}.",
}
// Acts picks a hand-written Russian act reply. Safe for concurrent use.
type Acts struct{ d *deck }
// LoadActs reads the embedded file. Pass a source to make the picking
// reproducible in tests; nil seeds from the clock.
func LoadActs(src rand.Source) (*Acts, error) {
d, err := loadDeck(actJSON, ActSchemaVersion, actKeys, actFloor, src)
if err != nil {
return nil, err
}
// The entries that name what ran or what he has to choose between. A
// variant that dropped the name would confirm an act without saying which.
for _, req := range []struct{ key, ph string }{
{ActDoneOut, "{out}"}, {ActDoneEntity, "{name}"}, {ActFailOut, "{out}"},
{ActFailEntity, "{name}"}, {ActConfirm, "{name}"},
{ActConfirmEntity, "{name}"}, {ActConfirmEntity, "{name_entity}"},
{ActWhich, "{name}"}, {ActWhich, "{items}"},
{EcoAmbiguous, "{items}"}, {EcoRecall, "{items}"},
{AttentionList, "{items}"}, {ChangesList, "{items}"}, {HomeOn, "{items}"},
{EcoDenied, "{name}"}, {EcoDown, "{name}"},
{HomeDark, "{count}"}, {HomeDark, "{word}"},
{AttentionNoneEntity, "{name}"}, {AttentionListEntity, "{name}"},
{AttentionListEntity, "{items}"}, {AttentionFailEntity, "{name}"},
} {
if err := d.requirePlaceholder(req.key, req.ph); err != nil {
return nil, err
}
}
return &Acts{d: d}, nil
}
// deck reads through a nil *Acts, which is the unloadable-file case.
func (a *Acts) deck() *deck {
if a == nil {
return floorDeck(actFloor)
}
return a.d
}
// Say returns one line for key, with the names filled into the frame.
func (a *Acts) Say(key string, vars map[string]string) string {
return a.deck().text(key, vars)
}
// Variants returns every line the file can produce, for the persona scorer.
func (a *Acts) Variants() []string { return a.deck().variants() }
var (
actOnce sync.Once
actsDeck *Acts
)
// DefaultActs returns the shared instance, loading it on first use. A broken
// file logs once and leaves a nil *Acts, which still answers from actFloor.
func DefaultActs() *Acts {
actOnce.Do(func() {
a, err := LoadActs(nil)
if err != nil {
log.Printf("phraser: act replies unavailable, using the built-in lines: %v", err)
return
}
actsDeck = a
})
return actsDeck
}
// A — one act reply, the way every caller says it.
func A(key string, vars map[string]string) string { return DefaultActs().Say(key, vars) }
// IsA reports whether text is a line key could have produced, for the tests.
func IsA(key string, vars map[string]string, text string) bool {
return DefaultActs().deck().matches(key, vars, text)
}
+118
View File
@@ -0,0 +1,118 @@
{
"schema_version": 1,
"name": "russian act and smart-home replies v1",
"notes": [
"What she says when a capability ran, refused, or could not be reached. Edit the wording here, no Go changes needed.",
"Rules: she is feminine about herself, he is a man addressed as ты. Never вы/вас/ваш, never он/его about him. No pet names.",
"\"it ran\", \"it was refused\", \"a service is down\" and \"I could not work out what you meant\" are four different truths. They keep four entries, because one variant set would let a failure report itself as a success.",
"She says what he would say. No schema words out loud: not «сущность», not «экосистема», not a capability id, not a config key. She is talking about a lamp or a server.",
"A service that is down or refusing is named. \"не отвечает\" with no subject tells him nothing he can act on, and Nexus, Praxis and Hexis fail independently.",
"Placeholders: {name} an entity or capability the caller resolved, {name_entity} the entity an act runs against when {name} is already the capability, {out} the command's own output, {items} a joined list, {count} a number, {word} the counted noun in the form {count} needs. Entity names and capability ids are interpolated Go-side.",
"A count never carries a hardcoded noun. Russian inflects it — 1 устройство, 2 устройства, 5 устройств — so the number goes in {count} and the noun comes from the Go helper through {word}.",
"An entry that only exists to read a list back must never be reached with an empty list. The caller routes an empty list to the matching _none entry, because a single-variant placeholder-only line has no shorter wording to fall back to.",
"fixed: true means exactly one variant and no picking. Used where the wording carries an instruction he has to act on — a confirmation, a pointer at /tools — and for the lines that report an act as done, because a success report that reworded itself is harder to trust and harder to test."
],
"entries": {
"act_done": {
"fixed": true,
"variants": ["готово."]
},
"act_done_out": {
"variants": ["готово: {out}", "сделала: {out}"]
},
"act_done_entity": {
"fixed": true,
"variants": ["готово: {name}."]
},
"act_confirm": {
"fixed": true,
"variants": ["выполнить «{name}»? да или нет."]
},
"act_confirm_entity": {
"fixed": true,
"variants": ["выполнить «{name}» для {name_entity}? да или нет."]
},
"act_which": {
"variants": ["какую команду для {name}: {items}?"]
},
"act_fail": {
"variants": ["не получилось выполнить команду."]
},
"act_fail_out": {
"variants": ["не получилось выполнить команду: {out}"]
},
"act_fail_entity": {
"variants": ["не получилось выполнить команду для {name}."]
},
"act_server_down": {
"variants": ["инструмент есть, но сервер не подключён."]
},
"act_withdrawn": {
"fixed": true,
"variants": ["сервер больше не отдаёт этот инструмент — сняла его с разрешённых, посмотри /tools."]
},
"act_needs_args": {
"variants": ["тут нужны аргументы, из голоса не соберу. угадывать не буду."]
},
"eco_denied": {
"variants": ["{name} отклоняет доступ, проверь токен."]
},
"eco_down": {
"variants": ["{name} не отвечает, попробуй ещё раз."]
},
"eco_ambiguous": {
"variants": ["что именно: {items}?"]
},
"eco_unknown_entity": {
"variants": ["не знаю, что это.", "такого у меня нет."]
},
"eco_no_nexus": {
"variants": ["не с чем связать — Nexus не настроен."]
},
"eco_about_what": {
"variants": ["про что именно?"]
},
"eco_recall": {
"variants": ["я помню: {items}"]
},
"attention_none": {
"variants": ["ничего не требует внимания."]
},
"attention_list": {
"variants": ["требует внимания: {items}"]
},
"attention_fail": {
"variants": ["не могу сейчас узнать, что требует внимания."]
},
"attention_none_entity": {
"variants": ["по «{name}» ничего нет.", "по «{name}» пока пусто."]
},
"attention_list_entity": {
"variants": ["по «{name}»: {items}"]
},
"attention_fail_entity": {
"variants": ["не могу сейчас узнать, что требует внимания по «{name}»."]
},
"changes_none": {
"variants": ["изменений нет."]
},
"changes_list": {
"variants": ["изменения: {items}"]
},
"changes_fail": {
"variants": ["не могу сейчас узнать об изменениях."]
},
"home_unreachable": {
"variants": ["дом не отвечает.", "не достучалась до дома."]
},
"home_empty": {
"variants": ["дом ничего не отдаёт."]
},
"home_on": {
"variants": ["включено: {items}"]
},
"home_dark": {
"variants": ["не отвечают: {count} {word}."]
}
}
}
+235
View File
@@ -0,0 +1,235 @@
package phraser
// deck — the mechanics every family of hand-written Russian lines shares.
//
// A family is one embedded JSON file: schema-versioned, several variants per
// entry, never the same variant twice running, and a hard floor of Go literals
// under it so a broken file cannot take her words away. fallbacks.go was the
// first family (Vikunja #501) and acks.go the second, at which point copying
// eighty lines of loader per family stopped being defensible.
//
// What stays per family: the file, the keys, the floor literals, the accessor
// names, and any validation only that family can state.
import (
"encoding/json"
"fmt"
"math/rand"
"strings"
"sync"
"time"
)
// deckEntry — one line she can say, in as many wordings as the file gives.
type deckEntry struct {
// Fixed — one variant, never picked between. For wording that must not
// drift from turn to turn, like a phrase naming one specific gap.
Fixed bool `json:"fixed"`
Variants []string `json:"variants"`
}
type deckFile struct {
SchemaVersion int `json:"schema_version"`
Name string `json:"name"`
Notes []string `json:"notes"`
Entries map[string]deckEntry `json:"entries"`
}
// deck picks a line. Safe for concurrent use. A deck with no entries answers
// from the floor, which is what an unloadable file leaves behind (floorDeck).
type deck struct {
mu sync.Mutex
rnd *rand.Rand
last map[string]string
file deckFile
keys []string
floor map[string]string
}
// loadDeck parses raw, checks the version and every required key, and seeds the
// picker. Pass a source to make the picking reproducible in tests; nil seeds
// from the clock.
func loadDeck(raw []byte, version int, keys []string, floor map[string]string, src rand.Source) (*deck, error) {
var f deckFile
if err := json.Unmarshal(raw, &f); err != nil {
return nil, fmt.Errorf("parse: %w", err)
}
if f.SchemaVersion != version {
return nil, fmt.Errorf("schema_version %d, want %d", f.SchemaVersion, version)
}
for _, k := range keys {
e, ok := f.Entries[k]
if !ok || len(e.Variants) == 0 {
return nil, fmt.Errorf("entry %q is missing or empty", k)
}
if e.Fixed && len(e.Variants) != 1 {
return nil, fmt.Errorf("entry %q is fixed but has %d variants", k, len(e.Variants))
}
}
if src == nil {
src = rand.NewSource(time.Now().UnixNano())
}
return &deck{rnd: rand.New(src), last: map[string]string{}, file: f, keys: keys, floor: floor}, nil
}
// requirePlaceholder fails the load when a variant of key does not use ph. For
// an entry whose whole job is to read something back, a variant without the
// placeholder silently drops it.
func (d *deck) requirePlaceholder(key, ph string) error {
for _, v := range d.file.Entries[key].Variants {
if !strings.Contains(v, ph) {
return fmt.Errorf("%q variant %q does not use %s", key, v, ph)
}
}
return nil
}
// text returns one variant for key with the placeholders filled in. A nil
// receiver answers from the floor, so no caller checks whether the file loaded.
func (d *deck) text(key string, vars map[string]string) string {
tmpl := ""
if d != nil {
if e, ok := d.file.Entries[key]; ok && len(e.Variants) > 0 {
tmpl = d.pick(key, fillable(e.Variants, vars))
}
}
if tmpl == "" {
tmpl = floorOf(d, key)
}
return fill(tmpl, vars)
}
// matches reports whether text is a line key could have produced. A caller that
// has to recognise one of these lines cannot compare against a literal any more.
func (d *deck) matches(key string, vars map[string]string, text string) bool {
if fill(floorOf(d, key), vars) == text {
return true
}
if d == nil {
return false
}
for _, v := range d.file.Entries[key].Variants {
if fill(v, vars) == text {
return true
}
}
return false
}
// variants returns every line the file can produce, in key order, for the
// persona scorer. Stable order so a failure names the same variant twice.
func (d *deck) variants() []string {
if d == nil {
return nil
}
var out []string
for _, k := range d.keys {
out = append(out, d.file.Entries[k].Variants...)
}
return out
}
// fillable narrows variants to the ones this call can actually say, which is
// the rule an optional placeholder needs: a caller with nothing to put in
// {tail} must not be handed a variant that has one. Two passes, because both
// halves matter. The first keeps only variants whose every placeholder has a
// non-empty value, so an absent optional never reaches him as braces. The
// second prefers, among those, the variants using the most of what the caller
// supplied, so a caveat he was given is not dropped for a shorter wording.
// Nothing fillable leaves the list alone, and the unfilled placeholder shows
// up in the answer rather than turning it into silence.
func fillable(variants []string, vars map[string]string) []string {
if len(variants) < 2 {
return variants
}
best, bestUsed := make([]string, 0, len(variants)), -1
for _, v := range variants {
used := 0
ok := true
for _, ph := range placeholders(v) {
if vars[ph] == "" {
ok = false
break
}
used++
}
if !ok || used < bestUsed {
continue
}
if used > bestUsed {
best, bestUsed = best[:0], used
}
best = append(best, v)
}
if len(best) == 0 {
return variants
}
return best
}
// placeholders lists the {name}s in tmpl, in order.
func placeholders(tmpl string) []string {
var out []string
for {
i := strings.IndexByte(tmpl, '{')
if i < 0 {
return out
}
j := strings.IndexByte(tmpl[i:], '}')
if j < 0 {
return out
}
out = append(out, tmpl[i+1:i+j])
tmpl = tmpl[i+j+1:]
}
}
// pick chooses at random, skipping whatever this entry said last time.
func (d *deck) pick(key string, variants []string) string {
d.mu.Lock()
defer d.mu.Unlock()
choices := variants
if len(choices) > 1 {
fresh := make([]string, 0, len(choices))
for _, v := range choices {
if v != d.last[key] {
fresh = append(fresh, v)
}
}
if len(fresh) > 0 {
choices = fresh
}
}
got := choices[d.rnd.Intn(len(choices))]
d.last[key] = got
return got
}
// floorOf reads the Go literal behind key. Every deck carries its own family's
// map, including the floor-only deck an unloadable file leaves behind, so no
// lookup ever crosses families. It used to go through one global map keyed by
// bare entry name, which two families both calling an entry query_unknown
// silently shared: whichever registered last answered for both (Vikunja #521).
func floorOf(d *deck, key string) string {
if d == nil {
return ""
}
return d.floor[key]
}
// floorDeck — the deck a family falls back to when its file will not load. It
// has no entries, so every read drops through to the floor literals, and it is
// a real *deck so no accessor has to know which case it is in.
func floorDeck(floor map[string]string) *deck {
return &deck{last: map[string]string{}, floor: floor}
}
// fill substitutes {name} for each var. A placeholder with no value is left
// alone rather than blanked, so a missing value is visible instead of silent.
func fill(tmpl string, vars map[string]string) string {
for k, v := range vars {
tmpl = strings.ReplaceAll(tmpl, "{"+k+"}", v)
}
return tmpl
}
+62
View File
@@ -0,0 +1,62 @@
package eval
import (
"math/rand"
"strings"
"testing"
"github.com/kami/maven/internal/phraser"
)
// TestFallbackPersona scores every line in every hand-written line family on the persona checks the nudges already pass. These lines are
// heard out loud and they live in a JSON file now, so a reworded variant that
// says "рад" or "вы" would otherwise reach him with nothing in between.
//
// Only the persona checks run. Mood and topic belong to a nudge, and these are
// not nudges.
func TestFallbackPersona(t *testing.T) {
fb, err := phraser.LoadFallbacks(rand.NewSource(20260804))
if err != nil {
t.Fatalf("LoadFallbacks: %v", err)
}
// No CheckHisGender. It reads a feminine verb near a second-person pronoun
// as addressing him as a woman, which is right for a nudge and wrong here:
// "не знаю — не нашла у тебя такой записи" is her own verb in her own
// sentence. CheckFeminine still holds her side of the rule.
persona := map[string]bool{
CheckLang: true, CheckFeminine: true,
CheckAddress: true, CheckCringe: true, CheckLength: true,
}
ack, err := phraser.LoadAcks(rand.NewSource(20260804))
if err != nil {
t.Fatalf("LoadAcks: %v", err)
}
qry, err := phraser.LoadQueries(rand.NewSource(20260804))
if err != nil {
t.Fatalf("LoadQueries: %v", err)
}
variants := append(fb.Variants(), ack.Variants()...)
variants = append(variants, qry.Variants()...)
act, err := phraser.LoadActs(rand.NewSource(20260804))
if err != nil {
t.Fatalf("LoadActs: %v", err)
}
variants = append(variants, act.Variants()...)
if len(variants) == 0 {
t.Fatal("no variants — the file loaded empty")
}
for _, v := range variants {
// The placeholders stand for his own words and carry no persona.
body := v
for _, ph := range []string{"{sources}", "{key}", "{value}", "{fn}", "{text}", "{when}", "{items}",
"{location}", "{temp}", "{condition}", "{tail}", "{out}", "{name}",
"{entity}", "{count}", "{word}"} {
body = strings.ReplaceAll(body, ph, "вода")
}
for _, r := range RunChecks(Case{}, body, "neutral") {
if persona[r.Name] && !r.Pass {
t.Errorf("%q fails %s: %s", v, r.Name, r.Detail)
}
}
}
}
+8 -2
View File
@@ -78,6 +78,12 @@ type TalkCase struct {
Note string `json:"note,omitempty"` Note string `json:"note,omitempty"`
} }
// TalkSchemaVersion — the version this loader understands. Separate from the
// nudge fixture's SchemaVersion: the two fixtures have different shapes and
// change on different days, and one shared constant would force a bump on the
// fixture that did not move.
const TalkSchemaVersion = 1
// TalkFixture — the versioned envelope, same gating as Fixture. // TalkFixture — the versioned envelope, same gating as Fixture.
type TalkFixture struct { type TalkFixture struct {
SchemaVersion int `json:"schema_version"` SchemaVersion int `json:"schema_version"`
@@ -92,8 +98,8 @@ func LoadTalk() (TalkFixture, error) {
if err := json.Unmarshal(talkFixtureJSON, &f); err != nil { if err := json.Unmarshal(talkFixtureJSON, &f); err != nil {
return TalkFixture{}, fmt.Errorf("parse talk fixture: %w", err) return TalkFixture{}, fmt.Errorf("parse talk fixture: %w", err)
} }
if f.SchemaVersion != SchemaVersion { if f.SchemaVersion != TalkSchemaVersion {
return TalkFixture{}, fmt.Errorf("talk fixture schema_version %d, want %d", f.SchemaVersion, SchemaVersion) return TalkFixture{}, fmt.Errorf("talk fixture schema_version %d, want %d", f.SchemaVersion, TalkSchemaVersion)
} }
if len(f.Cases) == 0 { if len(f.Cases) == 0 {
return TalkFixture{}, fmt.Errorf("talk fixture has no cases") return TalkFixture{}, fmt.Errorf("talk fixture has no cases")
+11 -16
View File
@@ -142,19 +142,13 @@ func TestLLMTalkBaseline(t *testing.T) {
p := phraser.NewLLMPhraserAt(base, cfg) p := phraser.NewLLMPhraserAt(base, cfg)
defer p.Close() defer p.Close()
// Unreachable server is fatal here, not a logged warning, and that differs // The model id names the run in the report. Since Vikunja #397 every path
// from the nudge test on purpose. PhraseNudge returns its errors, so a dead // returns its errors, so a server that dies mid-run shows up in the Errors
// server there shows up honestly in the Errors column. PhraseChat and // column instead of scoring as bad phrasing — the before-and-after probe that
// PhraseQuery do NOT: they swallow every failure and return a canned string // used to stand in for that is gone.
// ("поговорили.", "не знаю.", "вот что я нашла: …"). So on these three paths
// a dead server produces a full report with 0 errors and a terrible score —
// a number that looks like bad phrasing and is really no phrasing at all.
// Refusing to score without a confirmed model is the only guard available
// until the phraser reports its failures (Vikunja #397).
model, err := llm.ModelID(ctx, base) model, err := llm.ModelID(ctx, base)
if err != nil { if err != nil {
t.Fatalf("no model at %s: %v — refusing to score, these paths hide their errors "+ t.Fatalf("no model at %s: %v", base, err)
"and would report a plausible-looking result off a dead server", base, err)
} }
t.Logf("scoring model %s at %s", model, base) t.Logf("scoring model %s at %s", model, base)
@@ -169,10 +163,11 @@ func TestLLMTalkBaseline(t *testing.T) {
} }
t.Log("\n" + rep.String() + "\nreplies:\n" + rep.Replies() + "\nfailures:\n" + rep.Failures()) t.Log("\n" + rep.String() + "\nreplies:\n" + rep.Replies() + "\nfailures:\n" + rep.Failures())
// And again afterwards: the run takes minutes, and a server that died or got // A run where nothing was phrased is not a low score, it is no measurement.
// OOM-killed halfway through would leave the first cases scored and the rest if rep.Errors == rep.Total {
// silently canned. Checking only at the start would not catch that. t.Fatalf("every case errored — nothing was measured, the score above is not a phrasing result")
if _, err := llm.ModelID(ctx, base); err != nil { }
t.Fatalf("model at %s went away during the run: %v — the score above is not trustworthy", base, err) if rep.Errors > 0 {
t.Logf("%d/%d cases errored — those are model failures, not phrasing failures", rep.Errors, rep.Total)
} }
} }
+80
View File
@@ -0,0 +1,80 @@
package phraser
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// isFallback — the text she says is picked from that entry's variants, so a test
// pins the entry rather than the wording. Pinning one line would make editing
// fallbacks_ru_v1.json break Go tests, which is the coupling this file removed.
func isFallback(t *testing.T, key, sources, got string) bool {
t.Helper()
return DefaultFallbacks().deck().matches(key, map[string]string{"sources": sources}, got)
}
// A dead server must be distinguishable from bad phrasing. Both PhraseChat and
// PhraseQuery keep the turn alive with canned text — and every one of those
// lines is also a legitimate reply, so the text alone cannot say which happened.
// The error is the only signal, and before Vikunja #397 it was dropped: the talk
// scorer reported a full run with zero errors off a server that answered nothing.
func TestPhrasingReportsTheFailureWithTheFallback(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "model not loaded", http.StatusServiceUnavailable)
}))
t.Cleanup(srv.Close)
p := NewLLMPhraserAt(srv.URL, Config{})
cases := []struct {
name string
call func() (string, error)
key string
sources string
}{
{"chat", func() (string, error) {
return p.PhraseChat(context.Background(), "как дела", nil)
}, fbChat, ""},
{"knowledge", func() (string, error) {
return p.PhraseQuery(context.Background(), "кто написал войну и мир", nil)
}, fbQueryUnknown, ""},
{"evidence", func() (string, error) {
return p.PhraseQuery(context.Background(), "сколько воды я выпил", []string{"два литра"})
}, fbQuerySources, "два литра"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got, err := c.call()
if err == nil {
t.Fatalf("no error from a dead server; the scorer would count this as bad phrasing")
}
if !isFallback(t, c.key, c.sources, got) {
t.Errorf("fallback text = %q, want a %q variant — the daemon still has to say something", got, c.key)
}
})
}
}
// An empty answer is a failure too: the server is up and produced no tokens,
// which is not an answer and must not score as one.
func TestEmptyKnowledgeAnswerIsAnError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"choices":[{"message":{"content":""}}]}`))
}))
t.Cleanup(srv.Close)
p := NewLLMPhraserAt(srv.URL, Config{})
got, err := p.PhraseQuery(context.Background(), "кто написал войну и мир", nil)
if err == nil {
t.Fatal("an empty response scored as an answer")
}
if !isFallback(t, fbQueryUnknown, "", got) {
t.Errorf("fallback text = %q, want a %q variant", got, fbQueryUnknown)
}
if !strings.Contains(err.Error(), "empty") {
t.Errorf("error = %v; want it to name the empty response", err)
}
}
+138
View File
@@ -0,0 +1,138 @@
package phraser
// The phrasing fallbacks — what she says when the model gave her nothing usable.
//
// They were four string literals spread across phraser.go, llmphraser.go and
// cmd/mavend/worldmodel.go. Every one of them is a line he hears out loud, so
// rewording one was a Go edit, a rebuild and a redeploy for what is product copy.
//
// The floor under the floor is deliberate. These strings exist because something
// already failed, so a broken template file must not be able to take the last
// words she has: every accessor falls back to the literal it replaced.
import (
_ "embed"
"log"
"math/rand"
"sync"
)
//go:embed fallbacks_ru_v1.json
var fallbackJSON []byte
// FallbackSchemaVersion — the version this code understands. Its own constant,
// not shared with the nudge templates or the eval fixtures: two files that change
// on different days cannot be versioned by one number (Vikunja #397).
const FallbackSchemaVersion = 1
// The entry keys. Every one of them is read by a method below, so a typo in the
// file is caught at load rather than at the moment she needs the words.
const (
fbChat = "chat"
fbQueryUnknown = "query_unknown"
fbQuerySources = "query_sources"
fbWorldGap = "world_gap"
)
// fbKeys — every key the code requires the file to define.
var fbKeys = []string{fbChat, fbQueryUnknown, fbQuerySources, fbWorldGap}
// hardFloor — the literal each key falls back to when the file is unusable.
// These are the exact strings that lived in Go before this file existed.
var hardFloor = map[string]string{
fbChat: "даже не знаю, что сказать.",
fbQueryUnknown: "не знаю.",
fbQuerySources: "вот что я нашла: {sources}",
fbWorldGap: "сейчас не могу ответить — большая модель недоступна, а придумывать не хочу.",
}
// Fallbacks picks a hand-written Russian fallback line. Safe for concurrent use.
type Fallbacks struct{ d *deck }
// LoadFallbacks reads the embedded file. Pass a source to make the picking
// reproducible in tests; nil seeds from the clock.
func LoadFallbacks(src rand.Source) (*Fallbacks, error) {
d, err := loadDeck(fallbackJSON, FallbackSchemaVersion, fbKeys, hardFloor, src)
if err != nil {
return nil, err
}
// query_sources is the one entry whose whole job is to read something back.
if err := d.requirePlaceholder(fbQuerySources, "{sources}"); err != nil {
return nil, err
}
return &Fallbacks{d: d}, nil
}
// deck reads through a nil *Fallbacks, which is the unloadable-file case.
func (f *Fallbacks) deck() *deck {
if f == nil {
return floorDeck(hardFloor)
}
return f.d
}
// Chat — nothing usable came back on the chat path.
func (f *Fallbacks) Chat() string { return f.deck().text(fbChat, nil) }
// Unknown — a question she cannot answer and will not guess at.
func (f *Fallbacks) Unknown() string { return f.deck().text(fbQueryUnknown, nil) }
// FromSources — read back what she was handed, because phrasing it failed.
func (f *Fallbacks) FromSources(sources string) string {
return f.deck().text(fbQuerySources, map[string]string{"sources": sources})
}
// WorldGap — the world model is the one configured to answer and it is not
// answering. Fixed wording: it names a specific gap, and a variant set here
// would let "the big model is asleep" drift into "I don't know".
func (f *Fallbacks) WorldGap() string { return f.deck().text(fbWorldGap, nil) }
// Variants returns every line the file can produce, for the persona scorer.
func (f *Fallbacks) Variants() []string { return f.deck().variants() }
// The process-wide instance. Package-level because these lines are needed on
// paths that have no phraser to hand — cmd/mavend names the world gap without
// one — and because a template file that is embedded and validated at load has
// nothing per-instance to configure.
var (
fallbackOnce sync.Once
fallbacks *Fallbacks
)
// DefaultFallbacks returns the shared instance, loading it on first use. A
// broken file logs once and leaves a nil *Fallbacks, which still answers from
// hardFloor — a daemon must not fail to boot over its own copy deck.
func DefaultFallbacks() *Fallbacks {
fallbackOnce.Do(func() {
fb, err := LoadFallbacks(nil)
if err != nil {
log.Printf("phraser: fallbacks unavailable, using the built-in lines: %v", err)
return
}
fallbacks = fb
})
return fallbacks
}
// ChatFallback — what she says when the chat path produced nothing.
func ChatFallback() string { return DefaultFallbacks().Chat() }
// UnknownFallback — what she says when she has no answer and will not invent one.
func UnknownFallback() string { return DefaultFallbacks().Unknown() }
// SourcesFallback — read the sources back rather than ship a broken fragment.
func SourcesFallback(sources string) string { return DefaultFallbacks().FromSources(sources) }
// WorldGap — what he hears when the world model is configured and unreachable.
func WorldGap() string { return DefaultFallbacks().WorldGap() }
// IsUnknownFallback reports whether text is one of her "I do not know" lines.
// The daemon tests read it to tell an answer from a shrug.
func IsUnknownFallback(text string) bool {
return DefaultFallbacks().deck().matches(fbQueryUnknown, nil, text)
}
// IsSourcesFallback reports whether text is sources read back verbatim.
func IsSourcesFallback(text, sources string) bool {
return DefaultFallbacks().deck().matches(fbQuerySources, map[string]string{"sources": sources}, text)
}
+42
View File
@@ -0,0 +1,42 @@
{
"schema_version": 1,
"name": "russian phrasing fallbacks v1",
"notes": [
"What she says when the model gave her nothing usable. Edit the wording here, no Go changes needed.",
"Rules: she is feminine about herself, he is a man addressed as ты. Never вы/вас/ваш, never plural imperatives, never он/его about him. No pet names.",
"These are heard after a failure, so they stay short and admit the gap. None of them may claim knowledge she does not have.",
"Placeholders: {sources} the notes or passages she was handed. A variant whose placeholder has no value is skipped, so every entry needs at least one variant with no placeholder — except query_sources, which exists only to read sources back.",
"fixed: true means exactly one variant and no picking. Used where the wording is load-bearing and must not drift between turns."
],
"entries": {
"chat": {
"variants": [
"даже не знаю, что сказать.",
"не могу найти слов.",
"мысль ускользнула, повтори?",
"у меня сейчас пусто в голове."
]
},
"query_unknown": {
"variants": [
"не знаю.",
"не знаю, честно.",
"тут я пас.",
"не скажу, не знаю."
]
},
"query_sources": {
"variants": [
"вот что я нашла: {sources}",
"нашла вот это: {sources}",
"есть только это: {sources}"
]
},
"world_gap": {
"fixed": true,
"variants": [
"сейчас не могу ответить — большая модель недоступна, а придумывать не хочу."
]
}
}
}
+74
View File
@@ -0,0 +1,74 @@
package phraser
import (
"math/rand"
"strings"
"testing"
)
// The embedded file must load, or the daemon speaks from hardFloor and nobody
// finds out until he hears the wrong words.
func TestFallbacksLoad(t *testing.T) {
fb, err := LoadFallbacks(rand.NewSource(1))
if err != nil {
t.Fatalf("LoadFallbacks: %v", err)
}
if got := fb.FromSources("два литра"); !strings.Contains(got, "два литра") {
t.Errorf("FromSources = %q, want the sources in it", got)
}
if fb.WorldGap() != hardFloor[fbWorldGap] {
t.Errorf("WorldGap = %q, want the fixed wording %q", fb.WorldGap(), hardFloor[fbWorldGap])
}
}
// A broken or missing file must not take her last words away: every accessor
// answers from the literal it replaced.
func TestNilFallbacksAnswerFromTheHardFloor(t *testing.T) {
var fb *Fallbacks
if got := fb.Chat(); got != hardFloor[fbChat] {
t.Errorf("Chat = %q, want %q", got, hardFloor[fbChat])
}
if got := fb.Unknown(); got != hardFloor[fbQueryUnknown] {
t.Errorf("Unknown = %q, want %q", got, hardFloor[fbQueryUnknown])
}
if got := fb.FromSources("два литра"); got != "вот что я нашла: два литра" {
t.Errorf("FromSources = %q", got)
}
if got := fb.WorldGap(); got != hardFloor[fbWorldGap] {
t.Errorf("WorldGap = %q", got)
}
}
// Hearing the identical words every time a request fails is how a failure stops
// registering as one.
func TestFallbacksDoNotRepeat(t *testing.T) {
fb, err := LoadFallbacks(rand.NewSource(7))
if err != nil {
t.Fatalf("LoadFallbacks: %v", err)
}
prev := fb.Chat()
for i := 0; i < 20; i++ {
got := fb.Chat()
if got == prev {
t.Fatalf("chat repeated %q on turn %d", got, i)
}
prev = got
}
}
// The acknowledgements load, fill his words into the frame, and answer from the
// floor when the file is gone.
func TestAcksLoad(t *testing.T) {
a, err := LoadAcks(rand.NewSource(1))
if err != nil {
t.Fatalf("LoadAcks: %v", err)
}
got := a.Say(AckFactValue, map[string]string{"key": "вода", "value": "2л"})
if !strings.Contains(got, "вода") || !strings.Contains(got, "2л") {
t.Errorf("Say(%s) = %q, want his key and value in it", AckFactValue, got)
}
var nilAcks *Acks
if got := nilAcks.Say(AckNote, nil); got != ackFloor[AckNote] {
t.Errorf("nil Acks said %q, want the floor %q", got, ackFloor[AckNote])
}
}
+27 -18
View File
@@ -5,6 +5,7 @@ import (
"bytes" "bytes"
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"log" "log"
@@ -26,6 +27,11 @@ import (
var listenRE = regexp.MustCompile(`listening on (https?://\S+)`) var listenRE = regexp.MustCompile(`listening on (https?://\S+)`)
// errEmptyResponse — the server answered and said nothing. Separate from a
// transport failure: the model is up and produced no tokens, which is still not
// an answer and must not score as one.
var errEmptyResponse = errors.New("phraser: empty response from the model")
type LLMPhraser struct { type LLMPhraser struct {
cfg Config cfg Config
client *http.Client client *http.Client
@@ -428,8 +434,11 @@ func (p *LLMPhraser) PhraseNudge(ctx context.Context, c loop.Candidate) (deliver
} }
// PhraseQuery prompts the LLM with the user's utterance and matching notes to // PhraseQuery prompts the LLM with the user's utterance and matching notes to
// compose a natural answer. Falls back to "вот что я нашла: <notes>" on any // compose a natural answer. On any LLM error it returns the fallback text —
// LLM error — better to give the raw data than silence. // "вот что я нашла: <notes>", or "не знаю." with no notes — and the error
// together. The daemon uses the text and keeps the turn alive; a caller that is
// measuring counts the failure. Until Vikunja #397 the error was dropped, so a
// dead server scored as bad phrasing.
func (p *LLMPhraser) PhraseQuery(ctx context.Context, utterance string, notes []string) (string, error) { func (p *LLMPhraser) PhraseQuery(ctx context.Context, utterance string, notes []string) (string, error) {
// Blank sources are no sources. A caller that hands over one empty string — // Blank sources are no sources. A caller that hands over one empty string —
// a page that fetched to nothing, a snippet trimmed away — used to take the // a page that fetched to nothing, a snippet trimmed away — used to take the
@@ -439,13 +448,15 @@ func (p *LLMPhraser) PhraseQuery(ctx context.Context, utterance string, notes []
if len(notes) == 0 { if len(notes) == 0 {
sys, prompt := p.knowledgePrompt(utterance) sys, prompt := p.knowledgePrompt(utterance)
resp, err := p.chatWithSystem(ctx, sys, prompt, 768) resp, err := p.chatWithSystem(ctx, sys, prompt, 768)
if err != nil || resp == "" { if err != nil {
return "не знаю.", nil return UnknownFallback(), fmt.Errorf("phrase query (knowledge): %w", err)
}
if resp == "" {
return UnknownFallback(), errEmptyResponse
} }
text, _, perr := parseResponseMood(resp) text, _, perr := parseResponseMood(resp)
if perr != nil { if perr != nil {
log.Printf("phraser: PhraseQuery: %v", perr) return UnknownFallback(), fmt.Errorf("phrase query (knowledge): %w", perr)
return "не знаю.", nil
} }
if text != "" { if text != "" {
return text, nil return text, nil
@@ -457,13 +468,12 @@ func (p *LLMPhraser) PhraseQuery(ctx context.Context, utterance string, notes []
text, _, perr := parseResponseMood(resp) text, _, perr := parseResponseMood(resp)
if err != nil || perr != nil { if err != nil || perr != nil {
// Read the notes out rather than ship a broken fragment. // Read the notes out rather than ship a broken fragment.
if perr != nil { cause := err
log.Printf("phraser: PhraseQuery: %v", perr) if cause == nil {
cause = perr
} }
if len(notes) == 1 { return SourcesFallback(strings.Join(notes, "; ")),
return "вот что я нашла: " + notes[0], nil fmt.Errorf("phrase query (evidence): %w", cause)
}
return "вот что я нашла: " + strings.Join(notes, "; "), nil
} }
if text != "" { if text != "" {
return text, nil return text, nil
@@ -472,8 +482,9 @@ func (p *LLMPhraser) PhraseQuery(ctx context.Context, utterance string, notes []
} }
// PhraseChat uses the LLM to respond conversationally, building a multi-turn // PhraseChat uses the LLM to respond conversationally, building a multi-turn
// message array from dialogue history + the current user utterance. Falls back // message array from dialogue history + the current user utterance. On any LLM
// to a simple greeting on any LLM error — better to say something than nothing. // error it returns both ChatFallback and the error, on the same rule as
// PhraseQuery: the fallback keeps the turn alive, the error stays visible.
func (p *LLMPhraser) PhraseChat(ctx context.Context, utterance string, history []dialogue.Turn) (string, error) { func (p *LLMPhraser) PhraseChat(ctx context.Context, utterance string, history []dialogue.Turn) (string, error) {
sys := chatSystemPrompt(p.cfg.ContextBlock) sys := chatSystemPrompt(p.cfg.ContextBlock)
msgs := []chatMsg{ msgs := []chatMsg{
@@ -490,13 +501,11 @@ func (p *LLMPhraser) PhraseChat(ctx context.Context, utterance string, history [
resp, err := p.chatWithMessages(ctx, msgs, 768) resp, err := p.chatWithMessages(ctx, msgs, 768)
if err != nil { if err != nil {
log.Printf("phraser: PhraseChat: %v", err) return ChatFallback(), fmt.Errorf("phrase chat: %w", err)
return "поговорили.", nil
} }
text, _, perr := parseResponseMood(resp) text, _, perr := parseResponseMood(resp)
if perr != nil { if perr != nil {
log.Printf("phraser: PhraseChat: %v", perr) return ChatFallback(), fmt.Errorf("phrase chat: %w", perr)
return "поговорили.", nil
} }
if text != "" { if text != "" {
return text, nil return text, nil
+3 -6
View File
@@ -70,18 +70,15 @@ func NewStub() *Stub { return &Stub{} }
// prompted response from the model. The history parameter is accepted but // prompted response from the model. The history parameter is accepted but
// ignored at the stub level (the production impl uses it for multi-turn). // ignored at the stub level (the production impl uses it for multi-turn).
func (s *Stub) PhraseChat(_ context.Context, _ string, _ []dialogue.Turn) (string, error) { func (s *Stub) PhraseChat(_ context.Context, _ string, _ []dialogue.Turn) (string, error) {
return "поговорили.", nil return ChatFallback(), nil
} }
// PhraseQuery returns a deterministic summary of the best matching notes. // PhraseQuery returns a deterministic summary of the best matching notes.
func (s *Stub) PhraseQuery(_ context.Context, _ string, notes []string) (string, error) { func (s *Stub) PhraseQuery(_ context.Context, _ string, notes []string) (string, error) {
if len(notes) == 0 { if len(notes) == 0 {
return "не знаю.", nil return UnknownFallback(), nil
} }
if len(notes) == 1 { return SourcesFallback(strings.Join(notes, "; ")), nil
return "вот что я нашла: " + notes[0], nil
}
return "вот что я нашла: " + strings.Join(notes, "; "), nil
} }
// Close implements Phraser.Close (no-op for the stub). // Close implements Phraser.Close (no-op for the stub).
+47
View File
@@ -0,0 +1,47 @@
package phraser
// The counted noun, in the form the number in front of it demands.
//
// Russian inflects a noun after a numeral, and the form depends on the last two
// digits: 1 градус, 2 градуса, 5 градусов, 11 градусов, 21 градус, 22 градуса.
// A line file cannot spell that out, so a count in a template splits into two
// placeholders — the number, and {word} filled from here.
//
// The rule lived once as hostWord in cmd/mavend/netscan.go, which meant the
// weather line said "градусов" for every temperature and was wrong for 1-4 and
// for every number ending in 1-4. One helper, every count site (Vikunja #521).
import "math"
// CountWord picks between the three forms n needs: one for 1, few for 2-4, many
// for 0, 5-20 and anything ending in those. A negative count reads its own
// magnitude, since minus does not change the noun: -2 градуса.
func CountWord(n int, one, few, many string) string {
if n < 0 {
n = -n
}
if n%100 >= 11 && n%100 <= 14 {
return many
}
switch n % 10 {
case 1:
return one
case 2, 3, 4:
return few
default:
return many
}
}
// Degrees — the noun for a temperature. Takes the reading as it arrives from a
// weather provider and counts by the whole degrees she is about to say, so the
// noun agrees with the number in the same sentence rather than with the reading
// behind it.
func Degrees(temp float64) string {
return CountWord(int(math.Round(temp)), "градус", "градуса", "градусов")
}
// Devices — the noun for a count of hosts on the LAN or of smart-home devices.
func Devices(n int) string {
return CountWord(n, "устройство", "устройства", "устройств")
}
+32
View File
@@ -0,0 +1,32 @@
package phraser
import "testing"
// The bug the helper exists for: the weather line said "градусов" for every
// reading, which is wrong for 1-4 and for every number ending in 1-4.
func TestDegreesAgreeWithTheReading(t *testing.T) {
for temp, want := range map[float64]string{
1: "градус", 1.4: "градус", 2: "градуса", 4: "градуса", 5: "градусов",
0: "градусов", 11: "градусов", 14: "градусов", 21: "градус",
22: "градуса", 25: "градусов", 101: "градус",
// Minus does not change the noun, and a reading rounds to the number
// she is about to say: -2.4° is "-2 градуса", not "-2 градусов".
-1: "градус", -2.4: "градуса", -5: "градусов", -11: "градусов",
} {
if got := Degrees(temp); got != want {
t.Errorf("Degrees(%v) = %q, want %q", temp, got, want)
}
}
}
func TestDevicesAgreeWithTheCount(t *testing.T) {
for n, want := range map[int]string{
1: "устройство", 2: "устройства", 4: "устройства", 5: "устройств",
11: "устройств", 12: "устройств", 21: "устройство", 22: "устройства",
25: "устройств", 111: "устройств", 101: "устройство", 0: "устройств",
} {
if got := Devices(n); got != want {
t.Errorf("Devices(%d) = %q, want %q", n, got, want)
}
}
}
+165
View File
@@ -0,0 +1,165 @@
package phraser
// The query answers and gaps — what a query source says when it answers from
// something other than the model, and what it says when it has nothing.
//
// Third family on the shared deck (deck.go), after the fallbacks and the
// acknowledgements. They were literals spread across actions_query.go and
// netscan.go, where the largest single site held two dozen of them.
//
// QueryUnknown is not the phraser's UnknownFallback, even though the two read
// the same today. Here she looked and found nothing; there she failed to phrase
// an answer she had. Two files, two entries, so rewording one leaves the other.
import (
_ "embed"
"log"
"math/rand"
"sync"
)
//go:embed query_ru_v1.json
var queryJSON []byte
// QuerySchemaVersion — this family's own version.
const QuerySchemaVersion = 1
// The entry keys.
const (
QueryUnknown = "query_unknown"
QueryOtherDay = "other_day"
QueryPersonalNone = "personal_none"
QueryFactWhen = "fact_when"
QueryFactValue = "fact_value"
QueryFound = "found"
QueryPageText = "page_text"
QueryPageBlocked = "page_blocked"
QueryPageEmpty = "page_empty"
QueryFeedsOff = "feeds_off"
QueryFeedsNew = "feeds_new"
QueryFeedsEmpty = "feeds_empty"
QueryFeedsTopic = "feeds_empty_topic"
QueryWeatherNow = "weather_now"
QueryWeatherOff = "weather_off"
QueryWeatherWhere = "weather_nolocation"
QueryNetEmpty = "net_empty"
QueryFailPlan = "fail_plan"
QueryFailNotes = "fail_notes"
QueryFailFeeds = "fail_feeds"
QueryFailCalendar = "fail_calendar"
QueryFailWeather = "fail_weather"
QueryFailAnswer = "fail_answer"
QueryFailPage = "fail_page"
QueryFailNetscan = "fail_netscan"
)
var queryKeys = []string{
QueryUnknown, QueryOtherDay, QueryPersonalNone, QueryFactWhen, QueryFactValue,
QueryFound, QueryPageText, QueryPageBlocked, QueryPageEmpty,
QueryFeedsOff, QueryFeedsNew, QueryFeedsEmpty, QueryFeedsTopic,
QueryWeatherNow, QueryWeatherOff, QueryWeatherWhere, QueryNetEmpty,
QueryFailPlan, QueryFailNotes, QueryFailFeeds, QueryFailCalendar,
QueryFailWeather, QueryFailAnswer, QueryFailPage, QueryFailNetscan,
}
// queryFloor — the literal each key falls back to when the file is unusable.
// It started as the exact strings that lived in Go before this file existed and
// now tracks the file's first variant instead, because a floor that keeps the
// wording review threw out would say it back on the one turn nobody is watching.
var queryFloor = map[string]string{
QueryUnknown: "ничего не нашла.",
QueryOtherDay: "про другой день так не отвечу — спроси целиком.",
QueryPersonalNone: "не знаю — не нашла у тебя такой записи.",
QueryFactWhen: "записала это {when}",
QueryFactValue: "у меня записано: {key} — {value}",
QueryFound: "вот что я нашла: {text}",
QueryPageText: "вот что на странице: {text}",
QueryPageBlocked: "эта страница закрыта для чтения — robots.txt не разрешает.",
QueryPageEmpty: "страница открылась, но читать там нечего.",
QueryFeedsOff: "ленты не настроены.",
QueryFeedsNew: "вот что нового: {items}",
QueryFeedsEmpty: "в лентах пока ничего нового.",
QueryFeedsTopic: "по этой теме в лентах пока ничего.",
QueryWeatherNow: "в {location} сейчас {temp} {word}, {condition}.",
QueryWeatherOff: "погода не настроена.",
QueryWeatherWhere: "для какого города?",
QueryNetEmpty: "в сети никого не нашла.",
QueryFailPlan: "не получилось собрать план.",
QueryFailNotes: "не получилось посмотреть записи.",
QueryFailFeeds: "не получилось посмотреть ленты.",
QueryFailCalendar: "не получилось проверить календарь.",
QueryFailWeather: "не получилось узнать погоду.",
QueryFailAnswer: "не получилось найти ответ.",
QueryFailPage: "не получилось прочитать страницу.",
QueryFailNetscan: "не получилось просканировать сеть.",
}
// Queries picks a hand-written Russian query line. Safe for concurrent use.
type Queries struct{ d *deck }
// LoadQueries reads the embedded file. Pass a source to make the picking
// reproducible in tests; nil seeds from the clock.
func LoadQueries(src rand.Source) (*Queries, error) {
d, err := loadDeck(queryJSON, QuerySchemaVersion, queryKeys, queryFloor, src)
if err != nil {
return nil, err
}
// The entries that exist to read something back. A variant without the
// placeholder would answer the question by dropping the answer.
for _, req := range []struct{ key, ph string }{
{QueryFactWhen, "{when}"}, {QueryFactValue, "{key}"}, {QueryFactValue, "{value}"},
{QueryFound, "{text}"}, {QueryPageText, "{text}"}, {QueryFeedsNew, "{items}"},
{QueryWeatherNow, "{location}"}, {QueryWeatherNow, "{temp}"},
{QueryWeatherNow, "{word}"}, {QueryWeatherNow, "{condition}"},
} {
if err := d.requirePlaceholder(req.key, req.ph); err != nil {
return nil, err
}
}
return &Queries{d: d}, nil
}
// deck reads through a nil *Queries, which is the unloadable-file case.
func (q *Queries) deck() *deck {
if q == nil {
return floorDeck(queryFloor)
}
return q.d
}
// Say returns one line for key, with the values filled into the frame.
func (q *Queries) Say(key string, vars map[string]string) string {
return q.deck().text(key, vars)
}
// Variants returns every line the file can produce, for the persona scorer.
func (q *Queries) Variants() []string { return q.deck().variants() }
var (
queryOnce sync.Once
queries *Queries
)
// DefaultQueries returns the shared instance, loading it on first use. A broken
// file logs once and leaves a nil *Queries, which still answers from queryFloor.
func DefaultQueries() *Queries {
queryOnce.Do(func() {
q, err := LoadQueries(nil)
if err != nil {
log.Printf("phraser: query lines unavailable, using the built-in ones: %v", err)
return
}
queries = q
})
return queries
}
// Q — one query line, the way every caller says it.
func Q(key string, vars map[string]string) string { return DefaultQueries().Say(key, vars) }
// IsQ reports whether text is a line key could have produced, for the tests.
func IsQ(key string, vars map[string]string, text string) bool {
return DefaultQueries().deck().matches(key, vars, text)
}
+107
View File
@@ -0,0 +1,107 @@
package phraser
import (
"math/rand"
"strings"
"testing"
)
func TestQueriesLoad(t *testing.T) {
q, err := LoadQueries(rand.NewSource(1))
if err != nil {
t.Fatalf("LoadQueries: %v", err)
}
for _, k := range queryKeys {
if got := q.Say(k, nil); got == "" {
t.Errorf("%s says nothing", k)
}
}
}
// The bug: net_empty carried {tail} in every variant, and a scan that finished
// the whole range has no caveat to put there. Whatever the file says, an answer
// he can hear has to come out — never braces, never nothing.
func TestNetEmptySaysSomethingWithNoTail(t *testing.T) {
q, err := LoadQueries(rand.NewSource(1))
if err != nil {
t.Fatalf("LoadQueries: %v", err)
}
for _, vars := range []map[string]string{nil, {"tail": ""}} {
for i := 0; i < 20; i++ {
got := q.Say(QueryNetEmpty, vars)
if got == "" || strings.ContainsAny(got, "{}") {
t.Fatalf("net_empty with vars %v said %q", vars, got)
}
}
}
}
// The other half: a caveat he was given is not dropped for a shorter wording.
func TestNetEmptyKeepsTheTailItIsGiven(t *testing.T) {
q, err := LoadQueries(rand.NewSource(1))
if err != nil {
t.Fatalf("LoadQueries: %v", err)
}
const tail = ", но успела посмотреть не всю сеть"
for i := 0; i < 20; i++ {
if got := q.Say(QueryNetEmpty, map[string]string{"tail": tail}); !strings.Contains(got, tail) {
t.Fatalf("net_empty dropped the tail: %q", got)
}
}
}
// query_unknown means she looked and found nothing. The phraser's fallback
// means she failed to phrase an answer she had. Two causes, two sentences, or
// the distinction the two files exist for is unobservable from the outside.
func TestQueryUnknownNeverRepeatsAPhrasingFallback(t *testing.T) {
q, err := LoadQueries(rand.NewSource(1))
if err != nil {
t.Fatalf("LoadQueries: %v", err)
}
f, err := LoadFallbacks(rand.NewSource(1))
if err != nil {
t.Fatalf("LoadFallbacks: %v", err)
}
failures := map[string]bool{}
for _, v := range f.Variants() {
failures[v] = true
}
for _, v := range q.d.file.Entries[QueryUnknown].Variants {
if failures[v] {
t.Errorf("query_unknown variant %q is also a phrasing failure line", v)
}
}
}
// The weather line splits the count into a number and a noun, so a variant that
// says the temperature without {word} is the hardcoded "градусов" coming back.
func TestWeatherLineCountsWithTheHelper(t *testing.T) {
q, err := LoadQueries(rand.NewSource(1))
if err != nil {
t.Fatalf("LoadQueries: %v", err)
}
for _, v := range q.d.file.Entries[QueryWeatherNow].Variants {
if strings.Contains(v, "градус") {
t.Errorf("weather_now variant %q spells the noun out instead of using {word}", v)
}
}
got := q.Say(QueryWeatherNow, map[string]string{
"location": "Москва", "temp": "1", "word": Degrees(1), "condition": "ясно",
})
if !strings.Contains(got, "1 градус,") {
t.Errorf("weather_now said %q, want the singular noun", got)
}
}
// No line spoken to him names a config key. She asks instead.
func TestNoQueryLineRecitesAConfigPath(t *testing.T) {
q, err := LoadQueries(rand.NewSource(1))
if err != nil {
t.Fatalf("LoadQueries: %v", err)
}
for _, v := range q.Variants() {
if strings.Contains(v, "voice.") || strings.Contains(v, "_location") {
t.Errorf("variant %q says a config path out loud", v)
}
}
}
+95
View File
@@ -0,0 +1,95 @@
{
"schema_version": 1,
"name": "russian query answers and gaps v1",
"notes": [
"What a query source says when it answers from something other than the model, and what it says when it has nothing. Edit the wording here, no Go changes needed.",
"Rules: she is feminine about herself, he is a man addressed as ты. Never вы/вас/ваш, never он/его about him. No pet names.",
"A gap names its own gap. \"the feeds are not configured\", \"the search failed\" and \"I do not know\" are different sentences and must never collapse into one entry.",
"query_unknown here is a real answer — she looked and found nothing. The phraser's identical-looking line in fallbacks_ru_v1.json is a failure to phrase. Two files, two entries, on purpose, so no variant here may repeat a string from there.",
"Placeholders: {key} {value} a stored fact, {when} when she wrote it, {items} what she found, {text} a passage, {location} {temp} {condition} the weather, {word} the counted noun in the form {temp} needs, {tail} a caveat about how the answer was gathered.",
"A count never carries a hardcoded noun. Russian inflects it — 1 градус, 2 градуса, 5 градусов — so the number goes in {temp} and the noun comes from the Go helper through {word}.",
"{tail} is optional, and an entry that can be said without it needs one variant carrying no placeholder at all. Otherwise nothing is fillable and she says nothing, which he hears as a hang.",
"fixed: true means exactly one variant and no picking. Used where the wording is load-bearing: the personal boundary, and the refusal to re-ask a question for another day."
],
"entries": {
"query_unknown": {
"variants": ["ничего не нашла.", "искала — не нашла."]
},
"other_day": {
"fixed": true,
"variants": ["про другой день так не отвечу — спроси целиком."]
},
"personal_none": {
"fixed": true,
"variants": ["не знаю — не нашла у тебя такой записи."]
},
"fact_when": {
"variants": ["записала это {when}"]
},
"fact_value": {
"variants": ["у меня записано: {key} — {value}", "вот что записано: {key} — {value}"]
},
"found": {
"variants": ["вот что я нашла: {text}", "нашла вот это: {text}", "есть такое: {text}"]
},
"page_text": {
"variants": ["вот что на странице: {text}", "на странице вот это: {text}"]
},
"page_blocked": {
"fixed": true,
"variants": ["эта страница закрыта для чтения — robots.txt не разрешает."]
},
"page_empty": {
"variants": ["страница открылась, но читать там нечего."]
},
"feeds_off": {
"variants": ["ленты не настроены."]
},
"feeds_new": {
"variants": ["вот что нового: {items}", "нового вот что: {items}"]
},
"feeds_empty": {
"variants": ["в лентах пока ничего нового.", "в лентах тихо."]
},
"feeds_empty_topic": {
"variants": ["по этой теме в лентах пока ничего.", "по этой теме в лентах тихо."]
},
"weather_now": {
"variants": ["в {location} сейчас {temp} {word}, {condition}.", "{location}: {temp} {word}, {condition}."]
},
"weather_off": {
"variants": ["погода не настроена."]
},
"weather_nolocation": {
"fixed": true,
"variants": ["для какого города?"]
},
"net_empty": {
"variants": ["в сети никого не нашла.", "в сети никого не нашла{tail}."]
},
"fail_plan": {
"variants": ["не получилось собрать план."]
},
"fail_notes": {
"variants": ["не получилось посмотреть записи."]
},
"fail_feeds": {
"variants": ["не получилось посмотреть ленты."]
},
"fail_calendar": {
"variants": ["не получилось проверить календарь."]
},
"fail_weather": {
"variants": ["не получилось узнать погоду."]
},
"fail_answer": {
"variants": ["не получилось найти ответ."]
},
"fail_page": {
"variants": ["не получилось прочитать страницу."]
},
"fail_netscan": {
"variants": ["не получилось просканировать сеть."]
}
}
}
+5 -3
View File
@@ -215,10 +215,12 @@ func TestSwap_RollbackFailureLeavesNoBackendAndDegrades(t *testing.T) {
if _, _, aerr := p.acquire(); !errors.Is(aerr, ErrNoBackend) { if _, _, aerr := p.acquire(); !errors.Is(aerr, ErrNoBackend) {
t.Errorf("acquire error = %v; want ErrNoBackend", aerr) t.Errorf("acquire error = %v; want ErrNoBackend", aerr)
} }
// Phrasing degrades to its fallback instead of failing the turn. // Phrasing degrades to its fallback instead of failing the turn, and since
// Vikunja #397 it reports the error next to that fallback so a measuring
// caller can tell "no model" from "bad phrasing".
got, err := p.PhraseChat(context.Background(), "привет", nil) got, err := p.PhraseChat(context.Background(), "привет", nil)
if err != nil { if !errors.Is(err, ErrNoBackend) {
t.Fatalf("PhraseChat after a total failure returned an error: %v", err) t.Errorf("PhraseChat error = %v; want ErrNoBackend alongside the fallback", err)
} }
if got == "" { if got == "" {
t.Error("PhraseChat returned empty; the fallback must still say something") t.Error("PhraseChat returned empty; the fallback must still say something")
+11 -11
View File
@@ -25,7 +25,10 @@
// the daemon seam (config wiring, no CoreAPI or voice-package change). // the daemon seam (config wiring, no CoreAPI or voice-package change).
package voice package voice
import "github.com/kami/maven/internal/router" import (
"github.com/kami/maven/internal/phraser"
"github.com/kami/maven/internal/router"
)
// Replier — the reactive reply phrasing seam. The daemon's reactive handler // Replier — the reactive reply phrasing seam. The daemon's reactive handler
// calls Reply with the router's Decision; the impl produces a terse reply // calls Reply with the router's Decision; the impl produces a terse reply
@@ -60,27 +63,24 @@ func (s *StubReplier) Reply(d router.Decision) string {
if !d.Slots.HasFn { if !d.Slots.HasFn {
return "не могу это сделать — не разобрала действие." return "не могу это сделать — не разобрала действие."
} }
return "ок, записала действие: " + d.Slots.Fn return phraser.Ack(phraser.AckAct, map[string]string{"fn": d.Slots.Fn})
case router.IntentReminder: case router.IntentReminder:
if d.Slots.HasTime { return phraser.Ack(phraser.AckReminder, nil)
return "напомню."
}
return "напомню."
case router.IntentFact: case router.IntentFact:
if d.Slots.HasKey { if d.Slots.HasKey {
if d.Slots.Value != "" { if d.Slots.Value != "" {
return "отметила: " + d.Slots.Key + " = " + d.Slots.Value return phraser.Ack(phraser.AckFactValue, map[string]string{"key": d.Slots.Key, "value": d.Slots.Value})
} }
return "отметила: " + d.Slots.Key return phraser.Ack(phraser.AckFactKey, map[string]string{"key": d.Slots.Key})
} }
return "записала факт." return phraser.Ack(phraser.AckFact, nil)
case router.IntentNote: case router.IntentNote:
return "сохранила заметку." return phraser.Ack(phraser.AckNote, nil)
case router.IntentQuery: case router.IntentQuery:
return "поискала в заметках — ничего не нашла." return "поискала в заметках — ничего не нашла."
case router.IntentChat: case router.IntentChat:
return "поговорили." // stub — LLMReplier replaces this return "поговорили." // stub — LLMReplier replaces this
default: default:
return "приняла." return phraser.Ack(phraser.AckGeneric, nil)
} }
} }