feat(#14): design_adaptation_plan apply 계층 — AI 구조 plan 을 code 가 verbatim builder 재실행으로 적용

Gitea #98 Task 12 완결. 롤백된 _emergency_p4_ai_redistribute docstring 이 명시한
승인 아키텍처 5단계('AI: plan only → CODE: apply') 구현.

- _apply_design_adaptation_plan: op 분류 + repeat-count op 만 builder 재실행 적용
  - set/increase_repeat_count → _emergency_p4b_build_verbatim_slot_payload
    (override_slot_count, repeat 패턴 4종) — max(override, len(groups)) floor 로
    원문 그룹 절대 비손실 (Task 11c)
  - compact_spacing → policy_skip (T28.5 no_font_shrink) / rebalance_zone_ratio
    → policy_skip (CSS code 소유) / split_group(frame_repeat) → unsupported (#26)
    / frame_contract 변이 → unsupported (catalog 소유) — 전 op 사유와 함께
    design_plan_op_status 기록 (침묵 drop 금지, PZ-4)
  - count 필드 어휘 관용 추출 (count/repeat_count/to/target_count — 실측 변동)
- prompts.py: task12 계약에 op 스키마 예시 명시
- 시그니처: units 옵션 kwarg (구 호출 backward compat — 미제공 시 명시 status)

검증:
- 신규 테스트 13종 + 영향권/전체 게이트 1003 passed 0 failed
- mdx04 live: 04-1 apply=applied:design_adaptation_plan_no_delta (AI plan 이
  apply 계층 최초 통과 — count 5 = 현행 동일이라 no_delta 가 정답), coverage ok

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-06 10:23:46 +09:00
co-authored by Claude Opus 4.8
parent 2c7881c5cc
commit 9a72e7de3e
3 changed files with 380 additions and 12 deletions
@@ -0,0 +1,237 @@
"""issue #14 — design_adaptation_plan apply 계층 테스트 (Gitea #98 Task 12 완결).
계약 (승인 아키텍처 — _emergency_p4_ai_redistribute docstring 5단계):
AI 는 구조 조정 plan 만 내고, CODE 가 verbatim builder 재실행으로 적용한다.
- set_repeat_count / increase_repeat_count 만 effective (builder 재실행)
- override count 는 max(override, len(groups)) floor — 원문 그룹 절대 비손실
- compact_spacing / rebalance_zone_ratio 는 policy_skip (T28.5 no_font_shrink,
CSS code 소유), split_group(frame_repeat) 은 unsupported (#26 axis)
- 모든 op 는 design_plan_op_status 에 분류 사유와 함께 기록 (침묵 drop 금지)
- partial_overrides 기존 경로 및 구 시그니처(units 미전달) backward compat 유지
"""
from __future__ import annotations
from types import SimpleNamespace
from src.phase_z2_pipeline import (
_apply_ai_repair_proposals_to_zones,
_apply_design_adaptation_plan,
_emergency_p4b_build_verbatim_slot_payload,
)
_TEMPLATE = "pre_construction_model_info_stacked" # repeat 패턴 (pill_{n}, base 5)
def _unit(n_groups: int = 2):
lines = []
for i in range(1, n_groups + 1):
lines.append(f"- **그룹{i} 라벨**")
lines.append(f"- 항목 {i}-1")
lines.append(f"- 항목 {i}-2")
lines.append("")
return SimpleNamespace(title="테스트 제목", raw_content="\n".join(lines))
def _zone(unit) -> dict:
payload = _emergency_p4b_build_verbatim_slot_payload(unit, _TEMPLATE)
assert payload is not None, "전제: builder + partial 존재"
return {"position": "top", "template_id": _TEMPLATE, "slot_payload": payload}
def _record(operations: list, unit_index: int = 0) -> dict:
return {
"unit_index": unit_index,
"source_section_ids": ["04-1"],
"proposal": {
"proposal_kind": "design_adaptation_plan",
"payload": {"operations": operations},
"rationale": "test",
},
}
# ── set_repeat_count 적용 ────────────────────────────────────────────
def test_set_repeat_count_expands_padding_slots():
unit = _unit(n_groups=2)
zone = _zone(unit)
assert zone["slot_payload"]["_slot_count"] == 5 # base 5 > groups 2
rec = _record([{"op": "set_repeat_count", "target": "pills", "count": 7}])
_apply_ai_repair_proposals_to_zones([rec], ["top"], [zone], units=[unit])
assert rec["apply_status"] == "applied:design_adaptation_plan"
assert zone["slot_payload"]["_slot_count"] == 7
# 원문 텍스트 verbatim 보존 (AI 텍스트 유입 없음)
assert zone["slot_payload"]["pill_1_label"] == "그룹1 라벨"
assert zone["slot_payload"]["pill_7_label"] == "" # padding slot
def test_repeat_count_never_truncates_source_groups():
"""count 1 < groups 4 → floor 는 len(groups) — 원문 무손실 (Task 11c)."""
unit = _unit(n_groups=4)
zone = _zone(unit)
rec = _record([{"op": "set_repeat_count", "target": "pills", "count": 1}])
_apply_ai_repair_proposals_to_zones([rec], ["top"], [zone], units=[unit])
assert zone["slot_payload"]["_slot_count"] == 4
assert zone["slot_payload"]["pill_4_label"] == "그룹4 라벨"
def test_no_delta_when_count_matches_current_build():
unit = _unit(n_groups=2)
zone = _zone(unit)
before = dict(zone["slot_payload"])
rec = _record([{"op": "set_repeat_count", "target": "pills", "count": 5}])
_apply_ai_repair_proposals_to_zones([rec], ["top"], [zone], units=[unit])
assert rec["apply_status"] == "applied:design_adaptation_plan_no_delta"
assert zone["slot_payload"] == before
def test_increase_repeat_count_alias_and_repeat_count_key():
unit = _unit(n_groups=2)
zone = _zone(unit)
rec = _record([{"op": "increase_repeat_count", "repeat_count": 6}])
_apply_ai_repair_proposals_to_zones([rec], ["top"], [zone], units=[unit])
assert rec["apply_status"] == "applied:design_adaptation_plan"
assert zone["slot_payload"]["_slot_count"] == 6
# ── 정책 분류 (침묵 drop 금지) ────────────────────────────────────────
def test_policy_ops_are_classified_not_silently_dropped():
unit = _unit()
zone = _zone(unit)
before = dict(zone["slot_payload"])
rec = _record([
{"op": "preserve_source_order", "target": "pills"},
{"op": "compact_spacing", "target": ".f9b__pill", "line_height": 1.05},
{"op": "rebalance_zone_ratio", "target": ".f9b__pill-rows"},
{"op": "split_group_across_repeat_blocks", "target": "frame_repeat",
"repeat_count": 2},
{"op": "use_repeatable_frame", "target": "frame"},
{"op": "totally_unknown_op"},
])
_apply_ai_repair_proposals_to_zones([rec], ["top"], [zone], units=[unit])
assert rec["apply_status"] == "design_plan_no_applicable_op"
assert zone["slot_payload"] == before # zone 불변
statuses = {s["op"]: s["status"] for s in rec["design_plan_op_status"]}
assert statuses["preserve_source_order"] == "already_default:task9_source_order"
assert statuses["compact_spacing"] == "policy_skip:no_font_shrink_spacing_lock"
assert statuses["rebalance_zone_ratio"] == "policy_skip:css_code_owned"
assert statuses["split_group_across_repeat_blocks"] == (
"unsupported:multi_instance_requires_deck_split"
)
assert statuses["use_repeatable_frame"] == "unsupported:frame_swap_locked"
assert statuses["totally_unknown_op"] == "unsupported_op"
def test_invalid_count_recorded():
unit = _unit()
zone = _zone(unit)
rec = _record([{"op": "set_repeat_count", "count": "다섯"}])
_apply_ai_repair_proposals_to_zones([rec], ["top"], [zone], units=[unit])
assert rec["apply_status"] == "design_plan_no_applicable_op"
assert rec["design_plan_op_status"][0]["status"] == "invalid_count"
def test_count_field_vocabulary_tolerance():
"""실측: 모델이 count 대신 to/repeat_count/digit-string 을 씀 — 관용 추출."""
unit = _unit(n_groups=2)
zone = _zone(unit)
rec = _record([{"op": "increase_repeat_count", "target": "pills",
"from": 5, "to": 6}])
_apply_ai_repair_proposals_to_zones([rec], ["top"], [zone], units=[unit])
assert rec["apply_status"] == "applied:design_adaptation_plan"
assert zone["slot_payload"]["_slot_count"] == 6
def test_frame_contract_mutation_classified_unsupported():
"""실측: target=frame_contract.cardinality + add_sub_zone — catalog 소유
변이는 적용 불가로 정직 분류 (invalid_count 로 오분류 금지)."""
unit = _unit()
zone = _zone(unit)
before = dict(zone["slot_payload"])
rec = _record([{
"op": "increase_repeat_count",
"target": "frame_contract.cardinality",
"details": {"from": {"strict": 3}, "to": {"strict": 4},
"add_sub_zone": {"id": "item_4_body"}},
}])
_apply_ai_repair_proposals_to_zones([rec], ["top"], [zone], units=[unit])
assert rec["apply_status"] == "design_plan_no_applicable_op"
assert rec["design_plan_op_status"][0]["status"] == (
"unsupported:contract_mutation_requires_catalog_change"
)
assert zone["slot_payload"] == before
# ── 가드 / backward compat ──────────────────────────────────────────
def test_units_not_provided_backward_compat():
"""구 시그니처 호출 (units 미전달) — 예외 없이 명시적 status 기록."""
unit = _unit()
zone = _zone(unit)
rec = _record([{"op": "set_repeat_count", "count": 6}])
_apply_ai_repair_proposals_to_zones([rec], ["top"], [zone]) # units 없음
assert rec["apply_status"] == "design_plan_unit_unavailable"
def test_builder_unavailable_template():
unit = _unit()
zone = {"position": "top", "template_id": "no_such_template",
"slot_payload": {"title": "x"}}
rec = _record([{"op": "set_repeat_count", "count": 6}])
_apply_ai_repair_proposals_to_zones([rec], ["top"], [zone], units=[unit])
assert rec["apply_status"] == "design_plan_builder_unavailable:no_such_template"
def test_no_zone_match_status():
unit = _unit()
rec = _record([{"op": "set_repeat_count", "count": 6}])
_apply_ai_repair_proposals_to_zones([rec], [], [], units=[unit])
assert rec["apply_status"] == "no_zone_match"
def test_partial_overrides_path_unchanged():
"""기존 partial_overrides 경로 회귀 없음 (units kwarg 추가와 무관)."""
zone = {"position": "top", "template_id": _TEMPLATE,
"slot_payload": {"title": "old"}}
rec = {
"unit_index": 0,
"proposal": {
"proposal_kind": "partial_overrides",
"payload": {"slots": {"title": "new"}},
},
}
_apply_ai_repair_proposals_to_zones([rec], ["top"], [zone], units=[_unit()])
assert rec["apply_status"] == "applied:partial_overrides"
assert zone["slot_payload"]["title"] == "new"
def test_direct_handler_unit_none():
rec = _record([{"op": "set_repeat_count", "count": 6}])
zone = {"position": "top", "template_id": _TEMPLATE, "slot_payload": {}}
_apply_design_adaptation_plan(rec, None, zone)
assert rec["apply_status"] == "design_plan_unit_unavailable"