Skip to content

Validate model-returned product and image ids - #201

Merged
christophervoelpel merged 9 commits into
mainfrom
fix/validate-storyboard-image-ids
Sep 24, 2026
Merged

christophervoelpel merged 9 commits into
mainfrom
fix/validate-storyboard-image-ids

Conversation

@christophervoelpel

@christophervoelpel christophervoelpel commented Sep 21, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Validate model-returned product/image IDs against the supplied products, retry invalid responses up to four total attempts, and raise an error when retries are exhausted. Missing product descriptions become empty strings (SM-2 and SM-14).

At the UI handoff, preflight the entire generated storyboard before replacing the current storyboard or submitting any candidate. A missing image mapping, including a legacy cached result, shows an error and submits no scenes. Unmatched lookups omit referenceImage instead of constructing an object with undefined fields. Manual text-only candidate generation keeps its existing behavior.

Validation

  • Setup dispatch tests: 32 passed, including valid-result and invalid mixed-result cases.
  • RemixEngine mediated tests: 91 passed.
  • Python storyboard tests: 8 passed.
  • UI spec typecheck and lint passed.
  • Dispatch regressions failed before the guard and passed after it. No paid generation or live cloud workflow was run.

Stacked on #196. Its updated parent is included; merge after #196. Application CI currently targets PRs based on main, so rebase and retarget after the parent squash merge, then run fresh CI.

Backend:
- Build valid_product_image_combinations in actions/generate_storyboard.py.
- Retry Gemini call up to 4 times (1 initial + 3 retries) if invalid product_id or image_id is returned, matching write_products_script.py.
- Raise RuntimeError if retries are exhausted.
- Default missing or None product_description to "" instead of "None".

Frontend:
- Add optional chaining when indexing productsToOutpaintedImages and referenceImage in RemixEngineService.generateStoryboard.

Tests:
- Add unit tests in actions/test/test_generate_storyboard.py covering product_id retry, image_id retry, retry exhaustion RuntimeError, valid ID pass-through, and empty product_description handling.
- Add Vitest spec in remix-engine-mediated.spec.ts verifying unmatched IDs do not throw.
@gps-readability-bot

Copy link
Copy Markdown

Still need readability approvals from:

@gps-readability-bot

Copy link
Copy Markdown

Still need readability approvals from:

@gps-readability-bot

Copy link
Copy Markdown

Still need readability approvals from:

@gps-readability-bot

Copy link
Copy Markdown

Still need readability approvals from:

@gps-readability-bot

Copy link
Copy Markdown

Still need readability approvals from:

@gps-readability-bot

Copy link
Copy Markdown

Still need readability approvals from:

@gps-readability-bot

Copy link
Copy Markdown

Still need readability approvals from:

@christophervoelpel

Copy link
Copy Markdown
Collaborator Author

PR #201 — P2 — missing mapping silently becomes text-only generation

Reviewed head: 052b9bd3e1800528f51c2d3aa7d6a527ced9afbd.

Please fail visibly when the product/image lookup is missing in ui/src/app/services/remix-engine/remix-engine.ts:2020–2039, rather than return referenceImage: {url: undefined, path: undefined}.

The backend validation is bypassed by an existing action-cache hit: actions_wrapper.py keys on inputs/parameters and returns the cached result before calling the updated action. Storyboard requests are non-forced by default, so an invalid pre-update artifact remains reachable. After normal storyboard acceptance, Setup generates candidates and reads the missing referenceImage.path, silently requesting text-only video instead of using the product image.

The new frontend test currently asserts this silent result and no error. Please change it to require a visible failure and zero candidate-generation submissions when an expected mapping is missing, including a simulated legacy cached result. Preserve valid fresh-result behavior and the new backend validation. Merely omitting the empty object still permits unintended text-only generation; fail before dispatch instead. A broad cache-versioning change is not required for this fix.

@christophervoelpel

Copy link
Copy Markdown
Collaborator Author

Review: merge after small fixes

Multi-agent review (reviewer → independent critique agent re-verifying each claim against the code). The critique pass overturned the reviewer's main refactor suggestion; details below.

The approach is right. Validation lives backend-side where it can actually retry, with the TS change as genuine belt-and-braces rather than a second implementation of the rule. It deliberately follows the established write_products_script.py retry contract instead of inventing a new failure mode. Verified behaviour: one bad scene invalidates the whole response (break) and re-generates; there's no partial salvage; exhaustion raises RuntimeError → workflow fails → UI snackbar. Nothing is swallowed. Three of the five new Python tests genuinely fail against old code.

Also verified clean — the biggest latent risk isn't one: response_schema declares image_id / product_id as "string" (generate_storyboard.py:75-87) and the UI sends product.id.toString() (remix-engine.ts:838), so the str()-keyed validation cannot false-reject and burn four Gemini calls.

The PR body undersells its best change

The diff also changes str(img_obj.get("product_description", None)) to fall back to "". Previously str(None) == "None" is truthy, so the prompt literally shipped **Product description:** 'None' for every image lacking a description. That's the highest-value change in this PR, it's the only new Python test that fails against old code for that path, and it's mentioned in neither the title nor the body. Worth calling out — it's the kind of thing someone will otherwise "discover" again in six months.

Worth fixing

  1. Derive the combos map instead of maintaining parallel state (generate_storyboard.py:287-311). valid_product_image_combinations is built inside the image loop, in parallel with products[*].images. After the loop:

    valid_product_image_combinations = {p.id: {i.id for i in p.images} for p in products.values()}

    Deletes 4 added lines and the dict/set bookkeeping. Also missing the annotation (dict[str, set[str]]) that products has.

  2. remix-engine.ts:2031-2036 builds a GcsFile with undefined required fields:

    {url: referenceImage?.url, path: referenceImage?.path}

    config.ts:114-119 declares both as string; this only compiles because of the outer as GeneratedScene cast. A truthy referenceImage with no path will then pass if (scene.referenceImage) guards downstream. Prefer ...(referenceImage ? {referenceImage: {...}} : {}). (I flagged this from the spec's own expect(result?.[0].referenceImage?.url).toBeUndefined(); I did not independently open the GcsFile declaration.)

  3. Add one console.warn for an unmatched pair (remix-engine.ts:2018). Right now the frontend is completely silent — the new spec asserts expect(matSnackBarMock.open).not.toHaveBeenCalled() — while the backend hard-raises for the same condition. That makes the defence-in-depth path undiagnosable in the field.

  4. Docstring: execute() gained no Raises: for the new RuntimeError (write_products_script.py:61 has one). While you're there, worth noting a new asymmetry: "no storyboard text" and "No storyboard found" raise inside the retry loop (no retry), while an id hallucination retries. Defensible — just say so.

Suggested deletions (~70 lines)

  • test_execute_valid_ids_pass_through_unchanged (~28 lines) passes against old code and duplicates test_execute_success. Fold its two id assertions into that test (~3 lines).
  • Merge the two hallucination tests (..._hallucinated_product_id_triggers_retry / ..._image_id_...) into one subTest-parameterised test — they differ by one string (~40 lines).
  • Optional: reuse the new _make_mock_response in the two pre-existing tests that hand-roll the same MagicMock chain.

Placement nit: _make_mock_response and the five new tests were inserted above setUp, which now sits mid-class. Also one ~105-char assertion line vs pyproject.toml's line-length = 80.

Explicitly not recommended

  • Don't extract a shared validation helper into actions_lib. The retry block does look like a near-verbatim copy of write_products_script.py:201-239, but the two combos dicts are built from different shapes (that one walks a dict-of-dicts; this one builds inside the image loop), so only a ~12-line pure predicate is actually shared. A new module for a 12-line loop used twice is more surface than the duplication it removes.
  • Don't add retry backoff or prompt feedback. Resending an identical prompt four times is wasteful in principle, but it only happens in the pathological case, and changing it is a design decision, not a review fix.
  • A ProductImageId type would be over-engineering for a dict-of-sets used in one place.

Process notes

@christophervoelpel

Copy link
Copy Markdown
Collaborator Author

Consolidated Review

Verdict: Fix one blocker, then merge. This note reconciles the 19:29 and 19:50 comments on this PR against head 052b9bd, and every item below was re-verified by tracing callers on both sides and running the tests and probes listed under Evidence.

Do before merge

  1. Add a dispatch-time guard for a missing product/image mapping. ui/src/app/setup/setup.ts:795-826 calls generateCandidates for every scene once the storyboard dialog is accepted, without checking referenceImage. generateCandidates (remix-engine.ts:1096-1170) only checks that global config and a video model are available, never scene.referenceImage validity, so a truthy-but-empty referenceImage (built at remix-engine.ts:2020-2039) reaches startVideoGenerationWorkflow (remix-engine.ts:607-646) with productImagePath: undefined and silently starts text-only generation. This stays reachable after the backend validation ships too: actions_wrapper.py:190-241 keys the cache on an input checksum with no code-version salt, and force_execution defaults false, so a pre-fix cached result is served without calling the updated generate_storyboard.execute. Acceptance: the frontend spec must require a visible failure and zero candidate-submission calls when a mapping cannot be resolved, including a simulated legacy cached result, while the existing valid-fresh-result case still passes.

Optional, does not block

  • Make referenceImage honest at the type level: build {url, path} only when the lookup succeeds, instead of the undefined-field object that only compiles via the as GeneratedScene cast (remix-engine.ts:2031-2036, config.ts:114-119), and add a console.warn on the unmatched-pair path (remix-engine.ts:2018). Neither replaces the dispatch guard above; both fit in the same patch.
  • Derive valid_product_image_combinations from products after the loop instead of building it in parallel (generate_storyboard.py:287-311), with the dict[str, set[str]] annotation products already has.
  • Add a Raises: RuntimeError line to execute()'s docstring, matching write_products_script.py:61.
  • Fold test_execute_valid_ids_pass_through_unchanged into test_execute_success; merge the two hallucination tests into one parameterized case. Two lines exceed the 80-column limit.

Rejected or superseded, do not re-litigate

  • The 19:50 comment's fixes for the silent fallback (omit the referenceImage key, add a console.warn) do not close the gap alone: generateCandidates has no gate on referenceImage at all, so an omitted key still reaches startVideoGenerationWorkflow with productImagePath: undefined and still starts text-only generation. Keep both as secondary hardening once the dispatch guard lands.
  • The 19:50 count of three new Python tests failing on old code is off by one: four of five fail against base, only test_execute_valid_ids_pass_through_unchanged passes unchanged.
  • A shared actions_lib validation helper, retry backoff or prompt feedback, and a dedicated ProductImageId type: all three were considered and correctly rejected as unneeded for a small predicate used in two different call sites.

Evidence

  • pytest -q actions/test/test_generate_storyboard.py: 8 passed.
  • pytest -q test/test_frontdoor.py::test_trigger_action_non_forced_reuses_legacy_cache: 1 passed, confirms a non-forced cache hit bypasses the wrapped action.
  • Base-vs-head probe, run twice independently: head's test file against base's generate_storyboard.py gives 4 failed, 1 passed.
  • npm test -- --watch=false --include src/app/services/remix-engine/remix-engine-mediated.spec.ts: 91/91 passed, including the spec that currently asserts the silent fallback.
  • git diff --stat against the PR base: 4 files touched, none overlapping #196's actions_lib/gemini.py.
  • Not run: live Gemini calls, GCS/Firestore, browser flow, CI (workflow triggers on main only, this PR targets a feature branch).

Way forward

@gps-readability-bot

Copy link
Copy Markdown

Readability approvals granted:

Still need readability approvals from:

@christophervoelpel

Copy link
Copy Markdown
Collaborator Author

Follow-up on Consolidated Review

I addressed the generated-storyboard mapping blocker from the Consolidated Review on head 0e634300d9f1.

  • Setup now preflights every generated storyboard mapping before replacing the current storyboard or submitting candidates; a missing mapping, including a legacy cached result, produces an error and submits no scenes.
  • Unmatched lookups omit referenceImage rather than constructing an object with undefined fields. Manual text-only generation keeps its existing behavior.

Verification: Setup dispatch tests passed with 32 tests, RemixEngine mediated tests with 91, and Python storyboard tests with 8. UI spec typecheck and lint passed. The dispatch regression failed before the guard and passed afterward. No paid generation or live cloud workflow was run.

This branch includes the updated #196 parent. Because application workflows target PRs based on main, own-branch application CI is not triggered while stacked; after #196 squash-merges, this PR needs rebasing and retargeting to main for fresh application CI.

Parent #196 CI passed at e9fd413e9bf8, including Python 3.11/3.12/3.13, UI build/lint/tests and deploy/security checks. This PR's current GitHub check is CLA only; application CI has not run on the child branch. The local child checks are listed above; fresh application CI remains required after rebase and retargeting to main.

@christophervoelpel
christophervoelpel changed the base branch from fix/explicit-mime-allowlist to main September 22, 2026 08:51
Comment on lines +417 to +431
for scene in candidate_result["storyboard"]:
product_id = scene.get(Dimension.PRODUCT_ID.value)
image_id = scene.get(Dimension.IMAGE_ID.value)
if (
product_id not in valid_product_image_combinations
or image_id
not in valid_product_image_combinations.get(product_id, set())
):
is_valid = False
logger.warning(
"Invalid scene from Gemini: product_id=%s, image_id=%s",
product_id,
image_id,
)
break # This scene is invalid, so the whole result is.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In response_schema, video_prompt and scene_name are typed as "string" without "minLength": 1. Currently, the retry loop only checks (product_id, image_id) against valid_product_image_combinations and immediately breaks out of the retry loop with is_valid = True even if video_prompt or scene_name is an empty string (""). Those scenes are then silently dropped after the loop at lines 447–448 (if not image_id or not product_id or not video_prompt or not scene_name: continue), which can leave valid_scenes empty ({"storyboard": []}) without having triggered a retry.

Including not video_prompt and not scene_name in the retry validation condition ensures empty scene fields trigger the retry loop instead of bypassing retries and being silently discarded post-loop.

Suggested change
for scene in candidate_result["storyboard"]:
product_id = scene.get(Dimension.PRODUCT_ID.value)
image_id = scene.get(Dimension.IMAGE_ID.value)
if (
product_id not in valid_product_image_combinations
or image_id
not in valid_product_image_combinations.get(product_id, set())
):
is_valid = False
logger.warning(
"Invalid scene from Gemini: product_id=%s, image_id=%s",
product_id,
image_id,
)
break # This scene is invalid, so the whole result is.
for scene in candidate_result["storyboard"]:
product_id = scene.get(Dimension.PRODUCT_ID.value)
image_id = scene.get(Dimension.IMAGE_ID.value)
video_prompt = scene.get("video_prompt")
scene_name = scene.get("scene_name")
if (
product_id not in valid_product_image_combinations
or image_id
not in valid_product_image_combinations.get(product_id, set())
or not video_prompt
or not scene_name
):
is_valid = False
logger.warning(
"Invalid scene from Gemini: product_id=%s, image_id=%s",
product_id,
image_id,
)
break # This scene is invalid, so the whole result is.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, applied in a273ab6. An empty video_prompt or scene_name now fails the in-loop validation and triggers a retry, instead of passing validation and then being dropped by the post-loop filter (which could store {"storyboard": []} without ever retrying).

New test test_execute_empty_scene_fields_trigger_retry covers both fields as subtests. It asserts a second Gemini call and that the stored storyboard keeps its scene. Both subtests fail against the previous commit and pass now. Full Python suite: 713 passed.

Comment on lines +2034 to +2036
if (referenceImage) {
scene.referenceImage = referenceImage;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Assigning scene.referenceImage = referenceImage directly aliases the productsToOutpaintedImages[s.product_id][s.image_id] object across every scene in the storyboard that references the same (product_id, image_id). Shallow-cloning referenceImage (and preview when present) preserves per-scene object isolation consistent with the pre-PR behavior and other referenceImage assignments in RemixEngineService.

Suggested change
if (referenceImage) {
scene.referenceImage = referenceImage;
}
if (referenceImage) {
scene.referenceImage = {
...referenceImage,
...(referenceImage.preview
? {preview: {...referenceImage.preview}}
: {}),
};
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, applied in a273ab6 as suggested. It restores the per-scene copy main had and matches the {...scene.referenceImage} copies used elsewhere in RemixEngineService.

New spec gives each scene its own reference image object generates two scenes that share the same (product_id, image_id). It asserts their referenceImage values are equal but not the same object, and likewise for preview. It fails against the previous commit (1 failed / 91 passed) and passes now. UI: compile, lint, and typecheck:spec clean, 701/701 tests pass.

Treat an empty video_prompt or scene_name as an invalid Gemini result so
it triggers a retry instead of being silently dropped after the loop,
which could leave an empty storyboard. Give each generated scene its own
referenceImage (and preview) copy instead of aliasing the shared
outpainted-image entry across scenes.
@gps-readability-bot

Copy link
Copy Markdown

Readability approvals granted:

@christophervoelpel
christophervoelpel merged commit 27f3774 into main Sep 24, 2026
13 checks passed
@christophervoelpel
christophervoelpel deleted the fix/validate-storyboard-image-ids branch September 24, 2026 09:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants