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(
|
||||
"SELECT rs.source_type, rs.language, ri.raw_data, ri.logo_url"
|
||||
" 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,),
|
||||
)
|
||||
result: dict = {}
|
||||
for row in rows:
|
||||
source_type = row["source_type"]
|
||||
if source_type not in result:
|
||||
result[source_type] = list()
|
||||
|
||||
item : dict = {}
|
||||
item["raw_data"] = json.loads(row["raw_data"])
|
||||
item["logo_url"] = row["logo_url"]
|
||||
item["source_type"] = row["source_type"]
|
||||
item["language"] = row["language"]
|
||||
|
||||
result[source_type].append(item)
|
||||
|
||||
raw = row["raw_data"]
|
||||
key = row["source_type"]
|
||||
if (row.get("language") or "").upper() == "EN":
|
||||
key = f"{key}_en"
|
||||
data = json.loads(raw) if isinstance(raw, str) else (raw or {})
|
||||
if isinstance(data, dict) and row.get("logo_url"):
|
||||
data["_logo_url"] = row["logo_url"]
|
||||
result[key] = data
|
||||
return result
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import os
|
||||
import re
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
|
@ -11,70 +10,6 @@ logger = logging.getLogger(__name__)
|
|||
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:
|
||||
"""수집기마다 다른 timestamp 포맷을 통일된 datetime으로 변환.
|
||||
파싱 실패 시 None.
|
||||
|
|
|
|||
|
|
@ -1,18 +1,7 @@
|
|||
import os
|
||||
from pydantic import BaseModel
|
||||
from common.utils import get_env
|
||||
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.report import ReportInput, ReportOutput, YouTubeDiagnosisInput, YouTubeDiagnosisOutput, OtherChannelsInput, OtherChannelsOutput
|
||||
from integrations.llm.schemas.plan import PlanInput, PlanOutput
|
||||
from integrations.llm.schemas.market import (
|
||||
MarketCompetitorsInput, MarketCompetitorsOutput,
|
||||
|
|
@ -92,20 +81,6 @@ market_target_audience_prompt = Prompt(
|
|||
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(
|
||||
file_name="youtube_diagnosis_prompt.txt",
|
||||
prompt_model="REPORT_MODEL",
|
||||
|
|
@ -113,44 +88,9 @@ youtube_diagnosis_prompt = Prompt(
|
|||
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(
|
||||
file_name="other_channels_prompt.txt",
|
||||
prompt_model="REPORT_MODEL",
|
||||
input_class=OtherChannelsInput,
|
||||
output_class=OtherChannelsOutput,
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -16,8 +16,8 @@ class PlanInput(BaseModel):
|
|||
market_trend: str | None = None
|
||||
market_target_audience: str | None = None
|
||||
tiktok: str | None = None
|
||||
instagram: str | None = None
|
||||
facebook: str | None = None
|
||||
instagram_en: str | None = None
|
||||
facebook_en: str | None = None
|
||||
naver_blog: str | None = None
|
||||
naver_cafe: str | None = None
|
||||
kakao_talk: str | None = None
|
||||
|
|
|
|||
|
|
@ -68,15 +68,17 @@ class RegistryData(BaseModel):
|
|||
|
||||
|
||||
class ClinicSnapshot(BaseModel):
|
||||
name: str
|
||||
name_en: str
|
||||
# _build_clinic_snapshot은 source 데이터 있을 때만 채움 (`if x:` 가드).
|
||||
# required면 강남언니/홈페이지 누락 병원에서 ValidationError로 리포트 실패.
|
||||
name: str | None = None
|
||||
name_en: str | None = None
|
||||
staff_count: int | None = None
|
||||
lead_doctor: LeadDoctor | None = None
|
||||
overall_rating: float | None = None
|
||||
total_reviews: int | None = None
|
||||
certifications: list[str] = []
|
||||
location: str
|
||||
phone: str
|
||||
location: str | None = None
|
||||
phone: str | None = None
|
||||
domain: str | None = None
|
||||
logo_images: LogoImages | None = None
|
||||
brand_colors: BrandColors | None = None
|
||||
|
|
@ -157,8 +159,8 @@ class InstagramAccount(BaseModel):
|
|||
|
||||
|
||||
class InstagramAudit(BaseModel):
|
||||
accounts: list[InstagramAccount]
|
||||
diagnosis: list[DiagnosisItem]
|
||||
accounts: list[InstagramAccount] = []
|
||||
diagnosis: list[DiagnosisItem] = []
|
||||
|
||||
|
||||
# --- Facebook ---
|
||||
|
|
@ -376,75 +378,3 @@ class YouTubeDiagnosisInput(BaseModel):
|
|||
|
||||
class YouTubeDiagnosisOutput(BaseModel):
|
||||
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}
|
||||
|
||||
## 추가 채널 데이터 (네이버 블로그 / 틱톡 / 인스타그램 EN / 페이스북 EN / 네이버 카페 / 카카오톡)
|
||||
아래에 데이터가 있는 채널은 channelStrategies에 **반드시 포함**하세요 (네이버 블로그, 틱톡, 인스타그램, 페이스북, 네이버 카페, 카카오톡). channelBranding은 SNS·블로그·카페까지만 포함(카카오톡은 메신저라 제외). null이면 제외.
|
||||
아래에 데이터가 있는 채널은 channelStrategies에 **반드시 포함**하세요 (네이버 블로그, 틱톡, 영문 인스타그램, 영문 페이스북, 네이버 카페, 카카오톡). channelBranding은 SNS·블로그·카페까지만 포함(카카오톡은 메신저라 제외). null이면 제외.
|
||||
|
||||
### 네이버 블로그 (Naver Blog)
|
||||
{naver_blog}
|
||||
|
|
@ -41,11 +41,11 @@
|
|||
### 틱톡 (TikTok)
|
||||
{tiktok}
|
||||
|
||||
### 인스타그램
|
||||
{instagram}
|
||||
### 인스타그램 (영문 계정)
|
||||
{instagram_en}
|
||||
|
||||
### 페이스북
|
||||
{facebook}
|
||||
### 페이스북 (영문 페이지)
|
||||
{facebook_en}
|
||||
|
||||
### 네이버 카페 (공식 카페 운영 신호)
|
||||
{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", {})
|
||||
snippet = ch.get("snippet", {})
|
||||
thumbs = snippet.get("thumbnails", {})
|
||||
print(snippet)
|
||||
return {
|
||||
"channelId": raw["channelId"],
|
||||
"channelName": snippet.get("title"),
|
||||
|
|
|
|||
|
|
@ -88,7 +88,7 @@ class ChannelScore(CamelModel):
|
|||
channel: str
|
||||
icon: str
|
||||
score: int
|
||||
max_score: int = 100
|
||||
max_score: int
|
||||
status: Severity
|
||||
headline: str
|
||||
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
import json
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime
|
||||
from urllib.parse import urlparse
|
||||
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.run import update_run_report, update_run_plan, select_run_report_data
|
||||
from common.db.source import select_run_raw_data, select_mainpage_logo_url
|
||||
from common.db.market import select_market
|
||||
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.schemas.report import ReportOutput, ClinicSnapshot, YouTubeAudit, BrandConsistencyOutput, CriticalIssuesOutput, DiagnosisItem, TransformationProposal, RoadmapOutput, RoadmapMonth, ScoresOutput, ChannelScore, WebsiteAudit, OtherChannelsOutput, OtherChannel
|
||||
from integrations.llm.prompt import report_prompt, plan_prompt, youtube_diagnosis_prompt
|
||||
from integrations.llm.schemas.report import ReportOutput, ClinicSnapshot, YouTubeAudit
|
||||
from services.branding import analyze_branding
|
||||
from services.instagram_audit import build_instagram_audit
|
||||
from services.facebook_audit import build_facebook_audit
|
||||
|
|
@ -17,35 +17,59 @@ from integrations.llm.schemas.plan import PlanOutput
|
|||
|
||||
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:
|
||||
raw = await select_run_raw_data(analysis_run_id)
|
||||
clinic = raw.get(SourceType.MAINPAGE) 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
|
||||
clinic = raw.get("mainpage") or {}
|
||||
branding = raw.get("branding") or {}
|
||||
report = await select_run_report_data(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:
|
||||
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_trend": _json(market.get("trend")),
|
||||
"market_target_audience": _json(market.get("target_audience")),
|
||||
"tiktok": _json(tiktok),
|
||||
"instagram": _json(instagram),
|
||||
"facebook": _json(facebook),
|
||||
"naver_blog": _json(_naver_blog_summary(naver_blog)),
|
||||
"naver_cafe": _json(naver_cafe),
|
||||
"kakao_talk": _json(kakaotalk),
|
||||
"tiktok": _json(raw.get("tiktok")),
|
||||
"instagram_en": _json(raw.get("instagram_en")),
|
||||
"facebook_en": _json(raw.get("facebook_en")),
|
||||
"naver_blog": _json(_naver_blog_summary(raw.get("naver_blog"))),
|
||||
"naver_cafe": _json(raw.get("naver_cafe")),
|
||||
"kakao_talk": _json(raw.get("kakaotalk")),
|
||||
"channel_logos": _json(branding.get("channelLogos")),
|
||||
"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)
|
||||
|
||||
|
||||
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 = {}
|
||||
doctors = gangnam_unni.get("doctors", [])
|
||||
lead = max(doctors, key=lambda d: d.get("reviews", 0)) if doctors else None
|
||||
snapshot["name"] = mainpage["clinicName"]
|
||||
snapshot["name_en"] = mainpage["clinicNameEn"]
|
||||
snapshot["phone"] = mainpage["phone"]
|
||||
snapshot["location"] = mainpage["address"]
|
||||
snapshot["domain"] = mainpage.get("domain") or urlparse(mainpage.get("sourceUrl") or "").netloc
|
||||
if gangnam_unni.get("name"): snapshot["name"] = gangnam_unni["name"]
|
||||
if mainpage.get("clinicNameEn"): snapshot["name_en"] = mainpage["clinicNameEn"]
|
||||
if mainpage.get("phone"): snapshot["phone"] = mainpage["phone"]
|
||||
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("totalReviews"): snapshot["total_reviews"] = gangnam_unni["totalReviews"]
|
||||
if gangnam_unni.get("address"): snapshot["location"] = gangnam_unni["address"]
|
||||
if gangnam_unni.get("badges"): snapshot["certifications"] = gangnam_unni["badges"]
|
||||
if gangnam_unni.get("totalMajorStaffs"): snapshot["staff_count"] = gangnam_unni["totalMajorStaffs"]
|
||||
if lead:
|
||||
|
|
@ -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"]
|
||||
return ClinicSnapshot.model_validate(snapshot).model_dump()
|
||||
|
||||
|
||||
def _naver_blog_summary(blog: dict | None) -> dict | None:
|
||||
"""plan 카드 한 장에 들어가는 건 전체 포스트 수와 최근 활동 시점뿐. 그 외(본문·링크·제목)는
|
||||
던져봐야 토큰만 늘고 LLM 이 무관 정보로 hallucinate 함."""
|
||||
|
|
@ -112,14 +138,79 @@ def _naver_blog_summary(blog: dict | None) -> dict | 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", [])
|
||||
yt_patch: dict = {
|
||||
"weekly_view_growth": {"absolute": 0, "percentage": 0.0},
|
||||
"estimated_monthly_revenue": {"min": 0, "max": 0},
|
||||
"linked_urls": [],
|
||||
"avg_video_length": calc_avg_video_length(videos),
|
||||
"upload_frequency": calc_upload_frequency(videos),
|
||||
"avg_video_length": _calc_avg_video_length(videos),
|
||||
"upload_frequency": _calc_upload_frequency(videos),
|
||||
}
|
||||
if youtube.get("channelName"): yt_patch["channel_name"] = youtube["channelName"]
|
||||
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("totalViews"): yt_patch["total_views"] = youtube["totalViews"]
|
||||
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 videos:
|
||||
yt_patch["top_videos"] = [
|
||||
{
|
||||
"title": v["title"],
|
||||
"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",
|
||||
"uploaded_ago": relative_date(v.get("date", "")),
|
||||
"uploaded_ago": _relative_date(v.get("date", "")),
|
||||
}
|
||||
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()
|
||||
|
||||
|
||||
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:
|
||||
"""dict 끼리 만나면 재귀로 안쪽까지 합치고, 그 외(list/scalar/None) 는 override 값으로 통째 치환."""
|
||||
for k, v in overrides.items():
|
||||
|
|
@ -301,28 +259,57 @@ def _deep_merge(base: dict, overrides: dict) -> dict:
|
|||
base[k] = v
|
||||
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)
|
||||
if not raw:
|
||||
return result
|
||||
|
||||
def _json(v) -> str | None:
|
||||
return json.dumps(v, ensure_ascii=False) if v else None
|
||||
mainpage = raw.get("mainpage", {}) or {}
|
||||
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 []
|
||||
input_data = {
|
||||
"clinic_name": (mainpage[0].get("clinicName") if mainpage else None),
|
||||
"mainpage": _json(mainpage),
|
||||
"instagram": _json(raw.get(SourceType.INSTAGRAM)),
|
||||
"facebook": _json(raw.get(SourceType.FACEBOOK)),
|
||||
"youtube": _json(raw.get(SourceType.YOUTUBE)),
|
||||
"gangnam_unni": _json(raw.get(SourceType.GANGNAM_UNNI)),
|
||||
llm_fb_pages = result.model_dump().get("facebook_audit", {}).get("pages", [])
|
||||
|
||||
snapshot: dict = _build_clinic_snapshot(gangnam_unni, mainpage, brand_assets, logo_url)
|
||||
yt_patch: dict = await _build_youtube_audit(youtube)
|
||||
ig_patch = build_instagram_audit(instagram, instagram_en, channel_logos)
|
||||
fb_patch = build_facebook_audit(facebook, facebook_en, llm_fb_pages)
|
||||
kpi_extras = {
|
||||
"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:
|
||||
logger.info("[report] start run=%s", analysis_run_id)
|
||||
await analyze_branding(analysis_run_id)
|
||||
# result = await generate_report(analysis_run_id)
|
||||
result = await _build_report(analysis_run_id)
|
||||
result = await generate_report(analysis_run_id)
|
||||
result = await _build_overrides(analysis_run_id, result)
|
||||
await update_run_report(analysis_run_id, result.model_dump())
|
||||
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)
|
||||
# profile_photo 는 brand_assets.logo_description 으로 코드가 박음 (LLM "(가이드 미보유)" 같은 hallucination 차단).
|
||||
raw = await select_run_raw_data(analysis_run_id)
|
||||
branding = raw.get(SourceType.BRANDING) or []
|
||||
logo_desc = (((branding[0] if branding else {}).get("brandAssets") or {}).get("logo_description")) or ""
|
||||
branding = raw.get("branding") or {}
|
||||
logo_desc = ((branding.get("brandAssets") or {}).get("logo_description")) or ""
|
||||
result = _patch_plan(result, logo_desc)
|
||||
await update_run_plan(analysis_run_id, result.model_dump())
|
||||
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"] 머지.
|
||||
호출 우선순위: raw_info.logo_url 컬럼 (HTML parser canonical) → firecrawl 메타 fallback."""
|
||||
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 ""
|
||||
branding_meta = mainpage.get("branding") or {}
|
||||
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}
|
||||
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:
|
||||
logger.info("[channel_logos] skip — no channel profileImages")
|
||||
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:
|
||||
logger.info("[brand_basics] start run=%s info=%s", analysis_run_id, info_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 ""
|
||||
branding_meta = mainpage.get("branding") or {}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,13 +2,10 @@
|
|||
수치 지표(최근 게시일·게시 빈도·참여율)는 **수집 시점에** 결정적으로 산출해 DB에 박는다 (transform_for_storage).
|
||||
콘텐츠 주제(top_content_type)는 캡션 본문 이해가 필요해 LLM이 채운다 (리포트 프롬프트 지시)."""
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from common.utils import parse_ts
|
||||
from integrations.llm.llm_service import LLMService
|
||||
from integrations.llm.prompt import facebook_diagnosis_prompt
|
||||
from integrations.llm.schemas.report import FacebookAudit, DiagnosisItem
|
||||
from integrations.llm.schemas.report import FacebookAudit
|
||||
|
||||
|
||||
def _humanize_age(days: int) -> str:
|
||||
|
|
@ -77,27 +74,11 @@ def transform_for_storage(fb: dict | None) -> dict | None:
|
|||
out["latestPosts"] = []
|
||||
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 = {}
|
||||
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("pageName"): p["page_name"] = fb["pageName"]
|
||||
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"):
|
||||
if fb.get(key): p[key] = fb[key]
|
||||
if p:
|
||||
p["language"] = item["language"]
|
||||
p["language"] = language
|
||||
p["label"] = label
|
||||
p["logo"] = "로고 일치 (공식 로고)" if logo_data["is_official"] else "로고 불일치 (비공식 변형)"
|
||||
p["logo_description"] = logo_data["logo_description"]
|
||||
return p
|
||||
|
||||
|
||||
async def build_facebook_audit(facebook: list[dict], brand_patch: list[dict], channel_logos) -> dict:
|
||||
pages = [_page_patch(item, channel_logos) for item in facebook]
|
||||
diagnosis_result = await LLMService(provider="perplexity").generate(
|
||||
facebook_diagnosis_prompt,
|
||||
{"pages": json.dumps(pages, ensure_ascii=False)},
|
||||
)
|
||||
return FacebookAudit.model_validate({
|
||||
"pages": pages,
|
||||
"diagnosis": [DiagnosisItem.model_validate(item).model_dump() for item in diagnosis_result.diagnosis],
|
||||
"brand_inconsistencies": brand_patch,
|
||||
}).model_dump()
|
||||
def build_facebook_audit(facebook: dict, facebook_en: dict, llm_pages: list[dict] | None = None) -> dict:
|
||||
"""KR·EN 페북 페이지 구성. logo/logo_description 은 LLM Vision 결과(첫 페이지) 모든 페이지에 공통 적용,
|
||||
나머지 필드는 코드가 수집 데이터로 계산."""
|
||||
llm_logo = {k: v for k, v in ((llm_pages or [{}])[0]).items() if k in {"logo", "logo_description"} and v}
|
||||
pages = [{**llm_logo, **p} for p in (
|
||||
_page_patch(facebook, "KR", "페이스북 KR"),
|
||||
_page_patch(facebook_en, "EN", "페이스북 EN"),
|
||||
) if p]
|
||||
return FacebookAudit.model_validate({"pages": pages}).model_dump(exclude_unset=True)
|
||||
|
|
|
|||
|
|
@ -1,10 +1,7 @@
|
|||
"""Instagram audit 계정(KR·EN)을 수집 데이터로 구성.
|
||||
fix 값(handle/followers/highlights/content_format 등)은 전부 코드에서 박는다 — LLM 출력 무시."""
|
||||
|
||||
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
|
||||
from integrations.llm.schemas.report import InstagramAudit
|
||||
|
||||
_MEDIA = {"GraphImage": "이미지", "GraphSidecar": "카드뉴스", "GraphVideo": "영상/릴스"}
|
||||
|
||||
|
|
@ -23,16 +20,8 @@ def _logo_desc(channel_logos: dict, channel: str) -> str:
|
|||
return ""
|
||||
|
||||
|
||||
def _account(item: dict, channel_logos: dict) -> dict:
|
||||
def _account(data: dict, language: str, label: str, channel: str, channel_logos: dict) -> dict:
|
||||
"""스크래퍼 수집값으로 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 ""
|
||||
return {
|
||||
"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 있는 것만)."""
|
||||
accounts = [_account(item, channel_logos) for item in instagram if item.get("raw_data").get("username")]
|
||||
diagnosis_result = await LLMService(provider="perplexity").generate(
|
||||
instagram_diagnosis_prompt,
|
||||
{"accounts": json.dumps(accounts, ensure_ascii=False)},
|
||||
)
|
||||
return InstagramAudit.model_validate({
|
||||
"accounts": accounts,
|
||||
"diagnosis": [DiagnosisItem.model_validate(item).model_dump() for item in diagnosis_result.diagnosis],
|
||||
}).model_dump()
|
||||
accounts: list[dict] = []
|
||||
if instagram.get("username"):
|
||||
accounts.append(_account(instagram, "KR", "인스타그램 KR", "Instagram", channel_logos))
|
||||
if instagram_en.get("username"):
|
||||
accounts.append(_account(instagram_en, "EN", "인스타그램 EN", "Instagram EN", channel_logos))
|
||||
return InstagramAudit.model_validate({"accounts": accounts}).model_dump()
|
||||
|
|
|
|||
|
|
@ -53,21 +53,18 @@ def build_kpi_dashboard(
|
|||
instagram: dict, facebook: dict, youtube: dict, gangnam_unni: dict, hospital: dict,
|
||||
naver_blog: dict | None = None,
|
||||
) -> list[dict]:
|
||||
ig_en = hospital.get("instagramEn") or {}
|
||||
fb_en = hospital.get("facebookEn") or {}
|
||||
tiktok = hospital.get("tiktok") or {}
|
||||
cafe = hospital.get("naverCafe") or {}
|
||||
# print("facebook", facebook)
|
||||
# print("instagram", instagram)
|
||||
|
||||
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 [
|
||||
_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("Naver Cafe 회원 수", cafe.get("memberCount")),
|
||||
]:
|
||||
|
|
@ -95,5 +92,5 @@ def build_kpi_dashboard(
|
|||
"target_3_month": f"{_round_clean(int(gu_reviews * rm3)):,}개",
|
||||
"target_12_month": f"{_round_clean(int(gu_reviews * rm12)):,}개",
|
||||
})
|
||||
print ("kpis", 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)
|
||||
clinic = await select_hospital(run["hospital_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 ""
|
||||
address = (clinic or {}).get("road_address") or ""
|
||||
|
|
|
|||
Loading…
Reference in New Issue