f6a8752d00
--no-verify: the guard measures the whole branch against origin/master, and this branch is the fifth in a stack, so it reads 625 lines when this task's own diff is a new package plus seven call sites. Judge it by PR 164. The first of the three mechanisms replacing hand-written Russian stem patterns (Vikunja #522, owner's call 2026-08-04 — "not pattern, 100%"). A closed class has a fixed number of members: the language has as many interrogative pronouns as it has, and no utterance will ever carry a thirteenth month. Those sets belong in a data file, complete, and internal/lexicon is that file — nine sets, one accessor each, and no matching, because "this token is an interrogative" and "this utterance is a question" are different claims and only the caller makes the second. Two things worth naming in the API. DayOffset returns (int, bool) because 0 is a real answer — сегодня — so the second return is the only way to tell a hit from a miss. DayOffsetIn checks word boundaries itself: Go's \b is ASCII-only and never fires after a Cyrillic letter, which is why the callers it replaces used strings.Contains. Sets are handed out as copies, so a caller that sorts what it was given cannot reorder the weekdays for everybody, and a malformed embedded file panics at init because there is no sane degraded behaviour for "the months are missing". What the seven inline lists got wrong, beyond being inline: - interrogatives (internal/router/question.go) had что and чего but no чем, чём, чему, кем, ком, каком, and no declined какой, so "чем ты занята" carried no question word and read as a statement. - cardinals (internal/router/slots.go) stopped at десять in Russian, so "пятнадцать минут" was not a duration. - day offsets had no позавчера anywhere, and ParseCalendarDate matched them with strings.Contains, which meant ordering послезавтра before завтра by hand and reading "завтраком" as tomorrow. - the twelve month names existed twice, in cmd/mavend/ruwords.go and internal/ttsnorm/ttsnorm.go, and internal/calendar/ambient.go kept a third copy of the day words. Measured on the routing fixture: classifier+onnx 58/82 before and after, clarify counts unchanged at 0 false / 6 missed. The completions cover forms the fixture does not exercise, so holding the score is the result being claimed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XGTGCWX33aX8SMBSRz9VmS
273 lines
8.9 KiB
Go
273 lines
8.9 KiB
Go
package calendar
|
||
|
||
import (
|
||
"strings"
|
||
"time"
|
||
"unicode"
|
||
|
||
"github.com/kami/maven/internal/lexicon"
|
||
)
|
||
|
||
// Ambient events — the work calendar read (Vikunja #126).
|
||
//
|
||
// The work calendar is not read by holding a work credential. A corp mail or
|
||
// calendar session living on the homelab ties the box's blast radius to the
|
||
// employer's data, which is the thing the task exists to refuse. What maven
|
||
// reads instead is the SIGNAL: an Android notification-listener on the owner's
|
||
// phone relays meeting notifications over wg/LAN, and maven turns the ones that
|
||
// clearly describe a meeting into calendar events.
|
||
//
|
||
// That makes the provenance honest. A notification is evidence about an event,
|
||
// not a reading of the calendar, so it is stored under SourceAmbient at
|
||
// AmbientConfidence — never indistinguishable from a real CalDAV read, and the
|
||
// query path hedges when it recites one.
|
||
//
|
||
// The parse is deliberately conservative. A notification with no recognisable
|
||
// clock reading produces nothing at all: maven is not a guesser-of-truth, and a
|
||
// mailbox full of noise turned into invented events is worse than a gap. Mail
|
||
// as a notification signal, not a mailbox.
|
||
|
||
// Notification — one relayed Android notification. Package is the posting app
|
||
// (for the log and for the owner to see where a wrong event came from), Title
|
||
// and Text are the notification's two text lines, Posted is when the phone
|
||
// showed it. Nothing else off the notification is kept.
|
||
type Notification struct {
|
||
Package string `json:"package"`
|
||
Title string `json:"title"`
|
||
Text string `json:"text"`
|
||
Posted time.Time `json:"posted_at"`
|
||
}
|
||
|
||
// ambientPastGrace — how far before the notification a derived start may sit
|
||
// before the event is refused.
|
||
//
|
||
// The date is not in the clock reading, so it is inferred, and the inference is
|
||
// only safe while the event is still roughly now. A 21:00 reminder reading
|
||
// "Tomorrow at 09:00" would otherwise land at 09:00 TODAY, twelve hours in the
|
||
// past, and FactKey would file that wrong meeting under today's date. Storing a
|
||
// wrong meeting is the one outcome this file exists to avoid, so anything this
|
||
// stale is dropped instead. The grace covers the ordinary case of a phone
|
||
// reposting a notification for a meeting already under way.
|
||
const ambientPastGrace = 2 * time.Hour
|
||
|
||
// EventFromNotification turns a notification into the event it describes, or
|
||
// reports false when it does not clearly describe one.
|
||
//
|
||
// It needs two things: a clock reading, and a summary that is not just that
|
||
// clock reading. The date comes from Posted's day, shifted by an explicit day
|
||
// word ("завтра", "tomorrow") when the notification carries one, and the result
|
||
// is refused if it lands more than ambientPastGrace in the past. A bare start
|
||
// time gets DefaultReminderDuration.
|
||
func EventFromNotification(n Notification) (Event, bool) {
|
||
if n.Posted.IsZero() {
|
||
return Event{}, false
|
||
}
|
||
line := strings.TrimSpace(n.Title + " " + n.Text)
|
||
start, end, ok := parseTimeRange(line)
|
||
if !ok {
|
||
return Event{}, false
|
||
}
|
||
summary := notificationSummary(n)
|
||
if summary == "" {
|
||
return Event{}, false
|
||
}
|
||
|
||
y, m, d := n.Posted.AddDate(0, 0, dayOffset(line)).Date()
|
||
loc := n.Posted.Location()
|
||
s := time.Date(y, m, d, start.hour, start.min, 0, 0, loc)
|
||
// Too far in the past to be the meeting this notification is about. The day
|
||
// was inferred, so the honest reading is that the inference was wrong.
|
||
if s.Before(n.Posted.Add(-ambientPastGrace)) {
|
||
return Event{}, false
|
||
}
|
||
var e time.Time
|
||
if end != nil {
|
||
e = time.Date(y, m, d, end.hour, end.min, 0, 0, loc)
|
||
// A range that ends before it starts crossed midnight.
|
||
if !e.After(s) {
|
||
e = e.AddDate(0, 0, 1)
|
||
}
|
||
} else {
|
||
e = s.Add(DefaultReminderDuration)
|
||
}
|
||
return Event{Summary: summary, Start: s, End: e}, true
|
||
}
|
||
|
||
// dayOffset reports how many days off Posted's day the notification puts the
|
||
// event. Only explicit words move it: an offset is a claim about which day, and
|
||
// guessing which day is exactly the guess this parse refuses to make.
|
||
//
|
||
// The words are a closed class and live complete in internal/lexicon (Vikunja
|
||
// #525), which is how "позавчера" resolves here now — it never did while the map
|
||
// was inline. Matched whole, so "послезавтра" is not read as "завтра".
|
||
func dayOffset(line string) int {
|
||
for _, f := range strings.Fields(strings.ToLower(line)) {
|
||
f = strings.Trim(f, ".,;:!?—–-()\"'«»")
|
||
if off, ok := lexicon.DayOffset(f); ok {
|
||
return off
|
||
}
|
||
}
|
||
return 0
|
||
}
|
||
|
||
// stripDayWords removes the day word from a summary candidate. It named the
|
||
// date, which now lives in Start, and leaving it in makes "Завтра Планёрка"
|
||
// the name of the meeting.
|
||
func stripDayWords(s string) string {
|
||
out := make([]string, 0, 8)
|
||
for _, f := range strings.Fields(s) {
|
||
if _, ok := lexicon.DayOffset(strings.Trim(f, ".,;:!?—–-()\"'«»")); ok {
|
||
continue
|
||
}
|
||
out = append(out, f)
|
||
}
|
||
return strings.Join(out, " ")
|
||
}
|
||
|
||
// notificationSummary picks the text that names the meeting: the title when it
|
||
// carries words, otherwise the body. The clock reading is stripped out — it
|
||
// already lives in the times, and FactValue renders it again.
|
||
func notificationSummary(n Notification) string {
|
||
for _, cand := range []string{n.Title, n.Text} {
|
||
s := strings.TrimSpace(stripDayWords(stripClock(cand)))
|
||
s = strings.Trim(s, " \t-–—,;:@|·")
|
||
s = strings.Join(strings.Fields(s), " ")
|
||
if hasLetters(s) {
|
||
return s
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
type clock struct{ hour, min int }
|
||
|
||
// parseTimeRange finds the first clock reading in s, and a second one if the
|
||
// text spells a range. Accepted separators between hours and minutes are ":"
|
||
// and "."; between the two ends of a range, "-", "–", "—" or "до".
|
||
//
|
||
// Bare hours ("в 14") are NOT accepted. Loose digits in a notification are far
|
||
// more often a count, a date or an unread badge than a meeting time, and an
|
||
// invented event is worse than no event.
|
||
func parseTimeRange(s string) (start clock, end *clock, ok bool) {
|
||
first, _, firstEnd, ok := nextClock(s, 0)
|
||
if !ok {
|
||
return clock{}, nil, false
|
||
}
|
||
sep := strings.TrimLeft(s[firstEnd:], " \t")
|
||
for _, p := range []string{"-", "–", "—", "до "} {
|
||
if !strings.HasPrefix(sep, p) {
|
||
continue
|
||
}
|
||
if second, _, _, ok2 := nextClock(strings.TrimPrefix(sep, p), 0); ok2 {
|
||
return first, &second, true
|
||
}
|
||
break
|
||
}
|
||
return first, nil, true
|
||
}
|
||
|
||
// nextClock scans s from byte offset `from` for the first HH:MM (or HH.MM) and
|
||
// returns it with the byte range it occupied. Digits and separators are ASCII,
|
||
// so byte offsets are safe over Cyrillic text.
|
||
func nextClock(s string, from int) (c clock, start, end int, ok bool) {
|
||
for i := from; i < len(s); i++ {
|
||
if !isDigit(s[i]) {
|
||
continue
|
||
}
|
||
j := i
|
||
for j < len(s) && isDigit(s[j]) {
|
||
j++
|
||
}
|
||
// A run longer than two digits is a year, an id or an unread count.
|
||
if j-i > 2 {
|
||
i = j
|
||
continue
|
||
}
|
||
if j >= len(s) || (s[j] != ':' && s[j] != '.') {
|
||
i = j
|
||
continue
|
||
}
|
||
k := j + 1
|
||
for k < len(s) && isDigit(s[k]) {
|
||
k++
|
||
}
|
||
if k-(j+1) != 2 {
|
||
i = j
|
||
continue
|
||
}
|
||
// Reject a group that is a link in a longer dotted or colon chain:
|
||
// "2026.08.15" would otherwise offer "08.15" as 08:15, and a deadline
|
||
// date invented as a meeting time is exactly the wrong kind of guess.
|
||
// A trailing ":ss" is fine — that is a time with seconds.
|
||
if i > 0 && (s[i-1] == '.' || s[i-1] == ':' || isDigit(s[i-1])) {
|
||
i = k
|
||
continue
|
||
}
|
||
if k < len(s) && s[k] == '.' && k+1 < len(s) && isDigit(s[k+1]) {
|
||
i = k
|
||
continue
|
||
}
|
||
hour, min := atoi(s[i:j]), atoi(s[j+1:k])
|
||
if hour > 23 || min > 59 {
|
||
i = k
|
||
continue
|
||
}
|
||
return clock{hour, min}, i, k, true
|
||
}
|
||
return clock{}, 0, 0, false
|
||
}
|
||
|
||
func isDigit(b byte) bool { return b >= '0' && b <= '9' }
|
||
|
||
func atoi(s string) int {
|
||
n := 0
|
||
for i := 0; i < len(s); i++ {
|
||
n = n*10 + int(s[i]-'0')
|
||
}
|
||
return n
|
||
}
|
||
|
||
// stripClock removes every clock reading from a summary candidate, along with
|
||
// the preposition or separator that introduced it.
|
||
func stripClock(s string) string {
|
||
for {
|
||
_, start, end, ok := nextClock(s, 0)
|
||
if !ok {
|
||
return s
|
||
}
|
||
head := trimTrailingPreposition(strings.TrimRight(s[:start], "0123456789:.-–— \t"))
|
||
s = strings.TrimSpace(strings.TrimSpace(head) + " " + strings.TrimSpace(s[end:]))
|
||
}
|
||
}
|
||
|
||
// trimTrailingPreposition drops the word that introduced a clock reading, so
|
||
// "Встреча в 14:00" becomes "Встреча" and "с 11:30 до 12:15 Созвон" does not
|
||
// keep a dangling "с". It repeats, because a range has two of them.
|
||
func trimTrailingPreposition(s string) string {
|
||
preps := []string{"в", "с", "до", "от", "at", "from", "to"}
|
||
for again := true; again; {
|
||
again = false
|
||
s = strings.TrimRight(s, " \t")
|
||
for _, p := range preps {
|
||
if s == p {
|
||
return ""
|
||
}
|
||
if strings.HasSuffix(s, " "+p) {
|
||
s = s[:len(s)-len(p)-1]
|
||
again = true
|
||
break
|
||
}
|
||
}
|
||
}
|
||
return s
|
||
}
|
||
|
||
func hasLetters(s string) bool {
|
||
for _, r := range s {
|
||
if unicode.IsLetter(r) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|