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:
317
src/route/route.go
Normal file
317
src/route/route.go
Normal file
@@ -0,0 +1,317 @@
|
||||
// Package route is step 4 of the Hub: send the request to a destination.
|
||||
//
|
||||
// The action call holds no LLM (AGENTS.md rule 4). The routing decision is
|
||||
// already made. This package only dispatches.
|
||||
package route
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"hub/destinations"
|
||||
"hub/hear"
|
||||
"hub/ids"
|
||||
"hub/understand"
|
||||
)
|
||||
|
||||
// Kind is what the Hub did. It matches route.kind in docs/hub-api.md section 5.
|
||||
type Kind string
|
||||
|
||||
const (
|
||||
KindDirect Kind = "direct"
|
||||
KindProject Kind = "project"
|
||||
KindFanout Kind = "fanout"
|
||||
KindPaperclip Kind = "paperclip"
|
||||
KindClarify Kind = "clarify"
|
||||
KindNoMatch Kind = "no_match"
|
||||
KindRefused Kind = "refused"
|
||||
)
|
||||
|
||||
// Status is the top-level status in the reply.
|
||||
type Status string
|
||||
|
||||
const (
|
||||
StatusOK Status = "ok"
|
||||
StatusNeedsClarification Status = "needs_clarification"
|
||||
StatusNeedsApproval Status = "needs_approval"
|
||||
StatusNoMatch Status = "no_match"
|
||||
StatusRefused Status = "refused"
|
||||
StatusUnavailable Status = "unavailable"
|
||||
)
|
||||
|
||||
// Approval is a pending Hub-owned approval.
|
||||
type Approval struct {
|
||||
ID string `json:"approval_id"`
|
||||
Action string `json:"action"`
|
||||
Summary string `json:"summary"`
|
||||
Reason string `json:"reason"`
|
||||
Expires time.Time `json:"expires"`
|
||||
}
|
||||
|
||||
// Clarify is one short question the Hub asks the user.
|
||||
type Clarify struct {
|
||||
Question string `json:"question"`
|
||||
Options []string `json:"options,omitempty"`
|
||||
}
|
||||
|
||||
// Outcome is the result of step 4.
|
||||
type Outcome struct {
|
||||
Kind Kind
|
||||
Status Status
|
||||
Answer string
|
||||
Project string
|
||||
Projects []string
|
||||
Expires string
|
||||
Approval *Approval
|
||||
Clarify *Clarify
|
||||
Reason string
|
||||
|
||||
// Spoken records that a direct destination already used the speakers, so
|
||||
// the answer step must not announce again.
|
||||
Spoken bool
|
||||
}
|
||||
|
||||
// ProjectQuerier is one data project. *destinations.ProjectClient implements it.
|
||||
type ProjectQuerier interface {
|
||||
Ask(ctx context.Context, body destinations.AskRequest) (destinations.AskReply, error)
|
||||
Capabilities(ctx context.Context) ([]string, error)
|
||||
}
|
||||
|
||||
// Announcer starts house TTS. *destinations.Speaker implements it.
|
||||
type Announcer interface {
|
||||
Announce(ctx context.Context, text string) error
|
||||
}
|
||||
|
||||
// JobFiler files one Paperclip job. *destinations.Paperclip implements it.
|
||||
type JobFiler interface {
|
||||
FileJob(ctx context.Context, title, description string) (string, error)
|
||||
}
|
||||
|
||||
// ApprovalStore holds Hub-owned approvals. The hub package implements it.
|
||||
type ApprovalStore interface {
|
||||
Create(action, summary, reason string) *Approval
|
||||
}
|
||||
|
||||
// Dispatcher sends a decision to its destination.
|
||||
type Dispatcher struct {
|
||||
Rules *understand.Rules
|
||||
Projects map[string]ProjectQuerier
|
||||
Speaker Announcer
|
||||
Paperclip JobFiler
|
||||
Approvals ApprovalStore
|
||||
}
|
||||
|
||||
// Dispatch runs one decision. It never returns an error: a destination fault
|
||||
// becomes status "unavailable", because one project down is not an outage
|
||||
// (docs/hub-api.md section 5).
|
||||
func (d *Dispatcher) Dispatch(ctx context.Context, decision understand.Decision, req hear.Request) Outcome {
|
||||
switch decision.Action {
|
||||
case understand.ActionRefused:
|
||||
return Refused()
|
||||
case understand.ActionClarify:
|
||||
return Outcome{
|
||||
Kind: KindClarify,
|
||||
Status: StatusNeedsClarification,
|
||||
Answer: decision.Question,
|
||||
Clarify: &Clarify{
|
||||
Question: decision.Question,
|
||||
Options: decision.Options,
|
||||
},
|
||||
}
|
||||
case understand.ActionNoMatch:
|
||||
return Outcome{Kind: KindNoMatch, Status: StatusNoMatch, Answer: "I do not know where to send that."}
|
||||
case understand.ActionRoute:
|
||||
return d.dispatchOne(ctx, decision.Project, req)
|
||||
case understand.ActionFanout:
|
||||
return d.dispatchFanout(ctx, decision.Projects, req)
|
||||
default:
|
||||
return Outcome{Kind: KindNoMatch, Status: StatusNoMatch, Answer: "I do not know where to send that."}
|
||||
}
|
||||
}
|
||||
|
||||
// Refused is the fixed high-consequence control reply.
|
||||
func Refused() Outcome {
|
||||
return Outcome{
|
||||
Kind: KindRefused,
|
||||
Status: StatusRefused,
|
||||
Answer: "Use the admin app to change internet limits.",
|
||||
Reason: "high_consequence_control",
|
||||
}
|
||||
}
|
||||
|
||||
// dispatchOne sends to one destination.
|
||||
func (d *Dispatcher) dispatchOne(ctx context.Context, project string, req hear.Request) Outcome {
|
||||
kind := "project"
|
||||
if d.Rules != nil {
|
||||
kind = d.Rules.KindFor(project)
|
||||
}
|
||||
|
||||
switch kind {
|
||||
case "direct":
|
||||
return d.dispatchDirect(ctx, project, req)
|
||||
case "paperclip":
|
||||
return d.dispatchPaperclip(ctx, req)
|
||||
case "peer", "way_in":
|
||||
// A way in or a peer is not a destination the Hub routes to.
|
||||
return Outcome{
|
||||
Kind: KindNoMatch,
|
||||
Status: StatusNoMatch,
|
||||
Answer: "That is a way in, not a place I send work.",
|
||||
Reason: "not_a_destination",
|
||||
}
|
||||
default:
|
||||
return d.askProject(ctx, project, req)
|
||||
}
|
||||
}
|
||||
|
||||
// dispatchDirect calls a direct destination. Only the speakers exist in v1.
|
||||
func (d *Dispatcher) dispatchDirect(ctx context.Context, project string, req hear.Request) Outcome {
|
||||
switch project {
|
||||
case "speakers":
|
||||
if d.Speaker == nil {
|
||||
return Outcome{Kind: KindDirect, Status: StatusUnavailable, Answer: "The speakers are not connected.", Project: project}
|
||||
}
|
||||
// House TTS is out of band. Start it and do not wait.
|
||||
speak := d.Speaker
|
||||
text := req.Text
|
||||
go func() { _ = speak.Announce(context.Background(), text) }()
|
||||
return Outcome{Kind: KindDirect, Status: StatusOK, Answer: req.Text, Project: project, Spoken: true}
|
||||
default:
|
||||
return Outcome{
|
||||
Kind: KindDirect,
|
||||
Status: StatusUnavailable,
|
||||
Answer: "That destination is not built yet.",
|
||||
Project: project,
|
||||
Reason: "not_built",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// dispatchPaperclip creates an approval. It files nothing yet.
|
||||
func (d *Dispatcher) dispatchPaperclip(ctx context.Context, req hear.Request) Outcome {
|
||||
summary := "File a job: " + req.Text
|
||||
if len(summary) > 200 {
|
||||
summary = summary[:200]
|
||||
}
|
||||
var ap *Approval
|
||||
if d.Approvals != nil {
|
||||
ap = d.Approvals.Create("paperclip_job", summary, "A slow, multi-step job needs a yes.")
|
||||
} else {
|
||||
ap = &Approval{
|
||||
ID: ids.Approval(),
|
||||
Action: "paperclip_job",
|
||||
Summary: summary,
|
||||
Reason: "A slow, multi-step job needs a yes.",
|
||||
Expires: time.Now().Add(10 * time.Minute),
|
||||
}
|
||||
}
|
||||
return Outcome{
|
||||
Kind: KindPaperclip,
|
||||
Status: StatusNeedsApproval,
|
||||
Answer: "I need a yes on the admin app to file that job.",
|
||||
Approval: ap,
|
||||
}
|
||||
}
|
||||
|
||||
// askProject forwards to one data project.
|
||||
func (d *Dispatcher) askProject(ctx context.Context, project string, req hear.Request) Outcome {
|
||||
client, ok := d.Projects[project]
|
||||
if !ok || client == nil {
|
||||
return Outcome{
|
||||
Kind: KindProject,
|
||||
Status: StatusUnavailable,
|
||||
Answer: "That service is not connected.",
|
||||
Project: project,
|
||||
Reason: "no_client",
|
||||
}
|
||||
}
|
||||
reply, err := client.Ask(ctx, destinations.AskRequest{
|
||||
Question: req.Text,
|
||||
Asker: req.Asker,
|
||||
Person: req.PersonOrDefault(),
|
||||
Source: string(req.Source),
|
||||
})
|
||||
if err != nil {
|
||||
return Outcome{
|
||||
Kind: KindProject,
|
||||
Status: StatusUnavailable,
|
||||
Answer: "That service is not answering.",
|
||||
Project: project,
|
||||
Reason: err.Error(),
|
||||
}
|
||||
}
|
||||
if reply.Answer == nil || strings.TrimSpace(*reply.Answer) == "" {
|
||||
reason := reply.Reason
|
||||
if reason == "" {
|
||||
reason = "no answer"
|
||||
}
|
||||
return Outcome{
|
||||
Kind: KindProject,
|
||||
Status: StatusUnavailable,
|
||||
Answer: "That service cannot answer that.",
|
||||
Project: project,
|
||||
Reason: reason,
|
||||
}
|
||||
}
|
||||
return Outcome{
|
||||
Kind: KindProject,
|
||||
Status: StatusOK,
|
||||
Answer: strings.TrimSpace(*reply.Answer),
|
||||
Project: project,
|
||||
Expires: reply.Expires,
|
||||
}
|
||||
}
|
||||
|
||||
// dispatchFanout asks up to three projects in parallel, then merges the answers
|
||||
// into one short reply (routing.md section 2).
|
||||
func (d *Dispatcher) dispatchFanout(ctx context.Context, projects []string, req hear.Request) Outcome {
|
||||
if len(projects) == 0 {
|
||||
return Outcome{Kind: KindFanout, Status: StatusNoMatch, Answer: "I could not work out where to ask."}
|
||||
}
|
||||
if len(projects) > 3 {
|
||||
projects = projects[:3]
|
||||
}
|
||||
|
||||
type result struct {
|
||||
project string
|
||||
answer string
|
||||
ok bool
|
||||
}
|
||||
results := make([]result, len(projects))
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for i, p := range projects {
|
||||
wg.Add(1)
|
||||
go func(i int, p string) {
|
||||
defer wg.Done()
|
||||
o := d.askProject(ctx, p, req)
|
||||
results[i] = result{project: p, answer: o.Answer, ok: o.Status == StatusOK}
|
||||
}(i, p)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
var answers []string
|
||||
var used []string
|
||||
for _, r := range results {
|
||||
if r.ok {
|
||||
answers = append(answers, r.answer)
|
||||
used = append(used, r.project)
|
||||
}
|
||||
}
|
||||
if len(answers) == 0 {
|
||||
return Outcome{
|
||||
Kind: KindFanout,
|
||||
Status: StatusUnavailable,
|
||||
Answer: "Those services are not answering.",
|
||||
Projects: projects,
|
||||
}
|
||||
}
|
||||
return Outcome{
|
||||
Kind: KindFanout,
|
||||
Status: StatusOK,
|
||||
Answer: strings.Join(answers, " "),
|
||||
Projects: used,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user