feat(IMP-11): D-2 — frame min_height_px hint (backend → UI)
Step 9 v4_all_judgments[] now exposes per-candidate min_height_px from catalog frame_contracts.visual_hints.min_height_px (None when contract unregistered). SlideCanvas pendingLayout zones render a red ring + 'min H Npx' badge when zone height falls below the active frame's threshold. Visual hint only; resize clamp (minSize=0.05) unchanged. 5 axes (single commit per Stage 5 plan): - u1 backend: src/phase_z2_pipeline.py — Step 9 builder adds min_height_px via single get_contract(c.template_id) lookup; reuses _contract for catalog_registered (no double-lookup). - u2 type: Front/client/src/types/designAgent.ts — FrameCandidate gains optional minHeightPx?: number. - u3 mapper: Front/client/src/services/designAgentApi.ts — maps snake-case min_height_px → camelCase minHeightPx on v4_all_judgments path; v4_candidates fallback remains undefined (graceful). - u4 active-frame lookup: Front/client/src/components/SlideCanvas.tsx — activeFrameId = overrideFrameId ?? defaultFrameId; activeCandidate via region.frame_candidates.find. - u5 hint render: Front/client/src/components/SlideCanvas.tsx — zoneHeightPx = height * SLIDE_H (logical px, no double-apply); compare against activeCandidate.minHeightPx in pendingLayout mode only; red border + badge when below. Tests: 5/5 pass in tests/test_phase_z2_step9_v4_all_judgments_min_height.py (source-string + catalog-shape guards + None propagation, registered and unregistered template_ids). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -612,6 +612,28 @@ export default function SlideCanvas({
|
|||||||
: null;
|
: null;
|
||||||
const previewUrl = previewCandidate?.thumbnailUrl ?? null;
|
const previewUrl = previewCandidate?.thumbnailUrl ?? null;
|
||||||
|
|
||||||
|
// IMP-11 u4: active frame lookup — distinct axis from preview.
|
||||||
|
// preview is shown only when override differs from default; active is
|
||||||
|
// always defined as override-if-present-else-default. Used by u5 to
|
||||||
|
// compare the active frame's catalog min_height_px against zone height.
|
||||||
|
const activeFrameId = overrideFrameId ?? defaultFrameId;
|
||||||
|
const activeCandidate = activeFrameId
|
||||||
|
? region?.frame_candidates?.find((c) => c.id === activeFrameId)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
// IMP-11 u5: catalog min_height_px violation hint. height is already
|
||||||
|
// a fraction of SLIDE_H (1280x720 logical px coordinate space), so
|
||||||
|
// logical px = height * SLIDE_H. measuredSlideBody.h is intentionally
|
||||||
|
// not re-multiplied (double-apply would shrink the comparison value).
|
||||||
|
// Hint is pendingLayout-only; resize clamp (minSize=0.05) is unchanged.
|
||||||
|
const zoneHeightPx = isPendingLayout ? height * SLIDE_H : null;
|
||||||
|
const minHeightPx = activeCandidate?.minHeightPx ?? null;
|
||||||
|
const belowMinHeight =
|
||||||
|
isPendingLayout &&
|
||||||
|
minHeightPx != null &&
|
||||||
|
zoneHeightPx != null &&
|
||||||
|
zoneHeightPx < minHeightPx;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={zone.id}
|
key={zone.id}
|
||||||
@@ -695,6 +717,18 @@ export default function SlideCanvas({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* IMP-11 u5: red border + 'min H Npx' badge when zone height
|
||||||
|
is below the active frame's catalog min_height_px. Visual
|
||||||
|
hint only, no clamp/resize behavior change. */}
|
||||||
|
{belowMinHeight && minHeightPx != null && (
|
||||||
|
<>
|
||||||
|
<div className="absolute inset-0 pointer-events-none border-2 border-red-500" />
|
||||||
|
<span className="absolute bottom-1 right-1 text-[9px] font-black uppercase tracking-tighter px-1.5 py-0.5 rounded bg-red-500 text-white shadow pointer-events-none">
|
||||||
|
min H {minHeightPx}px
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* zone 라벨 — 좌상단. 주 라벨 = section ids (S1, S1+S2),
|
{/* zone 라벨 — 좌상단. 주 라벨 = section ids (S1, S1+S2),
|
||||||
부 라벨 = backend zone position (top, bottom, primary). */}
|
부 라벨 = backend zone position (top, bottom, primary). */}
|
||||||
<div className="absolute top-1 left-1 flex items-center gap-1 pointer-events-none">
|
<div className="absolute top-1 left-1 flex items-center gap-1 pointer-events-none">
|
||||||
|
|||||||
@@ -527,6 +527,10 @@ export async function loadRun(runId: string): Promise<LoadRunResult> {
|
|||||||
// backend step09 의 catalog_registered (frame_contracts.yaml 등록 여부).
|
// backend step09 의 catalog_registered (frame_contracts.yaml 등록 여부).
|
||||||
// v4_all_judgments 에만 있음. v4_candidates fallback 시 undefined.
|
// v4_all_judgments 에만 있음. v4_candidates fallback 시 undefined.
|
||||||
catalogRegistered: c.catalog_registered,
|
catalogRegistered: c.catalog_registered,
|
||||||
|
// backend step09 의 min_height_px (frame_contracts.yaml visual_hints.min_height_px).
|
||||||
|
// logical 1280x720 px 좌표계. contract 미등록 또는 visual_hints 부재 시 undefined.
|
||||||
|
// v4_all_judgments 에만 있음. v4_candidates fallback 시 undefined (graceful).
|
||||||
|
minHeightPx: c.min_height_px ?? undefined,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const displayStrategy = (
|
const displayStrategy = (
|
||||||
|
|||||||
@@ -127,6 +127,10 @@ export interface FrameCandidate {
|
|||||||
/** backend frame_contracts.yaml 에 catalog 등록 여부. false 면 사용자가 override
|
/** backend frame_contracts.yaml 에 catalog 등록 여부. false 면 사용자가 override
|
||||||
* 시도해도 Step 7-A 가 skip (render path 미연결). UI 회색 + "render path 미적용" 표시. */
|
* 시도해도 Step 7-A 가 skip (render path 미연결). UI 회색 + "render path 미적용" 표시. */
|
||||||
catalogRegistered?: boolean;
|
catalogRegistered?: boolean;
|
||||||
|
/** IMP-11 D-2 — frame contract visual_hints.min_height_px (logical 1280x720 px).
|
||||||
|
* Source = templates/phase_z2/catalog/frame_contracts.yaml visual_hints.min_height_px.
|
||||||
|
* Undefined when contract unregistered or visual_hints absent (frontend tolerates undefined). */
|
||||||
|
minHeightPx?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -3969,6 +3969,24 @@ def run_phase_z2_mvp1(
|
|||||||
"delegated_to": delegated,
|
"delegated_to": delegated,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# IMP-11 D-2 (u1) — per-candidate min_height_px source = catalog
|
||||||
|
# frame_contracts[template_id].visual_hints.min_height_px (logical 1280×720 px).
|
||||||
|
# None when contract unregistered (frontend tolerates undefined).
|
||||||
|
# Single get_contract lookup binds both catalog_registered and min_height_px.
|
||||||
|
v4_all_judgments_list = []
|
||||||
|
for c in v4_all_for_unit:
|
||||||
|
_contract = get_contract(c.template_id)
|
||||||
|
v4_all_judgments_list.append({
|
||||||
|
"template_id": c.template_id,
|
||||||
|
"frame_id": c.frame_id,
|
||||||
|
"frame_number": c.frame_number,
|
||||||
|
"v4_rank": c.v4_rank,
|
||||||
|
"confidence": c.confidence,
|
||||||
|
"label": c.label,
|
||||||
|
"catalog_registered": _contract is not None,
|
||||||
|
"min_height_px": (_contract or {}).get("visual_hints", {}).get("min_height_px"),
|
||||||
|
})
|
||||||
|
|
||||||
application_plan_units.append({
|
application_plan_units.append({
|
||||||
"unit_id": unit_id,
|
"unit_id": unit_id,
|
||||||
"layout_preset": layout_preset,
|
"layout_preset": layout_preset,
|
||||||
@@ -4004,18 +4022,8 @@ def run_phase_z2_mvp1(
|
|||||||
# v4_all_judgments 는 reject 포함.
|
# v4_all_judgments 는 reject 포함.
|
||||||
# catalog_registered = frame_contracts.yaml 에 contract 있는지 여부.
|
# catalog_registered = frame_contracts.yaml 에 contract 있는지 여부.
|
||||||
# false 면 사용자가 override 시도해도 Step 7-A 가 skip (render path 미연결).
|
# false 면 사용자가 override 시도해도 Step 7-A 가 skip (render path 미연결).
|
||||||
"v4_all_judgments": [
|
# IMP-11 D-2 (u1) : per-candidate min_height_px added (None when unregistered).
|
||||||
{
|
"v4_all_judgments": v4_all_judgments_list,
|
||||||
"template_id": c.template_id,
|
|
||||||
"frame_id": c.frame_id,
|
|
||||||
"frame_number": c.frame_number,
|
|
||||||
"v4_rank": c.v4_rank,
|
|
||||||
"confidence": c.confidence,
|
|
||||||
"label": c.label,
|
|
||||||
"catalog_registered": get_contract(c.template_id) is not None,
|
|
||||||
}
|
|
||||||
for c in v4_all_for_unit
|
|
||||||
],
|
|
||||||
"application_candidates": app_candidates,
|
"application_candidates": app_candidates,
|
||||||
# IMP-06 blocker-fix (Codex #13 Blocker 3 / #16) — plan-aware
|
# IMP-06 blocker-fix (Codex #13 Blocker 3 / #16) — plan-aware
|
||||||
# additive fields. None / False / [] when no override CLI used.
|
# additive fields. None / False / [] when no override CLI used.
|
||||||
|
|||||||
169
tests/test_phase_z2_step9_v4_all_judgments_min_height.py
Normal file
169
tests/test_phase_z2_step9_v4_all_judgments_min_height.py
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
"""IMP-11 D-2 (u1) — Step 9 v4_all_judgments[] min_height_px field tests.
|
||||||
|
|
||||||
|
u1 contract:
|
||||||
|
Each v4_all_judgments[] entry MUST expose `min_height_px` sourced from
|
||||||
|
catalog `frame_contracts[template_id].visual_hints.min_height_px`
|
||||||
|
(logical 1280×720 px), with `None` fallback when contract is unregistered.
|
||||||
|
A single `get_contract(c.template_id)` lookup binds both
|
||||||
|
`catalog_registered` and `min_height_px` (no double-lookup cost).
|
||||||
|
|
||||||
|
Production code = inline list builder in `run_phase_z2_mvp1`
|
||||||
|
(`src/phase_z2_pipeline.py`, near v4_all_for_unit loop). These tests follow
|
||||||
|
the same source-string + catalog-shape guard pattern as the existing
|
||||||
|
`test_step9_production_emits_candidate_evidence_and_alias` in
|
||||||
|
`tests/test_phase_z2_v4_fallback.py`, kept until a helper is extracted.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
from src import phase_z2_pipeline
|
||||||
|
from phase_z2_mapper import get_contract, load_frame_contracts
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Case 1 : u1 production-source guard ────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_v4_all_judgments_emits_min_height_px_with_none_fallback():
|
||||||
|
"""Source guard — single get_contract bound to `_contract`, then both
|
||||||
|
`catalog_registered` and `min_height_px` derived from that binding.
|
||||||
|
`min_height_px` uses the `(_contract or {})` chain so unregistered
|
||||||
|
contracts propagate `None` (frontend tolerates undefined).
|
||||||
|
"""
|
||||||
|
source = inspect.getsource(phase_z2_pipeline)
|
||||||
|
|
||||||
|
# u1 marker present (locates the builder)
|
||||||
|
assert "IMP-11 D-2 (u1)" in source
|
||||||
|
|
||||||
|
# Single get_contract lookup bound to local var
|
||||||
|
assert "_contract = get_contract(c.template_id)" in source
|
||||||
|
|
||||||
|
# catalog_registered reuses the local binding (no second lookup)
|
||||||
|
assert '"catalog_registered": _contract is not None' in source
|
||||||
|
|
||||||
|
# min_height_px source = visual_hints chain; None when contract is None
|
||||||
|
assert (
|
||||||
|
'"min_height_px": (_contract or {})'
|
||||||
|
'.get("visual_hints", {})'
|
||||||
|
'.get("min_height_px")'
|
||||||
|
) in source
|
||||||
|
|
||||||
|
# v4_all_judgments wires the new builder list
|
||||||
|
assert '"v4_all_judgments": v4_all_judgments_list' in source
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Case 2 : additive guarantee — existing 7 fields preserved ──────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_v4_all_judgments_preserves_existing_fields():
|
||||||
|
"""u1 is additive only — the existing 7 keys must remain in the per-entry
|
||||||
|
dict alongside the new `min_height_px`.
|
||||||
|
"""
|
||||||
|
source = inspect.getsource(phase_z2_pipeline)
|
||||||
|
|
||||||
|
builder_start = source.find("IMP-11 D-2 (u1)")
|
||||||
|
assert builder_start != -1
|
||||||
|
builder_end = source.find("application_plan_units.append", builder_start)
|
||||||
|
assert builder_end != -1
|
||||||
|
builder = source[builder_start:builder_end]
|
||||||
|
|
||||||
|
for field in (
|
||||||
|
'"template_id": c.template_id',
|
||||||
|
'"frame_id": c.frame_id',
|
||||||
|
'"frame_number": c.frame_number',
|
||||||
|
'"v4_rank": c.v4_rank',
|
||||||
|
'"confidence": c.confidence',
|
||||||
|
'"label": c.label',
|
||||||
|
'"catalog_registered": _contract is not None',
|
||||||
|
'"min_height_px":',
|
||||||
|
):
|
||||||
|
assert field in builder, f"missing field in u1 builder: {field!r}"
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Case 3 : catalog reality — visual_hints.min_height_px shape is real ────
|
||||||
|
|
||||||
|
|
||||||
|
def test_catalog_visual_hints_min_height_px_path_is_real():
|
||||||
|
"""The source-string guard depends on the actual catalog shape having
|
||||||
|
`visual_hints.min_height_px` as a positive int on registered contracts
|
||||||
|
whose `visual_hints` block declares it. Verify against the real
|
||||||
|
`frame_contracts.yaml` so a future catalog schema change cannot silently
|
||||||
|
invalidate the `.get("visual_hints", {}).get("min_height_px")` chain.
|
||||||
|
"""
|
||||||
|
load_frame_contracts()
|
||||||
|
|
||||||
|
# Real registered template_ids that ship with visual_hints.min_height_px
|
||||||
|
# (verified via load_frame_contracts() — see frame_contracts.yaml).
|
||||||
|
sample_template_ids = (
|
||||||
|
"three_parallel_requirements",
|
||||||
|
"process_product_two_way",
|
||||||
|
"construction_goals_three_circle_intersection",
|
||||||
|
"bim_dx_comparison_table",
|
||||||
|
)
|
||||||
|
|
||||||
|
found = 0
|
||||||
|
for tid in sample_template_ids:
|
||||||
|
contract = get_contract(tid)
|
||||||
|
if contract is None:
|
||||||
|
continue # tolerate catalog rename — at least one must remain
|
||||||
|
# The exact .get chain used by the u1 builder
|
||||||
|
min_h = (contract or {}).get("visual_hints", {}).get("min_height_px")
|
||||||
|
assert isinstance(min_h, int), (
|
||||||
|
f"{tid}: visual_hints.min_height_px must be int, "
|
||||||
|
f"got {type(min_h).__name__}={min_h!r}"
|
||||||
|
)
|
||||||
|
assert min_h > 0, f"{tid}: min_height_px must be positive, got {min_h}"
|
||||||
|
found += 1
|
||||||
|
|
||||||
|
assert found > 0, (
|
||||||
|
"no sample registered contract present — catalog audit drift; "
|
||||||
|
"update sample_template_ids to match current frame_contracts.yaml"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_registered_contract_without_min_height_px_propagates_none():
|
||||||
|
"""Registered contract whose `visual_hints` block omits `min_height_px`
|
||||||
|
(or sets it to `null`) must also propagate `None` through the u1 chain.
|
||||||
|
Real example in current catalog: `bim_issues_quadrant_four`.
|
||||||
|
"""
|
||||||
|
load_frame_contracts()
|
||||||
|
|
||||||
|
tid = "bim_issues_quadrant_four"
|
||||||
|
contract = get_contract(tid)
|
||||||
|
if contract is None:
|
||||||
|
import pytest # noqa: PLC0415 — runtime skip only when catalog drifts
|
||||||
|
pytest.skip(f"sample template {tid!r} no longer registered")
|
||||||
|
|
||||||
|
# Exact chain used by u1 builder
|
||||||
|
min_h = (contract or {}).get("visual_hints", {}).get("min_height_px")
|
||||||
|
assert min_h is None, (
|
||||||
|
f"{tid}: expected None when visual_hints.min_height_px is absent/null, "
|
||||||
|
f"got {min_h!r} — chain semantics changed"
|
||||||
|
)
|
||||||
|
# catalog_registered must still be True (additive, independent of value)
|
||||||
|
assert (contract is not None) is True
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Case 4 : None propagation for unregistered template_id ─────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_unregistered_template_id_propagates_none():
|
||||||
|
"""When `get_contract(template_id)` returns `None`, the u1 chain
|
||||||
|
`(_contract or {}).get("visual_hints", {}).get("min_height_px")` must
|
||||||
|
yield `None` (frontend tolerates undefined; no KeyError).
|
||||||
|
"""
|
||||||
|
load_frame_contracts()
|
||||||
|
|
||||||
|
# Synthetic template_id guaranteed not to be in the catalog
|
||||||
|
unregistered = "MOCK_template_unregistered_for_u1_test"
|
||||||
|
assert get_contract(unregistered) is None, (
|
||||||
|
"test precondition broken — synthetic template_id leaked into catalog"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Replicate the u1 chain exactly
|
||||||
|
_contract = get_contract(unregistered)
|
||||||
|
min_height = (_contract or {}).get("visual_hints", {}).get("min_height_px")
|
||||||
|
catalog_registered = _contract is not None
|
||||||
|
|
||||||
|
assert min_height is None
|
||||||
|
assert catalog_registered is False
|
||||||
Reference in New Issue
Block a user