Files
C.E.L_Slide_test2/src/phase_z2_router.py
T
KyeongminandClaude Opus 4.7 f3ef4d917c feat(#64): IMP-35 details_popup_escalation u1~u10 + Stage 3 R7 anchor re-pin
Land the production + test surface for the Step 17 cascade POPUP terminal
(DETERMINISTIC -> POPUP -> AI_REPAIR -> USER_OVERRIDE) per Stage 2 plan R2.
u11 (baseline-red invariance gate) was already landed in 7c93031 ahead of
this commit; this commit completes u1~u10 plus the Stage 3 R7 follow-up
anchor re-pin for test_imp17_comment_anchor.py.

Implementation units (Stage 2 R2 contract):
  u1  frame_reselect_insufficient failure_type + post-frame remeasure (q4)
        - src/phase_z2_failure_router.py, src/phase_z2_pipeline.py
  u2  NEXT_ACTION_BY_FAILURE row + impl_status flip
        - src/phase_z2_failure_router.py
  u3  Router details_popup_escalation MISSING->IMPLEMENTED + executor stub
        - src/phase_z2_router.py
  u4  step17.py AI split-decision contract (POPUP cascade_stage +
      route_for_label + skip_reason); API gated
        - src/phase_z2_ai_fallback/step17.py
  u5  Step 17 POPUP gate executor; popup_escalation_plan + has_popup marker
        - src/phase_z2_pipeline.py, src/phase_z2_ai_fallback/step17.py
  u6  Composition popup binding -- yaml strategy -> zone payload
        - src/phase_z2_composition.py
  u7  Pipeline composer -> render_slide wiring
      (popup_html / preview_text / has_popup)
        - src/phase_z2_pipeline.py
  u8  slide_base.html <details>/<summary> popup wrapper
        - templates/phase_z2/slide_base.html
  u9  display_strategies.yaml inline_preview + popup metadata
        - templates/phase_z2/regions/display_strategies.yaml
  u10 MDX preservation invariant: popup=full source / body=summary or subset
        (asserted by tests/phase_z2/test_popup_mdx_preservation.py)
  u11 (already in 7c93031) -- baseline-red invariance gate

Stage 3 R7 follow-up (anchor re-pin, test-only):
  - tests/orchestrator_unit/test_imp17_comment_anchor.py
    Pre-anchor additions in src/phase_z2_pipeline.py (u1 / u5 / u7) shifted
    the restructure/reject route-hint comments 578/579 -> 586/587. Re-pinned
    the two guard tests (and docstring re-pin lineage 564 -> 570 -> 578 ->
    586). Production code untouched.

Verification (Stage 4 R1):
  pytest -q tests/orchestrator_unit/test_imp17_comment_anchor.py
    -> 2 passed / 0.02s
  pytest -q <10 IMP-35 unit files in tests/phase_z2 + tests/phase_z2_ai_fallback>
    -> 136 passed / 15.94s
  Baseline-red invariance gate
    (tests/test_imp47b_step12_ai_wiring.py +
     tests/test_phase_z2_ai_fallback_config.py)
    -> 4 failed / 6 passed; FAILED set === IMP35_BASELINE_RED_NODE_IDS
    (frozen registry from 7c93031). Contract holds.
  Codex Stage 4 R1 = YES (independent verify).

Guardrails honored:
  - MDX content preservation: popup carries full source, body holds
    summary or subset only (CLAUDE.md 자세히보기 원칙;
    feedback_phase_z_spacing_direction -- capacity expanded, no margin shrink).
  - AI isolation contract: Step 17 POPUP gate is deterministic; AI hook
    surface is split-decision contract only, API call gated.
  - No hardcoding: escalation thresholds derived from existing overflow
    detector outputs; preview_chars deterministic from container px.
  - 1 commit = 1 decision unit: u1~u10 land together as the planned
    production surface; u11 was deliberately split into 7c93031 as Stage 3
    R7 carve-out, and the R7 anchor re-pin rides with this commit because
    it is the direct shift consequence of the u1/u5/u7 pre-anchor additions.
  - Scope-locked: .claude/settings.json explicitly excluded
    (Stage 4 exit report contract).

Out of scope (per Stage 1 + Stage 2):
  - AI_REPAIR API activation (post IMP-35 axis).
  - IMP-34 zone resize, IMP-36 responsive fit (chain partners,
    separate issues).
  - Print-time auto-expand JavaScript for <details>.
  - Popup escalation in stages other than Step 17.
  - Baseline-red body repair (4 frozen failures) -- separate follow-up
    issue; u11 only guards the count.
  - frame_reselect algorithm changes (entry point only).
  - templates/phase_z2/slide_base.html path rename.

source_comment_ids:
  Stage 1: claude_stage1_problem_review_imp35, codex_stage1_verification_imp35_yes
  Stage 2: Claude #4 R2 plan, Codex #5 R2 YES
  Stage 3: Claude #86 (R7 anchor re-pin), Codex #87 YES
  Stage 4: Claude #88 R1, Codex #89 R1 YES

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 07:36:57 +09:00

309 lines
15 KiB
Python

"""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)
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",
"frame_capacity_mismatch": "frame_reselect",
"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)",
"frame_capacity_mismatch":
"composition capacity_fit 가 이미 mismatch 신호 → V4 top-k 의 다른 frame 평가",
"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
"layout_adjust": "MISSING",
# 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 (자세히보기 원칙)."
),
}