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>
233 lines
7.5 KiB
Go
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
|
|
}
|