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 {

View File

@@ -75,8 +75,12 @@ func harryData() *fakeReader {
{ID: 9, ChildID: 1, Type: "task", Subject: "11MGMA MATHS", Title: "SAC 4: Networks",
Body: "Status: Pending", DueAt: at(2026, 10, 19, 16, 0)},
{ID: 10, ChildID: 1, Type: "message", Title: "Health Information", DueAt: at(2026, 10, 13, 9, 0)},
}},
scans: map[int64]store.ScanStatus{1: okScan(testNow.Add(-4 * time.Hour))},
},
2: {
{ID: 20, ChildID: 2, Type: "event", Title: "9:00: 10ENGA - B2 - SMI", DueAt: at(2026, 10, 15, 9, 0)},
},
},
scans: map[int64]store.ScanStatus{1: okScan(testNow.Add(-4 * time.Hour)), 2: okScan(testNow.Add(-4 * time.Hour))},
}
}
@@ -233,6 +237,72 @@ func TestEmptyQuestionCannotAnswer(t *testing.T) {
}
}
// TestChildNamedInQuestionResolvesThePerson covers the Hub's actual call shape:
// the Hub sends `person` equal to the asker, so the child's name is only in the
// question. The literal phase question must still answer.
func TestChildNamedInQuestionResolvesThePerson(t *testing.T) {
a := testAnswerer(harryData())
resp := a.Answer(context.Background(), AskRequest{
Question: "what does Harry have on today at school?",
Asker: "sam",
Person: "sam", // what the Hub forwards today (person defaults to asker)
Source: "console",
})
if resp.Answer == nil {
t.Fatalf("answer = nil, reason = %q", resp.Reason)
}
if resp.Data["child"] != "Harry" {
t.Errorf("data.child = %v, want Harry", resp.Data["child"])
}
if !strings.Contains(*resp.Answer, "school event") {
t.Errorf("answer = %q, want the lesson count", *resp.Answer)
}
}
// TestExplicitChildWinsOverQuestionName: a caller that names a child wins, even
// if the question text mentions another one.
func TestExplicitChildWinsOverQuestionName(t *testing.T) {
a := testAnswerer(harryData())
resp := a.Answer(context.Background(), AskRequest{
Question: "what does Harry have on today?",
Asker: "sam",
Person: "finn",
})
if resp.Answer == nil {
t.Fatalf("answer = nil, reason = %q", resp.Reason)
}
if resp.Data["child"] != "Finn" {
t.Errorf("data.child = %v, want Finn", resp.Data["child"])
}
}
// TestTwoChildrenNamedCannotAnswer: the contract answers for one person at
// most, so an ambiguous question is refused rather than guessed.
func TestTwoChildrenNamedCannotAnswer(t *testing.T) {
a := testAnswerer(harryData())
resp := a.Answer(context.Background(), AskRequest{
Question: "what do Harry and Finn have on today?",
Asker: "sam",
Person: "sam",
})
if resp.Answer != nil {
t.Fatalf("answer = %q, want nil for an ambiguous question", *resp.Answer)
}
}
// TestNameIsNotMatchedInsideAWord keeps the fallback to whole words.
func TestNameIsNotMatchedInsideAWord(t *testing.T) {
a := testAnswerer(harryData())
resp := a.Answer(context.Background(), AskRequest{
Question: "what does harrysaurus have on today?",
Asker: "sam",
Person: "sam",
})
if resp.Answer != nil {
t.Fatalf("answer = %q, want nil (no whole-word child name)", *resp.Answer)
}
}
func TestCapabilitiesNameMatchesRouting(t *testing.T) {
caps := DefaultCapabilities()
if caps.Project != "compass_nhs" {

View File

@@ -142,6 +142,48 @@ func TestHubCapabilitiesAndAsk(t *testing.T) {
}
}
// TestHubAskResolvesTheNamedChild covers the Hub's actual call shape: the Hub
// sends `person` equal to the asker (`PersonOrDefault`), so the child's name is
// only in the question text. The literal phase question must still answer.
func TestHubAskResolvesTheNamedChild(t *testing.T) {
ts, st := newTestServerHub(t, testAppriseConfig("", "", ""), testHubToken)
loc := mustLoc(t)
ctx := context.Background()
harry, _ := st.ChildByName(ctx, "Harry")
now := time.Now().In(loc)
today := time.Date(now.Year(), now.Month(), now.Day(), 9, 0, 0, 0, loc)
addHubItem(t, st, harry.ID, store.Item{
Type: "event", Subject: "11XMUSA6", Teacher: "JBR",
Title: "1 - 11XMUSA6 - A1 - JBR", DueAt: &today,
})
if _, err := st.RecordScan(ctx, &harry.ID, 5, 1, true, ""); err != nil {
t.Fatalf("record scan: %v", err)
}
resp, raw := hubJSON(t, ts, http.MethodPost, "/ask", testHubToken,
`{"question":"what does Harry have on today at school?","asker":"sam","person":"sam","source":"console"}`)
if resp.StatusCode != http.StatusOK {
t.Fatalf("ask status = %d, body = %s", resp.StatusCode, raw)
}
var ask struct {
Answer *string `json:"answer"`
Reason string `json:"reason"`
Data map[string]any `json:"data"`
}
if err := json.Unmarshal(raw, &ask); err != nil {
t.Fatalf("ask json: %v (%s)", err, raw)
}
if ask.Answer == nil {
t.Fatalf("answer = nil, reason = %q", ask.Reason)
}
if ask.Data["child"] != "Harry" {
t.Errorf("data.child = %v, want Harry", ask.Data["child"])
}
if !strings.Contains(*ask.Answer, "school event") {
t.Errorf("answer = %q, want the lesson count", *ask.Answer)
}
}
// TestHubAskReadsOnly is the contract's first rule made testable: /ask and
// /capabilities must not write anything (AGENTS.md §10 rule 5).
func TestHubAskReadsOnly(t *testing.T) {