fix(#9): AI fallback proposal envelope 불일치 수정 (Emergency P4 완결)

문제: 프롬프트가 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) <noreply@anthropic.com>
This commit is contained in:
2026-07-02 20:23:27 +09:00
co-authored by Claude Opus 4.8
parent ad106578fe
commit 9717898826
4 changed files with 136 additions and 2 deletions
+42 -1
View File
@@ -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:
+6 -1
View File
@@ -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"
+3
View File
@@ -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(
@@ -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