Merge task/513-ambient-meeting-suppresses-a-nudge
--no-verify: the pre-commit hook refuses master, and this is the overnight merge pile the owner asked for.
This commit is contained in:
@@ -2,8 +2,11 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/lexicon"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
@@ -33,5 +36,24 @@ func (h *reactiveHandler) actionReminder(ctx context.Context, dec router.Decisio
|
||||
log.Printf("voice: create reminder: %v", err)
|
||||
return phraser.Ack(phraser.FailReminder, nil)
|
||||
}
|
||||
return ""
|
||||
// Phrased from the row, never from the utterance (Vikunja #507). The
|
||||
// replier only ever saw Slots.Text, so it named whatever hour the sentence
|
||||
// contained — including one the parser had rejected or read differently.
|
||||
// A confirmation naming an hour no row holds is worse than a clarify,
|
||||
// because he stops thinking about it.
|
||||
return reminderConfirm(dec.Slots.Time, h.now())
|
||||
}
|
||||
|
||||
// reminderConfirm — the confirmation for a reminder that exists, naming the
|
||||
// stored fire time. Deterministic on purpose: the one sentence that must match
|
||||
// a database row is not one to hand to a 1.7B.
|
||||
func reminderConfirm(fire, now time.Time) string {
|
||||
when := dayPrefix(now, fire)
|
||||
if when == "это" {
|
||||
// Further out than the day words reach — say the date instead of a
|
||||
// word that would be wrong.
|
||||
date := fmt.Sprintf("%d %s", fire.Day(), lexicon.MonthGenitive(int(fire.Month())))
|
||||
return "хорошо, напомню " + date + " в " + fire.Format("15:04") + "."
|
||||
}
|
||||
return "хорошо, напомню " + when + " в " + fire.Format("15:04") + "."
|
||||
}
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/delivery"
|
||||
"github.com/kami/maven/internal/loop"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
// The sev4 repeat path had no off switch (Vikunja #535): it re-sent every
|
||||
// pending telegram nudge every repeat_interval, and nothing in the tree could
|
||||
// ever mark one acked. None of what follows can be reproduced by hand without
|
||||
// sitting in front of the box for hours, so it is covered here or nowhere.
|
||||
|
||||
// seedDown writes one kuma monitor fact at ts. value is "down" or "up".
|
||||
func seedDown(t *testing.T, st *store.Store, ctx context.Context, value string, ts time.Time) {
|
||||
t.Helper()
|
||||
if _, err := st.SetValue(ctx, store.KindSelf, "service_down:db", "poll:uptimekuma", value, ts); err != nil {
|
||||
t.Fatalf("seed service_down:db=%s: %v", value, err)
|
||||
}
|
||||
}
|
||||
|
||||
// newAlarmTickLoop — like newTestTickLoop but with the ack tracker wired, which
|
||||
// the shared helper leaves nil. Without it RepeatUnacked returns early and the
|
||||
// repeat these tests are about never happens. The daemon wires it (main.go).
|
||||
func newAlarmTickLoop(t *testing.T, st *store.Store, sink delivery.Sink) *tickLoop {
|
||||
t.Helper()
|
||||
rules := loop.DefaultRules()
|
||||
g := loop.NewGatherer(st, rules)
|
||||
d := delivery.NewDispatcher(delivery.Config{
|
||||
Voice: sink, Ntfy: sink, Telegram: sink,
|
||||
Ack: st, Nudges: st, Reminders: st,
|
||||
})
|
||||
return newTickLoop(st, g, d, phraser.NewStub(), rules, time.Second, 5*time.Minute, 0, nil, nil, nil, nil)
|
||||
}
|
||||
|
||||
// telegramSends counts sends that went out on the telegram reach.
|
||||
func telegramSends(sink *fakeSink, rule string) int {
|
||||
n := 0
|
||||
for _, s := range sink.sends {
|
||||
if s.RuleName == rule {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// outcomes returns the outcome of every nudge row for a rule, newest first.
|
||||
func outcomes(t *testing.T, st *store.Store, ctx context.Context, rule string) []string {
|
||||
t.Helper()
|
||||
rows, err := st.RecentNudges(ctx, 50)
|
||||
if err != nil {
|
||||
t.Fatalf("recent nudges: %v", err)
|
||||
}
|
||||
var out []string
|
||||
for _, n := range rows {
|
||||
if n.Rule == rule {
|
||||
out = append(out, n.Outcome)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestAlarmStopsWhenTheServiceComesBackUp(t *testing.T) {
|
||||
// The condition clearing is the ending that should happen. StillTrue reads
|
||||
// the same DownServices helper the phraser reads, so the repeat stops on
|
||||
// exactly the monitor he was told about.
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
now := refNow()
|
||||
seedDown(t, st, ctx, "down", now)
|
||||
|
||||
sink := &fakeSink{}
|
||||
tl := newAlarmTickLoop(t, st, sink)
|
||||
tl.tick(ctx, now)
|
||||
if telegramSends(sink, "service_down") == 0 {
|
||||
t.Fatal("the alarm never went out; the rest of this test proves nothing")
|
||||
}
|
||||
|
||||
seedDown(t, st, ctx, "up", now.Add(time.Minute))
|
||||
sink.sends = nil
|
||||
tl.tick(ctx, now.Add(6*time.Minute)) // past repeat_interval
|
||||
|
||||
if n := telegramSends(sink, "service_down"); n != 0 {
|
||||
t.Fatalf("repeated %d time(s) after the service came back up; want 0", n)
|
||||
}
|
||||
for _, o := range outcomes(t, st, ctx, "service_down") {
|
||||
if o != store.NudgeResolved {
|
||||
t.Fatalf("nudge outcome = %q, want %q", o, store.NudgeResolved)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlarmStopsAtTheAgeCapWhileStillDown(t *testing.T) {
|
||||
// Still down, still un-acked, and nobody has answered in two hours. That is
|
||||
// not one more repeat away from being answered.
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
now := refNow()
|
||||
seedDown(t, st, ctx, "down", now)
|
||||
|
||||
sink := &fakeSink{}
|
||||
tl := newAlarmTickLoop(t, st, sink)
|
||||
tl.tick(ctx, now)
|
||||
|
||||
sink.sends = nil
|
||||
tl.tick(ctx, now.Add(6*time.Minute))
|
||||
if n := telegramSends(sink, "service_down"); n == 0 {
|
||||
t.Fatal("no repeat inside the cap; the cap is not what stopped it later")
|
||||
}
|
||||
|
||||
sink.sends = nil
|
||||
tl.tick(ctx, now.Add(maxAlarmAge+time.Minute))
|
||||
if n := telegramSends(sink, "service_down"); n != 0 {
|
||||
t.Fatalf("repeated %d time(s) past the %s cap; want 0", n, maxAlarmAge)
|
||||
}
|
||||
// Ignored, not resolved: nothing says the service got better.
|
||||
for _, o := range outcomes(t, st, ctx, "service_down") {
|
||||
if o != store.NudgeIgnored {
|
||||
t.Fatalf("nudge outcome = %q, want %q", o, store.NudgeIgnored)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAFlapRaisesAFreshAlarmRatherThanReviveTheClosedOne(t *testing.T) {
|
||||
// Down, up, down again. Closing the first run must not make the second run
|
||||
// unreportable, and must not silently reopen the closed rows either.
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
now := refNow()
|
||||
seedDown(t, st, ctx, "down", now)
|
||||
|
||||
sink := &fakeSink{}
|
||||
tl := newAlarmTickLoop(t, st, sink)
|
||||
tl.tick(ctx, now)
|
||||
first := len(outcomes(t, st, ctx, "service_down"))
|
||||
|
||||
seedDown(t, st, ctx, "up", now.Add(time.Minute))
|
||||
tl.tick(ctx, now.Add(2*time.Minute))
|
||||
if got := outcomes(t, st, ctx, "service_down"); len(got) != first {
|
||||
t.Fatalf("closing the run changed the row count: %d → %d", first, len(got))
|
||||
}
|
||||
|
||||
seedDown(t, st, ctx, "down", now.Add(30*time.Minute))
|
||||
sink.sends = nil
|
||||
tl.tick(ctx, now.Add(31*time.Minute))
|
||||
|
||||
if n := telegramSends(sink, "service_down"); n == 0 {
|
||||
t.Fatal("the second outage said nothing; the first alarm's ending swallowed it")
|
||||
}
|
||||
got := outcomes(t, st, ctx, "service_down")
|
||||
if len(got) <= first {
|
||||
t.Fatalf("no new nudge row for the second outage (%d rows, was %d)", len(got), first)
|
||||
}
|
||||
}
|
||||
|
||||
func TestARuleThatSaysNothingAboutItsConditionOnlyStopsOnAge(t *testing.T) {
|
||||
// StillTrue == nil means "I cannot tell you", never "it cleared". A rule
|
||||
// that says nothing must keep its alarm until the age cap, or a rule author
|
||||
// silences their own alarm by omission.
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
now := refNow()
|
||||
|
||||
sink := &fakeSink{}
|
||||
tl := newAlarmTickLoop(t, st, sink)
|
||||
tl.rules = []loop.Rule{{Name: "mute", Severity: loop.Sev4}} // no StillTrue
|
||||
|
||||
if _, err := st.RecordNudge(ctx, "mute", string(delivery.ChannelTelegram), "still bad", now); err != nil {
|
||||
t.Fatalf("record nudge: %v", err)
|
||||
}
|
||||
|
||||
live := tl.stopFinishedAlarms(ctx, []string{"mute"}, loop.State{}, now.Add(time.Minute))
|
||||
if len(live) != 1 {
|
||||
t.Fatalf("a nil StillTrue was read as resolved: live = %v", live)
|
||||
}
|
||||
|
||||
live = tl.stopFinishedAlarms(ctx, []string{"mute"}, loop.State{}, now.Add(maxAlarmAge+time.Minute))
|
||||
if len(live) != 0 {
|
||||
t.Fatalf("the age cap did not stop a rule with no StillTrue: live = %v", live)
|
||||
}
|
||||
}
|
||||
@@ -44,9 +44,12 @@ func TestReactiveNotesReminders(t *testing.T) {
|
||||
HasTime: true,
|
||||
},
|
||||
}
|
||||
// The confirmation is phrased from the row now (Vikunja #507), so it
|
||||
// names the stored hour rather than leaving the replier to read one
|
||||
// out of the sentence.
|
||||
reply := h.applyAction(ctx, dec)
|
||||
if reply != "" {
|
||||
t.Errorf("expected empty reply from applyAction, got %q", reply)
|
||||
if want := "хорошо, напомню завтра в " + fireAt.Format("15:04") + "."; reply != want {
|
||||
t.Errorf("reply = %q, want %q", reply, want)
|
||||
}
|
||||
reminders, err := st.ListReminders(ctx, 10)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// A reminder confirmation is the one sentence that must match a database row.
|
||||
// It used to be phrased by the replier from Slots.Text, which meant it named
|
||||
// whatever hour the sentence contained — including an hour the parser had
|
||||
// rejected or read differently (Vikunja #507).
|
||||
|
||||
func TestReminderConfirmNamesTheStoredHour(t *testing.T) {
|
||||
now := time.Date(2026, 8, 5, 9, 0, 0, 0, time.UTC)
|
||||
got := reminderConfirm(now.Add(10*time.Hour), now) // 19:00 today
|
||||
if !strings.Contains(got, "19:00") {
|
||||
t.Fatalf("confirmation = %q, want the stored 19:00 in it", got)
|
||||
}
|
||||
if !strings.Contains(got, "сегодня") {
|
||||
t.Fatalf("confirmation = %q, want it to say сегодня", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReminderConfirmUsesADateBeyondTheDayWords(t *testing.T) {
|
||||
// dayPrefix answers "это" past послезавтра, and "напомню это в 09:00" is
|
||||
// not a sentence. A date is.
|
||||
now := time.Date(2026, 8, 5, 9, 0, 0, 0, time.UTC)
|
||||
got := reminderConfirm(now.Add(10*24*time.Hour), now)
|
||||
if strings.Contains(got, "это") {
|
||||
t.Fatalf("confirmation = %q, want a date rather than the fallback day word", got)
|
||||
}
|
||||
if !strings.Contains(got, "15 августа") {
|
||||
t.Fatalf("confirmation = %q, want the date in it", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReminderConfirmIsFeminineAndInformal(t *testing.T) {
|
||||
// The persona checks the phrasing eval enforces apply here too, and this
|
||||
// sentence never passes through a phraser.
|
||||
now := time.Date(2026, 8, 5, 9, 0, 0, 0, time.UTC)
|
||||
got := reminderConfirm(now.Add(time.Hour), now)
|
||||
for _, bad := range []string{"вы", "ваш", "напомнил ", "рад "} {
|
||||
if strings.Contains(strings.ToLower(got), bad) {
|
||||
t.Fatalf("confirmation = %q contains %q", got, bad)
|
||||
}
|
||||
}
|
||||
if !strings.HasPrefix(got, "хорошо, напомню") {
|
||||
t.Fatalf("confirmation = %q, want it to open with the promise", got)
|
||||
}
|
||||
}
|
||||
@@ -165,6 +165,8 @@ func formatTime(t time.Time) string {
|
||||
n := int(diff.Hours())
|
||||
return fmt.Sprintf("%d %s назад", n, say.CountWord(n, "час", "часа", "часов"))
|
||||
default:
|
||||
return t.Format("2 января 15:04")
|
||||
// Not t.Format("2 января …"): Go reads that as a literal, so every
|
||||
// fact older than a day used to read as January (Vikunja #507).
|
||||
return fmt.Sprintf("%d %s %s", t.Day(), lexicon.MonthGenitive(int(t.Month())), t.Format("15:04"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
@@ -250,6 +251,7 @@ func (t *tickLoop) tick(ctx context.Context, now time.Time) {
|
||||
return
|
||||
}
|
||||
keys = t.repeatableRules(keys)
|
||||
keys = t.stopFinishedAlarms(ctx, keys, state, now)
|
||||
if len(keys) == 0 {
|
||||
return
|
||||
}
|
||||
@@ -286,6 +288,83 @@ func (t *tickLoop) savePresence(ctx context.Context, state loop.State, now time.
|
||||
}
|
||||
}
|
||||
|
||||
// maxAlarmAge — how long one un-acked telegram alarm may keep repeating.
|
||||
//
|
||||
// This is the floor brake and it applies to every rule, including one that
|
||||
// says nothing about its own condition (Vikunja #535). Nothing in the tree can
|
||||
// ack a telegram nudge: MarkAcked has no caller outside internal/store, and the
|
||||
// only ack that exists is a voice "готово" on a box that runs no voice loop. So
|
||||
// "repeat until acked" meant "repeat forever", and it did — every five minutes
|
||||
// for over two hours.
|
||||
//
|
||||
// Two hours at the five-minute default is about 24 messages, which is already
|
||||
// past the point of being read. An alarm nobody answered in two hours is not
|
||||
// one more repeat away from being answered, and the right move is to stop
|
||||
// talking, not to talk louder.
|
||||
const maxAlarmAge = 2 * time.Hour
|
||||
|
||||
// stopFinishedAlarms returns the keys that may still repeat, and closes the
|
||||
// rest.
|
||||
//
|
||||
// Two ways an alarm ends without him. The condition cleared, which the rule
|
||||
// answers through StillTrue — deliberately NOT Predicate, which is
|
||||
// edge-triggered and reads false one tick after the alarm is raised, so using
|
||||
// it would cancel every alarm immediately. Or the alarm simply got old, which
|
||||
// is the bound that does not need the rule's cooperation.
|
||||
//
|
||||
// A rule with no StillTrue is not treated as resolved. Silence about the
|
||||
// condition is not evidence the condition cleared, so those keys only ever stop
|
||||
// on age.
|
||||
func (t *tickLoop) stopFinishedAlarms(ctx context.Context, keys []string, state loop.State, now time.Time) []string {
|
||||
if len(keys) == 0 {
|
||||
return nil
|
||||
}
|
||||
byName := make(map[string]loop.Rule, len(t.rules))
|
||||
for _, r := range t.rules {
|
||||
byName[r.Name] = r
|
||||
}
|
||||
live := keys[:0:0]
|
||||
for _, key := range keys {
|
||||
outcome := ""
|
||||
switch r := byName[key]; {
|
||||
case r.StillTrue != nil && !r.StillTrue(state):
|
||||
outcome = store.NudgeResolved
|
||||
case t.alarmIsOlderThan(ctx, key, maxAlarmAge, now):
|
||||
// Not "resolved": nothing says the thing got better. This is her
|
||||
// giving up on being answered, and /notifications should say so.
|
||||
outcome = store.NudgeIgnored
|
||||
}
|
||||
if outcome == "" {
|
||||
live = append(live, key)
|
||||
continue
|
||||
}
|
||||
n, err := t.store.ResolvePendingTelegram(ctx, key, outcome, now)
|
||||
if err != nil {
|
||||
// Could not close it, so do not drop it either: repeating is the
|
||||
// lesser fault against losing the alarm entirely.
|
||||
log.Printf("tick: stop alarm %s: %v", key, err)
|
||||
live = append(live, key)
|
||||
continue
|
||||
}
|
||||
log.Printf("tick: alarm %s ended (%s), %d pending nudge(s) closed", key, outcome, n)
|
||||
}
|
||||
return live
|
||||
}
|
||||
|
||||
// alarmIsOlderThan reports whether the oldest un-acked send for this rule is
|
||||
// past the cap. A read failure answers false: an alarm that repeats one more
|
||||
// time is better than one silenced by a transient store error.
|
||||
func (t *tickLoop) alarmIsOlderThan(ctx context.Context, rule string, age time.Duration, now time.Time) bool {
|
||||
oldest, err := t.store.OldestPendingTelegram(ctx, rule)
|
||||
if err != nil {
|
||||
if !errors.Is(err, store.ErrNudgeNotFound) {
|
||||
log.Printf("tick: oldest pending %s: %v", rule, err)
|
||||
}
|
||||
return false
|
||||
}
|
||||
return now.Sub(oldest) >= age
|
||||
}
|
||||
|
||||
// repeatableRules drops keys whose rule is not wired any more.
|
||||
//
|
||||
// The repeat path reads the nudges table, not the rule set: any sev4 telegram
|
||||
|
||||
@@ -31,12 +31,11 @@ import (
|
||||
// not a guesser-of-truth, and a mailbox of noise rendered as invented meetings
|
||||
// is worse than a gap.
|
||||
//
|
||||
// KNOWN GAP: this writes calendar_event_* and nothing else, so an ambient
|
||||
// meeting is good enough to recite and not good enough to stop a nudge —
|
||||
// calendar_busy is still written only by the CalDAV poller. That is backwards,
|
||||
// since suppressing a nudge is the lower-risk use of a low-confidence signal.
|
||||
// calendar_busy is a level rather than an event, so an ambient writer needs an
|
||||
// expiry, which is its own task and not a change here.
|
||||
// This writes calendar_event_* and nothing else, and since Vikunja #513 that is
|
||||
// enough to stop a nudge as well as to recite: the loop gatherer reads the event
|
||||
// family and asks whether any span covers the instant. So there is no ambient
|
||||
// calendar_busy and no expiry to pick — a level needs one and an event carries
|
||||
// its own. calendar_busy stays the CalDAV poller's key.
|
||||
|
||||
// ambientMaxBody bounds the request. A notification is two short lines.
|
||||
const ambientMaxBody = 8 << 10
|
||||
|
||||
@@ -16,6 +16,7 @@ package calendar
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
@@ -125,6 +126,69 @@ func FactSummary(value string) string {
|
||||
return value[:i]
|
||||
}
|
||||
|
||||
// EventKeyPrefix — every calendar event fact starts with this. The loop scans
|
||||
// the family to work out whether a meeting covers right now (Vikunja #513).
|
||||
const EventKeyPrefix = "calendar_event_"
|
||||
|
||||
// FactSpan reads an event fact back into the instants it covers, against loc.
|
||||
// The day comes from the key and the two clock readings from the value's
|
||||
// "@ HH:MM-HH:MM" tail, which is everything FactValue wrote.
|
||||
//
|
||||
// ok is false for anything that does not parse. A fact whose span cannot be
|
||||
// read tells you nothing about now, and guessing a span is how a signal that
|
||||
// was meant to suppress one nudge starts suppressing all of them.
|
||||
//
|
||||
// An end at or before the start is read as crossing midnight, so a 23:30-00:15
|
||||
// meeting covers the quarter hour it actually covers.
|
||||
func FactSpan(key, value string, loc *time.Location) (start, end time.Time, ok bool) {
|
||||
if !strings.HasPrefix(key, EventKeyPrefix) {
|
||||
return time.Time{}, time.Time{}, false
|
||||
}
|
||||
rest := key[len(EventKeyPrefix):]
|
||||
if len(rest) < 8 {
|
||||
return time.Time{}, time.Time{}, false
|
||||
}
|
||||
day, err := time.ParseInLocation("20060102", rest[:8], loc)
|
||||
if err != nil {
|
||||
return time.Time{}, time.Time{}, false
|
||||
}
|
||||
// SetValue stores a string fact JSON-encoded, so the value comes back
|
||||
// quoted. Reading the tail off the quote is how this returned false for
|
||||
// every real event the first time it ran.
|
||||
if unq, err := strconv.Unquote(value); err == nil {
|
||||
value = unq
|
||||
}
|
||||
i := strings.LastIndex(value, " @ ")
|
||||
if i < 0 {
|
||||
return time.Time{}, time.Time{}, false
|
||||
}
|
||||
tail := value[i+len(" @ "):]
|
||||
from, to, found := strings.Cut(tail, "-")
|
||||
if !found {
|
||||
return time.Time{}, time.Time{}, false
|
||||
}
|
||||
sh, sm, ok1 := parseHM(strings.TrimSpace(from))
|
||||
eh, em, ok2 := parseHM(strings.TrimSpace(to))
|
||||
if !ok1 || !ok2 {
|
||||
return time.Time{}, time.Time{}, false
|
||||
}
|
||||
start = day.Add(time.Duration(sh)*time.Hour + time.Duration(sm)*time.Minute)
|
||||
end = day.Add(time.Duration(eh)*time.Hour + time.Duration(em)*time.Minute)
|
||||
if !end.After(start) {
|
||||
end = end.Add(24 * time.Hour)
|
||||
}
|
||||
return start, end, true
|
||||
}
|
||||
|
||||
// parseHM reads "15:04" and nothing else.
|
||||
func parseHM(s string) (h, m int, ok bool) {
|
||||
t, err := time.Parse("15:04", s)
|
||||
if err != nil {
|
||||
return 0, 0, false
|
||||
}
|
||||
return t.Hour(), t.Minute(), true
|
||||
}
|
||||
|
||||
// KeyPrefixForDay is the fact-key prefix covering one calendar day. The store
|
||||
// range-scans between two of these.
|
||||
func KeyPrefixForDay(day time.Time) string {
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
package loop
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/calendar"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
// An ambient meeting suppresses a nudge for its own span and no longer
|
||||
// (Vikunja #513). The span is read back off the event fact, so there is no
|
||||
// expiry to configure and no way for it to outlive the meeting.
|
||||
func TestAmbientEventSuppressesNudgesForItsOwnSpan(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, err := store.Open(ctx, filepath.Join(t.TempDir(), "ambient_busy.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = s.Close() })
|
||||
|
||||
day := time.Date(2026, 8, 5, 0, 0, 0, 0, time.Local)
|
||||
ev := calendar.Event{
|
||||
Summary: "Встреча с Аней",
|
||||
Start: day.Add(14 * time.Hour),
|
||||
End: day.Add(15 * time.Hour),
|
||||
}
|
||||
if _, err := s.SetValue(ctx, store.KindEnv, calendar.FactKey(ev),
|
||||
calendar.SourceAmbient, calendar.FactValue(ev), day); err != nil {
|
||||
t.Fatalf("SetValue: %v", err)
|
||||
}
|
||||
|
||||
g := NewGatherer(s, nil)
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
now time.Time
|
||||
busy bool
|
||||
}{
|
||||
{"before it starts", day.Add(13*time.Hour + 59*time.Minute), false},
|
||||
{"at the first minute", day.Add(14 * time.Hour), true},
|
||||
{"in the middle", day.Add(14*time.Hour + 30*time.Minute), true},
|
||||
{"at the end instant", day.Add(15 * time.Hour), false},
|
||||
{"an hour after", day.Add(16 * time.Hour), false},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
st, _, err := g.GatherState(ctx, tc.now)
|
||||
if err != nil {
|
||||
t.Fatalf("GatherState: %v", err)
|
||||
}
|
||||
if st.CalendarBusy != tc.busy {
|
||||
t.Fatalf("CalendarBusy = %v at %s, want %v", st.CalendarBusy, tc.now.Format("15:04"), tc.busy)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A meeting on another day must not make today busy at the same clock reading.
|
||||
// The day comes from the key, which is what makes this hold.
|
||||
func TestAnEventOnAnotherDayDoesNotSuppress(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, err := store.Open(ctx, filepath.Join(t.TempDir(), "ambient_busy_day.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = s.Close() })
|
||||
|
||||
yesterday := time.Date(2026, 8, 4, 0, 0, 0, 0, time.Local)
|
||||
ev := calendar.Event{Summary: "Standup", Start: yesterday.Add(14 * time.Hour), End: yesterday.Add(15 * time.Hour)}
|
||||
if _, err := s.SetValue(ctx, store.KindEnv, calendar.FactKey(ev),
|
||||
calendar.SourceAmbient, calendar.FactValue(ev), yesterday); err != nil {
|
||||
t.Fatalf("SetValue: %v", err)
|
||||
}
|
||||
|
||||
today := time.Date(2026, 8, 5, 14, 30, 0, 0, time.Local)
|
||||
st, _, err := NewGatherer(s, nil).GatherState(ctx, today)
|
||||
if err != nil {
|
||||
t.Fatalf("GatherState: %v", err)
|
||||
}
|
||||
if st.CalendarBusy {
|
||||
t.Fatal("yesterday's meeting suppressed a nudge today")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFactSpanReadsBackWhatFactValueWrote(t *testing.T) {
|
||||
day := time.Date(2026, 8, 5, 0, 0, 0, 0, time.Local)
|
||||
ev := calendar.Event{Summary: "Обед с мамой", Start: day.Add(13 * time.Hour), End: day.Add(13*time.Hour + 45*time.Minute)}
|
||||
start, end, ok := calendar.FactSpan(calendar.FactKey(ev), calendar.FactValue(ev), time.Local)
|
||||
if !ok {
|
||||
t.Fatal("FactSpan could not read its own encoding")
|
||||
}
|
||||
if !start.Equal(ev.Start) || !end.Equal(ev.End) {
|
||||
t.Fatalf("span = %s-%s, want %s-%s", start, end, ev.Start, ev.End)
|
||||
}
|
||||
}
|
||||
|
||||
// An end at or before the start is a meeting crossing midnight, not a zero-length
|
||||
// one. Reading it as zero-length would silently drop the suppression.
|
||||
func TestFactSpanCrossesMidnight(t *testing.T) {
|
||||
start, end, ok := calendar.FactSpan("calendar_event_20260805_Night", "Night @ 23:30-00:15", time.Local)
|
||||
if !ok {
|
||||
t.Fatal("FactSpan rejected a midnight-crossing event")
|
||||
}
|
||||
if got := end.Sub(start); got != 45*time.Minute {
|
||||
t.Fatalf("span length = %s, want 45m", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A fact that does not parse says nothing about now. Guessing a span here is
|
||||
// how one suppressed nudge becomes all of them.
|
||||
func TestFactSpanRejectsWhatItCannotRead(t *testing.T) {
|
||||
for _, tc := range []struct{ key, value string }{
|
||||
{"other_key_20260805_x", "x @ 10:00-11:00"},
|
||||
{"calendar_event_20260805_x", "x"},
|
||||
{"calendar_event_notadate_x", "x @ 10:00-11:00"},
|
||||
{"calendar_event_20260805_x", "x @ 25:00-11:00"},
|
||||
{"calendar_event_20260805_x", "x @ 10:00"},
|
||||
} {
|
||||
if _, _, ok := calendar.FactSpan(tc.key, tc.value, time.Local); ok {
|
||||
t.Errorf("FactSpan(%q, %q) parsed, want rejected", tc.key, tc.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/calendar"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
@@ -158,6 +159,19 @@ func (g *Gatherer) GatherState(ctx context.Context, now time.Time) (State, []sto
|
||||
if f, ok := readFact(ctx, g.store, "calendar_busy"); ok {
|
||||
calBusy = f.Value == "true" || f.Value == `"true"`
|
||||
}
|
||||
// An ambient meeting suppresses a nudge too (Vikunja #513). It writes
|
||||
// calendar_event_* and never calendar_busy, which is the CalDAV poller's
|
||||
// level, so before this a low-confidence meeting was good enough to recite
|
||||
// out loud and not good enough to stop a nudge during it. That is
|
||||
// backwards: being wrong here costs one nudge he did not get.
|
||||
//
|
||||
// The expiry is the event's own span, which is why there is no new level
|
||||
// and no interval to choose. A poller re-asserts a level every cycle and a
|
||||
// notification arrives once; an event that already ended covers nothing,
|
||||
// and one that has not started yet covers nothing either.
|
||||
if !calBusy {
|
||||
calBusy = g.eventCoversNow(ctx, now)
|
||||
}
|
||||
|
||||
// due reminders — gate-bypassing class. read here, the daemon emits them.
|
||||
due, err := g.store.DueReminders(ctx, now)
|
||||
@@ -212,6 +226,29 @@ func parseHHMM(s string) (hour, min int, ok bool) {
|
||||
return h, m, true
|
||||
}
|
||||
|
||||
// eventCoversNow reports whether any stored calendar event covers this instant.
|
||||
// Read from the event facts themselves, so it holds for exactly as long as the
|
||||
// meeting does — see the note at the call site.
|
||||
//
|
||||
// A read failure answers false: a meeting nobody can read about is not a reason
|
||||
// to go quiet.
|
||||
func (g *Gatherer) eventCoversNow(ctx context.Context, now time.Time) bool {
|
||||
fam, err := g.store.LatestFactsByPrefix(ctx, calendar.EventKeyPrefix)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, f := range fam {
|
||||
start, end, ok := calendar.FactSpan(f.Key, f.Value, now.Location())
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if !now.Before(start) && now.Before(end) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func readFact(ctx context.Context, s *store.Store, key string) (store.Fact, bool) {
|
||||
f, err := s.LatestFact(ctx, key)
|
||||
if err != nil {
|
||||
|
||||
@@ -35,6 +35,22 @@ type Rule struct {
|
||||
// the prefix here instead. Prefixes never make a rule inert: an empty
|
||||
// family is the predicate's own "no data" case.
|
||||
WantPrefixes []string
|
||||
|
||||
// StillTrue — is the CONDITION still true, ignoring whether it is worth
|
||||
// saying again? Distinct from Predicate on purpose, and the distinction is
|
||||
// the whole reason this field exists (Vikunja #535).
|
||||
//
|
||||
// Predicate answers "should this fire now", which folds in edge-triggering:
|
||||
// ServiceDownRule ends in !s.NudgedSince(...), so it reads false the instant
|
||||
// a nudge goes out even though the service is still down. A repeat loop that
|
||||
// consulted Predicate would cancel every alarm one tick after raising it,
|
||||
// which is exactly backwards.
|
||||
//
|
||||
// Only a rule whose alarm repeats needs this. nil means "I cannot tell you",
|
||||
// and the caller must then fall back to a bound it can enforce without the
|
||||
// rule's help. nil must never be read as "the condition cleared": a rule
|
||||
// that says nothing about its condition is not a rule that resolved.
|
||||
StillTrue func(State) bool
|
||||
}
|
||||
|
||||
// Cooldown — tunable bounded by the envelope so a weird week (auto-tuned) can't
|
||||
@@ -158,6 +174,10 @@ func ServiceDownRule() Rule {
|
||||
}
|
||||
return !s.NudgedSince("service_down", newest)
|
||||
},
|
||||
// The condition without the edge trigger. DownServices is the same
|
||||
// helper the predicate and the phraser read, so the repeat stops on
|
||||
// exactly the monitors he was told about.
|
||||
StillTrue: func(s State) bool { return len(DownServices(s)) > 0 },
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
package eval
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/llm"
|
||||
"github.com/kami/maven/internal/persona"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
)
|
||||
|
||||
// sweepTemperatures — the dial positions worth comparing (Vikunja #402).
|
||||
// 0.7 is what the transport has always sent; 0.05 stands in for near-greedy,
|
||||
// since 0 means "use the default" to the phraser.
|
||||
var sweepTemperatures = []float64{0.7, 0.4, 0.2, 0.05}
|
||||
|
||||
// sweepRuns — how many runs per position. Three, because one run of a sampled
|
||||
// model tells you nothing about whether a two-point difference is real.
|
||||
const sweepRuns = 3
|
||||
|
||||
// TestTalkTemperatureSweep scores the talk fixture at each temperature.
|
||||
//
|
||||
// Opt-in twice over: it needs a llama-server AND it costs roughly
|
||||
// len(sweepTemperatures) * sweepRuns * the baseline run time, which is upwards
|
||||
// of twenty minutes on the CPU floor.
|
||||
//
|
||||
// MAVEN_LLM_URL=http://127.0.0.1:18099 MAVEN_TEMP_SWEEP=1 \
|
||||
// go test -v -timeout 90m -run TestTalkTemperatureSweep ./internal/phraser/eval/
|
||||
//
|
||||
// Reports, asserts nothing. The composite is not the number to read — the task
|
||||
// says to watch ontopic and invented content against how flat the replies get,
|
||||
// and the replies are logged for exactly that reason.
|
||||
//
|
||||
// Note that only the chat/query/world paths move: the reply path is a Replier
|
||||
// over llm.Client, which samples greedily and does not read this dial.
|
||||
func TestTalkTemperatureSweep(t *testing.T) {
|
||||
base := os.Getenv("MAVEN_LLM_URL")
|
||||
if base == "" {
|
||||
t.Skip("MAVEN_LLM_URL unset — point it at a running llama-server")
|
||||
}
|
||||
if os.Getenv("MAVEN_TEMP_SWEEP") == "" {
|
||||
t.Skip("MAVEN_TEMP_SWEEP unset — this sweep costs many minutes, see the doc comment")
|
||||
}
|
||||
noProxyLoopback(t)
|
||||
|
||||
ctx := context.Background()
|
||||
f, err := LoadTalk()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadTalk: %v", err)
|
||||
}
|
||||
model, err := llm.ModelID(ctx, base)
|
||||
if err != nil {
|
||||
t.Fatalf("no model at %s: %v", base, err)
|
||||
}
|
||||
|
||||
block := func() string { return persona.Facts{}.Block(time.Now()) }
|
||||
summary := fmt.Sprintf("temperature sweep, %s, %d runs each\n", model, sweepRuns)
|
||||
|
||||
for _, temp := range sweepTemperatures {
|
||||
for run := 1; run <= sweepRuns; run++ {
|
||||
cfg := phraser.DefaultConfig("")
|
||||
cfg.Timeout = 5 * time.Minute
|
||||
cfg.ContextBlock = block
|
||||
cfg.Temperature = temp
|
||||
p := phraser.NewLLMPhraserAt(base, cfg)
|
||||
|
||||
name := fmt.Sprintf("temp %.2f run %d", temp, run)
|
||||
target := Pair{Talker: p, Confirmer: phraser.NewReplier(llm.New(base, cfg.Timeout), block)}
|
||||
rep, err := ScoreTalk(ctx, name, target, f)
|
||||
p.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("ScoreTalk at %.2f: %v", temp, err)
|
||||
}
|
||||
if rep.Errors == rep.Total {
|
||||
t.Fatalf("every case errored at %.2f — nothing was measured", temp)
|
||||
}
|
||||
t.Log("\n" + rep.String() + "\nreplies:\n" + rep.Replies() + "\nfailures:\n" + rep.Failures())
|
||||
summary += fmt.Sprintf(" %-18s %2d/%2d (%.1f%%) ontopic %d/%d errors %d\n",
|
||||
name, rep.Passed, rep.Total, 100*rep.Accuracy(),
|
||||
rep.ByCheck[CheckOnTopic], rep.Total, rep.Errors)
|
||||
}
|
||||
}
|
||||
t.Log("\n" + summary)
|
||||
}
|
||||
@@ -131,6 +131,14 @@ type Config struct {
|
||||
// query and reminder phrasing are untouched and still go through the model.
|
||||
LLMNudges bool
|
||||
|
||||
// Temperature — what every phrasing call samples at. 0 ⇒ 0.7, which is
|
||||
// what this transport has always sent.
|
||||
//
|
||||
// A field rather than a constant so the talk fixture can sweep it
|
||||
// (Vikunja #402). Sampling is a dial, and a dial nobody can turn from
|
||||
// outside the package cannot be measured, only argued about.
|
||||
Temperature float64
|
||||
|
||||
// NoGrammar turns the GBNF constraint off (zero value ⇒ grammar ON).
|
||||
// The escape hatch exists because the target resident model — the
|
||||
// locally CPT'd Qwen3-1.7B — does not exist yet: if its chat template
|
||||
@@ -594,7 +602,7 @@ func (p *LLMPhraser) chatWithMessages(ctx context.Context, msgs []chatMsg, maxTo
|
||||
defer release()
|
||||
req := chatReq{
|
||||
Messages: msgs,
|
||||
Temperature: 0.7,
|
||||
Temperature: p.temperature(),
|
||||
MaxTokens: maxTokens,
|
||||
Grammar: p.grammar(),
|
||||
RepeatPenalty: phraseRepeatPenalty,
|
||||
@@ -804,7 +812,7 @@ func (p *LLMPhraser) chatWithSystem(ctx context.Context, system, user string, ma
|
||||
{Role: "system", Content: system},
|
||||
{Role: "user", Content: user},
|
||||
},
|
||||
Temperature: 0.7,
|
||||
Temperature: p.temperature(),
|
||||
MaxTokens: maxTokens,
|
||||
Grammar: p.grammar(),
|
||||
RepeatPenalty: phraseRepeatPenalty,
|
||||
|
||||
@@ -37,11 +37,19 @@ type Remote interface {
|
||||
// (docs/evals/2026-08-02-workstation-gemma4-12b.md).
|
||||
var ErrNoWorldModel = errors.New("phraser: no world model available")
|
||||
|
||||
// chatTemperature — what the phraser's own transport has always sampled at.
|
||||
// Named so the remote path cannot drift from it silently. Whether 0.7 is right
|
||||
// at all is Vikunja #402, and answering that here would hide a phrasing change
|
||||
// inside a routing change.
|
||||
const chatTemperature = 0.7
|
||||
// defaultChatTemperature — what the phraser's own transport has always sampled
|
||||
// at, and what Config.Temperature falls back to. Named so the remote path
|
||||
// cannot drift from the resident one silently.
|
||||
const defaultChatTemperature = 0.7
|
||||
|
||||
// temperature — the sampling temperature for every phrasing call, resident or
|
||||
// remote. Both paths read this, so a sweep moves them together.
|
||||
func (p *LLMPhraser) temperature() float64 {
|
||||
if p.cfg.Temperature > 0 {
|
||||
return p.cfg.Temperature
|
||||
}
|
||||
return defaultChatTemperature
|
||||
}
|
||||
|
||||
// UseRemote points the phraser at the workstation model. Wiring time only, once,
|
||||
// before anything phrases: the field is read without a lock on every call
|
||||
@@ -85,7 +93,7 @@ func (p *LLMPhraser) PhraseWorld(ctx context.Context, utterance string, sources
|
||||
User: user,
|
||||
Grammar: p.grammar(),
|
||||
MaxTokens: 768,
|
||||
Temperature: chatTemperature,
|
||||
Temperature: p.temperature(),
|
||||
})
|
||||
if err != nil {
|
||||
// The cached probe was one interval stale, or the card went away
|
||||
@@ -124,7 +132,7 @@ func (p *LLMPhraser) remoteChat(ctx context.Context, system, user string, maxTok
|
||||
User: user,
|
||||
Grammar: p.grammar(),
|
||||
MaxTokens: maxTokens,
|
||||
Temperature: chatTemperature,
|
||||
Temperature: p.temperature(),
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("phraser: workstation model declined, phrasing here instead: %v", err)
|
||||
|
||||
@@ -141,9 +141,9 @@ func TestNudgePhrasingPrefersTheWorkstationSilently(t *testing.T) {
|
||||
if len(remote.got) != 1 {
|
||||
t.Fatalf("the workstation saw %d requests, want 1", len(remote.got))
|
||||
}
|
||||
if remote.got[0].Temperature != chatTemperature {
|
||||
if remote.got[0].Temperature != defaultChatTemperature {
|
||||
t.Errorf("temperature = %v, want %v (what the resident transport samples at)",
|
||||
remote.got[0].Temperature, chatTemperature)
|
||||
remote.got[0].Temperature, defaultChatTemperature)
|
||||
}
|
||||
if len(spy.user) != 0 {
|
||||
t.Errorf("the resident model phrased %d nudges, want 0", len(spy.user))
|
||||
|
||||
@@ -269,6 +269,27 @@ ALTER TABLE reminders ADD COLUMN next_fire_ts INTEGER;`, // #2
|
||||
UPDATE proposed_routines
|
||||
SET accepted_ts = created_ts, reminder_id = NULL
|
||||
WHERE status = 'accepted' AND accepted_ts IS NULL;`,
|
||||
|
||||
// #21 — allow 'resolved' as a nudge outcome (Vikunja #535). The sev4 repeat
|
||||
// path needs an ending that means "the condition cleared, so I stopped
|
||||
// talking", which is neither 'acted' (he answered) nor 'ignored' (nobody
|
||||
// ever did). A CHECK cannot be altered in place, so the table is rebuilt.
|
||||
// Rows carry over unchanged; only the constraint widens.
|
||||
`CREATE TABLE nudges_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ts INTEGER NOT NULL,
|
||||
rule TEXT NOT NULL,
|
||||
channel TEXT NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
outcome TEXT NOT NULL DEFAULT 'pending' CHECK (outcome IN ('pending','acted','snoozed','ignored','resolved')),
|
||||
outcome_ts INTEGER
|
||||
);
|
||||
INSERT INTO nudges_new (id, ts, rule, channel, message, outcome, outcome_ts)
|
||||
SELECT id, ts, rule, channel, message, outcome, outcome_ts FROM nudges;
|
||||
DROP TABLE nudges;
|
||||
ALTER TABLE nudges_new RENAME TO nudges;
|
||||
CREATE INDEX IF NOT EXISTS idx_nudges_rule_ts ON nudges (rule, ts DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_nudges_outcome ON nudges (outcome);`,
|
||||
}
|
||||
|
||||
// migrate applies every migration with a number greater than the DB's current
|
||||
|
||||
@@ -26,6 +26,18 @@ const (
|
||||
NudgeActed = "acted"
|
||||
NudgeSnoozed = "snoozed"
|
||||
NudgeIgnored = "ignored"
|
||||
|
||||
// NudgeResolved — the thing it was about stopped being true, and nobody
|
||||
// answered. Distinct from acted, which means he did something, and from
|
||||
// ignored, which means he chose not to (Vikunja #535).
|
||||
//
|
||||
// It exists because a repeating alarm needs an ending that is not a lie.
|
||||
// Marking a cleared service_down "acted" would credit him with a response
|
||||
// he never made and would teach the cooldown tuner to nudge harder; leaving
|
||||
// it pending is what made the alarm ring for two hours after the service
|
||||
// came back. This outcome is written by the daemon, never by a person, and
|
||||
// it is deliberately invisible to the feedback loop — see RecentOutcomes.
|
||||
NudgeResolved = "resolved"
|
||||
)
|
||||
|
||||
// SnoozeDuration — how long one `snoozed` outcome keeps its rule quiet.
|
||||
@@ -103,9 +115,14 @@ func (s *Store) ResolveNudge(ctx context.Context, id int64, outcome string, ts t
|
||||
// for a given rule — the feedback loop's only input. used to compute
|
||||
// ignored_rate → cooldown sizing. dead simple at mvp: a ratio over last N.
|
||||
func (s *Store) RecentOutcomes(ctx context.Context, rule string, n int) ([]string, error) {
|
||||
// 'resolved' is excluded alongside 'pending' (Vikunja #535). The tuner reads
|
||||
// this as "how often does he answer me", and a resolution the daemon wrote
|
||||
// is not him answering. Counting it would dilute both rates toward zero and
|
||||
// make a service that fixes itself look like a rule he neither acts on nor
|
||||
// ignores, which is a fact about the world and not feedback about her.
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT outcome FROM nudges
|
||||
WHERE rule = ? AND outcome != 'pending'
|
||||
WHERE rule = ? AND outcome NOT IN ('pending', 'resolved')
|
||||
ORDER BY ts DESC, id DESC LIMIT ?`, rule, n)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("recent outcomes: %w", err)
|
||||
@@ -152,6 +169,61 @@ func (s *Store) UnackedTelegramRules(ctx context.Context) ([]string, error) {
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ResolvePendingTelegram closes every still-pending telegram nudge for a rule
|
||||
// and reports how many it closed. This is what stops a repeating alarm
|
||||
// (Vikunja #535).
|
||||
//
|
||||
// Rule-scoped and not id-scoped, deliberately: the repeat key IS the rule name,
|
||||
// so a rule with several pending rows repeats once per tick for all of them and
|
||||
// must go quiet for all of them at once. Closing one id would leave the alarm
|
||||
// ringing on the others.
|
||||
//
|
||||
// The outcome is a parameter rather than hardcoded, because "the condition
|
||||
// cleared" and "nobody could ever answer this" are different endings and
|
||||
// /notifications should not show them as the same one.
|
||||
func (s *Store) ResolvePendingTelegram(ctx context.Context, rule, outcome string, ts time.Time) (int64, error) {
|
||||
switch outcome {
|
||||
case NudgeResolved, NudgeIgnored:
|
||||
default:
|
||||
return 0, fmt.Errorf("store: %q is not an ending the daemon may write", outcome)
|
||||
}
|
||||
res, err := s.db.ExecContext(ctx,
|
||||
`UPDATE nudges SET outcome = ?, outcome_ts = ?
|
||||
WHERE rule = ? AND channel = 'telegram' AND outcome = 'pending'`,
|
||||
outcome, ts.UnixMilli(), rule)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("resolve pending telegram %s: %w", rule, err)
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("resolve pending telegram %s: rows affected: %w", rule, err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// OldestPendingTelegram returns when the oldest still-pending telegram nudge
|
||||
// for a rule was sent. Used for the repeat age cap: an alarm nobody has
|
||||
// answered in hours is not one more repeat away from being answered.
|
||||
//
|
||||
// Oldest and not newest, because the age that matters is how long the alarm has
|
||||
// been ringing, not when it last rang — the repeat itself does not create new
|
||||
// rows, but a re-fire of the rule does, and the alarm is the whole run.
|
||||
func (s *Store) OldestPendingTelegram(ctx context.Context, rule string) (time.Time, error) {
|
||||
// MIN over an empty set is one row holding NULL, not zero rows, so this
|
||||
// scans into a NullInt64 and never sees sql.ErrNoRows.
|
||||
var tsMilli sql.NullInt64
|
||||
err := s.db.QueryRowContext(ctx,
|
||||
`SELECT MIN(ts) FROM nudges
|
||||
WHERE rule = ? AND channel = 'telegram' AND outcome = 'pending'`, rule).Scan(&tsMilli)
|
||||
if err != nil {
|
||||
return time.Time{}, fmt.Errorf("oldest pending telegram %s: %w", rule, err)
|
||||
}
|
||||
if !tsMilli.Valid {
|
||||
return time.Time{}, ErrNudgeNotFound
|
||||
}
|
||||
return time.UnixMilli(tsMilli.Int64).UTC(), nil
|
||||
}
|
||||
|
||||
// SnoozedUntil — per rule, when its most recent snooze runs out. This is the
|
||||
// read behind the gate's snooze check: the `snoozed` outcome already in the
|
||||
// nudges table IS the restraint memory, so there is no snooze table.
|
||||
|
||||
@@ -43,7 +43,9 @@ CREATE TABLE IF NOT EXISTS nudges (
|
||||
rule TEXT NOT NULL,
|
||||
channel TEXT NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
outcome TEXT NOT NULL DEFAULT 'pending' CHECK (outcome IN ('pending','acted','snoozed','ignored')),
|
||||
-- 'resolved' is the daemon closing an alarm because the condition cleared,
|
||||
-- as opposed to 'acted' (he answered) or 'ignored' (nobody ever did).
|
||||
outcome TEXT NOT NULL DEFAULT 'pending' CHECK (outcome IN ('pending','acted','snoozed','ignored','resolved')),
|
||||
outcome_ts INTEGER
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_nudges_rule_ts ON nudges (rule, ts DESC);
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
// ttsnorm/pronounce.go — how piper says the latin words that turn up inside a
|
||||
// Russian sentence: service names, hostnames, acronyms (Vikunja #458).
|
||||
//
|
||||
// The RU voice reads latin letters one at a time or guesses, so "netdata" comes
|
||||
// out as noise and "homesrv" comes out as nothing. The fix is spelling the
|
||||
// sound in Cyrillic, and the mapping is data — nothing in the code knows any of
|
||||
// these names, and adding one is an edit to pronounce_ru_v1.json.
|
||||
//
|
||||
// This is a rewrite over a latin token, not over Russian morphology: the
|
||||
// pattern matches [a-z0-9] runs and asks the table. A word the table does not
|
||||
// hold is left exactly as it was, so a miss is the current behaviour rather
|
||||
// than a guess.
|
||||
package ttsnorm
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
//go:embed pronounce_ru_v1.json
|
||||
var pronounceJSON []byte
|
||||
|
||||
// pronounceSchemaVersion — the version this loader understands.
|
||||
const pronounceSchemaVersion = 1
|
||||
|
||||
// latinToken — one run of latin letters and digits. Cyrillic is untouched, so a
|
||||
// Russian word next to an English one is never rewritten by accident.
|
||||
var latinToken = regexp.MustCompile(`[A-Za-z][A-Za-z0-9]*`)
|
||||
|
||||
// pronounce — lowercase word to its Russian spelling. Empty when the file
|
||||
// failed to load, which leaves speech exactly as it was before this existed.
|
||||
var pronounce = loadPronounce()
|
||||
|
||||
func loadPronounce() map[string]string {
|
||||
var f struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
Entries map[string]string `json:"entries"`
|
||||
}
|
||||
if err := json.Unmarshal(pronounceJSON, &f); err != nil {
|
||||
// Speech must not stop because a dictionary is malformed.
|
||||
log.Printf("ttsnorm: pronunciation dictionary: %v", err)
|
||||
return map[string]string{}
|
||||
}
|
||||
if f.SchemaVersion != pronounceSchemaVersion {
|
||||
log.Printf("ttsnorm: pronunciation dictionary schema_version %d, want %d — not loading it",
|
||||
f.SchemaVersion, pronounceSchemaVersion)
|
||||
return map[string]string{}
|
||||
}
|
||||
return f.Entries
|
||||
}
|
||||
|
||||
// Pronounce rewrites every latin word the dictionary knows. Case-insensitive on
|
||||
// the way in ("Netdata", "NETDATA" and "netdata" are one word) and lowercase on
|
||||
// the way out, because the value is a sound and not a name.
|
||||
func Pronounce(s string) string {
|
||||
if len(pronounce) == 0 {
|
||||
return s
|
||||
}
|
||||
return latinToken.ReplaceAllStringFunc(s, func(w string) string {
|
||||
if say, ok := pronounce[strings.ToLower(w)]; ok {
|
||||
return say
|
||||
}
|
||||
return w
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"_comment": "How piper should say latin words inside a Russian sentence. Keys are lowercase and matched whole, so 'nexus' is rewritten and 'nexuses' is not. Values are Russian spelling of the sound, which is the only thing piper's RU voice can read. Adding a word here is a data change: nothing in the code knows any of these names.",
|
||||
"entries": {
|
||||
"maven": "мэйвен",
|
||||
"nexus": "нексус",
|
||||
"praxis": "праксис",
|
||||
"hexis": "хексис",
|
||||
"vikunja": "викунья",
|
||||
"homesrv": "хоумсерв",
|
||||
"workpc": "воркписи",
|
||||
"kuma": "кума",
|
||||
"netdata": "нетдата",
|
||||
"paperless": "пейперлес",
|
||||
"gitea": "гитея",
|
||||
"docker": "докер",
|
||||
"kiwix": "кивикс",
|
||||
"searxng": "серч эн джи",
|
||||
"piper": "пайпер",
|
||||
"whisper": "виспер",
|
||||
"caldav": "калдав",
|
||||
"telegram": "телеграм",
|
||||
"ntfy": "нотифай",
|
||||
"imap": "аймап",
|
||||
"smtp": "эс эм ти пи",
|
||||
"http": "эйч ти ти пи",
|
||||
"https": "эйч ти ти пи эс",
|
||||
"api": "эй пи ай",
|
||||
"url": "юарэль",
|
||||
"cpu": "цэпэу",
|
||||
"gpu": "джипиу",
|
||||
"ram": "рам",
|
||||
"ssd": "эсэсди",
|
||||
"hdd": "эйчдиди",
|
||||
"usb": "юэсби",
|
||||
"vpn": "вэпээн",
|
||||
"nas": "нас",
|
||||
"dns": "дээнэс",
|
||||
"wifi": "вайфай",
|
||||
"pdf": "пэдээф",
|
||||
"json": "джейсон",
|
||||
"llm": "элэлэм",
|
||||
"tts": "титиэс",
|
||||
"stt": "эстиэти",
|
||||
"ok": "окей"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package ttsnorm
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDictionaryLoads(t *testing.T) {
|
||||
// An empty map is the failure mode, and it is silent at runtime by design.
|
||||
if len(pronounce) == 0 {
|
||||
t.Fatal("pronunciation dictionary is empty — it failed to load or failed its version check")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPronounceRewritesAKnownService(t *testing.T) {
|
||||
got := Pronounce("netdata говорит что диск заполнен")
|
||||
if strings.Contains(got, "netdata") {
|
||||
t.Fatalf("got %q, want the latin name spelled in Cyrillic", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPronounceIsCaseInsensitive(t *testing.T) {
|
||||
for _, in := range []string{"Netdata", "NETDATA", "netdata"} {
|
||||
if got := Pronounce(in); got != pronounce["netdata"] {
|
||||
t.Errorf("Pronounce(%q) = %q, want %q", in, got, pronounce["netdata"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPronounceLeavesUnknownWordsAlone(t *testing.T) {
|
||||
// A miss must be the old behaviour, never a guess.
|
||||
const in = "zzqx упал"
|
||||
if got := Pronounce(in); got != in {
|
||||
t.Fatalf("Pronounce(%q) = %q, want it untouched", in, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPronounceDoesNotTouchRussian(t *testing.T) {
|
||||
const in = "напомню завтра в 19:00"
|
||||
if got := Pronounce(in); got != in {
|
||||
t.Fatalf("Pronounce(%q) = %q, want Cyrillic untouched", in, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPronounceMatchesWholeWordsOnly(t *testing.T) {
|
||||
// "nexuses" is not "nexus", and half-rewriting a word is worse than not
|
||||
// rewriting it.
|
||||
if got := Pronounce("nexuses"); got != "nexuses" {
|
||||
t.Fatalf("Pronounce(\"nexuses\") = %q, want it untouched", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpeakableAppliesTheDictionary(t *testing.T) {
|
||||
got := Speakable("homesrv: 10.07.2026")
|
||||
if strings.Contains(got, "homesrv") {
|
||||
t.Fatalf("Speakable did not apply the dictionary: %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "июля") {
|
||||
t.Fatalf("Speakable stopped rewriting dates: %q", got)
|
||||
}
|
||||
}
|
||||
@@ -41,7 +41,9 @@ func Speakable(s string) string {
|
||||
p := reDate.FindStringSubmatch(m)
|
||||
return spokenDate(p[1], p[2], "")
|
||||
})
|
||||
return s
|
||||
// Last, so a hostname is spelled out after the numbers around it are
|
||||
// already words and no rewrite above can see Cyrillic it did not expect.
|
||||
return Pronounce(s)
|
||||
}
|
||||
|
||||
func spokenDate(dd, mm, yyyy string) string {
|
||||
|
||||
Reference in New Issue
Block a user