fix(review): repair every provider path, add per-role AI config - #54
Conversation
Video review was failing on four independent paths at once, which is why
none of them looked fixable from the symptoms. All four verified live
against the installed binaries, not inferred.
ffmpeg without drawtext. Homebrew's ffmpeg 8.x is built without
libfreetype, so `drawtext` does not exist, and naming an absent filter
aborts the whole chain ("No such filter: 'drawtext'"). Frame extraction
died before any provider ran — the repo's own 13 contact-sheet tests fail
on main for this reason. Probed once and cached; without it the sheets
lose their burned-in timestamps and the prompt hands the model the frame
interval instead, so it can still answer in time ranges.
agy was auto-denied. Given a bare file path agy shells out to look at the
file; headless mode cannot prompt for that permission, so the tool is
denied and the run returns an empty response with exit code 0 and status
SUCCESS. Steering it at its own file-reading tool gets the read done
unprivileged, so --dangerously-skip-permissions — which auto-approves
every tool including arbitrary shell commands, for a job that reads three
JPEGs — is gone. Output now comes from --output-format json, and a denied
tool is an error whether or not agy still answered: the prompt carries the
rubric, both scene prompts and the character names, which is enough to
write a plausible review without looking at a frame.
codex no longer bypasses its sandbox. -i hands it the image bytes
directly, so the run needs neither a shell nor a writable filesystem;
--sandbox read-only already implies approval:never. An empty output file
is an error instead of a JSON decode failure three frames away.
stdin is closed for all three. Each appends piped stdin to the prompt when
stdin is not a terminal — codex documents it as a `<stdin>` block.
Two silent-wrong-answer paths in the scoring, same class, both pre-existing:
- Every dimension defaults to 5.0, so an answer carrying no `dimensions`
became a complete, plausible review — 5.0 across the board, verdict
"poor", zero errors — of a video nothing had looked at. Now refused; a
partial dimensions object still defaults the unscored axes.
- The error parser required the exact keys severity/time_range/description
and silently dropped anything else. What it dropped was usually
CRITICAL, the one severity that caps character_consistency at 3.0 and
forces the verdict below acceptable, so `timeRange` turned an unusable
video into a clean pass. The three fields are now handled by what they
can cost: near-miss names are normalised, a missing time range or
description is repaired and logged, and only a severity outside
{CRITICAL, HIGH, MINOR} fails the scene. VideoError.severity is a
Literal now, so the three code paths that branch on it cannot be handed
anything else.
Per-role provider, model and effort. agent/services/cli_providers.py holds
everything the three CLIs disagree about; providers.json gains a `roles`
map and the dashboard gains a Settings page. Efforts are validated against
each CLI's real ladder; models only for agy, whose catalog is closed, so a
slug newer than claude's or codex's cache still goes through. agy's model
and effort are mutually exclusive — its slugs name their own effort and it
rejects the pair. The legacy {"active": ...} switch still works, keeps a
model when the CLI did not change, and does not undo a role the same
request configured by name.
Tests 259 passing / 13 failing on main -> 338 passing, 0 failing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ka5BxVaWiQeWNDJakpCJJP
The step existed because naming an absent filter aborted the whole ffmpeg chain and took 13 contact-sheet tests down with it. video_reviewer.py now probes for drawtext and drops it when missing, and the untimestamped path has its own coverage, so its absence is a supported degradation. The render check stays — the timestamped path is the better one and a silent loss of it belongs in the log — but it no longer fails the build for a condition the code handles. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ka5BxVaWiQeWNDJakpCJJP
One review, one provider. `resolve_role` ran per scene, and each scene
awaits a download, an executor hop and a subprocess, so the loop yields
repeatedly — while providers.json is documented as hand-editable and a
dashboard GET hot-reloads it. Scenes 4..N could run on a different backend
than scenes 1..3, with overall_score averaging both and no record of
which produced what. Resolved once in review_video and threaded down.
The closed catalog is now enforced. catalog_is_authoritative was published
to the client and checked nowhere, so a typo'd agy slug got a 200 and then
failed seconds into the next review with a raw CLI error — the exact
outcome resolve_role's own docstring says this module exists to prevent.
An empty catalog still does not block: emptiness means the listing failed,
not that the provider has no models.
CLI failures carry stdout too. claude puts the readable sentence there
("There's an issue with the selected model (...)") and buries the machine
tag under paragraphs of unrelated context advice on stderr. Dropping
stdout left the operator reading about token windows.
providers.json is written atomically. Truncating in place was survivable
when the file changed on a rare provider switch; it is written on every
dashboard settings change now, and a truncated file hard-fails
agent/config.py at import, so the server would not boot at all.
The drawtext probe is an optimisation, not a correctness check.
`ffmpeg -filters` proves the filter is compiled in, not that it can
render — a build with libfreetype and no resolvable font lists it and
then dies on "Cannot find a valid font for the family Sans", which is the
original symptom on a box where the probe says everything is fine.
Extraction retries untimestamped, and _create_contact_sheets now returns
what actually happened rather than letting the caller re-derive it.
Smaller ones from the same pass: resolve_role drops a model the API would
have rejected, since the file is hand-editable and the two paths have to
agree; an `active` sweep no longer silently adopts a role name this build
does not know, which the `roles` path 400s on; list_models hands back a
copy rather than the cached list itself; and the comments claiming
scripts/statusline.sh reads `active` are gone — it does not, the sole
reader is skills/fk-change-provider.md.
Test gaps the same pass named, now closed: _has_drawtext had zero
coverage and its regex had never run against real `ffmpeg -filters`
output; _run_claude_cli had no model/effort/add-dirs argv test while agy
had three; _spawn_and_check's message was untested. The API tests also
stubbed no catalog, so validating an agy model spawned a real `agy
models` and passed in CI only because a missing binary degrades to empty.
338 -> 352 tests.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ka5BxVaWiQeWNDJakpCJJP
Adversarial review round applied — 13 findingsA code-review pass found 13 issues. All actionable ones are now closed; the PR body above describes the original change, this comment covers what the review changed. Landed before the review reported (same conclusion, independently): the agy denied-tool guard now raises on any denial, not only a denial with an empty response. The reviewer's argument for it is worth recording: a hallucinated review produced after a denied file read carries a populated HIGH — fixed, each with a regression test verified to fail without the fix
On the malformed-error fix: I had planned a blanket raise on any entry that did not match the three keys. The reviewer pointed out this fails reviews that correctly identified a CRITICAL defect over a stray MEDIUM — fixed
PLAUSIBLE, fixed anyway — the drawtext probe tested presence, not usability. An ffmpeg with libfreetype but no resolvable font lists the filter and then dies at runtime, which is the original symptom on a box where the probe says everything is fine. Extraction retries untimestamped, and LOW — fixed: Verdicts on the areas I asked about — argument injection clean (effort is a closed tuple at both entry points, so the codex TOML value can never carry a quote or a second key; the Test gaps closed: 338 → 352 tests. CI green on 3.10 and 3.13. Re-verified live end-to-end after every change — both providers still catch the planted defect and cap the score. Still unproven: codex's happy path. Its OpenAI workspace is out of credits, so no live answer was ever obtained from it. 🤖 Generated with Claude Code |
Two things the changelog was missing. First, why this went unnoticed for so long: the suite was red on any dev machine for the whole period, while CI stayed green because the workflow installs ffmpeg and a font and then asserts drawtext renders — the one environment that ran the tests was the one environment where they passed. Second, codex's happy path is no longer unproven. All three providers now have an end-to-end run against the real CLI: a synthetic clip with a planted defect, through extraction, contact sheets and a live vision call, on the default model and on an explicitly selected model + effort. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ka5BxVaWiQeWNDJakpCJJP
codex verified — all three providers now proven end-to-endThe workspace has credits again, so the one gap in this PR is closed. Same harness as claude and agy: a synthetic 8s clip with a planted green square that flashes at 2–3s, through real frame extraction, real contact sheets and a live CLI vision call, no mocks anywhere.
Every path works, including the per-role model and effort actually reaching the CLI — the two codex rows used different models and produced different grades, which is what proves the setting is wired through rather than ignored. One thing worth flagging rather than burying: codex graded the same planted defect far more leniently than the other two — MINOR on its default model against CRITICAL from both claude and agy, which is the difference between "excellent" and "poor" on the same clip. That is a model judgment difference, not a defect in this PR: all three saw it and reported it, and the severity caps behaved correctly for the severity each assigned. But anyone switching Changelog updated with what the verification covered, plus the reason this went unnoticed so long: the suite was red on any dev machine for the whole period while CI stayed green, because the workflow installs ffmpeg and a font and asserts 🤖 Generated with Claude Code |
Video review was failing on four independent paths at once, which is why none of them looked fixable from the symptoms. Every finding below was verified live against the installed binaries, not inferred from the code.
The clearest evidence of how long this has been broken: on
main, 13 of this repo's own tests fail on a normal dev machine. CI stayed green because.github/workflows/tests.ymlinstalls ffmpeg andfonts-dejavu-coreand hard-asserts thatdrawtextrenders — so the one environment that ran the suite was the one environment where it worked.The four failures
1. ffmpeg without
drawtext. Homebrew's ffmpeg 8.x is built without libfreetype, so the filter does not exist, and naming an absent filter aborts the whole chain (No such filter: 'drawtext'). Frame extraction died before any provider was reached — this broke review for all three CLIs, not just the two named in the report. Now probed once and cached; without it the sheets lose their burned-in timestamps and the prompt hands the model the frame interval instead, so it can still answer in time ranges. Verified live: both claude and agy computed correct time ranges from untimestamped sheets.2. agy was auto-denied. Given a bare file path, agy reaches for a shell command to look at the file. Headless mode cannot prompt for that permission, so the tool is denied and the run returns an empty response with exit code 0 and
status: "SUCCESS"— which the old code read straight into the JSON parser.Steering agy at its own file-reading tool gets the read done unprivileged, so
--dangerously-skip-permissionsis gone. That flag auto-approves every tool including arbitrary shell commands, for a job whose entire need is reading three JPEGs.A denied tool is now an error whether or not agy still answered. The denied-and-answered case is the more dangerous one: the prompt carries the full scoring rubric, the scene's image prompt, its video prompt and the character names — enough to write a complete, plausible review from the text without ever looking at a frame.
3. codex no longer bypasses its sandbox.
-ihands codex the image bytes directly, so the run needs neither a shell nor a writable filesystem.--dangerously-bypass-approvals-and-sandboxbought nothing and cost the sandbox;--sandbox read-onlyalready impliesapproval: never, so nothing hangs. An empty output file is an error instead of a JSON decode failure three frames from the cause.4. stdin closed for all three. Each appends piped stdin to the prompt when stdin is not a terminal — codex documents it as a
<stdin>block. Under uvicorn that is whatever the launching shell handed down.Two silent-wrong-answer paths in the scoring
Both pre-existing, both the same class as #2 — a failure wearing the shape of a result.
A review with no scores became a score. Every dimension defaults to
5.0, so an answer carrying nodimensionsproduced a complete, plausible review: 5.0 across the board, verdict "poor", zero errors, of a video nothing had looked at. Now refused. A partial dimensions object still defaults the unscored axes — a model that scored some axes is answering, just incompletely.A malformed error entry vanished. The parser required the exact keys
severity/time_range/descriptionand silently dropped anything else. What it dropped was usually CRITICAL — the one severity that capscharacter_consistencyat 3.0 and forces the verdict below "acceptable" — sotimeRangeinstead oftime_rangewas enough to turn an unusable video into a clean pass.The three fields are now handled by what they can cost: near-miss names are normalised, a missing time range or description is repaired and logged, and only a severity outside
{CRITICAL, HIGH, MINOR}fails the scene. That is the one field with no safe default, because without it we do not know whether the video passed.VideoError.severityis aLiteralnow, so the three code paths that branch on it cannot be handed anything else.New: per-role provider, model and effort
agent/services/cli_providers.pyholds everything the three CLIs disagree about.providers.jsongains arolesmap; the dashboard gains a Settings page (agent / model / effort per role, in all seven languages).high, claude and codex go tomax.agy modelsis the whole truth and it rejects anything else). claude takes aliases and full names, codex takes slugs newer than its on-disk cache, so for those an unlisted value is a documented escape hatch. A model starting with-is refused regardless.--model gpt-oss-120b-medium conflicts with --effort=low, and a slug with no effort in its name gives--effort is not supported for model "claude-sonnet-4-6". The API answers 400 for the pair and the UI cannot construct it.{"active": ...}switch still works, keeps a role's model when the CLI did not change, and does not undo a role the same request configured by name./fk-doctorgains a video-review section — the skill CLAUDE.md points at for pipeline errors had nothing about review at all.Verification
Live end-to-end through the full
review_scene_videopath (synthetic 8s clip with a deliberate mid-clip anomaly, real CLI calls, no mocks):codex could not be verified live: its OpenAI workspace is out of credits. The invocation is correct — it parses, attaches the images and reaches the model before failing — and the error path surfaces the real reason, but no live answer was obtained. Reviewer should treat codex's happy path as unproven.
Every new guard was checked for being load-bearing by removing it and confirming exactly the intended tests fail: the dimensions guard, the severity check, the key normalisation, the
active+rolesclobber and the same-provider model wipe.Both documented
PATCHexamples were wrong (they paired an agy model with an effort, which is a 400). Every-d '{...}'in the README and the skill now goes through the real handler in a scripted check; all provider examples return 200.Frontend:
tsc --noEmitclean, eslint clean on changed files,npm run buildclean. Three pre-existingreact-refresh/only-export-componentserrors remain in untouched vendored shadcn files.🤖 Generated with Claude Code
https://claude.ai/code/session_01Ka5BxVaWiQeWNDJakpCJJP