Files
Sam Rolfe d7d529eb0f 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>
2026-10-15 17:27:25 +11:00

324 lines
7.7 KiB
Go

// Package parse turns stored Compass pages into structured items (AGENTS.md
// §4 FR-3/FR-4/FR-5, §11 Phase 3). It is a deterministic, dependency-free HTML
// reader: the Compass pages are machine-generated, so parsing the markup is more
// reliable than asking a model to transcribe a table.
package parse
import (
"bytes"
"html"
"strings"
)
// voidElements never have children and are therefore never pushed on the stack.
var voidElements = map[string]bool{
"area": true, "base": true, "br": true, "col": true, "embed": true,
"hr": true, "img": true, "input": true, "link": true, "meta": true,
"param": true, "source": true, "track": true, "wbr": true,
}
// rawTextElements have text content that must not be tokenised as markup.
var rawTextElements = map[string]bool{
"script": true, "style": true, "textarea": true,
}
// node is one element in the parsed document. Text content is stored as
// "#text" children so that text and elements keep their document order; use
// Text for the concatenated descendant text.
type node struct {
Tag string
Attrs map[string]string
Kids []*node
Parent *node
text string
}
// Class returns the element's class attribute.
func (n *node) Class() string { return n.Attrs["class"] }
// hasClass reports whether the element carries the exact class.
func (n *node) hasClass(cls string) bool {
for _, f := range strings.Fields(n.Class()) {
if f == cls {
return true
}
}
return false
}
// hasClassPart reports whether any class token contains part.
func (n *node) hasClassPart(part string) bool {
for _, f := range strings.Fields(n.Class()) {
if strings.Contains(f, part) {
return true
}
}
return false
}
// hasAttr reports whether the attribute is present with a non-empty value.
func (n *node) hasAttr(key string) bool { return strings.TrimSpace(n.Attrs[key]) != "" }
// Text returns the concatenated text of the element and all descendants,
// whitespace-collapsed.
func (n *node) Text() string {
var b strings.Builder
n.writeText(&b)
return collapse(b.String())
}
func (n *node) writeText(b *strings.Builder) {
b.WriteString(n.text)
for _, k := range n.Kids {
k.writeText(b)
}
}
func collapse(s string) string { return strings.Join(strings.Fields(s), " ") }
// textUntil returns the concatenated text of the node's descendants up to and
// including the first matching direct-or-descendant element, exclusive of that
// element's subtree.
func (n *node) textUntil(pred func(*node) bool) string {
var b strings.Builder
var w func(x *node) bool
w = func(x *node) bool {
if pred(x) {
return false
}
b.WriteString(x.text)
for _, k := range x.Kids {
if !w(k) {
return false
}
}
return true
}
w(n)
return collapse(b.String())
}
// parseHTML tokenises and nests the byte slice. It never fails: malformed
// markup, stray tags and unclosed elements are tolerated, because ExtJS emits
// group headers whose <td> is not closed before the next table starts.
func parseHTML(data []byte) *node {
root := &node{Tag: "#root", Attrs: map[string]string{}}
stack := []*node{root}
n := len(data)
i := 0
top := func() *node { return stack[len(stack)-1] }
for i < n {
if data[i] != '<' {
j := bytes.IndexByte(data[i:], '<')
if j < 0 {
j = n - i
}
appendText(top(), string(data[i:i+j]))
i += j
continue
}
switch {
case bytes.HasPrefix(data[i:], []byte("<!--")):
end := bytes.Index(data[i:], []byte("-->"))
if end < 0 {
i = n
continue
}
i += end + 3
case bytes.HasPrefix(data[i:], []byte("<!")) || bytes.HasPrefix(data[i:], []byte("<?")):
end := bytes.IndexByte(data[i:], '>')
if end < 0 {
i = n
continue
}
i += end + 1
case bytes.HasPrefix(data[i:], []byte("</")):
k := i + 2
start := k
for k < n && isNameChar(data[k]) {
k++
}
name := strings.ToLower(string(data[start:k]))
gt := bytes.IndexByte(data[k:], '>')
if gt < 0 {
i = n
continue
}
i = k + gt + 1
stack = closeTag(stack, name)
default:
k := i + 1
start := k
for k < n && isNameChar(data[k]) {
k++
}
name := strings.ToLower(string(data[start:k]))
if name == "" { // a bare '<'
appendText(top(), "<")
i++
continue
}
attrs, selfClose, next := parseAttrs(data, k)
i = next
child := &node{Tag: name, Attrs: attrs, Parent: top()}
top().Kids = append(top().Kids, child)
if selfClose || voidElements[name] {
continue
}
stack = append(stack, child)
if rawTextElements[name] {
// Skip the content; scripts and styles are not parsed as markup.
if idx := indexFold(data[i:], "</"+name); idx >= 0 {
i += idx
} else {
i = n
}
}
}
}
return root
}
// parseAttrs reads tag attributes starting at p (just past the tag name). It
// returns the attributes, whether the tag closed with "/>", and the index just
// past the closing '>'.
func parseAttrs(data []byte, p int) (map[string]string, bool, int) {
attrs := map[string]string{}
selfClose := false
n := len(data)
for p < n {
for p < n && isSpace(data[p]) {
p++
}
if p >= n {
break
}
if data[p] == '>' {
p++
break
}
if data[p] == '/' {
selfClose = true
p++
continue
}
start := p
for p < n && !isSpace(data[p]) && data[p] != '=' && data[p] != '>' && data[p] != '/' {
p++
}
name := strings.ToLower(string(data[start:p]))
q := p
for q < n && isSpace(data[q]) {
q++
}
var value string
if q < n && data[q] == '=' {
q++
for q < n && isSpace(data[q]) {
q++
}
if q < n && (data[q] == '"' || data[q] == '\'') {
quote := data[q]
q++
v := q
for q < n && data[q] != quote {
q++
}
value = string(data[v:q])
if q < n {
q++
}
} else {
v := q
for q < n && !isSpace(data[q]) && data[q] != '>' {
q++
}
value = string(data[v:q])
}
p = q
}
if name != "" {
attrs[name] = html.UnescapeString(value)
}
}
return attrs, selfClose, p
}
// appendText appends an entity-decoded text node to the parent, keeping its
// position in document order.
func appendText(n *node, s string) {
if s == "" {
return
}
n.Kids = append(n.Kids, &node{Tag: "#text", text: html.UnescapeString(s), Parent: n})
}
// closeTag pops elements up to and including the nearest matching tag and
// returns the remaining stack.
func closeTag(stack []*node, name string) []*node {
for i := len(stack) - 1; i > 0; i-- {
if stack[i].Tag == name {
return stack[:i]
}
}
return stack
}
func isNameChar(c byte) bool {
return c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' ||
c >= '0' && c <= '9' || c == '-' || c == '_' || c == ':' || c == '.'
}
func isSpace(c byte) bool { return c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '\f' }
// indexFold finds needle in haystack, ignoring ASCII case.
func indexFold(haystack []byte, needle string) int {
if len(needle) == 0 {
return 0
}
lower := bytes.ToLower(haystack)
return bytes.Index(lower, []byte(strings.ToLower(needle)))
}
// walk visits n and its descendants in document order. When fn returns false the
// node's children are skipped; siblings are still visited. To abort a whole
// traversal, the predicate must track its own stop flag.
func walk(n *node, fn func(*node) bool) {
if !fn(n) {
return
}
for _, k := range n.Kids {
walk(k, fn)
}
}
// find returns the first descendant (including n) matching pred.
func find(n *node, pred func(*node) bool) *node {
if pred(n) {
return n
}
for _, k := range n.Kids {
if got := find(k, pred); got != nil {
return got
}
}
return nil
}
// findAll returns every descendant (including n) matching pred, in document order.
func findAll(n *node, pred func(*node) bool) []*node {
var out []*node
walk(n, func(x *node) bool {
if pred(x) {
out = append(out, x)
}
return true
})
return out
}