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:
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user