report output format 변경 및 clinic info출력 추가

upload
jaehwang 2026-05-18 15:40:37 +09:00
parent c1f39aceff
commit 9b4e99abf9
7 changed files with 689 additions and 55 deletions

View File

@ -4,18 +4,33 @@ from fastapi import APIRouter, Depends, HTTPException, Response
from common.db import fetchone
from common.deps import verify_api_key
from integrations.llm.schemas.plan import PlanOutput
from models.plan import PlanApiResponse
router = APIRouter(prefix="/api/plans", tags=["plans"], dependencies=[Depends(verify_api_key)])
logger = logging.getLogger(__name__)
@router.get("/{run_id}", response_model=PlanOutput | None)
@router.get("/{run_id}", response_model=PlanApiResponse)
async def get_plan(run_id: str):
logger.info("GET /api/plans/%s", run_id)
row = await fetchone("SELECT plan_data FROM analysis_runs WHERE analysis_run_id = %s", (run_id,))
row = await fetchone(
"SELECT ar.plan_data, ar.created_at, h.hospital_name, h.hospital_name_en, h.url"
" FROM analysis_runs ar"
" JOIN hospital_baseinfo h ON ar.hospital_id = h.hospital_id"
" WHERE ar.analysis_run_id = %s",
(run_id,),
)
if row is None:
raise HTTPException(status_code=404, detail="Run not found")
if row["plan_data"] is None:
return Response(status_code=204)
data = json.loads(row["plan_data"]) if isinstance(row["plan_data"], str) else row["plan_data"]
return PlanOutput(**data)
plan = PlanOutput(**data)
return PlanApiResponse(
id=run_id,
clinicName=row["hospital_name"],
clinicNameEn=row["hospital_name_en"],
createdAt=str(row["created_at"]),
targetUrl=row["url"],
**plan.model_dump(),
)

View File

@ -4,16 +4,20 @@ from fastapi import APIRouter, Depends, HTTPException, Response
from common.db import fetchone
from common.deps import verify_api_key
from integrations.llm.schemas.report import ReportOutput
from models.report import MarketingReportResponse
router = APIRouter(prefix="/api/reports", tags=["reports"], dependencies=[Depends(verify_api_key)])
logger = logging.getLogger(__name__)
@router.get("/{run_id}", response_model=ReportOutput | None)
@router.get("/{run_id}", response_model=MarketingReportResponse, response_model_by_alias=True)
async def get_report(run_id: str):
logger.info("GET /api/reports/%s", run_id)
row = await fetchone(
"SELECT report_data FROM analysis_runs WHERE analysis_run_id = %s",
"SELECT ar.report_data, ar.created_at, h.hospital_name, h.hospital_name_en, h.url"
" FROM analysis_runs ar"
" JOIN hospital_baseinfo h ON ar.hospital_id = h.hospital_id"
" WHERE ar.analysis_run_id = %s",
(run_id,),
)
if row is None:
@ -21,4 +25,11 @@ async def get_report(run_id: str):
if row["report_data"] is None:
return Response(status_code=204)
data = json.loads(row["report_data"]) if isinstance(row["report_data"], str) else row["report_data"]
return ReportOutput(**data)
llm_output = ReportOutput(**data)
response = MarketingReportResponse.model_validate(llm_output.model_dump())
response.id = run_id
response.clinic_name = row["hospital_name"]
response.clinic_name_en = row["hospital_name_en"]
response.created_at = str(row["created_at"])
response.target_url = row["url"]
return response

View File

@ -7,6 +7,7 @@ import httpx
REQUEST_TIMEOUT = 60
def get_env(key: str) -> str:
v = os.environ.get(key, "")
if not v:

View File

@ -45,6 +45,7 @@ class LLMService:
response = await self.client.chat.completions.create(
model=prompt.model,
messages=[{"role": "user", "content": prompt_text}],
max_tokens=16000,
response_format={
"type": "json_schema",
"json_schema": {"name": prompt.output_class.__name__, "schema": prompt.output_class.model_json_schema()},

View File

@ -1,8 +1,316 @@
from __future__ import annotations
from typing import Literal
from pydantic import BaseModel
Severity = Literal["critical", "warning", "good", "excellent", "unknown"]
ChannelStatus = Literal["active", "inactive", "unknown", "not_found"]
DataSource = Literal["registry", "scrape"]
Language = Literal["KR", "EN"]
VideoType = Literal["Short", "Long"]
AnnotationType = Literal["highlight", "arrow", "text"]
class ScreenshotAnnotation(BaseModel):
type: AnnotationType
x: float
y: float
width: float | None = None
height: float | None = None
label: str | None = None
color: str | None = None
class ScreenshotEvidence(BaseModel):
id: str
url: str
channel: str
captured_at: str
caption: str
source_url: str | None = None
annotations: list[ScreenshotAnnotation] | None = None
class DiagnosisItem(BaseModel):
category: str
detail: str
severity: Severity
evidence_ids: list[str] | None = None
# --- ClinicSnapshot ---
class LeadDoctor(BaseModel):
name: str
credentials: str
rating: float
review_count: int
class PriceRange(BaseModel):
min: str
max: str
currency: str
class LogoImages(BaseModel):
circle: str | None = None
horizontal: str | None = None
korean: str | None = None
class BrandColors(BaseModel):
primary: str
accent: str
text: str
class RegistryData(BaseModel):
district: str | None = None
branches: str | None = None
brand_group: str | None = None
website_en: str | None = None
naver_place_url: str | None = None
gangnam_unni_url: str | None = None
google_maps_url: str | None = None
class ClinicSnapshot(BaseModel):
name: str
name_en: str
established: str
years_in_business: int
staff_count: int
lead_doctor: LeadDoctor
overall_rating: float
total_reviews: int
price_range: PriceRange
certifications: list[str]
media_appearances: list[str]
medical_tourism: list[str]
location: str
nearest_station: str
phone: str
domain: str
logo_images: LogoImages | None = None
brand_colors: BrandColors | None = None
source: DataSource | None = None
registry_data: RegistryData | None = None
# --- ChannelScore ---
class ChannelScore(BaseModel):
channel: str
icon: str
score: int
max_score: int
status: Severity
headline: str
# --- YouTube ---
class WeeklyViewGrowth(BaseModel):
absolute: int
percentage: float
class EstimatedRevenue(BaseModel):
min: int
max: int
class LinkedUrl(BaseModel):
label: str
url: str
class TopVideo(BaseModel):
title: str
views: int
uploaded_ago: str
type: VideoType
duration: str | None = None
class YouTubeAudit(BaseModel):
channel_name: str
handle: str
subscribers: int
total_videos: int
total_views: int
weekly_view_growth: WeeklyViewGrowth
estimated_monthly_revenue: EstimatedRevenue
avg_video_length: str
upload_frequency: str
channel_created_date: str
subscriber_rank: str
channel_description: str
linked_urls: list[LinkedUrl]
playlists: list[str]
top_videos: list[TopVideo]
diagnosis: list[DiagnosisItem]
# --- Instagram ---
class InstagramAccount(BaseModel):
handle: str
language: Language
label: str
posts: int
followers: int
following: int
category: str
profile_link: str
highlights: list[str]
reels_count: int
content_format: str
profile_photo: str
bio: str
class InstagramAudit(BaseModel):
accounts: list[InstagramAccount]
diagnosis: list[DiagnosisItem]
# --- Facebook ---
class BrandInconsistencyValue(BaseModel):
channel: str
value: str
is_correct: bool
class BrandInconsistency(BaseModel):
field: str
values: list[BrandInconsistencyValue]
impact: str
recommendation: str
class FacebookPage(BaseModel):
url: str
page_name: str
language: Language
label: str
followers: int
following: int
category: str
bio: str
logo: str
logo_description: str
link: str
linked_domain: str
reviews: int
recent_post_age: str
has_whatsapp: bool
post_frequency: str | None = None
top_content_type: str | None = None
engagement: str | None = None
class FacebookAudit(BaseModel):
pages: list[FacebookPage]
diagnosis: list[DiagnosisItem]
brand_inconsistencies: list[BrandInconsistency]
consolidation_recommendation: str
# --- 기타 채널 / 웹사이트 ---
class OtherChannel(BaseModel):
name: str
status: ChannelStatus
details: str
url: str | None = None
class TrackingPixel(BaseModel):
name: str
installed: bool
details: str | None = None
class SnsLink(BaseModel):
platform: str
url: str
location: str
class AdditionalDomain(BaseModel):
domain: str
purpose: str
class WebsiteAudit(BaseModel):
primary_domain: str
additional_domains: list[AdditionalDomain]
sns_links_on_site: bool
sns_links_detail: list[SnsLink] | None = None
tracking_pixels: list[TrackingPixel]
main_cta: str
# --- Transformation ---
class AsIsToBeItem(BaseModel):
area: str
as_is: str
to_be: str
class StrategyDetail(BaseModel):
strategy: str
detail: str
class PlatformStrategy(BaseModel):
platform: str
icon: str
current_metric: str
target_metric: str
strategies: list[StrategyDetail]
class NewChannelProposal(BaseModel):
channel: str
priority: str
rationale: str
class TransformationProposal(BaseModel):
brand_identity: list[AsIsToBeItem]
content_strategy: list[AsIsToBeItem]
platform_strategies: list[PlatformStrategy]
website_improvements: list[AsIsToBeItem]
new_channel_proposals: list[NewChannelProposal]
# --- Roadmap / KPI ---
class RoadmapTask(BaseModel):
task: str
completed: bool
class RoadmapMonth(BaseModel):
month: int
title: str
subtitle: str
tasks: list[RoadmapTask]
class KPIMetric(BaseModel):
metric: str
current: str
target_3_month: str
target_12_month: str
# --- ReportInput (prompt 템플릿 변수) ---
# template.format(**model_dump()) 에 삽입될 변수들
# 각 채널 raw_data를 호출부에서 json.dumps()로 직렬화해서 넘겨야 함
class ReportInput(BaseModel):
clinic_name: str | None = None
clinic_name_en: str | None = None
@ -18,26 +326,25 @@ class ReportInput(BaseModel):
gangnam_unni: str | None = None
class ChannelScore(BaseModel):
score: int
summary: str
strengths: list[str]
weaknesses: list[str]
# --- MarketingReport ---
class ConversionStrategy(BaseModel):
summary: str
actions: list[str]
# response_format으로 OpenAI structured output에 전달 — dict 필드 사용 불가
class ReportOutput(BaseModel):
class MarketingReport(BaseModel):
id: str
created_at: str
target_url: str
overall_score: int
instagram: ChannelScore | None = None
facebook: ChannelScore | None = None
naver_blog: ChannelScore | None = None
youtube: ChannelScore | None = None
gangnam_unni: ChannelScore | None = None
conversion_strategy: ConversionStrategy
roadmap: list[str]
kpis: list[str]
clinic_snapshot: ClinicSnapshot
channel_scores: list[ChannelScore]
youtube_audit: YouTubeAudit
instagram_audit: InstagramAudit
facebook_audit: FacebookAudit
other_channels: list[OtherChannel]
website_audit: WebsiteAudit
problem_diagnosis: list[DiagnosisItem]
transformation: TransformationProposal
roadmap: list[RoadmapMonth]
kpi_dashboard: list[KPIMetric]
screenshots: list[ScreenshotEvidence] | None = None
ReportOutput = MarketingReport

View File

@ -1,16 +1,19 @@
from pydantic import BaseModel
from integrations.llm.schemas.plan import (
BrandGuide, ChannelStrategyCard, ContentStrategyData,
CalendarData, AssetCollectionData, RepurposingProposalItem,
)
class PlanCreate(BaseModel):
report_id: str
regenerate: bool = False
class PlanResponse(BaseModel):
class PlanApiResponse(BaseModel):
id: str
analysis_run_id: str
brand_guide: dict
channel_strategies: list
content_strategy: dict
calendar: list
created_at: str
clinicName: str | None = None
clinicNameEn: str | None = None
createdAt: str
targetUrl: str
brandGuide: BrandGuide
channelStrategies: list[ChannelStrategyCard]
contentStrategy: ContentStrategyData
calendar: CalendarData
assetCollection: AssetCollectionData
repurposingProposals: list[RepurposingProposalItem] | None = None

View File

@ -1,22 +1,318 @@
from pydantic import BaseModel
from __future__ import annotations
from pydantic import BaseModel, ConfigDict
from integrations.llm.schemas.report import (
Severity, ChannelStatus, DataSource, Language, VideoType, AnnotationType,
)
class ClinicInfo(BaseModel):
def _to_camel(s: str) -> str:
head, *tail = s.split("_")
return head + "".join(w.capitalize() for w in tail)
class CamelModel(BaseModel):
model_config = ConfigDict(populate_by_name=True, alias_generator=_to_camel)
class ScreenshotAnnotation(CamelModel):
type: AnnotationType
x: float
y: float
width: float | None = None
height: float | None = None
label: str | None = None
color: str | None = None
class ScreenshotEvidence(CamelModel):
id: str
url: str
channel: str
captured_at: str
caption: str
source_url: str | None = None
annotations: list[ScreenshotAnnotation] | None = None
class DiagnosisItem(CamelModel):
category: str
detail: str
severity: Severity
evidence_ids: list[str] | None = None
class LeadDoctor(CamelModel):
name: str
credentials: str
rating: float
review_count: int
class PriceRange(CamelModel):
min: str
max: str
currency: str
class LogoImages(CamelModel):
circle: str | None = None
horizontal: str | None = None
korean: str | None = None
class BrandColors(CamelModel):
primary: str
accent: str
text: str
class RegistryData(CamelModel):
district: str | None = None
branches: str | None = None
brand_group: str | None = None
website_en: str | None = None
naver_place_url: str | None = None
gangnam_unni_url: str | None = None
google_maps_url: str | None = None
class ClinicSnapshot(CamelModel):
name: str
name_en: str
established: str
years_in_business: int
staff_count: int
lead_doctor: LeadDoctor
overall_rating: float
total_reviews: int
price_range: PriceRange
certifications: list[str]
media_appearances: list[str]
medical_tourism: list[str]
location: str
nearest_station: str
phone: str
domain: str
logo_images: LogoImages | None = None
brand_colors: BrandColors | None = None
source: DataSource | None = None
registry_data: RegistryData | None = None
class ChannelScore(CamelModel):
channel: str
icon: str
score: int
max_score: int
status: Severity
headline: str
class WeeklyViewGrowth(CamelModel):
absolute: int
percentage: float
class EstimatedRevenue(CamelModel):
min: int
max: int
class LinkedUrl(CamelModel):
label: str
url: str
class ReportResponse(BaseModel):
class TopVideo(CamelModel):
title: str
views: int
uploaded_ago: str
type: VideoType
duration: str | None = None
class YouTubeAudit(CamelModel):
channel_name: str
handle: str
subscribers: int
total_videos: int
total_views: int
weekly_view_growth: WeeklyViewGrowth
estimated_monthly_revenue: EstimatedRevenue
avg_video_length: str
upload_frequency: str
channel_created_date: str
subscriber_rank: str
channel_description: str
linked_urls: list[LinkedUrl]
playlists: list[str]
top_videos: list[TopVideo]
diagnosis: list[DiagnosisItem]
class InstagramAccount(CamelModel):
handle: str
language: Language
label: str
posts: int
followers: int
following: int
category: str
profile_link: str
highlights: list[str]
reels_count: int
content_format: str
profile_photo: str
bio: str
class InstagramAudit(CamelModel):
accounts: list[InstagramAccount]
diagnosis: list[DiagnosisItem]
class BrandInconsistencyValue(CamelModel):
channel: str
value: str
is_correct: bool
class BrandInconsistency(CamelModel):
field: str
values: list[BrandInconsistencyValue]
impact: str
recommendation: str
class FacebookPage(CamelModel):
url: str
page_name: str
language: Language
label: str
followers: int
following: int
category: str
bio: str
logo: str
logo_description: str
link: str
linked_domain: str
reviews: int
recent_post_age: str
has_whatsapp: bool
post_frequency: str | None = None
top_content_type: str | None = None
engagement: str | None = None
class FacebookAudit(CamelModel):
pages: list[FacebookPage]
diagnosis: list[DiagnosisItem]
brand_inconsistencies: list[BrandInconsistency]
consolidation_recommendation: str
class OtherChannel(CamelModel):
name: str
status: ChannelStatus
details: str
url: str | None = None
class TrackingPixel(CamelModel):
name: str
installed: bool
details: str | None = None
class SnsLink(CamelModel):
platform: str
url: str
location: str
class AdditionalDomain(CamelModel):
domain: str
purpose: str
class WebsiteAudit(CamelModel):
primary_domain: str
additional_domains: list[AdditionalDomain]
sns_links_on_site: bool
sns_links_detail: list[SnsLink] | None = None
tracking_pixels: list[TrackingPixel]
main_cta: str
class AsIsToBeItem(CamelModel):
area: str
as_is: str
to_be: str
class StrategyDetail(CamelModel):
strategy: str
detail: str
class PlatformStrategy(CamelModel):
platform: str
icon: str
current_metric: str
target_metric: str
strategies: list[StrategyDetail]
class NewChannelProposal(CamelModel):
channel: str
priority: str
rationale: str
class TransformationProposal(CamelModel):
brand_identity: list[AsIsToBeItem]
content_strategy: list[AsIsToBeItem]
platform_strategies: list[PlatformStrategy]
website_improvements: list[AsIsToBeItem]
new_channel_proposals: list[NewChannelProposal]
class RoadmapTask(CamelModel):
task: str
completed: bool
class RoadmapMonth(CamelModel):
month: int
title: str
subtitle: str
tasks: list[RoadmapTask]
class KPIMetric(CamelModel):
metric: str
current: str
target_3_month: str
target_12_month: str
class MarketingReportResponse(CamelModel):
id: str
clinic: ClinicInfo
clinic_name: str | None = None
clinic_name_en: str | None = None
created_at: str
target_url: str
overall_score: int
youtube: dict
instagram: dict
facebook: dict
naver_place: dict
naver_blog: dict
gangnam_unni: dict
conversion_strategy: dict
roadmap: list
kpis: list
generated_at: str
clinic_snapshot: ClinicSnapshot
channel_scores: list[ChannelScore]
youtube_audit: YouTubeAudit
instagram_audit: InstagramAudit
facebook_audit: FacebookAudit
other_channels: list[OtherChannel]
website_audit: WebsiteAudit
problem_diagnosis: list[DiagnosisItem]
transformation: TransformationProposal
roadmap: list[RoadmapMonth]
kpi_dashboard: list[KPIMetric]
screenshots: list[ScreenshotEvidence] | None = None