21 KiB
[Claude #2] Stage 2 Round #1 — Plan
Stage 2 entry
- commit_sha =
7a52cebfaa8b356c42a5de45c7241a3002615cab - Issue scope = IMP-15 실행-1 image axis only. Table is issue #46 (실행-2). Classifier consumer +
visual_check_passedflip = 실행-3. debug.json surfacing + spec doc row = 실행-4. - Stage 1 exit_report scope_lock binding.
Stage 1 unresolved_questions resolved here
- IMAGE_ASPECT_DELTA_TOL anchor — Codex was right, no spec anchor for 0.05. Verified
docs/architecture/PHASE-Z-FIT-CLASSIFIER-ROUTER-SPEC.md§3.1 table (lines 86–95) does NOT containimage_aspect_mismatchrow;image_aspect_mismatchappears as a category name indocs/architecture/PHASE-Z-PIPELINE-OVERVIEW.md:312,316but the0.05numeric value is not anchored in any spec doc (grep negative acrossdocs/). Decision: lock constant at module scope with# TODO(IMP-15 실행-4): mirror in PHASE-Z-FIT-CLASSIFIER-ROUTER-SPEC.md §3.1 / §3.4comment. Spec doc row addition is out-of-scope per issue body (실행-4). - image_events[] schema — minimum fields locked. Per-event dict =
{src, zone_position, zone_template_id, frame_slot_id, natural_w, natural_h, rendered_w, rendered_h, aspect_natural, aspect_rendered, aspect_delta, aspect_mismatch}. Bbox px (x/y/w/h) deferred to 실행-4 (debug.json surfacing — current issue does not need bbox). - fail_reasons string format locked as:
f"image aspect mismatch in zone--{zone_position}: natural {nW}x{nH} rendered {rW}x{rH} delta {delta:.3f}". Matches issue body prefix verbatim; tail is measurable / grep-friendly. - Test fixture — synthesize minimal HTML in pytest tmp_path (not reuse Phase Z rendered artifacts). PNG generated on-the-fly via Pillow (200×100 single colour). HTML attaches
width=/height=to force rendered geometry. Keeps test independent of frame DB / composition state; Fixture C can omit.zonewrapper without breaking the slide_base contract.
Stage 1 commit_sha re-verified = 7a52cebfaa8b356c42a5de45c7241a3002615cab (via git rev-parse HEAD).
Issue-body axes — exhaustive coverage checklist
| # | axis (issue body verbatim) | covered by | how |
|---|---|---|---|
| A1 | "JS payload (image_events) + Python aggregation" — <img> traversal |
u1 | slide.querySelectorAll('img') → measure naturalWidth/Height + clientWidth/Height; result included in returned dict |
| A2 | "emit image_aspect_mismatch fail_reason when |
delta | > 0.05" |
| A3 | "result["image_events"] populated for every <img> in slide" |
u1 | return literal extended from 5 keys to 6 keys (image_events appended last; legacy 5 byte-identical) |
| A4 | "IMAGE_ASPECT_DELTA_TOL = 0.05 constant in pipeline" |
u1 | module scope in src/phase_z2_pipeline.py near existing SLIDE_W/SLIDE_H constants (line 118–129 region); importable as from src.phase_z2_pipeline import IMAGE_ASPECT_DELTA_TOL |
| A5 | "fail_reasons includes image aspect mismatch in zone--<pos>: ..." |
u1 | Python-side aggregator iterates result["image_events"] and appends fail_reason for every event where aspect_mismatch=True |
| A6 | "Selenium test suite added with skip-guard" | u2 | new file tests/phase_z2/test_phase_z2_step14_image_check.py |
| A7 | "Fixture A — no distortion (200×100 / 200×100): aspect_delta < 0.05, passed=True" | u2 | _make_fixture_A() writes 200×100 PNG via Pillow + HTML with <img width=200 height=100> inside a .zone; asserts image_events[0]["aspect_delta"] < 0.05, passed == True, no image aspect mismatch in fail_reasons |
| A8 | "Fixture B — forced distortion (200×100 intrinsic, 200×200 rendered): aspect_delta > 0.30, fail_reason present, passed=False" | u2 | _make_fixture_B() same PNG, <img width=200 height=200> inside .zone[data-zone-position="top"]; asserts aspect_delta > 0.30, passed == False, fail_reasons contains literal substring "image aspect mismatch in zone--top" |
| A9 | "Fixture C — image with no .zone ancestor: zone_position == 'unknown' (attachment fallback)" | u2 | _make_fixture_C() <img> placed directly under .slide (no .zone wrapper), forced distortion to trigger fail_reason; asserts image_events[0]["zone_position"] == "unknown", fail_reasons contains "image aspect mismatch in zone--unknown" |
| A10a | "Chromedriver skip-guard: skip when not resolvable" | u2 | helper _resolve_chromedriver() mirrors pipeline.py:2082-2099 resolver order (PROJECT_ROOT/chromedriver, PROJECT_ROOT/chromedriver.exe, then shutil.which('chromedriver')); fixture returns None → pytest.skip("chromedriver not resolvable") when env var unset |
| A10b | "xfail strict when PHASE_Z_REQUIRE_SELENIUM=1" | u2 | if os.environ.get("PHASE_Z_REQUIRE_SELENIUM") == "1" and resolver returns None: pytest.xfail(strict=True, reason="..."); expressed via pytest.mark.xfail(strict=True, condition=...) decorator wired through a fixture |
| B1 | Step note update — "image / table 검사 부재 — Step 14 ⚠ partial." → image half resolved | u1 | pipeline.py:4358 note string rewritten to: "Selenium 실측 — clientHeight / scrollHeight / excess_y / frame_slot_metrics / image_events. image 검사 추가됨 (IMP-15 실행-1). table 검사 부재 — Step 14 ⚠ partial." |
| C1 | Guardrail — no existing fail_reasons strings reordered | u1 | new image branch appended after existing zone clipped_inner branch in the same for z in result["zones"]: ... block sibling loop; existing string order preserved byte-identical |
| C2 | Guardrail — no AI calls in Step 14 path | u1 + u2 | aggregator is deterministic; tests check threshold logic only |
| C3 | Guardrail — no edits to templates/phase_z2/slide_base.html |
n/a | fixtures synthesize minimal HTML directly; production template untouched |
| C4 | Guardrail — IMAGE_ASPECT_DELTA_TOL >= 0.05 (Stage 1 guardrail wording) |
u1 | constant value locked at 0.05; comment cross-refs Stage 1 exit_report |
Per-unit plan
Atomicity: estimate_lines ≤ 50 AND files ≤ 3 per unit; counted as total diff in unit's commit (production + tests).
u1 — pipeline.py: module constant + JS payload image_events[] + Python aggregator branch + Step 14 note
Files (1):
src/phase_z2_pipeline.py
Before:
run_overflow_checkJS atsrc/phase_z2_pipeline.py:2105-2212returns{slide, slide_body, zones, frame_slot_metrics, zone_geometries_px}(line 2211). No<img>traversal.fail_reasonsaggregator atpipeline.py:2226-2253covers slide size / slide overflow / slide-body overflow / zone overflow / clipped_inner only. No image branch.- No
IMAGE_ASPECT_DELTA_TOLsymbol (grep negative). - Step 14 note at
pipeline.py:4358states"image / table 검사 부재 — Step 14 ⚠ partial.".
After (per-edit list — all in one file, single commit):
-
Module constant — insert after existing constants block (after line 129
DEFAULT_ZONE_MIN_HEIGHT_PX = 100):# Step 14 image aspect mismatch tolerance (IMP-15 실행-1). # Locally anchored — spec doc row pending in IMP-15 실행-4 # (PHASE-Z-FIT-CLASSIFIER-ROUTER-SPEC.md §3.1 / §3.4). # Category name appears in docs/architecture/PHASE-Z-PIPELINE-OVERVIEW.md:312,316. IMAGE_ASPECT_DELTA_TOL = 0.05 -
JS payload extension — insert before
return { slide: slideM, ... }at line 2211 (insidedriver.execute_script(...)):// IMP-15 실행-1 — <img> intrinsic vs rendered aspect mismatch detection. const image_events = []; slide.querySelectorAll('img').forEach((img) => { const parentZone = img.closest('.zone'); const zonePos = parentZone ? (parentZone.getAttribute('data-zone-position') || 'unknown') : 'unknown'; const zoneTid = parentZone ? (parentZone.getAttribute('data-template-id') || '?') : '?'; const slotEl = img.closest('[data-frame-slot-id]'); const slotId = slotEl ? slotEl.getAttribute('data-frame-slot-id') : null; image_events.push({ src: (img.getAttribute('src') || '').slice(0, 200), zone_position: zonePos, zone_template_id: zoneTid, frame_slot_id: slotId, natural_w: img.naturalWidth, natural_h: img.naturalHeight, rendered_w: img.clientWidth, rendered_h: img.clientHeight, }); }); -
Return literal — change line 2211 from
return { slide: slideM, slide_body: bodyM, zones, frame_slot_metrics, zone_geometries_px };to
return { slide: slideM, slide_body: bodyM, zones, frame_slot_metrics, zone_geometries_px, image_events }; -
Python aggregator branch — insert after existing zone loop at line 2249 (after the
clipped_innerinner loop, beforeresult["passed"] = ...):# IMP-15 실행-1 — image aspect mismatch detection (post-JS aggregation). for ev in result.get("image_events", []) or []: nW, nH = ev.get("natural_w") or 0, ev.get("natural_h") or 0 rW, rH = ev.get("rendered_w") or 0, ev.get("rendered_h") or 0 if nW <= 0 or nH <= 0 or rW <= 0 or rH <= 0: ev["aspect_natural"] = None ev["aspect_rendered"] = None ev["aspect_delta"] = None ev["aspect_mismatch"] = False continue an = nW / nH ar = rW / rH delta = abs(an - ar) / an ev["aspect_natural"] = an ev["aspect_rendered"] = ar ev["aspect_delta"] = delta ev["aspect_mismatch"] = delta > IMAGE_ASPECT_DELTA_TOL if ev["aspect_mismatch"]: fail_reasons.append( f"image aspect mismatch in zone--{ev['zone_position']}: " f"natural {nW}x{nH} rendered {rW}x{rH} delta {delta:.3f}" ) -
Step 14 note rewrite —
pipeline.py:4355-4359note string:note=( "Selenium 실측 — clientHeight / scrollHeight / excess_y / frame_slot_metrics / " "image_events. image 검사 추가됨 (IMP-15 실행-1, IMAGE_ASPECT_DELTA_TOL=0.05). " "table 검사 부재 — Step 14 ⚠ partial." ),step_status="partial"retained (table still partial).
Tests in u1: none. Tests live in u2 (Selenium-driven end-to-end fixtures). Smoke-import test is unnecessary because u2's fixture A exercises the constant import path implicitly (from src.phase_z2_pipeline import IMAGE_ASPECT_DELTA_TOL, run_overflow_check).
estimate_lines: ~35 (constant 5 + JS block 18 + return literal 1 + aggregator 18 + note 3 ≈ 45; trimmed via inline expression density). Single file. Under threshold.
Rollback: revert all 5 edits in src/phase_z2_pipeline.py. No data migration. Trace JSON loses image_events and image fail_reasons; legacy 5 keys + existing fail_reasons preserved.
u2 — Selenium test file with 3 fixtures + chromedriver skip-guard
Files (1):
tests/phase_z2/test_phase_z2_step14_image_check.py(NEW)
Skip-guard helper (mirrors pipeline.py:2082-2099 resolver order):
def _resolve_chromedriver() -> Path | None:
from src.phase_z2_pipeline import PROJECT_ROOT
for p in (PROJECT_ROOT / "chromedriver", PROJECT_ROOT / "chromedriver.exe"):
if p.is_file():
return p
which = shutil.which("chromedriver")
return Path(which) if which else None
@pytest.fixture
def chromedriver_or_skip():
p = _resolve_chromedriver()
if p is None:
if os.environ.get("PHASE_Z_REQUIRE_SELENIUM") == "1":
pytest.xfail("chromedriver unresolvable but PHASE_Z_REQUIRE_SELENIUM=1 (strict)")
pytest.skip("chromedriver not resolvable on PROJECT_ROOT or PATH")
return p
Note: pytest.xfail(...) raises Failed with strict semantics handled at session level. To match Stage 1 contract ("xfail strict — not skip — when PHASE_Z_REQUIRE_SELENIUM=1"), the helper raises via the pytest.xfail API directly inside the fixture. This causes the test to be reported as XFAIL (strict-friendly). If a future maintainer needs xpassed-detection semantics, the test signature can be decorated with @pytest.mark.xfail(strict=True, condition=...) — equivalent surface for CI.
Fixture builders:
def _write_png(path: Path, w: int, h: int) -> None:
from PIL import Image
Image.new("RGB", (w, h), (200, 100, 50)).save(path, "PNG")
def _write_html(html_path: Path, png_uri: str, *, in_zone: bool, render_w: int, render_h: int, zone_position: str = "top"):
inner = (
f'<div class="zone" data-zone-position="{zone_position}" data-template-id="t1">'
f'<img src="{png_uri}" width="{render_w}" height="{render_h}">'
f'</div>'
if in_zone
else f'<img src="{png_uri}" width="{render_w}" height="{render_h}">'
)
html = (
'<!doctype html><html><head><meta charset="utf-8">'
'<style>html,body{margin:0;padding:0;}'
'.slide{width:1280px;height:720px;position:relative;}'
'.slide-body{width:1180px;height:585px;position:absolute;left:50px;top:76px;}'
'.zone{position:relative;}</style></head>'
f'<body><div class="slide"><div class="slide-body">{inner}</div></div></body></html>'
)
html_path.write_text(html, encoding="utf-8")
Test cases (3, aligned with issue body Fixtures A/B/C):
-
test_fixture_A_no_distortion_passes(tmp_path, chromedriver_or_skip)—- PNG 200×100, rendered 200×100, inside
.zone[data-zone-position="top"]. - Expect:
result["image_events"][0]["aspect_delta"] < IMAGE_ASPECT_DELTA_TOL,result["passed"] is True, no fail_reason containing"image aspect mismatch".
- PNG 200×100, rendered 200×100, inside
-
test_fixture_B_forced_distortion_fails(tmp_path, chromedriver_or_skip)—- PNG 200×100, rendered 200×200, inside
.zone[data-zone-position="top"]. - Expect:
aspect_delta > 0.30,aspect_mismatch is True,result["passed"] is False, fail_reasons contains substring"image aspect mismatch in zone--top".
- PNG 200×100, rendered 200×200, inside
-
test_fixture_C_no_zone_ancestor_unknown(tmp_path, chromedriver_or_skip)—- PNG 200×100, rendered 200×200,
<img>directly under.slide-body(no.zonewrapper). - Expect:
image_events[0]["zone_position"] == "unknown",image_events[0]["zone_template_id"] == "?", fail_reasons contains"image aspect mismatch in zone--unknown".
- PNG 200×100, rendered 200×200,
estimate_lines: ~50 (imports 5 + skip-guard 12 + helpers 20 + 3 tests at ~5 lines each = ~52; trimmed via shared _run_and_get helper).
Tests under PHASE_Z_REQUIRE_SELENIUM=0: skip cleanly when chromedriver absent. Tests under PHASE_Z_REQUIRE_SELENIUM=1: xfail strict when chromedriver absent; pass when present.
Rollback: delete file. No production impact.
Rollback plan (combined)
- u2 rollback: delete test file.
- u1 rollback: revert 5 edits in
src/phase_z2_pipeline.py(constant + JS block + return literal + aggregator + note). All inverse-applicable from the unit's commit diff. - Combined order: u2 → u1. u1's production reverts must follow u2 deletion to avoid leaving orphaned tests that import a removed constant.
Side effects / follow-up issues
- (deferred — IMP-15 실행-2, issue #46)
table_eventsstandalone-table detection. Same JS extension pattern; independent commit. - (deferred — IMP-15 실행-3) Classifier consumer extension (
classify_visual_runtime_check) emits categoryimage_aspect_mismatchfromimage_events[i].aspect_mismatch == True.visual_check_passedflip — already correctly driven byoverflow["passed"]which u1 sets to False on mismatch (no flip needed in 실행-3 if 실행-1 lands first; verify in 실행-3 evidence). - (deferred — IMP-15 실행-4) debug.json surfacing of
image_events(with optional bbox px) + spec doc row update inPHASE-Z-FIT-CLASSIFIER-ROUTER-SPEC.md§3.1 / §3.4 (anchor sync forIMAGE_ASPECT_DELTA_TOL = 0.05). - (verify in Stage 3) Step 21 (PDF export) reads
slide_status.fail_reasonsand surfaces them in the PDF; no Step 21 code edit needed (verify by reading Step 21 site during Stage 3 u1 evidence capture). - (IMP-01 / IMP-13 soft-link)
image_events[]is additive top-level alongsideframe_slot_metricsandzone_geometries_px(IMP-01 trace layer). Same shape convention; no IMP-01/IMP-13 fields renamed/removed.
IMPLEMENTATION_UNITS
=== IMPLEMENTATION_UNITS ===
- id: u1
summary: src/phase_z2_pipeline.py — add IMAGE_ASPECT_DELTA_TOL=0.05 module constant; extend run_overflow_check JS payload with
traversal emitting image_events[] (naturalWidth/Height + clientWidth/Height + src + zone_position + zone_template_id + frame_slot_id); add Python aggregator branch computing aspect_natural/rendered/delta/mismatch and appending fail_reasons string "image aspect mismatch in zone--: natural NxN rendered NxN delta D.DDD" when delta > tol; rewrite Step 14 step-note to mark image half resolved (table half remains partial). files: [src/phase_z2_pipeline.py] tests: [] estimate_lines: 45
- id: u2 summary: tests/phase_z2/test_phase_z2_step14_image_check.py (NEW) — Selenium test file with chromedriver skip-guard (mirroring pipeline.py:2082-2099 resolver order; xfail strict under PHASE_Z_REQUIRE_SELENIUM=1) and 3 fixtures matching issue body Fixtures A/B/C: A=no distortion (200x100/200x100, passed=True), B=forced distortion (200x100 intrinsic, 200x200 rendered, fail_reason in zone--top, passed=False), C=no .zone ancestor (zone_position=='unknown', fail_reason in zone--unknown). PNG generated via Pillow into pytest tmp_path; HTML synthesized inline (no production template touched). files: [tests/phase_z2/test_phase_z2_step14_image_check.py] tests: [tests/phase_z2/test_phase_z2_step14_image_check.py] estimate_lines: 50
=== EVIDENCE ===
- commit_sha at Stage 2 entry:
7a52cebfaa8b356c42a5de45c7241a3002615cab(viagit rev-parse HEAD). - Step 14 JS surface return literal verified at
src/phase_z2_pipeline.py:2211:return { slide: slideM, slide_body: bodyM, zones, frame_slot_metrics, zone_geometries_px };— confirms u1 must extend return literal additively (6th key). - fail_reasons aggregator verified at
src/phase_z2_pipeline.py:2226-2252: 5 existing branches (slide size / slide overflow / slide-body overflow / zone overflow / clipped_inner). u1 inserts image branch as 6th, after zone loop, preserving order. - Module constants block verified at
src/phase_z2_pipeline.py:118-129(SLIDE_W=1280 … DEFAULT_ZONE_MIN_HEIGHT_PX=100). u1 inserts IMAGE_ASPECT_DELTA_TOL after line 129 — same module-scope band, importable. - Chromedriver resolver verified at
src/phase_z2_pipeline.py:2082-2099: PROJECT_ROOT/chromedriver → PROJECT_ROOT/chromedriver.exe → fallbackwebdriver.Chrome(options=options)(PATH). u2 skip-guard mirrors first two entries +shutil.whichfor the PATH fallback. - Step 14 note verified at
src/phase_z2_pipeline.py:4358:"image / table 검사 부재 — Step 14 ⚠ partial."— u1 rewrites image half to resolved while preserving table half. - Spec anchor claim audit:
docs/architecture/PHASE-Z-FIT-CLASSIFIER-ROUTER-SPEC.md§3.1 table (lines 86-95) — grep negative forimage_aspect_mismatchand0.05. Issue-body anchor claim (§3.1) is incorrect.docs/architecture/PHASE-Z-PIPELINE-OVERVIEW.md:312,316—image_aspect_mismatchlisted as category name; no0.05value.- Conclusion: lock as locally-anchored with TODO comment; spec doc row addition deferred to 실행-4 (per issue body out-of-scope).
- IMAGE_ASPECT_DELTA_TOL symbol grep verified absent in repo (Stage 1 evidence reproduced).
- Test file path verified absent:
tests/phase_z2/test_phase_z2_step14_image_check.pydoes not exist (Stage 1 evidence reproduced). - Selenium fixture strategy verified self-contained:
templates/phase_z2/slide_base.html:267usesdata-zone-positionattribute on.zoneonly — u2 fixtures synthesize a.zonewrapper directly, no slide_base template dependency. No CSS Grid required because fixtures use explicitwidth=/height=on<img>. - Pillow availability verified: project uses Pillow already (CLAUDE.md "이미지 크기 읽기: Pillow
Image.open().size"). No new dependency. - chromedriver.exe presence verified at
D:\ad-hoc\kei\design_agent\chromedriver/win64/147.0.7727.117/chromedriver.exe. Stage 3 may need to surface this path (PROJECT_ROOT/chromedriver{,.exe} resolver currently does NOT pick this up — Stage 1 chromedriver_candidates is shallow). This affects PHASE_Z_REQUIRE_SELENIUM=1 CI behaviour but does not change u1/u2 scope; flagged here for awareness. - Stage 1 scope_lock items 1-7 mapped:
- #1 (JS
measurement) → u1
- #2 (image_events[] emit) → u1
- #3 (fail_reasons image branch with locked string) → u1
- #4 (IMAGE_ASPECT_DELTA_TOL=0.05 module constant) → u1
- #5 (Selenium test with skip-guard + xfail strict) → u2
- #6 (zone_position='unknown' fallback) → u1 (JS
closest('.zone') ? attr : 'unknown') - #7 (Step note image half closed; table half partial) → u1
- #1 (JS
- Stage 1 guardrails honoured:
- No reordering of non-image fail_reasons strings (u1 appends after zone loop).
- No AI calls in Step 14 path (deterministic threshold).
- IMAGE_ASPECT_DELTA_TOL locked at 0.05 (not lowered).
- Skip vs xfail behaviour matches Stage 1 contract.
- zone_position fallback string is literal "unknown".
- No edits to templates/phase_z2/slide_base.html.
- Stage 1 out_of_scope honoured: table_events untouched (실행-2), classifier consumer untouched (실행-3), debug.json untouched (실행-4), spec doc row untouched (실행-4), all Step ≠ 14 untouched.
FINAL_CONSENSUS: YES