Files
compass_teams_nhs/internal/parse/importance.go
Sam Rolfe f6be17fe4f Phase 6: write important school events to the Home Assistant calendar
Add the Home Assistant REST calendar client and the idempotent sync that
writes important school events to Sam's Home Assistant calendar (FR-8). The
school stays read-only; the only write is to the calendar (rule 7).

- internal/calendar: HA REST client (states, create_event, get_events,
  delete_event) and the sync that selects important future events, adopts an
  existing event by its description marker, records the HA event id on the
  item, and records every attempt.
- internal/store: migration 003 (items.ha_event_uid, items.ha_pushed_at and
  the append-only calendar_pushes table), candidates, mark-synced and the
  push log.
- internal/parse: a school event is important; a routine class period is not.
- cmd/school: `school calendar entities|status|push|events`.
- web: an "Add to calendar" item action and a fail-loud calendar banner, so a
  failed write is never swallowed (FR-12).
- scripts/calendar-push.sh, Makefile target, env example, docs and tests.

The target is configurable (SCHOOL_HA_CALENDAR_ENTITY). Home Assistant has no
non-Google writable calendar today, so the entity is asked on AUD-110.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-15 18:01:30 +11:00

223 lines
7.1 KiB
Go

package parse
import (
"context"
"encoding/json"
"fmt"
"sort"
"strings"
"time"
)
// Importance labels stored in items.importance (FR-4).
const (
importanceHigh = "high"
importanceNormal = "normal"
importanceLow = "low"
)
// ImportanceClassifiers names the classifier used for a run, so the choice is
// recorded rather than implied.
const (
ClassifierNone = "deterministic"
ClassifierOmniRoute = "omniroute"
ClassifierJEV = "jev"
classifyBatchSize = 40
classifyMaxResponseChar = 16 * 1024
)
// Completer is one text-completion call. internal/llm supplies the OmniRoute
// client; tests supply a stub.
type Completer interface {
Complete(ctx context.Context, system, prompt string) (string, error)
}
// ClassifyDeterministic is the offline importance rule. Overdue work and work
// due within the next week is high; everything else is normal. It is the
// fallback when no model is configured, and it is deliberately conservative: it
// never marks work "done".
func ClassifyDeterministic(items []Item, now time.Time) {
week := now.AddDate(0, 0, 7)
for i := range items {
items[i].Importance = deterministicImportance(items[i], week)
}
}
func deterministicImportance(it Item, week time.Time) string {
// Only work carries a deadline that makes it important. A calendar entry's
// due_at is its start time, and a lesson that starts this week is not
// overdue work, so meetings stay normal under the offline rule.
if it.Type == TypeTask {
if it.DueAt != nil && it.DueAt.Before(week) {
return importanceHigh
}
if strings.Contains(strings.ToLower(it.Body), "overdue") {
return importanceHigh
}
}
// A school event is important; a routine class period is not (Phase 6
// writes only the important ones to the calendar, AGENTS.md §4 FR-8).
if it.Type == TypeEvent && eventIsSchoolEvent(it.Title) {
return importanceHigh
}
return importanceNormal
}
// eventIsSchoolEvent reports whether a calendar entry is a school event rather
// than a routine class period. Compass writes a lesson as
// "5 - 11HLEGB5 - D8 - LHT" and a school event as
// "End of Year Senior Music Concert - HALL (...) - KSH, CFR". The first part of
// a lesson is a period label ("5", "C" or "RLE GROUP D").
func eventIsSchoolEvent(title string) bool {
parts := splitDash(title)
if len(parts) == 0 || strings.TrimSpace(parts[0]) == "" {
return false
}
return !periodLabel.MatchString(parts[0])
}
// ClassifyModel asks a model to classify importance for every item (FR-4). It
// fails, loudly, on a model error or an unparseable answer; it never silently
// leaves a wrong label. Items are updated in place. now lets the model judge
// whether a due date is overdue.
func ClassifyModel(ctx context.Context, c Completer, items []Item, model string, now time.Time) error {
if c == nil {
return fmt.Errorf("classify: no model client configured")
}
const system = "You classify school items for a parent's morning brief. " +
"Reply with JSON only. No prose, no code fences."
for start := 0; start < len(items); start += classifyBatchSize {
end := start + classifyBatchSize
if end > len(items) {
end = len(items)
}
batch := items[start:end]
prompt := classifyPrompt(batch, model, now)
raw, err := c.Complete(ctx, system, prompt)
if err != nil {
return fmt.Errorf("classify: items %d-%d: %w", start, end-1, err)
}
labels, err := parseLabels(raw)
if err != nil {
return fmt.Errorf("classify: items %d-%d: %w", start, end-1, err)
}
for i, label := range labels {
if i >= len(batch) {
return fmt.Errorf("classify: items %d-%d: model returned %d labels for %d items", start, end-1, len(labels), len(batch))
}
switch label {
case importanceHigh, importanceNormal, importanceLow:
batch[i].Importance = label
default:
return fmt.Errorf("classify: item %d: invalid importance %q", start+i, label)
}
}
if len(labels) != len(batch) {
return fmt.Errorf("classify: items %d-%d: model returned %d labels for %d items", start, end-1, len(labels), len(batch))
}
}
return nil
}
// classifyPrompt builds the user message for one batch.
func classifyPrompt(batch []Item, model string, now time.Time) string {
type row struct {
I int `json:"i"`
Child string `json:"child"`
Type string `json:"type"`
Subject string `json:"subject,omitempty"`
Title string `json:"title"`
Due string `json:"due,omitempty"`
Detail string `json:"detail,omitempty"`
}
rows := make([]row, 0, len(batch))
for i, it := range batch {
due := ""
if it.DueAt != nil {
due = it.DueAt.Format(time.RFC3339)
}
rows = append(rows, row{
I: i,
Child: it.Child,
Type: it.Type,
Subject: it.Subject,
Title: it.Title,
Due: due,
Detail: detail(it.Body),
})
}
payload, _ := json.Marshal(rows)
var b strings.Builder
fmt.Fprintf(&b, "Model: %s\n", model)
fmt.Fprintf(&b, "Now: %s\n", now.Format(time.RFC3339))
b.WriteString("Label every item with importance high, normal or low.\n")
b.WriteString("high: overdue work, work due today or in the next 48 hours, or a school event that affects the family.\n")
b.WriteString("low: routine recurring lessons, past submitted work, or notices with no action.\n")
b.WriteString("normal: anything else.\n")
b.WriteString("A task with a due date before Now and a submission status of Pending or Overdue is high.\n")
b.WriteString("Return a JSON array with one object per item, in order, exactly: " +
`{"i":<index>,"importance":"high|normal|low"}` + "\n\nItems:\n")
b.Write(payload)
return b.String()
}
// detail flattens an item body into one line for the model.
func detail(s string) string {
return collapse(strings.ReplaceAll(s, "\n", "; "))
}
// parseLabels extracts the importance labels from a model reply. It tolerates
// code fences and surrounding prose, and returns the labels ordered by index.
func parseLabels(raw string) ([]string, error) {
raw = strings.TrimSpace(raw)
if len(raw) > classifyMaxResponseChar {
return nil, fmt.Errorf("model reply too large (%d bytes)", len(raw))
}
raw = strings.TrimPrefix(raw, "```json")
raw = strings.TrimPrefix(raw, "```")
raw = strings.TrimSuffix(raw, "```")
raw = strings.TrimSpace(raw)
if i := strings.IndexByte(raw, '['); i >= 0 {
if j := strings.LastIndexByte(raw, ']'); j > i {
raw = raw[i : j+1]
}
}
var entries []struct {
I *int `json:"i"`
Importance string `json:"importance"`
}
if err := json.Unmarshal([]byte(raw), &entries); err != nil {
return nil, fmt.Errorf("model reply is not JSON: %w", err)
}
if len(entries) == 0 {
return nil, fmt.Errorf("model reply has no items")
}
type indexed struct {
i int
label string
}
got := make([]indexed, 0, len(entries))
for pos, e := range entries {
idx := pos
if e.I != nil {
idx = *e.I
}
got = append(got, indexed{i: idx, label: strings.ToLower(strings.TrimSpace(e.Importance))})
}
sort.Slice(got, func(a, b int) bool { return got[a].i < got[b].i })
out := make([]string, len(got))
for k, g := range got {
if g.i < 0 || g.i >= len(got) {
return nil, fmt.Errorf("model returned out-of-range index %d", g.i)
}
out[k] = g.label
}
return out, nil
}