리포트/플랜에 브랜드·영문채널 반영

- overrides에 brandAssets·영문 인스타/페북 audit 보장 (채널별 빌더 분리)
- logoRules·other_channels·channel_scores 프롬프트 수정, 스키마 입력 필드 추가

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mina Choi 2026-05-27 13:27:39 +09:00
parent 4855d44381
commit 238c16334b
5 changed files with 211 additions and 62 deletions

View File

@ -15,6 +15,11 @@ class PlanInput(BaseModel):
market_keywords: str | None = None
market_trend: str | None = None
market_target_audience: str | None = None
tiktok: str | None = None
instagram_en: str | None = None
facebook_en: str | None = None
channel_logos: str | None = None
brand_assets: str | None = None
# --- BrandGuide ---

View File

@ -321,6 +321,12 @@ class ReportInput(BaseModel):
market_keywords: str | None = None
market_trend: str | None = None
market_target_audience: str | None = None
branding: str | None = None
brand_assets: str | None = None
tiktok: str | None = None
instagram_en: str | None = None
facebook_en: str | None = None
channel_logos: str | None = None
# --- MarketingReport ---

View File

@ -32,19 +32,47 @@
## 분석 리포트
{report}
## 추가 채널 데이터 (틱톡 / 인스타그램 EN / 페이스북 EN)
아래에 데이터가 있는 채널은 channelStrategies와 channelBranding에 **반드시 포함**하세요 (틱톡, 영문 인스타그램, 영문 페이스북). null이면 제외.
### 틱톡 (TikTok)
{tiktok}
### 인스타그램 (영문 계정)
{instagram_en}
### 페이스북 (영문 페이지)
{facebook_en}
## 채널별 로고 분석 (Gemini Vision) — 채널룰/일관성의 근거
{channel_logos}
- 위 channel_logos[]의 각 항목: channel(채널명), logo_description(프로필이 어떻게 생겼는지), is_official(공식 로고와 일치 여부).
- **channelBranding[]를 이 데이터로 채우세요**: 채널별로 profilePhoto=해당 채널의 logo_description, currentStatus=is_official이 true면 "correct" / false면 "incorrect" (데이터 없는 채널은 "missing"). bannerSpec은 권장 배너 규격(크기/디자인)을 작성.
- **brandInconsistencies[]에 "로고" 항목을 반드시 만드세요**: values[]에 채널마다 channel(채널명) / value(logo_description 그대로) / is_correct(is_official 값) 세 필드를 넣고, impact는 inconsistency_summary, recommendation은 channel_logos.recommendation 기반으로 작성 (공식 로고로 통일 권고 포함).
## 브랜드 자산 (홈페이지 CSS에서 추출 — 결정적 데이터)
{brand_assets}
- brand_assets.color_palette[]의 hex와 brand_assets.brand_colors(primary/accent/text)는 **홈페이지 CSS에서 실제로 추출한 값**입니다.
- **brandGuide.colors의 hex는 반드시 이 추출값을 그대로 사용하세요. hex를 새로 지어내거나 변형하지 마세요** (매 실행마다 동일해야 함). name/usage 설명은 의미있게 써도 되지만 hex 값 자체는 추출값으로 고정.
## 섹션별 작성 지침
### Section 1: brandGuide
- colors: 병원 아이덴티티에 맞는 컬러 팔레트 3~5개 (hex + 사용 가이드)
- colors: **brand_assets.color_palette / brand_colors의 hex를 그대로 사용** (홈페이지 CSS 추출값, 지어내기 금지). 3~5개, 각 hex에 name/usage 부여
- fonts: 제목/본문/캡션용 폰트 시스템 (한글/영문 포함)
- logoRules: DO/DON'T 형식의 로고 사용 규칙 4~6개
- logoRules: 로고 사용 규칙 4~6개. 각 항목은 rule / description / correct 3개 필드로 구성:
- rule: 규칙을 요약한 **구체적인 제목**. "DO"·"DON'T" 같은 단어를 그대로 넣지 말 것. 실제 규칙 내용을 쓸 것 (예: "보라색+골드 깃털 로고 통일 사용", "모델 사진 프로필 금지", "비공식 변형 로고 사용 금지", "로고 주변 여백 확보").
- description: 해당 규칙의 상세 설명.
- correct: 권장 규칙(DO)이면 true, 금지/지양 규칙(DON'T)이면 false. 권장(true)과 금지(false)를 섞어서 작성.
- toneOfVoice: 브랜드 성격 키워드, 커뮤니케이션 스타일, 권장/지양 표현 예시
- channelBranding: 리포트에 존재하는 채널별 브랜딩 적용 규칙
- brandInconsistencies: 채널 간 브랜딩 불일치 항목 및 개선 권고
### Section 2: channelStrategies
- 리포트에 데이터가 있는 채널만 포함
- 각 채널의 우선순위(P0/P1/P2), 목표, 콘텐츠 유형, 게시 빈도, 포맷 가이드라인 작성
- **currentStatus는 현재 채널 상태를 실제 수치로 서술** (예: "14,047 팔로워, Reels 0개", "104K 구독자, 주 2~3회 업로드"). `excellent`/`warning`/`good` 같은 등급·평가어를 절대 쓰지 마세요.
- targetGoal은 구체적 목표 수치로 작성 (예: "50K 팔로워, Reels 주 5개")
- 각 채널의 우선순위(P0/P1/P2), 콘텐츠 유형, 게시 빈도, 포맷 가이드라인 작성
- customerJourneyStage는 해당 채널의 주요 기여 단계로 설정
### Section 3: contentStrategy

View File

@ -12,6 +12,17 @@
- 시술: {services}
- 의료진: {doctors}
## 브랜드 자산 (홈페이지에서 자동 추출)
### Firecrawl branding (로고 URL / 색상 / 폰트)
{branding}
### 추출된 브랜드 자산 (로고 묘사 + CSS 색상 팔레트)
{brand_assets}
⚠️ clinic_snapshot.logo_images / brand_colors는 위 추출값(brand_assets.logo_images, brand_assets.brand_colors)을 **그대로** 사용하세요. hex나 로고 URL을 절대 추측하지 마세요. 추출값이 null이면 해당 필드도 null로 두세요.
로고에 대한 정성 평가(심볼/워드마크/톤)는 brand_assets.logo_description을 근거로 하고, 채널 프로필 이미지가 공식 로고와 일치하는지 판단할 때도 이 묘사를 기준으로 삼으세요.
## 시장 분석 데이터
### 경쟁 병원
@ -43,9 +54,38 @@
### 강남언니
{gangnam_unni}
### 틱톡 (TikTok)
{tiktok}
### 인스타그램 (영문 계정)
{instagram_en}
### 페이스북 (영문 페이지)
{facebook_en}
### 채널별 로고 분석 (Gemini Vision)
{channel_logos}
- channel_logos.channel_logos[]에 각 채널의 로고 설명(logo_description)과 공식 로고 일치 여부(is_official)가 있습니다.
- **facebook_audit.pages[].logo** 는 짧은 판정 타이틀로: is_official=true면 `"일치 (공식 로고)"`, false면 `"불일치 (비공식 변형)"`. 그리고 **facebook_audit.pages[].logo_description** 에 해당 채널의 logo_description(설명문)을 넣으세요.
- **instagram_audit.accounts[].profile_photo** 는 해당 채널 로고를 짧게 서술 (예: `"모델 사진 (브랜드 로고 아님)"`, `"VIEW 골드 로고"`). 긴 문장 말고 짧게.
- 위 값들은 channel_logos 데이터 기반으로만 작성하고 추측하지 마세요.
- 채널 간 로고 불일치(is_official=false)는 brand 일관성 진단(problem_diagnosis/weaknesses)에 반영하세요.
## 기타 채널 현황 (other_channels) 작성 지침
- other_channels에는 메인 audit(YouTube/Instagram/Facebook/Website)에 **포함되지 않은** 채널만 넣으세요.
- 위 '채널 데이터'에 **실제 수집된 데이터가 있는 채널만** status=active와 실제 url로 일관되게 포함: 네이버 블로그, 강남언니, 틱톡, 영문 인스타그램({instagram_en}), 영문 페이스북({facebook_en}).
- **영문 인스타그램·영문 페이스북은 KR 메인 audit(Instagram/Facebook)과 별개 계정이므로, 데이터가 있으면 반드시 other_channels에 "Instagram EN" / "Facebook EN"으로 각각 포함하세요 (절대 누락 금지).**
- **수집 데이터에 없는 채널(카카오톡/네이버플레이스/네이버카페/Threads 등)은 절대 임의로 만들지 마세요.** 데이터 없으면 그 채널은 생략 (랜덤 생성·추측 금지).
- url은 수집 데이터의 실제 URL만 사용. 없으면 빈 문자열.
## 분석 지침
- 점수는 0~100 기준입니다.
- **channel_scores(채널 종합도)에는 데이터가 있는 모든 채널을 각각 별도 항목으로 만드세요. 같은 플랫폼이라도 한국 계정과 영문 계정을 절대 하나로 합치지 마세요:**
- 인스타그램 KR → channel "Instagram", 영문 인스타그램({instagram_en}) 데이터가 있으면 → channel "Instagram EN" (별도 항목)
- 페이스북 KR → channel "Facebook", 영문 페이스북({facebook_en}) 데이터가 있으면 → channel "Facebook EN" (별도 항목)
- 틱톡({tiktok}) 데이터가 있으면 → channel "TikTok" (별도 항목)
- 데이터가 null인 계정은 항목을 만들지 마세요. icon은 instagram/facebook/video 등 플랫폼에 맞게 설정.
- strengths와 weaknesses는 각 3개 이상 작성하세요.
- roadmap은 우선순위 순으로 실행 가능한 액션으로 작성하세요.
- kpis는 실제 수집된 수치 기반으로 현실적인 측정 가능 지표로 작성하세요.

View File

@ -39,6 +39,12 @@ async def generate_report(analysis_run_id: str) -> ReportOutput:
"market_keywords": _json(market.get("keywords")),
"market_trend": _json(market.get("trend")),
"market_target_audience": _json(market.get("target_audience")),
"branding": _json(clinic.get("branding")),
"brand_assets": _json(clinic.get("brandAssets")),
"tiktok": _json(clinic.get("tiktok")),
"instagram_en": _json(clinic.get("instagramEn")),
"facebook_en": _json(clinic.get("facebookEn")),
"channel_logos": _json(clinic.get("channelLogos")),
**{
channel: _json(data)
for channel, data in raw.items()
@ -78,11 +84,110 @@ async def generate_plan(analysis_run_id: str) -> PlanOutput:
"market_keywords": _json(market.get("keywords")),
"market_trend": _json(market.get("trend")),
"market_target_audience": _json(market.get("target_audience")),
"tiktok": _json(clinic.get("tiktok")),
"instagram_en": _json(clinic.get("instagramEn")),
"facebook_en": _json(clinic.get("facebookEn")),
"channel_logos": _json(clinic.get("channelLogos")),
"brand_assets": _json(clinic.get("brandAssets")),
}
return await LLMService(provider="perplexity").generate(plan_prompt, input_data)
def _en_instagram_account(d: dict) -> dict:
"""영문 인스타 raw_data → InstagramAccount dict (factual 값 + 빈 정성필드). audit 보강용."""
return {
"handle": d["username"], "language": "EN", "label": "인스타그램 EN",
"posts": d.get("posts") or 0, "followers": d.get("followers") or 0,
"following": d.get("following") or 0, "category": "",
"profile_link": f"https://www.instagram.com/{d['username']}/",
"highlights": [], "reels_count": 0, "content_format": "", "profile_photo": "",
"bio": d.get("bio") or "",
}
def _en_facebook_page(d: dict) -> dict:
"""영문 페북 raw_data → FacebookPage dict (factual 값 + 빈 정성필드). audit 보강용."""
url = d.get("pageUrl") or ""
return {
"url": url, "page_name": d.get("pageName") or "", "language": "EN", "label": "페이스북 EN",
"followers": d.get("followers") or 0, "following": 0,
"category": ", ".join(d.get("categories") or []), "bio": d.get("intro") or "",
"logo": "", "logo_description": "", "link": url, "linked_domain": d.get("website") or "",
"reviews": 0, "recent_post_age": "", "has_whatsapp": False,
}
def _clinic_snapshot(brand_assets: dict, g: dict) -> dict:
"""brandAssets(색·로고) + 강남언니(평점/리뷰/대표의) → clinic_snapshot 정확값."""
snap: dict = {}
if brand_assets.get("brand_colors"): snap["brand_colors"] = brand_assets["brand_colors"]
if brand_assets.get("logo_images"): snap["logo_images"] = brand_assets["logo_images"]
if g.get("name"): snap["name"] = g["name"]
if g.get("rating"): snap["overall_rating"] = g["rating"]
if g.get("totalReviews"): snap["total_reviews"] = g["totalReviews"]
if g.get("address"): snap["location"] = g["address"]
if g.get("badges"): snap["certifications"] = g["badges"]
if g.get("totalMajorStaffs"): snap["staff_count"] = g["totalMajorStaffs"]
doctors = g.get("doctors", [])
if doctors:
lead = max(doctors, key=lambda d: d.get("reviews", 0))
snap["lead_doctor"] = {
"name": lead.get("name"), "credentials": lead.get("specialty"),
"rating": lead.get("rating"), "review_count": lead.get("reviews"),
}
return snap
def _instagram_patch(ig: dict) -> dict:
"""instagram_data(KR) → instagram_audit.accounts factual 덮어쓰기 값."""
p: dict = {}
if ig.get("username"):
p["handle"] = ig["username"]
p["profile_link"] = f"https://www.instagram.com/{ig['username']}/"
if ig.get("posts"): p["posts"] = ig["posts"]
if ig.get("followers"): p["followers"] = ig["followers"]
if ig.get("following"): p["following"] = ig["following"]
if ig.get("bio"): p["bio"] = ig["bio"]
return p
def _facebook_patch(fb: dict) -> dict:
"""facebook_data(KR) → facebook_audit.pages factual 덮어쓰기 값."""
p: dict = {}
if fb.get("pageUrl"):
p["url"] = fb["pageUrl"]
p["link"] = fb["pageUrl"]
if fb.get("pageName"): p["page_name"] = fb["pageName"]
if fb.get("followers"): p["followers"] = fb["followers"]
if fb.get("intro"): p["bio"] = fb["intro"]
if fb.get("categories"): p["category"] = ", ".join(fb["categories"])
if fb.get("website"): p["linked_domain"] = fb["website"]
return p
def _youtube_patch(yt: dict) -> dict:
"""youtube_data → youtube_audit factual 덮어쓰기 값."""
p: dict = {}
if yt.get("channelName"): p["channel_name"] = yt["channelName"]
if yt.get("handle"): p["handle"] = yt["handle"]
if yt.get("subscribers"): p["subscribers"] = yt["subscribers"]
if yt.get("totalVideos"): p["total_videos"] = yt["totalVideos"]
if yt.get("totalViews"): p["total_views"] = yt["totalViews"]
if yt.get("publishedAt"): p["channel_created_date"] = yt["publishedAt"][:10]
if yt.get("description"): p["channel_description"] = yt["description"]
if yt.get("videos"):
p["top_videos"] = [
{
"title": v["title"], "views": v["views"], "duration": v.get("duration"),
"type": "Short" if "M" not in v.get("duration", "") else "Long",
"uploaded_ago": v.get("date", "")[:10],
}
for v in yt["videos"]
]
return p
async def _build_overrides(analysis_run_id: str) -> dict:
run = await fetchone(
"SELECT hospital_id, instagram_data_id, facebook_data_id,"
@ -100,68 +205,15 @@ async def _build_overrides(analysis_run_id: str) -> dict:
hospital = json.loads(hospital_row["raw_data"]) if hospital_row and isinstance(hospital_row.get("raw_data"), str) else (hospital_row or {}).get("raw_data") or {}
instagram = await fetch_raw("instagram_data", run["instagram_data_id"]) or {}
facebook = await fetch_raw("facebook_data", run["facebook_data_id"]) or {}
naver_blog = await fetch_raw("naver_blog_data", run["naver_blog_data_id"]) or {}
youtube = await fetch_raw("youtube_data", run["youtube_data_id"]) or {}
gangnam_unni = await fetch_raw("gangnam_unni_data", run["gangnam_unni_data_id"]) or {}
snapshot: dict = {}
# ── gangnam_unni ──────────────────────────────────────────────────────────
doctors = gangnam_unni.get("doctors", [])
lead = max(doctors, key=lambda d: d.get("reviews", 0)) if doctors else None
if gangnam_unni.get("name"): snapshot["name"] = gangnam_unni["name"]
if gangnam_unni.get("rating"): snapshot["overall_rating"] = gangnam_unni["rating"]
if gangnam_unni.get("totalReviews"): snapshot["total_reviews"] = gangnam_unni["totalReviews"]
if gangnam_unni.get("address"): snapshot["location"] = gangnam_unni["address"]
if gangnam_unni.get("badges"): snapshot["certifications"] = gangnam_unni["badges"]
if gangnam_unni.get("totalMajorStaffs"): snapshot["staff_count"] = gangnam_unni["totalMajorStaffs"]
if lead:
snapshot["lead_doctor"] = {
"name": lead.get("name"),
"credentials": lead.get("specialty"),
"rating": lead.get("rating"),
"review_count": lead.get("reviews"),
}
# ── instagram ─────────────────────────────────────────────────────────────
ig_patch: dict = {}
if instagram.get("username"): ig_patch["handle"] = instagram["username"]
if instagram.get("posts"): ig_patch["posts"] = instagram["posts"]
if instagram.get("followers"): ig_patch["followers"] = instagram["followers"]
if instagram.get("following"): ig_patch["following"] = instagram["following"]
if instagram.get("bio"): ig_patch["bio"] = instagram["bio"]
if instagram.get("username"): ig_patch["profile_link"] = f"https://www.instagram.com/{instagram['username']}/"
# ── facebook ──────────────────────────────────────────────────────────────
fb_patch: dict = {}
if facebook.get("pageUrl"): fb_patch["url"] = facebook["pageUrl"]
if facebook.get("pageUrl"): fb_patch["link"] = facebook["pageUrl"]
if facebook.get("pageName"): fb_patch["page_name"] = facebook["pageName"]
if facebook.get("followers"): fb_patch["followers"] = facebook["followers"]
if facebook.get("intro"): fb_patch["bio"] = facebook["intro"]
if facebook.get("categories"): fb_patch["category"] = ", ".join(facebook["categories"])
if facebook.get("website"): fb_patch["linked_domain"] = facebook["website"]
# ── youtube ───────────────────────────────────────────────────────────────
yt_patch: dict = {}
if youtube.get("channelName"): yt_patch["channel_name"] = youtube["channelName"]
if youtube.get("handle"): yt_patch["handle"] = youtube["handle"]
if youtube.get("subscribers"): yt_patch["subscribers"] = youtube["subscribers"]
if youtube.get("totalVideos"): yt_patch["total_videos"] = youtube["totalVideos"]
if youtube.get("totalViews"): yt_patch["total_views"] = youtube["totalViews"]
if youtube.get("publishedAt"): yt_patch["channel_created_date"] = youtube["publishedAt"][:10]
if youtube.get("description"): yt_patch["channel_description"] = youtube["description"]
if youtube.get("videos"):
yt_patch["top_videos"] = [
{
"title": v["title"],
"views": v["views"],
"duration": v.get("duration"),
"type": "Short" if "M" not in v.get("duration", "") else "Long",
"uploaded_ago": v.get("date", "")[:10],
}
for v in youtube["videos"]
]
snapshot = _clinic_snapshot(hospital.get("brandAssets") or {}, gangnam_unni)
ig_patch = _instagram_patch(instagram)
fb_patch = _facebook_patch(facebook)
yt_patch = _youtube_patch(youtube)
ig_en = hospital.get("instagramEn") or {}
fb_en = hospital.get("facebookEn") or {}
overrides: dict = {}
if snapshot:
@ -172,6 +224,10 @@ async def _build_overrides(analysis_run_id: str) -> dict:
overrides["facebook_audit"] = {"pages": [fb_patch]}
if yt_patch:
overrides["youtube_audit"] = yt_patch
if ig_en.get("username"):
overrides["_en_ig_account"] = _en_instagram_account(ig_en)
if fb_en.get("pageUrl") or fb_en.get("pageName"):
overrides["_en_fb_page"] = _en_facebook_page(fb_en)
return overrides
@ -187,8 +243,22 @@ def _deep_merge(base: dict, overrides: dict) -> dict:
base[k] = v
return base
def _ensure_en_entry(audit: dict, list_key: str, en_entry: dict | None) -> None:
"""audit 리스트(accounts/pages)에 EN 항목이 없으면 추가 — LLM 누락 대비, 중복 방지."""
if not en_entry:
return
items = audit.setdefault(list_key, [])
if not any(it.get("language") == "EN" for it in items):
items.append(en_entry)
def _patch_report(result: ReportOutput, overrides: dict) -> ReportOutput:
en_ig = overrides.pop("_en_ig_account", None)
en_fb = overrides.pop("_en_fb_page", None)
merged = _deep_merge(result.model_dump(), overrides)
# LLM이 audit에 영문 계정을 빠뜨려도 항상 KR+EN 둘 다 보장.
_ensure_en_entry(merged.setdefault("instagram_audit", {}), "accounts", en_ig)
_ensure_en_entry(merged.setdefault("facebook_audit", {}), "pages", en_fb)
return ReportOutput(**merged)