Compare commits

..

4 Commits

10 changed files with 75 additions and 33 deletions

2
.gitignore vendored
View File

@ -46,3 +46,5 @@ alembic/versions/*.pyc
test_results/ test_results/
app/test* app/test*
docker-compose.yml

View File

@ -16,7 +16,7 @@ class FirecrawlClient:
HTTPMethod.POST, HTTPMethod.POST,
url=f"{FIRECRAWL_BASE}/scrape", url=f"{FIRECRAWL_BASE}/scrape",
headers=self._headers(), headers=self._headers(),
json_body={"url": url, "formats": ["json", "links"], "jsonOptions": json_options, "waitFor": wait_for}, json_body={"url": url, "formats": ["json", "links"], "jsonOptions": json_options, "waitFor": wait_for, "maxAge": 0},
label="firecrawl-scrape", label="firecrawl-scrape",
) )
if not resp or not resp.is_success: if not resp or not resp.is_success:
@ -76,7 +76,7 @@ class FirecrawlClient:
"url": url, "url": url,
"formats": ["json", "links", "rawHtml"], "formats": ["json", "links", "rawHtml"],
"jsonOptions": { "jsonOptions": {
"prompt": "Extract: clinic name (Korean), clinic name (English), address, phone with dash format, business hours, slogan, services offered, doctors with name/title/specialty, brand identity (primary/accent/background/text colors in hex, heading/body fonts, logo URL from the actual header/main <img> src, og:image from <meta property='og:image'> content, favicon URL)", "prompt": "Extract: 클리닉 이름 - clinicName (Korean), clinic name (English), address, phone with dash format, business hours, slogan, services offered, doctors with name/title/specialty, brand identity (primary/accent/background/text colors in hex, heading/body fonts, logo URL from the actual header/main <img> src, og:image from <meta property='og:image'> content, favicon URL)",
"schema": { "schema": {
"type": "object", "type": "object",
"properties": { "properties": {
@ -127,8 +127,11 @@ class FirecrawlClient:
}, },
}, },
"waitFor": 5000, "waitFor": 5000,
"maxAge": 0,
"proxy": "auto", # 기본 엔진이 차단되는 사이트(예: viewclinic.com)는 자동으로 stealth 프록시로 재시도.
"timeout": 120000, # proxy:auto면 60s로도 충분했지만(실측), 혹시 모르니 여유있게 120s.
}, },
timeout=60, timeout=150, # 위 Firecrawl 잡 타임아웃보다 길어야 우리 쪽 HTTP 클라이언트가 먼저 끊지 않음.
label="firecrawl-clinic-info", label="firecrawl-clinic-info",
) )
if not resp or not resp.is_success: if not resp or not resp.is_success:
@ -187,6 +190,7 @@ class FirecrawlClient:
}, },
}, },
"waitFor": 5000, "waitFor": 5000,
"maxAge": 0,
}, },
timeout=60, timeout=60,
label="firecrawl-gangnamunni", label="firecrawl-gangnamunni",

View File

@ -13,7 +13,7 @@ from integrations.llm.schemas.report import (
ScoresInput, ScoresOutput, ScoresInput, ScoresOutput,
OtherChannelsInput, OtherChannelsOutput OtherChannelsInput, OtherChannelsOutput
) )
from integrations.llm.schemas.plan import PlanInput, PlanOutput from integrations.llm.schemas.plan import PlanInput, PlanOutput, SummarizeInput, SummarizeOutput
from integrations.llm.schemas.market import ( from integrations.llm.schemas.market import (
MarketCompetitorsInput, MarketCompetitorsOutput, MarketCompetitorsInput, MarketCompetitorsOutput,
MarketKeywordsInput, MarketKeywordsOutput, MarketKeywordsInput, MarketKeywordsOutput,
@ -64,6 +64,13 @@ plan_prompt = Prompt(
output_class=PlanOutput, output_class=PlanOutput,
) )
summarize_prompt = Prompt(
file_name="summarize_prompt.txt",
prompt_model="PLAN_MODEL",
input_class=SummarizeInput,
output_class=SummarizeOutput,
)
market_competitors_prompt = Prompt( market_competitors_prompt = Prompt(
file_name="market_competitors_prompt.txt", file_name="market_competitors_prompt.txt",
prompt_model="MARKET_MODEL", prompt_model="MARKET_MODEL",

View File

@ -2,6 +2,15 @@ from typing import Literal
from pydantic import BaseModel from pydantic import BaseModel
class SummarizeInput(BaseModel):
label: str
data: str
class SummarizeOutput(BaseModel):
summary: str
class PlanInput(BaseModel): class PlanInput(BaseModel):
clinic_name: str | None = None clinic_name: str | None = None
clinic_name_en: str | None = None clinic_name_en: str | None = None

View File

@ -351,7 +351,7 @@ class MarketingReport(BaseModel):
facebook_audit: FacebookAudit facebook_audit: FacebookAudit
other_channels: list[OtherChannel] other_channels: list[OtherChannel]
website_audit: WebsiteAudit website_audit: WebsiteAudit
problem_diagnosis: list[CriticalIssueItem] problem_diagnosis: list[DiagnosisItem]
transformation: TransformationProposal transformation: TransformationProposal
roadmap: list[RoadmapMonth] roadmap: list[RoadmapMonth]
kpi_dashboard: list[KPIMetric] kpi_dashboard: list[KPIMetric]
@ -408,17 +408,13 @@ class ScoresOutput(BaseModel):
# --- Diagnosis --- # --- Diagnosis ---
class CriticalIssueItem(DiagnosisItem):
title: str
class CriticalIssuesInput(BaseModel): class CriticalIssuesInput(BaseModel):
clinic_name: str | None = None clinic_name: str | None = None
data: str | None = None data: str | None = None
class CriticalIssuesOutput(BaseModel): class CriticalIssuesOutput(BaseModel):
diagnosis: list[CriticalIssueItem] diagnosis: list[DiagnosisItem]
# --- Roadmap --- # --- Roadmap ---

View File

@ -3,7 +3,7 @@
{data} {data}
위 데이터를 바탕으로 이 병원의 마케팅 전반에 걸친 핵심 문제점과 개선사항을 진단해줘. 위 데이터를 바탕으로 이 병원의 마케팅 전반에 걸친 핵심 문제점과 개선사항을 진단해줘.
각 항목은 title(해당 문제를 대표하는 2~4단어), category(진단 카테고리), detail(상세 설명), severity(critical/warning/info) 형식의 JSON 배열로 출력해줘. 각 항목은 category(진단 카테고리), detail(상세 설명), severity(critical/warning/info) 형식의 JSON 배열로 출력해줘.
현재 주요 진단 카테고리는 3개야. 현재 주요 진단 카테고리는 3개야.
브랜드 아이덴티티 파편화 브랜드 아이덴티티 파편화

View File

@ -0,0 +1,7 @@
다음은 "{label}" 원본 데이터입니다.
{data}
위 데이터를 마케팅 플랜 생성에 필요한 핵심 정보만 남기고 간결하게 요약하세요.
구체적인 수치·날짜·고유명사(채널명, 게시물 제목 등)는 그대로 보존하고, 중복되거나 플랜 작성에 불필요한 메타데이터는 제거하세요.
요약문 하나의 문자열로만 출력하세요.

View File

@ -30,10 +30,6 @@ class DiagnosisItem(CamelModel):
evidence_ids: list[str] | None = None evidence_ids: list[str] | None = None
class CriticalIssueItem(DiagnosisItem):
title: str
class LeadDoctor(CamelModel): class LeadDoctor(CamelModel):
name: str name: str
credentials: str credentials: str
@ -299,7 +295,7 @@ class MarketingReportResponse(CamelModel):
facebook_audit: FacebookAudit facebook_audit: FacebookAudit
other_channels: list[OtherChannel] other_channels: list[OtherChannel]
website_audit: WebsiteAudit website_audit: WebsiteAudit
problem_diagnosis: list[CriticalIssueItem] problem_diagnosis: list[DiagnosisItem]
transformation: TransformationProposal transformation: TransformationProposal
roadmap: list[RoadmapMonth] roadmap: list[RoadmapMonth]
kpi_dashboard: list[KPIMetric] kpi_dashboard: list[KPIMetric]

View File

@ -1,3 +1,4 @@
import asyncio
import json import json
import logging import logging
from urllib.parse import urlparse from urllib.parse import urlparse
@ -7,8 +8,8 @@ from common.db.run import update_run_report, update_run_plan, select_run_report_
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, summarize_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, CriticalIssueItem, DiagnosisItem, TransformationProposal, RoadmapOutput, RoadmapMonth, ScoresOutput, ChannelScore, WebsiteAudit, OtherChannelsOutput, OtherChannel from integrations.llm.schemas.report import ReportOutput, ClinicSnapshot, YouTubeAudit, BrandConsistencyOutput, CriticalIssuesOutput, DiagnosisItem, TransformationProposal, RoadmapOutput, RoadmapMonth, ScoresOutput, ChannelScore, WebsiteAudit, OtherChannelsOutput, OtherChannel
from services.branding import analyze_branding from services.branding import analyze_branding
from services.instagram_audit import build_instagram_audit from services.instagram_audit import build_instagram_audit
from services.facebook_audit import build_facebook_audit from services.facebook_audit import build_facebook_audit
@ -49,14 +50,8 @@ async def generate_plan(analysis_run_id: str) -> PlanOutput:
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
input_data = { # map: 큰 입력은 LLM으로 압축 요약해 100KB 초과 에러를 방지하고, 작은 입력은 그대로 둔다.
"clinic_name": clinic.get("clinicName"), large_fields = {
"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),
"report": _json(report), "report": _json(report),
"market_competitors": _json(market.get("competitors")), "market_competitors": _json(market.get("competitors")),
"market_keywords": _json(market.get("keywords")), "market_keywords": _json(market.get("keywords")),
@ -65,16 +60,42 @@ async def generate_plan(analysis_run_id: str) -> PlanOutput:
"tiktok": _json(tiktok), "tiktok": _json(tiktok),
"instagram": _json(instagram), "instagram": _json(instagram),
"facebook": _json(facebook), "facebook": _json(facebook),
"naver_blog": _json(_naver_blog_summary(naver_blog)),
"naver_cafe": _json(naver_cafe), "naver_cafe": _json(naver_cafe),
"kakao_talk": _json(kakaotalk),
"channel_logos": _json(branding.get("channelLogos")), "channel_logos": _json(branding.get("channelLogos")),
"brand_assets": _json(branding.get("brandAssets")), "brand_assets": _json(branding.get("brandAssets")),
} }
summarized = dict(zip(
large_fields.keys(),
await asyncio.gather(*(_summarize(label, data) for label, data in large_fields.items())),
))
# reduce: 요약된 입력을 모아 최종 플랜 생성.
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),
"naver_blog": _json(_naver_blog_summary(naver_blog)),
"kakao_talk": _json(kakaotalk),
**summarized,
}
return await LLMService(provider="perplexity").generate(plan_prompt, input_data) return await LLMService(provider="perplexity").generate(plan_prompt, input_data)
_SUMMARIZE_THRESHOLD = 4000 # 이 길이(문자 수)를 넘는 입력만 요약 LLM 호출 (불필요한 호출 방지)
async def _summarize(label: str, data: str | None) -> str | None:
if not data or len(data) <= _SUMMARIZE_THRESHOLD:
return data
result = await LLMService(provider="perplexity").generate(summarize_prompt, {"label": label, "data": data})
return result.summary
def _build_clinic_snapshot(mainpage: dict, gangnam_unni: dict, brand_assets: dict, logo_url: str | None) -> dict: def _build_clinic_snapshot(mainpage: dict, gangnam_unni: dict, brand_assets: dict, logo_url: str | None) -> dict:
snapshot: dict = {} snapshot: dict = {}
doctors = gangnam_unni.get("doctors", []) doctors = gangnam_unni.get("doctors", [])
@ -190,7 +211,7 @@ async def _build_critical_issues(analysis_run_id: str, raw: dict) -> list[dict]:
"data": json.dumps(raw, ensure_ascii=False), "data": json.dumps(raw, ensure_ascii=False),
}, },
) )
return [CriticalIssueItem.model_validate(item).model_dump() for item in result.diagnosis] return [DiagnosisItem.model_validate(item).model_dump() for item in result.diagnosis]
async def _build_scores(analysis_run_id: str, raw: dict) -> ScoresOutput: async def _build_scores(analysis_run_id: str, raw: dict) -> ScoresOutput:

View File

@ -16,4 +16,4 @@ services:
- o2o-net - o2o-net
networks: networks:
o2o-net: o2o-net:
# external: true external: true