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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user