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>
240 lines
9.7 KiB
Markdown
240 lines
9.7 KiB
Markdown
# Phase 8 — Hub API: `/capabilities`, `/ask` (read-only), `/notify`
|
|
|
|
Status: **built, deployed and proven against the live service** (2026-10-15).
|
|
The live-Hub registration is the one remaining step; see *The Hub wiring* below.
|
|
|
|
Spec: `AGENTS.md` §4 FR-11, §8 (integrations), §10 rule 5 (`/ask` is read-only),
|
|
§11 Phase 8. Contract: `central_ai_llm_master/docs/ask-contract.md` (frozen).
|
|
|
|
## What the phase does
|
|
|
|
The service exposes the project side of the frozen Hub contract on its own port
|
|
(8792). The Hub reads `GET /capabilities` to route a question, calls `POST /ask`
|
|
to get one short answer, and may call `POST /notify` to have a person told
|
|
something. All three need the shared bearer token.
|
|
|
|
| Method | Path | Body / reply | Write? |
|
|
|---|---|---|---|
|
|
| `GET` | `/capabilities` | `{"project":"compass_nhs","topics":[…],"people":["harry","finn"]}` | no |
|
|
| `POST` | `/ask` | request `{"question","asker","person","source"}`; reply `{"answer","data","expires"}` or `{"answer":null,"reason"}` | **no** |
|
|
| `POST` | `/notify` | request `{"from","level","message","person"}`; reply `{"ok":true}` | Apprise only |
|
|
|
|
`GET /health` now also carries `"ok": true` and `"hub_api": true`, so the Hub's
|
|
existing health shape and this service's richer shape both work.
|
|
|
|
## Configuration
|
|
|
|
| Variable | Meaning |
|
|
|---|---|
|
|
| `SCHOOL_HUB_TOKEN` | Shared bearer token the Hub sends. Empty turns the three endpoints off with `503`; the dashboard is unaffected. |
|
|
|
|
The token is a secret. It lives only in `environment.d/school-service.env`
|
|
(gitignored). The Hub holds the same value as `HUB_PROJECT_COMPASS_NHS_TOKEN` in
|
|
`~/.config/environment.d/10-secrets.conf` and reaches this service through
|
|
`HUB_PROJECTS=compass_nhs=http://192.168.20.13:8792`.
|
|
|
|
## `/ask` — how the answer is built
|
|
|
|
The answers are deterministic. No model is on the path, so the Hub's 3 second
|
|
cap is never the reason an answer is missing. `/ask` reads only:
|
|
|
|
- `children` (find the person by name, case-insensitively),
|
|
- `items` (the child's stored Compass items),
|
|
- `scans` (the child's scan health).
|
|
|
|
It reuses `dashboard.Classify`, the same code that builds the dashboard and the
|
|
morning briefs, so the answer and the page never disagree.
|
|
|
|
The question picks a focus by plain keywords:
|
|
|
|
| The question mentions | The answer covers |
|
|
|---|---|
|
|
| "overdue", "not done", "behind" | overdue tasks |
|
|
| "this week" | overdue, then due this week |
|
|
| "notice", "message", "news" | school notices |
|
|
| "today", "timetable", "schedule", "class", "at school" | today's lessons, tasks due today, overdue |
|
|
| "due", "homework", "assessment", "exam" or anything else | the whole picture |
|
|
|
|
The reply is **one short sentence**, for example:
|
|
|
|
```
|
|
Harry has 8 school events today from 8:55am to 6:00pm and 6 overdue tasks.
|
|
```
|
|
|
|
The structured detail (per bucket, with subject, teacher and due time) is in
|
|
`data`; the Hub may ignore it. `expires` is the end of the family day.
|
|
|
|
### Fail loudly (FR-12, AGENTS.md §10 rule 2)
|
|
|
|
A failed or missing scan is **never** an empty day. `/ask` answers:
|
|
|
|
- no scan yet — `I have no school data for Harry yet: the Compass scan has not run, so due work is unknown.`
|
|
- last scan failed — `I cannot say what Harry has due: the last Compass scan on <time> failed, so due work is unknown.`
|
|
- last scan older than 36 hours — the normal answer plus `The last Compass scan for Harry was N days ago, so this may be out of date.`
|
|
|
|
`data.scan_ok` and `data.scan_stale` carry the same state for a caller that does
|
|
not read the sentence.
|
|
|
|
### Read-only
|
|
|
|
`internal/hub` depends on a `Reader` interface with three read methods and no
|
|
write method, so `/ask` and `/capabilities` cannot write. `TestHubAskReadsOnly`
|
|
also asserts that the `items`, `scans` and `notifications` row counts do not
|
|
change across `/ask` calls.
|
|
|
|
### Permission
|
|
|
|
The contract's rule 2 is enforced: a parent (`SCHOOL_APPRISE_PARENTS`, default
|
|
Jo and Sam) may ask about any child, and anyone else may ask about themselves
|
|
only. An unknown person returns `answer: null` with a reason; no data is leaked
|
|
between the children.
|
|
|
|
### Which child the question is about
|
|
|
|
The contract makes `person` optional and says it defaults to `asker`. The Hub
|
|
(as of AUD-116) forwards `person = asker`, so the spoken question *"what does
|
|
Harry have on today at school?"* arrives with `person: "sam"`. Because this
|
|
project is the expert on its own data, `resolveChild` falls back to the question
|
|
text when the requested person is not one of its children:
|
|
|
|
- The requested `person` wins when it names a child (so an explicit `person` is
|
|
never overridden).
|
|
- Otherwise the question is split into whole words and matched,
|
|
case-insensitively, against the child names this service already publishes in
|
|
`/capabilities`.
|
|
- Exactly one match resolves the child; two matches ("what do Harry and Finn
|
|
have on?") return `answer: null` + a reason, because the contract answers for
|
|
one person at most.
|
|
- The permission check still runs on the resolved child.
|
|
|
|
No model and no free-text guessing are involved, and a name is never matched
|
|
inside a longer word. This makes the Hub's current call shape answer correctly;
|
|
[AUD-117](/AUD/issues/AUD-117) (the Hub deriving `person` itself) remains a
|
|
useful Hub-side improvement, not a dependency of this phase.
|
|
|
|
## `/notify` — the interpretation
|
|
|
|
The contract defines `POST /notify` as project-to-Hub (the *project* posts to the
|
|
Hub so the Hub can notify a person). `AGENTS.md` FR-11 places `/notify` on this
|
|
service's Hub API. This phase follows the house interpretation already used by
|
|
the sibling `internet_use_control` service: this service **accepts** the
|
|
contract's notification shape and delivers it through Apprise to the named
|
|
person, and records every attempt in `notifications` with `scope = 'hub'`.
|
|
|
|
- An empty `person` defaults to Sam (the service owner).
|
|
- An unknown `person` is refused with `400` rather than sent to the wrong inbox.
|
|
- `level` maps to the Apprise class: `info` → info, `important` → warning,
|
|
`urgent` → failure.
|
|
- A delivery failure answers `502` and is recorded with `ok = 0`, no `sent_at`,
|
|
and the redacted error (FR-12).
|
|
|
|
This is the one write on the Hub surface. It never changes school data and never
|
|
touches the school.
|
|
|
|
## Commands
|
|
|
|
```bash
|
|
# The Phase 8 definition of done: /health, /capabilities, /ask, /notify auth.
|
|
make verify-phase8
|
|
|
|
# Also deliver one real /notify message to Sam.
|
|
deploy/verify-phase8.sh --send
|
|
```
|
|
|
|
## Evidence (2026-10-15)
|
|
|
|
### End-to-end through the live Hub (08:56 UTC / 19:56 AEDT)
|
|
|
|
The literal phase question, sent to the **Hub** on its inbound API with no
|
|
`person` field, after [AUD-116](/AUD/issues/AUD-116) registered `compass_nhs`:
|
|
|
|
```
|
|
POST http://192.168.20.13:8788/ask
|
|
{"text":"what does Harry have on today at school?","asker":"sam","source":"console"}
|
|
|
|
200
|
|
{
|
|
"status": "ok",
|
|
"request_id": "01M4ZC9G4TRXJ3MFSMJ66JSVQ1",
|
|
"answer": "Harry has 8 school events today from 8:55am to 6:00pm and 6 overdue tasks.",
|
|
"shape": "text",
|
|
"route": {"kind": "project", "project": "compass_nhs"},
|
|
"expires": "2026-10-15T13:00:00Z"
|
|
}
|
|
```
|
|
|
|
This is the Phase 8 definition of done: the Hub asks, and gets a correct answer.
|
|
|
|
### Against the service directly
|
|
|
|
`deploy/verify-phase8.sh` on `.13` against `http://127.0.0.1:8792`:
|
|
|
|
```
|
|
== GET /health ==
|
|
HTTP 200
|
|
{"ok":true,"status":"ok","service":"compass-teams-nhs","version":"0.6.0-phase8",
|
|
"database":"ok","scans":{…,"ok":true},"hub_api":true,"time":"2026-10-15T08:46:56Z"}
|
|
PASS /health 200 with ok true
|
|
|
|
== GET /capabilities without a token ==
|
|
PASS /capabilities refused an unsigned request
|
|
|
|
== GET /capabilities ==
|
|
HTTP 200
|
|
{"project":"compass_nhs","topics":["school","compass","homework","timetable",
|
|
"class","overdue","school notices"],"people":["harry","finn"]}
|
|
PASS /capabilities names compass_nhs
|
|
|
|
== POST /ask: what does Harry have on today at school? ==
|
|
HTTP 200
|
|
{"answer":"Harry has 8 school events today from 8:55am to 6:00pm and 6 overdue tasks.",
|
|
"data":{"child":"Harry","scan_ok":true,"scan_stale":false,"scan_age_hours":0,…},
|
|
"expires":"2026-10-15T13:00:00Z"}
|
|
PASS /ask returned an answer
|
|
|
|
== POST /notify: token proven without delivering ==
|
|
HTTP 400
|
|
{"ok":false,"error":"level must be info, important or urgent"}
|
|
PASS /notify rejected an invalid level (auth worked, nothing delivered)
|
|
|
|
== POST /notify: real delivery to Sam (--send) ==
|
|
HTTP 200
|
|
{"ok":true}
|
|
PASS /notify delivered a real message
|
|
```
|
|
|
|
The delivery log confirms both channels:
|
|
|
|
```
|
|
hub ask ... question="what does Harry have on today at school?" answered=true duration_ms=1
|
|
hub notify delivered from=verify-phase8 level=info recipient=Sam channel=email status=200
|
|
hub notify delivered from=verify-phase8 level=info recipient=Sam channel=whatsapp status=200
|
|
```
|
|
|
|
## The Hub wiring (done)
|
|
|
|
The Hub container (`central-ai-hub:local`, port 8788) now has this service
|
|
registered. The change was made by the Hub agent on
|
|
[AUD-116](/AUD/issues/AUD-116):
|
|
|
|
1. `~/.config/environment.d/10-secrets.conf` gained
|
|
`HUB_PROJECT_COMPASS_NHS_TOKEN` and `compass_nhs=http://192.168.20.13:8792`
|
|
in `HUB_PROJECTS` (alongside `email_agent` and `internet_control`).
|
|
2. The `hub` container was restarted once, bounded.
|
|
3. The transcript above is the result.
|
|
|
|
`routing.md` was not changed: the `compass_nhs` row already existed and matches
|
|
this service's `/capabilities` name.
|
|
|
|
## Decisions and notes
|
|
|
|
1. **The Hub-facing project name is `compass_nhs`** — the row already in the
|
|
Hub's human-owned `routing.md`. Changing the name would mean editing the Hub.
|
|
2. **Deterministic answers, no LLM.** The contract caps the call at 3 seconds
|
|
and a school question must answer on the first try. The sibling project made
|
|
the same choice.
|
|
3. **One sentence** for the answer, per the contract. The parenthetical stale
|
|
warning is the only second sentence, and only when the data is old.
|
|
4. **The token is one shared secret per project**, per the contract's
|
|
Authentication section.
|