Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 09f1696fce | |||
| 80f7322294 | |||
| fa5aebfbe4 | |||
| 59cec63da1 | |||
| 02e8786695 | |||
| 0272dc9d89 | |||
| 2ad7635501 | |||
| 9949b309b1 | |||
| a788ca3915 | |||
| 62d47d28ac | |||
| e9ff2c4912 | |||
| 3dbf67f8f9 | |||
| 84ba217892 | |||
| f179ae2fde | |||
| d00929ac0b |
@@ -0,0 +1,78 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/router"
|
||||||
|
)
|
||||||
|
|
||||||
|
// systemHandler — a handler with nothing but a fixed clock, which is all
|
||||||
|
// replySystem needs.
|
||||||
|
func systemHandler(now time.Time) *reactiveHandler {
|
||||||
|
return &reactiveHandler{now: func() time.Time { return now }}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestReplySystemDateOffset — "какое число завтра" must answer tomorrow's
|
||||||
|
// date, not today's (Vikunja #388).
|
||||||
|
func TestReplySystemDateOffset(t *testing.T) {
|
||||||
|
// Thursday, 30 July 2026.
|
||||||
|
now := time.Date(2026, 7, 30, 14, 5, 0, 0, time.UTC)
|
||||||
|
h := systemHandler(now)
|
||||||
|
cases := []struct{ utterance, want string }{
|
||||||
|
{"какое сегодня число", "сегодня четверг, 30 июля 2026 года"},
|
||||||
|
{"какое число", "сегодня четверг, 30 июля 2026 года"},
|
||||||
|
{"какое число завтра", "завтра пятница, 31 июля 2026 года"},
|
||||||
|
{"какое число послезавтра", "послезавтра суббота, 1 августа 2026 года"},
|
||||||
|
{"какое было число вчера", "вчера среда, 29 июля 2026 года"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
got := h.replySystem(context.Background(), router.Decision{Utterance: c.utterance})
|
||||||
|
if got != c.want {
|
||||||
|
t.Errorf("replySystem(%q) = %q, want %q", c.utterance, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A day she cannot work out must not come back as today's date — that is the
|
||||||
|
// same silent wrong answer #388 was about, one step further out.
|
||||||
|
func TestReplySystemUnknownDayIsHonest(t *testing.T) {
|
||||||
|
now := time.Date(2026, 7, 30, 14, 5, 0, 0, time.UTC)
|
||||||
|
h := systemHandler(now)
|
||||||
|
for _, u := range []string{
|
||||||
|
"какое число в пятницу",
|
||||||
|
"какое число через неделю",
|
||||||
|
"какое число в понедельник",
|
||||||
|
} {
|
||||||
|
got := h.replySystem(context.Background(), router.Decision{Utterance: u})
|
||||||
|
if got != onlyNearDaysReply {
|
||||||
|
t.Errorf("replySystem(%q) = %q, want the honest reply", u, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The days she does know must not be caught by the same guard.
|
||||||
|
if got := h.replySystem(context.Background(), router.Decision{Utterance: "какое число завтра"}); got == onlyNearDaysReply {
|
||||||
|
t.Error("завтра was treated as an unknown day")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestReplySystemClockCity — the clock arm must not answer local time for a
|
||||||
|
// question about another city (Vikunja #388). She keeps one clock, so every
|
||||||
|
// named place gets the honest "local time only" answer.
|
||||||
|
func TestReplySystemClockCity(t *testing.T) {
|
||||||
|
now := time.Date(2026, 7, 30, 12, 0, 0, 0, time.UTC)
|
||||||
|
h := systemHandler(now)
|
||||||
|
cases := []struct{ utterance, want string }{
|
||||||
|
{"который час", "сейчас 12 часов ровно"},
|
||||||
|
{"который час в киеве", onlyLocalTimeReply},
|
||||||
|
{"сколько времени в москве", onlyLocalTimeReply},
|
||||||
|
{"который час в лондоне", onlyLocalTimeReply},
|
||||||
|
{"который час в бишкеке", onlyLocalTimeReply},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
got := h.replySystem(context.Background(), router.Decision{Utterance: c.utterance})
|
||||||
|
if got != c.want {
|
||||||
|
t.Errorf("replySystem(%q) = %q, want %q", c.utterance, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+119
-11
@@ -752,7 +752,9 @@ func (h *reactiveHandler) applyAction(ctx context.Context, dec router.Decision)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Calendar questions: "что у меня сегодня?", "планы на завтра?"
|
// Calendar questions: "что у меня сегодня?", "планы на завтра?"
|
||||||
if date, ok := router.ParseCalendarDate(dec.Utterance, time.Now()); ok {
|
// h.now(), not time.Now(): the handler's clock is the injected one, so
|
||||||
|
// this arm can be tested at a fixed time like the rest.
|
||||||
|
if date, ok := router.ParseCalendarDate(dec.Utterance, h.now()); ok {
|
||||||
events, err := h.api.CalendarEvents(ctx, date, date.Add(24*time.Hour))
|
events, err := h.api.CalendarEvents(ctx, date, date.Add(24*time.Hour))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("voice: calendar events: %v", err)
|
log.Printf("voice: calendar events: %v", err)
|
||||||
@@ -936,6 +938,101 @@ var ruMonths = []string{
|
|||||||
"июля", "августа", "сентября", "октября", "ноября", "декабря",
|
"июля", "августа", "сентября", "октября", "ноября", "декабря",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// onlyLocalTimeReply — the honest answer when the user asks the time somewhere
|
||||||
|
// other than here. She only keeps one clock, and saying so is better than
|
||||||
|
// naming the wrong city's time.
|
||||||
|
//
|
||||||
|
// There used to be a city→time-zone table here. It was removed on purpose: the
|
||||||
|
// user only ever asks for local time, so the table was a second list of cities
|
||||||
|
// to keep in step with the weather one for no gain.
|
||||||
|
const onlyLocalTimeReply = "я знаю только местное время, про другие города пока не скажу."
|
||||||
|
|
||||||
|
// notPlaceAfterV — words that follow "в" without naming a place, so
|
||||||
|
// mentionsUnknownPlace does not mistake them for a city.
|
||||||
|
var notPlaceAfterV = map[string]bool{
|
||||||
|
"данный": true, "данную": true, "этот": true, "эту": true,
|
||||||
|
"котором": true, "какое": true, "какой": true, "который": true,
|
||||||
|
"общем": true, "точности": true, "курсе": true, "сутках": true,
|
||||||
|
"часах": true, "минутах": true, "секундах": true, "неделе": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// mentionsUnknownPlace reports whether the question has a "в <слово>" phrase
|
||||||
|
// that looks like a place we do not know ("который час в киеве"). Used only to
|
||||||
|
// pick the honest "local time only" reply instead of answering local time as
|
||||||
|
// if it were the city's.
|
||||||
|
func mentionsUnknownPlace(u string) bool {
|
||||||
|
toks := strings.Fields(u)
|
||||||
|
for i := 0; i+1 < len(toks); i++ {
|
||||||
|
if toks[i] != "в" && toks[i] != "во" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
next := strings.Trim(toks[i+1], ".,?!")
|
||||||
|
if next == "" || notPlaceAfterV[next] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// A number after "в" is a clock ("в 5 часов"), not a place.
|
||||||
|
if _, err := strconv.Atoi(strings.SplitN(next, ":", 2)[0]); err == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// onlyNearDaysReply — she can work out today, tomorrow, the day after and
|
||||||
|
// yesterday, and nothing further. Said out loud instead of answering today's
|
||||||
|
// date for a day she did not understand.
|
||||||
|
const onlyNearDaysReply = "я считаю только сегодня, завтра, послезавтра и вчера — про другие дни пока не скажу."
|
||||||
|
|
||||||
|
// dayWords — day references the calendar parser cannot resolve. A weekday name
|
||||||
|
// or a "через …" phrase means he asked about a specific other day.
|
||||||
|
var dayWords = []string{
|
||||||
|
"понедельник", "вторник", "сред", "четверг", "пятниц", "суббот", "воскресен",
|
||||||
|
"через", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday",
|
||||||
|
}
|
||||||
|
|
||||||
|
// mentionsUnknownDay reports whether the question names a day the calendar
|
||||||
|
// parser could not resolve. Mirror of mentionsUnknownPlace: it exists only to
|
||||||
|
// pick an honest reply over a confidently wrong one.
|
||||||
|
//
|
||||||
|
// Only called after ParseCalendarDate has already failed, so "завтра" and the
|
||||||
|
// other words it does know never reach here.
|
||||||
|
func mentionsUnknownDay(u string) bool {
|
||||||
|
for _, w := range dayWords {
|
||||||
|
if strings.Contains(u, w) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ruClock renders the clock part of the time reply: "15 часов 4 минуты".
|
||||||
|
func ruClock(t time.Time) string {
|
||||||
|
h, m := t.Hour(), t.Minute()
|
||||||
|
hourWord := ruPlural(h, "час", "часа", "часов")
|
||||||
|
if m == 0 {
|
||||||
|
return fmt.Sprintf("%d %s ровно", h, hourWord)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d %s %d %s", h, hourWord, m, ruPlural(m, "минута", "минуты", "минут"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// dayPrefix names the day relative to now ("завтра", "вчера", …) so the date
|
||||||
|
// reply opens the way a person would say it.
|
||||||
|
func dayPrefix(now, day time.Time) string {
|
||||||
|
base := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||||
|
switch int(day.Sub(base).Hours() / 24) {
|
||||||
|
case -1:
|
||||||
|
return "вчера"
|
||||||
|
case 0:
|
||||||
|
return "сегодня"
|
||||||
|
case 1:
|
||||||
|
return "завтра"
|
||||||
|
case 2:
|
||||||
|
return "послезавтра"
|
||||||
|
}
|
||||||
|
return "это"
|
||||||
|
}
|
||||||
|
|
||||||
func ruPlural(n int, one, two, many string) string {
|
func ruPlural(n int, one, two, many string) string {
|
||||||
n = n % 100
|
n = n % 100
|
||||||
if n > 10 && n < 20 {
|
if n > 10 && n < 20 {
|
||||||
@@ -1014,18 +1111,29 @@ func (h *reactiveHandler) replySystem(ctx context.Context, dec router.Decision)
|
|||||||
|
|
||||||
switch {
|
switch {
|
||||||
case strings.Contains(u, "час") || strings.Contains(u, "врем"):
|
case strings.Contains(u, "час") || strings.Contains(u, "врем"):
|
||||||
h := now.Hour()
|
// "который час в киеве" — she keeps one clock, so any named place gets
|
||||||
m := now.Minute()
|
// the honest answer. Never local time dressed up as the city's.
|
||||||
hourWord := ruPlural(h, "час", "часа", "часов")
|
if mentionsUnknownPlace(u) {
|
||||||
if m == 0 {
|
return onlyLocalTimeReply
|
||||||
return fmt.Sprintf("сейчас %d %s ровно", h, hourWord)
|
|
||||||
}
|
}
|
||||||
minWord := ruPlural(m, "минута", "минуты", "минут")
|
return "сейчас " + ruClock(now)
|
||||||
return fmt.Sprintf("сейчас %d %s %d %s", h, hourWord, m, minWord)
|
|
||||||
case strings.Contains(u, "день") || strings.Contains(u, "числ"):
|
case strings.Contains(u, "день") || strings.Contains(u, "числ"):
|
||||||
dow := ruWeekdays[now.Weekday()]
|
// "какое число завтра" — answer for the day the user asked about,
|
||||||
month := ruMonths[now.Month()-1]
|
// not today. Reuses the router's calendar day-word parser.
|
||||||
return fmt.Sprintf("сегодня %s, %d %s %d года", dow, now.Day(), month, now.Year())
|
day := now
|
||||||
|
prefix := "сегодня"
|
||||||
|
if d, ok := router.ParseCalendarDate(u, now); ok {
|
||||||
|
day = d
|
||||||
|
prefix = dayPrefix(now, d)
|
||||||
|
} else if mentionsUnknownDay(u) {
|
||||||
|
// He named a day she cannot work out ("в пятницу", "через неделю").
|
||||||
|
// Answering today's date here would be the same silent wrong answer
|
||||||
|
// this arm was fixed for, so say what she can do instead.
|
||||||
|
return onlyNearDaysReply
|
||||||
|
}
|
||||||
|
dow := ruWeekdays[day.Weekday()]
|
||||||
|
month := ruMonths[day.Month()-1]
|
||||||
|
return fmt.Sprintf("%s %s, %d %s %d года", prefix, dow, day.Day(), month, day.Year())
|
||||||
case strings.Contains(u, "кто дома") || strings.Contains(u, "человек дома"):
|
case strings.Contains(u, "кто дома") || strings.Contains(u, "человек дома"):
|
||||||
return "присутствие пока не подключено к голосовому запросу."
|
return "присутствие пока не подключено к голосовому запросу."
|
||||||
case strings.Contains(u, "памят") || strings.Contains(u, "процессор") || strings.Contains(u, "загрузк") || strings.Contains(u, "статус") || strings.Contains(u, "работа") || strings.Contains(u, "сервис") || strings.Contains(u, "диск") || strings.Contains(u, "ip") || strings.Contains(u, "аптайм") || strings.Contains(u, "трафик") || strings.Contains(u, "интернет"):
|
case strings.Contains(u, "памят") || strings.Contains(u, "процессор") || strings.Contains(u, "загрузк") || strings.Contains(u, "статус") || strings.Contains(u, "работа") || strings.Contains(u, "сервис") || strings.Contains(u, "диск") || strings.Contains(u, "ip") || strings.Contains(u, "аптайм") || strings.Contains(u, "трафик") || strings.Contains(u, "интернет"):
|
||||||
|
|||||||
@@ -137,9 +137,10 @@ func NewDispatcher(cfg Config) *Dispatcher {
|
|||||||
// picks for (severity, presence), sends via the matching sink, and records
|
// picks for (severity, presence), sends via the matching sink, and records
|
||||||
// one nudge row per successful send. returns the dispatches (one per channel).
|
// one nudge row per successful send. returns the dispatches (one per channel).
|
||||||
//
|
//
|
||||||
// a Drop channel = no send, no record (the nudge was suppressed by routing,
|
// a Drop channel = no send (the nudge was suppressed by routing, not by a
|
||||||
// not by a failure — "a missed water nudge is noise"). a nil sink = channel
|
// failure — "a missed water nudge is noise"), but it does leave a 'dropped'
|
||||||
// not wired, skip silently. a send error stops the dispatch and returns what
|
// outbox row so the suppression is visible. a nil sink = channel not wired,
|
||||||
|
// skip silently. a send error stops the dispatch and returns what
|
||||||
// got through — the daemon decides whether to retry.
|
// got through — the daemon decides whether to retry.
|
||||||
func (d *Dispatcher) DispatchNudge(ctx context.Context, pn PhrasedNudge, now time.Time) ([]Dispatch, error) {
|
func (d *Dispatcher) DispatchNudge(ctx context.Context, pn PhrasedNudge, now time.Time) ([]Dispatch, error) {
|
||||||
c := pn.Candidate
|
c := pn.Candidate
|
||||||
@@ -148,6 +149,16 @@ func (d *Dispatcher) DispatchNudge(ctx context.Context, pn PhrasedNudge, now tim
|
|||||||
for i := 0; i < len(channels); i++ {
|
for i := 0; i < len(channels); i++ {
|
||||||
ch := channels[i]
|
ch := channels[i]
|
||||||
if ch == ChannelDrop {
|
if ch == ChannelDrop {
|
||||||
|
// the routing table suppressed this nudge on purpose (a care nudge
|
||||||
|
// while you're away is noise). that stays — but it must not be
|
||||||
|
// invisible, or "she dropped it" and "the rule never fired" look
|
||||||
|
// the same afterwards. no nudges row: that table feeds the
|
||||||
|
// ignored_rate signal, and a nudge nobody could see must not
|
||||||
|
// count as ignored.
|
||||||
|
id := d.beginOutbox(ctx, "nudge", c.Rule.Name, 0, ch, pn.Summary, now)
|
||||||
|
d.completeOutbox(ctx, id, store.DeliveryDropped, now)
|
||||||
|
log.Printf("dispatcher: dropped %s (sev%d, presence=%s) — routing table suppressed it",
|
||||||
|
c.Rule.Name, c.Severity, c.State.Presence)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
s := Sendable{
|
s := Sendable{
|
||||||
@@ -396,6 +407,13 @@ func messageForChannel(s Sendable) string {
|
|||||||
if !isAway(s.Channel) {
|
if !isAway(s.Channel) {
|
||||||
return s.Body
|
return s.Body
|
||||||
}
|
}
|
||||||
|
return AwayMessage(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AwayMessage — the only text an off-box channel may ever carry. Exported so
|
||||||
|
// the away sinks share this one rule instead of each inventing a fallback: the
|
||||||
|
// summary if we have one, otherwise a fixed generic line. Never the body.
|
||||||
|
func AwayMessage(s Sendable) string {
|
||||||
if s.Summary != "" {
|
if s.Summary != "" {
|
||||||
return s.Summary
|
return s.Summary
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,10 +37,12 @@ func TestVoiceNoSessionFallthroughLeavesOutboxTrail(t *testing.T) {
|
|||||||
[]string{"voice", "ntfy"}, []string{store.DeliveryFailed, store.DeliverySent}},
|
[]string{"voice", "ntfy"}, []string{store.DeliveryFailed, store.DeliverySent}},
|
||||||
{"sev4 falls through to telegram", loop.Sev4,
|
{"sev4 falls through to telegram", loop.Sev4,
|
||||||
[]string{"voice", "telegram"}, []string{store.DeliveryFailed, store.DeliverySent}},
|
[]string{"voice", "telegram"}, []string{store.DeliveryFailed, store.DeliverySent}},
|
||||||
{"sev1 does not fall through", loop.Sev1,
|
// care severities still don't reach an away channel; since #370 the
|
||||||
[]string{"voice"}, []string{store.DeliveryFailed}},
|
// drop itself is a visible row instead of nothing.
|
||||||
{"sev2 does not fall through", loop.Sev2,
|
{"sev1 drops instead of falling through", loop.Sev1,
|
||||||
[]string{"voice"}, []string{store.DeliveryFailed}},
|
[]string{"voice", "drop"}, []string{store.DeliveryFailed, store.DeliveryDropped}},
|
||||||
|
{"sev2 drops instead of falling through", loop.Sev2,
|
||||||
|
[]string{"voice", "drop"}, []string{store.DeliveryFailed, store.DeliveryDropped}},
|
||||||
}
|
}
|
||||||
for _, c := range cases {
|
for _, c := range cases {
|
||||||
t.Run(c.name, func(t *testing.T) {
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
|||||||
@@ -2,10 +2,10 @@
|
|||||||
//
|
//
|
||||||
// ntfy is the away-channel for sev3 (ops soft) nudges, sev4 (ops hard)
|
// ntfy is the away-channel for sev3 (ops soft) nudges, sev4 (ops hard)
|
||||||
// nudges when present (alongside voice), and reminders when away. the
|
// nudges when present (alongside voice), and reminders when away. the
|
||||||
// message body is the Sendable's Summary — the minimal-body rule from the
|
// message body is delivery.AwayMessage — the minimal-body rule from the
|
||||||
// spec ("disk low on homesrv," not detail; no shoulder-surf exfil through
|
// spec ("disk low on homesrv," not detail; no shoulder-surf exfil through
|
||||||
// the relay). voice gets Body; away channels get Summary, enforced at the
|
// the relay). the dispatcher already strips detail off away sendables; the
|
||||||
// sink so a phraser bug can't exfil.
|
// sink uses the same helper so it can't leak the body on its own either.
|
||||||
//
|
//
|
||||||
// ntfy runs locally (docker, 127.0.0.1:8085, deny-all auth). maven publishes
|
// ntfy runs locally (docker, 127.0.0.1:8085, deny-all auth). maven publishes
|
||||||
// with a dedicated user (write-only to maven-* topics) — the credential is a
|
// with a dedicated user (write-only to maven-* topics) — the credential is a
|
||||||
@@ -69,18 +69,14 @@ func New(cfg Config) (*Sink, error) {
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send publishes one notification to ntfy. the body is the Sendable's Summary
|
// Send publishes one notification to ntfy. the body is the minimal away
|
||||||
// (minimal body); Title is "maven" (consistent sender identity on the lock
|
// message (never the full body); Title is "maven" (consistent sender identity
|
||||||
// screen — the content is in the body). Priority maps from severity/kind so
|
// on the lock screen — the content is in the body). Priority maps from severity/kind so
|
||||||
// the phone client can ring differently for an alarm vs a soft ops nudge.
|
// the phone client can ring differently for an alarm vs a soft ops nudge.
|
||||||
func (s *Sink) Send(ctx context.Context, d delivery.Sendable) error {
|
func (s *Sink) Send(ctx context.Context, d delivery.Sendable) error {
|
||||||
body := d.Summary
|
// never fall back to d.Body: ntfy leaves the box, so an empty summary gets
|
||||||
if body == "" {
|
// a generic line instead of the full detail.
|
||||||
body = d.Body // terse full message beats no message
|
body := delivery.AwayMessage(d)
|
||||||
}
|
|
||||||
if body == "" {
|
|
||||||
return fmt.Errorf("ntfysink: empty message for %s", d.Channel)
|
|
||||||
}
|
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.topicURL(), strings.NewReader(body))
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.topicURL(), strings.NewReader(body))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -147,9 +147,9 @@ func TestSendBodyIsSummaryNotFullBody(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSendFallsBackToBodyWhenSummaryEmpty(t *testing.T) {
|
func TestSendNeverSendsTheBodyWhenSummaryEmpty(t *testing.T) {
|
||||||
// a terse full message is better than no message; the phraser should
|
// #368: this used to fall back to the full body. ntfy leaves the box, so
|
||||||
// produce a summary for away-bound severities, but don't silently drop.
|
// an empty summary gets a fixed generic line plus the rule name instead.
|
||||||
rs := newRecordingServer(t, 200, "")
|
rs := newRecordingServer(t, 200, "")
|
||||||
srv := httptest.NewServer(rs.handler())
|
srv := httptest.NewServer(rs.handler())
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
@@ -160,12 +160,15 @@ func TestSendFallsBackToBodyWhenSummaryEmpty(t *testing.T) {
|
|||||||
t.Fatalf("Send: %v", err)
|
t.Fatalf("Send: %v", err)
|
||||||
}
|
}
|
||||||
_, _, body, _, _, _ := rs.snapshot()
|
_, _, body, _, _, _ := rs.snapshot()
|
||||||
if body != s.Body {
|
want := delivery.GenericAwayMessage + ": service_down"
|
||||||
t.Fatalf("fallback body: want %q, got %q", s.Body, body)
|
if body != want {
|
||||||
|
t.Fatalf("body: want %q, got %q", want, body)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSendRejectsEmptyMessage(t *testing.T) {
|
func TestSendNeverSendsAnEmptyMessage(t *testing.T) {
|
||||||
|
// with nothing at all to say we still send the generic line — an away
|
||||||
|
// channel can never carry detail, but it also never goes out blank.
|
||||||
rs := newRecordingServer(t, 200, "")
|
rs := newRecordingServer(t, 200, "")
|
||||||
srv := httptest.NewServer(rs.handler())
|
srv := httptest.NewServer(rs.handler())
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
@@ -173,9 +176,13 @@ func TestSendRejectsEmptyMessage(t *testing.T) {
|
|||||||
sink, _ := New(Config{BaseURL: srv.URL, Topic: "maven"})
|
sink, _ := New(Config{BaseURL: srv.URL, Topic: "maven"})
|
||||||
s := nudgeSendable(loop.Sev3, "")
|
s := nudgeSendable(loop.Sev3, "")
|
||||||
s.Body = ""
|
s.Body = ""
|
||||||
err := sink.Send(context.Background(), s)
|
s.RuleName = ""
|
||||||
if err == nil {
|
if err := sink.Send(context.Background(), s); err != nil {
|
||||||
t.Fatal("want error for empty message")
|
t.Fatalf("Send: %v", err)
|
||||||
|
}
|
||||||
|
_, _, body, _, _, _ := rs.snapshot()
|
||||||
|
if body != delivery.GenericAwayMessage {
|
||||||
|
t.Fatalf("body: want %q, got %q", delivery.GenericAwayMessage, body)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -208,10 +208,9 @@ func TestAwayChannelsGetMinimalBody(t *testing.T) {
|
|||||||
// TestCareAwayDropIsRecorded — DESIGN.md's drop is a decision ("a missed water
|
// TestCareAwayDropIsRecorded — DESIGN.md's drop is a decision ("a missed water
|
||||||
// nudge is noise, a missed backup failure isn't"), so it should be visible
|
// nudge is noise, a missed backup failure isn't"), so it should be visible
|
||||||
// rather than vanish. Today drop is a bare `continue`: no nudge row, no outbox
|
// rather than vanish. Today drop is a bare `continue`: no nudge row, no outbox
|
||||||
// attempt, no log — nothing an operator can see afterwards.
|
// attempt, no log — nothing an operator can see afterwards. now it leaves a
|
||||||
|
// 'dropped' outbox row.
|
||||||
func TestCareAwayDropIsRecorded(t *testing.T) {
|
func TestCareAwayDropIsRecorded(t *testing.T) {
|
||||||
t.Skip("not implemented: dispatcher.go:149-151 skips a Drop channel with no record; there is no 'dropped' outcome in store/delivery.go:16-21")
|
|
||||||
|
|
||||||
ob := &fakeOutbox{}
|
ob := &fakeOutbox{}
|
||||||
d := NewDispatcher(Config{Voice: &fakeSink{}, Nudges: &fakeNudgeRecorder{}, Outbox: ob})
|
d := NewDispatcher(Config{Voice: &fakeSink{}, Nudges: &fakeNudgeRecorder{}, Outbox: ob})
|
||||||
|
|
||||||
|
|||||||
@@ -2,11 +2,12 @@
|
|||||||
//
|
//
|
||||||
// telegram is the away-channel for sev4 (ops hard) nudges — "disk-fire alarm
|
// telegram is the away-channel for sev4 (ops hard) nudges — "disk-fire alarm
|
||||||
// at 2am routes to telegram, repeat til ack." the message body is the
|
// at 2am routes to telegram, repeat til ack." the message body is the
|
||||||
// Sendable's Summary — the minimal-body rule from the spec ("disk low on
|
// delivery.AwayMessage — the minimal-body rule from the spec ("disk low on
|
||||||
// homesrv," not detail; no shoulder-surf exfil through the relay). voice gets
|
// homesrv," not detail; no shoulder-surf exfil through the relay). the
|
||||||
// Body; away channels get Summary, enforced at the sink so a phraser bug can't
|
// dispatcher already strips detail off away sendables; the sink uses the same
|
||||||
// exfil. additionally, protect_content=true is passed on every send so the
|
// helper so it can't leak the body on its own either. additionally,
|
||||||
// message can't be forwarded out of the chat — locks the minimal body further.
|
// protect_content=true is passed on every send so the message can't be
|
||||||
|
// forwarded out of the chat — locks the minimal body further.
|
||||||
//
|
//
|
||||||
// telegram's bot API is region-restricted for this homesrv — direct egress to
|
// telegram's bot API is region-restricted for this homesrv — direct egress to
|
||||||
// api.telegram.org is unreliable. the spec's "away channels leave the box —
|
// api.telegram.org is unreliable. the spec's "away channels leave the box —
|
||||||
@@ -140,18 +141,13 @@ type telegramResp struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Send publishes one message to the configured telegram chat. the body is the
|
// Send publishes one message to the configured telegram chat. the body is the
|
||||||
// Sendable's Summary (minimal body); empty Summary falls back to Body (terse
|
// minimal away message (never the full body). protect_content=true so even
|
||||||
// full message beats no message). protect_content=true so a phraser bug (Body
|
// that can't be forwarded onward by the user or a chat observer — locks the
|
||||||
// leaking detail through Summary) can't be forwarded onward by the user or a
|
// minimal-body rule at the channel's own last mile.
|
||||||
// chat observer — locks the minimal-body rule at the channel's own last mile.
|
|
||||||
func (s *Sink) Send(ctx context.Context, d delivery.Sendable) error {
|
func (s *Sink) Send(ctx context.Context, d delivery.Sendable) error {
|
||||||
body := d.Summary
|
// never fall back to d.Body: telegram leaves the box, so an empty summary
|
||||||
if body == "" {
|
// gets a generic line instead of the full detail.
|
||||||
body = d.Body
|
body := delivery.AwayMessage(d)
|
||||||
}
|
|
||||||
if body == "" {
|
|
||||||
return fmt.Errorf("telegramsink: empty message for %s", d.Channel)
|
|
||||||
}
|
|
||||||
|
|
||||||
payload := sendMessageReq{
|
payload := sendMessageReq{
|
||||||
ChatID: s.cfg.ChatID,
|
ChatID: s.cfg.ChatID,
|
||||||
|
|||||||
@@ -173,9 +173,9 @@ func TestSendBodyIsSummaryNotFullBody(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSendFallsBackToBodyWhenSummaryEmpty(t *testing.T) {
|
func TestSendNeverSendsTheBodyWhenSummaryEmpty(t *testing.T) {
|
||||||
// terse full message beats none; the phraser should produce a summary for
|
// #368: this used to fall back to the full body. telegram leaves the box,
|
||||||
// away-bound severities, but don't silently drop.
|
// so an empty summary gets a fixed generic line plus the rule name.
|
||||||
rs := newRecordingServer(t, 200, "")
|
rs := newRecordingServer(t, 200, "")
|
||||||
srv := httptest.NewServer(rs.handler())
|
srv := httptest.NewServer(rs.handler())
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
@@ -188,12 +188,14 @@ func TestSendFallsBackToBodyWhenSummaryEmpty(t *testing.T) {
|
|||||||
_, _, body, _, _ := rs.snapshot()
|
_, _, body, _, _ := rs.snapshot()
|
||||||
var req sendMessageReq
|
var req sendMessageReq
|
||||||
_ = json.Unmarshal([]byte(body), &req)
|
_ = json.Unmarshal([]byte(body), &req)
|
||||||
if req.Text != s.Body {
|
want := delivery.GenericAwayMessage + ": service_down"
|
||||||
t.Fatalf("fallback text: want %q, got %q", s.Body, req.Text)
|
if req.Text != want {
|
||||||
|
t.Fatalf("text: want %q, got %q", want, req.Text)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSendRejectsEmptyMessage(t *testing.T) {
|
func TestSendNeverSendsAnEmptyMessage(t *testing.T) {
|
||||||
|
// with nothing at all to say we still send the generic line.
|
||||||
rs := newRecordingServer(t, 200, "")
|
rs := newRecordingServer(t, 200, "")
|
||||||
srv := httptest.NewServer(rs.handler())
|
srv := httptest.NewServer(rs.handler())
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
@@ -201,9 +203,15 @@ func TestSendRejectsEmptyMessage(t *testing.T) {
|
|||||||
sink, _ := New(sinkCfg(srv.URL))
|
sink, _ := New(sinkCfg(srv.URL))
|
||||||
s := nudgeSendable(loop.Sev4, "")
|
s := nudgeSendable(loop.Sev4, "")
|
||||||
s.Body = ""
|
s.Body = ""
|
||||||
err := sink.Send(context.Background(), s)
|
s.RuleName = ""
|
||||||
if err == nil {
|
if err := sink.Send(context.Background(), s); err != nil {
|
||||||
t.Fatal("want error for empty message")
|
t.Fatalf("Send: %v", err)
|
||||||
|
}
|
||||||
|
_, _, body, _, _ := rs.snapshot()
|
||||||
|
var req sendMessageReq
|
||||||
|
_ = json.Unmarshal([]byte(body), &req)
|
||||||
|
if req.Text != delivery.GenericAwayMessage {
|
||||||
|
t.Fatalf("text: want %q, got %q", delivery.GenericAwayMessage, req.Text)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package eval
|
package llm
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@@ -6,8 +6,20 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// UnknownModel is the label to print when the server would not say what it has
|
||||||
|
// loaded. Deliberately ugly: an honest "unknown" is fine, a plausible-looking
|
||||||
|
// but wrong model name is the bug this whole file exists to prevent.
|
||||||
|
const UnknownModel = "unknown-model"
|
||||||
|
|
||||||
|
// llama-server is local, so never send this through a proxy: this box's
|
||||||
|
// http_proxy answers 503 for loopback, which would look like "server won't say
|
||||||
|
// which model it has" when the server is right there and fine.
|
||||||
|
// A Transport with no Proxy set bypasses http_proxy entirely.
|
||||||
|
var modelHTTP = &http.Client{Timeout: 10 * time.Second, Transport: &http.Transport{}}
|
||||||
|
|
||||||
// ModelID asks llama-server which model it has loaded, so a scoring run can
|
// ModelID asks llama-server which model it has loaded, so a scoring run can
|
||||||
// label itself. Without this a bake-off between two models produces two tables
|
// label itself. Without this a bake-off between two models produces two tables
|
||||||
// that look identical, and the operator has to remember which server was up.
|
// that look identical, and the operator has to remember which server was up.
|
||||||
@@ -19,7 +31,7 @@ func ModelID(ctx context.Context, base string) (string, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
resp, err := http.DefaultClient.Do(req)
|
resp, err := modelHTTP.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -38,14 +50,21 @@ func ModelID(ctx context.Context, base string) (string, error) {
|
|||||||
if len(out.Data) == 0 {
|
if len(out.Data) == 0 {
|
||||||
return "", fmt.Errorf("models: empty list")
|
return "", fmt.Errorf("models: empty list")
|
||||||
}
|
}
|
||||||
return shortModelID(out.Data[0].ID), nil
|
short := shortModelID(out.Data[0].ID)
|
||||||
|
if short == "" {
|
||||||
|
// Server answered but the id field was missing or blank. Say so
|
||||||
|
// instead of handing back an empty label that reads as a real name.
|
||||||
|
return "", fmt.Errorf("models: no id in response")
|
||||||
|
}
|
||||||
|
return short, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// shortModelID trims the path and the .gguf suffix — llama-server reports the
|
// shortModelID trims the path and the .gguf suffix — llama-server reports the
|
||||||
// file name it was started with, which is too long for a table header.
|
// file name it was started with, which is too long for a table header.
|
||||||
func shortModelID(id string) string {
|
func shortModelID(id string) string {
|
||||||
|
id = strings.TrimSpace(id)
|
||||||
if i := strings.LastIndexAny(id, "/\\"); i >= 0 {
|
if i := strings.LastIndexAny(id, "/\\"); i >= 0 {
|
||||||
id = id[i+1:]
|
id = id[i+1:]
|
||||||
}
|
}
|
||||||
return strings.TrimSuffix(id, ".gguf")
|
return strings.TrimSpace(strings.TrimSuffix(id, ".gguf"))
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package llm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The point of these tests: a wrong-but-plausible model label is the bug, so
|
||||||
|
// every path that cannot learn the real name must return an error instead of a
|
||||||
|
// guess. No llama-server needed — a stub server stands in.
|
||||||
|
func TestModelID(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
body string
|
||||||
|
code int
|
||||||
|
want string // "" ⇒ expect an error
|
||||||
|
}{
|
||||||
|
{"full path", `{"data":[{"id":"/mnt/hdd1/llms/qwen3.5/Qwen3.5-0.8B.Q4_K_M.gguf"}]}`, 200, "Qwen3.5-0.8B.Q4_K_M"},
|
||||||
|
{"bare name", `{"data":[{"id":"LFM2.5-1.2B"}]}`, 200, "LFM2.5-1.2B"},
|
||||||
|
{"empty list", `{"data":[]}`, 200, ""},
|
||||||
|
{"id missing", `{"data":[{}]}`, 200, ""},
|
||||||
|
{"id blank", `{"data":[{"id":" "}]}`, 200, ""},
|
||||||
|
{"server error", `nope`, 500, ""},
|
||||||
|
{"not json", `<html>`, 200, ""},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/v1/models" {
|
||||||
|
t.Errorf("asked for %s, want /v1/models", r.URL.Path)
|
||||||
|
}
|
||||||
|
w.WriteHeader(c.code)
|
||||||
|
_, _ = w.Write([]byte(c.body))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
got, err := ModelID(context.Background(), srv.URL+"/")
|
||||||
|
if c.want == "" {
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("want an error, got label %q", got)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ModelID: %v", err)
|
||||||
|
}
|
||||||
|
if got != c.want {
|
||||||
|
t.Errorf("got %q, want %q", got, c.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModelIDUnreachable(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
|
||||||
|
url := srv.URL
|
||||||
|
srv.Close() // nothing listening now
|
||||||
|
|
||||||
|
if got, err := ModelID(context.Background(), url); err == nil {
|
||||||
|
t.Fatalf("want an error from a dead server, got label %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package eval
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestAddressTimeWordDoesNotBlind(t *testing.T) {
|
||||||
|
// A nudge that opens with a time word must still be caught. Without the
|
||||||
|
// time words in the stoplist, "сегодня" was read as the third party.
|
||||||
|
for _, s := range []string{
|
||||||
|
"сегодня он не ел 11 дней",
|
||||||
|
"вчера он не пил воду",
|
||||||
|
"опять он забыл про таблетки",
|
||||||
|
} {
|
||||||
|
if r := checkAddress(s); r.Pass {
|
||||||
|
t.Errorf("checkAddress(%q) passed, want a third-person failure", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Still must not fire when a third party really is named.
|
||||||
|
for _, s := range []string{
|
||||||
|
"сегодня сервис упал, он не отвечает",
|
||||||
|
"ты не пил воду четыре часа",
|
||||||
|
} {
|
||||||
|
if r := checkAddress(s); !r.Pass {
|
||||||
|
t.Errorf("checkAddress(%q) failed: %s", s, r.Detail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,10 +21,14 @@ const (
|
|||||||
// CheckHisGender — the other half of the persona rule: SHE is feminine, HE
|
// CheckHisGender — the other half of the persona rule: SHE is feminine, HE
|
||||||
// is male. "ты давно не отдыхала" addresses the operator as a woman.
|
// is male. "ты давно не отдыхала" addresses the operator as a woman.
|
||||||
CheckHisGender = "hisgender"
|
CheckHisGender = "hisgender"
|
||||||
|
|
||||||
|
// CheckAddress — she talks TO him, informally, one to one. Not "вы", not
|
||||||
|
// "он". See the comment block above checkAddress.
|
||||||
|
CheckAddress = "address"
|
||||||
)
|
)
|
||||||
|
|
||||||
// CheckNames — report order.
|
// CheckNames — report order.
|
||||||
var CheckNames = []string{CheckMood, CheckLang, CheckLength, CheckFeminine, CheckHisGender, CheckCringe, CheckOnTopic}
|
var CheckNames = []string{CheckMood, CheckLang, CheckLength, CheckFeminine, CheckHisGender, CheckAddress, CheckCringe, CheckOnTopic}
|
||||||
|
|
||||||
// Result — one check on one message.
|
// Result — one check on one message.
|
||||||
type Result struct {
|
type Result struct {
|
||||||
@@ -57,6 +61,7 @@ func RunChecks(c Case, body, mood string) []Result {
|
|||||||
checkLength(body),
|
checkLength(body),
|
||||||
checkFeminine(body),
|
checkFeminine(body),
|
||||||
checkHisGender(body),
|
checkHisGender(body),
|
||||||
|
checkAddress(body),
|
||||||
checkCringe(body),
|
checkCringe(body),
|
||||||
checkOnTopic(c, body),
|
checkOnTopic(c, body),
|
||||||
}
|
}
|
||||||
@@ -302,6 +307,159 @@ func prevWord(words []string, i int) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- how she addresses him ------------------------------------------------
|
||||||
|
//
|
||||||
|
// Persona hard constraint: Maven speaks TO him, informally, one to one. The
|
||||||
|
// phrasing eval produced two breaks of it, and both scored clean:
|
||||||
|
//
|
||||||
|
// - "Приходите… Жду вас" — the formal plural. Correct is ты/тебя/тебе and a
|
||||||
|
// singular imperative ("приходи", "жду тебя").
|
||||||
|
// - "Он не ел 11 дней" — she talks ABOUT him, in the third person, as if
|
||||||
|
// reporting to somebody else. Correct is "ты не ел 11 дней".
|
||||||
|
//
|
||||||
|
// Like checkHisGender this is a keyword + suffix heuristic, NOT a parser. Every
|
||||||
|
// hit prints the word it tripped on, so a false alarm is obvious at a glance and
|
||||||
|
// can be dismissed.
|
||||||
|
//
|
||||||
|
// Part 1, formal address. Two signals:
|
||||||
|
// - the "вы" pronoun family, matched as whole words, so there is nothing to
|
||||||
|
// exclude — "вы" and "вас" are never anything else.
|
||||||
|
// - a plural verb ending: -ите/-ете/-йте/-ьте ("приходите", "выпейте",
|
||||||
|
// "не забудьте", "хотите"). Nouns in the prepositional case share those
|
||||||
|
// endings ("в интернете", "в свете"), so a word right after a preposition is
|
||||||
|
// skipped. That is the whole exclusion list, on purpose: a bigger one would
|
||||||
|
// start swallowing real imperatives.
|
||||||
|
//
|
||||||
|
// Part 2, third person. "он" is perfectly fine when the message really is about
|
||||||
|
// somebody or something else ("сервис упал, он не отвечает"). The way to tell
|
||||||
|
// them apart: a legitimate third person has an ANTECEDENT — the thing it refers
|
||||||
|
// to was named earlier in the message. So "он" is only flagged when nothing
|
||||||
|
// before it in the message could be that thing.
|
||||||
|
//
|
||||||
|
// Where this gives up, plainly:
|
||||||
|
// - it only looks BACKWARD. "Он не отвечает, сервис упал" names the subject
|
||||||
|
// after the pronoun and is flagged wrongly.
|
||||||
|
// - any noun earlier in the message counts as an antecedent, even when it is
|
||||||
|
// not one ("после обеда он не ел" reads as legitimate and is missed). The
|
||||||
|
// common time words are stoplisted so the usual nudge opening does not
|
||||||
|
// blind it, but a message with any other noun in front still slips through.
|
||||||
|
// This is the check's real hole; widening it further would start flagging
|
||||||
|
// legitimate third-party messages, so it stops here.
|
||||||
|
// - a message that opens with "ты" and only later slips into "он" is missed,
|
||||||
|
// because "ты" itself is skipped but the words around it are not.
|
||||||
|
// - formal address outside these endings (short adjectives, "вашими" style
|
||||||
|
// forms not listed) is missed.
|
||||||
|
|
||||||
|
// addressWordRE also takes Latin words, because "him"/"he" is the same break in
|
||||||
|
// English.
|
||||||
|
var addressWordRE = regexp.MustCompile(`[\p{Cyrillic}]+|[a-zA-Z]+|[,.;:!?…—-]`)
|
||||||
|
|
||||||
|
// formalPronouns — the "вы" family. Whole-word match, so no false hits.
|
||||||
|
var formalPronouns = map[string]bool{
|
||||||
|
"вы": true, "вас": true, "вам": true, "вами": true,
|
||||||
|
"ваш": true, "ваша": true, "ваше": true, "ваши": true,
|
||||||
|
"вашего": true, "вашей": true, "вашему": true, "вашим": true,
|
||||||
|
"вашими": true, "вашу": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// prepositions — used twice: to skip prepositional-case nouns that look like
|
||||||
|
// plural verbs, and as words that cannot be what "он" refers to.
|
||||||
|
var prepositions = map[string]bool{
|
||||||
|
"в": true, "во": true, "на": true, "о": true, "об": true, "обо": true,
|
||||||
|
"при": true, "по": true, "за": true, "из": true, "с": true, "со": true,
|
||||||
|
"к": true, "ко": true, "до": true, "от": true, "у": true, "над": true,
|
||||||
|
"под": true, "про": true, "без": true, "для": true, "через": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// pluralVerb reports whether a word looks like a plural/formal verb form:
|
||||||
|
// "приходите", "выпейте", "забудьте", "хотите".
|
||||||
|
func pluralVerb(w string) bool {
|
||||||
|
if len([]rune(w)) < 5 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return strings.HasSuffix(w, "ите") || strings.HasSuffix(w, "ете") ||
|
||||||
|
strings.HasSuffix(w, "йте") || strings.HasSuffix(w, "ьте")
|
||||||
|
}
|
||||||
|
|
||||||
|
// thirdPersonHim — pronouns that would be talking about him instead of to him.
|
||||||
|
var thirdPersonHim = map[string]bool{
|
||||||
|
"он": true, "его": true, "ему": true, "него": true, "нему": true, "ним": true,
|
||||||
|
"he": true, "him": true, "his": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// notAnAntecedent — words that cannot be the thing "он" refers to: pronouns,
|
||||||
|
// particles, conjunctions, adverbs of time. If only these come before "он", the
|
||||||
|
// message never named a third party and "он" is him.
|
||||||
|
var notAnAntecedent = map[string]bool{
|
||||||
|
"не": true, "ни": true, "и": true, "а": true, "но": true, "да": true,
|
||||||
|
"же": true, "бы": true, "ли": true, "вот": true, "уже": true,
|
||||||
|
"ещё": true, "еще": true, "тоже": true, "там": true, "тут": true,
|
||||||
|
"здесь": true, "это": true, "что": true, "как": true, "когда": true,
|
||||||
|
"чтобы": true, "потому": true, "сейчас": true, "потом": true,
|
||||||
|
// Time words. A nudge almost always opens with one ("сегодня он не ел"),
|
||||||
|
// and without them the very next word is read as the person being talked
|
||||||
|
// about, so the check misses the exact break it was written for.
|
||||||
|
"сегодня": true, "вчера": true, "завтра": true, "послезавтра": true,
|
||||||
|
"утром": true, "днём": true, "днем": true, "вечером": true, "ночью": true,
|
||||||
|
"опять": true, "снова": true, "весь": true, "всю": true, "целый": true,
|
||||||
|
"я": true, "мне": true, "меня": true, "мной": true, "мы": true, "нас": true,
|
||||||
|
"ты": true, "тебя": true, "тебе": true, "тобой": true,
|
||||||
|
"твой": true, "твоя": true, "твоё": true, "твое": true, "твои": true, "твою": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// looksPastVerb — a past-tense verb needs a subject of its own, so it is not an
|
||||||
|
// antecedent either. Keeps "сервис упал, он не отвечает" working off "сервис".
|
||||||
|
func looksPastVerb(w string) bool {
|
||||||
|
if len([]rune(w)) < 3 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return strings.HasSuffix(w, "л") || strings.HasSuffix(w, "ла") ||
|
||||||
|
strings.HasSuffix(w, "ло") || strings.HasSuffix(w, "ли")
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkAddress(body string) Result {
|
||||||
|
words := addressWordRE.FindAllString(strings.ToLower(body), -1)
|
||||||
|
|
||||||
|
for i, w := range words {
|
||||||
|
if formalPronouns[w] {
|
||||||
|
return Result{CheckAddress, false,
|
||||||
|
fmt.Sprintf("formal %q — she says ты/тебя/тебе", w)}
|
||||||
|
}
|
||||||
|
if pluralVerb(w) && !(i > 0 && prepositions[words[i-1]]) {
|
||||||
|
return Result{CheckAddress, false,
|
||||||
|
fmt.Sprintf("plural imperative %q — she uses the singular", w)}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, w := range words {
|
||||||
|
if !thirdPersonHim[w] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
named := false
|
||||||
|
for j := 0; j < i; j++ {
|
||||||
|
p := words[j]
|
||||||
|
if !unicode.Is(unicode.Cyrillic, []rune(p)[0]) && !isLatinWord(p) {
|
||||||
|
continue // punctuation
|
||||||
|
}
|
||||||
|
if notAnAntecedent[p] || prepositions[p] || thirdPersonHim[p] || looksPastVerb(p) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
named = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if !named {
|
||||||
|
return Result{CheckAddress, false,
|
||||||
|
fmt.Sprintf("third person %q with nobody else named — she talks to him, not about him", w)}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Result{CheckAddress, true, ""}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isLatinWord(w string) bool {
|
||||||
|
r := []rune(w)[0]
|
||||||
|
return (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z')
|
||||||
|
}
|
||||||
|
|
||||||
// --- the cringe checks ---------------------------------------------------
|
// --- the cringe checks ---------------------------------------------------
|
||||||
//
|
//
|
||||||
// "Think Jarvis without the cringe part". DESIGN.md § Non-goals: "Not a
|
// "Think Jarvis without the cringe part". DESIGN.md § Non-goals: "Not a
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ func TestStubBaseline(t *testing.T) {
|
|||||||
CheckLength: 12,
|
CheckLength: 12,
|
||||||
CheckFeminine: 15,
|
CheckFeminine: 15,
|
||||||
CheckHisGender: 15,
|
CheckHisGender: 15,
|
||||||
|
CheckAddress: 15,
|
||||||
CheckCringe: 15,
|
CheckCringe: 15,
|
||||||
CheckOnTopic: 12,
|
CheckOnTopic: 12,
|
||||||
}
|
}
|
||||||
@@ -123,6 +124,10 @@ func TestChecksCatchWhatTheyClaim(t *testing.T) {
|
|||||||
{"asks how he feels", "как ты себя чувствуешь? попей воды.", CheckCringe},
|
{"asks how he feels", "как ты себя чувствуешь? попей воды.", CheckCringe},
|
||||||
{"praise", "молодец! теперь попей воды.", CheckCringe},
|
{"praise", "молодец! теперь попей воды.", CheckCringe},
|
||||||
{"off topic", "пора бы уже что-то сделать.", CheckOnTopic},
|
{"off topic", "пора бы уже что-то сделать.", CheckOnTopic},
|
||||||
|
// The two recorded persona breaks from the phrasing eval run. Pinned as
|
||||||
|
// unit tests because an eval run is sampled and may not reproduce them.
|
||||||
|
{"formal plural", "Приходите… Жду вас", CheckAddress},
|
||||||
|
{"third person about him", "Он не ел 11 дней", CheckAddress},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
@@ -144,6 +149,36 @@ func TestChecksCatchWhatTheyClaim(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestAddressCheck — the address check on its own, so the messages that must NOT
|
||||||
|
// trip it can be written without also having to satisfy the on-topic check.
|
||||||
|
func TestAddressCheck(t *testing.T) {
|
||||||
|
bad := []string{
|
||||||
|
"Приходите… Жду вас", // the recorded formal-plural break
|
||||||
|
"Он не ел 11 дней", // the recorded third-person break
|
||||||
|
"Выпейте воды, пожалуйста.", // plural imperative on its own
|
||||||
|
"Ваш обед был давно.", // formal possessive
|
||||||
|
}
|
||||||
|
for _, body := range bad {
|
||||||
|
if r := checkAddress(body); r.Pass {
|
||||||
|
t.Errorf("persona break not caught: %q", body)
|
||||||
|
} else {
|
||||||
|
t.Logf("%q -> %s", body, r.Detail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
good := []string{
|
||||||
|
"ты не пил воду четыре часа — попей.", // correct informal address
|
||||||
|
"сервис netdata упал, он не отвечает.", // legitimately about a third party
|
||||||
|
"я заметила, что зарядка была утром.", // no address at all
|
||||||
|
"в интернете опять тихо, всё работает.", // "интернете" is a noun, not an imperative
|
||||||
|
}
|
||||||
|
for _, body := range good {
|
||||||
|
if r := checkAddress(body); !r.Pass {
|
||||||
|
t.Errorf("clean message flagged: %q -> %s", body, r.Detail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestMoodCheckUsesTheEnum(t *testing.T) {
|
func TestMoodCheckUsesTheEnum(t *testing.T) {
|
||||||
if r := checkMood("cheerful"); r.Pass {
|
if r := checkMood("cheerful"); r.Pass {
|
||||||
t.Error("mood outside the enum passed")
|
t.Error("mood outside the enum passed")
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/llm"
|
||||||
"github.com/kami/maven/internal/phraser"
|
"github.com/kami/maven/internal/phraser"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -32,6 +33,7 @@ func TestLLMPhrasingBaseline(t *testing.T) {
|
|||||||
// case as a phrasing error and read as "the model cannot phrase".
|
// case as a phrasing error and read as "the model cannot phrase".
|
||||||
noProxyLoopback(t)
|
noProxyLoopback(t)
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
f, err := Load()
|
f, err := Load()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Load: %v", err)
|
t.Fatalf("Load: %v", err)
|
||||||
@@ -44,7 +46,19 @@ func TestLLMPhrasingBaseline(t *testing.T) {
|
|||||||
p := phraser.NewLLMPhraserAt(base, cfg)
|
p := phraser.NewLLMPhraserAt(base, cfg)
|
||||||
defer p.Close()
|
defer p.Close()
|
||||||
|
|
||||||
rep, err := Score(context.Background(), "llm (0.8B, built-in persona)", p, f)
|
// Label the run with whatever gguf the server actually has loaded. It used
|
||||||
|
// to say "0.8B" no matter what, so two runs of two different models came
|
||||||
|
// out named the same and were easy to mix up when comparing.
|
||||||
|
model, err := llm.ModelID(ctx, base)
|
||||||
|
if err != nil {
|
||||||
|
// An unlabelled score is still a score, but say so loudly — a made-up
|
||||||
|
// name in a bake-off table is worse than no name.
|
||||||
|
t.Logf("could not read model id from %s: %v — report will say %q", base, err, llm.UnknownModel)
|
||||||
|
model = llm.UnknownModel
|
||||||
|
}
|
||||||
|
t.Logf("scoring model %s at %s", model, base)
|
||||||
|
|
||||||
|
rep, err := Score(ctx, "llm ("+model+", built-in persona)", p, f)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Score: %v", err)
|
t.Fatalf("Score: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,12 +61,12 @@ func TestLLMRouterBaseline(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
model, err := ModelID(ctx, base)
|
model, err := llm.ModelID(ctx, base)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Not fatal: an unlabelled score is still a score. But say so loudly,
|
// Not fatal: an unlabelled score is still a score. But say so loudly,
|
||||||
// because an unlabelled row in a bake-off table is worthless.
|
// because an unlabelled row in a bake-off table is worthless.
|
||||||
t.Logf("could not read model id from %s: %v — reports will say %q", base, err, "unknown-model")
|
t.Logf("could not read model id from %s: %v — reports will say %q", base, err, llm.UnknownModel)
|
||||||
model = "unknown-model"
|
model = llm.UnknownModel
|
||||||
}
|
}
|
||||||
t.Logf("scoring model %s at %s", model, base)
|
t.Logf("scoring model %s at %s", model, base)
|
||||||
lr := router.NewLLMRouter(client)
|
lr := router.NewLLMRouter(client)
|
||||||
|
|||||||
@@ -449,16 +449,30 @@ func (AnaphoraResolver) Resolve(text string) (ref string, ok bool) {
|
|||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
|
|
||||||
// ParseCalendarDate detects RU calendar date words in text and returns the
|
// ParseCalendarDate detects RU/EN calendar day words in text and returns
|
||||||
// resolved time (midnight UTC+0 for "сегодня"/"today", next day for "завтра"/"tomorrow").
|
// midnight of that day in now's own time zone. Handles "сегодня", "завтра",
|
||||||
// Returns zero time + false if no match.
|
// "послезавтра", "вчера" (and the English words). Returns zero time + false
|
||||||
|
// if no match.
|
||||||
|
//
|
||||||
|
// "послезавтра" is checked before "завтра" because it contains it.
|
||||||
func ParseCalendarDate(text string, now time.Time) (time.Time, bool) {
|
func ParseCalendarDate(text string, now time.Time) (time.Time, bool) {
|
||||||
lower := strings.ToLower(text)
|
lower := strings.ToLower(text)
|
||||||
if strings.Contains(lower, "сегодня") || strings.Contains(lower, "today") {
|
switch {
|
||||||
return now.Truncate(24 * time.Hour), true
|
case strings.Contains(lower, "сегодня") || strings.Contains(lower, "today"):
|
||||||
}
|
return midnight(now, 0), true
|
||||||
if strings.Contains(lower, "завтра") || strings.Contains(lower, "tomorrow") {
|
case strings.Contains(lower, "послезавтра") || strings.Contains(lower, "day after tomorrow"):
|
||||||
return now.Truncate(24 * time.Hour).Add(24 * time.Hour), true
|
return midnight(now, 2), true
|
||||||
|
case strings.Contains(lower, "завтра") || strings.Contains(lower, "tomorrow"):
|
||||||
|
return midnight(now, 1), true
|
||||||
|
case strings.Contains(lower, "вчера") || strings.Contains(lower, "yesterday"):
|
||||||
|
return midnight(now, -1), true
|
||||||
}
|
}
|
||||||
return time.Time{}, false
|
return time.Time{}, false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// midnight returns the start of the day that is `days` away from now, in
|
||||||
|
// now's time zone (now.Truncate(24h) would cut on a UTC boundary instead).
|
||||||
|
func midnight(now time.Time, days int) time.Time {
|
||||||
|
y, m, d := now.AddDate(0, 0, days).Date()
|
||||||
|
return time.Date(y, m, d, 0, 0, 0, 0, now.Location())
|
||||||
|
}
|
||||||
|
|||||||
@@ -45,6 +45,9 @@ func TestParseCalendarDate(t *testing.T) {
|
|||||||
{"расписание на завтра", time.Date(2026, 7, 7, 0, 0, 0, 0, time.UTC), true},
|
{"расписание на завтра", time.Date(2026, 7, 7, 0, 0, 0, 0, time.UTC), true},
|
||||||
{"what's today", time.Date(2026, 7, 6, 0, 0, 0, 0, time.UTC), true},
|
{"what's today", time.Date(2026, 7, 6, 0, 0, 0, 0, time.UTC), true},
|
||||||
{"tomorrow plans", time.Date(2026, 7, 7, 0, 0, 0, 0, time.UTC), true},
|
{"tomorrow plans", time.Date(2026, 7, 7, 0, 0, 0, 0, time.UTC), true},
|
||||||
|
{"какое число послезавтра", time.Date(2026, 7, 8, 0, 0, 0, 0, time.UTC), true},
|
||||||
|
{"что было вчера", time.Date(2026, 7, 5, 0, 0, 0, 0, time.UTC), true},
|
||||||
|
{"yesterday plans", time.Date(2026, 7, 5, 0, 0, 0, 0, time.UTC), true},
|
||||||
{"какая погода", time.Time{}, false},
|
{"какая погода", time.Time{}, false},
|
||||||
{"сколько времени", time.Time{}, false},
|
{"сколько времени", time.Time{}, false},
|
||||||
{"", time.Time{}, false},
|
{"", time.Time{}, false},
|
||||||
|
|||||||
@@ -13,11 +13,15 @@ import (
|
|||||||
// unknown = a pending row found stale at startup: the process that started it
|
// unknown = a pending row found stale at startup: the process that started it
|
||||||
// is gone, and the send may or may not have reached the external channel.
|
// is gone, and the send may or may not have reached the external channel.
|
||||||
// Never auto-resolved into sent or failed — that would be guessing.
|
// Never auto-resolved into sent or failed — that would be guessing.
|
||||||
|
// dropped = the routing table deliberately suppressed this one (a care nudge
|
||||||
|
// while you're away). Nothing was sent and nothing went wrong; the row exists
|
||||||
|
// so "she dropped it" and "the rule never fired" don't look the same later.
|
||||||
const (
|
const (
|
||||||
DeliveryPending = "pending"
|
DeliveryPending = "pending"
|
||||||
DeliverySent = "sent"
|
DeliverySent = "sent"
|
||||||
DeliveryFailed = "failed"
|
DeliveryFailed = "failed"
|
||||||
DeliveryUnknown = "unknown"
|
DeliveryUnknown = "unknown"
|
||||||
|
DeliveryDropped = "dropped"
|
||||||
)
|
)
|
||||||
|
|
||||||
// BeginDeliveryAttempt durably records intent to send BEFORE the external
|
// BeginDeliveryAttempt durably records intent to send BEFORE the external
|
||||||
@@ -43,10 +47,11 @@ func (s *Store) BeginDeliveryAttempt(ctx context.Context, kind, rule string, rem
|
|||||||
}
|
}
|
||||||
|
|
||||||
// CompleteDeliveryAttempt records the sink's outcome for a prior
|
// CompleteDeliveryAttempt records the sink's outcome for a prior
|
||||||
// BeginDeliveryAttempt. status is "sent" or "failed" — never "pending" or
|
// BeginDeliveryAttempt. status is "sent", "failed" or "dropped" — never
|
||||||
// "unknown" (those are set only by Begin and reconciliation respectively).
|
// "pending" or "unknown" (those are set only by Begin and reconciliation
|
||||||
|
// respectively).
|
||||||
func (s *Store) CompleteDeliveryAttempt(ctx context.Context, id int64, status string, now time.Time) error {
|
func (s *Store) CompleteDeliveryAttempt(ctx context.Context, id int64, status string, now time.Time) error {
|
||||||
if status != DeliverySent && status != DeliveryFailed {
|
if status != DeliverySent && status != DeliveryFailed && status != DeliveryDropped {
|
||||||
return fmt.Errorf("store: invalid delivery completion status %q", status)
|
return fmt.Errorf("store: invalid delivery completion status %q", status)
|
||||||
}
|
}
|
||||||
_, err := s.db.ExecContext(ctx,
|
_, err := s.db.ExecContext(ctx,
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestDroppedDeliveryAttemptRoundTrips — Vikunja #370. A suppressed nudge is
|
||||||
|
// recorded as 'dropped'. The status column has a CHECK constraint, so this
|
||||||
|
// only works if migration #12 widened it; a fake outbox in a unit test would
|
||||||
|
// not catch that.
|
||||||
|
func TestDroppedDeliveryAttemptRoundTrips(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
id, err := s.BeginDeliveryAttempt(ctx, "nudge", "water", 0, "drop", "abc123", now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BeginDeliveryAttempt: %v", err)
|
||||||
|
}
|
||||||
|
if err := s.CompleteDeliveryAttempt(ctx, id, DeliveryDropped, now); err != nil {
|
||||||
|
t.Fatalf("CompleteDeliveryAttempt: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var status string
|
||||||
|
err = s.db.QueryRowContext(ctx, `SELECT status FROM delivery_attempts WHERE id = ?`, id).Scan(&status)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read back: %v", err)
|
||||||
|
}
|
||||||
|
if status != DeliveryDropped {
|
||||||
|
t.Fatalf("status: want %q, got %q", DeliveryDropped, status)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -88,6 +88,30 @@ ALTER TABLE reminders ADD COLUMN next_fire_ts INTEGER;`, // #2
|
|||||||
key TEXT PRIMARY KEY,
|
key TEXT PRIMARY KEY,
|
||||||
value TEXT NOT NULL
|
value TEXT NOT NULL
|
||||||
);`, // #11 — small key/value table for facts about the DB itself; first key is embedder_id (Vikunja #378)
|
);`, // #11 — small key/value table for facts about the DB itself; first key is embedder_id (Vikunja #378)
|
||||||
|
|
||||||
|
// #12 — a suppressed nudge gets a 'dropped' row (Vikunja #370). sqlite
|
||||||
|
// can't widen a CHECK constraint in place, so the table is rebuilt; the
|
||||||
|
// index goes with the old table and is recreated. The columns are listed
|
||||||
|
// out rather than `SELECT *` — copying by position would silently shuffle
|
||||||
|
// every row if the old table's column order ever differed from this one.
|
||||||
|
`CREATE TABLE delivery_attempts_v12 (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
kind TEXT NOT NULL CHECK (kind IN ('nudge','reminder')),
|
||||||
|
rule TEXT NOT NULL DEFAULT '',
|
||||||
|
reminder_id INTEGER NOT NULL DEFAULT 0,
|
||||||
|
channel TEXT NOT NULL,
|
||||||
|
body_hash TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','sent','failed','unknown','dropped')),
|
||||||
|
created_ts INTEGER NOT NULL,
|
||||||
|
completed_ts INTEGER
|
||||||
|
);
|
||||||
|
INSERT INTO delivery_attempts_v12
|
||||||
|
(id, kind, rule, reminder_id, channel, body_hash, status, created_ts, completed_ts)
|
||||||
|
SELECT id, kind, rule, reminder_id, channel, body_hash, status, created_ts, completed_ts
|
||||||
|
FROM delivery_attempts;
|
||||||
|
DROP TABLE delivery_attempts;
|
||||||
|
ALTER TABLE delivery_attempts_v12 RENAME TO delivery_attempts;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_delivery_attempts_status ON delivery_attempts (status);`,
|
||||||
}
|
}
|
||||||
|
|
||||||
// migrate applies every migration with a number greater than the DB's current
|
// migrate applies every migration with a number greater than the DB's current
|
||||||
|
|||||||
+79
-4
@@ -1,8 +1,10 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Unified script to stop all Maven services.
|
# Unified script to stop all Maven services.
|
||||||
# Usage: ./kill-maven.sh
|
# Usage: ./kill-maven.sh
|
||||||
# - Graceful SIGTERM is attempted first.
|
# - Docker deploy: `docker compose stop` (see why below).
|
||||||
# - If any process lingers, force with SIGKILL.
|
# - Bare-metal / dev run: graceful SIGTERM first, SIGKILL if anything lingers.
|
||||||
|
# Exits non-zero if it cannot confirm everything is stopped. It must never say
|
||||||
|
# "stopped" unless it checked.
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
@@ -24,6 +26,73 @@ else
|
|||||||
LLM='llama-server.*\.gguf'
|
LLM='llama-server.*\.gguf'
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
COMPOSE_FILE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/docker-compose.yml"
|
||||||
|
|
||||||
|
# --- containerised deploy ------------------------------------------------
|
||||||
|
# docker-compose.yml does not set `pid: host`, so each container has its own
|
||||||
|
# PID namespace: pkill on the host sees nothing inside them. This script used
|
||||||
|
# to print "all stopped" while every daemon was still happily running. Stop the
|
||||||
|
# containers through compose instead — that actually reaches them.
|
||||||
|
#
|
||||||
|
# running_containers prints the ids of the project's running containers, or
|
||||||
|
# nothing. Empty output plus a non-zero return means "could not ask docker",
|
||||||
|
# which is different from "nothing is running" and is handled below.
|
||||||
|
running_containers() {
|
||||||
|
docker compose -f "$COMPOSE_FILE" ps -q --status running 2>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
DOCKER_OK=0
|
||||||
|
CONTAINERS=""
|
||||||
|
if command -v docker >/dev/null 2>&1 && [ -f "$COMPOSE_FILE" ]; then
|
||||||
|
if CONTAINERS="$(running_containers)"; then
|
||||||
|
DOCKER_OK=1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$DOCKER_OK" = 1 ] && [ -n "$CONTAINERS" ]; then
|
||||||
|
echo "--- Maven is running in containers: stopping via docker compose ---"
|
||||||
|
if ! docker compose -f "$COMPOSE_FILE" stop; then
|
||||||
|
echo "ERROR: 'docker compose stop' failed. Containers may still be running." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "--- Verifying containers are gone ---"
|
||||||
|
LEFT="$(running_containers || true)"
|
||||||
|
if [ -n "$LEFT" ]; then
|
||||||
|
echo "ERROR: containers still running after stop:" >&2
|
||||||
|
docker compose -f "$COMPOSE_FILE" ps >&2 || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "All containers stopped."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- bare-metal / dev run -----------------------------------------------
|
||||||
|
# pgrep -f matches whole command lines, so a shell that merely mentions
|
||||||
|
# "mavend" (this script's own parent, for one) shows up. Drop ourselves and our
|
||||||
|
# parent, otherwise the SIGKILL sweep can take out the terminal you ran this in.
|
||||||
|
host_pids() {
|
||||||
|
pgrep -f "$PAT|$LLM" | grep -v -e "^$$\$" -e "^$PPID\$" | paste -sd, - || true
|
||||||
|
}
|
||||||
|
HOST_PIDS=$(host_pids)
|
||||||
|
|
||||||
|
if [ -z "$HOST_PIDS" ]; then
|
||||||
|
# Nothing on the host. Whether that means "already down" depends on whether
|
||||||
|
# we managed to ask docker, and the two must not read the same.
|
||||||
|
if [ "$DOCKER_OK" = 1 ]; then
|
||||||
|
# Docker answered and named no running containers, and there is nothing
|
||||||
|
# on the host either. That is a real answer: Maven is already stopped.
|
||||||
|
echo "Nothing to stop: no Maven processes and no running containers."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
# We could not ask docker, so Maven may be alive in a container we cannot
|
||||||
|
# see. Saying "stopped" here is the exact false success this script had.
|
||||||
|
echo "ERROR: no Maven processes on this host, and docker could not be asked." >&2
|
||||||
|
echo " If this is the container deploy it may still be running:" >&2
|
||||||
|
echo " docker compose -f $COMPOSE_FILE stop" >&2
|
||||||
|
echo " Nothing was stopped. Check by hand before assuming Maven is down." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
echo "--- Sending graceful SIGTERM to Maven services ---"
|
echo "--- Sending graceful SIGTERM to Maven services ---"
|
||||||
pkill -TERM -f "$PAT" || true
|
pkill -TERM -f "$PAT" || true
|
||||||
# mavend's Pdeathsig SIGKILLs its llama-server on exit, but sweep strays too
|
# mavend's Pdeathsig SIGKILLs its llama-server on exit, but sweep strays too
|
||||||
@@ -32,12 +101,18 @@ pkill -TERM -f "$LLM" || true
|
|||||||
|
|
||||||
echo "--- Verifying processes are gone ---"
|
echo "--- Verifying processes are gone ---"
|
||||||
sleep 1
|
sleep 1
|
||||||
PIDS=$(pgrep -d ',' -f "$PAT|$LLM") || PIDS=""
|
PIDS=$(host_pids)
|
||||||
if [ -n "$PIDS" ]; then
|
if [ -n "$PIDS" ]; then
|
||||||
echo "Warning: some processes still alive. PIDs: $PIDS"
|
echo "Warning: some processes still alive. PIDs: $PIDS"
|
||||||
echo "--- Force killing with SIGKILL ---"
|
echo "--- Force killing with SIGKILL ---"
|
||||||
echo "$PIDS" | tr ',' '\n' | xargs -r kill -9
|
echo "$PIDS" | tr ',' '\n' | xargs -r kill -9
|
||||||
|
sleep 1
|
||||||
|
LEFT=$(host_pids)
|
||||||
|
if [ -n "$LEFT" ]; then
|
||||||
|
echo "ERROR: still alive after SIGKILL. PIDs: $LEFT" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
echo "Done (SIGKILL)."
|
echo "Done (SIGKILL)."
|
||||||
else
|
else
|
||||||
echo "All services gracefully stopped."
|
echo "All services gracefully stopped."
|
||||||
fi
|
fi
|
||||||
|
|||||||
Reference in New Issue
Block a user