- 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>
283 lines
7.7 KiB
Go
283 lines
7.7 KiB
Go
// 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)
|
|
}
|