Skip to content

fix: content the pipeline was silently losing (articles, ASR, Firecrawl) + verify coverage on the dashboard - #133

Open
VGonPa wants to merge 7 commits into
developfrom
fix/pipeline-content-loss
Open

fix: content the pipeline was silently losing (articles, ASR, Firecrawl) + verify coverage on the dashboard#133
VGonPa wants to merge 7 commits into
developfrom
fix/pipeline-content-loss

Conversation

@VGonPa

@VGonPa VGonPa commented Aug 13, 2026

Copy link
Copy Markdown
Owner

Five commits, one theme: the pipeline was dropping content and saying nothing about it.

What was being lost

Article blocks. X serialises long-form Articles as Draft.js blocks + entityMap. The parser understood exactly two shapes — plain text and an atomic IMAGE — and silently discarded everything else: MARKDOWN/TEXT/HTML paragraph bodies, DIVIDER section breaks, embedded TWEETs, and embedded VIDEOs. Built against 29 real captured payloads, not a guess at the schema, which is the only reason the bit_rate vs bitrate bug turned up: X spells the key one way and select_variant reads the other, so every article video was silently resolving to the lowest-quality mp4.

Transcripts. parakeet-mlx is English-only and does not fail on Spanish — it fabricates fluent English. On a bilingual corpus that is the worst failure mode available: a confident transcript nobody can tell is invented, feeding the digest and every summary downstream. xbrain-transcribe-auto detects the language on the first 30s and dispatches; any uncertainty goes to whisper, never to parakeet. Also fixes a real hallucination already in the vault (whisper's subtitle-credit boilerplate over near-silent audio, stored as has_speech=true, which ffprobe cannot catch because the file does have an audio stream).

Articles that could have been recovered. The Firecrawl key was read only from $FIRECRAWL_API_KEY, so anyone logged in via the Firecrawl CLI had a working key on disk and still got the fallback extractor. No error, fewer articles, nothing to grep for.

And one thing that was being over-claimed

The dashboard showed Enriched 100% with no way to tell enriched from checked. Panel 09 now draws one full-width bar across the whole population — PASS / REVIEW / FAIL / UNJUDGED — so the judged slice can only be read against the total. On the current corpus that is 51 of 4,843 outputs: 1.1% coverage, which is the honest number and looks nothing like "96% PASS".

Stale verdicts count as unjudged, not as their stored verdict. Re-generating a judged output invalidates it, and this is not hypothetical — the two FAILs from the last pass went stale the moment their summaries were fixed.

Notes for review

  • tests/conftest.py gains an autouse fixture that points Firecrawl credential lookup at tmp_path. Without it, the existing "no key" tests would read the reviewer's own machine and pass or fail depending on whether they happen to be logged into Firecrawl.
  • The last commit is the fail-closed quality gate catching all three new scripts on its first outing: ruff does not discover extensionless #!/usr/bin/env python3 files from a directory, so it had been walking past them. Listing them surfaced 3 real E702s.
  • config.toml is gitignored, so the ASR wiring is documented in config.toml.example.

Gate: 1724 passed · ruff clean · mypy clean on 48 source files.

🤖 Generated with Claude Code

VGonPa and others added 7 commits August 13, 2026 07:39
…t, video

X serialises a long-form Article as ordered `blocks` + `entityMap`. The parser
only understood two shapes: plain `unstyled` text and an `atomic` block whose
entity is an IMAGE. Everything else was silently discarded, so an Article that
used any richer construct reached the vault with holes in it — and nothing
anywhere said so.

Four kinds of content were being lost:

* MARKDOWN / TEXT / HTML entities — the paragraph body lived under a different
  key than the one being read, so the block rendered as empty and vanished.
* DIVIDER — a section break with no text; dropped, silently merging two
  sections into one.
* TWEET — an embedded post; dropped entirely, taking with it the only pointer
  to what the paragraph was talking about. Now rendered as its status URL.
* MEDIA/VIDEO — an embedded video; the atomic block was recognised, failed the
  IMAGE check, and was dropped. Now a first-class `ArticleVideoBlock`.

Built against 29 real captured payloads rather than a guess at the schema,
which is the only reason the `bit_rate` vs `bitrate` bug surfaced: X spells the
key one way, `select_variant` reads the other, so every video silently resolved
to the LOWEST-quality mp4. `_normalise_variant` renames it at the boundary.

A video entity with an empty `variants` list now yields no video block at all.
Trusting it produced a "video" whose URL was the poster JPEG — a broken embed
that looked like a working one.

`_warn_if_media_unresolved` reports drift between the entities in a payload and
the blocks that came out, so the NEXT schema change is loud instead of silent.
It requires a MEDIA/IMAGE entity before it fires; counting any `atomic` block
made every prose+code article warn about media it never had.

`generate` renders a pending article video as a visible link line rather than
nothing. A video that has not been downloaded yet is a fact about the pipeline;
staying silent about it is how content goes missing without anyone noticing.
The dispatch now closes with `assert_never`, so a future block kind fails to
type-check instead of falling into a catch-all and disappearing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…rakeet

parakeet-mlx is English-only, and on Spanish audio it does not fail — it
FABRICATES fluent English. That is the worst failure mode available: a
confident transcript nobody can tell is invented, feeding the video digest and
every summary downstream. This corpus is bilingual, so the wrapper had to stop
being a single backend.

`xbrain-transcribe-auto` slices the first 30s with ffmpeg, runs whisper with NO
`--language` to detect, and dispatches on the answer. English → parakeet.
Anything else → whisper. Detection failed, returned nothing, or is ambiguous →
whisper. The asymmetry is deliberate: whisper on English audio costs time,
parakeet on Spanish audio costs truth.

Backend order for non-English is `xbrain-transcribe-mlx` first, falling back to
`xbrain-transcribe-whisper`. Measured on the same 68s clip: brew whisper 7m25s
on CPU vs mlx-whisper 17.7s on the Apple GPU — 25x — with byte-identical text.
The mlx wrapper exits non-zero WITHOUT writing an output file when the model
returns nothing, so the router's fallback actually triggers instead of the
caller reading a half-written file as success.

Detection model is `base`, not `tiny`. Measured: ~19s vs ~10s, both correct on
the clips tested. Nine seconds is not worth a coin-flip on which backend runs.

`xbrain-transcribe-whisper` joins the repo (it was loose on the machine) and
gains silence-hallucination handling: whisper emits subtitle-credit boilerplate
over near-silent audio, and ffprobe cannot catch it because the file DOES have
an audio stream. One such fabrication was already in the vault, stored with
has_speech=true. The match is on the whole stripped transcript only — a
substring match would delete real speech that happens to contain the phrase.

`config.toml` is gitignored, so the wiring is documented in `config.toml.example`
where someone can actually find it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The key was only ever read from `$FIRECRAWL_API_KEY`. Anyone who had logged in
with the Firecrawl CLI — which writes a credentials file — had a working key on
disk and still got the "no key configured" path, so `fetch --retry-failed`
quietly fell back to the weaker extractor. The failure was invisible: fewer
articles recovered, no error, nothing to grep for.

`firecrawl_key()` now falls back to the CLI credentials file, checking the
macOS, XDG and legacy locations in order. A malformed or unreadable file
resolves to None rather than raising: the credential is an optional
enhancement, and a broken one must degrade exactly like an absent one.

`XBRAIN_NO_FIRECRAWL` still wins over both, so the escape hatch keeps working.

`_echo_retry_plan` now prints WHERE it looked when it finds nothing. "No key"
is not actionable; "no key in $FIRECRAWL_API_KEY, none at <these three paths>"
is.

The autouse fixture in conftest is the load-bearing part of this change. The
existing "no key configured" tests asserted the fallback path — and once the
code reads a real file, those tests would read the DEVELOPER'S machine and pass
or fail depending on whether that person happens to have logged into Firecrawl.
The fixture points the lookup at a path inside tmp_path, so the suite tests the
code instead of the laptop.

The literal in the new tests is a fake ("fc-padded"), audited into the
detect-secrets baseline.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The dashboard had no verification signal at all. After a verify pass the only
place the result existed was a markdown report on disk, so the dashboard kept
showing "Enriched 100%" with nothing anywhere distinguishing enriched from
CHECKED. Those are very different claims.

Panel 09 draws ONE full-width stacked bar across the whole population —
PASS / REVIEW / FAIL / UNJUDGED — so the judged slice can only ever be read
against the total. A verdict mix on its own would report "96% PASS" off 51
judgments out of 4,843 outputs, which is true and deeply misleading. The caveat
line under the chart states the unjudged count in words, because a legend entry
is easy to skip and a sentence is not.

Coverage counts OUTPUTS, not items: one post carries a summary, a topics
assignment and possibly a video digest, each judged separately against its own
source.

A stale verdict counts as unjudged, not as its stored verdict. Re-generating a
judged output invalidates it — the verdict describes text nobody reads any
more. This is not hypothetical: the two FAILs from the last pass went stale the
moment their summaries were fixed, and showing them as current failures would
be as wrong as showing them as passes. Stale is surfaced separately, with its
own count, because "70 verdicts need re-earning" is actionable and folding them
into unjudged would hide the debt.

`compute_dashboard_data` now takes the output language: a verdict is bound to
the rubric contract it was judged under, and that contract is per-language.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`test_ruff_examines_every_python_file_in_the_repo` caught all three new scripts:
ruff does not discover extensionless `#!/usr/bin/env python3` executables from a
directory, so `ruff check scripts` was walking straight past them. The gate
would have printed ALL CRITICAL CHECKS PASSED over three unlinted files.

Listing them in `extend-include` turned up 3 real E702s that had been invisible,
now split, plus formatting the formatter had never been allowed to see.

This is the fail-closed gate doing exactly what it was built for, on the first
new files added after it landed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…body could read

`scan_store` was fixed once to KEEP records whose only candidates are uncertain.
`summarise_scan` then dropped them again, and `render_entity_report` filtered
them out of the table, so neither the terminal line nor the report showed a
single one. The docstring on `EntityRecord` promised "never dropped"; the code
dropped them two layers later.

This is not academic. A candidate lands in the uncertain tier when its POSITION
makes the capital ambiguous — chiefly sentence-initial, where Spanish
capitalises the first word whether or not it is a name. That is exactly where
this corpus puts names: summaries open with the subject ("Suhail afirma…",
"Ethan Mollick destaca…"). The one fabricated name the LLM judge pass caught —
"Suhail", where the source says @shl (Sahil Lavingia) — sits in this tier,
ungrounded, and the check had already found it:

    extract_entities("Suhail (shl) afirma…")   -> ['PRs']
    extract_uncertain("Suhail (shl) afirma…")  -> ['Suhail']   is_grounded -> False

The finding existed. No reader could reach it.

Reported on its own line and in its own table section, never merged into the
headline: the tiers have very different precision, and one number covering two
instruments is how a reader ends up quoting the wrong one. The gate still counts
confident flags only — that split is deliberate and unchanged.

On the corpus this makes visible 1,461 summaries and 117 digests that were
silently set aside.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ollision

Precision of the confident tier was measured at 32% on a 50-flag sample (the
report had said it "has NOT been independently re-measured"). Ruling on each
flag turned up two real defects and one the fix itself introduced.

**ASR disfluencies inside a name.** The evidence for `Application Default
Credentials` reads "the application default uh credential". All three words are
there and adjacent; a name is matched as a contiguous window, so one filler
means no window of the transcript ever spells it. Stripped from the EVIDENCE
side only — the output is written text and has no fillers, so stripping there
would corrupt real names.

**Translated names arrive reversed.** Spanish puts the adjective after the noun:
`Revolución Industrial` against "the industrial revolution". Reversal alone is
not enough, because one word is still in the other language — it composes with
the existing fuzzy threshold, which absorbs "industrialrevolucion" vs
"industrialrevolution". No lexicon entry, and it generalises to pairs the corpus
has not produced yet.

**A short form collides with the joint of two ordinary words.** Found by a test
written expecting it to pass: adding `usa` to the lexicon made the window `us`+`a`
of "gives us a much better result" ground `Estados Unidos` off a pronoun and an
article. Squashing erases word boundaries, so forms of ≤3 chars now match a
single token only — the same reasoning as `_FUZZY_MIN_LEN`.

`us` stays OUT of the lexicon. Grounding the country off the English pronoun,
present in nearly every transcript, does not remove a false alarm — it blinds the
check to every invented mention of the US. 8 flagged sources whose only evidence
is bare `us` stay flagged, on purpose, with a test pinning it.

Effect, measured rather than projected: 175 → 160 flagged entities on summary,
UNCHANGED on digest, and sample precision 32% → 34.8%. I had projected 55-60%.
Two of the three false-positive classes turned out smaller than they looked:
`España` and `Europa` were not translation misses at all (the evidence genuinely
lacks the name, or carries only the adjectival form), and `ChatGPT Plus` was the
check working correctly on non-adjacent words.

The dominant remaining class is untouched: the extractor pulls Spanish common
words as entities (Zona, Entrenar, Cuello, CEO, Pro). That is also what makes the
uncertain tier unusable, so one filter fixes both.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.

1 participant