plan report 이름 통일 및 코드 정리

upload
jaehwang 2026-05-18 17:15:50 +09:00
parent 9b4e99abf9
commit 42e09ae2d1
9 changed files with 312 additions and 135 deletions

View File

@ -1,6 +1,6 @@
from .clinics import router as clinics_router from .clinics import router as clinics_router
from .analysis import router as analysis_router from .analysis import router as analysis_router
from .reports import router as reports_router from .report import router as report_router
from .plans import router as plans_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]

View File

@ -6,13 +6,13 @@ from common.deps import verify_api_key
from integrations.llm.schemas.plan import PlanOutput from integrations.llm.schemas.plan import PlanOutput
from models.plan import PlanApiResponse 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__) 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): 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( row = await fetchone(
"SELECT ar.plan_data, ar.created_at, h.hospital_name, h.hospital_name_en, h.url" "SELECT ar.plan_data, ar.created_at, h.hospital_name, h.hospital_name_en, h.url"
" FROM analysis_runs ar" " FROM analysis_runs ar"
@ -28,9 +28,9 @@ async def get_plan(run_id: str):
plan = PlanOutput(**data) plan = PlanOutput(**data)
return PlanApiResponse( return PlanApiResponse(
id=run_id, id=run_id,
clinicName=row["hospital_name"], clinic_name=row["hospital_name"],
clinicNameEn=row["hospital_name_en"], clinic_name_en=row["hospital_name_en"],
createdAt=str(row["created_at"]), created_at=str(row["created_at"]),
targetUrl=row["url"], target_url=row["url"],
**plan.model_dump(), **plan.model_dump(),
) )

View File

@ -6,13 +6,13 @@ from common.deps import verify_api_key
from integrations.llm.schemas.report import ReportOutput from integrations.llm.schemas.report import ReportOutput
from models.report import MarketingReportResponse 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__) logger = logging.getLogger(__name__)
@router.get("/{run_id}", response_model=MarketingReportResponse, response_model_by_alias=True) @router.get("/{run_id}", response_model=MarketingReportResponse, response_model_by_alias=True)
async def get_report(run_id: str): 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( row = await fetchone(
"SELECT ar.report_data, ar.created_at, h.hospital_name, h.hospital_name_en, h.url" "SELECT ar.report_data, ar.created_at, h.hospital_name, h.hospital_name_en, h.url"
" FROM analysis_runs ar" " FROM analysis_runs ar"
@ -26,10 +26,11 @@ async def get_report(run_id: str):
return Response(status_code=204) return Response(status_code=204)
data = json.loads(row["report_data"]) if isinstance(row["report_data"], str) else row["report_data"] data = json.loads(row["report_data"]) if isinstance(row["report_data"], str) else row["report_data"]
llm_output = ReportOutput(**data) llm_output = ReportOutput(**data)
response = MarketingReportResponse.model_validate(llm_output.model_dump()) return MarketingReportResponse(
response.id = run_id id=run_id,
response.clinic_name = row["hospital_name"] clinic_name=row["hospital_name"],
response.clinic_name_en = row["hospital_name_en"] clinic_name_en=row["hospital_name_en"],
response.created_at = str(row["created_at"]) created_at=str(row["created_at"]),
response.target_url = row["url"] target_url=row["url"],
return response **llm_output.model_dump(exclude={"id", "created_at", "target_url"}),
)

View File

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

View File

@ -1,13 +1,6 @@
from __future__ import annotations from __future__ import annotations
from typing import Literal
from pydantic import BaseModel from pydantic import BaseModel
from models.status import Severity, ChannelStatus, DataSource, Language, VideoType, AnnotationType
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): class ScreenshotAnnotation(BaseModel):

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,19 +1,200 @@
from pydantic import BaseModel from typing import Literal
from integrations.llm.schemas.plan import ( from models.common import CamelModel
BrandGuide, ChannelStrategyCard, ContentStrategyData,
CalendarData, AssetCollectionData, RepurposingProposalItem,
)
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 id: str
clinicName: str | None = None source: Literal["homepage", "naver_place", "blog", "social", "youtube"]
clinicNameEn: str | None = None source_label: str
createdAt: str type: Literal["photo", "video", "text"]
targetUrl: str title: str
brandGuide: BrandGuide description: str
channelStrategies: list[ChannelStrategyCard] repurposing_suggestions: list[str]
contentStrategy: ContentStrategyData 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 calendar: CalendarData
assetCollection: AssetCollectionData asset_collection: AssetCollectionData
repurposingProposals: list[RepurposingProposalItem] | None = None repurposing_proposals: list[RepurposingProposalItem] | None = None

View File

@ -1,17 +1,6 @@
from __future__ import annotations from __future__ import annotations
from pydantic import BaseModel, ConfigDict from models.common import CamelModel
from integrations.llm.schemas.report import ( from models.status import Severity, ChannelStatus, DataSource, Language, VideoType, AnnotationType
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)
class ScreenshotAnnotation(CamelModel): class ScreenshotAnnotation(CamelModel):

View File

@ -14,3 +14,39 @@ class TaskStatus(StrEnum):
START = "start" START = "start"
PROCESSING = "processing" PROCESSING = "processing"
DONE = "done" 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"