feat(agent-office): Tarot Claude 파이프라인 + reroll 1회 (T5)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
132
agent-office/app/tarot/pipeline.py
Normal file
132
agent-office/app/tarot/pipeline.py
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
"""Tarot 파이프라인 — Claude Sonnet 호출 + 파싱 폴백 + reroll 1회."""
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from typing import Any, Dict
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from ..config import (
|
||||||
|
ANTHROPIC_API_KEY,
|
||||||
|
TAROT_MODEL,
|
||||||
|
TAROT_COST_INPUT_PER_M,
|
||||||
|
TAROT_COST_OUTPUT_PER_M,
|
||||||
|
TAROT_TIMEOUT_SEC,
|
||||||
|
)
|
||||||
|
from ..models import TarotInterpretRequest
|
||||||
|
from .prompt import SYSTEM_PROMPT, build_user_message
|
||||||
|
from .schema import validate_interpretation
|
||||||
|
|
||||||
|
|
||||||
|
API_URL = "https://api.anthropic.com/v1/messages"
|
||||||
|
|
||||||
|
|
||||||
|
class TarotError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def calc_cost(tokens_in: int, tokens_out: int) -> float:
|
||||||
|
return (
|
||||||
|
tokens_in / 1_000_000 * TAROT_COST_INPUT_PER_M
|
||||||
|
+ tokens_out / 1_000_000 * TAROT_COST_OUTPUT_PER_M
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_codeblock(text: str) -> str:
|
||||||
|
t = text.strip()
|
||||||
|
if t.startswith("```"):
|
||||||
|
t = t.strip("`")
|
||||||
|
if t.startswith("json"):
|
||||||
|
t = t[4:]
|
||||||
|
t = t.strip()
|
||||||
|
return t
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_json(raw: str) -> dict:
|
||||||
|
cleaned = _strip_codeblock(raw)
|
||||||
|
try:
|
||||||
|
return json.loads(cleaned)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
start, end = cleaned.find("{"), cleaned.rfind("}")
|
||||||
|
if start >= 0 and end > start:
|
||||||
|
try:
|
||||||
|
return json.loads(cleaned[start : end + 1])
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
async def _call_claude(user_text: str, feedback: str = "") -> tuple[dict, dict, str]:
|
||||||
|
if not ANTHROPIC_API_KEY:
|
||||||
|
raise TarotError("ANTHROPIC_API_KEY missing")
|
||||||
|
if feedback:
|
||||||
|
user_text = f"이전 응답이 다음 이유로 거절됨: {feedback}\n올바른 스키마(시스템 지침)로 다시 응답.\n\n{user_text}"
|
||||||
|
payload = {
|
||||||
|
"model": TAROT_MODEL,
|
||||||
|
"max_tokens": 2048,
|
||||||
|
"system": [{"type": "text", "text": SYSTEM_PROMPT,
|
||||||
|
"cache_control": {"type": "ephemeral"}}],
|
||||||
|
"messages": [{"role": "user", "content": [{"type": "text", "text": user_text}]}],
|
||||||
|
}
|
||||||
|
headers = {
|
||||||
|
"x-api-key": ANTHROPIC_API_KEY,
|
||||||
|
"anthropic-version": "2023-06-01",
|
||||||
|
"anthropic-beta": "prompt-caching-2024-07-31",
|
||||||
|
"content-type": "application/json",
|
||||||
|
}
|
||||||
|
started = time.monotonic()
|
||||||
|
async with httpx.AsyncClient(timeout=TAROT_TIMEOUT_SEC) as client:
|
||||||
|
r = await client.post(API_URL, headers=headers, json=payload)
|
||||||
|
r.raise_for_status()
|
||||||
|
resp = r.json()
|
||||||
|
latency_ms = int((time.monotonic() - started) * 1000)
|
||||||
|
raw_text = "".join(
|
||||||
|
b.get("text", "") for b in resp.get("content", []) if b.get("type") == "text"
|
||||||
|
)
|
||||||
|
parsed = _extract_json(raw_text)
|
||||||
|
usage = resp.get("usage", {}) or {}
|
||||||
|
meta = {
|
||||||
|
"tokens_in": int(usage.get("input_tokens", 0) or 0),
|
||||||
|
"tokens_out": int(usage.get("output_tokens", 0) or 0),
|
||||||
|
"latency_ms": latency_ms,
|
||||||
|
}
|
||||||
|
return parsed, meta, raw_text
|
||||||
|
|
||||||
|
|
||||||
|
async def interpret(req: TarotInterpretRequest) -> Dict[str, Any]:
|
||||||
|
user_text = build_user_message(
|
||||||
|
question=req.question or "",
|
||||||
|
category=req.category or "",
|
||||||
|
spread_type=req.spread_type,
|
||||||
|
cards_reference=req.cards_reference,
|
||||||
|
context_meta=req.context_meta or {},
|
||||||
|
spread_count=len(req.cards),
|
||||||
|
)
|
||||||
|
|
||||||
|
total_in, total_out, total_latency = 0, 0, 0
|
||||||
|
last_error = ""
|
||||||
|
for attempt in range(2):
|
||||||
|
try:
|
||||||
|
parsed, meta, _raw = await _call_claude(user_text, feedback=last_error)
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
raise TarotError(f"Claude HTTP error: {e}") from e
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
last_error = f"JSON 파싱 실패: {e}"
|
||||||
|
continue
|
||||||
|
total_in += meta["tokens_in"]
|
||||||
|
total_out += meta["tokens_out"]
|
||||||
|
total_latency += meta["latency_ms"]
|
||||||
|
|
||||||
|
ok, err = validate_interpretation(parsed, req.spread_type)
|
||||||
|
if ok:
|
||||||
|
return {
|
||||||
|
"interpretation_json": parsed,
|
||||||
|
"model": TAROT_MODEL,
|
||||||
|
"tokens_in": total_in,
|
||||||
|
"tokens_out": total_out,
|
||||||
|
"cost_usd": calc_cost(total_in, total_out),
|
||||||
|
"latency_ms": total_latency,
|
||||||
|
"reroll_count": attempt,
|
||||||
|
}
|
||||||
|
last_error = err
|
||||||
|
|
||||||
|
raise TarotError(f"검증 실패 (reroll 2회): {last_error}")
|
||||||
@@ -4,5 +4,6 @@ apscheduler==3.10.4
|
|||||||
websockets>=12.0
|
websockets>=12.0
|
||||||
httpx>=0.27
|
httpx>=0.27
|
||||||
respx>=0.21
|
respx>=0.21
|
||||||
|
pytest-asyncio>=0.23
|
||||||
google-api-python-client>=2.100.0
|
google-api-python-client>=2.100.0
|
||||||
pytrends>=4.9.2
|
pytrends>=4.9.2
|
||||||
|
|||||||
113
agent-office/tests/test_tarot_pipeline.py
Normal file
113
agent-office/tests/test_tarot_pipeline.py
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import json
|
||||||
|
import pytest
|
||||||
|
import respx
|
||||||
|
from httpx import Response
|
||||||
|
|
||||||
|
from app.tarot import pipeline as p
|
||||||
|
from app.models import TarotInterpretRequest
|
||||||
|
|
||||||
|
|
||||||
|
def _valid_response_text():
|
||||||
|
return json.dumps({
|
||||||
|
"summary": "S",
|
||||||
|
"cards": [
|
||||||
|
{"position": "과거", "card": "the-fool", "reversed": False,
|
||||||
|
"interpretation": "i", "advice": "a",
|
||||||
|
"evidence": {"card_meaning_used": "k", "position_logic": "p", "category_lens": "c"}},
|
||||||
|
{"position": "현재", "card": "the-lovers", "reversed": True,
|
||||||
|
"interpretation": "i", "advice": "a",
|
||||||
|
"evidence": {"card_meaning_used": "k", "position_logic": "p", "category_lens": "c"}},
|
||||||
|
{"position": "미래", "card": "ten-of-cups", "reversed": False,
|
||||||
|
"interpretation": "i", "advice": "a",
|
||||||
|
"evidence": {"card_meaning_used": "k", "position_logic": "p", "category_lens": "c"}},
|
||||||
|
],
|
||||||
|
"interactions": [{"type": "synergy", "between": ["the-fool", "ten-of-cups"], "explanation": "."}],
|
||||||
|
"advice": "A", "warning": None, "confidence": "medium",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _claude_resp(text, in_tok=100, out_tok=200):
|
||||||
|
return {
|
||||||
|
"content": [{"type": "text", "text": text}],
|
||||||
|
"usage": {"input_tokens": in_tok, "output_tokens": out_tok},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _req():
|
||||||
|
return TarotInterpretRequest(
|
||||||
|
spread_type="three_card",
|
||||||
|
category="연애",
|
||||||
|
question="Q",
|
||||||
|
cards=[
|
||||||
|
{"position": "과거", "card_id": "the-fool", "reversed": False},
|
||||||
|
{"position": "현재", "card_id": "the-lovers", "reversed": True},
|
||||||
|
{"position": "미래", "card_id": "ten-of-cups", "reversed": False},
|
||||||
|
],
|
||||||
|
cards_reference="REFERENCE",
|
||||||
|
context_meta={"major_minor_ratio": "2:1"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_interpret_happy_path(monkeypatch):
|
||||||
|
monkeypatch.setattr(p, "ANTHROPIC_API_KEY", "sk-test")
|
||||||
|
with respx.mock(base_url="https://api.anthropic.com") as mock:
|
||||||
|
mock.post("/v1/messages").mock(return_value=Response(200, json=_claude_resp(_valid_response_text())))
|
||||||
|
out = await p.interpret(_req())
|
||||||
|
assert out["interpretation_json"]["confidence"] == "medium"
|
||||||
|
assert out["tokens_in"] == 100
|
||||||
|
assert out["tokens_out"] == 200
|
||||||
|
assert out["reroll_count"] == 0
|
||||||
|
assert out["cost_usd"] > 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_interpret_codeblock_strip(monkeypatch):
|
||||||
|
monkeypatch.setattr(p, "ANTHROPIC_API_KEY", "sk-test")
|
||||||
|
wrapped = "```json\n" + _valid_response_text() + "\n```"
|
||||||
|
with respx.mock(base_url="https://api.anthropic.com") as mock:
|
||||||
|
mock.post("/v1/messages").mock(return_value=Response(200, json=_claude_resp(wrapped)))
|
||||||
|
out = await p.interpret(_req())
|
||||||
|
assert out["interpretation_json"]["summary"] == "S"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_interpret_reroll_on_validation_fail(monkeypatch):
|
||||||
|
monkeypatch.setattr(p, "ANTHROPIC_API_KEY", "sk-test")
|
||||||
|
bad = json.loads(_valid_response_text())
|
||||||
|
bad["cards"][0]["evidence"]["card_meaning_used"] = ""
|
||||||
|
bad_text = json.dumps(bad)
|
||||||
|
with respx.mock(base_url="https://api.anthropic.com") as mock:
|
||||||
|
route = mock.post("/v1/messages")
|
||||||
|
route.side_effect = [
|
||||||
|
Response(200, json=_claude_resp(bad_text)),
|
||||||
|
Response(200, json=_claude_resp(_valid_response_text())),
|
||||||
|
]
|
||||||
|
out = await p.interpret(_req())
|
||||||
|
assert out["reroll_count"] == 1
|
||||||
|
assert out["interpretation_json"]["cards"][0]["evidence"]["card_meaning_used"] == "k"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_interpret_raises_when_both_attempts_fail(monkeypatch):
|
||||||
|
monkeypatch.setattr(p, "ANTHROPIC_API_KEY", "sk-test")
|
||||||
|
bad = json.loads(_valid_response_text())
|
||||||
|
bad["cards"][0]["evidence"]["card_meaning_used"] = ""
|
||||||
|
bad_text = json.dumps(bad)
|
||||||
|
with respx.mock(base_url="https://api.anthropic.com") as mock:
|
||||||
|
mock.post("/v1/messages").mock(return_value=Response(200, json=_claude_resp(bad_text)))
|
||||||
|
with pytest.raises(p.TarotError):
|
||||||
|
await p.interpret(_req())
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_interpret_raises_when_api_key_missing(monkeypatch):
|
||||||
|
monkeypatch.setattr(p, "ANTHROPIC_API_KEY", "")
|
||||||
|
with pytest.raises(p.TarotError):
|
||||||
|
await p.interpret(_req())
|
||||||
|
|
||||||
|
|
||||||
|
def test_calc_cost():
|
||||||
|
assert p.calc_cost(1_000_000, 0) == pytest.approx(3.0)
|
||||||
|
assert p.calc_cost(0, 1_000_000) == pytest.approx(15.0)
|
||||||
|
assert p.calc_cost(500_000, 500_000) == pytest.approx(9.0)
|
||||||
Reference in New Issue
Block a user