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
parent
8c1e513dc0
commit
9da285e905
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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],
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue