Compare commits

Author SHA1 Message Date
Kyeongmin d777315a81 On main: stash-all-pre-IMP38-commit-20260521 2026-05-21 22:07:41 +09:00
Kyeongmin 921c8f6884 index on main: dceb101 feat(#63): IMP-34 R1 donor capacity measured bound (u1+u2) 2026-05-21 22:07:41 +09:00
Kyeongmin 8f085a28d3 untracked files on main: dceb101 feat(#63): IMP-34 R1 donor capacity measured bound (u1+u2) 2026-05-21 22:07:41 +09:00
KyeongminandClaude Opus 4.7 dceb10129f feat(#63): IMP-34 R1 donor capacity measured bound (u1+u2)
Bound donor capacity in plan_zone_ratio_retry by min(static_slack,
max(0, clientHeight-scrollHeight)) when both Step 14 measured fields
are present; fall back to static contract slack when absent. Prevents
the donor from being over-allocated when full-but-not-overflowing,
avoiding a wasted Selenium rerender before cascade falls to
cross_zone_redistribute.

- src/phase_z2_retry.py: planner block L122-157 only; donor filter
  (L107-112), slack<=0 gate, base_plan, greedy aggregation untouched.
  Adds measured_empty_px + slack_bound_source telemetry to
  donor_candidates_considered (additive only).
- tests/phase_z2/test_phase_z2_retry_measured_bound.py: 5-axis
  regression (static_fallback / measured<static / measured>=static /
  measured==0 excludes / filter+bool guard).

Guardrails honored: V4 rank-1 frame lock preserved, no frame_swap,
no spacing/padding/gap/line-height/font shrink, no content drop,
no MDX 03/04/05 branching, no Step 14 schema mutation. Static
fallback idempotent when measured fields absent.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 21:37:41 +09:00
KyeongminandClaude Opus 4.7 a06dd3d4b0 feat(#42): IMP-04b catalog extension to 32 frames (u1~u24)
Extends frame_contracts.yaml from 11 to 32 contracts to match V4 evidence
(tests/matching/v4_full32_result.yaml unique template_ids), closing the
IMP-04b gap surfaced in IMP-04 (#4) Track A milestone.

Scope (Stage 2 24-unit plan):
- u3/u4: WIP partial absorb — app_sw_package_vs_solution (F23),
  pre_construction_model_info_stacked (F9). Both promoted from
  _WIP_FILES.md to frame_contracts.yaml. WIP allowlist now empty.
- u5~u11: Track A 7 frames (index.html present, contract missing).
- u12~u23: Track B 12 frames (visual_pending: true; family partial
  authoring deferred — contract-first per Stage 2 plan).
- u24: BT closure gate. Adds
  test_imp04b_closure_gate_v4_coverage_and_wip_empty (catalog ↔ V4
  set-equal + WIP==0) and test_vp_exempt_keys_are_contracted_and_disk_absent
  (vp ∩ disk == ∅). Relaxes test_contracts_set_equals_disk_families_minus_wip
  to (disk - wip) ∪ vp. 32 derived from V4 evidence YAML (no hardcoding).

Closure facts (locked):
  contracts = 32, v4_unique = 32, missing = [], extra = [],
  wip_count = 0, vp_count = 19, vp ∩ disk = [].

Guardrails honored:
- No calculate_fit migration.
- No AI/Kei API call in per-frame work.
- No 1-2 sample hardcoding (Codex #7 generalization guardrail).
- No production refactor for tests (IMP-32 owns helper extract).
- figma_to_html / V4 / Phase Z 3-layer separation preserved.
- 1 commit = 1 IMP-04b decision unit (bundled u1~u24 per Stage 2
  plan; CAT+WIP atomicity for u3/u4 preserved).

Tests: tests/test_family_contract_baseline.py 4/4 PASS.
Cross-ref: IMP-04 (#4), IMP-29 (#38), IMP-30 (#39), IMP-31 (#40),
IMP-32 (#41), IMP-33 (#61), IMP-47A (#75).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 19:39:16 +09:00
KyeongminandClaude Opus 4.7 15ef7c65e9 fix(#75): IMP-47A mdx03 frontend execution stabilization (u1~u4)
u1: SlideCanvas iframe sandbox += allow-scripts (allow-same-origin preserved)
    → embedded-mode script in slide_base.html now applies html.embedded
    → standalone CSS reset deactivates inside iframe; no clipping
u2: designAgentApi.loadRun merges candidate_evidence + v4_all_judgments
    + v4_candidates via Map<template_id|id|frame_id> dedup,
    LABEL_PRIORITY (use_as_is<light_edit<restructure<reject) then
    confidence desc, capped TOP_N_FRAMES=6
u3: Home.handleGenerate useCallback deps = [uploadedFile, slidePlan,
    userSelection, pendingZones, pendingLayout] (5-tuple, stale-closure fix)
u4: tests/manual/imp47a_e2e.md — mdx03 manual e2e spec (5 axes)

Frontend-only. Backend src/ untouched. No template/catalog edits.
Determinism preserved (no LLM in frontend merge logic).
Baseline: pytest -q tests → 623 passed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 14:56:56 +09:00
KyeongminandClaude Opus 4.7 c864fe0479 feat(#61): IMP-33 AI fallback scaffolding (u1~u11, flag default OFF)
Frame-aware AI fallback module scaffolded under src/phase_z2_ai_fallback/
with master flag ai_fallback_enabled=False; normal-path AI call count
remains 0. AI output constrained to builder_options_patch /
partial_overrides / slot_mapping_proposal; MDX / frame_id / raw HTML /
raw CSS mutations rejected at schema layer. IMP-46 cache gate (cache.py)
raises AiFallbackCacheGateError unless visual_check_passed AND
user_approved. Step 12 wires AI repair after IMP-30 provisional payload
only; Step 17 stays blocked behind IMP-34 / IMP-35 prerequisites.
AST isolation guard forbids fallback package from importing Phase Q /
Kei / pipeline runtime symbols. Docs IMP-17 / IMP-31 bound to runtime
module surface via 11-row structural test pin (test_docs_sync.py) so
drift fails CI.

Tests: 116 fallback / 161 phase_z2 regression / 526 scoped full sweep
all passing. Existing pre-IMP-33 fixture issue in scripts/test_phase_t_*
remains untouched (out of scope).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 12:46:49 +09:00
Kyeongmin c412f1ea75 refactor(#41): IMP-32 Step 9 application_plan helper extraction (u1~u5)
Pure refactor — extract inline Step 9 per-unit application_plan dict
assembly into module-level private helpers for testability. Replaces
IMP-05 Case 7 inspect.getsource() literal guard with direct helper-call
shape test. Behavior preserved: key set/order, candidate_evidence +
fallback_chain compat alias identity, IMP-06 additive plan fields,
IMP-11 D-2 markers (single _contract = get_contract(c.template_id)
bind + catalog_registered + min_height_px chain).

- u1 _application_candidates_for_unit(unit) at src/phase_z2_pipeline.py
  :2829-2853 — APPLICATION_MODE_BY_V4_LABEL mapping (pure extraction)
- u2 _v4_all_judgments_for_unit(v4_all_for_unit) at :2855-2882 —
  IMP-11 D-2 chain preserved literally
- u3 _build_application_plan_unit(unit, zone_plan, selection_trace,
  plan_record, v4_all_for_unit, layout_preset, layout_candidates_list)
  at :2885-2995 — byte-identical per-unit dict (key set + order +
  value identity), candidate_evidence / fallback_chain compat alias,
  v4_candidates list, v4_all_judgments, application_candidates, IMP-06
  additive plan fields
- u4 Step 9 inline loop body at :4620-4658 replaced with helper call;
  per-index/per-id lookups (zone_region_plans[i], v4_fallback_traces
  .get(...), plan_record_by_unit_id.get(id(unit)), section_alias_by_id,
  lookup_v4_all_judgments(...)) stay at call-site
- u5 tests/test_phase_z2_v4_fallback.py Case 7 rewritten to
  test_build_application_plan_unit_emits_candidate_evidence_and_alias
  — direct helper call with SimpleNamespace duck-typed input; asserts
  candidate_evidence list identity (is), fallback_chain compat-alias
  identity (is), key order (candidate_evidence before fallback_chain),
  and compat-alias comment scoped to inspect.getsource(_build_
  application_plan_unit)

Verification: targeted 22 passed, full pytest 408 passed (0 fail/skip),
smoke 11/11 PASS (2 pre-existing baseline SKIPs unchanged).

Cross-ref: IMP-05 (#5) commit 23d1b25 Case 7 temporary source guard
(replaced) / Codex #20 + #21 / IMP-11 D-2 marker preserved.
2026-05-21 03:17:27 +09:00
Kyeongmin 182aa7c47f docs(#40): IMP-31 gate audit + activation reference cross-link
- u1: IMP-17-CARVE-OUT.md anchor cite :564 -> :570/:572/:575/:580/:664
- u2: new IMP-31-GATE-AUDIT.md (3-cond AND gate state + 8 issue-body axes)
- u3: backlog row + status-board section 5 cross-ref to audit doc (no verdict dup)

doc-only. no src/ templates/ tests/ touched. src/phase_z2_ai_fallback/ not created.
2026-05-21 01:59:03 +09:00
Kyeongmin 1efbf672bd feat(#39): IMP-30 first-render invariant + abort bypass (2 paths)
Restore first-render invariant: final.html + Step 20 slide_status MUST be
written for every input where Step 0~5 succeed. Two abort paths replaced
with provisional/empty-shell synthesis; MDX content preserved, AI-free.

- u1 V4Match.provisional + lookup_v4_match_with_fallback(allow_provisional)
  chain_exhausted -> synthesize rank-1 provisional (opt-in, default-off)
- u2 CompositionUnit.provisional propagation (single / parent_merged /
  parent_merged_inferred constructors)
- u3 select_composition_units(allow_provisional_fill=True) last-resort
  fill + _candidate_state="selected_provisional"
- u4 pipeline.py path-(a) abort guard replaced with provisional retry +
  terminal __empty__ shell (no sys.exit(1))
- u5 zones_data.provisional -> slide_base.html zone--provisional class +
  data-provisional + needs-adaptation badge (template-only)
- u6 compute_slide_status additive provisional_first_render_count/_units
  (overall enum unchanged per IMP-05 Codex #10 D4)
- u7 regression: tests/test_phase_z2_imp30_first_render.py (28 tests) +
  tests/test_phase_z2_v4_fallback.py (+5 cases)

Guardrails verified: MVP1_ALLOWED_STATUSES unchanged, no calculate_fit,
no LLM in fallback path, no MDX 03/04/05 hardcoding.

Anchor sync (Rule 13): tests/orchestrator_unit/test_imp17_comment_anchor.py
re-pinned 564/565 -> 570/571 to track V4Match.provisional shift at
src/phase_z2_pipeline.py:179-184.

Cross-ref: IMP-05 (#5) §5 defer + Codex #2 first-render invariant.
2026-05-21 00:40:58 +09:00
Kyeongmin b4872ba6ce feat(#38): IMP-29 frontend zone-level evidence bridge (candidate_evidence reader + types + UI) 2026-05-20 21:53:47 +09:00
Kyeongmin 265d70ed91 refactor(#28): IMP-28 L4 _parse_json dedup (4 modules -> src/json_utils)
Consolidate duplicate _parse_json helpers from content_editor.py /
design_director.py / kei_client.py (fuller form) and pipeline.py (simple form)
into shared src/json_utils.parse_json (strict superset). All 18 call-sites
preserved via `parse_json as _parse_json` alias import; no behavior change.

- src/json_utils.py (new): shared helper, fenced/plain-fence/bare-brace patterns
  + list-prefix cleanup fallback.
- tests/test_json_utils.py (new): 9 unit tests pinning parser semantics.
- src/content_editor.py / design_director.py: remove local helper +
  unused `import json` / `import re`.
- src/kei_client.py / pipeline.py: remove local helper; `json` / `re` retained
  (used elsewhere).

Targeted tests 9 passed; full pytest 374 passed (3 pre-existing scripts/
collection errors reproduce on baseline 909bf75, IMP-28 unrelated).
2026-05-20 20:44:19 +09:00
Kyeongmin 909bf75edc refactor(#27): IMP-27 K5 catalog loader + _get_block_by_id cleanup
Consolidate three duplicated catalog readers and two _get_block_by_id
implementations behind a single shared module (src/catalog.py) that owns
file-read + mtime cache. All caller signatures and return contracts
remain byte-identical.

Units:
- u1 NEW src/catalog.py (76 lines): load_root_catalog / load_blocks /
  get_block_by_id / get_catalog_mtime as the sole file-read +
  mtime-cache owner.
- u2 src/block_reference.py: _load_catalog delegates to load_blocks
  (list[dict] preserved); _get_block_by_id (no-arg) delegates to
  catalog.get_block_by_id. Module-level _catalog_cache removed.
- u3 src/block_selector.py: load_catalog delegates to load_root_catalog
  (root dict preserved); _get_block_by_id (catalog-injected sig
  preserved) delegates to catalog.get_block_by_id. Module-level
  _catalog_cache / _catalog_mtime / CATALOG_PATH removed.
- u4 src/renderer.py: _load_catalog_map and
  _load_catalog_map_with_variants consume catalog.load_blocks; renderer
  projection caches kept local but keyed via
  catalog.get_catalog_mtime(). Per-projection invalidation keys
  (_CATALOG_MAP_MTIME / _CATALOG_VARIANT_MAP_MTIME) introduced. import
  yaml, CATALOG_PATH, legacy _CATALOG_MTIME removed.
- tests NEW tests/test_catalog_shared_loader.py (421 lines, 23 cases):
  shared loader + 3 wrappers covering single file-read, contract
  preservation, signature preservation, shared cache, private state
  absence, mtime invalidation propagation to renderer projections.

Verification:
- pytest tests/test_catalog_shared_loader.py -v: 23/23 PASS in 0.13s.
- pytest tests/ -q --ignore=tests/matching: 365/365 PASS in 38.10s.
- src/fit_verifier.py, src/space_allocator.py, src/pipeline.py and
  templates/catalog.yaml unchanged (git diff empty).

Out of scope:
- catalog.yaml schema/path unchanged.
- Catalog direct-read call sites in fit_verifier / space_allocator /
  pipeline left for a separate follow-up axis.
- Phase Z 22-step runtime, frame_selection, light_edit/restructure
  flows untouched.

Refs: IMP-27 (gitea #27), INSIGHT-MAP §5 K5, PHASE-Q-AUDIT §2.10
2026-05-20 19:31:26 +09:00
Kyeongmin 2896bb691c docs(#26): IMP-26 J3 status pending->deferred + dual-precondition trigger
BACKLOG line 93 + INSIGHT-MAP line 150 (verbatim mirror per anchor sync
rule). Trigger axis now requires both Phase R' archive trigger AND
§2.1/§2.2 SoT signature unification to keep guardrail = code-removal-only.
No source files touched.
2026-05-20 18:27:55 +09:00
Kyeongmin a71355e005 docs(#25): PHASE-Q-AUDIT §1 lens B-1 row candidate-file 칸 정정 2026-05-20 17:52:29 +09:00
Kyeongmin b1897c01bc docs(#24): PHASE-Q-AUDIT §1 lens A-2 row candidate-file 칸 정정
block_reference.py, block_selector.py 를 간접 reference (catalog 로딩 /
block 검색 패턴) 로 재분류. A-2 main = frame_contracts.yaml + frame_partials
신규 구축 (Phase Q catalog schema ≠ Phase Z) 임을 명시. §2.10 K6 + §3-A
1242 binding SoT 와 정합. IMP-22 / IMP-23 A-3/A-4 lock 패턴 추종.
2026-05-20 17:19:24 +09:00
KyeongminandClaude Opus 4.7 5d23b747ff fix(orchestrator): P5b first-line agent header strict + supplement throttle
Bug discovered during #24 IMP-24 K6 Stage 2 (2026-05-20):
- Codex r1, r2, r3 started with '=== IMPLEMENTATION_UNITS ===' on first line
  (not '[Codex #N] ...'), so detect_agent (P0-1 strict, first-line only)
  returned None.
- For non-audit issues, the P5 supplement guard was audit-only gated → silent
  loop until Codex r4 happened to use correct format. 4 rounds wasted.

Verified that #21 Stage 4 had the same latent silent loop pattern
('## [Codex #1]' first line) — orchestrator looped through ~10 Claude rounds
before random recovery. P5b fix addresses this long-standing bug.

Patch (defensive parser-contract hardening; does not assume single root cause):

1. RULES global gets explicit "FIRST non-empty line MUST be [Claude #N] /
   [Codex #N]" rule that OVERRIDES any stage-specific "body MUST contain"
   constraint.

2. COMPACT_PLAN_RULE wording clarified: "body" begins AFTER the first-line
   agent header. The 'body MUST contain ONLY' set no longer accidentally
   permits '=== IMPLEMENTATION_UNITS ===' on line 1.

3. is_codex None supplement guard:
   - audit-only gate REMOVED → fires for all issues (#24 latent loop fixed)
   - Throttle: max 2 supplements per stage; on 3rd violation, orchestrator
     hard-stops the issue with explicit "user action required" message
     and exits run_stage cleanly
   - Supplement message names both Claude AND Codex (Claude's first-line
     violation also breaks downstream via Codex mimicry)
   - Body-head 80 chars logged on detection failure (debugging aid)

4. Regression tests (+5 cases in test_orchestrator_core.py):
   - TestDetectAgent: '=== IMPLEMENTATION_UNITS ===' first line → None
   - TestDetectAgent: [Codex #N] first line + units after → 'codex' OK
   - TestDetectAgent: '## ', '📌 **', '**' prefix all → None
   - TestRulesAndCompactPlanFirstLineContract: RULES wording has FIRST/OVERRIDES
   - TestRulesAndCompactPlanFirstLineContract: COMPACT_PLAN_RULE has carve-out

Cosmetic side effect (accepted): Claude's '📌 **[Claude #N] ...**' or
'## [Codex #N] ...' decoration prefixes will fail detect_agent. Agents
will drop decorations from line 1; line 2+ can still use them.

Out of scope (NOT included to keep regression risk low):
- detect_agent function logic UNCHANGED (P0-1 strict preserved)
- consensus parser UNCHANGED
- stage loop structure UNCHANGED
- git/Gitea retrieval logic UNCHANGED
- audit-only mode P4/P4a guards UNCHANGED
- pre-post comment validation (future axis, larger refactor)

Total: 131/131 pytest pass (126 prior + 5 new).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 17:01:24 +09:00
KyeongminandClaude Opus 4.7 447e702520 docs(#23): PHASE-Q-AUDIT §1 lens A-3/A-4 html_generator 칸 정정
§1 lens A-3 / A-4 candidate-file column에서 bare html_generator.py
token 제거. §2.9 J5 (L979–981) SoT가 두 행 모두 부정확으로 lock —
A-3는 html_generator에 selenium import 부재, A-4는 slide-base 호출
부재 (area HTML만 반환). IMP-22 (2ace54b)의 renderer.py 표기 보존,
IMP-21 (5590ef2)의 token-drop precedent 추종.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 14:39:11 +09:00
KyeongminandClaude Opus 4.7 2ace54bce1 docs(#22): PHASE-Q-AUDIT §1 lens A-3/A-4 renderer 칸 정정
A-3 main = slide_measurer.capture_slide_screenshot; renderer.py 는 간접
(render-path 자료); "selenium 캡처 흔적 추정" 제거.
A-4 = renderer.py (legacy slide-base.html 호출 지점 보유,
embedded/standalone CSS 분기 미구현) 추가. html_generator.py token
양 행 보존 (IMP-23 boundary).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 14:02:53 +09:00
KyeongminandClaude Opus 4.7 5590ef20b5 docs(#21): PHASE-Q-AUDIT §1 lens B-1/B-2 content_editor.py 오기재 제거
§1 lens 표 B-1 행(L104) "Phase Q 후보 파일" 칸 — content_editor.py 토큰 제거 (pipeline.py 보존).
§1 lens 표 B-2 행(L105) "Phase Q 후보 파일" 칸 — content_editor.py 토큰 제거 (글벗 fmt_slide.py html_to_slide_mdx 보존).
§2 모듈 리스트 L125 content_editor.py 항목의 (B-1, B-2) axis annotation 제거 (모듈 자체는 §2.6 audit 대상으로 유지).

근거: content_editor.py = slot-fill / Kei editor only (fill_content :73, fill_candidates :335). B-1 (zone-section override) = pipeline.py / composition planner 영역. B-2 (HTML→MDX 역변환) = 글벗 fmt_slide.py html_to_slide_mdx (외부 sibling). 로컬 src/phase_z2_verification_utils.py 는 B-2 검증 utility 만 (extract_text_from_html L64-73 / normalize_for_comparison L89-104 / strip_meta_lines L147-166). §2.6 G2 self-catch (L642-643, L679) 와 정합.

Scope: docs-only, 1 file changed, 3 insertions(+) / 3 deletions(-). src/ templates/ frontend 무변경. IMP-25 (pipeline.py B-1 precision 축) / 외부 fmt_slide.py reference 축 / content_editor archive verdict (§2.6) / PHASE-Q-INSIGHT-TO-22STEP-MAP.md:136 catch record 모두 보존.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 13:23:32 +09:00
KyeongminandClaude Opus 4.7 134f52d3d3 feat(#58): L3 dormant trigger guard -- DORMANT-TRIGGERS.yaml + checker + orchestrator hook
P5-1 docs/architecture/DORMANT-TRIGGERS.yaml -- 5 entries (IMP-16/17/18/19 active + IMP-20 followup-linked #55).
P5-2 scripts/check_dormant_triggers.py -- standalone, reads registry, scans tree + diff, writes .orchestrator/dormant_alerts.json, exit 0 always.
P5-3 orchestrator.py -- _check_dormant_triggers() helper + Stage 4->5 informational alert branch (skips audit-only, never blocks).
P5-4 tests/orchestrator_unit/test_dormant_triggers.py -- 30 cases (yaml schema, registry contents, checker matching, false-positive guards, manual-evidence skip, orchestrator branch, audit bypass, governance ref).
P5-5 PROJECT-INTENT-AND-GOVERNANCE.md -- single anti-patterns row referencing the L3 registry as binding contract surface.

Tests: pytest -q tests = 337 passed (baseline 307 + 30 new).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 09:43:14 +09:00
Kyeongmin 8c1e56366b docs(#57): INTEGRATION-AUDIT-02 doc-sync banner on IMP-16-U2-WIRING-DESIGN
IMP-16 = documented:dormant, IMP-07 = documented:no-runtime. Banner is
additive-only (9 lines, +0 deletions); existing L2-L75 contract preserved
byte-identical. Resolves issue #57.

Refs: INTEGRATION-AUDIT-02-REPORT.md Sections 3, 4, 7
      (final decision: NEEDS_DOC_SYNC_FOLLOWUP)
2026-05-20 08:11:17 +09:00
Kyeongmin 101143e67b docs(#56): INTEGRATION-AUDIT-02 + backlog L51/L67 -- IMP-07 no-runtime / IMP-16 dormant 2026-05-20 07:14:45 +09:00
KyeongminandClaude Opus 4.7 9389b8425b fix(orchestrator): P5 audit-anchor-first-line regression guard
Bug discovered during #56 INTEGRATION-AUDIT-02 execution (2026-05-20):
- Both Claude and Codex put "Audit anchor: ..." as the FIRST line of every
  Gitea comment per the #56 issue body instruction "cite anchor at start
  of every stage".
- detect_agent (P0-1 strict, first-line only) then returns None for these
  comments because the first line is "Audit anchor:..." not "[Codex #N]"
  or "[Claude #N]".
- Result: orchestrator's "is_codex" check (line ~1288) flips false →
  "Codex 응답 미감지 — continuing" → infinite Stage 4 loop. #56 reached
  Round #14 (>300 comments, ~2 hours wasted token).

Fix path (NOT relaxing detect_agent — that would revive the original #45
pre-P0-1 bug where [Claude #N] citations inside Codex bodies caused
mis-detection):

1. AUDIT_ONLY_NOTE updated to enforce comment format:
   - FIRST non-empty line MUST be `[Claude #N] <stage>` or `[Codex #N] <stage>`
   - Audit anchor / banners / prefaces MUST appear line 2 or later
   - Concrete CORRECT example included
   - Explicit warning that violation breaks stage advance

2. is_codex None guard auto-supplements:
   - When _audit_mode(title) AND detect_agent returns None, orchestrator
     posts a Gitea supplement comment requesting the correct format
   - Next round's Claude/Codex see the supplement and correct
   - Breaks the infinite loop automatically (no manual ctrl-C needed)

3. Regression tests in TestDetectAgent (test_orchestrator_core.py):
   - test_audit_anchor_preface_breaks_detection: confirms P0-1 strict
     correctly returns None when anchor is first line
   - test_audit_anchor_after_header_works: correct format passes

Total: 96/96 pytest pass (94 prior + 2 P5 regression).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 07:03:12 +09:00
KyeongminandClaude Opus 4.7 47f072ee05 docs: PROJECT-INTENT-AND-GOVERNANCE master doc
프로젝트의 왜 / 무엇을 위해 / 어떻게 라는 질문에 대한 master 답.
이 문서가 있으면 매번 처음부터 framing 설명할 필요 없음.

구조:
1. Destination — Phase Z 22-step + AI zone-fit frame generation
2. Q~Y 검토 = 이미 완료 (과거형). 결과 = INSIGHT-MAP + 28 초기 이슈.
3. INSIGHT-MAP catalog 구조 (§0~§5)
4. IMP 이슈 좌표 체계 (관련 step + source + priority + scope + guardrails)
5. orchestrator 의 disciplined executor 역할 (Claude + Codex 합의)
6. Audit cycle (meta-governance) — 발견은 follow-up 이슈로 분리
7. 도착점 도달 기준 5 항목
8. 자주 헷갈리는 anti-patterns (heritage 보존 X, MDX 최적화 X 등)
9. 핵심 참조 문서 인덱스
10. 한 줄 요약

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 00:56:52 +09:00
Kyeongmin 8c60f7cc85 docs(IMP-20): frame contract validation reference + cross-link -- documented-axis close 2026-05-20 00:02:18 +09:00
KyeongminandClaude Opus 4.7 e60aacc3dc docs(IMP-19): zone ratio reference + cross-link -- documented-axis close
Stage 5 commit for IMP-19 (gitea #19) — docs-only, no runtime surface.

- new: docs/architecture/IMP-19-ZONE-RATIO-REFERENCE.md (header + A1 consumer
  + A2 producer + A3 Phase Z solver delta + A4 IMP-09 boundary + A5
  re-activation gate / GR1-GR4).
- update: PHASE-Z-IMPLEMENTATION-ISSUE-BACKLOG.md — IMP-19 row pending ->
  documented + reference doc link; IMP-09 row carries soft back-link to the
  IMP-19 reference doc.
- update: PHASE-Q-INSIGHT-TO-22STEP-MAP.md §3 I4 row — prepend IMP-19 anchor
  + reference doc link (step/classification preserved).

Guardrails (Stage 1/2 binding contract): src/ untouched, no role-based
["배경","본심"] hardcoding into Phase Z, IMP-09 solver ownership preserved,
soft-link integrity holds. IMP-19 remains dormant until the A5 gate fires.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 21:28:17 +09:00
KyeongminandClaude Opus 4.7 02e2ae0afb docs(#54): F-4 legacy annotation + F-5 fixture convention -- AUDIT-01 housekeeping
INTEGRATION-AUDIT-01 (#50) §10.4 / §10.5 housekeeping carry-over.

F-4: annotate 14 remaining legacy Phase R'/Q sample-text hits across 10
src/ files with inline marker `# [legacy Phase R'/Q example -- INTEGRATION-AUDIT-01 §10.4]`.
Comment-only. No string-literal / regex / sample dict value mutated.
fit_verifier.py L612 marker keeps Phase Z partial-live import graph
(FitAnalysis / RoleFit / redistribute / salvage) byte-precise.

F-5: docs-only addendum -- §10.5.1 in INTEGRATION-AUDIT-01-REPORT.md +
tests/CLAUDE.md fixture convention note. No root tests/fixtures/ dir
created; existing tests/phase_z2/fixtures/ convention preserved. Documents
test-only sample-reference allowance vs src/** runtime prohibition.

Out of scope: Phase Z source 11 hits (phase_z2_content_extractor /
failure_router / mapper / retry), production behavior change, #19 work.

Verified: pytest -q tests/phase_z2/ = 157 PASS. git diff +210/-0
(35 src/docs lines + 175 new tests/CLAUDE.md). No behavioral delta.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 20:23:36 +09:00
KyeongminandClaude Opus 4.7 8f06a4c99f docs(IMP-52): reconcile Phase Z family count drift -- F-2 option (c)
Audit follow-up F-2 (INTEGRATION-AUDIT-01 §10.2). Phase Z families surface
showed 11 tracked / 11 contracted / 13 on disk. The 2 untracked WIP files
(app_sw_package_vs_solution.html, pre_construction_model_info_stacked.html)
are now declared in _WIP_FILES.md as uncontracted and out-of-scope for the
runtime matcher; promote/remove is gated on #42. The 11/11 tracked +
contracted baseline is unchanged. A new pytest enforces tracked families ↔
frame_contracts.yaml set-equality modulo the WIP allowlist parsed from
_WIP_FILES.md, so future drift fails fast in CI before #42 expands to 32
frames.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 19:15:04 +09:00
Kyeongmin 191b6a9d85 docs(IMP-53): resolve audit charter F-1 -- C3 producer path
Annotate `INTEGRATION-AUDIT-01-REPORT.md` §5.1 row C3 with an inline
correction pointer noting that the historical charter cites
`src/phase_z2_mapper.py + consumers` but the live `fit_classification`
producer is `src/phase_z2_classifier.py`. Stamp §5.4 F-1 surface-note
and §10.1 F-1 heading as `RESOLVED via IMP-53 (2026-05-19)`.

Documentation-only change. Runtime / templates / catalog / MATRIX /
OVERVIEW untouched. Historical §5.1 C3 quote preserved verbatim per
anchor_sync_rules. pytest -q tests = 303 passed (baseline parity).

Refs IMP-53 (F-1) -- gitea issue #53
2026-05-19 16:42:12 +09:00
KyeongminandClaude Opus 4.7 2bb0acac19 docs(IMP-51): reconcile Phase Z backlog status with audit-01 (F-3)
Per INTEGRATION-AUDIT-01 (#50) §6.2 / §9.3 cond.1 / §10.3:
- §1 IMP-02..IMP-11: pending -> implemented (10 rows, BACKLOG_STALE flip)
- §2 IMP-12..IMP-16: pending -> implemented (5 rows, BACKLOG_STALE flip)
- §2 IMP-17: pending -> documented (deferred) (carve-out boundary preserved)
- §2 IMP-18: documented unchanged (AGREE row)
- §2 footnote: IMP-15 child issues note (#45 e9b3d2e / #46 2827622 /
  #47 535c484 / #48 614c533 / #49 verification-only) — no standalone rows

Hard gate before #19 Stage 2 planning. Doc-only carve-out:
no src/templates/tests changes. Status strings match audit §6.2 verbatim.
Out-of-audit-scope rows (IMP-01, IMP-19/20, IMP-21..28) preserved.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 16:08:39 +09:00
KyeongminandClaude Opus 4.7 c37a554fb1 docs(IMP-50): backlog audit completion row for IMP-50
Append IMP-50 audit completion row referencing INTEGRATION-AUDIT-01-REPORT
(commit 8c7d693) with CONDITIONAL GO for #19 decision.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 14:59:39 +09:00
KyeongminandClaude Opus 4.7 8c7d6935b1 docs(IMP-50): Phase Z integration audit-01 — report-only carve-out
22 closed improvement issues × 22-step Phase Z pipeline audit.
4-axis verification: scope myopia, pipeline step mapping, cross-issue
conflict, backlog ↔ code reality. Decision: CONDITIONAL GO for #19.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 14:59:18 +09:00
KyeongminandClaude Opus 4.7 e32f632464 fix(orchestrator): P4a baseline-diff guard + Stage 5 commit scope
P4 had two production issues blocking #50 integration audit deployment:

1. Stage 3 guard had no baseline awareness — flagged ALL forbidden-path
   changes including pre-existing dirty WIP. Empirical: 328 such files
   already in current working tree (tests/matching/ artifacts etc).
   #50 would have hit reject loops immediately without Claude doing
   anything wrong.

2. Stage 5 had no commit-scope guard — if Claude ran `git add -A` and
   committed user's existing WIP, audit commit would be polluted with
   unrelated production changes.

P4a additions:
- _audit_baseline_path / _ensure_audit_baseline / _load_audit_baseline:
  snapshot working-tree dirty paths at run_issue entry for audit issues.
  Resumed runs preserve existing baseline (no overwrite).
- _check_audit_only_violations(baseline=None): accept baseline set,
  subtract from violations — only flags NEW forbidden changes introduced
  after audit start.
- _check_audit_commit_scope: verify HEAD commit's file list matches
  AUDIT_ALLOWED_COMMIT_GLOBS (INTEGRATION-AUDIT-*.md, BACKLOG.md).
- run_issue: save baseline on audit-mode entry only — no impact on
  normal issues.
- Stage 5 (commit-push) YES gate: new guard rejects on out-of-scope
  files with remediation prompt (git reset --soft + force-with-lease).

19 new tests:
- baseline subtraction (5): pre-existing removed, None=keep-all,
  empty-set=catch-all, full-coverage filter, Windows path normalize.
- baseline persist (5): roundtrip, no-overwrite on resume, missing
  fallback, corrupt JSON fallback, non-list fallback.
- commit scope detection (7): report-only allowed, backlog allowed,
  src/ rejected, unrelated docs rejected, git error fail-open,
  Windows backslash, empty commit pass.
- allowed globs sanity (2): every glob has audit marker, all under
  docs/architecture/.

Total: 94/94 pytest pass (75 prior + 19 new).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 10:29:15 +09:00
KyeongminandClaude Opus 4.7 4289a500b6 feat(orchestrator): P3 wrapper input/encoding fix + P4 audit-only mode
P3 hotfix (2026-05-18 — verified during #46 retry attempt):
- _run_with_tree_kill: encode input only when Popen is in binary mode.
  Previously force-encoded str→bytes even with encoding= set, breaking
  text-mode stdin pipes with: write() argument must be str, not bytes.
- run_claude path was the only affected call site.
- 3 new C7 regression tests (input+encoding / bytes+binary / auto-encode).
- C3/C6 test fixtures hardened with DEVNULL stdio isolation.

P4 audit-only mode (2026-05-19, prep for #50 integration audit):
- _is_audit_issue: title-based detection for [INTEGRATION-AUDIT*],
  [AUDIT-ONLY], or "integration audit" phrase.
- _audit_mode + --audit-only CLI flag: manual override regardless of title.
- AUDIT_ONLY_NOTE injected into context pack across all stages/rounds.
- Stage 3 (code-edit) YES gate: deterministic git status check.
  Changes touching src/**, templates/**, tests/** auto-reject Stage 3 YES
  and post a supplement-request comment. LLM-independent enforcement.
- 26 new audit-mode tests (title detection, CLI override, forbidden
  prefix detection, allowed paths pass, Windows backslash normalization,
  quoted paths with spaces, git error fail-open, constants sanity).

Total: 75/75 pytest pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 10:18:28 +09:00
KyeongminandClaude Opus 4.7 cbbc163860 docs(IMP-18): Phase Z SVG gap report — doc-only carve-out
u1: docs/architecture/IMP-18-SVG-GAP-REPORT.md (NEW, 64 lines)
  4 axes: Phase R' _preprocess_svg_data source refs (renderer.py:169-207,
  svg_calculator.py:15-156); Phase Z 15-partial SVG absence (grep
  <svg|viewBox = 0); IMP-04 activation gate; Phase R' read-only guardrail.

u2: docs/architecture/PHASE-Z-IMPLEMENTATION-ISSUE-BACKLOG.md L69
  IMP-18 row: status pending -> documented + gap-doc link appended.

Phase R' source (src/renderer.py, src/svg_calculator.py) and 15 Phase Z
partials remain unmodified. IMP-18 is dormant reference axis; activation
gated on IMP-04 registering an SVG-bearing partial.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 09:33:34 +09:00
KyeongminandClaude Opus 4.7 e10ec36617 feat(IMP-17): AI repair fallback infra carve-out — design-only boundary + 3-cond AND gate
u1 — src/phase_z2_pipeline.py:564 route hint comment corrected from
non-existent IMP-31 to IMP-17 (carve-out, AI fallback only, normal path 밖).
Line 565 IMP-29 frontend override reference untouched.

u2 — docs/architecture/IMP-17-CARVE-OUT.md (new) defines:
- allowed scope (Step 12 restructure proposal, Step 16/17 retry fallback)
- forbidden scope (normal-path AI calls, MDX compression, HTML structure)
- 3-condition AND activation gate (User GO ∧ B4 frame_selection evidence
  ∧ IMP-04 catalog + IMP-05 V4 fallback live)
- pattern shape reference (link-only): content_editor.py:21,318 +
  sse_utils.py:16-50 (Phase Q Archive Candidate, no port)
- AI 격리 contract + Kei persona 단절 (permanent)

u3 — PHASE-Z-IMPLEMENTATION-ISSUE-BACKLOG.md:68 IMP-17 row gains
carve-out doc link + 3-cond AND gate pointer.

u4 — PHASE-Q-INSIGHT-TO-22STEP-MAP.md AI repair fallback infra registry
row prefixed with IMP-17 + carve-out link; normal_path=no preserved.

Anchor test: tests/orchestrator_unit/test_imp17_comment_anchor.py asserts
line 564 IMP-17 wording AND line 565 IMP-29 preservation (2 tests pass).

Runtime behavior change: 0. Only delta in executable file is one comment
line. Normal-path AI invocation count remains 0.

Refs: gitea #17

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 08:12:43 +09:00
KyeongminandClaude Opus 4.7 23ba8b68cd feat(IMP-16): U1 H3 verification utility port + U2 wiring design
U1 (runtime, u1-u10): new Phase Z-owned deterministic verification module
src/phase_z2_verification_utils.py (335 LOC, stdlib only) porting H3 utility
surface — VerificationResult, extract_text_from_html, normalize_for_comparison,
extract_keywords, strip_meta_lines, split_into_sentences, verify_text_preservation,
detect_invented_text. 10 unit tests under tests/phase_z2/test_pz2_vu_*.py (56 tests).

u11 (design-only): docs/architecture/IMP-16-U2-WIRING-DESIGN.md fixes the Step
1/2/14/21/22 reverse-path contract, redesigned frame-contract pattern
reservation (IMP-20), and IMP-07 hard-gate criteria. No runtime wiring lands
in this commit — U2 stays blocked until IMP-07 reverse path is implemented +
verified + runtime-hit.

Guardrails: no src.content_verifier import; no FORBIDDEN_KEI_MEMOS /
generate_with_retry / REQUIRED_PATTERNS / verify_structure / verify_area /
verify_all_areas usage; no AI / Kei / httpx / SSE path; AI-isolation contract
upheld (utility is deterministic).

Tests: 56 targeted PASS (0.19s), 15 regression baseline PASS (7.59s).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 04:42:35 +09:00
KyeongminandClaude Opus 4.7 614c53358e feat(IMP-15): 실행-4 — debug.json event surfacing + spec taxonomy row
Issue: #48 (IMP-15 실행-4, axis 4: debug.json + spec doc trace).
Parent: #15. Depends on 실행-1/2/3 (events + classifier outputs).

Surfaces the image/table event streams that 실행-1/2/3 already produced
and consumed, mirroring the existing `zone_geometries_px` top-level
precedent (no new pattern introduced). Adds the matching taxonomy row
to the Phase Z fit-classifier/router spec.

src/phase_z2_pipeline.py (+3):
- write_debug_json now lifts `image_events` and `table_events` to
  top-level of `debug.json` via `(visual_runtime_check or {}).get(<k>, [])`,
  exactly mirroring the immediately preceding `zone_geometries_px`
  surfacing line. Defaults to `[]` when `visual_runtime_check` is None
  — additive, no consumer-visible breakage.

docs/architecture/PHASE-Z-FIT-CLASSIFIER-ROUTER-SPEC.md (+1):
- §3.1 taxonomy adds `image_aspect_mismatch` row. Row text explicitly
  marks the signal as post-render `fail_reasons` from Step 14
  visual_runtime_check (rendered vs declared aspect ratio mismatch),
  NOT a router-routed fit_classifier output, and notes the separate
  `image_events` stream surface. Prevents future readers from wiring
  this taxonomy into §3.2 priority list or §4 router action map.

tests/phase_z2/test_debug_json_event_surfacing.py (new, 2 tests):
- `test_write_debug_json_surfaces_image_and_table_events` invokes
  write_debug_json with synthetic visual_runtime_check containing
  both event lists; reads back the on-disk debug.json and asserts
  both keys are present at top level with the exact payloads.
- `test_write_debug_json_defaults_when_visual_runtime_check_none`
  asserts both new keys default to `[]` when visual_runtime_check
  is None — guards the defensive `(… or {})` pattern.

tests/phase_z2/test_spec_taxonomy_image_aspect_mismatch.py (new, 2 tests):
- `test_spec_has_image_aspect_mismatch_row` opens the spec file and
  asserts exactly one `^\| image_aspect_mismatch \|` row exists
  inside the §3.1 table block (no markdown-parser dependency).
- `test_spec_row_marks_post_render_fail_reasons_semantic` asserts the
  row text carries both "Post-render" and "fail_reasons" tokens —
  enforces the Stage 1 guardrail wording.

Verification (Stage 4 PASS, Claude + Codex independent):
- pytest -q tests/phase_z2/test_debug_json_event_surfacing.py \
              tests/phase_z2/test_spec_taxonomy_image_aspect_mismatch.py
  → 4 passed in 0.07s.
- git diff scope: 4 files, +148 insertions / 0 deletions.

Scope-locked: no edits to classifier (실행-3), event generation
(실행-1/2), Step 21 viewer, §3.2 priority list, §4 router action
mapping, or `table_self_overflow` taxonomy row. Pre-existing
dirty/untracked working-tree files left untouched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 22:25:41 +09:00
KyeongminandClaude Opus 4.7 535c4848fd feat(IMP-15): 실행-3 — classifier consumes image+table events
Issue #47 (IMP-15 실행-3 axis 3): extend `classify_visual_runtime_check`
to consume the `image_events[]` and `table_events[]` arrays produced by
`run_overflow_check` (실행-1/2) and widen `visual_check_passed`.

Changes (src/phase_z2_classifier.py):
- Remove `overflow.passed=True` early-return so image/table event scans
  always run, even when zone-level overflow was clean.
- Deferred import of `IMAGE_ASPECT_DELTA_TOL` and `TABLE_SCROLL_TOL_PX`
  from `phase_z2_pipeline` (circular-safe SSoT; no duplicate literals).
- New `image_events` scan emits `image_aspect_mismatch` when
  `delta is not None AND |delta| > IMAGE_ASPECT_DELTA_TOL`
  (delta=None ⇒ skip, image not loaded).
- New `table_events` scan emits `tabular_overflow` when
  `wrapper_clipped_index is None AND (excess_x or excess_y > TABLE_SCROLL_TOL_PX)`
  (wrapper-clipped tables deduped against the existing zone cascade).
- `visual_check_passed = overflow.passed AND not classifications` —
  any image/table classification now flips the gate.

Guardrails preserved:
- §3.2 8-rule zone cascade (clipped_inner / zone-self) untouched —
  the new emitters are ADDITIONAL.
- `placement_diagnostics`, `categories_seen`, `unclassified_signals`
  return-shape preserved.
- No `pipeline.py` production changes; no router action or
  `debug.json` passthrough changes.

Tests (tests/phase_z2/test_phase_z2_visual_classifier.py — new):
- `test_image_aspect_mismatch_emits_classification` (|delta|>TOL fires)
- `test_image_aspect_delta_below_tol_no_classification` (≤TOL skipped)
- `test_standalone_table_overflow_emits_classification`
  (wrapper_clipped_index=None, excess>TOL fires)
- `test_table_dedup_when_wrapper_clipped`
  (wrapper_clipped_index set ⇒ no `tabular_overflow` emit)

All 4 pure-dict (no Selenium / chromedriver / pipeline execution).
Tolerances imported from `phase_z2_pipeline` (SSoT enforced via test
import — no classifier-local literals).

Verification (Stage 4):
- New classifier tests: 4/4 PASS.
- Regression `tests/phase_z2/` excluding new file: 93/93 PASS.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 21:45:06 +09:00
KyeongminandClaude Opus 4.7 2827622858 feat(IMP-16): Step 14 table_self_overflow detection
Add table self-overflow detection with element-identity wrapper dedup,
mirroring the image_aspect_mismatch axis pattern (#45).

JS layer: TABLE_SCROLL_TOL_PX=5 module constant; clippedWrapperMap
built as Map<Element,int> keyed by DOM node reference (NOT className)
so two wrappers with identical class strings remain distinguishable;
table_events collected via querySelectorAll('table').forEach with
closest()-ancestor walk resolving wrapper_clipped_index = int|null.

Py layer: aggregate result['table_events'] and append fail_reason
'table_self_overflow' only when (excess_x>TOL OR excess_y>TOL)
AND wrapper_clipped_index is None; wrapper-clipped path continues
to fail via existing clipped_inner reporting.

Tests (Selenium, chromedriver guard mirrored from image_check):
- Fixture D: standalone <table> overflow → table_self_overflow fail
- Fixture E: <table> in clipped wrapper → dedup suppresses table fail
- Fixture F (F1 acceptance): two wrappers with identical className
  f13b-cell, W1 clipped by non-table child, W2 hosts self-overflow
  <table> with W2 itself NOT clipped → element-identity ensures W2's
  table is not suppressed by W1's class; both fails emitted.

Out of scope: image_events behavior (intact from #45), classifier
pass/fail consumer (→실행-3), debug.json surfacing (→실행-4).

Refs: #46

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 21:06:01 +09:00
KyeongminandClaude Opus 4.7 f3bff898fb feat(orchestrator): initial orchestrator + subprocess cleanup hardening
Pre-existing P0+P1 fixes (verified via #45 pilot 2026-05-18):
- P0-1: detect_agent first-line only (fixes #45 infinite loop)
- P0-2: stage_start_count sanity reset on external comment delete
- P0-3: 32 pytest cases for parse/detect regressions
- P1-4: execution-issue mode prompt (compact scope-tight)
- P1-5: Stage 2 COMPACT_PLAN_RULE (size budget, no code snippets)
- P1-6: tests:[] orchestrator-level enforcement at Stage 2 YES guard
- P1-7: dual-write CRLF/trailing-whitespace normalize

P3 subprocess cleanup (PID 2780 orphan grandchild regression):
- (pid, create_time) signature tracking — Windows PID reuse safe
- _kill_process_tree: parent-alive traversal path
- _kill_tracked: parent-dead orphan path
- _run_with_tree_kill: 1s monitor thread captures descendants live
- atexit + SIGINT safety net via _SPAWNED set
- 4 subprocess.run sites switched to wrapper (compaction/exit_report/
  run_claude/run_codex)
- 12 cleanup pytest cases incl. C6 PID 2780 regression test

Selenium boundary unchanged — driver.quit() in phase_z2_pipeline.py
and slide_measurer.py already protected by try/finally.

Total: 44/44 pytest pass (32 core + 12 cleanup).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 18:56:06 +09:00
KyeongminandClaude Opus 4.7 e9b3d2e9c0 feat(IMP-15): 실행-1 — Step 14 image_aspect_mismatch detection
Issue: #45 (IMP-15 실행-1, image axis only).

Adds Selenium-based <img> aspect ratio measurement to Step 14
run_overflow_check + numeric tolerance gate. Tolerance lives as
module-scope constant so tests can import it.

src/phase_z2_pipeline.py (+73/-2):
- L131-L135  IMAGE_ASPECT_DELTA_TOL = 0.05 (module scope, importable)
- L2216-L2261  JS payload extension: image_events[] per <img>
  (src, zone_position via closest('.zone') with 'unknown' fallback,
   zone_template_id, natural/rendered w+h+ratio, delta, slide-rel bbox)
- L2262  run_overflow_check return extended with image_events
- L2302-L2320  Python aggregation: abs(delta) > TOL ⇒ fail_reasons
  append 'image aspect mismatch in zone--<pos>: natural=<n> rendered=<r>
  delta=<+d> (template=<tid>, tol=0.05, src=<src>)'.
  Null-delta entries (image not loaded) are skipped — no false positive.
  Branch placed AFTER existing non-image branches; ordering & strings
  for slide/slide-body/zone/clipped_inner unchanged.
- L4425-L4429  Step 14 note: image half closed, table half deferred
  to 실행-2.

tests/phase_z2/test_phase_z2_step14_image_check.py (+196, new):
- 3-tier chromedriver resolver mirroring pipeline (PROJECT_ROOT/
  chromedriver{,.exe} → PATH → Selenium Manager probe).
- pytestmark: skip when chromedriver unresolvable AND
  PHASE_Z_REQUIRE_SELENIUM != '1'; xfail(strict=True) opt-in when =='1'.
- Fixture A: 200×100 img rendered 200×100 → aspect_delta < 0.05, passed.
- Fixture B: 200×100 intrinsic forced to 200×200 → delta > 0.30,
  fail_reason present.
- Fixture C: <img> with no .zone ancestor → zone_position == 'unknown'.

Verification (Stage 4 PASS, Claude + Codex independent):
- pytest -q tests/phase_z2/test_phase_z2_step14_image_check.py → 3 passed
- PHASE_Z_REQUIRE_SELENIUM=1 same suite → 3 passed (strict opt-in)
- pytest -q tests/phase_z2 → 90 passed (no regression)
- pytest -q --ignore=tests/matching → 174 passed

Scope-locked: no slide_base.html / catalog / classifier / debug.json /
spec-doc changes. table_events (실행-2), visual_check_passed flip
(실행-3), debug.json image_events surfacing + PHASE-Z spec doc row
(실행-4) remain queued as separate IMP-15 child execution issues.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 18:01:28 +09:00
Kyeongmin 7a52cebfaa feat(IMP-14): A-4 — slide_base embedded vs standalone mode contract
Step 13 owns iframe-vs-standalone CSS contract in slide_base.html via
3-valued embedded_mode enum (auto / embedded / standalone). Removes
SlideCanvas.tsx runtime CSS injection workaround; frontend now passes
?embedded=1 query so auto-mode script attaches html.embedded class and
scopes the standalone body centering/min-height/padding reset.

- templates/phase_z2/slide_base.html: conditional html.embedded class +
  CSP-safe auto-mode <script> + additive html.embedded body/.slide rules
- src/phase_z2_pipeline.py: render_slide gains keyword-only embedded_mode
  ("auto" default) + ValueError guard; 3 existing call sites unchanged
- Front/client/src/components/SlideCanvas.tsx: derive embeddedSrc with
  ?embedded=1 (query-preserving), drop reset CSS injection block
- tests/phase_z2/test_slide_base_embedded_mode.py: 6 cases — auto script,
  CSS rules, embedded/standalone explicit modes, byte-determinism,
  invalid-mode guard
2026-05-18 07:21:31 +09:00
KyeongminandClaude Opus 4.7 7d5639ad72 feat(IMP-13): A-3 — build-time frame preview generator (capture_slide_screenshot salvage)
scripts/generate_frame_previews.py iterates figma_to_html_agent/blocks/{frame_id}/index.html,
renders preview.png via Selenium headless (capture_slide_screenshot pattern reuse), and writes
_preview_manifest.json (schema v1) with idempotent stale-detect (mtime+sha256). Build-time only
— no runtime pipeline integration, no AI calls, no MDX/Jinja regen. Stage 2 baseline (commit
56619a0): total=33, renderable=20, missing_index_html=13, orphan=1 (1171281192).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 06:25:05 +09:00
KyeongminandClaude Opus 4.7 56619a0239 feat(IMP-12): Step 16/17 retry refinement — multi-donor + 3-stage salvage cascade
Extend Step 17 deterministic action surface so donor_slack_insufficient no longer
abort-terminates at zone_ratio_retry. AI is NOT invoked on the normal salvage path.

Source changes (4 files, scope-locked):
- src/phase_z2_retry.py — plan_zone_ratio_retry: single-primary-donor → multi-donor
  greedy aggregation (donors_used / aggregate_slack_used / aggregate_slack_available);
  new plan/apply pairs: cross_zone_redistribute (wraps fit_verifier.redistribute,
  data-role scoped CSS), glue_compression (wraps space_allocator.compute_glue_css_overrides,
  data-zone-position scoped), font_step_compression (wraps find_fitting_font_size,
  zone-scoped, defensive feasible=False on missing text_metrics).
- src/phase_z2_failure_router.py — classifier inspects salvage_steps[-1] via
  SALVAGE_FAILURE_TYPE_BY_ACTION; NEXT_ACTION_BY_FAILURE rewired into
  donor_slack_insufficient/no_donor_candidates → cross_zone_redistribute → glue
  → font_step → layout_adjust; 3 IMPLEMENTED salvage status rows added.
- src/phase_z2_router.py — ACTION_IMPLEMENTATION_STATUS registers 3 new salvage
  actions as IMPLEMENTED; ACTION_BY_CATEGORY untouched (cascade-only labels).
- src/phase_z2_pipeline.py — new _attempt_salvage_chain() iterates router
  next_proposed_action with retry_budget=1 per action; honors IMP-09 dynamic_cols
  / fr_default gate; preserves (b)-revert on all-fail; wires Step 17 telemetry
  (salvage_steps / salvage_passed).

Tests (6 new pytest modules):
- test_phase_z2_retry_multi_donor.py — single sufficient (regression), 1st
  insufficient + 2nd sufficient (multi-donor PASS), aggregate insufficient FAIL.
- test_phase_z2_cross_zone_redistribute.py — multi-role zone feasible,
  single-role zone short-circuits infeasible.
- test_phase_z2_glue_compression.py — feasible asserts emitted CSS contains
  [data-zone-position=...] selector and NO global :root/body/.slide rule.
- test_phase_z2_font_step_compression.py — 15.2 → 13 closes excess; 8px floor;
  missing text_metrics → defensive infeasible reason.
- test_phase_z2_failure_router_cascade.py — donor_slack_insufficient → cross_zone
  (impl=IMPLEMENTED); 3 new failure types → expected next actions; rerender_still_fails
  preserves frame_reselect terminus.
- test_phase_z2_step17_salvage_chain.py — end-to-end (a) cross_zone PASS promotes
  final.html, (b) cross_zone FAIL + glue PASS promotes 2nd candidate, (c) all-3
  FAIL preserves original final.html (revert).

Guardrails preserved:
- AI calls: 0 on normal path (feedback_ai_isolation_contract)
- Spacing direction: no shrink-common-margin; resolve via donor/glue/font-step
  within frame envelope (feedback_phase_z_spacing_direction)
- All CSS overrides scoped to [data-role=...] or [data-zone-position=...]
- IMP-09 dynamic_cols / fr_default gate honored in cascade
- (b)-revert preserved if all 3 salvage actions fail

Refs: gitea#12 IMP-12

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 02:07:22 +09:00
KyeongminandClaude Opus 4.7 a79bd8bc43 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>
2026-05-17 22:29:17 +09:00
KyeongminandClaude Opus 4.7 0fb168befc feat(IMP-10): D-1 — filtered_section_reasons UI (read-only)
Surface step20_slide_status.json.data.filtered_section_reasons in the
frontend Home header. Verbatim mirror of backend payload — no enum
redefinition, no translation, no auto-classification.

Units:
- u1: FilteredSectionReason interface mirroring src/phase_z2_pipeline.py
  :2217-2278 (10 fields incl. override-uncovered source/position variant).
- u2: RunMeta extension + loadRun() mapping with ?? [] back-compat defaults.
- u3: Header badge + <details> disclosure adjacent to existing status
  badge; hidden when filtered_section_ids.length === 0; renders all 10
  schema fields + filter_reasons[] verbatim.

Scope:
- Frontend-only, read-only. No backend / sync script / Kei·AI panel
  changes. Files: Front/client/src/services/designAgentApi.ts (+20),
  Front/client/src/pages/Home.tsx (+25).

Refs: gitea issue #10 (IMP-10 D-1 filtered_section_reasons UI)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 19:43:13 +09:00
KyeongminandClaude Opus 4.7 1fb973297f feat(IMP-09): PR 2 — 2-D dynamic dispatch for 5 preset families
Stage 3 lock implementation: extend build_layout_css dispatch beyond
the horizontal-2 / vertical-2 1-D dynamic paths. T / inverted-T /
side-T-left / side-T-right / 2x2 now flow through a 2-D track solver
instead of the fr_default sink, with length-locked heights_px (R) +
widths_px (C) on every return path (default and override).

PR 2 scope (u1~u5):
  - u1: _aggregate_zone_signals_per_track — per-row + per-col virtual
    zones via max(weight) + max(min_height_px) of single-span zones,
    falling back to all-span when a track has none.
  - u2: _build_grid_dynamic_2d default builder — feeds virtual zones
    into compute_zone_layout + compute_zone_layout_cols; emits
    computation="2d_dynamic_aggregated", dynamic_rows=True,
    dynamic_cols=True.
  - u3: _override_to_grid_tracks override builder — single-span
    aggregation (max h per row, max w per col), normalize, multiply
    by avail_h/avail_w, last-element diff absorb; emits
    computation="user_override_geometry"; falls back to u2 when
    total_h or total_w == 0.
  - u4: build_layout_css dispatcher wiring — topology in
    {T, inverted-T, side-T-left, side-T-right, 2x2} routes to
    _build_grid_dynamic_2d (default) or _override_to_grid_tracks
    (override); legacy [override-warning] stderr removed for the
    5 presets; step08 trace gains a 2-D-aware print line that fires
    before the dynamic_rows / dynamic_cols branches.
  - u5: PR 1 lock test test_top_1_bottom_2_fr_default_populates_geometry
    renamed to test_top_1_bottom_2_dynamic_2d_populates_geometry and
    flipped to PR 2 reality (computation="2d_dynamic_aggregated",
    dynamic_rows=True, dynamic_cols=True).

Fixtures: 10 build_layout_css (5 presets × {default, override}) +
5 retry_gate *_dynamic_2d.yaml locking the retry gate skip reason
"dynamic_cols (2-D topology) ... IMP-09 lock" for the 5 presets.

Tests: python -m pytest -q tests = 104 passed (Stage 2 baseline
10 RED → GREEN, 0 regressions). Kei archive
(build_containers_type_b / page_structure) untouched —
rg "build_containers_type_b|page_structure" src/phase_z2_pipeline.py
returns 0 hits.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 18:51:23 +09:00
127 changed files with 18949 additions and 535 deletions
+5 -1
View File
@@ -8,7 +8,11 @@ dist/
build/ build/
.venv/ .venv/
node_modules/ node_modules/
data/ data/*
# IMP-46 u6 — track only the frame_cache directory marker; cached payloads stay ignored.
!data/frame_cache/
data/frame_cache/*
!data/frame_cache/.gitkeep
# session workspace (push X — 작업 흐름 trace, 사용자 결정 2026-05-08) # session workspace (push X — 작업 흐름 trace, 사용자 결정 2026-05-08)
forex/ forex/
+167 -10
View File
@@ -19,6 +19,20 @@ interface FramePanelProps {
onNoDesignToggle: () => void; onNoDesignToggle: () => void;
} }
// ─── IMP-41 u3 — application_mode consequence tooltip map (issue #70) ────────
// Keyed by application_mode VALUE (backend authoritative), NOT V4 label.
// Source = src/phase_z2_pipeline.py APPLICATION_MODE_BY_V4_LABEL (:107-112)
// emitted via Step 9 unit.application_candidates[] and forwarded by
// designAgentApi.ts (IMP-41 u2). When applicationMode is absent (legacy
// fixtures pre-IMP-32, or candidate filtered out at Step 9) the tooltip
// falls back to the raw V4 label string per Stage 2 contract.
const APPLICATION_MODE_TOOLTIP_KR: Record<string, string> = {
direct_insert: "코드 직접 적용",
same_frame_with_adjustment: "AI 보강 필요",
layout_or_region_change: "AI restructure 필요",
exclude: "render path 제외",
};
export default function FramePanel({ export default function FramePanel({
slidePlan, slidePlan,
selectedZone, selectedZone,
@@ -46,6 +60,26 @@ export default function FramePanel({
return userSelection.overrides.zone_frames[targetRegion.id] || targetRegion.frame_match_strategy.frame_id; return userSelection.overrides.zone_frames[targetRegion.id] || targetRegion.frame_match_strategy.frame_id;
}, [selectedZone, selectedRegion, userSelection.overrides.zone_frames]); }, [selectedZone, selectedRegion, userSelection.overrides.zone_frames]);
// IMP-47B u11 — reject-click confirm guard. Per #76 policy: 사용자가 reject
// 카드 명시 클릭 → backend `--override-frame` 전달 + reject frame 유지 + AI 재구성.
// The window.confirm makes the AI-rebuild intent explicit (deselecting an
// already-applied reject frame does not prompt). Pure UX gate — no state
// mutation here; the parent `onFrameSelect` still owns the override apply.
const handleFrameSelect = React.useCallback(
(candidate: FrameCandidate) => {
const isReject = candidate.label === "reject";
const alreadyApplied = currentFrameId === candidate.id;
if (isReject && !alreadyApplied) {
const ok = window.confirm(
`"${candidate.name}" 은 V4 reject 라벨입니다.\n선택 시 frame 은 유지되고 AI 가 콘텐츠를 frame 구조에 맞게 재구성합니다.\n계속하시겠습니까?`,
);
if (!ok) return;
}
onFrameSelect(candidate.id);
},
[currentFrameId, onFrameSelect],
);
if (!selectedZone) { if (!selectedZone) {
return ( return (
<div className="h-full flex flex-col items-center justify-center bg-slate-50 p-8 text-center text-slate-400"> <div className="h-full flex flex-col items-center justify-center bg-slate-50 p-8 text-center text-slate-400">
@@ -87,6 +121,62 @@ export default function FramePanel({
// catalog 미등록 = backend Step 7-A 가 override 시도해도 skip. // catalog 미등록 = backend Step 7-A 가 override 시도해도 skip.
// catalogRegistered === false 만 체크 (undefined = 정보 없음, 일반 처리). // catalogRegistered === false 만 체크 (undefined = 정보 없음, 일반 처리).
const isCatalogMissing = candidate.catalogRegistered === false; const isCatalogMissing = candidate.catalogRegistered === false;
// ─── IMP-29 u3 — IMP-05 L2 candidate_evidence surface ───────────
// All evidence fields optional; silent degradation when undefined
// (pre-IMP-05 fixtures fall back to label/catalogRegistered only).
const isFilteredDirect = candidate.filteredForDirectExecution === true;
const hasDecision = candidate.decision === "selected" || candidate.decision === "skipped";
const isSkipped = candidate.decision === "skipped";
const isSelectedDecision = candidate.decision === "selected";
const showRouteChip =
candidate.routeHint && candidate.routeHint !== "direct_render";
const showStatusChip =
candidate.phaseZStatus && candidate.phaseZStatus !== "auto_renderable";
const hasCapacityFit =
candidate.capacityFit && candidate.capacityFit.fit_status;
const capacityMismatch =
hasCapacityFit && candidate.capacityFit!.fit_status !== "ok";
// Compose evidence tooltip lines (only when at least one signal present).
const evidenceLines: string[] = [];
if (candidate.decision) evidenceLines.push(`decision: ${candidate.decision}`);
if (candidate.reason) evidenceLines.push(`reason: ${candidate.reason}`);
if (candidate.routeHint) evidenceLines.push(`route: ${candidate.routeHint}`);
if (candidate.phaseZStatus)
evidenceLines.push(`phase_z_status: ${candidate.phaseZStatus}`);
if (hasCapacityFit) {
const cf = candidate.capacityFit!;
const capacityLine =
cf.fit_status === "ok"
? `capacity: ok${
typeof cf.item_count === "number"
? ` (items=${cf.item_count})`
: ""
}`
: `capacity: ${cf.fit_status}${
cf.mismatch_reason ? ` — ${cf.mismatch_reason}` : ""
}`;
evidenceLines.push(capacityLine);
}
const evidenceTooltip =
evidenceLines.length > 0 ? evidenceLines.join("\n") : undefined;
// Compose final tooltip: existing catalog/reject reasons first, then
// evidence detail (preserves Phase Q tooltip semantics).
const tooltipParts = [
isCatalogMissing
? "⚠ catalog 미등록 — render path 에서 적용 안 됨 (선택해도 backend 가 skip)"
: null,
isFilteredDirect
? "⚠ filtered_for_direct_execution — MVP1 직접 렌더 경로 제외"
: null,
isReject ? "V4 reject — render path 비추천" : null,
evidenceTooltip,
].filter((s): s is string => Boolean(s));
const composedTitle =
tooltipParts.length > 0 ? tooltipParts.join("\n\n") : undefined;
return ( return (
<motion.div <motion.div
key={candidate.id} key={candidate.id}
@@ -95,7 +185,7 @@ export default function FramePanel({
className="w-full" className="w-full"
> >
<button <button
onClick={() => onFrameSelect(candidate.id)} onClick={() => handleFrameSelect(candidate)}
draggable draggable
onDragStart={(e) => { onDragStart={(e) => {
e.dataTransfer.setData("frameId", candidate.id); e.dataTransfer.setData("frameId", candidate.id);
@@ -105,17 +195,13 @@ export default function FramePanel({
? 'border-blue-500 bg-white shadow-xl shadow-blue-500/10' ? 'border-blue-500 bg-white shadow-xl shadow-blue-500/10'
: isCatalogMissing : isCatalogMissing
? 'border-slate-100 bg-slate-50/40 opacity-60 hover:opacity-90 hover:border-amber-200' ? 'border-slate-100 bg-slate-50/40 opacity-60 hover:opacity-90 hover:border-amber-200'
: isFilteredDirect
? 'border-slate-100 bg-slate-50/30 opacity-50 hover:opacity-90 hover:border-amber-200'
: isReject : isReject
? 'border-slate-100 bg-slate-50/30 opacity-50 hover:opacity-90 hover:border-slate-200' ? 'border-slate-100 bg-slate-50/30 opacity-50 hover:opacity-90 hover:border-slate-200'
: 'border-slate-100 bg-slate-50/50 hover:border-slate-200 hover:bg-white' : 'border-slate-100 bg-slate-50/50 hover:border-slate-200 hover:bg-white'
}`} }`}
title={ title={composedTitle}
isCatalogMissing
? "⚠ catalog 미등록 — render path 에서 적용 안 됨 (선택해도 backend 가 skip)"
: isReject
? "V4 reject — render path 비추천"
: undefined
}
> >
{/* Rank Badge */} {/* Rank Badge */}
<div className="absolute top-3 left-3 z-10"> <div className="absolute top-3 left-3 z-10">
@@ -183,7 +269,19 @@ export default function FramePanel({
</span> </span>
)} )}
{/* V4 label badge */} {/* V4 label badge */}
{candidate.label && ( {candidate.label && (() => {
// IMP-41 u3 — applicationMode-keyed Korean consequence
// tooltip with legacy fallback. applicationMode is
// forwarded by designAgentApi.ts (u2) from Step 9
// unit.application_candidates[]; undefined when the
// backend did not emit a mapping for this candidate.
const consequence = candidate.applicationMode
? APPLICATION_MODE_TOOLTIP_KR[candidate.applicationMode]
: undefined;
const badgeTitle = consequence
? `${consequence} (${candidate.applicationMode})`
: `V4 label: ${candidate.label}`;
return (
<span <span
className={`text-[8px] font-black uppercase tracking-tight px-1.5 py-0.5 rounded ${ className={`text-[8px] font-black uppercase tracking-tight px-1.5 py-0.5 rounded ${
candidate.label === "use_as_is" candidate.label === "use_as_is"
@@ -194,10 +292,69 @@ export default function FramePanel({
? "bg-amber-100 text-amber-700" ? "bg-amber-100 text-amber-700"
: "bg-red-100 text-red-700" : "bg-red-100 text-red-700"
}`} }`}
title={`V4 label: ${candidate.label}`} title={badgeTitle}
> >
{candidate.label} {candidate.label}
</span> </span>
);
})()}
{/* IMP-29 u3 — route hint chip (skip when direct_render = default). */}
{showRouteChip && (
<span
className="text-[8px] font-black uppercase tracking-tight px-1.5 py-0.5 rounded bg-slate-100 text-slate-600"
title={`route_hint: ${candidate.routeHint}`}
>
{candidate.routeHint === "deterministic_minor_adjustment"
? "adapt"
: candidate.routeHint === "ai_adaptation_required"
? "ai req"
: candidate.routeHint === "design_reference_only"
? "ref"
: candidate.routeHint}
</span>
)}
{/* IMP-29 u3 — phase_z status warning chip (skip when auto_renderable). */}
{showStatusChip && (
<span
className="text-[8px] font-black uppercase tracking-tight px-1.5 py-0.5 rounded bg-amber-50 text-amber-700"
title={`phase_z_status: ${candidate.phaseZStatus}`}
>
{candidate.phaseZStatus!.replace(/_/g, " ")}
</span>
)}
{/* IMP-29 u3 — capacity_fit indicator (ok = subtle, mismatch = warning). */}
{hasCapacityFit && (
<span
className={`text-[8px] font-black uppercase tracking-tight px-1.5 py-0.5 rounded ${
capacityMismatch
? "bg-amber-100 text-amber-700"
: "bg-slate-100 text-slate-500"
}`}
title={`capacity_fit: ${candidate.capacityFit!.fit_status}${
candidate.capacityFit!.mismatch_reason
? ` — ${candidate.capacityFit!.mismatch_reason}`
: ""
}`}
>
{capacityMismatch
? `fit: ${candidate.capacityFit!.fit_status}`
: "fit ok"}
</span>
)}
{/* IMP-29 u3 — decision badge (Stage 2 contract: surface both selected & skipped). */}
{hasDecision && (
<span
className={`text-[8px] font-black uppercase tracking-tight px-1.5 py-0.5 rounded ${
isSelectedDecision
? "bg-emerald-50 text-emerald-700"
: "bg-red-50 text-red-600"
}`}
title={`decision: ${candidate.decision}${
candidate.reason ? ` — ${candidate.reason}` : ""
}`}
>
{isSkipped ? "skip" : "sel"}
</span>
)} )}
{isSelected && ( {isSelected && (
<div className="flex items-center gap-1 text-[8px] font-black text-emerald-500 uppercase"> <div className="flex items-center gap-1 text-[8px] font-black text-emerald-500 uppercase">
+47 -23
View File
@@ -186,6 +186,12 @@ export default function SlideCanvas({
// 슬라이드 박스 표시 조건 — final.html 있거나 pendingLayout 모드. // 슬라이드 박스 표시 조건 — final.html 있거나 pendingLayout 모드.
const showSlideBox = (finalHtmlUrl || isPendingLayout) && !isPipelineRunning; const showSlideBox = (finalHtmlUrl || isPendingLayout) && !isPipelineRunning;
// IMP-14 (Step 13 A-4) — backend slide_base.html 가 embedded vs standalone CSS
// contract 를 `?embedded=1` query 로 소유. 기존 query string 보존하면서 flag 만 추가.
const embeddedSrc = finalHtmlUrl
? `${finalHtmlUrl}${finalHtmlUrl.includes("?") ? "&" : "?"}embedded=1`
: undefined;
// wrapper 는 scaled 크기를 가지므로 layout 상 fit. 안의 슬라이드는 1280×720 으로 // wrapper 는 scaled 크기를 가지므로 layout 상 fit. 안의 슬라이드는 1280×720 으로
// top-left origin scale 후 wrapper 안에 정확히 맞춤. // top-left origin scale 후 wrapper 안에 정확히 맞춤.
const W_SCALED = SLIDE_W * scale; const W_SCALED = SLIDE_W * scale;
@@ -283,37 +289,21 @@ export default function SlideCanvas({
> >
<iframe <iframe
ref={iframeRef} ref={iframeRef}
src={finalHtmlUrl} src={embeddedSrc}
title="Phase Z 렌더 결과" title="Phase Z 렌더 결과"
className="w-full h-full border-0 block" className="w-full h-full border-0 block"
scrolling="no" scrolling="no"
sandbox="allow-same-origin" sandbox="allow-same-origin allow-scripts"
style={{ pointerEvents: isEditMode ? "auto" : "none" }} style={{ pointerEvents: isEditMode ? "auto" : "none" }}
onLoad={(e) => { onLoad={(e) => {
// final.html 은 standalone 표시용으로 body 에 padding / flex center / // IMP-14 (Step 13 A-4) — embedded vs standalone CSS reset 은 backend
// min-height: 100vh 가 있어서, iframe 안에서는 슬라이드가 잘림. // slide_base.html 가 `?embedded=1` query 로 소유. frontend 가 더 이상
// .slide (1280×720) 만 보이도록 reset CSS 를 contentDocument 에 주입. // reset CSS 를 contentDocument 에 inject 하지 않음. embedded query 가
// backend auto-mode detection script 를 trigger 해서 html.embedded
// class 를 붙이고 standalone-only body 규칙을 reset.
try { try {
const doc = (e.currentTarget as HTMLIFrameElement).contentDocument; const doc = (e.currentTarget as HTMLIFrameElement).contentDocument;
if (!doc) return; if (!doc) return;
const style = doc.createElement("style");
style.textContent = `
html, body {
margin: 0 !important;
padding: 0 !important;
min-height: 0 !important;
height: 720px !important;
width: 1280px !important;
background: transparent !important;
display: block !important;
overflow: hidden !important;
}
.slide {
box-shadow: none !important;
margin: 0 !important;
}
`;
doc.head.appendChild(style);
// 2026-05-14 — slide-level override CSS (catalog/template 무변). // 2026-05-14 — slide-level override CSS (catalog/template 무변).
// Home 이 mdx 별 default visual 보완 (bullet 간격 / zone 비율 등) 지정. // Home 이 mdx 별 default visual 보완 (bullet 간격 / zone 비율 등) 지정.
@@ -612,6 +602,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 +707,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">
+34 -1
View File
@@ -21,6 +21,7 @@ import {
runPipeline, runPipeline,
loadRun, loadRun,
computeZonePositions, computeZonePositions,
formatAiRepairHumanReviewMessage,
type RunMeta, type RunMeta,
type PipelineOverrides, type PipelineOverrides,
} from "../services/designAgentApi"; } from "../services/designAgentApi";
@@ -370,6 +371,13 @@ export default function Home() {
})); }));
setRunMeta(runMeta); setRunMeta(runMeta);
toast.success(`run "${result.run_id}" 완료 — ${runMeta.status}`); toast.success(`run "${result.run_id}" 완료 — ${runMeta.status}`);
// IMP-47B u11 — surface Step 12 AI repair failure axes (error /
// coverage_violated / unsupported_kind) as a human_review notification.
// Auto-pipeline first ([[feedback_auto_pipeline_first]]): no review_queue
// insertion — just an explicit error toast directing the user to pick
// another frame or edit manually. Helper returns null on success path.
const aiReviewMsg = formatAiRepairHumanReviewMessage(runMeta.ai_repair_status);
if (aiReviewMsg) toast.error(aiReviewMsg);
} catch (err) { } catch (err) {
console.error(err); console.error(err);
toast.error( toast.error(
@@ -377,7 +385,7 @@ export default function Home() {
); );
setState((p) => ({ ...p, isLoading: false })); setState((p) => ({ ...p, isLoading: false }));
} }
}, [state.uploadedFile]); }, [state.uploadedFile, state.slidePlan, state.userSelection, pendingZones, pendingLayout]);
// ── 섹션 드래그 앤 드롭 (Zone으로 재배치) ── // ── 섹션 드래그 앤 드롭 (Zone으로 재배치) ──
const handleSectionDrop = useCallback((sectionId: string, zoneId: string) => { const handleSectionDrop = useCallback((sectionId: string, zoneId: string) => {
@@ -527,6 +535,31 @@ export default function Home() {
> >
{runMeta.status} {runMeta.status}
</span> </span>
{runMeta.filtered_section_ids.length > 0 && (
<details className="relative">
<summary className="text-[10px] font-bold px-1.5 py-0.5 bg-amber-100 text-amber-700 rounded uppercase tracking-wider cursor-pointer list-none">
Filtered: {runMeta.filtered_section_ids.length}
</summary>
<div className="absolute top-full mt-1 left-0 z-50 bg-white border border-slate-200 rounded shadow-lg p-3 w-96 max-h-96 overflow-y-auto">
{runMeta.filtered_section_reasons.map((r, i) => (
<div key={i} className="mb-2 pb-2 border-b border-slate-100 last:border-0 last:mb-0 last:pb-0 text-[11px]">
<div className="font-mono text-slate-700">{r.section_ids.join(", ")}</div>
<div className="text-slate-500">selection_state: <span className="font-mono">{r.selection_state}</span></div>
{r.merge_type && <div className="text-slate-500">merge_type: <span className="font-mono">{r.merge_type}</span></div>}
{r.template_id && <div className="text-slate-500">template_id: <span className="font-mono">{r.template_id}</span></div>}
{r.v4_label && <div className="text-slate-500">v4_label: <span className="font-mono">{r.v4_label}</span></div>}
{r.phase_z_status && <div className="text-slate-500">phase_z_status: <span className="font-mono">{r.phase_z_status}</span></div>}
{r.score !== null && <div className="text-slate-500">score: <span className="font-mono">{r.score}</span></div>}
{r.source && <div className="text-slate-500">source: <span className="font-mono">{r.source}</span></div>}
{r.position && <div className="text-slate-500">position: <span className="font-mono">{r.position}</span></div>}
<ul className="mt-1 list-disc list-inside text-slate-600">
{r.filter_reasons.map((reason, j) => <li key={j} className="font-mono">{reason}</li>)}
</ul>
</div>
))}
</div>
</details>
)}
</> </>
)} )}
</div> </div>
+146 -6
View File
@@ -207,6 +207,51 @@ export async function exportSlidePlan(slidePlan: SlidePlan, userSelection: any):
// step20_slide_status.json → 최종 상태 (PASS / RENDERED_WITH_VISUAL_REGRESSION / ...) // step20_slide_status.json → 최종 상태 (PASS / RENDERED_WITH_VISUAL_REGRESSION / ...)
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// IMP-10 D-1 : verbatim mirror of step20_slide_status.json.data.filtered_section_reasons[]
// schema (src/phase_z2_pipeline.py:2217-2278). `source` / `position` only present on
// the override-uncovered additive variant. Strings rendered verbatim — no enum redefinition.
export interface FilteredSectionReason {
section_ids: string[];
merge_type: string | null;
template_id: string | null;
v4_label: string | null;
phase_z_status: string | null;
score: number | null;
selection_state: string;
filter_reasons: string[];
source?: string;
position?: string | null;
}
// IMP-47B u11 — verbatim mirror of step20_slide_status.ai_repair_status (u8 schema).
// Surfaces Step 12 AI repair outcomes so the frontend can render a
// human_review notification when AI proposal validation, coverage, or call
// itself failed. Enum / field names kept verbatim — no frontend redefinition.
export interface AiRepairStatus {
status: "ok" | "applied" | "unsupported_kind" | "coverage_violated" | "error" | string;
counts: {
total: number;
applied: number;
no_proposal: number;
no_zone_match: number;
unsupported_kind: number;
error: number;
};
unsupported_kind_records: Array<{
unit_index?: number | null;
source_section_ids: string[];
apply_status: string;
}>;
error_records: Array<{
unit_index?: number | null;
source_section_ids: string[];
error: string;
}>;
coverage_status: string;
dropped_section_ids: string[];
human_review_required: boolean;
}
export interface RunMeta { export interface RunMeta {
run_id: string; run_id: string;
mdx_path: string; mdx_path: string;
@@ -214,11 +259,45 @@ export interface RunMeta {
status: "PASS" | "RENDERED_WITH_VISUAL_REGRESSION" | "PARTIAL_COVERAGE" | "ABORTED" | string; status: "PASS" | "RENDERED_WITH_VISUAL_REGRESSION" | "PARTIAL_COVERAGE" | "ABORTED" | string;
visual_check_passed: boolean; visual_check_passed: boolean;
full_mdx_coverage: boolean; full_mdx_coverage: boolean;
filtered_section_ids: string[]; // step20 filtered_section_ids
filtered_section_reasons: FilteredSectionReason[]; // step20 filtered_section_reasons
preview_url: string; // /data/runs/{runId}/preview.png preview_url: string; // /data/runs/{runId}/preview.png
final_html_url: string; // /data/runs/{runId}/final.html final_html_url: string; // /data/runs/{runId}/final.html
layout_candidates: string[]; // step07 layout_candidates list layout_candidates: string[]; // step07 layout_candidates list
region_layout_candidates_by_zone: Record<string, string[]>; // step08 placeholder region_layout_candidates_by_zone: Record<string, string[]>; // step08 placeholder
display_strategy_candidates_by_zone: Record<string, string[]>; // step08 placeholder display_strategy_candidates_by_zone: Record<string, string[]>; // step08 placeholder
/** IMP-47B u11 — Step 12 AI repair outcome (u8 surfacing). null when
* step20 omits the field (legacy runs / pipeline aborted before Step 12). */
ai_repair_status: AiRepairStatus | null;
}
/**
* IMP-47B u11 — Build the human_review notification text when Step 12 AI repair
* reports a failure axis. Returns null when no notification is needed (success,
* no AI invocation, or human_review_required=false). Pure function — no DOM, no
* toast side-effect — so it can be unit-tested without React Testing Library.
*
* Failure axes mapped to user-facing text (verbatim policy from
* IMP-47B #76 guardrail: "AI 호출 실패 / proposal validation 실패 / coverage 미달
* → frontend 에 명확한 notification").
*/
export function formatAiRepairHumanReviewMessage(
ai: AiRepairStatus | null | undefined,
): string | null {
if (!ai || !ai.human_review_required) return null;
if (ai.status === "error") {
const n = ai.counts?.error ?? ai.error_records?.length ?? 0;
return `AI 재구성 호출 실패 (${n}건) — 다른 frame 선택 또는 수동 편집 필요`;
}
if (ai.status === "coverage_violated") {
const dropped = (ai.dropped_section_ids || []).join(", ");
return `AI 재구성 후 콘텐츠 누락 (dropped: ${dropped || "?"}) — 다른 frame 선택 또는 수동 편집 필요`;
}
if (ai.status === "unsupported_kind") {
const n = ai.counts?.unsupported_kind ?? ai.unsupported_kind_records?.length ?? 0;
return `AI 제안 형식 미지원 (${n}건) — 다른 frame 선택 또는 수동 편집 필요`;
}
return `AI 재구성 human_review 필요 (status: ${ai.status})`;
} }
export interface LoadRunResult { export interface LoadRunResult {
@@ -393,6 +472,8 @@ export async function loadRun(runId: string): Promise<LoadRunResult> {
status: slideStatus.data?.overall ?? "UNKNOWN", status: slideStatus.data?.overall ?? "UNKNOWN",
visual_check_passed: slideStatus.data?.visual_check_passed ?? false, visual_check_passed: slideStatus.data?.visual_check_passed ?? false,
full_mdx_coverage: slideStatus.data?.full_mdx_coverage ?? false, full_mdx_coverage: slideStatus.data?.full_mdx_coverage ?? false,
filtered_section_ids: slideStatus.data?.filtered_section_ids ?? [],
filtered_section_reasons: slideStatus.data?.filtered_section_reasons ?? [],
preview_url: `${base}/preview.png`, preview_url: `${base}/preview.png`,
final_html_url: `${base}/final.html`, final_html_url: `${base}/final.html`,
layout_candidates: layout.data?.layout_candidates ?? [], layout_candidates: layout.data?.layout_candidates ?? [],
@@ -408,6 +489,7 @@ export async function loadRun(runId: string): Promise<LoadRunResult> {
z.display_strategy_candidates ?? [], z.display_strategy_candidates ?? [],
]) ])
), ),
ai_repair_status: (slideStatus.data?.ai_repair_status ?? null) as AiRepairStatus | null,
}; };
// ── NormalizedContent ── // ── NormalizedContent ──
@@ -483,17 +565,51 @@ export async function loadRun(runId: string): Promise<LoadRunResult> {
restructure: 2, restructure: 2,
reject: 3, reject: 3,
}; };
const rawSource = (unit.v4_all_judgments?.length > 0) // IMP-29 u2 — source priority (deterministic, no LLM):
? unit.v4_all_judgments // 1) unit.candidate_evidence (IMP-05 L2 canonical, 14 fields per entry)
: (unit.v4_candidates ?? []); // 2) unit.v4_all_judgments (pre-IMP-05 audit array)
// 3) unit.v4_candidates (legacy minimal)
// fallback_chain alias is intentionally NOT read (Stage 2 guardrail).
const candidateEvidence = Array.isArray(unit.candidate_evidence)
? unit.candidate_evidence
: [];
const candidateMap = new Map<string, any>();
const pushCandidate = (c: any) => {
if (!c) return;
const key = c.template_id ?? c.id ?? c.frame_id;
if (!key) return;
if (!candidateMap.has(key)) candidateMap.set(key, c);
};
candidateEvidence.forEach(pushCandidate);
(unit.v4_all_judgments ?? []).forEach(pushCandidate);
(unit.v4_candidates ?? []).forEach(pushCandidate);
const rawSource = Array.from(candidateMap.values());
const v4Source = [...rawSource].sort((a: any, b: any) => { const v4Source = [...rawSource].sort((a: any, b: any) => {
const lp = (LABEL_PRIORITY[a.label] ?? 99) - (LABEL_PRIORITY[b.label] ?? 99); const lp = (LABEL_PRIORITY[a.label] ?? 99) - (LABEL_PRIORITY[b.label] ?? 99);
if (lp !== 0) return lp; if (lp !== 0) return lp;
return (b.confidence ?? 0) - (a.confidence ?? 0); return (b.confidence ?? 0) - (a.confidence ?? 0);
}); });
// ─── IMP-41 u2 — application_candidates enrichment (issue #70) ───────────
// Backend Step 9 emits `unit.application_candidates[]` (src/phase_z2_pipeline.py
// _application_candidates_for_unit, :3071-3092) one entry per v4 candidate with
// application_mode / auto_applicable / delegated_to derived from
// APPLICATION_MODE_BY_V4_LABEL (:107-112). Enrichment ONLY — does NOT alter
// candidate source priority, sorting, or TOP_N_FRAMES slicing.
const applicationCandidates: any[] = Array.isArray(unit.application_candidates)
? unit.application_candidates
: [];
const applicationModeMap = new Map<string, any>();
applicationCandidates.forEach((ac: any) => {
const key = ac?.template_id;
if (typeof key === "string" && key.length > 0) {
applicationModeMap.set(key, ac);
}
});
const frameCandidates: FrameCandidate[] = v4Source const frameCandidates: FrameCandidate[] = v4Source
.slice(0, TOP_N_FRAMES) .slice(0, TOP_N_FRAMES)
.map((c: any) => ({ .map((c: any) => {
const appMatch = applicationModeMap.get(c.template_id);
return ({
id: c.template_id, id: c.template_id,
name: c.template_id, name: c.template_id,
score: c.confidence ?? 0, score: c.confidence ?? 0,
@@ -505,9 +621,33 @@ export async function loadRun(runId: string): Promise<LoadRunResult> {
? `/frame-preview/${String(c.frame_number).padStart(2, "0")}` ? `/frame-preview/${String(c.frame_number).padStart(2, "0")}`
: undefined, : undefined,
// backend step09 의 catalog_registered (frame_contracts.yaml 등록 여부). // backend step09 의 catalog_registered (frame_contracts.yaml 등록 여부).
// v4_all_judgments 에만 있음. v4_candidates fallback 시 undefined. // candidate_evidence 및 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 에만 있음. candidate_evidence / v4_candidates fallback 시 undefined (graceful).
minHeightPx: c.min_height_px ?? undefined,
// ─── IMP-05 L2 candidate_evidence fields (IMP-29 u2) ─────────────────
// Populated when source = unit.candidate_evidence; otherwise silently
// undefined for legacy fixtures (pre-IMP-05 fallback path).
rank: c.rank,
frameId: c.frame_id,
v4Label: c.v4_label,
phaseZStatus: c.phase_z_status,
filteredForDirectExecution: c.filtered_for_direct_execution,
routeHint: c.route_hint,
decision: c.decision,
reason: c.reason,
capacityFit: c.capacity_fit,
// ─── IMP-41 u2 — application_mode forwarding (issue #70) ───────────
// Source = unit.application_candidates[] indexed by template_id above.
// Optional fields — undefined when no matching application_candidate
// (legacy fixtures pre-IMP-32 or candidates filtered out at Step 9).
applicationMode: appMatch?.application_mode,
autoApplicable: appMatch?.auto_applicable,
delegatedTo: appMatch?.delegated_to ?? null,
});
});
const displayStrategy = ( const displayStrategy = (
runMeta.display_strategy_candidates_by_zone[posEntry.name]?.[0] ?? runMeta.display_strategy_candidates_by_zone[posEntry.name]?.[0] ??
+61
View File
@@ -116,6 +116,23 @@ export interface InternalRegion {
frame_candidates: FrameCandidate[]; frame_candidates: FrameCandidate[];
} }
/** IMP-05 L2 candidate_evidence.capacity_fit — backend capacity vs. content shape audit.
* Source = src/phase_z2_pipeline.py compute_capacity_fit(). All fields optional —
* frontend tolerates absence for pre-IMP-05 fixtures and contract-less templates. */
export interface CapacityFitEvidence {
item_count?: number | null;
source_shape?: string | null;
capacity?: {
strict?: number | null;
min?: number | null;
max?: number | null;
truncate_at?: number | null;
pad_to?: number | null;
} | null;
fit_status?: string | null;
mismatch_reason?: string | null;
}
/** 프레임 후보 (V4 매칭 결과) */ /** 프레임 후보 (V4 매칭 결과) */
export interface FrameCandidate { export interface FrameCandidate {
id: string; id: string;
@@ -127,6 +144,50 @@ 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;
// ─── IMP-05 L2 candidate_evidence fields (IMP-29 u1) ───────────────────────
// Source = src/phase_z2_pipeline.py lookup_v4_match_with_fallback() candidate_trace.
// All fields optional — pre-IMP-05 fixtures fall back to v4_all_judgments/v4_candidates
// (deterministic, no LLM) and silently leave these undefined.
/** Candidate rank in V4 chain (1-based; 1 = primary). */
rank?: number;
/** Figma frame node id (backend `frame_id`). Distinct from `id` (= template_id). */
frameId?: string;
/** Alias of `label`. Kept separate for Codex IMP-05 L2 schema parity. */
v4Label?: 'use_as_is' | 'light_edit' | 'restructure' | 'reject';
/** Phase Z status enum (e.g. "auto_renderable", "fallback_candidate"). Open vocabulary. */
phaseZStatus?: string;
/** True when status is outside MVP1_ALLOWED_STATUSES (= excluded from direct render path). */
filteredForDirectExecution?: boolean;
/** Execution route mapped from `label` (direct_render / deterministic_minor_adjustment /
* ai_adaptation_required / design_reference_only). Null on unknown labels. */
routeHint?: 'direct_render' | 'deterministic_minor_adjustment' | 'ai_adaptation_required' | 'design_reference_only' | null;
/** Selection outcome ("selected" or "skipped"). */
decision?: 'selected' | 'skipped';
/** Human-readable rationale (e.g. "primary_selected", "fallback_selected",
* "duplicate_template_id", "skipped_no_contract", "capacity_mismatch:...",
* "phase_z_status_not_allowed:..."). */
reason?: string | null;
/** Capacity vs. content shape audit (compute_capacity_fit output). */
capacityFit?: CapacityFitEvidence | null;
// ─── IMP-41 application_mode forwarding (issue #70 u1) ─────────────────────
// Source = src/phase_z2_pipeline.py APPLICATION_MODE_BY_V4_LABEL (:107-112),
// emitted by _application_candidates_for_unit() into Step 9
// unit.application_candidates[]. Optional — legacy fixtures pre-IMP-32 omit
// these and the FramePanel tooltip falls back to the raw V4 label.
/** Application mode mapped from V4 label by backend (authoritative). */
applicationMode?: 'direct_insert' | 'same_frame_with_adjustment' | 'layout_or_region_change' | 'exclude';
/** True when backend marks the candidate as automatically applicable. */
autoApplicable?: boolean;
/** Delegation target step / actor (e.g. "step10_contract_check", "human_review"). */
delegatedTo?: string | null;
} }
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
+4 -3
View File
@@ -346,8 +346,10 @@ function vitePluginPhaseZApi(): Plugin {
const pythonExe = process.platform === "win32" ? "python.exe" : "python"; const pythonExe = process.platform === "win32" ? "python.exe" : "python";
// 2026-05-14 — env toggle forward (보고용 일회성). // 2026-05-14 — env toggle forward (보고용 일회성).
// PHASE_Z_ALLOW_RESTRUCTURE / PHASE_Z_ALLOW_REJECT : status 통과 // PHASE_Z_ALLOW_RESTRUCTURE / PHASE_Z_ALLOW_REJECT : status 통과
// PHASE_Z_MAX_RANK=32 : V4 fallback chain 의 max_rank 확대 (등록 frame 까지 검색) // 2026-05-21 — IMP-38 retire PHASE_Z_MAX_RANK env (never read by backend).
// 04-1 (all reject) / 05-2 (rank 1~3 미등록) 등 자동 매칭 가능. // v4 fallback chain max_rank 는 templates/phase_z2/catalog/v4_fallback_policy.yaml 의
// 정식 정책 (dynamic_usable_count_based) 으로 결정 — backend src/phase_z2_pipeline.py
// 의 lookup_v4_match_with_fallback() 가 load_v4_fallback_policy() 로 적용.
const proc = spawn(pythonExe, cliArgs, { const proc = spawn(pythonExe, cliArgs, {
cwd: DESIGN_AGENT_ROOT, cwd: DESIGN_AGENT_ROOT,
shell: false, shell: false,
@@ -355,7 +357,6 @@ function vitePluginPhaseZApi(): Plugin {
...process.env, ...process.env,
PHASE_Z_ALLOW_RESTRUCTURE: "1", PHASE_Z_ALLOW_RESTRUCTURE: "1",
PHASE_Z_ALLOW_REJECT: "1", PHASE_Z_ALLOW_REJECT: "1",
PHASE_Z_MAX_RANK: "32",
}, },
}); });
+135
View File
@@ -0,0 +1,135 @@
# Dormant trigger registry (L3 layer — machine-readable).
#
# Purpose :
# Closed-but-binding dormant backlog rows ("documented:dormant" /
# "documented (deferred)") carry implicit "trigger-on-X" contracts.
# L1 (human memory) + L2 (periodic INTEGRATION-AUDIT) are fragile / late.
# This file is the single source of truth that scripts/check_dormant_triggers.py
# reads to flag activation candidates on every orchestrator run.
#
# Schema (per entry) :
# - issue : int # closed Gitea issue id (the dormant axis)
# - title : string
# - doc : string # repo-relative path to the dormant reference doc
# - doc_evidence_lines : string # "start-end" line range citing the activation-gate text
# - status : enum # documented:dormant | documented:deferred | documented:no-runtime | followup-linked
# - followup_issue : int|null # set when an open issue already tracks the watch (then no checker watch needed)
# - trigger
# description : string
# file_patterns : [glob] # working-tree paths checked against changed files
# content_patterns : [regex] # python re patterns matched against changed-file contents
# manual_evidence_required : bool # true → checker skips (human-only gate; e.g. User GO, sign-off, runtime regression analysis)
# - on_trigger
# action : enum # create_runtime_issue | reactivate_dormant | manual_review | note_only
# template : string # suggested follow-up issue title (if action ≠ note_only)
#
# Guardrails :
# - Checker is informational only (exit 0 always; orchestrator never blocks Stage 5 on alerts).
# - manual_evidence_required: true entries do NOT auto-fire — they are noted for human review.
# - followup_issue is set: the registry entry is note-only; no checker watch (the open issue tracks the axis).
# - Out of scope for this registry : IMP-07 (documented:no-runtime — policy decline, reactivation = policy reopen, not a code trigger).
- issue: 16
title: "IMP-16 U2 wiring (Phase Q U1 → Phase Z runtime)"
doc: docs/architecture/IMP-16-U2-WIRING-DESIGN.md
doc_evidence_lines: "21-25"
status: documented:dormant
followup_issue: null
trigger:
description: >-
IMP-07 reverse-path actually lands runtime — a non-test module under src/
introduces the reverse-path adapter (html_to_slide_mdx / edited_html_to_mdx /
reverse_path). At that point IMP-16 U2 wiring (Step 1/2/14 surface use)
becomes a live integration axis, not a paper design.
file_patterns:
- "src/**/*.py"
content_patterns:
- "html_to_slide_mdx"
- "edited_html_to_mdx"
- "reverse_path"
manual_evidence_required: false
on_trigger:
action: create_runtime_issue
template: "[IMP-16][P5][WIRING] Activate U2 reverse-path wiring against new IMP-07 adapter"
- issue: 17
title: "IMP-17 AI repair fallback carve-out"
doc: docs/architecture/IMP-17-CARVE-OUT.md
doc_evidence_lines: "25-31"
status: documented:dormant
followup_issue: null
trigger:
description: >-
3-condition AND gate: (1) explicit User GO for axis activation,
(2) B4 frame_selection evidence integration complete (Step 9 evidence trace
stabilised), (3) IMP-04 (catalog expansion to 32 frames) + IMP-05 (V4
rank-2/3 fallback) live. All three required before the carve-out exits
design-only state.
file_patterns: []
content_patterns: []
manual_evidence_required: true
on_trigger:
action: manual_review
template: "[IMP-17][P5][CARVE-OUT] Activate ai_adaptation_required fallback (3-cond gate cleared)"
- issue: 18
title: "IMP-18 SVG coordinate pipeline gap report"
doc: docs/architecture/IMP-18-SVG-GAP-REPORT.md
doc_evidence_lines: "38-43"
status: documented:dormant
followup_issue: null
trigger:
description: >-
An SVG-bearing partial lands under templates/phase_z2/ (families or frames)
AND the partial declares slots consuming items[*].cx/cy/r + outer_r +
viewbox_* (the prepare_venn_data return contract). IMP-04 frame_partials
registration is the natural upstream.
file_patterns:
- "templates/phase_z2/families/*.html"
- "templates/phase_z2/frames/*.html"
content_patterns:
- "<svg"
- "viewBox"
manual_evidence_required: false
on_trigger:
action: create_runtime_issue
template: "[IMP-18][P5][SVG] Activate SVG coordinate pipeline for new partial"
- issue: 19
title: "IMP-19 zone ratio reference (Phase O role-container pattern)"
doc: docs/architecture/IMP-19-ZONE-RATIO-REFERENCE.md
doc_evidence_lines: "83-90"
status: documented:dormant
followup_issue: null
trigger:
description: >-
Phase Z Step 8 solver (min_height_first + content_weight) produces a
verifiable regression that the Phase O role-container pattern would have
handled correctly, AND the IMP-09 owner confirms the case is not
addressable inside the Phase Z solver (visual_hints.min_height_px /
content_weight.score adjustments insufficient). Requires failing-case MDX
+ frame_contract trace + observed vs expected geometry.
file_patterns: []
content_patterns: []
manual_evidence_required: true
on_trigger:
action: manual_review
template: "[IMP-19][P5][ZONE-RATIO] Re-activate Phase O role-container pattern (IMP-09 sign-off attached)"
- issue: 20
title: "IMP-20 frame contract validation reference"
doc: docs/architecture/IMP-20-FRAME-CONTRACT-VALIDATION-REFERENCE.md
doc_evidence_lines: "85-91"
status: followup-linked
followup_issue: 55
trigger:
description: >-
§A5 3-cond AND gate (Step 10 partial frame-contract emit insufficient +
evidence + IMP-04 sign-off). Watch surface already owned by open issue
#55 — no checker watch installed here to avoid double-tracking.
file_patterns: []
content_patterns: []
manual_evidence_required: true
on_trigger:
action: note_only
template: "Tracked under open issue #55 — no new watch needed."
@@ -0,0 +1,84 @@
# IMP-16-U2 — Phase Z verification wiring design (design-only)
> **⚠️ STATUS UPDATE (2026-05-20, INTEGRATION-AUDIT-02)** — IMP-16 is reclassified
> as `documented:dormant` and IMP-07 as `documented:no-runtime`. The 3 "Open items
> deferred until IMP-07 lands" below remain dormant until IMP-07 reverse-path
> actually lands runtime (no current plan).
>
> Resolution evidence: see `INTEGRATION-AUDIT-02-REPORT.md` Sections 3, 4, and 7
> (final decision: `NEEDS_DOC_SYNC_FOLLOWUP`).
>
> Do NOT treat this design contract as actionable in current Phase Z runtime.
**Status**: design-only contract. **No runtime wiring lands in this issue.** All wiring is gated behind IMP-07 reverse-path activation (B-2 main). When IMP-07 lands, this doc becomes the binding contract for the Step 1 / 2 / 14 / 21 / 22 changes that consume the IMP-16-U1 surface in `src/phase_z2_verification_utils.py`.
**Source anchors**
- IMP-16 backlog row — [`docs/architecture/PHASE-Z-IMPLEMENTATION-ISSUE-BACKLOG.md`](PHASE-Z-IMPLEMENTATION-ISSUE-BACKLOG.md):67 (priority ↓ low, hard link IMP-07, source §3 H3 Reference Only).
- IMP-07 backlog row — same doc line 51 (status `pending`).
- 22-step pipeline anchor — [`PHASE-Z-PIPELINE-OVERVIEW.md`](PHASE-Z-PIPELINE-OVERVIEW.md) Steps 1 / 2 / 14 / 21 / 22.
- U1 module — `src/phase_z2_verification_utils.py` (u1~u10 ports).
- Phase Q reference H3 (Reference Only — do not import) — `src/content_verifier.py`.
## Gate (hard block — do not merge wiring before this clears)
- IMP-07 status MUST be `implemented` and `verified` before any code change listed below lands.
- Repo grep `html_to_slide_mdx | edited_html_to_mdx | reverse_path` MUST return at least one runtime hit in a non-test module under `src/`.
- The reverse-path entry point MUST emit (a) a normalized re-entry MDX string and (b) the upstream generated HTML string, both as deterministic outputs accessible to Step 2 and Step 14 callers.
## Per-step wiring contract
### Step 1 — MDX upload (re-entered MDX validation)
- Caller : the reverse-path adapter introduced by IMP-07, immediately after it produces a re-entry MDX.
- Surface used : u6 `split_into_sentences` (validate that the reverse-path MDX yields at least one sentence after meta-strip + bullet-marker strip).
- Behavior : if `split_into_sentences(reentry_mdx)` returns an empty list, the reverse-path adapter MUST raise a deterministic input error before Step 2 starts. No silent fallback. No AI call. No content rewrite.
- Trace : `debug.json["step01"]["reentry_sentence_count"]` (additive integer field).
### Step 2 — MDX normalize (text preservation cross-check)
- Caller : `parse_mdx` / `align_sections_to_v4_granularity` post-normalize hook (added only when the input came through the IMP-07 reverse path; original-upload path is unchanged).
- Surface used : u8 `verify_text_preservation(reentry_mdx, upstream_generated_html, area_name="reentry_mdx_vs_upstream_html")`.
- Threshold : the U1 module default (`_TEXT_PRESERVATION_DEFAULT_THRESHOLD = 0.70`, ported verbatim from Phase Q). Do not redesign in U2.
- Behavior : `VerificationResult.passed == False` → adapter aborts the re-entry with the result's `errors` list surfaced; auto pipeline does NOT silently continue. Per `feedback_auto_pipeline_first`, no `review_required` / `review_queue` is inserted — adapter abort is the deterministic outcome.
- Trace : `debug.json["step02"]["reentry_text_preservation"] = {passed, score, area_name, missing_count}` (additive; missing sentences themselves NOT serialised, per privacy-by-default).
### Step 14 — Selenium visual runtime check (invented-text guard)
- Caller : the `run_overflow_check` post-render path, ONLY when the run was triggered from the reverse-path re-entry. Original-upload path keeps current Step 14 behavior unchanged (this is NOT an enhancement of Step 14 image/table coverage — that axis belongs to IMP-15).
- Surface used : u9 `detect_invented_text(reentry_mdx, final_html)` against the just-rendered `final.html`.
- Behavior : the returned `list[str]` is purely *telemetry*. It does NOT change render outcome and does NOT change `compute_slide_status` (Step 20). The reverse-path may consult the list to decide whether to surface a warning at Step 22 — but auto pipeline does not gate on it (per `feedback_auto_pipeline_first` + AI-isolation contract).
- Trace : `debug.json["step14"]["reentry_invented_text_fragments"] = list[str]` (additive; already truncated by u9's `_INVENTED_TEXT_TRUNCATE_LEN = 80`).
### Step 21 — Debug / trace recording (additive only)
- Surface used : none (Step 21 consumes the additive fields written by Step 1 / 2 / 14 above).
- Behavior : `write_debug_json` MUST treat the new fields as additive — no rename, no removal, no schema regression of existing keys. Missing fields (original-upload path) MUST be absent rather than null, so downstream consumers can distinguish "original upload" from "reverse-path re-entry".
- Trace contract : the three additive fields above + a single new flag `debug.json["pipeline"]["reverse_path_reentry"] = bool` (the only schema field that gates the existence of the other three).
### Step 22 — User confirmation / export (surface, no AI)
- Surface used : none directly (Step 22 is UI scope, currently CLI-only — see PHASE-Z-PIPELINE-OVERVIEW Step 22).
- Behavior contract for whoever lands Step 22 UI : Step 22 MAY render the additive Step 2 / Step 14 fields read-only. No write-back. No AI call. No content rewrite.
## Redesigned frame-contract pattern dict (reserved, NOT delivered in U2)
- Phase Q `REQUIRED_PATTERNS` (Phase Q reference: `src/content_verifier.py:382`) is `body_bg / core / sidebar / footer` — these are Phase Q *area* names, not Phase Z entities. **Values are NOT reused.**
- Phase Z replacement will be keyed on (frame_id, frame_slot_id) per the canonical hierarchy `Slide → Zone → Internal Region → Frame → Frame Slot → Content` ([`PHASE-Z-PIPELINE-OVERVIEW.md`](PHASE-Z-PIPELINE-OVERVIEW.md) §Operating Principles), and will be sourced from `templates/phase_z2/catalog/frame_contracts.yaml` (Step 0 / Step 10).
- **Out of scope for IMP-16-U2.** This belongs to IMP-20 (H2 frame contract validation — same backlog doc line 71). U2 must not ship a pattern dict; U2 must not import or wrap Phase Q `verify_structure` / `verify_area` / `verify_all_areas`.
## Guardrails (binding)
- **AI isolation contract** — all wiring above is deterministic. No LLM / Kei / httpx / SSE call on any path. (per `feedback_ai_isolation_contract` + `PZ-1: AI=0 normal`.)
- **No-hardcoding** — U2 ports the algorithm. The only literal values reused are the Phase Q H3 thresholds already lifted to named constants in u7 / u8 / u9. No sample-specific value (MDX 03 / 04 / 05) enters U2.
- **No `src.content_verifier` import** — under any condition. The U1 module is the sole Phase Z surface.
- **No FORBIDDEN_KEI_MEMOS / `generate_with_retry` port** — these are H4 / H5 archive markers and remain out of scope.
- **Schema additive only** — debug.json keys listed above are new; no existing key is renamed, removed, or repurposed. (per `feedback_artifact_status_naming` — final.html is not the same axis as preservation / invented-text telemetry.)
- **Spacing direction** — N/A for this axis (this is verification, not layout). No common CSS / padding / tolerance shrinking is introduced.
- **Status semantics** — Step 20 `compute_slide_status` is NOT changed by U2. Preservation / invented-text fields are *telemetry*; they do not flip `PASS` → `RENDERED_WITH_VISUAL_REGRESSION` on their own.
## Rollback
- All changes are additive: the Step 1 input-error path, the Step 2 post-normalize hook, the Step 14 telemetry call, the four new `debug.json` keys.
- Rollback = revert the IMP-07 reverse-path entry's call sites; no schema migration needed because the four debug.json keys are gated on `pipeline.reverse_path_reentry`.
## Open items deferred until IMP-07 lands
- Exact module path of the IMP-07 reverse-path adapter (TBD by IMP-07).
- Whether Step 2's preservation cross-check needs a per-section variant or only a whole-MDX variant — depends on whether IMP-07 emits a single re-entry MDX or per-section MDX fragments.
- Whether Step 14's invented-text telemetry should be emitted per `area_name` or only once globally — depends on whether IMP-07's reverse-path produces area-tagged HTML.
These are NOT resolved here. They are resolved at IMP-07 land time, in a follow-up update to this doc.
+55
View File
@@ -0,0 +1,55 @@
# IMP-17 — AI repair fallback infrastructure (carve-out)
**Status**: carve-out infra **scaffolded under IMP-33** (issue #61, Stage 3 u1~u11). Normal-path AI calls = 0 (PZ-1) — `ai_fallback_enabled` flag default `False` in `src/config.py`. Runtime AI is reachable only via fallback path entry points; Step 12 entry is provisional-gated, Step 17 entry is structurally blocked behind IMP-34 + IMP-35.
**Source anchors**
- IMP-17 backlog row — [`PHASE-Z-IMPLEMENTATION-ISSUE-BACKLOG.md`](PHASE-Z-IMPLEMENTATION-ISSUE-BACKLOG.md):68 (carve-out — normal path 밖, soft link IMP-04 + IMP-05).
- INSIGHT-MAP §3 — [`PHASE-Q-INSIGHT-TO-22STEP-MAP.md`](PHASE-Q-INSIGHT-TO-22STEP-MAP.md) (G3 AI repair fallback infra registry row, normal path = no).
- 22-step pipeline — [`PHASE-Z-PIPELINE-OVERVIEW.md`](PHASE-Z-PIPELINE-OVERVIEW.md) Step 12 (lines 280-287), Step 16 (lines 318-325), Step 17 (lines 326-333).
- Pattern shape reference (Phase Q Archive — link-only, do not port) — `src/content_editor.py:21,318` (httpx + retry shape, imports `sse_utils`) + `src/sse_utils.py:16-50` (SSE token parser).
- Route hint surface (current anchors) — `src/phase_z2_pipeline.py:570` (conceptual comment), `:572` (`_IMP05_ROUTE_HINTS` table), `:575` (`restructure` → `ai_adaptation_required`), `:580` (`_imp05_route_hint`), `:664` (candidate_evidence emission). Deterministic emission today; AI consumer deferred to IMP-17 (this carve-out). Anchor pin: `tests/orchestrator_unit/test_imp17_comment_anchor.py`.
## Carve-out boundary
### Allowed (fallback path only)
- **Step 12**: when V4 emits `restructure` (route hint `ai_adaptation_required`) AND deterministic mapping cannot satisfy the frame contract, an AI proposal MAY be invoked to map `content_object` → `Internal Region` / `Frame Slot`. Output = placement proposal at content-object granularity. Frame selection, layout selection, zone topology remain deterministic.
- **Step 16 / 17**: when retry router exhausts deterministic actions (zone_ratio_retry / layout_adjust / frame_reselect / details_popup_escalation / image_fit_candidate / frame_internal_fit_candidate) AND user-approved fallback budget remains, an AI proposal MAY be invoked. Output scope identical to Step 12 — content-object placement only.
### Forbidden (any path)
- Normal-path AI calls (Step 12 deterministic mapper, all other steps).
- MDX 원문 요약·삭제·재작성 (Phase Z spacing direction guardrail: never compress text).
- HTML / CSS 직접 생성, frame contract 신설, layout / zone topology 결정 (Layer-A / Layer-B planning은 코드 영역).
- 공통 padding / spacing / tolerance 축소 (PZ-4 — no silent shrink).
- 신규 IMP ID 발급 (이 carve-out 은 IMP-17 슬롯에 영구 귀속).
## Activation gate (3-condition AND — all three required)
1. **User GO** — 명시적 axis activation 요청. carve-out 자체로는 코드 작성 트리거 안 됨.
2. **B4 frame_selection evidence integration complete** — Step 9 frame_selection 의 evidence trace 가 안정화되어야 fallback proposal 이 어떤 frame contract 안에서 동작해야 하는지 식별 가능.
3. **IMP-04 (catalog 확장) + IMP-05 (V4 fallback) live** — 카탈로그가 32 frame 으로 확장되고 V4 rank-2/3 fallback 이 활성화돼야 `ai_adaptation_required` 라우트가 실제 의미를 가짐 (현재는 dead-end route hint).
세 조건 중 하나라도 미충족이면 본 carve-out 은 design-only 상태로 잠겨 있다.
## Pattern shape reference (link-only, do not import)
Phase Q `content_editor.py` 는 **Archive Candidate** ([`PHASE-Q-AUDIT.md`](PHASE-Q-AUDIT.md):660-673) — 포팅 대상 아님. 모양만 참조한다:
- httpx async streaming + retry 구조 — `src/content_editor.py:21,318` 라인 부근 (import + `stream_sse_tokens` 호출 site).
- SSE token 파서 분리 모듈 — `src/sse_utils.py:16-50` (`stream_sse_tokens` 본체).
- `EDITOR_PROMPT` (Kei persona) 및 Kei-API endpoint 는 **영구 단절**. 재사용 금지.
## AI 격리 + Kei persona 단절 contract
- AI 호출은 normal path 에 없다 (Phase Z 원칙, [memory `feedback_ai_isolation_contract`](../../README.md)).
- 출력 단위는 항상 content_object / Internal Region / Frame Slot 또는 restructuring proposal — HTML 구조 / 레이아웃 / 프리셋 결정 X.
- Phase Q 자산 (Kei persona prompts, Kei-API endpoint, persona retry semantics) 과 단절. Phase Z 의 fallback runtime 은 별도 prompt / endpoint 설계로 출발한다 (본 carve-out 활성 시).
## Runtime module surface (IMP-33 u1~u11 binding)
| Axis | Binding |
|---|---|
| Module path | `src/phase_z2_ai_fallback/` (locked by [`IMP-31-GATE-AUDIT.md`](IMP-31-GATE-AUDIT.md):31,50,56). |
| Step 12 entry | `src.phase_z2_ai_fallback.step12.gather_step12_ai_repair_proposals` — IMP-30 provisional gate (`not_provisional` skip) AND reject gate (`design_reference_only_no_ai` skip) AND non-AI route catch-all run BEFORE `route_ai_fallback`. |
| Step 17 entry | `src.phase_z2_ai_fallback.step17.gather_step17_ai_repair_proposals` — STRUCTURALLY BLOCKED. Every unit returns `skip_reason="step17_ai_blocked_imp_34_35_prerequisites_missing"`. Module does NOT import `route_ai_fallback` / `AiFallbackClient` / `anthropic`. |
| Cascade order | `src.phase_z2_ai_fallback.step17.OVERFLOW_CASCADE_ORDER = (DETERMINISTIC, POPUP, AI_REPAIR, USER_OVERRIDE)` — single source of truth for Step 17 consumers. Aligns with line 16 of this doc. |
| IMP-46 cache gate | `src.phase_z2_ai_fallback.cache.save_proposal(..., visual_check_passed, user_approved, auto_cache=False)` raises `AiFallbackCacheGateError` unless `visual_check_passed=True` AND (`user_approved=True` OR `auto_cache=True`). Persistent JSON backend at `data/frame_cache/{frame_id}/{signature_hash}.json` (u2); cache key = structural signature over 8 axes (u1+u4); read-side fingerprint invalidation via `read_proposal(..., fingerprints=...)` strict equality (u3); `--auto-cache` CLI flag + `settings.ai_fallback_auto_cache` (default `False`) bypasses ONLY the `user_approved` gate (u5); repo root tracked via `data/frame_cache/.gitkeep` with cached payloads git-ignored (u6). `read_proposal` returns `None` on missing / corrupt / fingerprint-mismatched entries — cache is a hint, never a hard dependency. |
| AST isolation | `tests/phase_z2_ai_fallback/test_ast_isolation.py` parses every `*.py` under `src/phase_z2_ai_fallback/` and forbids Phase Q runtime / Kei client / `src.phase_z2_*` (non-fallback) imports. Whitelist = `src.config` + intra-package + stdlib + `anthropic` + `pydantic`. |
@@ -0,0 +1,64 @@
# IMP-18 — Phase Z SVG Coordinate Pre-compute Gap Report
**Status**: documented (reference-only, dormant)
**Scope**: doc-only. No runtime surface modified.
**Related issue**: https://gitea.hmac.kr/Kyeongmin/C.E.L_Slide_test2/issues/18
**Soft dependency**: IMP-04 (frame_partials registration) — IMP-18 activates only when a SVG-bearing partial lands under `templates/phase_z2/`.
---
## A1 — Phase R' source pattern (read-only reference)
Phase R' implements SVG coordinate pre-compute as a renderer hook. References (do **not** modify):
- `src/renderer.py:169-207` — `_preprocess_svg_data(block_type, block_data)` — mutates `block_data` with computed coordinates when `block_type` ∈ `SVG_BLOCKS`; warns and falls back on exception.
- `src/renderer.py:175` — `SVG_BLOCKS = {"venn-diagram", "relationship"}` — exhaustive type allow-list.
- `src/renderer.py:321` — call site inside `render_multi_page()` (`block_data = _preprocess_svg_data(block_type, block_data)`), right before `_resolve_template_path` lookup.
- `src/svg_calculator.py:15-156` — five helpers:
- L15 `calc_circle_positions(n, center_x, center_y, radius)` — 12 o'clock clockwise N-element layout.
- L47 `calc_item_radius(n, base_radius=75.0)` — auto-shrink small-circle radius for crowding.
- L59 `calc_orbit_radius(n, base_orbit=120.0)` — auto-expand orbit for crowding.
- L70 `calc_outer_radius(n, orbit_radius, item_radius)` — outer enclosing circle, 40 px margin.
- L77 `prepare_venn_data(items, center_label, center_sub, description, viewbox_width=600.0, viewbox_height=550.0)` — top-level entry; mutates `items[*].cx/cy/r` and returns `outer_r`/`center_x`/`center_y`/`viewbox_width`/`viewbox_height`.
## A2 — Phase Z partial SVG inventory (gap)
Phase Z active partials surface:
- `templates/phase_z2/families/*.html` — **11 contracted + 2 WIP untracked = 13 on disk** (contracted set = `templates/phase_z2/catalog/frame_contracts.yaml` top-level keys; WIP allowlist = [`templates/phase_z2/families/_WIP_FILES.md`](../../templates/phase_z2/families/_WIP_FILES.md), gated on Gitea #42 / #52 F-2 option (c)).
- `templates/phase_z2/frames/*.html` — **2** files.
- Total surface = **13 active partials (11 contracted families + 2 frames) + 2 WIP untracked families** (15 on disk; runtime matcher consumes the contracted set only).
SVG usage scan (evidence): `rg "<svg|viewBox" templates/phase_z2/` → **0 matches** (exit 1).
Closest geometric candidate is `templates/phase_z2/families/construction_goals_three_circle_intersection.html` (frame_id `1171281189`, "cycle-3way-intersection" intent), but it renders three intersecting circles via HTML/CSS — `border-radius:50%` + `linear-gradient` + `::before` outer ring — **not** SVG. The Figma source's six accent kanji circles, six side labels, three decorative rects, and three arcs are explicitly **NOT PROMOTED** at the partial header (compact MDX-mapped focus). No partial currently demands the pre-computed `items[*].cx/cy/r` contract.
## A3 — IMP-04 activation gate (soft dependency)
IMP-18 has no Phase Z runtime consumer today. Re-activation triggers:
1. IMP-04 (frame_partials registration) lands an SVG-bearing partial under `templates/phase_z2/` (e.g., a venn-diagram or relationship frame promoted from Figma).
2. The partial declares slots that consume `items[*].cx/cy/r` + `outer_r` + `viewbox_*` (the `prepare_venn_data` return contract).
Until both conditions hold, IMP-18 stays dormant and this gap report is the sole deliverable.
## A4 — Phase R' guardrail (read-only lock)
Per `CLAUDE.md` Phase R' regression prevention rules and the Stage 1/2 exit reports:
- `src/renderer.py` — read-only. No edit to `_preprocess_svg_data` body, `SVG_BLOCKS` set, or `render_multi_page` call site.
- `src/svg_calculator.py` — read-only. No edit to the five helpers or their public signatures.
- `templates/phase_z2/families/*.html` (11 contracted + 2 WIP untracked = 13 on disk; WIP set = [`_WIP_FILES.md`](../../templates/phase_z2/families/_WIP_FILES.md)) + `templates/phase_z2/frames/*.html` (2) — no `<svg>` / `viewBox` insertion in IMP-18 scope. SVG-bearing partial onboarding is owned by IMP-04. The 2 WIP family templates are gated on Gitea #42 (promote-or-remove) and remain outside the runtime matcher set per #52 F-2 option (c).
- F12 `construction_goals_three_circle_intersection.html` HTML/CSS → SVG migration is **out of scope** (separate post-IMP-04 issue).
- No hardcoded SVG coordinates in Phase Z templates — when IMP-18 re-activates, coordinates must be derived from `svg_calculator` helpers (or equivalent forward-port into `phase_z2_renderer`), not hand-copied.
---
## Re-activation checklist (future)
When IMP-04 introduces the first SVG-bearing Phase Z partial:
- [ ] Identify partial(s) consuming `items[*].cx/cy/r` + `outer_r` + `viewbox_*`.
- [ ] Decide port target — extend `phase_z2_renderer` with a `_preprocess_svg_data` analog, or reuse `src/svg_calculator.py` directly.
- [ ] Keep Phase R' references untouched.
- [ ] Add anchor SHA bump only if runtime source surface changes.
@@ -0,0 +1,97 @@
# IMP-19 — Phase O/Q Zone Ratio Container Pattern Reference
**Status**: documented (reference-only, dormant)
**Scope**: doc-only. No runtime surface modified.
**Related issue**: https://gitea.hmac.kr/Kyeongmin/C.E.L_Slide_test2/issues/19
**Soft dependency**: IMP-09 (Phase Z Step 8 zone-ratio solver) — IMP-19 stays dormant; activates only via the A5 gate.
**Source axis**: INSIGHT-MAP §3 / §2.8 I4 — `renderer._group_blocks_by_area` pattern reference.
---
## A1 — Phase O/Q consumer pattern (read-only reference)
Phase O/Q implements role-based block grouping inside body-side zones at the renderer layer. References (do **not** modify):
- `src/renderer.py:210-295` — `_group_blocks_by_area(blocks, container_specs=None)` — `OrderedDict` grouping by `block["area"]`; when `container_specs` is supplied and `area ∈ {"body","left","right","hero","detail"}` enters the role-container branch (L230).
- `src/renderer.py:234` — hardcoded `role_order = ["배경", "본심"]` — two-role role-loop axis (block-level container, **not** zone geometry).
- `src/renderer.py:240-253` — topic_id-first match against `spec.topic_ids`, then fallback positional fill when topic_id match yields empty (L248-253).
- `src/renderer.py:261-274` — inline-style injection: `height:{spec.height_px}px; overflow:visible; display:flex; flex-direction:column; gap:8px; font-size:{font_size}px; --spacing-inner:{padding}px; --font-body:{font_size/16}rem;`. `font_size` / `padding` are read at `:262-263` via `spec.block_constraints.get("font_size_px", 15.2)` / `.get("padding_px", 20)` — **renderer-side defaults**, not producer-emitted (see A2).
- `src/renderer.py:277-279` — leftover (unassigned) blocks appended after role containers.
- `src/renderer.py:283-291` — non-container branch: `len(block_list)==1` → single html, else `flex-direction:column` wrapper with `gap:var(--spacing-block); height:100%`.
Call sites:
- `src/renderer.py:352-353` — `render_multi_page()` — passes `layout_concept.get("_container_specs")` as `container_specs` argument (Phase O activation path).
- `src/renderer.py:426` — `render_slide()` — invokes `_group_blocks_by_area(blocks_raw)` with **no** `container_specs` (legacy fallback / unit-test path).
Classification: block/role-level container injection at render time. **Not** Phase Z zone geometry.
## A2 — Phase O upstream producer (read-only reference)
`ContainerSpec` payloads consumed by A1 are produced upstream. References (do **not** modify):
- `src/space_allocator.py:445-586` — `build_containers_type_b(page_structure, slide_width=1280, slide_height=720, image_sizes=None)` — Phase X-B 유형 B container builder.
- `src/space_allocator.py:462-468` — token load (`_load_design_tokens`) + `pad`, `header_h`, `gap_block`, `gap_small`, `inner_w` derivation.
- `src/space_allocator.py:470-484` — role classification into `top_roles` / `bottom_roles` / `footer_role` by `info["zone"] ∈ {"top","bottom","bottom_left","bottom_right","footer"}`.
- `src/space_allocator.py:486-503` — usable height calculation against `slide_body_top=65` + `slide_body_h=590` with optional `footer_role` carve-out.
- `src/space_allocator.py:505-510` — `zone_overhead = zone_count * zone_title_h(28) + (zone_count-1) * zone_gap(16)`.
- `src/space_allocator.py:512-520` — `top_h` / `bottom_h` split by `weight` ratio over `usable_h`.
- `src/space_allocator.py:522-537` — image-aware top-zone width split (`img_w = min(top_h*ratio, inner_w*0.45)`).
- `src/space_allocator.py:541-556` — top-role `ContainerSpec` emission: `block_constraints = {"img_width_px": img_w, "img_height_px": top_h if img_w>0 else 0, "has_image": img_w>0}` — image-aware keys only.
- `src/space_allocator.py:562-574` — bottom-role `ContainerSpec` emission: `block_constraints = {}` (empty; no producer keys).
- `src/space_allocator.py:577-588` — footer-role `ContainerSpec` emission: `block_constraints = {}` (empty; `max_height_cost="low"` literal).
Producer classification: block-level role container with `height_px` + `width_px` + `block_constraints` containing **only** image-aware keys (`img_width_px`, `img_height_px`, `has_image`) on top role, **empty** on bottom/footer roles. **Not** zone-level ratio geometry. `font_size_px` / `padding_px` are **renderer-side defaults** (consumed via `.get(..., 15.2)` / `.get(..., 20)` at `src/renderer.py:262-263`), **not** producer output.
## A3 — Phase Z Step 8 solver delta (IMP-09 owned)
The active Phase Z zone-ratio solver lives in `src/phase_z2_pipeline.py` and is **IMP-09 owned**. IMP-19 does **not** absorb, replace, or amend this surface. References (do **not** modify):
- `src/phase_z2_pipeline.py:794-853` — `compute_zone_layout(zones_data, total_height=SLIDE_BODY_HEIGHT, gap=GRID_GAP)` — row-axis solver. Algorithm = `min_height_first + content_weight_distribution`: Step 1 reserves per-zone `min_height_px` from frame_contract `visual_hints` (with proportional scale-down on overflow), Step 2 distributes the remaining vertical budget by `content_weight.score`, Step 3 absorbs rounding residual into the last zone. Returns `heights_px` + `ratios` + reasoning trace.
- `src/phase_z2_pipeline.py:924-972` — `compute_zone_layout_cols(zones_data, total_width=SLIDE_BODY_WIDTH, gap=GRID_GAP)` — col-axis solver. Algorithm = `content_weight_distribution_cols` (weight-only; no `min_width_px` contract exists in `frame_contracts.yaml` per IMP-09 verification). Zero-weight guard splits evenly across `n` zones. Returns `widths_px` + `width_ratios`.
- `src/phase_z2_pipeline.py:1125-1452` — topology dispatch surface:
- `:1125-1152` `_build_rows_dynamic` — `topology=="rows"` (horizontal-2): dynamic row heights via `compute_zone_layout`, static fr column widths via `_parse_fr_string`.
- `:1155+` `_build_grid_dynamic_2d` — `topology ∈ {T, inverted-T, side-T-left, side-T-right, 2x2}`: per-row + per-col virtual-zone aggregation → row solver + col solver → `2d_dynamic_aggregated` computation.
- `:1444-1452` dynamic-branch dispatcher: `rows` / `cols` / 2-D / default fr.
- `:1380-1434` user-override geometry branch (`computation == "user_override_geometry"`) — preserves raw override percentages without invoking the weight solver.
Delta vs Phase O/Q (A1+A2):
| Axis | Phase O/Q (`renderer._group_blocks_by_area`) | Phase Z Step 8 (`compute_zone_layout` + cols) |
|---|---|---|
| Geometry level | block/role inside one zone | zone-level row/col tracks across slide_body |
| Width source | role x-anchor + `top_h` image carve-out | content_weight share (cols) / fr-string (rows) |
| Height source | producer `ContainerSpec.height_px` injection | min_height_first + content_weight remainder |
| Role axis | hardcoded `["배경","본심"]` (L234) | no role concept — zone position + frame contract |
| Min-height source | none (producer-emitted absolute px) | frame_contract `visual_hints.min_height_px` |
| Topology dispatch | none (single role-loop) | rows / cols / T / inverted-T / side-T-* / 2x2 / single |
| Inline-style injection | yes (height + font_size + spacing-inner) | no (geometry-only; styling handled downstream) |
Conclusion: Phase O role-container pattern and Phase Z zone-ratio solver operate at **different abstraction layers** (block-in-zone vs zone-in-slide). They are **not** drop-in interchangeable; IMP-19 surfaces this delta only for design-pattern comparison.
## A4 — IMP-09 boundary statement (soft-link)
IMP-19 is `soft link: IMP-09`. Ownership separation:
- **IMP-09 owns**: every algorithmic change to `compute_zone_layout`, `compute_zone_layout_cols`, the topology dispatch surface (`_build_rows_dynamic` / `_build_cols_dynamic` / `_build_grid_dynamic_2d` / `_build_fr_default`), and the frame_contract `visual_hints.min_height_px` contract.
- **IMP-19 owns**: reference-only documentation of the Phase O/Q `_group_blocks_by_area` + `build_containers_type_b` pattern (A1 + A2) and the Phase Z solver delta narrative (A3).
- **No bidirectional code flow**: IMP-19 does not move Phase O code into Phase Z, and IMP-09 does not consume Phase O `ContainerSpec` payloads. The two solvers remain isolated.
- **Reference direction is one-way**: this document points read-only at `src/renderer.py`, `src/space_allocator.py`, and `src/phase_z2_pipeline.py`. No reverse pointer is required in those source files.
If IMP-09 alters the Phase Z solver signature, A3 must be re-verified (file:line refs); the boundary statement itself does not change.
## A5 — Re-activation gate + guardrails
IMP-19 is `documented` (dormant). Re-activation requires **all** of the following gate conditions:
1. **Trigger**: Phase Z Step 8 produces a verifiable case where the active solver (`min_height_first + content_weight`) yields geometry that the Phase O role-container pattern would have handled correctly — i.e., a regression that maps cleanly to the block-level role abstraction, not the zone-level abstraction.
2. **Evidence requirement**: failing-case MDX + frame_contract trace + observed geometry vs expected geometry, attached to a new issue or this issue's reopened state.
3. **IMP-09 sign-off**: the IMP-09 owner confirms the failing case is **not** addressable inside the Phase Z solver (e.g., adding `visual_hints.min_height_px` or adjusting `content_weight.score` does not resolve it).
4. **Scope re-lock**: the new axis is scope-locked under a fresh implementation issue (not silently reopened in IMP-19) so the soft-link contract is preserved.
Guardrails (preserved from Stage 1 + Stage 2):
- **GR1 — No runtime integration**: this document does not authorize merging Phase O role-container code into the Phase Z runtime. Any such integration requires a new scope-locked issue with its own Stage 1/2 review.
- **GR2 — Phase O no-regression**: Phase O containers (`render_multi_page` path with `_container_specs`) must not re-enter the Phase Z render path; the `render_slide` legacy fallback at `src/renderer.py:426` (no `container_specs`) remains the unit-test entry.
- **GR3 — Reference extract stays in `docs/architecture/`**: never under `src/`. No code body copying; file:line refs only.
- **GR4 — Soft-link integrity**: IMP-19 status remains `documented` until the A5 gate fires. The IMP-09 backlog entry carries a back-reference (see u3); IMP-19 carries the forward reference here.
@@ -0,0 +1,109 @@
# IMP-20 — Phase Q `content_verifier` Frame Contract Validation Pattern Reference
**Status**: documented (reference-only, dormant)
**Scope**: doc-only. No runtime surface modified.
**Related issue**: https://gitea.hmac.kr/Kyeongmin/C.E.L_Slide_test2/issues/20
**Soft dependency**: IMP-04 (extended catalog application) — IMP-20 stays dormant; activates only via the A5 gate.
**Source axis**: INSIGHT-MAP §3 / §2.7 H2 — `content_verifier.verify_structure` pattern reference.
---
## A1 — Phase Q consumer pattern (read-only reference)
Phase Q implements area-level required-pattern validation at the content-verifier layer. References (do **not** modify):
- `src/content_verifier.py:382-392` — `REQUIRED_PATTERNS: dict[str, list[str]]` — top-level pattern dictionary keyed by area name (`body_bg`, `body_core`, `sidebar`, `footer`). Values verified: `body_bg=[]`, `body_core=["key-msg"]`, `sidebar=["padding-left", "text-indent"]`, `footer=[]`. Phase T (`L379-381` comment) removed the `overflow:hidden` requirement to reconcile with the Phase T prompt's "overflow:hidden 금지" directive — that no-regression boundary is preserved.
- `src/content_verifier.py:395-448` — `verify_structure(generated_html, area_name, has_image=False, font_hierarchy=None) → VerificationResult` — the substring-check + OR + tolerance core logic.
- `:405-412` — substring presence loop. Each pattern string is split on `|` (`pattern.split("|")` at L410) and treated as an OR alternation: any alternative present passes the pattern. Missing alternatives are appended to a `missing` list.
- `:414-416` — `has_image` branch. When `has_image=True` and `area_name == "body_core"`, an additional implicit requirement is enforced: `"slide-img-"` must appear in `generated_html`. Missing image marker is reported as `"slide-img-* (이미지 태그)"` in `missing`.
- `:418-436` — `font_hierarchy` branch. When supplied, area-name → max-font lookup uses a fixed `role_font_map = {"body_bg":bg/11, "body_core":core/12, "sidebar":sidebar/10, "footer":core/12}`. HTML `font-size:\s*(\d+(?:\.\d+)?)\s*px` matches are extracted via regex (L430); each measured size > `max_font + 1` (1px tolerance at L433) emits a `font_warnings` entry. Warnings do **not** flip `passed`.
- `:438-447` — result construction. `passed = (len(missing) == 0)`. `score = 1.0` on pass else `1.0 - len(missing) / max(1, len(patterns))` (continuous degradation; `max(1, …)` guards empty-pattern division by zero). Errors prefixed `"필수 패턴 누락: "`. Warnings carry font hierarchy violations only.
- `src/content_verifier.py:455-487` — `verify_area(original_text, generated_html, area_name, has_image=False) → VerificationResult` — composes L1 (`verify_text_preservation`) + L2 (`verify_no_forbidden_content`) + L3 (`verify_structure`) at L462-466. `verify_structure` call at L465 passes `has_image` but **not** `font_hierarchy` (font_hierarchy is unused inside `verify_area`).
- `src/content_verifier.py:490-529` — `verify_all_areas(generated, area_texts, has_image_areas=None)` — area dispatch fan-out. `body_html` is split into `body_bg` + `body_core` (L510-519); `body_core` is the **only** branch that propagates `has_image=("body_core" in has_image_areas)` to `verify_area` (L518). `sidebar_html` (L521-525) and `footer_html` (L527-531) call `verify_area` with default `has_image=False`.
Classification: area-level (Phase Q HTML area axis) required-pattern validation at content-verifier time. **Not** Phase Z frame_id × sub_zone contract validation.
## A2 — Phase Q `REQUIRED_PATTERNS` shape (read-only reference)
The Phase Q pattern-dict shape — **values are Phase Q-specific and excluded from reuse; only the shape is Phase Z design input.**
| Axis | Phase Q shape | Where observed |
|---|---|---|
| Key axis | area name (string) | `src/content_verifier.py:382` keys: `body_bg` / `body_core` / `sidebar` / `footer` |
| Value type | `list[str]` of substring patterns | `src/content_verifier.py:383-391` |
| Alternation semantics | `"a\|b"` → OR (any alt passes) via `pattern.split("|")` | `src/content_verifier.py:410` |
| Image-conditional branch | `has_image=True` ∧ `area_name=="body_core"` → implicit `"slide-img-"` requirement | `src/content_verifier.py:414-416` |
| Font hierarchy tolerance | 1px (`fs > max_font + 1`); area-name → max-font fixed lookup | `src/content_verifier.py:433`, `:421-426` |
| Pass/score rule | `passed = (missing == [])`; score = continuous degradation `1.0 - len(missing)/max(1, len(patterns))` | `src/content_verifier.py:438`, `:445` |
| Empty-pattern handling | `max(1, len(patterns))` guards divide-by-zero; empty pattern list always passes | `src/content_verifier.py:445`, `:382-383` (`body_bg=[]`) |
Shape-only carry-over candidates for Phase Z design (see A3 in u2):
- `dict[key]→list[pattern]` indirection.
- OR via in-string `|` separator (low-ceremony alternation).
- Conditional implicit requirement injected by external context flag (here `has_image`; in Phase Z potentially `accepted_content_types` per sub_zone).
- Continuous score degradation rather than binary pass/fail (downstream consumers can threshold).
- Separate `errors` (block) vs `warnings` (advisory) lanes — font hierarchy lives in warnings, not errors.
Values that **must not** carry into Phase Z: the literal strings `"key-msg"`, `"padding-left"`, `"text-indent"`, `"slide-img-"`, and the area names `body_bg` / `body_core` / `sidebar` / `footer` themselves — these are Phase Q area-HTML idioms, not Phase Z frame/slot idioms.
## A3 — Phase Z target pattern dict (design input, not yet active)
The Phase Z-native target axis = **frame_id × sub_zone** pattern dict, aligned with `templates/phase_z2/catalog/frame_contracts.yaml`. References (do **not** modify):
- `templates/phase_z2/catalog/frame_contracts.yaml:21` `three_parallel_requirements` (F13, 3 sub_zones), `:77` `process_product_two_way` (F29, 2 sub_zones × strict 3 cardinality), `:128` `bim_issues_quadrant_four` (F16, 4 sub_zones), `:189` `three_persona_benefits` (F14, 3 sub_zones), `:253` `construction_goals_three_circle_intersection` (F12, 3+1 sub_zones — `intersection` is `min:0,max:1`), `:323` `construction_bim_three_usage` (F11, 3 sub_zones), `:391` `bim_dx_comparison_table` (F18, 2 header + 1 `rows` with `min:1,max:12`), `:456` `dx_sw_necessity_three_perspectives` (F20, 3 sub_zones), `:520` `info_management_what_how_when` (F8, 3 sub_zones), `:580` `sw_reality_three_emphasis` (F28, 3 sub_zones), `:637` `bim_current_problems_paired` (F17, 8 sub_zones — row × side 2-axis).
- All 11 contracts carry `accepted_content_types` + `sub_zones`; field `density_envelope` is absent across the catalog (verified `grep -c "density_envelope" templates/phase_z2/catalog/frame_contracts.yaml` = 0).
- `src/phase_z2_mapper.py:49-57` `load_frame_contracts` / `get_contract` — direct dict lookup against the 11 entries above.
- `src/phase_z2_pipeline.py:3776-3805` Step 10 emit — currently surfaces `frame_id` / `family` / `source_shape` / `cardinality` / `visual_hints` / `accepted_content_types` / `sub_zones` / `payload_builder` / `payload_builder_options` to `step10_frame_contract.json` with `step_status="partial"`. No pattern-dict assertion runs against this payload yet.
Abstraction-mismatch table (Phase Q area-level vs Phase Z frame/slot-level):
| Axis | Phase Q (A1+A2) | Phase Z target (A3) |
|---|---|---|
| Key | area name (`body_bg`/`body_core`/`sidebar`/`footer`) | `(frame_id, sub_zone_id)` tuple — e.g. `(1171281190, "pillar_1")` |
| Cardinality of keys | 4 fixed area names | open over 11 contracts × N sub_zones (3+2+4+3+4+3+3+3+3+3+8 = 39 sub_zones in current catalog) |
| Value semantics | substring presence (HTML-string match) | candidates: substring presence and/or contract-field assertion (`cardinality.strict` / `accepts` membership / `partial_target_path` resolution) |
| Conditional branch input | `has_image` external flag | `accepted_content_types` per sub_zone (catalog-driven, not external flag) |
| Tolerance | 1px on font-size (single axis) | candidates: font-size 1px tolerance carried over **or** replaced by `visual_hints.min_height_px` envelope check |
| Validation timing | post-render HTML (`generated_html` string) | post Step 18 final.html (mirrors Phase Q timing) — Step 12 light_edit/restructure proposal is excluded (proposal is upstream of render) |
| Result lanes | `errors` (block) + `warnings` (advisory) | preserved as-is from Phase Q shape (continuous score; separate font-hierarchy warnings) |
Classification: Phase Q area axis ⇄ Phase Z frame/slot axis are **not** drop-in compatible. The shape (dict indirection + OR alternation + tolerance + conditional implicit-requirement + continuous score) is the only portable element; every value (key strings, area names, literal patterns) is Phase Q-local.
## A4 — IMP-04 soft-link boundary (catalog vs validation ownership)
IMP-20 is `soft link: IMP-04` per the backlog (`docs/architecture/PHASE-Z-IMPLEMENTATION-ISSUE-BACKLOG.md:71`). Ownership separation:
- **IMP-04 owns**: every `frame_contracts.yaml` entry — addition / removal / `accepted_content_types` change / `sub_zones` schema change / `cardinality` change / `visual_hints` change. `templates/phase_z2/catalog/frame_contracts.yaml` is the IMP-04 source of truth.
- **IMP-20 owns**: reference-only documentation of the Phase Q pattern-dict shape (A1 + A2) and the Phase Z target axis design narrative (A3). No catalog edits, no Step 10 promotion.
- **Coupling direction**: **one-way** read. A Phase Z pattern dict (if/when activated through the A5 gate) consumes `frame_contracts.yaml` as input. It does **not** publish back into the catalog. IMP-04 is unaware of IMP-20.
- **No bidirectional code flow**: IMP-20 does not move Phase Q `content_verifier.py` code into Phase Z, and IMP-04 does not consume `REQUIRED_PATTERNS`. The two surfaces remain isolated.
- **Reference direction is one-way**: this document points read-only at `src/content_verifier.py`, `src/phase_z2_mapper.py`, `src/phase_z2_pipeline.py`, and `templates/phase_z2/catalog/frame_contracts.yaml`. No reverse pointer is required in those source files.
If IMP-04 alters the catalog schema (e.g. adds `density_envelope` or renames `sub_zones`), A3 must be re-verified (key axis and conditional-branch row in particular). The boundary statement itself does not change.
## A5 — Re-activation gate + guardrails
IMP-20 is `documented` (dormant). Re-activation requires **all** of the following gate conditions (3-cond AND):
1. **Trigger**: Phase Z Step 10 produces a verifiable case where the partial frame-contract emit alone is insufficient — i.e., a final.html regression that a frame_id × sub_zone pattern dict would have caught (missing slot marker, contract field violation, font-hierarchy breach against a sub_zone-resolved max). The trigger must be a regression that maps cleanly to the frame/slot axis, **not** to a higher layer (composition planning, content adapter, render-time CSS).
2. **Evidence requirement**: failing-case MDX + `step10_frame_contract.json` trace + final.html excerpt with the slot path that should have asserted, attached to a new issue or this issue's reopened state.
3. **IMP-04 sign-off**: the IMP-04 owner confirms the failing case is **not** addressable inside the catalog (e.g. tightening `cardinality` or `accepted_content_types` does not resolve it) — only then is a Phase Z-native pattern dict justified.
Design questions resolved in this document (revisit if the gate fires):
- **Q1 — Key granularity**: `(frame_id, sub_zone_id)`. Frame-only granularity is insufficient because contracts with `sub_zones` of differing `accepts` (e.g. F29 `process_column` accepts `[text_block, transform_table]` vs `product_column` accepts `[text_block]`) require slot-level differentiation.
- **Q2 — Value type**: hybrid — substring patterns (Phase Q parity) **plus** contract-field assertions (`cardinality.strict` / `accepts` membership / `partial_target_path` resolved in DOM) **plus** numeric tolerance (carried from font-hierarchy 1px). Three lanes preserved separately so each can fail/pass independently.
- **Q3 — Validation timing**: post Step 18 final.html **only**. Step 12 light_edit/restructure proposal is upstream of render and exposes no HTML for substring assertion; running the dict there would either fire false negatives (no DOM yet) or duplicate Step 18 work.
- **Q4 — Font-hierarchy carry-over**: replaced — Phase Q's `role_font_map` fixed dict (area → max-font) is Phase Q-local. The Phase Z equivalent reads from `frame_contracts.yaml` `visual_hints` (`min_height_px` already present; a future `max_font_px` field would live in `visual_hints` and is IMP-04-owned). 1px tolerance shape is portable; the lookup source is replaced.
Guardrails (preserved from Stage 1 + Stage 2):
- **GR1 — Shape-only reference**: no Phase Q `REQUIRED_PATTERNS` value (`"key-msg"`, `"padding-left"`, `"text-indent"`, `"slide-img-"`) or area name (`body_bg`/`body_core`/`sidebar`/`footer`) may appear in any Phase Z pattern dict activation.
- **GR2 — Phase Q no-regression**: `src/content_verifier.py:382-392` `REQUIRED_PATTERNS` is no-touch. The Phase T `L379-381` comment (overflow:hidden removed) remains the no-regression boundary; any Phase Z dict design must not re-introduce removed patterns into Phase Q's surface.
- **GR3 — Phase Z dict is Phase Z-owned**: no `import` of `content_verifier.REQUIRED_PATTERNS` from Phase Z code. The two pattern dicts coexist without symbol sharing.
- **GR4 — IMP-04 soft-link one-way**: per § A4. Activating IMP-20 must not block on or modify IMP-04; the catalog is read-only input.
- **PZ-1 — AI isolation contract**: pattern dict is code/spec, not AI-generated content. No Kei rewrite, no LLM proposal of pattern values (`feedback_ai_isolation_contract`).
- **RULE 13 — Anchor sync**: any future activation must update backlog (`PHASE-Z-IMPLEMENTATION-ISSUE-BACKLOG.md`), status board (`PHASE-Z-PIPELINE-STATUS-BOARD.md`), and INSIGHT-MAP (`PHASE-Q-INSIGHT-TO-22STEP-MAP.md`) in the same commit.
If IMP-04 alters the catalog schema or `src/content_verifier.py` is rewritten upstream, A1–A3 must be re-verified (file:line refs); the A5 gate itself does not change.
+59
View File
@@ -0,0 +1,59 @@
# IMP-31 — AI-assisted frame-aware adaptation activation gate audit
**Status**: design-only audit. IMP-31 (#40) = IMP-17 carve-out activation tracking issue. No new design slot. No runtime AI code lands until the 3-condition AND gate clears.
**Source**
- Gitea issue [#40](https://gitea.hmac.kr/Kyeongmin/C.E.L_Slide_test2/issues/40) IMP-31 — AI-assisted frame-aware adaptation (restructure / reject routes).
- Carve-out boundary spec: [`IMP-17-CARVE-OUT.md`](IMP-17-CARVE-OUT.md) (allowed / forbidden / activation gate).
- Backlog row: [`PHASE-Z-IMPLEMENTATION-ISSUE-BACKLOG.md`](PHASE-Z-IMPLEMENTATION-ISSUE-BACKLOG.md):68 (IMP-17 — carve-out, normal path 밖, soft link IMP-04 + IMP-05).
- Stage 1 / Stage 2 exit reports: `.orchestrator/issues/40_stage_problem-review_exit.md` (Stage 1 binding contract).
## Issue-body anchor drift (axis C1)
Issue body cites `src/phase_z2_pipeline.py:452` for IMP-05 L5 `_imp05_route_hint()`. Current anchor surface (commit `1efbf67`):
- `:570` — conceptual comment ("restructure → AI-assisted frame-aware adaptation (deferred to IMP-17 …)").
- `:572` — `_IMP05_ROUTE_HINTS: dict[str, str] = {` declaration.
- `:575` — `"restructure": "ai_adaptation_required"` entry.
- `:580` — `def _imp05_route_hint(label: Optional[str]) -> Optional[str]:`.
- `:664` — `"route_hint": _imp05_route_hint(match.label)` candidate_evidence emission.
Anchor pin: `tests/orchestrator_unit/test_imp17_comment_anchor.py`. Synced in [`IMP-17-CARVE-OUT.md`](IMP-17-CARVE-OUT.md):10 (Stage 3 u1).
## 3-condition AND gate state (this cycle)
| # | Condition | State | Evidence |
|---|---|---|---|
| 1 | User GO — explicit activation request | **NOT CLEAR** | No axis activation directive in #40. Stage 1 root_cause: runtime consumer = 0. |
| 2 | B4 frame_selection evidence integration complete | **NOT CLEAR** (⚠ partial) | [`PHASE-Z-PIPELINE-STATUS-BOARD.md`](PHASE-Z-PIPELINE-STATUS-BOARD.md):48 Step 9 ⚠ partial; :82 "B4 frame_selection 의 V4 evidence 미통합"; :126 (j) ❌ pending. |
| 3 | IMP-04 catalog expansion + IMP-05 V4 fallback live | **AMBIGUOUS** | `templates/phase_z2/catalog/frame_contracts.yaml` = 11 `template_id:` entries vs 32 target. IMP-05 V4 rank-2/3 fallback selector logic live, but catalog coverage gates real semantics. |
**Verdict**: gate **NOT CLEAR**. Runtime AI adaptation remains gated. `src/phase_z2_ai_fallback/` = **scaffolded under IMP-33** (#61, Stage 3 u1~u11); module created, but `settings.ai_fallback_enabled` defaults to `False` (u1) so normal-path AI call count remains 0 (PZ-1). Runtime engagement still requires the 3-condition AND gate above.
## Issue-body axis verdict
| Axis | Issue-body line | Verdict | Binding boundary |
|---|---|---|---|
| A1 | restructure → ai_adaptation_required actual adaptation route | **gate-blocked** | Allowed only inside [`IMP-17-CARVE-OUT.md`](IMP-17-CARVE-OUT.md) Step 12 fallback path; runtime AI consumer not added this cycle. |
| A2 | reject → design_reference_only | **gate-blocked + frontend ownership** | Reject route = design reference only. Frontend zone-level override remains IMP-29 scope ([`PHASE-Z-PIPELINE-OVERVIEW.md`](PHASE-Z-PIPELINE-OVERVIEW.md) Step 12). |
| A3 | AI call provider | **Anthropic API only** | Kei API / `EDITOR_PROMPT` / Kei-API endpoint forbidden (Phase Q Kei persona 영구 단절 — [`IMP-17-CARVE-OUT.md`](IMP-17-CARVE-OUT.md) §"AI 격리 + Kei persona 단절 contract"). |
| A4 | candidate_evidence[].route_hint | **live (deterministic emission)** | Emission anchored at `src/phase_z2_pipeline.py:570/:572/:575/:580/:664`; AI consumer deferred. Anchor pin: `tests/orchestrator_unit/test_imp17_comment_anchor.py`. |
| A5 | MDX content preservation = strict | **locked** | No invent / rewrite / compress / summarize ([`IMP-17-CARVE-OUT.md`](IMP-17-CARVE-OUT.md) §Forbidden; memory `feedback_phase_z_spacing_direction`). |
| A6 | AI prompt = frame-aware placement only, not "rewrite content" | **locked** | Output = content_object → Internal Region / Frame Slot placement proposal at content-object granularity ([`IMP-17-CARVE-OUT.md`](IMP-17-CARVE-OUT.md) §Allowed). HTML / CSS / layout / zone topology / frame selection X. |
| A7 | popup / details / zone-resize routing when content cannot fit | **deferred to Step 17 fallback** | Deterministic actions exhausted (zone_ratio_retry / layout_adjust / frame_reselect / details_popup_escalation / image_fit_candidate / frame_internal_fit_candidate) before AI proposal ([`IMP-17-CARVE-OUT.md`](IMP-17-CARVE-OUT.md) §Allowed Step 16/17). |
| A8 | no `calculate_fit` migration | **locked** | IMP-05 selector uses V4 labels + frame-contract presence + Phase Z capacity precheck only (`src/phase_z2_pipeline.py:587` `lookup_v4_match_with_fallback` declaration; :599 docstring "it does not call calculate_fit"; secondary anchors :3093 / :4871). |
| C1 | Anchor drift `:452` → current | **synced** | Stage 3 u1 — [`IMP-17-CARVE-OUT.md`](IMP-17-CARVE-OUT.md):10. |
| C2 | Backlog + status-board cross-ref | **planned (u3)** | Cross-ref discoverability surfaces only; no verdict duplication. |
## Out of scope (this cycle)
Runtime AI consumer enablement (flag default OFF), `candidate_evidence` schema change, Phase Q file mutation, Kei API reuse, frontend zone override (IMP-29 scope), IMP-30 invariant change, `calculate_fit` migration. Note: `src/phase_z2_ai_fallback/` directory scaffold itself was created under IMP-33 (#61, Stage 3 u1~u11) — see [`IMP-17-CARVE-OUT.md`](IMP-17-CARVE-OUT.md) §"Runtime module surface".
## Future activation path
When the 3-condition AND gate clears (User GO ∧ B4 V4 evidence integrated ∧ catalog 32/32 + IMP-05 V4 fallback live):
- Runtime AI module path = `src/phase_z2_ai_fallback/` (scaffolded under IMP-33; flag default OFF until gate clears).
- Provider = Anthropic API only. Prompt design starts fresh (no Phase Q `EDITOR_PROMPT` import).
- Output granularity = content_object → Internal Region / Frame Slot placement proposal. Frame / layout / zone topology selection remains deterministic.
- Activation tracker = this issue (#40, IMP-31). No new IMP ID issued.
@@ -0,0 +1,162 @@
# INTEGRATION-AUDIT-01 -- Axis 2 pipeline map (22 issues x 22 steps)
**Anchor (Stage 1 lock)** :
> This audit verifies pipeline contracts. It does not optimize any single MDX sample.
**Companion file** : `docs/architecture/INTEGRATION-AUDIT-01-REPORT.md` -- this MATRIX is the spin-off body of REPORT Section 4 (Axis 2). Combined REPORT exceeded the 10 KB readability threshold (REPORT u1 size = 21,070 bytes) at u1 completion, so the grid is housed here per the Stage 2 split rule. REPORT Section 4 carries a back-pointer to this file.
**Pipeline reference** : `docs/architecture/PHASE-Z-PIPELINE-OVERVIEW.md` (22-step master). Block A (Steps 0-12) = pre-render planning; Block B (Step 13) = render; Block C (Steps 14-22) = post-render telemetry / exception handling.
**Closed issues under audit (22 total)** : `#2 #3 #4 #5 #6 #7 #8 #9 #10 #11 #12 #13 #14 #15 #16 #17 #18 #45 #46 #47 #48 #49`. `#15` = parent; `#45-#49` = execution children. Parent/child de-dup convention (Stage 1 lock) -- `#15` row records integration glue only, no `P` (primary) cells; real code attribution lives in `#45-#48` rows. `#49` = verification-only, no new SHA, re-uses `#48` evidence.
---
## Step 0 precondition NOTE (NOT an axis, recorded above the grid)
Step 0 = `docs/architecture/PHASE-Z-PIPELINE-OVERVIEW.md` precondition block (catalog / contract / matching data / template / asset). Per Stage 2 plan, Step 0 is NOT a grid column; it is recorded here as a precondition note. Closed issues that touched Step 0 :
| issue | Step 0 touch | scope summary | evidence path |
|---|---|---|---|
| `#4` | catalog + contract expansion (16 frame_partials + F17 paired_rows_4x2 + frame_contracts.yaml schema) | adds frame DB rows + contract schema fields | `templates/phase_z2/catalog/frame_contracts.yaml` ; `templates/phase_z2/families/*.html` |
| `#11` | contract field `min_height_px` exposure | additive contract payload field | `templates/phase_z2/catalog/frame_contracts.yaml` ; `src/phase_z2_pipeline.py` (commit `a79bd8b`) |
| `#13` | build-time frame preview generator (salvage of `capture_slide_screenshot`) | precondition asset only (lives in `scripts/`, NOT runtime pipeline) | `scripts/generate_frame_previews.py` (commit `7d5639a`) |
| `#14` | slide-base template contract bit (embedded vs standalone) | precondition template surface | `templates/phase_z2/slide_base.html` (commit `7a52ceb`) |
| `#18` | doc-only carve-out (no Step 0 code change) | SVG gap report + 1-line backlog status flip | `docs/architecture/IMP-18-SVG-GAP-REPORT.md` (commit `cbbc163`) |
Step 0 touches above are precondition data / template / contract; they do not flow runtime decisions in Steps 1-22 directly, except via consumers already accounted for as Step 5 / 9 / 10 / 12 / 13 / 22 cells in the grid below.
---
## Cell legend
- `P` = primary touch (the issue's own declared scope per body / closing commit)
- `A` = adjacent contract (consumer / producer / cross-step dependency surface, not the primary scope)
- `.` = not touched (blank-equivalent; dot used for column alignment in monospace renderers)
Rule applied : if an issue's body or closing commit explicitly names a step or its code file, that is `P`. If the change shape forces the issue to read from or write into another step's contract without being the primary scope, that is `A`. Otherwise `.`.
Parent `#15` row carries no `P` cells per the Stage 1 de-dup convention; its child rows (`#45-#48`) carry the actual `P` cells.
---
## 22 x 22 grid (Step 1 columns -> Step 22 columns)
Column header shorthand : `S1 = MDX upload | S2 = MDX normalize | S3 = content_object | S4 = section internal composition planning | S5 = V4 evidence | S6 = composition planning | S7 = layout vocabulary | S8 = zone+region ratio | S9 = region-level frame/display | S10 = frame contract | S11 = region-to-slot mapping | S12 = slot payload | S13 = render | S14 = visual_check | S15 = fit_classification | S16 = router | S17 = action | S18 = failure_classify | S19 = next_action | S20 = slide_status | S21 = debug.json | S22 = user UI/export`.
| issue | S1 | S2 | S3 | S4 | S5 | S6 | S7 | S8 | S9 | S10 | S11 | S12 | S13 | S14 | S15 | S16 | S17 | S18 | S19 | S20 | S21 | S22 | row total |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| `#2` | . | P | A | . | . | . | . | . | . | . | . | . | . | . | . | . | . | . | . | . | A | . | 3 |
| `#3` | . | A | P | . | . | . | . | . | . | . | . | . | . | . | . | . | . | . | . | . | A | . | 3 |
| `#4` | . | . | . | . | A | . | . | . | A | P | . | A | A | . | . | . | . | . | . | . | . | . | 5 |
| `#5` | . | . | . | . | A | A | . | . | P | . | . | . | . | . | . | A | A | . | . | P | . | . | 6 |
| `#6` | A | . | . | . | . | P | A | A | A | . | . | . | A | . | . | . | . | . | . | . | . | A | 7 |
| `#7` | A | A | . | . | . | . | . | . | . | . | . | . | . | . | . | . | . | . | . | . | . | P | 3 |
| `#8` | . | . | P | . | A | A | . | . | A | . | . | . | A | . | . | . | . | . | . | . | . | A | 6 |
| `#9` | . | . | . | . | . | . | A | P | A | . | . | . | A | . | . | . | A | . | . | . | . | . | 5 |
| `#10` | . | . | . | . | . | . | . | . | . | . | . | . | . | . | . | . | . | . | . | A | . | P | 2 |
| `#11` | . | . | . | . | . | . | . | . | A | . | . | . | . | . | . | . | . | . | . | . | . | P | 2 |
| `#12` | . | . | . | . | . | . | . | . | . | . | . | . | . | A | . | P | P | P | A | A | . | . | 6 |
| `#13` | . | . | . | . | . | . | . | . | . | . | . | . | . | A | . | . | . | . | . | . | . | . | 1 |
| `#14` | . | . | . | . | . | . | . | . | . | . | . | . | P | . | . | . | . | . | . | . | . | A | 2 |
| `#15` | . | . | . | . | . | . | . | . | . | . | . | . | . | A | A | . | . | . | . | . | A | . | 3 |
| `#16` | A | A | . | . | . | . | . | . | . | . | . | . | . | A | . | . | . | . | . | . | A | A | 5 |
| `#17` | . | . | . | . | . | . | . | . | . | . | . | P | . | . | . | A | A | . | . | . | . | . | 3 |
| `#18` | . | . | . | . | . | . | . | . | . | . | . | . | . | . | . | . | . | . | . | . | . | . | 0 |
| `#45` | . | . | . | . | . | . | . | . | . | . | . | . | . | P | A | . | . | . | . | . | A | . | 3 |
| `#46` | . | . | . | . | . | . | . | . | . | . | . | . | . | P | A | . | . | . | . | . | A | . | 3 |
| `#47` | . | . | . | . | . | . | . | . | . | . | . | . | . | A | P | A | . | . | . | . | . | . | 3 |
| `#48` | . | . | . | . | . | . | . | . | . | . | . | . | . | A | A | . | . | . | . | . | P | . | 3 |
| `#49` | . | . | . | . | . | . | . | . | . | . | . | . | . | A | A | . | . | . | . | . | A | . | 3 |
| **col total** | 3 | 4 | 3 | 0 | 3 | 3 | 2 | 2 | 6 | 1 | 0 | 2 | 5 | 9 | 6 | 4 | 4 | 1 | 1 | 3 | 8 | 7 | -- |
| **HOTSPOT (>= 4)** | . | H | . | . | . | . | . | . | H | . | . | . | H | H | H | H | H | . | . | . | H | H | -- |
Cell-count totals : sum of row totals = 77 ; sum of column totals = 77 (cross-check matches; 22 rows x 22 cols = 484 grid positions, of which 77 are non-blank).
---
## HOTSPOT enumeration (column total >= 4)
9 of the 22 steps are HOTSPOT (touched by 4 or more closed issues). Listed in pipeline order :
| step | col total | touching issues | hotspot meaning |
|---|---|---|---|
| `S2 MDX normalize` | 4 | `#2 P`, `#3 A`, `#7 A`, `#16 A` | Step 2 is the entry surface for both the Stage 0 chained adapter (`#2`) and downstream content-object trace (`#3`), with reverse-path (`#7`) and verification utility (`#16`) as adjacent consumers. Cross-issue contract = `parse_mdx` output shape stays compatible with `extract_*` semantics. |
| `S9 region-level frame/display` | 6 | `#4 A`, `#5 P`, `#6 A`, `#8 A`, `#9 A`, `#11 A` | Step 9 is the heaviest pre-render hotspot. `#5` is primary (V4 fallback / application_plan). `#4 #8 #11` extend the contract / schema feeding Step 9. `#6 #9` exercise the consumer of zone-region geometry. Cross-issue invariant : V4 candidates list + min_height contract + sub_section alias + region ratio must all agree at the Step 9 application_plan boundary. |
| `S13 render` | 5 | `#4 A`, `#6 A`, `#8 A`, `#9 A`, `#14 P` | Step 13 is the Jinja2 render surface. `#14` (slide-base iframe mode) is primary. `#4 #6 #8 #9` flow new payload / layout css into the same renderer. Cross-issue invariant : `build_layout_css` + frame_partial + slide_base remain deterministic with no AI in path. |
| `S14 visual_check` | 9 | `#12 A`, `#13 A`, `#15 A`, `#16 A`, `#45 P`, `#46 P`, `#47 A`, `#48 A`, `#49 A` | Highest column total. `#15` parent + 5 children (`#45-#49`) all converge here. `#12 #13 #16` are adjacent. Cross-issue invariant : detector producers (`#45 #46`) emit canonical event shape; classifier consumer (`#47`) reads the same shape; debug.json surfaces (`#48`) match -- to be re-verified by Axis 3 (REPORT Section 5). |
| `S15 fit_classification` | 6 | `#15 A`, `#45 A`, `#46 A`, `#47 P`, `#48 A`, `#49 A` | `#47` primary (classifier consumes image + table events). All `#15` family is adjacent. Cross-issue invariant : Step 14 producer event keys agree with Step 15 `CONTENT_TYPE_PATTERNS`. |
| `S16 router` | 4 | `#5 A`, `#12 P`, `#17 A`, `#47 A` | `#12` primary (3-stage salvage cascade). `#5` bridge fallback adjacent. `#17` gated carve-out adjacent. `#47` classifier output flows into router. Cross-issue invariant : router action map remains deterministic / no AI in normal path. |
| `S17 action` | 4 | `#5 A`, `#9 A`, `#12 P`, `#17 A` | `#12` primary (zone_ratio_retry expansion + cross-zone donor + 3-stage cascade). `#9` zone-geometry feeds the same retry surface. `#5` V4 fallback shares `PASS_WITH_FALLBACK` status enum. `#17` is gated. Cross-issue invariant : no common-CSS shrink (per `feedback_phase_z_spacing_direction`). |
| `S21 debug.json` | 8 | `#2 A`, `#3 A`, `#15 A`, `#16 A`, `#45 A`, `#46 A`, `#48 P`, `#49 A` | Second-highest column total. `#48` primary (debug.json event surfacing). 7 issues adjacent. Cross-issue invariant : debug.json schema additive only; no key type / semantic conflict (Axis 3 re-verifies this category). |
| `S22 user UI/export` | 7 | `#6 A`, `#7 P`, `#8 A`, `#10 P`, `#11 P`, `#14 A`, `#16 A` | Frontend / CLI exit surface. 3 primary (`#7 #10 #11`). 4 adjacent. Cross-issue invariant : `Front/` consumes backend artifacts as read-only payload; backend never reads from frontend except via the reverse path (`#7`). |
`S2 S9 S13 S14 S15 S16 S17 S21 S22` = 9 distinct hotspot steps (col total >= 4). The col-total HOTSPOT row in the grid carries 9 `H` marks ; counting check matches.
---
## Row total HOTSPOT (issues touching the most steps)
For information only -- this dimension is not an issue-body requirement, but is useful for scope-myopia cross-check with REPORT Section 3 :
| issue | row total | finding (per REPORT Section 3) |
|---|---|---|
| `#6` | 7 | Warning -- wide override blast radius (4 commits + Stage 4 blocker-fix `52ccb7f`) -- matrix row total agrees |
| `#5` | 6 | OK -- pre-render bridge ; rank-1 path unchanged |
| `#8` | 6 | OK -- additive schema with explicit backward-compat alias resolver |
| `#12` | 6 | Warning -- large blast radius (4 src + 5 test modules in `56619a0`) -- matrix row total agrees |
| `#4` | 5 | OK -- pre-render planning only ; catalog read-only for V4 |
| `#9` | 5 | OK -- 8-vocabulary build_layout_css with fixtures |
| `#16` | 5 | OK -- utility + design doc only ; gated by `#7` activation |
The two `Warning` rows in Section 3 (`#6` row total 7 and `#12` row total 6) sit at the top of the row-total ranking -- this is consistent with "wide blast radius" findings in Section 3. The other high-row-total issues (`#5 #8 #4 #9 #16`) are all `OK` per Section 3 because each ships with explicit backward-compat guards / fixtures / gating.
---
## Cross-check vs REPORT Section 3 adjacency list
REPORT Section 3 flagged 9 adjacent-contract pairs for Axis 3 re-verification. Each pair maps onto cells in this grid :
| Section 3 adjacency pair | matrix evidence |
|---|---|
| `#2` Step 2 normalize -> `#3` Step 3 content_object | `#2` S2 `P` + `#3` S2 `A` (producer/consumer same column) |
| `#3` content_object -> `#8` sub_sections | `#3` S3 `P` + `#8` S3 `P` (both primary on same step -- schema extension) |
| `#4` catalog -> `#5` V4 fallback | `#4` S5 `A` + `#5` S5 `A` (both adjacent on same step -- candidate pool dedup) |
| `#4` catalog -> `#10 #11` min_height | `#11` S0 (NOTE) ; `#11` S9 `A` (Step 9 consumer of min_height) -- direct adjacency |
| `#9` layout vocabulary -> `#12` retry zone-ratio | `#9` S17 `A` + `#12` S17 `P` (consumer/producer same step) |
| `#9` -> `#11` Step 9 min_height test | `#9` S9 `A` + `#11` S9 `A` (both adjacent on same step) |
| `#45 + #46` Step 14 -> `#47` Step 15 | `#45 #46` S14 `P` + `#47` S15 `P` ; `#47` S14 `A` (cross-step producer/consumer) |
| `#48` debug.json -> open `#21` consumer | `#48` S21 `P` ; `#21` is out-of-scope (open) -- no grid row |
| `#17` AI carve-out -> `#5 + #4` activation gate | `#17` S12 `P` ; `#17` S16 `A` ; `#17` S17 `A` (gated cells) |
All 9 adjacency pairs map onto provable cells. Axis 3 (REPORT Section 5) will verify each pair's producer-line / consumer-line on live code.
---
## Empty columns (col total = 0)
- `S4 section internal composition planning` -- 0 touches. Consistent with PHASE-Z-PIPELINE-OVERVIEW Step 4 status `missing` (no closed issue implemented Step 4 yet; it remains in the open backlog).
- `S11 content unit / child group -> internal region -> frame slot mapping` -- 0 touches. Consistent with PHASE-Z-PIPELINE-OVERVIEW Step 11 status `missing` (Layer A / Layer B 2-stage placement algorithm not implemented).
Step 4 and Step 11 are the two `missing` steps in Block A that no closed issue in the audit window addressed. This is expected per the master pipeline status; the audit records absence without claiming a gap (an implementation gap would require an OPEN issue to claim it, which is out of audit scope).
---
## Low-touch columns (col total = 1)
- `S10 frame contract` (1) -- `#4` only ; consistent with `#4` being the catalog/contract owner.
- `S18 failure_classify` (1) -- `#12` only ; consistent with `#12` being the retry cascade owner.
- `S19 next_action` (1) -- `#12` only ; same.
---
## Notes on parent / child row separation
- `#15` row carries 3 adjacencies (S14 / S15 / S21) and zero `P` cells per the Stage 1 de-dup convention.
- `#45 #46 #47 #48` carry the corresponding `P` cells (S14 for `#45 #46` ; S15 for `#47` ; S21 for `#48`).
- `#49` (verification-only, no new SHA) mirrors the `#48` adjacency pattern with all-`A` cells -- this is intentional and consistent with the Stage 1 lock that `#49` re-uses `#48` evidence (commit `614c533`). No double-count.
Sum cross-check : `#15` 3 + `#45` 3 + `#46` 3 + `#47` 3 + `#48` 3 + `#49` 3 = 18 row-total cells across the `#15` family. None of these duplicate code attribution -- only `#45 #46 #47 #48` carry the four `P` cells (one each), totaling 4 primary cells for the family. `#15 #49` carry zero primaries.
---
*End of MATRIX. Back to REPORT Section 4 for narrative integration.*
@@ -0,0 +1,547 @@
# INTEGRATION-AUDIT-01 -- Phase Z closed-issue cumulative consistency review
## Section 1. Audit anchor
**Anchor (cited verbatim per Stage 1 exit report)** :
> This audit verifies pipeline contracts. It does not optimize any single MDX sample.
**Scope** : 22 closed Gitea issues `#2-#18 + #45-#49` on `Kyeongmin/C.E.L_Slide_test2` against the 22-step Phase Z pipeline (`docs/architecture/PHASE-Z-PIPELINE-OVERVIEW.md`, Steps 1-22 plus Step 0 precondition).
**Mode** : audit-only -- no source code changes. Report-only file changes under `docs/architecture/INTEGRATION-AUDIT-*.md` and one row in `docs/architecture/PHASE-Z-IMPLEMENTATION-ISSUE-BACKLOG.md` (u7).
**Parent / child relationship** : Gitea `#15` = parent (IMP-15 Step 14 visual_check reinforcement). Execution children = `#45 / #46 / #47 / #48 / #49`. Locked child SHAs (Stage 1 exit report) :
- `#45` -> `e9b3d2e` (execution-1, image_aspect_mismatch detection)
- `#46` -> `2827622` (execution-2, table_self_overflow detection; commit message label says `IMP-16` but the closed Gitea issue is `#46`; flagged in Section 3)
- `#47` -> `535c484` (execution-3, classifier consumes image+table events)
- `#48` -> `614c533` (execution-4, debug.json event surfacing + spec taxonomy)
- `#49` -> no new SHA (verification-only per `#15` body; re-uses `614c533` evidence)
**Close timestamp anomaly** (Stage 1 lock, recorded; NOT reopened) :
- `#15` closed `2026-05-19T02:35:05+09:00`
- `#45 / #46 / #47 / #48` all closed BEFORE `#15` (correct ordering)
- `#49` closed `2026-05-19T02:49:56+09:00` -- about 15 minutes AFTER `#15` close (anomaly)
- Disposition : record-only in Section 3 / Section 6 finding column; no remediation row in backlog beyond the existing audit completion row (u7).
**Excluded (open / not in audit)** : `#1, #19, #20, #21, #22, #23, #24, #25, #26, #27, #28, #38, #39, #40, #41, #42, #43, #44`.
**Sample budget** : `samples/mdx_batch/03.mdx` (smoke) plus `samples/mdx_batch/04.mdx` (details + images). Pipeline runs captured in Section 7.
---
## Section 2. Baseline pytest
**Method** : `pytest -q tests` is the project regression suite. The audit captures it twice -- once before any u5 / u6 / u7 edits, once after Section 7 / 8 grep + render evidence is collected. Equality of both runs proves the audit-only work surface (`docs/architecture/INTEGRATION-AUDIT-*.md` + backlog row in u7) did not perturb production code.
**Command** : `pytest -q tests` (working dir = repo root `D:\ad-hoc\kei\design_agent\`).
**Pytest BEFORE audit u5 edits (audit date 2026-05-19)** :
- Result : `303 passed in 40.80s`
- Last 5 progress dots aggregated to `[100%]` then `Running teardown with pytest sessionfinish...` -- expected suite teardown banner.
**Pytest AFTER audit u5 edits (post §7 / §8 evidence collection, same audit date)** :
- Result : `303 passed in 40.54s`
- 303 == 303 ; 0 new failures, 0 skipped, 0 errored. Test count parity proves no test discovery side-effect from new audit docs.
**Verdict** : OK. Audit-only edits under `docs/architecture/INTEGRATION-AUDIT-*.md` introduce no regression. Baseline stable across u5 assembly.
---
## Section 3. Axis 1 -- Scope myopia (22 issues x adjacent-contract cross-reference)
**Method** : per closed issue, list (a) its own scope as declared in body / backlog row / closing commits, (b) adjacent pipeline contracts the change could have leaked into, (c) downstream consumers of its outputs, (d) finding label `OK` / `Warning` / `Blocker`. Each row cites `src/`, `tests/`, `docs/`, or `templates/` paths.
**De-dup convention** : `#15` is treated as the *integration parent*; the actual code/test changes are owned by execution children `#45-#49`. `#15` row records integration glue only (parent close evidence + cross-child reconciliation). No change is double-counted across parent + child.
**Pipeline step shorthand (per `PHASE-Z-PIPELINE-OVERVIEW.md`, full 22-step list)** :
- Step 0 precondition / 1 MDX upload / 2 normalize / 3 content_object / 4 internal composition planning / 5 V4 evidence / 6 composition planning / 7 layout vocabulary / 8 zone+region ratio / 9 region-level frame/display / 10 frame contract / 11 region-to-slot mapping / 12 slot payload / 13 render / 14 visual_check / 15 fit_classification / 16 router / 17 action / 18 failure_classify / 19 next_action / 20 slide_status / 21 debug.json / 22 user UI.
### Section 3 table -- 22 rows
| # | issue (title) | declared own_scope | adjacent contracts (potential leak surface) | downstream consumers | finding | evidence path |
|---|---|---|---|---|---|---|
| 1 | `#2` IMP-02 A-1 Stage 0 normalize chained adapter | Step 2 -- chained `normalize_mdx_content` + `extract_major_sections` + `extract_conclusion_text` with dual-write, preserve raw MDX | Step 3 content_object input shape (raw chunk handoff); Step 21 debug.json schema (`step02_*` keys) | Step 3 (IMP-03 ContentObject extractor); Step 21 trace writer; Step 7/8 layout planner (consumes normalized section list) | OK -- additive; preserves prior `extract_*` semantics via dual-write; no AI in path | `src/phase_z2_pipeline.py` (commit `bac13c0`, +165/-3) |
| 2 | `#3` IMP-03 A-1 popup/image/table trace | Step 3 -- normalize popups/images/tables into ContentObject (B1 v0 extension); slide-level rich ContentObject trace | Step 2 normalize output shape (consumer); Step 4 internal composition planning (Step 4 itself still not implemented, so this row only emits trace); Step 21 debug.json schema | Step 4 (not yet implemented; receives data only via trace); Step 21 debug.json (`content_objects` field) | OK -- emits trace without coupling to downstream Step 4 (Step 4 still pending); raw content preserved (no AI summarization; satisfies `feedback_ai_isolation_contract`) | `src/phase_z2_content_extractor.py` + `src/phase_z2_pipeline.py` (commit `fc3f7d8`) |
| 3 | `#4` IMP-04 A-2 catalog expansion | Step 0 + Step 9 -- register/expand 16 frame_partials + `frame_contracts.yaml` schema; F17 paired_rows_4x2 + pill alternation + theme | Step 5 V4 evidence (catalog size affects evidence pool); Step 10 frame contract validator (consumes new contracts); Step 12 mapper PAYLOAD_BUILDERS (consumes new schema); Step 13 render template surface (16 new `templates/phase_z2/families/*.html`) | Step 5/9/10/12/13; smoke tests `scripts/smoke_frame_render.py` | OK -- pre-render planning only; catalog is read-only data for V4; frame DB extension matches Step 0 contract; commit `73a98b8` corrected F17 schema after first land (factual_verification path active) | `templates/phase_z2/catalog/frame_contracts.yaml`; `templates/phase_z2/families/*.html`; `src/phase_z2_mapper.py`; `docs/architecture/IMP-04-FRAME-SUITABILITY-MATRIX.md` |
| 4 | `#5` IMP-05 A-5 V4 fallback | Step 9 + Step 16/17 -- deterministic V4 candidate bridge (pre-render rank-2/3 fallback); trace schema; dedup invariant test; new `PASS_WITH_FALLBACK` status semantics in Step 20 | Step 5 evidence (candidate dedup must agree with rank-1 path); Step 6 composition (candidates[0] backward-compat); Step 9 application_plan; Step 20 status enum; debug.json trace | Step 9/16/17/20; `tests/test_phase_z2_v4_fallback.py`; `tests/test_catalog_invariant.py` | OK -- pre-render bridge (Block A); deterministic (no AI); rank-1 path unchanged (backward compat per backlog guardrail); dedup invariant test guards collision with `#4` catalog expansion | `src/phase_z2_pipeline.py` + `src/phase_z2_composition.py` + `src/phase_z2_router.py` (commits `15c5b9a`, `21476ae`, `23d1b25`) |
| 5 | `#6` IMP-06 B-1 zone-section override | Step 6 + Step 1/22 input -- CLI arg + composition planner override (`replaced_auto_unit`, `render_records`, plan-aware traces, units rebuild, empty zone) | Step 1 CLI surface; Step 6 `plan_composition` schema (CompositionUnit); Step 7/8/9 downstream (units rebuild forces re-planning); Step 13 render (Catch K render-path) | Step 7/8/9/13; debug.json render_records; `Front/` (later wired via `#8` U3) | Warning -- wide blast radius (4 commits + Stage 4 blocker-fix `52ccb7f`); units-rebuild touches Step 7/8/9 implicitly; verified by `tests/test_phase_z2_section_assignment_override.py` (285 + 42 + 228 lines). No AI; deterministic. Risk = override path widens Step 6 surface where Step 4 is still pending | `src/phase_z2_pipeline.py` (commits `d596fab` `b81e564` `1f15495` `52ccb7f`) |
| 6 | `#7` IMP-07 B-2 edited HTML to MDX reverse path | Step 22 + Step 1/2 input -- Vite/React `Front/` plus reverse path glue; pipeline re-entry | Step 1 MDX upload; Step 2 normalize (must accept reverse-path MDX); CLI plus service API; `feedback_ai_isolation_contract` (reverse must not invoke AI rewrite) | Step 2 (reverse-path consumer); `Front/client/src/services/designAgentApi.ts`; pipeline CLI | OK -- frontend-shipped (`0f0d3fa`); reverse path schema aligned with `#2` Stage 0 normalize via hard-link declared in backlog. AI isolation preserved (no normal-path LLM in reverse). | `Front/`; `src/phase_z2_pipeline.py`; backlog row IMP-07 |
| 7 | `#8` IMP-08 B-3 sub-section drag-drop | Step 3 schema -- sub_sections schema + V4 alias resolver + aligner canonical sub-id + decimal alias guard (N-R5) + frontend wire | Step 3 ContentObject schema (extends `#3`); Step 5 V4 alias surface; Step 6 composition planner (consumer); Step 9 application_plan; `Front/` zoneSections override (U3) | Step 5/6/9/13; `tests/test_phase_z2_subsection_schema.py` (82+100+61 lines) | OK -- additive schema with explicit backward-compat guard (alias resolver at 4 lookup sites); Stage 5 R2 blocker-fix `8f6cffc` force-drills aligner only on override targets (scope contained) | `src/phase_z2_pipeline.py` + `src/phase_z2_composition.py` (commits `a422d72` `5191aca` `ab2764c` `8f6cffc`) |
| 8 | `#9` IMP-09 B-4 non-default layout zone-geometry | Step 8 -- col-axis solver + per-zone geometry mapper + retry gate; 2-D dynamic dispatch for 5 preset families (single + horizontal-2 + vertical-2 + top-1-bottom-2 + top-2-bottom-1 + left-1-right-2 + left-2-right-1 + grid-2x2) | Step 7 layout vocabulary (consumer); Step 9 region-level (zone geometry feeds region ratios; Step 9 region-level still warning); Step 17 zone_ratio_retry (`#12` IMP-12 retry path); Step 13 render `build_layout_css` | Step 9/13/17; `tests/phase_z2/fixtures/build_layout_css/*.yaml` (16 fixtures); `tests/phase_z2/fixtures/retry_gate/*.yaml` | OK -- all 8 vocabulary entries enabled in build_layout_css; fixtures supply provable diff per preset; no Kei/Phase R' regression (existing `build_containers_type_b` untouched) | `src/phase_z2_pipeline.py` (commits `201099e` PR1, `1fb9732` PR2) |
| 9 | `#10` IMP-10 D-1 filtered_section_reasons UI | Step 20/22 -- frontend read-only display of `filtered_section_reasons` artifact | Step 20 slide_status enum (read-only consumer); `Front/` service API; no backend mutation | `Front/client/src/pages/Home.tsx`; `Front/client/src/services/designAgentApi.ts` | OK -- frontend-only; backend artifact strictly read-only per backlog guardrail | `Front/` (commit `0fb168b`, +45 lines) |
| 10 | `#11` IMP-11 D-2 Frame min_height display | Step 22 -- `min_height_px` hint exposed backend to UI; resize hint read-only; Step 9 v4 all-judgments min_height test | Step 0 frame contract (`min_height_px` field); Step 9 region-level (consumer); `Front/` SlideCanvas | Step 9; `Front/client/src/components/SlideCanvas.tsx`; `tests/test_phase_z2_step9_v4_all_judgments_min_height.py` | OK -- contract read-only; backend exposure is additive payload field (`src/phase_z2_pipeline.py` +32/-12 in `a79bd8b`) | `src/phase_z2_pipeline.py` + `Front/client/src/components/SlideCanvas.tsx`; `tests/test_phase_z2_step9_v4_all_judgments_min_height.py` |
| 11 | `#12` IMP-12 Step 16/17 retry refinement | Step 16 + Step 17 -- multi-donor + 3-stage salvage cascade; `redistribute` + glue + font compression; new router action; new failure_router taxonomy | Step 14 visual_check (donor selection consumes overflow events); Step 18 failure_classify (cascade adds new failure types); Step 19 next_action (downstream router consumer); Step 20 status semantics; `feedback_phase_z_spacing_direction` (cross-zone redistribute is grant-changing, not common-shrink) | Step 18/19/20; `tests/phase_z2/test_phase_z2_*` (cross_zone, font_step, glue, multi_donor, step17_salvage_chain -- 5 new test modules) | Warning -- large blast radius (4 src files + 5 test modules in `56619a0`); multi-donor introduces cross-zone state in Step 17; verified by 5 dedicated test modules. Risk = cascade may interact with `#5` V4 fallback path in Step 20 status enum (mitigated by separate status enums per `#5` exit report) | `src/phase_z2_failure_router.py` + `src/phase_z2_pipeline.py` + `src/phase_z2_retry.py` + `src/phase_z2_router.py` (commit `56619a0`) |
| 12 | `#13` IMP-13 A-3 frame preview consistency | Step 0 + Step 14/21 -- build-time frame preview generator (salvage of `capture_slide_screenshot`) | Step 0 catalog frame_partials (consumer for snapshot); Step 14 visual_check (uses preview for sanity, read-only); no Phase R' regression | `scripts/generate_frame_previews.py`; `tests/test_generate_frame_previews.py` | OK -- build-time only (not in runtime pipeline); deterministic; no Phase R' coupling (script lives in `scripts/`) | `scripts/generate_frame_previews.py` (commit `7d5639a`, 239 LOC + 50 LOC test) |
| 13 | `#14` IMP-14 A-4 slide-base iframe mode | Step 13 render -- `slide-base.html` conditional CSS (embedded vs standalone); Step 0 contract bit | Step 0 slide_base template; Step 13 Jinja2 deterministic render; `Front/` SlideCanvas (consumer) | Step 13; `Front/client/src/components/SlideCanvas.tsx`; `tests/phase_z2/test_slide_base_embedded_mode.py` | OK -- render-time contract only; Jinja2 deterministic; embedded mode reduces SlideCanvas friction (34 LOC simplified) | `templates/phase_z2/slide_base.html` + `src/phase_z2_pipeline.py` (commit `7a52ceb`) |
| 14 | `#15` IMP-15 Step 14 visual_check reinforcement (PARENT -- execution children `#45-#49`) | Integration glue only -- *no direct code* under #15; closure depends on `#45-#49` SHAs. De-duped against children (real change attribution = #45-#49 rows below) | Step 14 (parent contract); Step 15 fit_classification consumer; Step 21 debug.json trace; `PHASE-Z-FIT-CLASSIFIER-ROUTER-SPEC.md` (taxonomy row added by `#48`) | Step 15/21; spec doc | Warning -- close-timestamp anomaly only : `#49` closed at `2026-05-19T02:49:56+09:00`, about 15 minutes AFTER `#15` close `02:35:05+09:00`. All other children (#45-#48) close BEFORE #15. `#49` body declares verification-only path (no new SHA; re-uses `614c533`), so post-close `#49` close does not leak code into `#15`. Disposition : record-only, no reopen. | `docs/architecture/PHASE-Z-PIPELINE-OVERVIEW.md` Step 14/15; child rows below |
| 15 | `#16` IMP-16 B-2 verification helper axis | Step 1/2/14/21/22 -- `phase_z2_verification_utils.py` port + 8 verification test modules + U2 wiring design doc | Step 22 reverse path verification (consumer is `#7` IMP-07 once activated); no normal-path coupling | Step 14/21 trace consumers (utility); future `#7` reverse-path verification | OK -- utility module plus design doc only; no normal-path coupling (gated by `#7` activation); commit `23ba8b6` is design + utility port (335 LOC utility + 8 test modules + wiring doc) | `src/phase_z2_verification_utils.py`; `docs/architecture/IMP-16-U2-WIRING-DESIGN.md` (commit `23ba8b6`) |
| 16 | `#17` IMP-17 AI repair fallback infra (carve-out -- outside normal path) | Design-only boundary + 3-cond AND gate (User GO AND B4 frame_selection evidence AND IMP-04/05 live); `httpx` + SSE + retry + JSON parse pattern reference | Step 12 (AI position contract; carve-out body asserts normal path AI = 0); Step 16/17 fallback path (gated activation); `feedback_ai_isolation_contract` (foundational rule); backlog row + INSIGHT-MAP cross-ref | Step 12 (design boundary); future activation gated by 3-cond AND | OK -- design-only carve-out; `src/phase_z2_pipeline.py` change = 1 line (comment anchor for orchestrator test); no runtime AI added | `docs/architecture/IMP-17-CARVE-OUT.md` + `tests/orchestrator_unit/test_imp17_comment_anchor.py` (commit `e10ec36`) |
| 17 | `#18` IMP-18 I3 SVG coordinate reinforcement | Doc-only carve-out -- SVG gap report; `renderer._preprocess_svg_data` pattern reference | Step 0 frame_partials SVG geometry (reference); Phase R' (renderer.py) read-only | doc consumers; backlog row | OK -- doc-only (`docs/architecture/IMP-18-SVG-GAP-REPORT.md` + 1-line backlog status flip from `pending` to `documented`); no code touched | `docs/architecture/IMP-18-SVG-GAP-REPORT.md` (commit `cbbc163`) |
| 18 | `#45` (`#15` execution-1) image_aspect_mismatch detection + runtime test | Step 14 -- `image_aspect_mismatch` detection in visual_check; runtime test `test_phase_z2_step14_image_check.py` | Step 15 fit_classification consumer; Step 21 debug.json event surfacing (delegated to `#48`); `#15` parent close evidence | Step 15 (consumer via classifier event); `#47` (classifier integration) | OK -- Step 14 detection only (no Step 15 wiring yet; delegated to `#47`). Test scope local. | `src/phase_z2_pipeline.py` + `tests/phase_z2/test_phase_z2_step14_image_check.py` (commit `e9b3d2e`) |
| 19 | `#46` (`#15` execution-2) table overflow + element-identity dedup + Selenium test | Step 14 -- `table_self_overflow` detection; element-identity dedup; Selenium integration test | Step 14 dedup logic (must agree with image events from `#45`); Step 15 consumer (delegated to `#47`); `#15` parent | Step 15 (consumer); `#47` | Warning -- commit-message label drift only (Step 14 scope-discipline pattern itself matches `#45`). Commit `2827622` message reads `feat(IMP-16): ...`, which mis-labels the closing Gitea issue (actually closes `#46` = `#15` execution-2; IMP-16 backlog row is the verification utility carved out separately). Audit attribution corrected here; SHA `2827622` is the authoritative anchor. No code/contract leak; risk is record-keeping only. | `src/phase_z2_pipeline.py` + `tests/phase_z2/test_phase_z2_step14_table_check.py` (commit `2827622`) |
| 20 | `#47` (`#15` execution-3) classifier consumer (image + table) + pure-dict test | Step 15 -- classifier consumes image+table events from Step 14; pure-dict test (no Selenium) | Step 14 producers (`#45` + `#46`); Step 15 `CONTENT_TYPE_PATTERNS` taxonomy; Step 16 router (consumer) | Step 16; `tests/phase_z2/test_phase_z2_visual_classifier.py` | OK -- classifier wiring with pure-dict tests isolates Step 15 from Selenium dependency; aligns Step 14 producer to Step 15 consumer (Axis 3 invariant -- to be re-verified in Section 5) | `src/phase_z2_classifier.py` (commit `535c484`) |
| 21 | `#48` (`#15` execution-4) debug.json event surfacing + spec doc trace + regression | Step 21 debug.json event surfacing + `PHASE-Z-FIT-CLASSIFIER-ROUTER-SPEC.md` taxonomy row + regression test | Step 21 trace schema (additive); spec doc; regression guard | spec doc consumers; debug.json consumers (Front/, audit tooling) | OK -- 3-line pipeline change + 2 test modules + 1-line spec doc row; smallest blast radius of #15 children | `src/phase_z2_pipeline.py` + `docs/architecture/PHASE-Z-FIT-CLASSIFIER-ROUTER-SPEC.md` (commit `614c533`) |
| 22 | `#49` (`#15` execution-5) final integration + parent close | Verification-only -- re-uses `#48` `614c533` evidence; no new SHA per `#15` body | `#15` parent close; integration-only | `#15` parent | Warning -- close-timestamp anomaly (closed `2026-05-19T02:49:56+09:00`, about 15 minutes AFTER `#15` close `02:35:05+09:00`). Verification-only path has no code change, so anomaly is administrative only; no contract leak. | `#15` body + `614c533` (re-used) |
### Section 3 finding summary
- **OK** rows : `#2 #3 #4 #5 #7 #8 #9 #10 #11 #13 #14 #16 #17 #18 #45 #47 #48` (17)
- **Warning** rows : `#6` (wide override blast radius, contained by tests); `#12` (multi-donor + cascade, contained by 5 test modules); `#15` (close-timestamp anomaly via `#49`); `#46` (commit-message label drift only, SHA correct); `#49` (close-timestamp anomaly, verification-only) -- 5 rows
- **Blocker** rows : 0
- **Total** : 17 OK + 5 Warning + 0 Blocker = 22 rows (matches 22 closed issues under audit).
- **De-dup audit** : `#15` row carries no code attribution; all code/test work attributed to `#45-#48` (and `#49` = verification-only). No double-count.
### Section 3 cross-issue scope-myopia adjacency check
Adjacent-contract pairs flagged for Section 5 Axis 3 re-verification (producer to consumer continuity) :
- `#2` Step 2 normalize output -> `#3` Step 3 content_object input
- `#3` content_object schema -> `#8` sub_sections schema extension
- `#4` catalog expansion -> `#5` V4 fallback candidate pool dedup
- `#4` catalog expansion -> `#10`-`#11` `min_height_px` exposure
- `#9` layout vocabulary -> `#12` retry zone-ratio donor selection
- `#9` layout vocabulary -> `#11` Step 9 min_height v4-all-judgments test
- `#45 + #46` Step 14 events -> `#47` Step 15 classifier -> Step 16 router
- `#48` debug.json event surfacing -> `#21` (Step 21 debug consumer; open, excluded)
- `#17` AI carve-out -> `#5 + #4` activation 3-cond AND gate (gated, not active)
Axis 3 (Section 5) will verify each pair has agreeing producer-line / consumer-line on the live code.
---
## Section 4. Axis 2 -- 22 issues x 22 steps pipeline matrix
**Split rationale** : at u1 completion the combined REPORT was 21,070 bytes / 136 lines -- over the 10 KB readability threshold defined in the Stage 2 plan. Per the split rule (`combined REPORT >= 10 KB grid moves to MATRIX.md + back-pointer`), the 22 x 22 grid lives in the companion file :
- `docs/architecture/INTEGRATION-AUDIT-01-MATRIX.md`
**What MATRIX.md contains** :
- Step 0 precondition NOTE (NOT a grid column) -- 5 issues (`#4 #11 #13 #14 #18`) recorded with scope summary + evidence path.
- 22 x 22 grid (Step 1 through Step 22 columns x 22 issue rows). Cell legend : `P` = primary touch, `A` = adjacent contract, `.` = no touch. ASCII-only.
- Row footer (touched-step count) and column footer (touching-issue count + `H` HOTSPOT marker for col total >= 4).
- HOTSPOT enumeration (9 steps : `S2 S9 S13 S14 S15 S16 S17 S21 S22`).
- Cross-check against the 9 adjacent-contract pairs flagged in Section 3.
- Empty / low-touch column notes (Step 4 and Step 11 are `missing` per PHASE-Z-PIPELINE-OVERVIEW -- 0 touches expected).
- Parent/child de-dup sum check : `#15` row carries 3 adjacencies and zero `P` cells ; only `#45 #46 #47 #48` carry the 4 primary cells for the `#15` family ; `#49` is verification-only with all-`A`.
**Section 4 summary (for readers staying in REPORT)** :
- 9 hotspot steps (col total >= 4) : Step 2 (4), Step 9 (6), Step 13 (5), Step 14 (9 highest), Step 15 (6), Step 16 (4), Step 17 (4), Step 21 (8), Step 22 (7).
- 2 empty columns : Step 4 + Step 11. Consistent with master pipeline `missing` status -- no audit gap.
- Total grid cells filled = 77 (row sum = col sum, cross-checked).
- Top row-total issues : `#6` (7), `#5` (6), `#8` (6), `#12` (6) -- the 2 `Warning` rows (`#6 #12`) sit at the top, consistent with Section 3 wide-blast-radius finding.
---
## Section 5. Axis 3 -- Cross-issue conflict per invariant category
**Method** : 6 invariant categories listed in the issue body. Per category, identify producer file:line, consumer file:line, the named state key / contract, the closed issues that touch it, agree-or-conflict verdict, plus grep evidence path. Categories are evaluated against the live tracked code at audit time, not against historical snapshots.
### 5.1 Invariant category roster (from issue body)
| # | category | issue-body wording |
|---|---|---|
| C1 | `debug.json` schema | phase_z2 debug payload paths; no conflicting key type / semantics |
| C2 | `visual_check_passed` | `src/phase_z2_pipeline.py` Step 14 / 17; set-site <-> read-site agree |
| C3 | `fit_classification` / router | `src/phase_z2_mapper.py` + consumers; labels consistent producer -> consumer (charter mis-cite; live producer = `src/phase_z2_classifier.py` -- see §10 F-1) |
| C4 | Step 14 / 17 / 21 interactions | expected state values stay aligned across the trio |
| C5 | Phase R vs Phase Z boundary | no R regression, Z additions don't leak into R |
| C6 | template / catalog / frame count | all docs / code use same numbers (family = 13) |
### 5.2 Producer / consumer / agreement table
| C# | invariant key | producer (file : line) | consumer(s) (file : line) | touching closed issues | verdict | grep evidence |
|---|---|---|---|---|---|---|
| C1 | per-step JSON schema = `step_num`, `step_name`, `step_status`, `pipeline_path_connected`, `input`, `output`, `note`, `data` (locked) | `src/phase_z2_pipeline.py:2593` `_write_step_artifact` definition; locked schema docstring at `2605-2611` (Locked schema lines `2607-2610`) | every step writer in `src/phase_z2_pipeline.py` -- 24 call sites at lines `2782, 2812, 2857, 2934, 3184, 3619, 3652, 3674, 3793, 3804, 3826, 3881, 4056, 4308, 4481, 4507, 4527, 4549, 4658, 4677, 4688, 4706, 4761, 4780`; `Front/` reads `data/runs/.../steps/*.json`; audit tooling | `#2 step02_*`; `#3 content_objects`; `#5 v4_fallback_summary` + `selection_paths` + `fallback_selection_count`; `#6 render_records`; `#11 min_height_px` payload; `#48 image_events` / `table_events` event surfacing | AGREE -- all step writers go through the single `_write_step_artifact` site with the locked field set; additive `data` payload only; no conflicting key types observed | `Grep _write_step_artifact src/phase_z2_pipeline.py` = 1 definition (line 2593) + 24 call sites = 25 total occurrences (all 24 call sites enumerated in consumer column); all share the same `_write_step_artifact(run_dir, step_num, name, data, *, step_status, pipeline_path_connected, inputs, outputs, note)` kwargs surface |
| C2 | `visual_check_passed: bool` set at Step 14 / read at Step 17 | `src/phase_z2_classifier.py:495` `visual_check_passed = bool(overflow.get("passed", False)) and not classifications` returned at `497` | `src/phase_z2_router.py:128` `if fit_classification.get("visual_check_passed", True): ... router_active = False`; `src/phase_z2_pipeline.py:2560` sets `slide_status["visual_check_passed"] = visual_passed`; pipeline summary reads at `4800`, `4804`, `4830` | `#15` parent; `#45` (image_events flip the flag); `#46` (table_events flip the flag); `#47` (classifier widens semantic to `passed AND no classifications`) | AGREE -- single set-site (classifier.py:495) + slide_status mirror (pipeline.py:2560); router.py:128 + pipeline.py:4800/4804/4830 read the same key. Default `.get(..., True)` at router.py:128 is safe because absent key = no classification = pass | `Grep visual_check_passed src` = 14 hits across `classifier.py` + `router.py` + `pipeline.py` -- producer / consumer line set matches |
| C3 | `fit_classification` dict keys = `visual_check_passed`, `classifications`, `summary`, `categories_seen`, `unclassified_signals`, `placement_diagnostics`; classifier <-> router consumer | `src/phase_z2_classifier.py:496-506` `classify_visual_runtime_check` return dict | `src/phase_z2_router.py:109` `route_fit_classification(fit_classification)`; `src/phase_z2_pipeline.py:4524` `fit_classification = classify_visual_runtime_check(overflow, debug_zones)`; pipeline re-classify after retry at `4582 / 4643`; router decision call at `4540 / 4583 / 4644`; retry consumer `src/phase_z2_retry.py:47` reads `fit_classification` | `#5` (V4 fallback PASS_WITH_FALLBACK semantics); `#12` (retry router multi-donor + cascade); `#15` parent; `#47` (classifier feed); `#48` (debug surfacing) | AGREE -- producer key set is the exact set consumed downstream. NOTE : the issue body says `src/phase_z2_mapper.py` for invariant C3, but the live producer is `src/phase_z2_classifier.py` (`mapper.py` owns slot payload, not fit classification). This is a record-keeping mismatch in the issue body, not a code conflict. Recorded as Section 10 follow-up candidate F-1 | `Grep fit_classification src` = 30 total occurrences across 4 files (`classifier.py` 3 hits incl. docstring/comments; `pipeline.py` 20 hits; `router.py` 5 hits; `retry.py` 2 hits). Active code use sites = producer at `classifier.py:497`; consumers at `router.py:128 / 139` + `pipeline.py 2732 / 4524 / 4540 / 4571 / 4582 / 4583 / 4643 / 4644 / 4754 / 4804 / 4805` + `retry.py:47 / 67`. Remaining occurrences are imports / function-parameter declarations / docstring references |
| C4 | Step 14 visual_check overflow events (`image_events`, `table_events`, `passed`) -> Step 15/16 (fit + router) -> Step 17 retry action -> Step 21 debug surface | Step 14 emit sites `src/phase_z2_pipeline.py:2236` (`image_events`), `2282` (`table_events`), `2367 / 2386` (aggregation); Step 15 classifier consumes both event lists at `src/phase_z2_classifier.py:429 / 453`; Step 16 router at `src/phase_z2_router.py:142`; Step 17 retry orchestration at `src/phase_z2_pipeline.py:4571 / 4583 / 4644`; Step 21 trace producer at `src/phase_z2_pipeline.py:4762-4777` (`step21_debug_index.json` + `debug.json` outputs) | Step 21 `debug.json` index reader (`Front/` + audit tooling); pipeline summary 4791-4841 | `#12` (retry cascade Step 17 multi-donor + glue + font compression); `#15 / #45 / #46 / #47 / #48` (Step 14 producer / Step 15 classifier consumer / Step 21 surface); `#10` filtered_section_reasons (Step 22 read-only, Step 21 source) | AGREE with one DOCUMENTED PARTIAL -- Step 21 writer at `pipeline.py:4772` is `step_status="partial"` with note `region marker partial 미주입 -- Step 21 ⚠ partial`. This is an *acknowledged* partial state recorded in trace, not a contract conflict between issues. Recorded as Section 6 status row | `Grep step_num.*=.*21\|outputs.*debug\.json src/phase_z2_pipeline.py` = single producer at line 4762-4777 |
| C5 | Phase R' (`src/renderer.py`, `src/content_editor.py`, `src/html_validator.py`, `src/block_selector.py`) <-> Phase Z (`src/phase_z2_*.py`) module boundary; no cross-import | `src/phase_z2_pipeline.py` (Phase Z entry) has zero imports of Phase R' modules; verified via `Grep "from renderer\|import renderer\|from phase_q\|from src\.renderer" src/phase_z2_pipeline.py` = `No matches found` | inverse direction `src/renderer.py` and `src/block_selector.py` have zero references to `phase_z2`; verified via `Grep phase_z2 src/renderer.py` = 0 and `Grep phase_z2 src/block_selector.py` = 0 | `#13` (build-time frame preview generator, scripts/ only); `#14` (slide-base iframe mode -- Phase Z only); `#16` (verification utility for Phase Z, no Phase R coupling); `#17` (AI carve-out, design-only no R coupling); `#18` (SVG gap report doc-only) | AGREE -- boundary clean both directions for the closed-issue scope. No Phase R' regression observed; Phase Z additions stay in `phase_z2_*.py` modules | `Grep` results above |
| C6 | family templates count vs frame_contracts.yaml count (= 11 in tracked baseline); docs cite "family = 13" including 2 in-progress untracked files | `templates/phase_z2/families/*.html` tracked = 11 (`git ls-files templates/phase_z2/families/` produces 11 entries); `templates/phase_z2/catalog/frame_contracts.yaml` top-level entries = 11 (`grep -cE "^[a-z_]+:$"` = 11) | `src/phase_z2_mapper.py` PAYLOAD_BUILDERS / ITEM_PARSERS / COLUMN_BODY_PARSERS registries (mapper.py:10-16 docstring + 262 / 306 / 332 / 369 / 414 / 424 / 471 registry sites); render surface `templates/phase_z2/families/*.html` | `#4` (16 frame_partials + F17 paired_rows_4x2 schema + theme); `#5` (V4 fallback candidate pool dedup); `#13` (frame preview generator); `#18` (SVG gap report cites `families/*.html (13)`) | AGREE FOR TRACKED BASELINE -- 11 tracked family templates <-> 11 frame_contracts entries. SURFACE NOTE : 2 untracked WIP family templates (`app_sw_package_vs_solution.html`, `pre_construction_model_info_stacked.html`) exist on disk but are NOT in any closed issue and NOT yet contracted. IMP-18 doc "families/*.html (13)" is forward-looking, includes the 2 WIP files. No closed-issue contract is broken; documentation drift is recorded as Section 10 follow-up candidate F-2 | `git ls-files templates/phase_z2/families/` = 11; `ls templates/phase_z2/families/*.html` = 13 (2 untracked); `grep -cE "^[a-z_]+:$" frame_contracts.yaml` = 11 |
### 5.3 Cross-issue adjacency continuity (Section 3 pairs re-verified)
| Section 3 adjacent pair | invariant carrying the contract | live continuity verdict |
|---|---|---|
| `#2` Step 2 normalize -> `#3` Step 3 content_object input | C1 (debug.json `step02_*` + content_objects) | OK -- additive payload, schema preserved via `_write_step_artifact` |
| `#3` content_object schema -> `#8` sub_sections schema | C1 + C4 (alias resolver state) | OK -- alias resolver covers 4 lookup sites (REPORT Section 3 row #8 evidence) |
| `#4` catalog -> `#5` V4 fallback dedup | C3 + C6 (frame count + classifier consumer) | OK -- candidates[0] backward-compat verified by `tests/test_catalog_invariant.py` (REPORT Section 3 row #5) |
| `#4` catalog -> `#10 / #11` `min_height_px` exposure | C1 + C6 | OK -- `min_height_px` is additive read-only field |
| `#9` layout vocabulary -> `#12` retry donor selection | C3 + C4 (Step 17 cascade) | OK -- multi-donor cross-zone state lives inside Step 17 retry; spacing direction matches `feedback_phase_z_spacing_direction` (no common-shrink) |
| `#9` layout vocabulary -> `#11` Step 9 min_height v4-all-judgments | C6 | OK -- guarded by `tests/test_phase_z2_step9_v4_all_judgments_min_height.py` |
| `#45 + #46` Step 14 events -> `#47` Step 15 classifier -> Step 16 router | C2 + C3 + C4 | OK -- live trace `image_events` / `table_events` enter classifier at `classifier.py:429 / 453`, flow into router at `router.py:142` |
| `#48` debug.json event surfacing -> `#21` (open, excluded) | C1 | OK for closed scope -- open consumer `#21` is outside audit window |
| `#17` AI carve-out -> `#5 / #4` activation 3-cond AND gate | C5 (boundary not yet crossed) | OK -- gate is *closed* (`User GO AND B4 frame_selection evidence AND IMP-04/05 live`); no normal-path AI active |
### 5.4 Axis 3 summary
- 6 invariant categories evaluated. All AGREE for the closed-issue audit scope.
- 2 surface notes recorded as Section 10 follow-up candidates :
- **F-1** : issue body cites `src/phase_z2_mapper.py` for invariant C3 (`fit_classification`), but the live producer is `src/phase_z2_classifier.py`. Record-keeping correction needed in any future audit charter, not a code conflict. RESOLVED via IMP-53 (2026-05-19)
- **F-2** : 2 untracked family templates exist on disk without `frame_contracts.yaml` entries; IMP-18 doc cites "families/*.html (13)" forward-looking. Tracked baseline (11 / 11) is consistent. Contract drift is *not* present for any closed issue; the WIP delta belongs to open work. RESOLVED via #52 option (c) (2026-05-19) -- WIP allowlist captured in `templates/phase_z2/families/_WIP_FILES.md`; tracked + contracted baseline unchanged at 11/11; promote / remove gated on #42.
- 1 documented partial recorded :
- Step 21 `_write_step_artifact` at `pipeline.py:4772` carries `step_status="partial"` with note `region marker partial 미주입 -- Step 21 ⚠ partial`. This is *self-honest acknowledged* per `feedback_artifact_status_naming`; no cross-issue conflict.
- Phase R' <-> Phase Z boundary clean both directions for the 22 closed issues.
- 0 Blocker findings in Axis 3.
### 5.5 Live-grep re-verification stamp (audit date 2026-05-19)
All numerical claims in Section 5.2 re-verified against live source on the audit date. Commands and results :
| Claim | Command | Live result | Status |
|---|---|---|---|
| C1 producer + consumer count | `Grep _write_step_artifact src/phase_z2_pipeline.py -n` | 1 definition (`pipeline.py:2593`) + 24 call sites at lines `2782, 2812, 2857, 2934, 3184, 3619, 3652, 3674, 3793, 3804, 3826, 3881, 4056, 4308, 4481, 4507, 4527, 4549, 4658, 4677, 4688, 4706, 4761, 4780` = 25 total occurrences | MATCH (Section 5.2 C1 row already lists all 24 call sites) |
| C2 consumer scan | `Grep visual_check_passed src` | 14 hits across 3 files (`classifier.py:5`, `pipeline.py:6`, `router.py:3`) | MATCH (Section 5.2 C2 row says "14 hits across `classifier.py` + `router.py` + `pipeline.py`") |
| C3 consumer scan | `Grep fit_classification src` | 30 hits across 4 files (`classifier.py:3`, `pipeline.py:20`, `retry.py:2`, `router.py:5`) | MATCH (Section 5.2 C3 row says "30 total occurrences across 4 files") |
| C6 family templates -- tracked | `git ls-files templates/phase_z2/families/` | 11 entries | MATCH (Section 5.2 C6 row says "tracked = 11") |
| C6 family templates -- on disk | `ls templates/phase_z2/families/*.html | wc -l` | 13 files (11 tracked + 2 WIP untracked : `app_sw_package_vs_solution.html`, `pre_construction_model_info_stacked.html`) | MATCH (Section 5.2 C6 row + F-2 follow-up candidate) |
| C6 frame_contracts entries | `grep -cE "^[a-z_]+:$" templates/phase_z2/catalog/frame_contracts.yaml` | 11 | MATCH (Section 5.2 C6 row says "= 11") |
No discrepancy between Section 5.2 grep evidence and live code. Re-verification re-confirms u3 Axis 3 conclusion : 6 invariant categories all AGREE; 2 record-keeping follow-up candidates (F-1, F-2); 1 documented partial (Step 21); 0 Blocker findings.
---
## Section 6. Axis 4 -- Backlog vs code reality status matrix
**Method** : per closed issue, compare (a) `docs/architecture/PHASE-Z-IMPLEMENTATION-ISSUE-BACKLOG.md` status column at audit time (live read 2026-05-19), (b) live src/ + templates/ + tests/ + docs/ evidence (grep hits + file existence), (c) the audit-allowed status enum `implemented | documented (deferred) | pending`, (d) mismatch flag.
**Per issue-body rule set** :
- `implemented` -> live grep on `src/**` MUST show wired call site(s); not just a single declaration with no consumer.
- `documented (deferred)` -> live grep on `src/**` MUST NOT show a production code path that assumes the feature is active (carve-out only).
- `pending` -> live grep on `src/**` MUST NOT show wired implementation (or evidence shows incomplete).
- `pending -> documented` flip -> reason cited in backlog row must match what `src/**` actually contains.
### 6.1 Backlog status legend (live read on audit date)
| backlog status | IMP rows under audit | meaning |
|---|---|---|
| `documented` | `IMP-18` (1 row) | doc-only carve-out, no production path |
| `pending` | `IMP-02` through `IMP-17` (16 rows) | backlog status column has NOT been flipped, despite Gitea issue being closed |
| (no backlog row) | `#45 / #46 / #47 / #48 / #49` (5 rows) | execution children of `#15`; backlog tracks the parent `IMP-15` only -- and `IMP-15` is itself still marked `pending` in §2 row |
**Headline Axis 4 finding** : `PHASE-Z-IMPLEMENTATION-ISSUE-BACKLOG.md` status column is **stale across the entire closed-issue audit scope** -- 16 of 22 audited issues are flagged `BACKLOG_STALE` (backlog `pending` vs Gitea closed + live code wired); additionally 5 of 22 carry `NO_BACKLOG_ROW` for the `#15` execution children (`#45-#49`), and only 1 of 22 (`#18`) is `AGREE`. Reconciliation: 16 `BACKLOG_STALE` + 5 `NO_BACKLOG_ROW` + 1 `AGREE` = 22 (matches Section 6.3 summary and the 15+1=16 flip plan in §6.3 follow-up reference). This is documentation drift, not a code-side contract conflict; recorded as Section 10 follow-up candidate `F-3`.
### 6.2 Axis 4 -- 22 row backlog vs code reality matrix
Status meaning (audit verdict column) :
- `implemented_live` = backlog should be flipped to `implemented`; live src/ wiring proves it (grep evidence below).
- `documented_live` = backlog `documented` matches code reality (doc-only carve-out; no prod path).
- `child_of_parent` = no backlog row by design (execution child of parent IMP-15); status tracked via parent row.
Mismatch flag :
- `BACKLOG_STALE` = backlog says `pending` but code is wired live. Documentation drift only; no code conflict.
- `AGREE` = backlog status matches live code reality.
- `NO_BACKLOG_ROW` = execution child, child not represented in backlog; not an error, but parent `IMP-15` row is itself stale.
| # | issue (title) | backlog status (live read) | audit verdict | mismatch flag | live grep evidence |
|---|---|---|---|---|---|
| 1 | `#2` IMP-02 A-1 Stage 0 normalize chained adapter | `pending` (§1 row 2) | `implemented_live` | BACKLOG_STALE | `Grep "normalize_mdx_content\|extract_major_sections\|extract_conclusion_text" src/` = 24 hits across 6 files (`mdx_normalizer.py`, `phase_z2_content_extractor.py`, `phase_z2_pipeline.py` 9 hits, `pipeline.py`, `pipeline_v2.py`, `section_parser.py`); commit `bac13c0` +165/-3 |
| 2 | `#3` IMP-03 A-1 popup/image/table trace | `pending` (§1 row 3) | `implemented_live` | BACKLOG_STALE | `src/phase_z2_content_extractor.py` file exists (Glob hit); commit `fc3f7d8` |
| 3 | `#4` IMP-04 A-2 catalog expansion | `pending` (§1 row 4) | `implemented_live` | BACKLOG_STALE | `git ls-files templates/phase_z2/families/` = 11 tracked; `frame_contracts.yaml` top-level entries = 11; commit `73a98b8` corrects F17 schema; matches Axis 3 C6 |
| 4 | `#5` IMP-05 A-5 V4 fallback | `pending` (§1 row 5) | `implemented_live` | BACKLOG_STALE | `Grep "PASS_WITH_FALLBACK\|v4_fallback\|fallback_selection" src/` = 28 hits in `phase_z2_pipeline.py`; commits `15c5b9a`, `21476ae`, `23d1b25` |
| 5 | `#6` IMP-06 B-1 Zone-section override | `pending` (§1 row 6) | `implemented_live` | BACKLOG_STALE | `Grep "replaced_auto_unit\|render_records\|zone_section_override" src/` = 33 hits in `phase_z2_pipeline.py`; commits `d596fab` / `b81e564` / `1f15495` / `52ccb7f` |
| 6 | `#7` IMP-07 B-2 edited HTML to MDX reverse path | `pending` (§1 row 7) | `implemented_live` | BACKLOG_STALE | `Front/client/src/services/designAgentApi.ts` file exists (Glob hit); commit `0f0d3fa` |
| 7 | `#8` IMP-08 B-3 sub-section drag-drop | `pending` (§1 row 8) | `implemented_live` | BACKLOG_STALE | `Grep "sub_sections\|sub_section_id\|subsection_alias" src/` = 14 hits across `block_assembler.py` (12) + `phase_z2_pipeline.py` (2); commits `a422d72` / `5191aca` / `ab2764c` / `8f6cffc` |
| 8 | `#9` IMP-09 B-4 non-default layout zone-geometry | `pending` (§1 row 9) | `implemented_live` | BACKLOG_STALE | `Grep "build_layout_css\|preset_layout\|zone_geometry" src/` = 11 hits in `phase_z2_pipeline.py`; commits `201099e` / `1fb9732` |
| 9 | `#10` IMP-10 D-1 filtered_section_reasons UI | `pending` (§1 row 10) | `implemented_live` | BACKLOG_STALE | `Grep "filtered_section_reasons" Front/` = 4 hits (`Home.tsx`, `designAgentApi.ts`); + `src/phase_z2_pipeline.py` 6 hits (read-only consumer); commit `0fb168b` +45 lines |
| 10 | `#11` IMP-11 D-2 Frame min_height display | `pending` (§1 row 11) | `implemented_live` | BACKLOG_STALE | `Grep "min_height_px" src/` = 50 hits across 6 files (`block_reference.py`, `block_selector.py`, `fit_verifier.py`, `phase_z2_pipeline.py` 21 hits, `phase_z2_retry.py`, `space_allocator.py`); + Front/ 21 hits across 7 files including `SlideCanvas.tsx` (8); commit `a79bd8b` |
| 11 | `#12` IMP-12 Step 16/17 retry refinement | `pending` (§2 row 12 IMP-12) | `implemented_live` | BACKLOG_STALE | `Grep "phase_z2_failure_router\|phase_z2_retry\|redistribute\|font_compression" src/` = 63 hits across 7 files (incl. `phase_z2_failure_router.py` 17, `phase_z2_retry.py` 16, `phase_z2_router.py` 6, `phase_z2_pipeline.py` 17); commit `56619a0` |
| 12 | `#13` IMP-13 A-3 frame preview consistency | `pending` (§2 row 13) | `implemented_live` | BACKLOG_STALE | `scripts/generate_frame_previews.py` file exists (Glob hit); build-time only (scripts/, not runtime src/) -- matches `documented (deferred)` semantics for *runtime* path but verdict here = implemented_live because the script is the deliverable per issue body; commit `7d5639a` |
| 13 | `#14` IMP-14 A-4 slide-base iframe mode | `pending` (§2 row 14) | `implemented_live` | BACKLOG_STALE | `templates/phase_z2/slide_base.html` file exists (Glob hit); `Grep "slide_base\|embedded_mode\|standalone_mode" src/` = 25 hits across 5 files (incl. `block_assembler.py` 8, `phase_z2_pipeline.py` 11); commit `7a52ceb` |
| 14 | `#15` IMP-15 Step 14 visual_check reinforcement (PARENT) | `pending` (§2 row 15) | `implemented_live` (via children `#45-#49`) | BACKLOG_STALE | parent integration only; live code attribution belongs to child rows below (Stage 1 de-dup rule). All 4 child SHAs present in repo (`e9b3d2e` / `2827622` / `535c484` / `614c533`) |
| 15 | `#16` IMP-16 B-2 verification helper axis | `pending` (§2 row 16) | `implemented_live` | BACKLOG_STALE | `src/phase_z2_verification_utils.py` file exists (Glob hit); `docs/architecture/IMP-16-U2-WIRING-DESIGN.md` exists; commit `23ba8b6` |
| 16 | `#17` IMP-17 AI repair fallback infra (carve-out) | `pending` (§2 row 17) | `documented_live` | BACKLOG_STALE (status semantics) | `docs/architecture/IMP-17-CARVE-OUT.md` file exists (Glob hit); src/ runtime AI = 0 (verified Axis 3 C5 boundary); 3-cond AND gate closed; commit `e10ec36` -- 1 line in `src/phase_z2_pipeline.py` is comment anchor only, not a runtime path. Mismatch FLAG semantics : backlog says `pending`, but reality = `documented (deferred)`. The flag is BACKLOG_STALE *with status-class shift*, distinguished from rows above. |
| 17 | `#18` IMP-18 I3 SVG coordinate reinforcement | `documented` (§2 row 18) | `documented_live` | AGREE | `docs/architecture/IMP-18-SVG-GAP-REPORT.md` file exists (Glob hit); pure doc carve-out; no `src/**` touched; commit `cbbc163` -- the ONLY closed audited issue whose backlog status already reflects code reality |
| 18 | `#45` (`#15` execution-1) image_aspect_mismatch detection | no backlog row | `child_of_parent` | NO_BACKLOG_ROW | `tests/phase_z2/test_phase_z2_step14_image_check.py` file exists (Glob hit); `Grep "image_aspect_mismatch" src/` = 6 hits across `phase_z2_classifier.py` (2 : lines 426, 435) + `phase_z2_pipeline.py` (4 : lines 131, 2236, 2367, 4517); commit `e9b3d2e` |
| 19 | `#46` (`#15` execution-2) table_self_overflow detection | no backlog row | `child_of_parent` | NO_BACKLOG_ROW | `tests/phase_z2/test_phase_z2_step14_table_check.py` file exists (Glob hit); `Grep "table_self_overflow" src/` = 3 hits all in `phase_z2_pipeline.py` (lines 136, 2282, 2386); commit `2827622` (commit-message label drift `feat(IMP-16)` flagged in Section 3 row 19) |
| 20 | `#47` (`#15` execution-3) classifier consumer (image + table) | no backlog row | `child_of_parent` | NO_BACKLOG_ROW | `tests/phase_z2/test_phase_z2_visual_classifier.py` file exists (Glob hit); `Grep "classify_visual_runtime_check\|CONTENT_TYPE_PATTERNS" src/` = 8 hits across `phase_z2_classifier.py` (4) + `phase_z2_pipeline.py` (4); commit `535c484` |
| 21 | `#48` (`#15` execution-4) debug.json event surfacing + spec doc + regression | no backlog row | `child_of_parent` | NO_BACKLOG_ROW | `Grep "step21_debug_index\|step21_debug" src/` = 1 hit (`phase_z2_pipeline.py`); `docs/architecture/PHASE-Z-FIT-CLASSIFIER-ROUTER-SPEC.md` has taxonomy row (Section 3 row 21 evidence); commit `614c533`; Axis 3 C4 confirms `image_events` / `table_events` end-to-end |
| 22 | `#49` (`#15` execution-5) final integration + parent close | no backlog row | `child_of_parent` (verification-only) | NO_BACKLOG_ROW + close-timestamp anomaly (recorded Section 3 row 22) | verification-only per `#15` body; no new SHA; re-uses `614c533` evidence; no fresh grep needed |
### 6.3 Axis 4 summary
- **BACKLOG_STALE** rows : `#2 #3 #4 #5 #6 #7 #8 #9 #10 #11 #12 #13 #14 #15 #16 #17` = 16 rows (status column reads `pending` but live code is wired; for `#17` the right target status is `documented (deferred)` while for the other 15 it is `implemented`).
- **AGREE** rows : `#18` = 1 row (the only issue whose backlog status truthfully reflects code reality).
- **NO_BACKLOG_ROW** rows : `#45 #46 #47 #48 #49` = 5 rows (execution children, by-design no backlog row; parent `IMP-15` row exists but is itself BACKLOG_STALE).
- **Total** : 16 + 1 + 5 = 22 rows (matches 22 closed issues under audit).
- **Implementation-vs-documented split** (audit verdict, ignoring backlog wording) :
- `implemented_live` (runtime path wired) : `#2 #3 #4 #5 #6 #7 #8 #9 #10 #11 #12 #13 #14 #15(via children) #16` = 15 rows
- `documented_live` (doc-only / design-only carve-out, no runtime path) : `#17 #18` = 2 rows
- `child_of_parent` (no backlog row, attribution via parent) : `#45-#49` = 5 rows
- **0 Blocker findings in Axis 4.** No closed issue is `pending` *and* unimplemented; the only mismatches are documentation drift in the backlog status column.
- **Cross-axis consistency** :
- Axis 3 C6 frame count `11 tracked / 11 contract entries / 13 on disk (2 WIP)` matches the IMP-04 evidence in Axis 4 row 3 (BACKLOG_STALE but live code present).
- Axis 3 C5 boundary (Phase R' <-> Phase Z) clean both ways re-confirms `#17 #18` as documented_live (no R' leak).
- Axis 1 (Section 3) `Warning` rows `#6 #12 #15 #46 #49` are all still `implemented_live` in Axis 4 -- the warnings are about *blast radius* and *administrative drift*, not implementation absence.
- **Follow-up candidate F-3** (Section 10) : `PHASE-Z-IMPLEMENTATION-ISSUE-BACKLOG.md` status column needs a sweep to flip 15 rows `pending` -> `implemented`, 1 row `pending` -> `documented (deferred)` for `IMP-17`, and either add child-row stubs for `#45-#49` or add a footnote on the `IMP-15` row pointing at the 5 execution children. This is a single-file documentation-only edit; orthogonal to source-code Stage 3 work; safe under audit-only scope (deferred to a separate follow-up issue, NOT this audit's u7 backlog row).
---
## Section 7. Representative pipeline runs
**Method** : run the Phase Z runtime entry (`python -m src.phase_z2_pipeline <mdx_path> <run_id>`) on the two locked samples (`samples/mdx_batch/03.mdx` smoke + `samples/mdx_batch/04.mdx` details+images). Per run capture (from `data/runs/<run_id>/phase_z2/debug.json`) : top-level keys, `slide_status.visual_check_passed`, `slide_status.overall`, zone count, per-zone frame template + slot keys + slot key count, `slide_status.visual_fail_reasons`, `slide_status.filtered_section_reasons`, `selection_paths`, `image_events` / `table_events` count. Compare invariants across both runs.
**Audit date** : 2026-05-19. Both runs are fresh on this audit pass (run_ids `audit50_run_03_smoke` + `audit50_run_04_details`).
### 7.1 Run #1 -- `samples/mdx_batch/03.mdx` (smoke baseline)
| field | value |
|---|---|
| `run_id` | `audit50_run_03_smoke` |
| MDX title parsed | `DX 실행 체계 구축 방안` |
| sections parsed | 2 (`03-1`, `03-2`) |
| layout preset | `horizontal-2` (composition v0 count-based) |
| mode | `composition_v0_layout_8preset` |
| debug.json top-level keys | `composition_planner_debug`, `fit_classification`, `image_events`, `layout_css`, `layout_preset`, `mode`, `mode_note`, `mvp1_allowed_statuses`, `retry_trace`, `router_decision`, `slide_status`, `table_events`, `v4_label_to_phase_z_status`, `v4_source`, `visual_runtime_check`, `zone_geometries_px`, `zones` (17 keys) |
| `slide_status.visual_check_passed` | `True` |
| `slide_status.full_mdx_coverage` | `True` |
| `slide_status.rendered` | `True` |
| `slide_status.overall` | `PASS` |
| `slide_status.visual_fail_reasons` | `[]` (empty) |
| `slide_status.filtered_section_reasons` | `[]` (empty) |
| `slide_status.fallback_selection_count` | `0` |
| `fit_classification.visual_check_passed` | `True` (mirrors slide_status) |
| `fit_classification.classifications` | `[]` |
| `fit_classification.categories_seen` | `[]` |
| `router_decision.action` | `None` (no retry path triggered) |
| `image_events` count | `0` |
| `table_events` count | `0` |
| zone count | `2` |
| zone[0] (top) | template `three_parallel_requirements` (frame 13), contract `three_parallel_requirements`, label `use_as_is`, slot keys `['pillars', 'title']` (2), sections `['03-1']`, `height_px=228`, `width_px=1180` |
| zone[1] (bottom) | template `process_product_two_way` (frame 29), contract `process_product_two_way`, label `use_as_is`, slot keys `['banner_left', 'banner_right', 'process', 'product', 'title']` (5), sections `['03-2']`, `height_px=343`, `width_px=1180` |
| `selection_paths` | both `rank_1` (no fallback) |
| fail / overflow events | none |
### 7.2 Run #2 -- `samples/mdx_batch/04.mdx` (details + images)
| field | value |
|---|---|
| `run_id` | `audit50_run_04_details` |
| MDX title parsed | `DX 지연 요인` |
| sections parsed | 2 (`04-1`, `04-2`) |
| sections aligned | 3 (`04-1`, `04-2-sub-1`, `04-2-sub-2`) -- IMP-08 sub_section schema active |
| layout preset | `single` (composition v0 count-based; only 1 unit survived filtering) |
| mode | `composition_v0_layout_8preset` |
| debug.json top-level keys | identical 17 keys as Run #1 (`composition_planner_debug`, `fit_classification`, `image_events`, `layout_css`, `layout_preset`, `mode`, `mode_note`, `mvp1_allowed_statuses`, `retry_trace`, `router_decision`, `slide_status`, `table_events`, `v4_label_to_phase_z_status`, `v4_source`, `visual_runtime_check`, `zone_geometries_px`, `zones`) |
| `slide_status.visual_check_passed` | `True` |
| `slide_status.full_mdx_coverage` | `False` |
| `slide_status.rendered` | `True` (partial artifact -- viable units only) |
| `slide_status.overall` | `PARTIAL_COVERAGE` |
| `slide_status.visual_fail_reasons` | `[]` (visual side OK; coverage failure is upstream of visual_check) |
| `slide_status.filtered_section_reasons` | `[]` (filtering recorded via `selection_paths` chain_exhausted / no_v4_candidate, not via `filtered_section_reasons`) |
| `slide_status.fallback_selection_count` | `0` |
| `fit_classification.visual_check_passed` | `True` |
| `fit_classification.classifications` | `[]` |
| `fit_classification.categories_seen` | `[]` |
| `router_decision.action` | `None` |
| `image_events` count | `0` |
| `table_events` count | `0` |
| zone count | `1` (single preset) |
| zone[0] (primary) | template `bim_issues_quadrant_four` (frame 16), contract `bim_issues_quadrant_four`, label `light_edit`, slot keys `['quadrant_1_body', 'quadrant_1_label', 'quadrant_2_body', 'quadrant_2_label', 'quadrant_3_body', 'quadrant_3_label', 'quadrant_4_body', 'quadrant_4_label', 'title']` (9), sections `['04-2-sub-2']`, `height_px=585`, `width_px=1180` |
| `selection_paths` | `04-1=chain_exhausted`, `04-2-sub-1=chain_exhausted`, `04-2-sub-2=rank_1`, `04-2=no_v4_candidate` |
| fail / overflow events | none |
### 7.3 Cross-run invariants
| invariant | run #1 (03.mdx) | run #2 (04.mdx) | verdict |
|---|---|---|---|
| debug.json top-level key set | 17 keys (above) | identical 17 keys | AGREE -- Step 21 schema stable across both runs (Axis 3 C1) |
| `slide_status` schema keys | 19 keys (`visual_check_passed`, `full_mdx_coverage`, `rendered`, `overall`, `visual_fail_reasons`, `filtered_section_ids`, `filtered_section_reasons`, `aligned_section_ids`, `covered_section_ids`, `adapter_needed_count`, `adapter_needed_units`, `content_truncated_count`, `content_truncated_units`, `fallback_selection_count`, `fallback_selections`, `fallback_used`, `selection_path`, `selection_paths`, `note`) | identical 19 keys | AGREE -- slide_status surface stable (Axis 3 C2 + C4) |
| `fit_classification` shape | `{visual_check_passed, classifications, summary, categories_seen, ...}` -- matches Axis 3 C3 row | identical shape | AGREE -- classifier output schema invariant |
| `visual_check_passed` semantic | `True` AND `classifications=[]` -> overall `PASS` | `True` AND `classifications=[]` AND `full_mdx_coverage=False` -> overall `PARTIAL_COVERAGE` | AGREE -- visual side passing under both runs; `PARTIAL_COVERAGE` is composition-planner side (upstream of Step 14), so visual_check_passed does NOT contradict overall status (Axis 3 C2 verdict re-confirmed) |
| zone count vs layout preset | `horizontal-2` -> 2 zones (top + bottom) | `single` -> 1 zone (primary) | AGREE -- preset-to-zone arity matches IMP-09 B-4 vocabulary (Axis 1 row 8) |
| frame contract resolution | both zones resolved to a contract id (rank_1 path) | only 1 of 4 selection paths resolved (3 `chain_exhausted` / `no_v4_candidate`) | DIFF EXPECTED -- 04.mdx exhibits v4 candidate gap; this is the composition-planner maturity gap (not in any closed-issue scope). Not a contract conflict. |
| `image_events` / `table_events` arity | both = 0 | both = 0 | AGREE -- neither sample triggers Step 14 image/table self-overflow; `#45` `image_aspect_mismatch` and `#46` `table_self_overflow` event-arrays exist in the schema and are *correctly empty* when no overflow is detected |
| router action triggered | `None` | `None` | AGREE -- Step 16 router is dormant when classifications=[] (Axis 3 C3 verdict re-confirmed; `#12` retry cascade not exercised by these samples) |
| pipeline final banner | `PASS` (full MDX coverage + visual OK) | `PARTIAL_COVERAGE` (visual OK + composition-planner filter) | self-honest status naming per [[feedback_artifact_status_naming]] |
### 7.4 Run-level findings
- Both runs pass the visual_check axis (Axis 3 C2 contract). `visual_check_passed=True` agrees between `fit_classification` and `slide_status` mirror in both runs.
- 04.mdx `PARTIAL_COVERAGE` is a composition-planner side filter (3 sections drop to `chain_exhausted` / `no_v4_candidate` before reaching Step 14). This is NOT an audit Blocker because (a) no closed issue under audit targets composition-planner coverage, (b) the status field is self-honestly named `PARTIAL_COVERAGE` rather than misnamed `PASS` (matches [[feedback_artifact_status_naming]]).
- Step 21 `debug.json` writer surfaces a stable 17-key top-level surface across both runs; Axis 3 C1 invariant re-confirmed at runtime.
- Zero Blocker findings in Section 7.
---
## Section 8. Anti-hardcoding grep checklist
**Method** : run the 6 anti-hardcoding patterns enumerated in the Issue #50 body. For each, capture the live hit set, classify hits (Phase Z scope vs. legacy Phase R'/Q out-of-scope vs. docstring/comment vs. test fixture), then return a verdict. Raw output preserved at `D:\ad-hoc\kei\design_agent\.orchestrator\tmp\50_grep_checklist_raw.txt` (evidence-only, not staged for commit per Stage 3 directive).
**Audit date** : 2026-05-19. Searched against tracked source on this date.
### 8.1 Checklist
| # | pattern (issue body) | expected | live hit count (src/) | hit classification | verdict |
|---|---|---|---|---|---|
| G1 | `grep -E 'if .* == ["'\\''].*\.mdx' src/` | 0 hits | 0 | none | PASS |
| G2 | `grep -E 'OVERRIDES\s*=\s*\{' src/` | each match sample-agnostic | 0 | none | PASS (vacuously sample-agnostic) |
| G3 | `grep -E '재구성\|건설산업 DX\|BIM' src/` -- sample text leak | 0 hits | 31 source hits across 14 `.py` files (binary `.pyc` matches ignored) | (a) 20 hits in legacy Phase R'/Q files (`block_assembler_b2.py` 1, `block_matcher_tfidf.py` 1, `block_reference.py` 3, `content_editor.py` 3, `design_director.py` 2, `design_tokens.py` 1, `fit_verifier.py` 1, `frame_extractor.py` 1, `kei_client.py` 4, `pipeline.py` 3) -- pre-Phase-Z; not in audit window; (b) 11 hits in Phase Z files (`phase_z2_content_extractor.py` 7 -- all inside `if __name__ == "__main__"` self-test data blocks at lines 466/493/511/556/565/573/591; `phase_z2_failure_router.py:123` 1 -- internal taxonomy string `"topology 부터 재구성. frame_reselect 는 그 다음 단계"`; `phase_z2_mapper.py:519/529` 2 -- docstring examples; `phase_z2_retry.py:59` 1 -- docstring). Per-file count sum = 20 + 11 = 31, matching the live total. | PASS for the audit scope -- **0 closed-issue (#2-#18 + #45-#49)** introduces new sample-specific hardcoded BIM/재구성/건설산업 string literals into runtime code paths. All 11 Phase Z hits are docstring/taxonomy/self-test fixtures, none injected into runtime contracts. Legacy 20 hits are out of audit window. Recorded as Section 10 follow-up candidate `F-4` for future cleanup (doc-only, optional). |
| G4 | `grep -E 'height\s*=\s*720\|aspect\s*=\s*0\.5' src/` -- magic literal pinning | 0 hits | 0 | none | PASS |
| G5 | sample paths come from CLI args / config, not hardcoded | sample-agnostic | 4 occurrences across 2 files (`src/block_assembler.py:1390/1393` + `src/image_utils.py:62/65`) | all 4 hits use `samples/mdx_batch` as one of several **generic asset search directories** alongside `samples/images` (image asset discovery fallback). The directory is treated as a discovery namespace, not as a path to a specific MDX file. CLI entry (`src/phase_z2_pipeline.py:4861`) takes `mdx_path` as positional arg -- no hardcoded MDX path on the runtime entry. | PASS -- sample-agnostic asset discovery default; not a per-sample pin. |
| G6 | `tests/` : sample-specific fixtures only under `tests/fixtures/`, not in production pipeline | fixtures isolated | `tests/fixtures/` directory does not exist; closest hits = `tests/phase_z2/test_pz2_vu_integration.py:6, 82` referencing `samples/mdx_batch/02.mdx` as smoke-coverage MDX | the references in `test_pz2_vu_integration.py` are inside a verification-utility integration test (`#16` IMP-16 scope). The test file is named with the test prefix and lives in `tests/phase_z2/`, so pytest discovery treats it as a test, not as a production module. No production pipeline file imports a sample MDX path literal. | PASS WITH NOTE -- no `tests/fixtures/` directory exists today; the existing integration tests already keep sample references inside `tests/phase_z2/test_*.py`, which discharges the spirit of the rule. Optional follow-up: formalize a `tests/fixtures/` directory if sample inventory grows. Recorded as Section 10 follow-up candidate `F-5` (low priority, doc-only). |
### 8.2 Anti-hardcoding verdict
- 4 patterns PASS cleanly with 0 hits (G1, G2, G4) and 1 PASS with sample-agnostic hits (G5).
- 1 pattern PASS-for-audit-scope with classification (G3) : 11 Phase Z hits are all docstrings/taxonomy/self-test fixtures; 20 legacy hits are out of the 22-closed-issue audit window. Per-file counts sum to 31, matching the live grep total. No closed issue introduces new hardcoded sample text into a runtime code path.
- 1 pattern PASS WITH NOTE (G6) : `tests/fixtures/` directory not yet established; existing integration test references stay inside `tests/phase_z2/`. Already aligned with the spirit of the rule.
- **0 Blocker findings in Section 8.**
- Cross-axis : the F-4 / F-5 follow-up candidates are doc-only optional cleanup; they do not alter any closed-issue contract.
---
## Section 9. Final decision
**Decision** : **CONDITIONAL GO for #19**.
### 9.1 Summary across all 4 audit axes + supporting sections
| section | axis | Blocker | Warning | OK | follow-up candidates |
|---|---|---|---|---|---|
| §3 | Axis 1 -- scope myopia | 0 | 5 (`#6 #12 #15 #46 #49`) | 17 | none Blocker; warnings are blast-radius + administrative drift |
| §4 + MATRIX.md | Axis 2 -- 22 x 22 pipeline matrix | 0 | (9 hotspot steps, 2 expected-empty cols) | 22 issues mapped | none Blocker; hotspots match expected Step 14 / 21 attention |
| §5 | Axis 3 -- cross-issue conflict (6 invariants) | 0 | 0 | 6 categories AGREE | F-1 (body cites mapper.py; live producer is classifier.py for `fit_classification`); F-2 (13 family templates on disk vs. 11 tracked / contracted -- 2 WIP outside any closed issue) |
| §6 | Axis 4 -- backlog vs code reality | 0 | -- | 1 AGREE, 16 BACKLOG_STALE (doc drift), 5 NO_BACKLOG_ROW (by design for `#45-#49`) | F-3 (backlog status sweep : flip 15 rows `pending` -> `implemented`, 1 row `pending` -> `documented (deferred)` for IMP-17, footnote `IMP-15` row with 5 children) |
| §7 | representative runs (03.mdx + 04.mdx) | 0 | -- | both runs visual_check_passed = True; debug.json schema stable; 04.mdx PARTIAL_COVERAGE is composition-planner side (no audit-window contract conflict) | none new |
| §8 | grep checklist (6 patterns from issue body) | 0 | -- | G1/G2/G4/G5 PASS; G3/G6 PASS WITH NOTE | F-4 (legacy Phase R'/Q BIM literals -- optional cleanup); F-5 (formalize `tests/fixtures/` -- optional) |
| §2 | baseline pytest | -- | -- | 303 passed BEFORE + 303 passed AFTER audit | none |
### 9.2 Blocker tally
- **0 Blocker** findings across all four axes and all supporting sections.
- 5 Warning rows in §3 are about blast radius (`#6 #12`), administrative commit-label drift (`#46`), and parent/child close-timestamp anomaly (`#15 #49`). None of them indicate broken code contracts.
- All BACKLOG_STALE rows in §6 are documentation drift, not implementation absence. Live grep on `src/**` confirms each closed issue is wired (or carved-out as designed for `#17 #18`).
- 5 follow-up candidates (F-1 .. F-5) are all doc-only. None require source code changes.
### 9.3 Why CONDITIONAL GO, not unconditional GO
Audit found zero Blocker, but the conditions for upgrading to unconditional GO are not met because:
1. **F-3 (backlog sweep)** is the largest doc-drift surface (16 of 22 audited rows mislabeled). Issue #19 will read the backlog when scoping next-step coverage; running #19 against a stale backlog risks a planner who treats already-implemented features as still pending. The F-3 follow-up should be filed and merged before -- or at minimum in parallel with -- #19 Stage 2 planning.
2. **F-2 (family template count drift)** matters if #19 touches the catalog / `frame_contracts.yaml` (likely). The audit confirms 11 tracked entries are consistent today, but #19 should reconcile the 2 WIP files (`app_sw_package_vs_solution.html`, `pre_construction_model_info_stacked.html`) before adding any new family templates.
3. **F-1 (record-keeping for invariant C3 producer file path)** -- a small but real mismatch between the issue body wording (`src/phase_z2_mapper.py`) and the live producer (`src/phase_z2_classifier.py`). Should be fixed in the audit charter / spec doc before the next integration audit so future audits do not repeat the same drift check.
F-4 / F-5 are optional and do not gate #19.
### 9.4 Conditions to satisfy for #19 progression
- File F-1 / F-2 / F-3 as Section 10 follow-up issues (text-only drafts produced in u6).
- F-3 backlog sweep should land before #19 Stage 2 (so #19 plans against accurate status).
- F-2 family template reconciliation should land before #19 introduces new family templates (whichever comes first).
- F-1 is a one-line spec-doc edit, can land any time before the next INTEGRATION-AUDIT issue is opened.
### 9.5 Decision sentence
> **Issue #19 is approved for entry under CONDITIONAL GO**, with the explicit dependency that follow-up F-3 (backlog status sweep) must land before #19 Stage 2 planning consumes the backlog, and F-2 (family template reconciliation) must land before any #19 work that extends the catalog. No production source code change is required from this audit. Pytest baseline stable (303 passed BEFORE + AFTER).
---
## Section 10. Follow-up issue drafts (text-only, not auto-posted)
**Scope rule (Stage 2 u6 contract)** : per-draft fields = `title` + `source_axis` (1-4) + `scope` (what files / what change) + `evidence_link` (REPORT section that produced the finding). **No Gitea post.** Final disposition of each candidate is the orchestrator / human triage decision after #50 closes; this REPORT only records the audit-side text.
Five candidates were produced by Axes 1-4. F-3 + F-2 + F-1 are blocking conditions for upgrading §9 CONDITIONAL GO -> unconditional GO for #19; F-4 + F-5 are optional housekeeping. None require source-code changes inside this audit.
### 10.1 F-1 -- audit charter record-keeping : invariant C3 producer file path -- RESOLVED via IMP-53 (2026-05-19)
- **title** : `[AUDIT-CHARTER-FIX] invariant C3 (fit_classification) producer cited as src/phase_z2_mapper.py; live producer is src/phase_z2_classifier.py`
- **source_axis** : Axis 3 (cross-issue conflict, invariant category C3) -- recorded in §5.2 C3 row + §5.4 follow-up bullet F-1.
- **scope** :
- one-line fix in any future INTEGRATION-AUDIT-* issue body or in `docs/architecture/PHASE-Z-PIPELINE-OVERVIEW.md` if it cites the wrong file for `fit_classification` producer.
- replace text `src/phase_z2_mapper.py` -> `src/phase_z2_classifier.py` *only in the context of `fit_classification` invariant* (mapper.py legitimately owns slot payload and registries, so do not blanket-rename).
- update audit charter template (if one exists) so the next integration audit does not repeat the drift check.
- **scope-lock** : **doc-only**, zero `src/**` / `templates/**` / `tests/**` edits.
- **evidence_link** :
- REPORT §5.2 row C3 (issue body wording vs. live producer).
- REPORT §5.4 follow-up bullet F-1.
- Live producer site : `src/phase_z2_classifier.py:495-497` (return dict with `visual_check_passed`, `classifications`, `summary`, `categories_seen`, `unclassified_signals`, `placement_diagnostics`).
- **priority / gating** : low priority on its own; required for charter cleanliness; **not** a blocker for #19 Stage 2.
### 10.2 F-2 -- family template count reconciliation : 11 tracked / 11 contracted / 13 on disk -- RESOLVED via #52 (option c, 2026-05-19)
- **title** : `[FAMILY-TEMPLATE-RECONCILE] templates/phase_z2/families/ has 13 .html files on disk but 11 tracked + 11 frame_contracts entries; 2 WIP files (app_sw_package_vs_solution.html, pre_construction_model_info_stacked.html) untracked`
- **source_axis** : Axis 3 (invariant category C6 template / catalog / frame count) -- recorded in §5.2 C6 row + §5.4 follow-up bullet F-2 + §6.3 Axis 4 cross-axis consistency bullet.
- **scope** :
- decide whether the 2 untracked WIP family templates (`app_sw_package_vs_solution.html`, `pre_construction_model_info_stacked.html`) should be (a) tracked + contracted (add to `frame_contracts.yaml`, add to `git ls-files`), (b) removed if abandoned, or (c) explicitly noted as in-progress with a parent issue.
- reconcile the IMP-18 SVG-gap report doc citation `families/*.html (13)` against whichever decision is chosen (so the doc count matches code reality).
- **scope-lock** : touches `templates/phase_z2/families/*.html`, `templates/phase_z2/catalog/frame_contracts.yaml`, `docs/architecture/IMP-18-SVG-GAP-REPORT.md`. **Must NOT be folded into #19 silently**: any catalog growth needs a dedicated issue per [[feedback_workflow_atomicity_rules]] (one commit = one decision).
- **evidence_link** :
- REPORT §5.2 row C6 ("AGREE FOR TRACKED BASELINE -- 11 tracked family templates <-> 11 frame_contracts entries").
- REPORT §5.5 row "C6 family templates -- on disk" (`ls templates/phase_z2/families/*.html` = 13).
- REPORT §6.3 Axis 4 cross-axis consistency bullet (matches IMP-04 evidence).
- **priority / gating** : **must land before #19 introduces any new family template** (per §9.3 condition 2). Until #19's catalog touch surface is known, this can be filed independently.
- **resolution** : option (c) -- 2 WIP family templates explicitly noted as in-progress and tracked outside `frame_contracts.yaml` (RESOLVED via Gitea #52, 2026-05-19) :
- WIP allowlist : `templates/phase_z2/families/_WIP_FILES.md` (added by #52 u1) -- names both files with Figma frame IDs (`app_sw_package_vs_solution.html` -> frame 23 / `1171281203`; `pre_construction_model_info_stacked.html` -> frame 9 / `1171281180`) and explicit "not in `frame_contracts.yaml`, not in runtime matcher set" status; promote / remove gated on Gitea #42.
- IMP-18 doc reconciled : `docs/architecture/IMP-18-SVG-GAP-REPORT.md` L28 + L30 + L51 corrected from disk-only "13 files" / "15 partials" wording to "11 contracted + 2 WIP untracked = 13 on disk" (#52 u2) -- runtime matcher consumes the contracted set only; doc / tracked / contracted surfaces agree at 11 active.
- baseline guard (planned by #52 u4) : `tests/test_family_contract_baseline.py` will enforce tracked families <-> `frame_contracts.yaml` 1:1 set-equality modulo WIP allowlist parsed from `_WIP_FILES.md`; future drift (#42 or otherwise) fails CI.
- tracked baseline (11 contracted families <-> 11 `frame_contracts.yaml` entries) unchanged; no contract entries added or removed; no runtime matcher mutation; **C6 invariant remains AGREE** for the closed-issue audit scope.
- **F-2 closed-by-#52** under [[feedback_workflow_atomicity_rules]] (one commit = one decision unit), without re-opening any §5 C-invariant or §6.3 Axis 4 conclusion. #19 catalog-touch gate (per §9.3 condition 2) is now satisfied for the current 11/11 baseline; any #19 / #42 catalog growth must reconcile the WIP allowlist before merge.
### 10.3 F-3 -- backlog status sweep : 15 rows pending->implemented + 1 row pending->documented(deferred) + IMP-15 children footnote
- **title** : `[BACKLOG-STATUS-SWEEP] PHASE-Z-IMPLEMENTATION-ISSUE-BACKLOG.md has 16 of 22 audited rows mislabeled as pending; flip 15 to implemented, 1 (IMP-17) to documented (deferred), footnote IMP-15 with 5 execution children`
- **source_axis** : Axis 4 (backlog vs code reality) -- recorded in §6.1 headline finding + §6.2 22-row matrix + §6.3 follow-up candidate F-3 + §9.1 §6 row + §9.3 condition 1.
- **scope** :
- **15 rows** to flip `pending` -> `implemented` : IMP-02, IMP-03, IMP-04, IMP-05, IMP-06, IMP-07, IMP-08, IMP-09, IMP-10, IMP-11, IMP-12, IMP-13, IMP-14, IMP-15 (parent), IMP-16.
- **1 row** to flip `pending` -> `documented (deferred)` : IMP-17 (status-class shift; runtime AI = 0, 3-cond AND gate closed; matches §6.2 row 16).
- **IMP-15 row** : add inline footnote citing the 5 execution children commits `#45 (e9b3d2e)`, `#46 (2827622)`, `#47 (535c484)`, `#48 (614c533)`, `#49 (verification-only, re-uses 614c533)`. Either as a footnote on the IMP-15 row or as 5 child stub rows -- pick one and apply consistently.
- **IMP-18 row** : leave as `documented` (already AGREE per §6.2 row 17).
- **scope-lock** : single-file edit to `docs/architecture/PHASE-Z-IMPLEMENTATION-ISSUE-BACKLOG.md`. **Doc-only**, zero `src/**` / `templates/**` / `tests/**` edits. Must be filed as a separate Gitea issue with its own Stage 5 commit (not merged into #19 or any other improvement issue).
- **evidence_link** :
- REPORT §6.1 headline finding (16 BACKLOG_STALE + 5 NO_BACKLOG_ROW + 1 AGREE = 22).
- REPORT §6.2 22-row matrix (per-row grep evidence + commit SHAs).
- REPORT §6.3 follow-up reference.
- REPORT §9.1 / §9.3 condition 1 ("F-3 backlog sweep should land before #19 Stage 2 planning consumes the backlog").
- **priority / gating** : **highest of the 5 candidates**. **Must land before #19 Stage 2 planning** (per §9.3 condition 1) so that #19's planner reads accurate `implemented` / `documented (deferred)` status and does not treat already-wired features as still pending.
### 10.4 F-4 -- legacy Phase R' / Q sample-literal cleanup (OPTIONAL)
- **title** : `[LEGACY-LITERAL-CLEANUP] 20 hits of 재구성 / 건설산업 DX / BIM across 10 legacy Phase R'/Q files (block_assembler_b2.py, block_matcher_tfidf.py, block_reference.py, content_editor.py, design_director.py, design_tokens.py, fit_verifier.py, frame_extractor.py, kei_client.py, pipeline.py)`
- **source_axis** : Axis-supporting Section 8 (anti-hardcoding grep checklist) -- recorded in §8.1 row G3 + §8.2 third bullet + §9.1 §8 row.
- **scope** :
- per-file review of the 20 legacy hits to determine which are docstrings / comments (keep), legacy taxonomy (keep with annotation), or true sample-literal pins (remove or generalize).
- per-file counts to triage : `block_assembler_b2.py` 1, `block_matcher_tfidf.py` 1, `block_reference.py` 3, `content_editor.py` 3, `design_director.py` 2, `design_tokens.py` 1, `fit_verifier.py` 1, `frame_extractor.py` 1, `kei_client.py` 4, `pipeline.py` 3.
- **NOT** touching the 11 Phase Z hits (`phase_z2_content_extractor.py` 7 self-test data, `phase_z2_failure_router.py:123` taxonomy, `phase_z2_mapper.py:519/529` docstring examples, `phase_z2_retry.py:59` docstring) -- those passed audit verdict G3.
- **scope-lock** : potentially touches legacy `src/**` files NOT in the Phase Z 22-step pipeline. Must be filed as a deliberate cleanup issue with its own scope-lock. If any file flagged here turns out to be on a live Phase Z code path on review, demote the candidate or split it.
- **evidence_link** :
- REPORT §8.1 row G3 (per-file count breakdown, audit date 2026-05-19).
- REPORT §8.2 third bullet (20 legacy + 11 Phase Z = 31, reconciliation).
- Raw grep output : `D:\ad-hoc\kei\design_agent\.orchestrator\tmp\50_grep_checklist_raw.txt`.
- **priority / gating** : **optional, low priority, doc-only follow-up note**. Does NOT gate #19; §8 verdict already PASS for audit scope. Recorded for completeness so future audits do not re-discover the same 20-hit baseline.
### 10.5 F-5 -- formalize tests/fixtures/ directory (OPTIONAL)
- **title** : `[TESTS-FIXTURES-FORMALIZE] tests/fixtures/ directory does not exist; sample MDX references currently live in tests/phase_z2/test_pz2_vu_integration.py`
- **source_axis** : Axis-supporting Section 8 (anti-hardcoding grep checklist G6) -- recorded in §8.1 row G6 + §8.2 fourth bullet.
- **scope** :
- if-and-only-if sample inventory grows beyond what fits inside `tests/phase_z2/test_*.py` files, formalize a `tests/fixtures/` directory holding sample-specific fixtures.
- migrate existing `samples/mdx_batch/02.mdx` references in `tests/phase_z2/test_pz2_vu_integration.py:6, 82` only if migration is part of a broader test-fixture refactor (otherwise leave them as integration smoke).
- update the issue-body rule wording to acknowledge that `tests/phase_z2/test_*.py` already discharges the spirit of "no sample-specific fixtures in production pipeline".
- **scope-lock** : touches `tests/fixtures/` (new directory if filed) + the cited test files. Must NOT be folded into any unrelated test refactor.
- **evidence_link** :
- REPORT §8.1 row G6 verdict "PASS WITH NOTE".
- REPORT §8.2 fourth bullet (`tests/fixtures/` not yet established).
- **priority / gating** : **optional, very low priority**. Filing is only justified when sample inventory grows; the current state is already aligned with the spirit of the rule.
#### 10.5.1 F-5 docs-only resolution addendum (#54 Stage 3 u5, 2026-05-19)
Per issue #54 Stage 2 plan, F-5 is closed as **docs-only**; no root `tests/fixtures/` directory is created in this work. The current fixture inventory does not justify migration, and the existing convention is sufficient. The convention is recorded here so future anti-hardcoding audits can distinguish fixture / test-only paths from production paths without re-discovering the §8 G6 PASS-WITH-NOTE baseline.
- **Existing convention (DO NOT CHANGE)** : `tests/phase_z2/fixtures/` exists as a YAML regression fixture root (loaded by `tests/phase_z2/test_fixtures_loader.py`). Subdirectories present at audit time : `tests/phase_z2/fixtures/build_layout_css/`, `tests/phase_z2/fixtures/retry_gate/`. This is the canonical home for Phase Z regression fixtures.
- **Root `tests/fixtures/` (ABSENT)** : not created in #54. If a future change requires a non-Phase-Z, non-YAML fixture corpus (for example, multi-file MDX golden inputs that grow beyond what `tests/phase_z2/test_*.py` can hold inline), the migration must be filed as its own Gitea issue with its own scope-lock per §10.5.
- **Allowed sample references** : `samples/mdx_batch/**` and `samples/mdx/**` may be referenced from `tests/**` (test-only paths) for integration smoke -- e.g. the existing `samples/mdx_batch/02.mdx` references in `tests/phase_z2/test_pz2_vu_integration.py`. These do not violate the §8 anti-hardcoding rule because the spirit of the rule targets production pipeline code, not test runners.
- **Forbidden sample references** : production pipeline code (`src/**` runtime path) must NOT hardcode sample-specific MDX filenames or content (e.g. `02.mdx`, `03.mdx`, frame-specific labels keyed to a sample). The 20 legacy Phase R'/Q hits annotated under F-4 (#54 Stage 3 u1-u4) are intentional documented examples in docstrings / comments / glossary regex / sample-data dicts, not runtime input pins; they are out of scope for this rule by §10.4 verdict.
- **AI-isolation contract** : this addendum is text-only. No production behavior change, no runtime sample-path mutation, no new fixture file. Compatible with PZ-1 (AI = 0 on normal path) and [[feedback_ai_isolation_contract]].
- **Cross-reference** : `tests/CLAUDE.md` fixture convention note (#54 Stage 3 u5) mirrors the test-only / production rule split documented here.
### 10.6 Follow-up summary
| candidate | source axis | doc-only? | gates #19? | priority |
|---|---|---|---|---|
| F-1 audit-charter producer file path | Axis 3 (§5) | YES | NO | low (charter cleanup) |
| F-2 family template count reconcile | Axis 3 (§5) | NO -- touches templates / catalog / docs | gate IF #19 extends catalog | medium |
| F-3 backlog status sweep | Axis 4 (§6) | YES | YES -- must land before #19 Stage 2 plan | **highest** |
| F-4 legacy R'/Q literal cleanup | §8 (anti-hardcoding) | NO -- legacy src/ touch surface | NO | low (optional) |
| F-5 tests/fixtures/ formalize | §8 (anti-hardcoding) | NO -- tests/ migration | NO | very low (optional) |
- **Counts** : 5 candidates total. 3 are blocking conditions for upgrading §9 CONDITIONAL GO to unconditional GO for #19 (F-3 hard-gates, F-2 conditional-gates on catalog touch, F-1 nice-to-have before next audit). 2 are optional housekeeping (F-4, F-5).
- **Compliance with Stage 2 u6 contract** : per-draft fields (title / source_axis / scope / evidence_link) populated for each of F-1 .. F-5. **Zero auto-posts** -- this section is text-only. Filing decisions = orchestrator / human after #50 closes.
- **AI-isolation contract** : none of the 5 follow-up candidates require AI on a normal path. F-2 / F-4 / F-5 are scope decisions to be made by a human reviewer. Compatible with [[feedback_ai_isolation_contract]] and PZ-1 (AI = 0 on normal path).
@@ -0,0 +1,197 @@
# INTEGRATION-AUDIT-02 — IMP-07 reverse-path ↔ backlog ↔ IMP-16-U2 deferred items
**Issue**: Gitea #56 ([`Kyeongmin/C.E.L_Slide_test2/issues/56`](https://gitea.hmac.kr/Kyeongmin/C.E.L_Slide_test2/issues/56))
**Mode**: audit-only (orchestrator P4/P4a) — no runtime code; reverse-path NOT implemented in this audit.
**HEAD at audit**: `47f072e` (`docs: PROJECT-INTENT-AND-GOVERNANCE master doc`)
**Binding evidence artifact**: `.orchestrator/tmp/issue7_comments_r3.json` (102144 B, mtime_utc `2026-05-19T17:11:58Z`, 13 comments)
**Live Gitea API calls during audit**: 0 (artifact is binding per Stage 1)
**Fallback exit-report check**: `ls .orchestrator/issues/ | grep '^7_stage' | wc -l = 0` (no local stage-exit fallback)
**Scope-lock (u1 binding)**
- Forbidden writes (4 surfaces): `src/**`, `templates/**`, `tests/**`, `docs/architecture/IMP-16-U2-WIRING-DESIGN.md`.
- Allowed writes (2 surfaces): CREATE this report; line-scoped EDIT to `docs/architecture/PHASE-Z-IMPLEMENTATION-ISSUE-BACKLOG.md` L51 + L67 status cells only.
**Cross-links**
- Project governance: [`PROJECT-INTENT-AND-GOVERNANCE.md`](PROJECT-INTENT-AND-GOVERNANCE.md)
- Pipeline anchors: [`PHASE-Z-PIPELINE-OVERVIEW.md`](PHASE-Z-PIPELINE-OVERVIEW.md), [`PHASE-Z-PIPELINE-STATUS-BOARD.md`](PHASE-Z-PIPELINE-STATUS-BOARD.md)
- Backlog: [`PHASE-Z-IMPLEMENTATION-ISSUE-BACKLOG.md`](PHASE-Z-IMPLEMENTATION-ISSUE-BACKLOG.md)
- Wiring-design (read-only, not edited here): [`IMP-16-U2-WIRING-DESIGN.md`](IMP-16-U2-WIRING-DESIGN.md)
- Prior audit: [`INTEGRATION-AUDIT-01-REPORT.md`](INTEGRATION-AUDIT-01-REPORT.md)
---
## 1. Executive decision
| Q | question | verdict | evidence anchor |
|---|---|---|---|
| Q1 | IMP-07 actual implementation status | **closed-as-no-runtime** (policy close; no backend adapter; no FE trigger) | u2 close-trio c.17970 / c.19226 / c.19240; u3 BE grep 1 hit (docstring) + FE grep 0 hits |
| Q2 | Backlog accuracy for IMP-07 (and dependent IMP-16) | **divergent — correct to `documented:no-runtime` (IMP-07) + `documented:dormant` (IMP-16)** | Backlog L51 / L67 currently both `implemented`; status-vocabulary precedent at L68–L71 (`documented`, `documented (deferred)`) |
| Q3 | IMP-16-U2 3 deferred items resolution | **all three DORMANT pending reverse-path reactivation** (no runtime substrate to resolve any of the three) | u4 §3 (a/b/c each cite u2 + u3 + IMP-16-U2-WIRING-DESIGN.md L14–16 gate clauses, all NOT CLEARED) |
| Q4 | Follow-up needs | **1 backlog correction (applied in u7) + 1 doc-sync follow-up (drafted in §6, NOT posted)**; no runtime follow-up needed under current policy | §5 (backlog patch) + §6 (doc-sync banner draft) |
**Final decision**: see §7 below.
---
## 2. Evidence table (4-axis convergence)
| axis | claim | observed state | source / anchor |
|---|---|---|---|
| Gitea #7 close text | reverse-path closed-as-no-runtime (policy) | c.17970 `<< 해당 기능 필요 없음 >>`; c.19226 §5 `"코드 변경 없이 close … '구현 완료'가 아니라 '기능 불필요 / 현 정책상 reverse path 미진행'"`; c.19240 `"이 이슈는 코드 변경 없이 정책 판단으로 close했다."` | `.orchestrator/tmp/issue7_comments_r3.json` (binding artifact); cited verbatim in `.orchestrator/drafts/56_close_evidence.md` §3 / §4 / §5 |
| Live BE code grep (`src/`) | no reverse-path adapter exists | pattern P `html_to_slide_mdx\|edited_html_to_mdx\|reverse_path\|reverse-path\|reversePath\|html-to-mdx` → 1 hit at `src/phase_z2_verification_utils.py:68`, classified **docstring-only** inside `extract_text_from_html()` (docstring says `Deterministic, pure: no I/O, no LLM, no network.`) | `src/phase_z2_verification_utils.py:64-73`; `.orchestrator/drafts/56_code_grep.md` §3 |
| Live FE code grep (`Front/client/src/`) | no reverse-path payload trigger exists | same pattern P → **0 hits** across populated tree (`App.tsx`, `components/`, `contexts/`, `data/`, `hooks/`, `lib/`, `pages/`, `services/`, `types/`, `utils/`); 0-hit is true absence, not missing-dir false negative | `.orchestrator/drafts/56_code_grep.md` §4 |
| Backlog status (`PHASE-Z-IMPLEMENTATION-ISSUE-BACKLOG.md` L51) | currently labels IMP-07 `implemented` — divergent from #7 close text + grep | L51 final cell = `implemented`; row preserves hard link to IMP-02 (normalize schema). Correct token under audit verdict = `documented:no-runtime`. | `docs/architecture/PHASE-Z-IMPLEMENTATION-ISSUE-BACKLOG.md:51`; proposed diff in `.orchestrator/drafts/56_backlog_diff.md` §2 |
| Backlog status (L67) | currently labels IMP-16 `implemented` — gated to closed IMP-07, so dormant | L67 final cell = `implemented`; row carries `hard link: IMP-07 (B-2 main 활성 시점 의미)`. Correct token under audit verdict = `documented:dormant`. | `docs/architecture/PHASE-Z-IMPLEMENTATION-ISSUE-BACKLOG.md:67`; proposed diff in `.orchestrator/drafts/56_backlog_diff.md` §3 |
| IMP-16-U2 deferred items (`IMP-16-U2-WIRING-DESIGN.md` L71–L73) | three items deferred "until IMP-07 lands" | (a) adapter module path TBD, (b) Step 2 per-section vs whole-MDX undecided, (c) Step 14 telemetry granularity undecided. None can be resolved while IMP-07 remains closed-as-no-runtime. | `docs/architecture/IMP-16-U2-WIRING-DESIGN.md:69-75`; gate at L14–L16 (all 3 clauses NOT CLEARED — `.orchestrator/drafts/56_imp16_deferred.md` §2) |
| Fallback orchestrator exit report | absent — binding artifact is sole source | `ls .orchestrator/issues/ \| grep '^7_stage' \| wc -l = 0` | `.orchestrator/drafts/56_close_evidence.md` §1 |
| Convergence | zero contradicting evidence across 37 independent passes (Stage 1 → Stage 3) | all 4 evidence axes (close-text / BE grep / FE grep / dependent doc gate) point to **policy-closed, no runtime, dependent doc dormant** | Stage 1 + Stage 2 exit reports; u2–u5 drafts; u4 §4 cross-axis check |
**Commit SHA at audit time**: `47f072e` (HEAD before u7's backlog patch).
---
## 3. IMP-07 verdict (with evidence)
**Verdict**: `documented:no-runtime` — reverse-path (B-2 Edited HTML → MDX) was closed by user policy decision on 2026-05-15 (c.17970) and re-affirmed by structured close-audit on 2026-05-18 (c.19226 + c.19240). **No backend adapter, no frontend trigger, no `html_to_slide_mdx` port exists in this repository.**
### Evidence chain (compact form — full verbatim in drafts)
1. **Initial close decision** — c.17970 (2026-05-15T18:28:22+09:00):
> `<< 해당 기능 필요 없음 >>`
> `(*) mdx → html 변환 이후 html 수기 수정된 것은 html에서만 적용.`
2. **Structured close-audit (v1)** — c.19226 (2026-05-18T08:31:05+09:00). Section 3 enumerates the *absence* of every required runtime surface: SlideCanvas outerHTML capture absent; backend POST absent; `/api/edit | /api/html_to_mdx | /api/save` endpoints absent; glubeot `html_to_slide_mdx` not ported. Section 5 verdict: `"코드 변경 없이 close … '구현 완료'가 아니라 '기능 불필요 / 현 정책상 reverse path 미진행'"`.
3. **Structured close-audit (v2 restatement)** — c.19240 (2026-05-18T08:41:19+09:00):
> `"이 이슈는 코드 변경 없이 정책 판단으로 close했다."`
4. **Live BE grep** (`src/`, pattern P): 1 hit at `src/phase_z2_verification_utils.py:68` inside the docstring of `extract_text_from_html()`. Function body is a deterministic, pure text extractor (`no I/O, no LLM, no network`) — **not** a reverse-path adapter, **not** an HTML→MDX converter, **not** a pipeline re-entry call site.
5. **Live FE grep** (`Front/client/src/`, pattern P): 0 hits across populated React/TS tree — true absence, not missing-dir false negative.
### Why not `implemented:partial`
c.19226 §3 enumerates the absence of **every** required runtime surface (frontend, backend, converter, endpoint). `implemented:partial` would imply at least one runtime substrate is present; none is.
### Why not plain `documented` / `documented (deferred)`
IMP-17/18/19/20 use `documented` / `documented (deferred)` to mean "design captured, runtime deferred pending an explicit activation gate (IMP-17 carve-out, IMP-18 gap report, etc.)". IMP-07 is a stronger statement — **closed by explicit policy decision, no runtime, reactivation requires reopening the policy in a separate issue**. The `:no-runtime` suffix encodes that distinction so future readers can tell IMP-07 apart from the IMP-17/18/19/20 `documented` family.
### Reactivation contract (informational, NOT a doc edit)
Per c.19226 §5 and c.19240 closing line, reverse-path reactivation requires reopening IMP-07 policy in a **separate** issue covering: endpoint design, marker coverage, re-entry validation. This audit does NOT reopen that policy.
---
## 4. IMP-16-U2 deferred items resolution (with evidence)
**Source**: `docs/architecture/IMP-16-U2-WIRING-DESIGN.md` lines 69–75 (read-only; this doc is FORBIDDEN to edit in this audit per u1).
**Governing gate** (doc L12–L16): three clauses MUST be cleared before any IMP-16-U2 wiring lands.
| gate clause | required state | observed | gate status |
|---|---|---|---|
| `IMP-07 implemented + verified` | runtime adapter in `src/`, verified | Gitea #7 closed as policy / no-runtime (c.17970 / c.19226 §5 / c.19240) | **NOT CLEARED** |
| Repo grep returns runtime hit in non-test `src/` module | ≥1 non-docstring runtime hit for pattern P | u3 hits=1, **docstring only** at `src/phase_z2_verification_utils.py:68` (pure text extractor) | **NOT CLEARED** |
| Reverse-path entry emits (a) re-entry MDX + (b) upstream HTML | both as deterministic outputs | c.19226 §3 enumerates absence of every required surface; u3 FE grep hits=0 | **NOT CLEARED** |
All three gate clauses NOT CLEARED → resolution policy from issue body Q3 branches: "If Q1 confirms no-runtime / dormant → reclassify item as dormant pending reverse-path reactivation."
### Per-item resolution
| item | text (verbatim, doc L71–L73) | classification | reason | evidence anchor |
|---|---|---|---|---|
| (a) | Exact module path of the IMP-07 reverse-path adapter (TBD by IMP-07). | **DORMANT** | No reverse-path adapter exists in `src/`. The TBD slot stays TBD — not answered with a placeholder path. | u3 §3 (single docstring hit at `src/phase_z2_verification_utils.py:68`); c.19226 §3 absent-surface enumeration |
| (b) | Step 2 preservation cross-check: per-section variant vs whole-MDX variant. | **DORMANT (gate closed)** | Step 2 surface = `verify_text_preservation(reentry_mdx, upstream_generated_html, area_name=...)` (doc L29). With no emitter producing `reentry_mdx`, the per-section vs whole-MDX choice is unanswerable from runtime evidence. | doc L29; u3 §3; c.19226 §3 (`html_to_slide_mdx` not in repo); c.19226 §5 |
| (c) | Step 14 invented-text telemetry: per `area_name` vs global. | **DORMANT (gate closed)** | Step 14 surface = `detect_invented_text(reentry_mdx, final_html)` (doc L35). With no FE producer of area-tagged HTML (u3 FE grep hits=0), the granularity question has no runtime substrate. The current Step 14 `run_overflow_check` path is unchanged because no reverse-path re-entry sets `debug.json["pipeline"]["reverse_path_reentry"] = True` (doc L42 schema gate). | doc L35; doc L42; u3 §4 (FE 0-hits); c.19240 closing line |
### Axis disambiguation (why DORMANT, not no-runtime)
IMP-07 is **policy-closed** (active decline). IMP-16's verification helpers are **code-present** in `src/phase_z2_verification_utils.py` (u6 `split_into_sentences`, u8 `verify_text_preservation`, u9 `detect_invented_text` ports). The wiring they would land is **gated by IMP-07** (doc L12–L16). Because the gate is closed, the helpers are runtime-inert — they have no upstream caller. `:dormant` captures "code-shape present, runtime entry-point absent"; `:no-runtime` would imply the helpers themselves are absent (they are not).
---
## 5. Backlog status correction proposal
Target file: `docs/architecture/PHASE-Z-IMPLEMENTATION-ISSUE-BACKLOG.md` — line-scoped edit to L51 and L67 status cells only. **Exactly 2 line changes**; surrounding cells (id / title / step / source / priority / scope / guardrail / dependency) byte-for-byte unchanged on both rows. Adjacent rows (L50 IMP-06, L52 IMP-08, L66 IMP-15, L68 IMP-17) untouched.
### L51 — IMP-07: `implemented` → `documented:no-runtime`
```diff
-| ... | hard link: IMP-02 (A-1 normalize schema 와 reverse path schema 정합 필요) | implemented |
+| ... | hard link: IMP-02 (A-1 normalize schema 와 reverse path schema 정합 필요) | documented:no-runtime |
```
Justification: §3 verdict + close-trio (c.17970 / c.19226 / c.19240) + BE grep (docstring only) + FE grep (0 hits).
### L67 — IMP-16: `implemented` → `documented:dormant`
```diff
-| ... | hard link: IMP-07 (B-2 main 활성 시점 의미) | implemented |
+| ... | hard link: IMP-07 (B-2 main 활성 시점 의미) | documented:dormant |
```
Justification: §4 — all three deferred items DORMANT under the IMP-07 no-runtime gate. The row's own `hard link: IMP-07` declares its meaning is conditioned on IMP-07 activation.
### Status-vocabulary precedent
Existing tokens in the file: `pending` (L45), `implemented` (L46–L66 majority), `documented (deferred)` (L68 IMP-17), `documented` (L69 IMP-18 / L70 IMP-19 / L71 IMP-20). The proposed `documented:<qualifier>` form is a minimal suffix extension of an already-present family — and is **explicitly enumerated by the issue body's Q2**: "propose corrected status (`implemented` / `implemented:partial` / `documented:dormant` / `documented:no-runtime` / etc.)".
---
## 6. Follow-up issue recommendations (drafts, NOT posted)
Auto-posting follow-ups is out-of-scope per u1. The drafts below are recommended text only; this audit does **not** post them.
### Recommended follow-up #1 — doc-sync banner for `IMP-16-U2-WIRING-DESIGN.md`
- **Draft title**: `[DOC-SYNC] IMP-16-U2-WIRING-DESIGN.md — add cross-reference banner to INTEGRATION-AUDIT-02-REPORT.md (IMP-07 closed-as-no-runtime context)`
- **Scope sketch**:
- Add a one-paragraph banner near the top of `IMP-16-U2-WIRING-DESIGN.md` (post-§1 "Status" paragraph) cross-referencing this audit report.
- Banner content: IMP-07 was closed-as-no-runtime per Gitea #7 (c.17970 / c.19226 / c.19240). The L12–L16 gate clauses remain unchanged but are currently NOT CLEARED; the 3 deferred items (L71–L73) are DORMANT pending a future reverse-path reactivation issue.
- **Do NOT** modify the gate clauses, the per-step wiring contract, or the deferred items themselves — preserve them verbatim as the binding contract for any future IMP-07 reactivation.
- **Allowed file changes**: `docs/architecture/IMP-16-U2-WIRING-DESIGN.md` (banner add only); optionally a one-line back-link in `INTEGRATION-AUDIT-02-REPORT.md`.
- **Forbidden**: any change to the doc's gate clauses, per-step contract, or deferred items list; any change to `src/**`, `templates/**`, `tests/**`.
- **Acceptance**: banner contains explicit cross-link to `INTEGRATION-AUDIT-02-REPORT.md`, cites c.17970 / c.19226 / c.19240, and states the 3 deferred items are DORMANT (not resolved, not closed).
- **Rationale for separating from this audit**: per u1 scope-lock, `IMP-16-U2-WIRING-DESIGN.md` is a forbidden write surface in INTEGRATION-AUDIT-02 (issue #56). The banner addition is a separate doc-sync axis.
### No runtime follow-up needed under current policy
Reverse-path runtime activation is **out-of-scope under current user policy** (c.17970 / c.19226 §5 / c.19240). A runtime follow-up would require reopening IMP-07 policy in a separate issue — that decision lies with the user, not with this audit. This audit does NOT recommend a runtime follow-up at this time.
### Pre-existing follow-up linkage (informational)
Per the issue body's "Sequence note", the next planned issue #57 ([P5][DORMANT-TRIGGER-GUARD]) will register IMP-17 / IMP-18 / IMP-19 + (per #56 outcome) IMP-16 / IMP-07 + IMP-20 as followup-linked to #55. This audit's verdict feeds #57's dormant-trigger registry input: IMP-07 enters as `documented:no-runtime`; IMP-16 enters as `documented:dormant`.
---
## 7. Final decision
**`NEEDS_DOC_SYNC_FOLLOWUP`**
Rationale: the in-scope reconciliation (backlog L51 + L67 status corrections) is performed in u7. However, `IMP-16-U2-WIRING-DESIGN.md` opens with `**Status**: design-only contract. **No runtime wiring lands in this issue.** All wiring is gated behind IMP-07 reverse-path activation (B-2 main). When IMP-07 lands, this doc becomes the binding contract …` (L3) — written under the original assumption that IMP-07 would eventually land as runtime. With IMP-07 now classified `documented:no-runtime` (policy decline, not deferred-pending-future), this framing is stale without a cross-reference banner pointing readers to the present audit. Because u1 forbids direct edits to that doc, the banner addition must be a separate follow-up issue (drafted in §6, NOT posted by this audit).
Why not `BACKLOG_PATCH_ONLY`: the backlog patch alone leaves `IMP-16-U2-WIRING-DESIGN.md` reading as a future-binding contract without acknowledging the IMP-07 close. A reader landing on that doc would not know to consult this audit.
Why not `NEEDS_RUNTIME_FOLLOWUP`: reverse-path runtime is out-of-scope under current user policy (c.17970 / c.19226 §5 / c.19240); recommending a runtime follow-up would contradict the binding close-decision.
---
## Acceptance Criteria checklist (issue body)
| AC | requirement | status |
|---|---|---|
| 1 | No production source code (`src/**`, `templates/**`, `tests/**`) changes | ✅ — u1 forbids; u2–u6 verified empty tracked diff on these surfaces; u7 scoped to BACKLOG.md only |
| 2 | No direct modification of `IMP-16-U2-WIRING-DESIGN.md` | ✅ — u1 forbids; banner addition deferred to follow-up #1 in §6 |
| 3 | Each of Q1~Q4 has evidence-backed answer | ✅ — §1 table cites u2/u3/u4 drafts; §3, §4, §5, §6 expand each answer |
| 4 | Evidence table includes concrete `file:line`, comment IDs, commit SHAs | ✅ — §2 cites `src/phase_z2_verification_utils.py:68`, c.17970 / c.19226 / c.19240, SHA `47f072e`, `BACKLOG.md:51` / `:67`, `IMP-16-U2-WIRING-DESIGN.md:69-75` / `:12-16` |
| 5 | Final decision ∈ {BACKLOG_PATCH_ONLY, NEEDS_DOC_SYNC_FOLLOWUP, NEEDS_RUNTIME_FOLLOWUP} | ✅ — §7 = `NEEDS_DOC_SYNC_FOLLOWUP` |
| 6 | Body size budget: each Gitea comment ≤ 8000 chars | ✅ — Stage 3 comments split large evidence into `.orchestrator/drafts/56_*.md` + this report; report body itself is not a comment |
---
## Evidence drafts (RULE-6 evidence-only; NOT staged for commit)
- u1: `.orchestrator/drafts/56_scope_lock.md` — scope binding + forbidden / allowed writes.
- u2: `.orchestrator/drafts/56_close_evidence.md` — c.17970 / c.19226 / c.19240 verbatim.
- u3: `.orchestrator/drafts/56_code_grep.md` — `src/` 1 hit (docstring) + `Front/client/src/` 0 hits.
- u4: `.orchestrator/drafts/56_imp16_deferred.md` — 3 deferred items DORMANT (per-item table).
- u5: `.orchestrator/drafts/56_backlog_diff.md` — L51 + L67 status-cell diff proposal.
These drafts are evidence-only per RULE 6 and remain untracked. The committed deliverables of INTEGRATION-AUDIT-02 are: (i) this report (`INTEGRATION-AUDIT-02-REPORT.md`), and (ii) the 2 line-scoped status-cell edits applied in u7 (`PHASE-Z-IMPLEMENTATION-ISSUE-BACKLOG.md` L51 + L67).
+6 -6
View File
@@ -96,13 +96,13 @@ Phase Z 는 본체이고, Phase Q 는 부품 창고 / 참고 자산이다. Phase
| id | 보완 항목 | 목적 | input | output | Phase Q 후보 파일 | 우선순위 | | id | 보완 항목 | 목적 | input | output | Phase Q 후보 파일 | 우선순위 |
|---|---|---|---|---|---|---| |---|---|---|---|---|---|---|
| **A-1** | Stage 0 normalize 통합 | HTML-heavy / 비정형 raw MDX 를 Phase Z canonical input 으로 변환 | raw MDX text | `{clean_text, title, images, popups, tables, sections}` (frontmatter / 코드블록 보호 / list/table HTML 변환 / AST 구조 추출) | `mdx_normalizer.py`, `section_parser.py` | 높음 | | **A-1** | Stage 0 normalize 통합 | HTML-heavy / 비정형 raw MDX 를 Phase Z canonical input 으로 변환 | raw MDX text | `{clean_text, title, images, popups, tables, sections}` (frontmatter / 코드블록 보호 / list/table HTML 변환 / AST 구조 추출) | `mdx_normalizer.py`, `section_parser.py` | 높음 |
| **A-2** | Catalog 확장 (frame_contracts + frame_partials) | V4 32 후보 중 backend 적용 가능한 frame 수 증가 (현재 3 → 32 목표) | `figma_to_html_agent/blocks/{frame_id}/` 의 index.html / assets / analysis.md | `templates/phase_z2/catalog/frame_contracts.yaml` entry + `templates/phase_z2/frames/{template_id}.html` partial | `block_reference.py`, `block_selector.py` | 높음 | | **A-2** | Catalog 확장 (frame_contracts + frame_partials) | V4 32 후보 중 backend 적용 가능한 frame 수 증가 (현재 3 → 32 목표) | `figma_to_html_agent/blocks/{frame_id}/` 의 index.html / assets / analysis.md | `templates/phase_z2/catalog/frame_contracts.yaml` entry + `templates/phase_z2/frames/{template_id}.html` partial | `block_reference.py`, `block_selector.py` (간접 — catalog 로딩 / block 검색 패턴 reference; A-2 main = frame_contracts.yaml + frame_partials 신규 구축, Phase Q catalog schema ≠ Phase Z) | 높음 |
| **A-3** | Frame preview png 일관성 | 모든 catalog frame 의 일관된 preview.png 자동 생성 (현재 figma_previews 우회) | frame partial HTML + assets | `figma_to_html_agent/blocks/{frame_id}/preview.png` | `renderer.py`, `html_generator.py` (selenium 캡처 흔적 추정) | 중 | | **A-3** | Frame preview png 일관성 | 모든 catalog frame 의 일관된 preview.png 자동 생성 (현재 figma_previews 우회) | frame partial HTML + assets | `figma_to_html_agent/blocks/{frame_id}/preview.png` | `slide_measurer.capture_slide_screenshot` (main), `renderer.py` (간접 — render-path 자료) | 중 |
| **A-4** | slide-base.html iframe-friendly mode | iframe embed 시 body padding / centering / min-height 미적용 (frontend CSS injection 제거) | slide-base.html template + query string `?embedded=1` 같은 시그널 | conditional CSS (standalone vs embedded) | `html_generator.py` | 중 | | **A-4** | slide-base.html iframe-friendly mode | iframe embed 시 body padding / centering / min-height 미적용 (frontend CSS injection 제거) | slide-base.html template + query string `?embedded=1` 같은 시그널 | conditional CSS (standalone vs embedded) | `renderer.py` (legacy `slide-base.html` 호출 지점 보유, embedded/standalone CSS 분기 미구현) | 중 |
| **A-5** | V4 후보 자동 fallback | rank-1 capacity / cardinality / structure mismatch 시 자동 rank-2/3 시도 | V4 후보 list + 각 frame contract 의 cardinality + 추출된 content items | 통과한 frame template_id (모두 fail 시 filtered_capacity) | `fit_verifier.py` | 높음 | | **A-5** | V4 후보 자동 fallback | rank-1 capacity / cardinality / structure mismatch 시 자동 rank-2/3 시도 | V4 후보 list + 각 frame contract 의 cardinality + 추출된 content items | 통과한 frame template_id (모두 fail 시 filtered_capacity) | `fit_verifier.py` | 높음 |
| **A-6** | Zone DOM 좌표 export | backend 가 zone 절대 px 좌표를 step08 / 별도 step 에 export (frontend 측정 우회) | layout_css + slide-base 좌표 | `zone_geometries_px: [{position, x, y, w, h}]` | `slide_measurer.py` | 중 | | **A-6** | Zone DOM 좌표 export | backend 가 zone 절대 px 좌표를 step08 / 별도 step 에 export (frontend 측정 우회) | layout_css + slide-base 좌표 | `zone_geometries_px: [{position, x, y, w, h}]` | `slide_measurer.py` | 중 |
| **B-1** | Zone-section assignment override | 사용자 drag drop 결과를 backend 가 받아 composition planner 의 자동 결정 강제 변경 | `--override-section-assignment ZONE_ID=section_id,section_id` (CLI multi) | units 배치가 사용자 매핑 따름 | `pipeline.py`, `content_editor.py` | 중 | | **B-1** | Zone-section assignment override | 사용자 drag drop 결과를 backend 가 받아 composition planner 의 자동 결정 강제 변경 | `--override-section-assignment ZONE_ID=section_id,section_id` (CLI multi) | units 배치가 사용자 매핑 따름 | `pipeline.py` (간접 — orchestration entry, Stage Y page_structure 생성 흐름 보유) | 중 |
| **B-2** | Edited HTML → MDX 역변환 | frontend 편집 모드의 텍스트 변경이 새 final.html 에 반영 | edited HTML (iframe contentDocument outerHTML) | 새 MDX text 또는 patched mapper input | 글벗 `fmt_slide.py html_to_slide_mdx`, `content_editor.py` | 중 | | **B-2** | Edited HTML → MDX 역변환 | frontend 편집 모드의 텍스트 변경이 새 final.html 에 반영 | edited HTML (iframe contentDocument outerHTML) | 새 MDX text 또는 patched mapper input | 글벗 `fmt_slide.py html_to_slide_mdx` | 중 |
| **B-3** | Sub-section (### 단위) drag drop backend 처리 | backend 가 sub-section id 를 인식해서 zone 에 sub-section 단위로 매핑 | sub-section id (e.g., "03-1-sub-2") + zone_id | 그 sub-content 단위로 unit 분할 | `section_parser.py` | 낮 | | **B-3** | Sub-section (### 단위) drag drop backend 처리 | backend 가 sub-section id 를 인식해서 zone 에 sub-section 단위로 매핑 | sub-section id (e.g., "03-1-sub-2") + zone_id | 그 sub-content 단위로 unit 분할 | `section_parser.py` | 낮 |
| **B-4** | 다른 layout 의 zone-geometry override 확장 | top-1-bottom-2 / top-2-bottom-1 / left-1-right-2 / left-2-right-1 / grid-2x2 도 사용자 ratio override 적용 (현재 horizontal-2 / vertical-2 만) | `--override-zone-geometry` 인자 + 새 layout_preset 분기 | build_layout_css 의 grid 표현 (areas / cols / rows) | `space_allocator.py` | 낮 | | **B-4** | 다른 layout 의 zone-geometry override 확장 | top-1-bottom-2 / top-2-bottom-1 / left-1-right-2 / left-2-right-1 / grid-2x2 도 사용자 ratio override 적용 (현재 horizontal-2 / vertical-2 만) | `--override-zone-geometry` 인자 + 새 layout_preset 분기 | build_layout_css 의 grid 표현 (areas / cols / rows) | `space_allocator.py` | 낮 |
| **D-1** | filtered_section_reasons 노출 UI | 사용자가 어떤 섹션이 왜 빠졌는지 즉시 인지 (Step 8 coverage UI) | `step20_slide_status.json.data.filtered_section_reasons` | frontend header / 패널 UI | N/A (frontend 만) — Phase Q audit 외 | 중 | | **D-1** | filtered_section_reasons 노출 UI | 사용자가 어떤 섹션이 왜 빠졌는지 즉시 인지 (Step 8 coverage UI) | `step20_slide_status.json.data.filtered_section_reasons` | frontend header / 패널 UI | N/A (frontend 만) — Phase Q audit 외 | 중 |
@@ -122,7 +122,7 @@ Phase Z 는 본체이고, Phase Q 는 부품 창고 / 참고 자산이다. Phase
3. `slide_measurer.py` (A-6) 3. `slide_measurer.py` (A-6)
4. `fit_verifier.py` (A-5, D-2 간접) 4. `fit_verifier.py` (A-5, D-2 간접)
5. `space_allocator.py` (B-4) 5. `space_allocator.py` (B-4)
6. `content_editor.py` (B-1, B-2) 6. `content_editor.py`
7. `content_verifier.py` (검증 — B-2 후속) 7. `content_verifier.py` (검증 — B-2 후속)
8. `renderer.py` (A-3, A-4) 8. `renderer.py` (A-3, A-4)
9. `html_generator.py` (A-3, A-4) 9. `html_generator.py` (A-3, A-4)
@@ -120,10 +120,10 @@
| A-4 slide-base iframe mode | Step 13 | §2.8 I2 (renderer.py slide-base 사용 호출 지점) | pending | yes (UI/backend) | | A-4 slide-base iframe mode | Step 13 | §2.8 I2 (renderer.py slide-base 사용 호출 지점) | pending | yes (UI/backend) |
| Step 14 visual_check 보강 | Step 14, 21 | §2.7 H1 (`content_verifier` utilities Reference Only) | pending | yes (deterministic) | | Step 14 visual_check 보강 | Step 14, 21 | §2.7 H1 (`content_verifier` utilities Reference Only) | pending | yes (deterministic) |
| B-2 verification 보조 | Step 1, 2, 14, 21, 22 | §2.7 H3 (text 추출 / 정규화 / 비교 utility) | pending | yes (UI/backend) | | B-2 verification 보조 | Step 1, 2, 14, 21, 22 | §2.7 H3 (text 추출 / 정규화 / 비교 utility) | pending | yes (UI/backend) |
| AI repair fallback infra | Step 12, 16, 17 | §2.6 G3 (`httpx` + SSE streaming + retry + JSON parse pattern) | pending | no (AI fallback only) | | IMP-17 AI repair fallback infra (carve-out — see [`IMP-17-CARVE-OUT.md`](IMP-17-CARVE-OUT.md)) | Step 12, 16, 17 | §2.6 G3 (`httpx` + SSE streaming + retry + JSON parse pattern) | pending | no (AI fallback only) |
| I3 SVG 좌표 보강 | Step 0, 9 | §2.8 I3 (`renderer._preprocess_svg_data`) | pending | yes (deterministic) | | I3 SVG 좌표 보강 | Step 0, 9 | §2.8 I3 (`renderer._preprocess_svg_data`) | pending | yes (deterministic) |
| I4 zone 비중 분배 | Step 8 | §2.8 I4 (`renderer._group_blocks_by_area`) | pending | yes (deterministic) | | IMP-19 I4 zone 비중 분배 (reference — see [`IMP-19-ZONE-RATIO-REFERENCE.md`](IMP-19-ZONE-RATIO-REFERENCE.md)) | Step 8 | §2.8 I4 (`renderer._group_blocks_by_area`) | pending | yes (deterministic) |
| H2 frame contract validation | Step 10 | §2.7 H2 (`content_verifier.verify_structure` pattern) | pending | yes (deterministic) | | IMP-20 H2 frame contract validation (reference — see [`IMP-20-FRAME-CONTRACT-VALIDATION-REFERENCE.md`](IMP-20-FRAME-CONTRACT-VALIDATION-REFERENCE.md)) | Step 10 | §2.7 H2 (`content_verifier.verify_structure` pattern) | pending | yes (deterministic) |
--- ---
@@ -147,7 +147,7 @@
| candidate ID | 출처 | cleanup 대상 | trigger axis | | candidate ID | 출처 | cleanup 대상 | trigger axis |
|---|---|---|---| |---|---|---|---|
| J3 | §2.9 `html_generator` | utility 중복 — `normalize_mdx` / `_slice_mdx_sections` / `_get_definitions` / `_get_conclusion` (vs §2.1 / §2.2 SoT) | Phase R' cleanup axis 활성 시 | | J3 | §2.9 `html_generator` | utility 중복 — `normalize_mdx` / `_slice_mdx_sections` / `_get_definitions` / `_get_conclusion` (vs §2.1 / §2.2 SoT) | Phase R' archive trigger AND §2.1/§2.2 SoT signature unification (both preconditions required to keep guardrail = code-removal-only) |
| K5 | §2.10 `block_reference` + `block_selector` + §2.8 `renderer` | catalog 로드 + `_get_block_by_id` 중복 (3 module) | Phase R' cleanup 또는 Phase Z catalog 확장 axis 활성 시 | | K5 | §2.10 `block_reference` + `block_selector` + §2.8 `renderer` | catalog 로드 + `_get_block_by_id` 중복 (3 module) | Phase R' cleanup 또는 Phase Z catalog 확장 axis 활성 시 |
| L4 | §2.11 `pipeline` + §2.6 `content_editor` + §2.9 `html_generator` | `_parse_json` 중복 (3 module) | Phase R' cleanup 또는 Phase Z utility 통합 axis 활성 시 | | L4 | §2.11 `pipeline` + §2.6 `content_editor` + §2.9 `html_generator` | `_parse_json` 중복 (3 module) | Phase R' cleanup 또는 Phase Z utility 통합 axis 활성 시 |
@@ -93,6 +93,7 @@ action :
| `moderate_overflow` | content_type ∈ {`text_flow`, `frame_label`} AND `line_equivalent` ∈ (1.5, 4] | | `moderate_overflow` | content_type ∈ {`text_flow`, `frame_label`} AND `line_equivalent` ∈ (1.5, 4] |
| `minor_overflow` | content_type ∈ {`text_flow`, `frame_label`} AND `line_equivalent` ≤ 1.5 | | `minor_overflow` | content_type ∈ {`text_flow`, `frame_label`} AND `line_equivalent` ≤ 1.5 |
| `hard_visual_fail` | 위 어디에도 매핑 안 됨 OR retry budget 소진 | | `hard_visual_fail` | 위 어디에도 매핑 안 됨 OR retry budget 소진 |
| `image_aspect_mismatch` | Post-render `fail_reasons` signal — Step 14 visual_runtime_check 가 이미지 frame slot 의 rendered aspect ratio 와 declared aspect ratio 불일치를 감지 (router-routed fit_classifier 출력 아님; 별도 image_events stream 으로 표면화) |
### 3.2 분류 우선순위 (위에서 아래로) ### 3.2 분류 우선순위 (위에서 아래로)
@@ -43,16 +43,16 @@
| ID | title | related step | source | priority | scope | guardrail / validation | dependency | status | | ID | title | related step | source | priority | scope | guardrail / validation | dependency | status |
|---|---|---|---|---|---|---|---|---| |---|---|---|---|---|---|---|---|---|
| IMP-01 | A-6 Zone DOM 좌표 export | Step 14, 21 | §2 A-6 Salvage | ↑ high (small) | `_MEASURE_SCRIPT` JS extension `getBoundingClientRect()` + artifact field 추가 | AI/Kei/V4/frame 선택 변경 X / DOM bbox trace / 기존 debug.json schema 보존 (additive) | none | pending | | IMP-01 | A-6 Zone DOM 좌표 export | Step 14, 21 | §2 A-6 Salvage | ↑ high (small) | `_MEASURE_SCRIPT` JS extension `getBoundingClientRect()` + artifact field 추가 | AI/Kei/V4/frame 선택 변경 X / DOM bbox trace / 기존 debug.json schema 보존 (additive) | none | pending |
| IMP-02 | A-1 Stage 0 normalize chained adapter | Step 2 | §2 A-1 Salvage chained | ↑ high (medium) | `normalize_mdx_content` + `extract_major_sections` + `extract_conclusion_text` chained adapter + dual-write | AI/Kei normalize 회귀 X / step02 sections / sub_sections trace 설명 가능 | none | pending | | IMP-02 | A-1 Stage 0 normalize chained adapter | Step 2 | §2 A-1 Salvage chained | ↑ high (medium) | `normalize_mdx_content` + `extract_major_sections` + `extract_conclusion_text` chained adapter + dual-write | AI/Kei normalize 회귀 X / step02 sections / sub_sections trace 설명 가능 | none | implemented |
| IMP-03 | A-1 popup/image/table trace | Step 3 | §2 A-1 chained 보강 | medium | normalized popups / images / tables → ContentObject 변환 (B1 v0 보강) | AI/Kei content extraction 회귀 X / popup/image/table 추출 trace 설명 가능 | hard link: IMP-02 (Stage 0 normalize output 의 popup/image/table list 의존) | pending | | IMP-03 | A-1 popup/image/table trace | Step 3 | §2 A-1 chained 보강 | medium | normalized popups / images / tables → ContentObject 변환 (B1 v0 보강) | AI/Kei content extraction 회귀 X / popup/image/table 추출 trace 설명 가능 | hard link: IMP-02 (Stage 0 normalize output 의 popup/image/table list 의존) | implemented |
| IMP-04 | A-2 Catalog 확장 | Step 0, 9 | §2 A-2 새로 만들기 (핵심 unblocker) | medium (large) | `frame_contracts.yaml` + frame_partials 32 frame 등록/확장 | Phase R' frame catalog 회귀 X / V4 logic 변경 X / catalog 확장 후 PASS/FAIL 변화와 frame 선택 trace 설명 가능 | none | pending | | IMP-04 | A-2 Catalog 확장 | Step 0, 9 | §2 A-2 새로 만들기 (핵심 unblocker) | medium (large) | `frame_contracts.yaml` + frame_partials 32 frame 등록/확장 | Phase R' frame catalog 회귀 X / V4 logic 변경 X / catalog 확장 후 PASS/FAIL 변화와 frame 선택 trace 설명 가능 | none | implemented |
| IMP-05 | A-5 V4 fallback | Step 9, 16, 17, 20 | §2 A-5 새로 만들기 | medium | Step 9 / Step 16 router 확장 (rank-1 fail 시 rank-2/3 fallback) + step20 status semantics | `calculate_fit` 통째 Migrate X (dual path 위험) / 신설 status (`PASS_WITH_FALLBACK` 등) 일관성 / frame 변경 허용 trace 설명 | hard link: IMP-04 (catalog 확장 후 fallback path 의미 있음) | pending | | IMP-05 | A-5 V4 fallback | Step 9, 16, 17, 20 | §2 A-5 새로 만들기 | medium | Step 9 / Step 16 router 확장 (rank-1 fail 시 rank-2/3 fallback) + step20 status semantics | `calculate_fit` 통째 Migrate X (dual path 위험) / 신설 status (`PASS_WITH_FALLBACK` 등) 일관성 / frame 변경 허용 trace 설명 | hard link: IMP-04 (catalog 확장 후 fallback path 의미 있음) | implemented |
| IMP-06 | B-1 Zone-section override | Step 6 + input Step 1, 22 | §2 B-1 새로 만들기 (backend path) | medium | CLI 인자 + composition planner override path 신설 | Kei composition / Phase R' frame 보조 회귀 X / override 적용 시 composition_unit schema 정합 + trace | soft link: IMP-04 (frame 후보 ↑ 시 override 의미 ↑) | pending | | IMP-06 | B-1 Zone-section override | Step 6 + input Step 1, 22 | §2 B-1 새로 만들기 (backend path) | medium | CLI 인자 + composition planner override path 신설 | Kei composition / Phase R' frame 보조 회귀 X / override 적용 시 composition_unit schema 정합 + trace | soft link: IMP-04 (frame 후보 ↑ 시 override 의미 ↑) | implemented |
| IMP-07 | B-2 Edited HTML → MDX reverse path | Step 22 + Step 1, 2 | §2 B-2 새로 만들기 (backend path) | medium | frontend edited HTML → backend → MDX 변환 → pipeline 재진입 (글벗 `html_to_slide_mdx` 참조) | AI/Kei reverse 회귀 X / 재진입 후 step02 정합 + visual_check 통과 | hard link: IMP-02 (A-1 normalize schema 와 reverse path schema 정합 필요) | pending | | IMP-07 | B-2 Edited HTML → MDX reverse path | Step 22 + Step 1, 2 | §2 B-2 새로 만들기 (backend path) | medium | frontend edited HTML → backend → MDX 변환 → pipeline 재진입 (글벗 `html_to_slide_mdx` 참조) | AI/Kei reverse 회귀 X / 재진입 후 step02 정합 + visual_check 통과 | hard link: IMP-02 (A-1 normalize schema 와 reverse path schema 정합 필요) | documented:no-runtime |
| IMP-08 | B-3 Sub-section drag drop | Step 3 | §2 B-3 새로 만들기 (backend schema) | ↓ low | Phase Z `section_id` schema 확장 (sub_sections 단위 매핑) | AI/Kei schema 회귀 X / backward compatible / step03 trace | hard link: IMP-02 (A-1 normalize sub_sections schema 의존) | pending | | IMP-08 | B-3 Sub-section drag drop | Step 3 | §2 B-3 새로 만들기 (backend schema) | ↓ low | Phase Z `section_id` schema 확장 (sub_sections 단위 매핑) | AI/Kei schema 회귀 X / backward compatible / step03 trace | hard link: IMP-02 (A-1 normalize sub_sections schema 의존) | implemented |
| IMP-09 | B-4 다른 layout zone-geometry | Step 8 | §2 B-4 새로 만들기 (backend layout) | ↓ low | `build_layout_css` 분기 확장 (top-1-bottom-2 / left-1-right-2 / grid-2x2 등) | Kei `build_containers_type_b` 회귀 X / step08 trace | none | pending | | IMP-09 | B-4 다른 layout zone-geometry | Step 8 | §2 B-4 새로 만들기 (backend layout) | ↓ low | `build_layout_css` 분기 확장 (top-1-bottom-2 / left-1-right-2 / grid-2x2 등) | Kei `build_containers_type_b` 회귀 X / step08 trace | soft back-link: IMP-19 ([reference doc](IMP-19-ZONE-RATIO-REFERENCE.md) — Phase O block-level pattern reference, no runtime integration) | implemented |
| IMP-10 | D-1 filtered_section_reasons UI | Step 20, 22 | §2 D-1 frontend 신규 | ↓ low | frontend UI — backend artifact read-only 표시 | AI/Kei UI 회귀 X / backend artifact read-only | none | pending | | IMP-10 | D-1 filtered_section_reasons UI | Step 20, 22 | §2 D-1 frontend 신규 | ↓ low | frontend UI — backend artifact read-only 표시 | AI/Kei UI 회귀 X / backend artifact read-only | none | implemented |
| IMP-11 | D-2 Frame min_height 표시 | Step 22 | §2 D-2 새로 만들기 (frontend hint + catalog 참조) | ↓ low | frontend UI — frame contract `min_height_px` read-only + resize hint | AI/Kei UI 회귀 X / catalog 참조 + resize limit | none | pending | | IMP-11 | D-2 Frame min_height 표시 | Step 22 | §2 D-2 새로 만들기 (frontend hint + catalog 참조) | ↓ low | frontend UI — frame contract `min_height_px` read-only + resize hint | AI/Kei UI 회귀 X / catalog 참조 + resize limit | none | implemented |
--- ---
@@ -60,15 +60,17 @@
| ID | title | related step | source | priority | scope | guardrail / validation | dependency | status | | ID | title | related step | source | priority | scope | guardrail / validation | dependency | status |
|---|---|---|---|---|---|---|---|---| |---|---|---|---|---|---|---|---|---|
| IMP-12 | Step 16/17 retry 정밀화 | Step 16, 17 | §3 group B (Salvage deterministic) | medium | `redistribute` + glue + font compression — Step 16 router action 신설 + Step 17 action 실행 | AI fallback X / Kei retry loop (H5) 회귀 X / status semantics 일관 | soft link: IMP-05 (Step 16 router 영역 공유, 병렬 가능) | pending | | IMP-12 | Step 16/17 retry 정밀화 | Step 16, 17 | §3 group B (Salvage deterministic) | medium | `redistribute` + glue + font compression — Step 16 router action 신설 + Step 17 action 실행 | AI fallback X / Kei retry loop (H5) 회귀 X / status semantics 일관 | soft link: IMP-05 (Step 16 router 영역 공유, 병렬 가능) | implemented |
| IMP-13 | A-3 frame preview 일관성 | Step 0, 14, 21 | §3 Salvage 후보 | ↓ low | `capture_slide_screenshot` Salvage — preview.png 자동 생성 path | Phase R' reference path 회귀 X / preview artifact trace | soft link: IMP-04 (catalog frame_partial 확장 시 의미 ↑) | pending | | IMP-13 | A-3 frame preview 일관성 | Step 0, 14, 21 | §3 Salvage 후보 | ↓ low | `capture_slide_screenshot` Salvage — preview.png 자동 생성 path | Phase R' reference path 회귀 X / preview artifact trace | soft link: IMP-04 (catalog frame_partial 확장 시 의미 ↑) | implemented |
| IMP-14 | A-4 slide-base iframe mode | Step 13 | §3 새로 만들기 | ↓ low | `slide-base.html` conditional CSS (embedded vs standalone) | Claude / Phase R' HTML generation 회귀 X / Jinja2 deterministic | none | pending | | IMP-14 | A-4 slide-base iframe mode | Step 13 | §3 새로 만들기 | ↓ low | `slide-base.html` conditional CSS (embedded vs standalone) | Claude / Phase R' HTML generation 회귀 X / Jinja2 deterministic | none | implemented |
| IMP-15 | Step 14 visual_check 보강 | Step 14, 21 | §3 H1 Reference Only | medium | image_aspect_mismatch / tabular_overflow 검사 추가 | AI/Kei classification 회귀 X / deterministic 검사 + trace | soft link: IMP-01 (Step 14 측정/trace layer 공유) | pending | | IMP-15 | Step 14 visual_check 보강 | Step 14, 21 | §3 H1 Reference Only | medium | image_aspect_mismatch / tabular_overflow 검사 추가 | AI/Kei classification 회귀 X / deterministic 검사 + trace | soft link: IMP-01 (Step 14 측정/trace layer 공유) | implemented |
| IMP-16 | B-2 verification 보조 axis | Step 1, 2, 14, 21, 22 | §3 H3 Reference Only | ↓ low | B-2 reverse path 의 verification 보조. main reverse path 는 IMP-07, 본 issue 는 text/visual/trace 검증 layer | AI/Kei verification 회귀 X / utility deterministic | hard link: IMP-07 (B-2 main 활성 시점 의미) | pending | | IMP-16 | B-2 verification 보조 axis | Step 1, 2, 14, 21, 22 | §3 H3 Reference Only | ↓ low | B-2 reverse path 의 verification 보조. main reverse path 는 IMP-07, 본 issue 는 text/visual/trace 검증 layer | AI/Kei verification 회귀 X / utility deterministic | hard link: IMP-07 (B-2 main 활성 시점 의미) | documented:dormant |
| **IMP-17** | **AI repair fallback infra** (**carve-out — normal path 밖**) | Step 12, 16, 17 | §3 G3 | (별 axis priority — pending) | `httpx` + SSE streaming + retry + JSON parse pattern reference — light_edit / restructure proposal | **normal path AI 호출 0 — 본 axis = fallback only, normal path 와 분리 설계** / Kei persona 단절 (Phase Q 자산과 단절) | soft link: IMP-04 + IMP-05 (catalog 확장 + V4 fallback 활성 시 의미) | pending | | **IMP-17** | **AI repair fallback infra** (**carve-out — normal path 밖**) | Step 12, 16, 17 | §3 G3 | (별 axis priority — pending) | [carve-out boundary + activation gate](IMP-17-CARVE-OUT.md) (3-cond AND: User GO ∧ B4 frame_selection evidence ∧ IMP-04/05 live — full def in u2 doc) — `httpx` + SSE streaming + retry + JSON parse pattern reference — light_edit / restructure proposal. Activation tracker = IMP-31 (#40); current gate state in [`IMP-31-GATE-AUDIT.md`](IMP-31-GATE-AUDIT.md) | **normal path AI 호출 0 — 본 axis = fallback only, normal path 와 분리 설계** / Kei persona 단절 (Phase Q 자산과 단절) | soft link: IMP-04 + IMP-05 (catalog 확장 + V4 fallback 활성 시 의미) | documented (deferred) |
| IMP-18 | I3 SVG 좌표 보강 | Step 0, 9 | §3 Reference Only | ↓ low | `renderer._preprocess_svg_data` 패턴 reference — frame_partials SVG 좌표 사전 박힘 | Phase R' (renderer.py) 회귀 X | soft link: IMP-04 (frame_partials 등록 후 의미 ↑) | pending | | IMP-18 | I3 SVG 좌표 보강 | Step 0, 9 | §3 Reference Only | ↓ low | `renderer._preprocess_svg_data` 패턴 reference — frame_partials SVG 좌표 사전 박힘 — [gap report](IMP-18-SVG-GAP-REPORT.md) | Phase R' (renderer.py) 회귀 X | soft link: IMP-04 (frame_partials 등록 후 의미 ↑) | documented |
| IMP-19 | I4 zone 비중 분배 | Step 8 | §3 Reference Only | ↓ low | `renderer._group_blocks_by_area` 패턴 reference — zone-level ratio 분배 | Phase O 컨테이너 회귀 X / 직접 통합 X | soft link: IMP-09 (zone 비중 분배 영역 공유) | pending | | IMP-19 | I4 zone 비중 분배 | Step 8 | §3 Reference Only | ↓ low | `renderer._group_blocks_by_area` 패턴 reference — zone-level ratio 분배 — [reference doc](IMP-19-ZONE-RATIO-REFERENCE.md) | Phase O 컨테이너 회귀 X / 직접 통합 X | soft link: IMP-09 (zone 비중 분배 영역 공유) | documented |
| IMP-20 | H2 frame contract validation | Step 10 | §3 Reference Only | ↓ low | `content_verifier.verify_structure` pattern reference — Phase Z frame contract 검증 pattern | Phase Q `REQUIRED_PATTERNS` 값 회귀 X / Phase Z 자체 pattern dict 설계 | soft link: IMP-04 (확장 catalog 적용 시 검증 범위 확대) | pending | | IMP-20 | H2 frame contract validation | Step 10 | §3 Reference Only | ↓ low | `content_verifier.verify_structure` pattern reference — Phase Z frame contract 검증 pattern — [reference doc](IMP-20-FRAME-CONTRACT-VALIDATION-REFERENCE.md) | Phase Q `REQUIRED_PATTERNS` 값 회귀 X / Phase Z 자체 pattern dict 설계 | soft link: IMP-04 (확장 catalog 적용 시 검증 범위 확대) | documented |
> **IMP-15 child issues note (#45–#49)** — IMP-15 (Step 14 visual_check 보강) is the parent row; child sub-axes were tracked as separate Gitea issues and are not given standalone backlog rows. Children: #45 (e9b3d2e), #46 (2827622), #47 (535c484), #48 (614c533), #49 (verification-only). Per INTEGRATION-AUDIT-01 §10.3 footnote option to avoid double-counting under IMP-15.
--- ---
@@ -88,7 +90,7 @@
| ID | title | related module | source | priority | scope | guardrail / validation | trigger axis | status | | ID | title | related module | source | priority | scope | guardrail / validation | trigger axis | status |
|---|---|---|---|---|---|---|---|---| |---|---|---|---|---|---|---|---|---|
| IMP-26 | J3 — html_generator utility 중복 cleanup | §2.9 html_generator | §5 J3 | ↓ low (future) | `normalize_mdx` / `_slice_mdx_sections` / `_get_definitions` / `_get_conclusion` 중복 제거 (vs §2.1/§2.2 SoT) | Phase R' 영역 — 코드 제거만 | Phase R' cleanup axis 활성 시 | pending | | IMP-26 | J3 — html_generator utility 중복 cleanup | §2.9 html_generator | §5 J3 | ↓ low (future) | `normalize_mdx` / `_slice_mdx_sections` / `_get_definitions` / `_get_conclusion` 중복 제거 (vs §2.1/§2.2 SoT) | Phase R' 영역 — 코드 제거만 | Phase R' archive trigger AND §2.1/§2.2 SoT signature unification (both preconditions required to keep guardrail = code-removal-only) | deferred |
| IMP-27 | K5 — catalog 로드 + `_get_block_by_id` 중복 cleanup | §2.10 + §2.8 (3 module) | §5 K5 | ↓ low (future) | block_reference / block_selector / renderer 의 catalog 로드 중복 제거 | Phase R' 영역 또는 Phase Z catalog 확장 axis | Phase Z catalog 확장 axis 활성 시 (soft link: IMP-04) | pending | | IMP-27 | K5 — catalog 로드 + `_get_block_by_id` 중복 cleanup | §2.10 + §2.8 (3 module) | §5 K5 | ↓ low (future) | block_reference / block_selector / renderer 의 catalog 로드 중복 제거 | Phase R' 영역 또는 Phase Z catalog 확장 axis | Phase Z catalog 확장 axis 활성 시 (soft link: IMP-04) | pending |
| IMP-28 | L4 — `_parse_json` 중복 cleanup | §2.11 + §2.6 + §2.9 (3 module) | §5 L4 | ↓ low (future) | pipeline / content_editor / html_generator 의 `_parse_json` 중복 제거 | Phase R' 영역 또는 Phase Z utility 통합 axis | Phase R' cleanup 또는 Phase Z utility 통합 axis 활성 시 | pending | | IMP-28 | L4 — `_parse_json` 중복 cleanup | §2.11 + §2.6 + §2.9 (3 module) | §5 L4 | ↓ low (future) | pipeline / content_editor / html_generator 의 `_parse_json` 중복 제거 | Phase R' 영역 또는 Phase Z utility 통합 axis | Phase R' cleanup 또는 Phase Z utility 통합 axis 활성 시 | pending |
@@ -132,3 +134,5 @@ Gitea Issues 활성 sanity check 별 GO ─┐
(Codex 1차 → Claude 재검토 → Codex 재검증 (Codex 1차 → Claude 재검토 → Codex 재검증
→ 100% 합의 → 구현 → 검증 → close) → 100% 합의 → 구현 → 검증 → close)
``` ```
- **IMP-50 audit (2026-05-19)** — [INTEGRATION-AUDIT-01-REPORT.md](INTEGRATION-AUDIT-01-REPORT.md) — Decision: **CONDITIONAL GO for #19** (F-3 backlog status sweep + F-2 family template reconciliation required before #19 Stage 2) — Stage 5 commit SHA: 8c7d693
@@ -46,7 +46,7 @@ Step 0 은 본체가 아닌 *준비 조건*. Step 1 (MDX 업로드) 부터가 ru
| A | 7 | Slide-Level Layout Planning | ⚠ partial (count-based / 7-A catalog + 7-B candidate fn 추가, runtime 호출처 X) | | A | 7 | Slide-Level Layout Planning | ⚠ partial (count-based / 7-A catalog + 7-B candidate fn 추가, runtime 호출처 X) |
| A | 8 | Zone + Internal Region Ratio Planning | ⚠ partial (zone-level horizontal-2 만 dynamic / 8-A region+display catalog + 8-B-1/2 candidate fn 추가, runtime 호출처 X / region-level 은 B2 안 partial) | | A | 8 | Zone + Internal Region Ratio Planning | ⚠ partial (zone-level horizontal-2 만 dynamic / 8-A region+display catalog + 8-B-1/2 candidate fn 추가, runtime 호출처 X / region-level 은 B2 안 partial) |
| A | 9 | Region-Level Frame / Display Selection | ⚠ partial (B4 가 catalog cover + declaration order 로 frame 선택 분담 / V4 evidence 미통합 / Step 5 와 conflate 잔존) | | A | 9 | Region-Level Frame / Display Selection | ⚠ partial (B4 가 catalog cover + declaration order 로 frame 선택 분담 / V4 evidence 미통합 / Step 5 와 conflate 잔존) |
| A | 10 | Frame Contract 확인 | ⚠ partial (B3 의 accepted_content_types + sub_zones 선언 추가 — B4 만 읽음, mapper 미읽음 / density envelope 별 axis) | | A | 10 | Frame Contract 확인 | ⚠ partial (B3 의 accepted_content_types + sub_zones 선언 추가 — B4 만 읽음, mapper 미읽음 / density envelope 별 axis) — IMP-20 ref: [reference doc](IMP-20-FRAME-CONTRACT-VALIDATION-REFERENCE.md) |
| A | 11 | Content Unit / Child Group → Internal Region → Frame Slot Mapping | ⚠ partial (B4 v0 dormant 2-stage + region 1:1 sub_zone + narrowest first + trace-only runtime 호출, render path 미연결) | | A | 11 | Content Unit / Child Group → Internal Region → Frame Slot Mapping | ⚠ partial (B4 v0 dormant 2-stage + region 1:1 sub_zone + narrowest first + trace-only runtime 호출, render path 미연결) |
| A | 12 | Slot Payload 생성 | ✅ (deterministic) | | A | 12 | Slot Payload 생성 | ✅ (deterministic) |
| B | 13 | Render | ✅ | | B | 13 | Render | ✅ |
@@ -157,6 +157,8 @@ Step 0 (사전 준비) 의 Figma → HTML 변환은 *precondition phase 의 작
다른 step 에서의 AI 호출은 본 도면 안에 *없음*. 다른 step 에서의 AI 호출은 본 도면 안에 *없음*.
> **Activation status reference** : runtime AI fallback (Step 12 light_edit / restructure) 는 IMP-17 carve-out infra + IMP-31 activation tracker (#40) 로 관리. carve-out boundary = [`IMP-17-CARVE-OUT.md`](IMP-17-CARVE-OUT.md). current 3-condition AND gate state + issue-body axis verdict = [`IMP-31-GATE-AUDIT.md`](IMP-31-GATE-AUDIT.md). 본 board 는 verdict 중복 X — gate / axis 판정은 audit doc 따름.
--- ---
## 6. 현재 병목 (한 줄) ## 6. 현재 병목 (한 줄)
@@ -0,0 +1,182 @@
# 프로젝트의 목적과 거버넌스
> 이 문서는 **왜** 이 프로젝트를 하는지, **무엇을 위해** 이슈와 audit 을 도는지, 그리고 **그 구조가 어떻게 짜여있는지** 기록한다. 매번 처음부터 설명하지 않기 위함.
>
> 작성: 2026-05-20.
---
## 1. Destination (도착점)
**Phase Z 가 다음 두 가지까지 작동하면 프로젝트 목표 달성**:
1. **22-step pipeline** end-to-end 작동
- 참조: [`PHASE-Z-PIPELINE-OVERVIEW.md`](PHASE-Z-PIPELINE-OVERVIEW.md)
- 현재 status: [`PHASE-Z-PIPELINE-STATUS-BOARD.md`](PHASE-Z-PIPELINE-STATUS-BOARD.md)
2. **AI 가 zone fit 평가 → 안 맞는 frame reject → zone 에 맞는 frame 생성**
- frame 이 zone 안에 들어가지 않으면 AI 가 reject
- reject 후 zone 에 맞춰 frame 을 생성하는 것까지가 destination
이 두 가지가 작동하면 끝. 그 이상은 별도 결정.
---
## 2. Q~Y 검토 = 이미 끝났음 (과거형)
Phase Z 구현 갭을 메우기 위해 Phase Q~Y 의 코드/기능을 **이미 다 검토했고**, 참고할 만한 것들을 22-step 에 매칭해서 **이슈로 다 정리해놓은 상태**.
- Q~Y 새로 다시 보지 않음 — 작업은 끝남
- 결과물 = INSIGHT-MAP 문서 + 28 개 초기 IMP 이슈 (#1~#28)
- 회귀 금지선 4 항목 (Q/R'/T 의 폐기된 path 로 돌아가지 않음) 도 [`PHASE-Q-INSIGHT-TO-22STEP-MAP.md §0`](PHASE-Q-INSIGHT-TO-22STEP-MAP.md) 에 같이 박혀있음
이제 남은 일 = **정리된 이슈를 orchestrator 로 처리해서 Phase Z 에 반영하는 것**.
---
## 3. 그 검토 결과 = INSIGHT-MAP 문서
**문서**: [`PHASE-Q-INSIGHT-TO-22STEP-MAP.md`](PHASE-Q-INSIGHT-TO-22STEP-MAP.md)
Q~Y 검토 결과를 22-step 의 어느 step 에 어떤 부품을 가져올지 매핑해서 정리한 catalog. 섹션 구성:
- §0: 목적 + 회귀 금지 4 항목 + Archive marker inventory (9 개)
- §1: SoT read result + 22 Step status snapshot
- §2: Salvage chained + new-make backend axes
- §3: Reference / carve-out
- §4: audit §1 lens column 정정
- §5: Module duplication cleanup
각 § cell 이 IMP 이슈로 1-to-1 분해됨.
---
## 4. IMP 이슈 = INSIGHT-MAP § cell 의 execution unit
**초기 28 개 (2026-05-12 한 번에 생성, #1~#28)**:
| INSIGHT-MAP § | 이슈 |
|---|---|
| §2 (Salvage chained + new-make backend) | #1~#11 (IMP-01~11: A-1~A-6, B-1~B-4, D-1, D-2) |
| §3 (Reference / carve-out) | #12~#20 (IMP-12~20: A-3/A-4, B-2, AI fallback, frame contract 등) |
| §4 (audit §1 lens column 정정) | #21~#25 (IMP-21~25: G2, I6, J5, K6, L5) |
| §5 (Module duplication cleanup) | #26~#28 (IMP-26~28: J3, K5, L4) |
**모든 IMP 이슈 본문에 표준 anchor**:
```
**관련 step**: Phase Z 22-step 좌표
**source**: INSIGHT-MAP §X (Q~Y 부품 출처)
**priority**: ↑ high / medium / ↓ low
**scope**: 구체 작업
**guardrails**: 깨면 안 되는 contract
```
**이후 추가된 이슈** (모두 source 명시):
| 이슈 | source | 의미 |
|---|---|---|
| #38~#41 (IMP-29~32) | IMP-05 §5 defer + Codex 분석 | V4 fallback 후 frontend bridge / AI adaptation 등 |
| #42 (IMP-04b) | IMP-04 milestone close 후 잔여 | Catalog 32 frames 확장 |
| #43, #44 | MDX 03/04/05 작업 중 발견 | 프론트 작업에서 발견된 새 axis |
| #45~#49 | #15 (Step 14 visual_check) decomposition | parent → 5 execution children |
| #50 | governance audit | 초반 28 다수 close 후 INTEGRATION-AUDIT-01 |
| #51~#54 | #50 audit 의 발견 (F-1~F-5) | follow-up 분리 처리 |
| #55 | #20 closed 후 runtime defer | doc-axis closed, runtime 별도 |
→ 추가 이슈도 모두 (관련 step, source, priority) 좌표로 anchor.
---
## 5. orchestrator 의 역할
이슈 처리의 **disciplined executor**.
**파일**: [`orchestrator.py`](../../orchestrator.py) (현재 line 수: ~1500)
**테스트**: [`tests/orchestrator_unit/`](../../tests/orchestrator_unit/) (현재 94 케이스)
**6 stage workflow**:
1. problem-review — 문제 검토
2. simulation-plan — 시뮬 기반 계획 수립 (IMPLEMENTATION_UNITS YAML 강제)
3. code-edit — 코드 수정 / 이슈 분기
4. test-verify — 테스트 및 검증
5. commit-push — 커밋 및 푸쉬
6. final-close — 최종 확인 / close
**원칙**:
- Claude (executor) + Codex (verifier) 양쪽 합의 + evidence required
- 단일 LLM 의견 X
- 매 stage 마다 dual-write (local draft + Gitea comment)
- exit report = stage 완료의 binding contract
**audit-only mode (P4/P4a)**:
- 제목에 `[INTEGRATION-AUDIT-*]`/`[AUDIT-ONLY]` 또는 `--audit-only` CLI flag
- Stage 3 에서 `src/`, `templates/`, `tests/` 변경 자동 reject (deterministic git diff guard)
- Stage 5 commit 범위 = `docs/architecture/INTEGRATION-AUDIT-*.md` + `BACKLOG.md` 만 허용
- audit 이슈는 fix 안 함 → follow-up 이슈로 분리
---
## 6. Audit cycle (meta-governance)
이슈 진행으로 인한 누적 drift / 충돌 / 하드코딩 / 매핑 누락을 주기적으로 검증.
**audit 자체는 코드 안 만짐**. 발견 사항은 별도 이슈로 분리해서 일반 workflow 로 처리.
**현재까지**:
- #50 INTEGRATION-AUDIT-01 (closed 2026-05-19)
- 산출: [`INTEGRATION-AUDIT-01-REPORT.md`](INTEGRATION-AUDIT-01-REPORT.md) + [`INTEGRATION-AUDIT-01-MATRIX.md`](INTEGRATION-AUDIT-01-MATRIX.md)
- 발견 F-1~F-5 → #51~#54 로 분리 (모두 closed)
**다음 audit 시점 trigger**:
- 닫힌 IMP 이슈가 일정 수 누적될 때 (5+ 연속)
- debug.json schema / layout / frame contract / router / visual_check_passed 의미가 바뀔 때
- 새 parent axis 진입 직전 (예: #19 → #20 → ...)
- 큰 feature 축 (#42 catalog 확장 / #38~#41 frontend bridge) 완료 후
---
## 7. 도착점 도달 기준
다음이 모두 작동해야 destination 도달:
- [ ] 22-step pipeline end-to-end (Step 0~22 모두 contract 준수, 회귀 0)
- [ ] AI 가 frame 을 zone fit 기준으로 평가 → 안 맞으면 reject
- [ ] reject 후 AI 가 zone 에 맞춰 frame 생성
- [ ] 하드코딩 0 (sample-specific 코드 없음 — anti-hardcoding mechanical check 통과)
- [ ] 모든 IMP 이슈 backlog 의 closed / documented (deferred) / pending 분류가 [`PHASE-Z-IMPLEMENTATION-ISSUE-BACKLOG.md`](PHASE-Z-IMPLEMENTATION-ISSUE-BACKLOG.md) 와 code reality 일치
---
## 8. 자주 헷갈리는 것들 (anti-patterns — 하지 말 것)
| 잘못된 framing | 옳은 framing |
|---|---|
| "Phase Q~Y heritage 를 보존한다" | Q~Y 는 부품 창고. 갭에 필요한 것만 선택적 참조 |
| "MDX 03 잘 만들면 끝" | 재사용 가능한 pipeline contract 가 목표. 특정 샘플 최적화 X |
| "audit 가 발견하면 그 자리에서 고친다" | follow-up 이슈로 분리. audit 자체는 코드 안 만짐 |
| "Claude 가 좋다고 하면 OK" | Claude + Codex 합의 + evidence 필수 |
| "이슈 본문은 참고일뿐" | 본문의 (관련 step, source, scope, guardrails) 가 binding anchor |
| "Phase R / R' / Q 의 path 로 돌아가도 됨" | 회귀 금지선 4 항목 (INSIGHT-MAP §0) 절대 위반 X |
| "destination 외 추가 기능도 욕심내자" | 22-step + AI frame generation 까지가 목표. 그 이상은 별도 결정 |
| "문서에 박힌 dormant 항목은 자동 실행 안 됨" | L3 registry [`DORMANT-TRIGGERS.yaml`](DORMANT-TRIGGERS.yaml) + `scripts/check_dormant_triggers.py` 가 orchestrator Stage 4→5 transition 에서 informational alert 로 발화 (closed 이슈 #16/#17/#18/#19/#20 의 trigger-on-X contract) |
---
## 9. 핵심 참조 문서 한 곳에
| 문서 | 역할 |
|---|---|
| [`PROJECT-INTENT-AND-GOVERNANCE.md`](PROJECT-INTENT-AND-GOVERNANCE.md) | **이 문서** — 왜/무엇을 |
| [`PHASE-Q-INSIGHT-TO-22STEP-MAP.md`](PHASE-Q-INSIGHT-TO-22STEP-MAP.md) | INSIGHT-MAP — Q~Y → Z 매핑 catalog |
| [`PHASE-Z-PIPELINE-OVERVIEW.md`](PHASE-Z-PIPELINE-OVERVIEW.md) | 22-step pipeline 정의 |
| [`PHASE-Z-PIPELINE-STATUS-BOARD.md`](PHASE-Z-PIPELINE-STATUS-BOARD.md) | 22-step 현재 status |
| [`PHASE-Z-IMPLEMENTATION-ISSUE-BACKLOG.md`](PHASE-Z-IMPLEMENTATION-ISSUE-BACKLOG.md) | IMP 이슈 backlog (closed/documented/pending) |
| [`PHASE-Z-ROADMAP.md`](PHASE-Z-ROADMAP.md) | 진행 로드맵 |
| [`INTEGRATION-AUDIT-01-REPORT.md`](INTEGRATION-AUDIT-01-REPORT.md) | 첫 audit 사이클 결과 |
| [`../../orchestrator.py`](../../orchestrator.py) | disciplined executor (Claude + Codex 합의 workflow) |
| [`../../CLAUDE.md`](../../CLAUDE.md) | AI 가 코드 작업할 때 따를 규칙 |
---
## 10. 한 줄 요약
> **Phase Z 가 "22-step pipeline + AI zone-fit frame generation" 까지 작동하는 것이 destination. Z 구현의 갭은 Phase Q~Y 를 부품 창고로 보고 선택적으로 참조해서 메움. INSIGHT-MAP 이 그 catalog, IMP 이슈가 execution unit. orchestrator 가 Claude + Codex 합의 + evidence 로 disciplined 하게 처리. INTEGRATION-AUDIT 가 주기적으로 누적 정합성 검증, 발견은 follow-up 이슈로 분리. 도착점은 22-step + AI frame generation 까지이고 그 이상은 별도 결정.**
+1828
View File
File diff suppressed because it is too large Load Diff
+191
View File
@@ -0,0 +1,191 @@
"""Dormant trigger guard — L3 machine-readable check (issue #58, P5-2).
Reads docs/architecture/DORMANT-TRIGGERS.yaml, scans the changed-file surface
(working tree via `git status --porcelain` + recent commit via
`git diff HEAD~1..HEAD --name-only`), and writes any matching activation
candidates to .orchestrator/dormant_alerts.json.
Guardrails (per Stage 1 scope-lock) :
- Informational only. Exit code is ALWAYS 0 — orchestrator never blocks on alerts.
- manual_evidence_required entries are skipped (require human gate).
- followup_issue entries are skipped (already tracked by the open follow-up).
- No LLM call. Deterministic file-pattern + content-pattern matching only.
- No hardcoding : the registry yaml is the single source of truth.
Run :
python scripts/check_dormant_triggers.py
"""
from __future__ import annotations
import json
import re
import subprocess
import sys
from datetime import datetime, timezone
from pathlib import Path
import yaml
REPO_ROOT = Path(__file__).resolve().parent.parent
REGISTRY_PATH = REPO_ROOT / "docs" / "architecture" / "DORMANT-TRIGGERS.yaml"
ALERT_OUT_PATH = REPO_ROOT / ".orchestrator" / "dormant_alerts.json"
def load_registry(path: Path = REGISTRY_PATH) -> list[dict]:
if not path.exists():
return []
with path.open("r", encoding="utf-8") as f:
data = yaml.safe_load(f) or []
if not isinstance(data, list):
raise ValueError(f"{path} must be a YAML list of entries.")
return data
def _git_lines(args: list[str]) -> list[str]:
try:
out = subprocess.run(
["git"] + args,
cwd=str(REPO_ROOT),
capture_output=True,
text=True,
timeout=20,
check=False,
)
except (OSError, subprocess.TimeoutExpired):
return []
if out.returncode != 0:
return []
return [ln for ln in out.stdout.splitlines() if ln.strip()]
def collect_changed_files() -> list[str]:
files: set[str] = set()
for ln in _git_lines(["status", "--porcelain"]):
path = ln[3:].strip() if len(ln) >= 4 else ln.strip()
if "->" in path:
path = path.split("->", 1)[1].strip()
path = path.strip('"')
if path:
files.add(path.replace("\\", "/"))
for ln in _git_lines(["diff", "HEAD~1..HEAD", "--name-only"]):
if ln.strip():
files.add(ln.strip().replace("\\", "/"))
return sorted(files)
def _glob_to_regex(pat: str) -> str:
"""Translate a posix-style glob with ``**`` to an anchored regex.
``**/`` matches zero or more directory levels (so ``src/**/*.py`` matches
both ``src/adapter.py`` and ``src/foo/adapter.py``). ``*`` and ``?`` do
NOT cross directory separators. Mirrors common ``.gitignore``-style
semantics; ``fnmatch.fnmatch`` alone cannot express this.
"""
out: list[str] = []
i = 0
n = len(pat)
while i < n:
if pat[i : i + 3] == "**/":
out.append("(?:.*/)?")
i += 3
elif pat[i : i + 2] == "**":
out.append(".*")
i += 2
elif pat[i] == "*":
out.append("[^/]*")
i += 1
elif pat[i] == "?":
out.append("[^/]")
i += 1
else:
out.append(re.escape(pat[i]))
i += 1
return "^" + "".join(out) + "$"
def _glob_match(path: str, patterns: list[str]) -> bool:
for pat in patterns:
if re.match(_glob_to_regex(pat), path):
return True
return False
def _content_match(file_path: Path, patterns: list[str]) -> list[str]:
if not patterns or not file_path.exists() or not file_path.is_file():
return []
try:
text = file_path.read_text(encoding="utf-8", errors="replace")
except OSError:
return []
hits = []
for pat in patterns:
try:
if re.search(pat, text):
hits.append(pat)
except re.error:
if pat in text:
hits.append(pat)
return hits
def check_entry(entry: dict, changed: list[str]) -> dict | None:
trig = entry.get("trigger") or {}
if trig.get("manual_evidence_required"):
return None
if entry.get("followup_issue"):
return None
file_patterns = trig.get("file_patterns") or []
content_patterns = trig.get("content_patterns") or []
if not file_patterns:
return None
matched_files = [p for p in changed if _glob_match(p, file_patterns)]
if not matched_files:
return None
if content_patterns:
hits: list[dict] = []
for mf in matched_files:
hit_patterns = _content_match(REPO_ROOT / mf, content_patterns)
if hit_patterns:
hits.append({"file": mf, "patterns": hit_patterns})
if not hits:
return None
match_info = {"files": [h["file"] for h in hits], "content_hits": hits}
else:
match_info = {"files": matched_files, "content_hits": []}
return {
"issue": entry.get("issue"),
"title": entry.get("title"),
"doc": entry.get("doc"),
"status": entry.get("status"),
"on_trigger": entry.get("on_trigger"),
"match": match_info,
}
def write_alerts(alerts: list[dict], path: Path = ALERT_OUT_PATH) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
payload = {
"generated_at": datetime.now(timezone.utc).isoformat(),
"registry": str(REGISTRY_PATH.relative_to(REPO_ROOT)).replace("\\", "/"),
"alerts": alerts,
}
path.write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8")
def main() -> int:
entries = load_registry()
changed = collect_changed_files()
alerts = [a for a in (check_entry(e, changed) for e in entries) if a]
write_alerts(alerts)
if alerts:
print(f"[dormant-trigger-guard] {len(alerts)} alert(s) written -> "
f"{ALERT_OUT_PATH.relative_to(REPO_ROOT)}")
for a in alerts:
print(f" - #{a['issue']} {a['title']} (files: {len(a['match']['files'])})")
else:
print("[dormant-trigger-guard] no dormant trigger alerts on current change surface.")
return 0
if __name__ == "__main__":
sys.exit(main())
+189
View File
@@ -0,0 +1,189 @@
"""IMP-13 build-time preview.png renderer for figma_to_html_agent/blocks/<frame_id> (u1-u6)."""
from __future__ import annotations
import argparse, hashlib, json, sys
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional
REPO_ROOT = Path(__file__).resolve().parent.parent
DEFAULT_BLOCKS_DIR = REPO_ROOT / "figma_to_html_agent" / "blocks"
DEFAULT_MANIFEST = DEFAULT_BLOCKS_DIR / "_preview_manifest.json"
@dataclass(frozen=True)
class FrameRow:
frame_id: str
block_dir: Path
index_html_path: Path
preview_png_path: Path
has_index: bool
has_preview: bool
def discover(blocks_dir: Path) -> List[FrameRow]:
if not blocks_dir.is_dir():
return []
rows: List[FrameRow] = []
for entry in sorted(blocks_dir.iterdir()):
if not entry.is_dir():
continue
idx, png = entry / "index.html", entry / "preview.png"
rows.append(FrameRow(entry.name, entry, idx, png, idx.is_file(), png.is_file()))
return rows
def _build_driver() -> Any:
"""Headless Chrome driver. Mirrors the run_overflow_check chromedriver-candidate + headless options pattern.
Inline per Stage 2 (no shared module). Per-frame window-size is set by the caller (u3), not here."""
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.chrome.service import Service
options = Options()
options.add_argument("--headless=new")
options.add_argument("--no-sandbox")
options.add_argument("--disable-dev-shm-usage")
candidates = [REPO_ROOT / "chromedriver", REPO_ROOT / "chromedriver.exe"]
last_err: Exception | None = None
for path in candidates:
if path.is_file():
try:
return webdriver.Chrome(service=Service(str(path)), options=options)
except Exception as exc: # noqa: BLE001 — propagate via aggregated error
last_err = exc
try:
return webdriver.Chrome(options=options)
except Exception as exc: # noqa: BLE001
raise RuntimeError(f"selenium init failed: {last_err or exc}") from exc
def render_one(driver: Any, row: FrameRow) -> tuple[int, int, Path]:
"""Render row.index_html_path -> row.preview_png_path via WebElement screenshot. Returns (w, h, path) or raises.
Driver is injected (caller owns lifecycle). .slide bbox drives window-size; no hardcoded slide dimensions."""
if not row.has_index:
raise FileNotFoundError(f"missing index.html: {row.index_html_path}")
from selenium.webdriver.common.by import By
driver.get(row.index_html_path.resolve().as_uri())
driver.set_script_timeout(15)
driver.execute_async_script(
"const cb=arguments[arguments.length-1];"
"(document.fonts&&document.fonts.ready?document.fonts.ready:Promise.resolve()).then(()=>cb(true));"
)
rect = driver.execute_script(
"const el=document.querySelector('.slide');"
"if(!el)return null;"
"const r=el.getBoundingClientRect();"
"return [Math.round(r.width), Math.round(r.height)];"
)
if not rect:
raise RuntimeError(f".slide not found in {row.index_html_path}")
w, h = int(rect[0]), int(rect[1])
driver.set_window_size(w, h)
el = driver.find_element(By.CSS_SELECTOR, ".slide")
row.preview_png_path.write_bytes(el.screenshot_as_png)
return w, h, row.preview_png_path
def _sha256_file(path: Path) -> str:
h = hashlib.sha256()
with path.open("rb") as f:
for chunk in iter(lambda: f.read(65536), b""):
h.update(chunk)
return h.hexdigest()
def is_unchanged(row: FrameRow, last_entry: Optional[Dict[str, Any]]) -> bool:
"""Stale-detect short-circuit: True iff preview.png mtime >= index.html mtime AND sha256 matches last_entry.
Returns False when prior entry is absent, preview.png is missing, preview is older than index, or hash differs."""
if last_entry is None or not row.has_index or not row.has_preview:
return False
try:
idx_mtime = row.index_html_path.stat().st_mtime
png_mtime = row.preview_png_path.stat().st_mtime
except OSError:
return False
if png_mtime < idx_mtime:
return False
recorded = last_entry.get("index_sha256")
if not recorded:
return False
return _sha256_file(row.index_html_path) == recorded
def categorize(rows: List[FrameRow]) -> Dict[str, List[FrameRow]]:
"""Bucket discover() rows so nothing is silently skipped (Stage 2 guardrail).
renderable = has_index (eligible for render or skipped_unchanged decision in u6).
missing_index_html = no index.html (catalog gap; IMP-04 follow-up).
orphan = preview.png exists without index.html (subset of missing_index_html; stale artifact to flag).
Buckets are intentionally non-disjoint: orphan is a subset of missing_index_html,
matching the Stage 2 evidence counts (renderable=20, missing_index_html=13, orphan=1)."""
renderable = [r for r in rows if r.has_index]
missing = [r for r in rows if not r.has_index]
orphan = [r for r in missing if r.has_preview]
return {"renderable": renderable, "missing_index_html": missing, "orphan": orphan}
def _load_manifest(path: Path) -> Dict[str, Any]:
try:
data = json.loads(path.read_text(encoding="utf-8"))
except Exception:
return {}
return data if isinstance(data, dict) else {}
def _render_entry(row: FrameRow, w: int, h: int) -> Dict[str, Any]:
return {"status": "rendered", "index_sha256": _sha256_file(row.index_html_path),
"index_mtime": row.index_html_path.stat().st_mtime,
"preview_mtime": row.preview_png_path.stat().st_mtime,
"viewport": {"w": w, "h": h}}
def main(argv: Iterable[str] | None = None) -> int:
p = argparse.ArgumentParser(prog="generate_frame_previews", description="IMP-13 build-time preview.png renderer.")
p.add_argument("--blocks-dir", type=Path, default=DEFAULT_BLOCKS_DIR)
p.add_argument("--manifest", type=Path, default=DEFAULT_MANIFEST)
p.add_argument("--dry-run", action="store_true")
args = p.parse_args(list(argv) if argv is not None else None)
rows = discover(args.blocks_dir)
if args.dry_run:
wi = sum(1 for r in rows if r.has_index)
wp = sum(1 for r in rows if r.has_preview)
print(f"discovered: total={len(rows)} with_index_html={wi} with_preview_png={wp}")
return 0
prev_frames = _load_manifest(args.manifest).get("frames") or {}
buckets = categorize(rows)
frames: Dict[str, Dict[str, Any]] = {}
counts = {"rendered": 0, "skipped_unchanged": 0, "error": 0}
driver = None
try:
for r in buckets["renderable"]:
last = prev_frames.get(r.frame_id) if isinstance(prev_frames, dict) else None
if is_unchanged(r, last):
frames[r.frame_id] = {**last, "status": "skipped_unchanged"}
counts["skipped_unchanged"] += 1
continue
if driver is None:
driver = _build_driver()
try:
w, h, _ = render_one(driver, r)
frames[r.frame_id] = _render_entry(r, w, h)
counts["rendered"] += 1
except Exception as exc: # noqa: BLE001
frames[r.frame_id] = {"status": "error", "error": str(exc)}
counts["error"] += 1
finally:
if driver is not None:
try: driver.quit()
except Exception: pass
orphan_ids = {r.frame_id for r in buckets["orphan"]}
for r in buckets["missing_index_html"]:
frames[r.frame_id] = {"status": "orphan" if r.frame_id in orphan_ids else "missing_index_html", "has_preview": r.has_preview}
summary = {"total": len(rows), "renderable": len(buckets["renderable"]), "missing_index_html": len(buckets["missing_index_html"]), "orphan": len(buckets["orphan"]), **counts}
payload = {"schema": 1, "generated_at": datetime.now(timezone.utc).isoformat(), "blocks_dir": str(args.blocks_dir), "summary": summary, "frames": frames}
args.manifest.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8")
print(f"coverage: total={summary['total']} renderable={summary['renderable']} rendered={counts['rendered']} skipped_unchanged={counts['skipped_unchanged']} missing_index_html={summary['missing_index_html']} orphan={summary['orphan']} error={counts['error']}")
return 1 if counts["error"] else 0
if __name__ == "__main__":
sys.exit(main())
+1
View File
@@ -8,6 +8,7 @@
- 블록 CSS의 글씨 크기를 font_hierarchy에 맞게 조정 (프로세스 내 조정) - 블록 CSS의 글씨 크기를 font_hierarchy에 맞게 조정 (프로세스 내 조정)
- 콘텐츠는 PipelineContext에서 가져옴 (하드코딩 아님) - 콘텐츠는 PipelineContext에서 가져옴 (하드코딩 아님)
- 블록은 콘텐츠에 맞게 재구성 (items 수 동적) - 블록은 콘텐츠에 맞게 재구성 (items 수 동적)
[legacy Phase R'/Q example — INTEGRATION-AUDIT-01 §10.4]
""" """
from __future__ import annotations from __future__ import annotations
+1
View File
@@ -107,6 +107,7 @@ class TfidfBlockMatcher:
text = text.replace("S/W", "SW 소프트웨어") text = text.replace("S/W", "SW 소프트웨어")
text = text.replace("H/W", "HW 하드웨어") text = text.replace("H/W", "HW 하드웨어")
text = re.sub(r'\bDX\b', 'DX 디지털전환', text) text = re.sub(r'\bDX\b', 'DX 디지털전환', text)
# [legacy Phase R'/Q example — INTEGRATION-AUDIT-01 §10.4]
text = re.sub(r'\bBIM\b', 'BIM 건설정보모델링', text) text = re.sub(r'\bBIM\b', 'BIM 건설정보모델링', text)
text = text.replace("(", " ").replace(")", " ") text = text.replace("(", " ").replace(")", " ")
text = text.replace("[", " ").replace("]", " ") text = text.replace("[", " ").replace("]", " ")
+10 -20
View File
@@ -20,9 +20,10 @@ import re
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
import yaml
from jinja2 import Environment, FileSystemLoader from jinja2 import Environment, FileSystemLoader
from src import catalog as _catalog_mod
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# 템플릿 디렉토리 # 템플릿 디렉토리
@@ -101,32 +102,18 @@ RELATION_CATEGORY_MAP: dict[str, list[str]] = {
# ══════════════════════════════════════ # ══════════════════════════════════════
# 카탈로그 로딩 (mtime 캐싱) # 카탈로그 로딩 (IMP-27: src.catalog 공유 로더 위임)
# ══════════════════════════════════════ # ══════════════════════════════════════
_catalog_cache: dict[str, Any] = {"data": None, "mtime": 0}
def _load_catalog() -> list[dict]: def _load_catalog() -> list[dict]:
"""catalog.yaml 로드 (mtime 캐싱).""" """catalog.yaml blocks list (IMP-27: shared loader delegation)."""
path = TEMPLATES_DIR / "catalog.yaml" return _catalog_mod.load_blocks()
mtime = path.stat().st_mtime
if _catalog_cache["data"] is not None and _catalog_cache["mtime"] == mtime:
return _catalog_cache["data"]
data = yaml.safe_load(path.read_text(encoding="utf-8"))
blocks = data.get("blocks", [])
_catalog_cache["data"] = blocks
_catalog_cache["mtime"] = mtime
return blocks
def _get_block_by_id(block_id: str) -> dict | None: def _get_block_by_id(block_id: str) -> dict | None:
"""블록 ID로 카탈로그 엔트리 조회.""" """블록 ID로 카탈로그 엔트리 조회 (IMP-27: shared loader delegation)."""
for b in _load_catalog(): return _catalog_mod.get_block_by_id(block_id)
if b["id"] == block_id:
return b
return None
# ══════════════════════════════════════ # ══════════════════════════════════════
@@ -399,6 +386,7 @@ _SAMPLE_DATA: dict[str, dict[str, Any]] = {
"center_label": "DX", "center_label": "DX",
"center_sub": "디지털 전환", "center_sub": "디지털 전환",
"items": [ "items": [
# [legacy Phase R'/Q example — INTEGRATION-AUDIT-01 §10.4]
{"label": "BIM", "color": "#ff6b35"}, {"label": "BIM", "color": "#ff6b35"},
{"label": "GIS", "color": "#00d4aa"}, {"label": "GIS", "color": "#00d4aa"},
{"label": "DT", "color": "#ffd700"}, {"label": "DT", "color": "#ffd700"},
@@ -406,6 +394,7 @@ _SAMPLE_DATA: dict[str, dict[str, Any]] = {
}, },
"keyword-circle-row": { "keyword-circle-row": {
"keywords": [ "keywords": [
# [legacy Phase R'/Q example — INTEGRATION-AUDIT-01 §10.4]
{"letter": "B", "label": "BIM", "description": "건물정보모델링"}, {"letter": "B", "label": "BIM", "description": "건물정보모델링"},
{"letter": "G", "label": "GIS", "description": "지리정보시스템"}, {"letter": "G", "label": "GIS", "description": "지리정보시스템"},
{"letter": "D", "label": "DX", "description": "디지털 전환"}, {"letter": "D", "label": "DX", "description": "디지털 전환"},
@@ -432,6 +421,7 @@ _SAMPLE_DATA: dict[str, dict[str, Any]] = {
"right_title": "개선", "right_title": "개선",
"rows": [ "rows": [
{"left": "수작업", "center": "프로세스", "right": "자동화"}, {"left": "수작업", "center": "프로세스", "right": "자동화"},
# [legacy Phase R'/Q example — INTEGRATION-AUDIT-01 §10.4]
{"left": "2D 도면", "center": "설계 도구", "right": "3D BIM"}, {"left": "2D 도면", "center": "설계 도구", "right": "3D BIM"},
], ],
}, },
+7 -32
View File
@@ -5,24 +5,18 @@ AI에게 불가능한 선택지를 주지 않는다 (Beautiful.ai 원칙).
주요 함수: 주요 함수:
- select_block_candidates(): topic + 컨테이너 → 물리적으로 가능한 후보 2-4개 - select_block_candidates(): topic + 컨테이너 → 물리적으로 가능한 후보 2-4개
- load_catalog(): catalog.yaml 로딩 + 캐싱 - load_catalog(): catalog.yaml 로딩 + 캐싱 (IMP-27: src.catalog 공유 로더 위임)
""" """
from __future__ import annotations from __future__ import annotations
import logging import logging
from pathlib import Path
from typing import Any from typing import Any
import yaml from src import catalog as _catalog_mod
from src.space_allocator import ContainerSpec, HEIGHT_COST_ORDER from src.space_allocator import ContainerSpec, HEIGHT_COST_ORDER
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
CATALOG_PATH = Path("templates/catalog.yaml")
_catalog_cache: dict | None = None
_catalog_mtime: float = 0.0
# ────────────────────────────────────── # ──────────────────────────────────────
# relation_type → 블록 카테고리 매핑 (Napkin.ai 방식) # relation_type → 블록 카테고리 매핑 (Napkin.ai 방식)
@@ -52,35 +46,16 @@ BLOCKS_FORCING_FORMAT_CHANGE = {
# ────────────────────────────────────── # ──────────────────────────────────────
# catalog.yaml 로딩 (mtime 캐시) # catalog.yaml 로딩 (IMP-27: src.catalog 공유 로더 위임)
# ────────────────────────────────────── # ──────────────────────────────────────
def load_catalog() -> dict: def load_catalog() -> dict:
"""catalog.yaml을 로딩한다. mtime 기반 캐싱.""" """catalog.yaml root dict (IMP-27: shared loader delegation)."""
global _catalog_cache, _catalog_mtime return _catalog_mod.load_root_catalog()
if not CATALOG_PATH.exists():
logger.error(f"catalog.yaml 미발견: {CATALOG_PATH}")
return {"blocks": []}
current_mtime = CATALOG_PATH.stat().st_mtime
if _catalog_cache is not None and current_mtime == _catalog_mtime:
return _catalog_cache
with open(CATALOG_PATH, encoding="utf-8") as f:
_catalog_cache = yaml.safe_load(f)
_catalog_mtime = current_mtime
block_count = len(_catalog_cache.get("blocks", []))
logger.info(f"[Q-2] catalog.yaml 로딩: {block_count}개 블록")
return _catalog_cache
def _get_block_by_id(block_id: str, catalog: dict) -> dict | None: def _get_block_by_id(block_id: str, catalog: dict) -> dict | None:
"""catalog에서 블록 ID로 검색.""" """catalog-injected 블록 ID 조회 (IMP-27: shared loader delegation)."""
for block in catalog.get("blocks", []): return _catalog_mod.get_block_by_id(block_id, catalog)
if block.get("id") == block_id:
return block
return None
# ────────────────────────────────────── # ──────────────────────────────────────
+76
View File
@@ -0,0 +1,76 @@
"""IMP-27: Shared catalog.yaml loader (single file-read + mtime cache).
Phase Q evolution 중 block_reference, block_selector, renderer 가 각각 templates/
catalog.yaml 을 읽고 mtime 캐시하던 중복을 한 곳으로 통합한다. call-site
signature 는 그대로 유지되며, 각 wrapper 는 본 모듈의 결과를 자신이 약속하는
형태(list[dict] / root dict / id→path projection)로 변환만 수행한다.
Functions:
load_root_catalog() -> dict : raw catalog dict (matches block_selector contract)
load_blocks() -> list[dict] : root_catalog.get("blocks", []) projection
get_block_by_id(block_id, catalog=None) -> dict | None
get_catalog_mtime() -> float : current cached mtime (renderer projection key)
"""
from __future__ import annotations
import logging
from pathlib import Path
import yaml
logger = logging.getLogger(__name__)
CATALOG_PATH = Path(__file__).parent.parent / "templates" / "catalog.yaml"
_catalog_cache: dict | None = None
_catalog_mtime: float = 0.0
def load_root_catalog() -> dict:
"""Load templates/catalog.yaml as root dict, with mtime caching.
Missing file → logs warning and returns ``{"blocks": []}`` (matches the
pre-IMP-27 behavior of block_selector.load_catalog and renderer._load_catalog_map).
"""
global _catalog_cache, _catalog_mtime
if not CATALOG_PATH.exists():
logger.warning(f"catalog.yaml 미발견: {CATALOG_PATH}")
return {"blocks": []}
current_mtime = CATALOG_PATH.stat().st_mtime
if _catalog_cache is not None and current_mtime == _catalog_mtime:
return _catalog_cache
with open(CATALOG_PATH, encoding="utf-8") as f:
_catalog_cache = yaml.safe_load(f)
_catalog_mtime = current_mtime
block_count = len((_catalog_cache or {}).get("blocks", []))
logger.info(f"[catalog] load: {block_count} blocks")
return _catalog_cache
def load_blocks() -> list[dict]:
"""Return blocks list (= root_catalog.get('blocks', []))."""
return load_root_catalog().get("blocks", [])
def get_block_by_id(block_id: str, catalog: dict | None = None) -> dict | None:
"""Locate a block entry by id.
``catalog=None`` → uses shared loader. caller-supplied catalog dict is
accepted as-is so the existing block_selector contract (catalog-injected)
keeps working unchanged.
"""
if catalog is None:
catalog = load_root_catalog()
for block in catalog.get("blocks", []):
if block.get("id") == block_id:
return block
return None
def get_catalog_mtime() -> float:
"""Current cached mtime (renderer projection caches key off this)."""
return _catalog_mtime
+20
View File
@@ -14,6 +14,26 @@ class Settings(BaseSettings):
slide_width: int = 1280 slide_width: int = 1280
slide_height: int = 720 slide_height: int = 720
# IMP-33 u1 — AI fallback policy. Fallback-path only; normal path AI=0.
# Defaults locked by Stage 2 plan; do NOT inline literals downstream.
ai_fallback_enabled: bool = False
ai_fallback_model: str = "claude-opus-4-6-20250415"
ai_fallback_timeout_s: float = 60.0
ai_fallback_max_retries: int = 3
ai_fallback_backoff_base_s: float = 1.0
ai_fallback_backoff_cap_s: float = 8.0
ai_fallback_backoff_jitter: float = 0.3
ai_fallback_budget_per_run: int = 10
ai_fallback_circuit_breaker_threshold: int = 5
# IMP-46 u5 — auto-cache flag. When True, `save_proposal` bypasses the
# `user_approved` gate only (`visual_check_passed` is never bypassed).
# Default OFF preserves the dual-gate contract; the CLI flag
# `--auto-cache` in `src/phase_z2_pipeline.py` mutates this setting at
# parse time. Downstream callers MUST source the flag from Settings,
# never inline literals.
ai_fallback_auto_cache: bool = False
model_config = {"env_file": ".env", "env_file_encoding": "utf-8"} model_config = {"env_file": ".env", "env_file_encoding": "utf-8"}
+4 -37
View File
@@ -8,9 +8,7 @@ Kei API 필수. fallback 없음. 성공할 때까지 무한 재시도.
""" """
from __future__ import annotations from __future__ import annotations
import json
import logging import logging
import re
from typing import Any from typing import Any
import anthropic import anthropic
@@ -18,10 +16,14 @@ import httpx
from src.config import settings from src.config import settings
from src.design_director import BLOCK_SLOTS from src.design_director import BLOCK_SLOTS
from src.json_utils import parse_json as _parse_json
from src.sse_utils import stream_sse_tokens from src.sse_utils import stream_sse_tokens
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# [legacy Phase R'/Q examples — INTEGRATION-AUDIT-01 §10.4]
# (sample-text literals at L43-L44 / L67 inside the EDITOR_PROMPT string below
# — "건설산업 디지털화", "BIM 전면 도입", "DX와 BIM 개념" preserved verbatim)
EDITOR_PROMPT = """당신은 도메인 전문가이자 콘텐츠 편집자이다. EDITOR_PROMPT = """당신은 도메인 전문가이자 콘텐츠 편집자이다.
원본 콘텐츠의 핵심 내용을 유지하면서 각 블록의 슬롯에 맞게 텍스트를 정리한다. 원본 콘텐츠의 핵심 내용을 유지하면서 각 블록의 슬롯에 맞게 텍스트를 정리한다.
@@ -438,38 +440,3 @@ async def fill_candidates(
logger.warning(f"[Phase P] 꼭지 {tid}: 텍스트 편집 파싱 실패") logger.warning(f"[Phase P] 꼭지 {tid}: 텍스트 편집 파싱 실패")
return candidates return candidates
def _parse_json(text: str) -> dict[str, Any] | None:
"""텍스트에서 JSON을 추출한다.
Kei API가 마크다운 리스트 접두사(- )를 붙여 응답하는 경우에도 처리.
"""
# 전처리: 각 줄 앞의 마크다운 리스트 접두사(- ) 제거
lines = text.split("\n")
cleaned_lines = []
for line in lines:
stripped = line.lstrip()
if stripped.startswith("- "):
cleaned_lines.append(stripped[2:])
elif stripped.startswith("* "):
cleaned_lines.append(stripped[2:])
else:
cleaned_lines.append(stripped)
cleaned = "\n".join(cleaned_lines)
# 원본 먼저 시도 → 클린 버전 시도
for target in [text, cleaned]:
patterns = [
r"```json\s*(.*?)```",
r"```\s*(.*?)```",
r"(\{.*\})",
]
for pattern in patterns:
match = re.search(pattern, target, re.DOTALL)
if match:
try:
return json.loads(match.group(1).strip())
except json.JSONDecodeError:
continue
return None
+3 -37
View File
@@ -5,9 +5,7 @@ Step B: 프리셋 안에서 블록 매핑 + 글자 수 가이드 (Sonnet)
""" """
from __future__ import annotations from __future__ import annotations
import json
import logging import logging
import re
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -15,6 +13,7 @@ import httpx
import yaml import yaml
from src.config import settings from src.config import settings
from src.json_utils import parse_json as _parse_json
from src.sse_utils import stream_sse_tokens from src.sse_utils import stream_sse_tokens
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -29,6 +28,7 @@ BLOCK_SLOTS = {
"slot_desc": { "slot_desc": {
"title_ko": "한글 메인 타이틀", "title_ko": "한글 메인 타이틀",
"title_en": "영문 서브 타이틀 (없으면 생략)", "title_en": "영문 서브 타이틀 (없으면 생략)",
# [legacy Phase R'/Q example — INTEGRATION-AUDIT-01 §10.4]
"breadcrumb": "상위 카테고리 경로 (예: 디지털전환 > BIM)", "breadcrumb": "상위 카테고리 경로 (예: 디지털전환 > BIM)",
"bg_image": "배경 이미지 경로", "bg_image": "배경 이미지 경로",
}, },
@@ -965,6 +965,7 @@ def _validate_height_budget(blocks: list[dict], preset: dict) -> list[dict]:
for block in blocks_to_remove: for block in blocks_to_remove:
blocks.remove(block) blocks.remove(block)
# [legacy Phase R'/Q example — INTEGRATION-AUDIT-01 §10.4]
# 삭제 후 zone_blocks 재구성 (후속 pill-pair/높이 체크에 반영) # 삭제 후 zone_blocks 재구성 (후속 pill-pair/높이 체크에 반영)
zone_blocks.clear() zone_blocks.clear()
for block in blocks: for block in blocks:
@@ -1064,38 +1065,3 @@ def _validate_height_budget(blocks: list[dict], preset: dict) -> list[dict]:
}) })
return overflows return overflows
def _parse_json(text: str) -> dict[str, Any] | None:
"""텍스트에서 JSON을 추출한다.
Kei API가 마크다운 리스트 접두사(- )를 붙여 응답하는 경우에도 처리.
"""
# 전처리: 각 줄 앞의 마크다운 리스트 접두사(- ) 제거
lines = text.split("\n")
cleaned_lines = []
for line in lines:
stripped = line.lstrip()
if stripped.startswith("- "):
cleaned_lines.append(stripped[2:])
elif stripped.startswith("* "):
cleaned_lines.append(stripped[2:])
else:
cleaned_lines.append(stripped)
cleaned = "\n".join(cleaned_lines)
# 원본 먼저 시도 → 클린 버전 시도
for target in [text, cleaned]:
patterns = [
r"```json\s*(.*?)```",
r"```\s*(.*?)```",
r"(\{.*\})",
]
for pattern in patterns:
match = re.search(pattern, target, re.DOTALL)
if match:
try:
return json.loads(match.group(1).strip())
except json.JSONDecodeError:
continue
return None
+3
View File
@@ -84,6 +84,9 @@ border-radius: 8px, padding: 14px 30px, text-align: center
def get_layout_rules() -> str: def get_layout_rules() -> str:
"""Phase S 검증 결과 기반 레이아웃 규칙.""" """Phase S 검증 결과 기반 레이아웃 규칙."""
# [legacy Phase R'/Q example — INTEGRATION-AUDIT-01 §10.4]
# (sample-text literal "DX와 BIM의 상세 비교" at ~L109 inside the return
# string below is preserved verbatim as a documented intentional example)
return """ return """
## 레이아웃 규칙 (검증 결과 기반 — 반드시 따를 것) ## 레이아웃 규칙 (검증 결과 기반 — 반드시 따를 것)
+1
View File
@@ -609,6 +609,7 @@ class SupplementBlock:
role: str role: str
block_id: str block_id: str
variant: str variant: str
# [legacy Phase R'/Q example — INTEGRATION-AUDIT-01 §10.4]
content_source: str # "popup:DX와 BIM의 구분" 등 content_source: str # "popup:DX와 BIM의 구분" 등
estimated_height_px: float estimated_height_px: float
available_px: float available_px: float
+1
View File
@@ -164,6 +164,7 @@ def _preprocess_text(text: str) -> str:
text = text.replace("S/W", "SW 소프트웨어") text = text.replace("S/W", "SW 소프트웨어")
text = text.replace("H/W", "HW 하드웨어") text = text.replace("H/W", "HW 하드웨어")
text = re.sub(r'\bDX\b', 'DX 디지털전환', text) text = re.sub(r'\bDX\b', 'DX 디지털전환', text)
# [legacy Phase R'/Q example — INTEGRATION-AUDIT-01 §10.4]
text = re.sub(r'\bBIM\b', 'BIM 건설정보모델링', text) text = re.sub(r'\bBIM\b', 'BIM 건설정보모델링', text)
# 괄호 내용 유지하되 괄호 제거 # 괄호 내용 유지하되 괄호 제거
+46
View File
@@ -0,0 +1,46 @@
"""JSON 추출 공용 유틸리티.
Kei / Claude API 응답 텍스트에서 JSON 객체를 추출한다.
content_editor, design_director, kei_client, pipeline 공통 헬퍼.
응답이 마크다운 리스트 접두사("- " / "* ")로 감싸진 경우에도 처리.
"""
from __future__ import annotations
import json
import re
from typing import Any
_JSON_PATTERNS: tuple[str, ...] = (
r"```json\s*(.*?)```",
r"```\s*(.*?)```",
r"(\{.*\})",
)
def parse_json(text: str) -> dict[str, Any] | None:
"""텍스트에서 JSON을 추출한다.
Kei API가 마크다운 리스트 접두사(- )를 붙여 응답하는 경우에도 처리.
원본 → 리스트 접두사 제거 버전 순서로 fenced JSON / plain fenced / 베어 brace 패턴을
차례로 시도한다. 모두 실패하면 None.
"""
lines = text.split("\n")
cleaned_lines: list[str] = []
for line in lines:
stripped = line.lstrip()
if stripped.startswith("- ") or stripped.startswith("* "):
cleaned_lines.append(stripped[2:])
else:
cleaned_lines.append(stripped)
cleaned = "\n".join(cleaned_lines)
for target in (text, cleaned):
for pattern in _JSON_PATTERNS:
match = re.search(pattern, target, re.DOTALL)
if match:
try:
return json.loads(match.group(1).strip())
except json.JSONDecodeError:
continue
return None
+7 -36
View File
@@ -13,6 +13,7 @@ from typing import Any
import httpx import httpx
from src.config import settings from src.config import settings
from src.json_utils import parse_json as _parse_json
from src.sse_utils import stream_sse_tokens from src.sse_utils import stream_sse_tokens
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -53,6 +54,7 @@ KEI_PROMPT = (
" 문장을 재작성하지 마라. 원본 문장을 그대로 가져와라.\n" " 문장을 재작성하지 마라. 원본 문장을 그대로 가져와라.\n"
"- **결론 텍스트도 원본 그대로.** 임의로 만들지 마라.\n" "- **결론 텍스트도 원본 그대로.** 임의로 만들지 마라.\n"
"- 원본에 있는 내용을 임의로 제거하거나 다른 의미로 바꾸지 마라.\n" "- 원본에 있는 내용을 임의로 제거하거나 다른 의미로 바꾸지 마라.\n"
# [legacy Phase R'/Q example — INTEGRATION-AUDIT-01 §10.4]
"- 텍스트 재구성이 허용되는 경우는 **빈 공간에 채울 요약(표, 팝업 요약)만**.\n" "- 텍스트 재구성이 허용되는 경우는 **빈 공간에 채울 요약(표, 팝업 요약)만**.\n"
"- 각 꼭지의 source_hint에 원본의 어떤 부분이 가는지 명시.\n\n" "- 각 꼭지의 source_hint에 원본의 어떤 부분이 가는지 명시.\n\n"
"## 배치 규칙\n" "## 배치 규칙\n"
@@ -162,6 +164,7 @@ KEI_PROMPT_B = (
" - 원본에 이미지가 참조되면 반드시 [이미지: 제목] 마커를 포함하라.\n" " - 원본에 이미지가 참조되면 반드시 [이미지: 제목] 마커를 포함하라.\n"
" - 출처가 있으면 포함하라.\n" " - 출처가 있으면 포함하라.\n"
" - '활용 필요', '구체화 필요' 같은 지시사항을 쓰지 마라. 실제 콘텐츠 항목만 쓰라.\n" " - '활용 필요', '구체화 필요' 같은 지시사항을 쓰지 마라. 실제 콘텐츠 항목만 쓰라.\n"
# [legacy Phase R'/Q examples — INTEGRATION-AUDIT-01 §10.4]
" - 예시: '건설산업(종합산업, 기술 통합 융합), BIM(정보관리 도구, 출처: 국토교통부 2020)'\n" " - 예시: '건설산업(종합산업, 기술 통합 융합), BIM(정보관리 도구, 출처: 국토교통부 2020)'\n"
" - 예시: '[이미지: DX와 핵심기술간 상호관계] 다이어그램, GIS 역할(공간 분석). [팝업: DX와 BIM의 구분] 비교표'\n\n" " - 예시: '[이미지: DX와 핵심기술간 상호관계] 다이어그램, GIS 역할(공간 분석). [팝업: DX와 BIM의 구분] 비교표'\n\n"
"## 출력 형식 (JSON만)\n" "## 출력 형식 (JSON만)\n"
@@ -789,6 +792,10 @@ async def call_kei_final_review(
# I-9: Kei 넘침 판단 호출 # I-9: Kei 넘침 판단 호출
# ────────────────────────────────────── # ──────────────────────────────────────
# [legacy Phase R'/Q example — INTEGRATION-AUDIT-01 §10.4]
# (sample-text literal "Option 2 (핵심 재구성 + 팝업 분리)" inside the
# KEI_OVERFLOW_PROMPT triple-quoted string below is preserved verbatim
# as a documented intentional example of overflow-judgment output)
KEI_OVERFLOW_PROMPT = """당신은 슬라이드 콘텐츠 전문가이다. KEI_OVERFLOW_PROMPT = """당신은 슬라이드 콘텐츠 전문가이다.
디자인 팀장이 배치한 블록들이 컨테이너(zone)의 높이 예산을 초과했다. 디자인 팀장이 배치한 블록들이 컨테이너(zone)의 높이 예산을 초과했다.
콘텐츠의 중요도와 전달 메시지를 기준으로 어떻게 처리할지 판단하라. 콘텐츠의 중요도와 전달 메시지를 기준으로 어떻게 처리할지 판단하라.
@@ -883,42 +890,6 @@ async def call_kei_overflow_judgment(
return None return None
def _parse_json(text: str) -> dict[str, Any] | None:
"""텍스트에서 JSON을 추출한다.
Kei API가 마크다운 리스트 접두사(- )를 붙여 응답하는 경우에도 처리.
"""
# 전처리: 각 줄 앞의 마크다운 리스트 접두사(- ) 제거
# Kei API가 JSON을 마크다운 리스트로 감싸서 응답하는 경우 대응
lines = text.split("\n")
cleaned_lines = []
for line in lines:
stripped = line.lstrip()
if stripped.startswith("- "):
cleaned_lines.append(stripped[2:])
elif stripped.startswith("* "):
cleaned_lines.append(stripped[2:])
else:
cleaned_lines.append(stripped)
cleaned = "\n".join(cleaned_lines)
# 원본 + 클린 버전 둘 다 시도
for target in [text, cleaned]:
patterns = [
r"```json\s*(.*?)```",
r"```\s*(.*?)```",
r"(\{.*\})",
]
for pattern in patterns:
match = re.search(pattern, target, re.DOTALL)
if match:
try:
return json.loads(match.group(1).strip())
except json.JSONDecodeError:
continue
return None
async def select_best_candidate( async def select_best_candidate(
topic_results: list[dict[str, Any]], topic_results: list[dict[str, Any]],
analysis: dict[str, Any], analysis: dict[str, Any],
+15
View File
@@ -0,0 +1,15 @@
"""IMP-33 AI fallback package (fallback path only).
Module path locked by IMP-31-GATE-AUDIT.md (Stage 1 binding).
Normal path AI call count MUST remain 0; this package only executes under
classified fallback routes (reject / restructure / overflow). See
`feedback_ai_isolation_contract`.
"""
from __future__ import annotations
from src.phase_z2_ai_fallback.schema import (
AiFallbackProposal,
ProposalKind,
)
__all__ = ["AiFallbackProposal", "ProposalKind"]
+243
View File
@@ -0,0 +1,243 @@
"""IMP-46 u2 + u3 + u5 — Persistent JSON cache backend for AI fallback proposals.
Replaces the IMP-33 u6 ``NotImplementedError`` stub with a content-addressed
store at ``data/frame_cache/{frame_id}/{signature_hash}.json``.
Key format:
* ``read_proposal(key)`` / ``save_proposal(key, ...)`` accept a string ``key``
of the form ``"{frame_id}::{signature_hash}"``. The two components are
parsed inside this module so that upstream callers (router, step 12)
remain unaware of the on-disk layout.
* ``read_proposal`` on a malformed (legacy) key silently returns ``None``
— the IMP-33 u7 router currently passes a legacy ``cache_key`` string,
and u4 will switch to the structural form. Until then, all such reads
must miss safely (no exception, no false hit).
* ``save_proposal`` on a malformed key raises ``ValueError`` (loud, never
silent) — writes are gated and must use the structural form.
Stored payload (one JSON file per (frame_id, signature_hash) pair):
{
"schema_version": 1,
"proposal": <AiFallbackProposal.model_dump(mode="json")>,
"slide_css": <str | null>,
"fingerprints": {"contract_sha": ..., "partial_sha": ..., "catalog_sha": ...}
}
u3 invalidation contract (this module is a *comparator*, not a *computer*):
* ``save_proposal`` persists the ``fingerprints`` dict supplied by the
caller verbatim. Cache.py never computes any fingerprint — the three
declared shas (``contract_sha`` / ``partial_sha`` / ``catalog_sha``) are
computed by callers from the live contract YAML / partial templates /
catalog payloads and handed in. Keeping the computation out of cache.py
preserves AI isolation (no Phase Z runtime knowledge in the cache
module) and keeps the cache schema-agnostic — additional fingerprint
axes can be added without editing cache.py.
* ``read_proposal`` accepts an optional ``fingerprints`` kwarg. When
supplied, the stored ``fingerprints`` dict must equal the caller's dict
exactly (strict equality, NOT subset). Any mismatch — including a key
the caller demands but the stored entry lacks, OR a key the stored
entry has but the caller does not pass — returns ``None``. Default
``fingerprints=None`` performs no comparison (back-compat for legacy
callers that have not yet adopted fingerprint-aware lookup).
Guardrails (locked by Stage 2 plan):
* Both write gates preserved — ``visual_check_passed=False`` always
raises ``AiFallbackCacheGateError`` BEFORE any filesystem touch.
``user_approved=False`` also raises by default; the IMP-46 u5
``auto_cache=True`` override bypasses ONLY the ``user_approved`` gate
(``visual_check_passed`` is never bypassed). Gate violation never
silently no-ops.
* Missing or corrupt files cause ``read_proposal`` to return ``None`` —
the cache is a hint, never a hard dependency. Errors are not propagated
to callers because the AI fallback path can always recompute.
* ``mkdir(parents=True, exist_ok=True)`` is performed lazily on save.
* No Anthropic / MDX / Phase Z runtime imports (AI isolation contract).
* Cache root is held as a module-level :data:`CACHE_ROOT` so tests can
redirect writes via ``monkeypatch.setattr`` without subclassing.
u5 auto-cache contract (CLI ``--auto-cache`` + ``settings.ai_fallback_auto_cache``):
* ``save_proposal(..., auto_cache=True)`` only bypasses the
``user_approved`` gate; ``visual_check_passed`` remains mandatory.
* ``auto_cache`` is keyword-only and defaults to ``False`` — existing
callers (and the test suite) see the original dual-gate behaviour
unless they opt in explicitly.
* The truth table over ``(visual_check_passed, user_approved, auto_cache)``
has eight cells; exactly three succeed:
``(True, True, False)``, ``(True, True, True)``, and
``(True, False, True)``. Every other cell raises
``AiFallbackCacheGateError``.
"""
from __future__ import annotations
import json
import pathlib
from src.phase_z2_ai_fallback.schema import AiFallbackProposal
SCHEMA_VERSION = 1
KEY_DELIMITER = "::"
CACHE_ROOT: pathlib.Path = pathlib.Path("data/frame_cache")
class AiFallbackCacheGateError(RuntimeError):
"""Raised when ``save_proposal`` is called without both IMP-46 gates True."""
def _parse_key(key: str) -> tuple[str, str] | None:
"""Parse a ``frame_id::signature_hash`` key. Returns ``None`` if malformed."""
if KEY_DELIMITER not in key:
return None
frame_id, _, signature_hash = key.partition(KEY_DELIMITER)
if not frame_id or not signature_hash:
return None
if KEY_DELIMITER in signature_hash:
return None
return frame_id, signature_hash
def _cache_path(frame_id: str, signature_hash: str) -> pathlib.Path:
return CACHE_ROOT / frame_id / f"{signature_hash}.json"
def read_proposal(
key: str,
*,
fingerprints: dict | None = None,
) -> AiFallbackProposal | None:
"""Look up a previously cached proposal by ``key``.
Returns ``None`` for:
* empty / non-string key → ``ValueError`` (loud);
* non-dict ``fingerprints`` (when supplied) → ``TypeError`` (loud,
symmetric with :func:`save_proposal`);
* legacy key format (no ``::`` delimiter) → silent ``None`` (router
back-compat until u4 switches to the structural form);
* missing file under ``data/frame_cache/{frame_id}/{signature_hash}.json``;
* corrupt JSON / payload schema mismatch — read errors never propagate;
* ``fingerprints`` supplied AND stored ``fingerprints`` field is not a
dict OR does not equal the supplied dict (strict equality,
u3 invalidation).
"""
if not isinstance(key, str) or not key:
raise ValueError("cache key must be a non-empty string")
if fingerprints is not None and not isinstance(fingerprints, dict):
raise TypeError("fingerprints must be a dict or None")
parsed = _parse_key(key)
if parsed is None:
return None
frame_id, signature_hash = parsed
path = _cache_path(frame_id, signature_hash)
if not path.is_file():
return None
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return None
if not isinstance(data, dict):
return None
if fingerprints is not None:
stored = data.get("fingerprints")
if not isinstance(stored, dict) or stored != fingerprints:
return None
proposal_dict = data.get("proposal")
if not isinstance(proposal_dict, dict):
return None
try:
return AiFallbackProposal.model_validate(proposal_dict)
except Exception: # noqa: BLE001 — corrupt payload must miss, not raise
return None
def save_proposal(
key: str,
proposal: AiFallbackProposal,
*,
visual_check_passed: bool,
user_approved: bool,
slide_css: str | None = None,
fingerprints: dict | None = None,
auto_cache: bool = False,
) -> pathlib.Path:
"""Persist ``proposal`` under ``key`` once the IMP-46 gates clear.
Gate contract (IMP-46 u5 truth table):
* ``visual_check_passed=False`` -> :class:`AiFallbackCacheGateError`
always (never bypassable; ``auto_cache`` cannot override).
* ``user_approved=False`` AND ``auto_cache=False`` ->
:class:`AiFallbackCacheGateError`.
* ``user_approved=False`` AND ``auto_cache=True`` -> bypass the
user-approval gate (IMP-46 u5 CLI / settings opt-in).
* Otherwise (``visual_check_passed=True`` AND either
``user_approved=True`` OR ``auto_cache=True``) -> persist payload.
Gate violations are raised BEFORE any filesystem touch — no parent
directory is created, no file is written. When the gates clear the
JSON payload (schema_version + proposal + slide_css + fingerprints)
is written to ``data/frame_cache/{frame_id}/{signature_hash}.json``
and the resolved :class:`pathlib.Path` is returned.
``slide_css`` may be ``None`` (no slide-level CSS captured) or a
string. ``fingerprints`` may be ``None`` (treated as empty dict) or a
dict mapping fingerprint name to SHA hex digest.
``auto_cache`` is keyword-only and defaults to ``False``. It is wired
from :data:`src.config.settings.ai_fallback_auto_cache`, which the
``--auto-cache`` CLI flag in ``src/phase_z2_pipeline.py`` toggles at
parse time. The cache module never reads the setting itself — the
caller passes the resolved boolean — so AI-isolation contracts
(no Phase Z runtime / no Anthropic import) remain intact.
"""
if not isinstance(key, str) or not key:
raise ValueError("cache key must be a non-empty string")
if not isinstance(proposal, AiFallbackProposal):
raise TypeError(
"proposal must be an AiFallbackProposal instance "
f"(got {type(proposal).__name__})"
)
if not isinstance(auto_cache, bool):
raise TypeError("auto_cache must be a bool")
if not visual_check_passed:
raise AiFallbackCacheGateError(
"IMP-46 gate: visual_check_passed=False; refusing to cache an "
"unverified proposal. (auto_cache cannot bypass this gate.)"
)
if not user_approved and not auto_cache:
raise AiFallbackCacheGateError(
"IMP-46 gate: user_approved=False and auto_cache=False; "
"refusing to cache without explicit user approval. Pass "
"auto_cache=True (or --auto-cache on the CLI) to bypass."
)
if slide_css is not None and not isinstance(slide_css, str):
raise TypeError("slide_css must be a string or None")
if fingerprints is None:
fingerprints = {}
elif not isinstance(fingerprints, dict):
raise TypeError("fingerprints must be a dict or None")
parsed = _parse_key(key)
if parsed is None:
raise ValueError(
"cache key must be in "
f"'frame_id{KEY_DELIMITER}signature_hash' format; got {key!r}"
)
frame_id, signature_hash = parsed
path = _cache_path(frame_id, signature_hash)
path.parent.mkdir(parents=True, exist_ok=True)
payload = {
"schema_version": SCHEMA_VERSION,
"proposal": proposal.model_dump(mode="json"),
"slide_css": slide_css,
"fingerprints": dict(fingerprints),
}
path.write_text(
json.dumps(payload, sort_keys=True, ensure_ascii=False, indent=2),
encoding="utf-8",
)
return path
+92
View File
@@ -0,0 +1,92 @@
"""IMP-33 u4 — AI fallback Anthropic client (fallback path only).
Wraps ``anthropic.Anthropic.messages.create`` with the timeout / retry /
backoff / budget / circuit-breaker policy locked in u1 ``Settings``. NO
inline policy literals: every knob is sourced from ``src.config.settings``.
Transient errors (timeout / connection / 429 / 5xx) are retried with
capped exponential backoff + jitter; all other errors propagate without
retry. PZ-1 invariant: this module is fallback-path only and MUST NOT be
imported on the normal pipeline path.
"""
from __future__ import annotations
import json
import random
import time
from dataclasses import dataclass
from typing import Any
import anthropic
from src.config import settings
from src.phase_z2_ai_fallback.schema import AiFallbackProposal
_TRANSIENT_ERRORS: tuple[type[BaseException], ...] = (
anthropic.APITimeoutError,
anthropic.APIConnectionError,
anthropic.RateLimitError,
anthropic.InternalServerError,
)
# Output cap is an Anthropic API requirement, not a policy knob (u1).
_MAX_OUTPUT_TOKENS = 4096
class AiFallbackBudgetExceeded(RuntimeError):
"""Per-run AI call budget (u1 ai_fallback_budget_per_run) exhausted."""
class AiFallbackCircuitOpen(RuntimeError):
"""Circuit breaker tripped (u1 ai_fallback_circuit_breaker_threshold)."""
@dataclass
class AiFallbackClient:
"""Stateful per-run fallback client (budget + circuit accounting)."""
client: Any = None
_calls: int = 0
_consecutive_failures: int = 0
def __post_init__(self) -> None:
if self.client is None:
self.client = anthropic.Anthropic(
api_key=settings.anthropic_api_key,
timeout=settings.ai_fallback_timeout_s,
)
def request_proposal(self, prompt: dict[str, str]) -> AiFallbackProposal:
if self._calls >= settings.ai_fallback_budget_per_run:
raise AiFallbackBudgetExceeded(
f"per-run budget {settings.ai_fallback_budget_per_run} exhausted"
)
if self._consecutive_failures >= settings.ai_fallback_circuit_breaker_threshold:
raise AiFallbackCircuitOpen(
f"circuit open after {self._consecutive_failures} consecutive failures"
)
self._calls += 1
last_error: BaseException | None = None
for attempt in range(settings.ai_fallback_max_retries + 1):
try:
response = self.client.messages.create(
model=settings.ai_fallback_model,
max_tokens=_MAX_OUTPUT_TOKENS,
system=prompt["system"],
messages=[{"role": "user", "content": prompt["user"]}],
)
text = "".join(
block.text for block in response.content if hasattr(block, "text")
)
self._consecutive_failures = 0
return AiFallbackProposal.model_validate(json.loads(text))
except _TRANSIENT_ERRORS as err:
last_error = err
if attempt >= settings.ai_fallback_max_retries:
break
base = settings.ai_fallback_backoff_base_s * (2 ** attempt)
delay = min(settings.ai_fallback_backoff_cap_s, base)
delay += random.uniform(0, delay * settings.ai_fallback_backoff_jitter)
time.sleep(delay)
self._consecutive_failures += 1
assert last_error is not None
raise last_error
+80
View File
@@ -0,0 +1,80 @@
"""IMP-33 u3 — AI fallback prompt builder (fallback path only).
System+user prompt for the Anthropic client (u4). MDX is READ-ONLY
(`feedback_ai_isolation_contract`); output is constrained to the u2
schema; frame_id swap is forbidden (V4 rank-1 protected,
`feedback_phase_z_spacing_direction`). Inputs per Stage 2 plan: V4
result (route=ai_adaptation_required, cardinality), frame_contract,
frame_visual HTML, figma_to_html_agent partial JSON, Internal Region,
MDX text.
"""
from __future__ import annotations
import json
from typing import Any
from src.phase_z2_ai_fallback.schema import FORBIDDEN_KINDS, ProposalKind
V4_ROUTE_AI_ADAPTATION = "ai_adaptation_required"
_ALLOWED_KINDS = ", ".join(sorted(k.value for k in ProposalKind))
_FORBIDDEN_KINDS = ", ".join(sorted(FORBIDDEN_KINDS))
SYSTEM_PROMPT = (
"You are an IMP-33 AI fallback adapter for Phase Z slide composition.\n"
"STRICT RULES:\n"
" 1. MDX text in the user payload is READ-ONLY. Do NOT rewrite, "
"compress, or paraphrase MDX.\n"
" 2. Output MUST be a single JSON object conforming to AiFallbackProposal.\n"
f" 3. proposal_kind MUST be one of: {_ALLOWED_KINDS}.\n"
f" 4. Do NOT propose any of: {_FORBIDDEN_KINDS}.\n"
" 5. Do NOT change frame_id — V4 rank-1 frame is locked.\n"
" 6. Keep declared frame slots (text/table/image/details) populated.\n"
" 7. Respect Internal Region containment; place content units within "
"the declared region only."
)
def build_ai_fallback_prompt(
*,
v4_result: dict[str, Any],
frame_contract: dict[str, Any],
frame_visual_html: str,
figma_partial_json: dict[str, Any],
internal_region: dict[str, Any],
mdx_text: str,
) -> dict[str, str]:
"""Build system+user prompt strings for the fallback AI adapter.
Raises:
ValueError: when ``v4_result.route`` is not
``ai_adaptation_required`` — the fallback prompt MUST NOT be
built outside this route (normal-path AI call count must
remain 0; PZ-1).
"""
route = v4_result.get("route") or v4_result.get("imp05_route_hint")
if route != V4_ROUTE_AI_ADAPTATION:
raise ValueError(
f"build_ai_fallback_prompt: v4_result.route={route!r} is not "
f"{V4_ROUTE_AI_ADAPTATION!r}; fallback prompt MUST NOT be built "
"outside the AI adaptation route."
)
user_payload = {
"v4": {
"route": route,
"cardinality": v4_result.get("cardinality")
or v4_result.get("cardinality_signature"),
"label": v4_result.get("label"),
"frame_id": v4_result.get("frame_id"),
"rank": v4_result.get("rank"),
},
"frame_contract": frame_contract,
"frame_visual_html": frame_visual_html,
"figma_partial_json": figma_partial_json,
"internal_region": internal_region,
"mdx_text_READ_ONLY": mdx_text,
}
return {
"system": SYSTEM_PROMPT,
"user": json.dumps(user_payload, ensure_ascii=False),
}
+89
View File
@@ -0,0 +1,89 @@
"""IMP-33 u7 — AI fallback router (fallback path only).
Composes the IMP-33 fallback flow:
1. flag gate (``settings.ai_fallback_enabled`` default OFF)
2. V4 route gate (route must equal ``ai_adaptation_required``)
3. cache read (u6 stub returns ``None`` until IMP-46 lands)
4. build prompt (u3)
5. call client (u4 ``request_proposal``)
6. validate (u5 ``validate_proposal``)
Returns the validated ``AiFallbackProposal``. Save to cache is NOT
performed here — it is caller-driven AFTER ``visual_check_passed=True``
AND ``user_approved=True``, per the u6 IMP-46 gate. The router does not
import ``save_proposal``; this is the structural guarantee that the
router cannot persist a proposal before the caller's visual + user
checks (`feedback_artifact_status_naming`).
Guardrails:
* PZ-1 — normal-path AI call count stays 0: flag-off OR route-mismatch
short-circuits BEFORE the prompt builder or client are touched.
* ``feedback_ai_isolation_contract`` — MDX READ-ONLY (u3 enforces in
prompt; this module never reads or writes MDX).
* ``feedback_phase_z_spacing_direction`` — V4 rank-1 protected (u5
enforces; router only forwards the contract).
"""
from __future__ import annotations
from typing import Any
from src.config import settings
from src.phase_z2_ai_fallback.cache import read_proposal
from src.phase_z2_ai_fallback.client import AiFallbackClient
from src.phase_z2_ai_fallback.prompts import (
V4_ROUTE_AI_ADAPTATION,
build_ai_fallback_prompt,
)
from src.phase_z2_ai_fallback.schema import AiFallbackProposal
from src.phase_z2_ai_fallback.validate import validate_proposal
def route_ai_fallback(
*,
cache_key: str,
v4_result: dict[str, Any],
frame_contract: dict[str, Any],
frame_visual_html: str,
figma_partial_json: dict[str, Any],
internal_region: dict[str, Any],
mdx_text: str,
client: AiFallbackClient | None = None,
) -> AiFallbackProposal | None:
"""Route a fallback request through cache → prompt → client → validate.
Returns ``None`` when the master flag is OFF or when the V4 route is
not ``ai_adaptation_required`` — both gates short-circuit BEFORE any
prompt/client work, so the normal-path AI call count stays at 0
(PZ-1).
"""
if not settings.ai_fallback_enabled:
return None
route = v4_result.get("route") or v4_result.get("imp05_route_hint")
if route != V4_ROUTE_AI_ADAPTATION:
return None
cached = read_proposal(cache_key)
if cached is not None:
validate_proposal(
cached,
frame_contract=frame_contract,
internal_region=internal_region,
)
return cached
prompt = build_ai_fallback_prompt(
v4_result=v4_result,
frame_contract=frame_contract,
frame_visual_html=frame_visual_html,
figma_partial_json=figma_partial_json,
internal_region=internal_region,
mdx_text=mdx_text,
)
active_client = client if client is not None else AiFallbackClient()
proposal = active_client.request_proposal(prompt)
validate_proposal(
proposal,
frame_contract=frame_contract,
internal_region=internal_region,
)
return proposal
+50
View File
@@ -0,0 +1,50 @@
"""IMP-33 u2 — AI fallback proposal schema.
Whitelisted proposal kinds (Stage 2 plan):
- builder_options_patch : zone/frame builder option overrides
- partial_overrides : Internal Region / Frame Slot content overrides
- slot_mapping_proposal : restructuring proposal (content unit mapping)
Forbidden output forms (rejected by validator):
- mdx_text (MDX read-only — `feedback_ai_isolation_contract`)
- frame_id_change (V4 rank-1 protected — `feedback_phase_z_spacing_direction`)
- raw_html (HTML structure is code-decided, not AI-generated)
- raw_css (same)
"""
from __future__ import annotations
from enum import Enum
from typing import Any
from pydantic import BaseModel, ConfigDict, Field, field_validator
class ProposalKind(str, Enum):
BUILDER_OPTIONS_PATCH = "builder_options_patch"
PARTIAL_OVERRIDES = "partial_overrides"
SLOT_MAPPING_PROPOSAL = "slot_mapping_proposal"
FORBIDDEN_KINDS: frozenset[str] = frozenset(
{"mdx_text", "frame_id_change", "raw_html", "raw_css"}
)
class AiFallbackProposal(BaseModel):
"""Single AI fallback proposal (output contract for u4 client)."""
model_config = ConfigDict(extra="forbid")
proposal_kind: ProposalKind
payload: dict[str, Any] = Field(default_factory=dict)
rationale: str = ""
@field_validator("proposal_kind", mode="before")
@classmethod
def _reject_forbidden_kind(cls, value: Any) -> Any:
if isinstance(value, str) and value in FORBIDDEN_KINDS:
raise ValueError(
f"proposal_kind={value!r} is forbidden (MDX/frame/raw HTML/CSS "
"mutations are not permitted under IMP-33)."
)
return value
+216
View File
@@ -0,0 +1,216 @@
"""IMP-33 u8 + IMP-46 u4 — Step 12 AI repair wiring with structural cache key.
Phase Z Step 12 = slot_payload (the runtime "light_edit / restructure" surface
where AI-assisted frame-aware adaptation is allowed per IMP-17 carve-out).
This module is the only call site that pipes Phase Z composition units into
``src.phase_z2_ai_fallback.router.route_ai_fallback``. One structural gate
preserves the AI isolation contract:
* IMP-30 provisional gate — units with ``provisional=False`` are skipped
before any route classification. AI repair is reserved for first-render
invariant survivors (no rank-1 V4 evidence, recovered as provisional).
Per IMP-47B u1+u2, the ``reject`` V4 label routes to
``ai_adaptation_required`` (no longer ``design_reference_only``) and is
admitted to the AI repair path; the legacy "reject gate" short-circuit is
removed. Any unit whose ``route_hint`` is not ``ai_adaptation_required``
still falls through to the catch-all ``route_not_ai_adaptation:<hint>``
skip — that single gate continues to enforce the AI=0 normal path.
Combined with the u7 router's flag-off + route-gate short-circuits, the
default Phase Z run path performs zero AI calls (PZ-1). Save to cache is
NOT performed here — that is the caller's responsibility AFTER
``visual_check_passed=True`` AND ``user_approved=True`` (u6 IMP-46 gate).
IMP-46 u4 — structural cache key + fingerprints
------------------------------------------------
The legacy ``cache_key`` was ``"{template_id}::{sorted(source_section_ids)}"``
which leaked sample / section identity into the cache surface
(no-hardcoding lock violation: structurally identical content with
different MDX section ids would miss). u4 replaces it with
``"{frame_id}::{signature_hash}"`` where ``signature_hash`` is the
deterministic SHA256 over the 8 declared structural axes (see
``src.phase_z2_ai_fallback.signature``). Per-unit signature inputs are
read from unit attributes:
* ``cardinality`` (int | None) — also forwarded to ``v4_result``
* ``layout_preset`` (str)
* ``zone_position`` (str)
* ``source_shape`` (str) — bullet / paragraph / table / mixed
* ``h3_count`` (int)
* ``char_count`` (int) — bucketed via ``bucket_char_count``
In parallel the three invalidation fingerprints
(``contract_sha`` / ``partial_sha`` / ``catalog_sha``) are computed and
attached to the record. The cache.py module remains a *comparator* — all
fingerprint *computation* happens here (or via injected loaders) so the
cache schema-agnostic contract is preserved. The router's existing
``read_proposal(cache_key)`` continues to perform exact-match lookup only
(fuzzy is deferred per Stage 2 plan); read-side fingerprint validation
through the router is a follow-up axis.
"""
from __future__ import annotations
import hashlib
import json
from typing import Any, Callable, Iterable
from src.phase_z2_ai_fallback.router import route_ai_fallback
from src.phase_z2_ai_fallback.signature import bucket_char_count, build_signature
_AI_ADAPTATION_ROUTE = "ai_adaptation_required"
def _sha256_of(payload: Any) -> str:
"""Deterministic SHA256 hex digest over a JSON-serialisable payload."""
encoded = json.dumps(payload, sort_keys=True, ensure_ascii=False).encode("utf-8")
return hashlib.sha256(encoded).hexdigest()
def gather_step12_ai_repair_proposals(
units: Iterable[Any],
*,
route_for_label: Callable[[str | None], str | None],
get_contract_fn: Callable[[str], dict | None],
frame_visual_loader: Callable[[str], str],
figma_partial_loader: Callable[[str], dict] | None = None,
internal_region_lookup: Callable[[Any], dict] | None = None,
mdx_text_loader: Callable[[Any], str] | None = None,
catalog_sha_loader: Callable[[], str] | None = None,
) -> list[dict]:
"""Return one record per unit describing the Step 12 AI repair decision.
The record schema is stable across all gate decisions so the Step 12
artifact consumer can rely on a single shape:
{
"unit_index": int,
"source_section_ids": list[str],
"frame_template_id": str,
"label": str | None,
"route_hint": str | None,
"provisional": bool,
"ai_called": bool,
"skip_reason": str | None,
"proposal": dict | None,
"error": str | None,
"cache_key": str | None, # IMP-46 u4
"fingerprints": dict | None, # IMP-46 u4
}
``cache_key`` and ``fingerprints`` are populated only when the unit
reaches the AI-eligible code path (provisional + ai_adaptation route).
Skipped units retain ``None`` for both — the structural axes
(layout_preset / zone_position / source_shape / h3_count / char_count)
are not guaranteed to be set for non-AI paths.
``ai_called`` is True only when ``route_ai_fallback`` was invoked AND
returned a proposal OR raised. Flag-off / route-mismatch returns
``None`` from the router and is surfaced as ``ai_called=False`` with
``skip_reason="router_short_circuit"`` so the caller can distinguish
"router decided not to run" from "router ran and returned a proposal".
"""
records: list[dict] = []
catalog_sha = (
catalog_sha_loader() if catalog_sha_loader is not None else ""
)
for index, unit in enumerate(units):
label = getattr(unit, "label", None)
route_hint = route_for_label(label)
record: dict = {
"unit_index": index,
"source_section_ids": list(getattr(unit, "source_section_ids", []) or []),
"frame_template_id": getattr(unit, "frame_template_id", None),
"label": label,
"route_hint": route_hint,
"provisional": bool(getattr(unit, "provisional", False)),
"ai_called": False,
"skip_reason": None,
"proposal": None,
"error": None,
"cache_key": None,
"fingerprints": None,
}
if not record["provisional"]:
record["skip_reason"] = "not_provisional"
records.append(record)
continue
if route_hint != _AI_ADAPTATION_ROUTE:
record["skip_reason"] = f"route_not_ai_adaptation:{route_hint}"
records.append(record)
continue
template_id = record["frame_template_id"] or ""
frame_contract = get_contract_fn(template_id) or {}
frame_visual_html = frame_visual_loader(template_id)
figma_partial_json = (
figma_partial_loader(template_id) if figma_partial_loader is not None else {}
)
internal_region = (
internal_region_lookup(unit) if internal_region_lookup is not None else {}
)
mdx_text = (
mdx_text_loader(unit)
if mdx_text_loader is not None
else (getattr(unit, "raw_content", "") or "")
)
frame_id_value = getattr(unit, "frame_id", "") or ""
cardinality = getattr(unit, "cardinality", None)
layout_preset = getattr(unit, "layout_preset", "") or ""
zone_position = getattr(unit, "zone_position", "") or ""
source_shape = getattr(unit, "source_shape", "paragraph") or "paragraph"
h3_count = int(getattr(unit, "h3_count", 0) or 0)
char_count = int(getattr(unit, "char_count", 0) or 0)
char_count_bucket = bucket_char_count(char_count)
signature_hash = build_signature(
frame_id=frame_id_value,
v4_label=label or "",
cardinality=cardinality,
source_shape=source_shape,
h3_count=h3_count,
char_count_bucket=char_count_bucket,
layout_preset=layout_preset,
zone_position=zone_position,
)
cache_key = f"{frame_id_value}::{signature_hash}"
fingerprints = {
"contract_sha": _sha256_of(frame_contract),
"partial_sha": _sha256_of(figma_partial_json),
"catalog_sha": catalog_sha,
}
record["cache_key"] = cache_key
record["fingerprints"] = fingerprints
v4_result = {
"route": route_hint,
"label": label,
"frame_id": getattr(unit, "frame_id", None),
"rank": getattr(unit, "v4_rank", None),
"cardinality": cardinality,
}
try:
proposal = route_ai_fallback(
cache_key=cache_key,
v4_result=v4_result,
frame_contract=frame_contract,
frame_visual_html=frame_visual_html,
figma_partial_json=figma_partial_json,
internal_region=internal_region,
mdx_text=mdx_text,
)
except Exception as exc: # noqa: BLE001 — record + continue, no AI re-raise
record["ai_called"] = True
record["error"] = f"{type(exc).__name__}: {exc}"
records.append(record)
continue
if proposal is None:
record["skip_reason"] = "router_short_circuit"
records.append(record)
continue
record["ai_called"] = True
record["proposal"] = proposal.model_dump()
records.append(record)
return records
+111
View File
@@ -0,0 +1,111 @@
"""IMP-33 u9 — Step 17 AI repair wiring (BLOCKED until IMP-34 + IMP-35 land).
Phase Z Step 17 = retry / salvage cascade (see ``src.phase_z2_pipeline``
section 11.7 ``_attempt_salvage_chain`` and the existing IMP-12 u8/u9
deterministic chain at ``src/phase_z2_pipeline.py:1994`` and
``src/phase_z2_pipeline.py:4948``).
Per IMP-17 carve-out (``docs/architecture/IMP-17-CARVE-OUT.md`` lines 16,
40-44), AI repair at Step 17 is permitted ONLY after the full deterministic
chain is exhausted AND popup escalation is exhausted AND a user-approved
fallback budget remains. IMP-34 (zone resize + compact retry) and IMP-35
(``details_popup_escalation``) are explicit prerequisites under the IMP-33
out-of-scope contract — neither has landed yet. Therefore Step 17 AI repair
is STRUCTURALLY BLOCKED at u9.
This module:
1. **SPECIFIES** the canonical overflow cascade order via
:data:`OVERFLOW_CASCADE_ORDER` — ``deterministic`` → ``popup`` →
``ai_repair`` → ``user_override``. Downstream Step 17 consumers can rely
on this single source of truth.
2. **KEEPS** Step 17 AI repair structurally blocked. The entry point
:func:`gather_step17_ai_repair_proposals` does NOT import
``route_ai_fallback`` (u7), does NOT instantiate ``AiFallbackClient`` (u4),
and does NOT call any Anthropic API. Every unit is recorded with
``skip_reason="step17_ai_blocked_imp_34_35_prerequisites_missing"`` so
the caller can distinguish "blocked by carve-out gate" from any other
skip path (e.g., u8 ``not_provisional`` / ``design_reference_only_no_ai``).
Once IMP-34 + IMP-35 land AND a user-approved fallback budget is granted,
this module will gain the actual ``route_ai_fallback`` wiring guarded by
the cascade-stage conjunction. Today the gate is closed.
"""
from __future__ import annotations
from enum import Enum
from typing import Any, Callable, Iterable
class OverflowCascadeStage(str, Enum):
"""Step 17 overflow cascade stages — canonical order (u9 single source of truth).
Members are ordered to match the AI isolation contract:
* ``DETERMINISTIC`` — IMP-12 u4/u5/u6 (``cross_zone_redistribute`` /
``glue_compression`` / ``font_step_compression``) + IMP-12 terminal
actions (``layout_adjust`` / ``frame_reselect``) + IMP-34
(``zone resize + compact retry``, pending). No AI in any sub-stage.
* ``POPUP`` — IMP-35 (``details_popup_escalation``, pending). Content
popup escalation as the final deterministic resort before any AI.
* ``AI_REPAIR`` — IMP-33 (this carve-out) + IMP-46 cache. Only reachable
after DETERMINISTIC and POPUP are both exhausted AND user-approved
fallback budget remains.
* ``USER_OVERRIDE`` — explicit user override after all auto stages.
"""
DETERMINISTIC = "deterministic"
POPUP = "popup"
AI_REPAIR = "ai_repair"
USER_OVERRIDE = "user_override"
OVERFLOW_CASCADE_ORDER: tuple[OverflowCascadeStage, ...] = (
OverflowCascadeStage.DETERMINISTIC,
OverflowCascadeStage.POPUP,
OverflowCascadeStage.AI_REPAIR,
OverflowCascadeStage.USER_OVERRIDE,
)
STEP17_AI_REPAIR_BLOCKED_REASON = (
"step17_ai_blocked_imp_34_35_prerequisites_missing"
)
def gather_step17_ai_repair_proposals(
units: Iterable[Any],
*,
route_for_label: Callable[[str | None], str | None],
) -> list[dict]:
"""Return one BLOCKED record per unit. No AI call is performed at u9.
The record schema mirrors :func:`src.phase_z2_ai_fallback.step12
.gather_step12_ai_repair_proposals` so the Step 17 artifact consumer can
reuse the same shape, with one addition: ``cascade_stage`` pins the
stage this record belongs to (always ``ai_repair`` here).
Per Stage 2 contract (IMP-33 u9): Step 17 AI repair is blocked behind
IMP-34 + IMP-35. Every unit returns with
``skip_reason=STEP17_AI_REPAIR_BLOCKED_REASON`` and ``ai_called=False``.
"""
records: list[dict] = []
for index, unit in enumerate(units):
label = getattr(unit, "label", None)
record: dict = {
"unit_index": index,
"source_section_ids": list(
getattr(unit, "source_section_ids", []) or []
),
"frame_template_id": getattr(unit, "frame_template_id", None),
"label": label,
"route_hint": route_for_label(label),
"provisional": bool(getattr(unit, "provisional", False)),
"cascade_stage": OverflowCascadeStage.AI_REPAIR.value,
"ai_called": False,
"skip_reason": STEP17_AI_REPAIR_BLOCKED_REASON,
"proposal": None,
"error": None,
}
records.append(record)
return records
+83
View File
@@ -0,0 +1,83 @@
"""IMP-33 u5 — AI fallback proposal validator (fallback path only).
Defence-in-depth layer between the u4 client output (already u2-schema-valid)
and the caller. Adds the four Stage 2 guards that u2 cannot express purely at
the schema level:
1. builder-options whitelist (BUILDER_OPTIONS_PATCH may only touch keys
already declared in ``frame_contract.payload.builder_options``).
2. dropped-slot guard (PARTIAL_OVERRIDES / SLOT_MAPPING_PROPOSAL must keep
every declared ``sub_zones[*].id`` populated — text/table/image/details
slots cannot disappear; `feedback_ai_isolation_contract`).
3. frame-swap guard (no ``frame_id`` mutation inside payload — V4 rank-1
protected; `feedback_phase_z_spacing_direction`).
4. Internal Region containment (``payload.region_id`` must match the
declared Internal Region id when present).
"""
from __future__ import annotations
from typing import Any
from src.phase_z2_ai_fallback.schema import AiFallbackProposal, ProposalKind
class AiFallbackValidationError(ValueError):
"""Raised when a proposal violates an IMP-33 u5 guard."""
_SLOT_KINDS = (ProposalKind.PARTIAL_OVERRIDES, ProposalKind.SLOT_MAPPING_PROPOSAL)
def validate_proposal(
proposal: AiFallbackProposal,
*,
frame_contract: dict[str, Any],
internal_region: dict[str, Any] | None = None,
) -> None:
"""Validate an AI fallback proposal against the active frame contract.
Raises ``AiFallbackValidationError`` on any guard violation. Returns
``None`` on success — caller is responsible for downstream application.
"""
AiFallbackProposal.model_validate(proposal.model_dump())
payload = proposal.payload
frame_id = frame_contract.get("frame_id")
if "frame_id" in payload and payload["frame_id"] != frame_id:
raise AiFallbackValidationError(
f"frame-swap guard: payload.frame_id={payload['frame_id']!r} "
f"differs from contract frame_id={frame_id!r}; V4 rank-1 is locked."
)
if proposal.proposal_kind is ProposalKind.BUILDER_OPTIONS_PATCH:
declared = (frame_contract.get("payload") or {}).get("builder_options") or {}
unknown = set(payload.keys()) - set(declared.keys())
if unknown:
raise AiFallbackValidationError(
f"builder whitelist: keys {sorted(unknown)} not in "
f"frame_contract.payload.builder_options {sorted(declared)}."
)
if proposal.proposal_kind in _SLOT_KINDS:
declared_slot_ids = [z.get("id") for z in (frame_contract.get("sub_zones") or [])]
slots = payload.get("slots")
if not isinstance(slots, dict):
raise AiFallbackValidationError(
"dropped-slot guard: PARTIAL_OVERRIDES / SLOT_MAPPING_PROPOSAL "
"payload MUST include a 'slots' mapping."
)
missing = [sid for sid in declared_slot_ids if sid not in slots]
if missing:
raise AiFallbackValidationError(
f"dropped-slot guard: declared slots {missing} are absent "
"from payload.slots (text/table/image/details must remain populated)."
)
region_id = payload.get("region_id")
if region_id is not None and internal_region is not None:
declared_region_id = internal_region.get("id")
if region_id != declared_region_id:
raise AiFallbackValidationError(
f"Internal Region containment: payload.region_id={region_id!r} "
f"differs from internal_region.id={declared_region_id!r}."
)
+61 -11
View File
@@ -344,7 +344,7 @@ def classify_visual_runtime_check(overflow: dict, debug_zones: list[dict]) -> di
Returns: Returns:
dict : dict :
visual_check_passed : Selenium 통과 여부 visual_check_passed : Selenium 통과 여부 (overflow.passed AND no classifications)
classifications : 각 overflow event 의 분류 결과 list classifications : 각 overflow event 의 분류 결과 list
summary : 텍스트 요약 (n events, categories seen) summary : 텍스트 요약 (n events, categories seen)
categories_seen : 등장한 카테고리 unique list categories_seen : 등장한 카테고리 unique list
@@ -353,6 +353,12 @@ def classify_visual_runtime_check(overflow: dict, debug_zones: list[dict]) -> di
divergence + region / slot_assignment / rejection divergence + region / slot_assignment / rejection
count) — passed 여부 무관 항상 surface count) — passed 여부 무관 항상 surface
""" """
# Deferred import — phase_z2_pipeline imports this module at module top, so
# a top-level `from phase_z2_pipeline import ...` would be circular. Pulled
# in at call time so both modules are fully loaded. Tolerances are owned by
# phase_z2_pipeline (single source of truth — see IMP-15 실행-1/2).
from phase_z2_pipeline import IMAGE_ASPECT_DELTA_TOL, TABLE_SCROLL_TOL_PX
# placement_diagnostics — debug_zones[i].placement_trace 를 per-zone diagnostic 으로 surface. # placement_diagnostics — debug_zones[i].placement_trace 를 per-zone diagnostic 으로 surface.
# passed 여부 무관 항상 빌드 (B4 vs mapper divergence 가 passed 에서도 진단 가치). # passed 여부 무관 항상 빌드 (B4 vs mapper divergence 가 passed 에서도 진단 가치).
placement_diagnostics = [ placement_diagnostics = [
@@ -364,15 +370,9 @@ def classify_visual_runtime_check(overflow: dict, debug_zones: list[dict]) -> di
for dz in (debug_zones or []) for dz in (debug_zones or [])
] ]
if overflow.get("passed", False): # IMP-15 실행-3 (issue #47): no early-return on overflow.passed=True.
return { # image_events / table_events scans below run unconditionally; the final
"visual_check_passed": True, # visual_check_passed is widened to: overflow.passed AND no classifications.
"classifications": [],
"summary": "visual check passed — no overflow to classify",
"categories_seen": [],
"unclassified_signals": [],
"placement_diagnostics": placement_diagnostics,
}
# zone position → debug_zones 매핑 (capacity_fit_status 추출용) # zone position → debug_zones 매핑 (capacity_fit_status 추출용)
capacity_status_by_position: dict[str, Optional[str]] = {} capacity_status_by_position: dict[str, Optional[str]] = {}
@@ -423,6 +423,53 @@ def classify_visual_runtime_check(overflow: dict, debug_zones: list[dict]) -> di
cls["scroll_height"] = c.get("scrollHeight") cls["scroll_height"] = c.get("scrollHeight")
classifications.append(cls) classifications.append(cls)
# IMP-15 실행-3 (issue #47): image_events scan — image_aspect_mismatch emitter.
# delta is None ⇒ skip (image not loaded; no false positive).
# |delta| > IMAGE_ASPECT_DELTA_TOL ⇒ emit classification.
for ev in (overflow.get("image_events") or []):
delta = ev.get("delta")
if delta is None:
continue
if abs(delta) > IMAGE_ASPECT_DELTA_TOL:
classifications.append({
"category": "image_aspect_mismatch",
"source": "image_event",
"zone_position": ev.get("zone_position"),
"zone_template_id": ev.get("zone_template_id"),
"src": ev.get("src"),
"natural_ratio": ev.get("natural_ratio"),
"rendered_ratio": ev.get("rendered_ratio"),
"delta": delta,
"rule_applied": (
f"|delta|={abs(delta):.4f} > IMAGE_ASPECT_DELTA_TOL="
f"{IMAGE_ASPECT_DELTA_TOL} (IMP-15 실행-3)"
),
})
# IMP-15 실행-3 (issue #47): table_events scan — tabular_overflow emitter.
# wrapper_clipped_index is not None ⇒ skip (clipped_inner already covers this
# case via zone cascade; honor dedup contract from pipeline producer).
# excess_x or excess_y > TABLE_SCROLL_TOL_PX ⇒ emit tabular_overflow.
for ev in (overflow.get("table_events") or []):
if ev.get("wrapper_clipped_index") is not None:
continue
excess_x = ev.get("excess_x") or 0
excess_y = ev.get("excess_y") or 0
if excess_x > TABLE_SCROLL_TOL_PX or excess_y > TABLE_SCROLL_TOL_PX:
classifications.append({
"category": "tabular_overflow",
"source": "table_event",
"zone_position": ev.get("zone_position"),
"zone_template_id": ev.get("zone_template_id"),
"excess_x": excess_x,
"excess_y": excess_y,
"rule_applied": (
f"table self-overflow — excess_x={excess_x} or excess_y="
f"{excess_y} > TABLE_SCROLL_TOL_PX={TABLE_SCROLL_TOL_PX} "
f"(wrapper not clipped; IMP-15 실행-3)"
),
})
# slide-level / slide-body overflow (zones 외부) 도 분류 시도 (보통 zone-level 에서 잡히지만 보조) # slide-level / slide-body overflow (zones 외부) 도 분류 시도 (보통 zone-level 에서 잡히지만 보조)
unclassified: list[dict] = [] unclassified: list[dict] = []
slide_m = overflow.get("slide") or {} slide_m = overflow.get("slide") or {}
@@ -443,8 +490,11 @@ def classify_visual_runtime_check(overflow: dict, debug_zones: list[dict]) -> di
}) })
categories = sorted({c["category"] for c in classifications}) categories = sorted({c["category"] for c in classifications})
# IMP-15 실행-3 (issue #47): widened semantic — overflow.passed alone is not
# enough; any image/table classification also flips visual_check_passed.
visual_check_passed = bool(overflow.get("passed", False)) and not classifications
return { return {
"visual_check_passed": False, "visual_check_passed": visual_check_passed,
"classifications": classifications, "classifications": classifications,
"summary": ( "summary": (
f"{len(classifications)} overflow event(s) classified, " f"{len(classifications)} overflow event(s) classified, "
+88 -3
View File
@@ -368,6 +368,15 @@ class CompositionUnit:
# 0 길이 = "no_non_reject_v4_candidate" 신호 (Step 9 application_plan input). # 0 길이 = "no_non_reject_v4_candidate" 신호 (Step 9 application_plan input).
v4_candidates: list = field(default_factory=list) v4_candidates: list = field(default_factory=list)
# IMP-30 u2 — provisional first-render flag. True when the V4Match
# backing this unit was synthesized via lookup_v4_match_with_fallback
# (allow_provisional=True) after chain_exhausted, or when u3 inserts
# a last-resort provisional fill for an uncovered section. Carried as
# data (not re-derived from label/selection_path downstream) so the
# render path / status / zone template can surface "needs adaptation"
# uniformly. Default False keeps non-provisional units byte-identical.
provisional: bool = False
# ─── Heading Tree ────────────────────────────────────────────── # ─── Heading Tree ──────────────────────────────────────────────
@@ -490,6 +499,7 @@ def collect_candidates(sections, v4_lookup_fn, v4_label_to_status: dict,
raw_content=s.raw_content, raw_content=s.raw_content,
title=s.title, title=s.title,
v4_candidates=_v4_cands(s.section_id), v4_candidates=_v4_cands(s.section_id),
provisional=getattr(match, "provisional", False),
) )
_apply_capacity_fit(c, capacity_fit_fn) _apply_capacity_fit(c, capacity_fit_fn)
candidates.append(c) candidates.append(c)
@@ -524,6 +534,7 @@ def collect_candidates(sections, v4_lookup_fn, v4_label_to_status: dict,
raw_content=merged_raw, raw_content=merged_raw,
title=pid, title=pid,
v4_candidates=_v4_cands(pid), v4_candidates=_v4_cands(pid),
provisional=getattr(parent_match, "provisional", False),
) )
_apply_capacity_fit(c_pm, capacity_fit_fn) _apply_capacity_fit(c_pm, capacity_fit_fn)
candidates.append(c_pm) candidates.append(c_pm)
@@ -624,6 +635,10 @@ def collect_candidates(sections, v4_lookup_fn, v4_label_to_status: dict,
notes=notes, notes=notes,
# rep_child 의 V4 후보 list (rep_match 와 같은 출처, frame_* 와 일관). # rep_child 의 V4 후보 list (rep_match 와 같은 출처, frame_* 와 일관).
v4_candidates=_v4_cands(rep_child.section_id), v4_candidates=_v4_cands(rep_child.section_id),
# IMP-30 u2 — rep_match drives frame selection so its provisional
# flag flows here. If a non-rep child match is provisional but the
# rep is not, this unit is not provisional (the rep frame is real).
provisional=getattr(rep_match, "provisional", False),
) )
_apply_capacity_fit(c_inf, capacity_fit_fn) _apply_capacity_fit(c_inf, capacity_fit_fn)
candidates.append(c_inf) candidates.append(c_inf)
@@ -670,7 +685,13 @@ def score_candidate(c: CompositionUnit) -> CompositionUnit:
# ─── Selection ───────────────────────────────────────────────── # ─── Selection ─────────────────────────────────────────────────
def select_composition_units(candidates, allowed_statuses: set[str]) -> list[CompositionUnit]: def select_composition_units(
candidates,
allowed_statuses: set[str],
*,
all_section_ids: Optional[list[str]] = None,
allow_provisional_fill: bool = False,
) -> list[CompositionUnit]:
"""Greedy non-overlapping selection by score, with coverage tiebreak. """Greedy non-overlapping selection by score, with coverage tiebreak.
1. 모든 candidate 점수 매김 1. 모든 candidate 점수 매김
@@ -685,6 +706,27 @@ def select_composition_units(candidates, allowed_statuses: set[str]) -> list[Com
auto_selectable=False candidate 는 자동 선택 X. debug 의 candidates_summary 에는 남음. auto_selectable=False candidate 는 자동 선택 X. debug 의 candidates_summary 에는 남음.
UI/editor layer 에서 사용자가 별도 처리 가능 (현 v0 범위 X). UI/editor layer 에서 사용자가 별도 처리 가능 (현 v0 범위 X).
IMP-30 u3 — last-resort provisional fill (opt-in via allow_provisional_fill):
After the normal greedy pass, sections in ``all_section_ids`` that are
still uncovered are filled with the highest-score *provisional*
candidate (``c.provisional == True``) that includes at least one
uncovered section and does not collide with already-covered ones. A
provisional candidate's backing V4Match was synthesized via
``lookup_v4_match_with_fallback(allow_provisional=True)`` (IMP-30 u1)
after chain_exhausted; its ``phase_z_status`` is therefore typically
*outside* ``allowed_statuses`` (extract_matched_zone / fallback_candidate),
which is why it gets filtered out of the normal greedy pass. The fill
preserves first-render invariant for sections whose rank-1~3 are all
restructure/reject. Default ``allow_provisional_fill=False`` keeps
pre-u3 behavior byte-identical (IMP-05 regression guard).
Args:
candidates: full candidate pool from collect_candidates().
allowed_statuses: phase_z_status set considered auto-renderable.
all_section_ids: ordered section id list (only consulted when
allow_provisional_fill=True; required for coverage check).
allow_provisional_fill: opt-in for last-resort provisional fill.
""" """
scored = [score_candidate(c) for c in candidates] scored = [score_candidate(c) for c in candidates]
viable = [ viable = [
@@ -701,6 +743,28 @@ def select_composition_units(candidates, allowed_statuses: set[str]) -> list[Com
selected.append(c) selected.append(c)
covered.update(c.source_section_ids) covered.update(c.source_section_ids)
# IMP-30 u3 — last-resort provisional fill (opt-in, default off).
# Honors first-render invariant by surfacing chain_exhausted sections as
# provisional zones instead of dropping them. Skip reasons on
# non-provisional filtered candidates are preserved (not mutated here).
if allow_provisional_fill and all_section_ids:
uncovered = {sid for sid in all_section_ids if sid not in covered}
if uncovered:
provisional_pool = [
c for c in scored
if c.provisional
and any(sid in uncovered for sid in c.source_section_ids)
]
provisional_pool.sort(
key=lambda c: (c.score, len(c.source_section_ids)),
reverse=True,
)
for c in provisional_pool:
if any(sid in covered for sid in c.source_section_ids):
continue
selected.append(c)
covered.update(c.source_section_ids)
return selected return selected
@@ -740,7 +804,9 @@ def select_layout_preset(units: list[CompositionUnit]) -> Optional[str]:
def plan_composition(sections, v4_lookup_fn, v4_label_to_status: dict, def plan_composition(sections, v4_lookup_fn, v4_label_to_status: dict,
allowed_statuses: set[str], allowed_statuses: set[str],
capacity_fit_fn=None, capacity_fit_fn=None,
v4_candidates_lookup_fn=None) -> tuple[list[CompositionUnit], Optional[str], dict]: v4_candidates_lookup_fn=None,
*,
allow_provisional_fill: bool = False) -> tuple[list[CompositionUnit], Optional[str], dict]:
"""Composition planner v0.2 entry. """Composition planner v0.2 entry.
v0.2 변경 : v0.2 변경 :
@@ -753,6 +819,14 @@ def plan_composition(sections, v4_lookup_fn, v4_label_to_status: dict,
logic 변화 X — 단일 frame_template_id / frame_id / label / confidence 는 그대로. logic 변화 X — 단일 frame_template_id / frame_id / label / confidence 는 그대로.
runtime 결과 무변. Step 9 application_plan input 위한 schema 확장. runtime 결과 무변. Step 9 application_plan input 위한 schema 확장.
IMP-30 u3 — last-resort provisional fill (opt-in, default off):
``allow_provisional_fill`` is plumbed to select_composition_units().
When True, uncovered sections receive a provisional fill from candidates
whose backing V4Match was synthesized via ``allow_provisional=True``
(IMP-30 u1). ``_candidate_state`` returns ``selected_provisional`` for
those filled units so the debug summary distinguishes greedy selections
from provisional fills. Default False keeps IMP-05 behavior identical.
v0.1 / v0.1.1 동작 (유지) : v0.1 / v0.1.1 동작 (유지) :
- parent_merged_inferred candidate 생성 (parent V4 없어도) - parent_merged_inferred candidate 생성 (parent V4 없어도)
- review 개념 X. auto_selectable + filter_reasons 만으로 자동 결정 - review 개념 X. auto_selectable + filter_reasons 만으로 자동 결정
@@ -771,11 +845,22 @@ def plan_composition(sections, v4_lookup_fn, v4_label_to_status: dict,
) )
scored_all = [score_candidate(c) for c in candidates] scored_all = [score_candidate(c) for c in candidates]
units = select_composition_units(candidates, allowed_statuses) units = select_composition_units(
candidates,
allowed_statuses,
all_section_ids=[s.section_id for s in sections] if allow_provisional_fill else None,
allow_provisional_fill=allow_provisional_fill,
)
preset = select_layout_preset(units) preset = select_layout_preset(units)
def _candidate_state(c: CompositionUnit) -> str: def _candidate_state(c: CompositionUnit) -> str:
if c in units: if c in units:
# IMP-30 u3 — provisional-fill units surface as a distinct state so
# downstream debug consumers can tell greedy selection apart from
# last-resort fill. unit.provisional flows from u1 (V4Match
# synthesis) → u2 (CompositionUnit propagation).
if c.provisional:
return "selected_provisional"
return "selected" return "selected"
if c.phase_z_status not in allowed_statuses: if c.phase_z_status not in allowed_statuses:
return "filtered_status" # V4 label → status not auto-renderable return "filtered_status" # V4 label → status not auto-renderable
+93 -13
View File
@@ -7,20 +7,29 @@ A3 (zone_ratio_retry) 의 결과 (retry_trace) 를 받아 :
본 module 은 ***분류 + 매핑까지만***. layout_adjust / frame_reselect / details_popup 본 module 은 ***분류 + 매핑까지만***. layout_adjust / frame_reselect / details_popup
실행 X. retry_trace 에 `failure_classification` + `next_action_proposal` 두 필드 추가. 실행 X. retry_trace 에 `failure_classification` + `next_action_proposal` 두 필드 추가.
**잠근 매핑** (사용자 잠금 — 2026-04-29) : **잠근 매핑** (사용자 잠금 — 2026-05-17, IMP-12 u3 cascade) :
| failure_type | next_proposed_action | | failure_type | next_proposed_action |
|---|---| |---|---|
| donor_slack_insufficient | layout_adjust | | donor_slack_insufficient | cross_zone_redistribute |
| no_donor_candidates | layout_adjust | | no_donor_candidates | cross_zone_redistribute |
| cross_zone_redistribute_insufficient | glue_compression |
| glue_absorption_insufficient | font_step_compression |
| font_step_insufficient | layout_adjust |
| rerender_still_fails | frame_reselect | | rerender_still_fails | frame_reselect |
| not_attempted | none | | not_attempted | none |
**escalation 단계 hierarchy** (이번 기본 매핑이 따르는 원칙) : **escalation 단계 hierarchy** (Step 17 deterministic salvage cascade → layout/frame) :
``` ```
layout_adjust (가장 가벼움 — zone 배치만 변경) cross_zone_redistribute (fit_verifier.redistribute — role-height adjustment)
↓ 그래도 안 되면 ↓ 그래도 안 되면
frame_reselect (중간 — frame 자체 변경) glue_compression (SPACING_GLUE envelope, frame-scoped)
↓ 그래도 안 되면
font_step_compression (FONT_SIZE_STEPS, zone-scoped)
↓ 그래도 안 되면
layout_adjust (zone topology 변경)
↓ 그래도 안 되면
frame_reselect (V4 top-k 의 다른 frame)
↓ 그래도 안 되면 ↓ 그래도 안 되면
details_popup_escalation (가장 invasive — content popup, 마지막 resort) details_popup_escalation (가장 invasive — content popup, 마지막 resort)
``` ```
@@ -53,26 +62,65 @@ FAILURE_TYPE_DESCRIPTIONS: dict[str, str] = {
"redistribution 실행 + rerender 까지 했는데도 visual_check 실패. " "redistribution 실행 + rerender 까지 했는데도 visual_check 실패. "
"현재 frame/zone 조합이 content 와 맞지 않음" "현재 frame/zone 조합이 content 와 맞지 않음"
), ),
"cross_zone_redistribute_insufficient": (
"cross_zone_redistribute salvage step failed — fit_verifier.redistribute "
"could not find a feasible role-height adjustment within the frame envelope"
),
"glue_absorption_insufficient": (
"glue_compression salvage step failed — frame envelope cannot absorb "
"remaining overflow via SPACING_GLUE overrides (no global spacing shrink)"
),
"font_step_insufficient": (
"font_step_compression salvage step failed — FONT_SIZE_STEPS exhausted "
"down to the floor without resolving overflow (or text_metrics missing)"
),
}
# ─── §A4-1b salvage_steps[-1].action → failure_type table ──────────
# u2 (IMP-12): _attempt_salvage_chain (u8) writes per-step records into
# retry_trace["salvage_steps"] with {action, passed, failure_reason}. classifier
# inspects salvage_steps[-1] so u3 can route 3 new types onto the cascade.
SALVAGE_FAILURE_TYPE_BY_ACTION: dict[str, str] = {
"cross_zone_redistribute": "cross_zone_redistribute_insufficient",
"glue_compression": "glue_absorption_insufficient",
"font_step_compression": "font_step_insufficient",
} }
# ─── §A4-2 next_action mapping (사용자 잠금) ────────────────────── # ─── §A4-2 next_action mapping (사용자 잠금) ──────────────────────
NEXT_ACTION_BY_FAILURE: dict[str, str] = { NEXT_ACTION_BY_FAILURE: dict[str, str] = {
"donor_slack_insufficient": "layout_adjust", "donor_slack_insufficient": "cross_zone_redistribute",
"no_donor_candidates": "layout_adjust", "no_donor_candidates": "cross_zone_redistribute",
"cross_zone_redistribute_insufficient": "glue_compression",
"glue_absorption_insufficient": "font_step_compression",
"font_step_insufficient": "layout_adjust",
"rerender_still_fails": "frame_reselect", "rerender_still_fails": "frame_reselect",
"not_attempted": "none", "not_attempted": "none",
} }
NEXT_ACTION_RATIONALE: dict[str, str] = { NEXT_ACTION_RATIONALE: dict[str, str] = {
"donor_slack_insufficient": ( "donor_slack_insufficient": (
"현재 layout 안 redistribution 끝남 → 다른 layout topology 검토 " "primary donor slack 한도 도달 → cross_zone_redistribute 로 sibling zone "
"(layout_adjust). frame 자체는 아직 의심 대상 X" "전체 role-height 재분배 (fit_verifier.redistribute). layout 변경은 cascade 끝"
), ),
"no_donor_candidates": ( "no_donor_candidates": (
"donor 자체 없거나 모두 막힘 → layout topology 부터 재구성하여 " "단일 donor 후보 없음 → cross_zone_redistribute 로 role-height 전체 "
"sibling/space 다시 만들어 보는 게 우선 (layout_adjust). frame 변경은 그 다음" "재할당 시도 (fit_verifier.redistribute). layout 변경은 cascade 끝"
),
"cross_zone_redistribute_insufficient": (
"role-height 재분배도 frame envelope 못 맞춤 → glue_compression "
"(SPACING_GLUE frame-scoped) 으로 frame 내부 여백 축소"
),
"glue_absorption_insufficient": (
"frame 여백 envelope 도 부족 → font_step_compression "
"(FONT_SIZE_STEPS zone-scoped) 으로 폰트 한 단계 축소"
),
"font_step_insufficient": (
"deterministic salvage cascade 모두 소진 → layout_adjust 로 zone "
"topology 부터 재구성. frame_reselect 는 그 다음 단계"
), ),
"rerender_still_fails": ( "rerender_still_fails": (
"redistribution + rerender 까지 했는데도 visual fail → 현재 " "redistribution + rerender 까지 했는데도 visual fail → 현재 "
@@ -85,7 +133,16 @@ NEXT_ACTION_RATIONALE: dict[str, str] = {
} }
# 본 매핑이 가리키는 next action 들의 *현재 코드* 구현 상태 # 본 매핑이 가리키는 next action 들의 *현재 코드* 구현 상태
# IMP-12 u7 (2026-05-18): 3 cascade salvage actions registered as IMPLEMENTED.
# plan/apply pairs live in phase_z2_retry (u4/u5/u6); pipeline orchestrator wiring
# (_attempt_salvage_chain) lands in u8/u9. router-level mapping is decoupled from
# orchestrator wiring on purpose so route_retry_failure → impl_status reflects
# the deterministic surface availability, not whether a given pipeline run has
# already invoked it.
NEXT_ACTION_IMPLEMENTATION_STATUS: dict[str, str] = { NEXT_ACTION_IMPLEMENTATION_STATUS: dict[str, str] = {
"cross_zone_redistribute": "IMPLEMENTED", # u4 plan_cross_zone_redistribute + apply_cross_zone_redistribute_css
"glue_compression": "IMPLEMENTED", # u5 plan_glue_compression + apply_glue_compression_css
"font_step_compression": "IMPLEMENTED", # u6 plan_font_step_compression + apply_font_step_compression_css
"layout_adjust": "MISSING", "layout_adjust": "MISSING",
"frame_reselect": "MISSING", "frame_reselect": "MISSING",
"none": "n/a", "none": "n/a",
@@ -106,6 +163,29 @@ def classify_retry_failure(retry_trace: dict) -> Optional[dict]:
if retry_trace.get("retry_passed"): if retry_trace.get("retry_passed"):
return None return None
# case 0.5 : salvage chain 자체 성공 — failure 없음 (u8/u9 wiring)
if retry_trace.get("salvage_passed"):
return None
# case 0.7 : salvage chain attempted and ended in a salvage-level failure.
# zone_ratio_retry 가 먼저 실패한 뒤 _attempt_salvage_chain 이 가동된 path —
# 마지막 salvage step 의 action 으로 failure_type 을 분류한다. u3 가 routing.
salvage_steps = retry_trace.get("salvage_steps") or []
if salvage_steps:
last = salvage_steps[-1] or {}
if not last.get("passed"):
action = (last.get("action") or "").lower()
ftype = SALVAGE_FAILURE_TYPE_BY_ACTION.get(action)
if ftype is not None:
reason = last.get("failure_reason") or ""
return {
"failure_type": ftype,
"classification_rule": (
f"salvage_steps[-1].action == {action!r} "
f"AND passed=False. raw failure_reason: {reason!r}"
),
}
# case 1 : retry 시도 자체 안 됨 (router_active=False 또는 다른 action) # case 1 : retry 시도 자체 안 됨 (router_active=False 또는 다른 action)
if not retry_trace.get("retry_attempted"): if not retry_trace.get("retry_attempted"):
return { return {
@@ -204,7 +284,7 @@ def route_retry_failure(failure_type: str) -> dict:
"next_action_implementation_status": NEXT_ACTION_IMPLEMENTATION_STATUS.get( "next_action_implementation_status": NEXT_ACTION_IMPLEMENTATION_STATUS.get(
next_action, "unknown" next_action, "unknown"
), ),
"mapping_source": "A4 NEXT_ACTION_BY_FAILURE (사용자 잠금 2026-04-29)", "mapping_source": "A4 NEXT_ACTION_BY_FAILURE (사용자 잠금 2026-05-17, IMP-12 u3 cascade)",
} }
+39
View File
@@ -32,6 +32,7 @@ import yaml
PROJECT_ROOT = Path(__file__).parent.parent PROJECT_ROOT = Path(__file__).parent.parent
CATALOG_PATH = PROJECT_ROOT / "templates" / "phase_z2" / "catalog" / "frame_contracts.yaml" CATALOG_PATH = PROJECT_ROOT / "templates" / "phase_z2" / "catalog" / "frame_contracts.yaml"
V4_FALLBACK_POLICY_PATH = PROJECT_ROOT / "templates" / "phase_z2" / "catalog" / "v4_fallback_policy.yaml"
class FitError(Exception): class FitError(Exception):
@@ -57,6 +58,44 @@ def get_contract(template_id: str) -> dict | None:
return load_frame_contracts().get(template_id) return load_frame_contracts().get(template_id)
# ─── V4 fallback policy loading (IMP-38) ──────────────────────────
_V4_FALLBACK_POLICY_CACHE: dict | None = None
_V4_FALLBACK_POLICY_DEFAULT: dict = {
"policy_type": "static",
"usable_threshold": 1,
"default_max_rank": 3,
"extended_max_rank": 3, # graceful: yaml 없을 시 확장 X (byte-identical to pre-IMP-38)
}
def load_v4_fallback_policy() -> dict:
"""IMP-38 V4 fallback policy loader (separate yaml, catalog 오염 방지).
Returns dict with keys: policy_type, usable_threshold, default_max_rank, extended_max_rank.
Codex #1 권장: frame_contracts.yaml top-level 오염 회피 (별 yaml).
Codex #3 LOCK: load_frame_contracts() shape 변경 X (이 함수는 별 cache).
Graceful fallback:
yaml 파일 없을 시 → _V4_FALLBACK_POLICY_DEFAULT (default_max_rank=3, extended=3)
→ backward compat byte-identical to pre-IMP-38 behavior.
Returns:
dict — 정책 키 (정책 yaml 의 superset 가능, 알 수 없는 키는 무시 권장).
"""
global _V4_FALLBACK_POLICY_CACHE
if _V4_FALLBACK_POLICY_CACHE is None:
if V4_FALLBACK_POLICY_PATH.exists():
loaded = yaml.safe_load(V4_FALLBACK_POLICY_PATH.read_text(encoding="utf-8")) or {}
# merge with default (yaml 키 부분 누락 시 default 로 fall through)
_V4_FALLBACK_POLICY_CACHE = {**_V4_FALLBACK_POLICY_DEFAULT, **loaded}
else:
_V4_FALLBACK_POLICY_CACHE = dict(_V4_FALLBACK_POLICY_DEFAULT)
return _V4_FALLBACK_POLICY_CACHE
# ─── Source-shape splitters ────────────────────────────────────── # ─── Source-shape splitters ──────────────────────────────────────
def _split_top_bullets(content: str) -> list[tuple[str, list[str]]]: def _split_top_bullets(content: str) -> list[tuple[str, list[str]]]:
+1573 -143
View File
File diff suppressed because it is too large Load Diff
+229 -14
View File
@@ -120,11 +120,30 @@ def plan_zone_ratio_retry(
continue continue
# rule 4-(d) 현재 height > min_height # rule 4-(d) 현재 height > min_height
# IMP-34 u1: donor capacity bounded by measured empty space
# (clientHeight - scrollHeight from Step 14) when both fields are present,
# falling back to static contract slack when absent. Prevents the donor
# from being over-allocated when it is already full but not overflowing.
height = zones_before.get(pos) height = zones_before.get(pos)
min_h = zone_min_by_pos.get(pos) min_h = zone_min_by_pos.get(pos)
if height is None or min_h is None: if height is None or min_h is None:
continue continue
slack = height - min_h static_slack = height - min_h
client_h = zinfo.get("clientHeight")
scroll_h = zinfo.get("scrollHeight")
if (
isinstance(client_h, (int, float))
and isinstance(scroll_h, (int, float))
and not isinstance(client_h, bool)
and not isinstance(scroll_h, bool)
):
measured_empty_px = max(0, int(client_h) - int(scroll_h))
slack = min(static_slack, measured_empty_px)
slack_bound_source = "measured_bound"
else:
measured_empty_px = None
slack = static_slack
slack_bound_source = "static_fallback"
if slack <= 0: if slack <= 0:
continue continue
@@ -134,6 +153,8 @@ def plan_zone_ratio_retry(
"min_height": min_h, "min_height": min_h,
"slack": slack, "slack": slack,
"capacity_fit_status": cap_status, "capacity_fit_status": cap_status,
"measured_empty_px": measured_empty_px,
"slack_bound_source": slack_bound_source,
}) })
# rule 4-(f) 여러 후보면 slack 가장 큰 것부터 # rule 4-(f) 여러 후보면 slack 가장 큰 것부터
@@ -162,35 +183,55 @@ def plan_zone_ratio_retry(
), ),
} }
# A3 minimal : single primary donor (multi-donor 는 future) # IMP-12 u1 : multi-donor greedy aggregation (slack-desc 순서대로 합산)
primary_donor = donor_candidates[0] aggregate_slack_available = sum(d["slack"] for d in donor_candidates)
if primary_donor["slack"] < target_added_px: if aggregate_slack_available < target_added_px:
return { return {
**base_plan, **base_plan,
"feasible": False, "feasible": False,
"donor_zone_position": primary_donor["position"], "donor_zone_position": donor_candidates[0]["position"],
"donor_max_slack": primary_donor["slack"], "donor_max_slack": donor_candidates[0]["slack"],
"donor_reduced_px": 0, "donor_reduced_px": 0,
"donors_used": [],
"aggregate_slack_used": 0,
"aggregate_slack_available": aggregate_slack_available,
"zones_after": dict(zones_before), "zones_after": dict(zones_before),
"failure_reason": ( "failure_reason": (
f"primary donor '{primary_donor['position']}' slack {primary_donor['slack']}px " f"primary donor '{donor_candidates[0]['position']}' slack "
f"< target_added_px {target_added_px}px (excess_y {target_excess_y} + " f"{donor_candidates[0]['slack']}px (aggregate "
f"safety_margin {safety_margin_px}). multi-donor aggregation is future axis." f"{aggregate_slack_available}px across {len(donor_candidates)} "
f"candidate(s)) < target_added_px {target_added_px}px "
f"(excess_y {target_excess_y} + safety_margin {safety_margin_px})."
), ),
} }
# feasible # feasible — greedy aggregation: 각 donor 에서 필요한 만큼만 차감
zones_after = dict(zones_before) zones_after = dict(zones_before)
zones_after[target_zone_position] = zones_before[target_zone_position] + target_added_px zones_after[target_zone_position] = zones_before[target_zone_position] + target_added_px
zones_after[primary_donor["position"]] = ( donors_used: list[dict] = []
zones_before[primary_donor["position"]] - target_added_px remaining = target_added_px
) for donor in donor_candidates:
if remaining <= 0:
break
take = min(donor["slack"], remaining)
zones_after[donor["position"]] = zones_before[donor["position"]] - take
donors_used.append({
"position": donor["position"],
"reduced_px": take,
"slack_before": donor["slack"],
"slack_after": donor["slack"] - take,
})
remaining -= take
primary_donor = donors_used[0]
return { return {
**base_plan, **base_plan,
"feasible": True, "feasible": True,
"donor_zone_position": primary_donor["position"], "donor_zone_position": primary_donor["position"],
"donor_reduced_px": target_added_px, "donor_reduced_px": primary_donor["reduced_px"],
"donors_used": donors_used,
"aggregate_slack_used": target_added_px,
"aggregate_slack_available": aggregate_slack_available,
"zones_after": zones_after, "zones_after": zones_after,
} }
@@ -213,3 +254,177 @@ def apply_retry_to_layout_css(layout_css: dict, plan: dict, zones_data: list[dic
new_layout_css["raw_zone_layout"] = (layout_css.get("raw_zone_layout") or {}).copy() new_layout_css["raw_zone_layout"] = (layout_css.get("raw_zone_layout") or {}).copy()
new_layout_css["raw_zone_layout"]["retry_applied"] = True new_layout_css["raw_zone_layout"]["retry_applied"] = True
return new_layout_css return new_layout_css
# ──────────────────────────────────────
# IMP-12 u4 : cross_zone_redistribute (Step 17 salvage cascade — stage 1)
# Wraps src.fit_verifier.redistribute in the Step-17 plan signature so the
# failure-router cascade (donor_slack_insufficient → cross_zone_redistribute)
# can drive it deterministically. Plan-only — no rerender / no final.html
# mutation. Side-effect-free (operates on deepcopy of fit_analysis).
# ──────────────────────────────────────
def plan_cross_zone_redistribute(
*,
fit_analysis,
containers: dict,
min_margin_px: float | None = None,
) -> dict:
"""Cross-zone (intra-zone role-to-role) redistribute plan.
Plan-only — no rerender / no final.html mutation. Side-effect-free
(operates on deepcopy of fit_analysis).
"""
from copy import deepcopy
from src.fit_verifier import redistribute as _fv_redistribute
role_heights_before = {
role: float(rf.allocated_px) for role, rf in (fit_analysis.roles or {}).items()
}
base_plan = {
"action": "cross_zone_redistribute",
"role_heights_before": role_heights_before,
}
if not role_heights_before:
return {**base_plan, "feasible": False, "role_heights_after": {},
"can_redistribute": False,
"failure_reason": "no roles in fit_analysis — cannot redistribute."}
result = _fv_redistribute(deepcopy(fit_analysis), containers, min_margin_px=min_margin_px)
redistribution = dict(result.redistribution or {})
can_redistribute = bool(result.can_redistribute)
if not can_redistribute or not redistribution:
return {
**base_plan,
"feasible": False,
"role_heights_after": redistribution or dict(role_heights_before),
"can_redistribute": can_redistribute,
"failure_reason": (
"fit_verifier.redistribute can_redistribute=False — single-role zone(s) "
"or surplus insufficient to cover deficit within envelope."
),
}
return {**base_plan, "feasible": True, "role_heights_after": redistribution,
"can_redistribute": True}
def apply_cross_zone_redistribute_css(plan: dict) -> str:
"""Emit scoped role-height CSS overrides — [data-role="<role>"] only.
Honors feedback_phase_z_spacing_direction: no :root / body / .slide / .zone selectors.
"""
if not plan.get("feasible"):
return ""
role_heights_after = plan.get("role_heights_after") or {}
role_heights_before = plan.get("role_heights_before") or {}
rules: list[str] = []
for role, new_height in role_heights_after.items():
before = role_heights_before.get(role)
if before is None or abs(float(before) - float(new_height)) < 0.5:
continue
new_h_int = int(round(float(new_height)))
rules.append(
f'[data-role="{role}"] {{ height: {new_h_int}px; min-height: {new_h_int}px; }}'
)
return "\n".join(rules)
# IMP-12 u5 : glue_compression — Step 17 salvage cascade (stage 2).
# Wraps space_allocator.compute_glue_css_overrides in the Step-17 plan signature.
# Frame-scoped: overrides emitted only under [data-zone-position="<pos>"]
# (feedback_phase_z_spacing_direction — no :root/body/.slide/.zone mutation).
def plan_glue_compression(
*, excess_px: float, block_count: int, zone_position: str,
) -> dict:
"""Glue compression plan (frame-scoped). feasible only when envelope absorbs excess."""
from src.space_allocator import (
calculate_glue_absorption, compute_glue_css_overrides,
)
base = {"action": "glue_compression", "zone_position": zone_position,
"excess_px": float(excess_px), "block_count": int(block_count)}
if excess_px <= 0:
return {**base, "feasible": False, "overrides": {}, "absorption_max_px": 0.0,
"failure_reason": "excess_px <= 0 — no compression needed."}
absorption_max = float(calculate_glue_absorption(block_count))
overrides = compute_glue_css_overrides(excess_px, block_count) or {}
if excess_px > absorption_max:
return {**base, "feasible": False, "overrides": overrides,
"absorption_max_px": absorption_max,
"failure_reason": (
f"glue envelope insufficient — excess_px {excess_px:.1f} > "
f"max absorption {absorption_max:.1f}px "
f"(block_count={block_count}, SPACING_GLUE shrink budget)."
)}
return {**base, "feasible": True, "overrides": overrides,
"absorption_max_px": absorption_max}
def apply_glue_compression_css(plan: dict) -> str:
"""Emit zone-scoped glue CSS — wrapped in [data-zone-position="<pos>"] only."""
if not plan.get("feasible"):
return ""
zone_position = plan.get("zone_position")
overrides = plan.get("overrides") or {}
if not zone_position or not overrides:
return ""
var_lines = "\n".join(f" {k}: {v};" for k, v in overrides.items())
return f'[data-zone-position="{zone_position}"] {{\n{var_lines}\n}}'
# IMP-12 u6 : font_step_compression — Step 17 salvage cascade (stage 3).
# Wraps space_allocator.find_fitting_font_size in the Step-17 plan signature.
# Zone-scoped: only [data-zone-position="<pos>"] (no :root/body/.slide/.zone).
def plan_font_step_compression(
*, current_font_px: float, excess_after_glue_px: float,
available_lines: int, chars_per_line: int, zone_position: str,
) -> dict:
"""Font-step compression plan (zone-scoped). feasible only when FONT_SIZE_STEPS
contains a size whose line-height savings cover excess_after_glue_px. Missing
text_metrics yields feasible=False (cascade routes onward to layout_adjust)."""
from src.space_allocator import FONT_SIZE_STEPS, find_fitting_font_size
floor = float(FONT_SIZE_STEPS[-1])
base = {"action": "font_step_compression", "zone_position": zone_position,
"current_font_px": float(current_font_px),
"excess_after_glue_px": float(excess_after_glue_px),
"available_lines": int(available_lines or 0),
"chars_per_line": int(chars_per_line or 0),
"font_floor_px": floor}
if excess_after_glue_px <= 0:
return {**base, "feasible": False, "target_font_px": None,
"failure_reason": "excess_after_glue_px <= 0 — no font compression needed."}
if not available_lines or available_lines <= 0 or not chars_per_line or chars_per_line <= 0:
return {**base, "feasible": False, "target_font_px": None,
"failure_reason": "text_metrics missing — available_lines/chars_per_line required."}
if current_font_px <= floor:
return {**base, "feasible": False, "target_font_px": None,
"failure_reason": (
f"current_font_px {current_font_px:.1f} already at FONT_SIZE_STEPS floor {floor:.1f}px.")}
target = find_fitting_font_size(
current_font_px=float(current_font_px),
excess_after_glue_px=float(excess_after_glue_px),
available_lines=int(available_lines), chars_per_line=int(chars_per_line))
if target is None:
return {**base, "feasible": False, "target_font_px": None,
"failure_reason": (
f"font_step floor — {floor:.1f}px cannot absorb "
f"excess_after_glue_px={excess_after_glue_px:.1f}px "
f"(available_lines={available_lines}, FONT_SIZE_STEPS exhausted).")}
return {**base, "feasible": True, "target_font_px": float(target)}
def apply_font_step_compression_css(plan: dict) -> str:
"""Emit zone-scoped font-size CSS — [data-zone-position="<pos>"] only."""
if not plan.get("feasible"):
return ""
zone_position = plan.get("zone_position")
target_font_px = plan.get("target_font_px")
if not zone_position or target_font_px is None:
return ""
return (f'[data-zone-position="{zone_position}"] {{\n'
f" font-size: {float(target_font_px):.1f}px;\n}}")
+7 -1
View File
@@ -56,7 +56,7 @@ ACTION_RATIONALE: dict[str, str] = {
"위 매핑 모두 미적용 — 마지막 fallback (현재 코드는 sys.exit 으로 abort)", "위 매핑 모두 미적용 — 마지막 fallback (현재 코드는 sys.exit 으로 abort)",
} }
# 각 action 의 *현재 코드* 구현 상태 (2026-04-29 기준) # 각 action 의 *현재 코드* 구현 상태 (2026-04-29 기준; IMP-12 u7 cascade 2026-05-18)
# A2 단계에서 이 매핑이 *어디까지 자동 처리되고 어디서 막히는지* trace 확보용 # A2 단계에서 이 매핑이 *어디까지 자동 처리되고 어디서 막히는지* trace 확보용
ACTION_IMPLEMENTATION_STATUS: dict[str, str] = { ACTION_IMPLEMENTATION_STATUS: dict[str, str] = {
"zone_ratio_retry": "IMPLEMENTED", # A3 (2026-04-29) phase_z2_retry.plan_zone_ratio_retry + pipeline orchestration "zone_ratio_retry": "IMPLEMENTED", # A3 (2026-04-29) phase_z2_retry.plan_zone_ratio_retry + pipeline orchestration
@@ -65,6 +65,12 @@ ACTION_IMPLEMENTATION_STATUS: dict[str, str] = {
"frame_reselect": "PARTIAL", # IMP-05 pre-render rank-2/3 fallback implemented; post-render rerender trace-only "frame_reselect": "PARTIAL", # IMP-05 pre-render rank-2/3 fallback implemented; post-render rerender trace-only
"adapter_needed": "PARTIAL", # composition v0.1.1 의 mapper FitError catch "adapter_needed": "PARTIAL", # composition v0.1.1 의 mapper FitError catch
"abort": "IMPLEMENTED", # sys.exit(1) — pipeline 의 현재 default "abort": "IMPLEMENTED", # sys.exit(1) — pipeline 의 현재 default
# IMP-12 u7 (2026-05-18): cascade-only salvage actions (no ACTION_BY_CATEGORY row;
# surfaced via NEXT_ACTION_BY_FAILURE in phase_z2_failure_router). plan/apply pairs
# implemented in phase_z2_retry; pipeline orchestrator wiring lands in u8/u9.
"cross_zone_redistribute": "IMPLEMENTED", # u4 phase_z2_retry.plan_cross_zone_redistribute + apply_cross_zone_redistribute_css
"glue_compression": "IMPLEMENTED", # u5 phase_z2_retry.plan_glue_compression + apply_glue_compression_css
"font_step_compression": "IMPLEMENTED", # u6 phase_z2_retry.plan_font_step_compression + apply_font_step_compression_css
} }
+335
View File
@@ -0,0 +1,335 @@
"""Phase Z2 deterministic verification utilities (IMP-16-U1 port).
Ports the H3 deterministic subset of src/content_verifier.py into a
Phase Z-owned module so the Phase Z pipeline never imports the Phase Q
reference-only module (which co-hosts H4/H5 Kei/AI assets).
Scope: deterministic, pure, no I/O, no LLM call, no httpx/SSE.
Wiring into Step 1/2/14/21/22 is gated behind IMP-07 (see
docs/architecture/IMP-16-U2-WIRING-DESIGN.md when u11 lands).
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from difflib import SequenceMatcher
from html.parser import HTMLParser
@dataclass
class VerificationResult:
"""Single-axis deterministic verification outcome.
Mirrors the Phase Q VerificationResult shape so callers ported from
that surface keep their field access; the value semantics are
Phase Z-owned (no Phase Q area defaults baked in).
"""
passed: bool
area_name: str
checks: dict[str, bool] = field(default_factory=dict)
score: float = 0.0
errors: list[str] = field(default_factory=list)
warnings: list[str] = field(default_factory=list)
class _TextExtractor(HTMLParser):
"""Extract visible text only. Skips <style> and <script> bodies.
Pure stdlib (html.parser). Whitespace-only data chunks are dropped;
surviving chunks are stripped before appending to preserve token
boundaries for downstream normalization / keyword logic.
"""
def __init__(self) -> None:
super().__init__()
self.texts: list[str] = []
self._skip = False
def handle_starttag(self, tag, attrs):
if tag in ("style", "script"):
self._skip = True
def handle_endtag(self, tag):
if tag in ("style", "script"):
self._skip = False
def handle_data(self, data):
if not self._skip:
stripped = data.strip()
if stripped:
self.texts.append(stripped)
def extract_text_from_html(html: str) -> list[str]:
"""Return ordered list of visible text fragments from an HTML string.
Deterministic, pure: no I/O, no LLM, no network. Used by Phase Z
verification to compare reverse-path HTML against MDX text without
importing the Phase Q reference-only module.
"""
parser = _TextExtractor()
parser.feed(html)
return parser.texts
_PARTICLES: list[str] = sorted(
["에서", "으로", "부터", "까지", "에게", "한테",
"은", "는", "이", "가", "을", "를", "에", "의",
"로", "와", "과", "도", "만", "께"],
key=len, reverse=True,
)
_ENDING_NORMALIZE: dict[str, str] = {
"있음": "있다", "됨": "된다", "함": "한다", "임": "이다",
"없음": "없다", "았음": "았다", "었음": "었다",
}
def normalize_for_comparison(text: str) -> str:
"""Normalize text for deterministic comparison (Phase Z H3 port).
Steps (order matters): collapse whitespace, strip bullet markers,
decode the small HTML-entity set used by the reverse path, then
fold a single trailing 개조식 ending to its 서술형 form.
"""
text = re.sub(r"\s+", " ", text).strip()
text = re.sub(r"[•◦·\-▪▸►]", "", text).strip()
text = text.replace("&amp;", "&").replace("&lt;", "<").replace("&gt;", ">")
text = text.replace("&nbsp;", " ").replace("&#39;", "'").replace("&quot;", '"')
for gaejo, seosul in _ENDING_NORMALIZE.items():
if text.endswith(gaejo):
text = text[: -len(gaejo)] + seosul
break
return text
def extract_keywords(text: str) -> list[str]:
"""Extract length>=3 tokens, then strip a trailing Korean particle.
Deterministic, pure: tokenises on the Phase Z H3 character class
``[가-힣a-zA-Z0-9()]+``, drops tokens shorter than 3 characters,
and folds a single longest-match trailing particle from
``_PARTICLES`` when the remaining stem is still length >= 2.
"""
words = re.findall(r"[가-힣a-zA-Z0-9()]+", text)
keywords: list[str] = []
for w in words:
if len(w) < 3:
continue
for p in _PARTICLES:
if w.endswith(p) and len(w) - len(p) >= 2:
w = w[: -len(p)]
break
if len(w) >= 2:
keywords.append(w)
return keywords
_META_PREFIXES: list[str] = [
"제목 라벨:",
"표현 의도:",
"슬라이드 주인공",
"가장 큰 시각적 비중",
"시각적으로",
"간결하게 제기",
"개별 증거로 제시",
"계층적으로 시각화",
]
_META_INLINE_FRAGMENTS: tuple[str, ...] = (
"현상-문제 인과관계",
"상위-하위 포함 관계",
"독립적 나열",
)
def strip_meta_lines(text: str) -> str:
"""Drop Kei prompt meta/instruction lines before verification.
A line is dropped if its stripped form starts with any prefix in
``_META_PREFIXES`` (e.g. ``제목 라벨:``) or contains any inline
expression-hint fragment in ``_META_INLINE_FRAGMENTS`` (e.g.
``현상-문제 인과관계``). These are prompt directives, not slide
content; they must not enter sentence/keyword extraction for the
B-2 reverse path. Deterministic, pure: no I/O, no LLM, no regex
against runtime data.
"""
filtered: list[str] = []
for line in text.split("\n"):
stripped = line.strip()
if any(stripped.startswith(prefix) for prefix in _META_PREFIXES):
continue
if any(fragment in stripped for fragment in _META_INLINE_FRAGMENTS):
continue
filtered.append(line)
return "\n".join(filtered)
_BULLET_MARKER_PATTERN = re.compile(r"^[\-•◦·\d]+[.)]\s*")
_SENTENCE_SPLIT_PATTERN = re.compile(r"(?<=\.)\s+")
_MIN_SENTENCE_LEN = 5
def split_into_sentences(text: str) -> list[str]:
"""Split text into sentences for deterministic comparison.
Pipeline (order matters): drop Kei meta/instruction lines via
``strip_meta_lines``, split on newline, skip empties and ``#``-led
header lines, strip any leading bullet/numeric marker matching
``_BULLET_MARKER_PATTERN``, then split on inter-sentence whitespace
following a period. Parts shorter than ``_MIN_SENTENCE_LEN`` are
dropped so single-token noise (e.g. residual punctuation) cannot
enter the preservation/invented-text checks.
"""
text = strip_meta_lines(text)
sentences: list[str] = []
for line in text.split("\n"):
line = line.strip()
if not line or line.startswith("#"):
continue
line = _BULLET_MARKER_PATTERN.sub("", line).strip()
if not line:
continue
for part in _SENTENCE_SPLIT_PATTERN.split(line):
part = part.strip()
if len(part) >= _MIN_SENTENCE_LEN:
sentences.append(part)
return sentences
_SENTENCE_KEYWORD_MATCH_THRESHOLD = 0.6
_SENTENCE_SEQUENCE_MATCH_THRESHOLD = 0.65
def _sentence_matches_html(
sentence: str,
html_combined: str,
html_texts: list[str],
) -> bool:
"""Return True if ``sentence`` is preserved in the HTML side.
Two-axis match: a keyword-ratio gate against ``html_combined`` (the
pre-normalized join of all visible HTML text fragments) and a
SequenceMatcher fallback against each individual normalized HTML
fragment. A sentence whose keyword set is empty after normalization
is treated as preserved (no falsifiable signal). Pure helper used
by ``verify_text_preservation`` (u8); thresholds are lifted to
named module constants so the surface is auditable.
"""
norm_orig = normalize_for_comparison(sentence)
keywords = extract_keywords(norm_orig)
if not keywords:
return True
kw_found = sum(1 for kw in keywords if kw in html_combined)
kw_ratio = kw_found / len(keywords)
best_ratio = 0.0
for html_text in html_texts:
norm_html = normalize_for_comparison(html_text)
ratio = SequenceMatcher(None, norm_orig, norm_html).ratio()
if ratio > best_ratio:
best_ratio = ratio
return (
kw_ratio >= _SENTENCE_KEYWORD_MATCH_THRESHOLD
or best_ratio >= _SENTENCE_SEQUENCE_MATCH_THRESHOLD
)
_TEXT_PRESERVATION_DEFAULT_THRESHOLD = 0.70
_MISSING_SENTENCE_REPORT_LIMIT = 5
_MISSING_SENTENCE_TRUNCATE_LEN = 60
def verify_text_preservation(
original_mdx: str,
generated_html: str,
area_name: str,
threshold: float = _TEXT_PRESERVATION_DEFAULT_THRESHOLD,
) -> VerificationResult:
"""Verify the original MDX text is preserved in the generated HTML.
Splits MDX via u6, pre-normalizes joined HTML via u2+u3, then per
sentence delegates to u7. Empty sentence list -> passed True,
score 1.0. Missing sentences are capped at the report limit and
each truncated to the truncate length constant.
"""
original_sentences = split_into_sentences(original_mdx)
if not original_sentences:
return VerificationResult(passed=True, area_name=area_name,
checks={"text_preservation": True}, score=1.0)
html_texts = extract_text_from_html(generated_html)
html_combined = normalize_for_comparison(" ".join(html_texts))
matched = 0
missing: list[str] = []
for sentence in original_sentences:
if _sentence_matches_html(sentence, html_combined, html_texts):
matched += 1
else:
missing.append(sentence)
score = matched / len(original_sentences)
passed = score >= threshold
errors: list[str] = []
if not passed:
errors = [f"누락 문장 ({len(missing)}/{len(original_sentences)}):"]
for s in missing[:_MISSING_SENTENCE_REPORT_LIMIT]:
errors.append(
f" - \"{s[:_MISSING_SENTENCE_TRUNCATE_LEN]}...\""
if len(s) > _MISSING_SENTENCE_TRUNCATE_LEN else f" - \"{s}\""
)
warnings = ([f"보존율: {score:.0%} ({matched}/{len(original_sentences)} 문장)"]
if score < 1.0 else [])
return VerificationResult(
passed=passed, area_name=area_name,
checks={"text_preservation": passed}, score=score,
errors=errors, warnings=warnings,
)
_INVENTED_TEXT_MIN_LENGTH = 15
_INVENTED_TEXT_ALLOWED_LABELS: frozenset[str] = frozenset({
"용어 정의", "핵심 메시지", "상세 비교",
})
_INVENTED_TEXT_CSS_NUMBER_PATTERN = re.compile(r"^[\d\s.,%px#rgb()]+$")
_INVENTED_TEXT_KEYWORD_THRESHOLD = 0.4
_INVENTED_TEXT_TRUNCATE_LEN = 80
def detect_invented_text(
original_mdx: str,
generated_html: str,
min_length: int = _INVENTED_TEXT_MIN_LENGTH,
) -> list[str]:
"""Detect HTML text fragments that are not anchored in the source MDX.
Phase Z port of the H3 hallucination guard (Phase Q reference:
``src/content_verifier.py:276-315``). Pipeline (order matters):
drop short fragments (< ``min_length``), drop structural label
exceptions in ``_INVENTED_TEXT_ALLOWED_LABELS``, drop CSS/numeric
noise matching ``_INVENTED_TEXT_CSS_NUMBER_PATTERN``, then per
surviving fragment compute keyword ratio (via u4 ``extract_keywords``
on the normalized fragment, checked against the normalized MDX). A
fragment is flagged when ``kw_ratio < _INVENTED_TEXT_KEYWORD_THRESHOLD``;
flagged values are truncated to ``_INVENTED_TEXT_TRUNCATE_LEN`` chars
before being returned. Empty keyword sets short-circuit as
non-falsifiable (matches Phase Q parity). Deterministic, pure.
"""
html_texts = extract_text_from_html(generated_html)
norm_mdx = normalize_for_comparison(original_mdx)
invented: list[str] = []
for text in html_texts:
text = text.strip()
if len(text) < min_length:
continue
if text in _INVENTED_TEXT_ALLOWED_LABELS:
continue
if _INVENTED_TEXT_CSS_NUMBER_PATTERN.match(text):
continue
norm_text = normalize_for_comparison(text)
keywords = extract_keywords(norm_text)
if not keywords:
continue
kw_found = sum(1 for kw in keywords if kw in norm_mdx)
kw_ratio = kw_found / len(keywords)
if kw_ratio < _INVENTED_TEXT_KEYWORD_THRESHOLD:
invented.append(text[:_INVENTED_TEXT_TRUNCATE_LEN])
return invented
+4 -17
View File
@@ -36,6 +36,7 @@ from src.image_utils import get_image_sizes, embed_images
from src.space_allocator import calculate_container_specs from src.space_allocator import calculate_container_specs
from src.slide_measurer import measure_rendered_heights, capture_slide_screenshot from src.slide_measurer import measure_rendered_heights, capture_slide_screenshot
from src.config import settings from src.config import settings
from src.json_utils import parse_json as _parse_json
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -1182,6 +1183,7 @@ async def generate_slide(
yield {"event": "progress", "data": "3/7 슬라이드 HTML 생성 중..."} yield {"event": "progress", "data": "3/7 슬라이드 HTML 생성 중..."}
async def stage_2(context: PipelineContext) -> dict: async def stage_2(context: PipelineContext) -> dict:
# [legacy Phase R'/Q example — INTEGRATION-AUDIT-01 §10.4]
# Phase X-BX': Type B는 code_assembled 직접 사용, Sonnet 재구성 스킵 # Phase X-BX': Type B는 code_assembled 직접 사용, Sonnet 재구성 스킵
if context.analysis.layout_template in ("B", "B'", "B''"): if context.analysis.layout_template in ("B", "B'", "B''"):
from src.block_assembler import assemble_slide_html_final from src.block_assembler import assemble_slide_html_final
@@ -1190,6 +1192,7 @@ async def generate_slide(
logger.info(f"[Stage 2] Type B: slide-base + 블록 (font_scale={fs:.1f})") logger.info(f"[Stage 2] Type B: slide-base + 블록 (font_scale={fs:.1f})")
return {"generated_html": generated} return {"generated_html": generated}
# [legacy Phase R'/Q example — INTEGRATION-AUDIT-01 §10.4]
# Type A: 기존 Sonnet 재구성 코드 그대로 # Type A: 기존 Sonnet 재구성 코드 그대로
from src.content_verifier import generate_with_retry from src.content_verifier import generate_with_retry
@@ -1998,6 +2001,7 @@ async def _apply_adjustments(
block["detail_target"] = True block["detail_target"] = True
if "data" in block: if "data" in block:
del block["data"] del block["data"]
# [legacy Phase R'/Q example — INTEGRATION-AUDIT-01 §10.4]
block["reason"] = f"재구성: {detail}" block["reason"] = f"재구성: {detail}"
logger.info( logger.info(
f"조정: {area} → kei_restructure (detail_target)" f"조정: {area} → kei_restructure (detail_target)"
@@ -2077,20 +2081,3 @@ def _convert_kei_judgment(
new_adjs.append(adj) new_adjs.append(adj)
review_result["adjustments"] = new_adjs review_result["adjustments"] = new_adjs
def _parse_json(text: str) -> dict[str, Any] | None:
"""텍스트에서 JSON을 추출한다."""
patterns = [
r"```json\s*(.*?)```",
r"```\s*(.*?)```",
r"(\{.*\})",
]
for pattern in patterns:
match = re.search(pattern, text, re.DOTALL)
if match:
try:
return json.loads(match.group(1).strip())
except json.JSONDecodeError:
continue
return None
+28 -38
View File
@@ -13,86 +13,76 @@ from collections import OrderedDict
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
import yaml
from jinja2 import Environment, FileSystemLoader from jinja2 import Environment, FileSystemLoader
from src import catalog as _catalog_mod
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
TEMPLATES_DIR = Path(__file__).parent.parent / "templates" TEMPLATES_DIR = Path(__file__).parent.parent / "templates"
STATIC_DIR = Path(__file__).parent.parent / "static" STATIC_DIR = Path(__file__).parent.parent / "static"
CATALOG_PATH = TEMPLATES_DIR / "catalog.yaml"
# 카테고리 검색 순서 # 카테고리 검색 순서
BLOCK_CATEGORIES = ["headers", "cards", "tables", "visuals", "emphasis", "media"] BLOCK_CATEGORIES = ["headers", "cards", "tables", "visuals", "emphasis", "media"]
# catalog.yaml에서 id → template 경로 매핑 로드 (BF-10: mtime 체크로 자동 갱신) # id → template 경로 매핑 (IMP-27: src.catalog 공유 로더 위임, renderer-local projection cache)
_CATALOG_MAP: dict[str, str] | None = None _CATALOG_MAP: dict[str, str] | None = None
_CATALOG_MTIME: float = 0.0 _CATALOG_MAP_MTIME: float = 0.0
# Phase R: variant별 template 경로 캐시 (renderer-local projection)
_CATALOG_VARIANT_MAP: dict[str, str] | None = None
_CATALOG_VARIANT_MAP_MTIME: float = 0.0
def _load_catalog_map() -> dict[str, str]: def _load_catalog_map() -> dict[str, str]:
"""catalog.yaml에서 블록 id → template 경로 매핑을 로드한다. """블록 id → template 경로 projection (IMP-27: src.catalog 공유 로더 위임).
파일 수정시간(mtime)을 확인하여, 변경 시에만 재로드한다. catalog 파일 읽기와 mtime 캐싱은 ``src.catalog`` 가 단독 소유. 본 함수는
그 결과를 ``id → template`` 형태로 변환한 renderer-local projection 캐시만
유지하며, projection 무효화는 ``src.catalog.get_catalog_mtime()`` 키잉.
""" """
global _CATALOG_MAP, _CATALOG_MTIME global _CATALOG_MAP, _CATALOG_MAP_MTIME
current_mtime = CATALOG_PATH.stat().st_mtime if CATALOG_PATH.exists() else 0.0 blocks = _catalog_mod.load_blocks()
current_mtime = _catalog_mod.get_catalog_mtime()
if _CATALOG_MAP is not None and _CATALOG_MTIME == current_mtime: if _CATALOG_MAP is not None and _CATALOG_MAP_MTIME == current_mtime:
return _CATALOG_MAP # 파일 변경 없음 → 캐시 재사용 return _CATALOG_MAP
# 변경 감지 또는 첫 로드 → 새로 읽기 _CATALOG_MAP_MTIME = current_mtime
_CATALOG_MTIME = current_mtime
_CATALOG_MAP = {} _CATALOG_MAP = {}
if CATALOG_PATH.exists(): for block in blocks:
try:
with open(CATALOG_PATH, encoding="utf-8") as f:
catalog = yaml.safe_load(f)
for block in catalog.get("blocks", []):
block_id = block.get("id", "") block_id = block.get("id", "")
template = block.get("template", "") template = block.get("template", "")
if block_id and template: if block_id and template:
_CATALOG_MAP[block_id] = template _CATALOG_MAP[block_id] = template
logger.info(f"catalog.yaml 로드: {len(_CATALOG_MAP)}개 블록 매핑") logger.info(f"catalog.yaml 로드: {len(_CATALOG_MAP)}개 블록 매핑")
except Exception as e:
logger.warning(f"catalog.yaml 로드 실패: {e}")
else:
logger.warning(f"catalog.yaml 미발견: {CATALOG_PATH}")
return _CATALOG_MAP return _CATALOG_MAP
# Phase R: variant별 template 경로 캐시
_CATALOG_VARIANT_MAP: dict[str, str] | None = None
def _load_catalog_map_with_variants() -> dict[str, str]: def _load_catalog_map_with_variants() -> dict[str, str]:
"""catalog.yaml에서 variant별 template 경로 매핑을 로드한다. """variant별 template 경로 projection (IMP-27: src.catalog 공유 로더 위임).
키: "block_id--variant_id" → 값: template 경로 키: "block_id--variant_id" → 값: template 경로.
""" """
global _CATALOG_VARIANT_MAP global _CATALOG_VARIANT_MAP, _CATALOG_VARIANT_MAP_MTIME
# _load_catalog_map이 이미 캐시 관리하므로 같은 mtime 사용 blocks = _catalog_mod.load_blocks()
_load_catalog_map() # 캐시 갱신 보장 current_mtime = _catalog_mod.get_catalog_mtime()
if _CATALOG_VARIANT_MAP is not None and _CATALOG_MTIME == (CATALOG_PATH.stat().st_mtime if CATALOG_PATH.exists() else 0.0): if _CATALOG_VARIANT_MAP is not None and _CATALOG_VARIANT_MAP_MTIME == current_mtime:
return _CATALOG_VARIANT_MAP return _CATALOG_VARIANT_MAP
_CATALOG_VARIANT_MAP_MTIME = current_mtime
_CATALOG_VARIANT_MAP = {} _CATALOG_VARIANT_MAP = {}
if CATALOG_PATH.exists(): for block in blocks:
try:
with open(CATALOG_PATH, encoding="utf-8") as f:
catalog = yaml.safe_load(f)
for block in catalog.get("blocks", []):
block_id = block.get("id", "") block_id = block.get("id", "")
for variant in block.get("variants", []): for variant in block.get("variants", []):
vid = variant.get("id", "default") vid = variant.get("id", "default")
vtemplate = variant.get("template", "") vtemplate = variant.get("template", "")
if vid != "default" and vtemplate: if vid != "default" and vtemplate:
_CATALOG_VARIANT_MAP[f"{block_id}--{vid}"] = vtemplate _CATALOG_VARIANT_MAP[f"{block_id}--{vid}"] = vtemplate
except Exception as e:
logger.warning(f"catalog variant 로드 실패: {e}")
return _CATALOG_VARIANT_MAP return _CATALOG_VARIANT_MAP
File diff suppressed because it is too large Load Diff
+38
View File
@@ -0,0 +1,38 @@
# Phase Z Families — WIP Marker
**Status:** intentionally untracked, uncontracted, out-of-scope for runtime matcher.
**Closes audit follow-up:** INTEGRATION-AUDIT-01-REPORT.md §10.2 F-2 (option c).
**Gate for promote/remove:** Gitea issue #42 (`IMP-04b Catalog extension to 32 frames`).
## Baseline lock (2026-05-19)
| Surface | Count | Notes |
|---|---|---|
| `git ls-files templates/phase_z2/families/*.html` | 11 | tracked family templates |
| `templates/phase_z2/catalog/frame_contracts.yaml` top-level keys | 11 | 1:1 with tracked basenames |
| `Get-ChildItem templates/phase_z2/families/*.html` | 13 | tracked 11 + WIP 2 (below) |
Active contracted family count = **11**. Tracked basenames ↔ `frame_contracts.yaml` top-level keys are set-equal. Drift between disk (13) and contracted (11) is fully explained by the 2 WIP files below.
## WIP family templates (uncontracted)
| File | Figma frame | Status |
|---|---|---|
> **#42 IMP-04b u3 (2026-05-21)** — frame 23 (`1171281203`) partial absorbed → `frame_contracts.yaml::app_sw_package_vs_solution`. Counts in `## Baseline lock (2026-05-19)` are historical (post-u3: 11 tracked + 1 WIP / 12 contract).
> **#42 IMP-04b u4 (2026-05-21)** — frame 9 (`1171281180`) partial absorbed → `frame_contracts.yaml::pre_construction_model_info_stacked`. WIP family table now empty (post-u4: 13 tracked / 13 contract).
These files are partials authored during Phase Z-2 MVP-1.5b exploration. They are **not** part of the contracted Phase Z runtime catalog and must not be enumerated by frame selection, matcher, or any Stage 3 pipeline surface.
## Rules
- Adding a file to `templates/phase_z2/families/*.html` without a matching `frame_contracts.yaml` entry is **only** permitted if it is named here as WIP.
- `tests/test_family_contract_baseline.py` enforces this invariant: tracked families ↔ `frame_contracts.yaml` keys must be set-equal, modulo the WIP allowlist parsed from this file.
- Promoting a WIP file (add `frame_contracts.yaml` entry + register with matcher) or removing it must happen under issue #42 or a follow-up issue, not silently.
## References
- `docs/architecture/INTEGRATION-AUDIT-01-REPORT.md` §10.2 F-2 (audit finding closed by issue #52, option c)
- `docs/architecture/IMP-18-SVG-GAP-REPORT.md` L28, L51 (count basis corrected to `11 contracted + 2 WIP`)
- Gitea issue #52 (this reconciliation)
- Gitea issue #42 (pre-flight gate for promote/remove)
+65 -2
View File
@@ -1,11 +1,23 @@
<!-- Phase Z-2 MVP-1.5b — single slide + Type B layout (top/bottom zones). <!-- Phase Z-2 MVP-1.5b — single slide + Type B layout (top/bottom zones).
원래 Phase Z 설계 복귀: MDX 1 = slide 1, layout preset = zone 분할, frame-derived block ⊂ zone. --> 원래 Phase Z 설계 복귀: MDX 1 = slide 1, layout preset = zone 분할, frame-derived block ⊂ zone. -->
<!DOCTYPE html> <!DOCTYPE html>
<html lang="ko"> <html lang="ko"{% if embedded_mode == "embedded" %} class="embedded"{% endif %}>
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=1280"> <meta name="viewport" content="width=1280">
<title>{{ slide_title }}</title> <title>{{ slide_title }}</title>
{% if embedded_mode == "auto" %}
<script>
(function(){
try {
var params = new URLSearchParams(window.location.search);
if (params.get('embedded') === '1' || window.self !== window.top) {
document.documentElement.classList.add('embedded');
}
} catch (e) {}
})();
</script>
{% endif %}
<style> <style>
/* ── existing tokens (inlined) ── */ /* ── existing tokens (inlined) ── */
{{ token_css | safe }} {{ token_css | safe }}
@@ -20,6 +32,19 @@
padding: 20px 0; padding: 20px 0;
} }
/* ── IMP-14 A-4: embedded mode reset (iframe consumer) ──
standalone-only body centering/min-height/padding undone so the .slide
(1280×720) sits at origin without vertical shift or clipping. */
html.embedded body {
background: transparent;
display: block;
min-height: 0;
padding: 0;
}
html.embedded .slide {
box-shadow: none;
}
/* ── 16:9 슬라이드 (single, 1280×720) ── */ /* ── 16:9 슬라이드 (single, 1280×720) ── */
.slide { .slide {
width: 1280px; height: 720px; width: 1280px; height: 720px;
@@ -89,6 +114,43 @@
min-height: 0; min-height: 0;
} }
/* ── IMP-30 u5 : provisional zone marker (first-render invariant) ──
When V4 rank-1 candidate falls outside MVP1_ALLOWED_STATUSES (chain_exhausted)
the pipeline still renders the rank-1 frame so the first-render invariant
holds, but the zone is tagged `provisional` so the user/AI can adapt later
(IMP-31). Visual contract:
- dashed amber border + striped wash → "needs adaptation" at a glance
- inline badge top-right → text label for non-color-perceiving readers
MDX content is preserved as-is; no shrink, no rewrite. */
.zone--provisional {
outline: 2px dashed #b8860b;
outline-offset: -2px;
background-image: repeating-linear-gradient(
45deg,
rgba(184, 134, 11, 0.04) 0,
rgba(184, 134, 11, 0.04) 8px,
transparent 8px,
transparent 16px
);
}
.zone--provisional .zone__needs-adaptation-badge {
position: absolute;
top: 4px;
right: 4px;
z-index: 10;
padding: 2px 6px;
background: #b8860b;
color: #fff;
font-size: 9px;
font-weight: 700;
line-height: 1.2;
letter-spacing: 0.04em;
border-radius: 2px;
text-transform: uppercase;
pointer-events: none;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.15);
}
/* ── Frame-family text layout contract (shared, reusable) ── /* ── Frame-family text layout contract (shared, reusable) ──
feedback-1 (mvp1.5b_test7): visible improvement 강화. feedback-1 (mvp1.5b_test7): visible improvement 강화.
Stronger hanging indent + breathing line spacing + visible hierarchy. */ Stronger hanging indent + breathing line spacing + visible hierarchy. */
@@ -239,7 +301,8 @@
<div class="slide-body"> <div class="slide-body">
<div class="layout-{{ layout_preset }}"> <div class="layout-{{ layout_preset }}">
{% for zone in zones %} {% for zone in zones %}
<div class="zone" data-zone-position="{{ zone.position }}" data-template-id="{{ zone.template_id }}" style="grid-area: {{ zone.position }};"> <div class="zone{% if zone.provisional %} zone--provisional{% endif %}" data-zone-position="{{ zone.position }}" data-template-id="{{ zone.template_id }}"{% if zone.provisional %} data-provisional="1"{% endif %} style="grid-area: {{ zone.position }};">
{% if zone.provisional %}<span class="zone__needs-adaptation-badge" aria-label="needs user or AI adaptation">needs adaptation</span>{% endif %}
{{ zone.partial_html | safe }} {{ zone.partial_html | safe }}
</div> </div>
{% endfor %} {% endfor %}
+175
View File
@@ -0,0 +1,175 @@
# CLAUDE.md — 매칭 시스템 작업 컨텍스트
이 파일은 Claude (AI) 가 `tests/` 디렉토리에서 작업할 때 참고하는 컨텍스트입니다.
프로젝트 루트의 [../CLAUDE.md](../CLAUDE.md) 와 함께 사용.
## 작업 디렉토리
- 메인 작업 디렉토리: `tests/matching/`
- 데이터 / 보고서 파일도 같은 위치
- 실행 시 항상 `tests/matching/` 에서 (스크립트 내부 상대 경로 의존)
## 시스템 개요
MDX 콘텐츠 ↔ Figma Frame 32 개를 매칭하는 4 단계 파이프라인 (V1~V4).
상세는 `README.md` / `PLAN.md` / `PROGRESS.md` 참조.
## 절대 규칙
### 1. 하드코딩 금지 (사용자 강조 사항)
- 결과물을 직접 고치지 말고 **프로세스/코드를 고쳐라**
- 임의 데이터 삽입 금지 (예: DECK 04 의 "제목·A 라벨·B 라벨·행 데이터" placeholder 사용 금지)
- 모든 표시값은 실제 코드 결과 (yaml / 함수 출력) 에서 가져와야 함
### 2. 사용자 직접 수정 보존
- 사용자가 HTML 파일을 직접 편집한 경우 **반드시 pipeline 코드에 반영** 후 재생성
- 코드만 고치고 재실행하면 사용자 수정이 사라짐
- 변경 시: 사용자 수정 8 개 모두 코드에 반영 → 재실행
### 3. 정직한 코드 동작 표시
- 임원 보고용 deck 라도 **코드의 한계를 솔직히 표시**
- 예: "MDX 자동 분석 결과 — 정책/요구사항 (사람이 보면 행렬형 비교)" 같은 표기
- "이 축은 사실 frame 매칭에 영향 없음" 같은 ablation 결과는 임원용에는 빼지만, 내부 문서 (PROGRESS.md) 에는 명시
### 4. 임원 보고용 톤
- 영문 enum 코드 (`policy_requirements`) 직접 노출 금지 — 한글 (정책/요구사항) 우선
- 매칭 키워드는 5~10 개 + "등 N 개" 로 축약
- 디자인: 그라데이션 / 화려한 카드 금지. 단순 표 + 흑백 + 강조 색 1~2 가지
- 정의 / 부연 설명 최소화 (def 는 한 줄, 길게 풀어 쓰지 말 것)
## 명명 규칙
### 파이프라인 스크립트
```
pipeline_<숫자>_<이름>.py
```
- 01~07: 입력 추출 + 전처리 + 키워드
- 08: V2 (semantic) / V3 (structure r2~r5) / V4 (template_fit, r1/r2)
- 09: V2 진단
- 10: Holdout 라벨링 / 평가
- 11: templates_v1 감사
- 12: templates_v2 생성 (r1, r2, r3, final, final_r2, promote_frame13)
- 13: meeting docs / samples
- 14: single sample
- 15: bm25 / idf / logistic regression 비교
- 16: deck 페이지 생성 (DECK 1~7)
- 17: V4 full32 (32 frame 전체 평가)
- 18: V4 slot 축 ablation
### 결과 파일
```
<단계>_<설명>_result.yaml
```
- `mdx_matching_result.yaml` — V1
- `v2_semantic_rerank_result.yaml` — V2
- `v3_structure_rerank_r5_result.yaml` — V3 (최종 r5)
- `v4_full32_result.yaml` — V4 (32 frame 전체)
- `structure_ontology_v2_final_r2.yaml` — Frame 32 DB
### 보고서
```
DECK_<번호>_<이름>.html — 임원 보고용 A4 페이지
ATTACH_<번호>_<이름>.html — 부속 자료
<NAME>_REPORT.html / .md — 분석 보고서
```
## 자주 쓰는 명령어
```bash
cd tests/matching/
# 매칭 시스템 전체 재실행
python pipeline_06_2_mdx_matching.py
python pipeline_08_v2_semantic_rerank.py
python pipeline_08_v3_r5_structure_rerank.py
python pipeline_17_v4_full32.py
# 보고서 재생성
python pipeline_16_deck_4pages.py # DECK 1~7
# Ablation / 검증
python pipeline_15_logistic_regression.py
python pipeline_18_slot_axis_ablation.py
```
## 파이프라인 핵심 가중치
### V1 키워드 매칭 (Logistic Regression 학습)
```
matching_score = 0.414 × 핵심 + 0.320 × 세트 + 0.265 × 연관
```
### V3 구조 매칭
```
total = 0.40 × 레이아웃 일치 + 0.35 × 콘텐츠 성격 + 0.25 × 시각 의도
```
### V4 종합 판정
```
confidence = 0.25 × anchor + 0.20 × cardinality + 0.20 × relation
+ 0.15 × slot + 0.20 × content − penalty
라벨 임계값:
≥ 0.90 → use_as_is (그대로 사용)
≥ 0.75 → light_edit (가벼운 편집)
≥ 0.60 → restructure (구조 재배치)
< 0.60 → reject (사용 불가)
```
## 데이터 소스
| 데이터 | 위치 | 용도 |
|---|---|---|
| Figma 텍스트 | `figma_to_html_agent/blocks/*/texts.md` | 32 frame 텍스트 추출 |
| BEPS 마스터 | (별도 위치) | 키워드 보강용 |
| MDX 검증 구간 | (`pipeline_01_extract_nodes.py` 의 `MDX_SECTIONS`) | 정답 매칭 검증 |
| Frame 이미지 | `data/figma_previews/<프레임번호>.png` | DECK 시각화 |
## 테스트 픽스처 컨벤션 (F-5, INTEGRATION-AUDIT-01 §10.5.1)
테스트 데이터 / 샘플 참조의 정식 위치 규약. `tests/` 안에서만 적용되고 `src/**` 프로덕션 경로에는 적용되지 않음.
| 경로 | 상태 | 용도 | 비고 |
|---|---|---|---|
| `tests/phase_z2/fixtures/` | **존재 (정식)** | Phase Z 회귀 YAML 픽스처 | `test_fixtures_loader.py` 가 로드. 서브디렉토리 : `build_layout_css/`, `retry_gate/`. |
| `tests/fixtures/` (루트) | **없음 (현재 미생성)** | 비-Phase-Z / 비-YAML 픽스처 미래 후보 | 샘플 인벤토리가 `tests/phase_z2/test_*.py` 인라인으로 감당 못 할 때만 별도 이슈로 신설. |
| `samples/mdx_batch/**` , `samples/mdx/**` | 존재 | 통합 스모크 입력 | `tests/**` 에서만 참조 가능. `src/**` 런타임 경로 하드코딩 금지. |
규칙 :
- 테스트 코드에서는 `samples/mdx_batch/02.mdx` 같은 샘플 MDX 를 직접 참조해도 됨 (예 : `tests/phase_z2/test_pz2_vu_integration.py`). `src/**` 런타임 입력은 절대 샘플 파일명 / 콘텐츠를 핀하지 말 것.
- 새 YAML 회귀 픽스처는 `tests/phase_z2/fixtures/` 아래 새 서브디렉토리로 추가. 루트 `tests/fixtures/` 신설은 금지 (별도 이슈 필요).
- `src/**` 안에 등장하는 "BIM" / "건설산업 DX" / "재구성" 같은 sample-like 리터럴은 INTEGRATION-AUDIT-01 §10.4 (F-4) 에서 의도된 docstring / glossary / 예시 dict 로 분류 완료. annotation marker 가 붙어 있으면 의도된 example. 새 sample 리터럴을 `src/**` 에 도입하지 말 것.
- 본 컨벤션의 anchor 정의는 `docs/architecture/INTEGRATION-AUDIT-01-REPORT.md` §10.5.1. 변경 시 anchor 부터 갱신.
## 자주 헷갈리는 것
### 영문 enum vs 한글 매핑
- 코드 / yaml: 영문 enum (`comparative_matrix`, `cycle_interrelation`)
- 보고서 표시: 한글 (`행렬형 비교`, `순환/상호 관계`)
- DECK 05 의 키워드 사전 표는 양쪽 다 표시 (사용자 매칭 가능)
### 항목수 vs 슬롯 후보 개수
- **동일** — `item_count = len(slot_candidates)` (표 / subsections / bullets 어떤 형태든)
- V4 의 cardinality 축과 slot.within 부분은 **같은 신호의 중복 가중** (ablation 으로 확인)
### V3 vs V4 구조 점수
- V3 = layout family + content_affinity + structure_intent (3 축)
- V4 = anchor + cardinality + relation + slot + content (5 축)
- **다른 모델**. V3 점수와 V4 confidence 는 별도 계산
## 사용자가 강조한 피드백
- "코드로 돌린 결과물이지 임의 데이터 아님" — 모든 표시 정직
- "임원 보고용이야" — 부정적 부연 / 디테일 산식 빼기
- "한가지만 해" — 한 번에 한 가지만 변경
- "모든 변경은 pipeline 코드에 반영" — HTML 직접 수정은 일시적
## 진행 중 발견된 약점
`PROGRESS.md` 의 "발견된 약점" 표 참조. 8 개 모두 Phase E 작업 대상.
가장 시급:
1. **02-2.2 매칭 실패** (E.5)
2. **MDX 분석 LLM 화** (E.1, E.2)
3. **슬롯 의미 매핑** (E.3, E.4)
+90
View File
@@ -0,0 +1,90 @@
# IMP-47A — mdx03 frontend stabilization manual e2e
Scope: frontend-only. Backend pipeline must NOT be modified during this test.
Path under test: `mdx=03` (default sample loaded on page open).
## Preconditions
- Backend running on `http://localhost:8001` (`uvicorn src.main:app --port 8001`).
- Frontend dev server running (`cd Front && npm run dev`).
- Working tree at IMP-47A Stage 3 HEAD (u1+u2+u3 applied to `Front/client/src/components/SlideCanvas.tsx`, `Front/client/src/services/designAgentApi.ts`, `Front/client/src/pages/Home.tsx`).
- Browser opens `http://localhost:5173/?mdx=03` (or default route, which auto-loads `mdx=03`).
## Section 1 — iframe rendering (axis 1)
Goal: verify u1 sandbox change lets `slide_base.html` script apply `html.embedded` class so the slide is not clipped inside the iframe.
Steps:
1. Open the app; wait for `mdx=03` auto-load and initial `final.html` render.
2. Click the "슬라이드 플랜 생성하기" button; wait for `run "<id>" 완료` toast.
3. Open browser DevTools → Elements; locate the iframe inside `SlideCanvas`; switch context to the iframe document.
4. Confirm `<html class="embedded">` is present (not just `<html>`).
5. Confirm the rendered slide content fills the 1280×720 frame with no top padding offset and no clipping at the bottom.
Pass: `html.embedded` class present AND no visible vertical shift/clipping.
Fail signal: iframe content pushed downward, footer cut off, or `html` lacks `embedded` class (means script never ran → sandbox regression).
## Section 2 — multi-source frame candidates (axis 2)
Goal: verify u2 3-source merge surfaces candidates from `candidate_evidence`, `v4_all_judgments`, and `v4_candidates` with deterministic dedup and cap.
Steps:
1. After Section 1 success, click any zone in the canvas; right panel switches to the "frame" tab.
2. In the frame candidate list, count visible candidates. Expect ≤ `TOP_N_FRAMES` (=6).
3. Open DevTools → Network → reload `/api/run/<id>`; inspect the JSON response and confirm at least two of `candidate_evidence`, `v4_all_judgments`, `v4_candidates` are non-empty.
4. Cross-check: union of `template_id ?? id ?? frame_id` keys from all three arrays (deduped, capped at 6) equals the UI list count and order.
5. Confirm the order respects LABEL_PRIORITY (`use_as_is` < `light_edit` < `restructure` < `reject`) then descending confidence.
Pass: union/dedup/cap/order all match.
Fail signal: only candidates from a single source visible, duplicates by template_id, more than 6 items, or order violates LABEL_PRIORITY.
## Section 3 — frame / layout override regeneration (axis 3)
Goal: verify u3 5-dep `handleGenerate` callback delivers the latest override state to backend (no stale closure).
Steps:
1. From Section 2, pick a non-default frame candidate (one whose label is not `use_as_is`); click "이 프레임 적용".
2. Confirm bottom-left button transforms to "선택대로 재생성하기" with amber pulse dot (hasPendingChanges = true).
3. Without any extra clicks, click "선택대로 재생성하기".
4. Open DevTools → Network → inspect the POST `/api/pipeline` body; confirm `overrides.frames` contains the chosen `{unit_id: frame_id}` mapping.
5. After success toast, confirm new `run_id` differs from the previous run, and the rendered iframe reflects the chosen frame (frame DOM class / id matches selection).
6. Repeat with a layout-card "적용하기" → pending overlay enters → "선택대로 재생성하기"; confirm POST body includes `overrides.layout` with the chosen preset id.
Pass: every override (frames / layout / zoneSections / zoneGeometries when applicable) reaches backend on first click; new `run_id` returned.
Fail signal: POST body lacks the override, or backend re-renders with the previous selection (stale closure regression).
## Section 4 — pending overlay enter / cancel / clear (axis 4)
Goal: verify pendingLayout overlay enters on "적용하기", exits on "취소", and auto-clears on successful regenerate.
Steps:
1. Click any non-current layout card "적용하기" button.
2. Confirm amber dashed overlay appears over `.slide-body` area with `PENDING BODY LAYOUT` label and chosen layout id.
3. Confirm "취소" button (top-right of canvas) is visible.
4. Click "취소"; confirm overlay disappears, `userSelection` resets, and `hasPendingChanges` indicator clears.
5. Re-enter pending mode (apply a layout again), this time click "선택대로 재생성하기"; confirm overlay disappears on success (Home.tsx clears `pendingLayout` + `hasPendingChanges` before pipeline call) and the new `final.html` renders inline (no overlay).
Pass: enter → cancel → re-enter → regenerate cycle leaves no overlay, no stuck pending state, no leftover `hasPendingChanges` flag.
Fail signal: overlay persists after regenerate, button stays in amber state, or cancel does not restore the canvas.
## Section 5 — mdx03 end-to-end pass (axis 5)
Goal: smoke run combining Sections 1–4 in one session to validate mdx03 demo path.
Steps:
1. Fresh reload `http://localhost:5173/?mdx=03`.
2. Click "슬라이드 플랜 생성하기" → wait for run completion → confirm iframe renders cleanly (Section 1 pass).
3. Click any zone → inspect 2+ candidates in frame list (Section 2 pass).
4. Apply a non-default frame → "선택대로 재생성하기" → new run id + iframe reflects override (Section 3 pass).
5. Apply a non-default layout → confirm overlay → "선택대로 재생성하기" → overlay clears (Section 4 pass).
6. Capture: run_id chain, final.html path under `data/runs/<run_id>/final.html`, and DOM screenshot of the rendered iframe content.
Pass: all five sections green, no console errors, no toast errors, three distinct `run_id`s produced across the session.
Fail signal: any earlier section regression OR backend pipeline failure (out-of-scope for IMP-47A — log separately).
## Out of scope (not tested here)
- AI fallback activation in `Step 12` (`light_edit` / `restructure`) — IMP-47B.
- Frame cache (#62 / IMP-46).
- mdx04 / mdx05 path-specific axes.
- Automated Playwright e2e replacement — future work.
View File
+401
View File
@@ -0,0 +1,401 @@
"""P4 (2026-05-19) — audit-only mode verification.
Covers:
- _is_audit_issue: title pattern detection (positive + negative)
- _audit_mode: title-based + CLI override (AUDIT_ONLY_OVERRIDE)
- _check_audit_only_violations: forbidden prefix detection via mocked git status
- AUDIT_ONLY_NOTE injection into context pack (via build_context_pack contract)
Run: pytest -q tests/orchestrator_unit/test_audit_mode.py
"""
import sys
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).parent.parent.parent
sys.path.insert(0, str(ROOT))
import orchestrator # noqa: E402
from orchestrator import ( # noqa: E402
_is_audit_issue,
_audit_mode,
_check_audit_only_violations,
_check_audit_commit_scope,
_ensure_audit_baseline,
_load_audit_baseline,
_audit_baseline_path,
AUDIT_ONLY_FORBIDDEN_PREFIXES,
AUDIT_ONLY_NOTE,
AUDIT_ALLOWED_COMMIT_GLOBS,
)
# ─────────────────────────────────────────────────────────────────
# _is_audit_issue — title detection
# ─────────────────────────────────────────────────────────────────
class TestIsAuditIssue:
def test_integration_audit_bracket(self):
assert _is_audit_issue("[INTEGRATION-AUDIT-01] cumulative review") is True
assert _is_audit_issue("[INTEGRATION-AUDIT-02] something") is True
assert _is_audit_issue("[INTEGRATION-AUDIT] no number") is True
def test_audit_only_bracket(self):
assert _is_audit_issue("[AUDIT-ONLY] doc consistency check") is True
def test_case_insensitive(self):
assert _is_audit_issue("[integration-audit-03] foo") is True
assert _is_audit_issue("[Audit-Only] bar") is True
def test_plain_integration_audit_phrase(self):
assert _is_audit_issue("Quarterly integration audit for closed issues") is True
assert _is_audit_issue("Integration Audit Q2") is True
def test_execution_issue_not_audit(self):
"""execution sub-issue 가 audit 로 잘못 감지되면 안 됨."""
assert _is_audit_issue("[IMP-15 실행-1] image_aspect_mismatch") is False
assert _is_audit_issue("[IMP-15 exec-2] table overflow") is False
def test_unrelated_issues(self):
assert _is_audit_issue("IMP-19 I4 zone 비중 분배") is False
assert _is_audit_issue("Fix overflow bug") is False
assert _is_audit_issue("docs(IMP-06): Stage 4 fix") is False
def test_empty_or_none(self):
assert _is_audit_issue("") is False
assert _is_audit_issue(None) is False
def test_audit_word_in_random_position_no_match(self):
"""'audit' 가 단독으로 나오는 건 안 잡아야 함 — 'integration audit' 만."""
assert _is_audit_issue("audit some code") is False
assert _is_audit_issue("security audit") is False
# ─────────────────────────────────────────────────────────────────
# _audit_mode — combination with CLI override
# ─────────────────────────────────────────────────────────────────
class TestAuditMode:
def setup_method(self):
# 각 테스트 전에 override 리셋.
orchestrator.AUDIT_ONLY_OVERRIDE = False
def teardown_method(self):
orchestrator.AUDIT_ONLY_OVERRIDE = False
def test_title_based_only(self):
assert _audit_mode("[INTEGRATION-AUDIT-01] foo") is True
assert _audit_mode("IMP-19 zone") is False
def test_cli_override_forces_audit(self):
"""title 에 marker 없어도 CLI flag 가 audit mode 강제."""
orchestrator.AUDIT_ONLY_OVERRIDE = True
assert _audit_mode("IMP-19 zone") is True
assert _audit_mode("any title") is True
assert _audit_mode("") is True
def test_override_off_falls_back_to_title(self):
orchestrator.AUDIT_ONLY_OVERRIDE = False
assert _audit_mode("IMP-19 zone") is False
assert _audit_mode("[INTEGRATION-AUDIT-01]") is True
# ─────────────────────────────────────────────────────────────────
# _check_audit_only_violations — git status parsing
# ─────────────────────────────────────────────────────────────────
class _FakeCompleted:
def __init__(self, stdout, returncode=0):
self.stdout = stdout
self.stderr = ""
self.returncode = returncode
class TestCheckAuditOnlyViolations:
"""subprocess.run 을 monkeypatch 해서 다양한 git status 출력 시나리오 검증."""
def test_clean_tree(self, monkeypatch):
def fake_run(*args, **kwargs):
return _FakeCompleted(stdout="")
monkeypatch.setattr(subprocess, "run", fake_run)
assert _check_audit_only_violations() == []
def test_only_allowed_changes(self, monkeypatch):
"""docs/architecture 변경만 있으면 violation 0."""
stdout = (
" M docs/architecture/INTEGRATION-AUDIT-01-REPORT.md\n"
"?? docs/architecture/INTEGRATION-AUDIT-01-MATRIX.md\n"
" M docs/architecture/PHASE-Z-IMPLEMENTATION-ISSUE-BACKLOG.md\n"
)
monkeypatch.setattr(subprocess, "run", lambda *a, **kw: _FakeCompleted(stdout=stdout))
assert _check_audit_only_violations() == []
def test_src_change_detected(self, monkeypatch):
stdout = (
" M src/phase_z2_pipeline.py\n"
" M docs/architecture/INTEGRATION-AUDIT-01-REPORT.md\n"
)
monkeypatch.setattr(subprocess, "run", lambda *a, **kw: _FakeCompleted(stdout=stdout))
v = _check_audit_only_violations()
assert v == ["src/phase_z2_pipeline.py"]
def test_templates_change_detected(self, monkeypatch):
stdout = " M templates/phase_z2/families/something.html\n"
monkeypatch.setattr(subprocess, "run", lambda *a, **kw: _FakeCompleted(stdout=stdout))
v = _check_audit_only_violations()
assert v == ["templates/phase_z2/families/something.html"]
def test_tests_change_detected(self, monkeypatch):
stdout = " M tests/phase_z2/test_overflow.py\n"
monkeypatch.setattr(subprocess, "run", lambda *a, **kw: _FakeCompleted(stdout=stdout))
v = _check_audit_only_violations()
assert v == ["tests/phase_z2/test_overflow.py"]
def test_multiple_violations(self, monkeypatch):
stdout = (
" M src/a.py\n"
"?? src/b.py\n"
" M templates/c.html\n"
" M tests/d.py\n"
" M docs/architecture/INTEGRATION-AUDIT-01-REPORT.md\n" # allowed
" M data/runs/run123.json\n" # allowed (not in forbidden)
)
monkeypatch.setattr(subprocess, "run", lambda *a, **kw: _FakeCompleted(stdout=stdout))
v = _check_audit_only_violations()
assert set(v) == {"src/a.py", "src/b.py", "templates/c.html", "tests/d.py"}
def test_renamed_file_destination_checked(self, monkeypatch):
"""rename 의 경우 destination 만 검사."""
stdout = "R docs/old.md -> src/new.py\n"
monkeypatch.setattr(subprocess, "run", lambda *a, **kw: _FakeCompleted(stdout=stdout))
v = _check_audit_only_violations()
assert v == ["src/new.py"]
def test_windows_backslash_path(self, monkeypatch):
"""Windows backslash path 도 forward-slash 로 정규화돼서 매치."""
stdout = " M src\\phase_z2_pipeline.py\n"
monkeypatch.setattr(subprocess, "run", lambda *a, **kw: _FakeCompleted(stdout=stdout))
v = _check_audit_only_violations()
assert v == ["src/phase_z2_pipeline.py"]
def test_quoted_path_with_spaces(self, monkeypatch):
"""공백/특수문자 포함 path 는 quoted — quote strip 후 검사."""
stdout = ' M "src/some file.py"\n'
monkeypatch.setattr(subprocess, "run", lambda *a, **kw: _FakeCompleted(stdout=stdout))
v = _check_audit_only_violations()
assert v == ["src/some file.py"]
def test_git_error_fails_open(self, monkeypatch):
"""git 자체 실패 → 가드 false positive 안 만들고 빈 list 반환."""
monkeypatch.setattr(subprocess, "run",
lambda *a, **kw: _FakeCompleted(stdout="", returncode=128))
assert _check_audit_only_violations() == []
def test_subprocess_exception_fails_open(self, monkeypatch):
"""subprocess.run 자체가 raise 해도 가드 false positive X."""
def boom(*a, **kw): raise RuntimeError("git missing")
monkeypatch.setattr(subprocess, "run", boom)
assert _check_audit_only_violations() == []
# ─────────────────────────────────────────────────────────────────
# AUDIT_ONLY_NOTE constants — sanity
# ─────────────────────────────────────────────────────────────────
# ─────────────────────────────────────────────────────────────────
# P4a: baseline-aware violations
# ─────────────────────────────────────────────────────────────────
class TestBaselineAwareViolations:
def test_baseline_subtraction_removes_preexisting(self, monkeypatch):
"""pre-existing forbidden path 는 baseline 에 있으면 violation 에서 제외."""
stdout = (
" M src/already_dirty.py\n" # baseline 안에 있음 — 제외돼야 함
" M src/new_violation.py\n" # baseline 밖 — violation
)
monkeypatch.setattr(subprocess, "run", lambda *a, **kw: _FakeCompleted(stdout=stdout))
baseline = {"src/already_dirty.py"}
v = _check_audit_only_violations(baseline=baseline)
assert v == ["src/new_violation.py"]
def test_baseline_none_keeps_all(self, monkeypatch):
"""baseline=None 이면 기존 동작 — 모든 forbidden 잡음."""
stdout = " M src/a.py\n M src/b.py\n"
monkeypatch.setattr(subprocess, "run", lambda *a, **kw: _FakeCompleted(stdout=stdout))
v = _check_audit_only_violations(baseline=None)
assert set(v) == {"src/a.py", "src/b.py"}
def test_baseline_empty_set_keeps_all(self, monkeypatch):
"""baseline=set() 이면 모두 새 violation 으로 잡음."""
stdout = " M src/a.py\n"
monkeypatch.setattr(subprocess, "run", lambda *a, **kw: _FakeCompleted(stdout=stdout))
v = _check_audit_only_violations(baseline=set())
assert v == ["src/a.py"]
def test_baseline_filters_all_violations(self, monkeypatch):
"""모든 violation 이 baseline 에 있으면 빈 list 반환 — clean 으로 판정."""
stdout = " M src/a.py\n M templates/b.html\n M tests/c.py\n"
monkeypatch.setattr(subprocess, "run", lambda *a, **kw: _FakeCompleted(stdout=stdout))
baseline = {"src/a.py", "templates/b.html", "tests/c.py"}
v = _check_audit_only_violations(baseline=baseline)
assert v == []
def test_baseline_path_normalized_match(self, monkeypatch):
"""baseline 의 path 는 forward-slash 정규화 형태. Windows backslash 도 매치."""
stdout = " M src\\windows_path.py\n"
monkeypatch.setattr(subprocess, "run", lambda *a, **kw: _FakeCompleted(stdout=stdout))
baseline = {"src/windows_path.py"} # baseline 도 forward-slash 형태로 저장
v = _check_audit_only_violations(baseline=baseline)
assert v == []
# ─────────────────────────────────────────────────────────────────
# P4a: _ensure_audit_baseline / _load_audit_baseline
# ─────────────────────────────────────────────────────────────────
class TestAuditBaselinePersist:
def test_save_and_load_roundtrip(self, monkeypatch, tmp_path):
"""baseline 저장 → 로드 → 동일 path set 반환."""
# Redirect ORCH_DIR to tmp_path for isolation.
monkeypatch.setattr(orchestrator, "ORCH_DIR", tmp_path)
# Mock git status output.
stdout = " M src/a.py\n?? src/b.py\n M docs/c.md\n"
monkeypatch.setattr(subprocess, "run", lambda *a, **kw: _FakeCompleted(stdout=stdout))
_ensure_audit_baseline(999)
loaded = _load_audit_baseline(999)
assert loaded == {"src/a.py", "src/b.py", "docs/c.md"}
def test_ensure_does_not_overwrite_existing(self, monkeypatch, tmp_path):
"""이미 baseline 파일 있으면 덮어쓰지 않음 — resumed run 의 가드 일관성."""
monkeypatch.setattr(orchestrator, "ORCH_DIR", tmp_path)
# First save with one set.
stdout1 = " M src/original.py\n"
monkeypatch.setattr(subprocess, "run", lambda *a, **kw: _FakeCompleted(stdout=stdout1))
_ensure_audit_baseline(999)
# Second call with DIFFERENT git status — should NOT overwrite.
stdout2 = " M src/different.py\n"
monkeypatch.setattr(subprocess, "run", lambda *a, **kw: _FakeCompleted(stdout=stdout2))
_ensure_audit_baseline(999)
loaded = _load_audit_baseline(999)
# Original baseline preserved.
assert loaded == {"src/original.py"}
def test_load_missing_returns_empty_set(self, monkeypatch, tmp_path):
monkeypatch.setattr(orchestrator, "ORCH_DIR", tmp_path)
assert _load_audit_baseline(8888) == set()
def test_load_corrupt_returns_empty_set(self, monkeypatch, tmp_path):
monkeypatch.setattr(orchestrator, "ORCH_DIR", tmp_path)
# Manually write corrupt JSON.
p = tmp_path / "audit_baseline_7777.json"
p.write_text("not valid json {{{", encoding="utf-8")
assert _load_audit_baseline(7777) == set()
def test_load_non_list_returns_empty_set(self, monkeypatch, tmp_path):
"""baseline 파일이 list 가 아닌 다른 JSON (예: dict) 이면 empty set."""
monkeypatch.setattr(orchestrator, "ORCH_DIR", tmp_path)
p = tmp_path / "audit_baseline_6666.json"
p.write_text('{"unexpected": "shape"}', encoding="utf-8")
assert _load_audit_baseline(6666) == set()
# ─────────────────────────────────────────────────────────────────
# P4a: _check_audit_commit_scope — Stage 5 guard
# ─────────────────────────────────────────────────────────────────
class TestAuditCommitScope:
def test_clean_commit_audit_report_only(self, monkeypatch):
"""audit report 파일만 commit 되면 통과."""
stdout = (
"docs/architecture/INTEGRATION-AUDIT-01-REPORT.md\n"
"docs/architecture/INTEGRATION-AUDIT-01-MATRIX.md\n"
)
monkeypatch.setattr(subprocess, "run", lambda *a, **kw: _FakeCompleted(stdout=stdout))
assert _check_audit_commit_scope() == []
def test_backlog_update_allowed(self, monkeypatch):
stdout = (
"docs/architecture/INTEGRATION-AUDIT-01-REPORT.md\n"
"docs/architecture/PHASE-Z-IMPLEMENTATION-ISSUE-BACKLOG.md\n"
)
monkeypatch.setattr(subprocess, "run", lambda *a, **kw: _FakeCompleted(stdout=stdout))
assert _check_audit_commit_scope() == []
def test_src_file_in_commit_detected(self, monkeypatch):
"""audit commit 에 src/ 파일이 끼면 violation."""
stdout = (
"docs/architecture/INTEGRATION-AUDIT-01-REPORT.md\n"
"src/phase_z2_pipeline.py\n"
)
monkeypatch.setattr(subprocess, "run", lambda *a, **kw: _FakeCompleted(stdout=stdout))
v = _check_audit_commit_scope()
assert v == ["src/phase_z2_pipeline.py"]
def test_unrelated_doc_detected(self, monkeypatch):
"""docs/ 라도 audit 관련 아닌 doc 은 violation."""
stdout = (
"docs/architecture/INTEGRATION-AUDIT-01-REPORT.md\n"
"docs/some_other_doc.md\n" # 다른 doc
"docs/architecture/PHASE-Z-PIPELINE-OVERVIEW.md\n" # audit 와 무관한 doc
)
monkeypatch.setattr(subprocess, "run", lambda *a, **kw: _FakeCompleted(stdout=stdout))
v = _check_audit_commit_scope()
assert set(v) == {"docs/some_other_doc.md",
"docs/architecture/PHASE-Z-PIPELINE-OVERVIEW.md"}
def test_git_error_fails_open(self, monkeypatch):
"""git show 자체 실패 → 빈 list (가드가 false positive 만들지 않음)."""
monkeypatch.setattr(subprocess, "run",
lambda *a, **kw: _FakeCompleted(stdout="", returncode=128))
assert _check_audit_commit_scope() == []
def test_windows_backslash_normalized(self, monkeypatch):
"""Windows backslash path 도 forward-slash 정규화 후 glob 매치."""
stdout = "docs\\architecture\\INTEGRATION-AUDIT-01-REPORT.md\n"
monkeypatch.setattr(subprocess, "run", lambda *a, **kw: _FakeCompleted(stdout=stdout))
assert _check_audit_commit_scope() == []
def test_empty_commit_passes(self, monkeypatch):
"""commit 에 파일 변경 없음 (보통 안 일어나지만) — 위반 없음."""
monkeypatch.setattr(subprocess, "run", lambda *a, **kw: _FakeCompleted(stdout=""))
assert _check_audit_commit_scope() == []
# ─────────────────────────────────────────────────────────────────
# P4a: allowed-glob shape sanity
# ─────────────────────────────────────────────────────────────────
class TestAuditCommitAllowedGlobs:
def test_globs_have_audit_marker(self):
"""모든 allowed glob 에 INTEGRATION-AUDIT 또는 BACKLOG 마커 존재."""
for g in AUDIT_ALLOWED_COMMIT_GLOBS:
assert ("INTEGRATION-AUDIT" in g) or ("BACKLOG" in g)
def test_globs_under_docs_architecture(self):
"""모든 allowed path 가 docs/architecture/ 산하 — src/ 등 우발적 허용 차단."""
for g in AUDIT_ALLOWED_COMMIT_GLOBS:
assert g.startswith("docs/architecture/"), f"glob escapes docs/architecture/: {g}"
class TestAuditOnlyConstants:
def test_note_mentions_forbidden_prefixes(self):
for p in AUDIT_ONLY_FORBIDDEN_PREFIXES:
assert p in AUDIT_ONLY_NOTE, f"AUDIT_ONLY_NOTE missing prefix mention: {p}"
def test_note_mentions_allowed_paths(self):
assert "INTEGRATION-AUDIT-*.md" in AUDIT_ONLY_NOTE
assert "PHASE-Z-IMPLEMENTATION-ISSUE-BACKLOG.md" in AUDIT_ONLY_NOTE
def test_note_states_no_code_edit(self):
# "report" 또는 "NOT code" 표현 명시 확인 (LLM 가독성 가드).
lower = AUDIT_ONLY_NOTE.lower()
assert "audit report" in lower or "report writing" in lower
assert "not code" in lower or "no production" in lower
def test_forbidden_prefixes_no_trailing_slash_issues(self):
"""블랙리스트는 startswith 매치 — 'src' (slash 없음) 면 'srcfoo.py' 도 매칭돼서 false positive.
모든 prefix 가 '/' 로 끝나야 함."""
for p in AUDIT_ONLY_FORBIDDEN_PREFIXES:
assert p.endswith("/"), f"prefix '{p}' must end with '/' to avoid false matches"
@@ -0,0 +1,492 @@
"""P5 (2026-05-20) — Dormant trigger guard tests (issue #58, unit u4).
Covers the L3 dormant trigger layer end-to-end:
- u1 — docs/architecture/DORMANT-TRIGGERS.yaml schema + content for the
IMP-16 / IMP-17 / IMP-18 / IMP-19 / IMP-20 axes.
- u2 — scripts/check_dormant_triggers.py file-pattern + content-pattern
matching, manual-evidence skip, followup-linked skip,
false-positive guards, exit-0 standalone invocation.
- u3 — orchestrator._check_dormant_triggers() helper fail-open contract
and the Stage 4→5 _audit_mode() bypass predicate.
- u5 — DORMANT-TRIGGERS.yaml self-documenting header
(governance doc cross-reference test runs after u5 lands).
Each test names the IMP-# trigger it exercises (scope-qualified verification
per the work-principles lock).
Run: pytest -q tests/orchestrator_unit/test_dormant_triggers.py
"""
from __future__ import annotations
import importlib
import json
import subprocess
import sys
from pathlib import Path
import pytest
import yaml
ROOT = Path(__file__).parent.parent.parent
sys.path.insert(0, str(ROOT))
sys.path.insert(0, str(ROOT / "scripts"))
REGISTRY_PATH = ROOT / "docs" / "architecture" / "DORMANT-TRIGGERS.yaml"
GOVERNANCE_PATH = ROOT / "docs" / "architecture" / "PROJECT-INTENT-AND-GOVERNANCE.md"
CHECKER_PATH = ROOT / "scripts" / "check_dormant_triggers.py"
# ─────────────────────────────────────────────────────────────────
# Shared fixtures
# ─────────────────────────────────────────────────────────────────
@pytest.fixture(scope="module")
def registry_entries() -> list[dict]:
"""Parsed registry — used across schema + content tests."""
with REGISTRY_PATH.open("r", encoding="utf-8") as f:
data = yaml.safe_load(f)
assert isinstance(data, list), "registry root must be a YAML list"
return data
@pytest.fixture
def chkmod():
"""Fresh import of the standalone checker module (function-scoped so
monkeypatched module attributes do not leak across tests)."""
if "check_dormant_triggers" in sys.modules:
return importlib.reload(sys.modules["check_dormant_triggers"])
import check_dormant_triggers as m # noqa: WPS433 — runtime import by design
return m
@pytest.fixture
def orch():
"""Orchestrator module under test (u3 helper)."""
import orchestrator as m # noqa: WPS433
return m
# ─────────────────────────────────────────────────────────────────
# u1 — registry yaml schema + content
# ─────────────────────────────────────────────────────────────────
class TestRegistrySchema:
"""u1 — DORMANT-TRIGGERS.yaml exists, parses, and shapes the 5 dormant axes."""
def test_registry_yaml_parses_with_pyyaml(self):
"""Schema sanity (covers all of IMP-16/17/18/19/20): file parses as YAML list."""
assert REGISTRY_PATH.exists(), f"registry missing: {REGISTRY_PATH}"
with REGISTRY_PATH.open("r", encoding="utf-8") as f:
data = yaml.safe_load(f)
assert isinstance(data, list), "registry root must be a YAML list"
def test_registry_has_5_entries_4_active_plus_1_followup_linked(self, registry_entries):
"""Stage 1/2 scope-lock: IMP-16 / IMP-17 / IMP-18 / IMP-19 + IMP-20 followup-linked."""
assert len(registry_entries) == 5
issues = sorted(e["issue"] for e in registry_entries)
assert issues == [16, 17, 18, 19, 20]
def test_registry_required_fields_present(self, registry_entries):
"""Every entry has issue / title / doc / status / trigger / on_trigger (covers IMP-16~20)."""
for e in registry_entries:
assert isinstance(e.get("issue"), int)
assert isinstance(e.get("title"), str) and e["title"]
assert isinstance(e.get("doc"), str) and e["doc"]
assert "status" in e
assert isinstance(e.get("trigger"), dict)
assert isinstance(e.get("on_trigger"), dict)
trig = e["trigger"]
assert "description" in trig
assert isinstance(trig.get("manual_evidence_required"), bool)
class TestImp16Entry:
"""IMP-16 (issue 16) — active watch on src/** reverse-path adapter."""
def test_imp16_active_src_glob_and_reverse_path_content_pattern(self, registry_entries):
"""IMP-16 trigger: src/**/*.py glob + reverse_path / html_to_slide_mdx content."""
e = next(x for x in registry_entries if x["issue"] == 16)
assert e["status"] == "documented:dormant"
assert not e.get("followup_issue"), "IMP-16 is active, not followup-linked"
t = e["trigger"]
assert t["manual_evidence_required"] is False
assert any("src/**" in p for p in t["file_patterns"])
cps = t["content_patterns"]
assert any("reverse_path" in p or "html_to_slide_mdx" in p for p in cps)
class TestImp17Entry:
"""IMP-17 (issue 17) — manual-evidence gate (3-cond User-GO AND)."""
def test_imp17_manual_evidence_required_true(self, registry_entries):
"""IMP-17 trigger gate: manual_evidence_required=true (User GO + B4 + IMP-04/05 live)."""
e = next(x for x in registry_entries if x["issue"] == 17)
assert e["trigger"]["manual_evidence_required"] is True
class TestImp18Entry:
"""IMP-18 (issue 18) — SVG partial under templates/phase_z2/."""
def test_imp18_active_watch_on_phase_z2_templates_with_svg_content(self, registry_entries):
"""IMP-18 trigger: templates/phase_z2/{families,frames}/*.html + SVG signature."""
e = next(x for x in registry_entries if x["issue"] == 18)
assert e["trigger"]["manual_evidence_required"] is False
fps = e["trigger"]["file_patterns"]
assert any("templates/phase_z2/" in p for p in fps)
cps = e["trigger"]["content_patterns"]
assert any("svg" in p.lower() or "viewBox" in p for p in cps)
class TestImp19Entry:
"""IMP-19 (issue 19) — manual-evidence gate (IMP-09 owner sign-off)."""
def test_imp19_manual_evidence_required_true(self, registry_entries):
"""IMP-19 trigger gate: manual_evidence_required=true (failing-case + IMP-09 sign-off)."""
e = next(x for x in registry_entries if x["issue"] == 19)
assert e["trigger"]["manual_evidence_required"] is True
class TestImp20Entry:
"""IMP-20 (issue 20) — followup-linked to open issue #55, note-only."""
def test_imp20_followup_linked_to_55_with_note_only_action(self, registry_entries):
"""IMP-20 status: followup_issue=55, on_trigger.action=note_only (no checker watch)."""
e = next(x for x in registry_entries if x["issue"] == 20)
assert e.get("followup_issue") == 55
assert e["on_trigger"]["action"] == "note_only"
# ─────────────────────────────────────────────────────────────────
# u2 — check_dormant_triggers.py
# ─────────────────────────────────────────────────────────────────
class TestCheckerMatching:
def test_checker_clean_tree_no_alerts_covers_imp16_18(self, chkmod, monkeypatch):
"""False-positive guard: empty change surface → no IMP-16 / IMP-18 alerts."""
monkeypatch.setattr(chkmod, "collect_changed_files", lambda: [])
entries = chkmod.load_registry()
alerts = [a for a in (chkmod.check_entry(e, []) for e in entries) if a]
assert alerts == []
def test_checker_imp16_alert_on_src_reverse_path_adapter(
self, chkmod, monkeypatch, tmp_path
):
"""IMP-16 positive: src/foo/adapter.py with `reverse_path` content → alert."""
fake_path = "src/foo/adapter.py"
(tmp_path / "src" / "foo").mkdir(parents=True)
(tmp_path / fake_path).write_text("def reverse_path(): pass\n", encoding="utf-8")
monkeypatch.setattr(chkmod, "REPO_ROOT", tmp_path)
entries = chkmod.load_registry()
imp16 = next(e for e in entries if e["issue"] == 16)
result = chkmod.check_entry(imp16, [fake_path])
assert result is not None
assert result["issue"] == 16
assert fake_path in result["match"]["files"]
def test_checker_imp16_no_alert_on_tests_path_false_positive_guard(
self, chkmod, monkeypatch, tmp_path
):
"""False-positive guard: IMP-16 must NOT fire on tests/foo.py even with matching content."""
fake_path = "tests/foo.py"
(tmp_path / "tests").mkdir()
(tmp_path / fake_path).write_text("def reverse_path(): pass\n", encoding="utf-8")
monkeypatch.setattr(chkmod, "REPO_ROOT", tmp_path)
entries = chkmod.load_registry()
imp16 = next(e for e in entries if e["issue"] == 16)
assert chkmod.check_entry(imp16, [fake_path]) is None
def test_checker_imp18_alert_on_phase_z2_family_svg(
self, chkmod, monkeypatch, tmp_path
):
"""IMP-18 positive: templates/phase_z2/families/new.html with <svg viewBox> → alert."""
fake_path = "templates/phase_z2/families/new_partial.html"
(tmp_path / "templates" / "phase_z2" / "families").mkdir(parents=True)
(tmp_path / fake_path).write_text(
'<svg viewBox="0 0 100 100"></svg>\n', encoding="utf-8"
)
monkeypatch.setattr(chkmod, "REPO_ROOT", tmp_path)
entries = chkmod.load_registry()
imp18 = next(e for e in entries if e["issue"] == 18)
result = chkmod.check_entry(imp18, [fake_path])
assert result is not None
assert result["issue"] == 18
def test_checker_imp18_flat_glob_boundary_nested_path_skipped(
self, chkmod, monkeypatch, tmp_path
):
"""False-positive guard: IMP-18 flat glob `families/*.html` skips nested family path."""
fake_path = "templates/phase_z2/families/nested/inner.html"
(tmp_path / "templates" / "phase_z2" / "families" / "nested").mkdir(parents=True)
(tmp_path / fake_path).write_text(
'<svg viewBox="0 0 100 100"></svg>\n', encoding="utf-8"
)
monkeypatch.setattr(chkmod, "REPO_ROOT", tmp_path)
entries = chkmod.load_registry()
imp18 = next(e for e in entries if e["issue"] == 18)
assert chkmod.check_entry(imp18, [fake_path]) is None
def test_checker_skips_imp17_manual_evidence(self, chkmod):
"""Guardrail: IMP-17 manual_evidence_required skips even with broad change surface."""
entries = chkmod.load_registry()
imp17 = next(e for e in entries if e["issue"] == 17)
assert chkmod.check_entry(imp17, ["src/foo.py", "anything.html"]) is None
def test_checker_skips_imp19_manual_evidence(self, chkmod):
"""Guardrail: IMP-19 manual_evidence_required skips even with broad change surface."""
entries = chkmod.load_registry()
imp19 = next(e for e in entries if e["issue"] == 19)
assert chkmod.check_entry(imp19, ["src/foo.py"]) is None
def test_checker_skips_imp20_followup_linked(self, chkmod):
"""Guardrail: IMP-20 followup_issue=55 skips (open issue #55 owns the watch)."""
entries = chkmod.load_registry()
imp20 = next(e for e in entries if e["issue"] == 20)
assert chkmod.check_entry(imp20, ["anything", "src/a.py"]) is None
def test_checker_standalone_invocation_exit_0(self):
"""Guardrail: standalone `python scripts/check_dormant_triggers.py` exits 0 always.
Exercises the IMP-16~20 informational-only contract — checker never blocks
the orchestrator regardless of working-tree state.
"""
r = subprocess.run(
[sys.executable, str(CHECKER_PATH)],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
cwd=str(ROOT),
timeout=30,
)
assert r.returncode == 0, f"checker exit={r.returncode} stderr={r.stderr!r}"
# ─────────────────────────────────────────────────────────────────
# u3 — orchestrator helper + Stage 4→5 hook
# ─────────────────────────────────────────────────────────────────
class TestOrchestratorDormantHook:
def test_orchestrator_helper_returns_list_on_clean_tree(self, orch):
"""u3 helper returns list[dict] (possibly empty) — never raises (IMP-16~20 informational)."""
result = orch._check_dormant_triggers()
assert isinstance(result, list)
def test_orchestrator_helper_fail_open_on_subprocess_error(self, orch, monkeypatch):
"""u3 helper: subprocess raise → [] (fail-open, no false positives across all IMPs)."""
def boom(*a, **kw):
raise RuntimeError("subprocess unavailable")
monkeypatch.setattr(orch.subprocess, "run", boom)
assert orch._check_dormant_triggers() == []
def test_orchestrator_helper_fail_open_on_nonzero_exit(self, orch, monkeypatch):
"""u3 helper: subprocess returncode != 0 → [] (fail-open)."""
class _Err:
returncode = 1
stdout = ""
stderr = "boom"
monkeypatch.setattr(orch.subprocess, "run", lambda *a, **kw: _Err())
assert orch._check_dormant_triggers() == []
def test_orchestrator_helper_fail_open_on_missing_alerts_file(
self, orch, monkeypatch, tmp_path
):
"""u3 helper: subprocess OK but alert file absent → [] (fail-open)."""
class _OK:
returncode = 0
stdout = ""
stderr = ""
monkeypatch.setattr(orch.subprocess, "run", lambda *a, **kw: _OK())
monkeypatch.setattr(orch, "ORCH_DIR", tmp_path)
assert orch._check_dormant_triggers() == []
def test_orchestrator_helper_parses_alerts_payload(self, orch, monkeypatch, tmp_path):
"""u3 helper: reads `alerts` list out of .orchestrator/dormant_alerts.json payload."""
class _OK:
returncode = 0
stdout = ""
stderr = ""
monkeypatch.setattr(orch.subprocess, "run", lambda *a, **kw: _OK())
monkeypatch.setattr(orch, "ORCH_DIR", tmp_path)
payload = {
"alerts": [
{"issue": 16, "title": "IMP-16 sample", "on_trigger": {"action": "create_runtime_issue"}},
],
}
(tmp_path / "dormant_alerts.json").write_text(
json.dumps(payload), encoding="utf-8"
)
result = orch._check_dormant_triggers()
assert isinstance(result, list)
assert len(result) == 1
assert result[0]["issue"] == 16
def test_orchestrator_helper_handles_non_list_alerts_payload(
self, orch, monkeypatch, tmp_path
):
"""u3 helper: malformed `alerts` (non-list) → [] (fail-open, IMP-16~20 informational)."""
class _OK:
returncode = 0
stdout = ""
stderr = ""
monkeypatch.setattr(orch.subprocess, "run", lambda *a, **kw: _OK())
monkeypatch.setattr(orch, "ORCH_DIR", tmp_path)
(tmp_path / "dormant_alerts.json").write_text(
json.dumps({"alerts": "oops"}), encoding="utf-8"
)
assert orch._check_dormant_triggers() == []
def test_stage_4_to_5_hook_predicate_audit_bypass(self, orch):
"""u3 Stage 4→5 hook guard: _audit_mode(title) True → dormant checker bypassed.
Mirrors P4a placement — the dormant hook condition is
`sid == "test-verify" and not _audit_mode(title)`. This test asserts
the gating predicate for the IMP-16/18 active watches.
"""
assert orch._audit_mode("[INTEGRATION-AUDIT-02] cumulative review") is True
assert orch._audit_mode("[AUDIT-ONLY] doc consistency") is True
assert orch._audit_mode("[P5][DORMANT-TRIGGER-GUARD] hook") is False
assert orch._audit_mode("IMP-16 U2 wiring") is False
# ─────────────────────────────────────────────────────────────────
# u3 — Stage 4→5 hook integration (focused static assertions on run_stage)
# ─────────────────────────────────────────────────────────────────
class TestRunStageDormantHookIntegration:
"""u3 — Stage 4→5 hook must be wired into ``orchestrator.run_stage``.
Codex #5 rewind: testing ``_audit_mode()`` in isolation is insufficient.
These focused static-source assertions on ``inspect.getsource(run_stage)``
fail if the dormant hook is silently removed or its audit-bypass predicate
is weakened — guarding the IMP-16/17/18/19/20 informational alert wiring.
"""
def _run_stage_src(self, orch) -> str:
import inspect
return inspect.getsource(orch.run_stage)
def test_run_stage_invokes_check_dormant_triggers_on_test_verify_non_audit(self, orch):
"""u3 contract: run_stage body calls _check_dormant_triggers().
Exercises the Stage 4 (test-verify) non-audit invocation path for
IMP-16/18 active watches. If the call disappears from run_stage,
this test fails (catches the regression that Codex #5 flagged).
"""
src = self._run_stage_src(orch)
assert "_check_dormant_triggers()" in src, (
"run_stage must invoke _check_dormant_triggers() — the L3 wiring "
"for IMP-16/17/18/19/20 dormant alerts. Silent removal breaks the "
"Stage 4→5 informational hook."
)
def test_run_stage_dormant_hook_gated_by_test_verify_and_non_audit(self, orch):
"""u3 contract: dormant hook is gated by both sid==test-verify AND not _audit_mode(title).
The predicate is what makes audit-only Stage 4 bypass the IMP-16~20
checker (Stage 1 scope-lock guardrail). Removing either conjunct
would silently re-enable the checker on audit-only issues whose
change surface is restricted to audit-report docs.
"""
import re
src = self._run_stage_src(orch)
m = re.search(
r'sid\s*==\s*"test-verify"\s+and\s+not\s+_audit_mode\(title\)\s*:\s*\n'
r'\s+alerts\s*=\s*_check_dormant_triggers\(\)',
src,
)
assert m is not None, (
"Stage 4→5 dormant hook must be gated by "
'`sid == "test-verify" and not _audit_mode(title):` immediately '
"before `alerts = _check_dormant_triggers()`. Audit-only bypass "
"and Stage 4 placement are part of the IMP-16~20 contract."
)
def test_run_stage_dormant_hook_is_informational_no_continue(self, orch):
"""u3 contract: dormant hook block must NOT contain a `continue` statement.
IMP-16~20 alerts are informational only (Stage 1 guardrail). The hook
block between the _check_dormant_triggers() call and the next
Stage 4 PASS log line ("YES (evidence verified)") must not short-
circuit Stage 5 entry via `continue`. Comments mentioning the word
"continue" are allowed (they document the contract).
"""
import re
src = self._run_stage_src(orch)
start = src.find("_check_dormant_triggers()")
assert start >= 0
end = src.find("YES (evidence verified)", start)
assert end > start, (
"could not locate Stage 4 PASS log line after dormant hook — "
"run_stage shape may have shifted; re-examine integration."
)
hook_block = src[start:end]
# Strip line comments before checking for the `continue` keyword as
# an actual statement — the source intentionally documents the
# informational-only contract with a `# Never continue — ...` comment.
stripped_lines = []
for line in hook_block.splitlines():
code = line.split("#", 1)[0]
stripped_lines.append(code)
code_only = "\n".join(stripped_lines)
assert not re.search(r'(^|\s)continue\b', code_only), (
"dormant hook block contains a `continue` statement — IMP-16~20 "
"alerts must never block Stage 5 entry (informational-only contract)."
)
def test_run_stage_dormant_hook_positioned_before_stage_pass_return(self, orch):
"""u3 contract: dormant hook is positioned within the Stage 4 YES PASS path.
Verifies the hook sits between the P4a audit commit-scope guard and
the Stage 4 success log+return (i.e., on the PASS path), not in an
unreachable branch. Protects against accidental relocation during
future refactors.
"""
src = self._run_stage_src(orch)
hook_pos = src.find("_check_dormant_triggers()")
pass_log_pos = src.find("YES (evidence verified)", hook_pos)
return_true_pos = src.find("return True", hook_pos)
assert hook_pos >= 0
assert 0 < pass_log_pos - hook_pos < 4000, (
"dormant hook is not adjacent to the Stage 4 PASS log line — "
"run_stage shape may have shifted away from PASS-path placement."
)
assert return_true_pos > pass_log_pos, (
"Stage 4 `return True` should follow the PASS log line; "
"dormant hook must precede both."
)
# ─────────────────────────────────────────────────────────────────
# u5 — registry self-documentation + governance cross-reference
# ─────────────────────────────────────────────────────────────────
class TestRegistryHeaderAndGovernanceRef:
def test_registry_header_explains_schema_and_l3_purpose(self):
"""u5 acceptance: yaml header explains schema + L3 informational-only purpose.
The header is the durable self-documentation surface that anchors the
IMP-16/17/18/19/20 registry (per Stage 1 exit unit u5).
"""
text = REGISTRY_PATH.read_text(encoding="utf-8")
assert "Schema" in text or "schema" in text
assert "dormant" in text.lower()
assert "L3" in text or "machine-readable" in text.lower()
assert "Guardrails" in text or "informational" in text.lower()
@pytest.mark.skipif(
not GOVERNANCE_PATH.exists()
or "DORMANT-TRIGGERS.yaml" not in GOVERNANCE_PATH.read_text(encoding="utf-8"),
reason="u5 governance-doc reference line not yet appended (passes after u5 lands).",
)
def test_governance_doc_references_registry(self):
"""u5 deliverable: PROJECT-INTENT-AND-GOVERNANCE.md cites DORMANT-TRIGGERS.yaml as L3.
Runs after u5 lands — IMP-16~20 registry surfaces in the governance
anti-patterns row so future maintainers find it without rediscovery.
"""
text = GOVERNANCE_PATH.read_text(encoding="utf-8")
assert "DORMANT-TRIGGERS.yaml" in text
@@ -0,0 +1,33 @@
"""IMP-17 u1 (2026-05-19) — comment anchor for src/phase_z2_pipeline.py route hint table.
Stage 1 finding: line 564 previously referenced a non-existent ID ("IMP-31").
The legitimate slot is IMP-17 (Gitea #17, carve-out — AI fallback only, normal path 밖).
Line 565 (IMP-29 frontend zone-level override) must remain untouched.
Anchor re-pin (2026-05-20, IMP-30 u1 follow-up): V4Match.provisional field added at
src/phase_z2_pipeline.py:179-184 shifted the route-hint table down by six lines.
Pinned line numbers updated from 564/565 → 570/571 to track the actual anchor location.
Run: pytest -q tests/orchestrator_unit/test_imp17_comment_anchor.py
"""
from pathlib import Path
ROOT = Path(__file__).parent.parent.parent
PIPELINE = ROOT / "src" / "phase_z2_pipeline.py"
def _lines() -> list[str]:
return PIPELINE.read_text(encoding="utf-8").splitlines()
def test_line_570_references_imp17_not_imp31():
line = _lines()[569] # 1-indexed line 570
assert "restructure" in line, f"line 570 anchor drifted: {line!r}"
assert "IMP-17" in line, f"line 570 must reference IMP-17 (carve-out): {line!r}"
assert "IMP-31" not in line, f"line 570 must not reference non-existent IMP-31: {line!r}"
def test_line_571_still_references_imp29():
line = _lines()[570] # 1-indexed line 571
assert "reject" in line, f"line 571 anchor drifted: {line!r}"
assert "IMP-29" in line, f"line 571 must still reference IMP-29 frontend override: {line!r}"
@@ -0,0 +1,348 @@
"""P0-3 (2026-05-18) — orchestrator self-test minimum set.
Covers detect_agent (the bug that caused #45 infinite loop), parse_consensus,
parse_remaining_units, IMPLEMENTATION_UNITS parsing, dual-write normalize.
Run: pytest -q tests/orchestrator_unit/
"""
import sys
from pathlib import Path
# Add design_agent root to sys.path so we can import orchestrator.py
ROOT = Path(__file__).parent.parent.parent
sys.path.insert(0, str(ROOT))
from orchestrator import (
detect_agent,
parse_consensus,
parse_remaining_units,
_is_execution_issue,
)
import re
class TestExecutionIssueDetection:
"""P1-4 — execution sub-issue title detection."""
def test_execution_korean_pattern(self):
assert _is_execution_issue("[IMP-15 실행-1] image_aspect_mismatch") is True
assert _is_execution_issue("[IMP-15 실행-2] table overflow") is True
assert _is_execution_issue("[IMP-15 실행 3] something") is True
def test_execution_english_pattern(self):
assert _is_execution_issue("[IMP-15 exec-1] image") is True
assert _is_execution_issue("[IMP-15 EXEC 2] table") is True
def test_non_execution_title(self):
assert _is_execution_issue("IMP-15 Step 14 visual_check 보강") is False
assert _is_execution_issue("IMP-09 B-4 다른 layout zone-geometry") is False
def test_empty_title(self):
assert _is_execution_issue("") is False
assert _is_execution_issue(None) is False
# ─────────────────────────────────────────────────────────────────
# detect_agent — the bug that caused #45 infinite loop
# ─────────────────────────────────────────────────────────────────
class TestDetectAgent:
def test_claude_header(self):
assert detect_agent("[Claude #1] Stage 1 ...") == "claude"
def test_codex_header(self):
assert detect_agent("[Codex #1] Stage 1 review") == "codex"
def test_codex_body_with_claude_citation(self):
"""The exact bug from #45 — Codex body contains [Claude #N] citation in
EVIDENCE section. Old detect_agent returned 'claude' (wrong)."""
body = """[Codex #2] Stage 2 Round #1 simulation-plan verification
Verdict: NO.
=== EVIDENCE ===
- Read current-stage Gitea comment `[Claude #2] Stage 2 Round #1 - Plan` only
"""
assert detect_agent(body) == "codex", \
"Codex body containing [Claude #N] citation must still detect as codex"
def test_claude_body_with_codex_citation(self):
body = """[Claude #3] Stage 2 Round #2 - Plan
Addressing [Codex #2] findings ...
"""
assert detect_agent(body) == "claude"
def test_empty_body(self):
assert detect_agent("") is None
assert detect_agent(None) is None
assert detect_agent(" \n ") is None
def test_no_agent_header(self):
assert detect_agent("This is some random text without any agent marker") is None
def test_leading_whitespace_before_header(self):
body = " \n[Codex #1] header after whitespace"
assert detect_agent(body) == "codex"
def test_header_must_be_at_start(self):
"""Body that doesn't start with [Agent header should return None."""
body = "Some intro text.\n[Codex #1] header on second line"
# P0-1 fix: only first non-empty line is checked.
# First line = "Some intro text." → no match → None
assert detect_agent(body) is None
def test_header_with_hash_immediately(self):
"""[Codex#1] (no space) should still match per regex \\[Codex[\\s#]."""
assert detect_agent("[Codex#1] hello") == "codex"
assert detect_agent("[Claude#5] hi") == "claude"
def test_audit_anchor_preface_breaks_detection(self):
"""P5 (2026-05-20) — regression: AUDIT-ONLY mode 의 'Audit anchor:' preface 가
첫 줄에 박히면 detect_agent 는 None 반환 (P0-1 strict 의도된 동작).
이게 #56 (INTEGRATION-AUDIT-02) 의 Stage 4 Round #14 infinite loop 의 직접 원인.
해결책 = detect_agent 완화 X, AUDIT_ONLY_NOTE 가 agent header 를 first line 으로 강제."""
body_anchor_first = (
"Audit anchor: This audit verifies pipeline contracts...\n"
"It does not implement runtime code.\n"
"\n"
"[Codex #14] Stage 4 (test-verify) Round #14 - INTEGRATION-AUDIT-02\n"
"\n"
"Verdict: PASS. Stage 3 satisfies all criteria.\n"
"FINAL_CONSENSUS: YES\n"
)
assert detect_agent(body_anchor_first) is None, (
"audit anchor preface as first line MUST cause detect_agent None "
"(P0-1 strict). Fix path: comment format, not detect_agent."
)
def test_audit_anchor_after_header_works(self):
"""P5 (2026-05-20) — 올바른 format: agent header first line, anchor line 2+."""
body_header_first = (
"[Codex #14] Stage 4 (test-verify) Round #14 - INTEGRATION-AUDIT-02\n"
"\n"
"Audit anchor: This audit verifies pipeline contracts...\n"
"\n"
"Verdict: PASS.\n"
"FINAL_CONSENSUS: YES\n"
)
assert detect_agent(body_header_first) == "codex"
# P5b (2026-05-20) — Stage 2 compact-plan first-line conflict regression.
# #24 IMP-24 K6: Codex r1~r3 가 첫 줄을 '=== IMPLEMENTATION_UNITS ===' 로 시작 →
# detect_agent None → orchestrator silent loop. fix path = comment format strict,
# NOT detect_agent 완화 (P0-1 강화 그대로 유지).
def test_implementation_units_first_line_breaks_detection(self):
"""=== IMPLEMENTATION_UNITS === 가 첫 줄이면 detect_agent None (P0-1 strict 정상 동작)."""
body = (
"=== IMPLEMENTATION_UNITS ===\n"
"- id: u1\n"
" summary: ...\n"
" files:\n"
" - docs/architecture/PHASE-Q-AUDIT.md\n"
" tests:\n"
" - pytest -q tests\n"
" estimate_lines: 1\n"
"\n"
"FINAL_CONSENSUS: YES\n"
)
assert detect_agent(body) is None, (
"=== IMPLEMENTATION_UNITS === as first line MUST cause detect_agent None "
"(P0-1 strict). Fix path: enforce agent header first-line in prompt, not relax detect_agent."
)
def test_compact_plan_with_header_first_works(self):
"""올바른 Stage 2 compact format: [Codex #N] 첫 줄 → === IMPLEMENTATION_UNITS === 둘째 줄+."""
body = (
"[Codex #4] Stage 2 simulation-plan review - IMP-24 K6\n"
"\n"
"=== IMPLEMENTATION_UNITS ===\n"
"- id: u1\n"
" summary: ...\n"
" tests:\n"
" - pytest -q tests\n"
"\n"
"FINAL_CONSENSUS: YES\n"
)
assert detect_agent(body) == "codex"
def test_markdown_prefix_breaks_detection(self):
"""P5b — `## [Codex #N]` 같은 markdown header prefix 도 detect_agent None.
(#21 Stage 4 에서 관찰된 latent silent loop 원인.)"""
body_hash = "## [Codex #1] Stage 4 test-verify Round #1\n\nVerdict: PASS\n"
body_emoji = "📌 **[Claude #1] Stage 2 plan**\n\nbody\n"
body_bold = "**[Codex #1] Stage 4**\n\nbody\n"
assert detect_agent(body_hash) is None
assert detect_agent(body_emoji) is None
assert detect_agent(body_bold) is None
class TestRulesAndCompactPlanFirstLineContract:
"""P5b (2026-05-20) — RULES 와 COMPACT_PLAN_RULE 둘 다 first-line agent header
rule 을 명시해야 함. wording 검증."""
def test_rules_has_first_line_strict(self):
from orchestrator import RULES
# RULES 안에 first-line strict + 모든 stage 적용 명시 있어야 함.
assert "FIRST non-empty line" in RULES
assert "[Claude #N]" in RULES and "[Codex #N]" in RULES
# P5b OVERRIDES 키워드 — body rule 들이 first-line rule 보다 우선하지 않음을 강조
assert "OVERRIDES" in RULES or "overrides" in RULES.lower()
def test_compact_plan_rule_carves_out_first_line(self):
from orchestrator import COMPACT_PLAN_RULE
# "body" 는 first-line agent header 다음부터 시작한다고 명시
assert "FIRST non-empty line" in COMPACT_PLAN_RULE or "first-line agent header" in COMPACT_PLAN_RULE
# "after the first-line" 같은 carve-out wording 검증
body_lower = COMPACT_PLAN_RULE.lower()
assert "after the first" in body_lower or "after the agent header" in body_lower
# ─────────────────────────────────────────────────────────────────
# parse_consensus — YES/NO + rewind_target
# ─────────────────────────────────────────────────────────────────
class TestParseConsensus:
def test_yes_only(self):
body = "Some text.\nFINAL_CONSENSUS: YES"
assert parse_consensus(body) == ("YES", None)
def test_no_with_rewind_target(self):
body = "Some text.\nrewind_target: stage_2_plan\nFINAL_CONSENSUS: NO"
assert parse_consensus(body) == ("NO", "stage_2_plan")
def test_no_with_continue_same(self):
body = "blah\nrewind_target: continue_same\nFINAL_CONSENSUS: NO"
assert parse_consensus(body) == ("NO", "continue_same")
def test_no_target_only_in_last_10_lines(self):
"""parse_consensus only scans last 10 lines."""
body = "rewind_target: stage_1_review\n" + "\n".join(["filler"] * 20) + "\nFINAL_CONSENSUS: NO"
status, target = parse_consensus(body)
assert status == "NO"
assert target is None # too far from end to be picked up
def test_no_consensus_marker(self):
assert parse_consensus("just text, no marker") == (None, None)
def test_empty_body(self):
assert parse_consensus("") == (None, None)
assert parse_consensus(None) == (None, None)
def test_unknown_rewind_target_ignored(self):
body = "rewind_target: bogus_target\nFINAL_CONSENSUS: NO"
status, target = parse_consensus(body)
assert status == "NO"
assert target is None # bogus is not in REWIND_TARGET_TO_SID
# ─────────────────────────────────────────────────────────────────
# parse_remaining_units — Stage 3 continue_same progress detection
# ─────────────────────────────────────────────────────────────────
class TestParseRemainingUnits:
def test_bracketed_list(self):
body = "Remaining units: [u2, u3, u4]"
assert parse_remaining_units(body) == {"u2", "u3", "u4"}
def test_comma_list_no_brackets(self):
body = "Remaining units: u5, u6, u7"
assert parse_remaining_units(body) == {"u5", "u6", "u7"}
def test_none_explicit(self):
assert parse_remaining_units("Remaining units: none") == set()
assert parse_remaining_units("Remaining units: []") == set()
assert parse_remaining_units("Remaining units: (none)") == set()
assert parse_remaining_units("Remaining units: -") == set()
def test_line_not_present(self):
assert parse_remaining_units("no remaining units mentioned here") is None
def test_case_insensitive(self):
body = "REMAINING UNITS: [U1, U2]"
assert parse_remaining_units(body) == {"u1", "u2"}
def test_only_u_prefixed_digits(self):
"""Sentence noise ignored — only u\\d+ pattern matched."""
body = "Remaining units: I still need to do u3 and u7 work"
assert parse_remaining_units(body) == {"u3", "u7"}
def test_empty_body(self):
assert parse_remaining_units("") is None
assert parse_remaining_units(None) is None
# ─────────────────────────────────────────────────────────────────
# IMPLEMENTATION_UNITS block parsing (used in Stage 2 YES guard)
# ─────────────────────────────────────────────────────────────────
class TestImplementationUnitsBlock:
"""Reproduces the parser in run_stage Stage 2 YES guard (line ~810)."""
def _parse(self, body):
iu_block_pat = re.compile(
r"===\s*IMPLEMENTATION_UNITS\s*===\s*\n(.*?)(?=\n===\s|\Z)",
re.IGNORECASE | re.DOTALL,
)
iu_unit_pat = re.compile(r"^\s*-\s*id:\s*u\d+", re.IGNORECASE | re.MULTILINE)
m = iu_block_pat.search(body or "")
return bool(m and iu_unit_pat.search(m.group(1)))
def test_valid_block(self):
body = """text
=== IMPLEMENTATION_UNITS ===
- id: u1
summary: ...
- id: u2
summary: ...
"""
assert self._parse(body) is True
def test_empty_block(self):
body = "=== IMPLEMENTATION_UNITS ===\n(no entries)\n"
assert self._parse(body) is False # header but no - id: uN entry
def test_block_missing(self):
body = "just text, no implementation_units"
assert self._parse(body) is False
def test_block_with_only_non_u_entries(self):
body = """=== IMPLEMENTATION_UNITS ===
- id: alpha
summary: ...
"""
assert self._parse(body) is False # 'alpha' is not 'u\\d+'
# ─────────────────────────────────────────────────────────────────
# Direct integration check — the #45 bug case
# ─────────────────────────────────────────────────────────────────
class TestRegressionForIssue45Bug:
"""Verify the exact body shape that caused #45 infinite loop is now handled."""
def test_codex_no_with_claude_citation_full_flow(self):
body = """[Codex #3] Stage 2 Round #2 simulation-plan verification for issue #45
Verdict: NO. The plan covers main axes but violates two Stage 2 requirements.
Findings:
- Unit u1 declares tests: [] in === IMPLEMENTATION_UNITS ===
- xfail-strict mechanism unclear
=== EVIDENCE ===
Commands run:
- git rev-parse HEAD
- Read current-stage Gitea comment `[Claude #3] Stage 2 Round #2 - Plan`
rewind_target: stage_2_plan
FINAL_CONSENSUS: NO
"""
# P0-1 fix: detect_agent reads only first line → "[Codex #3]" → codex
assert detect_agent(body) == "codex", "P0-1 regression test"
# parse_consensus: NO + rewind_target stage_2_plan
status, target = parse_consensus(body)
assert status == "NO"
assert target == "stage_2_plan"
@@ -0,0 +1,310 @@
"""P3-5 (2026-05-18) — subprocess cleanup hardening verification.
Covers:
C1: 정상 종료 → tree 잔류 0
C2: timeout → TimeoutExpired raise + 자손 0
C3: grandchild spawn 후 parent timeout → grandchild 정리
C4: 외부 (orchestrator 가 spawn 안한) 프로세스 보호
C5: _kill_process_tree(self.pid) 호출해도 orchestrator 자살 안 함
C6 (CORE): parent 정상 종료 후 grandchild orphan 정리 — PID 2780 regression
Run: pytest -q tests/orchestrator_unit/test_subprocess_cleanup.py
"""
import os
import sys
import time
import subprocess
from pathlib import Path
import psutil
import pytest
ROOT = Path(__file__).parent.parent.parent
sys.path.insert(0, str(ROOT))
from orchestrator import (
_kill_process_tree,
_kill_tracked,
_run_with_tree_kill,
_proc_signature,
_is_same_process,
_SPAWNED,
)
# ─────────────────────────────────────────────────────────────────
# Helpers
# ─────────────────────────────────────────────────────────────────
def _py():
"""Path to current Python interpreter — used to spawn dummy subprocesses."""
return sys.executable
def _alive(pid):
try:
return psutil.Process(pid).is_running() and psutil.Process(pid).status() != psutil.STATUS_ZOMBIE
except psutil.NoSuchProcess:
return False
# ─────────────────────────────────────────────────────────────────
# Signature helpers
# ─────────────────────────────────────────────────────────────────
class TestSignatureHelpers:
def test_proc_signature_alive(self):
p = psutil.Process(os.getpid())
sig = _proc_signature(p)
assert sig is not None
assert sig[0] == os.getpid()
assert isinstance(sig[1], float)
def test_is_same_process_orch_self_blocked(self):
"""C5 prep — orchestrator 자기 자신은 절대 same-process true 안 됨."""
p = psutil.Process(os.getpid())
sig = _proc_signature(p)
# _is_same_process 가 _ORCH_PID 체크로 False 반환해야 함.
assert _is_same_process(sig[0], sig[1]) is False
def test_is_same_process_dead_pid(self):
# 사용 가능성 낮은 PID 999999 — 거의 확실히 죽음.
assert _is_same_process(999999, time.time()) is False
def test_is_same_process_wrong_create_time(self):
"""PID 재사용 회피 검증 — 같은 PID 라도 create_time 안 맞으면 False."""
# 살아있는 외부 프로세스 빌려서 일부러 어긋난 create_time 으로 호출.
# System Idle 같은 특수 프로세스 (create_time=0) 회피 — 우리가 띄운 dummy 사용.
dummy = subprocess.Popen(
[_py(), "-c", "import time; time.sleep(5)"],
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
)
try:
# 실제 create_time 보다 1 년 전 시각 → 명백한 mismatch.
far_past = time.time() - 365 * 24 * 3600
assert _is_same_process(dummy.pid, far_past) is False
# 맞는 create_time 으로는 True 여야 함 (sanity).
real_ct = psutil.Process(dummy.pid).create_time()
assert _is_same_process(dummy.pid, real_ct) is True
finally:
dummy.kill()
dummy.wait(timeout=5)
# ─────────────────────────────────────────────────────────────────
# C1: 정상 종료 — tree 잔류 0
# ─────────────────────────────────────────────────────────────────
class TestC1_NormalExit:
def test_dummy_short_run_no_residue(self):
r = _run_with_tree_kill(
[_py(), "-c", "import time; time.sleep(0.3)"],
timeout=10,
)
assert r.returncode == 0
# 호출 후 _SPAWNED 에 우리 호출 잔재가 남으면 안 됨 (wrapper 가 discard).
# 다른 테스트 영향 가능성 있어서 set 전체가 0 이 아니어도 됨, 단 우리 잔재 없으면 OK.
# 보수적으로 — 우리 호출 직전에 _SPAWNED 가 비어있었으면 직후에도 비어있어야 함.
assert len(_SPAWNED) == 0
# ─────────────────────────────────────────────────────────────────
# C2: Timeout — TimeoutExpired raise + 자손 정리
# ─────────────────────────────────────────────────────────────────
class TestC2_Timeout:
def test_dummy_long_sleep_times_out(self):
with pytest.raises(subprocess.TimeoutExpired):
_run_with_tree_kill(
[_py(), "-c", "import time; time.sleep(60)"],
timeout=1.5,
)
# raise 후에도 _SPAWNED 우리 잔재 없어야 함 (wrapper finally 가 discard).
assert len(_SPAWNED) == 0
# ─────────────────────────────────────────────────────────────────
# C3: grandchild orphan 정리 — parent timeout path
# ─────────────────────────────────────────────────────────────────
class TestC3_GrandchildTimeoutPath:
def test_grandchild_killed_on_parent_timeout(self):
# parent 가 grandchild 띄우고 자기는 sleep — timeout 으로 강제 종료.
# grandchild 도 정리돼야 함.
# PID 캡처를 위해 grandchild 가 자기 PID 를 파일에 기록.
marker = ROOT / ".orchestrator" / "tmp" / "test_c3_gc_pid.txt"
marker.parent.mkdir(parents=True, exist_ok=True)
if marker.exists(): marker.unlink()
# grandchild 의 stdin/stdout/stderr 를 DEVNULL 로 분리 — production 의 claude.exe→python.exe -
# 케이스와 동일 (grandchild 가 wrapper 의 pipe 핸들 안 상속). 안 그러면 pipe inheritance 로
# communicate() 가 hang.
spawn_code = (
f"import subprocess, time, sys, os; "
f"gc = subprocess.Popen("
f" [sys.executable, '-c', 'import time; time.sleep(60)'], "
f" stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL); "
f"open(r'{marker}', 'w').write(str(gc.pid)); "
f"time.sleep(60)"
)
with pytest.raises(subprocess.TimeoutExpired):
_run_with_tree_kill(
[_py(), "-c", spawn_code],
timeout=3,
)
# marker 파일에서 grandchild PID 읽기.
assert marker.exists(), "grandchild marker not written — parent died too early"
gc_pid = int(marker.read_text().strip())
# 잠시 대기 (cleanup 비동기 가능성) 후 grandchild 죽었는지 확인.
deadline = time.time() + 5
while time.time() < deadline and _alive(gc_pid):
time.sleep(0.2)
assert not _alive(gc_pid), f"grandchild PID {gc_pid} still alive after parent timeout"
# ─────────────────────────────────────────────────────────────────
# C4: 외부 프로세스 보호
# ─────────────────────────────────────────────────────────────────
class TestC4_ExternalProcessProtection:
def test_outsider_not_killed(self):
# 사용자가 직접 띄운 척하는 외부 프로세스 (orchestrator 가 spawn 안 함).
outsider = subprocess.Popen([_py(), "-c", "import time; time.sleep(10)"])
try:
# _kill_tracked 에 외부 PID 의 (잘못된) signature 넘기면 무시돼야 함.
# signature 일치 안 하면 _is_same_process False → kill 안 됨.
wrong_sig = [(outsider.pid, 0.0)] # create_time 안 맞음
cleaned = _kill_tracked(wrong_sig)
assert cleaned == 0
assert _alive(outsider.pid), "outsider killed despite wrong create_time"
finally:
outsider.kill()
outsider.wait(timeout=5)
# ─────────────────────────────────────────────────────────────────
# C5: orchestrator 자살 방지
# ─────────────────────────────────────────────────────────────────
class TestC5_SelfKillProtection:
def test_kill_process_tree_self_pid_noop(self):
"""orchestrator(=pytest) PID 로 _kill_process_tree 호출해도 죽으면 안 됨."""
result = _kill_process_tree(os.getpid())
assert result == 0 # ORCH_PID 검사로 즉시 0 반환
def test_kill_tracked_with_orch_pid_noop(self):
# 일부러 self signature 를 tracked 에 넣어도 _is_same_process False → skip.
self_p = psutil.Process(os.getpid())
self_sig = _proc_signature(self_p)
cleaned = _kill_tracked([self_sig])
assert cleaned == 0 # 자기 자신 보호
# ─────────────────────────────────────────────────────────────────
# C6 (CORE): parent 정상 종료 후 grandchild orphan 정리
# — PID 2780 regression test
# ─────────────────────────────────────────────────────────────────
class TestC6_OrphanGrandchildAfterNormalExit:
"""PID 2780 path: parent 가 정상 exit 했는데 grandchild 만 살아남는 케이스.
monitor thread 가 parent 살아있을 때 grandchild 를 미리 추적해서 finally 에서 정리해야 함."""
def test_grandchild_killed_after_parent_normal_exit(self):
marker = ROOT / ".orchestrator" / "tmp" / "test_c6_gc_pid.txt"
marker.parent.mkdir(parents=True, exist_ok=True)
if marker.exists(): marker.unlink()
# parent 가:
# 1. grandchild 띄움 (DEVNULL 격리 — production claude.exe→python.exe - 과 동등).
# 2. PID 마커에 기록.
# 3. monitor 가 1초 polling 으로 catch 할 시간 확보 (2.5초 sleep).
# 4. 정상 종료.
spawn_code = (
f"import subprocess, time, sys, os; "
f"gc = subprocess.Popen("
f" [sys.executable, '-c', 'import time; time.sleep(60)'], "
f" stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL); "
f"open(r'{marker}', 'w').write(str(gc.pid)); "
f"time.sleep(2.5); "
f"sys.exit(0)"
)
# 정상 종료 (timeout 안 걸림) — wrapper 의 finally cleanup 만으로 grandchild 잡혀야 함.
r = _run_with_tree_kill(
[_py(), "-c", spawn_code],
timeout=15,
)
assert r.returncode == 0, "parent did not exit normally"
# marker 에서 grandchild PID.
assert marker.exists(), "grandchild marker missing"
gc_pid = int(marker.read_text().strip())
# 정리 비동기 가능성 → 짧게 대기 후 확인.
deadline = time.time() + 5
while time.time() < deadline and _alive(gc_pid):
time.sleep(0.2)
assert not _alive(gc_pid), (
f"REGRESSION: grandchild PID {gc_pid} survived parent normal exit "
f"(PID 2780 path not fixed)"
)
# ─────────────────────────────────────────────────────────────────
# C7: input + encoding path — run_claude 가 실제 사용하는 호출 모드.
# 2026-05-18 production bug: str input + encoding="utf-8" 일 때
# wrapper 가 input 을 강제로 bytes 인코딩 → Popen text mode pipe 에
# bytes 쓰려다 TypeError: write() argument must be str, not bytes.
# ─────────────────────────────────────────────────────────────────
class TestC7_InputEncodingPath:
def test_str_input_with_encoding_utf8(self):
"""run_claude 와 동일한 호출 모드 — input=str + encoding='utf-8'."""
# stdin 에서 읽은 그대로 stdout 으로 echo. 한글 포함해서 encoding 검증.
r = _run_with_tree_kill(
[_py(), "-c", "import sys; sys.stdout.write(sys.stdin.read())"],
input="hello 안녕\n",
encoding="utf-8",
timeout=10,
)
assert r.returncode == 0
# encoding= 모드면 stdout 는 str 이어야 함.
assert isinstance(r.stdout, str)
assert "hello" in r.stdout
assert "안녕" in r.stdout
def test_bytes_input_without_encoding(self):
"""encoding 없으면 binary mode — input=bytes 그대로 통과."""
r = _run_with_tree_kill(
[_py(), "-c", "import sys; sys.stdout.buffer.write(sys.stdin.buffer.read())"],
input=b"raw bytes",
timeout=10,
)
assert r.returncode == 0
assert isinstance(r.stdout, bytes)
assert r.stdout == b"raw bytes"
def test_str_input_without_encoding_auto_encoded(self):
"""input=str 인데 encoding 없으면 wrapper 가 자동 utf-8 인코딩."""
r = _run_with_tree_kill(
[_py(), "-c", "import sys; sys.stdout.buffer.write(sys.stdin.buffer.read())"],
input="auto encode 한글",
timeout=10,
)
assert r.returncode == 0
assert isinstance(r.stdout, bytes)
assert r.stdout.decode("utf-8") == "auto encode 한글"
# ─────────────────────────────────────────────────────────────────
# Bonus: _SPAWNED discipline — 다중 호출 후 누적 안 됨
# ─────────────────────────────────────────────────────────────────
class TestSpawnedDiscipline:
def test_spawned_drained_between_calls(self):
for _ in range(3):
_run_with_tree_kill([_py(), "-c", "pass"], timeout=10)
# 3 회 호출 후에도 우리 잔재 없음 (wrapper finally 가 discard).
assert len(_SPAWNED) == 0
@@ -0,0 +1,43 @@
input:
layout_preset: grid-2x2
zones_data:
- position: top-left
template_id: MOCK_top-left
content_weight:
score: 0.25
min_height_px: 200
- position: top-right
template_id: MOCK_top-right
content_weight:
score: 0.25
min_height_px: 200
- position: bottom-left
template_id: MOCK_bottom-left
content_weight:
score: 0.25
min_height_px: 200
- position: bottom-right
template_id: MOCK_bottom-right
content_weight:
score: 0.25
min_height_px: 200
override_zone_geometries: null
expected_layout_css:
areas: '"top-left top-right" "bottom-left bottom-right"'
cols: 583px 583px
rows: 286px 285px
heights_px:
- 286
- 285
widths_px:
- 583
- 583
ratios:
- 0.489
- 0.487
width_ratios:
- 0.494
- 0.494
computation: 2d_dynamic_aggregated
dynamic_rows: true
dynamic_cols: true
@@ -0,0 +1,63 @@
input:
layout_preset: grid-2x2
zones_data:
- position: top-left
template_id: MOCK_top-left
content_weight:
score: 0.25
min_height_px: 200
- position: top-right
template_id: MOCK_top-right
content_weight:
score: 0.25
min_height_px: 200
- position: bottom-left
template_id: MOCK_bottom-left
content_weight:
score: 0.25
min_height_px: 200
- position: bottom-right
template_id: MOCK_bottom-right
content_weight:
score: 0.25
min_height_px: 200
override_zone_geometries:
top-left:
x: 0
y: 0
w: 0.55
h: 0.4
top-right:
x: 0.55
y: 0
w: 0.45
h: 0.4
bottom-left:
x: 0
y: 0.4
w: 0.55
h: 0.6
bottom-right:
x: 0.55
y: 0.4
w: 0.45
h: 0.6
expected_layout_css:
areas: '"top-left top-right" "bottom-left bottom-right"'
cols: 641px 525px
rows: 228px 343px
heights_px:
- 228
- 343
widths_px:
- 641
- 525
ratios:
- 0.4
- 0.6
width_ratios:
- 0.55
- 0.45
computation: user_override_geometry
dynamic_rows: true
dynamic_cols: true
@@ -0,0 +1,38 @@
input:
layout_preset: left-1-right-2
zones_data:
- position: left
template_id: MOCK_left
content_weight:
score: 0.5
min_height_px: 200
- position: right-top
template_id: MOCK_right-top
content_weight:
score: 0.25
min_height_px: 200
- position: right-bottom
template_id: MOCK_right-bottom
content_weight:
score: 0.25
min_height_px: 200
override_zone_geometries: null
expected_layout_css:
areas: '"left right-top" "left right-bottom"'
cols: 777px 389px
rows: 286px 285px
heights_px:
- 286
- 285
widths_px:
- 777
- 389
ratios:
- 0.489
- 0.487
width_ratios:
- 0.658
- 0.33
computation: 2d_dynamic_aggregated
dynamic_rows: true
dynamic_cols: true
@@ -0,0 +1,53 @@
input:
layout_preset: left-1-right-2
zones_data:
- position: left
template_id: MOCK_left
content_weight:
score: 0.5
min_height_px: 200
- position: right-top
template_id: MOCK_right-top
content_weight:
score: 0.25
min_height_px: 200
- position: right-bottom
template_id: MOCK_right-bottom
content_weight:
score: 0.25
min_height_px: 200
override_zone_geometries:
left:
x: 0
y: 0
w: 0.4
h: 1.0
right-top:
x: 0.4
y: 0
w: 0.6
h: 0.5
right-bottom:
x: 0.4
y: 0.5
w: 0.6
h: 0.5
expected_layout_css:
areas: '"left right-top" "left right-bottom"'
cols: 466px 700px
rows: 286px 285px
heights_px:
- 286
- 285
widths_px:
- 466
- 700
ratios:
- 0.5
- 0.5
width_ratios:
- 0.4
- 0.6
computation: user_override_geometry
dynamic_rows: true
dynamic_cols: true
@@ -0,0 +1,38 @@
input:
layout_preset: left-2-right-1
zones_data:
- position: left-top
template_id: MOCK_left-top
content_weight:
score: 0.25
min_height_px: 200
- position: left-bottom
template_id: MOCK_left-bottom
content_weight:
score: 0.25
min_height_px: 200
- position: right
template_id: MOCK_right
content_weight:
score: 0.5
min_height_px: 200
override_zone_geometries: null
expected_layout_css:
areas: '"left-top right" "left-bottom right"'
cols: 389px 777px
rows: 286px 285px
heights_px:
- 286
- 285
widths_px:
- 389
- 777
ratios:
- 0.489
- 0.487
width_ratios:
- 0.33
- 0.658
computation: 2d_dynamic_aggregated
dynamic_rows: true
dynamic_cols: true
@@ -0,0 +1,53 @@
input:
layout_preset: left-2-right-1
zones_data:
- position: left-top
template_id: MOCK_left-top
content_weight:
score: 0.25
min_height_px: 200
- position: left-bottom
template_id: MOCK_left-bottom
content_weight:
score: 0.25
min_height_px: 200
- position: right
template_id: MOCK_right
content_weight:
score: 0.5
min_height_px: 200
override_zone_geometries:
left-top:
x: 0
y: 0
w: 0.35
h: 0.6
left-bottom:
x: 0
y: 0.6
w: 0.35
h: 0.4
right:
x: 0.35
y: 0
w: 0.65
h: 1.0
expected_layout_css:
areas: '"left-top right" "left-bottom right"'
cols: 408px 758px
rows: 343px 228px
heights_px:
- 343
- 228
widths_px:
- 408
- 758
ratios:
- 0.6
- 0.4
width_ratios:
- 0.35
- 0.65
computation: user_override_geometry
dynamic_rows: true
dynamic_cols: true
@@ -0,0 +1,38 @@
input:
layout_preset: top-1-bottom-2
zones_data:
- position: top
template_id: MOCK_top
content_weight:
score: 0.5
min_height_px: 200
- position: bottom-left
template_id: MOCK_bottom-left
content_weight:
score: 0.25
min_height_px: 200
- position: bottom-right
template_id: MOCK_bottom-right
content_weight:
score: 0.25
min_height_px: 200
override_zone_geometries: null
expected_layout_css:
areas: '"top top" "bottom-left bottom-right"'
cols: 583px 583px
rows: 314px 257px
heights_px:
- 314
- 257
widths_px:
- 583
- 583
ratios:
- 0.537
- 0.439
width_ratios:
- 0.494
- 0.494
computation: 2d_dynamic_aggregated
dynamic_rows: true
dynamic_cols: true
@@ -0,0 +1,53 @@
input:
layout_preset: top-1-bottom-2
zones_data:
- position: top
template_id: MOCK_top
content_weight:
score: 0.5
min_height_px: 200
- position: bottom-left
template_id: MOCK_bottom-left
content_weight:
score: 0.25
min_height_px: 200
- position: bottom-right
template_id: MOCK_bottom-right
content_weight:
score: 0.25
min_height_px: 200
override_zone_geometries:
top:
x: 0
y: 0
w: 1.0
h: 0.3
bottom-left:
x: 0
y: 0.3
w: 0.5
h: 0.7
bottom-right:
x: 0.5
y: 0.3
w: 0.5
h: 0.7
expected_layout_css:
areas: '"top top" "bottom-left bottom-right"'
cols: 583px 583px
rows: 171px 400px
heights_px:
- 171
- 400
widths_px:
- 583
- 583
ratios:
- 0.3
- 0.7
width_ratios:
- 0.5
- 0.5
computation: user_override_geometry
dynamic_rows: true
dynamic_cols: true
@@ -0,0 +1,38 @@
input:
layout_preset: top-2-bottom-1
zones_data:
- position: top-left
template_id: MOCK_top-left
content_weight:
score: 0.25
min_height_px: 200
- position: top-right
template_id: MOCK_top-right
content_weight:
score: 0.25
min_height_px: 200
- position: bottom
template_id: MOCK_bottom
content_weight:
score: 0.5
min_height_px: 200
override_zone_geometries: null
expected_layout_css:
areas: '"top-left top-right" "bottom bottom"'
cols: 583px 583px
rows: 257px 314px
heights_px:
- 257
- 314
widths_px:
- 583
- 583
ratios:
- 0.439
- 0.537
width_ratios:
- 0.494
- 0.494
computation: 2d_dynamic_aggregated
dynamic_rows: true
dynamic_cols: true
@@ -0,0 +1,53 @@
input:
layout_preset: top-2-bottom-1
zones_data:
- position: top-left
template_id: MOCK_top-left
content_weight:
score: 0.25
min_height_px: 200
- position: top-right
template_id: MOCK_top-right
content_weight:
score: 0.25
min_height_px: 200
- position: bottom
template_id: MOCK_bottom
content_weight:
score: 0.5
min_height_px: 200
override_zone_geometries:
top-left:
x: 0
y: 0
w: 0.6
h: 0.4
top-right:
x: 0.6
y: 0
w: 0.4
h: 0.4
bottom:
x: 0
y: 0.4
w: 1.0
h: 0.6
expected_layout_css:
areas: '"top-left top-right" "bottom bottom"'
cols: 700px 466px
rows: 228px 343px
heights_px:
- 228
- 343
widths_px:
- 700
- 466
ratios:
- 0.4
- 0.6
width_ratios:
- 0.6
- 0.4
computation: user_override_geometry
dynamic_rows: true
dynamic_cols: true
@@ -0,0 +1,24 @@
case_id: grid-2x2_dynamic_2d
description: |
grid-2x2 (2x2 topology) is promoted to 2-D dynamic in IMP-09 PR 2.
Row-axis retry MUST be skipped by the gate with the
"dynamic_cols (2-D topology)" reason.
input_layout_css:
areas: '"top-left top-right" "bottom-left bottom-right"'
cols: 583px 583px
rows: 286px 285px
heights_px: [286, 285]
widths_px: [583, 583]
ratios: [0.489, 0.487]
width_ratios: [0.494, 0.494]
dynamic_rows: true
dynamic_cols: true
router_decision:
router_active: true
proposed_actions_summary: [zone_ratio_retry]
expected_gate:
retry_attempted: false
retry_skipped_reason_contains:
- "dynamic_cols"
- "2-D"
- "IMP-09"
@@ -0,0 +1,24 @@
case_id: left-1-right-2_dynamic_2d
description: |
left-1-right-2 (side-T-left topology) is promoted to 2-D dynamic in
IMP-09 PR 2. Row-axis retry MUST be skipped by the gate with the
"dynamic_cols (2-D topology)" reason.
input_layout_css:
areas: '"left right-top" "left right-bottom"'
cols: 777px 389px
rows: 286px 285px
heights_px: [286, 285]
widths_px: [777, 389]
ratios: [0.489, 0.487]
width_ratios: [0.658, 0.33]
dynamic_rows: true
dynamic_cols: true
router_decision:
router_active: true
proposed_actions_summary: [zone_ratio_retry]
expected_gate:
retry_attempted: false
retry_skipped_reason_contains:
- "dynamic_cols"
- "2-D"
- "IMP-09"
@@ -0,0 +1,24 @@
case_id: left-2-right-1_dynamic_2d
description: |
left-2-right-1 (side-T-right topology) is promoted to 2-D dynamic in
IMP-09 PR 2. Row-axis retry MUST be skipped by the gate with the
"dynamic_cols (2-D topology)" reason.
input_layout_css:
areas: '"left-top right" "left-bottom right"'
cols: 389px 777px
rows: 286px 285px
heights_px: [286, 285]
widths_px: [389, 777]
ratios: [0.489, 0.487]
width_ratios: [0.33, 0.658]
dynamic_rows: true
dynamic_cols: true
router_decision:
router_active: true
proposed_actions_summary: [zone_ratio_retry]
expected_gate:
retry_attempted: false
retry_skipped_reason_contains:
- "dynamic_cols"
- "2-D"
- "IMP-09"
@@ -0,0 +1,26 @@
case_id: top-1-bottom-2_dynamic_2d
description: |
top-1-bottom-2 (T topology) is promoted to 2-D dynamic in IMP-09
PR 2 (dynamic_rows=True, dynamic_cols=True). Row-axis retry MUST be
skipped by the IMP-09 gate with the "dynamic_cols (2-D topology)"
skip reason, because row-only redistribution cannot reconcile both
axes simultaneously.
input_layout_css:
areas: '"top top" "bottom-left bottom-right"'
cols: 583px 583px
rows: 314px 257px
heights_px: [314, 257]
widths_px: [583, 583]
ratios: [0.537, 0.439]
width_ratios: [0.494, 0.494]
dynamic_rows: true
dynamic_cols: true
router_decision:
router_active: true
proposed_actions_summary: [zone_ratio_retry]
expected_gate:
retry_attempted: false
retry_skipped_reason_contains:
- "dynamic_cols"
- "2-D"
- "IMP-09"
@@ -0,0 +1,24 @@
case_id: top-2-bottom-1_dynamic_2d
description: |
top-2-bottom-1 (inverted-T topology) is promoted to 2-D dynamic in
IMP-09 PR 2. Row-axis retry MUST be skipped by the gate with the
"dynamic_cols (2-D topology)" reason.
input_layout_css:
areas: '"top-left top-right" "bottom bottom"'
cols: 583px 583px
rows: 257px 314px
heights_px: [257, 314]
widths_px: [583, 583]
ratios: [0.439, 0.537]
width_ratios: [0.494, 0.494]
dynamic_rows: true
dynamic_cols: true
router_decision:
router_active: true
proposed_actions_summary: [zone_ratio_retry]
expected_gate:
retry_attempted: false
retry_skipped_reason_contains:
- "dynamic_cols"
- "2-D"
- "IMP-09"
+8 -8
View File
@@ -138,21 +138,21 @@ def test_vertical_2_override_keeps_fr_cols_legacy():
assert result["width_ratios"] == [0.4, 0.6] assert result["width_ratios"] == [0.4, 0.6]
# ────────────────────── fr_default sink (PR 1) ────────────────────── # ────────────────────── 2-D dynamic dispatch (PR 2) ──────────────────────
def test_top_1_bottom_2_fr_default_populates_geometry(): def test_top_1_bottom_2_dynamic_2d_populates_geometry():
"""T-shape (top-1-bottom-2) falls through to fr_default in PR 1 """T-shape (top-1-bottom-2) is dispatched through the 2-D dynamic
but heights_px / widths_px must be populated (length-locked to builder in PR 2: heights_px / widths_px length-locked to grid
grid R=2, C=2).""" R=2, C=2 with both dynamic flags True."""
zones = [ zones = [
_zone("top", 0.5), _zone("top", 0.5),
_zone("bottom-left", 0.25), _zone("bottom-left", 0.25),
_zone("bottom-right", 0.25), _zone("bottom-right", 0.25),
] ]
result = build_layout_css("top-1-bottom-2", zones) result = build_layout_css("top-1-bottom-2", zones)
assert result["computation"] == "fr_default_from_preset" assert result["computation"] == "2d_dynamic_aggregated"
assert result["dynamic_rows"] is False assert result["dynamic_rows"] is True
assert result["dynamic_cols"] is False assert result["dynamic_cols"] is True
assert len(result["heights_px"]) == 2 # R rows assert len(result["heights_px"]) == 2 # R rows
assert len(result["widths_px"]) == 2 # C cols assert len(result["widths_px"]) == 2 # C cols
@@ -0,0 +1,81 @@
"""IMP-15 실행-4 (Gitea issue #48) — debug.json top-level event surfacing.
Verifies ``write_debug_json`` lifts ``image_events`` + ``table_events`` out of
``visual_runtime_check`` and exposes them as top-level keys, mirroring the
existing ``zone_geometries_px`` precedent (src/phase_z2_pipeline.py:2739).
Two scenarios:
* Populated — ``visual_runtime_check`` carries non-empty event lists; the
written debug dict surfaces both at the top level with identical payloads.
* None — ``visual_runtime_check is None``; both top-level keys default to ``[]``
(no KeyError, no propagated None).
"""
from __future__ import annotations
import json
from pathlib import Path
from src.phase_z2_pipeline import write_debug_json
def _read_debug(run_dir: Path) -> dict:
return json.loads((run_dir / "debug.json").read_text(encoding="utf-8"))
def test_write_debug_json_surfaces_image_and_table_events(tmp_path: Path) -> None:
image_events = [
{
"src": "img/a.png",
"zone_position": "primary",
"zone_template_id": "tid-1",
"natural_w": 200,
"natural_h": 100,
"rendered_w": 200,
"rendered_h": 200,
"delta": 1.0,
}
]
table_events = [
{
"zone_position": "secondary",
"zone_template_id": "tid-2",
"clientWidth": 300,
"scrollWidth": 360,
"excess_x": 60,
"wrapper_clipped_index": 0,
}
]
visual_runtime_check = {
"image_events": image_events,
"table_events": table_events,
"zone_geometries_px": [],
}
write_debug_json(
run_dir=tmp_path,
layout_preset="single",
debug_zones=[],
layout_css={},
visual_runtime_check=visual_runtime_check,
)
debug = _read_debug(tmp_path)
assert "image_events" in debug, "image_events must be a top-level key"
assert "table_events" in debug, "table_events must be a top-level key"
assert debug["image_events"] == image_events
assert debug["table_events"] == table_events
def test_write_debug_json_defaults_when_visual_runtime_check_none(tmp_path: Path) -> None:
write_debug_json(
run_dir=tmp_path,
layout_preset="single",
debug_zones=[],
layout_css={},
visual_runtime_check=None,
)
debug = _read_debug(tmp_path)
assert debug["image_events"] == []
assert debug["table_events"] == []
@@ -0,0 +1,89 @@
"""IMP-12 u11 — plan_cross_zone_redistribute tests.
Stage 2 contract (unit u11):
- multi-role zone feasible (deficit role + surplus role in the same zone)
- single-role zone infeasible reason (no peer to donate surplus)
u4 wraps fit_verifier.redistribute() in the Step-17 plan signature; feasibility
depends on whether deficit roles can be covered by surplus roles within the
same container.zone group (see src/fit_verifier.py:496-590). The plan exposes
role_heights_before / role_heights_after and surfaces the
'can_redistribute=False — single-role zone(s)' substring when redistribution
is impossible. The apply helper must scope output to [data-role=...] only
(feedback_phase_z_spacing_direction — no :root / body / .slide / .zone).
"""
from __future__ import annotations
from src.fit_verifier import FitAnalysis, RoleFit
from src.phase_z2_retry import (
apply_cross_zone_redistribute_css,
plan_cross_zone_redistribute,
)
def _fit(roles: dict[str, tuple[float, float]]) -> FitAnalysis:
"""roles dict = {role: (allocated_px, shortfall_px)}.
Sign convention matches fit_verifier.redistribute: shortfall_px > 0 = deficit,
shortfall_px < 0 = surplus (usable = abs(shortfall) - min_margin_px).
"""
return FitAnalysis(
roles={
name: RoleFit(role=name, allocated_px=alloc, shortfall_px=short)
for name, (alloc, short) in roles.items()
}
)
def test_multi_role_zone_feasible():
"""Two roles in the same zone — deficit covered by surplus → feasible."""
fit = _fit({"top": (200.0, 30.0), "bottom_l": (300.0, -50.0)})
containers = {
"top": {"zone": "slide_body", "height_px": 200},
"bottom_l": {"zone": "slide_body", "height_px": 300},
}
plan = plan_cross_zone_redistribute(
fit_analysis=fit, containers=containers, min_margin_px=10.0,
)
assert plan["action"] == "cross_zone_redistribute"
assert plan["feasible"] is True
assert plan["can_redistribute"] is True
assert plan["role_heights_before"] == {"top": 200.0, "bottom_l": 300.0}
after = plan["role_heights_after"]
# deficit (30) shifts top up, surplus (50-margin=40) shifts bottom_l down by 30.
assert after["top"] > 200.0
assert after["bottom_l"] < 300.0
assert abs((after["top"] - 200.0) - (300.0 - after["bottom_l"])) < 1.0
css = apply_cross_zone_redistribute_css(plan)
assert '[data-role="top"]' in css
assert '[data-role="bottom_l"]' in css
# Scope lock — no global rules emitted.
for forbidden in (":root", "body", ".slide", ".zone"):
assert forbidden not in css
def test_single_role_zone_infeasible_reason():
"""Lone role in a zone has no peer to donate surplus → infeasible."""
fit = _fit({"top": (200.0, 30.0)})
containers = {"top": {"zone": "slide_body", "height_px": 200}}
plan = plan_cross_zone_redistribute(
fit_analysis=fit, containers=containers, min_margin_px=10.0,
)
assert plan["feasible"] is False
assert plan["can_redistribute"] is False
reason = plan["failure_reason"]
assert "single-role zone" in reason
assert "can_redistribute=False" in reason
# apply emits nothing when infeasible.
assert apply_cross_zone_redistribute_css(plan) == ""
def test_empty_fit_analysis_infeasible():
"""No roles at all → defensive infeasible (cannot redistribute nothing)."""
plan = plan_cross_zone_redistribute(
fit_analysis=FitAnalysis(roles={}), containers={}, min_margin_px=10.0,
)
assert plan["feasible"] is False
assert plan["role_heights_before"] == {}
assert "no roles" in plan["failure_reason"]
assert apply_cross_zone_redistribute_css(plan) == ""
@@ -0,0 +1,119 @@
"""IMP-12 u14 — failure_router cascade tests.
Stage 2 contract (unit u14):
- donor_slack_insufficient → cross_zone_redistribute (impl=IMPLEMENTED)
- 3 new failure types (cross_zone_redistribute_insufficient,
glue_absorption_insufficient, font_step_insufficient) all route to
expected next actions per the locked NEXT_ACTION_BY_FAILURE table
- rerender_still_fails preserved → frame_reselect
u2 (classifier) inspects retry_trace["salvage_steps"][-1] for the 3 new
salvage failure types via SALVAGE_FAILURE_TYPE_BY_ACTION; u3 wires those
failure types onto the deterministic cascade in NEXT_ACTION_BY_FAILURE.
u7 records the cascade actions as IMPLEMENTED in NEXT_ACTION_IMPLEMENTATION_STATUS.
"""
from __future__ import annotations
from src.phase_z2_failure_router import (
NEXT_ACTION_BY_FAILURE,
NEXT_ACTION_IMPLEMENTATION_STATUS,
classify_retry_failure,
enrich_retry_trace_with_failure_classification,
route_retry_failure,
)
def test_donor_slack_insufficient_routes_to_cross_zone_redistribute_implemented():
"""Stage 1 root cause — primary donor slack insufficient classifies as
donor_slack_insufficient and routes onto the deterministic salvage cascade
starting with cross_zone_redistribute (IMPLEMENTED per u7)."""
trace = {
"retry_attempted": True,
"retry_passed": False,
"plan": {
"feasible": False,
"failure_reason": (
"primary donor 'bottom' slack 15px (aggregate 25px from 2 donor(s)) "
"< target_added_px 70px"
),
},
}
fc = classify_retry_failure(trace)
assert fc is not None
assert fc["failure_type"] == "donor_slack_insufficient"
nr = route_retry_failure("donor_slack_insufficient")
assert nr["next_proposed_action"] == "cross_zone_redistribute"
assert nr["next_action_implementation_status"] == "IMPLEMENTED"
# enrichment composes both fields onto the trace
enrich_retry_trace_with_failure_classification(trace)
assert trace["failure_classification"]["failure_type"] == "donor_slack_insufficient"
assert trace["next_action_proposal"]["next_proposed_action"] == "cross_zone_redistribute"
def test_no_donor_candidates_routes_to_cross_zone_redistribute_implemented():
"""no_donor_candidates is the second cascade entry — also onto
cross_zone_redistribute per the locked mapping."""
trace = {
"retry_attempted": True,
"retry_passed": False,
"plan": {"feasible": False, "failure_reason": "no donor candidates"},
}
fc = classify_retry_failure(trace)
assert fc["failure_type"] == "no_donor_candidates"
nr = route_retry_failure("no_donor_candidates")
assert nr["next_proposed_action"] == "cross_zone_redistribute"
assert nr["next_action_implementation_status"] == "IMPLEMENTED"
def test_three_new_salvage_failure_types_route_to_expected_cascade_actions():
"""u2 classifier inspects salvage_steps[-1]. u3 routes the 3 new failure
types through the deterministic cascade: cross_zone → glue → font_step →
layout_adjust. Verifies the locked NEXT_ACTION_BY_FAILURE table directly
and via the classifier path."""
# Direct mapping (u3 lock)
assert NEXT_ACTION_BY_FAILURE["cross_zone_redistribute_insufficient"] == "glue_compression"
assert NEXT_ACTION_BY_FAILURE["glue_absorption_insufficient"] == "font_step_compression"
assert NEXT_ACTION_BY_FAILURE["font_step_insufficient"] == "layout_adjust"
# Implementation status (u7): 2 cascade entries IMPLEMENTED, layout_adjust MISSING
assert NEXT_ACTION_IMPLEMENTATION_STATUS["glue_compression"] == "IMPLEMENTED"
assert NEXT_ACTION_IMPLEMENTATION_STATUS["font_step_compression"] == "IMPLEMENTED"
assert NEXT_ACTION_IMPLEMENTATION_STATUS["layout_adjust"] == "MISSING"
# Classifier path via salvage_steps[-1].action → failure_type → next action
cases = [
("cross_zone_redistribute", "cross_zone_redistribute_insufficient", "glue_compression"),
("glue_compression", "glue_absorption_insufficient", "font_step_compression"),
("font_step_compression", "font_step_insufficient", "layout_adjust"),
]
for action, expected_ftype, expected_next in cases:
trace = {
"retry_attempted": True,
"retry_passed": False,
"salvage_passed": False,
"salvage_steps": [
{"action": action, "passed": False, "failure_reason": "salvage failed"}
],
}
fc = classify_retry_failure(trace)
assert fc is not None, f"classifier returned None for action={action}"
assert fc["failure_type"] == expected_ftype
nr = route_retry_failure(fc["failure_type"])
assert nr["next_proposed_action"] == expected_next
def test_rerender_still_fails_preserved_routes_to_frame_reselect():
"""Pre-cascade behavior preserved: when plan was feasible and rerender ran
but visual still failed, classifier emits rerender_still_fails → frame_reselect."""
trace = {
"retry_attempted": True,
"retry_passed": False,
"plan": {"feasible": True},
"rerender_attempted": True,
}
fc = classify_retry_failure(trace)
assert fc["failure_type"] == "rerender_still_fails"
nr = route_retry_failure("rerender_still_fails")
assert nr["next_proposed_action"] == "frame_reselect"
@@ -0,0 +1,77 @@
"""IMP-12 u13 — plan_font_step_compression tests.
Stage 2 contract (unit u13):
- feasible case (15.2 → 13 closes excess)
- infeasible (8px floor — FONT_SIZE_STEPS exhausted)
- text_metrics missing → defensive infeasible reason
u6 wraps space_allocator.find_fitting_font_size in the Step-17 plan signature.
Height savings per candidate font_size (Korean 1.6 line-height):
height_saved = (current_font_px * 1.6 - font_size * 1.6) * available_lines
Scope lock per feedback_phase_z_spacing_direction:
- apply_font_step_compression_css emits ONLY [data-zone-position="<pos>"] rule.
- No :root / body / .slide / .zone selectors permitted.
"""
from src.phase_z2_retry import (
apply_font_step_compression_css,
plan_font_step_compression,
)
def test_feasible_15_2_to_13_closes_excess() -> None:
"""current=15.2, excess=20, lines=10 → 14.0 saves 19.2 (insufficient);
13.0 saves 35.2 (>=20) → target_font_px=13.0. Emitted CSS scope-locked."""
plan = plan_font_step_compression(
current_font_px=15.2, excess_after_glue_px=20.0,
available_lines=10, chars_per_line=40, zone_position="bottom_l",
)
assert plan["action"] == "font_step_compression"
assert plan["zone_position"] == "bottom_l"
assert plan["current_font_px"] == 15.2
assert plan["excess_after_glue_px"] == 20.0
assert plan["available_lines"] == 10
assert plan["chars_per_line"] == 40
assert plan["font_floor_px"] == 8.0
assert plan["feasible"] is True
assert plan["target_font_px"] == 13.0
assert "failure_reason" not in plan
css = apply_font_step_compression_css(plan)
assert '[data-zone-position="bottom_l"]' in css
assert "font-size: 13.0px" in css
for forbidden in (":root", "body ", ".slide", ".zone"):
assert forbidden not in css, f"scope-lock violation: {forbidden!r} in css"
def test_infeasible_font_floor_exhausted() -> None:
"""current=15.2, excess=200, lines=10 — even 8.0px floor saves only 115.2,
so FONT_SIZE_STEPS is exhausted → feasible=False, classifier-matching reason."""
plan = plan_font_step_compression(
current_font_px=15.2, excess_after_glue_px=200.0,
available_lines=10, chars_per_line=40, zone_position="top",
)
assert plan["feasible"] is False
assert plan["target_font_px"] is None
assert plan["font_floor_px"] == 8.0
reason = plan["failure_reason"]
assert "font_step floor" in reason
assert "8.0px" in reason
assert "200.0px" in reason
assert "FONT_SIZE_STEPS exhausted" in reason
assert apply_font_step_compression_css(plan) == ""
def test_text_metrics_missing_defensive_infeasible() -> None:
"""available_lines=0 → guard fires before find_fitting_font_size;
failure_reason carries the text_metrics missing substring (classifier-friendly)."""
plan = plan_font_step_compression(
current_font_px=15.2, excess_after_glue_px=40.0,
available_lines=0, chars_per_line=40, zone_position="bottom_r",
)
assert plan["feasible"] is False
assert plan["target_font_px"] is None
assert "text_metrics missing" in plan["failure_reason"]
assert "available_lines/chars_per_line required" in plan["failure_reason"]
assert apply_font_step_compression_css(plan) == ""
@@ -0,0 +1,87 @@
"""IMP-12 u12 — plan_glue_compression tests.
Stage 2 contract (unit u12):
- feasible case asserts emitted CSS contains [data-zone-position=...]
selector and NO global :root / body / .slide rule (scope lock)
- insufficient case feasible=False with envelope reason
u5 wraps space_allocator.calculate_glue_absorption + compute_glue_css_overrides
in the Step-17 plan signature. Glue envelope per block_count (SPACING_GLUE):
absorption_max = block_gap.shrink * (block_count-1) # 12 * (n-1)
+ inner_gap.shrink * block_count # 8 * n
+ title_gap.shrink * block_count # 4 * n
+ container_padding.shrink * 2 # 8 * 2
block_count=3 → 12*2 + 8*3 + 4*3 + 8*2 = 24+24+12+16 = 76 px
block_count=1 → 12*0 + 8*1 + 4*1 + 8*2 = 0+8+4+16 = 28 px
CSS must be wrapped under [data-zone-position="<pos>"] only
(feedback_phase_z_spacing_direction — no :root/body/.slide/.zone mutation).
"""
from __future__ import annotations
from src.phase_z2_retry import (
apply_glue_compression_css,
plan_glue_compression,
)
def test_feasible_case_emits_zone_scoped_css():
"""excess (40px) <= absorption_max (76px @ block_count=3) → feasible.
Emitted CSS must wrap overrides in [data-zone-position=...] selector and
contain none of the global selectors banned by feedback_phase_z_spacing_direction.
"""
plan = plan_glue_compression(
excess_px=40.0, block_count=3, zone_position="bottom_l",
)
assert plan["action"] == "glue_compression"
assert plan["zone_position"] == "bottom_l"
assert plan["feasible"] is True
assert plan["excess_px"] == 40.0
assert plan["block_count"] == 3
assert plan["absorption_max_px"] == 76.0
overrides = plan["overrides"]
assert overrides, "feasible plan must return non-empty overrides"
for key in ("--spacing-block", "--spacing-inner", "--container-padding"):
assert key in overrides, f"missing override key {key}"
css = apply_glue_compression_css(plan)
assert '[data-zone-position="bottom_l"]' in css
assert "--spacing-block:" in css
assert "--spacing-inner:" in css
assert "--container-padding:" in css
# Scope lock — no global rules permitted.
for forbidden in (":root", "body ", ".slide", ".zone"):
assert forbidden not in css, f"forbidden selector {forbidden!r} leaked into glue CSS"
def test_insufficient_envelope_feasible_false_with_reason():
"""excess (80px) > absorption_max (28px @ block_count=1) → infeasible.
failure_reason must surface the envelope shortage so the cascade router
(NEXT_ACTION_BY_FAILURE) can route onward to font_step_compression.
"""
plan = plan_glue_compression(
excess_px=80.0, block_count=1, zone_position="top",
)
assert plan["feasible"] is False
assert plan["absorption_max_px"] == 28.0
reason = plan["failure_reason"]
assert "glue envelope insufficient" in reason
assert "excess_px 80" in reason
assert "max absorption 28" in reason
# apply emits nothing when infeasible — no accidental CSS mutation on revert path.
assert apply_glue_compression_css(plan) == ""
def test_excess_non_positive_no_compression_needed():
"""excess_px <= 0 → defensive infeasible (no compression required)."""
plan = plan_glue_compression(
excess_px=0.0, block_count=3, zone_position="bottom_r",
)
assert plan["feasible"] is False
assert plan["overrides"] == {}
assert plan["absorption_max_px"] == 0.0
assert "no compression needed" in plan["failure_reason"]
assert apply_glue_compression_css(plan) == ""
@@ -0,0 +1,180 @@
"""IMP-34 R1 u2 — plan_zone_ratio_retry measured-empty donor capacity bound.
Stage 2 contract (unit u2) regression coverage for u1 planner change at
`src/phase_z2_retry.py:122-157`:
axis 1: absent measured fields → static_fallback (regression)
axis 2: measured_empty < static_slack → measured_bound applied
axis 3: measured_empty >= static_slack → static_slack honored
axis 4: measured_empty == 0 → donor excluded (slack<=0 gate)
axis 5: donor filter + telemetry source fields → eligibility + bool guards
u1 adds an additive measured-empty bound on donor capacity in
`plan_zone_ratio_retry`. Step 14 schema unchanged; reuses `clientHeight` /
`scrollHeight` already in `overflow["zones"]`. Static fallback preserves
prior behavior when measured fields are absent.
"""
from __future__ import annotations
from src.phase_z2_retry import plan_zone_ratio_retry
_ROUTER_ACTIVE = {"router_active": True}
def _classification(target_pos: str, excess_y: float) -> dict:
return {
"classifications": [
{
"proposed_action": "zone_ratio_retry",
"zone_position": target_pos,
"inputs": {"excess_y": excess_y},
}
]
}
def _zone(position: str, height_px: int, min_height_px: int,
fit_status: str | None = "ok") -> dict:
return {
"position": position,
"height_px": height_px,
"min_height_px": min_height_px,
"composition_rationale": {
"capacity_fit": {"fit_status": fit_status},
},
}
def _ozone(position: str, *, client_h=None, scroll_h=None,
overflowed: bool = False, clipped_inner: bool = False) -> dict:
z: dict = {"position": position, "overflowed": overflowed,
"clipped_inner": clipped_inner}
if client_h is not None:
z["clientHeight"] = client_h
if scroll_h is not None:
z["scrollHeight"] = scroll_h
return z
def test_axis1_absent_measured_fields_uses_static_fallback():
"""Step 14 zones without clientHeight/scrollHeight → planner falls back to
static slack (regression: pre-u1 behavior preserved)."""
debug_zones = [
_zone("top", height_px=200, min_height_px=180),
_zone("bottom", height_px=400, min_height_px=200), # static_slack=200
]
overflow = {"zones": [_ozone("bottom")]} # no clientHeight/scrollHeight
plan = plan_zone_ratio_retry(
debug_zones=debug_zones,
overflow=overflow,
fit_classification=_classification("top", excess_y=20.0),
router_decision=_ROUTER_ACTIVE,
)
assert plan is not None and plan["feasible"] is True
donors = plan["donor_candidates_considered"]
assert len(donors) == 1
assert donors[0]["position"] == "bottom"
assert donors[0]["slack"] == 200 # static slack unchanged
assert donors[0]["measured_empty_px"] is None
assert donors[0]["slack_bound_source"] == "static_fallback"
def test_axis2_measured_empty_less_than_static_slack_bound_applied():
"""measured_empty (30) < static_slack (200) → donor capacity bounded to 30,
telemetry reports measured_bound."""
debug_zones = [
_zone("top", height_px=200, min_height_px=180),
_zone("bottom", height_px=400, min_height_px=200), # static_slack=200
]
# clientHeight 400, scrollHeight 370 → measured_empty=30
overflow = {"zones": [_ozone("bottom", client_h=400, scroll_h=370)]}
plan = plan_zone_ratio_retry(
debug_zones=debug_zones,
overflow=overflow,
fit_classification=_classification("top", excess_y=20.0),
router_decision=_ROUTER_ACTIVE,
)
assert plan["feasible"] is True
donor = plan["donor_candidates_considered"][0]
assert donor["slack"] == 30 # bound by measured_empty
assert donor["measured_empty_px"] == 30
assert donor["slack_bound_source"] == "measured_bound"
# target_added_px = ceil(20)+4 = 24, donor slack=30 covers it
assert plan["aggregate_slack_available"] == 30
assert plan["donor_reduced_px"] == 24
def test_axis3_measured_empty_ge_static_slack_static_honored():
"""measured_empty (300) >= static_slack (50) → static_slack wins via min(),
but telemetry still reports measured_bound (both fields present)."""
debug_zones = [
_zone("top", height_px=200, min_height_px=180),
_zone("bottom", height_px=250, min_height_px=200), # static_slack=50
]
# clientHeight 250, scrollHeight -50 → measured_empty=max(0, 300)=300
# (extreme value to prove min() honors static_slack)
overflow = {"zones": [_ozone("bottom", client_h=250, scroll_h=-50)]}
plan = plan_zone_ratio_retry(
debug_zones=debug_zones,
overflow=overflow,
fit_classification=_classification("top", excess_y=20.0),
router_decision=_ROUTER_ACTIVE,
)
assert plan["feasible"] is True
donor = plan["donor_candidates_considered"][0]
assert donor["slack"] == 50 # static_slack honored via min()
assert donor["measured_empty_px"] == 300
assert donor["slack_bound_source"] == "measured_bound"
def test_axis4_measured_empty_zero_excludes_donor():
"""measured_empty == 0 (donor full) → slack<=0 gate excludes the donor
(planner avoids over-allocating a visually-full sibling)."""
debug_zones = [
_zone("top", height_px=200, min_height_px=180),
_zone("bottom", height_px=400, min_height_px=200), # static_slack=200
]
# clientHeight==scrollHeight → measured_empty=0
overflow = {"zones": [_ozone("bottom", client_h=400, scroll_h=400)]}
plan = plan_zone_ratio_retry(
debug_zones=debug_zones,
overflow=overflow,
fit_classification=_classification("top", excess_y=20.0),
router_decision=_ROUTER_ACTIVE,
)
assert plan is not None and plan["feasible"] is False
assert plan["donor_candidates_considered"] == []
assert "no donor candidates eligible" in plan["failure_reason"]
# zones_after preserves zones_before — revert-friendly
assert plan["zones_after"]["bottom"] == 400
def test_axis5_donor_filter_preservation_and_telemetry_bool_guard():
"""Donor eligibility filter (overflowed / clipped_inner) precedes the new
measured bound, and bool values must NOT be treated as numeric measured
fields (Python isinstance(True, int) is True without an explicit guard)."""
debug_zones = [
_zone("top", height_px=200, min_height_px=180),
_zone("middle", height_px=400, min_height_px=200), # static_slack=200
_zone("bottom", height_px=400, min_height_px=200), # static_slack=200
]
overflow = {"zones": [
# middle is itself overflowed → must be filtered out regardless of measured fields
_ozone("middle", client_h=400, scroll_h=350, overflowed=True),
# bottom uses bool measured fields → must fall back to static_fallback (not crash, not measured_bound)
_ozone("bottom", client_h=True, scroll_h=False),
]}
plan = plan_zone_ratio_retry(
debug_zones=debug_zones,
overflow=overflow,
fit_classification=_classification("top", excess_y=20.0),
router_decision=_ROUTER_ACTIVE,
)
assert plan["feasible"] is True
donors = plan["donor_candidates_considered"]
# middle filtered by overflowed=True; only bottom remains
assert [d["position"] for d in donors] == ["bottom"]
assert donors[0]["slack"] == 200 # static slack (bools rejected as measured)
assert donors[0]["measured_empty_px"] is None
assert donors[0]["slack_bound_source"] == "static_fallback"
@@ -0,0 +1,147 @@
"""IMP-12 u10 — plan_zone_ratio_retry multi-donor aggregation tests.
Stage 2 contract (unit u10):
- single-donor sufficient (regression — backward compat preserved)
- single insufficient + 2nd sufficient (multi-donor PASS path)
- aggregate insufficient (multi-donor FAIL path)
u1 extended plan_zone_ratio_retry from a single primary donor to greedy
slack-desc aggregation across all eligible sibling zones. The plan dict
now carries donors_used / aggregate_slack_used / aggregate_slack_available
while preserving donor_zone_position + donor_reduced_px for the failure
classifier substrings (router still keys off primary donor name).
"""
from __future__ import annotations
from src.phase_z2_retry import plan_zone_ratio_retry
_ROUTER_ACTIVE = {"router_active": True}
def _classification(target_pos: str, excess_y: float) -> dict:
return {
"classifications": [
{
"proposed_action": "zone_ratio_retry",
"zone_position": target_pos,
"inputs": {"excess_y": excess_y},
}
]
}
def _zone(position: str, height_px: int, min_height_px: int,
fit_status: str | None = "ok") -> dict:
return {
"position": position,
"height_px": height_px,
"min_height_px": min_height_px,
"composition_rationale": {
"capacity_fit": {"fit_status": fit_status},
},
}
def _overflow_clean(donor_positions: list[str]) -> dict:
return {
"zones": [
{"position": p, "overflowed": False, "clipped_inner": False}
for p in donor_positions
]
}
def test_single_donor_sufficient_regression():
"""One donor with abundant slack. Plan must remain feasible and the
legacy donor_zone_position / donor_reduced_px fields must reflect the
primary donor (router classifier substring stability)."""
debug_zones = [
_zone("top", height_px=200, min_height_px=180),
_zone("bottom", height_px=400, min_height_px=200), # slack=200
]
plan = plan_zone_ratio_retry(
debug_zones=debug_zones,
overflow=_overflow_clean(["bottom"]),
fit_classification=_classification("top", excess_y=20.0),
router_decision=_ROUTER_ACTIVE,
)
assert plan is not None
assert plan["feasible"] is True
# target_added_px = ceil(20) + DEFAULT_SAFETY_MARGIN_PX(4) = 24
assert plan["target_added_px"] == 24
assert plan["donor_zone_position"] == "bottom"
assert plan["donor_reduced_px"] == 24
assert plan["donors_used"] == [
{"position": "bottom", "reduced_px": 24,
"slack_before": 200, "slack_after": 176}
]
assert plan["aggregate_slack_used"] == 24
assert plan["aggregate_slack_available"] == 200
assert plan["zones_after"]["top"] == 224
assert plan["zones_after"]["bottom"] == 376
def test_multi_donor_pass_primary_insufficient_secondary_covers():
"""Primary donor alone has insufficient slack but primary + secondary
aggregate covers target_added_px. Multi-donor greedy aggregation must
split the deficit across both donors in slack-desc order."""
debug_zones = [
_zone("top", height_px=300, min_height_px=200),
_zone("middle", height_px=250, min_height_px=200), # slack=50
_zone("bottom", height_px=240, min_height_px=200), # slack=40
]
plan = plan_zone_ratio_retry(
debug_zones=debug_zones,
overflow=_overflow_clean(["middle", "bottom"]),
fit_classification=_classification("top", excess_y=66.0),
router_decision=_ROUTER_ACTIVE,
)
# target_added_px = ceil(66)+4 = 70. Primary (middle, slack=50) alone
# cannot cover, but middle(50)+bottom(40)=90 >= 70.
assert plan["feasible"] is True
assert plan["target_added_px"] == 70
assert plan["aggregate_slack_available"] == 90
assert plan["aggregate_slack_used"] == 70
assert plan["donor_zone_position"] == "middle" # primary
assert plan["donor_reduced_px"] == 50 # primary takes its full slack
assert [d["position"] for d in plan["donors_used"]] == ["middle", "bottom"]
assert plan["donors_used"][0]["reduced_px"] == 50
assert plan["donors_used"][1]["reduced_px"] == 20 # remainder
assert plan["zones_after"]["top"] == 370
assert plan["zones_after"]["middle"] == 200
assert plan["zones_after"]["bottom"] == 220
def test_multi_donor_fail_aggregate_insufficient():
"""All donors combined still cannot cover target_added_px. Plan must
be feasible=False with primary-donor substring preserved so the
failure_router classifier still routes through donor_slack_insufficient."""
debug_zones = [
_zone("top", height_px=300, min_height_px=200),
_zone("middle", height_px=210, min_height_px=200), # slack=10
_zone("bottom", height_px=215, min_height_px=200), # slack=15
]
plan = plan_zone_ratio_retry(
debug_zones=debug_zones,
overflow=_overflow_clean(["middle", "bottom"]),
fit_classification=_classification("top", excess_y=66.0),
router_decision=_ROUTER_ACTIVE,
)
# target_added_px=70, aggregate=25 → fail
assert plan["feasible"] is False
assert plan["aggregate_slack_available"] == 25
assert plan["aggregate_slack_used"] == 0
assert plan["donors_used"] == []
# Primary = highest-slack donor = bottom (15)
assert plan["donor_zone_position"] == "bottom"
assert plan["donor_max_slack"] == 15
# Classifier substring stability: "donor", "slack", and "<" still present
reason = plan["failure_reason"]
assert "donor" in reason
assert "slack" in reason
assert "<" in reason
# zones unchanged on fail (revert-friendly)
assert plan["zones_after"]["top"] == 300
assert plan["zones_after"]["middle"] == 210
assert plan["zones_after"]["bottom"] == 215
@@ -0,0 +1,196 @@
"""IMP-15 실행-1 (Gitea issue #45) — Step 14 image_aspect_mismatch detection.
Tests Selenium-driven `<img>` aspect measurement added to ``run_overflow_check``:
* Fixture A — 200×100 image rendered at 200×100 → ``abs(delta) < tol``, no fail
reason, ``passed=True``.
* Fixture B — 200×100 image forced to render 200×200 → ``abs(delta) > 0.30``,
fail reason includes ``image aspect mismatch in zone--primary:``,
``passed=False``.
* Fixture C — ``<img>`` with no ``.zone`` ancestor (attached directly under
``.slide``) → event reports ``zone_position == "unknown"``.
Chromedriver resolution mirrors the pipeline's order
(``PROJECT_ROOT/chromedriver{,.exe}`` → PATH fallback). When no driver is
resolvable the suite skips by default; under ``PHASE_Z_REQUIRE_SELENIUM=1`` the
tests are marked ``xfail(strict=True)`` so CI cannot silently lose coverage.
"""
from __future__ import annotations
import os
import shutil
from pathlib import Path
import pytest
from src.phase_z2_pipeline import (
IMAGE_ASPECT_DELTA_TOL,
PROJECT_ROOT,
run_overflow_check,
)
PIL_Image = pytest.importorskip("PIL.Image", reason="Pillow required for fixture PNGs")
# ─── chromedriver skip / xfail guard ─────────────────────────────────
def _selenium_manager_resolvable() -> bool:
"""Probe ``webdriver.Chrome(options=...)`` — pipeline's third tier.
``src/phase_z2_pipeline.py`` (run_overflow_check) tries
``PROJECT_ROOT/chromedriver{,.exe}`` first, then falls back to
``webdriver.Chrome(options=options)`` which delegates to Selenium Manager
for driver auto-resolution. The test resolver must mirror that fallback
or PHASE_Z_REQUIRE_SELENIUM=1 produces spurious strict-XPASS failures on
machines where Selenium Manager can satisfy the pipeline at runtime.
"""
try:
from selenium import webdriver
from selenium.webdriver.chrome.options import Options as _Opts
except Exception:
return False
opts = _Opts()
opts.add_argument("--headless=new")
opts.add_argument("--no-sandbox")
opts.add_argument("--disable-dev-shm-usage")
try:
drv = webdriver.Chrome(options=opts)
except Exception:
return False
try:
drv.quit()
except Exception:
pass
return True
def _chromedriver_resolvable() -> bool:
"""Mirror pipeline order: PROJECT_ROOT/chromedriver{,.exe} → PATH → Selenium Manager."""
for candidate in (PROJECT_ROOT / "chromedriver", PROJECT_ROOT / "chromedriver.exe"):
if candidate.is_file():
return True
if shutil.which("chromedriver") or shutil.which("chromedriver.exe"):
return True
return _selenium_manager_resolvable()
_REQUIRE_SELENIUM = os.environ.get("PHASE_Z_REQUIRE_SELENIUM") == "1"
_DRIVER_AVAILABLE = _chromedriver_resolvable()
if not _DRIVER_AVAILABLE:
if _REQUIRE_SELENIUM:
pytestmark = pytest.mark.xfail(
strict=True,
reason="PHASE_Z_REQUIRE_SELENIUM=1 but chromedriver is unresolvable",
)
else:
pytestmark = pytest.mark.skip(
reason=(
"chromedriver unresolvable (PROJECT_ROOT/chromedriver{,.exe} + PATH + Selenium Manager); "
"set PHASE_Z_REQUIRE_SELENIUM=1 to make this a hard failure"
),
)
# ─── HTML / PNG fixture helpers ──────────────────────────────────────
_SLIDE_CSS = """
html, body { margin: 0; padding: 0; }
.slide { width: 1280px; height: 720px; position: relative; box-sizing: border-box; }
.zone { display: block; }
"""
def _write_png(path: Path, width: int, height: int, colour=(120, 160, 200)) -> Path:
img = PIL_Image.new("RGB", (width, height), colour)
img.save(path, format="PNG")
return path
def _write_slide_html(tmp_path: Path, body_inner: str, name: str = "slide.html") -> Path:
html = (
"<!doctype html><html><head><meta charset='utf-8'>"
f"<style>{_SLIDE_CSS}</style></head><body>"
'<div class="slide" data-page="1">'
f"{body_inner}"
"</div></body></html>"
)
path = tmp_path / name
path.write_text(html, encoding="utf-8")
return path
def _find_event(events, src_basename: str) -> dict:
for ev in events:
if Path(ev.get("src", "")).name == src_basename:
return ev
raise AssertionError(f"image_events missing entry for {src_basename}; got {events}")
# ─── tests ───────────────────────────────────────────────────────────
def test_image_no_distortion(tmp_path: Path) -> None:
"""Fixture A — 200×100 image rendered at native 200×100. delta ≈ 0."""
png = _write_png(tmp_path / "ok.png", 200, 100)
body = (
'<div class="zone" data-zone-position="primary" data-template-id="t_ok">'
f'<img src="{png.name}" style="width:200px;height:100px;display:block">'
"</div>"
)
html_path = _write_slide_html(tmp_path, body, name="ok.html")
result = run_overflow_check(html_path)
assert "error" not in result, result
assert result.get("image_events"), "image_events must be populated"
ev = _find_event(result["image_events"], png.name)
assert ev["zone_position"] == "primary"
assert ev["natural_w"] == 200 and ev["natural_h"] == 100
assert ev["rendered_w"] == 200 and ev["rendered_h"] == 100
assert ev["delta"] is not None and abs(ev["delta"]) < IMAGE_ASPECT_DELTA_TOL
image_fails = [r for r in result.get("fail_reasons", []) if r.startswith("image aspect mismatch")]
assert image_fails == [], f"unexpected image fail_reasons: {image_fails}"
assert result["passed"] is True, result.get("fail_reasons")
def test_image_forced_distortion(tmp_path: Path) -> None:
"""Fixture B — 200×100 image forced to 200×200. delta > 0.30, fail emitted."""
png = _write_png(tmp_path / "bad.png", 200, 100, colour=(200, 80, 80))
body = (
'<div class="zone" data-zone-position="primary" data-template-id="t_bad">'
f'<img src="{png.name}" style="width:200px;height:200px;display:block">'
"</div>"
)
html_path = _write_slide_html(tmp_path, body, name="bad.html")
result = run_overflow_check(html_path)
assert "error" not in result, result
ev = _find_event(result["image_events"], png.name)
assert ev["natural_w"] == 200 and ev["natural_h"] == 100
assert ev["rendered_w"] == 200 and ev["rendered_h"] == 200
assert ev["delta"] is not None and abs(ev["delta"]) > 0.30
image_fails = [r for r in result.get("fail_reasons", []) if r.startswith("image aspect mismatch")]
assert len(image_fails) == 1, f"expected one image fail_reason, got: {image_fails}"
msg = image_fails[0]
assert msg.startswith("image aspect mismatch in zone--primary:"), msg
assert "natural=2.000" in msg and "rendered=1.000" in msg
assert f"src={png.name}" in msg or png.name in msg
assert result["passed"] is False
def test_image_no_zone_ancestor(tmp_path: Path) -> None:
"""Fixture C — <img> attached directly under .slide → zone_position == 'unknown'."""
png = _write_png(tmp_path / "loose.png", 200, 100, colour=(80, 200, 120))
body = f'<img src="{png.name}" style="width:200px;height:100px;display:block">'
html_path = _write_slide_html(tmp_path, body, name="loose.html")
result = run_overflow_check(html_path)
assert "error" not in result, result
ev = _find_event(result["image_events"], png.name)
assert ev["zone_position"] == "unknown"
assert ev["natural_w"] == 200 and ev["natural_h"] == 100
assert ev["delta"] is not None and abs(ev["delta"]) < IMAGE_ASPECT_DELTA_TOL
image_fails = [r for r in result.get("fail_reasons", []) if r.startswith("image aspect mismatch")]
assert image_fails == []
@@ -0,0 +1,334 @@
"""IMP-15 실행-2 (Gitea issue #46) — Step 14 table_self_overflow detection.
Tests Selenium-driven ``<table>`` self-overflow measurement and element-identity
wrapper dedup added to ``run_overflow_check``:
* Fixture D — standalone ``<table>`` self-overflow, no clipped wrapper ancestor →
``table_events`` entry reports ``wrapper_clipped_index = None`` and an
``excess_*`` exceeding ``TABLE_SCROLL_TOL_PX``; Python aggregation then emits
a ``table self-overflow`` fail_reason and flips ``result["passed"] = False``.
* Fixture E — ``<table>`` inside a clipped ``f13b`` wrapper. The wrapper itself
self-overflows (registers in ``clippedWrapperMap``) and the inner table also
self-overflows. Asserts dedup is honored: the table's ``wrapper_clipped_index``
resolves to the wrapper's map index (non-null) so the Python aggregation MUST
NOT emit a ``table self-overflow`` fail_reason — only the wrapper's pre-existing
``inner clipped`` fail line remains.
* Fixture F — two wrappers W1 / W2 share identical className ``f13b-cell``. W1
contains an overflowing inline-block child (no ``<table>``) → W1 self-overflows
and registers in ``clippedWrapperMap`` (emits ``inner clipped``). W2 contains
only a self-overflowing ``<table>``; W2's own scrollWidth equals its clientWidth
(the table's ``overflow:hidden`` keeps W2 itself uncliped). The element-identity
ancestor walk MUST resolve the W2 table's ``wrapper_clipped_index`` to ``None``
(W2 ≠ W1 by DOM reference, despite identical class string). A class-string
lookup would have falsely resolved the W2 table → W1 and suppressed the fail —
the test thereby proves ``Map<Element, int>`` distinguishes by node identity.
Chromedriver resolution mirrors the pipeline order
(``PROJECT_ROOT/chromedriver{,.exe}`` → PATH → Selenium Manager). When no driver
is resolvable the suite skips by default; under ``PHASE_Z_REQUIRE_SELENIUM=1``
the tests are marked ``xfail(strict=True)`` so CI cannot silently lose coverage.
"""
from __future__ import annotations
import os
import shutil
from pathlib import Path
import pytest
from src.phase_z2_pipeline import (
PROJECT_ROOT,
TABLE_SCROLL_TOL_PX,
run_overflow_check,
)
# ─── chromedriver skip / xfail guard ─────────────────────────────────
def _selenium_manager_resolvable() -> bool:
"""Probe ``webdriver.Chrome(options=...)`` — pipeline's third tier.
``src/phase_z2_pipeline.py`` (run_overflow_check) tries
``PROJECT_ROOT/chromedriver{,.exe}`` first, then falls back to
``webdriver.Chrome(options=options)`` which delegates to Selenium Manager
for driver auto-resolution. The test resolver must mirror that fallback
or ``PHASE_Z_REQUIRE_SELENIUM=1`` produces spurious strict-XPASS failures
on machines where Selenium Manager can satisfy the pipeline at runtime.
"""
try:
from selenium import webdriver
from selenium.webdriver.chrome.options import Options as _Opts
except Exception:
return False
opts = _Opts()
opts.add_argument("--headless=new")
opts.add_argument("--no-sandbox")
opts.add_argument("--disable-dev-shm-usage")
try:
drv = webdriver.Chrome(options=opts)
except Exception:
return False
try:
drv.quit()
except Exception:
pass
return True
def _chromedriver_resolvable() -> bool:
"""Mirror pipeline order: PROJECT_ROOT/chromedriver{,.exe} → PATH → Selenium Manager."""
for candidate in (PROJECT_ROOT / "chromedriver", PROJECT_ROOT / "chromedriver.exe"):
if candidate.is_file():
return True
if shutil.which("chromedriver") or shutil.which("chromedriver.exe"):
return True
return _selenium_manager_resolvable()
_REQUIRE_SELENIUM = os.environ.get("PHASE_Z_REQUIRE_SELENIUM") == "1"
_DRIVER_AVAILABLE = _chromedriver_resolvable()
if not _DRIVER_AVAILABLE:
if _REQUIRE_SELENIUM:
pytestmark = pytest.mark.xfail(
strict=True,
reason="PHASE_Z_REQUIRE_SELENIUM=1 but chromedriver is unresolvable",
)
else:
pytestmark = pytest.mark.skip(
reason=(
"chromedriver unresolvable (PROJECT_ROOT/chromedriver{,.exe} + PATH + Selenium Manager); "
"set PHASE_Z_REQUIRE_SELENIUM=1 to make this a hard failure"
),
)
# ─── HTML fixture helpers ────────────────────────────────────────────
_SLIDE_CSS = """
html, body { margin: 0; padding: 0; }
.slide { width: 1280px; height: 720px; position: relative; box-sizing: border-box; }
.zone { display: block; }
"""
def _write_slide_html(tmp_path: Path, body_inner: str, name: str = "slide.html") -> Path:
html = (
"<!doctype html><html><head><meta charset='utf-8'>"
f"<style>{_SLIDE_CSS}</style></head><body>"
'<div class="slide" data-page="1">'
f"{body_inner}"
"</div></body></html>"
)
path = tmp_path / name
path.write_text(html, encoding="utf-8")
return path
# ─── tests ───────────────────────────────────────────────────────────
def test_fixture_d_standalone_table_overflow(tmp_path: Path) -> None:
"""Fixture D — standalone ``<table>`` self-overflow, no clipped wrapper.
The table is forced into block layout with a fixed clientWidth (100px) and
``overflow: hidden``; the inner cell is 600px wide with ``white-space:nowrap``,
so the table's scrollWidth exceeds clientWidth by well over ``TABLE_SCROLL_TOL_PX``.
No ancestor carries an ``f13b/f29b/f16b`` class, so the element-identity walk
must report ``wrapper_clipped_index = None``. Python aggregation then emits a
``table self-overflow`` fail_reason and flips ``result["passed"]`` to ``False``.
"""
body = (
'<div class="zone" data-zone-position="primary" data-template-id="t_table">'
'<table style="display:block; width:100px; height:30px; overflow:hidden; '
'box-sizing:border-box; table-layout:fixed;">'
'<tr><td style="width:600px; white-space:nowrap;">'
'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'
'</td></tr>'
'</table>'
'</div>'
)
html_path = _write_slide_html(tmp_path, body, name="fixture_d.html")
result = run_overflow_check(html_path)
assert "error" not in result, result
assert "table_events" in result, "run_overflow_check must expose table_events"
table_events = result["table_events"]
assert len(table_events) == 1, f"expected one table_events entry, got: {table_events}"
ev = table_events[0]
assert ev["zone_position"] == "primary", ev
assert ev["zone_template_id"] == "t_table", ev
assert ev["wrapper_clipped_index"] is None, (
f"standalone table must have null wrapper_clipped_index; got {ev['wrapper_clipped_index']}"
)
assert ev["excess_x"] > TABLE_SCROLL_TOL_PX, (
f"expected excess_x > {TABLE_SCROLL_TOL_PX}; got {ev['excess_x']} "
f"(clientWidth={ev['clientWidth']}, scrollWidth={ev['scrollWidth']})"
)
# Python aggregation: emitted fail_reason + passed flipped to False.
fail_reasons = result.get("fail_reasons", [])
table_fails = [r for r in fail_reasons if "table self-overflow" in r]
assert len(table_fails) == 1, (
f"expected exactly one 'table self-overflow' fail_reason; got fail_reasons={fail_reasons}"
)
assert "zone--primary" in table_fails[0], table_fails[0]
assert f"tol={TABLE_SCROLL_TOL_PX}" in table_fails[0], table_fails[0]
assert result["passed"] is False, (
f"table self-overflow must flip passed=False; got result={result}"
)
def test_fixture_e_table_in_clipped_wrapper_dedup(tmp_path: Path) -> None:
"""Fixture E — ``<table>`` inside a clipped ``f13b`` wrapper (dedup honored).
The wrapper (clientWidth=300, ``overflow:hidden``) contains a ``display:block``
table forced to width=500px → wrapper.scrollWidth (≈500) − clientWidth (300) > 5px,
so the wrapper is registered in ``clippedWrapperMap`` (emits ``inner clipped`` fail).
The inner table is itself self-overflowing (clientWidth=500, content nowrap-cell
width=900 → scrollWidth ≈ 900). The element-identity ancestor walk MUST resolve
the table's ``wrapper_clipped_index`` to the wrapper's integer map index, and the
Python aggregation MUST then SKIP emitting a ``table self-overflow`` fail_reason
(the clipped wrapper already accounts for this).
"""
body = (
'<div class="zone" data-zone-position="primary" data-template-id="t_table_wrap">'
'<div class="f13b-cell" style="width:300px; height:60px; overflow:hidden; '
'box-sizing:border-box; position:relative;">'
'<table style="display:block; width:500px; height:40px; overflow:hidden; '
'box-sizing:border-box; table-layout:fixed;">'
'<tr><td style="width:900px; white-space:nowrap;">'
'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB'
'</td></tr>'
'</table>'
'</div>'
'</div>'
)
html_path = _write_slide_html(tmp_path, body, name="fixture_e.html")
result = run_overflow_check(html_path)
assert "error" not in result, result
table_events = result.get("table_events", [])
assert len(table_events) == 1, f"expected one table_events entry, got: {table_events}"
ev = table_events[0]
# Dedup signal: ancestor walk must hit the f13b wrapper via Map.has(node).
assert ev["wrapper_clipped_index"] is not None, (
f"table inside clipped wrapper must inherit wrapper index; got ev={ev}"
)
assert isinstance(ev["wrapper_clipped_index"], int), ev
# The inner table is itself overflowing — proves the dedup is the only thing
# suppressing the table_self_overflow fail (not absence of overflow).
assert ev["excess_x"] > TABLE_SCROLL_TOL_PX, (
f"inner table must be self-overflowing for this test to be meaningful; ev={ev}"
)
fail_reasons = result.get("fail_reasons", [])
table_fails = [r for r in fail_reasons if "table self-overflow" in r]
assert table_fails == [], (
f"dedup must suppress table self-overflow fail when wrapper is clipped; "
f"got table_fails={table_fails} fail_reasons={fail_reasons}"
)
# Wrapper's clipped_inner fail line must still be present.
clipped_fails = [r for r in fail_reasons if "inner clipped" in r and "f13b" in r]
assert len(clipped_fails) >= 1, (
f"wrapper clipped_inner fail must remain; got fail_reasons={fail_reasons}"
)
assert result["passed"] is False, result
def test_fixture_f_two_same_class_wrappers_element_identity(tmp_path: Path) -> None:
"""Fixture F (F1 acceptance) — two same-class wrappers, element-identity dedup.
W1 and W2 share the identical className ``f13b-cell``. W1 (clientWidth=300,
``overflow:hidden``) contains an inline-block ``<div>`` of width 600px →
W1.scrollWidth − clientWidth ≈ 300 > 5; W1 is registered in
``clippedWrapperMap`` and emits an ``inner clipped`` fail line. W2
(clientWidth=600, ``overflow:hidden``) contains a 500px-wide block-display
``<table>`` (matching the Fixture E table shape so the table is itself
self-overflowing with excess_x > 5). W2's clientWidth (600) is larger than
the table's outer width (500), so W2's own scrollWidth ≈ 500 < clientWidth
and W2 is NOT registered in ``clippedWrapperMap``.
The element-identity ancestor walk in the pipeline (L2298–L2304) walks from
the W2 table upward via ``parentElement`` and queries
``clippedWrapperMap.has(node)`` — keyed by DOM node, NOT className. W2 is
a different ``Element`` reference from W1 despite identical class string,
so the lookup returns false at W2 and the walk terminates at ``.slide`` with
``wrapper_clipped_index = null``. A class-substring keyed map (the F1
regression scenario described in issue #46) would have resolved any
``[class*="f13b"]`` ancestor of the W2 table → W1's index and falsely
suppressed the W2 table_self_overflow fail.
Asserts:
* Exactly ONE ``inner clipped`` fail line (for W1) — proves W1 is in the map.
* Exactly ONE ``table self-overflow`` fail line (for W2's table) — proves
the W2 table is NOT suppressed by W1's identical class string.
* W2 table's ``table_events`` entry reports ``wrapper_clipped_index = None``
(element-identity contract) and ``excess_x > TABLE_SCROLL_TOL_PX``.
"""
body = (
'<div class="zone" data-zone-position="primary" '
'data-template-id="t_table_same_class">'
# W1 — same className, overflowing non-table child.
'<div class="f13b-cell" id="w1" style="width:300px; height:60px; '
'overflow:hidden; box-sizing:border-box; position:relative; '
'margin-bottom:8px;">'
'<div style="display:inline-block; width:600px; white-space:nowrap;">'
'XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX'
'</div>'
'</div>'
# W2 — same className, NOT clipped (W2.clientWidth=600 > table.outer=500),
# but the inner table itself self-overflows (table width=500, td width=900).
'<div class="f13b-cell" id="w2" style="width:600px; height:60px; '
'overflow:hidden; box-sizing:border-box; position:relative;">'
'<table style="display:block; width:500px; height:40px; '
'overflow:hidden; box-sizing:border-box; table-layout:fixed;">'
'<tr><td style="width:900px; white-space:nowrap;">'
'YYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYY'
'</td></tr>'
'</table>'
'</div>'
'</div>'
)
html_path = _write_slide_html(tmp_path, body, name="fixture_f.html")
result = run_overflow_check(html_path)
assert "error" not in result, result
# Exactly one table_events entry (the W2 table — W1 has no <table>).
table_events = result.get("table_events", [])
assert len(table_events) == 1, f"expected one table_events entry, got: {table_events}"
ev = table_events[0]
# Element-identity contract: W2 ≠ W1, so the ancestor walk MUST NOT inherit
# W1's wrapper index merely because W2 shares W1's class string.
assert ev["wrapper_clipped_index"] is None, (
f"W2 (not itself clipped) must NOT inherit W1's index via class string; "
f"got wrapper_clipped_index={ev['wrapper_clipped_index']}. "
"This is the F1 regression — a class-substring map would have failed here."
)
assert ev["excess_x"] > TABLE_SCROLL_TOL_PX, (
f"W2's inner table must self-overflow for this test to be meaningful; ev={ev}"
)
fail_reasons = result.get("fail_reasons", [])
# W1: inner clipped fail emitted (W1 is in clippedWrapperMap, has overflowing inner div).
w1_clipped_fails = [r for r in fail_reasons if "inner clipped" in r and "f13b" in r]
assert len(w1_clipped_fails) == 1, (
f"expected exactly one W1 'inner clipped' fail; got fail_reasons={fail_reasons}"
)
# W2: table self-overflow fail emitted because element-identity dedup correctly
# reports wrapper_clipped_index=None for the W2 table (W2 ≠ W1 by DOM ref).
table_fails = [r for r in fail_reasons if "table self-overflow" in r]
assert len(table_fails) == 1, (
f"expected exactly one W2 'table self-overflow' fail (element-identity dedup); "
f"got fail_reasons={fail_reasons}"
)
assert "zone--primary" in table_fails[0], table_fails[0]
assert f"tol={TABLE_SCROLL_TOL_PX}" in table_fails[0], table_fails[0]
assert result["passed"] is False, result
@@ -0,0 +1,248 @@
"""IMP-12 u15 — End-to-end test of `_attempt_salvage_chain` (Step 17 deterministic salvage cascade).
Three Stage 2 cases against `src.phase_z2_pipeline._attempt_salvage_chain`:
(a) zone_ratio fail + cross_zone pass → final.html promoted, salvage_passed=True
(b) cross_zone fail + glue pass → 2nd cascade step promoted, salvage_passed=True
(c) all 3 fail → (b)-revert preserved, original final.html intact, salvage_passed=False
`render_slide` and `run_overflow_check` are monkey-patched so the test stays deterministic
(no Selenium / Jinja2 template files). The patches only stand in for the rendering / overflow
oracles — the planners (`plan_cross_zone_redistribute`, `plan_glue_compression`,
`plan_font_step_compression`) and the cascade router (`route_retry_failure` /
`SALVAGE_FAIL_BY_ACTION`) all run unmocked.
"""
from __future__ import annotations
import shutil
import tempfile
from pathlib import Path
import pytest
import src.phase_z2_pipeline as _pz_pipeline
from src.fit_verifier import FitAnalysis, RoleFit
from src.phase_z2_pipeline import _attempt_salvage_chain
_PROJECT_ROOT = _pz_pipeline.PROJECT_ROOT
@pytest.fixture
def project_tmp(tmp_path_factory):
"""Temp dir under PROJECT_ROOT so _attempt_salvage_chain can call
candidate_path.relative_to(PROJECT_ROOT) without ValueError on a
cross-drive system tmp path (pytest's default tmp_path is under
%LOCALAPPDATA% on Windows, which lives on a different drive from
the project root in this repo)."""
base = _PROJECT_ROOT / ".orchestrator" / "tmp"
base.mkdir(parents=True, exist_ok=True)
d = Path(tempfile.mkdtemp(prefix="u15_salvage_", dir=str(base)))
try:
yield d
finally:
shutil.rmtree(d, ignore_errors=True)
_LAYOUT_CSS_GATE_PASS = {
"areas": '"top" "bottom"',
"cols": "1fr",
"rows": "1fr 1fr",
"heights_px": [300, 290],
"widths_px": [1180],
"ratios": [0.508, 0.491],
"width_ratios": [1.0],
"dynamic_rows": True,
"dynamic_cols": False,
}
def _patch_render(monkeypatch):
"""Stub render_slide → deterministic HTML envelope so the cascade does not
need real Jinja2 templates. Returns a counter so tests can assert how many
times it was invoked (one per CSS-feasible cascade step)."""
counter = {"n": 0}
def _stub(slide_title, slide_footer, zones_data, layout_preset, layout_css, gap_px=14):
counter["n"] += 1
return (
f"<html><head><meta charset='utf-8'></head>"
f"<body><div data-slide-title='{slide_title}'></div></body></html>"
)
monkeypatch.setattr(_pz_pipeline, "render_slide", _stub)
return counter
def _kwargs(*, run_dir: Path, out_path: Path, cascade_inputs: dict,
initial_failure_type: str = "donor_slack_insufficient") -> dict:
return {
"run_dir": run_dir,
"out_path": out_path,
"slide_title": "u15-test",
"slide_footer": None,
"zones_data": [],
"layout_preset": "horizontal-2",
"layout_css": _LAYOUT_CSS_GATE_PASS,
"cascade_inputs": cascade_inputs,
"initial_failure_type": initial_failure_type,
"gap_px": 14,
}
def test_case_a_cross_zone_passes_final_html_promoted(project_tmp, monkeypatch):
"""(a) cross_zone_redistribute is feasible + run_overflow_check returns
passed=True → out_path overwritten with the cross_zone candidate HTML and
salvage_passed=True after the very first cascade iteration."""
out_path = project_tmp / "final.html"
out_path.write_text("ORIGINAL_BEFORE_SALVAGE", encoding="utf-8")
# Multi-role same-zone FitAnalysis: top +30 deficit, bottom_l -50 surplus.
fit_analysis = FitAnalysis(roles={
"top": RoleFit(role="top", allocated_px=200, shortfall_px=30.0),
"bottom_l": RoleFit(role="bottom_l", allocated_px=300, shortfall_px=-50.0),
})
containers = {
"top": {"zone": "slide_body", "height_px": 200},
"bottom_l": {"zone": "slide_body", "height_px": 300},
}
cascade_inputs = {
"fit_analysis": fit_analysis,
"containers": containers,
"min_margin_px": 10,
"excess_px": 30.0, "excess_after_glue_px": 30.0,
"block_count": 3, "zone_position": "top",
"current_font_px": 15.2, "available_lines": 10, "chars_per_line": 40,
}
_patch_render(monkeypatch)
monkeypatch.setattr(
_pz_pipeline, "run_overflow_check",
lambda p: {"passed": True, "fail_reasons": []},
)
trace = _attempt_salvage_chain(
**_kwargs(run_dir=project_tmp, out_path=out_path, cascade_inputs=cascade_inputs),
)
assert trace["salvage_attempted"] is True
assert trace["salvage_passed"] is True
assert len(trace["salvage_steps"]) == 1
step0 = trace["salvage_steps"][0]
assert step0["action"] == "cross_zone_redistribute"
assert step0["passed"] is True
assert step0["plan"]["feasible"] is True
assert step0["css_override"] and '[data-role=' in step0["css_override"]
# out_path was overwritten with the salvage candidate.
promoted = out_path.read_text(encoding="utf-8")
assert "ORIGINAL_BEFORE_SALVAGE" not in promoted
assert "u15-test" in promoted
def test_case_b_cross_zone_fails_glue_passes_second_promoted(project_tmp, monkeypatch):
"""(b) cross_zone is infeasible (single-role zone) → glue_compression CSS
emitted + run_overflow_check passes → out_path overwritten with the glue
candidate (2nd cascade step). salvage_passed=True; salvage_steps[0]
records the infeasible cross_zone attempt."""
out_path = project_tmp / "final.html"
out_path.write_text("ORIGINAL_BEFORE_SALVAGE", encoding="utf-8")
# Single-role zone → fit_verifier.redistribute returns can_redistribute=False
# (peer required, none present).
fit_analysis = FitAnalysis(roles={
"top": RoleFit(role="top", allocated_px=200, shortfall_px=30.0),
})
containers = {"top": {"zone": "slide_body", "height_px": 200}}
# Glue envelope at block_count=3 = 12*(3-1)+8*3+4*3+8*2 = 76 px → 40 px is feasible.
cascade_inputs = {
"fit_analysis": fit_analysis,
"containers": containers,
"min_margin_px": 10,
"excess_px": 40.0, "excess_after_glue_px": 40.0,
"block_count": 3, "zone_position": "bottom_l",
"current_font_px": 15.2, "available_lines": 10, "chars_per_line": 40,
}
render_counter = _patch_render(monkeypatch)
# cross_zone is infeasible → no CSS → no rerender / no overflow call. Glue is
# feasible → exactly one rerender + overflow call → return passed=True.
monkeypatch.setattr(
_pz_pipeline, "run_overflow_check",
lambda p: {"passed": True, "fail_reasons": []},
)
trace = _attempt_salvage_chain(
**_kwargs(run_dir=project_tmp, out_path=out_path, cascade_inputs=cascade_inputs),
)
assert trace["salvage_attempted"] is True
assert trace["salvage_passed"] is True
assert len(trace["salvage_steps"]) == 2
s0 = trace["salvage_steps"][0]
assert s0["action"] == "cross_zone_redistribute"
assert s0["passed"] is False
assert s0["plan"]["feasible"] is False
assert s0["css_override"] is None
assert "single-role zone" in (s0["plan"].get("failure_reason") or "")
s1 = trace["salvage_steps"][1]
assert s1["action"] == "glue_compression"
assert s1["passed"] is True
assert s1["plan"]["feasible"] is True
assert s1["css_override"] and '[data-zone-position="bottom_l"]' in s1["css_override"]
# render_slide was invoked exactly once (only the glue branch emitted CSS).
assert render_counter["n"] == 1
# out_path was overwritten with the glue candidate.
promoted = out_path.read_text(encoding="utf-8")
assert "ORIGINAL_BEFORE_SALVAGE" not in promoted
def test_case_c_all_three_fail_revert_preserved(project_tmp, monkeypatch):
"""(c) All three cascade actions are infeasible (no CSS emitted by any
planner) → run_overflow_check is never invoked, salvage_passed=False,
salvage_steps has three failed entries, and out_path is unchanged
(original final.html intact — (b)-revert preserved)."""
out_path = project_tmp / "final.html"
out_path.write_text("ORIGINAL_BEFORE_SALVAGE", encoding="utf-8")
cascade_inputs = {
# cross_zone: fit_analysis missing → plan returns feasible=False with reason
# `cascade_inputs.fit_analysis missing` (see _attempt_salvage_chain branch).
"fit_analysis": None,
"containers": {},
"min_margin_px": 10,
# glue: excess_px (200) > envelope max at block_count=1 (28) → infeasible.
"excess_px": 200.0, "excess_after_glue_px": 200.0,
"block_count": 1, "zone_position": "top",
# font_step: current_font_px=15.2 cannot absorb 200px even at 8px floor
# → find_fitting_font_size returns None → feasible=False.
"current_font_px": 15.2, "available_lines": 10, "chars_per_line": 40,
}
render_counter = _patch_render(monkeypatch)
# Guard: if run_overflow_check is ever called, the test fails loudly.
def _must_not_call(_p): # pragma: no cover — intentional sentinel
raise AssertionError("run_overflow_check must not run when no CSS is emitted")
monkeypatch.setattr(_pz_pipeline, "run_overflow_check", _must_not_call)
trace = _attempt_salvage_chain(
**_kwargs(run_dir=project_tmp, out_path=out_path, cascade_inputs=cascade_inputs),
)
assert trace["salvage_attempted"] is True
assert trace["salvage_passed"] is False
assert len(trace["salvage_steps"]) == 3
actions = [s["action"] for s in trace["salvage_steps"]]
assert actions == [
"cross_zone_redistribute",
"glue_compression",
"font_step_compression",
]
for step in trace["salvage_steps"]:
assert step["passed"] is False
assert step["css_override"] is None
assert step["failure_reason"]
# No CSS emitted anywhere → no render_slide calls either.
assert render_counter["n"] == 0
# (b) revert: out_path is untouched.
assert out_path.read_text(encoding="utf-8") == "ORIGINAL_BEFORE_SALVAGE"
@@ -0,0 +1,116 @@
"""IMP-15 실행-3 (Gitea issue #47) — classifier consumer pure-dict tests.
`classify_visual_runtime_check` was widened to consume the new
``image_events[]`` / ``table_events[]`` arrays produced by ``run_overflow_check``
(IMP-15 실행-1/2). The consumer must:
* emit ``image_aspect_mismatch`` when ``|delta| > IMAGE_ASPECT_DELTA_TOL`` and
skip when ``delta is None`` or ``|delta| <= IMAGE_ASPECT_DELTA_TOL``;
* emit ``tabular_overflow`` when a table self-overflows beyond
``TABLE_SCROLL_TOL_PX`` and ``wrapper_clipped_index is None`` — and dedupe
when the table sits under a wrapper already on the clipped-wrapper map
(``wrapper_clipped_index`` non-null);
* flip ``visual_check_passed`` to False whenever any classification fires, even
if zone-level overflow was clean (``overflow["passed"]=True``).
All four cases are pure-dict — no Selenium / chromedriver dependency.
"""
from __future__ import annotations
from src.phase_z2_classifier import classify_visual_runtime_check
from src.phase_z2_pipeline import IMAGE_ASPECT_DELTA_TOL, TABLE_SCROLL_TOL_PX
def _base_overflow(**overrides) -> dict:
"""Minimal clean overflow result; tests overlay image/table events."""
base = {
"passed": True,
"slide": {"overflowed": False},
"slide_body": {"overflowed": False},
"zones": [],
"image_events": [],
"table_events": [],
}
base.update(overrides)
return base
# ─── image_events scan ───────────────────────────────────────────────
def test_image_aspect_mismatch_emits_classification():
"""|delta| > IMAGE_ASPECT_DELTA_TOL ⇒ emit + flip visual_check_passed."""
delta = IMAGE_ASPECT_DELTA_TOL + 0.05
overflow = _base_overflow(image_events=[{
"zone_position": "top",
"zone_template_id": "f1b",
"src": "img/sample.png",
"natural_ratio": 2.0,
"rendered_ratio": 2.0 * (1.0 + delta),
"delta": delta,
}])
result = classify_visual_runtime_check(overflow, debug_zones=[])
assert result["visual_check_passed"] is False
assert result["categories_seen"] == ["image_aspect_mismatch"]
assert len(result["classifications"]) == 1
cls = result["classifications"][0]
assert cls["category"] == "image_aspect_mismatch"
assert cls["source"] == "image_event"
assert cls["zone_position"] == "top"
assert cls["delta"] == delta
def test_image_aspect_delta_below_tol_no_classification():
"""|delta| <= IMAGE_ASPECT_DELTA_TOL ⇒ skip (no false positive)."""
delta = IMAGE_ASPECT_DELTA_TOL / 2.0
overflow = _base_overflow(image_events=[{
"zone_position": "top",
"zone_template_id": "f1b",
"src": "img/sample.png",
"natural_ratio": 2.0,
"rendered_ratio": 2.0 * (1.0 + delta),
"delta": delta,
}])
result = classify_visual_runtime_check(overflow, debug_zones=[])
assert result["visual_check_passed"] is True
assert result["categories_seen"] == []
assert result["classifications"] == []
# ─── table_events scan ───────────────────────────────────────────────
def test_standalone_table_overflow_emits_classification():
"""wrapper_clipped_index=None AND excess > TOL ⇒ emit tabular_overflow."""
excess = TABLE_SCROLL_TOL_PX + 10
overflow = _base_overflow(table_events=[{
"zone_position": "bottom_l",
"zone_template_id": "f13b",
"wrapper_clipped_index": None,
"excess_x": 0,
"excess_y": excess,
}])
result = classify_visual_runtime_check(overflow, debug_zones=[])
assert result["visual_check_passed"] is False
assert result["categories_seen"] == ["tabular_overflow"]
assert len(result["classifications"]) == 1
cls = result["classifications"][0]
assert cls["category"] == "tabular_overflow"
assert cls["source"] == "table_event"
assert cls["zone_position"] == "bottom_l"
assert cls["excess_y"] == excess
def test_table_dedup_when_wrapper_clipped():
"""wrapper_clipped_index non-null ⇒ skip (dedupe with clipped_inner cascade)."""
overflow = _base_overflow(table_events=[{
"zone_position": "bottom_l",
"zone_template_id": "f13b",
"wrapper_clipped_index": 0,
"excess_x": 0,
"excess_y": TABLE_SCROLL_TOL_PX + 50,
}])
result = classify_visual_runtime_check(overflow, debug_zones=[])
assert result["visual_check_passed"] is True
assert result["categories_seen"] == []
assert result["classifications"] == []
+59
View File
@@ -0,0 +1,59 @@
"""u1 — VerificationResult dataclass surface (IMP-16-U1).
Locks the Phase Z verification utility module anchor and the
VerificationResult shape so downstream units (u2~u10) can rely on it
without importing src.content_verifier.
"""
from __future__ import annotations
import ast
import importlib
import pytest
def test_module_importable_without_content_verifier():
mod = importlib.import_module("src.phase_z2_verification_utils")
tree = ast.parse(open(mod.__file__, encoding="utf-8").read())
for node in ast.walk(tree):
if isinstance(node, ast.Import):
for alias in node.names:
assert "content_verifier" not in alias.name, (
"Phase Z verification utility must not import "
"src.content_verifier"
)
elif isinstance(node, ast.ImportFrom):
assert node.module is None or "content_verifier" not in node.module, (
"Phase Z verification utility must not import "
"src.content_verifier"
)
def test_verification_result_defaults():
from src.phase_z2_verification_utils import VerificationResult
r = VerificationResult(passed=True, area_name="zone_test")
assert r.passed is True
assert r.area_name == "zone_test"
assert r.checks == {}
assert r.score == 0.0
assert r.errors == []
assert r.warnings == []
def test_verification_result_independent_default_collections():
from src.phase_z2_verification_utils import VerificationResult
a = VerificationResult(passed=False, area_name="a")
b = VerificationResult(passed=False, area_name="b")
a.checks["x"] = True
a.errors.append("e")
a.warnings.append("w")
assert b.checks == {} and b.errors == [] and b.warnings == []
def test_verification_result_required_fields():
from src.phase_z2_verification_utils import VerificationResult
with pytest.raises(TypeError):
VerificationResult() # type: ignore[call-arg]
+54
View File
@@ -0,0 +1,54 @@
"""u2 — pure HTML text extraction surface (IMP-16-U1).
Locks the deterministic visible-text extraction contract:
- <style> / <script> contents are excluded.
- Whitespace-only chunks are dropped; surviving chunks are stripped.
- Order of visible-text fragments is preserved.
- No import of src.content_verifier.
"""
from __future__ import annotations
def test_extract_plain_text_fragments_in_order():
from src.phase_z2_verification_utils import extract_text_from_html
html = "<p>first</p><p>second</p><p>third</p>"
assert extract_text_from_html(html) == ["first", "second", "third"]
def test_extract_skips_style_and_script_bodies():
from src.phase_z2_verification_utils import extract_text_from_html
html = (
"<html><head>"
"<style>body { color: red; } .x { font-size: 12px; }</style>"
"<script>var keep_out = 1;</script>"
"</head><body><p>visible</p></body></html>"
)
out = extract_text_from_html(html)
assert "visible" in out
joined = " ".join(out)
assert "color: red" not in joined
assert "keep_out" not in joined
def test_extract_drops_whitespace_only_chunks_and_strips_survivors():
from src.phase_z2_verification_utils import extract_text_from_html
html = "<div> \n\n </div><div> hello </div><span> world\t</span>"
out = extract_text_from_html(html)
assert out == ["hello", "world"]
def test_extract_preserves_korean_and_inline_markup_text():
from src.phase_z2_verification_utils import extract_text_from_html
html = "<p>설계 <strong>방식</strong>의 왜곡</p>"
out = extract_text_from_html(html)
assert out == ["설계", "방식", "의 왜곡"]
def test_extract_empty_input_returns_empty_list():
from src.phase_z2_verification_utils import extract_text_from_html
assert extract_text_from_html("") == []
+106
View File
@@ -0,0 +1,106 @@
"""Tests for IMP-16-U1 unit u10: sample-backed smoke without pipeline import.
End-to-end smoke of the deterministic chain (extract_text_from_html ∘
normalize_for_comparison ∘ split_into_sentences ∘ _sentence_matches_html
→ verify_text_preservation / detect_invented_text) on a real
``samples/mdx_batch`` MDX file. Per Stage 2 rationale: smoke coverage
uses the sample but does NOT hardcode a sample-specific pass.
Also locks the AI-isolation contract for the verification axis: this
test and the production module MUST NOT import orchestrator /
phase_z2_pipeline / Phase Q content_verifier / Kei client.
"""
from __future__ import annotations
import ast
from pathlib import Path
from src.phase_z2_verification_utils import (
VerificationResult,
detect_invented_text,
verify_text_preservation,
)
_REPO_ROOT = Path(__file__).resolve().parents[2]
_SAMPLE_MDX_PATH = _REPO_ROOT / "samples" / "mdx_batch" / "02.mdx"
_FORBIDDEN_IMPORT_ROOTS = (
"orchestrator",
"src.phase_z2_pipeline",
"src.content_verifier",
"src.kei_client",
)
def _module_imports(path: Path) -> set[str]:
tree = ast.parse(path.read_text(encoding="utf-8"))
names: set[str] = set()
for node in ast.walk(tree):
if isinstance(node, ast.Import):
for alias in node.names:
names.add(alias.name)
elif isinstance(node, ast.ImportFrom) and node.module:
names.add(node.module)
return names
def test_integration_sample_mdx_exists():
# Smoke fixture availability gate; explicit so a missing sample
# surfaces as a fixture problem, not a downstream assertion failure.
assert _SAMPLE_MDX_PATH.exists(), f"sample missing: {_SAMPLE_MDX_PATH}"
def test_integration_full_chain_runs_on_real_sample():
# Locks API contract over the full chain on a real MDX: returns a
# VerificationResult, area_name passthrough works, score within
# [0.0, 1.0], and detect_invented_text returns a list. No assertion
# is made about a specific score so the sample is not hardcoded as
# the pipeline's pass rule (Stage 2 u10 rationale).
mdx = _SAMPLE_MDX_PATH.read_text(encoding="utf-8")
html = f"<div>{mdx}</div>"
result = verify_text_preservation(mdx, html, "smoke")
assert isinstance(result, VerificationResult)
assert result.area_name == "smoke"
assert 0.0 <= result.score <= 1.0
assert isinstance(detect_invented_text(mdx, html), list)
def test_integration_mirrored_html_passes_default_threshold():
# When the HTML side mirrors the MDX text verbatim, the deterministic
# preservation check must pass the Phase Q-default threshold (0.70).
# This is the integration-level guarantee for the B-2 reverse path:
# round-tripped HTML that preserves the MDX text must verify.
mdx = _SAMPLE_MDX_PATH.read_text(encoding="utf-8")
html = f"<div>{mdx}</div>"
result = verify_text_preservation(mdx, html, "smoke")
assert result.passed is True
def test_integration_fabricated_html_flags_invented_text():
# Locks the hallucination-guard end-to-end: HTML text that has no
# keyword anchor in the source MDX must be flagged. Synthetic
# sentence chosen so its keywords (완전히, 만들어낸, 원본, 등장 …)
# do not appear in samples/mdx_batch/02.mdx.
mdx = _SAMPLE_MDX_PATH.read_text(encoding="utf-8")
fabricated_html = (
"<p>완전히 새로 만들어낸 문장으로 원본에는 전혀 등장하지 않는 내용입니다.</p>"
)
invented = detect_invented_text(mdx, fabricated_html)
assert isinstance(invented, list)
assert len(invented) >= 1
def test_integration_no_forbidden_imports():
# AI-isolation + Phase Z scope-lock guard. Production module and
# this test file must not import orchestrator / phase_z2_pipeline /
# Phase Q content_verifier / Kei client. AST scan of the on-disk
# source (not the imported module) so re-exports cannot mask a leak.
for path in (
_REPO_ROOT / "src" / "phase_z2_verification_utils.py",
Path(__file__).resolve(),
):
modules = _module_imports(path)
for module in modules:
for forbidden in _FORBIDDEN_IMPORT_ROOTS:
assert not (module == forbidden or module.startswith(forbidden + ".")), (
f"{path.name} imports forbidden module: {module}"
)
+84
View File
@@ -0,0 +1,84 @@
"""Tests for IMP-16-U1 unit u9: ``detect_invented_text``.
Locks the Phase Z port of the deterministic hallucination guard
(Phase Q reference: ``src/content_verifier.py:276-315``). The function
is pure and composes u2 (extract_text_from_html), u3
(normalize_for_comparison), and u4 (extract_keywords). No Phase Q
import is exercised.
"""
from __future__ import annotations
from src.phase_z2_verification_utils import (
_INVENTED_TEXT_ALLOWED_LABELS,
_INVENTED_TEXT_CSS_NUMBER_PATTERN,
_INVENTED_TEXT_KEYWORD_THRESHOLD,
_INVENTED_TEXT_MIN_LENGTH,
_INVENTED_TEXT_TRUNCATE_LEN,
detect_invented_text,
)
def test_detect_invented_text_constants_locked() -> None:
"""Lock the five named module constants ported from Phase Q literals."""
assert _INVENTED_TEXT_MIN_LENGTH == 15
assert _INVENTED_TEXT_ALLOWED_LABELS == frozenset(
{"용어 정의", "핵심 메시지", "상세 비교"}
)
assert _INVENTED_TEXT_CSS_NUMBER_PATTERN.pattern == r"^[\d\s.,%px#rgb()]+$"
assert _INVENTED_TEXT_KEYWORD_THRESHOLD == 0.4
assert _INVENTED_TEXT_TRUNCATE_LEN == 80
def test_detect_invented_text_returns_empty_when_html_is_in_mdx() -> None:
"""Text whose keywords fully appear in MDX is NOT flagged."""
mdx = "원본 콘텐츠는 분석에 관한 것입니다."
html = "<p>원본 콘텐츠는 분석에 관한 것입니다.</p>"
assert detect_invented_text(mdx, html) == []
def test_detect_invented_text_flags_text_with_low_keyword_overlap() -> None:
"""Text whose keywords do not appear in MDX is flagged as invented."""
mdx = "원본 콘텐츠는 분석에 관한 것입니다."
html = "<p>완전히 다른 발명된 텍스트가 여기 있습니다 일반적이지 않은</p>"
result = detect_invented_text(mdx, html)
assert len(result) == 1
assert "발명된" in result[0]
def test_detect_invented_text_skips_short_text() -> None:
"""Text shorter than ``min_length`` is not even considered."""
mdx = "원본 콘텐츠"
html = "<p>짧은 텍스트</p>"
assert detect_invented_text(mdx, html) == []
def test_detect_invented_text_skips_allowed_structural_labels() -> None:
"""Allowed labels are skipped even when keyword overlap is zero.
Phase Q default ``min_length=15`` makes the allowed-label gate
unreachable for the bundled labels (all < 15 chars). The Phase Z
port preserves the gate verbatim — exercised here with
``min_length=0`` so the structural-label short-circuit is
actually observable.
"""
mdx = "원본 콘텐츠"
html = "<h2>용어 정의</h2><h2>핵심 메시지</h2><h2>상세 비교</h2>"
assert detect_invented_text(mdx, html, min_length=0) == []
def test_detect_invented_text_skips_css_number_pattern_fragments() -> None:
"""CSS/numeric fragments (e.g. ``100px 200px 300px``) are skipped."""
mdx = "원본 콘텐츠"
html = "<style>.x { padding: 100px; }</style><div>100px 200px 300px</div>"
assert detect_invented_text(mdx, html) == []
def test_detect_invented_text_truncates_flagged_value_to_80_chars() -> None:
"""A flagged fragment longer than 80 chars is truncated for reporting."""
mdx = "원본 콘텐츠"
invented = "발명" * 50
html = f"<p>{invented}</p>"
result = detect_invented_text(mdx, html)
assert len(result) == 1
assert len(result[0]) == 80
assert result[0] == invented[:80]

Some files were not shown because too many files have changed in this diff Show More