Files
Sam Rolfe 06f759c37b Phase 8 follow-up: resolve the child named in the question
The Hub forwards `person` equal to the asker (`PersonOrDefault`), so the spoken
question "what does Harry have on today at school?" arrives with person "sam"
and returned "no data for that person" (found by the Hub agent on AUD-116).

resolveChild now falls back to the question text when the requested person is
not one of this project's children: whole-word, case-insensitive match against
the child names this service already publishes in /capabilities. An explicit
person still wins, two named children still return answer:null, and the
permission check still runs on the resolved child. No model, no free text.

Live end-to-end through the Hub, with no person field:

  POST http://192.168.20.13:8788/ask
  {"text":"what does Harry have on today at school?","asker":"sam","source":"console"}
  200
  {"status":"ok",
   "answer":"Harry has 8 school events today from 8:55am to 6:00pm and 6 overdue tasks.",
   "route":{"kind":"project","project":"compass_nhs"}}

Image 0.6.1-phase8; docs/phase-8-hub-notes.md records the fallback and the
end-to-end transcript.

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

549 lines
17 KiB
Go

// 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"
"unicode"
"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 := resolveChild(children, person, req.Question)
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
}
// resolveChild picks the child a question is about.
//
// The contract makes `person` optional and says it defaults to `asker`. The Hub
// (as of AUD-116) forwards `person = asker`, so a spoken question such as "what
// does Harry have on today at school?" arrives as person "sam". This project is
// the expert on its own data: when the requested person is not one of its
// children, it looks for exactly one child named in the question text. The name
// must be one the service already publishes in /capabilities, the match is
// whole-word and case-insensitive, and the permission check still runs
// afterwards, so nothing is guessed and no data leaks between children.
func resolveChild(children []store.Child, person, question string) *store.Child {
if c := findChild(children, person); c != nil {
return c
}
words := wordsIn(question)
var named []*store.Child
for i := range children {
if words[normalize(children[i].Name)] {
named = append(named, &children[i])
}
}
if len(named) == 1 {
return named[0]
}
return nil
}
// wordsIn lower-cases a question and splits it into whole words, so "harry"
// matches in "what does Harry have on?" but never inside a longer word.
func wordsIn(s string) map[string]bool {
out := map[string]bool{}
var b strings.Builder
flush := func() {
if b.Len() > 0 {
out[b.String()] = true
b.Reset()
}
}
for _, r := range s {
if unicode.IsLetter(r) || unicode.IsDigit(r) {
b.WriteRune(unicode.ToLower(r))
continue
}
flush()
}
flush()
return out
}
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}
}