fix(tarot-lab): max_tokens 1400→2800 + stop_reason 검사로 응답 truncation 처리

3-card spread 해석 응답이 1400 토큰 한계에서 잘려 JSON "Unterminated string" 파싱 실패가 reroll 2회 모두 발생하던 버그 수정.

- max_tokens 1400 → 2800 (saju-lab 2400 기준 + interactions 마진)
- stop_reason == "max_tokens" 검사 → 신규 TarotTruncated 예외로 truncation 명시화
- reroll feedback에 "각 카드 1~2문장으로 축약" 안내 추가 → 모델이 다음 응답 길이 조절
- truncation 시나리오 테스트 2개 추가 (1차 잘림→성공, 2회 모두 잘림→TarotError)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-26 22:55:28 +09:00
parent 99dca8df64
commit be9165efd2
2 changed files with 60 additions and 4 deletions

View File

@@ -47,10 +47,11 @@ def _valid_response_json():
}
def _claude_envelope(text: str, in_tok=100, out_tok=200):
def _claude_envelope(text: str, in_tok=100, out_tok=200, stop_reason="end_turn"):
return {
"content": [{"type": "text", "text": text}],
"usage": {"input_tokens": in_tok, "output_tokens": out_tok},
"stop_reason": stop_reason,
}
@@ -112,3 +113,33 @@ async def test_interpret_http_error():
def test_calc_cost():
cost = pipeline.calc_cost(1_000_000, 1_000_000)
assert cost == pipeline.TAROT_COST_INPUT_PER_M + pipeline.TAROT_COST_OUTPUT_PER_M
@respx.mock
async def test_interpret_truncated_then_success():
"""1차 응답이 max_tokens에서 잘림 → 2차에서 정상 JSON 반환."""
truncated_text = '{"summary": "흐름이 있음", "cards": [{"position": "과거", "card": "the-fool", "reversed": false, "interpretation": "끝나지 않은 문장'
valid = json.dumps(_valid_response_json())
respx.post("https://api.anthropic.com/v1/messages").mock(
side_effect=[
httpx.Response(200, json=_claude_envelope(truncated_text, stop_reason="max_tokens")),
httpx.Response(200, json=_claude_envelope(valid)),
]
)
result = await pipeline.interpret(_req())
assert result["reroll_count"] == 1
assert "interpretation_json" in result
@respx.mock
async def test_interpret_truncated_twice_raises():
"""두 번 모두 max_tokens 잘림 → TarotError, 메시지에 'max_tokens' 포함."""
truncated_text = '{"summary": "...", "cards": [{"position":'
respx.post("https://api.anthropic.com/v1/messages").mock(
return_value=httpx.Response(
200, json=_claude_envelope(truncated_text, stop_reason="max_tokens")
)
)
with pytest.raises(pipeline.TarotError) as exc_info:
await pipeline.interpret(_req())
assert "max_tokens" in str(exc_info.value)