// Package hub implements the project side of the frozen Hub contract // (central_ai_llm_master/docs/ask-contract.md) for the Compass / NHS service: // // GET /capabilities -> the topics this project can answer (Hub routing) // POST /ask -> a short, read-only answer about school work // POST /notify -> accept the contract's notification shape // // The Hub is the expert on routing and reply shape. It must never learn this // project's data model, so /ask answers in one short sentence and never exposes // a token, a path or a database id. /ask and /capabilities are read-only: they // call only store read methods, so "read only" is a testable property // (TestAskReadsOnly), not a comment (AGENTS.md §10 rule 5, FR-11). // // The answers are deterministic. No model is in the path: the Hub already caps // the call at 3 seconds, and a school question must answer on the first try. package hub import ( "context" "fmt" "strings" "time" "gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/dashboard" "gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/store" ) // ProjectName is the value the Hub matches in routing.md. The Hub's topic map // (and central_ai_llm_master/routing.md) already names this project // `compass_nhs`, and the contract requires /capabilities to report the same // name, so this is the Hub-facing name. The repository, module and service keep // the fuller "Compass / NHS" name. const ProjectName = "compass_nhs" // DefaultStaleAfter is how old the last successful scan may be before /ask says // so. The 6:00am schedule scans daily, so a day and a half means the schedule // did not run (AGENTS.md §13 decision 7). const DefaultStaleAfter = 36 * time.Hour // Capabilities is the GET /capabilities reply. The Hub reads it to route a // question to this project. Keep the topic list short and plain; it holds no // private data. type Capabilities struct { Project string `json:"project"` Topics []string `json:"topics"` People []string `json:"people"` } // DefaultCapabilities returns the topics this project answers. They match the // school domain in AGENTS.md §1 and §4 and the Hub's routing.md row. func DefaultCapabilities() Capabilities { return Capabilities{ Project: ProjectName, Topics: []string{"school", "compass", "homework", "timetable", "class", "overdue", "school notices"}, People: []string{"harry", "finn"}, } } // AskRequest is the POST /ask body (ask-contract.md). type AskRequest struct { Question string `json:"question"` Asker string `json:"asker"` Person string `json:"person,omitempty"` Source string `json:"source,omitempty"` } // AskResponse is the POST /ask reply. A nil Answer means the project cannot // answer and Reason explains why. The shape is frozen by the contract. type AskResponse struct { Answer *string `json:"answer"` Data map[string]any `json:"data,omitempty"` Expires string `json:"expires,omitempty"` Reason string `json:"reason,omitempty"` } // NotifyRequest is the POST /notify body (ask-contract.md, "Notify"). The // contract defines it as project-to-Hub; this service accepts the same shape so // the Hub (or a future caller) can have a person notified without inventing a // second shape. Delivery is the only write on this surface. type NotifyRequest struct { From string `json:"from"` Level string `json:"level"` Message string `json:"message"` Person string `json:"person,omitempty"` } // Reader is the read-only slice of the store that /ask and /capabilities use. // It is an interface so the answer logic is testable without a database, and so // the "read only" promise is enforced by the type: there is no write method to // call. type Reader interface { ListChildren(ctx context.Context) ([]store.Child, error) ItemsForChild(ctx context.Context, childID int64) ([]store.Item, error) ScanSummaryForChild(ctx context.Context, childID int64) (store.ScanStatus, error) } // Answerer answers /ask from stored school items. It holds no state of its own // beyond the clock, so the same instance serves every request. type Answerer struct { reader Reader loc *time.Location // Now is the clock. It is a field so tests are deterministic. Now func() time.Time // StaleAfter is how old the last scan may be before the answer says so. // Zero means DefaultStaleAfter. StaleAfter time.Duration // Parents are the askers who may see any child. Every other asker may see // only themselves (ask-contract.md, "Rules for the project" rule 2). Parents []string } // NewAnswerer builds an answerer for the given reader and family timezone. func NewAnswerer(reader Reader, loc *time.Location) *Answerer { if loc == nil { loc = time.UTC } return &Answerer{ reader: reader, loc: loc, Now: time.Now, StaleAfter: DefaultStaleAfter, Parents: []string{"sam", "jo"}, } } // SetParents replaces the askers who may see any child. func (a *Answerer) SetParents(names ...string) { clean := make([]string, 0, len(names)) for _, n := range names { if n = normalize(n); n != "" { clean = append(clean, n) } } if len(clean) > 0 { a.Parents = clean } } // Answer answers one question about this project's own data. It always returns // a value: either an answer or a reason. It never guesses and never returns an // answer for a question it cannot place. func (a *Answerer) Answer(ctx context.Context, req AskRequest) AskResponse { if strings.TrimSpace(req.Question) == "" { return cannotAnswer("the question is required") } asker := normalize(req.Asker) if asker == "" { return cannotAnswer("the asker is required") } person := normalize(req.Person) if person == "" { person = asker } children, err := a.reader.ListChildren(ctx) if err != nil { return cannotAnswer("the school data could not be read") } child := findChild(children, person) if child == nil { return cannotAnswer("no data for that person") } if !a.maySee(asker, child.Name) { return cannotAnswer("that person is not allowed to ask about " + child.Name) } items, err := a.reader.ItemsForChild(ctx, child.ID) if err != nil { return cannotAnswer("the school data could not be read") } scan, err := a.reader.ScanSummaryForChild(ctx, child.ID) if err != nil { return cannotAnswer("the scan state could not be read") } now := a.Now().In(a.loc) buckets := dashboard.Classify(items, now, a.loc) state := scanHealthOf(scan, now, a.staleAfter()) resp := a.compose(child, buckets, state, req.Question, now) return resp } // scanHealth is the fail-loudly view of the scan for one child (FR-12). It never // lets a failed or missing scan read as "nothing due". type scanHealth struct { OK bool EverRun bool Stale bool Age time.Duration At *time.Time Error string } func scanHealthOf(st store.ScanStatus, now time.Time, staleAfter time.Duration) scanHealth { s := scanHealth{EverRun: st.EverRun} if st.Last == nil { return s } at := st.Last.StartedAt.In(now.Location()) s.At = &at s.Age = now.Sub(at) if !st.Last.OK { s.Error = strings.TrimSpace(st.Last.Error) return s } s.OK = true s.Stale = staleAfter > 0 && s.Age > staleAfter return s } // compose turns one child's buckets into the contract's reply. The answer is // one short sentence (with a second sentence only when the data is stale), and // the structured detail lives in `data` for the Hub to ignore or use. func (a *Answerer) compose(child *store.Child, b dashboard.Buckets, scan scanHealth, question string, now time.Time) AskResponse { data := map[string]any{ "child": child.Name, "scan_ok": scan.OK, "scan_stale": scan.Stale, } if scan.At != nil { data["scan_at"] = scan.At.UTC().Format(time.RFC3339) } if scan.Age > 0 { data["scan_age_hours"] = int(scan.Age.Hours()) } if scan.Error != "" { data["scan_error"] = scan.Error } // Fail loudly: a missing or failed scan is reported as its own answer, never // as an empty day (FR-12, AGENTS.md §10 rule 2). if !scan.OK { return AskResponse{ Answer: answerText(unavailableAnswer(child.Name, scan)), Data: data, } } data["overdue"] = itemList(b.Overdue, a.loc) data["due_today"] = itemList(b.DueToday, a.loc) data["due_week"] = itemList(b.DueWeek, a.loc) data["messages"] = itemList(b.Messages, a.loc) text := a.sentence(child.Name, b, focusFor(question), a.loc) if scan.Stale { text += " " + staleNote(child.Name, scan.Age) } return AskResponse{ Answer: answerText(text), Data: data, Expires: endOfDay(now).UTC().Format(time.RFC3339), } } // focus is the part of the data the question asks about. type focus int const ( focusAll focus = iota focusToday focusWeek focusOverdue focusMessages ) // focusFor reads the question. It is deliberately simple: the Hub routes the // question, so this project only has to place the few school shapes. An unknown // question still answers with the whole picture rather than guessing a bucket. func focusFor(question string) focus { q := strings.ToLower(question) switch { case contains(q, "overdue", "not done", "outstanding", "late work", "behind"): return focusOverdue case contains(q, "this week", "next week", "week"): return focusWeek case contains(q, "message", "notice", "news", "email", "announcement"): return focusMessages case contains(q, "today", "timetable", "schedule", "class", "period", "lesson", "at school", "on at school"): return focusToday case contains(q, "due", "homework", "assignment", "assessment", "task", "sac", "exam"): return focusAll default: return focusAll } } // sentence builds the one-sentence answer for a focus. Every part is a phrase // ("8 classes today from 8:55am", "2 overdue tasks"), so the joined sentence // stays grammatical. func (a *Answerer) sentence(name string, b dashboard.Buckets, f focus, loc *time.Location) string { events := onlyType(b.DueToday, "event") today := onlyType(b.DueToday, "task") var parts []string add := func(s string) { if s != "" { parts = append(parts, s) } } overdue := countPhrase(len(b.Overdue), "overdue task") week := countPhrase(len(b.DueWeek), "task due this week") switch f { case focusOverdue: add(overdue) if len(parts) == 0 { add(countPhrase(len(today), "task due today")) } case focusWeek: add(overdue) add(week) case focusMessages: add(countPhrase(len(b.Messages), "school notice")) case focusToday: add(eventsPhrase(events, loc)) add(countPhrase(len(today), "task due today")) add(overdue) default: // focusAll add(eventsPhrase(events, loc)) add(countPhrase(len(today), "task due today")) add(overdue) add(week) } if len(parts) == 0 { return name + " has nothing due today." } return name + " has " + joinParts(parts) + "." } // eventsPhrase is the today's-schedule part: "8 classes today from 8:55am". func eventsPhrase(events []store.Item, loc *time.Location) string { if len(events) == 0 { return "" } first, last := time.Time{}, time.Time{} for _, it := range events { if it.DueAt == nil { continue } t := it.DueAt.In(loc) if first.IsZero() || t.Before(first) { first = t } if last.IsZero() || t.After(last) { last = t } } word := "events" if len(events) == 1 { word = "event" } if first.IsZero() { return fmt.Sprintf("%d school %s today", len(events), word) } if first.Format("15:04") == last.Format("15:04") { return fmt.Sprintf("%d school %s today from %s", len(events), word, clock(first)) } return fmt.Sprintf("%d school %s today from %s to %s", len(events), word, clock(first), clock(last)) } // unavailableAnswer is the fail-loudly sentence for a missing or failed scan. func unavailableAnswer(name string, scan scanHealth) string { if !scan.EverRun { return fmt.Sprintf("I have no school data for %s yet: the Compass scan has not run, so due work is unknown.", name) } if scan.At != nil { return fmt.Sprintf("I cannot say what %s has due: the last Compass scan on %s failed, so due work is unknown.", name, scan.At.In(time.UTC).Format("Mon 02 Jan 15:04 MST")) } return fmt.Sprintf("I cannot say what %s has due: the last Compass scan failed, so due work is unknown.", name) } // staleNote qualifies an answer built on old data, so an out-of-date list is // never read as today's work. func staleNote(name string, age time.Duration) string { days := int(age.Hours() / 24) if days >= 2 { return fmt.Sprintf("The last Compass scan for %s was %d days ago, so this may be out of date.", name, days) } return fmt.Sprintf("The last Compass scan for %s was %d hours ago, so this may be out of date.", name, int(age.Hours())) } // maySee checks the contract's permission rule: a parent may see any child, and // anyone else may see only themselves. func (a *Answerer) maySee(asker, child string) bool { a2, c := normalize(asker), normalize(child) if a2 == c { return true } for _, p := range a.Parents { if normalize(p) == a2 { return true } } return false } func (a *Answerer) staleAfter() time.Duration { if a.StaleAfter > 0 { return a.StaleAfter } return DefaultStaleAfter } // findChild matches a name case-insensitively: the Hub lower-cases names, while // the database stores "Harry". func findChild(children []store.Child, name string) *store.Child { for i := range children { if normalize(children[i].Name) == name { return &children[i] } } return nil } func onlyType(items []store.Item, typ string) []store.Item { out := make([]store.Item, 0, len(items)) for _, it := range items { if it.Type == typ { out = append(out, it) } } return out } // itemList is the structured detail for `data`. It holds no secret and no path. func itemList(items []store.Item, loc *time.Location) []map[string]any { out := make([]map[string]any, 0, len(items)) for _, it := range items { m := map[string]any{ "type": it.Type, "title": strings.TrimSpace(it.Title), } if s := strings.TrimSpace(it.Subject); s != "" { m["subject"] = s } if t := strings.TrimSpace(it.Teacher); t != "" { m["teacher"] = t } if it.DueAt != nil { m["due_at"] = it.DueAt.In(loc).Format(time.RFC3339) } out = append(out, m) } return out } func countPhrase(n int, noun string) string { switch { case n == 0: return "" case n == 1: return "1 " + noun default: return fmt.Sprintf("%d %ss", n, noun) } } // joinParts renders "a", "a and b" or "a, b and c". func joinParts(parts []string) string { switch len(parts) { case 0: return "" case 1: return parts[0] case 2: return parts[0] + " and " + parts[1] default: return strings.Join(parts[:len(parts)-1], ", ") + " and " + parts[len(parts)-1] } } func contains(haystack string, needles ...string) bool { for _, n := range needles { if strings.Contains(haystack, n) { return true } } return false } func clock(t time.Time) string { return t.Format("3:04pm") } func endOfDay(t time.Time) time.Time { return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location()).AddDate(0, 0, 1) } func normalize(s string) string { return strings.ToLower(strings.TrimSpace(s)) } // answerText is a small helper so pointer handling stays in one place. func answerText(s string) *string { return &s } // cannotAnswer builds the contract's "cannot answer" reply. func cannotAnswer(reason string) AskResponse { return AskResponse{Answer: nil, Reason: reason} }