Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2b3e34c7e8 |
@@ -43,7 +43,7 @@ are the work.
|
||||
Both halves are wired as of 2026-08-03. Routing and replies prefer the workstation silently
|
||||
through `modelSeam`; nudge and reminder phrasing prefer it silently inside the phraser. A
|
||||
world question goes through `LLMPhraser.PhraseWorld` and names the gap when the card is not
|
||||
free — `worldGap` in `cmd/mavend/worldmodel.go`, which the owner hears instead of an invented
|
||||
free — `worldGap` in `cmd/mavend/worldmodel.go`, which he hears instead of an invented
|
||||
answer. A box with no `workstation` block behaves exactly as it did before the seam: naming
|
||||
a gap requires a gap. The offload table in `docs/offload.md` says which caller is which.
|
||||
|
||||
@@ -90,7 +90,7 @@ protocol; the config in `deploy/mavend.json` (with `${VAR}` env expansion from g
|
||||
**Seven of the nine run on homesrv. `mavwaked` and `mavenclient` do not, and that is the
|
||||
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.
|
||||
daemon there listens to nobody. They belong on a client machine where he 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
|
||||
@@ -165,15 +165,6 @@ that stood here until 2026-08-02 was contention, not the model.** See `docs/eval
|
||||
p50 825ms / p95 1.2s / max 3.0s and the full cascade at p50 0.80-1.04s. Do not plan latency
|
||||
work off the bakeoff table.
|
||||
|
||||
**Re-measured 2026-08-05 on the fixture as it now stands, 91 cases** (V-320 item 2,
|
||||
`docs/evals/2026-08-05-routing-resident-model.md`): cascade + resident model scores
|
||||
**75.8% full / 80.2% intent-only at p50 1.19s / p95 1.65s**. That is a new baseline and not
|
||||
a movement, because 14 cases were added since the 77-case number above. The model alone
|
||||
scores 37.4% full against 61.5% intent-only, and the gap is slots rather than routing: it
|
||||
routes `reminder` and leaves the time to the daemon, which is what the contract asks. To
|
||||
re-run it, start a **second** llama-server on a fixed host port — the resident one binds
|
||||
`--port 0` inside the container and no host process can reach it.
|
||||
|
||||
**The numbers above are the homesrv floor, not the ceiling.** With the workstation up, routing
|
||||
completes through `llm.Pair` against gemma-4-12b and scores **84.4% full / 93.5% intent-only at
|
||||
p50 329ms** — better than the resident model and about 2.5× faster (`docs/evals/2026-08-02-workstation-gemma4-12b.md`,
|
||||
@@ -236,13 +227,7 @@ llama-server in that run), so judge it again before quoting a cascade number.
|
||||
|
||||
Praxis taken off the model, 05-08-2026 (V-516). `PraxisGrammars()`
|
||||
(`internal/router/praxis.go`, wired in `buildRouter` before the capture marker because
|
||||
"отметь" is a capture verb) fills `Slots.Fn` with a Praxis capability name.
|
||||
**These grammars are the only path to Praxis, not a faster one.** Measured
|
||||
2026-08-05 with the resident model as router (V-517,
|
||||
`docs/evals/2026-08-05-reach-llm-router.md`): the model alone reaches Praxis
|
||||
**0/12**, the same as the classifier alone, because nothing in the router
|
||||
prompt names a Praxis capability and there is no string for it to write.
|
||||
Through the cascade it is 11/12. Deleting these rules costs every point. Praxis reach
|
||||
"отметь" is a capture verb) fills `Slots.Fn` with a Praxis capability name. Praxis reach
|
||||
was **0/12 and structurally so**: `handlePraxisAct` compares `Slots.Fn` to a capability
|
||||
alias, and that slot is filled from the deployment's enabled tool names, which no Praxis
|
||||
alias is on. Measured **16/30 → 27/30 overall, praxis 0/12 → 11/12, lifecycle 0/5 → 5/5**
|
||||
@@ -296,7 +281,7 @@ Seeds are scoring data. Editing one moves a recogniser and must be re-measured a
|
||||
Not a nag, not autonomous. Maven's persona is **feminine** — Russian
|
||||
self-reference must use feminine forms — `рада`, not `рад`; `поняла`, not `понял`. The owner
|
||||
is male and is addressed informally: "ты", singular, never "вы"/"ваш" and never "он"/"его"
|
||||
(she talks TO the owner, not about the owner). Pet names ("милый", "дорогой") are forbidden; the name
|
||||
(she talks TO him, not about him). Pet names ("милый", "дорогой") are forbidden; his name
|
||||
("Ками") is not. The eval enforces this: `CheckAddress`, `CheckFeminine` and `CheckCringe` in
|
||||
`internal/phraser/eval/checks.go`, scored by `make eval-phrasing`.
|
||||
|
||||
@@ -306,7 +291,7 @@ world questions, so she needs to read external sources. What replaces it:
|
||||
|
||||
- **No telemetry, no cloud model, no third-party account.** That part never changes. Nothing
|
||||
about Maven is reported to anyone, and inference stays on the box.
|
||||
- **The owner's data first, then the world.** Every source that reads the owner's facts, notes, calendar,
|
||||
- **His data first, then the world.** Every source that reads his facts, notes, calendar,
|
||||
tasks or house runs before anything outside, and the personal boundary sits between them.
|
||||
Reading beats recalling for a small model.
|
||||
- **In the world, live search leads and the ZIMs are the fallback** (owner's call,
|
||||
@@ -314,7 +299,7 @@ world questions, so she needs to read external sources. What replaces it:
|
||||
homesrv answer when the search is empty, unreachable, or the line is down.
|
||||
**Verified with the line down on 2026-08-05** (V-508,
|
||||
`docs/evals/2026-08-05-kiwix-offline-fallback.md`): a stopped SearXNG costs nothing,
|
||||
the ZIM answers in the same turn budget. A blackholed host cost 8 seconds the owner waited
|
||||
the ZIM answers in the same turn budget. A blackholed host cost 8 seconds he waited
|
||||
through. So the connect phase alone is capped at `dialTimeout` (1.5s), while a slow
|
||||
instance that did connect keeps the full 8. **A Russian question reads
|
||||
`wikipedia_ru_all_maxi_2026-02` verbatim** through `kiwix.book_ru`. The RU→EN rewriter
|
||||
@@ -333,8 +318,8 @@ world questions, so she needs to read external sources. What replaces it:
|
||||
capabilities. The code default is still off. `deploy/mavend.json` now ships a `search`
|
||||
block (owner's call, 2026-08-02), so it is on for this box and deleting the block turns
|
||||
it off again.
|
||||
- **The owner's notes and facts are never search input.** Looking up why the sky is blue and
|
||||
sending the owner's stored personal notes to an upstream engine are different acts. Only the utterance goes
|
||||
- **His notes and facts are never search input.** Looking up why the sky is blue and sending
|
||||
his stored personal notes to an upstream engine are different acts. Only the utterance goes
|
||||
out, never the persona block, history, or matched notes.
|
||||
|
||||
## Web UI conventions
|
||||
@@ -356,16 +341,6 @@ Vikunja is the durable task store. A task holds the goal, the constraints and th
|
||||
assumption ledger. Work without a task id is work nobody can resume, so a session that
|
||||
has no id asks for one before it starts.
|
||||
|
||||
The MCP tool schemas are deferred, so load the four you actually use in ONE call at the
|
||||
start of a session rather than one lookup per first use:
|
||||
|
||||
```text
|
||||
ToolSearch("select:mcp__vikunja__list_tasks,mcp__vikunja__get_task_details,mcp__vikunja__create_task,mcp__vikunja__update_task")
|
||||
```
|
||||
|
||||
`update_task` carrying a `description` resets `done` to false, so closing a task with a
|
||||
write-up takes two calls: the description, then `done: true`.
|
||||
|
||||
## Session workflow
|
||||
|
||||
`~/.local/bin/task` owns the branch, the commit identity and the PR. One task, one
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
// Command labelgen labels utterances with the stage 0 grammars and prints JSONL.
|
||||
//
|
||||
// docs/plans/18-routing-heads-on-e5-small.md calls the labeled set the whole
|
||||
// project, and it names the stage 0 grammars as the high-precision label
|
||||
// functions to start from. This runs them — the real ones, in the real
|
||||
// buildRouter order — rather than a reimplementation, so a rule change moves
|
||||
// the training data with it.
|
||||
//
|
||||
// A grammar that declines leaves the line unlabeled. Those go to the model, and
|
||||
// keeping them is the point: a set labeled only by the rules teaches only the
|
||||
// rules.
|
||||
//
|
||||
// go run ./cmd/labelgen < utterances.txt > labeled.jsonl
|
||||
//
|
||||
// The wakeword-act grammar is absent, because its allowlist is the deployment's
|
||||
// enabled tool names and this tool has no deployment. Every other rule is here.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
// label is one output row. The grammar name rides along so a reviewer can see
|
||||
// which rule made the claim, and so a rule that turns out to be wrong can have
|
||||
// its rows pulled without re-running everything.
|
||||
type label struct {
|
||||
Utterance string `json:"utterance"`
|
||||
Intent string `json:"intent,omitempty"`
|
||||
Grammar string `json:"grammar,omitempty"`
|
||||
Key string `json:"key,omitempty"`
|
||||
Value string `json:"value,omitempty"`
|
||||
Fn string `json:"fn,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
Labeled bool `json:"labeled"`
|
||||
}
|
||||
|
||||
// grammars mirrors buildRouter's order in cmd/mavend/voicewire.go. Order is
|
||||
// load-bearing there and so it is here: the agenda rules must sit after the
|
||||
// clock rules, Praxis before the capture marker, the narrative rules last.
|
||||
func grammars() []router.Grammar {
|
||||
var g []router.Grammar
|
||||
g = append(g, router.SystemTimeDateGrammars()...)
|
||||
g = append(g, router.AgendaQueryGrammars()...)
|
||||
g = append(g, router.FeedQueryGrammar())
|
||||
g = append(g, router.TaskListGrammar())
|
||||
g = append(g, router.ListGrammars()...)
|
||||
g = append(g, router.ReminderGrammar())
|
||||
g = append(g, router.PraxisGrammars()...)
|
||||
g = append(g, router.TaskCaptureGrammar())
|
||||
g = append(g, router.NarrativeQueryGrammars()...)
|
||||
return g
|
||||
}
|
||||
|
||||
func match(gs []router.Grammar, utterance string) label {
|
||||
out := label{Utterance: utterance}
|
||||
for _, g := range gs {
|
||||
m := g.Pattern.FindStringSubmatch(utterance)
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
d, ok := g.Build(m)
|
||||
if !ok {
|
||||
continue // the rule saw its shape and declined it
|
||||
}
|
||||
out.Intent = string(d.Intent)
|
||||
out.Grammar = g.Name
|
||||
out.Key = d.Slots.Key
|
||||
out.Value = d.Slots.Value
|
||||
out.Fn = d.Slots.Fn
|
||||
out.Text = d.Slots.Text
|
||||
out.Labeled = true
|
||||
return out
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func main() {
|
||||
gs := grammars()
|
||||
in := bufio.NewScanner(os.Stdin)
|
||||
in.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
||||
out := bufio.NewWriter(os.Stdout)
|
||||
defer out.Flush()
|
||||
|
||||
enc := json.NewEncoder(out)
|
||||
var seen, labeled int
|
||||
for in.Scan() {
|
||||
line := strings.TrimSpace(in.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
seen++
|
||||
l := match(gs, line)
|
||||
if l.Labeled {
|
||||
labeled++
|
||||
}
|
||||
if err := enc.Encode(l); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "labelgen:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
if err := in.Err(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "labelgen:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
// Coverage on stderr, so the count is visible without polluting the JSONL.
|
||||
fmt.Fprintf(os.Stderr, "labelgen: %d/%d labeled by %d grammars\n", labeled, seen, len(gs))
|
||||
}
|
||||
@@ -24,14 +24,6 @@ func (h *reactiveHandler) actionAct(ctx context.Context, dec router.Decision) st
|
||||
}
|
||||
}
|
||||
|
||||
// The board is Maven's own store, so a spoken status change is answered here
|
||||
// and never offered to an ecosystem client (Vikunja #512). First, because
|
||||
// task_status is on no allowlist and no capability registry: reaching either
|
||||
// of them would answer a turn about his own task list with a gap.
|
||||
if dec.Slots.Fn == router.TaskStatusFn {
|
||||
return h.resolveTaskStatus(ctx, dec)
|
||||
}
|
||||
|
||||
// Praxis ecosystem tools: intercept before the system command executor.
|
||||
if h.ecosystem != nil && h.ecosystem.praxis != nil && dec.Slots.HasFn {
|
||||
if reply := h.handlePraxisAct(ctx, dec); reply != "" {
|
||||
|
||||
@@ -95,12 +95,6 @@ func (h *reactiveHandler) removeListItem(ctx context.Context, cap router.ListCap
|
||||
return "", false
|
||||
}
|
||||
|
||||
// listFloor — the keyword test behind topicList, in the shape turnIsAbout takes.
|
||||
func listFloor(u string) bool {
|
||||
_, ok := router.ParseListQuery(u)
|
||||
return ok
|
||||
}
|
||||
|
||||
// queryList — "что в списке покупок?", "что мне купить?".
|
||||
//
|
||||
// A query source, so it sits in querySources and either claims the turn or
|
||||
@@ -108,15 +102,10 @@ func listFloor(u string) bool {
|
||||
// source is: the notes pass would otherwise answer a list question with
|
||||
// whatever note is nearest.
|
||||
func (h *reactiveHandler) queryList(ctx context.Context, t *queryTurn) (string, bool) {
|
||||
if h.dataStore == nil {
|
||||
list, ok := router.ParseListQuery(t.dec.Utterance)
|
||||
if !ok || h.dataStore == nil {
|
||||
return "", false
|
||||
}
|
||||
// The seeds decide the subject and listQueryPrefixes is the floor behind
|
||||
// them (V-522). Which list he named is a noun lookup either way.
|
||||
if !h.turnIsAbout(ctx, t, topicList, listFloor) {
|
||||
return "", false
|
||||
}
|
||||
list := router.ListNamedIn(t.dec.Utterance)
|
||||
items, err := h.dataStore.ListItems(ctx, list, "")
|
||||
if err != nil {
|
||||
log.Printf("voice: list items: %v", err)
|
||||
|
||||
@@ -122,12 +122,6 @@ var querySources = []querySource{
|
||||
{name: "network", answer: (*reactiveHandler).queryNetwork},
|
||||
{name: "calendar", answer: (*reactiveHandler).queryCalendar, dateAware: true},
|
||||
{name: "weather", answer: (*reactiveHandler).queryWeather},
|
||||
// A question about her, above the three sources that search his own data
|
||||
// (Vikunja #555). It has no answer anywhere else: below the boundary
|
||||
// SearXNG answers about somebody else's assistant, and above it his notes
|
||||
// answer by proximity — "кто ты" came back from a note of his, measured on
|
||||
// the box, because the recall index has no idea the subject is her.
|
||||
{name: "self", answer: (*reactiveHandler).querySelf},
|
||||
{name: "embed", answer: (*reactiveHandler).queryEmbed},
|
||||
{name: "memory", answer: (*reactiveHandler).queryMemory},
|
||||
{name: "notes", answer: (*reactiveHandler).queryNotes},
|
||||
@@ -298,14 +292,6 @@ const (
|
||||
feedReadOut = 3
|
||||
)
|
||||
|
||||
// feedFloor — the keyword test behind topicFeed, in the one-string shape
|
||||
// turnIsAbout takes. router.ParseFeedQuery returns the category too, which the
|
||||
// gate has no use for; the caller reads it separately.
|
||||
func feedFloor(u string) bool {
|
||||
_, ok := router.ParseFeedQuery(u)
|
||||
return ok
|
||||
}
|
||||
|
||||
// queryFeeds — "что нового в лентах?", "что нового по технологиям?"
|
||||
// (Vikunja #258).
|
||||
//
|
||||
@@ -313,13 +299,10 @@ func feedFloor(u string) bool {
|
||||
// never speaks; asking is the trigger. If that ever changes, the thing that
|
||||
// changed is "Maven is not a nag", not a detail of this file.
|
||||
func (h *reactiveHandler) queryFeeds(ctx context.Context, t *queryTurn) (string, bool) {
|
||||
// The seeds decide the subject; router.ParseFeedQuery is the floor behind
|
||||
// them (V-522). The category still comes from the utterance either way,
|
||||
// because a topic is marked by a preposition and needs no recogniser.
|
||||
if !h.turnIsAbout(ctx, t, topicFeed, feedFloor) {
|
||||
q, ok := router.ParseFeedQuery(t.dec.Utterance)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
category := router.FeedCategoryOf(t.dec.Utterance)
|
||||
if !h.feedsOn {
|
||||
// Claim only when nothing below can read the world. The reason this
|
||||
// source used to claim unconditionally was that general knowledge would
|
||||
@@ -344,7 +327,7 @@ func (h *reactiveHandler) queryFeeds(ctx context.Context, t *queryTurn) (string,
|
||||
}
|
||||
var picked []string
|
||||
for _, n := range notes {
|
||||
if !router.CategoryMatches(rss.NoteCategory(n.Text), category) {
|
||||
if !router.CategoryMatches(rss.NoteCategory(n.Text), q.Category) {
|
||||
continue
|
||||
}
|
||||
// The note carries title, summary, category tag and link; she reads the
|
||||
@@ -356,7 +339,7 @@ func (h *reactiveHandler) queryFeeds(ctx context.Context, t *queryTurn) (string,
|
||||
}
|
||||
}
|
||||
if len(picked) == 0 {
|
||||
if category != "" {
|
||||
if q.Category != "" {
|
||||
return phraser.Q(phraser.QueryFeedsTopic, nil), true
|
||||
}
|
||||
return phraser.Q(phraser.QueryFeedsEmpty, nil), true
|
||||
@@ -377,19 +360,6 @@ func (h *reactiveHandler) queryCalendar(ctx context.Context, t *queryTurn) (stri
|
||||
if isWeatherQuery(t.dec.Utterance) {
|
||||
return "", false
|
||||
}
|
||||
// Weather was one instance of a wider class (Vikunja #552). Naming a day
|
||||
// does not make a question his agenda: "какой сегодня курс доллара" and
|
||||
// "во сколько закат сегодня" both answered "ничего нет", which reads as an
|
||||
// answer about a subject she never looked at. All of them have an answer
|
||||
// in search, and search sits below this source. So the question must ask
|
||||
// about his schedule, not merely name a day.
|
||||
//
|
||||
// A continuation is exempt. "а завтра?" names no agenda and cannot: the
|
||||
// subject was in the turn before it, and this is the only date-aware
|
||||
// source there is.
|
||||
if !t.dec.Continued && !router.IsAgendaQuestion(t.dec.Utterance) {
|
||||
return "", false
|
||||
}
|
||||
date, ok := router.ParseCalendarDate(t.dec.Utterance, h.now())
|
||||
if !ok {
|
||||
return "", false
|
||||
@@ -487,10 +457,6 @@ func (h *reactiveHandler) queryWeather(ctx context.Context, t *queryTurn) (strin
|
||||
// sources below both need, run once, in the position it always ran in. It
|
||||
// only claims the turn when the embedder fails.
|
||||
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 {
|
||||
return "", false
|
||||
}
|
||||
vec, err := router.EmbedQuery(ctx, h.recall.embedder, t.dec.Utterance)
|
||||
if err != nil {
|
||||
log.Printf("voice: embed query: %v", err)
|
||||
|
||||
@@ -3,7 +3,6 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
"github.com/kami/maven/internal/dialogue"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
@@ -82,95 +81,7 @@ func (h *reactiveHandler) queryTasks(ctx context.Context, t *queryTurn) (string,
|
||||
cands = append(cands, dialogue.Candidate{Kind: "task", Ref: r.ID, Label: r.Text})
|
||||
}
|
||||
h.offerCandidates(ctx, cands)
|
||||
reply := tasks.FormatRU(ranked)
|
||||
// The counted shapes, after the list and only when there are any (V-512).
|
||||
// They answer "what is going wrong with this list" without assessing any of
|
||||
// it, and they are said here rather than announced: no tick rule reads them.
|
||||
if stalls := tasks.StallsRU(tasks.Stalls(taskItems(live), h.now())); stalls != "" {
|
||||
if !strings.HasSuffix(reply, ".") {
|
||||
reply += "."
|
||||
}
|
||||
reply += " " + stalls
|
||||
}
|
||||
return reply, true
|
||||
}
|
||||
|
||||
// resolveTaskStatus moves a task he named out loud (Vikunja #512).
|
||||
//
|
||||
// The position path already worked: resolveCandidate answers "первую сделал"
|
||||
// against the list she just read. This is the other half — naming the task
|
||||
// instead of its position, which reached no code at all before the stage-0 rule
|
||||
// in internal/router/taskstatus.go filled the fn slot.
|
||||
//
|
||||
// Three answers besides the move, and none of them guesses. No match says so. A
|
||||
// match on more than one asks which, because closing the wrong task is work he
|
||||
// never finished being marked done. No task named asks which too, since the
|
||||
// router claims the turn without the referent and the list lives here.
|
||||
func (h *reactiveHandler) resolveTaskStatus(ctx context.Context, dec router.Decision) string {
|
||||
live, err := h.api.ListTasks(ctx, "live")
|
||||
if err != nil {
|
||||
log.Printf("voice: task status: list: %v", err)
|
||||
return "не получилось посмотреть задачи."
|
||||
}
|
||||
if dec.Slots.Text == "" {
|
||||
return "какую задачу?"
|
||||
}
|
||||
match := matchTaskText(live, dec.Slots.Text)
|
||||
switch len(match) {
|
||||
case 0:
|
||||
return "не нашла такой задачи."
|
||||
case 1:
|
||||
default:
|
||||
return "у тебя несколько подходящих — какую именно?"
|
||||
}
|
||||
pick := match[0]
|
||||
status := dec.Slots.Value
|
||||
// A candidate is work Maven proposed and he never confirmed, and the store
|
||||
// refuses candidate → done: the legal move is to open it first. Saying it is
|
||||
// done IS the confirmation, so both writes happen rather than the turn
|
||||
// naming a gap about a distinction he did not make.
|
||||
if pick.Status == store.TaskCandidate && status == store.TaskDone {
|
||||
if err := h.api.SetTaskStatus(ctx, pick.ID, store.TaskOpen, h.now(), string(sourceVoice)); err != nil {
|
||||
log.Printf("voice: task status: promote %d: %v", pick.ID, err)
|
||||
return "не получилось изменить задачу."
|
||||
}
|
||||
}
|
||||
if err := h.api.SetTaskStatus(ctx, pick.ID, status, h.now(), string(sourceVoice)); err != nil {
|
||||
log.Printf("voice: task status: %d → %s: %v", pick.ID, status, err)
|
||||
return "не получилось изменить задачу."
|
||||
}
|
||||
log.Printf("voice: task %d (%q) → %s", pick.ID, pick.Text, status)
|
||||
if status == store.TaskDropped {
|
||||
return "убрала: " + pick.Text
|
||||
}
|
||||
return "закрыла: " + pick.Text
|
||||
}
|
||||
|
||||
// matchTaskText finds the live tasks he could have meant.
|
||||
//
|
||||
// Normalised containment, either direction, over store.NormalizeTaskText — the
|
||||
// same key capture dedupes on, so a task he can file twice is a task he can name
|
||||
// twice. Either direction because he shortens what he said ("молоко" for
|
||||
// "купить молоко") as often as he pads it.
|
||||
//
|
||||
// Deliberately not fuzzy. A ranked best guess would always return exactly one
|
||||
// answer, and the one thing this must be able to say is that it is not sure.
|
||||
func matchTaskText(live []ipc.Task, named string) []ipc.Task {
|
||||
want := store.NormalizeTaskText(named)
|
||||
if want == "" {
|
||||
return nil
|
||||
}
|
||||
var out []ipc.Task
|
||||
for _, t := range live {
|
||||
have := store.NormalizeTaskText(t.Text)
|
||||
if have == "" {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(have, want) || strings.Contains(want, have) {
|
||||
out = append(out, t)
|
||||
}
|
||||
}
|
||||
return out
|
||||
return tasks.FormatRU(ranked), true
|
||||
}
|
||||
|
||||
// taskItems maps wire rows onto the ranker's input. Written here rather than in
|
||||
|
||||
@@ -26,9 +26,6 @@ type taskAPI struct {
|
||||
tasks []ipc.Task
|
||||
listArg string
|
||||
listErr error
|
||||
|
||||
moved []setStatusCall
|
||||
moveErr error
|
||||
}
|
||||
|
||||
func (a *taskAPI) CaptureTask(_ context.Context, req ipc.CaptureTaskReq) (ipc.CaptureTaskResp, error) {
|
||||
@@ -256,87 +253,3 @@ func TestCaptureTaskFromNoteAcknowledgesAPromotion(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// setStatusCall — one SetTaskStatus the arm made, in order, so a candidate he
|
||||
// says is done can be shown to take both legal moves.
|
||||
type setStatusCall struct {
|
||||
id int64
|
||||
status string
|
||||
by string
|
||||
}
|
||||
|
||||
func (a *taskAPI) SetTaskStatus(_ context.Context, id int64, status string, _ time.Time, by string) error {
|
||||
a.moved = append(a.moved, setStatusCall{id: id, status: status, by: by})
|
||||
return a.moveErr
|
||||
}
|
||||
|
||||
func TestResolveTaskStatusMovesTheNamedTask(t *testing.T) {
|
||||
api := &taskAPI{tasks: []ipc.Task{
|
||||
{ID: 7, Text: "купить молоко", Status: "open"},
|
||||
{ID: 8, Text: "оплатить интернет", Status: "open"},
|
||||
}}
|
||||
h := taskHandler(api)
|
||||
reply := h.resolveTaskStatus(context.Background(), router.Decision{
|
||||
Intent: router.IntentAct,
|
||||
Slots: router.Slots{Fn: router.TaskStatusFn, HasFn: true, Value: "done", Text: "молоко"},
|
||||
})
|
||||
if api.listArg != "live" {
|
||||
t.Errorf("listed %q, want live — a resolved task cannot be resolved again", api.listArg)
|
||||
}
|
||||
if len(api.moved) != 1 {
|
||||
t.Fatalf("moved %d tasks, want 1: %+v", len(api.moved), api.moved)
|
||||
}
|
||||
if api.moved[0].id != 7 || api.moved[0].status != "done" {
|
||||
t.Errorf("moved %+v, want id 7 → done", api.moved[0])
|
||||
}
|
||||
if !strings.Contains(reply, "купить молоко") {
|
||||
t.Errorf("reply = %q, want the task named back", reply)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTaskStatusRefusesToGuess(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
tasks []ipc.Task
|
||||
named string
|
||||
want string
|
||||
}{
|
||||
{"no match", []ipc.Task{{ID: 7, Text: "купить молоко", Status: "open"}}, "позвонить маме", "не нашла"},
|
||||
{"two matches", []ipc.Task{
|
||||
{ID: 7, Text: "купить молоко", Status: "open"},
|
||||
{ID: 8, Text: "купить молоко и хлеб", Status: "open"},
|
||||
}, "купить молоко", "несколько"},
|
||||
{"none named", []ipc.Task{{ID: 7, Text: "купить молоко", Status: "open"}}, "", "какую"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
api := &taskAPI{tasks: c.tasks}
|
||||
h := taskHandler(api)
|
||||
reply := h.resolveTaskStatus(context.Background(), router.Decision{
|
||||
Slots: router.Slots{Fn: router.TaskStatusFn, HasFn: true, Value: "done", Text: c.named},
|
||||
})
|
||||
if len(api.moved) != 0 {
|
||||
t.Errorf("moved %+v — closing the wrong task is the failure this arm exists to avoid", api.moved)
|
||||
}
|
||||
if !strings.Contains(reply, c.want) {
|
||||
t.Errorf("reply = %q, want it to contain %q", reply, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTaskStatusOpensACandidateFirst(t *testing.T) {
|
||||
// The store refuses candidate → done. Saying it is done is the confirmation
|
||||
// the candidate was waiting for, so the arm makes both legal moves.
|
||||
api := &taskAPI{tasks: []ipc.Task{{ID: 9, Text: "продлить домен", Status: "candidate"}}}
|
||||
h := taskHandler(api)
|
||||
h.resolveTaskStatus(context.Background(), router.Decision{
|
||||
Slots: router.Slots{Fn: router.TaskStatusFn, HasFn: true, Value: "done", Text: "продлить домен"},
|
||||
})
|
||||
if len(api.moved) != 2 {
|
||||
t.Fatalf("moved %+v, want open then done", api.moved)
|
||||
}
|
||||
if api.moved[0].status != "open" || api.moved[1].status != "done" {
|
||||
t.Errorf("moved %+v, want open then done", api.moved)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
// TestCalendarStepsAsideForTheWorld — the defect (Vikunja #552). Weather was
|
||||
// one instance of a wider class, and V-474 fixed only that instance. Every one
|
||||
// of these answered "на 05.08.2026 ничего нет" on the deployed daemon, and
|
||||
// every one of them has an answer in search, which sits below the calendar.
|
||||
func TestCalendarStepsAsideForTheWorld(t *testing.T) {
|
||||
h, api := contQueryHandler()
|
||||
for _, u := range []string{
|
||||
"во сколько закат сегодня",
|
||||
"какой сегодня курс доллара",
|
||||
"какой сегодня праздник",
|
||||
"что интересного произошло сегодня в мире",
|
||||
} {
|
||||
if reply, ok := h.queryCalendar(context.Background(), &queryTurn{
|
||||
dec: router.Decision{Intent: router.IntentQuery, Utterance: u},
|
||||
}); ok {
|
||||
t.Errorf("the calendar claimed %q with %q", u, reply)
|
||||
}
|
||||
}
|
||||
if api.events != 0 {
|
||||
t.Errorf("CalendarEvents called %d times for world questions, want 0", api.events)
|
||||
}
|
||||
}
|
||||
|
||||
// The other half of the same narrowing: a question about his own day still
|
||||
// reaches the calendar, including the one that names no subject at all.
|
||||
func TestCalendarStillAnswersHisDay(t *testing.T) {
|
||||
for _, u := range []string{
|
||||
"что у меня сегодня",
|
||||
"во сколько у меня встреча сегодня",
|
||||
"какие встречи завтра",
|
||||
"что в календаре на завтра",
|
||||
"что сегодня?",
|
||||
} {
|
||||
h, _ := contQueryHandler()
|
||||
if _, ok := h.queryCalendar(context.Background(), &queryTurn{
|
||||
dec: router.Decision{Intent: router.IntentQuery, Utterance: u},
|
||||
}); !ok {
|
||||
t.Errorf("the calendar passed on %q", u)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A continuation carries its subject in the turn before it, and the calendar
|
||||
// is the only date-aware source, so the narrowing must not reach it.
|
||||
func TestCalendarStillAnswersAContinuation(t *testing.T) {
|
||||
h, _ := contQueryHandler()
|
||||
if _, ok := h.queryCalendar(context.Background(), &queryTurn{
|
||||
dec: router.Decision{Intent: router.IntentQuery, Utterance: "а завтра?", Continued: true},
|
||||
}); !ok {
|
||||
t.Error("the calendar passed on a continuation")
|
||||
}
|
||||
}
|
||||
@@ -179,20 +179,6 @@ func (h *reactiveHandler) askClarify(ctx context.Context, dec router.Decision) (
|
||||
// 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)
|
||||
}
|
||||
|
||||
func (h *reactiveHandler) resolveClarifyAnswer(ctx context.Context, text string) (string, bool) {
|
||||
if h.clarifyStore == nil {
|
||||
return "", false
|
||||
@@ -205,23 +191,6 @@ func (h *reactiveHandler) resolveClarifyAnswer(ctx context.Context, text string)
|
||||
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) {
|
||||
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 "", false
|
||||
}
|
||||
// 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 "напомни"
|
||||
|
||||
@@ -564,59 +564,3 @@ func TestARestartExpiresTheParkedQuestion(t *testing.T) {
|
||||
t.Fatalf("notice = %q, want silence: nothing survived to expire", notice)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClarifyStepsAsideForItsOwnRequest — Vikunja #554. An act she could not
|
||||
// fulfil parked "Что сделать?", and the three turns after it were scored as
|
||||
// answers to that question: a world question, then "как дела", then the give-up
|
||||
// line. None of them was ever an answer.
|
||||
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")
|
||||
}
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
// TestClarifyStillRetriesOnAnAnswerThatMissed — the other half of #554, and the
|
||||
// reason the test above is narrow. A bare noun answers nothing either, but it
|
||||
// carries no request of its own, so she asks again as before.
|
||||
func TestClarifyStillRetriesOnAnAnswerThatMissed(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
h, _, _ := newClarifyHandler(t)
|
||||
|
||||
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни позвонить маме"}, "напомни позвонить маме")); !asked {
|
||||
t.Fatal("expected the time question")
|
||||
}
|
||||
reply, handled := h.resolveClarifyAnswer(ctx, "ага")
|
||||
if !handled || reply == "" {
|
||||
t.Fatalf("a missed answer must still be re-asked, handled=%v reply=%q", handled, reply)
|
||||
}
|
||||
if h.clarifyStore.Get(voiceDialogueID, h.now()) == nil {
|
||||
t.Error("the question must survive a missed answer")
|
||||
}
|
||||
}
|
||||
|
||||
// TestClarifyQuestionShapedAnswerThatFillsTheGapStillLands — the guard runs only
|
||||
// where nothing was filled. "во сколько?" is question-shaped and is also how a
|
||||
// time gets said back, so an answer that closes the gap wins whatever its shape.
|
||||
func TestClarifyQuestionShapedAnswerThatFillsTheGapStillLands(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
h, st, _ := newClarifyHandler(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 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
+7
-40
@@ -54,56 +54,30 @@ var historyMarkersEn = [][2]string{
|
||||
// answers a topic far better than a list of the last five facts does.
|
||||
var historyRecall = []string{" про ", " об ", " о ", " about "}
|
||||
|
||||
// historySide — whose turn the question asks about. The rows read are the same
|
||||
// either way, because a tapped fact is one act seen from two sides, but the
|
||||
// sentence is not: answering "что ты записала сегодня?" with "ты говорил…"
|
||||
// hands the question back instead of answering it (Vikunja #456).
|
||||
type historySide int
|
||||
|
||||
const (
|
||||
historyAskedHim historySide = iota // "что я тебе говорил"
|
||||
historyAskedHer // "что ты записала сегодня"
|
||||
)
|
||||
|
||||
// isHistoryQuery reports whether he is asking what he told her.
|
||||
func isHistoryQuery(u string) bool {
|
||||
_, ok := historyAsks(u)
|
||||
return ok
|
||||
}
|
||||
|
||||
// historyAsks reports whether this is a history question, and whose turn it is
|
||||
// about.
|
||||
func historyAsks(u string) (historySide, bool) {
|
||||
s := " " + strings.ToLower(strings.TrimSpace(u)) + " "
|
||||
if s == " " {
|
||||
return historyAskedHim, false
|
||||
return false
|
||||
}
|
||||
for _, r := range historyRecall {
|
||||
if strings.Contains(s, r) {
|
||||
return historyAskedHim, false
|
||||
return false
|
||||
}
|
||||
}
|
||||
for _, pair := range historyMarkersEn {
|
||||
if strings.Contains(s, pair[0]) && strings.Contains(s, pair[1]) {
|
||||
if strings.Contains(pair[0], "you") {
|
||||
return historyAskedHer, true
|
||||
}
|
||||
return historyAskedHim, true
|
||||
return true
|
||||
}
|
||||
}
|
||||
toks := historyTokens(s)
|
||||
if !hasAny(toks, "что", "чего") {
|
||||
return historyAskedHim, false
|
||||
return false
|
||||
}
|
||||
// His side is tested first: "отмечать" is on both verb lists, so "что я
|
||||
// отметил" must not read as a question about her.
|
||||
if hasAny(toks, firstPersonSubjects...) && hasVerbForm(toks, historySpokenVerbs) {
|
||||
return historyAskedHim, true
|
||||
return true
|
||||
}
|
||||
if hasAny(toks, secondPersonSubjects...) && hasVerbForm(toks, historyRecordedVerbs) {
|
||||
return historyAskedHer, true
|
||||
}
|
||||
return historyAskedHim, false
|
||||
return hasAny(toks, secondPersonSubjects...) && hasVerbForm(toks, historyRecordedVerbs)
|
||||
}
|
||||
|
||||
// historyTokens splits an utterance into bare words. The punctuation goes
|
||||
@@ -168,8 +142,7 @@ const historyWindow = 24 * time.Hour
|
||||
// pass: the notes pass would otherwise answer this from whatever note happens
|
||||
// to be nearest, which reads as an answer and is not one.
|
||||
func (h *reactiveHandler) queryHistory(ctx context.Context, t *queryTurn) (string, bool) {
|
||||
side, ok := historyAsks(t.dec.Utterance)
|
||||
if !ok {
|
||||
if !isHistoryQuery(t.dec.Utterance) {
|
||||
return "", false
|
||||
}
|
||||
facts, err := h.api.RecentFacts(ctx, historyScan)
|
||||
@@ -191,14 +164,8 @@ func (h *reactiveHandler) queryHistory(ctx context.Context, t *queryTurn) (strin
|
||||
if len(said) == 0 {
|
||||
// Claim the turn rather than fall through. "ничего не говорил" is the
|
||||
// true answer, and recall would answer it with an old note instead.
|
||||
if side == historyAskedHer {
|
||||
return "за последние сутки я ничего с твоих слов не записывала.", true
|
||||
}
|
||||
return "за последние сутки ты мне ничего такого не говорил.", true
|
||||
}
|
||||
if side == historyAskedHer {
|
||||
return "я записала: " + strings.Join(said, "; "), true
|
||||
}
|
||||
return "ты говорил: " + strings.Join(said, "; "), true
|
||||
}
|
||||
|
||||
|
||||
@@ -89,33 +89,6 @@ func TestHistoryReadsOnlyWhatHeSaid(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The rows are the same either way, because a tapped fact is one act seen from
|
||||
// two sides. The sentence is not: "что ты записала" answered with "ты говорил"
|
||||
// hands the question back (Vikunja #456).
|
||||
func TestHistoryAnswersTheSideItWasAsked(t *testing.T) {
|
||||
now := time.Date(2026, 8, 4, 20, 0, 0, 0, time.UTC)
|
||||
h, _ := historyHandler(now, ipc.Fact{Key: "water", Value: "выпил", Source: "tap:voice", Ts: now.Add(-time.Hour)})
|
||||
|
||||
his, ok := askHistory(h, "что я тебе говорил?")
|
||||
if !ok || !strings.HasPrefix(his, "ты говорил") {
|
||||
t.Errorf("reply = %q, ok = %v, want his side", his, ok)
|
||||
}
|
||||
hers, ok := askHistory(h, "что ты записала сегодня?")
|
||||
if !ok || !strings.HasPrefix(hers, "я записала") {
|
||||
t.Errorf("reply = %q, ok = %v, want her side", hers, ok)
|
||||
}
|
||||
// "отмечать" is on both verb lists, so his subject has to win.
|
||||
if side, ok := historyAsks("что я отметил?"); !ok || side != historyAskedHim {
|
||||
t.Errorf("historyAsks(что я отметил) = %v, %v", side, ok)
|
||||
}
|
||||
|
||||
empty, _ := historyHandler(now)
|
||||
none, ok := askHistory(empty, "что ты записала сегодня?")
|
||||
if !ok || !strings.Contains(none, "не записывала") {
|
||||
t.Errorf("empty reply = %q, ok = %v, want her side", none, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// Nothing said is an answer of its own. Falling through would hand the question
|
||||
// to recall, which answers it with an old note.
|
||||
func TestHistorySaysWhenThereIsNothing(t *testing.T) {
|
||||
|
||||
@@ -128,8 +128,6 @@ func run(args []string) error {
|
||||
wrappedKeyPath := flag.String("wrapped-key-file", "", "path to wrapped encryption key blob (enables cold-start unlock)")
|
||||
reembed := flag.Bool("reembed", false, "re-embed every stored note and fact with the configured embedder, then serve normally (run once after an embedder swap; the daemon does not answer until it finishes)")
|
||||
allowSeed := flag.Bool("allow-seed", false, "enable the backdated seed_event write path (QA only: it lets a caller place a fact in the past and mint a routine the tick loop will then act on; off means the method has nothing to write with)")
|
||||
wipe := flag.Bool("wipe", false, "print every table and its row count, then exit without serving; add -confirm-wipe to delete all of it")
|
||||
confirmWipe := flag.Bool("confirm-wipe", false, "with -wipe, actually remove every piece of personal data (facts, notes, vectors, events, tasks, sessions, traces, voiceprints). config, models, passkeys and the encryption key are files and survive")
|
||||
flag.CommandLine.Parse(args)
|
||||
reembedOnStart = *reembed
|
||||
allowSeedOnStart = *allowSeed
|
||||
@@ -212,14 +210,6 @@ func run(args []string) error {
|
||||
}()
|
||||
}
|
||||
|
||||
// ----- wipe: never serves, exits when it is done (Vikunja #494) -----
|
||||
if *wipe {
|
||||
if locked {
|
||||
return fmt.Errorf("wipe: the store is locked and there is no key to open it with")
|
||||
}
|
||||
return runWipe(ctx, st, os.Stdout, *confirmWipe)
|
||||
}
|
||||
|
||||
// ----- daemon components (only wired when unlocked) -----
|
||||
// Pre-declare so the unlock path can wire them later.
|
||||
var (
|
||||
@@ -351,7 +341,6 @@ func run(args []string) error {
|
||||
getDayPlan: func(ctx context.Context) ipc.DayPlan { return tl.dayPlan(ctx, time.Now()) },
|
||||
getEvents: intakeEventsFn(evBus),
|
||||
seedStore: seedStoreIfAllowed(st),
|
||||
nexus: nexusOf(voiceW),
|
||||
}
|
||||
if voiceW != nil && voiceW.handler != nil {
|
||||
api := coreAPI.(*daemonAPI)
|
||||
|
||||
+25
-23
@@ -8,7 +8,6 @@ import (
|
||||
"unicode"
|
||||
|
||||
"github.com/kami/maven/internal/dialogue"
|
||||
"github.com/kami/maven/internal/lexicon"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
@@ -27,41 +26,44 @@ import (
|
||||
// does not say what to do with it. Acting on the bare word would guess, and a
|
||||
// wrong guess here closes work he never finished.
|
||||
|
||||
// The position words come from the lexicon, which lists every form with its
|
||||
// position and "последний" as -1 (V-522). They used to be stem prefixes here —
|
||||
// {"перв", 1}, {"втор", 2} — which is the shape that sweep removed: a stem
|
||||
// decides meaning by guessing where a word ends, and "трет" also opens
|
||||
// "third-party". The lexicon runs to twelve rather than five, so he can pick
|
||||
// past the fifth of a longer list; resolveCandidate already answers a position
|
||||
// she did not read.
|
||||
// candidateOrdinals — the words that pick a position, by index. Prefix match,
|
||||
// because Russian declines them: "первый", "первую", "первое".
|
||||
var candidateOrdinals = []struct {
|
||||
word string
|
||||
nth int
|
||||
}{
|
||||
{"перв", 1}, {"втор", 2}, {"трет", 3}, {"четв", 4}, {"пят", 5},
|
||||
{"first", 1}, {"second", 2}, {"third", 3},
|
||||
}
|
||||
|
||||
// candidateDigits — "второй" said as a number. Matched whole, never by prefix:
|
||||
// "15" starts with "1" and is a time, not a position. Digits are not a Russian
|
||||
// word list, so they stay here rather than in the lexicon.
|
||||
// "15" starts with "1" and is a time, not a position.
|
||||
var candidateDigits = map[string]int{"1": 1, "2": 2, "3": 3, "4": 4, "5": 5}
|
||||
|
||||
// candidateLast — "последний" picks the end of the list whatever its length.
|
||||
var candidateLast = []string{"последн", "last"}
|
||||
|
||||
// parseOrdinal reads which position he named. 0 and false when he named none.
|
||||
// A negative result means the last one.
|
||||
func parseOrdinal(text string) (int, bool) {
|
||||
// Token by token, not substring: " 1" would otherwise match inside
|
||||
// "напомни в 15:00" and turn a reminder into a selection.
|
||||
toks := strings.FieldsFunc(strings.ToLower(text), func(r rune) bool {
|
||||
for _, tok := range strings.FieldsFunc(strings.ToLower(text), func(r rune) bool {
|
||||
return !unicode.IsLetter(r) && !unicode.IsDigit(r)
|
||||
})
|
||||
for i, tok := range toks {
|
||||
}) {
|
||||
for _, w := range candidateLast {
|
||||
if strings.HasPrefix(tok, w) {
|
||||
return -1, true
|
||||
}
|
||||
}
|
||||
if n, ok := candidateDigits[tok]; ok {
|
||||
return n, true
|
||||
}
|
||||
// A spoken half hour names the hour it is entering with the same
|
||||
// genitive ordinal: "в половине восьмого" is 07:30, not the eighth
|
||||
// thing she read out. She reads a list and he answers with a time
|
||||
// often enough that this has to be declined here, or the reminder
|
||||
// becomes a selection.
|
||||
if i > 0 && lexicon.IsHalfHour(toks[i-1]) {
|
||||
continue
|
||||
}
|
||||
if n, ok := lexicon.Ordinal(tok); ok {
|
||||
return n, true
|
||||
for _, o := range candidateOrdinals {
|
||||
// Prefix, because Russian declines them: "первый", "первую".
|
||||
if strings.HasPrefix(tok, o.word) {
|
||||
return o.nth, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
|
||||
@@ -27,16 +27,6 @@ func TestParseOrdinalReadsThePosition(t *testing.T) {
|
||||
{"", 0, false},
|
||||
// A digit inside a time is not a position.
|
||||
{"напомни в 15:00", 0, false},
|
||||
// Forms the stem list used to miss, and positions past its fifth.
|
||||
{"вторым", 2, true},
|
||||
{"седьмую", 7, true},
|
||||
{"одиннадцатый", 11, true},
|
||||
// A spoken half hour names its hour with the same genitive ordinal, so
|
||||
// this is 07:30 and not the eighth thing she read out (V-522).
|
||||
{"напомни в половине восьмого", 0, false},
|
||||
{"полвосьмого", 0, false},
|
||||
// The ordinal still wins when the half word is not in front of it.
|
||||
{"восьмую сделал", 8, true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got, ok := parseOrdinal(c.text)
|
||||
|
||||
@@ -77,32 +77,6 @@ var worldSeeds = []string{
|
||||
"что мне почитать про историю",
|
||||
"что я должен знать про питон",
|
||||
"what can i watch tonight",
|
||||
// A third shape that looks personal and is not: asking when something
|
||||
// happens (Vikunja #553). "во сколько закат сегодня" scored personal,
|
||||
// because "что у меня сегодня" and "когда моя встреча" put that frame on
|
||||
// the personal side and nothing here answered it. The sunset is the one
|
||||
// thing on his list that is the same for everybody standing outside.
|
||||
// "сегодня" is carried on purpose. Without it these caught nothing: the
|
||||
// day word is most of what pulls the frame personal, because "что у меня
|
||||
// сегодня" is a personal seed and the day word is the half it shares.
|
||||
"во сколько сегодня открывается магазин",
|
||||
"когда сегодня начинается матч",
|
||||
"во сколько сегодня восход солнца",
|
||||
// The other frame a day word carries, and the same story: "что у меня
|
||||
// сегодня" is a personal seed, so "какой сегодня праздник" and "что
|
||||
// интересного произошло сегодня в мире" were refused as his after the
|
||||
// topic seeds had already let them past the weather source.
|
||||
"какой сегодня курс валют",
|
||||
"что сегодня происходит в мире",
|
||||
// The narrative shape (Vikunja #554). "расскажи про Байкал" was refused as
|
||||
// his by 0.0052, and nothing here was phrased as an order rather than a
|
||||
// question: every world seed above opens with an interrogative. So a world
|
||||
// question that names its subject and asks for prose landed nearer "я тебе
|
||||
// рассказывал об этом?", which is the same verb about his own words.
|
||||
"расскажи про байкал",
|
||||
"расскажи про древний рим",
|
||||
"объясни как работает двигатель",
|
||||
"tell me about the roman empire",
|
||||
}
|
||||
|
||||
// personalBoundary holds the embedded seeds. Zero value is usable and means
|
||||
|
||||
@@ -68,27 +68,6 @@ func TestONNXPersonalBoundary(t *testing.T) {
|
||||
{"я хочу узнать про рим", false},
|
||||
{"кто такой гагарин", false},
|
||||
{"how do i boil an egg", false},
|
||||
// Asking when a public thing happens (Vikunja #553). "во сколько закат
|
||||
// сегодня" was answered "не знаю — не нашла у тебя такой записи",
|
||||
// because the frame lived only on the personal side. The pair above it
|
||||
// is the control: "во сколько у меня встреча" is the same frame about
|
||||
// something that IS his, and it has to stay personal.
|
||||
{"во сколько закат сегодня", false},
|
||||
{"когда сегодня заканчивается концерт", false},
|
||||
{"во сколько завтра открывается аптека", false},
|
||||
// The "какой сегодня X" frame. These clear the weather topic after the
|
||||
// V-553 seeds and were then refused here, which is the same defect one
|
||||
// source further down the chain.
|
||||
{"какой сегодня праздник", false},
|
||||
{"что интересного произошло сегодня в мире", false},
|
||||
{"кто выиграл вчера матч", false},
|
||||
// The narrative shape, held out from the seeds above (Vikunja #554).
|
||||
// The control is the row after them: the same verb about his own words
|
||||
// is still his.
|
||||
{"расскажи про эверест", false},
|
||||
{"расскажи про войну 1812 года", false},
|
||||
{"объясни что такое инфляция", false},
|
||||
{"я рассказывал тебе про байкал?", true},
|
||||
}
|
||||
|
||||
h := &reactiveHandler{recall: recallWiring{embedder: emb}}
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"regexp"
|
||||
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
)
|
||||
|
||||
// A question about her — "что ты умеешь", "кто ты" — used to have no answer at
|
||||
// all (Vikunja #555). It reached the personal boundary, which claimed it as his
|
||||
// and said "не знаю — не нашла у тебя такой записи", because the boundary knows
|
||||
// two sides and this is neither: her own description is not his data and it is
|
||||
// not the world's either. Letting it past the boundary is no better, because
|
||||
// then SearXNG answers about somebody else's assistant.
|
||||
//
|
||||
// The description does NOT live in the note store. Notes are his. A note about
|
||||
// her sitting in his index would come back for "что я записал", would be fed to
|
||||
// the digestion worker as something he said, and would be recalled by vector
|
||||
// proximity for questions that are not about her at all. It is her own text, so
|
||||
// it lives here, in one place, and it is the only copy.
|
||||
//
|
||||
// This source sits ABOVE the boundary, because a question about her never had
|
||||
// an answer below it.
|
||||
|
||||
// selfDescription — what she is and what this box actually does. Frozen text,
|
||||
// and the one rule for editing it: name only what is really wired. Anything
|
||||
// that depends on config — the house, the LAN, the feeds, telegram, search — is
|
||||
// named as depending on what he allowed, never claimed outright. Inventing a
|
||||
// capability here is the same defect as inventing a fact, and it is worse than
|
||||
// silence because he would plan around it.
|
||||
//
|
||||
// Written in her own voice, feminine, addressing him informally, because it is
|
||||
// handed to the phraser as the evidence for the answer and the phraser will
|
||||
// keep the words it is given.
|
||||
const selfDescription = `Я Мэйвен, твоя помощница. Я живу на твоём сервере, ` +
|
||||
`и наружу уходит только поисковый запрос — больше ничего.
|
||||
|
||||
Что я делаю сама: запоминаю, что ты мне говоришь, и потом отвечаю на вопросы ` +
|
||||
`об этом; веду заметки; ставлю напоминания; читаю твой календарь и задачи; ` +
|
||||
`отвечаю на вопросы о мире — сначала поиском, а если сети нет, то по ` +
|
||||
`офлайновой энциклопедии.
|
||||
|
||||
Что зависит от того, что ты мне разрешил: дом, локальная сеть, ленты, ` +
|
||||
`список покупок, погода, телеграм. Если что-то из этого не настроено, я ` +
|
||||
`скажу об этом прямо, а не буду выдумывать ответ.
|
||||
|
||||
Говорю по-русски и по-английски.`
|
||||
|
||||
// selfSeeds — the questions this source claims. Scoring data like every other
|
||||
// topic set: editing one moves the recogniser and has to be re-measured against
|
||||
// TestONNXTopics.
|
||||
//
|
||||
// All of them are about HER — what she is, what she can do, who made her. The
|
||||
// neighbouring set is topicAttend, "что требует внимания", which asks about the
|
||||
// state of his things; the two share almost nothing but the second person.
|
||||
var selfSeeds = []string{
|
||||
"что ты умеешь",
|
||||
"что ты можешь делать",
|
||||
"кто ты такая",
|
||||
"расскажи о себе",
|
||||
"какие у тебя возможности",
|
||||
// Added after measuring: it won self by 0.0002, under the margin, and the
|
||||
// floor does not carry it — "способна" names no verb the floor matches.
|
||||
"на что ты способна",
|
||||
"чем ты можешь помочь",
|
||||
"what can you do",
|
||||
"who are you",
|
||||
}
|
||||
|
||||
// selfFloor — the offline floor, for a handler with no embedder or a turn whose
|
||||
// vector never got computed. Narrow on purpose, like every other floor here: it
|
||||
// answers only when the seeds cannot, and a broad guess made blind is worse
|
||||
// than a narrow one.
|
||||
//
|
||||
// Go's \b is ASCII-only and never fires next to a Cyrillic letter, so the
|
||||
// Russian patterns spell the boundary out.
|
||||
var selfPatterns = []*regexp.Regexp{
|
||||
regexp.MustCompile(`(?i)(^|[^\p{L}\p{N}])ты\s+(умеешь|можешь)([^\p{L}\p{N}]|$)`),
|
||||
regexp.MustCompile(`(?i)(^|[^\p{L}\p{N}])кто\s+ты([^\p{L}\p{N}]|$)`),
|
||||
regexp.MustCompile(`(?i)(^|[^\p{L}\p{N}])(расскажи|поведай)\s+о\s+себе([^\p{L}\p{N}]|$)`),
|
||||
regexp.MustCompile(`(?i)\bwhat\s+can\s+you\s+do\b`),
|
||||
regexp.MustCompile(`(?i)\bwho\s+are\s+you\b`),
|
||||
}
|
||||
|
||||
func selfFloor(utterance string) bool {
|
||||
for _, re := range selfPatterns {
|
||||
if re.MatchString(utterance) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// querySelf answers a question about her from selfDescription. The description
|
||||
// goes through the phraser as evidence so the answer is shaped to what he
|
||||
// asked — "что ты умеешь" and "кто ты" want different halves of it — and falls
|
||||
// back to the text itself, which is already readable, if the model is down.
|
||||
func (h *reactiveHandler) querySelf(ctx context.Context, t *queryTurn) (string, bool) {
|
||||
if !h.turnIsAbout(ctx, t, topicSelf, selfFloor) {
|
||||
return "", false
|
||||
}
|
||||
var reply string
|
||||
if h.phraser != nil {
|
||||
var err error
|
||||
reply, err = h.phraser.PhraseSelf(ctx, t.dec.Utterance, selfDescription)
|
||||
if err != nil {
|
||||
log.Printf("voice: phrase self: %v", err)
|
||||
}
|
||||
}
|
||||
if reply == "" {
|
||||
reply = phraser.Q(phraser.QueryFound, map[string]string{"text": selfDescription})
|
||||
}
|
||||
return reply, true
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
// TestSelfFloorClaimsAQuestionAboutHerAndNothingElse — the offline floor, which
|
||||
// is what answers with no embedder. Narrow on purpose, so the rows that must
|
||||
// NOT match are the point.
|
||||
func TestSelfFloorClaimsAQuestionAboutHerAndNothingElse(t *testing.T) {
|
||||
claimed := []string{
|
||||
"что ты умеешь",
|
||||
"что ты можешь",
|
||||
"а что ты умеешь?",
|
||||
"кто ты",
|
||||
"кто ты такая?",
|
||||
"расскажи о себе",
|
||||
"what can you do",
|
||||
"who are you",
|
||||
}
|
||||
for _, u := range claimed {
|
||||
if !selfFloor(u) {
|
||||
t.Errorf("%q is a question about her and the floor missed it", u)
|
||||
}
|
||||
}
|
||||
declined := []string{
|
||||
"что у меня сегодня",
|
||||
"расскажи про байкал",
|
||||
"кто изобрёл телефон",
|
||||
"что требует внимания",
|
||||
"запиши что я пил воду",
|
||||
// The floor spells its own word boundaries out, because Go's \b never
|
||||
// fires next to a Cyrillic letter. Without that these would match.
|
||||
"кто тыкал в розетку",
|
||||
"расскажи о себестоимости",
|
||||
}
|
||||
for _, u := range declined {
|
||||
if selfFloor(u) {
|
||||
t.Errorf("%q is not about her and the floor claimed it", u)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSelfSourceAnswersFromTheDescription — with no embedder the source falls
|
||||
// to the floor, and the answer has to be the description rather than silence.
|
||||
func TestSelfSourceAnswersFromTheDescription(t *testing.T) {
|
||||
h := personalHandler()
|
||||
reply, claimed := h.querySelf(context.Background(), &queryTurn{
|
||||
dec: router.Decision{Utterance: "что ты умеешь"},
|
||||
})
|
||||
if !claimed {
|
||||
t.Fatal("a question about her must be claimed above the boundary")
|
||||
}
|
||||
if !strings.Contains(reply, "напоминания") {
|
||||
t.Errorf("the answer must come from the description: %q", reply)
|
||||
}
|
||||
if _, claimed := h.querySelf(context.Background(), &queryTurn{
|
||||
dec: router.Decision{Utterance: "почему небо синее"},
|
||||
}); claimed {
|
||||
t.Error("a world question must pass this source")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSelfDescriptionHoldsThePersona — it is her own text and she reads it out,
|
||||
// so the same rules the phrasing eval enforces apply to it. Feminine
|
||||
// self-reference, informal address, no pet names.
|
||||
func TestSelfDescriptionHoldsThePersona(t *testing.T) {
|
||||
lower := strings.ToLower(selfDescription)
|
||||
for _, bad := range []string{"я рад ", "я готов ", "вы ", "ваш", "милый", "дорогой"} {
|
||||
if strings.Contains(lower, bad) {
|
||||
t.Errorf("the description breaks the persona on %q", bad)
|
||||
}
|
||||
}
|
||||
for _, want := range []string{"тво", "ты"} {
|
||||
if !strings.Contains(lower, want) {
|
||||
t.Errorf("the description must address him directly, missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSelfDescriptionClaimsNothingUnconditionally — the constraint that makes
|
||||
// this text safe to read out. Every capability that depends on config has to be
|
||||
// named as depending on it, and inventing one here is the same defect as
|
||||
// inventing a fact.
|
||||
func TestSelfDescriptionClaimsNothingUnconditionally(t *testing.T) {
|
||||
conditional := selfDescription[strings.Index(selfDescription, "Что зависит"):]
|
||||
for _, cap := range []string{"дом", "локальная сеть", "ленты", "список покупок", "погода", "телеграм"} {
|
||||
if !strings.Contains(conditional, cap) {
|
||||
t.Errorf("%q is configured, not wired — it must sit under the conditional half", cap)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -347,55 +347,6 @@ func (s *scriptedLLM) Complete(_ context.Context, r llm.Req) (string, error) {
|
||||
map[bool]string{true: "route", false: "reply"}[routing], truncateRunes(r.User, 60))
|
||||
}
|
||||
|
||||
// scriptedPhraser answers the chat path from the same script the router reads.
|
||||
//
|
||||
// It exists because actionChat calls h.phraser.PhraseChat, and the production
|
||||
// implementation posts raw HTTP to /v1/chat/completions rather than going
|
||||
// through the llm client scriptedLLM stands in for. So until this, no scenario
|
||||
// could script what she SAYS on a chat turn: the simulator wired phraser.NewStub()
|
||||
// and every chat reply came back as a pick from fallbacks_ru_v1.json, four
|
||||
// variants deep, which varied between two runs of one scenario (V-542 item 4).
|
||||
//
|
||||
// Everything except PhraseChat is the Stub's, by embedding. A nudge and a
|
||||
// reminder are phrased by the tick loop, which has its own phraser and its own
|
||||
// assertions; this seam is only about the conversation.
|
||||
type scriptedPhraser struct {
|
||||
*phraser.Stub
|
||||
entries []scriptEntry
|
||||
}
|
||||
|
||||
// PhraseChat returns the scripted reply for the utterance, or an error when the
|
||||
// scenario scripted none. The error rather than a fallback is deliberate and
|
||||
// matches scriptedLLM: actionChat logs it and falls back to ChatFallback(), so a
|
||||
// scenario that never meant to assert on a chat reply behaves exactly as it did
|
||||
// before, and one that DID means to is told its script has a hole.
|
||||
func (p *scriptedPhraser) PhraseChat(_ context.Context, utterance string, _ []dialogue.Turn) (string, error) {
|
||||
for _, e := range p.entries {
|
||||
if e.Reply == "" {
|
||||
continue
|
||||
}
|
||||
if e.Match != "" && !strings.Contains(strings.ToLower(utterance), strings.ToLower(e.Match)) {
|
||||
continue
|
||||
}
|
||||
return chatReplyText(e.Reply), nil
|
||||
}
|
||||
return "", fmt.Errorf("simulator: no scripted chat reply for %q", truncateRunes(utterance, 60))
|
||||
}
|
||||
|
||||
// chatReplyText reads a scripted reply in either shape the phrasing contract
|
||||
// allows: the {"response","mood"} object the model emits, or plain text.
|
||||
// LLMPhraser does this parse itself, so a scenario writes one thing and both
|
||||
// paths understand it.
|
||||
func chatReplyText(reply string) string {
|
||||
var out struct {
|
||||
Response string `json:"response"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(reply), &out); err == nil && out.Response != "" {
|
||||
return out.Response
|
||||
}
|
||||
return reply
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Building the world
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -489,7 +440,7 @@ func newSimWorld(t *testing.T, sc scenario) *simWorld {
|
||||
api: api,
|
||||
matcher: matcher,
|
||||
tools: tool.NewExecutor(api, 5*time.Second),
|
||||
phraser: &scriptedPhraser{Stub: phraser.NewStub(), entries: sc.Script},
|
||||
phraser: phraser.NewStub(),
|
||||
replier: newLLMReplier(scripted, nil),
|
||||
now: clock.Now,
|
||||
dataStore: st,
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"name": "conversation_anaphora",
|
||||
"description": "Five consecutive Russian turns about one object, replayed from the run that found V-542 on the box on 05-08-2026. He names a monitor, then asks four questions that all say \"он\" and never name it again.\n\nThis scenario exists because the shape had nowhere to fail. The routing fixture scores one utterance at a time, so a conversation that breaks on its second turn cannot lose a point there, and V-44 step 2 could only be verified by hand. That is item 3 of V-542.\n\nFour of the five replies below are WRONG, and the assertions pin them anyway. Read them as the recorded defect rather than the contract: she has the last four turns in front of her and never once names the thing he is asking about. Every wrong assertion is marked in its step note with what it must become. When V-542 lands, those flip and the ones marked correct do not move.\n\nWhat the four assert is that the reply LACKS \"монитор\". Absence is the defect itself: she is answering a question about a thing she wrote down two minutes ago and cannot name it. It also survives the fallback picker, which matters on the three query turns — they refuse from internal/phraser/fallbacks_ru_v1.json, four variants deep, and the same scenario returned \"тут я пас.\" one run and \"не знаю, честно.\" the next, so a string assertion there would pin the picker rather than the daemon.\n\nTurn 4 asserts its text as well, because that turn goes through the chat path and the chat path is now scriptable. scriptedPhraser in simulator_test.go answers PhraseChat from the same script entries the router reads (V-542 item 4); before it, the simulator wired phraser.NewStub() and no scenario could say what she SAYS on a chat turn at all.\n\nThe routes are scripted exactly as the box produced them, because the failure is not the model's. Turn 1 went to fact despite \"давай поболтаем\", every question after it went to query, and turn 4 went to chat. A scripted route is what lets this scenario pin the daemon's half without a llama-server in the loop.",
|
||||
"start": "2026-08-05T14:00:00+03:00",
|
||||
"script": [
|
||||
{
|
||||
"match": "купил новый монитор",
|
||||
"route": "[{\"intent\":\"fact\",\"key\":\"purchase\",\"value\":\"новый монитор\"}]",
|
||||
"reply": "{\"response\":\"записала: новый монитор.\",\"mood\":\"neutral\"}"
|
||||
},
|
||||
{
|
||||
"match": "он большой",
|
||||
"route": "[{\"intent\":\"query\",\"text\":\"а он большой?\"}]"
|
||||
},
|
||||
{
|
||||
"match": "сколько он примерно стоит",
|
||||
"route": "[{\"intent\":\"query\",\"text\":\"сколько он примерно стоит по-твоему?\"}]"
|
||||
},
|
||||
{
|
||||
"match": "переплатил",
|
||||
"route": "[{\"intent\":\"chat\",\"text\":\"мне кажется я переплатил\"}]",
|
||||
"reply": "{\"response\":\"я не знаю, о каком именно устройстве ты говоришь.\",\"mood\":\"neutral\"}"
|
||||
},
|
||||
{
|
||||
"match": "стоит его вернуть",
|
||||
"route": "[{\"intent\":\"query\",\"text\":\"стоит его вернуть?\"}]"
|
||||
},
|
||||
{
|
||||
"match": "",
|
||||
"route": "[{\"intent\":\"chat\",\"text\":\"\"}]",
|
||||
"reply": "{\"response\":\"я рада тебя слышать.\",\"mood\":\"happy\"}"
|
||||
}
|
||||
],
|
||||
"steps": [
|
||||
{
|
||||
"at": "14:00",
|
||||
"note": "CORRECT, and it is the first half of the defect. \"давай поболтаем\" is an explicit request to converse and the turn is filed as a fact anyway. Storing what he said is not wrong on its own — he did buy a monitor — but the object then lives in the fact store and never enters the transcript PhraseChat reads. That is V-542 decision 2: either the marker claims the turn at stage 0, or it means nothing and comes out of the fixture.",
|
||||
"say": "давай поболтаем: я вчера купил новый монитор",
|
||||
"expect_events": ["purchase"],
|
||||
"expect_no_send": true
|
||||
},
|
||||
{
|
||||
"at": "14:01",
|
||||
"note": "WRONG. \"он\" is the monitor from one turn ago, and she says she has no record of it. followUpMerge inherits prev.Slots.Key, and a query turn asking about a pronoun has no key to merge, so the question reaches the query sources naked and the notes source answers the only way it can. Must become: an answer about the monitor, or a route to chat where the transcript is.",
|
||||
"say": "а он большой?",
|
||||
"expect_reply_lacks": ["монитор"],
|
||||
"expect_no_send": true
|
||||
},
|
||||
{
|
||||
"at": "14:02",
|
||||
"note": "WRONG, and it rules out one explanation. This is not the previous turn failing to stick — it is the same wall a second time, two turns from where the monitor was named. Nothing accumulates across query turns.",
|
||||
"say": "сколько он примерно стоит по-твоему?",
|
||||
"expect_reply_lacks": ["монитор"],
|
||||
"expect_no_send": true
|
||||
},
|
||||
{
|
||||
"at": "14:03",
|
||||
"note": "WRONG, and it is the same wall from the other side. This turn routed chat, so it HAD the history that Session.History holds, and it asks which device he means anyway — because turn 1's object went to the fact store rather than the transcript. So a source reading the conversation is not sufficient on its own; decision 1 has to say which store the referent comes from. This is the one step whose text is pinned: the reply is scripted and reaches PhraseChat, so it is the box's own words rather than a fallback pick. Must become: a reply that names the monitor.",
|
||||
"say": "мне кажется я переплатил",
|
||||
"expect_reply_contains": ["о каком именно устройстве"],
|
||||
"expect_reply_lacks": ["монитор"],
|
||||
"expect_no_send": true
|
||||
},
|
||||
{
|
||||
"at": "14:04",
|
||||
"note": "WRONG. The fifth turn is the one that shows the cost. A returns question about a purchase two minutes old is answered with \"не нашла у тебя такой записи\", which is wrong in kind rather than merely unhelpful: the record exists, she wrote it herself at 14:00 under the key purchase.",
|
||||
"say": "стоит его вернуть?",
|
||||
"expect_reply_lacks": ["монитор"],
|
||||
"expect_no_send": true
|
||||
},
|
||||
{
|
||||
"at": "14:05",
|
||||
"note": "CORRECT, and it is the control. Nothing in five conversational turns was sent at him unprompted, and a tick with him mid-conversation stays silent. Whatever V-542 changes must not change this.",
|
||||
"tick": true,
|
||||
"expect_no_send": true
|
||||
}
|
||||
]
|
||||
}
|
||||
+2
-2
@@ -74,9 +74,9 @@
|
||||
},
|
||||
{
|
||||
"at": "08:50",
|
||||
"note": "he asks what he missed, and Praxis holds one unresolved item — the morning medicine — so she reads that back. This step pinned \"не знаю\" until 05-08-2026, and that was the keyword floor's blind spot rather than a rule: isAttentionQuery does not match \"что я пропустил\", while the topicAttend seeds carry \"что важное я пропустил\" almost verbatim. The seeds only started deciding when turnVector fixed the empty query vector every topic source was reading (V-547). Reading a surfaced item aloud is not inventing a morning summary, so the no-hallucination floor still holds; what moved is which source answers. Note what the persona check here is and is not: the reply is a constant in the Go source, so expect_reply_lacks pins that constant, not anything the model wrote. The step below is the one that reads model output.",
|
||||
"note": "he asks. The query path answers from local recall only: nothing stored clears the score gate, so she refuses rather than inventing a morning summary, and the replier is never reached. That refusal is the no-hallucination floor and this step pins it. Note what the persona check here is and is not: the reply is a constant in the Go source, so expect_reply_lacks pins that constant, not anything the model wrote. The step below is the one that reads model output.",
|
||||
"say": "что я пропустил?",
|
||||
"expect_reply_contains": ["требует внимания", "morning_medicine"],
|
||||
"expect_reply_contains": ["не знаю"],
|
||||
"expect_reply_lacks": ["рад ", "милый", "ваш"]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -23,11 +23,6 @@ type daemonAPI struct {
|
||||
chatFn func(ctx context.Context, conversation, text string) string
|
||||
getMCPServers func() []ipc.MCPServerStatus
|
||||
getEvents func(n int) []ipc.IntakeEvent
|
||||
// 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
|
||||
// instead of storing a name.
|
||||
nexus *nexusClient
|
||||
// seedStore — non-nil ONLY when mavend was started with -allow-seed. It is
|
||||
// the whole off-switch for the backdated write path (Vikunja #518), and it
|
||||
// is a store rather than a bool so that leaving the flag off means the
|
||||
@@ -46,48 +41,6 @@ func (d *daemonAPI) RecentEvents(ctx context.Context, n int) ([]ipc.IntakeEvent,
|
||||
return d.getEvents(n), nil
|
||||
}
|
||||
|
||||
// nexusOf — the identity client the voice wiring built, or nil. Same shape as
|
||||
// embedderOf: a wiring that is absent and a wiring with no nexus block are one
|
||||
// answer here.
|
||||
func nexusOf(w *voiceWiring) *nexusClient {
|
||||
if w == nil || w.handler == nil || w.handler.ecosystem == nil {
|
||||
return nil
|
||||
}
|
||||
return w.handler.ecosystem.nexus
|
||||
}
|
||||
|
||||
// ResolveEntity asks Nexus for the canonical id behind a name (Vikunja #511).
|
||||
//
|
||||
// Three outcomes, kept apart on purpose. No nexus block is ErrNotImplemented,
|
||||
// so a surface can say "identity is not configured here" rather than invent an
|
||||
// id. A miss is ipc.ErrNoEntity. A match against several entities comes back
|
||||
// Ambiguous with the names, because picking one is how a task ends up blocked
|
||||
// on the wrong person and nobody can see it happened.
|
||||
func (d *daemonAPI) ResolveEntity(ctx context.Context, query string, types []string) (ipc.EntityRef, error) {
|
||||
if d.nexus == nil {
|
||||
return ipc.EntityRef{}, ipc.ErrNotImplemented
|
||||
}
|
||||
res, err := d.nexus.Resolve(ctx, query, types)
|
||||
if err != nil {
|
||||
return ipc.EntityRef{}, err
|
||||
}
|
||||
if len(res.Candidates) > 1 {
|
||||
names := make([]string, 0, len(res.Candidates))
|
||||
for _, c := range res.Candidates {
|
||||
names = append(names, c.DisplayName)
|
||||
}
|
||||
return ipc.EntityRef{Ambiguous: true, Candidates: names}, nil
|
||||
}
|
||||
if res.Entity == nil || res.Entity.ID == "" {
|
||||
return ipc.EntityRef{}, ipc.ErrNoEntity
|
||||
}
|
||||
return ipc.EntityRef{
|
||||
ID: res.Entity.ID,
|
||||
Type: res.Entity.Type,
|
||||
DisplayName: res.Entity.DisplayName,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Chat runs one text turn and reports which query source claimed it. The sink
|
||||
// rides the context so handleText keeps the one string signature the mic,
|
||||
// telegram and the web all call it through (V-539).
|
||||
|
||||
+4
-121
@@ -9,7 +9,7 @@ import (
|
||||
)
|
||||
|
||||
// Which subject is this question about — the weather, the house, the LAN, what
|
||||
// needs looking at, his feeds, or none of them. Third of the three mechanisms replacing hand-written Russian
|
||||
// needs looking at, or none of them. Third of the three mechanisms replacing hand-written Russian
|
||||
// patterns (Vikunja #522, owner's call 2026-08-04). internal/lexicon holds the
|
||||
// sets that can be finished and internal/morph answers the grammar questions;
|
||||
// this is for the sets that can never be finished, because "is this about the
|
||||
@@ -37,11 +37,6 @@ import (
|
||||
// внимания", which is Praxis's operational state and reached the web search
|
||||
// before the source existed (Vikunja #475).
|
||||
//
|
||||
// A fifth joined on 05-08-2026: the feeds, "что нового в лентах". Its word lists
|
||||
// were the last pair of hand-written Russian stem lists in the router (V-522),
|
||||
// and they carried the same admission in their own comments — vagueNouns exists
|
||||
// because "что нового?" is a greeting that matched a feed noun.
|
||||
//
|
||||
// The regexes stay as the offline floor, unchanged, for a handler with no
|
||||
// embedder or a turn whose vector never got computed. They are allowed to remain
|
||||
// narrow now precisely because they are no longer the only answer.
|
||||
@@ -57,9 +52,6 @@ const (
|
||||
topicHome topicLabel = "home"
|
||||
topicNetwork topicLabel = "network"
|
||||
topicAttend topicLabel = "attention"
|
||||
topicFeed topicLabel = "feeds"
|
||||
topicList topicLabel = "list"
|
||||
topicSelf topicLabel = "self"
|
||||
topicOther topicLabel = "other"
|
||||
)
|
||||
|
||||
@@ -125,56 +117,7 @@ var topicSeedSets = map[topicLabel][]string{
|
||||
"what needs attention",
|
||||
"what needs looking at right now",
|
||||
},
|
||||
topicFeed: {
|
||||
"что нового в лентах",
|
||||
"какие новости",
|
||||
"что нового по технологиям",
|
||||
"почитай заголовки",
|
||||
// Two seeds carrying a day word beside the headlines. Without them
|
||||
// "какие сегодня заголовки" read as weather, because "какая сегодня
|
||||
// погода" is the nearest thing in the whole set with "сегодня" in it.
|
||||
"заголовки за сегодня",
|
||||
"какие главные новости за день",
|
||||
"покажи новости за сегодня",
|
||||
"что пишут в новостях",
|
||||
"что нового про политику",
|
||||
"расскажи что нового в ленте",
|
||||
"what is new in the feeds",
|
||||
"any news headlines today",
|
||||
},
|
||||
// Reading a standing list back, and only that. Adding to one and clearing
|
||||
// one stay on the phrase tables in internal/router/list.go — see its header
|
||||
// for why a span and a delete are not seed-shaped work.
|
||||
topicList: {
|
||||
"что в списке покупок",
|
||||
"что мне нужно купить",
|
||||
"прочитай список покупок",
|
||||
"покажи что в списке",
|
||||
"что осталось купить в магазине",
|
||||
"что мне нужно в аптеке",
|
||||
"какой у меня список покупок",
|
||||
"what is on my shopping list",
|
||||
"read me the grocery list",
|
||||
},
|
||||
// Questions about her (Vikunja #555). The set lives in self.go beside the
|
||||
// description it unlocks, so the two are edited together — a seed claiming
|
||||
// a question the description does not answer is the failure mode.
|
||||
//
|
||||
// "что ты умеешь" was a topicOther seed until this existed, put there so an
|
||||
// attention question had something to lose to. It is a self seed now, and
|
||||
// it cannot be both: a phrasing on two sides never clears the margin.
|
||||
topicSelf: selfSeeds,
|
||||
topicOther: {
|
||||
// A task question is not a list read-back. They collide on "что у меня",
|
||||
// and the list has its own table to lose to as well.
|
||||
"какие у меня задачи",
|
||||
"что у меня в делах",
|
||||
// The bare newness opener, which is a greeting and not a request for
|
||||
// headlines. It sits here on purpose: it is close enough to the feed
|
||||
// seeds that it will not clear topicMargin, and a thin call goes to
|
||||
// ParseFeedQuery, which declines a vague noun with no topic beside it.
|
||||
"что нового",
|
||||
"как дела",
|
||||
// Complaints, which are not requests to scan or to read the house.
|
||||
// isNetworkQuery's comment names this one: a scan she runs unasked is
|
||||
// the noisy behaviour the bounds exist to prevent.
|
||||
@@ -194,40 +137,9 @@ var topicSeedSets = map[topicLabel][]string{
|
||||
"что я говорил про бэкапы",
|
||||
"что у меня сегодня по календарю",
|
||||
"напомни мне позвонить маме",
|
||||
// An attention question is about the state of his things; this is not.
|
||||
"что ты умеешь",
|
||||
"what did i say about backups",
|
||||
// World questions that name a day (Vikunja #553). Weather was the only
|
||||
// topic whose seeds carry a day word — four of its eight do — so every
|
||||
// "какой сегодня X" landed nearest it and cleared the margin: the
|
||||
// dollar rate by 0.0220 and a public holiday by 0.0398, against 0.0883
|
||||
// for a real weather question. The gate then asked "для какого города?"
|
||||
// about the dollar.
|
||||
//
|
||||
// The margin was not the knob. 0.0398 is not a coin flip, and raising
|
||||
// the bar far enough to catch it would take real weather questions with
|
||||
// it. What was missing is the negative class: a day word means the
|
||||
// question is about a day, and says nothing about whether it is about
|
||||
// the sky.
|
||||
"сколько стоит биткоин сегодня",
|
||||
"какой завтра праздник в стране",
|
||||
"во сколько сегодня восход солнца",
|
||||
"кто вчера победил в чемпионате",
|
||||
// The frame itself, twice. "какая сегодня погода" is a weather seed,
|
||||
// and the four above did not move "какой сегодня курс доллара" or
|
||||
// "что интересного произошло сегодня в мире" off weather, because what
|
||||
// pulls them is the frame and not the noun. A frame that both topics
|
||||
// use has to sit on both sides, or the side that owns it wins every
|
||||
// noun it has never seen.
|
||||
"какой сегодня курс валют",
|
||||
"что сегодня происходит в мире",
|
||||
// The same story one topic over, found while verifying V-554 on the
|
||||
// box: "кто изобрёл телефон" ran a LAN scan and answered "нашла 3
|
||||
// устройства". The network set opens with "кто в сети сейчас" and
|
||||
// names devices throughout, so a "кто ..." question about any device
|
||||
// noun landed there. A device has a history, and asking about it is
|
||||
// not asking what is plugged in.
|
||||
"кто изобрёл телефон",
|
||||
"когда появился первый компьютер",
|
||||
"как работает роутер",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -289,35 +201,6 @@ func (x *topicIndex) best(vec []float32) (label topicLabel, margin float64, ok b
|
||||
return label, first - second, true
|
||||
}
|
||||
|
||||
// turnVector returns the turn's query vector, computing it on first ask and
|
||||
// caching it on the turn.
|
||||
//
|
||||
// It exists because every topic source sits ABOVE the "embed" source in
|
||||
// querySources, and that source was the only thing that ever set t.vec. So
|
||||
// turnIsAbout was reading an empty vector on every deployed turn, best returned
|
||||
// ok=false, and all six recognisers ran on their keyword floors — the seeds
|
||||
// decided nothing outside the tests, which embed the utterance themselves and
|
||||
// call best directly. Found on the box on 05-08-2026: "что мне нужно купить" was
|
||||
// answered from an old note, and the seeds place it as the list by 0.0841.
|
||||
//
|
||||
// Computing here rather than moving the embed source up: the cost is paid by the
|
||||
// turns that ask, the cache means queryEmbed below reuses this one, and the
|
||||
// order of querySources stays what its comments argue for.
|
||||
func (h *reactiveHandler) turnVector(ctx context.Context, t *queryTurn) []float32 {
|
||||
if len(t.vec) > 0 || h.recall.embedder == nil {
|
||||
return t.vec
|
||||
}
|
||||
vec, err := router.EmbedQuery(ctx, h.recall.embedder, t.dec.Utterance)
|
||||
if err != nil {
|
||||
// The floor answers. A topic source is not the place to fail a turn:
|
||||
// the recall sources below hit the same embedder and report it there.
|
||||
log.Printf("voice: topic vector for %q: %v", t.dec.Utterance, err)
|
||||
return nil
|
||||
}
|
||||
t.vec = vec
|
||||
return vec
|
||||
}
|
||||
|
||||
// turnIsAbout — the recogniser every topic source calls. The seeds decide when
|
||||
// the embedder is there, which is every deployed box; floor is the source's own
|
||||
// keyword test, which answers when they are not.
|
||||
@@ -330,7 +213,7 @@ func (h *reactiveHandler) turnVector(ctx context.Context, t *queryTurn) []float3
|
||||
// network by 0.0055; isNetworkQuery says no, so it stays the complaint it is.
|
||||
func (h *reactiveHandler) turnIsAbout(ctx context.Context, t *queryTurn, want topicLabel, floor func(string) bool) bool {
|
||||
h.recall.topics.load(ctx, h.recall.embedder)
|
||||
label, margin, ok := h.recall.topics.best(h.turnVector(ctx, t))
|
||||
label, margin, ok := h.recall.topics.best(t.vec)
|
||||
if !ok {
|
||||
return floor(t.dec.Utterance)
|
||||
}
|
||||
|
||||
@@ -25,8 +25,6 @@ func TestTopicFloorAnswersWithoutSeeds(t *testing.T) {
|
||||
{"что включено в доме?", topicHome, isHomeQuery, true},
|
||||
{"какие устройства в сети?", topicNetwork, isNetworkQuery, true},
|
||||
{"что требует внимания?", topicAttend, isAttentionQuery, true},
|
||||
{"что нового в лентах?", topicFeed, feedFloor, true},
|
||||
{"что в списке покупок?", topicList, listFloor, true},
|
||||
{"почему небо синее", topicWeather, isWeatherQuery, false},
|
||||
{"я дома", topicHome, isHomeQuery, false},
|
||||
{"интернет не работает", topicNetwork, isNetworkQuery, false},
|
||||
@@ -88,50 +86,6 @@ func TestONNXTopics(t *testing.T) {
|
||||
{"что требует моего внимания сейчас", topicAttend, isAttentionQuery},
|
||||
{"что не так с базой данных", topicAttend, isAttentionQuery},
|
||||
{"есть что-то срочное на сегодня", topicAttend, isAttentionQuery},
|
||||
{"что нового в ленте за сегодня", topicFeed, feedFloor},
|
||||
{"какие сегодня заголовки", topicFeed, feedFloor},
|
||||
{"что нового про искусственный интеллект", topicFeed, feedFloor},
|
||||
// The greeting. It has to lose to topicOther, or fall thin enough that
|
||||
// ParseFeedQuery — which declines a vague noun with no topic — answers.
|
||||
{"что нового?", topicOther, feedFloor},
|
||||
{"что мне надо купить в магазине", topicList, listFloor},
|
||||
{"прочитай мне список", topicList, listFloor},
|
||||
{"что там в аптеке нужно взять", topicList, listFloor},
|
||||
// A task read-back is not a list read-back, and the two collide on
|
||||
// "что у меня".
|
||||
{"какие у меня сейчас задачи", topicOther, listFloor},
|
||||
// World questions that name a day (Vikunja #553). Weather was the only
|
||||
// topic carrying day words, so all of these read as weather and two of
|
||||
// them cleared the margin: the gate asked "для какого города?" about
|
||||
// the dollar. The last two are far from any seed on purpose — the
|
||||
// first three are close enough to the new topicOther seeds that they
|
||||
// would pass on similarity alone.
|
||||
{"какой сегодня курс доллара", topicOther, isWeatherQuery},
|
||||
{"какой сегодня праздник", topicOther, isWeatherQuery},
|
||||
{"что интересного произошло сегодня в мире", topicOther, isWeatherQuery},
|
||||
{"во сколько завтра открывается музей", topicOther, isWeatherQuery},
|
||||
{"кто сегодня играет в лиге чемпионов", topicOther, isWeatherQuery},
|
||||
// The control the seeds above must not cost: real weather still reads
|
||||
// as weather, including the two that lean on the keyword floor.
|
||||
{"будет ли завтра дождь в москве", topicWeather, isWeatherQuery},
|
||||
{"какая температура завтра утром", topicWeather, isWeatherQuery},
|
||||
// The same shape one topic over, seen on the box (Vikunja #554): a
|
||||
// device has a history, and asking about it is not asking what is
|
||||
// plugged in. "кто изобрёл телефон" answered "нашла 3 устройства".
|
||||
// Held out from the seeds, which name the telephone and the computer.
|
||||
{"кто придумал радио", topicOther, isNetworkQuery},
|
||||
{"когда изобрели телевизор", topicOther, isNetworkQuery},
|
||||
{"как устроен телефон внутри", topicOther, isNetworkQuery},
|
||||
// The control: a real scan is still a scan.
|
||||
{"какие устройства подключены к вайфаю", topicNetwork, isNetworkQuery},
|
||||
// Questions about her (Vikunja #555), held out from selfSeeds.
|
||||
{"а что ты вообще умеешь делать", topicSelf, selfFloor},
|
||||
{"какие у тебя навыки", topicSelf, selfFloor},
|
||||
{"расскажи мне о себе", topicSelf, selfFloor},
|
||||
{"what are you able to do", topicSelf, selfFloor},
|
||||
// The control: an attention question is about the state of his things,
|
||||
// and it is the neighbour these seeds could have taken.
|
||||
{"что требует внимания у меня в сервисах", topicAttend, isAttentionQuery},
|
||||
}
|
||||
|
||||
h := &reactiveHandler{recall: recallWiring{embedder: emb}}
|
||||
|
||||
@@ -400,10 +400,6 @@ func buildRouter(emb router.Embedder, acts router.ActMatcher, threshold float64,
|
||||
// explicit capture marker beats the model, which called it an act and
|
||||
// rewrote the task text (Vikunja #467). After the rules above because a
|
||||
// marker never collides with a clock or agenda question.
|
||||
// After Praxis, whose bare "закрой" claim this rule cannot reach (it needs the
|
||||
// board noun), and before the capture marker, which would otherwise read
|
||||
// "убери из задач купить молоко" as a new task (Vikunja #512).
|
||||
grammars = append(grammars, router.TaskStatusGrammar())
|
||||
grammars = append(grammars, router.TaskCaptureGrammar())
|
||||
// After the capture marker, so "запиши" still wins over "расскажи", and
|
||||
// last overall because it matches on the first word alone: "расскажи про
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
// runWipe implements the -wipe flag: it prints what the database holds, and
|
||||
// removes it only when the operator also passed -confirm-wipe (Vikunja #494).
|
||||
//
|
||||
// Two flags rather than one, because the destructive reading of a single flag
|
||||
// is the reading a mistyped command gets. Without the confirmation this is a
|
||||
// dry run that costs nothing and answers the question a QA session actually
|
||||
// has — what is on this box right now.
|
||||
//
|
||||
// It runs before any daemon component is wired, so nothing is writing while
|
||||
// the tables go. The daemon exits afterwards rather than serving a store it
|
||||
// just emptied, because every component that read the old rows at boot would
|
||||
// still be holding them.
|
||||
func runWipe(ctx context.Context, st *store.Store, out io.Writer, confirmed bool) error {
|
||||
counts, err := st.WipeCounts(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("wipe: read counts: %w", err)
|
||||
}
|
||||
total := 0
|
||||
for _, c := range counts {
|
||||
total += c.Rows
|
||||
fmt.Fprintf(out, " %-24s %d\n", c.Table, c.Rows)
|
||||
}
|
||||
fmt.Fprintf(out, " %-24s %d rows in %d tables\n", "TOTAL", total, len(counts))
|
||||
|
||||
if !confirmed {
|
||||
fmt.Fprintln(out, "\nnothing was deleted. pass -confirm-wipe to delete all of it.")
|
||||
fmt.Fprintln(out, "config, models, passkeys and the encryption key are files and are never touched.")
|
||||
return nil
|
||||
}
|
||||
if err := st.Wipe(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(out, "\nwiped. %d rows gone, the schema is intact, mavend knows nobody.\n", total)
|
||||
return nil
|
||||
}
|
||||
+22
-186
@@ -883,19 +883,14 @@ type taskRow struct {
|
||||
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
|
||||
}
|
||||
|
||||
// handleTasks serves the task review surface (GET) and the five writes it
|
||||
// offers (POST): add, edit, confirm, done, drop.
|
||||
// handleTasks serves the task review surface (GET) and the four writes it
|
||||
// offers (POST): add, 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
|
||||
@@ -905,13 +900,6 @@ type taskRow struct {
|
||||
// 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.
|
||||
@@ -977,7 +965,6 @@ func handleTasks(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
||||
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),
|
||||
DueValue: fmtTaskDateValue(t.Due), Weight: t.Weight,
|
||||
Why: r.Reason,
|
||||
}
|
||||
if t.Status == "candidate" {
|
||||
@@ -992,12 +979,11 @@ func handleTasks(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := tasksTmpl.Execute(w, 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)}); err != nil {
|
||||
}{msg, errMsg, cands, open, resolved, resolvedTotal > len(resolved)}); err != nil {
|
||||
log.Printf("tasks render: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1013,16 +999,27 @@ func applyTaskPost(ctx context.Context, core ipc.CoreAPI, r *http.Request) (stri
|
||||
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
|
||||
// Importance is his, stated on the form. Out-of-range values are
|
||||
// clamped rather than rejected — a bad select is not worth a 400.
|
||||
if v := r.FormValue("weight"); v != "" {
|
||||
// strconv, not Sscanf: Sscanf("3junk", "%d") succeeds with 3, and a
|
||||
// form value is not a place to accept trailing garbage.
|
||||
wgt, err := strconv.Atoi(v)
|
||||
if err != nil || wgt < 0 {
|
||||
return "", fmt.Errorf("bad weight %q", v)
|
||||
}
|
||||
if wgt > tasks.MaxWeight {
|
||||
wgt = tasks.MaxWeight
|
||||
}
|
||||
req.Weight = wgt
|
||||
}
|
||||
req.Weight = wgt
|
||||
due, err := formDue(r, now())
|
||||
if err != nil {
|
||||
return "", err
|
||||
if d := r.FormValue("due"); d != "" {
|
||||
due, err := time.ParseInLocation("2006-01-02", d, now().Location())
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("bad due date %q", d)
|
||||
}
|
||||
req.Due = &due
|
||||
}
|
||||
req.Due = due
|
||||
resp, err := core.CaptureTask(ctx, req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -1040,44 +1037,6 @@ func applyTaskPost(ctx context.Context, core ipc.CoreAPI, r *http.Request) (stri
|
||||
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":
|
||||
@@ -1090,134 +1049,11 @@ func applyTaskPost(ctx context.Context, core ipc.CoreAPI, r *http.Request) (stri
|
||||
return "", fmt.Errorf("unknown action %q", action)
|
||||
}
|
||||
if err := core.SetTaskStatus(ctx, id, status, now(), "tap:web"); err != nil {
|
||||
if errors.Is(err, ipc.ErrTaskNoDoneWhen) {
|
||||
// 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).
|
||||
return "", errors.New("write a definition of done before confirming this candidate")
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
// fmtTaskDateValue renders a due date the way <input type=date> requires, or
|
||||
// "" for no date. Separate from fmtTaskDate, which renders it for reading.
|
||||
// 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 := ""
|
||||
if name := strings.TrimSpace(r.FormValue("blocked_on")); name != "" {
|
||||
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, ", "))
|
||||
}
|
||||
blockedOn = ref.ID
|
||||
}
|
||||
|
||||
if err := core.SetTaskFields(ctx, id, doneWhen, blockedOn); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if due != nil {
|
||||
wgt, err := formWeight(r)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
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 {
|
||||
if errors.Is(err, ipc.ErrTaskNoDoneWhen) {
|
||||
return "", errors.New("write a definition of done before confirming this candidate")
|
||||
}
|
||||
return "", 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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
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 "—"
|
||||
|
||||
+6
-44
@@ -21,43 +21,20 @@
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{{if .Stalls}}
|
||||
<section class=card>
|
||||
<h2 class=card-title>shapes</h2>
|
||||
<!-- Counts, and nothing about what they mean (V-512). Whether a task should be
|
||||
dropped is his call and Maven does not have an opinion to show here. -->
|
||||
<div class=scroll><table>
|
||||
<tr><th>count</th><th>shape</th></tr>
|
||||
{{range .Stalls}}<tr><td>{{.N}}</td><td>{{.Line}}</td></tr>{{end}}
|
||||
</table></div>
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
{{if .Candidates}}
|
||||
<section class=card>
|
||||
<h2 class=card-title>found, not confirmed <span class=badge>{{len .Candidates}}</span></h2>
|
||||
<div class=hint>maven derived these from something she read. nothing counts as your work until you confirm it.</div>
|
||||
<div class=hint>confirming asks for a definition of done: what has to be true for this to be finished. a task without one can never leave the board. a date here also books a reminder that morning.</div>
|
||||
<div class=scroll><table>
|
||||
<tr><th>task</th><th>where from</th><th>captured</th><th>confirm</th><th></th></tr>
|
||||
<tr><th>task</th><th>where from</th><th>due</th><th>captured</th><th></th><th></th></tr>
|
||||
{{range .Candidates}}<tr>
|
||||
<td class=text-max>{{.Text}}</td>
|
||||
<td class=hint>{{.Source}}{{if .Evidence}} — {{.Evidence}}{{end}}</td>
|
||||
<td>{{.Due}}</td>
|
||||
<td class=muted>{{.Created}}</td>
|
||||
<td><form method=post action=/tasks class=inline-form>
|
||||
<input type=hidden name=id value="{{.ID}}">
|
||||
<input type=hidden name=action value=promote>
|
||||
<input type=hidden name=text value="{{.Text}}">
|
||||
<input type=text name=done_when placeholder="готово, когда…" size=26 required>
|
||||
<!-- A name, not an id. It is resolved against nexus before anything is
|
||||
stored, and a name nexus cannot place stops the confirmation. -->
|
||||
<input type=text name=blocked_on placeholder="ждёт кого-то" size=14>
|
||||
<input type=date name=due value="{{.DueValue}}" title="due date">
|
||||
<select name=weight title=importance>
|
||||
<option value=0 {{if eq .Weight 0}}selected{{end}}>normal</option>
|
||||
<option value=2 {{if eq .Weight 2}}selected{{end}}>важно</option>
|
||||
<option value=3 {{if eq .Weight 3}}selected{{end}}>срочно</option>
|
||||
</select>
|
||||
<input type=hidden name=action value=confirm>
|
||||
<button class=btn>confirm</button></form></td>
|
||||
<td><form method=post action=/tasks class=inline-form>
|
||||
<input type=hidden name=id value="{{.ID}}">
|
||||
@@ -71,27 +48,12 @@
|
||||
<h2 class=card-title>open <span class=badge>{{len .Open}}</span></h2>
|
||||
<div class=hint>most pressing first — by the deadlines and the urgency you gave. nothing about a task is guessed; the only signal that is not yours is age, which lifts anything sitting here for weeks.</div>
|
||||
{{if .Open}}<div class=scroll><table>
|
||||
<tr><th>task</th><th>why</th><th>from</th><th>captured</th><th></th><th></th></tr>
|
||||
<tr><th>task</th><th>why</th><th>from</th><th>due</th><th>captured</th><th></th><th></th></tr>
|
||||
{{range .Open}}<tr>
|
||||
<!-- The text, the date and the importance are editable in place (V-509): a
|
||||
dictated task can carry a typo, and a deadline moves. The status is not
|
||||
here — that ladder is one-way and has its own two buttons. -->
|
||||
<td class=text-max><form method=post action=/tasks class=inline-form>
|
||||
<input type=hidden name=id value="{{.ID}}">
|
||||
<input type=hidden name=action value=edit>
|
||||
<input type=text name=text value="{{.Text}}" size=30 required>
|
||||
<input type=date name=due value="{{.DueValue}}" title="due date">
|
||||
<select name=weight title=importance>
|
||||
<!-- Any weight that is not one of the three rungs keeps its own option, or
|
||||
saving an unrelated edit would silently reset it to normal. -->
|
||||
{{if and (ne .Weight 0) (ne .Weight 2) (ne .Weight 3)}}<option value={{.Weight}} selected>{{.Weight}}</option>{{end}}
|
||||
<option value=0 {{if eq .Weight 0}}selected{{end}}>normal</option>
|
||||
<option value=2 {{if eq .Weight 2}}selected{{end}}>важно</option>
|
||||
<option value=3 {{if eq .Weight 3}}selected{{end}}>срочно</option>
|
||||
</select>
|
||||
<button class="btn btn-muted">save</button></form></td>
|
||||
<td class=text-max>{{.Text}}</td>
|
||||
<td class=hint>{{.Why}}</td>
|
||||
<td class=hint>{{.Source}}</td>
|
||||
<td>{{.Due}}</td>
|
||||
<td class=muted>{{.Created}}</td>
|
||||
<td><form method=post action=/tasks class=inline-form>
|
||||
<input type=hidden name=id value="{{.ID}}">
|
||||
|
||||
@@ -77,14 +77,10 @@ func TestHandleTasksSplitsCandidatesFromOpen(t *testing.T) {
|
||||
t.Errorf("body missing %q", want)
|
||||
}
|
||||
}
|
||||
// The candidate must offer the intake form, and it asks for a definition of
|
||||
// done before it will confirm anything (V-511).
|
||||
if !strings.Contains(body, "value=promote") {
|
||||
// The candidate must offer confirm, and the open task must not.
|
||||
if !strings.Contains(body, "value=confirm") {
|
||||
t.Error("candidate row has no confirm action")
|
||||
}
|
||||
if !strings.Contains(body, "name=done_when") {
|
||||
t.Error("the confirm form does not ask for a definition of done")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleTasksAddCaptures(t *testing.T) {
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
# Routing from audio: four paths, one fixture
|
||||
|
||||
**05-08-2026. Vikunja #486.** Workstation `gemma-4-12B-it-qat-UD-Q4_K_XL` with
|
||||
`mmproj-F16.gguf`, homesrv whisper `ggml-small`, piper `ru_RU-irina-medium`.
|
||||
|
||||
**Verdict: transcribe, then route.** One call from audio straight to a route loses 36
|
||||
points, so it is not a candidate. Moving speech-to-text to the workstation buys 375ms and
|
||||
better transcripts at no measurable accuracy cost. So #486 proceeds on the two-call shape.
|
||||
|
||||
## The numbers
|
||||
|
||||
72 Russian cases from `internal/router/eval/ru_routing_v1.json`, rendered by piper at
|
||||
16kHz mono, 153.9s of audio, mean 2.14s per clip. Every path used the daemon's own
|
||||
`routeSystem` prompt and `routeGrammar`, read out of `internal/router/llmrouter.go` at run
|
||||
time, at `temperature 0` and `enable_thinking:false`.
|
||||
|
||||
| Path | Intent-only | Verbatim transcripts | p50 | p95 |
|
||||
|---|---|---|---|---|
|
||||
| text in, the ceiling | **90.3%** (65/72) | — | 361ms | 495ms |
|
||||
| whisper on homesrv, then route | **84.7%** (61/72) | 29/72 | 1372ms | 1546ms |
|
||||
| workstation transcribes, then routes | **83.3%** (60/72) | 48/72 | 997ms | 1177ms |
|
||||
| workstation, one call from audio | **54.2%** (39/72) | — | 425ms | 756ms |
|
||||
|
||||
The 90.3% ceiling is the same model on the same 72 cases with the utterance as text. It is
|
||||
not the 93.5% in `docs/evals/2026-08-02-workstation-gemma4-12b.md`, which scored all 87
|
||||
cases including the English ones.
|
||||
|
||||
The two speech-to-text paths differ by one case, which is noise on 72. So the choice
|
||||
between them is latency and transcript quality, and the workstation wins both.
|
||||
|
||||
## One call from audio is not a transcription failure
|
||||
|
||||
The obvious reading of 54.2% is that the audio encoder cannot hear Russian. It can. Eight
|
||||
of the failing clips were sent back with a transcribe instruction instead of the router
|
||||
prompt:
|
||||
|
||||
| Clip | Said | Heard, transcribing | Routing from audio |
|
||||
|---|---|---|---|
|
||||
| ru-sys-002 | какое число завтра | Какое число завтра? | `unknown` |
|
||||
| ru-sys-003 | переходи в тихий режим | Переходи в тихий режим. | `unknown` |
|
||||
| ru-query-001 | сколько воды я выпил с утра | Сколько воды я выпил с утра? | `fact`, value "выпил с утра" |
|
||||
| ru-act-002 | выключи свет в спальне | Выключи свет в спальне. | `fact`, value "включен" |
|
||||
|
||||
Four clips it transcribes word for word, and routes wrong or refuses. The `ru-query-001`
|
||||
row shows the mechanism: the emitted slot holds the tail of the sentence and the
|
||||
interrogative head is gone. The model is not deaf, it stops attending to the audio once it
|
||||
is also holding a 3.5k-character classification prompt.
|
||||
|
||||
That pattern decides the whole task. A long system prompt and an audio part compete, so the
|
||||
transcription has to be its own call with a short instruction. It also means the number
|
||||
would not be rescued by a better prompt, a longer clip, or a bigger `mmproj`.
|
||||
|
||||
The failures cluster where the head of the sentence carries the intent: `ru-act` 1/6,
|
||||
`ru-sys` 2/5, `ru-query` 12/25. Reminders scored 10/10, because "напомни" is the first word
|
||||
and nothing after it changes the answer.
|
||||
|
||||
## Transcript quality and routing accuracy come apart
|
||||
|
||||
The workstation transcribes 48 of 72 verbatim against whisper's 29, and routes one case
|
||||
worse. Both directions of that appear in the same run:
|
||||
|
||||
- `ru-chat-002`: whisper heard "Кто думаешь про переезд", the workstation heard "Что ты
|
||||
думаешь про переезд". The correct transcript routed to `chat`, the broken one to `query`.
|
||||
- `ru-query-020`: whisper heard "Кто дальше?", the workstation heard the correct "Что
|
||||
дальше?". The **broken** transcript routed correctly and the correct one missed.
|
||||
|
||||
A word error rate is not a proxy for routing accuracy here. Judge a speech-to-text change
|
||||
on the routing fixture, not on transcripts.
|
||||
|
||||
Three cases only the text path gets right. No speech-to-text path recovers them, so they
|
||||
are lost in the rendering rather than in the model.
|
||||
|
||||
## Latency
|
||||
|
||||
Whisper `ggml-small` on homesrv CPU costs p50 998ms for a 2.14s clip, which is nearly
|
||||
all of that path's 1372ms. The workstation does the same job inside its 997ms end-to-end
|
||||
total for two calls. So the transfer is worth about 375ms per turn at p50, and more at p95.
|
||||
|
||||
Both are above the one-call 425ms, and that is the trade the table settles: 29 points of
|
||||
accuracy for 572ms.
|
||||
|
||||
## Notes for the next run
|
||||
|
||||
- `--mmproj /mnt/D/AI/gemma4/mmproj-F16.gguf` has to be in `llama_args` in
|
||||
`~/.config/mavgpud.json`, or `/props` reports `modalities.audio: false` and every audio
|
||||
part is dropped silently. It was added for this measurement and removed afterwards, so
|
||||
the box is back to the text-only config.
|
||||
- `enable_thinking:false` is mandatory. It was set for all 224 calls here.
|
||||
- The degenerate `<|channel>thought` output recorded against #486 did not reproduce, in 80
|
||||
transcribe calls or in 144 routing calls.
|
||||
- Piper renders at 22050Hz mono. Every clip was resampled with
|
||||
`ffmpeg -ar 16000 -ac 1 -c:a pcm_s16le`, because 16kHz is what `audio.PCM16kMono`
|
||||
declares and what the earlier measurement used.
|
||||
@@ -1,70 +0,0 @@
|
||||
# Ecosystem reach with the resident model as router
|
||||
|
||||
Date: 2026-08-05. Vikunja #517, split out of #405.
|
||||
Fixture: `internal/router/eval/ru_ecosystem_v1.json`, 30 held-out Russian cases.
|
||||
Model: Qwen3-1.7B-UD-Q4_K_XL, llama-server on the host at 127.0.0.1:8899.
|
||||
Harness: `TestReachWithLLMRouter` in `internal/router/eval/llmrouter_test.go`.
|
||||
|
||||
## The numbers
|
||||
|
||||
| configuration | reached the right place | praxis | hexis | none |
|
||||
|---|---|---|---|---|
|
||||
| classifier + hash (V-405 floor) | 16/30 | 0/12 | — | — |
|
||||
| classifier + ONNX, after the V-516 grammars | 27/30 | 11/12 | — | — |
|
||||
| **llm-only** (resident model alone) | **17/30 (56.7%)** | **0/12** | 10/10 | 7/8 |
|
||||
| **cascade + llm** + hash fallback | **28/30 (93.3%)** | **11/12** | 10/10 | 7/8 |
|
||||
|
||||
Latency: llm-only p50 1.29s, p95 1.65s. Cascade p50 1.11s, p95 1.64s.
|
||||
No case errored in either configuration.
|
||||
|
||||
## The open question is answered: the model never reaches Praxis
|
||||
|
||||
The route grammar lets the model write any string into the `fn` slot. So it
|
||||
could in principle emit a literal Praxis capability name, and reach a service
|
||||
the classifier structurally cannot. It does not. **Praxis is 0/12 with the
|
||||
model alone.** That is exactly what the classifier alone scores. Every one of
|
||||
the twelve fails the same way: the utterance stays local with an empty `fn`.
|
||||
|
||||
So the stage-0 Praxis grammars from V-516 are not a determinism argument. They
|
||||
are the only path to Praxis that exists. Deleting them takes reach from 11/12
|
||||
back to 0/12 whichever engine is answering.
|
||||
|
||||
The failure is not that the model routes these badly in its own terms. It
|
||||
spreads them across `query`, `fact`, `system` and `chat`. Those are reasonable
|
||||
readings of "что требует внимания" and "готово, закрывай" for a model that has
|
||||
never been told Praxis exists. Nothing in the prompt names a Praxis capability,
|
||||
so there is no string for it to write.
|
||||
|
||||
## What the model does buy
|
||||
|
||||
Hexis is 10/10 with the model alone, and the mutating tag is 10/15 llm-only
|
||||
against 15/15 through the cascade. The model reaches everything Hexis owns
|
||||
without help, which is the half the act allowlist already names in the prompt.
|
||||
|
||||
Cascade + llm scores one point above the classifier baseline: 28/30 against
|
||||
27/30, the difference being one attention case. That is the same shape as the
|
||||
routing fixture, where the router buys about 4 points rather than a doubling.
|
||||
|
||||
## The two that still miss
|
||||
|
||||
- `eco-ru-021 "что там с нексусом"`. Routes `query`, stays local, wants Praxis.
|
||||
Asking after a named service reads as a question about a thing, and no
|
||||
grammar claims a service name.
|
||||
- `eco-ru-029 "сделай это"`. Routes `act` and reaches Hexis. The fixture wants
|
||||
nothing reached, because "это" names no target. This is the overreach case
|
||||
and it is the one direction worth failing on. The confirmation binding
|
||||
downstream still resolves a canonical entity id before anything executes.
|
||||
The fixture is right that the turn should have asked.
|
||||
|
||||
Overreach is 1 in both configurations, under the 4 the harness asserts.
|
||||
|
||||
## How to re-run
|
||||
|
||||
```sh
|
||||
MAVEN_LLM_URL=http://127.0.0.1:8899 \
|
||||
deps/go/go/bin/go test -v -count=1 -timeout 40m \
|
||||
-run TestReachWithLLMRouter ./internal/router/eval/
|
||||
```
|
||||
|
||||
The host `http_proxy` answers 503 for 127.0.0.1. `noProxyLoopback` in the test
|
||||
excludes it. A run that scores every case as a route error measured the proxy.
|
||||
@@ -1,69 +0,0 @@
|
||||
# Routing with the resident model, re-measured
|
||||
|
||||
Date: 2026-08-05. Vikunja #320 items 2 and 3.
|
||||
Fixture: `internal/router/eval/ru_routing_v1.json`, now **91 cases** (76 ru, 15 en).
|
||||
Model: Qwen3-1.7B-UD-Q4_K_XL, llama-server on the host at 127.0.0.1:8899.
|
||||
Harness: `TestLLMRouterBaseline`, `make eval-models`.
|
||||
|
||||
## How the block was cleared
|
||||
|
||||
Item 2 was blocked because the resident llama-server binds `--host 127.0.0.1
|
||||
--port 0` inside `maven-mavend-1`. The port is kernel-assigned, scraped from
|
||||
stderr and never published, so no `go test` on the host can reach it. The task
|
||||
listed three ways out. This run took the first: a **second** llama-server on
|
||||
the same gguf, on a fixed host port. The Vega takes the second copy of a 1.7B
|
||||
without complaint.
|
||||
|
||||
## The numbers
|
||||
|
||||
| configuration | full | intent-only | p50 | p95 |
|
||||
|---|---|---|---|---|
|
||||
| llm-only | 34/91 (37.4%) | 61.5% | 1.24s | 1.65s |
|
||||
| cascade + llm + hash fallback | 69/91 (75.8%) | 80.2% | 1.19s | 1.65s |
|
||||
|
||||
By language, through the cascade: ru 57/76, en 12/15.
|
||||
Clarify: 3 false, 1 missed. No errors. Six slots deferred to the daemon.
|
||||
|
||||
For comparison, the figures that stood in CLAUDE.md were 72.7% full and 77.9%
|
||||
intent-only, measured on 77 cases. The fixture has grown by 14 cases since, so
|
||||
this is a new baseline rather than a movement.
|
||||
|
||||
## llm-only is low for a reason that is not routing
|
||||
|
||||
37.4% full against 61.5% intent-only is the gap, and it is almost entirely
|
||||
slots. Every reminder case fails with "no time slot, want one". The model
|
||||
routes `reminder` correctly and leaves the time to the daemon, which is what
|
||||
the contract asks of it. The cascade fills those slots. That is why the same
|
||||
model scores 38 points higher inside it.
|
||||
|
||||
Three cases errored in the llm-only arm and none in the cascade, which is the
|
||||
fallback working as designed.
|
||||
|
||||
## Item 3: latency
|
||||
|
||||
Router p50 1.19s, p95 1.65s, max 1.79s through the cascade. The one earlier
|
||||
data point in the task, roughly 6s wall clock for `привет` through
|
||||
`POST /api/chat`, was the whole path and not the router. It is not comparable
|
||||
and should not be quoted as a routing number.
|
||||
|
||||
These numbers are the homesrv floor. With the workstation up, routing completes
|
||||
against gemma-4-12b at p50 329ms, measured separately in
|
||||
`docs/evals/2026-08-02-workstation-gemma4-12b.md`.
|
||||
|
||||
## What still misses
|
||||
|
||||
The confusion is concentrated in one direction: `query→fact ×4`,
|
||||
`query→note ×3`, `query→system ×3`. A question about his own rows that carries
|
||||
no interrogative reads as a statement to the model. Ten of the twenty-two
|
||||
failures are that shape, including "я сегодня вообще пил воду" and "чем я
|
||||
занимался в среду". This is the case V-546's three-head classifier is aimed at.
|
||||
|
||||
The two `разбуди меня` cases clarify at 0.300 instead of routing `reminder`.
|
||||
|
||||
## Item 4 is still not run
|
||||
|
||||
Killing the resident llama-server to confirm the classifier floor needs a
|
||||
permission this session does not have. The test is otherwise ready. It now has
|
||||
a second half. With the workstation up, killing the resident server should
|
||||
still complete a turn through `modelSeam`. Only killing both proves the
|
||||
classifier answers.
|
||||
@@ -0,0 +1,82 @@
|
||||
# Gemma as a label function, and what it found in the seeds
|
||||
|
||||
**06-08-2026. V-546.** Measured on workpc against gemma-4-12b-it-qat-UD-Q4_K_XL.
|
||||
|
||||
`docs/plans/18-routing-heads-on-e5-small.md` puts the labeled set at 20k examples through
|
||||
gemma, costing 2 to 4 hours of the card. This is the check before spending that. Gemma
|
||||
labels the 344 hand-written classifier seeds. Agreement with the label a person already
|
||||
chose is a precision number rather than a guess.
|
||||
|
||||
## What ran
|
||||
|
||||
`cmd/labelgen` runs the stage 0 grammars. The real ones, in `buildRouter` order, minus
|
||||
`wakeword-act`, whose allowlist is a deployment's enabled tool names. It labels 62 of 339
|
||||
seed lines and leaves the rest.
|
||||
|
||||
The remaining 277 went to gemma through the daemon's own `routeSystem` prompt and
|
||||
`routeGrammar`, both extracted from `internal/router/llmrouter.go` at run time rather than
|
||||
retyped. Temperature 0.
|
||||
|
||||
## Cost
|
||||
|
||||
**334ms per call, 0 unparsed of 277.** The GBNF held every time. At that rate the plan's
|
||||
20k examples is under two hours of card, which matches its estimate.
|
||||
|
||||
## The stage 0 rules as label functions
|
||||
|
||||
Agreement between the grammar's label and the seed file the line came from:
|
||||
|
||||
| seed intent | agree |
|
||||
|---|---|
|
||||
| reminder | 37/37 |
|
||||
| query | 9/10 |
|
||||
| system | 7/8 |
|
||||
| act | 2/2 |
|
||||
| chat | 0/4 |
|
||||
| note | 0/1 |
|
||||
|
||||
`ReminderGrammar` at 37/37 is the evidence the plan wanted. The chat column is a defect
|
||||
rather than a disagreement: `chatNarrativeTopics` is Russian-only, so `tell me about
|
||||
yourself` survives the decline and routes IntentQuery with topic `yourself`. Filed as
|
||||
V-625, which also records that `как дела у сервера` appears verbatim in two seed files
|
||||
under two intents.
|
||||
|
||||
## Gemma against the seeds
|
||||
|
||||
**197/277, 71.1%.** By intent:
|
||||
|
||||
| seed intent | agree |
|
||||
|---|---|
|
||||
| note | 33/33 |
|
||||
| act | 57/64 |
|
||||
| fact | 37/40 |
|
||||
| query | 51/54 |
|
||||
| chat | 15/35 |
|
||||
| system | 4/43 |
|
||||
| reminder | 0/8 |
|
||||
|
||||
The number is not gemma's error rate. Reading the 80 disagreements, most are the seed files
|
||||
and the prompt holding different definitions of the same intent. Three boundaries carry 42
|
||||
of them, and V-626 is the fix:
|
||||
|
||||
- **system, 26 lines.** The prompt restricts system to the clock, the calendar date and the
|
||||
assistant itself. The seeds also put sensor and host state there. That is the V-374 edit
|
||||
of 31-07-2026, which the seeds never received.
|
||||
- **world questions, 8 lines.** `почему небо голубое`, `why is the sky blue`. Written when
|
||||
chat was the only honest destination for a question nothing could answer, and external
|
||||
search now answers them.
|
||||
- **bare verbs, 8 lines.** `поставь напоминание` with nothing to remind about. The prompt
|
||||
calls that unknown. This one is not staleness. A nearest-neighbour centroid wants the
|
||||
bare verb phrase, and that is what a seed file is for.
|
||||
|
||||
Four intents have not been redefined since the seeds were written: note, fact, query and
|
||||
act. They agree at 178 of 191.
|
||||
|
||||
## What this says about the plan
|
||||
|
||||
Gemma is usable as a label function on those four and not on system, chat or a bare verb.
|
||||
The plan already budgets a day of the owner reading the set. This says where to spend it.
|
||||
|
||||
It also says the two engines in the cascade are being taught different rules on 80 lines.
|
||||
A routing measurement that swaps between the classifier and the router is measuring some of
|
||||
that disagreement rather than the models.
|
||||
+1
-8
@@ -1,6 +1,6 @@
|
||||
# Offloading model work to the workstation
|
||||
|
||||
*Last verified: 2026-08-05 @ b789676. Living doc: correct it in place, do not append.*
|
||||
*Last verified: 2026-08-03 @ 12530c8. Living doc: correct it in place, do not append.*
|
||||
|
||||
Owner's call, 2026-08-02. Vikunja #483 is the umbrella. Tasks #484 to #487 are the
|
||||
work, and this file holds the shape and the rules all four must obey.
|
||||
@@ -149,13 +149,6 @@ flips. It is wired anyway: `PhraseReminder` is on the same transport and is on.
|
||||
Then the embedder above, **whisper.cpp** in `mavsttd`, and **piper** in `mavttsd`.
|
||||
`mavwaked` uses no model at all: an energy-threshold VAD over 30ms frames.
|
||||
|
||||
Speech-to-text stays two stages when it moves. One call carrying both a clip and the router
|
||||
prompt was measured on 05-08-2026. It scores 54.2% intent-only against 84.7% for whisper on
|
||||
homesrv, on the same 72 cases. The model transcribes clips it then routes wrong, so a long
|
||||
classification prompt and an audio part compete for attention. Transcribing on the
|
||||
workstation and routing the text scores 83.3% at p50 997ms. So the transfer buys 375ms and
|
||||
cleaner transcripts, not accuracy. See `docs/evals/2026-08-05-audio-in-routing.md`.
|
||||
|
||||
## Order
|
||||
|
||||
1. **Transport** (#484). Nothing else is possible until a seam can cross a host.
|
||||
|
||||
@@ -142,11 +142,6 @@ type Task struct {
|
||||
Weight int `json:"weight,omitempty"`
|
||||
Resolved *time.Time `json:"resolved,omitempty"`
|
||||
ResolvedBy string `json:"resolved_by,omitempty"`
|
||||
// DoneWhen — the acceptance criterion. Empty until he writes one, and a
|
||||
// candidate with no criterion cannot be promoted to open (Vikunja #510).
|
||||
DoneWhen string `json:"done_when,omitempty"`
|
||||
// BlockedOn — a canonical Nexus entity id, never a name.
|
||||
BlockedOn string `json:"blocked_on,omitempty"`
|
||||
}
|
||||
|
||||
// CaptureTaskReq — THE INTAKE SEAM. Everything that captures a task goes
|
||||
@@ -175,11 +170,6 @@ type CaptureTaskReq struct {
|
||||
Due *time.Time `json:"due,omitempty"`
|
||||
Weight int `json:"weight,omitempty"`
|
||||
Ts time.Time `json:"ts"`
|
||||
// DoneWhen and BlockedOn are optional at intake. A derived source leaves
|
||||
// both empty: mail says what to do, not what finishing means, and guessing
|
||||
// a criterion would put Maven's reading in the field he is meant to write.
|
||||
DoneWhen string `json:"done_when,omitempty"`
|
||||
BlockedOn string `json:"blocked_on,omitempty"`
|
||||
}
|
||||
|
||||
// CaptureTaskResp — Created is false when the same live task already existed,
|
||||
@@ -549,51 +539,6 @@ type setTaskStatusReq struct {
|
||||
By string `json:"by,omitempty"`
|
||||
}
|
||||
|
||||
// EntityRef — one canonical entity from Nexus. Maven never mints these: an id
|
||||
// exists because Nexus resolved a name to it.
|
||||
//
|
||||
// Ambiguous is the answer when the name matched more than one entity. It is a
|
||||
// separate state from "not found" because the surface handles them
|
||||
// differently: an unknown name may be a typo, and an ambiguous one has to be
|
||||
// asked about, never picked (ECOSYSTEM-SPEC, and the same rule the mutating
|
||||
// Hexis path follows).
|
||||
type EntityRef struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
Type string `json:"type,omitempty"`
|
||||
DisplayName string `json:"display_name,omitempty"`
|
||||
Ambiguous bool `json:"ambiguous,omitempty"`
|
||||
Candidates []string `json:"candidates,omitempty"`
|
||||
}
|
||||
|
||||
type resolveEntityReq struct {
|
||||
Query string `json:"query"`
|
||||
Types []string `json:"types,omitempty"`
|
||||
}
|
||||
|
||||
type resolveEntityResp struct {
|
||||
Ref EntityRef `json:"ref"`
|
||||
}
|
||||
|
||||
// editTaskReq — the rewrite of the three fields capture set (Vikunja #509).
|
||||
// Due nil clears the date, so "no date given" and "remove the date" cannot be
|
||||
// the same request.
|
||||
type editTaskReq struct {
|
||||
ID int64 `json:"id"`
|
||||
Text string `json:"text"`
|
||||
Due *time.Time `json:"due,omitempty"`
|
||||
Weight int `json:"weight,omitempty"`
|
||||
}
|
||||
|
||||
// setTaskFieldsReq — the write for the two board columns. Both are sent every
|
||||
// time and both may be empty: clearing a blocker is as ordinary as setting one,
|
||||
// so an omitted field cannot mean "leave it alone" without a second way to say
|
||||
// "make it empty".
|
||||
type setTaskFieldsReq struct {
|
||||
ID int64 `json:"id"`
|
||||
DoneWhen string `json:"done_when,omitempty"`
|
||||
BlockedOn string `json:"blocked_on,omitempty"`
|
||||
}
|
||||
|
||||
// idReq — methods keyed by a single id.
|
||||
type idReq struct {
|
||||
ID int64 `json:"id"`
|
||||
@@ -902,23 +847,6 @@ type unlockReq struct {
|
||||
// wire round-tripping via errors.Is).
|
||||
var ErrToolNotFound = errors.New("ipc: tool not found")
|
||||
|
||||
// ErrTaskNoDoneWhen — a candidate cannot be promoted to open with no
|
||||
// definition of done (Vikunja #510). Carried across the wire so the /tasks
|
||||
// form can say which refusal it hit rather than "не найдено".
|
||||
var ErrTaskNoDoneWhen = errors.New("ipc: task has no definition of done")
|
||||
|
||||
// ErrTaskDuplicate — an edit would collide with another live task's normalised
|
||||
// text (Vikunja #509). The surface says which row holds it rather than merging.
|
||||
var ErrTaskDuplicate = errors.New("ipc: another live task already has this text")
|
||||
|
||||
// ErrNoEntity — Nexus resolved the name to nothing. Distinct from an outage,
|
||||
// which surfaces as the transport error: "there is no such person" and "Nexus
|
||||
// is down" must not read the same to a caller deciding whether to store an id.
|
||||
var ErrNoEntity = errors.New("ipc: no such entity")
|
||||
|
||||
// ErrTaskResolved — a resolved task is not editable.
|
||||
var ErrTaskResolved = errors.New("ipc: task is resolved")
|
||||
|
||||
// callerKey — context key for the authenticated caller. Server sets it from
|
||||
// SO_PEERCRED before dispatch; in-process callers omit it (the adapter treats
|
||||
// a missing Caller as "trusted same-process", the equivalent of the socket's
|
||||
|
||||
@@ -515,22 +515,6 @@ func (c *Client) SetTaskStatus(ctx context.Context, id int64, status string, ts
|
||||
return c.call(ctx, MethodSetTaskStatus, setTaskStatusReq{ID: id, Status: status, Ts: ts, By: by}, nil)
|
||||
}
|
||||
|
||||
func (c *Client) ResolveEntity(ctx context.Context, query string, types []string) (EntityRef, error) {
|
||||
var r resolveEntityResp
|
||||
if err := c.call(ctx, MethodResolveEntity, resolveEntityReq{Query: query, Types: types}, &r); err != nil {
|
||||
return EntityRef{}, err
|
||||
}
|
||||
return r.Ref, nil
|
||||
}
|
||||
|
||||
func (c *Client) EditTask(ctx context.Context, id int64, text string, due *time.Time, weight int) error {
|
||||
return c.call(ctx, MethodEditTask, editTaskReq{ID: id, Text: text, Due: due, Weight: weight}, nil)
|
||||
}
|
||||
|
||||
func (c *Client) SetTaskFields(ctx context.Context, id int64, doneWhen, blockedOn string) error {
|
||||
return c.call(ctx, MethodSetTaskFields, setTaskFieldsReq{ID: id, DoneWhen: doneWhen, BlockedOn: blockedOn}, nil)
|
||||
}
|
||||
|
||||
// IngestMail hands one fetched message to core for extraction. ErrUnknownMethod
|
||||
// means core has no email block configured — the caller should stop asking, not
|
||||
// retry.
|
||||
|
||||
@@ -142,23 +142,6 @@ type TaskAPI interface {
|
||||
// SetTaskStatus moves a task forward once: candidate→open|dropped,
|
||||
// open→done|dropped. Any other move is refused.
|
||||
SetTaskStatus(ctx context.Context, id int64, status string, ts time.Time, by string) error
|
||||
// ResolveEntity asks Nexus for the canonical id behind a name. It is how a
|
||||
// surface turns "Kate" into an entity id before storing one, because
|
||||
// identity lives in Nexus and a local name is a second answer to a
|
||||
// question Nexus already owns. ErrNotImplemented when no Nexus is
|
||||
// configured, ErrNoEntity when the name matched nothing, and an Ambiguous
|
||||
// ref when it matched several — the caller asks, it does not pick.
|
||||
ResolveEntity(ctx context.Context, query string, types []string) (EntityRef, error)
|
||||
// EditTask rewrites the three fields capture set: text, due date and
|
||||
// weight. Status is not among them — that ladder is one-way and belongs to
|
||||
// SetTaskStatus. A resolved task is refused, and a text edit that would
|
||||
// duplicate another live task is refused rather than merged.
|
||||
EditTask(ctx context.Context, id int64, text string, due *time.Time, weight int) error
|
||||
// SetTaskFields writes the definition of done and the blocker. Not a
|
||||
// status move, so it is not one-way: he may sharpen a criterion, and a
|
||||
// blocker clears when the person answers. blockedOn is a canonical Nexus
|
||||
// entity id or empty, never a name the caller had lying around.
|
||||
SetTaskFields(ctx context.Context, id int64, doneWhen, blockedOn string) error
|
||||
}
|
||||
|
||||
// SystemAPI — what the daemon knows about itself, plus the one method that
|
||||
|
||||
@@ -31,9 +31,6 @@ var mapErrPairs = []struct {
|
||||
{"ErrReminderNotFound", store.ErrReminderNotFound, ErrReminderNotFound},
|
||||
{"ErrReminderState", store.ErrReminderState, ErrReminderState},
|
||||
{"ErrToolNotFound", store.ErrToolNotFound, ErrToolNotFound},
|
||||
{"ErrTaskNoDoneWhen", store.ErrTaskNoDoneWhen, ErrTaskNoDoneWhen},
|
||||
{"ErrTaskDuplicate", store.ErrTaskDuplicate, ErrTaskDuplicate},
|
||||
{"ErrTaskResolved", store.ErrTaskResolved, ErrTaskResolved},
|
||||
}
|
||||
|
||||
// unmappedStoreErrors — store sentinels that deliberately have no wire twin,
|
||||
|
||||
@@ -540,19 +540,6 @@ var methodTable = map[Method]handlerFunc{
|
||||
MethodSetTaskStatus: withParamsVoid(func(ctx context.Context, api CoreAPI, p setTaskStatusReq) error {
|
||||
return api.SetTaskStatus(ctx, p.ID, p.Status, p.Ts, p.By)
|
||||
}),
|
||||
MethodResolveEntity: withParams(func(ctx context.Context, api CoreAPI, p resolveEntityReq) (resolveEntityResp, error) {
|
||||
ref, err := api.ResolveEntity(ctx, p.Query, p.Types)
|
||||
if err != nil {
|
||||
return resolveEntityResp{}, err
|
||||
}
|
||||
return resolveEntityResp{Ref: ref}, nil
|
||||
}),
|
||||
MethodEditTask: withParamsVoid(func(ctx context.Context, api CoreAPI, p editTaskReq) error {
|
||||
return api.EditTask(ctx, p.ID, p.Text, p.Due, p.Weight)
|
||||
}),
|
||||
MethodSetTaskFields: withParamsVoid(func(ctx context.Context, api CoreAPI, p setTaskFieldsReq) error {
|
||||
return api.SetTaskFields(ctx, p.ID, p.DoneWhen, p.BlockedOn)
|
||||
}),
|
||||
MethodListProposedRoutines: withoutParams(func(ctx context.Context, api CoreAPI) (listProposedRoutinesResp, error) {
|
||||
out, err := api.ListProposedRoutines(ctx)
|
||||
if err != nil {
|
||||
|
||||
@@ -317,8 +317,6 @@ func (a *storeAPI) CaptureTask(ctx context.Context, req CaptureTaskReq) (Capture
|
||||
Status: req.Status,
|
||||
Due: req.Due,
|
||||
Weight: req.Weight,
|
||||
DoneWhen: req.DoneWhen,
|
||||
BlockedOn: req.BlockedOn,
|
||||
})
|
||||
if err != nil {
|
||||
return CaptureTaskResp{}, mapErr(err)
|
||||
@@ -345,8 +343,6 @@ func (a *storeAPI) ListTasks(ctx context.Context, status string) ([]Task, error)
|
||||
Weight: t.Weight,
|
||||
Resolved: t.ResolvedTs,
|
||||
ResolvedBy: t.ResolvedBy,
|
||||
DoneWhen: t.DoneWhen,
|
||||
BlockedOn: t.BlockedOn,
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
@@ -356,21 +352,6 @@ func (a *storeAPI) SetTaskStatus(ctx context.Context, id int64, status string, t
|
||||
return mapErr(a.s.SetTaskStatus(ctx, id, status, ts, by))
|
||||
}
|
||||
|
||||
// ResolveEntity is not the store's to answer: identity lives in Nexus and this
|
||||
// adapter has no client. The daemon overrides it (cmd/mavend/tick_api.go), and
|
||||
// a deployment with no nexus block keeps this refusal.
|
||||
func (a *storeAPI) ResolveEntity(ctx context.Context, query string, types []string) (EntityRef, error) {
|
||||
return EntityRef{}, ErrNotImplemented
|
||||
}
|
||||
|
||||
func (a *storeAPI) EditTask(ctx context.Context, id int64, text string, due *time.Time, weight int) error {
|
||||
return mapErr(a.s.EditTask(ctx, id, text, due, weight))
|
||||
}
|
||||
|
||||
func (a *storeAPI) SetTaskFields(ctx context.Context, id int64, doneWhen, blockedOn string) error {
|
||||
return mapErr(a.s.SetTaskFields(ctx, id, doneWhen, blockedOn))
|
||||
}
|
||||
|
||||
func (a *storeAPI) ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, error) {
|
||||
rs, err := a.s.ListProposedRoutines(ctx)
|
||||
if err != nil {
|
||||
@@ -477,12 +458,6 @@ func mapErr(err error) error {
|
||||
return ErrReminderState
|
||||
case errors.Is(err, store.ErrToolNotFound):
|
||||
return ErrToolNotFound
|
||||
case errors.Is(err, store.ErrTaskNoDoneWhen):
|
||||
return ErrTaskNoDoneWhen
|
||||
case errors.Is(err, store.ErrTaskDuplicate):
|
||||
return ErrTaskDuplicate
|
||||
case errors.Is(err, store.ErrTaskResolved):
|
||||
return ErrTaskResolved
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -114,15 +114,6 @@ func (UnimplementedCoreAPI) ListTasks(ctx context.Context, status string) ([]Tas
|
||||
func (UnimplementedCoreAPI) SetTaskStatus(ctx context.Context, id int64, status string, ts time.Time, by string) error {
|
||||
return ErrNotImplemented
|
||||
}
|
||||
func (UnimplementedCoreAPI) ResolveEntity(ctx context.Context, query string, types []string) (EntityRef, error) {
|
||||
return EntityRef{}, ErrNotImplemented
|
||||
}
|
||||
func (UnimplementedCoreAPI) EditTask(ctx context.Context, id int64, text string, due *time.Time, weight int) error {
|
||||
return ErrNotImplemented
|
||||
}
|
||||
func (UnimplementedCoreAPI) SetTaskFields(ctx context.Context, id int64, doneWhen, blockedOn string) error {
|
||||
return ErrNotImplemented
|
||||
}
|
||||
func (UnimplementedCoreAPI) ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, error) {
|
||||
return nil, ErrNotImplemented
|
||||
}
|
||||
|
||||
@@ -55,9 +55,6 @@ const (
|
||||
MethodCaptureTask Method = "capture_task"
|
||||
MethodListTasks Method = "list_tasks"
|
||||
MethodSetTaskStatus Method = "set_task_status"
|
||||
MethodSetTaskFields Method = "set_task_fields"
|
||||
MethodEditTask Method = "edit_task"
|
||||
MethodResolveEntity Method = "resolve_entity"
|
||||
MethodIngestMail Method = "ingest_mail"
|
||||
MethodSwapModel Method = "swap_model"
|
||||
MethodModelStatus Method = "model_status"
|
||||
|
||||
@@ -63,8 +63,7 @@ func mustLoad() lexiconFile {
|
||||
for _, name := range []string{
|
||||
"interrogatives", "capture_verbs", "narrative_requests", "cardinals", "ordinals",
|
||||
"day_offsets", "weekdays", "months_genitive", "hours_spoken",
|
||||
"not_place_after_v", "parts_of_day", "reminder_verbs", "half_hour",
|
||||
"filler_particles", "task_done_words", "task_drop_words",
|
||||
"not_place_after_v", "parts_of_day", "reminder_verbs",
|
||||
} {
|
||||
s, ok := f.Sets[name]
|
||||
if !ok || (len(s.Words) == 0 && len(s.Values) == 0) {
|
||||
@@ -113,47 +112,6 @@ func PartsOfDay() []string { return words("parts_of_day") }
|
||||
// ReminderVerbs returns the imperatives that open a reminder.
|
||||
func ReminderVerbs() []string { return words("reminder_verbs") }
|
||||
|
||||
// TaskDoneWords returns the words that finish a task, and TaskDropWords the
|
||||
// words that abandon one. Two sets rather than one with a value, because the
|
||||
// store records which of the two happened and the caller has to say so.
|
||||
//
|
||||
// Both mix moods on purpose, and the caller must match them the way the sets'
|
||||
// notes say: an imperative exactly, a stative by lemma.
|
||||
func TaskDoneWords() []string { return words("task_done_words") }
|
||||
|
||||
// TaskDropWords — see TaskDoneWords.
|
||||
func TaskDropWords() []string { return words("task_drop_words") }
|
||||
|
||||
// IsFillerParticle reports whether a word can never be the subject of a
|
||||
// request: a particle, a politeness word, or the first-person object. See the
|
||||
// set's own note for why this is not a stopword list.
|
||||
func IsFillerParticle(word string) bool {
|
||||
w := norm(word)
|
||||
for _, p := range ru.Sets["filler_particles"].Words {
|
||||
if w == p {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// HalfHourWords returns those forms, for a caller folding every time word into
|
||||
// one set rather than asking about one word.
|
||||
func HalfHourWords() []string { return words("half_hour") }
|
||||
|
||||
// IsHalfHour reports whether a word introduces a spoken half hour, so the
|
||||
// ordinal after it is an hour rather than a position. One caller reads that
|
||||
// ordinal as the hour and another has to decline it; both ask here.
|
||||
func IsHalfHour(word string) bool {
|
||||
w := norm(word)
|
||||
for _, h := range ru.Sets["half_hour"].Words {
|
||||
if w == h {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Cardinal reports the value of a spoken number word. The word is compared
|
||||
// lowercased and trimmed, because it arrives from a tokenizer that may not have
|
||||
// done either.
|
||||
|
||||
@@ -166,37 +166,6 @@
|
||||
"напомни", "напомните", "напомнить", "напоминай",
|
||||
"remind"
|
||||
]
|
||||
},
|
||||
"half_hour": {
|
||||
"note": "The forms of \"половина\" that introduce a spoken half hour: \"в половине восьмого\", \"к половине\", the bare \"пол\" of \"полвосьмого\". The set matters to two callers and for opposite reasons (V-522). The clock rewrite reads the ordinal after one of these as the hour being entered, and the ordinal-selection turn has to REFUSE that ordinal, because \"в половине восьмого\" names a time and not the eighth thing she read out.",
|
||||
"words": [
|
||||
"половина", "половине", "половину", "половины", "пол",
|
||||
"half"
|
||||
]
|
||||
},
|
||||
"filler_particles": {
|
||||
"note": "Words that carry no subject of their own: particles, the politeness words, and the first-person object he addresses her with. A caller asking \"did he say WHAT to remind him about\" has to discount these, or \"ну напомни же\" and \"напомни мне пожалуйста\" both read as a reminder whose subject is the particle. Closed in the sense that matters: these are function words, and the language is not adding any. Not a stopword list — a stopword list is a scoring convenience and may be as long as it likes, while every word here has to be one that cannot BE a reminder's subject.",
|
||||
"words": [
|
||||
"ну", "же", "уж", "там", "вот", "пожалуйста", "плиз", "ка",
|
||||
"давай", "давай-ка", "а", "и", "бы", "мне", "меня", "мной",
|
||||
"please", "just", "hey", "me"
|
||||
]
|
||||
},
|
||||
"task_done_words": {
|
||||
"note": "The ways he says a task is finished, split by mood the way the Praxis lifecycle words are (Vikunja #512). The imperatives are addressed to her and are matched exactly, because morph.SameWord makes \"закрой\" and \"закрыл\" one word and only one of them is an instruction. The statives report his own day and are matched by lemma, since \"сделано\", \"сделана\" and \"сделанную\" are one state. Closed because these are her vocabulary for one transition, not a discovery about Russian.",
|
||||
"words": [
|
||||
"закрой", "закройте", "закрыть", "заверши", "завершить", "close", "finish",
|
||||
"сделано", "сделал", "сделала", "выполнено", "выполнил", "выполнила",
|
||||
"готово", "готова", "закрыл", "закрыла", "done", "finished"
|
||||
]
|
||||
},
|
||||
"task_drop_words": {
|
||||
"note": "The ways he abandons a task rather than finishing it (Vikunja #512). Same two moods as task_done_words and the same matching rule. Separate from the done words because the store records which of the two happened and /tasks shows it: dropped work he chose to stop is not work he did.",
|
||||
"words": [
|
||||
"убери", "уберите", "убрать", "удали", "удалить", "отмени", "отменить",
|
||||
"drop", "remove", "cancel",
|
||||
"передумал", "передумала", "неактуально"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -512,40 +512,19 @@ func (p *LLMPhraser) PhraseQuery(ctx context.Context, utterance string, notes []
|
||||
// message array from dialogue history + the current user utterance. On any LLM
|
||||
// error it returns both ChatFallback and the error, on the same rule as
|
||||
// PhraseQuery: the fallback keeps the turn alive, the error stays visible.
|
||||
// chatUserMessage folds the prior turns and the current one into a single user
|
||||
// message, because some chat templates (Ministral and others) reject two user
|
||||
// turns in a row. That constraint is real; what was wrong is how it was met.
|
||||
//
|
||||
// The turns used to be joined with newlines and nothing else, so the model was
|
||||
// handed four unlabelled lines and no way to tell which one it was answering
|
||||
// (Vikunja #554). It answered an earlier one, or answered all of them at once:
|
||||
// asked "как дела" after a question about the telephone, she carried on about
|
||||
// the telephone. Four turns live for fifteen minutes, so the wrong line was
|
||||
// often several minutes old.
|
||||
//
|
||||
// The history holds only his own utterances, never her replies, so the label
|
||||
// says so and stays in the second person the persona requires. With no history
|
||||
// the message is the utterance alone, which is the common case and unchanged.
|
||||
func chatUserMessage(utterance string, history []dialogue.Turn) string {
|
||||
prior := make([]string, 0, len(history))
|
||||
for _, t := range history {
|
||||
if s := strings.TrimSpace(t.Text); s != "" {
|
||||
prior = append(prior, "- "+s)
|
||||
}
|
||||
}
|
||||
if len(prior) == 0 {
|
||||
return strings.TrimSpace(utterance)
|
||||
}
|
||||
return "Раньше ты говорил:\n" + strings.Join(prior, "\n") +
|
||||
"\n\nОтветь только на то, что ты говоришь сейчас: " + strings.TrimSpace(utterance)
|
||||
}
|
||||
|
||||
func (p *LLMPhraser) PhraseChat(ctx context.Context, utterance string, history []dialogue.Turn) (string, error) {
|
||||
sys := chatSystemPrompt(p.cfg.ContextBlock)
|
||||
msgs := []chatMsg{
|
||||
{Role: "system", Content: sys},
|
||||
}
|
||||
msgs = append(msgs, chatMsg{Role: "user", Content: chatUserMessage(utterance, history)})
|
||||
// Combine history and current utterance into one user message.
|
||||
// Some model chat templates (Ministral, etc.) reject consecutive user turns.
|
||||
var combined string
|
||||
for _, t := range history {
|
||||
combined += t.Text + "\n"
|
||||
}
|
||||
combined += utterance
|
||||
msgs = append(msgs, chatMsg{Role: "user", Content: strings.TrimSpace(combined)})
|
||||
|
||||
resp, err := p.chatWithMessages(ctx, msgs, 768)
|
||||
if err != nil {
|
||||
@@ -941,41 +920,6 @@ func (p *LLMPhraser) knowledgePrompt(utterance string) (sys, user string) {
|
||||
fmt.Sprintf("Пользователь спрашивает: \"%s\".", utterance)
|
||||
}
|
||||
|
||||
// PhraseSelf answers a question about her from her own description. Same
|
||||
// discipline as the evidence branch — say only what the text says — and a
|
||||
// different opener, because "вот что я нашла: я — твоя помощница" says she
|
||||
// looked herself up (Vikunja #555). She did not; this is the one subject she
|
||||
// does not have to read about.
|
||||
//
|
||||
// On any error it reads the description out rather than ship a fragment. That
|
||||
// is already a readable answer, which is why this needs no separate fallback.
|
||||
func (p *LLMPhraser) PhraseSelf(ctx context.Context, utterance, description string) (string, error) {
|
||||
sys := persona.Prepend(p.cfg.ContextBlock,
|
||||
"Он спрашивает о тебе. Отвечай ТОЛЬКО по описанию, которое тебе дали: всё, что ты говоришь о себе, должно быть в нём. "+
|
||||
"Не добавляй умений, которых там нет, и не догадывайся. Не начинай с \"вот что я нашла\" — ты говоришь о себе, а не о находке. "+
|
||||
// The gender rule is stated WITHOUT the "-ла" example the other
|
||||
// prompts carry. Measured on the box: a 1.7B reads that as an
|
||||
// instruction to use the past tense and answers "я вела заметки,
|
||||
// управляла домом" — she describes what she does, in the present,
|
||||
// and the past tense makes a live capability sound finished.
|
||||
"Отвечай по-русски, коротко и своими словами, в настоящем времени — ты описываешь, что делаешь сейчас. О себе говори в женском роде. "+
|
||||
"Он мужчина, обращайся к нему на \"ты\". Отвечай ТОЛЬКО одним объектом JSON: {\"response\": \"...\", \"mood\": \"neutral\"}.")
|
||||
prompt := fmt.Sprintf("Он спрашивает: %q\n\nТвоё описание:\n%s\n\nОтветь ему на то, что он спросил.", utterance, description)
|
||||
resp, err := p.chatWithSystem(ctx, sys, prompt, 768)
|
||||
text, _, perr := parseResponseMood(resp)
|
||||
if err != nil || perr != nil {
|
||||
cause := err
|
||||
if cause == nil {
|
||||
cause = perr
|
||||
}
|
||||
return description, fmt.Errorf("phrase self: %w", cause)
|
||||
}
|
||||
if text != "" {
|
||||
return text, nil
|
||||
}
|
||||
return description, nil
|
||||
}
|
||||
|
||||
// evidencePrompt — the sources branch: read these, add nothing. Shared with
|
||||
// PhraseWorld for the same reason as knowledgePrompt.
|
||||
func (p *LLMPhraser) evidencePrompt(utterance string, notes []string) (sys, user string) {
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
package phraser
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/kami/maven/internal/dialogue"
|
||||
)
|
||||
|
||||
// TestChatUserMessageMarksWhichTurnToAnswer — Vikunja #554. Four prior turns
|
||||
// were joined with newlines and nothing else, so nothing in the message said
|
||||
// which line was the question.
|
||||
func TestChatUserMessageMarksWhichTurnToAnswer(t *testing.T) {
|
||||
got := chatUserMessage("как дела", []dialogue.Turn{
|
||||
{Text: "кто изобрёл телефон"},
|
||||
{Text: "а когда это было"},
|
||||
})
|
||||
if !strings.Contains(got, "кто изобрёл телефон") {
|
||||
t.Error("the prior turns must survive — they are what anaphora reads")
|
||||
}
|
||||
now := strings.LastIndex(got, "как дела")
|
||||
if now < strings.Index(got, "кто изобрёл телефон") {
|
||||
t.Error("the current utterance must come last, after the turns it follows")
|
||||
}
|
||||
if !strings.Contains(got, "Раньше ты говорил") {
|
||||
t.Errorf("the prior turns must be labelled as prior: %q", got)
|
||||
}
|
||||
if strings.Contains(got, " вы ") || strings.Contains(got, "Вы ") {
|
||||
t.Errorf("the persona addresses him informally: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestChatUserMessageWithNoHistoryIsJustTheUtterance — the common case must not
|
||||
// grow a preamble that the model then has to see past.
|
||||
func TestChatUserMessageWithNoHistoryIsJustTheUtterance(t *testing.T) {
|
||||
if got := chatUserMessage(" привет ", nil); got != "привет" {
|
||||
t.Errorf("chatUserMessage = %q, want %q", got, "привет")
|
||||
}
|
||||
if got := chatUserMessage("привет", []dialogue.Turn{{Text: " "}}); got != "привет" {
|
||||
t.Errorf("a blank prior turn must not label anything: %q", got)
|
||||
}
|
||||
}
|
||||
@@ -49,9 +49,6 @@ type Phraser interface {
|
||||
PhraseNudge(ctx context.Context, c loop.Candidate) (delivery.PhrasedNudge, error)
|
||||
PhraseReminder(ctx context.Context, d loop.ReminderDecision) (delivery.PhrasedReminder, error)
|
||||
PhraseQuery(ctx context.Context, utterance string, notes []string) (string, error)
|
||||
// PhraseSelf answers a question about her from her own description, which
|
||||
// is not a source she read and must not be phrased as one (Vikunja #555).
|
||||
PhraseSelf(ctx context.Context, utterance, description string) (string, error)
|
||||
PhraseChat(ctx context.Context, utterance string, history []dialogue.Turn) (string, error)
|
||||
Close() error
|
||||
}
|
||||
@@ -84,13 +81,6 @@ func (s *Stub) PhraseQuery(_ context.Context, _ string, notes []string) (string,
|
||||
return SourcesFallback(strings.Join(notes, "; ")), nil
|
||||
}
|
||||
|
||||
// PhraseSelf reads the description out as it stands. There is nothing to fall
|
||||
// back to and nothing to shorten: the text is already written in her voice, and
|
||||
// that is the whole reason it is a constant rather than a prompt.
|
||||
func (s *Stub) PhraseSelf(_ context.Context, _, description string) (string, error) {
|
||||
return description, nil
|
||||
}
|
||||
|
||||
// Close implements Phraser.Close (no-op for the stub).
|
||||
func (s *Stub) Close() error { return nil }
|
||||
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
package router
|
||||
|
||||
import "regexp"
|
||||
|
||||
// IsAgendaQuestion answers whether an utterance asks about the owner's own
|
||||
// schedule, as opposed to merely naming a day.
|
||||
//
|
||||
// It exists because the calendar query source used to match on a day word and
|
||||
// nothing else (Vikunja #552). Every world question that happened to name a
|
||||
// day was claimed by the calendar and answered with an empty schedule: "какой
|
||||
// сегодня курс доллара" replied "на 05.08.2026 ничего нет", which reads as an
|
||||
// answer about a subject she never looked at. V-474 had already fixed one
|
||||
// instance of the class by teaching the calendar to step aside on weather
|
||||
// wording. Sunset, holidays, exchange rates and world news are the same class
|
||||
// and weather wording does not cover them.
|
||||
//
|
||||
// Three arms, and the order is only readability — any one of them is enough:
|
||||
//
|
||||
// - an agenda grammar already claims the phrasing. Reusing
|
||||
// AgendaQueryGrammars means the rule that ROUTES a question to the query
|
||||
// chain and the rule that lets the CALENDAR answer it cannot drift apart.
|
||||
// - the utterance names a scheduled thing. Wider than the grammars on
|
||||
// purpose: "какие встречи завтра" carries no possessive and no plan noun,
|
||||
// so no grammar claims it, and it is plainly a calendar question.
|
||||
// - the question names no subject of its own. "что сегодня?" is his agenda
|
||||
// by default, because there is nothing else for it to be about. This is
|
||||
// the same test the bare-imperative Praxis arm applies.
|
||||
//
|
||||
// Not a routing decision and not a fact, so a pattern is the right mechanism
|
||||
// here: it selects which source answers, and every source below still runs
|
||||
// when it returns false.
|
||||
func IsAgendaQuestion(u string) bool {
|
||||
for _, g := range AgendaQueryGrammars() {
|
||||
if g.Pattern.MatchString(u) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return scheduledThing.MatchString(u) || subjectlessDayQuestion.MatchString(u)
|
||||
}
|
||||
|
||||
// scheduledThing — the nouns that name something on a calendar. Closed in the
|
||||
// sense that matters: these are the words for an appointment itself, not the
|
||||
// words for what an appointment is about. The stems are the union of the ones
|
||||
// AgendaQueryGrammars already carries, read here as a noun test rather than as
|
||||
// part of a phrasing.
|
||||
//
|
||||
// Stems and not whole words, because Russian declines them and "какие встречи"
|
||||
// and "на встречу" are one question.
|
||||
var scheduledThing = regexp.MustCompile(`(?i)(календар|расписани|повестк|планёрк|планерк|встреч|созвон|митинг|совещани|приём|прием|собеседовани|тренировк|занятие|занятия)`)
|
||||
|
||||
// subjectlessDayQuestion — "что сегодня?", "что там на завтра", "что в среду".
|
||||
// An interrogative, an optional preposition, a day word, and nothing else. The
|
||||
// anchors at both ends are the whole point: the moment the sentence names what
|
||||
// it is asking about, it stops being his agenda and this must not match.
|
||||
var subjectlessDayQuestion = regexp.MustCompile(
|
||||
`(?i)^\s*(что|чего|какие|сколько|what)\s+(там\s+|ещё\s+|еще\s+)?(на\s+|в\s+|во\s+)?` +
|
||||
dayWordPattern + `\s*[?!.]*$`)
|
||||
@@ -1,66 +0,0 @@
|
||||
package router
|
||||
|
||||
import "testing"
|
||||
|
||||
// The four utterances in Vikunja #552 plus the ones that must keep reaching
|
||||
// the calendar. The list is the whole point of the predicate: every "want
|
||||
// false" row was answered "на 05.08.2026 ничего нет" on the deployed daemon.
|
||||
func TestIsAgendaQuestionSeparatesHisDayFromTheWorld(t *testing.T) {
|
||||
tests := []struct {
|
||||
utterance string
|
||||
want bool
|
||||
}{
|
||||
// His day.
|
||||
{"что у меня сегодня", true},
|
||||
{"во сколько у меня встреча сегодня", true},
|
||||
{"что в календаре на завтра", true},
|
||||
{"какие планы на завтра", true},
|
||||
{"какие встречи завтра", true},
|
||||
{"когда планёрка", true},
|
||||
{"покажи расписание на среду", true},
|
||||
{"что дальше?", true},
|
||||
// No subject of its own, so his day by default.
|
||||
{"что сегодня?", true},
|
||||
{"что на завтра", true},
|
||||
{"что там в среду?", true},
|
||||
|
||||
// The world, naming a day. Every one of these is #552.
|
||||
{"во сколько закат сегодня", false},
|
||||
{"какой сегодня курс доллара", false},
|
||||
{"какой сегодня праздник", false},
|
||||
{"что интересного произошло сегодня в мире", false},
|
||||
{"кто выиграл вчера матч", false},
|
||||
// A day word plus a subject is never subjectless, however short.
|
||||
{"что за праздник сегодня", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := IsAgendaQuestion(tt.utterance); got != tt.want {
|
||||
t.Errorf("IsAgendaQuestion(%q) = %v, want %v", tt.utterance, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// One hand-written utterance per agenda grammar, keyed by name. Asserting that
|
||||
// the grammars pass IsAgendaQuestion would be true by construction, since the
|
||||
// first arm is the loop over them. This asserts something else: that each
|
||||
// grammar still matches the case its own comment gives, and that the set of
|
||||
// grammars has not grown a member nobody wrote an example for.
|
||||
func TestEachAgendaGrammarStillMatchesItsOwnExample(t *testing.T) {
|
||||
examples := map[string]string{
|
||||
"calendar-query": "что в календаре на завтра",
|
||||
"agenda-query": "что у меня сегодня",
|
||||
"plan-day-query": "какие планы на завтра",
|
||||
"rest-of-day-query": "что дальше?",
|
||||
"event-time-query": "когда планёрка",
|
||||
}
|
||||
for _, g := range AgendaQueryGrammars() {
|
||||
u, ok := examples[g.Name]
|
||||
if !ok {
|
||||
t.Errorf("agenda grammar %q has no example here — add one", g.Name)
|
||||
continue
|
||||
}
|
||||
if !g.Pattern.MatchString(u) {
|
||||
t.Errorf("grammar %q no longer matches %q", g.Name, u)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -49,13 +49,6 @@ try:
|
||||
text = re.sub(r'(\d+)\s+(?:час(?:а|ов)?\s+)?вечера\b', r'\1 pm', text, flags=re.IGNORECASE)
|
||||
text = re.sub(r'(\d+)\s+(?:час(?:а|ов)?\s+)?дня\b', r'\1 pm', text, flags=re.IGNORECASE)
|
||||
text = re.sub(r'(\d+)\s+(?:час(?:а|ов)?\s+)?ночи\b', r'\1 am', text, flags=re.IGNORECASE)
|
||||
# A bare hour after a preposition is dropped on the floor by dateparser:
|
||||
# "завтра в 7" resolves to tomorrow at the CURRENT clock, and "завтра в 7
|
||||
# часов" is read as seven hours from now. Only a qualifier (already an
|
||||
# am/pm above) or a colon makes it read the hour, so give it the colon.
|
||||
# English "at 7" fails identically, so both prepositions are rewritten.
|
||||
text = re.sub(r'(?<![\w:])(в|во|at)\s+([01]?\d|2[0-3])(?:\s+час(?:а|ов)?)?(?![\d:.\w])',
|
||||
lambda m: '%s %02d:00' % (m.group(1), int(m.group(2))), text, flags=re.IGNORECASE)
|
||||
settings = {'PREFER_DATES_FROM': 'future', 'RELATIVE_BASE': now}
|
||||
# Two-step: search_dates finds the date substring in text,
|
||||
# parse() gets the time right (search_dates mishandles AM/PM).
|
||||
|
||||
@@ -103,53 +103,6 @@ func TestPythonDateParser(t *testing.T) {
|
||||
}
|
||||
},
|
||||
},
|
||||
// A bare hour after a day word used to be dropped, and the current
|
||||
// clock carried onto that day: at 21:12 "завтра в семь" confirmed a
|
||||
// reminder for 21:12 tomorrow (Vikunja #551). She invented a time
|
||||
// instead of asking, on a path that then fires.
|
||||
{
|
||||
name: "ru bare hour — завтра в семь",
|
||||
text: "напомни мне завтра в семь позвонить маме",
|
||||
wantOK: true,
|
||||
checkT: func(t *testing.T, got, now time.Time) {
|
||||
if got.Hour() != 7 || got.Minute() != 0 {
|
||||
t.Errorf("завтра в семь: %02d:%02d, want 07:00", got.Hour(), got.Minute())
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
// "7 часов" was read as seven hours from now, which also moved the day.
|
||||
name: "ru bare hour — завтра в 7 часов",
|
||||
text: "напомни завтра в 7 часов позвонить",
|
||||
wantOK: true,
|
||||
checkT: func(t *testing.T, got, now time.Time) {
|
||||
if got.Hour() != 7 || got.Minute() != 0 {
|
||||
t.Errorf("завтра в 7 часов: %02d:%02d, want 07:00", got.Hour(), got.Minute())
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "en bare hour — tomorrow at 7",
|
||||
text: "remind me tomorrow at 7 to call mum",
|
||||
wantOK: true,
|
||||
checkT: func(t *testing.T, got, now time.Time) {
|
||||
if got.Hour() != 7 || got.Minute() != 0 {
|
||||
t.Errorf("tomorrow at 7: %02d:%02d, want 07:00", got.Hour(), got.Minute())
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
// The rewrite must not touch a duration: "через 2 часа" is not "в 2".
|
||||
name: "ru duration is untouched — через 2 часа",
|
||||
text: "напомни через 2 часа выпить воды",
|
||||
wantOK: true,
|
||||
checkT: func(t *testing.T, got, now time.Time) {
|
||||
d := got.Sub(now)
|
||||
if d < 110*time.Minute || d > 130*time.Minute {
|
||||
t.Errorf("через 2 часа: got %v from now, want ~2h", d)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "no date — напомни мне",
|
||||
text: "напомни мне",
|
||||
|
||||
@@ -243,7 +243,6 @@ func newBaselineRouter(t *testing.T, emb router.Embedder, llmR *router.LLMRouter
|
||||
grammars = append(grammars, router.ListGrammars()...)
|
||||
grammars = append(grammars, router.ReminderGrammar())
|
||||
grammars = append(grammars, router.PraxisGrammars()...)
|
||||
grammars = append(grammars, router.TaskStatusGrammar())
|
||||
grammars = append(grammars, router.TaskCaptureGrammar())
|
||||
// "расскажи про X" is a world question the model called a fact, and the
|
||||
// rule goes last because it matches on the first word alone (Vikunja #498).
|
||||
|
||||
@@ -112,84 +112,6 @@ func TestLLMRouterBaseline(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestReachWithLLMRouter — the reach fixture scored with the resident model as
|
||||
// router (Vikunja #517). V-405 measured the classifier only, and the LLM router
|
||||
// is the deployed default, so 16/30 with praxis 0/12 is the floor rather than
|
||||
// the shipped behaviour.
|
||||
//
|
||||
// The question is specific. The route grammar lets the model write any string
|
||||
// into the fn slot, so it *could* emit a literal Praxis capability name and
|
||||
// reach a service the classifier structurally cannot. If it does, V-516's
|
||||
// stage-0 Praxis grammars are a determinism argument. If it does not, they are
|
||||
// the only path.
|
||||
//
|
||||
// Same gate and same two configurations as TestLLMRouterBaseline, for the same
|
||||
// reason: "the LLM router" alone and the cascade that actually ships answer
|
||||
// different questions.
|
||||
func TestReachWithLLMRouter(t *testing.T) {
|
||||
base := os.Getenv("MAVEN_LLM_URL")
|
||||
if base == "" {
|
||||
t.Skip("MAVEN_LLM_URL unset — start llama-server and point it here (see doc comment)")
|
||||
}
|
||||
noProxyLoopback(t)
|
||||
|
||||
f, err := LoadReach()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadReach: %v", err)
|
||||
}
|
||||
client := llm.New(base, 60*time.Second)
|
||||
if err := ping(context.Background(), client); err != nil {
|
||||
t.Skipf("llama-server at %s unreachable: %v", base, err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
model, err := llm.ModelID(ctx, base)
|
||||
if err != nil {
|
||||
t.Logf("could not read model id from %s: %v — reports will say %q", base, err, llm.UnknownModel)
|
||||
model = llm.UnknownModel
|
||||
}
|
||||
t.Logf("scoring reach with model %s at %s", model, base)
|
||||
lr := router.NewLLMRouter(client)
|
||||
m := router.DefaultActMatcher{Fns: actFns}
|
||||
|
||||
llmOnly := RouterFunc(func(ctx context.Context, u string, now time.Time) (router.Decision, error) {
|
||||
d, ok, err := lr.Route(ctx, u, now)
|
||||
if err != nil {
|
||||
return d, err
|
||||
}
|
||||
if !ok {
|
||||
return d, fmt.Errorf("llm router declined without an error")
|
||||
}
|
||||
return d, nil
|
||||
})
|
||||
repLLM, err := ScoreReach(ctx, "reach: llm-only ("+model+")", llmOnly, m, f)
|
||||
if err != nil {
|
||||
t.Fatalf("ScoreReach llm-only: %v", err)
|
||||
}
|
||||
t.Log("\n" + repLLM.String() + repLLM.Failures())
|
||||
|
||||
// Hash embedder for the classifier floor, so any lift is the model's and
|
||||
// not the embedder's — the same control TestLLMRouterBaseline uses.
|
||||
repCascade, err := ScoreReach(ctx, "reach: cascade+llm ("+model+") + hash fallback",
|
||||
newBaselineRouter(t, router.NewHashEmbedder(1024), lr), m, f)
|
||||
if err != nil {
|
||||
t.Fatalf("ScoreReach cascade: %v", err)
|
||||
}
|
||||
t.Log("\n" + repCascade.String() + repCascade.Failures())
|
||||
|
||||
for _, rep := range []ReachReport{repLLM, repCascade} {
|
||||
if rep.Errors == rep.Total {
|
||||
t.Errorf("%s: all %d cases errored — harness fault, not a measurement", rep.Name, rep.Total)
|
||||
}
|
||||
}
|
||||
// Overreach is the one direction worth failing on, for the reason
|
||||
// TestReachBaselineHash gives: he never gets asked about it.
|
||||
if repCascade.Overreach > 4 {
|
||||
t.Errorf("%d utterances reached a service they should not have, want <= 4:\n%s",
|
||||
repCascade.Overreach, repCascade.Failures())
|
||||
}
|
||||
}
|
||||
|
||||
func ping(ctx context.Context, c *llm.Client) error {
|
||||
ctx, cancel := context.WithTimeout(ctx, 90*time.Second)
|
||||
defer cancel()
|
||||
|
||||
@@ -70,8 +70,6 @@
|
||||
{ "id": "ru-act-004", "utterance": "включи вытяжку", "lang": "ru", "intent": "act", "want_fn": true },
|
||||
{ "id": "ru-act-005", "utterance": "запусти бэкап сейчас", "lang": "ru", "intent": "act", "want_fn": true },
|
||||
{ "id": "ru-act-006", "utterance": "закрой жалюзи", "lang": "ru", "intent": "act", "want_fn": true },
|
||||
{ "id": "ru-act-020", "utterance": "закрой задачу купить молоко", "lang": "ru", "intent": "act", "want_fn": true, "tags": ["task", "status"], "note": "a spoken status change over the board. Routed act with no allowlisted fn until V-512, so the gate asked \"Что сделать?\"; TaskStatusGrammar fills the fn slot with task_status and actionAct answers it from Maven's own store" },
|
||||
{ "id": "ru-act-021", "utterance": "убери из задач оплатить интернет", "lang": "ru", "intent": "act", "want_fn": true, "tags": ["task", "status"], "note": "the drop half of the same rule. Dropped work he chose to stop is not work he did, so the two status sets are separate lexicons" },
|
||||
{ "id": "en-act-001", "utterance": "maven, restart the media server", "lang": "en", "intent": "act", "want_fn": true, "tags": ["wake-token"] },
|
||||
{ "id": "en-act-002", "utterance": "turn off the kitchen light", "lang": "en", "intent": "act", "want_fn": true },
|
||||
|
||||
@@ -104,8 +102,6 @@
|
||||
{ "id": "amb-003", "utterance": "ну это", "lang": "ru", "want_clarify": true, "tags": ["ambiguous", "filler"] },
|
||||
{ "id": "amb-004", "utterance": "сделай это", "lang": "ru", "want_clarify": true, "tags": ["ambiguous", "anaphora"], "note": "unresolved anaphora with an imperative — must not guess an fn" },
|
||||
{ "id": "amb-005", "utterance": "потом", "lang": "ru", "want_clarify": true, "tags": ["ambiguous", "filler"] },
|
||||
{ "id": "amb-006", "utterance": "the thing from earlier", "lang": "en", "want_clarify": true, "tags": ["ambiguous", "anaphora"] },
|
||||
{ "id": "amb-007", "utterance": "напомни", "lang": "ru", "want_clarify": true, "tags": ["ambiguous", "reminder"], "note": "the reminder verb and nothing else — she knows the shape of the request and not one thing about it. Answered 'не получилось разобрать время напоминания' on the box until V-548: the subjectless-reminder gate tested Slots.Text == \"\", and fillSlots had put the verb in that slot" },
|
||||
{ "id": "amb-008", "utterance": "ну напомни же", "lang": "ru", "want_clarify": true, "tags": ["ambiguous", "reminder", "filler"], "note": "the same request wrapped in particles, which is why filler_particles is a lexicon set — without it the particles read as the subject" }
|
||||
{ "id": "amb-006", "utterance": "the thing from earlier", "lang": "en", "want_clarify": true, "tags": ["ambiguous", "anaphora"] }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -5,16 +5,10 @@ import "strings"
|
||||
// Feed queries — "что нового в лентах?", "что нового по технологиям?"
|
||||
// (Vikunja #258).
|
||||
//
|
||||
// This file is the OFFLINE FLOOR as of 05-08-2026 (V-522). Whether a turn is
|
||||
// about the feeds is a question about meaning, so the frozen seeds decide it —
|
||||
// topicFeed in cmd/mavend/topics.go, through turnIsAbout. The word lists below
|
||||
// stay because they always answer: a box with no embedder, a turn whose vector
|
||||
// never got computed, and any call that does not clear topicMargin. They are
|
||||
// allowed to stay narrow now that they are not the only answer.
|
||||
//
|
||||
// What has not changed is that no generative model decides this. One would
|
||||
// occasionally answer "что нового?" out of world knowledge, which is the one
|
||||
// thing a feed reader exists to avoid.
|
||||
// Deterministic matching, like the calendar, plan and habit matchers above it:
|
||||
// the LLM router says this is a query, and this decides whether it is a question
|
||||
// about the feeds. A model deciding that would occasionally answer "что нового?"
|
||||
// out of world knowledge, which is the one thing a feed reader exists to avoid.
|
||||
|
||||
// FeedQuery — a parsed "what's new" question. Category is the topic he named
|
||||
// ("технологии"), empty when he asked about the feeds in general.
|
||||
@@ -101,13 +95,6 @@ func ParseFeedQuery(text string) (FeedQuery, bool) {
|
||||
// cannot be mistaken for anything else.
|
||||
var categoryPreps = map[string]bool{"по": true, "об": true, "про": true, "about": true, "on": true}
|
||||
|
||||
// FeedCategoryOf reads the topic out of an utterance without deciding whether the
|
||||
// turn is a feed question at all. The seeds answer that now (topicFeed in
|
||||
// cmd/mavend/topics.go, V-522), and they answer it for phrasings the word lists
|
||||
// here never held — but a claimed turn still needs its category, and a category
|
||||
// is marked by a preposition rather than recognised.
|
||||
func FeedCategoryOf(text string) string { return feedCategory(planTokens(text)) }
|
||||
|
||||
func feedCategory(toks []string) string {
|
||||
for i, t := range toks {
|
||||
if categoryPreps[t] && i+1 < len(toks) {
|
||||
|
||||
@@ -24,9 +24,13 @@ import (
|
||||
// had one and added when it did not, because the stub scans for "в" before a
|
||||
// clock and the contracted "полвосьмого" carries no preposition at all.
|
||||
|
||||
// The forms of "половина" a spoken time uses are a closed class and live in the
|
||||
// lexicon as half_hour, because the ordinal-selection turn has to decline the
|
||||
// same ordinal this file reads (V-522). Ask lexicon.IsHalfHour.
|
||||
// halfWords — the forms of "половина" a spoken time uses. "в половине",
|
||||
// "половина", "к половине", "полвосьмого". Closed and tiny; the ordinal beside
|
||||
// them is what carries the hour, and that comes from the lexicon.
|
||||
var halfWords = map[string]bool{
|
||||
"половина": true, "половине": true, "половину": true, "половины": true,
|
||||
"пол": true, "half": true,
|
||||
}
|
||||
|
||||
// minutesTo — the words that name the minutes in a "без X" hour. "четверти" is
|
||||
// the only one that is not a number; the rest are cardinals and are read as
|
||||
@@ -70,7 +74,7 @@ func halfPastAt(toks []string, i int) (hour, width int, ok bool) {
|
||||
return h, 1, true
|
||||
}
|
||||
}
|
||||
if !lexicon.IsHalfHour(head) || i+1 >= len(toks) {
|
||||
if !halfWords[head] || i+1 >= len(toks) {
|
||||
return 0, 0, false
|
||||
}
|
||||
h, ok := enteredHour(cleanWord(toks[i+1]))
|
||||
|
||||
@@ -17,15 +17,6 @@ import (
|
||||
// The markers are deliberately explicit. "молоко закончилось" is an
|
||||
// observation about the world and belongs in a note; only an instruction to
|
||||
// put something on a list puts it there.
|
||||
//
|
||||
// The four paths split on 05-08-2026, and the split is by what the caller needs
|
||||
// rather than by language (V-522). Reading a list back needs one bit — is this
|
||||
// about the list — so the seeds decide it, topicList through turnIsAbout, and
|
||||
// listQueryPrefixes below is the offline floor. The other three keep the tables
|
||||
// as the answer. Add and remove need to know WHERE the item starts, and a
|
||||
// cosine over a whole utterance does not say which byte the milk begins at.
|
||||
// Clear DELETES the list, so it stays on exact phrases: a false claim there
|
||||
// loses rows he cannot get back, which is not the trade a margin makes.
|
||||
|
||||
// listTags — the lists he can name, as one dictionary form each. Russian
|
||||
// declines the tag ("список покупок", "в покупки", "в покупках"), and the
|
||||
@@ -195,31 +186,6 @@ func ParseListQuery(text string) (string, bool) {
|
||||
return list, true
|
||||
}
|
||||
|
||||
// ListNamedIn reports which standing list an utterance names, anywhere in it,
|
||||
// defaulting to покупки when it names none.
|
||||
//
|
||||
// takeListTag is not enough for a read-back, because it reads the FRONT of a
|
||||
// remainder a prefix table has already eaten. The seeds claim a read-back
|
||||
// without eating anything (topicList, cmd/mavend/topics.go, V-522), so "что мне
|
||||
// нужно в аптеке" has to be scanned rather than trimmed. A list name is a noun
|
||||
// in the dictionary, so this is a lookup and decides nothing about meaning.
|
||||
func ListNamedIn(text string) string {
|
||||
for _, f := range strings.Fields(strings.ToLower(text)) {
|
||||
head := strings.Trim(f, listTrimCut)
|
||||
for _, s := range listTags {
|
||||
if morph.SameWord(head, s.word) {
|
||||
return s.list
|
||||
}
|
||||
}
|
||||
for _, s := range listTagsEN {
|
||||
if head == s.word {
|
||||
return s.list
|
||||
}
|
||||
}
|
||||
}
|
||||
return "покупки"
|
||||
}
|
||||
|
||||
// ParseListClear reports whether an utterance crosses off a whole list.
|
||||
func ParseListClear(text string) (string, bool) {
|
||||
lower := strings.ToLower(strings.Trim(strings.TrimSpace(text), listTrimCut))
|
||||
|
||||
@@ -26,24 +26,6 @@ var (
|
||||
captureVerbs = lexicon.CaptureVerbs()
|
||||
)
|
||||
|
||||
// CarriesCaptureVerb reports whether text tells Maven to write something down.
|
||||
// Sibling of IsQuestionShaped and matched over the same tokens, and the two do
|
||||
// not overlap: IsQuestionShaped returns false for anything this returns true
|
||||
// for, because "запиши что я пил воду" is a capture and not a question.
|
||||
//
|
||||
// Both exist together so a caller can ask "is this its own request?" — a
|
||||
// clarify answer that asks a question or orders a capture is not an answer
|
||||
// (Vikunja #554).
|
||||
func CarriesCaptureVerb(text string) bool {
|
||||
toks := planTokens(strings.TrimSpace(text))
|
||||
for _, v := range captureVerbs {
|
||||
if hasTok(toks, v) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// IsQuestionShaped reports whether text asks for something rather than
|
||||
// records it. It is a deterministic offline test over tokens, so it costs
|
||||
// nothing and never depends on the model that produced the routing decision.
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/kami/maven/internal/lexicon"
|
||||
"github.com/kami/maven/internal/morph"
|
||||
)
|
||||
|
||||
// A reminder needs something to say at the hour, and the gate that checks for
|
||||
// one was reading a slot that is never empty.
|
||||
//
|
||||
// gateLLMDecision has asked about a subjectless reminder since V-383, on the
|
||||
// test `d.Slots.Text == ""`. Measured on the box on 05-08-2026: "напомни" alone
|
||||
// routes to IntentReminder with `Text:напомни`, because fillSlots hands the text
|
||||
// slot the utterance when the model names nothing narrower. So the slot was
|
||||
// never empty, the gate never fired, and the turn reached actionReminder and
|
||||
// answered "не получилось разобрать время напоминания." — a parse error for a
|
||||
// request she never finished asking about. "ну напомни же" did the same.
|
||||
//
|
||||
// The fix is to ask what the text slot CONTAINS rather than whether it is set.
|
||||
// Two closed classes answer that and no third mechanism is needed: the reminder
|
||||
// verbs are her own vocabulary (lexicon.ReminderVerbs), and the particles and
|
||||
// politeness words cannot be the subject of anything (lexicon.IsFillerParticle).
|
||||
// A verb is matched by lemma through morph.SameWord, so "напоминай" and
|
||||
// "напомнить" need no entry of their own.
|
||||
//
|
||||
// Deliberately NOT reusing cmd/mavend/reminderbody.go, which strips the same
|
||||
// marker: that function also strips the time words, so "напомни завтра" would
|
||||
// read as subjectless there. Asking is right when he named no subject, and wrong
|
||||
// when he named a day — the reminder for tomorrow is the one whose subject she
|
||||
// should ask about, not one she should treat as noise.
|
||||
|
||||
// reminderHasSubject reports whether a reminder's text names anything to say at
|
||||
// the hour. False for "напомни", "напомни мне", "ну напомни же"; true for
|
||||
// "напомни позвонить маме" and for "напомни завтра", where the day is a subject
|
||||
// she can ask nothing better about.
|
||||
func reminderHasSubject(text string) bool {
|
||||
for _, f := range strings.Fields(strings.ToLower(text)) {
|
||||
w := strings.Trim(f, " ,.;:!?—-«»\"'()")
|
||||
if w == "" || lexicon.IsFillerParticle(w) {
|
||||
continue
|
||||
}
|
||||
if isReminderVerb(w) {
|
||||
continue
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// isReminderVerb matches one of her reminder imperatives by lemma. Lemma and not
|
||||
// prefix: "напоминание" is a noun he can perfectly well ask to be reminded
|
||||
// about, and a stem test would eat it.
|
||||
func isReminderVerb(word string) bool {
|
||||
for _, v := range lexicon.ReminderVerbs() {
|
||||
if word == v || morph.SameWord(word, v) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
package router
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestReminderHasSubject(t *testing.T) {
|
||||
cases := []struct {
|
||||
text string
|
||||
want bool
|
||||
}{
|
||||
// The three the box produced, and the reason this file exists.
|
||||
{"напомни", false},
|
||||
{"ну напомни же", false},
|
||||
{"напомни мне", false},
|
||||
{"напомни мне пожалуйста", false},
|
||||
// Lemma, not literal: none of these forms is the one in the utterance
|
||||
// the lexicon lists first.
|
||||
{"напоминай", false},
|
||||
{"напомнить", false},
|
||||
{"remind me", false},
|
||||
{"remind me please", false},
|
||||
// A real subject, however short.
|
||||
{"напомни позвонить маме", true},
|
||||
{"напомни про таблетки", true},
|
||||
{"напомни выпить воды", true},
|
||||
{"remind me to call mom", true},
|
||||
// A day is a subject she can ask nothing better about, so she does not
|
||||
// ask. This is where reminderBody's stripping would disagree, on purpose.
|
||||
{"напомни завтра", true},
|
||||
{"напомни в семь", true},
|
||||
// A noun that starts like the verb. A stem test would eat it.
|
||||
{"напомни про напоминание", true},
|
||||
// Empty is subjectless without asking the lexicon anything.
|
||||
{"", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := reminderHasSubject(c.text); got != c.want {
|
||||
t.Errorf("reminderHasSubject(%q) = %v, want %v", c.text, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -189,12 +189,7 @@ func (r *Router) gateLLMDecision(d *Decision) {
|
||||
// A reminder with no subject: she knows when but not what to say then.
|
||||
// Setting it anyway fires an empty reminder at the hour, which reads as a
|
||||
// bug to him and cannot be repaired after the fact. Ask (Vikunja #383).
|
||||
//
|
||||
// The test is what the text slot CONTAINS, not whether it is set. It was the
|
||||
// latter until 05-08-2026, and the slot is never empty: fillSlots hands it
|
||||
// the utterance, so "напомни" arrived here with Text:напомни and the gate
|
||||
// never fired (V-457). See remindersubject.go.
|
||||
if d.Intent == IntentReminder && !reminderHasSubject(d.Slots.Text) && d.Confidence > llmThinConfidence {
|
||||
if d.Intent == IntentReminder && d.Slots.Text == "" && d.Confidence > llmThinConfidence {
|
||||
d.Confidence = llmThinConfidence
|
||||
}
|
||||
if d.Confidence < r.threshold {
|
||||
|
||||
@@ -1,178 +0,0 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/kami/maven/internal/lexicon"
|
||||
"github.com/kami/maven/internal/morph"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
// A spoken status change over the task list (Vikunja #512, step 4 of
|
||||
// docs/plans/15-board-surface.md).
|
||||
//
|
||||
// Capture and the list read were built; moving a task was only possible in the
|
||||
// two turns after she read one out, through resolveCandidate — "первую сделал"
|
||||
// against the bound list. Naming the task instead of its position reached
|
||||
// nothing: "закрой задачу купить молоко" routed act, found no allowlisted fn,
|
||||
// and the gate asked "Что сделать?".
|
||||
//
|
||||
// Same shape TaskCaptureGrammar uses, for the same reason: no eighth intent, so
|
||||
// the grammar matches broadly and a deterministic parser inside Build decides.
|
||||
// The task's own warning applies — every such grammar runs its parser ahead of
|
||||
// the resident model on every turn, so this is the last one that is free.
|
||||
//
|
||||
// TaskStatusFn is the fn slot the daemon dispatches on. Not a Hexis capability
|
||||
// and not a Praxis one: the board is Maven's own store, so actionAct intercepts
|
||||
// this name before either ecosystem client sees it.
|
||||
const TaskStatusFn = "task_status"
|
||||
|
||||
// TaskStatus — a parsed status change. Status is a store task status, and Text
|
||||
// is the task he named, empty when he named none ("закрой задачу"), which is a
|
||||
// turn the daemon claims and answers by asking which.
|
||||
type TaskStatus struct {
|
||||
Status string
|
||||
Text string
|
||||
}
|
||||
|
||||
// taskStatusNouns — the noun that makes this a board turn rather than ordinary
|
||||
// speech. Required, and it is the whole reason this rule is safe to run on every
|
||||
// utterance: "готово" alone is him reporting his day, "убери" alone is a request
|
||||
// about the room, and neither names the list.
|
||||
//
|
||||
// "дело" is deliberately absent. "в чём дело" and "дело в том" are ordinary
|
||||
// speech, and "список дел" is already a list query.
|
||||
var taskStatusNouns = []string{"task", "tasks", "todo", "todos"}
|
||||
|
||||
// taskStatusFillers — the words to ignore when what is left over is the task he
|
||||
// named. Prepositions and the possessive, because "убери из моих задач купить
|
||||
// молоко" names the same task as "убери задачу купить молоко".
|
||||
var taskStatusFillers = []string{"из", "в", "во", "с", "со", "мои", "моих", "моё", "мой", "моя", "мою", "my", "the", "from", "off", "as", "как"}
|
||||
|
||||
// ParseTaskStatus reads a status change over the board: which transition, and
|
||||
// which task.
|
||||
//
|
||||
// Three conditions, all required. A task noun, so no ordinary sentence claims
|
||||
// the turn. Exactly one status class, because "готово, убери" names two and
|
||||
// asking beats picking. And a status word that is either an imperative in the
|
||||
// exact form he said it or a stative by lemma — the trap quiet_toggle.go
|
||||
// documents, where "закрой" and "закрыл" are one lemma and only one is a
|
||||
// command.
|
||||
func ParseTaskStatus(text string) (TaskStatus, bool) {
|
||||
toks := praxisTokens(strings.ToLower(strings.TrimSpace(text)))
|
||||
if len(toks) == 0 || !taskStatusNamesBoard(toks) {
|
||||
return TaskStatus{}, false
|
||||
}
|
||||
status := ""
|
||||
for _, c := range []struct {
|
||||
status string
|
||||
words []string
|
||||
}{
|
||||
{store.TaskDone, lexicon.TaskDoneWords()},
|
||||
{store.TaskDropped, lexicon.TaskDropWords()},
|
||||
} {
|
||||
if !taskStatusHasWord(toks, c.words) {
|
||||
continue
|
||||
}
|
||||
if status != "" {
|
||||
// Two transitions in one sentence. They are different rows on the
|
||||
// page, so this declines and the cascade answers.
|
||||
return TaskStatus{}, false
|
||||
}
|
||||
status = c.status
|
||||
}
|
||||
if status == "" {
|
||||
return TaskStatus{}, false
|
||||
}
|
||||
return TaskStatus{Status: status, Text: taskStatusReferent(toks)}, true
|
||||
}
|
||||
|
||||
// taskStatusNamesBoard reports whether the sentence names the task list. The
|
||||
// Russian noun is matched by lemma, because a noun means the same thing in every
|
||||
// case and he says "из задач", "задачу", "задача" for one list.
|
||||
func taskStatusNamesBoard(toks []string) bool {
|
||||
for _, t := range toks {
|
||||
if morph.SameWord(t, "задача") {
|
||||
return true
|
||||
}
|
||||
for _, n := range taskStatusNouns {
|
||||
if t == n {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// taskStatusHasWord matches a status word the way its set's note requires: an
|
||||
// imperative exactly, a stative by lemma. It cannot tell the two columns apart
|
||||
// from the data, so it tries the exact form first and then the lemma — which
|
||||
// costs the imperative trap back, except that both columns of one set mean the
|
||||
// SAME transition. "закрой" and "закрыл" are one lemma and, here, one status.
|
||||
func taskStatusHasWord(toks, words []string) bool {
|
||||
for _, t := range toks {
|
||||
for _, w := range words {
|
||||
if t == w || morph.SameWord(t, w) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// taskStatusReferent is what is left after the status words, the board noun and
|
||||
// the fillers: the task he named, or "" when he named none.
|
||||
//
|
||||
// Word order is kept, because the leftover is matched against stored task text
|
||||
// and he says the task the way he first said it.
|
||||
func taskStatusReferent(toks []string) string {
|
||||
done, drop := lexicon.TaskDoneWords(), lexicon.TaskDropWords()
|
||||
var out []string
|
||||
for _, t := range toks {
|
||||
switch {
|
||||
case taskStatusHasWord([]string{t}, done), taskStatusHasWord([]string{t}, drop):
|
||||
case morph.SameWord(t, "задача"), taskStatusIn(t, taskStatusNouns):
|
||||
case taskStatusIn(t, taskStatusFillers), lexicon.IsFillerParticle(t):
|
||||
default:
|
||||
out = append(out, t)
|
||||
}
|
||||
}
|
||||
return strings.Join(out, " ")
|
||||
}
|
||||
|
||||
func taskStatusIn(tok string, words []string) bool {
|
||||
for _, w := range words {
|
||||
if tok == w {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// TaskStatusGrammar — stage 0 for a spoken status change. Wired after the Praxis
|
||||
// rules and before the capture marker: Praxis claims a bare "закрой" and this
|
||||
// rule requires the board noun, so the two cannot collide, and the capture
|
||||
// marker must not read "убери из задач купить молоко" as a new task.
|
||||
func TaskStatusGrammar() Grammar {
|
||||
return Grammar{
|
||||
Name: "task-status",
|
||||
Pattern: regexp.MustCompile(`(?s)^\s*(.+)$`),
|
||||
Build: func(m []string) (Decision, bool) {
|
||||
c, ok := ParseTaskStatus(m[1])
|
||||
if !ok {
|
||||
return Decision{}, false
|
||||
}
|
||||
return Decision{
|
||||
Stage: 0,
|
||||
Intent: IntentAct,
|
||||
Confidence: 1.0,
|
||||
// Value carries the transition and Text the task he named,
|
||||
// which is the pairing handlePraxisAct uses for an item and its
|
||||
// reference. Empty Text is a claim, not a refusal: the daemon
|
||||
// asks which task, having the list she does not.
|
||||
Slots: Slots{Fn: TaskStatusFn, HasFn: true, Value: c.Status, Text: c.Text},
|
||||
}, true
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
package router
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseTaskStatus(t *testing.T) {
|
||||
cases := []struct {
|
||||
utterance string
|
||||
ok bool
|
||||
status string
|
||||
text string
|
||||
}{
|
||||
// The shapes that reached nothing before this rule.
|
||||
{"закрой задачу купить молоко", true, "done", "купить молоко"},
|
||||
{"задачу купить молоко сделал", true, "done", "купить молоко"},
|
||||
{"убери из задач купить молоко", true, "dropped", "купить молоко"},
|
||||
{"убери из моих задач купить молоко", true, "dropped", "купить молоко"},
|
||||
{"отмени задачу оплатить интернет", true, "dropped", "оплатить интернет"},
|
||||
{"task buy milk done", true, "done", "buy milk"},
|
||||
// The referent may be missing. The turn is still his, and the daemon has
|
||||
// the list to ask about.
|
||||
{"закрой задачу", true, "done", ""},
|
||||
{"убери задачу", true, "dropped", ""},
|
||||
// No board noun: ordinary speech, and every one of these means something
|
||||
// else. "закрой" alone belongs to Praxis.
|
||||
{"готово", false, "", ""},
|
||||
{"закрой", false, "", ""},
|
||||
{"убери со стола", false, "", ""},
|
||||
{"я всё сделал", false, "", ""},
|
||||
{"закрой шторы в комнате", false, "", ""},
|
||||
// The board noun with no status word is a list query, not a move.
|
||||
{"какие у меня задачи", false, "", ""},
|
||||
{"добавь в задачи купить молоко", false, "", ""},
|
||||
// Two transitions in one sentence. Asking beats picking.
|
||||
{"задачу купить молоко готово убери", false, "", ""},
|
||||
{"", false, "", ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got, ok := ParseTaskStatus(c.utterance)
|
||||
if ok != c.ok {
|
||||
t.Errorf("ParseTaskStatus(%q) ok = %v, want %v", c.utterance, ok, c.ok)
|
||||
continue
|
||||
}
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if got.Status != c.status || got.Text != c.text {
|
||||
t.Errorf("ParseTaskStatus(%q) = %+v, want status %q text %q", c.utterance, got, c.status, c.text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskStatusGrammarFillsTheFnSlot(t *testing.T) {
|
||||
g := TaskStatusGrammar()
|
||||
m := g.Pattern.FindStringSubmatch("закрой задачу купить молоко")
|
||||
if m == nil {
|
||||
t.Fatal("pattern did not match")
|
||||
}
|
||||
dec, ok := g.Build(m)
|
||||
if !ok {
|
||||
t.Fatal("Build declined")
|
||||
}
|
||||
if dec.Intent != IntentAct || !dec.Slots.HasFn || dec.Slots.Fn != TaskStatusFn {
|
||||
t.Fatalf("decision = %+v, want act with fn %q", dec, TaskStatusFn)
|
||||
}
|
||||
if dec.Slots.Value != "done" || dec.Slots.Text != "купить молоко" {
|
||||
t.Fatalf("slots = %+v, want value done text \"купить молоко\"", dec.Slots)
|
||||
}
|
||||
}
|
||||
@@ -129,7 +129,7 @@ func buildTimeMarkers() map[string]bool {
|
||||
m[w] = true
|
||||
}
|
||||
}
|
||||
for _, w := range lexicon.HalfHourWords() {
|
||||
for w := range halfWords {
|
||||
m[w] = true
|
||||
}
|
||||
for w := range minutesTo {
|
||||
|
||||
@@ -36,13 +36,6 @@ const (
|
||||
TasksFirst = "tasks_first"
|
||||
TasksCandidates = "tasks_candidates"
|
||||
|
||||
// The counted stall shapes on /tasks and in the spoken list (V-512). Each
|
||||
// one states a count and nothing about what it means: "лежит дольше десяти
|
||||
// дней" is arithmetic, "стоит бросить" would be a judgement she may not make.
|
||||
StallOverdue = "stall_overdue"
|
||||
StallSitting = "stall_sitting"
|
||||
StallUnconfirmed = "stall_unconfirmed"
|
||||
|
||||
ReasonOverdue = "reason_overdue"
|
||||
ReasonOverdueDays = "reason_overdue_days"
|
||||
ReasonToday = "reason_today"
|
||||
@@ -71,7 +64,6 @@ const (
|
||||
var summaryKeys = []string{
|
||||
PlanRestEmpty, PlanDayEmpty, PlanDay, PlanUncertain,
|
||||
TasksNone, TasksFirst, TasksCandidates,
|
||||
StallOverdue, StallSitting, StallUnconfirmed,
|
||||
ReasonOverdue, ReasonOverdueDays, ReasonToday, ReasonTomorrow,
|
||||
ReasonInDays, ReasonImportant, ReasonUrgent, ReasonStale,
|
||||
HabitWeekday, HabitWeekdaySame, HabitWeekdayNone,
|
||||
@@ -94,10 +86,6 @@ var summaryFloor = map[string]string{
|
||||
TasksFirst: "сначала: {items}",
|
||||
TasksCandidates: "нашла ещё, но ты не подтверждал: {items}",
|
||||
|
||||
StallOverdue: "{n} {word} просрочено",
|
||||
StallSitting: "{n} {word} лежит дольше {days} {dayword}",
|
||||
StallUnconfirmed: "{n} {word} ждёт подтверждения",
|
||||
|
||||
ReasonOverdue: "просрочено",
|
||||
ReasonOverdueDays: "просрочено на {n} {word}",
|
||||
ReasonToday: "сегодня",
|
||||
@@ -140,9 +128,6 @@ func LoadSummaries(src rand.Source) (*Summaries, error) {
|
||||
{PlanDayEmpty, "{date}"}, {PlanDay, "{date}"}, {PlanDay, "{items}"},
|
||||
{PlanUncertain, "{line}"},
|
||||
{TasksFirst, "{items}"}, {TasksCandidates, "{items}"},
|
||||
{StallOverdue, "{n}"}, {StallOverdue, "{word}"},
|
||||
{StallSitting, "{n}"}, {StallSitting, "{days}"},
|
||||
{StallUnconfirmed, "{n}"}, {StallUnconfirmed, "{word}"},
|
||||
{ReasonOverdueDays, "{n}"}, {ReasonOverdueDays, "{word}"},
|
||||
{ReasonInDays, "{n}"}, {ReasonInDays, "{word}"},
|
||||
{HabitWeekday, "{day}"}, {HabitWeekday, "{items}"},
|
||||
|
||||
@@ -44,19 +44,6 @@
|
||||
"variants": ["нашла ещё, но ты не подтверждал: {items}"]
|
||||
},
|
||||
|
||||
"stall_overdue": {
|
||||
"fixed": true,
|
||||
"variants": ["{n} {word} просрочено"]
|
||||
},
|
||||
"stall_sitting": {
|
||||
"fixed": true,
|
||||
"variants": ["{n} {word} лежит дольше {days} {dayword}"]
|
||||
},
|
||||
"stall_unconfirmed": {
|
||||
"fixed": true,
|
||||
"variants": ["{n} {word} ждёт подтверждения"]
|
||||
},
|
||||
|
||||
"reason_overdue": {
|
||||
"fixed": true,
|
||||
"variants": ["просрочено"]
|
||||
|
||||
@@ -290,16 +290,6 @@ ALTER TABLE reminders ADD COLUMN next_fire_ts INTEGER;`, // #2
|
||||
ALTER TABLE nudges_new RENAME TO nudges;
|
||||
CREATE INDEX IF NOT EXISTS idx_nudges_rule_ts ON nudges (rule, ts DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_nudges_outcome ON nudges (outcome);`,
|
||||
|
||||
// #22 — the two columns that make tasks a work board rather than a to-do
|
||||
// list (Vikunja #510). done_when is the acceptance criterion, and blocked_on
|
||||
// is a canonical Nexus entity id: it names a person, identity lives in
|
||||
// Nexus, and a local free-text name would be a second answer to a question
|
||||
// Nexus already owns. Both default to empty rather than NULL, because "he
|
||||
// has not written one" and "there is nothing to write" are the same state
|
||||
// here and no caller has to tell them apart.
|
||||
`ALTER TABLE tasks ADD COLUMN done_when TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE tasks ADD COLUMN blocked_on TEXT NOT NULL DEFAULT '';`,
|
||||
}
|
||||
|
||||
// migrate applies every migration with a number greater than the DB's current
|
||||
|
||||
+6
-122
@@ -66,16 +66,6 @@ type Task struct {
|
||||
Weight int
|
||||
ResolvedTs *time.Time
|
||||
ResolvedBy string
|
||||
|
||||
// DoneWhen — the acceptance criterion, in his words. It must be able to
|
||||
// close on either outcome: "it already works" counts as complete, and a
|
||||
// criterion only one result satisfies is a wish rather than a definition
|
||||
// (Vikunja #510). Empty until he writes one.
|
||||
DoneWhen string
|
||||
// BlockedOn — a canonical Nexus entity id, never a name. Identity lives in
|
||||
// Nexus, so storing "Саша" here would be a second answer to a question
|
||||
// Nexus already owns. Empty when nothing blocks the task.
|
||||
BlockedOn string
|
||||
}
|
||||
|
||||
// CaptureResult — what CaptureTask did. Created is a new row. Promoted is an
|
||||
@@ -92,22 +82,6 @@ var (
|
||||
ErrTaskNotFound = errors.New("store: task not found")
|
||||
ErrTaskEmpty = errors.New("store: task text is empty")
|
||||
ErrTaskStatus = errors.New("store: invalid task status")
|
||||
// ErrTaskNoDoneWhen — a candidate cannot be promoted to open without a
|
||||
// definition of done (Vikunja #510). Same refusal ParseTaskCapture makes
|
||||
// for a capture marker with nothing after it: confirming work whose
|
||||
// finish line nobody wrote is how a board fills with rows that can never
|
||||
// leave it. Dropping such a candidate stays legal.
|
||||
ErrTaskNoDoneWhen = errors.New("store: task has no definition of done")
|
||||
// ErrTaskDuplicate — an edit would give this task the normalised text of
|
||||
// another live row (Vikunja #509). A refusal, not a merge: two live rows
|
||||
// carry two provenances, two capture times and possibly two external
|
||||
// identities, and merging picks a winner for all three silently. The
|
||||
// surface tells the owner which row already holds the text and lets him
|
||||
// drop one.
|
||||
ErrTaskDuplicate = errors.New("store: another live task already has this text")
|
||||
// ErrTaskResolved — a resolved task is not editable. Its text is the
|
||||
// record of what was finished, and rewriting it rewrites history.
|
||||
ErrTaskResolved = errors.New("store: task is resolved")
|
||||
)
|
||||
|
||||
// liveTaskStatuses — the two statuses that count as outstanding work.
|
||||
@@ -186,10 +160,10 @@ func (s *Store) CaptureTask(ctx context.Context, t Task) (CaptureResult, error)
|
||||
// Untargeted DO NOTHING: either unique index may be the one that fires, and
|
||||
// the lookup below sorts out which.
|
||||
res, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO tasks (created_ts, text, norm, source, evidence, ext_id, status, due_ts, weight, done_when, blocked_on)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?)
|
||||
`INSERT INTO tasks (created_ts, text, norm, source, evidence, ext_id, status, due_ts, weight)
|
||||
VALUES (?,?,?,?,?,?,?,?,?)
|
||||
ON CONFLICT DO NOTHING`,
|
||||
created2.UnixMilli(), text, norm, t.Source, t.Evidence, ext, status, due, t.Weight, t.DoneWhen, t.BlockedOn)
|
||||
created2.UnixMilli(), text, norm, t.Source, t.Evidence, ext, status, due, t.Weight)
|
||||
if err != nil {
|
||||
return CaptureResult{}, fmt.Errorf("capture task: %w", err)
|
||||
}
|
||||
@@ -220,7 +194,7 @@ func (s *Store) CaptureTask(ctx context.Context, t Task) (CaptureResult, error)
|
||||
}
|
||||
}
|
||||
if status == TaskOpen && existing.Status == TaskCandidate {
|
||||
if err := s.setTaskStatus(ctx, existing.ID, TaskOpen, created2, t.Source, false); err != nil {
|
||||
if err := s.SetTaskStatus(ctx, existing.ID, TaskOpen, created2, t.Source); err != nil {
|
||||
return CaptureResult{}, fmt.Errorf("capture task: promote candidate: %w", err)
|
||||
}
|
||||
return CaptureResult{ID: existing.ID, Promoted: true}, nil
|
||||
@@ -257,7 +231,7 @@ func (s *Store) lookupLiveTaskByNorm(ctx context.Context, norm string) (Task, er
|
||||
return t, nil
|
||||
}
|
||||
|
||||
const taskSelect = `SELECT id, created_ts, text, source, evidence, COALESCE(ext_id,''), status, due_ts, weight, resolved_ts, resolved_by, done_when, blocked_on FROM tasks`
|
||||
const taskSelect = `SELECT id, created_ts, text, source, evidence, COALESCE(ext_id,''), status, due_ts, weight, resolved_ts, resolved_by FROM tasks`
|
||||
|
||||
// LookupTask returns one task by id.
|
||||
func (s *Store) LookupTask(ctx context.Context, id int64) (Task, error) {
|
||||
@@ -330,16 +304,6 @@ func (s *Store) ListTasks(ctx context.Context, status string) ([]Task, error) {
|
||||
// "tap:voice"). It is recorded on the row, so a task that turns up resolved
|
||||
// says what resolved it.
|
||||
func (s *Store) SetTaskStatus(ctx context.Context, id int64, status string, ts time.Time, by string) error {
|
||||
return s.setTaskStatus(ctx, id, status, ts, by, true)
|
||||
}
|
||||
|
||||
// setTaskStatus — the move, with the promotion gate optional.
|
||||
//
|
||||
// It is optional for exactly one caller: CaptureTask promoting a candidate he
|
||||
// stated out loud (Vikunja #510). Refusing there would deny intake rather than
|
||||
// ask for a criterion, and a direct open capture never had one either — the gate
|
||||
// belongs to the deliberate promotion on /tasks, where there is a form to fill.
|
||||
func (s *Store) setTaskStatus(ctx context.Context, id int64, status string, ts time.Time, by string, gateDoneWhen bool) error {
|
||||
var from []string
|
||||
switch status {
|
||||
case TaskOpen:
|
||||
@@ -365,13 +329,6 @@ func (s *Store) setTaskStatus(ctx context.Context, id int64, status string, ts t
|
||||
for _, f := range from {
|
||||
args = append(args, f)
|
||||
}
|
||||
if status == TaskOpen && gateDoneWhen {
|
||||
// Promotion needs an acceptance criterion. Checked in the same
|
||||
// statement rather than read-then-write, so two callers confirming one
|
||||
// candidate cannot race past it; the row is read afterwards only to say
|
||||
// WHICH refusal this was.
|
||||
q += ` AND done_when <> ''`
|
||||
}
|
||||
res, err := s.db.ExecContext(ctx, q, args...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set task status: %w", err)
|
||||
@@ -381,84 +338,11 @@ func (s *Store) setTaskStatus(ctx context.Context, id int64, status string, ts t
|
||||
return fmt.Errorf("set task status: rows affected: %w", err)
|
||||
}
|
||||
if n == 0 {
|
||||
if status == TaskOpen && gateDoneWhen {
|
||||
if t, lookErr := s.LookupTask(ctx, id); lookErr == nil && t.Status == TaskCandidate && t.DoneWhen == "" {
|
||||
return fmt.Errorf("%w: id=%d", ErrTaskNoDoneWhen, id)
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("%w: id=%d not in %v", ErrTaskNotFound, id, from)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EditTask rewrites the three fields capture set and nothing else: text, due
|
||||
// date and weight (Vikunja #509). Status stays the one-way ladder SetTaskStatus
|
||||
// owns, and a resolved task is refused outright — its text is the record of
|
||||
// what was finished.
|
||||
//
|
||||
// Editing text re-normalises the dedupe key, which can collide with another
|
||||
// live row. That is ErrTaskDuplicate and it is a refusal: merging would pick
|
||||
// one row's provenance, capture time and external identity over the other's
|
||||
// with nobody asked.
|
||||
//
|
||||
// due nil clears the date. Clearing has to be sayable, so an absent date and
|
||||
// "remove the date" cannot be the same argument.
|
||||
func (s *Store) EditTask(ctx context.Context, id int64, text string, due *time.Time, weight int) error {
|
||||
text = strings.TrimSpace(text)
|
||||
if text == "" {
|
||||
return ErrTaskEmpty
|
||||
}
|
||||
cur, err := s.LookupTask(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if cur.Status != TaskCandidate && cur.Status != TaskOpen {
|
||||
return fmt.Errorf("%w: id=%d is %s", ErrTaskResolved, id, cur.Status)
|
||||
}
|
||||
norm := NormalizeTaskText(text)
|
||||
if norm != NormalizeTaskText(cur.Text) {
|
||||
if other, err := s.lookupLiveTaskByNorm(ctx, norm); err == nil && other.ID != id {
|
||||
return fmt.Errorf("%w: id=%d holds it", ErrTaskDuplicate, other.ID)
|
||||
} else if err != nil && !errors.Is(err, ErrTaskNotFound) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
var dueVal sql.NullInt64
|
||||
if due != nil {
|
||||
dueVal = sql.NullInt64{Int64: due.UnixMilli(), Valid: true}
|
||||
}
|
||||
if _, err := s.db.ExecContext(ctx,
|
||||
`UPDATE tasks SET text = ?, norm = ?, due_ts = ?, weight = ? WHERE id = ?`,
|
||||
text, norm, dueVal, weight, id); err != nil {
|
||||
return fmt.Errorf("edit task: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetTaskFields writes the two board columns. Separate from SetTaskStatus
|
||||
// because a status move is one-way and these are not: he may sharpen a
|
||||
// definition of done, and a blocker clears when the person answers.
|
||||
//
|
||||
// blockedOn is a canonical Nexus entity id or empty. Free text does not belong
|
||||
// here — identity lives in Nexus, and a local name would be a second answer to
|
||||
// a question Nexus already owns. The caller resolves before it writes.
|
||||
func (s *Store) SetTaskFields(ctx context.Context, id int64, doneWhen, blockedOn string) error {
|
||||
res, err := s.db.ExecContext(ctx,
|
||||
`UPDATE tasks SET done_when = ?, blocked_on = ? WHERE id = ?`,
|
||||
strings.TrimSpace(doneWhen), strings.TrimSpace(blockedOn), id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set task fields: %w", err)
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("set task fields: rows affected: %w", err)
|
||||
}
|
||||
if n == 0 {
|
||||
return fmt.Errorf("%w: id=%d", ErrTaskNotFound, id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// NormalizeTaskText is the dedupe key: lowercased, punctuation dropped,
|
||||
// whitespace collapsed. Exported because the intake seam (and its tests) needs
|
||||
// to reason about what will and will not be treated as the same task.
|
||||
@@ -488,7 +372,7 @@ func scanTask(sc scanner) (Task, error) {
|
||||
var t Task
|
||||
var created int64
|
||||
var due, resolved sql.NullInt64
|
||||
if err := sc.Scan(&t.ID, &created, &t.Text, &t.Source, &t.Evidence, &t.ExternalID, &t.Status, &due, &t.Weight, &resolved, &t.ResolvedBy, &t.DoneWhen, &t.BlockedOn); err != nil {
|
||||
if err := sc.Scan(&t.ID, &created, &t.Text, &t.Source, &t.Evidence, &t.ExternalID, &t.Status, &due, &t.Weight, &resolved, &t.ResolvedBy); err != nil {
|
||||
return Task{}, err
|
||||
}
|
||||
t.CreatedTs = time.UnixMilli(created).UTC()
|
||||
|
||||
@@ -126,11 +126,6 @@ func TestSetTaskStatusMovesOnceForwardOnly(t *testing.T) {
|
||||
if err := st.SetTaskStatus(ctx, cand, TaskDone, now, "tap:web"); !errors.Is(err, ErrTaskNotFound) {
|
||||
t.Errorf("candidate→done err = %v, want ErrTaskNotFound", err)
|
||||
}
|
||||
// Promotion needs a definition of done; see
|
||||
// TestPromotingACandidateNeedsADefinitionOfDone for that refusal.
|
||||
if err := st.SetTaskFields(ctx, cand, "запись есть", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.SetTaskStatus(ctx, cand, TaskOpen, now, "tap:web"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -250,10 +245,7 @@ func TestCaptureTaskExternalIDSurvivesResolution(t *testing.T) {
|
||||
if !first.Created {
|
||||
t.Fatal("first capture must create a row")
|
||||
}
|
||||
// He confirms it and does it. Confirming needs a criterion (Vikunja #510).
|
||||
if err := st.SetTaskFields(ctx, first.ID, "страховка продлена", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// He confirms it and does it.
|
||||
if err := st.SetTaskStatus(ctx, first.ID, TaskOpen, now.Add(time.Hour), "tap:web"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -382,163 +374,3 @@ func TestListTasksIsBounded(t *testing.T) {
|
||||
t.Errorf("first = %q, want the newest", all[0].Text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskBoardColumnsRoundTrip(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st := newTestStore(t)
|
||||
now := time.Date(2026, 8, 5, 9, 0, 0, 0, time.UTC)
|
||||
|
||||
res, err := st.CaptureTask(ctx, Task{
|
||||
Text: "оплатить интернет", Source: "tap:voice", CreatedTs: now,
|
||||
DoneWhen: "квитанция оплачена", BlockedOn: "ent_kate",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := st.LookupTask(ctx, res.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.DoneWhen != "квитанция оплачена" || got.BlockedOn != "ent_kate" {
|
||||
t.Fatalf("task = %+v, want both board columns back", got)
|
||||
}
|
||||
|
||||
// Not one-way, unlike a status move: he sharpens the criterion, and the
|
||||
// blocker clears when the person answers.
|
||||
if err := st.SetTaskFields(ctx, res.ID, " пришло подтверждение ", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err = st.LookupTask(ctx, res.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.DoneWhen != "пришло подтверждение" {
|
||||
t.Errorf("done_when = %q, want the trimmed rewrite", got.DoneWhen)
|
||||
}
|
||||
if got.BlockedOn != "" {
|
||||
t.Errorf("blocked_on = %q, want it cleared", got.BlockedOn)
|
||||
}
|
||||
if err := st.SetTaskFields(ctx, 9999, "x", ""); !errors.Is(err, ErrTaskNotFound) {
|
||||
t.Errorf("SetTaskFields on a missing row = %v, want ErrTaskNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromotingACandidateNeedsADefinitionOfDone(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st := newTestStore(t)
|
||||
now := time.Date(2026, 8, 5, 9, 0, 0, 0, time.UTC)
|
||||
|
||||
res, err := st.CaptureTask(ctx, Task{
|
||||
Text: "продлить домен", Source: "email:main", Status: TaskCandidate,
|
||||
ExternalID: "msg-1:0", Evidence: "Domain expiring", CreatedTs: now,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A board row whose finish line nobody wrote can never leave the board.
|
||||
if err := st.SetTaskStatus(ctx, res.ID, TaskOpen, now, "tap:web"); !errors.Is(err, ErrTaskNoDoneWhen) {
|
||||
t.Fatalf("promotion with no criterion = %v, want ErrTaskNoDoneWhen", err)
|
||||
}
|
||||
// Dropping it stays legal — declining work does not need one.
|
||||
dropped, err := st.CaptureTask(ctx, Task{
|
||||
Text: "перезвонить в банк", Source: "email:main", Status: TaskCandidate,
|
||||
ExternalID: "msg-2:0", CreatedTs: now,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.SetTaskStatus(ctx, dropped.ID, TaskDropped, now, "tap:web"); err != nil {
|
||||
t.Fatalf("dropping a candidate with no criterion: %v", err)
|
||||
}
|
||||
|
||||
if err := st.SetTaskFields(ctx, res.ID, "домен продлён до 2027", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.SetTaskStatus(ctx, res.ID, TaskOpen, now, "tap:web"); err != nil {
|
||||
t.Fatalf("promotion after writing a criterion: %v", err)
|
||||
}
|
||||
got, err := st.LookupTask(ctx, res.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Status != TaskOpen {
|
||||
t.Errorf("status = %q, want open", got.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditTaskRewritesTheCaptureFields(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st := newTestStore(t)
|
||||
now := time.Date(2026, 8, 5, 9, 0, 0, 0, time.UTC)
|
||||
due := now.Add(48 * time.Hour)
|
||||
|
||||
res, err := st.CaptureTask(ctx, Task{Text: "купить малако", Source: "tap:voice", CreatedTs: now, Weight: 1})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.EditTask(ctx, res.ID, " купить молоко ", &due, 3); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := st.LookupTask(ctx, res.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Text != "купить молоко" || got.Weight != 3 || got.Due == nil || !got.Due.Equal(due) {
|
||||
t.Fatalf("task = %+v, want the dictation typo fixed with the date and weight", got)
|
||||
}
|
||||
// Clearing the date has to be sayable, or an absent date and "remove the
|
||||
// date" would be one argument.
|
||||
if err := st.EditTask(ctx, res.ID, "купить молоко", nil, 3); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, err = st.LookupTask(ctx, res.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
} else if got.Due != nil {
|
||||
t.Errorf("due = %v, want it cleared", got.Due)
|
||||
}
|
||||
// The dedupe key moved with the text: capturing the old wording is new work.
|
||||
again, err := st.CaptureTask(ctx, Task{Text: "купить малако", Source: "tap:voice", CreatedTs: now})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !again.Created {
|
||||
t.Error("the old normalised text must be free after the edit")
|
||||
}
|
||||
if err := st.EditTask(ctx, res.ID, "", nil, 0); !errors.Is(err, ErrTaskEmpty) {
|
||||
t.Errorf("empty text = %v, want ErrTaskEmpty", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditTaskRefusesACollisionAndAResolvedRow(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st := newTestStore(t)
|
||||
now := time.Date(2026, 8, 5, 9, 0, 0, 0, time.UTC)
|
||||
|
||||
first, err := st.CaptureTask(ctx, Task{Text: "купить молоко", Source: "tap:voice", CreatedTs: now})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := st.CaptureTask(ctx, Task{Text: "оплатить интернет", Source: "tap:voice", CreatedTs: now})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Two live rows carry two provenances and two capture times, so a merge
|
||||
// would pick a winner for both with nobody asked.
|
||||
if err := st.EditTask(ctx, second.ID, "Купить молоко!", nil, 0); !errors.Is(err, ErrTaskDuplicate) {
|
||||
t.Errorf("collision = %v, want ErrTaskDuplicate", err)
|
||||
}
|
||||
// Editing a row to the text it already has is not a collision with itself.
|
||||
if err := st.EditTask(ctx, first.ID, "купить молоко", nil, 2); err != nil {
|
||||
t.Errorf("re-saving the same text: %v", err)
|
||||
}
|
||||
if err := st.SetTaskStatus(ctx, first.ID, TaskDone, now.Add(time.Hour), "tap:web"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A resolved task's text is the record of what was finished.
|
||||
if err := st.EditTask(ctx, first.ID, "купить кефир", nil, 0); !errors.Is(err, ErrTaskResolved) {
|
||||
t.Errorf("editing a resolved task = %v, want ErrTaskResolved", err)
|
||||
}
|
||||
if err := st.EditTask(ctx, 9999, "что-то", nil, 0); !errors.Is(err, ErrTaskNotFound) {
|
||||
t.Errorf("editing a missing row = %v, want ErrTaskNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// WipeCount is one table and the number of rows a wipe would remove from it.
|
||||
type WipeCount struct {
|
||||
Table string
|
||||
Rows int
|
||||
}
|
||||
|
||||
// WipeCounts reports every table in the database and its row count, so a caller
|
||||
// can show what a wipe is about to destroy before it destroys it. Tables with
|
||||
// no rows are included: a QA reader needs to see that the table was considered,
|
||||
// not guess whether it was missed.
|
||||
func (s *Store) WipeCounts(ctx context.Context) ([]WipeCount, error) {
|
||||
names, err := userTables(ctx, s.db)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]WipeCount, 0, len(names))
|
||||
for _, name := range names {
|
||||
var n int
|
||||
// name comes from sqlite_master, not from a caller, and is quoted.
|
||||
q := fmt.Sprintf(`SELECT count(*) FROM %s`, quoteIdent(name))
|
||||
if err := s.db.QueryRowContext(ctx, q).Scan(&n); err != nil {
|
||||
return nil, fmt.Errorf("count %s: %w", name, err)
|
||||
}
|
||||
out = append(out, WipeCount{Table: name, Rows: n})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Wipe removes every piece of personal data and leaves the install standing.
|
||||
//
|
||||
// It drops every object in the database and rebuilds the schema from
|
||||
// schema.sql plus the migration list, rather than deleting from a hand-written
|
||||
// list of tables. That is the whole point: a list has to be edited every time a
|
||||
// table is added, and the one time it is not, a wipe leaves personal data
|
||||
// behind while reporting success. Dropping what the database says exists cannot
|
||||
// miss a table (Vikunja #494).
|
||||
//
|
||||
// Nothing outside the database is touched. Config, models, the WebAuthn
|
||||
// credentials in passkeys.json and the encryption key all live in files, so
|
||||
// this leaves a working install with no memory of anyone.
|
||||
//
|
||||
// The caller is responsible for the daemon being the only thing holding the
|
||||
// store, and for asking whoever ran it whether they meant it.
|
||||
func (s *Store) Wipe(ctx context.Context) error {
|
||||
names, err := userTables(ctx, s.db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Foreign keys are on per connection. Dropping tables in an arbitrary order
|
||||
// trips them, and the drop order does not matter once everything goes.
|
||||
if _, err := s.db.ExecContext(ctx, `PRAGMA foreign_keys = OFF`); err != nil {
|
||||
return fmt.Errorf("wipe: disable foreign keys: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if _, err := s.db.ExecContext(ctx, `PRAGMA foreign_keys = ON`); err != nil {
|
||||
// The handle is about to be closed by the caller either way.
|
||||
_ = err
|
||||
}
|
||||
}()
|
||||
|
||||
var drops strings.Builder
|
||||
for _, name := range names {
|
||||
fmt.Fprintf(&drops, "DROP TABLE IF EXISTS %s;\n", quoteIdent(name))
|
||||
}
|
||||
// user_version back to zero, or migrate() applies nothing to the fresh
|
||||
// schema and every ALTER that a later migration assumes is missing.
|
||||
drops.WriteString("PRAGMA user_version = 0;\n")
|
||||
if _, err := s.db.ExecContext(ctx, drops.String()); err != nil {
|
||||
return fmt.Errorf("wipe: drop tables: %w", err)
|
||||
}
|
||||
if _, err := s.db.ExecContext(ctx, schemaSQL); err != nil {
|
||||
return fmt.Errorf("wipe: reapply schema: %w", err)
|
||||
}
|
||||
if err := migrate(ctx, s.db); err != nil {
|
||||
return fmt.Errorf("wipe: re-migrate: %w", err)
|
||||
}
|
||||
// The file keeps the pages the dropped rows used until it is vacuumed, and
|
||||
// a wipe that leaves readable text in free pages is not a wipe.
|
||||
if _, err := s.db.ExecContext(ctx, `VACUUM`); err != nil {
|
||||
return fmt.Errorf("wipe: vacuum: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// userTables lists every table the schema owns, sorted, excluding sqlite's own
|
||||
// internal tables (sqlite_sequence and friends, which cannot be dropped).
|
||||
func userTables(ctx context.Context, db *sql.DB) ([]string, error) {
|
||||
rows, err := db.QueryContext(ctx,
|
||||
`SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list tables: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var names []string
|
||||
for rows.Next() {
|
||||
var name string
|
||||
if err := rows.Scan(&name); err != nil {
|
||||
return nil, fmt.Errorf("list tables: %w", err)
|
||||
}
|
||||
names = append(names, name)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("list tables: %w", err)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return names, nil
|
||||
}
|
||||
|
||||
func quoteIdent(name string) string {
|
||||
return `"` + strings.ReplaceAll(name, `"`, `""`) + `"`
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// A wipe has to leave a working, empty install: every table still there, every
|
||||
// migration still applied, and not one row of his anywhere (Vikunja #494).
|
||||
func TestWipeEmptiesEveryTableAndLeavesTheSchemaUsable(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
path := filepath.Join(t.TempDir(), "wipe.db")
|
||||
s, err := Open(ctx, path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.Close()
|
||||
|
||||
now := time.Now()
|
||||
if _, err := s.WriteFact(ctx, now, KindSelf, "monitor", "новый", "tap:test", 1, sql.NullInt64{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.WriteNote(ctx, now, "купил монитор", []float32{0.1, 0.2}, "tap:test"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.CaptureTask(ctx, Task{Text: "вернуть монитор", Source: "tap:test", Status: TaskOpen, CreatedTs: now}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
before, err := s.WipeCounts(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
populated := 0
|
||||
for _, c := range before {
|
||||
if c.Rows > 0 {
|
||||
populated++
|
||||
}
|
||||
}
|
||||
if populated == 0 {
|
||||
t.Fatal("nothing was written, so the wipe proves nothing")
|
||||
}
|
||||
|
||||
if err := s.Wipe(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
after, err := s.WipeCounts(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(after) != len(before) {
|
||||
t.Errorf("table count changed across the wipe: %d before, %d after", len(before), len(after))
|
||||
}
|
||||
for _, c := range after {
|
||||
if c.Rows != 0 {
|
||||
t.Errorf("table %s still holds %d rows after the wipe", c.Table, c.Rows)
|
||||
}
|
||||
}
|
||||
|
||||
// The migrations are what make the schema usable, so the check that matters
|
||||
// is a write through the newest columns, not a version number.
|
||||
if _, err := s.CaptureTask(ctx, Task{Text: "новая задача", Source: "tap:test", Status: TaskOpen, CreatedTs: now}); err != nil {
|
||||
t.Errorf("the store is not usable after a wipe: %v", err)
|
||||
}
|
||||
if _, err := s.WriteFact(ctx, now, KindSelf, "monitor", "другой", "tap:test", 1, sql.NullInt64{}); err != nil {
|
||||
t.Errorf("the store is not usable after a wipe: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A wipe that reports success while leaving a table behind is the failure the
|
||||
// drop-everything approach exists to prevent, so the count has to see the
|
||||
// tables migrations added, not only the ones schema.sql declares.
|
||||
func TestWipeCountsSeeMigratedTables(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, err := Open(ctx, filepath.Join(t.TempDir(), "counts.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.Close()
|
||||
|
||||
counts, err := s.WipeCounts(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, c := range counts {
|
||||
seen[c.Table] = true
|
||||
}
|
||||
// One from schema.sql, three from migrations, one from a table rebuild.
|
||||
for _, want := range []string{"facts", "memory_vectors", "tasks", "ecosystem_traces", "nudges"} {
|
||||
if !seen[want] {
|
||||
t.Errorf("wipe does not see table %s", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
package tasks
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/say"
|
||||
)
|
||||
|
||||
// Counted stall shapes (Vikunja #512, step 5 of docs/plans/15-board-surface.md).
|
||||
//
|
||||
// She may count. She may not assess. Every shape here is arithmetic over rows he
|
||||
// can see — how many, how long, how many undated — and none of it says whether a
|
||||
// task matters, whether a blocker is real, or whether something should be
|
||||
// dropped. That is the line internal/memory/behavior.go already drew for habits
|
||||
// and the reason is the same: a 1.7B asked to judge will agree fluently and
|
||||
// launder a guess into a decision.
|
||||
//
|
||||
// Not a nag either. Nothing here is read by the tick loop; the counts go on
|
||||
// /tasks and into the answer when he asks for the list. tickLoop.dayPlan
|
||||
// deliberately does not read tasks — keep it that way.
|
||||
|
||||
// StallDays — how long a live task has to have sat before it is counted as
|
||||
// sitting. Ten days rather than a week, because a task captured on a Friday and
|
||||
// still open the next Friday is an ordinary week, not a stall.
|
||||
//
|
||||
// Measured from Created, which is the only clock a live row carries: the store
|
||||
// stamps resolved_ts and nothing else, so "no state change in eleven days" is
|
||||
// exactly "captured eleven days ago and still live". That is a narrower claim
|
||||
// than the plan's wording and it is the one the data supports.
|
||||
const StallDays = 10
|
||||
|
||||
// Stall — one counted shape: how many tasks, and the sentence that says what
|
||||
// they have in common. N is always ≥ 1; a shape with no tasks in it is not
|
||||
// returned, because "нет просроченных" is a reassurance nobody asked for.
|
||||
type Stall struct {
|
||||
N int
|
||||
Line string
|
||||
}
|
||||
|
||||
// Stalls counts the shapes present in a set of live tasks, in a fixed order:
|
||||
// overdue first, then sitting, then unconfirmed. Fixed because the order is what
|
||||
// he reads first, and sorting by count would move the sections around every time
|
||||
// one number changed.
|
||||
//
|
||||
// Resolved tasks are not passed in and would not be counted if they were: this
|
||||
// is a statement about outstanding work.
|
||||
func Stalls(items []Item, now time.Time) []Stall {
|
||||
var overdue, sitting, unconfirmed int
|
||||
for _, it := range items {
|
||||
if it.Status == StatusCandidate {
|
||||
unconfirmed++
|
||||
// A candidate is Maven's reading of something she read. Counting it as
|
||||
// overdue would put her own guess about a deadline in a number he is
|
||||
// meant to act on.
|
||||
continue
|
||||
}
|
||||
if it.Due != nil && it.Due.Before(now) {
|
||||
overdue++
|
||||
}
|
||||
if now.Sub(it.Created) >= StallDays*24*time.Hour {
|
||||
sitting++
|
||||
}
|
||||
}
|
||||
var out []Stall
|
||||
add := func(n int, key string, args map[string]string) {
|
||||
if n == 0 {
|
||||
return
|
||||
}
|
||||
out = append(out, Stall{N: n, Line: say.S(key, args)})
|
||||
}
|
||||
add(overdue, say.StallOverdue, map[string]string{
|
||||
"n": fmt.Sprint(overdue), "word": say.CountWord(overdue, "задача", "задачи", "задач"),
|
||||
})
|
||||
add(sitting, say.StallSitting, map[string]string{
|
||||
"n": fmt.Sprint(sitting), "word": say.CountWord(sitting, "задача", "задачи", "задач"),
|
||||
"days": fmt.Sprint(StallDays), "dayword": say.Days(StallDays),
|
||||
})
|
||||
add(unconfirmed, say.StallUnconfirmed, map[string]string{
|
||||
"n": fmt.Sprint(unconfirmed), "word": say.CountWord(unconfirmed, "задача", "задачи", "задач"),
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// StallsRU joins the shapes into one sentence for the spoken list, or "" when
|
||||
// there are none. Empty on purpose: the list read is the answer to his question,
|
||||
// and appending "ничего не залежалось" to it every time is a nag with a friendly
|
||||
// face.
|
||||
func StallsRU(stalls []Stall) string {
|
||||
if len(stalls) == 0 {
|
||||
return ""
|
||||
}
|
||||
parts := make([]string, 0, len(stalls))
|
||||
for _, s := range stalls {
|
||||
parts = append(parts, s.Line)
|
||||
}
|
||||
return strings.Join(parts, "; ") + "."
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
package tasks
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func stallNow() time.Time { return time.Date(2026, 8, 5, 12, 0, 0, 0, time.UTC) }
|
||||
|
||||
func TestStallsCountsTheThreeShapes(t *testing.T) {
|
||||
now := stallNow()
|
||||
day := 24 * time.Hour
|
||||
yesterday := now.Add(-day)
|
||||
items := []Item{
|
||||
// Overdue and sitting at once: it counts in both, because they are two
|
||||
// different things wrong with one task.
|
||||
{ID: 1, Text: "оплатить интернет", Status: StatusOpen, Created: now.Add(-20 * day), Due: &yesterday},
|
||||
{ID: 2, Text: "купить молоко", Status: StatusOpen, Created: now.Add(-12 * day)},
|
||||
{ID: 3, Text: "позвонить маме", Status: StatusOpen, Created: now.Add(-time.Hour)},
|
||||
{ID: 4, Text: "продлить домен", Status: StatusCandidate, Created: now.Add(-30 * day), Due: &yesterday},
|
||||
}
|
||||
got := Stalls(items, now)
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("shapes = %+v, want overdue, sitting, unconfirmed", got)
|
||||
}
|
||||
if got[0].N != 1 {
|
||||
t.Errorf("overdue = %d, want 1 — a candidate's due date is Maven's reading of a mail", got[0].N)
|
||||
}
|
||||
if got[1].N != 2 {
|
||||
t.Errorf("sitting = %d, want 2", got[1].N)
|
||||
}
|
||||
if got[2].N != 1 {
|
||||
t.Errorf("unconfirmed = %d, want 1", got[2].N)
|
||||
}
|
||||
line := StallsRU(got)
|
||||
for _, want := range []string{"просрочено", "лежит", "подтверждения"} {
|
||||
if !strings.Contains(line, want) {
|
||||
t.Errorf("StallsRU = %q, want it to mention %q", line, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStallsSaysNothingWhenThereIsNothing(t *testing.T) {
|
||||
now := stallNow()
|
||||
items := []Item{{ID: 1, Text: "купить молоко", Status: StatusOpen, Created: now.Add(-time.Hour)}}
|
||||
if got := Stalls(items, now); len(got) != 0 {
|
||||
t.Fatalf("shapes = %+v, want none", got)
|
||||
}
|
||||
// "ничего не залежалось" appended to every list read is a nag with a
|
||||
// friendly face, so the empty case renders as nothing at all.
|
||||
if got := StallsRU(nil); got != "" {
|
||||
t.Errorf("StallsRU(nil) = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStallsCountsNoJudgement(t *testing.T) {
|
||||
// The line this shape may not cross. Every sentence states a count; none of
|
||||
// them says whether the work matters or should be dropped.
|
||||
now := stallNow()
|
||||
items := []Item{{ID: 1, Text: "оплатить интернет", Status: StatusOpen, Created: now.Add(-40 * 24 * time.Hour)}}
|
||||
line := StallsRU(Stalls(items, now))
|
||||
for _, banned := range []string{"стоит", "лучше", "надо", "брось", "важно"} {
|
||||
if strings.Contains(line, banned) {
|
||||
t.Errorf("StallsRU = %q — %q is an assessment, and counting is the whole licence here", line, banned)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user