SmartestHome/chores/README.md

183 lines
11 KiB
Markdown

# chores
Camera-verified household task distribution, from
[Phase 20 of the project plan](../docs/project-plan.md).
**The principle, stated once because it drives every design choice here: "I don't
care who does it, as long as it gets done."** This is not an assignment system that
picks one fair person and waits on them — it's a nudge system that keeps redirecting
to whoever is actually around until the chore is done. Fairness is tracked for
comparison, not enforced by any algorithm — see "The tally is passive" below.
Runs every ~2 hours via a systemd timer with `RandomizedDelaySec=1800` (systemd's
own built-in jitter — the "+/-30 min in case something else is running" the phase
was specified with, not custom code). Three steps, in order — see `check.py`'s own
module docstring for the full reasoning on each:
1. **Trash-day-eve** — reads the same personal collection-date feed
`trash-calendar` reads (`WASTE_ICS_URL`); if pickup is tomorrow, opens a "trash"
chore.
2. **Camera checks** (opt-in, needs `FRIGATE_URL` + `CAMERA_WATCHPOINTS`) — grabs a
Frigate snapshot per configured watch point (optionally moving a PTZ camera to a
preset first), asks an Ollama vision model a one-word question ("is this bin
FULL/PARTIAL/EMPTY", "is this counter DIRTY/CLEAN", "is there litter left out
here, YES/NO"), opens a chore on "needs attention," auto-closes one on "clear."
3. **Nudging** — ASAP, not on a fixed schedule: the first run after a chore opens
nudges whoever `identity` reports home right now — preferring anyone **assigned**
that chore type, minus anyone `chore_exempt`, both below. If the chore is still open `NEGLECT_THRESHOLD_HOURS` after the last
nudge (and the household calendar isn't showing a busy window), the nudge goes
to **someone different from who was last asked** — "the next person that walks
by" — rather than re-nagging the same person. `litter` chores are special-cased
to prefer whoever the camera most recently recognized nearby (a best-effort
"who left this" guess), since the point there is telling the actual person, not
just whoever's around — and `litter` also ignores `chore_exempt` entirely, see
below. Each nudge's wording is a plain template unless the target has a
`chore_reminder_style` set, see below.
## Assignment is a preference, not a lock
Set in `identity`'s admin panel (`admin.html`, People → a person → Chores), stored as
that person's `chore_assignments` and read off the same `/presence` call as everything
else here.
When someone assigned to a chore type **is home**, they get nudged instead of whoever
happens to be nearest — that's the point of assigning it. When **no assignee is home**,
the nudge falls through to the ordinary whoever's-here rotation rather than waiting,
because the stated principle is still *"I don't care who does it, as long as it gets
done."* An absent assignee doesn't get to be the reason the bin stays full.
Set **`CHORE_ASSIGNMENT_STRICT=true`** if your household wants the opposite: the chore
waits for its assignee and nobody else is asked. Both readings of "assign chores" are
legitimate and this file can't pick for you, so it's one env var rather than a
hard-coded opinion. Strict mode still never stalls a chore that was assigned to
*nobody* — that falls through regardless.
**Litter can't be assigned** (`_ASSIGNMENTS_DONT_APPLY` in `check.py`), for the same
reason it ignores exemptions: it goes to whoever left the mess, and cleaning up after
yourself was never a task anyone could be handed.
## Chore-exempt people — everyone except litter
Set in the same place (`identity`'s admin panel, or `POST /people/<id>/chore-settings`
directly). A `chore_exempt` person is
dropped from the nudge rotation entirely — the "cousin visits often but doesn't owe
me chores" case. **Litter is the deliberate exception** (`_EXEMPTIONS_DONT_APPLY`
in `check.py`): an exempt person still gets told to put trash they left out into
the bin, because that isn't "doing a chore," it's cleaning up after yourself. If
everyone currently home is chore_exempt for a non-litter chore, that run just logs
and skips — the chore stays open until someone eligible is around.
## Reminder tone is per-person and LLM-phrased, but never LLM-decided
`identity`'s `chore_reminder_style` free-text field ("be assertive, don't let up" /
"be gentle, give me a few minutes of grace") is passed to `OLLAMA_TEXT_MODEL` (if
configured) purely to phrase the ntfy message text — see `_compose_message()`. This
is deliberately scoped narrower than the general chore-distribution LLM idea from
earlier in the project: it changes *how the reminder is worded*, never *who* gets
nudged or *when* — that decision stays presence/calendar-driven, per the module
docstring's stated principle. No `OLLAMA_TEXT_MODEL` configured, no style set for
that person, or the call fails/returns nothing for any reason: the plain
un-styled template is used verbatim, same wording as before this feature existed.
## The tally is passive
Every nudge is logged with who got it. **Nothing reads that log to decide who to
nudge next** — that decision is presence/calendar-driven only, per the stated
principle. The log exists purely so a household member can look at the numbers
later and judge fairness for themselves ("comparison for fairness's sake," not an
automated fairness algorithm) — `print_tally()` logs a rolling 30-day count each
run; nothing renders it anywhere yet, see "What's not built."
## The calendar busy-check is household-wide, not per-person
`_household_currently_busy()` looks for a currently-active event on the **one**
shared household calendar whose summary contains a configured keyword (`busy`,
`meeting`, `call`, `movie`, `sleep`, ...) and pauses nudging for that run if it
finds one. This is a real, honest limitation: it can't tell that only one person is
in that meeting and nudge someone else who's free — everyone's nudges pause
together. A real per-person calendar/availability model would need per-person
calendars, which this project doesn't have (see `docs/project-plan.md`'s open
decisions). Fails open (treats an unreachable/misconfigured calendar as "not busy")
so a broken calendar check can never be the reason chores stop getting nudged
entirely.
## Never auto-completes from nudging
Only a fresh camera check finding a watch point clear, or a manual close (not built
here — see "What's not built" below), ever marks a chore done. A nudge firing is
not the same as the chore being done; conflating the two would let something that
was repeatedly nudged but never actually done silently vanish.
## Why this is a separate service, not folded into `identity` or `pantry-vision`
It reads from `identity` (who's home, who's nearby, who was recently seen by a
camera) and reuses `trash-calendar`'s own ICS feed config and digest-engine's own
CalDAV credential names, but owns a genuinely different job — task state,
presence-driven nudging, notification escalation — that doesn't belong bolted onto
any of those services' own single responsibilities. Same reasoning as
`trash-calendar` itself being separate from `digest-engine`'s read-only calendar
ingestion.
## What's not built
- **No way to manually mark a chore done** — no HA button, no voice phrase, no API
endpoint. Right now the only way a chore closes is a camera re-check finding it
clear, or direct SQLite surgery. A real deployment probably wants an HA
button/voice "mark the trash as done" — deliberately left out of this pass rather
than guessed at without knowing how the household actually wants to interact with
it.
- **No per-person ntfy topics** — one shared `NTFY_TOPIC`, message text names who
it's for. Set up real per-person routing yourself if that's not enough.
- **No web UI / dashboard for chore history or the fairness tally** —
`chore_events` logs everything (created, nudged, auto_closed) and the tally is
logged each run, but nothing renders either anywhere yet.
- **No per-person calendar availability** — see "household-wide, not per-person"
above.
- **No re-check of `_compose_message()`'s LLM output** — whatever the model
returns (if anything) is sent as-is, no validation that it's actually on-topic,
on-tone, or even non-empty garbage beyond the plain empty-string fallback check.
- **No confirmation that an assignee actually did it** — assignment changes who gets
asked, not who gets credited. Attribution when a chore closes is still the same
best-effort "whoever was nudged most recently" heuristic, which an assignment doesn't
make any more truthful. See "The tally is passive" above.
## Configure
```sh
cp chores/chores.env.example /opt/smart-home/chores/chores.env
chmod 600 /opt/smart-home/chores/chores.env
$EDITOR /opt/smart-home/chores/chores.env
```
Works with nothing but `IDENTITY_URL`/`IDENTITY_TOKEN` and ntfy filled in — the
trash-day-eve check, calendar busy-check, and camera checks all individually no-op
when left unconfigured.
## Manual verification still outstanding
1. **Frigate's PTZ move-to-preset API shape** (`_frigate_snapshot()`'s
`POST /api/<camera>/ptz/move/<preset>`) is assumed from Frigate's general PTZ
feature set, not confirmed against a real Tapo PTZ camera wired into Frigate —
see `docs/project-plan.md` §1.18/Phase 20's own callout. A wrong endpoint just
means the snapshot is taken from wherever the camera already was.
2. Whether the vision model's one-word FULL/PARTIAL/EMPTY/DIRTY/CLEAN/YES/NO
answers are actually reliable for a real bin/sink/hallway from a real camera
angle — completely unmeasured, same caveat as pantry-vision's own vision-model
accuracy note.
3. `_likely_culprit()`'s reliance on `identity`'s `face_seen_recently` field
assumes Frigate face-recognition presence corroboration is actually wired up
and working (`identity/README.md`'s own "Camera face recognition" section is
itself unverified) — until then, litter chores just fall back to "whoever's
around," same as every other chore type.
4. `_household_currently_busy()`'s CalDAV read has never been run against a real
Nextcloud instance from this specific code path (digest-engine's own
`ingest/caldav.py` is a separate, independently-tested read).
5. No camera hardware has been chosen (`docs/project-plan.md` §1.18) — nothing here
has been run against a real Tapo camera or Frigate PTZ integration at all.
6. `_compose_message()`'s LLM-phrased reminders have never been checked against a
real `OLLAMA_TEXT_MODEL` for whether the output actually respects a given
`chore_reminder_style` reliably, vs. just producing generically-toned text —
same "no measured accuracy" caveat as the vision checks above. A bad or
off-style result still gets sent (there's no re-check of the LLM's own output
here), just not blocked — see "What's not built" for why there's no re-check.