Compare commits

..

7 Commits

22 changed files with 589 additions and 245 deletions

View File

@ -66,19 +66,23 @@ async def select_run_raw_data(analysis_run_id: str) -> dict:
rows = await fetchall( rows = await fetchall(
"SELECT rs.source_type, rs.language, ri.raw_data, ri.logo_url" "SELECT rs.source_type, rs.language, ri.raw_data, ri.logo_url"
" FROM raw_info ri JOIN remote_source rs USING (source_id)" " FROM raw_info ri JOIN remote_source rs USING (source_id)"
" WHERE ri.analysis_run_id = %s", " WHERE ri.analysis_run_id = %s AND ri.status = 'done'",
(analysis_run_id,), (analysis_run_id,),
) )
result: dict = {} result: dict = {}
for row in rows: for row in rows:
raw = row["raw_data"] source_type = row["source_type"]
key = row["source_type"] if source_type not in result:
if (row.get("language") or "").upper() == "EN": result[source_type] = list()
key = f"{key}_en"
data = json.loads(raw) if isinstance(raw, str) else (raw or {}) item : dict = {}
if isinstance(data, dict) and row.get("logo_url"): item["raw_data"] = json.loads(row["raw_data"])
data["_logo_url"] = row["logo_url"] item["logo_url"] = row["logo_url"]
result[key] = data item["source_type"] = row["source_type"]
item["language"] = row["language"]
result[source_type].append(item)
return result return result

View File

@ -1,4 +1,5 @@
import os import os
import re
import asyncio import asyncio
import logging import logging
from datetime import datetime, timezone from datetime import datetime, timezone
@ -10,6 +11,70 @@ logger = logging.getLogger(__name__)
REQUEST_TIMEOUT = 60 REQUEST_TIMEOUT = 60
def parse_iso_duration_seconds(iso: str) -> int:
m = re.match(r"PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?", iso or "")
if not m:
return 0
h, mins, s = (int(x or 0) for x in m.groups())
return h * 3600 + mins * 60 + s
def format_seconds(seconds: int) -> str:
m, s = divmod(seconds, 60)
h, m = divmod(m, 60)
return f"{h}시간 {m}" if h else f"{m}{s}"
def format_clock(seconds: int) -> str:
m, s = divmod(seconds, 60)
h, m = divmod(m, 60)
return f"{h}:{m:02d}:{s:02d}" if h else f"{m}:{s:02d}"
def calc_avg_video_length(videos: list[dict]) -> str:
durations = [parse_iso_duration_seconds(v.get("duration", "")) for v in videos]
durations = [d for d in durations if d > 0]
if not durations:
return ""
return format_seconds(sum(durations) // len(durations))
def relative_date(date_str: str) -> str:
if not date_str:
return ""
try:
past = datetime.fromisoformat(date_str[:10])
except ValueError:
return ""
days = (datetime.now() - past).days
if days < 1:
return "오늘"
if days < 30:
return f"{days}일 전"
if days < 365:
return f"{days // 30}개월 전"
return f"{days // 365}년 전"
def calc_upload_frequency(videos: list[dict]) -> str:
dates = sorted(
[v["date"][:10] for v in videos if v.get("date")],
reverse=True,
)
if len(dates) < 2:
return ""
gaps = [
(datetime.fromisoformat(dates[i]) - datetime.fromisoformat(dates[i + 1])).days
for i in range(len(dates) - 1)
]
avg_days = sum(gaps) // len(gaps)
if avg_days <= 7:
return f"{7 // max(avg_days, 1)}"
if avg_days <= 30:
return f"{30 // avg_days}"
return f"{avg_days}일에 1회"
def parse_ts(v) -> datetime | None: def parse_ts(v) -> datetime | None:
"""수집기마다 다른 timestamp 포맷을 통일된 datetime으로 변환. """수집기마다 다른 timestamp 포맷을 통일된 datetime으로 변환.
파싱 실패 None. 파싱 실패 None.

View File

@ -1,7 +1,18 @@
import os import os
from pydantic import BaseModel from pydantic import BaseModel
from common.utils import get_env from common.utils import get_env
from integrations.llm.schemas.report import ReportInput, ReportOutput, YouTubeDiagnosisInput, YouTubeDiagnosisOutput, OtherChannelsInput, OtherChannelsOutput from integrations.llm.schemas.report import (
ReportInput, ReportOutput,
CriticalIssuesInput, CriticalIssuesOutput,
YouTubeDiagnosisInput, YouTubeDiagnosisOutput,
InstagramDiagnosisInput, InstagramDiagnosisOutput,
FacebookDiagnosisInput, FacebookDiagnosisOutput,
BrandConsistencyInput, BrandConsistencyOutput,
TransformationInput, TransformationProposal,
RoadmapInput, RoadmapOutput,
ScoresInput, ScoresOutput,
OtherChannelsInput, OtherChannelsOutput
)
from integrations.llm.schemas.plan import PlanInput, PlanOutput from integrations.llm.schemas.plan import PlanInput, PlanOutput
from integrations.llm.schemas.market import ( from integrations.llm.schemas.market import (
MarketCompetitorsInput, MarketCompetitorsOutput, MarketCompetitorsInput, MarketCompetitorsOutput,
@ -81,6 +92,20 @@ market_target_audience_prompt = Prompt(
output_class=MarketTargetAudienceOutput, output_class=MarketTargetAudienceOutput,
) )
facebook_diagnosis_prompt = Prompt(
file_name="facebook_diagnosis_prompt.txt",
prompt_model="REPORT_MODEL",
input_class=FacebookDiagnosisInput,
output_class=FacebookDiagnosisOutput,
)
instagram_diagnosis_prompt = Prompt(
file_name="instagram_diagnosis_prompt.txt",
prompt_model="REPORT_MODEL",
input_class=InstagramDiagnosisInput,
output_class=InstagramDiagnosisOutput,
)
youtube_diagnosis_prompt = Prompt( youtube_diagnosis_prompt = Prompt(
file_name="youtube_diagnosis_prompt.txt", file_name="youtube_diagnosis_prompt.txt",
prompt_model="REPORT_MODEL", prompt_model="REPORT_MODEL",
@ -88,9 +113,44 @@ youtube_diagnosis_prompt = Prompt(
output_class=YouTubeDiagnosisOutput, output_class=YouTubeDiagnosisOutput,
) )
brand_consistency_prompt = Prompt(
file_name="brand_consistency_prompt.txt",
prompt_model="REPORT_MODEL",
input_class=BrandConsistencyInput,
output_class=BrandConsistencyOutput,
)
critical_issues_prompt = Prompt(
file_name="critical_issues_prompt.txt",
prompt_model="REPORT_MODEL",
input_class=CriticalIssuesInput,
output_class=CriticalIssuesOutput,
)
transformation_prompt = Prompt(
file_name="transformation_prompt.txt",
prompt_model="REPORT_MODEL",
input_class=TransformationInput,
output_class=TransformationProposal,
)
scores_prompt = Prompt(
file_name="scores_prompt.txt",
prompt_model="REPORT_MODEL",
input_class=ScoresInput,
output_class=ScoresOutput,
)
roadmap_prompt = Prompt(
file_name="roadmap_prompt.txt",
prompt_model="REPORT_MODEL",
input_class=RoadmapInput,
output_class=RoadmapOutput,
)
other_channels_prompt = Prompt( other_channels_prompt = Prompt(
file_name="other_channels_prompt.txt", file_name="other_channels_prompt.txt",
prompt_model="REPORT_MODEL", prompt_model="REPORT_MODEL",
input_class=OtherChannelsInput, input_class=OtherChannelsInput,
output_class=OtherChannelsOutput, output_class=OtherChannelsOutput,
) )

View File

@ -16,8 +16,8 @@ class PlanInput(BaseModel):
market_trend: str | None = None market_trend: str | None = None
market_target_audience: str | None = None market_target_audience: str | None = None
tiktok: str | None = None tiktok: str | None = None
instagram_en: str | None = None instagram: str | None = None
facebook_en: str | None = None facebook: str | None = None
naver_blog: str | None = None naver_blog: str | None = None
naver_cafe: str | None = None naver_cafe: str | None = None
kakao_talk: str | None = None kakao_talk: str | None = None

View File

@ -68,17 +68,15 @@ class RegistryData(BaseModel):
class ClinicSnapshot(BaseModel): class ClinicSnapshot(BaseModel):
# _build_clinic_snapshot은 source 데이터 있을 때만 채움 (`if x:` 가드). name: str
# required면 강남언니/홈페이지 누락 병원에서 ValidationError로 리포트 실패. name_en: str
name: str | None = None
name_en: str | None = None
staff_count: int | None = None staff_count: int | None = None
lead_doctor: LeadDoctor | None = None lead_doctor: LeadDoctor | None = None
overall_rating: float | None = None overall_rating: float | None = None
total_reviews: int | None = None total_reviews: int | None = None
certifications: list[str] = [] certifications: list[str] = []
location: str | None = None location: str
phone: str | None = None phone: str
domain: str | None = None domain: str | None = None
logo_images: LogoImages | None = None logo_images: LogoImages | None = None
brand_colors: BrandColors | None = None brand_colors: BrandColors | None = None
@ -159,8 +157,8 @@ class InstagramAccount(BaseModel):
class InstagramAudit(BaseModel): class InstagramAudit(BaseModel):
accounts: list[InstagramAccount] = [] accounts: list[InstagramAccount]
diagnosis: list[DiagnosisItem] = [] diagnosis: list[DiagnosisItem]
# --- Facebook --- # --- Facebook ---
@ -378,3 +376,75 @@ class YouTubeDiagnosisInput(BaseModel):
class YouTubeDiagnosisOutput(BaseModel): class YouTubeDiagnosisOutput(BaseModel):
diagnosis: list[DiagnosisItem] diagnosis: list[DiagnosisItem]
class InstagramDiagnosisInput(BaseModel):
accounts: str | None = None
class InstagramDiagnosisOutput(BaseModel):
diagnosis: list[DiagnosisItem]
class FacebookDiagnosisInput(BaseModel):
pages: str | None = None
class FacebookDiagnosisOutput(BaseModel):
diagnosis: list[DiagnosisItem]
# --- Scores ---
class ScoresInput(BaseModel):
clinic_name: str | None = None
data: str | None = None
class ScoresOutput(BaseModel):
overall_score: int
channel_scores: list[ChannelScore]
# --- Diagnosis ---
class CriticalIssuesInput(BaseModel):
clinic_name: str | None = None
data: str | None = None
class CriticalIssuesOutput(BaseModel):
diagnosis: list[DiagnosisItem]
# --- Roadmap ---
class RoadmapInput(BaseModel):
clinic_name: str | None = None
data: str | None = None
class RoadmapOutput(BaseModel):
roadmap: list[RoadmapMonth]
# --- Transformation ---
class TransformationInput(BaseModel):
clinic_name: str | None = None
data: str | None = None
# --- BrandConsistency ---
class BrandConsistencyInput(BaseModel):
clinic_name: str | None = None
mainpage: str | None = None
instagram: str | None = None
facebook: str | None = None
youtube: str | None = None
gangnam_unni: str | None = None
class BrandConsistencyOutput(BaseModel):
brand_inconsistencies: list[BrandInconsistency]

View File

@ -0,0 +1,18 @@
다음은 성형외과/피부과 {clinic_name} 의 채널별 브랜드 데이터입니다.
공식 홈페이지: {mainpage}
인스타그램: {instagram}
페이스북: {facebook}
유튜브: {youtube}
강남언니: {gangnam_unni}
위 채널들 간의 브랜드 불일치 항목을 분석해줘.
비교 대상 필드 예시: 병원명(한글/영문), 전화번호, 주소, 로고, 슬로건, 소개 문구 등.
각 항목은 다음 JSON 형식의 배열로 출력해줘:
- field: 불일치 필드명
- values: 채널별 실제 값 목록 (channel, value, is_correct)
- impact: 불일치가 브랜드에 미치는 영향
- recommendation: 개선 권고사항
출처 번호([1], [2] 등)는 포함하지 마.

View File

@ -0,0 +1,13 @@
다음은 성형외과/피부과 {clinic_name} 의 전 채널 수집 데이터입니다.
{data}
위 데이터를 바탕으로 이 병원의 마케팅 전반에 걸친 핵심 문제점과 개선사항을 진단해줘.
각 항목은 category(진단 카테고리), detail(상세 설명), severity(critical/warning/info) 형식의 JSON 배열로 출력해줘.
현재 주요 진단 카테고리는 3개야.
브랜드 아이덴티티 파편화
콘텐츠 전략 부재
플랫폼 간 유입 단절
출처 번호([1], [2] 등)는 포함하지 마.

View File

@ -0,0 +1,8 @@
다음은 성형외과/피부과의 페이스북 페이지 데이터입니다.
{pages}
위 데이터를 바탕으로 이 병원의 페이스북 마케팅 현황을 진단해줘.
각 항목은 category(진단 카테고리), detail(상세 설명), severity(critical/warning/info) 형식의 JSON 배열로 출력해줘.
출처 번호([1], [2] 등)는 포함하지 마.

View File

@ -0,0 +1,8 @@
다음은 성형외과/피부과의 인스타그램 계정 데이터입니다.
{accounts}
위 데이터를 바탕으로 이 병원의 인스타그램 마케팅 현황을 진단해줘.
각 항목은 category(진단 카테고리), detail(상세 설명), severity(critical/warning/info) 형식의 JSON 배열로 출력해줘.
출처 번호([1], [2] 등)는 포함하지 마.

View File

@ -33,7 +33,7 @@
{report} {report}
## 추가 채널 데이터 (네이버 블로그 / 틱톡 / 인스타그램 EN / 페이스북 EN / 네이버 카페 / 카카오톡) ## 추가 채널 데이터 (네이버 블로그 / 틱톡 / 인스타그램 EN / 페이스북 EN / 네이버 카페 / 카카오톡)
아래에 데이터가 있는 채널은 channelStrategies에 **반드시 포함**하세요 (네이버 블로그, 틱톡, 영문 인스타그램, 영문 페이스북, 네이버 카페, 카카오톡). channelBranding은 SNS·블로그·카페까지만 포함(카카오톡은 메신저라 제외). null이면 제외. 아래에 데이터가 있는 채널은 channelStrategies에 **반드시 포함**하세요 (네이버 블로그, 틱톡, 인스타그램, 페이스북, 네이버 카페, 카카오톡). channelBranding은 SNS·블로그·카페까지만 포함(카카오톡은 메신저라 제외). null이면 제외.
### 네이버 블로그 (Naver Blog) ### 네이버 블로그 (Naver Blog)
{naver_blog} {naver_blog}
@ -41,11 +41,11 @@
### 틱톡 (TikTok) ### 틱톡 (TikTok)
{tiktok} {tiktok}
### 인스타그램 (영문 계정) ### 인스타그램
{instagram_en} {instagram}
### 페이스북 (영문 페이지) ### 페이스북
{facebook_en} {facebook}
### 네이버 카페 (공식 카페 운영 신호) ### 네이버 카페 (공식 카페 운영 신호)
{naver_cafe} {naver_cafe}

View File

@ -0,0 +1,14 @@
다음은 성형외과/피부과 {clinic_name} 의 전 채널 수집 데이터입니다.
{data}
위 데이터를 바탕으로 이 병원의 3개월 마케팅 실행 로드맵을 수립해줘.
month 1, 2, 3 각각 하나씩, 총 3개 항목을 포함한 roadmap JSON 배열로 출력해줘.
각 항목은 아래 형식을 따라줘:
- month: 월 번호 (1, 2, 3)
- title: 해당 월의 핵심 테마 (예: "브랜드 정비")
- subtitle: 한 줄 부제 (예: "기반 구축 — 로고·계정 통일")
- tasks: 실행 과제 목록, 각 과제는 task(string)와 completed(false)로 구성
출처 번호([1], [2] 등)는 포함하지 마.

View File

@ -0,0 +1,16 @@
다음은 성형외과/피부과 {clinic_name} 의 전 채널 수집 데이터입니다.
{data}
위 데이터를 바탕으로 이 병원의 마케팅 종합 점수를 평가해줘.
1. overall_score: 전체 마케팅 종합 점수 (0~100 정수)
2. channel_scores: 채널별 점수 목록. 각 항목은 아래 형식:
- channel: 채널명 (예: YouTube, Instagram, Facebook, 웹사이트 등)
- icon: 채널 아이콘 식별자 (예: youtube, instagram, facebook, website)
- score: 해당 채널 점수 (20 ~ 100)(정수)
- max_score: 해당 채널 최대 점수 (정수)
- status: 심각도 (critical / warning / info)
- headline: 한 줄 평가 요약
출처 번호([1], [2] 등)는 포함하지 마.

View File

@ -0,0 +1,14 @@
다음은 성형외과/피부과 {clinic_name} 의 전 채널 수집 데이터입니다.
{data}
위 데이터를 바탕으로 이 병원의 마케팅 전환 전략을 수립해줘.
아래 5개 항목을 포함한 JSON을 출력해줘.
1. brand_identity: 브랜드 아이덴티티 개선 항목 (area, as_is, to_be)
2. content_strategy: 콘텐츠 전략 개선 항목 (area, as_is, to_be)
3. platform_strategies: 플랫폼별 전략 (platform, icon, current_metric, target_metric, strategies: 각 항목은 strategy와 detail 포함)
4. website_improvements: 웹사이트 개선 항목 (area, as_is, to_be)
5. new_channel_proposals: 신규 채널 제안 (channel, priority, rationale)
출처 번호([1], [2] 등)는 포함하지 마.

View File

@ -99,6 +99,7 @@ class YouTubeClient:
stats = ch.get("statistics", {}) stats = ch.get("statistics", {})
snippet = ch.get("snippet", {}) snippet = ch.get("snippet", {})
thumbs = snippet.get("thumbnails", {}) thumbs = snippet.get("thumbnails", {})
print(snippet)
return { return {
"channelId": raw["channelId"], "channelId": raw["channelId"],
"channelName": snippet.get("title"), "channelName": snippet.get("title"),

View File

@ -88,7 +88,7 @@ class ChannelScore(CamelModel):
channel: str channel: str
icon: str icon: str
score: int score: int
max_score: int max_score: int = 100
status: Severity status: Severity
headline: str headline: str

View File

@ -1,14 +1,14 @@
import json import json
import logging import logging
import re
from datetime import datetime
from urllib.parse import urlparse from urllib.parse import urlparse
from common.db.run import update_run_report, update_run_plan, select_run_report_data from models.status import SourceType
from common.utils import parse_iso_duration_seconds, format_seconds, format_clock, calc_avg_video_length, relative_date, calc_upload_frequency
from common.db.run import update_run_report, update_run_plan, select_run_report_data, select_run
from common.db.source import select_run_raw_data, select_mainpage_logo_url from common.db.source import select_run_raw_data, select_mainpage_logo_url
from common.db.market import select_market from common.db.market import select_market
from integrations.llm.llm_service import LLMService from integrations.llm.llm_service import LLMService
from integrations.llm.prompt import report_prompt, plan_prompt, youtube_diagnosis_prompt from integrations.llm.prompt import report_prompt, plan_prompt, youtube_diagnosis_prompt, brand_consistency_prompt, critical_issues_prompt, transformation_prompt, roadmap_prompt, scores_prompt, other_channels_prompt
from integrations.llm.schemas.report import ReportOutput, ClinicSnapshot, YouTubeAudit from integrations.llm.schemas.report import ReportOutput, ClinicSnapshot, YouTubeAudit, BrandConsistencyOutput, CriticalIssuesOutput, DiagnosisItem, TransformationProposal, RoadmapOutput, RoadmapMonth, ScoresOutput, ChannelScore, WebsiteAudit, OtherChannelsOutput, OtherChannel
from services.branding import analyze_branding from services.branding import analyze_branding
from services.instagram_audit import build_instagram_audit from services.instagram_audit import build_instagram_audit
from services.facebook_audit import build_facebook_audit from services.facebook_audit import build_facebook_audit
@ -17,59 +17,35 @@ from integrations.llm.schemas.plan import PlanOutput
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
async def generate_report(analysis_run_id: str) -> ReportOutput:
raw = await select_run_raw_data(analysis_run_id)
clinic = raw.get("mainpage") or {}
branding = raw.get("branding") or {}
market = await select_market(analysis_run_id)
def _json(v) -> str | None:
return json.dumps(v, ensure_ascii=False) if v else None
input_data = {
"clinic_name": clinic.get("clinicName"),
"clinic_name_en": clinic.get("clinicNameEn"),
"address": clinic.get("address"),
"phone": clinic.get("phone"),
"slogan": clinic.get("slogan"),
"services": json.dumps(clinic.get("services", []), ensure_ascii=False),
"doctors": json.dumps(clinic.get("doctors", []), ensure_ascii=False),
"market_competitors": _json(market.get("competitors")),
"market_keywords": _json(market.get("keywords")),
"market_trend": _json(market.get("trend")),
"market_target_audience": _json(market.get("target_audience")),
# firecrawl 이 mainpage 에서 뽑은 branding 메타(logoUrl/ogImage/faviconUrl) + Vision/CSS 산출물
"branding": _json(clinic.get("branding")),
"brand_assets": _json(branding.get("brandAssets")),
"channel_logos": _json(branding.get("channelLogos")),
# 부가 채널 (raw_info entry) — raw dict 의 한국식 key 그대로
"tiktok": _json(raw.get("tiktok")),
"instagram_en": _json(raw.get("instagram_en")),
"facebook_en": _json(raw.get("facebook_en")),
"kakao_talk": _json(raw.get("kakaotalk")),
"naver_cafe": _json(raw.get("naver_cafe")),
# 메인 5채널은 raw dict 그대로 펼쳐서 prompt placeholder 와 매칭
**{
source_type: _json(data)
for source_type, data in raw.items()
if source_type not in {
"mainpage", "branding",
"tiktok", "instagram_en", "facebook_en", "kakaotalk", "naver_cafe",
}
},
}
return await LLMService(provider="perplexity").generate(report_prompt, input_data)
async def generate_plan(analysis_run_id: str) -> PlanOutput: async def generate_plan(analysis_run_id: str) -> PlanOutput:
raw = await select_run_raw_data(analysis_run_id) raw = await select_run_raw_data(analysis_run_id)
clinic = raw.get("mainpage") or {} clinic = raw.get(SourceType.MAINPAGE) or []
branding = raw.get("branding") or {} clinic = clinic[0]["raw_data"] # if not exist, must error
branding = raw.get(SourceType.BRANDING) or []
branding = branding[0]["raw_data"] # if not exist, must error
report = await select_run_report_data(analysis_run_id) report = await select_run_report_data(analysis_run_id)
market = await select_market(analysis_run_id) market = await select_market(analysis_run_id)
mainpage = raw.get(SourceType.MAINPAGE) or []
mainpage = mainpage[0]["raw_data"] # 유일
branding = raw.get(SourceType.BRANDING) or []
branding = branding[0]["raw_data"] # 유일
instagram = raw.get(SourceType.INSTAGRAM) or []
facebook = raw.get(SourceType.FACEBOOK) or []
youtube = raw.get(SourceType.YOUTUBE) or []
youtube = youtube[0]["raw_data"] if youtube else None # 유일 (기획상)
gangnam_unni = raw.get(SourceType.GANGNAM_UNNI) or []
gangnam_unni = gangnam_unni[0]["raw_data"] if gangnam_unni else None# 유일 (기획상)
naver_blog = raw.get(SourceType.NAVER_BLOG) or []
naver_blog = naver_blog[0]["raw_data"] if naver_blog else None# 유일 (기획상)
tiktok = raw.get(SourceType.TIKTOK) or []
tiktok = tiktok[0]["raw_data"] if tiktok else None# 유일 (기획상)
naver_cafe = raw.get(SourceType.NAVER_CAFE) or []
naver_cafe = naver_cafe[0]["raw_data"] if naver_cafe else None# 유일 (기획상)
kakaotalk = raw.get(SourceType.KAKAOTALK) or []
kakaotalk = kakaotalk[0]["raw_data"] if kakaotalk else None# 유일 (기획상)
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
@ -86,12 +62,12 @@ async def generate_plan(analysis_run_id: str) -> PlanOutput:
"market_keywords": _json(market.get("keywords")), "market_keywords": _json(market.get("keywords")),
"market_trend": _json(market.get("trend")), "market_trend": _json(market.get("trend")),
"market_target_audience": _json(market.get("target_audience")), "market_target_audience": _json(market.get("target_audience")),
"tiktok": _json(raw.get("tiktok")), "tiktok": _json(tiktok),
"instagram_en": _json(raw.get("instagram_en")), "instagram": _json(instagram),
"facebook_en": _json(raw.get("facebook_en")), "facebook": _json(facebook),
"naver_blog": _json(_naver_blog_summary(raw.get("naver_blog"))), "naver_blog": _json(_naver_blog_summary(naver_blog)),
"naver_cafe": _json(raw.get("naver_cafe")), "naver_cafe": _json(naver_cafe),
"kakao_talk": _json(raw.get("kakaotalk")), "kakao_talk": _json(kakaotalk),
"channel_logos": _json(branding.get("channelLogos")), "channel_logos": _json(branding.get("channelLogos")),
"brand_assets": _json(branding.get("brandAssets")), "brand_assets": _json(branding.get("brandAssets")),
} }
@ -99,18 +75,17 @@ 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 _build_clinic_snapshot(gangnam_unni: dict, mainpage: dict, brand_assets: dict, logo_url: str | None) -> dict: def _build_clinic_snapshot(mainpage: dict, gangnam_unni: dict, brand_assets: dict, logo_url: str | None) -> dict:
snapshot: dict = {} snapshot: dict = {}
doctors = gangnam_unni.get("doctors", []) doctors = gangnam_unni.get("doctors", [])
lead = max(doctors, key=lambda d: d.get("reviews", 0)) if doctors else None lead = max(doctors, key=lambda d: d.get("reviews", 0)) if doctors else None
if gangnam_unni.get("name"): snapshot["name"] = gangnam_unni["name"] snapshot["name"] = mainpage["clinicName"]
if mainpage.get("clinicNameEn"): snapshot["name_en"] = mainpage["clinicNameEn"] snapshot["name_en"] = mainpage["clinicNameEn"]
if mainpage.get("phone"): snapshot["phone"] = mainpage["phone"] snapshot["phone"] = mainpage["phone"]
domain = mainpage.get("domain") or urlparse(mainpage.get("sourceUrl") or "").netloc snapshot["location"] = mainpage["address"]
if domain: snapshot["domain"] = domain snapshot["domain"] = mainpage.get("domain") or urlparse(mainpage.get("sourceUrl") or "").netloc
if gangnam_unni.get("rating"): snapshot["overall_rating"] = gangnam_unni["rating"] 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("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("badges"): snapshot["certifications"] = gangnam_unni["badges"]
if gangnam_unni.get("totalMajorStaffs"): snapshot["staff_count"] = gangnam_unni["totalMajorStaffs"] if gangnam_unni.get("totalMajorStaffs"): snapshot["staff_count"] = gangnam_unni["totalMajorStaffs"]
if lead: if lead:
@ -126,7 +101,6 @@ def _build_clinic_snapshot(gangnam_unni: dict, mainpage: dict, brand_assets: dic
if brand_assets.get("brand_colors"): snapshot["brand_colors"] = brand_assets["brand_colors"] if brand_assets.get("brand_colors"): snapshot["brand_colors"] = brand_assets["brand_colors"]
return ClinicSnapshot.model_validate(snapshot).model_dump() return ClinicSnapshot.model_validate(snapshot).model_dump()
def _naver_blog_summary(blog: dict | None) -> dict | None: def _naver_blog_summary(blog: dict | None) -> dict | None:
"""plan 카드 한 장에 들어가는 건 전체 포스트 수와 최근 활동 시점뿐. 그 외(본문·링크·제목)는 """plan 카드 한 장에 들어가는 건 전체 포스트 수와 최근 활동 시점뿐. 그 외(본문·링크·제목)는
던져봐야 토큰만 늘고 LLM 무관 정보로 hallucinate .""" 던져봐야 토큰만 늘고 LLM 무관 정보로 hallucinate ."""
@ -138,79 +112,14 @@ def _naver_blog_summary(blog: dict | None) -> dict | None:
"latestPostDate": posts[0].get("postDate") if posts else None, "latestPostDate": posts[0].get("postDate") if posts else None,
} }
async def _build_youtube_audit(youtube: dict) -> dict: # 기획상 1개의 input channel, 다중 채널은 기획에 없음.
def _parse_iso_duration_seconds(iso: str) -> int:
m = re.match(r"PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?", iso or "")
if not m:
return 0
h, mins, s = (int(x or 0) for x in m.groups())
return h * 3600 + mins * 60 + s
def _format_seconds(seconds: int) -> str:
m, s = divmod(seconds, 60)
h, m = divmod(m, 60)
return f"{h}시간 {m}" if h else f"{m}{s}"
def _format_clock(seconds: int) -> str:
m, s = divmod(seconds, 60)
h, m = divmod(m, 60)
return f"{h}:{m:02d}:{s:02d}" if h else f"{m}:{s:02d}"
def _calc_avg_video_length(videos: list[dict]) -> str:
durations = [_parse_iso_duration_seconds(v.get("duration", "")) for v in videos]
durations = [d for d in durations if d > 0]
if not durations:
return ""
return _format_seconds(sum(durations) // len(durations))
def _relative_date(date_str: str) -> str:
if not date_str:
return ""
try:
past = datetime.fromisoformat(date_str[:10])
except ValueError:
return ""
days = (datetime.now() - past).days
if days < 1:
return "오늘"
if days < 30:
return f"{days}일 전"
if days < 365:
return f"{days // 30}개월 전"
return f"{days // 365}년 전"
def _calc_upload_frequency(videos: list[dict]) -> str:
dates = sorted(
[v["date"][:10] for v in videos if v.get("date")],
reverse=True,
)
if len(dates) < 2:
return ""
gaps = [
(datetime.fromisoformat(dates[i]) - datetime.fromisoformat(dates[i + 1])).days
for i in range(len(dates) - 1)
]
avg_days = sum(gaps) // len(gaps)
if avg_days <= 7:
return f"{7 // max(avg_days, 1)}"
if avg_days <= 30:
return f"{30 // avg_days}"
return f"{avg_days}일에 1회"
async def _build_youtube_audit(youtube: dict) -> dict:
videos = youtube.get("videos", []) videos = youtube.get("videos", [])
yt_patch: dict = { yt_patch: dict = {
"weekly_view_growth": {"absolute": 0, "percentage": 0.0}, "weekly_view_growth": {"absolute": 0, "percentage": 0.0},
"estimated_monthly_revenue": {"min": 0, "max": 0}, "estimated_monthly_revenue": {"min": 0, "max": 0},
"linked_urls": [], "linked_urls": [],
"avg_video_length": _calc_avg_video_length(videos), "avg_video_length": calc_avg_video_length(videos),
"upload_frequency": _calc_upload_frequency(videos), "upload_frequency": calc_upload_frequency(videos),
} }
if youtube.get("channelName"): yt_patch["channel_name"] = youtube["channelName"] if youtube.get("channelName"): yt_patch["channel_name"] = youtube["channelName"]
if youtube.get("handle"): yt_patch["handle"] = youtube["handle"] if youtube.get("handle"): yt_patch["handle"] = youtube["handle"]
@ -218,16 +127,16 @@ async def _build_youtube_audit(youtube: dict) -> dict:
if youtube.get("totalVideos"): yt_patch["total_videos"] = youtube["totalVideos"] if youtube.get("totalVideos"): yt_patch["total_videos"] = youtube["totalVideos"]
if youtube.get("totalViews"): yt_patch["total_views"] = youtube["totalViews"] 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("publishedAt"): yt_patch["channel_created_date"] = youtube["publishedAt"][:10]
if youtube.get("description"): yt_patch["channel_description"] = youtube["description"] yt_patch["channel_description"] = youtube.get("description") or ""
if youtube.get("playlists"): yt_patch["playlists"] = youtube["playlists"] if youtube.get("playlists"): yt_patch["playlists"] = youtube["playlists"]
if videos: if videos:
yt_patch["top_videos"] = [ yt_patch["top_videos"] = [
{ {
"title": v["title"], "title": v["title"],
"views": v["views"], "views": v["views"],
"duration": _format_clock(_parse_iso_duration_seconds(v.get("duration", ""))), "duration": format_clock(parse_iso_duration_seconds(v.get("duration", ""))),
"type": "Short" if "M" not in v.get("duration", "") else "Long", "type": "Short" if "M" not in v.get("duration", "") else "Long",
"uploaded_ago": _relative_date(v.get("date", "")), "uploaded_ago": relative_date(v.get("date", "")),
} }
for v in videos for v in videos
] ]
@ -250,6 +159,139 @@ async def _build_youtube_audit(youtube: dict) -> dict:
return YouTubeAudit.model_validate(yt_patch).model_dump() return YouTubeAudit.model_validate(yt_patch).model_dump()
async def _build_roadmap(analysis_run_id: str, raw: dict) -> list[dict]:
result: RoadmapOutput = await LLMService(provider="perplexity").generate(
roadmap_prompt,
{
"clinic_name": (raw.get(SourceType.MAINPAGE) or [{}])[0]["raw_data"].get("clinicName"),
"data": json.dumps(raw, ensure_ascii=False),
},
)
return [RoadmapMonth.model_validate(item).model_dump() for item in result.roadmap]
async def _build_transformation(analysis_run_id: str, raw: dict) -> dict:
result: TransformationProposal = await LLMService(provider="perplexity").generate(
transformation_prompt,
{
"clinic_name": (raw.get(SourceType.MAINPAGE) or [{}])[0]["raw_data"].get("clinicName"),
"data": json.dumps(raw, ensure_ascii=False),
},
)
return result.model_dump()
async def _build_critical_issues(analysis_run_id: str, raw: dict) -> list[dict]:
result: CriticalIssuesOutput = await LLMService(provider="perplexity").generate(
critical_issues_prompt,
{
"clinic_name": (raw.get(SourceType.MAINPAGE) or [{}])[0]["raw_data"].get("clinicName"),
"data": json.dumps(raw, ensure_ascii=False),
},
)
return [DiagnosisItem.model_validate(item).model_dump() for item in result.diagnosis]
async def _build_scores(analysis_run_id: str, raw: dict) -> ScoresOutput:
return await LLMService(provider="perplexity").generate(
scores_prompt,
{
"clinic_name": (raw.get(SourceType.MAINPAGE) or [{}])[0]["raw_data"].get("clinicName"),
"data": json.dumps(raw, ensure_ascii=False),
},
)
def _build_website_audit(mainpage: dict) -> dict:
"""mainpage raw_data 에서 직접 매핑. LLM 미경유.
Firecrawl raw HTML collect_mainpage 정규식 파싱해서 tracking/SNS/domain 까지 mainpage 박아둠."""
domain = mainpage.get("domain") or urlparse(mainpage.get("sourceUrl") or "").netloc
sns_links = mainpage.get("snsLinks") or []
audit = {
"primary_domain": domain,
"additional_domains": mainpage.get("additionalDomains") or [],
"sns_links_on_site": bool(sns_links),
"sns_links_detail": sns_links or None,
"tracking_pixels": mainpage.get("trackingPixels") or [],
"main_cta": mainpage.get("mainCta") or "",
}
return WebsiteAudit.model_validate(audit).model_dump()
async def _build_other_channels(raw: dict) -> list[dict]:
result: OtherChannelsOutput = await LLMService(provider="perplexity").generate(
other_channels_prompt,
{
"clinic_name": (raw.get(SourceType.MAINPAGE) or [{}])[0]["raw_data"].get("clinicName"),
"tiktok": json.dumps(raw.get(SourceType.TIKTOK), ensure_ascii=False),
"kakao_talk": json.dumps(raw.get(SourceType.KAKAOTALK), ensure_ascii=False),
"naver_cafe": json.dumps(raw.get(SourceType.NAVER_CAFE), ensure_ascii=False),
"naver_blog": json.dumps(raw.get(SourceType.NAVER_BLOG), ensure_ascii=False),
"gangnam_unni": json.dumps(raw.get(SourceType.GANGNAM_UNNI), ensure_ascii=False),
},
)
return [OtherChannel.model_validate(item).model_dump() for item in result.other_channels]
async def _build_report(analysis_run_id: str) -> dict:
raw = await select_run_raw_data(analysis_run_id)
run = await select_run(analysis_run_id)
if not raw:
return {}
mainpage = raw.get(SourceType.MAINPAGE) or []
mainpage = mainpage[0]["raw_data"] # 유일
branding = raw.get(SourceType.BRANDING) or []
branding = branding[0]["raw_data"] # 유일
instagram = raw.get(SourceType.INSTAGRAM) or []
facebook = raw.get(SourceType.FACEBOOK) or []
youtube = raw.get(SourceType.YOUTUBE) or []
youtube = youtube[0]["raw_data"] if youtube else None # 유일 (기획상)
gangnam_unni = raw.get(SourceType.GANGNAM_UNNI) or []
gangnam_unni = gangnam_unni[0]["raw_data"] if gangnam_unni else None# 유일 (기획상)
naver_blog = raw.get(SourceType.NAVER_BLOG) or []
naver_blog = naver_blog[0]["raw_data"] if naver_blog else None# 유일 (기획상)
tiktok = raw.get(SourceType.TIKTOK) or []
tiktok = tiktok[0]["raw_data"] if tiktok else None# 유일 (기획상)
naver_cafe = raw.get(SourceType.NAVER_CAFE) or []
naver_cafe = naver_cafe[0]["raw_data"] if naver_cafe else None# 유일 (기획상)
brand_assets = branding.get("brandAssets") or {}
channel_logos = branding.get("channelLogos") or {}
logo_url = await select_mainpage_logo_url(analysis_run_id)
brand = await generate_brand_consistency(analysis_run_id)
brand_patch : list[dict] = brand.model_dump()["brand_inconsistencies"]
kpi_extras = {
"tiktok": tiktok,
"naverCafe": naver_cafe,
}
scores = (await _build_scores(analysis_run_id, raw)).model_dump()
report = {
"id" : analysis_run_id,
"created_at" : str(run["created_at"]) if run.get("created_at") else None,
"target_url" : mainpage.get("domain") or urlparse(mainpage.get("sourceUrl") or "").netloc,
"overall_score" : scores.get("overall_score"),
"channel_scores" : scores.get("channel_scores"),
"clinic_snapshot": _build_clinic_snapshot(mainpage, gangnam_unni, brand_assets, logo_url),
"instagram_audit": await build_instagram_audit(instagram, channel_logos),
"facebook_audit": await build_facebook_audit(facebook, brand_patch, channel_logos),
"youtube_audit": await _build_youtube_audit(youtube),
"other_channels": await _build_other_channels(raw),
"website_audit": _build_website_audit(mainpage),
"problem_diagnosis": await _build_critical_issues(analysis_run_id, raw),
"transformation" : await _build_transformation(analysis_run_id, raw),
"roadmap": await _build_roadmap(analysis_run_id, raw),
"kpi_dashboard": build_kpi_dashboard(instagram, facebook, youtube, gangnam_unni, kpi_extras, naver_blog),
}
return ReportOutput(**report)
def _deep_merge(base: dict, overrides: dict) -> dict: def _deep_merge(base: dict, overrides: dict) -> dict:
"""dict 끼리 만나면 재귀로 안쪽까지 합치고, 그 외(list/scalar/None) 는 override 값으로 통째 치환.""" """dict 끼리 만나면 재귀로 안쪽까지 합치고, 그 외(list/scalar/None) 는 override 값으로 통째 치환."""
for k, v in overrides.items(): for k, v in overrides.items():
@ -259,57 +301,28 @@ def _deep_merge(base: dict, overrides: dict) -> dict:
base[k] = v base[k] = v
return base return base
async def generate_brand_consistency(analysis_run_id: str) -> BrandConsistencyOutput:
async def _build_overrides(analysis_run_id: str, result: ReportOutput) -> ReportOutput:
raw = await select_run_raw_data(analysis_run_id) raw = await select_run_raw_data(analysis_run_id)
if not raw:
return result
mainpage = raw.get("mainpage", {}) or {} def _json(v) -> str | None:
branding = raw.get("branding", {}) or {} return json.dumps(v, ensure_ascii=False) if v else None
instagram = raw.get("instagram", {}) or {}
facebook = raw.get("facebook", {}) or {}
youtube = raw.get("youtube", {}) or {}
gangnam_unni = raw.get("gangnam_unni", {}) or {}
naver_blog = raw.get("naver_blog", {}) or {}
instagram_en = raw.get("instagram_en", {}) or {}
facebook_en = raw.get("facebook_en", {}) or {}
tiktok = raw.get("tiktok", {}) or {}
naver_cafe = raw.get("naver_cafe", {}) or {}
brand_assets = branding.get("brandAssets") or {}
channel_logos = branding.get("channelLogos") or {}
logo_url = await select_mainpage_logo_url(analysis_run_id)
llm_fb_pages = result.model_dump().get("facebook_audit", {}).get("pages", []) mainpage = raw.get(SourceType.MAINPAGE) or []
input_data = {
snapshot: dict = _build_clinic_snapshot(gangnam_unni, mainpage, brand_assets, logo_url) "clinic_name": (mainpage[0].get("clinicName") if mainpage else None),
yt_patch: dict = await _build_youtube_audit(youtube) "mainpage": _json(mainpage),
ig_patch = build_instagram_audit(instagram, instagram_en, channel_logos) "instagram": _json(raw.get(SourceType.INSTAGRAM)),
fb_patch = build_facebook_audit(facebook, facebook_en, llm_fb_pages) "facebook": _json(raw.get(SourceType.FACEBOOK)),
kpi_extras = { "youtube": _json(raw.get(SourceType.YOUTUBE)),
"instagramEn": instagram_en, "gangnam_unni": _json(raw.get(SourceType.GANGNAM_UNNI)),
"facebookEn": facebook_en,
"tiktok": tiktok,
"naverCafe": naver_cafe,
} }
kpi = build_kpi_dashboard(instagram, facebook, youtube, gangnam_unni, kpi_extras, naver_blog) return await LLMService(provider="perplexity").generate(brand_consistency_prompt, input_data)
overrides: dict = {}
if snapshot: overrides["clinic_snapshot"] = snapshot
if ig_patch: overrides["instagram_audit"] = ig_patch
if fb_patch: overrides["facebook_audit"] = fb_patch
if yt_patch: overrides["youtube_audit"] = yt_patch
if kpi: overrides["kpi_dashboard"] = kpi
merged = _deep_merge(result.model_dump(), overrides)
return ReportOutput(**merged)
async def run_report_task(analysis_run_id: str) -> None: async def run_report_task(analysis_run_id: str) -> None:
logger.info("[report] start run=%s", analysis_run_id) logger.info("[report] start run=%s", analysis_run_id)
await analyze_branding(analysis_run_id) await analyze_branding(analysis_run_id)
result = await generate_report(analysis_run_id) # result = await generate_report(analysis_run_id)
result = await _build_overrides(analysis_run_id, result) result = await _build_report(analysis_run_id)
await update_run_report(analysis_run_id, result.model_dump()) await update_run_report(analysis_run_id, result.model_dump())
logger.info("[report] done run=%s", analysis_run_id) logger.info("[report] done run=%s", analysis_run_id)
@ -328,8 +341,8 @@ async def run_plan_task(analysis_run_id: str) -> None:
result = await generate_plan(analysis_run_id) result = await generate_plan(analysis_run_id)
# profile_photo 는 brand_assets.logo_description 으로 코드가 박음 (LLM "(가이드 미보유)" 같은 hallucination 차단). # profile_photo 는 brand_assets.logo_description 으로 코드가 박음 (LLM "(가이드 미보유)" 같은 hallucination 차단).
raw = await select_run_raw_data(analysis_run_id) raw = await select_run_raw_data(analysis_run_id)
branding = raw.get("branding") or {} branding = raw.get(SourceType.BRANDING) or []
logo_desc = ((branding.get("brandAssets") or {}).get("logo_description")) or "" logo_desc = (((branding[0] if branding else {}).get("brandAssets") or {}).get("logo_description")) or ""
result = _patch_plan(result, logo_desc) result = _patch_plan(result, logo_desc)
await update_run_plan(analysis_run_id, result.model_dump()) await update_run_plan(analysis_run_id, result.model_dump())
logger.info("[plan] done run=%s", analysis_run_id) logger.info("[plan] done run=%s", analysis_run_id)

View File

@ -16,7 +16,7 @@ async def _describe_logo(analysis_run_id: str, info_id: int, vc: VisionClient) -
"""공식 로고 정성 묘사. branding raw_info["brandAssets"] 머지. """공식 로고 정성 묘사. branding raw_info["brandAssets"] 머지.
호출 우선순위: raw_info.logo_url 컬럼 (HTML parser canonical) firecrawl 메타 fallback.""" 호출 우선순위: raw_info.logo_url 컬럼 (HTML parser canonical) firecrawl 메타 fallback."""
raw = await select_run_raw_data(analysis_run_id) raw = await select_run_raw_data(analysis_run_id)
mainpage = raw.get("mainpage") or {} mainpage = ((raw.get("mainpage") or [{}])[0].get("raw_data")) or {}
homepage_url = mainpage.get("sourceUrl") or "" homepage_url = mainpage.get("sourceUrl") or ""
branding_meta = mainpage.get("branding") or {} branding_meta = mainpage.get("branding") or {}
column_logo = await select_mainpage_logo_url(analysis_run_id) column_logo = await select_mainpage_logo_url(analysis_run_id)
@ -57,7 +57,7 @@ async def _describe_channel_logos(analysis_run_id: str, info_id: int, vc: Vision
} }
logos = [{"channel": label, "url": img} logos = [{"channel": label, "url": img}
for key, label in _label.items() for key, label in _label.items()
if (img := (raw.get(key) or {}).get("_logo_url"))] if (img := (raw.get(key) or [{}])[0].get("logo_url"))]
if not logos: if not logos:
logger.info("[channel_logos] skip — no channel profileImages") logger.info("[channel_logos] skip — no channel profileImages")
return return

View File

@ -156,7 +156,7 @@ async def collect_kakaotalk(analysis_run_id: str, info_id: int, url: str) -> Non
async def collect_brand_basics(analysis_run_id: str, info_id: int) -> None: async def collect_brand_basics(analysis_run_id: str, info_id: int) -> None:
logger.info("[brand_basics] start run=%s info=%s", analysis_run_id, info_id) logger.info("[brand_basics] start run=%s info=%s", analysis_run_id, info_id)
raw = await select_run_raw_data(analysis_run_id) raw = await select_run_raw_data(analysis_run_id)
mainpage = raw.get("mainpage") or {} mainpage = ((raw.get("mainpage") or [{}])[0].get("raw_data")) or {}
homepage_url = mainpage.get("sourceUrl") or "" homepage_url = mainpage.get("sourceUrl") or ""
branding_meta = mainpage.get("branding") or {} branding_meta = mainpage.get("branding") or {}

View File

@ -2,10 +2,13 @@
수치 지표(최근 게시일·게시 빈도·참여율) **수집 시점에** 결정적으로 산출해 DB에 박는다 (transform_for_storage). 수치 지표(최근 게시일·게시 빈도·참여율) **수집 시점에** 결정적으로 산출해 DB에 박는다 (transform_for_storage).
콘텐츠 주제(top_content_type) 캡션 본문 이해가 필요해 LLM이 채운다 (리포트 프롬프트 지시).""" 콘텐츠 주제(top_content_type) 캡션 본문 이해가 필요해 LLM이 채운다 (리포트 프롬프트 지시)."""
import json
from datetime import datetime, timezone from datetime import datetime, timezone
from common.utils import parse_ts from common.utils import parse_ts
from integrations.llm.schemas.report import FacebookAudit from integrations.llm.llm_service import LLMService
from integrations.llm.prompt import facebook_diagnosis_prompt
from integrations.llm.schemas.report import FacebookAudit, DiagnosisItem
def _humanize_age(days: int) -> str: def _humanize_age(days: int) -> str:
@ -74,11 +77,27 @@ def transform_for_storage(fb: dict | None) -> dict | None:
out["latestPosts"] = [] out["latestPosts"] = []
return out return out
def _logo_data(channel_logos: dict, channel: str) -> dict:
"""channelLogos(비전 결과)에서 해당 채널 가져온다."""
for c in (channel_logos or {}).get("channel_logos", []):
if c.get("channel") == channel:
return c
print("channel_logos NOT FOUND : ", channel_logos)
return {
"is_official" : False,
"logo_description" : "로고 찾지 못함"
}
def _page_patch(fb: dict, language: str, label: str) -> dict: def _page_patch(item: dict, channel_logos) -> dict:
"""저장된 페북 페이지 → FacebookPage 스키마 필드 패치. 수치 지표는 수집 시점에 박혀있어 그대로 복사.
language/label 데이터 있을 때만 명시적으로 박음 template-copy KR 값을 EN 슬롯에 잘못 상속시키는 방지."""
p: dict = {} p: dict = {}
fb = item["raw_data"]
language = item.get("language") if item.get("language") else "KR"
label = "페이스북 " + language
channel = "Facebook"
if language != "KR":
channel = channel + " " + language
logo_data = _logo_data(channel_logos, channel)
if fb.get("pageUrl"): p["url"] = p["link"] = fb["pageUrl"] if fb.get("pageUrl"): p["url"] = p["link"] = fb["pageUrl"]
if fb.get("pageName"): p["page_name"] = fb["pageName"] if fb.get("pageName"): p["page_name"] = fb["pageName"]
if fb.get("followers"): p["followers"] = fb["followers"] if fb.get("followers"): p["followers"] = fb["followers"]
@ -90,17 +109,21 @@ def _page_patch(fb: dict, language: str, label: str) -> dict:
for key in ("recent_post_age", "post_frequency", "engagement"): for key in ("recent_post_age", "post_frequency", "engagement"):
if fb.get(key): p[key] = fb[key] if fb.get(key): p[key] = fb[key]
if p: if p:
p["language"] = language p["language"] = item["language"]
p["label"] = label p["label"] = label
p["logo"] = "로고 일치 (공식 로고)" if logo_data["is_official"] else "로고 불일치 (비공식 변형)"
p["logo_description"] = logo_data["logo_description"]
return p return p
def build_facebook_audit(facebook: dict, facebook_en: dict, llm_pages: list[dict] | None = None) -> dict: async def build_facebook_audit(facebook: list[dict], brand_patch: list[dict], channel_logos) -> dict:
"""KR·EN 페북 페이지 구성. logo/logo_description 은 LLM Vision 결과(첫 페이지) 모든 페이지에 공통 적용, pages = [_page_patch(item, channel_logos) for item in facebook]
나머지 필드는 코드가 수집 데이터로 계산.""" diagnosis_result = await LLMService(provider="perplexity").generate(
llm_logo = {k: v for k, v in ((llm_pages or [{}])[0]).items() if k in {"logo", "logo_description"} and v} facebook_diagnosis_prompt,
pages = [{**llm_logo, **p} for p in ( {"pages": json.dumps(pages, ensure_ascii=False)},
_page_patch(facebook, "KR", "페이스북 KR"), )
_page_patch(facebook_en, "EN", "페이스북 EN"), return FacebookAudit.model_validate({
) if p] "pages": pages,
return FacebookAudit.model_validate({"pages": pages}).model_dump(exclude_unset=True) "diagnosis": [DiagnosisItem.model_validate(item).model_dump() for item in diagnosis_result.diagnosis],
"brand_inconsistencies": brand_patch,
}).model_dump()

View File

@ -1,7 +1,10 @@
"""Instagram audit 계정(KR·EN)을 수집 데이터로 구성. """Instagram audit 계정(KR·EN)을 수집 데이터로 구성.
fix (handle/followers/highlights/content_format ) 전부 코드에서 박는다 LLM 출력 무시.""" fix (handle/followers/highlights/content_format ) 전부 코드에서 박는다 LLM 출력 무시."""
from integrations.llm.schemas.report import InstagramAudit import json
from integrations.llm.llm_service import LLMService
from integrations.llm.prompt import instagram_diagnosis_prompt
from integrations.llm.schemas.report import InstagramAudit, DiagnosisItem
_MEDIA = {"GraphImage": "이미지", "GraphSidecar": "카드뉴스", "GraphVideo": "영상/릴스"} _MEDIA = {"GraphImage": "이미지", "GraphSidecar": "카드뉴스", "GraphVideo": "영상/릴스"}
@ -20,8 +23,16 @@ def _logo_desc(channel_logos: dict, channel: str) -> str:
return "" return ""
def _account(data: dict, language: str, label: str, channel: str, channel_logos: dict) -> dict: def _account(item: dict, channel_logos: dict) -> dict:
"""스크래퍼 수집값으로 InstagramAccount 전 필드를 구성.""" """스크래퍼 수집값으로 InstagramAccount 전 필드를 구성."""
language = item.get("language") if item.get("language") else "KR"
label = "인스타그램 " + language
channel = "Instagram"
if language != "KR":
channel = channel + " " + language
data = item.get("raw_data")
handle = data.get("username") or "" handle = data.get("username") or ""
return { return {
"handle": handle, "handle": handle,
@ -40,11 +51,14 @@ def _account(data: dict, language: str, label: str, channel: str, channel_logos:
} }
def build_instagram_audit(instagram: dict, instagram_en: dict, channel_logos: dict) -> dict: async def build_instagram_audit(instagram: list[dict], channel_logos: dict) -> dict:
"""KR·EN 인스타 계정 리스트 구성 (username 있는 것만).""" """KR·EN 인스타 계정 리스트 구성 (username 있는 것만)."""
accounts: list[dict] = [] accounts = [_account(item, channel_logos) for item in instagram if item.get("raw_data").get("username")]
if instagram.get("username"): diagnosis_result = await LLMService(provider="perplexity").generate(
accounts.append(_account(instagram, "KR", "인스타그램 KR", "Instagram", channel_logos)) instagram_diagnosis_prompt,
if instagram_en.get("username"): {"accounts": json.dumps(accounts, ensure_ascii=False)},
accounts.append(_account(instagram_en, "EN", "인스타그램 EN", "Instagram EN", channel_logos)) )
return InstagramAudit.model_validate({"accounts": accounts}).model_dump() return InstagramAudit.model_validate({
"accounts": accounts,
"diagnosis": [DiagnosisItem.model_validate(item).model_dump() for item in diagnosis_result.diagnosis],
}).model_dump()

View File

@ -53,18 +53,21 @@ def build_kpi_dashboard(
instagram: dict, facebook: dict, youtube: dict, gangnam_unni: dict, hospital: dict, instagram: dict, facebook: dict, youtube: dict, gangnam_unni: dict, hospital: dict,
naver_blog: dict | None = None, naver_blog: dict | None = None,
) -> list[dict]: ) -> list[dict]:
ig_en = hospital.get("instagramEn") or {}
fb_en = hospital.get("facebookEn") or {}
tiktok = hospital.get("tiktok") or {} tiktok = hospital.get("tiktok") or {}
cafe = hospital.get("naverCafe") or {} cafe = hospital.get("naverCafe") or {}
# print("facebook", facebook)
# print("instagram", instagram)
kpis: list[dict] = [] kpis: list[dict] = []
facebook_kpis = [_follower_kpi("Facebook " + fb["language"] + " 팔로워", fb['raw_data'].get("followers") ) for fb in facebook]
instagram_kpis = [_follower_kpi("Instagram " + ib["language"] + " 팔로워", ib['raw_data'].get("followers")) for ib in instagram]
print("facebook_kpis", facebook_kpis)
print("instagram_kpis", instagram_kpis)
kpis += facebook_kpis
kpis += instagram_kpis
for k in [ for k in [
_follower_kpi("YouTube 구독자", youtube.get("subscribers")), _follower_kpi("YouTube 구독자", youtube.get("subscribers")),
_follower_kpi("Instagram KR 팔로워", instagram.get("followers")),
_follower_kpi("Instagram EN 팔로워", ig_en.get("followers")),
_follower_kpi("Facebook KR 팔로워", facebook.get("followers")),
_follower_kpi("Facebook EN 팔로워", fb_en.get("followers")),
_follower_kpi("TikTok 팔로워", tiktok.get("followers")), _follower_kpi("TikTok 팔로워", tiktok.get("followers")),
_follower_kpi("Naver Cafe 회원 수", cafe.get("memberCount")), _follower_kpi("Naver Cafe 회원 수", cafe.get("memberCount")),
]: ]:
@ -92,5 +95,5 @@ def build_kpi_dashboard(
"target_3_month": f"{_round_clean(int(gu_reviews * rm3)):,}", "target_3_month": f"{_round_clean(int(gu_reviews * rm3)):,}",
"target_12_month": f"{_round_clean(int(gu_reviews * rm12)):,}", "target_12_month": f"{_round_clean(int(gu_reviews * rm12)):,}",
}) })
print ("kpis", kpis)
return [KPIMetric.model_validate(k).model_dump() for k in kpis] return [KPIMetric.model_validate(k).model_dump() for k in kpis]

View File

@ -31,7 +31,7 @@ async def run_market_analysis(analysis_run_id: str) -> None:
run = await select_run(analysis_run_id) run = await select_run(analysis_run_id)
clinic = await select_hospital(run["hospital_id"]) clinic = await select_hospital(run["hospital_id"])
raw = await select_run_raw_data(analysis_run_id) raw = await select_run_raw_data(analysis_run_id)
mainpage = raw.get("mainpage") or {} mainpage = ((raw.get("mainpage") or [{}])[0].get("raw_data")) or {}
clinic_name = (clinic or {}).get("hospital_name") or "" clinic_name = (clinic or {}).get("hospital_name") or ""
address = (clinic or {}).get("road_address") or "" address = (clinic or {}).get("road_address") or ""