Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0793955896 | |||
| 71a9a59403 | |||
| d3c63e6493 | |||
| c586346a60 | |||
| 23d89b2831 | |||
| 06ddf41228 | |||
| 2e64c8ce94 | |||
| 7695620a96 | |||
| 758fb6a3f0 | |||
| 0e75245205 | |||
| 8d816f47e9 | |||
| 4425ba112b | |||
| a4d5155029 | |||
| c62c7034fa | |||
| 58b546a27e | |||
| 997f92f5c4 | |||
| d9ef9ecef2 | |||
| be3e5cea25 |
@@ -231,8 +231,10 @@ fact or a route is the defect; a regex over structured input — HTML, MIME, JSO
|
|||||||
argv list — is not. Before writing a Russian word list, pick one of these:
|
argv list — is not. Before writing a Russian word list, pick one of these:
|
||||||
|
|
||||||
- **`internal/lexicon`** — closed classes, in `lexicon_ru_v1.json`. Interrogatives,
|
- **`internal/lexicon`** — closed classes, in `lexicon_ru_v1.json`. Interrogatives,
|
||||||
capture verbs, cardinals, day offsets, weekdays, months, spoken hours. Editing a word is
|
capture verbs, reminder verbs, cardinals, day offsets, parts of day, weekdays, months,
|
||||||
a data change, and there is exactly one copy: months used to live in three files.
|
spoken hours. Editing a word is a data change, and there is exactly one copy: months used
|
||||||
|
to live in three files. Cardinals carry the oblique forms, because a spoken time declines
|
||||||
|
and `в семь` / `к семи` are one hour.
|
||||||
- **`internal/morph`** — grammar, from the vendored golem Russian dictionary. `IsVerbForm`
|
- **`internal/morph`** — grammar, from the vendored golem Russian dictionary. `IsVerbForm`
|
||||||
and `SameWord`. Note that lemma matching is BROADER than stem-plus-one-ending, so a verb
|
and `SameWord`. Note that lemma matching is BROADER than stem-plus-one-ending, so a verb
|
||||||
slot that means the imperative must be matched exactly — `говори` and `говорил` are one
|
slot that means the imperative must be matched exactly — `говори` and `говорил` are one
|
||||||
|
|||||||
+76
-13
@@ -6,6 +6,8 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/morph"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Command history — "что я тебе говорил?", "что ты записала сегодня?"
|
// Command history — "что я тебе говорил?", "что ты записала сегодня?"
|
||||||
@@ -15,18 +17,34 @@ import (
|
|||||||
// storage: everything he tapped in is already a row with a source and a
|
// storage: everything he tapped in is already a row with a source and a
|
||||||
// timestamp, and this only reads them back.
|
// timestamp, and this only reads them back.
|
||||||
|
|
||||||
// historyMarkers — the ways he asks what he told her. Each entry is a pair of
|
// A history question needs three things in one utterance: the interrogative,
|
||||||
// substrings that must BOTH appear, because either half alone is a different
|
// whose turn is being asked about, and a verb of saying or recording. Any two of
|
||||||
// question: "что я говорил про сервер" is a recall question the notes pass
|
// them are a different question. "что я говорил про сервер" names a topic and
|
||||||
// answers better, and "что ты записала" with no "что" is not a question at all.
|
// the notes pass answers it better; "записал молоко" is a capture.
|
||||||
var historyMarkers = [][2]string{
|
//
|
||||||
{"что я", "говорил"},
|
// The verbs are matched by lemma through internal/morph, not by a truncated
|
||||||
{"что я", "сказал"},
|
// prefix (Vikunja #530). The pairs here used to hold "рассказ" and "записал",
|
||||||
{"что я", "рассказ"},
|
// which is the defect V-528 fixed in complaint.go: "рассказ" is also the noun,
|
||||||
{"что ты", "записал"},
|
// so "что я рассказал ей" and "что я читал рассказ" were the same string test.
|
||||||
{"что ты", "запомнил"},
|
// Aspect pairs are separate lemmas in the dictionary, so both members are listed.
|
||||||
{"что я", "отмечал"},
|
var (
|
||||||
{"что я", "отметил"},
|
// historySpokenVerbs — what HE did. "что я тебе говорил".
|
||||||
|
historySpokenVerbs = []string{"говорить", "сказать", "рассказать", "рассказывать", "отметить", "отмечать"}
|
||||||
|
|
||||||
|
// historyRecordedVerbs — what SHE did with it. "что ты записала сегодня".
|
||||||
|
historyRecordedVerbs = []string{"записать", "запомнить", "отметить", "отмечать"}
|
||||||
|
|
||||||
|
// firstPersonSubjects and secondPersonSubjects — whose turn the question is
|
||||||
|
// about. Only the subject forms: "что я тебе говорил" is his turn, and the
|
||||||
|
// dative "тебе" in it is not the subject.
|
||||||
|
firstPersonSubjects = []string{"я"}
|
||||||
|
secondPersonSubjects = []string{"ты"}
|
||||||
|
)
|
||||||
|
|
||||||
|
// historyMarkersEn — the English pairs, kept as substrings because the
|
||||||
|
// dictionary is Russian. Each half alone is a different question, the same way
|
||||||
|
// the Russian test needs all three parts.
|
||||||
|
var historyMarkersEn = [][2]string{
|
||||||
{"what did i", "tell"},
|
{"what did i", "tell"},
|
||||||
{"what did you", "record"},
|
{"what did you", "record"},
|
||||||
}
|
}
|
||||||
@@ -47,11 +65,56 @@ func isHistoryQuery(u string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, pair := range historyMarkers {
|
for _, pair := range historyMarkersEn {
|
||||||
if strings.Contains(s, pair[0]) && strings.Contains(s, pair[1]) {
|
if strings.Contains(s, pair[0]) && strings.Contains(s, pair[1]) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
toks := historyTokens(s)
|
||||||
|
if !hasAny(toks, "что", "чего") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if hasAny(toks, firstPersonSubjects...) && hasVerbForm(toks, historySpokenVerbs) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return hasAny(toks, secondPersonSubjects...) && hasVerbForm(toks, historyRecordedVerbs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// historyTokens splits an utterance into bare words. The punctuation goes
|
||||||
|
// because "говорил?" is the same word as "говорил".
|
||||||
|
func historyTokens(s string) []string {
|
||||||
|
toks := strings.Fields(s)
|
||||||
|
out := make([]string, 0, len(toks))
|
||||||
|
for _, t := range toks {
|
||||||
|
if t = strings.Trim(t, ".,!?;:—–-()\"'«»"); t != "" {
|
||||||
|
out = append(out, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasAny(toks []string, want ...string) bool {
|
||||||
|
for _, t := range toks {
|
||||||
|
for _, w := range want {
|
||||||
|
if t == w {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasVerbForm reports whether any token is a form of any of the lemmas. Both
|
||||||
|
// sides go through the dictionary, so a caller may name the infinitive and he
|
||||||
|
// may say the past tense.
|
||||||
|
func hasVerbForm(toks []string, lemmas []string) bool {
|
||||||
|
for _, t := range toks {
|
||||||
|
for _, l := range lemmas {
|
||||||
|
if morph.SameWord(t, l) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,17 @@ func TestIsHistoryQuery(t *testing.T) {
|
|||||||
{"что я тебе говорил?", true},
|
{"что я тебе говорил?", true},
|
||||||
{"что ты записала сегодня?", true},
|
{"что ты записала сегодня?", true},
|
||||||
{"что я отмечал?", true},
|
{"что я отмечал?", true},
|
||||||
|
// Forms the truncated prefixes did not reach. The dictionary answers
|
||||||
|
// these because it lemmatises both sides (V-530).
|
||||||
|
{"что я тебе рассказывал?", true},
|
||||||
|
{"что я сказала вчера", true},
|
||||||
|
{"что ты запомнила?", true},
|
||||||
|
// The noun, not the verb. "рассказ" was a prefix of the old pair, so
|
||||||
|
// this read as a history question — the same defect V-528 fixed in
|
||||||
|
// complaint.go, where "лаг" matched "лагерь".
|
||||||
|
{"что я читал рассказ", false},
|
||||||
|
// A verb of saying with nobody saying it.
|
||||||
|
{"что записать?", false},
|
||||||
// A named topic is a recall question, and the notes pass answers it
|
// A named topic is a recall question, and the notes pass answers it
|
||||||
// better than a list of the last five facts does.
|
// better than a list of the last five facts does.
|
||||||
{"что я говорил про сервер?", false},
|
{"что я говорил про сервер?", false},
|
||||||
|
|||||||
@@ -127,8 +127,10 @@ func run(args []string) error {
|
|||||||
cfgPath := flag.String("config", defaultConfigPath(), "path to mavend JSON config")
|
cfgPath := flag.String("config", defaultConfigPath(), "path to mavend JSON config")
|
||||||
wrappedKeyPath := flag.String("wrapped-key-file", "", "path to wrapped encryption key blob (enables cold-start unlock)")
|
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)")
|
reembed := flag.Bool("reembed", false, "re-embed every stored note and fact with the configured embedder, then serve normally (run once after an embedder swap; the daemon does not answer until it finishes)")
|
||||||
|
allowSeed := flag.Bool("allow-seed", false, "enable the backdated seed_event write path (QA only: it lets a caller place a fact in the past and mint a routine the tick loop will then act on; off means the method has nothing to write with)")
|
||||||
flag.CommandLine.Parse(args)
|
flag.CommandLine.Parse(args)
|
||||||
reembedOnStart = *reembed
|
reembedOnStart = *reembed
|
||||||
|
allowSeedOnStart = *allowSeed
|
||||||
cfg, err := config.Load(*cfgPath)
|
cfg, err := config.Load(*cfgPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -338,6 +340,7 @@ func run(args []string) error {
|
|||||||
getMorningStatus: func(ctx context.Context) []ipc.MorningRoutineStatus { return tl.morningStatus(ctx, time.Now()) },
|
getMorningStatus: func(ctx context.Context) []ipc.MorningRoutineStatus { return tl.morningStatus(ctx, time.Now()) },
|
||||||
getDayPlan: func(ctx context.Context) ipc.DayPlan { return tl.dayPlan(ctx, time.Now()) },
|
getDayPlan: func(ctx context.Context) ipc.DayPlan { return tl.dayPlan(ctx, time.Now()) },
|
||||||
getEvents: intakeEventsFn(evBus),
|
getEvents: intakeEventsFn(evBus),
|
||||||
|
seedStore: seedStoreIfAllowed(st),
|
||||||
}
|
}
|
||||||
if voiceW != nil && voiceW.handler != nil {
|
if voiceW != nil && voiceW.handler != nil {
|
||||||
api := coreAPI.(*daemonAPI)
|
api := coreAPI.(*daemonAPI)
|
||||||
@@ -605,6 +608,7 @@ func run(args []string) error {
|
|||||||
getMorningStatus: func(ctx context.Context) []ipc.MorningRoutineStatus { return tl.morningStatus(ctx, time.Now()) },
|
getMorningStatus: func(ctx context.Context) []ipc.MorningRoutineStatus { return tl.morningStatus(ctx, time.Now()) },
|
||||||
getDayPlan: func(ctx context.Context) ipc.DayPlan { return tl.dayPlan(ctx, time.Now()) },
|
getDayPlan: func(ctx context.Context) ipc.DayPlan { return tl.dayPlan(ctx, time.Now()) },
|
||||||
getEvents: intakeEventsFn(evBus),
|
getEvents: intakeEventsFn(evBus),
|
||||||
|
seedStore: seedStoreIfAllowed(st),
|
||||||
}
|
}
|
||||||
if voiceW != nil && voiceW.handler != nil {
|
if voiceW != nil && voiceW.handler != nil {
|
||||||
newAPI.chatFn = voiceW.handler.handleText
|
newAPI.chatFn = voiceW.handler.handleText
|
||||||
|
|||||||
@@ -2,12 +2,20 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/lexicon"
|
||||||
)
|
)
|
||||||
|
|
||||||
// reminderMarker — the words that open a reminder. Stripped because they are
|
// reminderMarker — the words that open a reminder. Stripped because they are
|
||||||
// the instruction, not the thing to say at the hour.
|
// the instruction, not the thing to say at the hour.
|
||||||
var reminderMarker = regexp.MustCompile(`(?i)^\s*(?:напомни(?:те)?|напомнить|remind)\s*(?:мне|me)?[\s,:—-]*`)
|
//
|
||||||
|
// The verbs come from the lexicon (Vikunja #530). They are a closed set of the
|
||||||
|
// commands she answers to, exactly like capture_verbs, and the literal that
|
||||||
|
// stood here knew four of them.
|
||||||
|
var reminderMarker = regexp.MustCompile(`(?i)^\s*(?:` + alternation(lexicon.ReminderVerbs()) +
|
||||||
|
`)\s*(?:мне|me)?[\s,:—-]*`)
|
||||||
|
|
||||||
// reminderTimeWords — the time expressions a reminder carries, removed from
|
// reminderTimeWords — the time expressions a reminder carries, removed from
|
||||||
// the body because the fire time is already a column. Ordered longest-first
|
// the body because the fire time is already a column. Ordered longest-first
|
||||||
@@ -17,12 +25,36 @@ var reminderMarker = regexp.MustCompile(`(?i)^\s*(?:напомни(?:те)?|на
|
|||||||
// Go's \b is ASCII-only and never fires next to a Cyrillic letter, so the word
|
// Go's \b is ASCII-only and never fires next to a Cyrillic letter, so the word
|
||||||
// boundaries here are written out as whitespace or an end of string — the same
|
// boundaries here are written out as whitespace or an end of string — the same
|
||||||
// trap the agenda grammars hit.
|
// trap the agenda grammars hit.
|
||||||
|
//
|
||||||
|
// The Russian word lists are gone (Vikunja #530). The day words are
|
||||||
|
// lexicon.DayOffsetWords, which is why "вчера" and "позавчера" are stripped now
|
||||||
|
// and were not before, and the times of day are lexicon.PartsOfDay. What is
|
||||||
|
// still written out here is the shape of a clock reading — a preposition, digits,
|
||||||
|
// a colon — which is structured input rather than a claim about Russian.
|
||||||
var reminderTimeWords = []*regexp.Regexp{
|
var reminderTimeWords = []*regexp.Regexp{
|
||||||
regexp.MustCompile(`(?i)(^|\s)через\s+\S+(\s+(часа?|часов|минут[уы]?|секунд[уы]?|дня|дней|недел[юи]))?(\s|$)`),
|
regexp.MustCompile(`(?i)(^|\s)через\s+\S+(\s+(часа?|часов|минут[уы]?|секунд[уы]?|дня|дней|недел[юи]))?(\s|$)`),
|
||||||
regexp.MustCompile(`(?i)(^|\s)(в|во)\s+\d{1,2}(:\d{2})?(\s*(часа?|часов))?(\s*(утра|вечера|дня|ночи))?(\s|$)`),
|
regexp.MustCompile(`(?i)(^|\s)(в|во)\s+\d{1,2}(:\d{2})?(\s*(часа?|часов))?(\s*(утра|вечера|дня|ночи))?(\s|$)`),
|
||||||
regexp.MustCompile(`(?i)(^|\s)(завтра|послезавтра|сегодня|вечером|утром|днём|днем|ночью)(\s|$)`),
|
regexp.MustCompile(`(?i)(^|\s)(` + alternation(lexicon.DayOffsetWords()) + `)(\s|$)`),
|
||||||
|
regexp.MustCompile(`(?i)(^|\s)(` + alternation(lexicon.PartsOfDay()) + `)(\s|$)`),
|
||||||
regexp.MustCompile(`(?i)(^|\s)(at|in)\s+\d{1,2}(:\d{2})?\s*(am|pm)?(\s|$)`),
|
regexp.MustCompile(`(?i)(^|\s)(at|in)\s+\d{1,2}(:\d{2})?\s*(am|pm)?(\s|$)`),
|
||||||
regexp.MustCompile(`(?i)(^|\s)(tomorrow|today|tonight)(\s|$)`),
|
}
|
||||||
|
|
||||||
|
// alternation folds a lexicon set into one regexp branch, longest member first
|
||||||
|
// so "послезавтра" is not matched as "завтра" with a tail left behind. Sorted
|
||||||
|
// rather than taken as given, because two members of equal length must still
|
||||||
|
// produce the same pattern on every build.
|
||||||
|
func alternation(set []string) string {
|
||||||
|
out := make([]string, 0, len(set))
|
||||||
|
for _, w := range set {
|
||||||
|
out = append(out, regexp.QuoteMeta(w))
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool {
|
||||||
|
if len(out[i]) != len(out[j]) {
|
||||||
|
return len(out[i]) > len(out[j])
|
||||||
|
}
|
||||||
|
return out[i] < out[j]
|
||||||
|
})
|
||||||
|
return strings.Join(out, "|")
|
||||||
}
|
}
|
||||||
|
|
||||||
// reminderBody is what she says at the hour.
|
// reminderBody is what she says at the hour.
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
// mavend/seed.go — the backdated-fact seam (Vikunja #518).
|
||||||
|
//
|
||||||
|
// The pattern detector needs four events for one action+object, spread by at
|
||||||
|
// least pattern.MinIntervalDays, before it proposes a routine. Nothing could
|
||||||
|
// produce that against a running daemon in one sitting: the only writer is a
|
||||||
|
// fact write at time.Now(), so V-43, V-46, V-247 and V-254 all stopped at the
|
||||||
|
// same missing step and had been stopped there since they were filed.
|
||||||
|
//
|
||||||
|
// This is the write path that unblocks them, and it is deliberately the narrow
|
||||||
|
// one. It takes a fact, not an event, so pattern.Extract runs for real and a
|
||||||
|
// key the extractor ignores seeds nothing. It runs detectAndPropose, so what a
|
||||||
|
// seed proves is the daemon's own wiring rather than the detector in isolation
|
||||||
|
// — which is what an eval-lab fixture would have proved, and is not what those
|
||||||
|
// four tasks doubt.
|
||||||
|
//
|
||||||
|
// It is off unless mavend was started with -allow-seed, and AuthStepUp in the
|
||||||
|
// authority table besides. See ipc.SeedEventReq and auth.Requirement.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/ipc"
|
||||||
|
"github.com/kami/maven/internal/pattern"
|
||||||
|
"github.com/kami/maven/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// errSeedDisabled — what a caller gets on an ordinary box. Named rather than
|
||||||
|
// inline so the mavweb route can tell "not allowed here" apart from "the seed
|
||||||
|
// ran and the extractor declined", which look the same to a reader otherwise.
|
||||||
|
var errSeedDisabled = errors.New("mavend: seeding is off (start with -allow-seed)")
|
||||||
|
|
||||||
|
// seedSource — every seeded fact carries this, and no other writer uses it.
|
||||||
|
// The point is that seeded data stays identifiable forever: a fact that came
|
||||||
|
// from a QA sitting must never be mistaken for something he said, either by a
|
||||||
|
// person reading /history or by the wipe in V-494 when it lands.
|
||||||
|
const seedSource = "seed:qa"
|
||||||
|
|
||||||
|
// seedStoreIfAllowed returns st only when -allow-seed was passed, and logs the
|
||||||
|
// fact loudly when it does. A box that can rewrite its own past should say so
|
||||||
|
// in its boot log, so nobody reads a seeded routine months later as evidence of
|
||||||
|
// something he actually did.
|
||||||
|
func seedStoreIfAllowed(st *store.Store) *store.Store {
|
||||||
|
if !allowSeedOnStart {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
log.Printf("seed: -allow-seed is ON — backdated fact writes are permitted under source %q (Vikunja #518)", seedSource)
|
||||||
|
return st
|
||||||
|
}
|
||||||
|
|
||||||
|
// SeedEvent writes the fact at the caller's timestamp, extracts an event from
|
||||||
|
// it, and runs the same detect-and-propose step the voice path runs.
|
||||||
|
//
|
||||||
|
// Best-effort is NOT the shape here, unlike detectPattern: a seed that half
|
||||||
|
// worked is a QA result nobody can trust, so every step reports its own
|
||||||
|
// failure. Extraction declining is not a failure — it is the extractor's
|
||||||
|
// documented answer for a value outside its lexicon, and Extracted says so.
|
||||||
|
func (d *daemonAPI) SeedEvent(ctx context.Context, req ipc.SeedEventReq) (ipc.SeedEventResp, error) {
|
||||||
|
if d.seedStore == nil {
|
||||||
|
return ipc.SeedEventResp{}, errSeedDisabled
|
||||||
|
}
|
||||||
|
if req.Key == "" || req.Value == "" {
|
||||||
|
return ipc.SeedEventResp{}, errors.New("mavend: seed needs a key and a value")
|
||||||
|
}
|
||||||
|
if req.Ts.IsZero() {
|
||||||
|
return ipc.SeedEventResp{}, errors.New("mavend: seed needs an explicit timestamp")
|
||||||
|
}
|
||||||
|
|
||||||
|
// No Subject, unlike the voice path: a seeded key must not queue a Nexus
|
||||||
|
// resolution. QA data has no business reaching the ecosystem.
|
||||||
|
factID, err := d.seedStore.WriteFact(ctx, req.Ts, store.KindSelf, req.Key, req.Value, seedSource, 1.0, sql.NullInt64{})
|
||||||
|
if err != nil {
|
||||||
|
return ipc.SeedEventResp{}, fmt.Errorf("seed write fact: %w", err)
|
||||||
|
}
|
||||||
|
resp := ipc.SeedEventResp{FactID: factID}
|
||||||
|
|
||||||
|
ev := pattern.Extract(factID, req.Key, req.Value, req.Ts)
|
||||||
|
if ev == nil {
|
||||||
|
// The fact is written and stays written. Saying so matters: a caller
|
||||||
|
// that assumed a seed always produces an event would otherwise read
|
||||||
|
// four silent successes and conclude the detector is broken.
|
||||||
|
log.Printf("seed: %s=%s wrote fact %d, no event (value outside the action lexicon)", req.Key, req.Value, factID)
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
resp.Extracted, resp.Action, resp.Object = true, ev.Action, ev.Object
|
||||||
|
|
||||||
|
eventID, err := d.seedStore.CreateEvent(ctx, factID, ev.Action, ev.Object, req.Ts)
|
||||||
|
if err != nil {
|
||||||
|
return resp, fmt.Errorf("seed create event: %w", err)
|
||||||
|
}
|
||||||
|
resp.EventID = eventID
|
||||||
|
|
||||||
|
r, routineID, err := detectAndPropose(ctx, d.seedStore, ev.Action, ev.Object, req.Ts)
|
||||||
|
if err != nil {
|
||||||
|
return resp, fmt.Errorf("seed detect: %w", err)
|
||||||
|
}
|
||||||
|
if r == nil {
|
||||||
|
return resp, nil // too few events yet, too irregular, or already decided
|
||||||
|
}
|
||||||
|
resp.Proposed, resp.RoutineID, resp.IntervalDays = true, routineID, r.IntervalDays
|
||||||
|
log.Printf("seed: proposed routine %d — %s/%s every %.1f days", routineID, r.Action, r.Object, r.IntervalDays)
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/ipc"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Off is the default and it must mean "nothing to write with", not "permission
|
||||||
|
// to refuse later". A daemonAPI with no seedStore writes no fact at all.
|
||||||
|
func TestSeedRefusedWithoutTheFlag(t *testing.T) {
|
||||||
|
d := &daemonAPI{CoreAPI: ipc.UnimplementedCoreAPI{}}
|
||||||
|
_, err := d.SeedEvent(context.Background(), ipc.SeedEventReq{
|
||||||
|
Key: "cat_water_fountain", Value: "заправил", Ts: time.Now(),
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("seed succeeded with no seedStore")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "-allow-seed") {
|
||||||
|
t.Errorf("error does not name the flag: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The whole point of the task: four seeds spread past the detector's floor
|
||||||
|
// produce a proposal against the real daemon path, which is what nobody could
|
||||||
|
// do before (Vikunja #518). Three seeds must NOT propose — MinEvents is four,
|
||||||
|
// and a test that only checked the happy end would pass on an off-by-one.
|
||||||
|
func TestSeedFourEventsProposesARoutine(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
d := &daemonAPI{CoreAPI: ipc.UnimplementedCoreAPI{}, seedStore: newTestStore(t)}
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
var last ipc.SeedEventResp
|
||||||
|
// Oldest first, three hours apart — past MinIntervalDays (two hours).
|
||||||
|
for i := 3; i >= 0; i-- {
|
||||||
|
var err error
|
||||||
|
last, err = d.SeedEvent(ctx, ipc.SeedEventReq{
|
||||||
|
Key: "cat_water_fountain",
|
||||||
|
Value: "заправил",
|
||||||
|
Ts: now.Add(-time.Duration(i) * 3 * time.Hour),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("seed %d: %v", i, err)
|
||||||
|
}
|
||||||
|
if !last.Extracted {
|
||||||
|
t.Fatalf("seed %d: no event extracted from a lexicon verb", i)
|
||||||
|
}
|
||||||
|
if i > 0 && last.Proposed {
|
||||||
|
t.Fatalf("proposed after only %d events, MinEvents is 4", 4-i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !last.Proposed {
|
||||||
|
t.Fatal("four spaced events did not propose a routine")
|
||||||
|
}
|
||||||
|
if last.Action != "refill" || last.Object != "cat_water_fountain" {
|
||||||
|
t.Errorf("wrong pair: %s/%s", last.Action, last.Object)
|
||||||
|
}
|
||||||
|
if last.IntervalDays < 0.1 {
|
||||||
|
t.Errorf("interval %v — the detector saw a burst, not a rhythm", last.IntervalDays)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The proposal is readable through the same list the /routines page uses,
|
||||||
|
// which is the wiring an eval-lab fixture would not have proved.
|
||||||
|
proposed, err := d.seedStore.ListProposedRoutines(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list: %v", err)
|
||||||
|
}
|
||||||
|
if len(proposed) != 1 {
|
||||||
|
t.Fatalf("expected 1 proposed routine, got %d", len(proposed))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A value outside the action lexicon writes the fact and says it seeded
|
||||||
|
// nothing. Silence here would read as four working seeds and a broken
|
||||||
|
// detector.
|
||||||
|
func TestSeedReportsWhenExtractionDeclines(t *testing.T) {
|
||||||
|
d := &daemonAPI{CoreAPI: ipc.UnimplementedCoreAPI{}, seedStore: newTestStore(t)}
|
||||||
|
resp, err := d.SeedEvent(context.Background(), ipc.SeedEventReq{
|
||||||
|
Key: "mood", Value: "ok", Ts: time.Now(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("seed: %v", err)
|
||||||
|
}
|
||||||
|
if resp.FactID == 0 {
|
||||||
|
t.Error("fact was not written")
|
||||||
|
}
|
||||||
|
if resp.Extracted || resp.EventID != 0 || resp.Proposed {
|
||||||
|
t.Errorf("claimed an event for a non-action value: %+v", resp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A seed with no timestamp is refused rather than defaulting to now: the only
|
||||||
|
// reason this seam exists is the caller choosing when, so a zero Ts is a bug in
|
||||||
|
// the caller and must not silently write a fact at the wrong time.
|
||||||
|
func TestSeedRequiresAnExplicitTimestamp(t *testing.T) {
|
||||||
|
d := &daemonAPI{CoreAPI: ipc.UnimplementedCoreAPI{}, seedStore: newTestStore(t)}
|
||||||
|
if _, err := d.SeedEvent(context.Background(), ipc.SeedEventReq{
|
||||||
|
Key: "cat_water_fountain", Value: "заправил",
|
||||||
|
}); err == nil {
|
||||||
|
t.Fatal("seed accepted a zero timestamp")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
|
|
||||||
"github.com/kami/maven/internal/ipc"
|
"github.com/kami/maven/internal/ipc"
|
||||||
"github.com/kami/maven/internal/loop"
|
"github.com/kami/maven/internal/loop"
|
||||||
|
"github.com/kami/maven/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
// daemonAPI wraps a store-backed CoreAPI and overrides TickTrace with the
|
// daemonAPI wraps a store-backed CoreAPI and overrides TickTrace with the
|
||||||
@@ -22,6 +23,11 @@ type daemonAPI struct {
|
|||||||
chatFn func(ctx context.Context, conversation, text string) string
|
chatFn func(ctx context.Context, conversation, text string) string
|
||||||
getMCPServers func() []ipc.MCPServerStatus
|
getMCPServers func() []ipc.MCPServerStatus
|
||||||
getEvents func(n int) []ipc.IntakeEvent
|
getEvents func(n int) []ipc.IntakeEvent
|
||||||
|
// seedStore — non-nil ONLY when mavend was started with -allow-seed. It is
|
||||||
|
// the whole off-switch for the backdated write path (Vikunja #518), and it
|
||||||
|
// is a store rather than a bool so that leaving the flag off means the
|
||||||
|
// method has nothing to write with, not merely permission to refuse.
|
||||||
|
seedStore *store.Store
|
||||||
}
|
}
|
||||||
|
|
||||||
// RecentEvents — the unified intake journal (Vikunja #283). Empty, not an
|
// RecentEvents — the unified intake journal (Vikunja #283). Empty, not an
|
||||||
|
|||||||
@@ -552,6 +552,11 @@ func repairFactVectors(dataStore *store.Store, emb router.Embedder) {
|
|||||||
// runReembed.
|
// runReembed.
|
||||||
var reembedOnStart bool
|
var reembedOnStart bool
|
||||||
|
|
||||||
|
// allowSeedOnStart is the -allow-seed flag (set in run()). Opt-in, and the
|
||||||
|
// default is the one that matters: a box nobody is testing has no live path to
|
||||||
|
// write a fact into the past. See seed.go and Vikunja #518.
|
||||||
|
var allowSeedOnStart bool
|
||||||
|
|
||||||
// checkStoredEmbedder compares the embedder we just loaded with the one that
|
// checkStoredEmbedder compares the embedder we just loaded with the one that
|
||||||
// wrote the vectors already in the DB (Vikunja #378).
|
// wrote the vectors already in the DB (Vikunja #378).
|
||||||
//
|
//
|
||||||
|
|||||||
+78
-20
@@ -1094,34 +1094,53 @@ func handleRoutines(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, se
|
|||||||
var msg string
|
var msg string
|
||||||
if r.Method == http.MethodPost {
|
if r.Method == http.MethodPost {
|
||||||
action := r.FormValue("action")
|
action := r.FormValue("action")
|
||||||
idStr := r.FormValue("id")
|
// "seed" is the one action with no routine to act on — it is what
|
||||||
var rid int64
|
// MAKES a routine (Vikunja #518), so it runs before the id parse. It
|
||||||
if n, _ := fmt.Sscanf(idStr, "%d", &rid); n != 1 {
|
// lives on this route rather than a page of its own because it is
|
||||||
http.Error(w, "invalid id", http.StatusBadRequest)
|
// already the step-up-gated surface for this table, and a second gated
|
||||||
return
|
// surface is a second thing to get wrong.
|
||||||
}
|
if action == "seed" {
|
||||||
switch action {
|
|
||||||
case "accept":
|
|
||||||
if !stepUpOK(session, requireStepUp) {
|
if !stepUpOK(session, requireStepUp) {
|
||||||
http.Error(w, "step-up required: assert a passkey first", http.StatusForbidden)
|
http.Error(w, "step-up required: assert a passkey first", http.StatusForbidden)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := acceptRoutine(ctx, core, rid); err != nil {
|
out, err := seedRoutineEvent(ctx, core, r)
|
||||||
log.Printf("routines: accept %d: %v", rid, err)
|
if err != nil {
|
||||||
http.Error(w, "accept failed: "+err.Error(), http.StatusBadGateway)
|
log.Printf("routines: seed: %v", err)
|
||||||
|
http.Error(w, "seed failed: "+err.Error(), http.StatusBadGateway)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
msg = "accepted routine — maven will remind you"
|
msg = out
|
||||||
case "dismiss":
|
} else {
|
||||||
if err := core.DismissProposedRoutine(ctx, rid); err != nil {
|
idStr := r.FormValue("id")
|
||||||
log.Printf("routines: dismiss %d: %v", rid, err)
|
var rid int64
|
||||||
http.Error(w, "dismiss failed: "+err.Error(), http.StatusBadGateway)
|
if n, _ := fmt.Sscanf(idStr, "%d", &rid); n != 1 {
|
||||||
|
http.Error(w, "invalid id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch action {
|
||||||
|
case "accept":
|
||||||
|
if !stepUpOK(session, requireStepUp) {
|
||||||
|
http.Error(w, "step-up required: assert a passkey first", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := acceptRoutine(ctx, core, rid); err != nil {
|
||||||
|
log.Printf("routines: accept %d: %v", rid, err)
|
||||||
|
http.Error(w, "accept failed: "+err.Error(), http.StatusBadGateway)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
msg = "accepted routine — maven will remind you"
|
||||||
|
case "dismiss":
|
||||||
|
if err := core.DismissProposedRoutine(ctx, rid); err != nil {
|
||||||
|
log.Printf("routines: dismiss %d: %v", rid, err)
|
||||||
|
http.Error(w, "dismiss failed: "+err.Error(), http.StatusBadGateway)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
msg = "dismissed routine"
|
||||||
|
default:
|
||||||
|
http.Error(w, "unknown action", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
msg = "dismissed routine"
|
|
||||||
default:
|
|
||||||
http.Error(w, "unknown action", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
proposed, err := core.ListProposedRoutines(ctx)
|
proposed, err := core.ListProposedRoutines(ctx)
|
||||||
@@ -1158,6 +1177,45 @@ func toRoutineViews(rs []ipc.ProposedRoutine) []routineView {
|
|||||||
// may do it (Vikunja #367): accepting gives the tick loop a standing new
|
// may do it (Vikunja #367): accepting gives the tick loop a standing new
|
||||||
// reason to speak, which DESIGN.md puts at layer 3, and the button here is
|
// reason to speak, which DESIGN.md puts at layer 3, and the button here is
|
||||||
// behind step-up. Voice can park the question and dismiss, never accept.
|
// behind step-up. Voice can park the question and dismiss, never accept.
|
||||||
|
// seedRoutineEvent drives one backdated fact write through core (Vikunja #518),
|
||||||
|
// so the pattern detector can be exercised against a running daemon instead of
|
||||||
|
// over real days. Refused unless mavend was started with -allow-seed; on an
|
||||||
|
// ordinary box the error says so and nothing is written.
|
||||||
|
//
|
||||||
|
// Takes "ago" rather than an absolute timestamp — hours before now, as a float
|
||||||
|
// so a QA sitting can space four seeds three hours apart without doing clock
|
||||||
|
// arithmetic. The detector's floor is two hours, and "0" is a legal answer
|
||||||
|
// meaning now.
|
||||||
|
func seedRoutineEvent(ctx context.Context, core ipc.CoreAPI, r *http.Request) (string, error) {
|
||||||
|
key := strings.TrimSpace(r.FormValue("key"))
|
||||||
|
value := strings.TrimSpace(r.FormValue("value"))
|
||||||
|
if key == "" || value == "" {
|
||||||
|
return "", errors.New("seed needs a key and a value")
|
||||||
|
}
|
||||||
|
agoHours, err := strconv.ParseFloat(strings.TrimSpace(r.FormValue("ago")), 64)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("seed: bad ago (hours before now): %w", err)
|
||||||
|
}
|
||||||
|
if agoHours < 0 {
|
||||||
|
return "", errors.New("seed: ago is hours BEFORE now, so it cannot be negative")
|
||||||
|
}
|
||||||
|
resp, err := core.SeedEvent(ctx, ipc.SeedEventReq{
|
||||||
|
Key: key,
|
||||||
|
Value: value,
|
||||||
|
Ts: time.Now().Add(-time.Duration(agoHours * float64(time.Hour))),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if !resp.Extracted {
|
||||||
|
return fmt.Sprintf("wrote fact %d, but %q is not in the action lexicon — no event, no pattern", resp.FactID, value), nil
|
||||||
|
}
|
||||||
|
if !resp.Proposed {
|
||||||
|
return fmt.Sprintf("seeded %s/%s (fact %d, event %d) — not enough yet to propose", resp.Action, resp.Object, resp.FactID, resp.EventID), nil
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("seeded %s/%s and PROPOSED routine %d, every %.1f days", resp.Action, resp.Object, resp.RoutineID, resp.IntervalDays), nil
|
||||||
|
}
|
||||||
|
|
||||||
func acceptRoutine(ctx context.Context, core ipc.CoreAPI, id int64) error {
|
func acceptRoutine(ctx context.Context, core ipc.CoreAPI, id int64) error {
|
||||||
proposed, err := core.ListProposedRoutines(ctx)
|
proposed, err := core.ListProposedRoutines(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
+166
-14
@@ -1,17 +1,21 @@
|
|||||||
# QA plan: checking Maven properly
|
# QA plan: checking Maven properly
|
||||||
|
|
||||||
*Last verified: 2026-08-04 @ 58635f1. Living doc: correct it in place, do not append.*
|
*Last verified: 2026-08-04 @ 8d816f4. Living doc: correct it in place, do not append.*
|
||||||
|
|
||||||
Written 2026-08-01, after the 35-PR stack landed and the box came back up.
|
Written 2026-08-01, after the 35-PR stack landed and the box came back up.
|
||||||
Refreshed 2026-08-02 against the live list, after PRs #85-#90.
|
Refreshed 2026-08-02 against the live list, after PRs #85-#90.
|
||||||
|
Reconciled 2026-08-04 against the board, after the review stack merged.
|
||||||
|
|
||||||
42 of the 50 open Vikunja tasks are `QA:` tasks. They are verification work, not
|
The board holds 95 open tasks and 35 of them are `QA:` tasks. The ratio moved
|
||||||
build work. Most sat unverifiable while Maven was down for 11 days. That
|
because the build backlog grew, not because verification shrank. QA is
|
||||||
blocker is gone.
|
verification work, not build work, and most of it sat unverifiable while Maven
|
||||||
|
was down for 11 days. That blocker is gone.
|
||||||
|
|
||||||
The plan as written on 2026-08-01 named 40 task numbers. Ten open `QA:` tasks were
|
Every open `QA:` task appears below. Distrust the count in this header first. It
|
||||||
missing and two of the named ones had closed. Every open task now appears below,
|
is right on the day it is written and wrong a week later.
|
||||||
the eight non-QA ones in the last two sections.
|
|
||||||
|
Fourteen ids this plan used to name closed on 2026-08-04 and are gone from it. If
|
||||||
|
you cannot find one, check whether it closed before assuming the plan dropped it.
|
||||||
|
|
||||||
This plan orders them by what unblocks what. Do sessions 1 and 2 first. Almost everything
|
This plan orders them by what unblocks what. Do sessions 1 and 2 first. Almost everything
|
||||||
downstream assumes the voice loop works, and nobody has confirmed that since
|
downstream assumes the voice loop works, and nobody has confirmed that since
|
||||||
@@ -84,8 +88,71 @@ session quality), **321** steps 3-5 (quiet mode), **288** (STT golden audio).
|
|||||||
**288 is not blocked.** The fixtures are committed under `cmd/mavsttd/testdata/`
|
**288 is not blocked.** The fixtures are committed under `cmd/mavsttd/testdata/`
|
||||||
and `make test-stt-golden` runs today. This plan said otherwise until 02-08-2026.
|
and `make test-stt-golden` runs today. This plan said otherwise until 02-08-2026.
|
||||||
|
|
||||||
Steps 1 and 3-6 were run on 02-08-2026 and pass. Steps 2 and 7-9 still need a
|
Steps 1 and 3-6 were run on 02-08-2026 and pass.
|
||||||
person at the box, because they need a microphone or a nudge to arrive.
|
|
||||||
|
**Step 2 no longer needs a person, and step 9 has a number now** (04-08-2026).
|
||||||
|
`POST /api/ptt` takes raw PCM16 16kHz mono and answers with audio plus an
|
||||||
|
`X-Reply-Text` header, so the committed STT fixtures stand in for a microphone:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
tail -c +45 cmd/mavsttd/testdata/ru_query.wav > /tmp/q.pcm
|
||||||
|
curl -s --noproxy '*' -D /tmp/h -o /tmp/reply.pcm -X POST \
|
||||||
|
http://127.0.0.1:9201/api/ptt --data-binary @/tmp/q.pcm \
|
||||||
|
-H 'Content-Type: application/octet-stream' -m 180
|
||||||
|
```
|
||||||
|
|
||||||
|
That covers audio in → STT → router → phrasing → TTS audio out. It leaves only
|
||||||
|
browser microphone capture needing a person, and the wake path needing a machine.
|
||||||
|
Do not post `en_act.wav` without deciding first: it is a mutating act.
|
||||||
|
|
||||||
|
**Steps 7 and 8 still cannot run, but 15 is no longer the reason** (04-08-2026).
|
||||||
|
The desk presence poster is installed on workpc. It is a `maven-desk` systemd
|
||||||
|
user timer on a 60s cadence, gated by hypridle at 120s idle. `desk_active` facts
|
||||||
|
now arrive, and the first landed at 18:43.
|
||||||
|
|
||||||
|
What blocks the two steps now is that no rule wants to fire. `/trace` shows all
|
||||||
|
five at `predicate`, none inert:
|
||||||
|
|
||||||
|
| rule | sev | why it is false |
|
||||||
|
|---|---|---|
|
||||||
|
| water | 1 | needs ≥3h since the last `water` fact; step 2's `ru_fact` wrote one |
|
||||||
|
| meal | 1 | needs ≥6h since a `meal` fact; none exists |
|
||||||
|
| break | 2 | needs both `desk_active` and a `break` fact; `break` has never been written |
|
||||||
|
| service_down | 4 | no kuma monitor is down |
|
||||||
|
| netdata_critical | 3 | nothing critical |
|
||||||
|
|
||||||
|
So the honest way to run step 8 is to wait three hours after the last `water`
|
||||||
|
fact, or to write one antedated. Do not read the water rule's silence as a defect.
|
||||||
|
|
||||||
|
**The sev4 telegram reach works** (04-08-2026). Resuming a paused kuma monitor
|
||||||
|
for paperless, which is genuinely down, put a real `service_down` through the
|
||||||
|
whole path with presence away:
|
||||||
|
|
||||||
|
```
|
||||||
|
23:03 voicesink: no live voice session for service_down, falling through to away channels
|
||||||
|
/notifications: 19:03 | service_down | telegram | pending | Сервис перестал отвечать.
|
||||||
|
04.08 23:03 | nudge | service_down | telegram | sent | 23:03
|
||||||
|
```
|
||||||
|
|
||||||
|
`ChannelsFor(Sev4, Away)` returned telegram, the send succeeded, and the row
|
||||||
|
holds at `pending` because sev4 repeats until acked. The 15:51 row shows the
|
||||||
|
same rule reaching `acted` earlier, so the ack path works too.
|
||||||
|
|
||||||
|
The body was `Сервис перестал отвечать.`, which names no service. That is a bug
|
||||||
|
and it is deterministic, filed as **534**. `nudgeValues` fills `{service}` from
|
||||||
|
`State.Fact("service_down")`, an exact key mavpoll stopped writing when
|
||||||
|
per-monitor facts landed. Nine of the ten templates carry `{service}`, so all
|
||||||
|
nine are rejected as unfillable. The one nameless variant is left as the only
|
||||||
|
usable one, every time. The stub and LLM phrasers both call `loop.DownServices`
|
||||||
|
and get it right. The template path is the one that runs.
|
||||||
|
|
||||||
|
**Presence itself has a real defect, filed as 532.** `SavePresenceState` has no
|
||||||
|
caller outside tests, so the singleton row is never written. The gate is fine,
|
||||||
|
because it reads the bucket `GatherState` computes in memory each tick. Two
|
||||||
|
things follow. Hysteresis is dead, because `lastBucket` is always cold-start `Away`
|
||||||
|
and the 0.30-0.55 hold band never applies. And every presence readout lies:
|
||||||
|
`/dash` shows `away — score 0.00 (never)` with fresh `desk_active` facts arriving
|
||||||
|
every 60s. Do not trust that number while checking anything else here.
|
||||||
|
|
||||||
Steps 1 and 3-6 do not need a browser. `POST /api/chat` takes a form-encoded
|
Steps 1 and 3-6 do not need a browser. `POST /api/chat` takes a form-encoded
|
||||||
`text=` field and a cookie jar, and answers with the rendered `/chat` page:
|
`text=` field and a cookie jar, and answers with the rendered `/chat` page:
|
||||||
@@ -108,6 +175,33 @@ turns look misaligned when they are not.
|
|||||||
back. This covers browser mic to STT to core to TTS as one path. It does
|
back. This covers browser mic to STT to core to TTS as one path. It does
|
||||||
**not** cover the wake word or the voice-activity gate, and no step here
|
**not** cover the wake word or the voice-activity gate, and no step here
|
||||||
does — see below.
|
does — see below.
|
||||||
|
**Passes below the browser** (04-08-2026, three fixtures through `/api/ptt`):
|
||||||
|
HTTP 200, `audio/l16;rate=16000;channels=1`, and real speech back. `ru_query`
|
||||||
|
answered `на 04.08.2026 ничего нет.` in 3.82s of audio at RMS 3865, `ru_fact`
|
||||||
|
answered `отметила: water = выпил`, `ru_reminder` answered `хорошо, напомню.`
|
||||||
|
at `intent=reminder`.
|
||||||
|
**Passes in the browser too** (04-08-2026), and it needed no person. Headless
|
||||||
|
Chrome takes a fake microphone, so the whole browser half runs unattended:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
chrome --headless=new --remote-debugging-port=9333 --remote-allow-origins='*' \
|
||||||
|
--use-fake-device-for-media-stream --use-fake-ui-for-media-stream \
|
||||||
|
--use-file-for-fake-audio-capture=cmd/mavsttd/testdata/ru_query.wav%noloop
|
||||||
|
```
|
||||||
|
|
||||||
|
Then drive it over the debug protocol: click `#btn`, wait, click again, read
|
||||||
|
`#status` and `#log`. That covers `getUserMedia`, `MediaRecorder`, the webm
|
||||||
|
decode and the hand-written resample to 16k Int16. It logged
|
||||||
|
`sending 188160 bytes`, which is 5.88s at 16k mono, and got the reply back.
|
||||||
|
|
||||||
|
**The button is on `/`, not `/dash`.** `handleVoice` serves it at the root
|
||||||
|
(`main.go:332`). `/dash` is the presence and fact dashboard and carries no
|
||||||
|
`#btn`. This step said `/dash` until 04-08-2026.
|
||||||
|
|
||||||
|
One defect fell out, filed as **533**. The reply logged as
|
||||||
|
`на+04.08.2026+ничего+нет.` The header is escaped with `url.QueryEscape`,
|
||||||
|
which writes a space as `+`, then decoded with `decodeURIComponent`, which
|
||||||
|
leaves `+` alone. Transcript only, the audio is fine.
|
||||||
3. Say `тихий режим`. Expect `тихий режим включён. буду реже напоминать.` **Passes.**
|
3. Say `тихий режим`. Expect `тихий режим включён. буду реже напоминать.` **Passes.**
|
||||||
4. Say `выключи тихий режим`. Expect `тихий режим выключен.` Negation must win. **Passes.**
|
4. Say `выключи тихий режим`. Expect `тихий режим выключен.` Negation must win. **Passes.**
|
||||||
5. Say `в комнате тихо`. Quiet mode must NOT flip. Confirm on `/history` that no
|
5. Say `в комнате тихо`. Quiet mode must NOT flip. Confirm on `/history` that no
|
||||||
@@ -128,6 +222,29 @@ turns look misaligned when they are not.
|
|||||||
**First evidence, in text** (02-08-2026): nothing breaks, but answers wander
|
**First evidence, in text** (02-08-2026): nothing breaks, but answers wander
|
||||||
and stitch unrelated topics. Asked whether he should move flats, she opened
|
and stitch unrelated topics. Asked whether he should move flats, she opened
|
||||||
with the weather. That is 287, and it is a phrasing problem, not a loop problem.
|
with the weather. That is 287, and it is a phrasing problem, not a loop problem.
|
||||||
|
**The slowness now has a cause and a number** (04-08-2026). A spoken turn
|
||||||
|
takes 32 to 34 seconds. One phrasing call is 30.0s of that. STT is 1.0s
|
||||||
|
and routing is under 10ms. Both interactive calls decoded exactly 512 tokens,
|
||||||
|
which is the phrasing cap. Both were truncated, to produce a reply of
|
||||||
|
under 25 characters.
|
||||||
|
The cause is `responseGrammar`, not the model. Its last rule is
|
||||||
|
`ws ::= [ \t\n]*`, and `*` is unbounded, so the model emits `{` and then
|
||||||
|
satisfies `ws` with whitespace until `max_tokens` stops it. Reproduced on a
|
||||||
|
second server: at `repeat_penalty` 1.0 it runs to 512 and returns
|
||||||
|
`finish_reason=length`, at 1.3 it stops at 24. Bounding the rule to
|
||||||
|
`[ \t\n]{0,4}` gives a clean stop at 33 tokens three times out of three with
|
||||||
|
no penalty at all.
|
||||||
|
Only some callers are exposed. `internal/llm.Req` sends `repeat_penalty` and
|
||||||
|
the replier sets it to 1.3, so that path is protected by accident. `chatReq`
|
||||||
|
in the phraser sends no penalty, so `PhraseChat`, `PhraseQuery`,
|
||||||
|
`PhraseNudge` and `PhraseReminder` all run at the default 1.0. Filed as
|
||||||
|
**531**.
|
||||||
|
Two guesses were wrong on the way and are recorded so nobody repeats them.
|
||||||
|
It is not reasoning tokens: the probe returned `reasoning_content` of length
|
||||||
|
0, and the grammar constrains output from the first token. It is not the
|
||||||
|
`--cache-ram 512` limit either: that is MiB of prompt cache and the 512 that
|
||||||
|
was hit is a token count.
|
||||||
|
The wandering is a second thing and stays on 287.
|
||||||
|
|
||||||
**The wake path cannot be checked here, and that is now the decision rather
|
**The wake path cannot be checked here, and that is now the decision rather
|
||||||
than a gap.** `mavwaked` and `mavenclient` appear in no compose file and run as
|
than a gap.** `mavwaked` and `mavenclient` appear in no compose file and run as
|
||||||
@@ -252,10 +369,12 @@ check that the failure floor catches a mid-session model death.
|
|||||||
|
|
||||||
These need real use rather than a command, grouped by what one sitting covers.
|
These need real use rather than a command, grouped by what one sitting covers.
|
||||||
|
|
||||||
**Morning and delivery** (**280**, **281**, **128**, **282**, **283**, **285**):
|
**Morning and delivery** (**280**, **281**, **128**, **283**, **285**):
|
||||||
open `/morning`, walk the seven required behaviours, then check the four
|
open `/morning`, walk the seven required behaviours, then check the four
|
||||||
interruption outcomes and the digest gap. **282** needs the `desk_active` script
|
interruption outcomes and the digest gap. The presence half of this sitting
|
||||||
enabled on the desk PC first, which is **15** and needs you at that machine.
|
cannot run. `desk-active.sh` is on workpc, but no systemd user unit enables it,
|
||||||
|
so no `desk_active` fact has ever been written. That is **15** and needs you at
|
||||||
|
that machine.
|
||||||
**283** is the event intake envelope every reach shares, so a delivery check
|
**283** is the event intake envelope every reach shares, so a delivery check
|
||||||
exercises it whether you name it or not. **285** is not verification: the bridge
|
exercises it whether you name it or not. **285** is not verification: the bridge
|
||||||
framework works and the remaining ask is more adapters. Decide which reach comes
|
framework works and the remaining ask is more adapters. Decide which reach comes
|
||||||
@@ -381,6 +500,36 @@ With Praxis stopped the card reads `praxis — unreachable` while Nexus and Hexi
|
|||||||
keep rendering. On `docker start` the card returns to `nothing needs attention.`
|
keep rendering. On `docker start` the card returns to `nothing needs attention.`
|
||||||
with no mavend restart. Independent degradation and recovery both hold.
|
with no mavend restart. Independent degradation and recovery both hold.
|
||||||
|
|
||||||
|
**Workstation offload** (**492**): never run, and added to this plan on
|
||||||
|
2026-08-04. It covers **485**, which shipped in PR #97. Three states, one rule:
|
||||||
|
silent when the workstation would only do the job better, named when the resident
|
||||||
|
model cannot do the job at all.
|
||||||
|
|
||||||
|
1. **Card free.** mavgpud 200, llama-server holding gemma-4-12b. A routing turn
|
||||||
|
and a phrased reply both complete through the workstation. Confirm that from
|
||||||
|
the mavgpud request log, not from the answer sounding good. Nothing in the
|
||||||
|
answer says where it was phrased.
|
||||||
|
2. **Card held.** Start a training run so mavgpud yields and answers 503. The same
|
||||||
|
turns complete on Qwen3-1.7B with no mention of the fallback. Then kill the
|
||||||
|
card mid-utterance, with a request in flight. That is the case no unit test
|
||||||
|
reaches and the one most likely to hang.
|
||||||
|
3. **Machine asleep.** Suspend workpc. It must be indistinguishable from held.
|
||||||
|
Bring it back and confirm the prober re-admits it inside one 15s interval, with
|
||||||
|
no mavend restart.
|
||||||
|
|
||||||
|
Two things are likely wrong. A remote that accepts the connection and then never
|
||||||
|
answers is worse than a 503. `timeout` is 90s, so measure what a turn waits. And
|
||||||
|
two models mean two prompt renderings: `check_prompt_parity.py` guards Go against
|
||||||
|
the relabelling prompt, not gemma against Qwen, so confirm `{"response","mood"}`
|
||||||
|
parses from both.
|
||||||
|
|
||||||
|
**workpc is running training as of 2026-08-04**, so the held state is available
|
||||||
|
today and the free state is not. Run step 2 first, out of order.
|
||||||
|
|
||||||
|
Write down one number at the end. Read the mavgpud journal and record the
|
||||||
|
fraction of a working week the card is free. That is what **488** left open, and
|
||||||
|
it decides whether the offload is worth carrying.
|
||||||
|
|
||||||
**Operations** (**249**, **250**): both ran 02-08-2026. The code is correct and
|
**Operations** (**249**, **250**): both ran 02-08-2026. The code is correct and
|
||||||
neither lever can be pulled on this box. See **477**.
|
neither lever can be pulled on this box. See **477**.
|
||||||
|
|
||||||
@@ -549,7 +698,7 @@ Not QA. These are blocked on a decision or a credential only you have.
|
|||||||
| # | what |
|
| # | what |
|
||||||
|---|---|
|
|---|---|
|
||||||
| 16 | Create the Kuma API key. `-kuma-key uk5_mavpoll-key` in `docker-compose.yml` is still the placeholder. |
|
| 16 | Create the Kuma API key. `-kuma-key uk5_mavpoll-key` in `docker-compose.yml` is still the placeholder. |
|
||||||
| 15 | Deploy `desk_active` on the desk PC. Blocks **282**. |
|
| 15 | Enable the `desk_active` units on workpc. The script is there; the timer is `not-found`, so the strongest presence signal writes nothing. Blocks the presence half of session 3. |
|
||||||
| 122 | Finish the CPT run for Qwen3-1.7B. The persona fix depends on it. |
|
| 122 | Finish the CPT run for Qwen3-1.7B. The persona fix depends on it. |
|
||||||
| 355 | Deploy the Hexis auth change. Was blocked on Maven being under construction, which it no longer is. The client half is vendored and wired. |
|
| 355 | Deploy the Hexis auth change. Was blocked on Maven being under construction, which it no longer is. The client half is vendored and wired. |
|
||||||
| 357 | Decide whether entity-existence validation is the permanent target guard or whether blessing lands in Nexus. |
|
| 357 | Decide whether entity-existence validation is the permanent target guard or whether blessing lands in Nexus. |
|
||||||
@@ -583,8 +732,11 @@ where they land, so the board stops reading as 50 things Maven owes.
|
|||||||
31ms. The router buys about 4 points of accuracy for four orders of magnitude
|
31ms. The router buys about 4 points of accuracy for four orders of magnitude
|
||||||
of latency. Whether that still earns its place is now an open question.
|
of latency. Whether that still earns its place is now an open question.
|
||||||
4. Housekeeping. Cheap, and it makes the remaining backlog honest.
|
4. Housekeeping. Cheap, and it makes the remaining backlog honest.
|
||||||
5. Session 3, split whichever way suits you. All five sittings ran on
|
5. Session 3, split whichever way suits you. Five of its six sittings ran on
|
||||||
02-08-2026. Read the per-sitting notes before repeating any of them.
|
02-08-2026. Read the per-sitting notes before repeating any of them.
|
||||||
|
6. The workstation offload sitting (**492**), which has never run. It is last
|
||||||
|
because it is newest, not because it matters least. It is the one sitting whose
|
||||||
|
subject changes state on its own.
|
||||||
|
|
||||||
The next thing to fix is not in this plan. Four defects say the same sentence:
|
The next thing to fix is not in this plan. Four defects say the same sentence:
|
||||||
a capability is built and no utterance reaches it. **466** (a clarify is global),
|
a capability is built and no utterance reaches it. **466** (a clarify is global),
|
||||||
|
|||||||
@@ -91,6 +91,19 @@ func Requirement(m ipc.Method) Authority {
|
|||||||
// can do is make Maven stop recognising someone, which is the state the
|
// can do is make Maven stop recognising someone, which is the state the
|
||||||
// box ships in anyway.
|
// box ships in anyway.
|
||||||
return AuthWrite
|
return AuthWrite
|
||||||
|
case ipc.MethodSeedEvent:
|
||||||
|
// The one backdating write path in the tree (Vikunja #518). AuthStepUp,
|
||||||
|
// the same rung as mutating the tool allowlist, and for a reason that is
|
||||||
|
// not about privilege: every other write records when something actually
|
||||||
|
// happened, and this one asserts it. A caller who can place a fact in the
|
||||||
|
// past can manufacture a routine Maven will then act on forever, which is
|
||||||
|
// the tick loop obeying evidence nobody produced.
|
||||||
|
//
|
||||||
|
// Step-up is not the real gate and is not meant to be. mavend refuses the
|
||||||
|
// method entirely unless started with -allow-seed, so the ordinary state
|
||||||
|
// of the box is that no gesture reaches it. This rung is what stops a
|
||||||
|
// module from calling it on a box where QA left the flag on.
|
||||||
|
return AuthStepUp
|
||||||
case ipc.MethodWriteFact:
|
case ipc.MethodWriteFact:
|
||||||
return AuthWrite
|
return AuthWrite
|
||||||
case ipc.MethodIngestMail:
|
case ipc.MethodIngestMail:
|
||||||
|
|||||||
@@ -184,6 +184,48 @@ type CaptureTaskResp struct {
|
|||||||
Promoted bool `json:"promoted,omitempty"`
|
Promoted bool `json:"promoted,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SeedEventReq — write one fact at a caller-supplied timestamp and run the
|
||||||
|
// pattern path over it, so a recurring routine can be produced on demand
|
||||||
|
// instead of over real days (Vikunja #518).
|
||||||
|
//
|
||||||
|
// This is the ONLY backdating write path in the tree, and it exists for one
|
||||||
|
// reason: the detector needs four events spread over hours before it proposes
|
||||||
|
// anything, so V-43, V-46, V-247 and V-254 could not be verified against a
|
||||||
|
// running daemon at all. A store fixture would have exercised the detector
|
||||||
|
// without the wiring those tasks doubt.
|
||||||
|
//
|
||||||
|
// Two things hold it shut. It is AuthStepUp in the authority table, the same
|
||||||
|
// rung as mutating the tool allowlist. And mavend refuses it outright unless
|
||||||
|
// started with -allow-seed, so a box nobody is testing carries no live
|
||||||
|
// backdating path even for a caller who cleared the gate.
|
||||||
|
//
|
||||||
|
// Key and Value are a fact, not an event: extraction runs for real, so a key
|
||||||
|
// the extractor ignores seeds nothing and says so. That is deliberate — a
|
||||||
|
// seam that accepted action and object directly would let QA prove a detector
|
||||||
|
// against events no utterance could ever produce.
|
||||||
|
type SeedEventReq struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Value string `json:"value"`
|
||||||
|
Ts time.Time `json:"ts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SeedEventResp — what the seed produced. Extracted is false when the fact was
|
||||||
|
// written but yielded no event, which is the extractor declining rather than a
|
||||||
|
// failure. Proposed is true only when this seed completed a pattern; the first
|
||||||
|
// three seeds of a run return false with no routine.
|
||||||
|
type SeedEventResp struct {
|
||||||
|
FactID int64 `json:"fact_id"`
|
||||||
|
EventID int64 `json:"event_id,omitempty"`
|
||||||
|
Extracted bool `json:"extracted"`
|
||||||
|
Action string `json:"action,omitempty"`
|
||||||
|
Object string `json:"object,omitempty"`
|
||||||
|
Proposed bool `json:"proposed"`
|
||||||
|
RoutineID int64 `json:"routine_id,omitempty"`
|
||||||
|
// IntervalDays — the median the detector settled on, echoed so QA can
|
||||||
|
// check it against the spacing it asked for.
|
||||||
|
IntervalDays float64 `json:"interval_days,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
// IngestMailReq — one message a mail reader has fetched, handed to core for
|
// IngestMailReq — one message a mail reader has fetched, handed to core for
|
||||||
// extraction (Vikunja #246).
|
// extraction (Vikunja #246).
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -487,6 +487,14 @@ func (c *Client) ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, e
|
|||||||
return r.Routines, nil
|
return r.Routines, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *Client) SeedEvent(ctx context.Context, req SeedEventReq) (SeedEventResp, error) {
|
||||||
|
var r SeedEventResp
|
||||||
|
if err := c.call(ctx, MethodSeedEvent, req, &r); err != nil {
|
||||||
|
return SeedEventResp{}, err
|
||||||
|
}
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Client) CaptureTask(ctx context.Context, req CaptureTaskReq) (CaptureTaskResp, error) {
|
func (c *Client) CaptureTask(ctx context.Context, req CaptureTaskReq) (CaptureTaskResp, error) {
|
||||||
var r CaptureTaskResp
|
var r CaptureTaskResp
|
||||||
if err := c.call(ctx, MethodCaptureTask, req, &r); err != nil {
|
if err := c.call(ctx, MethodCaptureTask, req, &r); err != nil {
|
||||||
|
|||||||
@@ -110,6 +110,13 @@ type RoutineAPI interface {
|
|||||||
// loop takes the schedule from there — no reminder is created (Vikunja #366).
|
// loop takes the schedule from there — no reminder is created (Vikunja #366).
|
||||||
AcceptProposedRoutine(ctx context.Context, id int64) error
|
AcceptProposedRoutine(ctx context.Context, id int64) error
|
||||||
|
|
||||||
|
// SeedEvent writes a backdated fact and runs extraction and detection over
|
||||||
|
// it, so a proposal can be produced in one sitting rather than over real
|
||||||
|
// days (Vikunja #518). See SeedEventReq for why this exists and what keeps
|
||||||
|
// it shut. Daemon-computed, like MorningStatus — the store adapter refuses
|
||||||
|
// it, because the detect-and-propose step lives in mavend.
|
||||||
|
SeedEvent(ctx context.Context, req SeedEventReq) (SeedEventResp, error)
|
||||||
|
|
||||||
// MorningStatus returns each configured morning routine's current
|
// MorningStatus returns each configured morning routine's current
|
||||||
// checklist state (see internal/morning): active today/now, which items
|
// checklist state (see internal/morning): active today/now, which items
|
||||||
// are done, which are still missing.
|
// are done, which are still missing.
|
||||||
|
|||||||
@@ -524,6 +524,9 @@ var methodTable = map[Method]handlerFunc{
|
|||||||
MethodCaptureTask: withParams(func(ctx context.Context, api CoreAPI, p CaptureTaskReq) (CaptureTaskResp, error) {
|
MethodCaptureTask: withParams(func(ctx context.Context, api CoreAPI, p CaptureTaskReq) (CaptureTaskResp, error) {
|
||||||
return api.CaptureTask(ctx, p)
|
return api.CaptureTask(ctx, p)
|
||||||
}),
|
}),
|
||||||
|
MethodSeedEvent: withParams(func(ctx context.Context, api CoreAPI, p SeedEventReq) (SeedEventResp, error) {
|
||||||
|
return api.SeedEvent(ctx, p)
|
||||||
|
}),
|
||||||
MethodListTasks: withParams(func(ctx context.Context, api CoreAPI, p listTasksReq) (listTasksResp, error) {
|
MethodListTasks: withParams(func(ctx context.Context, api CoreAPI, p listTasksReq) (listTasksResp, error) {
|
||||||
out, err := api.ListTasks(ctx, p.Status)
|
out, err := api.ListTasks(ctx, p.Status)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -265,6 +265,14 @@ func (a *storeAPI) TickTrace(ctx context.Context) (TickTrace, error) {
|
|||||||
return TickTrace{}, errors.New("store: tick trace not available via direct store API")
|
return TickTrace{}, errors.New("store: tick trace not available via direct store API")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SeedEvent — same shape as MorningStatus: writing the fact is a store call,
|
||||||
|
// but extraction and detect-and-propose live in mavend, and a seed that wrote
|
||||||
|
// the fact without running them would be the one thing this seam must not be,
|
||||||
|
// a way to prove a detector that never ran (Vikunja #518).
|
||||||
|
func (a *storeAPI) SeedEvent(ctx context.Context, req SeedEventReq) (SeedEventResp, error) {
|
||||||
|
return SeedEventResp{}, errors.New("store: seed event not available via direct store API")
|
||||||
|
}
|
||||||
|
|
||||||
func (a *storeAPI) MorningStatus(ctx context.Context) ([]MorningRoutineStatus, error) {
|
func (a *storeAPI) MorningStatus(ctx context.Context) ([]MorningRoutineStatus, error) {
|
||||||
return nil, errors.New("store: morning status not available via direct store API")
|
return nil, errors.New("store: morning status not available via direct store API")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,6 +105,9 @@ func (UnimplementedCoreAPI) DeleteTool(ctx context.Context, name string) error {
|
|||||||
func (UnimplementedCoreAPI) CaptureTask(ctx context.Context, req CaptureTaskReq) (CaptureTaskResp, error) {
|
func (UnimplementedCoreAPI) CaptureTask(ctx context.Context, req CaptureTaskReq) (CaptureTaskResp, error) {
|
||||||
return CaptureTaskResp{}, ErrNotImplemented
|
return CaptureTaskResp{}, ErrNotImplemented
|
||||||
}
|
}
|
||||||
|
func (UnimplementedCoreAPI) SeedEvent(ctx context.Context, req SeedEventReq) (SeedEventResp, error) {
|
||||||
|
return SeedEventResp{}, ErrNotImplemented
|
||||||
|
}
|
||||||
func (UnimplementedCoreAPI) ListTasks(ctx context.Context, status string) ([]Task, error) {
|
func (UnimplementedCoreAPI) ListTasks(ctx context.Context, status string) ([]Task, error) {
|
||||||
return nil, ErrNotImplemented
|
return nil, ErrNotImplemented
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ const (
|
|||||||
MethodListSpeakers Method = "list_speakers"
|
MethodListSpeakers Method = "list_speakers"
|
||||||
MethodForgetSpeaker Method = "forget_speaker"
|
MethodForgetSpeaker Method = "forget_speaker"
|
||||||
MethodRecentEvents Method = "recent_events"
|
MethodRecentEvents Method = "recent_events"
|
||||||
|
MethodSeedEvent Method = "seed_event"
|
||||||
|
|
||||||
// MethodPing — liveness, and the only method that answers in locked mode
|
// MethodPing — liveness, and the only method that answers in locked mode
|
||||||
// without a passkey assertion. It reaches no store, takes no arguments and
|
// without a passkey assertion. It reaches no store, takes no arguments and
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ func mustLoad() lexiconFile {
|
|||||||
for _, name := range []string{
|
for _, name := range []string{
|
||||||
"interrogatives", "capture_verbs", "narrative_requests", "cardinals",
|
"interrogatives", "capture_verbs", "narrative_requests", "cardinals",
|
||||||
"day_offsets", "weekdays", "months_genitive", "hours_spoken",
|
"day_offsets", "weekdays", "months_genitive", "hours_spoken",
|
||||||
"not_place_after_v",
|
"not_place_after_v", "parts_of_day", "reminder_verbs",
|
||||||
} {
|
} {
|
||||||
s, ok := f.Sets[name]
|
s, ok := f.Sets[name]
|
||||||
if !ok || (len(s.Words) == 0 && len(s.Values) == 0) {
|
if !ok || (len(s.Words) == 0 && len(s.Values) == 0) {
|
||||||
@@ -104,6 +104,14 @@ func FirstPerson() []string { return words("first_person") }
|
|||||||
// NotPlaceAfterV returns the words that follow "в" without naming a place.
|
// NotPlaceAfterV returns the words that follow "в" without naming a place.
|
||||||
func NotPlaceAfterV() []string { return words("not_place_after_v") }
|
func NotPlaceAfterV() []string { return words("not_place_after_v") }
|
||||||
|
|
||||||
|
// PartsOfDay returns the one-word names for a time of day: "вечером", "утром".
|
||||||
|
// They say which part of a day and never which day, so a caller that needs the
|
||||||
|
// day wants DayOffsetWords instead.
|
||||||
|
func PartsOfDay() []string { return words("parts_of_day") }
|
||||||
|
|
||||||
|
// ReminderVerbs returns the imperatives that open a reminder.
|
||||||
|
func ReminderVerbs() []string { return words("reminder_verbs") }
|
||||||
|
|
||||||
// Cardinal reports the value of a spoken number word. The word is compared
|
// 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
|
// lowercased and trimmed, because it arrives from a tokenizer that may not have
|
||||||
// done either.
|
// done either.
|
||||||
|
|||||||
@@ -38,37 +38,37 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"cardinals": {
|
"cardinals": {
|
||||||
"note": "Number words as spoken, with the gender variants Russian requires: один/одна/одно and два/две agree with the noun that follows. Values are the number itself. Twenties and up are compounds and are read as their parts, so only the round members are listed.",
|
"note": "Number words as spoken, with the gender variants Russian requires (один/одна/одно and два/две agree with the noun that follows) and the oblique forms, because a spoken time declines: \"в семь\", \"к семи\", \"около семи\" are three forms of one hour (Vikunja #530). Values are the number itself. Twenties and up are compounds and are read as their parts, so only the round members are listed.",
|
||||||
"values": {
|
"values": {
|
||||||
"ноль": 0, "нуль": 0, "zero": 0,
|
"ноль": 0, "нуль": 0, "zero": 0,
|
||||||
"один": 1, "одна": 1, "одно": 1, "one": 1,
|
"один": 1, "одна": 1, "одно": 1, "одного": 1, "одной": 1, "одну": 1, "one": 1,
|
||||||
"два": 2, "две": 2, "two": 2,
|
"два": 2, "две": 2, "двух": 2, "two": 2,
|
||||||
"три": 3, "three": 3,
|
"три": 3, "трёх": 3, "трех": 3, "three": 3,
|
||||||
"четыре": 4, "four": 4,
|
"четыре": 4, "четырёх": 4, "четырех": 4, "four": 4,
|
||||||
"пять": 5, "five": 5,
|
"пять": 5, "пяти": 5, "five": 5,
|
||||||
"шесть": 6, "six": 6,
|
"шесть": 6, "шести": 6, "six": 6,
|
||||||
"семь": 7, "seven": 7,
|
"семь": 7, "семи": 7, "seven": 7,
|
||||||
"восемь": 8, "eight": 8,
|
"восемь": 8, "восьми": 8, "eight": 8,
|
||||||
"девять": 9, "nine": 9,
|
"девять": 9, "девяти": 9, "nine": 9,
|
||||||
"десять": 10, "ten": 10,
|
"десять": 10, "десяти": 10, "ten": 10,
|
||||||
"одиннадцать": 11, "eleven": 11,
|
"одиннадцать": 11, "одиннадцати": 11, "eleven": 11,
|
||||||
"двенадцать": 12, "twelve": 12,
|
"двенадцать": 12, "двенадцати": 12, "twelve": 12,
|
||||||
"тринадцать": 13, "thirteen": 13,
|
"тринадцать": 13, "тринадцати": 13, "thirteen": 13,
|
||||||
"четырнадцать": 14, "fourteen": 14,
|
"четырнадцать": 14, "четырнадцати": 14, "fourteen": 14,
|
||||||
"пятнадцать": 15, "fifteen": 15,
|
"пятнадцать": 15, "пятнадцати": 15, "fifteen": 15,
|
||||||
"шестнадцать": 16, "sixteen": 16,
|
"шестнадцать": 16, "шестнадцати": 16, "sixteen": 16,
|
||||||
"семнадцать": 17, "seventeen": 17,
|
"семнадцать": 17, "семнадцати": 17, "seventeen": 17,
|
||||||
"восемнадцать": 18, "eighteen": 18,
|
"восемнадцать": 18, "восемнадцати": 18, "eighteen": 18,
|
||||||
"девятнадцать": 19, "nineteen": 19,
|
"девятнадцать": 19, "девятнадцати": 19, "nineteen": 19,
|
||||||
"двадцать": 20, "twenty": 20,
|
"двадцать": 20, "двадцати": 20, "twenty": 20,
|
||||||
"тридцать": 30, "thirty": 30,
|
"тридцать": 30, "тридцати": 30, "thirty": 30,
|
||||||
"сорок": 40, "forty": 40,
|
"сорок": 40, "сорока": 40, "forty": 40,
|
||||||
"пятьдесят": 50, "fifty": 50,
|
"пятьдесят": 50, "пятидесяти": 50, "fifty": 50,
|
||||||
"шестьдесят": 60, "sixty": 60,
|
"шестьдесят": 60, "шестидесяти": 60, "sixty": 60,
|
||||||
"семьдесят": 70, "seventy": 70,
|
"семьдесят": 70, "семидесяти": 70, "seventy": 70,
|
||||||
"восемьдесят": 80, "eighty": 80,
|
"восемьдесят": 80, "восьмидесяти": 80, "eighty": 80,
|
||||||
"девяносто": 90, "ninety": 90,
|
"девяносто": 90, "девяноста": 90, "ninety": 90,
|
||||||
"сто": 100, "hundred": 100
|
"сто": 100, "ста": 100, "hundred": 100
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"day_offsets": {
|
"day_offsets": {
|
||||||
@@ -134,6 +134,20 @@
|
|||||||
"сутках", "часах", "минутах", "секундах", "неделе", "месяце", "году",
|
"сутках", "часах", "минутах", "секундах", "неделе", "месяце", "году",
|
||||||
"начале", "конце", "середине", "течение", "течении"
|
"начале", "конце", "середине", "течение", "течении"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"parts_of_day": {
|
||||||
|
"note": "The times of day named as one word, in the instrumental case Russian uses for when something happens. A day has as many parts as it has, so this set is finished. They are not day offsets: \"вечером\" says which part of a day, never which day (Vikunja #530).",
|
||||||
|
"words": [
|
||||||
|
"утром", "днём", "днем", "вечером", "ночью",
|
||||||
|
"morning", "afternoon", "evening", "night"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"reminder_verbs": {
|
||||||
|
"note": "The imperatives that mean \"remind me\", in the forms he speaks. The same kind of set as capture_verbs and decided the same way: it is her vocabulary, not a discovery about Russian (Vikunja #530).",
|
||||||
|
"words": [
|
||||||
|
"напомни", "напомните", "напомнить", "напоминай",
|
||||||
|
"remind"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ func (t *NudgeTemplates) PhraseNudge(_ context.Context, c loop.Candidate) (deliv
|
|||||||
// template fits it uses the plain per-rule fallback.
|
// template fits it uses the plain per-rule fallback.
|
||||||
func (t *NudgeTemplates) Nudge(c loop.Candidate) (body, mood string) {
|
func (t *NudgeTemplates) Nudge(c loop.Candidate) (body, mood string) {
|
||||||
rule := c.Rule.Name
|
rule := c.Rule.Name
|
||||||
family := t.family(rule)
|
family := t.pluralFamily(t.family(rule), c)
|
||||||
set, ok := t.file.Rules[family]
|
set, ok := t.file.Rules[family]
|
||||||
if !ok {
|
if !ok {
|
||||||
return fallbackNudge(c), "neutral"
|
return fallbackNudge(c), "neutral"
|
||||||
@@ -155,6 +155,25 @@ func (t *NudgeTemplates) family(rule string) string {
|
|||||||
return "default"
|
return "default"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// pluralFamily swaps in the plural wording when {service} will hold a list.
|
||||||
|
// Russian agrees the verb with the subject, so one set of templates cannot
|
||||||
|
// serve both: "Сервис paperless не отвечает" and "Сервисы nginx, paperless не
|
||||||
|
// отвечают" differ in the noun, the verb and the adjective. Filling a list into
|
||||||
|
// the singular text is the kind of near-miss that reads as machine-written.
|
||||||
|
//
|
||||||
|
// Only service_down has a plural form today. A family with no "_many" set in
|
||||||
|
// the file is returned unchanged, so adding one is a data change.
|
||||||
|
func (t *NudgeTemplates) pluralFamily(family string, c loop.Candidate) string {
|
||||||
|
if len(loop.DownServices(c.State)) < 2 {
|
||||||
|
return family
|
||||||
|
}
|
||||||
|
many := family + "_many"
|
||||||
|
if _, ok := t.file.Rules[many]; ok {
|
||||||
|
return many
|
||||||
|
}
|
||||||
|
return family
|
||||||
|
}
|
||||||
|
|
||||||
// placeholderRE — the {name} slots a template may use.
|
// placeholderRE — the {name} slots a template may use.
|
||||||
var placeholderRE = regexp.MustCompile(`\{([a-z]+)\}`)
|
var placeholderRE = regexp.MustCompile(`\{([a-z]+)\}`)
|
||||||
|
|
||||||
@@ -165,17 +184,25 @@ func nudgeValues(c loop.Candidate) map[string]string {
|
|||||||
vals := map[string]string{}
|
vals := map[string]string{}
|
||||||
rule := c.Rule.Name
|
rule := c.Rule.Name
|
||||||
|
|
||||||
|
// {service} — one fact per kuma monitor, keyed "service_down:<name>", so
|
||||||
|
// the name lives in the key SUFFIX and there is no fact called plain
|
||||||
|
// "service_down" to read. loop.DownServices is the same helper the rule
|
||||||
|
// fired on, which is what stops the message naming a service that is up.
|
||||||
|
// This used to read c.State.Fact(rule) — the pre-per-monitor aggregate —
|
||||||
|
// and so never filled, leaving the one nameless variant as the only
|
||||||
|
// fillable template every time (Vikunja #534).
|
||||||
|
if down := loop.DownServices(c.State); len(down) > 0 {
|
||||||
|
vals["service"] = strings.Join(down, ", ")
|
||||||
|
}
|
||||||
// {since} — only at hour scale. Below an hour the phrase would be minutes,
|
// {since} — only at hour scale. Below an hour the phrase would be minutes,
|
||||||
// and none of the templates read well with "сорок минут".
|
// and none of the templates read well with "сорок минут". service_down has
|
||||||
|
// no {since} to offer: its facts are keyed by monitor, and the rule is
|
||||||
|
// edge-triggered, so it fires on the transition rather than hours later.
|
||||||
if d, ok := c.State.Since(rule); ok && d >= time.Hour {
|
if d, ok := c.State.Since(rule); ok && d >= time.Hour {
|
||||||
if s := ruSinceWords(d); s != "" {
|
if s := ruSinceWords(d); s != "" {
|
||||||
vals["since"] = s
|
vals["since"] = s
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// {service} — the aggregate fact's key carries the service name.
|
|
||||||
if f, ok := c.State.Fact(rule); ok && f.Key != "" && f.Key != rule {
|
|
||||||
vals["service"] = f.Key
|
|
||||||
}
|
|
||||||
// {what} — the Russian suffix of "routine:таблетки" / "morning:утро".
|
// {what} — the Russian suffix of "routine:таблетки" / "morning:утро".
|
||||||
if i := strings.IndexByte(rule, ':'); i > 0 && i+1 < len(rule) {
|
if i := strings.IndexByte(rule, ':'); i > 0 && i+1 < len(rule) {
|
||||||
vals["what"] = rule[i+1:]
|
vals["what"] = rule[i+1:]
|
||||||
|
|||||||
@@ -11,6 +11,102 @@ import (
|
|||||||
"github.com/kami/maven/internal/store"
|
"github.com/kami/maven/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// downCand builds a service_down candidate the way a tick actually does it:
|
||||||
|
// one fact per kuma monitor under the prefix, carrying the source and value
|
||||||
|
// loop.DownServices checks. The old cand() shape wrote a single fact keyed
|
||||||
|
// plain "service_down", which mavpoll stopped producing, and that is why the
|
||||||
|
// tests passed through the whole of #534.
|
||||||
|
func downCand(names ...string) loop.Candidate {
|
||||||
|
now := time.Date(2026, 7, 31, 21, 40, 0, 0, time.UTC)
|
||||||
|
st := loop.State{Now: now, Facts: map[string]store.Fact{}}
|
||||||
|
for _, n := range names {
|
||||||
|
key := loop.ServiceDownPrefix + n
|
||||||
|
st.Facts[key] = store.Fact{
|
||||||
|
Key: key, Ts: now.Add(-3 * time.Minute),
|
||||||
|
Source: loop.ServiceDownSource, Value: `"down"`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return loop.Candidate{
|
||||||
|
Rule: loop.Rule{Name: "service_down", Severity: loop.Sev4},
|
||||||
|
Severity: loop.Sev4, State: st,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The nudge he reads on telegram must name what broke. It is a sev4 that
|
||||||
|
// reaches him away from the box, so "a service is down" costs him a trip to
|
||||||
|
// kuma to learn anything at all.
|
||||||
|
func TestNudgeNamesTheDownService(t *testing.T) {
|
||||||
|
// Lowercased before matching: a name that opens the sentence is
|
||||||
|
// capitalized by capitalizeFirst, which is wanted.
|
||||||
|
nt := newTestTemplates(t, 5)
|
||||||
|
for i := 0; i < 40; i++ {
|
||||||
|
body, _ := nt.Nudge(downCand("paperless"))
|
||||||
|
if !strings.Contains(strings.ToLower(body), "paperless") {
|
||||||
|
t.Fatalf("body does not name the service: %q", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Two down: both named, in the key order the rule itself uses.
|
||||||
|
for i := 0; i < 40; i++ {
|
||||||
|
body, _ := nt.Nudge(downCand("nginx", "paperless"))
|
||||||
|
low := strings.ToLower(body)
|
||||||
|
if !strings.Contains(low, "nginx") || !strings.Contains(low, "paperless") {
|
||||||
|
t.Fatalf("body drops a service: %q", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Russian agrees the verb with the subject, so a list of services cannot go
|
||||||
|
// into the singular sentence. One down takes the singular set, two or more
|
||||||
|
// take service_down_many.
|
||||||
|
func TestNudgeAgreesWithTheServiceCount(t *testing.T) {
|
||||||
|
nt := newTestTemplates(t, 9)
|
||||||
|
// "упал " keeps its trailing space: "упали" starts with "упал", and the
|
||||||
|
// plural must not read as the singular by prefix.
|
||||||
|
singular := []string{"не отвечает", "недоступен", "лежит", "упал "}
|
||||||
|
plural := []string{"не отвечают", "недоступны", "лежат", "упали"}
|
||||||
|
|
||||||
|
for i := 0; i < 60; i++ {
|
||||||
|
body, _ := nt.Nudge(downCand("paperless"))
|
||||||
|
if !containsAny(body, singular) {
|
||||||
|
t.Fatalf("one down, no singular verb: %q", body)
|
||||||
|
}
|
||||||
|
if containsAny(body, plural) {
|
||||||
|
t.Fatalf("one down, plural wording: %q", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i := 0; i < 60; i++ {
|
||||||
|
body, _ := nt.Nudge(downCand("nginx", "paperless"))
|
||||||
|
if !containsAny(body, plural) {
|
||||||
|
t.Fatalf("two down, no plural verb: %q", body)
|
||||||
|
}
|
||||||
|
if containsAny(body, singular) {
|
||||||
|
t.Fatalf("two down, singular wording: %q", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsAny(s string, subs []string) bool {
|
||||||
|
for _, sub := range subs {
|
||||||
|
if strings.Contains(s, sub) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nothing down means no template fits, and the fallback answers rather than
|
||||||
|
// the picker inventing a name.
|
||||||
|
func TestNudgeServiceDownWithoutFacts(t *testing.T) {
|
||||||
|
nt := newTestTemplates(t, 5)
|
||||||
|
body, mood := nt.Nudge(downCand())
|
||||||
|
if body != "Сервис не отвечает." {
|
||||||
|
t.Fatalf("fallback body %q", body)
|
||||||
|
}
|
||||||
|
if mood != "neutral" {
|
||||||
|
t.Fatalf("mood %q", mood)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// cand builds a candidate the way a tick would.
|
// cand builds a candidate the way a tick would.
|
||||||
func cand(rule string, sinceMin int, factKey string) loop.Candidate {
|
func cand(rule string, sinceMin int, factKey string) loop.Candidate {
|
||||||
now := time.Date(2026, 7, 31, 21, 40, 0, 0, time.UTC)
|
now := time.Date(2026, 7, 31, 21, 40, 0, 0, time.UTC)
|
||||||
@@ -36,7 +132,7 @@ func newTestTemplates(t *testing.T, seed int64) *NudgeTemplates {
|
|||||||
|
|
||||||
func TestNudgeTemplatesLoad(t *testing.T) {
|
func TestNudgeTemplatesLoad(t *testing.T) {
|
||||||
nt := newTestTemplates(t, 1)
|
nt := newTestTemplates(t, 1)
|
||||||
for _, rule := range []string{"water", "meal", "break", "service_down", "netdata_critical", "routine", "morning", "default"} {
|
for _, rule := range []string{"water", "meal", "break", "service_down", "service_down_many", "netdata_critical", "routine", "morning", "default"} {
|
||||||
set, ok := nt.file.Rules[rule]
|
set, ok := nt.file.Rules[rule]
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Errorf("no templates for %q", rule)
|
t.Errorf("no templates for %q", rule)
|
||||||
@@ -46,8 +142,12 @@ func TestNudgeTemplatesLoad(t *testing.T) {
|
|||||||
t.Errorf("%s: only %d variants", rule, len(set.Variants))
|
t.Errorf("%s: only %d variants", rule, len(set.Variants))
|
||||||
}
|
}
|
||||||
// Every rule needs one variant that needs no value, or a candidate
|
// Every rule needs one variant that needs no value, or a candidate
|
||||||
// without context has nothing to say. routine and morning are exempt:
|
// without context has nothing to say. routine, morning and
|
||||||
// they always carry a name and must always say it.
|
// service_down are exempt: they always carry a name and must always
|
||||||
|
// say it. service_down's predicate cannot fire without a down fact,
|
||||||
|
// so loop.DownServices always has something to fill {service} with,
|
||||||
|
// and the nameless variant it used to carry was the bug (#534) —
|
||||||
|
// {service} never filled, so that variant was the only fillable one.
|
||||||
plain := 0
|
plain := 0
|
||||||
seen := map[string]bool{}
|
seen := map[string]bool{}
|
||||||
for _, v := range set.Variants {
|
for _, v := range set.Variants {
|
||||||
@@ -59,7 +159,7 @@ func TestNudgeTemplatesLoad(t *testing.T) {
|
|||||||
}
|
}
|
||||||
seen[v] = true
|
seen[v] = true
|
||||||
}
|
}
|
||||||
if plain == 0 && rule != "routine" && rule != "morning" {
|
if plain == 0 && rule != "routine" && rule != "morning" && !strings.HasPrefix(rule, "service_down") {
|
||||||
t.Errorf("%s: every variant needs a placeholder value", rule)
|
t.Errorf("%s: every variant needs a placeholder value", rule)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -102,8 +202,8 @@ func TestNudgeNoLeftoverPlaceholders(t *testing.T) {
|
|||||||
cand("water", 0, ""), // no duration
|
cand("water", 0, ""), // no duration
|
||||||
cand("water", 30, ""), // under an hour
|
cand("water", 30, ""), // under an hour
|
||||||
cand("water", 200, ""), // hours
|
cand("water", 200, ""), // hours
|
||||||
cand("service_down", 3, "vaultwarden"),
|
downCand("vaultwarden"),
|
||||||
cand("service_down", 3, ""), // no service name
|
downCand(), // nothing down: the fallback answers
|
||||||
cand("routine:таблетки", 0, ""),
|
cand("routine:таблетки", 0, ""),
|
||||||
cand("morning:утро", 0, ""),
|
cand("morning:утро", 0, ""),
|
||||||
cand("unknown_rule", 0, ""),
|
cand("unknown_rule", 0, ""),
|
||||||
|
|||||||
@@ -5,7 +5,8 @@
|
|||||||
"Hand-written Russian nudges. Edit the wording here, no Go changes needed.",
|
"Hand-written Russian nudges. Edit the wording here, no Go changes needed.",
|
||||||
"Rules: she is feminine about herself, he is a man addressed as ты. Never вы/вас/ваш, never plural imperatives (выпейте), never он/его about him.",
|
"Rules: she is feminine about herself, he is a man addressed as ты. Never вы/вас/ваш, never plural imperatives (выпейте), never он/его about him.",
|
||||||
"One short sentence. No questions, no emoji, no pet names, no emotional support.",
|
"One short sentence. No questions, no emoji, no pet names, no emotional support.",
|
||||||
"Placeholders: {since} how long it has been (only used when it is at least an hour), {service} the service name, {what} the routine name. A variant whose placeholder has no value is skipped, so every rule needs at least one variant with no placeholder. The exception is routine and morning: those only exist for rules like routine:таблетки that always carry a name, and a routine nudge that drops the name is useless.",
|
"Placeholders: {since} how long it has been (only used when it is at least an hour), {service} the service name, {what} the routine name. A variant whose placeholder has no value is skipped, so every rule needs at least one variant with no placeholder. The exception is routine, morning and service_down: those only exist for rules that always carry a name, and one that drops the name is useless.",
|
||||||
|
"A rule may carry a second set named <rule>_many, used when {service} holds more than one name. Russian agrees the verb with the subject, so the plural needs its own wording rather than a list dropped into the singular sentence. Only service_down has one.",
|
||||||
"mood must be one of: neutral, happy, thinking, tired, confused."
|
"mood must be one of: neutral, happy, thinking, tired, confused."
|
||||||
],
|
],
|
||||||
"rules": {
|
"rules": {
|
||||||
@@ -62,13 +63,24 @@
|
|||||||
"{service} не отвечает, сервис нужно поднимать.",
|
"{service} не отвечает, сервис нужно поднимать.",
|
||||||
"Сервис {service} недоступен.",
|
"Сервис {service} недоступен.",
|
||||||
"Проверь {service}: сервис не отвечает.",
|
"Проверь {service}: сервис не отвечает.",
|
||||||
"Сервис перестал отвечать.",
|
|
||||||
"Сервис {service} лежит, нужно смотреть.",
|
"Сервис {service} лежит, нужно смотреть.",
|
||||||
"{service} не отвечает уже {since}.",
|
|
||||||
"Мониторинг сообщает: {service} лежит.",
|
"Мониторинг сообщает: {service} лежит.",
|
||||||
"Сервис {service} не отвечает, посмотри логи."
|
"Сервис {service} не отвечает, посмотри логи."
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"service_down_many": {
|
||||||
|
"mood": "neutral",
|
||||||
|
"variants": [
|
||||||
|
"Сервисы {service} не отвечают.",
|
||||||
|
"{service} упали — сервисы не отвечают.",
|
||||||
|
"{service} не отвечают, сервисы нужно поднимать.",
|
||||||
|
"Сервисы {service} недоступны.",
|
||||||
|
"Проверь {service}: сервисы не отвечают.",
|
||||||
|
"Сервисы {service} лежат, нужно смотреть.",
|
||||||
|
"Мониторинг сообщает: {service} лежат.",
|
||||||
|
"Сервисы {service} не отвечают, посмотри логи."
|
||||||
|
]
|
||||||
|
},
|
||||||
"netdata_critical": {
|
"netdata_critical": {
|
||||||
"mood": "neutral",
|
"mood": "neutral",
|
||||||
"variants": [
|
"variants": [
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ func narrativeRouter(t *testing.T) *Router {
|
|||||||
r.grammars = append(r.grammars, AgendaQueryGrammars()...)
|
r.grammars = append(r.grammars, AgendaQueryGrammars()...)
|
||||||
r.grammars = append(r.grammars, TaskListGrammar())
|
r.grammars = append(r.grammars, TaskListGrammar())
|
||||||
r.grammars = append(r.grammars, TaskCaptureGrammar())
|
r.grammars = append(r.grammars, TaskCaptureGrammar())
|
||||||
r.grammars = append(r.grammars, NarrativeQueryGrammars()[1])
|
r.grammars = append(r.grammars, NarrativeQueryGrammars()...)
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+28
-32
@@ -1,38 +1,34 @@
|
|||||||
package router
|
package router
|
||||||
|
|
||||||
import "strings"
|
import (
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
// ruNumerals — spoken numbers as digits, for the clock hours and the minutes
|
"github.com/kami/maven/internal/lexicon"
|
||||||
// that follow them. Every case ending he might say is listed rather than
|
)
|
||||||
// stemmed: "в семь", "к семи", "около семи" are three forms of one hour, and a
|
|
||||||
// prefix rule short enough to cover them also matches "семья".
|
// hourNouns — the two words that are the hour noun as often as they are the
|
||||||
|
// number one. "в час дня" means one o'clock, so rewriting it to "в 1 дня" is
|
||||||
|
// right either way.
|
||||||
//
|
//
|
||||||
// Stops at thirty, which is as far as a spoken time goes ("без двадцати
|
// They are not cardinals and do not belong in the lexicon's number set: nobody
|
||||||
// восемь", "в половине шестого"). Anything larger is said in digits.
|
// counts "час яблок". Everything else this file reads comes from
|
||||||
var ruNumerals = map[string]string{
|
// lexicon.Cardinal, which is where the number words live complete, oblique forms
|
||||||
"один": "1", "одного": "1", "одну": "1", "час": "1", "часу": "1",
|
// included (Vikunja #530). The table here used to be a second copy that stopped
|
||||||
"два": "2", "две": "2", "двух": "2",
|
// at fifty and disagreed with the lexicon about its own members.
|
||||||
"три": "3", "трёх": "3", "трех": "3",
|
var hourNouns = map[string]string{"час": "1", "часу": "1"}
|
||||||
"четыре": "4", "четырёх": "4", "четырех": "4",
|
|
||||||
"пять": "5", "пяти": "5",
|
// numeralDigit reports the digits a spoken number is written as, for a clock
|
||||||
"шесть": "6", "шести": "6",
|
// hour or the minutes after it.
|
||||||
"семь": "7", "семи": "7",
|
func numeralDigit(word string) (string, bool) {
|
||||||
"восемь": "8", "восьми": "8",
|
if d, ok := hourNouns[word]; ok {
|
||||||
"девять": "9", "девяти": "9",
|
return d, true
|
||||||
"десять": "10", "десяти": "10",
|
}
|
||||||
"одиннадцать": "11", "одиннадцати": "11",
|
n, ok := lexicon.Cardinal(word)
|
||||||
"двенадцать": "12", "двенадцати": "12",
|
if !ok {
|
||||||
"тринадцать": "13", "тринадцати": "13",
|
return "", false
|
||||||
"четырнадцать": "14", "четырнадцати": "14",
|
}
|
||||||
"пятнадцать": "15", "пятнадцати": "15",
|
return strconv.Itoa(n), true
|
||||||
"шестнадцать": "16", "шестнадцати": "16",
|
|
||||||
"семнадцать": "17", "семнадцати": "17",
|
|
||||||
"восемнадцать": "18", "восемнадцати": "18",
|
|
||||||
"девятнадцать": "19", "девятнадцати": "19",
|
|
||||||
"двадцать": "20", "двадцати": "20",
|
|
||||||
"тридцать": "30", "тридцати": "30",
|
|
||||||
"сорок": "40", "сорока": "40",
|
|
||||||
"пятьдесят": "50", "пятидесяти": "50",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// numeralContext — the words that make a numeral a time. A numeral is only
|
// numeralContext — the words that make a numeral a time. A numeral is only
|
||||||
@@ -67,7 +63,7 @@ func SpellOutDigits(text string) string {
|
|||||||
copy(out, toks)
|
copy(out, toks)
|
||||||
for i, tok := range toks {
|
for i, tok := range toks {
|
||||||
key := strings.ToLower(strings.Trim(tok, ".,!?;:«»\"'"))
|
key := strings.ToLower(strings.Trim(tok, ".,!?;:«»\"'"))
|
||||||
digit, ok := ruNumerals[key]
|
digit, ok := numeralDigit(key)
|
||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-17
@@ -230,27 +230,24 @@ func AgendaQueryGrammars() []Grammar {
|
|||||||
// herself, and the query chain has no source for either.
|
// herself, and the query chain has no source for either.
|
||||||
var chatNarrativeTopics = regexp.MustCompile(`(?i)(анекдот|шутк|сказк|истори[юи]\s+на\s+ночь|о\s+себе|про\s+себя|о\s+нас|про\s+нас)`)
|
var chatNarrativeTopics = regexp.MustCompile(`(?i)(анекдот|шутк|сказк|истори[юи]\s+на\s+ночь|о\s+себе|про\s+себя|о\s+нас|про\s+нас)`)
|
||||||
|
|
||||||
// NarrativeQueryGrammars — stage-0 grammars for the two question shapes that
|
// NarrativeQueryGrammars — the stage-0 grammar for "расскажи про X", a question
|
||||||
// carry no question mark and no interrogative, and so reached the resident
|
// shape that carries no question mark and no interrogative, and so reached the
|
||||||
// model with nothing deterministic in front of them (Vikunja #498).
|
// resident model with nothing deterministic in front of it (Vikunja #498).
|
||||||
//
|
//
|
||||||
// Both were routed IntentFact by the model. The fact gate catches the write and
|
// The model routed it IntentFact. The fact gate catches the write and re-runs
|
||||||
// re-runs the turn as a query, so nothing breaks today; what they cost is a full
|
// the turn as a query, so nothing broke; what it cost is a full model round trip
|
||||||
// model round trip to reach a decision two patterns can make offline, and a
|
// to reach a decision one pattern makes offline, and a wrong row on the routing
|
||||||
// wrong row on the routing fixture.
|
// fixture.
|
||||||
//
|
//
|
||||||
// Wired after the agenda grammars, which is where their overlap resolves:
|
// It held a second grammar named rest-of-day-query until V-530. fe489df merged
|
||||||
// "расскажи, что у меня сегодня" is claimed here as a query either way.
|
// task/467 into the sweep line and both sides had landed V-498, so the merge
|
||||||
|
// kept both blocks textually. buildRouter wires the agenda grammars first and
|
||||||
|
// the agenda copy claims every case this one did, so it could never fire.
|
||||||
|
//
|
||||||
|
// Wired after the agenda grammars, which is where the overlap resolves:
|
||||||
|
// "расскажи, что у меня сегодня" is claimed there as a query either way.
|
||||||
func NarrativeQueryGrammars() []Grammar {
|
func NarrativeQueryGrammars() []Grammar {
|
||||||
return []Grammar{
|
return []Grammar{
|
||||||
{
|
|
||||||
// "что дальше?" — the rest of the day. IsRestOfDayQuery already
|
|
||||||
// recognises it downstream in the query chain, but that runs after
|
|
||||||
// the routing decision, and the routing decision was fact.
|
|
||||||
Name: "rest-of-day-query",
|
|
||||||
Pattern: regexp.MustCompile(`(?i)(^|\s)(что|чего)\s+(там\s+|потом\s+)?дальше(\s|[?!.]|$)|(^|\s)what'?s?\s+next(\s|[?!.]|$)`),
|
|
||||||
Build: agendaQueryBuild,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
// "расскажи про X" — a world question phrased as an instruction.
|
// "расскажи про X" — a world question phrased as an instruction.
|
||||||
// The lexicon is narrativeRequests, already written for the
|
// The lexicon is narrativeRequests, already written for the
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"time"
|
"time"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/morph"
|
||||||
)
|
)
|
||||||
|
|
||||||
type OpenMeteoProvider struct {
|
type OpenMeteoProvider struct {
|
||||||
@@ -101,10 +104,20 @@ func (p *OpenMeteoProvider) CurrentWeather(ctx context.Context, location string)
|
|||||||
// sentence, in order. He says "какая погода в Казани", so the word arrives in
|
// sentence, in order. He says "какая погода в Казани", so the word arrives in
|
||||||
// the prepositional case and the geocoder wants the nominative (Vikunja #421).
|
// the prepositional case and the geocoder wants the nominative (Vikunja #421).
|
||||||
//
|
//
|
||||||
// Two cheap reversals cover most of what he says: a final "е" is usually a
|
// The dictionary answers first (Vikunja #530). internal/morph lemmatises
|
||||||
// nominative "а" (Москве → Москва) or nothing at all (Лондоне → Лондон), and a
|
// "Уфе" to "Уфа" and "Москве" to "Москва", which is the same question this
|
||||||
// final "и" is usually a soft sign (Казани → Казань). Indeclinable names —
|
// used to guess at by reversing endings, asked of something that knows.
|
||||||
// Тбилиси, Сочи, Осло — are already nominative and the first candidate answers.
|
//
|
||||||
|
// The reversals stay behind it, because the dictionary does not know every
|
||||||
|
// place: "Твери" and "Перми" come back unchanged, and a final "и" is usually a
|
||||||
|
// soft sign. A final "е" is usually a nominative "а" (Москве → Москва) or
|
||||||
|
// nothing at all (Лондоне → Лондон). Indeclinable names — Тбилиси, Сочи, Осло —
|
||||||
|
// are already nominative and the first candidate answers, which is why the word
|
||||||
|
// as spoken is always tried before anything derived from it.
|
||||||
|
//
|
||||||
|
// There used to be a four-rune floor here, so "Уфе" was asked as spoken and
|
||||||
|
// "Уфа" was never tried. The floor was there to stop a two-letter stem, and the
|
||||||
|
// stem length is what it now tests.
|
||||||
//
|
//
|
||||||
// Nothing here is a guess about the weather: a wrong candidate finds no city
|
// Nothing here is a guess about the weather: a wrong candidate finds no city
|
||||||
// and the caller says so. It only decides which strings are worth asking about.
|
// and the caller says so. It only decides which strings are worth asking about.
|
||||||
@@ -121,8 +134,9 @@ func locationCandidates(location string) []string {
|
|||||||
}
|
}
|
||||||
out = append(out, s)
|
out = append(out, s)
|
||||||
}
|
}
|
||||||
|
add(titleFirst(morph.Lemma(location)))
|
||||||
r := []rune(location)
|
r := []rune(location)
|
||||||
if len(r) < 4 {
|
if len(r) < 3 {
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
stem := string(r[:len(r)-1])
|
stem := string(r[:len(r)-1])
|
||||||
@@ -139,6 +153,17 @@ func locationCandidates(location string) []string {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// titleFirst restores the leading capital a place name carries. morph.Lemma
|
||||||
|
// answers lowercased, because a lemma is a dictionary entry and the dictionary
|
||||||
|
// has no opinion about proper nouns.
|
||||||
|
func titleFirst(s string) string {
|
||||||
|
r := []rune(s)
|
||||||
|
if len(r) == 0 {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return string(unicode.ToUpper(r[0])) + string(r[1:])
|
||||||
|
}
|
||||||
|
|
||||||
func (p *OpenMeteoProvider) geocode(ctx context.Context, location string) (lat, lon float64, name string, err error) {
|
func (p *OpenMeteoProvider) geocode(ctx context.Context, location string) (lat, lon float64, name string, err error) {
|
||||||
for _, cand := range locationCandidates(location) {
|
for _, cand := range locationCandidates(location) {
|
||||||
lat, lon, name, err = p.geocodeOne(ctx, cand)
|
lat, lon, name, err = p.geocodeOne(ctx, cand)
|
||||||
|
|||||||
@@ -86,14 +86,20 @@ func TestStubProvider(t *testing.T) {
|
|||||||
|
|
||||||
// TestLocationCandidates — he speaks the prepositional case and the geocoder
|
// TestLocationCandidates — he speaks the prepositional case and the geocoder
|
||||||
// wants the nominative (Vikunja #421).
|
// wants the nominative (Vikunja #421).
|
||||||
|
//
|
||||||
|
// The dictionary answers before the reversals now, so the nominative it knows
|
||||||
|
// comes second and anything derived by hand follows (V-530). "Уфе" used to fall
|
||||||
|
// under a four-rune floor and was asked as spoken, so "Уфа" was never tried.
|
||||||
func TestLocationCandidates(t *testing.T) {
|
func TestLocationCandidates(t *testing.T) {
|
||||||
cases := map[string][]string{
|
cases := map[string][]string{
|
||||||
"Москве": {"Москве", "Москва", "Москв"},
|
"Москве": {"Москве", "Москва", "Москв"},
|
||||||
"Казани": {"Казани", "Казань", "Казан"},
|
"Казани": {"Казани", "Казань", "Казан"},
|
||||||
"Лондоне": {"Лондоне", "Лондона", "Лондон"},
|
"Лондоне": {"Лондоне", "Лондон", "Лондона"},
|
||||||
"Тбилиси": {"Тбилиси", "Тбились", "Тбилис"},
|
"Тбилиси": {"Тбилиси", "Тбились", "Тбилис"},
|
||||||
"Berlin": {"Berlin"},
|
"Berlin": {"Berlin"},
|
||||||
"Уфе": {"Уфе"}, // too short to strip — asked as spoken
|
"Уфе": {"Уфе", "Уфа", "Уф"},
|
||||||
|
// The dictionary does not know it, so the soft-sign reversal answers.
|
||||||
|
"Твери": {"Твери", "Тверь", "Твер"},
|
||||||
}
|
}
|
||||||
for in, want := range cases {
|
for in, want := range cases {
|
||||||
got := locationCandidates(in)
|
got := locationCandidates(in)
|
||||||
|
|||||||
Reference in New Issue
Block a user