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
@@ -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