diff --git a/app/integrations/llm/temp-prompt/plan_prompt.txt b/app/integrations/llm/temp-prompt/plan_prompt.txt
index 0b7e757..d98d285 100644
--- a/app/integrations/llm/temp-prompt/plan_prompt.txt
+++ b/app/integrations/llm/temp-prompt/plan_prompt.txt
@@ -32,8 +32,11 @@
## 분석 리포트
{report}
-## 추가 채널 데이터 (틱톡 / 인스타그램 EN / 페이스북 EN)
-아래에 데이터가 있는 채널은 channelStrategies와 channelBranding에 **반드시 포함**하세요 (틱톡, 영문 인스타그램, 영문 페이스북). null이면 제외.
+## 추가 채널 데이터 (네이버 블로그 / 틱톡 / 인스타그램 EN / 페이스북 EN)
+아래에 데이터가 있는 채널은 channelStrategies와 channelBranding에 **반드시 포함**하세요 (네이버 블로그, 틱톡, 영문 인스타그램, 영문 페이스북). null이면 제외.
+
+### 네이버 블로그 (Naver Blog)
+{naver_blog}
### 틱톡 (TikTok)
{tiktok}
@@ -47,7 +50,12 @@
## 채널별 로고 분석 (Gemini Vision) — 채널룰/일관성의 근거
{channel_logos}
- 위 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 기반으로 작성 (공식 로고로 통일 권고 포함).
## 브랜드 자산 (홈페이지 CSS에서 추출 — 결정적 데이터)
@@ -69,10 +77,10 @@
- brandInconsistencies: 채널 간 브랜딩 불일치 항목 및 개선 권고
### Section 2: channelStrategies
-- 리포트에 데이터가 있는 채널만 포함
-- **currentStatus는 현재 채널 상태를 실제 수치로 서술** (예: "14,047 팔로워, Reels 0개", "104K 구독자, 주 2~3회 업로드"). `excellent`/`warning`/`good` 같은 등급·평가어를 절대 쓰지 마세요.
-- targetGoal은 구체적 목표 수치로 작성 (예: "50K 팔로워, Reels 주 5개")
-- 각 채널의 우선순위(P0/P1/P2), 콘텐츠 유형, 게시 빈도, 포맷 가이드라인 작성
+- 메인 SNS 채널(Instagram, Facebook, YouTube, TikTok, 네이버 블로그) + 영문 계정(Instagram EN, Facebook EN) 카드를 **모두 포함**. 데이터 없는 채널도 빠뜨리지 말 것.
+- **currentStatus**: 데이터 있는 채널은 실제 수치로 서술 (예: "14,047 팔로워, Reels 0개", "104K 구독자, 주 2~3회 업로드"). **데이터 없는 채널은 "계정 없음"** 으로 표시. `excellent`/`warning`/`good` 같은 등급·평가어 금지.
+- **targetGoal은 모든 채널에 반드시 채울 것** — 구체적 목표 수치(예: "50K 팔로워, Reels 주 5개"). 데이터 없는 채널도 시작 시 권장 목표를 작성하고 비우지 말 것.
+- 각 채널의 우선순위(P0/P1/P2), 콘텐츠 유형, 게시 빈도, 포맷 가이드라인 모두 권장값으로 작성 — 데이터 없어도 시작 권장값으로 채울 것.
- customerJourneyStage는 해당 채널의 주요 기여 단계로 설정
### Section 3: contentStrategy
diff --git a/app/integrations/naver.py b/app/integrations/naver.py
index 168ba47..54eedfa 100644
--- a/app/integrations/naver.py
+++ b/app/integrations/naver.py
@@ -64,6 +64,20 @@ class NaverClient:
return None
return resp.text
+ async def fetch_blog_total_count(self, handle: str) -> int | None:
+ """블로그 전체 글 수는 RSS에 없어서 PostList HTML에서 '554개의 글' 패턴 추출.
+
... 554개의 글
구조."""
+ 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:
blog_handle = urlparse(url).path.strip("/").split("/")[0] if "://" in url else url
xml = await self.fetch_blog_rss(blog_handle)
@@ -82,10 +96,15 @@ class NaverClient:
"postDate": date.group(1) if date else "",
"description": re.sub(r"<[^>]*>", "", desc.group(1) if desc else "").strip()[:150],
})
+ # RSS의 totalCount 우선, 없으면 블로그 PostList 페이지에서 "N개의 글" 파싱, 그것도 없으면 RSS 글수
total_match = re.search(r"(\d+)", xml)
+ if total_match:
+ total = int(total_match.group(1))
+ else:
+ total = await self.fetch_blog_total_count(blog_handle) or len(posts)
return {
"officialBlogUrl": f"https://blog.naver.com/{blog_handle}",
"officialBlogHandle": blog_handle,
- "totalResults": int(total_match.group(1)) if total_match else len(posts),
+ "totalResults": total,
"posts": posts[:10],
}
diff --git a/app/services/analysis.py b/app/services/analysis.py
index baf8eeb..8bbcd3a 100644
--- a/app/services/analysis.py
+++ b/app/services/analysis.py
@@ -46,6 +46,8 @@ async def generate_report(analysis_run_id: str) -> ReportOutput:
"tiktok": _json(clinic.get("tiktok")),
"instagram_en": _json(clinic.get("instagramEn")),
"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: _json(data)
@@ -69,6 +71,7 @@ async def generate_plan(analysis_run_id: str) -> PlanOutput:
report_data = run["report_data"]
report = json.loads(report_data) if isinstance(report_data, str) else report_data
market = await get_market_analysis(analysis_run_id)
+ raw = await get_analysis_raw_data(analysis_run_id)
def _json(v) -> str | 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")),
"instagram_en": _json(clinic.get("instagramEn")),
"facebook_en": _json(clinic.get("facebookEn")),
+ "naver_blog": _json(_naver_blog_summary(raw.get("naver_blog"))),
"channel_logos": _json(clinic.get("channelLogos")),
"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)
+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:
run = await fetchone(
"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)
+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:
logger.info("[plan] start run=%s", 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(
"UPDATE analysis_runs SET plan_data = %s WHERE analysis_run_id = %s",
(json.dumps(result.model_dump(), ensure_ascii=False), analysis_run_id),