Files
C.E.L_Slide_test2/.orchestrator/tmp/issue45_claude2_plan.md
T

21 KiB
Raw Blame History

[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_passed flip = 실행-3. debug.json surfacing + spec doc row = 실행-4.
  • Stage 1 exit_report scope_lock binding.

Stage 1 unresolved_questions resolved here

  1. 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 8695) does NOT contain image_aspect_mismatch row; image_aspect_mismatch appears as a category name in docs/architecture/PHASE-Z-PIPELINE-OVERVIEW.md:312,316 but the 0.05 numeric value is not anchored in any spec doc (grep negative across docs/). Decision: lock constant at module scope with # TODO(IMP-15 실행-4): mirror in PHASE-Z-FIT-CLASSIFIER-ROUTER-SPEC.md §3.1 / §3.4 comment. Spec doc row addition is out-of-scope per issue body (실행-4).
  2. 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).
  3. 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.
  4. 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 .zone wrapper 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 118129 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 Nonepytest.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_check JS at src/phase_z2_pipeline.py:2105-2212 returns {slide, slide_body, zones, frame_slot_metrics, zone_geometries_px} (line 2211). No <img> traversal.
  • fail_reasons aggregator at pipeline.py:2226-2253 covers slide size / slide overflow / slide-body overflow / zone overflow / clipped_inner only. No image branch.
  • No IMAGE_ASPECT_DELTA_TOL symbol (grep negative).
  • Step 14 note at pipeline.py:4358 states "image / table 검사 부재 — Step 14 ⚠ partial.".

After (per-edit list — all in one file, single commit):

  1. 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
    
  2. JS payload extension — insert before return { slide: slideM, ... } at line 2211 (inside driver.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,
        });
    });
    
  3. 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 };
    
  4. Python aggregator branch — insert after existing zone loop at line 2249 (after the clipped_inner inner loop, before result["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}"
            )
    
  5. Step 14 note rewritepipeline.py:4355-4359 note 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):

  1. 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".
  2. 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".
  3. test_fixture_C_no_zone_ancestor_unknown(tmp_path, chromedriver_or_skip)

    • PNG 200×100, rendered 200×200, <img> directly under .slide-body (no .zone wrapper).
    • Expect: image_events[0]["zone_position"] == "unknown", image_events[0]["zone_template_id"] == "?", fail_reasons contains "image aspect mismatch in zone--unknown".

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_events standalone-table detection. Same JS extension pattern; independent commit.
  • (deferred — IMP-15 실행-3) Classifier consumer extension (classify_visual_runtime_check) emits category image_aspect_mismatch from image_events[i].aspect_mismatch == True. visual_check_passed flip — already correctly driven by overflow["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 in PHASE-Z-FIT-CLASSIFIER-ROUTER-SPEC.md §3.1 / §3.4 (anchor sync for IMAGE_ASPECT_DELTA_TOL = 0.05).
  • (verify in Stage 3) Step 21 (PDF export) reads slide_status.fail_reasons and 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 alongside frame_slot_metrics and zone_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 (via git 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 → fallback webdriver.Chrome(options=options) (PATH). u2 skip-guard mirrors first two entries + shutil.which for 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 for image_aspect_mismatch and 0.05. Issue-body anchor claim (§3.1) is incorrect.
    • docs/architecture/PHASE-Z-PIPELINE-OVERVIEW.md:312,316image_aspect_mismatch listed as category name; no 0.05 value.
    • 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.py does not exist (Stage 1 evidence reproduced).
  • Selenium fixture strategy verified self-contained: templates/phase_z2/slide_base.html:267 uses data-zone-position attribute on .zone only — u2 fixtures synthesize a .zone wrapper directly, no slide_base template dependency. No CSS Grid required because fixtures use explicit width=/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
  • 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