feat(insta-lab): card_writer with Claude 10-page JSON generator
This commit is contained in:
100
insta-lab/app/card_writer.py
Normal file
100
insta-lab/app/card_writer.py
Normal file
@@ -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
|
||||||
75
insta-lab/tests/test_card_writer.py
Normal file
75
insta-lab/tests/test_card_writer.py
Normal file
@@ -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")
|
||||||
Reference in New Issue
Block a user