feat: explain provider capability gaps instead of degrading silently (#358) #492

Merged
claude-bot merged 6 commits from feat/358-capability-gaps into main 2026-09-05 02:13:20 +00:00
Contributor

Closes #358. Also finishes the criterion #351 deliberately left partial: a run is now marked schema_degraded from the provider's declaration, before any output is parsed, not only when the tolerant parser repairs.

What

  • Gap derivation, pureapp/services/capability_service.py: derive_capability_gaps(asr, llm, llm_config)CapabilityGap(code, severity, title, consequence) in plain language, worst-first. Catalogue: llm_schema_not_enforced (high), llm_window_needs_chunking (medium, N computed with the summariser's own prompt_budget_tokens + split_transcript_windows against a documented 60k-token representative transcript: 8k→13 passes, 32k→3, 65k→2, ≥128k→1), llm_no_prompt_token_reporting (medium), llm_front_truncates (low, medium when prompt tokens are also unreported), llm_provider_guessed (low), asr_no_word_timestamps (medium), asr_no_vocabulary (low).
  • APIGET /api/admin/ai/capabilities{asr, llm, gaps}; an unconfigured side reports configured: false rather than erroring. The admin Test LLM / Test Whisper responses gain provider, provider_pinned, gaps — on the failure path too, since a wrongly-guessed provider is a plausible cause of the failure.
  • Provenance — migration a5b6c7d8e9fa: one nullable JSONB summarisation_runs.capability_gaps (NULL = not recorded, [] = no gaps). process_audio seeds the run with both sides' gaps; summarise() marks schema_degraded from the declaration up front and never raises over provenance.
  • Frontend — an "What your AI configuration can and cannot do" panel first in Bot Settings, severity-styled, refreshed after save and after either test button; the test results show the provider and gap count. Session detail shows "Summarised under a limited configuration: …" in the same amber shape as the existing pre-timeline-fix marker. That note is GM-only: the page never read the run before, and the runs endpoint is GM-gated because a failed beat can quote transcript content; a player-visible version would be a small derived field on the session response, not a loosened endpoint.
  • Docs: OPERATIONS gets the gap table under "Choosing an LLM".

Rebased onto main after #355 (one changelog conflict, both entries kept).

Verification

  • Migration applied, downgraded and re-applied on PostgreSQL 16.
  • Backend: 1744 passed (28 new). Frontend: 479 passed, eslint clean apart from one pre-existing warning. ruff clean. Version sync OK; no bot contract change.

🤖 Generated with Claude Code

Closes #358. Also finishes the criterion #351 deliberately left partial: a run is now marked `schema_degraded` from the provider's *declaration*, before any output is parsed, not only when the tolerant parser repairs. ## What - **Gap derivation, pure** — `app/services/capability_service.py`: `derive_capability_gaps(asr, llm, llm_config)` → `CapabilityGap(code, severity, title, consequence)` in plain language, worst-first. Catalogue: `llm_schema_not_enforced` (high), `llm_window_needs_chunking` (medium, N computed with the summariser's own `prompt_budget_tokens` + `split_transcript_windows` against a documented 60k-token representative transcript: 8k→13 passes, 32k→3, 65k→2, ≥128k→1), `llm_no_prompt_token_reporting` (medium), `llm_front_truncates` (low, medium when prompt tokens are also unreported), `llm_provider_guessed` (low), `asr_no_word_timestamps` (medium), `asr_no_vocabulary` (low). - **API** — `GET /api/admin/ai/capabilities` → `{asr, llm, gaps}`; an unconfigured side reports `configured: false` rather than erroring. The admin **Test LLM / Test Whisper** responses gain `provider`, `provider_pinned`, `gaps` — on the failure path too, since a wrongly-guessed provider is a plausible cause of the failure. - **Provenance** — migration `a5b6c7d8e9fa`: one nullable JSONB `summarisation_runs.capability_gaps` (NULL = not recorded, `[]` = no gaps). `process_audio` seeds the run with both sides' gaps; `summarise()` marks `schema_degraded` from the declaration up front and never raises over provenance. - **Frontend** — an "What your AI configuration can and cannot do" panel first in Bot Settings, severity-styled, refreshed after save and after either test button; the test results show the provider and gap count. Session detail shows "Summarised under a limited configuration: …" in the same amber shape as the existing pre-timeline-fix marker. That note is **GM-only**: the page never read the run before, and the runs endpoint is GM-gated because a failed beat can quote transcript content; a player-visible version would be a small derived field on the session response, not a loosened endpoint. - Docs: OPERATIONS gets the gap table under "Choosing an LLM". Rebased onto main after #355 (one changelog conflict, both entries kept). ## Verification - Migration applied, downgraded and re-applied on PostgreSQL 16. - Backend: **1744 passed** (28 new). Frontend: **479 passed**, eslint clean apart from one pre-existing warning. ruff clean. Version sync OK; no bot contract change. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
`AsrCapabilities` and `LlmCapabilities` have named word timestamps, schema
enforcement, the context window and prompt-token reporting since #350/#351.
Nothing read any of it. The declarations existed so the pipeline could branch;
the person who picked the model was never told what the branch cost them — and
the person hitting it is usually a self-hoster who chose on VRAM and price.

`capability_service.derive_capability_gaps` turns the declarations into a list
of `{code, severity, title, consequence}` in a GM's terms. Each entry says what
someone will *see*: "the model is not held to the format Quest Board asks for,
so events can be dropped with nothing failing", not "schema_enforcement is
ADVISORY". Mechanism stays in the provider docstrings, where it already is.

Pure by construction — capability objects in, list out, no database, no HTTP,
no settings — so each gap is unit-testable, including the combinations no
bundled provider declares today (ADVISORY enforcement, unreported prompt
tokens). Those are exactly the shapes a future managed adapter will have.

Two decisions worth recording:

- The chunking gap reports "a typical session takes N passes", not "your window
  is small", because only the first is actionable. N comes from the
  summariser's own `prompt_budget_tokens` and `split_transcript_windows` run
  against a documented ~60k-token representative session (3.5 hours, five
  speakers). A second copy of that arithmetic is how the #349 harness once
  measured a fix as doing nothing.
- Front-truncation is `low` while the provider still reports prompt tokens —
  the existing warning fires and an operator can act — and `medium` when it does
  not, because the pair leaves nothing anywhere able to notice.

Gap codes are a separate namespace from the `DEGRADED_*` codes in
`app.providers`: those say what happened to one result, these say what the
configuration cannot do before anything has run, and only one of them has a fix.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Migration a5b6c7d8e9fa. One nullable JSONB column on `summarisation_runs`,
additive, no backfill.

`schema_degraded` landed in f7a8b0c1d2e3 with a deliberately partial criterion:
it was set only when the tolerant parser had to repair the extractor's output.
That is the symptom. A provider that *declares* it cannot enforce a schema is
knowable before the first token is generated, and the runs where an unenforced
schema produced plausible-but-wrong JSON — the ones most worth doubting — never
tripped the repair path and were recorded as clean. `summarise` now stamps the
record from the declaration up front; the repair-time marking stays, because it
also catches a provider that claims enforcement and does not deliver it (#281).

`capability_gaps` is the wider record: everything both providers could not do
at the moment the run was made. `process_audio` seeds it from both sides — it
is the only place that knows the ASR config as well as the LLM one — and
`summarise` tops up the LLM side for every other caller, so a record passed in
from anywhere carries at least the gaps that could affect the prose.

Stored rather than re-derived, for the same reason `speaker_legend` is: the
answer changes the moment an operator changes a setting, and a summary read
next season should be explained by the configuration that actually wrote it.
NULL means "not recorded" — every run predating the column, and any run whose
providers could not be resolved. An empty list is the different, stronger claim
that the configuration had no gaps.

Both stamping paths swallow their own failures and log. Losing a summary over
its provenance is the inversion `_persist_summarisation_run` already guards
against.

Verified: upgrade head, downgrade -1, upgrade head against PostgreSQL 16.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`GET /api/admin/ai/capabilities` returns both resolved providers' declarations
and their merged gap list, worst first. Admin-only, via the same `require_admin`
dependency as the rest of the router.

It describes rather than acts, so an unconfigured side is *data*: `configured:
false` with a reason, never a 4xx. This is the fourth case in `settings_service`'s
policy list, and it matters here more than anywhere — someone opens this
endpoint precisely when something is wrong, and answering a missing endpoint
with a 500 would hide the one fact they came for. A stored provider name this
build does not know is reported the same way rather than raising `UnknownProvider`.

The two "Test connection" buttons now return the resolved provider name and the
same gap list alongside their existing fields. Reachability was the whole answer
and it is the easy half: an endpoint can reply in 300 ms and still be unable to
enforce a schema or hold a session in one prompt. The capability fields are
returned on the *failure* path too, deliberately — a provider guessed wrongly
from the URL is a plausible cause of the failure being reported, so dropping the
list there would hide the likeliest explanation at the moment it is wanted.

Additive throughout: every existing key on both responses is unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Two surfaces, one idea: a limitation of the endpoint you picked should not read
as a limitation of Quest Board.

**Admin → Bot Settings** opens with "What your AI configuration can and cannot
do" — the resolved provider on each side, and every gap with the consequence a
GM will notice. Placed first, not last, because someone lands on this page
because their summaries look wrong. Severity is carried in the colour and a
badge as well as the text, so the schema gap — categorically worse than the rest
— does not have to be read to be seen. The panel re-reads after a save and after
either Test button, since both are things an operator does immediately after
changing a provider, and the test results now show the resolved provider and
gap count next to the latency.

**Session detail** shows "Summarised under a limited configuration: …" with the
gap titles when the run behind the summary recorded any. Deliberately the same
amber shape as the existing pre-timeline-fix marker: both say "this content is
real but was produced under a known limitation", and a GM should recognise that
at a glance rather than learning two notices. Titles only — each consequence is
a paragraph, and those live in Admin.

The run is fetched lazily and GM-only (the endpoint is, because a failed beat
can quote transcript content). A rejection is swallowed: every session
summarised before runs were persisted 404s here, and that means "no record",
not an error worth putting on a page.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Backend (`test_capability_gaps.py`, 28 tests): one per gap, a control asserting
a fully capable configuration yields none — without which every other test could
pass on a function that returns everything unconditionally — the endpoint with a
configured and an unconfigured side, and the two test buttons.

Most tests construct capability objects directly rather than going through the
registry. Several gaps describe properties no bundled provider declares today
(ADVISORY enforcement, unreported prompt tokens), and those are precisely the
shapes a future managed adapter will have; testing only what ships would leave
the interesting branches unexercised until someone depended on them. One test
does pin the shipped defaults, so a change to the bundled Whisper server or the
default llama.cpp window has to be acknowledged rather than silently dropping a
gap.

The marking test patches the prose call to return nothing at all, so the
assertion that the run came back `schema_degraded` with its gaps recorded can
only pass if the record was stamped from the declaration rather than from a
response. A companion test asserts NULL rather than `[]` for a run nobody
observed, because those are different claims.

Frontend: the panel renders each gap with its severity and consequence, orders
high before medium before low, names both providers and says when one was only
guessed, reports an unconfigured side plainly, and re-reads after the LLM test
button. On the session page, the provenance note names every gap; a run with no
gaps and a session with no run both render nothing.

Backend rules observed: one authenticated client per test, `httpx.AsyncClient`
patched at the constructor, response shape asserted before its keys.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
docs: name the capability panel and the gaps it can report (#358)
All checks were successful
CI / Backend lint (ruff) (pull_request) Successful in 26s
CI / Summarisation accuracy eval harness (stub provider) (pull_request) Successful in 57s
CI / Frontend tests, audit, and build (pull_request) Successful in 2m18s
CI / Docker image build (pull_request) Successful in 22s
CI / Bot/backend version sync (pull_request) Successful in 25s
CI / Bot tests and audit (pull_request) Successful in 1m39s
CI / Backend migration, tests, and audit (pull_request) Successful in 7m9s
35797680f9
OPERATIONS.md gains "What your AI configuration can and cannot do" under
"Choosing an LLM", with a table of every gap, its severity, and what a GM would
otherwise just experience — which is the framing the whole issue is about. It
also states the two things an operator will otherwise assume wrongly: nothing
in the panel is a measurement of their endpoint (it is what the adapter declares
about itself, hence available without sending a request), and the gaps stored on
a run are never re-derived, so fixing a setting today does not rewrite last
month's provenance.

The changelog entry leads with the migration, since that is the line a
self-hoster reads first.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
claude-bot scheduled this pull request to auto merge when all checks succeed 2026-09-05 02:06:27 +00:00
claude-bot deleted branch feat/358-capability-gaps 2026-09-05 02:13:20 +00:00
Sign in to join this conversation.
No description provided.