From 4623c68d4e993043042513db4d7bef23da816a50 Mon Sep 17 00:00:00 2001 From: gahusb Date: Sun, 24 May 2026 00:18:42 +0900 Subject: [PATCH] =?UTF-8?q?feat(agent-office):=20Tarot=20Claude=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=B4=ED=94=84=EB=9D=BC=EC=9D=B8=20+=20reroll=201?= =?UTF-8?q?=ED=9A=8C=20(T5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- agent-office/app/tarot/pipeline.py | 132 ++++++++++++++++++++++ agent-office/requirements.txt | 1 + agent-office/tests/test_tarot_pipeline.py | 113 ++++++++++++++++++ 3 files changed, 246 insertions(+) create mode 100644 agent-office/app/tarot/pipeline.py create mode 100644 agent-office/tests/test_tarot_pipeline.py diff --git a/agent-office/app/tarot/pipeline.py b/agent-office/app/tarot/pipeline.py new file mode 100644 index 0000000..7b5ef94 --- /dev/null +++ b/agent-office/app/tarot/pipeline.py @@ -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}") diff --git a/agent-office/requirements.txt b/agent-office/requirements.txt index e0fdb37..5bf9753 100644 --- a/agent-office/requirements.txt +++ b/agent-office/requirements.txt @@ -4,5 +4,6 @@ apscheduler==3.10.4 websockets>=12.0 httpx>=0.27 respx>=0.21 +pytest-asyncio>=0.23 google-api-python-client>=2.100.0 pytrends>=4.9.2 diff --git a/agent-office/tests/test_tarot_pipeline.py b/agent-office/tests/test_tarot_pipeline.py new file mode 100644 index 0000000..0966fb2 --- /dev/null +++ b/agent-office/tests/test_tarot_pipeline.py @@ -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)