AUD-117: derive person from the question via project people list

Decode the people list from each project's /capabilities and set the /ask
person field from a whole-word, case-insensitive name in the question when
the caller did not name a person. Match only the routed project's people,
so a name from another project does not leak across. Fall back to asker
when no name matches.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
2026-10-15 19:54:26 +11:00
parent ac7e6abbf9
commit 1d079a8bf1
9 changed files with 273 additions and 28 deletions

View File

@@ -32,7 +32,7 @@ central_ai_llm_master/
ids/ ULID request and approval ids ids/ ULID request and approval ids
config/ environment and secrets loading config/ environment and secrets loading
hear/ step 1: decode and validate the request hear/ step 1: decode and validate the request
understand/ step 2: never-route check and the routing LLM understand/ step 2: never-route check, person match, routing LLM
check/ step 3: identity and source checks check/ step 3: identity and source checks
route/ step 4: dispatch to one destination route/ step 4: dispatch to one destination
answer/ step 5: reply shaping (text, speech, notify) answer/ step 5: reply shaping (text, speech, notify)

View File

@@ -17,8 +17,8 @@ For each request, the Hub runs the checks in this order:
1. **Never-route check.** If the request is a control action (section 3), return 1. **Never-route check.** If the request is a control action (section 3), return
`status: "refused"`. Stop. Do not ask an LLM. `status: "refused"`. Stop. Do not ask an LLM.
2. **Read capabilities.** Read the topic list from each project's 2. **Read capabilities.** Read the topic list and the people list from each
`/capabilities` (`docs/ask-contract.md`). project's `/capabilities` (`docs/ask-contract.md`).
3. **Read this file.** Read the topic map (section 1) and the overlap rule 3. **Read this file.** Read the topic map (section 1) and the overlap rule
(section 2). (section 2).
4. **Ask the LLM.** Give the LLM the question, the capability lists, and this 4. **Ask the LLM.** Give the LLM the question, the capability lists, and this
@@ -28,6 +28,9 @@ For each request, the Hub runs the checks in this order:
- `clarify` — ask the user one short question. - `clarify` — ask the user one short question.
- `no_match` — say it does not know. - `no_match` — say it does not know.
5. **Route.** Send the request. The action call holds no LLM (brief rule 4). 5. **Route.** Send the request. The action call holds no LLM (brief rule 4).
When the caller did not name a `person`, set it from the question: match a
name against the routed project's `people` list, case-insensitive and whole
word. When no name matches, `person` stays the `asker`.
A project name in this file must match the `project` value in that project's A project name in this file must match the `project` value in that project's
`/capabilities` reply. A new project adds one row here and one `/capabilities` `/capabilities` reply. A new project adds one row here and one `/capabilities`

View File

@@ -90,33 +90,43 @@ func (p *ProjectClient) Ask(ctx context.Context, body AskRequest) (AskReply, err
return reply, nil return reply, nil
} }
// Capabilities reads the project's topic list. It is best effort: a project // Capabilities is a project's published lists from GET /capabilities
// that is down does not stop the Hub. // (docs/ask-contract.md). Topics route the question. People let the Hub set
func (p *ProjectClient) Capabilities(ctx context.Context) ([]string, error) { // the /ask person field from the question.
type Capabilities struct {
Project string
Topics []string
People []string
}
// Capabilities reads the project's topic and people lists. It is best effort:
// a project that is down does not stop the Hub.
func (p *ProjectClient) Capabilities(ctx context.Context) (Capabilities, error) {
ctx, cancel := context.WithTimeout(ctx, p.Timeout) ctx, cancel := context.WithTimeout(ctx, p.Timeout)
defer cancel() defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.URL+"/capabilities", nil) req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.URL+"/capabilities", nil)
if err != nil { if err != nil {
return nil, err return Capabilities{}, err
} }
if p.Token != "" { if p.Token != "" {
req.Header.Set("Authorization", "Bearer "+p.Token) req.Header.Set("Authorization", "Bearer "+p.Token)
} }
resp, err := p.HTTP.Do(req) resp, err := p.HTTP.Do(req)
if err != nil { if err != nil {
return nil, err return Capabilities{}, err
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("project %s: capabilities status %d", p.Name, resp.StatusCode) return Capabilities{}, fmt.Errorf("project %s: capabilities status %d", p.Name, resp.StatusCode)
} }
var out struct { var out struct {
Project string `json:"project"` Project string `json:"project"`
Topics []string `json:"topics"` Topics []string `json:"topics"`
People []string `json:"people"`
} }
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return nil, err return Capabilities{}, err
} }
return out.Topics, nil return Capabilities{Project: out.Project, Topics: out.Topics, People: out.People}, nil
} }

View File

@@ -15,6 +15,7 @@ import (
"hub/answer" "hub/answer"
"hub/apierr" "hub/apierr"
"hub/config" "hub/config"
"hub/destinations"
"hub/hear" "hub/hear"
"hub/logbook" "hub/logbook"
"hub/route" "hub/route"
@@ -36,9 +37,17 @@ type Service struct {
capMu sync.Mutex capMu sync.Mutex
capTopics []string capTopics []string
capPeople map[string][]string
capFetched time.Time capFetched time.Time
} }
// capabilitySnapshot is the live project ability list, keyed by project for
// people so a name from one project never leaks into another.
type capabilitySnapshot struct {
topics []string
people map[string][]string
}
// CapabilityTTL is how long the Hub keeps a project capability list. // CapabilityTTL is how long the Hub keeps a project capability list.
const CapabilityTTL = 30 * time.Second const CapabilityTTL = 30 * time.Second
@@ -51,10 +60,11 @@ func (s *Service) Ask(ctx context.Context, req hear.Request) answer.Response {
if understand.IsControlAction(req.Text) { if understand.IsControlAction(req.Text) {
outcome = route.Refused() outcome = route.Refused()
} else { } else {
caps := s.capabilities(ctx)
decision, err := s.Router.Decide(ctx, understand.Input{ decision, err := s.Router.Decide(ctx, understand.Input{
Question: req.Text, Question: req.Text,
Person: req.PersonOrDefault(), Person: req.PersonOrDefault(),
Topics: s.capabilityTopics(ctx), Topics: caps.topics,
Rules: s.Rules, Rules: s.Rules,
}) })
if err != nil { if err != nil {
@@ -65,6 +75,7 @@ func (s *Service) Ask(ctx context.Context, req hear.Request) answer.Response {
Reason: err.Error(), Reason: err.Error(),
} }
} else { } else {
req = applyPerson(req, decision, caps.people)
outcome = s.Dispatcher.Dispatch(ctx, decision, req) outcome = s.Dispatcher.Dispatch(ctx, decision, req)
} }
} }
@@ -83,48 +94,51 @@ func (s *Service) Ask(ctx context.Context, req hear.Request) answer.Response {
return resp return resp
} }
// capabilityTopics reads the live capability topics from every project, with a // capabilities reads the live capability topics and people from every project,
// short cache. A project that is down contributes nothing and does not block. // with a short cache. A project that is down contributes nothing and does not
func (s *Service) capabilityTopics(ctx context.Context) []string { // block.
func (s *Service) capabilities(ctx context.Context) capabilitySnapshot {
if s.Dispatcher == nil || len(s.Dispatcher.Projects) == 0 { if s.Dispatcher == nil || len(s.Dispatcher.Projects) == 0 {
return nil return capabilitySnapshot{}
} }
s.capMu.Lock() s.capMu.Lock()
if time.Since(s.capFetched) < CapabilityTTL && s.capTopics != nil { if time.Since(s.capFetched) < CapabilityTTL && s.capTopics != nil {
topics := s.capTopics snap := capabilitySnapshot{topics: s.capTopics, people: s.capPeople}
s.capMu.Unlock() s.capMu.Unlock()
return topics return snap
} }
s.capMu.Unlock() s.capMu.Unlock()
type result struct { type result struct {
topics []string project string
caps destinations.Capabilities
} }
results := make([]result, 0, len(s.Dispatcher.Projects)) results := make([]result, 0, len(s.Dispatcher.Projects))
var mu sync.Mutex var mu sync.Mutex
var wg sync.WaitGroup var wg sync.WaitGroup
for _, client := range s.Dispatcher.Projects { for name, client := range s.Dispatcher.Projects {
wg.Add(1) wg.Add(1)
go func(c route.ProjectQuerier) { go func(name string, c route.ProjectQuerier) {
defer wg.Done() defer wg.Done()
cctx, cancel := context.WithTimeout(ctx, 1*time.Second) cctx, cancel := context.WithTimeout(ctx, 1*time.Second)
defer cancel() defer cancel()
topics, err := c.Capabilities(cctx) caps, err := c.Capabilities(cctx)
if err != nil { if err != nil {
return return
} }
mu.Lock() mu.Lock()
results = append(results, result{topics: topics}) results = append(results, result{project: name, caps: caps})
mu.Unlock() mu.Unlock()
}(client) }(name, client)
} }
wg.Wait() wg.Wait()
var topics []string var topics []string
seen := map[string]bool{} seen := map[string]bool{}
people := map[string][]string{}
for _, r := range results { for _, r := range results {
for _, t := range r.topics { for _, t := range r.caps.Topics {
t = strings.ToLower(strings.TrimSpace(t)) t = strings.ToLower(strings.TrimSpace(t))
if t == "" || seen[t] { if t == "" || seen[t] {
continue continue
@@ -132,13 +146,48 @@ func (s *Service) capabilityTopics(ctx context.Context) []string {
seen[t] = true seen[t] = true
topics = append(topics, t) topics = append(topics, t)
} }
for _, person := range r.caps.People {
person = strings.ToLower(strings.TrimSpace(person))
if person == "" {
continue
}
people[r.project] = append(people[r.project], person)
}
} }
s.capMu.Lock() s.capMu.Lock()
s.capTopics = topics s.capTopics = topics
s.capPeople = people
s.capFetched = time.Now() s.capFetched = time.Now()
s.capMu.Unlock() s.capMu.Unlock()
return topics return capabilitySnapshot{topics: topics, people: people}
}
// applyPerson sets req.Person from a household name in the question when the
// caller did not name a person. It matches only the routed project's published
// people list, so a name from another project does not leak across. When no
// name matches, PersonOrDefault still falls back to the asker (docs/hub-api.md
// section 5).
func applyPerson(req hear.Request, decision understand.Decision, people map[string][]string) hear.Request {
if req.Person != "" {
return req
}
var projects []string
switch decision.Action {
case understand.ActionRoute:
projects = []string{decision.Project}
case understand.ActionFanout:
projects = decision.Projects
default:
return req
}
for _, p := range projects {
if name := understand.MatchPerson(req.Text, people[p]); name != "" {
req.Person = name
return req
}
}
return req
} }
// LogAsk writes one log line for a completed request (docs/hub-api.md section // LogAsk writes one log line for a completed request (docs/hub-api.md section

View File

@@ -82,7 +82,7 @@ func (p *fakePaperclip) FileJob(ctx context.Context, title, description string)
func newTestService(t *testing.T, decider understand.Decider) (*Service, *fakeproject.Project, *recordingSpeaker, *fakePaperclip) { func newTestService(t *testing.T, decider understand.Decider) (*Service, *fakeproject.Project, *recordingSpeaker, *fakePaperclip) {
t.Helper() t.Helper()
fp := &fakeproject.Project{Name: "compass_nhs", Topics: []string{"school", "homework", "timetable"}} fp := &fakeproject.Project{Name: "compass_nhs", Topics: []string{"school", "homework", "timetable"}, People: []string{"harry", "finn"}}
srv := fp.Server() srv := fp.Server()
t.Cleanup(srv.Close) t.Cleanup(srv.Close)
@@ -135,6 +135,96 @@ func TestAskRoutesThroughLLMToFakeProject(t *testing.T) {
} }
} }
// newMultiProjectService wires two fake projects so a test can prove a name
// published by one project does not leak into the other.
func newMultiProjectService(t *testing.T, decider understand.Decider) (*Service, *fakeproject.Project, *fakeproject.Project) {
t.Helper()
compass := &fakeproject.Project{Name: "compass_nhs", Topics: []string{"school"}, People: []string{"harry"}, Answers: map[string]string{"school": "School ends at 3pm today."}}
compassSrv := compass.Server()
t.Cleanup(compassSrv.Close)
email := &fakeproject.Project{Name: "email_agent", Topics: []string{"email"}, People: []string{"finn"}, Answers: map[string]string{"finn": "Finn has two emails."}}
emailSrv := email.Server()
t.Cleanup(emailSrv.Close)
svc := &Service{
Config: &config.Config{},
Rules: understand.DefaultRules(),
Router: decider,
Dispatcher: &route.Dispatcher{
Rules: understand.DefaultRules(),
Projects: map[string]route.ProjectQuerier{
"compass_nhs": destinations.NewProjectClient("compass_nhs", compassSrv.URL, "", time.Second),
"email_agent": destinations.NewProjectClient("email_agent", emailSrv.URL, "", time.Second),
},
Speaker: &recordingSpeaker{},
},
Started: time.Now(),
}
return svc, compass, email
}
func TestAskDerivesPersonFromQuestion(t *testing.T) {
dec := &recordingDecider{decide: understand.Decision{Action: understand.ActionRoute, Project: "compass_nhs"}}
svc, fp, _, _ := newTestService(t, dec)
req := hear.Request{Text: "what does Harry have on today at school?", Asker: "sam", Source: hear.SourceConsole, RequestID: "r7"}
resp := svc.Ask(context.Background(), req)
if resp.Status != "ok" {
t.Fatalf("status = %q (%s)", resp.Status, resp.Answer)
}
if got := fp.Persons(); len(got) != 1 || got[0] != "harry" {
t.Fatalf("project persons = %v, want [harry]", got)
}
}
func TestAskPersonDefaultsToAskerWhenNoName(t *testing.T) {
dec := &recordingDecider{decide: understand.Decision{Action: understand.ActionRoute, Project: "compass_nhs"}}
svc, fp, _, _ := newTestService(t, dec)
req := hear.Request{Text: "what is on at school today?", Asker: "sam", Source: hear.SourceConsole, RequestID: "r8"}
resp := svc.Ask(context.Background(), req)
if resp.Status != "ok" {
t.Fatalf("status = %q (%s)", resp.Status, resp.Answer)
}
if got := fp.Persons(); len(got) != 1 || got[0] != "sam" {
t.Fatalf("project persons = %v, want [sam]", got)
}
}
func TestAskPersonNameFromAnotherProjectNotMatched(t *testing.T) {
dec := &recordingDecider{decide: understand.Decision{Action: understand.ActionRoute, Project: "compass_nhs"}}
svc, compass, _ := newMultiProjectService(t, dec)
// finn is published by email_agent only. The question routes to compass_nhs,
// so compass_nhs must not receive finn.
req := hear.Request{Text: "what does finn have on today at school?", Asker: "sam", Source: hear.SourceConsole, RequestID: "r9"}
resp := svc.Ask(context.Background(), req)
if resp.Status != "ok" {
t.Fatalf("status = %q (%s)", resp.Status, resp.Answer)
}
if got := compass.Persons(); len(got) != 1 || got[0] != "sam" {
t.Fatalf("compass persons = %v, want [sam]", got)
}
}
func TestAskPersonMatchesOwnProject(t *testing.T) {
dec := &recordingDecider{decide: understand.Decision{Action: understand.ActionRoute, Project: "email_agent"}}
svc, _, email := newMultiProjectService(t, dec)
req := hear.Request{Text: "what does finn have on today?", Asker: "sam", Source: hear.SourceConsole, RequestID: "r10"}
resp := svc.Ask(context.Background(), req)
if resp.Status != "ok" {
t.Fatalf("status = %q (%s)", resp.Status, resp.Answer)
}
if got := email.Persons(); len(got) != 1 || got[0] != "finn" {
t.Fatalf("email persons = %v, want [finn]", got)
}
}
func TestControlActionNeverReachesLLM(t *testing.T) { func TestControlActionNeverReachesLLM(t *testing.T) {
dec := &recordingDecider{decide: understand.Decision{Action: understand.ActionNoMatch}} dec := &recordingDecider{decide: understand.Decision{Action: understand.ActionNoMatch}}
svc, _, _, _ := newTestService(t, dec) svc, _, _, _ := newTestService(t, dec)

View File

@@ -76,7 +76,7 @@ type Outcome struct {
// ProjectQuerier is one data project. *destinations.ProjectClient implements it. // ProjectQuerier is one data project. *destinations.ProjectClient implements it.
type ProjectQuerier interface { type ProjectQuerier interface {
Ask(ctx context.Context, body destinations.AskRequest) (destinations.AskReply, error) Ask(ctx context.Context, body destinations.AskRequest) (destinations.AskReply, error)
Capabilities(ctx context.Context) ([]string, error) Capabilities(ctx context.Context) (destinations.Capabilities, error)
} }
// Announcer starts house TTS. *destinations.Speaker implements it. // Announcer starts house TTS. *destinations.Speaker implements it.

View File

@@ -25,6 +25,9 @@ var DefaultAnswers = map[string]string{
type Project struct { type Project struct {
Name string Name string
Topics []string Topics []string
// People is the household list the fake project publishes, and the list it
// accepts in the /ask person field.
People []string
// Answers maps a question keyword to a short answer. Nil uses // Answers maps a question keyword to a short answer. Nil uses
// DefaultAnswers. // DefaultAnswers.
@@ -37,6 +40,7 @@ type Project struct {
mu sync.Mutex mu sync.Mutex
questions []string questions []string
persons []string
} }
// Ask records that the Hub reached the project. // Ask records that the Hub reached the project.
@@ -55,6 +59,15 @@ func (p *Project) Questions() []string {
return out return out
} }
// Persons returns the person field the Hub sent, in order.
func (p *Project) Persons() []string {
p.mu.Lock()
defer p.mu.Unlock()
out := make([]string, len(p.persons))
copy(out, p.persons)
return out
}
// Handler returns the three /ask-contract endpoints. // Handler returns the three /ask-contract endpoints.
func (p *Project) Handler() http.Handler { func (p *Project) Handler() http.Handler {
mux := http.NewServeMux() mux := http.NewServeMux()
@@ -69,6 +82,7 @@ func (p *Project) Handler() http.Handler {
writeJSON(w, http.StatusOK, map[string]any{ writeJSON(w, http.StatusOK, map[string]any{
"project": name, "project": name,
"topics": p.Topics, "topics": p.Topics,
"people": p.People,
}) })
}) })
mux.HandleFunc("/ask", p.handleAsk) mux.HandleFunc("/ask", p.handleAsk)
@@ -94,6 +108,9 @@ func (p *Project) handleAsk(w http.ResponseWriter, r *http.Request) {
http.Error(w, "bad json", http.StatusBadRequest) http.Error(w, "bad json", http.StatusBadRequest)
return return
} }
p.mu.Lock()
p.persons = append(p.persons, body.Person)
p.mu.Unlock()
p.Ask(body.Question) p.Ask(body.Question)
if p.Delay > 0 { if p.Delay > 0 {

37
src/understand/person.go Normal file
View File

@@ -0,0 +1,37 @@
package understand
import (
"regexp"
"strings"
)
// MatchPerson returns the household name from people that appears as a whole
// word in the question, or "" when no name matches.
//
// It is deterministic: no LLM is in this path (AGENTS.md rule 4). The Hub uses
// it to set the /ask person field from the question when the caller did not
// name a person. A caller-supplied person always wins; the caller does not call
// this function in that case.
//
// The match is case-insensitive and whole-word, so "harry" matches "What does
// Harry have on today?" but not "harrypotter". The matched name is returned as
// the project published it, trimmed.
func MatchPerson(question string, people []string) string {
if strings.TrimSpace(question) == "" || len(people) == 0 {
return ""
}
q := strings.ToLower(question)
for _, p := range people {
name := strings.TrimSpace(p)
if name == "" {
continue
}
// \b is a word boundary in Go's RE2. QuoteMeta keeps a name that
// holds a regexp character literal.
re := regexp.MustCompile(`\b` + regexp.QuoteMeta(strings.ToLower(name)) + `\b`)
if re.MatchString(q) {
return name
}
}
return ""
}

View File

@@ -0,0 +1,39 @@
package understand
import "testing"
func TestMatchPerson(t *testing.T) {
people := []string{"harry", "finn"}
cases := []struct {
name string
question string
want string
}{
{"capitalised", "What does Harry have on today at school?", "harry"},
{"lowercase", "what does harry have on today?", "harry"},
{"possessive", "what is Harry's timetable?", "harry"},
{"second name", "has finn done his homework?", "finn"},
{"no name", "what is the weather today?", ""},
{"substring is not a word", "harrypotter is a film", ""},
{"empty question", "", ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := MatchPerson(tc.question, people); got != tc.want {
t.Fatalf("MatchPerson(%q) = %q, want %q", tc.question, got, tc.want)
}
})
}
}
func TestMatchPersonNoPeople(t *testing.T) {
if got := MatchPerson("what does Harry have on today?", nil); got != "" {
t.Fatalf("MatchPerson with no people = %q, want empty", got)
}
}
func TestMatchPersonRegexpNameIsLiteral(t *testing.T) {
if got := MatchPerson("does a.b have homework?", []string{"a.b"}); got != "a.b" {
t.Fatalf("MatchPerson literal dot = %q, want %q", got, "a.b")
}
}