Skip to content

Harden podcast extractor: coerce malformed model output + vitest suite - #7

Merged
chrisschouk merged 4 commits into
mainfrom
claude/podcast-extractor-test-hardening-8dvn4g
Jul 21, 2026
Merged

chrisschouk merged 4 commits into
mainfrom
claude/podcast-extractor-test-hardening-8dvn4g

Conversation

@chrisschouk

@chrisschouk chrisschouk commented Jul 21, 2026

Copy link
Copy Markdown
Owner

Summary

Hardens src/ai/extractor.ts, whose real job is turning untrusted model output into structured DigestEntry values. 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 ai SDK's generateText and drives extractBatch end-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

  • vitest dev dependency + "test": "vitest run" script, wired into CI (pnpm test runs before pnpm build).
  • src/ai/__tests__/extractor.test.ts39 cases.
  • src/ai/__tests__/fixtures.ts — shared config/episode builders and the eval dataset.
  • Extractor hardening — a defensive coercion layer (see below).
  • CI fix (pnpm-workspace.yaml) — see below.

Coverage by group

Group Cases What it pins
Well-formed output 5 array parsing, field mapping, metadata + processedAt, entry keying, token usage
Recovery 8 ```json / bare fences, leading & trailing prose, truncated mid-array, inline fences inside string values, missing-field defaults
Clean failure 7 empty string, non-JSON garbage, no [ bracket, truncated first object, empty array, stray control chars, missing-usage token fallback
Count reconciliation 2 fewer / more extractions than episodes
Malformed-object handling 6 the three fixes below + supporting cases
estimateCost 5 batch/per-episode token math, ollama free branch, ~$ formatting, unknown-provider fallback, growth with volume
Eval dataset 6 realistic episode shapes end-to-end

Eval 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.

  1. Lone JSON object was dropped → now recovered. A single {...} 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.
  2. No field-type validation → now coerced. Every field is coerced to its declared type: 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.
  3. null array element crashed → now clean. [null] previously threw an unhandled TypeError; 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 main push 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 the setup-node cache step runs pnpm store path, which rejects a pnpm-workspace.yaml that has no packages: field (ERROR packages field missing or empty). The job dies before pnpm 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, so pnpm install --frozen-lockfile stays green. It deliberately does not add web/, whose @totalaudiopromo/ui file: dependency points outside this repo and would fail a frozen install.

Verification (local)

  • pnpm test → 39 passed
  • pnpm typecheck → clean
  • pnpm build → success
  • pnpm install --frozen-lockfile → clean (lockfile unchanged)

🤖 Generated with Claude Code

claude added 4 commits July 21, 2026 22:57
…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
@chrisschouk chrisschouk changed the title test: harden podcast extractor JSON parsing against malformed model output Harden podcast extractor: coerce malformed model output + vitest suite Jul 21, 2026
@chrisschouk
chrisschouk merged commit 332f253 into main Jul 21, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants