diff --git a/app/api/plans.py b/app/api/plans.py index d9cb440..c918a75 100644 --- a/app/api/plans.py +++ b/app/api/plans.py @@ -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(), + ) diff --git a/app/api/reports.py b/app/api/reports.py index 1c1ff96..6fca3da 100644 --- a/app/api/reports.py +++ b/app/api/reports.py @@ -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 diff --git a/app/common/utils.py b/app/common/utils.py index e2d6be2..d7c4b21 100644 --- a/app/common/utils.py +++ b/app/common/utils.py @@ -7,6 +7,7 @@ import httpx REQUEST_TIMEOUT = 60 + def get_env(key: str) -> str: v = os.environ.get(key, "") if not v: diff --git a/app/integrations/llm/llm_service.py b/app/integrations/llm/llm_service.py index 8272d54..c35928f 100644 --- a/app/integrations/llm/llm_service.py +++ b/app/integrations/llm/llm_service.py @@ -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()}, diff --git a/app/integrations/llm/schemas/report.py b/app/integrations/llm/schemas/report.py index 0757e14..4dae1c4 100644 --- a/app/integrations/llm/schemas/report.py +++ b/app/integrations/llm/schemas/report.py @@ -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 diff --git a/app/models/plan.py b/app/models/plan.py index 724dda9..9e41b1a 100644 --- a/app/models/plan.py +++ b/app/models/plan.py @@ -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 diff --git a/app/models/report.py b/app/models/report.py index 043e9b6..b719769 100644 --- a/app/models/report.py +++ b/app/models/report.py @@ -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