diff --git a/insta-lab/app/card_writer.py b/insta-lab/app/card_writer.py new file mode 100644 index 0000000..a763e5f --- /dev/null +++ b/insta-lab/app/card_writer.py @@ -0,0 +1,100 @@ +"""Claude로 10페이지 카드 카피를 한 번에 생성.""" + +import json +import logging +import re +from typing import Any, Dict, Optional + +from anthropic import Anthropic + +from .config import ANTHROPIC_API_KEY, ANTHROPIC_MODEL_SONNET +from . import db + +logger = logging.getLogger(__name__) + +DEFAULT_ACCENT_BY_CATEGORY = { + "economy": "#0F62FE", + "psychology": "#A66CFF", + "celebrity": "#FF5C8A", +} + +DEFAULT_PROMPT = """너는 인스타그램 카드 뉴스 카피라이터다. +카테고리: {category} +키워드: {keyword} +참고 기사: +{articles} + +10페이지 인스타 카드용 카피를 다음 JSON 한 객체로만 출력해라 (코드펜스 금지): +{{ + "cover_copy": {{"headline": "<훅 한 줄>", "body": "<서브카피 1~2줄>", "accent_color": "#hex"}}, + "body_copies": [ + {{"headline": "<포인트 헤드라인>", "body": "<2~4문장 본문>"}}, + ... (총 8개) + ], + "cta_copy": {{"headline": "<요약 한 줄>", "body": "<마무리 1~2줄>", "cta": "팔로우/저장 등"}}, + "suggested_caption": "<인스타 캡션 본문>", + "hashtags": ["#태그1", "#태그2", ...] +}} +""" + + +def _client() -> Anthropic: + return Anthropic(api_key=ANTHROPIC_API_KEY) + + +def _strip_codefence(s: str) -> str: + s = s.strip() + if s.startswith("```"): + s = re.sub(r"^```(?:json)?\s*|\s*```$", "", s).strip() + return s + + +def _load_prompt() -> str: + pt = db.get_prompt_template("slate_writer") + if pt and pt.get("template"): + return pt["template"] + return DEFAULT_PROMPT + + +def write_slate(keyword: str, category: str, + articles: Optional[list] = None) -> int: + """Claude로 10페이지 카피 생성 후 card_slates에 저장. slate_id 반환.""" + if articles is None: + articles = db.list_news_articles(category=category, days=2) + article_text = "\n".join( + f"- {a['title']}: {a.get('summary', '')[:120]}" for a in articles[:8] + ) or "(참고 기사 없음)" + + prompt = _load_prompt().format(category=category, keyword=keyword, articles=article_text) + msg = _client().messages.create( + model=ANTHROPIC_MODEL_SONNET, + max_tokens=4000, + messages=[{"role": "user", "content": prompt}], + ) + raw = msg.content[0].text + cleaned = _strip_codefence(raw) + try: + data: Dict[str, Any] = json.loads(cleaned) + except json.JSONDecodeError as e: + logger.warning("slate JSON parse failed: %s", e) + raise ValueError(f"Invalid JSON from LLM: {e}") from e + + body_copies = data.get("body_copies") or [] + if len(body_copies) != 8: + raise ValueError(f"body_copies must have 8 items, got {len(body_copies)}") + + cover = data.get("cover_copy") or {} + if not cover.get("accent_color"): + cover["accent_color"] = DEFAULT_ACCENT_BY_CATEGORY.get(category, "#222831") + + sid = db.add_card_slate({ + "keyword": keyword, + "category": category, + "status": "draft", + "cover_copy": cover, + "body_copies": body_copies, + "cta_copy": data.get("cta_copy") or {}, + "suggested_caption": data.get("suggested_caption") or "", + "hashtags": data.get("hashtags") or [], + }) + return sid diff --git a/insta-lab/tests/test_card_writer.py b/insta-lab/tests/test_card_writer.py new file mode 100644 index 0000000..a5263e0 --- /dev/null +++ b/insta-lab/tests/test_card_writer.py @@ -0,0 +1,75 @@ +import json +import os +import tempfile +from unittest.mock import patch, MagicMock + +import pytest + +from app import db as db_module +from app import card_writer + + +@pytest.fixture +def tmp_db(monkeypatch): + fd, path = tempfile.mkstemp(suffix=".db") + os.close(fd) + monkeypatch.setattr(db_module, "DB_PATH", path) + db_module.init_db() + yield path + import gc + gc.collect() + for ext in ("", "-wal", "-shm"): + try: + os.remove(path + ext) + except OSError: + pass + + +SAMPLE_LLM_JSON = { + "cover_copy": {"headline": "금리 인상 단행", "body": "왜 지금?", "accent_color": "#0F62FE"}, + "body_copies": [ + {"headline": f"포인트 {i+1}", "body": f"본문 {i+1}"} for i in range(8) + ], + "cta_copy": {"headline": "정리", "body": "바로 확인", "cta": "팔로우"}, + "suggested_caption": "금리에 대해 알아보자", + "hashtags": ["#금리", "#경제"], +} + + +def _fake_messages_create(*_args, **_kwargs): + msg = MagicMock() + block = MagicMock() + block.text = json.dumps(SAMPLE_LLM_JSON, ensure_ascii=False) + msg.content = [block] + return msg + + +def test_write_slate_persists_full_payload(tmp_db, monkeypatch): + db_module.add_news_article({ + "category": "economy", "title": "기준금리 인상 단행", + "link": "https://example.com/1", "summary": "한국은행 발표", + }) + fake_client = MagicMock() + fake_client.messages.create = _fake_messages_create + monkeypatch.setattr(card_writer, "_client", lambda: fake_client) + + sid = card_writer.write_slate(keyword="기준금리", category="economy") + slate = db_module.get_card_slate(sid) + assert slate["status"] == "draft" + body_copies = json.loads(slate["body_copies"]) + assert len(body_copies) == 8 + assert body_copies[0]["headline"] == "포인트 1" + assert json.loads(slate["cover_copy"])["accent_color"] == "#0F62FE" + + +def test_write_slate_raises_on_invalid_json(tmp_db, monkeypatch): + fake_client = MagicMock() + bad_msg = MagicMock() + bad_block = MagicMock() + bad_block.text = "not json" + bad_msg.content = [bad_block] + fake_client.messages.create.return_value = bad_msg + monkeypatch.setattr(card_writer, "_client", lambda: fake_client) + + with pytest.raises(ValueError): + card_writer.write_slate(keyword="x", category="economy")