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:
2026-10-10 17:38:24 +11:00
parent 81f9722eee
commit bf80efa25d
33 changed files with 4025 additions and 110 deletions

282
src/hub/hub.go Normal file
View File

@@ -0,0 +1,282 @@
// Package hub wires the five steps: hear, understand, check, route, answer.
//
// The HTTP layer in main.go stays thin. This package holds the pipeline so it
// can be tested against a fake project without a network.
package hub
import (
"bytes"
"context"
"encoding/json"
"strings"
"sync"
"time"
"hub/answer"
"hub/apierr"
"hub/config"
"hub/hear"
"hub/logbook"
"hub/route"
"hub/understand"
)
// Service is the wired Hub.
type Service struct {
Config *config.Config
Rules *understand.Rules
Router understand.Decider
Dispatcher *route.Dispatcher
Approvals *Approvals
Log *logbook.Log
Speaker route.Announcer
Paperclip route.JobFiler
Started time.Time
HealthProbe func(ctx context.Context) map[string]string
capMu sync.Mutex
capTopics []string
capFetched time.Time
}
// CapabilityTTL is how long the Hub keeps a project capability list.
const CapabilityTTL = 30 * time.Second
// Ask runs the five steps for one POST /ask request. It never returns an error
// for a routing fault: a fault becomes a status in the reply.
func (s *Service) Ask(ctx context.Context, req hear.Request) answer.Response {
var outcome route.Outcome
// Step 2a: the never-route check. No LLM runs on a control action.
if understand.IsControlAction(req.Text) {
outcome = route.Refused()
} else {
decision, err := s.Router.Decide(ctx, understand.Input{
Question: req.Text,
Person: req.PersonOrDefault(),
Topics: s.capabilityTopics(ctx),
Rules: s.Rules,
})
if err != nil {
outcome = route.Outcome{
Kind: route.KindNoMatch,
Status: route.StatusUnavailable,
Answer: "I cannot work out where to send that.",
Reason: err.Error(),
}
} else {
outcome = s.Dispatcher.Dispatch(ctx, decision, req)
}
}
resp := answer.Build(req, outcome)
// Answer step: speak a house reply. House TTS is out of band; the Hub
// starts it and does not wait for the speakers (docs/hub-api.md section 8).
if !outcome.Spoken && answer.ShouldSpeak(resp) && s.Speaker != nil {
text := resp.Speech.Text
speak := s.Speaker
go func() {
_ = speak.Announce(context.Background(), text)
}()
}
return resp
}
// capabilityTopics reads the live capability topics from every project, with a
// short cache. A project that is down contributes nothing and does not block.
func (s *Service) capabilityTopics(ctx context.Context) []string {
if s.Dispatcher == nil || len(s.Dispatcher.Projects) == 0 {
return nil
}
s.capMu.Lock()
if time.Since(s.capFetched) < CapabilityTTL && s.capTopics != nil {
topics := s.capTopics
s.capMu.Unlock()
return topics
}
s.capMu.Unlock()
type result struct {
topics []string
}
results := make([]result, 0, len(s.Dispatcher.Projects))
var mu sync.Mutex
var wg sync.WaitGroup
for _, client := range s.Dispatcher.Projects {
wg.Add(1)
go func(c route.ProjectQuerier) {
defer wg.Done()
cctx, cancel := context.WithTimeout(ctx, 1*time.Second)
defer cancel()
topics, err := c.Capabilities(cctx)
if err != nil {
return
}
mu.Lock()
results = append(results, result{topics: topics})
mu.Unlock()
}(client)
}
wg.Wait()
var topics []string
seen := map[string]bool{}
for _, r := range results {
for _, t := range r.topics {
t = strings.ToLower(strings.TrimSpace(t))
if t == "" || seen[t] {
continue
}
seen[t] = true
topics = append(topics, t)
}
}
s.capMu.Lock()
s.capTopics = topics
s.capFetched = time.Now()
s.capMu.Unlock()
return topics
}
// LogAsk writes one log line for a completed request (docs/hub-api.md section
// 10). It holds no secret.
func (s *Service) LogAsk(req hear.Request, resp answer.Response, latency time.Duration) {
if s.Log == nil {
return
}
project := ""
if resp.Route != nil {
project = resp.Route.Project
}
routeText := string(resp.Shape)
if resp.Route != nil {
routeText = resp.Route.Kind
if project != "" {
routeText += ":" + project
}
}
_ = s.Log.Write(logbook.Entry{
Time: time.Now().UTC().Format(time.RFC3339),
RequestID: req.RequestID,
Source: string(req.Source),
Asker: req.Asker,
Text: req.Text,
Route: routeText,
Status: resp.Status,
LatencyMS: latency.Milliseconds(),
Shape: string(resp.Shape),
})
}
// ApproveRequest is the POST /approve body.
type ApproveRequest struct {
ApprovalID string `json:"approval_id"`
Decision string `json:"decision"`
Asker string `json:"asker"`
Device string `json:"device"`
}
// ApproveResult is the result block.
type ApproveResult struct {
Kind string `json:"kind"`
Status string `json:"status"`
Detail string `json:"detail,omitempty"`
}
// ApproveResponse is the POST /approve reply.
type ApproveResponse struct {
Status string `json:"status"`
ApprovalID string `json:"approval_id"`
Decision string `json:"decision"`
Result ApproveResult `json:"result"`
Answer string `json:"answer"`
}
// ErrBadApprove is returned for a malformed /approve body.
func ParseApprove(body []byte) (ApproveRequest, *apierr.Error) {
var r ApproveRequest
if err := decodeJSON(body, &r); err != nil {
return ApproveRequest{}, apierr.New(400, "bad_request", "The request body is not valid JSON.")
}
r.ApprovalID = strings.TrimSpace(r.ApprovalID)
r.Decision = strings.TrimSpace(r.Decision)
r.Asker = strings.TrimSpace(r.Asker)
if r.ApprovalID == "" {
return ApproveRequest{}, apierr.New(422, "unprocessable", "Field 'approval_id' is required.")
}
if r.Asker != "sam" {
return ApproveRequest{}, apierr.New(422, "unsupported_asker", "The Hub supports one asker, Sam.")
}
switch r.Decision {
case "approve", "deny":
default:
return ApproveRequest{}, apierr.New(400, "bad_request", "Field 'decision' must be approve or deny.")
}
return r, nil
}
// Approve records one decision and runs the approved action. In v1 the only
// Hub-owned approval is a Paperclip job (docs/hub-api.md section 6).
func (s *Service) Approve(ctx context.Context, req ApproveRequest) (ApproveResponse, *apierr.Error) {
ap, err := s.Approvals.Decide(req.ApprovalID)
if err != nil {
switch err {
case ErrNoApproval:
return ApproveResponse{}, apierr.New(404, "not_found", "That approval does not exist.")
case ErrApprovalExpired:
return ApproveResponse{}, apierr.New(409, "approval_expired", "That approval expired. Ask again.")
case ErrAlreadyDecided:
return ApproveResponse{}, apierr.New(409, "already_decided", "That approval was already decided.")
default:
return ApproveResponse{}, apierr.New(500, "internal", "The Hub had an internal problem.")
}
}
resp := ApproveResponse{
Status: "ok",
ApprovalID: ap.ID,
Decision: req.Decision,
Result: ApproveResult{Kind: ap.Action},
}
if req.Decision == "deny" {
resp.Result.Status = "denied"
resp.Answer = "Fine, I did not file it."
return resp, nil
}
switch ap.Action {
case "paperclip_job":
if s.Paperclip == nil {
resp.Result.Status = "failed"
resp.Result.Detail = "Paperclip is not connected."
resp.Answer = "I could not file the job."
return resp, nil
}
_, err := s.Paperclip.FileJob(ctx, ap.Summary, "Filed by the Hub after an admin approval.")
if err != nil {
resp.Result.Status = "failed"
resp.Result.Detail = "The job did not file."
resp.Answer = "I could not file the job."
return resp, nil
}
resp.Result.Status = "filed"
resp.Result.Detail = "Issue filed."
resp.Answer = "Filed the job."
return resp, nil
default:
resp.Result.Status = "failed"
resp.Result.Detail = "Unknown action."
resp.Answer = "I do not know how to run that."
return resp, nil
}
}
// decodeJSON decodes one JSON object. Unknown fields are ignored so clients
// may add fields (docs/hub-api.md section 4).
func decodeJSON(body []byte, v any) error {
return json.NewDecoder(bytes.NewReader(body)).Decode(v)
}