[Table Tools] Auto-fill attendance from recording speakers #114

Closed
opened 2026-07-14 19:51:29 +00:00 by claude-bot · 1 comment
Contributor

Context / Motivation

The recording pipeline already knows who was at the table, but attendance is entered by hand:

  • Attendance today: SessionAttendance (webapp/backend/app/models/session_attendance.pysession_id, user_id, attended bool, unique per session+user), set manually GM-only via PUT /sessions/{session_id}/attendance/{user_id} (set_attendance, routers/sessions.py:391; service attendance_service.upsert_attendance, services/attendance_service.py:54). The model docstring even anticipates the bot setting attendance via the same endpoint.
  • Speaker identity in the pipeline: process_audio (webapp/backend/app/tasks/reminder_tasks.py:1461) reads per-user WAV tracks plus speakers.json (discord_user_id → display_name, read at :1519) from the audio_temp volume, then resolves Discord ids → users → members via PlatformLink + CampaignMember (:1541-1580).
  • Silent-but-present members: the live status already tracks them — build_recording_status (bot/questboard_bot/services/recording_status.py:10) returns both speakers (per-user seconds captured) and uncaptured_members (voice-channel members with no captured audio).

Spec

Hook: at the end of successful process_audio (the point where member identity is already resolved, reminder_tasks.py:1541-1580 / success path :1631-1646) — not at transcript approval, so proposals are ready when the GM reviews the transcript.

Proposals, not writes:

  • New columns or a small side table for proposal state, e.g. AttendanceProposal (session_id, user_id, source: spoke | in_channel_silent, created_at) — distinct from SessionAttendance so manual data is never touched.
  • Members with captured audio (from speakers.json + PlatformLink resolution) → proposed present (spoke).
  • Members present in the voice channel but silent → proposed present (in_channel_silent). Requires the bot to persist the final speakers/uncaptured_members snapshot at /record/stop time into the payload it sends the backend (extend the audio-notify contract in webapp/backend/app/routers/bot.py + bot/questboard_bot/api_client.py in the same commit per the repo rule).
  • Unlinked Discord users (no PlatformLink) are skipped and listed in the banner as "unmatched speakers".

GM UX: session page shows a one-click confirm banner ("Recording detected 4 attendees — apply?") listing proposed members and sources. Confirm applies all proposals through the existing upsert_attendance path. Dismiss discards.

Campaign setting: attendance_autofill: propose (default) | auto_apply | off. auto_apply writes attendance directly but still never overwrites an existing manually set SessionAttendance row (existing row wins in all modes).

Out of scope

  • Absence inference (not proposing "absent" for missing members — the GM decides).
  • Minimum-speaking-time thresholds beyond a simple >0 bytes captured check.
  • Retroactive proposals for already-processed sessions.

Acceptance criteria

  • A recorded session with N linked speakers + M silent channel members yields exactly N+M proposals with correct sources; unlinked speakers surface as unmatched, not as proposals.
  • Pre-existing manual attendance rows are unchanged by proposals and by auto-apply (explicit test).
  • Confirm applies all proposals in one action; resulting rows match upsert_attendance semantics.
  • off disables generation entirely; reprocessing audio does not duplicate proposals (idempotent upsert).

References

  • webapp/backend/app/models/session_attendance.py
  • webapp/backend/app/services/attendance_service.py:54 (upsert_attendance), routers/sessions.py:391 (set_attendance)
  • webapp/backend/app/tasks/reminder_tasks.py:1461 (process_audio), :1519 (speakers.json), :1541-1580 (PlatformLink/CampaignMember resolution)
  • bot/questboard_bot/services/recording_status.py:10 (build_recording_statusspeakers / uncaptured_members)
  • webapp/backend/app/routers/bot.py, bot/questboard_bot/api_client.py (API contract pair)

Filed from the July 2026 full-project review.

## Context / Motivation The recording pipeline already knows who was at the table, but attendance is entered by hand: - **Attendance today**: `SessionAttendance` (`webapp/backend/app/models/session_attendance.py` — `session_id`, `user_id`, `attended` bool, unique per session+user), set manually GM-only via `PUT /sessions/{session_id}/attendance/{user_id}` (`set_attendance`, `routers/sessions.py:391`; service `attendance_service.upsert_attendance`, `services/attendance_service.py:54`). The model docstring even anticipates the bot setting attendance via the same endpoint. - **Speaker identity in the pipeline**: `process_audio` (`webapp/backend/app/tasks/reminder_tasks.py:1461`) reads per-user WAV tracks plus `speakers.json` (`discord_user_id → display_name`, read at `:1519`) from the audio_temp volume, then resolves Discord ids → users → members via `PlatformLink` + `CampaignMember` (`:1541-1580`). - **Silent-but-present members**: the live status already tracks them — `build_recording_status` (`bot/questboard_bot/services/recording_status.py:10`) returns both `speakers` (per-user seconds captured) and `uncaptured_members` (voice-channel members with no captured audio). ## Spec **Hook**: at the end of successful `process_audio` (the point where member identity is already resolved, `reminder_tasks.py:1541-1580` / success path `:1631-1646`) — not at transcript approval, so proposals are ready when the GM reviews the transcript. **Proposals, not writes**: - New columns or a small side table for proposal state, e.g. `AttendanceProposal` (`session_id`, `user_id`, `source`: `spoke` | `in_channel_silent`, `created_at`) — distinct from `SessionAttendance` so manual data is never touched. - Members with captured audio (from `speakers.json` + `PlatformLink` resolution) → proposed **present** (`spoke`). - Members present in the voice channel but silent → proposed **present** (`in_channel_silent`). Requires the bot to persist the final `speakers`/`uncaptured_members` snapshot at `/record/stop` time into the payload it sends the backend (extend the audio-notify contract in `webapp/backend/app/routers/bot.py` + `bot/questboard_bot/api_client.py` **in the same commit** per the repo rule). - Unlinked Discord users (no `PlatformLink`) are skipped and listed in the banner as "unmatched speakers". **GM UX**: session page shows a one-click confirm banner ("Recording detected 4 attendees — apply?") listing proposed members and sources. Confirm applies all proposals through the existing `upsert_attendance` path. Dismiss discards. **Campaign setting**: `attendance_autofill`: `propose` (default) | `auto_apply` | `off`. `auto_apply` writes attendance directly but still **never overwrites an existing manually set `SessionAttendance` row** (existing row wins in all modes). ## Out of scope - Absence inference (not proposing "absent" for missing members — the GM decides). - Minimum-speaking-time thresholds beyond a simple >0 bytes captured check. - Retroactive proposals for already-processed sessions. ## Acceptance criteria - A recorded session with N linked speakers + M silent channel members yields exactly N+M proposals with correct sources; unlinked speakers surface as unmatched, not as proposals. - Pre-existing manual attendance rows are unchanged by proposals and by auto-apply (explicit test). - Confirm applies all proposals in one action; resulting rows match `upsert_attendance` semantics. - `off` disables generation entirely; reprocessing audio does not duplicate proposals (idempotent upsert). ## References - `webapp/backend/app/models/session_attendance.py` - `webapp/backend/app/services/attendance_service.py:54` (`upsert_attendance`), `routers/sessions.py:391` (`set_attendance`) - `webapp/backend/app/tasks/reminder_tasks.py:1461` (`process_audio`), `:1519` (`speakers.json`), `:1541-1580` (PlatformLink/CampaignMember resolution) - `bot/questboard_bot/services/recording_status.py:10` (`build_recording_status` — `speakers` / `uncaptured_members`) - `webapp/backend/app/routers/bot.py`, `bot/questboard_bot/api_client.py` (API contract pair) _Filed from the July 2026 full-project review._
Author
Contributor

Done — merged in PR #195 (backend+bot 80f14f8, bot-test fix 76d4fe2, frontend). CI green.

Shipped:

  • AttendanceProposal side table (spoke/in_channel_silent) distinct from SessionAttendance; migration c6d7e8f9a0b1 (round-trips). Campaign attendance_autofill (propose/auto_apply/off); Session attendance_unmatched_speakers (JSONB).
  • generate_attendance_proposals: verified-discord-link resolution only; captured→spoke, silent→in_channel_silent (spoke wins), unlinked→unmatched. Idempotent. THE INVARIANT (never overwrite a manual attendance row, any mode) enforced at one chokepoint bulk_insert_attendance_absent.
  • process_audio hook: savepoint-isolated + non-fatal + mode-aware.
  • Endpoints (GM): GET proposals, POST apply ({applied, skipped_existing}), DELETE dismiss.
  • Bot: sends uncaptured_member_ids; backend persists to presence.json. Additive — no BOT_CONTRACT_VERSION bump.
  • Frontend: GM confirm banner + attendance_autofill setting.

Tests: backend 486 pass (+8, incl. the auto_apply/apply invariant); frontend 239 (+10); bot 188 (+1 — updated the exact-call assertion for the additive uncaptured_member_ids field, which is what tripped CI once before the fix).

Acceptance criteria met: N linked speakers + M silent → N spoke + M in_channel_silent proposals, unlinked surface as unmatched; manual rows unchanged by proposals and auto_apply; confirm applies in one action; off disables generation; reprocessing is idempotent.

Closing.

Done — merged in PR #195 (backend+bot `80f14f8`, bot-test fix `76d4fe2`, frontend). CI green. **Shipped:** - `AttendanceProposal` side table (`spoke`/`in_channel_silent`) distinct from `SessionAttendance`; migration `c6d7e8f9a0b1` (round-trips). Campaign `attendance_autofill` (`propose`/`auto_apply`/`off`); Session `attendance_unmatched_speakers` (JSONB). - `generate_attendance_proposals`: verified-discord-link resolution only; captured→spoke, silent→in_channel_silent (spoke wins), unlinked→unmatched. Idempotent. **THE INVARIANT** (never overwrite a manual attendance row, any mode) enforced at one chokepoint `bulk_insert_attendance_absent`. - `process_audio` hook: savepoint-isolated + non-fatal + mode-aware. - Endpoints (GM): GET proposals, POST apply (`{applied, skipped_existing}`), DELETE dismiss. - Bot: sends `uncaptured_member_ids`; backend persists to `presence.json`. Additive — no `BOT_CONTRACT_VERSION` bump. - Frontend: GM confirm banner + `attendance_autofill` setting. **Tests:** backend 486 pass (+8, incl. the auto_apply/apply invariant); frontend 239 (+10); bot 188 (+1 — updated the exact-call assertion for the additive `uncaptured_member_ids` field, which is what tripped CI once before the fix). **Acceptance criteria** met: N linked speakers + M silent → N spoke + M in_channel_silent proposals, unlinked surface as unmatched; manual rows unchanged by proposals and auto_apply; confirm applies in one action; `off` disables generation; reprocessing is idempotent. Closing.
rbrooks referenced this issue from a commit 2026-07-18 04:59:00 +00:00
Sign in to join this conversation.
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
rbrooks/Quest-Board#114
No description provided.