"""메타데이터 생성 — Claude Haiku + 템플릿 폴백.""" import os import json import logging import httpx logger = logging.getLogger("music-lab.metadata") CLAUDE_HAIKU_MODEL_DEFAULT = "claude-haiku-4-5-20251001" TIMEOUT_S = 30 def _get_api_key() -> str: return os.getenv("ANTHROPIC_API_KEY", "") def _get_model() -> str: return os.getenv("CLAUDE_HAIKU_MODEL", CLAUDE_HAIKU_MODEL_DEFAULT) async def generate(*, track: dict, template: dict, trend_keywords: list[str], feedback: str = "") -> dict: """메타데이터 생성. 성공 시 LLM, 실패/미설정 시 템플릿 치환 폴백. 반환: {"title", "description", "tags", "category_id", "used_fallback", "error"} """ api_key = _get_api_key() if not api_key: return {**_fallback_template(track, template), "used_fallback": True, "error": "no api key"} try: result = await _call_claude(track, template, trend_keywords, feedback, api_key=api_key, model=_get_model()) return {**result, "used_fallback": False, "error": None} except (httpx.HTTPError, httpx.TimeoutException, KeyError, ValueError, json.JSONDecodeError) as e: logger.warning("메타데이터 LLM 실패 — 폴백: %s", e) return {**_fallback_template(track, template), "used_fallback": True, "error": str(e)} def _fallback_template(track: dict, template: dict) -> dict: fmt_vars = { "title": track.get("title", ""), "genre": track.get("genre", ""), "bpm": track.get("bpm", ""), "key": track.get("key", ""), "scale": track.get("scale", ""), } title = template.get("title", "{title}").format(**fmt_vars) description = template.get("description", "{title}").format(**fmt_vars) return { "title": title[:100], "description": description[:5000], "tags": (template.get("tags") or [])[:15], "category_id": template.get("category_id", 10), } async def _call_claude(track: dict, template: dict, trend_keywords: list[str], feedback: str, *, api_key: str, model: str) -> dict: user_prompt = ( "다음 트랙의 YouTube 메타데이터를 생성하세요. JSON으로만 응답.\n\n" f"트랙: {json.dumps(track, ensure_ascii=False)}\n" f"템플릿: {json.dumps(template, ensure_ascii=False)}\n" f"트렌드 키워드: {', '.join(trend_keywords)}\n" ) if feedback: user_prompt += f"\n사용자 피드백: {feedback}\n" user_prompt += ( '\n출력 JSON: {"title": "60자 이내", "description": "1000자 이내, 3-5문단",' ' "tags": ["15개 이내"], "category_id": 10}' ) async with httpx.AsyncClient(timeout=TIMEOUT_S) as client: resp = await client.post( "https://api.anthropic.com/v1/messages", headers={ "x-api-key": api_key, "anthropic-version": "2023-06-01", "content-type": "application/json", }, json={ "model": model, "max_tokens": 1024, "messages": [{"role": "user", "content": user_prompt}], }, ) resp.raise_for_status() text = resp.json()["content"][0]["text"] # 가장 첫 JSON 블록 추출 start = text.find("{") end = text.rfind("}") + 1 if start < 0 or end <= start: raise ValueError("Claude 응답에 JSON 블록 없음") return json.loads(text[start:end])