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

@@ -15,6 +15,7 @@ import (
"hub/answer"
"hub/apierr"
"hub/config"
"hub/destinations"
"hub/hear"
"hub/logbook"
"hub/route"
@@ -36,9 +37,17 @@ type Service struct {
capMu sync.Mutex
capTopics []string
capPeople map[string][]string
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.
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) {
outcome = route.Refused()
} else {
caps := s.capabilities(ctx)
decision, err := s.Router.Decide(ctx, understand.Input{
Question: req.Text,
Person: req.PersonOrDefault(),
Topics: s.capabilityTopics(ctx),
Topics: caps.topics,
Rules: s.Rules,
})
if err != nil {
@@ -65,6 +75,7 @@ func (s *Service) Ask(ctx context.Context, req hear.Request) answer.Response {
Reason: err.Error(),
}
} else {
req = applyPerson(req, decision, caps.people)
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
}
// 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 {
// capabilities reads the live capability topics and people from every project,
// with a short cache. A project that is down contributes nothing and does not
// block.
func (s *Service) capabilities(ctx context.Context) capabilitySnapshot {
if s.Dispatcher == nil || len(s.Dispatcher.Projects) == 0 {
return nil
return capabilitySnapshot{}
}
s.capMu.Lock()
if time.Since(s.capFetched) < CapabilityTTL && s.capTopics != nil {
topics := s.capTopics
snap := capabilitySnapshot{topics: s.capTopics, people: s.capPeople}
s.capMu.Unlock()
return topics
return snap
}
s.capMu.Unlock()
type result struct {
topics []string
project string
caps destinations.Capabilities
}
results := make([]result, 0, len(s.Dispatcher.Projects))
var mu sync.Mutex
var wg sync.WaitGroup
for _, client := range s.Dispatcher.Projects {
for name, client := range s.Dispatcher.Projects {
wg.Add(1)
go func(c route.ProjectQuerier) {
go func(name string, c route.ProjectQuerier) {
defer wg.Done()
cctx, cancel := context.WithTimeout(ctx, 1*time.Second)
defer cancel()
topics, err := c.Capabilities(cctx)
caps, err := c.Capabilities(cctx)
if err != nil {
return
}
mu.Lock()
results = append(results, result{topics: topics})
results = append(results, result{project: name, caps: caps})
mu.Unlock()
}(client)
}(name, client)
}
wg.Wait()
var topics []string
seen := map[string]bool{}
people := map[string][]string{}
for _, r := range results {
for _, t := range r.topics {
for _, t := range r.caps.Topics {
t = strings.ToLower(strings.TrimSpace(t))
if t == "" || seen[t] {
continue
@@ -132,13 +146,48 @@ func (s *Service) capabilityTopics(ctx context.Context) []string {
seen[t] = true
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.capTopics = topics
s.capPeople = people
s.capFetched = time.Now()
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