diff --git a/app/api/__init__.py b/app/api/__init__.py index 360625f..67f80b1 100644 --- a/app/api/__init__.py +++ b/app/api/__init__.py @@ -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] diff --git a/app/api/plans.py b/app/api/plan.py similarity index 71% rename from app/api/plans.py rename to app/api/plan.py index c918a75..ba59702 100644 --- a/app/api/plans.py +++ b/app/api/plan.py @@ -6,13 +6,13 @@ 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)]) +router = APIRouter(prefix="/api/plan", tags=["plan"], dependencies=[Depends(verify_api_key)]) logger = logging.getLogger(__name__) -@router.get("/{run_id}", response_model=PlanApiResponse) +@router.get("/{run_id}", response_model=PlanApiResponse, response_model_by_alias=True) async def get_plan(run_id: str): - logger.info("GET /api/plans/%s", run_id) + 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" @@ -28,9 +28,9 @@ async def get_plan(run_id: str): 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"], + 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(), ) diff --git a/app/api/reports.py b/app/api/report.py similarity index 69% rename from app/api/reports.py rename to app/api/report.py index 6fca3da..15f93a6 100644 --- a/app/api/reports.py +++ b/app/api/report.py @@ -6,13 +6,13 @@ 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)]) +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/reports/%s", run_id) + 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" @@ -26,10 +26,11 @@ async def get_report(run_id: str): 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) - 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 + 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"}), + ) diff --git a/app/integrations/llm/schemas/plan.py b/app/integrations/llm/schemas/plan.py index 8eb5567..80a15b8 100644 --- a/app/integrations/llm/schemas/plan.py +++ b/app/integrations/llm/schemas/plan.py @@ -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 diff --git a/app/integrations/llm/schemas/report.py b/app/integrations/llm/schemas/report.py index 4dae1c4..96092a0 100644 --- a/app/integrations/llm/schemas/report.py +++ b/app/integrations/llm/schemas/report.py @@ -1,13 +1,6 @@ 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"] +from models.status import Severity, ChannelStatus, DataSource, Language, VideoType, AnnotationType class ScreenshotAnnotation(BaseModel): diff --git a/app/models/common.py b/app/models/common.py new file mode 100644 index 0000000..73774cc --- /dev/null +++ b/app/models/common.py @@ -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) diff --git a/app/models/plan.py b/app/models/plan.py index 9e41b1a..5e67efa 100644 --- a/app/models/plan.py +++ b/app/models/plan.py @@ -1,19 +1,200 @@ -from pydantic import BaseModel -from integrations.llm.schemas.plan import ( - BrandGuide, ChannelStrategyCard, ContentStrategyData, - CalendarData, AssetCollectionData, RepurposingProposalItem, -) +from typing import Literal +from models.common import CamelModel -class PlanApiResponse(BaseModel): +class BrandPlanInconsistencyValue(CamelModel): + channel: str + value: str + is_correct: bool + + +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 - clinicName: str | None = None - clinicNameEn: str | None = None - createdAt: str - targetUrl: str - brandGuide: BrandGuide - channelStrategies: list[ChannelStrategyCard] - contentStrategy: ContentStrategyData + 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 - assetCollection: AssetCollectionData - repurposingProposals: list[RepurposingProposalItem] | None = None + asset_collection: AssetCollectionData + repurposing_proposals: list[RepurposingProposalItem] | None = None diff --git a/app/models/report.py b/app/models/report.py index b719769..a5c765c 100644 --- a/app/models/report.py +++ b/app/models/report.py @@ -1,17 +1,6 @@ from __future__ import annotations -from pydantic import BaseModel, ConfigDict -from integrations.llm.schemas.report import ( - Severity, ChannelStatus, DataSource, Language, VideoType, AnnotationType, -) - - -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) +from models.common import CamelModel +from models.status import Severity, ChannelStatus, DataSource, Language, VideoType, AnnotationType class ScreenshotAnnotation(CamelModel): diff --git a/app/models/status.py b/app/models/status.py index 6f54d5e..34bac8b 100644 --- a/app/models/status.py +++ b/app/models/status.py @@ -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"