feat(blog-lab): 평가자 단계 — 6기준 60점 체계 + link_natural 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-07 01:00:21 +09:00
parent 4646b79e6e
commit b82a10e580
3 changed files with 126 additions and 12 deletions

View File

@@ -168,18 +168,19 @@ def _seed_templates(conn: sqlite3.Connection) -> None:
},
{
"name": "quality_review",
"description": "블로그 글 품질 리뷰 (5기준 × 10점)",
"description": "블로그 글 품질 리뷰 (6기준 × 10점)",
"template": (
"당신은 블로그 콘텐츠 품질 평가 전문가입니다.\n"
"아래 블로그 글을 5가지 기준으로 평가해주세요.\n\n"
"아래 블로그 글을 6가지 기준으로 평가해주세요.\n\n"
"제목: {title}\n"
"본문: {body}\n\n"
"평가 기준 (각 1-10점):\n"
"1. 독자 공감도: 1인칭 체험기가 자연스럽고 공감되는가?\n"
"2. 제목 클릭 유도력: 검색 결과에서 클릭하고 싶은 제목인가?\n"
"3. 구매 전환력: 읽고 나서 제품을 사고 싶어지는가?\n"
"4. SEO 최적화: 키워드 배치, 소제목, 길이가 적절한가?\n"
"5. 형식 완성도: 비교표, 이미지 설명, 단락 구성이 잘 되어있는가?\n\n"
"1. 독자 공감도 (empathy): 1인칭 체험기가 자연스럽고 공감되는가?\n"
"2. 제목 클릭 유도력 (click_appeal): 검색 결과에서 클릭하고 싶은 제목인가?\n"
"3. 구매 전환력 (conversion): 읽고 나서 제품을 사고 싶어지는가?\n"
"4. SEO 최적화 (seo): 키워드 배치, 소제목, 길이가 적절한가?\n"
"5. 형식 완성도 (format): 비교표, 이미지 설명, 단락 구성이 잘 되어있는가?\n"
"6. 링크 자연스러움 (link_natural): 제휴 링크가 광고처럼 느껴지지 않고 자연스럽게 녹아있는가? (링크가 없으면 5점 기본)\n\n"
"JSON 형식으로 응답:\n"
"{{\n"
" \"scores\": {{\n"
@@ -187,7 +188,8 @@ def _seed_templates(conn: sqlite3.Connection) -> None:
" \"click_appeal\": N,\n"
" \"conversion\": N,\n"
" \"seo\": N,\n"
" \"format\": N\n"
" \"format\": N,\n"
" \"link_natural\": N\n"
" }},\n"
" \"total\": N,\n"
" \"pass\": true/false,\n"
@@ -258,6 +260,38 @@ def _migrate_templates(conn: sqlite3.Connection) -> None:
(new_blog_write,),
)
new_quality_review = (
"당신은 블로그 콘텐츠 품질 평가 전문가입니다.\n"
"아래 블로그 글을 6가지 기준으로 평가해주세요.\n\n"
"제목: {title}\n"
"본문: {body}\n\n"
"평가 기준 (각 1-10점):\n"
"1. 독자 공감도 (empathy): 1인칭 체험기가 자연스럽고 공감되는가?\n"
"2. 제목 클릭 유도력 (click_appeal): 검색 결과에서 클릭하고 싶은 제목인가?\n"
"3. 구매 전환력 (conversion): 읽고 나서 제품을 사고 싶어지는가?\n"
"4. SEO 최적화 (seo): 키워드 배치, 소제목, 길이가 적절한가?\n"
"5. 형식 완성도 (format): 비교표, 이미지 설명, 단락 구성이 잘 되어있는가?\n"
"6. 링크 자연스러움 (link_natural): 제휴 링크가 광고처럼 느껴지지 않고 자연스럽게 녹아있는가? (링크가 없으면 5점 기본)\n\n"
"JSON 형식으로 응답:\n"
"{{\n"
" \"scores\": {{\n"
" \"empathy\": N,\n"
" \"click_appeal\": N,\n"
" \"conversion\": N,\n"
" \"seo\": N,\n"
" \"format\": N,\n"
" \"link_natural\": N\n"
" }},\n"
" \"total\": N,\n"
" \"pass\": true/false,\n"
" \"feedback\": \"개선 사항 설명\"\n"
"}}"
)
conn.execute(
"UPDATE prompt_templates SET template = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE name = 'quality_review'",
(new_quality_review,),
)
# marketer_enhance가 없으면 추가
existing = conn.execute("SELECT id FROM prompt_templates WHERE name = 'marketer_enhance'").fetchone()
if not existing:

View File

@@ -1,4 +1,4 @@
"""Claude API 기반 블로그 글 품질 리뷰 — 5기준 × 10점, 35/50 통과."""
"""Claude API 기반 블로그 글 품질 리뷰 — 6기준 × 10점, 42/60 통과."""
import json
import logging
@@ -11,7 +11,7 @@ from .db import get_template
logger = logging.getLogger(__name__)
PASS_THRESHOLD = 35 # 50점 만점 중 35점 이상이면 통과
PASS_THRESHOLD = 42 # 60점 만점 중 42점 이상이면 통과 (70%)
_client: Optional[anthropic.Anthropic] = None
@@ -28,7 +28,10 @@ def review_post(title: str, body: str) -> Dict[str, Any]:
Returns:
{
"scores": {"empathy": N, "click_appeal": N, "conversion": N, "seo": N, "format": N},
"scores": {
"empathy": N, "click_appeal": N, "conversion": N,
"seo": N, "format": N, "link_natural": N
},
"total": N,
"pass": bool,
"feedback": str
@@ -69,7 +72,10 @@ def review_post(title: str, body: str) -> Dict[str, Any]:
except (json.JSONDecodeError, KeyError, TypeError) as e:
logger.warning("Quality review JSON parse failed: %s", e)
return {
"scores": {"empathy": 0, "click_appeal": 0, "conversion": 0, "seo": 0, "format": 0},
"scores": {
"empathy": 0, "click_appeal": 0, "conversion": 0,
"seo": 0, "format": 0, "link_natural": 0,
},
"total": 0,
"pass": False,
"feedback": f"리뷰 파싱 실패. 원본 응답:\n{raw[:500]}",

View File

@@ -0,0 +1,74 @@
"""평가자 단계 테스트 — 6기준 60점."""
import json
import pytest
from unittest.mock import patch
def test_review_post_has_6_criteria():
"""6개 기준으로 채점하는지 확인."""
from app.quality_reviewer import review_post
mock_response = json.dumps({
"scores": {
"empathy": 8, "click_appeal": 7, "conversion": 9,
"seo": 8, "format": 7, "link_natural": 9,
},
"total": 48,
"pass": True,
"feedback": "전체적으로 우수합니다",
})
with patch("app.quality_reviewer._get_client") as mock_client_fn, \
patch("app.quality_reviewer.get_template", return_value="제목: {title}\n본문: {body}"):
mock_client = mock_client_fn.return_value
mock_client.messages.create.return_value.content = [type("C", (), {"text": mock_response})()]
result = review_post("테스트 제목", "<p>본문</p>")
assert "link_natural" in result["scores"]
assert len(result["scores"]) == 6
assert result["total"] == 48
assert result["pass"] is True
def test_review_pass_threshold_is_42():
"""통과 기준이 42점인지 확인."""
from app.quality_reviewer import PASS_THRESHOLD
assert PASS_THRESHOLD == 42
def test_review_fails_below_42():
"""42점 미만이면 불통과."""
from app.quality_reviewer import review_post
mock_response = json.dumps({
"scores": {
"empathy": 5, "click_appeal": 5, "conversion": 5,
"seo": 5, "format": 5, "link_natural": 5,
},
"total": 30,
"pass": False,
"feedback": "개선 필요",
})
with patch("app.quality_reviewer._get_client") as mock_client_fn, \
patch("app.quality_reviewer.get_template", return_value="제목: {title}\n본문: {body}"):
mock_client = mock_client_fn.return_value
mock_client.messages.create.return_value.content = [type("C", (), {"text": mock_response})()]
result = review_post("제목", "<p>본문</p>")
assert result["pass"] is False
def test_review_handles_parse_failure():
"""JSON 파싱 실패 시 기본값 반환 (6개 기준)."""
from app.quality_reviewer import review_post
with patch("app.quality_reviewer._get_client") as mock_client_fn, \
patch("app.quality_reviewer.get_template", return_value="제목: {title}\n본문: {body}"):
mock_client = mock_client_fn.return_value
mock_client.messages.create.return_value.content = [type("C", (), {"text": "잘못된 응답"})()]
result = review_post("제목", "<p>본문</p>")
assert result["pass"] is False
assert "link_natural" in result["scores"]
assert result["total"] == 0