feat(plan): 네이버 블로그 채널 + brand_guide profile_photo 시스템 박기

네이버 블로그 채널 추가:
- naver.fetch_blog_total_count: RSS에 totalCount 없으면 blog.naver.com 의 PostList
  페이지 HTML에서 '(\d+)개의 글' 패턴으로 진짜 전체 글 수 추출
  (RSS는 최근 50개만 줘서 그동안 totalResults=50 으로 잘못 박혔음 — 뷰성형외과 실제 554개)
- analysis._naver_blog_summary 다이어트: totalPosts + latestPostDate 만 LLM에 보냄
  (posts 본문/링크/제목 빼서 토큰 절약 + LLM의 무관 정보 hallucinate 방지)
- plan_prompt: channelStrategies 리스트에 네이버 블로그 명시 포함

brand_guide.channel_branding.profile_photo 코드 박기:
- 기존: LLM이 "공식 로고로 통일 (가이드 미보유)" 같은 fallback 문구 hallucinate
- 수정: analysis._patch_plan 이 모든 채널의 profile_photo 를 brand_assets.logo_description
  으로 일괄 박음 (채널 통일 전략이라 모두 동일 값)
- plan_prompt: "profilePhoto 는 빈 문자열로 두세요 — 시스템이 채웁니다" 명시

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
channel-brand
Mina Choi 2026-05-29 10:44:18 +09:00
parent 8c1e513dc0
commit 9da285e905
3 changed files with 67 additions and 8 deletions

View File

@ -32,8 +32,11 @@
## 분석 리포트 ## 분석 리포트
{report} {report}
## 추가 채널 데이터 (틱톡 / 인스타그램 EN / 페이스북 EN) ## 추가 채널 데이터 (네이버 블로그 / 틱톡 / 인스타그램 EN / 페이스북 EN)
아래에 데이터가 있는 채널은 channelStrategies와 channelBranding에 **반드시 포함**하세요 (틱톡, 영문 인스타그램, 영문 페이스북). null이면 제외. 아래에 데이터가 있는 채널은 channelStrategies와 channelBranding에 **반드시 포함**하세요 (네이버 블로그, 틱톡, 영문 인스타그램, 영문 페이스북). null이면 제외.
### 네이버 블로그 (Naver Blog)
{naver_blog}
### 틱톡 (TikTok) ### 틱톡 (TikTok)
{tiktok} {tiktok}
@ -47,7 +50,12 @@
## 채널별 로고 분석 (Gemini Vision) — 채널룰/일관성의 근거 ## 채널별 로고 분석 (Gemini Vision) — 채널룰/일관성의 근거
{channel_logos} {channel_logos}
- 위 channel_logos[]의 각 항목: channel(채널명), logo_description(프로필이 어떻게 생겼는지), is_official(공식 로고와 일치 여부). - 위 channel_logos[]의 각 항목: channel(채널명), logo_description(프로필이 어떻게 생겼는지), is_official(공식 로고와 일치 여부).
- **channelBranding[]를 이 데이터로 채우세요**: 채널별로 profilePhoto=해당 채널의 logo_description, currentStatus=is_official이 true면 "correct" / false면 "incorrect" (데이터 없는 채널은 "missing"). bannerSpec은 권장 배너 규격(크기/디자인)을 작성. - **channelBranding[]은 "어떻게 해야 하는지 권장 가이드라인" 섹션입니다.** 채널 통일 전략 기준으로 권장값 박을 것:
- profilePhoto: **빈 문자열 ""로 두세요.** 시스템이 brand_assets.logo_description으로 직접 채우므로 LLM은 만들지 마세요.
- bannerSpec: 권장 배너 규격 (크기·디자인 가이드)
- bioTemplate: 권장 bio 템플릿 (구조·필수 요소·예약 링크 포함 여부)
- currentStatus: is_official=true면 "correct" / false면 "incorrect" (데이터 없는 채널은 "N/A") — 현재 상태 마커는 이 필드 하나로만.
- 현재 채널 프로필 이미지의 실제 묘사(channel_logos.channel_logos[].logo_description)는 brandInconsistencies에서만 사용. channelBranding에서 채널별로 다른 묘사를 박지 마세요.
- **brandInconsistencies[]에 "로고" 항목을 반드시 만드세요**: values[]에 채널마다 channel(채널명) / value(logo_description 그대로) / is_correct(is_official 값) 세 필드를 넣고, impact는 inconsistency_summary, recommendation은 channel_logos.recommendation 기반으로 작성 (공식 로고로 통일 권고 포함). - **brandInconsistencies[]에 "로고" 항목을 반드시 만드세요**: values[]에 채널마다 channel(채널명) / value(logo_description 그대로) / is_correct(is_official 값) 세 필드를 넣고, impact는 inconsistency_summary, recommendation은 channel_logos.recommendation 기반으로 작성 (공식 로고로 통일 권고 포함).
## 브랜드 자산 (홈페이지 CSS에서 추출 — 결정적 데이터) ## 브랜드 자산 (홈페이지 CSS에서 추출 — 결정적 데이터)
@ -69,10 +77,10 @@
- brandInconsistencies: 채널 간 브랜딩 불일치 항목 및 개선 권고 - brandInconsistencies: 채널 간 브랜딩 불일치 항목 및 개선 권고
### Section 2: channelStrategies ### Section 2: channelStrategies
- 리포트에 데이터가 있는 채널만 포함 - 메인 SNS 채널(Instagram, Facebook, YouTube, TikTok, 네이버 블로그) + 영문 계정(Instagram EN, Facebook EN) 카드를 **모두 포함**. 데이터 없는 채널도 빠뜨리지 말 것.
- **currentStatus는 현재 채널 상태를 실제 수치로 서술** (예: "14,047 팔로워, Reels 0개", "104K 구독자, 주 2~3회 업로드"). `excellent`/`warning`/`good` 같은 등급·평가어를 절대 쓰지 마세요. - **currentStatus**: 데이터 있는 채널은 실제 수치로 서술 (예: "14,047 팔로워, Reels 0개", "104K 구독자, 주 2~3회 업로드"). **데이터 없는 채널은 "계정 없음"** 으로 표시. `excellent`/`warning`/`good` 같은 등급·평가어 금지.
- targetGoal은 구체적 목표 수치로 작성 (예: "50K 팔로워, Reels 주 5개") - **targetGoal은 모든 채널에 반드시 채울 것** — 구체적 목표 수치(예: "50K 팔로워, Reels 주 5개"). 데이터 없는 채널도 시작 시 권장 목표를 작성하고 비우지 말 것.
- 각 채널의 우선순위(P0/P1/P2), 콘텐츠 유형, 게시 빈도, 포맷 가이드라인 작성 - 각 채널의 우선순위(P0/P1/P2), 콘텐츠 유형, 게시 빈도, 포맷 가이드라인 모두 권장값으로 작성 — 데이터 없어도 시작 권장값으로 채울 것.
- customerJourneyStage는 해당 채널의 주요 기여 단계로 설정 - customerJourneyStage는 해당 채널의 주요 기여 단계로 설정
### Section 3: contentStrategy ### Section 3: contentStrategy

View File

@ -64,6 +64,20 @@ class NaverClient:
return None return None
return resp.text return resp.text
async def fetch_blog_total_count(self, handle: str) -> int | None:
"""블로그 전체 글 수는 RSS에 없어서 PostList HTML에서 '554개의 글' 패턴 추출.
<h4 class="category_title pcol2">... 554개의 </h4> 구조."""
resp = await http_request(
HTTPMethod.GET,
url=f"https://blog.naver.com/PostList.naver?blogId={handle}&from=postList&directAccess=true",
timeout=15,
label="naver-blog-postlist",
)
if not resp or not resp.is_success:
return None
m = re.search(r"(\d+)개의 글", resp.text)
return int(m.group(1)) if m else None
async def get_blog_rss(self, url: str) -> dict | None: async def get_blog_rss(self, url: str) -> dict | None:
blog_handle = urlparse(url).path.strip("/").split("/")[0] if "://" in url else url blog_handle = urlparse(url).path.strip("/").split("/")[0] if "://" in url else url
xml = await self.fetch_blog_rss(blog_handle) xml = await self.fetch_blog_rss(blog_handle)
@ -82,10 +96,15 @@ class NaverClient:
"postDate": date.group(1) if date else "", "postDate": date.group(1) if date else "",
"description": re.sub(r"<[^>]*>", "", desc.group(1) if desc else "").strip()[:150], "description": re.sub(r"<[^>]*>", "", desc.group(1) if desc else "").strip()[:150],
}) })
# RSS의 totalCount 우선, 없으면 블로그 PostList 페이지에서 "N개의 글" 파싱, 그것도 없으면 RSS 글수
total_match = re.search(r"<totalCount>(\d+)</totalCount>", xml) total_match = re.search(r"<totalCount>(\d+)</totalCount>", xml)
if total_match:
total = int(total_match.group(1))
else:
total = await self.fetch_blog_total_count(blog_handle) or len(posts)
return { return {
"officialBlogUrl": f"https://blog.naver.com/{blog_handle}", "officialBlogUrl": f"https://blog.naver.com/{blog_handle}",
"officialBlogHandle": blog_handle, "officialBlogHandle": blog_handle,
"totalResults": int(total_match.group(1)) if total_match else len(posts), "totalResults": total,
"posts": posts[:10], "posts": posts[:10],
} }

View File

@ -46,6 +46,8 @@ async def generate_report(analysis_run_id: str) -> ReportOutput:
"tiktok": _json(clinic.get("tiktok")), "tiktok": _json(clinic.get("tiktok")),
"instagram_en": _json(clinic.get("instagramEn")), "instagram_en": _json(clinic.get("instagramEn")),
"facebook_en": _json(clinic.get("facebookEn")), "facebook_en": _json(clinic.get("facebookEn")),
"kakao_talk": _json(clinic.get("kakaoTalk")),
"naver_cafe": _json(clinic.get("naverCafe")),
"channel_logos": _json(clinic.get("channelLogos")), "channel_logos": _json(clinic.get("channelLogos")),
**{ **{
channel: _json(data) channel: _json(data)
@ -69,6 +71,7 @@ async def generate_plan(analysis_run_id: str) -> PlanOutput:
report_data = run["report_data"] report_data = run["report_data"]
report = json.loads(report_data) if isinstance(report_data, str) else report_data report = json.loads(report_data) if isinstance(report_data, str) else report_data
market = await get_market_analysis(analysis_run_id) market = await get_market_analysis(analysis_run_id)
raw = await get_analysis_raw_data(analysis_run_id)
def _json(v) -> str | None: def _json(v) -> str | None:
return json.dumps(v, ensure_ascii=False) if v else None return json.dumps(v, ensure_ascii=False) if v else None
@ -89,6 +92,7 @@ async def generate_plan(analysis_run_id: str) -> PlanOutput:
"tiktok": _json(clinic.get("tiktok")), "tiktok": _json(clinic.get("tiktok")),
"instagram_en": _json(clinic.get("instagramEn")), "instagram_en": _json(clinic.get("instagramEn")),
"facebook_en": _json(clinic.get("facebookEn")), "facebook_en": _json(clinic.get("facebookEn")),
"naver_blog": _json(_naver_blog_summary(raw.get("naver_blog"))),
"channel_logos": _json(clinic.get("channelLogos")), "channel_logos": _json(clinic.get("channelLogos")),
"brand_assets": _json(clinic.get("brandAssets")), "brand_assets": _json(clinic.get("brandAssets")),
} }
@ -96,6 +100,18 @@ async def generate_plan(analysis_run_id: str) -> PlanOutput:
return await LLMService(provider="perplexity").generate(plan_prompt, input_data) return await LLMService(provider="perplexity").generate(plan_prompt, input_data)
def _naver_blog_summary(blog: dict | None) -> dict | None:
"""plan 카드 한 장에 들어가는 건 전체 포스트 수와 최근 활동 시점뿐. 그 외(본문·링크·제목)는
던져봐야 토큰만 늘고 LLM이 무관 정보로 hallucinate ."""
if not blog:
return None
posts = blog.get("posts") or []
return {
"totalPosts": blog.get("totalResults"),
"latestPostDate": posts[0].get("postDate") if posts else None,
}
async def _build_overrides(analysis_run_id: str) -> dict: async def _build_overrides(analysis_run_id: str) -> dict:
run = await fetchone( run = await fetchone(
"SELECT hospital_id, instagram_data_id, facebook_data_id," "SELECT hospital_id, instagram_data_id, facebook_data_id,"
@ -204,9 +220,25 @@ async def run_report_task(analysis_run_id: str) -> None:
logger.info("[report] done run=%s", analysis_run_id) logger.info("[report] done run=%s", analysis_run_id)
def _patch_plan(result: PlanOutput, logo_desc: str) -> PlanOutput:
"""brand_guide.channel_branding[].profile_photo 는 LLM 안 맡기고 코드가 박는다
(모든 채널 동일값 = brand_assets.logo_description). LLM이 fallback 문구 hallucinate 방지."""
p = result.model_dump()
for ch in (p.get("brand_guide") or {}).get("channel_branding") or []:
ch["profile_photo"] = logo_desc
return PlanOutput(**p)
async def run_plan_task(analysis_run_id: str) -> None: async def run_plan_task(analysis_run_id: str) -> None:
logger.info("[plan] start run=%s", analysis_run_id) logger.info("[plan] start run=%s", analysis_run_id)
result = await generate_plan(analysis_run_id) result = await generate_plan(analysis_run_id)
# profile_photo 는 brand_assets.logo_description 으로 코드가 박음 (LLM "(가이드 미보유)" 같은 hallucination 차단)
run = await fetchone("SELECT hospital_id FROM analysis_runs WHERE analysis_run_id = %s", (analysis_run_id,))
if run:
hr = await fetchone("SELECT raw_data FROM hospital_baseinfo WHERE hospital_id = %s", (run["hospital_id"],))
h = json.loads(hr["raw_data"]) if hr and isinstance(hr.get("raw_data"), str) else (hr or {}).get("raw_data") or {}
logo_desc = ((h.get("brandAssets") or {}).get("logo_description")) or ""
result = _patch_plan(result, logo_desc)
await execute( await execute(
"UPDATE analysis_runs SET plan_data = %s WHERE analysis_run_id = %s", "UPDATE analysis_runs SET plan_data = %s WHERE analysis_run_id = %s",
(json.dumps(result.model_dump(), ensure_ascii=False), analysis_run_id), (json.dumps(result.model_dump(), ensure_ascii=False), analysis_run_id),