Skip to content

Release: develop → main (video-digest, vision selector, dashboard, photo descriptions, X-articles)#70

Merged
VGonPa merged 116 commits into
mainfrom
develop
Jul 4, 2026
Merged

Release: develop → main (video-digest, vision selector, dashboard, photo descriptions, X-articles)#70
VGonPa merged 116 commits into
mainfrom
develop

Conversation

@VGonPa

@VGonPa VGonPa commented Jul 4, 2026

Copy link
Copy Markdown
Owner

Consolidates ~2 months of develop work into main. Every change landed as its own CI-green PR; this is the release cut.

Highlights

All quality gates green on develop (ruff, mypy, radon, bandit, vulture, interrogate, tests+coverage, deptry).

VGonPa and others added 30 commits May 24, 2026 13:42
Replaces the flat `Media(type, url)` model with a four-variant tagged
union — `MediaPhotoPending` / `MediaPhotoDownloaded` / `MediaPhotoFailed`
/ `MediaVideoPending` — discriminated by `kind`. Mirrors the
`ContentSource` design from #20: illegal states (e.g. "downloaded"
without `local_path`) are unrepresentable.

A `_normalise_legacy_media` BeforeValidator promotes the pre-Phase-A
`{type, url}` shape on read so existing `data/items.json` files load
without a manual migration. A `Media(...)` factory function preserves
the constructor signature used by `extract/graphql.py` and `archive.py`
— those modules are out of scope for #33 and keep working unchanged.

`MediaFailureReason` adds the categorised failure bucket
(http_4xx / http_5xx / timeout / format_error / unknown_error) the
downloader will fill in. Transient vs permanent semantics mirror #19.

Tests cover legacy migration (photo + video), pass-through of the new
shape, the discriminator rejecting unknown kinds, required-field
enforcement on the failure variant, the factory routing, and a full
`Item` round-trip through the legacy media shape.

Refs #33
`xbrain.media.download_all` walks every photo entry across the store,
downloads bytes from pbs.twimg.com with a size cascade
(`name=orig` → `name=large` → `name=medium`), validates the bytes
with Pillow, and writes the file atomically (tmp + rename) under
`data/media/<item-id>/<index>.<ext>`.

Failure handling mirrors `xbrain.fetch` (#19/#20):
- 4xx → `http_4xx` (permanent)
- 5xx → `http_5xx` (transient — auto-retried next run)
- `requests.Timeout` → `timeout` (transient)
- Connection errors / unknown exceptions → `unknown_error` (transient)
- Bytes that Pillow can't decode → `format_error` (permanent)

`KeyboardInterrupt` propagates untouched (narrow except clauses). A
total failure (all attempts failed) raises `RuntimeError`, mirroring
`ApiExecutor.enrich_items` (#24) — the CLI's `_handle_cli_errors`
turns this into a clean operator message + exit 1.

Idempotency: a re-run is a no-op for `MediaPhotoDownloaded`. A
progress callback fires after each photo transition, so the CLI
can persist `items.json` between photos — Ctrl-C mid-batch leaves
a coherent store.

I/O is dependency-injected (`session`, `sleep`, `on_progress`) so
tests run offline with a hand-rolled `FakeSession` — no
`requests-mock` dependency in the unit path.

Tests cover: 200-on-orig success, cascade fallback, idempotency,
all five failure buckets, `--force`, `--limit`, `--items` filter,
throttling, progress callback, video-pending no-op, Ctrl-C
propagation, total-failure raise, partial-failure no-raise,
atomic write (no `.part` residue), summary-line emission silence
vs. full counters.

Adds Pillow as a runtime dep (`uv add pillow`).

Refs #33
The `media` command wires the Phase A downloader into the CLI:
1. Auto-snapshots `data/` first via `_auto_snapshot(cfg, "media")`,
   reusing the #17 destructive-op recovery boundary.
2. Loads `items.json`, runs `xbrain.media.download_all` with a
   per-photo `on_progress` callback that persists the store
   atomically — Ctrl-C mid-run leaves a coherent `items.json`.
3. Persists once more in a finally block so the partial state
   from a `RuntimeError`-on-total-failure is not lost.
4. Emits the SUMMARY line on stderr (mirror of #24) and an
   operator-friendly recap on stdout.

Flags: `--force` (re-download already-downloaded photos),
`--limit N` (cap attempts), `--items <a,b,c>` (filter by id).

`Config.media_dir` resolves to `data/media/` — under the existing
gitignored `data/*` umbrella, no `.gitignore` change needed.

Tests cover the snapshot-before-run invariant (mirrors the existing
fetch/vocab/topics auto-snapshot tests), the full happy path
through a fake `requests.Session` (no real network), the
`--items` filter, and the total-failure → exit-1 contract with
the partial-state-persisted invariant verified.

Refs #33
`xbrain generate` now handles every media variant introduced by #33:

- `MediaPhotoDownloaded` → embedded inline as `![[_media/<id>/<n>.<ext>]]`
  after the tweet body, before the `## Enlaces` section. Files are
  mirrored from `data/media/` into `<output_dir>/_media/` at render
  time so the vault is fully self-contained — no Obsidian config or
  symlinks needed on the user side.
- `MediaPhotoFailed` → one-line ⚠ warning carrying the original URL
  and the failure reason (translated to user-facing Spanish via
  `_FAILURE_ES_MEDIA`).
- `MediaPhotoPending` → silent. Not an error, just "not yet processed".
- `MediaVideoPending` → 🎥 placeholder with the URL. Phase A does not
  download videos; the URL is the only evidence we surface.

`_has_note` extended: an item with only media (no link, no enrichment)
is now note-worthy. A photo-only tweet was previously invisible in the
wiki — now it gets its own page as soon as `xbrain media` advances
the variant.

Render order in the Tweet section:
  tweet text → photos → links → external article content

Deviation from the implementation plan: the plan called for embeds
pointing at `data/media/<id>/<n>.<ext>` and "verifying in the
end-to-end test" that Obsidian could resolve the relative path. That
only works when the vault root and `data/` align, which is not the
common case. Mirroring the photos into `<output_dir>/_media/` makes
the embed resolve unconditionally for ~zero extra disk (the canonical
copy stays at `data/media/`; the vault copy is a render-time
artifact). Documented in README.

Tests cover all four render branches, the media-block-before-Enlaces
ordering, multi-photo inline rendering, missing-bytes recovery (logs
+ keeps embed), and backward-compat when `generate(media_root=None)`.

Refs #33
`DiffReport` now carries a `media: MediaDiff` block with per-variant
counts on both sides (A/B) plus the four deltas
(`delta_downloaded`, `delta_pending`, `delta_failed`,
`delta_video_pending`). `DiffSummary` surfaces the two highest-signal
counters (`media_delta_downloaded`, `media_delta_failed`) at the top
level for quick-glance ops review.

The text formatter adds a `MEDIA` block with right-aligned
A/B columns and `±N` deltas. The JSON formatter is automatic via
pydantic — `media` becomes a fifth top-level key. The CLI tests that
asserted the top-level key set have been updated.

Negative deltas are legal — `xbrain media --force` can move a
previously-downloaded photo back to `failed` if the URL turned 404
in the meantime. `MediaStateCounts` carries the absolute counts
unsigned; the deltas carry sign.

Refs #33
README:
- Add `media` row to the Commands table.
- New "Local media storage" section covering on-disk layout
  (`data/media/<id>/<n>.<ext>`), vault rendering convention
  (mirrored into `<output_subdir>/_media/` for self-contained
  embeds), disk budget guidance, throttling, and the
  transient/permanent failure-retry contract.

ARCHITECTURE:
- New "media" entry under per-stage detail (state machine,
  Ctrl-C safety, snapshot trigger).
- Artifacts table row for `media/<id>/<n>.<ext>`.
- Invariant #10: media variants are mutually exclusive states;
  state transitions only via `xbrain media`; legacy `{type, url}`
  records migrate on read.

Refs #33
No behaviour change. ruff format normalises trailing commas, line
breaks and trivia in the files touched by the Phase A series.

Refs #33
Split the 19-line orchestrator into three small helpers:

- `_build_session` extracts the new-vs-injected session decision so
  `download_all` does not carry a one-off branch.
- `_eligible_items` is a generator that filters by `items_filter`
  and skips items without media, so the orchestrator reads as a
  flat "for each eligible item, process it" loop.
- `_DownloadContext` is a frozen dataclass that bundles the inner-
  loop deps (session, timeouts, throttle, callbacks, force) so
  `_process_item` takes one context argument instead of seven.

`download_all` drops from C(19) → B(7); `_process_item` stays at
B(10); no behaviour change, all 412 tests still pass.

Refs #33
`xbrain.media` imports `requests` directly for the HTTP session.
Pulling it through trafilatura's transitive dep was a deptry DEP003
warning; declaring it explicitly is the right hygiene.

No behaviour change — the same `requests-2.34.2` was already
installed via trafilatura.

Refs #33
… wrap write

- Narrow `_decode_image` exception set to Pillow-specific
  (UnidentifiedImageError, DecompressionBombError, SyntaxError). Parent
  OSError swallowed real I/O bugs (disk/permission/network).
- Wire `logger.warning` per failure in `_record_outcome` — the
  total-failure RuntimeError now actually has the breadcrumbs it
  references.
- Wrap `_write_bytes` call in `_download_one` with `try/except OSError`,
  bucketing disk-full / permission / read-only-fs errors as
  `unknown_error` (transient) so a single bad write doesn't abort the
  whole batch.
- Sweep stale `*.part` files at the entry to `download_all` to clean up
  SIGKILL/OOM orphans from previous runs.
- Document persistence-failure semantics in `_run_media` docstring.
- Add `AVISO:` stderr line on `--items` filter that matches no item.
- Add `--verbose` flag printing failed (item_id, reason, url) at the end.
- Drop SessionProtocol (defined for one use); use `requests.Session | None`
  directly in `download_all`.
- Drop `_DownloadContext` dataclass, `_process_item` helper, and the
  mutable-list `remaining=[limit]` "box" trick. Inline the per-item loop
  into `download_all` with a plain `int | None` counter and
  `enumerate(item.media)` instead of `range(len)`.
- Inline `_build_session` (used once), `_eligible_items` (used once), and
  `_finalise` (one-line, called twice) into `download_all`.
- Drop `original` param from `_record_outcome` and `entry` param from
  `_failed` — both were unused and rationalised by speculative comments.
  Rewrite the now-truthful docstrings.
- Move `_REASON_SEVERITY` to module top alongside `_TRANSIENT_MEDIA_FAILURES`
  and `_SIZE_CASCADE` (was defined mid-file). Drop the unused
  `format_error: 5` row — `format_error` is an early-return path, never
  cascade-compared.
- Cap `_format_error` output at 500 chars to keep hostile CDN bodies out
  of `items.json`.
- Add `typing.assert_never` exhaustiveness at the three media-variant
  isinstance chains (media.py:_is_eligible, generate.py:_render_media_lines,
  diff.py:_count_media_variants) so Phase B catches missed variants
  statically.
- Trim media.py module docstring from 5 paragraphs to 2.
- `MediaPhotoDownloaded.width / height / bytes_size` now `Field(gt=0)`.
  A zero-pixel or zero-byte downloaded photo is semantically illegal;
  Pillow validation rules it out at the seam, the type pins it at the
  data layer.
- `MediaPhotoDownloaded.local_path` gets a pydantic validator that
  rejects absolute paths and `..` components. The downloader never
  builds such a path, but `items.json` is on-disk plain JSON the user
  can edit — defence in depth at the type boundary is cheap.
- `MediaPhotoFailed.attempts` drops its default and becomes
  `Field(ge=1)`. "Failed but never attempted" is nonsense; the
  downloader always increments before producing the variant.
- `_MediaPhotoBase` docstring corrected: `type` was never the
  discriminator, it's preserved for wire-compat with the legacy
  `{type, url}` shape.
- `Media()` factory annotated `# noqa: N802` (the rule is intentional)
  with a TODO marker to remove when the extractor/archive migrate to
  direct variant construction.
- Tests updated to pass `attempts=1` where they previously relied on
  the default.
- Strip `Phase A`, `Phase B`, `(#33)`, `pre-#33`, `pre-Phase-A` markers
  from new/modified code, docstrings, and tests. Also strip `(#17)`,
  `(#19)`, `(#20)`, `(#24)` references added in this PR's diff. The PR
  description carries the issue link; code should describe lasting
  invariants, not the PR that introduced them.
- Rewrite `_TRANSIENT_MEDIA_FAILURES` cross-reference comment: was
  `_TRANSIENT_FAILURES` (the actual symbol in fetch.py).
- Reword `Failed(transient)` "terminal-ish" phrasing in ARCHITECTURE.md
  `### media` — `Failed(transient)` IS auto-retried; not terminal.
- Reconcile snapshot/media docs: snapshots cover only the four JSON
  artifacts; photo bytes under `data/media/` are NOT snapshotted today.
  Updated `config.py:media_dir` docstring and ARCHITECTURE.md to make
  the carve-out explicit; re-downloading is the recovery path.
- Trim ARCHITECTURE.md invariant #10 to match the brief style of
  invariants #1-#9. Retry contract and storage layout moved to the
  `### media` section above (now with storage layout subsection).
- Trim the `media` row in README.md Commands table to one sentence with
  a link to `Local media storage`. Mark the disk-budget numbers as
  approximate.
…bose

- Split the old `test_download_all_records_http_4xx_failure_when_cascade_exhausted`
  into two: one asserts the bucket via a partial-success setup; the other
  asserts the lone-attempt RuntimeError. Mixing both concerns made the
  original test confusing.
- Add `test_download_all_falls_back_through_full_cascade_to_medium`: orig
  404 + large 404 + medium 200 — proves the third cascade step works.
- Add WebP and JPEG extension tests: `.webp` and `.jpg` URLs end up with
  the right extension on disk.
- Add 3xx (304) handling: bucketed as `unknown_error` (transient).
- Add atomic-write rollback test: simulate `Path.replace` raising; the
  `.part` file is cleaned up and no half-written final file remains.
- Add `_sweep_part_orphans` smoke test: a stale `*.part` left by a SIGKILL
  is removed on next `download_all` entry.
- Add partial-failure SUMMARY line test: a run with 0 downloads + N
  failures is NOT silent — ops needs the count.
- Add Pillow truncated-PNG test: `_png_bytes()[:32]` lands in the
  `format_error` bucket.
- Add `test_diff_media_reports_delta_failed`: diff surfaces a `delta_failed`
  count between snapshots (both on `media.delta_failed` and
  `summary.media_delta_failed`).
- Add `test_media_command_resume_after_interrupt_completes_remaining`:
  the full Ctrl-C-and-resume integration round.
- Add `test_media_command_warns_when_items_filter_matches_nothing`:
  AVISO line on a no-match `--items` filter.
- Add `test_media_command_verbose_lists_failed_urls`: `--verbose` prints
  `<item_id> <reason> <url>` per failure on stderr.
- Tighten `test_media_command_runs_on_empty_store`: assert exit-code 0
  AND items.json is byte-identical (no spurious rewrite).
- Fix `test_generate_renders_failed_photo_as_warning`: replace
  `"URL no encontrada" in body or "HTTP 4xx" in body` with the actual
  rendered string `"URL no encontrada (HTTP 4xx)"`.
- Add `_ = cls` line to `_reject_path_traversal` to silence vulture's
  unused-variable warning; pydantic's `@field_validator + @classmethod`
  contract requires the `cls` parameter even when the body does not
  reference it.
- Apply `ruff format` to `tests/test_cli.py`. No semantic changes.
The PR description carries the issue link; code shouldn't reference PR
numbers in docstrings. Replace '(mirror of #24)' / '(mirrors #24)' with
behavioural phrasing so the tests still self-document but don't go stale
when the PR is closed.
The placate-vulture stub (`_ = cls`) was the first statement of the
function body, so Python parsed the subsequent triple-quoted block as a
no-op expression rather than as the function's docstring. Result:
inspect.getdoc(MediaPhotoDownloaded._reject_path_traversal) returned
None, hiding the rationale for the validator from tooling and IDEs.

Move the docstring to the first-statement position (where Python looks
for it) and put the vulture stub immediately after.
The new MediaPhotoDownloaded / MediaPhotoFailed validators (path
traversal, gt=0 dimensions, ge=1 attempts) were only exercised by the
happy paths. Add explicit rejection tests so a future refactor that
weakens a constraint trips a red test, not a silent regression at the
data boundary.

Six new tests, one per rejection path:
- absolute local_path
- parent-traversal local_path
- bytes_size=0
- width=0
- height=0
- attempts=0 on MediaPhotoFailed
[#33] Phase A: download X-post photos and render in notes
After round-1's inline-the-helpers fix, download_all hit radon C(18) and
_download_one C(11) — they carried inherent orchestration complexity that
flat inlining could not shed.

Targeted refactor without re-introducing single-caller-rename helpers:

- Extract _iter_eligible_attempts as a generator yielding the (item_id,
  item, index, entry) work tuples. The generator carries the filter +
  empty-media skip + eligibility cascade + global limit countdown — real
  state-machine logic, not a renamed parameter list. download_all becomes
  a flat loop with one decision per iteration.

- Extract _filter_by_ids for the items-filter restriction (was inlined
  inside the generator; pulling it out shaves the and-conjunction from
  the inner loop).

- Extract _classify_status to fold the 4xx / 5xx / other branches in
  _download_one into a single lookup-then-bucket line.

Result:
  download_all C(18) -> B(7)
  _download_one C(11) -> B(9)
  _iter_eligible_attempts (new) B(10)
  No grade C functions remain in media.py.

All 431 tests pass; ruff lint + format + mypy clean; behaviour unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
[#33] reduce download_all + _download_one complexity below grade C
Introduce a fifth media variant carrying the vision-LLM output:
is_decorative + description + description_lang + description_version +
described_at, on top of the downloaded-state fields. The discriminated
union grows from four to five `kind`s; the path-traversal validator
carries over verbatim, since the on-disk bytes are inherited from the
prior downloaded state.

Update every isinstance cascade that walked the union (media.py
eligibility, generate.py rendering + mirror, diff.py counting) to
handle the new variant explicitly so `assert_never` keeps the
exhaustiveness check honest. The diff report grows a `described`
column and a `delta_described` field; the count is exposed through
both the text and JSON renderers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The vision-describe call ships a declarative system prompt — same
shape as the other rubrics. It enumerates the decorative-vs-content
classification, the per-image description contract (1-3 sentences,
plain prose, faithful), the refusal fallback (decorative + empty
description), and the exact JSON output shape (list of {index,
is_decorative, description}).

The `{language}` placeholder follows the existing rubric convention
so `output_language` propagates into vision calls the same way it
already propagates into summary/topic-page rubrics.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The orchestrator walks every eligible photo, batches the bytes into
vision-API calls (default 5 images per call), parses the per-image
JSON list judgments, and transitions matched entries to
MediaPhotoDescribed. Decorative classifications are persisted with
an empty description so downstream consumers (enrich-time prompt,
topic-synth prompt) can filter them out at a single seam.

The shape mirrors xbrain.executors.api and xbrain.topic_synth:
narrow recoverable-errors tuple, per-batch failure isolation,
logger.warning on every failure, RuntimeError on total failure, and
a SUMMARY: described/failed/skipped stderr line. Programmer bugs
(AttributeError) and KeyboardInterrupt propagate as their narrow
catch is on Exception subclasses only.

Eligibility uses a description-version tag: bumping the configured
version invalidates persisted descriptions automatically (no --force
needed). The Ctrl-C-coherent invariant is delegated to an
on_progress callback the CLI wires to a store write. 41 unit tests
exercise idempotency, force/stale-version, batching, partial+total
failure, refusal handling, language plumbing, programmer-bug
propagation, KeyboardInterrupt, file-missing fallback, summary
line, and clock injection — all via FakeAnthropic.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The CLI surface mirrors xbrain media: --force, --limit, --items,
--model, --batch-size, --verbose. The output_language and the
description_version both come from config (config.toml + new
[describe] section); --model overrides config.describe_model per
invocation. The pre-describe snapshot is auto-taken so a runaway
prompt or wrong model can be rolled back with xbrain snapshot
restore.

Persistence is double-anchored: on_progress writes the store
between batches (Ctrl-C-coherent invariant) and a finally clause
catches any total-failure RuntimeError so the per-photo
MediaPhotoDescribed records that landed before the raise are not
discarded.

config.toml.example documents the new [describe] section with
defaults (Sonnet 4.6 + version v1). 4 CLI tests cover the happy
path, no-op exit-0, --items mismatch warning, and total-failure
exit-1 propagation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The enrich-time prompt in executors/api.py now carries an
"Images in this post:" section listing every non-decorative
MediaPhotoDescribed entry's prose, sitting between the post body
and the links/article block so the LLM reads context in natural
order. Decorative photos are filtered at the seam — they introduce
no topic noise.

TopicInput grows an image_descriptions field; build_topic_inputs
flattens content-bearing descriptions across every post in the
topic and feeds them to the topic-synth prompt below the
per-post summaries. Same decorative-filter as enrich. The prompt
shape stays back-compat: items / topics without described photos
emit no images section at all.

8 new tests guard the integration: inclusion, decorative-filter,
omission-when-empty, ordering vs links/article. Both _user_prompt
helpers were split into per-section builders to keep complexity
under grade C — no new C functions introduced.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The diff module already grew the described column + delta_described
field in the model commit. This commit adds the two regression tests
that prove the diff surfaces a downloaded → described transition
between snapshots and that the text renderer emits the new
"described:" row alongside the existing four.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
README grows a Commands row for describe, a Configuration row for
[describe].model and [describe].version, and a Vision descriptions
subsection in the Local media storage section that explains the
$3-5 budget, the decorative-filter, and the description-version
re-describe trigger.

ARCHITECTURE adds a Per-stage describe section between fetch and
vocab covering the state machine, batching, refusal handling,
failure isolation, snapshot trigger, and the consumption seam in
executors/api.py + topic_synth.py. The rubrics table grows a
describe-image row; invariant #10 names the fifth Media variant and
makes the linear-pipeline shape explicit (Pending → Downloaded →
Described, with Failed as the off-ramp from Pending).

Also bundles two formatter-only cleanups picked up by ruff format
on the executors/api.py and topics.py changes from the prompt
integration commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Drop `_print_partial_failure_summary` from `describe_all`: the CLI's
  `describe_emit_summary_line` is the single source of truth (mirrors
  `media`). Eliminates the double `SUMMARY:` emission on partial-failure
  runs (python-code-reviewer BLOCKER).
- `MediaPhotoDescribed` now carries:
  - `description_lang: SupportedLanguage` (typed alias over
    `i18n.SUPPORTED_LANGUAGES`) — rejects unknown languages at the
    type boundary (type-design-analyzer).
  - Model validator enforcing `is_decorative => description == ""`
    so a hand-constructed variant cannot violate the invariant
    (type-design-analyzer).
  - `described_at` UTC-aware enforcement via field_validator.
- Dedupe the `local_path` traversal guard via a shared free function
  (`_reject_local_path_traversal`); same for the UTC-aware check
  (`_require_utc_aware`). Closes the validator duplication called out
  by python-code-reviewer + code-simplifier — done via free functions
  rather than class inheritance because Liskov substitution would
  silently re-match `MediaPhotoDescribed` against 25+
  `isinstance(entry, MediaPhotoDownloaded)` call sites.
- Add the same UTC-aware contract to `MediaPhotoDownloaded.downloaded_at`
  and `MediaPhotoFailed.last_attempt_at` (closes the pre-existing gap
  for the variants this PR touches).
- Tests: orchestrator no longer emits SUMMARY on partial failure
  (assert `err.count("SUMMARY:") == 0` — pinning the dedup). Test
  helpers for decorative described-photo construction honour the new
  empty-description invariant.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Convergent fixes from the round-1 reviewer pipeline.

Category 2.1 mixed-language drift (type-design-analyzer + silent-failure-hunter):
- `_is_stale` now also returns True when `description_lang` drifted vs
  the configured output language. Switching `output_language` between
  Spanish and English no longer leaves stale-language prose persisted —
  the next run re-describes those entries.
- `output_language` parameter is typed `SupportedLanguage`, matching
  the new `MediaPhotoDescribed.description_lang` field type. The
  orchestrator now propagates a typed language end-to-end.

Category 3 over-decomposed orchestrator (code-simplifier):
- Collapsed the four-helper candidate chain (`_tally_skipped` +
  `_iter_item_candidates` + `_take_with_limit` + outer
  `_iter_candidates` + inner `_all_eligible` closure) into a single
  `_iter_eligible_candidates` generator, mirroring `media._iter_eligible_attempts`.
  A small `_tally_idempotency_skip` helper keeps the loop body
  under radon grade C; `_filtered_items` keeps the outer scan flat.
- Removed `_take_with_limit`, `_chunked`, `_messages_create`, and
  `_user_directive` as standalone helpers. `itertools.batched(...)`
  replaces `_chunked`; the SDK call is inlined; the directive is a
  small dict literal builder local to the block building.

Category 1.3 dead defence-in-depth (python-code-reviewer):
- Dropped the unreachable duplicate-judgment-index guard in
  `_apply_batch_judgments`. The parser already rejects duplicate
  indices, making the dict-construction guard dead code.

Category 4 silent-failure regressions (silent-failure-hunter):
- 4.1: SDK `stop_reason="refusal"` now transitions every photo in the
  batch to `MediaPhotoDescribed(is_decorative=True, description="")`.
  Satisfies the rubric contract at the SDK level so refused batches
  make progress instead of churning.
- 4.2: `JSONDecodeError` now logs `response.stop_reason` so a
  max-tokens truncation is diagnosable from the warning line alone.
- 4.3: Unknown extension in `_media_type` now emits
  `logger.warning` before falling back to `image/jpeg`.
- 4.4: A batch where the model omitted some judgments now bumps
  `batches_failed += 1` — the batch did not return complete data, so
  the operator's "did this API call do its job" view is honest.

Category 5.3 type design (type-design-analyzer):
- Defined `MessagesClient` + `VisionClient` Protocols local to
  `describe.py`. Dropped the `# type: ignore[attr-defined]` on
  `client.messages.create(...)`.

Category 6 tests (pr-test-analyzer):
- 6.1: New full-payload kwarg shape assertion (model, max_tokens,
  system, role, image+text block ordering, base64 source layout).
- 6.2: `test_describe_all_handles_refusal_as_decorative_empty` plus a
  dedicated `_FakeRefusalResponse` fake.
- 6.3: `test_parse_batch_response_strips_fence_without_language_tag`.
- 6.4: `--verbose` CLI test exercising the per-failure listing.
- 6.5: New mixed-language eligibility test
  (`test_eligible_described_stale_language_is_eligible`) and CLI
  partial-failure SUMMARY count test pinning to exactly one.
- Plus refactored `_FakeMessagesList` so pre-built response objects
  (refusal, truncation) bypass the JSON-list wrapper.

Category 7 comments + docs:
- 7.1: Fixed misleading `_MEDIA_TYPES` comment (was "three", actual
  cardinality is 4 keys mapping to 3 distinct media types).
- 7.2: Rewrote `_run_describe` docstring — coherence is held by the
  outer `try/finally`, not by `on_progress`.
- 7.3: Trimmed the `describe.py` module docstring to 2 paragraphs
  matching the project voice (was 4 paragraphs, 22 lines).
- 7.4: Reworded ARCHITECTURE.md `### describe` ("a new variant on
  the `MediaEntry` union"). Documented the language-staleness
  contract in the state-machine doc.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
VGonPa and others added 29 commits July 2, 2026 10:43
 transcripts)

The producer (`export_topic_worksheet`) emits `image_descriptions`, but the
claude-code TOPICS *consumers* dropped it, so `topics --resynth --executor
claude-code` never surfaced the images — and every item uses the claude-code
track. Wire both consumer artifacts, and fold in the symmetric
`video_transcripts` gap (already on `TopicInput`, injected by the api topic
path) which closes #56's transcript-to-topics half.

Consumers:
- `.claude/workflows/resynth-topic-overviews.js`: the per-topic extraction
  command now prints `image_descriptions` + `video_transcripts` (the agent is
  told to use only what the command prints), and PASO 2/3 gain "Images across…"
  and "Video transcripts across…" evidence blocks mirroring
  `topic_synth._user_prompt`.
- `.claude/skills/enriching-x-knowledge/SKILL.md`: the topic-object field list
  now names `image_descriptions` + `video_transcripts`, and step 3 instructs the
  session to weigh them as visual/transcript evidence.

Producer:
- `topic_synth.export_topic_worksheet` also emits `video_transcripts`
  (per-video-bounded by `build_topic_inputs`, empty list when none).

Guard + tests (TDD; producer tests can't catch a consumer that drops a field):
- `tests/test_worksheet_consumer_wiring.py`: text-level assertions that the
  workflow extraction/prompt and the skill field list carry BOTH new fields —
  fails if a future edit removes them from the consumer.
- Producer tests for `video_transcripts` (present + empty), mirroring the
  image tests.

Minor:
- Worksheet + topic-worksheet `instructions` now name the extra evidence
  (links / article / transcript / image descriptions) as signal to weigh.
- Docs (ARCHITECTURE / CLAUDE / README): replace the vague "forced re-run" with
  the actual propagation commands — `xbrain vocab --regenerate` (clears
  enrichments) then `enrich`, and `xbrain topics --resynth`; note the workflow +
  skill consumers surface the field.
- Test-quality comment on the decorative worksheet test: the model invariant
  `is_decorative => description == ""` makes the filter's decorative clause
  independently untestable (the empty-description backstop already excludes it).

Not in scope: frame-descriptions -> topics (remaining #56 piece; frames aren't
on TopicInput), the api path, and the #44 video code beyond the one export line.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…t enrich/topics tracks (#62)

Closes #34; closes the transcript-to-topics half of #56.

Producer + consumer wiring so content-bearing image descriptions (and bounded video transcripts) reach the LLM on the claude-code worksheet track, not just the API track.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add an additive, ordered `blocks: list[ArticleBlock]` field to
`ContentSourceSuccess`, mirroring exactly how `frames`/`has_speech`/
`language` were added for `x_video` in #44 — no new top-level model, no
parallel store. `ArticleBlock` is a `kind`-discriminated union:

  - `ArticleTextBlock(kind="text", text)`
  - `ArticleImageBlock(kind="image", media: MediaEntry, alt=None)`

The image block WRAPS the existing `MediaEntry` photo-state union, so the
photo download engine, the `_reject_local_path_traversal` /
`_require_utc_aware` validators, the `_media/` mirror and a future
describe path all apply with zero new plumbing. `text` stays the source
of truth for the flattened body (= concatenation of the text blocks), so
enrich/topics/generate's fallback consume it unchanged.

Purely additive + back-compat: legacy `items.json` (no `blocks` key,
pre-#20 `ok:`/legacy-media shapes) loads unchanged, defaulting `blocks`
to `[]`. Model-only PR: no extract/fetch/media/generate behaviour change.

Adds `ArticleBlockAdapter` (out-of-`Item` validation, mirrors
`MediaEntryAdapter`). Docs: ARCHITECTURE data-model invariant #12 + the
`x_article` note; CLAUDE.md model-seam bullet.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…mageBlock)

Additive `blocks: list[ArticleBlock]` on ContentSourceSuccess; the image
block wraps the existing MediaEntry photo-state union so the download
engine + validators + _media/ mirror apply with no new plumbing. `text`
stays the flattened body (unchanged), so enrich/topics/generate's
fallback consume it as before. Purely additive + back-compat (proven
lossless on 29k real records) — legacy items.json loads with blocks=[].
Model-only; the producer (fetch), download walk (media) and blogpost
renderer (generate) land in PR2-5.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`digest-video --frames` was locked to whatever single model `[vision].command`
pointed at. Add a selector so one command serves local AND cloud vision, picked
per run:

- scripts/xbrain-vision — a multi-backend wrapper fulfilling the xbrain vision
  contract (`<cmd> [--model NAME] <image>` → stdout). NAME routes by registry:
  local (mlx-vlm: qwen-3b/7b/32b or any hf/repo) or cloud (Claude opus/sonnet/
  haiku or any claude-<id>). Cloud uses the Anthropic REST API via stdlib urllib
  — no SDK dependency; local shells to the mlx-vlm tool python.
- digest-video --vision-model NAME — overrides [vision].model for the run, so a
  slide-heavy talk can go to `opus` while the bulk stays local `qwen-3b`.
- Docs: README "Local models for digest-video" (install + selector table +
  per-run examples), config.toml.example [vision] selector block.

Tests: --vision-model reaches the vision command; no override falls back to
[vision].model. mypy clean, digest+cli suites green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ards

Address PR2 review round (all non-blocking):
- dedup the synthesized article link against a canonical URL key
  (host aliases → x.com, drop scheme + trailing slash) so a twitter.com /
  http / trailing-slash variant in entities.urls no longer double-adds;
- only synthesize for an Article __typename (reject a Card carrying a rest_id);
- require a numeric-string rest_id (kills the non-scalar garbage-URL vector);
- keep _extract_links at radon A by hoisting the append into a helper;
- note the card/unified_card variant as a conscious deferral folded into the
  same real-payload validation step (docstring + ARCHITECTURE).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Extract detects the X long-form Article entity on a tweet result
(article.article_results.result.rest_id, anchored via _dig) and synthesizes its
canonical https://x.com/i/article/<id> Link, routing through the existing
rendered-fetch path with no fetch_x change. Normalized dedup against
entities.urls (host aliases → x.com, scheme + trailing slash insensitive) plus
__typename ("Article"-only) and numeric-string rest_id guards prevent duplicate
or garbage links. The Article GraphQL fixture is constructed, not a recorded
live payload — validate the key path (and the deferred card/unified_card
variant) against a real bookmarked-Article response before production reliance
(RFC #39 open-Q #4).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- xbrain-vision: guard image read (OSError → clean error), catch URLError (not
  just HTTPError) on the cloud path, add an inner mlx subprocess timeout (240s <
  the 300s runner cap, so no orphaned child), sentinel-wrap local output so
  library stdout noise can't leak into the caption, and unpack tuple/list
  generate() returns defensively. `haiku` alias → plain `claude-haiku-4-5`.
- cli: `--vision-model` without `--frames` is now a hard error (was a silent
  no-op), matching the flag's documented requirement.
- docs: fix README (XBRAIN_VISION_MLX_PYTHON is the LOCAL knob, not cloud) + note
  large local models must be pre-pulled (300s timeout); drop the stale
  `qwen2-vl-7b` example the wrapper would reject from config.toml.example.
- tests: new tests/test_xbrain_vision.py locks _resolve routing (aliases, claude
  passthrough, hf/repo, unknown→exit, default) + empty-output→exit-1; plus a CLI
  test for the --vision-model-without-frames error.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…-model guard

CI (80-col) wrapped/styled the typer BadParameter panel, breaking the literal
'--frames' substring match that passed at a wider local width. Assert exit_code
== 2 (click usage error) + strip ANSI before matching.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(digest): multi-backend vision model selector + docs (#56)
…ant)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ot-crash, determinism pin

- article.py: WARN on a dropped entity-bearing block (real-payload key-drift
  visibility); `_find_url_by_key` now prefers the canonical CDN key globally
  (deep media_url_https beats a shallow bare url); decompose _coerce_content_state.
- fetch_x.py: INFO structured/fallback, WARN on captured-but-0-blocks (silent-death
  signal) + on structured-body truncation vs trafilatura (<50%); DEBUG the .json()
  swallow; wrap parse in try/except → degrade to fallback on ANY exception
  (incl. RecursionError); `_attach_x_sources` gains an injectable `now=` clock.
- models.py: document that an image-only article legitimately has text=="".
- Determinism: pin PYTHONHASHSEED in scripts/check.sh + quality.yml; mock
  trafilatura in the structured-path test.
- Tests: decoy-blocks-key gate, canonical-URL preference, dropped-block warning,
  captured-but-0-blocks warning, truncation warning, parser-exception fallback,
  injectable-clock. README: "reads like a blogpost" → "can later render as one".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…rade, not crash)

The truncation diagnostic runs on the structured SUCCESS path; a trafilatura
failure there must not discard the already-built good structured body. Wrap the
trafilatura.extract call in try/except → skip the check (DEBUG), keep the body.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…lidator, observability, tripwire)

Structured X-article capture: intercept the article-content GraphQL response and
parse its Draft.js content_state into an ordered ArticleBlock body (text runs +
MediaPhotoPending inline images, in document order); degrade to the retained
trafilatura.extract text-only fallback on any interception/parse miss (never a
crash, never a partial body masquerading as complete). The flattened `text` is
enforced to equal the concatenation of the text runs by a ContentSourceSuccess
model_validator. x_article re-enrich hygiene: _attach_x_sources bumps fetched_at
only on a material change (reused fingerprint). Observability + a truncation
tripwire surface silent degradation; the parser degrades on any exception.

Fixture + article GraphQL op-name are CONSTRUCTED (no real captured payload) —
validate against a real bookmarked-Article response before production reliance
(RFC #39 open-Q #4). PR3 attaches blocks with pending images; the image DOWNLOAD
is PR4 and the blogpost RENDER is PR5.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…nerator, docs scope-down

Fix round addressing PR4 reviews:
- Dedicated MediaReport.article_images_skipped counter (stop folding article
  skips into photos_skipped_already_downloaded); surfaced in SUMMARY + CLI echo.
- Thread --limit into _iter_eligible_article_images (top-of-iteration budget
  check, matching the photo generator) so a spent budget no longer scans past
  and miscounts skips.
- Docs: scope down two render/mirror overclaims (article render/mirror is PR5,
  not PR4) in README + ARCHITECTURE; reconcile skip-counter wording in CLAUDE.
- Tests: article-only total-failure raises; two-x_article-source global index
  continuation; limit-exhaustion skip-count parity; dedicated skip-counter
  assertions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…57)

Diagnosis: a large share of "transcription failed" videos are simply SILENT —
no audio track at the source (muted clips, GIFs, screencasts; confirmed with
yt-dlp that no audio format exists, ruling out separate audio streams). parakeet
-mlx writes no output file for them, which xbrain's transcribe contract treats as
a hard failure — even though xbrain's own design wants them attached as
has_speech=false ("silent video" note).

- scripts/xbrain-transcribe: wraps parakeet-mlx; when parakeet exits 0 but wrote
  no file, `ffprobe`-checks the media — no audio stream → emit the empty-speech
  JSON (silent video); HAS audio but no output → surface as a real failure (not
  masked as silent). Non-zero parakeet exit propagates. Local, no API.
- docs: README "Transcriber wrapper" note + config.toml.example [transcribe].
- tests/test_xbrain_transcribe.py: locks the 4 branches (speech pass-through,
  silent→empty-speech, audio-but-empty→fail, parakeet-nonzero→propagate) + the
  no-ffprobe fallback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…nscribe wrapper (PR #64 review)

Review (python-bug-finder) surfaced that xbrain reads the first NON-EMPTY *.json
by content (transcribe.py glob), not <stem>.json by name. Hardened accordingly:

- Detect "parakeet produced a transcript" as any non-empty *.json (mirrors
  xbrain), not `<stem>.json`.exists(). Fixes: an empty/stub file no longer fools
  the wrapper into skipping silent-classification (finding #1), and output under
  an unexpected filename is still detected (removes stem coupling, finding #4).
- `_confirmed_no_audio`: classify silent ONLY when ffprobe positively confirms no
  audio (returncode 0 + no audio stream). Unknown (no ffprobe / probe error) →
  real failure, never masked as silent (finding #3).
- `XBRAIN_PARAKEET` is shlex-split so a multi-token wrapper command works.
- Tests: empty-stub-not-output, non-stem output name, confirm-silence truth table
  (no-audio / audio / probe-error / no-ffprobe), + the four main() branches.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(digest): handle silent (no-audio) videos instead of failing them (#57)
… branch

Address the PR5 review panel (all non-blocking):
- Skip the bare `## Content:` heading when an x_article's blocks render to
  nothing (e.g. an all-pending image-only Article), mirroring
  `_video_digest_lines`' empty-block guard.
- Add a test for the video-variant-on-article-image defensive branch (logged +
  skipped, no embed, no crash).
- Strengthen the pending-image-silent test (no `![[` embed, no dead URL leaks).
- Tighten `_article_caption_lines` type hint to `MediaPhotoDownloaded | MediaPhotoDescribed`.
- Fix ARCHITECTURE wording: the failed-image line is a `>` blockquote, and note
  the empty-body heading suppression.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ages

Closes the render stage. The full #39 chain (extract→fetch/blocks→media→
generate) is now complete end-to-end. Text-only fallback preserved. The
constructed-fixture caveat (open-Q #4) still stands.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…stics

Post-review tightening on the #39 chain (all reviewers cleared; small, scoped):

- architect M1: hoist the "\n\n" inter-paragraph separator to a single source
  of truth `models.ARTICLE_PARAGRAPH_SEP`, imported by both the producer
  (extract.article) and the consumer (generate). The two independent constants
  coupled only by a comment could drift; now they can't. models is the shared
  home (both already depend on it; no new generate->extract edge).
- data-safety #1: the navigation `except` in fetch_x `_fetch_tweet` /
  `_fetch_rendered` bucketed every failure as `timeout` and discarded
  `str(exc)`. Keep the `timeout` classification but capture a capped `str(exc)`
  into `error` (new `_nav_error`, capped at `_MAX_ERROR_LEN`, mirroring
  media._format_error) and add `logger.warning(..., exc_info=True)` so a real
  nav failure on the unvalidated article path is debuggable.
- data-safety #4: `_fetch_tweet`'s `on_response` swallowed a non-JSON body with
  a bare `pass`; now a DEBUG log with the url, matching `_fetch_rendered`.
- quality-gates: add the `_needs_x_fetch` `until`-upper-bound test (created_at >
  until => not fetched), closing the one untested pure-logic branch.

No failure_reason reclassification, no render/model/media behavior change.
Gate green in-worktree: 1032 tests, 95% coverage, radon A/B.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
[#39] Capture X long-form Articles as blogposts
…eet-mlx, mlx-vlm)

The digest-video external tools were only described in the "Local models" section,
not surfaced as prerequisites, and ffmpeg (needed for --frames extraction AND the
transcribe wrapper's audio probe) was never listed as an install step.

- Prerequisites table: add ffmpeg + parakeet-mlx + mlx-vlm (optional — only for
  digest-video; external, not pulled by `uv pip install`), linking the setup section.
- Local-models section: add `brew install ffmpeg` as step 1; document the models
  that download on first use + their sizes/RAM, and the no-install cloud option.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
docs: surface digest-video external dependencies (ffmpeg, parakeet-mlx, mlx-vlm)
@VGonPa VGonPa merged commit 5cd0c5e into main Jul 4, 2026
1 check 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.

1 participant