Skip to content

Scope Media-bin media to the service: code narrows, the model decides - #161

Merged
jimhoggey merged 5 commits into
mainfrom
service-folder-scoping
Oct 30, 2026
Merged

jimhoggey merged 5 commits into
mainfrom
service-folder-scoping

Conversation

@jimhoggey

@jimhoggey jimhoggey commented Oct 30, 2026 •

Copy link
Copy Markdown
Owner

Why

On the booth machine, v2.18.0 pulled in the right Summit media — and some random media with it, and nothing said which folders it had used. Two causes, both in media_match.py:

  1. A short folder name matched on one shared word. _folder_score divided by the shorter name, so a folder called Youth scored a perfect 1.0 against any runsheet that mentioned youth — and Summit is a youth event. Its whole contents became candidates.
  2. No template meant searching the whole bin, 150 assets ranked by name, when nothing matched.

And the banner only named a folder when exactly one was used, so the multi-folder case was silent.

What changed

Code narrows, the model decides. The flow is drawn at the top of propresenter/media_match.py:

  1. Narrow (code) — drop folders named for another known service (saved rows + template names) unless a runsheet line mentions it. A Summit runsheet never sees Sunday Service Media.
  2. Decide (model) — new parsing/folder_plan.py asks which folders hold this service's media. It reads folder names (dozens), never the bin's thousands of files. Skipped when the operator has saved folders for the service.
  3. Narrow (code) — those folders' files, at most 150, with a share per runsheet line instead of one global rank.
  4. Decide (model) — the existing asset call places files under items.
  5. Check (code) — real files, real items, never over a template item, four per segment.

Saved folders per service. settings["media_folders_by_service"], e.g. Sunday → Current media + Sunday media. Per service on purpose — a global "always use" list would leak Sunday media into Youth. A row applies only if it shares a word with the service's kind (service_type), not just its dated title, so a Sunday row can't claim "Summit 2026 — Sunday Night".

The folder line and picker. A second line under the template banner says which folders were used and who chose them (your saved folders / AI / matched by name / none). Clicking it opens a picker listed recommendation-first: used for this runsheet, folders that look like this service, everything else. Saving stores the ticks for the service and places this runsheet's media again (POST /api/media/place) without re-parsing.

No whole-bin fallback. Nothing for this service is said, not papered over.

One helper for the model calls (second commit). media_plan, lighting_plan and the new folder_plan each carried the same twenty lines to ask OpenRouter for JSON. parsing/ask.py is those lines once, as ask_json(), and holds the guarantees no caller can drop — data_collection: deny, temperature 0, None rather than an exception, the key never logged. tests/test_ask.py states them directly; the three test files share one openrouter fixture instead of three copies. The live folder check gives identical answers through it.

Smaller: service_tokens (was _template_signal_tokens) is now the one tokeniser for "which service is this", with folder words passed in so template detection is unchanged; settings.json is read as UTF-8 (a migrated file with em-dashes decoded to mojibake on Windows without raising); a failed playlist-name lookup no longer cancels all bin media.

Verified

  • Live against OpenRouter (GPT-4.1 mini), four hard cases, three runs each, identical every time:
    • Summit — Saturday Night with a "youth band" line → Saturday + Summit pre/after-service; Friday and Youth left out.
    • Summit 2026, no night → both nights + pre/after.
    • Youth Summit — Friday → Summit Friday + pre/after; not the weekly youth folder.
    • Sunday with "promo Summit" → Sunday Service Media + Summit's main folders, not its loops.
      The first prompt failed two of these (it skipped Sunday's own folder, and both Summit nights when the model wasn't shown the title); the prompt and the title are what changed.
  • In the browser: both lines render; picker groups, filter, ticks surviving the filter, Escape and focus return; Save with ProPresenter down keeps the parse's placements and says so.
  • _budgeted on 3,000 files × 60 lines: 35 ms.

Found and fixed while building

  • Re-placing could wipe the parse's slides, two ways. It cleared the bin's earlier picks before reading the bin, and again before asking the model — so ProPresenter restarting, or a dropped model call, between the parse and Save left nothing placed. Third commit: the steps run on a working view and the runsheet only changes once there is a real answer. plan_media now returns None for "didn't answer" and [] for "answered, nothing fits"; a real "nothing fits" still clears the old picks, because the operator un-ticked those folders. Tests for both doors.
  • service: ["Summit"] was stringified to "['Summit']" and saved as a row. Strings only now.

Front-end review (fourth commit)

A frontend-reviewer pass drove the page headless and measured. Fixed:

  • Drag-selecting the service name and releasing on the backdrop closed the picker and lost the ticks.
  • Save held nothing during placement — Create could build from the old picks mid-way, a second Save raced the first, and an answer for the previous runsheet could overwrite a new one. Create, Save and Forget now wait; stale answers are dropped.
  • Errors were a grey hint far from the buttons, showing raw TypeError: Failed to fetch. Now beside the buttons, role="alert", plain sentences.
  • After a failed placement, reopening showed the old folders, so a second Save restored them.
  • Identical banner redraws replayed the slide-in, re-announced the line and dropped keyboard focus.
  • The folder line appeared before the match call finished, when Save had nothing to place into.
  • Save sat outside the card at 667×427 (the minimum window at 150% Windows scaling).
  • Folder names inherited the muted label colour and a dead 6px gap; counts now read "38 files"; groups are labelled for screen readers; Enter saves; "Let AI choose" is now "Forget saved folders".

Each verified in the browser afterwards. Not changed, deliberately: no dialog in the app traps focus or sets inert (an app-wide pattern, not this picker's), and .btn-grn text contrast is shared with Create — both worth their own change.

Not covered

  • Never run against your real Media bin's folder names — ProPresenter wasn't running here. That's the first thing to check on the booth machine: app.log logs Media bin: N of M items got media from K folder(s), <source>: ....

Checks: 1357 passed, 1 skipped.

🤖 Generated with Claude Code

jimhoggey and others added 2 commits October 30, 2026 12:41
v2.18.0 pulled in random media on the booth machine and never said which
folders it used. A short folder name matched on one shared word -- a folder
called "Youth" scored a perfect match against any runsheet that mentioned
youth -- and with no template the whole bin was searched.

Now five steps, drawn at the top of propresenter/media_match.py: code drops
folders named for another known service unless a line mentions it; the
model picks folders from their names (new parsing/folder_plan.py); code cuts
those folders' files to 150 with a share per runsheet line; the model places
files; code checks every pick. Folders saved per service
(settings media_folders_by_service) skip the folder call and are final. A
row applies only if it shares a word with the service's kind, so a "Sunday"
row can't claim "Summit 2026 -- Sunday Night". No whole-bin fallback.

A folder line under the template banner says which folders were used and
who chose them; clicking it opens a recommendation-first picker that saves
for the service and places the media again without re-parsing.

Also: one tokeniser for "which service is this" (service_tokens), with
template detection unchanged; settings.json read as UTF-8; a failed
playlist-name lookup no longer cancels bin media; re-placing never clears
earlier picks when ProPresenter doesn't answer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
media_plan, lighting_plan and the new folder_plan each carried the same
twenty lines: resolve the model, build a body that only goes to providers
that keep nothing, add reasoning, call, check the status, dig the JSON out
of the reply. parsing/ask.py is those lines once, as ask_json(). Each module
keeps what differs -- its prompt, its timeout, what it accepts back.

The guarantees now live in one place no caller can drop: data_collection
deny, temperature 0, None rather than an exception on any failure, the key
never logged. tests/test_ask.py states them directly.

The three test files stubbed fetch_catalogue/resolve_model/chat by module
name; they share one `openrouter` fixture in conftest.py now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
jimhoggey and others added 2 commits October 30, 2026 12:50
Placing media again after a folder change cleared the bin's earlier picks
before asking the model, so a dropped call -- or any error in the steps --
wiped every slide the parse had placed. The steps now run on a working view
and the runsheet is only changed once there is an answer.

That needs "the model didn't answer" told apart from "it answered and
nothing fits": plan_media returns None for the first and [] for the second.
A real answer that nothing fits still clears the old picks -- the operator
un-ticked those folders.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Dragging out of the service field and letting go on the backdrop closed
  the picker and lost the ticks. It now closes only on a click that starts
  and ends on the backdrop.
- Save didn't hold anything while placing media: Create could build the
  playlist from the old picks mid-way, a second Save raced the first, and an
  answer for the previous runsheet could overwrite a new one. Create, Save
  and Forget now wait, and an answer for an older runsheet is dropped.
- A failed Save changed a grey hint far above the buttons, and errors
  showed raw "TypeError: Failed to fetch". Errors now appear beside the
  buttons in alert colours (role="alert") as plain sentences.
- After a failed placement, reopening showed the old folders, so a second
  Save put them back. It now shows what was saved.
- Banners are only rewritten when they change: identical redraws replayed
  the slide-in, re-announced the line and dropped keyboard focus. Focus
  follows to the new button when the line does change.
- The folder line waited for nothing: it appeared before the match call
  finished, and Save then placed nothing. It now appears with the results.
- On a short window (the minimum at 150% Windows scaling) Save sat outside
  the card; the list now gives way and the card scrolls past that.
- Folder names were muted like their counts with a dead gap between rows
  (inherited label styles); counts read "38 files"; each group is labelled
  for screen readers; Enter in the service field saves; "Let AI choose" is
  now "Forget saved folders" and forgets the row that actually applied.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@jimhoggey

Copy link
Copy Markdown
Owner Author

Self-review

Read the full diff against main, four commits. What I checked, and what it turned up.

Found and fixed while reviewing. Re-placing media after a folder change cleared the bin's earlier picks before reading the bin and before asking the model. ProPresenter restarting, or one dropped OpenRouter call, between the parse and pressing Save would have left the runsheet with no media at all. Fixed in 08b2ee5: the steps run on a working view, and items only changes once there is a real answer. That needed plan_media to tell "didn't answer" (None) from "answered, nothing fits" ([]) — the shared helper made that a two-line change. Tests cover both doors and the "nothing fits still clears" case.

Precedence. A saved row is final: the folder call is skipped and the row's folders are used as-is, even one the model would never pick. A model suggestion is re-checked against eligible_folders, so it can't reach another service's folder even if a runsheet line talks it into one. sections() still never touches an item a template filled, and bin sections are marked from_bin so a re-run replaces only its own.

Matching identity. Rows match the parse model's service_type, not the dated title — a "Sunday" row can't claim "Summit 2026 — Sunday Night". service_tokens is now the one tokeniser; template detection is unchanged (test pins _template_signal_tokens output).

Blast radius. Every failure path in place_bin_media returns either None (re-run: nothing changed, said plainly) or the empty summary (parse: headers only, as before this existed). A failed template-name lookup no longer cancels bin media. No whole-bin fallback remains.

Security. Both new endpoints take an object body only, accept strings only (a list once got stringified into a saved row — fixed), cap every length and count, and need application/json, so a cross-site page can't post to them without a preflight the app doesn't answer. Host and port go through pp_base's vetting. Model output is indices checked against the list it was shown. Folder names reach the page via textContent/escapeHtml and logs via log_safe. No new stats.track(). /api/media/place writes nothing to ProPresenter.

Live model behaviour. Four hard cases against GPT-4.1 mini, three runs each plus one after the helper refactor, identical every time. The first prompt failed two; the dated title and relevance-not-scarcity wording fixed them.

Front end. A frontend-reviewer pass measured ten issues in the picker; the ones that cost a volunteer a Sunday are fixed and each re-verified in the browser (55be2c8). Left alone deliberately: no dialog in the app traps focus, and .btn-grn contrast is shared with Create.

Performance. _budgeted tokenises each name and line once: 3,000 files × 60 lines in 35 ms.

Not covered. Never run against the real Media bin's folder names — ProPresenter wasn't running here. First thing to check on the booth machine; app.log names the folders and who chose them.

Checks: 1357 passed, 1 skipped locally.

🤖 Reviewed by Claude Code

CodeQL #197: /api/media/place returned str(e) for UnreachableHost. The
message is operator-written, but the route didn't need to catch it at all —
propresenter_app.py already answers UnreachableHost for every route in the
standard {"ok": false, "error": ...} shape, as library.py relies on. Test
added for the SSRF guard on this route.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@jimhoggey

Copy link
Copy Markdown
Owner Author

Self-review: 98c5c89. One commit since the review above: /api/media/place no longer catches UnreachableHost and returns str(e) (CodeQL #197). The app-level handler already answers it for every route, so the behaviour is the same with one less path. New test confirms 8.8.8.8 is refused through it. Tests 7/7 green including Windows, CodeQL green, no open alerts, no unresolved threads.

🤖 Reviewed by Claude Code

@jimhoggey
jimhoggey merged commit e0c7158 into main Oct 30, 2026
9 checks passed
@jimhoggey
jimhoggey deleted the service-folder-scoping branch October 30, 2026 02:03
@jimhoggey jimhoggey mentioned this pull request Oct 30, 2026
jimhoggey added a commit that referenced this pull request Oct 30, 2026
Media-bin media scoped to the service: folders saved per service, picked
by the model from their names otherwise, and a folder line under Step 1
saying which folders were used and who chose them (#161).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants