From 9a72e7de3e0686a071ab58d459ae637375fcae2b Mon Sep 17 00:00:00 2001 From: kyeongmin Date: Mon, 6 Jul 2026 10:23:46 +0900 Subject: [PATCH] =?UTF-8?q?feat(#14):=20design=5Fadaptation=5Fplan=20apply?= =?UTF-8?q?=20=EA=B3=84=EC=B8=B5=20=E2=80=94=20AI=20=EA=B5=AC=EC=A1=B0=20p?= =?UTF-8?q?lan=20=EC=9D=84=20code=20=EA=B0=80=20verbatim=20builder=20?= =?UTF-8?q?=EC=9E=AC=EC=8B=A4=ED=96=89=EC=9C=BC=EB=A1=9C=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/phase_z2_ai_fallback/prompts.py | 7 + src/phase_z2_pipeline.py | 148 ++++++++++- ...test_phase_z2_issue14_design_plan_apply.py | 237 ++++++++++++++++++ 3 files changed, 380 insertions(+), 12 deletions(-) create mode 100644 tests/test_phase_z2_issue14_design_plan_apply.py diff --git a/src/phase_z2_ai_fallback/prompts.py b/src/phase_z2_ai_fallback/prompts.py index 6ce9da9..7c18518 100644 --- a/src/phase_z2_ai_fallback/prompts.py +++ b/src/phase_z2_ai_fallback/prompts.py @@ -91,6 +91,13 @@ def build_ai_fallback_prompt( "compact_spacing", "use_repeatable_frame", ], + # issue #14 — repeat-count op 의 정확한 스키마 (code 가 적용하는 + # 유일한 op 계열). count 는 top-level 정수 필드 "count" 사용. + "operation_schema_example": { + "op": "set_repeat_count", + "target": "", + "count": 5, + }, "forbidden_content_fields": [ "text", "title", diff --git a/src/phase_z2_pipeline.py b/src/phase_z2_pipeline.py index 300e8e1..9265aad 100644 --- a/src/phase_z2_pipeline.py +++ b/src/phase_z2_pipeline.py @@ -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 없음, 원문 보존만. Returns slot_payload dict OR None (해당 frame 의 builder 없음 OR Jinja partial 없음 → 기존 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 없음) # 차단. _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) if template_id == "construction_bim_three_usage": return _build_verbatim_cards_n_grid( - unit, slot_count=3, + unit, slot_count=override_slot_count or 3, label_pattern="category_{n}_label", 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) if template_id == "bim_issues_quadrant_four": return _build_verbatim_cards_n_grid( - unit, slot_count=4, + unit, slot_count=override_slot_count or 4, label_pattern="quadrant_{n}_label", body_pattern="quadrant_{n}_body", ) if template_id == "sw_dependency_four_problems": return _build_verbatim_cards_n_grid( - unit, slot_count=4, + unit, slot_count=override_slot_count or 4, label_pattern="problem_{n}_label", body_pattern="problem_{n}_body", ) if template_id == "pre_construction_model_info_stacked": return _build_verbatim_cards_n_grid( - unit, slot_count=5, + unit, slot_count=override_slot_count or 5, label_pattern="pill_{n}_label", 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"}) +# ── 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( ai_repair_records: list[dict], unit_positions: list[str], zones_data: list[dict], + units: Optional[list] = None, ) -> None: """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 guarantees declared-slot completeness, so ``dict.update`` is the 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} for record in ai_repair_records: @@ -2138,6 +2253,19 @@ def _apply_ai_repair_proposals_to_zones( # model_dump() 는 str-mixin Enum 멤버를 그대로 반환할 수 있음 — # artifact 에 "ProposalKind.X" repr 이 박히지 않도록 value 로 정규화. 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: record["apply_status"] = f"unsupported_kind_for_reject_route:{kind}" print( @@ -2147,12 +2275,6 @@ def _apply_ai_repair_proposals_to_zones( file=sys.stderr, ) 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: record["apply_status"] = "no_zone_match" continue @@ -9731,7 +9853,9 @@ def run_phase_z2_mvp1( if _plan_record is not None and _plan_record.get("position"): _pos = _plan_record["position"] 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 ─── # Structural defense: AI repair must not silently drop a unit's diff --git a/tests/test_phase_z2_issue14_design_plan_apply.py b/tests/test_phase_z2_issue14_design_plan_apply.py new file mode 100644 index 0000000..d4a7a63 --- /dev/null +++ b/tests/test_phase_z2_issue14_design_plan_apply.py @@ -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"