Compare commits

...

2 Commits

13 changed files with 986 additions and 175 deletions

View File

@ -1,6 +1,6 @@
from .clinics import router as clinics_router
from .analysis import router as analysis_router
from .reports import router as reports_router
from .plans import router as plans_router
from .report import router as report_router
from .plan import router as plan_router
routers = [clinics_router, analysis_router, reports_router, plans_router]
routers = [clinics_router, analysis_router, report_router, plan_router]

36
app/api/plan.py Normal file
View File

@ -0,0 +1,36 @@
import json
import logging
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/plan", tags=["plan"], dependencies=[Depends(verify_api_key)])
logger = logging.getLogger(__name__)
@router.get("/{run_id}", response_model=PlanApiResponse, response_model_by_alias=True)
async def get_plan(run_id: str):
logger.info("GET /api/plan/%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"]
plan = PlanOutput(**data)
return PlanApiResponse(
id=run_id,
clinic_name=row["hospital_name"],
clinic_name_en=row["hospital_name_en"],
created_at=str(row["created_at"]),
target_url=row["url"],
**plan.model_dump(),
)

View File

@ -1,21 +0,0 @@
import json
import logging
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
router = APIRouter(prefix="/api/plans", tags=["plans"], dependencies=[Depends(verify_api_key)])
logger = logging.getLogger(__name__)
@router.get("/{run_id}", response_model=PlanOutput | None)
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,))
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)

36
app/api/report.py Normal file
View File

@ -0,0 +1,36 @@
import json
import logging
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/report", tags=["report"], dependencies=[Depends(verify_api_key)])
logger = logging.getLogger(__name__)
@router.get("/{run_id}", response_model=MarketingReportResponse, response_model_by_alias=True)
async def get_report(run_id: str):
logger.info("GET /api/report/%s", run_id)
row = await fetchone(
"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:
raise HTTPException(status_code=404, detail="Run not found")
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"]
llm_output = ReportOutput(**data)
return MarketingReportResponse(
id=run_id,
clinic_name=row["hospital_name"],
clinic_name_en=row["hospital_name_en"],
created_at=str(row["created_at"]),
target_url=row["url"],
**llm_output.model_dump(exclude={"id", "created_at", "target_url"}),
)

View File

@ -1,24 +0,0 @@
import json
import logging
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
router = APIRouter(prefix="/api/reports", tags=["reports"], dependencies=[Depends(verify_api_key)])
logger = logging.getLogger(__name__)
@router.get("/{run_id}", response_model=ReportOutput | None)
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",
(run_id,),
)
if row is None:
raise HTTPException(status_code=404, detail="Run not found")
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)

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

@ -25,7 +25,7 @@ class FontSpec(BaseModel):
family: str
weight: str
usage: str
sampleText: str
sample_text: str
class LogoUsageRule(BaseModel):
@ -36,29 +36,29 @@ class LogoUsageRule(BaseModel):
class ToneOfVoice(BaseModel):
personality: list[str]
communicationStyle: str
doExamples: list[str]
dontExamples: list[str]
communication_style: str
do_examples: list[str]
dont_examples: list[str]
class ChannelBrandingRule(BaseModel):
channel: str
icon: str
profilePhoto: str
bannerSpec: str
bioTemplate: str
currentStatus: Literal["correct", "incorrect", "missing"]
profile_photo: str
banner_spec: str
bio_template: str
current_status: Literal["correct", "incorrect", "missing"]
class BrandInconsistencyValue(BaseModel):
class BrandPlanInconsistencyValue(BaseModel):
channel: str
value: str
isCorrect: bool
is_correct: bool
class BrandInconsistency(BaseModel):
class BrandPlanInconsistency(BaseModel):
field: str
values: list[BrandInconsistencyValue]
values: list[BrandPlanInconsistencyValue]
impact: str
recommendation: str
@ -66,26 +66,26 @@ class BrandInconsistency(BaseModel):
class BrandGuide(BaseModel):
colors: list[ColorSwatch]
fonts: list[FontSpec]
logoRules: list[LogoUsageRule]
toneOfVoice: ToneOfVoice
channelBranding: list[ChannelBrandingRule]
brandInconsistencies: list[BrandInconsistency]
logo_rules: list[LogoUsageRule]
tone_of_voice: ToneOfVoice
channel_branding: list[ChannelBrandingRule]
brand_inconsistencies: list[BrandPlanInconsistency]
# --- ChannelStrategy ---
class ChannelStrategyCard(BaseModel):
channelId: str
channelName: str
channel_id: str
channel_name: str
icon: str
currentStatus: str
targetGoal: str
contentTypes: list[str]
postingFrequency: str
current_status: str
target_goal: str
content_types: list[str]
posting_frequency: str
tone: str
formatGuidelines: list[str]
format_guidelines: list[str]
priority: Literal["P0", "P1", "P2"]
customerJourneyStage: Literal["awareness", "interest", "consideration", "conversion", "loyalty"] | None = None
customer_journey_stage: Literal["awareness", "interest", "consideration", "conversion", "loyalty"] | None = None
# --- ContentStrategy ---
@ -93,8 +93,8 @@ class ChannelStrategyCard(BaseModel):
class ContentPillar(BaseModel):
title: str
description: str
relatedUSP: str
exampleTopics: list[str]
related_usp: str
example_topics: list[str]
color: str
@ -121,30 +121,30 @@ class RepurposingOutput(BaseModel):
class ContentStrategyData(BaseModel):
pillars: list[ContentPillar]
typeMatrix: list[ContentTypeRow]
type_matrix: list[ContentTypeRow]
workflow: list[WorkflowStep]
repurposingSource: str
repurposingOutputs: list[RepurposingOutput]
repurposing_source: str
repurposing_outputs: list[RepurposingOutput]
# --- Calendar ---
class CalendarEntry(BaseModel):
dayOfWeek: int
day_of_week: int
channel: str
channelIcon: str
contentType: Literal["video", "blog", "social", "ad"]
channel_icon: str
content_type: Literal["video", "blog", "social", "ad"]
title: str
id: str | None = None
description: str | None = None
pillar: str | None = None
status: Literal["draft", "approved", "published"] | None = None
isManualEdit: bool | None = None
aiPromptSeed: str | None = None
is_manual_edit: bool | None = None
ai_prompt_seed: str | None = None
class CalendarWeek(BaseModel):
weekNumber: int
week_number: int
label: str
entries: list[CalendarEntry]
@ -158,7 +158,7 @@ class ContentCountSummary(BaseModel):
class CalendarData(BaseModel):
weeks: list[CalendarWeek]
monthlySummary: list[ContentCountSummary]
monthly_summary: list[ContentCountSummary]
# --- AssetCollection ---
@ -166,11 +166,11 @@ class CalendarData(BaseModel):
class AssetCard(BaseModel):
id: str
source: Literal["homepage", "naver_place", "blog", "social", "youtube"]
sourceLabel: str
source_label: str
type: Literal["photo", "video", "text"]
title: str
description: str
repurposingSuggestions: list[str]
repurposing_suggestions: list[str]
status: Literal["collected", "pending", "needs_creation"]
@ -178,62 +178,29 @@ class YouTubeRepurposeItem(BaseModel):
title: str
views: int
type: Literal["Short", "Long"]
repurposeAs: list[str]
repurpose_as: list[str]
class AssetCollectionData(BaseModel):
assets: list[AssetCard]
youtubeRepurpose: list[YouTubeRepurposeItem]
youtube_repurpose: list[YouTubeRepurposeItem]
# --- Repurposing ---
class RepurposingProposalItem(BaseModel):
sourceVideo: YouTubeRepurposeItem
source_video: YouTubeRepurposeItem
outputs: list[RepurposingOutput]
estimatedEffort: Literal["low", "medium", "high"]
estimated_effort: Literal["low", "medium", "high"]
priority: Literal["high", "medium", "low"]
# --- Workflow ---
class WorkflowVideoDraft(BaseModel):
script: str
shootingGuide: list[str]
duration: str
# class WorkflowImageTextDraft(BaseModel):
# type: Literal["cardnews", "blog"]
# headline: str
# copy: list[str]
# layoutHint: str | None = None
# class WorkflowItem(BaseModel):
# id: str
# title: str
# contentType: Literal["video", "image-text"]
# channel: str
# channelIcon: str
# stage: Literal["planning", "ai-draft", "review", "approved", "scheduled"]
# userNotes: str | None = None
# videoDraft: WorkflowVideoDraft | None = None
# imageTextDraft: WorkflowImageTextDraft | None = None
# scheduledDate: str | None = None
# class WorkflowData(BaseModel):
# items: list[WorkflowItem]
# --- PlanOutput ---
class PlanOutput(BaseModel):
brandGuide: BrandGuide
channelStrategies: list[ChannelStrategyCard]
contentStrategy: ContentStrategyData
brand_guide: BrandGuide
channel_strategies: list[ChannelStrategyCard]
content_strategy: ContentStrategyData
calendar: CalendarData
assetCollection: AssetCollectionData
repurposingProposals: list[RepurposingProposalItem] | None = None
#workflow: WorkflowData | None = None
asset_collection: AssetCollectionData
repurposing_proposals: list[RepurposingProposalItem] | None = None

View File

@ -1,8 +1,309 @@
from __future__ import annotations
from pydantic import BaseModel
from models.status import Severity, ChannelStatus, DataSource, Language, VideoType, AnnotationType
# template.format(**model_dump()) 에 삽입될 변수들
# 각 채널 raw_data를 호출부에서 json.dumps()로 직렬화해서 넘겨야 함
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 템플릿 변수) ---
class ReportInput(BaseModel):
clinic_name: str | None = None
clinic_name_en: str | None = None
@ -18,26 +319,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

10
app/models/common.py Normal file
View File

@ -0,0 +1,10 @@
from pydantic import BaseModel, ConfigDict
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)

View File

@ -1,16 +1,200 @@
from pydantic import BaseModel
from typing import Literal
from models.common import CamelModel
class PlanCreate(BaseModel):
report_id: str
regenerate: bool = False
class BrandPlanInconsistencyValue(CamelModel):
channel: str
value: str
is_correct: bool
class PlanResponse(BaseModel):
class BrandPlanInconsistency(CamelModel):
field: str
values: list[BrandPlanInconsistencyValue]
impact: str
recommendation: str
# --- BrandGuide ---
class ColorSwatch(CamelModel):
name: str
hex: str
usage: str
class FontSpec(CamelModel):
family: str
weight: str
usage: str
sample_text: str
class LogoUsageRule(CamelModel):
rule: str
description: str
correct: bool
class ToneOfVoice(CamelModel):
personality: list[str]
communication_style: str
do_examples: list[str]
dont_examples: list[str]
class ChannelBrandingRule(CamelModel):
channel: str
icon: str
profile_photo: str
banner_spec: str
bio_template: str
current_status: Literal["correct", "incorrect", "missing"]
class BrandGuide(CamelModel):
colors: list[ColorSwatch]
fonts: list[FontSpec]
logo_rules: list[LogoUsageRule]
tone_of_voice: ToneOfVoice
channel_branding: list[ChannelBrandingRule]
brand_inconsistencies: list[BrandPlanInconsistency]
# --- ChannelStrategy ---
class ChannelStrategyCard(CamelModel):
channel_id: str
channel_name: str
icon: str
current_status: str
target_goal: str
content_types: list[str]
posting_frequency: str
tone: str
format_guidelines: list[str]
priority: Literal["P0", "P1", "P2"]
customer_journey_stage: Literal["awareness", "interest", "consideration", "conversion", "loyalty"] | None = None
# --- ContentStrategy ---
class ContentPillar(CamelModel):
title: str
description: str
related_usp: str
example_topics: list[str]
color: str
class ContentTypeRow(CamelModel):
format: str
channels: list[str]
frequency: str
purpose: str
class WorkflowStep(CamelModel):
step: int
name: str
description: str
owner: str
duration: str
class RepurposingOutput(CamelModel):
format: str
channel: str
description: str
class ContentStrategyData(CamelModel):
pillars: list[ContentPillar]
type_matrix: list[ContentTypeRow]
workflow: list[WorkflowStep]
repurposing_source: str
repurposing_outputs: list[RepurposingOutput]
# --- Calendar ---
class CalendarEntry(CamelModel):
day_of_week: int
channel: str
channel_icon: str
content_type: Literal["video", "blog", "social", "ad"]
title: str
id: str | None = None
description: str | None = None
pillar: str | None = None
status: Literal["draft", "approved", "published"] | None = None
is_manual_edit: bool | None = None
ai_prompt_seed: str | None = None
class CalendarWeek(CamelModel):
week_number: int
label: str
entries: list[CalendarEntry]
class ContentCountSummary(CamelModel):
type: Literal["video", "blog", "social", "ad"]
label: str
count: int
color: str
class CalendarData(CamelModel):
weeks: list[CalendarWeek]
monthly_summary: list[ContentCountSummary]
# --- AssetCollection ---
class AssetCard(CamelModel):
id: str
analysis_run_id: str
brand_guide: dict
channel_strategies: list
content_strategy: dict
calendar: list
source: Literal["homepage", "naver_place", "blog", "social", "youtube"]
source_label: str
type: Literal["photo", "video", "text"]
title: str
description: str
repurposing_suggestions: list[str]
status: Literal["collected", "pending", "needs_creation"]
class YouTubeRepurposeItem(CamelModel):
title: str
views: int
type: Literal["Short", "Long"]
repurpose_as: list[str]
class AssetCollectionData(CamelModel):
assets: list[AssetCard]
youtube_repurpose: list[YouTubeRepurposeItem]
# --- Repurposing ---
class RepurposingProposalItem(CamelModel):
source_video: YouTubeRepurposeItem
outputs: list[RepurposingOutput]
estimated_effort: Literal["low", "medium", "high"]
priority: Literal["high", "medium", "low"]
# --- PlanApiResponse ---
class PlanApiResponse(CamelModel):
id: str
clinic_name: str | None = None
clinic_name_en: str | None = None
created_at: str
target_url: str
brand_guide: BrandGuide
channel_strategies: list[ChannelStrategyCard]
content_strategy: ContentStrategyData
calendar: CalendarData
asset_collection: AssetCollectionData
repurposing_proposals: list[RepurposingProposalItem] | None = None

View File

@ -1,22 +1,307 @@
from pydantic import BaseModel
from __future__ import annotations
from models.common import CamelModel
from models.status import Severity, ChannelStatus, DataSource, Language, VideoType, AnnotationType
class ClinicInfo(BaseModel):
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

View File

@ -14,3 +14,39 @@ class TaskStatus(StrEnum):
START = "start"
PROCESSING = "processing"
DONE = "done"
class Severity(StrEnum):
CRITICAL = "critical"
WARNING = "warning"
GOOD = "good"
EXCELLENT = "excellent"
UNKNOWN = "unknown"
class ChannelStatus(StrEnum):
ACTIVE = "active"
INACTIVE = "inactive"
UNKNOWN = "unknown"
NOT_FOUND = "not_found"
class DataSource(StrEnum):
REGISTRY = "registry"
SCRAPE = "scrape"
class Language(StrEnum):
KR = "KR"
EN = "EN"
class VideoType(StrEnum):
SHORT = "Short"
LONG = "Long"
class AnnotationType(StrEnum):
HIGHLIGHT = "highlight"
ARROW = "arrow"
TEXT = "text"