Compare commits
No commits in common. "5baea01ae1d0274e845428d549e8382a511cba1f" and "f949a23717e955c6ebc1fb72f30b0f258399a778" have entirely different histories.
5baea01ae1
...
f949a23717
|
|
@ -66,23 +66,19 @@ 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 AND ri.status = 'done'",
|
" WHERE ri.analysis_run_id = %s",
|
||||||
(analysis_run_id,),
|
(analysis_run_id,),
|
||||||
)
|
)
|
||||||
result: dict = {}
|
result: dict = {}
|
||||||
for row in rows:
|
for row in rows:
|
||||||
source_type = row["source_type"]
|
raw = row["raw_data"]
|
||||||
if source_type not in result:
|
key = row["source_type"]
|
||||||
result[source_type] = list()
|
if (row.get("language") or "").upper() == "EN":
|
||||||
|
key = f"{key}_en"
|
||||||
item : dict = {}
|
data = json.loads(raw) if isinstance(raw, str) else (raw or {})
|
||||||
item["raw_data"] = json.loads(row["raw_data"])
|
if isinstance(data, dict) and row.get("logo_url"):
|
||||||
item["logo_url"] = row["logo_url"]
|
data["_logo_url"] = row["logo_url"]
|
||||||
item["source_type"] = row["source_type"]
|
result[key] = data
|
||||||
item["language"] = row["language"]
|
|
||||||
|
|
||||||
result[source_type].append(item)
|
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import os
|
import os
|
||||||
import re
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
@ -11,70 +10,6 @@ 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.
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,7 @@
|
||||||
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 (
|
from integrations.llm.schemas.report import ReportInput, ReportOutput, YouTubeDiagnosisInput, YouTubeDiagnosisOutput, OtherChannelsInput, OtherChannelsOutput
|
||||||
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,
|
||||||
|
|
@ -92,20 +81,6 @@ 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",
|
||||||
|
|
@ -113,44 +88,9 @@ 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,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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: str | None = None
|
instagram_en: str | None = None
|
||||||
facebook: str | None = None
|
facebook_en: 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
|
||||||
|
|
|
||||||
|
|
@ -68,15 +68,17 @@ class RegistryData(BaseModel):
|
||||||
|
|
||||||
|
|
||||||
class ClinicSnapshot(BaseModel):
|
class ClinicSnapshot(BaseModel):
|
||||||
name: str
|
# _build_clinic_snapshot은 source 데이터 있을 때만 채움 (`if x:` 가드).
|
||||||
name_en: str
|
# required면 강남언니/홈페이지 누락 병원에서 ValidationError로 리포트 실패.
|
||||||
|
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
|
location: str | None = None
|
||||||
phone: str
|
phone: str | None = None
|
||||||
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
|
||||||
|
|
@ -157,8 +159,8 @@ class InstagramAccount(BaseModel):
|
||||||
|
|
||||||
|
|
||||||
class InstagramAudit(BaseModel):
|
class InstagramAudit(BaseModel):
|
||||||
accounts: list[InstagramAccount]
|
accounts: list[InstagramAccount] = []
|
||||||
diagnosis: list[DiagnosisItem]
|
diagnosis: list[DiagnosisItem] = []
|
||||||
|
|
||||||
|
|
||||||
# --- Facebook ---
|
# --- Facebook ---
|
||||||
|
|
@ -376,75 +378,3 @@ 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]
|
|
||||||
|
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
다음은 성형외과/피부과 {clinic_name} 의 채널별 브랜드 데이터입니다.
|
|
||||||
|
|
||||||
공식 홈페이지: {mainpage}
|
|
||||||
인스타그램: {instagram}
|
|
||||||
페이스북: {facebook}
|
|
||||||
유튜브: {youtube}
|
|
||||||
강남언니: {gangnam_unni}
|
|
||||||
|
|
||||||
위 채널들 간의 브랜드 불일치 항목을 분석해줘.
|
|
||||||
비교 대상 필드 예시: 병원명(한글/영문), 전화번호, 주소, 로고, 슬로건, 소개 문구 등.
|
|
||||||
|
|
||||||
각 항목은 다음 JSON 형식의 배열로 출력해줘:
|
|
||||||
- field: 불일치 필드명
|
|
||||||
- values: 채널별 실제 값 목록 (channel, value, is_correct)
|
|
||||||
- impact: 불일치가 브랜드에 미치는 영향
|
|
||||||
- recommendation: 개선 권고사항
|
|
||||||
|
|
||||||
출처 번호([1], [2] 등)는 포함하지 마.
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
다음은 성형외과/피부과 {clinic_name} 의 전 채널 수집 데이터입니다.
|
|
||||||
|
|
||||||
{data}
|
|
||||||
|
|
||||||
위 데이터를 바탕으로 이 병원의 마케팅 전반에 걸친 핵심 문제점과 개선사항을 진단해줘.
|
|
||||||
각 항목은 category(진단 카테고리), detail(상세 설명), severity(critical/warning/info) 형식의 JSON 배열로 출력해줘.
|
|
||||||
|
|
||||||
현재 주요 진단 카테고리는 3개야.
|
|
||||||
브랜드 아이덴티티 파편화
|
|
||||||
콘텐츠 전략 부재
|
|
||||||
플랫폼 간 유입 단절
|
|
||||||
|
|
||||||
출처 번호([1], [2] 등)는 포함하지 마.
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
다음은 성형외과/피부과의 페이스북 페이지 데이터입니다.
|
|
||||||
|
|
||||||
{pages}
|
|
||||||
|
|
||||||
위 데이터를 바탕으로 이 병원의 페이스북 마케팅 현황을 진단해줘.
|
|
||||||
각 항목은 category(진단 카테고리), detail(상세 설명), severity(critical/warning/info) 형식의 JSON 배열로 출력해줘.
|
|
||||||
|
|
||||||
출처 번호([1], [2] 등)는 포함하지 마.
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
다음은 성형외과/피부과의 인스타그램 계정 데이터입니다.
|
|
||||||
|
|
||||||
{accounts}
|
|
||||||
|
|
||||||
위 데이터를 바탕으로 이 병원의 인스타그램 마케팅 현황을 진단해줘.
|
|
||||||
각 항목은 category(진단 카테고리), detail(상세 설명), severity(critical/warning/info) 형식의 JSON 배열로 출력해줘.
|
|
||||||
|
|
||||||
출처 번호([1], [2] 등)는 포함하지 마.
|
|
||||||
|
|
@ -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}
|
{instagram_en}
|
||||||
|
|
||||||
### 페이스북
|
### 페이스북 (영문 페이지)
|
||||||
{facebook}
|
{facebook_en}
|
||||||
|
|
||||||
### 네이버 카페 (공식 카페 운영 신호)
|
### 네이버 카페 (공식 카페 운영 신호)
|
||||||
{naver_cafe}
|
{naver_cafe}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
다음은 성형외과/피부과 {clinic_name} 의 전 채널 수집 데이터입니다.
|
|
||||||
|
|
||||||
{data}
|
|
||||||
|
|
||||||
위 데이터를 바탕으로 이 병원의 3개월 마케팅 실행 로드맵을 수립해줘.
|
|
||||||
month 1, 2, 3 각각 하나씩, 총 3개 항목을 포함한 roadmap JSON 배열로 출력해줘.
|
|
||||||
|
|
||||||
각 항목은 아래 형식을 따라줘:
|
|
||||||
- month: 월 번호 (1, 2, 3)
|
|
||||||
- title: 해당 월의 핵심 테마 (예: "브랜드 정비")
|
|
||||||
- subtitle: 한 줄 부제 (예: "기반 구축 — 로고·계정 통일")
|
|
||||||
- tasks: 실행 과제 목록, 각 과제는 task(string)와 completed(false)로 구성
|
|
||||||
|
|
||||||
출처 번호([1], [2] 등)는 포함하지 마.
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
다음은 성형외과/피부과 {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] 등)는 포함하지 마.
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
다음은 성형외과/피부과 {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] 등)는 포함하지 마.
|
|
||||||
|
|
@ -99,7 +99,6 @@ 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"),
|
||||||
|
|
|
||||||
|
|
@ -88,7 +88,7 @@ class ChannelScore(CamelModel):
|
||||||
channel: str
|
channel: str
|
||||||
icon: str
|
icon: str
|
||||||
score: int
|
score: int
|
||||||
max_score: int = 100
|
max_score: int
|
||||||
status: Severity
|
status: Severity
|
||||||
headline: str
|
headline: str
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 models.status import SourceType
|
from common.db.run import update_run_report, update_run_plan, select_run_report_data
|
||||||
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, brand_consistency_prompt, critical_issues_prompt, transformation_prompt, roadmap_prompt, scores_prompt, other_channels_prompt
|
from integrations.llm.prompt import report_prompt, plan_prompt, youtube_diagnosis_prompt
|
||||||
from integrations.llm.schemas.report import ReportOutput, ClinicSnapshot, YouTubeAudit, BrandConsistencyOutput, CriticalIssuesOutput, DiagnosisItem, TransformationProposal, RoadmapOutput, RoadmapMonth, ScoresOutput, ChannelScore, WebsiteAudit, OtherChannelsOutput, OtherChannel
|
from integrations.llm.schemas.report import ReportOutput, ClinicSnapshot, YouTubeAudit
|
||||||
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,35 +17,59 @@ 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(SourceType.MAINPAGE) or []
|
clinic = raw.get("mainpage") or {}
|
||||||
clinic = clinic[0]["raw_data"] # if not exist, must error
|
branding = raw.get("branding") or {}
|
||||||
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
|
||||||
|
|
||||||
|
|
@ -62,12 +86,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(tiktok),
|
"tiktok": _json(raw.get("tiktok")),
|
||||||
"instagram": _json(instagram),
|
"instagram_en": _json(raw.get("instagram_en")),
|
||||||
"facebook": _json(facebook),
|
"facebook_en": _json(raw.get("facebook_en")),
|
||||||
"naver_blog": _json(_naver_blog_summary(naver_blog)),
|
"naver_blog": _json(_naver_blog_summary(raw.get("naver_blog"))),
|
||||||
"naver_cafe": _json(naver_cafe),
|
"naver_cafe": _json(raw.get("naver_cafe")),
|
||||||
"kakao_talk": _json(kakaotalk),
|
"kakao_talk": _json(raw.get("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")),
|
||||||
}
|
}
|
||||||
|
|
@ -75,17 +99,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 _build_clinic_snapshot(mainpage: dict, gangnam_unni: dict, brand_assets: dict, logo_url: str | None) -> dict:
|
def _build_clinic_snapshot(gangnam_unni: dict, mainpage: 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
|
||||||
snapshot["name"] = mainpage["clinicName"]
|
if gangnam_unni.get("name"): snapshot["name"] = gangnam_unni["name"]
|
||||||
snapshot["name_en"] = mainpage["clinicNameEn"]
|
if mainpage.get("clinicNameEn"): snapshot["name_en"] = mainpage["clinicNameEn"]
|
||||||
snapshot["phone"] = mainpage["phone"]
|
if mainpage.get("phone"): snapshot["phone"] = mainpage["phone"]
|
||||||
snapshot["location"] = mainpage["address"]
|
domain = mainpage.get("domain") or urlparse(mainpage.get("sourceUrl") or "").netloc
|
||||||
snapshot["domain"] = mainpage.get("domain") or urlparse(mainpage.get("sourceUrl") or "").netloc
|
if domain: snapshot["domain"] = domain
|
||||||
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:
|
||||||
|
|
@ -101,6 +126,7 @@ def _build_clinic_snapshot(mainpage: dict, gangnam_unni: 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 함."""
|
||||||
|
|
@ -112,14 +138,79 @@ 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"]
|
||||||
|
|
@ -127,16 +218,16 @@ async def _build_youtube_audit(youtube: dict) -> dict: # 기획상 1개의 input
|
||||||
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]
|
||||||
yt_patch["channel_description"] = youtube.get("description") or ""
|
if youtube.get("description"): yt_patch["channel_description"] = youtube["description"]
|
||||||
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
|
||||||
]
|
]
|
||||||
|
|
@ -159,139 +250,6 @@ async def _build_youtube_audit(youtube: dict) -> dict: # 기획상 1개의 input
|
||||||
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():
|
||||||
|
|
@ -301,28 +259,57 @@ 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
|
||||||
|
|
||||||
def _json(v) -> str | None:
|
mainpage = raw.get("mainpage", {}) or {}
|
||||||
return json.dumps(v, ensure_ascii=False) if v else None
|
branding = raw.get("branding", {}) or {}
|
||||||
|
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)
|
||||||
|
|
||||||
mainpage = raw.get(SourceType.MAINPAGE) or []
|
llm_fb_pages = result.model_dump().get("facebook_audit", {}).get("pages", [])
|
||||||
input_data = {
|
|
||||||
"clinic_name": (mainpage[0].get("clinicName") if mainpage else None),
|
snapshot: dict = _build_clinic_snapshot(gangnam_unni, mainpage, brand_assets, logo_url)
|
||||||
"mainpage": _json(mainpage),
|
yt_patch: dict = await _build_youtube_audit(youtube)
|
||||||
"instagram": _json(raw.get(SourceType.INSTAGRAM)),
|
ig_patch = build_instagram_audit(instagram, instagram_en, channel_logos)
|
||||||
"facebook": _json(raw.get(SourceType.FACEBOOK)),
|
fb_patch = build_facebook_audit(facebook, facebook_en, llm_fb_pages)
|
||||||
"youtube": _json(raw.get(SourceType.YOUTUBE)),
|
kpi_extras = {
|
||||||
"gangnam_unni": _json(raw.get(SourceType.GANGNAM_UNNI)),
|
"instagramEn": instagram_en,
|
||||||
|
"facebookEn": facebook_en,
|
||||||
|
"tiktok": tiktok,
|
||||||
|
"naverCafe": naver_cafe,
|
||||||
}
|
}
|
||||||
return await LLMService(provider="perplexity").generate(brand_consistency_prompt, input_data)
|
kpi = build_kpi_dashboard(instagram, facebook, youtube, gangnam_unni, kpi_extras, naver_blog)
|
||||||
|
|
||||||
|
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_report(analysis_run_id)
|
result = await _build_overrides(analysis_run_id, result)
|
||||||
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)
|
||||||
|
|
||||||
|
|
@ -341,8 +328,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(SourceType.BRANDING) or []
|
branding = raw.get("branding") or {}
|
||||||
logo_desc = (((branding[0] if branding else {}).get("brandAssets") or {}).get("logo_description")) or ""
|
logo_desc = ((branding.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)
|
||||||
|
|
|
||||||
|
|
@ -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 [{}])[0].get("raw_data")) or {}
|
mainpage = raw.get("mainpage") 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 [{}])[0].get("logo_url"))]
|
if (img := (raw.get(key) or {}).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
|
||||||
|
|
|
||||||
|
|
@ -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 [{}])[0].get("raw_data")) or {}
|
mainpage = raw.get("mainpage") 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 {}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,10 @@
|
||||||
수치 지표(최근 게시일·게시 빈도·참여율)는 **수집 시점에** 결정적으로 산출해 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.llm_service import LLMService
|
from integrations.llm.schemas.report import FacebookAudit
|
||||||
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:
|
||||||
|
|
@ -77,27 +74,11 @@ 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(item: dict, channel_logos) -> dict:
|
def _page_patch(fb: dict, language: str, label: str) -> 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"]
|
||||||
|
|
@ -109,21 +90,17 @@ def _page_patch(item: dict, channel_logos) -> 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"] = item["language"]
|
p["language"] = 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
|
||||||
|
|
||||||
|
|
||||||
async def build_facebook_audit(facebook: list[dict], brand_patch: list[dict], channel_logos) -> dict:
|
def build_facebook_audit(facebook: dict, facebook_en: dict, llm_pages: list[dict] | None = None) -> dict:
|
||||||
pages = [_page_patch(item, channel_logos) for item in facebook]
|
"""KR·EN 페북 페이지 구성. logo/logo_description 은 LLM Vision 결과(첫 페이지) 모든 페이지에 공통 적용,
|
||||||
diagnosis_result = await LLMService(provider="perplexity").generate(
|
나머지 필드는 코드가 수집 데이터로 계산."""
|
||||||
facebook_diagnosis_prompt,
|
llm_logo = {k: v for k, v in ((llm_pages or [{}])[0]).items() if k in {"logo", "logo_description"} and v}
|
||||||
{"pages": json.dumps(pages, ensure_ascii=False)},
|
pages = [{**llm_logo, **p} for p in (
|
||||||
)
|
_page_patch(facebook, "KR", "페이스북 KR"),
|
||||||
return FacebookAudit.model_validate({
|
_page_patch(facebook_en, "EN", "페이스북 EN"),
|
||||||
"pages": pages,
|
) if p]
|
||||||
"diagnosis": [DiagnosisItem.model_validate(item).model_dump() for item in diagnosis_result.diagnosis],
|
return FacebookAudit.model_validate({"pages": pages}).model_dump(exclude_unset=True)
|
||||||
"brand_inconsistencies": brand_patch,
|
|
||||||
}).model_dump()
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,7 @@
|
||||||
"""Instagram audit 계정(KR·EN)을 수집 데이터로 구성.
|
"""Instagram audit 계정(KR·EN)을 수집 데이터로 구성.
|
||||||
fix 값(handle/followers/highlights/content_format 등)은 전부 코드에서 박는다 — LLM 출력 무시."""
|
fix 값(handle/followers/highlights/content_format 등)은 전부 코드에서 박는다 — LLM 출력 무시."""
|
||||||
|
|
||||||
import json
|
from integrations.llm.schemas.report import InstagramAudit
|
||||||
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": "영상/릴스"}
|
||||||
|
|
||||||
|
|
@ -23,16 +20,8 @@ def _logo_desc(channel_logos: dict, channel: str) -> str:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
def _account(item: dict, channel_logos: dict) -> dict:
|
def _account(data: dict, language: str, label: str, channel: str, 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,
|
||||||
|
|
@ -51,14 +40,11 @@ def _account(item: dict, channel_logos: dict) -> dict:
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async def build_instagram_audit(instagram: list[dict], channel_logos: dict) -> dict:
|
def build_instagram_audit(instagram: dict, instagram_en: dict, channel_logos: dict) -> dict:
|
||||||
"""KR·EN 인스타 계정 리스트 구성 (username 있는 것만)."""
|
"""KR·EN 인스타 계정 리스트 구성 (username 있는 것만)."""
|
||||||
accounts = [_account(item, channel_logos) for item in instagram if item.get("raw_data").get("username")]
|
accounts: list[dict] = []
|
||||||
diagnosis_result = await LLMService(provider="perplexity").generate(
|
if instagram.get("username"):
|
||||||
instagram_diagnosis_prompt,
|
accounts.append(_account(instagram, "KR", "인스타그램 KR", "Instagram", channel_logos))
|
||||||
{"accounts": json.dumps(accounts, ensure_ascii=False)},
|
if instagram_en.get("username"):
|
||||||
)
|
accounts.append(_account(instagram_en, "EN", "인스타그램 EN", "Instagram EN", channel_logos))
|
||||||
return InstagramAudit.model_validate({
|
return InstagramAudit.model_validate({"accounts": accounts}).model_dump()
|
||||||
"accounts": accounts,
|
|
||||||
"diagnosis": [DiagnosisItem.model_validate(item).model_dump() for item in diagnosis_result.diagnosis],
|
|
||||||
}).model_dump()
|
|
||||||
|
|
|
||||||
|
|
@ -53,21 +53,18 @@ 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")),
|
||||||
]:
|
]:
|
||||||
|
|
@ -95,5 +92,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]
|
||||||
|
|
|
||||||
|
|
@ -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 [{}])[0].get("raw_data")) or {}
|
mainpage = raw.get("mainpage") 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 ""
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue