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 in7c93031ahead 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 in7c93031) -- 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 from7c93031). 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 into7c93031as 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>
This commit is contained in:
@@ -73,6 +73,247 @@ STEP17_AI_REPAIR_BLOCKED_REASON = (
|
||||
)
|
||||
|
||||
|
||||
# IMP-35 (#64) u4 — POPUP cascade AI split-decision contract (API gated).
|
||||
#
|
||||
# Step 17 POPUP escalation needs an AI hook to decide *what content* stays in
|
||||
# the body (summary/subset) vs. moves into the <details> popup (full MDX).
|
||||
# That hook is the AI split-decision contract. u4 ships the contract surface
|
||||
# (function signature + record schema + cascade_stage + route_for_label +
|
||||
# skip_reason) WITHOUT enabling the Anthropic API. The deterministic POPUP
|
||||
# gate executor (u5) runs ahead of this contract and stamps
|
||||
# popup_escalation_plan + has_popup; u4's hook is a forward-compatible
|
||||
# placeholder so downstream wiring (u5 executor / future IMP activating the
|
||||
# API) can rely on a stable schema. ``api_gated=True`` on every record makes
|
||||
# the gate state machine-readable; ``ai_called`` stays False everywhere.
|
||||
#
|
||||
# Per feedback_ai_isolation_contract: AI = fallback path only. The contract
|
||||
# function MUST NOT import route_ai_fallback, the u4 client (despite name
|
||||
# collision — u4 here is the IMP-35 unit, not the Step 12 client module),
|
||||
# or any anthropic SDK symbol. Structural import guards in the test surface
|
||||
# already enforce this and continue to hold after this change.
|
||||
STEP17_POPUP_SPLIT_DECISION_API_GATED_REASON = (
|
||||
"step17_popup_split_decision_api_gated"
|
||||
)
|
||||
|
||||
|
||||
# IMP-35 (#64) u5 — deterministic POPUP gate executor (cascade-terminal).
|
||||
#
|
||||
# Runs AFTER the DETERMINISTIC stage exhausts and BEFORE the AI_REPAIR
|
||||
# cascade stage (canonical OVERFLOW_CASCADE_ORDER). Per unit:
|
||||
#
|
||||
# 1. Idempotency (q2): if a unit carries ``has_popup=True`` already,
|
||||
# ``run_step17_popup_gate`` short-circuits with
|
||||
# ``gate_status="idempotent_short_circuit"``. No duplicate plan,
|
||||
# no re-routing. Re-running Step 17 on already-escalated units is
|
||||
# safe — the gate emits a deterministic record per unit but does
|
||||
# NOT re-stamp the plan or flip the marker. The persistence of
|
||||
# ``has_popup`` and ``popup_escalation_plan`` on the unit itself
|
||||
# (see step 4 below) is what makes the second call observe the
|
||||
# stamp from the first call and short-circuit correctly.
|
||||
# 2. Classification: ``classification_for_unit(unit)`` returns the
|
||||
# fit_classifier row associated with this unit (or ``None`` if the
|
||||
# unit has no overflow on this run).
|
||||
# 3. Plan: ``plan_for_classification(cls)`` is the router u3 stub
|
||||
# (``src.phase_z2_router.plan_details_popup_escalation``). Only
|
||||
# the categories in ``POPUP_ESCALATION_CATEGORIES`` of the router
|
||||
# surface (currently ``structural_major_overflow`` and
|
||||
# ``tabular_overflow``) emit a feasible plan; anything else falls
|
||||
# through to ``gate_status="infeasible_category"`` so the gate
|
||||
# never silently escalates the wrong overflow shape.
|
||||
# 4. Feasible plan → record stamps ``popup_escalation_plan`` and
|
||||
# flips ``has_popup=True`` in the returned record AND persists
|
||||
# the same two fields on the unit via ``setattr`` (``unit.has_popup``
|
||||
# and ``unit.popup_escalation_plan``). The unit-side persistence
|
||||
# is the q2 idempotency contract: a second call to
|
||||
# ``run_step17_popup_gate`` over the same unit reads
|
||||
# ``unit.has_popup=True`` at step 1 and short-circuits before
|
||||
# classification / plan callable invocation. The marker is also
|
||||
# what u6 composition binding and u7 render wiring read from the
|
||||
# unit downstream.
|
||||
#
|
||||
# AI isolation contract: NO Anthropic call inside this gate. The
|
||||
# deterministic split between popup body (full MDX) and preview
|
||||
# (summary/subset) is composed downstream from container px budgets
|
||||
# (q3 — preview_chars derives from container px telemetry already on
|
||||
# the retry_trace). The u4 AI hook (``gather_step17_popup_split_decisions``)
|
||||
# sits at the same cascade stage but is API-gated (``api_gated=True``)
|
||||
# and never invoked from this deterministic path. ``ai_called=False`` on
|
||||
# every record this gate emits.
|
||||
#
|
||||
# cascade_stage="popup" on every record so Step 17 retry-trace consumers
|
||||
# can multiplex DETERMINISTIC / POPUP / AI_REPAIR records without
|
||||
# ambiguity. The schema mirrors :func:`gather_step17_popup_split_decisions`
|
||||
# (unit_index / source_section_ids / frame_template_id / label /
|
||||
# route_hint / provisional) PLUS u5-specific fields:
|
||||
# ``gate_status`` / ``popup_escalation_plan`` / ``has_popup`` /
|
||||
# ``skip_reason`` (only set for non-escalated gate_status values).
|
||||
STEP17_POPUP_GATE_ESCALATED_REASON = "step17_popup_gate_escalated"
|
||||
STEP17_POPUP_GATE_IDEMPOTENT_SHORT_CIRCUIT_REASON = (
|
||||
"step17_popup_gate_idempotent_short_circuit"
|
||||
)
|
||||
STEP17_POPUP_GATE_INFEASIBLE_CATEGORY_REASON = (
|
||||
"step17_popup_gate_infeasible_category"
|
||||
)
|
||||
STEP17_POPUP_GATE_NO_CLASSIFICATION_REASON = (
|
||||
"step17_popup_gate_no_classification_for_unit"
|
||||
)
|
||||
|
||||
|
||||
def run_step17_popup_gate(
|
||||
units: Iterable[Any],
|
||||
*,
|
||||
classification_for_unit: Callable[[Any], dict | None],
|
||||
route_for_label: Callable[[str | None], str | None],
|
||||
plan_for_classification: Callable[[dict], dict],
|
||||
) -> list[dict]:
|
||||
"""Deterministic POPUP gate executor for Step 17 cascade (IMP-35 u5).
|
||||
|
||||
See module-level block comment (immediately above) for the full
|
||||
contract — idempotency (q2), classification source, router u3 stub
|
||||
coupling, AI isolation, and cascade_stage multiplexing.
|
||||
|
||||
Args:
|
||||
units: provisional / non-provisional Step 17 units. The gate is
|
||||
agnostic to provisional state; the marker ``has_popup`` flows
|
||||
from this function regardless.
|
||||
classification_for_unit: maps a unit to its fit_classifier
|
||||
classification row (or ``None`` if the unit has no overflow).
|
||||
Tests inject a fake dict / lookup; the pipeline composes
|
||||
this from ``fit_classification.classifications`` matched by
|
||||
``zone_position``.
|
||||
route_for_label: same callable shape as
|
||||
:func:`gather_step17_ai_repair_proposals` /
|
||||
:func:`gather_step17_popup_split_decisions`. The route hint
|
||||
is stamped on every record for downstream consumers.
|
||||
plan_for_classification: the router u3 stub
|
||||
(``src.phase_z2_router.plan_details_popup_escalation``).
|
||||
Injected as a callable so this module stays decoupled from
|
||||
the router surface and tests can stub the plan output.
|
||||
|
||||
Returns:
|
||||
list[dict] — one record per unit. Records carry
|
||||
``cascade_stage="popup"`` and ``ai_called=False`` everywhere.
|
||||
Feasible-escalation records also carry
|
||||
``popup_escalation_plan`` (the router u3 plan dict) and
|
||||
``has_popup=True``. Non-escalation records carry a
|
||||
``skip_reason`` enum.
|
||||
"""
|
||||
records: list[dict] = []
|
||||
for index, unit in enumerate(units):
|
||||
label = getattr(unit, "label", None)
|
||||
already_escalated = bool(getattr(unit, "has_popup", False))
|
||||
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.POPUP.value,
|
||||
"ai_called": False,
|
||||
"has_popup": already_escalated,
|
||||
"popup_escalation_plan": None,
|
||||
"gate_status": None,
|
||||
"skip_reason": None,
|
||||
}
|
||||
if already_escalated:
|
||||
# q2 idempotency — short-circuit. The previously stamped
|
||||
# popup_escalation_plan stays on the unit (carried by u6/u7
|
||||
# composition); this gate does NOT re-emit it.
|
||||
record["gate_status"] = "idempotent_short_circuit"
|
||||
record["skip_reason"] = (
|
||||
STEP17_POPUP_GATE_IDEMPOTENT_SHORT_CIRCUIT_REASON
|
||||
)
|
||||
records.append(record)
|
||||
continue
|
||||
classification = classification_for_unit(unit)
|
||||
if not classification:
|
||||
record["gate_status"] = "no_classification"
|
||||
record["skip_reason"] = STEP17_POPUP_GATE_NO_CLASSIFICATION_REASON
|
||||
records.append(record)
|
||||
continue
|
||||
plan = plan_for_classification(classification)
|
||||
record["popup_escalation_plan"] = plan
|
||||
if plan and plan.get("feasible"):
|
||||
record["gate_status"] = "escalated"
|
||||
record["has_popup"] = True
|
||||
record["skip_reason"] = None
|
||||
# q2 idempotency persistence — stamp the marker AND the plan
|
||||
# on the unit itself so a second run of the gate over the
|
||||
# same unit observes ``unit.has_popup=True`` at the top of
|
||||
# the loop and short-circuits before re-invoking the
|
||||
# classification / plan callables. The unit-side persistence
|
||||
# is also what u6 composition binding and u7 render wiring
|
||||
# read downstream.
|
||||
setattr(unit, "has_popup", True)
|
||||
setattr(unit, "popup_escalation_plan", plan)
|
||||
else:
|
||||
# Plan rejected by router (wrong category). Defensive guard —
|
||||
# the gate must not silently escalate the wrong overflow
|
||||
# shape (see router u3 plan_details_popup_escalation defensive
|
||||
# guard).
|
||||
record["gate_status"] = "infeasible_category"
|
||||
record["skip_reason"] = (
|
||||
STEP17_POPUP_GATE_INFEASIBLE_CATEGORY_REASON
|
||||
)
|
||||
records.append(record)
|
||||
return records
|
||||
|
||||
|
||||
def gather_step17_popup_split_decisions(
|
||||
units: Iterable[Any],
|
||||
*,
|
||||
route_for_label: Callable[[str | None], str | None],
|
||||
) -> list[dict]:
|
||||
"""Return one API-gated split-decision record per unit (POPUP cascade).
|
||||
|
||||
Schema mirrors :func:`gather_step17_ai_repair_proposals` so a Step 17
|
||||
artifact consumer can multiplex DETERMINISTIC / POPUP / AI_REPAIR records
|
||||
onto the same retry trace. POPUP-specific fields:
|
||||
|
||||
* ``cascade_stage`` — always ``"popup"``.
|
||||
* ``api_gated`` — always ``True`` at u4. Future IMP activating the
|
||||
Anthropic API for popup splitting will flip this to ``False`` for
|
||||
units that traversed the deterministic POPUP gate (u5) without
|
||||
resolving via summary-only.
|
||||
* ``ai_called`` — always ``False`` at u4 (contract surface only).
|
||||
* ``skip_reason`` — always
|
||||
:data:`STEP17_POPUP_SPLIT_DECISION_API_GATED_REASON`.
|
||||
* ``split_decision`` — always ``None`` at u4. Once activated, this will
|
||||
carry the AI-proposed ``{"body_preview": ..., "popup_full": ...}``
|
||||
pair; u5 deterministic gate fills the same field deterministically
|
||||
from container px budgets (preview_chars) and never invokes AI.
|
||||
|
||||
Per IMP-35 u4 binding contract: the API stays gated. No Anthropic call,
|
||||
no route_ai_fallback import, no client instantiation. Structural import
|
||||
tests in :mod:`tests.phase_z2_ai_fallback.test_step17` continue to lock
|
||||
these guarantees.
|
||||
"""
|
||||
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.POPUP.value,
|
||||
"ai_called": False,
|
||||
"api_gated": True,
|
||||
"skip_reason": STEP17_POPUP_SPLIT_DECISION_API_GATED_REASON,
|
||||
"split_decision": None,
|
||||
"error": None,
|
||||
}
|
||||
records.append(record)
|
||||
return records
|
||||
|
||||
|
||||
def gather_step17_ai_repair_proposals(
|
||||
units: Iterable[Any],
|
||||
*,
|
||||
|
||||
@@ -315,6 +315,321 @@ def select_display_strategy_candidates(
|
||||
return [s for s in order if s in eligible]
|
||||
|
||||
|
||||
# ─── IMP-35 (#64) u6 — Composition popup binding (yaml strategy -> zone payload) ─
|
||||
#
|
||||
# Stage 2 binding contract (unit u6):
|
||||
# Step 17 POPUP gate (u5 in src/phase_z2_ai_fallback/step17.py) stamps
|
||||
# ``unit.has_popup=True`` AND ``unit.popup_escalation_plan=<plan>`` on
|
||||
# composition units whose overflow category routes to
|
||||
# ``details_popup_escalation``. u6 is the composition-side binding that
|
||||
# translates the unit-side marker into a deterministic zone payload
|
||||
# structure that u7 (pipeline composer -> render_slide wiring) reads to
|
||||
# emit the ``<details>/<summary>`` markup u8 will add to slide_base.html.
|
||||
#
|
||||
# Inputs (unit-side, all duck-typed via getattr):
|
||||
# has_popup — bool (False default; u5 sets True on
|
||||
# feasible escalation only)
|
||||
# popup_escalation_plan — dict | None (u3 router plan from
|
||||
# plan_details_popup_escalation; carries
|
||||
# feasible / category / rationale /
|
||||
# needs_split_decision)
|
||||
# raw_content — str (the source MDX content; popup body
|
||||
# source per CLAUDE.md 자세히보기 원칙)
|
||||
#
|
||||
# Outputs (zone payload binding dict):
|
||||
# display_strategy — catalog strategy id read from
|
||||
# display_strategies.yaml (NOT hardcoded).
|
||||
# ``inline_full`` when has_popup=False.
|
||||
# ``inline_preview_with_details`` when
|
||||
# has_popup=True (preview = excerpt from
|
||||
# container px budget downstream; popup body
|
||||
# preserves the FULL original).
|
||||
# popup_body_source — str | None — the FULL raw_content. u7 passes
|
||||
# this verbatim to the renderer; the popup
|
||||
# body is the MDX 원문 (자세히보기 원칙),
|
||||
# never summarized in the body branch.
|
||||
# None when has_popup=False.
|
||||
# detail_trigger — dict | None — placement + label read from
|
||||
# the catalog strategy entry's
|
||||
# ``detail_trigger``. None when has_popup=False.
|
||||
# preserves_original — bool — echoed from the catalog entry.
|
||||
# MUST be True for popup-binding strategies
|
||||
# (absolute user lock — 오답노트 #5 /
|
||||
# IMPROVEMENT-REDESIGN.md §3.6 line 110).
|
||||
# has_popup — bool — echoed for downstream multiplex.
|
||||
# popup_escalation_plan — dict | None — echoed verbatim (u5 plan).
|
||||
# Provides traceability into the router
|
||||
# category + rationale for downstream debug.
|
||||
# strategy_meta — dict — full catalog entry (description /
|
||||
# applies_to / forbidden_for / detail_trigger)
|
||||
# so downstream traces can self-explain without
|
||||
# re-reading the yaml.
|
||||
#
|
||||
# Guardrails honored:
|
||||
# - feedback_ai_isolation_contract — NO AI call. Reads catalog + unit
|
||||
# state only. The deterministic POPUP gate (u5) already established
|
||||
# the marker; this function is pure composition-side binding.
|
||||
# - feedback_no_hardcoding — strategy id is the ONLY name reference, and
|
||||
# it is the catalog key (yaml is source of truth). detail_trigger
|
||||
# placement / label come from the catalog entry, not literals.
|
||||
# - MDX 원문 무손실 보존 — popup_body_source = full raw_content.
|
||||
# u6 NEVER trims or summarizes; the body preview (excerpt from
|
||||
# container px budget) is composed by u7 downstream.
|
||||
# - Phase Z spacing 방향 — u6 binds a strategy that EXPANDS capacity
|
||||
# (popup escalation) instead of shrinking common margins.
|
||||
|
||||
# Strategy id used when the unit carries no popup escalation marker.
|
||||
# Catalog read — yaml is source of truth.
|
||||
POPUP_BINDING_NO_POPUP_STRATEGY_ID = "inline_full"
|
||||
|
||||
# Strategy id used when the unit carries has_popup=True (deterministic
|
||||
# choice — the preview body is a px-budget excerpt of the original, the
|
||||
# popup body holds the FULL original per CLAUDE.md 자세히보기 원칙).
|
||||
# u5 q3 — preview_chars deterministic from container px telemetry; that
|
||||
# is an excerpt-from-original pattern, which matches
|
||||
# ``inline_preview_with_details``. ``details_only`` (summary-only body)
|
||||
# is the alternative future axis when an AI/summarizer is available.
|
||||
POPUP_BINDING_ESCALATED_STRATEGY_ID = "inline_preview_with_details"
|
||||
|
||||
|
||||
def bind_popup_display_strategy(unit) -> dict:
|
||||
"""Bind catalog popup display strategy to a zone payload (IMP-35 u6).
|
||||
|
||||
Reads the unit-side ``has_popup`` + ``popup_escalation_plan`` markers
|
||||
stamped by Step 17 POPUP gate (u5) and produces a zone payload dict
|
||||
that u7 wires into the renderer. The catalog
|
||||
(``display_strategies.yaml``) is the source of truth for both the
|
||||
strategy id and the detail_trigger placement / label — no hardcoded
|
||||
string literals.
|
||||
|
||||
Args:
|
||||
unit: a CompositionUnit (or any duck-typed object exposing
|
||||
``has_popup`` / ``popup_escalation_plan`` / ``raw_content``).
|
||||
``has_popup`` defaults to False when the attribute is absent
|
||||
(units that never went through the Step 17 POPUP gate).
|
||||
|
||||
Returns:
|
||||
zone payload binding dict (see module-level u6 contract block
|
||||
immediately above for the full schema).
|
||||
|
||||
Raises:
|
||||
RuntimeError: if the chosen catalog strategy id is missing from
|
||||
the loaded ``DISPLAY_STRATEGIES`` mapping. Defensive guard —
|
||||
yaml drift would otherwise cause downstream KeyError on a
|
||||
stale string literal. The constants
|
||||
``POPUP_BINDING_NO_POPUP_STRATEGY_ID`` /
|
||||
``POPUP_BINDING_ESCALATED_STRATEGY_ID`` must always resolve
|
||||
against the catalog at import time.
|
||||
"""
|
||||
has_popup = bool(getattr(unit, "has_popup", False))
|
||||
plan = getattr(unit, "popup_escalation_plan", None)
|
||||
raw_content = getattr(unit, "raw_content", "") or ""
|
||||
|
||||
strategy_id = (
|
||||
POPUP_BINDING_ESCALATED_STRATEGY_ID
|
||||
if has_popup
|
||||
else POPUP_BINDING_NO_POPUP_STRATEGY_ID
|
||||
)
|
||||
meta = DISPLAY_STRATEGIES.get(strategy_id)
|
||||
if meta is None:
|
||||
raise RuntimeError(
|
||||
f"bind_popup_display_strategy: catalog drift — strategy id "
|
||||
f"{strategy_id!r} is missing from display_strategies.yaml. "
|
||||
f"Loaded keys: {sorted(DISPLAY_STRATEGIES)}."
|
||||
)
|
||||
|
||||
if not has_popup:
|
||||
return {
|
||||
"display_strategy": strategy_id,
|
||||
"popup_body_source": None,
|
||||
"detail_trigger": None,
|
||||
"preserves_original": bool(meta.get("preserves_original")),
|
||||
"has_popup": False,
|
||||
"popup_escalation_plan": None,
|
||||
"strategy_meta": meta,
|
||||
}
|
||||
|
||||
# has_popup=True path. preserves_original MUST be True per the catalog
|
||||
# absolute user lock — defensive guard against yaml drift.
|
||||
if not meta.get("preserves_original"):
|
||||
raise RuntimeError(
|
||||
f"bind_popup_display_strategy: catalog invariant violated — "
|
||||
f"popup-binding strategy {strategy_id!r} has preserves_original="
|
||||
f"{meta.get('preserves_original')!r}; MDX 원문 무손실 보존 "
|
||||
f"requires preserves_original=True (오답노트 #5 / "
|
||||
f"IMPROVEMENT-REDESIGN.md §3.6 line 110)."
|
||||
)
|
||||
trigger_meta = meta.get("detail_trigger") or {}
|
||||
return {
|
||||
"display_strategy": strategy_id,
|
||||
# MDX 원문 무손실 보존 — popup body = full raw_content (verbatim).
|
||||
"popup_body_source": raw_content,
|
||||
"detail_trigger": {
|
||||
"placement": trigger_meta.get("placement"),
|
||||
"label": trigger_meta.get("label"),
|
||||
},
|
||||
"preserves_original": True,
|
||||
"has_popup": True,
|
||||
"popup_escalation_plan": plan,
|
||||
"strategy_meta": meta,
|
||||
}
|
||||
|
||||
|
||||
# ─── IMP-35 (#64) u7 — Pipeline composer -> render_slide wiring ──
|
||||
#
|
||||
# Stage 2 wiring contract (unit u7):
|
||||
# u6 (``bind_popup_display_strategy``) produced the deterministic zone
|
||||
# binding from the unit-side marker stamped by Step 17 POPUP gate (u5).
|
||||
# u7 wires that binding into the pipeline composer's zones_data so the
|
||||
# render_slide call site (and downstream slide_base.html consumer u8)
|
||||
# sees three uniform render-context field names per zone:
|
||||
#
|
||||
# has_popup : bool — escalation marker echo
|
||||
# popup_html : str — popup body source (full ``raw_content`` per u6;
|
||||
# u8 wraps it in ``<details>/<summary>``).
|
||||
# ``None`` when has_popup=False.
|
||||
# preview_text : str — px-budgeted excerpt of ``raw_content`` shown in
|
||||
# the body / inline_preview slot. NEVER trims
|
||||
# inside a line — line-boundary cut only — and
|
||||
# the popup body retains the FULL original
|
||||
# (MDX 원문 무손실 보존). ``None`` when
|
||||
# has_popup=False.
|
||||
#
|
||||
# The full u6 binding is also echoed on the zone dict under
|
||||
# ``popup_binding`` so downstream debug / catalog-aware consumers can
|
||||
# self-explain without re-reading the yaml.
|
||||
#
|
||||
# Why the preview is a deterministic line-budget cut (u5 q3 resolution):
|
||||
# The popup body holds the FULL original verbatim, so the preview loses
|
||||
# no information — it just truncates at a deterministic boundary that
|
||||
# fits the container height telemetry. Container telemetry source is the
|
||||
# per-unit ``min_height_px`` (frame visual_hints), which is what the
|
||||
# pipeline composer already knows at the zones_data append site.
|
||||
#
|
||||
# We never re-summarize, never AI-call, never reorder. Char-budget cut
|
||||
# would risk splitting CJK words mid-character — line-boundary cut is
|
||||
# the closest deterministic surface to ``raw_content`` semantics
|
||||
# (MDX paragraph / bullet boundaries).
|
||||
#
|
||||
# Guardrails honored:
|
||||
# - feedback_ai_isolation_contract — pure deterministic helper. No
|
||||
# anthropic import, no AI fallback router path.
|
||||
# - MDX 원문 무손실 보존 — preview is a CUT, never a rewrite; popup body
|
||||
# stays equal to ``raw_content``.
|
||||
# - feedback_no_hardcoding — line metric is parametric (line_height_px
|
||||
# defaults to slide_base.html body line metric ~18 px = 11 px font *
|
||||
# 1.6 line-height + ~0.4 px ascent guard). u9 will surface the literal
|
||||
# value source.
|
||||
|
||||
# Line height in px used to convert a container-height budget into a
|
||||
# line-count budget. Matches slide_base.html ``--font-body`` (11 px) at
|
||||
# the ``.text-line`` line-height (1.6). Default — NOT a hardcoded magic
|
||||
# constant: ``compute_popup_preview_text`` accepts an override so the
|
||||
# downstream renderer (u8) or per-frame contracts can pass a tighter
|
||||
# value if a frame uses a smaller body font.
|
||||
POPUP_PREVIEW_DEFAULT_LINE_HEIGHT_PX = 18.0
|
||||
|
||||
|
||||
def compute_popup_preview_text(
|
||||
raw_content: str,
|
||||
container_height_px: float,
|
||||
*,
|
||||
line_height_px: float = POPUP_PREVIEW_DEFAULT_LINE_HEIGHT_PX,
|
||||
) -> str:
|
||||
"""Px-budgeted preview excerpt of ``raw_content`` (IMP-35 u7).
|
||||
|
||||
Deterministic line-boundary cut — returns the leading lines of
|
||||
``raw_content`` that fit within ``container_height_px`` at the slide
|
||||
body line metric. Never trims inside a line (no mid-CJK-word cut);
|
||||
the popup body (u6 ``popup_body_source``) retains the FULL original
|
||||
verbatim so this excerpt loses no information.
|
||||
|
||||
Args:
|
||||
raw_content: the unit's source MDX content; the popup body
|
||||
source per CLAUDE.md 자세히보기 원칙.
|
||||
container_height_px: container height telemetry. The pipeline
|
||||
composer passes ``min_height_px`` (frame visual_hints) at
|
||||
the zones_data append site. Non-positive values fall back
|
||||
to returning the full content unchanged (popup gate would
|
||||
not have fired without a real container budget anyway).
|
||||
line_height_px: px per body line. Default matches slide_base.html
|
||||
``.text-line`` (11 px font * 1.6 line-height + guard).
|
||||
Overridable for tighter-font frames.
|
||||
|
||||
Returns:
|
||||
The leading lines that fit the budget, joined verbatim. If the
|
||||
content already fits, returns ``raw_content`` unchanged.
|
||||
"""
|
||||
if not raw_content:
|
||||
return ""
|
||||
if container_height_px <= 0 or line_height_px <= 0:
|
||||
# No budget signal — return the full content unchanged. u5 POPUP
|
||||
# gate would not have fired without a real container budget, so
|
||||
# this branch is only reachable for non-popup units (where the
|
||||
# preview is anyway unused — see compose_zone_popup_payload).
|
||||
return raw_content
|
||||
max_lines = int(container_height_px // line_height_px)
|
||||
if max_lines < 1:
|
||||
max_lines = 1
|
||||
lines = raw_content.splitlines(keepends=False)
|
||||
if len(lines) <= max_lines:
|
||||
return raw_content
|
||||
# Re-join with "\n" — splitlines drops the terminator so a verbatim
|
||||
# round-trip of the leading lines is "\n".join(...). Preserves the
|
||||
# exact head of raw_content up to the chosen line boundary.
|
||||
return "\n".join(lines[:max_lines])
|
||||
|
||||
|
||||
def compose_zone_popup_payload(unit, container_height_px: float) -> dict:
|
||||
"""Compose the per-zone popup render-context payload (IMP-35 u7).
|
||||
|
||||
Reads u6 ``bind_popup_display_strategy(unit)`` and surfaces the three
|
||||
uniform render-context field names the pipeline composer attaches to
|
||||
each zone in ``zones_data``. The full u6 binding is also echoed
|
||||
under ``popup_binding`` so downstream debug / u8 / u9 consumers can
|
||||
self-explain without re-reading the yaml.
|
||||
|
||||
Args:
|
||||
unit: a CompositionUnit (or any duck-typed object exposing
|
||||
``has_popup`` / ``popup_escalation_plan`` / ``raw_content``).
|
||||
container_height_px: container height telemetry. The pipeline
|
||||
composer passes ``min_height_px`` at the zones_data append
|
||||
site. The non-popup branch ignores the value (preview_text
|
||||
is always None when has_popup=False).
|
||||
|
||||
Returns:
|
||||
Dict with the four wiring keys (``has_popup``, ``popup_html``,
|
||||
``preview_text``, ``popup_binding``). Spreadable into a zone
|
||||
dict via ``zones_data.append({..., **payload})``.
|
||||
"""
|
||||
binding = bind_popup_display_strategy(unit)
|
||||
has_popup = bool(binding.get("has_popup"))
|
||||
if not has_popup:
|
||||
return {
|
||||
"has_popup": False,
|
||||
"popup_html": None,
|
||||
"preview_text": None,
|
||||
"popup_binding": binding,
|
||||
}
|
||||
raw_content = getattr(unit, "raw_content", "") or ""
|
||||
popup_html = binding.get("popup_body_source")
|
||||
preview_text = compute_popup_preview_text(raw_content, container_height_px)
|
||||
return {
|
||||
"has_popup": True,
|
||||
# popup body = FULL raw_content (u6 popup_body_source). u8 wraps
|
||||
# this in <details>/<summary> markup on slide_base.html.
|
||||
"popup_html": popup_html,
|
||||
# body preview = px-budgeted line-boundary cut of raw_content.
|
||||
# NEVER trims inside a line; popup body holds the FULL original
|
||||
# so this excerpt loses no information.
|
||||
"preview_text": preview_text,
|
||||
# Full u6 binding echo — downstream debug surfaces (catalog
|
||||
# detail_trigger placement, popup_escalation_plan category /
|
||||
# rationale) without re-reading yaml.
|
||||
"popup_binding": binding,
|
||||
}
|
||||
|
||||
|
||||
# ─── CompositionUnit ────────────────────────────────────────────
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -34,8 +34,12 @@ frame_reselect (V4 top-k 의 다른 frame)
|
||||
details_popup_escalation (가장 invasive — content popup, 마지막 resort)
|
||||
```
|
||||
|
||||
`details_popup_escalation` 은 본 매핑에 *없음* — tabular_overflow / structural_major_overflow /
|
||||
frame_reselect 실패 이후 단계에서 다룸 (별 step).
|
||||
IMP-35 (#64) u2 — cascade terminal landed. `frame_reselect_insufficient`
|
||||
(post-frame remeasure failure, classifier path locked in u1) now routes onto
|
||||
`details_popup_escalation`. The status table records the popup action as
|
||||
MISSING here; the actual executor stub + MISSING→IMPLEMENTED flip lives in
|
||||
`src/phase_z2_router.py` (u3 surface), so this module advertises the cascade
|
||||
terminal without claiming an implementation it does not own.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -74,6 +78,13 @@ FAILURE_TYPE_DESCRIPTIONS: dict[str, str] = {
|
||||
"font_step_compression salvage step failed — FONT_SIZE_STEPS exhausted "
|
||||
"down to the floor without resolving overflow (or text_metrics missing)"
|
||||
),
|
||||
"frame_reselect_insufficient": (
|
||||
"frame_reselect salvage step failed — V4 top-k alternate frame swap "
|
||||
"re-rendered + post-frame remeasure (run_overflow_check) still fails. "
|
||||
"IMP-35 (#64) u1 contract: emitted from salvage_steps[-1].action == "
|
||||
"'frame_reselect' AND passed=False AND post_salvage_overflow present. "
|
||||
"Routes to details_popup_escalation in u2 (cascade terminal)."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -86,6 +97,12 @@ 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",
|
||||
# IMP-35 (#64) u1: post-frame remeasure failure. frame_reselect salvage step
|
||||
# writes a salvage_steps entry with action='frame_reselect', passed=False,
|
||||
# and post_salvage_overflow populated by run_overflow_check on the swapped
|
||||
# frame's HTML. classifier reads that entry; u2 adds the NEXT_ACTION row
|
||||
# that routes this onto details_popup_escalation.
|
||||
"frame_reselect": "frame_reselect_insufficient",
|
||||
}
|
||||
|
||||
|
||||
@@ -98,6 +115,14 @@ NEXT_ACTION_BY_FAILURE: dict[str, str] = {
|
||||
"glue_absorption_insufficient": "font_step_compression",
|
||||
"font_step_insufficient": "layout_adjust",
|
||||
"rerender_still_fails": "frame_reselect",
|
||||
# IMP-35 (#64) u2 — cascade terminal. frame_reselect salvage exhausted
|
||||
# (post-frame remeasure failed; classifier path gated on
|
||||
# post_salvage_overflow per u1/q4) escalates onto details_popup_escalation.
|
||||
# Popup body holds full MDX source; preview shows summary/subset
|
||||
# (CLAUDE.md 자세히보기 원칙). Executor + MISSING→IMPLEMENTED flip lands
|
||||
# in u3 (src/phase_z2_router.py); this module owns the cascade mapping
|
||||
# only.
|
||||
"frame_reselect_insufficient": "details_popup_escalation",
|
||||
"not_attempted": "none",
|
||||
}
|
||||
|
||||
@@ -127,6 +152,12 @@ NEXT_ACTION_RATIONALE: dict[str, str] = {
|
||||
"frame/zone 조합 자체 부적합, V4 top-k 의 다른 frame 평가 (frame_reselect). "
|
||||
"popup 직행은 아직 빠름 (tabular / structural_major 가 아닌 한)"
|
||||
),
|
||||
"frame_reselect_insufficient": (
|
||||
"V4 top-k frame swap + 명시적 post-frame remeasure 까지 했는데도 overflow "
|
||||
"잔존 → cascade terminal 인 details_popup_escalation 으로 escalate. "
|
||||
"본문 = summary/subset, popup = MDX 원문 (자세히보기 원칙). "
|
||||
"AI repair 진입 전 deterministic 마지막 단계."
|
||||
),
|
||||
"not_attempted": (
|
||||
"retry 시도 자체가 없었음 (visual ok 등) — escalation 불필요"
|
||||
),
|
||||
@@ -145,6 +176,12 @@ NEXT_ACTION_IMPLEMENTATION_STATUS: dict[str, str] = {
|
||||
"font_step_compression": "IMPLEMENTED", # u6 plan_font_step_compression + apply_font_step_compression_css
|
||||
"layout_adjust": "MISSING",
|
||||
"frame_reselect": "MISSING",
|
||||
# IMP-35 (#64) u2 — cascade terminal advertised as MISSING here. The
|
||||
# router executor stub + MISSING→IMPLEMENTED flip lives in
|
||||
# src/phase_z2_router.py (u3). Keeping this entry as MISSING until u3
|
||||
# lands prevents premature "popup ready" claims from the failure-router
|
||||
# surface.
|
||||
"details_popup_escalation": "MISSING",
|
||||
"none": "n/a",
|
||||
}
|
||||
|
||||
@@ -170,21 +207,40 @@ def classify_retry_failure(retry_trace: dict) -> Optional[dict]:
|
||||
# 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.
|
||||
#
|
||||
# IMP-35 (#64) u1 — q4 explicit remeasure contract: the frame_reselect
|
||||
# branch is gated on post_salvage_overflow being present on the salvage
|
||||
# step. A bare passed=False flag with no remeasure payload is *not*
|
||||
# sufficient to emit frame_reselect_insufficient (which routes to
|
||||
# details_popup_escalation in u2). When the gate fails, the classifier
|
||||
# falls through to lower-priority cases so the salvage trace surfaces as
|
||||
# an unmatched defensive fallback instead of a spurious popup escalation.
|
||||
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}"
|
||||
),
|
||||
}
|
||||
frame_reselect_blocked = (
|
||||
action == "frame_reselect"
|
||||
and not last.get("post_salvage_overflow")
|
||||
)
|
||||
if not frame_reselect_blocked:
|
||||
ftype = SALVAGE_FAILURE_TYPE_BY_ACTION.get(action)
|
||||
if ftype is not None:
|
||||
reason = last.get("failure_reason") or ""
|
||||
rule_suffix = (
|
||||
" AND post_salvage_overflow present"
|
||||
if action == "frame_reselect"
|
||||
else ""
|
||||
)
|
||||
return {
|
||||
"failure_type": ftype,
|
||||
"classification_rule": (
|
||||
f"salvage_steps[-1].action == {action!r} "
|
||||
f"AND passed=False{rule_suffix}. "
|
||||
f"raw failure_reason: {reason!r}"
|
||||
),
|
||||
}
|
||||
|
||||
# case 1 : retry 시도 자체 안 됨 (router_active=False 또는 다른 action)
|
||||
if not retry_trace.get("retry_attempted"):
|
||||
|
||||
@@ -41,6 +41,7 @@ from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||
from phase_z2_composition import (
|
||||
LAYOUT_PRESETS,
|
||||
CompositionUnit,
|
||||
compose_zone_popup_payload,
|
||||
derive_parent_id,
|
||||
plan_composition,
|
||||
resplit_all_reject_merges,
|
||||
@@ -57,7 +58,7 @@ from phase_z2_mapper import (
|
||||
map_with_contract,
|
||||
)
|
||||
from phase_z2_classifier import classify_visual_runtime_check
|
||||
from phase_z2_router import route_fit_classification
|
||||
from phase_z2_router import plan_details_popup_escalation, route_fit_classification
|
||||
from phase_z2_retry import (
|
||||
DEFAULT_SAFETY_MARGIN_PX,
|
||||
apply_cross_zone_redistribute_css,
|
||||
@@ -85,6 +86,13 @@ from phase_z2_placement_planner import plan_placement
|
||||
# stays in src/config.py + src/phase_z2_ai_fallback/router.py.
|
||||
from src.phase_z2_ai_fallback.step12 import gather_step12_ai_repair_proposals
|
||||
|
||||
# IMP-35 (#64) u5 — Step 17 deterministic POPUP gate executor. Runs after
|
||||
# the salvage cascade exhausts at cascade-terminal action
|
||||
# ``details_popup_escalation`` (router u3 / failure_router u2) and BEFORE
|
||||
# the AI_REPAIR cascade stage. Stamps ``popup_escalation_plan`` and the
|
||||
# idempotent ``has_popup`` marker onto retry_trace per unit. No AI call.
|
||||
from src.phase_z2_ai_fallback.step17 import run_step17_popup_gate
|
||||
|
||||
|
||||
# ─── Constants ──────────────────────────────────────────────────
|
||||
|
||||
@@ -2476,6 +2484,41 @@ def _attempt_salvage_chain(
|
||||
return trace
|
||||
|
||||
|
||||
def _remeasure_after_frame_reselect(
|
||||
*, candidate_path: Path, plan: Optional[dict] = None,
|
||||
) -> dict:
|
||||
"""IMP-35 (#64) u1 — post-frame remeasure helper for the cascade terminal.
|
||||
|
||||
Contract (q4 / Stage 2): frame_reselect_insufficient is detected by an
|
||||
*explicit overflow re-measure* after a V4 top-k alternate frame swap —
|
||||
NOT a failure-flag carryover. This helper runs run_overflow_check on the
|
||||
re-rendered candidate HTML and shapes the salvage_steps entry that
|
||||
classify_retry_failure / SALVAGE_FAILURE_TYPE_BY_ACTION read.
|
||||
|
||||
Future frame_reselect orchestrator (post-IMP-35) writes the candidate
|
||||
HTML and calls this helper to append the entry to retry_trace.salvage_steps.
|
||||
On passed=True the orchestrator promotes the candidate to final.html; on
|
||||
passed=False the classifier emits frame_reselect_insufficient → u2 routes
|
||||
onto details_popup_escalation (Step 17 POPUP gate / u5).
|
||||
"""
|
||||
candidate_overflow = run_overflow_check(candidate_path)
|
||||
passed = bool(candidate_overflow.get("passed", False))
|
||||
return {
|
||||
"action": "frame_reselect",
|
||||
"plan": plan,
|
||||
"passed": passed,
|
||||
"candidate_path": (
|
||||
str(candidate_path.relative_to(PROJECT_ROOT))
|
||||
if candidate_path.is_absolute() else str(candidate_path)
|
||||
),
|
||||
"post_salvage_overflow": candidate_overflow,
|
||||
"failure_reason": (
|
||||
None if passed
|
||||
else (candidate_overflow.get("fail_reasons") or "post-frame remeasure: overflow persists")
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def render_slide(slide_title: str, slide_footer: Optional[str],
|
||||
zones_data: list[dict], layout_preset: str,
|
||||
layout_css: dict, gap_px: int = GRID_GAP,
|
||||
@@ -4232,6 +4275,11 @@ def run_phase_z2_mvp1(
|
||||
# first-render invariant holds; u5 will surface the provisional flag as
|
||||
# a zone class + needs-adaptation badge.
|
||||
if unit.frame_template_id == "__empty__":
|
||||
# IMP-35 u7 — popup payload wiring. Empty-shell units never go
|
||||
# through the Step 17 POPUP gate (no raw content to escalate),
|
||||
# so compose_zone_popup_payload returns the no-popup branch
|
||||
# (has_popup=False, popup_html=None, preview_text=None).
|
||||
_popup_payload = compose_zone_popup_payload(unit, 0)
|
||||
zones_data.append({
|
||||
"position": position,
|
||||
"template_id": "__empty__",
|
||||
@@ -4241,6 +4289,7 @@ def run_phase_z2_mvp1(
|
||||
"assignment_source": "imp30_u4_empty_shell",
|
||||
"section_assignment_override": False,
|
||||
"provisional": bool(getattr(unit, "provisional", False)),
|
||||
**_popup_payload,
|
||||
})
|
||||
debug_zones.append({
|
||||
"position": position,
|
||||
@@ -4411,6 +4460,15 @@ def run_phase_z2_mvp1(
|
||||
# needs-adaptation badge. Default False keeps non-provisional zones
|
||||
# byte-identical to pre-u5; only u1-synthesized rank-1 fills or u4
|
||||
# empty-shell synthesize provisional=True units.
|
||||
#
|
||||
# IMP-35 u7 — popup payload wiring. `compose_zone_popup_payload(unit,
|
||||
# min_height_px)` reads u6 binding (yaml strategy + popup_body_source)
|
||||
# AND derives a px-budgeted preview from min_height_px. Surfaces three
|
||||
# uniform render-context fields per zone (has_popup / popup_html /
|
||||
# preview_text) plus the full u6 binding under `popup_binding` for
|
||||
# u8 / u9 downstream consumers. Non-popup units (has_popup=False)
|
||||
# return the no-popup branch — byte-identical zone shape pre-u7.
|
||||
_popup_payload = compose_zone_popup_payload(unit, min_height_px)
|
||||
zones_data.append({
|
||||
"position": position,
|
||||
"template_id": unit.frame_template_id,
|
||||
@@ -4420,6 +4478,7 @@ def run_phase_z2_mvp1(
|
||||
"assignment_source": plan_assignment_source,
|
||||
"section_assignment_override": plan_section_override,
|
||||
"provisional": bool(getattr(unit, "provisional", False)),
|
||||
**_popup_payload,
|
||||
})
|
||||
debug_zones.append({
|
||||
"position": position,
|
||||
@@ -4475,6 +4534,12 @@ def run_phase_z2_mvp1(
|
||||
pos = record["position"]
|
||||
if pos in renderable_positions:
|
||||
continue
|
||||
# IMP-35 u7 — popup payload wiring for unrenderable empty
|
||||
# plan record. No CompositionUnit exists for this branch
|
||||
# (section-assignment plan produced no unit), so we stamp the
|
||||
# no-popup defaults directly. Keeps the zone shape uniform
|
||||
# across all three append paths so slide_base.html (u8) does
|
||||
# not have to branch on the presence of popup fields.
|
||||
zones_data.append({
|
||||
"position": pos,
|
||||
"template_id": "__empty__",
|
||||
@@ -4486,6 +4551,10 @@ def run_phase_z2_mvp1(
|
||||
record.get("skipped_reason")
|
||||
or "section_assignment_override_empty_or_unrenderable"
|
||||
),
|
||||
"has_popup": False,
|
||||
"popup_html": None,
|
||||
"preview_text": None,
|
||||
"popup_binding": None,
|
||||
})
|
||||
debug_zones.append({
|
||||
"position": pos,
|
||||
@@ -5615,6 +5684,54 @@ def run_phase_z2_mvp1(
|
||||
# fields become None (no failure to classify, no escalation pending).
|
||||
enrich_retry_trace_with_failure_classification(retry_trace)
|
||||
|
||||
# 11.8 IMP-35 (#64) u5 — Step 17 deterministic POPUP gate executor.
|
||||
# Runs after the salvage cascade exits at cascade-terminal action
|
||||
# `details_popup_escalation` (router u3 IMPLEMENTED + failure_router u2
|
||||
# cascade row). Stamps popup_escalation_plan + idempotent has_popup
|
||||
# marker per unit onto retry_trace["popup_gate_records"]. Deterministic
|
||||
# gate — no AI call (feedback_ai_isolation_contract); the u4
|
||||
# api_gated split-decision hook is a separate cascade-stage record
|
||||
# consumed only when a future IMP activates the Anthropic API.
|
||||
# Consumer side (composition popup binding / render wiring) lands in
|
||||
# u6 / u7. q1 (per-unit), q2 (idempotent via has_popup), q3
|
||||
# (deterministic from fit_classification) — see Stage 2 plan.
|
||||
# next_proposed_action is the single canonical signal: it is set by
|
||||
# enrich_retry_trace_with_failure_classification via failure_router u2
|
||||
# (NEXT_ACTION_BY_FAILURE), which routes frame_reselect_insufficient ->
|
||||
# details_popup_escalation. This check is independent of whether the
|
||||
# salvage chain block ran, so the popup gate fires for any retry path
|
||||
# that lands on the cascade-terminal popup action.
|
||||
_next_action = (
|
||||
retry_trace.get("next_action_proposal") or {}
|
||||
).get("next_proposed_action")
|
||||
if _next_action == "details_popup_escalation":
|
||||
_popup_cls_by_zone = {
|
||||
c.get("zone_position"): c
|
||||
for c in (fit_classification.get("classifications") or [])
|
||||
if c.get("category") in {
|
||||
"structural_major_overflow",
|
||||
"tabular_overflow",
|
||||
}
|
||||
}
|
||||
_zone_by_ssids = {
|
||||
tuple(z.get("source_section_ids") or []): z.get("position")
|
||||
for z in debug_zones
|
||||
}
|
||||
|
||||
def _classification_for_unit(u):
|
||||
ssids = tuple(getattr(u, "source_section_ids", []) or [])
|
||||
zone_pos = _zone_by_ssids.get(ssids)
|
||||
return _popup_cls_by_zone.get(zone_pos) if zone_pos else None
|
||||
|
||||
retry_trace["popup_gate_records"] = run_step17_popup_gate(
|
||||
units,
|
||||
classification_for_unit=_classification_for_unit,
|
||||
route_for_label=_imp05_route_hint,
|
||||
plan_for_classification=plan_details_popup_escalation,
|
||||
)
|
||||
retry_trace["popup_gate_executed"] = True
|
||||
retry_trace["popup_gate_terminal_action"] = "details_popup_escalation"
|
||||
|
||||
# ─── Step 17: Implemented Action (retry) ───
|
||||
_write_step_artifact(
|
||||
run_dir, 17, "retry_trace",
|
||||
|
||||
@@ -56,12 +56,24 @@ ACTION_RATIONALE: dict[str, str] = {
|
||||
"위 매핑 모두 미적용 — 마지막 fallback (현재 코드는 sys.exit 으로 abort)",
|
||||
}
|
||||
|
||||
# 각 action 의 *현재 코드* 구현 상태 (2026-04-29 기준; IMP-12 u7 cascade 2026-05-18)
|
||||
# 각 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",
|
||||
"details_popup_escalation": "MISSING", # CLAUDE.md 의 <details> 원칙은 있음, runtime 미구현
|
||||
# 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
|
||||
@@ -185,3 +197,112 @@ def route_fit_classification(fit_classification: dict) -> dict:
|
||||
"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 (자세히보기 원칙)."
|
||||
),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user