Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dde556a3d3 | |||
| 22edc3cdfb | |||
| 0445693a16 | |||
| c7d22858ba | |||
| 12ecc30c57 | |||
| 190cf0c794 | |||
| 6b3749f5a2 | |||
| d156be3442 | |||
| f29bc107d4 | |||
| 10975eff07 | |||
| cc48309c7c | |||
| 4534101d10 | |||
| 5b8707e21e | |||
| 76d123edf3 | |||
| 62675e8fe4 | |||
| 46acf3cba0 | |||
| 373229ab7a | |||
| 2dbf476c45 | |||
| 13cb1903a9 | |||
| 8d20efcfbb | |||
| c661f7bd1a | |||
| e5158d8828 | |||
| 07f7550931 | |||
| 8c1e457150 | |||
| 6923a983aa | |||
| 1d10c9535c | |||
| 3b3660da9a | |||
| dd699b706f |
@@ -235,7 +235,13 @@ func (h *reactiveHandler) queryFactByKey(ctx context.Context, t *queryTurn) (str
|
||||
//
|
||||
// Read-only by construction — the plan is assembled and rendered core-side and
|
||||
// nothing here schedules or announces. "что дальше?" asks for the rest of the
|
||||
// day, so that phrasing trims what has already passed.
|
||||
// day, so that phrasing trims what has already passed and reads only the next
|
||||
// morning.NextSpoken entries. Trimming alone was not enough: asked early it cuts
|
||||
// nothing, and she read 43 entries aloud in one sentence (V-618).
|
||||
//
|
||||
// "что у меня сегодня?" is a different question and is not narrowed here — it
|
||||
// carries no plan word, so IsDayPlanQuery declines it and the calendar source
|
||||
// answers the whole day.
|
||||
//
|
||||
// What surface this belongs on is still open, tracked as Vikunja #431 ("Board
|
||||
// surface: Maven holds the work board, runs the intake form, never argues").
|
||||
@@ -254,16 +260,23 @@ func (h *reactiveHandler) queryDayPlan(ctx context.Context, t *queryTurn) (strin
|
||||
}
|
||||
// Rebuild the pure plan so the rest-of-day rendering is the same code that
|
||||
// rendered the whole day — one formatter, one persona.
|
||||
p := morning.Plan{Date: plan.Date}
|
||||
//
|
||||
// The instants are put back in the asking clock's zone on the way in. They
|
||||
// arrive carrying whatever zone the core read them in — a calendar fact's Ts
|
||||
// and a reminder's FireTs are UTC out of the store — and FormatRU reads the
|
||||
// hours in the plan's own frame, so setting that frame here is what makes
|
||||
// the recital name his clock rather than the store's (V-614).
|
||||
zone := h.now().Location()
|
||||
p := morning.Plan{Date: plan.Date.In(zone)}
|
||||
for _, it := range plan.Items {
|
||||
p.Items = append(p.Items, morning.PlanEntry{
|
||||
At: it.At,
|
||||
At: it.At.In(zone),
|
||||
Text: it.Text,
|
||||
Kind: morning.PlanKind(it.Kind),
|
||||
Uncertain: it.Uncertain,
|
||||
})
|
||||
}
|
||||
return p.After(h.now()).FormatRU(), true
|
||||
return p.Next(h.now(), morning.NextSpoken).FormatRU(), true
|
||||
}
|
||||
|
||||
// habitFactWindow — how many recent SELF facts the behaviour profile is counted
|
||||
|
||||
@@ -19,9 +19,10 @@ func (h *reactiveHandler) actionReminder(ctx context.Context, dec router.Decisio
|
||||
// time wasn't parsed. Run the parser as a fallback.
|
||||
if dec.Stage == 0 && h.timeParser != nil {
|
||||
t, ok, err := h.timeParser.Parse(ctx, dec.Utterance, h.now())
|
||||
// Same gate as the extractor (V-577, V-579): a request that named
|
||||
// no hour gets asked about, never completed from the clock.
|
||||
if err == nil && ok && router.NamesAnHour(dec.Utterance) {
|
||||
// Same gate as the extractor (V-577, V-579, V-610): a request whose
|
||||
// hour was not spoken, or was spoken and not read, gets asked about
|
||||
// and is never completed from the clock.
|
||||
if err == nil && ok && router.ResolvedTheHour(dec.Utterance, t) {
|
||||
dec.Slots.Time = t
|
||||
dec.Slots.HasTime = true
|
||||
}
|
||||
|
||||
+13
-1
@@ -24,6 +24,14 @@ type pendingHexisExec struct {
|
||||
entityID string
|
||||
displayName string
|
||||
expiry time.Time
|
||||
|
||||
// correlationID — the id the proposing turn minted for this action. A
|
||||
// confirm arrives on a later turn with a context of its own, so without
|
||||
// carrying it here the execution recorded a fresh id and no causation at
|
||||
// all, and the resolve, the discovery and the thing they authorised sat in
|
||||
// the trace as unrelated calls. The contract mints one id per action, and
|
||||
// the action began when she asked.
|
||||
correlationID string
|
||||
}
|
||||
|
||||
// pendingRoutineConfirm — a proposed routine awaiting a spoken y/n to become
|
||||
@@ -144,7 +152,11 @@ func (h *reactiveHandler) confirmResolvers(ctx context.Context) []confirmResolve
|
||||
return hx != nil && !h.now().After(hx.expiry)
|
||||
},
|
||||
yes: func() string {
|
||||
return h.execHexis(ctx, hx.capabilityID, hx.capName, hx.entityID, hx.displayName)
|
||||
execCtx := ctx
|
||||
if hx.correlationID != "" {
|
||||
execCtx = withCorrelationID(execCtx, hx.correlationID)
|
||||
}
|
||||
return h.execHexis(execCtx, hx.capabilityID, hx.capName, hx.entityID, hx.displayName)
|
||||
},
|
||||
no: func() string { return phraser.C(phraser.ConfirmCancelled, nil) },
|
||||
},
|
||||
|
||||
@@ -4,12 +4,14 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/calendar"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/morning"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/store"
|
||||
@@ -111,6 +113,88 @@ func TestQueryDayPlanRestOfDayWhenNothingIsLeft(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// "что дальше?" rebuilds the plan off the wire and renders it here, and the
|
||||
// instants on it carry the zone the core read them in — a calendar fact's Ts
|
||||
// and a reminder's FireTs are UTC out of the store. Read raw, the recital named
|
||||
// the store's clock instead of his (V-614). The asking clock is three hours off
|
||||
// whatever this machine runs in, so the assertion holds under TZ=UTC too.
|
||||
func TestQueryDayPlanRestOfDayReadsHisClock(t *testing.T) {
|
||||
_, off := time.Now().Zone()
|
||||
away := time.FixedZone("away", off+3*60*60)
|
||||
stored := time.Date(2026, 8, 3, 8, 0, 0, 0, time.UTC)
|
||||
|
||||
h := &reactiveHandler{
|
||||
api: &planAPI{plan: ipc.DayPlan{
|
||||
Date: time.Date(2026, 8, 3, 0, 0, 0, 0, time.UTC),
|
||||
Items: []ipc.DayPlanItem{{At: stored, Text: "позвонить маме", Kind: "reminder"}},
|
||||
}},
|
||||
now: func() time.Time { return time.Date(2026, 8, 3, 9, 0, 0, 0, away) },
|
||||
}
|
||||
reply, ok := h.queryDayPlan(context.Background(), &queryTurn{
|
||||
dec: router.Decision{Intent: router.IntentQuery, Utterance: "что дальше?"},
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("expected the plan source to claim it")
|
||||
}
|
||||
if want := stored.In(away).Format("15:04"); !strings.Contains(reply, want) {
|
||||
t.Errorf("the reminder is not read in his clock (%s): %q", want, reply)
|
||||
}
|
||||
if bad := stored.Format("15:04"); strings.Contains(reply, bad) {
|
||||
t.Errorf("the reminder is read in the store's zone (%s): %q", bad, reply)
|
||||
}
|
||||
}
|
||||
|
||||
// The defect V-618 fixes, at the handler: asked at 04:45 the trim removes
|
||||
// nothing, because the whole day is still ahead. She read 43 entries aloud as
|
||||
// one sentence. The zone is three hours off UTC so the test also fails under
|
||||
// TZ=UTC if the rendering ever slips zones.
|
||||
func TestQueryDayPlanCapsWhatItReadsAloud(t *testing.T) {
|
||||
zone := time.FixedZone("MSK", 3*60*60)
|
||||
mid := time.Date(2026, 8, 3, 0, 0, 0, 0, zone)
|
||||
plan := ipc.DayPlan{Date: mid, Spoken: "план на 03.08.2026: …"}
|
||||
for i := 0; i < 43; i++ {
|
||||
plan.Items = append(plan.Items, ipc.DayPlanItem{
|
||||
At: mid.Add(time.Duration(345+i*20) * time.Minute), // 05:45 onward
|
||||
Text: fmt.Sprintf("пункт %d", i),
|
||||
Kind: "event",
|
||||
})
|
||||
}
|
||||
h := &reactiveHandler{api: &planAPI{plan: plan}, now: func() time.Time {
|
||||
return time.Date(2026, 8, 3, 4, 45, 0, 0, zone)
|
||||
}}
|
||||
reply, ok := h.queryDayPlan(context.Background(), &queryTurn{
|
||||
dec: router.Decision{Intent: router.IntentQuery, Utterance: "что дальше?"},
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("expected the plan source to claim it")
|
||||
}
|
||||
if n := strings.Count(reply, "пункт "); n != morning.NextSpoken {
|
||||
t.Errorf("read %d entries aloud, want %d: %q", n, morning.NextSpoken, reply)
|
||||
}
|
||||
if !strings.HasPrefix(reply, "дальше: 05:45 — пункт 0;") {
|
||||
t.Errorf("the next thing is not first: %q", reply)
|
||||
}
|
||||
// The rest is counted, not silently dropped.
|
||||
if !strings.Contains(reply, "и ещё 40 дел до конца дня.") {
|
||||
t.Errorf("the sentence hides that the day goes on: %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// "что у меня сегодня?" is the whole day and is not narrowed. It carries no
|
||||
// plan word, so the plan source declines it and the calendar listing answers —
|
||||
// asserted here beside the cap so the two questions cannot drift together.
|
||||
func TestWholeDayQuestionIsNotTheRestOfTheDay(t *testing.T) {
|
||||
if router.IsDayPlanQuery("что у меня сегодня?") {
|
||||
t.Error("the plan source claims the whole-day question")
|
||||
}
|
||||
if !router.IsDayPlanQuery("что дальше?") {
|
||||
t.Error("the plan source stopped claiming the rest-of-day question")
|
||||
}
|
||||
if router.IsRestOfDayQuery("какие планы на сегодня?") {
|
||||
t.Error("the whole-day plan question got narrowed to the rest of the day")
|
||||
}
|
||||
}
|
||||
|
||||
// A question that is not about the plan must fall through, or the plan buries
|
||||
// the calendar listing and the weather behind it.
|
||||
func TestQueryDayPlanPassesOnEverythingElse(t *testing.T) {
|
||||
|
||||
@@ -346,14 +346,24 @@ func (entityAttentionCapability) handle(ctx context.Context, h *reactiveHandler,
|
||||
})
|
||||
|
||||
var parts []string
|
||||
var spoken []string
|
||||
for _, item := range items {
|
||||
title, _ := item["title"].(string)
|
||||
if title == "" {
|
||||
continue
|
||||
}
|
||||
parts = append(parts, title)
|
||||
surfaceSpoken(ctx, px, item)
|
||||
if id := surfaceSpoken(ctx, px, item); id != "" {
|
||||
spoken = append(spoken, id)
|
||||
}
|
||||
}
|
||||
// The scoped digest is a list she read out, so it replaces the positional
|
||||
// memory exactly as the unscoped one does. It used to surface these items
|
||||
// and remember none of them, which left the previous digest live: "отметь
|
||||
// второй как сделанное" then indexed into a list he had not just heard and
|
||||
// transitioned somebody else's item (docs/ecosystem.md — a wrong guess here
|
||||
// transitions the wrong item).
|
||||
h.rememberSurfaced(spoken)
|
||||
if known := h.localFactsForEntity(ctx, entityID); known != "" {
|
||||
parts = append(parts, known)
|
||||
}
|
||||
@@ -768,6 +778,9 @@ func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decisio
|
||||
entityID: entityID,
|
||||
displayName: displayName,
|
||||
expiry: h.now().Add(confirmTTL),
|
||||
// This action's id, so the execution the confirm authorises is
|
||||
// joined to the resolve and the discovery that proposed it.
|
||||
correlationID: correlationIDFromCtx(ctx),
|
||||
}
|
||||
h.mu.Unlock()
|
||||
h.recordEcosystemTrace(ctx, "hexis", "confirmation", tracePending, started,
|
||||
|
||||
@@ -115,3 +115,83 @@ func TestFakeNexus_FaultInjectionThenRecovery(t *testing.T) {
|
||||
t.Fatalf("expected success once nexus recovers, got %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPraxisEntityAttention_RemembersWhatItReadOut: the scoped digest is a list
|
||||
// she read out, so a positional follow-up must land on one of ITS items. It
|
||||
// surfaced them and remembered none, which left the previous digest live and
|
||||
// sent "отметь второй" at somebody else's item.
|
||||
func TestPraxisEntityAttention_RemembersWhatItReadOut(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"))
|
||||
scoped := fixturePraxisAttentionScoped("ent_muzick",
|
||||
map[string]any{"id": "item_scoped_1", "title": "indexer wedged"})
|
||||
praxis := newFakePraxis(t, scoped)
|
||||
h := ecoHandler(t, nexus, praxis, nil)
|
||||
|
||||
// A digest from an earlier turn, still the positional memory.
|
||||
h.rememberSurfaced([]string{"item_stale"})
|
||||
|
||||
reply := h.handlePraxisAct(ctx, router.Decision{
|
||||
Intent: router.IntentAct,
|
||||
Slots: router.Slots{Fn: "entity_attention", HasFn: true, Value: "muzick indexer"},
|
||||
})
|
||||
if !strings.Contains(reply, "indexer wedged") {
|
||||
t.Fatalf("expected the scoped item to be read out, got %q", reply)
|
||||
}
|
||||
|
||||
h.mu.Lock()
|
||||
surfaced := append([]string(nil), h.surfacedItems...)
|
||||
h.mu.Unlock()
|
||||
if len(surfaced) != 1 || surfaced[0] != "item_scoped_1" {
|
||||
t.Fatalf("scoped digest must replace the positional memory, got %v", surfaced)
|
||||
}
|
||||
|
||||
// The follow-up resolves against what he just heard, not the stale list.
|
||||
if reply := h.handlePraxisAct(ctx, praxisItemDec("resolve_item", "last")); reply == "" {
|
||||
t.Fatal("positional follow-up should have been claimed by praxis")
|
||||
}
|
||||
var body string
|
||||
for _, r := range praxis.Requests() {
|
||||
if r.Method == "POST" && r.Path == "/api/v1/tools/resolve" {
|
||||
body = string(r.Body)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(body, "item_scoped_1") {
|
||||
t.Fatalf("resolve must transition the item she read out, posted %q", body)
|
||||
}
|
||||
if strings.Contains(body, "item_stale") {
|
||||
t.Fatal("resolve transitioned an item from a previous digest")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHexisConfirm_KeepsOneCorrelationIDPerAction: the confirm arrives on a
|
||||
// later turn with a context of its own. The contract mints one id per action,
|
||||
// so the execution it authorises must still be joinable to the resolve and the
|
||||
// discovery that proposed it — it recorded a fresh id and no causation at all.
|
||||
func TestHexisConfirm_KeepsOneCorrelationIDPerAction(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"))
|
||||
caps := fixtureHexisCapabilities(map[string]any{"id": "cap_restart", "name": "restart", "read_only": false})
|
||||
hexis := newFakeHexis(t, caps, fixtureHexisExecuted("exec_1", "succeeded"))
|
||||
h := ecoHandler(t, nexus, nil, hexis)
|
||||
|
||||
if reply := h.handleHexisAct(ctx, actDec("restart")); !strings.Contains(reply, "да") {
|
||||
t.Fatalf("mutating capability must ask for confirmation, got %q", reply)
|
||||
}
|
||||
resolve := findTrace(t, h, "nexus", "resolve")
|
||||
if resolve == nil || resolve.CorrelationID == "" {
|
||||
t.Fatalf("expected a nexus resolve trace carrying a correlation id, got %+v", resolve)
|
||||
}
|
||||
|
||||
if _, handled := h.resolveConfirm(ctx, "да"); !handled {
|
||||
t.Fatal("confirm should have been claimed")
|
||||
}
|
||||
exec := findTrace(t, h, "hexis", "execute")
|
||||
if exec == nil {
|
||||
t.Fatal("expected a hexis execute trace")
|
||||
}
|
||||
if exec.CausationID != resolve.CorrelationID {
|
||||
t.Fatalf("confirmed execution must cite the action that proposed it: causation %q, action %q",
|
||||
exec.CausationID, resolve.CorrelationID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,6 +154,11 @@ func hasDurationWords(u string) bool {
|
||||
// Used by the query handler when answering "когда я это сделал?"-style questions.
|
||||
func formatTime(t time.Time) string {
|
||||
now := time.Now()
|
||||
// The argument is a fact's Ts, which the store hands back as UTC. Only the
|
||||
// last branch names a wall clock, and it named the store's until V-614: an
|
||||
// answer to "когда я это сделал?" read hours off, in the same sentence
|
||||
// shape the plan reads a day in.
|
||||
t = t.Local()
|
||||
if t.After(now.Add(-2*time.Minute)) && t.Before(now.Add(2*time.Minute)) {
|
||||
return "только что"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,28 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// "когда я это сделал?" answers off a fact's Ts, which the store hands back as
|
||||
// UTC, and the branch that names a wall clock printed it in whatever zone it
|
||||
// arrived in (V-614). The instant here is built three hours off this machine's
|
||||
// zone, so the assertion holds under TZ=UTC as well.
|
||||
func TestFormatTimeReadsHisClock(t *testing.T) {
|
||||
_, off := time.Now().Zone()
|
||||
away := time.FixedZone("away", off+3*60*60)
|
||||
stored := time.Now().Add(-72 * time.Hour).In(away)
|
||||
|
||||
got := formatTime(stored)
|
||||
if want := stored.Local().Format("15:04"); !strings.Contains(got, want) {
|
||||
t.Errorf("formatTime = %q, want the hour on his clock (%s)", got, want)
|
||||
}
|
||||
if bad := stored.Format("15:04"); strings.Contains(got, bad) {
|
||||
t.Errorf("formatTime = %q reads the zone the fact arrived in (%s)", got, bad)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMentionsUnknownDayReadsWordsNotStems — the defect V-581 found. The
|
||||
// weekday half of this guard was a list of stems matched with strings.Contains,
|
||||
|
||||
+34
-1
@@ -77,6 +77,21 @@ func run(args []string) error {
|
||||
if *netdataURL == "" && *kumaURL == "" && *wgIface == "" && *zenTokenFile == "" {
|
||||
return fmt.Errorf("nothing to poll: set -netdata, -kuma, -wg and/or -zenmoney-token-file")
|
||||
}
|
||||
// A bad duration or an empty -wg-cmd used to get past start and kill the
|
||||
// poller on the first tick — time.NewTicker panics on a non-positive
|
||||
// interval, and pollWg indexed field 0 of an empty command. A zero -timeout
|
||||
// is worse than a crash: http.Client reads it as "no deadline", so one
|
||||
// wedged source stalls every other source behind it forever. Refuse all
|
||||
// three here, where the operator sees the message.
|
||||
if *interval <= 0 {
|
||||
return fmt.Errorf("-interval must be positive, got %s", *interval)
|
||||
}
|
||||
if *timeout <= 0 {
|
||||
return fmt.Errorf("-timeout must be positive, got %s", *timeout)
|
||||
}
|
||||
if *wgIface != "" && strings.TrimSpace(*wgCmd) == "" {
|
||||
return fmt.Errorf("-wg-cmd is empty but -wg is set")
|
||||
}
|
||||
|
||||
zen, err := newZenClient(*zenTokenFile, *zenURL, *timeout)
|
||||
if err != nil {
|
||||
@@ -283,9 +298,19 @@ const (
|
||||
// `wg show` needs CAP_NET_ADMIN; run mavpoll with the cap or set -wg-cmd "sudo wg".
|
||||
func (p *poller) pollWg(ctx context.Context) error {
|
||||
fields := strings.Fields(p.wgCmd)
|
||||
if len(fields) == 0 {
|
||||
return fmt.Errorf("wg command is empty")
|
||||
}
|
||||
args := append(fields[1:], "show", p.wgIface, "latest-handshakes")
|
||||
out, err := exec.CommandContext(ctx, fields[0], args...).Output()
|
||||
if err != nil {
|
||||
// wg says why it refused on stderr — usually a missing CAP_NET_ADMIN or
|
||||
// an interface that does not exist. Output() drops that, leaving a log
|
||||
// line that reads "exit status 1" and diagnoses nothing.
|
||||
var ee *exec.ExitError
|
||||
if errors.As(err, &ee) && len(ee.Stderr) > 0 {
|
||||
return fmt.Errorf("run %s: %w: %s", p.wgCmd, err, strings.TrimSpace(string(ee.Stderr)))
|
||||
}
|
||||
return fmt.Errorf("run %s: %w", p.wgCmd, err)
|
||||
}
|
||||
maxTs := parseMaxHandshake(string(out))
|
||||
@@ -552,6 +577,11 @@ func isNoFact(err error) bool {
|
||||
|
||||
// maxBodyBytes caps what a source can make the poller hold. Kuma's whole
|
||||
// metrics page is a few hundred kilobytes, so 4 MiB is slack, not a budget.
|
||||
//
|
||||
// Hitting the cap is an error, not a shorter body. A truncated kuma page parses
|
||||
// cleanly right up to the cut, and every monitor past it reads as deleted — the
|
||||
// poller would write "unknown" over live services and the down-rule would go
|
||||
// quiet. Reading one byte past the cap is how we tell full from truncated.
|
||||
const maxBodyBytes = 4 << 20
|
||||
|
||||
func (p *poller) get(ctx context.Context, url, basicUser string) ([]byte, error) {
|
||||
@@ -567,12 +597,15 @@ func (p *poller) get(ctx context.Context, url, basicUser string) ([]byte, error)
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, maxBodyBytes))
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, maxBodyBytes+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("GET %s: %s", url, resp.Status)
|
||||
}
|
||||
if len(body) > maxBodyBytes {
|
||||
return nil, fmt.Errorf("GET %s: body over %d bytes", url, maxBodyBytes)
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
@@ -212,3 +213,59 @@ func TestRunRequiresSomethingToPoll(t *testing.T) {
|
||||
t.Errorf("err = %v, want a 'nothing to poll' refusal", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A flag value that would kill the poller later is refused at start, before it
|
||||
// dials core: a non-positive interval panics time.NewTicker on the first tick, a
|
||||
// zero timeout means http.Client waits forever, and an empty wg command used to
|
||||
// index field 0 of an empty slice.
|
||||
func TestRunRefusesFlagsThatCrashLater(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{"zero interval", []string{"-interval", "0"}, "-interval must be positive"},
|
||||
{"negative interval", []string{"-interval", "-5s"}, "-interval must be positive"},
|
||||
{"zero timeout", []string{"-timeout", "0"}, "-timeout must be positive"},
|
||||
{"empty wg command", []string{"-wg", "wg0", "-wg-cmd", " "}, "-wg-cmd is empty"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
args := append([]string{"-socket", "/tmp/nope.sock"}, c.args...)
|
||||
err := run(args)
|
||||
if err == nil || !strings.Contains(err.Error(), c.want) {
|
||||
t.Errorf("err = %v, want %q", err, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// pollWg refuses an empty command rather than panicking on fields[0].
|
||||
func TestPollWgEmptyCommand(t *testing.T) {
|
||||
p := &poller{core: &factCore{}, wgIface: "wg0", wgCmd: ""}
|
||||
if err := p.pollWg(context.Background()); err == nil {
|
||||
t.Error("want an error, got a poll that ran something")
|
||||
}
|
||||
}
|
||||
|
||||
// A body at the cap is a truncated body, and a truncated kuma page reads as
|
||||
// "every monitor past the cut was deleted". Refuse it instead of parsing it.
|
||||
func TestGetRefusesTruncatedBody(t *testing.T) {
|
||||
big := strings.Repeat("x", maxBodyBytes+64)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
io.WriteString(w, big)
|
||||
}))
|
||||
defer srv.Close()
|
||||
p := &poller{http: srv.Client()}
|
||||
if _, err := p.get(context.Background(), srv.URL, ""); err == nil {
|
||||
t.Error("want an over-size refusal, got a silently truncated body")
|
||||
}
|
||||
small := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
io.WriteString(w, "ok")
|
||||
}))
|
||||
defer small.Close()
|
||||
body, err := p.get(context.Background(), small.URL, "")
|
||||
if err != nil || string(body) != "ok" {
|
||||
t.Errorf("get = %q, %v; want the whole small body", body, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,8 +14,13 @@ memory only, so a restart empties this.</div>
|
||||
<div class=scroll><table class=mono>
|
||||
<tr><th>noticed<th>happened<th>source<th>kind<th>pri<th>what<th>detail</tr>
|
||||
{{range .Events}}<tr>
|
||||
<td>{{.NoticedAt.Format "02.01 15:04:05"}}</td>
|
||||
<td class=gray>{{.OccurredAt.Format "02.01 15:04:05"}}</td>
|
||||
<!-- Both columns in his clock (V-469 on /reminders, same rule here). NoticedAt
|
||||
is the bus's local instant, OccurredAt is whatever zone the source used —
|
||||
the store hands back UTC and internal/rss parses a pubDate to UTC — so
|
||||
rendering them raw put two zones side by side in the same row and made a
|
||||
feed item look hours older than it was. -->
|
||||
<td>{{.NoticedAt.Local.Format "02.01 15:04:05"}}</td>
|
||||
<td class=gray>{{.OccurredAt.Local.Format "02.01 15:04:05"}}</td>
|
||||
<td class=gray>{{.Source}}</td>
|
||||
<td class=gray>{{.Kind}}</td>
|
||||
<td class=gray>{{.Priority}}</td>
|
||||
|
||||
@@ -46,7 +46,8 @@ func TestEventsPageRendersTheJournal(t *testing.T) {
|
||||
t.Fatalf("status = %d, want 200", w.Code)
|
||||
}
|
||||
body := w.Body.String()
|
||||
for _, want := range []string{"rss:tech", "Вышло ядро 6.19", "ambient:notif", "10:00-11:00 планёрка", "01.08 10:00:00"} {
|
||||
occurred := time.Date(2026, 8, 1, 10, 0, 0, 0, time.UTC).Local().Format("02.01 15:04:05")
|
||||
for _, want := range []string{"rss:tech", "Вышло ядро 6.19", "ambient:notif", "10:00-11:00 планёрка", occurred} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("page does not mention %q", want)
|
||||
}
|
||||
@@ -89,6 +90,38 @@ func TestEventsPageWithoutCore(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// awayFromLocal returns a zone three hours off whatever this machine runs in,
|
||||
// so a test can tell "rendered in his clock" apart from "rendered in whatever
|
||||
// zone the value arrived in" without depending on TZ.
|
||||
func awayFromLocal() *time.Location {
|
||||
_, off := time.Now().Zone()
|
||||
return time.FixedZone("away", off+3*60*60)
|
||||
}
|
||||
|
||||
func TestEventsPageRendersBothTimesInLocalZone(t *testing.T) {
|
||||
// OccurredAt carries the source's zone — the store hands back UTC and
|
||||
// internal/rss parses a pubDate to UTC — while NoticedAt is the bus's local
|
||||
// instant. Rendered raw, the two columns of one row were in two zones and a
|
||||
// feed item read hours older than it was.
|
||||
away := awayFromLocal()
|
||||
occurred := time.Date(2026, 8, 1, 7, 15, 0, 0, time.UTC).In(away)
|
||||
noticed := occurred.Add(2 * time.Minute)
|
||||
core := &eventsCore{events: []ipc.IntakeEvent{{
|
||||
Source: "rss:tech", Kind: "note", Title: "Вышло ядро 6.19", Priority: "low",
|
||||
OccurredAt: occurred, NoticedAt: noticed,
|
||||
}}}
|
||||
body := getEvents(t, core).Body.String()
|
||||
const layout = "02.01 15:04:05"
|
||||
for _, ts := range []time.Time{occurred, noticed} {
|
||||
if !strings.Contains(body, ts.Local().Format(layout)) {
|
||||
t.Errorf("page does not render %s in his clock (%s)", ts, ts.Local().Format(layout))
|
||||
}
|
||||
if strings.Contains(body, ts.In(away).Format(layout)) {
|
||||
t.Errorf("page rendered %s in the source's zone", ts)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventsPageEscapesIntakeText(t *testing.T) {
|
||||
// Titles come from outside — a feed headline, a notification. They are shown
|
||||
// on a page and must never be able to inject markup into it.
|
||||
|
||||
@@ -8,7 +8,10 @@
|
||||
<div class=scroll><table class=mono>
|
||||
<tr><th>at<th>kind<th>what</tr>
|
||||
{{range .Items}}<tr>
|
||||
<td>{{.At.Format "15:04"}}</td>
|
||||
<!-- In his clock. A plan item's At is a calendar fact's Ts or a reminder's
|
||||
FireTs, and the store hands both back as UTC, so the raw hour printed a
|
||||
reminder here at an hour /reminders did not agree with (V-469). -->
|
||||
<td>{{.At.Local.Format "15:04"}}</td>
|
||||
<td class=gray>{{.Kind}}</td>
|
||||
<td>{{if .Uncertain}}<span class=hint title="relayed notification, not a calendar read">похоже,</span> {{end}}{{.Text}}</td>
|
||||
</tr>{{end}}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
)
|
||||
|
||||
// morningCore serves a canned checklist and day plan.
|
||||
type morningCore struct {
|
||||
ipc.UnimplementedCoreAPI
|
||||
status []ipc.MorningRoutineStatus
|
||||
plan ipc.DayPlan
|
||||
}
|
||||
|
||||
func (c *morningCore) MorningStatus(context.Context) ([]ipc.MorningRoutineStatus, error) {
|
||||
return c.status, nil
|
||||
}
|
||||
|
||||
func (c *morningCore) DayPlan(context.Context) (ipc.DayPlan, error) { return c.plan, nil }
|
||||
|
||||
func TestMorningRendersPlanTimesInLocalZone(t *testing.T) {
|
||||
// A plan item's At is a calendar fact's Ts or a reminder's FireTs, and the
|
||||
// store hands both back as UTC. Printed raw, /morning named an hour for a
|
||||
// reminder that /reminders — which does call Local — disagreed with.
|
||||
away := awayFromLocal()
|
||||
at := time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC).In(away)
|
||||
core := &morningCore{plan: ipc.DayPlan{
|
||||
Date: at,
|
||||
Items: []ipc.DayPlanItem{{At: at, Text: "выпить таблетки", Kind: "reminder"}},
|
||||
}}
|
||||
w := httptest.NewRecorder()
|
||||
handleMorning(w, httptest.NewRequest(http.MethodGet, "/morning", nil), core)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", w.Code)
|
||||
}
|
||||
body := w.Body.String()
|
||||
if !strings.Contains(body, at.Local().Format("15:04")) {
|
||||
t.Errorf("plan item not rendered in his clock (%s): %s", at.Local().Format("15:04"), body)
|
||||
}
|
||||
if strings.Contains(body, at.In(away).Format("15:04")) {
|
||||
t.Errorf("plan item rendered in the stored zone: %s", body)
|
||||
}
|
||||
}
|
||||
+9
-5
@@ -300,11 +300,15 @@ func promoteCandidate(ctx context.Context, core ipc.CoreAPI, r *http.Request, id
|
||||
if err := core.SetTaskFields(ctx, id, doneWhen, blockedOn); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if due != nil {
|
||||
wgt, err := formWeight(r)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
wgt, err := formWeight(r)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// The importance select is posted whether or not a date is. This ran under
|
||||
// `if due != nil`, so confirming a candidate as "срочно" with no deadline
|
||||
// dropped the word on the floor — the row came back normal and nothing said
|
||||
// why. A promote with neither field set still writes nothing.
|
||||
if due != nil || wgt != 0 {
|
||||
if err := core.EditTask(ctx, id, text, due, wgt); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
@@ -32,6 +32,31 @@ type fakeTaskCore struct {
|
||||
statusErr error
|
||||
|
||||
promoted bool
|
||||
|
||||
// The promote path's two extra writes.
|
||||
fields []any
|
||||
edits []editCall
|
||||
editErr error
|
||||
fieldErr error
|
||||
}
|
||||
|
||||
// editCall records one EditTask, so a test can say what the form actually sent
|
||||
// down rather than only that the promotion succeeded.
|
||||
type editCall struct {
|
||||
ID int64
|
||||
Text string
|
||||
Due *time.Time
|
||||
Weight int
|
||||
}
|
||||
|
||||
func (f *fakeTaskCore) EditTask(_ context.Context, id int64, text string, due *time.Time, weight int) error {
|
||||
f.edits = append(f.edits, editCall{id, text, due, weight})
|
||||
return f.editErr
|
||||
}
|
||||
|
||||
func (f *fakeTaskCore) SetTaskFields(_ context.Context, id int64, doneWhen, blockedOn string) error {
|
||||
f.fields = append(f.fields, []any{id, doneWhen, blockedOn})
|
||||
return f.fieldErr
|
||||
}
|
||||
|
||||
func (f *fakeTaskCore) ListTasks(_ context.Context, status string) ([]ipc.Task, error) {
|
||||
@@ -220,6 +245,49 @@ func TestApplyTaskPostCarriesWeight(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Confirming a candidate posts the importance select whether or not a date is
|
||||
// set. The weight write hung off `if due != nil`, so "срочно" with no deadline
|
||||
// was read off the form and thrown away, and the row came back normal.
|
||||
func TestPromoteCandidateCarriesWeightWithoutADueDate(t *testing.T) {
|
||||
core := &fakeTaskCore{}
|
||||
form := url.Values{
|
||||
"action": {"promote"}, "id": {"4"}, "text": {"продлить страховку"},
|
||||
"done_when": {"полис на руках"}, "weight": {"3"},
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/tasks", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
handleTasks(httptest.NewRecorder(), req, core)
|
||||
|
||||
if len(core.edits) != 1 {
|
||||
t.Fatalf("edits = %+v, want the weight written once", core.edits)
|
||||
}
|
||||
if core.edits[0].Weight != 3 || core.edits[0].ID != 4 {
|
||||
t.Errorf("edit = %+v, want id 4 at weight 3", core.edits[0])
|
||||
}
|
||||
if core.edits[0].Due != nil {
|
||||
t.Errorf("edit invented a due date: %v", core.edits[0].Due)
|
||||
}
|
||||
if core.statusVal != "open" {
|
||||
t.Errorf("status = %q, want the candidate promoted", core.statusVal)
|
||||
}
|
||||
}
|
||||
|
||||
// A promote with neither field set still writes nothing: the row is unchanged
|
||||
// apart from its status, and an EditTask here would be a no-op that can fail.
|
||||
func TestPromoteCandidateWithNoDateAndNoWeightDoesNotEdit(t *testing.T) {
|
||||
core := &fakeTaskCore{}
|
||||
form := url.Values{
|
||||
"action": {"promote"}, "id": {"4"}, "text": {"продлить страховку"},
|
||||
"done_when": {"полис на руках"}, "weight": {"0"},
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/tasks", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
handleTasks(httptest.NewRecorder(), req, core)
|
||||
if len(core.edits) != 0 {
|
||||
t.Errorf("edits = %+v, want none", core.edits)
|
||||
}
|
||||
}
|
||||
|
||||
// Out of range clamps rather than 400s; a non-number is a real client error.
|
||||
func TestApplyTaskPostClampsWeight(t *testing.T) {
|
||||
core := &fakeTaskCore{created: true}
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
# The fact parser: closed classes against the substring stems they replaced
|
||||
|
||||
Measured 2026-08-06 at 22edc3c and its parent 0445693, on the corpus in
|
||||
`internal/router/factparser_corpus_test.go`. Dated file: it is not edited after
|
||||
today, and a newer number is a new file.
|
||||
|
||||
V-586 rewrote `DefaultFactParser` off hand-written Russian stems onto six closed
|
||||
classes in `internal/lexicon`. Its commit message reported 64/91 on the RU
|
||||
routing fixture, unchanged. That number does not bear on the change: the fixture
|
||||
holds three fact cases and all three miss on intent, so the parser is never
|
||||
reached. This file measures the parser directly, and runs the LLM arm the
|
||||
original commit skipped.
|
||||
|
||||
**The rewrite is better on the utterances it was designed for and no worse on
|
||||
the ones it was not.** True positives go 35/40 to 39/40, misfires rejected go
|
||||
8/15 to 14/15. What neither version has is coverage: of 36 plausible utterances
|
||||
whose word is in no lexicon set, the substring parser caught 3 by accident and
|
||||
the closed-class parser catches 0. That is the honest headline. The word list
|
||||
did not shrink the vocabulary — it never had one — it made the boundary visible.
|
||||
|
||||
## The corpus
|
||||
|
||||
91 cases, three classes. **True positives** are sentences the owner would say,
|
||||
with the key that must be written. **Misfires** are sentences the substring
|
||||
parser wrote a fact for and should not have, including the three hard negatives
|
||||
the rewrite was argued on. **False negatives** are sentences a reasonable person
|
||||
would say whose word is in no set at all; `want` is the key a human would
|
||||
assign, and the ship parser is expected to miss them. They are the measurement
|
||||
of what a closed class costs, not a bug list.
|
||||
|
||||
The old parser is carried in the test file as `legacyFactParse`, copied verbatim
|
||||
from 0445693, so the comparison reruns:
|
||||
|
||||
```sh
|
||||
deps/go/go/bin/go test -run TestFactParserCorpus -v ./internal/router/
|
||||
```
|
||||
|
||||
## Score
|
||||
|
||||
| | true positives | misfires rejected | false-negative cases recovered |
|
||||
|---|---|---|---|
|
||||
| old (substring stems, 0445693) | 35/40 | 8/15 | 3/36 |
|
||||
| **new (closed classes, 22edc3c)** | **39/40** | **14/15** | **0/36** |
|
||||
|
||||
Sixteen cases disagree. Eleven of them the rewrite wins, three it loses, and two
|
||||
are cases neither gets.
|
||||
|
||||
**Won.** Every misfire the commit message named — `душа болит`, `в комнате
|
||||
душно`, `это была беда`, `наша победа`, `на душе легко` — plus `водитель пилота
|
||||
ждёт`, where two stems in one sentence made the old water arm fire. And five true
|
||||
positives the stems simply did not list: `перекусил`, `передохнул`, `отдыхаю`,
|
||||
`пойду спать`, `i showered`. Morphology buys those; a stem list would need a new
|
||||
entry for each.
|
||||
|
||||
**Lost.** `допил воду` is a real regression and the only failing true positive.
|
||||
The vendored dictionary lemmatises `допил` to `допилить`, to finish sawing —
|
||||
exactly the collision `drink_verbs` already carries `пил` and `пили` as surface
|
||||
forms to dodge, left unhandled for the prefixed form. `допить` is in the set and
|
||||
the sentence still misses. It is flagged `broken` in the corpus rather than
|
||||
fixed, because this branch measures.
|
||||
|
||||
`был в душе` and `после душа полегчало` are the price of matching the shower set
|
||||
exactly. The dictionary makes `душ` and `душа` one word, so a lemma test cannot
|
||||
tell a shower from a soul; exact matching keeps `на душе легко` out and loses
|
||||
the oblique cases of the real noun with it. The old parser got both by accident,
|
||||
along with the soul. That trade is right — writing a shower fact when he said
|
||||
his soul feels light is worse than missing one — but it is a trade and the two
|
||||
rows are what it costs.
|
||||
|
||||
`недоспал` is the third loss and the least defensible: the old substring `спал`
|
||||
caught it, and `недоспать` is in no set.
|
||||
|
||||
**Neither.** `обеденный перерыв отменили` — a cancelled lunch break — is a fact
|
||||
for both parsers, `meal` for the old one off the adjective and `break` for the
|
||||
new one off `перерыв`. Nothing in either design reads the cancellation.
|
||||
`дрых до обеда` is scored `meal` by both, because the meal arm runs first and
|
||||
`обеда` is in it, which is not wrong so much as beside the point.
|
||||
|
||||
## The false-negative surface
|
||||
|
||||
This is the half the routing fixture cannot see and the half that decides
|
||||
whether the design holds. 36 cases, 0 recovered:
|
||||
|
||||
- **water** — `выпил чаю`, `глотнул воды`, `хлебнул воды`, `выпил стакан`,
|
||||
`i hydrated`, `finished my bottle of water`. The water arm needs a noun AND a
|
||||
verb, so an elided noun or an unlisted verb drops the whole capture.
|
||||
- **meal** — `ем суп`, `съел бутерброд`, `наелся`, `пожрал`, `полдник был`,
|
||||
`snack`, `supper`, `brunch`, `i eat now`. `есть` is deliberately absent for
|
||||
`есть новости по бэкапу`, and `ем`, its most ordinary spoken form, goes with it.
|
||||
- **shower** — `помылся`, `сходил в ванную`, `искупался`, `i am showering`,
|
||||
plus the two oblique cases above.
|
||||
- **break** — `сделал передышку`, `перекур`, `полежал немного`, `сделал паузу`,
|
||||
`i took five`, `resting now`.
|
||||
- **sleep** — `вздремнул`, `прикорнул`, `дрых`, `недоспал`, `лёг в двенадцать`,
|
||||
`сон был короткий`, `i napped`, `took a nap`.
|
||||
|
||||
None of these are exotic. They are the second and third word a person reaches
|
||||
for, and every one of them is a fact the owner stated and Maven silently did not
|
||||
record. A silent miss is the worst failure mode this parser has: he said it, she
|
||||
heard it, nothing was written, and nothing told him.
|
||||
|
||||
## The routing fixture, LLM arm
|
||||
|
||||
The arm 22edc3c skipped. `MAVEN_LLM_URL` points the harness at any llama-server;
|
||||
the previous run reported none reachable, which was the shell's `HTTP_PROXY` and
|
||||
not the network. Run against **gemma-4-12B-it-qat-UD-Q4_K_XL on the workstation
|
||||
at `192.168.1.105:8080`**, the same box as the 02-08 measurement, with
|
||||
`NO_PROXY=192.168.1.105`:
|
||||
|
||||
```sh
|
||||
NO_PROXY=192.168.1.105 no_proxy=192.168.1.105 \
|
||||
make eval-models MAVEN_LLM_URL=http://192.168.1.105:8080
|
||||
```
|
||||
|
||||
| | full | intent-only | p50 |
|
||||
|---|---|---|---|
|
||||
| llm-only, 0445693 | 51.6% (47/91) | 82.4% | — |
|
||||
| llm-only, 22edc3c | 52.7% (48/91) | 83.5% | 341ms |
|
||||
| cascade+llm, 0445693 | 85.7% (78/91) | 93.4% | — |
|
||||
| **cascade+llm, 22edc3c** | **86.8% (79/91)** | **94.5%** | 334ms |
|
||||
|
||||
One case either way, both directions, and the failing set is identical between
|
||||
the two commits. That is run-to-run variance on a sampling model, not a signal.
|
||||
The parser change is invisible to the routing fixture on the LLM arm for the
|
||||
same reason it is invisible on the classifier arm: the three fact cases miss on
|
||||
intent and the parser is never called. Do not read these rows as evidence about
|
||||
the parser. They are evidence that the fixture cannot answer the question, which
|
||||
is why the corpus above exists.
|
||||
|
||||
## Verdict
|
||||
|
||||
The closed-class rewrite holds up as a rewrite. It is strictly better than what
|
||||
it replaced on both classes anyone argued about, and the one regression
|
||||
(`допил`) and one bad trade (the oblique `душ`) are both dictionary collisions
|
||||
rather than design faults.
|
||||
|
||||
It does not hold up as an answer. A closed class is the right mechanism for a
|
||||
set that is actually closed — interrogatives, weekdays, cardinals — and
|
||||
"the words a person uses to say he ate" is not that set. The corpus puts a
|
||||
number on it: 36 ordinary sentences, 0 recovered, and every new one costs a
|
||||
lexicon edit by whoever notices. The three mechanisms CLAUDE.md names do not
|
||||
contain the right one for this job. The embedder-topic mechanism is the closest
|
||||
fit and is wrong too, because this is slot extraction rather than aboutness.
|
||||
|
||||
This is a case for the V-546 slot-tagging head. Self-care facts are a bounded
|
||||
key space (five keys) over unbounded surface forms, which is exactly what a BIO
|
||||
tagger on e5-small is for: it generalises to `вздремнул` without anyone adding
|
||||
`вздремнуть` to a list, and max softmax gives the confidence the parser's
|
||||
hardcoded `true` does not have. Until it lands, the closed classes are the
|
||||
correct floor and the 36 rows above are the size of the gap they leave.
|
||||
|
||||
## The corpus, case by case
|
||||
|
||||
| utterance | class | want | old (substring) | new (closed class) |
|
||||
|---|---|---|---|---|
|
||||
| `выпил стакан воды` | tp | water | water | water |
|
||||
| `попил воды` | tp | water | water | water |
|
||||
| `я попил водички` | tp | water | water | water |
|
||||
| `пью воду` | tp | water | water | water |
|
||||
| `воду пил уже` | tp | water | water | water |
|
||||
| `допил воду` | tp | water | water | — **≠** |
|
||||
| `запил таблетку водой` | tp | water | water | water |
|
||||
| `drank water` | tp | water | water | water |
|
||||
| `i drank some water` | tp | water | water | water |
|
||||
| `поужинал` | tp | meal | meal | meal |
|
||||
| `я пообедал` | tp | meal | meal | meal |
|
||||
| `позавтракал кашей` | tp | meal | meal | meal |
|
||||
| `перекусил бутербродом` | tp | meal | — | meal **≠** |
|
||||
| `покушал` | tp | meal | meal | meal |
|
||||
| `поел супа` | tp | meal | meal | meal |
|
||||
| `обед был в час` | tp | meal | meal | meal |
|
||||
| `ужинать буду позже` | tp | meal | meal | meal |
|
||||
| `i ate` | tp | meal | meal | meal |
|
||||
| `had lunch` | tp | meal | meal | meal |
|
||||
| `dinner done` | tp | meal | meal | meal |
|
||||
| `принял душ` | tp | shower | shower | shower |
|
||||
| `сходил в душ` | tp | shower | shower | shower |
|
||||
| `душ принят` | tp | shower | shower | shower |
|
||||
| `ополоснулся душем` | tp | shower | shower | shower |
|
||||
| `took a shower` | tp | shower | shower | shower |
|
||||
| `i showered` | tp | shower | — | shower **≠** |
|
||||
| `сделал перерыв` | tp | break | break | break |
|
||||
| `отдохнул полчаса` | tp | break | break | break |
|
||||
| `передохнул немного` | tp | break | — | break **≠** |
|
||||
| `отдыхаю` | tp | break | — | break **≠** |
|
||||
| `был перерыв на обед` | tp | meal | meal | meal |
|
||||
| `took a break` | tp | break | break | break |
|
||||
| `спал восемь часов` | tp | sleep | sleep | sleep |
|
||||
| `спала плохо` | tp | sleep | sleep | sleep |
|
||||
| `поспал днём` | tp | sleep | sleep | sleep |
|
||||
| `выспался наконец` | tp | sleep | sleep | sleep |
|
||||
| `проспал будильник` | tp | sleep | sleep | sleep |
|
||||
| `пойду спать` | tp | sleep | — | sleep **≠** |
|
||||
| `slept 8 hours` | tp | sleep | sleep | sleep |
|
||||
| `i slept badly` | tp | sleep | sleep | sleep |
|
||||
| `пилот сказал что вылет через час` | misfire | — | — | — |
|
||||
| `водитель уже подъехал` | misfire | — | — | — |
|
||||
| `надо заводить машину` | misfire | — | — | — |
|
||||
| `душа болит` | misfire | — | shower | — **≠** |
|
||||
| `в комнате душно` | misfire | — | shower | — **≠** |
|
||||
| `это была беда` | misfire | — | meal | — **≠** |
|
||||
| `наша победа` | misfire | — | meal | — **≠** |
|
||||
| `пила лежит в гараже` | misfire | — | — | — |
|
||||
| `водитель пилота ждёт` | misfire | — | water | — **≠** |
|
||||
| `обеденный перерыв отменили` | misfire | — | meal | break **≠** |
|
||||
| `есть новости по бэкапу базы` | misfire | — | — | — |
|
||||
| `напоминания на завтра есть` | misfire | — | — | — |
|
||||
| `на душе легко` | misfire | — | shower | — **≠** |
|
||||
| `пилил доску весь вечер` | misfire | — | — | — |
|
||||
| `поставь будильник на завтра` | misfire | — | — | — |
|
||||
| `выпил чаю` | fn | water | — | — |
|
||||
| `глотнул воды` | fn | water | — | — |
|
||||
| `хлебнул воды` | fn | water | — | — |
|
||||
| `воды хлебнул из бутылки` | fn | water | — | — |
|
||||
| `выпил стакан` | fn | water | — | — |
|
||||
| `i hydrated` | fn | water | — | — |
|
||||
| `finished my bottle of water` | fn | water | — | — |
|
||||
| `ем суп` | fn | meal | — | — |
|
||||
| `съел бутерброд` | fn | meal | — | — |
|
||||
| `наелся` | fn | meal | — | — |
|
||||
| `пожрал` | fn | meal | — | — |
|
||||
| `полдник был` | fn | meal | — | — |
|
||||
| `i had a snack` | fn | meal | — | — |
|
||||
| `having supper` | fn | meal | — | — |
|
||||
| `brunch was good` | fn | meal | — | — |
|
||||
| `i eat now` | fn | meal | — | — |
|
||||
| `был в душе` | fn | shower | shower | — **≠** |
|
||||
| `после душа полегчало` | fn | shower | shower | — **≠** |
|
||||
| `помылся` | fn | shower | — | — |
|
||||
| `сходил в ванную` | fn | shower | — | — |
|
||||
| `искупался` | fn | shower | — | — |
|
||||
| `i am showering` | fn | shower | — | — |
|
||||
| `сделал передышку` | fn | break | — | — |
|
||||
| `перекур` | fn | break | — | — |
|
||||
| `полежал немного` | fn | break | — | — |
|
||||
| `сделал паузу` | fn | break | — | — |
|
||||
| `i took five` | fn | break | — | — |
|
||||
| `resting now` | fn | break | — | — |
|
||||
| `вздремнул` | fn | sleep | — | — |
|
||||
| `прикорнул на диване` | fn | sleep | — | — |
|
||||
| `дрых до обеда` | fn | sleep | meal | meal |
|
||||
| `недоспал` | fn | sleep | sleep | — **≠** |
|
||||
| `лёг в двенадцать` | fn | sleep | — | — |
|
||||
| `сон был короткий` | fn | sleep | — | — |
|
||||
| `i napped` | fn | sleep | — | — |
|
||||
| `took a nap` | fn | sleep | — | — |
|
||||
`≠` marks a disagreement. `—` is no fact written.
|
||||
@@ -172,21 +172,49 @@ func (s *Sink) Send(ctx context.Context, d delivery.Sendable) error {
|
||||
return fmt.Errorf("telegramsink: sendMessage: %w", s.redact(err))
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
rb, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
rb, _ := io.ReadAll(io.LimitReader(resp.Body, maxRespBytes))
|
||||
|
||||
// telegram returns 200 with ok=true on success; non-2xx with ok=false +
|
||||
// error_code + description on failure. parse the body either way so a 200
|
||||
// with ok=false (shouldn't happen, but the API reserves that) still surfaces.
|
||||
var tr telegramResp
|
||||
if jsonErr := json.Unmarshal(rb, &tr); jsonErr == nil && !tr.Ok {
|
||||
jsonErr := json.Unmarshal(rb, &tr)
|
||||
if jsonErr == nil && !tr.Ok {
|
||||
return fmt.Errorf("telegramsink: telegram returned error %d: %s", tr.ErrorCode, strings.TrimSpace(tr.Description))
|
||||
}
|
||||
if resp.StatusCode/100 != 2 {
|
||||
return fmt.Errorf("telegramsink: telegram returned %d: %s", resp.StatusCode, strings.TrimSpace(string(rb)))
|
||||
return fmt.Errorf("telegramsink: telegram returned %d: %s", resp.StatusCode, snippet(rb))
|
||||
}
|
||||
// A 2xx whose body is not the bot API's envelope did not come from the bot
|
||||
// API. The normal path here is the relay: this box reaches telegram through
|
||||
// an HTTP/SOCKS5 proxy, and a proxy that is up but cannot reach
|
||||
// api.telegram.org answers 200 with an HTML page of its own. Reading that as
|
||||
// a delivered message is the worst outcome the sink has — the dispatcher
|
||||
// writes a 'sent' outbox row, MarkSent restarts the repeat clock, and the
|
||||
// sev4 alarm that never arrived goes quiet for a whole interval. Only
|
||||
// ok=true is a send.
|
||||
if jsonErr != nil {
|
||||
return fmt.Errorf("telegramsink: telegram returned %d with a body that is not the bot API envelope (not a confirmed send): %s", resp.StatusCode, snippet(rb))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// maxRespBytes caps the response read — the body is wire-controlled and the
|
||||
// relay in front of it is not telegram. It is far above any sendMessage
|
||||
// envelope (a few hundred bytes; the result echoes one short away message),
|
||||
// because a truncated body no longer parses and now reads as a failed send.
|
||||
const maxRespBytes = 64 << 10
|
||||
|
||||
// snippet trims a response body down to something an error line can carry. A
|
||||
// relay's HTML page is measured in kilobytes and none of it belongs in the log.
|
||||
func snippet(rb []byte) string {
|
||||
s := strings.TrimSpace(string(rb))
|
||||
if len(s) > 200 {
|
||||
return s[:200] + "…"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// sendMessageURL — the bot API path. the token is in the URL path
|
||||
// (https://api.telegram.org/bot<token>/sendMessage); telegram does not accept
|
||||
// it anywhere else. the URL is built per-send from the resolved base and never
|
||||
|
||||
@@ -291,6 +291,66 @@ func TestSendReturnsErrorOnTelegramError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A relay that is up but cannot reach api.telegram.org answers 200 with a page
|
||||
// of its own. That is not a delivered message, and calling it one silences a
|
||||
// sev4 alarm for a full repeat interval.
|
||||
func TestSendRefusesA200ThatIsNotTheBotAPIEnvelope(t *testing.T) {
|
||||
rs := newRecordingServer(t, http.StatusOK, `<html><body>proxy: upstream unreachable</body></html>`)
|
||||
srv := httptest.NewServer(rs.handler())
|
||||
defer srv.Close()
|
||||
|
||||
sink, _ := New(sinkCfg(srv.URL))
|
||||
err := sink.Send(context.Background(), nudgeSendable(loop.Sev4, "down"))
|
||||
if err == nil {
|
||||
t.Fatal("want error on a 200 that is not the bot API envelope")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "not a confirmed send") {
|
||||
t.Fatalf("error should say the send is unconfirmed, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The success path must stay a success: ok=true on 200 is a send.
|
||||
func TestSendAcceptsOkTrue(t *testing.T) {
|
||||
rs := newRecordingServer(t, http.StatusOK, `{"ok":true,"result":{"message_id":7}}`)
|
||||
srv := httptest.NewServer(rs.handler())
|
||||
defer srv.Close()
|
||||
|
||||
sink, _ := New(sinkCfg(srv.URL))
|
||||
if err := sink.Send(context.Background(), nudgeSendable(loop.Sev4, "down")); err != nil {
|
||||
t.Fatalf("want success on ok=true, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A body long enough to have been truncated by the old 4096-byte cap still
|
||||
// parses, so a real send is not reported as a failure.
|
||||
func TestSendAcceptsAnOversizedButValidEnvelope(t *testing.T) {
|
||||
rs := newRecordingServer(t, http.StatusOK,
|
||||
`{"ok":true,"result":{"message_id":7,"text":"`+strings.Repeat("x", 8000)+`"}}`)
|
||||
srv := httptest.NewServer(rs.handler())
|
||||
defer srv.Close()
|
||||
|
||||
sink, _ := New(sinkCfg(srv.URL))
|
||||
if err := sink.Send(context.Background(), nudgeSendable(loop.Sev4, "down")); err != nil {
|
||||
t.Fatalf("want success on a large ok=true envelope, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The error line carries a snippet, not the relay's whole page.
|
||||
func TestSendErrorDoesNotCarryTheWholeBody(t *testing.T) {
|
||||
rs := newRecordingServer(t, http.StatusBadGateway, strings.Repeat("z", 5000))
|
||||
srv := httptest.NewServer(rs.handler())
|
||||
defer srv.Close()
|
||||
|
||||
sink, _ := New(sinkCfg(srv.URL))
|
||||
err := sink.Send(context.Background(), nudgeSendable(loop.Sev4, "down"))
|
||||
if err == nil {
|
||||
t.Fatal("want error on 502")
|
||||
}
|
||||
if len(err.Error()) > 500 {
|
||||
t.Fatalf("error line should be trimmed, got %d bytes", len(err.Error()))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendReturnsErrorOnNon2xx(t *testing.T) {
|
||||
rs := newRecordingServer(t, http.StatusBadGateway, "bad gateway")
|
||||
srv := httptest.NewServer(rs.handler())
|
||||
|
||||
@@ -135,6 +135,30 @@ func ConfirmNo() []string { return words("confirm_no") }
|
||||
// TaskDropWords — see TaskDoneWords.
|
||||
func TaskDropWords() []string { return words("task_drop_words") }
|
||||
|
||||
// WaterNouns and DrinkVerbs are the two halves of a water fact: he has to name
|
||||
// the drink and the drinking, because "вода" alone is a word about water and
|
||||
// "выпил" alone does not say what. The other four self-care sets need only one
|
||||
// word each. All six are matched over tokens by lemma — except ShowerWords, see
|
||||
// its own note.
|
||||
func WaterNouns() []string { return words("water_nouns") }
|
||||
|
||||
// DrinkVerbs — see WaterNouns.
|
||||
func DrinkVerbs() []string { return words("drink_verbs") }
|
||||
|
||||
// MealWords returns the nouns and verbs of having eaten.
|
||||
func MealWords() []string { return words("meal_words") }
|
||||
|
||||
// ShowerWords returns the shower noun. Match these EXACTLY and not by lemma:
|
||||
// the dictionary makes "душ" and "душа" one word, and only one of them is a
|
||||
// shower. The set's note says why exact matching costs nothing here.
|
||||
func ShowerWords() []string { return words("shower_words") }
|
||||
|
||||
// BreakWords returns the noun and verbs of taking a break.
|
||||
func BreakWords() []string { return words("break_words") }
|
||||
|
||||
// SleepWords returns the verbs of having slept.
|
||||
func SleepWords() []string { return words("sleep_words") }
|
||||
|
||||
// SlotValueFrame returns the words that can surround a bare slot value without
|
||||
// making the utterance a request of its own. A caller strips these (along with
|
||||
// the numbers and the other closed time sets) to see whether an utterance
|
||||
|
||||
@@ -252,6 +252,50 @@
|
||||
"yes", "yeah", "yep", "yup", "ok", "okay", "sure", "confirm", "affirmative"
|
||||
]
|
||||
},
|
||||
"water_nouns": {
|
||||
"note": "The water noun, in the forms he drinks it in, plus English (V-586). Closed because it is one noun: Russian gives it six cases and two numbers and that is the whole list. Both \"вода\" and \"водой\" are listed even though one declension covers both, because the vendored dictionary lemmatises \"воды\" to \"вод\" and \"водой\" to \"вода\" — two lemmas for one noun, so the set has to name both or a caller matching by lemma misses half of them. Matched over tokens with morph.SameWord, never as a substring: the \"вод\" this replaced fired on \"водитель\" and \"заводить\".",
|
||||
"words": [
|
||||
"вода", "водой", "водичка", "water"
|
||||
]
|
||||
},
|
||||
"drink_verbs": {
|
||||
"note": "Drinking, in the aspects and prefixes he speaks (V-586). Closed in the sense that matters: these are the verbs that make a water noun a water FACT, and the list is her vocabulary rather than a discovery about Russian. \"пил\" and \"пили\" are listed as surface forms because the dictionary lemmatises them to \"пила\", the saw; the perfective forms lemmatise correctly and one member each covers them. Whole tokens only — the substring \"пил\" this replaced fired on \"пилот\".",
|
||||
"words": [
|
||||
"пить", "пил", "пили", "пей", "выпить", "попить", "допить", "запить",
|
||||
"drink", "drank", "drinking"
|
||||
]
|
||||
},
|
||||
"meal_words": {
|
||||
"note": "Eating: the meal nouns and the verbs of having one (V-586). Closed the same way capture_verbs is — these are the words that write a meal fact, decided here. The verbs are listed in the infinitive because that is the lemma the dictionary returns, so \"поужинал\" and \"позавтракал\" match without their own entries. \"есть\" and \"ел\" are deliberately ABSENT: \"есть\" is also the existential, and \"есть новости по бэкапу\" is a question rather than a meal. The English \"ate\" carried a guard against \"backup\" when this was a substring test; over tokens the guard is unnecessary.",
|
||||
"words": [
|
||||
"обед", "обедать", "пообедать",
|
||||
"ужин", "ужинать", "поужинать",
|
||||
"завтрак", "завтракать", "позавтракать",
|
||||
"еда", "перекус", "перекусить",
|
||||
"поесть", "кушать", "покушать",
|
||||
"meal", "ate", "lunch", "dinner", "breakfast"
|
||||
]
|
||||
},
|
||||
"shower_words": {
|
||||
"note": "The shower, and the one set here matched EXACTLY rather than by lemma (V-586). The dictionary lemmatises \"душ\" to \"душа\", so a lemma test cannot tell a shower from a soul, and \"на душе легко\" is not a fact about washing. The accusative of an inanimate noun is its nominative, so \"принял душ\" and \"сходил в душ\" are both the bare form and exact matching loses nothing he actually says. The substring this replaced also fired on \"душно\".",
|
||||
"words": [
|
||||
"душ", "душем", "shower", "showered"
|
||||
]
|
||||
},
|
||||
"break_words": {
|
||||
"note": "Taking a break, noun and verb (V-586). Closed like meal_words and for the same reason. \"отдых\" and \"отдыхать\" are both listed because the noun and the verb are separate lemmas; the perfective \"отдохнул\" lemmatises to \"отдохнуть\".",
|
||||
"words": [
|
||||
"перерыв", "отдых", "отдыхать", "отдохнуть", "передохнуть",
|
||||
"break", "rest"
|
||||
]
|
||||
},
|
||||
"sleep_words": {
|
||||
"note": "Sleeping (V-586). The imperfective surface forms \"спал\" and \"спала\" are listed because the dictionary lemmatises them to \"спасть\", a different verb, and one entry for the pair is the honest fix; the prefixed forms lemmatise consistently and their infinitives cover them. \"сон\" is absent: the noun names a dream as readily as a night's sleep, and it was not in the pattern this replaces either.",
|
||||
"words": [
|
||||
"спать", "спал", "поспать", "поспал", "проспать", "выспаться",
|
||||
"sleep", "slept", "sleeping"
|
||||
]
|
||||
},
|
||||
"confirm_no": {
|
||||
"note": "The answers that decline a parked confirm. Same matching rule as confirm_yes and the same reason. The multi-word members are here rather than assembled by a caller because \"надо\" alone is not an answer and \"не надо\" is the opposite of one: the two must land on opposite sides, and only the phrase says which. \"не\" on its own is NOT a member — \"не забудь купить хлеб\" is a reminder, not a refusal.",
|
||||
"words": [
|
||||
|
||||
+10
-1
@@ -257,7 +257,16 @@ func (c *Client) call(ctx context.Context, method string, params any, out any) e
|
||||
if resp.Error != nil {
|
||||
return fmt.Errorf("mcp: %s: %s: %w", c.name, method, resp.Error)
|
||||
}
|
||||
if out == nil || len(resp.Result) == 0 {
|
||||
// A frame carrying our id and neither result nor error is not an answer.
|
||||
// The HTTP transport already refuses one; the stdio transport does not, and
|
||||
// without this check the refusal depended on which door the server was
|
||||
// behind. Letting it through is the one failure that lies: tools/call
|
||||
// returns an empty string and a nil error, so the act is recorded as done
|
||||
// and the tool never ran.
|
||||
if len(resp.Result) == 0 {
|
||||
return fmt.Errorf("mcp: %s: %s: response carries neither result nor error", c.name, method)
|
||||
}
|
||||
if out == nil {
|
||||
return nil
|
||||
}
|
||||
if err := json.Unmarshal(resp.Result, out); err != nil {
|
||||
|
||||
@@ -525,10 +525,16 @@ func (m *Manager) Resources(ctx context.Context) []Resource {
|
||||
|
||||
// ReadResource reads one resource from one server.
|
||||
func (m *Manager) ReadResource(ctx context.Context, server, uri string) (string, error) {
|
||||
cl, cfg, _, _, _ := m.lookup(server, "")
|
||||
if cl == nil {
|
||||
cl, cfg, _, configured, _ := m.lookup(server, "")
|
||||
if !configured {
|
||||
return "", fmt.Errorf("%w: %s", ErrNoServer, server)
|
||||
}
|
||||
// A configured server that is merely down is not an unknown server. Call
|
||||
// already keeps the two apart; reporting ErrNoServer here tells a caller
|
||||
// the resource can never exist, when the truth is "not right now".
|
||||
if cl == nil {
|
||||
return "", fmt.Errorf("%w: %s", ErrNotConnected, server)
|
||||
}
|
||||
cctx, cancel := context.WithTimeout(ctx, cfg.Timeout)
|
||||
defer cancel()
|
||||
return cl.ReadResource(cctx, uri)
|
||||
|
||||
@@ -641,3 +641,59 @@ func TestReconnectBackoffGrows(t *testing.T) {
|
||||
t.Fatalf("first retry = %v, want %v", c.backoff(), DefaultReconnectEvery)
|
||||
}
|
||||
}
|
||||
|
||||
// emptyFrameTransport answers the handshake normally and then replies to every
|
||||
// later call with a well-formed frame carrying our id and nothing else — no
|
||||
// result, no error. That is the answer a partially-implemented server gives,
|
||||
// and it is the one that lies: without a check it reads as success.
|
||||
type emptyFrameTransport struct{ handshaken bool }
|
||||
|
||||
func (t *emptyFrameTransport) Call(ctx context.Context, req *rpcRequest) (*rpcResponse, error) {
|
||||
id := req.ID
|
||||
if !t.handshaken {
|
||||
t.handshaken = true
|
||||
raw, _ := json.Marshal(map[string]any{
|
||||
"protocolVersion": ProtocolVersion,
|
||||
"serverInfo": map[string]any{"name": "empty", "version": "0"},
|
||||
})
|
||||
return &rpcResponse{JSONRPC: "2.0", ID: &id, Result: raw}, nil
|
||||
}
|
||||
return &rpcResponse{JSONRPC: "2.0", ID: &id}, nil
|
||||
}
|
||||
|
||||
func (t *emptyFrameTransport) Notify(context.Context, string, any) error { return nil }
|
||||
func (t *emptyFrameTransport) Close() error { return nil }
|
||||
|
||||
func TestResultlessResponseIsNotSuccess(t *testing.T) {
|
||||
c := newClient("empty", &emptyFrameTransport{})
|
||||
if err := c.Initialize(context.Background()); err != nil {
|
||||
t.Fatalf("initialize: %v", err)
|
||||
}
|
||||
out, err := c.CallTool(context.Background(), "break_thing", map[string]any{"q": "x"})
|
||||
if err == nil {
|
||||
t.Fatalf("a frame with neither result nor error must not read as success (got %q)", out)
|
||||
}
|
||||
if out != "" {
|
||||
t.Fatalf("out = %q", out)
|
||||
}
|
||||
if _, err := c.ListTools(context.Background()); err == nil {
|
||||
t.Fatal("tools/list with no result must be an error, not an empty catalogue")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadResourceOnDownServerIsNotConnected(t *testing.T) {
|
||||
// A url server with no poster factory: configured, validated, never dialed.
|
||||
m, err := NewManager(nil, []ServerConfig{{
|
||||
Name: "down", URL: "http://example.test/mcp", Enabled: true,
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m.Connect(context.Background())
|
||||
if _, err := m.ReadResource(context.Background(), "down", "note://one"); !errors.Is(err, ErrNotConnected) {
|
||||
t.Fatalf("err = %v, want ErrNotConnected", err)
|
||||
}
|
||||
if _, err := m.ReadResource(context.Background(), "nosuch", "note://one"); !errors.Is(err, ErrNoServer) {
|
||||
t.Fatalf("err = %v, want ErrNoServer", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,10 +52,13 @@ type PlanEntry struct {
|
||||
// Plan — the ordered day. Date is the calendar day it describes. Rest marks a
|
||||
// plan trimmed by After, which changes what an empty one means: a day with
|
||||
// nothing on it and a day whose last item has passed are different answers.
|
||||
// More counts what Next dropped off the end, so the sentence can say that more
|
||||
// remains instead of implying the day ends after the third line.
|
||||
type Plan struct {
|
||||
Date time.Time
|
||||
Items []PlanEntry
|
||||
Rest bool
|
||||
More int
|
||||
}
|
||||
|
||||
// BuildPlan orders everything known about the day Now falls on: calendar
|
||||
@@ -142,13 +145,24 @@ func checklistEntries(routines []Routine, facts map[string]store.Fact, now time.
|
||||
return out
|
||||
}
|
||||
|
||||
// NextSpoken — how many entries "что дальше?" reads aloud. Three, for the same
|
||||
// reason the feed reads three headlines: the answer is spoken once and cannot be
|
||||
// scrolled back, and a list longer than a breath is not an answer, it is a
|
||||
// recital. Asked at 04:45 on a day with 43 entries, the trim below removes
|
||||
// nothing — everything is still ahead — so the cap is what makes "дальше" mean
|
||||
// next rather than today (V-618).
|
||||
const NextSpoken = 3
|
||||
|
||||
// After returns the part of the plan that has not happened yet — the answer to
|
||||
// "что дальше?" as opposed to "какие планы на сегодня?". The Date is kept, so an
|
||||
// empty result still knows which day it is empty for.
|
||||
//
|
||||
// Strictly after: an entry at exactly now is the thing happening, not the thing
|
||||
// next.
|
||||
func (p Plan) After(now time.Time) Plan {
|
||||
out := Plan{Date: p.Date, Rest: true}
|
||||
for _, it := range p.Items {
|
||||
if it.At.Before(now) {
|
||||
if !it.At.After(now) {
|
||||
continue
|
||||
}
|
||||
out.Items = append(out.Items, it)
|
||||
@@ -156,10 +170,30 @@ func (p Plan) After(now time.Time) Plan {
|
||||
return out
|
||||
}
|
||||
|
||||
// Next is After with a spoken cap — what "что дальше?" actually answers with.
|
||||
// The overflow is counted rather than dropped, because "дальше: 10:00 …" with
|
||||
// forty entries hidden behind it is a false picture of the day.
|
||||
func (p Plan) Next(now time.Time, n int) Plan {
|
||||
out := p.After(now)
|
||||
if n > 0 && len(out.Items) > n {
|
||||
out.More = len(out.Items) - n
|
||||
out.Items = out.Items[:n]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// FormatRU renders the plan as maven says it. Feminine self-reference,
|
||||
// informal address, no pet names — and no exhortation: she reads the day back,
|
||||
// she does not tell him to get on with it.
|
||||
//
|
||||
// Every hour is read in the plan's own zone — Date's, which BuildPlan sets from
|
||||
// the asking clock. Printed raw, an hour read whatever zone its instant arrived
|
||||
// in: an event or a reminder comes off the store as UTC, while a checklist line
|
||||
// is built local, so one spoken sentence named two zones. This is the voice
|
||||
// path, so that is what the owner heard (V-614); the same defect on the two web
|
||||
// pages was V-612.
|
||||
func (p Plan) FormatRU() string {
|
||||
zone := p.Date.Location()
|
||||
if len(p.Items) == 0 {
|
||||
// "что дальше?" after the last item of the day. The day was not empty,
|
||||
// it is over, and saying it was empty is a false statement about a day
|
||||
@@ -171,14 +205,28 @@ func (p Plan) FormatRU() string {
|
||||
}
|
||||
parts := make([]string, len(p.Items))
|
||||
for i, it := range p.Items {
|
||||
line := fmt.Sprintf("%s — %s", it.At.Format("15:04"), it.Text)
|
||||
line := fmt.Sprintf("%s — %s", it.At.In(zone).Format("15:04"), it.Text)
|
||||
if it.Uncertain {
|
||||
line = say.S(say.PlanUncertain, map[string]string{"line": line})
|
||||
}
|
||||
parts[i] = line
|
||||
}
|
||||
items := strings.Join(parts, "; ")
|
||||
// The rest of the day is a different sentence, not a shorter day plan. It
|
||||
// carries no date — he asked what is next, and he knows which day he is in —
|
||||
// and it says out loud when there is more behind the cap.
|
||||
if p.Rest {
|
||||
if p.More > 0 {
|
||||
return say.S(say.PlanNextMore, map[string]string{
|
||||
"items": items,
|
||||
"n": fmt.Sprint(p.More),
|
||||
"word": say.CountWord(p.More, "дело", "дела", "дел"),
|
||||
})
|
||||
}
|
||||
return say.S(say.PlanNext, map[string]string{"items": items})
|
||||
}
|
||||
return say.S(say.PlanDay, map[string]string{
|
||||
"date": p.Date.Format("02.01.2006"),
|
||||
"items": strings.Join(parts, "; "),
|
||||
"items": items,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package morning
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -151,6 +152,37 @@ func TestPlanFormatRU(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// One spoken sentence names one clock. An event and a reminder come off the
|
||||
// store as UTC and a checklist line is built in the asking clock's zone, so the
|
||||
// raw Format printed the two halves of one sentence in two zones (V-614). The
|
||||
// zones here are three hours off whatever this machine runs in, so the test
|
||||
// tells "read in his clock" apart from "read in the zone the instant arrived
|
||||
// in" under TZ=UTC as well.
|
||||
func TestPlanFormatRUReadsEveryHourInThePlansZone(t *testing.T) {
|
||||
_, off := time.Now().Zone()
|
||||
away := time.FixedZone("away", off+3*60*60)
|
||||
|
||||
stored := time.Date(2026, 8, 3, 14, 0, 0, 0, time.UTC)
|
||||
p := Plan{
|
||||
Date: time.Date(2026, 8, 3, 0, 0, 0, 0, away),
|
||||
Items: []PlanEntry{
|
||||
{At: stored, Text: "Планёрка", Kind: PlanEvent},
|
||||
{At: planAt(time.Date(2026, 8, 3, 0, 0, 0, 0, away), 10, 30),
|
||||
Text: "утро — осталось: витамины", Kind: PlanChecklist},
|
||||
},
|
||||
}
|
||||
got := p.FormatRU()
|
||||
if want := stored.In(away).Format("15:04"); !strings.Contains(got, want) {
|
||||
t.Errorf("the event is not read in the plan's zone (%s): %q", want, got)
|
||||
}
|
||||
if bad := stored.Format("15:04"); strings.Contains(got, bad) {
|
||||
t.Errorf("the event is read in the zone it was stored in (%s): %q", bad, got)
|
||||
}
|
||||
if !strings.Contains(got, "10:30") {
|
||||
t.Errorf("the checklist line moved zone: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanAfter(t *testing.T) {
|
||||
p, now := planFixture(t)
|
||||
rest := p.After(planAt(now, 11, 0))
|
||||
@@ -171,6 +203,84 @@ func TestPlanAfter(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// nextFixture — a day with more entries than the cap, built in a zone three
|
||||
// hours off UTC so the test fails under TZ=UTC as well as under the machine's
|
||||
// own zone if the plan ever renders in the wrong one.
|
||||
func nextFixture(t *testing.T) (Plan, time.Time) {
|
||||
t.Helper()
|
||||
zone := time.FixedZone("MSK", 3*60*60)
|
||||
now := time.Date(2026, 8, 3, 4, 45, 0, 0, zone)
|
||||
var events []PlanEntry
|
||||
for _, hhmm := range [][2]int{{5, 45}, {10, 0}, {14, 0}, {18, 30}, {21, 12}} {
|
||||
events = append(events, PlanEntry{
|
||||
At: planAt(now, hhmm[0], hhmm[1]),
|
||||
Text: fmt.Sprintf("событие %02d:%02d", hhmm[0], hhmm[1]),
|
||||
Kind: PlanEvent,
|
||||
})
|
||||
}
|
||||
return BuildPlan(nil, nil, events, nil, now), now
|
||||
}
|
||||
|
||||
// "что дальше?" asked at 04:45 on a day with everything still ahead. The trim
|
||||
// removes nothing there, so before V-618 she read the whole day out loud.
|
||||
func TestPlanNextCapsWhatIsSpoken(t *testing.T) {
|
||||
p, now := nextFixture(t)
|
||||
got := p.Next(now, NextSpoken).FormatRU()
|
||||
want := "дальше: 05:45 — событие 05:45; 10:00 — событие 10:00; " +
|
||||
"14:00 — событие 14:00. и ещё 2 дела до конца дня."
|
||||
if got != want {
|
||||
t.Errorf("got %q\nwant %q", got, want)
|
||||
}
|
||||
// No date: he asked what is next, not what day it is.
|
||||
if strings.Contains(got, "03.08.2026") {
|
||||
t.Errorf("rest-of-day answer stamps a date: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Nothing hidden means nothing claimed hidden.
|
||||
func TestPlanNextWithinTheCapSaysNoMore(t *testing.T) {
|
||||
p, now := nextFixture(t)
|
||||
got := p.Next(planAt(now, 15, 0), NextSpoken).FormatRU()
|
||||
want := "дальше: 18:30 — событие 18:30; 21:12 — событие 21:12"
|
||||
if got != want {
|
||||
t.Errorf("got %q\nwant %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// The whole-day question is not narrowed: same plan, no trim, no cap.
|
||||
func TestPlanWholeDayIsNotNarrowed(t *testing.T) {
|
||||
p, _ := nextFixture(t)
|
||||
got := p.FormatRU()
|
||||
if n := strings.Count(got, "событие"); n != 5 {
|
||||
t.Errorf("whole day read %d of 5 entries: %q", n, got)
|
||||
}
|
||||
if !strings.HasPrefix(got, "план на 03.08.2026: ") {
|
||||
t.Errorf("whole day lost its date: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The empty case says the day is over rather than returning an empty sentence,
|
||||
// and it does not say the day was empty.
|
||||
func TestPlanNextEmptySaysSo(t *testing.T) {
|
||||
p, now := nextFixture(t)
|
||||
got := p.Next(planAt(now, 23, 30), NextSpoken).FormatRU()
|
||||
if got != "на сегодня больше ничего не запланировано." {
|
||||
t.Errorf("got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// An entry at exactly the asking minute is what is happening, not what is next.
|
||||
func TestPlanNextIsStrictlyAfterNow(t *testing.T) {
|
||||
p, now := nextFixture(t)
|
||||
rest := p.Next(planAt(now, 5, 45), NextSpoken)
|
||||
if len(rest.Items) != 3 || rest.Items[0].At.Hour() != 10 {
|
||||
t.Errorf("got %+v", rest.Items)
|
||||
}
|
||||
if rest.More != 1 {
|
||||
t.Errorf("More = %d, want 1", rest.More)
|
||||
}
|
||||
}
|
||||
|
||||
// The plan says what today still has not got done, and a closed window does not
|
||||
// make a skipped routine untrue. Evaluate reports Active only inside the
|
||||
// window, so keying the checklist line off it meant the one thing the plan can
|
||||
|
||||
@@ -78,3 +78,36 @@ func TestEmptyKnowledgeAnswerIsAnError(t *testing.T) {
|
||||
t.Errorf("error = %v; want it to name the empty response", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The other two paths, which had no such guard. The evidence branch of
|
||||
// PhraseQuery and PhraseChat both returned ("", nil) off a server that produced
|
||||
// no tokens — an empty answer reported as a successful phrasing. The daemon's
|
||||
// callers check for the empty string and paper over it; the eval does not, and
|
||||
// scored a silent model as bad phrasing rather than as a failure.
|
||||
func TestEmptyAnswerIsAnErrorOnEveryPath(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"choices":[{"message":{"content":""}}]}`))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
p := NewLLMPhraserAt(srv.URL, Config{})
|
||||
|
||||
t.Run("evidence", func(t *testing.T) {
|
||||
got, err := p.PhraseQuery(context.Background(), "сколько воды я выпил", []string{"два литра"})
|
||||
if err == nil {
|
||||
t.Fatal("an empty response scored as an answer")
|
||||
}
|
||||
if !isFallback(t, fbQuerySources, "два литра", got) {
|
||||
t.Errorf("fallback text = %q, want a %q variant", got, fbQuerySources)
|
||||
}
|
||||
})
|
||||
t.Run("chat", func(t *testing.T) {
|
||||
got, err := p.PhraseChat(context.Background(), "как дела", nil)
|
||||
if err == nil {
|
||||
t.Fatal("an empty response scored as an answer")
|
||||
}
|
||||
if !isFallback(t, fbChat, "", got) {
|
||||
t.Errorf("fallback text = %q, want a %q variant", got, fbChat)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -299,6 +299,16 @@ func (p *LLMPhraser) PhraseQuery(ctx context.Context, utterance string, notes []
|
||||
if text != "" {
|
||||
return text, nil
|
||||
}
|
||||
if raw == "" {
|
||||
// Same guard the knowledge branch above has had since it was written,
|
||||
// and this branch did not: the server answered and the model wrote
|
||||
// nothing, which returned ("", nil) — an empty answer reported as a
|
||||
// successful phrasing. The daemon's callers happen to check for the
|
||||
// empty string, so it read as a silent fallback there; the eval scored
|
||||
// it as bad phrasing rather than as the failure it is, and nothing on
|
||||
// either path logged that the model had produced no tokens.
|
||||
return SourcesFallback(strings.Join(notes, "; ")), errEmptyResponse
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
@@ -375,7 +385,13 @@ func (p *LLMPhraser) PhraseChat(ctx context.Context, utterance string, history [
|
||||
if i := strings.IndexByte(resp, '\n'); i >= 0 {
|
||||
resp = resp[:i]
|
||||
}
|
||||
return strings.TrimSpace(resp), nil
|
||||
if resp = strings.TrimSpace(resp); resp == "" {
|
||||
// The model was up and wrote nothing. Same rule as PhraseQuery: the
|
||||
// fallback keeps the turn alive and the failure stays visible, rather
|
||||
// than ("", nil) telling the caller the chat path succeeded.
|
||||
return ChatFallback(), fmt.Errorf("phrase chat: %w", errEmptyResponse)
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (p *LLMPhraser) PhraseReminder(ctx context.Context, d loop.ReminderDecision) (delivery.PhrasedReminder, error) {
|
||||
@@ -414,11 +430,9 @@ func (p *LLMPhraser) PhraseReminder(ctx context.Context, d loop.ReminderDecision
|
||||
if mood == "" {
|
||||
mood = "neutral"
|
||||
}
|
||||
summary := body
|
||||
if len(summary) > 60 {
|
||||
summary = summary[:57] + "..."
|
||||
}
|
||||
return delivery.PhrasedReminder{Decision: d, Body: body, Summary: summary, Mood: mood}, nil
|
||||
return delivery.PhrasedReminder{
|
||||
Decision: d, Body: body, Summary: reminderSummary(body), Mood: mood,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *LLMPhraser) chat(ctx context.Context, userPrompt string) (string, error) {
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
package phraser
|
||||
|
||||
// The persona guard for the Go floor strings.
|
||||
//
|
||||
// internal/phraser/eval/fallbacks_test.go already scores everything Variants()
|
||||
// returns — that is the JSON decks. What it cannot see is the floor UNDER those
|
||||
// decks: the hardFloor/ackFloor/queryFloor/actFloor/confirmFloor maps and the
|
||||
// literals in nudge_llm.go, which are what she says when the JSON is unusable or
|
||||
// when the model is unreachable. Those are exactly the moments the model is not
|
||||
// doing the talking, so leaving them unscored left the persona unchecked when it
|
||||
// was most load-bearing (Vikunja #621).
|
||||
//
|
||||
// Two tests here, and the second one is the point:
|
||||
//
|
||||
// - TestGoFloorPersona scores the floor corpus on the same checks.
|
||||
// - TestGoFloorCoverage walks the package source with go/ast and fails on any
|
||||
// Russian string literal that neither reached the corpus nor sits inside a
|
||||
// declaration declared prompt-side. A hand-written list of strings would rot
|
||||
// the first time somebody adds one; a hand-written list of PROMPT BUILDERS
|
||||
// does not, because the default for a new literal is "must be scored".
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"io/fs"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/kami/maven/internal/loop"
|
||||
"github.com/kami/maven/internal/phraser/eval"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
// personaChecks — the checks that apply to a floor line.
|
||||
//
|
||||
// The same three the non-goals section names (feminine self-reference, how she
|
||||
// addresses him, no pet names), plus lang: an English floor line is unusable out
|
||||
// loud. No hisgender, for the reason eval/fallbacks_test.go gives — her own
|
||||
// feminine verb near "тебе" is correct and that check reads it as addressing him
|
||||
// as a woman. No length, because a floor line composed from his own data has no
|
||||
// bounded length, and no ontopic/mood, which need a fixture case.
|
||||
var personaChecks = map[string]bool{
|
||||
eval.CheckLang: true,
|
||||
eval.CheckFeminine: true,
|
||||
eval.CheckAddress: true,
|
||||
eval.CheckCringe: true,
|
||||
}
|
||||
|
||||
var floorPlaceholderRE = regexp.MustCompile(`\{[a-z_]+\}`)
|
||||
|
||||
// formatVerbRE — the fmt verbs a floor line is composed with, so the coverage
|
||||
// test compares the Russian either side of them and not the verb.
|
||||
var formatVerbRE = regexp.MustCompile(`%[+\-# 0-9.]*[a-zA-Z]`)
|
||||
|
||||
// floorLine — one scored string and where it came from, so a failure names the
|
||||
// map or the function to go and edit.
|
||||
type floorLine struct {
|
||||
origin string
|
||||
text string
|
||||
}
|
||||
|
||||
// floorCorpus — every line the Go floor can produce. Maps are read whole, so a
|
||||
// new entry in one is scored without touching this file; the composing functions
|
||||
// are CALLED rather than scraped, so their glue text is scored in place.
|
||||
func floorCorpus() []floorLine {
|
||||
var out []floorLine
|
||||
add := func(origin, text string) {
|
||||
if strings.TrimSpace(text) != "" {
|
||||
out = append(out, floorLine{origin, text})
|
||||
}
|
||||
}
|
||||
for name, m := range map[string]map[string]string{
|
||||
"fallbacks.go hardFloor": hardFloor,
|
||||
"acks.go ackFloor": ackFloor,
|
||||
"query.go queryFloor": queryFloor,
|
||||
"acts.go actFloor": actFloor,
|
||||
"confirm.go confirmFloor": confirmFloor,
|
||||
"nudge_llm.go fallbackNudges": fallbackNudges,
|
||||
} {
|
||||
for key, text := range m {
|
||||
add(name+"["+key+"]", text)
|
||||
}
|
||||
}
|
||||
|
||||
// fallbackNudge composes three of its four arms in Go. Drive every rule name
|
||||
// the maps know, one it does not, and the down-services arm.
|
||||
rules := map[string]bool{"": true, "unknown_rule": true}
|
||||
for name := range fallbackNudges {
|
||||
rules[name] = true
|
||||
}
|
||||
for name := range ruleTopics {
|
||||
rules[name] = true
|
||||
}
|
||||
for name := range ruleKeywords {
|
||||
rules[name] = true
|
||||
}
|
||||
for name := range rules {
|
||||
c := loop.Candidate{}
|
||||
c.Rule.Name = name
|
||||
add(fmt.Sprintf("nudge_llm.go fallbackNudge(%q)", name), fallbackNudge(c))
|
||||
}
|
||||
// The keyword arm again, through a rule name shaped "family:keyword", which
|
||||
// is where ruleKeyword's second branch lives.
|
||||
c := loop.Candidate{}
|
||||
c.Rule.Name = "custom:зарядку"
|
||||
add("nudge_llm.go fallbackNudge(custom)", fallbackNudge(c))
|
||||
|
||||
// The keywords themselves. A rule that has both a keyword and a fallback
|
||||
// line never reaches the keyword arm, but the map is edited as one thing and
|
||||
// the next rule may have only the keyword, so score every value.
|
||||
for rule, kw := range ruleKeywords {
|
||||
add("nudge_llm.go ruleKeywords["+rule+"]", "Напоминаю: "+kw+".")
|
||||
}
|
||||
|
||||
// The down-services arm, which needs a service actually reading down.
|
||||
down := loop.Candidate{}
|
||||
down.Rule.Name = "service_down"
|
||||
down.State.Facts = map[string]store.Fact{
|
||||
loop.ServiceDownPrefix + "gitea": {
|
||||
Key: loop.ServiceDownPrefix + "gitea",
|
||||
Value: `"down"`,
|
||||
Source: loop.ServiceDownSource,
|
||||
Ts: time.Now(),
|
||||
},
|
||||
}
|
||||
add("nudge_llm.go fallbackNudge(down services)", fallbackNudge(down))
|
||||
|
||||
// The spoken duration words. Both functions are pure and bounded, so scoring
|
||||
// their whole range beats scraping the literals out of the switch.
|
||||
for m := 0; m <= 60*30; m += 7 {
|
||||
d := time.Duration(m) * time.Minute
|
||||
add("nudge_llm.go ruDur", ruDur(d))
|
||||
add("nudge_templates.go ruSinceWords", ruSinceWords(d))
|
||||
}
|
||||
for h := 0; h <= hoursSpoken; h++ {
|
||||
add("nudge_templates.go hourPlural", hourPlural(h))
|
||||
add("nudge_templates.go hourWord", hourWord(h))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestGoFloorPersona scores every line the Go floor can say.
|
||||
func TestGoFloorPersona(t *testing.T) {
|
||||
corpus := floorCorpus()
|
||||
if len(corpus) == 0 {
|
||||
t.Fatal("no floor lines — the corpus builder found nothing to score")
|
||||
}
|
||||
for _, line := range corpus {
|
||||
// A placeholder stands for his own words and carries no persona.
|
||||
body := floorPlaceholderRE.ReplaceAllString(line.text, "вода")
|
||||
for _, r := range eval.RunChecks(eval.Case{}, body, "neutral") {
|
||||
if personaChecks[r.Name] && !r.Pass {
|
||||
t.Errorf("%s: %q fails %s: %s", line.origin, line.text, r.Name, r.Detail)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// promptDecls — declarations whose Russian is written FOR the model, not for
|
||||
// him. They are excluded by name, not by string, so adding a line inside one of
|
||||
// them stays excluded and adding a line anywhere else fails the coverage test.
|
||||
//
|
||||
// Every name here is asserted to still exist, so a rename fails loudly instead
|
||||
// of silently widening the exemption.
|
||||
var promptDecls = map[string]string{
|
||||
"ReplySystemPrompt": "the system prompt for the reply model",
|
||||
"replyContext": "renders the decision FOR the model, never spoken",
|
||||
"ruleTopics": "situation descriptions fed to the nudge prompt",
|
||||
"ruleTopic": "same, plus the two prefixes it composes",
|
||||
"buildNudgePrompt": "the nudge prompt itself",
|
||||
"chatUserMessage": "the history block handed to the model",
|
||||
"PhraseReminder": "the reminder prompt; its reply is scored, its prompt is not",
|
||||
}
|
||||
|
||||
// promptFiles — files whose whole job is prompt text. Asserted to exist, same
|
||||
// reason as promptDecls.
|
||||
var promptFiles = map[string]string{
|
||||
"prompts.go": "every literal in it is a prompt",
|
||||
}
|
||||
|
||||
func hasCyrillic(s string) bool {
|
||||
for _, r := range s {
|
||||
if unicode.Is(unicode.Cyrillic, r) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// TestGoFloorCoverage — the guard that survives the next person.
|
||||
//
|
||||
// It reads the package source and requires every Russian string literal to be
|
||||
// one of two things: reachable in floorCorpus (so TestGoFloorPersona scored it),
|
||||
// or inside a declaration named above as prompt-side. There is no third answer
|
||||
// and no way to add a floor string that quietly gets neither.
|
||||
func TestGoFloorCoverage(t *testing.T) {
|
||||
var scored []string
|
||||
for _, line := range floorCorpus() {
|
||||
scored = append(scored, line.text)
|
||||
}
|
||||
// A literal is covered when every Russian piece of it shows up in something
|
||||
// the corpus scored. Pieces, not the whole string, because a format string
|
||||
// ("%d ч") and a concatenation fragment ("Не отвечает: ") only ever reach him
|
||||
// with the surrounding value filled in.
|
||||
covered := func(lit string) bool {
|
||||
for _, part := range formatVerbRE.Split(lit, -1) {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" || !hasCyrillic(part) {
|
||||
continue
|
||||
}
|
||||
found := false
|
||||
for _, s := range scored {
|
||||
if strings.Contains(s, part) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
fset := token.NewFileSet()
|
||||
pkgs, err := parser.ParseDir(fset, ".", func(fi fs.FileInfo) bool {
|
||||
return !strings.HasSuffix(fi.Name(), "_test.go")
|
||||
}, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("parse package: %v", err)
|
||||
}
|
||||
pkg, ok := pkgs["phraser"]
|
||||
if !ok {
|
||||
t.Fatal("package phraser did not parse — the coverage guard cannot run")
|
||||
}
|
||||
|
||||
seenDecl := map[string]bool{}
|
||||
seenFile := map[string]bool{}
|
||||
for path, file := range pkg.Files {
|
||||
base := path[strings.LastIndexByte(path, '/')+1:]
|
||||
if _, exempt := promptFiles[base]; exempt {
|
||||
seenFile[base] = true
|
||||
continue
|
||||
}
|
||||
for _, decl := range file.Decls {
|
||||
names := declNames(decl)
|
||||
skip := false
|
||||
for _, n := range names {
|
||||
if _, ok := promptDecls[n]; ok {
|
||||
seenDecl[n] = true
|
||||
skip = true
|
||||
}
|
||||
}
|
||||
if skip {
|
||||
continue
|
||||
}
|
||||
ast.Inspect(decl, func(n ast.Node) bool {
|
||||
bl, ok := n.(*ast.BasicLit)
|
||||
if !ok || bl.Kind != token.STRING {
|
||||
return true
|
||||
}
|
||||
lit, err := strconv.Unquote(bl.Value)
|
||||
if err != nil || !hasCyrillic(lit) {
|
||||
return true
|
||||
}
|
||||
if !covered(lit) {
|
||||
t.Errorf("%s: Russian literal %q is spoken by nothing the persona guard scores.\n"+
|
||||
"Either reach it from floorCorpus in persona_floor_test.go, or — if it is written "+
|
||||
"for the model rather than for him — name its declaration in promptDecls.",
|
||||
fset.Position(bl.Pos()), lit)
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
}
|
||||
for name, why := range promptDecls {
|
||||
if !seenDecl[name] {
|
||||
t.Errorf("promptDecls names %q (%s) and no such declaration exists — "+
|
||||
"a rename left the exemption open", name, why)
|
||||
}
|
||||
}
|
||||
for base, why := range promptFiles {
|
||||
if !seenFile[base] {
|
||||
t.Errorf("promptFiles names %q (%s) and no such file exists", base, why)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// declNames — the names a top-level declaration binds, so a prompt-side var,
|
||||
// const or func can be matched whatever kind it is.
|
||||
func declNames(decl ast.Decl) []string {
|
||||
switch d := decl.(type) {
|
||||
case *ast.FuncDecl:
|
||||
return []string{d.Name.Name}
|
||||
case *ast.GenDecl:
|
||||
var out []string
|
||||
for _, spec := range d.Specs {
|
||||
switch s := spec.(type) {
|
||||
case *ast.ValueSpec:
|
||||
for _, n := range s.Names {
|
||||
out = append(out, n.Name)
|
||||
}
|
||||
case *ast.TypeSpec:
|
||||
out = append(out, s.Name.Name)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -115,11 +115,32 @@ func (s *Stub) PhraseReminder(_ context.Context, d loop.ReminderDecision) (deliv
|
||||
if text == "" {
|
||||
text = "reminder"
|
||||
}
|
||||
summary := text
|
||||
if len(summary) > 60 {
|
||||
summary = summary[:57] + "..."
|
||||
// Mood, for the same reason PhraseNudge sets it: the Stub is a production
|
||||
// fallback, so it owes the output contract a value. This one was left at the
|
||||
// zero value, which is not one of the five moods.
|
||||
return delivery.PhrasedReminder{
|
||||
Decision: d, Body: text, Summary: reminderSummary(text), Mood: "neutral",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// summaryLimit — how much of a reminder goes to the away channels.
|
||||
const summaryLimit = 60
|
||||
|
||||
// reminderSummary shortens a reminder body to the away-channel summary.
|
||||
//
|
||||
// Counted in runes. Both copies of this counted bytes — `len(s) > 60` and
|
||||
// `s[:57]` — and on a Russian reminder that is wrong twice. A Cyrillic letter is
|
||||
// two bytes, so the cut fell at about 28 letters rather than 60; and byte 57
|
||||
// lands inside a letter about half the time, so the summary ended in half a
|
||||
// rune. That is not cosmetic: Sendable.Summary is the text voicesink hands to
|
||||
// piper and the text the telegram sink posts, so the broken byte was spoken and
|
||||
// sent.
|
||||
func reminderSummary(body string) string {
|
||||
r := []rune(body)
|
||||
if len(r) <= summaryLimit {
|
||||
return body
|
||||
}
|
||||
return delivery.PhrasedReminder{Decision: d, Body: text, Summary: summary}, nil
|
||||
return string(r[:summaryLimit-3]) + "..."
|
||||
}
|
||||
|
||||
// phraseNudge — the per-rule templates. each reads the context the predicate
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/kami/maven/internal/delivery"
|
||||
"github.com/kami/maven/internal/dialogue"
|
||||
@@ -185,6 +186,34 @@ func TestPhraseReminderTruncatesLongSummary(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The same truncation, in the language she actually speaks. The test above is
|
||||
// ASCII, which is what let the byte arithmetic stand: `len(s) > 60` and `s[:57]`
|
||||
// cut a Russian reminder at about 28 letters instead of 60, and landed inside a
|
||||
// letter about half the time. Summary is what voicesink hands to piper and what
|
||||
// the telegram sink posts, so half a rune was spoken and sent.
|
||||
func TestPhraseReminderSummaryCountsRunesNotBytes(t *testing.T) {
|
||||
long := "позвонить маме и забрать посылку из пункта выдачи на соседней улице до восьми вечера"
|
||||
rd := loop.ReminderDecision{
|
||||
Reminder: store.Reminder{Payload: `{"text":"` + long + `"}`},
|
||||
State: loop.State{Now: time.Now().UTC()},
|
||||
}
|
||||
pr, _ := NewStub().PhraseReminder(context.Background(), rd)
|
||||
if !utf8.ValidString(pr.Summary) {
|
||||
t.Fatalf("summary is not valid UTF-8, it was cut mid-letter: %q", pr.Summary)
|
||||
}
|
||||
if n := utf8.RuneCountInString(pr.Summary); n > summaryLimit {
|
||||
t.Fatalf("summary = %d runes, want at most %d: %q", n, summaryLimit, pr.Summary)
|
||||
}
|
||||
// The cut must be near the limit, not near half of it. A byte count would
|
||||
// stop at 28 letters here.
|
||||
if n := utf8.RuneCountInString(pr.Summary); n < summaryLimit-5 {
|
||||
t.Fatalf("summary = %d runes, cut far too early — counted in bytes? %q", n, pr.Summary)
|
||||
}
|
||||
if pr.Mood == "" {
|
||||
t.Error("Mood is empty; the Stub is a production fallback and owes the contract a mood")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPhraseReminderNonJSONPayload(t *testing.T) {
|
||||
// a payload that isn't JSON → the phraser falls back to the raw string.
|
||||
rd := loop.ReminderDecision{
|
||||
|
||||
@@ -58,8 +58,15 @@ try:
|
||||
# read at all until V-579: "в 9" set the reminder and "на 9" did not.
|
||||
# "к двум часам" is a third preposition and the dative that goes with it,
|
||||
# and it was read as no time at all until V-609.
|
||||
# The preposition is normalised as well as the hour (V-610). dateparser
|
||||
# joins a day word to a clock through "в" and through no other Russian
|
||||
# preposition, so "завтра к 03:00 pm" loses the clock and resolves to
|
||||
# tomorrow at the CURRENT minute. "на" was silently losing it the same way.
|
||||
def _at(m):
|
||||
prep = 'at' if m.group(1).lower() in ('at', 'by') else 'в'
|
||||
return '%s %02d:00' % (prep, int(m.group(2)))
|
||||
text = re.sub(r'(?<![\w:])(в|во|на|к|ко|at|by)\s+([01]?\d|2[0-3])(?:\s+час(?:а|ов|у|ам)?)?(?![\d:.\w])',
|
||||
lambda m: '%s %02d:00' % (m.group(1), int(m.group(2))), text, flags=re.IGNORECASE)
|
||||
_at, text, flags=re.IGNORECASE)
|
||||
settings = {'PREFER_DATES_FROM': 'future', 'RELATIVE_BASE': now}
|
||||
# Two-step: search_dates finds the date substring in text,
|
||||
# parse() gets the time right (search_dates mishandles AM/PM).
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The fact-parser corpus (V-586). DefaultFactParser is the only thing standing
|
||||
// between a spoken sentence and a written self-care fact, and until this file
|
||||
// it was covered by a handful of examples chosen by whoever last edited it. The
|
||||
// RU routing fixture does not cover it either: it holds three fact cases and
|
||||
// all three miss on intent, so the parser is never reached and a parser change
|
||||
// scores as "unchanged".
|
||||
//
|
||||
// So the corpus is here, and it scores BOTH implementations: the closed-class
|
||||
// parser that ships, and legacyFactParse below, a faithful copy of the
|
||||
// hand-written substring version 22edc3c replaced. The table is the measurement
|
||||
// — see docs/evals/2026-08-06-fact-parser.md for the numbers as of that day.
|
||||
//
|
||||
// Three case classes, and the third is the point:
|
||||
//
|
||||
// true positive — a sentence he would say, with the key it must write.
|
||||
// misfire — a sentence the substring parser wrote a fact for and
|
||||
// should not have. want is "".
|
||||
// false-negative — a sentence he would plausibly say whose word is in NO
|
||||
// lexicon set. want is the key a human would assign, and
|
||||
// the ship parser is EXPECTED to miss it. These measure the
|
||||
// cost of the word-list design, not a bug in it.
|
||||
//
|
||||
// Nothing here asserts a score. A closed class is a decision about vocabulary
|
||||
// and the bar belongs in a dated eval, not in an assertion that turns every
|
||||
// vocabulary edit into a red test. What it does fail on is a regression in the
|
||||
// two classes that are not judgement calls: a true positive that stops matching
|
||||
// and a misfire that starts.
|
||||
|
||||
type factCase struct {
|
||||
utterance string
|
||||
want string // "" — no fact
|
||||
class string // "tp", "misfire", "fn"
|
||||
broken bool // ship parser is known to get this wrong; see the 06-08 eval
|
||||
note string
|
||||
}
|
||||
|
||||
var factCorpus = []factCase{
|
||||
// ---- water, true positives -------------------------------------------
|
||||
{"выпил стакан воды", "water", "tp", false, ""},
|
||||
{"попил воды", "water", "tp", false, ""},
|
||||
{"я попил водички", "water", "tp", false, ""},
|
||||
{"пью воду", "water", "tp", false, ""},
|
||||
{"воду пил уже", "water", "tp", false, ""},
|
||||
{"допил воду", "water", "tp", true, "REGRESSION: the dictionary lemmatises допил to допилить, to finish sawing — the same saw collision drink_verbs already carries пил for, unfixed for the prefixed form"},
|
||||
{"запил таблетку водой", "water", "tp", false, ""},
|
||||
{"drank water", "water", "tp", false, ""},
|
||||
{"i drank some water", "water", "tp", false, ""},
|
||||
|
||||
// ---- meal, true positives --------------------------------------------
|
||||
{"поужинал", "meal", "tp", false, ""},
|
||||
{"я пообедал", "meal", "tp", false, ""},
|
||||
{"позавтракал кашей", "meal", "tp", false, ""},
|
||||
{"перекусил бутербродом", "meal", "tp", false, ""},
|
||||
{"покушал", "meal", "tp", false, ""},
|
||||
{"поел супа", "meal", "tp", false, ""},
|
||||
{"обед был в час", "meal", "tp", false, ""},
|
||||
{"ужинать буду позже", "meal", "tp", false, ""},
|
||||
{"i ate", "meal", "tp", false, ""},
|
||||
{"had lunch", "meal", "tp", false, ""},
|
||||
{"dinner done", "meal", "tp", false, ""},
|
||||
|
||||
// ---- shower, true positives ------------------------------------------
|
||||
{"принял душ", "shower", "tp", false, ""},
|
||||
{"сходил в душ", "shower", "tp", false, ""},
|
||||
{"душ принят", "shower", "tp", false, ""},
|
||||
{"ополоснулся душем", "shower", "tp", false, ""},
|
||||
{"took a shower", "shower", "tp", false, ""},
|
||||
{"i showered", "shower", "tp", false, ""},
|
||||
|
||||
// ---- break, true positives -------------------------------------------
|
||||
{"сделал перерыв", "break", "tp", false, ""},
|
||||
{"отдохнул полчаса", "break", "tp", false, ""},
|
||||
{"передохнул немного", "break", "tp", false, ""},
|
||||
{"отдыхаю", "break", "tp", false, ""},
|
||||
{"был перерыв на обед", "meal", "tp", false, "meal wins the switch; both keys are true of the sentence"},
|
||||
{"took a break", "break", "tp", false, ""},
|
||||
|
||||
// ---- sleep, true positives -------------------------------------------
|
||||
{"спал восемь часов", "sleep", "tp", false, ""},
|
||||
{"спала плохо", "sleep", "tp", false, "she may report her own; the parser is speaker-agnostic"},
|
||||
{"поспал днём", "sleep", "tp", false, ""},
|
||||
{"выспался наконец", "sleep", "tp", false, ""},
|
||||
{"проспал будильник", "sleep", "tp", false, ""},
|
||||
{"пойду спать", "sleep", "tp", false, ""},
|
||||
{"slept 8 hours", "sleep", "tp", false, ""},
|
||||
{"i slept badly", "sleep", "tp", false, ""},
|
||||
|
||||
// ---- misfires 22edc3c set out to reject -------------------------------
|
||||
{"пилот сказал что вылет через час", "", "misfire", false, "substring пил"},
|
||||
{"водитель уже подъехал", "", "misfire", false, "substring вод"},
|
||||
{"надо заводить машину", "", "misfire", false, "substring вод"},
|
||||
{"душа болит", "", "misfire", false, "substring душ"},
|
||||
{"в комнате душно", "", "misfire", false, "substring душ"},
|
||||
{"это была беда", "", "misfire", false, "substring еда"},
|
||||
{"наша победа", "", "misfire", false, "substring еда"},
|
||||
{"пила лежит в гараже", "", "misfire", false, "substring пил"},
|
||||
{"водитель пилота ждёт", "", "misfire", false, "both stems in one sentence — the old water arm fires"},
|
||||
{"обеденный перерыв отменили", "", "misfire", true, "neither parser gets this: the old one writes meal off the adjective, the new one writes break off перерыв. A cancelled break is not a break taken."},
|
||||
|
||||
// ---- hard negatives that decide the design ----------------------------
|
||||
{"есть новости по бэкапу базы", "", "misfire", false, "есть is the existential, not a meal"},
|
||||
{"напоминания на завтра есть", "", "misfire", false, "завтра carries завтрак as a substring"},
|
||||
{"на душе легко", "", "misfire", false, "душе is the soul, and also the prepositional of душ"},
|
||||
{"пилил доску весь вечер", "", "misfire", false, ""},
|
||||
{"поставь будильник на завтра", "", "misfire", false, "завтра again, no meal"},
|
||||
|
||||
// ---- false negatives: words in no lexicon set -------------------------
|
||||
// Water.
|
||||
{"выпил чаю", "water", "fn", false, "hydration by any liquid; чай is in no set"},
|
||||
{"глотнул воды", "water", "fn", false, "глотнуть is not a drink verb"},
|
||||
{"хлебнул воды", "water", "fn", false, "хлебнуть is not a drink verb"},
|
||||
{"воды хлебнул из бутылки", "water", "fn", false, ""},
|
||||
{"выпил стакан", "water", "fn", false, "the noun is elided; he says this"},
|
||||
{"i hydrated", "water", "fn", false, "hydrate is in no set"},
|
||||
{"finished my bottle of water", "water", "fn", false, "no drink verb in the English set"},
|
||||
|
||||
// Meal.
|
||||
{"ем суп", "meal", "fn", false, "есть is deliberately absent, and this is the cost"},
|
||||
{"съел бутерброд", "meal", "fn", false, "съесть is in no set"},
|
||||
{"наелся", "meal", "fn", false, "наесться is in no set"},
|
||||
{"пожрал", "meal", "fn", false, "coarse but spoken"},
|
||||
{"полдник был", "meal", "fn", false, "полдник is in no set"},
|
||||
{"i had a snack", "meal", "fn", false, "snack is in no set"},
|
||||
{"having supper", "meal", "fn", false, "supper is in no set"},
|
||||
{"brunch was good", "meal", "fn", false, "brunch is in no set"},
|
||||
{"i eat now", "meal", "fn", false, "eat is in no set — only ate is"},
|
||||
|
||||
// Shower.
|
||||
{"был в душе", "shower", "fn", false, "prepositional; anyExact cannot take it without taking the soul"},
|
||||
{"после душа полегчало", "shower", "fn", false, "genitive, same collision"},
|
||||
{"помылся", "shower", "fn", false, "помыться is in no set"},
|
||||
{"сходил в ванную", "shower", "fn", false, "ванная is in no set"},
|
||||
{"искупался", "shower", "fn", false, "искупаться is in no set"},
|
||||
{"i am showering", "shower", "fn", false, "showering is not a member and the set is exact-matched"},
|
||||
|
||||
// Break.
|
||||
{"сделал передышку", "break", "fn", false, "передышка is in no set"},
|
||||
{"перекур", "break", "fn", false, "перекур is in no set"},
|
||||
{"полежал немного", "break", "fn", false, "полежать is in no set"},
|
||||
{"сделал паузу", "break", "fn", false, "пауза is in no set"},
|
||||
{"i took five", "break", "fn", false, "idiomatic, in no set"},
|
||||
{"resting now", "break", "fn", false, "resting is not a member and rest is matched by lemma only"},
|
||||
|
||||
// Sleep.
|
||||
{"вздремнул", "sleep", "fn", false, "вздремнуть is in no set"},
|
||||
{"прикорнул на диване", "sleep", "fn", false, "прикорнуть is in no set"},
|
||||
{"дрых до обеда", "sleep", "fn", false, "дрыхнуть is in no set — and обед makes this a MEAL for both parsers"},
|
||||
{"недоспал", "sleep", "fn", false, "недоспать is in no set"},
|
||||
{"лёг в двенадцать", "sleep", "fn", false, "лечь is in no set"},
|
||||
{"сон был короткий", "sleep", "fn", false, "сон deliberately absent"},
|
||||
{"i napped", "sleep", "fn", false, "nap is in no set"},
|
||||
{"took a nap", "sleep", "fn", false, "break matches nothing here either"},
|
||||
}
|
||||
|
||||
// legacyFactParse — DefaultFactParser exactly as it stood at 22edc3c's parent
|
||||
// (0445693), copied here so the corpus scores the trade rather than describing
|
||||
// it. Do not fix it. It is a frozen baseline, and the day it stops being worth
|
||||
// comparing against, delete it and the two-column table with it.
|
||||
func legacyFactParse(utterance string) (string, string, bool) {
|
||||
s := strings.ToLower(strings.TrimSpace(utterance))
|
||||
hasRoot := func(root string) bool { return strings.Contains(s, root) }
|
||||
containsWord := func(w string) bool {
|
||||
for _, tok := range strings.Fields(s) {
|
||||
if tok == w {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
switch {
|
||||
case (containsWord("water") && containsWord("drank")) ||
|
||||
(hasRoot("вод") && (hasRoot("пил") || hasRoot("пью") || hasRoot("пей"))):
|
||||
return "water", `"drank"`, true
|
||||
case containsWord("meal") || (containsWord("ate") && !containsWord("backup")) || containsWord("lunch") || containsWord("dinner") ||
|
||||
hasRoot("поел") || hasRoot("поесть") || hasRoot("куша") || hasRoot("обед") || hasRoot("ужин") || hasRoot("завтрак") || hasRoot("еда"):
|
||||
return "meal", `"ate"`, true
|
||||
case containsWord("shower") || hasRoot("душ"):
|
||||
return "shower", `"took"`, true
|
||||
case containsWord("break") || hasRoot("перерыв") || hasRoot("отдох"):
|
||||
return "break", `"took"`, true
|
||||
case containsWord("slept") || containsWord("sleep") ||
|
||||
hasRoot("спал") || hasRoot("выспал"):
|
||||
if v, ok := parseDurationValue(afterWord(s, "slept")); ok {
|
||||
return "sleep", strconv.Quote(v), true
|
||||
}
|
||||
return "sleep", `"slept"`, true
|
||||
}
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
// TestFactParserCorpus scores both parsers over the corpus and prints the
|
||||
// side-by-side. Run it with -v; the table is the output.
|
||||
func TestFactParserCorpus(t *testing.T) {
|
||||
type tally struct{ tpHit, tpMiss, misfire, misfireOK, fnHit, fnMiss int }
|
||||
var now, old tally
|
||||
|
||||
var rows []string
|
||||
rows = append(rows, "| utterance | class | want | old (substring) | new (closed class) |")
|
||||
rows = append(rows, "|---|---|---|---|---|")
|
||||
|
||||
score := func(key string, ok bool, c factCase, tl *tally) string {
|
||||
got := ""
|
||||
if ok {
|
||||
got = key
|
||||
}
|
||||
switch c.class {
|
||||
case "tp":
|
||||
if got == c.want {
|
||||
tl.tpHit++
|
||||
} else {
|
||||
tl.tpMiss++
|
||||
}
|
||||
case "misfire":
|
||||
if got == c.want {
|
||||
tl.misfireOK++
|
||||
} else {
|
||||
tl.misfire++
|
||||
}
|
||||
case "fn":
|
||||
if got == c.want {
|
||||
tl.fnHit++
|
||||
} else {
|
||||
tl.fnMiss++
|
||||
}
|
||||
}
|
||||
if got == "" {
|
||||
return "—"
|
||||
}
|
||||
return got
|
||||
}
|
||||
|
||||
for _, c := range factCorpus {
|
||||
nk, _, nok := DefaultFactParser{}.Parse(c.utterance)
|
||||
ok, _, ook := legacyFactParse(c.utterance)
|
||||
gotNew := score(nk, nok, c, &now)
|
||||
gotOld := score(ok, ook, c, &old)
|
||||
want := c.want
|
||||
if want == "" {
|
||||
want = "—"
|
||||
}
|
||||
mark := ""
|
||||
if gotOld != gotNew {
|
||||
mark = " **≠**"
|
||||
}
|
||||
rows = append(rows, "| `"+c.utterance+"` | "+c.class+" | "+want+" | "+gotOld+" | "+gotNew+mark+" |")
|
||||
}
|
||||
|
||||
line := func(name string, tl tally) string {
|
||||
return name +
|
||||
": true positives " + strconv.Itoa(tl.tpHit) + "/" + strconv.Itoa(tl.tpHit+tl.tpMiss) +
|
||||
", misfires rejected " + strconv.Itoa(tl.misfireOK) + "/" + strconv.Itoa(tl.misfireOK+tl.misfire) +
|
||||
", false-negative cases recovered " + strconv.Itoa(tl.fnHit) + "/" + strconv.Itoa(tl.fnHit+tl.fnMiss)
|
||||
}
|
||||
t.Log("\n" + strings.Join(rows, "\n") + "\n\n" + line("old (substring)", old) + "\n" + line("new (closed class)", now))
|
||||
|
||||
// The two regressions that are not judgement calls.
|
||||
for _, c := range factCorpus {
|
||||
k, _, ok := DefaultFactParser{}.Parse(c.utterance)
|
||||
got := ""
|
||||
if ok {
|
||||
got = k
|
||||
}
|
||||
if c.class == "fn" {
|
||||
continue
|
||||
}
|
||||
if c.broken {
|
||||
// Recorded as wrong on 06-08. If it starts passing, someone fixed
|
||||
// it and the flag is now a lie — say so rather than staying green.
|
||||
if got == c.want {
|
||||
t.Errorf("%q now returns %q as wanted — drop its broken flag", c.utterance, got)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if got != c.want {
|
||||
t.Errorf("%s %q: got %q, want %q (%s)", c.class, c.utterance, got, c.want, c.note)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os/exec"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// probeNow is the clock the five sentences below were measured against on the
|
||||
// box at 03:53 on 2026-08-06, right after #252 deployed. Three of them wrote a
|
||||
// reminder for 03:53 itself, which is the current minute and not an hour anyone
|
||||
// said (V-610).
|
||||
var probeNow = time.Date(2026, 8, 6, 3, 53, 0, 0, time.Local)
|
||||
|
||||
// kProbe — the five sentences, with what each must resolve to. The two that
|
||||
// already worked are here so that fixing "к" cannot cost "в".
|
||||
var kProbe = []struct {
|
||||
text string
|
||||
// day is the offset from probeNow's date, hour is the fire hour.
|
||||
day int
|
||||
hour int
|
||||
whyItIs string
|
||||
}{
|
||||
{
|
||||
text: "напомни завтра в три часа дня позвонить врачу",
|
||||
day: 1,
|
||||
hour: 15,
|
||||
whyItIs: "в plus a spoken hour and a qualifier resolves, and did before #252",
|
||||
},
|
||||
{
|
||||
text: "напомни завтра в 15:00 позвонить врачу",
|
||||
day: 1,
|
||||
hour: 15,
|
||||
whyItIs: "a written clock resolves, and did before #252",
|
||||
},
|
||||
{
|
||||
text: "напомни завтра к трём часам дня позвонить врачу",
|
||||
day: 1,
|
||||
hour: 15,
|
||||
whyItIs: "к names the same hour в does, and wrote 03:53 after #252",
|
||||
},
|
||||
{
|
||||
text: "напомни сегодня к пяти часам вечера позвонить врачу",
|
||||
day: 0,
|
||||
hour: 17,
|
||||
whyItIs: "the same defect on today and on the oblique пяти",
|
||||
},
|
||||
{
|
||||
text: "напомни завтра к трём часам позвонить врачу",
|
||||
day: 1,
|
||||
hour: 3,
|
||||
whyItIs: "no qualifier, so the hour reads as spoken and the daemon asks which half",
|
||||
},
|
||||
}
|
||||
|
||||
// TestKPrepositionHourStub — the probe against the floor parser, which answers
|
||||
// on every box whether or not python is installed.
|
||||
func TestKPrepositionHourStub(t *testing.T) {
|
||||
ex := Extractor{Time: StubDateTimeParser{}}
|
||||
for _, c := range kProbe {
|
||||
t.Run(c.text, func(t *testing.T) {
|
||||
got := ex.Extract(context.Background(), IntentReminder, c.text, probeNow)
|
||||
if !got.HasTime {
|
||||
t.Fatalf("time slot empty: %s", c.whyItIs)
|
||||
}
|
||||
checkFire(t, got.Time, c.day, c.hour, c.whyItIs)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestKPrepositionHourPython — the same probe against the production parser.
|
||||
// Skips where python3 or dateparser is missing, as the rest of this package's
|
||||
// python tests do.
|
||||
func TestKPrepositionHourPython(t *testing.T) {
|
||||
requirePython(t)
|
||||
p := NewPythonDateParser()
|
||||
ex := Extractor{Time: p}
|
||||
for _, c := range kProbe {
|
||||
t.Run(c.text, func(t *testing.T) {
|
||||
got := ex.Extract(context.Background(), IntentReminder, c.text, probeNow)
|
||||
if !got.HasTime {
|
||||
t.Fatalf("time slot empty: %s", c.whyItIs)
|
||||
}
|
||||
checkFire(t, got.Time, c.day, c.hour, c.whyItIs)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func checkFire(t *testing.T, fire time.Time, dayOffset, hour int, why string) {
|
||||
t.Helper()
|
||||
if fire.Hour() != hour || fire.Minute() != 0 {
|
||||
t.Errorf("fire = %s, want %02d:00 — %s", fire.Format("2006-01-02 15:04"), hour, why)
|
||||
}
|
||||
if fire.Minute() == probeNow.Minute() && fire.Hour() == probeNow.Hour() {
|
||||
t.Errorf("fire = %s, which is the clock at the moment of the turn and not an hour he said", fire.Format("15:04"))
|
||||
}
|
||||
want := probeNow.AddDate(0, 0, dayOffset)
|
||||
if fire.Year() != want.Year() || fire.Month() != want.Month() || fire.Day() != want.Day() {
|
||||
t.Errorf("fire = %s, want the %s — %s", fire.Format("2006-01-02"), want.Format("2006-01-02"), why)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUnresolvedHourLeavesTheSlotEmpty — the durable half. A parser that read
|
||||
// the day and took the minute off the clock must not fill the time slot, no
|
||||
// matter which preposition lost the hour. This is the whole class the "к" case
|
||||
// was one member of.
|
||||
func TestUnresolvedHourLeavesTheSlotEmpty(t *testing.T) {
|
||||
ex := Extractor{Time: clockEchoParser{}}
|
||||
for _, s := range []string{
|
||||
"напомни завтра к трём часам дня позвонить врачу",
|
||||
"напомни завтра в три часа дня позвонить врачу",
|
||||
"напомни завтра на девять позвонить врачу",
|
||||
"напомни сегодня к пяти часам вечера позвонить врачу",
|
||||
} {
|
||||
got := ex.Extract(context.Background(), IntentReminder, s, probeNow)
|
||||
if got.HasTime {
|
||||
t.Errorf("Extract(%q) filled the slot with %s, which is the current minute; she has to ask", s, got.Time.Format("15:04"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSpokenMinutesSurviveTheRefusal — the three shapes that name a minute of
|
||||
// their own, and an hour that happens to be the hour it is spoken in. Refusing
|
||||
// on the whole instant instead of on the minute would cost every one of these,
|
||||
// and ru-rem-006 in the routing fixture is the case that says so.
|
||||
func TestSpokenMinutesSurviveTheRefusal(t *testing.T) {
|
||||
noon := time.Date(2026, 7, 30, 12, 0, 0, 0, time.UTC)
|
||||
ex := Extractor{Time: StubDateTimeParser{}}
|
||||
for _, c := range []struct {
|
||||
text string
|
||||
hour int
|
||||
min int
|
||||
}{
|
||||
{"напомни послезавтра в 12 забрать заказ", 12, 0},
|
||||
{"разбуди меня в 6:30", 6, 30},
|
||||
{"напомни в половине восьмого выпить таблетку", 7, 30},
|
||||
{"напомни без четверти восемь выходить", 7, 45},
|
||||
} {
|
||||
got := ex.Extract(context.Background(), IntentReminder, c.text, noon)
|
||||
if !got.HasTime {
|
||||
t.Errorf("Extract(%q) left the slot empty; he said the time", c.text)
|
||||
continue
|
||||
}
|
||||
if got.Time.Hour() != c.hour || got.Time.Minute() != c.min {
|
||||
t.Errorf("Extract(%q) = %s, want %02d:%02d", c.text, got.Time.Format("15:04"), c.hour, c.min)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntervalKeepsTheCurrentMinute — an interval is measured from now and may
|
||||
// land on now's own minute, so the refusal above must not reach it.
|
||||
func TestIntervalKeepsTheCurrentMinute(t *testing.T) {
|
||||
ex := Extractor{Time: StubDateTimeParser{}}
|
||||
got := ex.Extract(context.Background(), IntentReminder, "напомни через час позвонить врачу", probeNow)
|
||||
if !got.HasTime {
|
||||
t.Fatal("через час names one instant and answers the hour and the day together")
|
||||
}
|
||||
if want := probeNow.Add(time.Hour); !got.Time.Equal(want) {
|
||||
t.Errorf("fire = %s, want %s", got.Time.Format("15:04"), want.Format("15:04"))
|
||||
}
|
||||
}
|
||||
|
||||
func requirePython(t *testing.T) {
|
||||
t.Helper()
|
||||
if _, err := exec.LookPath("python3"); err != nil {
|
||||
t.Skip("python3 not on PATH — skipping dateparser tests")
|
||||
}
|
||||
if err := exec.Command("python3", "-c", "import dateparser").Run(); err != nil {
|
||||
t.Skip("python dateparser not installed — skipping dateparser tests")
|
||||
}
|
||||
}
|
||||
+42
-28
@@ -7,6 +7,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/lexicon"
|
||||
"github.com/kami/maven/internal/morph"
|
||||
)
|
||||
|
||||
// DateTimeParser — resolves relative→absolute AT CAPTURE ("in 4h" → now+4h),
|
||||
@@ -55,11 +56,12 @@ func (e Extractor) Extract(ctx context.Context, intent Intent, utterance string,
|
||||
switch intent {
|
||||
case IntentReminder:
|
||||
if e.Time != nil {
|
||||
// NamesAnHour is the gate, not the parser's ok (V-577, V-579). A
|
||||
// sentence that names a day and no hour parses to that day at the
|
||||
// current minute, and filling the slot with it invents the answer
|
||||
// she asked for. Left empty, the daemon asks.
|
||||
if t, ok, err := e.Time.Parse(ctx, utterance, now); err == nil && ok && NamesAnHour(utterance) {
|
||||
// ResolvedTheHour is the gate, not the parser's ok (V-577, V-579,
|
||||
// V-610). A sentence that names a day and no hour parses to that day
|
||||
// at the current minute, and so does one whose hour the parser could
|
||||
// not read. Filling the slot with either invents the answer she asked
|
||||
// for. Left empty, the daemon asks.
|
||||
if t, ok, err := e.Time.Parse(ctx, utterance, now); err == nil && ok && ResolvedTheHour(utterance, t) {
|
||||
s.Time = t
|
||||
s.HasTime = true
|
||||
}
|
||||
@@ -122,23 +124,22 @@ type DefaultFactParser struct{}
|
||||
|
||||
func (DefaultFactParser) Parse(utterance string) (string, string, bool) {
|
||||
s := strings.ToLower(strings.TrimSpace(utterance))
|
||||
// Maven is ru-first (voice, tts). Each case carries the English tokens AND
|
||||
// Russian stems — matched by prefix (hasStem) because Russian inflects
|
||||
// (воды/воду/вода share "вод"), so exact-token matching would miss most
|
||||
// real utterances and silently drop the capture.
|
||||
toks := strings.Fields(s)
|
||||
// Maven is ru-first (voice, tts), and Russian inflects, so the words each
|
||||
// case reads are closed classes in internal/lexicon and the inflection is
|
||||
// morph's job (V-586). What stood here was a fourth mechanism: hand-written
|
||||
// stems matched as substrings, so "пилот" was drinking, "водитель" was
|
||||
// water, "душно" was a shower and "победа" was a meal.
|
||||
switch {
|
||||
case (containsWord(s, "water") && containsWord(s, "drank")) ||
|
||||
(hasRoot(s, "вод") && (hasRoot(s, "пил") || hasRoot(s, "пью") || hasRoot(s, "пей"))):
|
||||
case anyLemma(toks, lexicon.WaterNouns()) && anyLemma(toks, lexicon.DrinkVerbs()):
|
||||
return "water", `"drank"`, true
|
||||
case containsWord(s, "meal") || (containsWord(s, "ate") && !containsWord(s, "backup")) || containsWord(s, "lunch") || containsWord(s, "dinner") ||
|
||||
hasRoot(s, "поел") || hasRoot(s, "поесть") || hasRoot(s, "куша") || hasRoot(s, "обед") || hasRoot(s, "ужин") || hasRoot(s, "завтрак") || hasRoot(s, "еда"):
|
||||
case anyLemma(toks, lexicon.MealWords()):
|
||||
return "meal", `"ate"`, true
|
||||
case containsWord(s, "shower") || hasRoot(s, "душ"):
|
||||
case anyExact(toks, lexicon.ShowerWords()):
|
||||
return "shower", `"took"`, true
|
||||
case containsWord(s, "break") || hasRoot(s, "перерыв") || hasRoot(s, "отдох"):
|
||||
case anyLemma(toks, lexicon.BreakWords()):
|
||||
return "break", `"took"`, true
|
||||
case containsWord(s, "slept") || containsWord(s, "sleep") ||
|
||||
hasRoot(s, "спал") || hasRoot(s, "выспал"):
|
||||
case anyLemma(toks, lexicon.SleepWords()):
|
||||
if v, ok := parseDurationValue(afterWord(s, "slept")); ok {
|
||||
return "sleep", strconv.Quote(v), true
|
||||
}
|
||||
@@ -147,18 +148,31 @@ func (DefaultFactParser) Parse(utterance string) (string, string, bool) {
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
// hasRoot — substring match on the whole utterance. Russian inflects with BOTH
|
||||
// prefixes and suffixes (вы-пил, по-пил, пил-и), so a prefix test misses the
|
||||
// verb; the root as a substring catches all forms. A rare over-match (пил in
|
||||
// пилот) is fine at this floor. ponytail: substring roots over a morphology lib
|
||||
// until misfires actually bite.
|
||||
func hasRoot(s, root string) bool { return strings.Contains(s, root) }
|
||||
// anyLemma reports whether any token is one of the set's words, in any case or
|
||||
// tense. Exact equality first because morph falls back to it without the
|
||||
// dictionary, and because a member the dictionary lemmatises oddly is carried
|
||||
// in the set as its surface form.
|
||||
func anyLemma(toks []string, set []string) bool {
|
||||
for _, tok := range toks {
|
||||
t := cleanWord(tok)
|
||||
for _, w := range set {
|
||||
if t == w || morph.SameWord(t, w) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// containsWord — whole-token membership (avoids "breakfast" matching "break").
|
||||
func containsWord(s, w string) bool {
|
||||
for _, tok := range strings.Fields(s) {
|
||||
if tok == w {
|
||||
return true
|
||||
// anyExact is anyLemma without the grammar, for a set whose members share a
|
||||
// lemma with a word that means something else. Only ShowerWords needs it.
|
||||
func anyExact(toks []string, set []string) bool {
|
||||
for _, tok := range toks {
|
||||
t := cleanWord(tok)
|
||||
for _, w := range set {
|
||||
if t == w {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
|
||||
@@ -23,6 +23,24 @@ func TestDefaultFactParserRU(t *testing.T) {
|
||||
{"немного отдохнул", "break", true},
|
||||
{"поспал шесть часов", "sleep", true},
|
||||
{"перезапусти nginx", "", false}, // an act, not a fact
|
||||
// Inflections the substring stems caught and a lemma test must keep.
|
||||
{"только что выпил кружку воды", "water", true},
|
||||
{"воды попил наконец", "water", true},
|
||||
{"пью воду", "water", true},
|
||||
{"поужинал", "meal", true},
|
||||
{"отметь что я позавтракал овсянкой", "meal", true},
|
||||
{"сходил в душ", "shower", true},
|
||||
{"отдыхал час", "break", true},
|
||||
{"выспался", "sleep", true},
|
||||
// Misfires the substring stems accepted. Rejecting them is the point of
|
||||
// V-586: none of these is a fact about his day.
|
||||
{"я пилот", "", false},
|
||||
{"водитель приехал", "", false},
|
||||
{"надо заводить машину", "", false},
|
||||
{"у меня душа болит", "", false},
|
||||
{"в комнате душно", "", false},
|
||||
{"это была беда", "", false},
|
||||
{"победа наша", "", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
k, _, ok := p.Parse(c.utterance)
|
||||
|
||||
@@ -119,6 +119,55 @@ func NamesAnHour(text string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// ResolvedTheHour reports whether a parse read the hour the sentence names,
|
||||
// rather than inheriting the clock it was handed as its relative base.
|
||||
//
|
||||
// It is the second half of the gate NamesAnHour opens (V-610). NamesAnHour asks
|
||||
// whether an hour was spoken and cannot ask whether it was read, so a
|
||||
// preposition the parser half knew wrote a reminder at 03:53 for "напомни
|
||||
// завтра к трём часам дня" and confirmed it as if it were the hour he said. A
|
||||
// wrong instant she states as fact is worse than a question, because he stops
|
||||
// thinking about it.
|
||||
//
|
||||
// The tell is the minute. A spoken hour lands on the hour, and the only three
|
||||
// shapes that name a minute of their own are a written clock, a half hour and a
|
||||
// quarter to. A parse that came back with any other minute took it from the
|
||||
// clock it was handed, whatever hour it put in front of it. An interval is
|
||||
// exempt, because it is measured from now and lands wherever the arithmetic
|
||||
// says.
|
||||
//
|
||||
// Comparing the whole instant to now would be the obvious test and it is the
|
||||
// wrong one: "напомни послезавтра в 12" resolves to 12:00 and the fixture's
|
||||
// reference clock is 12:00, so an hour he did say would read as an hour nobody
|
||||
// did.
|
||||
func ResolvedTheHour(text string, t time.Time) bool {
|
||||
if !NamesAnHour(text) {
|
||||
return false
|
||||
}
|
||||
if NamesAnInterval(text) || t.Minute() == 0 {
|
||||
return true
|
||||
}
|
||||
return namesTheMinute(text)
|
||||
}
|
||||
|
||||
// namesTheMinute reports whether the sentence says which minute of the hour it
|
||||
// means, in any of the three ways it can.
|
||||
func namesTheMinute(text string) bool {
|
||||
toks := strings.Fields(strings.ToLower(text))
|
||||
for i, raw := range toks {
|
||||
if isDigitClock(cleanWord(raw)) {
|
||||
return true
|
||||
}
|
||||
if _, _, ok := halfPastAt(toks, i); ok {
|
||||
return true
|
||||
}
|
||||
if _, _, _, ok := quarterToAt(toks, i); ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// NamesAnInterval reports whether the sentence measures the time from now
|
||||
// instead of naming it: "через час", "через 10 минут", "in 30 minutes".
|
||||
//
|
||||
|
||||
@@ -93,8 +93,17 @@ func TestReminderSlotRefusesAnHourNobodySaid(t *testing.T) {
|
||||
if got := ex.Extract(context.Background(), IntentReminder, "на завтра", now); got.HasTime {
|
||||
t.Errorf("«на завтра» filled the time slot with %s, which is the clock", got.Time.Format("15:04"))
|
||||
}
|
||||
if got := ex.Extract(context.Background(), IntentReminder, "на 9", now); !got.HasTime {
|
||||
t.Error("«на 9» names an hour and must still fill the slot")
|
||||
// "на 9" names an hour, and a parser that answered with the clock did not
|
||||
// read it (V-610). Naming one is necessary and reading it is what fills the
|
||||
// slot, so this echo is refused too and the daemon asks.
|
||||
if got := ex.Extract(context.Background(), IntentReminder, "на 9", now); got.HasTime {
|
||||
t.Errorf("«на 9» took %s from the clock; the parser never read the nine", got.Time.Format("15:04"))
|
||||
}
|
||||
// A parser that does read it fills the slot, which is the other half of the
|
||||
// same rule.
|
||||
real := Extractor{Time: StubDateTimeParser{}}
|
||||
if got := real.Extract(context.Background(), IntentReminder, "на 9", now); !got.HasTime || got.Time.Hour() != 9 {
|
||||
t.Errorf("«на 9» must fill the slot with nine o'clock, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,12 @@ const (
|
||||
PlanDay = "plan_day"
|
||||
PlanUncertain = "plan_uncertain"
|
||||
|
||||
// "что дальше?" — the next few entries, not the day. PlanNextMore is the
|
||||
// same sentence when the cap hid something, so the count it states is the
|
||||
// only signal that the day is not over after the last line read.
|
||||
PlanNext = "plan_next"
|
||||
PlanNextMore = "plan_next_more"
|
||||
|
||||
TasksNone = "tasks_none"
|
||||
TasksFirst = "tasks_first"
|
||||
TasksCandidates = "tasks_candidates"
|
||||
@@ -70,6 +76,7 @@ const (
|
||||
|
||||
var summaryKeys = []string{
|
||||
PlanRestEmpty, PlanDayEmpty, PlanDay, PlanUncertain,
|
||||
PlanNext, PlanNextMore,
|
||||
TasksNone, TasksFirst, TasksCandidates,
|
||||
StallOverdue, StallSitting, StallUnconfirmed,
|
||||
ReasonOverdue, ReasonOverdueDays, ReasonToday, ReasonTomorrow,
|
||||
@@ -89,6 +96,8 @@ var summaryFloor = map[string]string{
|
||||
PlanDayEmpty: "на {date} ничего не запланировано.",
|
||||
PlanDay: "план на {date}: {items}",
|
||||
PlanUncertain: "похоже, {line}",
|
||||
PlanNext: "дальше: {items}",
|
||||
PlanNextMore: "дальше: {items}. и ещё {n} {word} до конца дня.",
|
||||
|
||||
TasksNone: "задач нет.",
|
||||
TasksFirst: "сначала: {items}",
|
||||
@@ -139,6 +148,8 @@ func LoadSummaries(src rand.Source) (*Summaries, error) {
|
||||
for _, req := range []struct{ key, ph string }{
|
||||
{PlanDayEmpty, "{date}"}, {PlanDay, "{date}"}, {PlanDay, "{items}"},
|
||||
{PlanUncertain, "{line}"},
|
||||
{PlanNext, "{items}"},
|
||||
{PlanNextMore, "{items}"}, {PlanNextMore, "{n}"}, {PlanNextMore, "{word}"},
|
||||
{TasksFirst, "{items}"}, {TasksCandidates, "{items}"},
|
||||
{StallOverdue, "{n}"}, {StallOverdue, "{word}"},
|
||||
{StallSitting, "{n}"}, {StallSitting, "{word}"},
|
||||
|
||||
@@ -30,6 +30,14 @@
|
||||
"fixed": true,
|
||||
"variants": ["похоже, {line}"]
|
||||
},
|
||||
"plan_next": {
|
||||
"fixed": true,
|
||||
"variants": ["дальше: {items}"]
|
||||
},
|
||||
"plan_next_more": {
|
||||
"fixed": true,
|
||||
"variants": ["дальше: {items}. и ещё {n} {word} до конца дня."]
|
||||
},
|
||||
|
||||
"tasks_none": {
|
||||
"fixed": true,
|
||||
|
||||
@@ -2,6 +2,7 @@ package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
@@ -38,16 +39,21 @@ func (s *Store) MarkSent(ctx context.Context, key string, ts time.Time) error {
|
||||
// send goes through RecordNudge, not MarkSent, so the first MarkSent comes on
|
||||
// the repeat path — LastSent may legitimately be zero until then).
|
||||
func (s *Store) LastSent(ctx context.Context, key string) (time.Time, error) {
|
||||
var millis int64
|
||||
// MAX over an empty set is one row holding NULL, not zero rows, so this
|
||||
// scans into a NullInt64 — the same trap OldestPendingTelegram spells out.
|
||||
// A bare int64 turned the ordinary "nothing sent yet" case into a scan
|
||||
// error, and RepeatUnacked aborts its whole sweep on one, so the first
|
||||
// repeat could never go out for any rule.
|
||||
var millis sql.NullInt64
|
||||
err := s.db.QueryRowContext(ctx,
|
||||
`SELECT MAX(sent_at) FROM ack_sends WHERE rule = ?`, key).Scan(&millis)
|
||||
if err != nil {
|
||||
return time.Time{}, fmt.Errorf("last sent %s: %w", key, err)
|
||||
}
|
||||
if millis == 0 {
|
||||
if !millis.Valid {
|
||||
return time.Time{}, nil
|
||||
}
|
||||
return time.UnixMilli(millis).UTC(), nil
|
||||
return time.UnixMilli(millis.Int64).UTC(), nil
|
||||
}
|
||||
|
||||
// MarkAcked marks ALL pending telegram nudges for the rule as "acted" —
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestLastSentOnEmptyTableIsZero pins the aggregate-over-nothing trap that
|
||||
// nudges.go's OldestPendingTelegram already documents: MAX over an empty set is
|
||||
// one row holding NULL, not zero rows. Scanning that into a bare int64 is an
|
||||
// error, and the doc on LastSent promises a zero time instead.
|
||||
//
|
||||
// It is not a cosmetic promise. ack_sends is written only by MarkSent, and
|
||||
// MarkSent is called only after a repeat has already gone out, so the first
|
||||
// repeat for every rule reads an empty table. delivery.Dispatcher.RepeatUnacked
|
||||
// aborts the whole sweep on that error, which means the repeat-til-ack loop can
|
||||
// never take its first step for any rule.
|
||||
func TestLastSentOnEmptyTableIsZero(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
last, err := s.LastSent(ctx, "service_down")
|
||||
if err != nil {
|
||||
t.Fatalf("last sent on an empty table must not error: %v", err)
|
||||
}
|
||||
if !last.IsZero() {
|
||||
t.Fatalf("want the zero time before anything was sent, got %v", last)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLastSentIsScopedToItsRule — a send for another rule must not answer for
|
||||
// this one, or the repeat interval is clocked off somebody else's alarm.
|
||||
func TestLastSentIsScopedToItsRule(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
at := time.UnixMilli(1_700_000_000_000).UTC()
|
||||
|
||||
if err := s.MarkSent(ctx, "other_rule", at); err != nil {
|
||||
t.Fatalf("mark sent: %v", err)
|
||||
}
|
||||
last, err := s.LastSent(ctx, "service_down")
|
||||
if err != nil {
|
||||
t.Fatalf("last sent: %v", err)
|
||||
}
|
||||
if !last.IsZero() {
|
||||
t.Fatalf("want zero for a rule with no sends, got %v", last)
|
||||
}
|
||||
|
||||
if err := s.MarkSent(ctx, "service_down", at); err != nil {
|
||||
t.Fatalf("mark sent: %v", err)
|
||||
}
|
||||
last, err = s.LastSent(ctx, "service_down")
|
||||
if err != nil {
|
||||
t.Fatalf("last sent: %v", err)
|
||||
}
|
||||
if !last.Equal(at) {
|
||||
t.Fatalf("want %v, got %v", at, last)
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,9 @@ package store
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
@@ -44,19 +46,33 @@ func DigestBodyHash(rule, body string) string {
|
||||
}
|
||||
|
||||
// EnqueueDigestEntry durably records a suppressed care candidate worth
|
||||
// resurfacing later. If a pending entry with the same rule+body already
|
||||
// resurfacing later. If a LIVE pending entry with the same rule+body already
|
||||
// exists, this is a no-op that returns the existing id and deduped=true —
|
||||
// the same suppressed nudge repeating across ticks must not pile up into
|
||||
// several copies of itself in the eventual bundle.
|
||||
//
|
||||
// "Live" carries the same expiry test PendingDigestEntries reads with, and for
|
||||
// the same reason: a row past its expires_ts is still status='pending' until
|
||||
// the sweep gets to it, and the tick enqueues before it sweeps. Deduping
|
||||
// against one meant reporting deduped=true against an entry that will never be
|
||||
// spoken — the caller drops the phrasing it just paid the LLM for and nothing
|
||||
// reaches the bundle. Not yet swept must not mean still deliverable on the
|
||||
// write side either.
|
||||
func (s *Store) EnqueueDigestEntry(ctx context.Context, rule string, severity int, body string, now, expiresAt time.Time) (id int64, deduped bool, err error) {
|
||||
hash := DigestBodyHash(rule, body)
|
||||
var existing int64
|
||||
err = s.db.QueryRowContext(ctx,
|
||||
`SELECT id FROM digest_entries WHERE status = ? AND rule = ? AND body_hash = ? LIMIT 1`,
|
||||
DigestPending, rule, hash).Scan(&existing)
|
||||
`SELECT id FROM digest_entries
|
||||
WHERE status = ? AND rule = ? AND body_hash = ? AND expires_ts > ? LIMIT 1`,
|
||||
DigestPending, rule, hash, now.UnixMilli()).Scan(&existing)
|
||||
if err == nil {
|
||||
return existing, true, nil
|
||||
}
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
// A real read failure is not "nothing there". Inserting anyway would
|
||||
// duplicate an entry whose existence we never established.
|
||||
return 0, false, fmt.Errorf("enqueue digest entry: dedupe lookup: %w", err)
|
||||
}
|
||||
|
||||
res, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO digest_entries (rule, severity, body, body_hash, status, created_ts, expires_ts)
|
||||
|
||||
@@ -200,3 +200,46 @@ func TestDigestEntryDrainMarksDrainedNotDeleted(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDigestEnqueueDoesNotDedupeAgainstAnExpiredEntry — an entry past its
|
||||
// expires_ts is still status='pending' until the sweep runs, and the tick
|
||||
// enqueues before it sweeps. Deduping against one reports deduped=true for a
|
||||
// row PendingDigestEntries will never hand back, so the suppressed nudge is
|
||||
// dropped instead of held: the entry says the thing was recorded when nothing
|
||||
// was.
|
||||
func TestDigestEnqueueDoesNotDedupeAgainstAnExpiredEntry(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
created := time.Now()
|
||||
expiresAt := created.Add(time.Hour)
|
||||
|
||||
first, deduped, err := s.EnqueueDigestEntry(ctx, "break", 2, "ты долго не отдыхала", created, expiresAt)
|
||||
if err != nil {
|
||||
t.Fatalf("enqueue: %v", err)
|
||||
}
|
||||
if deduped {
|
||||
t.Fatal("first enqueue must not report deduped")
|
||||
}
|
||||
|
||||
// A tick after the expiry, with the sweep not yet run: the same suppressed
|
||||
// nudge comes round again and must be recorded afresh.
|
||||
after := expiresAt.Add(time.Minute)
|
||||
second, deduped, err := s.EnqueueDigestEntry(ctx, "break", 2, "ты долго не отдыхала", after, after.Add(time.Hour))
|
||||
if err != nil {
|
||||
t.Fatalf("re-enqueue: %v", err)
|
||||
}
|
||||
if deduped {
|
||||
t.Fatal("an expired entry must not swallow a fresh one")
|
||||
}
|
||||
if second == first {
|
||||
t.Fatalf("want a new row, got the expired one back: id=%d", second)
|
||||
}
|
||||
|
||||
entries, err := s.PendingDigestEntries(ctx, after)
|
||||
if err != nil {
|
||||
t.Fatalf("pending: %v", err)
|
||||
}
|
||||
if len(entries) != 1 || entries[0].ID != second {
|
||||
t.Fatalf("want the fresh entry %d pending, got %+v", second, entries)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -205,8 +205,30 @@ func (s *Store) ListReminders(ctx context.Context, n int) ([]Reminder, error) {
|
||||
|
||||
// RescheduleReminder computes the next fire time for a recurring reminder and
|
||||
// updates next_fire_ts. Returns ErrReminderState if the reminder is not
|
||||
// recurring or not pending. If no more valid fire times exist, marks it fired.
|
||||
// recurring or not pending. If the schedule yields no further fire time at all,
|
||||
// marks it fired.
|
||||
//
|
||||
// Two things the first version got wrong, both fixed 06-08-2026 (V-616).
|
||||
//
|
||||
// The cron expression is a WALL CLOCK statement — "0 9 * * *" is nine in the
|
||||
// morning where the owner stands — but scanReminder hands back instants in UTC,
|
||||
// and robfig's Next walks the calendar in the location of the time it is given.
|
||||
// Computing from a UTC instant therefore produced the next 09:00 UTC, so the
|
||||
// second occurrence of a daily reminder landed one UTC offset late and stayed
|
||||
// there: 12:00 for a Moscow owner. Everything is converted to loc first, which
|
||||
// also makes the walk DST-correct — the schedule keeps its wall-clock hour
|
||||
// across a changeover instead of drifting an hour with the offset.
|
||||
//
|
||||
// And a missed occurrence used to KILL the reminder: any next fire earlier than
|
||||
// now marked it fired, so a daemon down overnight ended a daily standup forever.
|
||||
// Occurrences in the past are skipped instead, so the reminder rolls forward to
|
||||
// the first one strictly after now. Skipping and not replaying is deliberate:
|
||||
// the same no-backlog rule routine.DueAccepted follows.
|
||||
func (s *Store) RescheduleReminder(ctx context.Context, id int64, now time.Time) error {
|
||||
return s.rescheduleReminderIn(ctx, id, now, time.Local)
|
||||
}
|
||||
|
||||
func (s *Store) rescheduleReminderIn(ctx context.Context, id int64, now time.Time, loc *time.Location) error {
|
||||
row := s.db.QueryRowContext(ctx, `
|
||||
SELECT id, created_ts, fire_ts, next_fire_ts, payload, status, cron
|
||||
FROM reminders WHERE id = ?`, id)
|
||||
@@ -225,8 +247,14 @@ func (s *Store) RescheduleReminder(ctx context.Context, id int64, now time.Time)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse cron %q: %w", r.Cron, err)
|
||||
}
|
||||
next := sched.Next(r.NextFireTs.Add(time.Minute))
|
||||
if next.IsZero() || next.Before(now) {
|
||||
// Next is strictly after the time it is given, so the last fire cannot be
|
||||
// returned again and no fudge minute is needed. The bound stops a schedule
|
||||
// that somehow yields a non-advancing time from spinning here.
|
||||
next := sched.Next(r.NextFireTs.In(loc))
|
||||
for i := 0; i < 4096 && !next.IsZero() && !next.After(now); i++ {
|
||||
next = sched.Next(next)
|
||||
}
|
||||
if next.IsZero() || !next.After(now) {
|
||||
_, err = s.db.ExecContext(ctx, "UPDATE reminders SET status = 'fired' WHERE id = ?", id)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// A recurring reminder keeps its wall-clock hour in the owner's zone. The cron
|
||||
// walk used to run on the UTC instant scanReminder returns, so "0 9 * * *"
|
||||
// created for 09:00 Moscow rescheduled to 09:00 UTC — noon, and noon every day
|
||||
// after that (V-616).
|
||||
func TestRescheduleKeepsWallClockHour(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
msk := time.FixedZone("MSK", 3*60*60)
|
||||
|
||||
fire := time.Date(2026, 7, 1, 9, 0, 0, 0, msk)
|
||||
id, err := s.CreateReminder(ctx, fire, `{"text":"стендап"}`, "0 9 * * *")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.rescheduleReminderIn(ctx, id, fire, msk); err != nil {
|
||||
t.Fatalf("reschedule: %v", err)
|
||||
}
|
||||
|
||||
want := time.Date(2026, 7, 2, 9, 0, 0, 0, msk)
|
||||
got := nextFire(t, s, id)
|
||||
if !got.Equal(want) {
|
||||
t.Fatalf("next fire: want %s, got %s", want, got.In(msk))
|
||||
}
|
||||
}
|
||||
|
||||
// A daily reminder survives a daemon that was down for days. The past
|
||||
// occurrences are skipped, not replayed, and the reminder stays pending.
|
||||
func TestRescheduleRollsForwardAfterDowntime(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
msk := time.FixedZone("MSK", 3*60*60)
|
||||
|
||||
fire := time.Date(2026, 7, 1, 9, 0, 0, 0, msk)
|
||||
id, err := s.CreateReminder(ctx, fire, `{"text":"стендап"}`, "0 9 * * *")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The box comes back three days later, mid-afternoon.
|
||||
now := time.Date(2026, 7, 4, 15, 0, 0, 0, msk)
|
||||
if err := s.rescheduleReminderIn(ctx, id, now, msk); err != nil {
|
||||
t.Fatalf("reschedule: %v", err)
|
||||
}
|
||||
|
||||
rs, err := s.ListReminders(ctx, 10)
|
||||
if err != nil || len(rs) != 1 {
|
||||
t.Fatalf("list: %d reminders, %v", len(rs), err)
|
||||
}
|
||||
if rs[0].Status != ReminderPending {
|
||||
t.Fatalf("status: want %s, got %s — downtime killed the recurrence", ReminderPending, rs[0].Status)
|
||||
}
|
||||
want := time.Date(2026, 7, 5, 9, 0, 0, 0, msk)
|
||||
if got := nextFire(t, s, id); !got.Equal(want) {
|
||||
t.Fatalf("next fire: want %s, got %s", want, got.In(msk))
|
||||
}
|
||||
// And exactly one fire is owed, not a backlog of four.
|
||||
due, err := s.DueReminders(ctx, want.Add(time.Second))
|
||||
if err != nil || len(due) != 1 {
|
||||
t.Fatalf("due after roll-forward: want 1, got %d (%v)", len(due), err)
|
||||
}
|
||||
}
|
||||
|
||||
// A daily 03:00 reminder does not drift across the spring-forward changeover:
|
||||
// the hour is wall clock, so the interval is 23 hours that day, not 24.
|
||||
func TestRescheduleAcrossDSTKeepsHour(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
berlin, err := time.LoadLocation("Europe/Berlin")
|
||||
if err != nil {
|
||||
t.Skipf("tzdata unavailable: %v", err)
|
||||
}
|
||||
|
||||
fire := time.Date(2027, 3, 27, 3, 0, 0, 0, berlin) // CET, day before the change
|
||||
id, err := s.CreateReminder(ctx, fire, `{"text":"бэкап"}`, "0 3 * * *")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.rescheduleReminderIn(ctx, id, fire, berlin); err != nil {
|
||||
t.Fatalf("reschedule: %v", err)
|
||||
}
|
||||
got := nextFire(t, s, id).In(berlin)
|
||||
if got.Hour() != 3 || got.Day() != 28 {
|
||||
t.Fatalf("next fire: want 2027-03-28 03:00 local, got %s", got)
|
||||
}
|
||||
if d := got.Sub(fire); d != 23*time.Hour {
|
||||
t.Fatalf("gap across spring forward: want 23h, got %s", d)
|
||||
}
|
||||
}
|
||||
|
||||
func nextFire(t *testing.T, s *Store, id int64) time.Time {
|
||||
t.Helper()
|
||||
var ms int64
|
||||
if err := s.db.QueryRow("SELECT next_fire_ts FROM reminders WHERE id = ?", id).Scan(&ms); err != nil {
|
||||
t.Fatalf("read next_fire_ts: %v", err)
|
||||
}
|
||||
return time.UnixMilli(ms).UTC()
|
||||
}
|
||||
Reference in New Issue
Block a user