Files
compass_teams_nhs/internal/parse/importance.go
Sam Rolfe da57efad73 Phase 6: fix the live Home Assistant write (Local Calendar, no event id)
The first live run against the new calendar.school entity failed loudly twice,
and both causes are fixed here.

- The Local Calendar rejects `return_response` on calendar.create_event. The
  client retries the call without it and ignores the changed-states array that
  Home Assistant returns instead.
- Home Assistant then reports no event id. The item keeps the description
  marker ([compass-nhs:<id>]) as its reference, so it leaves the candidate set
  and is never written twice. The live retry proved it: both events already in
  the calendar were adopted by their markers, with no duplicate.
- calendar.get_events reports `start` / `end` (a date-time or a bare date), not
  `start_date_time`; the reader now accepts all three shapes.

A model must no longer classify events. The calendar write depends on the
school-event rule, and the LLM labelled every event "normal"; events now keep
the deterministic rule under both classifiers.

Also fix the Phase 5 screenshot script: the calendar banner shares the `.scan`
class, so select each banner by its labelled section.

Docs record the accepted decision: Sam chose a Home Assistant Local Calendar,
so the target is calendar.school.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-15 19:09:06 +11:00

233 lines
7.5 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.
//
// Calendar events keep the deterministic rule. A model must not decide whether
// a class period is a "school event": the calendar write depends on that call
// (FR-8), and a wrong label there either spams the family calendar with every
// lesson or silently drops the notice they needed.
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")
}
ClassifyDeterministic(items, now)
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))
}
if batch[i].Type == TypeEvent {
continue // events keep the deterministic rule
}
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
}