Phase 9: Hub API and Apprise alerts (/capabilities, /ask, /notify)
Build on Phases 1-8. Adds the project side of the frozen Hub contract
(central_ai_llm_master/docs/ask-contract.md) and the alert engine from
AGENTS.md §4 FR-12/FR-13, §8, §11 Phase 9.
- hub: internal/hub implements AskRequest/AskResponse/NotifyRequest and a
deterministic, read-only Answerer. It answers screen time today/yesterday,
what is blocked now, and device presence, scoped to one child or all kids.
Screen time is the foreground-app total per device-day; the domain total is
used only when a device reports no apps, so a report carrying both is never
double-counted.
- apprise: internal/apprise is a small Apprise API client (stored key or
stateless URL mode, optional bearer token, typed notifications).
- alert: internal/alert evaluates device state on an interval and raises one
Apprise alert per transition: a limit hit (a device becomes blocked) or a
device offline longer than the threshold. A never-seen device is never
alerted, and the first observation seeds state without replaying an old
limit. The alert is recorded before delivery, with its delivery outcome.
- store: migration 005 adds `alerts` (delivery outcome, audited) and
`alert_state` (per-device dedup). New usage reads from the telemetry detail
table so the daily answer can be checked independently.
- web: GET /capabilities, POST /ask (read-only) and POST /notify behind the
shared CONTROL_HUB_TOKEN, separate from the session and admin API tokens.
/notify accepts the contract's {from,level,message,person} and delivers it
through Apprise (the future tamper-report path, FR-17).
- cmd/control: start the alert engine (CONTROL_ALERTS_ENABLED,
CONTROL_ALERT_OFFLINE_AFTER, CONTROL_ALERT_INTERVAL) and add `alerts` and
`usage` subcommands for read-only inspection.
- deploy/verify-phase9.sh + `make verify-phase9` prove the definition of done;
version 0.7.0-phase9.
Measured on .13 (2026-10-13):
- GET /capabilities -> internet_use_control topics.
- POST /ask "how long have the kids been on screens today?" -> "The kids have
had 1h 22m of screen time today." (total_seconds=4925); the independent
count from the telemetry detail table is also 4925 (app=4925, domain=4800).
- A manual lock raised a delivered limit alert (id 4); an MQTT offline status
raised a delivered offline alert (id 5); POST /notify delivered (id 6). All
three report delivered=1 in `alerts`.
Tests: gofmt, go vet ./... and go test ./... pass in golang:1.25-alpine.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
345
internal/hub/ask.go
Normal file
345
internal/hub/ask.go
Normal file
@@ -0,0 +1,345 @@
|
||||
// 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)
|
||||
}
|
||||
Reference in New Issue
Block a user