From 971789882635db43375b3e0a77ad0c54e1208250 Mon Sep 17 00:00:00 2001 From: kyeongmin Date: Thu, 2 Jul 2026 20:23:27 +0900 Subject: [PATCH] =?UTF-8?q?fix(#9):=20AI=20fallback=20proposal=20envelope?= =?UTF-8?q?=20=EB=B6=88=EC=9D=BC=EC=B9=98=20=EC=88=98=EC=A0=95=20(Emergenc?= =?UTF-8?q?y=20P4=20=EC=99=84=EA=B2=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 문제: 프롬프트가 AiFallbackProposal envelope({proposal_kind, payload, rationale}) 형태를 명시하지 않아 모델이 Task 12 plan 필드(operations/slot_axis_plan/...)를 top-level 에 평평하게 출력 → extra=forbid 로 전량 ValidationError → proposal 유실 (ai_called=true 인데 repair 미적용, mdx02 PARTIAL_COVERAGE 오염 / mdx04 ai_repair error). 수정: - prompts.py: SYSTEM_PROMPT 에 envelope JSON 예시 명시 (rule 2) - client.py: _coerce_proposal_envelope 방어층 — proposal_kind 존재 시 non-envelope top-level 키를 payload 로 하향 이동 (kind 추론은 하지 않음 — 정책 위반 침묵 통과 방지) - pipeline.py: apply_status 의 enum repr 정규화 (ProposalKind.X → x) - tests: test_client_envelope.py 6종 (관측 실패 케이스 재현 + 회귀 방지) 검증: - mdx04 재실행: 04-1/04-2 proposal 검증 통과 (kind=design_adaptation_plan, operations 합리적), coverage_invariant ok - pytest: ai_fallback 252 + imp47b apply 24 = 276 passed - 잔여: design_adaptation_plan apply 계층은 미구현 (unsupported_kind — issue #7 axis) Co-Authored-By: Claude Opus 4.8 (1M context) --- src/phase_z2_ai_fallback/client.py | 43 +++++++++- src/phase_z2_ai_fallback/prompts.py | 7 +- src/phase_z2_pipeline.py | 3 + .../test_client_envelope.py | 85 +++++++++++++++++++ 4 files changed, 136 insertions(+), 2 deletions(-) create mode 100644 tests/phase_z2_ai_fallback/test_client_envelope.py diff --git a/src/phase_z2_ai_fallback/client.py b/src/phase_z2_ai_fallback/client.py index 61450b3..481792b 100644 --- a/src/phase_z2_ai_fallback/client.py +++ b/src/phase_z2_ai_fallback/client.py @@ -31,6 +31,45 @@ _TRANSIENT_ERRORS: tuple[type[BaseException], ...] = ( # Output cap is an Anthropic API requirement, not a policy knob (u1). _MAX_OUTPUT_TOKENS = 4096 +# Issue #9 (Emergency P4 후속) — AiFallbackProposal envelope 키. +# 모델이 plan 필드(operations / slot_axis_plan / ...)를 top-level 에 평평하게 +# 출력하는 envelope 위반이 관측됨 (extra="forbid" 전멸 → proposal 유실). +# 프롬프트에 envelope 예시를 명시(u3)했고, 방어층으로 non-envelope 키를 +# payload 로 하향 이동하는 코어싱을 둔다. proposal_kind 추론은 하지 않음 +# (kind 없는 출력은 그대로 ValidationError — 정책 위반을 침묵 통과시키지 않음). +_ENVELOPE_KEYS: frozenset[str] = frozenset({"proposal_kind", "payload", "rationale"}) + + +def _coerce_proposal_envelope(data: Any) -> Any: + """Move non-envelope top-level keys into ``payload`` (defence-in-depth). + + Only activates when the parsed output is a dict that already carries + ``proposal_kind`` — i.e. the model understood the contract but flattened + the plan fields. Existing ``payload`` entries win on key collision so a + well-formed proposal is never mutated. + """ + if not isinstance(data, dict) or "proposal_kind" not in data: + return data + extras = [k for k in data.keys() if k not in _ENVELOPE_KEYS] + if not extras: + return data + payload = data.get("payload") + payload = dict(payload) if isinstance(payload, dict) else {} + for key in extras: + payload.setdefault(key, data[key]) + coerced = { + "proposal_kind": data["proposal_kind"], + "payload": payload, + "rationale": data.get("rationale", ""), + } + # stdout diag — IMP-33 AST isolation whitelist 상 sys/logging import 금지, + # pipeline [DIAG] 관례에 맞춰 stdout 사용. + print( + " [ai-fallback-client] envelope coercion: moved top-level keys " + f"{sorted(extras)} into payload (prompt contract drift)." + ) + return coerced + # IMP-92 u2 — Anthropic SDK exception → operational error kind classifier. # Stamped onto Step 12 AI repair records (api_error_kind) so the frontend # operational alert formatter can surface quota / billing / auth to users @@ -127,7 +166,9 @@ class AiFallbackClient: block.text for block in response.content if hasattr(block, "text") ) self._consecutive_failures = 0 - return AiFallbackProposal.model_validate(json.loads(text)) + return AiFallbackProposal.model_validate( + _coerce_proposal_envelope(json.loads(text)) + ) except _TRANSIENT_ERRORS as err: last_error = err if attempt >= settings.ai_fallback_max_retries: diff --git a/src/phase_z2_ai_fallback/prompts.py b/src/phase_z2_ai_fallback/prompts.py index 7102de6..6ce9da9 100644 --- a/src/phase_z2_ai_fallback/prompts.py +++ b/src/phase_z2_ai_fallback/prompts.py @@ -25,7 +25,12 @@ SYSTEM_PROMPT = ( "STRICT RULES:\n" " 1. MDX text in the user payload is READ-ONLY. Do NOT rewrite, " "compress, or paraphrase MDX.\n" - " 2. Output MUST be a single JSON object conforming to AiFallbackProposal.\n" + " 2. Output MUST be a single JSON object with EXACTLY this envelope — " + "top-level keys are proposal_kind / payload / rationale ONLY; every " + "plan detail (operations, slot_axis_plan, cardinality_result, ...) " + "goes INSIDE payload:\n" + ' {"proposal_kind": "design_adaptation_plan", ' + '"payload": {"operations": [...]}, "rationale": "..."}\n' f" 3. proposal_kind MUST be one of: {_ALLOWED_KINDS}.\n" f" 4. Do NOT propose any of: {_FORBIDDEN_KINDS}.\n" " 5. Do NOT change frame_id — V4 rank-1 frame is locked.\n" diff --git a/src/phase_z2_pipeline.py b/src/phase_z2_pipeline.py index 1e26a8a..3983867 100644 --- a/src/phase_z2_pipeline.py +++ b/src/phase_z2_pipeline.py @@ -2135,6 +2135,9 @@ def _apply_ai_repair_proposals_to_zones( record["apply_status"] = "no_proposal" continue kind = proposal.get("proposal_kind") + # model_dump() 는 str-mixin Enum 멤버를 그대로 반환할 수 있음 — + # artifact 에 "ProposalKind.X" repr 이 박히지 않도록 value 로 정규화. + kind = getattr(kind, "value", kind) if kind not in _REJECT_SUPPORTED_PROPOSAL_KINDS: record["apply_status"] = f"unsupported_kind_for_reject_route:{kind}" print( diff --git a/tests/phase_z2_ai_fallback/test_client_envelope.py b/tests/phase_z2_ai_fallback/test_client_envelope.py new file mode 100644 index 0000000..a7024c3 --- /dev/null +++ b/tests/phase_z2_ai_fallback/test_client_envelope.py @@ -0,0 +1,85 @@ +"""Issue #9 (Emergency P4 후속) — AI fallback envelope coercion tests. + +관측된 실패 모드: 모델이 Task 12 plan 필드(operations / slot_axis_plan / +cardinality_result / frame_id / template_id)를 AiFallbackProposal envelope +({proposal_kind, payload, rationale}) 밖 top-level 에 평평하게 출력 → +``extra="forbid"`` 로 전량 ValidationError → proposal 유실 (ai_called=true +인데 repair 미적용). + +방어층 계약 (client._coerce_proposal_envelope): + - proposal_kind 가 있는 dict 에서만 활성 (kind 추론 금지 — kind 없는 + 출력은 그대로 ValidationError). + - non-envelope top-level 키를 payload 로 하향 이동. + - 기존 payload 키가 충돌 시 우선 (well-formed proposal 불변). +""" +from __future__ import annotations + +import pytest +from pydantic import ValidationError + +from src.phase_z2_ai_fallback.client import _coerce_proposal_envelope +from src.phase_z2_ai_fallback.schema import AiFallbackProposal + + +_FLAT_DESIGN_PLAN = { + "proposal_kind": "design_adaptation_plan", + "rationale": "pill repeat 5 로 확장", + # ↓ envelope 위반 — 전부 payload 안에 있어야 하는 필드들 (관측 사례) + "frame_id": "1171281180", + "template_id": "pre_construction_model_info_stacked", + "operations": [{"op": "set_repeat_count", "target": "pills", "count": 5}], + "slot_axis_plan": {"axis": "pills", "expected_lines_per_unit": 4}, + "cardinality_result": {"requested": 5, "overflow": False}, +} + + +def test_flat_design_plan_is_coerced_into_payload() -> None: + coerced = _coerce_proposal_envelope(dict(_FLAT_DESIGN_PLAN)) + assert set(coerced.keys()) == {"proposal_kind", "payload", "rationale"} + assert coerced["payload"]["operations"] == _FLAT_DESIGN_PLAN["operations"] + assert coerced["payload"]["slot_axis_plan"] == _FLAT_DESIGN_PLAN["slot_axis_plan"] + assert coerced["rationale"] == "pill repeat 5 로 확장" + # 코어싱 결과는 스키마 검증을 통과해야 한다 (원래 실패하던 케이스) + proposal = AiFallbackProposal.model_validate(coerced) + assert proposal.payload["frame_id"] == "1171281180" + + +def test_well_formed_proposal_is_untouched() -> None: + data = { + "proposal_kind": "design_adaptation_plan", + "payload": {"operations": [{"op": "compact_spacing"}]}, + "rationale": "ok", + } + assert _coerce_proposal_envelope(dict(data)) == data + + +def test_payload_wins_on_key_collision() -> None: + data = { + "proposal_kind": "design_adaptation_plan", + "payload": {"operations": [{"op": "keep_me"}]}, + "operations": [{"op": "top_level_dup"}], + } + coerced = _coerce_proposal_envelope(dict(data)) + assert coerced["payload"]["operations"] == [{"op": "keep_me"}] + + +def test_missing_proposal_kind_is_not_inferred() -> None: + # kind 없는 평평한 출력은 코어싱하지 않고 그대로 — ValidationError 가 정답 + data = {"operations": [{"op": "set_repeat_count"}]} + assert _coerce_proposal_envelope(dict(data)) == data + with pytest.raises(ValidationError): + AiFallbackProposal.model_validate(data) + + +def test_non_dict_output_passes_through() -> None: + assert _coerce_proposal_envelope("not json object") == "not json object" + assert _coerce_proposal_envelope([1, 2]) == [1, 2] + + +def test_system_prompt_states_envelope() -> None: + # 프롬프트가 envelope 예시를 명시하는지 (u3 수정 회귀 방지) + from src.phase_z2_ai_fallback.prompts import SYSTEM_PROMPT + + assert '"proposal_kind": "design_adaptation_plan"' in SYSTEM_PROMPT + assert '"payload"' in SYSTEM_PROMPT + assert "INSIDE payload" in SYSTEM_PROMPT