feat(evals): synthetic-session harness — an invented 30-minute session that exercises transcription end to end (#433) #497
Loading…
Reference in a new issue
No description provided.
Delete branch "feat/433-synthetic-session"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #433 (build stages 1–4 and 6–8 of the plan; the LLM leg of stage 5 and its band calibration run during dev validation — see "Known gaps").
The per-speaker transcription change (#342) had only ever run against unit mocks. This is a committed, re-runnable session built from invented audio: no real voices, no real content.
What
evals/synthetic_session/scene.py): 218 utterances, 1160 s of speech in 1800 s, six tracks + a presence-only member — GM, two linked players, a late joiner at 360 s, an unlinked guest, a quiet player (~68 s, mostly out-of-game) and an all-zeros silent track. Every line carries a register (IC 899 s / OOC 169 s / META 72 s / PROPOSED 20 s → table talk 20.7 %), 16.5 % overlap, the four seeded episodes, the proposed-but-unperformed plan, a repeated line by two speakers, twelve scripted silences. Timing sampled from statistics with a fixed seed; nothing from a real session. One documented deviation: the plan's speech shares were mutually inconsistent, so the majors' ratios and the quiet player's absolute figure were kept (GM lands at 44 %)..opus, 9.07 MB (within the carve-out budget), each verified by transcription (mean WER 4.8 %); the renderer may retry a synthesis failure but is forbidden from retrying a missing marker.fullandic_onlyassemblies;track_sha256over PCM frames (a file hash would break on an ffmpeg upgrade for a non-audio reason); the resample step runs the bot'sconvert_to_whisper_wavcommand rather than importing the bot's numpy resampler into the backend.seed_db --i-know-this-writes --verifyseeds all threeresolve_speakerslabel paths and proves each resolves to the manifest's label.large-v3-turbo, CUDA): session WER 3.73 % full / 3.32 % ic-only, marker detection 72 % / 65 %, foreign markers 0, onset drift median 0.07 s, the quiet player above the 30 s threshold in both assemblies. Exact assertions are exact; bands carry the measured value and the rationale.has_audible_speechprecondition, #344 snowflake-as-value on the unlinked owner, #342 symmetric swap).QB_SYNTHETIC_REQUIRE_AUDIO=1so a silently skipped subject fails rather than passing.make synthetic-session. README with what it proves, the five caveats, and how to author and re-render.Rebased onto main (changelog and Makefile conflicts; both sides kept — a follow-up commit repairs the Makefile after the first resolution went wrong).
Known gaps (also in the README)
POST /api/bot/sessions/{id}/audiowith a real LLM, thefullvsic_onlysummary comparison, and leg 3 (live LLM on the ASR transcript) are not wired — the curl and the seeding/assembly are validated for it; the orchestrator runs it during dev validation.Marlgatepassed 2/2 and was lost at all four occurrences) — the highest-value follow-on.Verification
🤖 Generated with Claude Code
`manifest.json` is the ground truth for everything scoreable in the fixture, and `assemble.py` turns it plus the committed clips into exactly the directory the bot hands the backend: `{discord_user_id}.wav` per captured speaker at 16 kHz mono, all spanning the whole session from one t0, plus `speakers.json` and `presence.json`. Content is authored; **timing is sampled**, with a fixed seed, from six statistics that live next to the scene. Nothing per-line from a real session is read at build time or committed, which is what keeps the repository unambiguously clean of anything session-derived. Clip durations are measured rather than sampled — they are whatever piper produced, and the timeline is laid out around them. Realised: 218 utterances, 1 160 s of speech in a 1 800 s session (64% duty cycle), 20.7% table talk, 16.5% of utterances overlapping another track, the late joiner's first audio at exactly 360.0 s, twelve scripted silences of 8 s or more, and 84 s of tail padding after the last line. **`track_sha256` covers the PCM frames, not the file bytes.** ffmpeg stamps its own version string into a `LIST` chunk of every WAV it writes, so a file hash would break on an ffmpeg upgrade for a reason that has nothing to do with the audio — turning a genuinely useful drift alarm into one nobody trusts. Hashing the samples keeps the alarm pointed at the resampler, which is the only part of the chain that can plausibly move: Opus decode is normatively bit-exact. The resample step runs the bot's own command from `bot/questboard_bot/utils/audio.py`. One caveat, named in the module rather than glossed: since #176 the live capture path does not use it — the sink resamples as audio arrives with the windowed-sinc filter in `utils/resample.py`. Using that would mean adding numpy to the backend for one filter, so the harness takes ffmpeg's and says so. `seed_db.py` writes the campaign, members, platform links and characters through the app's own models, so `resolve_speakers` can be checked against **real rows** on all three label paths — GM, linked player with a character, and an unlinked guest who genuinely has no `PlatformLink` (with a linked owner the backend recovers the name from the database and #344's path is never taken). Ids are UUID5 under a fixture namespace so they cannot collide with real data, and it still refuses to run without `--i-know-this-writes`, because "cannot collide" is a claim about this code and not about the database it is aimed at. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>b916e6920eceb6d51bfc