Phase 8 follow-up: resolve the child named in the question

The Hub forwards `person` equal to the asker (`PersonOrDefault`), so the spoken
question "what does Harry have on today at school?" arrives with person "sam"
and returned "no data for that person" (found by the Hub agent on AUD-116).

resolveChild now falls back to the question text when the requested person is
not one of this project's children: whole-word, case-insensitive match against
the child names this service already publishes in /capabilities. An explicit
person still wins, two named children still return answer:null, and the
permission check still runs on the resolved child. No model, no free text.

Live end-to-end through the Hub, with no person field:

  POST http://192.168.20.13:8788/ask
  {"text":"what does Harry have on today at school?","asker":"sam","source":"console"}
  200
  {"status":"ok",
   "answer":"Harry has 8 school events today from 8:55am to 6:00pm and 6 overdue tasks.",
   "route":{"kind":"project","project":"compass_nhs"}}

Image 0.6.1-phase8; docs/phase-8-hub-notes.md records the fallback and the
end-to-end transcript.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
2026-10-15 19:54:26 +11:00
parent 75e4ff12bf
commit 06f759c37b
7 changed files with 230 additions and 31 deletions

View File

@@ -20,6 +20,7 @@ import (
"fmt"
"strings"
"time"
"unicode"
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/dashboard"
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/store"
@@ -156,7 +157,7 @@ func (a *Answerer) Answer(ctx context.Context, req AskRequest) AskResponse {
if err != nil {
return cannotAnswer("the school data could not be read")
}
child := findChild(children, person)
child := resolveChild(children, person, req.Question)
if child == nil {
return cannotAnswer("no data for that person")
}
@@ -415,6 +416,55 @@ func findChild(children []store.Child, name string) *store.Child {
return nil
}
// resolveChild picks the child a question is about.
//
// The contract makes `person` optional and says it defaults to `asker`. The Hub
// (as of AUD-116) forwards `person = asker`, so a spoken question such as "what
// does Harry have on today at school?" arrives as person "sam". This project is
// the expert on its own data: when the requested person is not one of its
// children, it looks for exactly one child named in the question text. The name
// must be one the service already publishes in /capabilities, the match is
// whole-word and case-insensitive, and the permission check still runs
// afterwards, so nothing is guessed and no data leaks between children.
func resolveChild(children []store.Child, person, question string) *store.Child {
if c := findChild(children, person); c != nil {
return c
}
words := wordsIn(question)
var named []*store.Child
for i := range children {
if words[normalize(children[i].Name)] {
named = append(named, &children[i])
}
}
if len(named) == 1 {
return named[0]
}
return nil
}
// wordsIn lower-cases a question and splits it into whole words, so "harry"
// matches in "what does Harry have on?" but never inside a longer word.
func wordsIn(s string) map[string]bool {
out := map[string]bool{}
var b strings.Builder
flush := func() {
if b.Len() > 0 {
out[b.String()] = true
b.Reset()
}
}
for _, r := range s {
if unicode.IsLetter(r) || unicode.IsDigit(r) {
b.WriteRune(unicode.ToLower(r))
continue
}
flush()
}
flush()
return out
}
func onlyType(items []store.Item, typ string) []store.Item {
out := make([]store.Item, 0, len(items))
for _, it := range items {