Stage 5: Alembic migrations, durable call rows, one transcript truth #5

Merged
r merged 1 commits from feature/stage5-data-layer into feature/stage4-honest-health 2026-07-10 17:49:01 +00:00
Owner

Stage 5 of the architecture-review roadmap (stacked on #4). The data layer stops being a demo: schema changes are migrations, a call exists in history the moment it starts, and there is exactly one representation each for transcripts, responses, and row mapping.

Alembic first (F8)

  • alembic.ini + async db/migrations/env.py against Base.metadata. Two entry paths share it: CLI (alembic upgrade head) builds its own engine from Settings.database_url; app startup passes its open connection via config.attributes["connection"].
  • Baseline 1173a71329ed autogenerated from the create_all-era schema; 5187577efc23 drops the dead call_records.transcript Text column and converts devices.is_online String → Boolean (batch mode for SQLite, USING coalesce(lower(is_online) in ('true','t','1'), false) for Postgres).
  • init_db now runs upgrade head; a pre-Alembic database (no alembic_version, tables present) is stamped at the baseline first, so existing deployments adopt cleanly. create_all remains for tests only.
  • Verified against the real dev DB: dry-ran the DDL in a rolled-back transaction first, then let boot-time init_db adopt + migrate it — now at head with the new schema. (Noted: an orphan contacts table from the Stage-4 model deletion still sits in the dev DB; left untouched.)

Durable call rows (F8)

CallManager gains an on_call_created hook; the composition root wires it to persist_call_on_create, which inserts an in_progress CallRecord immediately (same 3× retry policy). persist_call_on_end now finalizes that row (update-or-insert, so a failed create still gets a record). Acceptance test run live: placed a mock call over REST, kill -9'd the server mid-call — the in_progress row survived.

One transcript representation (F8)

  • ActiveCall.transcript_chunks holds TranscriptEntry(t_offset_ms, speaker, text); add_transcript stamps real offsets from connect time — click-to-seek can actually work now.
  • The receptionist passes speaker="caller" instead of smuggling it in "caller: ..." string prefixes; the prefix-parsing hack in persistence is gone.
  • Persisted TranscriptChunk rows carry the real t_offset_ms; the never-read CallRecord.transcript Text column is dropped by migration.

Model de-triplication (F8)

  • CallResponse.from_call / CallStatusResponse.from_call — the one ActiveCall→response mapping, replacing hand-copied field lists in api/calls.py.
  • DeviceStatus deleted: can_receive_call is now a serialized computed_field on the Device domain model and the list endpoint returns list[Device].
  • All row↔dict/domain mapping is concentrated in call_persistence.py: record_summary/record_detail/chunk_to_dict (call_history routes are now one-liners) and create/update/delete_device_row (devices routes no longer touch the ORM).

Tests

New tests/test_data_layer.py (7): upgrade-head-matches-models, pre-Alembic adoption (baseline schema without alembic_version → stamped + migrated), durable in_progress row from creation through finalize with transcript chunks, end-without-create fallback, real transcript offsets + speakers, consolidated response models. 134 passed + the 2 pre-existing failures on main.

Live verification: boot with USE_MOCK_SIP=true migrated the dev DB (alembic_version=5187577efc23, transcript column gone, is_online boolean), /health degraded/mock/db-ok, history endpoint serves from the shared mappers.

🤖 Generated with Claude Code

Stage 5 of the architecture-review roadmap (stacked on #4). The data layer stops being a demo: schema changes are migrations, a call exists in history the moment it starts, and there is exactly one representation each for transcripts, responses, and row mapping. ## Alembic first (F8) - `alembic.ini` + async `db/migrations/env.py` against `Base.metadata`. Two entry paths share it: CLI (`alembic upgrade head`) builds its own engine from `Settings.database_url`; app startup passes its open connection via `config.attributes["connection"]`. - **Baseline `1173a71329ed`** autogenerated from the create_all-era schema; **`5187577efc23`** drops the dead `call_records.transcript` Text column and converts `devices.is_online` String → Boolean (batch mode for SQLite, `USING coalesce(lower(is_online) in ('true','t','1'), false)` for Postgres). - `init_db` now runs `upgrade head`; a pre-Alembic database (no `alembic_version`, tables present) is stamped at the baseline first, so existing deployments adopt cleanly. `create_all` remains for tests only. - **Verified against the real dev DB**: dry-ran the DDL in a rolled-back transaction first, then let boot-time `init_db` adopt + migrate it — now at head with the new schema. (Noted: an orphan `contacts` table from the Stage-4 model deletion still sits in the dev DB; left untouched.) ## Durable call rows (F8) `CallManager` gains an `on_call_created` hook; the composition root wires it to `persist_call_on_create`, which inserts an `in_progress` CallRecord immediately (same 3× retry policy). `persist_call_on_end` now finalizes that row (update-or-insert, so a failed create still gets a record). **Acceptance test run live**: placed a mock call over REST, `kill -9`'d the server mid-call — the `in_progress` row survived. ## One transcript representation (F8) - `ActiveCall.transcript_chunks` holds `TranscriptEntry(t_offset_ms, speaker, text)`; `add_transcript` stamps real offsets from connect time — click-to-seek can actually work now. - The receptionist passes `speaker="caller"` instead of smuggling it in `"caller: ..."` string prefixes; the prefix-parsing hack in persistence is gone. - Persisted `TranscriptChunk` rows carry the real `t_offset_ms`; the never-read `CallRecord.transcript` Text column is dropped by migration. ## Model de-triplication (F8) - `CallResponse.from_call` / `CallStatusResponse.from_call` — the one ActiveCall→response mapping, replacing hand-copied field lists in `api/calls.py`. - `DeviceStatus` deleted: `can_receive_call` is now a serialized `computed_field` on the `Device` domain model and the list endpoint returns `list[Device]`. - All row↔dict/domain mapping is concentrated in `call_persistence.py`: `record_summary`/`record_detail`/`chunk_to_dict` (call_history routes are now one-liners) and `create/update/delete_device_row` (devices routes no longer touch the ORM). ## Tests New `tests/test_data_layer.py` (7): upgrade-head-matches-models, pre-Alembic adoption (baseline schema without `alembic_version` → stamped + migrated), durable `in_progress` row from creation through finalize with transcript chunks, end-without-create fallback, real transcript offsets + speakers, consolidated response models. **134 passed** + the 2 pre-existing failures on main. Live verification: boot with `USE_MOCK_SIP=true` migrated the dev DB (`alembic_version=5187577efc23`, transcript column gone, `is_online` boolean), `/health` degraded/mock/db-ok, history endpoint serves from the shared mappers. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
r added 1 commit 2026-07-10 11:43:24 +00:00
Alembic replaces create_all as the schema authority: async env.py
against Base.metadata (CLI and in-app entry paths share it via
config.attributes["connection"]), an autogenerated baseline of the
create_all-era schema, and init_db now runs upgrade head — stamping
the baseline first on a pre-Alembic database so existing deployments
adopt cleanly. create_all remains for tests only.

Calls are durable from the start: CallManager gains an
on_call_created hook (wired to persist_call_on_create) that inserts
an in_progress CallRecord the moment a call is created;
persist_call_on_end finalizes that same row. A SIGKILL mid-call now
leaves an in_progress row instead of erasing the call from history
(verified live against the dev database).

One transcript representation: ActiveCall.transcript_chunks holds
TranscriptEntry (t_offset_ms, speaker, text) — add_transcript stamps
real offsets from connect time, receptionist passes speaker instead
of encoding it into "caller: ..." strings, persisted chunks carry
real seek offsets, and the dead CallRecord.transcript Text column is
dropped by migration. Device.is_online migrates String → Boolean
(with a USING cast for existing rows).

Model de-triplication: CallResponse/CallStatusResponse build via
from_call classmethods (one ActiveCall→response mapping);
DeviceStatus deleted — can_receive_call is a computed field on
Device and the list endpoint returns the domain model; all row↔dict
and row↔domain mapping now lives in call_persistence.py
(record_summary/record_detail/chunk_to_dict + device row functions).

New tests/test_data_layer.py: upgrade-head-matches-models,
pre-Alembic adoption, durable in_progress rows, end-without-create
fallback, transcript offsets, consolidated response models.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
r merged commit 5bc782540b into feature/stage4-honest-health 2026-07-10 17:49:01 +00:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: r/hold-slayer#5