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>
549 lines
17 KiB
Go
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}
|
|
}
|