Address PR review comments on 50, 52, 53, 54, 59, 61
Seven fixes, each answering a line comment on the stack.
**Weather no longer invents Moscow** (PR 50). extractWeatherLocation returned
the string "Moscow" when he named no city and voice.weather.default_location
was unset — a made-up answer presented as fact, which is the one thing maven
must never do. It returns "" now and the query path says it does not know.
**Digest statuses are a defined type** (PR 50). DigestStatus string plus the
three constants, so a rule name cannot reach the status column.
**Quiet-mode negation is not adjacency** (PR 53). The OFF list carried
{"не","тих"}, an adjacency pattern, so "не надо тихий режим" missed OFF, hit
the ON pattern {"тих","режим"}, and asking for quiet mode to stop turned it
on. Negators are scanned over the whole utterance now, with the two ON phrases
that are themselves built on "не" excluded. "тихий режим выключи" works too,
which it did not before.
**Pattern stability uses a median band** (PR 54). max/min over the extremes
asked whether every gap resembles every other gap, so 7,7,7,7,20 — four clean
weeks and one holiday — was thrown away at a ratio of 2.9. Each interval is
now tested against the median and 70% must be in band, and the reported
interval is the median of the in-band ones, so a holiday no longer drags a
weekly habit to "every 9.6 days". The reviewer's 5,8,10,3 is still rejected.
**The weekday profile stops reciting everyday habits** (PR 59). "What do I do
on Saturdays?" answered "you drink water" — true, and useless, because it is
equally true of every other day. Activities that are habits on six or more
weekdays move to Profile.Everyday and are read back as daily habits instead of
as an answer about that day.
**Russian phrase tables move out of Go** (PR 59, PR 61). The behaviour glosses
and weekday names, and the task capture/urgency/list vocabulary, are now
behavior_ru.json and task_phrases.json, embedded with go:embed. Single-binary
deploy is unchanged; wording edits are no longer source diffs.
**nginx template stops taking nginx down** (PR 52). Two host-side failure
modes, both plausible causes of today's crash. The $connection_upgrade map is
fatal when duplicated, so it moved to its own nginx-upgrade-map.conf with a
grep-first note. And `listen 10.42.0.1:80` fails with EADDRNOTAVAIL when wg0
is not up yet, so nginx exits on a reboot that beats WireGuard — the header
now documents net.ipv4.ip_nonlocal_bind.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TrVSBKe3RFDF4fGYKWYQnX
This commit is contained in:
@@ -325,6 +325,11 @@ func (h *reactiveHandler) queryWeather(ctx context.Context, t *queryTurn) (strin
|
||||
return "", false
|
||||
}
|
||||
loc := extractWeatherLocation(t.dec.Utterance, h.weatherLocation)
|
||||
if loc == "" {
|
||||
// He named no city and voice.weather.default_location is unset. Saying
|
||||
// so is the only honest answer; picking a city would be inventing one.
|
||||
return "не знаю, для какого города — задай voice.weather.default_location или назови город.", true
|
||||
}
|
||||
ctxWT, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
w, err := h.weatherProvider.CurrentWeather(ctxWT, loc)
|
||||
|
||||
@@ -110,11 +110,18 @@ func quietPhrase(tokens, pattern []string) bool {
|
||||
}
|
||||
|
||||
// quietOffPhrases / quietOnPhrases — the toggle vocabulary, as stem sequences.
|
||||
//
|
||||
// Note what is NOT here any more: the OFF list used to carry {"не", "тих"} and
|
||||
// the ON list {"не", "шум"} / {"не", "беспоко"}. Both were adjacency patterns,
|
||||
// and negation is not an adjacency phenomenon. "не надо тихий режим" put two
|
||||
// tokens between "не" and "тих", so the OFF pattern missed, the ON pattern
|
||||
// {"тих","режим"} matched, and asking for quiet mode to stop turned it on.
|
||||
// Negation is handled by quietNegators below, over the whole utterance.
|
||||
var (
|
||||
quietOffPhrases = [][]string{
|
||||
{"quiet", "off"}, {"quiet", "end"},
|
||||
{"громк", "режим"}, {"шумн", "режим"},
|
||||
{"отмен", "тих"}, {"выключ", "тих"}, {"не", "тих"},
|
||||
{"отмен", "тих"}, {"выключ", "тих"},
|
||||
}
|
||||
quietOnPhrases = [][]string{
|
||||
{"quiet", "on"}, {"quiet", "mode"},
|
||||
@@ -123,11 +130,44 @@ var (
|
||||
}
|
||||
)
|
||||
|
||||
// classifyQuietToggle reads an utterance as a quiet-mode command. OFF is
|
||||
// resolved before ON for the same reason classifyConfirm checks negatives
|
||||
// first: the OFF phrases are built out of the ON words ("выключи тихий"
|
||||
// contains "тихий"), so scanning ON first would shadow them and "выключи
|
||||
// тихий режим" would turn quiet mode on. Negation wins.
|
||||
// quietNegatorWords — negators that are whole words with no useful stem.
|
||||
var quietNegatorWords = map[string]bool{
|
||||
"не": true, "нет": true, "хватит": true, "no": true, "not": true, "off": true,
|
||||
}
|
||||
|
||||
// quietNegatorStems — negators that inflect. Matched through quietStem, the
|
||||
// same one-ending rule the toggle vocabulary uses, so "выключи", "выключить"
|
||||
// and "выключай" all count and "выключатель" does not.
|
||||
var quietNegatorStems = []string{"выключ", "отмен", "прекрат", "убер", "stop", "cancel", "disable"}
|
||||
|
||||
// quietNegated reports whether the utterance carries a negator. Two ON phrases
|
||||
// are themselves built on "не" — "не шуми", "не беспокой" — and those are
|
||||
// requests FOR quiet, so they are excluded before the scan: a negator only
|
||||
// counts when it is not part of the phrase that matched.
|
||||
func quietNegated(tokens []string, matched []string) bool {
|
||||
if len(matched) > 0 && matched[0] == "не" {
|
||||
return false
|
||||
}
|
||||
for _, t := range tokens {
|
||||
if quietNegatorWords[t] {
|
||||
return true
|
||||
}
|
||||
for _, stem := range quietNegatorStems {
|
||||
if quietStem(t, stem) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// classifyQuietToggle reads an utterance as a quiet-mode command.
|
||||
//
|
||||
// Explicit OFF phrases resolve first, for the same reason classifyConfirm
|
||||
// checks negatives first: they are built out of the ON words ("выключи тихий"
|
||||
// contains "тихий"), so scanning ON first would shadow them. An ON phrase that
|
||||
// matches is then checked for negation across the whole utterance, so any way
|
||||
// of saying "not quiet mode" turns it off rather than on.
|
||||
func classifyQuietToggle(text string) (on, off bool) {
|
||||
tokens := quietTokens(text)
|
||||
for _, p := range quietOffPhrases {
|
||||
@@ -137,8 +177,23 @@ func classifyQuietToggle(text string) (on, off bool) {
|
||||
}
|
||||
for _, p := range quietOnPhrases {
|
||||
if quietPhrase(tokens, p) {
|
||||
if quietNegated(tokens, p) {
|
||||
return false, true
|
||||
}
|
||||
return true, false
|
||||
}
|
||||
}
|
||||
// No ON phrase matched, but he negated a quiet word: "не тихо", "хватит
|
||||
// тихого режима". The ON vocabulary cannot see these — bare "тих" only
|
||||
// matches a one-token utterance, by design, so the negator pushes the token
|
||||
// count past it — and reading them as "no command" would leave quiet mode
|
||||
// on after he asked for it to stop.
|
||||
if quietNegated(tokens, nil) {
|
||||
for _, t := range tokens {
|
||||
if quietStem(t, "тих") {
|
||||
return false, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false, false
|
||||
}
|
||||
|
||||
@@ -112,3 +112,38 @@ func TestResolveQuietToggle(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestQuietToggleNegationIsNotAdjacency — negation used to be an adjacency
|
||||
// pattern ({"не","тих"} in the OFF list), so any word between the negator and
|
||||
// the quiet word made the ON pattern win and asking for quiet mode to STOP
|
||||
// turned it on. Negation is scanned over the whole utterance now.
|
||||
func TestQuietToggleNegationIsNotAdjacency(t *testing.T) {
|
||||
off := []string{
|
||||
"не надо тихий режим",
|
||||
"не хочу тихий режим",
|
||||
"тихий режим выключи",
|
||||
"убери тихий режим",
|
||||
"хватит тихого режима",
|
||||
"прекрати тихий режим",
|
||||
"тихий режим отмени пожалуйста",
|
||||
}
|
||||
for _, text := range off {
|
||||
t.Run(text, func(t *testing.T) {
|
||||
on, isOff := classifyQuietToggle(text)
|
||||
if on || !isOff {
|
||||
t.Fatalf("%q: want OFF, got on=%v off=%v", text, on, isOff)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// The two ON phrases that are themselves built on "не" must stay ON: they
|
||||
// are requests FOR quiet, not negations of one.
|
||||
for _, text := range []string{"не шуми", "не беспокоить"} {
|
||||
t.Run(text, func(t *testing.T) {
|
||||
on, isOff := classifyQuietToggle(text)
|
||||
if !on || isOff {
|
||||
t.Fatalf("%q: want ON, got on=%v off=%v", text, on, isOff)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+30
-22
@@ -19,32 +19,40 @@ func isWeatherQuery(u string) bool {
|
||||
strings.Contains(lower, "temperature")
|
||||
}
|
||||
|
||||
// extractWeatherLocation parses a location from the utterance, or falls back
|
||||
// to the configured default. Very basic: just checks for known city names.
|
||||
// weatherCities — the city names an utterance may name explicitly, as
|
||||
// lowercase substrings mapped to the provider's spelling. This is a
|
||||
// convenience for "какая погода в Лондоне", NOT a source of default truth:
|
||||
// nothing here is used unless he actually said it.
|
||||
var weatherCities = map[string]string{
|
||||
"москв": "Moscow",
|
||||
"moscow": "Moscow",
|
||||
"питер": "Saint Petersburg",
|
||||
"spb": "Saint Petersburg",
|
||||
"петербур": "Saint Petersburg",
|
||||
"лондон": "London",
|
||||
"london": "London",
|
||||
"париж": "Paris",
|
||||
"paris": "Paris",
|
||||
"берлин": "Berlin",
|
||||
"berlin": "Berlin",
|
||||
"нью-йорк": "New York",
|
||||
"new york": "New York",
|
||||
}
|
||||
|
||||
// extractWeatherLocation returns the city he named, or the configured default
|
||||
// when he named none. It returns "" when he named none AND no default is
|
||||
// configured — the caller must then say it does not know.
|
||||
//
|
||||
// It used to return "Moscow" in that case. That is a made-up answer presented
|
||||
// as fact: reading out Moscow's temperature to someone who is not in Moscow is
|
||||
// wrong in exactly the way maven must never be wrong. voice.weather
|
||||
// .default_location is the only source of an unstated location.
|
||||
func extractWeatherLocation(u, defaultLoc string) string {
|
||||
lower := strings.ToLower(u)
|
||||
cities := map[string]string{
|
||||
"москв": "Moscow",
|
||||
"moscow": "Moscow",
|
||||
"питер": "Saint Petersburg",
|
||||
"spb": "Saint Petersburg",
|
||||
"петербур": "Saint Petersburg",
|
||||
"лондон": "London",
|
||||
"london": "London",
|
||||
"париж": "Paris",
|
||||
"paris": "Paris",
|
||||
"берлин": "Berlin",
|
||||
"berlin": "Berlin",
|
||||
"нью-йорк": "New York",
|
||||
"new york": "New York",
|
||||
}
|
||||
for substr, name := range cities {
|
||||
for substr, name := range weatherCities {
|
||||
if strings.Contains(lower, substr) {
|
||||
return name
|
||||
}
|
||||
}
|
||||
if defaultLoc != "" {
|
||||
return defaultLoc
|
||||
}
|
||||
return "Moscow"
|
||||
return defaultLoc
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# $connection_upgrade — WebSocket upgrade helper for the maven.<domain> block
|
||||
# in nginx.conf. Install this ONLY if your nginx does not already define
|
||||
# $connection_upgrade somewhere in the http context.
|
||||
#
|
||||
# It is a separate file because nginx treats a duplicate `map` directive as a
|
||||
# fatal configuration error, not a warning: if this block were inside
|
||||
# nginx.conf and your setup already had one (nginx-panel and most WebSocket
|
||||
# recipes ship one), the next `nginx -s reload` would fail the config test and
|
||||
# nginx would refuse to come back up — taking every other site on the box down
|
||||
# with it, not just maven.
|
||||
#
|
||||
# Check before installing:
|
||||
# grep -rn 'connection_upgrade' /etc/nginx/
|
||||
# Nothing? Drop this in /etc/nginx/conf.d/ and reload. Something already there?
|
||||
# Skip this file entirely; nginx.conf works as-is.
|
||||
#
|
||||
# Verify either way before reloading:
|
||||
# nginx -t
|
||||
map $http_upgrade $connection_upgrade {
|
||||
default upgrade;
|
||||
'' close;
|
||||
}
|
||||
@@ -12,6 +12,25 @@
|
||||
# On a different box, replace both addresses with that box's wg and LAN IPs.
|
||||
# Do NOT "fix" a failed bind by reverting to `listen 80` (all interfaces) —
|
||||
# that removes the only access control these containers have.
|
||||
#
|
||||
# BEFORE YOU RELOAD — two ways this file takes nginx down, both host-side.
|
||||
# These blocks are for the HOST nginx, not for anything inside the compose;
|
||||
# the containers publish on 127.0.0.1 and have no nginx of their own.
|
||||
#
|
||||
# 1. Binding an address that does not exist yet. `listen 10.42.0.1:80` fails
|
||||
# with EADDRNOTAVAIL if wg0 is down, and nginx exits rather than starting
|
||||
# without it — so a reboot that brings nginx up before WireGuard leaves the
|
||||
# box with no web at all. Allow the bind to succeed regardless:
|
||||
# sysctl -w net.ipv4.ip_nonlocal_bind=1
|
||||
# echo 'net.ipv4.ip_nonlocal_bind = 1' > /etc/sysctl.d/99-nginx-bind.conf
|
||||
# Ordering nginx after the wg interface works too, but only until the next
|
||||
# time the tunnel restarts.
|
||||
#
|
||||
# 2. A duplicate $connection_upgrade map. That is a fatal config error, so the
|
||||
# map now lives in nginx-upgrade-map.conf and is installed separately —
|
||||
# read the note at the top of that file first.
|
||||
#
|
||||
# `nginx -t` catches the second and not the first. Run it anyway, every time.
|
||||
|
||||
# maven.<domain> → mavweb (docker-compose.yml publishes it on 127.0.0.1:9201).
|
||||
# Same bind + ACL as the siblings, and for a stronger reason: mavweb serves
|
||||
@@ -22,14 +41,9 @@
|
||||
# allow/deny anyway — belt and braces on an RCE surface.
|
||||
#
|
||||
# WebSocket upgrade matters here: /ws carries push-to-talk audio, so the
|
||||
# Upgrade/Connection headers below are required, not decoration. The map keeps
|
||||
# `Connection: upgrade` off plain requests; it sits in the http context, which
|
||||
# is where sites-available files are included — if your nginx already defines
|
||||
# $connection_upgrade, drop this block.
|
||||
map $http_upgrade $connection_upgrade {
|
||||
default upgrade;
|
||||
'' close;
|
||||
}
|
||||
# Upgrade/Connection headers below are required, not decoration. They reference
|
||||
# $connection_upgrade, which this file does NOT define — see
|
||||
# nginx-upgrade-map.conf and point 2 above.
|
||||
|
||||
server {
|
||||
listen 10.42.0.1:80;
|
||||
|
||||
+64
-30
@@ -53,15 +53,27 @@ type Activity struct {
|
||||
TypicalAt time.Duration
|
||||
}
|
||||
|
||||
// Profile — the counted behaviour model. Weekly holds the activities that
|
||||
// recur on a given weekday, Overall the ones that recur at all.
|
||||
// Profile — the counted behaviour model.
|
||||
//
|
||||
// Weekly holds only the activities that DISTINGUISH a weekday: things he does
|
||||
// on Tuesdays and not on most other days. Everyday holds the ones that recur
|
||||
// across the week, and All holds both. The split exists because the two answer
|
||||
// different questions, and conflating them produced the failure that named
|
||||
// this: asked what he does on Saturdays, maven replied "ты пьёшь воду".
|
||||
type Profile struct {
|
||||
Since time.Time
|
||||
Until time.Time
|
||||
Weekly map[time.Weekday][]Activity
|
||||
All []Activity
|
||||
Since time.Time
|
||||
Until time.Time
|
||||
Weekly map[time.Weekday][]Activity
|
||||
Everyday []Activity
|
||||
All []Activity
|
||||
}
|
||||
|
||||
// EverydaySpan — the number of weekdays an activity must be a habit on before
|
||||
// it stops counting as characteristic of any one of them. Six of seven, not
|
||||
// five: a weekday-only rhythm spans exactly five, and "по будням ты
|
||||
// тренируешься" is a real answer about Tuesday. Six days a week is not.
|
||||
const EverydaySpan = 6
|
||||
|
||||
// MinHabitDays — how many distinct days an activity must appear on before maven
|
||||
// will call it usual. Two is the smallest number that can distinguish a habit
|
||||
// from a one-off; below that she says she does not know yet, which is true.
|
||||
@@ -161,9 +173,36 @@ func BuildProfile(obs []Observation, now time.Time) Profile {
|
||||
}
|
||||
|
||||
p.All = harvest(all)
|
||||
|
||||
// How many weekdays each key is a habit on. An activity that recurs on
|
||||
// most days of the week is a daily habit, and naming it as an answer to
|
||||
// "что я обычно делаю по субботам?" is a non-answer: "ты пьёшь воду" is
|
||||
// true of Saturday and of every other day, so it says nothing about
|
||||
// Saturday. Those are held in Everyday and read back separately.
|
||||
span := map[string]int{}
|
||||
harvested := map[time.Weekday][]Activity{}
|
||||
for wd, m := range weekly {
|
||||
if acts := harvest(m); len(acts) > 0 {
|
||||
p.Weekly[wd] = acts
|
||||
acts := harvest(m)
|
||||
harvested[wd] = acts
|
||||
for _, a := range acts {
|
||||
span[a.Key]++
|
||||
}
|
||||
}
|
||||
for wd, acts := range harvested {
|
||||
var distinct []Activity
|
||||
for _, a := range acts {
|
||||
if span[a.Key] >= EverydaySpan {
|
||||
continue
|
||||
}
|
||||
distinct = append(distinct, a)
|
||||
}
|
||||
if len(distinct) > 0 {
|
||||
p.Weekly[wd] = distinct
|
||||
}
|
||||
}
|
||||
for _, a := range p.All {
|
||||
if span[a.Key] >= EverydaySpan {
|
||||
p.Everyday = append(p.Everyday, a)
|
||||
}
|
||||
}
|
||||
return p
|
||||
@@ -193,33 +232,28 @@ func medianInt(xs []int) int {
|
||||
return (s[mid-1] + s[mid]) / 2
|
||||
}
|
||||
|
||||
// weekdayRU — accusative, as "по вторникам" and "в среду" both need it read
|
||||
// back. Index is time.Weekday.
|
||||
var weekdayRU = [...]string{"воскресеньям", "понедельникам", "вторникам", "средам", "четвергам", "пятницам", "субботам"}
|
||||
// weekdayRU and activityRU are loaded from the embedded behavior_ru.json;
|
||||
// see behavior_ru.go.
|
||||
|
||||
// activityRU glosses the loop's known fact keys. An unknown key is read back
|
||||
// verbatim: it is what the store holds, and inventing a Russian phrase for a key
|
||||
// maven does not recognise would be putting words in his mouth.
|
||||
var activityRU = map[string]string{
|
||||
"water": "пьёшь воду",
|
||||
"meal": "ешь",
|
||||
"sleep": "спишь",
|
||||
"break": "делаешь перерыв",
|
||||
"shower": "принимаешь душ",
|
||||
"walk": "гуляешь",
|
||||
"pills": "пьёшь витамины",
|
||||
"workout": "тренируешься",
|
||||
}
|
||||
|
||||
// FormatWeekdayRU reads back what he usually does on a given weekday.
|
||||
// FormatWeekdayRU reads back what DISTINGUISHES a given weekday.
|
||||
// Second person singular and informal, as she speaks TO him.
|
||||
//
|
||||
// When nothing distinguishes it, she says so and names the daily habits as
|
||||
// daily habits instead of passing them off as an answer about that day. The
|
||||
// previous version had no such distinction and answered "что я делаю по
|
||||
// субботам?" with "ты пьёшь воду" — true, useless, and phrased as if Saturday
|
||||
// were the reason.
|
||||
func (p Profile) FormatWeekdayRU(wd time.Weekday) string {
|
||||
acts := p.Weekly[wd]
|
||||
day := weekdayRU[int(wd)%7]
|
||||
if len(acts) == 0 {
|
||||
return fmt.Sprintf("по %s у меня пока нет ничего постоянного.", day)
|
||||
acts := p.Weekly[wd]
|
||||
if len(acts) > 0 {
|
||||
return fmt.Sprintf("по %s ты обычно %s.", day, joinActivities(acts))
|
||||
}
|
||||
return fmt.Sprintf("по %s ты обычно %s.", day, joinActivities(acts))
|
||||
if len(p.Everyday) > 0 {
|
||||
return fmt.Sprintf("по %s у тебя нет ничего особенного — то же, что и в остальные дни: %s.",
|
||||
day, joinActivities(p.Everyday))
|
||||
}
|
||||
return fmt.Sprintf("по %s у меня пока нет ничего постоянного.", day)
|
||||
}
|
||||
|
||||
// FormatOverallRU reads back the habits that hold across the whole week.
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// The Russian read-back vocabulary lives in behavior_ru.json, not in Go source.
|
||||
//
|
||||
// It is data, not logic: a weekday name and a verb gloss are wording choices
|
||||
// that change independently of anything the counter does, and having them as
|
||||
// literals scattered through behavior.go meant every phrasing tweak was a code
|
||||
// diff. go:embed keeps the single-binary deploy — the JSON is compiled in, so
|
||||
// there is no file to install alongside mavend and no way for the two to drift.
|
||||
//
|
||||
// Parsed once at init. A malformed file is a panic, deliberately: it can only
|
||||
// happen if the embedded asset is broken at build time, and a daemon that comes
|
||||
// up with an empty vocabulary would answer with bare fact keys.
|
||||
|
||||
//go:embed behavior_ru.json
|
||||
var behaviorRUJSON []byte
|
||||
|
||||
type behaviorRU struct {
|
||||
Weekdays []string `json:"weekdays"`
|
||||
Activities map[string]string `json:"activities"`
|
||||
}
|
||||
|
||||
var (
|
||||
// weekdayRU — dative plural, indexed by time.Weekday.
|
||||
weekdayRU []string
|
||||
// activityRU — fact key to second-person-singular verb phrase.
|
||||
activityRU map[string]string
|
||||
)
|
||||
|
||||
func init() {
|
||||
var v behaviorRU
|
||||
if err := json.Unmarshal(behaviorRUJSON, &v); err != nil {
|
||||
panic(fmt.Sprintf("memory: behavior_ru.json: %v", err))
|
||||
}
|
||||
if len(v.Weekdays) != 7 {
|
||||
panic(fmt.Sprintf("memory: behavior_ru.json: want 7 weekdays, got %d", len(v.Weekdays)))
|
||||
}
|
||||
weekdayRU, activityRU = v.Weekdays, v.Activities
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"_comment": [
|
||||
"Russian read-back vocabulary for the counted behaviour profile.",
|
||||
"Embedded into the binary by behavior_ru.go — there is no runtime file to",
|
||||
"ship or lose. Editing wording here does not need a Go change, which is the",
|
||||
"whole point: these are strings for a person, not program logic.",
|
||||
"",
|
||||
"weekdays are indexed by time.Weekday (0 = Sunday) and are in the dative",
|
||||
"plural, because both 'по вторникам' and 'по средам' need that form.",
|
||||
"",
|
||||
"activities glosses a fact key as a second-person-singular verb phrase. An",
|
||||
"unknown key is read back verbatim rather than guessed at: inventing a",
|
||||
"Russian phrase for a key maven does not recognise puts words in his mouth."
|
||||
],
|
||||
"weekdays": [
|
||||
"воскресеньям",
|
||||
"понедельникам",
|
||||
"вторникам",
|
||||
"средам",
|
||||
"четвергам",
|
||||
"пятницам",
|
||||
"субботам"
|
||||
],
|
||||
"activities": {
|
||||
"water": "пьёшь воду",
|
||||
"meal": "ешь",
|
||||
"sleep": "спишь",
|
||||
"break": "делаешь перерыв",
|
||||
"shower": "принимаешь душ",
|
||||
"walk": "гуляешь",
|
||||
"pills": "пьёшь витамины",
|
||||
"workout": "тренируешься"
|
||||
}
|
||||
}
|
||||
@@ -161,3 +161,49 @@ func TestBuildProfileIgnoresFutureRows(t *testing.T) {
|
||||
t.Fatalf("future rows counted: %+v", p.All)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWeekdayProfileExcludesEverydayHabits — the "You drink water" case.
|
||||
// Drinking water every day is not what he does on Saturdays, and answering
|
||||
// with it makes the weekday question pointless.
|
||||
func TestWeekdayProfileExcludesEverydayHabits(t *testing.T) {
|
||||
now := time.Date(2026, 8, 1, 20, 0, 0, 0, time.UTC) // a Saturday
|
||||
var obs []Observation
|
||||
// water: twice a day, every day, for three weeks.
|
||||
for d := 1; d <= 21; d++ {
|
||||
day := now.AddDate(0, 0, -d)
|
||||
obs = append(obs,
|
||||
Observation{At: time.Date(day.Year(), day.Month(), day.Day(), 9, 0, 0, 0, time.UTC), Key: "water", Kind: "self"},
|
||||
Observation{At: time.Date(day.Year(), day.Month(), day.Day(), 18, 0, 0, 0, time.UTC), Key: "water", Kind: "self"})
|
||||
}
|
||||
// workout: Saturdays only.
|
||||
for _, d := range []int{7, 14, 21} {
|
||||
day := now.AddDate(0, 0, -d)
|
||||
obs = append(obs, Observation{
|
||||
At: time.Date(day.Year(), day.Month(), day.Day(), 11, 0, 0, 0, time.UTC), Key: "workout", Kind: "self"})
|
||||
}
|
||||
|
||||
p := BuildProfile(obs, now)
|
||||
|
||||
sat := p.Weekly[time.Saturday]
|
||||
if len(sat) != 1 || sat[0].Key != "workout" {
|
||||
t.Fatalf("Saturday should be characterised by workout alone, got %+v", sat)
|
||||
}
|
||||
if len(p.Everyday) != 1 || p.Everyday[0].Key != "water" {
|
||||
t.Fatalf("water should be an everyday habit, got %+v", p.Everyday)
|
||||
}
|
||||
|
||||
got := p.FormatWeekdayRU(time.Saturday)
|
||||
if !strings.Contains(got, "тренируешься") {
|
||||
t.Fatalf("Saturday readout should name the workout: %q", got)
|
||||
}
|
||||
if strings.Contains(got, "воду") {
|
||||
t.Fatalf("Saturday readout must not recite the everyday habit: %q", got)
|
||||
}
|
||||
|
||||
// A day with nothing of its own says so rather than reciting water as if
|
||||
// Wednesday were the reason for it.
|
||||
wed := p.FormatWeekdayRU(time.Wednesday)
|
||||
if !strings.Contains(wed, "ничего особенного") || !strings.Contains(wed, "воду") {
|
||||
t.Fatalf("plain weekday readout should say the day is unremarkable and name the daily habits: %q", wed)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package pattern
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -11,14 +12,32 @@ import (
|
||||
type ProposedRoutine struct {
|
||||
Action string
|
||||
Object string
|
||||
IntervalDays float64 // mean interval in days (float for sub-day precision)
|
||||
IntervalDays float64 // median of the on-pattern intervals, in days
|
||||
N int // number of events used
|
||||
}
|
||||
|
||||
// MaxIntervalRatio is the maximum ratio between the longest and shortest
|
||||
// interval for a pattern to be considered stable. ±50% variance allowed.
|
||||
// MaxIntervalRatio — how far an interval may sit from the median and still
|
||||
// count as on-pattern. 1.5 means a 7-day rhythm accepts gaps between ~4.7 and
|
||||
// ~10.5 days.
|
||||
//
|
||||
// It is applied per interval against the MEDIAN, not to the longest/shortest
|
||||
// pair. The old extremes test asked "is every gap similar to every other gap",
|
||||
// which is a different and much more brittle question: 7, 7, 7, 7, 20 is four
|
||||
// clean weeks and one holiday, and max/min = 2.9 threw the whole thing away.
|
||||
// One missed week should not erase a habit.
|
||||
const MaxIntervalRatio = 1.5
|
||||
|
||||
// MinOnPatternFraction — how much of the history must sit inside the band
|
||||
// before a rhythm is a rhythm. A strict majority: with the median as the
|
||||
// centre, half the intervals are inside it by construction, so anything at or
|
||||
// below 0.5 would accept noise. 5, 8, 10, 3 has a median of 6.5 and only two
|
||||
// of four gaps in band, so it stays what it is — irregular, no routine.
|
||||
//
|
||||
// At the MinEvents floor (three intervals) 0.7 demands all three, which is
|
||||
// right: four events is already the cheapest bar and there is no room in it to
|
||||
// also forgive an outlier. Tolerance starts at five intervals, where 4/5 passes.
|
||||
const MinOnPatternFraction = 0.7
|
||||
|
||||
// MinEvents is the minimum number of events needed to detect a pattern.
|
||||
// With N events there are N-1 intervals, so 4 events means 3 intervals.
|
||||
//
|
||||
@@ -36,8 +55,14 @@ const MinEvents = 4
|
||||
|
||||
// Detect checks whether a sequence of events for the same action+object
|
||||
// forms a stable recurring pattern. Returns a ProposedRoutine when:
|
||||
// - At least MinEvents events exist (≥2 intervals)
|
||||
// - The ratio longest/shortest interval ≤ MaxIntervalRatio
|
||||
// - At least MinEvents events exist (≥3 intervals)
|
||||
// - At least MinOnPatternFraction of the intervals sit within
|
||||
// MaxIntervalRatio of the median interval
|
||||
//
|
||||
// The reported IntervalDays is the median of the ON-PATTERN intervals only.
|
||||
// Outliers are excluded from the number as well as from the test, so a habit
|
||||
// interrupted by a two-week holiday is still reported as weekly rather than as
|
||||
// "every 9.6 days" — a figure that describes neither the habit nor the gap.
|
||||
//
|
||||
// Returns nil when there aren't enough events or the intervals are too
|
||||
// irregular — false negatives are harmless. The only dangerous mistake
|
||||
@@ -51,10 +76,6 @@ func Detect(events []Event) (*ProposedRoutine, error) {
|
||||
nIntervals := len(events) - 1
|
||||
intervals := make([]float64, nIntervals)
|
||||
|
||||
var sum float64
|
||||
var min float64 = math.MaxFloat64
|
||||
var max float64
|
||||
|
||||
for i := 0; i < nIntervals; i++ {
|
||||
diff := events[i+1].Ts.Sub(events[i].Ts)
|
||||
days := diff.Hours() / 24.0
|
||||
@@ -64,32 +85,51 @@ func Detect(events []Event) (*ProposedRoutine, error) {
|
||||
return nil, nil
|
||||
}
|
||||
intervals[i] = days
|
||||
sum += days
|
||||
if days < min {
|
||||
min = days
|
||||
}
|
||||
if days > max {
|
||||
max = days
|
||||
}
|
||||
}
|
||||
|
||||
// Stability check: the most extreme intervals shouldn't differ by
|
||||
// more than MaxIntervalRatio. A ratio of 1.5 means a 7-day pattern
|
||||
// can have intervals between ~5.6 and ~8.4 days.
|
||||
if min > 0 && max/min > MaxIntervalRatio {
|
||||
center := medianFloat(intervals)
|
||||
if center <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Keep the intervals that sit inside the band around the median. The
|
||||
// bound is symmetric in ratio terms, not in days: half the median below,
|
||||
// the median times the ratio above.
|
||||
var onPattern []float64
|
||||
for _, d := range intervals {
|
||||
if d <= center*MaxIntervalRatio && d >= center/MaxIntervalRatio {
|
||||
onPattern = append(onPattern, d)
|
||||
}
|
||||
}
|
||||
if float64(len(onPattern))/float64(nIntervals) < MinOnPatternFraction {
|
||||
return nil, nil // too irregular
|
||||
}
|
||||
|
||||
mean := sum / float64(nIntervals)
|
||||
|
||||
return &ProposedRoutine{
|
||||
Action: events[0].Action,
|
||||
Object: events[0].Object,
|
||||
IntervalDays: math.Round(mean*10) / 10, // round to 1 decimal
|
||||
IntervalDays: math.Round(medianFloat(onPattern)*10) / 10, // round to 1 decimal
|
||||
N: len(events),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// medianFloat — the middle value, averaging the two middles on an even count.
|
||||
// Sorts a copy: the caller's interval order is the event order and stays that
|
||||
// way.
|
||||
func medianFloat(xs []float64) float64 {
|
||||
if len(xs) == 0 {
|
||||
return 0
|
||||
}
|
||||
s := make([]float64, len(xs))
|
||||
copy(s, xs)
|
||||
sort.Float64s(s)
|
||||
mid := len(s) / 2
|
||||
if len(s)%2 == 1 {
|
||||
return s[mid]
|
||||
}
|
||||
return (s[mid-1] + s[mid]) / 2
|
||||
}
|
||||
|
||||
// PhraseRoutine generates a human-readable suggestion string for a
|
||||
// detected routine. Returns a Russian phrase like
|
||||
// "ты заправляешь поилку раз в 7 дней — напоминать?"
|
||||
|
||||
@@ -161,3 +161,58 @@ func TestPhraseRoutine(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// evAt builds a run of events at the given day offsets.
|
||||
func evAt(offsets ...float64) []Event {
|
||||
base := time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC)
|
||||
out := make([]Event, len(offsets))
|
||||
for i, d := range offsets {
|
||||
out[i] = Event{Action: "refill", Object: "cat_water",
|
||||
Ts: base.Add(time.Duration(d * float64(24*time.Hour)))}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestDetectMedianBandNotExtremes — the stability test used to be
|
||||
// longest/shortest, so a single outlier vetoed an otherwise clean rhythm and
|
||||
// the reported interval was a mean dragged toward that outlier. Both are
|
||||
// median-based now.
|
||||
func TestDetectMedianBandNotExtremes(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
days []float64
|
||||
want float64 // 0 means "expect no routine"
|
||||
}{
|
||||
// Four clean weeks and one holiday. max/min was 20/7 = 2.9, rejected.
|
||||
{"weekly with one long gap", []float64{0, 7, 14, 21, 28, 48}, 7},
|
||||
// The reviewer's case: 5, 8, 10, 3. Median 6.5, only two gaps in band.
|
||||
{"genuinely irregular", []float64{0, 5, 13, 23, 26}, 0},
|
||||
// A short gap outlier is treated the same as a long one.
|
||||
{"weekly with one short gap", []float64{0, 7, 14, 15, 22, 29}, 7},
|
||||
// Two outliers out of five is past the fraction.
|
||||
{"too many outliers", []float64{0, 7, 14, 34, 41, 61}, 0},
|
||||
// At the MinEvents floor there is no outlier budget at all.
|
||||
{"floor rejects one outlier", []float64{0, 7, 14, 34}, 0},
|
||||
{"floor accepts a clean run", []float64{0, 7, 14, 21}, 7},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
r, err := Detect(evAt(tc.days...))
|
||||
if err != nil {
|
||||
t.Fatalf("Detect: %v", err)
|
||||
}
|
||||
if tc.want == 0 {
|
||||
if r != nil {
|
||||
t.Fatalf("want no routine, got interval %.1f", r.IntervalDays)
|
||||
}
|
||||
return
|
||||
}
|
||||
if r == nil {
|
||||
t.Fatal("want a routine, got nil")
|
||||
}
|
||||
if r.IntervalDays != tc.want {
|
||||
t.Fatalf("interval: want %.1f, got %.1f", tc.want, r.IntervalDays)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+8
-49
@@ -13,30 +13,9 @@ import "strings"
|
||||
// follow. What the model classifies is unchanged; what these functions decide
|
||||
// is which store the turn lands in.
|
||||
|
||||
// taskCapturePrefixes — the leading phrases that mean "put this on the list".
|
||||
// A prefix, not a keyword anywhere in the sentence: "добавь в задачи купить
|
||||
// молоко" is a capture, "я не добавил молоко в список" is him talking, and only
|
||||
// position tells them apart.
|
||||
//
|
||||
// Everything here is an explicit instruction. There is deliberately no entry
|
||||
// for "надо" / "нужно" — "надо бы поспать" is a thing he says, not a task he
|
||||
// files, and a capture path that guesses would fill the list with his moods.
|
||||
var taskCapturePrefixes = []string{
|
||||
"добавь в задачи",
|
||||
"добавь в список задач",
|
||||
"добавь в список дел",
|
||||
"добавь в список",
|
||||
"добавь задачу",
|
||||
"запиши в задачи",
|
||||
"запиши задачу",
|
||||
"новая задача",
|
||||
"в задачи",
|
||||
"add a task",
|
||||
"add task",
|
||||
"add to my tasks",
|
||||
"add to tasks",
|
||||
"new task",
|
||||
}
|
||||
// The phrase tables this file matches against — taskCapturePrefixes,
|
||||
// urgencyMarkers, taskListWords, taskListWordsShortcut — are loaded from the
|
||||
// embedded task_phrases.json. See task_phrases.go.
|
||||
|
||||
// TaskCapture — a parsed capture: the task itself, plus the importance he
|
||||
// stated out loud if he stated one (Vikunja #129). Weight 0 means he said
|
||||
@@ -47,19 +26,6 @@ type TaskCapture struct {
|
||||
Weight int
|
||||
}
|
||||
|
||||
// urgencyMarkers — the words that set a weight, strongest first. Only these
|
||||
// two rungs: "срочно" is a deadline he has not named, "важно" is a preference,
|
||||
// and a third shade of urgent would be a distinction he never makes out loud.
|
||||
var urgencyMarkers = []struct {
|
||||
word string
|
||||
weight int
|
||||
}{
|
||||
{"срочно", 3},
|
||||
{"urgent", 3},
|
||||
{"важно", 2},
|
||||
{"important", 2},
|
||||
}
|
||||
|
||||
// ParseTaskCapture reports whether an utterance explicitly files a task, and
|
||||
// returns the task text with the marker stripped. A marker with nothing after it
|
||||
// is not a capture (there is no task in "добавь в задачи") — the caller falls
|
||||
@@ -101,11 +67,11 @@ func stripUrgency(text string) (string, int) {
|
||||
for _, m := range urgencyMarkers {
|
||||
lower := strings.ToLower(text)
|
||||
switch {
|
||||
case strings.HasPrefix(lower, m.word+" "):
|
||||
return strings.TrimSpace(text[len(m.word):]), m.weight
|
||||
case strings.HasSuffix(lower, " "+m.word):
|
||||
return strings.TrimSpace(text[:len(text)-len(m.word)]), m.weight
|
||||
case lower == m.word:
|
||||
case strings.HasPrefix(lower, m.Word+" "):
|
||||
return strings.TrimSpace(text[len(m.Word):]), m.Weight
|
||||
case strings.HasSuffix(lower, " "+m.Word):
|
||||
return strings.TrimSpace(text[:len(text)-len(m.Word)]), m.Weight
|
||||
case lower == m.Word:
|
||||
// Nothing but the marker — no task in it.
|
||||
return "", 0
|
||||
}
|
||||
@@ -113,13 +79,6 @@ func stripUrgency(text string) (string, int) {
|
||||
return text, 0
|
||||
}
|
||||
|
||||
// taskListWords — the nouns that make a question be about the task list.
|
||||
var taskListWords = []string{"задачи", "задачах", "задач", "задачам", "дела", "делах", "дел", "tasks", "todo", "todos"}
|
||||
|
||||
// taskListVerbs — the asks that pair with those nouns. "что мне нужно сделать?"
|
||||
// has no task noun in it at all, so it is matched as a phrase below.
|
||||
var taskListWordsShortcut = []string{"задачи", "задач", "tasks"}
|
||||
|
||||
// IsTaskListQuery reports whether an utterance asks for the outstanding task
|
||||
// list — "какие у меня задачи?", "что мне нужно сделать?", "список дел".
|
||||
//
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// The task vocabulary lives in task_phrases.json, not in Go source. See the
|
||||
// comment block inside that file for what each list means and why the entries
|
||||
// that are NOT in it were left out.
|
||||
//
|
||||
// go:embed, so the single-binary deploy is unchanged: the JSON is compiled into
|
||||
// mavend and there is nothing to install beside it. Parsed once at init; a
|
||||
// malformed asset panics at startup rather than silently disabling task capture,
|
||||
// which would look like the feature quietly not working.
|
||||
|
||||
//go:embed task_phrases.json
|
||||
var taskPhrasesJSON []byte
|
||||
|
||||
type urgencyMarker struct {
|
||||
Word string `json:"word"`
|
||||
Weight int `json:"weight"`
|
||||
}
|
||||
|
||||
type taskPhrases struct {
|
||||
CapturePrefixes []string `json:"capture_prefixes"`
|
||||
UrgencyMarkers []urgencyMarker `json:"urgency_markers"`
|
||||
ListNouns []string `json:"list_nouns"`
|
||||
ListShortcutNouns []string `json:"list_shortcut_nouns"`
|
||||
}
|
||||
|
||||
var (
|
||||
taskCapturePrefixes []string
|
||||
urgencyMarkers []urgencyMarker
|
||||
taskListWords []string
|
||||
taskListWordsShortcut []string
|
||||
)
|
||||
|
||||
func init() {
|
||||
var v taskPhrases
|
||||
if err := json.Unmarshal(taskPhrasesJSON, &v); err != nil {
|
||||
panic(fmt.Sprintf("router: task_phrases.json: %v", err))
|
||||
}
|
||||
if len(v.CapturePrefixes) == 0 || len(v.ListNouns) == 0 {
|
||||
panic("router: task_phrases.json: capture_prefixes and list_nouns must be non-empty")
|
||||
}
|
||||
// Strongest urgency first, so stripUrgency finds "срочно" before "важно"
|
||||
// in an utterance carrying both. The file is written in that order already;
|
||||
// sorting here means a careless edit cannot silently downgrade a task.
|
||||
sort.SliceStable(v.UrgencyMarkers, func(i, j int) bool {
|
||||
return v.UrgencyMarkers[i].Weight > v.UrgencyMarkers[j].Weight
|
||||
})
|
||||
taskCapturePrefixes = v.CapturePrefixes
|
||||
urgencyMarkers = v.UrgencyMarkers
|
||||
taskListWords = v.ListNouns
|
||||
taskListWordsShortcut = v.ListShortcutNouns
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"_comment": [
|
||||
"The task capture and task-listing vocabulary. Embedded by task_phrases.go.",
|
||||
"",
|
||||
"These are lexicon, not logic: which words mean 'put this on the list' is a",
|
||||
"fact about how he speaks, and it changes as he uses the thing. Keeping them",
|
||||
"in Go meant every new phrasing was a source diff.",
|
||||
"",
|
||||
"capture_prefixes must be LEADING phrases. 'добавь в задачи купить молоко' is",
|
||||
"a capture; 'я не добавил молоко в список' is him talking, and only position",
|
||||
"tells them apart. There is deliberately no 'надо'/'нужно' entry: 'надо бы",
|
||||
"поспать' is a mood, not a task, and guessing would fill the list with them.",
|
||||
"",
|
||||
"urgency markers carry the weight he stated out loud. Two rungs only:",
|
||||
"'срочно' is a deadline he has not named and 'важно' is a preference. A third",
|
||||
"shade would be a distinction he never makes.",
|
||||
"",
|
||||
"list_nouns are the nouns that make a question be about the list.",
|
||||
"list_shortcut_nouns are the subset that stand alone as a whole utterance",
|
||||
"('задачи'), which the longer nouns do not ('дел')."
|
||||
],
|
||||
"capture_prefixes": [
|
||||
"добавь в задачи",
|
||||
"добавь в список задач",
|
||||
"добавь в список дел",
|
||||
"добавь в список",
|
||||
"добавь задачу",
|
||||
"запиши в задачи",
|
||||
"запиши задачу",
|
||||
"новая задача",
|
||||
"в задачи",
|
||||
"add a task",
|
||||
"add task",
|
||||
"add to my tasks",
|
||||
"add to tasks",
|
||||
"new task"
|
||||
],
|
||||
"urgency_markers": [
|
||||
{ "word": "срочно", "weight": 3 },
|
||||
{ "word": "urgent", "weight": 3 },
|
||||
{ "word": "важно", "weight": 2 },
|
||||
{ "word": "important", "weight": 2 }
|
||||
],
|
||||
"list_nouns": [
|
||||
"задачи",
|
||||
"задачах",
|
||||
"задач",
|
||||
"задачам",
|
||||
"дела",
|
||||
"делах",
|
||||
"дел",
|
||||
"tasks",
|
||||
"todo",
|
||||
"todos"
|
||||
],
|
||||
"list_shortcut_nouns": ["задачи", "задач", "tasks"]
|
||||
}
|
||||
@@ -8,13 +8,20 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// Digest entry statuses. pending = enqueued, waiting for a drain. drained =
|
||||
// spoken as part of a bundle. expired = the tick loop's expiry sweep found it
|
||||
// past its expires_ts before a drain happened — dropped, not delivered late.
|
||||
// DigestStatus — the lifecycle state of a digest entry. Go has no enum type;
|
||||
// the idiom is a defined type plus constants, which is what this is. The point
|
||||
// is not ceremony: with bare strings nothing stopped a rule name or a body
|
||||
// hash being passed where a status belongs, and every one of these values
|
||||
// reaches SQL. A defined type makes that a compile error.
|
||||
type DigestStatus string
|
||||
|
||||
// pending = enqueued, waiting for a drain. drained = spoken as part of a
|
||||
// bundle. expired = the tick loop's expiry sweep found it past its expires_ts
|
||||
// before a drain happened — dropped, not delivered late.
|
||||
const (
|
||||
DigestPending = "pending"
|
||||
DigestDrained = "drained"
|
||||
DigestExpired = "expired"
|
||||
DigestPending DigestStatus = "pending"
|
||||
DigestDrained DigestStatus = "drained"
|
||||
DigestExpired DigestStatus = "expired"
|
||||
)
|
||||
|
||||
// DigestEntry — one gate-suppressed care candidate durably held for later
|
||||
|
||||
@@ -144,7 +144,7 @@ func TestDigestEntryExpiresRatherThanDeliversLate(t *testing.T) {
|
||||
t.Fatalf("want 1 entry expired, got %d", n)
|
||||
}
|
||||
|
||||
var status string
|
||||
var status DigestStatus
|
||||
if err := s.db.QueryRowContext(ctx, `SELECT status FROM digest_entries WHERE id = ?`, id).Scan(&status); err != nil {
|
||||
t.Fatalf("read back: %v", err)
|
||||
}
|
||||
@@ -191,7 +191,7 @@ func TestDigestEntryDrainMarksDrainedNotDeleted(t *testing.T) {
|
||||
}
|
||||
|
||||
for _, id := range []int64{id1, id2} {
|
||||
var status string
|
||||
var status DigestStatus
|
||||
if err := s.db.QueryRowContext(ctx, `SELECT status FROM digest_entries WHERE id = ?`, id).Scan(&status); err != nil {
|
||||
t.Fatalf("read back %d: %v", id, err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user