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(