AUD-49: build the Hub skeleton (hear-route-answer)
- Three inbound routes from docs/hub-api.md: POST /ask, POST /approve, GET /health. - Five-step pipeline in src/hub, with packages hear, understand, check, route, answer. - Routing through OmniRoute with the capability list and routing.md. The never-route control check runs before any LLM call. - One real destination: Jervis house TTS, started out of band. - Fake /ask project (src/tests/fakeproject) for the round-trip test. - Append-only, size-capped request log. Bearer auth, source match, rate limit. - Tests: all packages pass, including the fake /ask round trip. Race clean. - Dockerfile and compose updated to mount routing.md and secrets, log on tmpfs. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -23,6 +23,38 @@ Relevant keys:
|
||||
| `HUB_TOKEN_CONSOLE` | Hub token for the console portal. |
|
||||
| `HUB_TOKEN_ADMIN` | Hub token for the admin app. The only trusted door. |
|
||||
|
||||
## Hub Environment Keys
|
||||
|
||||
The Hub reads these keys from the environment, then from the secrets file. The
|
||||
secrets file path is `HUB_SECRETS_FILE`, or `~/.config/environment.d/10-secrets.conf`.
|
||||
|
||||
| Key | Default | Use |
|
||||
|---|---|---|
|
||||
| `HUB_PORT` | `8788` | Listen port. |
|
||||
| `HUB_ROUTING_FILE` | `routing.md` | Human-edited routing rules. |
|
||||
| `HUB_SECRETS_FILE` | `~/.config/environment.d/10-secrets.conf` | Read secrets when the environment is empty. |
|
||||
| `HUB_PROJECTS` | empty | Data projects: `name=url,name2=url2`. |
|
||||
| `HUB_PROJECT_<NAME>_TOKEN` | empty | Per-project bearer token. |
|
||||
| `OMNIROUTE_URL` | `http://192.168.20.13:20129` | Model gateway base. |
|
||||
| `OMNIROUTE_MODEL` | `gpt-4o-mini` | Routing model. The gateway is OpenAI-compatible. |
|
||||
| `JERVIS_URL` | `http://192.168.20.13:8501` | House TTS. |
|
||||
| `PAPERCLIP_BASE_URL` | `http://192.168.20.13:3100` | Paperclip board. |
|
||||
| `PAPERCLIP_COMPANY_ID` | from the repo convention | Company for filed jobs. |
|
||||
| `HUB_LOG_PATH` | `hub.log` | Append-only request log. |
|
||||
| `HUB_LOG_MAX_BYTES` | `1048576` | Rotate size. |
|
||||
| `HUB_LOG_KEEP_DAYS` | `7` | Rotated files kept. |
|
||||
| `HUB_LLM_TIMEOUT` | `3s` | Routing LLM cap (`docs/hub-api.md` section 8). |
|
||||
| `HUB_PROJECT_TIMEOUT` | `3s` | One project `/ask` cap. |
|
||||
| `HUB_DEST_TIMEOUT` | `2s` | One destination command cap. |
|
||||
| `HUB_TTS_TIMEOUT` | `15s` | House TTS completes out of band. |
|
||||
| `HUB_ASK_TIMEOUT` | `8s` | `POST /ask` total. |
|
||||
| `HUB_APPROVE_TIMEOUT` | `5s` | `POST /approve` total. |
|
||||
| `HUB_RATE_PER_MIN` | `30` | Requests per client token per minute. |
|
||||
|
||||
> Observed on 2026-10-10: the routing LLM answer through OmniRoute took 0.8 s to
|
||||
> 1.9 s with `gpt-4o-mini`, inside the 3 s cap. The `auto/*` combos were slower
|
||||
> than 3 s and are not the default.
|
||||
|
||||
## Services and Ports
|
||||
|
||||
| Service | Host | Port | Use |
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
# Central AI Hub — Code Structure Map
|
||||
|
||||
> **Writing Style**: ASD-STE100 Simplified Technical English (`ste-writing`).
|
||||
> **Status**: Skeleton built (AUD-48). Routing (AUD-49) is not built yet.
|
||||
> **Status**: Hub skeleton built (AUD-49). The three inbound routes answer.
|
||||
|
||||
## Planned Folder Layout
|
||||
## Folder Layout
|
||||
|
||||
```text
|
||||
central_ai_llm_master/
|
||||
AGENTS.md project rules for agents
|
||||
hub_brief.md agreed design (source of truth)
|
||||
README.md technical readme
|
||||
routing.md human-edited routing rules
|
||||
Dockerfile multi-stage build (static binary, distroless)
|
||||
docker-compose.yml one container, port 8788
|
||||
.dockerignore build context filter
|
||||
@@ -22,40 +23,69 @@ central_ai_llm_master/
|
||||
ask-contract.md Hub to project interface
|
||||
hub-api.md frozen inbound API (v1)
|
||||
vikunja-tasks.json human task list
|
||||
src/ Hub service code
|
||||
go.mod module hub
|
||||
main.go HTTP entry point (GET /health)
|
||||
hear/ takes voice or text
|
||||
understand/ LLM intent routing
|
||||
check/ authorization and approval
|
||||
route/ dispatch to a destination
|
||||
answer/ reply shaping (text, TTS, notify)
|
||||
destinations/ one client per destination
|
||||
tests/ unit and round-trip tests
|
||||
scripts/
|
||||
paperclip-roundtrip.sh Phase 1 outbound job proof
|
||||
src/ Hub service code (module hub)
|
||||
go.mod module hub (Go 1.25, standard library only)
|
||||
main.go HTTP layer: POST /ask, POST /approve, GET /health
|
||||
apierr/ the frozen error envelope
|
||||
ids/ ULID request and approval ids
|
||||
config/ environment and secrets loading
|
||||
hear/ step 1: decode and validate the request
|
||||
understand/ step 2: never-route check and the routing LLM
|
||||
check/ step 3: identity and source checks
|
||||
route/ step 4: dispatch to one destination
|
||||
answer/ step 5: reply shaping (text, speech, notify)
|
||||
destinations/ one client per destination:
|
||||
project.go (a data project through /ask)
|
||||
speakers.go (Jervis house TTS)
|
||||
paperclip.go (file a job after approval)
|
||||
logbook/ append-only, size-capped request log
|
||||
hub/ the pipeline that wires the five steps
|
||||
tests/fakeproject/ fake /ask project for tests and manual runs
|
||||
```
|
||||
|
||||
## Entry Points
|
||||
|
||||
| Entry point | Use |
|
||||
|---|---|
|
||||
| `POST /ask` | Send a voice transcript or text. Returns a reply. |
|
||||
| `POST /approve` | Admin app confirms a restricted action. |
|
||||
| `GET /health` | Liveness check. |
|
||||
| Entry point | Use | Handler |
|
||||
|---|---|---|
|
||||
| `POST /ask` | Send a voice transcript or text. Returns a reply. | `main.go`, `hub.Service.Ask` |
|
||||
| `POST /approve` | Admin app confirms a Hub-owned action. | `main.go`, `hub.Service.Approve` |
|
||||
| `GET /health` | Liveness check. No authentication. | `main.go`, `hub.Service.Health` |
|
||||
|
||||
The inbound API is frozen. See `docs/hub-api.md`.
|
||||
|
||||
## The Five Steps in Code
|
||||
|
||||
| Step | Package | Notes |
|
||||
|---|---|---|
|
||||
| Hear | `hear` | Decode JSON, validate the frozen shape, make a request id. |
|
||||
| Understand | `understand` | The never-route check runs first, with no LLM. Then the OmniRoute LLM picks route, fanout, clarify, or no_match. |
|
||||
| Check | `check` | One identity (Sam) and a source that matches the token. |
|
||||
| Route | `route` | Dispatch. The action call holds no LLM. |
|
||||
| Answer | `answer` | Choose the reply shape from `source` and `want`. House TTS is out of band. |
|
||||
|
||||
## Primary Dependencies
|
||||
|
||||
- HTTP server (Go 1.25, standard library `net/http`).
|
||||
- HTTP client for OmniRoute, Home Assistant, Paperclip.
|
||||
- MQTT client for voice topics.
|
||||
- Tavily API client for web search.
|
||||
- HTTP server and client (Go 1.25, standard library `net/http`).
|
||||
- No third-party module. The binary is static and small.
|
||||
- OmniRoute, Home Assistant, MQTT, and Paperclip are called over HTTP.
|
||||
|
||||
## Destinations
|
||||
|
||||
Each destination gets one client under `src/destinations/`. The client calls the
|
||||
destination API. The client does not hold business logic.
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
# Unit and round-trip tests. No network, no secrets.
|
||||
docker run --rm -v "$PWD/src":/src -w /src golang:1.25-alpine go test ./...
|
||||
```
|
||||
|
||||
The fake `/ask` project (`src/tests/fakeproject/`) proves the Hub-to-project
|
||||
round trip. It has no real data and changes no state.
|
||||
|
||||
## Source Control
|
||||
|
||||
- Gitea: `sam/central_ai_llm_master`
|
||||
|
||||
@@ -83,6 +83,84 @@ announce ran one time.
|
||||
- The house speaks the result. **DONE.**
|
||||
- This file records the result. **DONE.**
|
||||
|
||||
## Phase 2 — Hub Skeleton (AUD-49)
|
||||
|
||||
This phase builds the Hub itself. The shape is frozen in `docs/hub-api.md`. The
|
||||
code layout is in `docs/outline/03-code-map.md`.
|
||||
|
||||
### What was built
|
||||
|
||||
- `POST /ask`, `POST /approve`, and `GET /health`.
|
||||
- The five steps: `hear` → `understand` → `check` → `route` → `answer`.
|
||||
- Routing through the LLM at OmniRoute, with the capability topics and
|
||||
`routing.md`.
|
||||
- The never-route check runs before the LLM. A control action never reaches a
|
||||
model.
|
||||
- One real destination: the house speakers (Jervis `/announce`).
|
||||
- A fake `/ask` project for tests and manual runs.
|
||||
|
||||
### Test command
|
||||
|
||||
```bash
|
||||
docker run --rm -v "$PWD/src":/src -w /src golang:1.25-alpine go test ./...
|
||||
```
|
||||
|
||||
Result on 2026-10-10: all packages pass, including the fake `/ask` round trip.
|
||||
|
||||
### Live round trip
|
||||
|
||||
The Hub ran on port 18788 with `HUB_PROJECTS="compass_nhs=http://127.0.0.1:9199"`
|
||||
and the fake project on 9199. The real OmniRoute LLM chose the project. Jervis
|
||||
spoke the reply on the house speakers.
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"text": "what does Harry have on today?",
|
||||
"asker": "sam",
|
||||
"source": "esp32",
|
||||
"device": "kitchen-esp32",
|
||||
"request_id": "01AUD49PROOF"
|
||||
}
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"request_id": "01AUD49PROOF",
|
||||
"answer": "Harry has maths and a music lesson today.",
|
||||
"shape": "speech",
|
||||
"speech": {"text": "Harry has maths and a music lesson today.", "target": "house"},
|
||||
"route": {"kind": "project", "project": "compass_nhs"}
|
||||
}
|
||||
```
|
||||
|
||||
Hub stdout (evidence):
|
||||
|
||||
```text
|
||||
router: model=gpt-4o-mini status=200 elapsed=1.554598547s
|
||||
speakers: announced 41 chars
|
||||
```
|
||||
|
||||
The routing LLM answered in 1.5 s (inside the 3 s cap). The house announcement
|
||||
ran out of band and Jervis returned HTTP 200. Jervis has no playback telemetry,
|
||||
so the HTTP 200 and the spoken reply are the strongest available proof.
|
||||
|
||||
### Control action proof
|
||||
|
||||
`"cut the kids internet"` returned `status: "refused"`, `route.kind: "refused"`,
|
||||
`route.reason: "high_consequence_control"`. No LLM call ran for that request.
|
||||
The unit test `TestControlActionNeverReachesLLM` asserts the zero call count.
|
||||
|
||||
### Deploy note
|
||||
|
||||
The `docker-compose.yml` mounts `routing.md` and the secrets file read-only, and
|
||||
uses a `/tmp` tmpfs for the log. To deploy: build the image, then restart the
|
||||
container. A `HUB_TOKEN_*` key per client must exist in the secrets file first.
|
||||
|
||||
## After Phase 1
|
||||
|
||||
Follow the build order in `AGENTS.md`. Do not start a destination before the
|
||||
|
||||
Reference in New Issue
Block a user