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
+7
View File
@@ -91,6 +91,13 @@ def build_ai_fallback_prompt(
"compact_spacing", "compact_spacing",
"use_repeatable_frame", "use_repeatable_frame",
], ],
# issue #14 — repeat-count op 의 정확한 스키마 (code 가 적용하는
# 유일한 op 계열). count 는 top-level 정수 필드 "count" 사용.
"operation_schema_example": {
"op": "set_repeat_count",
"target": "<repeat_axis_name>",
"count": 5,
},
"forbidden_content_fields": [ "forbidden_content_fields": [
"text", "text",
"title", "title",
+136 -12
View File
@@ -663,11 +663,20 @@ def _build_verbatim_compare_table_2col(unit) -> dict:
} }
def _emergency_p4b_build_verbatim_slot_payload(unit, template_id: str) -> Optional[dict]: def _emergency_p4b_build_verbatim_slot_payload(
unit, template_id: str, override_slot_count: Optional[int] = None,
) -> Optional[dict]:
"""Per-frame verbatim slot_payload builder. AI 없음, 원문 보존만. """Per-frame verbatim slot_payload builder. AI 없음, 원문 보존만.
Returns slot_payload dict OR None (해당 frame builder 없음 OR Jinja partial Returns slot_payload dict OR None (해당 frame builder 없음 OR Jinja partial
없음 기존 adapter_needed path fallback). 없음 기존 adapter_needed path fallback).
override_slot_count (issue #14) — design_adaptation_plan 의
set_repeat_count / increase_repeat_count op 적용용. repeat 패턴
builder(cards_n_grid 계열) base slot_count 대체한다. 최종 count
max(override, len(groups)) 이므로 **원문 그룹은 절대 잘리지 않는다**
(Task 11c no-truncate) override padding slot 개수만 조정.
-repeat builder 에는 영향 없음.
""" """
# Partial template 존재 check — visual_pending frame (contract 있지만 .html 없음) # Partial template 존재 check — visual_pending frame (contract 있지만 .html 없음)
# 차단. _is_visual_pending 기준 + 직접 file 존재 확인 동시. # 차단. _is_visual_pending 기준 + 직접 file 존재 확인 동시.
@@ -685,7 +694,7 @@ def _emergency_p4b_build_verbatim_slot_payload(unit, template_id: str) -> Option
return _build_verbatim_cycle_intersect_3(unit) return _build_verbatim_cycle_intersect_3(unit)
if template_id == "construction_bim_three_usage": if template_id == "construction_bim_three_usage":
return _build_verbatim_cards_n_grid( return _build_verbatim_cards_n_grid(
unit, slot_count=3, unit, slot_count=override_slot_count or 3,
label_pattern="category_{n}_label", label_pattern="category_{n}_label",
body_pattern="category_{n}_body", body_pattern="category_{n}_body",
) )
@@ -693,19 +702,19 @@ def _emergency_p4b_build_verbatim_slot_payload(unit, template_id: str) -> Option
return _build_verbatim_compare_table_2col(unit) return _build_verbatim_compare_table_2col(unit)
if template_id == "bim_issues_quadrant_four": if template_id == "bim_issues_quadrant_four":
return _build_verbatim_cards_n_grid( return _build_verbatim_cards_n_grid(
unit, slot_count=4, unit, slot_count=override_slot_count or 4,
label_pattern="quadrant_{n}_label", label_pattern="quadrant_{n}_label",
body_pattern="quadrant_{n}_body", body_pattern="quadrant_{n}_body",
) )
if template_id == "sw_dependency_four_problems": if template_id == "sw_dependency_four_problems":
return _build_verbatim_cards_n_grid( return _build_verbatim_cards_n_grid(
unit, slot_count=4, unit, slot_count=override_slot_count or 4,
label_pattern="problem_{n}_label", label_pattern="problem_{n}_label",
body_pattern="problem_{n}_body", body_pattern="problem_{n}_body",
) )
if template_id == "pre_construction_model_info_stacked": if template_id == "pre_construction_model_info_stacked":
return _build_verbatim_cards_n_grid( return _build_verbatim_cards_n_grid(
unit, slot_count=5, unit, slot_count=override_slot_count or 5,
label_pattern="pill_{n}_label", label_pattern="pill_{n}_label",
body_pattern="pill_{n}_body", body_pattern="pill_{n}_body",
) )
@@ -2112,11 +2121,113 @@ def _run_step12_ai_repair(units) -> list[dict]:
_REJECT_SUPPORTED_PROPOSAL_KINDS: frozenset[str] = frozenset({"partial_overrides"}) _REJECT_SUPPORTED_PROPOSAL_KINDS: frozenset[str] = frozenset({"partial_overrides"})
# ── issue #14 — design_adaptation_plan apply (Gitea #98 Task 12 완결) ──
# 롤백된 _emergency_p4_ai_redistribute docstring 이 명시한 승인 아키텍처의
# 5단계: "AI: frame adaptation plan only (구조 조정, 텍스트 X) → CODE: apply".
# op 분류 정책:
# set_repeat_count / increase_repeat_count → builder 재실행 (원문 무손실 —
# override 는 max(override, len(groups)) floor 로만 작동)
# preserve_source_order → already_default (Task 9 가 기본 보장)
# compact_spacing → policy_skip (T28.5 no_font_shrink + spacing lock —
# font/line-height/padding 축소는 fit lever 로 금지)
# rebalance_zone_ratio → policy_skip (CSS/grid 는 code 소유; zone ratio 는
# retry engine 소유 — AI CSS 주입 금지)
# split_group_across_repeat_blocks(frame_repeat) → unsupported (frame 다중
# 인스턴스 = 슬라이드 분할 axis, GitHub #26)
# use_repeatable_frame → unsupported (frame_id locked — V4 rank-1 보호)
_DESIGN_PLAN_KIND = "design_adaptation_plan"
_DESIGN_PLAN_COUNT_OPS = frozenset({"set_repeat_count", "increase_repeat_count"})
# 모델별 count 필드 어휘 변동 관측 (count / repeat_count / to) — 관용 추출.
_DESIGN_PLAN_COUNT_KEYS = ("count", "repeat_count", "to", "target_count")
def _extract_design_plan_count(op: dict) -> Optional[int]:
for key in _DESIGN_PLAN_COUNT_KEYS:
val = op.get(key)
if isinstance(val, bool):
continue
if isinstance(val, int) and val > 0:
return val
if isinstance(val, str) and val.isdigit() and int(val) > 0:
return int(val)
return None
_DESIGN_PLAN_OP_STATUS: dict[str, str] = {
"preserve_source_order": "already_default:task9_source_order",
"compact_spacing": "policy_skip:no_font_shrink_spacing_lock",
"rebalance_zone_ratio": "policy_skip:css_code_owned",
"split_group_across_repeat_blocks": "unsupported:multi_instance_requires_deck_split",
"use_repeatable_frame": "unsupported:frame_swap_locked",
}
def _apply_design_adaptation_plan(record: dict, unit, zone: Optional[dict]) -> None:
"""issue #14 — Apply one design_adaptation_plan proposal to its zone.
구조 op(repeat count) verbatim builder 재실행으로 반영하고, 나머지
op 분류 사유와 함께 기록만 한다 (침묵 drop 금지 PZ-4 surface).
텍스트는 항상 code(verbatim builder) 배치하므로 AI 텍스트 유입 경로
없음 (feedback_ai_isolation_contract).
"""
payload = (record.get("proposal") or {}).get("payload") or {}
operations = payload.get("operations") or []
op_status: list[dict] = []
target_count: Optional[int] = None
for op in operations:
if not isinstance(op, dict):
op_status.append({"op": str(op), "status": "unsupported_op"})
continue
name = str(op.get("op") or "")
if name in _DESIGN_PLAN_COUNT_OPS:
op_target = str(op.get("target") or "")
if "frame_contract" in op_target or "add_sub_zone" in (op.get("details") or {}):
# frame contract 자체 변이(cardinality/sub_zone 추가)는 catalog
# 소유 — AI plan 으로 적용 불가 (사용자/코드 승인 axis).
op_status.append({
"op": name,
"status": "unsupported:contract_mutation_requires_catalog_change",
})
continue
count = _extract_design_plan_count(op)
if count is not None:
target_count = max(target_count or 0, count)
op_status.append({"op": name, "status": "effective"})
else:
op_status.append({"op": name, "status": "invalid_count"})
else:
op_status.append({
"op": name,
"status": _DESIGN_PLAN_OP_STATUS.get(name, "unsupported_op"),
})
record["design_plan_op_status"] = op_status
if target_count is None:
record["apply_status"] = "design_plan_no_applicable_op"
return
if zone is None:
record["apply_status"] = "no_zone_match"
return
if unit is None:
record["apply_status"] = "design_plan_unit_unavailable"
return
template_id = zone.get("template_id")
rebuilt = _emergency_p4b_build_verbatim_slot_payload(
unit, template_id, override_slot_count=target_count,
)
if rebuilt is None:
record["apply_status"] = f"design_plan_builder_unavailable:{template_id}"
return
if rebuilt == zone.get("slot_payload"):
record["apply_status"] = "applied:design_adaptation_plan_no_delta"
return
zone["slot_payload"] = rebuilt
record["apply_status"] = "applied:design_adaptation_plan"
def _apply_ai_repair_proposals_to_zones( def _apply_ai_repair_proposals_to_zones(
ai_repair_records: list[dict], ai_repair_records: list[dict],
unit_positions: list[str], unit_positions: list[str],
zones_data: list[dict], zones_data: list[dict],
units: Optional[list] = None,
) -> None: ) -> None:
"""IMP-47B u5 — Apply PARTIAL_OVERRIDES into zones_data.slot_payload. """IMP-47B u5 — Apply PARTIAL_OVERRIDES into zones_data.slot_payload.
@@ -2127,6 +2238,10 @@ def _apply_ai_repair_proposals_to_zones(
untouched (human_review surfacing u8). IMP-33 u5 validator untouched (human_review surfacing u8). IMP-33 u5 validator
guarantees declared-slot completeness, so ``dict.update`` is the guarantees declared-slot completeness, so ``dict.update`` is the
structural merge (``feedback_ai_isolation_contract``). structural merge (``feedback_ai_isolation_contract``).
issue #14 — ``design_adaptation_plan`` kind 는
:func:`_apply_design_adaptation_plan` 위임 (``units`` 필요 미제공
backward-compat ``design_plan_unit_unavailable`` 기록).
""" """
zone_by_position = {z["position"]: z for z in zones_data} zone_by_position = {z["position"]: z for z in zones_data}
for record in ai_repair_records: for record in ai_repair_records:
@@ -2138,6 +2253,19 @@ def _apply_ai_repair_proposals_to_zones(
# model_dump() 는 str-mixin Enum 멤버를 그대로 반환할 수 있음 — # model_dump() 는 str-mixin Enum 멤버를 그대로 반환할 수 있음 —
# artifact 에 "ProposalKind.X" repr 이 박히지 않도록 value 로 정규화. # artifact 에 "ProposalKind.X" repr 이 박히지 않도록 value 로 정규화.
kind = getattr(kind, "value", kind) kind = getattr(kind, "value", kind)
unit_index = record["unit_index"]
position = (
unit_positions[unit_index]
if 0 <= unit_index < len(unit_positions) else None
)
zone = zone_by_position.get(position) if position is not None else None
if kind == _DESIGN_PLAN_KIND:
unit = (
units[unit_index]
if units is not None and 0 <= unit_index < len(units) else None
)
_apply_design_adaptation_plan(record, unit, zone)
continue
if kind not in _REJECT_SUPPORTED_PROPOSAL_KINDS: if kind not in _REJECT_SUPPORTED_PROPOSAL_KINDS:
record["apply_status"] = f"unsupported_kind_for_reject_route:{kind}" record["apply_status"] = f"unsupported_kind_for_reject_route:{kind}"
print( print(
@@ -2147,12 +2275,6 @@ def _apply_ai_repair_proposals_to_zones(
file=sys.stderr, file=sys.stderr,
) )
continue continue
unit_index = record["unit_index"]
position = (
unit_positions[unit_index]
if 0 <= unit_index < len(unit_positions) else None
)
zone = zone_by_position.get(position) if position is not None else None
if zone is None: if zone is None:
record["apply_status"] = "no_zone_match" record["apply_status"] = "no_zone_match"
continue continue
@@ -9731,7 +9853,9 @@ def run_phase_z2_mvp1(
if _plan_record is not None and _plan_record.get("position"): if _plan_record is not None and _plan_record.get("position"):
_pos = _plan_record["position"] _pos = _plan_record["position"]
unit_positions.append(_pos) unit_positions.append(_pos)
_apply_ai_repair_proposals_to_zones(ai_repair_records, unit_positions, zones_data) _apply_ai_repair_proposals_to_zones(
ai_repair_records, unit_positions, zones_data, units=units,
)
# ─── Step 12 IMP-47B u7 — Post-AI source_section_ids coverage invariant ─── # ─── Step 12 IMP-47B u7 — Post-AI source_section_ids coverage invariant ───
# Structural defense: AI repair must not silently drop a unit's # Structural defense: AI repair must not silently drop a unit's
@@ -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"