Compare commits
65 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2b3e34c7e8 | |||
| fa67dd82fe | |||
| 52fd218c70 | |||
| 33032b859a | |||
| 0d8cbaec01 | |||
| 1f38e71d1a | |||
| 2de5a339fb | |||
| 1ee9a930d3 | |||
| f48c2280bd | |||
| 888c1c6768 | |||
| 7cacbc8b21 | |||
| dc3cda666e | |||
| 1b354e9b39 | |||
| c07722266a | |||
| be758d9a59 | |||
| 8bbdcd2727 | |||
| f8947bef5a | |||
| b752ec037e | |||
| 4dbeca5a2e | |||
| 9e15ff36aa | |||
| 7955a41105 | |||
| a93a16d7b3 | |||
| dd63180e44 | |||
| 9d80a39a30 | |||
| b5b599e287 | |||
| bb51c28a19 | |||
| 549d4c8380 | |||
| 4766167c3a | |||
| f6f9e75eac | |||
| 1524991adc | |||
| 9da468810e | |||
| 7b4fb6229a | |||
| cdd81e2ad5 | |||
| 32d5f68710 | |||
| fdc18edd87 | |||
| a98d25ecac | |||
| 113508eaac | |||
| 6dc2622596 | |||
| dd91c6961c | |||
| 0560684b35 | |||
| c4cf06d610 | |||
| acd985323e | |||
| 598f4fc011 | |||
| 69db1cf849 | |||
| f5480e281b | |||
| cc72f69769 | |||
| b954e0cea6 | |||
| c82dbd1e65 | |||
| 69ecea19d5 | |||
| 8a13d189bb | |||
| 8e7aa0d451 | |||
| 00595c2211 | |||
| b54ccccd0a | |||
| 4cfef41541 | |||
| 0793955896 | |||
| 71a9a59403 | |||
| d3c63e6493 | |||
| c586346a60 | |||
| 23d89b2831 | |||
| 06ddf41228 | |||
| 2e64c8ce94 | |||
| 7695620a96 | |||
| 758fb6a3f0 | |||
| 0e75245205 | |||
| 8d816f47e9 |
@@ -171,6 +171,17 @@ p50 329ms** — better than the resident model and about 2.5× faster (`docs/eva
|
||||
Vikunja #485). The workstation is never assumed up, so both sets of numbers are live. Judge a
|
||||
routing change against the classifier and the resident model, since those are what always answer.
|
||||
|
||||
**The intended third engine is not a generative model** (owner's call, 05-08-2026, V-546,
|
||||
`docs/plans/18-routing-heads-on-e5-small.md`). Routing has a bounded output space, so it is
|
||||
classification, and the 118M multilingual-e5-small is already resident. Three heads on one
|
||||
forward pass: intent, mood, and BIO slot tags. Roughly 5e15 FLOPs to train, so 10 to 30
|
||||
minutes on the workstation. A 100M decoder from scratch is 10 to 20 GPU hours. Two things
|
||||
it buys that a decoder cannot. No grammar is needed, because a softmax cannot emit a value
|
||||
that does not exist. And max softmax is a calibratable confidence, where `Confidence: 1.0`
|
||||
was a hardcode. **Fine-tune a copy of the weights.** The resident embedder backs memory
|
||||
recall. Training it in place couples routing accuracy to recall@1, with nothing in the
|
||||
suite to name the trade.
|
||||
|
||||
`Confidence: 1.0` used to be hardcoded in `llmrouter.go`, so the LLM
|
||||
path could never ask for clarification (6/6 refusal cases missed on the fixture) — Vikunja
|
||||
#359. Fixed 31-07-2026 with structural signal (single-token utterance, keyless fact, act with
|
||||
@@ -214,6 +225,20 @@ New fixture cases ru-query-024 and ru-query-025. Classifier + ONNX baseline **56
|
||||
58/82 (70.7%)**, no case regressed, no new false clarify. The LLM arm was not measured (no
|
||||
llama-server in that run), so judge it again before quoting a cascade number.
|
||||
|
||||
Praxis taken off the model, 05-08-2026 (V-516). `PraxisGrammars()`
|
||||
(`internal/router/praxis.go`, wired in `buildRouter` before the capture marker because
|
||||
"отметь" is a capture verb) fills `Slots.Fn` with a Praxis capability name. Praxis reach
|
||||
was **0/12 and structurally so**: `handlePraxisAct` compares `Slots.Fn` to a capability
|
||||
alias, and that slot is filled from the deployment's enabled tool names, which no Praxis
|
||||
alias is on. Measured **16/30 → 27/30 overall, praxis 0/12 → 11/12, lifecycle 0/5 → 5/5**
|
||||
(`docs/evals/2026-08-05-praxis-reach.md`). Two rules to know before editing: a **stative**
|
||||
lifecycle word ("готово", "принято") needs an item named beside it, while a bare
|
||||
**imperative** ("закрывай") may ask which one. The bare arm additionally requires that
|
||||
the sentence name no object of its own, or "закрой шторы в комнате" goes to Praxis instead
|
||||
of the house. A demonstrative ("отметь это как сделанное") resolves against
|
||||
`h.surfacedItems` only when exactly one item was spoken. Otherwise the turn goes back to
|
||||
the cascade rather than transitioning the wrong item.
|
||||
|
||||
## LLM output contract
|
||||
|
||||
All phrasing paths emit `{"response":"...","mood":"..."}` (parsed in `replier_llm.go` and
|
||||
@@ -272,6 +297,23 @@ world questions, so she needs to read external sources. What replaces it:
|
||||
- **In the world, live search leads and the ZIMs are the fallback** (owner's call,
|
||||
2026-08-02). A self-hosted SearXNG (`search` block) answers first; the Kiwix ZIMs on
|
||||
homesrv answer when the search is empty, unreachable, or the line is down.
|
||||
**Verified with the line down on 2026-08-05** (V-508,
|
||||
`docs/evals/2026-08-05-kiwix-offline-fallback.md`): a stopped SearXNG costs nothing,
|
||||
the ZIM answers in the same turn budget. A blackholed host cost 8 seconds he waited
|
||||
through. So the connect phase alone is capped at `dialTimeout` (1.5s), while a slow
|
||||
instance that did connect keeps the full 8. **A Russian question reads
|
||||
`wikipedia_ru_all_maxi_2026-02` verbatim** through `kiwix.book_ru`. The RU→EN rewriter
|
||||
is the workaround for an English book and is skipped there. Kiwix catalog names come
|
||||
from the filename, not the `<name>` field.
|
||||
`Response.Empty()` is the whole gate and there is no quality threshold in front of it:
|
||||
the three signals one could read were measured on 2026-08-05 and none of them separate a
|
||||
real question from an invented one. Token overlap would cost "столица Франции" its
|
||||
answer, because the answer is Париж and that word is not in the question. See
|
||||
`docs/evals/2026-08-05-search-quality-signals.md` (V-539). **Which query source claimed
|
||||
a turn is readable on `/chat`** as a badge beside the reply, carried on
|
||||
`ipc.ChatReply.Source` and noted by `noteQuerySource` in `cmd/mavend/querysource.go`. It
|
||||
rides the context, so `handleText` keeps the one string signature the mic, telegram and
|
||||
the web share.
|
||||
- **External search is allowed and off unless configured**, like the weather and telegram
|
||||
capabilities. The code default is still off. `deploy/mavend.json` now ships a `search`
|
||||
block (owner's call, 2026-08-02), so it is on for this box and deleting the block turns
|
||||
|
||||
+11
@@ -82,11 +82,22 @@ FROM debian:trixie-slim AS runtime
|
||||
# tzdata so the TZ env (set in compose) resolves — otherwise Go can't load the
|
||||
# zone and time.Now() stays UTC, and mavend answers clock/date queries and
|
||||
# evaluates quiet-hours in UTC.
|
||||
#
|
||||
# TZ is a build arg as well as an env because the image was self-inconsistent
|
||||
# without it (V-545): compose set TZ=Europe/Samara and Go read it, but
|
||||
# /etc/localtime still pointed at Etc/UTC, so anything asking the system zone
|
||||
# instead of the environment answered UTC. The reminder path shells out to
|
||||
# python dateparser, which is exactly such a caller. Compose passes the same
|
||||
# zone it already declares, so the zone is written in one place.
|
||||
ARG TZ=Etc/UTC
|
||||
ENV TZ=$TZ
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates libvulkan1 mesa-vulkan-drivers libgomp1 tzdata \
|
||||
python3 python3-pip && \
|
||||
pip3 install --no-cache-dir --break-system-packages 'dateparser==1.4.1' && \
|
||||
apt-get purge -y --auto-remove python3-pip && \
|
||||
ln -snf "/usr/share/zoneinfo/$TZ" /etc/localtime && \
|
||||
echo "$TZ" > /etc/timezone && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# runtime native libs: whisper/ggml (incl. vulkan) are real files in deps/lib.
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
// Command labelgen labels utterances with the stage 0 grammars and prints JSONL.
|
||||
//
|
||||
// docs/plans/18-routing-heads-on-e5-small.md calls the labeled set the whole
|
||||
// project, and it names the stage 0 grammars as the high-precision label
|
||||
// functions to start from. This runs them — the real ones, in the real
|
||||
// buildRouter order — rather than a reimplementation, so a rule change moves
|
||||
// the training data with it.
|
||||
//
|
||||
// A grammar that declines leaves the line unlabeled. Those go to the model, and
|
||||
// keeping them is the point: a set labeled only by the rules teaches only the
|
||||
// rules.
|
||||
//
|
||||
// go run ./cmd/labelgen < utterances.txt > labeled.jsonl
|
||||
//
|
||||
// The wakeword-act grammar is absent, because its allowlist is the deployment's
|
||||
// enabled tool names and this tool has no deployment. Every other rule is here.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
// label is one output row. The grammar name rides along so a reviewer can see
|
||||
// which rule made the claim, and so a rule that turns out to be wrong can have
|
||||
// its rows pulled without re-running everything.
|
||||
type label struct {
|
||||
Utterance string `json:"utterance"`
|
||||
Intent string `json:"intent,omitempty"`
|
||||
Grammar string `json:"grammar,omitempty"`
|
||||
Key string `json:"key,omitempty"`
|
||||
Value string `json:"value,omitempty"`
|
||||
Fn string `json:"fn,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
Labeled bool `json:"labeled"`
|
||||
}
|
||||
|
||||
// grammars mirrors buildRouter's order in cmd/mavend/voicewire.go. Order is
|
||||
// load-bearing there and so it is here: the agenda rules must sit after the
|
||||
// clock rules, Praxis before the capture marker, the narrative rules last.
|
||||
func grammars() []router.Grammar {
|
||||
var g []router.Grammar
|
||||
g = append(g, router.SystemTimeDateGrammars()...)
|
||||
g = append(g, router.AgendaQueryGrammars()...)
|
||||
g = append(g, router.FeedQueryGrammar())
|
||||
g = append(g, router.TaskListGrammar())
|
||||
g = append(g, router.ListGrammars()...)
|
||||
g = append(g, router.ReminderGrammar())
|
||||
g = append(g, router.PraxisGrammars()...)
|
||||
g = append(g, router.TaskCaptureGrammar())
|
||||
g = append(g, router.NarrativeQueryGrammars()...)
|
||||
return g
|
||||
}
|
||||
|
||||
func match(gs []router.Grammar, utterance string) label {
|
||||
out := label{Utterance: utterance}
|
||||
for _, g := range gs {
|
||||
m := g.Pattern.FindStringSubmatch(utterance)
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
d, ok := g.Build(m)
|
||||
if !ok {
|
||||
continue // the rule saw its shape and declined it
|
||||
}
|
||||
out.Intent = string(d.Intent)
|
||||
out.Grammar = g.Name
|
||||
out.Key = d.Slots.Key
|
||||
out.Value = d.Slots.Value
|
||||
out.Fn = d.Slots.Fn
|
||||
out.Text = d.Slots.Text
|
||||
out.Labeled = true
|
||||
return out
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func main() {
|
||||
gs := grammars()
|
||||
in := bufio.NewScanner(os.Stdin)
|
||||
in.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
||||
out := bufio.NewWriter(os.Stdout)
|
||||
defer out.Flush()
|
||||
|
||||
enc := json.NewEncoder(out)
|
||||
var seen, labeled int
|
||||
for in.Scan() {
|
||||
line := strings.TrimSpace(in.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
seen++
|
||||
l := match(gs, line)
|
||||
if l.Labeled {
|
||||
labeled++
|
||||
}
|
||||
if err := enc.Encode(l); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "labelgen:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
if err := in.Err(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "labelgen:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
// Coverage on stderr, so the count is visible without polluting the JSONL.
|
||||
fmt.Fprintf(os.Stderr, "labelgen: %d/%d labeled by %d grammars\n", labeled, seen, len(gs))
|
||||
}
|
||||
@@ -61,7 +61,7 @@ func (h *reactiveHandler) actionChat(ctx context.Context, dec router.Decision) s
|
||||
if h.phraser == nil {
|
||||
return "поговорили."
|
||||
}
|
||||
history := h.chatHistory()
|
||||
history := h.chatHistory(ctx)
|
||||
// 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)
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"strconv"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/memory"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/store"
|
||||
@@ -89,7 +90,15 @@ func (h *reactiveHandler) actionFact(ctx context.Context, dec router.Decision) s
|
||||
// hears; storing the utterance meant recall answered with his own sentence
|
||||
// rather than the value. The utterance stays alongside as provenance —
|
||||
// readable on /trace, never the answer and never embedded.
|
||||
//
|
||||
// The vector id carries a timestamp, so a second tap of the same key adds a
|
||||
// row rather than replacing one, and recall then scores the superseded
|
||||
// value against the current one. CorrectValue and VoidLatestFact already
|
||||
// drop the key's vectors; an ordinary re-tap is the third way a value is
|
||||
// superseded and it did not (#493). Dropping first keeps exactly one vector
|
||||
// per key, which is what "recall answers with the current value" means.
|
||||
if h.recall.memStore != nil {
|
||||
pruneFactVectors(ctx, h.recall.memStore, dec.Slots.Key)
|
||||
text := store.FactRecallText(dec.Slots.Key, dec.Slots.Value)
|
||||
if vec, err := router.EmbedPassage(ctx, h.recall.embedder, text); err != nil {
|
||||
log.Printf("voice: embed fact for memory: %v", err)
|
||||
@@ -114,3 +123,29 @@ func (h *reactiveHandler) actionFact(ctx context.Context, dec router.Decision) s
|
||||
}
|
||||
return "" // replier phrases the success reply
|
||||
}
|
||||
|
||||
// vectorPruner — the part of the vector index this file needs and memory.Store
|
||||
// does not carry. store.MemoryStore implements it; the in-memory test double
|
||||
// may not, and a double that cannot prune is not a reason to fail a fact write.
|
||||
type vectorPruner interface {
|
||||
DeletePrefix(ctx context.Context, prefix string) (int64, error)
|
||||
}
|
||||
|
||||
// pruneFactVectors drops every vector for one fact key, so the insert that
|
||||
// follows is the only one left. Best-effort and silent on a store that cannot
|
||||
// prune: the fact row is the truth, and a stale vector costs a wrong recall,
|
||||
// not a lost fact.
|
||||
func pruneFactVectors(ctx context.Context, ms memory.Store, key string) {
|
||||
p, ok := ms.(vectorPruner)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
n, err := p.DeletePrefix(ctx, "fact:"+key+":")
|
||||
if err != nil {
|
||||
log.Printf("voice: prune memory vectors for %q: %v", key, err)
|
||||
return
|
||||
}
|
||||
if n > 0 {
|
||||
log.Printf("voice: %q superseded, dropped %d stale memory vector(s)", key, n)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/kami/maven/internal/crawl"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
@@ -161,8 +162,10 @@ func (h *reactiveHandler) actionQuery(ctx context.Context, dec router.Decision)
|
||||
// no query-source field, so a wrong answer could not be told from a
|
||||
// wrongly-ordered chain (Vikunja #474). Only the name is logged —
|
||||
// the utterance and the answer are already on the voice lines above
|
||||
// and below this one.
|
||||
// and below this one. The same name goes to the turn's sink when the
|
||||
// caller asked for one, so /chat can show it (V-539).
|
||||
log.Printf("voice: query claimed by source %q", src.name)
|
||||
noteQuerySource(ctx, src.name)
|
||||
return reply
|
||||
}
|
||||
}
|
||||
@@ -693,11 +696,19 @@ func (h *reactiveHandler) queryKiwix(ctx context.Context, t *queryTurn) (string,
|
||||
ctxK, cancel := context.WithTimeout(ctx, kiwixTimeout)
|
||||
defer cancel()
|
||||
|
||||
// The ZIMs are English and kiwix ranks by keyword overlap, not meaning, so
|
||||
// a Russian sentence matches nothing at all. The rewriter turns it into a
|
||||
// handful of English keywords with the resident model.
|
||||
// A Russian question reads the Russian ZIM verbatim when there is one
|
||||
// (V-508). Kiwix ranks by keyword overlap rather than meaning, so an English
|
||||
// book matches a Russian sentence not at all, and the rewriter exists to
|
||||
// turn the question into English keywords with the resident model. Against a
|
||||
// Russian book that is a translation of his own words back at him: it costs
|
||||
// a model call and drops whatever the keywords do not carry.
|
||||
book, verbatim := h.kiwix.book, false
|
||||
if h.kiwix.bookRU != "" && hasCyrillic(t.dec.Utterance) {
|
||||
book, verbatim = h.kiwix.bookRU, true
|
||||
}
|
||||
|
||||
pattern := t.dec.Utterance
|
||||
if h.kiwix.rewriter != nil {
|
||||
if h.kiwix.rewriter != nil && !verbatim {
|
||||
q, err := h.kiwix.rewriter.Rewrite(ctxK, t.dec.Utterance)
|
||||
if err != nil {
|
||||
// Fall through to the verbatim question rather than give up. It
|
||||
@@ -708,7 +719,7 @@ func (h *reactiveHandler) queryKiwix(ctx context.Context, t *queryTurn) (string,
|
||||
}
|
||||
}
|
||||
|
||||
hits, err := h.kiwix.client.Search(ctxK, pattern, h.kiwix.book, h.kiwix.max)
|
||||
hits, err := h.kiwix.client.Search(ctxK, pattern, book, h.kiwix.max)
|
||||
if err != nil {
|
||||
log.Printf("voice: kiwix: search %q: %v", pattern, err)
|
||||
return "", false
|
||||
@@ -720,7 +731,7 @@ func (h *reactiveHandler) queryKiwix(ctx context.Context, t *queryTurn) (string,
|
||||
// Logged on the way through, not only on failure. Without this there is no
|
||||
// way to tell from the outside whether an answer came off a ZIM or out of
|
||||
// the model's weights, and those are the two cases worth telling apart.
|
||||
log.Printf("voice: kiwix: %q → %d hits, top %q", pattern, len(hits), top.Title)
|
||||
log.Printf("voice: kiwix: %q in %q → %d hits, top %q", pattern, book, len(hits), top.Title)
|
||||
|
||||
// The top hit only, read as an article rather than as a snippet. Kiwix
|
||||
// builds its snippet from wherever the keyword matched, which on Wikipedia
|
||||
@@ -752,6 +763,18 @@ func (h *reactiveHandler) queryKiwix(ctx context.Context, t *queryTurn) (string,
|
||||
return reply, true
|
||||
}
|
||||
|
||||
// hasCyrillic reports whether the text carries a Cyrillic letter, which is the
|
||||
// whole test for "he asked this in Russian". A question mixing a Latin proper
|
||||
// noun into a Russian sentence is still Russian, so one letter is enough.
|
||||
func hasCyrillic(s string) bool {
|
||||
for _, r := range s {
|
||||
if unicode.Is(unicode.Cyrillic, r) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// queryPersonal — stop the walk on a question about him that his own data did
|
||||
// not answer.
|
||||
//
|
||||
|
||||
@@ -2,8 +2,11 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/lexicon"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
@@ -33,5 +36,24 @@ func (h *reactiveHandler) actionReminder(ctx context.Context, dec router.Decisio
|
||||
log.Printf("voice: create reminder: %v", err)
|
||||
return phraser.Ack(phraser.FailReminder, nil)
|
||||
}
|
||||
return ""
|
||||
// Phrased from the row, never from the utterance (Vikunja #507). The
|
||||
// replier only ever saw Slots.Text, so it named whatever hour the sentence
|
||||
// contained — including one the parser had rejected or read differently.
|
||||
// A confirmation naming an hour no row holds is worse than a clarify,
|
||||
// because he stops thinking about it.
|
||||
return reminderConfirm(dec.Slots.Time, h.now())
|
||||
}
|
||||
|
||||
// reminderConfirm — the confirmation for a reminder that exists, naming the
|
||||
// stored fire time. Deterministic on purpose: the one sentence that must match
|
||||
// a database row is not one to hand to a 1.7B.
|
||||
func reminderConfirm(fire, now time.Time) string {
|
||||
when := dayPrefix(now, fire)
|
||||
if when == "это" {
|
||||
// Further out than the day words reach — say the date instead of a
|
||||
// word that would be wrong.
|
||||
date := fmt.Sprintf("%d %s", fire.Day(), lexicon.MonthGenitive(int(fire.Month())))
|
||||
return "хорошо, напомню " + date + " в " + fire.Format("15:04") + "."
|
||||
}
|
||||
return "хорошо, напомню " + when + " в " + fire.Format("15:04") + "."
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ func (h *reactiveHandler) queryTasks(ctx context.Context, t *queryTurn) (string,
|
||||
for _, r := range spoken {
|
||||
cands = append(cands, dialogue.Candidate{Kind: "task", Ref: r.ID, Label: r.Text})
|
||||
}
|
||||
h.offerCandidates(cands)
|
||||
h.offerCandidates(ctx, cands)
|
||||
return tasks.FormatRU(ranked), true
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/delivery"
|
||||
"github.com/kami/maven/internal/loop"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
// The sev4 repeat path had no off switch (Vikunja #535): it re-sent every
|
||||
// pending telegram nudge every repeat_interval, and nothing in the tree could
|
||||
// ever mark one acked. None of what follows can be reproduced by hand without
|
||||
// sitting in front of the box for hours, so it is covered here or nowhere.
|
||||
|
||||
// seedDown writes one kuma monitor fact at ts. value is "down" or "up".
|
||||
func seedDown(t *testing.T, st *store.Store, ctx context.Context, value string, ts time.Time) {
|
||||
t.Helper()
|
||||
if _, err := st.SetValue(ctx, store.KindSelf, "service_down:db", "poll:uptimekuma", value, ts); err != nil {
|
||||
t.Fatalf("seed service_down:db=%s: %v", value, err)
|
||||
}
|
||||
}
|
||||
|
||||
// newAlarmTickLoop — like newTestTickLoop but with the ack tracker wired, which
|
||||
// the shared helper leaves nil. Without it RepeatUnacked returns early and the
|
||||
// repeat these tests are about never happens. The daemon wires it (main.go).
|
||||
func newAlarmTickLoop(t *testing.T, st *store.Store, sink delivery.Sink) *tickLoop {
|
||||
t.Helper()
|
||||
rules := loop.DefaultRules()
|
||||
g := loop.NewGatherer(st, rules)
|
||||
d := delivery.NewDispatcher(delivery.Config{
|
||||
Voice: sink, Ntfy: sink, Telegram: sink,
|
||||
Ack: st, Nudges: st, Reminders: st,
|
||||
})
|
||||
return newTickLoop(st, g, d, phraser.NewStub(), rules, time.Second, 5*time.Minute, 0, nil, nil, nil, nil)
|
||||
}
|
||||
|
||||
// telegramSends counts sends that went out on the telegram reach.
|
||||
func telegramSends(sink *fakeSink, rule string) int {
|
||||
n := 0
|
||||
for _, s := range sink.sends {
|
||||
if s.RuleName == rule {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// outcomes returns the outcome of every nudge row for a rule, newest first.
|
||||
func outcomes(t *testing.T, st *store.Store, ctx context.Context, rule string) []string {
|
||||
t.Helper()
|
||||
rows, err := st.RecentNudges(ctx, 50)
|
||||
if err != nil {
|
||||
t.Fatalf("recent nudges: %v", err)
|
||||
}
|
||||
var out []string
|
||||
for _, n := range rows {
|
||||
if n.Rule == rule {
|
||||
out = append(out, n.Outcome)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestAlarmStopsWhenTheServiceComesBackUp(t *testing.T) {
|
||||
// The condition clearing is the ending that should happen. StillTrue reads
|
||||
// the same DownServices helper the phraser reads, so the repeat stops on
|
||||
// exactly the monitor he was told about.
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
now := refNow()
|
||||
seedDown(t, st, ctx, "down", now.Add(-5*time.Minute))
|
||||
|
||||
sink := &fakeSink{}
|
||||
tl := newAlarmTickLoop(t, st, sink)
|
||||
tl.tick(ctx, now)
|
||||
if telegramSends(sink, "service_down") == 0 {
|
||||
t.Fatal("the alarm never went out; the rest of this test proves nothing")
|
||||
}
|
||||
|
||||
seedDown(t, st, ctx, "up", now.Add(time.Minute))
|
||||
sink.sends = nil
|
||||
tl.tick(ctx, now.Add(6*time.Minute)) // past repeat_interval
|
||||
|
||||
if n := telegramSends(sink, "service_down"); n != 0 {
|
||||
t.Fatalf("repeated %d time(s) after the service came back up; want 0", n)
|
||||
}
|
||||
for _, o := range outcomes(t, st, ctx, "service_down") {
|
||||
if o != store.NudgeResolved {
|
||||
t.Fatalf("nudge outcome = %q, want %q", o, store.NudgeResolved)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlarmStopsAtTheAgeCapWhileStillDown(t *testing.T) {
|
||||
// Still down, still un-acked, and nobody has answered in two hours. That is
|
||||
// not one more repeat away from being answered.
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
now := refNow()
|
||||
seedDown(t, st, ctx, "down", now.Add(-5*time.Minute))
|
||||
|
||||
sink := &fakeSink{}
|
||||
tl := newAlarmTickLoop(t, st, sink)
|
||||
tl.tick(ctx, now)
|
||||
|
||||
sink.sends = nil
|
||||
tl.tick(ctx, now.Add(6*time.Minute))
|
||||
if n := telegramSends(sink, "service_down"); n == 0 {
|
||||
t.Fatal("no repeat inside the cap; the cap is not what stopped it later")
|
||||
}
|
||||
|
||||
sink.sends = nil
|
||||
tl.tick(ctx, now.Add(maxAlarmAge+time.Minute))
|
||||
if n := telegramSends(sink, "service_down"); n != 0 {
|
||||
t.Fatalf("repeated %d time(s) past the %s cap; want 0", n, maxAlarmAge)
|
||||
}
|
||||
// Ignored, not resolved: nothing says the service got better.
|
||||
for _, o := range outcomes(t, st, ctx, "service_down") {
|
||||
if o != store.NudgeIgnored {
|
||||
t.Fatalf("nudge outcome = %q, want %q", o, store.NudgeIgnored)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAFlapRaisesAFreshAlarmRatherThanReviveTheClosedOne(t *testing.T) {
|
||||
// Down, up, down again. Closing the first run must not make the second run
|
||||
// unreportable, and must not silently reopen the closed rows either.
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
now := refNow()
|
||||
seedDown(t, st, ctx, "down", now.Add(-5*time.Minute))
|
||||
|
||||
sink := &fakeSink{}
|
||||
tl := newAlarmTickLoop(t, st, sink)
|
||||
tl.tick(ctx, now)
|
||||
first := len(outcomes(t, st, ctx, "service_down"))
|
||||
|
||||
seedDown(t, st, ctx, "up", now.Add(time.Minute))
|
||||
tl.tick(ctx, now.Add(2*time.Minute))
|
||||
if got := outcomes(t, st, ctx, "service_down"); len(got) != first {
|
||||
t.Fatalf("closing the run changed the row count: %d → %d", first, len(got))
|
||||
}
|
||||
|
||||
seedDown(t, st, ctx, "down", now.Add(25*time.Minute))
|
||||
sink.sends = nil
|
||||
tl.tick(ctx, now.Add(31*time.Minute))
|
||||
|
||||
if n := telegramSends(sink, "service_down"); n == 0 {
|
||||
t.Fatal("the second outage said nothing; the first alarm's ending swallowed it")
|
||||
}
|
||||
got := outcomes(t, st, ctx, "service_down")
|
||||
if len(got) <= first {
|
||||
t.Fatalf("no new nudge row for the second outage (%d rows, was %d)", len(got), first)
|
||||
}
|
||||
}
|
||||
|
||||
func TestARuleThatSaysNothingAboutItsConditionOnlyStopsOnAge(t *testing.T) {
|
||||
// StillTrue == nil means "I cannot tell you", never "it cleared". A rule
|
||||
// that says nothing must keep its alarm until the age cap, or a rule author
|
||||
// silences their own alarm by omission.
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
now := refNow()
|
||||
|
||||
sink := &fakeSink{}
|
||||
tl := newAlarmTickLoop(t, st, sink)
|
||||
tl.rules = []loop.Rule{{Name: "mute", Severity: loop.Sev4}} // no StillTrue
|
||||
|
||||
if _, err := st.RecordNudge(ctx, "mute", string(delivery.ChannelTelegram), "still bad", now); err != nil {
|
||||
t.Fatalf("record nudge: %v", err)
|
||||
}
|
||||
|
||||
live := tl.stopFinishedAlarms(ctx, []string{"mute"}, loop.State{}, now.Add(time.Minute))
|
||||
if len(live) != 1 {
|
||||
t.Fatalf("a nil StillTrue was read as resolved: live = %v", live)
|
||||
}
|
||||
|
||||
live = tl.stopFinishedAlarms(ctx, []string{"mute"}, loop.State{}, now.Add(maxAlarmAge+time.Minute))
|
||||
if len(live) != 0 {
|
||||
t.Fatalf("the age cap did not stop a rule with no StillTrue: live = %v", live)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// An empty attention list used to be answered "ничего не требует внимания"
|
||||
// unconditionally, which is an all-clear Maven had no way to know was true
|
||||
// (ECOSYSTEM-SPEC §2.6, Vikunja #540).
|
||||
func TestAttentionEmptyWithHealthySourcesIsAllClear(t *testing.T) {
|
||||
praxis := newFakePraxisWithSources(t, `[]`, `[{"source_id":"src_ntfy","health":"ok"}]`)
|
||||
h := newPraxisTestHandler(t, praxis)
|
||||
|
||||
reply := h.handlePraxisAct(context.Background(), praxisActDec("list_attention"))
|
||||
if !strings.Contains(reply, "ничего не требует внимания") {
|
||||
t.Fatalf("healthy and quiet should be an all-clear, got %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttentionEmptyWithAFailedSourceHedges(t *testing.T) {
|
||||
praxis := newFakePraxisWithSources(t, `[]`, `[
|
||||
{"source_id":"src_ntfy","health":"ok"},
|
||||
{"source_id":"src_llamacpp","health":"failed"},
|
||||
{"source_id":"src_imap","health":"stale"}
|
||||
]`)
|
||||
h := newPraxisTestHandler(t, praxis)
|
||||
|
||||
reply := h.handlePraxisAct(context.Background(), praxisActDec("list_attention"))
|
||||
if strings.Contains(reply, "ничего не требует внимания") {
|
||||
t.Fatalf("a failed source must not read as all-clear, got %q", reply)
|
||||
}
|
||||
for _, want := range []string{"src_llamacpp", "src_imap"} {
|
||||
if !strings.Contains(reply, want) {
|
||||
t.Errorf("reply names no %s: %q", want, reply)
|
||||
}
|
||||
}
|
||||
if strings.Contains(reply, "src_ntfy") {
|
||||
t.Errorf("the healthy source is named as a problem: %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// A Praxis that polls nothing knows nothing, which is the state the box is in.
|
||||
func TestAttentionEmptyWithNoSourcesHedges(t *testing.T) {
|
||||
praxis := newFakePraxisWithSources(t, `[]`, `[]`)
|
||||
h := newPraxisTestHandler(t, praxis)
|
||||
|
||||
reply := h.handlePraxisAct(context.Background(), praxisActDec("list_attention"))
|
||||
if strings.Contains(reply, "ничего не требует внимания") {
|
||||
t.Fatalf("a Praxis with no sources must not answer all-clear, got %q", reply)
|
||||
}
|
||||
if !strings.Contains(reply, "источник") {
|
||||
t.Errorf("reply does not say why she cannot tell: %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// The spec's own mechanism, which the deployed Praxis does not send yet: the
|
||||
// envelope's degraded array is believed without a second call.
|
||||
func TestAttentionDegradedEnvelopeIsReadWithoutASourcesCall(t *testing.T) {
|
||||
praxis := newFakePraxisWithSources(t,
|
||||
`{"items":[],"degraded":["src_metrics"]}`,
|
||||
`[{"source_id":"src_ntfy","health":"ok"}]`)
|
||||
h := newPraxisTestHandler(t, praxis)
|
||||
|
||||
reply := h.handlePraxisAct(context.Background(), praxisActDec("list_attention"))
|
||||
if !strings.Contains(reply, "src_metrics") {
|
||||
t.Fatalf("the envelope's degraded source is not named: %q", reply)
|
||||
}
|
||||
for _, r := range praxis.Requests() {
|
||||
if r.Path == "/api/v1/sources" {
|
||||
t.Error("sources was read even though the response carried degraded")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A sources endpoint that errors is not evidence of a fault: the attention call
|
||||
// itself succeeded, and hedging on it would make her permanently uncertain.
|
||||
func TestAttentionKeepsAllClearWhenSourcesCannotBeRead(t *testing.T) {
|
||||
praxis := newFakePraxisWithSources(t, `[]`, `[]`)
|
||||
praxis.SetRouteFault("/api/v1/sources", 500)
|
||||
h := newPraxisTestHandler(t, praxis)
|
||||
|
||||
reply := h.handlePraxisAct(context.Background(), praxisActDec("list_attention"))
|
||||
if !strings.Contains(reply, "ничего не требует внимания") {
|
||||
t.Fatalf("an unreadable sources list should leave the answer alone, got %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// Both response shapes decode, because the spec says one and the box sends the
|
||||
// other.
|
||||
func TestPraxisAttentionDecodesBothShapes(t *testing.T) {
|
||||
var bare praxisAttention
|
||||
if err := json.Unmarshal([]byte(`[{"id":"item_1"}]`), &bare); err != nil {
|
||||
t.Fatalf("bare array: %v", err)
|
||||
}
|
||||
if len(bare.Items) != 1 || len(bare.Degraded) != 0 {
|
||||
t.Errorf("bare array decoded as %+v", bare)
|
||||
}
|
||||
|
||||
var env praxisAttention
|
||||
if err := json.Unmarshal([]byte(`{"items":[{"id":"item_2"}],"degraded":["src_a"]}`), &env); err != nil {
|
||||
t.Fatalf("envelope: %v", err)
|
||||
}
|
||||
if len(env.Items) != 1 || len(env.Degraded) != 1 || env.Degraded[0] != "src_a" {
|
||||
t.Errorf("envelope decoded as %+v", env)
|
||||
}
|
||||
}
|
||||
|
||||
// A source that reports no health at all counts as healthy. A Praxis that never
|
||||
// fills the field would otherwise make every quiet turn a hedge.
|
||||
func TestUnhealthySourcesTreatsAnAbsentHealthFieldAsHealthy(t *testing.T) {
|
||||
praxis := newFakePraxisWithSources(t, `[]`, `[{"source_id":"src_a"},{"id":"src_b","health":"stale"}]`)
|
||||
bad, total, err := newPraxisClient(praxis.URL).UnhealthySources(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("UnhealthySources: %v", err)
|
||||
}
|
||||
if total != 2 {
|
||||
t.Errorf("total = %d, want 2", total)
|
||||
}
|
||||
if len(bad) != 1 || bad[0] != "src_b" {
|
||||
t.Errorf("bad = %v, want [src_b]", bad)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/llm"
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
// The chat path must answer when llama-server is down (Vikunja #45 step 4).
|
||||
// Both halves of a turn call the model — the router and the replier — and each
|
||||
// has its own floor: the cascade falls to the classifier, the replier falls to
|
||||
// the stub. This wires a client at a closed port so both floors are exercised
|
||||
// by a dial error rather than by a stubbed error value.
|
||||
func TestChatAnswersWithNoLlamaServer(t *testing.T) {
|
||||
h, _, _ := newClarifyHandler(t)
|
||||
dead := llm.New("http://127.0.0.1:1", 500*time.Millisecond)
|
||||
emb := router.NewHashEmbedder(1024)
|
||||
h.recall.embedder = emb
|
||||
h.router = buildRouter(emb, h.matcher, 0.55, pickLLMRouter(true, dead))
|
||||
h.replier = newLLMReplier(dead, nil)
|
||||
|
||||
ctx := withDialogueID(context.Background(), dialogueIDFor(sourceText, "web"))
|
||||
for _, utt := range []string{
|
||||
"привет",
|
||||
"запиши что кофе закончился",
|
||||
"что у меня сегодня",
|
||||
} {
|
||||
reply := h.handleText(ctx, "web", utt)
|
||||
if reply == "" {
|
||||
t.Errorf("%q answered with nothing; a dead model must degrade to the stub", utt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// daemonAPI.Chat reports an error only when the voice path was never wired.
|
||||
// A turn that reaches handleText always carries text, which is what keeps
|
||||
// mavweb's /api/chat off its error branch when the model is down.
|
||||
func TestChatAPIErrsOnlyWhenUnwired(t *testing.T) {
|
||||
d := &daemonAPI{}
|
||||
if _, err := d.Chat(context.Background(), "web", "привет"); err == nil {
|
||||
t.Fatal("an unwired daemon must say so")
|
||||
}
|
||||
d.chatFn = func(context.Context, string, string) string { return "" }
|
||||
if _, err := d.Chat(context.Background(), "web", "привет"); err != nil {
|
||||
t.Fatalf("a wired daemon must not error: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -305,9 +305,9 @@ func (h *reactiveHandler) reaskOrGiveUp(ctx context.Context, q *dialogue.Pending
|
||||
func (h *reactiveHandler) finishClarified(ctx context.Context, dec router.Decision) string {
|
||||
if h.dialogueSessions != nil {
|
||||
now := h.now()
|
||||
prev := h.dialogueSessions.Get(voiceDialogueID, now)
|
||||
prev := h.dialogueSessions.Get(dialogueIDOf(ctx), now)
|
||||
dec = followUpMerge(prev, dec, now)
|
||||
h.rememberTurn(prev, dec, now)
|
||||
h.rememberTurn(ctx, prev, dec, now)
|
||||
}
|
||||
reply := h.applyAction(ctx, dec)
|
||||
if reply == "" {
|
||||
@@ -323,7 +323,7 @@ func (h *reactiveHandler) finishClarified(ctx context.Context, dec router.Decisi
|
||||
// rememberTurn stores this turn as the dialogue session the next follow-up
|
||||
// inherits from, carrying up to 4 prior turns of history for anaphora. Capped so
|
||||
// one long conversation can't grow the session unboundedly.
|
||||
func (h *reactiveHandler) rememberTurn(prev *dialogue.Session, dec router.Decision, now time.Time) {
|
||||
func (h *reactiveHandler) rememberTurn(ctx context.Context, prev *dialogue.Session, dec router.Decision, now time.Time) {
|
||||
var history []dialogue.Turn
|
||||
if prev != nil {
|
||||
history = append(history, dialogue.Turn{
|
||||
@@ -359,7 +359,7 @@ func (h *reactiveHandler) rememberTurn(prev *dialogue.Session, dec router.Decisi
|
||||
if !dec.Continued && (dec.Intent == router.IntentSystem || dec.Intent == router.IntentQuery) {
|
||||
slots.Text = dec.Utterance
|
||||
}
|
||||
h.dialogueSessions.Put(voiceDialogueID, &dialogue.Session{
|
||||
h.dialogueSessions.Put(dialogueIDOf(ctx), &dialogue.Session{
|
||||
Intent: dialogue.Intent(dec.Intent),
|
||||
Slots: slots,
|
||||
Timestamp: now,
|
||||
|
||||
+14
-12
@@ -6,6 +6,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
@@ -118,13 +119,13 @@ func (h *reactiveHandler) confirmResolvers(ctx context.Context) []confirmResolve
|
||||
//
|
||||
// Acceptance itself is recorded by /routines, and the tick
|
||||
// loop nudges on the interval from there (Vikunja #366).
|
||||
return "поняла — подтверди на странице рутин, и начну напоминать."
|
||||
return phraser.C(phraser.ConfirmRoutineAuthed, nil)
|
||||
},
|
||||
no: func() string {
|
||||
if err := h.dataStore.DismissProposedRoutine(ctx, pr.routineID); err != nil {
|
||||
log.Printf("voice: dismiss proposed routine: %v", err)
|
||||
}
|
||||
return "хорошо, не буду."
|
||||
return phraser.C(phraser.ConfirmRoutineNo, nil)
|
||||
},
|
||||
},
|
||||
// Hexis execution confirm. Bound to the exact capability + target that
|
||||
@@ -137,7 +138,7 @@ func (h *reactiveHandler) confirmResolvers(ctx context.Context) []confirmResolve
|
||||
yes: func() string {
|
||||
return h.execHexis(ctx, hx.capabilityID, hx.capName, hx.entityID, hx.displayName)
|
||||
},
|
||||
no: func() string { return "отменила." },
|
||||
no: func() string { return phraser.C(phraser.ConfirmCancelled, nil) },
|
||||
},
|
||||
// Tool confirm.
|
||||
{
|
||||
@@ -150,16 +151,16 @@ func (h *reactiveHandler) confirmResolvers(ctx context.Context) []confirmResolve
|
||||
if err != nil {
|
||||
log.Printf("voice: tool %s (confirmed): %v", p.fn, err)
|
||||
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 != "" {
|
||||
return "готово: " + firstLine(out)
|
||||
return phraser.A(phraser.ActDoneOut, map[string]string{"out": firstLine(out)})
|
||||
}
|
||||
return "готово."
|
||||
return phraser.A(phraser.ActDone, nil)
|
||||
},
|
||||
no: func() string { return "отменила." },
|
||||
no: func() string { return phraser.C(phraser.ConfirmCancelled, nil) },
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -170,17 +171,18 @@ func (h *reactiveHandler) confirmResolvers(ctx context.Context) []confirmResolve
|
||||
func (h *reactiveHandler) proposeGap(ctx context.Context, dec router.Decision) string {
|
||||
name := firstWord(stripWake(dec.Utterance))
|
||||
if name == "" {
|
||||
return "не разобрала команду — попробуй иначе."
|
||||
return phraser.C(phraser.ProposeNoVerb, nil)
|
||||
}
|
||||
vars := map[string]string{"name": name}
|
||||
newly, err := h.api.ProposeTool(ctx, name, dec.Utterance, "", h.now())
|
||||
if err != nil {
|
||||
log.Printf("voice: propose tool %q: %v", name, err)
|
||||
return "команды «" + name + "» нет в списке разрешённых."
|
||||
return phraser.C(phraser.ProposeFailed, vars)
|
||||
}
|
||||
if newly {
|
||||
return "команды «" + name + "» нет в списке. Предложила её добавить — включи через клиент."
|
||||
return phraser.C(phraser.ProposeNew, vars)
|
||||
}
|
||||
return "команды «" + name + "» пока нет в списке — она уже предложена, включи через клиент."
|
||||
return phraser.C(phraser.ProposeAlready, vars)
|
||||
}
|
||||
|
||||
// confirmVerdict — the parse of a y/n confirm answer.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -149,12 +150,13 @@ func TestRememberTurnRefreshesTheTopic(t *testing.T) {
|
||||
now: func() time.Time { return contNow },
|
||||
dialogueSessions: dialogue.NewSessionStore(2 * time.Minute),
|
||||
}
|
||||
h.rememberTurn(nil, router.Decision{
|
||||
ctx := context.Background()
|
||||
h.rememberTurn(ctx, nil, router.Decision{
|
||||
Intent: router.IntentQuery, Utterance: "во сколько у меня встреча",
|
||||
}, contNow)
|
||||
// The second turn arrives with the first turn's Text already merged in.
|
||||
prev := h.dialogueSessions.Get(voiceDialogueID, contNow)
|
||||
h.rememberTurn(prev, router.Decision{
|
||||
h.rememberTurn(ctx, prev, router.Decision{
|
||||
Intent: router.IntentQuery,
|
||||
Utterance: "какие у меня планы",
|
||||
Slots: router.Slots{Text: "во сколько у меня встреча"},
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
// The list she read at the mic is not the list a browser is looking at
|
||||
// (Vikunja #45 step 3). The clarify store was keyed per reach in #466; the
|
||||
// dialogue session was still one slot for the box, so "второй" typed on the web
|
||||
// closed the second task she had recited out loud.
|
||||
func TestCandidatesDoNotCrossReaches(t *testing.T) {
|
||||
h, st, _ := newClarifyHandler(t)
|
||||
voiceCtx := withDialogueID(context.Background(), dialogueIDFor(sourceVoice, ""))
|
||||
webCtx := withDialogueID(context.Background(), dialogueIDFor(sourceText, "web"))
|
||||
|
||||
ids := seedTasks(t, st, "купить хлеб", "позвонить маме")
|
||||
putCandidates(h, voiceCtx, ids, "купить хлеб", "позвонить маме")
|
||||
|
||||
if reply, handled := h.resolveCandidate(webCtx, "первую сделал", sourceText); handled {
|
||||
t.Fatalf("a web turn picked from the list she read aloud: %q", reply)
|
||||
}
|
||||
live, err := st.ListTasks(context.Background(), "live")
|
||||
if err != nil {
|
||||
t.Fatalf("list tasks: %v", err)
|
||||
}
|
||||
if len(live) != 2 {
|
||||
t.Fatalf("%d tasks live, want 2 — the web turn moved one", len(live))
|
||||
}
|
||||
// The reach that was offered the list still owns it.
|
||||
if _, handled := h.resolveCandidate(voiceCtx, "первую сделал", sourceVoice); !handled {
|
||||
t.Fatal("the mic lost its own list")
|
||||
}
|
||||
}
|
||||
|
||||
// A selection writes a fact, so the fact must name the reach the words arrived
|
||||
// on. It said "tap:voice" for a typed turn.
|
||||
func TestCandidateProvenanceFollowsTheReach(t *testing.T) {
|
||||
h, st, _ := newClarifyHandler(t)
|
||||
ctx := withDialogueID(context.Background(), dialogueIDFor(sourceText, "web"))
|
||||
ids := seedTasks(t, st, "купить хлеб")
|
||||
putCandidates(h, ctx, ids, "купить хлеб")
|
||||
|
||||
if _, handled := h.resolveCandidate(ctx, "первую сделал", sourceText); !handled {
|
||||
t.Fatal("the pick was not acted on")
|
||||
}
|
||||
done, err := st.ListTasks(context.Background(), "done")
|
||||
if err != nil {
|
||||
t.Fatalf("list tasks: %v", err)
|
||||
}
|
||||
if len(done) != 1 {
|
||||
t.Fatalf("%d tasks done, want 1", len(done))
|
||||
}
|
||||
if by := done[0].ResolvedBy; by != string(sourceText) {
|
||||
t.Errorf("resolved_by = %q, want %q", by, sourceText)
|
||||
}
|
||||
}
|
||||
|
||||
// Anaphora is per reach too: an ellipsis typed on the web must not continue the
|
||||
// question he asked at the mic. Both surfaces stay usable at once, which is the
|
||||
// case a single-owner box actually hits — a phone open while he talks.
|
||||
func TestAnaphoraDoesNotCrossReaches(t *testing.T) {
|
||||
h, _, _ := newClarifyHandler(t)
|
||||
voiceCtx := withDialogueID(context.Background(), dialogueIDFor(sourceVoice, ""))
|
||||
webCtx := withDialogueID(context.Background(), dialogueIDFor(sourceText, "web"))
|
||||
now := h.now()
|
||||
|
||||
h.rememberTurn(voiceCtx, nil, router.Decision{
|
||||
Intent: router.IntentQuery, Utterance: "во сколько у меня встреча",
|
||||
}, now)
|
||||
|
||||
if sess := h.dialogueSessions.Get(dialogueIDOf(webCtx), now); sess != nil {
|
||||
t.Fatalf("the web reach inherited the mic's turn: %+v", sess)
|
||||
}
|
||||
sess := h.dialogueSessions.Get(dialogueIDOf(voiceCtx), now)
|
||||
if sess == nil || !strings.Contains(sess.Slots.Text, "встреча") {
|
||||
t.Fatalf("the mic lost its own turn: %+v", sess)
|
||||
}
|
||||
}
|
||||
+71
-4
@@ -263,18 +263,85 @@ func (c *praxisClient) getJSON(ctx context.Context, op, path string, out any) er
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *praxisClient) ListAttention(ctx context.Context, limit int) ([]map[string]any, error) {
|
||||
var out []map[string]any
|
||||
// praxisAttention — an attention response in either of the two shapes Praxis
|
||||
// may send (Vikunja #540).
|
||||
//
|
||||
// ECOSYSTEM-SPEC §2.6 says the response carries `degraded: [source_ids]` when a
|
||||
// source is failed or stale, and that Maven is required to say so rather than
|
||||
// report all-clear. The deployed Praxis answers with a bare JSON array and no
|
||||
// envelope at all, so both are decoded here: an array is the items, an object is
|
||||
// the spec envelope. This lands the Maven half without waiting on the server,
|
||||
// and the sources read below is what makes the hedge work meanwhile.
|
||||
type praxisAttention struct {
|
||||
Items []map[string]any
|
||||
Degraded []string
|
||||
}
|
||||
|
||||
func (a *praxisAttention) UnmarshalJSON(data []byte) error {
|
||||
trimmed := bytes.TrimSpace(data)
|
||||
if len(trimmed) > 0 && trimmed[0] == '[' {
|
||||
return json.Unmarshal(trimmed, &a.Items)
|
||||
}
|
||||
var env struct {
|
||||
Items []map[string]any `json:"items"`
|
||||
Degraded []string `json:"degraded"`
|
||||
}
|
||||
if err := json.Unmarshal(trimmed, &env); err != nil {
|
||||
return err
|
||||
}
|
||||
a.Items, a.Degraded = env.Items, env.Degraded
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *praxisClient) ListAttention(ctx context.Context, limit int) (praxisAttention, error) {
|
||||
var out praxisAttention
|
||||
err := c.getJSON(ctx, "attention", fmt.Sprintf("/api/v1/tools/attention?limit=%d", limit), &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
// praxisSource — one polled source, as much of it as the hedge needs. The tools
|
||||
// API does not expose sources, so this decodes the plain `/api/v1/sources` rows.
|
||||
type praxisSource struct {
|
||||
ID string `json:"id"`
|
||||
SourceID string `json:"source_id"`
|
||||
Health string `json:"health"`
|
||||
}
|
||||
|
||||
func (s praxisSource) name() string {
|
||||
if s.SourceID != "" {
|
||||
return s.SourceID
|
||||
}
|
||||
return s.ID
|
||||
}
|
||||
|
||||
// UnhealthySources reports which sources cannot be trusted to have reported,
|
||||
// and how many sources Praxis has at all (Vikunja #540).
|
||||
//
|
||||
// Only read when the attention list came back empty, which is the one turn where
|
||||
// an all-clear is at stake. A source whose health field is absent counts as
|
||||
// healthy: a Praxis that never reports health would otherwise make every quiet
|
||||
// turn a hedge, and an unreported field is not evidence of a fault. Everything it
|
||||
// does report other than "ok" — failed, stale, degraded, unknown — counts as
|
||||
// cannot-tell, because none of them mean the source has spoken.
|
||||
func (c *praxisClient) UnhealthySources(ctx context.Context) (bad []string, total int, err error) {
|
||||
var out []praxisSource
|
||||
if err := c.getJSON(ctx, "sources", "/api/v1/sources", &out); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
for _, s := range out {
|
||||
if s.Health != "" && s.Health != "ok" {
|
||||
bad = append(bad, s.name())
|
||||
}
|
||||
}
|
||||
return bad, len(out), nil
|
||||
}
|
||||
|
||||
// ListAttentionForEntity is ListAttention scoped to a single canonical Nexus
|
||||
// entity, so callers already holding a resolved entity_id (e.g. after
|
||||
// resolveEntityReference) can ask "what needs attention for this entity"
|
||||
// instead of filtering the unscoped list client-side.
|
||||
func (c *praxisClient) ListAttentionForEntity(ctx context.Context, entityID string, limit int) ([]map[string]any, error) {
|
||||
var out []map[string]any
|
||||
func (c *praxisClient) ListAttentionForEntity(ctx context.Context, entityID string, limit int) (praxisAttention, error) {
|
||||
var out praxisAttention
|
||||
err := c.getJSON(ctx, "attention_for_entity",
|
||||
fmt.Sprintf("/api/v1/tools/attention?limit=%d&entity_id=%s", limit, url.QueryEscape(entityID)), &out)
|
||||
return out, err
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -105,6 +106,13 @@ func (h *reactiveHandler) handlePraxisAct(ctx context.Context, dec router.Decisi
|
||||
ctx = withCorrelationID(ctx, newCorrelationID())
|
||||
}
|
||||
px := h.ecosystem.praxis
|
||||
dec, ok := h.resolveSurfacedPosition(dec)
|
||||
if !ok {
|
||||
// A demonstrative with no digest behind it. "я это сделал" is a sentence
|
||||
// about his day, so the rest of the cascade gets it back rather than
|
||||
// hearing "какой пункт?" for something that was never about a пункт.
|
||||
return ""
|
||||
}
|
||||
for _, capability := range praxisCapabilities {
|
||||
for _, alias := range capability.aliases() {
|
||||
if alias == dec.Slots.Fn {
|
||||
@@ -154,18 +162,23 @@ func (listAttentionCapability) aliases() []string {
|
||||
|
||||
func (listAttentionCapability) handle(ctx context.Context, h *reactiveHandler, px *praxisClient, _ router.Decision) string {
|
||||
started := h.now()
|
||||
items, err := px.ListAttention(ctx, 20)
|
||||
att, err := px.ListAttention(ctx, 20)
|
||||
if err != nil {
|
||||
log.Printf("ecosystem: praxis attention: %v", err)
|
||||
h.recordEcosystemTrace(ctx, "praxis", "list_attention", traceStatusForError(err),
|
||||
started, traceErrorFields(err))
|
||||
return phraser.A(phraser.AttentionFail, nil)
|
||||
}
|
||||
items := att.Items
|
||||
if len(items) == 0 {
|
||||
if hedge := h.attentionCannotTell(ctx, px, att.Degraded, started); hedge != "" {
|
||||
return hedge
|
||||
}
|
||||
return phraser.A(phraser.AttentionNone, nil)
|
||||
}
|
||||
h.recordPraxisTrace(ctx, "list_attention", started, map[string]any{"count": len(items)})
|
||||
var parts []string
|
||||
var spoken []string
|
||||
for _, item := range items {
|
||||
title, _ := item["title"].(string)
|
||||
// importance arrives as JSON number ⇒ float64 over the HTTP contract.
|
||||
@@ -190,11 +203,15 @@ func (listAttentionCapability) handle(ctx context.Context, h *reactiveHandler, p
|
||||
// (ECOSYSTEM-SPEC.md §2.3: surfaced != acknowledged). Best-effort:
|
||||
// a failed surface call must not block delivering the digest.
|
||||
if id, ok := item["id"].(string); ok && id != "" {
|
||||
// Recorded in the order she says them, and only for items she could
|
||||
// say: an item skipped above has no position in what he heard (#516).
|
||||
spoken = append(spoken, id)
|
||||
if _, err := px.Surface(ctx, id); err != nil {
|
||||
log.Printf("ecosystem: praxis surface %s: %v", id, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
h.rememberSurfaced(spoken)
|
||||
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
|
||||
@@ -299,14 +316,14 @@ func (entityAttentionCapability) handle(ctx context.Context, h *reactiveHandler,
|
||||
}
|
||||
|
||||
queried := h.now()
|
||||
items, err := px.ListAttentionForEntity(ctx, entityID, 20)
|
||||
att, err := px.ListAttentionForEntity(ctx, entityID, 20)
|
||||
if err != nil {
|
||||
log.Printf("ecosystem: praxis attention for %s: %v", entityID, err)
|
||||
h.recordEcosystemTrace(ctx, "praxis", "entity_attention", traceStatusForError(err),
|
||||
queried, mergeFields(traceErrorFields(err), map[string]any{"entity_id": entityID}))
|
||||
return phraser.A(phraser.AttentionFailEntity, map[string]string{"name": displayName})
|
||||
}
|
||||
items, scoped := scopedToEntity(items, entityID)
|
||||
items, scoped := scopedToEntity(att.Items, entityID)
|
||||
if !scoped {
|
||||
// A Praxis old enough to ignore an unknown query parameter answers the
|
||||
// scoped question with the unscoped list. Reading that back as "по
|
||||
@@ -339,6 +356,11 @@ func (entityAttentionCapability) handle(ctx context.Context, h *reactiveHandler,
|
||||
parts = append(parts, known)
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
// The scoped list is as exposed to a silent source as the unscoped one,
|
||||
// and a per-entity all-clear is the more convincing of the two (#540).
|
||||
if hedge := h.attentionCannotTell(ctx, px, att.Degraded, queried); hedge != "" {
|
||||
return hedge
|
||||
}
|
||||
return phraser.A(phraser.AttentionNoneEntity, map[string]string{"name": displayName})
|
||||
}
|
||||
return phraser.A(phraser.AttentionListEntity, map[string]string{"name": displayName, "items": strings.Join(parts, "; ")})
|
||||
@@ -783,3 +805,105 @@ func (h *reactiveHandler) hexisBeforeClarify(ctx context.Context, dec router.Dec
|
||||
}
|
||||
return h.handleHexisAct(ctx, dec)
|
||||
}
|
||||
|
||||
// attentionCannotTell returns the hedge to say instead of an all-clear, or ""
|
||||
// when an empty attention list really does mean nothing needs looking at
|
||||
// (ECOSYSTEM-SPEC §2.6, Vikunja #540).
|
||||
//
|
||||
// "Nothing needs attention" and "I cannot currently tell" are different answers
|
||||
// and only one of them was ever said. The spec's mechanism is a `degraded` array
|
||||
// on the attention response, which the deployed Praxis does not send, so the
|
||||
// source health read is the half that works today. It costs one HTTP call and
|
||||
// only on the empty-list turn, which is the only turn where an all-clear is at
|
||||
// stake.
|
||||
//
|
||||
// A failed sources read is deliberately NOT a hedge. The attention call itself
|
||||
// succeeded, and not being able to ask about health is not evidence of a fault —
|
||||
// hedging on it would turn one flaky endpoint into a permanently uncertain
|
||||
// assistant.
|
||||
func (h *reactiveHandler) attentionCannotTell(ctx context.Context, px *praxisClient, degraded []string, started time.Time) string {
|
||||
if len(degraded) > 0 {
|
||||
h.recordPraxisTrace(ctx, "attention_degraded", started, map[string]any{
|
||||
"degraded": strings.Join(degraded, ","), "source": "response",
|
||||
})
|
||||
return phraser.A(phraser.AttentionDegraded, map[string]string{"items": strings.Join(degraded, ", ")})
|
||||
}
|
||||
bad, total, err := px.UnhealthySources(ctx)
|
||||
if err != nil {
|
||||
log.Printf("ecosystem: praxis sources: %v", err)
|
||||
return ""
|
||||
}
|
||||
if total == 0 {
|
||||
// A Praxis that polls nothing knows nothing, so its silence is not an
|
||||
// all-clear either. This is the state the box is in as of 2026-08-05:
|
||||
// /api/v1/sources answers with an empty array.
|
||||
h.recordPraxisTrace(ctx, "attention_no_sources", started, map[string]any{"sources": 0})
|
||||
return phraser.A(phraser.AttentionNoSources, nil)
|
||||
}
|
||||
if len(bad) > 0 {
|
||||
h.recordPraxisTrace(ctx, "attention_degraded", started, map[string]any{
|
||||
"degraded": strings.Join(bad, ","), "sources": total, "source": "health",
|
||||
})
|
||||
return phraser.A(phraser.AttentionDegraded, map[string]string{"items": strings.Join(bad, ", ")})
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// rememberSurfaced records the item ids she just read out, replacing whatever the
|
||||
// previous digest left. Called with the ids in speaking order (Vikunja #516).
|
||||
func (h *reactiveHandler) rememberSurfaced(ids []string) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
h.surfacedItems = ids
|
||||
}
|
||||
|
||||
// resolveSurfacedPosition turns a positional item reference into a Praxis item
|
||||
// id, using the list she last read out.
|
||||
//
|
||||
// The router names a position and not an id, because only the daemon has the
|
||||
// list: PraxisGrammars fills the value slot with "2", "last" or "this". An id is
|
||||
// left alone, since "item_ab12" is already one.
|
||||
//
|
||||
// The second return says whether the turn is still Praxis's. A position that
|
||||
// names nothing keeps the turn and clears the slot, so the capability answers its
|
||||
// own "какой пункт?" — he said "второй пункт" and deserves to hear that there is
|
||||
// no second one. A demonstrative that resolves to nothing gives the turn BACK,
|
||||
// because "я это сделал" was probably never about a пункт at all. "это" also
|
||||
// needs the list to hold exactly one item: pointing at one of five is a guess,
|
||||
// and a wrong guess here transitions the wrong item.
|
||||
func (h *reactiveHandler) resolveSurfacedPosition(dec router.Decision) (router.Decision, bool) {
|
||||
ref := dec.Slots.Value
|
||||
if ref == "" || strings.HasPrefix(ref, "item") {
|
||||
return dec, true
|
||||
}
|
||||
h.mu.Lock()
|
||||
ids := h.surfacedItems
|
||||
h.mu.Unlock()
|
||||
|
||||
idx := -1
|
||||
switch {
|
||||
case ref == "this":
|
||||
if len(ids) != 1 {
|
||||
log.Printf("ecosystem: praxis \"это\" has no single item (%d surfaced)", len(ids))
|
||||
return dec, false
|
||||
}
|
||||
idx = 0
|
||||
case ref == "last":
|
||||
idx = len(ids) - 1
|
||||
default:
|
||||
n, err := strconv.Atoi(ref)
|
||||
if err != nil || n < 1 {
|
||||
// Neither a position nor an id: leave it for the capability to
|
||||
// reject rather than silently rewriting what he said.
|
||||
return dec, true
|
||||
}
|
||||
idx = n - 1
|
||||
}
|
||||
if idx < 0 || idx >= len(ids) {
|
||||
log.Printf("ecosystem: praxis position %q has no item (%d surfaced)", ref, len(ids))
|
||||
dec.Slots.Value = ""
|
||||
return dec, true
|
||||
}
|
||||
dec.Slots.Value = ids[idx]
|
||||
return dec, true
|
||||
}
|
||||
|
||||
@@ -167,3 +167,69 @@ func TestActionFact_AskedToRememberAComplaintStillWrites(t *testing.T) {
|
||||
t.Fatalf("an explicit capture was refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A second tap of the same key supersedes the first, so recall must hold one
|
||||
// vector and it must be the new value (Vikunja #493). Before this the id
|
||||
// carried a timestamp, both rows stayed, and the superseded value went on
|
||||
// competing for the turn.
|
||||
func TestActionFact_ARetapSupersedesTheOldVector(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
h, _ := newFactGateHandler(t, time.Now())
|
||||
|
||||
h.actionFact(ctx, router.Decision{
|
||||
Intent: router.IntentFact,
|
||||
Utterance: "запиши что я пил воду",
|
||||
Slots: router.Slots{Key: "water", HasKey: true, Value: `"250мл"`},
|
||||
})
|
||||
// A later tap of the same key. The clock moves, so the old id and the new
|
||||
// one differ — which is exactly what used to leave two rows behind.
|
||||
h.now = func() time.Time { return time.Now().Add(time.Hour) }
|
||||
h.actionFact(ctx, router.Decision{
|
||||
Intent: router.IntentFact,
|
||||
Utterance: "запиши что я пил воду",
|
||||
Slots: router.Slots{Key: "water", HasKey: true, Value: `"500мл"`},
|
||||
})
|
||||
|
||||
hits, err := h.recall.memStore.Search(ctx, mustEmbedPassage(t, h, "вода"), 5)
|
||||
if err != nil {
|
||||
t.Fatalf("memory search: %v", err)
|
||||
}
|
||||
if len(hits) != 1 {
|
||||
t.Fatalf("want one vector for the key, got %d: %+v", len(hits), hits)
|
||||
}
|
||||
if got := hits[0].Meta["text"]; got != "water — 500мл" {
|
||||
t.Errorf("indexed text = %q, want the current value", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Another key is not this key. A prefix delete that widened would take the
|
||||
// whole index with it.
|
||||
func TestActionFact_ARetapLeavesOtherKeysAlone(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
h, _ := newFactGateHandler(t, time.Now())
|
||||
|
||||
h.actionFact(ctx, router.Decision{
|
||||
Intent: router.IntentFact,
|
||||
Utterance: "запиши что я обедал",
|
||||
Slots: router.Slots{Key: "meal", HasKey: true, Value: `"суп"`},
|
||||
})
|
||||
h.actionFact(ctx, router.Decision{
|
||||
Intent: router.IntentFact,
|
||||
Utterance: "запиши что я пил воду",
|
||||
Slots: router.Slots{Key: "water", HasKey: true, Value: `"250мл"`},
|
||||
})
|
||||
|
||||
hits, err := h.recall.memStore.Search(ctx, mustEmbedPassage(t, h, "обед"), 5)
|
||||
if err != nil {
|
||||
t.Fatalf("memory search: %v", err)
|
||||
}
|
||||
var found bool
|
||||
for _, hit := range hits {
|
||||
if hit.Meta["text"] == "meal — суп" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("writing water dropped the meal vector: %+v", hits)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,6 +178,14 @@ func (fs *fakeServer) Requests() []capturedRequest {
|
||||
return out
|
||||
}
|
||||
|
||||
// ResetRequests drops the captured requests, so a test can assert about one
|
||||
// turn without subtracting the setup turn's calls.
|
||||
func (fs *fakeServer) ResetRequests() {
|
||||
fs.mu.Lock()
|
||||
defer fs.mu.Unlock()
|
||||
fs.requests = nil
|
||||
}
|
||||
|
||||
func jsonHandler(status int, body string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
@@ -330,7 +338,17 @@ func newFakeNexus(t *testing.T, resolveBody string) *fakeServer {
|
||||
// Maven's praxisClient calls. Every route returns its fixed body until a
|
||||
// fault is injected via SetFault.
|
||||
func newFakePraxis(t *testing.T, attentionBody string) *fakeServer {
|
||||
// One healthy source by default: an empty attention list only means
|
||||
// all-clear when something is actually polling (Vikunja #540), and the
|
||||
// other tests here are about attention rather than about source health.
|
||||
return newFakePraxisWithSources(t, attentionBody, `[{"source_id":"src_ntfy","health":"ok"}]`)
|
||||
}
|
||||
|
||||
// newFakePraxisWithSources is newFakePraxis with the /api/v1/sources body
|
||||
// under the test's control, for the degraded and no-sources hedges.
|
||||
func newFakePraxisWithSources(t *testing.T, attentionBody, sourcesBody string) *fakeServer {
|
||||
return newFakeServer(t, map[string]http.HandlerFunc{
|
||||
"GET /api/v1/sources": jsonHandler(http.StatusOK, sourcesBody),
|
||||
"GET /api/v1/tools/attention": jsonHandler(http.StatusOK, attentionBody),
|
||||
"GET /api/v1/tools/changes": jsonHandler(http.StatusOK, `[]`),
|
||||
"POST /api/v1/tools/surface": jsonHandler(http.StatusOK, `{}`),
|
||||
|
||||
+20
-5
@@ -8,10 +8,10 @@ import (
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
// voiceDialogueID — the dialogue-session key for the microphone, and the
|
||||
// clarify key for it too. This is a single-user box (ponytail), so one slot
|
||||
// suffices; a second speaker would need per-speaker ids, which waits on
|
||||
// voice-print attribution (see PROGRESS multi-user deferral).
|
||||
// voiceDialogueID — the dialogue-session and clarify key for the microphone.
|
||||
// This is a single-user box (ponytail), so one slot per reach suffices; a
|
||||
// second speaker would need per-speaker ids, which waits on voice-print
|
||||
// attribution (see PROGRESS multi-user deferral).
|
||||
const voiceDialogueID = "voice"
|
||||
|
||||
// textDialogueID — the clarify key for a text turn that named no conversation.
|
||||
@@ -97,7 +97,8 @@ var anaphoraResolver router.AnaphoraResolver
|
||||
// followUpMerge fills the current turn's missing slots from a prior
|
||||
// non-expired session — the multi-turn seam. It handles three cases:
|
||||
//
|
||||
// 1. Same-intent: inherit missing slots via InheritSlots (existing behavior).
|
||||
// 1. Same-intent: inherit missing slots via InheritSlots (existing behavior),
|
||||
// except a reminder time the current sentence named and the parser missed.
|
||||
// 2. Cross-intent anaphora: if the current utterance contains a pronoun
|
||||
// ("это" / "он" / "она" etc.) AND the prior session has a key, inherit
|
||||
// the key for fact-lookup queries and reminder creation.
|
||||
@@ -113,8 +114,22 @@ func followUpMerge(prev *dialogue.Session, dec router.Decision, now time.Time) r
|
||||
|
||||
// Case 1: same-intent inheritance (existing).
|
||||
if prev.Intent == dialogue.Intent(dec.Intent) {
|
||||
// A reminder that named an hour nobody could read must not borrow the
|
||||
// last one's. Two reminders in a row and the second landed at the
|
||||
// first's time, confirmed as if it had been read from the sentence:
|
||||
// "напомни без четверти восемь выходить" fired at 07:30 (V-543). The
|
||||
// hour is also what fills before the action's own fallback parse can
|
||||
// run, so inheriting it hid a time that did parse.
|
||||
//
|
||||
// Inheriting is still right when the sentence names no time at all,
|
||||
// which is the follow-up this seam exists for.
|
||||
blockTime := dec.Intent == router.IntentReminder &&
|
||||
!dec.Slots.HasTime && router.MentionsTime(dec.Utterance)
|
||||
merged := dialogue.InheritSlots(prev.Slots, toDialogueSlots(dec.Slots))
|
||||
dec.Slots = applyDialogueSlots(dec.Slots, merged)
|
||||
if blockTime {
|
||||
dec.Slots.Time, dec.Slots.HasTime = time.Time{}, false
|
||||
}
|
||||
return dec
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,42 @@ func TestFollowUpMerge(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
// V-543, measured on the box: four reminders in a row all landed at the
|
||||
// first one's hour, each confirmed as if it had been read from the sentence.
|
||||
// A sentence that names a time and fails to parse must ask, not borrow.
|
||||
t.Run("a named time that did not parse is not inherited", func(t *testing.T) {
|
||||
for _, utt := range []string{
|
||||
"напомни без четверти восемь выходить",
|
||||
"напомни в половине первого пообедать",
|
||||
"напомни завтра принять лекарство",
|
||||
"remind me at noon to stretch",
|
||||
} {
|
||||
cur := router.Decision{
|
||||
Intent: router.IntentReminder,
|
||||
Utterance: utt,
|
||||
Slots: router.Slots{Text: utt},
|
||||
}
|
||||
got := followUpMerge(prev, cur, base.Add(30*time.Second))
|
||||
if got.Slots.HasTime {
|
||||
t.Errorf("%q borrowed the previous hour %v", utt, got.Slots.Time)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// The follow-up this seam exists for still works: the sentence names no
|
||||
// time, so the previous one is the only one it could mean.
|
||||
t.Run("a follow-up naming no time still inherits", func(t *testing.T) {
|
||||
cur := router.Decision{
|
||||
Intent: router.IntentReminder,
|
||||
Utterance: "и ещё полить цветы",
|
||||
Slots: router.Slots{Text: "полить цветы"},
|
||||
}
|
||||
got := followUpMerge(prev, cur, base.Add(30*time.Second))
|
||||
if !got.Slots.HasTime || !got.Slots.Time.Equal(fireAt) {
|
||||
t.Errorf("time not inherited: HasTime=%v Time=%v", got.Slots.HasTime, got.Slots.Time)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("current slot wins over prior (gaps only)", func(t *testing.T) {
|
||||
own := base.Add(48 * time.Hour)
|
||||
cur := router.Decision{
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestHasCyrillic(t *testing.T) {
|
||||
for _, s := range []string{"что такое фотосинтез", "кто такой Elon Musk", "фотосинтез"} {
|
||||
if !hasCyrillic(s) {
|
||||
t.Errorf("hasCyrillic(%q) = false; it is a Russian question", s)
|
||||
}
|
||||
}
|
||||
for _, s := range []string{"what is photosynthesis", "", "3:2"} {
|
||||
if hasCyrillic(s) {
|
||||
t.Errorf("hasCyrillic(%q) = true; there is no Cyrillic in it", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The book choice and the rewrite decision are the same decision: a Russian
|
||||
// book reads his question as he asked it, an English one needs it translated
|
||||
// into keywords first (V-508).
|
||||
func TestKiwixBookChoice(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
wiring kiwixWiring
|
||||
utterance string
|
||||
wantBook string
|
||||
wantVerb bool
|
||||
}{
|
||||
{"a russian question reads the russian book verbatim",
|
||||
kiwixWiring{book: "en", bookRU: "ru"}, "что такое фотосинтез", "ru", true},
|
||||
{"an english question reads the english book",
|
||||
kiwixWiring{book: "en", bookRU: "ru"}, "what is photosynthesis", "en", false},
|
||||
{"no russian book configured leaves every question on the english one",
|
||||
kiwixWiring{book: "en"}, "что такое фотосинтез", "en", false},
|
||||
} {
|
||||
book, verbatim := tc.wiring.book, false
|
||||
if tc.wiring.bookRU != "" && hasCyrillic(tc.utterance) {
|
||||
book, verbatim = tc.wiring.bookRU, true
|
||||
}
|
||||
if book != tc.wantBook || verbatim != tc.wantVerb {
|
||||
t.Errorf("%s: book=%q verbatim=%v, want %q/%v", tc.name, book, verbatim, tc.wantBook, tc.wantVerb)
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
-2
@@ -19,8 +19,12 @@ type kiwixWiring struct {
|
||||
client *kiwix.Client
|
||||
rewriter *kiwix.Rewriter // nil ⇒ the question is searched verbatim
|
||||
book string
|
||||
max int
|
||||
runes int
|
||||
// bookRU — searched instead of book when the question is Cyrillic, and
|
||||
// searched verbatim because it is in his language already (V-508). Empty ⇒
|
||||
// every question goes to book.
|
||||
bookRU string
|
||||
max int
|
||||
runes int
|
||||
}
|
||||
|
||||
// wireKiwix builds the ZIM reader from the `kiwix` block, or returns nil when
|
||||
@@ -38,9 +42,13 @@ func wireKiwix(cfg *config.Config, c *llm.Client) *kiwixWiring {
|
||||
w := &kiwixWiring{
|
||||
client: kiwix.New(kc.URL),
|
||||
book: kc.Book,
|
||||
bookRU: kc.BookRU,
|
||||
max: kc.MaxResults,
|
||||
runes: kc.SnippetRunes,
|
||||
}
|
||||
if kc.BookRU != "" {
|
||||
log.Printf("voice: kiwix: russian questions read %q verbatim", kc.BookRU)
|
||||
}
|
||||
switch {
|
||||
case !kc.RewriteEnabled():
|
||||
log.Printf("voice: kiwix at %s (book %q, query rewriting off by config)", kc.URL, kc.Book)
|
||||
|
||||
@@ -127,8 +127,10 @@ func run(args []string) error {
|
||||
cfgPath := flag.String("config", defaultConfigPath(), "path to mavend JSON config")
|
||||
wrappedKeyPath := flag.String("wrapped-key-file", "", "path to wrapped encryption key blob (enables cold-start unlock)")
|
||||
reembed := flag.Bool("reembed", false, "re-embed every stored note and fact with the configured embedder, then serve normally (run once after an embedder swap; the daemon does not answer until it finishes)")
|
||||
allowSeed := flag.Bool("allow-seed", false, "enable the backdated seed_event write path (QA only: it lets a caller place a fact in the past and mint a routine the tick loop will then act on; off means the method has nothing to write with)")
|
||||
flag.CommandLine.Parse(args)
|
||||
reembedOnStart = *reembed
|
||||
allowSeedOnStart = *allowSeed
|
||||
cfg, err := config.Load(*cfgPath)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -338,6 +340,7 @@ func run(args []string) error {
|
||||
getMorningStatus: func(ctx context.Context) []ipc.MorningRoutineStatus { return tl.morningStatus(ctx, time.Now()) },
|
||||
getDayPlan: func(ctx context.Context) ipc.DayPlan { return tl.dayPlan(ctx, time.Now()) },
|
||||
getEvents: intakeEventsFn(evBus),
|
||||
seedStore: seedStoreIfAllowed(st),
|
||||
}
|
||||
if voiceW != nil && voiceW.handler != nil {
|
||||
api := coreAPI.(*daemonAPI)
|
||||
@@ -605,6 +608,7 @@ func run(args []string) error {
|
||||
getMorningStatus: func(ctx context.Context) []ipc.MorningRoutineStatus { return tl.morningStatus(ctx, time.Now()) },
|
||||
getDayPlan: func(ctx context.Context) ipc.DayPlan { return tl.dayPlan(ctx, time.Now()) },
|
||||
getEvents: intakeEventsFn(evBus),
|
||||
seedStore: seedStoreIfAllowed(st),
|
||||
}
|
||||
if voiceW != nil && voiceW.handler != nil {
|
||||
newAPI.chatFn = voiceW.handler.handleText
|
||||
|
||||
@@ -97,20 +97,20 @@ func parseCandidateVerb(text string) (status, say string, ok bool) {
|
||||
// offerCandidates records the list she just read, so his next words can pick
|
||||
// from it. Best effort: no session store, or a session that expired between the
|
||||
// question and the answer, means the words route normally.
|
||||
func (h *reactiveHandler) offerCandidates(cands []dialogue.Candidate) {
|
||||
func (h *reactiveHandler) offerCandidates(ctx context.Context, cands []dialogue.Candidate) {
|
||||
if h.dialogueSessions == nil || len(cands) == 0 {
|
||||
return
|
||||
}
|
||||
h.dialogueSessions.SetCandidates(voiceDialogueID, h.now(), cands)
|
||||
h.dialogueSessions.SetCandidates(dialogueIDOf(ctx), h.now(), cands)
|
||||
}
|
||||
|
||||
// resolveCandidate handles "второй", "первую сделал", "последнюю убери" against
|
||||
// the list she just read.
|
||||
func (h *reactiveHandler) resolveCandidate(ctx context.Context, text string) (string, bool) {
|
||||
func (h *reactiveHandler) resolveCandidate(ctx context.Context, text string, src turnSource) (string, bool) {
|
||||
if h.dialogueSessions == nil {
|
||||
return "", false
|
||||
}
|
||||
sess := h.dialogueSessions.Get(voiceDialogueID, h.now())
|
||||
sess := h.dialogueSessions.Get(dialogueIDOf(ctx), h.now())
|
||||
if sess == nil || len(sess.Candidates) == 0 {
|
||||
return "", false
|
||||
}
|
||||
@@ -133,13 +133,13 @@ func (h *reactiveHandler) resolveCandidate(ctx context.Context, text string) (st
|
||||
// a sentence, and the second half is the next turn.
|
||||
return pick.Label, true
|
||||
}
|
||||
if err := h.api.SetTaskStatus(ctx, pick.Ref, status, h.now(), "tap:voice"); err != nil {
|
||||
if err := h.api.SetTaskStatus(ctx, pick.Ref, status, h.now(), string(src)); err != nil {
|
||||
log.Printf("voice: candidate %d → %s: %v", pick.Ref, status, err)
|
||||
return "не получилось изменить задачу.", true
|
||||
}
|
||||
// Spent: the list she read is no longer the list, and a second ordinal
|
||||
// against it would close the wrong task.
|
||||
h.dialogueSessions.SetCandidates(voiceDialogueID, h.now(), nil)
|
||||
h.dialogueSessions.SetCandidates(dialogueIDOf(ctx), h.now(), nil)
|
||||
log.Printf("voice: candidate %d (%q) → %s", pick.Ref, pick.Label, status)
|
||||
return say + ": " + pick.Label, true
|
||||
}
|
||||
|
||||
+14
-12
@@ -38,7 +38,7 @@ func TestParseOrdinalReadsThePosition(t *testing.T) {
|
||||
|
||||
func TestOrdinalPassesWithNothingOffered(t *testing.T) {
|
||||
h, _, _ := newClarifyHandler(t)
|
||||
if _, handled := h.resolveCandidate(context.Background(), "второй"); handled {
|
||||
if _, handled := h.resolveCandidate(context.Background(), "второй", sourceVoice); handled {
|
||||
t.Error("an ordinal with no list behind it was claimed")
|
||||
}
|
||||
}
|
||||
@@ -47,14 +47,14 @@ func TestOrdinalReadsBackWithoutAVerb(t *testing.T) {
|
||||
h, st, _ := newClarifyHandler(t)
|
||||
ctx := context.Background()
|
||||
ids := seedTasks(t, st, "купить хлеб", "позвонить маме")
|
||||
putCandidates(h, ids, "купить хлеб", "позвонить маме")
|
||||
putCandidates(h, context.Background(), ids, "купить хлеб", "позвонить маме")
|
||||
|
||||
reply, handled := h.resolveCandidate(ctx, "второй")
|
||||
reply, handled := h.resolveCandidate(ctx, "второй", sourceVoice)
|
||||
if !handled || !strings.Contains(reply, "позвонить маме") {
|
||||
t.Fatalf("a bare ordinal did not read the task back: %q handled=%v", reply, handled)
|
||||
}
|
||||
// Still live: naming one is often the first half of a sentence.
|
||||
if _, handled := h.resolveCandidate(ctx, "первый"); !handled {
|
||||
if _, handled := h.resolveCandidate(ctx, "первый", sourceVoice); !handled {
|
||||
t.Error("the list was spent by a read-back")
|
||||
}
|
||||
}
|
||||
@@ -63,9 +63,9 @@ func TestOrdinalWithAVerbMovesTheTask(t *testing.T) {
|
||||
h, st, _ := newClarifyHandler(t)
|
||||
ctx := context.Background()
|
||||
ids := seedTasks(t, st, "купить хлеб", "позвонить маме")
|
||||
putCandidates(h, ids, "купить хлеб", "позвонить маме")
|
||||
putCandidates(h, context.Background(), ids, "купить хлеб", "позвонить маме")
|
||||
|
||||
reply, handled := h.resolveCandidate(ctx, "первую сделал")
|
||||
reply, handled := h.resolveCandidate(ctx, "первую сделал", sourceVoice)
|
||||
if !handled || !strings.Contains(reply, "купить хлеб") {
|
||||
t.Fatalf("the pick was not acted on: %q handled=%v", reply, handled)
|
||||
}
|
||||
@@ -80,7 +80,7 @@ func TestOrdinalWithAVerbMovesTheTask(t *testing.T) {
|
||||
}
|
||||
// Spent: a second ordinal against a list that no longer holds would close
|
||||
// the wrong task.
|
||||
if _, handled := h.resolveCandidate(ctx, "второй"); handled {
|
||||
if _, handled := h.resolveCandidate(ctx, "второй", sourceVoice); handled {
|
||||
t.Error("the list survived the pick it was spent on")
|
||||
}
|
||||
}
|
||||
@@ -88,9 +88,9 @@ func TestOrdinalWithAVerbMovesTheTask(t *testing.T) {
|
||||
func TestOrdinalPastTheEndSaysHowMany(t *testing.T) {
|
||||
h, st, _ := newClarifyHandler(t)
|
||||
ids := seedTasks(t, st, "купить хлеб")
|
||||
putCandidates(h, ids, "купить хлеб")
|
||||
putCandidates(h, context.Background(), ids, "купить хлеб")
|
||||
|
||||
reply, handled := h.resolveCandidate(context.Background(), "третий")
|
||||
reply, handled := h.resolveCandidate(context.Background(), "третий", sourceVoice)
|
||||
if !handled || !strings.Contains(reply, "1") {
|
||||
t.Fatalf("a position she never read was not answered: %q handled=%v", reply, handled)
|
||||
}
|
||||
@@ -118,11 +118,13 @@ func seedTasks(t *testing.T, st *store.Store, texts ...string) []int64 {
|
||||
return ids
|
||||
}
|
||||
|
||||
func putCandidates(h *reactiveHandler, ids []int64, labels ...string) {
|
||||
// putCandidates binds a list to the reach the ctx names, the way queryTasks
|
||||
// does when she recites one.
|
||||
func putCandidates(h *reactiveHandler, ctx context.Context, ids []int64, labels ...string) {
|
||||
cands := make([]dialogue.Candidate, 0, len(ids))
|
||||
for i, id := range ids {
|
||||
cands = append(cands, dialogue.Candidate{Kind: "task", Ref: id, Label: labels[i]})
|
||||
}
|
||||
h.dialogueSessions.Put(voiceDialogueID, &dialogue.Session{Timestamp: h.now()})
|
||||
h.offerCandidates(cands)
|
||||
h.dialogueSessions.Put(dialogueIDOf(ctx), &dialogue.Session{Timestamp: h.now()})
|
||||
h.offerCandidates(ctx, cands)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// "отметь второй пункт" names a position, and only the daemon knows which item
|
||||
// that is. The router fills the value slot with "2"; this is where it becomes an
|
||||
// item id (Vikunja #516).
|
||||
func TestPositionResolvesAgainstTheLastSpokenList(t *testing.T) {
|
||||
praxis := newFakePraxis(t, `[
|
||||
{"id":"item_a","title":"диск заканчивается"},
|
||||
{"id":"item_b","title":"бэкап не прошёл"},
|
||||
{"id":"item_c","title":"сертификат истекает"}
|
||||
]`)
|
||||
h := newPraxisTestHandler(t, praxis)
|
||||
|
||||
if reply := h.handlePraxisAct(context.Background(), praxisActDec("list_attention")); reply == "" {
|
||||
t.Fatal("attention returned nothing")
|
||||
}
|
||||
|
||||
cases := []struct{ ref, wantItem string }{
|
||||
{"2", "item_b"},
|
||||
{"1", "item_a"},
|
||||
{"last", "item_c"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
praxis.ResetRequests()
|
||||
reply := h.handlePraxisAct(context.Background(), praxisItemDec("acknowledge_item", c.ref))
|
||||
if !strings.Contains(reply, "принято") {
|
||||
t.Errorf("ref %q: reply %q", c.ref, reply)
|
||||
}
|
||||
if !requestedPathContaining(praxis, c.wantItem) {
|
||||
t.Errorf("ref %q did not acknowledge %s; paths %v", c.ref, c.wantItem, paths(praxis))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A position past the end must not acknowledge the wrong item. It asks.
|
||||
func TestPositionPastTheEndAsksInsteadOfGuessing(t *testing.T) {
|
||||
praxis := newFakePraxis(t, `[{"id":"item_a","title":"диск заканчивается"}]`)
|
||||
h := newPraxisTestHandler(t, praxis)
|
||||
h.handlePraxisAct(context.Background(), praxisActDec("list_attention"))
|
||||
|
||||
praxis.ResetRequests()
|
||||
reply := h.handlePraxisAct(context.Background(), praxisItemDec("resolve_item", "4"))
|
||||
if !strings.Contains(reply, "какой пункт") {
|
||||
t.Errorf("a position with no item should ask, got %q", reply)
|
||||
}
|
||||
if requestedPathContaining(praxis, "item_a") {
|
||||
t.Error("the only surfaced item was resolved for a position that did not name it")
|
||||
}
|
||||
}
|
||||
|
||||
// No digest yet means no positions. Nothing is mutated.
|
||||
func TestPositionWithNoSpokenListAsks(t *testing.T) {
|
||||
praxis := newFakePraxis(t, `[]`)
|
||||
h := newPraxisTestHandler(t, praxis)
|
||||
|
||||
reply := h.handlePraxisAct(context.Background(), praxisItemDec("acknowledge_item", "1"))
|
||||
if !strings.Contains(reply, "какой пункт") {
|
||||
t.Errorf("want the ask, got %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// An explicit id is not a position and passes through untouched.
|
||||
func TestExplicitItemIDIsNotRewritten(t *testing.T) {
|
||||
praxis := newFakePraxis(t, `[{"id":"item_a","title":"диск"}]`)
|
||||
h := newPraxisTestHandler(t, praxis)
|
||||
h.handlePraxisAct(context.Background(), praxisActDec("list_attention"))
|
||||
|
||||
praxis.ResetRequests()
|
||||
h.handlePraxisAct(context.Background(), praxisItemDec("pin_item", "item_zz"))
|
||||
if !requestedPathContaining(praxis, "item_zz") {
|
||||
t.Errorf("the id he gave was not the one called; paths %v", paths(praxis))
|
||||
}
|
||||
}
|
||||
|
||||
// An item Praxis sent without a title is never spoken, so it holds no position.
|
||||
func TestUnspokenItemsHoldNoPosition(t *testing.T) {
|
||||
praxis := newFakePraxis(t, `[
|
||||
{"id":"item_silent"},
|
||||
{"id":"item_said","title":"бэкап не прошёл"}
|
||||
]`)
|
||||
h := newPraxisTestHandler(t, praxis)
|
||||
h.handlePraxisAct(context.Background(), praxisActDec("list_attention"))
|
||||
|
||||
praxis.ResetRequests()
|
||||
h.handlePraxisAct(context.Background(), praxisItemDec("acknowledge_item", "1"))
|
||||
if !requestedPathContaining(praxis, "item_said") {
|
||||
t.Errorf("position 1 is the first item she SAID; paths %v", paths(praxis))
|
||||
}
|
||||
}
|
||||
|
||||
// The item id travels in the POST body, so that is what these read.
|
||||
func paths(f *fakeServer) []string {
|
||||
var out []string
|
||||
for _, r := range f.Requests() {
|
||||
out = append(out, r.Path+" "+string(r.Body))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func requestedPathContaining(f *fakeServer, want string) bool {
|
||||
for _, r := range f.Requests() {
|
||||
if strings.Contains(string(r.Body), want) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// "отметь это как сделанное" after a one-item digest points at that item.
|
||||
func TestDemonstrativeResolvesWhenOneItemWasSpoken(t *testing.T) {
|
||||
praxis := newFakePraxis(t, `[{"id":"item_only","title":"бэкап не прошёл"}]`)
|
||||
h := newPraxisTestHandler(t, praxis)
|
||||
h.handlePraxisAct(context.Background(), praxisActDec("list_attention"))
|
||||
|
||||
praxis.ResetRequests()
|
||||
reply := h.handlePraxisAct(context.Background(), praxisItemDec("acknowledge_item", "this"))
|
||||
if !strings.Contains(reply, "принято") {
|
||||
t.Errorf("reply %q", reply)
|
||||
}
|
||||
if !requestedPathContaining(praxis, "item_only") {
|
||||
t.Errorf("the one surfaced item was not acknowledged; paths %v", paths(praxis))
|
||||
}
|
||||
}
|
||||
|
||||
// Pointing at one of several is a guess, and a wrong guess transitions the wrong
|
||||
// item. The turn goes back to the cascade instead.
|
||||
func TestDemonstrativeWithSeveralItemsGivesTheTurnBack(t *testing.T) {
|
||||
praxis := newFakePraxis(t, `[
|
||||
{"id":"item_a","title":"диск"},
|
||||
{"id":"item_b","title":"бэкап"}
|
||||
]`)
|
||||
h := newPraxisTestHandler(t, praxis)
|
||||
h.handlePraxisAct(context.Background(), praxisActDec("list_attention"))
|
||||
|
||||
praxis.ResetRequests()
|
||||
if reply := h.handlePraxisAct(context.Background(), praxisItemDec("resolve_item", "this")); reply != "" {
|
||||
t.Errorf("want a fall-through, got %q", reply)
|
||||
}
|
||||
for _, p := range paths(praxis) {
|
||||
if strings.Contains(p, "resolve") {
|
||||
t.Error("an ambiguous demonstrative resolved an item anyway")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// "я это сделал" with no digest behind it is a sentence about his day.
|
||||
func TestDemonstrativeWithNoDigestGivesTheTurnBack(t *testing.T) {
|
||||
praxis := newFakePraxis(t, `[]`)
|
||||
h := newPraxisTestHandler(t, praxis)
|
||||
|
||||
if reply := h.handlePraxisAct(context.Background(), praxisItemDec("resolve_item", "this")); reply != "" {
|
||||
t.Errorf("want a fall-through, got %q", reply)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/loop"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
// The row is the whole mechanism, and nothing wrote it (Vikunja #532).
|
||||
//
|
||||
// The existing hysteresis test in internal/store scores the pure function and
|
||||
// passed throughout, which is exactly why this went unnoticed: Resolve was
|
||||
// always correct and was always handed the cold-start Away. So this test asserts
|
||||
// the round trip — the tick writes what gather resolved, and the next load
|
||||
// reads it back — rather than re-testing the function.
|
||||
func TestTickPersistsTheResolvedBucket(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st := newTestStore(t)
|
||||
now := time.Now()
|
||||
|
||||
// Cold start: no row, so a load must say Away and the zero time.
|
||||
b, score, updated, err := st.LoadPresenceState(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("load before: %v", err)
|
||||
}
|
||||
if b != store.Away || score != 0 || !updated.IsZero() {
|
||||
t.Fatalf("cold start = %s/%v/%v, want away/0/zero", b, score, updated)
|
||||
}
|
||||
|
||||
tl := &tickLoop{store: st}
|
||||
tl.savePresence(ctx, loop.State{Presence: store.Present, PresenceScore: 0.9}, now)
|
||||
|
||||
b, score, updated, err = st.LoadPresenceState(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("load after: %v", err)
|
||||
}
|
||||
if b != store.Present {
|
||||
t.Errorf("bucket = %s, want present", b)
|
||||
}
|
||||
if score != 0.9 {
|
||||
t.Errorf("score = %v, want 0.9", score)
|
||||
}
|
||||
if updated.IsZero() {
|
||||
t.Error("updated_ts was not written, so /dash still reads (never)")
|
||||
}
|
||||
}
|
||||
|
||||
// The singleton stays a singleton, and a later tick overwrites rather than
|
||||
// accumulating. A row per tick would make LoadPresenceState's single-row query
|
||||
// return whichever one SQLite felt like.
|
||||
func TestPresenceStateIsOverwrittenNotAppended(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st := newTestStore(t)
|
||||
tl := &tickLoop{store: st}
|
||||
now := time.Now()
|
||||
|
||||
tl.savePresence(ctx, loop.State{Presence: store.Present, PresenceScore: 0.9}, now)
|
||||
tl.savePresence(ctx, loop.State{Presence: store.Away, PresenceScore: 0.1}, now.Add(time.Minute))
|
||||
|
||||
b, score, _, err := st.LoadPresenceState(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
if b != store.Away || score != 0.1 {
|
||||
t.Fatalf("got %s/%v, want the second write (away/0.1)", b, score)
|
||||
}
|
||||
}
|
||||
|
||||
// What the persisted row buys: the hold band. A score sitting between Exit and
|
||||
// Enter holds Present when the last bucket was Present, and stays Away when it
|
||||
// was Away. Before the write existed the second arm was the only one that could
|
||||
// ever run, so presence dropped at roughly four minutes of idle instead of
|
||||
// holding to the exit threshold at about nine.
|
||||
func TestPersistedBucketIsWhatFeedsHysteresis(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st := newTestStore(t)
|
||||
tl := &tickLoop{store: st}
|
||||
|
||||
mid := (store.PresenceExit + store.PresenceEnter) / 2
|
||||
if mid <= store.PresenceExit || mid >= store.PresenceEnter {
|
||||
t.Fatalf("%v is not inside the hold band", mid)
|
||||
}
|
||||
|
||||
tl.savePresence(ctx, loop.State{Presence: store.Present, PresenceScore: 0.9}, time.Now())
|
||||
last, _, _, err := st.LoadPresenceState(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
if got := store.Resolve(mid, last); got != store.Present {
|
||||
t.Errorf("Resolve(%v, %s) = %s, want present — the hold band did not apply", mid, last, got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// The query source that claimed a turn was visible in the daemon log and
|
||||
// nowhere else (V-539). A QA step reading /chat could see a wrong answer but
|
||||
// not tell a wrong answer from a wrongly ordered chain: "почему небо голубое"
|
||||
// answered badly reads the same whether search claimed it, the ZIM did, or the
|
||||
// resident model answered from memory.
|
||||
//
|
||||
// It rides the context rather than a return value because handleText answers
|
||||
// every reach through one string, and threading a second value through the
|
||||
// whole action dispatch would change a signature the mic, telegram and the web
|
||||
// all share. The sink is per turn, created by the caller that wants to read it;
|
||||
// a turn with no sink notes nothing, which is what the mic path does.
|
||||
type querySourceKey struct{}
|
||||
|
||||
// querySourceSink holds the name of the source that claimed one turn. The mutex
|
||||
// is there because a query source may fan out to goroutines of its own, not
|
||||
// because two turns share a sink.
|
||||
type querySourceSink struct {
|
||||
mu sync.Mutex
|
||||
name string
|
||||
}
|
||||
|
||||
func (s *querySourceSink) note(name string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.name = name
|
||||
}
|
||||
|
||||
// Name is the source that claimed, or empty when nothing did or the turn was
|
||||
// not a query at all.
|
||||
func (s *querySourceSink) Name() string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.name
|
||||
}
|
||||
|
||||
// withQuerySourceSink returns a context that collects the claiming source, and
|
||||
// the sink to read after the turn has answered.
|
||||
func withQuerySourceSink(ctx context.Context) (context.Context, *querySourceSink) {
|
||||
sink := &querySourceSink{}
|
||||
return context.WithValue(ctx, querySourceKey{}, sink), sink
|
||||
}
|
||||
|
||||
// noteQuerySource records which source claimed the turn. It is a no-op when the
|
||||
// caller did not ask for one.
|
||||
func noteQuerySource(ctx context.Context, name string) {
|
||||
if sink, ok := ctx.Value(querySourceKey{}).(*querySourceSink); ok {
|
||||
sink.note(name)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestQuerySourceSinkCollectsTheClaimingName(t *testing.T) {
|
||||
ctx, sink := withQuerySourceSink(context.Background())
|
||||
if sink.Name() != "" {
|
||||
t.Fatalf("a fresh sink names a source: %q", sink.Name())
|
||||
}
|
||||
noteQuerySource(ctx, "kiwix")
|
||||
if got := sink.Name(); got != "kiwix" {
|
||||
t.Errorf("sink.Name() = %q, want kiwix", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A turn with no sink must not panic. The mic path asks for no source, and a
|
||||
// query source calls noteQuerySource unconditionally.
|
||||
func TestNoteQuerySourceWithoutASinkIsSilent(t *testing.T) {
|
||||
noteQuerySource(context.Background(), "search")
|
||||
}
|
||||
|
||||
// The last source to claim wins, because only one does: actionQuery returns on
|
||||
// the first claim. This pins that the sink overwrites rather than appends, so a
|
||||
// second turn on the same context could not read a stale name.
|
||||
func TestQuerySourceSinkKeepsTheLastNote(t *testing.T) {
|
||||
ctx, sink := withQuerySourceSink(context.Background())
|
||||
noteQuerySource(ctx, "search")
|
||||
noteQuerySource(ctx, "kiwix")
|
||||
if got := sink.Name(); got != "kiwix" {
|
||||
t.Errorf("sink.Name() = %q, want kiwix", got)
|
||||
}
|
||||
}
|
||||
@@ -44,9 +44,12 @@ func TestReactiveNotesReminders(t *testing.T) {
|
||||
HasTime: true,
|
||||
},
|
||||
}
|
||||
// The confirmation is phrased from the row now (Vikunja #507), so it
|
||||
// names the stored hour rather than leaving the replier to read one
|
||||
// out of the sentence.
|
||||
reply := h.applyAction(ctx, dec)
|
||||
if reply != "" {
|
||||
t.Errorf("expected empty reply from applyAction, got %q", reply)
|
||||
if want := "хорошо, напомню завтра в " + fireAt.Format("15:04") + "."; reply != want {
|
||||
t.Errorf("reply = %q, want %q", reply, want)
|
||||
}
|
||||
reminders, err := st.ListReminders(ctx, 10)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// A reminder confirmation is the one sentence that must match a database row.
|
||||
// It used to be phrased by the replier from Slots.Text, which meant it named
|
||||
// whatever hour the sentence contained — including an hour the parser had
|
||||
// rejected or read differently (Vikunja #507).
|
||||
|
||||
func TestReminderConfirmNamesTheStoredHour(t *testing.T) {
|
||||
now := time.Date(2026, 8, 5, 9, 0, 0, 0, time.UTC)
|
||||
got := reminderConfirm(now.Add(10*time.Hour), now) // 19:00 today
|
||||
if !strings.Contains(got, "19:00") {
|
||||
t.Fatalf("confirmation = %q, want the stored 19:00 in it", got)
|
||||
}
|
||||
if !strings.Contains(got, "сегодня") {
|
||||
t.Fatalf("confirmation = %q, want it to say сегодня", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReminderConfirmUsesADateBeyondTheDayWords(t *testing.T) {
|
||||
// dayPrefix answers "это" past послезавтра, and "напомню это в 09:00" is
|
||||
// not a sentence. A date is.
|
||||
now := time.Date(2026, 8, 5, 9, 0, 0, 0, time.UTC)
|
||||
got := reminderConfirm(now.Add(10*24*time.Hour), now)
|
||||
if strings.Contains(got, "это") {
|
||||
t.Fatalf("confirmation = %q, want a date rather than the fallback day word", got)
|
||||
}
|
||||
if !strings.Contains(got, "15 августа") {
|
||||
t.Fatalf("confirmation = %q, want the date in it", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReminderConfirmIsFeminineAndInformal(t *testing.T) {
|
||||
// The persona checks the phrasing eval enforces apply here too, and this
|
||||
// sentence never passes through a phraser.
|
||||
now := time.Date(2026, 8, 5, 9, 0, 0, 0, time.UTC)
|
||||
got := reminderConfirm(now.Add(time.Hour), now)
|
||||
for _, bad := range []string{"вы", "ваш", "напомнил ", "рад "} {
|
||||
if strings.Contains(strings.ToLower(got), bad) {
|
||||
t.Fatalf("confirmation = %q contains %q", got, bad)
|
||||
}
|
||||
}
|
||||
if !strings.HasPrefix(got, "хорошо, напомню") {
|
||||
t.Fatalf("confirmation = %q, want it to open with the promise", got)
|
||||
}
|
||||
}
|
||||
@@ -165,6 +165,8 @@ func formatTime(t time.Time) string {
|
||||
n := int(diff.Hours())
|
||||
return fmt.Sprintf("%d %s назад", n, say.CountWord(n, "час", "часа", "часов"))
|
||||
default:
|
||||
return t.Format("2 января 15:04")
|
||||
// Not t.Format("2 января …"): Go reads that as a literal, so every
|
||||
// fact older than a day used to read as January (Vikunja #507).
|
||||
return fmt.Sprintf("%d %s %s", t.Day(), lexicon.MonthGenitive(int(t.Month())), t.Format("15:04"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
// mavend/seed.go — the backdated-fact seam (Vikunja #518).
|
||||
//
|
||||
// The pattern detector needs four events for one action+object, spread by at
|
||||
// least pattern.MinIntervalDays, before it proposes a routine. Nothing could
|
||||
// produce that against a running daemon in one sitting: the only writer is a
|
||||
// fact write at time.Now(), so V-43, V-46, V-247 and V-254 all stopped at the
|
||||
// same missing step and had been stopped there since they were filed.
|
||||
//
|
||||
// This is the write path that unblocks them, and it is deliberately the narrow
|
||||
// one. It takes a fact, not an event, so pattern.Extract runs for real and a
|
||||
// key the extractor ignores seeds nothing. It runs detectAndPropose, so what a
|
||||
// seed proves is the daemon's own wiring rather than the detector in isolation
|
||||
// — which is what an eval-lab fixture would have proved, and is not what those
|
||||
// four tasks doubt.
|
||||
//
|
||||
// It is off unless mavend was started with -allow-seed, and AuthStepUp in the
|
||||
// authority table besides. See ipc.SeedEventReq and auth.Requirement.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/pattern"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
// errSeedDisabled — what a caller gets on an ordinary box. Named rather than
|
||||
// inline so the mavweb route can tell "not allowed here" apart from "the seed
|
||||
// ran and the extractor declined", which look the same to a reader otherwise.
|
||||
var errSeedDisabled = errors.New("mavend: seeding is off (start with -allow-seed)")
|
||||
|
||||
// seedSource — every seeded fact carries this, and no other writer uses it.
|
||||
// The point is that seeded data stays identifiable forever: a fact that came
|
||||
// from a QA sitting must never be mistaken for something he said, either by a
|
||||
// person reading /history or by the wipe in V-494 when it lands.
|
||||
const seedSource = "seed:qa"
|
||||
|
||||
// seedStoreIfAllowed returns st only when -allow-seed was passed, and logs the
|
||||
// fact loudly when it does. A box that can rewrite its own past should say so
|
||||
// in its boot log, so nobody reads a seeded routine months later as evidence of
|
||||
// something he actually did.
|
||||
func seedStoreIfAllowed(st *store.Store) *store.Store {
|
||||
if !allowSeedOnStart {
|
||||
return nil
|
||||
}
|
||||
log.Printf("seed: -allow-seed is ON — backdated fact writes are permitted under source %q (Vikunja #518)", seedSource)
|
||||
return st
|
||||
}
|
||||
|
||||
// SeedEvent writes the fact at the caller's timestamp, extracts an event from
|
||||
// it, and runs the same detect-and-propose step the voice path runs.
|
||||
//
|
||||
// Best-effort is NOT the shape here, unlike detectPattern: a seed that half
|
||||
// worked is a QA result nobody can trust, so every step reports its own
|
||||
// failure. Extraction declining is not a failure — it is the extractor's
|
||||
// documented answer for a value outside its lexicon, and Extracted says so.
|
||||
func (d *daemonAPI) SeedEvent(ctx context.Context, req ipc.SeedEventReq) (ipc.SeedEventResp, error) {
|
||||
if d.seedStore == nil {
|
||||
return ipc.SeedEventResp{}, errSeedDisabled
|
||||
}
|
||||
if req.Key == "" || req.Value == "" {
|
||||
return ipc.SeedEventResp{}, errors.New("mavend: seed needs a key and a value")
|
||||
}
|
||||
if req.Ts.IsZero() {
|
||||
return ipc.SeedEventResp{}, errors.New("mavend: seed needs an explicit timestamp")
|
||||
}
|
||||
|
||||
// No Subject, unlike the voice path: a seeded key must not queue a Nexus
|
||||
// resolution. QA data has no business reaching the ecosystem.
|
||||
factID, err := d.seedStore.WriteFact(ctx, req.Ts, store.KindSelf, req.Key, req.Value, seedSource, 1.0, sql.NullInt64{})
|
||||
if err != nil {
|
||||
return ipc.SeedEventResp{}, fmt.Errorf("seed write fact: %w", err)
|
||||
}
|
||||
resp := ipc.SeedEventResp{FactID: factID}
|
||||
|
||||
ev := pattern.Extract(factID, req.Key, req.Value, req.Ts)
|
||||
if ev == nil {
|
||||
// The fact is written and stays written. Saying so matters: a caller
|
||||
// that assumed a seed always produces an event would otherwise read
|
||||
// four silent successes and conclude the detector is broken.
|
||||
log.Printf("seed: %s=%s wrote fact %d, no event (value outside the action lexicon)", req.Key, req.Value, factID)
|
||||
return resp, nil
|
||||
}
|
||||
resp.Extracted, resp.Action, resp.Object = true, ev.Action, ev.Object
|
||||
|
||||
eventID, err := d.seedStore.CreateEvent(ctx, factID, ev.Action, ev.Object, req.Ts)
|
||||
if err != nil {
|
||||
return resp, fmt.Errorf("seed create event: %w", err)
|
||||
}
|
||||
resp.EventID = eventID
|
||||
|
||||
r, routineID, err := detectAndPropose(ctx, d.seedStore, ev.Action, ev.Object, req.Ts)
|
||||
if err != nil {
|
||||
return resp, fmt.Errorf("seed detect: %w", err)
|
||||
}
|
||||
if r == nil {
|
||||
return resp, nil // too few events yet, too irregular, or already decided
|
||||
}
|
||||
resp.Proposed, resp.RoutineID, resp.IntervalDays = true, routineID, r.IntervalDays
|
||||
log.Printf("seed: proposed routine %d — %s/%s every %.1f days", routineID, r.Action, r.Object, r.IntervalDays)
|
||||
return resp, nil
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
)
|
||||
|
||||
// Off is the default and it must mean "nothing to write with", not "permission
|
||||
// to refuse later". A daemonAPI with no seedStore writes no fact at all.
|
||||
func TestSeedRefusedWithoutTheFlag(t *testing.T) {
|
||||
d := &daemonAPI{CoreAPI: ipc.UnimplementedCoreAPI{}}
|
||||
_, err := d.SeedEvent(context.Background(), ipc.SeedEventReq{
|
||||
Key: "cat_water_fountain", Value: "заправил", Ts: time.Now(),
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("seed succeeded with no seedStore")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "-allow-seed") {
|
||||
t.Errorf("error does not name the flag: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The whole point of the task: four seeds spread past the detector's floor
|
||||
// produce a proposal against the real daemon path, which is what nobody could
|
||||
// do before (Vikunja #518). Three seeds must NOT propose — MinEvents is four,
|
||||
// and a test that only checked the happy end would pass on an off-by-one.
|
||||
func TestSeedFourEventsProposesARoutine(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
d := &daemonAPI{CoreAPI: ipc.UnimplementedCoreAPI{}, seedStore: newTestStore(t)}
|
||||
now := time.Now()
|
||||
|
||||
var last ipc.SeedEventResp
|
||||
// Oldest first, three hours apart — past MinIntervalDays (two hours).
|
||||
for i := 3; i >= 0; i-- {
|
||||
var err error
|
||||
last, err = d.SeedEvent(ctx, ipc.SeedEventReq{
|
||||
Key: "cat_water_fountain",
|
||||
Value: "заправил",
|
||||
Ts: now.Add(-time.Duration(i) * 3 * time.Hour),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("seed %d: %v", i, err)
|
||||
}
|
||||
if !last.Extracted {
|
||||
t.Fatalf("seed %d: no event extracted from a lexicon verb", i)
|
||||
}
|
||||
if i > 0 && last.Proposed {
|
||||
t.Fatalf("proposed after only %d events, MinEvents is 4", 4-i)
|
||||
}
|
||||
}
|
||||
if !last.Proposed {
|
||||
t.Fatal("four spaced events did not propose a routine")
|
||||
}
|
||||
if last.Action != "refill" || last.Object != "cat_water_fountain" {
|
||||
t.Errorf("wrong pair: %s/%s", last.Action, last.Object)
|
||||
}
|
||||
if last.IntervalDays < 0.1 {
|
||||
t.Errorf("interval %v — the detector saw a burst, not a rhythm", last.IntervalDays)
|
||||
}
|
||||
|
||||
// The proposal is readable through the same list the /routines page uses,
|
||||
// which is the wiring an eval-lab fixture would not have proved.
|
||||
proposed, err := d.seedStore.ListProposedRoutines(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if len(proposed) != 1 {
|
||||
t.Fatalf("expected 1 proposed routine, got %d", len(proposed))
|
||||
}
|
||||
}
|
||||
|
||||
// A value outside the action lexicon writes the fact and says it seeded
|
||||
// nothing. Silence here would read as four working seeds and a broken
|
||||
// detector.
|
||||
func TestSeedReportsWhenExtractionDeclines(t *testing.T) {
|
||||
d := &daemonAPI{CoreAPI: ipc.UnimplementedCoreAPI{}, seedStore: newTestStore(t)}
|
||||
resp, err := d.SeedEvent(context.Background(), ipc.SeedEventReq{
|
||||
Key: "mood", Value: "ok", Ts: time.Now(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
if resp.FactID == 0 {
|
||||
t.Error("fact was not written")
|
||||
}
|
||||
if resp.Extracted || resp.EventID != 0 || resp.Proposed {
|
||||
t.Errorf("claimed an event for a non-action value: %+v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
// A seed with no timestamp is refused rather than defaulting to now: the only
|
||||
// reason this seam exists is the caller choosing when, so a zero Ts is a bug in
|
||||
// the caller and must not silently write a fact at the wrong time.
|
||||
func TestSeedRequiresAnExplicitTimestamp(t *testing.T) {
|
||||
d := &daemonAPI{CoreAPI: ipc.UnimplementedCoreAPI{}, seedStore: newTestStore(t)}
|
||||
if _, err := d.SeedEvent(context.Background(), ipc.SeedEventReq{
|
||||
Key: "cat_water_fountain", Value: "заправил",
|
||||
}); err == nil {
|
||||
t.Fatal("seed accepted a zero timestamp")
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
@@ -160,6 +161,7 @@ func (t *tickLoop) tick(ctx context.Context, now time.Time) {
|
||||
log.Printf("tick: gather: %v", err)
|
||||
return
|
||||
}
|
||||
t.savePresence(ctx, state, now)
|
||||
|
||||
// proactive: at most one candidate, max severity.
|
||||
cand, trace := loop.ExplainTick(state, t.rules)
|
||||
@@ -249,6 +251,7 @@ func (t *tickLoop) tick(ctx context.Context, now time.Time) {
|
||||
return
|
||||
}
|
||||
keys = t.repeatableRules(keys)
|
||||
keys = t.stopFinishedAlarms(ctx, keys, state, now)
|
||||
if len(keys) == 0 {
|
||||
return
|
||||
}
|
||||
@@ -260,6 +263,108 @@ func (t *tickLoop) tick(ctx context.Context, now time.Time) {
|
||||
}
|
||||
}
|
||||
|
||||
// savePresence writes back the bucket GatherState just resolved.
|
||||
//
|
||||
// It lives here and not in GatherState because that method holds a read-only
|
||||
// transaction on purpose — one consistent snapshot per tick — and a write
|
||||
// inside it would either break that guarantee or quietly upgrade the
|
||||
// transaction. The tick is the layer that already owns writes.
|
||||
//
|
||||
// Nothing wrote this row before (Vikunja #532), and the row is the whole
|
||||
// mechanism, so two things were broken at once. Hysteresis was dead: lastBucket
|
||||
// read the cold-start Away on every tick, so store.Resolve only ever took the
|
||||
// `last == Away` arm and demanded a full PresenceEnter score to say he is
|
||||
// there. The 0.30-0.55 hold band the function exists to provide never applied
|
||||
// once. And every readout lied: /dash and ipc.Presence read this row, so they
|
||||
// showed "away — score 0.00 (never)" while desk_active facts were arriving
|
||||
// every sixty seconds.
|
||||
//
|
||||
// A failure logs and the tick continues. The gate reads the in-memory bucket,
|
||||
// which is why nudge routing kept working through all of this — losing the
|
||||
// write costs the next tick's hysteresis, not this tick's decisions.
|
||||
func (t *tickLoop) savePresence(ctx context.Context, state loop.State, now time.Time) {
|
||||
if err := t.store.SavePresenceState(ctx, state.Presence, state.PresenceScore, now); err != nil {
|
||||
log.Printf("tick: save presence state: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// maxAlarmAge — how long one un-acked telegram alarm may keep repeating.
|
||||
//
|
||||
// This is the floor brake and it applies to every rule, including one that
|
||||
// says nothing about its own condition (Vikunja #535). Nothing in the tree can
|
||||
// ack a telegram nudge: MarkAcked has no caller outside internal/store, and the
|
||||
// only ack that exists is a voice "готово" on a box that runs no voice loop. So
|
||||
// "repeat until acked" meant "repeat forever", and it did — every five minutes
|
||||
// for over two hours.
|
||||
//
|
||||
// Two hours at the five-minute default is about 24 messages, which is already
|
||||
// past the point of being read. An alarm nobody answered in two hours is not
|
||||
// one more repeat away from being answered, and the right move is to stop
|
||||
// talking, not to talk louder.
|
||||
const maxAlarmAge = 2 * time.Hour
|
||||
|
||||
// stopFinishedAlarms returns the keys that may still repeat, and closes the
|
||||
// rest.
|
||||
//
|
||||
// Two ways an alarm ends without him. The condition cleared, which the rule
|
||||
// answers through StillTrue — deliberately NOT Predicate, which is
|
||||
// edge-triggered and reads false one tick after the alarm is raised, so using
|
||||
// it would cancel every alarm immediately. Or the alarm simply got old, which
|
||||
// is the bound that does not need the rule's cooperation.
|
||||
//
|
||||
// A rule with no StillTrue is not treated as resolved. Silence about the
|
||||
// condition is not evidence the condition cleared, so those keys only ever stop
|
||||
// on age.
|
||||
func (t *tickLoop) stopFinishedAlarms(ctx context.Context, keys []string, state loop.State, now time.Time) []string {
|
||||
if len(keys) == 0 {
|
||||
return nil
|
||||
}
|
||||
byName := make(map[string]loop.Rule, len(t.rules))
|
||||
for _, r := range t.rules {
|
||||
byName[r.Name] = r
|
||||
}
|
||||
live := keys[:0:0]
|
||||
for _, key := range keys {
|
||||
outcome := ""
|
||||
switch r := byName[key]; {
|
||||
case r.StillTrue != nil && !r.StillTrue(state):
|
||||
outcome = store.NudgeResolved
|
||||
case t.alarmIsOlderThan(ctx, key, maxAlarmAge, now):
|
||||
// Not "resolved": nothing says the thing got better. This is her
|
||||
// giving up on being answered, and /notifications should say so.
|
||||
outcome = store.NudgeIgnored
|
||||
}
|
||||
if outcome == "" {
|
||||
live = append(live, key)
|
||||
continue
|
||||
}
|
||||
n, err := t.store.ResolvePendingTelegram(ctx, key, outcome, now)
|
||||
if err != nil {
|
||||
// Could not close it, so do not drop it either: repeating is the
|
||||
// lesser fault against losing the alarm entirely.
|
||||
log.Printf("tick: stop alarm %s: %v", key, err)
|
||||
live = append(live, key)
|
||||
continue
|
||||
}
|
||||
log.Printf("tick: alarm %s ended (%s), %d pending nudge(s) closed", key, outcome, n)
|
||||
}
|
||||
return live
|
||||
}
|
||||
|
||||
// alarmIsOlderThan reports whether the oldest un-acked send for this rule is
|
||||
// past the cap. A read failure answers false: an alarm that repeats one more
|
||||
// time is better than one silenced by a transient store error.
|
||||
func (t *tickLoop) alarmIsOlderThan(ctx context.Context, rule string, age time.Duration, now time.Time) bool {
|
||||
oldest, err := t.store.OldestPendingTelegram(ctx, rule)
|
||||
if err != nil {
|
||||
if !errors.Is(err, store.ErrNudgeNotFound) {
|
||||
log.Printf("tick: oldest pending %s: %v", rule, err)
|
||||
}
|
||||
return false
|
||||
}
|
||||
return now.Sub(oldest) >= age
|
||||
}
|
||||
|
||||
// repeatableRules drops keys whose rule is not wired any more.
|
||||
//
|
||||
// The repeat path reads the nudges table, not the rule set: any sev4 telegram
|
||||
|
||||
+14
-3
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/loop"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
// daemonAPI wraps a store-backed CoreAPI and overrides TickTrace with the
|
||||
@@ -22,6 +23,11 @@ type daemonAPI struct {
|
||||
chatFn func(ctx context.Context, conversation, text string) string
|
||||
getMCPServers func() []ipc.MCPServerStatus
|
||||
getEvents func(n int) []ipc.IntakeEvent
|
||||
// seedStore — non-nil ONLY when mavend was started with -allow-seed. It is
|
||||
// the whole off-switch for the backdated write path (Vikunja #518), and it
|
||||
// is a store rather than a bool so that leaving the flag off means the
|
||||
// method has nothing to write with, not merely permission to refuse.
|
||||
seedStore *store.Store
|
||||
}
|
||||
|
||||
// RecentEvents — the unified intake journal (Vikunja #283). Empty, not an
|
||||
@@ -35,11 +41,16 @@ func (d *daemonAPI) RecentEvents(ctx context.Context, n int) ([]ipc.IntakeEvent,
|
||||
return d.getEvents(n), nil
|
||||
}
|
||||
|
||||
func (d *daemonAPI) Chat(ctx context.Context, conversation, text string) (string, error) {
|
||||
// Chat runs one text turn and reports which query source claimed it. The sink
|
||||
// rides the context so handleText keeps the one string signature the mic,
|
||||
// telegram and the web all call it through (V-539).
|
||||
func (d *daemonAPI) Chat(ctx context.Context, conversation, text string) (ipc.ChatReply, error) {
|
||||
if d.chatFn == nil {
|
||||
return "", errors.New("mavend: chat not available")
|
||||
return ipc.ChatReply{}, errors.New("mavend: chat not available")
|
||||
}
|
||||
return d.chatFn(ctx, conversation, text), nil
|
||||
ctx, sink := withQuerySourceSink(ctx)
|
||||
reply := d.chatFn(ctx, conversation, text)
|
||||
return ipc.ChatReply{Reply: reply, Source: sink.Name()}, nil
|
||||
}
|
||||
|
||||
// MCPServers — the configured MCP servers and their health (Vikunja #251).
|
||||
|
||||
@@ -584,7 +584,9 @@ func TestDigestSev4BypassesQueue(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
now := refNow()
|
||||
markPresent(t, st, ctx, now)
|
||||
if _, err := st.SetValue(ctx, store.KindSelf, "service_down:db", "poll:uptimekuma", "down", now); err != nil {
|
||||
// Older than loop.MinDownAge, so this tests the digest bypass and not the
|
||||
// flap debounce (Vikunja #536).
|
||||
if _, err := st.SetValue(ctx, store.KindSelf, "service_down:db", "poll:uptimekuma", "down", now.Add(-5*time.Minute)); err != nil {
|
||||
t.Fatalf("seed service_down: %v", err)
|
||||
}
|
||||
sink := &fakeSink{}
|
||||
|
||||
+17
-7
@@ -132,8 +132,9 @@ type reactiveHandler struct {
|
||||
// The production dateparser will replace StubDateTimeParser here too.
|
||||
timeParser router.DateTimeParser
|
||||
|
||||
// dialogueSessions carries slots across turns for follow-ups (single-user
|
||||
// box → one session slot, keyed voiceDialogueID). nil ⇒ no carry-over.
|
||||
// dialogueSessions carries slots across turns for follow-ups. Keyed by the
|
||||
// reach the turn arrived on (dialogueIDOf), like the clarify store: one
|
||||
// slot per reach, not one for the box. nil ⇒ no carry-over.
|
||||
dialogueSessions *dialogue.SessionStore
|
||||
|
||||
// clarifyStore parks the request behind an open question she asked (see
|
||||
@@ -158,6 +159,15 @@ type reactiveHandler struct {
|
||||
pendingRoutine *pendingRoutineConfirm // routine proposal awaiting y/n
|
||||
pendingHexis *pendingHexisExec // mutating Hexis capability awaiting y/n
|
||||
|
||||
// surfacedItems — the Praxis item ids she last read out, in the order she
|
||||
// read them, so "отметь второй пункт" has a second pункт to mean (Vikunja
|
||||
// #516). Same single-slot posture as pending above: the next attention digest
|
||||
// replaces the list, because a position only refers to the last one spoken.
|
||||
// No TTL — a stale position resolves to an item that Praxis will report as
|
||||
// already acknowledged, which is a harmless answer, unlike a stale
|
||||
// confirmation that would execute something.
|
||||
surfacedItems []string
|
||||
|
||||
ecosystem *ecosystemWiring // nexus + hexis + praxis clients
|
||||
}
|
||||
|
||||
@@ -304,7 +314,7 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour
|
||||
// just read (ordinal.go). Before routing, and only when a list is actually
|
||||
// bound to the session: with nothing offered, "второй" is an ordinary word
|
||||
// and keeps routing.
|
||||
if reply, handled := h.resolveCandidate(ctx, text); handled {
|
||||
if reply, handled := h.resolveCandidate(ctx, text, src); handled {
|
||||
return withNotice(expiredNotice, reply)
|
||||
}
|
||||
|
||||
@@ -320,7 +330,7 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour
|
||||
)
|
||||
now := h.now()
|
||||
if h.dialogueSessions != nil {
|
||||
prev = h.dialogueSessions.Get(voiceDialogueID, now)
|
||||
prev = h.dialogueSessions.Get(dialogueIDOf(ctx), now)
|
||||
}
|
||||
cont := false
|
||||
if dec, cont = continuationDecision(prev, text, now); cont {
|
||||
@@ -352,7 +362,7 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour
|
||||
dec = followUpMerge(prev, dec, now)
|
||||
}
|
||||
if !dec.Clarify {
|
||||
h.rememberTurn(prev, dec, now)
|
||||
h.rememberTurn(ctx, prev, dec, now)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -483,12 +493,12 @@ func (h *reactiveHandler) replySystem(ctx context.Context, dec router.Decision)
|
||||
// chatHistory collects dialogue turns from the session store for the current
|
||||
// conversation. Returns prior user utterances (newest last) up to a depth of
|
||||
// 4 turns. Returns nil when there's no session or no history.
|
||||
func (h *reactiveHandler) chatHistory() []dialogue.Turn {
|
||||
func (h *reactiveHandler) chatHistory(ctx context.Context) []dialogue.Turn {
|
||||
if h.dialogueSessions == nil {
|
||||
return nil
|
||||
}
|
||||
now := h.now()
|
||||
prev := h.dialogueSessions.Get(voiceDialogueID, now)
|
||||
prev := h.dialogueSessions.Get(dialogueIDOf(ctx), now)
|
||||
if prev == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -392,6 +392,10 @@ func buildRouter(emb router.Embedder, acts router.ActMatcher, threshold float64,
|
||||
grammars = append(grammars, router.TaskListGrammar())
|
||||
grammars = append(grammars, router.ListGrammars()...)
|
||||
grammars = append(grammars, router.ReminderGrammar())
|
||||
// Before the capture marker, because "отметь" is a capture verb and "отметь
|
||||
// второй пункт" is not a note. The Praxis rules are the narrower claim — a
|
||||
// lifecycle verb AND an item named — so they get first refusal (Vikunja #516).
|
||||
grammars = append(grammars, router.PraxisGrammars()...)
|
||||
// Last, and it matches any utterance shape — its Build is the filter. An
|
||||
// explicit capture marker beats the model, which called it an act and
|
||||
// rewrote the task text (Vikunja #467). After the rules above because a
|
||||
@@ -552,6 +556,11 @@ func repairFactVectors(dataStore *store.Store, emb router.Embedder) {
|
||||
// runReembed.
|
||||
var reembedOnStart bool
|
||||
|
||||
// allowSeedOnStart is the -allow-seed flag (set in run()). Opt-in, and the
|
||||
// default is the one that matters: a box nobody is testing has no live path to
|
||||
// write a fact into the past. See seed.go and Vikunja #518.
|
||||
var allowSeedOnStart bool
|
||||
|
||||
// checkStoredEmbedder compares the embedder we just loaded with the one that
|
||||
// wrote the vectors already in the DB (Vikunja #378).
|
||||
//
|
||||
|
||||
@@ -31,12 +31,11 @@ import (
|
||||
// not a guesser-of-truth, and a mailbox of noise rendered as invented meetings
|
||||
// is worse than a gap.
|
||||
//
|
||||
// KNOWN GAP: this writes calendar_event_* and nothing else, so an ambient
|
||||
// meeting is good enough to recite and not good enough to stop a nudge —
|
||||
// calendar_busy is still written only by the CalDAV poller. That is backwards,
|
||||
// since suppressing a nudge is the lower-risk use of a low-confidence signal.
|
||||
// calendar_busy is a level rather than an event, so an ambient writer needs an
|
||||
// expiry, which is its own task and not a change here.
|
||||
// This writes calendar_event_* and nothing else, and since Vikunja #513 that is
|
||||
// enough to stop a nudge as well as to recite: the loop gatherer reads the event
|
||||
// family and asks whether any span covers the instant. So there is no ambient
|
||||
// calendar_busy and no expiry to pick — a level needs one and an event carries
|
||||
// its own. calendar_busy stays the CalDAV poller's key.
|
||||
|
||||
// ambientMaxBody bounds the request. A notification is two short lines.
|
||||
const ambientMaxBody = 8 << 10
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
{{if .Error}}<div class="msg msg-err">{{.Error}}</div>{{end}}
|
||||
<div class="scroll chat-scroll" id=chatHistory>
|
||||
{{range .Messages}}
|
||||
<div class="chat-msg {{.Role}}"><strong>{{if eq .Role "user"}}you{{else}}maven{{end}}:</strong> {{.Text}}</div>
|
||||
<div class="chat-msg {{.Role}}"><strong>{{if eq .Role "user"}}you{{else}}maven{{end}}:</strong> {{.Text}}{{if .Source}} <span class="badge badge-accent" title="the query source that claimed this turn">{{.Source}}</span>{{end}}</div>
|
||||
{{else}}
|
||||
<div class=empty>
|
||||
<svg class=icon width="20" height="20"><use href="/ethos-icons.svg#i-message"/></svg>
|
||||
|
||||
@@ -67,8 +67,9 @@ type fakeCore struct {
|
||||
traceErr error
|
||||
|
||||
// for handleChatAPI tests
|
||||
chatText string
|
||||
chatErr error
|
||||
chatText string
|
||||
chatSource string
|
||||
chatErr error
|
||||
|
||||
// for the MCP section of /tools
|
||||
mcpServers []ipc.MCPServerStatus
|
||||
@@ -79,12 +80,12 @@ func (f *fakeCore) MCPServers(context.Context) ([]ipc.MCPServerStatus, error) {
|
||||
return f.mcpServers, f.mcpErr
|
||||
}
|
||||
|
||||
func (f *fakeCore) Chat(_ context.Context, _, text string) (string, error) {
|
||||
func (f *fakeCore) Chat(_ context.Context, _, text string) (ipc.ChatReply, error) {
|
||||
f.chatText = text
|
||||
if f.chatErr != nil {
|
||||
return "", f.chatErr
|
||||
return ipc.ChatReply{}, f.chatErr
|
||||
}
|
||||
return "поняла", nil
|
||||
return ipc.ChatReply{Reply: "поняла", Source: f.chatSource}, nil
|
||||
}
|
||||
|
||||
func (f *fakeCore) EnableTool(_ context.Context, name string, cmd []string, destructive bool, scope string, _ time.Time) error {
|
||||
@@ -1285,3 +1286,37 @@ func TestHandleNotifications_ShowsTheOutbox(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- the query source badge (V-539) ---
|
||||
//
|
||||
// Which query source claimed a turn was readable in the daemon log and nowhere
|
||||
// else, so a QA step could not tell a wrong answer from a wrongly ordered
|
||||
// chain. It now rides the redirect and renders beside the reply.
|
||||
|
||||
func TestHandleChatAPI_CarriesTheClaimingSource(t *testing.T) {
|
||||
core := &fakeCore{chatSource: "kiwix"}
|
||||
rr := httptest.NewRecorder()
|
||||
handleChatAPI(rr, postChat("почему небо голубое"), core, nil, false)
|
||||
loc := rr.Header().Get("Location")
|
||||
if !strings.Contains(loc, "s=kiwix") {
|
||||
t.Errorf("redirect = %q; want the claiming source in it", loc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleChatAPI_OmitsTheSourceWhenNothingClaimed(t *testing.T) {
|
||||
core := &fakeCore{}
|
||||
rr := httptest.NewRecorder()
|
||||
handleChatAPI(rr, postChat("запиши что я пил воду"), core, nil, false)
|
||||
if loc := rr.Header().Get("Location"); strings.Contains(loc, "s=") {
|
||||
t.Errorf("redirect = %q; a turn no source claimed carries no badge", loc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatPageRendersTheSourceBadge(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/chat?q=%D1%82%D0%B5%D1%81%D1%82&r=%D0%BE%D1%82%D0%B2%D0%B5%D1%82&s=search", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
handleChatPage(rr, req, &fakeCore{})
|
||||
if body := rr.Body.String(); !strings.Contains(body, ">search</span>") {
|
||||
t.Errorf("chat page does not render the source badge; body=%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
+97
-24
@@ -1094,34 +1094,53 @@ func handleRoutines(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, se
|
||||
var msg string
|
||||
if r.Method == http.MethodPost {
|
||||
action := r.FormValue("action")
|
||||
idStr := r.FormValue("id")
|
||||
var rid int64
|
||||
if n, _ := fmt.Sscanf(idStr, "%d", &rid); n != 1 {
|
||||
http.Error(w, "invalid id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
switch action {
|
||||
case "accept":
|
||||
// "seed" is the one action with no routine to act on — it is what
|
||||
// MAKES a routine (Vikunja #518), so it runs before the id parse. It
|
||||
// lives on this route rather than a page of its own because it is
|
||||
// already the step-up-gated surface for this table, and a second gated
|
||||
// surface is a second thing to get wrong.
|
||||
if action == "seed" {
|
||||
if !stepUpOK(session, requireStepUp) {
|
||||
http.Error(w, "step-up required: assert a passkey first", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if err := acceptRoutine(ctx, core, rid); err != nil {
|
||||
log.Printf("routines: accept %d: %v", rid, err)
|
||||
http.Error(w, "accept failed: "+err.Error(), http.StatusBadGateway)
|
||||
out, err := seedRoutineEvent(ctx, core, r)
|
||||
if err != nil {
|
||||
log.Printf("routines: seed: %v", err)
|
||||
http.Error(w, "seed failed: "+err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
msg = "accepted routine — maven will remind you"
|
||||
case "dismiss":
|
||||
if err := core.DismissProposedRoutine(ctx, rid); err != nil {
|
||||
log.Printf("routines: dismiss %d: %v", rid, err)
|
||||
http.Error(w, "dismiss failed: "+err.Error(), http.StatusBadGateway)
|
||||
msg = out
|
||||
} else {
|
||||
idStr := r.FormValue("id")
|
||||
var rid int64
|
||||
if n, _ := fmt.Sscanf(idStr, "%d", &rid); n != 1 {
|
||||
http.Error(w, "invalid id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
switch action {
|
||||
case "accept":
|
||||
if !stepUpOK(session, requireStepUp) {
|
||||
http.Error(w, "step-up required: assert a passkey first", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if err := acceptRoutine(ctx, core, rid); err != nil {
|
||||
log.Printf("routines: accept %d: %v", rid, err)
|
||||
http.Error(w, "accept failed: "+err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
msg = "accepted routine — maven will remind you"
|
||||
case "dismiss":
|
||||
if err := core.DismissProposedRoutine(ctx, rid); err != nil {
|
||||
log.Printf("routines: dismiss %d: %v", rid, err)
|
||||
http.Error(w, "dismiss failed: "+err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
msg = "dismissed routine"
|
||||
default:
|
||||
http.Error(w, "unknown action", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
msg = "dismissed routine"
|
||||
default:
|
||||
http.Error(w, "unknown action", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
proposed, err := core.ListProposedRoutines(ctx)
|
||||
@@ -1158,6 +1177,45 @@ func toRoutineViews(rs []ipc.ProposedRoutine) []routineView {
|
||||
// may do it (Vikunja #367): accepting gives the tick loop a standing new
|
||||
// reason to speak, which DESIGN.md puts at layer 3, and the button here is
|
||||
// behind step-up. Voice can park the question and dismiss, never accept.
|
||||
// seedRoutineEvent drives one backdated fact write through core (Vikunja #518),
|
||||
// so the pattern detector can be exercised against a running daemon instead of
|
||||
// over real days. Refused unless mavend was started with -allow-seed; on an
|
||||
// ordinary box the error says so and nothing is written.
|
||||
//
|
||||
// Takes "ago" rather than an absolute timestamp — hours before now, as a float
|
||||
// so a QA sitting can space four seeds three hours apart without doing clock
|
||||
// arithmetic. The detector's floor is two hours, and "0" is a legal answer
|
||||
// meaning now.
|
||||
func seedRoutineEvent(ctx context.Context, core ipc.CoreAPI, r *http.Request) (string, error) {
|
||||
key := strings.TrimSpace(r.FormValue("key"))
|
||||
value := strings.TrimSpace(r.FormValue("value"))
|
||||
if key == "" || value == "" {
|
||||
return "", errors.New("seed needs a key and a value")
|
||||
}
|
||||
agoHours, err := strconv.ParseFloat(strings.TrimSpace(r.FormValue("ago")), 64)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("seed: bad ago (hours before now): %w", err)
|
||||
}
|
||||
if agoHours < 0 {
|
||||
return "", errors.New("seed: ago is hours BEFORE now, so it cannot be negative")
|
||||
}
|
||||
resp, err := core.SeedEvent(ctx, ipc.SeedEventReq{
|
||||
Key: key,
|
||||
Value: value,
|
||||
Ts: time.Now().Add(-time.Duration(agoHours * float64(time.Hour))),
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !resp.Extracted {
|
||||
return fmt.Sprintf("wrote fact %d, but %q is not in the action lexicon — no event, no pattern", resp.FactID, value), nil
|
||||
}
|
||||
if !resp.Proposed {
|
||||
return fmt.Sprintf("seeded %s/%s (fact %d, event %d) — not enough yet to propose", resp.Action, resp.Object, resp.FactID, resp.EventID), nil
|
||||
}
|
||||
return fmt.Sprintf("seeded %s/%s and PROPOSED routine %d, every %.1f days", resp.Action, resp.Object, resp.RoutineID, resp.IntervalDays), nil
|
||||
}
|
||||
|
||||
func acceptRoutine(ctx context.Context, core ipc.CoreAPI, id int64) error {
|
||||
proposed, err := core.ListProposedRoutines(ctx)
|
||||
if err != nil {
|
||||
@@ -1540,7 +1598,13 @@ func handlePTT(w http.ResponseWriter, r *http.Request, voiceAddr string, session
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "audio/l16;rate=16000;channels=1")
|
||||
w.Header().Set("X-Reply-Text", url.QueryEscape(pttResp.ReplyText))
|
||||
// PathEscape, not QueryEscape (Vikunja #533). QueryEscape writes a space
|
||||
// as "+", which is form encoding, and the client decodes this header
|
||||
// with decodeURIComponent, which only knows "%20" — so every space in a
|
||||
// spoken reply reached the on-page log as a plus sign. PathEscape is the
|
||||
// flavour decodeURIComponent actually reverses, which keeps the encoding
|
||||
// a property of the header rather than something the client has to know.
|
||||
w.Header().Set("X-Reply-Text", url.PathEscape(pttResp.ReplyText))
|
||||
w.Write(pttResp.ReplyAudio.Bytes)
|
||||
return
|
||||
}
|
||||
@@ -1563,8 +1627,8 @@ func handleChatPage(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
||||
if q := r.URL.Query().Get("q"); q != "" {
|
||||
msgs = append(msgs, chatMsg{Role: "user", Text: q})
|
||||
}
|
||||
if r := r.URL.Query().Get("r"); r != "" {
|
||||
msgs = append(msgs, chatMsg{Role: "assistant", Text: r})
|
||||
if reply := r.URL.Query().Get("r"); reply != "" {
|
||||
msgs = append(msgs, chatMsg{Role: "assistant", Text: reply, Source: r.URL.Query().Get("s")})
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := chatTmpl.Execute(w, struct {
|
||||
@@ -1613,13 +1677,22 @@ func handleChatAPI(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, ses
|
||||
http.Redirect(w, r, "/chat", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/chat?q="+url.QueryEscape(text)+"&r="+url.QueryEscape(reply), http.StatusSeeOther)
|
||||
// The claiming query source rides back on the redirect so the page can show
|
||||
// it. Empty for a turn no source claimed, which is most of them.
|
||||
dest := "/chat?q=" + url.QueryEscape(text) + "&r=" + url.QueryEscape(reply.Reply)
|
||||
if reply.Source != "" {
|
||||
dest += "&s=" + url.QueryEscape(reply.Source)
|
||||
}
|
||||
http.Redirect(w, r, dest, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// chatMsg — one message in the conversation history.
|
||||
type chatMsg struct {
|
||||
Role string // "user" | "assistant"
|
||||
Text string
|
||||
// Source — the query source that claimed the turn, shown as a badge beside
|
||||
// the reply. Empty for a turn no source claimed (V-539).
|
||||
Source string
|
||||
}
|
||||
|
||||
func mustMarshal(v any) json.RawMessage {
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// decodeURIComponent is what static/app.js calls on X-Reply-Text. PathUnescape
|
||||
// is its Go equivalent for this purpose: both turn %XX into bytes and both
|
||||
// leave a literal "+" alone. That last part is the whole defect — QueryEscape
|
||||
// wrote spaces as "+" and the client had no way to tell those from a plus the
|
||||
// speaker actually said.
|
||||
func decodeURIComponent(t *testing.T, s string) string {
|
||||
t.Helper()
|
||||
out, err := url.PathUnescape(s)
|
||||
if err != nil {
|
||||
t.Fatalf("decodeURIComponent(%q): %v", s, err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// The reply the QA session actually saw was "на+04.08.2026+ничего+нет."
|
||||
// (Vikunja #533). Round-tripping through the client's decoder is the assertion
|
||||
// that matters — checking the encoder in isolation would have passed with
|
||||
// QueryEscape too.
|
||||
func TestReplyTextSurvivesTheClientDecoder(t *testing.T) {
|
||||
cases := []string{
|
||||
"на 04.08.2026 ничего нет.",
|
||||
"Я поставила тебе напоминание позвонить маме через час.",
|
||||
// A literal plus must stay a plus, which is the case that makes
|
||||
// "just replace + with space on the JS side" the wrong fix.
|
||||
"два плюс два = 2+2",
|
||||
// Headers cannot carry a raw newline. PathEscape writes %0A.
|
||||
"первая строка\nвторая строка",
|
||||
"", // no reply text at all
|
||||
}
|
||||
for _, want := range cases {
|
||||
encoded := url.PathEscape(want)
|
||||
if strings.ContainsAny(encoded, "\r\n") {
|
||||
t.Errorf("encoded %q contains a raw newline, which is not a legal header value", want)
|
||||
}
|
||||
if got := decodeURIComponent(t, encoded); got != want {
|
||||
t.Errorf("round trip: got %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The specific regression, named. QueryEscape is form encoding and this header
|
||||
// is not a form.
|
||||
func TestReplyTextDoesNotUseFormEncoding(t *testing.T) {
|
||||
const spoken = "на 04.08.2026 ничего нет."
|
||||
if got := decodeURIComponent(t, url.QueryEscape(spoken)); got == spoken {
|
||||
t.Skip("QueryEscape round-trips here, so this test proves nothing — check the decoder stand-in")
|
||||
}
|
||||
if strings.Contains(url.PathEscape(spoken), "+") {
|
||||
t.Errorf("PathEscape(%q) still writes a plus", spoken)
|
||||
}
|
||||
}
|
||||
@@ -90,6 +90,7 @@
|
||||
"kiwix": {
|
||||
"url": "http://kiwix-server:8080",
|
||||
"book": "wikipedia_en_all_maxi_2026-02",
|
||||
"book_ru": "wikipedia_ru_all_maxi_2026-02",
|
||||
"max_results": 5,
|
||||
"snippet_runes": 1500
|
||||
},
|
||||
|
||||
+6
-1
@@ -12,7 +12,12 @@ x-image: &image
|
||||
# build on EVERY service (same image name ⇒ built once) so `docker compose
|
||||
# build <anyservice>` actually rebuilds. With build on only one service, the
|
||||
# others silently no-op and you deploy a stale binary.
|
||||
build: .
|
||||
build:
|
||||
context: .
|
||||
# the zone is declared once, here. The image points /etc/localtime at it so
|
||||
# a caller reading the system zone agrees with one reading TZ (V-545).
|
||||
args:
|
||||
TZ: Europe/Samara
|
||||
pull_policy: never # only ever the locally-built image
|
||||
restart: unless-stopped
|
||||
# local time for clock/date replies AND quiet-hours evaluation. Change to
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
# Half-past and quarter-to hours, 2026-08-05
|
||||
|
||||
Vikunja V-538. `rewriteHalfPast` in `internal/router/halfpast.go`, run in front of
|
||||
the token pass inside `SpellOutDigits`, so both date parsers see digits.
|
||||
|
||||
## What the shapes are
|
||||
|
||||
Russian names a half hour by the hour being ENTERED, in the genitive. "половина
|
||||
восьмого" is 07:30. "без четверти восемь" counts the other way, from a cardinal,
|
||||
and is 07:45. Both are minus one from the word in the sentence, and the arithmetic
|
||||
lives in one function, `clockHourBefore`.
|
||||
|
||||
## Result
|
||||
|
||||
| | before | after |
|
||||
|---|---|---|
|
||||
| classifier + onnx over the routing fixture | 58/82 (70.7%) | 62/87 (71.3%) |
|
||||
| new fixture cases passing | — | 2 of 3 |
|
||||
| stub parser reads a half hour | no | yes |
|
||||
|
||||
The three new cases are ru-rem-008, ru-rem-009 and ru-rem-010. No existing case
|
||||
regressed and no new clarify appeared.
|
||||
|
||||
ru-rem-009, "разбуди меня полвосьмого", still misses the intent. Its two siblings
|
||||
without a half hour miss it the same way. ru-rem-005 "разбуди меня в 6:30" routes
|
||||
to `fact`, and en-rem-002 "wake me at 6:15" does too. So the miss is the "разбуди"
|
||||
phrasing against the classifier, not the half hour. The time slot now fills.
|
||||
|
||||
## Not measured here
|
||||
|
||||
Python dateparser. It is not installed on this host, so only the stub was run.
|
||||
The rewrite emits "в 7:30 вечера". The script's own qualifier rewrite turns that
|
||||
trailing "вечера" into "pm", which is the shape it already reads for a whole hour.
|
||||
Judge it on the box.
|
||||
|
||||
The LLM arm. No llama-server in this run, so the cascade number is the classifier
|
||||
floor.
|
||||
|
||||
## Left out on purpose
|
||||
|
||||
Minutes a spoken clock does not use. "без семи восемь" is not rewritten, because
|
||||
nobody says it and a guess in this shape is a missed dose. The parsers fail on it
|
||||
as they did before.
|
||||
@@ -0,0 +1,65 @@
|
||||
# Does the ZIM answer when the line is down? (V-508)
|
||||
|
||||
Measured 2026-08-05 on the deploy, through `POST /api/chat`. The question was
|
||||
`что такое фотосинтез` in every run. Which source claimed is read off
|
||||
`voice: query claimed by source` and off the badge V-539 added.
|
||||
|
||||
## It fires, and it is fast when the host is gone
|
||||
|
||||
`docker stop searxng`, then one question:
|
||||
|
||||
| | Claimed by | Turn |
|
||||
|---|---|---|
|
||||
| Search reachable | search | 3.5 s |
|
||||
| Container stopped | kiwix | 3.5 s |
|
||||
| Host blackholed | kiwix | 15.4 s |
|
||||
|
||||
With the container stopped, DNS failed and the ZIM answered inside the same
|
||||
second:
|
||||
|
||||
```
|
||||
15:40:51 voice: search "что такое фотосинтез": ... lookup searxng: no such host
|
||||
15:40:51 voice: kiwix: "photosynthesis" → 5 hits, top "Photosynthesis"
|
||||
15:40:53 voice: query claimed by source "kiwix"
|
||||
```
|
||||
|
||||
The rewrite, the search and the reply all fit in the same turn budget as a live
|
||||
search. The fallback works.
|
||||
|
||||
## The blackhole is the case that hurts
|
||||
|
||||
192.0.2.1 is reserved and routed nowhere. Pointing `search.url` at it is the
|
||||
shape of a real outage: the router drops the packet instead of refusing it. The
|
||||
search sat for its full 8-second budget before the ZIM was asked. The turn took
|
||||
15.4 seconds against 3.5. He waits through all of it with nothing
|
||||
being said.
|
||||
|
||||
Fixed by capping the connect phase alone at 1.5 s (`dialTimeout` in
|
||||
`internal/websearch/searxng.go`). The instance is on the LAN, so a connection it
|
||||
will ever accept is accepted in milliseconds. A reachable instance that is
|
||||
merely slow still gets the whole 8 seconds. It is fanning out to real engines,
|
||||
which is worth waiting for.
|
||||
|
||||
## The Russian ZIM is now on the box and is read directly
|
||||
|
||||
`wikipedia_ru_all_maxi_2026-02` (41 GB) was copied to the kiwix zims directory
|
||||
and kiwix-serve picked it up. Note that the catalog name is derived from the
|
||||
filename. `books.name=wikipedia_ru_all_maxi_2026-02` returns Фотосинтез,
|
||||
С4-фотосинтез and Википедия. The `<name>` field in the catalog says
|
||||
`wikipedia_ru_all`, which returns nothing.
|
||||
|
||||
A Cyrillic question now searches that book verbatim (`book_ru` in the `kiwix`
|
||||
block). The rewriter was never a feature. An English ZIM cannot match a Russian
|
||||
sentence, so the resident model translated the question into English keywords
|
||||
first. That costs a model call. It also drops whatever the keywords do not carry.
|
||||
Against a Russian book it is a translation of his own words back at him.
|
||||
|
||||
## Not measured here
|
||||
|
||||
- The Russian book answering a driven turn. The `book_ru` field is a binary
|
||||
change, so it needs a rebuild the owner runs. The book itself was verified by
|
||||
querying kiwix-serve directly.
|
||||
- Recall against the Russian book compared with the rewrite path. Reading his
|
||||
own language directly should win, and it was not scored.
|
||||
- `ru.stackoverflow.com_mul_all_2026-02.zim` is still in the staging directory
|
||||
and is wired to nothing.
|
||||
@@ -0,0 +1,63 @@
|
||||
# Praxis reach at stage 0, 2026-08-05
|
||||
|
||||
Vikunja #516. Measured with `make eval-reach` on the held-out ecosystem fixture
|
||||
(`internal/router/eval/ru_ecosystem_v1.json`, 30 cases), classifier + ONNX embedder,
|
||||
no llama-server in the run. The LLM arm was not measured, so judge a cascade
|
||||
number again before quoting one.
|
||||
|
||||
## Result
|
||||
|
||||
| | before | after |
|
||||
|---|---|---|
|
||||
| overall | 16/30 (53.3%) | 27/30 (90.0%) |
|
||||
| by want: praxis | 0/12 | 11/12 |
|
||||
| by want: hexis | 9/10 | 9/10 |
|
||||
| by want: none | 7/8 | 7/8 |
|
||||
| by tag: lifecycle | 0/5 | 5/5 |
|
||||
| by tag: attention | 0/7 | 6/7 |
|
||||
| by tag: reading | 0/7 | 6/7 |
|
||||
| wrong praxis arm | 0 | 0 |
|
||||
| p50 latency | 20.6ms | 16.5ms |
|
||||
|
||||
## Why it was zero
|
||||
|
||||
Not a tuning gap. `handlePraxisAct` dispatches on exact equality between
|
||||
`Slots.Fn` and a capability alias, and the fn slot is filled by `DefaultActMatcher`
|
||||
from the deployment's enabled tool names. No Praxis alias is on that list, so no
|
||||
utterance could put one in the slot. The Russian aliases in `praxisCapabilities`
|
||||
read as if they matched speech. They are compared against a fn slot and never
|
||||
against an utterance.
|
||||
|
||||
`PraxisGrammars()` (`internal/router/praxis.go`) fills the slot at stage 0, wired in
|
||||
`buildRouter` before the capture marker because "отметь" is a capture verb.
|
||||
|
||||
## The three misses that remain
|
||||
|
||||
- `eco-ru-006` "запусти бэкап на нексусе", a Hexis case, routed note. Pre-existing.
|
||||
- `eco-ru-028` "выключи", reached Hexis, should have asked. Pre-existing.
|
||||
- `eco-ru-021` "что там с нексусом" wants scoped attention. Deliberately not
|
||||
claimed. "что там с X" also opens "что там с погодой". Routing a weather
|
||||
question to Nexus is worse than one missed fixture case.
|
||||
|
||||
## Two judgement calls worth re-arguing
|
||||
|
||||
**A lifecycle word alone does not transition an item.** "готово" is what he says
|
||||
about the thing he just finished. So the rules split lifecycle words by mood. An
|
||||
imperative he says to her ("закрывай") claims the turn bare, and the capability
|
||||
asks which пункт. A stative ("готово", "принято") needs an item named beside it.
|
||||
|
||||
The bare-imperative arm also requires that nothing else in the sentence is being
|
||||
acted on. "закрой шторы в комнате" is an imperative too. Without that guard it took
|
||||
a house command to Praxis, measured at hexis 8/10 mid-change.
|
||||
|
||||
**A demonstrative resolves only against a one-item digest.** "отметь это как
|
||||
сделанное" points at what she just read. `resolveSurfacedPosition` maps it to an id
|
||||
only when exactly one item was spoken. With two or more it gives the turn back to
|
||||
the cascade rather than transitioning one of them at random. With no digest at all
|
||||
it gives the turn back too, because "я это сделал" was never about a пункт.
|
||||
|
||||
## Routing fixture
|
||||
|
||||
`make eval-router`, same run: classifier + ONNX 60/84 (71.4% full and intent-only),
|
||||
0 false clarifies, 6 missed clarifies (the known `amb-*` set). No failure in that
|
||||
list comes from a stage-0 decision. Every one carries a classifier confidence score.
|
||||
@@ -0,0 +1,78 @@
|
||||
# Does SearXNG claim a question it cannot answer? (V-539)
|
||||
|
||||
Measured 2026-08-05 against the configured instance, `http://127.0.0.1:9563`,
|
||||
`max_results: 4`, `language: auto`. Sixteen Russian questions: eight real, eight
|
||||
invented from non-words. The probe read SearXNG's JSON directly, so this measures
|
||||
the search, not the cascade around it.
|
||||
|
||||
## The premise no longer reproduces
|
||||
|
||||
V-539 was filed on the 2026-08-02 measurement, where SearXNG returned four
|
||||
results for every query including `зыркабулентный флогистон Мшанского`, and no
|
||||
`voice: kiwix:` line ever appeared. Today the same shape of query returns
|
||||
nothing:
|
||||
|
||||
| Query set | Zero results | Four results claimed |
|
||||
|---|---|---|
|
||||
| Eight real questions | 0 | 8 |
|
||||
| Eight invented questions | 7 | 1 |
|
||||
|
||||
`Response.Empty()` is already the gate. Seven of eight invented questions now
|
||||
pass the turn to the ZIM with no code change at all. What changed is upstream.
|
||||
Every real answer today comes from `google cse`. It answers a non-word with an
|
||||
empty result set, where the engine set of three days ago answered with
|
||||
something.
|
||||
|
||||
## The one that still claims
|
||||
|
||||
`трюмбальная нидроскопия` returned four results, all about a lumbar puncture:
|
||||
|
||||
```
|
||||
Люмбальная пункция - адреса и стоимость в больницах в СПб
|
||||
Пункция спинного мозга - Больница «Шиба
|
||||
Педиатрический фантом люмбальной пункции новорожденного
|
||||
```
|
||||
|
||||
The engine read the invented word as a misspelling of a real one and answered
|
||||
the real one. That is the whole remaining failure, and it is a near-miss
|
||||
spelling rather than a catch-all.
|
||||
|
||||
## The three candidate signals do not separate the sets
|
||||
|
||||
V-539 named three signals a quality gate could read. Each was recorded per
|
||||
query:
|
||||
|
||||
- **No result title shares a token with the query.** Useless. It is true of the
|
||||
one bad claim, and also true of `столица Франции`, whose four titles are
|
||||
`Париж`, `Франция`, `Париж — Путеводитель`, `Париж - Море Трэвел`. The right
|
||||
answer to a capital-city question is the city, which is not a word in the
|
||||
question. Two more real questions score 3 of 4 rather than 4.
|
||||
- **Every snippet is empty.** Never fired. Zero empty snippets across all
|
||||
sixteen queries, real or invented. `ParseResponse` already drops a hit with no
|
||||
text, so this signal cannot fire by construction.
|
||||
- **A spelling-suggestion or catch-all engine answered.** Never fired. SearXNG
|
||||
returned no `corrections` and no `suggestions` for any query, including the one
|
||||
that silently corrected the spelling itself.
|
||||
|
||||
## Decision: do not build the threshold
|
||||
|
||||
A gate on token overlap would cost `столица Франции` a correct answer to save
|
||||
one invented word, and the other two signals cannot fire. The task said a wrong
|
||||
threshold costs a real answer and needs measuring first. It was measured and it
|
||||
loses.
|
||||
|
||||
What ships instead is the second half of V-539. The claiming query source now
|
||||
crosses the IPC seam on `ipc.ChatReply.Source`. It renders as a badge beside the
|
||||
reply on `/chat`. The only evidence before it was a `voice:` log line, which is
|
||||
why this was hard to judge. The next occurrence is readable off the UI rather
|
||||
than off the box.
|
||||
|
||||
## Not measured here
|
||||
|
||||
- The cascade. This probe read SearXNG directly. It says nothing about how
|
||||
`querySearch` phrases what it gets, or whether the resident model turns four
|
||||
weak snippets into a confident wrong sentence.
|
||||
- Kiwix. It was healthy on 2026-08-02 and was not re-probed today.
|
||||
- English questions. The premise was about Russian, where the invented words are.
|
||||
- Whether the engine set is stable. The whole finding is that it moved in three
|
||||
days, so this table is a reading of one day.
|
||||
@@ -0,0 +1,60 @@
|
||||
# Talk fixture against the resident model, 2026-08-05
|
||||
|
||||
Vikunja #44 step 1. `MAVEN_LLM_URL=http://127.0.0.1:8899 make eval-phrasing`,
|
||||
Qwen3-1.7B-UD-Q4_K_XL on the host, no workstation in the run. The fixture holds
|
||||
36 cases now, against 27 when the bakeoff measured it. So the old score is not
|
||||
a column in this table.
|
||||
|
||||
## Result
|
||||
|
||||
| | before the escape fix | after |
|
||||
|---|---|---|
|
||||
| talk, passes every check | 2/36 (5.6%) | 25/36 (69.4%) |
|
||||
| failed generations | 31 | 0 |
|
||||
| by path: chat | 0/9 | 4/9 |
|
||||
| by path: knowledge | 1/9 | 6/9 |
|
||||
| by path: query | 0/9 | 9/9 |
|
||||
| by path: reply | 1/9 | 6/9 |
|
||||
| feminine | 5/36 | 36/36 |
|
||||
| address | 5/36 | 33/36 |
|
||||
| ontopic | 2/36 | 28/36 |
|
||||
| p50 latency | 3.05s | 2.97s |
|
||||
| nudges (15 cases) | 15/15 | 15/15 |
|
||||
|
||||
## What the 31 errors were
|
||||
|
||||
Not the model. `escapeRawControls` in `internal/phraser/llmphraser.go`, added
|
||||
for #537 to repair a raw newline written inside a string, escaped the whole
|
||||
object. Qwen3-1.7B pretty-prints: it opens `{` and writes three newlines before
|
||||
the first key. Those newlines became a literal backslash-n, which is legal
|
||||
nowhere outside a string, so the object stopped parsing and `parseResponseMood`
|
||||
reported `errBrokenJSON`.
|
||||
|
||||
The comment said escaping unconditionally could not turn valid JSON into
|
||||
anything else, because JSON permits no control character outside a string. It
|
||||
permits three. Newline, tab and return are whitespace between tokens, and that
|
||||
is what pretty-printing is made of.
|
||||
|
||||
Every chat reply and every knowledge answer the resident model wrote was being
|
||||
discarded for a stub line. The nudge path never showed it, because the nudge
|
||||
prompt gets compact JSON back.
|
||||
|
||||
## The 11 that still fail
|
||||
|
||||
Eight are `ontopic`, three are `address`.
|
||||
|
||||
The address failures are all plural imperatives written to a formal listener:
|
||||
`держите`, `уточните`, `попробуйте`. Feminine self-reference held in all 36,
|
||||
which is the half #122 is training for. So the persona gap the CPT is aimed at
|
||||
is now the address half, not the gender half.
|
||||
|
||||
The ontopic failures are the resident model answering next to the question
|
||||
rather than in it. `chat-joke` describes crying dolls instead of telling one,
|
||||
`know-hiccups` calls hiccups an icon, `know-boil-egg` answers about an omelette.
|
||||
`chat-about-me` answers "Я - записка", which is the same confabulation the
|
||||
bakeoff recorded.
|
||||
|
||||
## Not measured here
|
||||
|
||||
The workstation. Every number above is the homesrv floor. `make eval-phrasing`
|
||||
points at one URL, so a gemma-4-12b column needs its own run.
|
||||
@@ -0,0 +1,58 @@
|
||||
# Talk temperature sweep: Qwen3-1.7B, 4 temperatures × 3 runs
|
||||
|
||||
Date: 05-08-2026. Model: Qwen3-1.7B-UD-Q4_K_XL, the resident model, on homesrv.
|
||||
Harness: `TestTalkTemperatureSweep` (`internal/phraser/eval/temperature_test.go`),
|
||||
gated on `MAVEN_LLM_URL` + `MAVEN_TEMP_SWEEP`. Fixture: the 36-case talk set.
|
||||
Wall clock: 3394s for all twelve runs. Vikunja #402.
|
||||
|
||||
## What was asked
|
||||
|
||||
Whether 0.7 is the right sampling temperature for phrasing, and whether a lower
|
||||
one buys persona compliance.
|
||||
|
||||
## Numbers
|
||||
|
||||
| temp | run 1 | run 2 | run 3 | mean | errors |
|
||||
|---|---|---|---|---|---|
|
||||
| 0.70 | 24/36 | 24/36 | 22/36 | 23.3 (64.8%) | 7, 4, 6 |
|
||||
| 0.40 | 25/36 | 25/36 | 26/36 | 25.3 (70.4%) | 3, 4, 5 |
|
||||
| 0.20 | 22/36 | 23/36 | 26/36 | 23.7 (65.7%) | 6, 5, 4 |
|
||||
| 0.05 | 23/36 | 25/36 | 23/36 | 23.7 (65.7%) | 5, 5, 6 |
|
||||
|
||||
## What it says
|
||||
|
||||
**The sweep does not separate the temperatures.** 0.40 leads by 5.6 points on
|
||||
the mean. The spread inside a single temperature is 11 points: 0.20 ranges 22 to
|
||||
26 across three runs of the same setting. Three runs cannot tell a 5.6-point
|
||||
effect from that noise. Lowering the temperature to 0.05 does not help either.
|
||||
That is the result that would have been most useful if it had.
|
||||
|
||||
**So the default stays 0.7.** `Config.Temperature` is now a config field, so
|
||||
setting it is a one-line change. No measurement here justifies moving it. Anyone
|
||||
re-running this needs more runs per setting, not more settings.
|
||||
|
||||
## The finding that is not about temperature
|
||||
|
||||
Sixty of the failures across twelve runs are one error:
|
||||
`phraser: model output starts as JSON but does not parse`. The case fails with
|
||||
an empty string, so it costs a whole case rather than one check.
|
||||
|
||||
They are not spread evenly. Every one lands in the `reply` family, and the
|
||||
distribution is:
|
||||
|
||||
| case | runs failed (of 12) |
|
||||
|---|---|
|
||||
| reply-reminder-tomorrow | 12 |
|
||||
| reply-reminder-evening | 12 |
|
||||
| reply-question-bait | 11 |
|
||||
| reply-formality-bait | 9 |
|
||||
| reply-note-router | 8 |
|
||||
| reply-fact-weight | 6 |
|
||||
|
||||
Two cases fail in every single run at every temperature. That is not sampling
|
||||
noise, and no temperature will fix it. It is a defect in the reply phrasing
|
||||
path. It caps the talk fixture at 30/36 before persona is scored at all. Filed
|
||||
as Vikunja #537.
|
||||
|
||||
The talk score of 27/36 recorded on 2026-08-04 went through a different call
|
||||
path. It is not comparable to the numbers above.
|
||||
@@ -0,0 +1,82 @@
|
||||
# Gemma as a label function, and what it found in the seeds
|
||||
|
||||
**06-08-2026. V-546.** Measured on workpc against gemma-4-12b-it-qat-UD-Q4_K_XL.
|
||||
|
||||
`docs/plans/18-routing-heads-on-e5-small.md` puts the labeled set at 20k examples through
|
||||
gemma, costing 2 to 4 hours of the card. This is the check before spending that. Gemma
|
||||
labels the 344 hand-written classifier seeds. Agreement with the label a person already
|
||||
chose is a precision number rather than a guess.
|
||||
|
||||
## What ran
|
||||
|
||||
`cmd/labelgen` runs the stage 0 grammars. The real ones, in `buildRouter` order, minus
|
||||
`wakeword-act`, whose allowlist is a deployment's enabled tool names. It labels 62 of 339
|
||||
seed lines and leaves the rest.
|
||||
|
||||
The remaining 277 went to gemma through the daemon's own `routeSystem` prompt and
|
||||
`routeGrammar`, both extracted from `internal/router/llmrouter.go` at run time rather than
|
||||
retyped. Temperature 0.
|
||||
|
||||
## Cost
|
||||
|
||||
**334ms per call, 0 unparsed of 277.** The GBNF held every time. At that rate the plan's
|
||||
20k examples is under two hours of card, which matches its estimate.
|
||||
|
||||
## The stage 0 rules as label functions
|
||||
|
||||
Agreement between the grammar's label and the seed file the line came from:
|
||||
|
||||
| seed intent | agree |
|
||||
|---|---|
|
||||
| reminder | 37/37 |
|
||||
| query | 9/10 |
|
||||
| system | 7/8 |
|
||||
| act | 2/2 |
|
||||
| chat | 0/4 |
|
||||
| note | 0/1 |
|
||||
|
||||
`ReminderGrammar` at 37/37 is the evidence the plan wanted. The chat column is a defect
|
||||
rather than a disagreement: `chatNarrativeTopics` is Russian-only, so `tell me about
|
||||
yourself` survives the decline and routes IntentQuery with topic `yourself`. Filed as
|
||||
V-625, which also records that `как дела у сервера` appears verbatim in two seed files
|
||||
under two intents.
|
||||
|
||||
## Gemma against the seeds
|
||||
|
||||
**197/277, 71.1%.** By intent:
|
||||
|
||||
| seed intent | agree |
|
||||
|---|---|
|
||||
| note | 33/33 |
|
||||
| act | 57/64 |
|
||||
| fact | 37/40 |
|
||||
| query | 51/54 |
|
||||
| chat | 15/35 |
|
||||
| system | 4/43 |
|
||||
| reminder | 0/8 |
|
||||
|
||||
The number is not gemma's error rate. Reading the 80 disagreements, most are the seed files
|
||||
and the prompt holding different definitions of the same intent. Three boundaries carry 42
|
||||
of them, and V-626 is the fix:
|
||||
|
||||
- **system, 26 lines.** The prompt restricts system to the clock, the calendar date and the
|
||||
assistant itself. The seeds also put sensor and host state there. That is the V-374 edit
|
||||
of 31-07-2026, which the seeds never received.
|
||||
- **world questions, 8 lines.** `почему небо голубое`, `why is the sky blue`. Written when
|
||||
chat was the only honest destination for a question nothing could answer, and external
|
||||
search now answers them.
|
||||
- **bare verbs, 8 lines.** `поставь напоминание` with nothing to remind about. The prompt
|
||||
calls that unknown. This one is not staleness. A nearest-neighbour centroid wants the
|
||||
bare verb phrase, and that is what a seed file is for.
|
||||
|
||||
Four intents have not been redefined since the seeds were written: note, fact, query and
|
||||
act. They agree at 178 of 191.
|
||||
|
||||
## What this says about the plan
|
||||
|
||||
Gemma is usable as a label function on those four and not on system, chat or a bare verb.
|
||||
The plan already budgets a day of the owner reading the set. This says where to spend it.
|
||||
|
||||
It also says the two engines in the cascade are being taught different rules on 80 lines.
|
||||
A routing measurement that swaps between the classifier and the router is measuring some of
|
||||
that disagreement rather than the models.
|
||||
@@ -0,0 +1,148 @@
|
||||
# Plan: route with heads on e5-small, not with a generative model
|
||||
|
||||
**Owner's call, 05-08-2026. Vikunja #546.**
|
||||
|
||||
**Verdict: the routing model is the 118M multilingual-e5-small already resident on
|
||||
homesrv.** It gets one classification head per output. No LoRA on a decoder, no 100M model
|
||||
trained from scratch. Routing has a bounded output space, so it is classification. A model
|
||||
that generates is being asked to do the wrong job.
|
||||
|
||||
Last verified: 05-08-2026 @ 52fd218
|
||||
|
||||
## The two options this rules out
|
||||
|
||||
**A LoRA on Qwen3-0.6B or 1.7B.** About 1 to 4 GPU hours on the workstation, for 20k
|
||||
examples over three epochs. It works, and it still generates. So the output still needs a
|
||||
GBNF grammar in front of it. The confidence still has to be rebuilt from structure, the way
|
||||
`gateLLMDecision` does today.
|
||||
|
||||
**A 100M decoder from scratch.** It needs roughly 2B tokens to be a usable language model.
|
||||
That is 6 × 1e8 × 2e9, about 1.2e18 FLOPs. A 16GB card
|
||||
does that in 10 to 20 GPU hours at its effective throughput. It also needs a Russian tokenizer built and a corpus assembled.
|
||||
What it buys is a small model generating Russian, and `CLAUDE.md` already records that as
|
||||
the thing that does not work. LFM2.5-350M routes at 5.2% and answered "столица Франции?"
|
||||
with the invented non-word "Сторзит".
|
||||
|
||||
That finding is about generation, not about size. A 118M encoder classifying Russian is a
|
||||
different job with a bounded output space. The measured recall of e5-small on this box is
|
||||
the evidence it reads the language well enough.
|
||||
|
||||
## What the model becomes
|
||||
|
||||
The encoder body stays as it is. Three heads sit on top of one forward pass:
|
||||
|
||||
| Output | Head | Reads |
|
||||
|---|---|---|
|
||||
| intent | `Linear(384, 7)` | the mean-pooled vector |
|
||||
| mood | `Linear(384, 5)` | the mean-pooled vector |
|
||||
| slots | `Linear(384, 9)` per token | `last_hidden_state` |
|
||||
|
||||
Seven intents are the existing enum: fact, reminder, note, query, act, chat, system. Five
|
||||
moods are the existing enum: neutral, happy, thinking, tired, confused. Nine slot tags are
|
||||
BIO over `key`, `value`, `when` and `fn`, plus outside.
|
||||
|
||||
Total head size is about 12k parameters. That number decides how this is served. See the
|
||||
serving section below.
|
||||
|
||||
## Two things this buys that the current router cannot
|
||||
|
||||
**Constrained output stops being a grammar problem.** There is no free generation, so the
|
||||
heads can only emit values that exist. Four mechanisms exist today because a decoder can
|
||||
write anything. The GBNF grammar, the JSON parse, the fallback to plain text, the legacy
|
||||
`{"body","summary"}` path. A softmax cannot write anything.
|
||||
|
||||
**Confidence becomes a real number.** `Confidence: 1.0` was hardcoded in `llmrouter.go`,
|
||||
so the router could never ask for clarification. V-359 had to rebuild a signal out of
|
||||
structure: single-token utterance, keyless fact, act with no allowlisted fn. Max softmax
|
||||
over the intent head is calibratable against the fixture. `r.threshold` and the stage-3
|
||||
gate would read a probability instead of a proxy. Two false clarifies survived the V-359
|
||||
fix, both in the act-with-no-allowlisted-fn arm. That is the arm a calibrated score
|
||||
replaces.
|
||||
|
||||
## Cost
|
||||
|
||||
About 20k examples at 64 tokens over five epochs is 6.4M tokens. So 6 × 1.2e8 × 6.4e6,
|
||||
roughly 5e15 FLOPs. **10 to 30 minutes on the workstation. Under 2GB of VRAM.** It also
|
||||
finishes overnight on homesrv's CPU when the card is busy. That matters, because the
|
||||
workstation is never assumed up.
|
||||
|
||||
Freeze the embedding table. The XLM-R vocabulary is about 96M of the 118M parameters, and
|
||||
it is the part that overfits 20k examples. Train the twelve layers and the heads, at 2e-5
|
||||
on the body and 1e-3 on the heads, batch 32, sequence 64. Loss is cross-entropy on intent
|
||||
plus mood plus per-token tags, with the tag term down-weighted.
|
||||
|
||||
## The trap: fine-tune a copy
|
||||
|
||||
The resident embedder backs memory recall. `docs/evals/2026-08-04-recall-e5-small.md`
|
||||
measured ten points of recall@1 above MiniLM, at 2.5× the speed. `CLAUDE.md` says it stays
|
||||
on homesrv permanently, because it backs the floor.
|
||||
|
||||
Training it in place couples routing accuracy to recall. Say a run gains four points of
|
||||
intent accuracy and quietly loses six of recall@1. It would look like a win, and nothing
|
||||
in the test suite would name the trade. So the routing weights are a second file. About
|
||||
45MB extra, quantized. `modelIDFromPath` already derives the DB marker from the filename.
|
||||
So two files means two ids, and no ambiguity about which model wrote an embedding.
|
||||
|
||||
## Serving: the heads do not need a runtime
|
||||
|
||||
`internal/router/onnxembedder.go` already asks the ONNX session for `last_hidden_state` at
|
||||
`[1, 128, 384]` and mean-pools in Go. Both tensors the heads need already cross into Go on
|
||||
every call.
|
||||
|
||||
At 12k parameters the heads are three dot products. They can be plain Go over a weights
|
||||
file rather than a second ONNX graph. Then the export step covers the encoder only, and
|
||||
the head weights are data. That keeps the whole thing inside the existing session, the
|
||||
existing vendored tokenizer and the existing `TestONNX*` tests.
|
||||
|
||||
Wire it where the router sits. `pickLLMRouter` becomes a three-way choice. The classifier
|
||||
stays underneath as the floor. The rule that keeps it there now still holds: a turn must
|
||||
never break on a model. The resident model keeps chat, world answers and phrasing. None of that
|
||||
is classification, and this cannot do it.
|
||||
|
||||
## The labeled set is the whole project
|
||||
|
||||
There are 77 RU routing cases and 30 Praxis cases today. That is a test set, not a
|
||||
training set.
|
||||
|
||||
The stage 0 grammars are high-precision label functions. `AgendaQueryGrammars`,
|
||||
`NarrativeQueryGrammar` and `PraxisGrammars` each decide a shape deterministically, so
|
||||
running them over the turn history self-labels it. Training on their output distils the
|
||||
rules into the model. That is the point rather than a compromise: the model generalizes
|
||||
past a regex, where Go's `\b` never fires after a Cyrillic letter.
|
||||
|
||||
Two rules for the data:
|
||||
|
||||
- **The fixtures stay out of training.** Otherwise the measurement reads the rules and
|
||||
reports them as the model.
|
||||
- **Keep cases the grammars do not cover.** A set labeled only by the rules teaches only
|
||||
the rules. The hard cases carry no interrogative and no question mark, which is why
|
||||
V-498 existed.
|
||||
|
||||
Expanding to 20k runs through gemma-4-12b on the workstation. `docs/evals/2026-08-02-workstation-gemma4-12b.md`
|
||||
measured 329ms per call. So that is 2 to 4 hours of the card, plus a day of the owner
|
||||
reading it. That is the real cost of this plan.
|
||||
|
||||
## How it gets judged
|
||||
|
||||
The 77-case RU routing fixture, against the two numbers that always answer:
|
||||
|
||||
| Path | Full | Intent-only | p50 |
|
||||
|---|---|---|---|
|
||||
| classifier | 68.8% | — | 16.6µs |
|
||||
| resident Qwen3-1.7B, through the cascade | 72.7% | 77.9% | 0.80 to 1.04s |
|
||||
| heads on e5-small | to measure | to measure | to measure |
|
||||
|
||||
Latency should land near the classifier rather than near the router. This is one encoder
|
||||
pass and three dot products, against the classifier's one encoder pass and a
|
||||
nearest-neighbour scan. If it does, V-464 answers itself, and the latency trade the LLM
|
||||
router asks for stops being a trade.
|
||||
|
||||
## Not decided here
|
||||
|
||||
- Whether mood belongs on this model at all. Mood is a phrasing property, and the router
|
||||
emitting it is a convenience. A fifth head is cheap, so this is a question about where
|
||||
the value is read, not about cost.
|
||||
- Whether the slot head replaces the stage 0 grammars or sits behind them. Cascade order
|
||||
is a separate measurement, and the rules are currently faster and exact.
|
||||
- The confidence calibration method. Temperature scaling on a held-out split is the
|
||||
obvious first try, and it has not been measured.
|
||||
+116
-3
@@ -1,6 +1,6 @@
|
||||
# QA plan: checking Maven properly
|
||||
|
||||
*Last verified: 2026-08-04 @ a4d5155. Living doc: correct it in place, do not append.*
|
||||
*Last verified: 2026-08-04 @ 8d816f4. Living doc: correct it in place, do not append.*
|
||||
|
||||
Written 2026-08-01, after the 35-PR stack landed and the box came back up.
|
||||
Refreshed 2026-08-02 against the live list, after PRs #85-#90.
|
||||
@@ -88,8 +88,71 @@ session quality), **321** steps 3-5 (quiet mode), **288** (STT golden audio).
|
||||
**288 is not blocked.** The fixtures are committed under `cmd/mavsttd/testdata/`
|
||||
and `make test-stt-golden` runs today. This plan said otherwise until 02-08-2026.
|
||||
|
||||
Steps 1 and 3-6 were run on 02-08-2026 and pass. Steps 2 and 7-9 still need a
|
||||
person at the box, because they need a microphone or a nudge to arrive.
|
||||
Steps 1 and 3-6 were run on 02-08-2026 and pass.
|
||||
|
||||
**Step 2 no longer needs a person, and step 9 has a number now** (04-08-2026).
|
||||
`POST /api/ptt` takes raw PCM16 16kHz mono and answers with audio plus an
|
||||
`X-Reply-Text` header, so the committed STT fixtures stand in for a microphone:
|
||||
|
||||
```sh
|
||||
tail -c +45 cmd/mavsttd/testdata/ru_query.wav > /tmp/q.pcm
|
||||
curl -s --noproxy '*' -D /tmp/h -o /tmp/reply.pcm -X POST \
|
||||
http://127.0.0.1:9201/api/ptt --data-binary @/tmp/q.pcm \
|
||||
-H 'Content-Type: application/octet-stream' -m 180
|
||||
```
|
||||
|
||||
That covers audio in → STT → router → phrasing → TTS audio out. It leaves only
|
||||
browser microphone capture needing a person, and the wake path needing a machine.
|
||||
Do not post `en_act.wav` without deciding first: it is a mutating act.
|
||||
|
||||
**Steps 7 and 8 still cannot run, but 15 is no longer the reason** (04-08-2026).
|
||||
The desk presence poster is installed on workpc. It is a `maven-desk` systemd
|
||||
user timer on a 60s cadence, gated by hypridle at 120s idle. `desk_active` facts
|
||||
now arrive, and the first landed at 18:43.
|
||||
|
||||
What blocks the two steps now is that no rule wants to fire. `/trace` shows all
|
||||
five at `predicate`, none inert:
|
||||
|
||||
| rule | sev | why it is false |
|
||||
|---|---|---|
|
||||
| water | 1 | needs ≥3h since the last `water` fact; step 2's `ru_fact` wrote one |
|
||||
| meal | 1 | needs ≥6h since a `meal` fact; none exists |
|
||||
| break | 2 | needs both `desk_active` and a `break` fact; `break` has never been written |
|
||||
| service_down | 4 | no kuma monitor is down |
|
||||
| netdata_critical | 3 | nothing critical |
|
||||
|
||||
So the honest way to run step 8 is to wait three hours after the last `water`
|
||||
fact, or to write one antedated. Do not read the water rule's silence as a defect.
|
||||
|
||||
**The sev4 telegram reach works** (04-08-2026). Resuming a paused kuma monitor
|
||||
for paperless, which is genuinely down, put a real `service_down` through the
|
||||
whole path with presence away:
|
||||
|
||||
```
|
||||
23:03 voicesink: no live voice session for service_down, falling through to away channels
|
||||
/notifications: 19:03 | service_down | telegram | pending | Сервис перестал отвечать.
|
||||
04.08 23:03 | nudge | service_down | telegram | sent | 23:03
|
||||
```
|
||||
|
||||
`ChannelsFor(Sev4, Away)` returned telegram, the send succeeded, and the row
|
||||
holds at `pending` because sev4 repeats until acked. The 15:51 row shows the
|
||||
same rule reaching `acted` earlier, so the ack path works too.
|
||||
|
||||
The body was `Сервис перестал отвечать.`, which names no service. That is a bug
|
||||
and it is deterministic, filed as **534**. `nudgeValues` fills `{service}` from
|
||||
`State.Fact("service_down")`, an exact key mavpoll stopped writing when
|
||||
per-monitor facts landed. Nine of the ten templates carry `{service}`, so all
|
||||
nine are rejected as unfillable. The one nameless variant is left as the only
|
||||
usable one, every time. The stub and LLM phrasers both call `loop.DownServices`
|
||||
and get it right. The template path is the one that runs.
|
||||
|
||||
**Presence itself has a real defect, filed as 532.** `SavePresenceState` has no
|
||||
caller outside tests, so the singleton row is never written. The gate is fine,
|
||||
because it reads the bucket `GatherState` computes in memory each tick. Two
|
||||
things follow. Hysteresis is dead, because `lastBucket` is always cold-start `Away`
|
||||
and the 0.30-0.55 hold band never applies. And every presence readout lies:
|
||||
`/dash` shows `away — score 0.00 (never)` with fresh `desk_active` facts arriving
|
||||
every 60s. Do not trust that number while checking anything else here.
|
||||
|
||||
Steps 1 and 3-6 do not need a browser. `POST /api/chat` takes a form-encoded
|
||||
`text=` field and a cookie jar, and answers with the rendered `/chat` page:
|
||||
@@ -112,6 +175,33 @@ turns look misaligned when they are not.
|
||||
back. This covers browser mic to STT to core to TTS as one path. It does
|
||||
**not** cover the wake word or the voice-activity gate, and no step here
|
||||
does — see below.
|
||||
**Passes below the browser** (04-08-2026, three fixtures through `/api/ptt`):
|
||||
HTTP 200, `audio/l16;rate=16000;channels=1`, and real speech back. `ru_query`
|
||||
answered `на 04.08.2026 ничего нет.` in 3.82s of audio at RMS 3865, `ru_fact`
|
||||
answered `отметила: water = выпил`, `ru_reminder` answered `хорошо, напомню.`
|
||||
at `intent=reminder`.
|
||||
**Passes in the browser too** (04-08-2026), and it needed no person. Headless
|
||||
Chrome takes a fake microphone, so the whole browser half runs unattended:
|
||||
|
||||
```sh
|
||||
chrome --headless=new --remote-debugging-port=9333 --remote-allow-origins='*' \
|
||||
--use-fake-device-for-media-stream --use-fake-ui-for-media-stream \
|
||||
--use-file-for-fake-audio-capture=cmd/mavsttd/testdata/ru_query.wav%noloop
|
||||
```
|
||||
|
||||
Then drive it over the debug protocol: click `#btn`, wait, click again, read
|
||||
`#status` and `#log`. That covers `getUserMedia`, `MediaRecorder`, the webm
|
||||
decode and the hand-written resample to 16k Int16. It logged
|
||||
`sending 188160 bytes`, which is 5.88s at 16k mono, and got the reply back.
|
||||
|
||||
**The button is on `/`, not `/dash`.** `handleVoice` serves it at the root
|
||||
(`main.go:332`). `/dash` is the presence and fact dashboard and carries no
|
||||
`#btn`. This step said `/dash` until 04-08-2026.
|
||||
|
||||
One defect fell out, filed as **533**. The reply logged as
|
||||
`на+04.08.2026+ничего+нет.` The header is escaped with `url.QueryEscape`,
|
||||
which writes a space as `+`, then decoded with `decodeURIComponent`, which
|
||||
leaves `+` alone. Transcript only, the audio is fine.
|
||||
3. Say `тихий режим`. Expect `тихий режим включён. буду реже напоминать.` **Passes.**
|
||||
4. Say `выключи тихий режим`. Expect `тихий режим выключен.` Negation must win. **Passes.**
|
||||
5. Say `в комнате тихо`. Quiet mode must NOT flip. Confirm on `/history` that no
|
||||
@@ -132,6 +222,29 @@ turns look misaligned when they are not.
|
||||
**First evidence, in text** (02-08-2026): nothing breaks, but answers wander
|
||||
and stitch unrelated topics. Asked whether he should move flats, she opened
|
||||
with the weather. That is 287, and it is a phrasing problem, not a loop problem.
|
||||
**The slowness now has a cause and a number** (04-08-2026). A spoken turn
|
||||
takes 32 to 34 seconds. One phrasing call is 30.0s of that. STT is 1.0s
|
||||
and routing is under 10ms. Both interactive calls decoded exactly 512 tokens,
|
||||
which is the phrasing cap. Both were truncated, to produce a reply of
|
||||
under 25 characters.
|
||||
The cause is `responseGrammar`, not the model. Its last rule is
|
||||
`ws ::= [ \t\n]*`, and `*` is unbounded, so the model emits `{` and then
|
||||
satisfies `ws` with whitespace until `max_tokens` stops it. Reproduced on a
|
||||
second server: at `repeat_penalty` 1.0 it runs to 512 and returns
|
||||
`finish_reason=length`, at 1.3 it stops at 24. Bounding the rule to
|
||||
`[ \t\n]{0,4}` gives a clean stop at 33 tokens three times out of three with
|
||||
no penalty at all.
|
||||
Only some callers are exposed. `internal/llm.Req` sends `repeat_penalty` and
|
||||
the replier sets it to 1.3, so that path is protected by accident. `chatReq`
|
||||
in the phraser sends no penalty, so `PhraseChat`, `PhraseQuery`,
|
||||
`PhraseNudge` and `PhraseReminder` all run at the default 1.0. Filed as
|
||||
**531**.
|
||||
Two guesses were wrong on the way and are recorded so nobody repeats them.
|
||||
It is not reasoning tokens: the probe returned `reasoning_content` of length
|
||||
0, and the grammar constrains output from the first token. It is not the
|
||||
`--cache-ram 512` limit either: that is MiB of prompt cache and the 512 that
|
||||
was hit is a token count.
|
||||
The wandering is a second thing and stays on 287.
|
||||
|
||||
**The wake path cannot be checked here, and that is now the decision rather
|
||||
than a gap.** `mavwaked` and `mavenclient` appear in no compose file and run as
|
||||
|
||||
@@ -348,8 +348,8 @@ func TestGate_IpcServer_ChatAllowedForEnrolledCaller(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("Chat: %v", err)
|
||||
}
|
||||
if reply != "echo: привет" {
|
||||
t.Fatalf("Chat reply = %q; want %q", reply, "echo: привет")
|
||||
if reply.Reply != "echo: привет" {
|
||||
t.Fatalf("Chat reply = %q; want %q", reply.Reply, "echo: привет")
|
||||
}
|
||||
if fake.chats != 1 {
|
||||
t.Fatalf("CoreAPI.Chat calls = %d; want 1", fake.chats)
|
||||
@@ -373,9 +373,9 @@ func (r *recordingAPI) WriteFact(_ context.Context, _ ipc.WriteFactReq) (int64,
|
||||
return int64(r.writes), nil
|
||||
}
|
||||
|
||||
func (r *recordingAPI) Chat(_ context.Context, _, text string) (string, error) {
|
||||
func (r *recordingAPI) Chat(_ context.Context, _, text string) (ipc.ChatReply, error) {
|
||||
r.chats++
|
||||
return "echo: " + text, nil
|
||||
return ipc.ChatReply{Reply: "echo: " + text}, nil
|
||||
}
|
||||
|
||||
// mustWriteFactParams — minimal WriteFactReq JSON with only the source field,
|
||||
|
||||
@@ -91,6 +91,19 @@ func Requirement(m ipc.Method) Authority {
|
||||
// can do is make Maven stop recognising someone, which is the state the
|
||||
// box ships in anyway.
|
||||
return AuthWrite
|
||||
case ipc.MethodSeedEvent:
|
||||
// The one backdating write path in the tree (Vikunja #518). AuthStepUp,
|
||||
// the same rung as mutating the tool allowlist, and for a reason that is
|
||||
// not about privilege: every other write records when something actually
|
||||
// happened, and this one asserts it. A caller who can place a fact in the
|
||||
// past can manufacture a routine Maven will then act on forever, which is
|
||||
// the tick loop obeying evidence nobody produced.
|
||||
//
|
||||
// Step-up is not the real gate and is not meant to be. mavend refuses the
|
||||
// method entirely unless started with -allow-seed, so the ordinary state
|
||||
// of the box is that no gesture reaches it. This rung is what stops a
|
||||
// module from calling it on a box where QA left the flag on.
|
||||
return AuthStepUp
|
||||
case ipc.MethodWriteFact:
|
||||
return AuthWrite
|
||||
case ipc.MethodIngestMail:
|
||||
|
||||
@@ -16,6 +16,7 @@ package calendar
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
@@ -125,6 +126,69 @@ func FactSummary(value string) string {
|
||||
return value[:i]
|
||||
}
|
||||
|
||||
// EventKeyPrefix — every calendar event fact starts with this. The loop scans
|
||||
// the family to work out whether a meeting covers right now (Vikunja #513).
|
||||
const EventKeyPrefix = "calendar_event_"
|
||||
|
||||
// FactSpan reads an event fact back into the instants it covers, against loc.
|
||||
// The day comes from the key and the two clock readings from the value's
|
||||
// "@ HH:MM-HH:MM" tail, which is everything FactValue wrote.
|
||||
//
|
||||
// ok is false for anything that does not parse. A fact whose span cannot be
|
||||
// read tells you nothing about now, and guessing a span is how a signal that
|
||||
// was meant to suppress one nudge starts suppressing all of them.
|
||||
//
|
||||
// An end at or before the start is read as crossing midnight, so a 23:30-00:15
|
||||
// meeting covers the quarter hour it actually covers.
|
||||
func FactSpan(key, value string, loc *time.Location) (start, end time.Time, ok bool) {
|
||||
if !strings.HasPrefix(key, EventKeyPrefix) {
|
||||
return time.Time{}, time.Time{}, false
|
||||
}
|
||||
rest := key[len(EventKeyPrefix):]
|
||||
if len(rest) < 8 {
|
||||
return time.Time{}, time.Time{}, false
|
||||
}
|
||||
day, err := time.ParseInLocation("20060102", rest[:8], loc)
|
||||
if err != nil {
|
||||
return time.Time{}, time.Time{}, false
|
||||
}
|
||||
// SetValue stores a string fact JSON-encoded, so the value comes back
|
||||
// quoted. Reading the tail off the quote is how this returned false for
|
||||
// every real event the first time it ran.
|
||||
if unq, err := strconv.Unquote(value); err == nil {
|
||||
value = unq
|
||||
}
|
||||
i := strings.LastIndex(value, " @ ")
|
||||
if i < 0 {
|
||||
return time.Time{}, time.Time{}, false
|
||||
}
|
||||
tail := value[i+len(" @ "):]
|
||||
from, to, found := strings.Cut(tail, "-")
|
||||
if !found {
|
||||
return time.Time{}, time.Time{}, false
|
||||
}
|
||||
sh, sm, ok1 := parseHM(strings.TrimSpace(from))
|
||||
eh, em, ok2 := parseHM(strings.TrimSpace(to))
|
||||
if !ok1 || !ok2 {
|
||||
return time.Time{}, time.Time{}, false
|
||||
}
|
||||
start = day.Add(time.Duration(sh)*time.Hour + time.Duration(sm)*time.Minute)
|
||||
end = day.Add(time.Duration(eh)*time.Hour + time.Duration(em)*time.Minute)
|
||||
if !end.After(start) {
|
||||
end = end.Add(24 * time.Hour)
|
||||
}
|
||||
return start, end, true
|
||||
}
|
||||
|
||||
// parseHM reads "15:04" and nothing else.
|
||||
func parseHM(s string) (h, m int, ok bool) {
|
||||
t, err := time.Parse("15:04", s)
|
||||
if err != nil {
|
||||
return 0, 0, false
|
||||
}
|
||||
return t.Hour(), t.Minute(), true
|
||||
}
|
||||
|
||||
// KeyPrefixForDay is the fact-key prefix covering one calendar day. The store
|
||||
// range-scans between two of these.
|
||||
func KeyPrefixForDay(day time.Time) string {
|
||||
|
||||
@@ -1085,6 +1085,17 @@ type KiwixConfig struct {
|
||||
// query at a time.
|
||||
Book string `json:"book,omitempty"`
|
||||
|
||||
// BookRU — the ZIM to search when the question is in Russian, by the same
|
||||
// catalog name. Empty ⇒ every question goes to Book.
|
||||
//
|
||||
// It exists because the rewriter is a workaround, not a feature (V-508). An
|
||||
// English ZIM cannot match a Russian sentence, so the resident model turns
|
||||
// the question into English keywords first, and that costs a model call and
|
||||
// loses whatever the keywords drop. A Russian ZIM matches the question as he
|
||||
// asked it. So a Cyrillic question searches this book verbatim and skips the
|
||||
// rewrite, and the English book keeps answering English ones.
|
||||
BookRU string `json:"book_ru,omitempty"`
|
||||
|
||||
// MaxResults — how many hits are asked for. 0 ⇒ DefaultKiwixResults.
|
||||
// Only the top few reach the phraser regardless; the rest are context the
|
||||
// snippet ranking throws away.
|
||||
|
||||
+56
-1
@@ -184,6 +184,48 @@ type CaptureTaskResp struct {
|
||||
Promoted bool `json:"promoted,omitempty"`
|
||||
}
|
||||
|
||||
// SeedEventReq — write one fact at a caller-supplied timestamp and run the
|
||||
// pattern path over it, so a recurring routine can be produced on demand
|
||||
// instead of over real days (Vikunja #518).
|
||||
//
|
||||
// This is the ONLY backdating write path in the tree, and it exists for one
|
||||
// reason: the detector needs four events spread over hours before it proposes
|
||||
// anything, so V-43, V-46, V-247 and V-254 could not be verified against a
|
||||
// running daemon at all. A store fixture would have exercised the detector
|
||||
// without the wiring those tasks doubt.
|
||||
//
|
||||
// Two things hold it shut. It is AuthStepUp in the authority table, the same
|
||||
// rung as mutating the tool allowlist. And mavend refuses it outright unless
|
||||
// started with -allow-seed, so a box nobody is testing carries no live
|
||||
// backdating path even for a caller who cleared the gate.
|
||||
//
|
||||
// Key and Value are a fact, not an event: extraction runs for real, so a key
|
||||
// the extractor ignores seeds nothing and says so. That is deliberate — a
|
||||
// seam that accepted action and object directly would let QA prove a detector
|
||||
// against events no utterance could ever produce.
|
||||
type SeedEventReq struct {
|
||||
Key string `json:"key"`
|
||||
Value string `json:"value"`
|
||||
Ts time.Time `json:"ts"`
|
||||
}
|
||||
|
||||
// SeedEventResp — what the seed produced. Extracted is false when the fact was
|
||||
// written but yielded no event, which is the extractor declining rather than a
|
||||
// failure. Proposed is true only when this seed completed a pattern; the first
|
||||
// three seeds of a run return false with no routine.
|
||||
type SeedEventResp struct {
|
||||
FactID int64 `json:"fact_id"`
|
||||
EventID int64 `json:"event_id,omitempty"`
|
||||
Extracted bool `json:"extracted"`
|
||||
Action string `json:"action,omitempty"`
|
||||
Object string `json:"object,omitempty"`
|
||||
Proposed bool `json:"proposed"`
|
||||
RoutineID int64 `json:"routine_id,omitempty"`
|
||||
// IntervalDays — the median the detector settled on, echoed so QA can
|
||||
// check it against the spacing it asked for.
|
||||
IntervalDays float64 `json:"interval_days,omitempty"`
|
||||
}
|
||||
|
||||
// IngestMailReq — one message a mail reader has fetched, handed to core for
|
||||
// extraction (Vikunja #246).
|
||||
//
|
||||
@@ -622,7 +664,20 @@ type chatReq struct {
|
||||
Conversation string `json:"conversation,omitempty"`
|
||||
}
|
||||
type chatResp struct {
|
||||
Reply string `json:"reply"`
|
||||
Reply string `json:"reply"`
|
||||
Source string `json:"source,omitempty"`
|
||||
}
|
||||
|
||||
// ChatReply — one text turn's answer plus which query source claimed it.
|
||||
//
|
||||
// Source is diagnostic and is empty unless the turn was a question a source
|
||||
// claimed: a fact write, an act or a chat turn names none. It exists because
|
||||
// the claiming source was readable only in the daemon log, so a QA step could
|
||||
// not tell a wrong answer from a wrongly ordered chain (V-539). It is not
|
||||
// authorization and nothing routes on it.
|
||||
type ChatReply struct {
|
||||
Reply string
|
||||
Source string
|
||||
}
|
||||
|
||||
type proposeToolReq struct {
|
||||
|
||||
+11
-3
@@ -487,6 +487,14 @@ func (c *Client) ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, e
|
||||
return r.Routines, nil
|
||||
}
|
||||
|
||||
func (c *Client) SeedEvent(ctx context.Context, req SeedEventReq) (SeedEventResp, error) {
|
||||
var r SeedEventResp
|
||||
if err := c.call(ctx, MethodSeedEvent, req, &r); err != nil {
|
||||
return SeedEventResp{}, err
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func (c *Client) CaptureTask(ctx context.Context, req CaptureTaskReq) (CaptureTaskResp, error) {
|
||||
var r CaptureTaskResp
|
||||
if err := c.call(ctx, MethodCaptureTask, req, &r); err != nil {
|
||||
@@ -632,12 +640,12 @@ func (c *Client) AcceptProposedRoutine(ctx context.Context, id int64) error {
|
||||
return c.call(ctx, MethodAcceptProposedRoutine, acceptProposedRoutineReq{ID: id}, nil)
|
||||
}
|
||||
|
||||
func (c *Client) Chat(ctx context.Context, conversation, text string) (string, error) {
|
||||
func (c *Client) Chat(ctx context.Context, conversation, text string) (ChatReply, error) {
|
||||
var r chatResp
|
||||
if err := c.call(ctx, MethodChat, chatReq{Text: text, Conversation: conversation}, &r); err != nil {
|
||||
return "", err
|
||||
return ChatReply{}, err
|
||||
}
|
||||
return r.Reply, nil
|
||||
return ChatReply{Reply: r.Reply, Source: r.Source}, nil
|
||||
}
|
||||
|
||||
func (c *Client) TickTrace(ctx context.Context) (TickTrace, error) {
|
||||
|
||||
@@ -110,6 +110,13 @@ type RoutineAPI interface {
|
||||
// loop takes the schedule from there — no reminder is created (Vikunja #366).
|
||||
AcceptProposedRoutine(ctx context.Context, id int64) error
|
||||
|
||||
// SeedEvent writes a backdated fact and runs extraction and detection over
|
||||
// it, so a proposal can be produced in one sitting rather than over real
|
||||
// days (Vikunja #518). See SeedEventReq for why this exists and what keeps
|
||||
// it shut. Daemon-computed, like MorningStatus — the store adapter refuses
|
||||
// it, because the detect-and-propose step lives in mavend.
|
||||
SeedEvent(ctx context.Context, req SeedEventReq) (SeedEventResp, error)
|
||||
|
||||
// MorningStatus returns each configured morning routine's current
|
||||
// checklist state (see internal/morning): active today/now, which items
|
||||
// are done, which are still missing.
|
||||
@@ -169,7 +176,7 @@ type SystemAPI interface {
|
||||
// conversation, so an unanswered question on one reach cannot eat the next
|
||||
// utterance from another (Vikunja #466). Empty means the unattributed text
|
||||
// tap and is still one conversation of its own, separate from the mic.
|
||||
Chat(ctx context.Context, conversation, text string) (string, error)
|
||||
Chat(ctx context.Context, conversation, text string) (ChatReply, error)
|
||||
}
|
||||
|
||||
// CoreAPI — what core exposes to modules. One Go interface, satisfied by:
|
||||
|
||||
@@ -405,8 +405,17 @@ func TestChatViaClient(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("Chat: %v", err)
|
||||
}
|
||||
if reply != "и тебе привет!" {
|
||||
t.Fatalf("Chat = %q, want %q", reply, "и тебе привет!")
|
||||
if reply.Reply != "и тебе привет!" {
|
||||
t.Fatalf("Chat = %q, want %q", reply.Reply, "и тебе привет!")
|
||||
}
|
||||
|
||||
// The claiming query source crosses the wire beside the reply (V-539).
|
||||
sourced, err := cli.Chat(context.Background(), "web", "почему небо голубое")
|
||||
if err != nil {
|
||||
t.Fatalf("Chat: %v", err)
|
||||
}
|
||||
if sourced.Source != "kiwix" {
|
||||
t.Fatalf("Chat source = %q, want kiwix", sourced.Source)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -417,11 +426,11 @@ type chatTestAPI struct {
|
||||
UnimplementedCoreAPI
|
||||
}
|
||||
|
||||
func (a *chatTestAPI) Chat(ctx context.Context, _, text string) (string, error) {
|
||||
func (a *chatTestAPI) Chat(ctx context.Context, _, text string) (ChatReply, error) {
|
||||
if text == "привет" {
|
||||
return "и тебе привет!", nil
|
||||
return ChatReply{Reply: "и тебе привет!"}, nil
|
||||
}
|
||||
return "поговорили.", nil
|
||||
return ChatReply{Reply: "поговорили.", Source: "kiwix"}, nil
|
||||
}
|
||||
|
||||
// TestDispatch_UnknownMethod — an unknown method over the wire comes back as
|
||||
|
||||
@@ -524,6 +524,9 @@ var methodTable = map[Method]handlerFunc{
|
||||
MethodCaptureTask: withParams(func(ctx context.Context, api CoreAPI, p CaptureTaskReq) (CaptureTaskResp, error) {
|
||||
return api.CaptureTask(ctx, p)
|
||||
}),
|
||||
MethodSeedEvent: withParams(func(ctx context.Context, api CoreAPI, p SeedEventReq) (SeedEventResp, error) {
|
||||
return api.SeedEvent(ctx, p)
|
||||
}),
|
||||
MethodListTasks: withParams(func(ctx context.Context, api CoreAPI, p listTasksReq) (listTasksResp, error) {
|
||||
out, err := api.ListTasks(ctx, p.Status)
|
||||
if err != nil {
|
||||
@@ -562,7 +565,7 @@ var methodTable = map[Method]handlerFunc{
|
||||
}),
|
||||
MethodChat: withParams(func(ctx context.Context, api CoreAPI, p chatReq) (chatResp, error) {
|
||||
reply, err := api.Chat(ctx, p.Conversation, p.Text)
|
||||
return chatResp{Reply: reply}, err
|
||||
return chatResp{Reply: reply.Reply, Source: reply.Source}, err
|
||||
}),
|
||||
MethodTickTrace: withoutParams(func(ctx context.Context, api CoreAPI) (TickTrace, error) {
|
||||
return api.TickTrace(ctx)
|
||||
|
||||
@@ -257,14 +257,22 @@ func (a *storeAPI) RevertFact(ctx context.Context, key string) (int64, error) {
|
||||
return newID, mapErr(err)
|
||||
}
|
||||
|
||||
func (a *storeAPI) Chat(ctx context.Context, conversation, text string) (string, error) {
|
||||
return "", errors.New("store: chat not available via direct store API")
|
||||
func (a *storeAPI) Chat(ctx context.Context, conversation, text string) (ChatReply, error) {
|
||||
return ChatReply{}, errors.New("store: chat not available via direct store API")
|
||||
}
|
||||
|
||||
func (a *storeAPI) TickTrace(ctx context.Context) (TickTrace, error) {
|
||||
return TickTrace{}, errors.New("store: tick trace not available via direct store API")
|
||||
}
|
||||
|
||||
// SeedEvent — same shape as MorningStatus: writing the fact is a store call,
|
||||
// but extraction and detect-and-propose live in mavend, and a seed that wrote
|
||||
// the fact without running them would be the one thing this seam must not be,
|
||||
// a way to prove a detector that never ran (Vikunja #518).
|
||||
func (a *storeAPI) SeedEvent(ctx context.Context, req SeedEventReq) (SeedEventResp, error) {
|
||||
return SeedEventResp{}, errors.New("store: seed event not available via direct store API")
|
||||
}
|
||||
|
||||
func (a *storeAPI) MorningStatus(ctx context.Context) ([]MorningRoutineStatus, error) {
|
||||
return nil, errors.New("store: morning status not available via direct store API")
|
||||
}
|
||||
|
||||
@@ -105,6 +105,9 @@ func (UnimplementedCoreAPI) DeleteTool(ctx context.Context, name string) error {
|
||||
func (UnimplementedCoreAPI) CaptureTask(ctx context.Context, req CaptureTaskReq) (CaptureTaskResp, error) {
|
||||
return CaptureTaskResp{}, ErrNotImplemented
|
||||
}
|
||||
func (UnimplementedCoreAPI) SeedEvent(ctx context.Context, req SeedEventReq) (SeedEventResp, error) {
|
||||
return SeedEventResp{}, ErrNotImplemented
|
||||
}
|
||||
func (UnimplementedCoreAPI) ListTasks(ctx context.Context, status string) ([]Task, error) {
|
||||
return nil, ErrNotImplemented
|
||||
}
|
||||
@@ -144,6 +147,6 @@ func (UnimplementedCoreAPI) MCPServers(ctx context.Context) ([]MCPServerStatus,
|
||||
func (UnimplementedCoreAPI) DayPlan(ctx context.Context) (DayPlan, error) {
|
||||
return DayPlan{}, ErrNotImplemented
|
||||
}
|
||||
func (UnimplementedCoreAPI) Chat(ctx context.Context, conversation, text string) (string, error) {
|
||||
return "", ErrNotImplemented
|
||||
func (UnimplementedCoreAPI) Chat(ctx context.Context, conversation, text string) (ChatReply, error) {
|
||||
return ChatReply{}, ErrNotImplemented
|
||||
}
|
||||
|
||||
@@ -67,6 +67,7 @@ const (
|
||||
MethodListSpeakers Method = "list_speakers"
|
||||
MethodForgetSpeaker Method = "forget_speaker"
|
||||
MethodRecentEvents Method = "recent_events"
|
||||
MethodSeedEvent Method = "seed_event"
|
||||
|
||||
// MethodPing — liveness, and the only method that answers in locked mode
|
||||
// without a passkey assertion. It reaches no store, takes no arguments and
|
||||
|
||||
@@ -61,7 +61,7 @@ func mustLoad() lexiconFile {
|
||||
panic(fmt.Sprintf("lexicon: parse %s: %v", ruFile, err))
|
||||
}
|
||||
for _, name := range []string{
|
||||
"interrogatives", "capture_verbs", "narrative_requests", "cardinals",
|
||||
"interrogatives", "capture_verbs", "narrative_requests", "cardinals", "ordinals",
|
||||
"day_offsets", "weekdays", "months_genitive", "hours_spoken",
|
||||
"not_place_after_v", "parts_of_day", "reminder_verbs",
|
||||
} {
|
||||
@@ -120,6 +120,58 @@ func Cardinal(word string) (int, bool) {
|
||||
return n, ok
|
||||
}
|
||||
|
||||
// Ordinal reports the 1-based position a position word names, with -1 for the
|
||||
// last one. Same lookup shape as Cardinal, and the same reason: "второй" and
|
||||
// "вторым" are one position, and a caller matching stems would also match
|
||||
// "вторник".
|
||||
func Ordinal(word string) (int, bool) {
|
||||
n, ok := ru.Sets["ordinals"].Values[norm(word)]
|
||||
return n, ok
|
||||
}
|
||||
|
||||
// Ordinals returns the position words with their positions, sorted, so a caller
|
||||
// that needs a form this set does not list can ask a morphological dictionary
|
||||
// whether one of these is the same word. Sorted because map order is not stable
|
||||
// and a caller folding these into a pattern would otherwise build a different one
|
||||
// every run.
|
||||
func Ordinals() []struct {
|
||||
Word string
|
||||
N int
|
||||
} {
|
||||
vals := ru.Sets["ordinals"].Values
|
||||
out := make([]struct {
|
||||
Word string
|
||||
N int
|
||||
}, 0, len(vals))
|
||||
for w, n := range vals {
|
||||
out = append(out, struct {
|
||||
Word string
|
||||
N int
|
||||
}{w, n})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Word < out[j].Word })
|
||||
return out
|
||||
}
|
||||
|
||||
// OrdinalIn reports the position word that comes FIRST in a sentence, so a
|
||||
// caller does not have to tokenize before asking. Word-boundary matched for the
|
||||
// reason above, and earliest-wins rather than first-found: map iteration order
|
||||
// would otherwise answer "отметь первый и второй" differently between runs.
|
||||
func OrdinalIn(text string) (int, bool) {
|
||||
lower := norm(text)
|
||||
best, at := 0, -1
|
||||
for w, n := range ru.Sets["ordinals"].Values {
|
||||
i := indexWord(lower, w)
|
||||
if i < 0 || (at >= 0 && i > at) {
|
||||
continue
|
||||
}
|
||||
// Two different words cannot match at one offset: both ends are
|
||||
// boundary-checked, so no key is a prefix of another as matched.
|
||||
best, at = n, i
|
||||
}
|
||||
return best, at >= 0
|
||||
}
|
||||
|
||||
// DayOffset reports how many days a relative day word moves from today.
|
||||
//
|
||||
// The zero value is a real answer here — "сегодня" is offset 0 — so the second
|
||||
@@ -192,6 +244,28 @@ func abs(n int) int {
|
||||
return n
|
||||
}
|
||||
|
||||
// indexWord is containsWord returning where the match starts, or -1.
|
||||
func indexWord(haystack, needle string) int {
|
||||
if needle == "" {
|
||||
return -1
|
||||
}
|
||||
from := 0
|
||||
for {
|
||||
i := strings.Index(haystack[from:], needle)
|
||||
if i < 0 {
|
||||
return -1
|
||||
}
|
||||
i += from
|
||||
if boundaryBefore(haystack, i) && boundaryAfter(haystack, i+len(needle)) {
|
||||
return i
|
||||
}
|
||||
from = i + len(needle)
|
||||
if from >= len(haystack) {
|
||||
return -1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// containsWord reports whether haystack holds needle on word boundaries. Go's
|
||||
// \b is ASCII-only and never fires after a Cyrillic letter, so the boundary is
|
||||
// checked here instead: a rune on either side must not be a letter or a digit.
|
||||
|
||||
@@ -37,6 +37,24 @@
|
||||
"tell", "explain", "describe", "list"
|
||||
]
|
||||
},
|
||||
"ordinals": {
|
||||
"note": "Position words, as spoken, with the gender and oblique forms Russian requires: \"отметь второй пункт\" and \"закрепи вторым\" name one position (Vikunja #516). Values are the 1-based position, and -1 is the last one, which is a position rather than a count. The genitive forms here are also what a half-past hour needs (\"в половине восьмого\", V-538), so this set is written for two callers. It runs to twelve rather than ten because a clock does: \"в половине двенадцатого\" is 11:30.",
|
||||
"values": {
|
||||
"первый": 1, "первая": 1, "первое": 1, "первого": 1, "первую": 1, "первым": 1, "первой": 1, "first": 1,
|
||||
"второй": 2, "вторая": 2, "второе": 2, "второго": 2, "вторую": 2, "вторым": 2, "second": 2,
|
||||
"третий": 3, "третья": 3, "третье": 3, "третьего": 3, "третью": 3, "третьим": 3, "третьей": 3, "third": 3,
|
||||
"четвёртый": 4, "четвертый": 4, "четвёртая": 4, "четвертая": 4, "четвёртого": 4, "четвертого": 4, "четвёртую": 4, "четвертую": 4, "четвёртым": 4, "четвертым": 4, "fourth": 4,
|
||||
"пятый": 5, "пятая": 5, "пятое": 5, "пятого": 5, "пятую": 5, "пятым": 5, "пятой": 5, "fifth": 5,
|
||||
"шестой": 6, "шестая": 6, "шестое": 6, "шестого": 6, "шестую": 6, "шестым": 6, "sixth": 6,
|
||||
"седьмой": 7, "седьмая": 7, "седьмое": 7, "седьмого": 7, "седьмую": 7, "седьмым": 7, "seventh": 7,
|
||||
"восьмой": 8, "восьмая": 8, "восьмое": 8, "восьмого": 8, "восьмую": 8, "восьмым": 8, "eighth": 8,
|
||||
"девятый": 9, "девятая": 9, "девятое": 9, "девятого": 9, "девятую": 9, "девятым": 9, "ninth": 9,
|
||||
"десятый": 10, "десятая": 10, "десятое": 10, "десятого": 10, "десятую": 10, "десятым": 10, "tenth": 10,
|
||||
"одиннадцатый": 11, "одиннадцатая": 11, "одиннадцатое": 11, "одиннадцатого": 11, "одиннадцатую": 11, "одиннадцатым": 11, "eleventh": 11,
|
||||
"двенадцатый": 12, "двенадцатая": 12, "двенадцатое": 12, "двенадцатого": 12, "двенадцатую": 12, "двенадцатым": 12, "twelfth": 12,
|
||||
"последний": -1, "последняя": -1, "последнее": -1, "последнего": -1, "последнюю": -1, "последним": -1, "last": -1
|
||||
}
|
||||
},
|
||||
"cardinals": {
|
||||
"note": "Number words as spoken, with the gender variants Russian requires (один/одна/одно and два/две agree with the noun that follows) and the oblique forms, because a spoken time declines: \"в семь\", \"к семи\", \"около семи\" are three forms of one hour (Vikunja #530). Values are the number itself. Twenties and up are compounds and are read as their parts, so only the round members are listed.",
|
||||
"values": {
|
||||
|
||||
@@ -112,3 +112,51 @@ func TestCallerCannotEditTheLexicon(t *testing.T) {
|
||||
t.Fatal("the lexicon handed out its own backing array")
|
||||
}
|
||||
}
|
||||
|
||||
// The positions carry gender and oblique forms, because "второй пункт" and
|
||||
// "закрепи вторым" name one position (Vikunja #516). "last" is a position and not
|
||||
// a count, so it is -1 rather than a large number.
|
||||
func TestOrdinalsSpanGenderAndCase(t *testing.T) {
|
||||
for _, w := range []string{"второй", "вторая", "второе", "вторым", "второго", "second"} {
|
||||
n, ok := Ordinal(w)
|
||||
if !ok || n != 2 {
|
||||
t.Errorf("Ordinal(%q) = %d, %v; want 2, true", w, n, ok)
|
||||
}
|
||||
}
|
||||
for _, w := range []string{"последний", "последнюю", "last"} {
|
||||
if n, ok := Ordinal(w); !ok || n != -1 {
|
||||
t.Errorf("Ordinal(%q) = %d, %v; want -1, true", w, n, ok)
|
||||
}
|
||||
}
|
||||
// A weekday shares a stem with a position and is not one.
|
||||
if n, ok := Ordinal("вторник"); ok {
|
||||
t.Errorf("Ordinal(\"вторник\") = %d; a weekday is not a position", n)
|
||||
}
|
||||
}
|
||||
|
||||
// Earliest wins, not map order: the same sentence must answer the same way twice.
|
||||
func TestOrdinalInTakesTheFirstPosition(t *testing.T) {
|
||||
for i := 0; i < 50; i++ {
|
||||
n, ok := OrdinalIn("отметь первый и второй пункт")
|
||||
if !ok || n != 1 {
|
||||
t.Fatalf("run %d: OrdinalIn = %d, %v; want 1, true", i, n, ok)
|
||||
}
|
||||
}
|
||||
if _, ok := OrdinalIn("отметь пункт"); ok {
|
||||
t.Error("a sentence with no position reported one")
|
||||
}
|
||||
}
|
||||
|
||||
// Ordinals is the escape hatch for the cases the file does not list, so it must
|
||||
// hand out every entry and hand out the same order twice.
|
||||
func TestOrdinalsListIsCompleteAndStable(t *testing.T) {
|
||||
a, b := Ordinals(), Ordinals()
|
||||
if len(a) != len(ru.Sets["ordinals"].Values) {
|
||||
t.Errorf("Ordinals returned %d of %d entries", len(a), len(ru.Sets["ordinals"].Values))
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
t.Fatalf("Ordinals order is not stable at %d: %v vs %v", i, a[i], b[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -153,6 +154,11 @@ type body struct {
|
||||
type resp struct {
|
||||
Choices []struct {
|
||||
Message msg `json:"message"`
|
||||
// FinishReason — "length" means the token cap cut the generation off.
|
||||
// Worth a log line on every path (Vikunja #531): a grammar-constrained
|
||||
// generation that runs to the cap can still parse, so nothing above
|
||||
// this struct can tell a truncated answer from a whole one.
|
||||
FinishReason string `json:"finish_reason"`
|
||||
} `json:"choices"`
|
||||
}
|
||||
|
||||
@@ -205,6 +211,9 @@ func (c *Client) Complete(ctx context.Context, r Req) (string, error) {
|
||||
if len(out.Choices) == 0 {
|
||||
return "", fmt.Errorf("llm: no choices")
|
||||
}
|
||||
if out.Choices[0].FinishReason == "length" {
|
||||
log.Printf("llm: generation hit the %d-token cap (finish_reason=length) — output truncated, or the model was looping", r.MaxTokens)
|
||||
}
|
||||
content := out.Choices[0].Message.Content
|
||||
if content == "" {
|
||||
content = out.Choices[0].Message.ReasoningContent
|
||||
|
||||
+10
-2
@@ -159,21 +159,28 @@ func (p *Pair) set(up bool) {
|
||||
// admission answer is a cache and can be one interval out of date, so an error
|
||||
// here is expected rather than exceptional.
|
||||
//
|
||||
// This is the silent half of the degradation rule. It must be indistinguishable
|
||||
// from today's behaviour when the workstation is down.
|
||||
// This is the silent half of the degradation rule: he is not told which model
|
||||
// phrased his reply. The log is told, one line per call, because the seam was
|
||||
// otherwise unreadable after the fact — the transition lines say the card was
|
||||
// free at 11:27, not which side answered the turn at 13:24. QA had no way to
|
||||
// tell an offloaded turn from a floor one.
|
||||
func (p *Pair) Complete(ctx context.Context, r Req) (string, error) {
|
||||
if p.floor == nil {
|
||||
return "", ErrNoFloor
|
||||
}
|
||||
why := "workstation down"
|
||||
if p.Available() {
|
||||
out, err := p.remote.Complete(ctx, r)
|
||||
if err == nil {
|
||||
log.Print("llm: served by the workstation model")
|
||||
return out, nil
|
||||
}
|
||||
// The cached answer was wrong. Correct it now rather than sending the
|
||||
// next request into the same hole, then fall back.
|
||||
p.set(false)
|
||||
why = "workstation failed mid-request"
|
||||
}
|
||||
log.Printf("llm: served by the resident model (%s)", why)
|
||||
return p.floor.Complete(ctx, r)
|
||||
}
|
||||
|
||||
@@ -189,5 +196,6 @@ func (p *Pair) CompleteRemote(ctx context.Context, r Req) (string, error) {
|
||||
p.set(false)
|
||||
return "", errors.Join(ErrRemoteUnavailable, err)
|
||||
}
|
||||
log.Print("llm: served by the workstation model, no floor for this caller")
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
package loop
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/calendar"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
// An ambient meeting suppresses a nudge for its own span and no longer
|
||||
// (Vikunja #513). The span is read back off the event fact, so there is no
|
||||
// expiry to configure and no way for it to outlive the meeting.
|
||||
func TestAmbientEventSuppressesNudgesForItsOwnSpan(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, err := store.Open(ctx, filepath.Join(t.TempDir(), "ambient_busy.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = s.Close() })
|
||||
|
||||
day := time.Date(2026, 8, 5, 0, 0, 0, 0, time.Local)
|
||||
ev := calendar.Event{
|
||||
Summary: "Встреча с Аней",
|
||||
Start: day.Add(14 * time.Hour),
|
||||
End: day.Add(15 * time.Hour),
|
||||
}
|
||||
if _, err := s.SetValue(ctx, store.KindEnv, calendar.FactKey(ev),
|
||||
calendar.SourceAmbient, calendar.FactValue(ev), day); err != nil {
|
||||
t.Fatalf("SetValue: %v", err)
|
||||
}
|
||||
|
||||
g := NewGatherer(s, nil)
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
now time.Time
|
||||
busy bool
|
||||
}{
|
||||
{"before it starts", day.Add(13*time.Hour + 59*time.Minute), false},
|
||||
{"at the first minute", day.Add(14 * time.Hour), true},
|
||||
{"in the middle", day.Add(14*time.Hour + 30*time.Minute), true},
|
||||
{"at the end instant", day.Add(15 * time.Hour), false},
|
||||
{"an hour after", day.Add(16 * time.Hour), false},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
st, _, err := g.GatherState(ctx, tc.now)
|
||||
if err != nil {
|
||||
t.Fatalf("GatherState: %v", err)
|
||||
}
|
||||
if st.CalendarBusy != tc.busy {
|
||||
t.Fatalf("CalendarBusy = %v at %s, want %v", st.CalendarBusy, tc.now.Format("15:04"), tc.busy)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A meeting on another day must not make today busy at the same clock reading.
|
||||
// The day comes from the key, which is what makes this hold.
|
||||
func TestAnEventOnAnotherDayDoesNotSuppress(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, err := store.Open(ctx, filepath.Join(t.TempDir(), "ambient_busy_day.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = s.Close() })
|
||||
|
||||
yesterday := time.Date(2026, 8, 4, 0, 0, 0, 0, time.Local)
|
||||
ev := calendar.Event{Summary: "Standup", Start: yesterday.Add(14 * time.Hour), End: yesterday.Add(15 * time.Hour)}
|
||||
if _, err := s.SetValue(ctx, store.KindEnv, calendar.FactKey(ev),
|
||||
calendar.SourceAmbient, calendar.FactValue(ev), yesterday); err != nil {
|
||||
t.Fatalf("SetValue: %v", err)
|
||||
}
|
||||
|
||||
today := time.Date(2026, 8, 5, 14, 30, 0, 0, time.Local)
|
||||
st, _, err := NewGatherer(s, nil).GatherState(ctx, today)
|
||||
if err != nil {
|
||||
t.Fatalf("GatherState: %v", err)
|
||||
}
|
||||
if st.CalendarBusy {
|
||||
t.Fatal("yesterday's meeting suppressed a nudge today")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFactSpanReadsBackWhatFactValueWrote(t *testing.T) {
|
||||
day := time.Date(2026, 8, 5, 0, 0, 0, 0, time.Local)
|
||||
ev := calendar.Event{Summary: "Обед с мамой", Start: day.Add(13 * time.Hour), End: day.Add(13*time.Hour + 45*time.Minute)}
|
||||
start, end, ok := calendar.FactSpan(calendar.FactKey(ev), calendar.FactValue(ev), time.Local)
|
||||
if !ok {
|
||||
t.Fatal("FactSpan could not read its own encoding")
|
||||
}
|
||||
if !start.Equal(ev.Start) || !end.Equal(ev.End) {
|
||||
t.Fatalf("span = %s-%s, want %s-%s", start, end, ev.Start, ev.End)
|
||||
}
|
||||
}
|
||||
|
||||
// An end at or before the start is a meeting crossing midnight, not a zero-length
|
||||
// one. Reading it as zero-length would silently drop the suppression.
|
||||
func TestFactSpanCrossesMidnight(t *testing.T) {
|
||||
start, end, ok := calendar.FactSpan("calendar_event_20260805_Night", "Night @ 23:30-00:15", time.Local)
|
||||
if !ok {
|
||||
t.Fatal("FactSpan rejected a midnight-crossing event")
|
||||
}
|
||||
if got := end.Sub(start); got != 45*time.Minute {
|
||||
t.Fatalf("span length = %s, want 45m", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A fact that does not parse says nothing about now. Guessing a span here is
|
||||
// how one suppressed nudge becomes all of them.
|
||||
func TestFactSpanRejectsWhatItCannotRead(t *testing.T) {
|
||||
for _, tc := range []struct{ key, value string }{
|
||||
{"other_key_20260805_x", "x @ 10:00-11:00"},
|
||||
{"calendar_event_20260805_x", "x"},
|
||||
{"calendar_event_notadate_x", "x @ 10:00-11:00"},
|
||||
{"calendar_event_20260805_x", "x @ 25:00-11:00"},
|
||||
{"calendar_event_20260805_x", "x @ 10:00"},
|
||||
} {
|
||||
if _, _, ok := calendar.FactSpan(tc.key, tc.value, time.Local); ok {
|
||||
t.Errorf("FactSpan(%q, %q) parsed, want rejected", tc.key, tc.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -103,7 +103,7 @@ func TestExplainGate_PresenceAway(t *testing.T) {
|
||||
func TestExplainGate_PresenceAwayOpsBypass(t *testing.T) {
|
||||
now := refTime()
|
||||
s := State{Now: now, Presence: store.Away,
|
||||
Facts: map[string]store.Fact{"service_down:db": factAt("service_down:db", "poll:uptimekuma", `"down"`, now.Add(-1*time.Minute))},
|
||||
Facts: map[string]store.Fact{"service_down:db": factAt("service_down:db", "poll:uptimekuma", `"down"`, now.Add(-5*time.Minute))},
|
||||
}
|
||||
r := ServiceDownRule() // Sev4 ops
|
||||
passed, blocked, d := ExplainGate(s, r)
|
||||
@@ -260,7 +260,7 @@ func TestExplainTick_WinnerRecorded(t *testing.T) {
|
||||
Presence: store.Present,
|
||||
Facts: map[string]store.Fact{
|
||||
"water": factAt("water", "tap:water", `"250ml"`, now.Add(-4*time.Hour)),
|
||||
"service_down:db": factAt("service_down:db", "poll:uptimekuma", `"down"`, now.Add(-1*time.Minute)),
|
||||
"service_down:db": factAt("service_down:db", "poll:uptimekuma", `"down"`, now.Add(-5*time.Minute)),
|
||||
},
|
||||
}
|
||||
cand, trace := ExplainTick(s, DefaultRules())
|
||||
|
||||
@@ -202,7 +202,7 @@ func TestTickNeverDogpilesAndPicksLoudest(t *testing.T) {
|
||||
"meal": ago("meal", "voice", `"lunch"`, 8*time.Hour),
|
||||
"desk_active": ago("desk_active", "infer:hyprland", "1", 30*time.Second),
|
||||
"break": ago("break", "voice", `"walk"`, 3*time.Hour),
|
||||
"service_down:db": ago("service_down:db", "poll:uptimekuma", `"down"`, time.Minute),
|
||||
"service_down:db": ago("service_down:db", "poll:uptimekuma", `"down"`, 5*time.Minute),
|
||||
"netdata_alarm": ago("netdata_alarm", "poll:netdata", `"critical"`, time.Minute),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/calendar"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
@@ -158,6 +159,19 @@ func (g *Gatherer) GatherState(ctx context.Context, now time.Time) (State, []sto
|
||||
if f, ok := readFact(ctx, g.store, "calendar_busy"); ok {
|
||||
calBusy = f.Value == "true" || f.Value == `"true"`
|
||||
}
|
||||
// An ambient meeting suppresses a nudge too (Vikunja #513). It writes
|
||||
// calendar_event_* and never calendar_busy, which is the CalDAV poller's
|
||||
// level, so before this a low-confidence meeting was good enough to recite
|
||||
// out loud and not good enough to stop a nudge during it. That is
|
||||
// backwards: being wrong here costs one nudge he did not get.
|
||||
//
|
||||
// The expiry is the event's own span, which is why there is no new level
|
||||
// and no interval to choose. A poller re-asserts a level every cycle and a
|
||||
// notification arrives once; an event that already ended covers nothing,
|
||||
// and one that has not started yet covers nothing either.
|
||||
if !calBusy {
|
||||
calBusy = g.eventCoversNow(ctx, now)
|
||||
}
|
||||
|
||||
// due reminders — gate-bypassing class. read here, the daemon emits them.
|
||||
due, err := g.store.DueReminders(ctx, now)
|
||||
@@ -212,6 +226,29 @@ func parseHHMM(s string) (hour, min int, ok bool) {
|
||||
return h, m, true
|
||||
}
|
||||
|
||||
// eventCoversNow reports whether any stored calendar event covers this instant.
|
||||
// Read from the event facts themselves, so it holds for exactly as long as the
|
||||
// meeting does — see the note at the call site.
|
||||
//
|
||||
// A read failure answers false: a meeting nobody can read about is not a reason
|
||||
// to go quiet.
|
||||
func (g *Gatherer) eventCoversNow(ctx context.Context, now time.Time) bool {
|
||||
fam, err := g.store.LatestFactsByPrefix(ctx, calendar.EventKeyPrefix)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, f := range fam {
|
||||
start, end, ok := calendar.FactSpan(f.Key, f.Value, now.Location())
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if !now.Before(start) && now.Before(end) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func readFact(ctx context.Context, s *store.Store, key string) (store.Fact, bool) {
|
||||
f, err := s.LatestFact(ctx, key)
|
||||
if err != nil {
|
||||
|
||||
@@ -25,7 +25,9 @@ func TestGatherStateLoadsPrefixFamilies(t *testing.T) {
|
||||
"service_down:db": "down",
|
||||
"service_down:web": "up",
|
||||
} {
|
||||
if _, err := s.SetValue(ctx, store.KindEnv, key, ServiceDownSource, val, now.Add(-time.Minute)); err != nil {
|
||||
// Older than MinDownAge, so this tests the gather path and not the
|
||||
// flap debounce.
|
||||
if _, err := s.SetValue(ctx, store.KindEnv, key, ServiceDownSource, val, now.Add(-5*time.Minute)); err != nil {
|
||||
t.Fatalf("SetValue %s: %v", key, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@ func TestTickOpsHardSurvivesAwayAndQuiet(t *testing.T) {
|
||||
Presence: store.Away,
|
||||
QuietHours: true,
|
||||
Facts: map[string]store.Fact{
|
||||
"service_down:db": factAt("service_down:db", "poll:uptimekuma", `"down"`, now.Add(-1*time.Minute)),
|
||||
"service_down:db": factAt("service_down:db", "poll:uptimekuma", `"down"`, now.Add(-5*time.Minute)),
|
||||
},
|
||||
}
|
||||
got := Tick(s, DefaultRules())
|
||||
@@ -99,7 +99,7 @@ func TestTickServiceSourceTrustRefusesForgedTrigger(t *testing.T) {
|
||||
Now: now,
|
||||
Presence: store.Present,
|
||||
Facts: map[string]store.Fact{
|
||||
"service_down:db": factAt("service_down:db", "ambient", `"down"`, now.Add(-1*time.Minute)),
|
||||
"service_down:db": factAt("service_down:db", "ambient", `"down"`, now.Add(-5*time.Minute)),
|
||||
},
|
||||
}
|
||||
if got := Tick(s, DefaultRules()); got != nil {
|
||||
@@ -116,7 +116,7 @@ func TestTickOneNudgePerTickMaxSeverityWins(t *testing.T) {
|
||||
Presence: store.Present,
|
||||
Facts: map[string]store.Fact{
|
||||
"water": factAt("water", "tap:water", `"250ml"`, now.Add(-4*time.Hour)),
|
||||
"service_down:db": factAt("service_down:db", "poll:uptimekuma", `"down"`, now.Add(-1*time.Minute)),
|
||||
"service_down:db": factAt("service_down:db", "poll:uptimekuma", `"down"`, now.Add(-5*time.Minute)),
|
||||
},
|
||||
}
|
||||
got := Tick(s, DefaultRules())
|
||||
|
||||
+62
-10
@@ -35,6 +35,22 @@ type Rule struct {
|
||||
// the prefix here instead. Prefixes never make a rule inert: an empty
|
||||
// family is the predicate's own "no data" case.
|
||||
WantPrefixes []string
|
||||
|
||||
// StillTrue — is the CONDITION still true, ignoring whether it is worth
|
||||
// saying again? Distinct from Predicate on purpose, and the distinction is
|
||||
// the whole reason this field exists (Vikunja #535).
|
||||
//
|
||||
// Predicate answers "should this fire now", which folds in edge-triggering:
|
||||
// ServiceDownRule ends in !s.NudgedSince(...), so it reads false the instant
|
||||
// a nudge goes out even though the service is still down. A repeat loop that
|
||||
// consulted Predicate would cancel every alarm one tick after raising it,
|
||||
// which is exactly backwards.
|
||||
//
|
||||
// Only a rule whose alarm repeats needs this. nil means "I cannot tell you",
|
||||
// and the caller must then fall back to a bound it can enforce without the
|
||||
// rule's help. nil must never be read as "the condition cleared": a rule
|
||||
// that says nothing about its condition is not a rule that resolved.
|
||||
StillTrue func(State) bool
|
||||
}
|
||||
|
||||
// Cooldown — tunable bounded by the envelope so a weird week (auto-tuned) can't
|
||||
@@ -128,6 +144,44 @@ func DownServices(s State) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// MinDownAge — how long a monitor must have read "down" before it is worth
|
||||
// waking him (Vikunja #536).
|
||||
//
|
||||
// Technitium read down on one kuma poll and up on the next, sixty seconds
|
||||
// apart, and the alarm arrived after the service was already back. mavpoll
|
||||
// writes a service_down fact only when the state CHANGES, so the fact's
|
||||
// timestamp is the instant the monitor went down and its age is how long it
|
||||
// has stayed there. That is the whole debounce: no history to keep, no counter
|
||||
// to persist.
|
||||
//
|
||||
// Ninety seconds is one poll interval plus room for jitter, so a monitor must
|
||||
// survive at least one further poll as down. The cost is up to ninety seconds
|
||||
// of alarm latency on a real outage, against never being paged for a blip.
|
||||
//
|
||||
// It bounds the alarm, not the truth: DownServices still reports a monitor the
|
||||
// instant it goes down, because /dash showing a fresh outage is right even
|
||||
// when phoning him about it is not.
|
||||
const MinDownAge = 90 * time.Second
|
||||
|
||||
// downLongEnough — the newest down fact that has aged past MinDownAge, or the
|
||||
// zero time when no monitor has. The rule fires off this and not off the
|
||||
// newest down fact outright.
|
||||
func downLongEnough(s State, now time.Time) time.Time {
|
||||
var newest time.Time
|
||||
for _, f := range s.FactsUnder(ServiceDownPrefix) {
|
||||
if f.Source != ServiceDownSource || f.Value != `"down"` {
|
||||
continue
|
||||
}
|
||||
if now.Sub(f.Ts) < MinDownAge {
|
||||
continue
|
||||
}
|
||||
if f.Ts.After(newest) {
|
||||
newest = f.Ts
|
||||
}
|
||||
}
|
||||
return newest
|
||||
}
|
||||
|
||||
// ServiceDownRule — sev4 ops hard: at least one kuma monitor reads "down".
|
||||
//
|
||||
// It used to read one aggregate `service_down` fact, which is why it was
|
||||
@@ -144,20 +198,18 @@ func ServiceDownRule() Rule {
|
||||
Cooldown: Cooldown{Base: 15 * time.Minute, Min: 5 * time.Minute, Max: 1 * time.Hour},
|
||||
WantPrefixes: []string{ServiceDownPrefix},
|
||||
Predicate: func(s State) bool {
|
||||
var newest time.Time
|
||||
for _, f := range s.FactsUnder(ServiceDownPrefix) {
|
||||
if f.Source != ServiceDownSource || f.Value != `"down"` {
|
||||
continue
|
||||
}
|
||||
if f.Ts.After(newest) {
|
||||
newest = f.Ts
|
||||
}
|
||||
}
|
||||
newest := downLongEnough(s, s.Now)
|
||||
if newest.IsZero() {
|
||||
return false // nothing down, or no data at all → shut up
|
||||
// Nothing down, no data at all, or nothing down long enough
|
||||
// to be more than a flap → shut up. See MinDownAge.
|
||||
return false
|
||||
}
|
||||
return !s.NudgedSince("service_down", newest)
|
||||
},
|
||||
// The condition without the edge trigger. DownServices is the same
|
||||
// helper the predicate and the phraser read, so the repeat stops on
|
||||
// exactly the monitors he was told about.
|
||||
StillTrue: func(s State) bool { return len(DownServices(s)) > 0 },
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+57
-17
@@ -193,13 +193,13 @@ func TestOpsRulePredicates(t *testing.T) {
|
||||
{
|
||||
name: "service_down fires on a kuma down fact",
|
||||
rule: ServiceDownRule(),
|
||||
facts: map[string]store.Fact{"service_down:db": ago("service_down:db", "poll:uptimekuma", `"down"`, time.Minute)},
|
||||
facts: map[string]store.Fact{"service_down:db": ago("service_down:db", "poll:uptimekuma", `"down"`, 5*time.Minute)},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "service_down quiet when kuma says up",
|
||||
rule: ServiceDownRule(),
|
||||
facts: map[string]store.Fact{"service_down:db": ago("service_down:db", "poll:uptimekuma", `"up"`, time.Minute)},
|
||||
facts: map[string]store.Fact{"service_down:db": ago("service_down:db", "poll:uptimekuma", `"up"`, 5*time.Minute)},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
@@ -218,31 +218,31 @@ func TestOpsRulePredicates(t *testing.T) {
|
||||
{
|
||||
name: "service_down refuses a forgery from the netdata poller",
|
||||
rule: ServiceDownRule(),
|
||||
facts: map[string]store.Fact{"service_down:db": ago("service_down:db", "poll:netdata", `"down"`, time.Minute)},
|
||||
facts: map[string]store.Fact{"service_down:db": ago("service_down:db", "poll:netdata", `"down"`, 5*time.Minute)},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "service_down refuses a forgery from ambient audio",
|
||||
rule: ServiceDownRule(),
|
||||
facts: map[string]store.Fact{"service_down:db": ago("service_down:db", "ambient:other", `"down"`, time.Minute)},
|
||||
facts: map[string]store.Fact{"service_down:db": ago("service_down:db", "ambient:other", `"down"`, 5*time.Minute)},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "service_down refuses a forgery from the user's own voice",
|
||||
rule: ServiceDownRule(),
|
||||
facts: map[string]store.Fact{"service_down:db": ago("service_down:db", "voice", `"down"`, time.Minute)},
|
||||
facts: map[string]store.Fact{"service_down:db": ago("service_down:db", "voice", `"down"`, 5*time.Minute)},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "service_down refuses a source that only looks like kuma",
|
||||
rule: ServiceDownRule(),
|
||||
facts: map[string]store.Fact{"service_down:db": ago("service_down:db", "poll:uptimekuma-staging", `"down"`, time.Minute)},
|
||||
facts: map[string]store.Fact{"service_down:db": ago("service_down:db", "poll:uptimekuma-staging", `"down"`, 5*time.Minute)},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "service_down refuses an unquoted down value",
|
||||
rule: ServiceDownRule(),
|
||||
facts: map[string]store.Fact{"service_down:db": ago("service_down:db", "poll:uptimekuma", `down`, time.Minute)},
|
||||
facts: map[string]store.Fact{"service_down:db": ago("service_down:db", "poll:uptimekuma", `down`, 5*time.Minute)},
|
||||
want: false,
|
||||
},
|
||||
|
||||
@@ -383,7 +383,7 @@ func TestPredicatesArePure(t *testing.T) {
|
||||
"meal": ago("meal", "voice", `"lunch"`, 7*time.Hour),
|
||||
"desk_active": ago("desk_active", "infer:hyprland", "1", 30*time.Second),
|
||||
"break": ago("break", "voice", `"walk"`, 2*time.Hour),
|
||||
"service_down:db": ago("service_down:db", "poll:uptimekuma", `"down"`, time.Minute),
|
||||
"service_down:db": ago("service_down:db", "poll:uptimekuma", `"down"`, 5*time.Minute),
|
||||
})
|
||||
for _, r := range DefaultRules() {
|
||||
first := r.Predicate(s)
|
||||
@@ -445,12 +445,12 @@ func TestDownServicesNamesOnlyTheDownOnes(t *testing.T) {
|
||||
s := State{
|
||||
Now: refTime(),
|
||||
Facts: map[string]store.Fact{
|
||||
"service_down:web": ago("service_down:web", ServiceDownSource, `"up"`, time.Minute),
|
||||
"service_down:db": ago("service_down:db", ServiceDownSource, `"down"`, time.Minute),
|
||||
"service_down:vault": ago("service_down:vault", ServiceDownSource, `"down"`, time.Minute),
|
||||
"service_down:paused": ago("service_down:paused", ServiceDownSource, `"maintenance"`, time.Minute),
|
||||
"service_down:forged": ago("service_down:forged", "voice", `"down"`, time.Minute),
|
||||
"service_down:missing": ago("service_down:missing", ServiceDownSource, `"unknown"`, time.Minute),
|
||||
"service_down:web": ago("service_down:web", ServiceDownSource, `"up"`, 5*time.Minute),
|
||||
"service_down:db": ago("service_down:db", ServiceDownSource, `"down"`, 5*time.Minute),
|
||||
"service_down:vault": ago("service_down:vault", ServiceDownSource, `"down"`, 5*time.Minute),
|
||||
"service_down:paused": ago("service_down:paused", ServiceDownSource, `"maintenance"`, 5*time.Minute),
|
||||
"service_down:forged": ago("service_down:forged", "voice", `"down"`, 5*time.Minute),
|
||||
"service_down:missing": ago("service_down:missing", ServiceDownSource, `"unknown"`, 5*time.Minute),
|
||||
},
|
||||
}
|
||||
got := DownServices(s)
|
||||
@@ -469,8 +469,8 @@ func TestDownServicesNamesOnlyTheDownOnes(t *testing.T) {
|
||||
// the aggregate stayed "down" and pausing achieved nothing.
|
||||
func TestPausedMonitorSilencesOnlyItself(t *testing.T) {
|
||||
base := map[string]store.Fact{
|
||||
"service_down:db": ago("service_down:db", ServiceDownSource, `"maintenance"`, time.Minute),
|
||||
"service_down:web": ago("service_down:web", ServiceDownSource, `"down"`, time.Minute),
|
||||
"service_down:db": ago("service_down:db", ServiceDownSource, `"maintenance"`, 5*time.Minute),
|
||||
"service_down:web": ago("service_down:web", ServiceDownSource, `"down"`, 5*time.Minute),
|
||||
}
|
||||
if !ServiceDownRule().Predicate(State{Now: refTime(), Facts: base}) {
|
||||
t.Fatal("web is still down, the rule must fire")
|
||||
@@ -494,8 +494,48 @@ func TestServiceDownFiresOncePerTransition(t *testing.T) {
|
||||
t.Fatal("already told about this transition, must be quiet")
|
||||
}
|
||||
// A second service goes down after that nudge — a new edge, so it fires.
|
||||
s.Facts["service_down:web"] = ago("service_down:web", ServiceDownSource, `"down"`, time.Minute)
|
||||
s.Facts["service_down:web"] = ago("service_down:web", ServiceDownSource, `"down"`, 5*time.Minute)
|
||||
if !ServiceDownRule().Predicate(s) {
|
||||
t.Fatal("a later transition must fire again")
|
||||
}
|
||||
}
|
||||
|
||||
// A monitor that reads down on one poll and up on the next raises nothing
|
||||
// (Vikunja #536). Technitium did exactly that at 01:27:59 on 05-08-2026, and
|
||||
// the alarm arrived after the service was already back.
|
||||
func TestAFlappingMonitorRaisesNothing(t *testing.T) {
|
||||
fresh := ago("service_down:dns", ServiceDownSource, `"down"`, 10*time.Second)
|
||||
s := State{Now: refTime(), Facts: map[string]store.Fact{"service_down:dns": fresh}}
|
||||
if ServiceDownRule().Predicate(s) {
|
||||
t.Fatal("a monitor down for ten seconds must not wake him")
|
||||
}
|
||||
// It stays down. mavpoll writes only on change, so the fact does not move
|
||||
// and its age is how long the outage has run.
|
||||
s.Now = fresh.Ts.Add(MinDownAge)
|
||||
if !ServiceDownRule().Predicate(s) {
|
||||
t.Fatal("past MinDownAge this is a real outage and must fire")
|
||||
}
|
||||
}
|
||||
|
||||
// The debounce bounds the alarm, not the truth. /dash reads DownServices and
|
||||
// showing a fresh outage there is right even when phoning him about it is not.
|
||||
func TestDownServicesReportsAFreshOutage(t *testing.T) {
|
||||
s := State{
|
||||
Now: refTime(),
|
||||
Facts: map[string]store.Fact{"service_down:dns": ago("service_down:dns", ServiceDownSource, `"down"`, 10*time.Second)},
|
||||
}
|
||||
if got := DownServices(s); len(got) != 1 || got[0] != "dns" {
|
||||
t.Fatalf("DownServices = %v, want [dns]", got)
|
||||
}
|
||||
}
|
||||
|
||||
// One monitor flapping must not hide another that has been down for an hour.
|
||||
func TestAFlapDoesNotMaskARealOutage(t *testing.T) {
|
||||
s := State{Now: refTime(), Facts: map[string]store.Fact{
|
||||
"service_down:dns": ago("service_down:dns", ServiceDownSource, `"down"`, 10*time.Second),
|
||||
"service_down:db": ago("service_down:db", ServiceDownSource, `"down"`, time.Hour),
|
||||
}}
|
||||
if !ServiceDownRule().Predicate(s) {
|
||||
t.Fatal("db has been down an hour, the rule must fire")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,6 +132,26 @@ func (s *InMemoryStore) Delete(_ context.Context, id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeletePrefix removes every row whose id starts with prefix and returns how
|
||||
// many went. It matches store.MemoryStore's method so a test double and the
|
||||
// real index agree about superseding a fact (Vikunja #493) — a double that
|
||||
// silently kept the old vectors would pass a test the daemon fails.
|
||||
func (s *InMemoryStore) DeletePrefix(_ context.Context, prefix string) (int64, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
kept := s.items[:0]
|
||||
var n int64
|
||||
for _, it := range s.items {
|
||||
if strings.HasPrefix(it.id, prefix) {
|
||||
n++
|
||||
continue
|
||||
}
|
||||
kept = append(kept, it)
|
||||
}
|
||||
s.items = kept
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func (s *InMemoryStore) Search(_ context.Context, vec []float32, topK int) ([]Result, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
@@ -60,13 +60,17 @@ const (
|
||||
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"
|
||||
// AttentionDegraded and AttentionNoSources — the two ways an empty
|
||||
// attention list is not an all-clear (ECOSYSTEM-SPEC §2.6, Vikunja #540).
|
||||
AttentionDegraded = "attention_degraded"
|
||||
AttentionNoSources = "attention_no_sources"
|
||||
ChangesNone = "changes_none"
|
||||
ChangesList = "changes_list"
|
||||
ChangesFail = "changes_fail"
|
||||
HomeUnreachable = "home_unreachable"
|
||||
HomeEmpty = "home_empty"
|
||||
HomeOn = "home_on"
|
||||
HomeDark = "home_dark"
|
||||
)
|
||||
|
||||
var actKeys = []string{
|
||||
@@ -76,6 +80,7 @@ var actKeys = []string{
|
||||
EcoDenied, EcoDown, EcoAmbiguous, EcoUnknownEntity, EcoNoNexus, EcoAboutWhat, EcoRecall,
|
||||
AttentionNone, AttentionList, AttentionFail,
|
||||
AttentionNoneEntity, AttentionListEntity, AttentionFailEntity,
|
||||
AttentionDegraded, AttentionNoSources,
|
||||
ChangesNone, ChangesList, ChangesFail,
|
||||
HomeUnreachable, HomeEmpty, HomeOn, HomeDark,
|
||||
}
|
||||
@@ -113,6 +118,8 @@ var actFloor = map[string]string{
|
||||
AttentionNoneEntity: "по «{name}» ничего нет.",
|
||||
AttentionListEntity: "по «{name}»: {items}",
|
||||
AttentionFailEntity: "не могу сейчас узнать, что требует внимания по «{name}».",
|
||||
AttentionDegraded: "за всё не отвечу — источники молчат: {items}.",
|
||||
AttentionNoSources: "мне пока нечего смотреть — у Praxis нет источников.",
|
||||
ChangesNone: "изменений нет.",
|
||||
ChangesList: "изменения: {items}",
|
||||
ChangesFail: "не могу сейчас узнать об изменениях.",
|
||||
|
||||
@@ -114,6 +114,14 @@
|
||||
"fixed": true,
|
||||
"variants": ["не могу сейчас узнать, что требует внимания по «{name}»."]
|
||||
},
|
||||
"attention_degraded": {
|
||||
"fixed": true,
|
||||
"variants": ["за всё не отвечу — источники молчат: {items}."]
|
||||
},
|
||||
"attention_no_sources": {
|
||||
"fixed": true,
|
||||
"variants": ["мне пока нечего смотреть — у Praxis нет источников."]
|
||||
},
|
||||
"changes_none": {
|
||||
"fixed": true,
|
||||
"variants": ["изменений нет."]
|
||||
|
||||
@@ -52,3 +52,86 @@ func TestGrammarStringBoundHasRoomForARealAnswer(t *testing.T) {
|
||||
t.Error("grammar string bound is not 1000; 400 truncated real replies mid-word (see the comment on responseGrammar)")
|
||||
}
|
||||
}
|
||||
|
||||
// A multi-line reply is the sixty-failure shape from the 2026-08-05 temperature
|
||||
// sweep (Vikunja #537). The model wrote a literal newline inside the string,
|
||||
// which the old grammar allowed and json.Unmarshal rejects with "invalid
|
||||
// character '\n' in string literal". The object starts with "{", so it came back
|
||||
// as errBrokenJSON and the case answered with nothing at all.
|
||||
//
|
||||
// The grammar is the fix. This is the parser's own second line, for the paths
|
||||
// that send no grammar: the reply is readable, so read it.
|
||||
func TestParseResponseMoodRepairsARawNewline(t *testing.T) {
|
||||
raw := "{\"response\": \"первая строка\nвторая строка\", \"mood\": \"neutral\"}"
|
||||
text, mood, err := parseResponseMood(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("err = %v, want nil — a raw newline is repairable, not a failed generation", err)
|
||||
}
|
||||
if want := "первая строка\nвторая строка"; text != want {
|
||||
t.Errorf("response = %q, want %q", text, want)
|
||||
}
|
||||
if mood != "neutral" {
|
||||
t.Errorf("mood = %q, want neutral", mood)
|
||||
}
|
||||
}
|
||||
|
||||
// Repairing must not rewrite JSON that was already fine: an escaped newline
|
||||
// stays one newline, and a backslash the model escaped properly is left alone.
|
||||
func TestEscapeRawControlsLeavesValidJSONAlone(t *testing.T) {
|
||||
raw := `{"response": "строка\nдве \\ и \"кавычки\"", "mood": "happy"}`
|
||||
if got := escapeRawControls(raw); got != raw {
|
||||
t.Errorf("escapeRawControls rewrote valid JSON:\n got %q\nwant %q", got, raw)
|
||||
}
|
||||
text, _, err := parseResponseMood(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
if want := "строка\nдве \\ и \"кавычки\""; text != want {
|
||||
t.Errorf("response = %q, want %q", text, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Pretty-printed JSON is what Qwen3-1.7B writes: it opens the object and puts
|
||||
// three newlines before the first key. Escaping those structural newlines made
|
||||
// the object unparseable, so 31 of 36 conversational cases in the talk fixture
|
||||
// answered from the stub (Vikunja #44, measured 2026-08-05).
|
||||
func TestParseResponseMoodReadsPrettyPrintedJSON(t *testing.T) {
|
||||
raw := "{\n\n\n \"response\": \"Хорошо настроение.\",\n \"mood\": \"neutral\"\n}"
|
||||
text, mood, err := parseResponseMood(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("err = %v, want nil — this is valid JSON, not a failed generation", err)
|
||||
}
|
||||
if want := "Хорошо настроение."; text != want {
|
||||
t.Errorf("response = %q, want %q", text, want)
|
||||
}
|
||||
if mood != "neutral" {
|
||||
t.Errorf("mood = %q, want neutral", mood)
|
||||
}
|
||||
}
|
||||
|
||||
// Both at once: structural newlines outside the strings, a raw one inside.
|
||||
func TestParseResponseMoodRepairsInsideAndKeepsOutside(t *testing.T) {
|
||||
raw := "{\n\t\"response\": \"первая\nвторая\",\n\t\"mood\": \"tired\"\n}"
|
||||
text, mood, err := parseResponseMood(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("err = %v, want nil", err)
|
||||
}
|
||||
if want := "первая\nвторая"; text != want {
|
||||
t.Errorf("response = %q, want %q", text, want)
|
||||
}
|
||||
if mood != "tired" {
|
||||
t.Errorf("mood = %q, want tired", mood)
|
||||
}
|
||||
}
|
||||
|
||||
// A tab and a bare control byte take the same path as the newline.
|
||||
func TestParseResponseMoodRepairsOtherControls(t *testing.T) {
|
||||
raw := "{\"response\": \"таб\tи \x01байт\", \"mood\": \"tired\"}"
|
||||
text, _, err := parseResponseMood(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("err = %v, want nil", err)
|
||||
}
|
||||
if want := "таб\tи \x01байт"; text != want {
|
||||
t.Errorf("response = %q, want %q", text, want)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
package phraser
|
||||
|
||||
// The confirmation answers — what she says once he has answered a confirm, and
|
||||
// what she says when an act names a verb she may not run.
|
||||
//
|
||||
// Fifth family on the shared deck (deck.go). They were literals in
|
||||
// cmd/mavend/confirm.go.
|
||||
//
|
||||
// The prompt is deliberately not here. act_confirm and act_confirm_entity are in
|
||||
// acts_ru_v1.json, where they belong: the sentence he has to hear before he says
|
||||
// yes is an act line, and it already loads with its {name} placeholder required.
|
||||
// Vikunja #505 asked whether this family should move at all. It moves, but only
|
||||
// the answers, and every entry is fixed. He answered a question about one
|
||||
// specific thing, so variety here buys nothing and costs the one property that
|
||||
// matters: the same act reports the same outcome every time.
|
||||
//
|
||||
// The tool-confirm success and failure lines are not here either. They said
|
||||
// "готово." and "не получилось выполнить команду." in two places, which is the
|
||||
// acts family word for word, so the confirmed run now reports through ActDone
|
||||
// and ActFail rather than keeping a second copy that can drift.
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"log"
|
||||
"math/rand"
|
||||
"sync"
|
||||
|
||||
"github.com/kami/maven/internal/say"
|
||||
)
|
||||
|
||||
//go:embed confirm_ru_v1.json
|
||||
var confirmJSON []byte
|
||||
|
||||
// ConfirmSchemaVersion — this family's own version.
|
||||
const ConfirmSchemaVersion = 1
|
||||
|
||||
// The entry keys.
|
||||
const (
|
||||
// ConfirmCancelled — he said no. It names no capability, because the
|
||||
// prompt he answered named one and nothing ran.
|
||||
ConfirmCancelled = "confirm_cancelled"
|
||||
// ConfirmRoutineAuthed — he said yes to a proposed routine out loud, which
|
||||
// is not an acceptance. A room mic cannot hand the tick loop a standing new
|
||||
// reason to speak, so the row stays proposed and this line points at the
|
||||
// page where the accept button is gated (Vikunja #367).
|
||||
ConfirmRoutineAuthed = "confirm_routine_authed"
|
||||
ConfirmRoutineNo = "confirm_routine_no"
|
||||
|
||||
// The propose-gap lines: an act whose verb is not on the allowlist. She
|
||||
// drafts the registration and says so. She never enables it.
|
||||
ProposeNoVerb = "propose_no_verb"
|
||||
ProposeFailed = "propose_failed"
|
||||
ProposeNew = "propose_new"
|
||||
ProposeAlready = "propose_already"
|
||||
)
|
||||
|
||||
var confirmKeys = []string{
|
||||
ConfirmCancelled, ConfirmRoutineAuthed, ConfirmRoutineNo,
|
||||
ProposeNoVerb, ProposeFailed, ProposeNew, ProposeAlready,
|
||||
}
|
||||
|
||||
// confirmFloor — the literal each key falls back to when the file is unusable.
|
||||
// These are the exact strings that lived in cmd/mavend/confirm.go.
|
||||
var confirmFloor = map[string]string{
|
||||
ConfirmCancelled: "отменила.",
|
||||
ConfirmRoutineAuthed: "поняла — подтверди на странице рутин, и начну напоминать.",
|
||||
ConfirmRoutineNo: "хорошо, не буду.",
|
||||
|
||||
ProposeNoVerb: "не разобрала команду — попробуй иначе.",
|
||||
ProposeFailed: "команды «{name}» нет в списке разрешённых.",
|
||||
ProposeNew: "команды «{name}» нет в списке. Предложила её добавить — включи через клиент.",
|
||||
ProposeAlready: "команды «{name}» пока нет в списке — она уже предложена, включи через клиент.",
|
||||
}
|
||||
|
||||
// Confirms picks a hand-written Russian confirmation answer. Safe for
|
||||
// concurrent use.
|
||||
type Confirms struct{ d *say.Deck }
|
||||
|
||||
// LoadConfirms reads the embedded file. Pass a source to make the picking
|
||||
// reproducible in tests; nil seeds from the clock.
|
||||
func LoadConfirms(src rand.Source) (*Confirms, error) {
|
||||
d, err := say.Load(confirmJSON, ConfirmSchemaVersion, confirmKeys, confirmFloor, src)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Every propose line that names the verb must keep naming it. A variant that
|
||||
// dropped {name} would tell him a command is not allowed without saying
|
||||
// which one, and the whole point of the line is that he goes and enables it.
|
||||
for _, key := range []string{ProposeFailed, ProposeNew, ProposeAlready} {
|
||||
if err := d.RequirePlaceholder(key, "{name}"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return &Confirms{d: d}, nil
|
||||
}
|
||||
|
||||
// deck reads through a nil *Confirms, which is the unloadable-file case.
|
||||
func (c *Confirms) deck() *say.Deck {
|
||||
if c == nil {
|
||||
return say.FloorDeck(confirmFloor)
|
||||
}
|
||||
return c.d
|
||||
}
|
||||
|
||||
// Say returns the line for key, with the verb filled into the frame.
|
||||
func (c *Confirms) Say(key string, vars map[string]string) string {
|
||||
return c.deck().Text(key, vars)
|
||||
}
|
||||
|
||||
// Variants returns every line the file can produce, for the persona scorer.
|
||||
func (c *Confirms) Variants() []string { return c.deck().Variants() }
|
||||
|
||||
var (
|
||||
confirmOnce sync.Once
|
||||
confirmsDeck *Confirms
|
||||
)
|
||||
|
||||
// DefaultConfirms returns the shared instance, loading it on first use. A broken
|
||||
// file logs once and leaves a nil *Confirms, which still answers from
|
||||
// confirmFloor: a daemon must not fail to boot over its own copy deck.
|
||||
func DefaultConfirms() *Confirms {
|
||||
confirmOnce.Do(func() {
|
||||
c, err := LoadConfirms(nil)
|
||||
if err != nil {
|
||||
log.Printf("phraser: confirmation answers unavailable, using the built-in lines: %v", err)
|
||||
return
|
||||
}
|
||||
confirmsDeck = c
|
||||
})
|
||||
return confirmsDeck
|
||||
}
|
||||
|
||||
// C — one confirmation answer, the way every caller says it.
|
||||
func C(key string, vars map[string]string) string { return DefaultConfirms().Say(key, vars) }
|
||||
|
||||
// IsC reports whether text is a line key could have produced, for the tests.
|
||||
func IsC(key string, vars map[string]string, text string) bool {
|
||||
return DefaultConfirms().deck().Matches(key, vars, text)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package phraser
|
||||
|
||||
import (
|
||||
"math/rand"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The confirmation family has the strictest constraints of the six, so they are
|
||||
// tested rather than left to the doc comment (Vikunja #505).
|
||||
func TestConfirmFamilyLoads(t *testing.T) {
|
||||
c, err := LoadConfirms(rand.NewSource(1))
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfirms: %v", err)
|
||||
}
|
||||
if got := len(c.Variants()); got != len(confirmKeys) {
|
||||
t.Errorf("variants %d, want %d: every entry is fixed at one wording", got, len(confirmKeys))
|
||||
}
|
||||
}
|
||||
|
||||
// A propose line that lost {name} would tell him a command is not allowed
|
||||
// without saying which one, which is the whole content of the line.
|
||||
func TestEveryProposeLineNamesTheVerb(t *testing.T) {
|
||||
for _, key := range []string{ProposeFailed, ProposeNew, ProposeAlready} {
|
||||
got := C(key, map[string]string{"name": "перезагрузи"})
|
||||
if !strings.Contains(got, "перезагрузи") {
|
||||
t.Errorf("%s: %q does not name the verb", key, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A spoken yes does not accept a routine. The line has to keep saying where the
|
||||
// acceptance happens, or he hears agreement and gets no reminders.
|
||||
func TestRoutineYesStillPointsAtThePage(t *testing.T) {
|
||||
got := C(ConfirmRoutineAuthed, nil)
|
||||
if !strings.Contains(got, "рутин") {
|
||||
t.Errorf("%q does not name the routines page", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The floor answers when the file will not load, so the deck can never leave a
|
||||
// confirmed act with nothing to say.
|
||||
func TestConfirmFloorAnswersWithoutTheFile(t *testing.T) {
|
||||
var c *Confirms
|
||||
if got := c.Say(ConfirmCancelled, nil); got != confirmFloor[ConfirmCancelled] {
|
||||
t.Errorf("nil deck: %q, want the floor line", got)
|
||||
}
|
||||
if got := c.Say(ProposeFailed, map[string]string{"name": "стоп"}); !strings.Contains(got, "стоп") {
|
||||
t.Errorf("nil deck: %q does not name the verb", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"name": "confirm_ru_v1",
|
||||
"notes": [
|
||||
"What she says once a confirmation has been answered, and what she says when an act names a verb she is not allowed to run.",
|
||||
"The prompt itself is not here. act_confirm and act_confirm_entity live in acts_ru_v1.json, because the sentence he has to hear before he says yes is an act line.",
|
||||
"Every entry is fixed. He answered a question about one specific thing, so the answer names what happened to that thing and does not get reworded for variety.",
|
||||
"confirm_routine_authed is the strictest of them. A spoken yes does not accept a routine, so this line has to keep pointing at the page that does."
|
||||
],
|
||||
"entries": {
|
||||
"confirm_cancelled": {
|
||||
"fixed": true,
|
||||
"variants": ["отменила."]
|
||||
},
|
||||
"confirm_routine_authed": {
|
||||
"fixed": true,
|
||||
"variants": ["поняла — подтверди на странице рутин, и начну напоминать."]
|
||||
},
|
||||
"confirm_routine_no": {
|
||||
"fixed": true,
|
||||
"variants": ["хорошо, не буду."]
|
||||
},
|
||||
"propose_no_verb": {
|
||||
"fixed": true,
|
||||
"variants": ["не разобрала команду — попробуй иначе."]
|
||||
},
|
||||
"propose_failed": {
|
||||
"fixed": true,
|
||||
"variants": ["команды «{name}» нет в списке разрешённых."]
|
||||
},
|
||||
"propose_new": {
|
||||
"fixed": true,
|
||||
"variants": ["команды «{name}» нет в списке. Предложила её добавить — включи через клиент."]
|
||||
},
|
||||
"propose_already": {
|
||||
"fixed": true,
|
||||
"variants": ["команды «{name}» пока нет в списке — она уже предложена, включи через клиент."]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,11 @@ func TestFallbackPersona(t *testing.T) {
|
||||
t.Fatalf("LoadActs: %v", err)
|
||||
}
|
||||
variants = append(variants, act.Variants()...)
|
||||
con, err := phraser.LoadConfirms(rand.NewSource(20260804))
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfirms: %v", err)
|
||||
}
|
||||
variants = append(variants, con.Variants()...)
|
||||
sum, err := say.LoadSummaries(rand.NewSource(20260804))
|
||||
if err != nil {
|
||||
t.Fatalf("LoadSummaries: %v", err)
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
package eval
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/llm"
|
||||
"github.com/kami/maven/internal/persona"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
)
|
||||
|
||||
// sweepTemperatures — the dial positions worth comparing (Vikunja #402).
|
||||
// 0.7 is what the transport has always sent; 0.05 stands in for near-greedy,
|
||||
// since 0 means "use the default" to the phraser.
|
||||
var sweepTemperatures = []float64{0.7, 0.4, 0.2, 0.05}
|
||||
|
||||
// sweepRuns — how many runs per position. Three, because one run of a sampled
|
||||
// model tells you nothing about whether a two-point difference is real.
|
||||
const sweepRuns = 3
|
||||
|
||||
// TestTalkTemperatureSweep scores the talk fixture at each temperature.
|
||||
//
|
||||
// Opt-in twice over: it needs a llama-server AND it costs roughly
|
||||
// len(sweepTemperatures) * sweepRuns * the baseline run time, which is upwards
|
||||
// of twenty minutes on the CPU floor.
|
||||
//
|
||||
// MAVEN_LLM_URL=http://127.0.0.1:18099 MAVEN_TEMP_SWEEP=1 \
|
||||
// go test -v -timeout 90m -run TestTalkTemperatureSweep ./internal/phraser/eval/
|
||||
//
|
||||
// Reports, asserts nothing. The composite is not the number to read — the task
|
||||
// says to watch ontopic and invented content against how flat the replies get,
|
||||
// and the replies are logged for exactly that reason.
|
||||
//
|
||||
// Note that only the chat/query/world paths move: the reply path is a Replier
|
||||
// over llm.Client, which samples greedily and does not read this dial.
|
||||
func TestTalkTemperatureSweep(t *testing.T) {
|
||||
base := os.Getenv("MAVEN_LLM_URL")
|
||||
if base == "" {
|
||||
t.Skip("MAVEN_LLM_URL unset — point it at a running llama-server")
|
||||
}
|
||||
if os.Getenv("MAVEN_TEMP_SWEEP") == "" {
|
||||
t.Skip("MAVEN_TEMP_SWEEP unset — this sweep costs many minutes, see the doc comment")
|
||||
}
|
||||
noProxyLoopback(t)
|
||||
|
||||
ctx := context.Background()
|
||||
f, err := LoadTalk()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadTalk: %v", err)
|
||||
}
|
||||
model, err := llm.ModelID(ctx, base)
|
||||
if err != nil {
|
||||
t.Fatalf("no model at %s: %v", base, err)
|
||||
}
|
||||
|
||||
block := func() string { return persona.Facts{}.Block(time.Now()) }
|
||||
summary := fmt.Sprintf("temperature sweep, %s, %d runs each\n", model, sweepRuns)
|
||||
|
||||
for _, temp := range sweepTemperatures {
|
||||
for run := 1; run <= sweepRuns; run++ {
|
||||
cfg := phraser.DefaultConfig("")
|
||||
cfg.Timeout = 5 * time.Minute
|
||||
cfg.ContextBlock = block
|
||||
cfg.Temperature = temp
|
||||
p := phraser.NewLLMPhraserAt(base, cfg)
|
||||
|
||||
name := fmt.Sprintf("temp %.2f run %d", temp, run)
|
||||
target := Pair{Talker: p, Confirmer: phraser.NewReplier(llm.New(base, cfg.Timeout), block)}
|
||||
rep, err := ScoreTalk(ctx, name, target, f)
|
||||
p.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("ScoreTalk at %.2f: %v", temp, err)
|
||||
}
|
||||
if rep.Errors == rep.Total {
|
||||
t.Fatalf("every case errored at %.2f — nothing was measured", temp)
|
||||
}
|
||||
t.Log("\n" + rep.String() + "\nreplies:\n" + rep.Replies() + "\nfailures:\n" + rep.Failures())
|
||||
summary += fmt.Sprintf(" %-18s %2d/%2d (%.1f%%) ontopic %d/%d errors %d\n",
|
||||
name, rep.Passed, rep.Total, 100*rep.Accuracy(),
|
||||
rep.ByCheck[CheckOnTopic], rep.Total, rep.Errors)
|
||||
}
|
||||
}
|
||||
t.Log("\n" + summary)
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The other four families, held to the rule internal/say holds the fifth to:
|
||||
// The five embedded families, held to the rule internal/say holds the summary to:
|
||||
// one variant means fixed. Reported per family, because a failure that names
|
||||
// "some file" is a failure nobody acts on.
|
||||
func TestEverySingleVariantEntryIsFixed(t *testing.T) {
|
||||
@@ -25,11 +25,16 @@ func TestEverySingleVariantEntryIsFixed(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("LoadActs: %v", err)
|
||||
}
|
||||
confirms, err := LoadConfirms(rand.NewSource(1))
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfirms: %v", err)
|
||||
}
|
||||
for name, keys := range map[string][]string{
|
||||
"fallbacks": f.d.UnfixedSingles(),
|
||||
"acks": a.d.UnfixedSingles(),
|
||||
"queries": q.d.UnfixedSingles(),
|
||||
"acts": acts.d.UnfixedSingles(),
|
||||
"confirms": confirms.d.UnfixedSingles(),
|
||||
} {
|
||||
if len(keys) > 0 {
|
||||
t.Errorf("%s: single-variant entries not marked fixed: %v", name, keys)
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
package phraser
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// unboundedRepeat finds a `*` or `+` applied to a character class or a group.
|
||||
//
|
||||
// Those are the two forms that let the model generate without limit. A literal
|
||||
// `*` inside a quoted terminal is not one, which is why the pattern anchors on
|
||||
// `]` and `)`.
|
||||
var unboundedRepeat = regexp.MustCompile(`[\]\)]\s*[*+]`)
|
||||
|
||||
// An unbounded ws rule cost 24-30 seconds a turn (Vikunja #531). The model
|
||||
// opened the JSON object, satisfied `ws ::= [ \t\n]*` with whitespace, and ran
|
||||
// to the 512-token cap doing it — both interactive turns measured on
|
||||
// 2026-08-04 decoded exactly 512 tokens of mostly whitespace.
|
||||
//
|
||||
// The rule this test enforces is stronger than "ws is bounded", on purpose. A
|
||||
// grammar is a budget: every repetition in it is something the model is allowed
|
||||
// to do until the token cap, and the cap is not a design. A new rule with a
|
||||
// bare `*` is the same defect wearing a different name, and it would cost
|
||||
// another QA sitting to find.
|
||||
func TestResponseGrammarHasNoUnboundedRepetition(t *testing.T) {
|
||||
for i, line := range strings.Split(responseGrammar, "\n") {
|
||||
if m := unboundedRepeat.FindString(line); m != "" {
|
||||
t.Errorf("responseGrammar line %d has unbounded repetition %q — bound it, a cap is not a design:\n\t%s",
|
||||
i+1, m, strings.TrimSpace(line))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The measured bound. 400 was too tight for the string rule and the comment on
|
||||
// responseGrammar records why; {0,4} for whitespace was measured the same way,
|
||||
// three runs stopping cleanly at 33 tokens with no repeat penalty at all.
|
||||
func TestResponseGrammarWhitespaceIsBounded(t *testing.T) {
|
||||
if !strings.Contains(responseGrammar, `ws ::= [ \t\n]{0,4}`) {
|
||||
t.Errorf("the ws rule is not the measured {0,4} bound:\n%s", responseGrammar)
|
||||
}
|
||||
}
|
||||
|
||||
// The phrasing request must carry a repeat penalty. It is not what fixes #531 —
|
||||
// the bounded grammar is — but chatReq having no such field while
|
||||
// Replier.PhraseReply sent 1.3 is how one phrasing path ran away and the other
|
||||
// did not. Two wire structs disagreeing about the sampler is not a decision
|
||||
// anybody made.
|
||||
func TestPhrasingRequestCarriesARepeatPenalty(t *testing.T) {
|
||||
if phraseRepeatPenalty <= 1.0 {
|
||||
t.Fatalf("phraseRepeatPenalty is %v, which is the server default and no penalty at all", phraseRepeatPenalty)
|
||||
}
|
||||
var got []float64
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var req chatReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
t.Errorf("decode request: %v", err)
|
||||
}
|
||||
got = append(got, req.RepeatPenalty)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"choices":[{"message":{"content":"{\"response\": \"ага\", \"mood\": \"neutral\"}"},"finish_reason":"stop"}]}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
// Every path, not just one: the defect was a whole transport missing the
|
||||
// field, so a test that checked a single caller would have passed before
|
||||
// the fix as easily as after it.
|
||||
callAllPhrasingPaths(t, NewLLMPhraserAt(srv.URL, Config{LLMNudges: true}))
|
||||
|
||||
if len(got) == 0 {
|
||||
t.Fatal("no request captured")
|
||||
}
|
||||
for i, p := range got {
|
||||
if p != phraseRepeatPenalty {
|
||||
t.Errorf("request %d sent repeat_penalty %v, want %v", i, p, phraseRepeatPenalty)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -90,8 +90,13 @@ func TestNoGrammarConfigDisablesIt(t *testing.T) {
|
||||
// The grammar's string rule must accept any codepoint, not just ASCII. Replies
|
||||
// are Russian: an ASCII-only class would constrain the model into empty replies.
|
||||
func TestGrammarStringRuleIsNotASCIIOnly(t *testing.T) {
|
||||
if !strings.Contains(responseGrammar, `([^"\\] | "\\" ["\\/bfnrt])`) {
|
||||
t.Error("string rule is not the any-codepoint-except-quote-and-backslash class; Cyrillic replies would be impossible")
|
||||
if !strings.Contains(responseGrammar, `[^"\\\x00-\x1F]`) {
|
||||
t.Error("string rule is not the any-codepoint-except-quote-backslash-and-controls class; Cyrillic replies would be impossible")
|
||||
}
|
||||
// The control range must be out (Vikunja #537): a raw newline inside a JSON
|
||||
// string is not JSON, and the model wrote one whenever it wanted two lines.
|
||||
if strings.Contains(responseGrammar, `([^"\\] |`) {
|
||||
t.Error("string rule still admits raw control characters; a multi-line reply will fail to parse")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+134
-14
@@ -131,6 +131,14 @@ type Config struct {
|
||||
// query and reminder phrasing are untouched and still go through the model.
|
||||
LLMNudges bool
|
||||
|
||||
// Temperature — what every phrasing call samples at. 0 ⇒ 0.7, which is
|
||||
// what this transport has always sent.
|
||||
//
|
||||
// A field rather than a constant so the talk fixture can sweep it
|
||||
// (Vikunja #402). Sampling is a dial, and a dial nobody can turn from
|
||||
// outside the package cannot be measured, only argued about.
|
||||
Temperature float64
|
||||
|
||||
// NoGrammar turns the GBNF constraint off (zero value ⇒ grammar ON).
|
||||
// The escape hatch exists because the target resident model — the
|
||||
// locally CPT'd Qwen3-1.7B — does not exist yet: if its chat template
|
||||
@@ -593,10 +601,11 @@ func (p *LLMPhraser) chatWithMessages(ctx context.Context, msgs []chatMsg, maxTo
|
||||
}
|
||||
defer release()
|
||||
req := chatReq{
|
||||
Messages: msgs,
|
||||
Temperature: 0.7,
|
||||
MaxTokens: maxTokens,
|
||||
Grammar: p.grammar(),
|
||||
Messages: msgs,
|
||||
Temperature: p.temperature(),
|
||||
MaxTokens: maxTokens,
|
||||
Grammar: p.grammar(),
|
||||
RepeatPenalty: phraseRepeatPenalty,
|
||||
}
|
||||
body, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
@@ -626,6 +635,7 @@ func (p *LLMPhraser) chatWithMessages(ctx context.Context, msgs []chatMsg, maxTo
|
||||
if len(cr.Choices) == 0 {
|
||||
return "", fmt.Errorf("llm: no choices in response")
|
||||
}
|
||||
logIfTruncated("chat", cr.Choices[0].FinishReason, maxTokens)
|
||||
content := cr.Choices[0].Message.Content
|
||||
if content == "" {
|
||||
content = cr.Choices[0].Message.ReasoningContent
|
||||
@@ -690,8 +700,22 @@ type chatReq struct {
|
||||
// Grammar is llama-server's `grammar` field (GBNF). Same wiring as
|
||||
// internal/llm.Req.Grammar. Empty ⇒ unconstrained sampling.
|
||||
Grammar string `json:"grammar,omitempty"`
|
||||
// RepeatPenalty — defence in depth behind the bounded grammar, not the fix
|
||||
// for #531. This struct had no such field, so every caller through
|
||||
// chatWithSystem ran at the server default of 1.0 while Replier.PhraseReply
|
||||
// sent 1.3 through internal/llm and was protected by accident. Two wire
|
||||
// structs that disagree about the sampler is the condition that let one
|
||||
// path run away and the other not, and it should not survive as a
|
||||
// difference nobody chose.
|
||||
RepeatPenalty float64 `json:"repeat_penalty,omitempty"`
|
||||
}
|
||||
|
||||
// phraseRepeatPenalty — matches Replier.PhraseReply, which has sent 1.3 since
|
||||
// it was written. The value is not tuned here and is not what stops the
|
||||
// whitespace loop; the bounded ws rule is. It is here so the two phrasing
|
||||
// paths sample alike.
|
||||
const phraseRepeatPenalty = 1.3
|
||||
|
||||
// responseGrammar — GBNF constraining the model to the documented phrasing
|
||||
// contract and nothing else: {"response": "<text>", "mood": "<enum>"}.
|
||||
//
|
||||
@@ -700,21 +724,40 @@ type chatReq struct {
|
||||
// which eats the token budget before the JSON closes. Modelled on
|
||||
// routeGrammar in internal/router/llmrouter.go so the two read alike.
|
||||
//
|
||||
// text accepts ANY codepoint except the two JSON must escape — the replies are
|
||||
// Russian, so an ASCII-only rule would make every reply empty. The escape rule
|
||||
// is what lets the model close a string it opened with a quote inside. Length
|
||||
// is bounded so a repetition loop truncates the field, not the JSON object.
|
||||
// text accepts ANY codepoint except the two JSON must escape and the control
|
||||
// range — the replies are Russian, so an ASCII-only rule would make every reply
|
||||
// empty. The escape rule is what lets the model close a string it opened with a
|
||||
// quote inside. Length is bounded so a repetition loop truncates the field, not
|
||||
// the JSON object.
|
||||
//
|
||||
// The control range is excluded because a raw newline inside a JSON string is
|
||||
// not JSON (Vikunja #537). The class used to be `[^"\\]`, which let the model
|
||||
// write a multi-line reply that satisfied the grammar and then failed
|
||||
// json.Unmarshal with "invalid character '\n' in string literal" — the object
|
||||
// starts with "{", so it came back as errBrokenJSON and the case answered with
|
||||
// an empty string. Sixty of the failures in the 2026-08-05 temperature sweep
|
||||
// were that one error, and it never once hit the token cap, which is why the
|
||||
// truncation reading was wrong. The escape alternatives are llama.cpp's own
|
||||
// json.gbnf: a model that wants a line break must write \n, which parses.
|
||||
//
|
||||
// That bound was 400 and 400 was too tight. Measured against Qwen3.5-0.8B: on
|
||||
// "почему гром слышно позже молнии?" the reply came back exactly 400 characters
|
||||
// long, cut mid-word ("Нужно записать и,"), at every token cap from 256 to 2048.
|
||||
// So the token cap was never what stopped it — this rule was. 1000 characters is
|
||||
// roughly six Russian sentences, still short enough to stop a repetition loop.
|
||||
//
|
||||
// ws is bounded for the same reason and it is the more expensive of the two.
|
||||
// `*` let the model open the object and then satisfy ws with whitespace until
|
||||
// max_tokens, which is 512 here: both interactive turns measured on 2026-08-04
|
||||
// decoded exactly 512 tokens and spent 24-30 seconds doing it, all of it
|
||||
// whitespace (Vikunja #531). Nothing on this path sends a repeat penalty —
|
||||
// chatReq had no field for one — so the sampler never broke the loop. {0,4}
|
||||
// was measured: three runs, three clean stops at 33 tokens, no penalty needed.
|
||||
const responseGrammar = `
|
||||
root ::= "{" ws "\"response\"" ws ":" ws string ws "," ws "\"mood\"" ws ":" ws mood ws "}"
|
||||
mood ::= "\"neutral\"" | "\"happy\"" | "\"thinking\"" | "\"tired\"" | "\"confused\""
|
||||
string ::= "\"" ([^"\\] | "\\" ["\\/bfnrt]){0,1000} "\""
|
||||
ws ::= [ \t\n]*
|
||||
string ::= "\"" ([^"\\\x00-\x1F] | "\\" ["\\/bfnrt] | "\\u" [0-9a-fA-F]{4}){0,1000} "\""
|
||||
ws ::= [ \t\n]{0,4}
|
||||
`
|
||||
|
||||
// ResponseGrammar exposes responseGrammar to the other callers that emit the
|
||||
@@ -738,9 +781,27 @@ type chatResp struct {
|
||||
Reasoning string `json:"reasoning"`
|
||||
ReasoningContent string `json:"reasoning_content"`
|
||||
} `json:"message"`
|
||||
// FinishReason — "stop" when the model chose to end, "length" when the
|
||||
// token cap cut it off. Parsed since #531, where two turns ran to the
|
||||
// 512-token cap and both happened to parse anyway: the grammar had
|
||||
// already closed the JSON, so a truncated generation was indistinguishable
|
||||
// from a good one at every layer above this struct.
|
||||
FinishReason string `json:"finish_reason"`
|
||||
} `json:"choices"`
|
||||
}
|
||||
|
||||
// logIfTruncated says so when a generation stopped at the token cap.
|
||||
//
|
||||
// A cap hit is never routine. Either the model was looping, which is the #531
|
||||
// shape, or the reply was genuinely longer than maxTokens, which means she cut
|
||||
// herself off mid-sentence. Both are worth a line, and neither produced one
|
||||
// before: the caller sees a parsed string and cannot tell.
|
||||
func logIfTruncated(where, reason string, maxTokens int) {
|
||||
if reason == "length" {
|
||||
log.Printf("phraser: %s hit the %d-token cap (finish_reason=length) — the reply is truncated, or the model was looping", where, maxTokens)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *LLMPhraser) chat(ctx context.Context, userPrompt string) (string, error) {
|
||||
return p.chatWithSystem(ctx, p.systemPrompt(), userPrompt, 512)
|
||||
}
|
||||
@@ -762,9 +823,10 @@ func (p *LLMPhraser) chatWithSystem(ctx context.Context, system, user string, ma
|
||||
{Role: "system", Content: system},
|
||||
{Role: "user", Content: user},
|
||||
},
|
||||
Temperature: 0.7,
|
||||
MaxTokens: maxTokens,
|
||||
Grammar: p.grammar(),
|
||||
Temperature: p.temperature(),
|
||||
MaxTokens: maxTokens,
|
||||
Grammar: p.grammar(),
|
||||
RepeatPenalty: phraseRepeatPenalty,
|
||||
}
|
||||
body, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
@@ -798,6 +860,7 @@ func (p *LLMPhraser) chatWithSystem(ctx context.Context, system, user string, ma
|
||||
if len(cr.Choices) == 0 {
|
||||
return "", fmt.Errorf("llm: no choices in response")
|
||||
}
|
||||
logIfTruncated("chatWithSystem", cr.Choices[0].FinishReason, maxTokens)
|
||||
content := cr.Choices[0].Message.Content
|
||||
if content == "" {
|
||||
content = cr.Choices[0].Message.ReasoningContent
|
||||
@@ -1076,7 +1139,7 @@ func parseResponseMood(raw string) (response, mood string, err error) {
|
||||
return "", "", nil
|
||||
}
|
||||
var parsed responseMood
|
||||
if e := json.Unmarshal([]byte(cleaned[start:end+1]), &parsed); e != nil {
|
||||
if e := json.Unmarshal([]byte(escapeRawControls(cleaned[start:end+1])), &parsed); e != nil {
|
||||
if strings.HasPrefix(cleaned, "{") {
|
||||
return "", "", errBrokenJSON
|
||||
}
|
||||
@@ -1085,6 +1148,63 @@ func parseResponseMood(raw string) (response, mood string, err error) {
|
||||
return parsed.Response, parsed.Mood, nil
|
||||
}
|
||||
|
||||
// escapeRawControls escapes the control characters a model writes literally
|
||||
// inside a JSON string, so a reply that is otherwise fine still parses.
|
||||
//
|
||||
// The grammar is what stops these being generated (Vikunja #537). This is the
|
||||
// second line, for the paths that send no grammar at all — NoGrammar, and any
|
||||
// remote model whose server ignores one. A raw newline is the shape that was
|
||||
// measured; the rest of the range is here because the same argument covers it.
|
||||
//
|
||||
// Inside a string only. The first version escaped the whole object on the
|
||||
// argument that JSON permits no control character outside a string either, so
|
||||
// rewriting one could not do harm. That argument is wrong: JSON permits a
|
||||
// newline, a tab and a return BETWEEN tokens, which is what pretty-printing is.
|
||||
// Qwen3-1.7B pretty-prints — it opens `{` and writes three newlines before the
|
||||
// first key — and escaping those into a literal backslash-n broke every reply
|
||||
// it wrote. Measured 2026-08-05 on the talk fixture: 31 of 36 conversational
|
||||
// cases came back as errBrokenJSON and answered from the stub (Vikunja #44).
|
||||
func escapeRawControls(s string) string {
|
||||
if !strings.ContainsFunc(s, func(r rune) bool { return r < 0x20 }) {
|
||||
return s
|
||||
}
|
||||
var b strings.Builder
|
||||
b.Grow(len(s) + 8)
|
||||
inString := false
|
||||
escaped := false
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case escaped:
|
||||
// The character after a backslash is the model's own escape and is
|
||||
// already whatever it meant to write.
|
||||
escaped = false
|
||||
b.WriteRune(r)
|
||||
continue
|
||||
case inString && r == '\\':
|
||||
escaped = true
|
||||
b.WriteRune(r)
|
||||
continue
|
||||
case r == '"':
|
||||
inString = !inString
|
||||
b.WriteRune(r)
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case !inString || r >= 0x20:
|
||||
b.WriteRune(r)
|
||||
case r == '\n':
|
||||
b.WriteString(`\n`)
|
||||
case r == '\r':
|
||||
b.WriteString(`\r`)
|
||||
case r == '\t':
|
||||
b.WriteString(`\t`)
|
||||
default:
|
||||
fmt.Fprintf(&b, `\u%04x`, r)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func parsePhrase(raw string) (body, summary string) {
|
||||
cleaned := strings.TrimSpace(raw)
|
||||
start := strings.Index(cleaned, "{")
|
||||
|
||||
@@ -94,7 +94,7 @@ func (t *NudgeTemplates) PhraseNudge(_ context.Context, c loop.Candidate) (deliv
|
||||
// template fits it uses the plain per-rule fallback.
|
||||
func (t *NudgeTemplates) Nudge(c loop.Candidate) (body, mood string) {
|
||||
rule := c.Rule.Name
|
||||
family := t.family(rule)
|
||||
family := t.pluralFamily(t.family(rule), c)
|
||||
set, ok := t.file.Rules[family]
|
||||
if !ok {
|
||||
return fallbackNudge(c), "neutral"
|
||||
@@ -155,6 +155,25 @@ func (t *NudgeTemplates) family(rule string) string {
|
||||
return "default"
|
||||
}
|
||||
|
||||
// pluralFamily swaps in the plural wording when {service} will hold a list.
|
||||
// Russian agrees the verb with the subject, so one set of templates cannot
|
||||
// serve both: "Сервис paperless не отвечает" and "Сервисы nginx, paperless не
|
||||
// отвечают" differ in the noun, the verb and the adjective. Filling a list into
|
||||
// the singular text is the kind of near-miss that reads as machine-written.
|
||||
//
|
||||
// Only service_down has a plural form today. A family with no "_many" set in
|
||||
// the file is returned unchanged, so adding one is a data change.
|
||||
func (t *NudgeTemplates) pluralFamily(family string, c loop.Candidate) string {
|
||||
if len(loop.DownServices(c.State)) < 2 {
|
||||
return family
|
||||
}
|
||||
many := family + "_many"
|
||||
if _, ok := t.file.Rules[many]; ok {
|
||||
return many
|
||||
}
|
||||
return family
|
||||
}
|
||||
|
||||
// placeholderRE — the {name} slots a template may use.
|
||||
var placeholderRE = regexp.MustCompile(`\{([a-z]+)\}`)
|
||||
|
||||
@@ -165,17 +184,25 @@ func nudgeValues(c loop.Candidate) map[string]string {
|
||||
vals := map[string]string{}
|
||||
rule := c.Rule.Name
|
||||
|
||||
// {service} — one fact per kuma monitor, keyed "service_down:<name>", so
|
||||
// the name lives in the key SUFFIX and there is no fact called plain
|
||||
// "service_down" to read. loop.DownServices is the same helper the rule
|
||||
// fired on, which is what stops the message naming a service that is up.
|
||||
// This used to read c.State.Fact(rule) — the pre-per-monitor aggregate —
|
||||
// and so never filled, leaving the one nameless variant as the only
|
||||
// fillable template every time (Vikunja #534).
|
||||
if down := loop.DownServices(c.State); len(down) > 0 {
|
||||
vals["service"] = strings.Join(down, ", ")
|
||||
}
|
||||
// {since} — only at hour scale. Below an hour the phrase would be minutes,
|
||||
// and none of the templates read well with "сорок минут".
|
||||
// and none of the templates read well with "сорок минут". service_down has
|
||||
// no {since} to offer: its facts are keyed by monitor, and the rule is
|
||||
// edge-triggered, so it fires on the transition rather than hours later.
|
||||
if d, ok := c.State.Since(rule); ok && d >= time.Hour {
|
||||
if s := ruSinceWords(d); s != "" {
|
||||
vals["since"] = s
|
||||
}
|
||||
}
|
||||
// {service} — the aggregate fact's key carries the service name.
|
||||
if f, ok := c.State.Fact(rule); ok && f.Key != "" && f.Key != rule {
|
||||
vals["service"] = f.Key
|
||||
}
|
||||
// {what} — the Russian suffix of "routine:таблетки" / "morning:утро".
|
||||
if i := strings.IndexByte(rule, ':'); i > 0 && i+1 < len(rule) {
|
||||
vals["what"] = rule[i+1:]
|
||||
|
||||
@@ -11,6 +11,102 @@ import (
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
// downCand builds a service_down candidate the way a tick actually does it:
|
||||
// one fact per kuma monitor under the prefix, carrying the source and value
|
||||
// loop.DownServices checks. The old cand() shape wrote a single fact keyed
|
||||
// plain "service_down", which mavpoll stopped producing, and that is why the
|
||||
// tests passed through the whole of #534.
|
||||
func downCand(names ...string) loop.Candidate {
|
||||
now := time.Date(2026, 7, 31, 21, 40, 0, 0, time.UTC)
|
||||
st := loop.State{Now: now, Facts: map[string]store.Fact{}}
|
||||
for _, n := range names {
|
||||
key := loop.ServiceDownPrefix + n
|
||||
st.Facts[key] = store.Fact{
|
||||
Key: key, Ts: now.Add(-3 * time.Minute),
|
||||
Source: loop.ServiceDownSource, Value: `"down"`,
|
||||
}
|
||||
}
|
||||
return loop.Candidate{
|
||||
Rule: loop.Rule{Name: "service_down", Severity: loop.Sev4},
|
||||
Severity: loop.Sev4, State: st,
|
||||
}
|
||||
}
|
||||
|
||||
// The nudge he reads on telegram must name what broke. It is a sev4 that
|
||||
// reaches him away from the box, so "a service is down" costs him a trip to
|
||||
// kuma to learn anything at all.
|
||||
func TestNudgeNamesTheDownService(t *testing.T) {
|
||||
// Lowercased before matching: a name that opens the sentence is
|
||||
// capitalized by capitalizeFirst, which is wanted.
|
||||
nt := newTestTemplates(t, 5)
|
||||
for i := 0; i < 40; i++ {
|
||||
body, _ := nt.Nudge(downCand("paperless"))
|
||||
if !strings.Contains(strings.ToLower(body), "paperless") {
|
||||
t.Fatalf("body does not name the service: %q", body)
|
||||
}
|
||||
}
|
||||
// Two down: both named, in the key order the rule itself uses.
|
||||
for i := 0; i < 40; i++ {
|
||||
body, _ := nt.Nudge(downCand("nginx", "paperless"))
|
||||
low := strings.ToLower(body)
|
||||
if !strings.Contains(low, "nginx") || !strings.Contains(low, "paperless") {
|
||||
t.Fatalf("body drops a service: %q", body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Russian agrees the verb with the subject, so a list of services cannot go
|
||||
// into the singular sentence. One down takes the singular set, two or more
|
||||
// take service_down_many.
|
||||
func TestNudgeAgreesWithTheServiceCount(t *testing.T) {
|
||||
nt := newTestTemplates(t, 9)
|
||||
// "упал " keeps its trailing space: "упали" starts with "упал", and the
|
||||
// plural must not read as the singular by prefix.
|
||||
singular := []string{"не отвечает", "недоступен", "лежит", "упал "}
|
||||
plural := []string{"не отвечают", "недоступны", "лежат", "упали"}
|
||||
|
||||
for i := 0; i < 60; i++ {
|
||||
body, _ := nt.Nudge(downCand("paperless"))
|
||||
if !containsAny(body, singular) {
|
||||
t.Fatalf("one down, no singular verb: %q", body)
|
||||
}
|
||||
if containsAny(body, plural) {
|
||||
t.Fatalf("one down, plural wording: %q", body)
|
||||
}
|
||||
}
|
||||
for i := 0; i < 60; i++ {
|
||||
body, _ := nt.Nudge(downCand("nginx", "paperless"))
|
||||
if !containsAny(body, plural) {
|
||||
t.Fatalf("two down, no plural verb: %q", body)
|
||||
}
|
||||
if containsAny(body, singular) {
|
||||
t.Fatalf("two down, singular wording: %q", body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func containsAny(s string, subs []string) bool {
|
||||
for _, sub := range subs {
|
||||
if strings.Contains(s, sub) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Nothing down means no template fits, and the fallback answers rather than
|
||||
// the picker inventing a name.
|
||||
func TestNudgeServiceDownWithoutFacts(t *testing.T) {
|
||||
nt := newTestTemplates(t, 5)
|
||||
body, mood := nt.Nudge(downCand())
|
||||
if body != "Сервис не отвечает." {
|
||||
t.Fatalf("fallback body %q", body)
|
||||
}
|
||||
if mood != "neutral" {
|
||||
t.Fatalf("mood %q", mood)
|
||||
}
|
||||
}
|
||||
|
||||
// cand builds a candidate the way a tick would.
|
||||
func cand(rule string, sinceMin int, factKey string) loop.Candidate {
|
||||
now := time.Date(2026, 7, 31, 21, 40, 0, 0, time.UTC)
|
||||
@@ -36,7 +132,7 @@ func newTestTemplates(t *testing.T, seed int64) *NudgeTemplates {
|
||||
|
||||
func TestNudgeTemplatesLoad(t *testing.T) {
|
||||
nt := newTestTemplates(t, 1)
|
||||
for _, rule := range []string{"water", "meal", "break", "service_down", "netdata_critical", "routine", "morning", "default"} {
|
||||
for _, rule := range []string{"water", "meal", "break", "service_down", "service_down_many", "netdata_critical", "routine", "morning", "default"} {
|
||||
set, ok := nt.file.Rules[rule]
|
||||
if !ok {
|
||||
t.Errorf("no templates for %q", rule)
|
||||
@@ -46,8 +142,12 @@ func TestNudgeTemplatesLoad(t *testing.T) {
|
||||
t.Errorf("%s: only %d variants", rule, len(set.Variants))
|
||||
}
|
||||
// Every rule needs one variant that needs no value, or a candidate
|
||||
// without context has nothing to say. routine and morning are exempt:
|
||||
// they always carry a name and must always say it.
|
||||
// without context has nothing to say. routine, morning and
|
||||
// service_down are exempt: they always carry a name and must always
|
||||
// say it. service_down's predicate cannot fire without a down fact,
|
||||
// so loop.DownServices always has something to fill {service} with,
|
||||
// and the nameless variant it used to carry was the bug (#534) —
|
||||
// {service} never filled, so that variant was the only fillable one.
|
||||
plain := 0
|
||||
seen := map[string]bool{}
|
||||
for _, v := range set.Variants {
|
||||
@@ -59,7 +159,7 @@ func TestNudgeTemplatesLoad(t *testing.T) {
|
||||
}
|
||||
seen[v] = true
|
||||
}
|
||||
if plain == 0 && rule != "routine" && rule != "morning" {
|
||||
if plain == 0 && rule != "routine" && rule != "morning" && !strings.HasPrefix(rule, "service_down") {
|
||||
t.Errorf("%s: every variant needs a placeholder value", rule)
|
||||
}
|
||||
}
|
||||
@@ -102,8 +202,8 @@ func TestNudgeNoLeftoverPlaceholders(t *testing.T) {
|
||||
cand("water", 0, ""), // no duration
|
||||
cand("water", 30, ""), // under an hour
|
||||
cand("water", 200, ""), // hours
|
||||
cand("service_down", 3, "vaultwarden"),
|
||||
cand("service_down", 3, ""), // no service name
|
||||
downCand("vaultwarden"),
|
||||
downCand(), // nothing down: the fallback answers
|
||||
cand("routine:таблетки", 0, ""),
|
||||
cand("morning:утро", 0, ""),
|
||||
cand("unknown_rule", 0, ""),
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
"Hand-written Russian nudges. 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.",
|
||||
"One short sentence. No questions, no emoji, no pet names, no emotional support.",
|
||||
"Placeholders: {since} how long it has been (only used when it is at least an hour), {service} the service name, {what} the routine name. A variant whose placeholder has no value is skipped, so every rule needs at least one variant with no placeholder. The exception is routine and morning: those only exist for rules like routine:таблетки that always carry a name, and a routine nudge that drops the name is useless.",
|
||||
"Placeholders: {since} how long it has been (only used when it is at least an hour), {service} the service name, {what} the routine name. A variant whose placeholder has no value is skipped, so every rule needs at least one variant with no placeholder. The exception is routine, morning and service_down: those only exist for rules that always carry a name, and one that drops the name is useless.",
|
||||
"A rule may carry a second set named <rule>_many, used when {service} holds more than one name. Russian agrees the verb with the subject, so the plural needs its own wording rather than a list dropped into the singular sentence. Only service_down has one.",
|
||||
"mood must be one of: neutral, happy, thinking, tired, confused."
|
||||
],
|
||||
"rules": {
|
||||
@@ -62,13 +63,24 @@
|
||||
"{service} не отвечает, сервис нужно поднимать.",
|
||||
"Сервис {service} недоступен.",
|
||||
"Проверь {service}: сервис не отвечает.",
|
||||
"Сервис перестал отвечать.",
|
||||
"Сервис {service} лежит, нужно смотреть.",
|
||||
"{service} не отвечает уже {since}.",
|
||||
"Мониторинг сообщает: {service} лежит.",
|
||||
"Сервис {service} не отвечает, посмотри логи."
|
||||
]
|
||||
},
|
||||
"service_down_many": {
|
||||
"mood": "neutral",
|
||||
"variants": [
|
||||
"Сервисы {service} не отвечают.",
|
||||
"{service} упали — сервисы не отвечают.",
|
||||
"{service} не отвечают, сервисы нужно поднимать.",
|
||||
"Сервисы {service} недоступны.",
|
||||
"Проверь {service}: сервисы не отвечают.",
|
||||
"Сервисы {service} лежат, нужно смотреть.",
|
||||
"Мониторинг сообщает: {service} лежат.",
|
||||
"Сервисы {service} не отвечают, посмотри логи."
|
||||
]
|
||||
},
|
||||
"netdata_critical": {
|
||||
"mood": "neutral",
|
||||
"variants": [
|
||||
|
||||
@@ -37,11 +37,19 @@ type Remote interface {
|
||||
// (docs/evals/2026-08-02-workstation-gemma4-12b.md).
|
||||
var ErrNoWorldModel = errors.New("phraser: no world model available")
|
||||
|
||||
// chatTemperature — what the phraser's own transport has always sampled at.
|
||||
// Named so the remote path cannot drift from it silently. Whether 0.7 is right
|
||||
// at all is Vikunja #402, and answering that here would hide a phrasing change
|
||||
// inside a routing change.
|
||||
const chatTemperature = 0.7
|
||||
// defaultChatTemperature — what the phraser's own transport has always sampled
|
||||
// at, and what Config.Temperature falls back to. Named so the remote path
|
||||
// cannot drift from the resident one silently.
|
||||
const defaultChatTemperature = 0.7
|
||||
|
||||
// temperature — the sampling temperature for every phrasing call, resident or
|
||||
// remote. Both paths read this, so a sweep moves them together.
|
||||
func (p *LLMPhraser) temperature() float64 {
|
||||
if p.cfg.Temperature > 0 {
|
||||
return p.cfg.Temperature
|
||||
}
|
||||
return defaultChatTemperature
|
||||
}
|
||||
|
||||
// UseRemote points the phraser at the workstation model. Wiring time only, once,
|
||||
// before anything phrases: the field is read without a lock on every call
|
||||
@@ -85,7 +93,7 @@ func (p *LLMPhraser) PhraseWorld(ctx context.Context, utterance string, sources
|
||||
User: user,
|
||||
Grammar: p.grammar(),
|
||||
MaxTokens: 768,
|
||||
Temperature: chatTemperature,
|
||||
Temperature: p.temperature(),
|
||||
})
|
||||
if err != nil {
|
||||
// The cached probe was one interval stale, or the card went away
|
||||
@@ -124,7 +132,7 @@ func (p *LLMPhraser) remoteChat(ctx context.Context, system, user string, maxTok
|
||||
User: user,
|
||||
Grammar: p.grammar(),
|
||||
MaxTokens: maxTokens,
|
||||
Temperature: chatTemperature,
|
||||
Temperature: p.temperature(),
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("phraser: workstation model declined, phrasing here instead: %v", err)
|
||||
|
||||
@@ -141,9 +141,9 @@ func TestNudgePhrasingPrefersTheWorkstationSilently(t *testing.T) {
|
||||
if len(remote.got) != 1 {
|
||||
t.Fatalf("the workstation saw %d requests, want 1", len(remote.got))
|
||||
}
|
||||
if remote.got[0].Temperature != chatTemperature {
|
||||
if remote.got[0].Temperature != defaultChatTemperature {
|
||||
t.Errorf("temperature = %v, want %v (what the resident transport samples at)",
|
||||
remote.got[0].Temperature, chatTemperature)
|
||||
remote.got[0].Temperature, defaultChatTemperature)
|
||||
}
|
||||
if len(spy.user) != 0 {
|
||||
t.Errorf("the resident model phrased %d nudges, want 0", len(spy.user))
|
||||
|
||||
@@ -91,9 +91,32 @@ func (p *PythonDateParser) Parse(ctx context.Context, text string, now time.Time
|
||||
log.Printf("router: python dateparser unavailable, falling back to stub: %v", err)
|
||||
return p.fallback.Parse(ctx, text, now)
|
||||
}
|
||||
if ok {
|
||||
t = rollPastClockForward(t, now, text)
|
||||
}
|
||||
return t, ok, nil
|
||||
}
|
||||
|
||||
// rollPastClockForward moves a clock that has already gone by to its next
|
||||
// occurrence.
|
||||
//
|
||||
// dateparser is handed PREFER_DATES_FROM future and does not apply it to an
|
||||
// HH:MM time on today's date, so at 14:41 "напомни в половине первого пообедать"
|
||||
// resolved to 12:30 the same day and the reminder was two hours in the past
|
||||
// (V-544). parseClock in the stub has always rolled forward, so this is the
|
||||
// production parser agreeing with the floor rather than a new rule.
|
||||
//
|
||||
// Only a bare clock rolls. A sentence that names its day keeps it, so a
|
||||
// deliberate "сегодня в 12:30" stays where he put it, and the backdated write
|
||||
// path (V-518) is a different seam entirely. Past by a day or more is not a
|
||||
// clock resolved onto today, so it is left alone too.
|
||||
func rollPastClockForward(t, now time.Time, text string) time.Time {
|
||||
if t.After(now) || now.Sub(t) >= 24*time.Hour || NamesADay(text) {
|
||||
return t
|
||||
}
|
||||
return t.Add(24 * time.Hour)
|
||||
}
|
||||
|
||||
// parseWithPython runs the dateparser script and parses the timestamp output.
|
||||
// Returns (zero, false, error) on process/exec failure; (zero, false, nil)
|
||||
// when the script ran but found no date.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user