From b82a10e5806870e6271d2f0a8383749a8ee6f841 Mon Sep 17 00:00:00 2001 From: gahusb Date: Tue, 7 Apr 2026 01:00:21 +0900 Subject: [PATCH] =?UTF-8?q?feat(blog-lab):=20=ED=8F=89=EA=B0=80=EC=9E=90?= =?UTF-8?q?=20=EB=8B=A8=EA=B3=84=20=E2=80=94=206=EA=B8=B0=EC=A4=80=2060?= =?UTF-8?q?=EC=A0=90=20=EC=B2=B4=EA=B3=84=20+=20link=5Fnatural=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- blog-lab/app/db.py | 50 +++++++++++++++++---- blog-lab/app/quality_reviewer.py | 14 ++++-- blog-lab/tests/test_evaluator.py | 74 ++++++++++++++++++++++++++++++++ 3 files changed, 126 insertions(+), 12 deletions(-) create mode 100644 blog-lab/tests/test_evaluator.py diff --git a/blog-lab/app/db.py b/blog-lab/app/db.py index 13092ef..79e1ab5 100644 --- a/blog-lab/app/db.py +++ b/blog-lab/app/db.py @@ -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: diff --git a/blog-lab/app/quality_reviewer.py b/blog-lab/app/quality_reviewer.py index cb2cd0f..f9928cf 100644 --- a/blog-lab/app/quality_reviewer.py +++ b/blog-lab/app/quality_reviewer.py @@ -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]}", diff --git a/blog-lab/tests/test_evaluator.py b/blog-lab/tests/test_evaluator.py new file mode 100644 index 0000000..12bb631 --- /dev/null +++ b/blog-lab/tests/test_evaluator.py @@ -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("테스트 제목", "

본문

") + + 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("제목", "

본문

") + + 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("제목", "

본문

") + + assert result["pass"] is False + assert "link_natural" in result["scores"] + assert result["total"] == 0