Harden podcast extractor: coerce malformed model output + vitest suite - #7
Merged
chrisschouk merged 4 commits intoJul 21, 2026
Merged
Conversation
…utput Add a vitest suite for src/ai/extractor.ts covering the failure modes that live at the model-output boundary. The suite mocks the `ai` SDK's generateText so extractBatch is driven end-to-end with deterministic response strings — no network, no provider key, zero production changes. Coverage (31 cases): - well-formed output: array parsing, field mapping, metadata, token usage - recovery: ```json / bare fences, leading & trailing prose, truncated mid-array, inline fences in string values, missing-field defaults - clean failure: empty string, non-JSON garbage, no array bracket, truncated first object, empty array, stray control chars - count reconciliation: fewer / more extractions than episodes - eval dataset: 6 synthetic-but-realistic episodes (single guest, solo monologue, multi-guest panel, low-relevance, mixed batch, fenced reply) Also characterises three known defects (no fix — out of scope): - a lone JSON object (not array-wrapped) is silently dropped - wrong-typed fields (guests/relevanceScore) are stored verbatim - a null array element throws an unhandled TypeError Wires `pnpm test` into CI and adds the "test": "vitest run" script. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EeZj5JAbowAXFeLW727KKN
CI has failed on every main push for the last 8+ commits: pnpm 9.15.9 in
the setup-node cache step runs `pnpm store path`, which rejects a
pnpm-workspace.yaml that has no `packages:` field ("packages field missing
or empty"), so the job dies before `pnpm install` ever runs — the tests
never execute.
Declare a root-only workspace (`packages: ["."]`). This satisfies pnpm's
workspace parser while keeping the single existing lockfile importer, so
`pnpm install --frozen-lockfile` stays green. Notably it does NOT pull in
web/, whose `@totalaudiopromo/ui` file: dependency points outside this repo
and would fail a frozen install.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EeZj5JAbowAXFeLW727KKN
Round out the extractor module's exported surface: - estimateCost: batch/per-episode token projection math, ollama "free (local)" branch, ~$ formatting for paid providers, unknown-provider fallback to anthropic rates, and token growth with episode volume. - extractBatch: token counts default to 0 when the model result omits a usage object. 37 cases total. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EeZj5JAbowAXFeLW727KKN
Harden extractBatch against the three failure modes the test suite
characterised, so malformed model output recovers or fails cleanly rather
than producing wrong output or throwing:
- Lone extraction-shaped object (not array-wrapped) is now wrapped and
mapped instead of being silently dropped — a plausible single-episode
response no longer loses the episode. A non-extraction object (e.g. an
{"error":...} refusal) still yields no entries.
- Every field is coerced to its declared type via a defensive layer: a
wrong-typed `guests` (string) collapses to [], non-object guest/idea/
person elements are dropped, and a non-numeric relevanceScore becomes 0.
Bad types can no longer leak into a DigestEntry and crash a downstream
.map/.length far from the parse site.
- A null array element maps to a clean empty entry (preserving index
alignment with the episode list) instead of throwing an unhandled
TypeError.
The characterisation tests that pinned these defects are inverted to assert
the corrected behaviour. Suite now at 39 cases; typecheck and build clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EeZj5JAbowAXFeLW727KKN
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Hardens
src/ai/extractor.ts, whose real job is turning untrusted model output into structuredDigestEntryvalues. Adds a vitest suite covering every failure mode at that boundary, then fixes the three defects the suite surfaced so malformed output recovers or fails cleanly instead of producing wrong output or throwing.The suite mocks the
aiSDK'sgenerateTextand drivesextractBatchend-to-end with deterministic response strings — no network, no provider key.Audit note
The task brief assumed a prior pass had already added
src/ai/__tests__/extractor.test.ts(13 cases) and a"test": "vitest"script. On inspection none of that existed — no__tests__dir, no vitest dependency, no test script. This PR builds the suite from scratch.What's added
vitestdev dependency +"test": "vitest run"script, wired into CI (pnpm testruns beforepnpm build).src/ai/__tests__/extractor.test.ts— 39 cases.src/ai/__tests__/fixtures.ts— shared config/episode builders and the eval dataset.pnpm-workspace.yaml) — see below.Coverage by group
processedAt, entry keying, token usage```json/ bare fences, leading & trailing prose, truncated mid-array, inline fences inside string values, missing-field defaults[bracket, truncated first object, empty array, stray control chars, missing-usagetoken fallbackestimateCost~$formatting, unknown-provider fallback, growth with volumeEval dataset (3 → 6 episodes)
No real Apple Podcasts transcripts are committed to the repo, so the dataset is synthetic-but-realistic: single-guest interview, solo monologue (no guests), multi-guest panel, low-relevance off-topic episode, a mixed 3-episode batch, and a code-fenced reply. A guard test asserts the dataset can't silently shrink back below 6 cases / 8 episodes.
Extractor hardening — three defects found and fixed
The suite first characterised these (tests pinning the broken behaviour); a follow-up commit fixes each with a defensive coercion layer and inverts the tests to assert the corrected behaviour.
{...}extraction object (a plausible response for a single-episode batch) is wrapped and mapped instead of yielding zero entries. A non-extraction object (e.g. an{"error":...}refusal) still yields nothing.guests(string) collapses to[], non-object guest/idea/person elements are dropped, and a non-numericrelevanceScorebecomes0. Bad types can no longer leak into aDigestEntryand crash a downstream.map/.length.nullarray element crashed → now clean.[null]previously threw an unhandledTypeError; it now maps to a clean empty entry, preserving index alignment with the episode list.CI fix (pre-existing, unrelated breakage)
CI had failed on every
mainpush for the last 8+ commits, and the first run on this branch failed too — but not because of these tests. pnpm 9.15.9 in thesetup-nodecache step runspnpm store path, which rejects apnpm-workspace.yamlthat has nopackages:field (ERROR packages field missing or empty). The job dies beforepnpm install, so the tests never execute.Fix: declare a root-only workspace (
packages: ["."]). This satisfies pnpm's parser while keeping the single existing lockfile importer, sopnpm install --frozen-lockfilestays green. It deliberately does not addweb/, whose@totalaudiopromo/uifile:dependency points outside this repo and would fail a frozen install.Verification (local)
pnpm test→ 39 passedpnpm typecheck→ cleanpnpm build→ successpnpm install --frozen-lockfile→ clean (lockfile unchanged)🤖 Generated with Claude Code