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),