Phase 3: parse stored Compass pages into structured items
Turn the raw pages stored by Phase 2 into rows in the SQLite items table (AGENTS.md §11 Phase 3, FR-3/FR-4/FR-5/FR-12). - internal/parse: dependency-free HTML reader plus extractors for the ExtJS learning-task grid (subject, title, category, kind, due date, status), the home news feed (author, posted time, title, body) and the calendar week grid (day column + grid offset give the time; label gives title/room/teacher). - internal/parse: fail loudly. A challenge page, login page, changed layout or unknown due-date format is an explicit error, never an empty day. - FR-4: structure is parsed deterministically; importance is classified by an OmniRoute model (default openrouter/google/gemini-3.5-flash-lite) with a deterministic fallback. The classifier used is printed and recorded. - internal/llm: small OpenAI-compatible OmniRoute client (handles SSE replies). - internal/store: migration 002 adds source_page, importance and external_ref with a unique (child_id, source, type, external_ref) index; UpsertItems, CountItemsByType, SampleItems and ChildByName. - cmd/school parse: reads var/raw/<child>/<run>, upserts items, records the scan row (ok=0 plus the error on failure) and prints counts and samples. - scripts/parse-compass.sh and make parse run it in a one-off container against the service's own volume without restarting the service. - docs/phase-3-parse-notes.md records the approach, the FR-4 decision and the known gaps. Evidence on the live database: 56 rows for Harry (20 task, 1 message, 35 event). Re-running the same raw pages inserts 0 and updates 56. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
163
internal/parse/parse.go
Normal file
163
internal/parse/parse.go
Normal file
@@ -0,0 +1,163 @@
|
||||
package parse
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Source and item types match the items table CHECK constraints (AGENTS.md §7).
|
||||
const (
|
||||
SourceCompass = "compass"
|
||||
|
||||
TypeMessage = "message"
|
||||
TypeTask = "task"
|
||||
TypeEvent = "event"
|
||||
)
|
||||
|
||||
// Recognised page slugs, matching the <slug>.html files stored by the Phase 2
|
||||
// fetchers under var/raw/<child>/<runId>/.
|
||||
const (
|
||||
PageHome = "home"
|
||||
PageChildProfile = "child-profile"
|
||||
PageLearningTasks = "learning-tasks"
|
||||
PageCalendar = "calendar"
|
||||
)
|
||||
|
||||
// Item is one message, learning task or calendar event read from Compass.
|
||||
// Every field maps to a column of the items table (FR-5).
|
||||
type Item struct {
|
||||
Child string `json:"child"`
|
||||
Source string `json:"source"`
|
||||
Type string `json:"type"`
|
||||
Subject string `json:"subject,omitempty"`
|
||||
Teacher string `json:"teacher,omitempty"`
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body,omitempty"`
|
||||
DueAt *time.Time `json:"due_at,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
SourcePage string `json:"source_page"`
|
||||
ExternalRef string `json:"external_ref"`
|
||||
Importance string `json:"importance"`
|
||||
}
|
||||
|
||||
// Page is one stored raw page: its slug, the URL it was fetched from, and the
|
||||
// HTML bytes.
|
||||
type Page struct {
|
||||
Slug string
|
||||
URL string
|
||||
HTML []byte
|
||||
}
|
||||
|
||||
// Parse turns the stored pages for one child into structured items. It fails,
|
||||
// loudly and explicitly, when a page it recognises cannot be parsed, so a broken
|
||||
// scan can never be shown as "nothing due" (FR-12).
|
||||
//
|
||||
// Pages with an unknown slug are ignored. When no recognised page yields any
|
||||
// structure at all, Parse returns an error.
|
||||
func Parse(child string, pages []Page, loc *time.Location) ([]Item, error) {
|
||||
if strings.TrimSpace(child) == "" {
|
||||
return nil, fmt.Errorf("parse: child is empty")
|
||||
}
|
||||
if loc == nil {
|
||||
loc = time.UTC
|
||||
}
|
||||
|
||||
var items []Item
|
||||
recognised := 0
|
||||
for _, p := range pagesOf(pages) {
|
||||
switch p.Slug {
|
||||
case PageLearningTasks:
|
||||
got, err := parseLearningTasks(child, p, loc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, got...)
|
||||
recognised++
|
||||
case PageChildProfile:
|
||||
// The React profile page has no server-rendered grid; when it does
|
||||
// carry the legacy grid, use it.
|
||||
got, found, err := extractLearningTasks(child, p, loc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if found {
|
||||
items = append(items, got...)
|
||||
}
|
||||
recognised++
|
||||
case PageHome:
|
||||
got, err := parseNews(child, p, loc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, got...)
|
||||
recognised++
|
||||
case PageCalendar:
|
||||
got, err := parseCalendar(child, p, loc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, got...)
|
||||
recognised++
|
||||
}
|
||||
}
|
||||
|
||||
if recognised == 0 {
|
||||
return nil, fmt.Errorf("parse: no recognised Compass page in %d stored page(s)", len(pages))
|
||||
}
|
||||
|
||||
// A task row is the same task wherever it appears (learning tasks and child
|
||||
// profile pages both carry the grid). Keep the first, drop the repeats.
|
||||
seen := map[string]bool{}
|
||||
out := items[:0]
|
||||
for _, it := range items {
|
||||
key := it.Type + "\x1f" + it.ExternalRef
|
||||
if seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
if it.Importance == "" {
|
||||
it.Importance = importanceNormal
|
||||
}
|
||||
out = append(out, it)
|
||||
}
|
||||
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
if out[i].Type != out[j].Type {
|
||||
return out[i].Type < out[j].Type
|
||||
}
|
||||
return out[i].ExternalRef < out[j].ExternalRef
|
||||
})
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// pagesOf returns the pages with duplicates removed, keeping the last copy of a
|
||||
// slug (the fetcher overwrites a slug file on a re-fetch, so the last is
|
||||
// authoritative).
|
||||
func pagesOf(pages []Page) []Page {
|
||||
bySlug := map[string]Page{}
|
||||
var order []string
|
||||
for _, p := range pages {
|
||||
if _, ok := bySlug[p.Slug]; !ok {
|
||||
order = append(order, p.Slug)
|
||||
}
|
||||
bySlug[p.Slug] = p
|
||||
}
|
||||
out := make([]Page, 0, len(order))
|
||||
for _, slug := range order {
|
||||
out = append(out, bySlug[slug])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// dedupeKey joins the identity fields of an item into a stable external
|
||||
// reference. Re-parsing the same page must update the same row, not insert a
|
||||
// second one.
|
||||
func dedupeKey(parts ...string) string {
|
||||
clean := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
clean = append(clean, collapse(p))
|
||||
}
|
||||
return strings.Join(clean, "|")
|
||||
}
|
||||
Reference in New Issue
Block a user