Skip to content

admin: count master-YAML templates on Overview; pptx: say what a slide type takes - #197

Merged
dvejsada merged 4 commits into
masterfrom
claude/admin-overview-master-yaml
Sep 21, 2026
Merged

dvejsada merged 4 commits into
masterfrom
claude/admin-overview-master-yaml

Conversation

@dvejsada

Copy link
Copy Markdown
Collaborator

Two fixes, both found by reviewing the deployed instance after #194 shipped. They are unrelated in code but came from the same look, so they are one PR with a commit each.


1. The Overview did not count hand-written templates

The deployment has the shape that exposes this: one Word template declared in config/docx_templates.yaml, none managed by the UI. Three pages then disagreed about the same fact:

Page Said
Word ▸ Templates smlouva_o_poskytovani_sluzeb — Live — master YAML ✅
Word ▸ Overview → "Tools the AI can call" only create_word_document — the live tool absent ❌
Dashboard → Word tile 0 LIVE ❌
Server ▸ Status 1 LIVE WORD TOOLS ✅

section_facts() built its counts and its tool list from store.list_specs() — the managed half only. template_table() walks the managed specs and unmanaged_master_specs(), which is why the Templates tab was the one page that was right. A hand-written template registers the same MCP tool as one made here, so it belongs in both.

Both halves now feed the record. ToolFact.origin keeps them distinguishable — From master YAML rather than From a template, because one is editable here and the other is not, and knowing which saves a trip to the Templates tab to find out why a row has no Edit button. The note above the table used to say master templates are shown read-only elsewhere, which read as "and are not counted here"; it now says they are counted.

On the tests: they register the template the way main.py does at startup. A fixture that only writes the YAML has a template the registry has never heard of, so live 0 is correct there and the test passes against the bug — which is what my first draft did, and why it's worth calling out.

2. A rejected slide field now says what that type does take

The same review turned up a real failed call on the server:

Invalid slides: slide 0 -> body: Extra inputs are not permitted;
slide 2 -> subtitle: Extra inputs are not permitted; slide 3 -> subtitle:
… slide 7 -> body: Extra inputs are not permitted

A seven-slide deck lost whole — the schema is strict, so one field on the wrong type fails the entire call and nothing is generated. The caller assumed what most people assume: that every slide takes a title, a subtitle and a body. It put body on the title slide and subtitle on the content slides.

The published schema was not the gap. It already names the types per field — subtitle renders as [title] Subtitle: author, tagline, date… [closing] Line under the closing title. The gap is that the error names the mistake and not the fix, and by the time it arrives the caller has read the schema once and drawn the wrong conclusion. Repeating the rule where it failed is what turns a retry into a correction:

slide 1 -> subtitle: 'subtitle' is not a field of a 'section' slide
(it takes: title, notes, layout); 'subtitle' belongs to: title, closing.

Both sides are derived from _SLIDE_MODELS, so a new slide type is covered without touching the error path, and a field no type declares says so rather than pointing nowhere. Only extra_forbidden is rewritten — every other error keeps pydantic's own wording, which already says something useful, and there is a test pinning that.

The tool description gains one paragraph on the same point, including that the call fails whole — worth knowing before writing eight slides — and docs/powerpoint-slides.md a matching line under its per-type field table.

Also corrects coerce_slides()'s docstring, which claimed MCP callers never reach this path because FastMCP validates first. They always reach it: the parameter is Any carrying a hand-built schema, so nothing validates earlier. These strings are the tool's error message, not developer diagnostics.


Testing

ruff check . clean. 2459 passed, 12 failed — the same pre-existing set verified on master by stashing (7 test_pptx_text_metrics font metrics, 3 test_admin_source_files Windows symlink/filename, 1 test_admin_pptx encoding, 1 test_admin_log_view capture-level).

8 new tests. The three admin ones fail without the fix and pass with it (checked by stashing only the two source files). The pptx ones pin the exact production payload.

Docs updated in the same change: docs/development/admin-ui.md, docs/development/tools/powerpoint.md, docs/powerpoint-slides.md, and the create_powerpoint_presentation description in main.py.

🤖 Generated with Claude Code

Daniel Vejsada and others added 3 commits September 20, 2026 23:13
Found by reviewing the deployed instance, which has the shape that exposes
it: one Word template declared in config/docx_templates.yaml and none managed
by the UI. Three pages then disagreed about the same fact.

The Templates tab showed smlouva_o_poskytovani_sluzeb as Live. Server >
Status counted 1 live Word tool. But the Word Overview said 0 live, the
dashboard tile said 0, and the tool was missing entirely from the table
headed "Tools the AI can call" — which it can.

section_facts() built its counts and its tool list from store.list_specs(),
the managed half only. template_table() walks the managed specs *and*
unmanaged_master_specs(), which is why the Templates tab was the one page
that was right. A hand-written template registers the same MCP tool as one
made here, so it belongs in both.

Both halves now feed the record. The origin on each ToolFact keeps them
distinguishable — "From master YAML" rather than "From a template", because
one is editable here and the other is not, and knowing which saves a trip to
the Templates tab to find out why a row has no Edit button. The note above
the table changes with it: it used to say master templates are shown
read-only elsewhere, which read as "and are not counted here"; it now says
they are counted.

The regression tests register the template the way main.py does at startup.
A fixture that only writes the YAML has a template the registry has never
heard of, so "live 0" would be correct there and the test would pass against
the bug — which is exactly what the first draft of it did.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The same review turned up a real failed call on the deployed server:

  Invalid slides: slide 0 -> body: Extra inputs are not permitted;
  slide 2 -> subtitle: Extra inputs are not permitted; slide 3 -> subtitle:
  … slide 7 -> body: Extra inputs are not permitted

A seven-slide deck, lost whole — the schema is strict, so one field on the
wrong type fails the entire call and nothing is generated. The caller had
assumed what most people assume: that every slide takes a title, a subtitle
and a body. It put 'body' on the title slide and 'subtitle' on the content
slides.

The published schema already says which types accept which field — 'subtitle'
renders as "[title] … [closing] …". So the schema was not the gap. The gap is
that the error names the mistake and not the fix, and by the time it arrives
the caller has read the schema once and drawn the wrong conclusion. Repeating
the rule where it failed is what turns a retry into a correction:

  slide 1 -> subtitle: 'subtitle' is not a field of a 'section' slide
  (it takes: title, notes, layout); 'subtitle' belongs to: title, closing.

Both sides are derived from _SLIDE_MODELS, so a new slide type is covered
without touching the error path, and a field no type declares says so rather
than pointing nowhere. Only extra_forbidden is rewritten; every other error
keeps pydantic's own wording, which already says something useful.

The tool description gains one paragraph on the same point, including that
the call fails whole — worth knowing before writing eight slides — and the
reference page a matching line under its per-type field table.

Also corrects coerce_slides()'s docstring, which claimed MCP callers never
reach this path because FastMCP validates first. They always reach it: the
parameter is declared Any carrying a hand-built schema, so nothing validates
earlier. These strings are the tool's error message, not developer
diagnostics, and the docstring now says so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both sides added a paragraph to the same place in docs/powerpoint-slides.md,
under the per-type field table. Kept both, the field contract first because
it is what the table directly above it is a list of.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@dvejsada

Copy link
Copy Markdown
Collaborator Author

@claude review

@claude

claude Bot commented Sep 20, 2026 •

Copy link
Copy Markdown

Claude finished @dvejsada's task in 2m 4s —— View job


Review complete

  • Gather context (read diff, changed files)
  • Review admin/app.py + admin/views/sections.py (Overview master-YAML counting fix)
  • Review pptx_tools/schema.py (extra_forbidden error rewrite)
  • Review main.py + docs updates
  • Review new/updated tests
  • Post final review feedback

Both fixes are correct and well-targeted. Details below.

1. Overview counting fix (admin/app.py, admin/views/sections.py)

section_facts() now builds specs from both store.list_specs() and unmanaged_master_specs() (admin/app.py:299-302), tagging each with its origin, and derives templates/live/disabled from the combined list (admin/app.py:341-346). This matches what template_table() already did, so the three previously-disagreeing pages (Overview, dashboard tile, Templates tab) now read from the same data. unmanaged_master_specs() already excludes any master entry a managed spec overrides, so there's no double-counting risk.

The ToolFact.origin distinction ("template" vs "master", admin/views/sections.py:38-40, 115-121) is a nice touch — no existing code branches on the literal string "template" elsewhere in the tree, so introducing the new "master" value is safe.

The test fixture note is worth calling out as good practice: test_admin_master_templates.py's _install_and_register() explicitly registers the template the way main.py does at startup, rather than just writing YAML — otherwise the test would pass against the bug (live 0 both before and after the fix), which the PR description flags as a mistake caught in an earlier draft. Good catch, and good that it's documented in the test docstring.

2. extra_forbidden rewrite (pptx_tools/schema.py)

_rejected_field() / _fields_by_type() / _types_by_field() are cleanly derived from _SLIDE_MODELS, so a new slide type is picked up automatically, matching the PR's stated intent. Traced through the field lists for all 14 slide types (pptx_tools/schema.py:198-454) — no top-level field name collides with a SLIDE_TYPES string, so _describe_error's existing rest = [part for part in loc[1:] if part not in SLIDE_TYPES] filtering (unchanged by this PR) correctly leaves len(rest) == 1 only for genuine top-level extra fields. Nested extra fields (e.g. inside fills, left/right, or a blank slide's elements) fall through to pydantic's default wording rather than misfiring with wrong guidance — verified this is intentional via the len(rest) == 1 guard.

One pre-existing fragility, not introduced by this PR: the field/slide-type disambiguation relies on no field ever being named the same as a SLIDE_TYPES value (e.g. a future field literally called image or table). If that ever happens, _describe_error would misidentify slide_type or strip a legitimate path segment. Not worth blocking on since it's an existing pattern this PR extends rather than a new risk, but worth a one-line comment near SLIDE_TYPES if someone wants to harden it later.

Docs (docs/development/admin-ui.md, docs/development/tools/powerpoint.md, docs/powerpoint-slides.md) and the main.py tool description all accurately reflect the code — checked the "FIELDS ARE PER TYPE" paragraph against the actual per-type field lists and it's correct (subtitle only on title/closing, body only on content/chart/image, section has no fields beyond the base title/notes/layout).

Tests

New tests in both files exercise exactly the production failure modes described in the PR body (master-YAML-only deployment shape; the real seven-slide rejection payload) and assert on the specific wording, not just "raises". test_every_type_can_describe_its_own_fields and test_other_errors_keep_their_own_wording are good guardrails against regressions in either direction.

Note on verification

I wasn't able to run ruff check . or pytest in this sandboxed session (shell commands beyond basic search tools required approval that wasn't available here), so this review is based on static reading of the diff plus tracing the schema/field definitions by hand. The PR description's stated test results (2459 passed, 12 pre-existing failures, 8 new tests) weren't independently re-verified.

No blocking issues found.

Review flagged as a latent risk that the slide-type/field disambiguation
would misbehave "if a field is ever named the same as a slide type". It
already is: `title` is a field on every slide and also a slide type.

The path was built by dropping every segment that matched a type name, so
the one field whose name collides was silently removed from it. An error
about a slide's title rendered as

    slide 0: Input should be a valid string

naming no field at all — on the field every single slide has. Whatever else
a caller can be expected to work out, that one is unguessable.

The discriminator tag is always at position 1 of a tagged union's path, so
position is what identifies it. Exactly one leading segment is dropped, and
a collision cannot recur however the field names change. The same change
makes this commit's own slide_type lookup exact rather than first-match.

    slide 0 -> title: Input should be a valid string

Pre-existing, not introduced here, but this PR builds on that filter, so it
is this PR's to fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@dvejsada

Copy link
Copy Markdown
Collaborator Author

Thanks — no changes needed for the two fixes themselves, but the 'latent fragility' is worth reporting back on, because it is not latent.

the field/slide-type disambiguation relies on no field ever being named the same as a SLIDE_TYPES value (e.g. a future field literally called image or table). If that ever happens…

It already has. title is both a field on every slide and a slide type. The path was built by dropping every segment matching a type name, so the one field whose name collides was being silently removed from it:

>>> coerce_slides([{'type': 'content', 'title': {'bad': 1}, 'body': '- a'}])
Invalid slides: slide 0: Input should be a valid string

No field named — on the field every single slide has. Fixed in d8ceade: the discriminator tag is always at position 1 of a tagged union's path, so position identifies it and exactly one leading segment is dropped. A collision cannot recur however the field names change, which is stronger than the comment you suggested.

Invalid slides: slide 0 -> title: Input should be a valid string

Two tests added: one for a field named like a slide type keeping its name, one for a nested path (series.0.values.0) surviving the tag being dropped. Pre-existing rather than introduced here, but this PR builds on that filter, so it is this PR's to fix.

Re-ran locally: ruff check . clean, and the pptx/schema selection is 603 passed, 8 failed — all 8 the pre-existing set (7 test_pptx_text_metrics font metrics, 1 test_admin_pptx encoding). Noted that you could not run the suite yourself; CI's lint-and-test is the independent check.

@dvejsada
dvejsada merged commit 72bb96a into master Sep 21, 2026
1 check passed
@dvejsada
dvejsada deleted the claude/admin-overview-master-yaml branch September 21, 2026 04:03
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