"""Phase Z-2 overflow_router v0 (A2 — 정책 매핑 layer 만). fit_classifier 의 출력 (category) 를 spec §4 의 *proposed_action* 으로 매핑하는 layer. 본 module 은 ***매핑까지만***. 실제 action 실행은 별도 step (A3+). 출력 = 각 classification 에 proposed_action 추가 + router 전체 summary. 원칙 : - classifier = 사실 분류 (category 결정) - router = 정책 결정 (그 category 면 무엇을 *제안* 할 것인가) - 본 단계는 *제안 trace* 만. pipeline behavior / abort 정책 / rerender 변경 X - 실행 안 됨 → 현재 코드는 여전히 visual_check_passed=False 시 sys.exit(1) 그러나 debug.json 에 *어떤 action 이 제안됐는지* 가 기록됨 다음 step (별도 — A3) : zone_ratio_retry action 의 *실제 구현* — 지금 spec §4 mapping 의 가장 자주 트리거되는 action. """ from __future__ import annotations from typing import Optional # ─── §4 mapping table (spec PHASE-Z-FIT-CLASSIFIER-ROUTER-SPEC §4) ── # category → proposed_action (primary) # IMP-88 (#88) u1 (2026-05-24): two ACTION_BY_CATEGORY edits to align the # primary router surface with PHASE-Z-PIPELINE-OVERVIEW.md Step 16 + Step 17 # spec (anchor PHASE-Z-PIPELINE-OVERVIEW.md:321): # 1. NEW row `image_aspect_mismatch → image_fit` — closes the unmapped # classifier emission (phase_z2_classifier.py:434-447) that previously # returned proposed_action=None and stalled visual_check on overflow # runs carrying image_event payloads. # 2. REMAP `frame_capacity_mismatch → frame_internal_fit_candidate` # (previously frame_reselect) — OVERVIEW.md Step 17 locks # frame_internal_fit_candidate as the per-zone first-pass salvage # *inside* the declared frame envelope; frame_reselect (V4 top-k # alternate frame swap) stays available downstream via the # failure_router cascade (rerender_still_fails → frame_reselect). ACTION_BY_CATEGORY: dict[str, str] = { "minor_overflow": "zone_ratio_retry", "moderate_overflow": "layout_adjust", "structural_minor_overflow": "zone_ratio_retry", "structural_major_overflow": "details_popup_escalation", "tabular_overflow": "details_popup_escalation", "image_aspect_mismatch": "image_fit", "frame_capacity_mismatch": "frame_internal_fit_candidate", "layout_zone_mismatch": "layout_adjust", "hard_visual_fail": "abort", } # 매핑 근거 — *왜 이 category 면 이 action 인가* trace 용 ACTION_RATIONALE: dict[str, str] = { "minor_overflow": "1.5 줄 미만 text/label flow → zone 양보 / spacing 재계산으로 fit 가능", "moderate_overflow": "1.5~4 줄 text/label → layout/zone ratio 재분배 필요", "structural_minor_overflow": "structural unit boundary spill (<1 unit drop) → zone 양보로 fit, 단위 자르기 X", "structural_major_overflow": "1+ structural unit 완전 잘림 → 의미 손실, popup 으로 escalate", "tabular_overflow": "표는 행 단위로 잘리면 의미 손실 → popup escalate (또는 table-friendly frame reselect)", "image_aspect_mismatch": "image 자연 비율과 렌더 비율 mismatch → frame 내부 image fit (object-fit / " "max-w/h) 로 envelope 안에서 비율 회복. 공통 image CSS 변경 X (frame-scoped).", "frame_capacity_mismatch": "composition capacity_fit 가 이미 mismatch 신호 → frame contract envelope " "안 internal fit 변형 (density / line rhythm / row 배치) 우선. " "frame swap 은 cascade 다음 단계 (rerender_still_fails → frame_reselect).", "layout_zone_mismatch": "frame root 자체 overflow → layout preset 변경 또는 zone 키움", "hard_visual_fail": "위 매핑 모두 미적용 — 마지막 fallback (현재 코드는 sys.exit 으로 abort)", } # 각 action 의 *현재 코드* 구현 상태 (2026-04-29 기준; IMP-12 u7 cascade 2026-05-18; # IMP-35 u3 popup-stub 2026-05-23) # A2 단계에서 이 매핑이 *어디까지 자동 처리되고 어디서 막히는지* trace 확보용 ACTION_IMPLEMENTATION_STATUS: dict[str, str] = { "zone_ratio_retry": "IMPLEMENTED", # A3 (2026-04-29) phase_z2_retry.plan_zone_ratio_retry + pipeline orchestration # IMP-88 (#88) u1→u7 (2026-05-24): three Step 17 retry actions registered # here. u1 added the data-surface rows (initial state MISSING). u3/u4/u5 # landed the deterministic planners in src/phase_z2_retry.py. u6 wired the # salvage dispatcher (_attempt_salvage_chain), and u7 wired the Step 17 # entry runtime (_attempt_step17_image_fit_single_pass + §11.7.1/§11.7.2). # Status flips MISSING→IMPLEMENTED land here on u7 completion — once the # end-to-end path (planner + apply + dispatcher + entry) is wired the # action is IMPLEMENTED on the deterministic surface. (Same convention as # IMP-12 u7 cascade rows below: planner-surface availability + orchestrator # wiring together constitute IMPLEMENTED; route_action's # implementation_status field reflects surface availability, not whether a # given pipeline run has invoked the action.) "layout_adjust": "IMPLEMENTED", # u3 plan_layout_adjust + u6 dispatcher branch + u7 cascade entry "image_fit": "IMPLEMENTED", # u4 plan_image_fit + u7 _attempt_step17_image_fit_single_pass entry "frame_internal_fit_candidate": "IMPLEMENTED", # u5 plan_frame_internal_fit_candidate + u6 dispatcher branch + u7 cascade entry # IMP-35 (#64) u3 — MISSING → IMPLEMENTED on the primary router surface. # `plan_details_popup_escalation` (below) provides the deterministic stub # that downstream units consume: u4 binds the AI split-decision contract # in `src/phase_z2_ai_fallback/step17.py`; u5 wires the Step 17 POPUP # gate executor in `src/phase_z2_pipeline.py`. Router-level mapping is # decoupled from orchestrator wiring (same precedent as the IMP-12 u7 # cascade actions below): IMPLEMENTED here reflects deterministic # *surface availability* (importable stub), not whether a given pipeline # run has invoked it. The failure_router companion surface # (NEXT_ACTION_IMPLEMENTATION_STATUS in phase_z2_failure_router.py) keeps # `details_popup_escalation` as MISSING until u5 lands the pipeline gate. "details_popup_escalation": "IMPLEMENTED", "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 "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 } # ─── 단일 분류 → routing 결과 ───────────────────────────────────── def route_action(category: str) -> dict: """category → proposed_action mapping 결과 (단일). Returns: dict : proposed_action : action 이름 (또는 None) rationale : *왜* 이 action 인가 implementation_status : implemented / partial / missing / unknown mapping_source : "spec §4 ACTION_BY_CATEGORY" 또는 "no mapping" """ action = ACTION_BY_CATEGORY.get(category) if action is None: return { "proposed_action": None, "rationale": f"category '{category}' has no mapping in ACTION_BY_CATEGORY", "implementation_status": "unknown", "mapping_source": "no mapping (unknown category)", } return { "proposed_action": action, "rationale": ACTION_RATIONALE.get(category, ""), "implementation_status": ACTION_IMPLEMENTATION_STATUS.get(action, "unknown"), "mapping_source": "spec §4 ACTION_BY_CATEGORY", } # ─── fit_classification 전체 → router decision ────────────────── def route_fit_classification(fit_classification: dict) -> dict: """fit_classification 의 모든 classifications 에 proposed_action 추가 + summary. 각 classification 에 다음 필드를 *추가* (기존 필드 보존) : - proposed_action - proposed_action_rationale - proposed_action_implementation_status - proposed_action_mapping_source Returns: router decision summary dict : router_active : True/False (visual_check_passed=False 일 때만 True) proposed_actions_summary : unique action 들 sorted list implementation_status_summary : {status: count} dict routed_count : 처리된 classification 수 routed_details : per-classification routing trace missing_actions_pending_impl : 본 routing 에서 *현재 미구현* 인 action 모음 note : 사용자 안내 텍스트 """ if fit_classification.get("visual_check_passed", True): return { "router_active": False, "proposed_actions_summary": [], "implementation_status_summary": {}, "routed_count": 0, "routed_details": [], "missing_actions_pending_impl": [], "note": "visual check passed — no overflow to route", } classifications = fit_classification.get("classifications", []) or [] routed_details = [] for cls in classifications: category = cls.get("category", "hard_visual_fail") routing = route_action(category) # classification entry 에 proposed_action 정보 *추가* (기존 필드 보존) cls["proposed_action"] = routing["proposed_action"] cls["proposed_action_rationale"] = routing["rationale"] cls["proposed_action_implementation_status"] = routing["implementation_status"] cls["proposed_action_mapping_source"] = routing["mapping_source"] routed_details.append({ "source": cls.get("source"), "zone_position": cls.get("zone_position"), "category": category, "proposed_action": routing["proposed_action"], "implementation_status": routing["implementation_status"], }) # summary actions_seen = sorted({ r["proposed_action"] for r in routed_details if r["proposed_action"] is not None }) status_breakdown: dict[str, int] = {} missing_actions: list[str] = [] for r in routed_details: s = r["implementation_status"] status_breakdown[s] = status_breakdown.get(s, 0) + 1 if s == "MISSING" and r["proposed_action"] not in missing_actions: missing_actions.append(r["proposed_action"]) return { "router_active": True, "proposed_actions_summary": actions_seen, "implementation_status_summary": status_breakdown, "routed_count": len(routed_details), "routed_details": routed_details, "missing_actions_pending_impl": sorted(missing_actions), "note": ( "router 는 category → proposed_action 매핑까지 담당. 실제 action 실행은 " "pipeline 의 별도 orchestrator 가 처리 (예: zone_ratio_retry 는 " "_attempt_zone_ratio_retry 에서 실행). proposed_action 의 implementation_status " "가 IMPLEMENTED 이면 pipeline 이 시도하고 결과는 retry_trace 에 기록, " "MISSING 이면 그 action 은 실행 X 이고 기존 abort/status 흐름 (sys.exit(1)) 으로 종료." ), } # ─── IMP-35 (#64) u3 — details_popup_escalation deterministic stub ─ # Surface contract for the cascade-terminal popup escalation. This stub # does NOT mutate HTML / CSS / MDX content; it emits the canonical plan # marker that the Step 17 POPUP gate (u5) and the AI split-decision hook # (u4) consume. Keeping the executor surface here (next to the primary # ACTION_BY_CATEGORY mapping) lets the router report IMPLEMENTED for # `details_popup_escalation` while u4/u5 are still landing. # # Contract (locked in Stage 2 IMPLEMENTATION_UNITS u3): # - Inputs: classification dict (a single fit_classifier output row). # The category MUST be one of the two ACTION_BY_CATEGORY # rows that map onto `details_popup_escalation` — # `structural_major_overflow` or `tabular_overflow`. # Other categories raise the stub's defensive guard (so # callers do not silently popup-escalate the wrong category). # - Output: popup_escalation_plan dict with `feasible=True`, # `stub=True`, the source category, the canonical # ACTION_RATIONALE entry, and `needs_split_decision=True` # to flag that u4 (AI hook) must run before u5 renders. # - No side effects (no AI call, no MDX read, no HTML mutation). # # Guardrails honored: # - feedback_ai_isolation_contract: stub is deterministic-with-data; # no AI call inside the router surface. # - Phase Z spacing 방향: stub does not shrink common margins; it # expands capacity by routing content to popup downstream. # - 자세히보기 원칙 (CLAUDE.md): plan carries the marker that u5 uses # to put MDX 원문 in popup body and a summary/subset in preview. # - 1 turn = 1 unit: this is router-surface only. u4/u5 own the # downstream wiring on their respective files. # Categories that legitimately escalate onto details_popup_escalation # per the ACTION_BY_CATEGORY mapping above. Kept as a derived constant # so the router cannot drift away from the single source of truth. POPUP_ESCALATION_CATEGORIES: frozenset[str] = frozenset( category for category, action in ACTION_BY_CATEGORY.items() if action == "details_popup_escalation" ) def plan_details_popup_escalation(classification: dict) -> dict: """Cascade-terminal popup escalation plan stub (IMP-35 u3). Returns a deterministic popup_escalation_plan marker. The actual content split (popup_html / preview_text / has_popup payload) is composed downstream: u4 binds the AI split-decision contract on `src/phase_z2_ai_fallback/step17.py`; u5 wires the Step 17 POPUP gate executor on `src/phase_z2_pipeline.py`. Args: classification: a single fit_classifier classification dict. Must contain a `category` key. Only the categories that map onto `details_popup_escalation` in ACTION_BY_CATEGORY (currently `structural_major_overflow` and `tabular_overflow`) are accepted; any other category produces an `feasible=False` plan with `failure_reason` so the caller never silently popup-escalates the wrong overflow shape. Returns: popup_escalation_plan dict with at least: action : "details_popup_escalation" feasible : True/False (True for accepted categories) stub : True (marks u3 surface; u4/u5 fill in) category : echoed from input rationale : canonical ACTION_RATIONALE entry needs_split_decision : True (u4 AI hook must run before u5 renders) mapping_source : "IMP-35 u3 plan_details_popup_escalation stub" note : downstream-wiring pointer text """ category = (classification or {}).get("category") base = { "action": "details_popup_escalation", "stub": True, "category": category, "mapping_source": "IMP-35 u3 plan_details_popup_escalation stub", } if category not in POPUP_ESCALATION_CATEGORIES: return { **base, "feasible": False, "needs_split_decision": False, "rationale": "", "failure_reason": ( f"category {category!r} does not map onto details_popup_escalation " f"in ACTION_BY_CATEGORY. Accepted categories: " f"{sorted(POPUP_ESCALATION_CATEGORIES)}. Defensive guard — " f"router must not silently popup-escalate the wrong overflow shape." ), "note": ( "u3 stub — caller passed a category that should not popup-escalate. " "Honour the ACTION_BY_CATEGORY mapping at the router entry point." ), } return { **base, "feasible": True, "needs_split_decision": True, "rationale": ACTION_RATIONALE.get(category, ""), "note": ( "u3 stub — actual content split planning lands in u4 " "(AI split-decision contract on src/phase_z2_ai_fallback/step17.py) " "and u5 (Step 17 POPUP gate executor on src/phase_z2_pipeline.py). " "popup body = MDX 원문, preview = summary/subset (자세히보기 원칙)." ), }