// Read-only question answering for the Hub (AGENTS.md §4 FR-12, §10 rule 9, // §11 Phase 9). // // The answerer decides intent from the question text and answers from SQLite. // It is not an LLM call: it is deterministic, answers in milliseconds, and // never guesses. An unanswerable question returns answer=null plus a reason. // // Reader is the read-only data surface. *store.Store satisfies it. Keeping it // an interface makes the read-only guarantee explicit: every method reads. package hub import ( "context" "fmt" "strings" "time" "gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/store" ) // Reader is the read-only data surface the answerer uses. type Reader interface { ListDevices(ctx context.Context) ([]store.Device, error) EffectiveState(ctx context.Context, dev store.Device, now time.Time) (store.EffectiveState, error) UsageByDeviceForDay(ctx context.Context, day string) ([]store.DeviceDayUsage, error) } // Answerer answers one question at a time from a read-only Reader. type Answerer struct { Reader Reader // Location is the schedule timezone, so "today" matches the day a device // stamped its telemetry in. Defaults to the process local zone. Location *time.Location // Now is injectable so the answer is deterministic in tests. Now func() time.Time } // NewAnswerer builds an Answerer over a read-only Reader. func NewAnswerer(r Reader, loc *time.Location) *Answerer { if loc == nil { loc = time.Local } return &Answerer{Reader: r, Location: loc, Now: func() time.Time { return time.Now().In(loc) }} } func (a *Answerer) now() time.Time { if a.Now == nil { return time.Now().In(a.loc()) } return a.Now() } func (a *Answerer) loc() *time.Location { if a.Location == nil { return time.Local } return a.Location } // Answer returns a short, speakable answer for one question. It never writes // state. func (a *Answerer) Answer(ctx context.Context, req AskRequest) AskResponse { now := a.now() asker := strings.ToLower(strings.TrimSpace(req.Asker)) person := strings.ToLower(strings.TrimSpace(req.Person)) switch { case asker == "": return cannotAnswer("no asker was given") case asker != "sam": return cannotAnswer("I only answer for Sam") } q := normalizeQuestion(req.Question) if q == "" { return cannotAnswer("no question was given") } // Resolve who the question is about. The person field wins when it names a // child; otherwise a child named in the question is used; otherwise every // device is included. child := "" switch person { case "", "sam", "kids", "children", "everyone", "both": // aggregate (or derive from the question below) case "harry", "finn": child = person default: return cannotAnswer("no data for that person") } if child == "" { switch { case strings.Contains(q, "harry"): child = "harry" case strings.Contains(q, "finn"): child = "finn" } } devs, err := a.Reader.ListDevices(ctx) if err != nil { return cannotAnswer("the device list could not be read") } if child != "" && !knownChild(devs, child) { return cannotAnswer("no data for that person") } switch { case containsAny(q, "screen time", "screentime", "screen-time", "on screens", "on screen", "screen", "how long", "spent", "usage", "used"): return a.answerScreenTime(ctx, now, child, q) case containsAny(q, "blocked", "block", "locked", "lock", "restricted", "restrict"): return a.answerBlocked(ctx, now, child, devs) case containsAny(q, "online", "offline", "connected", "reachable"): return a.answerPresence(ctx, now, child, devs) default: return cannotAnswer("I can answer questions about screen time, what is blocked, and whether a device is online") } } // answerScreenTime answers "how long have the kids been on screens today?" from // the stored daily rollup. The day is "today" in the schedule timezone, or // yesterday when the question says so. func (a *Answerer) answerScreenTime(ctx context.Context, now time.Time, child, q string) AskResponse { day := now if containsAny(q, "yesterday", "last night") { day = now.AddDate(0, 0, -1) } dayStr := day.Format("2006-01-02") usage, err := a.Reader.UsageByDeviceForDay(ctx, dayStr) if err != nil { return cannotAnswer("usage could not be read") } total := 0 byDevice := make([]map[string]any, 0, len(usage)) for _, u := range usage { if child != "" && !strings.EqualFold(strings.TrimSpace(u.Child), child) { continue } seconds := ScreenSeconds(u.AppSeconds, u.DomainSeconds) total += seconds byDevice = append(byDevice, map[string]any{ "device": u.Name, "child": u.Child, "seconds": seconds, "app_seconds": u.AppSeconds, "domain_seconds": u.DomainSeconds, }) } when := "today" if strings.EqualFold(dayStr, now.AddDate(0, 0, -1).Format("2006-01-02")) { when = "yesterday" } subject := "The kids" verb := "have" if child != "" { subject = titleCase(child) verb = "has" } answer := fmt.Sprintf("%s %s had no recorded screen time %s.", subject, verb, when) if total > 0 { answer = fmt.Sprintf("%s %s had %s of screen time %s.", subject, verb, humanDuration(total), when) } return AskResponse{ Answer: answerText(answer), Data: map[string]any{"day": dayStr, "total_seconds": total, "by_device": byDevice}, Expires: expiresIn(now, 15*time.Minute), } } // answerBlocked reports whether anything is blocked right now, and until when. func (a *Answerer) answerBlocked(ctx context.Context, now time.Time, child string, devs []store.Device) AskResponse { blocked := make([]map[string]any, 0, len(devs)) for _, dev := range devs { if !matchesChild(dev, child) { continue } eff, err := a.Reader.EffectiveState(ctx, dev, now) if err != nil { return cannotAnswer("device state could not be read") } if !eff.Locked { continue } item := map[string]any{ "device": dev.Name, "child": dev.Child, "reason": eff.Reason, } if eff.Until != nil { item["until"] = eff.Until.UTC().Format(time.RFC3339) item["until_local"] = eff.Until.In(a.loc()).Format("15:04") } blocked = append(blocked, item) } resp := AskResponse{ Data: map[string]any{"blocked": blocked, "count": len(blocked)}, Expires: expiresIn(now, 5*time.Minute), } switch len(blocked) { case 0: resp.Answer = answerText("Nothing is blocked right now.") case 1: name, _ := blocked[0]["device"].(string) reason, _ := blocked[0]["reason"].(string) if until, ok := blocked[0]["until_local"].(string); ok && until != "" { resp.Answer = answerText(fmt.Sprintf("%s is blocked until %s (%s).", name, until, reason)) } else { resp.Answer = answerText(fmt.Sprintf("%s is blocked (%s).", name, reason)) } default: resp.Answer = answerText(fmt.Sprintf("%d devices are blocked right now.", len(blocked))) } return resp } // answerPresence reports whether the devices are online. Presence comes from // the broker's Last Will and Testament (FR-6). func (a *Answerer) answerPresence(ctx context.Context, now time.Time, child string, devs []store.Device) AskResponse { selected := make([]store.Device, 0, len(devs)) for _, dev := range devs { if matchesChild(dev, child) { selected = append(selected, dev) } } if len(selected) == 0 { return cannotAnswer("no devices are registered") } offline := make([]map[string]any, 0, len(selected)) online := 0 for _, dev := range selected { if dev.Online { online++ continue } item := map[string]any{"device": dev.Name, "child": dev.Child} if dev.LastSeen != nil { item["last_seen"] = dev.LastSeen.UTC().Format(time.RFC3339) item["last_seen_local"] = dev.LastSeen.In(a.loc()).Format("15:04") item["offline_for"] = humanDuration(int(now.Sub(*dev.LastSeen).Seconds())) } offline = append(offline, item) } resp := AskResponse{ Data: map[string]any{"online": online, "offline": offline, "total": len(selected)}, Expires: expiresIn(now, 5*time.Minute), } switch { case len(offline) == 0: if len(selected) == 1 { resp.Answer = answerText(fmt.Sprintf("%s is online.", selected[0].Name)) } else { resp.Answer = answerText(fmt.Sprintf("All %d devices are online.", len(selected))) } case len(offline) == 1: name, _ := offline[0]["device"].(string) dur, _ := offline[0]["offline_for"].(string) resp.Answer = answerText(fmt.Sprintf("%s is offline (for %s).", name, dur)) default: resp.Answer = answerText(fmt.Sprintf("%d devices are offline.", len(offline))) } return resp } // ScreenSeconds is the screen-time measure for one device-day. The // foreground-app total is the measure; domain seconds are a browsing // sub-measure and are only used when a device reported no app rows at all (the // phone's VPN filter, for example). A report that carries both is not // double-counted. func ScreenSeconds(app, domain int) int { if app > 0 { return app } return domain } // knownChild reports whether any registered device belongs to child. func knownChild(devs []store.Device, child string) bool { for _, dev := range devs { if strings.EqualFold(strings.TrimSpace(dev.Child), child) { return true } } return false } // matchesChild reports whether a device is in scope for a question. An empty // child means every device. func matchesChild(dev store.Device, child string) bool { return child == "" || strings.EqualFold(strings.TrimSpace(dev.Child), child) } // normalizeQuestion lowercases and collapses whitespace so intent matching is // stable. func normalizeQuestion(q string) string { return strings.Join(strings.Fields(strings.ToLower(q)), " ") } func containsAny(q string, subs ...string) bool { for _, s := range subs { if strings.Contains(q, s) { return true } } return false } // expiresIn formats an RFC3339 expiry used by data-bearing answers. func expiresIn(now time.Time, d time.Duration) string { return now.UTC().Add(d).Format(time.RFC3339) } // titleCase upper-cases the first letter of a single-word name. func titleCase(s string) string { s = strings.TrimSpace(s) if s == "" { return s } r := []rune(s) return strings.ToUpper(string(r[0])) + string(r[1:]) } // humanDuration renders seconds compactly: "3h 20m", "45m", "under a minute". func humanDuration(seconds int) string { if seconds <= 0 { return "0m" } if seconds < 60 { return "under a minute" } m := seconds / 60 h := m / 60 m %= 60 if h == 0 { return fmt.Sprintf("%dm", m) } return fmt.Sprintf("%dh %02dm", h, m) }