diff --git a/Makefile b/Makefile index 50715be..c74155d 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ -IMAGE ?= compass-teams-nhs/school:0.5.0-phase7 -BUILD_VERSION ?= 0.5.0-phase7 +IMAGE ?= compass-teams-nhs/school:0.6.0-phase8 +BUILD_VERSION ?= 0.6.0-phase8 # Parse options (see scripts/parse-compass.sh). CHILD ?= Harry @@ -19,7 +19,7 @@ GO_RUN = timeout 600 docker run --rm --network host --user "$$(id -u):$$(id -g)" -e HOME=/src/.gocache -e GOMODCACHE=/src/.gocache/mod -e GOCACHE=/src/.gocache/build \ -v "$$PWD":/src -w /src $(GO_IMAGE) -.PHONY: help test vet build up down restart logs ps deps fmt hash-password gen-secret verify-phase1 parse scan calendar notify morning-brief +.PHONY: help test vet build up down restart logs ps deps fmt hash-password gen-secret verify-phase1 verify-phase8 parse scan calendar notify morning-brief help: @echo "make test run go test ./... in golang:1.25-alpine" @@ -39,6 +39,7 @@ help: @echo "make preview print the briefs without sending (Phase 7)" @echo "make morning-brief scan both children, then send the briefs (the n8n job)" @echo "make verify-phase1 prove /health, the login and the Caddy route" + @echo "make verify-phase8 prove the Hub API: /capabilities, /ask, /notify (--send delivers)" test: $(GO_RUN) go test ./... @@ -105,3 +106,8 @@ morning-brief: # Caddy route on .35. verify-phase1: deploy/verify-phase1.sh + +# Phase 8 definition of done: the Hub API answers and authenticates. Pass +# ARGS=--send to deliver one real /notify message to Sam. +verify-phase8: + deploy/verify-phase8.sh $(ARGS) diff --git a/README.md b/README.md index da5f2bb..f87fe37 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,36 @@ A school service for Harry and Finn: **Compass** for the daily schedule and notices, **Teams** for homework and assignments. It stores what is due per child, puts overdue work first, and tells the family before work is late. -> Build spec: [`AGENTS.md`](AGENTS.md). This repository is at **Phase 7** (notify -> and send via Apprise). See §11 of the spec for the phase order. +> Build spec: [`AGENTS.md`](AGENTS.md). This repository is at **Phase 8** (the +> Hub API). See §11 of the spec for the phase order. + +## Status — Phase 8 + +Phase 8 exposes the project side of the frozen Hub contract +(`central_ai_llm_master/docs/ask-contract.md`) on this service's own port +(FR-11): + +| Method | Path | Notes | +|---|---|---| +| `GET` | `/capabilities` | `{"project":"compass_nhs","topics":[…],"people":[…]}`, matching the Hub's `routing.md` row. | +| `POST` | `/ask` | **Read-only.** Answers from the stored Compass items in one short sentence; returns the contract's `answer: null` + `reason` when it cannot answer. A failed or stale scan is stated in the answer, never as "nothing due" (FR-12). | +| `POST` | `/notify` | Accepts the contract's `{"from","level","message","person"}` and delivers it through Apprise. | + +All three require `Authorization: Bearer $SCHOOL_HUB_TOKEN`. With no token set +they answer `503`. See [`docs/phase-8-hub-notes.md`](docs/phase-8-hub-notes.md). + +```bash +make verify-phase8 # /health ok, /capabilities, /ask, /notify auth +deploy/verify-phase8.sh --send # also delivers one real /notify message to Sam + +# The Hub's own client shape, against the live service: +curl -sS -H "Authorization: Bearer $SCHOOL_HUB_TOKEN" -H 'Content-Type: application/json' \ + -d '{"question":"what does Harry have on today at school?","asker":"sam","person":"harry"}' \ + http://127.0.0.1:8792/ask +``` + +The `/ask` answers are deterministic (no model on the path), so the Hub's 3 +second cap is never the reason an answer is missing. ## Status — Phase 7 @@ -98,8 +126,8 @@ Built and deployed on `.13`: - Its own container and port **8792**, behind Caddy at `school.lab.audasmedia.com.au`. -Not built yet (later phases): the Hub API. The service itself does **not** scan -the school; n8n triggers the scan. Teams (FR-15) is a separate source. +Not built yet (later phases): Teams (FR-15) and the Phase 9 documentation set. +The service itself does **not** scan the school; n8n triggers the scan. ### Fail loudly (FR-12) @@ -120,6 +148,7 @@ internal/parse/ HTML -> structured items (tasks, messages, events) internal/llm/ OmniRoute client (importance classification only) internal/apprise/ Apprise API client (tag-based, redacts errors) internal/brief/ build + deliver the morning briefs (FR-9, FR-14) +internal/hub/ the Hub contract: capabilities + read-only answers (FR-11) internal/calendar/ Home Assistant calendar client + the idempotent sync internal/auth/ session login (signed cookies, CSRF) internal/web/ HTTP handlers, templates, static assets @@ -139,8 +168,8 @@ docker compose up -d --build Generate secrets with the built image: ```bash -docker run --rm --entrypoint /app/school compass-teams-nhs/school:0.5.0-phase7 gen-secret -docker run --rm --entrypoint /app/school compass-teams-nhs/school:0.5.0-phase7 hash-password 'the-password' +docker run --rm --entrypoint /app/school compass-teams-nhs/school:0.6.0-phase8 gen-secret +docker run --rm --entrypoint /app/school compass-teams-nhs/school:0.6.0-phase8 hash-password 'the-password' ``` Note: Docker Compose interpolates `$VAR` inside `env_file`. A bcrypt hash @@ -149,7 +178,8 @@ contains `$`, so escape each one as `$$` in the env file. ## Verify ```bash -make verify-phase1 +make verify-phase1 # container, /health, login, the Caddy route +make verify-phase8 # the Hub API: /capabilities, /ask, /notify ``` It checks the container is healthy, `GET /health` returns 200, the login page diff --git a/deploy/verify-phase8.sh b/deploy/verify-phase8.sh new file mode 100755 index 0000000..ba1e019 --- /dev/null +++ b/deploy/verify-phase8.sh @@ -0,0 +1,120 @@ +#!/usr/bin/env bash +# Phase 8 definition of done (AUD-112). +# +# - GET /capabilities returns the routing topics for `compass_nhs` +# - POST /ask answers "what does Harry have on today at school?" in one sentence +# - POST /notify is authenticated and refuses a bad request without delivering +# - an unsigned request is refused (401), and an unset token closes the API (503) +# +# Run on .13 from the project folder. It reads SCHOOL_HUB_TOKEN from +# environment.d/school-service.env (gitignored) and never prints it. /notify is +# not made to deliver a real message: the token is proven with a validation +# refusal. Pass --send to deliver one real message to Sam. +# +# Read-only against the school (AGENTS.md §10 rule 1): nothing here calls the +# school. /ask reads SQLite only. +set -u + +BASE="${SCHOOL_VERIFY_BASE:-http://127.0.0.1:8792}" +ENV_FILE="${SCHOOL_ENV_FILE:-environment.d/school-service.env}" +SEND=0 +[ "${1:-}" = "--send" ] && SEND=1 +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT + +fail=0 +pass() { printf 'PASS %s\n' "$1"; } +bad() { printf 'FAIL %s\n' "$1"; fail=1; } +skip() { printf 'SKIP %s\n' "$1"; } + +token="${SCHOOL_HUB_TOKEN:-}" +if [ -z "$token" ] && [ -f "$ENV_FILE" ]; then + token="$(sed -n 's/^SCHOOL_HUB_TOKEN=//p' "$ENV_FILE" | head -1)" +fi + +echo "== GET /health ==" +code="$(curl -sS -o "$TMP/health.json" -w '%{http_code}' "$BASE/health" || echo 000)" +printf 'HTTP %s\n' "$code" +cat "$TMP/health.json" 2>/dev/null; echo +if [ "$code" = "200" ] && grep -q '"ok":true' "$TMP/health.json"; then + pass "/health 200 with ok true" +else + bad "/health did not return 200 ok true" +fi + +echo +echo "== GET /capabilities without a token ==" +code="$(curl -sS -o "$TMP/caps401.json" -w '%{http_code}' "$BASE/capabilities" || echo 000)" +if [ "$code" = "401" ]; then + pass "/capabilities refused an unsigned request" +elif [ "$code" = "503" ]; then + bad "/capabilities answered 503: SCHOOL_HUB_TOKEN is not set on the service" +else + bad "/capabilities answered $code without a token, want 401" +fi + +if [ -z "$token" ]; then + skip "no SCHOOL_HUB_TOKEN in the environment or $ENV_FILE; the authenticated checks cannot run" +else + echo + echo "== GET /capabilities ==" + code="$(curl -sS -H "Authorization: Bearer $token" -o "$TMP/caps.json" -w '%{http_code}' "$BASE/capabilities" || echo 000)" + printf 'HTTP %s\n' "$code" + cat "$TMP/caps.json" 2>/dev/null; echo + if [ "$code" = "200" ] && grep -q '"project":"compass_nhs"' "$TMP/caps.json"; then + pass "/capabilities names compass_nhs" + else + bad "/capabilities did not name compass_nhs (HTTP $code)" + fi + + echo + echo "== POST /ask: what does Harry have on today at school? ==" + code="$(curl -sS -H "Authorization: Bearer $token" -H 'Content-Type: application/json' \ + -d '{"question":"what does Harry have on today at school?","asker":"sam","person":"harry","source":"esp32"}' \ + -o "$TMP/ask.json" -w '%{http_code}' "$BASE/ask" || echo 000)" + printf 'HTTP %s\n' "$code" + cat "$TMP/ask.json" 2>/dev/null; echo + if [ "$code" = "200" ] && grep -q '"answer":"' "$TMP/ask.json"; then + pass "/ask returned an answer" + else + bad "/ask did not return an answer (HTTP $code)" + fi + + echo + echo "== POST /notify: token proven without delivering ==" + code="$(curl -sS -H "Authorization: Bearer $token" -H 'Content-Type: application/json' \ + -d '{"from":"verify","level":"nonsense","message":"validation probe","person":"Sam"}' \ + -o "$TMP/notify.json" -w '%{http_code}' "$BASE/notify" || echo 000)" + printf 'HTTP %s\n' "$code" + cat "$TMP/notify.json" 2>/dev/null; echo + if [ "$code" = "400" ]; then + pass "/notify rejected an invalid level (auth worked, nothing delivered)" + else + bad "/notify answered $code for a bad level, want 400" + fi + + if [ "$SEND" = "1" ]; then + echo + echo "== POST /notify: real delivery to Sam (--send) ==" + code="$(curl -sS -H "Authorization: Bearer $token" -H 'Content-Type: application/json' \ + -d '{"from":"verify-phase8","level":"info","message":"Compass / NHS Hub API check.","person":"Sam"}' \ + -o "$TMP/send.json" -w '%{http_code}' "$BASE/notify" || echo 000)" + printf 'HTTP %s\n' "$code" + cat "$TMP/send.json" 2>/dev/null; echo + if [ "$code" = "200" ] && grep -q '"ok":true' "$TMP/send.json"; then + pass "/notify delivered a real message" + else + bad "/notify did not deliver (HTTP $code)" + fi + else + skip "real /notify delivery not attempted; rerun with --send" + fi +fi + +echo +if [ "$fail" = "0" ]; then + echo "RESULT: Phase 8 verified" +else + echo "RESULT: Phase 8 verification FAILED" +fi +exit "$fail" diff --git a/docker-compose.yml b/docker-compose.yml index 0be27b0..127a4bd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,8 +10,8 @@ services: build: context: . args: - VERSION: "0.5.0-phase7" - image: compass-teams-nhs/school:0.5.0-phase7 + VERSION: "0.6.0-phase8" + image: compass-teams-nhs/school:0.6.0-phase8 container_name: compass-teams-nhs restart: unless-stopped env_file: diff --git a/docs/phase-8-hub-notes.md b/docs/phase-8-hub-notes.md new file mode 100644 index 0000000..a50c292 --- /dev/null +++ b/docs/phase-8-hub-notes.md @@ -0,0 +1,202 @@ +# 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