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(
|
||||
|
||||
Reference in New Issue
Block a user