Compare commits
200 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 97e1a44c1a | |||
| dde556a3d3 | |||
| 22edc3cdfb | |||
| 0445693a16 | |||
| c7d22858ba | |||
| 0b994ff1c3 | |||
| 12ecc30c57 | |||
| 190cf0c794 | |||
| 6b3749f5a2 | |||
| d156be3442 | |||
| f29bc107d4 | |||
| 10975eff07 | |||
| cc48309c7c | |||
| 4534101d10 | |||
| 5b8707e21e | |||
| 76d123edf3 | |||
| 62675e8fe4 | |||
| 46acf3cba0 | |||
| 373229ab7a | |||
| 2dbf476c45 | |||
| 13cb1903a9 | |||
| 8d20efcfbb | |||
| c661f7bd1a | |||
| e5158d8828 | |||
| 07f7550931 | |||
| 8c1e457150 | |||
| 6923a983aa | |||
| 1d10c9535c | |||
| 3b3660da9a | |||
| dd699b706f | |||
| 0b1efe4911 | |||
| 580959f856 | |||
| bf2587c7fa | |||
| 26ff646ace | |||
| 9e1958e7b0 | |||
| e6923490fd | |||
| d7a43afd90 | |||
| fabc3bc274 | |||
| b6eed20af2 | |||
| 936c6d71db | |||
| 72aa97dae8 | |||
| 1c0a1d0db0 | |||
| 37feee1eb3 | |||
| 94c273780a | |||
| 93c08f9de1 | |||
| 4f96bbd6ec | |||
| 7dba1b7935 | |||
| 8102c73f83 | |||
| 8c36e7ef84 | |||
| 95cbf82e38 | |||
| 5bca435146 | |||
| 69270f4cfb | |||
| 01230bf16b | |||
| ebce90b984 | |||
| 04584fb2da | |||
| 5447f08c06 | |||
| 650363ce67 | |||
| 85456d3833 | |||
| 901354002e | |||
| f4a021d3da | |||
| 316fb197a8 | |||
| 67decc42f0 | |||
| 201fe03d20 | |||
| fec572c997 | |||
| 5187f3bd14 | |||
| 1b5d093148 | |||
| 502327678f | |||
| 5d2fd91c06 | |||
| 33c2d782a9 | |||
| e8ece874b1 | |||
| 1fa14e95a4 | |||
| dd6da78aeb | |||
| d5d4166710 | |||
| 6ec4220668 | |||
| 59cc882265 | |||
| be18649953 | |||
| 41d3a4a903 | |||
| 188d9fc02f | |||
| 5a85d37fa5 | |||
| d70cb7e9ab | |||
| c0aee1558f | |||
| 0d49745a17 | |||
| 2063f8e770 | |||
| 173531be8c | |||
| 01c78ef369 | |||
| bac8673f05 | |||
| 5cc51c5b6e | |||
| be869c6a48 | |||
| 8559f1f450 | |||
| b908c39e45 | |||
| 8c774abe5b | |||
| e2777177b0 | |||
| b8250a8711 | |||
| 02ce730cb2 | |||
| aaf1f0236b | |||
| e488ee2285 | |||
| b6680398c3 | |||
| 0feb8d3dbd | |||
| 0886662360 | |||
| 439ceb5d8e | |||
| ecaf3407fa | |||
| b60264701c | |||
| 60540fa934 | |||
| db8cbdc20a | |||
| 91f6ea84a0 | |||
| b726658692 | |||
| 5f5f14eba7 | |||
| 59b98d5c4d | |||
| 59210cf07d | |||
| 828e034c96 | |||
| c699c139b4 | |||
| b705a786ef | |||
| 20fe909f76 | |||
| ceba69c4bb | |||
| 95eeef13dd | |||
| cfbef45feb | |||
| 3bb82a90db | |||
| abbf0fe60d | |||
| b46bab99f7 | |||
| b9ee858421 | |||
| 594bfc2bc3 | |||
| c600b426f3 | |||
| b95566aa98 | |||
| 795ecf67a5 | |||
| cb3b507ed5 | |||
| 7262310fce | |||
| 69eda3ceee | |||
| 43dc487113 | |||
| 22a8eed1c3 | |||
| 1e35a10f33 | |||
| 69a6eb0fb9 | |||
| 4f7ad7d99a | |||
| 01b47e3864 | |||
| 12c2ae1d17 | |||
| d457c97355 | |||
| 4761c20ad6 | |||
| ed0331c774 | |||
| 968477ea59 | |||
| 82d0384020 | |||
| 1b6d51dc71 | |||
| 9ed259660d | |||
| c8a5b5416e | |||
| 40152e3688 | |||
| 7f411656c2 | |||
| e4f0508a2f | |||
| 5fede2acb7 | |||
| 9ea178be99 | |||
| b49755302c | |||
| 9ef6f286a9 | |||
| c0b99828f9 | |||
| 7414ef4c39 | |||
| 231a4e00f6 | |||
| 52548abe02 | |||
| 70b32af8a7 | |||
| 521c315b30 | |||
| 2acfeb4453 | |||
| 1c20df70f8 | |||
| fffd0cb5fa | |||
| 77a7c994d7 | |||
| 63812af920 | |||
| 869580c913 | |||
| 31deb7d565 | |||
| 1338ec6e2a | |||
| 7843728174 | |||
| a3ad9b5040 | |||
| cb0a3a4f20 | |||
| 6abd2768e8 | |||
| 6e6f73da35 | |||
| 1a64c30427 | |||
| 5753f90752 | |||
| 4d94277836 | |||
| 530c3ff395 | |||
| e031f8f5f5 | |||
| 6c24e19b83 | |||
| 934a28d67c | |||
| cf28f6fdf0 | |||
| eec3d9bed2 | |||
| a5b245dbf5 | |||
| 3e6a427e85 | |||
| 5ac7347c38 | |||
| 5417692566 | |||
| b56e0e6248 | |||
| 0558dfed0f | |||
| 13a5ef0100 | |||
| ac78f83406 | |||
| 40c59aa275 | |||
| 84a75274bf | |||
| da2d11dab6 | |||
| de3f2b5fc2 | |||
| e8f4baf407 | |||
| 92eb6cf6e1 | |||
| 6759ff6003 | |||
| 39284cd851 | |||
| ea0eb167fd | |||
| b6305f1b6e | |||
| 5bd1406c7a | |||
| d988154063 | |||
| 1b76fa8205 | |||
| e87088afb8 | |||
| 1b8d2c60d3 |
@@ -79,7 +79,7 @@ Pure-Go packages (`router`, `memory`, `mavweb`, …) run under a plain `go test
|
||||
| `mavttsd` | Text-to-speech (piper subprocess). |
|
||||
| `mavwaked` | Wake-word / VAD gate. **Not on homesrv** — see below. |
|
||||
| `mavenclient` | Voice loop client (mic → stt → core → tts). **Not on homesrv** — see below. |
|
||||
| `mavpoll` | Telegram long-poll reach. |
|
||||
| `mavpoll` | Environment poller: netdata alarms, uptime-kuma, zenmoney, wireguard presence. Writes facts, sends nothing. Telegram is `internal/delivery/telegramsink`, not this. |
|
||||
| `mavcaldav` | CalDAV calendar sync. |
|
||||
| `mavmaild` | Mail reader (IMAP, read-only). Holds the IMAP password; core never sees it. |
|
||||
|
||||
@@ -91,10 +91,17 @@ protocol; the config in `deploy/mavend.json` (with `${VAR}` env expansion from g
|
||||
decision, not an oversight** (Vikunja #463, `docs/plans/17-where-the-voice-loop-runs.md`).
|
||||
homesrv has a microphone — it is a laptop — but it is in the wrong room, so a wake-word
|
||||
daemon there listens to nobody. They belong on a client machine where the owner is standing.
|
||||
`ipc.Dial` already takes `tcp://host:port?token=...` through the netaddr seam, so nothing
|
||||
needs building to allow it, but no such machine exists yet. **The consequence: the wake
|
||||
word and the VAD gate are covered by unit tests and by nothing else, and no amount of
|
||||
sitting at the box changes that.** Push-to-talk through `/dash` is what QA actually covers.
|
||||
|
||||
**That machine is workpc** (owner's correction, 2026-08-05). This section used to say no
|
||||
such machine existed, which was written when the workstation was only a model host. It is
|
||||
where he sits most of the day and it has the microphone. `ipc.Dial` already takes
|
||||
`tcp://host:port?token=...` through the netaddr seam, so the two daemons need deploying,
|
||||
not building. V-515 is that deployment.
|
||||
|
||||
Until they are deployed, **the wake word and the VAD gate are covered by unit tests and by
|
||||
nothing else**, and push-to-talk through `/dash` is what QA actually covers. Note that
|
||||
deploying them does not by itself prove a wake word: `mavwaked` gates on energy and has no
|
||||
keyword model (V-487), so the loop runs open until that lands.
|
||||
|
||||
## The ecosystem: Nexus, Praxis, Hexis
|
||||
|
||||
@@ -155,6 +162,19 @@ on in deploy** — this section used to say it was wired `nil`, which stopped be
|
||||
Cascade order: `stage0.go` exact-match fast-path → LLM router (when non-nil) → classifier
|
||||
fallback. Any LLM error falls through to the classifier so a turn never breaks on the model.
|
||||
|
||||
**A stage-0 decision is slot-extracted too, since 06-08-2026** (V-572). `fillMatchedSlots`
|
||||
in `router.go` runs the stage-2 extractor over whatever a grammar built and fills only the
|
||||
slots it left empty — a matched value always wins, because the rule read a literal pattern
|
||||
and the extractor guesses. It did not run before, so `ReminderGrammar` handed the daemon
|
||||
`HasTime: false` for "напомни в 11:00 позвонить маме" and `missingFor` read the silence as
|
||||
absence and asked "Когда?". It applies to every grammar and is inert for all but the
|
||||
reminder: `Extract` fills Time, Fn and Key and nothing else, and the query, clock, agenda,
|
||||
feed, list, task and narrative rules all emit intents with no such slot. Benchmarked at
|
||||
20000x, a stage-0 query costs 3.7µs against 3.9µs before. **`Slots.Text` is deliberately not
|
||||
filled** — a grammar that left it empty meant it, and `agendaQueryBuild` hands the query
|
||||
chain the utterance itself. Fixture unchanged at 64/91, with "slots deferred to daemon"
|
||||
6 → 0.
|
||||
|
||||
Measured on the 77-case RU fixture. **Re-measured 2026-08-02: the classifier scores 68.8%
|
||||
full accuracy at p50 16.6µs**, not the 36.8% at p50 31ms that stood here from
|
||||
`docs/evals/2026-07-31-model-bakeoff.md`. That older figure predates the stage 0 rules and the
|
||||
@@ -254,11 +274,32 @@ of the house. A demonstrative ("отметь это как сделанное")
|
||||
`h.surfacedItems` only when exactly one item was spoken. Otherwise the turn goes back to
|
||||
the cascade rather than transitioning the wrong item.
|
||||
|
||||
**Who claimed a turn is now recorded, and so is who did not** (V-564, umbrella
|
||||
V-558). Arbitration between the claimants on the utterance stream is order,
|
||||
hardcoded in the pre-route resolver ladder, in `buildRouter` and in
|
||||
`querySources`. `internal/decision` records one `Record` per turn: every
|
||||
claimant, what it would have made the turn, the score it reported, and whether
|
||||
it won, declined, lost on score, was thinned by a gate or was **never asked**.
|
||||
The record rides the context, the same seam `querysource.go` uses, so a claim
|
||||
site cannot change a route and a context with no record costs nothing. It is
|
||||
installed in `runTurn`, so the mic, telegram and the web all leave the same
|
||||
trail. Storage is a 25-turn in-memory ring on the handler (`decision.Ring`),
|
||||
read over `ipc.TurnDecisions` and rendered as the second table on `/trace`.
|
||||
Nothing persists: a turn record is read minutes later or never, and his words do
|
||||
not belong in a table that outlives the diagnosis. Adding a rung to the ladder
|
||||
in `runTurn` means adding its name to `preRouteLadder` in
|
||||
`cmd/mavend/decisiontrace.go`, or that rung is silently missing from the record.
|
||||
|
||||
## LLM output contract
|
||||
|
||||
All phrasing paths emit `{"response":"...","mood":"..."}` (parsed in `replier_llm.go` and
|
||||
`internal/phraser/llmphraser.go`), with fallback to plain text and the legacy
|
||||
`{"body","summary"}`. Mood is a fixed enum. Router prompt is a separate contract:
|
||||
All phrasing paths emit `{"response":"...","mood":"..."}`, with fallback to plain text when
|
||||
the model skips the JSON. **One parser, `parseResponseMood` in
|
||||
`internal/phraser/parse.go`**, and every path reaches it: the six `LLMPhraser` methods,
|
||||
`PhraseWorld`, and `Replier.PhraseReply`, which `cmd/mavend/replier_llm.go` wraps — that file
|
||||
holds the stub fallback and no parsing of its own. The legacy `{"body","summary"}` fallback
|
||||
was deleted on 2026-08-06 (V-397): it was the contract before `{"response","mood"}` replaced
|
||||
it, no prompt asks for that shape, the GBNF cannot emit it, and no test covered it.
|
||||
Mood is a fixed enum. Router prompt is a separate contract:
|
||||
`[{"intent":<enum>, key?, value?, text?, verb?}, ...]`, 7 intents (`fact, reminder,
|
||||
note, query, act, chat, system`). `llm/check_prompt_parity.py` in the training
|
||||
workspace enforces that the Go and relabelling prompts remain identical.
|
||||
|
||||
@@ -188,6 +188,11 @@ func (p *poller) pollOnce(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// maxResponseBody bounds every CalDAV response this daemon reads (the poller's
|
||||
// GET and the renderer's PROPFIND) — a misbehaving or malicious server gets a
|
||||
// truncated read, not an unbounded one.
|
||||
const maxResponseBody = 4 << 20
|
||||
|
||||
// fetchEvents GETs the calendar URL and parses VEVENTs from the iCal response.
|
||||
func (p *poller) fetchEvents(ctx context.Context, now time.Time) ([]calendar.Event, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.url, nil)
|
||||
@@ -203,7 +208,7 @@ func (p *poller) fetchEvents(ctx context.Context, now time.Time) ([]calendar.Eve
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBody))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -142,7 +142,7 @@ func (r *renderer) listPublished(ctx context.Context) ([]int64, error) {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBody))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -31,15 +31,11 @@ import (
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/audio"
|
||||
"github.com/kami/maven/internal/voice"
|
||||
@@ -155,9 +151,3 @@ func writeWAV(path string, a audio.Audio) error {
|
||||
// jsonUnmarshal — kept local rather than pulling encoding/json into main.go
|
||||
// top-level space.
|
||||
func jsonUnmarshal(b []byte, v any) error { return json.Unmarshal(b, v) }
|
||||
|
||||
// keep strconv + io + net + time alive for future duration/size helpers.
|
||||
var _ = strconv.Atoi
|
||||
var _ io.Reader = (io.Reader)(nil)
|
||||
var _ = net.IPv4
|
||||
var _ = time.Second
|
||||
|
||||
@@ -9,9 +9,24 @@ import (
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
// nothingToCorrectReply — what she says to a correction that points at
|
||||
// nothing. Filing it would put a sentence in his memory that reads as a fact.
|
||||
const nothingToCorrectReply = "не поняла, что поправить. скажи целиком, и я запишу."
|
||||
|
||||
// actionNote handles router.IntentNote: embed the note, persist it, and
|
||||
// index it for recall.
|
||||
//
|
||||
// The stored body is dec.Utterance and nothing else (V-576). It is not
|
||||
// Slots.Text, not phraser output and not any other model string: a note is
|
||||
// durable, the embedder indexes it, and it comes back later as recall in his
|
||||
// own words. Phrasing belongs in the spoken confirmation.
|
||||
func (h *reactiveHandler) actionNote(ctx context.Context, dec router.Decision) string {
|
||||
// A correction with no referent. Everything that could own one has already
|
||||
// run by here: clarify, confirm and repair are all resolved before routing,
|
||||
// so a fragment reaching the note path has nothing behind it (V-576).
|
||||
if correctionFragment(dec.Utterance) {
|
||||
return nothingToCorrectReply
|
||||
}
|
||||
// An utterance that explicitly files a task is work, not recall, and
|
||||
// belongs in the task store (Vikunja #130). Checked before the embedding
|
||||
// is paid for. Everything else is a note, exactly as before.
|
||||
|
||||
+126
-29
@@ -11,6 +11,7 @@ import (
|
||||
"unicode"
|
||||
|
||||
"github.com/kami/maven/internal/crawl"
|
||||
"github.com/kami/maven/internal/decision"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/memory"
|
||||
"github.com/kami/maven/internal/morning"
|
||||
@@ -87,15 +88,15 @@ var querySources = []querySource{
|
||||
// through every source to the web search (Vikunja #475). Its matcher needs
|
||||
// an attention marker, and it falls through when Praxis is not configured.
|
||||
{name: "attention", answer: (*reactiveHandler).queryAttention},
|
||||
// Before the recall sources too: "сколько я потратил?" is a question about
|
||||
// the money facts the poller wrote, and the notes pass would otherwise
|
||||
// answer it from whatever he once said about spending. Its matcher needs a
|
||||
// money noun plus an actual ask, so "я потратил весь день" is untouched.
|
||||
// Next to "tasks" and for the same reason: "что мне купить?" is a question
|
||||
// about the shopping list, and the recall pass would otherwise answer it
|
||||
// from an old note about the shop. Its matcher needs an explicit list
|
||||
// marker, so "надо бы съездить в магазин" is untouched.
|
||||
{name: "list", answer: (*reactiveHandler).queryList},
|
||||
// Before the recall sources too: "сколько я потратил?" is a question about
|
||||
// the money facts the poller wrote, and the notes pass would otherwise
|
||||
// answer it from whatever he once said about spending. Its matcher needs a
|
||||
// money noun plus an actual ask, so "я потратил весь день" is untouched.
|
||||
{name: "money", answer: (*reactiveHandler).queryMoney},
|
||||
// Also above the recall sources: "что я тебе говорил?" is a question about
|
||||
// the facts he tapped in, and the notes pass would answer it with whatever
|
||||
@@ -158,8 +159,17 @@ var querySources = []querySource{
|
||||
|
||||
func (h *reactiveHandler) actionQuery(ctx context.Context, dec router.Decision) string {
|
||||
t := &queryTurn{dec: dec}
|
||||
// The roster, so the record can say which sources were never reached rather
|
||||
// than leaving them out and letting a reader assume they looked and passed
|
||||
// (V-564). Finish names everyone below the winner.
|
||||
decision.Expect(ctx, decision.StageQuery, querySourceNames())
|
||||
rec := decision.From(ctx)
|
||||
for _, src := range querySources {
|
||||
if dec.Continued && !src.dateAware {
|
||||
rec.Note(decision.Claim{
|
||||
Stage: decision.StageQuery, Claimant: src.name, Outcome: decision.NeverAsked,
|
||||
Reason: "a continuation turn only asks the date-aware sources",
|
||||
})
|
||||
continue
|
||||
}
|
||||
if reply, ok := src.answer(h, ctx, t); ok {
|
||||
@@ -172,8 +182,16 @@ func (h *reactiveHandler) actionQuery(ctx context.Context, dec router.Decision)
|
||||
// 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)
|
||||
rec.Note(decision.Claim{
|
||||
Stage: decision.StageQuery, Claimant: src.name,
|
||||
Intent: string(dec.Intent), Outcome: decision.Won,
|
||||
})
|
||||
return reply
|
||||
}
|
||||
rec.Note(decision.Claim{
|
||||
Stage: decision.StageQuery, Claimant: src.name, Outcome: decision.Declined,
|
||||
Reason: "it had no answer for this turn",
|
||||
})
|
||||
}
|
||||
if dec.Continued {
|
||||
// The previous question cannot be re-asked for another day. Saying so
|
||||
@@ -217,7 +235,13 @@ func (h *reactiveHandler) queryFactByKey(ctx context.Context, t *queryTurn) (str
|
||||
//
|
||||
// Read-only by construction — the plan is assembled and rendered core-side and
|
||||
// nothing here schedules or announces. "что дальше?" asks for the rest of the
|
||||
// day, so that phrasing trims what has already passed.
|
||||
// day, so that phrasing trims what has already passed and reads only the next
|
||||
// morning.NextSpoken entries. Trimming alone was not enough: asked early it cuts
|
||||
// nothing, and she read 43 entries aloud in one sentence (V-618).
|
||||
//
|
||||
// "что у меня сегодня?" is a different question and is not narrowed here — it
|
||||
// carries no plan word, so IsDayPlanQuery declines it and the calendar source
|
||||
// answers the whole day.
|
||||
//
|
||||
// What surface this belongs on is still open, tracked as Vikunja #431 ("Board
|
||||
// surface: Maven holds the work board, runs the intake form, never argues").
|
||||
@@ -236,16 +260,23 @@ func (h *reactiveHandler) queryDayPlan(ctx context.Context, t *queryTurn) (strin
|
||||
}
|
||||
// Rebuild the pure plan so the rest-of-day rendering is the same code that
|
||||
// rendered the whole day — one formatter, one persona.
|
||||
p := morning.Plan{Date: plan.Date}
|
||||
//
|
||||
// The instants are put back in the asking clock's zone on the way in. They
|
||||
// arrive carrying whatever zone the core read them in — a calendar fact's Ts
|
||||
// and a reminder's FireTs are UTC out of the store — and FormatRU reads the
|
||||
// hours in the plan's own frame, so setting that frame here is what makes
|
||||
// the recital name his clock rather than the store's (V-614).
|
||||
zone := h.now().Location()
|
||||
p := morning.Plan{Date: plan.Date.In(zone)}
|
||||
for _, it := range plan.Items {
|
||||
p.Items = append(p.Items, morning.PlanEntry{
|
||||
At: it.At,
|
||||
At: it.At.In(zone),
|
||||
Text: it.Text,
|
||||
Kind: morning.PlanKind(it.Kind),
|
||||
Uncertain: it.Uncertain,
|
||||
})
|
||||
}
|
||||
return p.After(h.now()).FormatRU(), true
|
||||
return p.Next(h.now(), morning.NextSpoken).FormatRU(), true
|
||||
}
|
||||
|
||||
// habitFactWindow — how many recent SELF facts the behaviour profile is counted
|
||||
@@ -410,6 +441,10 @@ func (h *reactiveHandler) queryCalendar(ctx context.Context, t *queryTurn) (stri
|
||||
return f.FormatEntries(entries, date), true
|
||||
}
|
||||
|
||||
// homeTimeout — the whole house read. Longer than the weather call because the
|
||||
// hub is polled over the LAN and answers for every device at once.
|
||||
const homeTimeout = 10 * time.Second
|
||||
|
||||
// queryHome answers a question about the house. Read-only by construction: it
|
||||
// calls States and nothing else, so there is no confirm turn here — the only
|
||||
// way to CHANGE something is an enabled allowlist row through tool.Executor.
|
||||
@@ -426,7 +461,7 @@ func (h *reactiveHandler) queryHome(ctx context.Context, t *queryTurn) (string,
|
||||
// unreachable case is different and homeSummary covers it.
|
||||
return "", false
|
||||
}
|
||||
ctxH, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
ctxH, cancel := context.WithTimeout(ctx, homeTimeout)
|
||||
defer cancel()
|
||||
return h.home.homeSummary(ctxH)
|
||||
}
|
||||
@@ -450,6 +485,10 @@ func (h *reactiveHandler) queryNetwork(ctx context.Context, t *queryTurn) (strin
|
||||
return h.netscan.scanSummary(ctx)
|
||||
}
|
||||
|
||||
// weatherTimeout — one geocode plus one forecast read. He asked a question with
|
||||
// a one-line answer, so a provider that is slower than this is a failure.
|
||||
const weatherTimeout = 5 * time.Second
|
||||
|
||||
func (h *reactiveHandler) queryWeather(ctx context.Context, t *queryTurn) (string, bool) {
|
||||
if !h.turnIsAbout(ctx, t, topicWeather, isWeatherQuery) {
|
||||
return "", false
|
||||
@@ -460,7 +499,7 @@ func (h *reactiveHandler) queryWeather(ctx context.Context, t *queryTurn) (strin
|
||||
// so is the only honest answer; picking a city would be inventing one.
|
||||
return phraser.Q(phraser.QueryWeatherWhere, nil), true
|
||||
}
|
||||
ctxWT, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
ctxWT, cancel := context.WithTimeout(ctx, weatherTimeout)
|
||||
defer cancel()
|
||||
w, err := h.weatherProvider.CurrentWeather(ctxWT, loc)
|
||||
if errors.Is(err, weather.ErrNotConfigured) {
|
||||
@@ -485,7 +524,15 @@ func (h *reactiveHandler) queryWeather(ctx context.Context, t *queryTurn) (strin
|
||||
|
||||
// queryEmbed isn't an answer source — it's the shared cost the two recall
|
||||
// sources below both need, run once, in the position it always ran in. It
|
||||
// only claims the turn when the embedder fails.
|
||||
// never claims the turn.
|
||||
//
|
||||
// It used to claim on an embedder error, and that made a RAG hint a hard gate
|
||||
// over everything below it (V-568): one failing EmbedQuery and the memory, the
|
||||
// notes, the boundary, the search, the ZIMs, the named page and the model all
|
||||
// answered "не смогла ответить", including the questions search and Kiwix
|
||||
// would have answered without ever touching the embedder. A failed embed means
|
||||
// this source cannot claim, not that the turn is over — same shape as
|
||||
// turnVector in topics.go, which had it right.
|
||||
func (h *reactiveHandler) queryEmbed(ctx context.Context, t *queryTurn) (string, bool) {
|
||||
// A topic source above already paid for this one; see turnVector.
|
||||
if len(t.vec) > 0 {
|
||||
@@ -493,13 +540,37 @@ func (h *reactiveHandler) queryEmbed(ctx context.Context, t *queryTurn) (string,
|
||||
}
|
||||
vec, err := router.EmbedQuery(ctx, h.recall.embedder, t.dec.Utterance)
|
||||
if err != nil {
|
||||
// Logged once, here, and the chain walks on. The two recall sources
|
||||
// below read the empty vector and pass; the boundary drops to its
|
||||
// offline floor.
|
||||
log.Printf("voice: embed query: %v", err)
|
||||
return phraser.Q(phraser.QueryFailAnswer, nil), true
|
||||
return "", false
|
||||
}
|
||||
t.vec = vec
|
||||
return "", false
|
||||
}
|
||||
|
||||
// memoryRecallWidth and noteRecallWidth — how many candidates each recall pass
|
||||
// pulls before the gate reads them. Both are small on purpose: the gate wants a
|
||||
// best hit and its runner-up, and every further row is a margin the top match
|
||||
// has to beat.
|
||||
const (
|
||||
memoryRecallWidth = 3
|
||||
noteRecallWidth = 5
|
||||
)
|
||||
|
||||
// recallOnTopic — the topic veto both recall sources apply after the score gate
|
||||
// (#470). A memory about his slow network scored high enough to answer "почему
|
||||
// небо синее?", because the right-note and must-be-silent score ranges overlap
|
||||
// and no threshold sits between them.
|
||||
func recallOnTopic(utterance, text string) bool {
|
||||
if memory.RecallAllowed(utterance, text) {
|
||||
return true
|
||||
}
|
||||
log.Printf("voice: recall %q rejected for %q: a world question and no shared topic word", text, utterance)
|
||||
return false
|
||||
}
|
||||
|
||||
// queryMemory — long-term memory first: ONE search over everything Maven
|
||||
// remembers (notes and facts share this index) and ONE confidence gate, so
|
||||
// the memory that is clearly the best match answers — a note just as much as
|
||||
@@ -514,7 +585,13 @@ func (h *reactiveHandler) queryMemory(ctx context.Context, t *queryTurn) (string
|
||||
if h.recall.memStore == nil {
|
||||
return "", false
|
||||
}
|
||||
hits, herr := h.recall.memStore.Search(ctx, t.vec, 3)
|
||||
if len(t.vec) == 0 {
|
||||
// No query vector: the embed above failed or there is no embedder.
|
||||
// Searching on an empty vector is not a search, and its scores are not
|
||||
// a "there is nothing" answer — pass rather than gate the chain.
|
||||
return "", false
|
||||
}
|
||||
hits, herr := h.recall.memStore.Search(ctx, t.vec, memoryRecallWidth)
|
||||
if herr != nil {
|
||||
log.Printf("voice: memory search: %v", herr)
|
||||
return "", false
|
||||
@@ -524,12 +601,8 @@ func (h *reactiveHandler) queryMemory(ctx context.Context, t *queryTurn) (string
|
||||
return "", false
|
||||
}
|
||||
text := hit.Meta["text"]
|
||||
// The score cleared the gate and the topic still has to match (#470). A
|
||||
// note about his slow network scored high enough to answer "почему небо
|
||||
// синее?", because the right-note and must-be-silent score ranges overlap
|
||||
// and no threshold sits between them.
|
||||
if !memory.RecallAllowed(t.dec.Utterance, text) {
|
||||
log.Printf("voice: recall %q rejected for %q: a world question and no shared topic word", text, t.dec.Utterance)
|
||||
// The score cleared the gate and the topic still has to match.
|
||||
if !recallOnTopic(t.dec.Utterance, text) {
|
||||
return "", false
|
||||
}
|
||||
// A note is phrased in Maven's voice; a fact is read back as it was
|
||||
@@ -560,10 +633,18 @@ func (h *reactiveHandler) queryMemory(ctx context.Context, t *queryTurn) (string
|
||||
// band. See memory.Confident. Failing the gate passes the turn on to general
|
||||
// knowledge, which is what "don't read back the runner-up" means here.
|
||||
func (h *reactiveHandler) queryNotes(ctx context.Context, t *queryTurn) (string, bool) {
|
||||
notes, err := h.api.QueryNotes(ctx, t.vec, 5)
|
||||
if len(t.vec) == 0 {
|
||||
// Same reason as queryMemory above (V-568): with no query vector this
|
||||
// source could not look, and could-not-look passes.
|
||||
return "", false
|
||||
}
|
||||
notes, err := h.api.QueryNotes(ctx, t.vec, noteRecallWidth)
|
||||
if err != nil {
|
||||
// The store failed, so this source could not look either. It used to
|
||||
// claim here, which stopped the search, the ZIMs and the model from
|
||||
// answering a question that never needed a note (V-568).
|
||||
log.Printf("voice: query notes: %v", err)
|
||||
return phraser.Q(phraser.QueryFailAnswer, nil), true
|
||||
return "", false
|
||||
}
|
||||
t.notes = notes
|
||||
noteScores := make([]float64, len(notes))
|
||||
@@ -573,10 +654,9 @@ func (h *reactiveHandler) queryNotes(ctx context.Context, t *queryTurn) (string,
|
||||
if !memory.ConfidentScores(noteScores, h.recall.minScore, h.recall.minMargin) {
|
||||
return "", false
|
||||
}
|
||||
// Same topic veto as queryMemory above: the best note must be about what
|
||||
// he asked, not merely the nearest vector in the index.
|
||||
if !memory.RecallAllowed(t.dec.Utterance, notes[0].Text) {
|
||||
log.Printf("voice: note %q rejected for %q: a world question and no shared topic word", notes[0].Text, t.dec.Utterance)
|
||||
// The best note must be about what he asked, not merely the nearest vector
|
||||
// in the index.
|
||||
if !recallOnTopic(t.dec.Utterance, notes[0].Text) {
|
||||
return "", false
|
||||
}
|
||||
texts := make([]string, len(notes))
|
||||
@@ -598,6 +678,23 @@ func (h *reactiveHandler) queryNotes(ctx context.Context, t *queryTurn) (string,
|
||||
// prompt, the persona block and the reply.
|
||||
const webPageContextRunes = 1500
|
||||
|
||||
// webFetchTimeout — the whole named-page source. Longer than the other outside
|
||||
// sources because he named this page himself, so waiting for it is what he asked
|
||||
// for, and there is nothing below that can answer instead.
|
||||
const webFetchTimeout = 30 * time.Second
|
||||
|
||||
// readBackRunes — how much of the evidence is read out when the phraser gave
|
||||
// nothing back. It is spoken aloud, so it is a couple of sentences and not a
|
||||
// page.
|
||||
const readBackRunes = 300
|
||||
|
||||
// readBack — what an outside source says when the phraser gave nothing back.
|
||||
// The evidence is read out plainly rather than dropped, because the fetch did
|
||||
// happen and its result is a better answer than silence.
|
||||
func readBack(evidence string) string {
|
||||
return phraser.Q(phraser.QueryFound, map[string]string{"text": crawl.TrimRunes(evidence, readBackRunes)})
|
||||
}
|
||||
|
||||
// queryWeb — "посмотри https://example.org/x — что там?" (Vikunja #259).
|
||||
//
|
||||
// It claims a turn ONLY when he named a URL, which is what keeps a fallback from
|
||||
@@ -616,7 +713,7 @@ func (h *reactiveHandler) queryWeb(ctx context.Context, t *queryTurn) (string, b
|
||||
// guess dressed as an answer (Vikunja #479).
|
||||
return phraser.Q(phraser.QueryPageOff, nil), true
|
||||
}
|
||||
ctxFetch, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
ctxFetch, cancel := context.WithTimeout(ctx, webFetchTimeout)
|
||||
defer cancel()
|
||||
page, err := h.crawler.Page(ctxFetch, link)
|
||||
if err != nil {
|
||||
@@ -637,7 +734,7 @@ func (h *reactiveHandler) queryWeb(ctx context.Context, t *queryTurn) (string, b
|
||||
if reply == "" {
|
||||
// No phraser (or it failed): read back the top of the page rather than
|
||||
// pretend the fetch did not happen.
|
||||
return phraser.Q(phraser.QueryPageText, map[string]string{"text": crawl.TrimRunes(page.Text, 300)}), true
|
||||
return phraser.Q(phraser.QueryPageText, map[string]string{"text": crawl.TrimRunes(page.Text, readBackRunes)}), true
|
||||
}
|
||||
return reply, true
|
||||
}
|
||||
@@ -703,7 +800,7 @@ func (h *reactiveHandler) querySearch(ctx context.Context, t *queryTurn) (string
|
||||
if reply == "" {
|
||||
// No phraser, or it failed. Read back the best evidence rather than
|
||||
// pretend the search did not happen.
|
||||
return phraser.Q(phraser.QueryFound, map[string]string{"text": crawl.TrimRunes(resp.Snippets()[0], 300)}), true
|
||||
return readBack(resp.Snippets()[0]), true
|
||||
}
|
||||
return reply, true
|
||||
}
|
||||
@@ -792,7 +889,7 @@ func (h *reactiveHandler) queryKiwix(ctx context.Context, t *queryTurn) (string,
|
||||
if reply == "" {
|
||||
// No phraser, or it failed. Read back the best hit rather than pretend
|
||||
// the search did not happen.
|
||||
return phraser.Q(phraser.QueryFound, map[string]string{"text": crawl.TrimRunes(top.Title+" — "+page.Text, 300)}), true
|
||||
return readBack(top.Title + " — " + page.Text), true
|
||||
}
|
||||
return reply, true
|
||||
}
|
||||
|
||||
@@ -19,7 +19,10 @@ func (h *reactiveHandler) actionReminder(ctx context.Context, dec router.Decisio
|
||||
// time wasn't parsed. Run the parser as a fallback.
|
||||
if dec.Stage == 0 && h.timeParser != nil {
|
||||
t, ok, err := h.timeParser.Parse(ctx, dec.Utterance, h.now())
|
||||
if err == nil && ok {
|
||||
// Same gate as the extractor (V-577, V-579, V-610): a request whose
|
||||
// hour was not spoken, or was spoken and not read, gets asked about
|
||||
// and is never completed from the clock.
|
||||
if err == nil && ok && router.ResolvedTheHour(dec.Utterance, t) {
|
||||
dec.Slots.Time = t
|
||||
dec.Slots.HasTime = true
|
||||
}
|
||||
|
||||
+303
-72
@@ -6,6 +6,8 @@ import (
|
||||
"math/rand"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/kami/maven/internal/dialogue"
|
||||
"github.com/kami/maven/internal/router"
|
||||
@@ -65,8 +67,28 @@ var clarifyExpiredVariants = []string{
|
||||
"Столько времени прошло, что я отпустила прошлую просьбу. Скажи заново, если она в силе.",
|
||||
}
|
||||
|
||||
// clarifyExpiredLine picks one of them at random.
|
||||
func clarifyExpiredLine() string {
|
||||
// clarifyExpiredPluralVariants — the same notice when TWO parked requests died
|
||||
// together (Vikunja #561). Since a side query suspends the flow instead of
|
||||
// dropping it, the stack can hold both the flow and the thing he interrupted it
|
||||
// with, and TakeExpired drops the whole stack when the top times out. "Прошлую
|
||||
// просьбу" would then be a lie about the count: he loses two and hears about
|
||||
// one.
|
||||
//
|
||||
// Two phrasings only, against five for the singular. This fires when he walks
|
||||
// off in the middle of an interrupted exchange, which is rarer than walking off
|
||||
// in the middle of a plain one, so it repeats less and needs less variety.
|
||||
var clarifyExpiredPluralVariants = []string{
|
||||
"Прости, я слишком долго ждала и отпустила обе прошлые просьбы. Если они ещё нужны, скажи заново.",
|
||||
"Я не дождалась ответа и убрала обе прошлые просьбы. Повтори, если они всё ещё нужны.",
|
||||
}
|
||||
|
||||
// clarifyExpiredLine picks one of them at random. n is how many requests died;
|
||||
// anything above one gets the plural wording, because the bound is two today and
|
||||
// a third would still be "обе" short of the truth only if MaxStackDepth grew.
|
||||
func clarifyExpiredLine(n int) string {
|
||||
if n > 1 {
|
||||
return clarifyExpiredPluralVariants[rand.Intn(len(clarifyExpiredPluralVariants))]
|
||||
}
|
||||
return clarifyExpiredVariants[rand.Intn(len(clarifyExpiredVariants))]
|
||||
}
|
||||
|
||||
@@ -74,23 +96,34 @@ func clarifyExpiredLine() string {
|
||||
// notice is glued in front of this turn's reply (see withNotice), so a caller
|
||||
// checking for it has to match a prefix, not the whole string.
|
||||
func isClarifyExpired(s string) bool {
|
||||
for _, v := range clarifyExpiredVariants {
|
||||
if strings.HasPrefix(s, v) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
_, ok := cutClarifyExpired(s)
|
||||
return ok
|
||||
}
|
||||
|
||||
// trimClarifyExpired strips a leading expiry notice, leaving this turn's actual
|
||||
// reply. "" ⇒ the notice was the whole thing.
|
||||
func trimClarifyExpired(s string) string {
|
||||
for _, v := range clarifyExpiredVariants {
|
||||
if strings.HasPrefix(s, v) {
|
||||
return strings.TrimSpace(strings.TrimPrefix(s, v))
|
||||
rest, ok := cutClarifyExpired(s)
|
||||
if !ok {
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
return rest
|
||||
}
|
||||
|
||||
// cutClarifyExpired matches either expiry deck as a prefix and returns what
|
||||
// follows it. Both decks, since V-561 added the plural line: a caller asking
|
||||
// "did she say a request timed out" means the fact, not which wording carried
|
||||
// it, and a helper that knew only the singular would read the two-request
|
||||
// notice as ordinary reply text.
|
||||
func cutClarifyExpired(s string) (string, bool) {
|
||||
for _, deck := range [][]string{clarifyExpiredVariants, clarifyExpiredPluralVariants} {
|
||||
for _, v := range deck {
|
||||
if strings.HasPrefix(s, v) {
|
||||
return strings.TrimSpace(strings.TrimPrefix(s, v)), true
|
||||
}
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(s)
|
||||
return "", false
|
||||
}
|
||||
|
||||
// clarifyExpiredNotice returns that line when a parked question had just timed
|
||||
@@ -101,11 +134,12 @@ func (h *reactiveHandler) clarifyExpiredNotice(ctx context.Context) string {
|
||||
if h.clarifyStore == nil {
|
||||
return ""
|
||||
}
|
||||
if !h.clarifyStore.TakeExpired(dialogueIDOf(ctx), h.now()) {
|
||||
n := h.clarifyStore.TakeExpired(dialogueIDOf(ctx), h.now())
|
||||
if n == 0 {
|
||||
return ""
|
||||
}
|
||||
log.Printf("voice: clarify — parked question expired, telling him and routing the words fresh")
|
||||
return clarifyExpiredLine()
|
||||
log.Printf("voice: clarify — %d parked question(s) expired, telling him and routing the words fresh", n)
|
||||
return clarifyExpiredLine(n)
|
||||
}
|
||||
|
||||
// withNotice glues the expiry notice in front of this turn's reply. One turn
|
||||
@@ -121,10 +155,52 @@ func withNotice(notice, reply string) string {
|
||||
return notice + " " + reply
|
||||
}
|
||||
|
||||
// withResumed puts the resumed question on the END of this turn's reply, where
|
||||
// withNotice puts the expiry notice on the front (Vikunja #561).
|
||||
//
|
||||
// The order is the owner's: "в Риме сейчас ..., на какое время поставить
|
||||
// напоминание?" — answer first, then the open question. A question in front of
|
||||
// its own answer would read as ignoring what he asked.
|
||||
//
|
||||
// A statement's full stop is folded into a comma, so the two acts read as one
|
||||
// sentence — that is the owner's own punctuation, "в Риме сейчас ..., на какое
|
||||
// время поставить напоминание?". An answer that is ITSELF a question keeps its
|
||||
// mark and the resume starts a new sentence: she sometimes answers a side query
|
||||
// by asking him to say it again, and "переформулировать?, на какое время" folds
|
||||
// two questions into one unreadable line.
|
||||
//
|
||||
// A resume with no answer in front of it is just the question.
|
||||
func withResumed(reply, resumed string) string {
|
||||
if resumed == "" {
|
||||
return reply
|
||||
}
|
||||
reply = strings.TrimSpace(reply)
|
||||
if reply == "" {
|
||||
return resumed
|
||||
}
|
||||
if strings.HasSuffix(reply, "?") {
|
||||
return reply + " " + resumed
|
||||
}
|
||||
if trimmed := strings.TrimRight(reply, ".!"); trimmed != "" {
|
||||
reply = trimmed
|
||||
}
|
||||
return reply + ", " + lowerFirst(resumed)
|
||||
}
|
||||
|
||||
// lowerFirst lowercases the opening rune, so a deck line written as a standalone
|
||||
// sentence reads as the second half of one. Only the first rune: "На какое
|
||||
// время" must become "на какое время" and nothing else in it may move.
|
||||
func lowerFirst(s string) string {
|
||||
for i, r := range s {
|
||||
return string(unicode.ToLower(r)) + s[i+utf8.RuneLen(r):]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// missingFor returns the slots a decision still needs, most important first.
|
||||
// Empty ⇒ there is nothing identifiable to ask about.
|
||||
func missingFor(dec router.Decision) []dialogue.Slot {
|
||||
return dialogue.StillMissing(wantedSlots[dec.Intent], toDialogueSlots(dec.Slots))
|
||||
return stillMissingFor(dec.Intent, dec.Utterance, toDialogueSlots(dec.Slots))
|
||||
}
|
||||
|
||||
// clarifyQuestion picks the one question to ask for a clarify decision. Returns
|
||||
@@ -133,18 +209,32 @@ func missingFor(dec router.Decision) []dialogue.Slot {
|
||||
// One question about one thing: if two slots are missing she asks about the
|
||||
// first only. Two questions in one breath is an interrogation. The second gap
|
||||
// is picked up on the turn after the first one is answered (askRemainingGap).
|
||||
func clarifyQuestion(dec router.Decision) (dialogue.Slot, string, bool) {
|
||||
func (h *reactiveHandler) clarifyQuestion(dec router.Decision) (dialogue.Slot, string, bool) {
|
||||
missing := missingFor(dec)
|
||||
if len(missing) == 0 {
|
||||
return "", "", false
|
||||
}
|
||||
q, ok := clarifyQuestionFor(missing[0], 1)
|
||||
q, ok := h.questionFor(missing[0], 1, dec.Utterance, toDialogueSlots(dec.Slots), "")
|
||||
if !ok {
|
||||
return "", "", false
|
||||
}
|
||||
return missing[0], q, true
|
||||
}
|
||||
|
||||
// questionFor picks the wording for one gap. Every slot but the reminder's time
|
||||
// reads its deck by attempt; the time asks about whichever of the hour, the half
|
||||
// of the day and the day he has not said, and states the clock while it does
|
||||
// (V-579).
|
||||
//
|
||||
// taken is the acknowledgement of what his last turn added, empty when it added
|
||||
// nothing and empty for a first ask, which has no turn behind it (V-593).
|
||||
func (h *reactiveHandler) questionFor(slot dialogue.Slot, attempt int, utterance string, s dialogue.Slots, taken string) (string, bool) {
|
||||
if slot != dialogue.SlotTime {
|
||||
return clarifyQuestionFor(slot, attempt)
|
||||
}
|
||||
return whenQuestion(whenGapOf(utterance, s.HasTime), attempt, h.now(), taken)
|
||||
}
|
||||
|
||||
// askClarify parks the request and returns the question to ask instead of the
|
||||
// canned "не поняла". Returns ("", false) when there is nothing to ask about, so
|
||||
// the caller falls back to the canned reply.
|
||||
@@ -152,11 +242,16 @@ func (h *reactiveHandler) askClarify(ctx context.Context, dec router.Decision) (
|
||||
if h.clarifyStore == nil {
|
||||
return "", false
|
||||
}
|
||||
slot, question, ok := clarifyQuestion(dec)
|
||||
slot, question, ok := h.clarifyQuestion(dec)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
h.clarifyStore.Put(dialogueIDOf(ctx), &dialogue.PendingQuestion{
|
||||
// An act she could not resolve is a refusal, not a question (Vikunja #556).
|
||||
if slot == dialogue.SlotFn {
|
||||
log.Printf("voice: clarify — act %q matched no capability; saying so instead of asking", dec.Utterance)
|
||||
return actNotRecognized, true
|
||||
}
|
||||
q := &dialogue.PendingQuestion{
|
||||
Intent: dialogue.Intent(dec.Intent),
|
||||
Slots: toDialogueSlots(dec.Slots),
|
||||
Missing: []dialogue.Slot{slot},
|
||||
@@ -165,70 +260,162 @@ func (h *reactiveHandler) askClarify(ctx context.Context, dec router.Decision) (
|
||||
TTL: clarifyTTL,
|
||||
Attempts: 1, // this ask
|
||||
MaxAttempts: h.clarifyMaxAttempts,
|
||||
})
|
||||
}
|
||||
// Push, not Put, when this turn suspended a flow (Vikunja #561): the side
|
||||
// query needs clarifying of ITS own, and Put would replace the top of the
|
||||
// stack — which is the very question the side query was allowed to interrupt
|
||||
// rather than kill. Push keeps both.
|
||||
//
|
||||
// Push returns whatever the depth bound forced out, and that one has to be
|
||||
// spoken: MaxStackDepth is a promise that every level she keeps is a level
|
||||
// she can name when it dies. It is glued in front, like every other notice
|
||||
// about something let go.
|
||||
rt := turnRouteFrom(ctx)
|
||||
if rt != nil && rt.suspended {
|
||||
if evicted := h.clarifyStore.Push(dialogueIDOf(ctx), q); evicted != nil {
|
||||
log.Printf("voice: clarify — stack full at %d, letting go of the request behind %q", dialogue.MaxStackDepth, evicted.Utterance)
|
||||
rt.dropped = withNotice(rt.dropped, clarifyDropped)
|
||||
}
|
||||
// One question per breath still holds. The side query turned out to need
|
||||
// a question of its own, so THAT is the one she asks; resuming as well
|
||||
// would put two questions in one reply, which is the interrogation
|
||||
// askRemainingGap already refuses to run. The suspended flow keeps its
|
||||
// place underneath and is not lost — if it is never reached it dies on
|
||||
// the TTL, and the expiry notice (now plural-aware) says so.
|
||||
rt.resume = ""
|
||||
log.Printf("voice: clarify — asked about %s for intent=%s, stacked on a suspended flow", slot, dec.Intent)
|
||||
return question, true
|
||||
}
|
||||
h.clarifyStore.Put(dialogueIDOf(ctx), q)
|
||||
log.Printf("voice: clarify — asked about %s for intent=%s", slot, dec.Intent)
|
||||
return question, true
|
||||
}
|
||||
|
||||
// resolveClarifyAnswer reads an utterance as the answer to a parked question.
|
||||
// Returns ("", false) when no live question is parked (or it expired), so the
|
||||
// caller routes the utterance normally as a fresh request. Sibling of
|
||||
// resolveConfirm and checked in the same place.
|
||||
//
|
||||
// The answer is parsed with the same extractor the router uses, for the intent
|
||||
// she parked — no second parser. If it still does not fill the gap she asks
|
||||
// again, up to MaxAttempts; after that she says out loud that she did not
|
||||
// understand. She never drops the request in silence.
|
||||
// isOwnRequest reports whether an utterance asks for something in its own
|
||||
// right, which is what a clarify answer never does. Two offline tests over
|
||||
// tokens, both already written for other callers: a question shape, and a
|
||||
// capture verb. Cheap on purpose — this runs on the answer to every parked
|
||||
// question, and it must not cost a model call.
|
||||
//
|
||||
// It is not a general relevance test. A bare noun that answers nothing ("синий"
|
||||
// after "Что сделать?") is still treated as an answer and still re-asked, and
|
||||
// that is the intended shape: only an utterance that carries its own request
|
||||
// wins over the question in front of it.
|
||||
func isOwnRequest(text string) bool {
|
||||
return router.IsQuestionShaped(text) || router.CarriesCaptureVerb(text)
|
||||
}
|
||||
// clarifyCancelled — he called the half-built request off. Said out loud, like
|
||||
// every other way it can end: a silent drop reads as "done". Feminine
|
||||
// self-reference ("отменила"), as everywhere.
|
||||
const clarifyCancelled = "Хорошо, отменила."
|
||||
|
||||
// clarifyDropped — he asked for something ELSE instead, so the parked request
|
||||
// is gone. Glued in front of the answer to what he actually asked, because
|
||||
// nothing may be dropped in silence.
|
||||
//
|
||||
// Only new_request and cancel reach this since V-561. A side query used to as
|
||||
// well, and the owner rejected it on sight: he asks about the weather in the
|
||||
// middle of setting a reminder, and hearing "прошлую просьбу отпускаю" tells him
|
||||
// a thing he did not ask to lose has been lost. It had not been — there was
|
||||
// simply nowhere to put it. Now there is (ClarifyStore's stack), so a side query
|
||||
// suspends and resumes, and apologising for a drop that did not happen is worse
|
||||
// than saying nothing.
|
||||
const clarifyDropped = "Прошлую просьбу отпускаю."
|
||||
|
||||
// resolveClarifyAnswer reads an utterance against the parked question and
|
||||
// decides what it IS before deciding what to do with it. Returns ("", false)
|
||||
// when the turn is not this resolver's — nothing parked, or the utterance turned
|
||||
// out to be a request of its own — so the caller dispatches it normally.
|
||||
//
|
||||
// The order is the point (Vikunja #560). The utterance is ROUTED first, and the
|
||||
// role is read off that decision: a routed decision that stands on its own is
|
||||
// not an answer, whatever the extractor found inside it. Before this the
|
||||
// extractor decided, so "какая сейчас погода в Риме?" became the time of a
|
||||
// reminder on the strength of the word "сейчас".
|
||||
//
|
||||
// The answer itself is parsed with the same extractor the router uses, for the
|
||||
// intent she parked — no second parser. If it still does not fill the gap she
|
||||
// asks again, up to MaxAttempts; after that she says out loud that she did not
|
||||
// understand. She never drops the request in silence.
|
||||
func (h *reactiveHandler) resolveClarifyAnswer(ctx context.Context, text string) (string, bool) {
|
||||
if h.clarifyStore == nil {
|
||||
return "", false
|
||||
}
|
||||
q := h.clarifyStore.Get(dialogueIDOf(ctx), h.now())
|
||||
if q == nil {
|
||||
return "", false
|
||||
return "", false // not_applicable: nothing is pending
|
||||
}
|
||||
|
||||
intent := router.Intent(q.Intent)
|
||||
answer := h.extractor.Extract(ctx, intent, text, h.now())
|
||||
merged := q.Answer(text, toDialogueSlots(answer))
|
||||
// He moved on. A parked question used to swallow whatever came next, so one
|
||||
// act she could not fulfil ate the following three turns: "выключи свет в
|
||||
// спальне" asked "Что сделать?", and "кто изобрёл телефон" was scored as an
|
||||
// answer to it, then "как дела" after that (Vikunja #554). Nothing checked
|
||||
// whether the words could be an answer at all.
|
||||
//
|
||||
// Deliberately narrow. It only fires where the answer filled nothing, so a
|
||||
// turn that closes the gap is still an answer whatever shape it has, and
|
||||
// the retry budget is untouched — the count was never the problem. Dropping
|
||||
// the question and routing the utterance as itself is what he meant either
|
||||
// way: if he really was answering, he can say it again, and if he was not,
|
||||
// he gets the thing he asked for instead of being asked a third time.
|
||||
if len(dialogue.StillMissing(q.Missing, merged)) > 0 && isOwnRequest(text) {
|
||||
|
||||
var (
|
||||
routed router.Decision
|
||||
routedOK bool
|
||||
)
|
||||
if needsRoute(text) {
|
||||
routed, routedOK = h.routeForRole(ctx, text)
|
||||
}
|
||||
role := classifyTurnRole(q, text, toDialogueSlots(answer), routed, routedOK)
|
||||
log.Printf("voice: clarify — %q is a %s against %s (routed=%v)", text, role, dialogue.CapabilityFor(q.Intent), routedOK)
|
||||
|
||||
switch role {
|
||||
case roleCancel:
|
||||
h.clarifyStore.Delete(dialogueIDOf(ctx))
|
||||
log.Printf("voice: clarify — %q is its own request, not an answer to %v; dropping the question", text, q.Missing)
|
||||
return clarifyCancelled, true
|
||||
case roleSideQuery:
|
||||
// He asked something of his own WITHOUT leaving the flow. The question
|
||||
// stays exactly where it is — same slot, same attempt, same parked
|
||||
// utterance — and these words go on to be answered as themselves. The
|
||||
// resumed question is then glued onto the back of that answer, so one
|
||||
// reply carries both acts (Vikunja #561).
|
||||
//
|
||||
// No attempt is spent. He answered the side query, not the parked
|
||||
// question, and charging a retry for a turn that was never an answer is
|
||||
// the V-554 shape.
|
||||
h.noteSuspended(ctx, q)
|
||||
return "", false
|
||||
case roleAside:
|
||||
// He stated something in the middle of the flow. Same machinery as a
|
||||
// side query and for the same reason: the words are answered as
|
||||
// themselves, so the note or the fact is stored, and the question comes
|
||||
// back on the end of the same reply (V-577 shape 2). Storing it in
|
||||
// silence and dropping it in silence are both wrong, and dropping it is
|
||||
// what she did.
|
||||
h.noteSuspended(ctx, q)
|
||||
return "", false
|
||||
case roleNewRequest:
|
||||
// He moved on. A parked question used to swallow whatever came next, so
|
||||
// one act she could not fulfil ate the following three turns (Vikunja
|
||||
// #554) and a world question set a reminder for a time nobody asked for
|
||||
// (#558). Drop the question, say so, and let these words be themselves.
|
||||
h.clarifyStore.Delete(dialogueIDOf(ctx))
|
||||
h.noteDropped(ctx)
|
||||
return "", false
|
||||
}
|
||||
|
||||
merged := q.Answer(text, toDialogueSlots(answer))
|
||||
// Fold a newly answered subject into the raw utterance. Downstream actions
|
||||
// phrase from Utterance, not from the text slot — actionReminder stores it
|
||||
// as the reminder payload — so a reminder clarified out of a bare "напомни"
|
||||
// would fire at 11:00 saying "напомни" and nothing else.
|
||||
q.Utterance = foldAnswerIntoUtterance(q.Utterance, merged.Text)
|
||||
if len(dialogue.StillMissing(q.Missing, merged)) > 0 {
|
||||
return h.reaskOrGiveUp(ctx, q, merged, text), true
|
||||
// A fact answers with a key and a value and fills no Text slot at all, so
|
||||
// the fold above leaves the utterance at the bare "запиши" — and that is
|
||||
// what the confirmation now reads back to him (V-592). His raw words are the
|
||||
// only record of what he said, so they are what is folded. Never for a time
|
||||
// question: what he says about when is kept apart in WhenText on purpose,
|
||||
// or the reminder would read the day back at him when it fires.
|
||||
if merged.Text == "" && !asksAboutTime(q.Missing) {
|
||||
q.Utterance = foldAnswerIntoUtterance(q.Utterance, text)
|
||||
}
|
||||
// An answer about the time joins everything else he has said about the time,
|
||||
// and the whole of it is re-read as one request (V-579). "завтра" names the
|
||||
// day of an hour she is already holding, and read alone it names no hour at
|
||||
// all, so the parser would have nothing and she would ask for ever.
|
||||
// What he had already said about the time, read BEFORE this answer joins it.
|
||||
// A re-ask that cannot tell the two apart is the one that repeats itself
|
||||
// byte for byte (V-593).
|
||||
var taken string
|
||||
if asksAboutTime(q.Missing) {
|
||||
before := whenKnownOf(whenTextOf(q), q.Slots.HasTime)
|
||||
q.WhenText = strings.TrimSpace(q.WhenText + " " + text)
|
||||
if t, ok := h.readWhen(ctx, intent, q, text); ok {
|
||||
merged.Time, merged.HasTime = t, true
|
||||
}
|
||||
if before.movedForward(whenKnownOf(whenTextOf(q), merged.HasTime)) {
|
||||
taken = whenTakenLine(text)
|
||||
}
|
||||
}
|
||||
if stillOpen(q.Missing, whenTextOf(q), merged) {
|
||||
return h.reaskOrGiveUp(ctx, q, merged, text, taken), true
|
||||
}
|
||||
h.clarifyStore.Delete(dialogueIDOf(ctx))
|
||||
|
||||
@@ -257,6 +444,45 @@ func (h *reactiveHandler) resolveClarifyAnswer(ctx context.Context, text string)
|
||||
return h.finishClarified(ctx, dec), true
|
||||
}
|
||||
|
||||
// noteDropped records that the parked request was let go this turn, so runTurn
|
||||
// can say it in front of whatever these words are answered with. Nothing to
|
||||
// record outside runTurn — a unit test calling one resolver has no turn to glue
|
||||
// a notice onto.
|
||||
func (h *reactiveHandler) noteDropped(ctx context.Context) {
|
||||
if rt := turnRouteFrom(ctx); rt != nil {
|
||||
rt.dropped = clarifyDropped
|
||||
}
|
||||
}
|
||||
|
||||
// noteSuspended keeps the parked question alive across a side query and records
|
||||
// the words that bring it back, so runTurn can put them after this turn's answer
|
||||
// (Vikunja #561).
|
||||
//
|
||||
// Two things happen to the question and neither is an attempt. Its clock is
|
||||
// restarted, because she is about to ask it again and the 90s TTL measures the
|
||||
// pause since she last spoke it — leaving Asked at the original ask would let a
|
||||
// flow he is actively working through die of a wait he did not take. And the
|
||||
// stack is left exactly as it is: the question is already on top, so suspending
|
||||
// it is not a write.
|
||||
//
|
||||
// A slot with no resumed wording (clarifyResumedFor says so) resumes nothing and
|
||||
// says nothing. She must not claim to be holding a question she cannot re-ask.
|
||||
func (h *reactiveHandler) noteSuspended(ctx context.Context, q *dialogue.PendingQuestion) {
|
||||
rt := turnRouteFrom(ctx)
|
||||
if rt == nil || len(q.Missing) == 0 {
|
||||
return
|
||||
}
|
||||
question, ok := clarifyResumedFor(q.Missing[0])
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
q.Asked = h.now()
|
||||
h.clarifyStore.Put(dialogueIDOf(ctx), q)
|
||||
rt.resume = question
|
||||
rt.suspended = true
|
||||
log.Printf("voice: clarify — is its own request; suspending the question about %s and resuming it in the same reply", q.Missing[0])
|
||||
}
|
||||
|
||||
// foldAnswerIntoUtterance appends an answered subject to the original words,
|
||||
// unless they already carry it. "напомни" + "позвонить маме" reads as the
|
||||
// request he would have made in one breath. Nothing is appended when the
|
||||
@@ -284,13 +510,13 @@ func foldAnswerIntoUtterance(utterance, subject string) string {
|
||||
// costs a question exactly like a second try at the first one does, so the cap
|
||||
// still bounds how many times she can speak before acting or letting go.
|
||||
func (h *reactiveHandler) askRemainingGap(ctx context.Context, q *dialogue.PendingQuestion, intent router.Intent, merged dialogue.Slots) (string, bool) {
|
||||
remaining := dialogue.StillMissing(wantedSlots[intent], merged)
|
||||
remaining := stillMissingFor(intent, whenTextOf(q), merged)
|
||||
if len(remaining) == 0 {
|
||||
return "", false
|
||||
}
|
||||
// Attempts+1 is the question she is about to ask, and the budget is shared
|
||||
// with the re-ask path, so the second gap is worded like a second try.
|
||||
question, ok := clarifyQuestionFor(remaining[0], q.Attempts+1)
|
||||
question, ok := h.questionFor(remaining[0], q.Attempts+1, whenTextOf(q), merged, "")
|
||||
if !ok || !q.CanAsk() {
|
||||
return "", false
|
||||
}
|
||||
@@ -299,6 +525,7 @@ func (h *reactiveHandler) askRemainingGap(ctx context.Context, q *dialogue.Pendi
|
||||
Slots: merged,
|
||||
Missing: []dialogue.Slot{remaining[0]},
|
||||
Utterance: q.Utterance,
|
||||
WhenText: q.WhenText,
|
||||
Asked: h.now(),
|
||||
TTL: clarifyTTL,
|
||||
Attempts: q.Attempts + 1,
|
||||
@@ -311,10 +538,12 @@ func (h *reactiveHandler) askRemainingGap(ctx context.Context, q *dialogue.Pendi
|
||||
// reaskOrGiveUp handles an answer that left the gap open: ask the same question
|
||||
// again while she has attempts left, otherwise say she did not understand and
|
||||
// let the request go. Never returns "" — a mute give-up reads as "done".
|
||||
func (h *reactiveHandler) reaskOrGiveUp(ctx context.Context, q *dialogue.PendingQuestion, merged dialogue.Slots, text string) string {
|
||||
// taken is the acknowledgement of what this answer DID give, empty when it gave
|
||||
// nothing (V-593). The give-up line never carries it: it is not another ask.
|
||||
func (h *reactiveHandler) reaskOrGiveUp(ctx context.Context, q *dialogue.PendingQuestion, merged dialogue.Slots, text, taken string) string {
|
||||
question := ""
|
||||
if len(q.Missing) > 0 {
|
||||
question, _ = clarifyQuestionFor(q.Missing[0], q.Attempts+1)
|
||||
question, _ = h.questionFor(q.Missing[0], q.Attempts+1, whenTextOf(q), merged, taken)
|
||||
}
|
||||
if question == "" || !q.CanAsk() {
|
||||
h.clarifyStore.Delete(dialogueIDOf(ctx))
|
||||
@@ -351,20 +580,22 @@ func (h *reactiveHandler) finishClarified(ctx context.Context, dec router.Decisi
|
||||
return reply
|
||||
}
|
||||
|
||||
// maxCarriedHistory — how many turns of PRIOR history (beyond the immediate
|
||||
// last turn) rememberTurn carries forward. The session ends up holding this
|
||||
// many plus the one just-finished turn, so callers describing the total
|
||||
// depth (chatHistory's doc comment, this one) say "up to 4".
|
||||
const maxCarriedHistory = 3
|
||||
|
||||
// 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(ctx context.Context, prev *dialogue.Session, dec router.Decision, now time.Time) {
|
||||
var history []dialogue.Turn
|
||||
if prev != nil {
|
||||
history = append(history, dialogue.Turn{
|
||||
Intent: prev.Intent,
|
||||
Slots: prev.Slots,
|
||||
Text: prev.Slots.Text,
|
||||
})
|
||||
history = append(history, sessionAsTurn(prev))
|
||||
maxHist := len(prev.History)
|
||||
if maxHist > 3 {
|
||||
maxHist = 3
|
||||
if maxHist > maxCarriedHistory {
|
||||
maxHist = maxCarriedHistory
|
||||
}
|
||||
history = append(history, prev.History[:maxHist]...)
|
||||
}
|
||||
|
||||
+163
-43
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
"github.com/kami/maven/internal/dialogue"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/memory"
|
||||
"github.com/kami/maven/internal/phraser/eval"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/store"
|
||||
@@ -57,18 +58,26 @@ func TestClarifyQuestionForMissingSlot(t *testing.T) {
|
||||
want string
|
||||
asked bool
|
||||
}{
|
||||
{"reminder without a time", clarifyDec(router.IntentReminder, router.Slots{Text: "напомни позвонить маме"}, "напомни позвонить маме"), "Когда?", true},
|
||||
{"reminder without a time", clarifyDec(router.IntentReminder, router.Slots{Text: "напомни позвонить маме"}, "напомни позвонить маме"), "Сейчас 09:00. Когда?", true},
|
||||
{"fact without a key", clarifyDec(router.IntentFact, router.Slots{Text: "запиши"}, "запиши"), "Что записать?", true},
|
||||
{"act without a fn", clarifyDec(router.IntentAct, router.Slots{Text: "сделай это"}, "сделай это"), "Что сделать?", true},
|
||||
// A time with nothing to say at that time is still half a reminder, so
|
||||
// the subject is what she asks about — not silence.
|
||||
{"reminder that has a time but no subject", clarifyDec(router.IntentReminder, router.Slots{HasTime: true}, "напомни в 11"), "О чём напомнить?", true},
|
||||
{"reminder that has both", clarifyDec(router.IntentReminder, router.Slots{Text: "позвонить маме", HasTime: true}, "напомни в 11 позвонить маме"), "", false},
|
||||
// A bare hour is half of a day away from being an answer, and she asks
|
||||
// which half rather than picking one (V-579).
|
||||
{"reminder whose hour could be either half of the day", clarifyDec(router.IntentReminder, router.Slots{Text: "позвонить маме", HasTime: true}, "напомни в 11 позвонить маме"), "Сейчас 09:00. Это утра или вечера?", true},
|
||||
{"reminder that has all three", clarifyDec(router.IntentReminder, router.Slots{Text: "позвонить маме", HasTime: true}, "напомни завтра в 15:00 позвонить маме"), "", false},
|
||||
// The owner's own two, confirmed 2026-08-06: an unambiguous time and a
|
||||
// relative one are both complete and are never asked about.
|
||||
{"an interval names the instant by itself", clarifyDec(router.IntentReminder, router.Slots{Text: "позвонить маме", HasTime: true}, "напомни через час позвонить маме"), "", false},
|
||||
{"half an hour is an interval too", clarifyDec(router.IntentReminder, router.Slots{Text: "выключить духовку", HasTime: true}, "напомни через полчаса выключить духовку"), "", false},
|
||||
{"chat is never worth a question", clarifyDec(router.IntentChat, router.Slots{Text: "мгм"}, "мгм"), "", false},
|
||||
{"query is never worth a question", clarifyDec(router.IntentQuery, router.Slots{Text: "а"}, "а"), "", false},
|
||||
}
|
||||
h, _, _ := newClarifyHandler(t)
|
||||
for _, tc := range cases {
|
||||
_, got, asked := clarifyQuestion(tc.dec)
|
||||
_, got, asked := h.clarifyQuestion(tc.dec)
|
||||
if asked != tc.asked || got != tc.want {
|
||||
t.Errorf("%s: got (%q, %v), want (%q, %v)", tc.name, got, asked, tc.want, tc.asked)
|
||||
}
|
||||
@@ -82,11 +91,13 @@ func TestClarifyReminderCompletesOnAnswer(t *testing.T) {
|
||||
h, st, _ := newClarifyHandler(t)
|
||||
|
||||
question, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни позвонить маме"}, "напомни позвонить маме"))
|
||||
if !asked || question != "Когда?" {
|
||||
if !asked || question != "Сейчас 09:00. Когда?" {
|
||||
t.Fatalf("expected the time question, got %q asked=%v", question, asked)
|
||||
}
|
||||
|
||||
reply, handled := h.resolveClarifyAnswer(ctx, "в 11:00")
|
||||
// The answer names the day as well as the hour. A reminder commits on what,
|
||||
// what time and what day, and a dayless hour is asked about (V-579).
|
||||
reply, handled := h.resolveClarifyAnswer(ctx, "сегодня в 11:00")
|
||||
if !handled {
|
||||
t.Fatal("the answer to an open question must be consumed as an answer")
|
||||
}
|
||||
@@ -158,11 +169,11 @@ func TestClarifyAsksThreeTimesThenSaysSo(t *testing.T) {
|
||||
}
|
||||
// The wording changes with the attempt (Vikunja #457): repeating a
|
||||
// question he already failed to answer is the worst way to ask it.
|
||||
want, _ := clarifyQuestionFor(dialogue.SlotTime, i)
|
||||
want, _ := whenQuestion(whenNoHour, i, h.now(), "")
|
||||
if reply != want {
|
||||
t.Fatalf("attempt %d should ask again as %q, got %q", i, want, reply)
|
||||
}
|
||||
if first, _ := clarifyQuestionFor(dialogue.SlotTime, 1); reply == first {
|
||||
if first, _ := whenQuestion(whenNoHour, 1, h.now(), ""); reply == first {
|
||||
t.Fatalf("attempt %d repeated the first wording: %q", i, reply)
|
||||
}
|
||||
if h.clarifyStore.Get(voiceDialogueID, h.now()) == nil {
|
||||
@@ -215,10 +226,11 @@ func TestClarifyRestatedAnswerWins(t *testing.T) {
|
||||
if q == nil {
|
||||
t.Fatal("expected an armed question")
|
||||
}
|
||||
first := h.extractor.Extract(ctx, router.IntentReminder, "в 11:00", h.now())
|
||||
q.Slots = q.Answer("в 11:00", toDialogueSlots(first))
|
||||
first := h.extractor.Extract(ctx, router.IntentReminder, "сегодня в 11:00", h.now())
|
||||
q.Slots = q.Answer("сегодня в 11:00", toDialogueSlots(first))
|
||||
q.WhenText = "сегодня в 11:00"
|
||||
|
||||
if reply, handled := h.resolveClarifyAnswer(ctx, "нет, в 15:00"); !handled || reply == clarifyGaveUp {
|
||||
if reply, handled := h.resolveClarifyAnswer(ctx, "нет, сегодня в 15:00"); !handled || reply == clarifyGaveUp {
|
||||
t.Fatalf("the restated answer should complete the request, handled=%v reply=%q", handled, reply)
|
||||
}
|
||||
reminders, err := st.DueReminders(ctx, h.now().Add(48*time.Hour))
|
||||
@@ -231,34 +243,35 @@ func TestClarifyRestatedAnswerWins(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestClarifiedActOffAllowlistIsStillRefused — clarification fills in an
|
||||
// argument, it never grants authority.
|
||||
func TestClarifiedActOffAllowlistIsStillRefused(t *testing.T) {
|
||||
// TestActOffAllowlistIsStillRefused — naming a capability is not being granted
|
||||
// one. Since Vikunja #556 an unresolved act no longer parks a question, so this
|
||||
// goes through applyAction, which is the only way an act runs.
|
||||
func TestActOffAllowlistIsStillRefused(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
h, st, _ := newClarifyHandler(t)
|
||||
marker := filepath.Join(t.TempDir(), "not-allowed-ran")
|
||||
if err := st.EnableTool(ctx, "uptime", []string{"true"}, false, "test", h.now()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentAct, router.Slots{Text: "сделай это"}, "сделай это")); !asked {
|
||||
t.Fatal("an act with no fn should be asked about")
|
||||
}
|
||||
reply, handled := h.resolveClarifyAnswer(ctx, "rm "+marker)
|
||||
if !handled {
|
||||
t.Fatal("the answer should be consumed")
|
||||
}
|
||||
reply := h.applyAction(ctx, router.Decision{
|
||||
Utterance: "rm " + marker,
|
||||
Intent: router.IntentAct,
|
||||
Slots: router.Slots{Fn: "rm " + marker, HasFn: true},
|
||||
})
|
||||
if strings.Contains(reply, "готово") {
|
||||
t.Fatalf("an act that is not on the allowlist must not report success: %q", reply)
|
||||
}
|
||||
if _, err := os.Stat(marker); !os.IsNotExist(err) {
|
||||
t.Fatalf("a clarified act off the allowlist ran anyway: %v", err)
|
||||
}
|
||||
if tools, err := st.ListTools(ctx, "enabled"); err != nil || len(tools) != 0 {
|
||||
t.Fatalf("clarify must not enable a tool: tools=%+v err=%v", tools, err)
|
||||
if tools, err := st.ListTools(ctx, "enabled"); err != nil || len(tools) != 1 {
|
||||
t.Fatalf("an act must not enable a tool: tools=%+v err=%v", tools, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClarifiedDestructiveActStillNeedsConfirm — the confirm gate survives the
|
||||
// clarify path.
|
||||
func TestClarifiedDestructiveActStillNeedsConfirm(t *testing.T) {
|
||||
// TestDestructiveActStillNeedsConfirm — the confirm gate stands on the act path.
|
||||
func TestDestructiveActStillNeedsConfirm(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
h, st, _ := newClarifyHandler(t)
|
||||
marker := filepath.Join(t.TempDir(), "destructive-ran")
|
||||
@@ -266,18 +279,16 @@ func TestClarifiedDestructiveActStillNeedsConfirm(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentAct, router.Slots{Text: "сделай это"}, "сделай это")); !asked {
|
||||
t.Fatal("expected a question")
|
||||
}
|
||||
reply, handled := h.resolveClarifyAnswer(ctx, "delete_backups")
|
||||
if !handled {
|
||||
t.Fatal("the answer should be consumed")
|
||||
}
|
||||
reply := h.applyAction(ctx, router.Decision{
|
||||
Utterance: "delete_backups",
|
||||
Intent: router.IntentAct,
|
||||
Slots: router.Slots{Fn: "delete_backups", HasFn: true},
|
||||
})
|
||||
if !strings.Contains(reply, "да") || h.pending == nil {
|
||||
t.Fatalf("a clarified destructive act must still park a confirm: reply=%q pending=%+v", reply, h.pending)
|
||||
t.Fatalf("a destructive act must park a confirm: reply=%q pending=%+v", reply, h.pending)
|
||||
}
|
||||
if _, err := os.Stat(marker); !os.IsNotExist(err) {
|
||||
t.Fatalf("a clarified destructive act ran before confirmation: %v", err)
|
||||
t.Fatalf("a destructive act ran before confirmation: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -357,7 +368,7 @@ func TestClarifyAsksAboutTheSecondGapToo(t *testing.T) {
|
||||
}
|
||||
// Second gap, second attempt, so it is the second wording of the time
|
||||
// question — the attempt budget is shared between the two paths.
|
||||
want, _ := clarifyQuestionFor(dialogue.SlotTime, 2)
|
||||
want, _ := whenQuestion(whenNoHour, 2, h.now(), "")
|
||||
if reply != want {
|
||||
t.Fatalf("a filled subject with no time must ask about the time as %q, got %q", want, reply)
|
||||
}
|
||||
@@ -369,7 +380,7 @@ func TestClarifyAsksAboutTheSecondGapToo(t *testing.T) {
|
||||
t.Fatalf("the re-parked question lost the answered subject: %+v", q.Slots)
|
||||
}
|
||||
|
||||
if reply, handled := h.resolveClarifyAnswer(ctx, "в 11:00"); !handled || reply == clarifyGaveUp {
|
||||
if reply, handled := h.resolveClarifyAnswer(ctx, "сегодня в 11:00"); !handled || reply == clarifyGaveUp {
|
||||
t.Fatalf("the time answer must complete the reminder, handled=%v reply=%q", handled, reply)
|
||||
}
|
||||
reminders, err := st.DueReminders(ctx, h.now().Add(48*time.Hour))
|
||||
@@ -419,7 +430,7 @@ func TestClarifyProseHoldsThePersona(t *testing.T) {
|
||||
eval.CheckAddress: true,
|
||||
eval.CheckCringe: true,
|
||||
}
|
||||
lines := append([]string{clarifyGaveUp}, clarifyExpiredVariants...)
|
||||
lines := append([]string{clarifyGaveUp, clarifyCancelled, clarifyDropped}, clarifyExpiredVariants...)
|
||||
lines = append(lines, clarifyMissedVariants...)
|
||||
for _, variants := range clarifyQuestionVariants {
|
||||
lines = append(lines, variants...)
|
||||
@@ -487,7 +498,7 @@ func TestClarifySubjectAnswerFillsRatherThanClobbers(t *testing.T) {
|
||||
at := h.now().Add(2 * time.Hour)
|
||||
|
||||
question, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder,
|
||||
router.Slots{Time: at, HasTime: true}, "напомни в 11"))
|
||||
router.Slots{Time: at, HasTime: true}, "напомни сегодня в 11 утра"))
|
||||
if !asked || question != "О чём напомнить?" {
|
||||
t.Fatalf("expected the subject question, got %q asked=%v", question, asked)
|
||||
}
|
||||
@@ -573,14 +584,24 @@ func TestClarifyStepsAsideForItsOwnRequest(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
h, _, _ := newClarifyHandler(t)
|
||||
|
||||
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentAct, router.Slots{Text: "выключи свет в спальне"}, "выключи свет в спальне")); !asked {
|
||||
t.Fatal("an act with no fn should be asked about")
|
||||
// A reminder, not the act this bug was found on: since Vikunja #556 an act
|
||||
// no longer parks anything, so it can no longer eat the turn after it.
|
||||
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни позвонить маме"}, "напомни позвонить маме")); !asked {
|
||||
t.Fatal("a reminder with no time should be asked about")
|
||||
}
|
||||
if reply, handled := h.resolveClarifyAnswer(ctx, "кто изобрёл телефон"); handled {
|
||||
t.Fatalf("a world question must route as itself, got %q", reply)
|
||||
}
|
||||
if h.clarifyStore.Get(voiceDialogueID, h.now()) != nil {
|
||||
t.Error("the parked question must be dropped, not left to eat the turn after this one")
|
||||
// Not eating the turn is `handled == false` above, and that is the whole of
|
||||
// #554. Since V-561 the question also SURVIVES it: a side query suspends the
|
||||
// flow rather than ending it, so the reminder is still there and still on the
|
||||
// attempt it was parked with.
|
||||
q := h.clarifyStore.Get(voiceDialogueID, h.now())
|
||||
if q == nil {
|
||||
t.Fatal("a side query must suspend the parked question, not drop it")
|
||||
}
|
||||
if q.Attempts != 1 {
|
||||
t.Errorf("a turn that was never an answer spent an attempt: %d, want 1", q.Attempts)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -613,10 +634,109 @@ func TestClarifyQuestionShapedAnswerThatFillsTheGapStillLands(t *testing.T) {
|
||||
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни позвонить маме"}, "напомни позвонить маме")); !asked {
|
||||
t.Fatal("expected the time question")
|
||||
}
|
||||
if reply, handled := h.resolveClarifyAnswer(ctx, "а что если в 11:00"); !handled || reply == clarifyGaveUp {
|
||||
if reply, handled := h.resolveClarifyAnswer(ctx, "а что если сегодня в 11:00"); !handled || reply == clarifyGaveUp {
|
||||
t.Fatalf("an answer that fills the gap must land, handled=%v reply=%q", handled, reply)
|
||||
}
|
||||
if reminders, err := st.DueReminders(ctx, h.now().Add(48*time.Hour)); err != nil || len(reminders) != 1 {
|
||||
t.Fatalf("reminder was not created: reminders=%v err=%v", reminders, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUnresolvedActSaysItDoesNotKnowTheCommand — Vikunja #556. "Что сделать?"
|
||||
// has no answer he can give, so an act that matched no capability is refused in
|
||||
// one line and nothing is parked. It does not recite what she can do instead.
|
||||
func TestUnresolvedActSaysItDoesNotKnowTheCommand(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
h, st, _ := newClarifyHandler(t)
|
||||
// Enabled tools change nothing here: this act matched none of them.
|
||||
if err := st.EnableTool(ctx, "uptime", []string{"true"}, false, "test", h.now()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
reply, spoken := h.askClarify(ctx, clarifyDec(router.IntentAct, router.Slots{Text: "выключи свет"}, "выключи свет"))
|
||||
if !spoken || reply != actNotRecognized {
|
||||
t.Fatalf("reply = %q spoken=%v, want %q", reply, spoken, actNotRecognized)
|
||||
}
|
||||
if strings.Contains(reply, "uptime") {
|
||||
t.Errorf("reply = %q, want no list of capabilities he did not ask about", reply)
|
||||
}
|
||||
if h.clarifyStore.Get(voiceDialogueID, h.now()) != nil {
|
||||
t.Error("nothing to ask about, so nothing may be parked")
|
||||
}
|
||||
}
|
||||
|
||||
// newRoutingClarifyHandler wires the real cascade (hash embedder, no model) onto
|
||||
// the clarify handler, so a test can drive handleText end to end and see which
|
||||
// gate claimed the turn.
|
||||
func newRoutingClarifyHandler(t *testing.T) (*reactiveHandler, *store.Store) {
|
||||
t.Helper()
|
||||
h, st, _ := newClarifyHandler(t)
|
||||
h.router = buildRouter(router.NewHashEmbedder(1024), h.matcher, 0.55, nil)
|
||||
h.recall = recallWiring{embedder: router.NewHashEmbedder(1024), memStore: memory.NewInMemoryStore()}
|
||||
return h, st
|
||||
}
|
||||
|
||||
// TestIncompleteReminderAsksInsteadOfFailing — Vikunja #557. "напомни позвонить"
|
||||
// is routed confidently and is still half a request. It used to reach applyAction,
|
||||
// fail on the missing time and park nothing, so the "в семь вечера" that followed
|
||||
// was routed as a world question and web-searched.
|
||||
func TestIncompleteReminderAsksInsteadOfFailing(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
h, st := newRoutingClarifyHandler(t)
|
||||
|
||||
reply := h.handleText(ctx, "web", "напомни позвонить маме")
|
||||
want, _ := whenQuestion(whenNoHour, 1, h.now(), "")
|
||||
if reply != want {
|
||||
t.Fatalf("reply = %q, want the time question %q", reply, want)
|
||||
}
|
||||
if h.clarifyStore.Get(dialogueIDFor(sourceText, "web"), h.now()) == nil {
|
||||
t.Fatal("the request must be parked, or the answer has nowhere to land")
|
||||
}
|
||||
if reply := h.handleText(ctx, "web", "сегодня в семь вечера"); strings.Contains(reply, "нашла") {
|
||||
t.Fatalf("the answer to her own question must not be looked up: %q", reply)
|
||||
}
|
||||
if reminders, err := st.DueReminders(ctx, h.now().Add(48*time.Hour)); err != nil || len(reminders) != 1 {
|
||||
t.Fatalf("the answer did not complete the reminder: reminders=%v err=%v", reminders, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBareCaptureVerbAsksWhatToRecord — the other half of #557. A bare "запиши"
|
||||
// went to the resident model as chat, which agreed to a wording change nobody
|
||||
// asked for. It is a fact with no key, and that gap has a question.
|
||||
func TestBareCaptureVerbAsksWhatToRecord(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
h, _ := newRoutingClarifyHandler(t)
|
||||
|
||||
reply := h.handleText(ctx, "web", "запиши")
|
||||
want, _ := clarifyQuestionFor(dialogue.SlotKey, 1)
|
||||
if reply != want {
|
||||
t.Fatalf("reply = %q, want %q", reply, want)
|
||||
}
|
||||
if h.clarifyStore.Get(dialogueIDFor(sourceText, "web"), h.now()) == nil {
|
||||
t.Fatal("the request must be parked so the next utterance completes it")
|
||||
}
|
||||
}
|
||||
|
||||
// TestACompleteTurnStillDoesNotAsk — the gate reads a missing slot, not any
|
||||
// slot, so a request she can act on must never turn into a question. Checked on
|
||||
// the decision rather than through the cascade: what is at stake is the gate's
|
||||
// condition, and driving it through the hash embedder would measure routing.
|
||||
func TestACompleteTurnStillDoesNotAsk(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
h, _, _ := newClarifyHandler(t)
|
||||
|
||||
complete := []router.Decision{
|
||||
{Intent: router.IntentReminder, Slots: router.Slots{Text: "позвонить маме", HasTime: true}, Utterance: "напомни завтра в 11 утра позвонить маме"},
|
||||
{Intent: router.IntentFact, Slots: router.Slots{Key: "water", Value: "выпил", HasKey: true}, Utterance: "я выпил воды"},
|
||||
{Intent: router.IntentNote, Slots: router.Slots{Text: "купить хлеб"}, Utterance: "запиши купить хлеб"},
|
||||
{Intent: router.IntentQuery, Slots: router.Slots{Text: "что у меня сегодня"}, Utterance: "что у меня сегодня"},
|
||||
}
|
||||
for _, dec := range complete {
|
||||
if gaps := missingFor(dec); len(gaps) > 0 {
|
||||
t.Errorf("%q reads as incomplete: %v", dec.Utterance, gaps)
|
||||
}
|
||||
if reply, asked := h.askClarify(ctx, dec); asked {
|
||||
t.Errorf("%q was answered with a question: %q", dec.Utterance, reply)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,9 @@ var clarifyQuestionVariants = map[dialogue.Slot][]string{
|
||||
"Что именно отметить?",
|
||||
"Назови, что записать — например, «выпил воды».",
|
||||
},
|
||||
// Not spoken since Vikunja #556: askClarify answers actNotRecognized for a
|
||||
// missing capability rather than asking. Kept because clarifyQuestion still
|
||||
// reports the gap, and a re-ask deck with a hole in it is harder to read.
|
||||
dialogue.SlotFn: {
|
||||
"Что сделать?",
|
||||
"Какое действие выполнить?",
|
||||
@@ -47,6 +50,49 @@ var clarifyQuestionVariants = map[dialogue.Slot][]string{
|
||||
},
|
||||
}
|
||||
|
||||
// clarifyResumedVariants — the wording for a question coming BACK after a side
|
||||
// query took the turn away from it (Vikunja #561).
|
||||
//
|
||||
// It is not the first question again. "Когда?" works in the same breath as
|
||||
// "напомни позвонить маме", because the thing it is about was just said. After
|
||||
// a turn about the weather in Rome it does not: he has been thinking about
|
||||
// something else, and a bare "Когда?" asks him to remember what she is holding.
|
||||
// So the resumed form names the request — "напоминание", "заметка" — and the
|
||||
// first form stays short.
|
||||
//
|
||||
// One wording per slot, not a rotation and not an attempt ladder. A resume does
|
||||
// not spend an attempt (that is the point of suspending rather than re-asking),
|
||||
// so there is no attempt number to vary on, and this line is heard once per
|
||||
// interruption rather than repeatedly.
|
||||
//
|
||||
// Persona holds: infinitive, so no gender agreement, "ты" nowhere needed, no pet
|
||||
// names.
|
||||
var clarifyResumedVariants = map[dialogue.Slot]string{
|
||||
dialogue.SlotTime: "На какое время поставить напоминание?",
|
||||
dialogue.SlotText: "Так о чём напомнить?",
|
||||
dialogue.SlotKey: "Так что записать?",
|
||||
dialogue.SlotFn: "Так какое действие выполнить?",
|
||||
}
|
||||
|
||||
// clarifyResumedFor gives the resumed wording for a slot. ("", false) when the
|
||||
// slot has none, and the caller then resumes nothing rather than inventing a
|
||||
// question — a flow it cannot re-ask is one it must not claim to be holding.
|
||||
func clarifyResumedFor(slot dialogue.Slot) (string, bool) {
|
||||
q, ok := clarifyResumedVariants[slot]
|
||||
return q, ok
|
||||
}
|
||||
|
||||
// actNotRecognized is what an act she cannot run gets (Vikunja #556).
|
||||
//
|
||||
// The deck used to ask "Что сделать?" instead. That question has no answer he
|
||||
// can give: he already said what he wanted, and nothing he repeats will match a
|
||||
// capability that is not there. So she asked, failed, asked again and gave up —
|
||||
// three turns spent on one refusal. She says it once now, and parks nothing.
|
||||
//
|
||||
// It does not recite the allowlist. A list of names he did not ask about is not
|
||||
// an answer to the thing he did ask about.
|
||||
const actNotRecognized = "Такую команду я не знаю."
|
||||
|
||||
// clarifyQuestionFor picks the wording for this attempt. attempt is 1-based, as
|
||||
// PendingQuestion.Attempts counts it; anything past the list uses the last and
|
||||
// most explicit phrasing rather than wrapping round to the short one, because
|
||||
|
||||
+111
-22
@@ -3,9 +3,13 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/kami/maven/internal/lexicon"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
@@ -20,6 +24,14 @@ type pendingHexisExec struct {
|
||||
entityID string
|
||||
displayName string
|
||||
expiry time.Time
|
||||
|
||||
// correlationID — the id the proposing turn minted for this action. A
|
||||
// confirm arrives on a later turn with a context of its own, so without
|
||||
// carrying it here the execution recorded a fresh id and no causation at
|
||||
// all, and the resolve, the discovery and the thing they authorised sat in
|
||||
// the trace as unrelated calls. The contract mints one id per action, and
|
||||
// the action began when she asked.
|
||||
correlationID string
|
||||
}
|
||||
|
||||
// pendingRoutineConfirm — a proposed routine awaiting a spoken y/n to become
|
||||
@@ -57,10 +69,18 @@ func (h *reactiveHandler) park(fn string, args []string, phrase string) {
|
||||
// resolveConfirm interprets an utterance as the answer to a parked destructive
|
||||
// act OR a parked routine proposal. Returns (reply, true) when it consumed the
|
||||
// utterance as a y/n answer; ("", false) when there's nothing pending (or the
|
||||
// parked act expired), so the caller routes the utterance normally. An
|
||||
// unrecognised answer cancels the pending and routes normally — a confirm that
|
||||
// can't be answered clearly is safer abandoned than left armed.
|
||||
// parked act expired), so the caller routes the utterance normally.
|
||||
//
|
||||
// An utterance that is not clearly yes or no is not an answer at all, so it is
|
||||
// handed straight back and the pending stays parked until it expires (V-567).
|
||||
// This resolver runs before routing and holds the most dangerous trigger on the
|
||||
// box; it may only claim a turn it is certain about.
|
||||
func (h *reactiveHandler) resolveConfirm(ctx context.Context, text string) (string, bool) {
|
||||
verdict := classifyConfirm(text)
|
||||
if verdict == confirmUnknown {
|
||||
return "", false
|
||||
}
|
||||
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
@@ -68,16 +88,12 @@ func (h *reactiveHandler) resolveConfirm(ctx context.Context, text string) (stri
|
||||
if !r.claim() {
|
||||
continue
|
||||
}
|
||||
// The slot is already cleared by claim(): every branch below drops the
|
||||
// pending, including the unclear one — a confirm that can't be
|
||||
// answered clearly is safer abandoned than left armed.
|
||||
switch classifyConfirm(text) {
|
||||
// The slot is already cleared by claim().
|
||||
switch verdict {
|
||||
case confirmYes:
|
||||
return r.yes(), true
|
||||
case confirmNo:
|
||||
return r.no(), true
|
||||
default:
|
||||
return "", false
|
||||
return r.no(), true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
@@ -136,7 +152,11 @@ func (h *reactiveHandler) confirmResolvers(ctx context.Context) []confirmResolve
|
||||
return hx != nil && !h.now().After(hx.expiry)
|
||||
},
|
||||
yes: func() string {
|
||||
return h.execHexis(ctx, hx.capabilityID, hx.capName, hx.entityID, hx.displayName)
|
||||
execCtx := ctx
|
||||
if hx.correlationID != "" {
|
||||
execCtx = withCorrelationID(execCtx, hx.correlationID)
|
||||
}
|
||||
return h.execHexis(execCtx, hx.capabilityID, hx.capName, hx.entityID, hx.displayName)
|
||||
},
|
||||
no: func() string { return phraser.C(phraser.ConfirmCancelled, nil) },
|
||||
},
|
||||
@@ -194,23 +214,92 @@ const (
|
||||
confirmNo
|
||||
)
|
||||
|
||||
// classifyConfirm reads a short ru/en yes-or-no answer. Substring match on the
|
||||
// stems so inflections/fillers ("да, давай", "нет, отмени") still land.
|
||||
// confirmWords are the two closed sets, tokenized once and ordered
|
||||
// longest-first so "не надо" is read before "нет" could claim any of it.
|
||||
var (
|
||||
confirmYesPhrases = confirmPhrases(lexicon.ConfirmYes())
|
||||
confirmNoPhrases = confirmPhrases(lexicon.ConfirmNo())
|
||||
)
|
||||
|
||||
// confirmPhrases splits each lexicon member into tokens and sorts the result
|
||||
// longest-first, so a walk that tries them in order matches the longest member
|
||||
// that fits.
|
||||
func confirmPhrases(words []string) [][]string {
|
||||
out := make([][]string, 0, len(words))
|
||||
for _, w := range words {
|
||||
if toks := confirmTokens(w); len(toks) > 0 {
|
||||
out = append(out, toks)
|
||||
}
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool { return len(out[i]) > len(out[j]) })
|
||||
return out
|
||||
}
|
||||
|
||||
// confirmTokens splits an utterance into lowercase word tokens. Punctuation and
|
||||
// spacing are separators; an apostrophe is not, because "don't" is one word.
|
||||
func confirmTokens(text string) []string {
|
||||
return strings.FieldsFunc(strings.ToLower(text), func(r rune) bool {
|
||||
if r == '\'' || r == '’' {
|
||||
return false
|
||||
}
|
||||
return !unicode.IsLetter(r) && !unicode.IsDigit(r)
|
||||
})
|
||||
}
|
||||
|
||||
// classifyConfirm reads a short ru/en yes-or-no answer to a parked confirm.
|
||||
//
|
||||
// The whole utterance must consist of confirmation words and filler, matched as
|
||||
// whole tokens against the closed lexicon sets. Anything else is
|
||||
// confirmUnknown, which leaves the confirm parked and routes the turn — see
|
||||
// resolveConfirm. Both halves of that are the fix for V-567: this used to be a
|
||||
// substring test over bare stems, so "погода", "дальше", "надо" and "давление"
|
||||
// all read as "да", and "покажи" and "около" read as "ок". A parked destructive
|
||||
// act fired on a question about the weather.
|
||||
//
|
||||
// Requiring the WHOLE utterance is the second half. A leading confirm word does
|
||||
// not make a sentence an answer: "давай посмотрим погоду" opens a request, and
|
||||
// the only safe reading of a sentence that carries its own subject is that he
|
||||
// moved on. Guessing wrong here executes something; guessing wrong the other way
|
||||
// asks again.
|
||||
func classifyConfirm(text string) confirmVerdict {
|
||||
t := strings.ToLower(strings.TrimSpace(text))
|
||||
// negatives first — "не надо" contains no "да", but check no-stems before
|
||||
// yes so a leading "нет" isn't shadowed.
|
||||
for _, no := range []string{"нет", "не надо", "отмен", "стоп", "no", "cancel", "stop", "don't"} {
|
||||
if strings.Contains(t, no) {
|
||||
tokens := confirmTokens(text)
|
||||
if len(tokens) == 0 {
|
||||
return confirmUnknown
|
||||
}
|
||||
verdict := confirmUnknown
|
||||
for i := 0; i < len(tokens); {
|
||||
// Negatives first: "не надо" and "не хочу" open with a token that is
|
||||
// not itself an answer, and a yes hit must never shadow them.
|
||||
if n := matchConfirm(confirmNoPhrases, tokens[i:]); n > 0 {
|
||||
return confirmNo
|
||||
}
|
||||
if n := matchConfirm(confirmYesPhrases, tokens[i:]); n > 0 {
|
||||
verdict, i = confirmYes, i+n
|
||||
continue
|
||||
}
|
||||
if lexicon.IsFillerParticle(tokens[i]) {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
// A word that is neither an answer nor filler carries a subject of its
|
||||
// own, so this utterance is not an answer to her question.
|
||||
return confirmUnknown
|
||||
}
|
||||
for _, yes := range []string{"да", "ага", "давай", "подтвер", "конечно", "yes", "yeah", "yep", "confirm", "ок", "okay", "ok"} {
|
||||
if strings.Contains(t, yes) {
|
||||
return confirmYes
|
||||
return verdict
|
||||
}
|
||||
|
||||
// matchConfirm reports the length of the longest phrase matching at the head of
|
||||
// tokens, or 0.
|
||||
func matchConfirm(phrases [][]string, tokens []string) int {
|
||||
for _, p := range phrases {
|
||||
if len(p) > len(tokens) {
|
||||
continue
|
||||
}
|
||||
if slices.Equal(p, tokens[:len(p)]) {
|
||||
return len(p)
|
||||
}
|
||||
}
|
||||
return confirmUnknown
|
||||
return 0
|
||||
}
|
||||
|
||||
// actPhrase renders "fn arg1 arg2" for the confirm prompt.
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/tool"
|
||||
)
|
||||
|
||||
// TestClassifyConfirmRejectsSubstrings — V-567. The old matcher tested bare
|
||||
// stems with strings.Contains, so every word below answered a question she had
|
||||
// asked about something else: "погода", "дальше", "надо" and "давление" carry
|
||||
// "да"; "покажи", "около" and "окно" carry "ок". A parked destructive act fired
|
||||
// on a question about the weather.
|
||||
func TestClassifyConfirmRejectsSubstrings(t *testing.T) {
|
||||
for _, text := range []string{
|
||||
"погода",
|
||||
"какая погода",
|
||||
"что дальше",
|
||||
"надо ещё",
|
||||
"покажи заметки",
|
||||
"около окна",
|
||||
"давление",
|
||||
"давай посмотрим погоду",
|
||||
"не забудь купить хлеб",
|
||||
"окно открыто",
|
||||
"стоит ли брать зонт",
|
||||
"",
|
||||
} {
|
||||
if got := classifyConfirm(text); got != confirmUnknown {
|
||||
t.Errorf("classifyConfirm(%q) = %v, want confirmUnknown", text, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestClassifyConfirmAcceptsAnswers keeps every genuine answer the substring
|
||||
// matcher accepted, and pins the pair the fix could most easily get wrong:
|
||||
// "надо" is not an answer and "не надо" is the opposite of one.
|
||||
func TestClassifyConfirmAcceptsAnswers(t *testing.T) {
|
||||
yes := []string{"да", "Да!", "ага", "давай", "да, давай", "конечно", "подтверждаю", "ну да", "yes", "yeah", "ok", "okay", "confirm"}
|
||||
no := []string{"нет", "Нет.", "не надо", "не нужно", "не сейчас", "отмена", "отмени", "стоп", "нет, отмени", "no", "nope", "cancel", "stop", "don't"}
|
||||
|
||||
for _, text := range yes {
|
||||
if got := classifyConfirm(text); got != confirmYes {
|
||||
t.Errorf("classifyConfirm(%q) = %v, want confirmYes", text, got)
|
||||
}
|
||||
}
|
||||
for _, text := range no {
|
||||
if got := classifyConfirm(text); got != confirmNo {
|
||||
t.Errorf("classifyConfirm(%q) = %v, want confirmNo", text, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestUnrelatedTurnLeavesConfirmParked — the whole point of V-567. An utterance
|
||||
// that is not an answer must not execute the parked act, must not consume the
|
||||
// turn, and must not disarm the confirm either: the answer he has not given yet
|
||||
// is still answerable until it expires.
|
||||
func TestUnrelatedTurnLeavesConfirmParked(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st := newTestStore(t)
|
||||
api := ipc.NewStoreAPI(st)
|
||||
now := time.Date(2026, 8, 6, 9, 0, 0, 0, time.UTC)
|
||||
h := &reactiveHandler{
|
||||
api: api,
|
||||
dataStore: st,
|
||||
now: func() time.Time { return now },
|
||||
tools: tool.NewExecutor(api, time.Second),
|
||||
}
|
||||
|
||||
marker := filepath.Join(t.TempDir(), "destructive-tool-ran")
|
||||
if err := st.EnableTool(ctx, "delete_backups", []string{"touch", marker}, true, "test", h.now()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h.park("delete_backups", nil, "delete_backups")
|
||||
|
||||
if reply, handled := h.resolveConfirm(ctx, "какая погода"); handled {
|
||||
t.Fatalf("the weather question was consumed as a confirm: %q", reply)
|
||||
}
|
||||
if _, err := os.Stat(marker); !os.IsNotExist(err) {
|
||||
t.Fatalf("the parked destructive command ran on an unrelated turn: %v", err)
|
||||
}
|
||||
if h.pending == nil {
|
||||
t.Fatal("the confirm was disarmed by a turn that did not answer it")
|
||||
}
|
||||
|
||||
// It is still answerable, and answering it still runs the act.
|
||||
reply, handled := h.resolveConfirm(ctx, "да")
|
||||
if !handled || !strings.Contains(reply, "готово") {
|
||||
t.Fatalf("the still-parked confirm did not resolve: handled=%v reply=%q", handled, reply)
|
||||
}
|
||||
if _, err := os.Stat(marker); err != nil {
|
||||
t.Fatalf("confirmed destructive command did not run: %v", err)
|
||||
}
|
||||
if h.pending != nil {
|
||||
t.Fatal("the confirm stayed parked after being answered")
|
||||
}
|
||||
}
|
||||
@@ -173,6 +173,13 @@ func (a *crawlFetcher) Get(ctx context.Context, u string) (*crawl.Response, erro
|
||||
case errors.Is(err, webfetch.ErrBlocked), errors.Is(err, webfetch.ErrPrivate), errors.Is(err, webfetch.ErrScheme):
|
||||
return nil, fmt.Errorf("%w: %v", crawl.ErrFetchRefused, err)
|
||||
case errors.Is(err, webfetch.ErrStatus):
|
||||
// Carry the code across the seam. The crawler needs to tell a 5xx
|
||||
// from a 404 to decide what a failed robots.txt means, and it must
|
||||
// not learn that by reading this sentence.
|
||||
var se *webfetch.StatusError
|
||||
if errors.As(err, &se) {
|
||||
return nil, &crawl.StatusError{Code: se.Code}
|
||||
}
|
||||
return nil, fmt.Errorf("%w: %v", crawl.ErrFetchStatus, err)
|
||||
}
|
||||
return nil, err
|
||||
|
||||
@@ -4,12 +4,14 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/calendar"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/morning"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/store"
|
||||
@@ -111,6 +113,88 @@ func TestQueryDayPlanRestOfDayWhenNothingIsLeft(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// "что дальше?" rebuilds the plan off the wire and renders it here, and the
|
||||
// instants on it carry the zone the core read them in — a calendar fact's Ts
|
||||
// and a reminder's FireTs are UTC out of the store. Read raw, the recital named
|
||||
// the store's clock instead of his (V-614). The asking clock is three hours off
|
||||
// whatever this machine runs in, so the assertion holds under TZ=UTC too.
|
||||
func TestQueryDayPlanRestOfDayReadsHisClock(t *testing.T) {
|
||||
_, off := time.Now().Zone()
|
||||
away := time.FixedZone("away", off+3*60*60)
|
||||
stored := time.Date(2026, 8, 3, 8, 0, 0, 0, time.UTC)
|
||||
|
||||
h := &reactiveHandler{
|
||||
api: &planAPI{plan: ipc.DayPlan{
|
||||
Date: time.Date(2026, 8, 3, 0, 0, 0, 0, time.UTC),
|
||||
Items: []ipc.DayPlanItem{{At: stored, Text: "позвонить маме", Kind: "reminder"}},
|
||||
}},
|
||||
now: func() time.Time { return time.Date(2026, 8, 3, 9, 0, 0, 0, away) },
|
||||
}
|
||||
reply, ok := h.queryDayPlan(context.Background(), &queryTurn{
|
||||
dec: router.Decision{Intent: router.IntentQuery, Utterance: "что дальше?"},
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("expected the plan source to claim it")
|
||||
}
|
||||
if want := stored.In(away).Format("15:04"); !strings.Contains(reply, want) {
|
||||
t.Errorf("the reminder is not read in his clock (%s): %q", want, reply)
|
||||
}
|
||||
if bad := stored.Format("15:04"); strings.Contains(reply, bad) {
|
||||
t.Errorf("the reminder is read in the store's zone (%s): %q", bad, reply)
|
||||
}
|
||||
}
|
||||
|
||||
// The defect V-618 fixes, at the handler: asked at 04:45 the trim removes
|
||||
// nothing, because the whole day is still ahead. She read 43 entries aloud as
|
||||
// one sentence. The zone is three hours off UTC so the test also fails under
|
||||
// TZ=UTC if the rendering ever slips zones.
|
||||
func TestQueryDayPlanCapsWhatItReadsAloud(t *testing.T) {
|
||||
zone := time.FixedZone("MSK", 3*60*60)
|
||||
mid := time.Date(2026, 8, 3, 0, 0, 0, 0, zone)
|
||||
plan := ipc.DayPlan{Date: mid, Spoken: "план на 03.08.2026: …"}
|
||||
for i := 0; i < 43; i++ {
|
||||
plan.Items = append(plan.Items, ipc.DayPlanItem{
|
||||
At: mid.Add(time.Duration(345+i*20) * time.Minute), // 05:45 onward
|
||||
Text: fmt.Sprintf("пункт %d", i),
|
||||
Kind: "event",
|
||||
})
|
||||
}
|
||||
h := &reactiveHandler{api: &planAPI{plan: plan}, now: func() time.Time {
|
||||
return time.Date(2026, 8, 3, 4, 45, 0, 0, zone)
|
||||
}}
|
||||
reply, ok := h.queryDayPlan(context.Background(), &queryTurn{
|
||||
dec: router.Decision{Intent: router.IntentQuery, Utterance: "что дальше?"},
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("expected the plan source to claim it")
|
||||
}
|
||||
if n := strings.Count(reply, "пункт "); n != morning.NextSpoken {
|
||||
t.Errorf("read %d entries aloud, want %d: %q", n, morning.NextSpoken, reply)
|
||||
}
|
||||
if !strings.HasPrefix(reply, "дальше: 05:45 — пункт 0;") {
|
||||
t.Errorf("the next thing is not first: %q", reply)
|
||||
}
|
||||
// The rest is counted, not silently dropped.
|
||||
if !strings.Contains(reply, "и ещё 40 дел до конца дня.") {
|
||||
t.Errorf("the sentence hides that the day goes on: %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// "что у меня сегодня?" is the whole day and is not narrowed. It carries no
|
||||
// plan word, so the plan source declines it and the calendar listing answers —
|
||||
// asserted here beside the cap so the two questions cannot drift together.
|
||||
func TestWholeDayQuestionIsNotTheRestOfTheDay(t *testing.T) {
|
||||
if router.IsDayPlanQuery("что у меня сегодня?") {
|
||||
t.Error("the plan source claims the whole-day question")
|
||||
}
|
||||
if !router.IsDayPlanQuery("что дальше?") {
|
||||
t.Error("the plan source stopped claiming the rest-of-day question")
|
||||
}
|
||||
if router.IsRestOfDayQuery("какие планы на сегодня?") {
|
||||
t.Error("the whole-day plan question got narrowed to the rest of the day")
|
||||
}
|
||||
}
|
||||
|
||||
// A question that is not about the plan must fall through, or the plan buries
|
||||
// the calendar listing and the weather behind it.
|
||||
func TestQueryDayPlanPassesOnEverythingElse(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
// mavend/decisiontrace.go — the daemon's half of the per-turn decision record.
|
||||
//
|
||||
// V-564. The router says what the cascade did (internal/router/decisiontrace.go);
|
||||
// this file covers the two claimant sets that live in the daemon: the stateful
|
||||
// resolvers that run BEFORE routing and pre-empt it unconditionally, and the
|
||||
// query source chain that runs after. Those two are where the arbitration is
|
||||
// least visible, because both are a hardcoded order of functions that each
|
||||
// answer "is this mine?" alone and none of which answers "is this more mine
|
||||
// than yours?" (V-558).
|
||||
//
|
||||
// Recording changes no route. Every helper here is a no-op on a context with no
|
||||
// record, which is what every test that does not ask for one gets.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/kami/maven/internal/decision"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
// preRouteLadder — the resolvers runTurn offers the utterance to before the
|
||||
// router sees it, in the order they get their say. Kept here as a roster rather
|
||||
// than derived from the code, so a resolver that returns early and skips the
|
||||
// rest still leaves the rest NAMED in the record: a claimant that never looked
|
||||
// and one that looked and passed are the distinction the ordering hides, and
|
||||
// they are the difference between a bug in the ladder and a bug in a resolver.
|
||||
//
|
||||
// Adding a step to runTurn means adding its name here. Nothing enforces that,
|
||||
// and nothing should: a missing name costs one line of the record, while a
|
||||
// check that walks the ladder would have to run the ladder.
|
||||
var preRouteLadder = []string{
|
||||
"confirm", "clarify-answer", "quiet-toggle", "snooze", "ack", "repair", "ordinal",
|
||||
}
|
||||
|
||||
// notePreRoute records one rung of that ladder and passes its verdict through
|
||||
// unchanged, so the call site stays the single `if handled` it already was.
|
||||
func notePreRoute(ctx context.Context, name string, handled bool) bool {
|
||||
rec := decision.From(ctx)
|
||||
if rec == nil {
|
||||
return handled
|
||||
}
|
||||
if handled {
|
||||
rec.Note(decision.Claim{
|
||||
Stage: decision.StagePreRoute, Claimant: name, Outcome: decision.Won,
|
||||
Reason: "it pre-empted routing, so the router never saw this turn",
|
||||
})
|
||||
return handled
|
||||
}
|
||||
rec.Note(decision.Claim{
|
||||
Stage: decision.StagePreRoute, Claimant: name, Outcome: decision.Declined,
|
||||
Reason: "nothing of its own was pending",
|
||||
})
|
||||
return handled
|
||||
}
|
||||
|
||||
// noteTerminal records whoever actually produced the reply, but only if the
|
||||
// turn is still unclaimed. A route decides the intent; it does not answer, and
|
||||
// on a thinned route or a plain act nothing downstream keeps a scoreboard. So
|
||||
// the record would otherwise close with an empty winner, which reads as a lost
|
||||
// turn instead of an asked question.
|
||||
func noteTerminal(ctx context.Context, claimant string, intent router.Intent, reason string) {
|
||||
decision.From(ctx).NoteIfUnclaimed(decision.Claim{
|
||||
Stage: decision.StageAction, Claimant: claimant,
|
||||
Intent: string(intent), Reason: reason,
|
||||
})
|
||||
}
|
||||
|
||||
// noteMerge records the follow-up merge, which is the one claimant that edits
|
||||
// the winning decision instead of taking the turn from it. It is compared on
|
||||
// the four slots the merge can fill, because a Decision holds a slice and is
|
||||
// not comparable.
|
||||
func noteMerge(ctx context.Context, before, after router.Decision) {
|
||||
rec := decision.From(ctx)
|
||||
if rec == nil {
|
||||
return
|
||||
}
|
||||
changed := before.Slots.HasTime != after.Slots.HasTime ||
|
||||
before.Slots.HasKey != after.Slots.HasKey ||
|
||||
before.Slots.HasFn != after.Slots.HasFn ||
|
||||
before.Slots.Text != after.Slots.Text ||
|
||||
before.Intent != after.Intent
|
||||
if !changed {
|
||||
rec.Note(decision.Claim{
|
||||
Stage: decision.StageMerge, Claimant: "follow-up-merge", Outcome: decision.Declined,
|
||||
Reason: "no slot of this turn was left for a previous one to fill",
|
||||
})
|
||||
return
|
||||
}
|
||||
rec.Note(decision.Claim{
|
||||
Stage: decision.StageMerge, Claimant: "follow-up-merge", Intent: string(after.Intent),
|
||||
Outcome: decision.Merged, Reason: "filled this turn's gaps from the previous turn",
|
||||
})
|
||||
}
|
||||
|
||||
// turnDecisionsFn — the reader mavweb gets, or nil when voice was never wired.
|
||||
// Same shape as intakeEventsFn: the daemon holds the ring, the IPC layer only
|
||||
// converts it.
|
||||
func turnDecisionsFn(w *voiceWiring) func(int) []ipc.TurnDecision {
|
||||
if w == nil || w.handler == nil || w.handler.decisions == nil {
|
||||
return nil
|
||||
}
|
||||
ring := w.handler.decisions
|
||||
return func(n int) []ipc.TurnDecision {
|
||||
recs := ring.Recent(n)
|
||||
out := make([]ipc.TurnDecision, 0, len(recs))
|
||||
for _, rec := range recs {
|
||||
claims := make([]ipc.TurnClaim, 0, len(rec.Claims))
|
||||
for _, c := range rec.Claims {
|
||||
claims = append(claims, ipc.TurnClaim{
|
||||
Stage: c.Stage, Claimant: c.Claimant, Intent: c.Intent,
|
||||
Score: c.Score, HasScore: c.HasScore,
|
||||
Outcome: c.Outcome, Reason: c.Reason,
|
||||
})
|
||||
}
|
||||
out = append(out, ipc.TurnDecision{
|
||||
Ts: rec.Ts, Utterance: rec.Utterance, Winner: rec.Winner, Claims: claims,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
|
||||
// querySourceNames — the query chain's roster, in chain order.
|
||||
func querySourceNames() []string {
|
||||
names := make([]string, len(querySources))
|
||||
for i, src := range querySources {
|
||||
names[i] = src.name
|
||||
}
|
||||
return names
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/decision"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/memory"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/tool"
|
||||
"github.com/kami/maven/internal/voice"
|
||||
)
|
||||
|
||||
// traceHandler — a handler with the decision ring wired, the same shape the
|
||||
// daemon builds in wireVoice.
|
||||
func traceHandler(t *testing.T, ring *decision.Ring) *reactiveHandler {
|
||||
t.Helper()
|
||||
st := newTestStore(t)
|
||||
api := ipc.NewStoreAPI(st)
|
||||
now := time.Now()
|
||||
emb := router.NewHashEmbedder(1024)
|
||||
return &reactiveHandler{
|
||||
api: api,
|
||||
recall: recallWiring{embedder: emb, memStore: memory.NewInMemoryStore()},
|
||||
router: buildRouter(emb, tool.NewMatcher(api), 0.55, nil),
|
||||
replier: voice.NewStubReplier(),
|
||||
now: func() time.Time { return now },
|
||||
dataStore: st,
|
||||
decisions: ring,
|
||||
}
|
||||
}
|
||||
|
||||
// findClaim — the first claim for a claimant, or nil.
|
||||
func findClaim(rec *decision.Record, claimant string) *decision.Claim {
|
||||
for i := range rec.Claims {
|
||||
if rec.Claims[i].Claimant == claimant {
|
||||
return &rec.Claims[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestTurnRecordNamesWinnerAndLosers — the point of V-564. A turn a stage-0
|
||||
// grammar claims must leave a record naming that grammar as the winner, naming
|
||||
// a pre-route resolver that declined, and naming the routing engines that were
|
||||
// never reached at all. The last of those is the fact the hardcoded ordering
|
||||
// hides: "classifier" absent from the record and "classifier" never asked read
|
||||
// the same to a human, and only one of them is the truth.
|
||||
func TestTurnRecordNamesWinnerAndLosers(t *testing.T) {
|
||||
ring := decision.NewRing()
|
||||
h := traceHandler(t, ring)
|
||||
|
||||
reply := h.handleText(context.Background(), "web", "сколько сейчас времени")
|
||||
if reply == "" {
|
||||
t.Fatal("turn produced no reply")
|
||||
}
|
||||
|
||||
recs := ring.Recent(5)
|
||||
if len(recs) != 1 {
|
||||
t.Fatalf("want 1 record, got %d", len(recs))
|
||||
}
|
||||
rec := recs[0]
|
||||
if rec.Utterance != "сколько сейчас времени" {
|
||||
t.Errorf("utterance = %q", rec.Utterance)
|
||||
}
|
||||
if !strings.HasPrefix(rec.Winner, "stage0:") {
|
||||
t.Errorf("want a stage-0 grammar as the winner, got %q", rec.Winner)
|
||||
}
|
||||
|
||||
// A loser that examined the turn: the confirm resolver ran first and had
|
||||
// nothing pending.
|
||||
confirm := findClaim(rec, "confirm")
|
||||
if confirm == nil || confirm.Outcome != decision.Declined {
|
||||
t.Errorf("confirm claim = %+v, want a decline", confirm)
|
||||
}
|
||||
|
||||
// A loser that never looked: stage 0 answered, so neither routing engine
|
||||
// was reached.
|
||||
for _, name := range []string{"llm-router", "classifier"} {
|
||||
c := findClaim(rec, name)
|
||||
if c != nil && c.Outcome == decision.Won {
|
||||
t.Errorf("%s cannot have won a stage-0 turn: %+v", name, c)
|
||||
}
|
||||
}
|
||||
|
||||
// And every rung of the ladder below the winner is named, not omitted.
|
||||
for _, name := range preRouteLadder {
|
||||
if findClaim(rec, name) == nil {
|
||||
t.Errorf("ladder rung %q is missing from the record", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecordingDoesNotChangeTheReply — instrumentation, so a turn with the ring
|
||||
// wired and the same turn without it must answer identically. If this ever
|
||||
// fails, a claim site is doing more than noting.
|
||||
func TestRecordingDoesNotChangeTheReply(t *testing.T) {
|
||||
for _, utt := range []string{
|
||||
"сколько сейчас времени",
|
||||
"запиши что я пил воду",
|
||||
"что у меня сегодня",
|
||||
} {
|
||||
withRing := traceHandler(t, decision.NewRing()).handleText(context.Background(), "web", utt)
|
||||
without := traceHandler(t, nil).handleText(context.Background(), "web", utt)
|
||||
if withRing != without {
|
||||
t.Errorf("%q: recorded reply %q != unrecorded %q", utt, withRing, without)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestQueryChainRecordsWhoWasNeverAsked — a query source below the claimant is
|
||||
// never consulted, and the record must say so rather than leave it out. This is
|
||||
// the arm that would have explained the Rome misroute in one read.
|
||||
func TestQueryChainRecordsWhoWasNeverAsked(t *testing.T) {
|
||||
ring := decision.NewRing()
|
||||
h := traceHandler(t, ring)
|
||||
h.handleText(context.Background(), "web", "что у меня сегодня")
|
||||
|
||||
rec := ring.Recent(1)[0]
|
||||
var asked, never int
|
||||
for _, c := range rec.Claims {
|
||||
if c.Stage != decision.StageQuery {
|
||||
continue
|
||||
}
|
||||
if c.Outcome == decision.NeverAsked {
|
||||
never++
|
||||
} else {
|
||||
asked++
|
||||
}
|
||||
}
|
||||
if asked == 0 {
|
||||
t.Fatal("no query source reported at all")
|
||||
}
|
||||
if never == 0 {
|
||||
t.Fatal("no query source was recorded as never asked; the chain cannot have run to the end")
|
||||
}
|
||||
if got := len(querySourceNames()); asked+never != got {
|
||||
t.Errorf("record covers %d of %d query sources", asked+never, got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTurnDecisionsFnConvertsTheRing — the IPC read path. Nil when voice was
|
||||
// never wired, because a box with no turns is an empty page and not an error.
|
||||
func TestTurnDecisionsFnConvertsTheRing(t *testing.T) {
|
||||
if fn := turnDecisionsFn(nil); fn != nil {
|
||||
t.Error("no wiring should mean no reader")
|
||||
}
|
||||
ring := decision.NewRing()
|
||||
h := traceHandler(t, ring)
|
||||
h.handleText(context.Background(), "web", "сколько сейчас времени")
|
||||
|
||||
fn := turnDecisionsFn(&voiceWiring{handler: h})
|
||||
if fn == nil {
|
||||
t.Fatal("wired handler produced no reader")
|
||||
}
|
||||
out := fn(10)
|
||||
if len(out) != 1 || out[0].Winner == "" || len(out[0].Claims) == 0 {
|
||||
t.Fatalf("conversion lost the record: %+v", out)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,807 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/dialogue"
|
||||
"github.com/kami/maven/internal/memory"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
// Dialogue contract tests (V-563, child of V-558).
|
||||
//
|
||||
// Every other clarify test is single-shot: one ask, one answer, one assertion.
|
||||
// Three bugs of the same family shipped in two days that way — V-554 (a parked
|
||||
// question ate the three turns after it), V-557 (a confidently routed but
|
||||
// incomplete reminder parked nothing, so the answer was web-searched) and the
|
||||
// Rome case in V-558 (a side question was eaten as the time answer). None of
|
||||
// them is visible in one turn. The dialogue path is a state machine, so it can
|
||||
// be enumerated instead: whole traces, each with a per-turn expectation and an
|
||||
// expected END state — what was written to the store, and what is still parked.
|
||||
//
|
||||
// Two rules for the rows below.
|
||||
//
|
||||
// Where today's behaviour is correct, it is asserted. Where it is WRONG, the row
|
||||
// carries the CORRECT expectation and is skipped with the Vikunja id that will
|
||||
// unskip it. A weakened expectation would be worse than no row: it would pin the
|
||||
// bug as the contract.
|
||||
//
|
||||
// Everything runs on the offline floor — hash embedder, no llama-server, no
|
||||
// ONNX, StubDateTimeParser. That has one consequence worth knowing before
|
||||
// reading a fire time here: the stub reads "в 11:00" and "через час" and does
|
||||
// not read "на 9" or "на завтра", so a trace that needs those is noted where it
|
||||
// sits.
|
||||
|
||||
// claim — which claimant consumed an utterance. Not asserted: it is derived from
|
||||
// the log lines the daemon already emits and printed on every failure, because
|
||||
// "the reply differed" does not distinguish a wrong claimant from wrong copy,
|
||||
// and that distinction is the whole point of V-558.
|
||||
type claim struct {
|
||||
utterance string
|
||||
steps []string
|
||||
}
|
||||
|
||||
func (c claim) String() string { return c.utterance + " ⇒ " + strings.Join(c.steps, " → ") }
|
||||
|
||||
// claimMarkers — log fragment to claimant name, in the order runTurn checks
|
||||
// them. The fragments are the daemon's own words (clarify.go, repair.go,
|
||||
// voice.go); a rename there shows up here as an "unclaimed" step rather than a
|
||||
// silent mislabel.
|
||||
var claimMarkers = []struct{ fragment, name string }{
|
||||
{"parked question expired", "clarify:expired"},
|
||||
{"is its own request", "clarify:stepped-aside"},
|
||||
{"gave up on", "clarify:gave-up"},
|
||||
{"did not fill", "clarify:re-ask"},
|
||||
{"one gap filled", "clarify:ask-second-gap"},
|
||||
{"asked about", "clarify:ask"},
|
||||
{"repair —", "repair"},
|
||||
{"route result: intent=", "route"},
|
||||
}
|
||||
|
||||
// claimsOf reads the turn's log output and names the claimants that touched it.
|
||||
func claimsOf(utterance, logged string) claim {
|
||||
c := claim{utterance: utterance}
|
||||
for _, line := range strings.Split(logged, "\n") {
|
||||
for _, m := range claimMarkers {
|
||||
if strings.Contains(line, m.fragment) {
|
||||
name := m.name
|
||||
if m.name == "route" {
|
||||
name = "route:" + intentInLine(line)
|
||||
}
|
||||
c.steps = append(c.steps, name)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(c.steps) == 0 {
|
||||
c.steps = []string{"unclaimed"}
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func intentInLine(line string) string {
|
||||
_, rest, ok := strings.Cut(line, "intent=")
|
||||
if !ok {
|
||||
return "?"
|
||||
}
|
||||
intent, _, _ := strings.Cut(rest, " ")
|
||||
return intent
|
||||
}
|
||||
|
||||
// parkedWant — the question that must be armed after a turn. Attempt matters:
|
||||
// a claimant that spends a retry on an utterance that was never an answer is
|
||||
// exactly the V-554 shape, and the count is the only place it shows.
|
||||
type parkedWant struct {
|
||||
slot dialogue.Slot
|
||||
attempt int
|
||||
// carries — a substring the parked utterance must still hold, so a re-park
|
||||
// that lost the answered subject fails here rather than three turns later.
|
||||
carries string
|
||||
}
|
||||
|
||||
// turn — one utterance and everything that must be true right after it.
|
||||
type turn struct {
|
||||
say string
|
||||
// wait — the clock moves this far BEFORE the utterance. The only way to
|
||||
// reach the TTL without sleeping.
|
||||
wait time.Duration
|
||||
// question — the reply must be exactly this clarify question, worded for
|
||||
// this attempt. Zero slot ⇒ not checked.
|
||||
question dialogue.Slot
|
||||
attempt int
|
||||
// gap — which part of the time she is asking about, for a SlotTime question
|
||||
// (V-579). Zero value is the missing hour, which is what she asks first.
|
||||
gap whenGap
|
||||
// took — the words of the PREVIOUS turn that this ask must acknowledge
|
||||
// before asking again (V-593). Empty ⇒ the ask carries no acknowledgement,
|
||||
// which is right for a first ask and for an answer that moved nothing.
|
||||
took string
|
||||
// differs — this reply must not be byte-identical to the one before it. Set
|
||||
// on a re-ask whose turn moved the request forward (V-593).
|
||||
differs bool
|
||||
contains []string
|
||||
notContain []string
|
||||
// noQuestion — the reply must not be any clarify question. Used where the
|
||||
// correct behaviour is known but her wording for it is not written yet: a
|
||||
// cancel must not be answered with another question, whatever it does say.
|
||||
noQuestion bool
|
||||
expired bool // the reply must open with the TTL notice
|
||||
// parked — what is armed after the turn. nil ⇒ nothing may be armed.
|
||||
parked *parkedWant
|
||||
}
|
||||
|
||||
// endState — what the store holds once the trace is over. Counts and
|
||||
// substrings, not rows: a trace is about who claimed what, and a payload
|
||||
// substring is enough to catch a request landing under the wrong words.
|
||||
type endState struct {
|
||||
reminders []reminderWant
|
||||
factKeys []string
|
||||
notes int
|
||||
tasks []string
|
||||
}
|
||||
|
||||
type reminderWant struct {
|
||||
payload string // substring of the stored payload
|
||||
fireAt string // "2006-01-02 15:04" in UTC, "" ⇒ not checked
|
||||
}
|
||||
|
||||
// trace — a named conversation, its turns, and the end state.
|
||||
type trace struct {
|
||||
name string
|
||||
skip string // non-empty ⇒ t.Skip: today's behaviour is wrong, this names the fix
|
||||
turns []turn
|
||||
end endState
|
||||
}
|
||||
|
||||
// newDialogueHandler — the offline floor with the real cascade and a movable
|
||||
// clock: newClarifyHandler's wiring (stub date parser, real fact parser, tool
|
||||
// matcher) plus the router newRoutingClarifyHandler builds, and the `now`
|
||||
// pointer so a turn can carry a wait.
|
||||
func newDialogueHandler(t *testing.T) (*reactiveHandler, *store.Store, *time.Time) {
|
||||
t.Helper()
|
||||
h, st, now := newClarifyHandler(t)
|
||||
// A minute no trace ever says, so "fires at the current clock" is a defect
|
||||
// and never a coincidence (V-577, V-579). checkEnd refuses any reminder
|
||||
// landing on it, and at 09:00 the row that answers "на 9" would trip that.
|
||||
*now = time.Date(2026, 7, 31, 9, 17, 0, 0, time.UTC)
|
||||
h.router = buildRouter(router.NewHashEmbedder(1024), h.matcher, 0.55, nil)
|
||||
h.recall = recallWiring{embedder: router.NewHashEmbedder(1024), memStore: memory.NewInMemoryStore()}
|
||||
return h, st, now
|
||||
}
|
||||
|
||||
// wantedQuestion builds the question a turn must be answered with, from the
|
||||
// same code the daemon asks through. A time question is built from the gap,
|
||||
// because she names the clock and asks about the part he left out (V-579).
|
||||
func wantedQuestion(tn turn, now time.Time) (string, bool) {
|
||||
if tn.question == dialogue.SlotTime {
|
||||
gap := tn.gap
|
||||
if gap == whenComplete {
|
||||
gap = whenNoHour
|
||||
}
|
||||
return whenQuestion(gap, tn.attempt, now, whenTakenLine(tn.took))
|
||||
}
|
||||
return clarifyQuestionFor(tn.question, tn.attempt)
|
||||
}
|
||||
|
||||
// runTrace drives one trace through handleText and checks every turn, then the
|
||||
// end state. Every failure carries the decision trace so far, so a wrong
|
||||
// claimant reads differently from wrong copy.
|
||||
func runTrace(t *testing.T, tr trace) {
|
||||
t.Helper()
|
||||
// MAVEN_DIALOGUE_NO_SKIP=1 runs the rows that fail today. That is how
|
||||
// whoever lands V-560, V-561 or V-562 sees their row go green before
|
||||
// deleting its skip, and it is also the check that a skip is still earned:
|
||||
// a row that passes with the skip in place is a fix nobody noticed.
|
||||
if tr.skip != "" && os.Getenv("MAVEN_DIALOGUE_NO_SKIP") == "" {
|
||||
t.Skip(tr.skip)
|
||||
}
|
||||
ctx := context.Background()
|
||||
h, st, now := newDialogueHandler(t)
|
||||
const conversation = "web"
|
||||
id := dialogueIDFor(sourceText, conversation)
|
||||
|
||||
var claims []claim
|
||||
var previous string
|
||||
fail := func(turnIdx int, format string, args ...any) {
|
||||
t.Helper()
|
||||
lines := make([]string, 0, len(claims))
|
||||
for _, c := range claims {
|
||||
lines = append(lines, " "+c.String())
|
||||
}
|
||||
t.Fatalf("turn %d: "+format+"\n who claimed what:\n%s",
|
||||
append([]any{turnIdx}, append(args, strings.Join(lines, "\n"))...)...)
|
||||
}
|
||||
|
||||
for i, tn := range tr.turns {
|
||||
if tn.wait > 0 {
|
||||
*now = now.Add(tn.wait)
|
||||
}
|
||||
var logged bytes.Buffer
|
||||
prev := log.Writer()
|
||||
log.SetOutput(&logged)
|
||||
reply := h.handleText(ctx, conversation, tn.say)
|
||||
log.SetOutput(prev)
|
||||
claims = append(claims, claimsOf(tn.say, logged.String()))
|
||||
|
||||
body := reply
|
||||
if tn.expired {
|
||||
if !isClarifyExpired(reply) {
|
||||
fail(i, "reply %q must open with the expiry notice", reply)
|
||||
}
|
||||
body = trimClarifyExpired(reply)
|
||||
// The notice is glued in front of this turn's reply, and both halves
|
||||
// have to survive: the words he just said are routed fresh, and
|
||||
// answering only "I let the old one go" drops them.
|
||||
if body == "" {
|
||||
fail(i, "the notice was the whole reply; the fresh words were never answered")
|
||||
}
|
||||
} else if isClarifyExpired(reply) {
|
||||
fail(i, "reply %q announced an expiry nothing asked for", reply)
|
||||
}
|
||||
if tn.question != "" {
|
||||
want, ok := wantedQuestion(tn, h.now())
|
||||
if !ok {
|
||||
fail(i, "no question exists for slot %s attempt %d", tn.question, tn.attempt)
|
||||
}
|
||||
if body != want {
|
||||
fail(i, "reply %q, want the %s question worded for attempt %d, %q", body, tn.question, tn.attempt, want)
|
||||
}
|
||||
}
|
||||
if tn.noQuestion && isAnyClarifyQuestion(body) {
|
||||
fail(i, "reply %q is another question; this turn is not something to ask about", body)
|
||||
}
|
||||
for _, want := range tn.contains {
|
||||
if !strings.Contains(body, want) {
|
||||
fail(i, "reply %q does not carry %q", body, want)
|
||||
}
|
||||
}
|
||||
for _, unwanted := range tn.notContain {
|
||||
if strings.Contains(body, unwanted) {
|
||||
fail(i, "reply %q carries %q and must not", body, unwanted)
|
||||
}
|
||||
}
|
||||
if tn.differs && reply == previous {
|
||||
fail(i, "reply %q is byte-identical to the one before it, and his turn between them answered part of the gap", reply)
|
||||
}
|
||||
previous = reply
|
||||
checkParked(t, fail, i, h.clarifyStore.Get(id, h.now()), tn.parked)
|
||||
}
|
||||
checkEnd(t, ctx, st, h, tr.end, claims)
|
||||
}
|
||||
|
||||
// isAnyClarifyQuestion — is this reply one of her clarify questions, at any
|
||||
// attempt wording? Reads the templates rather than a list of its own.
|
||||
func isAnyClarifyQuestion(reply string) bool {
|
||||
for _, variants := range clarifyQuestionVariants {
|
||||
for _, v := range variants {
|
||||
// HasSuffix, not equality: a question about the time opens with the
|
||||
// clock she is reasoning from (V-579).
|
||||
if strings.HasSuffix(reply, v) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
// The two questions with no deck behind them, asked when the hour is said
|
||||
// and its half of the day or its day is not.
|
||||
return strings.HasSuffix(reply, "утра или вечера?") || strings.HasSuffix(reply, "В какой день?")
|
||||
}
|
||||
|
||||
func checkParked(t *testing.T, fail func(int, string, ...any), i int, got *dialogue.PendingQuestion, want *parkedWant) {
|
||||
t.Helper()
|
||||
if want == nil {
|
||||
if got != nil {
|
||||
fail(i, "a question about %v is still armed and nothing should be: %+v", got.Missing, got.Slots)
|
||||
}
|
||||
return
|
||||
}
|
||||
if got == nil {
|
||||
fail(i, "nothing is armed, want a question about %s (attempt %d)", want.slot, want.attempt)
|
||||
return
|
||||
}
|
||||
if len(got.Missing) != 1 || got.Missing[0] != want.slot {
|
||||
fail(i, "armed question is about %v, want %s", got.Missing, want.slot)
|
||||
}
|
||||
if got.Attempts != want.attempt {
|
||||
fail(i, "armed question is on attempt %d, want %d — a retry spent on something that was never an answer is the V-554 shape", got.Attempts, want.attempt)
|
||||
}
|
||||
if want.carries != "" && !strings.Contains(got.Utterance, want.carries) {
|
||||
fail(i, "the parked request no longer carries %q: %q", want.carries, got.Utterance)
|
||||
}
|
||||
}
|
||||
|
||||
func checkEnd(t *testing.T, ctx context.Context, st *store.Store, h *reactiveHandler, want endState, claims []claim) {
|
||||
t.Helper()
|
||||
lines := make([]string, 0, len(claims))
|
||||
for _, c := range claims {
|
||||
lines = append(lines, " "+c.String())
|
||||
}
|
||||
trace := "\n who claimed what:\n" + strings.Join(lines, "\n")
|
||||
|
||||
reminders, err := st.DueReminders(ctx, h.now().Add(14*24*time.Hour))
|
||||
if err != nil {
|
||||
t.Fatalf("DueReminders: %v", err)
|
||||
}
|
||||
if len(reminders) != len(want.reminders) {
|
||||
t.Fatalf("end state: %d reminder(s), want %d: %+v%s", len(reminders), len(want.reminders), reminders, trace)
|
||||
}
|
||||
// No trace may leave a reminder at the current clock, whatever else it
|
||||
// asserts (V-577, V-579). Twice on the box a sentence naming a day and no
|
||||
// hour was completed from time.Now(): "что у меня сегодня?" became 01:28 and
|
||||
// "на завтра" became 01:38. Neither minute was ever spoken, and a row that
|
||||
// only checked the payload would have passed both.
|
||||
for _, r := range reminders {
|
||||
if r.FireTs.In(h.now().Location()).Format("15:04") == h.now().Format("15:04") {
|
||||
t.Fatalf("end state: reminder %q fires at %s, which is the clock — a time slot naming no hour is asked about, never filled from now()%s",
|
||||
r.Payload, r.FireTs.Format("15:04"), trace)
|
||||
}
|
||||
}
|
||||
for i, w := range want.reminders {
|
||||
if !strings.Contains(reminders[i].Payload, w.payload) {
|
||||
t.Fatalf("end state: reminder %d payload %q does not carry %q%s", i, reminders[i].Payload, w.payload, trace)
|
||||
}
|
||||
if w.fireAt != "" {
|
||||
if got := reminders[i].FireTs.UTC().Format("2006-01-02 15:04"); got != w.fireAt {
|
||||
t.Fatalf("end state: reminder %d fires at %s, want %s%s", i, got, w.fireAt, trace)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
facts, err := st.RecentFacts(ctx, 20)
|
||||
if err != nil {
|
||||
t.Fatalf("RecentFacts: %v", err)
|
||||
}
|
||||
if len(facts) != len(want.factKeys) {
|
||||
t.Fatalf("end state: %d fact(s), want %d: %+v%s", len(facts), len(want.factKeys), facts, trace)
|
||||
}
|
||||
for i, key := range want.factKeys {
|
||||
if facts[i].Key != key {
|
||||
t.Fatalf("end state: fact %d is %q, want %q%s", i, facts[i].Key, key, trace)
|
||||
}
|
||||
}
|
||||
|
||||
notes, err := st.RecentNotes(ctx, 20)
|
||||
if err != nil {
|
||||
t.Fatalf("RecentNotes: %v", err)
|
||||
}
|
||||
if len(notes) != want.notes {
|
||||
t.Fatalf("end state: %d note(s), want %d%s", len(notes), want.notes, trace)
|
||||
}
|
||||
|
||||
tasks, err := st.ListTasks(ctx, store.TaskOpen)
|
||||
if err != nil {
|
||||
t.Fatalf("ListTasks: %v", err)
|
||||
}
|
||||
if len(tasks) != len(want.tasks) {
|
||||
t.Fatalf("end state: %d open task(s), want %d: %+v%s", len(tasks), len(want.tasks), tasks, trace)
|
||||
}
|
||||
for i, text := range want.tasks {
|
||||
if !strings.Contains(tasks[i].Text, text) {
|
||||
t.Fatalf("end state: task %d is %q, want it to carry %q%s", i, tasks[i].Text, text, trace)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDialogueTraces(t *testing.T) {
|
||||
for _, tr := range dialogueTraces() {
|
||||
tr := tr
|
||||
t.Run(tr.name, func(t *testing.T) { runTrace(t, tr) })
|
||||
}
|
||||
}
|
||||
|
||||
// dialogueTraces — the fixture. Order is the order the shapes were found, not a
|
||||
// dependency: each trace builds its own handler and store.
|
||||
func dialogueTraces() []trace {
|
||||
return []trace{
|
||||
// The plain two-turn shape, and the one every other row is a deviation
|
||||
// from: she asks for the time, he gives it, the reminder lands with the
|
||||
// subject he said in the FIRST turn.
|
||||
{
|
||||
// Three turns since V-579, not two. An hour with no day named is
|
||||
// not an answer she can act on: 11:00 today has passed as often as
|
||||
// not, and picking one for him is the invention the whole rule is
|
||||
// against. So she says the clock she is reasoning from and asks
|
||||
// which day.
|
||||
name: "reminder completed over three turns",
|
||||
turns: []turn{
|
||||
{say: "напомни позвонить маме", question: dialogue.SlotTime, attempt: 1,
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1, carries: "маме"}},
|
||||
{say: "в 11:00", question: dialogue.SlotTime, attempt: 2, gap: whenNoDay, took: "в 11:00",
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 2, carries: "маме"}},
|
||||
{say: "сегодня", contains: []string{"11:00"}, notContain: []string{"?"}},
|
||||
},
|
||||
end: endState{reminders: []reminderWant{{payload: "позвонить маме", fireAt: "2026-07-31 11:00"}}},
|
||||
},
|
||||
// The same shape on the fact path, where the answer carries both halves
|
||||
// of what was missing — the key and the value — in one breath.
|
||||
{
|
||||
name: "fact completed over two turns",
|
||||
turns: []turn{
|
||||
{say: "запиши", question: dialogue.SlotKey, attempt: 1,
|
||||
parked: &parkedWant{slot: dialogue.SlotKey, attempt: 1}},
|
||||
// His words back, not the key the parser filed them under
|
||||
// (V-592). "water" is machine vocabulary and he never said it.
|
||||
{say: "пил воду", contains: []string{"пил воду"}},
|
||||
},
|
||||
end: endState{factKeys: []string{"water"}},
|
||||
},
|
||||
// An answer past the TTL is a new request, not an answer (V-385). She
|
||||
// says the old one is gone and routes the words fresh. A bare time on
|
||||
// its own carries no request, so the fresh routing lands on the canned
|
||||
// reply — the point of the row is that NOTHING is created: a reminder
|
||||
// here would fire with the subject of a request she had already let go.
|
||||
{
|
||||
name: "answer arrives after the TTL",
|
||||
turns: []turn{
|
||||
{say: "напомни позвонить маме", question: dialogue.SlotTime, attempt: 1,
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1}},
|
||||
{say: "в 11:00", wait: clarifyTTL + time.Second, expired: true},
|
||||
},
|
||||
end: endState{},
|
||||
},
|
||||
// Three questions is the budget, and running out is SPOKEN: a mute
|
||||
// give-up reads as "done" and he would wait for a reminder that was
|
||||
// never set. The wording changes with the attempt (V-457).
|
||||
{
|
||||
name: "three unclear answers then the give-up line",
|
||||
turns: []turn{
|
||||
{say: "напомни позвонить маме", question: dialogue.SlotTime, attempt: 1,
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1}},
|
||||
{say: "ну не знаю", question: dialogue.SlotTime, attempt: 2,
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 2}},
|
||||
{say: "ну не знаю", question: dialogue.SlotTime, attempt: 3,
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 3}},
|
||||
{say: "ну не знаю", contains: []string{clarifyGaveUp}, noQuestion: true},
|
||||
},
|
||||
end: endState{},
|
||||
},
|
||||
// A correction points at the previous ACTED turn (repair.go): she redoes
|
||||
// it under the intent he names and says so out loud, because a
|
||||
// correction he cannot see is indistinguishable from one that was
|
||||
// dropped. The task she filed first stays filed — repair redoes, it does
|
||||
// not retract, and V-455 decided that deliberately.
|
||||
//
|
||||
// The corrected-to intent has to differ from the one she used, or repair
|
||||
// declines: teaching the classifier the label it already produced is
|
||||
// worse than doing nothing.
|
||||
{
|
||||
name: "correction of the previous turn",
|
||||
turns: []turn{
|
||||
{say: "добавь в задачи купить молоко", contains: []string{"купить молоко"}},
|
||||
{say: "нет, это был вопрос", contains: []string{"поняла, это вопрос"}},
|
||||
},
|
||||
end: endState{tasks: []string{"купить молоко"}},
|
||||
},
|
||||
// He walks away from his own request: a question is parked, the next
|
||||
// utterance is an unrelated request of its own, and nothing follows.
|
||||
// V-554's fix is what makes this row pass — the question steps aside
|
||||
// rather than scoring "добавь в задачи" as the time. The reminder is
|
||||
// dropped in silence and that is the decision: if he meant it he says it
|
||||
// again, and a question left armed eats the turn after next.
|
||||
{
|
||||
name: "abandoned flow: parked, then an unrelated request",
|
||||
turns: []turn{
|
||||
{say: "напомни позвонить маме", question: dialogue.SlotTime, attempt: 1,
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1}},
|
||||
{say: "добавь в задачи купить молоко", contains: []string{"купить молоко"}},
|
||||
{say: "спасибо"},
|
||||
},
|
||||
end: endState{tasks: []string{"купить молоко"}},
|
||||
},
|
||||
|
||||
// V-577 shape 1, the worst of the nine claimants measured on 2026-08-06.
|
||||
// Every token of "что у меня сегодня?" is frame — an interrogative, a
|
||||
// preposition, a particle and a day word — so the role classifier never
|
||||
// looked at the route, the parked reminder read "сегодня" as its time,
|
||||
// and the hour came from the clock. He got a reminder he never asked for
|
||||
// at a minute he never said, and his question was answered nowhere.
|
||||
//
|
||||
// Two claims: the calendar answers, and nothing is written. The flow
|
||||
// survives underneath, because a question of his own is not a request to
|
||||
// abandon the one he was making.
|
||||
{
|
||||
name: "an agenda question mid-flow is answered, not eaten",
|
||||
turns: []turn{
|
||||
{say: "напомни забрать посылку", question: dialogue.SlotTime, attempt: 1,
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1, carries: "посылку"}},
|
||||
{say: "что у меня сегодня?", contains: []string{"31.07.2026"},
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1, carries: "посылку"}},
|
||||
},
|
||||
end: endState{},
|
||||
},
|
||||
// V-577 shape 2. He states something in the middle of the flow. It is
|
||||
// neither a slot value nor a cancel, and it was scored as a failed
|
||||
// answer and dropped in silence: alone the same sentence is stored.
|
||||
// Silence is the one option that is wrong, so it is stored, no retry is
|
||||
// spent, and the question comes back on the end of the same reply.
|
||||
//
|
||||
// The words are a fact and not the owner's note, because the fact parser
|
||||
// is deterministic and the offline floor marks every classifier route
|
||||
// Clarify. The row below carries his own sentence and needs the model.
|
||||
//
|
||||
// What this floor can prove is the arbitration: no retry is spent, the
|
||||
// flow survives on the same attempt, and the words are answered as
|
||||
// themselves with the question coming back after them. Whether the fact
|
||||
// is then WRITTEN is the routing engine's business — the hash embedder
|
||||
// is unsure of every sentence it sees, and an unsure fact has never been
|
||||
// stored.
|
||||
{
|
||||
name: "a fact stated mid-flow steps aside without spending a retry",
|
||||
turns: []turn{
|
||||
{say: "напомни позвонить врачу", question: dialogue.SlotTime, attempt: 1,
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1, carries: "врачу"}},
|
||||
// Nothing she says about it may be a word he did not say
|
||||
// (V-592). On the box this sentence came back as "Проверила, что
|
||||
// ты выпел стакан воды": a non-word for the verb, a glass copied
|
||||
// out of the example in ReplySystemPrompt, and a claim to have
|
||||
// checked something. The store held key=water value="drank"
|
||||
// throughout, so all of it was generated from two tokens.
|
||||
//
|
||||
// The positive half of the contract — the confirmation IS his
|
||||
// sentence — is asserted by "fact completed over two turns"
|
||||
// above. It cannot be asserted here: the hash embedder marks
|
||||
// this route Clarify, and an unsure fact is answered with the
|
||||
// canned line rather than a confirmation of anything.
|
||||
{say: "я выпил воды", contains: []string{"напоминание?"},
|
||||
notContain: []string{"стакан", "выпел", "Проверила", "water"},
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1, carries: "врачу"}},
|
||||
},
|
||||
end: endState{},
|
||||
},
|
||||
// V-593: two asks about the same half of the day, with a turn between
|
||||
// them that answered the DAY. Asking again is right and asking in the
|
||||
// same bytes is not — from his side it is indistinguishable from not
|
||||
// having been heard, which is what the whole V-558 family is about.
|
||||
//
|
||||
// The clock still opens every ask (the owner's rule, V-579); the
|
||||
// acknowledgement goes after it and before the question.
|
||||
{
|
||||
name: "a re-ask names what the answer before it gave her",
|
||||
turns: []turn{
|
||||
{say: "напомни позвонить маме", question: dialogue.SlotTime, attempt: 1,
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1}},
|
||||
{say: "на 9", question: dialogue.SlotTime, attempt: 2, gap: whenAmbiguousHour, took: "на 9",
|
||||
contains: []string{"Сейчас "},
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 2}},
|
||||
{say: "на завтра", question: dialogue.SlotTime, attempt: 3, gap: whenAmbiguousHour, took: "на завтра",
|
||||
contains: []string{"Сейчас ", "завтра"}, differs: true,
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 3}},
|
||||
},
|
||||
end: endState{},
|
||||
},
|
||||
// V-579 turn 3: the preposition decided whether the hour was read. "в 9"
|
||||
// set the reminder and "на 9" was not read at all, on the same build and
|
||||
// with the same cardinal.
|
||||
{
|
||||
// It is read, and being read is not the same as being enough: nine is
|
||||
// either half of the day, so she asks which and then which day
|
||||
// (V-579). Both answers are frame words and neither carries an hour
|
||||
// of its own, so this row is also the proof that an answer is read
|
||||
// against the whole request rather than alone.
|
||||
name: "на 9 answers the time question like в 9",
|
||||
turns: []turn{
|
||||
{say: "напомни позвонить маме", question: dialogue.SlotTime, attempt: 1,
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1}},
|
||||
{say: "на 9", question: dialogue.SlotTime, attempt: 2, gap: whenAmbiguousHour, took: "на 9",
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 2}},
|
||||
{say: "утра", question: dialogue.SlotTime, attempt: 3, gap: whenNoDay, took: "утра",
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 3}},
|
||||
{say: "завтра", contains: []string{"09:00"}},
|
||||
},
|
||||
end: endState{reminders: []reminderWant{{payload: "позвонить маме", fireAt: "2026-08-01 09:00"}}},
|
||||
},
|
||||
// The owner's own four, ruled 2026-08-06 (V-579). A reminder commits
|
||||
// when what, what time and what day are all answered, and every ask
|
||||
// states the clock she is reasoning from.
|
||||
{
|
||||
name: "his first example: a bare 3 is asked about",
|
||||
turns: []turn{
|
||||
{say: "напомни завтра в 3 заказать цветы",
|
||||
question: dialogue.SlotTime, attempt: 1, gap: whenAmbiguousHour,
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1, carries: "цветы"}},
|
||||
},
|
||||
end: endState{},
|
||||
},
|
||||
{
|
||||
// The hour is unambiguous and the day is still missing, so she asks.
|
||||
// Today being a valid reading is not the same as him saying it.
|
||||
name: "his second example: nine in the evening of which day",
|
||||
turns: []turn{
|
||||
{say: "напомни в 9 вечера разгрузить стиралку",
|
||||
question: dialogue.SlotTime, attempt: 1, gap: whenNoDay,
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1, carries: "стиралку"}},
|
||||
{say: "завтра", contains: []string{"21:00"}},
|
||||
},
|
||||
end: endState{reminders: []reminderWant{{payload: "стиралку", fireAt: "2026-08-01 21:00"}}},
|
||||
},
|
||||
{
|
||||
// All three answered in one breath, so she does not ask at all.
|
||||
name: "his third example: a full time commits",
|
||||
turns: []turn{
|
||||
{say: "напомни завтра в 15:00 заказать цветы", notContain: []string{"?"}},
|
||||
},
|
||||
end: endState{reminders: []reminderWant{{payload: "цветы", fireAt: "2026-08-01 15:00"}}},
|
||||
},
|
||||
{
|
||||
// An interval is one instant, so it answers the hour and the day
|
||||
// together. Confirmed by the owner: "через час is fine as is".
|
||||
name: "an interval commits without a question",
|
||||
turns: []turn{
|
||||
{say: "напомни через час позвонить маме", notContain: []string{"?"}},
|
||||
},
|
||||
end: endState{reminders: []reminderWant{{payload: "маме", fireAt: "2026-07-31 10:17"}}},
|
||||
},
|
||||
// V-579 turn 4: he named a day and no hour, and got the day at the
|
||||
// current minute. She has to ask instead, and the global check in
|
||||
// checkEnd refuses the invented minute for every row at once.
|
||||
{
|
||||
name: "a day with no hour is asked about, not taken from the clock",
|
||||
turns: []turn{
|
||||
{say: "напомни позвонить маме", question: dialogue.SlotTime, attempt: 1,
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1}},
|
||||
{say: "на завтра", question: dialogue.SlotTime, attempt: 2,
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 2}},
|
||||
},
|
||||
end: endState{},
|
||||
},
|
||||
|
||||
// ---- rows below carry the CORRECT expectation and fail today ----
|
||||
|
||||
// The owner's own sentence from V-577 shape 2, in his words. It needs
|
||||
// an engine that can route it: the hash embedder marks it note with
|
||||
// Clarify set, and a route she is not sure of is not evidence that he
|
||||
// stated anything. The row above is the same contract in words the
|
||||
// floor's deterministic fact parser reads.
|
||||
{
|
||||
name: "a note stated mid-flow is stored, not dropped",
|
||||
skip: "the offline floor cannot route «у меня новый ноутбук» confidently; needs the resident model",
|
||||
turns: []turn{
|
||||
{say: "напомни позвонить врачу", question: dialogue.SlotTime, attempt: 1,
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1, carries: "врачу"}},
|
||||
{say: "у меня новый ноутбук",
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1, carries: "врачу"}},
|
||||
},
|
||||
end: endState{notes: 1},
|
||||
},
|
||||
|
||||
// The owner's target transcript, V-561. He asks for a reminder, she asks
|
||||
// when, he asks something else entirely, and then comes back to her
|
||||
// question. On the box this created a reminder at 00:12 and never
|
||||
// answered Rome; on the offline floor the side question is recognised as
|
||||
// its own request and the flow is dropped instead, so the wrong reminder
|
||||
// is not made and the right one is not either.
|
||||
//
|
||||
// Both are the same defect: there is no suspend and resume. The correct
|
||||
// shape is the middle turn answered on its own and the parked question
|
||||
// still standing, on the same attempt — a side query is not a failed
|
||||
// answer and must not spend a retry.
|
||||
//
|
||||
// The skip came off with V-579. What held it was the parser, not the
|
||||
// arbitration: neither the stub nor the production one read "на 9",
|
||||
// because only "в" framed a spoken hour, and "на завтра" was completed
|
||||
// from the clock.
|
||||
//
|
||||
// Turn 3 now closes the flow, where the transcript has one more exchange
|
||||
// in it. That is the 12-hour question — the owner's turn 4 answers "на
|
||||
// 9" with "сейчас 15:23, на 9 сегодня вечером?" — and it is a decision of
|
||||
// its own, not one to invent here. Nine o'clock is read as nine and, at
|
||||
// 09:17, as tomorrow's, which is where the transcript ends up anyway.
|
||||
// Turn 4 then has nothing to answer and must not write anything.
|
||||
{
|
||||
name: "the owner's transcript from V-561",
|
||||
turns: []turn{
|
||||
{say: "напомни позвонить маме", question: dialogue.SlotTime, attempt: 1,
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1}},
|
||||
{say: "какая сейчас погода в Риме?",
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1, carries: "маме"}},
|
||||
// His words, unchanged. What changed under V-579 is that "на 9"
|
||||
// is a question and not a commit: nine could be either half of
|
||||
// the day, so she says the clock she is reading from and asks.
|
||||
// "на завтра." then answers the day and leaves the half open, so
|
||||
// she asks that one again.
|
||||
// Each ask names what the turn before it gave her (V-593). The
|
||||
// two asks about the half of the day are the same question and
|
||||
// must not be the same sentence: he answered between them, and a
|
||||
// reply with no trace of that reads as not having been heard.
|
||||
{say: "а, да, прости - на 9.", question: dialogue.SlotTime, attempt: 2, gap: whenAmbiguousHour, took: "а, да, прости - на 9.",
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 2}},
|
||||
{say: "на завтра.", question: dialogue.SlotTime, attempt: 3, gap: whenAmbiguousHour, took: "на завтра.",
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 3}},
|
||||
},
|
||||
end: endState{},
|
||||
},
|
||||
// The same shape said in words StubDateTimeParser reads. GREEN since
|
||||
// V-561. Same three claims: Rome is answered, the question survives the
|
||||
// side query on the same attempt, and the answer after it completes the
|
||||
// reminder he actually asked for.
|
||||
//
|
||||
// It sits under the "fail today" header because the row above it still
|
||||
// does. Do not re-skip it to tidy that up: this is the owner's
|
||||
// acceptance test in the only words the offline floor can read.
|
||||
{
|
||||
name: "nested question: a parked question, then one of his own",
|
||||
turns: []turn{
|
||||
{say: "напомни позвонить маме", question: dialogue.SlotTime, attempt: 1,
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1}},
|
||||
{say: "какая сейчас погода в Риме?",
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1, carries: "маме"}},
|
||||
{say: "в 11:00", question: dialogue.SlotTime, attempt: 2, gap: whenNoDay, took: "в 11:00",
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 2, carries: "маме"}},
|
||||
{say: "сегодня", contains: []string{"11:00"}},
|
||||
},
|
||||
end: endState{reminders: []reminderWant{{payload: "позвонить маме", fireAt: "2026-07-31 11:00"}}},
|
||||
},
|
||||
// A cancel is one of the five turn roles V-560 names, and today it is
|
||||
// none of them: "неважно" fills no slot and carries no request of its
|
||||
// own, so it reads as a failed answer and spends a retry. Two turns
|
||||
// later she is still asking about a reminder he called off.
|
||||
//
|
||||
// The row asserts what is knowable — nothing armed, nothing written, and
|
||||
// not another question — rather than her wording for it, which is not
|
||||
// written yet and is not this task's to invent.
|
||||
{
|
||||
name: "cancel: a parked question, then never mind",
|
||||
skip: "V-560: a cancel is scored as a failed answer, not as a cancel",
|
||||
turns: []turn{
|
||||
{say: "напомни позвонить маме", question: dialogue.SlotTime, attempt: 1,
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1}},
|
||||
{say: "неважно", noQuestion: true},
|
||||
},
|
||||
end: endState{},
|
||||
},
|
||||
// Order in runTurn is the whole arbitration (V-558), and this is what it
|
||||
// costs: the clarify answer is checked at step 3 and the repair marker at
|
||||
// step 4d, so while a question is parked no correction can be made. She
|
||||
// scores "нет, это была заметка" as a bad time answer and asks again.
|
||||
{
|
||||
name: "correction while a question is parked",
|
||||
skip: "V-560: clarify pre-empts the repair marker, so a correction cannot be spoken mid-flow",
|
||||
turns: []turn{
|
||||
{say: "добавь в задачи купить молоко", contains: []string{"купить молоко"}},
|
||||
{say: "напомни позвонить маме", question: dialogue.SlotTime, attempt: 1,
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1}},
|
||||
{say: "нет, это был вопрос", contains: []string{"поняла, это вопрос"},
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1, carries: "маме"}},
|
||||
},
|
||||
end: endState{tasks: []string{"купить молоко"}},
|
||||
},
|
||||
// A reminder said whole, in one breath, with the hour in it — and she
|
||||
// asks when. ReminderGrammar (stage0.go) builds its slots by hand and
|
||||
// never runs the extractor, so a stage-0 reminder carries no time
|
||||
// whatever the sentence says, and the clarify gate reads the gap as
|
||||
// real. It costs a turn on the commonest reminder shape there is.
|
||||
//
|
||||
// Hermetic despite the date parser: stage 0 calls no parser at all, so
|
||||
// this fails the same way with or without python dateparser installed.
|
||||
{
|
||||
name: "a reminder said whole is not asked about",
|
||||
skip: "V-562: a stage-0 decision never meets the extractor, so its slots are never validated",
|
||||
turns: []turn{
|
||||
{say: "напомни в 11:00 позвонить маме", contains: []string{"11:00"}, noQuestion: true},
|
||||
},
|
||||
end: endState{reminders: []reminderWant{{payload: "позвонить маме", fireAt: "2026-07-31 11:00"}}},
|
||||
},
|
||||
// The same gap on the repair path. A correction redoes the request
|
||||
// through finishClarified, which goes straight to applyAction — it never
|
||||
// passes the clarify gate — so a redo that lands short answers with the
|
||||
// parse error V-557 removed from the routing path: "не поняла, на когда
|
||||
// напомнить." She should ask, exactly as she does for a fresh reminder
|
||||
// with no time.
|
||||
{
|
||||
name: "a correction that lands short asks rather than failing",
|
||||
skip: "V-562: finishClarified skips the clarify gate, so a repaired decision is never checked for gaps",
|
||||
turns: []turn{
|
||||
{say: "добавь в задачи купить молоко", contains: []string{"купить молоко"}},
|
||||
{say: "нет, это было напоминание", contains: []string{"поняла, это напоминание"},
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1}},
|
||||
},
|
||||
end: endState{tasks: []string{"купить молоко"}},
|
||||
},
|
||||
}
|
||||
}
|
||||
+151
-108
@@ -12,6 +12,7 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
hexisclient "github.com/kami/hexis/pkg/client"
|
||||
@@ -42,28 +43,100 @@ const ecosystemAPIVersion = "v1"
|
||||
// anonymous HTTP client.
|
||||
const mavenRequester = "maven"
|
||||
|
||||
// setEcosystemHeaders stamps the version, requester, auth and correlation
|
||||
// headers common to every outgoing ecosystem request. token may be empty,
|
||||
// which means the transport itself is trusted (loopback or unix socket).
|
||||
// ecosystemHTTP is the JSON transport every ecosystem client shares: one base
|
||||
// URL, one bearer token, and the header set the contract requires on each
|
||||
// request. Nexus and Praxis differ only in the service name and the version
|
||||
// header, so both embed this rather than repeating build, send and classify.
|
||||
type ecosystemHTTP struct {
|
||||
service string // "nexus", "praxis" — the name errors and traces carry
|
||||
versionHeader string
|
||||
baseURL string
|
||||
token string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func newEcosystemHTTP(service, versionHeader, baseURL string) ecosystemHTTP {
|
||||
return ecosystemHTTP{
|
||||
service: service,
|
||||
versionHeader: versionHeader,
|
||||
baseURL: baseURL,
|
||||
httpClient: &http.Client{Timeout: 10 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// setHeaders stamps the version, requester, auth and correlation headers common
|
||||
// to every outgoing ecosystem request. The token may be empty, which means the
|
||||
// transport itself is trusted (loopback or unix socket).
|
||||
//
|
||||
// The correlation ID is read from the context and never minted here. Minting
|
||||
// one per request sent the far side an ID that existed nowhere on this side,
|
||||
// and gave a single multi-hop action as many unrelated IDs as it made calls.
|
||||
// Callers that start an action assign the ID once (handleHexisAct,
|
||||
// The correlation ID is read from the request's own context and never minted
|
||||
// here. Minting one per request sent the far side an ID that existed nowhere on
|
||||
// this side, and gave a single multi-hop action as many unrelated IDs as it
|
||||
// made calls. Callers that start an action assign the ID once (handleHexisAct,
|
||||
// handlePraxisAct, resolveEntityReference) and every hop inherits it.
|
||||
func setEcosystemHeaders(req *http.Request, ctx context.Context, versionHeader, token string) {
|
||||
func (t *ecosystemHTTP) setHeaders(req *http.Request) {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set(versionHeader, ecosystemAPIVersion)
|
||||
req.Header.Set(t.versionHeader, ecosystemAPIVersion)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("X-Requested-By", mavenRequester)
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
if t.token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+t.token)
|
||||
}
|
||||
if id := correlationIDFromCtx(ctx); id != "" {
|
||||
if id := correlationIDFromCtx(req.Context()); id != "" {
|
||||
req.Header.Set("X-Correlation-ID", id)
|
||||
}
|
||||
}
|
||||
|
||||
// call sends one request and decodes the JSON answer into out, which may be nil
|
||||
// when the body carries nothing worth reading. op is the logical operation name
|
||||
// for errors and traces: the path carries the query string, and after entity
|
||||
// scoping that means an entity id in every log line built from the error, next
|
||||
// to a trace that redacts far less than that.
|
||||
//
|
||||
// Every failure is an *ecosystemError, including the transport and decode ones.
|
||||
// Some of these paths mutate remote state, and the question worth answering
|
||||
// afterwards is whether the call never left or was refused.
|
||||
func (t *ecosystemHTTP) call(ctx context.Context, method, op, path string, payload, out any) error {
|
||||
var body io.Reader
|
||||
if payload != nil {
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return &ecosystemError{Service: t.service, Op: op, Err: err}
|
||||
}
|
||||
body = bytes.NewReader(data)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, t.baseURL+path, body)
|
||||
if err != nil {
|
||||
return &ecosystemError{Service: t.service, Op: op, Err: err}
|
||||
}
|
||||
t.setHeaders(req)
|
||||
|
||||
resp, err := t.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return &ecosystemError{Service: t.service, Op: op, Err: err}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
return httpError(t.service, op, resp.StatusCode)
|
||||
}
|
||||
if out == nil {
|
||||
return nil
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
|
||||
return &ecosystemError{Service: t.service, Op: op, Status: resp.StatusCode, Err: err}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// getJSON performs a GET and decodes the JSON body into out.
|
||||
func (t *ecosystemHTTP) getJSON(ctx context.Context, op, path string, out any) error {
|
||||
return t.call(ctx, http.MethodGet, op, path, nil, out)
|
||||
}
|
||||
|
||||
// postJSON posts a JSON payload and decodes the JSON answer into out.
|
||||
func (t *ecosystemHTTP) postJSON(ctx context.Context, op, path string, payload, out any) error {
|
||||
return t.call(ctx, http.MethodPost, op, path, payload, out)
|
||||
}
|
||||
|
||||
// ecosystemError is the typed failure every ecosystem client returns, so
|
||||
// callers can tell a transport failure from a refusal from a contract
|
||||
// mismatch without matching on message text. The distinction matters:
|
||||
@@ -107,17 +180,60 @@ func httpError(service, op string, status int) *ecosystemError {
|
||||
}
|
||||
}
|
||||
|
||||
// hexisStatusTexts maps the http.StatusText spelling back to its code, for the
|
||||
// failure statuses a Hexis call can plausibly answer with. It is the inverse of
|
||||
// what the vendored client threw away.
|
||||
var hexisStatusTexts = func() map[string]int {
|
||||
codes := []int{
|
||||
http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden,
|
||||
http.StatusNotFound, http.StatusMethodNotAllowed, http.StatusNotAcceptable,
|
||||
http.StatusRequestTimeout, http.StatusConflict, http.StatusGone,
|
||||
http.StatusUnprocessableEntity, http.StatusUpgradeRequired,
|
||||
http.StatusTooManyRequests, http.StatusInternalServerError,
|
||||
http.StatusNotImplemented, http.StatusBadGateway,
|
||||
http.StatusServiceUnavailable, http.StatusGatewayTimeout,
|
||||
}
|
||||
m := make(map[string]int, len(codes))
|
||||
for _, c := range codes {
|
||||
m[http.StatusText(c)] = c
|
||||
}
|
||||
return m
|
||||
}()
|
||||
|
||||
// hexisError re-wraps an error from the vendored Hexis client as an
|
||||
// *ecosystemError, so a Hexis failure classifies the same way a Nexus or Praxis
|
||||
// one does and ecosystemGap can tell a refused credential from an outage.
|
||||
//
|
||||
// This is a boundary adapter and it is not the fix anyone would choose. The
|
||||
// Hexis client lives in another repository and returns
|
||||
// fmt.Errorf("%s: %s", http.StatusText(status), body) for every status at or
|
||||
// above 400, so the status text is the only signal that survives — the correct
|
||||
// fix is a typed error carrying the code, and Maven cannot land it unilaterally
|
||||
// (Vikunja #587, docs/plans/20-two-artifacts-and-neither-is-spring.md). Parsing
|
||||
// here is bounded: the message's first colon-delimited segment is the status
|
||||
// text verbatim, no status text contains a colon, and anything unrecognised —
|
||||
// "do request: ...", "create request: ..." — is a transport failure and is left
|
||||
// at status 0, which is exactly what Unreachable() means.
|
||||
func hexisError(op string, err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
var ee *ecosystemError
|
||||
if errors.As(err, &ee) {
|
||||
return err
|
||||
}
|
||||
head, _, _ := strings.Cut(err.Error(), ": ")
|
||||
return &ecosystemError{
|
||||
Service: "hexis", Op: op, Status: hexisStatusTexts[head], Err: err,
|
||||
}
|
||||
}
|
||||
|
||||
type nexusClient struct {
|
||||
baseURL string
|
||||
token string
|
||||
httpClient *http.Client
|
||||
ecosystemHTTP
|
||||
}
|
||||
|
||||
func newNexusClient(url string) *nexusClient {
|
||||
return &nexusClient{
|
||||
baseURL: url,
|
||||
httpClient: &http.Client{Timeout: 10 * time.Second},
|
||||
}
|
||||
return &nexusClient{newEcosystemHTTP("nexus", "X-Nexus-Version", url)}
|
||||
}
|
||||
|
||||
// withToken sets the bearer token sent on every request. Returns the client so
|
||||
@@ -176,62 +292,26 @@ func (c *nexusClient) Resolve(ctx context.Context, query string, types []string)
|
||||
body["types"] = types
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(body)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/api/v1/resolve", bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
setEcosystemHeaders(req, ctx, "X-Nexus-Version", c.token)
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, &ecosystemError{Service: "nexus", Op: "resolve", Err: err}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, httpError("nexus", "resolve", resp.StatusCode)
|
||||
}
|
||||
|
||||
var result nexusResolveResult
|
||||
if err := json.Unmarshal(bodyBytes, &result); err != nil {
|
||||
return nil, &ecosystemError{Service: "nexus", Op: "resolve", Status: resp.StatusCode, Err: err}
|
||||
if err := c.postJSON(ctx, "resolve", "/api/v1/resolve", body, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func (c *nexusClient) Health(ctx context.Context) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/health", nil)
|
||||
if err != nil {
|
||||
return &ecosystemError{Service: "nexus", Op: "health", Err: err}
|
||||
}
|
||||
setEcosystemHeaders(req, ctx, "X-Nexus-Version", c.token)
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return &ecosystemError{Service: "nexus", Op: "health", Err: err}
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
return httpError("nexus", "health", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
return c.getJSON(ctx, "health", "/health", nil)
|
||||
}
|
||||
|
||||
// praxisClient talks to the Praxis HTTP tools API. Maven must not open Praxis's
|
||||
// SQLite store directly (ecosystem invariant: no component reads another's DB),
|
||||
// so attention/changes/lifecycle all go over this HTTP contract against praxisd.
|
||||
type praxisClient struct {
|
||||
baseURL string
|
||||
token string
|
||||
httpClient *http.Client
|
||||
ecosystemHTTP
|
||||
}
|
||||
|
||||
func newPraxisClient(url string) *praxisClient {
|
||||
return &praxisClient{
|
||||
baseURL: url,
|
||||
httpClient: &http.Client{Timeout: 10 * time.Second},
|
||||
}
|
||||
return &praxisClient{newEcosystemHTTP("praxis", "X-Praxis-Version", url)}
|
||||
}
|
||||
|
||||
func (c *praxisClient) withToken(token string) *praxisClient {
|
||||
@@ -239,30 +319,6 @@ func (c *praxisClient) withToken(token string) *praxisClient {
|
||||
return c
|
||||
}
|
||||
|
||||
// getJSON performs a GET and decodes the JSON body into out. op is the logical
|
||||
// operation name for errors and traces: the path carries the query string, and
|
||||
// after entity scoping that means an entity id in every log line built from the
|
||||
// error, next to a trace that redacts far less than that.
|
||||
func (c *praxisClient) getJSON(ctx context.Context, op, path string, out any) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
setEcosystemHeaders(req, ctx, "X-Praxis-Version", c.token)
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return &ecosystemError{Service: "praxis", Op: op, Err: err}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
return httpError("praxis", op, resp.StatusCode)
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
|
||||
return &ecosystemError{Service: "praxis", Op: op, Status: resp.StatusCode, Err: err}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// praxisAttention — an attention response in either of the two shapes Praxis
|
||||
// may send (Vikunja #540).
|
||||
//
|
||||
@@ -376,31 +432,14 @@ type praxisItem struct {
|
||||
// postItemAction posts {"item_id": id} to a Praxis tools lifecycle endpoint
|
||||
// and decodes the resulting item. Shared by Surface/Acknowledge/Resolve/Ignore.
|
||||
func (c *praxisClient) postItemAction(ctx context.Context, op, path, itemID string) (*praxisItem, error) {
|
||||
return c.postJSON(ctx, op, path, map[string]any{"item_id": itemID})
|
||||
return c.postItem(ctx, op, path, map[string]any{"item_id": itemID})
|
||||
}
|
||||
|
||||
// postJSON posts a body to a Praxis lifecycle endpoint and decodes the item.
|
||||
// Every failure is a *ecosystemError, including the transport and decode ones:
|
||||
// these are the paths that mutate remote state, and the question worth
|
||||
// answering afterwards is whether the call never left or was refused.
|
||||
func (c *praxisClient) postJSON(ctx context.Context, op, path string, payload map[string]any) (*praxisItem, error) {
|
||||
body, _ := json.Marshal(payload)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, &ecosystemError{Service: "praxis", Op: op, Err: err}
|
||||
}
|
||||
setEcosystemHeaders(req, ctx, "X-Praxis-Version", c.token)
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, &ecosystemError{Service: "praxis", Op: op, Err: err}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, httpError("praxis", op, resp.StatusCode)
|
||||
}
|
||||
// postItem posts a body to a Praxis lifecycle endpoint and decodes the item.
|
||||
func (c *praxisClient) postItem(ctx context.Context, op, path string, payload map[string]any) (*praxisItem, error) {
|
||||
var out praxisItem
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
return nil, &ecosystemError{Service: "praxis", Op: op, Status: resp.StatusCode, Err: err}
|
||||
if err := c.postJSON(ctx, op, path, payload, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
@@ -425,7 +464,7 @@ func (c *praxisClient) Ignore(ctx context.Context, itemID string) (*praxisItem,
|
||||
}
|
||||
|
||||
func (c *praxisClient) Pin(ctx context.Context, itemID string, pinned bool) (*praxisItem, error) {
|
||||
return c.postJSON(ctx, "pin", "/api/v1/tools/pin", map[string]any{"item_id": itemID, "pinned": pinned})
|
||||
return c.postItem(ctx, "pin", "/api/v1/tools/pin", map[string]any{"item_id": itemID, "pinned": pinned})
|
||||
}
|
||||
|
||||
func (c *praxisClient) GetItem(ctx context.Context, itemID string) (*praxisItem, error) {
|
||||
@@ -540,6 +579,7 @@ func (w *ecosystemWiring) discoverCapabilities(ctx context.Context, entityID str
|
||||
}
|
||||
caps, err := w.hexis.Capabilities(ctx, entityID)
|
||||
if err != nil {
|
||||
err = hexisError("capabilities", err)
|
||||
log.Printf("ecosystem: hexis capabilities error: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
@@ -567,7 +607,10 @@ func (w *ecosystemWiring) executeCapability(ctx context.Context, capabilityID, t
|
||||
|
||||
exec, err := w.hexis.Execute(ctx, req)
|
||||
if err != nil {
|
||||
return correlationID, fmt.Errorf("execute: %w", err)
|
||||
// A classified dependency failure. The two returns below are NOT: an
|
||||
// execution that ran and failed is the command failing, not Hexis
|
||||
// degrading, and it keeps its plain error so the caller says so.
|
||||
return correlationID, hexisError("execute", err)
|
||||
}
|
||||
if exec.Status == "succeeded" {
|
||||
return correlationID, nil
|
||||
|
||||
@@ -22,13 +22,25 @@ import (
|
||||
// already knows which one it was talking to — it records the same name in the
|
||||
// trace (Vikunja #521).
|
||||
const (
|
||||
serviceNexus = "Nexus"
|
||||
serviceHexis = "Hexis"
|
||||
serviceNexus = "Nexus"
|
||||
servicePraxis = "Praxis"
|
||||
serviceHexis = "Hexis"
|
||||
)
|
||||
|
||||
// serviceVars — the one-key map the eco_down and eco_denied lines take.
|
||||
func serviceVars(name string) map[string]string { return map[string]string{"name": name} }
|
||||
|
||||
// ecosystemGap names the service that failed. A rejected credential gets its
|
||||
// own line, because a wrong token looks exactly like an outage to him and
|
||||
// "try again" is advice that will never work. Every degrade path reads through
|
||||
// here, so all of them name the service and none of them guesses instead.
|
||||
func ecosystemGap(service string, err error) string {
|
||||
if unauthorizedEcosystemError(err) {
|
||||
return phraser.A(phraser.EcoDenied, serviceVars(service))
|
||||
}
|
||||
return phraser.A(phraser.EcoDown, serviceVars(service))
|
||||
}
|
||||
|
||||
// praxisCapability is one arm of the Praxis act dispatch. This is an interface
|
||||
// rather than a map[string]func because each arm carries its own state: the
|
||||
// verb aliases it answers to, the trace name it records, and its own reply
|
||||
@@ -130,7 +142,13 @@ type praxisItemAction struct {
|
||||
verbs []string
|
||||
ask string // reply when no item id was given
|
||||
op string // trace + log name of the operation
|
||||
failure string // reply when the Praxis call errors
|
||||
// failure is the first half of the reply when the Praxis call errors: which
|
||||
// operation did not happen. ecosystemGap supplies the second half, which
|
||||
// names Praxis and splits a refused token from an outage — those two used to
|
||||
// produce the identical sentence and neither said "Praxis" (Vikunja #588).
|
||||
// The verb is kept alongside the service name because the trace is the only
|
||||
// other place it exists, and he is not reading the trace.
|
||||
failure string
|
||||
success string
|
||||
call func(ctx context.Context, px *praxisClient, id string) error
|
||||
}
|
||||
@@ -147,7 +165,7 @@ func (a praxisItemAction) handle(ctx context.Context, h *reactiveHandler, px *pr
|
||||
log.Printf("ecosystem: praxis %s %s: %v", a.op, id, err)
|
||||
h.recordEcosystemTrace(ctx, "praxis", a.op, traceStatusForError(err), started,
|
||||
mergeFields(traceErrorFields(err), map[string]any{"item_id": id}))
|
||||
return a.failure
|
||||
return a.failure + " " + ecosystemGap(servicePraxis, err)
|
||||
}
|
||||
h.recordPraxisTrace(ctx, a.op, started, map[string]any{"item_id": id})
|
||||
return a.success
|
||||
@@ -199,16 +217,10 @@ func (listAttentionCapability) handle(ctx context.Context, h *reactiveHandler, p
|
||||
}
|
||||
parts = append(parts, s)
|
||||
|
||||
// Speaking an item surfaces it, it does not acknowledge it
|
||||
// (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).
|
||||
// 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).
|
||||
if id := surfaceSpoken(ctx, px, item); id != "" {
|
||||
spoken = append(spoken, id)
|
||||
if _, err := px.Surface(ctx, id); err != nil {
|
||||
log.Printf("ecosystem: praxis surface %s: %v", id, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
h.rememberSurfaced(spoken)
|
||||
@@ -298,12 +310,7 @@ func (entityAttentionCapability) handle(ctx context.Context, h *reactiveHandler,
|
||||
// trace gets. A trace that stores a rune count next to a log line
|
||||
// storing the runes is not redacted at all.
|
||||
log.Printf("ecosystem: entity attention resolve %s: %v", redactSubject(subject), err)
|
||||
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceStatusForError(err), started,
|
||||
mergeFields(traceErrorFields(err), map[string]any{"subject": redactSubject(subject)}))
|
||||
if unauthorizedEcosystemError(err) {
|
||||
return phraser.A(phraser.EcoDenied, serviceVars(serviceNexus))
|
||||
}
|
||||
return phraser.A(phraser.EcoDown, serviceVars(serviceNexus))
|
||||
return h.nexusResolveFailed(ctx, subject, started, err)
|
||||
}
|
||||
if len(ambiguous) > 0 {
|
||||
return phraser.A(phraser.EcoAmbiguous, map[string]string{"items": strings.Join(ambiguous, ", ")})
|
||||
@@ -339,19 +346,24 @@ func (entityAttentionCapability) handle(ctx context.Context, h *reactiveHandler,
|
||||
})
|
||||
|
||||
var parts []string
|
||||
var spoken []string
|
||||
for _, item := range items {
|
||||
title, _ := item["title"].(string)
|
||||
if title == "" {
|
||||
continue
|
||||
}
|
||||
parts = append(parts, title)
|
||||
// Same surfaced != acknowledged rule as the unscoped digest.
|
||||
if id, ok := item["id"].(string); ok && id != "" {
|
||||
if _, err := px.Surface(ctx, id); err != nil {
|
||||
log.Printf("ecosystem: praxis surface %s: %v", id, err)
|
||||
}
|
||||
if id := surfaceSpoken(ctx, px, item); id != "" {
|
||||
spoken = append(spoken, id)
|
||||
}
|
||||
}
|
||||
// The scoped digest is a list she read out, so it replaces the positional
|
||||
// memory exactly as the unscoped one does. It used to surface these items
|
||||
// and remember none of them, which left the previous digest live: "отметь
|
||||
// второй как сделанное" then indexed into a list he had not just heard and
|
||||
// transitioned somebody else's item (docs/ecosystem.md — a wrong guess here
|
||||
// transitions the wrong item).
|
||||
h.rememberSurfaced(spoken)
|
||||
if known := h.localFactsForEntity(ctx, entityID); known != "" {
|
||||
parts = append(parts, known)
|
||||
}
|
||||
@@ -366,6 +378,22 @@ func (entityAttentionCapability) handle(ctx context.Context, h *reactiveHandler,
|
||||
return phraser.A(phraser.AttentionListEntity, map[string]string{"name": displayName, "items": strings.Join(parts, "; ")})
|
||||
}
|
||||
|
||||
// surfaceSpoken marks an item she just read out as surfaced. Speaking an item
|
||||
// surfaces it, it does not acknowledge it (ECOSYSTEM-SPEC.md §2.3: surfaced !=
|
||||
// acknowledged), so this calls Surface and nothing else. Best effort: a failed
|
||||
// surface call must not block delivering the digest. Returns the item id, or ""
|
||||
// when the item carried none.
|
||||
func surfaceSpoken(ctx context.Context, px *praxisClient, item map[string]any) string {
|
||||
id, _ := item["id"].(string)
|
||||
if id == "" {
|
||||
return ""
|
||||
}
|
||||
if _, err := px.Surface(ctx, id); err != nil {
|
||||
log.Printf("ecosystem: praxis surface %s: %v", id, err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// scopedToEntity drops items that carry an entity_id other than the one asked
|
||||
// about, and reports whether the response can be trusted as scoped at all. An
|
||||
// item without an entity_id is kept only when at least one sibling carries the
|
||||
@@ -474,6 +502,14 @@ func traceStatusForError(err error) string {
|
||||
return traceFailed
|
||||
}
|
||||
|
||||
// nexusResolveFailed records a resolve that failed and returns the named gap.
|
||||
// The subject is his words, so the trace keeps a rune count and not the runes.
|
||||
func (h *reactiveHandler) nexusResolveFailed(ctx context.Context, subject string, started time.Time, err error) string {
|
||||
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceStatusForError(err), started,
|
||||
mergeFields(traceErrorFields(err), map[string]any{"subject": redactSubject(subject)}))
|
||||
return ecosystemGap(serviceNexus, err)
|
||||
}
|
||||
|
||||
// redactSubject reduces a user utterance to something safe to persist in a
|
||||
// trace: its length only. Traces are diagnostics, and his words are not
|
||||
// diagnostics — the correlation ID is what ties a trace to the turn.
|
||||
@@ -532,26 +568,31 @@ func unauthorizedEcosystemError(err error) bool {
|
||||
return errors.As(err, &ee) && ee.Unauthorized()
|
||||
}
|
||||
|
||||
// isEcosystemError reports a failure that belongs to the service rather than to
|
||||
// what was asked of it: a call that never landed, or one the far side refused.
|
||||
// It separates "Hexis is down" from "the restart failed".
|
||||
func isEcosystemError(err error) bool {
|
||||
var ee *ecosystemError
|
||||
return errors.As(err, &ee)
|
||||
}
|
||||
|
||||
// traceErrorFields describes an ecosystemError for a trace without leaking the
|
||||
// payload: the HTTP status and the failure class, nothing else.
|
||||
func traceErrorFields(err error) map[string]any {
|
||||
fields := map[string]any{}
|
||||
fields := map[string]any{"class": "error"}
|
||||
var ee *ecosystemError
|
||||
if errors.As(err, &ee) {
|
||||
fields["http_status"] = ee.Status
|
||||
switch {
|
||||
case ee.Unauthorized():
|
||||
fields["class"] = "unauthorized"
|
||||
case ee.ContractMismatch():
|
||||
fields["class"] = "contract_mismatch"
|
||||
case ee.Unreachable():
|
||||
fields["class"] = "unreachable"
|
||||
default:
|
||||
fields["class"] = "error"
|
||||
}
|
||||
if !errors.As(err, &ee) {
|
||||
return fields
|
||||
}
|
||||
fields["class"] = "error"
|
||||
fields["http_status"] = ee.Status
|
||||
switch {
|
||||
case ee.Unauthorized():
|
||||
fields["class"] = "unauthorized"
|
||||
case ee.ContractMismatch():
|
||||
fields["class"] = "contract_mismatch"
|
||||
case ee.Unreachable():
|
||||
fields["class"] = "unreachable"
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
@@ -636,16 +677,11 @@ func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decisio
|
||||
res := h.resolveEntityCandidates(ctx, entityReferences(dec))
|
||||
subject, entityID, displayName, ambiguous, err := res.subject, res.entityID, res.displayName, res.ambiguous, res.err
|
||||
if err != nil {
|
||||
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceStatusForError(err), started,
|
||||
mergeFields(traceErrorFields(err), map[string]any{"subject": redactSubject(subject)}))
|
||||
if unauthorizedEcosystemError(err) {
|
||||
return phraser.A(phraser.EcoDenied, serviceVars(serviceNexus))
|
||||
}
|
||||
// A genuine Nexus dependency failure, not "no such entity" — stop here
|
||||
// and report degradation rather than silently falling through to the
|
||||
// local command executor (ECOSYSTEM-SPEC.md: services degrade
|
||||
// independently, never a silent all-clear).
|
||||
return phraser.A(phraser.EcoDown, serviceVars(serviceNexus))
|
||||
return h.nexusResolveFailed(ctx, subject, started, err)
|
||||
}
|
||||
if len(ambiguous) > 0 {
|
||||
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceAmbig, started,
|
||||
@@ -668,10 +704,7 @@ func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decisio
|
||||
if err != nil {
|
||||
h.recordEcosystemTrace(ctx, "hexis", "capabilities", traceStatusForError(err), discovered,
|
||||
mergeFields(traceErrorFields(err), map[string]any{"entity_id": entityID}))
|
||||
if unauthorizedEcosystemError(err) {
|
||||
return phraser.A(phraser.EcoDenied, serviceVars(serviceHexis))
|
||||
}
|
||||
return phraser.A(phraser.EcoDown, serviceVars(serviceHexis))
|
||||
return ecosystemGap(serviceHexis, err)
|
||||
}
|
||||
h.recordEcosystemTrace(ctx, "hexis", "capabilities", traceOK, discovered,
|
||||
map[string]any{"entity_id": entityID, "count": len(caps)})
|
||||
@@ -745,6 +778,9 @@ func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decisio
|
||||
entityID: entityID,
|
||||
displayName: displayName,
|
||||
expiry: h.now().Add(confirmTTL),
|
||||
// This action's id, so the execution the confirm authorises is
|
||||
// joined to the resolve and the discovery that proposed it.
|
||||
correlationID: correlationIDFromCtx(ctx),
|
||||
}
|
||||
h.mu.Unlock()
|
||||
h.recordEcosystemTrace(ctx, "hexis", "confirmation", tracePending, started,
|
||||
@@ -769,6 +805,14 @@ func (h *reactiveHandler) execHexis(ctx context.Context, capID, capName, entityI
|
||||
mergeFields(traceErrorFields(err), map[string]any{
|
||||
"entity_id": entityID, "capability": capName, "causation_id": causationID,
|
||||
}))
|
||||
// Hexis never answering, or answering "no", is a gap in Hexis and is
|
||||
// named as one — a refused token said "не получилось выполнить команду"
|
||||
// here and sent him to debug a capability that was never reached
|
||||
// (Vikunja #587). An execution that genuinely ran and failed is not an
|
||||
// ecosystemError and keeps the command-level line.
|
||||
if isEcosystemError(err) {
|
||||
return ecosystemGap(serviceHexis, err)
|
||||
}
|
||||
return phraser.A(phraser.ActFailEntity, map[string]string{"name": displayName})
|
||||
}
|
||||
// One record per hop: the second write this used to make said the same
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
)
|
||||
|
||||
// A refused credential and an outage are different answers, and on the Hexis
|
||||
// path only one of them used to be said. These tests pin the difference at both
|
||||
// Hexis sites: the discovery hop and the execute hop (Vikunja #587). The Praxis
|
||||
// half of the same defect is in praxis_gap_test.go.
|
||||
//
|
||||
// unreachableURL is a port nothing listens on, which is what "the service is
|
||||
// down" looks like from inside a call: the connection is refused, no HTTP
|
||||
// answer is ever produced, and ecosystemError.Unreachable() is true.
|
||||
const unreachableURL = "http://127.0.0.1:1"
|
||||
|
||||
func denied(service, reply string) bool {
|
||||
return phraser.IsA(phraser.EcoDenied, serviceVars(service), reply)
|
||||
}
|
||||
|
||||
func down(service, reply string) bool {
|
||||
return phraser.IsA(phraser.EcoDown, serviceVars(service), reply)
|
||||
}
|
||||
|
||||
// hexisGapHandler wires a handler whose Nexus resolves cleanly and whose Hexis
|
||||
// is the caller's to break. hexisURL is taken separately so a test can point it
|
||||
// at a dead port.
|
||||
func hexisGapHandler(t *testing.T, nexusURL, hexisURL string) *reactiveHandler {
|
||||
t.Helper()
|
||||
st := newTestStore(t)
|
||||
now := time.Now()
|
||||
return &reactiveHandler{
|
||||
api: ipc.NewStoreAPI(st),
|
||||
dataStore: st,
|
||||
now: func() time.Time { return now },
|
||||
ecosystem: stubEcosystem(nexusURL, hexisURL),
|
||||
}
|
||||
}
|
||||
|
||||
// TestHexisDiscovery401IsDeniedNotDown — the discovery hop.
|
||||
//
|
||||
// The vendored Hexis client returns a plain fmt.Errorf for every status at or
|
||||
// above 400, so errors.As for *ecosystemError never matched and every failure
|
||||
// fell through to the outage line. "Hexis is down" for a rejected token sends
|
||||
// him to inspect a service that is running fine.
|
||||
func TestHexisDiscovery401IsDeniedNotDown(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", muzickIndexer, "service"))
|
||||
caps := fixtureHexisCapabilities(map[string]any{"id": "cap_restart", "name": "restart", "read_only": true})
|
||||
hexis := newFakeHexis(t, caps, fixtureHexisExecuted("exec_1", "succeeded"))
|
||||
h := hexisGapHandler(t, nexus.URL, hexis.URL)
|
||||
|
||||
hexis.SetFault(401)
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||
if !denied(serviceHexis, reply) {
|
||||
t.Fatalf("401 from hexis discovery: got %q, want the denied line naming Hexis", reply)
|
||||
}
|
||||
if !strings.Contains(reply, serviceHexis) {
|
||||
t.Errorf("reply does not name Hexis: %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHexisDiscoveryOutageIsDownNotDenied — the other half of the same fork.
|
||||
// Without this the fix could pass by calling everything a refused credential.
|
||||
func TestHexisDiscoveryOutageIsDownNotDenied(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", muzickIndexer, "service"))
|
||||
h := hexisGapHandler(t, nexus.URL, unreachableURL)
|
||||
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||
if !down(serviceHexis, reply) {
|
||||
t.Fatalf("connection refused from hexis: got %q, want the outage line naming Hexis", reply)
|
||||
}
|
||||
if denied(serviceHexis, reply) {
|
||||
t.Error("an outage must not be reported as a refused credential")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHexisExecute401IsDeniedNotCommandFailure — the execute hop, which did not
|
||||
// consult ecosystemGap at all and named neither the service nor the cause.
|
||||
func TestHexisExecute401IsDeniedNotCommandFailure(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", muzickIndexer, "service"))
|
||||
caps := fixtureHexisCapabilities(map[string]any{"id": "cap_restart", "name": "restart", "read_only": true})
|
||||
hexis := newFakeHexis(t, caps, fixtureHexisExecuted("exec_1", "succeeded"))
|
||||
h := hexisGapHandler(t, nexus.URL, hexis.URL)
|
||||
|
||||
// Discovery stays healthy; only the execute endpoint refuses. A blanket
|
||||
// fault would never reach the site under test.
|
||||
hexis.SetRouteFault("/api/v1/execute", 401)
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||
if !denied(serviceHexis, reply) {
|
||||
t.Fatalf("401 from hexis execute: got %q, want the denied line naming Hexis", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHexisExecuteOutageIsDown — same site, the other classification.
|
||||
//
|
||||
// Discovery and execution share one base URL, so the outage has to be scoped to
|
||||
// the execute endpoint rather than to the server: it answers capabilities
|
||||
// normally and drops the connection on execute, which is what the client sees
|
||||
// when the far side dies mid-call. That produces no HTTP status at all, which is
|
||||
// what Unreachable() means.
|
||||
func TestHexisExecuteOutageIsDown(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", muzickIndexer, "service"))
|
||||
caps := fixtureHexisCapabilities(map[string]any{"id": "cap_restart", "name": "restart", "read_only": true})
|
||||
hexis := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/api/v1/execute" {
|
||||
conn, _, err := w.(http.Hijacker).Hijack()
|
||||
if err != nil {
|
||||
t.Errorf("hijack: %v", err)
|
||||
return
|
||||
}
|
||||
conn.Close()
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(caps))
|
||||
}))
|
||||
t.Cleanup(hexis.Close)
|
||||
h := hexisGapHandler(t, nexus.URL, hexis.URL)
|
||||
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||
if !down(serviceHexis, reply) {
|
||||
t.Fatalf("dropped connection on hexis execute: got %q, want the outage line", reply)
|
||||
}
|
||||
if denied(serviceHexis, reply) {
|
||||
t.Error("an outage must not be reported as a refused credential")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHexisExecutionFailedStaysCommandFailure — the boundary of the fix. Hexis
|
||||
// answering 200 with a failed execution is the command failing, not Hexis
|
||||
// degrading, and it must keep the command-level line rather than accusing a
|
||||
// healthy service of being down.
|
||||
func TestHexisExecutionFailedStaysCommandFailure(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", muzickIndexer, "service"))
|
||||
caps := fixtureHexisCapabilities(map[string]any{"id": "cap_restart", "name": "restart", "read_only": true})
|
||||
hexis := newFakeHexis(t, caps, fixtureHexisExecutionFailed("exec_1", "unit refused to start"))
|
||||
h := hexisGapHandler(t, nexus.URL, hexis.URL)
|
||||
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||
if down(serviceHexis, reply) || denied(serviceHexis, reply) {
|
||||
t.Fatalf("a failed execution must not be reported as an ecosystem gap, got %q", reply)
|
||||
}
|
||||
if !phraser.IsA(phraser.ActFailEntity, map[string]string{"name": muzickIndexer}, reply) {
|
||||
t.Fatalf("want the command-failure line, got %q", reply)
|
||||
}
|
||||
}
|
||||
@@ -115,3 +115,83 @@ func TestFakeNexus_FaultInjectionThenRecovery(t *testing.T) {
|
||||
t.Fatalf("expected success once nexus recovers, got %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPraxisEntityAttention_RemembersWhatItReadOut: the scoped digest is a list
|
||||
// she read out, so a positional follow-up must land on one of ITS items. It
|
||||
// surfaced them and remembered none, which left the previous digest live and
|
||||
// sent "отметь второй" at somebody else's item.
|
||||
func TestPraxisEntityAttention_RemembersWhatItReadOut(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"))
|
||||
scoped := fixturePraxisAttentionScoped("ent_muzick",
|
||||
map[string]any{"id": "item_scoped_1", "title": "indexer wedged"})
|
||||
praxis := newFakePraxis(t, scoped)
|
||||
h := ecoHandler(t, nexus, praxis, nil)
|
||||
|
||||
// A digest from an earlier turn, still the positional memory.
|
||||
h.rememberSurfaced([]string{"item_stale"})
|
||||
|
||||
reply := h.handlePraxisAct(ctx, router.Decision{
|
||||
Intent: router.IntentAct,
|
||||
Slots: router.Slots{Fn: "entity_attention", HasFn: true, Value: "muzick indexer"},
|
||||
})
|
||||
if !strings.Contains(reply, "indexer wedged") {
|
||||
t.Fatalf("expected the scoped item to be read out, got %q", reply)
|
||||
}
|
||||
|
||||
h.mu.Lock()
|
||||
surfaced := append([]string(nil), h.surfacedItems...)
|
||||
h.mu.Unlock()
|
||||
if len(surfaced) != 1 || surfaced[0] != "item_scoped_1" {
|
||||
t.Fatalf("scoped digest must replace the positional memory, got %v", surfaced)
|
||||
}
|
||||
|
||||
// The follow-up resolves against what he just heard, not the stale list.
|
||||
if reply := h.handlePraxisAct(ctx, praxisItemDec("resolve_item", "last")); reply == "" {
|
||||
t.Fatal("positional follow-up should have been claimed by praxis")
|
||||
}
|
||||
var body string
|
||||
for _, r := range praxis.Requests() {
|
||||
if r.Method == "POST" && r.Path == "/api/v1/tools/resolve" {
|
||||
body = string(r.Body)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(body, "item_scoped_1") {
|
||||
t.Fatalf("resolve must transition the item she read out, posted %q", body)
|
||||
}
|
||||
if strings.Contains(body, "item_stale") {
|
||||
t.Fatal("resolve transitioned an item from a previous digest")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHexisConfirm_KeepsOneCorrelationIDPerAction: the confirm arrives on a
|
||||
// later turn with a context of its own. The contract mints one id per action,
|
||||
// so the execution it authorises must still be joinable to the resolve and the
|
||||
// discovery that proposed it — it recorded a fresh id and no causation at all.
|
||||
func TestHexisConfirm_KeepsOneCorrelationIDPerAction(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"))
|
||||
caps := fixtureHexisCapabilities(map[string]any{"id": "cap_restart", "name": "restart", "read_only": false})
|
||||
hexis := newFakeHexis(t, caps, fixtureHexisExecuted("exec_1", "succeeded"))
|
||||
h := ecoHandler(t, nexus, nil, hexis)
|
||||
|
||||
if reply := h.handleHexisAct(ctx, actDec("restart")); !strings.Contains(reply, "да") {
|
||||
t.Fatalf("mutating capability must ask for confirmation, got %q", reply)
|
||||
}
|
||||
resolve := findTrace(t, h, "nexus", "resolve")
|
||||
if resolve == nil || resolve.CorrelationID == "" {
|
||||
t.Fatalf("expected a nexus resolve trace carrying a correlation id, got %+v", resolve)
|
||||
}
|
||||
|
||||
if _, handled := h.resolveConfirm(ctx, "да"); !handled {
|
||||
t.Fatal("confirm should have been claimed")
|
||||
}
|
||||
exec := findTrace(t, h, "hexis", "execute")
|
||||
if exec == nil {
|
||||
t.Fatal("expected a hexis execute trace")
|
||||
}
|
||||
if exec.CausationID != resolve.CorrelationID {
|
||||
t.Fatalf("confirmed execution must cite the action that proposed it: causation %q, action %q",
|
||||
exec.CausationID, resolve.CorrelationID)
|
||||
}
|
||||
}
|
||||
|
||||
+154
-185
@@ -68,6 +68,11 @@ import (
|
||||
"github.com/kami/maven/internal/webauthn"
|
||||
)
|
||||
|
||||
// stepUpTTL is how long one passkey assertion keeps the session stepped up.
|
||||
// Long enough for the unlock call that follows it, short enough that a walked
|
||||
// away laptop does not stay authorized.
|
||||
const stepUpTTL = 5 * time.Minute
|
||||
|
||||
var errLocked = errors.New("mavend: daemon locked — complete passkey assertion first")
|
||||
|
||||
// daemonLock tracks whether the daemon is in locked (pre-unlock) mode, and
|
||||
@@ -249,42 +254,12 @@ func run(args []string) error {
|
||||
|
||||
if !locked {
|
||||
rules = wireRules(cfg)
|
||||
gatherer = loop.NewGatherer(st, rules)
|
||||
if cfg.QuietHours != nil {
|
||||
gatherer.SetQuietHours(cfg.QuietHours.Start, cfg.QuietHours.End)
|
||||
}
|
||||
gatherer = wireGatherer(st, cfg, rules)
|
||||
|
||||
// phraser
|
||||
phr = phraser.NewStub()
|
||||
if cfg.Phraser != nil {
|
||||
pc := phraser.Config{
|
||||
ModelPath: cfg.Phraser.ModelPath,
|
||||
BinPath: cfg.Phraser.BinPath,
|
||||
Listen: cfg.Phraser.Listen,
|
||||
NGpuLayers: cfg.Phraser.NGpuLayers,
|
||||
NCtx: cfg.Phraser.NCtx,
|
||||
CacheRAMMiB: cacheRAMMiB(cfg.Phraser.CacheRAMMiB),
|
||||
Timeout: time.Duration(cfg.Phraser.Timeout),
|
||||
LLMNudges: cfg.Phraser.LLMNudges,
|
||||
ContextBlock: contextBlockFn(cfg, time.Now),
|
||||
}
|
||||
if pc.BinPath == "" {
|
||||
pc.BinPath = "llama-server"
|
||||
}
|
||||
if pc.Listen == "" {
|
||||
pc.Listen = "127.0.0.1:0"
|
||||
}
|
||||
if pc.NCtx <= 0 {
|
||||
pc.NCtx = 2048
|
||||
}
|
||||
if pc.Timeout <= 0 {
|
||||
pc.Timeout = 30 * time.Second
|
||||
}
|
||||
var err error
|
||||
phr, err = phraser.NewLLMPhraser(ctx, pc)
|
||||
if err != nil {
|
||||
return fmt.Errorf("phraser: %w", err)
|
||||
}
|
||||
phr, err = wirePhraser(ctx, cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("phraser: %w", err)
|
||||
}
|
||||
|
||||
// ecosystem — nexus + hexis + praxis (all over HTTP; no direct DB access)
|
||||
@@ -297,48 +272,13 @@ func run(args []string) error {
|
||||
}
|
||||
|
||||
// delivery
|
||||
var ntfy delivery.Sink
|
||||
if cfg.Ntfy != nil {
|
||||
s, err := ntfysink.New(*cfg.Ntfy)
|
||||
if err != nil {
|
||||
return fmt.Errorf("wire ntfy sink: %w", err)
|
||||
}
|
||||
ntfy = s
|
||||
dispatcher, err = wireDispatcher(st, cfg, voiceW)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var telegram delivery.Sink
|
||||
if cfg.Telegram != nil {
|
||||
s, err := telegramsink.New(*cfg.Telegram)
|
||||
if err != nil {
|
||||
return fmt.Errorf("wire telegram sink: %w", err)
|
||||
}
|
||||
telegram = s
|
||||
}
|
||||
var voiceSink delivery.Sink
|
||||
if voiceW != nil {
|
||||
voiceSink = voiceW.voiceSink
|
||||
}
|
||||
// A crashed prior run may have left "pending" delivery attempts (send
|
||||
// may have landed externally, then the process died before recording
|
||||
// it) — reconcile them to "unknown" before the tick loop resumes
|
||||
// sending, so nothing auto-resends into that ambiguity.
|
||||
if _, err := st.ReconcileStaleDeliveryAttempts(context.Background(), time.Now()); err != nil {
|
||||
log.Printf("delivery outbox reconcile: %v", err)
|
||||
}
|
||||
dispatcher = delivery.NewDispatcher(delivery.Config{
|
||||
Ntfy: ntfy,
|
||||
Telegram: telegram,
|
||||
Voice: voiceSink,
|
||||
Ack: st,
|
||||
Nudges: st,
|
||||
Reminders: st,
|
||||
Outbox: st,
|
||||
})
|
||||
|
||||
// tick loop
|
||||
tickInterval := time.Duration(cfg.TickInterval)
|
||||
repeatInterval := time.Duration(cfg.RepeatInterval)
|
||||
autotuneInterval := time.Duration(cfg.AutotuneInterval)
|
||||
tl = newTickLoop(st, gatherer, dispatcher, phr, rules, tickInterval, repeatInterval, autotuneInterval, cfg.Digest, routinesFromConfig(cfg.Routines), config.MorningRoutinesFromConfig(cfg.MorningRoutines), cfg.PatternProposals)
|
||||
tl = wireTickLoop(st, gatherer, dispatcher, phr, rules, cfg)
|
||||
factWorker = newFactEnrichmentWorker(st, eco, time.Duration(cfg.FactEnrichmentInterval))
|
||||
evalWorker = newMemoryEvalWorker(st, phr, cfg)
|
||||
feedWkr = newFeedWorker(coreFor(), embedderOf(voiceW), cfg)
|
||||
@@ -350,6 +290,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),
|
||||
getDecisions: turnDecisionsFn(voiceW),
|
||||
seedStore: seedStoreIfAllowed(st),
|
||||
nexus: nexusOf(voiceW),
|
||||
}
|
||||
@@ -379,7 +320,7 @@ func run(args []string) error {
|
||||
return fmt.Errorf("ipc listen: %w", err)
|
||||
}
|
||||
|
||||
passkeySess := webauthn.NewPasskeySession(5 * time.Minute)
|
||||
passkeySess := webauthn.NewPasskeySession(stepUpTTL)
|
||||
|
||||
// Set Server.Check — the single authorization guard, run once by
|
||||
// Server.dispatch before any CoreAPI method is called (see
|
||||
@@ -527,40 +468,11 @@ func run(args []string) error {
|
||||
|
||||
// Wire everything.
|
||||
rules = wireRules(cfg)
|
||||
gatherer = loop.NewGatherer(st, rules)
|
||||
if cfg.QuietHours != nil {
|
||||
gatherer.SetQuietHours(cfg.QuietHours.Start, cfg.QuietHours.End)
|
||||
}
|
||||
gatherer = wireGatherer(st, cfg, rules)
|
||||
|
||||
phr = phraser.NewStub()
|
||||
if cfg.Phraser != nil {
|
||||
pc := phraser.Config{
|
||||
ModelPath: cfg.Phraser.ModelPath,
|
||||
BinPath: cfg.Phraser.BinPath,
|
||||
Listen: cfg.Phraser.Listen,
|
||||
NGpuLayers: cfg.Phraser.NGpuLayers,
|
||||
NCtx: cfg.Phraser.NCtx,
|
||||
CacheRAMMiB: cacheRAMMiB(cfg.Phraser.CacheRAMMiB),
|
||||
Timeout: time.Duration(cfg.Phraser.Timeout),
|
||||
LLMNudges: cfg.Phraser.LLMNudges,
|
||||
ContextBlock: contextBlockFn(cfg, time.Now),
|
||||
}
|
||||
if pc.BinPath == "" {
|
||||
pc.BinPath = "llama-server"
|
||||
}
|
||||
if pc.Listen == "" {
|
||||
pc.Listen = "127.0.0.1:0"
|
||||
}
|
||||
if pc.NCtx <= 0 {
|
||||
pc.NCtx = 2048
|
||||
}
|
||||
if pc.Timeout <= 0 {
|
||||
pc.Timeout = 30 * time.Second
|
||||
}
|
||||
phr, err = phraser.NewLLMPhraser(ctx, pc)
|
||||
if err != nil {
|
||||
return fmt.Errorf("phraser: %w", err)
|
||||
}
|
||||
phr, err = wirePhraser(ctx, cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("phraser: %w", err)
|
||||
}
|
||||
|
||||
eco = wireEcosystem(cfg)
|
||||
@@ -570,43 +482,12 @@ func run(args []string) error {
|
||||
return fmt.Errorf("wire voice: %w", err)
|
||||
}
|
||||
|
||||
var ntfy delivery.Sink
|
||||
if cfg.Ntfy != nil {
|
||||
s, err := ntfysink.New(*cfg.Ntfy)
|
||||
if err != nil {
|
||||
return fmt.Errorf("wire ntfy sink: %w", err)
|
||||
}
|
||||
ntfy = s
|
||||
dispatcher, err = wireDispatcher(st, cfg, voiceW)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var telegram delivery.Sink
|
||||
if cfg.Telegram != nil {
|
||||
s, err := telegramsink.New(*cfg.Telegram)
|
||||
if err != nil {
|
||||
return fmt.Errorf("wire telegram sink: %w", err)
|
||||
}
|
||||
telegram = s
|
||||
}
|
||||
var voiceSink delivery.Sink
|
||||
if voiceW != nil {
|
||||
voiceSink = voiceW.voiceSink
|
||||
}
|
||||
if _, err := st.ReconcileStaleDeliveryAttempts(context.Background(), time.Now()); err != nil {
|
||||
log.Printf("delivery outbox reconcile: %v", err)
|
||||
}
|
||||
dispatcher = delivery.NewDispatcher(delivery.Config{
|
||||
Ntfy: ntfy,
|
||||
Telegram: telegram,
|
||||
Voice: voiceSink,
|
||||
Ack: st,
|
||||
Nudges: st,
|
||||
Reminders: st,
|
||||
Outbox: st,
|
||||
})
|
||||
|
||||
tickInterval := time.Duration(cfg.TickInterval)
|
||||
repeatInterval := time.Duration(cfg.RepeatInterval)
|
||||
autotuneInterval := time.Duration(cfg.AutotuneInterval)
|
||||
tl = newTickLoop(st, gatherer, dispatcher, phr, rules, tickInterval, repeatInterval, autotuneInterval, cfg.Digest, routinesFromConfig(cfg.Routines), config.MorningRoutinesFromConfig(cfg.MorningRoutines), cfg.PatternProposals)
|
||||
tl = wireTickLoop(st, gatherer, dispatcher, phr, rules, cfg)
|
||||
factWorker = newFactEnrichmentWorker(st, eco, time.Duration(cfg.FactEnrichmentInterval))
|
||||
evalWorker = newMemoryEvalWorker(st, phr, cfg)
|
||||
feedWkr = newFeedWorker(coreFor(), embedderOf(voiceW), cfg)
|
||||
@@ -619,6 +500,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),
|
||||
getDecisions: turnDecisionsFn(voiceW),
|
||||
seedStore: seedStoreIfAllowed(st),
|
||||
}
|
||||
if voiceW != nil && voiceW.handler != nil {
|
||||
@@ -696,71 +578,39 @@ func run(args []string) error {
|
||||
}
|
||||
}
|
||||
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
goWorker(&wg, func() {
|
||||
if err := srv.Serve(); err != nil && !errors.Is(err, net.ErrClosed) {
|
||||
log.Printf("ipc serve: %v", err)
|
||||
}
|
||||
}()
|
||||
})
|
||||
log.Printf("mavend: ipc listening on %s", srv.Path())
|
||||
|
||||
if !locked && voiceW != nil {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
goWorker(&wg, func() {
|
||||
if err := voiceW.server.Serve(); err != nil && !errors.Is(err, net.ErrClosed) {
|
||||
log.Printf("voice serve: %v", err)
|
||||
}
|
||||
}()
|
||||
})
|
||||
log.Printf("mavend: voice listening on %s", voiceW.server.Addr())
|
||||
}
|
||||
|
||||
if !locked {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
tl.run(ctx)
|
||||
}()
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
factWorker.run(ctx)
|
||||
}()
|
||||
goWorker(&wg, func() { tl.run(ctx) })
|
||||
goWorker(&wg, func() { factWorker.run(ctx) })
|
||||
if evalWorker != nil {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
evalWorker.run(ctx)
|
||||
}()
|
||||
goWorker(&wg, func() { evalWorker.run(ctx) })
|
||||
}
|
||||
if feedWkr != nil {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
feedWkr.run(ctx)
|
||||
}()
|
||||
goWorker(&wg, func() { feedWkr.run(ctx) })
|
||||
}
|
||||
if crawlWkr != nil {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
crawlWkr.run(ctx)
|
||||
}()
|
||||
goWorker(&wg, func() { crawlWkr.run(ctx) })
|
||||
}
|
||||
if voiceW != nil && voiceW.mcp != nil {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
voiceW.mcp.run(ctx)
|
||||
}()
|
||||
goWorker(&wg, func() { voiceW.mcp.run(ctx) })
|
||||
}
|
||||
if voiceW != nil && voiceW.home != nil {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
voiceW.home.run(ctx)
|
||||
}()
|
||||
goWorker(&wg, func() { voiceW.home.run(ctx) })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -848,6 +698,125 @@ func waitWorkers(wg *sync.WaitGroup, d time.Duration) bool {
|
||||
}
|
||||
}
|
||||
|
||||
// Phraser defaults, applied when the config block leaves a field unset. They
|
||||
// are the daemon's, not the library's: phraser.Config carries no defaults of
|
||||
// its own, so an empty field here would reach llama-server as an empty flag.
|
||||
const (
|
||||
defaultLlamaBin = "llama-server"
|
||||
defaultPhraserListen = "127.0.0.1:0"
|
||||
defaultPhraserNCtx = 2048
|
||||
defaultPhraserTimeout = 30 * time.Second
|
||||
)
|
||||
|
||||
// wirePhraser builds the phrasing seam. No phraser block means the
|
||||
// deterministic stub, which is the floor and not an error: the daemon answers
|
||||
// without a model, in fixed words.
|
||||
func wirePhraser(ctx context.Context, cfg *config.Config) (phraser.Phraser, error) {
|
||||
if cfg.Phraser == nil {
|
||||
return phraser.NewStub(), nil
|
||||
}
|
||||
pc := phraser.Config{
|
||||
ModelPath: cfg.Phraser.ModelPath,
|
||||
BinPath: cfg.Phraser.BinPath,
|
||||
Listen: cfg.Phraser.Listen,
|
||||
NGpuLayers: cfg.Phraser.NGpuLayers,
|
||||
NCtx: cfg.Phraser.NCtx,
|
||||
CacheRAMMiB: cacheRAMMiB(cfg.Phraser.CacheRAMMiB),
|
||||
Timeout: time.Duration(cfg.Phraser.Timeout),
|
||||
LLMNudges: cfg.Phraser.LLMNudges,
|
||||
ContextBlock: contextBlockFn(cfg, time.Now),
|
||||
}
|
||||
if pc.BinPath == "" {
|
||||
pc.BinPath = defaultLlamaBin
|
||||
}
|
||||
if pc.Listen == "" {
|
||||
pc.Listen = defaultPhraserListen
|
||||
}
|
||||
if pc.NCtx <= 0 {
|
||||
pc.NCtx = defaultPhraserNCtx
|
||||
}
|
||||
if pc.Timeout <= 0 {
|
||||
pc.Timeout = defaultPhraserTimeout
|
||||
}
|
||||
return phraser.NewLLMPhraser(ctx, pc)
|
||||
}
|
||||
|
||||
// wireGatherer builds the nudge gatherer over the given rule set and applies
|
||||
// the configured quiet hours.
|
||||
func wireGatherer(st *store.Store, cfg *config.Config, rules []loop.Rule) *loop.Gatherer {
|
||||
g := loop.NewGatherer(st, rules)
|
||||
if cfg.QuietHours != nil {
|
||||
g.SetQuietHours(cfg.QuietHours.Start, cfg.QuietHours.End)
|
||||
}
|
||||
return g
|
||||
}
|
||||
|
||||
// wireDispatcher builds the delivery fan-out. Each sink stays nil unless its
|
||||
// config block is present, and a sink that fails to build fails the boot
|
||||
// rather than going quiet.
|
||||
//
|
||||
// A crashed prior run may have left "pending" delivery attempts (send may have
|
||||
// landed externally, then the process died before recording it). They are
|
||||
// reconciled to "unknown" here, before the tick loop resumes sending, so
|
||||
// nothing auto-resends into that ambiguity.
|
||||
func wireDispatcher(st *store.Store, cfg *config.Config, voiceW *voiceWiring) (*delivery.Dispatcher, error) {
|
||||
var ntfy delivery.Sink
|
||||
if cfg.Ntfy != nil {
|
||||
s, err := ntfysink.New(*cfg.Ntfy)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("wire ntfy sink: %w", err)
|
||||
}
|
||||
ntfy = s
|
||||
}
|
||||
var telegram delivery.Sink
|
||||
if cfg.Telegram != nil {
|
||||
s, err := telegramsink.New(*cfg.Telegram)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("wire telegram sink: %w", err)
|
||||
}
|
||||
telegram = s
|
||||
}
|
||||
var voiceSink delivery.Sink
|
||||
if voiceW != nil {
|
||||
voiceSink = voiceW.voiceSink
|
||||
}
|
||||
if _, err := st.ReconcileStaleDeliveryAttempts(context.Background(), time.Now()); err != nil {
|
||||
log.Printf("delivery outbox reconcile: %v", err)
|
||||
}
|
||||
return delivery.NewDispatcher(delivery.Config{
|
||||
Ntfy: ntfy,
|
||||
Telegram: telegram,
|
||||
Voice: voiceSink,
|
||||
Ack: st,
|
||||
Nudges: st,
|
||||
Reminders: st,
|
||||
Outbox: st,
|
||||
}), nil
|
||||
}
|
||||
|
||||
// wireTickLoop reads the loop's three intervals and its schedules out of the
|
||||
// config, so the two boot paths cannot disagree about them.
|
||||
func wireTickLoop(st *store.Store, gatherer *loop.Gatherer, dispatcher *delivery.Dispatcher, phr phraser.Phraser, rules []loop.Rule, cfg *config.Config) *tickLoop {
|
||||
return newTickLoop(st, gatherer, dispatcher, phr, rules,
|
||||
time.Duration(cfg.TickInterval),
|
||||
time.Duration(cfg.RepeatInterval),
|
||||
time.Duration(cfg.AutotuneInterval),
|
||||
cfg.Digest,
|
||||
routinesFromConfig(cfg.Routines),
|
||||
config.MorningRoutinesFromConfig(cfg.MorningRoutines),
|
||||
cfg.PatternProposals)
|
||||
}
|
||||
|
||||
// goWorker starts run on its own goroutine and registers it with wg, so
|
||||
// shutdown can wait for it inside workerGrace.
|
||||
func goWorker(wg *sync.WaitGroup, run func()) {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
run()
|
||||
}()
|
||||
}
|
||||
|
||||
// wireRules builds the nudge rule set, minus anything config turned off. The
|
||||
// drop is logged because a rule vanishing silently is indistinguishable from a
|
||||
// rule that is broken, and the next person to wonder why she stopped nudging
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/kami/maven/internal/lexicon"
|
||||
"github.com/kami/maven/internal/morph"
|
||||
)
|
||||
|
||||
// correctionFragment reports that an utterance replaces a referent and states
|
||||
// nothing of its own: "нет, не маме, а папе" (V-576).
|
||||
//
|
||||
// Measured on the box 2026-08-06, that fragment routed to note and was filed.
|
||||
// It is not a repair either, because it names no intent, so parseRepair
|
||||
// declines it and repair.go is the wrong place to catch it. This is the note
|
||||
// path saying it has nothing to store.
|
||||
//
|
||||
// Three offline tests, all of them narrow on purpose. The sentence opens with a
|
||||
// refusal word from the lexicon, it carries the contrastive "а" that names the
|
||||
// replacement, and no token in it is a verb form. The verb test is what keeps
|
||||
// the rule off real notes: "нет, я не поеду, а останусь" says something, and a
|
||||
// Russian verb carries its own subject and tense.
|
||||
func correctionFragment(utterance string) bool {
|
||||
toks := repairTokens(strings.ToLower(strings.TrimSpace(utterance)))
|
||||
if len(toks) < 3 {
|
||||
return false
|
||||
}
|
||||
if !refusalWord(toks[0]) {
|
||||
return false
|
||||
}
|
||||
var negated, contrasted bool
|
||||
for _, tok := range toks[1:] {
|
||||
switch tok {
|
||||
case "не", "not":
|
||||
negated = true
|
||||
case "а", "but":
|
||||
contrasted = true
|
||||
}
|
||||
if morph.IsVerbForm(tok) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return negated && contrasted
|
||||
}
|
||||
|
||||
// refusalWord reports that a token is a one-word refusal. The lexicon set holds
|
||||
// phrases too ("не надо"), and those are not what opens a correction.
|
||||
func refusalWord(tok string) bool {
|
||||
for _, w := range lexicon.ConfirmNo() {
|
||||
if strings.ContainsRune(w, ' ') {
|
||||
continue
|
||||
}
|
||||
if w == tok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/memory"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/store"
|
||||
"github.com/kami/maven/internal/tool"
|
||||
"github.com/kami/maven/internal/voice"
|
||||
)
|
||||
|
||||
func TestCorrectionFragment(t *testing.T) {
|
||||
cases := []struct {
|
||||
utterance string
|
||||
want bool
|
||||
}{
|
||||
{"нет, не маме, а папе", true},
|
||||
{"Нет, не маме — а папе", true},
|
||||
{"no, not mom, but dad", true},
|
||||
// States something of its own, so it is his to keep.
|
||||
{"нет, я не поеду, а останусь дома", false},
|
||||
{"нет", false},
|
||||
{"не маме, а папе", false}, // no refusal word opening it
|
||||
{"нет, маме и папе", false}, // nothing negated
|
||||
{"нет, не маме", false}, // nothing put in its place
|
||||
{"запомни что кофе закончился", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := correctionFragment(c.utterance); got != c.want {
|
||||
t.Errorf("correctionFragment(%q) = %v, want %v", c.utterance, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func newNoteHandler(t *testing.T) (*reactiveHandler, *store.Store) {
|
||||
t.Helper()
|
||||
st := newTestStore(t)
|
||||
api := ipc.NewStoreAPI(st)
|
||||
now := time.Now()
|
||||
emb := router.NewHashEmbedder(1024)
|
||||
h := &reactiveHandler{
|
||||
api: api,
|
||||
recall: recallWiring{embedder: emb, memStore: memory.NewInMemoryStore()},
|
||||
router: buildRouter(emb, tool.NewMatcher(api), 0.55, nil),
|
||||
replier: voice.NewStubReplier(),
|
||||
now: func() time.Time { return now },
|
||||
dataStore: st,
|
||||
}
|
||||
return h, st
|
||||
}
|
||||
|
||||
// TestNoteBodyIsTheUtterance — the stored body comes from the utterance, never
|
||||
// from Slots.Text, which the LLM router is free to write anything into (V-576).
|
||||
func TestNoteBodyIsTheUtterance(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
h, st := newNoteHandler(t)
|
||||
|
||||
dec := router.Decision{
|
||||
Intent: router.IntentNote,
|
||||
Utterance: "купил хлеб и молоко",
|
||||
Slots: router.Slots{Text: "ты поедешь на дачу"},
|
||||
}
|
||||
if reply := h.applyAction(ctx, dec); reply != "" {
|
||||
t.Fatalf("applyAction = %q, want empty", reply)
|
||||
}
|
||||
notes, err := st.RecentNotes(ctx, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("RecentNotes: %v", err)
|
||||
}
|
||||
if len(notes) != 1 || notes[0].Text != dec.Utterance {
|
||||
t.Fatalf("stored note = %+v, want body %q", notes, dec.Utterance)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNoteBodyIsStable — the same utterance twice stores the same text.
|
||||
func TestNoteBodyIsStable(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
h, st := newNoteHandler(t)
|
||||
|
||||
dec := router.Decision{Intent: router.IntentNote, Utterance: "кофе закончился"}
|
||||
h.applyAction(ctx, dec)
|
||||
h.applyAction(ctx, dec)
|
||||
|
||||
notes, err := st.RecentNotes(ctx, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("RecentNotes: %v", err)
|
||||
}
|
||||
if len(notes) != 2 {
|
||||
t.Fatalf("notes = %d, want 2", len(notes))
|
||||
}
|
||||
if notes[0].Text != notes[1].Text || notes[0].Text != dec.Utterance {
|
||||
t.Fatalf("bodies differ: %q vs %q", notes[0].Text, notes[1].Text)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCorrectionFragmentWritesNoNote — a correction with nothing behind it is
|
||||
// not a note, and she says so instead of filing it (V-576).
|
||||
func TestCorrectionFragmentWritesNoNote(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
h, st := newNoteHandler(t)
|
||||
|
||||
dec := router.Decision{Intent: router.IntentNote, Utterance: "нет, не маме, а папе"}
|
||||
if reply := h.applyAction(ctx, dec); reply != nothingToCorrectReply {
|
||||
t.Fatalf("reply = %q, want %q", reply, nothingToCorrectReply)
|
||||
}
|
||||
notes, err := st.RecentNotes(ctx, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("RecentNotes: %v", err)
|
||||
}
|
||||
if len(notes) != 0 {
|
||||
t.Fatalf("notes = %+v, want none", notes)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
)
|
||||
|
||||
// A Praxis lifecycle failure used to return a hardcoded constant that named the
|
||||
// verb and never the service, so an outage, a refused token and a contract
|
||||
// mismatch all produced the identical sentence (Vikunja #588). The helpers and
|
||||
// the Hexis half of the same defect are in ecosystem_gap_test.go.
|
||||
|
||||
func TestPraxisLifecycle401NamesPraxis(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
praxis := newFakePraxis(t, fixturePraxisAttentionItems())
|
||||
h := newPraxisTestHandler(t, praxis)
|
||||
|
||||
praxis.SetFault(401)
|
||||
reply := h.handlePraxisAct(ctx, praxisItemDec("resolve_item", "item_1"))
|
||||
if !strings.Contains(reply, servicePraxis) {
|
||||
t.Fatalf("praxis failure does not name Praxis: %q", reply)
|
||||
}
|
||||
if !strings.Contains(reply, phraser.A(phraser.EcoDenied, serviceVars(servicePraxis))) {
|
||||
t.Fatalf("401 from praxis: got %q, want the denied line", reply)
|
||||
}
|
||||
// The verb that did not happen is still said: the trace is the only other
|
||||
// place it exists and he is not reading the trace.
|
||||
if !strings.Contains(reply, "не получилось отметить сделанным.") {
|
||||
t.Errorf("reply dropped the operation that failed: %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPraxisLifecycleOutageDiffersFrom401 — the identity that was the bug.
|
||||
func TestPraxisLifecycleOutageDiffersFrom401(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
praxis := newFakePraxis(t, fixturePraxisAttentionItems())
|
||||
h := newPraxisTestHandler(t, praxis)
|
||||
|
||||
praxis.SetFault(401)
|
||||
refused := h.handlePraxisAct(ctx, praxisItemDec("acknowledge_item", "item_1"))
|
||||
|
||||
h.ecosystem = &ecosystemWiring{praxis: newPraxisClient(unreachableURL)}
|
||||
outage := h.handlePraxisAct(ctx, praxisItemDec("acknowledge_item", "item_1"))
|
||||
|
||||
if refused == outage {
|
||||
t.Fatalf("a refused token and an outage still say the same thing: %q", refused)
|
||||
}
|
||||
if !strings.Contains(outage, phraser.A(phraser.EcoDown, serviceVars(servicePraxis))) {
|
||||
t.Fatalf("praxis outage: got %q, want the outage line naming Praxis", outage)
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,11 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -31,6 +34,57 @@ func (f *fixedEmbedder) Embed(_ context.Context, text string) ([]float32, error)
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// brokenEmbedder fails every call, which is what an ONNX session error looks
|
||||
// like from the query chain's side.
|
||||
type brokenEmbedder struct{}
|
||||
|
||||
func (brokenEmbedder) Dim() int { return 4 }
|
||||
func (brokenEmbedder) Close() error { return nil }
|
||||
func (brokenEmbedder) Embed(context.Context, string) ([]float32, error) {
|
||||
return nil, errors.New("onnx: session failed")
|
||||
}
|
||||
|
||||
// TestQueryEmbedFailureDoesNotStopTheChain — V-568. The embed source used to
|
||||
// claim the turn on an embedder error, so one failing EmbedQuery answered every
|
||||
// question below it with "не смогла ответить", including the ones the search
|
||||
// answers without an embedder at all. A source that could not look must pass.
|
||||
func TestQueryEmbedFailureDoesNotStopTheChain(t *testing.T) {
|
||||
const q = "почему небо голубое"
|
||||
|
||||
h, _ := searchHandler(t, searchBody, http.StatusOK)
|
||||
h.api = ipc.NewStoreAPI(newTestStore(t))
|
||||
h.recall = recallWiring{embedder: brokenEmbedder{}, minScore: 0.55, minMargin: 0.008}
|
||||
h.now = time.Now
|
||||
|
||||
// The embed source itself passes rather than claiming.
|
||||
turn := &queryTurn{dec: router.Decision{Intent: router.IntentQuery, Utterance: q}}
|
||||
if reply, ok := h.queryEmbed(context.Background(), turn); ok {
|
||||
t.Fatalf("queryEmbed claimed the turn on an embedder error: %q", reply)
|
||||
}
|
||||
|
||||
// And the whole chain still reaches the search below it.
|
||||
reply := askQuery(t, h, q)
|
||||
if !strings.Contains(reply, "рэлеевского рассеяния") {
|
||||
t.Fatalf("reply = %q, want the search answer", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// The recall sources read the empty vector the failed embed left behind, and
|
||||
// neither of them may turn that into an answer: no vector means they could not
|
||||
// look, which is not the same as looking and finding nothing.
|
||||
func TestQueryRecallPassesWithoutAVector(t *testing.T) {
|
||||
h, _ := buildRecallHandler(t, "где молоко", []recallCase{
|
||||
{text: "молоко стоит в холодильнике", score: 0.90, kind: "note"},
|
||||
})
|
||||
turn := &queryTurn{dec: router.Decision{Intent: router.IntentQuery, Utterance: "где молоко"}}
|
||||
if reply, ok := h.queryMemory(context.Background(), turn); ok {
|
||||
t.Errorf("queryMemory claimed with no vector: %q", reply)
|
||||
}
|
||||
if reply, ok := h.queryNotes(context.Background(), turn); ok {
|
||||
t.Errorf("queryNotes claimed with no vector: %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// scoreVec builds a unit vector whose cosine against the query vector
|
||||
// (1,0,0,0) is exactly score.
|
||||
func scoreVec(score float64) []float32 {
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/dialogue"
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
// A reminder commits only when three things are answered: what to say, what
|
||||
// time to say it, and what day (owner's rule, 2026-08-06, V-579). Anything
|
||||
// missing is asked about, and nothing missing is filled from the clock.
|
||||
//
|
||||
// "напомни завтра в 3 заказать цветы" has the what and the day and an hour that
|
||||
// could be either half of the day, so she asks which 3. "напомни в 9 вечера
|
||||
// разгрузить стиралку" has the what and an unambiguous hour and no day, so she
|
||||
// asks which day. Today being a valid reading is not the same as him saying it.
|
||||
//
|
||||
// Two things are already whole and are not asked about. A time that admits one
|
||||
// reading is not queried for its half of the day, so "завтра в 15:00" commits.
|
||||
// And an interval is an instant, so "через час" carries all three by itself.
|
||||
type whenGap string
|
||||
|
||||
const (
|
||||
whenComplete whenGap = ""
|
||||
whenNoHour whenGap = "hour"
|
||||
whenAmbiguousHour whenGap = "part_of_day"
|
||||
whenNoDay whenGap = "day"
|
||||
)
|
||||
|
||||
// whenGapOf reads the request and names the first thing about its time that he
|
||||
// has not said. hasTime is whether a parser could read an instant out of it,
|
||||
// which is necessary and not sufficient: the parser answers a dayless "в 9"
|
||||
// with a day it picked.
|
||||
func whenGapOf(text string, hasTime bool) whenGap {
|
||||
if !router.NamesAnHour(text) {
|
||||
return whenNoHour
|
||||
}
|
||||
if router.NamesAnInterval(text) {
|
||||
return whenComplete
|
||||
}
|
||||
if !hasTime {
|
||||
return whenNoHour
|
||||
}
|
||||
if router.HourIsAmbiguous(text) {
|
||||
return whenAmbiguousHour
|
||||
}
|
||||
if !router.NamesADay(text) {
|
||||
return whenNoDay
|
||||
}
|
||||
return whenComplete
|
||||
}
|
||||
|
||||
// whenQuestion is what she asks for each gap. Every one of them opens with the
|
||||
// current time, because she is reasoning from it and he cannot check that
|
||||
// reasoning unless he hears it. The hour deck varies with the attempt, like
|
||||
// every other slot; the other two say one thing and there is only one way to
|
||||
// say it.
|
||||
//
|
||||
// taken is what his last turn added, in his own words, and it goes between the
|
||||
// clock and the question (V-593). It is empty whenever his turn moved nothing
|
||||
// forward, which is the case where repeating the question verbatim is honest.
|
||||
func whenQuestion(gap whenGap, attempt int, now time.Time, taken string) (string, bool) {
|
||||
clock := fmt.Sprintf("Сейчас %s.", now.Format("15:04"))
|
||||
if taken != "" {
|
||||
clock += " " + taken
|
||||
}
|
||||
switch gap {
|
||||
case whenNoHour:
|
||||
q, ok := clarifyQuestionFor(dialogue.SlotTime, attempt)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
return clock + " " + q, true
|
||||
case whenAmbiguousHour:
|
||||
return clock + " Это утра или вечера?", true
|
||||
case whenNoDay:
|
||||
return clock + " В какой день?", true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// whenKnown — the three things he has to say about the time, and whether the
|
||||
// words so far say them. Read off the same predicates whenGapOf reads, so the
|
||||
// two cannot disagree about what is still open.
|
||||
type whenKnown struct{ hour, part, day bool }
|
||||
|
||||
func whenKnownOf(text string, hasTime bool) whenKnown {
|
||||
if !router.NamesAnHour(text) {
|
||||
return whenKnown{}
|
||||
}
|
||||
if router.NamesAnInterval(text) {
|
||||
return whenKnown{hour: true, part: true, day: true}
|
||||
}
|
||||
if !hasTime {
|
||||
return whenKnown{}
|
||||
}
|
||||
return whenKnown{
|
||||
hour: true,
|
||||
part: !router.HourIsAmbiguous(text),
|
||||
day: router.NamesADay(text),
|
||||
}
|
||||
}
|
||||
|
||||
// movedForward reports whether b says something a did not.
|
||||
func (a whenKnown) movedForward(b whenKnown) bool {
|
||||
return (!a.hour && b.hour) || (!a.part && b.part) || (!a.day && b.day)
|
||||
}
|
||||
|
||||
// whenTakenLine — the acknowledgement in front of a re-ask, in the words he
|
||||
// just used (V-593).
|
||||
//
|
||||
// It is an echo and never a restatement, for the same reason the fact
|
||||
// confirmation is (V-592): a 1.7B asked to say a Russian sentence back invents.
|
||||
// Its only job is evidence that the turn between two asks was heard, so after
|
||||
// "на 9" and then "на завтра" she does not ask "утра или вечера?" twice
|
||||
// byte-identically while he wonders whether the microphone is on.
|
||||
func whenTakenLine(text string) string {
|
||||
text = strings.TrimSpace(text)
|
||||
text = strings.TrimRight(text, " \t.,!?;:")
|
||||
if text == "" {
|
||||
return ""
|
||||
}
|
||||
return "Поняла: " + text + "."
|
||||
}
|
||||
|
||||
// whenTextOf is everything he has said about when, the original request plus
|
||||
// every answer he has given to a question about it.
|
||||
//
|
||||
// The answers are kept apart from the utterance on purpose. The utterance is
|
||||
// the reminder's payload, so folding "завтра" into it would have her read the
|
||||
// day back to him at the time she says it. And a time answer has to be read
|
||||
// against the request rather than alone: "завтра" names no hour, and the hour
|
||||
// it belongs to is the one she is already holding.
|
||||
func whenTextOf(q *dialogue.PendingQuestion) string {
|
||||
if q.WhenText == "" {
|
||||
return q.Utterance
|
||||
}
|
||||
return strings.TrimSpace(q.Utterance + " " + q.WhenText)
|
||||
}
|
||||
|
||||
// slotStillMissing reports whether a slot is still open. Every slot but the
|
||||
// reminder's time is open when it is empty; the time is open until all three of
|
||||
// what he must say about it are said.
|
||||
func slotStillMissing(slot dialogue.Slot, utterance string, s dialogue.Slots) bool {
|
||||
if len(dialogue.StillMissing([]dialogue.Slot{slot}, s)) > 0 {
|
||||
return true
|
||||
}
|
||||
return slot == dialogue.SlotTime && whenGapOf(utterance, s.HasTime) != whenComplete
|
||||
}
|
||||
|
||||
// readWhen reads the instant out of what he has said about the time, newest
|
||||
// statement first.
|
||||
//
|
||||
// The request plus his latest answer is tried before the whole history, and
|
||||
// that order is what makes a correction win: "нет, сегодня в 15:00" after "в
|
||||
// 11:00" must land on 15:00, and a parser reading left to right off the joined
|
||||
// history would find the 11 he just took back. The history is the fallback,
|
||||
// because an answer often completes an earlier one rather than replacing it -
|
||||
// "вечера" says which 9, and alone it names no hour at all.
|
||||
func (h *reactiveHandler) readWhen(ctx context.Context, intent router.Intent, q *dialogue.PendingQuestion, text string) (time.Time, bool) {
|
||||
latest := strings.TrimSpace(q.Utterance + " " + text)
|
||||
if router.NamesAnHour(text) {
|
||||
if w := h.extractor.Extract(ctx, intent, latest, h.now()); w.HasTime {
|
||||
return w.Time, true
|
||||
}
|
||||
}
|
||||
if w := h.extractor.Extract(ctx, intent, whenTextOf(q), h.now()); w.HasTime {
|
||||
return w.Time, true
|
||||
}
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
// asksAboutTime reports whether the parked question is one about when.
|
||||
func asksAboutTime(missing []dialogue.Slot) bool {
|
||||
for _, s := range missing {
|
||||
if s == dialogue.SlotTime {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// stillOpen reports whether any of the slots she asked about is still unsaid.
|
||||
func stillOpen(missing []dialogue.Slot, utterance string, s dialogue.Slots) bool {
|
||||
for _, slot := range missing {
|
||||
if slotStillMissing(slot, utterance, s) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// stillMissingFor is missingFor's engine, in wantedSlots order. It reads the
|
||||
// utterance as well as the slots, which plain StillMissing cannot: whether an
|
||||
// hour is ambiguous is a fact about the words, not about the instant they
|
||||
// parsed to.
|
||||
func stillMissingFor(intent router.Intent, utterance string, s dialogue.Slots) []dialogue.Slot {
|
||||
var out []dialogue.Slot
|
||||
for _, want := range wantedSlots[intent] {
|
||||
if slotStillMissing(want, utterance, s) {
|
||||
out = append(out, want)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -31,6 +31,14 @@ func (r *llmReplier) Reply(d router.Decision) string {
|
||||
// a generation to say something this small.
|
||||
return clarifyMissedLine(d)
|
||||
}
|
||||
if d.Intent == router.IntentFact {
|
||||
// A captured fact is confirmed by echoing him, and the model is not
|
||||
// asked (V-592). It has nothing to phrase FROM: replyContext hands it
|
||||
// "записала факт: water \"drank\"", so every Russian word in the reply
|
||||
// was the model's own invention, and on 2026-08-06 that was "Проверила,
|
||||
// что ты выпел стакан воды" for "я выпил воды".
|
||||
return phraser.FactAck(d.Utterance)
|
||||
}
|
||||
out, err := r.p.PhraseReply(context.Background(), d)
|
||||
if err != nil || out == "" {
|
||||
return r.stub.Reply(d)
|
||||
|
||||
+19
-9
@@ -17,6 +17,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/lexicon"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/say"
|
||||
)
|
||||
|
||||
@@ -73,22 +74,26 @@ func mentionsUnknownPlace(u string) bool {
|
||||
// date for a day she did not understand.
|
||||
const onlyNearDaysReply = "я считаю только сегодня, завтра, послезавтра и вчера — про другие дни пока не скажу."
|
||||
|
||||
// dayWords — day references the calendar parser cannot resolve. A weekday name
|
||||
// or a "через …" phrase means he asked about a specific other day.
|
||||
var dayWords = []string{
|
||||
"понедельник", "вторник", "сред", "четверг", "пятниц", "суббот", "воскресен",
|
||||
"через", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday",
|
||||
}
|
||||
|
||||
// mentionsUnknownDay reports whether the question names a day the calendar
|
||||
// parser could not resolve. Mirror of mentionsUnknownPlace: it exists only to
|
||||
// pick an honest reply over a confidently wrong one.
|
||||
//
|
||||
// Only called after ParseCalendarDate has already failed, so "завтра" and the
|
||||
// other words it does know never reach here.
|
||||
//
|
||||
// The weekday half was a list of STEMS matched with strings.Contains until
|
||||
// V-581 — "сред", "пятниц", "суббот". That is the hand-written Russian pattern
|
||||
// the sweep of 2026-08-04 took out, and it was wrong in the way such a pattern
|
||||
// always is: "среди", "средство" and "средний" all contain "сред", so a question
|
||||
// carrying any of them was answered with onlyNearDaysReply instead of the date.
|
||||
// Whole tokens now, and the weekday itself is router.WeekdayIndex, which reads
|
||||
// the lexicon and asks the dictionary about the case.
|
||||
func mentionsUnknownDay(u string) bool {
|
||||
for _, w := range dayWords {
|
||||
if strings.Contains(u, w) {
|
||||
for _, tok := range quietTokens(u) {
|
||||
if tok == "через" {
|
||||
return true
|
||||
}
|
||||
if _, ok := router.WeekdayIndex(tok); ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -149,6 +154,11 @@ func hasDurationWords(u string) bool {
|
||||
// Used by the query handler when answering "когда я это сделал?"-style questions.
|
||||
func formatTime(t time.Time) string {
|
||||
now := time.Now()
|
||||
// The argument is a fact's Ts, which the store hands back as UTC. Only the
|
||||
// last branch names a wall clock, and it named the store's until V-614: an
|
||||
// answer to "когда я это сделал?" read hours off, in the same sentence
|
||||
// shape the plan reads a day in.
|
||||
t = t.Local()
|
||||
if t.After(now.Add(-2*time.Minute)) && t.Before(now.Add(2*time.Minute)) {
|
||||
return "только что"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// "когда я это сделал?" answers off a fact's Ts, which the store hands back as
|
||||
// UTC, and the branch that names a wall clock printed it in whatever zone it
|
||||
// arrived in (V-614). The instant here is built three hours off this machine's
|
||||
// zone, so the assertion holds under TZ=UTC as well.
|
||||
func TestFormatTimeReadsHisClock(t *testing.T) {
|
||||
_, off := time.Now().Zone()
|
||||
away := time.FixedZone("away", off+3*60*60)
|
||||
stored := time.Now().Add(-72 * time.Hour).In(away)
|
||||
|
||||
got := formatTime(stored)
|
||||
if want := stored.Local().Format("15:04"); !strings.Contains(got, want) {
|
||||
t.Errorf("formatTime = %q, want the hour on his clock (%s)", got, want)
|
||||
}
|
||||
if bad := stored.Format("15:04"); strings.Contains(got, bad) {
|
||||
t.Errorf("formatTime = %q reads the zone the fact arrived in (%s)", got, bad)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMentionsUnknownDayReadsWordsNotStems — the defect V-581 found. The
|
||||
// weekday half of this guard was a list of stems matched with strings.Contains,
|
||||
// so "среди", "средство" and "средний" all read as Wednesday and the question
|
||||
// was answered with onlyNearDaysReply instead of a date.
|
||||
//
|
||||
// The other half of the fix is coverage: a stem list stops at the forms whoever
|
||||
// wrote it thought of, and "воскресеньях" was not one of them.
|
||||
func TestMentionsUnknownDayReadsWordsNotStems(t *testing.T) {
|
||||
for _, u := range []string{
|
||||
"какое число в понедельник",
|
||||
"какое число в среду",
|
||||
"какое число в среде",
|
||||
"что там по воскресеньям",
|
||||
"what is the date on friday",
|
||||
"какое число через неделю",
|
||||
} {
|
||||
if !mentionsUnknownDay(u) {
|
||||
t.Errorf("mentionsUnknownDay(%q) = false, want true", u)
|
||||
}
|
||||
}
|
||||
for _, u := range []string{
|
||||
"какое число в среднем",
|
||||
"сколько это в среднем",
|
||||
"какое сегодня средство",
|
||||
"какое число",
|
||||
} {
|
||||
if mentionsUnknownDay(u) {
|
||||
t.Errorf("mentionsUnknownDay(%q) = true; it names no day", u)
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -24,8 +24,8 @@
|
||||
"at": "21:00",
|
||||
"note": "he speaks. The whole voice path runs: push-to-talk, the STT seam parked with the golden transcript, the real router, the real store write, the phrasing contract.",
|
||||
"audio": "ru_fact",
|
||||
"expect_reply_contains": ["записала"],
|
||||
"expect_reply_lacks": ["записал ", "записал,", "записал.", "милый", "ваш"],
|
||||
"expect_reply_contains": ["записала", "выпил воды"],
|
||||
"expect_reply_lacks": ["записал ", "записал,", "записал.", "милый", "ваш", "стакан"],
|
||||
"expect_events": ["water"]
|
||||
},
|
||||
{
|
||||
|
||||
+3
-3
@@ -81,10 +81,10 @@
|
||||
},
|
||||
{
|
||||
"at": "08:55",
|
||||
"note": "stating a fact writes it and says so, in the feminine. This reply comes back through the replier from the scripted model, so the persona check is against generated text rather than a constant. The masculine forms are listed with their following character — \"записал \" and \"записал,\" — because \"записала\" contains \"записал\", and the earlier check on the comma alone passed on \"записал что ты выпил воды\".",
|
||||
"note": "stating a fact writes it and says so, in the feminine, and in his own words. The reply no longer comes from the model at all (V-592): a 1.7B asked to restate «я выпил воды» answered «Проверила, что ты выпел стакан воды», so the confirmation is now a deck frame with his sentence in it. The masculine forms are listed with their following character — \"записал \" and \"записал,\" — because \"записала\" contains \"записал\".",
|
||||
"say": "я выпил воды",
|
||||
"expect_reply_contains": ["записала"],
|
||||
"expect_reply_lacks": ["записал ", "записал,", "записал.", "милый"],
|
||||
"expect_reply_contains": ["записала", "я выпил воды"],
|
||||
"expect_reply_lacks": ["записал ", "записал,", "записал.", "милый", "стакан"],
|
||||
"expect_events": ["water"]
|
||||
},
|
||||
{
|
||||
|
||||
+14
-9
@@ -319,10 +319,7 @@ func (t *tickLoop) stopFinishedAlarms(ctx context.Context, keys []string, state
|
||||
if len(keys) == 0 {
|
||||
return nil
|
||||
}
|
||||
byName := make(map[string]loop.Rule, len(t.rules))
|
||||
for _, r := range t.rules {
|
||||
byName[r.Name] = r
|
||||
}
|
||||
byName := t.rulesByName()
|
||||
live := keys[:0:0]
|
||||
for _, key := range keys {
|
||||
outcome := ""
|
||||
@@ -381,19 +378,27 @@ func (t *tickLoop) repeatableRules(keys []string) []string {
|
||||
if len(keys) == 0 {
|
||||
return nil
|
||||
}
|
||||
wired := make(map[string]bool, len(t.rules))
|
||||
for _, r := range t.rules {
|
||||
wired[r.Name] = true
|
||||
}
|
||||
wired := t.rulesByName()
|
||||
out := keys[:0:0]
|
||||
for _, k := range keys {
|
||||
if wired[k] {
|
||||
if _, ok := wired[k]; ok {
|
||||
out = append(out, k)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// rulesByName indexes the wired rule set by name, for the two lookups above
|
||||
// that only care whether a key is still wired (repeatableRules) or need the
|
||||
// rule itself (stopFinishedAlarms).
|
||||
func (t *tickLoop) rulesByName() map[string]loop.Rule {
|
||||
byName := make(map[string]loop.Rule, len(t.rules))
|
||||
for _, r := range t.rules {
|
||||
byName[r.Name] = r
|
||||
}
|
||||
return byName
|
||||
}
|
||||
|
||||
// cachePhrase keeps the latest phrased nudge per rule for the sev4-repeat
|
||||
// path. writing under a mutex; the repeat path reads under the same. the
|
||||
// cache is bounded by the rule count (≤ ~30 per spec) so eviction is not a
|
||||
|
||||
@@ -23,6 +23,7 @@ type daemonAPI struct {
|
||||
chatFn func(ctx context.Context, conversation, text string) string
|
||||
getMCPServers func() []ipc.MCPServerStatus
|
||||
getEvents func(n int) []ipc.IntakeEvent
|
||||
getDecisions func(n int) []ipc.TurnDecision
|
||||
// nexus — the identity client, nil when no nexus block is configured. It
|
||||
// is what makes ResolveEntity answerable at all; without it the store
|
||||
// adapter's refusal stands, and a surface that wanted an entity id says so
|
||||
@@ -118,6 +119,17 @@ func (d *daemonAPI) TickTrace(ctx context.Context) (ipc.TickTrace, error) {
|
||||
return toIPCTickTrace(*trace), nil
|
||||
}
|
||||
|
||||
// TurnDecisions — the arbitration records of the last few turns (V-564). Nil
|
||||
// getter means voice was never wired, and that is an empty list rather than an
|
||||
// error: a box with no voice path has had no turns to arbitrate, which is not a
|
||||
// fault and renders as an empty table.
|
||||
func (d *daemonAPI) TurnDecisions(ctx context.Context, n int) ([]ipc.TurnDecision, error) {
|
||||
if d.getDecisions == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return d.getDecisions(n), nil
|
||||
}
|
||||
|
||||
func (d *daemonAPI) MorningStatus(ctx context.Context) ([]ipc.MorningRoutineStatus, error) {
|
||||
if d.getMorningStatus == nil {
|
||||
return nil, errors.New("mavend: morning status not available")
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/kami/maven/internal/dialogue"
|
||||
"github.com/kami/maven/internal/lexicon"
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
// turnRole — what this utterance IS, relative to the action Maven is in the
|
||||
// middle of assembling. The five roles are the owner's vocabulary (Vikunja
|
||||
// #558), plus the sixth answer a resolver is allowed to give: not_applicable,
|
||||
// which hands the turn back to generic dispatch.
|
||||
//
|
||||
// It exists because the arbitration used to be ordering. The clarify resolver,
|
||||
// the confirm gate, the follow-up merge and the repair marker all ran BEFORE
|
||||
// the router, so the claimant holding conversational state decided what an
|
||||
// utterance was without asking the one component whose job that is — and on
|
||||
// 2026-08-05 "какая сейчас погода в Риме?" became the time of a reminder,
|
||||
// because the extractor found "сейчас" in it and nothing looked at the rest.
|
||||
//
|
||||
// The rule that fixes that: a routed decision which stands on its own — its own
|
||||
// intent, its own slots filled from its own words — is not an answer, whatever
|
||||
// the extractor found inside it.
|
||||
type turnRole string
|
||||
|
||||
const (
|
||||
roleAnswer turnRole = "answer" // it fills the slot she asked about
|
||||
roleCorrection turnRole = "correction" // it replaces a value she already had
|
||||
roleSideQuery turnRole = "side_query" // a question of its own, asked mid-flow
|
||||
roleNewRequest turnRole = "new_request" // a different request entirely
|
||||
roleAside turnRole = "aside" // something he stated, not an answer
|
||||
roleCancel turnRole = "cancel" // call the pending action off
|
||||
roleNotApplicable turnRole = "not_applicable" // nothing is pending; not our turn
|
||||
)
|
||||
|
||||
// frameWords — the words that can stand around a bare slot value without adding
|
||||
// a request. Every member is a closed class from internal/lexicon: the frame
|
||||
// itself, the interrogatives, the parts of a spoken clock, the days and the
|
||||
// months. Assembled once; the sets are copies, so this cannot edit them.
|
||||
var frameWords = buildFrameWords()
|
||||
|
||||
func buildFrameWords() map[string]bool {
|
||||
out := make(map[string]bool)
|
||||
add := func(list []string) {
|
||||
for _, w := range list {
|
||||
out[strings.ToLower(w)] = true
|
||||
}
|
||||
}
|
||||
add(lexicon.SlotValueFrame())
|
||||
add(lexicon.Interrogatives())
|
||||
add(lexicon.PartsOfDay())
|
||||
add(lexicon.HalfHourWords())
|
||||
add(lexicon.DayOffsetWords())
|
||||
for i := 0; i < 7; i++ {
|
||||
out[lexicon.Weekday(i)] = true
|
||||
}
|
||||
for m := 1; m <= 12; m++ {
|
||||
out[lexicon.MonthGenitive(m)] = true
|
||||
}
|
||||
for hh := 0; hh <= 23; hh++ {
|
||||
add(strings.Fields(lexicon.HourSpoken(hh)))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// cancelWords — the same, for the words that call the pending action off.
|
||||
var cancelWords = buildCancelWords()
|
||||
|
||||
func buildCancelWords() map[string]bool {
|
||||
out := make(map[string]bool)
|
||||
for _, w := range lexicon.DialogueCancel() {
|
||||
out[strings.ToLower(w)] = true
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// turnTokens splits an utterance the way the router's own predicates do: over
|
||||
// letters and digits, lowercased, so punctuation and a clock's colon fall out.
|
||||
func turnTokens(text string) []string {
|
||||
return strings.FieldsFunc(strings.ToLower(text), func(r rune) bool {
|
||||
return !unicode.IsLetter(r) && !unicode.IsDigit(r)
|
||||
})
|
||||
}
|
||||
|
||||
// ownContent lists the tokens of an utterance that are neither frame nor value:
|
||||
// what it is about, over and above the thing she asked for. Numbers go out
|
||||
// because a number is the commonest slot value there is, and the closed number
|
||||
// and day lexicons go out with them.
|
||||
//
|
||||
// Empty ⇒ the utterance is a slot value and nothing else, however it is dressed
|
||||
// up. That is the whole test, and it is what separates "а что если в 11:00" —
|
||||
// which is a time, hedged — from "какая сейчас погода в Риме?", which leaves
|
||||
// "погода" and "риме" behind and is therefore about something.
|
||||
func ownContent(text string) []string {
|
||||
var out []string
|
||||
for _, tok := range turnTokens(text) {
|
||||
if frameWords[tok] || lexicon.IsFillerParticle(tok) {
|
||||
continue
|
||||
}
|
||||
if _, ok := lexicon.Cardinal(tok); ok {
|
||||
continue
|
||||
}
|
||||
if _, ok := lexicon.Ordinal(tok); ok {
|
||||
continue
|
||||
}
|
||||
if isNumeric(tok) {
|
||||
continue
|
||||
}
|
||||
out = append(out, tok)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func isNumeric(tok string) bool {
|
||||
for _, r := range tok {
|
||||
if !unicode.IsDigit(r) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return tok != ""
|
||||
}
|
||||
|
||||
// isCancel reports whether the utterance is nothing but a call-off. Every
|
||||
// content token has to be a cancel word, so "забудь" ends the exchange and
|
||||
// "забудь купить молоко" does not.
|
||||
func isCancel(text string) bool {
|
||||
content := ownContent(text)
|
||||
if len(content) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, tok := range content {
|
||||
if !cancelWords[tok] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// carriesOwnRequest reads the ROUTED decision for the thing that decides this:
|
||||
// does the utterance ask for something in its own right? Each intent is asked
|
||||
// the question in its own terms, because "its own slots filled from its own
|
||||
// words" means a different field for each of them.
|
||||
//
|
||||
// A query or a system question needs no further evidence — the router already
|
||||
// read a question in these words. The write intents need the verb or the slot
|
||||
// that names the request, so a bare value the router guessed a home for does
|
||||
// not count as one.
|
||||
func carriesOwnRequest(dec router.Decision, text string) bool {
|
||||
if dec.Clarify {
|
||||
// The router itself was unsure. An utterance she could not route is
|
||||
// not an utterance that outranks the question in front of it.
|
||||
return false
|
||||
}
|
||||
switch dec.Intent {
|
||||
case router.IntentQuery, router.IntentSystem:
|
||||
return true
|
||||
case router.IntentReminder:
|
||||
return carriesReminderVerb(text)
|
||||
case router.IntentFact, router.IntentNote:
|
||||
return router.CarriesCaptureVerb(text)
|
||||
case router.IntentAct:
|
||||
// An act that resolved to a capability is a command. One that did not
|
||||
// is words she cannot execute anyway, so it stays an answer and gets
|
||||
// re-asked — the same thing that happens to it today.
|
||||
return dec.Slots.HasFn
|
||||
default: // chat
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// carriesReminderVerb — "напомни" and its forms, matched over tokens. The
|
||||
// reminder verbs are a closed lexicon and are not capture verbs, so
|
||||
// CarriesCaptureVerb never sees them.
|
||||
func carriesReminderVerb(text string) bool {
|
||||
toks := turnTokens(text)
|
||||
for _, v := range lexicon.ReminderVerbs() {
|
||||
for _, t := range toks {
|
||||
if t == strings.ToLower(v) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// offlineOwnRequest is the shape half of the evidence: the offline token tests,
|
||||
// which cost nothing and never depend on the model that produced the routing.
|
||||
// It is also the whole answer when there is no route to read — the classifier
|
||||
// is the failure floor and a turn must never break on the model.
|
||||
func offlineOwnRequest(text string) bool {
|
||||
return router.IsQuestionShaped(text) || router.CarriesCaptureVerb(text) || carriesReminderVerb(text)
|
||||
}
|
||||
|
||||
// classifyTurnRole decides what this utterance is against the pending action.
|
||||
//
|
||||
// The fast path is the first line and it is a fast path to the SAME answer, not
|
||||
// a second decision procedure: an utterance with no content of its own can
|
||||
// never be a request of its own, so it can never be anything but an answer, and
|
||||
// the route below would spend a second on the resident model to say so. Every
|
||||
// other utterance is routed first, and the role is read off the decision.
|
||||
//
|
||||
// `routed` is the turn's routing, already computed; ok is false when there was
|
||||
// none to compute (no router wired, or the route failed). A failed route falls
|
||||
// to the offline shape tests rather than breaking the turn.
|
||||
func classifyTurnRole(q *dialogue.PendingQuestion, text string, answer dialogue.Slots, routed router.Decision, ok bool) turnRole {
|
||||
if isCancel(text) {
|
||||
return roleCancel
|
||||
}
|
||||
// Two pieces of evidence, and the content gate in front of both. The shape
|
||||
// tests are the floor and answer for free; the route is what sees a request
|
||||
// with no shape to it — "погода в риме" asks a question and carries neither
|
||||
// a question mark nor an interrogative, and only the router knows that.
|
||||
//
|
||||
// An utterance of pure frame gets one more chance, and V-577 is why. Every
|
||||
// token of "что у меня сегодня?" is frame, so the content gate called it an
|
||||
// answer, the parked reminder took "сегодня" for its time, and the question
|
||||
// he asked was answered nowhere. A routed intent beats a frame match,
|
||||
// because the frame is a hint and the route is a decision.
|
||||
//
|
||||
// The condition is that it fills nothing she asked about. That keeps the
|
||||
// hedged "а что если в 11:00" an answer, which is what it is: it carries the
|
||||
// hour, and no route saying "question" changes that. It works because the
|
||||
// extractor no longer reads a day word as the current clock, so a sentence
|
||||
// that names no hour now fills nothing to weigh.
|
||||
own := false
|
||||
if len(ownContent(text)) > 0 {
|
||||
own = offlineOwnRequest(text) || (ok && carriesOwnRequest(routed, text))
|
||||
} else if ok && fillsNothingAsked(q, answer) {
|
||||
own = carriesOwnRequest(routed, text)
|
||||
}
|
||||
if !own {
|
||||
if isAside(q, text, answer, routed, ok) {
|
||||
return roleAside
|
||||
}
|
||||
if replacesFilledSlot(q, answer) {
|
||||
return roleCorrection
|
||||
}
|
||||
return roleAnswer
|
||||
}
|
||||
if router.IsQuestionShaped(text) || (ok && (routed.Intent == router.IntentQuery || routed.Intent == router.IntentSystem)) {
|
||||
return roleSideQuery
|
||||
}
|
||||
return roleNewRequest
|
||||
}
|
||||
|
||||
// isAside reports whether the utterance is something he STATED while she was
|
||||
// waiting on a question (V-577 shape 2).
|
||||
//
|
||||
// "у меня новый ноутбук" said into a parked reminder was dropped in silence: it
|
||||
// carries no capture verb, so it is not a request of its own, and it fills no
|
||||
// slot, so it is not an answer either. Neither storing it nor saying it was
|
||||
// ignored is the one behaviour that is wrong, and it was the behaviour.
|
||||
//
|
||||
// Three conditions, and all three are needed. The route has to call it a
|
||||
// statement AND stand behind that, so a bare time is never an aside. It has to
|
||||
// fill none of what she asked about, so an answer she can use stays an answer.
|
||||
// And it has to say something, so a shrug is still a failed answer and still
|
||||
// spends a retry.
|
||||
func isAside(q *dialogue.PendingQuestion, text string, answer dialogue.Slots, routed router.Decision, ok bool) bool {
|
||||
if !ok || q == nil {
|
||||
return false
|
||||
}
|
||||
if !statesSomething(routed) {
|
||||
return false
|
||||
}
|
||||
if len(ownContent(text)) == 0 {
|
||||
return false
|
||||
}
|
||||
return fillsNothingAsked(q, answer)
|
||||
}
|
||||
|
||||
// statesSomething reports whether the route is evidence that these words state
|
||||
// a thing, rather than a guess she has to interrupt a flow over.
|
||||
//
|
||||
// Two kinds of evidence, and the second one exists because the classifier floor
|
||||
// marks nearly everything Clarify. A parsed fact key comes from the
|
||||
// deterministic fact parser and not from a similarity score, so "я выпил воды"
|
||||
// is a statement on any engine. A confident note or fact is the other kind, and
|
||||
// that is the one the resident model gives for "у меня новый ноутбук".
|
||||
func statesSomething(routed router.Decision) bool {
|
||||
switch routed.Intent {
|
||||
case router.IntentFact:
|
||||
return routed.Slots.HasKey || !routed.Clarify
|
||||
case router.IntentNote:
|
||||
return !routed.Clarify
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// fillsNothingAsked reports whether the utterance gave her none of what she
|
||||
// asked for. Nothing is pending counts as nothing filled.
|
||||
func fillsNothingAsked(q *dialogue.PendingQuestion, answer dialogue.Slots) bool {
|
||||
if q == nil {
|
||||
return true
|
||||
}
|
||||
return len(dialogue.StillMissing(q.Missing, answer)) == len(q.Missing)
|
||||
}
|
||||
|
||||
// replacesFilledSlot reports whether the utterance overwrites something the
|
||||
// pending action already had, rather than filling the gap she asked about —
|
||||
// "нет, на девять" while she is waiting for the subject. Both are handled the
|
||||
// same way (dialogue.Answer already prefers the newer value), so this only
|
||||
// names the turn honestly for the log and for the decision trace V-564 adds.
|
||||
func replacesFilledSlot(q *dialogue.PendingQuestion, answer dialogue.Slots) bool {
|
||||
if q == nil {
|
||||
return false
|
||||
}
|
||||
asked := make(map[dialogue.Slot]bool, len(q.Missing))
|
||||
for _, s := range q.Missing {
|
||||
asked[s] = true
|
||||
}
|
||||
if answer.HasTime && q.Slots.HasTime && !asked[dialogue.SlotTime] && !answer.Time.Equal(q.Slots.Time) {
|
||||
return true
|
||||
}
|
||||
if answer.HasKey && q.Slots.HasKey && !asked[dialogue.SlotKey] && answer.Key != q.Slots.Key {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/dialogue"
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
// TestOwnContentSeparatesAValueFromAQuestion pins the test the whole role
|
||||
// classifier rests on: after the frame, the numbers and the closed time sets
|
||||
// come out, does anything of his own remain? A hedged time leaves nothing. A
|
||||
// question about the weather leaves the weather.
|
||||
func TestOwnContentSeparatesAValueFromAQuestion(t *testing.T) {
|
||||
cases := []struct {
|
||||
text string
|
||||
own bool
|
||||
}{
|
||||
{"в 11:00", false},
|
||||
{"в семь вечера", false},
|
||||
{"нет, в 15:00", false},
|
||||
{"а что если в 11:00", false},
|
||||
{"на 9", false},
|
||||
{"а, да, прости — на 9", false},
|
||||
{"завтра", false},
|
||||
{"в половине восьмого", false},
|
||||
{"какая сейчас погода в Риме?", true},
|
||||
{"кто изобрёл телефон", true},
|
||||
{"напомни в 11:00", true},
|
||||
{"позвонить маме", true},
|
||||
{"запиши что я пил воду", true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := len(ownContent(tc.text)) > 0; got != tc.own {
|
||||
t.Errorf("ownContent(%q) = %v, want own content = %v", tc.text, ownContent(tc.text), tc.own)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCancelIsTheWholeUtterance — a call-off calls the request off, and a
|
||||
// sentence that merely contains the word does not.
|
||||
func TestCancelIsTheWholeUtterance(t *testing.T) {
|
||||
for _, yes := range []string{"отмена", "забудь", "неважно", "проехали", "cancel", "ой, отмена"} {
|
||||
if !isCancel(yes) {
|
||||
t.Errorf("isCancel(%q) = false, want true", yes)
|
||||
}
|
||||
}
|
||||
for _, no := range []string{"забудь купить молоко", "в 11:00", "позвонить маме", ""} {
|
||||
if isCancel(no) {
|
||||
t.Errorf("isCancel(%q) = true, want false", no)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestTurnRoleReadsTheRoutedDecision — the inversion itself. The same utterance
|
||||
// gets a different role depending on what the router made of it, which is the
|
||||
// evidence the old guard never had.
|
||||
func TestTurnRoleReadsTheRoutedDecision(t *testing.T) {
|
||||
q := &dialogue.PendingQuestion{
|
||||
Intent: dialogue.Intent(router.IntentReminder),
|
||||
Missing: []dialogue.Slot{dialogue.SlotTime},
|
||||
}
|
||||
dec := func(in router.Intent, s router.Slots) router.Decision {
|
||||
return router.Decision{Intent: in, Slots: s}
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
text string
|
||||
routed router.Decision
|
||||
ok bool
|
||||
answer dialogue.Slots
|
||||
want turnRole
|
||||
}{
|
||||
{
|
||||
// The measured defect. The extractor finds "сейчас" and would have
|
||||
// closed the gap with it; the route says this is a question of its
|
||||
// own, and the question wins.
|
||||
name: "a world question mid-flow is a side query",
|
||||
text: "какая сейчас погода в Риме?",
|
||||
routed: dec(router.IntentQuery, router.Slots{Text: "какая сейчас погода в Риме?"}),
|
||||
ok: true,
|
||||
answer: dialogue.Slots{HasTime: true, Time: time.Now()},
|
||||
want: roleSideQuery,
|
||||
},
|
||||
{
|
||||
name: "a hedged time is an answer even routed as a query",
|
||||
text: "а что если в 11:00",
|
||||
routed: dec(router.IntentQuery, router.Slots{Text: "а что если в 11:00"}),
|
||||
ok: true,
|
||||
answer: dialogue.Slots{HasTime: true, Time: time.Now()},
|
||||
want: roleAnswer,
|
||||
},
|
||||
{
|
||||
name: "a fresh reminder is a new request",
|
||||
text: "напомни завтра позвонить маме",
|
||||
routed: dec(router.IntentReminder, router.Slots{Text: "позвонить маме", HasTime: true}),
|
||||
ok: true,
|
||||
want: roleNewRequest,
|
||||
},
|
||||
{
|
||||
name: "a capture is a new request",
|
||||
text: "запиши что я пил воду",
|
||||
routed: dec(router.IntentFact, router.Slots{Key: "water", HasKey: true}),
|
||||
ok: true,
|
||||
want: roleNewRequest,
|
||||
},
|
||||
{
|
||||
name: "an act that resolved to a capability is a new request",
|
||||
text: "выключи свет в спальне",
|
||||
routed: dec(router.IntentAct, router.Slots{Fn: "light_off", HasFn: true}),
|
||||
ok: true,
|
||||
want: roleNewRequest,
|
||||
},
|
||||
{
|
||||
// She could not route it. An utterance she did not understand does
|
||||
// not outrank the question in front of it.
|
||||
name: "a clarify decision is not a request of its own",
|
||||
text: "выключи свет",
|
||||
routed: router.Decision{Intent: router.IntentAct, Slots: router.Slots{Fn: "light_off", HasFn: true}, Clarify: true},
|
||||
ok: true,
|
||||
want: roleAnswer,
|
||||
},
|
||||
{
|
||||
// V-577 shape 1. Every token is frame, so the content gate called
|
||||
// this an answer and the reminder took "сегодня" for its time. It
|
||||
// fills nothing she asked about, so the route decides, and the route
|
||||
// says the calendar answers it.
|
||||
name: "an agenda question of pure frame words is a side query",
|
||||
text: "что у меня сегодня?",
|
||||
routed: dec(router.IntentQuery, router.Slots{}),
|
||||
ok: true,
|
||||
want: roleSideQuery,
|
||||
},
|
||||
{
|
||||
// V-577 shape 2. Neither a slot value nor a request nor a cancel.
|
||||
// It was dropped in silence; it is an aside, and an aside is stored
|
||||
// and re-asked.
|
||||
name: "a fact stated mid-flow is an aside",
|
||||
text: "у меня новый ноутбук",
|
||||
routed: dec(router.IntentNote, router.Slots{Text: "у меня новый ноутбук"}),
|
||||
ok: true,
|
||||
want: roleAside,
|
||||
},
|
||||
{
|
||||
// A route she is not sure of is not evidence that he stated
|
||||
// anything, and "позвонить маме" is the answer to the other half of
|
||||
// a reminder.
|
||||
name: "an unsure note is not an aside",
|
||||
text: "позвонить маме",
|
||||
routed: router.Decision{Intent: router.IntentNote, Clarify: true},
|
||||
ok: true,
|
||||
want: roleAnswer,
|
||||
},
|
||||
{
|
||||
name: "a bare noun that answers nothing is still an answer",
|
||||
text: "ага",
|
||||
ok: false,
|
||||
want: roleAnswer,
|
||||
},
|
||||
{
|
||||
name: "no route to read falls back to the shape",
|
||||
text: "кто изобрёл телефон",
|
||||
ok: false,
|
||||
want: roleSideQuery,
|
||||
},
|
||||
{
|
||||
name: "a call-off needs no route at all",
|
||||
text: "отмена",
|
||||
ok: false,
|
||||
want: roleCancel,
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := classifyTurnRole(q, tc.text, tc.answer, tc.routed, tc.ok); got != tc.want {
|
||||
t.Errorf("%s: classifyTurnRole(%q) = %s, want %s", tc.name, tc.text, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestTurnRoleNamesACorrection — the answer overwrites a slot she was not
|
||||
// asking about. Handled like an answer, named as what it is.
|
||||
func TestTurnRoleNamesACorrection(t *testing.T) {
|
||||
nine := time.Date(2026, 8, 6, 9, 0, 0, 0, time.UTC)
|
||||
q := &dialogue.PendingQuestion{
|
||||
Intent: dialogue.Intent(router.IntentReminder),
|
||||
Missing: []dialogue.Slot{dialogue.SlotText},
|
||||
Slots: dialogue.Slots{HasTime: true, Time: nine.Add(2 * time.Hour)},
|
||||
}
|
||||
got := classifyTurnRole(q, "нет, на 9", dialogue.Slots{HasTime: true, Time: nine}, router.Decision{}, false)
|
||||
if got != roleCorrection {
|
||||
t.Fatalf("role = %s, want %s", got, roleCorrection)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRomeIsAnsweredAndTheReminderIsNotInvented — the measured failure of
|
||||
// 2026-08-05, end to end through the real cascade. "напомни позвонить маме"
|
||||
// parks the time question; the weather question that follows must not become
|
||||
// its answer and must not create a reminder for a time nobody asked for.
|
||||
//
|
||||
// V-560 got that far by DROPPING the parked request and saying so, and the
|
||||
// owner rejected the notice on sight: he did not ask to lose the reminder. So
|
||||
// the contract here is V-561's — the flow is suspended, this turn's reply ends
|
||||
// with the question coming back, and nothing says anything was let go.
|
||||
func TestRomeIsAnsweredAndTheReminderIsNotInvented(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
h, st := newRoutingClarifyHandler(t)
|
||||
|
||||
if reply := h.handleText(ctx, "web", "напомни позвонить маме"); !strings.Contains(reply, "?") {
|
||||
t.Fatalf("expected the time question, got %q", reply)
|
||||
}
|
||||
reply := h.handleText(ctx, "web", "какая сейчас погода в Риме?")
|
||||
if strings.Contains(reply, "напомню") {
|
||||
t.Fatalf("the question was eaten as the reminder's time again: %q", reply)
|
||||
}
|
||||
if strings.Contains(reply, clarifyDropped) {
|
||||
t.Fatalf("a side query suspends the flow; nothing was dropped, so nothing may say so: %q", reply)
|
||||
}
|
||||
resumed, _ := clarifyResumedFor(dialogue.SlotTime)
|
||||
if !strings.HasSuffix(reply, resumed) {
|
||||
t.Fatalf("the reply must end with the resumed question %q, got %q", resumed, reply)
|
||||
}
|
||||
if reminders, err := st.DueReminders(ctx, h.now().Add(48*time.Hour)); err != nil || len(reminders) != 0 {
|
||||
t.Fatalf("a reminder was invented for a time nobody asked for: %v err=%v", reminders, err)
|
||||
}
|
||||
// Still parked, and still on its first attempt: he answered the side query,
|
||||
// not this question, so no retry may have been spent on it.
|
||||
q := h.clarifyStore.Get(dialogueIDFor(sourceText, "web"), h.now())
|
||||
if q == nil {
|
||||
t.Fatal("the parked question was dropped instead of suspended")
|
||||
}
|
||||
if q.Attempts != 1 {
|
||||
t.Fatalf("the side query spent a clarify attempt: attempts = %d, want 1", q.Attempts)
|
||||
}
|
||||
if !strings.Contains(q.Utterance, "маме") {
|
||||
t.Fatalf("the suspended request lost what it was about: %q", q.Utterance)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClarifyCancelEndsTheExchange — "отмена" while she is waiting calls the
|
||||
// half-built request off, out loud, and creates nothing.
|
||||
func TestClarifyCancelEndsTheExchange(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
h, st := newRoutingClarifyHandler(t)
|
||||
|
||||
if reply := h.handleText(ctx, "web", "напомни позвонить маме"); !strings.Contains(reply, "?") {
|
||||
t.Fatalf("expected the time question, got %q", reply)
|
||||
}
|
||||
if reply := h.handleText(ctx, "web", "отмена"); reply != clarifyCancelled {
|
||||
t.Fatalf("reply = %q, want %q", reply, clarifyCancelled)
|
||||
}
|
||||
if reminders, err := st.DueReminders(ctx, h.now().Add(48*time.Hour)); err != nil || len(reminders) != 0 {
|
||||
t.Fatalf("a cancelled request still landed: %v err=%v", reminders, err)
|
||||
}
|
||||
if h.clarifyStore.Get(dialogueIDFor(sourceText, "web"), h.now()) != nil {
|
||||
t.Fatal("a cancelled exchange must leave nothing parked")
|
||||
}
|
||||
}
|
||||
|
||||
// TestTheTurnIsRoutedOnce — the cost bound. A turn with a question parked pays
|
||||
// for one extra route and not two: the clarify resolver and the pipeline read
|
||||
// the same memo.
|
||||
func TestTheTurnIsRoutedOnce(t *testing.T) {
|
||||
h, _ := newRoutingClarifyHandler(t)
|
||||
rt := h.newTurnRoute("какая сейчас погода в Риме?", h.now())
|
||||
ctx := withTurnRoute(withDialogueID(context.Background(), voiceDialogueID), rt)
|
||||
|
||||
first, ok := h.routeForRole(ctx, rt.text)
|
||||
if !ok {
|
||||
t.Fatal("the cascade must produce a decision to classify against")
|
||||
}
|
||||
second, _, _, err := rt.resolve(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve: %v", err)
|
||||
}
|
||||
if second.Intent != first.Intent || second.Utterance != first.Utterance {
|
||||
t.Fatalf("the pipeline routed again and got something else: %+v vs %+v", second, first)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/dialogue"
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
// turnRoute is this turn's routing, computed at most once.
|
||||
//
|
||||
// It exists because the arbitration was inverted (Vikunja #560): the clarify
|
||||
// resolver now reads the routed decision before deciding what the utterance is,
|
||||
// and the pipeline then acts on that same decision. Routing twice would cost a
|
||||
// second on the resident model and — worse — could disagree with itself, which
|
||||
// is exactly the class of bug this task is about.
|
||||
type turnRoute struct {
|
||||
h *reactiveHandler
|
||||
text string
|
||||
now time.Time
|
||||
|
||||
once sync.Once
|
||||
dec router.Decision
|
||||
cont bool
|
||||
prev *dialogue.Session
|
||||
err error
|
||||
|
||||
// dropped — what she let go of this turn and must say out loud. A parked
|
||||
// request that dies without a word leaves him thinking it landed.
|
||||
dropped string
|
||||
|
||||
// resume — the parked question, re-worded, to put AFTER this turn's answer
|
||||
// (Vikunja #561). A side query does not end the flow it interrupted, so the
|
||||
// reply carries two acts: the answer he asked for, then the question he
|
||||
// still owes her. Empty ⇒ nothing was suspended.
|
||||
resume string
|
||||
// suspended — a flow is parked underneath this turn. askClarify reads it to
|
||||
// decide between Put (replace the top) and Push (keep the flow and stack the
|
||||
// new question on it), because a side query that needs clarifying of its own
|
||||
// must not overwrite the thing it interrupted.
|
||||
suspended bool
|
||||
}
|
||||
|
||||
type turnRouteKey struct{}
|
||||
|
||||
func (h *reactiveHandler) newTurnRoute(text string, now time.Time) *turnRoute {
|
||||
return &turnRoute{h: h, text: text, now: now}
|
||||
}
|
||||
|
||||
func withTurnRoute(ctx context.Context, rt *turnRoute) context.Context {
|
||||
return context.WithValue(ctx, turnRouteKey{}, rt)
|
||||
}
|
||||
|
||||
// turnRouteFrom returns the turn's memo, or nil when the caller is not inside
|
||||
// runTurn — a unit test calling one resolver directly, most often.
|
||||
func turnRouteFrom(ctx context.Context) *turnRoute {
|
||||
rt, _ := ctx.Value(turnRouteKey{}).(*turnRoute)
|
||||
return rt
|
||||
}
|
||||
|
||||
// resolve does the routing exactly as step 5 of runTurn does it: an elliptical
|
||||
// follow-up is answered from the previous turn, everything else goes to the
|
||||
// router. One copy of that, so the pre-route the clarify resolver reads and the
|
||||
// decision the pipeline acts on cannot drift apart.
|
||||
func (r *turnRoute) resolve(ctx context.Context) (router.Decision, bool, *dialogue.Session, error) {
|
||||
r.once.Do(func() {
|
||||
if r.h.dialogueSessions != nil {
|
||||
r.prev = r.h.dialogueSessions.Get(dialogueIDOf(ctx), r.now)
|
||||
}
|
||||
if dec, cont := continuationDecision(r.prev, r.text, r.now); cont {
|
||||
log.Printf("voice: continuation of %s from the previous turn", dec.Intent)
|
||||
r.dec, r.cont = dec, true
|
||||
return
|
||||
}
|
||||
if r.h.router == nil {
|
||||
r.err = router.ErrNoIntents
|
||||
return
|
||||
}
|
||||
r.dec, r.err = r.h.router.Route(ctx, r.text, r.now)
|
||||
})
|
||||
return r.dec, r.cont, r.prev, r.err
|
||||
}
|
||||
|
||||
// routeForRole gives the role classifier the turn's routed decision. The second
|
||||
// return is false when there is no usable decision — no router wired, or the
|
||||
// route failed — and the classifier falls back to its offline tests then. A
|
||||
// turn must never break on the model, so the error is logged and swallowed
|
||||
// here; step 5 reads the same memo and reports it the way it always has.
|
||||
func (h *reactiveHandler) routeForRole(ctx context.Context, text string) (router.Decision, bool) {
|
||||
rt := turnRouteFrom(ctx)
|
||||
if rt == nil {
|
||||
rt = h.newTurnRoute(text, h.now())
|
||||
}
|
||||
dec, _, _, err := rt.resolve(ctx)
|
||||
if err != nil {
|
||||
log.Printf("voice: role — no route to classify against (%v), falling back to the offline tests", err)
|
||||
return router.Decision{}, false
|
||||
}
|
||||
return dec, true
|
||||
}
|
||||
|
||||
// needsRoute reports whether classifying this utterance's role is worth a
|
||||
// route. It is not: an utterance with no content of its own carries no request
|
||||
// of its own, so the classifier reaches the same answer without the model. A
|
||||
// call-off is the same — it is read off a closed lexicon and nothing else.
|
||||
//
|
||||
// This is a fast path to the SAME answer and must stay one. If it ever needs a
|
||||
// rule the classifier does not have, it has become a second decision procedure
|
||||
// and it is the thing V-560 deleted.
|
||||
//
|
||||
// A question shape is the exception and V-577 is why (measured 2026-08-06).
|
||||
// "что у меня сегодня?" is an interrogative, a preposition, a particle and a day
|
||||
// word, so every token of it is frame and it left no content of its own. The
|
||||
// fast path called it an answer, the parked reminder read "сегодня" as its time,
|
||||
// and the question he asked was never answered. Asked alone the same sentence
|
||||
// routes to query at stage 0, so the route knew and was never consulted.
|
||||
func needsRoute(text string) bool {
|
||||
if isCancel(text) {
|
||||
return false
|
||||
}
|
||||
return len(ownContent(text)) > 0 || router.IsQuestionShaped(text)
|
||||
}
|
||||
+80
-35
@@ -53,6 +53,7 @@ import (
|
||||
|
||||
"github.com/kami/maven/internal/audio"
|
||||
"github.com/kami/maven/internal/crawl"
|
||||
"github.com/kami/maven/internal/decision"
|
||||
"github.com/kami/maven/internal/dialogue"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/lexicon"
|
||||
@@ -137,6 +138,13 @@ type reactiveHandler struct {
|
||||
// slot per reach, not one for the box. nil ⇒ no carry-over.
|
||||
dialogueSessions *dialogue.SessionStore
|
||||
|
||||
// decisions holds the last few turns' arbitration records (V-564): who
|
||||
// claimed the turn, who lost it and who was never asked. In memory and
|
||||
// bounded, because a turn record is read minutes later or never, and none
|
||||
// of his words belong in a table that outlives the diagnosis. nil ⇒ nothing
|
||||
// is recorded, which is what a test that did not ask for one gets.
|
||||
decisions *decision.Ring
|
||||
|
||||
// clarifyStore parks the request behind an open question she asked (see
|
||||
// clarify.go). nil ⇒ she falls back to the canned "не поняла" reply.
|
||||
clarifyStore *dialogue.ClarifyStore
|
||||
@@ -247,7 +255,27 @@ const (
|
||||
// path wraps it in stt/tts, the text path returns it as-is.
|
||||
//
|
||||
// The ordering is load-bearing — see the step comments.
|
||||
func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSource) string {
|
||||
func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSource) (reply string) {
|
||||
// 0. the decision record (V-564). Installed here rather than in the IPC
|
||||
// entry point, so the mic, telegram and the web all leave the same trail —
|
||||
// a record only the web produced would be missing exactly the turns that
|
||||
// are hardest to reproduce. It rides the context, costs a few dozen structs
|
||||
// on a human-rate path, and no claim site can change a route with it.
|
||||
if h.decisions != nil {
|
||||
var rec *decision.Record
|
||||
ctx, rec = decision.With(ctx, text)
|
||||
decision.Expect(ctx, decision.StagePreRoute, preRouteLadder)
|
||||
defer func() { h.decisions.Push(rec.Finish(h.now())) }()
|
||||
}
|
||||
|
||||
// 0b. the turn's routing, computed at most once and shared (Vikunja #560).
|
||||
// The clarify resolver reads it to decide what this utterance IS before
|
||||
// claiming it, and step 5 acts on the same decision — routing twice would
|
||||
// cost a second on the resident model and could disagree with itself.
|
||||
now := h.now()
|
||||
rt := h.newTurnRoute(text, now)
|
||||
ctx = withTurnRoute(ctx, rt)
|
||||
|
||||
// 1. expired clarify — a question was parked but its TTL ran out, so the
|
||||
// request behind it is gone. Say that out loud (see clarify.go) and carry
|
||||
// on: these words are still routed as a fresh utterance below, with the
|
||||
@@ -263,7 +291,7 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour
|
||||
// 2. confirm turn — if a destructive act is parked, this utterance is its
|
||||
// y/n answer, not a fresh command. Handled before routing so "да" doesn't
|
||||
// get classified as some other intent.
|
||||
if reply, handled := h.resolveConfirm(ctx, text); handled {
|
||||
if reply, handled := h.resolveConfirm(ctx, text); notePreRoute(ctx, "confirm", handled) {
|
||||
return withNotice(expiredNotice, reply)
|
||||
}
|
||||
|
||||
@@ -275,15 +303,27 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour
|
||||
// so the notice is empty here in practice. withNotice anyway: every exit
|
||||
// from runTurn carries it, and that is what stops the next one from
|
||||
// forgetting.
|
||||
if reply, handled := h.resolveClarifyAnswer(ctx, text); handled {
|
||||
if reply, handled := h.resolveClarifyAnswer(ctx, text); notePreRoute(ctx, "clarify-answer", handled) {
|
||||
return withNotice(expiredNotice, reply)
|
||||
}
|
||||
// It did not claim the turn. If it let a parked request go to get out of the
|
||||
// way, that has to be said in front of whatever these words are answered
|
||||
// with — carried on the same notice, so every exit below keeps it.
|
||||
expiredNotice = withNotice(expiredNotice, rt.dropped)
|
||||
|
||||
// 3b. and if it SUSPENDED a request instead of letting it go, the question
|
||||
// comes back on the end of whatever these words are answered with (Vikunja
|
||||
// #561). A deferred append rather than a call at each exit: there are eight
|
||||
// returns between here and the replier, and the flow has to survive all of
|
||||
// them — one that forgot would be a request parked for ever, waiting for an
|
||||
// answer to a question he never heard asked.
|
||||
defer func() { reply = withResumed(reply, rt.resume) }()
|
||||
|
||||
// 4. quiet-hours toggle — keyword match, not classifier-dependent.
|
||||
// "тихий режим" / "quiet on" would route through the classifier
|
||||
// unreliably (it's a command, not a free-form query), so we match it
|
||||
// before routing. Same pattern as the confirm turn above.
|
||||
if reply, handled := h.resolveQuietToggle(ctx, text, src); handled {
|
||||
if reply, handled := h.resolveQuietToggle(ctx, text, src); notePreRoute(ctx, "quiet-toggle", handled) {
|
||||
return withNotice(expiredNotice, reply)
|
||||
}
|
||||
|
||||
@@ -291,14 +331,14 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour
|
||||
// sent. Only handled when a pending nudge is actually inside the window
|
||||
// (snooze.go); otherwise the words route normally, because "потом" is an
|
||||
// ordinary word and eating every one of them would break real sentences.
|
||||
if reply, handled := h.resolveSnooze(ctx, text, src); handled {
|
||||
if reply, handled := h.resolveSnooze(ctx, text, src); notePreRoute(ctx, "snooze", handled) {
|
||||
return withNotice(expiredNotice, reply)
|
||||
}
|
||||
|
||||
// 4c. spoken ack — "готово" closes that same nudge as `acted`. Only the
|
||||
// contentless form is intercepted here; "выпил воды" keeps routing and
|
||||
// closes the nudge after its fact lands (ackFromFact, step 8b).
|
||||
if reply, handled := h.resolveAck(ctx, text, src); handled {
|
||||
if reply, handled := h.resolveAck(ctx, text, src); notePreRoute(ctx, "ack", handled) {
|
||||
return withNotice(expiredNotice, reply)
|
||||
}
|
||||
|
||||
@@ -306,7 +346,7 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour
|
||||
// turn and names what it should have been (repair.go). Before routing,
|
||||
// like the confirm and clarify turns: routing the correction as a fresh
|
||||
// utterance files the correction itself instead of fixing anything.
|
||||
if reply, handled := h.resolveRepair(ctx, text); handled {
|
||||
if reply, handled := h.resolveRepair(ctx, text); notePreRoute(ctx, "repair", handled) {
|
||||
return withNotice(expiredNotice, reply)
|
||||
}
|
||||
|
||||
@@ -314,7 +354,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, src); handled {
|
||||
if reply, handled := h.resolveCandidate(ctx, text, src); notePreRoute(ctx, "ordinal", handled) {
|
||||
return withNotice(expiredNotice, reply)
|
||||
}
|
||||
|
||||
@@ -323,22 +363,7 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour
|
||||
// missing, so no amount of routing recovers it, and the model's guess
|
||||
// costs seconds to obtain and is close to a coin flip. Everything else
|
||||
// goes to the router.
|
||||
var (
|
||||
dec router.Decision
|
||||
err error
|
||||
prev *dialogue.Session
|
||||
)
|
||||
now := h.now()
|
||||
if h.dialogueSessions != nil {
|
||||
prev = h.dialogueSessions.Get(dialogueIDOf(ctx), now)
|
||||
}
|
||||
cont := false
|
||||
if dec, cont = continuationDecision(prev, text, now); cont {
|
||||
log.Printf("voice: continuation of %s from the previous turn", dec.Intent)
|
||||
}
|
||||
if !cont {
|
||||
dec, err = h.router.Route(ctx, text, now)
|
||||
}
|
||||
dec, cont, prev, err := rt.resolve(ctx)
|
||||
if err != nil {
|
||||
// ErrNoIntents ⇒ classifier unseeded (cold boot). reply with a
|
||||
// "still warming up" rather than a wire error.
|
||||
@@ -359,21 +384,31 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour
|
||||
// ("а завтра?" … "а послезавтра?") keeps working.
|
||||
if h.dialogueSessions != nil {
|
||||
if !cont {
|
||||
dec = followUpMerge(prev, dec, now)
|
||||
merged := followUpMerge(prev, dec, now)
|
||||
noteMerge(ctx, dec, merged)
|
||||
dec = merged
|
||||
}
|
||||
if !dec.Clarify {
|
||||
h.rememberTurn(ctx, prev, dec, now)
|
||||
}
|
||||
}
|
||||
|
||||
// 7. clarify — she is not sure. If one named thing is missing, ask about it
|
||||
// and park the request (clarify.go); otherwise the replier's canned reply
|
||||
// stands.
|
||||
if dec.Clarify {
|
||||
// 7. clarify — something she needs is missing. If one named thing is missing,
|
||||
// ask about it and park the request (clarify.go); otherwise the replier's
|
||||
// canned reply stands.
|
||||
//
|
||||
// Not gated on dec.Clarify alone (Vikunja #557). A turn the cascade routed
|
||||
// confidently but incompletely skipped this entirely: "напомни позвонить"
|
||||
// reached applyAction, failed on the missing time, parked nothing, and the
|
||||
// "в семь вечера" that followed was web-searched as a world question. A
|
||||
// required slot that missingFor names is a gap whatever the confidence.
|
||||
if dec.Clarify || len(missingFor(dec)) > 0 {
|
||||
if reply := h.hexisBeforeClarify(ctx, dec); reply != "" {
|
||||
return withNotice(expiredNotice, reply)
|
||||
}
|
||||
if question, asked := h.askClarify(ctx, dec); asked {
|
||||
noteTerminal(ctx, "clarify-ask", dec.Intent,
|
||||
"the route was below the threshold, so she asked instead of acting")
|
||||
return withNotice(expiredNotice, question)
|
||||
}
|
||||
}
|
||||
@@ -390,6 +425,9 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour
|
||||
// the round-trip stays alive.
|
||||
replyText := h.applyAction(ctx, dec)
|
||||
log.Printf("voice: applyAction returned: %q", replyText)
|
||||
// A query turn was already claimed by a source inside the chain; every other
|
||||
// intent has no chain and no scoreboard, so the handler is the winner.
|
||||
noteTerminal(ctx, "action-handler", dec.Intent, "")
|
||||
|
||||
// 8b. a fact that answers a live nudge closes it as `acted` (ack.go).
|
||||
// Silent: the fact reply stands, she does not congratulate him for it.
|
||||
@@ -490,6 +528,17 @@ func (h *reactiveHandler) replySystem(ctx context.Context, dec router.Decision)
|
||||
}
|
||||
}
|
||||
|
||||
// sessionAsTurn projects a stored session onto the dialogue.Turn shape used in
|
||||
// history lists. Shared by chatHistory and rememberTurn (clarify.go) so the
|
||||
// same session is described the same way in both places.
|
||||
func sessionAsTurn(s *dialogue.Session) dialogue.Turn {
|
||||
return dialogue.Turn{
|
||||
Intent: s.Intent,
|
||||
Slots: s.Slots,
|
||||
Text: s.Slots.Text,
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -503,13 +552,9 @@ func (h *reactiveHandler) chatHistory(ctx context.Context) []dialogue.Turn {
|
||||
return nil
|
||||
}
|
||||
// History already includes the immediate prior turn (set by the dialogue
|
||||
// merge at lines 373-395), plus up to 3 more from deeper history.
|
||||
// merge in runTurn's step 6, above), plus up to 3 more from deeper history.
|
||||
out := make([]dialogue.Turn, 0, 1+len(prev.History))
|
||||
out = append(out, dialogue.Turn{
|
||||
Intent: prev.Intent,
|
||||
Slots: prev.Slots,
|
||||
Text: prev.Slots.Text,
|
||||
})
|
||||
out = append(out, sessionAsTurn(prev))
|
||||
out = append(out, prev.History...)
|
||||
return out
|
||||
}
|
||||
|
||||
+21
-10
@@ -11,6 +11,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/config"
|
||||
"github.com/kami/maven/internal/decision"
|
||||
"github.com/kami/maven/internal/delivery"
|
||||
"github.com/kami/maven/internal/delivery/voicesink"
|
||||
"github.com/kami/maven/internal/dialogue"
|
||||
@@ -235,21 +236,22 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem
|
||||
memStore = memory.NewInMemoryStore()
|
||||
}
|
||||
|
||||
// ----- dialogue (multi-turn slot carry-over; 2-min follow-up window) -----
|
||||
// ----- dialogue (multi-turn slot carry-over; dialogueSessionTTL follow-up window) -----
|
||||
// Store-backed when the daemon passes a store, so a restart mid-conversation
|
||||
// keeps the thread (Vikunja #363). Sessions past their TTL are dropped on
|
||||
// load, never revived. Clarify's parked question stays in memory only, and
|
||||
// that is a decision rather than an omission (Vikunja #385, docs/design.md):
|
||||
// a restart expires it, so the thread comes back and the open question does
|
||||
// not.
|
||||
const dialogueSessionTTL = 2 * time.Minute
|
||||
var dialogueSessions *dialogue.SessionStore
|
||||
if dataStore != nil {
|
||||
dialogueSessions = dialogue.NewPersistentSessionStore(2*time.Minute, dataStore)
|
||||
dialogueSessions = dialogue.NewPersistentSessionStore(dialogueSessionTTL, dataStore)
|
||||
if err := dialogueSessions.Load(context.Background(), time.Now()); err != nil {
|
||||
log.Printf("dialogue: load saved sessions: %v", err)
|
||||
}
|
||||
} else {
|
||||
dialogueSessions = dialogue.NewSessionStore(2 * time.Minute)
|
||||
dialogueSessions = dialogue.NewSessionStore(dialogueSessionTTL)
|
||||
}
|
||||
clarifyStore := dialogue.NewClarifyStore(clarifyTTL)
|
||||
timeParser := router.NewPythonDateParser()
|
||||
@@ -293,7 +295,11 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem
|
||||
},
|
||||
dataStore: dataStore,
|
||||
dialogueSessions: dialogueSessions,
|
||||
clarifyStore: clarifyStore,
|
||||
// Always on (V-564). The record is the instrument the rest of V-558 is
|
||||
// measured with, and one that only runs when a flag is set is not there
|
||||
// on the night the misroute happens.
|
||||
decisions: decision.NewRing(),
|
||||
clarifyStore: clarifyStore,
|
||||
// 0 here (unset config) ⇒ the dialogue default.
|
||||
clarifyMaxAttempts: cfg.Voice.ClarifyMaxAttempts,
|
||||
extractor: router.Extractor{Time: timeParser, Acts: matcher, Facts: router.DefaultFactParser{}},
|
||||
@@ -371,9 +377,9 @@ func pickLLMRouter(enabled bool, c router.Completer) *router.LLMRouter {
|
||||
// - The embedder is provided by wireVoice: HashEmbedder (floor) when no
|
||||
// embedder config is present, or the ONNX multilingual model when
|
||||
// configured — same interface, one constructor change.
|
||||
// - 6 bootstrap examples covering the 5 intents + one compound-capture
|
||||
// placeholder. Spec calls for ~10 per intent at production; this is the
|
||||
// bootstrapping floor swapped by tuning the seed set later.
|
||||
// - The classifier is floored by seedClassifier, which loads one file per
|
||||
// intent from seedDir (models/seeds/<intent>.txt) — see seedClassifier
|
||||
// below for the current intent list and file names.
|
||||
// - Threshold is from voice.router_threshold config (default 0.55).
|
||||
func buildRouter(emb router.Embedder, acts router.ActMatcher, threshold float64, llmR *router.LLMRouter) *router.Router {
|
||||
cls := router.NewClassifier(emb)
|
||||
@@ -404,6 +410,10 @@ func buildRouter(emb router.Embedder, acts router.ActMatcher, threshold float64,
|
||||
// board noun), and before the capture marker, which would otherwise read
|
||||
// "убери из задач купить молоко" as a new task (Vikunja #512).
|
||||
grammars = append(grammars, router.TaskStatusGrammar())
|
||||
// Before the capture markers, which all need an object. A capture verb
|
||||
// alone is a fact with no key, and the clarify path asks for it rather than
|
||||
// letting the model invent an answer (Vikunja #557).
|
||||
grammars = append(grammars, router.BareCaptureGrammar()...)
|
||||
grammars = append(grammars, router.TaskCaptureGrammar())
|
||||
// After the capture marker, so "запиши" still wins over "расскажи", and
|
||||
// last overall because it matches on the first word alone: "расскажи про
|
||||
@@ -452,9 +462,10 @@ func seedPath() string {
|
||||
|
||||
// seedClassifier floors the embedded examples so the cold-boot path
|
||||
// doesn't return ErrNoIntents. Loads examples from seedDir — one file per
|
||||
// intent (act.txt, reminder.txt, fact.txt, note.txt, query.txt). When the
|
||||
// classifier can't decide it falls through to Clarify — the last-resort
|
||||
// path asks the user to rephrase rather than guessing wrong.
|
||||
// intent (act.txt, reminder.txt, fact.txt, note.txt, query.txt, chat.txt,
|
||||
// system.txt). When the classifier can't decide it falls through to
|
||||
// Clarify — the last-resort path asks the user to rephrase rather than
|
||||
// guessing wrong.
|
||||
func seedClassifier(c *router.Classifier) {
|
||||
intents := []router.Intent{
|
||||
router.IntentAct,
|
||||
|
||||
+39
-9
@@ -7,6 +7,10 @@ package main
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/kami/maven/internal/lexicon"
|
||||
"github.com/kami/maven/internal/morph"
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
// isWeatherQuery returns true if the utterance is about weather.
|
||||
@@ -27,16 +31,42 @@ func isWeatherQuery(u string) bool {
|
||||
// come through whole and "в 5 утра" does not.
|
||||
var weatherPlace = regexp.MustCompile(`(?i)(?:^|\s)(?:в|во|in)\s+([\p{L}-]+(?:\s+[\p{L}-]+)?)`)
|
||||
|
||||
// weatherNonPlaces — words that follow "в" in a weather question and are not
|
||||
// cities. "какая погода в доме" is the smart-home sensor, not Open-Meteo, and
|
||||
// "тепло в комнате" is the same question about the same room.
|
||||
var weatherNonPlaces = map[string]bool{
|
||||
// weatherRooms — the rooms of the house, which are the only words in this
|
||||
// guard that belong to it. "какая погода в доме" is the smart-home sensor, not
|
||||
// Open-Meteo, and "тепло в комнате" is the same question about the same room.
|
||||
//
|
||||
// The rest of the guard used to be a third copy of three closed sets that
|
||||
// already exist in the lexicon: the weekdays, the parts of the day, and the
|
||||
// words that follow "в" without naming a place (V-581). Each copy was short in
|
||||
// its own direction — "среду" but not "среде", "утром" but not "утра", "целом"
|
||||
// but not "общем" — so the same question phrased one word differently reached
|
||||
// the geocoder as a city.
|
||||
var weatherRooms = map[string]bool{
|
||||
"доме": true, "квартире": true, "комнате": true, "спальне": true,
|
||||
"гостиной": true, "кухне": true, "гараже": true, "офисе": true,
|
||||
"выходные": true, "субботу": true, "воскресенье": true, "понедельник": true,
|
||||
"вторник": true, "среду": true, "четверг": true, "пятницу": true,
|
||||
"обед": true, "обеде": true, "утро": true, "утром": true, "вечер": true,
|
||||
"вечером": true, "ночь": true, "ночью": true, "целом": true, "принципе": true,
|
||||
"обед": true, "обеде": true, "выходные": true, "выходных": true,
|
||||
}
|
||||
|
||||
// isWeatherNonPlace reports whether the word after "в" names something other
|
||||
// than a place he could ask the weather for.
|
||||
func isWeatherNonPlace(word string) bool {
|
||||
if weatherRooms[word] {
|
||||
return true
|
||||
}
|
||||
if _, ok := router.WeekdayIndex(word); ok {
|
||||
return true
|
||||
}
|
||||
for _, w := range lexicon.PartsOfDay() {
|
||||
if word == w || morph.SameWord(word, w) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, w := range lexicon.NotPlaceAfterV() {
|
||||
if word == w {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// extractWeatherLocation returns the place he named, or the configured default
|
||||
@@ -63,7 +93,7 @@ func extractWeatherLocation(u, defaultLoc string) string {
|
||||
}
|
||||
place := strings.TrimSpace(m[1])
|
||||
first := strings.ToLower(strings.Fields(place)[0])
|
||||
if weatherNonPlaces[first] {
|
||||
if isWeatherNonPlace(first) {
|
||||
return defaultLoc
|
||||
}
|
||||
return place
|
||||
|
||||
@@ -29,6 +29,13 @@ func TestExtractWeatherLocation(t *testing.T) {
|
||||
// the house sensors and the day words answer elsewhere.
|
||||
{"тепло в комнате?", "Berlin", "Berlin"},
|
||||
{"какая погода в выходные", "Berlin", "Berlin"},
|
||||
// The cases the three private copies of the lexicon were short by
|
||||
// (V-581): a weekday in a case the old map did not list, a part of the
|
||||
// day in one it did not list, and "в общем".
|
||||
{"какая погода в среде", "Berlin", "Berlin"},
|
||||
{"какая погода в воскресеньях", "Berlin", "Berlin"},
|
||||
{"какая погода в понедельникам", "Berlin", "Berlin"},
|
||||
{"какая погода в общем", "Berlin", "Berlin"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := extractWeatherLocation(c.utterance, c.def); got != c.want {
|
||||
|
||||
+139
-58
@@ -6,10 +6,11 @@
|
||||
// restart-free, fail-independent — a crashing poller can't touch the store key
|
||||
// (it never had it), worst case a stale env fact until the next tick.
|
||||
//
|
||||
// Two sources, each its own provenance (the loop's rules trust source):
|
||||
// Four sources, each its own provenance (the loop's rules trust source):
|
||||
// - netdata → poll:netdata resource alarms (disk/mem/cert/temp)
|
||||
// - kuma → poll:uptimekuma service up/down (the source of truth for it)
|
||||
// - zenmoney → poll:zenmoney spending/income totals (Vikunja #125)
|
||||
// - wireguard → infer:wg latest handshake, the presence signal
|
||||
//
|
||||
// The zenmoney source is why the token lives HERE and not in core: the poller
|
||||
// already owns every other third-party credential, it holds no store key, and
|
||||
@@ -76,27 +77,31 @@ func run(args []string) error {
|
||||
if *netdataURL == "" && *kumaURL == "" && *wgIface == "" && *zenTokenFile == "" {
|
||||
return fmt.Errorf("nothing to poll: set -netdata, -kuma, -wg and/or -zenmoney-token-file")
|
||||
}
|
||||
// A bad duration or an empty -wg-cmd used to get past start and kill the
|
||||
// poller on the first tick — time.NewTicker panics on a non-positive
|
||||
// interval, and pollWg indexed field 0 of an empty command. A zero -timeout
|
||||
// is worse than a crash: http.Client reads it as "no deadline", so one
|
||||
// wedged source stalls every other source behind it forever. Refuse all
|
||||
// three here, where the operator sees the message.
|
||||
if *interval <= 0 {
|
||||
return fmt.Errorf("-interval must be positive, got %s", *interval)
|
||||
}
|
||||
if *timeout <= 0 {
|
||||
return fmt.Errorf("-timeout must be positive, got %s", *timeout)
|
||||
}
|
||||
if *wgIface != "" && strings.TrimSpace(*wgCmd) == "" {
|
||||
return fmt.Errorf("-wg-cmd is empty but -wg is set")
|
||||
}
|
||||
|
||||
// The token is read from a file, never taken as a flag value: an argv token
|
||||
// is visible in `ps` to every user on the box and lands in the compose file
|
||||
// and the shell history. Read once at start — a rotated token means a
|
||||
// restart, which is cheaper than re-reading his credential every hour.
|
||||
var zen *zenmoney.Client
|
||||
if *zenTokenFile != "" {
|
||||
raw, err := os.ReadFile(*zenTokenFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read zenmoney token: %w", err)
|
||||
}
|
||||
zen, err = zenmoney.New(strings.TrimSpace(string(raw)), *zenURL, *timeout*3)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
zen, err := newZenClient(*zenTokenFile, *zenURL, *timeout)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
core, err := ipc.DialWait(*socket, 60*time.Second)
|
||||
core, err := ipc.DialWait(*socket, coreDialWait)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -117,14 +122,46 @@ func run(args []string) error {
|
||||
// The token is never logged, not even its length.
|
||||
log.Printf("mavpoll: polling every %s (netdata=%q kuma=%q wg=%q zenmoney=%v every %s)",
|
||||
*interval, *netdataURL, *kumaURL, *wgIface, zen != nil, *zenInterval)
|
||||
p.loop(ctx, *interval)
|
||||
return nil
|
||||
}
|
||||
|
||||
// coreDialWait — how long to wait for core's socket at start. The poller and
|
||||
// core come up together under compose, so a cold start is a wait, not a failure.
|
||||
const coreDialWait = 60 * time.Second
|
||||
|
||||
// zenTimeoutFactor — the zenmoney client gets a longer deadline than the other
|
||||
// sources. A diff call walks his whole transaction history, where netdata and
|
||||
// kuma answer from memory.
|
||||
const zenTimeoutFactor = 3
|
||||
|
||||
// newZenClient builds the money client, or nil when no token file was given.
|
||||
//
|
||||
// The token is read from a file, never taken as a flag value: an argv token is
|
||||
// visible in `ps` to every user on the box and lands in the compose file and
|
||||
// the shell history. Read once at start — a rotated token means a restart,
|
||||
// which is cheaper than re-reading his credential every hour.
|
||||
func newZenClient(tokenFile, baseURL string, timeout time.Duration) (*zenmoney.Client, error) {
|
||||
if tokenFile == "" {
|
||||
return nil, nil
|
||||
}
|
||||
raw, err := os.ReadFile(tokenFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read zenmoney token: %w", err)
|
||||
}
|
||||
return zenmoney.New(strings.TrimSpace(string(raw)), baseURL, timeout*zenTimeoutFactor)
|
||||
}
|
||||
|
||||
// loop polls until the context is cancelled.
|
||||
func (p *poller) loop(ctx context.Context, interval time.Duration) {
|
||||
p.pollOnce(ctx) // fire immediately; don't idle a full interval on start
|
||||
t := time.NewTicker(*interval)
|
||||
t := time.NewTicker(interval)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
log.Printf("mavpoll: bye")
|
||||
return nil
|
||||
return
|
||||
case <-t.C:
|
||||
p.pollOnce(ctx)
|
||||
}
|
||||
@@ -151,8 +188,8 @@ type poller struct {
|
||||
zenLast time.Time
|
||||
}
|
||||
|
||||
// pollOnce — one sweep of both sources. A failure in one source logs and does
|
||||
// NOT abort the other: netdata being down shouldn't blind kuma and vice versa.
|
||||
// pollOnce — one sweep of every configured source. A failure in one logs and
|
||||
// does NOT abort the rest: netdata being down shouldn't blind kuma.
|
||||
func (p *poller) pollOnce(ctx context.Context) {
|
||||
now := time.Now()
|
||||
if p.netdataURL != "" {
|
||||
@@ -244,6 +281,14 @@ func (p *poller) pollZenmoney(ctx context.Context, now time.Time) error {
|
||||
|
||||
// ---- wireguard: latest handshake → presence signal -------------------------
|
||||
|
||||
const (
|
||||
// wgFactKey / wgSource — the presence signal, read by the decay in core.
|
||||
// The source says infer because a handshake is evidence he is home, not a
|
||||
// reading of where he is.
|
||||
wgFactKey = "wg_handshake"
|
||||
wgSource = "infer:wg"
|
||||
)
|
||||
|
||||
// pollWg reads `wg show <iface> latest-handshakes` and writes a wg_handshake
|
||||
// fact (source=infer:wg) stamped with the MOST RECENT peer handshake time — not
|
||||
// now(). Presence decays from the real handshake instant, so the fact's ts must
|
||||
@@ -253,9 +298,19 @@ func (p *poller) pollZenmoney(ctx context.Context, now time.Time) error {
|
||||
// `wg show` needs CAP_NET_ADMIN; run mavpoll with the cap or set -wg-cmd "sudo wg".
|
||||
func (p *poller) pollWg(ctx context.Context) error {
|
||||
fields := strings.Fields(p.wgCmd)
|
||||
if len(fields) == 0 {
|
||||
return fmt.Errorf("wg command is empty")
|
||||
}
|
||||
args := append(fields[1:], "show", p.wgIface, "latest-handshakes")
|
||||
out, err := exec.CommandContext(ctx, fields[0], args...).Output()
|
||||
if err != nil {
|
||||
// wg says why it refused on stderr — usually a missing CAP_NET_ADMIN or
|
||||
// an interface that does not exist. Output() drops that, leaving a log
|
||||
// line that reads "exit status 1" and diagnoses nothing.
|
||||
var ee *exec.ExitError
|
||||
if errors.As(err, &ee) && len(ee.Stderr) > 0 {
|
||||
return fmt.Errorf("run %s: %w: %s", p.wgCmd, err, strings.TrimSpace(string(ee.Stderr)))
|
||||
}
|
||||
return fmt.Errorf("run %s: %w", p.wgCmd, err)
|
||||
}
|
||||
maxTs := parseMaxHandshake(string(out))
|
||||
@@ -263,20 +318,19 @@ func (p *poller) pollWg(ctx context.Context) error {
|
||||
return nil // no peer has ever handshaked → drop out of presence
|
||||
}
|
||||
hs := time.Unix(maxTs, 0)
|
||||
prev, err := p.core.LatestFactBySource(ctx, "wg_handshake", "infer:wg")
|
||||
prev, err := p.core.LatestFactBySource(ctx, wgFactKey, wgSource)
|
||||
if err == nil && !hs.After(prev.Ts) {
|
||||
return nil // not newer → no churn
|
||||
}
|
||||
if err != nil && err != ipc.ErrNoFact && !isNoFact(err) {
|
||||
return fmt.Errorf("read wg_handshake: %w", err)
|
||||
if err != nil && !isNoFact(err) {
|
||||
return fmt.Errorf("read %s: %w", wgFactKey, err)
|
||||
}
|
||||
if _, err := p.core.WriteFact(ctx, ipc.WriteFactReq{
|
||||
Ts: hs, Kind: "env", Key: "wg_handshake", Value: `"up"`,
|
||||
Source: "infer:wg", Confidence: 1.0,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("write wg_handshake: %w", err)
|
||||
// The ts is the handshake instant, not now(): presence decays from when he
|
||||
// was last seen.
|
||||
if err := p.writeFact(ctx, wgFactKey, wgSource, `"up"`, hs); err != nil {
|
||||
return err
|
||||
}
|
||||
log.Printf("mavpoll: wg_handshake @ %s (infer:wg)", hs.Format(time.RFC3339))
|
||||
log.Printf("mavpoll: %s @ %s (%s)", wgFactKey, hs.Format(time.RFC3339), wgSource)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -431,29 +485,53 @@ func kumaState(v float64) string {
|
||||
|
||||
// ---- helpers ---------------------------------------------------------------
|
||||
|
||||
// writeIfChanged writes a `facts(kind=env)` row only when val differs from the
|
||||
// latest fact for (key, source). Values are stored JSON-encoded (the store's
|
||||
// convention: `"down"`, `"critical"`), matching how rules compare f.Value.
|
||||
func (p *poller) writeIfChanged(ctx context.Context, key, source, val string, now time.Time) error {
|
||||
jv, _ := json.Marshal(val) // string never fails to marshal
|
||||
// factConfidence — every poll is a direct reading of another service, never an
|
||||
// inference, so the fact goes in at full confidence.
|
||||
const factConfidence = 1.0
|
||||
|
||||
// unchanged reports whether the latest fact for (key, source) already holds
|
||||
// jsonVal. A missing fact is not an error here, it is the first write.
|
||||
func (p *poller) unchanged(ctx context.Context, key, source, jsonVal string) (bool, error) {
|
||||
prev, err := p.core.LatestFactBySource(ctx, key, source)
|
||||
switch {
|
||||
case err == nil && prev.Value == string(jv):
|
||||
return nil // unchanged → no churn
|
||||
case err != nil && err != ipc.ErrNoFact && !isNoFact(err):
|
||||
return fmt.Errorf("read %s: %w", key, err)
|
||||
case err == nil:
|
||||
return prev.Value == jsonVal, nil
|
||||
case isNoFact(err):
|
||||
return false, nil
|
||||
default:
|
||||
return false, fmt.Errorf("read %s: %w", key, err)
|
||||
}
|
||||
_, err = p.core.WriteFact(ctx, ipc.WriteFactReq{
|
||||
}
|
||||
|
||||
// writeFact writes one `facts(kind=env)` row. Every poll in this file lands
|
||||
// here, so the row shape is written once.
|
||||
func (p *poller) writeFact(ctx context.Context, key, source, jsonVal string, now time.Time) error {
|
||||
_, err := p.core.WriteFact(ctx, ipc.WriteFactReq{
|
||||
Ts: now,
|
||||
Kind: "env",
|
||||
Key: key,
|
||||
Value: string(jv),
|
||||
Value: jsonVal,
|
||||
Source: source,
|
||||
Confidence: 1.0, // a direct reading, not an inference
|
||||
Confidence: factConfidence,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("write %s: %w", key, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeIfChanged writes only when val differs from the latest fact for
|
||||
// (key, source). Values are stored JSON-encoded (the store's convention:
|
||||
// `"down"`, `"critical"`), matching how rules compare f.Value.
|
||||
func (p *poller) writeIfChanged(ctx context.Context, key, source, val string, now time.Time) error {
|
||||
jv, _ := json.Marshal(val) // a string never fails to marshal
|
||||
same, err := p.unchanged(ctx, key, source, string(jv))
|
||||
if err != nil || same {
|
||||
return err // unchanged → no churn
|
||||
}
|
||||
if err := p.writeFact(ctx, key, source, string(jv), now); err != nil {
|
||||
return err
|
||||
}
|
||||
log.Printf("mavpoll: %s=%s (%s)", key, val, source)
|
||||
return nil
|
||||
}
|
||||
@@ -466,18 +544,12 @@ func (p *poller) writeIfChanged(ctx context.Context, key, source, val string, no
|
||||
// The log line names the key and the source, never the figures: mavpoll's log
|
||||
// is not the place his spending ends up.
|
||||
func (p *poller) writeIfChangedRaw(ctx context.Context, key, source, jsonVal string, now time.Time) error {
|
||||
prev, err := p.core.LatestFactBySource(ctx, key, source)
|
||||
switch {
|
||||
case err == nil && prev.Value == jsonVal:
|
||||
return nil
|
||||
case err != nil && err != ipc.ErrNoFact && !isNoFact(err):
|
||||
return fmt.Errorf("read %s: %w", key, err)
|
||||
same, err := p.unchanged(ctx, key, source, jsonVal)
|
||||
if err != nil || same {
|
||||
return err
|
||||
}
|
||||
if _, err := p.core.WriteFact(ctx, ipc.WriteFactReq{
|
||||
Ts: now, Kind: "env", Key: key, Value: jsonVal,
|
||||
Source: source, Confidence: 1.0,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("write %s: %w", key, err)
|
||||
if err := p.writeFact(ctx, key, source, jsonVal, now); err != nil {
|
||||
return err
|
||||
}
|
||||
log.Printf("mavpoll: %s updated (%s)", key, source)
|
||||
return nil
|
||||
@@ -490,11 +562,8 @@ func (p *poller) writeIfChangedRaw(ctx context.Context, key, source, jsonVal str
|
||||
// The log line names the key only, never the figures: mavpoll's log is not the
|
||||
// place his spending ends up.
|
||||
func (p *poller) writeMoneyFact(ctx context.Context, key, jsonVal string, now time.Time) error {
|
||||
if _, err := p.core.WriteFact(ctx, ipc.WriteFactReq{
|
||||
Ts: now, Kind: "env", Key: key, Value: jsonVal,
|
||||
Source: zenmoney.Source, Confidence: 1.0,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("write %s: %w", key, err)
|
||||
if err := p.writeFact(ctx, key, zenmoney.Source, jsonVal, now); err != nil {
|
||||
return err
|
||||
}
|
||||
log.Printf("mavpoll: %s read (%s)", key, zenmoney.Source)
|
||||
return nil
|
||||
@@ -506,6 +575,15 @@ func isNoFact(err error) bool {
|
||||
return errors.Is(err, ipc.ErrNoFact)
|
||||
}
|
||||
|
||||
// maxBodyBytes caps what a source can make the poller hold. Kuma's whole
|
||||
// metrics page is a few hundred kilobytes, so 4 MiB is slack, not a budget.
|
||||
//
|
||||
// Hitting the cap is an error, not a shorter body. A truncated kuma page parses
|
||||
// cleanly right up to the cut, and every monitor past it reads as deleted — the
|
||||
// poller would write "unknown" over live services and the down-rule would go
|
||||
// quiet. Reading one byte past the cap is how we tell full from truncated.
|
||||
const maxBodyBytes = 4 << 20
|
||||
|
||||
func (p *poller) get(ctx context.Context, url, basicUser string) ([]byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
@@ -519,12 +597,15 @@ func (p *poller) get(ctx context.Context, url, basicUser string) ([]byte, error)
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, maxBodyBytes+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("GET %s: %s", url, resp.Status)
|
||||
}
|
||||
if len(body) > maxBodyBytes {
|
||||
return nil, fmt.Errorf("GET %s: body over %d bytes", url, maxBodyBytes)
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
@@ -212,3 +213,59 @@ func TestRunRequiresSomethingToPoll(t *testing.T) {
|
||||
t.Errorf("err = %v, want a 'nothing to poll' refusal", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A flag value that would kill the poller later is refused at start, before it
|
||||
// dials core: a non-positive interval panics time.NewTicker on the first tick, a
|
||||
// zero timeout means http.Client waits forever, and an empty wg command used to
|
||||
// index field 0 of an empty slice.
|
||||
func TestRunRefusesFlagsThatCrashLater(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{"zero interval", []string{"-interval", "0"}, "-interval must be positive"},
|
||||
{"negative interval", []string{"-interval", "-5s"}, "-interval must be positive"},
|
||||
{"zero timeout", []string{"-timeout", "0"}, "-timeout must be positive"},
|
||||
{"empty wg command", []string{"-wg", "wg0", "-wg-cmd", " "}, "-wg-cmd is empty"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
args := append([]string{"-socket", "/tmp/nope.sock"}, c.args...)
|
||||
err := run(args)
|
||||
if err == nil || !strings.Contains(err.Error(), c.want) {
|
||||
t.Errorf("err = %v, want %q", err, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// pollWg refuses an empty command rather than panicking on fields[0].
|
||||
func TestPollWgEmptyCommand(t *testing.T) {
|
||||
p := &poller{core: &factCore{}, wgIface: "wg0", wgCmd: ""}
|
||||
if err := p.pollWg(context.Background()); err == nil {
|
||||
t.Error("want an error, got a poll that ran something")
|
||||
}
|
||||
}
|
||||
|
||||
// A body at the cap is a truncated body, and a truncated kuma page reads as
|
||||
// "every monitor past the cut was deleted". Refuse it instead of parsing it.
|
||||
func TestGetRefusesTruncatedBody(t *testing.T) {
|
||||
big := strings.Repeat("x", maxBodyBytes+64)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
io.WriteString(w, big)
|
||||
}))
|
||||
defer srv.Close()
|
||||
p := &poller{http: srv.Client()}
|
||||
if _, err := p.get(context.Background(), srv.URL, ""); err == nil {
|
||||
t.Error("want an over-size refusal, got a silently truncated body")
|
||||
}
|
||||
small := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
io.WriteString(w, "ok")
|
||||
}))
|
||||
defer small.Close()
|
||||
body, err := p.get(context.Background(), small.URL, "")
|
||||
if err != nil || string(body) != "ok" {
|
||||
t.Errorf("get = %q, %v; want the whole small body", body, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,18 @@ import (
|
||||
// to it before sending, so it's also the rate the silence gate assumes.
|
||||
const whisperSampleRate = 16000
|
||||
|
||||
// whisperThreads — greedy decode is single-pass and this is a laptop CPU
|
||||
// (homesrv), not a server box; 4 was picked to leave headroom for the rest
|
||||
// of the daemons sharing the machine, not measured against a latency target.
|
||||
const whisperThreads = 4
|
||||
|
||||
// noSpeechFloor — whisper's own no_speech_prob past this point means the
|
||||
// segment it transcribed is not speech (the model still emits token
|
||||
// probabilities for silence/noise, so a high avgLogProb-derived confidence
|
||||
// can coexist with a segment that should be zero). Read as "at least 90%
|
||||
// sure this was not speech."
|
||||
const noSpeechFloor = 0.9
|
||||
|
||||
type whisperHandler struct {
|
||||
ctx *C.struct_whisper_context
|
||||
minMs int // clips shorter than this are dropped (hallucination bait)
|
||||
@@ -101,7 +113,7 @@ func (h *whisperHandler) Transcribe(ctx context.Context, req worker.TranscribeRe
|
||||
params.print_realtime = false
|
||||
params.print_timestamps = false
|
||||
params.print_special = false
|
||||
params.n_threads = C.int(4)
|
||||
params.n_threads = C.int(whisperThreads)
|
||||
params.single_segment = true
|
||||
|
||||
lang := C.CString(req.Lang)
|
||||
@@ -162,7 +174,7 @@ func (h *whisperHandler) Transcribe(ctx context.Context, req worker.TranscribeRe
|
||||
}
|
||||
|
||||
noSpeechProb := float64(C.whisper_full_get_segment_no_speech_prob(h.ctx, 0))
|
||||
if noSpeechProb > 0.9 {
|
||||
if noSpeechProb > noSpeechFloor {
|
||||
confidence = 0
|
||||
}
|
||||
|
||||
|
||||
@@ -116,18 +116,27 @@ func (h *piperHandler) Synthesize(ctx context.Context, req worker.SynthesizeReq)
|
||||
}, nil
|
||||
}
|
||||
|
||||
// resample22050To16000 converts raw 16-bit PCM from 22050 Hz to 16000 Hz
|
||||
// using linear interpolation.
|
||||
// piperSampleRate is the rate piper's onnx voices render at (ru_RU-irina and
|
||||
// the other models this daemon has been pointed at). targetSampleRate is the
|
||||
// canonical maven wire rate (audio.PCM16kMono) that every downstream
|
||||
// consumer — playback, the voice wire, whisper on the way back in — expects.
|
||||
const (
|
||||
piperSampleRate = 22050
|
||||
targetSampleRate = 16000
|
||||
)
|
||||
|
||||
// resample22050To16000 converts raw 16-bit PCM from piperSampleRate to
|
||||
// targetSampleRate using linear interpolation.
|
||||
func resample22050To16000(input []byte) []byte {
|
||||
if len(input) < 2 {
|
||||
return nil
|
||||
}
|
||||
|
||||
nSamples := len(input) / 2
|
||||
outSamples := int(float64(nSamples) * 16000.0 / 22050.0)
|
||||
outSamples := int(float64(nSamples) * float64(targetSampleRate) / float64(piperSampleRate))
|
||||
output := make([]byte, outSamples*2)
|
||||
|
||||
ratio := 22050.0 / 16000.0
|
||||
ratio := float64(piperSampleRate) / float64(targetSampleRate)
|
||||
|
||||
for i := 0; i < outSamples; i++ {
|
||||
srcPos := float64(i) * ratio
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/webauthn"
|
||||
)
|
||||
|
||||
//go:embed chat.html
|
||||
var chatPageHTML string
|
||||
|
||||
// chatTmpl — plain text conversation interface. No JS: form POSTs to /api/chat
|
||||
// and the handler redirects back to /chat with the response.
|
||||
var chatTmpl = parsePage("chat", chatPageHTML, nil)
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// handleChatPage renders the chat conversation page.
|
||||
func handleChatPage(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
||||
if !requireCore(w, core, "chat") {
|
||||
return
|
||||
}
|
||||
msgs := []chatMsg{}
|
||||
// Read user message + reply from query params (set by /api/chat redirect).
|
||||
if q := r.URL.Query().Get("q"); q != "" {
|
||||
msgs = append(msgs, chatMsg{Role: "user", Text: q})
|
||||
}
|
||||
if reply := r.URL.Query().Get("r"); reply != "" {
|
||||
msgs = append(msgs, chatMsg{Role: "assistant", Text: reply, Source: r.URL.Query().Get("s")})
|
||||
}
|
||||
renderPage(w, chatTmpl, struct {
|
||||
Error string
|
||||
Messages []chatMsg
|
||||
}{Messages: msgs})
|
||||
}
|
||||
|
||||
// handleChatAPI processes a chat message POST and redirects back to /chat.
|
||||
//
|
||||
// State-changing, and the widest surface on this server: the text reaches the
|
||||
// router, the LLM, and through mavend's applyAction the whole action path
|
||||
// including `act` — so it is gated on the same step-up as POST /tools and
|
||||
// POST /api/revert (Vikunja #317). With WebAuthn unconfigured the gate is
|
||||
// fail-open exactly like the others (see stepUpOK); with -require-stepup it
|
||||
// denies, which is the point of that flag.
|
||||
func handleChatAPI(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, session *webauthn.PasskeySession, requireStepUp bool) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "POST only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if !requireCore(w, core, "chat") {
|
||||
return
|
||||
}
|
||||
if !stepUpGate(w, session, requireStepUp) {
|
||||
return
|
||||
}
|
||||
text := strings.TrimSpace(r.FormValue("text"))
|
||||
if text == "" {
|
||||
http.Redirect(w, r, "/chat", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
// One conversation id for the whole web chat, and a different one from
|
||||
// telegram or the mic. A parked question belongs to the reach that was
|
||||
// asked; before this, a clarify nobody answered on the web ate the next
|
||||
// utterance spoken at the mic (Vikunja #466). This server has no
|
||||
// per-browser session, so every browser tab is the same conversation —
|
||||
// which is right for a single-owner box.
|
||||
reply, err := core.Chat(r.Context(), "web", text)
|
||||
if err != nil {
|
||||
log.Printf("chat api: %v", err)
|
||||
http.Redirect(w, r, "/chat", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
// 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)
|
||||
}
|
||||
@@ -117,8 +117,5 @@ func handleEcosystem(w http.ResponseWriter, r *http.Request, urls ecoURLs, core
|
||||
d.Calls.Rows = rows
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := ecosystemTmpl.Execute(w, d); err != nil {
|
||||
log.Printf("ecosystem render: %v", err)
|
||||
}
|
||||
renderPage(w, ecosystemTmpl, d)
|
||||
}
|
||||
|
||||
@@ -14,8 +14,13 @@ memory only, so a restart empties this.</div>
|
||||
<div class=scroll><table class=mono>
|
||||
<tr><th>noticed<th>happened<th>source<th>kind<th>pri<th>what<th>detail</tr>
|
||||
{{range .Events}}<tr>
|
||||
<td>{{.NoticedAt.Format "02.01 15:04:05"}}</td>
|
||||
<td class=gray>{{.OccurredAt.Format "02.01 15:04:05"}}</td>
|
||||
<!-- Both columns in his clock (V-469 on /reminders, same rule here). NoticedAt
|
||||
is the bus's local instant, OccurredAt is whatever zone the source used —
|
||||
the store hands back UTC and internal/rss parses a pubDate to UTC — so
|
||||
rendering them raw put two zones side by side in the same row and made a
|
||||
feed item look hours older than it was. -->
|
||||
<td>{{.NoticedAt.Local.Format "02.01 15:04:05"}}</td>
|
||||
<td class=gray>{{.OccurredAt.Local.Format "02.01 15:04:05"}}</td>
|
||||
<td class=gray>{{.Source}}</td>
|
||||
<td class=gray>{{.Kind}}</td>
|
||||
<td class=gray>{{.Priority}}</td>
|
||||
|
||||
@@ -46,7 +46,8 @@ func TestEventsPageRendersTheJournal(t *testing.T) {
|
||||
t.Fatalf("status = %d, want 200", w.Code)
|
||||
}
|
||||
body := w.Body.String()
|
||||
for _, want := range []string{"rss:tech", "Вышло ядро 6.19", "ambient:notif", "10:00-11:00 планёрка", "01.08 10:00:00"} {
|
||||
occurred := time.Date(2026, 8, 1, 10, 0, 0, 0, time.UTC).Local().Format("02.01 15:04:05")
|
||||
for _, want := range []string{"rss:tech", "Вышло ядро 6.19", "ambient:notif", "10:00-11:00 планёрка", occurred} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("page does not mention %q", want)
|
||||
}
|
||||
@@ -89,6 +90,38 @@ func TestEventsPageWithoutCore(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// awayFromLocal returns a zone three hours off whatever this machine runs in,
|
||||
// so a test can tell "rendered in his clock" apart from "rendered in whatever
|
||||
// zone the value arrived in" without depending on TZ.
|
||||
func awayFromLocal() *time.Location {
|
||||
_, off := time.Now().Zone()
|
||||
return time.FixedZone("away", off+3*60*60)
|
||||
}
|
||||
|
||||
func TestEventsPageRendersBothTimesInLocalZone(t *testing.T) {
|
||||
// OccurredAt carries the source's zone — the store hands back UTC and
|
||||
// internal/rss parses a pubDate to UTC — while NoticedAt is the bus's local
|
||||
// instant. Rendered raw, the two columns of one row were in two zones and a
|
||||
// feed item read hours older than it was.
|
||||
away := awayFromLocal()
|
||||
occurred := time.Date(2026, 8, 1, 7, 15, 0, 0, time.UTC).In(away)
|
||||
noticed := occurred.Add(2 * time.Minute)
|
||||
core := &eventsCore{events: []ipc.IntakeEvent{{
|
||||
Source: "rss:tech", Kind: "note", Title: "Вышло ядро 6.19", Priority: "low",
|
||||
OccurredAt: occurred, NoticedAt: noticed,
|
||||
}}}
|
||||
body := getEvents(t, core).Body.String()
|
||||
const layout = "02.01 15:04:05"
|
||||
for _, ts := range []time.Time{occurred, noticed} {
|
||||
if !strings.Contains(body, ts.Local().Format(layout)) {
|
||||
t.Errorf("page does not render %s in his clock (%s)", ts, ts.Local().Format(layout))
|
||||
}
|
||||
if strings.Contains(body, ts.In(away).Format(layout)) {
|
||||
t.Errorf("page rendered %s in the source's zone", ts)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventsPageEscapesIntakeText(t *testing.T) {
|
||||
// Titles come from outside — a feed headline, a notification. They are shown
|
||||
// on a page and must never be able to inject markup into it.
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/webauthn"
|
||||
)
|
||||
|
||||
// The two fact-writing API routes: POST /api/signal appends a presence
|
||||
// observation, POST /api/revert voids the latest fact for a key. Neither
|
||||
// renders a page.
|
||||
|
||||
// presenceSignals — the only fact keys /api/signal may write. mavweb is a
|
||||
// network-facing surface inside wg; an allowlist keeps a compromised caller
|
||||
// boxed to forging weak presence signals (reachability, multi-source, never
|
||||
// truth) — it can't write arbitrary facts. ponytail: floor auth (wg-only); a
|
||||
// per-signal token belongs here if the tunnel ever hosts untrusted devices.
|
||||
var presenceSignals = map[string]string{
|
||||
"desk_active": "infer:hyprland",
|
||||
"page_heartbeat": "infer:heartbeat",
|
||||
"wg_handshake": "infer:wg",
|
||||
}
|
||||
|
||||
// handleSignal ingests one presence signal and writes a fresh fact through
|
||||
// CoreAPI. The fact's timestamp (now) is all the presence scorer reads; value
|
||||
// is a marker. Only allowlisted keys are accepted (see presenceSignals).
|
||||
func handleSignal(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "POST only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if !requireCore(w, core, "presence ingest") {
|
||||
return
|
||||
}
|
||||
key := r.URL.Query().Get("key")
|
||||
source, ok := presenceSignals[key]
|
||||
if !ok {
|
||||
http.Error(w, "unknown signal key", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// kind=env: an observation about the device/surface, NOT a self-fact — a
|
||||
// passive signal never writes truth about you (spec), it only feeds
|
||||
// presence. confidence 1.0: the reading ("input happened") is certain;
|
||||
// presence applies its own per-signal weight/decay on top.
|
||||
if _, err := core.WriteFact(r.Context(), ipc.WriteFactReq{
|
||||
Ts: time.Now(),
|
||||
Kind: "env",
|
||||
Key: key,
|
||||
Value: `"active"`,
|
||||
Source: source,
|
||||
Confidence: 1.0,
|
||||
}); err != nil {
|
||||
log.Printf("signal %s: %v", key, err)
|
||||
http.Error(w, "write failed", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func handleRevert(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, session *webauthn.PasskeySession, requireStepUp bool) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "POST only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if !requireCore(w, core, "revert") {
|
||||
return
|
||||
}
|
||||
if !stepUpGate(w, session, requireStepUp) {
|
||||
return
|
||||
}
|
||||
|
||||
key := strings.TrimSpace(r.FormValue("key"))
|
||||
if key == "" {
|
||||
http.Error(w, "key required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
newID, err := core.RevertFact(r.Context(), key)
|
||||
if err != nil {
|
||||
log.Printf("revert %q: %v", key, err)
|
||||
if errors.Is(err, ipc.ErrNoFact) {
|
||||
http.Error(w, "no fact to revert", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
http.Error(w, "revert failed", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
log.Printf("reverted fact for key=%s, new_id=%d", key, newID)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{"reverted": true, "new_id": newID})
|
||||
}
|
||||
@@ -65,6 +65,7 @@ type fakeCore struct {
|
||||
// for handleTrace tests
|
||||
tickTrace ipc.TickTrace
|
||||
traceErr error
|
||||
turns []ipc.TurnDecision
|
||||
|
||||
// for handleChatAPI tests
|
||||
chatText string
|
||||
@@ -173,6 +174,10 @@ func (f *fakeCore) RevertFact(_ context.Context, key string) (int64, error) {
|
||||
return f.revertNewID, nil
|
||||
}
|
||||
|
||||
func (f *fakeCore) TurnDecisions(_ context.Context, _ int) ([]ipc.TurnDecision, error) {
|
||||
return f.turns, nil
|
||||
}
|
||||
|
||||
func (f *fakeCore) TickTrace(_ context.Context) (ipc.TickTrace, error) {
|
||||
if f.traceErr != nil {
|
||||
return ipc.TickTrace{}, f.traceErr
|
||||
@@ -825,6 +830,41 @@ func TestHandleTrace(t *testing.T) {
|
||||
t.Error("rendered 'nothing fired' but a winner was set")
|
||||
}
|
||||
})
|
||||
|
||||
// The turn arbitration shares this page (V-564). A reader must see the
|
||||
// winner, a loser and the claimants that were never asked, because the last
|
||||
// of those is what the hardcoded ordering hides.
|
||||
t.Run("renders the turn decision record", func(t *testing.T) {
|
||||
core := &fakeCore{turns: []ipc.TurnDecision{{
|
||||
Ts: time.Date(2025, 6, 1, 12, 0, 0, 0, time.UTC),
|
||||
Utterance: "какая погода в риме",
|
||||
Winner: "query:weather",
|
||||
Claims: []ipc.TurnClaim{
|
||||
{Stage: "query", Claimant: "weather", Intent: "query", Outcome: "won"},
|
||||
{Stage: "query", Claimant: "calendar", Outcome: "declined", Reason: "no answer"},
|
||||
{Stage: "query", Claimant: "kiwix", Outcome: "never_asked"},
|
||||
},
|
||||
}}}
|
||||
rr := httptest.NewRecorder()
|
||||
handleTrace(rr, httptest.NewRequest(http.MethodGet, "/trace", nil), core)
|
||||
body := rr.Body.String()
|
||||
for _, want := range []string{"какая погода в риме", "query:weather", "calendar", "kiwix", "never_asked"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("rendered page is missing %q", want)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no turns renders the empty note, not an error", func(t *testing.T) {
|
||||
rr := httptest.NewRecorder()
|
||||
handleTrace(rr, httptest.NewRequest(http.MethodGet, "/trace", nil), &fakeCore{})
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rr.Code)
|
||||
}
|
||||
if !strings.Contains(rr.Body.String(), "no turn has run") {
|
||||
t.Error("empty ring did not render its note")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// --- handleRevert ---
|
||||
|
||||
+71
-1674
File diff suppressed because it is too large
Load Diff
+5
-41
@@ -2,8 +2,8 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
_ "embed"
|
||||
"errors"
|
||||
"html/template"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
@@ -31,43 +31,10 @@ type modelController interface {
|
||||
SwapModel(ctx context.Context, req ipc.SwapModelReq) (ipc.SwapModelResp, error)
|
||||
}
|
||||
|
||||
var modelsTmpl = template.Must(template.New("models").Funcs(shellFuncs()).Parse(shellHTML + modelsHTML))
|
||||
//go:embed models.html
|
||||
var modelsHTML string
|
||||
|
||||
const modelsHTML = `{{template "shellTop" "models"}}
|
||||
<h1>Resident model</h1>
|
||||
<p class=hint>swapping requires step-up — <a href=/auth/passkey>assert a passkey</a> first. The old model is unloaded before the new one is loaded (one model fits the iGPU at a time), so turns during the load are refused and fall back to the classifier.</p>
|
||||
<p class=hint>a swap is not remembered. Nothing writes it down, so the next restart of the daemon — including the one <code>mavupdate</code> does — comes back on <code>phraser.model_path</code> from the config. Make it stick by editing that.</p>
|
||||
{{if .Msg}}<div class="msg msg-ok">{{.Msg}}</div>{{end}}
|
||||
{{if .Err}}<div class="msg msg-err">{{.Err}}</div>{{end}}
|
||||
{{if .Off}}
|
||||
<section class=card>
|
||||
<h2 class=card-title>swap not configured</h2>
|
||||
<p class=hint>this core has no <code>phraser.swap_models</code> allowlist, so there is nothing to swap to. Add the gguf paths you allow to <code>deploy/mavend.json</code> and restart once.</p>
|
||||
</section>
|
||||
{{else}}
|
||||
<section class=card>
|
||||
<h2 class=card-title>loaded now</h2>
|
||||
<div class=scroll><table>
|
||||
<tr><th>model</th><td><code>{{.Status.Model}}</code></td></tr>
|
||||
<tr><th>file</th><td><code>{{.Status.ModelPath}}</code></td></tr>
|
||||
<tr><th>server</th><td><code>{{.Status.BaseURL}}</code></td></tr>
|
||||
<tr><th>n_ctx</th><td>{{.Status.NCtx}}</td></tr>
|
||||
<tr><th>n_gpu_layers</th><td>{{.Status.NGpuLayers}}</td></tr>
|
||||
</table></div>
|
||||
<p class=hint>the model name is what llama-server reports for itself, not what the config says it should be.</p>
|
||||
</section>
|
||||
<section class=card>
|
||||
<h2 class=card-title>allowed models <span class=badge>{{len .Status.Swappable}}</span></h2>
|
||||
{{if .Status.Swappable}}<div class=scroll><table><tr><th>file</th><th></th></tr>
|
||||
{{range .Status.Swappable}}<tr><td><code>{{.}}</code></td>
|
||||
<td><form method=post action=/models class=inline-form>
|
||||
<input type=hidden name=model_path value="{{.}}">
|
||||
<button class=btn>load this one</button></form></td></tr>{{end}}
|
||||
</table></div>
|
||||
{{else}}<div class=empty><div>no models allowlisted</div></div>{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
{{template "shellBottom"}}`
|
||||
var modelsTmpl = parsePage("models", modelsHTML, nil)
|
||||
|
||||
type modelsPage struct {
|
||||
Msg string
|
||||
@@ -154,8 +121,5 @@ func handleModels(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, swap
|
||||
}
|
||||
}
|
||||
page.Status = st
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := modelsTmpl.Execute(w, page); err != nil {
|
||||
log.Printf("models render: %v", err)
|
||||
}
|
||||
renderPage(w, modelsTmpl, page)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
{{template "shellTop" "models"}}
|
||||
<h1>Resident model</h1>
|
||||
<p class=hint>swapping requires step-up — <a href=/auth/passkey>assert a passkey</a> first. The old model is unloaded before the new one is loaded (one model fits the iGPU at a time), so turns during the load are refused and fall back to the classifier.</p>
|
||||
<p class=hint>a swap is not remembered. Nothing writes it down, so the next restart of the daemon — including the one <code>mavupdate</code> does — comes back on <code>phraser.model_path</code> from the config. Make it stick by editing that.</p>
|
||||
{{if .Msg}}<div class="msg msg-ok">{{.Msg}}</div>{{end}}
|
||||
{{if .Err}}<div class="msg msg-err">{{.Err}}</div>{{end}}
|
||||
{{if .Off}}
|
||||
<section class=card>
|
||||
<h2 class=card-title>swap not configured</h2>
|
||||
<p class=hint>this core has no <code>phraser.swap_models</code> allowlist, so there is nothing to swap to. Add the gguf paths you allow to <code>deploy/mavend.json</code> and restart once.</p>
|
||||
</section>
|
||||
{{else}}
|
||||
<section class=card>
|
||||
<h2 class=card-title>loaded now</h2>
|
||||
<div class=scroll><table>
|
||||
<tr><th>model</th><td><code>{{.Status.Model}}</code></td></tr>
|
||||
<tr><th>file</th><td><code>{{.Status.ModelPath}}</code></td></tr>
|
||||
<tr><th>server</th><td><code>{{.Status.BaseURL}}</code></td></tr>
|
||||
<tr><th>n_ctx</th><td>{{.Status.NCtx}}</td></tr>
|
||||
<tr><th>n_gpu_layers</th><td>{{.Status.NGpuLayers}}</td></tr>
|
||||
</table></div>
|
||||
<p class=hint>the model name is what llama-server reports for itself, not what the config says it should be.</p>
|
||||
</section>
|
||||
<section class=card>
|
||||
<h2 class=card-title>allowed models <span class=badge>{{len .Status.Swappable}}</span></h2>
|
||||
{{if .Status.Swappable}}<div class=scroll><table><tr><th>file</th><th></th></tr>
|
||||
{{range .Status.Swappable}}<tr><td><code>{{.}}</code></td>
|
||||
<td><form method=post action=/models class=inline-form>
|
||||
<input type=hidden name=model_path value="{{.}}">
|
||||
<button class=btn>load this one</button></form></td></tr>{{end}}
|
||||
</table></div>
|
||||
{{else}}<div class=empty><div>no models allowlisted</div></div>{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
{{template "shellBottom"}}
|
||||
@@ -8,7 +8,10 @@
|
||||
<div class=scroll><table class=mono>
|
||||
<tr><th>at<th>kind<th>what</tr>
|
||||
{{range .Items}}<tr>
|
||||
<td>{{.At.Format "15:04"}}</td>
|
||||
<!-- In his clock. A plan item's At is a calendar fact's Ts or a reminder's
|
||||
FireTs, and the store hands both back as UTC, so the raw hour printed a
|
||||
reminder here at an hour /reminders did not agree with (V-469). -->
|
||||
<td>{{.At.Local.Format "15:04"}}</td>
|
||||
<td class=gray>{{.Kind}}</td>
|
||||
<td>{{if .Uncertain}}<span class=hint title="relayed notification, not a calendar read">похоже,</span> {{end}}{{.Text}}</td>
|
||||
</tr>{{end}}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
)
|
||||
|
||||
// morningCore serves a canned checklist and day plan.
|
||||
type morningCore struct {
|
||||
ipc.UnimplementedCoreAPI
|
||||
status []ipc.MorningRoutineStatus
|
||||
plan ipc.DayPlan
|
||||
}
|
||||
|
||||
func (c *morningCore) MorningStatus(context.Context) ([]ipc.MorningRoutineStatus, error) {
|
||||
return c.status, nil
|
||||
}
|
||||
|
||||
func (c *morningCore) DayPlan(context.Context) (ipc.DayPlan, error) { return c.plan, nil }
|
||||
|
||||
func TestMorningRendersPlanTimesInLocalZone(t *testing.T) {
|
||||
// A plan item's At is a calendar fact's Ts or a reminder's FireTs, and the
|
||||
// store hands both back as UTC. Printed raw, /morning named an hour for a
|
||||
// reminder that /reminders — which does call Local — disagreed with.
|
||||
away := awayFromLocal()
|
||||
at := time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC).In(away)
|
||||
core := &morningCore{plan: ipc.DayPlan{
|
||||
Date: at,
|
||||
Items: []ipc.DayPlanItem{{At: at, Text: "выпить таблетки", Kind: "reminder"}},
|
||||
}}
|
||||
w := httptest.NewRecorder()
|
||||
handleMorning(w, httptest.NewRequest(http.MethodGet, "/morning", nil), core)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", w.Code)
|
||||
}
|
||||
body := w.Body.String()
|
||||
if !strings.Contains(body, at.Local().Format("15:04")) {
|
||||
t.Errorf("plan item not rendered in his clock (%s): %s", at.Local().Format("15:04"), body)
|
||||
}
|
||||
if strings.Contains(body, at.In(away).Format("15:04")) {
|
||||
t.Errorf("plan item rendered in the stored zone: %s", body)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
)
|
||||
|
||||
//go:embed notifications.html
|
||||
var notificationsHTML string
|
||||
|
||||
var notificationsTmpl = parsePage("notifications", notificationsHTML, nil)
|
||||
|
||||
// deliveryRow is one outbox line, with every timestamp already formatted so
|
||||
// the template holds no date logic — same shape as taskRow.
|
||||
type deliveryRow struct {
|
||||
Kind string
|
||||
Target string
|
||||
Channel string
|
||||
Status string
|
||||
Created string
|
||||
Completed string
|
||||
}
|
||||
|
||||
func deliveryRows(as []ipc.DeliveryAttempt) []deliveryRow {
|
||||
out := make([]deliveryRow, 0, len(as))
|
||||
for _, a := range as {
|
||||
target := a.Rule
|
||||
if target == "" && a.ReminderID != 0 {
|
||||
target = "reminder #" + strconv.FormatInt(a.ReminderID, 10)
|
||||
}
|
||||
row := deliveryRow{
|
||||
Kind: a.Kind,
|
||||
Target: target,
|
||||
Channel: a.Channel,
|
||||
Status: a.Status,
|
||||
Created: a.Created.Format("02.01 15:04"),
|
||||
}
|
||||
if a.Completed != nil {
|
||||
row.Completed = a.Completed.Format("15:04")
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func handleNotifications(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
||||
if !requireCore(w, core, "notifications") {
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
nudges, err := core.RecentNudges(ctx, 50)
|
||||
if err != nil {
|
||||
log.Printf("notifications: %v", err)
|
||||
http.Error(w, "notifications error: "+err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
// The outbox, on the page that already answers "what did she send".
|
||||
// A failed or dropped attempt is why she went quiet, and until now it was
|
||||
// recorded and unreadable (Vikunja #390). Filter with ?status=dropped.
|
||||
status := r.URL.Query().Get("status")
|
||||
attempts, err := core.DeliveryAttempts(ctx, status, 50)
|
||||
if err != nil {
|
||||
// The nudge list is still worth showing, so this is a note on the page
|
||||
// rather than a dead page.
|
||||
log.Printf("notifications: delivery attempts: %v", err)
|
||||
}
|
||||
renderPage(w, notificationsTmpl, map[string]any{
|
||||
"Nudges": nudges,
|
||||
"Attempts": deliveryRows(attempts),
|
||||
"Status": status,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
_ "embed"
|
||||
"html/template"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
)
|
||||
|
||||
// The read-only pages: dash, history, trace, morning, events, and the voice
|
||||
// page mounted at "/". Each is GET-only, reads through CoreAPI and renders.
|
||||
// The write surfaces live next to their own handlers (tasks.go, tools.go,
|
||||
// routines.go, chat.go).
|
||||
|
||||
//go:embed dash.html
|
||||
var dashHTML string
|
||||
|
||||
//go:embed history.html
|
||||
var historyHTML string
|
||||
|
||||
//go:embed trace.html
|
||||
var traceHTML string
|
||||
|
||||
//go:embed morning.html
|
||||
var morningHTML string
|
||||
|
||||
//go:embed events.html
|
||||
var eventsHTML string
|
||||
|
||||
//go:embed voice.html
|
||||
var voiceHTML string
|
||||
|
||||
//go:embed ecosystem.html
|
||||
var ecosystemHTML string
|
||||
|
||||
// dashTmpl — the monitoring read surface, server-rendered from dash.html;
|
||||
// a small fetch loop refreshes the tables in place. html/template escapes the
|
||||
// user text in facts/nudges. Read-only: browses the append-only store via
|
||||
// CoreAPI, never writes — the store IS the audit trail, this just shows it.
|
||||
var dashTmpl = parsePage("dash", dashHTML, nil)
|
||||
|
||||
var historyTmpl = parsePage("history", historyHTML, nil)
|
||||
|
||||
var traceTmpl = parsePage("trace", traceHTML, template.FuncMap{
|
||||
"fmtTime": func(t *time.Time) string {
|
||||
if t == nil || t.IsZero() {
|
||||
return "—"
|
||||
}
|
||||
return t.Format("15:04:05")
|
||||
},
|
||||
"join": strings.Join,
|
||||
})
|
||||
|
||||
// morningTmpl — read-only view of today's checklist state per configured
|
||||
// morning routine (internal/morning). Same shape as trace.html: a plain
|
||||
// server-rendered page, refreshed on reload — no live-update loop, since
|
||||
// checklist state changes on the scale of minutes, not seconds.
|
||||
var morningTmpl = parsePage("morning", morningHTML, nil)
|
||||
|
||||
// eventsTmpl — the unified intake journal (Vikunja #283), read-only. Same
|
||||
// shape as trace.html and morning.html: server-rendered, refreshed on reload.
|
||||
var eventsTmpl = parsePage("events", eventsHTML, nil)
|
||||
|
||||
var voiceTmpl = parsePage("voice", voiceHTML, nil)
|
||||
|
||||
// ecosystemTmpl — read-only view of the Nexus/Praxis/Hexis siblings, whose only
|
||||
// human surface is here (they ship no web UI of their own).
|
||||
var ecosystemTmpl = parsePage("ecosystem", ecosystemHTML, nil)
|
||||
|
||||
func handleDash(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
||||
if !requireCore(w, core, "dash") {
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
pres, err1 := core.Presence(ctx)
|
||||
facts, err2 := core.RecentFacts(ctx, 50)
|
||||
nudges, err3 := core.RecentNudges(ctx, 50)
|
||||
notes, err4 := core.RecentNotes(ctx, 50)
|
||||
if err := cmp.Or(err1, err2, err3, err4); err != nil {
|
||||
log.Printf("dash: %v", err)
|
||||
http.Error(w, "core read failed", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
renderPage(w, dashTmpl, struct {
|
||||
Presence ipc.Presence
|
||||
Facts []ipc.Fact
|
||||
Nudges []ipc.Nudge
|
||||
Notes []ipc.Note
|
||||
}{pres, facts, nudges, notes})
|
||||
}
|
||||
|
||||
func handleHistory(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
||||
if !requireCore(w, core, "history") {
|
||||
return
|
||||
}
|
||||
facts, err := core.RecentFacts(r.Context(), 200)
|
||||
if err != nil {
|
||||
log.Printf("history: %v", err)
|
||||
http.Error(w, "core read failed", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
renderPage(w, historyTmpl, struct {
|
||||
Facts []ipc.Fact
|
||||
}{facts})
|
||||
}
|
||||
|
||||
func handleTrace(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
||||
if !requireCore(w, core, "trace") {
|
||||
return
|
||||
}
|
||||
trace, err := core.TickTrace(r.Context())
|
||||
if err != nil {
|
||||
log.Printf("trace: %v", err)
|
||||
http.Error(w, "core read failed", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
// The turn records share this page rather than getting one of their own
|
||||
// (V-564). Both answer the same question, who won and who lost and why, and
|
||||
// one is about nudges while the other is about utterances. A read failure
|
||||
// here is not fatal to the page. The rule trace above it still renders, and
|
||||
// a daemon too old to know the method is the ordinary case during a rolling
|
||||
// deploy.
|
||||
turns, err := core.TurnDecisions(r.Context(), 25)
|
||||
if err != nil {
|
||||
log.Printf("trace: turn decisions: %v", err)
|
||||
}
|
||||
renderPage(w, traceTmpl, traceData{Tick: trace, Turns: turns})
|
||||
}
|
||||
|
||||
// traceData — what trace.html renders: the last tick's rule arbitration and the
|
||||
// last turns' claim arbitration.
|
||||
type traceData struct {
|
||||
Tick ipc.TickTrace
|
||||
Turns []ipc.TurnDecision
|
||||
}
|
||||
|
||||
// morningView — what /morning renders: today's plan on top, the checklist
|
||||
// state under it. PlanErr is set instead of Plan when the core could not build
|
||||
// a plan, so the page says so rather than showing an empty day.
|
||||
type morningView struct {
|
||||
Plan *ipc.DayPlan
|
||||
PlanErr string
|
||||
Routines []ipc.MorningRoutineStatus
|
||||
}
|
||||
|
||||
func handleMorning(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
||||
if !requireCore(w, core, "morning") {
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
status, err := core.MorningStatus(ctx)
|
||||
if err != nil {
|
||||
log.Printf("morning: %v", err)
|
||||
http.Error(w, "core read failed", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
view := morningView{Routines: status}
|
||||
// The day plan (#128) shows on this page because it is the same question at
|
||||
// a different scale. A plan read that fails must not take the checklist
|
||||
// down with it — the page degrades to what it had before.
|
||||
plan, err := core.DayPlan(ctx)
|
||||
if err != nil {
|
||||
log.Printf("morning: day plan: %v", err)
|
||||
view.PlanErr = err.Error()
|
||||
} else {
|
||||
view.Plan = &plan
|
||||
}
|
||||
renderPage(w, morningTmpl, view)
|
||||
}
|
||||
|
||||
// eventsView — what /events renders. Err is set instead of Events when the
|
||||
// core could not serve the journal, so the page says why rather than showing an
|
||||
// empty intake and implying nothing arrived.
|
||||
type eventsView struct {
|
||||
Events []ipc.IntakeEvent
|
||||
Err string
|
||||
}
|
||||
|
||||
// eventsPageLimit — how many envelopes the page shows. The ring holds more; a
|
||||
// page is for scanning what just happened, not for archaeology.
|
||||
const eventsPageLimit = 200
|
||||
|
||||
func handleEvents(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
||||
if !requireCore(w, core, "intake journal") {
|
||||
return
|
||||
}
|
||||
var view eventsView
|
||||
evs, err := core.RecentEvents(r.Context(), eventsPageLimit)
|
||||
if err != nil {
|
||||
log.Printf("events: %v", err)
|
||||
view.Err = err.Error()
|
||||
} else {
|
||||
view.Events = evs
|
||||
}
|
||||
renderPage(w, eventsTmpl, view)
|
||||
}
|
||||
|
||||
func handleVoice(w http.ResponseWriter, r *http.Request) {
|
||||
renderPage(w, voiceTmpl, nil)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
{{template "shellTop" "passkey"}}
|
||||
<h1>Passkey</h1>
|
||||
<p class=hint>Enroll a passkey once, then assert it to unlock destructive actions (tool enable) for a few minutes.</p>
|
||||
<div class=flex gap-2>
|
||||
<button class=btn onclick=enroll()>enroll passkey</button>
|
||||
<button class=btn onclick=assert()>assert (step-up)</button>
|
||||
<button class=btn onclick=rewrapKey()>rewrite cold-start key</button>
|
||||
<a href=/tools><button class=btn-primary>→ tools</button></a>
|
||||
</div>
|
||||
<p class=hint>Rewriting the cold-start key points it at the passkey you assert next. Every other enrolled passkey stops being able to unlock a cold-booted daemon.</p>
|
||||
<div id=msg></div>
|
||||
{{template "shellBottom"}}
|
||||
<script>
|
||||
const b64u=b=>btoa(String.fromCharCode(...new Uint8Array(b))).replace(/\+/g,'-').replace(/\//g,'_').replace(/=+$/,'');
|
||||
const ub64=s=>{s=s.replace(/-/g,'+').replace(/_/g,'/');const b=atob(s),a=new Uint8Array(b.length);for(let i=0;i<b.length;i++)a[i]=b.charCodeAt(i);return a;};
|
||||
const say=(t,ok)=>{const m=document.getElementById('msg');m.textContent=t;m.className=ok?'msg msg-ok':'msg msg-err';};
|
||||
async function enroll(){try{
|
||||
const {challenge,options}=await (await fetch('/auth/webauthn/register/begin')).json();
|
||||
options.challenge=ub64(options.challenge);
|
||||
options.user.id=ub64(options.user.id);
|
||||
const c=await navigator.credentials.create({publicKey:options});
|
||||
const r=await fetch('/auth/webauthn/register/finish',{method:'POST',headers:{'content-type':'application/json'},
|
||||
body:JSON.stringify({challenge,credential:{id:c.id,type:c.type,response:{
|
||||
clientDataJSON:b64u(c.response.clientDataJSON),attestationObject:b64u(c.response.attestationObject)}}})});
|
||||
if(!r.ok){say('enroll failed: '+await r.text(),false);return;}
|
||||
// The wrapped key can only be written from an assertion: PRF results are
|
||||
// not produced at create() time on most authenticators. Enrolment reports
|
||||
// whether PRF is available at all so he is not told cold-start works when
|
||||
// it cannot.
|
||||
const ext=c.getClientExtensionResults?c.getClientExtensionResults():{};
|
||||
const prfOK=!!(ext.prf&&ext.prf.enabled);
|
||||
say(prfOK?'enrolled ✓ — now assert once to write the cold-start key':
|
||||
'enrolled ✓ — but this authenticator has no PRF: cold-start unlock unavailable',true);
|
||||
}catch(e){say('enroll error: '+e,false);}}
|
||||
async function assert(explicit){try{
|
||||
const {challenge,options}=await (await fetch('/auth/webauthn/assert/begin')).json();
|
||||
options.challenge=ub64(options.challenge);
|
||||
const c=await navigator.credentials.get({publicKey:options});
|
||||
// The PRF result is the cold-start secret. It never touches localStorage
|
||||
// and is posted once, over the same request as the assertion.
|
||||
const ext=c.getClientExtensionResults?c.getClientExtensionResults():{};
|
||||
const prf=ext.prf&&ext.prf.results&&ext.prf.results.first?b64u(ext.prf.results.first):'';
|
||||
const r=await fetch('/auth/webauthn/assert/finish',{method:'POST',headers:{'content-type':'application/json'},
|
||||
body:JSON.stringify({challenge,prf,explicit:!!explicit,credential:{id:c.id,type:c.type,response:{
|
||||
clientDataJSON:b64u(c.response.clientDataJSON),authenticatorData:b64u(c.response.authenticatorData),
|
||||
signature:b64u(c.response.signature)}}})});
|
||||
if(!r.ok){say('assert failed: '+await r.text(),false);return;}
|
||||
if(!prf){say('stepped up ✓ — no PRF from this authenticator, so cold-start unlock stayed unavailable',true);return;}
|
||||
say(explicit?'stepped up ✓ — cold-start key now points at this passkey':
|
||||
'stepped up ✓ — enable tools now',true);
|
||||
}catch(e){say('assert error: '+e,false);}}
|
||||
// Rewriting the wrapped key is a separate gesture, never a side effect of a
|
||||
// step-up. Only this button sets explicit, and only explicit lets the daemon
|
||||
// replace a blob that already exists.
|
||||
async function rewrapKey(){
|
||||
if(!confirm('Rewrite the cold-start key under the passkey you are about to assert? Every other enrolled passkey stops being able to unlock a cold-booted daemon.'))return;
|
||||
await assert(true);}
|
||||
</script>
|
||||
@@ -0,0 +1,74 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
)
|
||||
|
||||
//go:embed reminders.html
|
||||
var remindersHTML string
|
||||
|
||||
var remindersTmpl = parsePage("reminders", remindersHTML, nil)
|
||||
|
||||
// reminderRow is one line on /reminders, with the payload unwrapped and both
|
||||
// timestamps already in his clock.
|
||||
//
|
||||
// The page rendered `{{.Payload}}` and the UTC instant, so a reminder read
|
||||
// `{"text":"выпить таблетки"}` and fired an hour off what he was told
|
||||
// (Vikunja #469). Neither is a formatting nicety: the envelope is an internal
|
||||
// shape he never chose, and a time on a page he reads is the time on his wall.
|
||||
type reminderRow struct {
|
||||
Created string
|
||||
Fires string
|
||||
Status string
|
||||
Text string
|
||||
}
|
||||
|
||||
// reminderText unwraps the {"text":...} payload the router writes.
|
||||
//
|
||||
// A copy of store.ReminderText rather than a call to it, because mavweb is one
|
||||
// of the pure-Go daemons and internal/store carries the CGO sqlite driver. The
|
||||
// ipc DTO is decoupled from the store on purpose, so the unwrap belongs to
|
||||
// whoever renders it. Payload that is not that shape is shown as he said it.
|
||||
func reminderText(payload string) string {
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal([]byte(payload), &m); err == nil {
|
||||
if t, ok := m["text"]; ok {
|
||||
if s, isStr := t.(string); isStr && s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(payload)
|
||||
}
|
||||
|
||||
func reminderRows(rs []ipc.Reminder) []reminderRow {
|
||||
out := make([]reminderRow, 0, len(rs))
|
||||
for _, r := range rs {
|
||||
out = append(out, reminderRow{
|
||||
Created: r.CreatedTs.Local().Format("02 Jan 15:04"),
|
||||
Fires: r.FireTs.Local().Format("02 Jan 15:04"),
|
||||
Status: r.Status,
|
||||
Text: reminderText(r.Payload),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func handleReminders(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
||||
if !requireCore(w, core, "reminders") {
|
||||
return
|
||||
}
|
||||
reminders, err := core.ListReminders(r.Context(), 50)
|
||||
if err != nil {
|
||||
log.Printf("reminders: %v", err)
|
||||
http.Error(w, "reminders error: "+err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
renderPage(w, remindersTmpl, map[string]any{"Reminders": reminderRows(reminders)})
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
_ "embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/pattern"
|
||||
"github.com/kami/maven/internal/webauthn"
|
||||
)
|
||||
|
||||
//go:embed routines.html
|
||||
var routinesHTML string
|
||||
|
||||
// routinesTmpl — the proposed-routine review surface. One row per thing maven
|
||||
// noticed, in her words, with at most two actions: accept or dismiss.
|
||||
var routinesTmpl = parsePage("routines", routinesHTML, nil)
|
||||
|
||||
// routineView is one line on the page: what maven noticed, in her words, and
|
||||
// how long ago she noticed it. A view model, not a database row — the template
|
||||
// never formats an interval or a timestamp itself.
|
||||
type routineView struct {
|
||||
ID int64
|
||||
Phrase string
|
||||
Noticed string
|
||||
}
|
||||
|
||||
// handleRoutines serves the routine review surface (GET) and answers a
|
||||
// proposal (POST id + action=accept|dismiss).
|
||||
//
|
||||
// Accept is gated at step-up, the same tier as enabling a tool: saying yes
|
||||
// hands the trigger loop a new standing reason to speak to the human, so it
|
||||
// moves the boundary and only an authed surface may do it. Dismiss is not
|
||||
// gated — it only ever removes a reason to speak, so the worst a weaker caller
|
||||
// can do is make maven quieter.
|
||||
func handleRoutines(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, session *webauthn.PasskeySession, requireStepUp bool) {
|
||||
if !requireCore(w, core, "routines") {
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
var msg string
|
||||
if r.Method == http.MethodPost {
|
||||
var ok bool
|
||||
if msg, ok = applyRoutinePost(w, r, core, session, requireStepUp); !ok {
|
||||
return
|
||||
}
|
||||
}
|
||||
proposed, err := core.ListProposedRoutines(ctx)
|
||||
if err != nil {
|
||||
log.Printf("routines: list: %v", err)
|
||||
http.Error(w, "routines error: "+err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
renderPage(w, routinesTmpl, struct {
|
||||
Msg string
|
||||
Proposed []routineView
|
||||
}{msg, toRoutineViews(proposed)})
|
||||
}
|
||||
|
||||
// applyRoutinePost performs one write and returns the message to show. Unlike
|
||||
// the task form, a bad request here is an HTTP status rather than an inline
|
||||
// note, so the second return says whether the response was already written.
|
||||
func applyRoutinePost(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, session *webauthn.PasskeySession, requireStepUp bool) (string, bool) {
|
||||
ctx := r.Context()
|
||||
action := r.FormValue("action")
|
||||
// "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 !stepUpGate(w, session, requireStepUp) {
|
||||
return "", false
|
||||
}
|
||||
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 "", false
|
||||
}
|
||||
return out, true
|
||||
}
|
||||
|
||||
idStr := r.FormValue("id")
|
||||
var rid int64
|
||||
if n, _ := fmt.Sscanf(idStr, "%d", &rid); n != 1 {
|
||||
http.Error(w, "invalid id", http.StatusBadRequest)
|
||||
return "", false
|
||||
}
|
||||
switch action {
|
||||
case "accept":
|
||||
if !stepUpGate(w, session, requireStepUp) {
|
||||
return "", false
|
||||
}
|
||||
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 "", false
|
||||
}
|
||||
return "accepted routine — maven will remind you", true
|
||||
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 "", false
|
||||
}
|
||||
return "dismissed routine", true
|
||||
default:
|
||||
http.Error(w, "unknown action", http.StatusBadRequest)
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
// toRoutineViews turns the wire rows into view models. The phrase comes from
|
||||
// pattern.PhraseRoutine so the page says the same thing maven's voice says.
|
||||
func toRoutineViews(rs []ipc.ProposedRoutine) []routineView {
|
||||
out := make([]routineView, 0, len(rs))
|
||||
for _, r := range rs {
|
||||
p := pattern.ProposedRoutine{Action: r.Action, Object: r.Object, IntervalDays: r.IntervalDays}
|
||||
noticed := "just now"
|
||||
if r.CreatedTs > 0 {
|
||||
noticed = time.Since(time.UnixMilli(r.CreatedTs)).Round(time.Minute).String() + " ago"
|
||||
}
|
||||
out = append(out, routineView{ID: r.ID, Phrase: pattern.PhraseRoutine(&p), Noticed: noticed})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// acceptRoutine marks a proposal accepted. This page is the ONLY surface that
|
||||
// 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.
|
||||
func acceptRoutine(ctx context.Context, core ipc.CoreAPI, id int64) error {
|
||||
proposed, err := core.ListProposedRoutines(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var found *ipc.ProposedRoutine
|
||||
for i := range proposed {
|
||||
if proposed[i].ID == id {
|
||||
found = &proposed[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if found == nil {
|
||||
return errors.New("no such proposed routine")
|
||||
}
|
||||
|
||||
// No reminder is created here. Accepting only flips the status; the tick
|
||||
// loop reads accepted routines and nudges on the interval (Vikunja #366).
|
||||
// The old code made a one-shot reminder, so a non-weekly routine fired
|
||||
// once and then went quiet forever.
|
||||
return core.AcceptProposedRoutine(ctx, id)
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"html/template"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/webauthn"
|
||||
)
|
||||
|
||||
// shellHTML — the shell partial every page is wrapped in: "shellTop", the
|
||||
// "sidebar" it calls, and "shellBottom". It used to be two Go string constants
|
||||
// with the sidebar assembled by a strings.Builder, which is the one piece of
|
||||
// markup that was still concatenated in Go.
|
||||
//
|
||||
// Two template pieces wrap every page:
|
||||
//
|
||||
// {{template "shellTop" "<page-key>"}} ← opens <html>, topbar, sidebar, content
|
||||
// {{template "shellBottom"}} ← closes content, inspector, </html>
|
||||
//
|
||||
// The page-key argument highlights the active sidebar link and sets breadcrumbs.
|
||||
//
|
||||
//go:embed shell.html
|
||||
var shellHTML string
|
||||
|
||||
// sidebarSections maps sidebar section → page entries {label, url, icon}
|
||||
var sidebarSections = []struct {
|
||||
Label string
|
||||
Pages []struct{ Label, URL, Key string }
|
||||
}{
|
||||
{
|
||||
Label: "Workspace",
|
||||
Pages: []struct{ Label, URL, Key string }{
|
||||
{Label: "Dashboard", URL: "/dash", Key: "dash"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Label: "Infrastructure",
|
||||
Pages: []struct{ Label, URL, Key string }{
|
||||
{Label: "History", URL: "/history", Key: "history"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Label: "Automation",
|
||||
Pages: []struct{ Label, URL, Key string }{
|
||||
{Label: "Rule Trace", URL: "/trace", Key: "trace"},
|
||||
{Label: "Notifications", URL: "/notifications", Key: "notifications"},
|
||||
{Label: "Tasks", URL: "/tasks", Key: "tasks"},
|
||||
{Label: "Reminders", URL: "/reminders", Key: "reminders"},
|
||||
{Label: "Routines", URL: "/routines", Key: "routines"},
|
||||
{Label: "Morning", URL: "/morning", Key: "morning"},
|
||||
{Label: "Intake", URL: "/events", Key: "events"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Label: "Ecosystem",
|
||||
Pages: []struct{ Label, URL, Key string }{
|
||||
{Label: "Siblings", URL: "/ecosystem", Key: "ecosystem"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Label: "AI",
|
||||
Pages: []struct{ Label, URL, Key string }{
|
||||
{Label: "Chat", URL: "/chat", Key: "chat"},
|
||||
{Label: "Voice", URL: "/", Key: "voice"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Label: "Settings",
|
||||
Pages: []struct{ Label, URL, Key string }{
|
||||
{Label: "Tools", URL: "/tools", Key: "tools"},
|
||||
{Label: "Model", URL: "/models", Key: "models"},
|
||||
{Label: "Passkey", URL: "/auth/passkey", Key: "passkey"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// pageChrome is the per-page title and ethos-icons.svg symbol id, keyed by the
|
||||
// page key a page hands to shellTop. One table rather than two parallel
|
||||
// switches, so a new page cannot end up with a title and no icon.
|
||||
var pageChrome = map[string]struct{ Title, Icon string }{
|
||||
"dash": {"Dashboard", "i-grid"},
|
||||
"history": {"History", "i-clock"},
|
||||
"trace": {"Rule Trace", "i-wave"},
|
||||
"notifications": {"Notifications", "i-bell"},
|
||||
"tasks": {"Tasks", "i-grid"},
|
||||
"reminders": {"Reminders", "i-calendar"},
|
||||
"routines": {"Routines", "i-repeat"},
|
||||
"morning": {"Morning Routines", "i-calendar"},
|
||||
"events": {"Intake", "i-download"},
|
||||
"chat": {"Chat", "i-message"},
|
||||
"voice": {"Voice", "i-mic"},
|
||||
"ecosystem": {"Ecosystem", "i-grid"},
|
||||
"tools": {"Tools", "i-settings"},
|
||||
"models": {"Resident Model", "i-wave"},
|
||||
"passkey": {"Passkey", "i-lock"},
|
||||
}
|
||||
|
||||
// pageIcon returns the ethos-icons.svg symbol id for the given page. The
|
||||
// sidebar template wraps it in the <use> reference.
|
||||
func pageIcon(key string) string {
|
||||
if c, ok := pageChrome[key]; ok {
|
||||
return c.Icon
|
||||
}
|
||||
return "i-search"
|
||||
}
|
||||
|
||||
// pageTitle returns the human-readable page title for the given key. An
|
||||
// unknown key renders as itself rather than as a blank crumb.
|
||||
func pageTitle(key string) string {
|
||||
if c, ok := pageChrome[key]; ok {
|
||||
return c.Title
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
// shellFuncs returns the FuncMap shared by every server-rendered page template.
|
||||
func shellFuncs() template.FuncMap {
|
||||
return template.FuncMap{
|
||||
"pageTitle": pageTitle,
|
||||
"pageIcon": pageIcon,
|
||||
"sidebarSections": func() any { return sidebarSections },
|
||||
"ago": func(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
return "never"
|
||||
}
|
||||
return time.Since(t).Round(time.Second).String() + " ago"
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// parsePage parses one server-rendered page: the shell partial plus the page's
|
||||
// own embedded markup, under the shared FuncMap. extra adds page-local
|
||||
// functions (/tools needs capability lookups, /trace a time format) and may be
|
||||
// nil.
|
||||
//
|
||||
// The name is also the page's log label, so a render failure says which page.
|
||||
func parsePage(name, body string, extra template.FuncMap) *template.Template {
|
||||
funcs := shellFuncs()
|
||||
for k, v := range extra {
|
||||
funcs[k] = v
|
||||
}
|
||||
return template.Must(template.New(name).Funcs(funcs).Parse(shellHTML + body))
|
||||
}
|
||||
|
||||
// renderPage writes one page. Every handler sent the same content type and
|
||||
// logged the same way on failure; the header is already written by then, so a
|
||||
// render error can only be logged, never reported.
|
||||
func renderPage(w http.ResponseWriter, t *template.Template, data any) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := t.Execute(w, data); err != nil {
|
||||
log.Printf("%s render: %v", t.Name(), err)
|
||||
}
|
||||
}
|
||||
|
||||
// requireCore answers whether the surface has a core to read. mavweb runs
|
||||
// without -core (voice-only), and every page that needs mavend says so with a
|
||||
// 503 naming itself rather than a blank error.
|
||||
func requireCore(w http.ResponseWriter, core ipc.CoreAPI, surface string) bool {
|
||||
if core == nil {
|
||||
http.Error(w, surface+" disabled (no -core)", http.StatusServiceUnavailable)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// stepUpGate reports whether the caller may proceed through the AuthStepUp
|
||||
// gate, writing the 403 itself when it may not. See stepUpOK for the policy.
|
||||
func stepUpGate(w http.ResponseWriter, session *webauthn.PasskeySession, requireStepUp bool) bool {
|
||||
if stepUpOK(session, requireStepUp) {
|
||||
return true
|
||||
}
|
||||
http.Error(w, "step-up required: assert a passkey first", http.StatusForbidden)
|
||||
return false
|
||||
}
|
||||
|
||||
// stepUpOK is the single decision point for the AuthStepUp gate shared by
|
||||
// POST /tools and POST /api/revert.
|
||||
//
|
||||
// A nil session means WebAuthn is not configured (-webauthn-origin /
|
||||
// -webauthn-rpid unset), so step-up can never be asserted — not merely unmet.
|
||||
// The default is therefore fail-OPEN: gating on an unassertable session would
|
||||
// 403 those surfaces permanently. In that mode the actions rest on the
|
||||
// transport-level auth in front of mavweb (wg+nginx+auth), and main logs a
|
||||
// startup warning naming them. With -require-stepup the same situation fails
|
||||
// CLOSED instead: no assertable step-up ⇒ deny.
|
||||
func stepUpOK(session *webauthn.PasskeySession, requireStepUp bool) bool {
|
||||
if session == nil {
|
||||
return !requireStepUp
|
||||
}
|
||||
return session.IsStepUp()
|
||||
}
|
||||
@@ -0,0 +1,412 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
_ "embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/tasks"
|
||||
)
|
||||
|
||||
//go:embed tasks.html
|
||||
var tasksHTML string
|
||||
|
||||
var tasksTmpl = parsePage("tasks", tasksHTML, nil)
|
||||
|
||||
// now — the wall clock, indirected so the task page can be rendered at a fixed
|
||||
// instant in a test. internal/tasks is pure and the daemon path already ranks
|
||||
// through a clock it is handed; the page had no reason to be the one surface
|
||||
// that could only be tested at whatever time it happened to run.
|
||||
var now = time.Now
|
||||
|
||||
// resolvedShown — how many finished tasks the page renders. The list is
|
||||
// history, it only grows, and the rows below the first screen are read by
|
||||
// nobody.
|
||||
const resolvedShown = 50
|
||||
|
||||
// taskRow is one line on /tasks, with every timestamp already formatted so the
|
||||
// template holds no date logic.
|
||||
type taskRow struct {
|
||||
ID int64
|
||||
Text string
|
||||
Source string
|
||||
Evidence string
|
||||
Status string
|
||||
Due string
|
||||
Created string
|
||||
Resolved string
|
||||
ResolvedBy string
|
||||
// DueValue and Weight are the raw values the edit form posts back
|
||||
// (Vikunja #509). Due above is for reading and says "—" for no date; a
|
||||
// date input needs "2026-08-07" or the empty string.
|
||||
DueValue string
|
||||
Weight int
|
||||
// Why — the ranker's reason for this row's position (Vikunja #129), in
|
||||
// Russian, empty when nothing distinguished the task. Blank is the honest
|
||||
// rendering: he never said this one mattered more.
|
||||
Why string
|
||||
}
|
||||
|
||||
// rowOf renders one wire task into the shared read-only columns. The two call
|
||||
// sites below add what only they need: the live rows carry the edit form's raw
|
||||
// values and the ranker's reason, the resolved rows carry neither.
|
||||
func rowOf(t ipc.Task) taskRow {
|
||||
return taskRow{
|
||||
ID: t.ID, Text: t.Text, Source: t.Source, Evidence: t.Evidence,
|
||||
Status: t.Status, Created: fmtTaskTime(&t.CreatedTs),
|
||||
Due: fmtTaskDate(t.Due), Resolved: fmtTaskTime(t.Resolved),
|
||||
ResolvedBy: t.ResolvedBy,
|
||||
}
|
||||
}
|
||||
|
||||
// handleTasks serves the task review surface (GET) and the five writes it
|
||||
// offers (POST): add, edit, confirm, done, drop.
|
||||
//
|
||||
// Not step-up gated, unlike /tools and /routines, and the difference is the
|
||||
// point: enabling a tool defines argv Maven will execute, and accepting a
|
||||
// routine hands the tick loop a new standing reason to interrupt him. A task is
|
||||
// neither — nothing in the tick loop reads the tasks table, so the worst a
|
||||
// weaker caller can do here is write a line onto a list he reads himself. It
|
||||
// still sits behind whatever transport auth fronts mavweb, like every other
|
||||
// page.
|
||||
//
|
||||
// "edit" was re-argued on the same terms rather than inheriting the exemption
|
||||
// (Vikunja #509), and it stays ungated. It rewrites a line on a list he reads
|
||||
// himself, the same blast radius "drop" already has on this page, and the store
|
||||
// refuses the two edits that would cost something: a resolved task keeps the
|
||||
// text it was finished under, and a text collision with another live row is
|
||||
// named instead of merged.
|
||||
//
|
||||
// "confirm" is the only interesting move: it promotes a candidate Maven derived
|
||||
// from something she read into work he owns. That review step is why derived
|
||||
// tasks are captured as candidates in the first place.
|
||||
func handleTasks(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
||||
if !requireCore(w, core, "tasks") {
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
var msg, errMsg string
|
||||
if r.Method == http.MethodPost {
|
||||
var err error
|
||||
msg, err = applyTaskPost(ctx, core, r)
|
||||
if err != nil {
|
||||
log.Printf("tasks: %v", err)
|
||||
errMsg = err.Error()
|
||||
}
|
||||
}
|
||||
|
||||
all, err := core.ListTasks(ctx, "")
|
||||
if err != nil {
|
||||
log.Printf("tasks: list: %v", err)
|
||||
http.Error(w, "tasks error: "+err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
// Live rows are ordered by the same ranker the spoken list uses, so the page
|
||||
// and the voice reply can never disagree about what comes first. Resolved
|
||||
// rows keep store order (newest first) — ranking finished work is pointless.
|
||||
var live []tasks.Item
|
||||
var resolved []taskRow
|
||||
resolvedTotal := 0
|
||||
for _, t := range all {
|
||||
switch t.Status {
|
||||
case "candidate", "open":
|
||||
live = append(live, tasks.Item{
|
||||
ID: t.ID, Text: t.Text, Status: t.Status,
|
||||
Created: t.CreatedTs, Due: t.Due, Weight: t.Weight,
|
||||
})
|
||||
default:
|
||||
resolvedTotal++
|
||||
// Finished work is history, and the history only grows. The page
|
||||
// showed every row that ever existed, which is a page that gets
|
||||
// slower every month for a section nobody reads past the top of.
|
||||
if len(resolved) >= resolvedShown {
|
||||
continue
|
||||
}
|
||||
resolved = append(resolved, rowOf(t))
|
||||
}
|
||||
}
|
||||
byID := make(map[int64]ipc.Task, len(all))
|
||||
for _, t := range all {
|
||||
byID[t.ID] = t
|
||||
}
|
||||
var cands, open []taskRow
|
||||
for _, r := range tasks.Rank(live, now()) {
|
||||
t := byID[r.ID]
|
||||
row := rowOf(t)
|
||||
row.DueValue = fmtTaskDateValue(t.Due)
|
||||
row.Weight = t.Weight
|
||||
row.Why = r.Reason
|
||||
if t.Status == "candidate" {
|
||||
// A candidate's due date is Maven's reading of a mail, so its
|
||||
// ranking reason is not shown as if he had set a priority.
|
||||
row.Why = ""
|
||||
cands = append(cands, row)
|
||||
} else {
|
||||
open = append(open, row)
|
||||
}
|
||||
}
|
||||
renderPage(w, tasksTmpl, struct {
|
||||
Msg, Err string
|
||||
Stalls []tasks.Stall
|
||||
Candidates []taskRow
|
||||
Open []taskRow
|
||||
Resolved []taskRow
|
||||
ResolvedMore bool
|
||||
}{msg, errMsg, tasks.Stalls(live, now()), cands, open, resolved, resolvedTotal > len(resolved)})
|
||||
}
|
||||
|
||||
// applyTaskPost performs one write and returns the message to show. A bad
|
||||
// request returns an error, which the page renders inline rather than as a
|
||||
// bare 400 — this is a form surface, not an API.
|
||||
func applyTaskPost(ctx context.Context, core ipc.CoreAPI, r *http.Request) (string, error) {
|
||||
action := r.FormValue("action")
|
||||
if action == "add" {
|
||||
text := strings.TrimSpace(r.FormValue("text"))
|
||||
if text == "" {
|
||||
return "", errors.New("empty task text")
|
||||
}
|
||||
req := ipc.CaptureTaskReq{Text: text, Source: "tap:web", Status: "open", Ts: now()}
|
||||
wgt, err := formWeight(r)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Weight = wgt
|
||||
due, err := formDue(r, now())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Due = due
|
||||
resp, err := core.CaptureTask(ctx, req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if resp.Promoted {
|
||||
return "confirmed a candidate maven had found", nil
|
||||
}
|
||||
if !resp.Created {
|
||||
return "already on the list", nil
|
||||
}
|
||||
return "added task", nil
|
||||
}
|
||||
|
||||
id, err := strconv.ParseInt(r.FormValue("id"), 10, 64)
|
||||
if err != nil {
|
||||
return "", errors.New("invalid id")
|
||||
}
|
||||
|
||||
if action == "promote" {
|
||||
msg, err := promoteCandidate(ctx, core, r, id)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
if action == "edit" {
|
||||
// The three fields capture set, and only those (Vikunja #509). Status
|
||||
// is not editable here: that ladder is one-way and has its own buttons.
|
||||
text := strings.TrimSpace(r.FormValue("text"))
|
||||
if text == "" {
|
||||
return "", errors.New("empty task text")
|
||||
}
|
||||
wgt, err := formWeight(r)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
due, err := formDue(r, now())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
switch err := core.EditTask(ctx, id, text, due, wgt); {
|
||||
case err == nil:
|
||||
return "saved task", nil
|
||||
case errors.Is(err, ipc.ErrTaskDuplicate):
|
||||
// Naming the collision instead of merging: two live rows carry two
|
||||
// provenances, and picking one is not the page's call.
|
||||
return "", errors.New("another open task already says this — drop one of the two")
|
||||
case errors.Is(err, ipc.ErrTaskResolved):
|
||||
return "", errors.New("a resolved task keeps the text it was finished under")
|
||||
default:
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
var status, msg string
|
||||
switch action {
|
||||
case "confirm":
|
||||
status, msg = "open", "confirmed task"
|
||||
case "done":
|
||||
status, msg = "done", "task done"
|
||||
case "drop":
|
||||
status, msg = "dropped", "dropped task"
|
||||
default:
|
||||
return "", fmt.Errorf("unknown action %q", action)
|
||||
}
|
||||
if err := core.SetTaskStatus(ctx, id, status, now(), "tap:web"); err != nil {
|
||||
return "", statusWriteErr(err)
|
||||
}
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
// errNoDoneWhen — the refusal has to name what is missing, or the button looks
|
||||
// broken. The field it asks for arrives with the intake form (Vikunja #511).
|
||||
var errNoDoneWhen = errors.New("write a definition of done before confirming this candidate")
|
||||
|
||||
// statusWriteErr translates a SetTaskStatus failure into what the page says.
|
||||
func statusWriteErr(err error) error {
|
||||
if errors.Is(err, ipc.ErrTaskNoDoneWhen) {
|
||||
return errNoDoneWhen
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// promoteCandidate turns a candidate into open work with the three things the
|
||||
// board needs (Vikunja #511): a definition of done, an optional blocker, and an
|
||||
// optional date.
|
||||
//
|
||||
// The definition of done is required, and the refusal is the store's — this
|
||||
// only reaches it in a readable order. The blocker is a NAME here and an entity
|
||||
// id in the row: identity lives in Nexus, so the name is resolved first and a
|
||||
// name Nexus cannot resolve stops the promotion instead of being stored.
|
||||
//
|
||||
// A date set here writes a reminder, which is the one unprompted delivery the
|
||||
// persona allows: he asked to be told, on a day he named.
|
||||
func promoteCandidate(ctx context.Context, core ipc.CoreAPI, r *http.Request, id int64) (string, error) {
|
||||
doneWhen := strings.TrimSpace(r.FormValue("done_when"))
|
||||
if doneWhen == "" {
|
||||
return "", errors.New("write a definition of done — what has to be true for this to be finished")
|
||||
}
|
||||
text := strings.TrimSpace(r.FormValue("text"))
|
||||
if text == "" {
|
||||
return "", errors.New("empty task text")
|
||||
}
|
||||
due, err := formDue(r, now())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
blockedOn, err := resolveBlocker(ctx, core, r.FormValue("blocked_on"))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if err := core.SetTaskFields(ctx, id, doneWhen, blockedOn); err != nil {
|
||||
return "", err
|
||||
}
|
||||
wgt, err := formWeight(r)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// The importance select is posted whether or not a date is. This ran under
|
||||
// `if due != nil`, so confirming a candidate as "срочно" with no deadline
|
||||
// dropped the word on the floor — the row came back normal and nothing said
|
||||
// why. A promote with neither field set still writes nothing.
|
||||
if due != nil || wgt != 0 {
|
||||
if err := core.EditTask(ctx, id, text, due, wgt); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
if err := core.SetTaskStatus(ctx, id, "open", now(), "tap:web"); err != nil {
|
||||
return "", statusWriteErr(err)
|
||||
}
|
||||
if due == nil {
|
||||
return "confirmed", nil
|
||||
}
|
||||
// A date-only field has no hour. Nine in the morning, because the reminder
|
||||
// is about a day's work and being told at midnight is being told the night
|
||||
// before.
|
||||
fire := time.Date(due.Year(), due.Month(), due.Day(), 9, 0, 0, 0, due.Location())
|
||||
if _, err := core.CreateReminder(ctx, fire, text, ""); err != nil {
|
||||
// The task IS promoted; only the reminder failed. Saying "confirmed"
|
||||
// and nothing else would leave him expecting a nudge that will not come.
|
||||
return "", fmt.Errorf("confirmed, but the reminder did not save: %w", err)
|
||||
}
|
||||
return "confirmed, and maven will remind you that morning", nil
|
||||
}
|
||||
|
||||
// resolveBlocker turns the blocked-on NAME the form posts into the entity id
|
||||
// the row stores. Identity lives in Nexus, so an unresolvable name stops the
|
||||
// promotion instead of being written as free text. An empty field is no
|
||||
// blocker and reaches Nexus not at all.
|
||||
func resolveBlocker(ctx context.Context, core ipc.CoreAPI, field string) (string, error) {
|
||||
name := strings.TrimSpace(field)
|
||||
if name == "" {
|
||||
return "", nil
|
||||
}
|
||||
ref, err := core.ResolveEntity(ctx, name, []string{"person"})
|
||||
switch {
|
||||
case errors.Is(err, ipc.ErrNotImplemented):
|
||||
return "", errors.New("no identity service here, so blocked-on cannot be stored — leave it empty")
|
||||
case errors.Is(err, ipc.ErrNoEntity):
|
||||
return "", fmt.Errorf("nexus does not know %q", name)
|
||||
case err != nil:
|
||||
return "", fmt.Errorf("resolving %q: %w", name, err)
|
||||
case ref.Ambiguous:
|
||||
// Asking, not picking: a task blocked on the wrong person is a
|
||||
// mistake nobody can see afterwards.
|
||||
return "", fmt.Errorf("%q matches %s — say which", name, strings.Join(ref.Candidates, ", "))
|
||||
}
|
||||
return ref.ID, nil
|
||||
}
|
||||
|
||||
// formWeight reads the importance select. Out-of-range clamps rather than
|
||||
// rejects — a bad select is not worth a 400 — but trailing garbage is refused,
|
||||
// because strconv is not Sscanf and "3junk" is not a 3.
|
||||
func formWeight(r *http.Request) (int, error) {
|
||||
v := r.FormValue("weight")
|
||||
if v == "" {
|
||||
return 0, nil
|
||||
}
|
||||
wgt, err := strconv.Atoi(v)
|
||||
if err != nil || wgt < 0 {
|
||||
return 0, fmt.Errorf("bad weight %q", v)
|
||||
}
|
||||
if wgt > tasks.MaxWeight {
|
||||
wgt = tasks.MaxWeight
|
||||
}
|
||||
return wgt, nil
|
||||
}
|
||||
|
||||
// formDue reads the date input. An empty field is nil, which on an edit means
|
||||
// "clear the date" — the form has no other way to say it.
|
||||
func formDue(r *http.Request, now time.Time) (*time.Time, error) {
|
||||
d := r.FormValue("due")
|
||||
if d == "" {
|
||||
return nil, nil
|
||||
}
|
||||
due, err := time.ParseInLocation("2006-01-02", d, now.Location())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("bad due date %q", d)
|
||||
}
|
||||
return &due, nil
|
||||
}
|
||||
|
||||
// fmtTaskDateValue renders a due date the way <input type=date> requires, or
|
||||
// "" for no date. Separate from fmtTaskDate, which renders it for reading.
|
||||
func fmtTaskDateValue(t *time.Time) string {
|
||||
if t == nil || t.IsZero() {
|
||||
return ""
|
||||
}
|
||||
return t.Local().Format("2006-01-02")
|
||||
}
|
||||
|
||||
func fmtTaskTime(t *time.Time) string {
|
||||
if t == nil || t.IsZero() {
|
||||
return "—"
|
||||
}
|
||||
return t.Local().Format("02 Jan 15:04")
|
||||
}
|
||||
|
||||
func fmtTaskDate(t *time.Time) string {
|
||||
if t == nil || t.IsZero() {
|
||||
return "—"
|
||||
}
|
||||
return t.Local().Format("02 Jan")
|
||||
}
|
||||
@@ -32,6 +32,31 @@ type fakeTaskCore struct {
|
||||
statusErr error
|
||||
|
||||
promoted bool
|
||||
|
||||
// The promote path's two extra writes.
|
||||
fields []any
|
||||
edits []editCall
|
||||
editErr error
|
||||
fieldErr error
|
||||
}
|
||||
|
||||
// editCall records one EditTask, so a test can say what the form actually sent
|
||||
// down rather than only that the promotion succeeded.
|
||||
type editCall struct {
|
||||
ID int64
|
||||
Text string
|
||||
Due *time.Time
|
||||
Weight int
|
||||
}
|
||||
|
||||
func (f *fakeTaskCore) EditTask(_ context.Context, id int64, text string, due *time.Time, weight int) error {
|
||||
f.edits = append(f.edits, editCall{id, text, due, weight})
|
||||
return f.editErr
|
||||
}
|
||||
|
||||
func (f *fakeTaskCore) SetTaskFields(_ context.Context, id int64, doneWhen, blockedOn string) error {
|
||||
f.fields = append(f.fields, []any{id, doneWhen, blockedOn})
|
||||
return f.fieldErr
|
||||
}
|
||||
|
||||
func (f *fakeTaskCore) ListTasks(_ context.Context, status string) ([]ipc.Task, error) {
|
||||
@@ -220,6 +245,49 @@ func TestApplyTaskPostCarriesWeight(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Confirming a candidate posts the importance select whether or not a date is
|
||||
// set. The weight write hung off `if due != nil`, so "срочно" with no deadline
|
||||
// was read off the form and thrown away, and the row came back normal.
|
||||
func TestPromoteCandidateCarriesWeightWithoutADueDate(t *testing.T) {
|
||||
core := &fakeTaskCore{}
|
||||
form := url.Values{
|
||||
"action": {"promote"}, "id": {"4"}, "text": {"продлить страховку"},
|
||||
"done_when": {"полис на руках"}, "weight": {"3"},
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/tasks", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
handleTasks(httptest.NewRecorder(), req, core)
|
||||
|
||||
if len(core.edits) != 1 {
|
||||
t.Fatalf("edits = %+v, want the weight written once", core.edits)
|
||||
}
|
||||
if core.edits[0].Weight != 3 || core.edits[0].ID != 4 {
|
||||
t.Errorf("edit = %+v, want id 4 at weight 3", core.edits[0])
|
||||
}
|
||||
if core.edits[0].Due != nil {
|
||||
t.Errorf("edit invented a due date: %v", core.edits[0].Due)
|
||||
}
|
||||
if core.statusVal != "open" {
|
||||
t.Errorf("status = %q, want the candidate promoted", core.statusVal)
|
||||
}
|
||||
}
|
||||
|
||||
// A promote with neither field set still writes nothing: the row is unchanged
|
||||
// apart from its status, and an EditTask here would be a no-op that can fail.
|
||||
func TestPromoteCandidateWithNoDateAndNoWeightDoesNotEdit(t *testing.T) {
|
||||
core := &fakeTaskCore{}
|
||||
form := url.Values{
|
||||
"action": {"promote"}, "id": {"4"}, "text": {"продлить страховку"},
|
||||
"done_when": {"полис на руках"}, "weight": {"0"},
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/tasks", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
handleTasks(httptest.NewRecorder(), req, core)
|
||||
if len(core.edits) != 0 {
|
||||
t.Errorf("edits = %+v, want none", core.edits)
|
||||
}
|
||||
}
|
||||
|
||||
// Out of range clamps rather than 400s; a non-number is a real client error.
|
||||
func TestApplyTaskPostClampsWeight(t *testing.T) {
|
||||
core := &fakeTaskCore{created: true}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
_ "embed"
|
||||
"html/template"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/tool"
|
||||
"github.com/kami/maven/internal/webauthn"
|
||||
)
|
||||
|
||||
//go:embed tools.html
|
||||
var toolsHTML string
|
||||
|
||||
// toolsTmpl — the enable surface. Server-rendered, no JS: a plain HTML form
|
||||
// POSTs back to /tools to enable a proposal. html/template escapes tool names +
|
||||
// utterances (they came from voice STT — untrusted text).
|
||||
var toolsTmpl = parsePage("tools", toolsHTML, template.FuncMap{
|
||||
"join": strings.Join,
|
||||
"capability": func(t ipc.Tool) string { return tool.CapabilityOf(t).String() },
|
||||
"risk": func(t ipc.Tool) string { return string(tool.RiskOf(t)) },
|
||||
})
|
||||
|
||||
// handleTools serves the enable surface (GET) and applies an enable (POST).
|
||||
// POST fields: name, cmd (space-separated argv), destructive (checkbox). cmd is
|
||||
// whitespace-split — argv with embedded spaces isn't supported (ponytail: no
|
||||
// shell-word parsing; the box owner controls this input, quote a wrapper script
|
||||
// if an arg needs spaces).
|
||||
func handleTools(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, session *webauthn.PasskeySession, requireStepUp bool) {
|
||||
if !requireCore(w, core, "tools") {
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
var msg string
|
||||
if r.Method == http.MethodPost {
|
||||
if !stepUpGate(w, session, requireStepUp) {
|
||||
return
|
||||
}
|
||||
action := r.FormValue("action")
|
||||
name := strings.TrimSpace(r.FormValue("name"))
|
||||
switch action {
|
||||
case "enable":
|
||||
scope := r.FormValue("scope")
|
||||
cmd := strings.Fields(r.FormValue("cmd"))
|
||||
destructive := r.FormValue("destructive") != ""
|
||||
if name == "" || len(cmd) == 0 {
|
||||
http.Error(w, "name and cmd required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := core.EnableTool(ctx, name, cmd, destructive, scope, time.Now()); err != nil {
|
||||
log.Printf("tools: enable %q: %v", name, err)
|
||||
http.Error(w, "enable failed: "+err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
msg = "enabled " + name
|
||||
case "disable":
|
||||
if name == "" {
|
||||
http.Error(w, "name required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := core.DisableTool(ctx, name); err != nil {
|
||||
log.Printf("tools: disable %q: %v", name, err)
|
||||
http.Error(w, "disable failed: "+err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
msg = "disabled " + name
|
||||
case "dismiss":
|
||||
if name == "" {
|
||||
http.Error(w, "name required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := core.DeleteTool(ctx, name); err != nil {
|
||||
log.Printf("tools: dismiss %q: %v", name, err)
|
||||
http.Error(w, "dismiss failed: "+err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
msg = "dismissed " + name
|
||||
default:
|
||||
http.Error(w, "unknown action", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
proposed, err1 := core.ListTools(ctx, "proposed")
|
||||
enabled, err2 := core.ListTools(ctx, "enabled")
|
||||
if err := cmp.Or(err1, err2); err != nil {
|
||||
log.Printf("tools: %v", err)
|
||||
http.Error(w, "core read failed", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
// MCP is off by default and an older core may not know the method at all,
|
||||
// so a failure here renders an empty section rather than breaking the page.
|
||||
servers, err := core.MCPServers(ctx)
|
||||
if err != nil {
|
||||
log.Printf("tools: mcp servers: %v", err)
|
||||
servers = nil
|
||||
}
|
||||
// Enabled rows are shown grouped by capability domain (Vikunja #452). A
|
||||
// flat list stops answering "what can she do to the house" somewhere
|
||||
// around fifteen rows, and that is the question this page exists for.
|
||||
renderPage(w, toolsTmpl, struct {
|
||||
Msg string
|
||||
Proposed []ipc.Tool
|
||||
Enabled []ipc.Tool
|
||||
Groups []tool.CapabilityGroup
|
||||
MCP []ipc.MCPServerStatus
|
||||
}{msg, proposed, enabled, tool.GroupByDomain(enabled), servers})
|
||||
}
|
||||
+22
-2
@@ -1,9 +1,9 @@
|
||||
{{template "shellTop" "trace"}}
|
||||
<h1>Rule Trace</h1>
|
||||
<div class="hint mb-4">{{.Now | ago}} — winner: <strong>{{if .Winner}}{{.Winner}}{{else}}nothing fired{{end}}</strong></div>
|
||||
<div class="hint mb-4">{{.Tick.Now | ago}} — winner: <strong>{{if .Tick.Winner}}{{.Tick.Winner}}{{else}}nothing fired{{end}}</strong></div>
|
||||
<div class=scroll><table class=mono>
|
||||
<tr><th>rule<th>sev<th>predicate<th>gate<th>blocked by<th>detail<th>selected<th>lost to</tr>
|
||||
{{range .Rules}}<tr>
|
||||
{{range .Tick.Rules}}<tr>
|
||||
<td>{{.RuleName}}</td>
|
||||
<td>{{.Severity}}</td>
|
||||
<td class={{if .PredicateResult}}green{{else}}gray{{end}}>{{.PredicateResult}}</td>
|
||||
@@ -21,5 +21,25 @@
|
||||
<td>{{.LostTo}}</td>
|
||||
</tr>{{end}}
|
||||
</table></div>
|
||||
|
||||
<h1 class=mt-4>Turn Decisions</h1>
|
||||
<div class="hint mb-4">Who claimed each utterance, who lost it, and who was never asked. In memory, newest first, cleared on restart.</div>
|
||||
{{if not .Turns}}<div class=hint>no turn has run since the daemon started</div>{{end}}
|
||||
{{range .Turns}}
|
||||
<details class=mb-4>
|
||||
<summary><span class=mono>{{.Utterance}}</span> — <strong>{{if .Winner}}{{.Winner}}{{else}}nobody{{end}}</strong> <span class=hint>{{.Ts | ago}}</span></summary>
|
||||
<div class=scroll><table class=mono>
|
||||
<tr><th>stage<th>claimant<th>would have been<th>score<th>outcome<th>why</tr>
|
||||
{{range .Claims}}<tr>
|
||||
<td>{{.Stage}}</td>
|
||||
<td>{{.Claimant}}</td>
|
||||
<td>{{if .Intent}}{{.Intent}}{{else}}—{{end}}</td>
|
||||
<td>{{if .HasScore}}{{printf "%.3f" .Score}}{{else}}—{{end}}</td>
|
||||
<td class={{if eq .Outcome "won"}}green{{else if eq .Outcome "never_asked"}}red{{else}}gray{{end}}>{{.Outcome}}</td>
|
||||
<td>{{.Reason}}</td>
|
||||
</tr>{{end}}
|
||||
</table></div>
|
||||
</details>
|
||||
{{end}}
|
||||
{{template "shellBottom"}}
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
"github.com/kami/maven/internal/audio"
|
||||
"github.com/kami/maven/internal/voice"
|
||||
"github.com/kami/maven/internal/webauthn"
|
||||
)
|
||||
|
||||
// The two proxies onto mavend's voice port: GET /ws streams turns over a
|
||||
// websocket, POST /api/ptt does one turn over plain HTTP. Both carry the same
|
||||
// step-up gate, because speaking an act is not a smaller act than typing one
|
||||
// (Vikunja #317). The length-prefixed framing they share is at the bottom.
|
||||
|
||||
// maxFrame caps a single voice frame in either direction.
|
||||
const maxFrame = 64 << 20
|
||||
|
||||
// pushToTalk builds the one request either proxy sends. Surface is
|
||||
// SurfacePCClient for both: the browser is standing in for the PC client.
|
||||
func pushToTalk(pcm []byte) voice.Request {
|
||||
return voice.Request{
|
||||
ID: uint64(time.Now().UnixNano()),
|
||||
Method: voice.MethodPushToTalk,
|
||||
Params: mustMarshal(voice.PushToTalkReq{
|
||||
Audio: audio.Audio{Format: audio.PCM16kMono, Bytes: pcm},
|
||||
Lang: "mixed",
|
||||
Surface: voice.SurfacePCClient,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
func handleWS(w http.ResponseWriter, r *http.Request, voiceAddr string, session *webauthn.PasskeySession, requireStepUp bool) {
|
||||
if !stepUpGate(w, session, requireStepUp) {
|
||||
return
|
||||
}
|
||||
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
|
||||
OriginPatterns: []string{"*"},
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("ws accept: %v", err)
|
||||
return
|
||||
}
|
||||
defer conn.Close(websocket.StatusNormalClosure, "bye")
|
||||
|
||||
ctx := r.Context()
|
||||
|
||||
var d net.Dialer
|
||||
tc, err := d.DialContext(ctx, "tcp", voiceAddr)
|
||||
if err != nil {
|
||||
log.Printf("dial voice: %v", err)
|
||||
writeWSErr(conn, ctx, "voice unavailable")
|
||||
return
|
||||
}
|
||||
defer tc.Close()
|
||||
|
||||
for {
|
||||
_, msg, err := conn.Read(ctx)
|
||||
if err != nil {
|
||||
log.Printf("ws read: %v", err)
|
||||
return
|
||||
}
|
||||
if len(msg) < 4 {
|
||||
log.Printf("ws msg too short (%d bytes)", len(msg))
|
||||
continue
|
||||
}
|
||||
|
||||
log.Printf("ws got %d bytes from client", len(msg))
|
||||
req := pushToTalk(msg)
|
||||
if err := writeFrame(tc, &req); err != nil {
|
||||
log.Printf("write voice req: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Read frames until we get the matching Response (handling any interleaved Pushes)
|
||||
for {
|
||||
resp, push, err := readOneFrame(tc)
|
||||
if err != nil {
|
||||
log.Printf("read voice: %v", err)
|
||||
return
|
||||
}
|
||||
if push != nil {
|
||||
data, _ := json.Marshal(push)
|
||||
conn.Write(ctx, websocket.MessageText, data)
|
||||
continue
|
||||
}
|
||||
if resp.Error != nil {
|
||||
writeWSErr(conn, ctx, resp.Error.Message)
|
||||
break
|
||||
}
|
||||
var pttResp voice.PushToTalkResp
|
||||
if err := json.Unmarshal(resp.Result, &pttResp); err != nil {
|
||||
log.Printf("unmarshal resp: %v", err)
|
||||
break
|
||||
}
|
||||
if pttResp.ReplyText != "" {
|
||||
conn.Write(ctx, websocket.MessageText, []byte(pttResp.ReplyText))
|
||||
}
|
||||
if len(pttResp.ReplyAudio.Bytes) > 0 {
|
||||
conn.Write(ctx, websocket.MessageBinary, pttResp.ReplyAudio.Bytes)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func handlePTT(w http.ResponseWriter, r *http.Request, voiceAddr string, session *webauthn.PasskeySession, requireStepUp bool) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "POST only", 405)
|
||||
return
|
||||
}
|
||||
if !stepUpGate(w, session, requireStepUp) {
|
||||
return
|
||||
}
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 400)
|
||||
return
|
||||
}
|
||||
if len(body) < 4 {
|
||||
http.Error(w, "too short", 400)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("ptt got %d bytes from client", len(body))
|
||||
|
||||
var d net.Dialer
|
||||
tc, err := d.DialContext(r.Context(), "tcp", voiceAddr)
|
||||
if err != nil {
|
||||
log.Printf("ptt dial voice: %v", err)
|
||||
http.Error(w, "voice unavailable", 503)
|
||||
return
|
||||
}
|
||||
defer tc.Close()
|
||||
|
||||
req := pushToTalk(body)
|
||||
if err := writeFrame(tc, &req); err != nil {
|
||||
log.Printf("ptt write: %v", err)
|
||||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
}
|
||||
|
||||
for {
|
||||
resp, push, err := readOneFrame(tc)
|
||||
if err != nil {
|
||||
log.Printf("ptt read: %v", err)
|
||||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
}
|
||||
if push != nil {
|
||||
continue
|
||||
}
|
||||
if resp.Error != nil {
|
||||
http.Error(w, resp.Error.Message, 500)
|
||||
return
|
||||
}
|
||||
var pttResp voice.PushToTalkResp
|
||||
if err := json.Unmarshal(resp.Result, &pttResp); err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "audio/l16;rate=16000;channels=1")
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
func writeWSErr(conn *websocket.Conn, ctx context.Context, msg string) {
|
||||
conn.Write(ctx, websocket.MessageText, []byte(`{"error":"`+msg+`"}`))
|
||||
}
|
||||
|
||||
func writeFrame(w io.Writer, v any) error {
|
||||
body, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal: %w", err)
|
||||
}
|
||||
if len(body) > maxFrame {
|
||||
return fmt.Errorf("frame too large: %d", len(body))
|
||||
}
|
||||
var hdr [4]byte
|
||||
binary.BigEndian.PutUint32(hdr[:], uint32(len(body)))
|
||||
if _, err := w.Write(hdr[:]); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = w.Write(body)
|
||||
return err
|
||||
}
|
||||
|
||||
func readFrame(r io.Reader, v any) error {
|
||||
var hdr [4]byte
|
||||
if _, err := io.ReadFull(r, hdr[:]); err != nil {
|
||||
return err
|
||||
}
|
||||
n := binary.BigEndian.Uint32(hdr[:])
|
||||
if n > maxFrame {
|
||||
return fmt.Errorf("frame too large: %d", n)
|
||||
}
|
||||
buf := make([]byte, n)
|
||||
if _, err := io.ReadFull(r, buf); err != nil {
|
||||
return err
|
||||
}
|
||||
return json.Unmarshal(buf, v)
|
||||
}
|
||||
|
||||
func readOneFrame(r io.Reader) (*voice.Response, *voice.Push, error) {
|
||||
var raw struct {
|
||||
ID uint64 `json:"id"`
|
||||
Result json.RawMessage `json:"r,omitempty"`
|
||||
Error *voice.RpcError `json:"e,omitempty"`
|
||||
Kind voice.PushKind `json:"kind,omitempty"`
|
||||
Params json.RawMessage `json:"p,omitempty"`
|
||||
}
|
||||
if err := readFrame(r, &raw); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if raw.Kind != "" && raw.ID == 0 {
|
||||
return nil, &voice.Push{Kind: raw.Kind, Params: raw.Params}, nil
|
||||
}
|
||||
return &voice.Response{ID: raw.ID, Result: raw.Result, Error: raw.Error}, nil, nil
|
||||
}
|
||||
|
||||
func mustMarshal(v any) json.RawMessage {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
+10
-61
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -76,69 +77,17 @@ func newPasskeyHandle(cfg webauthn.Config, core ipc.CoreAPI, storePath string, s
|
||||
// on: assert here (bumps the daemon session to L3 for the assertion TTL), then
|
||||
// enable a tool on /tools within that window.
|
||||
func (h *PasskeyHandle) Page(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
passkeyTmpl.Execute(w, nil)
|
||||
renderPage(w, passkeyTmpl, nil)
|
||||
}
|
||||
|
||||
// passkeyPageHTML — rendered via passkeyTmpl (main.go) which wraps with shellTop/shellBottom.
|
||||
const passkeyPageHTML = `{{template "shellTop" "passkey"}}
|
||||
<h1>Passkey</h1>
|
||||
<p class=hint>Enroll a passkey once, then assert it to unlock destructive actions (tool enable) for a few minutes.</p>
|
||||
<div class=flex gap-2>
|
||||
<button class=btn onclick=enroll()>enroll passkey</button>
|
||||
<button class=btn onclick=assert()>assert (step-up)</button>
|
||||
<button class=btn onclick=rewrapKey()>rewrite cold-start key</button>
|
||||
<a href=/tools><button class=btn-primary>→ tools</button></a>
|
||||
</div>
|
||||
<p class=hint>Rewriting the cold-start key points it at the passkey you assert next. Every other enrolled passkey stops being able to unlock a cold-booted daemon.</p>
|
||||
<div id=msg></div>
|
||||
{{template "shellBottom"}}
|
||||
<script>
|
||||
const b64u=b=>btoa(String.fromCharCode(...new Uint8Array(b))).replace(/\+/g,'-').replace(/\//g,'_').replace(/=+$/,'');
|
||||
const ub64=s=>{s=s.replace(/-/g,'+').replace(/_/g,'/');const b=atob(s),a=new Uint8Array(b.length);for(let i=0;i<b.length;i++)a[i]=b.charCodeAt(i);return a;};
|
||||
const say=(t,ok)=>{const m=document.getElementById('msg');m.textContent=t;m.className=ok?'msg msg-ok':'msg msg-err';};
|
||||
async function enroll(){try{
|
||||
const {challenge,options}=await (await fetch('/auth/webauthn/register/begin')).json();
|
||||
options.challenge=ub64(options.challenge);
|
||||
options.user.id=ub64(options.user.id);
|
||||
const c=await navigator.credentials.create({publicKey:options});
|
||||
const r=await fetch('/auth/webauthn/register/finish',{method:'POST',headers:{'content-type':'application/json'},
|
||||
body:JSON.stringify({challenge,credential:{id:c.id,type:c.type,response:{
|
||||
clientDataJSON:b64u(c.response.clientDataJSON),attestationObject:b64u(c.response.attestationObject)}}})});
|
||||
if(!r.ok){say('enroll failed: '+await r.text(),false);return;}
|
||||
// The wrapped key can only be written from an assertion: PRF results are
|
||||
// not produced at create() time on most authenticators. Enrolment reports
|
||||
// whether PRF is available at all so he is not told cold-start works when
|
||||
// it cannot.
|
||||
const ext=c.getClientExtensionResults?c.getClientExtensionResults():{};
|
||||
const prfOK=!!(ext.prf&&ext.prf.enabled);
|
||||
say(prfOK?'enrolled ✓ — now assert once to write the cold-start key':
|
||||
'enrolled ✓ — but this authenticator has no PRF: cold-start unlock unavailable',true);
|
||||
}catch(e){say('enroll error: '+e,false);}}
|
||||
async function assert(explicit){try{
|
||||
const {challenge,options}=await (await fetch('/auth/webauthn/assert/begin')).json();
|
||||
options.challenge=ub64(options.challenge);
|
||||
const c=await navigator.credentials.get({publicKey:options});
|
||||
// The PRF result is the cold-start secret. It never touches localStorage
|
||||
// and is posted once, over the same request as the assertion.
|
||||
const ext=c.getClientExtensionResults?c.getClientExtensionResults():{};
|
||||
const prf=ext.prf&&ext.prf.results&&ext.prf.results.first?b64u(ext.prf.results.first):'';
|
||||
const r=await fetch('/auth/webauthn/assert/finish',{method:'POST',headers:{'content-type':'application/json'},
|
||||
body:JSON.stringify({challenge,prf,explicit:!!explicit,credential:{id:c.id,type:c.type,response:{
|
||||
clientDataJSON:b64u(c.response.clientDataJSON),authenticatorData:b64u(c.response.authenticatorData),
|
||||
signature:b64u(c.response.signature)}}})});
|
||||
if(!r.ok){say('assert failed: '+await r.text(),false);return;}
|
||||
if(!prf){say('stepped up ✓ — no PRF from this authenticator, so cold-start unlock stayed unavailable',true);return;}
|
||||
say(explicit?'stepped up ✓ — cold-start key now points at this passkey':
|
||||
'stepped up ✓ — enable tools now',true);
|
||||
}catch(e){say('assert error: '+e,false);}}
|
||||
// Rewriting the wrapped key is a separate gesture, never a side effect of a
|
||||
// step-up. Only this button sets explicit, and only explicit lets the daemon
|
||||
// replace a blob that already exists.
|
||||
async function rewrapKey(){
|
||||
if(!confirm('Rewrite the cold-start key under the passkey you are about to assert? Every other enrolled passkey stops being able to unlock a cold-booted daemon.'))return;
|
||||
await assert(true);}
|
||||
</script>`
|
||||
// passkeyPageHTML — the enrolment page's own markup, wrapped by passkeyTmpl
|
||||
// with shellTop/shellBottom. It was a Go string constant, which is the one
|
||||
// place page markup still lived in Go.
|
||||
//
|
||||
//go:embed passkey.html
|
||||
var passkeyPageHTML string
|
||||
|
||||
var passkeyTmpl = parsePage("passkey", passkeyPageHTML, nil)
|
||||
|
||||
func (h *PasskeyHandle) RegisterBegin(w http.ResponseWriter, r *http.Request) {
|
||||
opts, challenge, err := h.rp.CreationOptions([]byte("maven-user"), "maven user")
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
# Nine claimants interrupting a parked reminder
|
||||
|
||||
Measured 2026-08-06 on homesrv against master at `70b32af`, so after V-560, V-565, V-567
|
||||
and V-572. Probed through `POST /api/chat` on mavweb, three turns per scenario, an explicit
|
||||
`отмена` between scenarios so each starts from an empty pending slot.
|
||||
|
||||
The question this answers: the Rome failure was one claimant eating one question. How many
|
||||
other claimants do the same, and does routing first fix all of them or only weather?
|
||||
|
||||
## Time forms, no pending state
|
||||
|
||||
V-572 holds for every spoken shape, not just the one it was measured on.
|
||||
|
||||
| said | answered |
|
||||
|---|---|
|
||||
| `напомни в 11:00 позвонить маме` | `хорошо, напомню сегодня в 11:00.` |
|
||||
| `напомни в 9 позвонить маме` | `хорошо, напомню сегодня в 09:00.` |
|
||||
| `напомни в 9 утра позвонить маме` | `хорошо, напомню сегодня в 09:00.` |
|
||||
| `напомни завтра в 9 купить молоко` | `хорошо, напомню завтра в 09:00.` |
|
||||
| `напомни в девять позвонить маме` | `хорошо, напомню сегодня в 09:00.` |
|
||||
| `напомни через час выпить воды` | `хорошо, напомню сегодня в 02:26.` |
|
||||
|
||||
The spelled-out `девять` works, which is the lexicon's oblique cardinals doing their job.
|
||||
|
||||
## The nine scenarios
|
||||
|
||||
Each opens with a reminder that names no time, so a clarify is parked. Then a different
|
||||
claimant speaks.
|
||||
|
||||
| | interrupted by | claiming source | flow | verdict |
|
||||
|---|---|---|---|---|
|
||||
| A | `какая сейчас погода в Риме?` | weather | dropped, announced | question answered |
|
||||
| B | `что я говорил про отпуск?` | personal | dropped, announced | question answered |
|
||||
| C | `какая столица Франции?` | search | dropped, announced | question answered |
|
||||
| D | `у меня новый ноутбук` | none | kept | **note silently discarded** |
|
||||
| E | `что у меня сегодня?` | none | completed wrongly | **reminder invented at the clock** |
|
||||
| F | `что требует внимания?` | none | dropped, announced | question answered |
|
||||
| G | `напомни завтра в 9 купить молоко` | none | dropped, announced | new reminder set |
|
||||
| H | reminder said whole, then weather, then a repair | weather | none parked | **repair invents a note** |
|
||||
| I | `сколько времени?` | none | dropped, announced | question answered |
|
||||
|
||||
Six of nine answer the interrupting utterance. That is what V-560 bought, and it is the
|
||||
half of Rome that was broken.
|
||||
|
||||
## The three that fail
|
||||
|
||||
**E is the worst of the nine.**
|
||||
|
||||
```text
|
||||
me: напомни забрать посылку
|
||||
maven: Когда?
|
||||
me: что у меня сегодня?
|
||||
maven: хорошо, напомню сегодня в 01:28.
|
||||
```
|
||||
|
||||
The agenda question is never answered and a reminder exists that nobody asked for, at the
|
||||
current minute. Asked with nothing parked, the same sentence is answered by the calendar.
|
||||
So stage 0 routes it correctly and the pending claimant runs in front of that route. This
|
||||
is worse than the pre-V-560 Rome behaviour, which at least failed audibly. Filed as V-577.
|
||||
|
||||
**D drops a note without saying so.**
|
||||
|
||||
```text
|
||||
me: напомни позвонить врачу
|
||||
maven: Когда?
|
||||
me: у меня новый ноутбук
|
||||
maven: Во сколько напомнить?
|
||||
```
|
||||
|
||||
Nothing wrong is written, and nothing right is either. Alone the sentence is stored. Also
|
||||
V-577.
|
||||
|
||||
**H writes a note nobody dictated.**
|
||||
|
||||
```text
|
||||
нет, не маме, а папе -> Сохранила заметку о том, что ты поедешь на дачу.
|
||||
```
|
||||
|
||||
Reproduced with no pending state at all, twice, with two different inventions. The stored
|
||||
body is generated rather than captured. Filed as V-576. It is the only defect here that outlives
|
||||
the turn. A wrong note is indexed and comes back later as recall.
|
||||
|
||||
## What the drop notice looks like now
|
||||
|
||||
Six scenarios answer the question and say `Прошлую просьбу отпускаю.` first. That sentence
|
||||
is rejected by the owner. V-561 replaces it with suspend and resume for a side query, and
|
||||
keeps it for a new request (G) and a cancel.
|
||||
|
||||
## Reading of the set
|
||||
|
||||
The pattern behind Rome, V-567 and V-577 is one pattern. The claimant that knows least
|
||||
about the utterance holds the earliest and strongest trigger. V-560 moved the routing
|
||||
in front of one resolver. The other roles still decide before the route is read.
|
||||
@@ -0,0 +1,247 @@
|
||||
# The fact parser: closed classes against the substring stems they replaced
|
||||
|
||||
Measured 2026-08-06 at 22edc3c and its parent 0445693, on the corpus in
|
||||
`internal/router/factparser_corpus_test.go`. Dated file: it is not edited after
|
||||
today, and a newer number is a new file.
|
||||
|
||||
V-586 rewrote `DefaultFactParser` off hand-written Russian stems onto six closed
|
||||
classes in `internal/lexicon`. Its commit message reported 64/91 on the RU
|
||||
routing fixture, unchanged. That number does not bear on the change: the fixture
|
||||
holds three fact cases and all three miss on intent, so the parser is never
|
||||
reached. This file measures the parser directly, and runs the LLM arm the
|
||||
original commit skipped.
|
||||
|
||||
**The rewrite is better on the utterances it was designed for and no worse on
|
||||
the ones it was not.** True positives go 35/40 to 39/40, misfires rejected go
|
||||
8/15 to 14/15. What neither version has is coverage: of 36 plausible utterances
|
||||
whose word is in no lexicon set, the substring parser caught 3 by accident and
|
||||
the closed-class parser catches 0. That is the honest headline. The word list
|
||||
did not shrink the vocabulary — it never had one — it made the boundary visible.
|
||||
|
||||
## The corpus
|
||||
|
||||
91 cases, three classes. **True positives** are sentences the owner would say,
|
||||
with the key that must be written. **Misfires** are sentences the substring
|
||||
parser wrote a fact for and should not have, including the three hard negatives
|
||||
the rewrite was argued on. **False negatives** are sentences a reasonable person
|
||||
would say whose word is in no set at all; `want` is the key a human would
|
||||
assign, and the ship parser is expected to miss them. They are the measurement
|
||||
of what a closed class costs, not a bug list.
|
||||
|
||||
The old parser is carried in the test file as `legacyFactParse`, copied verbatim
|
||||
from 0445693, so the comparison reruns:
|
||||
|
||||
```sh
|
||||
deps/go/go/bin/go test -run TestFactParserCorpus -v ./internal/router/
|
||||
```
|
||||
|
||||
## Score
|
||||
|
||||
| | true positives | misfires rejected | false-negative cases recovered |
|
||||
|---|---|---|---|
|
||||
| old (substring stems, 0445693) | 35/40 | 8/15 | 3/36 |
|
||||
| **new (closed classes, 22edc3c)** | **39/40** | **14/15** | **0/36** |
|
||||
|
||||
Sixteen cases disagree. Eleven of them the rewrite wins, three it loses, and two
|
||||
are cases neither gets.
|
||||
|
||||
**Won.** Every misfire the commit message named — `душа болит`, `в комнате
|
||||
душно`, `это была беда`, `наша победа`, `на душе легко` — plus `водитель пилота
|
||||
ждёт`, where two stems in one sentence made the old water arm fire. And five true
|
||||
positives the stems simply did not list: `перекусил`, `передохнул`, `отдыхаю`,
|
||||
`пойду спать`, `i showered`. Morphology buys those; a stem list would need a new
|
||||
entry for each.
|
||||
|
||||
**Lost.** `допил воду` is a real regression and the only failing true positive.
|
||||
The vendored dictionary lemmatises `допил` to `допилить`, to finish sawing —
|
||||
exactly the collision `drink_verbs` already carries `пил` and `пили` as surface
|
||||
forms to dodge, left unhandled for the prefixed form. `допить` is in the set and
|
||||
the sentence still misses. It is flagged `broken` in the corpus rather than
|
||||
fixed, because this branch measures.
|
||||
|
||||
`был в душе` and `после душа полегчало` are the price of matching the shower set
|
||||
exactly. The dictionary makes `душ` and `душа` one word, so a lemma test cannot
|
||||
tell a shower from a soul; exact matching keeps `на душе легко` out and loses
|
||||
the oblique cases of the real noun with it. The old parser got both by accident,
|
||||
along with the soul. That trade is right — writing a shower fact when he said
|
||||
his soul feels light is worse than missing one — but it is a trade and the two
|
||||
rows are what it costs.
|
||||
|
||||
`недоспал` is the third loss and the least defensible: the old substring `спал`
|
||||
caught it, and `недоспать` is in no set.
|
||||
|
||||
**Neither.** `обеденный перерыв отменили` — a cancelled lunch break — is a fact
|
||||
for both parsers, `meal` for the old one off the adjective and `break` for the
|
||||
new one off `перерыв`. Nothing in either design reads the cancellation.
|
||||
`дрых до обеда` is scored `meal` by both, because the meal arm runs first and
|
||||
`обеда` is in it, which is not wrong so much as beside the point.
|
||||
|
||||
## The false-negative surface
|
||||
|
||||
This is the half the routing fixture cannot see and the half that decides
|
||||
whether the design holds. 36 cases, 0 recovered:
|
||||
|
||||
- **water** — `выпил чаю`, `глотнул воды`, `хлебнул воды`, `выпил стакан`,
|
||||
`i hydrated`, `finished my bottle of water`. The water arm needs a noun AND a
|
||||
verb, so an elided noun or an unlisted verb drops the whole capture.
|
||||
- **meal** — `ем суп`, `съел бутерброд`, `наелся`, `пожрал`, `полдник был`,
|
||||
`snack`, `supper`, `brunch`, `i eat now`. `есть` is deliberately absent for
|
||||
`есть новости по бэкапу`, and `ем`, its most ordinary spoken form, goes with it.
|
||||
- **shower** — `помылся`, `сходил в ванную`, `искупался`, `i am showering`,
|
||||
plus the two oblique cases above.
|
||||
- **break** — `сделал передышку`, `перекур`, `полежал немного`, `сделал паузу`,
|
||||
`i took five`, `resting now`.
|
||||
- **sleep** — `вздремнул`, `прикорнул`, `дрых`, `недоспал`, `лёг в двенадцать`,
|
||||
`сон был короткий`, `i napped`, `took a nap`.
|
||||
|
||||
None of these are exotic. They are the second and third word a person reaches
|
||||
for, and every one of them is a fact the owner stated and Maven silently did not
|
||||
record. A silent miss is the worst failure mode this parser has: he said it, she
|
||||
heard it, nothing was written, and nothing told him.
|
||||
|
||||
## The routing fixture, LLM arm
|
||||
|
||||
The arm 22edc3c skipped. `MAVEN_LLM_URL` points the harness at any llama-server;
|
||||
the previous run reported none reachable, which was the shell's `HTTP_PROXY` and
|
||||
not the network. Run against **gemma-4-12B-it-qat-UD-Q4_K_XL on the workstation
|
||||
at `192.168.1.105:8080`**, the same box as the 02-08 measurement, with
|
||||
`NO_PROXY=192.168.1.105`:
|
||||
|
||||
```sh
|
||||
NO_PROXY=192.168.1.105 no_proxy=192.168.1.105 \
|
||||
make eval-models MAVEN_LLM_URL=http://192.168.1.105:8080
|
||||
```
|
||||
|
||||
| | full | intent-only | p50 |
|
||||
|---|---|---|---|
|
||||
| llm-only, 0445693 | 51.6% (47/91) | 82.4% | — |
|
||||
| llm-only, 22edc3c | 52.7% (48/91) | 83.5% | 341ms |
|
||||
| cascade+llm, 0445693 | 85.7% (78/91) | 93.4% | — |
|
||||
| **cascade+llm, 22edc3c** | **86.8% (79/91)** | **94.5%** | 334ms |
|
||||
|
||||
One case either way, both directions, and the failing set is identical between
|
||||
the two commits. That is run-to-run variance on a sampling model, not a signal.
|
||||
The parser change is invisible to the routing fixture on the LLM arm for the
|
||||
same reason it is invisible on the classifier arm: the three fact cases miss on
|
||||
intent and the parser is never called. Do not read these rows as evidence about
|
||||
the parser. They are evidence that the fixture cannot answer the question, which
|
||||
is why the corpus above exists.
|
||||
|
||||
## Verdict
|
||||
|
||||
The closed-class rewrite holds up as a rewrite. It is strictly better than what
|
||||
it replaced on both classes anyone argued about, and the one regression
|
||||
(`допил`) and one bad trade (the oblique `душ`) are both dictionary collisions
|
||||
rather than design faults.
|
||||
|
||||
It does not hold up as an answer. A closed class is the right mechanism for a
|
||||
set that is actually closed — interrogatives, weekdays, cardinals — and
|
||||
"the words a person uses to say he ate" is not that set. The corpus puts a
|
||||
number on it: 36 ordinary sentences, 0 recovered, and every new one costs a
|
||||
lexicon edit by whoever notices. The three mechanisms CLAUDE.md names do not
|
||||
contain the right one for this job. The embedder-topic mechanism is the closest
|
||||
fit and is wrong too, because this is slot extraction rather than aboutness.
|
||||
|
||||
This is a case for the V-546 slot-tagging head. Self-care facts are a bounded
|
||||
key space (five keys) over unbounded surface forms, which is exactly what a BIO
|
||||
tagger on e5-small is for: it generalises to `вздремнул` without anyone adding
|
||||
`вздремнуть` to a list, and max softmax gives the confidence the parser's
|
||||
hardcoded `true` does not have. Until it lands, the closed classes are the
|
||||
correct floor and the 36 rows above are the size of the gap they leave.
|
||||
|
||||
## The corpus, case by case
|
||||
|
||||
| utterance | class | want | old (substring) | new (closed class) |
|
||||
|---|---|---|---|---|
|
||||
| `выпил стакан воды` | tp | water | water | water |
|
||||
| `попил воды` | tp | water | water | water |
|
||||
| `я попил водички` | tp | water | water | water |
|
||||
| `пью воду` | tp | water | water | water |
|
||||
| `воду пил уже` | tp | water | water | water |
|
||||
| `допил воду` | tp | water | water | — **≠** |
|
||||
| `запил таблетку водой` | tp | water | water | water |
|
||||
| `drank water` | tp | water | water | water |
|
||||
| `i drank some water` | tp | water | water | water |
|
||||
| `поужинал` | tp | meal | meal | meal |
|
||||
| `я пообедал` | tp | meal | meal | meal |
|
||||
| `позавтракал кашей` | tp | meal | meal | meal |
|
||||
| `перекусил бутербродом` | tp | meal | — | meal **≠** |
|
||||
| `покушал` | tp | meal | meal | meal |
|
||||
| `поел супа` | tp | meal | meal | meal |
|
||||
| `обед был в час` | tp | meal | meal | meal |
|
||||
| `ужинать буду позже` | tp | meal | meal | meal |
|
||||
| `i ate` | tp | meal | meal | meal |
|
||||
| `had lunch` | tp | meal | meal | meal |
|
||||
| `dinner done` | tp | meal | meal | meal |
|
||||
| `принял душ` | tp | shower | shower | shower |
|
||||
| `сходил в душ` | tp | shower | shower | shower |
|
||||
| `душ принят` | tp | shower | shower | shower |
|
||||
| `ополоснулся душем` | tp | shower | shower | shower |
|
||||
| `took a shower` | tp | shower | shower | shower |
|
||||
| `i showered` | tp | shower | — | shower **≠** |
|
||||
| `сделал перерыв` | tp | break | break | break |
|
||||
| `отдохнул полчаса` | tp | break | break | break |
|
||||
| `передохнул немного` | tp | break | — | break **≠** |
|
||||
| `отдыхаю` | tp | break | — | break **≠** |
|
||||
| `был перерыв на обед` | tp | meal | meal | meal |
|
||||
| `took a break` | tp | break | break | break |
|
||||
| `спал восемь часов` | tp | sleep | sleep | sleep |
|
||||
| `спала плохо` | tp | sleep | sleep | sleep |
|
||||
| `поспал днём` | tp | sleep | sleep | sleep |
|
||||
| `выспался наконец` | tp | sleep | sleep | sleep |
|
||||
| `проспал будильник` | tp | sleep | sleep | sleep |
|
||||
| `пойду спать` | tp | sleep | — | sleep **≠** |
|
||||
| `slept 8 hours` | tp | sleep | sleep | sleep |
|
||||
| `i slept badly` | tp | sleep | sleep | sleep |
|
||||
| `пилот сказал что вылет через час` | misfire | — | — | — |
|
||||
| `водитель уже подъехал` | misfire | — | — | — |
|
||||
| `надо заводить машину` | misfire | — | — | — |
|
||||
| `душа болит` | misfire | — | shower | — **≠** |
|
||||
| `в комнате душно` | misfire | — | shower | — **≠** |
|
||||
| `это была беда` | misfire | — | meal | — **≠** |
|
||||
| `наша победа` | misfire | — | meal | — **≠** |
|
||||
| `пила лежит в гараже` | misfire | — | — | — |
|
||||
| `водитель пилота ждёт` | misfire | — | water | — **≠** |
|
||||
| `обеденный перерыв отменили` | misfire | — | meal | break **≠** |
|
||||
| `есть новости по бэкапу базы` | misfire | — | — | — |
|
||||
| `напоминания на завтра есть` | misfire | — | — | — |
|
||||
| `на душе легко` | misfire | — | shower | — **≠** |
|
||||
| `пилил доску весь вечер` | misfire | — | — | — |
|
||||
| `поставь будильник на завтра` | misfire | — | — | — |
|
||||
| `выпил чаю` | fn | water | — | — |
|
||||
| `глотнул воды` | fn | water | — | — |
|
||||
| `хлебнул воды` | fn | water | — | — |
|
||||
| `воды хлебнул из бутылки` | fn | water | — | — |
|
||||
| `выпил стакан` | fn | water | — | — |
|
||||
| `i hydrated` | fn | water | — | — |
|
||||
| `finished my bottle of water` | fn | water | — | — |
|
||||
| `ем суп` | fn | meal | — | — |
|
||||
| `съел бутерброд` | fn | meal | — | — |
|
||||
| `наелся` | fn | meal | — | — |
|
||||
| `пожрал` | fn | meal | — | — |
|
||||
| `полдник был` | fn | meal | — | — |
|
||||
| `i had a snack` | fn | meal | — | — |
|
||||
| `having supper` | fn | meal | — | — |
|
||||
| `brunch was good` | fn | meal | — | — |
|
||||
| `i eat now` | fn | meal | — | — |
|
||||
| `был в душе` | fn | shower | shower | — **≠** |
|
||||
| `после душа полегчало` | fn | shower | shower | — **≠** |
|
||||
| `помылся` | fn | shower | — | — |
|
||||
| `сходил в ванную` | fn | shower | — | — |
|
||||
| `искупался` | fn | shower | — | — |
|
||||
| `i am showering` | fn | shower | — | — |
|
||||
| `сделал передышку` | fn | break | — | — |
|
||||
| `перекур` | fn | break | — | — |
|
||||
| `полежал немного` | fn | break | — | — |
|
||||
| `сделал паузу` | fn | break | — | — |
|
||||
| `i took five` | fn | break | — | — |
|
||||
| `resting now` | fn | break | — | — |
|
||||
| `вздремнул` | fn | sleep | — | — |
|
||||
| `прикорнул на диване` | fn | sleep | — | — |
|
||||
| `дрых до обеда` | fn | sleep | meal | meal |
|
||||
| `недоспал` | fn | sleep | sleep | — **≠** |
|
||||
| `лёг в двенадцать` | fn | sleep | — | — |
|
||||
| `сон был короткий` | fn | sleep | — | — |
|
||||
| `i napped` | fn | sleep | — | — |
|
||||
| `took a nap` | fn | sleep | — | — |
|
||||
`≠` marks a disagreement. `—` is no fact written.
|
||||
@@ -0,0 +1,94 @@
|
||||
# The reminder completeness rule, measured on the box
|
||||
|
||||
Measured 2026-08-06 at 02:34 local, on homesrv, master `59cc882`, immediately after V-577
|
||||
and V-579 merged as #231. Probe is `POST /api/chat` through mavweb, which is the same
|
||||
`handleText` seam the mic and telegram use.
|
||||
|
||||
## The rule being measured
|
||||
|
||||
The owner ruled it the same night. A reminder commits only when three things are answered:
|
||||
what, what time, and what day. Anything missing is asked for. Every time question opens by
|
||||
stating the clock.
|
||||
|
||||
He gave two examples and confirmed two derived cases when asked.
|
||||
|
||||
## Result: the rule holds on all four
|
||||
|
||||
| said | what | time | day | reply |
|
||||
|---|---|---|---|---|
|
||||
| `напомни завтра в 3 заказать цветы` | yes | no part of day | yes | `Сейчас 02:34. Это утра или вечера?` |
|
||||
| `напомни в 9 вечера разгрузить стиралку` | yes | yes | no | `Сейчас 02:34. В какой день?` |
|
||||
| `напомни завтра в 15:00 позвонить врачу` | yes | yes | yes | `хорошо, напомню завтра в 15:00.` |
|
||||
| `напомни через час выключить духовку` | yes | yes | yes | `хорошо, напомню сегодня в 03:34.` |
|
||||
|
||||
Row two is the one worth noting. `в 9 вечера` is unambiguous as a clock time and is still
|
||||
asked about, because the day is missing. Today being a valid reading is not enough.
|
||||
|
||||
## The invented clock is gone
|
||||
|
||||
Last night the worst failure was silent. A reminder was written at the current minute and
|
||||
nothing said so.
|
||||
|
||||
```text
|
||||
напомни забрать посылку -> Сейчас 02:34. Когда?
|
||||
что у меня сегодня? -> на 06.08.2026 ничего нет, на какое время поставить напоминание? [s=calendar]
|
||||
в 15 -> Сейчас 02:34. В какой день?
|
||||
```
|
||||
|
||||
Measured last night, the second turn answered nothing and wrote a reminder at the clock.
|
||||
The question now reaches the calendar and the parked question resumes on the same reply.
|
||||
|
||||
The root cause was one step earlier than filed. `ownContent("что у меня сегодня?")` returns
|
||||
empty, because every token is frame. `что` is an interrogative, `у` a frame word, `меня`
|
||||
filler, `сегодня` a day offset. So `needsRoute` said no and no route was computed at all.
|
||||
The turn fell through to answering the parked question. The extractor read `сегодня`, and
|
||||
the date parser answered a bare day word with that day at the current minute.
|
||||
|
||||
So this was not a claimant beating the route. It was a claimant running where the route was
|
||||
never asked for.
|
||||
|
||||
## Two defects the probe found
|
||||
|
||||
**V-592, invented content.** `я выпил воды` came back as
|
||||
`Проверила, что ты выпел стакан воды`. `выпел` is not a Russian word and `стакан` was never
|
||||
said. The arbitration around it is correct: the aside is taken, the parked question resumes,
|
||||
nothing is dropped. Only the content is wrong. Whether the store agrees with the sentence is
|
||||
not yet known. That answer decides whether this is a phrasing defect or a capture defect.
|
||||
|
||||
**V-593, an unacknowledged partial answer.** `на 9` then `на завтра` produces the same ask
|
||||
twice, byte-identical.
|
||||
|
||||
```text
|
||||
а, да, прости - на 9 -> Сейчас 02:34. Это утра или вечера?
|
||||
на завтра -> Сейчас 02:34. Это утра или вечера?
|
||||
```
|
||||
|
||||
The behaviour is right, since the part of day is missing both times. The wording gives him
|
||||
no evidence he was heard, which is the failure mode the V-558 family exists to remove.
|
||||
|
||||
## The pinned acceptance transcript is now superseded
|
||||
|
||||
The transcript the owner wrote has her guess a reading and accept `на завтра` as the last
|
||||
turn:
|
||||
|
||||
```text
|
||||
me: а, да, прости - на 9.
|
||||
maven: сейчас 15:23 - на 9 сегодня вечером?
|
||||
me: на завтра.
|
||||
maven: напомню.
|
||||
```
|
||||
|
||||
Under the rule he later ruled, `на 9` plus `на завтра` still names no part of day. So she
|
||||
must ask a third time rather than commit. The transcript and the rule disagree, and the rule
|
||||
is the newer statement. Offering a reading, as that transcript does, remains allowed and is
|
||||
not required. The implementation asks `Это утра или вечера?` and offers none.
|
||||
|
||||
## Regression checked
|
||||
|
||||
A new request still drops the old one out loud. That is the behaviour the owner kept when he
|
||||
rejected the silent drop for side queries.
|
||||
|
||||
```text
|
||||
напомни позвонить маме -> Сейчас 02:34. Когда?
|
||||
напомни завтра в 9 купить молоко -> Прошлую просьбу отпускаю. Сейчас 02:34. Это утра или вечера?
|
||||
```
|
||||
@@ -47,7 +47,13 @@ for.
|
||||
|
||||
QA session 1 step 2 should say what it actually covers, which is push-to-talk through
|
||||
`/dash`. It should not read as though it covers the voice loop. The wake path is checked
|
||||
on the client machine or it is not checked, and today there is no client machine.
|
||||
on the client machine or it is not checked.
|
||||
|
||||
**Correction, 2026-08-05.** This plan said there was no client machine. There is: workpc,
|
||||
where he sits most of the day and where the microphone is. The sentence was written when
|
||||
the workstation was only a model host. The verdict above is unchanged, and so is
|
||||
everything about the seam. What changes is the size of the remaining work: deploying two
|
||||
daemons and asking mavend to listen on TCP, not acquiring hardware.
|
||||
|
||||
That is the honest state, and it is worse than the task suggests: this is not a
|
||||
configuration gap that a compose entry closes. Until a machine with a microphone runs
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
# Plan: dialogue arbitration, one channel and many claimants
|
||||
|
||||
Umbrella V-558. This file collects the design for its children.
|
||||
|
||||
Last verified: 06-08-2026 @ b6305f1
|
||||
|
||||
## A common unit for claims on an utterance (V-565)
|
||||
|
||||
**Verdict: four ordinal bands, and the band is the tie-break rather than the decision.
|
||||
Coverage decides first.** The measurement below says no claimant Maven has today can produce
|
||||
a graded confidence. A float would be an invention either way. What is available is the KIND
|
||||
of evidence a claimant holds, and there are exactly four kinds.
|
||||
|
||||
### What the claimants report today
|
||||
|
||||
Measured 06-08-2026 on the 91-case RU fixture (`internal/router/eval`), through the deployed
|
||||
cascade with the quantized multilingual-e5-small embedder. The harness is
|
||||
`TestONNXClaimConfidenceDistribution` and `TestStage0Contention` in
|
||||
`internal/router/eval/claims_test.go`. Correct means the right intent, or a refusal where the
|
||||
fixture wants one. Slots are excluded, because a slot miss is a parser question and would
|
||||
blur what the number is being asked to predict.
|
||||
|
||||
| Claimant | Values it can emit | Distribution on the fixture | Correct |
|
||||
|---|---|---|---|
|
||||
| Stage 0 grammars, 21 of them | `1.0`, always | claimed 20 of 91 cases | 20/20 (100%) |
|
||||
| Classifier, cosine | continuous in principle | observed range 0.859 to 0.942 over 71 cases | 44/71 (62%) |
|
||||
| LLM router | `1.0` or `0.3`, nothing between | not run here, no llama-server | see below |
|
||||
| Query sources, 22 of them | a bool | not routed by the fixture | n/a |
|
||||
| Stateful four | nothing at all | n/a | n/a |
|
||||
|
||||
Four findings, and each one constrains the band set.
|
||||
|
||||
**The classifier's cosine carries no signal about correctness.** It scores 62% below the
|
||||
median and 62% above it. That is 13/21 in 0.8 to 0.9, and 31/50 in 0.9 to 1.0. The spread is
|
||||
0.083 wide. Every case sits above the 0.55 threshold, so the gate never fires here. A number
|
||||
flat against correctness, which never crosses its own gate, is not a confidence.
|
||||
|
||||
**Nor does the margin between its top two intents.** Top1 minus top2 is min 0.000, p50
|
||||
0.009, max 0.025. Sixty-eight of the 71 classified cases sit under 0.02 and score 60%. Three
|
||||
clear 0.02 and score 3/3, which is a sample of three. So the ledger's question is answered:
|
||||
a calibrated float is NOT cheaply available from the classifier alone. Nearest-centroid over
|
||||
frozen seeds ranks intents, and the ranking is decided in the third decimal place. It can say
|
||||
which intent is nearest. It cannot say how near.
|
||||
|
||||
**Stage 0 asserts 1.0 by fiat, and on this fixture the fiat is right.** Twenty of twenty.
|
||||
That is not evidence that a hand-written anchored pattern is always right. It is evidence
|
||||
that anchored and nearest are different kinds of claim, and must not share a scale. The gap
|
||||
is 100% against 62% on the same 91 utterances.
|
||||
|
||||
**Stage 0 contention is rarer than the list order suggests.** Exactly one case of 91 draws
|
||||
two grammars. That is `ru-query-019`, where `calendar-query` and `agenda-query` both match,
|
||||
and `calendar-query` wins because it is earlier in `buildRouter`. Both would route
|
||||
`IntentQuery`, so the ordering costs nothing there. The finding is not that ordering is
|
||||
harmless. It is that the fixture barely exercises what V-558 is about. Part of what a claim
|
||||
object buys is making the contention countable.
|
||||
|
||||
**The LLM router emits two values, and one of them is not a confidence.** `llmFullConfidence`
|
||||
is 1.0 and `llmThinConfidence` is 0.3. `gateLLMDecision` moves a decision to 0.3 through
|
||||
three named arms. A fact with no key, an act with no allowlisted fn, a reminder with no
|
||||
subject. Each is a self-veto with a reason, flattened into a number that then loses the
|
||||
reason. Both values are meaningful only against `config.DefaultRouterThreshold`. 0.3 is below
|
||||
0.55 and 1.0 is above it, and nothing anywhere reads any other property of either.
|
||||
|
||||
### The band set
|
||||
|
||||
Four bands, ordinal, highest first. They name the kind of evidence, because that is the one
|
||||
thing every claimant can report without inventing it.
|
||||
|
||||
**`BandAnchored`.** A literal pattern anchored in the utterance matched, and the matched span
|
||||
is what decides the intent. Stage 0 grammars and query-source matchers. The claimant is
|
||||
certain about the shape of the sentence. That is not the same as being certain about the
|
||||
answer. Measured 20/20.
|
||||
|
||||
**`BandStructural`.** A claimant read the whole sentence and produced a complete route. Every
|
||||
slot the intent requires is filled. The LLM router at `llmFullConfidence` sits here, and so
|
||||
does a stateful claimant holding a pending question. Not anchored, because nothing in the
|
||||
utterance is pointed at.
|
||||
|
||||
**`BandNearest`.** The claim rests only on resemblance to something else. No anchor in the
|
||||
utterance, no structural check behind it. The classifier. One band rather than a graded
|
||||
scale, and the measurement is the argument. 62% at both ends of the cosine range, and a
|
||||
top-two margin that never reaches 0.03.
|
||||
|
||||
**`BandVetoed`.** The claimant will take the turn only if nobody else will, and says why it
|
||||
should not. The three arms of `gateLLMDecision` land here with their reason preserved. A
|
||||
vetoed claim is still a claim. Maven asking "о чём напомнить?" beats silence.
|
||||
|
||||
There is no fifth band, and that is a measurement result rather than a preference. No
|
||||
claimant in the cascade today can report what a fifth band would carry. V-546 lands a softmax
|
||||
head whose max probability is a calibrated number. That one gets read as a number, not
|
||||
squeezed into these four.
|
||||
|
||||
### Coverage decides before the band does
|
||||
|
||||
The band is the tie-break. The first question is how much of the utterance a claim explains,
|
||||
and that is `Consumed` against `Unexplained` on the claim object. Two reasons.
|
||||
|
||||
It is the fix for the failure that opened V-558. "какая сейчас погода в Риме?" arrived while
|
||||
a reminder was pending. The pending claimant ate the whole utterance as a time answer while
|
||||
explaining none of it. Not "погода", not "Риме", not the question mark. A weather claim
|
||||
explains all of it. Coverage-first arbitration prefers the weather claim without knowing that
|
||||
a pending reminder is less trustworthy than a grammar. The pending question then survives to
|
||||
be asked again.
|
||||
|
||||
It also keeps the stateful four out of the top slot without special-casing them. They sit at
|
||||
`BandStructural`, below any anchored claim. That is the whole V-558 complaint about the
|
||||
highest-priority claimants being the least informed, expressed as one rule.
|
||||
|
||||
### The claim object
|
||||
|
||||
```go
|
||||
type Claim struct {
|
||||
Claimant string // who wants the turn
|
||||
Intent string // plain string: internal/dialogue must not import internal/router
|
||||
Filled []string // the slots this claim would fill
|
||||
Consumed []string // utterance tokens this claim explains
|
||||
Unexplained []string // the rest, in order
|
||||
Band Band
|
||||
Veto string // why this claim should NOT win, empty when there is none
|
||||
}
|
||||
```
|
||||
|
||||
`Intent` is a plain `string` rather than `router.Intent` on purpose. `internal/dialogue` must
|
||||
not import `internal/router`, so the claim package must not either, and a shared string costs
|
||||
one conversion at each edge.
|
||||
|
||||
`Unexplained` is carried rather than derived at read time. A claimant can then decline to
|
||||
explain a span it did match.
|
||||
|
||||
### What this task does not do
|
||||
|
||||
`router.Decision.Confidence` stays and keeps its float. `r.threshold` and `gateLLMDecision`
|
||||
read it, and the classifier is the failure floor. A rewire that broke either would trade a
|
||||
measured floor for an unmeasured design. V-565 lands the type and the builder beside the
|
||||
existing path. The arbiter that reads claims is V-560.
|
||||
@@ -0,0 +1,411 @@
|
||||
# Two artifacts, and neither one is Spring
|
||||
|
||||
Proposal. V-585. Related umbrella V-558, and the design collected in
|
||||
`docs/plans/19-dialogue-arbitration.md`.
|
||||
|
||||
## Verdict
|
||||
|
||||
**Thesis one holds for four seams and fails for one.** Four are one shape: the routing
|
||||
cascade, the query source chain, the pre-route resolver ladder, the digestion tick. Reach
|
||||
selection is not. It maps severity and presence to a set of channels. It has no claimants and
|
||||
no losers.
|
||||
|
||||
**Thesis two holds.** An arbitration kernel is a package and a convention inside one program.
|
||||
It is not a framework. A framework whose only client is the codebase it came from is that
|
||||
codebase with more ceremony.
|
||||
|
||||
**The answer is two artifacts of different sizes.** One package inside Maven, built from
|
||||
`internal/claim` and `internal/decision`. Both already exist and neither is wired. One small
|
||||
library across the four services, holding the correlation id, the headers, the timeout policy
|
||||
and the named gap. Neither is Spring. The daemons must not get a third.
|
||||
|
||||
**The strongest finding is the duplication, not the shape.** Three structural holes make a
|
||||
route untrustworthy. They are written out by hand in three files, for three consumers, with
|
||||
three return types. `gateLLMDecision` flattens them to a float. `vetoOf` re-derives them as a
|
||||
sentence. `thinReason` re-derives them again as a trace string. That is what having no common
|
||||
unit costs, and it is countable in lines rather than in taste.
|
||||
|
||||
## The five seams, tested
|
||||
|
||||
### 1. The routing cascade. The shape, ordered by hand.
|
||||
|
||||
The claimant is `router.Grammar` (`internal/router/stage0.go:19`). It holds a name, a regex
|
||||
and a `Build` that may still decline. A claim is a regex match plus `ok` from `Build`.
|
||||
|
||||
Ordering is the append order in `buildRouter` (`cmd/mavend/voicewire.go:384`). Twelve appends.
|
||||
Each one carries a comment arguing its position against its neighbours. First match wins at
|
||||
confidence 1.0.
|
||||
|
||||
Below stage 0 the two engines are alternatives, not rivals. The classifier runs only when the
|
||||
router is nil or errored. Inside the classifier the order is cosine score, and the top three
|
||||
are recorded.
|
||||
|
||||
Losers are recorded and change nothing. `noteGrammarOutcomes`
|
||||
(`internal/router/decisiontrace.go:56`) separates a grammar that did not match from one whose
|
||||
`Build` declined. Everything past the winner is marked `NeverAsked`.
|
||||
|
||||
### 2. The query source chain. The shape, ordered by hand, with a boundary in it.
|
||||
|
||||
The claimant is `querySource` (`cmd/mavend/actions_query.go:45`), a name and one function
|
||||
returning `(string, bool)`. Twenty-four of them sit in one slice literal, walked in order. The
|
||||
comment on the slice says the order is load-bearing. It is right.
|
||||
|
||||
This seam carries something the others do not. The personal boundary at line 139 is a stop,
|
||||
not an answer. Everything above it reads the owner's data. Everything below reads the world. A
|
||||
question about him that reaches the boundary ends there.
|
||||
|
||||
### 3. The pre-route resolver ladder. The shape, and the one that hurts.
|
||||
|
||||
Seven rungs, each returning `(reply string, handled bool)`. Ordering is the order of the `if`
|
||||
statements in `runTurn` (`cmd/mavend/voice.go:258`). The roster in `preRouteLadder`
|
||||
(`cmd/mavend/decisiontrace.go:33`) is kept by hand, and its own comment admits nothing
|
||||
enforces the correspondence.
|
||||
|
||||
The recurring bug lives here. A rung claims before the utterance is routed. So the claimant
|
||||
with the earliest and strongest trigger is the one that knows least about what was said. V-560
|
||||
fixed half of it. It computes the route once, before the ladder, and lets the clarify resolver
|
||||
read it. The other rungs still decide without reading it.
|
||||
|
||||
### 4. The digestion tick. The shape, and the only one already done right.
|
||||
|
||||
This corrects the brief. `loop.Tick` (`internal/loop/loop.go:85`) is not a first-to-claim
|
||||
walk. It is an arbitration with a declared comparator.
|
||||
|
||||
- The claimant is `loop.Rule`. Its `Predicate` says whether it wants the turn.
|
||||
- The gate is separate from the claim. `Gate` (`loop.go:21`) checks snooze, cooldown, quiet
|
||||
hours, calendar busy, presence and missing data. `ExplainGate` names which one blocked.
|
||||
- The comparator is data, not position. Max severity wins, and ties break on name.
|
||||
`DefaultRules` states outright that slice order is not load-bearing.
|
||||
- Losers are recorded with what they lost to. `ExplainTick` (`internal/loop/explain.go:86`)
|
||||
fills `LostTo`, and rewrites the previous best when a higher severity displaces it.
|
||||
- Losers get a second life. `DigestEligible` (`loop.go:129`) decides which suppressed
|
||||
candidates are bundled for later. It refuses cooldown and snooze, because neither is
|
||||
restraint.
|
||||
|
||||
Every property the kernel wants already exists here, on five rules. The kernel argues that the
|
||||
other three seams should look like this one. It does not need a new idea.
|
||||
|
||||
### 5. Reach selection. Not the shape.
|
||||
|
||||
`ChannelsFor` (`internal/delivery/channel.go:73`) takes severity and presence and returns a
|
||||
slice of channels. Nothing claims. Nothing passes. Nothing loses. Every channel in the
|
||||
returned slice sends, so there is not even one winner.
|
||||
|
||||
Naming the sinks claimants would be the forced abstraction. It would also hide the property
|
||||
this table has and the ladders lack. It is total, it is pure, and every cell is covered by
|
||||
`TestChannelsForEveryTableCell`.
|
||||
|
||||
One thing in the dispatcher does re-decide. `ErrVoiceNoSession` means the presence guess was
|
||||
wrong, so the remaining channel list is replaced with the away table
|
||||
(`internal/delivery/dispatcher.go:188`). That is a retry on new evidence, not a contest. Leave
|
||||
it alone.
|
||||
|
||||
### What else has the shape
|
||||
|
||||
`fillMatchedSlots` (`internal/router/router.go:212`) arbitrates per slot. A matched value
|
||||
always wins, and the extractor fills only what was left empty. That is the coverage-first rule
|
||||
the kernel proposes, written once for four slots.
|
||||
|
||||
`bestRecall` and the topic veto pick between a fact and a note by score with a margin. That
|
||||
one is a real score comparison, and it should stay one.
|
||||
|
||||
## The abstractions
|
||||
|
||||
Three, not four. The straw man had `Claimant`, `Claim`, `Arbiter` and `Record`. Drop
|
||||
`Claimant`.
|
||||
|
||||
**`claim.Claim`, evidence rather than a verdict.** It exists at `internal/claim/claim.go`,
|
||||
built and tested, imported by one function that nothing calls. It carries `Consumed` and
|
||||
`Unexplained` for coverage, an ordinal `Band`, and a `Veto` string that keeps the reason a
|
||||
float threw away. Coverage is compared before band. That is the fix for the Rome failure,
|
||||
where a parked reminder ate the whole utterance while explaining none of it.
|
||||
|
||||
**`decision.Record`, the trace.** It exists at `internal/decision/decision.go` and it is wired
|
||||
everywhere. It separates won, declined, lost on score, thinned, merged and never asked. The
|
||||
last one is the valuable one. A claimant that never looked reads identically to one that looked and
|
||||
passed. That is what hardcoded order hides.
|
||||
|
||||
**`Arbiter`, the thing that does not exist.** One function. It takes a set of claims and a
|
||||
comparator, returns a winner, and notes the rest. `loop.Tick` is that function, specialised to
|
||||
rules. Generalising it is the proposal.
|
||||
|
||||
**Against a `Claimant` interface.** Every seam already rejected one, for the same reason.
|
||||
`querySource` is a struct of one function because the sources are methods on one handler with
|
||||
no state. An interface would mean one empty type per source. `confirmResolver` is the same
|
||||
shape, and `loop.Rule` is a struct with a closure. An interface would buy a shared name and
|
||||
cost twenty-four empty types. The claimants stay what they are. Each seam builds `claim.Claim`
|
||||
values at its own edge, which is what `router.ClaimOf` already does.
|
||||
|
||||
## What ordering becomes
|
||||
|
||||
Ordering becomes a comparator plus a rank, and the rank is data.
|
||||
|
||||
Today ordering is position in a slice, and position is invisible in the record. Add a rung to
|
||||
`runTurn`, forget `preRouteLadder`, and the rung vanishes from the trace. The roster's own
|
||||
comment admits nothing enforces it.
|
||||
|
||||
The proposal is smaller than a dependency graph. A claimant declares a rank. The arbiter sorts
|
||||
by coverage, then band, then rank. Rank breaks the tie that evidence cannot break.
|
||||
|
||||
**A dependency graph is the wrong tool.** The real constraints are pairwise and local. Day
|
||||
plan before calendar. Praxis before the capture marker. Narrative last. A graph turns those
|
||||
into edges and then needs a topological sort whose output nobody can read. The twelve comments
|
||||
in `buildRouter` would become twelve edges with the arguments deleted. Keep the arguments.
|
||||
|
||||
**Two claimants at the same rank must be an error, caught at wiring time.** Not at turn time.
|
||||
The registry is built once at boot, so a duplicate rank is a boot failure naming both
|
||||
claimants. Falling back to slice order on a tie would restore the invisible ordering the
|
||||
kernel exists to remove.
|
||||
|
||||
**The roster stops being hand-kept.** A claimant registered with the arbiter is on the roster
|
||||
by construction. That deletes the `preRouteLadder` failure mode outright.
|
||||
|
||||
## What it buys
|
||||
|
||||
**It deletes three copies of one test.** The three structural holes appear in
|
||||
`gateLLMDecision` (`internal/router/router.go:271`), in `vetoOf`
|
||||
(`internal/router/claim.go:101`), and in `thinReason` (`internal/router/decisiontrace.go:28`).
|
||||
Three files, three return types, one rule. A fourth consumer would write it a fourth time.
|
||||
With a claim carrying `Veto`, the rule is written once. The float, the sentence and the trace
|
||||
string all derive from it. This one is worth the work on its own.
|
||||
|
||||
**It makes the recurring bug expressible.** Rome, V-567 and V-577 are one defect. The claimant
|
||||
that knows least holds the earliest trigger. Coverage-first arbitration states the fix once,
|
||||
in `MoreSpecificThan`. A parked clarify explaining zero tokens of "какая сейчас погода в
|
||||
Риме?" loses to a weather claim explaining all of them. Nobody has to encode that a parked
|
||||
clarify is less trustworthy than a grammar.
|
||||
|
||||
**The limit.** The kernel prevents the class only where the losing claimant
|
||||
computes low coverage. Rome, V-567 and V-577 all qualify. Each is a stateful claimant
|
||||
swallowing an utterance it explains none of. A claimant that matches a substring does explain
|
||||
those tokens, and coverage does not catch it. V-567's substring match is that case from the
|
||||
other side. Coverage there has to be measured against the whole utterance rather than the
|
||||
matched span. `claimSpans` already does that, in the safe direction. So the kernel
|
||||
prevents most of the class and describes the rest. Claiming more would be dishonest.
|
||||
|
||||
**It makes contention countable.** Today it is not. The 91-case fixture draws two stage-0
|
||||
grammars exactly once, at `ru-query-019`, and both route the same intent. Nobody knows whether
|
||||
contention is rare or whether the fixture omits it. An arbiter that sees every claim can
|
||||
count.
|
||||
|
||||
**What it does not buy.** No accuracy point comes from this alone. Every number in
|
||||
`docs/evals/2026-08-05-routing-resident-model.md` is reachable without it. The kernel is a
|
||||
place to put the fix, not the fix.
|
||||
|
||||
## What it costs
|
||||
|
||||
Every seam rewritten is a chance to break a measured number.
|
||||
|
||||
Re-measure the 91-case routing fixture. The baseline is 75.8% full and 80.2% intent-only at
|
||||
p50 1.19s, in `docs/evals/2026-08-05-routing-resident-model.md`. Judge against the classifier
|
||||
and the resident model, because those are what always answer.
|
||||
|
||||
Re-measure Praxis reach. The baseline is 27/30 overall, 11/12 Praxis and 5/5 lifecycle, in
|
||||
`docs/evals/2026-08-05-praxis-reach.md`. The Praxis grammars are the only path to Praxis, so a
|
||||
reordering that demotes them costs every point.
|
||||
|
||||
Re-run the nine-scenario interleave probe in `docs/evals/2026-08-06-claimant-interleave.md`.
|
||||
Six of nine pass today. That probe measures exactly what this proposal is for.
|
||||
|
||||
Re-run the dialogue contract tests from V-563. They are whole multi-turn traces, and the only
|
||||
tests that cover the ladder as a ladder.
|
||||
|
||||
Latency is the cheap part. A stage-0 query costs 3.7µs. One claim per claimant adds two slices
|
||||
and a token split, on a path whose p50 is over a second.
|
||||
|
||||
The real cost is the arguments. Twenty-three comments across `buildRouter` and `querySources`
|
||||
explain why each entry sits where it does. A migration that turns them into rank integers and
|
||||
drops the prose destroys the only documentation the ordering has.
|
||||
|
||||
## Migration order
|
||||
|
||||
**Step one, no behaviour change. Delete the duplication.** Make `vetoOf` the single definition
|
||||
of the three structural holes. Have `gateLLMDecision` and `thinReason` read it. One rule,
|
||||
three consumers, no new abstraction. Re-measure the routing fixture and nothing else. This is
|
||||
worth landing whether or not the rest does.
|
||||
|
||||
**Step two, the proof. Arbitrate the pre-route ladder.** Smallest seam, seven rungs, and the
|
||||
one with the measured defect. Each resolver returns a claim instead of a bool. The arbiter
|
||||
compares coverage, then band, then rank. The roster comes from the registry. The proof is the
|
||||
interleave probe reaching nine of nine with the routing fixture unmoved.
|
||||
|
||||
**Step three, the query source chain.** Twenty-four sources, most of which already compute a
|
||||
match span. The personal boundary does not become a ranked claimant. It stays a hard stop, and
|
||||
the arbiter runs above it and below it separately. Re-measure Praxis reach and the search and
|
||||
Kiwix fallback.
|
||||
|
||||
**Step four, stage 0, or not at all.** Twelve grammar groups whose order encodes twelve
|
||||
arguments, scoring 20/20 on the fixture. Most to lose, least to gain. Defer it until steps two
|
||||
and three have sat in the deploy long enough to break something.
|
||||
|
||||
**`loop.Tick` moves last or never.** It already has the comparator, the gate with reasons, the
|
||||
loser trace and the loser rescue. Rewriting it to call a generic arbiter risks the digest path
|
||||
to gain a shared name.
|
||||
|
||||
## What must not be in the kernel
|
||||
|
||||
**Authorization.** This is the hard line. CLAUDE.md is explicit that LLM output is not
|
||||
authorization, and that a confirmation binds capability id, target entity, arguments,
|
||||
requester and expiry (`cmd/mavend/confirm.go`). A generic arbiter turns many opinions into
|
||||
one winner. That is the wrong shape for a binding. Make confirm a claimant with a rank and a band, and a claim that scored higher could take the
|
||||
turn from it. The binding would be softened into a comparison. Confirm may report to the record. It must not compete in the
|
||||
arbiter.
|
||||
|
||||
The same rule covers the Hexis path. Free text never reaches a mutating call, and resolution
|
||||
happens against Nexus. Neither is a contest, so neither is arbitration.
|
||||
|
||||
**The personal boundary.** Same reason, different currency. The boundary is not the most
|
||||
specific claimant. It is a stop. A boundary that can lose to a higher-coverage claim is not a
|
||||
boundary. The failure is the owner's notes reaching a search engine.
|
||||
|
||||
**Confidence as a float.** The band exists because the measurement said a calibrated float is
|
||||
not available. The classifier scores 62% correct below its median and 62% above, over a spread
|
||||
0.083 wide. Its top-two margin has a p50 of 0.009
|
||||
(`docs/plans/19-dialogue-arbitration.md`). A kernel with a `Score float64` on the claim invites
|
||||
every claimant to invent one. When V-546 lands a softmax head with a calibrated probability,
|
||||
that number is read beside the bands, not squeezed inside them.
|
||||
|
||||
**Slot extraction and validation.** `fillMatchedSlots` runs after a winner exists, and slot
|
||||
validation against the action schema is V-562. Both ask whether a claim is well formed. Neither
|
||||
asks which claim wins.
|
||||
|
||||
## The second artifact: the ecosystem client
|
||||
|
||||
The kernel is Maven's alone. The one thing here with plural clients by construction is the
|
||||
contract between the four services. Maven implements its side of it twice and a half.
|
||||
|
||||
### Is the contract uniform today? No.
|
||||
|
||||
Nexus and Praxis share one implementation. `ecosystemHTTP` (`cmd/mavend/ecosystem.go:62`) is
|
||||
embedded in both, so both get the same 10 second timeout, the same `setHeaders`, the same
|
||||
typed `ecosystemError`, and the same correlation key. Hexis is a separate client in another
|
||||
repository, vendored at `vendor/github.com/kami/hexis/pkg/client`, and it agrees on some of
|
||||
that and not the rest.
|
||||
|
||||
Eleven divergences. Four of them are defects rather than style, and each is filed on its own:
|
||||
V-587 the 401, V-588 the unnamed Praxis service, V-590 the uncorrelated discovery hop, V-591
|
||||
the unsent causation id. They are ranked in that order, worst first, and none of them waits on
|
||||
this proposal. V-587 is the only one that makes the owner check the wrong thing.
|
||||
|
||||
**A Hexis 401 is spoken as an outage.** Nexus and Praxis return `*ecosystemError` with
|
||||
`Unauthorized()`, `ContractMismatch()` and `Unreachable()` classifiers
|
||||
(`cmd/mavend/ecosystem.go:144`). Hexis returns `fmt.Errorf` strings
|
||||
(`hexis/pkg/client/client.go:157`). So `unauthorizedEcosystemError`
|
||||
(`cmd/mavend/ecosystem_acts.go:549`) does `errors.As` and always gets false for Hexis. The
|
||||
owner hears "Hexis is down" when the truth is that Hexis refused the credential. The comment
|
||||
at `ecosystem_acts.go:32` says that conflation must not happen.
|
||||
|
||||
**The Hexis discovery hop is uncorrelated.** Hexis carries its own context key
|
||||
(`client.go:81`), invisible to Maven's. The bridge is a manual second stamp at
|
||||
`ecosystem.go:546`. `discoverCapabilities` (`ecosystem.go:527`) does not do it, and discovery
|
||||
runs before execute, so that call goes out with no correlation id. The doc comment above it
|
||||
claims the opposite.
|
||||
|
||||
**Causation is computed and never sent.** `causationID` is derived at
|
||||
`ecosystem_acts.go:771`. Hexis supports `X-Causation-ID` (`client.go:147`). Nothing passes it.
|
||||
|
||||
**A Praxis failure names no service.** `ecosystemGap` (`ecosystem_acts.go:36`) is the shared
|
||||
named-gap helper, and Nexus and Hexis call it. Praxis returns per-verb Russian strings instead
|
||||
(`ecosystem_acts.go:62`, `:73`, `:84`, `:95`). There is no `servicePraxis` constant. A Praxis
|
||||
outage and a Praxis 403 both say "не получилось", with the service unnamed.
|
||||
|
||||
The rest are real but smaller. Hexis sends no `X-Requested-By: maven` and no `Accept` header,
|
||||
so Hexis cannot attribute a read call to Maven at all. Its timeout is 30 seconds against
|
||||
Maven's 10, which Maven cannot change from here. Its success predicate is `>= 400` where the
|
||||
shared client uses `!= 200`. `withToken` is duplicated verbatim per client because the
|
||||
embedded struct cannot return the concrete type. The version constant `"v1"` is defined twice,
|
||||
in two repositories, with nothing keeping the two equal.
|
||||
|
||||
### Would a shared library have plural clients?
|
||||
|
||||
**Partly verifiable, and the honest answer is that two of the three cannot be checked from
|
||||
here.**
|
||||
|
||||
Hexis is verified. It is a Go module consumed through a `replace` directive, and it already
|
||||
publishes a Go client library that Maven imports. That is an existence proof that the pattern
|
||||
works for one of them.
|
||||
|
||||
Praxis is suggested and not proven. Two comments reference Go paths in its repository
|
||||
(`cmd/mavend/factenrichment.go:4`, `cmd/mavend/ecosystem.go:365`). Nothing here compiles
|
||||
against it.
|
||||
|
||||
Nexus is unverifiable from this repository. There is an HTTP base URL and some JSON shapes,
|
||||
and no language signal at all.
|
||||
|
||||
So the claim that a shared library would have plural clients rests on one confirmed adopter
|
||||
and two assumptions. Do not present it as settled. The cheaper test is to fix Maven's side first,
|
||||
in Maven. Offer the package outward once it has proven itself on one caller.
|
||||
|
||||
### What belongs in it
|
||||
|
||||
The straw man is right, with one addition.
|
||||
|
||||
- The correlation id, minted once per action, with one context key that all clients read. The
|
||||
two-key split is the cause of the uncorrelated discovery hop.
|
||||
- Causation, since one of the three already supports it and the value is already computed.
|
||||
- The version header and `X-Requested-By`.
|
||||
- The timeout policy, as one number rather than 10 in one repository and 30 in another.
|
||||
- A typed error with the three classifiers, so a refused credential never speaks as an outage.
|
||||
- The named gap shape, so no client invents its own vocabulary the way Praxis did.
|
||||
|
||||
### What must stay out
|
||||
|
||||
**Authorization, for the second time and the same reason.** The confirmation binding is
|
||||
Maven's. It binds capability id, target entity, arguments, requester and expiry, and it lives
|
||||
in `cmd/mavend/confirm.go`. A shared client that offered a policy hook would invite each
|
||||
service to supply its own, and the binding would become configuration. Free text never reaches
|
||||
a mutating Hexis call, and entity resolution stays in Nexus. Neither belongs in a transport
|
||||
library.
|
||||
|
||||
Retry stays out too, or nearly. The only retry Maven has is Nexus enrichment at the worker
|
||||
layer, with backoff from one minute to one hour (`cmd/mavend/factenrichment.go:49`). It is
|
||||
there because enrichment is a background job with no listener. A turn cannot retry, because
|
||||
the owner is standing there. A transport-level retry in a shared library would hide a
|
||||
second budget behind a turn that already has a name for failing.
|
||||
|
||||
## The daemons must not get a framework
|
||||
|
||||
The instinct is right, and half the work is already done in a way that shows why.
|
||||
|
||||
Transport is shared and abstracted. `ipc.Dial` (`internal/ipc/client.go:94`) and `ipc.Listen`
|
||||
(`internal/ipc/server.go:202`) both go through `netaddr`. A bare path is a unix socket with
|
||||
`SO_PEERCRED` identity. A `tcp://host:port?token=...` address binds a network listener with a
|
||||
mandatory token. Callers pass a string and never branch on scheme. That seam made the
|
||||
workstation offload a deployment rather than a build. It is a library, it has nine clients in
|
||||
this repository, and it earns its weight.
|
||||
|
||||
Startup is not shared, and should not be. There are twelve binaries under `cmd/`, each with a
|
||||
hand-written `main`, and no common lifecycle package. `cmd/mavend/main.go` runs 830 lines. The
|
||||
reason is visible in it. The daemon can boot **locked**, with no store at all, and wire its
|
||||
components later from inside an unlock handler. Thirteen subsystem pointers are pre-declared
|
||||
nil (`main.go:236`) and filled on one of two paths. The whole graph is built a second time
|
||||
inside the unlock path (`main.go:478`). A container owning object lifecycle would have to model
|
||||
a graph whose nodes do not exist at boot and may never exist.
|
||||
|
||||
The deeper reason is a design property. Every daemon degrades alone, and every ecosystem
|
||||
client is nil unless configured. A wiring framework's job is to fail loudly when a dependency
|
||||
is missing. Maven needs the opposite: a missing dependency is a named gap in one answer and a
|
||||
working daemon everywhere else. An abstraction over startup would trade that property for a
|
||||
shorter `main`.
|
||||
|
||||
The duplicated locked and unlocked wiring in `mavend` is a real defect and worth fixing. Fix it
|
||||
by extracting one function in that file. That is not a framework.
|
||||
|
||||
## The honest comparison
|
||||
|
||||
Spring and Django own object lifecycle and request handling for applications they have never
|
||||
seen. That is where the weight is paid for. The abstraction is general because the clients are
|
||||
unknown.
|
||||
|
||||
Maven is one application, on one box, with one user. Its clients are known, there are nine of
|
||||
them, and they are in this repository. A framework here would be an abstraction with a census.
|
||||
|
||||
So the right unit is a shared package and a convention. That is a smaller and more defensible
|
||||
claim. The evidence for its size is that both halves already exist in that form, and nobody
|
||||
called them a framework. `internal/ipc` is a shared package and a convention, and it carried
|
||||
the daemons off the box. `internal/decision` is a shared package and a convention, and it made
|
||||
the losers readable in one release. `internal/claim` is the third, written and waiting for a
|
||||
caller.
|
||||
|
||||
The thing to disagree with: **the answer is two artifacts of different sizes. A package inside
|
||||
Maven, a library across the four services, and neither one is Spring.**
|
||||
+17
-8
@@ -1,6 +1,6 @@
|
||||
# QA plan: checking Maven properly
|
||||
|
||||
*Last verified: 2026-08-04 @ 8d816f4. Living doc: correct it in place, do not append.*
|
||||
*Last verified: 2026-08-05 @ 12667fd. 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.
|
||||
@@ -255,8 +255,13 @@ room — **463**, written up in `docs/plans/17-where-the-voice-loop-runs.md`.
|
||||
So the wake word and the VAD gate are covered by their unit tests and by
|
||||
nothing else, and no session at this box changes that. Checking them needs a
|
||||
machine with a microphone running both binaries against a TCP-listening mavend.
|
||||
`ipc.Dial` already speaks `tcp://host:port?token=...`, so the work is a machine
|
||||
and a config line, not protocol work. Until then, **287** can only be
|
||||
`ipc.Dial` already speaks `tcp://host:port?token=...`, so the work is not
|
||||
protocol work.
|
||||
|
||||
That machine is workpc (owner's correction, 05-08-2026). This section used to
|
||||
call it a machine Maven does not have. That was written when the workstation
|
||||
was only a model host. So the remaining work is deploying two daemons and
|
||||
asking mavend to listen on TCP. Until that is done, **287** can only be
|
||||
half-answered, and step 2 above is push-to-talk, not the voice loop.
|
||||
|
||||
**319's single-token bug is fixed** (01-08-2026). Single-word Russian utterances no longer come
|
||||
@@ -418,11 +423,15 @@ something only a ZIM answers, with the search block on. Live search leads and th
|
||||
ZIMs are the fallback since 02-08-2026. **286**'s remaining half is doc and
|
||||
git ingestion, which is build work, not a check.
|
||||
|
||||
**Do not read `/trace` for this.** `/trace` is the nudge-rule trace: rule,
|
||||
severity, predicate, gate, selected. No query-source field exists anywhere in the
|
||||
codebase. The only evidence of which query source claimed a turn is the
|
||||
`voice: search:` and `voice: kiwix:` lines in `docker compose logs mavend`
|
||||
(`actions_query.go:589` and `:660`).
|
||||
**Read `/trace` for this.** It carries two tables since 06-08-2026 (V-564). The
|
||||
nudge-rule trace it always had, and below it the **turn decisions**: one
|
||||
collapsible record per utterance. Each names every claimant, what it would have
|
||||
made the turn, the score it reported, and whether it won, declined, lost or was
|
||||
**never asked**. That last one answers "did Kiwix pass, or was it never
|
||||
reached". The log lines cannot tell you that. The ring holds the last 25 turns
|
||||
in daemon memory and is empty after a restart, so read it in the same sitting.
|
||||
`/chat` still shows the claiming source as a badge, and the `voice: query
|
||||
claimed by source` line is still in `docker compose logs mavend`.
|
||||
|
||||
Run 02-08-2026, 20 turns. **Search leads and the personal boundary holds.** Every
|
||||
world question that reached the boundary was claimed by search. All three
|
||||
|
||||
@@ -82,6 +82,38 @@ func TestWAVRoundTrip(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A LIST chunk sitting between fmt and data is common (arecord and ffmpeg both
|
||||
// write one), and its payload is free text that can spell "data". The parser
|
||||
// walks chunk headers, so the text is skipped and the real samples are read.
|
||||
func TestPCMFromWAVSkipsLISTChunk(t *testing.T) {
|
||||
t.Parallel()
|
||||
pcm := []byte{1, 0, 2, 0, 3, 0, 4, 0}
|
||||
list := []byte("LIST")
|
||||
payload := []byte("INFOICMTdata is not here")
|
||||
list = binary.LittleEndian.AppendUint32(list, uint32(len(payload)))
|
||||
list = append(list, payload...)
|
||||
|
||||
plain, err := WAVFromPCM(PCM16kMono, pcm)
|
||||
if err != nil {
|
||||
t.Fatalf("WAVFromPCM: %v", err)
|
||||
}
|
||||
wav := append([]byte{}, plain[:36]...)
|
||||
wav = append(wav, list...)
|
||||
wav = append(wav, plain[36:]...)
|
||||
binary.LittleEndian.PutUint32(wav[4:8], uint32(len(wav)-8))
|
||||
|
||||
f, got, err := PCMFromWAV(wav)
|
||||
if err != nil {
|
||||
t.Fatalf("PCMFromWAV: %v", err)
|
||||
}
|
||||
if !f.IsValid() {
|
||||
t.Fatalf("parsed format invalid: %+v", f)
|
||||
}
|
||||
if !bytes.Equal(got, pcm) {
|
||||
t.Fatalf("PCM mismatch: got %v, want %v", got, pcm)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPCMFromWAVRejectsNonCanonical(t *testing.T) {
|
||||
t.Parallel()
|
||||
// too short
|
||||
|
||||
+30
-12
@@ -36,6 +36,10 @@ const wavHeaderSize = 44
|
||||
// raw PCM samples (little-endian int16 as bytes). A non-canonical blob is
|
||||
// rejected with ErrNotCanonicalPCM; the format mismatch is logged at the seam
|
||||
// so the caller surfaces it, not a hidden silent downmix.
|
||||
//
|
||||
// The returned PCM aliases wav rather than copying it, because a recording is
|
||||
// large and the caller already owns the bytes. A caller that keeps the PCM past
|
||||
// the life of wav, or that reuses wav as a read buffer, must copy first.
|
||||
func PCMFromWAV(wav []byte) (Format, []byte, error) {
|
||||
if len(wav) < wavHeaderSize {
|
||||
return Format{}, nil, fmt.Errorf("audio: wav too short: %d bytes", len(wav))
|
||||
@@ -61,17 +65,13 @@ func PCMFromWAV(wav []byte) (Format, []byte, error) {
|
||||
return Format{}, nil, fmt.Errorf("%w: channels=%d bits=%d (want 1/16)", ErrNotCanonicalPCM, channels, bitsPerSample)
|
||||
}
|
||||
// data chunk: the spec mandates it appears right after fmt, but real
|
||||
// recorders sometimes append extra chunks (LIST, fact). Find the "data"
|
||||
// chunk by scanning; require it within the region we'd expect.
|
||||
dataIdx := -1
|
||||
for i := wavHeaderSize - 8; i+8 <= len(wav) && i < wavHeaderSize+4096; i++ {
|
||||
if string(wav[i:i+4]) == "data" {
|
||||
dataIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if dataIdx < 0 {
|
||||
return Format{}, nil, fmt.Errorf("%w: no data chunk", ErrNotCanonicalPCM)
|
||||
// recorders sometimes append extra chunks (LIST, fact). Walk the chunk
|
||||
// headers rather than scanning for the four bytes "data", because those
|
||||
// bytes occur inside a LIST/INFO payload as ordinary text and a byte scan
|
||||
// would take the middle of a comment for a chunk header.
|
||||
dataIdx, err := findDataChunk(wav)
|
||||
if err != nil {
|
||||
return Format{}, nil, err
|
||||
}
|
||||
dataSize := binary.LittleEndian.Uint32(wav[dataIdx+4 : dataIdx+8])
|
||||
body := wav[dataIdx+8:]
|
||||
@@ -90,6 +90,24 @@ func PCMFromWAV(wav []byte) (Format, []byte, error) {
|
||||
return f, body, nil
|
||||
}
|
||||
|
||||
// findDataChunk returns the offset of the "data" chunk header, walking the
|
||||
// chunk list that starts after the 16-byte fmt chunk. Chunks are word-aligned,
|
||||
// so an odd size carries one pad byte the next header sits behind.
|
||||
func findDataChunk(wav []byte) (int, error) {
|
||||
for pos := wavHeaderSize - 8; pos+8 <= len(wav); {
|
||||
size := int(binary.LittleEndian.Uint32(wav[pos+4 : pos+8]))
|
||||
if string(wav[pos:pos+4]) == "data" {
|
||||
return pos, nil
|
||||
}
|
||||
next := pos + 8 + size + size%2
|
||||
if next <= pos || next > len(wav) {
|
||||
break
|
||||
}
|
||||
pos = next
|
||||
}
|
||||
return 0, fmt.Errorf("%w: no data chunk", ErrNotCanonicalPCM)
|
||||
}
|
||||
|
||||
// WAVFromPCM wraps raw 16-bit mono PCM bytes in a canonical 44-byte WAV
|
||||
// header so the result can be written to disk and played with `aplay`.
|
||||
// Used by the reference client to write the TTS reply; not on the wire.
|
||||
@@ -115,7 +133,7 @@ const WAVHeaderSize = wavHeaderSize
|
||||
// avoiding.
|
||||
func WAVHeader(format Format, n int) ([]byte, error) {
|
||||
if !format.IsValid() {
|
||||
return nil, fmt.Errorf("audio: WAVFromPCM: %w: %+v", ErrNotCanonicalPCM, format)
|
||||
return nil, fmt.Errorf("audio: WAVHeader: %w: %+v", ErrNotCanonicalPCM, format)
|
||||
}
|
||||
out := make([]byte, wavHeaderSize)
|
||||
// RIFF header
|
||||
|
||||
@@ -140,6 +140,12 @@ const EventKeyPrefix = "calendar_event_"
|
||||
//
|
||||
// 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.
|
||||
//
|
||||
// Both readings are built with time.Date rather than added to midnight as a
|
||||
// duration. A day is 23 or 25 hours wide on the two DST changeovers, so
|
||||
// midnight plus fourteen hours is 13:00 or 15:00 on those days, and the busy
|
||||
// gate would then read a 14:00 meeting an hour off. The same goes for the
|
||||
// midnight crossing, which is AddDate and not a 24-hour add.
|
||||
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
|
||||
@@ -172,10 +178,11 @@ func FactSpan(key, value string, loc *time.Location) (start, end time.Time, ok b
|
||||
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)
|
||||
y, mo, d := day.Date()
|
||||
start = time.Date(y, mo, d, sh, sm, 0, 0, loc)
|
||||
end = time.Date(y, mo, d, eh, em, 0, 0, loc)
|
||||
if !end.After(start) {
|
||||
end = end.Add(24 * time.Hour)
|
||||
end = end.AddDate(0, 0, 1)
|
||||
}
|
||||
return start, end, true
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ func ParseICalDay(body []byte, now time.Time) []Event {
|
||||
// Reports false for all-day events and parse failures.
|
||||
func parseVEVENT(block string, loc *time.Location) (Event, bool) {
|
||||
var e Event
|
||||
for _, line := range strings.Split(block, "\n") {
|
||||
for _, line := range strings.Split(unfold(block), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
switch {
|
||||
case strings.HasPrefix(line, "DTSTART"):
|
||||
@@ -78,9 +78,9 @@ func parseVEVENT(block string, loc *time.Location) (Event, bool) {
|
||||
e.End = t
|
||||
}
|
||||
case strings.HasPrefix(line, "SUMMARY"):
|
||||
e.Summary = afterColon(line)
|
||||
e.Summary = unescapeText(afterColon(line))
|
||||
case strings.HasPrefix(line, "UID"):
|
||||
e.UID = afterColon(line)
|
||||
e.UID = unescapeText(afterColon(line))
|
||||
}
|
||||
}
|
||||
if e.Start.IsZero() || e.End.IsZero() {
|
||||
@@ -89,6 +89,48 @@ func parseVEVENT(block string, loc *time.Location) (Event, bool) {
|
||||
return e, true
|
||||
}
|
||||
|
||||
// unfold undoes RFC 5545 content-line folding, where a long property is split
|
||||
// with a CRLF and the continuation begins with one space or tab.
|
||||
//
|
||||
// It runs before the block is split into lines, because splitting first and
|
||||
// trimming each line destroys the leading space that marks a continuation. A
|
||||
// server folds at 75 octets and a Russian summary is two bytes a letter, so
|
||||
// "Еженедельная планёрка с командой" crosses the limit easily — without this
|
||||
// the tail of the summary was read as an unknown property and dropped, and the
|
||||
// event was filed under a truncated name.
|
||||
func unfold(block string) string {
|
||||
if !strings.Contains(block, "\n ") && !strings.Contains(block, "\n\t") {
|
||||
return block
|
||||
}
|
||||
return strings.NewReplacer("\r\n ", "", "\r\n\t", "", "\n ", "", "\n\t", "").Replace(block)
|
||||
}
|
||||
|
||||
// unescapeText reverses the RFC 5545 TEXT escaping escapeText applies. Without
|
||||
// it a summary a server wrote as "Обед\, потом созвон" reaches the day plan
|
||||
// with the backslash still in it, and FactKey folds that literal into the key.
|
||||
func unescapeText(s string) string {
|
||||
if !strings.Contains(s, `\`) {
|
||||
return s
|
||||
}
|
||||
var b strings.Builder
|
||||
b.Grow(len(s))
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] != '\\' || i+1 >= len(s) {
|
||||
b.WriteByte(s[i])
|
||||
continue
|
||||
}
|
||||
i++
|
||||
switch s[i] {
|
||||
case 'n', 'N':
|
||||
b.WriteByte('\n')
|
||||
default:
|
||||
// ";", ",", "\\" and anything else a writer escaped needlessly.
|
||||
b.WriteByte(s[i])
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func afterColon(line string) string {
|
||||
if i := strings.Index(line, ":"); i >= 0 {
|
||||
return strings.TrimSpace(line[i+1:])
|
||||
|
||||
@@ -61,6 +61,44 @@ func TestRenderICalEscapesInjection(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A folded SUMMARY is one property, not a property plus a dropped tail. Servers
|
||||
// fold at 75 octets and a Russian summary is two bytes a letter.
|
||||
func TestParseICalUnfoldsAndUnescapes(t *testing.T) {
|
||||
body := []byte("BEGIN:VEVENT\r\n" +
|
||||
"UID:u1\r\n" +
|
||||
"DTSTART:20260703T130000Z\r\n" +
|
||||
"DTEND:20260703T140000Z\r\n" +
|
||||
"SUMMARY:Еженедельная планёрка\\, потом\r\n созвон\r\n" +
|
||||
"END:VEVENT\r\n")
|
||||
from := time.Date(2026, 7, 3, 0, 0, 0, 0, time.UTC)
|
||||
events := ParseICal(body, from, from.AddDate(0, 0, 1))
|
||||
if len(events) != 1 {
|
||||
t.Fatalf("got %d events, want 1", len(events))
|
||||
}
|
||||
if want := "Еженедельная планёрка, потом созвон"; events[0].Summary != want {
|
||||
t.Errorf("Summary = %q, want %q", events[0].Summary, want)
|
||||
}
|
||||
}
|
||||
|
||||
// A day is 23 hours wide where DST starts, so a wall clock reading has to be
|
||||
// built with time.Date and never as midnight plus a duration.
|
||||
func TestFactSpanAcrossDSTStart(t *testing.T) {
|
||||
loc, err := time.LoadLocation("Europe/Berlin")
|
||||
if err != nil {
|
||||
t.Skipf("no tzdata for Europe/Berlin: %v", err)
|
||||
}
|
||||
start, end, ok := FactSpan("calendar_event_20260329_Planerka", "Planerka @ 14:00-15:00", loc)
|
||||
if !ok {
|
||||
t.Fatal("FactSpan reported not ok")
|
||||
}
|
||||
if start.Hour() != 14 || start.Minute() != 0 {
|
||||
t.Errorf("start = %s, want a 14:00 wall clock", start)
|
||||
}
|
||||
if end.Hour() != 15 {
|
||||
t.Errorf("end = %s, want a 15:00 wall clock", end)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReminderEventEmptyPayload(t *testing.T) {
|
||||
e := ReminderEvent(3, time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC), " ", 0)
|
||||
if e.Summary != "напоминание" {
|
||||
|
||||
+41
-26
@@ -161,6 +161,18 @@ func (s *Session) finish() error {
|
||||
return s.spool.Sync()
|
||||
}
|
||||
|
||||
// discard closes the spool and deletes it, leaving nothing behind. Used by the
|
||||
// reaper and by Abort, which throw a recording away rather than harvest it.
|
||||
func (s *Session) discard() {
|
||||
s.mu.Lock()
|
||||
_ = s.finish()
|
||||
path := s.path
|
||||
s.mu.Unlock()
|
||||
if path != "" {
|
||||
_ = os.Remove(path)
|
||||
}
|
||||
}
|
||||
|
||||
// Duration is how much audio has been collected, from the bytes rather than the
|
||||
// wall clock: a stream that dropped frames should report the audio that exists,
|
||||
// not the time that passed.
|
||||
@@ -174,9 +186,16 @@ func (s *Session) duration() time.Duration {
|
||||
return pcmDuration(s.format, s.n)
|
||||
}
|
||||
|
||||
// bytesPerSample is one sample across all channels. Cutting a buffer anywhere
|
||||
// that is not a multiple of it shifts every following sample by a byte.
|
||||
func bytesPerSample(f audio.Format) int64 { return int64(f.SampleBits / 8 * f.Channels) }
|
||||
|
||||
// bytesPerSecond is the format's byte rate, 32000 for the canonical 16 kHz mono.
|
||||
func bytesPerSecond(f audio.Format) int64 { return int64(f.SampleRate) * bytesPerSample(f) }
|
||||
|
||||
// pcmDuration is how long n bytes of PCM lasts in the given format.
|
||||
func pcmDuration(f audio.Format, n int64) time.Duration {
|
||||
per := int64(f.SampleRate) * int64(f.Channels) * int64(f.SampleBits) / 8
|
||||
per := bytesPerSecond(f)
|
||||
if per <= 0 {
|
||||
return 0
|
||||
}
|
||||
@@ -279,19 +298,21 @@ func (r *Recorder) Start(label string) (*Session, error) {
|
||||
f.Close()
|
||||
return nil, err
|
||||
}
|
||||
// A session that never opened leaves no spool file behind.
|
||||
abandon := func(err error) (*Session, error) {
|
||||
f.Close()
|
||||
_ = os.Remove(f.Name())
|
||||
return nil, err
|
||||
}
|
||||
// The header is written first and rewritten at Stop with the real length,
|
||||
// so the spool file is a playable WAV rather than headerless PCM that has
|
||||
// to be copied to gain 44 bytes.
|
||||
if _, err := f.Write(hdr); err != nil {
|
||||
f.Close()
|
||||
_ = os.Remove(f.Name())
|
||||
return nil, fmt.Errorf("capture: spool header: %w", err)
|
||||
return abandon(fmt.Errorf("capture: spool header: %w", err))
|
||||
}
|
||||
token, err := newToken()
|
||||
if err != nil {
|
||||
f.Close()
|
||||
_ = os.Remove(f.Name())
|
||||
return nil, err
|
||||
return abandon(err)
|
||||
}
|
||||
s := &Session{
|
||||
Label: strings.TrimSpace(label),
|
||||
@@ -330,15 +351,11 @@ func (r *Recorder) reapLocked() {
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.expired = true
|
||||
_ = s.finish()
|
||||
path := s.path
|
||||
s.mu.Unlock()
|
||||
if path != "" {
|
||||
// The audio goes with it. A recording nobody stopped is one nobody is
|
||||
// waiting for, and keeping it would mean storing a meeting on the
|
||||
// strength of a dropped connection.
|
||||
_ = os.Remove(path)
|
||||
}
|
||||
// The audio goes with it. A recording nobody stopped is one nobody is
|
||||
// waiting for, and keeping it would mean storing a meeting on the strength
|
||||
// of a dropped connection.
|
||||
s.discard()
|
||||
r.current = nil
|
||||
}
|
||||
|
||||
@@ -536,13 +553,7 @@ func (r *Recorder) Abort(token string) bool {
|
||||
return false
|
||||
}
|
||||
r.current = nil
|
||||
s.mu.Lock()
|
||||
_ = s.finish()
|
||||
path := s.path
|
||||
s.mu.Unlock()
|
||||
if path != "" {
|
||||
_ = os.Remove(path)
|
||||
}
|
||||
s.discard()
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -576,7 +587,7 @@ func (r *Recorder) transcribeFile(ctx context.Context, path string, format audio
|
||||
}
|
||||
// Never cut mid-sample: a split inside an int16 shifts every following
|
||||
// sample by a byte and turns the tail of the window into noise.
|
||||
if bps := int64(format.SampleBits / 8 * format.Channels); bps > 0 {
|
||||
if bps := bytesPerSample(format); bps > 0 {
|
||||
size -= size % bps
|
||||
}
|
||||
if size <= 0 {
|
||||
@@ -608,12 +619,16 @@ func (r *Recorder) transcribeFile(ctx context.Context, path string, format audio
|
||||
// is read by him in a note next to the words around it.
|
||||
const gapMarker = "[…не разобрала…]"
|
||||
|
||||
// windowBytes is how many PCM bytes one STT window holds.
|
||||
// windowBytes is how many PCM bytes one STT window holds, rounded down to a
|
||||
// whole sample.
|
||||
func windowBytes(f audio.Format, window time.Duration) int64 {
|
||||
bps := int64(f.SampleBits / 8 * f.Channels)
|
||||
bps := bytesPerSample(f)
|
||||
if bps <= 0 || f.SampleRate <= 0 || window <= 0 {
|
||||
return 0
|
||||
}
|
||||
per := int64(window.Seconds()) * int64(f.SampleRate) * bps
|
||||
// Fractional seconds count. Truncating the window to whole seconds turned
|
||||
// any sub-second window into zero bytes, which the caller reads as "no
|
||||
// window" and answers by handing the transcriber the entire meeting at once.
|
||||
per := int64(window.Seconds() * float64(bytesPerSecond(f)))
|
||||
return per - per%bps
|
||||
}
|
||||
|
||||
@@ -193,9 +193,7 @@ func ChunkText(text string, maxRunes int) []string {
|
||||
// Oversized sentence: emit what is buffered, then cut this one on
|
||||
// word boundaries.
|
||||
flush()
|
||||
for _, piece := range splitWords(sr, maxRunes) {
|
||||
out = append(out, piece)
|
||||
}
|
||||
out = append(out, splitWords(sr, maxRunes)...)
|
||||
continue
|
||||
}
|
||||
if len(cur)+len(sr) > maxRunes {
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
// Package claim is the common unit for the many claimants that compete for one
|
||||
// utterance (V-565, umbrella V-558, design in
|
||||
// docs/plans/19-dialogue-arbitration.md).
|
||||
//
|
||||
// Maven's cascade has twenty-two stage-0 grammars, seven router intents,
|
||||
// twenty-two query sources and seven stateful pre-emptors, and every one of them
|
||||
// answers "is this mine?" alone. None can answer "is this more mine than
|
||||
// yours?", because their scores are not comparable: stage 0 asserts 1.0 by
|
||||
// fiat, the classifier reports a cosine, the LLM router derives one from
|
||||
// structure. So list order is the whole arbitration.
|
||||
//
|
||||
// A Claim carries evidence rather than a verdict. Two things read that evidence
|
||||
// and neither needs a float:
|
||||
//
|
||||
// - specificity — a claim explaining more of the utterance is preferred, and
|
||||
// that is Consumed against Unexplained;
|
||||
// - negative constraint — a claimant may veto itself and say why, and that is
|
||||
// Veto.
|
||||
//
|
||||
// Where a number is unavoidable, it is an ordinal Band and not a probability.
|
||||
// The band set is argued from measurement in the plan doc: the classifier's
|
||||
// cosine is flat against correctness (62% at both ends of a spread 0.083 wide)
|
||||
// and its top-two margin is p50 0.009, so no claimant Maven has today can
|
||||
// produce a graded confidence.
|
||||
//
|
||||
// This package deliberately imports nothing from the rest of Maven.
|
||||
// internal/dialogue must not import internal/router, so a shared unit that
|
||||
// pulled in router.Intent would smuggle that edge back in. Intent is a plain
|
||||
// string and the conversion happens at each edge.
|
||||
package claim
|
||||
|
||||
import "strings"
|
||||
|
||||
// Band — the kind of evidence behind a claim, ordinal and comparable. Higher
|
||||
// wins a tie. Four values, because four is what the claimants can report.
|
||||
type Band int
|
||||
|
||||
const (
|
||||
// BandUnknown — the zero value. A claim that never set a band is a bug in
|
||||
// its builder, not a weak claim, so it must not silently rank as one.
|
||||
BandUnknown Band = iota
|
||||
|
||||
// BandVetoed — the claimant will take the turn only if nobody else will,
|
||||
// and Veto says why it should not. The three arms of gateLLMDecision (a
|
||||
// fact with no key, an act with no allowlisted fn, a reminder with no
|
||||
// subject) land here. Still a claim: asking "о чём напомнить?" beats
|
||||
// silence.
|
||||
BandVetoed
|
||||
|
||||
// BandNearest — the claim rests only on resemblance to something else,
|
||||
// with no anchor in the utterance and no structural check behind it. The
|
||||
// nearest-centroid classifier. One band and not a graded scale, because
|
||||
// the cosine measured flat against correctness.
|
||||
BandNearest
|
||||
|
||||
// BandStructural — the claimant read the whole sentence and produced a
|
||||
// complete route, every slot its intent requires filled. The LLM router at
|
||||
// full confidence, and a stateful claimant holding a pending question.
|
||||
// Below BandAnchored on purpose: the stateful claimants pre-empt
|
||||
// unconditionally today, and that is the V-558 defect. There are seven of
|
||||
// them and preRouteLadder in cmd/mavend/decisiontrace.go is the roster.
|
||||
BandStructural
|
||||
|
||||
// BandAnchored — a literal pattern anchored in the utterance matched, and
|
||||
// the matched span is what decides the intent. Stage 0 grammars and
|
||||
// query-source matchers. Certainty about the shape of the sentence, which
|
||||
// is not certainty about the answer.
|
||||
BandAnchored
|
||||
)
|
||||
|
||||
// String — the band's name, for a trace line and for a test failure that has to
|
||||
// say which band it got.
|
||||
func (b Band) String() string {
|
||||
switch b {
|
||||
case BandVetoed:
|
||||
return "vetoed"
|
||||
case BandNearest:
|
||||
return "nearest"
|
||||
case BandStructural:
|
||||
return "structural"
|
||||
case BandAnchored:
|
||||
return "anchored"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// Claim — one claimant's bid for one utterance.
|
||||
type Claim struct {
|
||||
// Claimant — who wants the turn. A grammar name, a query source name, a
|
||||
// stage label. Read by the trace and by a test naming a loser.
|
||||
Claimant string
|
||||
|
||||
// Intent — the route this claim would take. A plain string and not
|
||||
// router.Intent: see the package comment.
|
||||
Intent string
|
||||
|
||||
// Filled — the slot names this claim would fill ("time", "fn", "key",
|
||||
// "text"). Names and not values, because arbitration compares shape.
|
||||
Filled []string
|
||||
|
||||
// Consumed — the utterance tokens this claim explains, in the order they
|
||||
// appear. The numerator of specificity.
|
||||
Consumed []string
|
||||
|
||||
// Unexplained — the tokens this claim does not explain, in order. Carried
|
||||
// rather than derived, so a claimant may decline a span it did match.
|
||||
Unexplained []string
|
||||
|
||||
// Band — the kind of evidence. The tie-break, after coverage.
|
||||
Band Band
|
||||
|
||||
// Veto — why this claim should NOT win, empty when there is none. A
|
||||
// non-empty Veto and a Band above BandVetoed is legal: a claim can be
|
||||
// well-evidenced and still name a reason to prefer somebody else.
|
||||
Veto string
|
||||
}
|
||||
|
||||
// Coverage — the fraction of the utterance this claim explains, in [0,1]. A
|
||||
// claim with no tokens either way covers nothing; it is not division by zero
|
||||
// and it is not a full claim.
|
||||
func (c Claim) Coverage() float64 {
|
||||
total := len(c.Consumed) + len(c.Unexplained)
|
||||
if total == 0 {
|
||||
return 0
|
||||
}
|
||||
return float64(len(c.Consumed)) / float64(total)
|
||||
}
|
||||
|
||||
// Vetoed reports whether the claimant named a reason against itself.
|
||||
func (c Claim) Vetoed() bool { return c.Veto != "" }
|
||||
|
||||
// MoreSpecificThan — the ordering V-560's arbiter will read. Coverage first,
|
||||
// because that is what fixes the failure this program opened with: a pending
|
||||
// reminder ate "какая сейчас погода в Риме?" as a time answer while explaining
|
||||
// none of it. Band only breaks a coverage tie.
|
||||
//
|
||||
// Deliberately NOT wired into the cascade by V-565. It is here so the ordering
|
||||
// is one function with tests on it, rather than a rule restated at each of the
|
||||
// sites that will eventually call it.
|
||||
func (c Claim) MoreSpecificThan(other Claim) bool {
|
||||
cc, oc := c.Coverage(), other.Coverage()
|
||||
if cc != oc {
|
||||
return cc > oc
|
||||
}
|
||||
return c.Band > other.Band
|
||||
}
|
||||
|
||||
// Tokens — the utterance split for coverage accounting. Whitespace, then
|
||||
// trailing and leading punctuation, then lowercased.
|
||||
//
|
||||
// This is tokenization over the raw string and not a Russian pattern: it
|
||||
// contains no word list, and its output is a count rather than a fact or a
|
||||
// route (CLAUDE.md § Russian patterns). Lowercasing is Unicode-aware, so
|
||||
// Cyrillic folds the same way Latin does.
|
||||
func Tokens(utterance string) []string {
|
||||
fields := strings.FieldsFunc(utterance, func(r rune) bool {
|
||||
return r == ' ' || r == '\t' || r == '\n' || r == '\r'
|
||||
})
|
||||
out := make([]string, 0, len(fields))
|
||||
for _, f := range fields {
|
||||
t := strings.Trim(strings.ToLower(f), ".,!?;:()\"'«»…-–—")
|
||||
if t == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, t)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Split partitions the utterance's tokens into the ones a claim explains and
|
||||
// the rest, preserving order in both. A token is explained when it appears in
|
||||
// one of the spans the claimant filled (a slot value, a matched substring).
|
||||
//
|
||||
// Duplicates are handled by membership and not by count: "напомни напомни
|
||||
// позвонить" with span "напомни" explains both copies. The alternative is a
|
||||
// multiset, and no claimant Maven has can say which copy it meant.
|
||||
func Split(utterance string, spans ...string) (consumed, unexplained []string) {
|
||||
explained := map[string]bool{}
|
||||
for _, s := range spans {
|
||||
for _, t := range Tokens(s) {
|
||||
explained[t] = true
|
||||
}
|
||||
}
|
||||
for _, t := range Tokens(utterance) {
|
||||
if explained[t] {
|
||||
consumed = append(consumed, t)
|
||||
} else {
|
||||
unexplained = append(unexplained, t)
|
||||
}
|
||||
}
|
||||
return consumed, unexplained
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package claim
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTokensStripsPunctuationAndCase(t *testing.T) {
|
||||
got := Tokens("Какая сейчас погода в Риме?")
|
||||
want := []string{"какая", "сейчас", "погода", "в", "риме"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("Tokens = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// The dash forms matter: an STT transcript routinely carries "а, да, прости -
|
||||
// на 9", and a stray dash counted as a token would dilute every coverage score
|
||||
// in the sentence.
|
||||
func TestTokensDropsBareDashes(t *testing.T) {
|
||||
got := Tokens("а, да, прости — на 9")
|
||||
want := []string{"а", "да", "прости", "на", "9"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("Tokens = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitPartitionsInOrder(t *testing.T) {
|
||||
consumed, unexplained := Split("напомни позвонить маме", "позвонить маме")
|
||||
if want := []string{"позвонить", "маме"}; !reflect.DeepEqual(consumed, want) {
|
||||
t.Errorf("consumed = %q, want %q", consumed, want)
|
||||
}
|
||||
if want := []string{"напомни"}; !reflect.DeepEqual(unexplained, want) {
|
||||
t.Errorf("unexplained = %q, want %q", unexplained, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoverageIsZeroWithoutTokens(t *testing.T) {
|
||||
if got := (Claim{}).Coverage(); got != 0 {
|
||||
t.Errorf("Coverage of an empty claim = %v, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoverageFraction(t *testing.T) {
|
||||
c := Claim{Consumed: []string{"a", "b", "c"}, Unexplained: []string{"d"}}
|
||||
if got := c.Coverage(); got != 0.75 {
|
||||
t.Errorf("Coverage = %v, want 0.75", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The band order is load-bearing, so it is asserted rather than assumed from
|
||||
// the iota. BandUnknown must sit at the bottom: a claim whose builder forgot to
|
||||
// set a band is a bug and must not outrank a measured one.
|
||||
func TestBandOrder(t *testing.T) {
|
||||
ordered := []Band{BandUnknown, BandVetoed, BandNearest, BandStructural, BandAnchored}
|
||||
for i := 1; i < len(ordered); i++ {
|
||||
if !(ordered[i-1] < ordered[i]) {
|
||||
t.Errorf("%v is not below %v", ordered[i-1], ordered[i])
|
||||
}
|
||||
}
|
||||
for _, b := range ordered {
|
||||
if b.String() == "" {
|
||||
t.Errorf("band %d has no name", b)
|
||||
}
|
||||
}
|
||||
if BandUnknown.String() != "unknown" {
|
||||
t.Errorf("BandUnknown.String() = %q", BandUnknown.String())
|
||||
}
|
||||
}
|
||||
|
||||
// The failure V-558 opened with, as an ordering test. A pending reminder eats
|
||||
// "какая сейчас погода в Риме?" as a time answer and explains none of it; the
|
||||
// weather source explains all of it. Coverage decides, and the band never gets
|
||||
// consulted, which is the point: the pending claimant is structural and the
|
||||
// weather claim is anchored, but even if the bands were equal the weather claim
|
||||
// wins.
|
||||
func TestCoverageBeatsBand(t *testing.T) {
|
||||
utterance := "какая сейчас погода в Риме?"
|
||||
pendingConsumed, pendingRest := Split(utterance, "сейчас")
|
||||
pending := Claim{
|
||||
Claimant: "reminder-followup", Intent: "reminder",
|
||||
Consumed: pendingConsumed, Unexplained: pendingRest,
|
||||
Band: BandStructural,
|
||||
}
|
||||
weatherConsumed, weatherRest := Split(utterance, utterance)
|
||||
weather := Claim{
|
||||
Claimant: "weather", Intent: "query",
|
||||
Consumed: weatherConsumed, Unexplained: weatherRest,
|
||||
Band: BandNearest,
|
||||
}
|
||||
if !weather.MoreSpecificThan(pending) {
|
||||
t.Errorf("weather (%v cover) did not beat pending (%v cover)",
|
||||
weather.Coverage(), pending.Coverage())
|
||||
}
|
||||
if pending.MoreSpecificThan(weather) {
|
||||
t.Error("pending beat weather, so the ordering is not asymmetric")
|
||||
}
|
||||
}
|
||||
|
||||
// Where coverage ties, the band decides. Two grammars claiming the same
|
||||
// utterance is the stage-0 contention case (ru-query-019 on the fixture), and
|
||||
// today list order settles it with nothing recorded.
|
||||
func TestBandBreaksACoverageTie(t *testing.T) {
|
||||
anchored := Claim{Claimant: "calendar-query", Consumed: []string{"a"}, Band: BandAnchored}
|
||||
nearest := Claim{Claimant: "classifier", Consumed: []string{"a"}, Band: BandNearest}
|
||||
if !anchored.MoreSpecificThan(nearest) {
|
||||
t.Error("anchored did not beat nearest on equal coverage")
|
||||
}
|
||||
if nearest.MoreSpecificThan(anchored) {
|
||||
t.Error("nearest beat anchored on equal coverage")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVetoed(t *testing.T) {
|
||||
if (Claim{}).Vetoed() {
|
||||
t.Error("a claim with no veto reports itself vetoed")
|
||||
}
|
||||
if !(Claim{Veto: "fact with no key"}).Vetoed() {
|
||||
t.Error("a claim with a veto reason does not report itself vetoed")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package config
|
||||
|
||||
import "time"
|
||||
|
||||
// Two loops that run behind the conversation and never speak. Both are absent
|
||||
// by default, and both follow the same rule as every other cost the owner did
|
||||
// not ask for: present-but-empty (`{}`) is a valid "on with the defaults".
|
||||
|
||||
// MemoryEvalConfig — the background memory-evaluation loop (Vikunja #248).
|
||||
// Absent ⇒ off, like every other capability that costs something the owner did
|
||||
// not ask for. Each evaluation is a full LLM round-trip on the one resident
|
||||
// model, which is the same model answering him; running it hourly by default
|
||||
// would put a multi-second stall in front of an occasional voice turn for a
|
||||
// feature he may not want.
|
||||
//
|
||||
// The loop only ever writes notes (source infer:memory-eval, visible on
|
||||
// /dash). It cannot speak — see internal/memeval.
|
||||
type MemoryEvalConfig struct {
|
||||
// Interval — how often to evaluate. 0 ⇒ DefaultMemoryEvalInterval.
|
||||
Interval Duration `json:"interval,omitempty"`
|
||||
|
||||
// MaxItems — recent facts / notes / nudges fed into one evaluation.
|
||||
// 0 ⇒ memeval.DefaultMaxItems.
|
||||
MaxItems int `json:"max_items,omitempty"`
|
||||
|
||||
// MinConfidence — observations the model scores below this are dropped.
|
||||
// 0 ⇒ memeval.DefaultMinConfidence.
|
||||
MinConfidence float64 `json:"min_confidence,omitempty"`
|
||||
}
|
||||
|
||||
// DefaultMemoryEvalInterval — the plan's cadence (1h) for the memory
|
||||
// evaluation loop, applied only when the block is present at all.
|
||||
const DefaultMemoryEvalInterval = time.Hour
|
||||
|
||||
// normaliseMemoryEval leaves an absent block nil (⇒ no evaluation loop) and
|
||||
// gives a present one the plan's cadence.
|
||||
func (c *Config) normaliseMemoryEval() {
|
||||
if c.MemoryEval != nil && c.MemoryEval.Interval <= 0 {
|
||||
c.MemoryEval.Interval = Duration(DefaultMemoryEvalInterval)
|
||||
}
|
||||
}
|
||||
|
||||
// EmailConfig — core's half of the email reader: how many task candidates one
|
||||
// message may produce, and how long the extraction call may take.
|
||||
//
|
||||
// There is deliberately nothing about a mailbox here. Core does not connect to
|
||||
// IMAP, does not know an account exists, and holds no mail credential — the
|
||||
// reader daemon does, the same split mavpoll uses for the zenmoney token. This
|
||||
// block only says "extraction is allowed, with these bounds".
|
||||
type EmailConfig struct {
|
||||
// MaxTasks — candidates per message. 0 ⇒ email.MaxCandidates (3).
|
||||
MaxTasks int `json:"max_tasks,omitempty"`
|
||||
|
||||
// Timeout — per-message extraction budget. 0 ⇒ DefaultEmailTimeout. This is
|
||||
// a Thinking model reading a mail; nobody is waiting on the answer, but a
|
||||
// hung llama-server must not pin the reader's connection forever.
|
||||
Timeout Duration `json:"timeout,omitempty"`
|
||||
}
|
||||
|
||||
// DefaultEmailTimeout — extraction budget per message.
|
||||
const DefaultEmailTimeout = 2 * time.Minute
|
||||
|
||||
// normaliseEmail leaves an absent block nil (⇒ mail ingestion refused) and
|
||||
// gives a present one the timeout default.
|
||||
func (c *Config) normaliseEmail() {
|
||||
if c.Email != nil && c.Email.Timeout <= 0 {
|
||||
c.Email.Timeout = Duration(DefaultEmailTimeout)
|
||||
}
|
||||
}
|
||||
+64
-1378
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,126 @@
|
||||
package config
|
||||
|
||||
// The two scheduled outbound readers: RSS/Atom feeds and watched web pages.
|
||||
// Both are off unless configured, both write notes and neither may speak —
|
||||
// nothing they fetch is dispatched, nudged or announced on arrival. That is the
|
||||
// "not a nag" constraint, and it is why there is no severity or channel field in
|
||||
// either block to reach for.
|
||||
//
|
||||
// Only the URL leaves the box. His notes, facts, persona block and history are
|
||||
// never part of a request; neither package can read the store.
|
||||
|
||||
// FeedsConfig — the RSS/Atom reader (Vikunja #258, docs/plans/13-rss-news-feeds.md).
|
||||
//
|
||||
// Absent ⇒ off. Present with an empty `sources` list is also off — a poller with
|
||||
// nothing to poll is not wired, and normaliseFeeds folds that back to nil.
|
||||
type FeedsConfig struct {
|
||||
// Sources — the feeds to read. Empty ⇒ the reader stays down.
|
||||
Sources []FeedSourceConfig `json:"sources,omitempty"`
|
||||
|
||||
// PollInterval — default per-feed cadence. 0 ⇒ rss.DefaultPollInterval (30m).
|
||||
PollInterval Duration `json:"poll_interval,omitempty"`
|
||||
|
||||
// MaxItems — most items kept from one feed in one poll. 0 ⇒
|
||||
// rss.DefaultMaxItems (5). This is the "не завали мне /dash" knob.
|
||||
MaxItems int `json:"max_items,omitempty"`
|
||||
|
||||
// MaxAge — on a first poll (no saved mark), how far back to take items.
|
||||
// 0 ⇒ rss.DefaultMaxAge (24h), so switching a feed on imports today, not
|
||||
// the archive.
|
||||
MaxAge Duration `json:"max_age,omitempty"`
|
||||
|
||||
// AllowHosts — when set, the reader may only connect to these hosts (and
|
||||
// their subdomains). The feed URLs' own hosts are added automatically, so
|
||||
// this is only needed to be stricter than that.
|
||||
AllowHosts []string `json:"allow_hosts,omitempty"`
|
||||
|
||||
// Timeout — per-request budget. 0 ⇒ webfetch.DefaultTimeout.
|
||||
Timeout Duration `json:"timeout,omitempty"`
|
||||
|
||||
// MaxBytes — response size cap. 0 ⇒ webfetch.DefaultMaxBytes (2 MiB).
|
||||
MaxBytes int64 `json:"max_bytes,omitempty"`
|
||||
}
|
||||
|
||||
// FeedSourceConfig — one feed.
|
||||
type FeedSourceConfig struct {
|
||||
Name string `json:"name"` // note source is "rss:<name>"
|
||||
URL string `json:"url"` // http(s) only
|
||||
Category string `json:"category,omitempty"` // "технологии" — what "что нового по X?" matches
|
||||
Interval Duration `json:"interval,omitempty"` // 0 ⇒ FeedsConfig.PollInterval
|
||||
Include []string `json:"include,omitempty"` // keep only items containing one of these
|
||||
Exclude []string `json:"exclude,omitempty"` // drop items containing any of these
|
||||
}
|
||||
|
||||
// normaliseFeeds folds a block with no sources back to nil: it is the same as
|
||||
// no block, and keeping that "off" in one place is the point.
|
||||
func (c *Config) normaliseFeeds() {
|
||||
if c.Feeds != nil && len(c.Feeds.Sources) == 0 {
|
||||
c.Feeds = nil
|
||||
}
|
||||
}
|
||||
|
||||
// CrawlConfig — the web crawler (Vikunja #259, docs/plans/14-web-crawler.md).
|
||||
//
|
||||
// Absent ⇒ off, and off means no page is ever fetched. Present with neither
|
||||
// `on_demand` nor a `watches` entry is also off: there would be nothing to do.
|
||||
//
|
||||
// The crawler is the LAST place an answer is looked for, behind the model, his
|
||||
// own memory, the live search and the local Kiwix ZIMs. That ordering lives in
|
||||
// the query-source chain (cmd/mavend/actions_query.go), not here, but it is the
|
||||
// reason this block is small: it is a fallback, not a search engine.
|
||||
type CrawlConfig struct {
|
||||
// OnDemand — may he ask her to read a page he names out loud
|
||||
// ("посмотри https://… — что там пишут?"). false ⇒ the on-demand answer
|
||||
// source stays off and only the watches below run.
|
||||
OnDemand bool `json:"on_demand,omitempty"`
|
||||
|
||||
// Watches — pages re-read on a schedule. A page whose text changed is
|
||||
// written as a note (source "crawl:<name>"); nothing is announced.
|
||||
Watches []CrawlWatchConfig `json:"watches,omitempty"`
|
||||
|
||||
// Interval — default watch cadence. 0 ⇒ crawl.DefaultWatchInterval (6h).
|
||||
Interval Duration `json:"interval,omitempty"`
|
||||
|
||||
// AllowHosts — when set, the ONLY hosts the crawler may reach (subdomains
|
||||
// included). Setting this is how "she may read the arch wiki and nothing
|
||||
// else" is expressed.
|
||||
//
|
||||
// A watched page's own host is reachable by the scheduled crawler whether
|
||||
// or not it is listed here, because configuring a watch is already saying
|
||||
// she may read it. That does NOT extend to on-demand reading: a watch is
|
||||
// not an allowlist entry for pages he pastes.
|
||||
AllowHosts []string `json:"allow_hosts,omitempty"`
|
||||
|
||||
// DenyHosts — never reachable, checked first. Private addresses do not need
|
||||
// to be listed: they are refused unconditionally (see internal/webfetch).
|
||||
DenyHosts []string `json:"deny_hosts,omitempty"`
|
||||
|
||||
// UserAgent — sent on every request AND matched against robots.txt groups.
|
||||
// Empty ⇒ webfetch.DefaultUserAgent.
|
||||
UserAgent string `json:"user_agent,omitempty"`
|
||||
|
||||
// Timeout — per-request budget. 0 ⇒ webfetch.DefaultTimeout.
|
||||
Timeout Duration `json:"timeout,omitempty"`
|
||||
|
||||
// MaxBytes — response size cap. 0 ⇒ webfetch.DefaultMaxBytes (2 MiB).
|
||||
MaxBytes int64 `json:"max_bytes,omitempty"`
|
||||
|
||||
// MaxRunes — how much extracted text is kept. 0 ⇒ crawl.DefaultMaxRunes
|
||||
// (4000), which is what fits a 4096-token context alongside a prompt.
|
||||
MaxRunes int `json:"max_runes,omitempty"`
|
||||
}
|
||||
|
||||
// CrawlWatchConfig — one page kept an eye on.
|
||||
type CrawlWatchConfig struct {
|
||||
Name string `json:"name"` // note source is "crawl:<name>"
|
||||
URL string `json:"url"`
|
||||
Interval Duration `json:"interval,omitempty"` // 0 ⇒ CrawlConfig.Interval
|
||||
}
|
||||
|
||||
// normaliseCrawl folds a block that neither answers on demand nor watches
|
||||
// anything back to nil: it has nothing to do.
|
||||
func (c *Config) normaliseCrawl() {
|
||||
if c.Crawl != nil && !c.Crawl.OnDemand && len(c.Crawl.Watches) == 0 {
|
||||
c.Crawl = nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestDeployConfigLoads parses the file the box actually runs on.
|
||||
//
|
||||
// Every other test in this package builds its own JSON, so a key renamed in one
|
||||
// place and not the other would go unnoticed until the daemon refused to start.
|
||||
// This one reads deploy/mavend.json through the same Load the daemon calls, so
|
||||
// a config change and a code change have to agree here or the suite is red.
|
||||
//
|
||||
// The ${VAR} expansions come from a gitignored deploy/telegram.env that is not
|
||||
// present in CI. An unset var expands to the empty string, which is exactly the
|
||||
// "not configured" state every block already has to handle, so the parse is
|
||||
// still meaningful without the secrets.
|
||||
func TestDeployConfigLoads(t *testing.T) {
|
||||
path := filepath.Join("..", "..", "deploy", "mavend.json")
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Skipf("no deploy config at %s: %v", path, err)
|
||||
}
|
||||
cfg, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Load(%s): %v", path, err)
|
||||
}
|
||||
|
||||
// Spot-check the settings whose absence would be a silent behaviour change
|
||||
// rather than a startup error.
|
||||
if cfg.Phraser == nil {
|
||||
t.Fatal("deploy config has no phraser block")
|
||||
}
|
||||
if cfg.Phraser.NGpuLayers == 0 {
|
||||
t.Error("phraser.n_gpu_layers is 0 — llama-server would run CPU-only, " +
|
||||
"because nothing in this package defaults that field")
|
||||
}
|
||||
if cfg.Voice == nil || !cfg.Voice.Enabled {
|
||||
t.Fatal("deploy config does not enable voice")
|
||||
}
|
||||
if !cfg.Voice.UseLLMRouter() {
|
||||
t.Error("deploy config turned the LLM router off")
|
||||
}
|
||||
if cfg.Voice.RouterThreshold <= 0 {
|
||||
t.Error("router threshold did not get its default")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package config
|
||||
|
||||
// The ecosystem trio. Maven owns conversation and personal memory; it owns
|
||||
// neither identity, nor operational state, nor execution. Each of these three
|
||||
// is nil unless configured and each degrades on its own — an outage is a named
|
||||
// gap in the answer, never a broken turn and never a guess. See docs/ecosystem.md.
|
||||
//
|
||||
// All three carry the same two fields, and they stay three types rather than one
|
||||
// shared EndpointConfig on purpose: the block a reader greps for is the service
|
||||
// they are debugging, and a shared type would put "url" in one place for three
|
||||
// different trust levels.
|
||||
|
||||
// PraxisConfig — maven's connection to the Praxis attention service.
|
||||
type PraxisConfig struct {
|
||||
// URL — the Praxis HTTP API base URL (e.g. "http://localhost:9742").
|
||||
URL string `json:"url,omitempty"`
|
||||
|
||||
// Token — the shared bearer token sent on every request. Empty ⇒ calls
|
||||
// go out unauthenticated, which is only appropriate on a loopback or
|
||||
// unix-socket transport. Supports ${VAR} expansion, so the secret lives
|
||||
// in deploy/telegram.env, not in the committed config.
|
||||
Token string `json:"token,omitempty"`
|
||||
}
|
||||
|
||||
// NexusConfig — connection to the Nexus identity service.
|
||||
type NexusConfig struct {
|
||||
// URL — the Nexus HTTP API base URL (e.g. "http://localhost:9740").
|
||||
URL string `json:"url,omitempty"`
|
||||
|
||||
// Token — shared bearer token; see PraxisConfig.Token.
|
||||
Token string `json:"token,omitempty"`
|
||||
}
|
||||
|
||||
// HexisConfig — connection to the Hexis capability execution service.
|
||||
type HexisConfig struct {
|
||||
// URL — the Hexis HTTP API base URL (e.g. "http://localhost:9741").
|
||||
URL string `json:"url,omitempty"`
|
||||
|
||||
// Token — shared bearer token; see PraxisConfig.Token.
|
||||
Token string `json:"token,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/netscan"
|
||||
"github.com/kami/maven/internal/smarthome"
|
||||
)
|
||||
|
||||
// SmartHomeConfig — the Home Assistant block (Vikunja #256). Dark until
|
||||
// `"enabled": true`, and even then a discovered device is only ever PROPOSED
|
||||
// into the act allowlist: Kami enables it on /tools, behind step-up, exactly as
|
||||
// he would a shell tool. Finding a switch on the network is not the same as
|
||||
// being allowed to flip it.
|
||||
type SmartHomeConfig struct {
|
||||
// Provider — only "homeassistant" is implemented. MQTT / Zigbee2MQTT are
|
||||
// not: Home Assistant already fronts them, and a broker client is a
|
||||
// dependency this vendored module tree cannot take on tonight.
|
||||
Provider string `json:"provider,omitempty"`
|
||||
|
||||
// URL — the instance base, "http://192.168.1.50:8123".
|
||||
//
|
||||
// Plain http is accepted and is what the deploy block uses. That is a
|
||||
// deliberate choice, not an oversight: the instance is on the LAN behind
|
||||
// wireguard, and a self-signed cert on a home box buys a warning rather
|
||||
// than a guarantee. It does mean the long-lived token crosses the LAN in
|
||||
// cleartext on every refresh, so the LAN is part of the trust boundary.
|
||||
URL string `json:"url,omitempty"`
|
||||
|
||||
// Token — a long-lived access token. Use ${HA_TOKEN} and keep the value in
|
||||
// the gitignored env file, like the telegram credentials.
|
||||
Token string `json:"token,omitempty"`
|
||||
|
||||
// Domains — entity domains to take. Empty ⇒ the controllable domains
|
||||
// EXCEPT lock (light, switch, fan, cover) plus sensor and binary_sensor
|
||||
// for reads. A lock is only enumerated when it is named here, because a
|
||||
// front door is not a lamp. Narrow it when the instance is large: a tool name the 1.7B
|
||||
// half-remembers is a wrong act.
|
||||
Domains []string `json:"domains,omitempty"`
|
||||
|
||||
// MaxEntities — cap on the proposal catalogue. 0 ⇒ 40.
|
||||
MaxEntities int `json:"max_entities,omitempty"`
|
||||
|
||||
// Timeout — per-call budget. 0 ⇒ 10s.
|
||||
Timeout Duration `json:"timeout,omitempty"`
|
||||
|
||||
// Refresh — how often the entity list is re-read and new devices proposed.
|
||||
// 0 ⇒ 15m, and anything under MinSmartHomeRefresh is raised to it:
|
||||
// "refresh": "1s" used to pass validation and enumerate the whole instance
|
||||
// every second. Discovery is idempotent, so this only ever adds rows.
|
||||
Refresh Duration `json:"refresh,omitempty"`
|
||||
|
||||
// Enabled — false (the default) keeps a written block dark, so it can be
|
||||
// reviewed before the house is wired to a voice.
|
||||
Enabled bool `json:"enabled,omitempty"`
|
||||
}
|
||||
|
||||
// DefaultSmartHomeRefresh — how often the house is re-enumerated for new
|
||||
// devices. Slow on purpose: discovery only adds proposals, and a flat does not
|
||||
// grow a new lamp every minute.
|
||||
const DefaultSmartHomeRefresh = 15 * time.Minute
|
||||
|
||||
// MinSmartHomeRefresh — the floor under SmartHomeConfig.Refresh. Enumerating
|
||||
// every entity in the house is a full /api/states read; a misconfigured second
|
||||
// would hammer the instance for proposals that are idempotent anyway.
|
||||
const MinSmartHomeRefresh = time.Minute
|
||||
|
||||
// SmartHomeClient maps the config block onto the smarthome package's own type.
|
||||
// Returns ok=false when nothing is configured or it is disabled, so validation
|
||||
// and daemon wiring cannot drift on the mapping.
|
||||
func (c *Config) SmartHomeClient() (smarthome.Config, bool) {
|
||||
if c.SmartHome == nil || !c.SmartHome.Enabled {
|
||||
return smarthome.Config{}, false
|
||||
}
|
||||
return smarthome.Config{
|
||||
URL: c.SmartHome.URL,
|
||||
Token: c.SmartHome.Token,
|
||||
Domains: c.SmartHome.Domains,
|
||||
MaxEntities: c.SmartHome.MaxEntities,
|
||||
Timeout: time.Duration(c.SmartHome.Timeout),
|
||||
}, true
|
||||
}
|
||||
|
||||
// normaliseSmartHome applies the block's defaults. A block that is not enabled
|
||||
// is the same as no block at all, so "off" stays in one place.
|
||||
func (c *Config) normaliseSmartHome() {
|
||||
if c.SmartHome != nil && !c.SmartHome.Enabled {
|
||||
c.SmartHome = nil
|
||||
}
|
||||
if c.SmartHome != nil && c.SmartHome.Refresh <= 0 {
|
||||
c.SmartHome.Refresh = Duration(DefaultSmartHomeRefresh)
|
||||
}
|
||||
if c.SmartHome != nil && c.SmartHome.Refresh < Duration(MinSmartHomeRefresh) {
|
||||
c.SmartHome.Refresh = Duration(MinSmartHomeRefresh)
|
||||
}
|
||||
}
|
||||
|
||||
// validateSmartHome fails a missing token or a bare hostname at startup, not at
|
||||
// the first "выключи свет".
|
||||
func (c *Config) validateSmartHome() error {
|
||||
hc, ok := c.SmartHomeClient()
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if p := c.SmartHome.Provider; p != "" && p != "homeassistant" {
|
||||
return fmt.Errorf("smarthome: provider %q: only \"homeassistant\" is implemented", p)
|
||||
}
|
||||
return smarthome.Validate(hc)
|
||||
}
|
||||
|
||||
// NetScanConfig — the LAN scanner block (Vikunja #257). Dark until
|
||||
// `"enabled": true`.
|
||||
//
|
||||
// The important field is Subnets, and it is the ONLY source of a scan target.
|
||||
// Nothing an utterance, a router or a scanned host says can widen or move the
|
||||
// range: internal/netscan.Scanner.Scan takes no target argument at all. Each
|
||||
// subnet must be private and no larger than netscan.MaxPrefixHosts addresses
|
||||
// (a /22), enforced at config load rather than at the first spoken scan.
|
||||
type NetScanConfig struct {
|
||||
// Subnets — CIDRs to scan, "192.168.1.0/24".
|
||||
Subnets []string `json:"subnets,omitempty"`
|
||||
|
||||
// Ports — TCP ports to try per host. Empty ⇒ netscan.DefaultPorts
|
||||
// (22, 80, 443, 8080).
|
||||
Ports []int `json:"ports,omitempty"`
|
||||
|
||||
// Timeout — per-connection budget. 0 ⇒ netscan.DefaultTimeout (400ms).
|
||||
Timeout Duration `json:"timeout,omitempty"`
|
||||
|
||||
// Rate — connections per second across the whole scan. 0 ⇒
|
||||
// netscan.DefaultRate (100). Low on purpose: a scan should look like
|
||||
// background traffic, not a portscan.
|
||||
Rate int `json:"rate,omitempty"`
|
||||
|
||||
// MaxHosts — cap on addresses probed per scan. 0 ⇒ netscan.DefaultMaxHosts
|
||||
// (256).
|
||||
MaxHosts int `json:"max_hosts,omitempty"`
|
||||
|
||||
// Enabled — false (the default) keeps a written block dark.
|
||||
Enabled bool `json:"enabled,omitempty"`
|
||||
}
|
||||
|
||||
// NetScanner maps the config block onto the netscan package's own type.
|
||||
// ok=false when absent or disabled, so validation and daemon wiring cannot
|
||||
// drift on the mapping.
|
||||
func (c *Config) NetScanner() (netscan.Config, bool) {
|
||||
if c.NetScan == nil || !c.NetScan.Enabled {
|
||||
return netscan.Config{}, false
|
||||
}
|
||||
return netscan.Config{
|
||||
Subnets: c.NetScan.Subnets,
|
||||
Ports: c.NetScan.Ports,
|
||||
Timeout: time.Duration(c.NetScan.Timeout),
|
||||
Rate: c.NetScan.Rate,
|
||||
MaxHosts: c.NetScan.MaxHosts,
|
||||
}, true
|
||||
}
|
||||
|
||||
// normaliseNetScan applies the block's defaults. Same rule as the house: not
|
||||
// enabled is the same as no block at all.
|
||||
func (c *Config) normaliseNetScan() {
|
||||
if c.NetScan != nil && !c.NetScan.Enabled {
|
||||
c.NetScan = nil
|
||||
}
|
||||
}
|
||||
|
||||
// validateNetScan fails a scanner pointed at the public internet, or at a /8,
|
||||
// here rather than after the packets have already left.
|
||||
func (c *Config) validateNetScan() error {
|
||||
nc, ok := c.NetScanner()
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return netscan.Validate(nc)
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/mcp"
|
||||
)
|
||||
|
||||
// MCPConfig — the MCP client block. Servers are dark until one has
|
||||
// `"enabled": true`, and a discovered tool is only ever PROPOSED: Kami enables
|
||||
// it on /tools, on the authed surface, exactly as he would a shell tool. The
|
||||
// voice path can never grant a capability to itself.
|
||||
type MCPConfig struct {
|
||||
// Servers — the configured servers. Each needs exactly one of command
|
||||
// (a subprocess on this box) or url (a streamable-HTTP endpoint).
|
||||
Servers []MCPServerConfig `json:"servers,omitempty"`
|
||||
|
||||
// Timeout — per-call budget for every server that does not set its own.
|
||||
// 0 ⇒ mcp.DefaultTimeout (15s). A tool slower than this is not usable in a
|
||||
// spoken turn.
|
||||
Timeout Duration `json:"timeout,omitempty"`
|
||||
|
||||
// AllowHosts / DenyHosts — the host lists for the shared webfetch door that
|
||||
// url servers go through. Deny wins. Private addresses are refused
|
||||
// unconditionally unless the individual server sets allow_private.
|
||||
AllowHosts []string `json:"allow_hosts,omitempty"`
|
||||
DenyHosts []string `json:"deny_hosts,omitempty"`
|
||||
|
||||
// MaxBytes — cap on one JSON-RPC response. 0 ⇒ webfetch.DefaultMaxBytes.
|
||||
MaxBytes int64 `json:"max_bytes,omitempty"`
|
||||
|
||||
// HostInterval — minimum spacing between two requests to one MCP server.
|
||||
// 0 ⇒ DefaultMCPHostInterval (50ms), NOT webfetch's own one-second default.
|
||||
// That default was sized for a feed poll loop, and this path is in a spoken
|
||||
// turn: one dial is three requests (initialize, initialized, tools/list),
|
||||
// so a second of spacing is two seconds of pure sleeping per dial and up to
|
||||
// another second before every tools/call leaves the box.
|
||||
HostInterval Duration `json:"host_interval,omitempty"`
|
||||
}
|
||||
|
||||
// DefaultMCPHostInterval — see MCPConfig.HostInterval. Enough to stop a
|
||||
// runaway loop hammering a server, small enough not to be heard.
|
||||
const DefaultMCPHostInterval = 50 * time.Millisecond
|
||||
|
||||
// normaliseMCP applies the block's defaults. A block with no server at all is
|
||||
// the same as no block. A block whose servers are all disabled is NOT
|
||||
// normalised away, because validate has to see their shape — a dark block with
|
||||
// a typo in it should fail at startup, which is the whole reason it can be
|
||||
// written before it is switched on. wireMCP builds nothing when nothing is
|
||||
// enabled, so "off" still holds.
|
||||
func (c *Config) normaliseMCP() {
|
||||
if c.MCP != nil && len(c.MCP.Servers) == 0 {
|
||||
c.MCP = nil
|
||||
}
|
||||
if c.MCP != nil && c.MCP.HostInterval <= 0 {
|
||||
c.MCP.HostInterval = Duration(DefaultMCPHostInterval)
|
||||
}
|
||||
}
|
||||
|
||||
// MCPServerConfig — one MCP server.
|
||||
type MCPServerConfig struct {
|
||||
// Name — the local handle. It prefixes every tool this server contributes
|
||||
// ("vikunja" + "list_tasks" ⇒ the allowlist row "vikunja_list_tasks") and
|
||||
// becomes the store scope "mcp:<name>", so its provenance is readable on
|
||||
// /tools without opening the config.
|
||||
Name string `json:"name"`
|
||||
|
||||
// Command / Args / Env / Dir — a stdio server: a child process of mavend,
|
||||
// on this box, under this user. argv, never a shell string.
|
||||
Command string `json:"command,omitempty"`
|
||||
Args []string `json:"args,omitempty"`
|
||||
Env []string `json:"env,omitempty"`
|
||||
Dir string `json:"dir,omitempty"`
|
||||
|
||||
// URL — a streamable-HTTP endpoint. It is fetched through
|
||||
// internal/webfetch, so the SSRF guard, the redirect cap, the size cap and
|
||||
// the one-request-per-host-per-second limit all apply.
|
||||
URL string `json:"url,omitempty"`
|
||||
|
||||
// AllowPrivate — let THIS server be a loopback or LAN address. The Vikunja
|
||||
// server on homesrv is "http://localhost:9100/mcp", which is refused
|
||||
// without this flag. Understand what it means before setting it: a local
|
||||
// server is a DIFFERENT trust level from a public one. It is inside the
|
||||
// network, it usually needs no credential, and it can change things that
|
||||
// matter — so an argument the router got wrong lands somewhere real. Set it
|
||||
// only for a server you run yourself, and prefer allow_tools with it.
|
||||
AllowPrivate bool `json:"allow_private,omitempty"`
|
||||
|
||||
// AllowTools — when set, the ONLY remote tool names taken from this server.
|
||||
// This is the knob that keeps the catalogue deliberate: the resident model
|
||||
// is a 1.7B with a 4096-token context, and a tool name it half-remembers is
|
||||
// a wrong act, so fewer and better-chosen beats complete.
|
||||
AllowTools []string `json:"allow_tools,omitempty"`
|
||||
|
||||
// MaxTools — cap on this server's contribution. 0 ⇒ mcp.DefaultMaxTools (12).
|
||||
MaxTools int `json:"max_tools,omitempty"`
|
||||
|
||||
// Timeout — per-call budget for this server. 0 ⇒ MCPConfig.Timeout.
|
||||
Timeout Duration `json:"timeout,omitempty"`
|
||||
|
||||
// Headers — sent verbatim on every request to a url server. This is how a
|
||||
// bearer token reaches a real remote MCP server: {"Authorization": "Bearer
|
||||
// ${MCP_TOKEN}"}, with the value in the gitignored env file like the
|
||||
// telegram credentials. The Vikunja server on homesrv needs none only
|
||||
// because it is unauthenticated on loopback.
|
||||
Headers map[string]string `json:"headers,omitempty"`
|
||||
|
||||
// Enabled — false (the default) keeps a configured server described but
|
||||
// dark, so a block can be written and reviewed before it is switched on.
|
||||
Enabled bool `json:"enabled,omitempty"`
|
||||
}
|
||||
|
||||
// MCPServers maps the config blocks onto the mcp package's own type. It lives
|
||||
// here so config validation and daemon wiring cannot drift on the mapping.
|
||||
// Returns nil when nothing is configured or nothing is enabled.
|
||||
//
|
||||
// Disabled servers are dropped here, which is why validation does NOT use this
|
||||
// list — see allMCPServers.
|
||||
func (c *Config) MCPServers() []mcp.ServerConfig {
|
||||
return c.mcpServers(true)
|
||||
}
|
||||
|
||||
// allMCPServers is every configured server, enabled or not, for validation.
|
||||
//
|
||||
// Validating only the enabled ones meant a block with both command and url, or
|
||||
// a bare hostname as the url, passed startup validation while it was dark. The
|
||||
// doc on Enabled says a block can be written and reviewed before it is switched
|
||||
// on; the review the config layer could give was the one thing skipped. Enabled
|
||||
// gates the dialing, not the shape check.
|
||||
func (c *Config) allMCPServers() []mcp.ServerConfig {
|
||||
return c.mcpServers(false)
|
||||
}
|
||||
|
||||
func (c *Config) mcpServers(onlyEnabled bool) []mcp.ServerConfig {
|
||||
if c.MCP == nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]mcp.ServerConfig, 0, len(c.MCP.Servers))
|
||||
for _, s := range c.MCP.Servers {
|
||||
if onlyEnabled && !s.Enabled {
|
||||
continue
|
||||
}
|
||||
timeout := time.Duration(s.Timeout)
|
||||
if timeout <= 0 {
|
||||
timeout = time.Duration(c.MCP.Timeout)
|
||||
}
|
||||
out = append(out, mcp.ServerConfig{
|
||||
Name: s.Name,
|
||||
Command: s.Command,
|
||||
Args: s.Args,
|
||||
Env: s.Env,
|
||||
Dir: s.Dir,
|
||||
URL: s.URL,
|
||||
AllowPrivate: s.AllowPrivate,
|
||||
AllowTools: s.AllowTools,
|
||||
MaxTools: s.MaxTools,
|
||||
Headers: s.Headers,
|
||||
Timeout: timeout,
|
||||
Enabled: s.Enabled,
|
||||
})
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// validateMCP fails a block with a typo (no name, both command and url, a bare
|
||||
// hostname as the url) at startup, rather than at the first turn that needed
|
||||
// the tool.
|
||||
func (c *Config) validateMCP() error {
|
||||
return mcp.Validate(c.allMCPServers())
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package config
|
||||
|
||||
import "time"
|
||||
|
||||
// The two blocks that shape what reaches him unasked: how nudges are batched,
|
||||
// and whether a routine Maven inferred by herself may be announced at all.
|
||||
|
||||
// DigestConfig — notification batching / digest mode. When enabled, eligible
|
||||
// nudges (severity ≤ SeverityCeiling) are queued in memory instead of sent
|
||||
// immediately. Every Window duration (or when MaxItems reached), the queue is
|
||||
// flushed as a single digest notification. nil ⇒ digest disabled (legacy
|
||||
// behaviour — every nudge is sent as it fires).
|
||||
type DigestConfig struct {
|
||||
Enabled bool `json:"enabled,omitempty"`
|
||||
Window Duration `json:"window,omitempty"` // e.g. "30m"
|
||||
MaxItems int `json:"max_items,omitempty"` // flush at this count
|
||||
SeverityCeiling int `json:"severity_ceiling,omitempty"` // max sev batched
|
||||
}
|
||||
|
||||
// Digest defaults, applied in normaliseDigest.
|
||||
const (
|
||||
DefaultDigestWindow = 30 * time.Minute
|
||||
DefaultDigestMaxItems = 5
|
||||
DefaultDigestSeverityCeiling = 2
|
||||
)
|
||||
|
||||
// normaliseDigest is the one block that does NOT fold an absent block to nil:
|
||||
// it materialises a disabled one instead, because the dispatcher reads
|
||||
// c.Digest.Enabled without a nil check.
|
||||
func (c *Config) normaliseDigest() {
|
||||
if c.Digest == nil {
|
||||
c.Digest = &DigestConfig{Enabled: false}
|
||||
}
|
||||
if c.Digest.Window == 0 {
|
||||
c.Digest.Window = Duration(DefaultDigestWindow)
|
||||
}
|
||||
if c.Digest.MaxItems == 0 {
|
||||
c.Digest.MaxItems = DefaultDigestMaxItems
|
||||
}
|
||||
if c.Digest.SeverityCeiling == 0 {
|
||||
c.Digest.SeverityCeiling = DefaultDigestSeverityCeiling
|
||||
}
|
||||
}
|
||||
|
||||
// PatternProposalConfig — announcement policy for routines the digestion tick
|
||||
// inferred by itself (Vikunja #247, #43).
|
||||
//
|
||||
// Detection is always on and always silent by default: the tick writes a
|
||||
// proposed_routines row and the /routines page shows it. Notify is what turns
|
||||
// "she noticed" into "she said something", and it is OFF unless configured —
|
||||
// Maven is not a nag and not autonomous, so a behaviour that speaks without
|
||||
// being asked has to be switched on deliberately, like weather and telegram.
|
||||
//
|
||||
// When Notify is on, the announcement is still heavily restrained:
|
||||
// - at most one proposal per tick, however many were detected;
|
||||
// - at most one per Cooldown across all pairs (not per pair), so a batch of
|
||||
// freshly-detected patterns cannot turn into a queue of interruptions;
|
||||
// - through the ordinary care-class gate (quiet hours / away / snooze), at
|
||||
// sev1 — the lowest severity there is. A proposal is the least urgent
|
||||
// thing Maven can say.
|
||||
//
|
||||
// A pair is only ever announced once, because it is only ever proposed once:
|
||||
// proposed_routines is UNIQUE(action, object) and the row survives dismissal.
|
||||
type PatternProposalConfig struct {
|
||||
// Notify — announce newly inferred routines. Default false.
|
||||
Notify bool `json:"notify,omitempty"`
|
||||
|
||||
// Cooldown — minimum spacing between two proposal announcements. 0 ⇒
|
||||
// DefaultProposalCooldown (24h).
|
||||
Cooldown Duration `json:"cooldown,omitempty"`
|
||||
}
|
||||
|
||||
// DefaultProposalCooldown — one inferred-routine announcement per day at
|
||||
// most. A proposal is never urgent; if two patterns surface in the same
|
||||
// hour, the second one waits, and the /routines page has it either way.
|
||||
const DefaultProposalCooldown = 24 * time.Hour
|
||||
|
||||
// AnnounceProposals reports whether inferred routines may be announced. Safe
|
||||
// on a nil receiver — an absent config block means silent detection.
|
||||
func (p *PatternProposalConfig) AnnounceProposals() bool {
|
||||
return p != nil && p.Notify
|
||||
}
|
||||
|
||||
// normalisePatternProposals leaves an absent block nil, which means silent
|
||||
// detection. A present-but-partial one gets the cooldown, so `{"notify": true}`
|
||||
// is enough to switch announcements on.
|
||||
func (c *Config) normalisePatternProposals() {
|
||||
if c.PatternProposals != nil && c.PatternProposals.Cooldown <= 0 {
|
||||
c.PatternProposals.Cooldown = Duration(DefaultProposalCooldown)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// PhraserConfig — the LLM-backed phraser seam. The daemon spawns llama-server
|
||||
// as a managed subprocess and sends chat-completion requests to phrase nudge
|
||||
// and reminder messages. nil ⇒ the template-based Stub is used instead.
|
||||
//
|
||||
// ModelPath is the only required field. cmd/mavend fills BinPath
|
||||
// ("llama-server", found via PATH at spawn time) and Listen ("127.0.0.1:0", a
|
||||
// random port read back off stderr) when they are empty.
|
||||
//
|
||||
// Nothing in this package defaults the rest, and an omitted numeric field
|
||||
// reaches llama-server as a zero. That matters most for NGpuLayers: see below.
|
||||
type PhraserConfig struct {
|
||||
ModelPath string `json:"model_path"`
|
||||
BinPath string `json:"bin_path,omitempty"`
|
||||
Listen string `json:"listen,omitempty"`
|
||||
|
||||
// NGpuLayers — layers offloaded to the GPU, passed straight through as
|
||||
// `-ngl`. Omitted ⇒ 0, which is CPU-only inference.
|
||||
//
|
||||
// phraser.DefaultConfig says -1 (offload everything), but nothing calls it:
|
||||
// cmd/mavend builds a phraser.Config literally and copies this field across.
|
||||
// So the deploy config carries `"n_gpu_layers": 99` and must keep carrying
|
||||
// it. Vikunja has the discrepancy; do not "fix" it by writing a default
|
||||
// here, because that would change what a box without the key does.
|
||||
NGpuLayers int `json:"n_gpu_layers,omitempty"`
|
||||
|
||||
// NCtx — context window, passed through as `-c`. Omitted ⇒ 0, which lets
|
||||
// llama-server pick. The resident model is a Thinking variant and needs
|
||||
// 4096; the deploy config sets it.
|
||||
NCtx int `json:"n_ctx,omitempty"`
|
||||
|
||||
// Timeout — per-request budget. Omitted ⇒ phraser's own default.
|
||||
Timeout Duration `json:"timeout,omitempty"`
|
||||
|
||||
// CacheRAMMiB bounds llama-server's prompt cache. Omitted ⇒ 512 MiB, which
|
||||
// is what keeps the resident model near 1 GB of RSS instead of the 7.9 GB
|
||||
// measured on 2026-08-03. Set it to -1 to pass no flag at all and let the
|
||||
// server apply its own 8 GiB default. See phraser.Config.CacheRAMMiB.
|
||||
CacheRAMMiB int `json:"cache_ram_mib,omitempty"`
|
||||
|
||||
// LLMNudges — let the model word nudges again. Off by default: nudges are
|
||||
// worded from hand-written Russian templates now (the model broke the
|
||||
// persona and invented units). Chat, query and reminder phrasing always go
|
||||
// through the model regardless. See phraser.Config.LLMNudges.
|
||||
LLMNudges bool `json:"llm_nudges,omitempty"`
|
||||
|
||||
// SwapModels — the gguf files the running daemon is allowed to swap to
|
||||
// without a restart (Vikunja #250). Empty (the default) means the swap
|
||||
// capability does not exist: ipc.MethodSwapModel answers ErrUnknownMethod,
|
||||
// exactly like an unconfigured weather or telegram block.
|
||||
//
|
||||
// It is an allowlist and not a directory on purpose. The request carries a
|
||||
// path, and llama-server is started with it as `-m`; anything short of an
|
||||
// exact match against a list a human wrote in this file would make "swap the
|
||||
// model" mean "load a file of your choosing off my disk". ModelPath is
|
||||
// always swappable back to whether or not it is listed.
|
||||
//
|
||||
// Paths must be absolute — the daemon's working directory is not the
|
||||
// operator's, and a relative path here would resolve somewhere surprising.
|
||||
SwapModels []string `json:"swap_models,omitempty"`
|
||||
}
|
||||
|
||||
// validatePhraser refuses a block with no model, and a swap allowlist entry
|
||||
// that would resolve somewhere other than where a reader of this file expects.
|
||||
func (c *Config) validatePhraser() error {
|
||||
if c.Phraser == nil {
|
||||
return nil
|
||||
}
|
||||
if c.Phraser.ModelPath == "" {
|
||||
return errors.New("phraser.model_path is required")
|
||||
}
|
||||
// A relative entry in the swap allowlist would resolve against the
|
||||
// daemon's working directory, so the path a human reads in this file
|
||||
// would not be the path llama-server is handed. Fail at startup.
|
||||
for _, m := range c.Phraser.SwapModels {
|
||||
if !filepath.IsAbs(m) {
|
||||
return fmt.Errorf("phraser.swap_models: %q must be an absolute path", m)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/morning"
|
||||
"github.com/robfig/cron/v3"
|
||||
)
|
||||
|
||||
// The two scheduled things Maven says on her own, plus the window she does not
|
||||
// say them in. Both routine kinds default to severity 1 — the care class, which
|
||||
// quiet hours suppress and which never reaches an away channel. A routine that
|
||||
// speaks at 3am has to be written that way on purpose.
|
||||
|
||||
// RoutineConfig — one scheduled routine. Cron is a standard 5-field expression
|
||||
// ("0 8 * * *" = 08:00 daily). Body is the RU text delivered verbatim (routines
|
||||
// are not LLM-phrased). Severity (1-4, default 1) drives routing: care-class
|
||||
// (≤2) is suppressed by quiet hours and drops when away; ops-class reaches away
|
||||
// channels.
|
||||
type RoutineConfig struct {
|
||||
Name string `json:"name"`
|
||||
Cron string `json:"cron"`
|
||||
Body string `json:"body"`
|
||||
Severity int `json:"severity,omitempty"`
|
||||
}
|
||||
|
||||
// MorningRoutineConfig — one daily checklist. WindowStart/WindowEnd/NudgeAt
|
||||
// are "HH:MM" local time; NudgeAt empty defaults to WindowEnd. Weekdays are
|
||||
// 0=Sunday..6=Saturday; empty means every day (set two routines under
|
||||
// different names for weekday/weekend variants).
|
||||
type MorningRoutineConfig struct {
|
||||
Name string `json:"name"`
|
||||
Weekdays []int `json:"weekdays,omitempty"`
|
||||
WindowStart string `json:"window_start"`
|
||||
WindowEnd string `json:"window_end"`
|
||||
NudgeAt string `json:"nudge_at,omitempty"`
|
||||
Severity int `json:"severity,omitempty"`
|
||||
Items []MorningRoutineItemConfig `json:"items"`
|
||||
}
|
||||
|
||||
// MorningRoutineItemConfig — one checklist entry. FactKey is the fact whose
|
||||
// presence within the window counts as completion evidence.
|
||||
type MorningRoutineItemConfig struct {
|
||||
Key string `json:"key"`
|
||||
FactKey string `json:"fact_key"`
|
||||
Label string `json:"label"`
|
||||
// Optional — this one being skipped does not earn a nudge. Default false,
|
||||
// so a routine written before 04-08-2026 keeps behaving as it did.
|
||||
Optional bool `json:"optional,omitempty"`
|
||||
}
|
||||
|
||||
// QuietHoursConfig — a recurring daily quiet-window. Times are local to the
|
||||
// server's wall clock. A window crossing midnight (Start > End) is handled:
|
||||
// "23:00"-"08:00" means quiet from 23:00 to 08:00 the next day.
|
||||
type QuietHoursConfig struct {
|
||||
Start string `json:"start,omitempty"` // "HH:MM" local time, e.g. "23:00"
|
||||
End string `json:"end,omitempty"` // "HH:MM" local time, e.g. "08:00"
|
||||
}
|
||||
|
||||
// normaliseRoutines defaults both routine kinds to severity 1, the care class:
|
||||
// the safe floor, so a misconfigured routine cannot blast an away channel at
|
||||
// 3am.
|
||||
func (c *Config) normaliseRoutines() {
|
||||
for i := range c.Routines {
|
||||
if c.Routines[i].Severity == 0 {
|
||||
c.Routines[i].Severity = 1
|
||||
}
|
||||
}
|
||||
for i := range c.MorningRoutines {
|
||||
if c.MorningRoutines[i].Severity == 0 {
|
||||
c.MorningRoutines[i].Severity = 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// validateRoutines fails a routine that could never fire. A typo in a cron
|
||||
// expression or a missing body should stop the daemon at startup, not go
|
||||
// unnoticed as silence at 08:00 every day.
|
||||
func (c *Config) validateRoutines() error {
|
||||
for _, r := range c.Routines {
|
||||
if r.Name == "" {
|
||||
return errors.New("routine: name is required")
|
||||
}
|
||||
if r.Body == "" {
|
||||
return fmt.Errorf("routine %q: body is required", r.Name)
|
||||
}
|
||||
if _, err := cron.ParseStandard(r.Cron); err != nil {
|
||||
return fmt.Errorf("routine %q: bad cron %q: %w", r.Name, r.Cron, err)
|
||||
}
|
||||
}
|
||||
if len(c.MorningRoutines) > 0 {
|
||||
if err := morning.Validate(morningRoutinesFromConfig(c.MorningRoutines)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// morningRoutinesFromConfig maps the config's morning-routine blocks to the
|
||||
// engine type. Shared with the daemon so config validation and daemon wiring
|
||||
// can never drift on the mapping.
|
||||
func morningRoutinesFromConfig(mc []MorningRoutineConfig) []morning.Routine {
|
||||
out := make([]morning.Routine, len(mc))
|
||||
for i, r := range mc {
|
||||
items := make([]morning.Item, len(r.Items))
|
||||
for j, it := range r.Items {
|
||||
items[j] = morning.Item{Key: it.Key, FactKey: it.FactKey, Label: it.Label, Optional: it.Optional}
|
||||
}
|
||||
weekdays := make([]time.Weekday, len(r.Weekdays))
|
||||
for j, w := range r.Weekdays {
|
||||
weekdays[j] = time.Weekday(w)
|
||||
}
|
||||
out[i] = morning.Routine{
|
||||
Name: r.Name,
|
||||
Weekdays: weekdays,
|
||||
WindowStart: r.WindowStart,
|
||||
WindowEnd: r.WindowEnd,
|
||||
NudgeAt: r.NudgeAt,
|
||||
Severity: r.Severity,
|
||||
Items: items,
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// MorningRoutinesFromConfig is the exported form daemon wiring uses.
|
||||
func MorningRoutinesFromConfig(mc []MorningRoutineConfig) []morning.Routine {
|
||||
return morningRoutinesFromConfig(mc)
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/vision"
|
||||
)
|
||||
|
||||
// The senses: seeing, hearing, and knowing who spoke. All three are off unless
|
||||
// someone typed a path on purpose, and all three depend on the media block —
|
||||
// nothing in this repo holds an image or a recording only in memory.
|
||||
|
||||
// MediaConfig — the on-disk blob store for images and captured audio
|
||||
// (internal/media). It is shared by all three senses: vision intake, meeting
|
||||
// capture, and speaker enrolment samples all write here.
|
||||
//
|
||||
// Absent ⇒ off, and off means Maven cannot accept an image or start a recording
|
||||
// at all. That default is deliberate: a capability that keeps photos and audio of
|
||||
// people on disk should require someone to have typed a path.
|
||||
type MediaConfig struct {
|
||||
// Dir — the blob store root, created 0700. Relative paths resolve against
|
||||
// StateDir. Required; an empty dir means the store is not wired.
|
||||
Dir string `json:"dir,omitempty"`
|
||||
|
||||
// Retention — how long a blob is kept before the tick prunes it. 0 ⇒
|
||||
// media.DefaultRetention (7 days). This is the knob that stops recordings
|
||||
// of people accumulating; raising it past a few weeks should need a reason.
|
||||
Retention Duration `json:"retention,omitempty"`
|
||||
|
||||
// MaxBytes — per-blob cap. 0 ⇒ media.DefaultMaxBytes (64 MiB).
|
||||
MaxBytes int64 `json:"max_bytes,omitempty"`
|
||||
|
||||
// MaxTotalBytes — whole-store cap. 0 ⇒ media.DefaultMaxTotalBytes (4 GiB).
|
||||
// The per-blob cap bounds one call; this one bounds the sum of them, which
|
||||
// is what actually decides whether the disk mavend's database lives on can
|
||||
// be filled from outside.
|
||||
MaxTotalBytes int64 `json:"max_total_bytes,omitempty"`
|
||||
}
|
||||
|
||||
// StoreDir reports the configured blob directory, or "" when media is not
|
||||
// wired. Safe on a nil receiver.
|
||||
func (m *MediaConfig) StoreDir() string {
|
||||
if m == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(m.Dir)
|
||||
}
|
||||
|
||||
// validateMedia fails a media dir that cannot be created here rather than at
|
||||
// wiring time. A capability silently not existing is the hardest kind of
|
||||
// misconfiguration to notice.
|
||||
func (c *Config) validateMedia() error {
|
||||
if c.Media == nil {
|
||||
return nil
|
||||
}
|
||||
if c.Media.StoreDir() == "" {
|
||||
return errors.New("media.dir is required when a media block is present")
|
||||
}
|
||||
if c.Media.MaxBytes < 0 || c.Media.MaxTotalBytes < 0 {
|
||||
return errors.New("media: max_bytes and max_total_bytes cannot be negative")
|
||||
}
|
||||
if c.Media.MaxTotalBytes > 0 && c.Media.MaxBytes > c.Media.MaxTotalBytes {
|
||||
return fmt.Errorf("media: max_bytes %d is above max_total_bytes %d",
|
||||
c.Media.MaxBytes, c.Media.MaxTotalBytes)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// VisionConfig — the vision provider (internal/vision, docs/plans/07-vision.md).
|
||||
//
|
||||
// Absent, or enabled=false, ⇒ the daemon wires vision.Disabled and every attempt
|
||||
// to look at an image answers that vision is not set up. There is no cloud
|
||||
// option in this block on purpose: Endpoint must be a loopback or private
|
||||
// address and internal/vision refuses anything else at startup, because
|
||||
// inference stays on the box and a photo of his flat is the last thing to make
|
||||
// an exception for.
|
||||
type VisionConfig struct {
|
||||
// Enabled — may she look at images. Default false.
|
||||
Enabled bool `json:"enabled,omitempty"`
|
||||
|
||||
// Endpoint — base URL of a llama-server running a vision model with its
|
||||
// mmproj, e.g. "http://127.0.0.1:8081". Loopback / private only.
|
||||
Endpoint string `json:"endpoint,omitempty"`
|
||||
|
||||
// Model — model name sent in the request. llama-server ignores it.
|
||||
Model string `json:"model,omitempty"`
|
||||
|
||||
// MaxDim — longest edge the image is scaled to before inference. 0 ⇒
|
||||
// media.DefaultMaxDim (896).
|
||||
MaxDim int `json:"max_dim,omitempty"`
|
||||
|
||||
// MaxTokens — cap on the description. 0 ⇒ vision.DefaultMaxTokens (300).
|
||||
MaxTokens int `json:"max_tokens,omitempty"`
|
||||
|
||||
// Timeout — per-description budget. 0 ⇒ vision.DefaultTimeout (90s). A small
|
||||
// VLM on an iGPU is slow; a tight timeout here just means no answer ever.
|
||||
Timeout Duration `json:"timeout,omitempty"`
|
||||
|
||||
// Prompt — the default question when he only sent a picture. Empty ⇒
|
||||
// vision.DefaultPrompt (Russian, "опиши что на изображении").
|
||||
Prompt string `json:"prompt,omitempty"`
|
||||
}
|
||||
|
||||
// LooksAtImages reports whether vision is configured well enough to try. Safe on
|
||||
// a nil receiver, and false without an endpoint — enabled with nothing to talk
|
||||
// to is a misconfiguration, not a capability.
|
||||
func (v *VisionConfig) LooksAtImages() bool {
|
||||
return v != nil && v.Enabled && strings.TrimSpace(v.Endpoint) != ""
|
||||
}
|
||||
|
||||
// validateVision fails an endpoint that is a typo, or a vision block with
|
||||
// nowhere to keep the bytes, at startup.
|
||||
func (c *Config) validateVision() error {
|
||||
if c.Vision == nil || !c.Vision.Enabled {
|
||||
return nil
|
||||
}
|
||||
if strings.TrimSpace(c.Vision.Endpoint) == "" {
|
||||
return errors.New("vision.enabled set but vision.endpoint is empty")
|
||||
}
|
||||
if err := vision.ValidateEndpoint(c.Vision.Endpoint); err != nil {
|
||||
return err
|
||||
}
|
||||
if c.Media.StoreDir() == "" {
|
||||
return errors.New("vision.enabled set but there is no media block to keep the bytes in")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CaptureConfig — the meeting recorder (internal/capture,
|
||||
// docs/plans/08-hearing.md).
|
||||
//
|
||||
// Absent, or enabled=false, ⇒ the recorder is not wired and the capture methods
|
||||
// return "unknown method", so no client can start a recording however it asks.
|
||||
// A media block is required too: audio is never held only in memory.
|
||||
//
|
||||
// There is deliberately no "auto", no keyword trigger and no duration default
|
||||
// long enough to be forgotten about. Recording other people is an explicit act
|
||||
// with a start, a stop, and a cap.
|
||||
type CaptureConfig struct {
|
||||
// Enabled — may she record a meeting when asked. Default false.
|
||||
Enabled bool `json:"enabled,omitempty"`
|
||||
|
||||
// MaxMinutes — hard cap on one session; it stops itself there. 0 ⇒
|
||||
// capture.DefaultMaxDuration (120 minutes).
|
||||
MaxMinutes int `json:"max_minutes,omitempty"`
|
||||
|
||||
// STTWindow — audio handed to whisper per call. 0 ⇒
|
||||
// capture.DefaultSTTWindow (5m). Larger windows transcribe slightly better
|
||||
// and block the STT worker for longer.
|
||||
STTWindow Duration `json:"stt_window,omitempty"`
|
||||
|
||||
// ChunkRunes — transcript runes per summarisation prompt. 0 ⇒
|
||||
// capture.DefaultChunkRunes (3000), sized for the resident model's n_ctx of
|
||||
// 4096. Raise this only if the resident model's context grows.
|
||||
ChunkRunes int `json:"chunk_runes,omitempty"`
|
||||
|
||||
// MaxChunks — how many windows one meeting may be summarised in before the
|
||||
// transcript is truncated and the summary says so. 0 ⇒
|
||||
// capture.DefaultMaxChunks (40).
|
||||
MaxChunks int `json:"max_chunks,omitempty"`
|
||||
|
||||
// SaveTranscript — write the full transcript as a note alongside the
|
||||
// summary. Default false, and the cost is not disk: a note is embedded and
|
||||
// becomes recall corpus, so every later question can surface verbatim words
|
||||
// other people said in a room. That is the reason it takes a deliberate yes.
|
||||
// The audio blob is pruned by media.retention either way; the notes are not.
|
||||
//
|
||||
// A meeting with no summary writes its transcript regardless. The choice
|
||||
// here is transcript IN ADDITION to a summary, not whether the meeting is
|
||||
// remembered at all.
|
||||
SaveTranscript bool `json:"save_transcript,omitempty"`
|
||||
}
|
||||
|
||||
// Records reports whether the recorder should be wired. Safe on a nil receiver.
|
||||
func (c *CaptureConfig) Records() bool {
|
||||
return c != nil && c.Enabled
|
||||
}
|
||||
|
||||
// MaxDuration is the configured session cap as a duration, or 0 for the
|
||||
// package default. Safe on a nil receiver.
|
||||
func (c *CaptureConfig) MaxDuration() time.Duration {
|
||||
if c == nil || c.MaxMinutes <= 0 {
|
||||
return 0
|
||||
}
|
||||
return time.Duration(c.MaxMinutes) * time.Minute
|
||||
}
|
||||
|
||||
// validateCapture refuses a recorder with nowhere to keep the audio.
|
||||
func (c *Config) validateCapture() error {
|
||||
if c.Capture.Records() && c.Media.StoreDir() == "" {
|
||||
return errors.New("capture.enabled set but there is no media block to keep the audio in")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SpeakerConfig — voice identification (internal/speaker,
|
||||
// docs/plans/10-speaker-recognition.md).
|
||||
//
|
||||
// Absent, or enabled=false, ⇒ no voiceprint is computed for any turn, the
|
||||
// enrolment methods do not exist, and nobody can be enrolled. A voiceprint is
|
||||
// biometric data about a person, so this one is off until someone typed a model
|
||||
// path on purpose.
|
||||
//
|
||||
// It cannot currently be turned on: there is no speaker-embedding model on this
|
||||
// box. See the plan document for what to download.
|
||||
type SpeakerConfig struct {
|
||||
// Enabled — may she work out who is speaking. Default false.
|
||||
Enabled bool `json:"enabled,omitempty"`
|
||||
|
||||
// ModelPath — an ECAPA-TDNN (or equivalent) speaker-embedding ONNX model.
|
||||
// Required; without it the recognizer runs disabled and says so once.
|
||||
ModelPath string `json:"model_path,omitempty"`
|
||||
|
||||
// LibPath — onnxruntime shared library, as for the text embedder. Empty ⇒
|
||||
// the same default the embedder block uses.
|
||||
//
|
||||
// Nothing reads it yet: cmd/mavend's newSpeakerEmbedder discards the whole
|
||||
// block, because there is no speaker model on this box to load. It stays
|
||||
// declared so the block a reader writes matches the plan document.
|
||||
LibPath string `json:"lib_path,omitempty"`
|
||||
|
||||
// Threshold — cosine similarity a match must beat. 0 ⇒
|
||||
// speaker.DefaultThreshold (0.7). Lower it and she starts calling guests by
|
||||
// his name, which is the expensive direction of this error.
|
||||
Threshold float64 `json:"threshold,omitempty"`
|
||||
|
||||
// MinSeconds — least speech an identification will look at. 0 ⇒
|
||||
// speaker.DefaultMinSeconds (2s).
|
||||
MinSeconds float64 `json:"min_seconds,omitempty"`
|
||||
}
|
||||
|
||||
// Recognizes reports whether voice identification should be wired. Safe on a
|
||||
// nil receiver, and false without a model path — enabled with nothing to embed
|
||||
// with is a misconfiguration, not a capability.
|
||||
func (s *SpeakerConfig) Recognizes() bool {
|
||||
return s != nil && s.Enabled && strings.TrimSpace(s.ModelPath) != ""
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The blocks nested under voice: the two worker seams, the embedder the
|
||||
// classifier scores with, the weather provider and the act allowlist. They live
|
||||
// here because none of them is reachable except through a voice block.
|
||||
|
||||
// WorkerConfig — a unix-socket worker module connection. Used by Stt and
|
||||
// (via TtsConfig embedding the same fields) by Tts. Socket is the unix
|
||||
// socket path the worker module listens on (e.g.
|
||||
// /run/user/$UID/maven/stt.sock). Lang overrides the surface default for
|
||||
// this module when the user wants different langs for stt vs tts (rare).
|
||||
type WorkerConfig struct {
|
||||
Socket string `json:"socket,omitempty"`
|
||||
Lang string `json:"lang,omitempty"`
|
||||
}
|
||||
|
||||
// TtsConfig — the tts worker module connection + tts-specific Voice field
|
||||
// (a named voice when the worker supports multiple; "" ⇒ the worker's
|
||||
// configured default).
|
||||
type TtsConfig struct {
|
||||
Socket string `json:"socket,omitempty"`
|
||||
Lang string `json:"lang,omitempty"`
|
||||
Voice string `json:"voice,omitempty"`
|
||||
}
|
||||
|
||||
// EmbedderConfig — paths for the ONNX multilingual embedder. The daemon
|
||||
// constructs an in-process ONNX embedder when all three paths are non-empty;
|
||||
// the router's classifier then uses real sentence embeddings instead of the
|
||||
// floor HashEmbedder stub. Model_path is the ONNX model file, tokenizer_path
|
||||
// is tokenizer.json (Unigram), lib_path is the ONNX Runtime shared library.
|
||||
type EmbedderConfig struct {
|
||||
ModelPath string `json:"model_path,omitempty"`
|
||||
TokenizerPath string `json:"tokenizer_path,omitempty"`
|
||||
LibPath string `json:"lib_path,omitempty"`
|
||||
}
|
||||
|
||||
// WeatherConfig configures the weather provider for voice queries.
|
||||
type WeatherConfig struct {
|
||||
Provider string `json:"provider,omitempty"` // "open-meteo" or "" → stub
|
||||
DefaultLocation string `json:"default_location,omitempty"` // e.g. "Moscow"
|
||||
}
|
||||
|
||||
// ToolConfig — one enabled tool. Name is the spoken verb ("restart"); Cmd is
|
||||
// the fixed argv prefix (["systemctl","restart"]); Destructive marks acts that
|
||||
// must not fire from the voice path (they need a confirm on an authed surface).
|
||||
type ToolConfig struct {
|
||||
Name string `json:"name"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
Cmd []string `json:"cmd"`
|
||||
Destructive bool `json:"destructive,omitempty"`
|
||||
}
|
||||
|
||||
// Voice defaults, applied in normaliseVoice.
|
||||
const (
|
||||
DefaultRouterThreshold = 0.55
|
||||
DefaultQueryMinScore = 0.55
|
||||
// Read off the margin sweep in internal/memory/recalleval on the e5
|
||||
// embedder: 0.008 answers 68% of real questions (down from 72%) and cuts
|
||||
// false recall from 5/5 to 1/5. Every larger delta costs real recall
|
||||
// without removing that last one until 0.020, which drops recall to 44%.
|
||||
DefaultQueryMinMargin = 0.008
|
||||
// DefaultClarifyMaxAttempts — see dialogue.DefaultMaxAttempts.
|
||||
DefaultClarifyMaxAttempts = 3
|
||||
DefaultToolTimeout = 30 * time.Second
|
||||
// DefaultLLMRouter — route with the resident model unless told otherwise.
|
||||
DefaultLLMRouter = true
|
||||
)
|
||||
|
||||
// UseLLMRouter reports whether to route with the resident model. Unset means
|
||||
// on; only an explicit false in the config turns it off.
|
||||
func (v *VoiceConfig) UseLLMRouter() bool {
|
||||
if v == nil || v.LLMRouter == nil {
|
||||
return DefaultLLMRouter
|
||||
}
|
||||
return *v.LLMRouter
|
||||
}
|
||||
|
||||
// normaliseVoice applies the block's defaults. An absent block stays nil: the
|
||||
// surface is off and there is nothing to tune.
|
||||
func (c *Config) normaliseVoice() {
|
||||
if c.Voice == nil {
|
||||
return
|
||||
}
|
||||
if c.Voice.RouterThreshold <= 0 {
|
||||
c.Voice.RouterThreshold = DefaultRouterThreshold
|
||||
}
|
||||
if c.Voice.QueryMinScore <= 0 {
|
||||
c.Voice.QueryMinScore = DefaultQueryMinScore
|
||||
}
|
||||
// Unset ⇒ default. Negative is how you turn the margin off on purpose,
|
||||
// so it is clamped to 0 rather than replaced by the default.
|
||||
switch {
|
||||
case c.Voice.QueryMinMargin == 0:
|
||||
c.Voice.QueryMinMargin = DefaultQueryMinMargin
|
||||
case c.Voice.QueryMinMargin < 0:
|
||||
c.Voice.QueryMinMargin = 0
|
||||
}
|
||||
if c.Voice.ClarifyMaxAttempts <= 0 {
|
||||
c.Voice.ClarifyMaxAttempts = DefaultClarifyMaxAttempts
|
||||
}
|
||||
if c.Voice.ToolTimeout <= 0 {
|
||||
c.Voice.ToolTimeout = Duration(DefaultToolTimeout)
|
||||
}
|
||||
if c.Voice.LLMRouter == nil {
|
||||
on := DefaultLLMRouter
|
||||
c.Voice.LLMRouter = &on
|
||||
}
|
||||
}
|
||||
|
||||
// validateVoice refuses a surface that would listen nowhere, and an embedder
|
||||
// block with only some of its three paths filled in.
|
||||
func (c *Config) validateVoice() error {
|
||||
if c.Voice == nil || !c.Voice.Enabled {
|
||||
return nil
|
||||
}
|
||||
if c.Voice.Bind == "" {
|
||||
return errors.New("voice.enabled set but voice.bind is empty — refusing to start a voice surface with no bind address")
|
||||
}
|
||||
if e := c.Voice.Embedder; e != nil {
|
||||
if e.ModelPath == "" || e.TokenizerPath == "" || e.LibPath == "" {
|
||||
return errors.New("voice.embedder: all three of model_path, tokenizer_path, lib_path must be set, or remove embedder to use the floor stub")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// VoiceConfig — the client↔core TCP surface + the stt/tts worker-module
|
||||
// seams.
|
||||
//
|
||||
// Enabled gates wiring; Bind is the TCP address (inside the wg tunnel in
|
||||
// production; "127.0.0.1:9100" for the local smoke). Lang is the default
|
||||
// language hint passed to both stt and tts (per-call overrides later).
|
||||
//
|
||||
// Stt and Tts are the worker-module seams. nil Stt ⇒ daemon wires the
|
||||
// in-process stt.Stub (the "no models on disk" floor — the loop is
|
||||
// exercisable end-to-end with a deterministic no-model transcriber).
|
||||
// non-nil Stt with Socket ⇒ daemon wires stt.Remote dialing that unix
|
||||
// socket (cmd/mavsttd serves the other end; production swaps in a
|
||||
// faster-whisper handler in cmd/mavsttd, no daemon or stt-package
|
||||
// change). Tts mirrors for tts.Remote + cmd/mavttsd.
|
||||
//
|
||||
// Embedder configures the router's sentence embedder. When all three
|
||||
// paths are set, the daemon constructs an ONNX multilingual embedder
|
||||
// (in-process); when nil, it falls back to the floor HashEmbedder stub
|
||||
// (deterministic, no model files required — good for CI and smoke).
|
||||
//
|
||||
// The daemon refuses to start if Voice.Enabled but Bind is empty — the
|
||||
// bind is the one operational config the surface can't default (127.0.0.1
|
||||
// is too relaxed for production, a wg-tunnel address is the user's);
|
||||
// surfacing the gap explicitly beats an idle listener the user thinks is
|
||||
// wired but isn't reachable.
|
||||
type VoiceConfig struct {
|
||||
Enabled bool `json:"enabled,omitempty"`
|
||||
Bind string `json:"bind,omitempty"`
|
||||
Lang string `json:"lang,omitempty"`
|
||||
Stt *WorkerConfig `json:"stt,omitempty"`
|
||||
Tts *TtsConfig `json:"tts,omitempty"`
|
||||
Embedder *EmbedderConfig `json:"embedder,omitempty"`
|
||||
|
||||
// RouterThreshold — the minimum confidence score for the intent classifier
|
||||
// (stage 3 gate). Below this → clarify, don't guess. 0 ⇒
|
||||
// DefaultRouterThreshold (0.55), tuned for the ONNX embedder; the
|
||||
// HashEmbedder floor scores lexically and may need a lower value.
|
||||
//
|
||||
// There is no way to ask for "permissive, never clarify" through this
|
||||
// field: normaliseVoice replaces anything ≤ 0 with the default, so a
|
||||
// written 0 is the default and a written negative is too.
|
||||
RouterThreshold float64 `json:"router_threshold,omitempty"`
|
||||
|
||||
// LLMRouter — route with the resident model instead of the embedding
|
||||
// classifier. On by default since Vikunja #320.
|
||||
//
|
||||
// Measured on the held-out fixture (docs/evals/2026-07-31-routing.md): 63.2% of
|
||||
// intents right against the classifier's 50.0%, and no route errors. It
|
||||
// costs about 1s per turn instead of 30ms.
|
||||
//
|
||||
// It is safe to leave on. The model can refuse — it answers "unknown" when
|
||||
// it cannot route, and the turn drops to the classifier and its clarify
|
||||
// gate. Any LLM error does the same, so a turn never breaks on the model.
|
||||
// Slot extraction runs on LLM decisions too, so acts get their Fn and
|
||||
// reminders their Time.
|
||||
//
|
||||
// Set it false to go back to the classifier, e.g. on a box with no
|
||||
// llama-server or when 1s a turn is too slow.
|
||||
//
|
||||
// It is a pointer so that "missing from the file" and "explicitly false"
|
||||
// are different things: missing means on, false means off. Read it with
|
||||
// UseLLMRouter(), not directly.
|
||||
LLMRouter *bool `json:"llm_router,omitempty"`
|
||||
|
||||
// QueryMinScore — the note-recall confidence gate. Top cosine below this
|
||||
// ⇒ "I don't know" instead of a guess. Tuned for the ONNX embedder (0.55);
|
||||
// the HashEmbedder floor scores lexically and may never clear it. 0.55
|
||||
// default if unset.
|
||||
QueryMinScore float64 `json:"query_min_score,omitempty"`
|
||||
|
||||
// QueryMinMargin — the second half of the recall gate: the top hit must
|
||||
// beat the runner-up by more than this. The absolute score above cannot do
|
||||
// the job on its own, because the e5 embedder puts every cosine in one
|
||||
// narrow high band, so a made-up question scores as high as a real one.
|
||||
// The margin asks whether one note is clearly the best instead.
|
||||
// Negative ⇒ off. 0 ⇒ the default below.
|
||||
QueryMinMargin float64 `json:"query_min_margin,omitempty"`
|
||||
|
||||
// ClarifyMaxAttempts — how many clarifying questions she may ask about one
|
||||
// request before she gives up and says she did not understand. Default 3.
|
||||
ClarifyMaxAttempts int `json:"clarify_max_attempts,omitempty"`
|
||||
|
||||
// Persona — optional prompt prefix that tunes maven's character. Prepended
|
||||
// to every LLM system prompt (nudge phrasing, note queries, general
|
||||
// knowledge). Empty string ⇒ current hardcoded persona (feminine-gendered
|
||||
// Russian self-reference). Example: "Be formal and answer in English only."
|
||||
Persona string `json:"persona,omitempty"`
|
||||
|
||||
// OwnerName / City — optional facts about the owner, added to the shared
|
||||
// context block (internal/persona). Empty is fine: the block still states
|
||||
// who he is grammatically (a man, addressed as "ты") and the current time.
|
||||
// Nothing about correct behaviour may depend on these being filled in.
|
||||
OwnerName string `json:"owner_name,omitempty"`
|
||||
City string `json:"city,omitempty"`
|
||||
|
||||
// Weather — the weather provider config. nil ⇒ the daemon wires
|
||||
// the stub provider (returns ErrNotConfigured — "погода не настроена").
|
||||
// Set provider to "open-meteo" to use the keyless Open-Meteo API.
|
||||
Weather *WeatherConfig `json:"weather,omitempty"`
|
||||
|
||||
// Tools — the enabled act allowlist. Each is a spoken verb → argv the
|
||||
// executor runs (args from the utterance appended). Editing this set is the
|
||||
// human-only "enable" act (per spec); maven can't add to it from a request.
|
||||
// Empty ⇒ every act is refused (nothing enabled).
|
||||
Tools []ToolConfig `json:"tools,omitempty"`
|
||||
|
||||
// ToolTimeout bounds each tool invocation. Zero ⇒ executor default (30s).
|
||||
ToolTimeout Duration `json:"tool_timeout,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// WorkstationConfig — the big model on the owner's desktop (workpc, a
|
||||
// 7900 GRE with 16GB), fronted by mavgpud.
|
||||
//
|
||||
// homesrv cannot grow a GPU, so the resident Qwen3-1.7B is the floor and this
|
||||
// is the preferred model above it (owner's call, 2026-08-02, docs/offload.md).
|
||||
// The workstation is never assumed up: its card is often held by a CPT run and
|
||||
// the machine sleeps. No block, or an empty URL, and homesrv behaves exactly as
|
||||
// it does today.
|
||||
//
|
||||
// Only the prompt crosses the LAN, and the workstation is not "the box". The
|
||||
// rules in CLAUDE.md about what may leave still apply.
|
||||
type WorkstationConfig struct {
|
||||
// URL — where mavgpud listens, e.g. "http://192.168.1.105:8080". Empty ⇒
|
||||
// the whole block is normalised to nil and nothing probes anything.
|
||||
URL string `json:"url,omitempty"`
|
||||
|
||||
// Health — the admission endpoint. Empty ⇒ URL + "/health", which is what
|
||||
// mavgpud serves. It answers 503 while the card is held, and that is the
|
||||
// signal, so it must be the supervisor's endpoint and not llama-server's.
|
||||
Health string `json:"health,omitempty"`
|
||||
|
||||
// Probe — how often admission is re-checked. 0 ⇒ DefaultWorkstationProbe.
|
||||
// Nothing on the hot path waits for it: the answer is cached and read
|
||||
// atomically, so this only sets how late Maven notices the card came back.
|
||||
Probe Duration `json:"probe,omitempty"`
|
||||
|
||||
// Timeout — the per-request budget for a completion on the workstation.
|
||||
// 0 ⇒ DefaultWorkstationTimeout. A big model on a LAN host is slower than
|
||||
// the resident one, and a request that overruns falls back to the floor.
|
||||
Timeout Duration `json:"timeout,omitempty"`
|
||||
}
|
||||
|
||||
// Workstation defaults, applied in normaliseWorkstation.
|
||||
const (
|
||||
DefaultWorkstationProbe = 15 * time.Second
|
||||
DefaultWorkstationTimeout = 90 * time.Second
|
||||
)
|
||||
|
||||
// normaliseWorkstation applies the block's defaults. No address, no preferred
|
||||
// model: an unconfigured workstation is the default deploy and must be
|
||||
// indistinguishable from today.
|
||||
func (c *Config) normaliseWorkstation() {
|
||||
if c.Workstation != nil && strings.TrimSpace(c.Workstation.URL) == "" {
|
||||
c.Workstation = nil
|
||||
}
|
||||
if c.Workstation == nil {
|
||||
return
|
||||
}
|
||||
w := c.Workstation
|
||||
if strings.TrimSpace(w.Health) == "" {
|
||||
w.Health = strings.TrimRight(w.URL, "/") + "/health"
|
||||
}
|
||||
if w.Probe <= 0 {
|
||||
w.Probe = Duration(DefaultWorkstationProbe)
|
||||
}
|
||||
if w.Timeout <= 0 {
|
||||
w.Timeout = Duration(DefaultWorkstationTimeout)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package config
|
||||
|
||||
import "strings"
|
||||
|
||||
// The two world sources in the query chain, in the order they answer: a live
|
||||
// SearXNG search first, the offline ZIMs behind it (owner's call, 2026-08-02).
|
||||
// Everything of HIS still comes first — the personal boundary runs above both,
|
||||
// so a question about him is never searched.
|
||||
//
|
||||
// Only the query string leaves the box in either case. Notes, facts, the
|
||||
// persona block and the history are never part of a request; neither
|
||||
// internal/websearch nor internal/kiwix can read the store.
|
||||
|
||||
// SearchConfig — the self-hosted SearXNG instance she searches with.
|
||||
//
|
||||
// External search is allowed and off unless configured (CLAUDE.md). Configuring
|
||||
// it is the whole opt-in: no `search` block, no query ever leaves the LAN.
|
||||
type SearchConfig struct {
|
||||
// URL — base address of the SearXNG instance, e.g. "http://searxng:9563".
|
||||
// Empty ⇒ the whole block is normalised to nil and the source stays off.
|
||||
//
|
||||
// The instance needs `search.formats` to include `json` in its settings.yml.
|
||||
// A stock install answers 403 to format=json, and then every search fails.
|
||||
URL string `json:"url,omitempty"`
|
||||
|
||||
// MaxResults — how many hits are kept as evidence. 0 ⇒ DefaultSearchResults.
|
||||
// Small on purpose: the snippets share a 4096-token context with the persona
|
||||
// block and the prompt.
|
||||
MaxResults int `json:"max_results,omitempty"`
|
||||
|
||||
// SnippetRunes — how much of the joined evidence reaches the phraser.
|
||||
// 0 ⇒ DefaultSearchSnippetRunes.
|
||||
SnippetRunes int `json:"snippet_runes,omitempty"`
|
||||
|
||||
// Language — SearXNG's `language` parameter, e.g. "ru", "en" or "auto".
|
||||
// Empty ⇒ the instance default. He asks in Russian and in English, so
|
||||
// pinning one language here is usually the wrong call.
|
||||
Language string `json:"language,omitempty"`
|
||||
|
||||
// Engines — comma-separated engine names to restrict the search to, e.g.
|
||||
// "duckduckgo,wikipedia". Empty ⇒ whatever the instance has enabled.
|
||||
Engines string `json:"engines,omitempty"`
|
||||
|
||||
// Timeout — per-search budget. 0 ⇒ websearch.DefaultTimeout. SearXNG waits
|
||||
// on the slowest upstream engine, so this is the knob that decides how long
|
||||
// a voice turn can stall on a bad network.
|
||||
Timeout Duration `json:"timeout,omitempty"`
|
||||
}
|
||||
|
||||
// Search defaults, applied in normaliseSearch.
|
||||
const (
|
||||
DefaultSearchResults = 4
|
||||
DefaultSearchSnippetRunes = 1500
|
||||
)
|
||||
|
||||
// normaliseSearch applies the block's defaults. No address, nothing to search.
|
||||
func (c *Config) normaliseSearch() {
|
||||
if c.Search != nil && strings.TrimSpace(c.Search.URL) == "" {
|
||||
c.Search = nil
|
||||
}
|
||||
if c.Search == nil {
|
||||
return
|
||||
}
|
||||
if c.Search.MaxResults <= 0 {
|
||||
c.Search.MaxResults = DefaultSearchResults
|
||||
}
|
||||
if c.Search.SnippetRunes <= 0 {
|
||||
c.Search.SnippetRunes = DefaultSearchSnippetRunes
|
||||
}
|
||||
}
|
||||
|
||||
// KiwixConfig — the offline encyclopedia. A kiwix-serve instance holding ZIM
|
||||
// archives (Wikipedia, ifixit, devdocs) on the LAN, searched when the live
|
||||
// search is empty, unreachable, or the line is down. Dark until configured,
|
||||
// same as every other reach.
|
||||
//
|
||||
// This is the "local sources first" rule in CLAUDE.md made concrete: a 1.7B
|
||||
// does not know enough to answer a world question, but it can read. A local
|
||||
// read costs nothing and leaves the box only as far as the LAN.
|
||||
type KiwixConfig struct {
|
||||
// URL — base address of kiwix-serve, e.g. "http://kiwix:8080". Empty ⇒ the
|
||||
// whole block is normalised to nil and the source stays off.
|
||||
URL string `json:"url,omitempty"`
|
||||
|
||||
// Book — the ZIM to search, by its catalog name, e.g.
|
||||
// "wikipedia_en_all_maxi_2026-02". Take it from the /content/… href in
|
||||
// /catalog/v2/entries; the display title is not the name.
|
||||
//
|
||||
// Required. kiwix-serve answers 400 to a search with an empty books.name,
|
||||
// so a block without one is normalised to nil rather than left to fail one
|
||||
// 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.
|
||||
MaxResults int `json:"max_results,omitempty"`
|
||||
|
||||
// SnippetRunes — how much of the joined snippets is handed to the phraser.
|
||||
// 0 ⇒ DefaultKiwixSnippetRunes. Sized against the 4096-token context, which
|
||||
// also holds the persona block and the prompt.
|
||||
SnippetRunes int `json:"snippet_runes,omitempty"`
|
||||
|
||||
// Rewrite — turn the Russian question into English keywords with the
|
||||
// resident model before searching. The ZIMs are English and kiwix ranks by
|
||||
// keyword, not meaning, so a Russian sentence matches nothing. Costs one
|
||||
// short LLM call per query. Default true; set false only to measure the
|
||||
// difference or when the books are Russian.
|
||||
Rewrite *bool `json:"rewrite,omitempty"`
|
||||
}
|
||||
|
||||
// Kiwix defaults, applied in normaliseKiwix.
|
||||
const (
|
||||
DefaultKiwixResults = 5
|
||||
DefaultKiwixSnippetRunes = 1500
|
||||
)
|
||||
|
||||
// RewriteEnabled — Rewrite with its default applied. Absent ⇒ on.
|
||||
func (k *KiwixConfig) RewriteEnabled() bool {
|
||||
return k.Rewrite == nil || *k.Rewrite
|
||||
}
|
||||
|
||||
// normaliseKiwix applies the block's defaults. No address or no book, nothing
|
||||
// to search.
|
||||
func (c *Config) normaliseKiwix() {
|
||||
if c.Kiwix != nil && (strings.TrimSpace(c.Kiwix.URL) == "" || strings.TrimSpace(c.Kiwix.Book) == "") {
|
||||
c.Kiwix = nil
|
||||
}
|
||||
if c.Kiwix == nil {
|
||||
return
|
||||
}
|
||||
if c.Kiwix.MaxResults <= 0 {
|
||||
c.Kiwix.MaxResults = DefaultKiwixResults
|
||||
}
|
||||
if c.Kiwix.SnippetRunes <= 0 {
|
||||
c.Kiwix.SnippetRunes = DefaultKiwixSnippetRunes
|
||||
}
|
||||
}
|
||||
+17
-8
@@ -57,6 +57,16 @@ var (
|
||||
ErrFetchStatus = errors.New("crawl: the server answered with an error status")
|
||||
)
|
||||
|
||||
// StatusError is ErrFetchStatus with the code the server actually sent. The
|
||||
// adapter builds it; isServerError reads Code rather than the message, so a
|
||||
// reworded error can no longer turn a 503 robots.txt into permission to crawl.
|
||||
type StatusError struct{ Code int }
|
||||
|
||||
func (e *StatusError) Error() string {
|
||||
return fmt.Sprintf("crawl: the server answered with status %d", e.Code)
|
||||
}
|
||||
func (e *StatusError) Unwrap() error { return ErrFetchStatus }
|
||||
|
||||
// Fetcher is the guarded HTTP door (internal/webfetch adapted by the daemon). An
|
||||
// interface so this package constructs no http.Client of its own and can be
|
||||
// tested without a network.
|
||||
@@ -233,16 +243,15 @@ func (c *Crawler) markFetched(host string) {
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// isServerError — a 5xx rather than any other non-2xx. The adapter formats the
|
||||
// status into the message, which is the only place it survives.
|
||||
// isServerError — a 5xx rather than any other non-2xx. A status the adapter
|
||||
// could not recover reads as 0 and is not a server error, which keeps the
|
||||
// standard's "404 means allow" as the default for an unknown.
|
||||
func isServerError(err error) bool {
|
||||
s := err.Error()
|
||||
for _, code := range []string{" 50", " 51", " 52", " 53"} {
|
||||
if strings.Contains(s, code) {
|
||||
return true
|
||||
}
|
||||
var se *StatusError
|
||||
if !errors.As(err, &se) {
|
||||
return false
|
||||
}
|
||||
return false
|
||||
return se.Code >= 500 && se.Code <= 599
|
||||
}
|
||||
|
||||
// Hash is the dedup key for a crawl result: the sha256 of the extracted text,
|
||||
|
||||
@@ -79,7 +79,7 @@ func TestPage_ABrokenRobotsServerIsNotPermissionToCrawl(t *testing.T) {
|
||||
// way to resolve an unknown.
|
||||
f := &timedFetcher{
|
||||
pages: map[string]Response{"https://example.org/a": {Body: []byte("<html><body>a</body></html>")}},
|
||||
errs: map[string]error{"https://example.org/robots.txt": fmt.Errorf("%w: 503", ErrFetchStatus)},
|
||||
errs: map[string]error{"https://example.org/robots.txt": &StatusError{Code: 503}},
|
||||
}
|
||||
c := New(f, Config{UserAgent: "Maven/1.0"})
|
||||
if _, err := c.Page(context.Background(), "https://example.org/a"); !errors.Is(err, ErrFetchStatus) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user