diff --git a/app/assets/fonts/Pretendard-Bold.otf b/app/assets/fonts/Pretendard-Bold.otf new file mode 100644 index 0000000..8e5e30a Binary files /dev/null and b/app/assets/fonts/Pretendard-Bold.otf differ diff --git a/app/assets/fonts/Pretendard-ExtraBold.otf b/app/assets/fonts/Pretendard-ExtraBold.otf new file mode 100644 index 0000000..388f3ca Binary files /dev/null and b/app/assets/fonts/Pretendard-ExtraBold.otf differ diff --git a/app/assets/fonts/Pretendard-Medium.otf b/app/assets/fonts/Pretendard-Medium.otf new file mode 100644 index 0000000..0575069 Binary files /dev/null and b/app/assets/fonts/Pretendard-Medium.otf differ diff --git a/app/assets/mascots/mascots.json b/app/assets/mascots/mascots.json new file mode 100644 index 0000000..d500b77 --- /dev/null +++ b/app/assets/mascots/mascots.json @@ -0,0 +1,8 @@ +{ + "files": [ + "기본핀.png", + "행복함핀.png", + "뿌듯핀.png", + "캐릭터_음흉씨핀.png" + ] +} diff --git "a/app/assets/mascots/\352\270\260\353\263\270\355\225\200.png" "b/app/assets/mascots/\352\270\260\353\263\270\355\225\200.png" new file mode 100644 index 0000000..b53f728 Binary files /dev/null and "b/app/assets/mascots/\352\270\260\353\263\270\355\225\200.png" differ diff --git "a/app/assets/mascots/\353\277\214\353\223\257\355\225\200.png" "b/app/assets/mascots/\353\277\214\353\223\257\355\225\200.png" new file mode 100644 index 0000000..ff2789a Binary files /dev/null and "b/app/assets/mascots/\353\277\214\353\223\257\355\225\200.png" differ diff --git "a/app/assets/mascots/\354\272\220\353\246\255\355\204\260_\354\235\214\355\235\211\354\224\250\355\225\200.png" "b/app/assets/mascots/\354\272\220\353\246\255\355\204\260_\354\235\214\355\235\211\354\224\250\355\225\200.png" new file mode 100644 index 0000000..c562673 Binary files /dev/null and "b/app/assets/mascots/\354\272\220\353\246\255\355\204\260_\354\235\214\355\235\211\354\224\250\355\225\200.png" differ diff --git "a/app/assets/mascots/\355\226\211\353\263\265\355\225\250\355\225\200.png" "b/app/assets/mascots/\355\226\211\353\263\265\355\225\250\355\225\200.png" new file mode 100644 index 0000000..364134e Binary files /dev/null and "b/app/assets/mascots/\355\226\211\353\263\265\355\225\250\355\225\200.png" differ diff --git a/app/assets/policy_cardnews_palettes.json b/app/assets/policy_cardnews_palettes.json new file mode 100644 index 0000000..347efa6 --- /dev/null +++ b/app/assets/policy_cardnews_palettes.json @@ -0,0 +1,51 @@ +{ + "palettes": { + "issueissyu_blue": { + "outer": [29, 135, 255], + "offset": [255, 230, 90], + "accent": [29, 135, 255] + }, + + "fresh_mint": { + "outer": [52, 199, 153], + "offset": [214, 255, 236], + "accent": [36, 148, 108] + }, + + "soft_purple": { + "outer": [132, 94, 247], + "offset": [229, 219, 255], + "accent": [96, 64, 201] + }, + + "sunset_orange": { + "outer": [255, 126, 95], + "offset": [255, 225, 180], + "accent": [220, 88, 52] + }, + + "modern_slate": { + "outer": [58, 72, 95], + "offset": [210, 220, 235], + "accent": [35, 45, 65] + }, + + "rose_pink": { + "outer": [238, 108, 133], + "offset": [255, 220, 228], + "accent": [198, 72, 98] + }, + + "deep_ocean": { + "outer": [0, 119, 182], + "offset": [202, 240, 248], + "accent": [2, 62, 138] + }, + + "lime_energy": { + "outer": [140, 198, 62], + "offset": [240, 255, 204], + "accent": [92, 148, 32] + } + } +} diff --git a/app/assets/policy_cardnews_templates/template_cover.json b/app/assets/policy_cardnews_templates/template_cover.json new file mode 100644 index 0000000..778360c --- /dev/null +++ b/app/assets/policy_cardnews_templates/template_cover.json @@ -0,0 +1,35 @@ +{ + "name": "template_cover", + "canvas": { "width": 1080, "height": 1350 }, + "frame": { "card_inset": 36, "content_pad": 36, "offset_px": 12 }, + "layout": { + "text_bottom_ratio_with_hero": 0.44, + "mascot_zone_ratio": 0.52, + "mascot_min_h": 420 + }, + "fonts": { + "eyebrow_start": 34, + "eyebrow_min": 26, + "eyebrow_max_lines": 2, + "headline_start": 72, + "headline_min": 48, + "headline_max_lines": 3, + "highlight_start": 68, + "highlight_min": 44, + "highlight_max_lines": 2 + }, + "highlight": { + "type": "accent_badge", + "radius": 8, + "pad_x": 18, + "pad_y": 5, + "tape_extra_h": 14, + "text_fill": [20, 24, 32] + }, + "hero": { + "enabled": true, + "box_inset": 22, + "corner_radius": 28, + "fill_mode": "contain" + } +} diff --git a/app/assets/policy_cardnews_templates/template_cta.json b/app/assets/policy_cardnews_templates/template_cta.json new file mode 100644 index 0000000..83e761d --- /dev/null +++ b/app/assets/policy_cardnews_templates/template_cta.json @@ -0,0 +1,53 @@ +{ + "name": "template_cta", + "canvas": { "width": 1080, "height": 1350 }, + "frame": { "card_inset": 36, "content_pad": 36, "offset_px": 12 }, + "defaults": { + "eyebrow": "마무리", + "headline": "자세한 내용은 원문에서", + "cta": "원문 뉴스 보기", + "speech": "궁금하면 원문 봐!", + "speech_max_len": 18 + }, + "layout": { + "gap_above_mascot": 36, + "panel_inset": 22, + "content_scale_base": 180, + "content_scale_min": 1.08, + "content_scale_max": 1.48, + "separator_width": 2 + }, + "fonts": { + "url_start": 20, + "url_min": 18, + "body_start": 24, + "body_min": 20, + "term_title": 22, + "term_body": 20 + }, + "cta_panel": { + "height_start": 96, + "height_min": 76, + "height_max": 108, + "radius": 14, + "label_start": 40, + "label_min": 28, + "inset_panel": 22 + }, + "url": { + "display_max": 34, + "prefix": "앱에서 링크 · " + }, + "term_guide": { + "title": "쉬운 말로", + "panel_radius": 12, + "max_guides": 2, + "max_body_lines": 3 + }, + "header": { + "eyebrow_start": 32, + "eyebrow_min": 24, + "headline_start": 52, + "headline_min": 36 + } +} diff --git a/app/assets/policy_cardnews_templates/template_grid.json b/app/assets/policy_cardnews_templates/template_grid.json new file mode 100644 index 0000000..994fc93 --- /dev/null +++ b/app/assets/policy_cardnews_templates/template_grid.json @@ -0,0 +1,24 @@ +{ + "name": "template_grid", + "canvas": { "width": 1080, "height": 1350 }, + "frame": { "card_inset": 36, "content_pad": 36, "offset_px": 12 }, + "defaults": { + "headline": "이렇게 확인하세요", + "label_prefix": "포인트" + }, + "layout": { + "max_items": 4, + "gap_tile": 18, + "tile_min_h": 120, + "tile_pad": 24, + "panel_radius": 14, + "ribbon_ratio": 0.28, + "ribbon_min_h": 52 + }, + "fonts": { + "label_start": 42, + "label_min": 30, + "body_start": 44, + "body_min": 30 + } +} diff --git a/app/assets/policy_cardnews_templates/template_numbered.json b/app/assets/policy_cardnews_templates/template_numbered.json new file mode 100644 index 0000000..e5e0fc3 --- /dev/null +++ b/app/assets/policy_cardnews_templates/template_numbered.json @@ -0,0 +1,30 @@ +{ + "name": "template_numbered", + "canvas": { "width": 1080, "height": 1350 }, + "frame": { "card_inset": 36, "content_pad": 36, "offset_px": 12 }, + "defaults": { + "eyebrow": "한 장 요약", + "speech": "핵심만!", + "speech_max_len": 18 + }, + "layout": { + "max_items": 4, + "mascot_max_items": 3, + "panel_inset": 22, + "gap_row": 14, + "row_min_h": 48, + "scale_base_per_row": 64, + "scale_max": 1.65, + "separator_width": 2, + "separator_inset_left": 56, + "max_wrap_lines": 3 + }, + "fonts": { + "row_title_start": 36, + "row_title_min": 28, + "row_title_max": 48, + "num_start": 30, + "num_min": 24, + "num_max": 38 + } +} diff --git a/app/assets/policy_cardnews_templates/template_three_col.json b/app/assets/policy_cardnews_templates/template_three_col.json new file mode 100644 index 0000000..d6dc40f --- /dev/null +++ b/app/assets/policy_cardnews_templates/template_three_col.json @@ -0,0 +1,29 @@ +{ + "name": "template_three_col", + "canvas": { "width": 1080, "height": 1350 }, + "frame": { "card_inset": 36, "content_pad": 36, "offset_px": 12 }, + "defaults": { + "headline": "핵심만 정리", + "speech": "핵심포인트!", + "speech_max_len": 18, + "label_prefix": "항목" + }, + "layout": { + "max_items": 3, + "col_gap": 20, + "panel_radius": 14, + "panel_inset": 22, + "badge_min": 44, + "badge_max": 56, + "badge_ratio": 0.13, + "label_h_ratio": 0.18, + "label_h_min": 36 + }, + "fonts": { + "badge_num_start": 22, + "label_start": 32, + "label_min": 24, + "body_start": 30, + "body_min": 22 + } +} diff --git a/app/clients/PolicyNewsClient.py b/app/clients/PolicyNewsClient.py new file mode 100644 index 0000000..586051c --- /dev/null +++ b/app/clients/PolicyNewsClient.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +import asyncio +import logging +from typing import Any +from urllib.parse import urlencode + +import httpx + +from app.core.config import Settings, settings +from app.utils.policy_news_parse import parse_policy_news_xml, policy_result_ok + +logger = logging.getLogger(__name__) + +ENDPOINT_POLICY_NEWS_LIST = "policyNewsList" + +class PolicyNewsClient: + # 문화체육관광부 정책브리핑 정책뉴스 OpenAPI (공공데이터포털) + + def __init__( + self, + *, + service_key: str, + base_url: str = settings.policy_news_api_base_url, + timeout_seconds: float = settings.policy_news_request_timeout_seconds, + request_interval_seconds: float = settings.policy_news_request_interval_seconds, + http_client: httpx.AsyncClient | None = None, + ) -> None: + self._service_key = service_key + self._base_url = base_url.rstrip("/") + self._timeout = timeout_seconds + self._interval = max(0.0, request_interval_seconds) + self._http = http_client + self._owns_client = http_client is None + + @classmethod + def from_settings(cls, config: Settings | None = None) -> PolicyNewsClient: + s = config or settings + secret = s.policy_news_service_key + if secret is None: + raise RuntimeError( + "POLICY_NEWS_SERVICE_KEY가 설정되어 있지 않습니다.", + ) + return cls( + service_key=secret.get_secret_value(), + base_url=s.policy_news_api_base_url, + timeout_seconds=s.policy_news_request_timeout_seconds, + request_interval_seconds=s.policy_news_request_interval_seconds, + ) + + async def __aenter__(self) -> PolicyNewsClient: + if self._http is None: + self._http = httpx.AsyncClient(timeout=self._timeout) + return self + + async def __aexit__(self, *args: object) -> None: + if self._owns_client and self._http is not None: + await self._http.aclose() + + async def _get_xml(self, endpoint: str, **params: Any) -> str: + if self._http is None: + raise RuntimeError( + "PolicyNewsClient is not started; use async with PolicyNewsClient.from_settings()", + ) + query_params = { + "serviceKey": self._service_key, + **{k: str(v) for k, v in params.items() if v is not None}, + } + query = urlencode(query_params) + url = f"{self._base_url}/{endpoint}?{query}" + response = await self._http.get(url) + response.raise_for_status() + if self._interval > 0: + await asyncio.sleep(self._interval) + return response.text + + async def policy_news_list( + self, + *, + start_date: str, + end_date: str, + ) -> tuple[dict[str, str], list[dict[str, str]]]: + xml_text = await self._get_xml( + ENDPOINT_POLICY_NEWS_LIST, + startDate=start_date, + endDate=end_date, + ) + header, items = parse_policy_news_xml(xml_text) + ok, msg = policy_result_ok(header) + if not ok: + raise RuntimeError(f"policyNewsList 실패 ({start_date}~{end_date}): {msg}") + return header, items diff --git a/app/core/config.py b/app/core/config.py index 07305cf..3ec72f9 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -159,6 +159,120 @@ class Settings(BaseSettings): alias="VECTOR_TEXT_SEARCH_CONFIG", ) + # 문화체육관광부 정책브리핑 정책뉴스 OpenAPI (공공데이터포털) + policy_news_service_key: SecretStr | None = Field( + default=None, + alias="POLICY_NEWS_SERVICE_KEY", + description="정책뉴스 전용 API 키 (서비스별 독립 키, 필수)", + ) + policy_news_api_base_url: str = Field( + default="http://apis.data.go.kr/1371000/policyNewsService", + alias="POLICY_NEWS_API_BASE_URL", + ) + policy_news_request_timeout_seconds: float = Field( + default=30.0, + gt=0, + alias="POLICY_NEWS_REQUEST_TIMEOUT_SECONDS", + ) + policy_news_request_interval_seconds: float = Field( + default=0.15, + ge=0, + alias="POLICY_NEWS_REQUEST_INTERVAL_SECONDS", + ) + policy_sync_lookback_days: int = Field( + default=3, + ge=1, + le=30, + alias="POLICY_SYNC_LOOKBACK_DAYS", + description="배치 수집 시 오늘 포함 N일 (API 3일 제한 고려)", + ) + policy_sync_interval_days: int = Field( + default=3, + ge=1, + le=30, + alias="POLICY_SYNC_INTERVAL_DAYS", + description="자동 sync 최소 간격(일)", + ) + policy_sync_schedule_hour_kst: int = Field( + default=1, + ge=0, + le=23, + alias="POLICY_SYNC_SCHEDULE_HOUR_KST", + description="정책 핀 sync 스케줄 확인 시각 (KST)", + ) + policy_admin_user_name: str = Field( + default="admin", + validation_alias=AliasChoices("POLICY_ADMIN_USER_NAME", "POLICY_ADMIN_NICKNAME"), + description="정책 핀 등록에 사용할 user.user_name", + ) + policy_sync_batch_size: int = Field( + default=5, + ge=1, + le=25, + alias="POLICY_SYNC_BATCH_SIZE", + description="sync/transform/import 1회 배치 건수", + ) + policy_transform_concurrency: int = Field( + default=3, + ge=1, + le=10, + alias="POLICY_TRANSFORM_CONCURRENCY", + description="정책 pin_content·카드뉴스 Gemini 가공 동시 호출 수", + ) + policy_prune_pipeline_after_import: bool = Field( + default=True, + alias="POLICY_PRUNE_PIPELINE_AFTER_IMPORT", + description="DB INSERT 성공 후 JSONL·로컬 카드뉴스 캐시 제거", + ) + policy_cardnews_keep_local_files: bool = Field( + default=False, + alias="POLICY_CARDNEWS_KEEP_LOCAL_FILES", + description="True면 S3 업로드 후에도 rag/output/policy_cardnews 유지", + ) + policy_sync_merge_documents: bool = Field( + default=False, + alias="POLICY_SYNC_MERGE_DOCUMENTS", + description="False면 sync 수집 시 policy_documents.jsonl을 이번 구간으로 덮어씀", + ) + policy_cardnews_s3_prefix: str = Field( + default="policy-cardnews", + alias="POLICY_CARDNEWS_S3_PREFIX", + description="정책 카드뉴스 S3 object key prefix", + ) + gemini_cardnews_image_model: str = Field( + default="gemini-2.5-flash-image", + alias="GEMINI_CARDNEWS_IMAGE_MODEL", + description="정책 카드뉴스 슬라이드 이미지 생성 모델", + ) + gemini_cardnews_image_fallback_models: str = Field( + default="gemini-3-pro-image-preview,imagen-3.0-generate-002", + alias="GEMINI_CARDNEWS_IMAGE_FALLBACK_MODELS", + ) + policy_cardnews_pillow_fallback: bool = Field( + default=True, + alias="POLICY_CARDNEWS_PILLOW_FALLBACK", + description="이미지 모델 실패 시 Pillow 템플릿 합성으로 폴백", + ) + policy_cardnews_use_template: bool = Field( + default=True, + alias="POLICY_CARDNEWS_USE_TEMPLATE", + description="고정 카드뉴스 템플릿(레퍼런스 형식) 사용", + ) + policy_cardnews_use_image_model: bool = Field( + default=False, + alias="POLICY_CARDNEWS_USE_IMAGE_MODEL", + description="True면 Gemini 이미지 모델, False면 Pillow SNS 템플릿", + ) + rag_retrieve_top_k: int = Field(default=10, ge=1, le=100, alias="RAG_RETRIEVE_TOP_K") + rag_rerank_top_k: int = Field(default=5, ge=1, le=100, alias="RAG_RERANK_TOP_K") + rag_enable_rerank: bool = Field(default=False, alias="RAG_ENABLE_RERANK") + rag_vector_query_mode: str = Field(default="hybrid", alias="RAG_VECTOR_QUERY_MODE") + policy_cardnews_font_dir: str = Field( + default="../assets/fonts", + alias="POLICY_CARDNEWS_FONT_DIR", + description="Pretendard 등 폰트 폴더 (app/policy_cardnews 기준 상대 경로)", + ) + # 한국관광공사 TourAPI (공공데이터포털 활용신청 키) visitkorea_service_key: SecretStr | None = Field( default=None, @@ -206,6 +320,25 @@ class Settings(BaseSettings): alias="FESTIVAL_TRANSFORM_CONCURRENCY", description="축제 pin_content Gemini 가공 동시 호출 수 (Cron/API 공통)", ) + policy_cardnews_mascot_dir: str | None = Field( + default="app/assets/mascots", + alias="POLICY_CARDNEWS_MASCOT_DIR", + description="핀 캐릭터 PNG 폴더. mascots.json files 목록에 있는 PNG만 사용", + ) + + @field_validator("policy_cardnews_font_dir", mode="before") + @classmethod + def _empty_string_policy_cardnews_font_dir(cls, value: object) -> object: + if value == "": + return "../assets/fonts" + return value + + @field_validator("policy_cardnews_mascot_dir", mode="before") + @classmethod + def _empty_string_policy_cardnews_mascot_dir(cls, value: object) -> object: + if value == "": + return None + return value festival_batch_size: int = Field( default=10, ge=1, diff --git a/app/core/deps.py b/app/core/deps.py index 2b69e12..3bbefe5 100644 --- a/app/core/deps.py +++ b/app/core/deps.py @@ -11,6 +11,7 @@ from app.core.exceptions import raise_business_exception from app.login.http_auth import get_current_user_id, get_optional_user_id from app.models.User import User +from app.repositories.CardnewsImageS3Repo import CardnewsImageS3Repo from app.repositories.CommunityRepo import CommunityRepo from app.repositories.ComplaintPetitionRepo import ComplaintPetitionRepo from app.repositories.DepartmentRepo import DepartmentRepo @@ -28,8 +29,11 @@ from app.services.internal.IssuePinBackgroundRunner import IssuePinBackgroundRunner from app.services.UserService import UserService from app.services.ComplaintEmailService import ComplaintEmailService -from app.services.ComplaintPetitionService import ComplaintPetitionService from app.services.FestivalPinService import FestivalPinService +from app.services.PolicyEventIngestService import PolicyEventIngestService +from app.services.PolicyPinService import PolicyPinService +from app.services.internal.PolicyPinSchedulerService import PolicyPinSchedulerService +from app.services.ComplaintPetitionService import ComplaintPetitionService from app.services.FestivalEventIngestService import FestivalEventIngestService from app.services.RagRerankService import RagRerankService from app.services.RagRetrievalService import RagRetrievalService @@ -508,3 +512,57 @@ def get_festival_event_ingest_service( FestivalEventIngestService, Depends(get_festival_event_ingest_service), ] + + +def get_policy_pin_service() -> PolicyPinService: + return PolicyPinService() + + +PolicyPinServiceDep = Annotated[ + PolicyPinService, + Depends(get_policy_pin_service), +] + + +def get_cardnews_image_s3_repo(session: DbSessionDep) -> CardnewsImageS3Repo: + return CardnewsImageS3Repo(session) + + +CardnewsImageS3RepoDep = Annotated[CardnewsImageS3Repo, Depends(get_cardnews_image_s3_repo)] + + +def get_policy_event_ingest_service( + pin_repo: PinRepoDep, + event_pin_repo: EventPinRepoDep, + community_repo: CommunityRepoDep, + cardnews_image_s3_repo: CardnewsImageS3RepoDep, + user_repo: UserRepoDep, +) -> PolicyEventIngestService: + return PolicyEventIngestService( + pin_repo=pin_repo, + event_pin_repo=event_pin_repo, + community_repo=community_repo, + cardnews_image_s3_repo=cardnews_image_s3_repo, + user_repo=user_repo, + ) + + +PolicyEventIngestServiceDep = Annotated[ + PolicyEventIngestService, + Depends(get_policy_event_ingest_service), +] + + +def get_policy_pin_scheduler(request: Request) -> PolicyPinSchedulerService | None: + scheduler = getattr(request.app.state, "policy_pin_scheduler", None) + if scheduler is None: + return None + if not isinstance(scheduler, PolicyPinSchedulerService): + raise RuntimeError("policy_pin_scheduler is not initialized correctly.") + return scheduler + + +PolicyPinSchedulerDep = Annotated[ + PolicyPinSchedulerService | None, + Depends(get_policy_pin_scheduler), +] diff --git a/app/main.py b/app/main.py index 706f834..c274e21 100644 --- a/app/main.py +++ b/app/main.py @@ -1,5 +1,7 @@ from contextlib import asynccontextmanager +import asyncio import logging +import sys from typing import Any import httpx @@ -21,10 +23,15 @@ from app.services.RagRetrievalService import RagRetrievalService from app.services.VectorStoreService import VectorStoreService from app.services.internal.ComplaintPetitionSchedulerService import ComplaintPetitionSchedulerService +from app.services.internal.PolicyPinSchedulerService import PolicyPinSchedulerService from app.services.internal.ai.ComplaintEmailLLMService import ComplaintEmailLLMService from app.services.internal.ai.VLMService import VLMService from app.services.internal.ai.gemini_retry import parse_gemini_model_list -from app.services.vector_domains import build_vector_domain_configs +from app.services.vector_domains import ( + DomainVectorConfig, + VectorDomain, + build_vector_domain_configs, +) from app.schemas.IssueDTO import ( CreateIssuePinMultipartRequest, PinImageIsMainItem, @@ -49,7 +56,27 @@ def _configure_local_logging() -> None: logger.info("Local logging level configured to INFO") +def _configure_windows_event_loop_for_subprocess() -> None: + """ + Windows에서 asyncio subprocess_exec가 NotImplementedError가 나는 경우가 있습니다. + Playwright(Chromium) 같은 컴포넌트가 subprocess를 띄우기 때문에 + 이벤트 루프 정책을 Proactor로 맞춰줍니다. + """ + if sys.platform != "win32": + return + + try: + policy = asyncio.get_event_loop_policy() + if policy.__class__.__name__ == "WindowsSelectorEventLoopPolicy": + asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) + logger.info("Windows event loop policy switched to Proactor for subprocess support.") + except Exception: + # 정책 변경 실패해도 서버 자체는 계속 동작해야 합니다. + logger.debug("Windows event loop policy switch failed", exc_info=True) + + _configure_local_logging() +_configure_windows_event_loop_for_subprocess() @asynccontextmanager @@ -128,7 +155,7 @@ async def lifespan(app: FastAPI): exc, ) - scheduler = None + complaint_scheduler = None if ( gemini_api_key_secret is not None and getattr(app.state, "vector_store_service", None) is not None @@ -167,24 +194,39 @@ async def lifespan(app: FastAPI): complaint_llm_service=complaint_llm_service, rag_retrieval_service=rag_retrieval_service, ) - scheduler = ComplaintPetitionSchedulerService( + complaint_scheduler = ComplaintPetitionSchedulerService( complaint_email_service=complaint_email_service, s3_util=app.state.s3_util, ) - scheduler.start() - app.state.complaint_scheduler = scheduler + complaint_scheduler.start() + app.state.complaint_scheduler = complaint_scheduler except Exception as exc: logger.warning("Complaint scheduler initialization failed: %s", exc) + policy_pin_scheduler = None + if settings.policy_news_service_key is not None and gemini_api_key_secret is not None: + try: + policy_pin_scheduler = PolicyPinSchedulerService(s3_util=app.state.s3_util) + policy_pin_scheduler.start() + app.state.policy_pin_scheduler = policy_pin_scheduler + except Exception as exc: + logger.warning("Policy pin scheduler initialization failed: %s", exc) + try: yield finally: - scheduler = getattr(app.state, "complaint_scheduler", None) - if scheduler is not None: + complaint_scheduler = getattr(app.state, "complaint_scheduler", None) + if complaint_scheduler is not None: try: - await scheduler.stop() + await complaint_scheduler.stop() except Exception as exc: logger.warning("Complaint scheduler stop failed: %s", exc) + policy_pin_scheduler = getattr(app.state, "policy_pin_scheduler", None) + if policy_pin_scheduler is not None: + try: + await policy_pin_scheduler.stop() + except Exception as exc: + logger.warning("Policy pin scheduler stop failed: %s", exc) try: await ComplaintEmailPdfService.stop_playwright_browser() except Exception as exc: diff --git a/app/models/CardnewsImageS3.py b/app/models/CardnewsImageS3.py new file mode 100644 index 0000000..dea4764 --- /dev/null +++ b/app/models/CardnewsImageS3.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from sqlalchemy import BigInteger, ForeignKey, Identity, String +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.core.database import Base +from app.models.Community import Community + + +class CardnewsImageS3(Base): + __tablename__ = "cardnews_image_s3" + + cardnews_image_s3_id: Mapped[int] = mapped_column( + "cardnews_image_s3_id", + BigInteger, + Identity(), + primary_key=True, + ) + community_id: Mapped[int] = mapped_column( + "community_id", + BigInteger, + ForeignKey("community.community_id"), + nullable=False, + ) + community: Mapped[Community] = relationship( + "Community", + foreign_keys=[community_id], + back_populates="cardnews_images", + lazy="selectin", + ) + cardnews_image_s3_key: Mapped[str] = mapped_column( + "cardnews_image_s3_key", + String(500), + nullable=False, + ) + cardnews_image_s3_url: Mapped[str] = mapped_column( + "cardnews_image_s3_url", + String(500), + nullable=False, + ) diff --git a/app/models/Community.py b/app/models/Community.py index e77af6e..9dc6d48 100644 --- a/app/models/Community.py +++ b/app/models/Community.py @@ -1,7 +1,7 @@ from __future__ import annotations -from sqlalchemy import BigInteger, ForeignKey, Identity -from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy import BigInteger, Float, ForeignKey, Identity, String +from sqlalchemy.orm import Mapped, mapped_column, relationship from app.models.BaseEntity import BaseEntity @@ -21,3 +21,15 @@ class Community(BaseEntity): ForeignKey("pin.pin_id"), nullable=True, ) + community_type: Mapped[str] = mapped_column( + "community_type", + String(50), + nullable=False, + server_default="POLICY", + ) + popularity: Mapped[float] = mapped_column("popularity", Float, nullable=False, default=0.0) + cardnews_images: Mapped[list["CardnewsImageS3"]] = relationship( + "CardnewsImageS3", + back_populates="community", + lazy="selectin", + ) diff --git a/app/models/EventPin.py b/app/models/EventPin.py index 313e29d..519d918 100644 --- a/app/models/EventPin.py +++ b/app/models/EventPin.py @@ -31,12 +31,18 @@ class EventPin(BaseEntity): back_populates="event_pin", lazy="selectin", ) - festival_api_id: Mapped[int] = mapped_column( + festival_api_id: Mapped[int | None] = mapped_column( "festival_api_id", BigInteger, unique=True, nullable=True, ) + policy_api_id: Mapped[int | None] = mapped_column( + "policy_api_id", + BigInteger, + unique=True, + nullable=True, + ) event_start_time: Mapped[datetime] = mapped_column("event_start_time", DateTime, nullable=False) event_end_time: Mapped[datetime] = mapped_column("event_end_time", DateTime, nullable=False) discount: Mapped[str | None] = mapped_column("discount", String(255), nullable=True) diff --git a/app/models/__init__.py b/app/models/__init__.py index d80d2fd..05dfb18 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -1,3 +1,5 @@ +from app.models.CardnewsImageS3 import CardnewsImageS3 +from app.models.Community import Community from app.models.ComplaintPetition import ComplaintPetition from app.models.Department import Department from app.models.DbConnectionCheck import DbConnectionCheck @@ -24,6 +26,8 @@ from app.models.enum.UserRole import UserRole __all__ = [ + "CardnewsImageS3", + "Community", "DbConnectionCheck", "ComplaintPetition", "Department", diff --git a/app/policy_cardnews/__init__.py b/app/policy_cardnews/__init__.py new file mode 100644 index 0000000..593c995 --- /dev/null +++ b/app/policy_cardnews/__init__.py @@ -0,0 +1,16 @@ +# 정책 카드뉴스 렌더링 패키지 + +from app.policy_cardnews.images import download_cardnews_image, download_cardnews_images +from app.policy_cardnews.render import ( + render_policy_cardnews_slides, + save_slide_image_bytes, +) +from app.policy_cardnews.slides import parse_cardnews_slides_json + +__all__ = [ + "download_cardnews_image", + "download_cardnews_images", + "parse_cardnews_slides_json", + "render_policy_cardnews_slides", + "save_slide_image_bytes", +] diff --git a/app/policy_cardnews/constants.py b/app/policy_cardnews/constants.py new file mode 100644 index 0000000..38be7cc --- /dev/null +++ b/app/policy_cardnews/constants.py @@ -0,0 +1,20 @@ +# 정책 카드뉴스 공통 상수 + +from __future__ import annotations + +# Canvas +CANVAS_WIDTH = 1080 +CANVAS_HEIGHT = 1350 + +# Card frame +CARD_INSET = 36 +CONTENT_PAD = 36 +OFFSET_PX = 12 + +# Spacing +GAP_SECTION = 22 +GAP_BLOCK = 16 +GAP_LINE_SM = 8 +GAP_LINE_MD = 12 +GAP_LINE_LG = 16 + diff --git a/app/policy_cardnews/copy.py b/app/policy_cardnews/copy.py new file mode 100644 index 0000000..6cbbac2 --- /dev/null +++ b/app/policy_cardnews/copy.py @@ -0,0 +1,257 @@ +from __future__ import annotations + +import re +from typing import Any + +from app.policy_cardnews.terms import simplify_policy_text + +# LLM이 자주 쓰는 의미 없는 채움 문구 +_FILLER_PATTERNS = ( + r"정책\s*현장\s*사진", + r"한눈에\s*보기", + r"핵심만\s*정리", + r"확인해\s*보세요\.?$", + r"확인하세요\.?$", + r"더\s*자세히\s*알아보", +) + +_FILLER_RE = re.compile("|".join(f"(?:{p})" for p in _FILLER_PATTERNS), re.IGNORECASE) + +_SPELLING_FIXES: tuple[tuple[re.Pattern[str], str], ...] = ( + (re.compile(r"되요\b"), "돼요"), + (re.compile(r"안되요"), "안 돼요"), + (re.compile(r"안돼요"), "안 돼요"), + (re.compile(r"확인해주세요"), "확인해 주세요"), + (re.compile(r"신청해주세요"), "신청해 주세요"), + (re.compile(r"알려주세요"), "알려 주세요"), + (re.compile(r"해보세요"), "해 보세요"), + (re.compile(r"해당되는지"), "해당하는지"), + (re.compile(r"\s{2,}"), " "), +) + + +def polish_korean_text(text: str) -> str: + value = (text or "").strip() + if not value: + return "" + for pattern, replacement in _SPELLING_FIXES: + value = pattern.sub(replacement, value) + return value.strip() + + +def is_filler_text(text: str) -> bool: + value = (text or "").strip() + if not value: + return True + if len(value) < 6 and value.endswith(("!", "?", ".")): + return False + return bool(_FILLER_RE.search(value)) + + +def normalize_slide_copy(slide: dict[str, Any]) -> dict[str, Any]: + row = dict(slide) + layout = str(row.get("layout_type") or "cover_big_typo") + + for key in ("eyebrow", "headline", "highlight", "subtext", "body", "cta", "speech"): + row[key] = simplify_policy_text(polish_korean_text(str(row.get(key) or ""))) + + simplified_items: list[dict[str, str]] = [] + for item in row.get("items") or []: + if isinstance(item, dict): + simplified_items.append( + { + "label": simplify_policy_text(polish_korean_text(str(item.get("label") or ""))), + "text": simplify_policy_text(polish_korean_text(str(item.get("text") or ""))), + } + ) + elif str(item).strip(): + simplified_items.append({"label": "", "text": simplify_policy_text(polish_korean_text(str(item)))}) + if simplified_items: + row["items"] = simplified_items + + highlight = row["highlight"] + headline = row["headline"] + body = row["body"] + subtext = row["subtext"] + speech = row["speech"] + + if highlight and headline and highlight == headline: + if layout in {"cover_big_typo", "template_cover"}: + headline = "" + else: + highlight = "" + + if body and is_filler_text(body): + body = "" + if subtext and is_filler_text(subtext): + subtext = "" + if headline and is_filler_text(headline) and layout != "cover_big_typo": + headline = "" + + if not body and subtext: + body = subtext + subtext = "" + + if not headline and highlight and layout != "cover_big_typo": + headline = highlight + highlight = "" + + if not speech: + speech = derive_speech(row, layout=layout) + + row["highlight"] = highlight + row["headline"] = headline + row["body"] = body + row["subtext"] = subtext + row["speech"] = speech + return row + + +_SPEECH_MAX_CHARS = 10 + + +def _trim_speech(text: str, max_chars: int = _SPEECH_MAX_CHARS) -> str: + """말풍선에 들어갈 짧은 문구 — 단어 경계에서 자름.""" + value = (text or "").strip() + if not value: + return "" + # 이미 짧으면 그대로 + if len(value) <= max_chars: + return value + # 띄어쓰기 기준으로 잘라낸 뒤 말줄임 + trimmed = value[:max_chars].rstrip() + # 마지막 조사/어미가 붙다 잘리는 것 방지: 공백 이전으로 후퇴 + if " " in value[:max_chars]: + trimmed = value[:max_chars].rsplit(" ", 1)[0] + return trimmed.rstrip(".,!?") + "!" + + +def derive_speech(slide: dict[str, Any], *, layout: str) -> str: + explicit = polish_korean_text(str(slide.get("speech") or "")) + if explicit: + # 8자 이내 단어·구만 사용 (긴 문장은 말풍선에 부적합) + if len(explicit) <= _SPEECH_MAX_CHARS: + return explicit + # 길면 비워서 말풍선 자체를 생략 + explicit = "" + + if layout in {"cta", "template_cta"}: + cta = polish_korean_text(str(slide.get("cta") or "")) + if cta and len(cta) <= _SPEECH_MAX_CHARS: + return cta.rstrip(".!") + "!" + + for candidate in ( + slide.get("highlight"), + slide.get("headline"), + ): + text = polish_korean_text(str(candidate or "")) + if text and not is_filler_text(text): + if len(text) <= _SPEECH_MAX_CHARS: + if layout in {"cover_big_typo", "template_cover", "cta", "template_cta"}: + return text.rstrip(".!") + "!" + # 너무 길면 건너뜀 (말풍선 잘림 방지) + + if layout in {"cover_big_typo", "template_cover"}: + return "꼭 확인해요!" + if layout in {"cta", "template_cta"}: + return "원문 확인!" + return "" + + +def slide_content_score(slide: dict[str, Any]) -> int: + score = 0 + for key in ("eyebrow", "headline", "highlight", "subtext", "body", "cta", "speech"): + score += len(str(slide.get(key) or "").strip()) + for item in slide.get("items") or []: + if isinstance(item, dict): + score += len(str(item.get("label") or "")) + len(str(item.get("text") or "")) + else: + score += len(str(item).strip()) + return score + + +def _merge_slides(target: dict[str, Any], source: dict[str, Any]) -> dict[str, Any]: + merged = dict(target) + merged_items = list(merged.get("items") or []) + seen = {str(i.get("text") if isinstance(i, dict) else i).strip() for i in merged_items} + + for item in source.get("items") or []: + text = str(item.get("text") if isinstance(item, dict) else item).strip() + if text and text not in seen: + merged_items.append(item if isinstance(item, dict) else {"label": "", "text": text}) + seen.add(text) + merged["items"] = merged_items[:8] + + for field in ("body", "headline", "highlight", "subtext"): + if not str(merged.get(field) or "").strip(): + value = str(source.get(field) or "").strip() + if value and not is_filler_text(value): + merged[field] = value + + if len(merged.get("items") or []) >= 2: + merged["layout_type"] = "info_blocks" + merged["speech"] = "" + return merged + + +def compact_cardnews_slides(slides: list[dict[str, Any]]) -> list[dict[str, Any]]: + # 여백 많은 중간 슬라이드는 이전 장에 병합하거나 제외 + if not slides: + return [] + + working = [dict(s) for s in slides[:3]] + working[0]["layout_type"] = "cover_big_typo" + if len(working) > 1: + working[-1]["layout_type"] = "cta" + # 템플릿 모드에서는 렌더 단계에서 layout 재매핑 + + merged: list[dict[str, Any]] = [] + min_middle_score = 45 + + for slide in working: + layout = str(slide.get("layout_type") or "") + score = slide_content_score(slide) + + if layout in {"cover_big_typo", "cta"}: + merged.append(slide) + continue + + if score < 20: + continue + if score < min_middle_score and merged: + # 표지(cover_big_typo)나 CTA(cta)는 병합 대상으로 사용하지 않고 그대로 유지 + last_layout = str(merged[-1].get("layout_type") or "") + if last_layout not in {"cover_big_typo", "cta"}: + merged[-1] = _merge_slides(merged[-1], slide) + else: + merged.append(slide) + continue + merged.append(slide) + + if len(merged) == 1 and len(working) > 1 and str(working[-1].get("layout_type")) == "cta": + merged.append(working[-1]) + + result: list[dict[str, Any]] = [] + for index, slide in enumerate(merged[:3], start=1): + row = dict(slide) + row["slide"] = index + result.append(row) + return result + + +def is_slide_empty(slide: dict[str, Any]) -> bool: + layout = str(slide.get("layout_type") or "") + score = slide_content_score(slide) + if layout in {"cover_big_typo", "template_cover"}: + return not bool( + str(slide.get("headline") or "").strip() + or str(slide.get("highlight") or "").strip() + or str(slide.get("body") or "").strip() + ) + if layout in {"cta", "template_cta"}: + return not ( + bool(str(slide.get("cta") or "").strip()) or bool(str(slide.get("headline") or "").strip()) + ) + if layout in {"info_blocks", "template_numbered", "template_three_col", "template_grid"}: + return score < 50 and len(slide.get("items") or []) < 3 + return score < 40 diff --git a/app/policy_cardnews/images.py b/app/policy_cardnews/images.py new file mode 100644 index 0000000..1faf9cd --- /dev/null +++ b/app/policy_cardnews/images.py @@ -0,0 +1,66 @@ +# 정책 카드뉴스 원문·커버 이미지 다운로드 + +from __future__ import annotations + +import logging +from io import BytesIO + +import httpx +from PIL import Image, UnidentifiedImageError + +logger = logging.getLogger(__name__) + + +async def download_cardnews_image( + url: str, + *, + timeout: float = 20.0, + referer: str = "", +) -> Image.Image | None: + if not url.startswith(("http://", "https://")): + return None + headers = { + "User-Agent": ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36" + ), + "Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8", + } + if referer: + headers["Referer"] = referer + try: + async with httpx.AsyncClient(timeout=timeout, follow_redirects=True, headers=headers) as client: + response = await client.get(url) + response.raise_for_status() + with Image.open(BytesIO(response.content)) as img: + return img.convert("RGBA") + except (httpx.HTTPError, UnidentifiedImageError, OSError): + logger.warning("카드뉴스 배경 이미지 다운로드 실패: %s", url) + return None + + +async def download_cardnews_images( + urls: list[str], + *, + timeout: float = 20.0, + referer: str = "", +) -> list[Image.Image]: + import asyncio + + unique_urls: list[str] = [] + seen: set[str] = set() + for url in urls: + url = (url or "").strip() + if not url or url in seen: + continue + seen.add(url) + unique_urls.append(url) + + if not unique_urls: + return [] + + tasks = [ + download_cardnews_image(url, timeout=timeout, referer=referer) for url in unique_urls + ] + results = await asyncio.gather(*tasks) + return [img for img in results if img is not None] diff --git a/app/policy_cardnews/mascot.py b/app/policy_cardnews/mascot.py new file mode 100644 index 0000000..6389bd0 --- /dev/null +++ b/app/policy_cardnews/mascot.py @@ -0,0 +1,330 @@ +from __future__ import annotations + +import json +import logging +import random +from pathlib import Path + +from PIL import Image, ImageDraw, ImageFont + +logger = logging.getLogger(__name__) + +CANVAS_WIDTH = 1080 +CANVAS_HEIGHT = 1350 +MANIFEST_NAME = "mascots.json" + +_REPO_ROOT = Path(__file__).resolve().parents[2] +_DEFAULT_MASCOT_DIR = _REPO_ROOT / "app" / "assets" / "mascots" + +BRAND_BLUE = (29, 135, 255) +BRAND_ACCENT = (255, 255, 255) +BRAND_HIGHLIGHT = (255, 230, 90) +INK_BLACK = (18, 20, 26) + +_MASCOT_CACHE: tuple[tuple[str, Image.Image], ...] | None = None +_MASCOT_CACHE_KEY: tuple[str, float] | None = None +_ALLOWED_NAMES: frozenset[str] | None = None + + +def mascot_dir() -> Path: + from app.core.config import settings + + configured = (settings.policy_cardnews_mascot_dir or "").strip() + if configured: + return Path(configured) + return _DEFAULT_MASCOT_DIR + + +def _manifest_mtime(directory: Path) -> float: + manifest = directory / MANIFEST_NAME + if not manifest.is_file(): + return 0.0 + return manifest.stat().st_mtime + + +def _read_manifest_filenames(directory: Path) -> list[str]: + manifest = directory / MANIFEST_NAME + if not manifest.is_file(): + logger.warning( + "mascots.json 없음 — 캐릭터 미사용. %s/%s 에 files 목록을 작성하세요.", + directory, + MANIFEST_NAME, + ) + return [] + + try: + payload = json.loads(manifest.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + logger.warning("mascots.json 읽기 실패: %s", exc) + return [] + + if isinstance(payload, list): + return [str(name).strip() for name in payload if str(name).strip()] + if isinstance(payload, dict): + raw = payload.get("files") or [] + if isinstance(raw, list): + return [str(name).strip() for name in raw if str(name).strip()] + logger.warning("mascots.json 형식 오류 — files 배열이 필요합니다.") + return [] + + +def allowed_mascot_names() -> frozenset[str]: + global _ALLOWED_NAMES + directory = mascot_dir() + names = _read_manifest_filenames(directory) + _ALLOWED_NAMES = frozenset(names) + return _ALLOWED_NAMES + + +def load_mascots() -> tuple[tuple[str, Image.Image], ...]: + # mascots.json files 목록에 있는 PNG만 로드 + global _MASCOT_CACHE, _MASCOT_CACHE_KEY + + directory = mascot_dir() + cache_key = (str(directory.resolve()), _manifest_mtime(directory)) + if _MASCOT_CACHE is not None and _MASCOT_CACHE_KEY == cache_key: + return _MASCOT_CACHE + + allowed = allowed_mascot_names() + if not allowed: + _MASCOT_CACHE = () + _MASCOT_CACHE_KEY = cache_key + return _MASCOT_CACHE + + loaded: list[tuple[str, Image.Image]] = [] + for name in sorted(allowed): + path = (directory / name).resolve() + if path.parent.resolve() != directory.resolve(): + logger.warning("mascots.json 경로 이탈 차단: %s", name) + continue + if not path.is_file(): + logger.warning("mascots.json 항목 없음: %s", path) + continue + try: + with Image.open(path) as img: + rgba = img.convert("RGBA") + cleaned = _trim_alpha(rgba) + if cleaned.size[0] < 24 or cleaned.size[1] < 24: + logger.warning("캐릭터 PNG 너무 작음: %s", name) + continue + loaded.append((name, cleaned)) + except OSError: + logger.warning("캐릭터 PNG 로드 실패: %s", name) + + if not loaded: + logger.warning("mascots.json에 등록된 사용 가능한 캐릭터가 없습니다: %s", directory) + else: + logger.info("캐릭터 %d개 로드 (manifest 전용): %s", len(loaded), ", ".join(n for n, _ in loaded)) + + _MASCOT_CACHE = tuple(loaded) + _MASCOT_CACHE_KEY = cache_key + return _MASCOT_CACHE + + +def pick_mascot(rng: random.Random) -> tuple[str, Image.Image] | None: + # manifest 등록 파일만 무작위 선택 + mascots = load_mascots() + if not mascots: + return None + name, image = mascots[rng.randrange(len(mascots))] + logger.info("카드뉴스 캐릭터 선택: %s", name) + return name, image.copy() + + +def pick_pin_mascot( + rng: random.Random, +) -> tuple[str, Image.Image] | None: + # 마무리 등 — app/assets/mascots (mascots.json) 핀 캐릭터만 사용 + mascots = load_mascots() + if not mascots: + logger.warning("mascots.json 핀 캐릭터 없음 — 마무리 캐릭터 생략") + return None + name, image = mascots[rng.randrange(len(mascots))] + logger.info("카드뉴스 핀 캐릭터(마무리): %s", name) + return name, image.copy() + + +def _trim_alpha(img: Image.Image) -> Image.Image: + alpha = img.split()[3] + bbox = alpha.getbbox() + if bbox is None: + return img + return img.crop(bbox) + + +def _wrap_speech_lines( + draw: ImageDraw.ImageDraw, + text: str, + font: ImageFont.FreeTypeFont, + max_width: int, +) -> list[str]: + text = (text or "").strip() + if not text: + return [] + + lines: list[str] = [] + current = "" + for char in text: + trial = current + char + if char == " " and current: + if draw.textlength(trial, font=font) <= max_width: + current = trial + else: + lines.append(current.strip()) + current = "" + continue + if draw.textlength(trial, font=font) <= max_width: + current = trial + else: + if current: + lines.append(current.strip()) + current = char + if current.strip(): + lines.append(current.strip()) + return lines[:3] + + +BUBBLE_FILL = (255, 255, 255) +BUBBLE_OUTLINE = (32, 36, 48) + + +def draw_classic_speech_bubble( + draw: ImageDraw.ImageDraw, + *, + bx0: int, + by0: int, + bx1: int, + by1: int, + tail_tip: tuple[int, int], + tail_from_side: str = "left", + fill: tuple[int, int, int] = BUBBLE_FILL, + outline: tuple[int, int, int] = BUBBLE_OUTLINE, + outline_width: int = 3, + radius: int = 28, +) -> None: + """흰색 라운드 말풍선 + 꼬리(캐릭터 방향).""" + draw.rounded_rectangle( + (bx0, by0, bx1, by1), + radius=radius, + fill=fill, + outline=outline, + width=outline_width, + ) + tip_x, tip_y = tail_tip + if tail_from_side == "left": + mid_y = (by0 + by1) // 2 + base_x = bx0 + max(6, radius // 3) + draw.polygon([(tip_x, tip_y), (base_x, mid_y - 20), (base_x, mid_y + 20)], fill=fill) + draw.line([(base_x, mid_y - 20), (tip_x, tip_y), (base_x, mid_y + 20)], fill=outline, width=outline_width) + else: + mid_x = (bx0 + bx1) // 2 + base_y = by1 - max(4, radius // 4) + draw.polygon([(tip_x, tip_y), (mid_x - 22, base_y), (mid_x + 22, base_y)], fill=fill) + draw.line([(mid_x - 22, base_y), (tip_x, tip_y), (mid_x + 22, base_y)], fill=outline, width=outline_width) + + +def draw_mascot_narrator( + canvas: Image.Image, + mascot: Image.Image, + speech: str, + *, + font: ImageFont.FreeTypeFont, + strip_top: int, + strip_bottom: int, + bubble_fill: tuple[int, int, int] = BUBBLE_FILL, + text_fill: tuple[int, int, int] = INK_BLACK, + mascot_max_height: int = 320, + margin: int = 52, + gap: int = 28, + center_in_strip: bool = False, + strip_x0: int | None = None, + strip_x1: int | None = None, +) -> Image.Image: + speech = (speech or "").strip() + if mascot is None: + return canvas + + base = canvas.convert("RGBA") + draw = ImageDraw.Draw(base) + strip_h = max(strip_bottom - strip_top, 120) + sx0 = strip_x0 if strip_x0 is not None else margin + sx1 = strip_x1 if strip_x1 is not None else CANVAS_WIDTH - margin + strip_inner_w = max(200, sx1 - sx0) + + target_h = min(mascot_max_height, int(strip_h * 0.92)) + target_w = int(target_h * 1.38) + icon = mascot.copy() + icon.thumbnail((target_w, target_h), Image.Resampling.LANCZOS) + + if not speech: + mx = sx0 + (strip_inner_w - icon.width) // 2 + my = strip_top + (strip_h - icon.height) // 2 + base.paste(icon, (mx, my), icon) + return base + + pad_x, pad_y = 36, 28 + line_h = int(font.size * 1.32) + max_bubble_w = max(140, strip_inner_w - icon.width - gap - 24) + + lines = _wrap_speech_lines(draw, speech, font, max_bubble_w - pad_x * 2) + if not lines: + mx = sx0 + (strip_inner_w - icon.width) // 2 + my = strip_top + (strip_h - icon.height) // 2 + base.paste(icon, (mx, my), icon) + return base + + bubble_w = min( + max_bubble_w, + max(int(draw.textlength(line, font=font)) for line in lines) + pad_x * 2, + ) + bubble_h = pad_y * 2 + line_h * len(lines) + bubble_h = min(bubble_h, strip_h - 24) + + group_w = icon.width + gap + bubble_w + if center_in_strip: + mx = sx0 + max(0, (strip_inner_w - group_w) // 2) + else: + mx = sx0 + mx = max(sx0, min(mx, sx1 - group_w)) + my = strip_top + max(0, (strip_h - icon.height) // 2) + + bubble_x0 = mx + icon.width + gap + bx0 = bubble_x0 + bx1 = bx0 + bubble_w + by0 = strip_top + max(0, (strip_h - bubble_h) // 2) + by1 = by0 + bubble_h + if by1 > strip_bottom - 8: + by1 = strip_bottom - 8 + by0 = by1 - bubble_h + if by0 < strip_top + 8: + by0 = strip_top + 8 + by1 = by0 + bubble_h + bx1 = min(bx1, sx1 - 8) + tail_tip = (mx + icon.width - 6, my + icon.height // 2) + + shadow = Image.new("RGBA", base.size, (0, 0, 0, 0)) + sdraw = ImageDraw.Draw(shadow) + sdraw.rounded_rectangle((bx0 + 4, by0 + 6, bx1 + 4, by1 + 6), radius=30, fill=(0, 0, 0, 50)) + base = Image.alpha_composite(base, shadow) + draw = ImageDraw.Draw(base) + + draw_classic_speech_bubble( + draw, + bx0=bx0, + by0=by0, + bx1=bx1, + by1=by1, + tail_tip=tail_tip, + tail_from_side="left", + fill=bubble_fill, + outline=BUBBLE_OUTLINE, + radius=30, + ) + + ty = by0 + pad_y + for line in lines: + draw.text((bx0 + pad_x, ty), line, font=font, fill=text_fill) + ty += line_h + + base.paste(icon, (mx, my), icon) + return base diff --git a/app/policy_cardnews/render.py b/app/policy_cardnews/render.py new file mode 100644 index 0000000..31c02ab --- /dev/null +++ b/app/policy_cardnews/render.py @@ -0,0 +1,181 @@ +from __future__ import annotations + +import logging +import random +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from PIL import Image + +from app.policy_cardnews.copy import ( + compact_cardnews_slides, + is_slide_empty, + normalize_slide_copy, +) +from app.policy_cardnews.images import download_cardnews_images +from app.policy_cardnews.mascot import pick_mascot, pick_pin_mascot +from app.policy_cardnews.slides import parse_cardnews_slides_json +from app.policy_cardnews.template import ( + LAYOUT_COVER, + MASCOT_LAYOUTS, + apply_deck_template_theme, + build_template_context, + normalize_to_template_slide, + render_template_slide, + resolve_template_palette, +) + +logger = logging.getLogger(__name__) + +_REPO_ROOT = Path(__file__).resolve().parents[2] + + +@dataclass +class SlideRenderContext: + slide: dict[str, Any] + minister: str + hero_image: Image.Image | None + mascot: Image.Image | None + mascot_name: str + slide_total: int + source_url: str = "" + + +def _to_handoff_path(path: Path) -> str: + try: + return path.relative_to(_REPO_ROOT).as_posix() + except ValueError: + return path.as_posix() + + +def _normalize_slide_copy(slide: dict[str, Any]) -> dict[str, Any]: + return normalize_slide_copy(slide) + + +def _render_slide_image(*, ctx: SlideRenderContext) -> Image.Image: + slide_no = int(ctx.slide.get("slide") or 1) + slide = normalize_to_template_slide(ctx.slide, index=slide_no, total=ctx.slide_total) + palette = resolve_template_palette(str(slide.get("template_palette") or "royal_blue")) + tmpl_ctx = build_template_context( + slide, + slide_no=slide_no, + slide_total=ctx.slide_total, + minister=ctx.minister, + mascot=ctx.mascot, + source_url=ctx.source_url, + palette=palette, + hero_image=ctx.hero_image, + use_cover_image=slide_no == 1 and ctx.hero_image is not None, + ) + return render_template_slide(tmpl_ctx) + + +def save_slide_image_bytes( + *, + contentid: str, + slide_no: int, + image_bytes: bytes, + output_dir: Path, +) -> str: + target_dir = output_dir / contentid + target_dir.mkdir(parents=True, exist_ok=True) + out_path = target_dir / f"slide_{slide_no:02d}.png" + out_path.write_bytes(image_bytes) + return _to_handoff_path(out_path) + + +async def render_policy_cardnews_slides( + *, + contentid: str, + slides: list[dict[str, Any]], + output_dir: Path, + minister: str = "", + image_urls: list[str] | None = None, + background_image_url: str | None = None, + source_url: str = "", + pin_content: str = "", +) -> list[str]: + target_dir = output_dir / contentid + target_dir.mkdir(parents=True, exist_ok=True) + + from app.utils.policy_news_parse import enrich_cover_image_urls, merge_policy_image_urls + + urls = merge_policy_image_urls( + original_image_urls=image_urls, + cardnews_image_urls=None, + ) + if background_image_url and background_image_url not in urls: + urls.insert(0, background_image_url) + urls = await enrich_cover_image_urls(urls, source_url=source_url) + photos = await download_cardnews_images(urls[:8], referer=source_url or "https://www.korea.kr") + + rng = random.Random() + prepared = [_normalize_slide_copy(s) for s in slides] + prepared = apply_deck_template_theme(prepared, rng=rng, contentid=contentid) + prepared = compact_cardnews_slides(prepared) + from app.policy_cardnews.terms import enrich_cardnews_terminology + + prepared = enrich_cardnews_terminology(prepared, pin_content=pin_content) + prepared = [_normalize_slide_copy(s) for s in prepared] + slides_to_render = [s for s in prepared if not is_slide_empty(s)] + if not slides_to_render: + raise ValueError("렌더링할 카드뉴스 슬라이드가 없습니다 (내용 부족)") + + slide_total = len(slides_to_render) + saved_paths: list[str] = [] + for index, slide in enumerate(slides_to_render, start=1): + slide = dict(slide) + slide["slide"] = index + slide_no = index + layout = str(slide.get("layout_type") or LAYOUT_COVER) + is_cover = layout == LAYOUT_COVER or slide_no == 1 + is_cta = slide_no == slide_total or layout == "template_cta" + + mascot_name = "" + mascot = None + hero: Image.Image | None = None + + if is_cover: + slide["use_image"] = True + if photos: + hero = photos[0] + else: + mascot_pick = pick_mascot(rng) + if mascot_pick: + mascot_name, mascot = mascot_pick[0], mascot_pick[1] + else: + logger.warning("표지: 원문 사진·마스코트를 모두 사용할 수 없습니다") + else: + hero = photos[index % len(photos)] if photos else None + if is_cta: + mascot_pick = pick_pin_mascot(rng) + elif layout in MASCOT_LAYOUTS: + mascot_pick = pick_mascot(rng) + else: + mascot_pick = None + if mascot_pick: + mascot_name, mascot = mascot_pick[0], mascot_pick[1] + + ctx = SlideRenderContext( + slide=slide, + minister=minister, + hero_image=hero, + mascot=mascot, + mascot_name=mascot_name, + slide_total=slide_total, + source_url=source_url, + ) + out_path = target_dir / f"slide_{slide_no:02d}.png" + image = _render_slide_image(ctx=ctx) + image.save(out_path, format="PNG") + saved_paths.append(_to_handoff_path(out_path)) + + return saved_paths + + +__all__ = [ + "parse_cardnews_slides_json", + "render_policy_cardnews_slides", + "save_slide_image_bytes", +] diff --git a/app/policy_cardnews/slides.py b/app/policy_cardnews/slides.py new file mode 100644 index 0000000..122adbc --- /dev/null +++ b/app/policy_cardnews/slides.py @@ -0,0 +1,115 @@ +# 정책 카드뉴스 슬라이드 JSON 파싱·레이아웃 + +from __future__ import annotations + +import json +import re +from typing import Any + +from app.policy_cardnews.copy import polish_korean_text + +TEMPLATE_LAYOUTS = frozenset({ + "template_cover", + "template_numbered", + "template_three_col", + "template_grid", + "template_cta", +}) + +_JSON_ARRAY_RE = re.compile(r"\[[\s\S]*\]") + + +def _parse_items(raw_items: Any) -> list[dict[str, str]]: + if not isinstance(raw_items, list): + return [] + items: list[dict[str, str]] = [] + for item in raw_items: + if isinstance(item, str): + text = polish_korean_text(item.strip()) + if text: + items.append({"label": "", "text": text}) + elif isinstance(item, dict): + label = polish_korean_text(str(item.get("label") or "").strip()) + text = polish_korean_text(str(item.get("text") or item.get("value") or "").strip()) + if label or text: + items.append({"label": label, "text": text}) + return items + + +def _normalize_layout_type(item: dict[str, Any], *, index: int, total: int) -> str: + layout = str(item.get("layout_type") or "").strip() + if layout in TEMPLATE_LAYOUTS: + return layout + parsed_items = _parse_items(item.get("items")) + n_items = len(parsed_items) + if index == 1: + return "template_cover" + if index == total: + return "template_cta" + if n_items == 3: + return "template_three_col" + if n_items == 4: + return "template_grid" + return "template_numbered" + + +def _slide_has_content(item: dict[str, Any]) -> bool: + if str(item.get("headline") or "").strip(): + return True + if str(item.get("highlight") or "").strip(): + return True + if str(item.get("body") or "").strip(): + return True + if str(item.get("subtext") or "").strip(): + return True + if str(item.get("cta") or "").strip(): + return True + return bool(_parse_items(item.get("items"))) + + +def parse_cardnews_slides_json(raw: str) -> list[dict[str, Any]]: + text = (raw or "").strip() + if not text: + raise ValueError("카드뉴스 슬라이드 JSON이 비어 있음") + + if text.startswith("```"): + text = re.sub(r"^```(?:json)?\s*", "", text) + text = re.sub(r"\s*```$", "", text) + + match = _JSON_ARRAY_RE.search(text) + if match is None: + raise ValueError("카드뉴스 슬라이드 JSON 배열을 찾을 수 없음") + + payload = json.loads(match.group(0)) + if not isinstance(payload, list) or not payload: + raise ValueError("카드뉴스 슬라이드 JSON이 배열이 아님") + + raw_slides = [item for item in payload if isinstance(item, dict) and _slide_has_content(item)] + if not raw_slides: + raise ValueError("유효한 카드뉴스 슬라이드가 없음") + + total = len(raw_slides) + slides: list[dict[str, Any]] = [] + for index, item in enumerate(raw_slides, start=1): + slides.append( + { + "slide": int(item.get("slide") or index), + "layout_type": _normalize_layout_type(item, index=index, total=total), + "theme": str(item.get("theme") or "snow_clean").strip(), + "eyebrow": str(item.get("eyebrow") or "").strip(), + "headline": str(item.get("headline") or "").strip(), + "highlight": str(item.get("highlight") or "").strip(), + "subtext": str(item.get("subtext") or "").strip(), + "body": str(item.get("body") or "").strip(), + "items": _parse_items(item.get("items")), + "cta": polish_korean_text(str(item.get("cta") or "")), + "speech": polish_korean_text(str(item.get("speech") or "")), + "emoji": str(item.get("emoji") or "").strip(), + "use_image": bool(item.get("use_image", True)), + } + ) + + # 최대 3장까지 사용하되, 첫 장과 마지막 CTA 슬라이드는 항상 보존 + if len(slides) > 3: + return slides[:2] + [slides[-1]] + return slides diff --git a/app/policy_cardnews/template/__init__.py b/app/policy_cardnews/template/__init__.py new file mode 100644 index 0000000..191ca75 --- /dev/null +++ b/app/policy_cardnews/template/__init__.py @@ -0,0 +1,35 @@ +# JSON·Pillow 고정 템플릿 + +from app.policy_cardnews.template.dispatch import ( + LAYOUT_COVER, + LAYOUT_CTA, + LAYOUT_GRID, + LAYOUT_NUMBERED, + LAYOUT_THREE_COL, + MASCOT_LAYOUTS, + TemplateContext, + apply_deck_template_theme, + build_template_context, + load_custom_palettes, + normalize_to_template_slide, + render_template_slide, + resolve_template_palette, + template_palette_names, +) + +__all__ = [ + "LAYOUT_COVER", + "LAYOUT_CTA", + "LAYOUT_GRID", + "LAYOUT_NUMBERED", + "LAYOUT_THREE_COL", + "MASCOT_LAYOUTS", + "TemplateContext", + "apply_deck_template_theme", + "build_template_context", + "load_custom_palettes", + "normalize_to_template_slide", + "render_template_slide", + "resolve_template_palette", + "template_palette_names", +] diff --git a/app/policy_cardnews/template/dispatch.py b/app/policy_cardnews/template/dispatch.py new file mode 100644 index 0000000..b9d5e48 --- /dev/null +++ b/app/policy_cardnews/template/dispatch.py @@ -0,0 +1,524 @@ +# 정책 카드뉴스 고정 템플릿 + +from __future__ import annotations + +import json +import logging +import random +from dataclasses import dataclass +from functools import lru_cache +from pathlib import Path +from typing import Any + +logger = logging.getLogger(__name__) + +from PIL import Image, ImageDraw, ImageFont + +from app.policy_cardnews.constants import ( + CANVAS_HEIGHT, + CANVAS_WIDTH, + CARD_INSET, + CONTENT_PAD, + GAP_BLOCK, + GAP_LINE_LG, + GAP_LINE_MD, + GAP_LINE_SM, + GAP_SECTION, + OFFSET_PX, +) +from app.policy_cardnews.template.metrics import ( + COVER_MASCOT_MIN_H, + COVER_MASCOT_ZONE_RATIO, + CTA_MIN_MASCOT_ZONE, + GAP_MASCOT, + MIN_MASCOT_ZONE_H, +) +from app.policy_cardnews.template.draw import ( + fill_scale, + line_height, + scaled_size, + wrap_text, +) +from app.policy_cardnews.mascot import ( + BRAND_ACCENT, + BRAND_BLUE, + INK_BLACK, + draw_classic_speech_bubble, + draw_mascot_narrator, +) +LAYOUT_COVER = "template_cover" +LAYOUT_NUMBERED = "template_numbered" +LAYOUT_THREE_COL = "template_three_col" +LAYOUT_GRID = "template_grid" +LAYOUT_CTA = "template_cta" + +# 2×2 그리드는 캐릭터 미사용 +MASCOT_LAYOUTS = {LAYOUT_COVER, LAYOUT_CTA, LAYOUT_THREE_COL, LAYOUT_NUMBERED} + +_REPO_ROOT = Path(__file__).resolve().parents[3] +# JS import처럼 app/policy_cardnews 패키지 기준 상대 경로 해석 +_POLICY_CARDNEWS_DIR = Path(__file__).resolve().parents[1] + + +def _font_dir() -> Path: + from app.core.config import settings + + raw = Path(settings.policy_cardnews_font_dir) + if raw.is_absolute(): + return raw + return (_POLICY_CARDNEWS_DIR / raw).resolve() + + +@dataclass(frozen=True) +class CardFrame: + outer: tuple[int, int, int, int] + white: tuple[int, int, int, int] + content: tuple[int, int, int, int] + + +@dataclass(frozen=True) +class TemplatePalette: + name: str + outer: tuple[int, int, int] + offset: tuple[int, int, int] + accent: tuple[int, int, int] + + +TEMPLATE_PALETTES: dict[str, TemplatePalette] = { + "royal_blue": TemplatePalette("royal_blue", (29, 135, 255), (196, 232, 82), (29, 135, 255)), + "coral_sunset": TemplatePalette("coral_sunset", (255, 108, 88), (255, 218, 120), (220, 72, 52)), + "mint_pop": TemplatePalette("mint_pop", (64, 192, 148), (186, 248, 210), (36, 148, 108)), + "purple_modern": TemplatePalette("purple_modern", (118, 88, 210), (218, 198, 255), (92, 62, 178)), + "amber_warm": TemplatePalette("amber_warm", (255, 168, 52), (255, 238, 170), (210, 120, 28)), + "slate_cool": TemplatePalette("slate_cool", (72, 88, 118), (188, 206, 228), (52, 68, 96)), + "rose_soft": TemplatePalette("rose_soft", (238, 118, 142), (255, 208, 218), (198, 72, 98)), + "teal_fresh": TemplatePalette("teal_fresh", (32, 168, 178), (170, 235, 240), (22, 130, 140)), +} + +TEMPLATE_PALETTE_NAMES = list(TEMPLATE_PALETTES.keys()) + +_CUSTOM_PALETTES_PATH = _REPO_ROOT / "app" / "assets" / "policy_cardnews_palettes.json" + + +def _parse_rgb_triplet(raw: object, *, field: str, palette_key: str) -> tuple[int, int, int]: + if not isinstance(raw, (list, tuple)) or len(raw) != 3: + raise ValueError(f"팔레트 '{palette_key}'의 {field}는 [R,G,B] 배열이어야 합니다") + values = tuple(int(v) for v in raw) + if any(v < 0 or v > 255 for v in values): + raise ValueError(f"팔레트 '{palette_key}'의 {field} RGB는 0~255여야 합니다") + return values # type: ignore[return-value] + + +@lru_cache(maxsize=1) +def load_custom_palettes() -> dict[str, TemplatePalette]: + """`app/assets/policy_cardnews_palettes.json`에 정의한 사용자 팔레트.""" + path = _CUSTOM_PALETTES_PATH + if not path.is_file(): + return {} + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + logger.warning("policy_cardnews_palettes.json 로드 실패: %s", exc) + return {} + if not isinstance(payload, dict): + return {} + entries = payload.get("palettes") + if not isinstance(entries, dict): + return {} + out: dict[str, TemplatePalette] = {} + for key, spec in entries.items(): + if not isinstance(key, str) or not isinstance(spec, dict): + continue + name = key.strip() + if not name or name.startswith("_"): + continue + try: + out[name] = TemplatePalette( + name=name, + outer=_parse_rgb_triplet(spec.get("outer"), field="outer", palette_key=name), + offset=_parse_rgb_triplet(spec.get("offset"), field="offset", palette_key=name), + accent=_parse_rgb_triplet(spec.get("accent"), field="accent", palette_key=name), + ) + except ValueError as exc: + logger.warning("%s", exc) + return out + + +def all_template_palettes() -> dict[str, TemplatePalette]: + merged = dict(TEMPLATE_PALETTES) + merged.update(load_custom_palettes()) + return merged + + +def template_palette_names() -> list[str]: + return list(all_template_palettes().keys()) + + +THEME_TO_TEMPLATE_PALETTE: dict[str, str] = { + "cream_warm": "amber_warm", + "mint_fresh": "mint_pop", + "slate_modern": "slate_cool", + "peach_soft": "coral_sunset", + "lavender_light": "purple_modern", + "snow_clean": "royal_blue", +} + + +@dataclass +class TemplateContext: + slide: dict[str, Any] + mascot: Image.Image | None + slide_no: int + slide_total: int + minister: str + source_url: str + palette: TemplatePalette + hero_image: Image.Image | None = None + use_cover_image: bool = False + + +def resolve_template_palette(name: str) -> TemplatePalette: + palettes = all_template_palettes() + key = (name or "royal_blue").strip() + return palettes.get(key, palettes["royal_blue"]) + + +DECK_THEME_NAMES = tuple(THEME_TO_TEMPLATE_PALETTE.keys()) + + +def _pick_deck_theme_and_palette( + slides: list[dict[str, Any]], + *, + rng: random.Random, +) -> tuple[str, str]: + # 카드뉴스 1건(주제)에 쓸 theme·template_palette 한 세트 + deck_theme = "" + for slide in slides: + candidate = str(slide.get("theme") or "").strip() + if candidate in THEME_TO_TEMPLATE_PALETTE: + deck_theme = candidate + break + + palettes = all_template_palettes() + palette_names = template_palette_names() + + if deck_theme: + palette = THEME_TO_TEMPLATE_PALETTE[deck_theme] + if palette in palettes: + return deck_theme, palette + + palette = str(slides[0].get("template_palette") if slides else "").strip() + if palette not in palettes: + palette = rng.choice(palette_names) + deck_theme = deck_theme or next( + (t for t, p in THEME_TO_TEMPLATE_PALETTE.items() if p == palette), + rng.choice(DECK_THEME_NAMES), + ) + return deck_theme, palette + + +def apply_deck_template_theme( + slides: list[dict[str, Any]], + *, + rng: random.Random, + contentid: str = "", +) -> list[dict[str, Any]]: + # 한 주제(카드뉴스) 안 모든 슬라이드에 동일 theme·palette + deck_rng = random.Random(contentid) if contentid else rng + deck_theme, deck_palette = _pick_deck_theme_and_palette(slides, rng=deck_rng) + out: list[dict[str, Any]] = [] + for slide in slides: + row = dict(slide) + row["theme"] = deck_theme + row["template_palette"] = deck_palette + out.append(row) + return out + + +@lru_cache(maxsize=128) +def _load_font(size: int, *, bold: bool = False, extra_bold: bool = False) -> ImageFont.FreeTypeFont: + size = max(18, int(size)) + if extra_bold: + names = ["Pretendard-ExtraBold.otf", "Pretendard-ExtraBold.ttf", "Pretendard-Bold.otf"] + elif bold: + names = ["Pretendard-Bold.otf", "Pretendard-Bold.ttf", "Pretendard-SemiBold.otf"] + else: + names = ["Pretendard-Medium.otf", "Pretendard-Regular.otf"] + base_dir = _font_dir() + for name in names: + path = base_dir / name + if path.is_file(): + return ImageFont.truetype(str(path), size=size) + logger.warning( + "Pretendard 폰트를 찾지 못해 기본 폰트로 렌더합니다 (font_dir=%s)", + base_dir, + ) + return ImageFont.load_default() + + +def _content_and_mascot_bounds( + cy0: int, + cy1: int, + has_mascot: bool, + *, + text_end: int | None = None, +) -> tuple[int, int, int, int]: + # 본문 하한·캐릭터 영역을 분리해 겹침 방지 + content_top = cy0 + mascot_bottom = cy1 - CONTENT_PAD // 2 + if not has_mascot: + return content_top, mascot_bottom, mascot_bottom, mascot_bottom + + zone_h = max(MIN_MASCOT_ZONE_H, int((cy1 - cy0) * 0.34)) + mascot_top = mascot_bottom - zone_h + if text_end is not None: + needed_top = text_end + GAP_MASCOT + if needed_top > mascot_top: + mascot_top = min(needed_top, mascot_bottom - 160) + content_bottom = mascot_top - GAP_MASCOT + content_bottom = max(content_top + 80, content_bottom) + return content_top, content_bottom, mascot_top, mascot_bottom + + +def _center_block_x(block_w: int, cx0: int, cx1: int) -> int: + return cx0 + max(0, (cx1 - cx0 - block_w) // 2) + + +def _layout_center_mascot_block( + mascot: Image.Image, + speech: str, + zone_h: int, + *, + cx0: int, + cx1: int, +) -> tuple[Image.Image, ImageFont.FreeTypeFont, list[str], int, int, int, int, int, int]: + speech = (speech or "").strip()[:12] + # 마스코트 높이 상한 — zone_h 의 60 % 또는 절대치 320px 중 작은 값 + max_h = min(320, max(160, int(zone_h * 0.60))) + font_size = scaled_size(36, fill_scale(50, zone_h, max_scale=1.55), min_size=30, max_size=44) + font = _load_font(font_size, bold=True) + icon = mascot.copy() + icon_max = min(max_h, max(100, int(zone_h * 0.58))) + icon.thumbnail((int(icon_max * 1.1), icon_max), Image.Resampling.LANCZOS) + mx = (CANVAS_WIDTH - icon.width) // 2 + + speech_gap = 18 + pad_x, pad_y = 32, 22 + line_h = int(font.size * 1.28) + bubble_max_w = min(460, cx1 - cx0 - 56) + probe = ImageDraw.Draw(Image.new("RGB", (10, 10))) + lines = wrap_text(probe, speech, font, bubble_max_w - 72)[:2] if speech else [] + bh = pad_y * 2 + line_h * len(lines) if lines else 0 + block_h = icon.height + (bh + speech_gap if lines else 0) + GAP_BLOCK * 2 + if block_h > zone_h: + shrink = max(0.5, zone_h / block_h) + icon_max = max(90, int(icon_max * shrink)) + icon.thumbnail((int(icon_max * 1.1), icon_max), Image.Resampling.LANCZOS) + font_size = max(22, int(font_size * shrink)) + font = _load_font(font_size, bold=True) + line_h = int(font.size * 1.28) + lines = wrap_text(probe, speech, font, bubble_max_w - 72)[:2] if speech else [] + bh = pad_y * 2 + line_h * len(lines) if lines else 0 + + my = zone_h - icon.height - GAP_BLOCK + by0, by1 = GAP_BLOCK, GAP_BLOCK + bh + if lines: + by1 = my - speech_gap + by0 = max(GAP_BLOCK, by1 - bh) + if by0 <= GAP_BLOCK and by1 + speech_gap + icon.height > zone_h - GAP_BLOCK: + my = zone_h - icon.height - GAP_BLOCK + by1 = max(GAP_BLOCK + bh, my - speech_gap) + by0 = max(GAP_BLOCK, by1 - bh) + return icon, font, lines, mx, my, by0, by1, pad_x, pad_y, line_h + + +def _estimate_cta_mascot_zone_height( + mascot: Image.Image, + speech: str, + *, + cx0: int, + cx1: int, + zone_bottom: int, + zone_top_limit: int, +) -> int: + # 캐릭터+말풍선이 들어갈 최소 세로 높이 + strip_cap = zone_bottom - zone_top_limit + probe_h = max(CTA_MIN_MASCOT_ZONE, min(560, strip_cap)) + icon, _font, lines, _mx, my, by0, _by1, _px, _py, _lh = _layout_center_mascot_block( + mascot, speech, probe_h, cx0=cx0, cx1=cx1 + ) + top = by0 if lines else my + used = probe_h - top + GAP_BLOCK + return max(CTA_MIN_MASCOT_ZONE, min(int(used), strip_cap)) + + +def _cover_mascot_bounds( + cy0: int, cy1: int, text_end: int | None = None +) -> tuple[int, int, int]: + # 표지: 캐릭터 영역을 크게 + mascot_bottom = cy1 - CONTENT_PAD // 2 + zone_h = max(COVER_MASCOT_MIN_H, int((cy1 - cy0) * COVER_MASCOT_ZONE_RATIO)) + mascot_top = mascot_bottom - zone_h + if text_end is not None: + mascot_top = max(mascot_top, text_end + GAP_MASCOT) + min_top = mascot_bottom - COVER_MASCOT_MIN_H + mascot_top = min(mascot_top, min_top) + text_bottom = mascot_top - GAP_MASCOT + return max(cy0 + 60, text_bottom), mascot_top, mascot_bottom + + +def _slide_items(slide: dict[str, Any]) -> list[dict[str, str]]: + items = [i for i in list(slide.get("items") or []) if str(i.get("text") or "").strip()] + if not items and slide.get("body"): + items = [{"label": "", "text": t} for t in str(slide["body"]).split("\n") if t.strip()] + return items[:6] + + +def _card_frame() -> CardFrame: + wx0 = CARD_INSET + wy0 = CARD_INSET + wx1 = CANVAS_WIDTH - CARD_INSET + wy1 = CANVAS_HEIGHT - CARD_INSET + white = (wx0, wy0, wx1, wy1) + content = (wx0 + CONTENT_PAD, wy0 + CONTENT_PAD, wx1 - CONTENT_PAD, wy1 - CONTENT_PAD) + return CardFrame(outer=(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT), white=white, content=content) + + +def _paste_mascot_zone( + canvas: Image.Image, + mascot: Image.Image | None, + speech: str, + *, + zone_top: int, + zone_bottom: int, + align: str = "left", +) -> Image.Image: + if mascot is None: + return canvas + zone_h = zone_bottom - zone_top + large_center = align == "center" + # 절대 상한 설정 — 영역을 가득 채우지 않도록 + if large_center: + max_h = min(340, max(160, int(zone_h * 0.62))) + else: + max_h = min(280, max(140, int(zone_h * 0.55))) + font_size = scaled_size(36, fill_scale(50, zone_h, max_scale=1.55), min_size=30, max_size=44) + font = _load_font(font_size, bold=True) + frame = _card_frame() + content_x0, _, content_x1, _ = frame.content + + if align == "left": + return draw_mascot_narrator( + canvas, + mascot, + speech, + font=font, + strip_top=zone_top, + strip_bottom=zone_bottom, + text_fill=INK_BLACK, + mascot_max_height=max_h, + margin=content_x0, + gap=26, + center_in_strip=True, + strip_x0=content_x0, + strip_x1=content_x1, + ) + + icon, font, lines, mx, my, by0, by1, pad_x, pad_y, line_h = _layout_center_mascot_block( + mascot, speech, zone_h, cx0=content_x0, cx1=content_x1 + ) + strip = Image.new("RGBA", (CANVAS_WIDTH, zone_h), (0, 0, 0, 0)) + if lines: + draw = ImageDraw.Draw(strip) + bw = max(int(draw.textlength(line, font=font)) for line in lines) + pad_x * 2 + bx0 = _center_block_x(bw, content_x0, content_x1) + tail_tip = (mx + icon.width // 2, min(my + 4, zone_h - GAP_BLOCK)) + shadow = Image.new("RGBA", strip.size, (0, 0, 0, 0)) + sdraw = ImageDraw.Draw(shadow) + sdraw.rounded_rectangle((bx0 + 4, by0 + 6, bx0 + bw + 4, by1 + 6), radius=28, fill=(0, 0, 0, 50)) + strip = Image.alpha_composite(strip, shadow) + draw = ImageDraw.Draw(strip) + draw_classic_speech_bubble( + draw, + bx0=bx0, + by0=by0, + bx1=bx0 + bw, + by1=by1, + tail_tip=tail_tip, + tail_from_side="bottom", + ) + ty = by0 + pad_y + for line in lines: + draw.text((bx0 + pad_x, ty), line, font=font, fill=INK_BLACK) + ty += line_h + strip.paste(icon, (mx, my), icon) + base = canvas.convert("RGBA") + base.paste(strip, (0, zone_top), strip) + return base + + +def pick_middle_layout(slide: dict[str, Any]) -> str: + items = _slide_items(slide) + n = len(items) + if n == 3: + return LAYOUT_THREE_COL + if n == 4: + return LAYOUT_GRID + return LAYOUT_NUMBERED + + +def normalize_to_template_slide(slide: dict[str, Any], *, index: int, total: int) -> dict[str, Any]: + row = dict(slide) + if index == 1: + row["layout_type"] = LAYOUT_COVER + elif index == total: + row["layout_type"] = LAYOUT_CTA + else: + row["layout_type"] = pick_middle_layout(row) + return row + + +def render_template_slide(ctx: TemplateContext) -> Image.Image: + from app.policy_cardnews.template.json_render import ( + render_template_cover, + render_template_cta, + render_template_grid, + render_template_numbered, + render_template_three_col, + ) + + layout = str(ctx.slide.get("layout_type") or LAYOUT_COVER) + renderers = { + LAYOUT_COVER: render_template_cover, + LAYOUT_CTA: render_template_cta, + LAYOUT_THREE_COL: render_template_three_col, + LAYOUT_GRID: render_template_grid, + LAYOUT_NUMBERED: render_template_numbered, + } + return renderers.get(layout, render_template_numbered)(ctx) + + +def build_template_context( + slide: dict[str, Any], + *, + slide_no: int, + slide_total: int, + minister: str, + mascot: Image.Image | None, + source_url: str, + palette: TemplatePalette | None = None, + hero_image: Image.Image | None = None, + use_cover_image: bool = False, +) -> TemplateContext: + pal = palette or resolve_template_palette(str(slide.get("template_palette") or "royal_blue")) + return TemplateContext( + slide=slide, + mascot=mascot, + slide_no=slide_no, + slide_total=slide_total, + minister=minister, + source_url=source_url, + palette=pal, + hero_image=hero_image, + use_cover_image=use_cover_image, + ) diff --git a/app/policy_cardnews/template/draw.py b/app/policy_cardnews/template/draw.py new file mode 100644 index 0000000..6239952 --- /dev/null +++ b/app/policy_cardnews/template/draw.py @@ -0,0 +1,257 @@ +# 정책 카드뉴스 템플릿의 반복 그리기 로직 + +from __future__ import annotations + +from typing import Any, Callable + +from PIL import Image, ImageDraw, ImageFont + + +def draw_frame_base( + *, + canvas_width: int, + canvas_height: int, + outer_fill: tuple[int, int, int], + offset_fill: tuple[int, int, int], + white_rect: tuple[int, int, int, int], + offset_px: int, +) -> Image.Image: + canvas = Image.new("RGB", (canvas_width, canvas_height), outer_fill) + draw = ImageDraw.Draw(canvas) + gx0, gy0, gx1, gy1 = white_rect + draw.rectangle((gx0 + offset_px, gy0 + offset_px, gx1 + offset_px, gy1 + offset_px), fill=offset_fill) + draw.rectangle(white_rect, fill=(255, 255, 255)) + return canvas + + +def draw_highlighter_title( + draw: ImageDraw.ImageDraw, + *, + x: int, + y: int, + text: str, + max_w: int, + font: ImageFont.FreeTypeFont, + highlight_fill: tuple[int, int, int], + text_fill: tuple[int, int, int], + wrap_fn: Callable[[ImageDraw.ImageDraw, str, ImageFont.FreeTypeFont, int], list[str]], + line_height_fn: Callable[[ImageFont.FreeTypeFont, int], int], + line_gap: int, +) -> int: + lines = wrap_fn(draw, text, font, max_w) + cy = y + for line in lines[:3]: + lw = int(draw.textlength(line, font=font)) + lx = x + (max_w - lw) // 2 + draw.rectangle( + (lx - 8, cy + 6, lx + lw + 8, cy + line_height_fn(font, 4)), + fill=highlight_fill, + ) + draw.text((lx, cy), line, font=font, fill=text_fill) + cy += line_height_fn(font, line_gap) + return cy + + +def draw_cta_action_panel( + draw: ImageDraw.ImageDraw, + *, + x0: int, + x1: int, + y0: int, + y1: int, + label: str, + fill: tuple[int, int, int], + text_fill: tuple[int, int, int], + radius: int, + inset_panel: int, + gap_line_sm: int, + fit_font_lines_fn: Callable[..., tuple[ImageFont.FreeTypeFont, list[str]]], + measure_text_block_fn: Callable[[ImageDraw.ImageDraw, list[str], ImageFont.FreeTypeFont], int], + line_height_fn: Callable[[ImageFont.FreeTypeFont, int], int], +) -> None: + # 그리드 타일 리본과 동일한 톤의 CTA 버튼(텍스트 자동 줄바꿈/중앙 정렬) + draw.rounded_rectangle((x0, y0, x1, y1), radius=radius, fill=fill) + inner_h = y1 - y0 + font, lines = fit_font_lines_fn( + draw, + label, + x1 - x0 - inset_panel * 2, + max_lines=2, + bold=True, + ) + block_h = measure_text_block_fn(draw, lines, font) + ty = y0 + max(inset_panel // 2, (inner_h - block_h) // 2) + for line in lines: + lw = int(draw.textlength(line, font=font)) + draw.text((x0 + (x1 - x0 - lw) // 2, ty), line, font=font, fill=text_fill) + ty += line_height_fn(font, gap_line_sm) + + +def line_height(font: ImageFont.FreeTypeFont, gap: int = 0) -> int: + ascent, descent = font.getmetrics() + return ascent + descent + gap + + +def wrap_text(draw: ImageDraw.ImageDraw, text: str, font: ImageFont.FreeTypeFont, max_w: int) -> list[str]: + value = (text or "").replace("\n", " ").strip() + if not value: + return [] + + words = value.split() + lines: list[str] = [] + cur = "" + for word in words: + if draw.textlength(word, font=font) > max_w: + if cur: + lines.append(cur) + cur = "" + for char in word: + trial = cur + char + if draw.textlength(trial, font=font) <= max_w: + cur = trial + else: + if cur: + lines.append(cur) + cur = char + else: + trial = f"{cur} {word}".strip() if cur else word + if draw.textlength(trial, font=font) <= max_w: + cur = trial + else: + if cur: + lines.append(cur) + cur = word + if cur: + lines.append(cur) + return lines + + +def fit_font_lines( + draw: ImageDraw.ImageDraw, + text: str, + max_w: int, + *, + start_size: int, + min_size: int, + max_lines: int, + load_font_fn: Callable[..., ImageFont.FreeTypeFont], + bold: bool = False, + extra_bold: bool = False, +) -> tuple[ImageFont.FreeTypeFont, list[str]]: + value = (text or "").strip() + if not value: + font = load_font_fn(min_size, bold=bold, extra_bold=extra_bold) + return font, [] + for size in range(start_size, min_size - 1, -2): + font = load_font_fn(size, bold=bold, extra_bold=extra_bold) + lines = wrap_text(draw, value, font, max_w) + if len(lines) <= max_lines: + return font, lines + font = load_font_fn(min_size, bold=bold, extra_bold=extra_bold) + return font, wrap_text(draw, value, font, max_w)[:max_lines] + + +def measure_text_block( + lines: list[str], + font: ImageFont.FreeTypeFont, + *, + line_gap: int, +) -> int: + if not lines: + return 0 + return len(lines) * line_height(font, line_gap) - line_gap + + +def fill_scale(used_h: int, available_h: int, *, min_scale: float = 1.08, max_scale: float = 1.78) -> float: + if available_h <= 0: + return min_scale + if used_h <= 0: + return max_scale + fill_ratio = used_h / available_h + if fill_ratio < 0.78: + return max_scale + if fill_ratio >= 0.96: + return min_scale + ratio = available_h / used_h + return max(min_scale, min(max_scale, ratio * 0.98)) + + +def scaled_size(base: int, scale: float, *, density: float = 1.08, min_size: int = 18, max_size: int = 112) -> int: + return max(min_size, min(max_size, int(base * density * scale))) + + +def draw_template_header( + draw: ImageDraw.ImageDraw, + *, + frame: Any, + palette: Any, + eyebrow: str, + headline: str, + bottom_limit: int, + fit_font_lines_fn: Callable[..., tuple[ImageFont.FreeTypeFont, list[str]]], + line_height_fn: Callable[[ImageFont.FreeTypeFont, int], int], + draw_highlighter_title_fn: Callable[..., int], + fill_scale_fn: Callable[..., float], + scaled_size_fn: Callable[..., int], + gap_block: int, + gap_line_md: int, + gap_line_lg: int, + gap_section: int, +) -> int: + cx0, cy0, cx1, _ = frame.content + cw = cx1 - cx0 + available = bottom_limit - cy0 + y = cy0 + gap_block + + if eyebrow: + font, lines = fit_font_lines_fn( + draw, + eyebrow, + cw - 24, + start_size=scaled_size_fn(32, fill_scale_fn(36, available)), + min_size=24, + max_lines=2, + bold=True, + ) + for line in lines: + lh = line_height_fn(font, gap_line_md) + 8 + if y + lh > bottom_limit - gap_block: + break + y = draw_highlighter_title_fn( + draw, + x=cx0, + y=y, + text=line, + max_w=cw, + font=font, + highlight_fill=palette.offset, + line_gap=gap_line_md, + ) + y += gap_line_md + + if headline: + font, lines = fit_font_lines_fn( + draw, + headline, + cw - 16, + start_size=scaled_size_fn(52, fill_scale_fn(72, available)), + min_size=36, + max_lines=2, + extra_bold=True, + ) + for line in lines: + lh = line_height_fn(font, gap_line_lg) + 8 + if y + lh > bottom_limit - gap_block: + break + y = draw_highlighter_title_fn( + draw, + x=cx0, + y=y, + text=line, + max_w=cw, + font=font, + highlight_fill=palette.offset, + line_gap=gap_line_lg, + ) + return min(y + gap_section, bottom_limit) + diff --git a/app/policy_cardnews/template/json_render.py b/app/policy_cardnews/template/json_render.py new file mode 100644 index 0000000..8a4dac3 --- /dev/null +++ b/app/policy_cardnews/template/json_render.py @@ -0,0 +1,1136 @@ +# JSON 기반 정책 카드뉴스 템플릿 렌더러 + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from PIL import Image, ImageDraw, ImageFont + +from app.policy_cardnews.constants import ( + CANVAS_HEIGHT, + CANVAS_WIDTH, + CONTENT_PAD, + GAP_BLOCK, + GAP_LINE_LG, + GAP_LINE_MD, + GAP_LINE_SM, + GAP_SECTION, +) +from app.policy_cardnews.mascot import BRAND_ACCENT +from app.policy_cardnews.template.draw import ( + draw_cta_action_panel, + draw_frame_base, + draw_highlighter_title, + draw_template_header, + fill_scale, + fit_font_lines, + line_height, + measure_text_block, + scaled_size, + wrap_text, +) +from app.policy_cardnews.template.metrics import COVER_TEXT_RATIO_WITH_HERO +from app.policy_cardnews.visual import paste_rounded_image_cover, paste_rounded_image_fit + +_REPO_ROOT = Path(__file__).resolve().parents[3] +TEMPLATE_DIR = _REPO_ROOT / "app" / "assets" / "policy_cardnews_templates" + +INK = (20, 24, 32) +INK_BODY = (52, 60, 78) +GRAY_PANEL = (244, 246, 250) +DOT_GRAY = (200, 208, 220) +INSET_PANEL = 22 +GAP_COL = 20 +GAP_TILE = 18 +GAP_ROW = 14 +INSET_TILE = 24 + +_TEMPLATE_CACHE: dict[Path, dict[str, Any]] = {} + + +def load_template_config(name: str) -> dict[str, Any]: + path = TEMPLATE_DIR / f"{name}.json" + cached = _TEMPLATE_CACHE.get(path) + if cached is not None: + return cached + payload = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(payload, dict): + raise ValueError(f"template json must be object: {path}") + _TEMPLATE_CACHE[path] = payload + return payload + + +def _slide_items(slide: dict[str, Any]) -> list[dict[str, str]]: + items = [i for i in list(slide.get("items") or []) if str(i.get("text") or "").strip()] + if not items and slide.get("body"): + items = [{"label": "", "text": t} for t in str(slide["body"]).split("\n") if t.strip()] + return items[:6] + + +def _center_block_x(block_w: int, cx0: int, cx1: int) -> int: + return cx0 + max(0, (cx1 - cx0 - block_w) // 2) + + +def _fit_text_in_rect( + draw: ImageDraw.ImageDraw, + text: str, + inner_w: int, + inner_h: int, + *, + start_size: int, + min_size: int, + line_gap: int, + load_font_fn: Any, + bold: bool = False, + extra_bold: bool = False, +) -> tuple[ImageFont.FreeTypeFont, list[str]]: + value = (text or "").strip() + if not value or inner_h < 16 or inner_w < 32: + return load_font_fn(min_size, bold=bold, extra_bold=extra_bold), [] + + for size in range(start_size, min_size - 1, -2): + font = load_font_fn(size, bold=bold, extra_bold=extra_bold) + lines = wrap_text(draw, value, font, inner_w) + lh = line_height(font, line_gap) + max_lines = max(1, inner_h // max(lh, 1)) + clipped = lines[:max_lines] + if len(lines) <= max_lines: + block_h = measure_text_block(clipped, font, line_gap=line_gap) + if block_h <= inner_h: + return font, clipped + + font = load_font_fn(min_size, bold=bold, extra_bold=extra_bold) + lines = wrap_text(draw, value, font, inner_w) + lh = line_height(font, line_gap) + max_lines = max(1, inner_h // max(lh, 1)) + return font, lines[:max_lines] + + +def _draw_centered_lines( + draw: ImageDraw.ImageDraw, + *, + lines: list[str], + font: ImageFont.FreeTypeFont, + x0: int, + width: int, + y: int, + fill: tuple[int, int, int], + line_gap: int, +) -> int: + cy = y + for line in lines: + lw = int(draw.textlength(line, font=font)) + draw.text((x0 + (width - lw) // 2, cy), line, font=font, fill=fill) + cy += line_height(font, line_gap) + return cy + + +def _new_canvas(ctx: Any, config_name: str) -> tuple[Any, Image.Image, ImageDraw.ImageDraw, Any, int, int, int, int, int]: + from types import SimpleNamespace + + config = load_template_config(config_name) + palette = ctx.palette + frame_cfg = config.get("frame") or {} + card_inset = int(frame_cfg.get("card_inset", 36)) + content_pad = int(frame_cfg.get("content_pad", CONTENT_PAD)) + offset_px = int(frame_cfg.get("offset_px", 12)) + white, content = _card_frame(card_inset=card_inset, content_pad=content_pad) + cx0, cy0, cx1, cy1 = content + frame = SimpleNamespace(white=white, content=content) + canvas = draw_frame_base( + canvas_width=CANVAS_WIDTH, + canvas_height=CANVAS_HEIGHT, + outer_fill=tuple(palette.outer), + offset_fill=tuple(palette.offset), + white_rect=white, + offset_px=offset_px, + ) + draw = ImageDraw.Draw(canvas) + return config, canvas, draw, frame, cx0, cy0, cx1, cy1, cx1 - cx0 + + +def _draw_slide_header( + draw: ImageDraw.ImageDraw, + *, + frame: Any, + palette: Any, + eyebrow: str, + headline: str, + bottom_limit: int, + load_font_fn: Any, +) -> int: + def _highlighter(draw_obj: ImageDraw.ImageDraw, **kwargs: Any) -> int: + return _draw_header_highlighter(draw_obj, **kwargs) + + return draw_template_header( + draw, + frame=frame, + palette=palette, + eyebrow=eyebrow, + headline=headline, + bottom_limit=bottom_limit, + fit_font_lines_fn=lambda d, t, mw, **kw: fit_font_lines(d, t, mw, load_font_fn=load_font_fn, **kw), + line_height_fn=line_height, + draw_highlighter_title_fn=_highlighter, + fill_scale_fn=fill_scale, + scaled_size_fn=scaled_size, + gap_block=GAP_BLOCK, + gap_line_md=GAP_LINE_MD, + gap_line_lg=GAP_LINE_LG, + gap_section=GAP_SECTION, + ) + + +def _card_frame(*, card_inset: int, content_pad: int) -> tuple[tuple[int, int, int, int], tuple[int, int, int, int]]: + wx0 = card_inset + wy0 = card_inset + wx1 = CANVAS_WIDTH - card_inset + wy1 = CANVAS_HEIGHT - card_inset + white = (wx0, wy0, wx1, wy1) + content = (wx0 + content_pad, wy0 + content_pad, wx1 - content_pad, wy1 - content_pad) + return white, content + + +def _fonts_cfg(config: dict[str, Any]) -> dict[str, int]: + fonts = config.get("fonts") or {} + return { + "eyebrow_start": int(fonts.get("eyebrow_start", 34)), + "eyebrow_min": int(fonts.get("eyebrow_min", 26)), + "eyebrow_max_lines": int(fonts.get("eyebrow_max_lines", 2)), + "headline_start": int(fonts.get("headline_start", 80)), + "headline_min": int(fonts.get("headline_min", 52)), + "headline_max_lines": int(fonts.get("headline_max_lines", 2)), + "highlight_start": int(fonts.get("highlight_start", 76)), + "highlight_min": int(fonts.get("highlight_min", 48)), + "highlight_max_lines": int(fonts.get("highlight_max_lines", 2)), + } + + +def _highlight_cfg(config: dict[str, Any]) -> dict[str, Any]: + hl = config.get("highlight") or {} + layout = config.get("layout") or {} + text_fill_raw = hl.get("text_fill") + if isinstance(text_fill_raw, (list, tuple)) and len(text_fill_raw) == 3: + text_fill = tuple(int(v) for v in text_fill_raw) + else: + text_fill = INK + return { + "type": str(hl.get("type", "accent_badge")), + "radius": int(hl.get("radius", layout.get("highlight_badge_radius", 8))), + "pad_x": int(hl.get("pad_x", layout.get("highlight_badge_pad_x", 18))), + "pad_y": int(hl.get("pad_y", layout.get("highlight_badge_pad_y", 5))), + "tape_extra_h": int(hl.get("tape_extra_h", 14)), + "line_gap": int(hl.get("line_gap", GAP_LINE_MD)), + "text_fill": text_fill, + } + + +def _draw_highlight_accent_badge( + draw: ImageDraw.ImageDraw, + *, + cx: int, + y: int, + line: str, + font: ImageFont.FreeTypeFont, + accent_fill: tuple[int, int, int], + text_fill: tuple[int, int, int] = INK, + radius: int, + pad_x: int, + pad_y: int, + tape_extra_h: int, +) -> int: + lw = int(draw.textlength(line, font=font)) + tape_h = line_height(font, 6) + tape_extra_h + draw.rounded_rectangle( + (cx - lw // 2 - pad_x, y, cx + lw // 2 + pad_x, y + tape_h), + radius=radius, + fill=accent_fill, + ) + draw.text((cx - lw // 2, y + pad_y), line, font=font, fill=text_fill) + return y + tape_h + GAP_LINE_LG + + +def render_template_cover(ctx: Any) -> Image.Image: + """`template_cover.json` 기반 표지 슬라이드.""" + from app.policy_cardnews.template.dispatch import ( + _load_font, + _paste_mascot_zone, + ) + + config = load_template_config("template_cover") + slide = ctx.slide + palette = ctx.palette + + frame_cfg = config.get("frame") or {} + card_inset = int(frame_cfg.get("card_inset", 36)) + content_pad = int(frame_cfg.get("content_pad", CONTENT_PAD)) + offset_px = int(frame_cfg.get("offset_px", 12)) + + white, content = _card_frame(card_inset=card_inset, content_pad=content_pad) + cx0, cy0, cx1, cy1 = content + cw = cx1 - cx0 + cx = (cx0 + cx1) // 2 + + layout_cfg = config.get("layout") or {} + cover_text_ratio_with_hero = float( + layout_cfg.get("text_bottom_ratio_with_hero", COVER_TEXT_RATIO_WITH_HERO) + ) + fonts = _fonts_cfg(config) + highlight_cfg = _highlight_cfg(config) + + canvas = draw_frame_base( + canvas_width=CANVAS_WIDTH, + canvas_height=CANVAS_HEIGHT, + outer_fill=tuple(palette.outer), + offset_fill=tuple(palette.offset), + white_rect=white, + offset_px=offset_px, + ) + draw = ImageDraw.Draw(canvas) + + eyebrow = str(slide.get("eyebrow") or slide.get("subtext") or "").strip() + headline = str(slide.get("headline") or "").strip() + highlight = str(slide.get("highlight") or "").strip() + speech = str(slide.get("speech") or "").strip() + + hero_cfg = config.get("hero") or {} + hero_enabled = bool(hero_cfg.get("enabled", True)) + use_hero_image = bool( + hero_enabled and ctx.use_cover_image and ctx.hero_image is not None + ) + use_mascot_hero = bool(not use_hero_image and ctx.mascot is not None) + use_visual_fill = use_hero_image or use_mascot_hero + + if use_visual_fill: + text_bottom = cy0 + int((cy1 - cy0) * cover_text_ratio_with_hero) + else: + text_bottom = cy1 - content_pad // 2 + + text_available = text_bottom - cy0 + parts: list[tuple[str, str]] = [] + if eyebrow: + parts.append(("eyebrow", eyebrow)) + if headline: + parts.append(("headline", headline)) + if highlight and highlight != headline: + parts.append(("highlight", highlight)) + + probe = ImageDraw.Draw(Image.new("RGB", (10, 10))) + + def _fit_block(kind: str, text: str, scale: float) -> tuple[Any, list[str], int]: + if kind == "eyebrow": + max_lines = fonts["eyebrow_max_lines"] + font, lines = fit_font_lines( + probe, + text, + cw - 32, + start_size=scaled_size(fonts["eyebrow_start"], scale), + min_size=fonts["eyebrow_min"], + max_lines=max_lines, + load_font_fn=_load_font, + bold=True, + ) + block_h = measure_text_block(lines, font, line_gap=GAP_LINE_SM) + GAP_LINE_LG + return font, lines, block_h + if kind == "headline": + max_lines = fonts["headline_max_lines"] + font, lines = fit_font_lines( + probe, + text, + cw - 40, + start_size=scaled_size(fonts["headline_start"], scale), + min_size=fonts["headline_min"], + max_lines=max_lines, + load_font_fn=_load_font, + extra_bold=True, + ) + block_h = measure_text_block(lines, font, line_gap=GAP_LINE_MD) + GAP_LINE_LG + return font, lines, block_h + max_lines = fonts["highlight_max_lines"] + font, lines = fit_font_lines( + probe, + text, + cw - 40, + start_size=scaled_size(fonts["highlight_start"], scale), + min_size=fonts["highlight_min"], + max_lines=max_lines, + load_font_fn=_load_font, + extra_bold=True, + ) + tape_h = line_height(font, 6) + highlight_cfg["tape_extra_h"] + block_h = len(lines) * (tape_h + GAP_LINE_LG) if lines else 0 + return font, lines, block_h + + scale = fill_scale(1, text_available, max_scale=1.78) + blocks: list[tuple[str, str, Any, list[str], int]] = [] + for _ in range(28): + total = GAP_BLOCK + blocks = [] + for kind, text in parts: + font, lines, block_h = _fit_block(kind, text, scale) + blocks.append((kind, text, font, lines, block_h)) + total += block_h + if total <= text_available: + break + scale = max(1.0, scale * (text_available / total) * 0.96) + + y = cy0 + max(GAP_BLOCK, int((text_available - total) * 0.08)) + + for kind, text, font, lines, block_h in blocks: + if kind == "eyebrow": + for line in lines: + lh = line_height(font, GAP_LINE_SM) + lw = int(draw.textlength(line, font=font)) + draw.text((cx - lw // 2, y), line, font=font, fill=INK_BODY) + y += lh + y += GAP_LINE_LG + elif kind == "headline": + for line in lines: + lh = line_height(font, GAP_LINE_MD) + lw = int(draw.textlength(line, font=font)) + draw.text((cx - lw // 2, y), line, font=font, fill=INK) + y += lh + y += GAP_LINE_LG + else: + for line in lines: + if highlight_cfg["type"] == "highlight_bar": + y = draw_highlighter_title( + draw, + x=cx0, + y=y, + text=line, + max_w=cw, + font=font, + highlight_fill=tuple(palette.offset), + text_fill=INK, + wrap_fn=wrap_text, + line_height_fn=line_height, + line_gap=highlight_cfg["line_gap"], + ) + y += GAP_LINE_LG + else: + y = _draw_highlight_accent_badge( + draw, + cx=cx, + y=y, + line=line, + font=font, + accent_fill=tuple(palette.offset), + text_fill=highlight_cfg["text_fill"], + radius=highlight_cfg["radius"], + pad_x=highlight_cfg["pad_x"], + pad_y=highlight_cfg["pad_y"], + tape_extra_h=highlight_cfg["tape_extra_h"], + ) + + img_y0 = y + GAP_SECTION + img_y1 = cy1 - content_pad // 2 + + if use_hero_image and ctx.hero_image is not None: + radius = int(hero_cfg.get("corner_radius", 28)) + box_inset = int(hero_cfg.get("box_inset", INSET_PANEL)) + img_box = (cx0 + box_inset, img_y0, cx1 - box_inset, img_y1) + fill_mode = str(hero_cfg.get("fill_mode") or "contain").strip().lower() + if fill_mode == "cover": + canvas = paste_rounded_image_cover(canvas, ctx.hero_image, box=img_box, radius=radius) + else: + canvas = paste_rounded_image_fit(canvas, ctx.hero_image, box=img_box, radius=radius) + return canvas.convert("RGB") + + if use_mascot_hero and ctx.mascot is not None: + return _paste_mascot_zone( + canvas, + ctx.mascot, + speech, + zone_top=img_y0, + zone_bottom=img_y1, + align="center", + ) + return canvas.convert("RGB") + + +def _draw_header_highlighter( + draw: ImageDraw.ImageDraw, + *, + x: int, + y: int, + text: str, + max_w: int, + font: ImageFont.FreeTypeFont, + highlight_fill: tuple[int, int, int], + line_gap: int, +) -> int: + return draw_highlighter_title( + draw, + x=x, + y=y, + text=text, + max_w=max_w, + font=font, + highlight_fill=highlight_fill, + text_fill=INK, + wrap_fn=wrap_text, + line_height_fn=line_height, + line_gap=line_gap, + ) + + +def _draw_term_guide_panel( + draw: ImageDraw.ImageDraw, + *, + cx0: int, + cx1: int, + y: int, + guides: list[str], + bottom_limit: int, + panel_inset: int, + title: str, + title_size: int, + body_size: int, + panel_radius: int, + max_guides: int, + max_body_lines: int, + load_font_fn: Any, +) -> int: + lines = [g.strip() for g in guides if g.strip()][:max_guides] + if not lines or y >= bottom_limit - 60: + return y + + pad_x = panel_inset + inner_w = cx1 - cx0 - pad_x * 2 + title_f = load_font_fn(title_size, bold=True) + body_f = load_font_fn(body_size) + title_h = line_height(title_f, GAP_LINE_SM) + body_lines: list[str] = [] + for line in lines: + body_lines.extend(wrap_text(draw, line, body_f, inner_w)[:2]) + body_lines = body_lines[:max_body_lines] + panel_h = ( + pad_x + + title_h + + GAP_LINE_SM + + measure_text_block(body_lines, body_f, line_gap=GAP_LINE_SM) + + pad_x + ) + panel_h = min(panel_h, max(80, bottom_limit - y - GAP_BLOCK)) + px0 = cx0 + pad_x + px1 = cx1 - pad_x + py0 = y + py1 = min(bottom_limit - GAP_BLOCK, py0 + panel_h) + draw.rounded_rectangle((px0, py0, px1, py1), radius=panel_radius, fill=GRAY_PANEL) + ty = py0 + pad_x // 2 + draw.text((px0 + pad_x, ty), title, font=title_f, fill=INK) + ty += title_h + GAP_LINE_SM + for line in body_lines: + draw.text((px0 + pad_x, ty), line, font=body_f, fill=INK_BODY) + ty += line_height(body_f, GAP_LINE_SM) + return py1 + GAP_SECTION + + +def render_template_cta(ctx: Any) -> Image.Image: + """`template_cta.json` 기반 마무리(CTA) 슬라이드.""" + from types import SimpleNamespace + + from app.policy_cardnews.template.dispatch import ( + _estimate_cta_mascot_zone_height, + _load_font, + _paste_mascot_zone, + ) + + config = load_template_config("template_cta") + slide = ctx.slide + palette = ctx.palette + defaults = config.get("defaults") or {} + layout_cfg = config.get("layout") or {} + fonts_cfg = config.get("fonts") or {} + panel_cfg = config.get("cta_panel") or {} + url_cfg = config.get("url") or {} + term_cfg = config.get("term_guide") or {} + + frame_cfg = config.get("frame") or {} + card_inset = int(frame_cfg.get("card_inset", 36)) + content_pad = int(frame_cfg.get("content_pad", CONTENT_PAD)) + offset_px = int(frame_cfg.get("offset_px", 12)) + panel_inset = int(layout_cfg.get("panel_inset", INSET_PANEL)) + gap_above_mascot = int(layout_cfg.get("gap_above_mascot", 36)) + + white, content = _card_frame(card_inset=card_inset, content_pad=content_pad) + cx0, cy0, cx1, cy1 = content + cw = cx1 - cx0 + frame = SimpleNamespace(white=white, content=content) + + canvas = draw_frame_base( + canvas_width=CANVAS_WIDTH, + canvas_height=CANVAS_HEIGHT, + outer_fill=tuple(palette.outer), + offset_fill=tuple(palette.offset), + white_rect=white, + offset_px=offset_px, + ) + draw = ImageDraw.Draw(canvas) + + eyebrow = str(slide.get("eyebrow") or defaults.get("eyebrow", "마무리")).strip() + headline = str(slide.get("headline") or defaults.get("headline", "자세한 내용은 원문에서")).strip() + cta = str(slide.get("cta") or defaults.get("cta", "원문 뉴스 보기")).strip() + body = str(slide.get("body") or "").strip() + speech = str(slide.get("speech") or "").strip() + speech_max = int(defaults.get("speech_max_len", 18)) + if not speech: + speech = str(defaults.get("speech", "궁금하면 원문 봐!")).strip() + speech = speech[:speech_max] + term_guides = [str(g).strip() for g in list(slide.get("term_guides") or []) if str(g).strip()] + + has_mascot = ctx.mascot is not None + mascot_bottom = cy1 - content_pad // 2 + mascot_top = mascot_bottom + content_limit = mascot_bottom + + if has_mascot and ctx.mascot is not None: + zone_h = _estimate_cta_mascot_zone_height( + ctx.mascot, + speech, + cx0=cx0, + cx1=cx1, + zone_bottom=mascot_bottom, + zone_top_limit=cy0, + ) + mascot_top = mascot_bottom - zone_h + content_limit = mascot_top - gap_above_mascot + + available = max(120, content_limit - cy0) + scale = fill_scale( + int(layout_cfg.get("content_scale_base", 180)), + available, + min_scale=float(layout_cfg.get("content_scale_min", 1.08)), + max_scale=float(layout_cfg.get("content_scale_max", 1.48)), + ) + + panel_x0 = cx0 + panel_inset + panel_x1 = cx1 - panel_inset + box_h = scaled_size( + int(panel_cfg.get("height_start", 96)), + scale, + min_size=int(panel_cfg.get("height_min", 76)), + max_size=int(panel_cfg.get("height_max", 108)), + ) + uf = _load_font( + scaled_size(int(fonts_cfg.get("url_start", 20)), scale, min_size=int(fonts_cfg.get("url_min", 18))) + ) + url_line_h = line_height(uf, GAP_LINE_SM) + url = (ctx.source_url or "").strip() + url_block_h = url_line_h + (GAP_LINE_LG if url else 0) + + term_block_h = 0 + if term_guides: + probe = ImageDraw.Draw(Image.new("RGB", (10, 10))) + tf = _load_font(int(fonts_cfg.get("term_title", 22)), bold=True) + bf = _load_font(int(fonts_cfg.get("term_body", 20))) + for g in term_guides[: int(term_cfg.get("max_guides", 2))]: + term_block_h += line_height(tf, GAP_LINE_SM) + measure_text_block( + wrap_text(probe, g, bf, cw - panel_inset * 4)[:2], bf, line_gap=GAP_LINE_SM + ) + term_block_h += panel_inset + GAP_SECTION + + body_block_h = 0 + small = _load_font( + scaled_size(int(fonts_cfg.get("body_start", 24)), scale, min_size=int(fonts_cfg.get("body_min", 20))) + ) + if body: + probe = ImageDraw.Draw(Image.new("RGB", (10, 10))) + blines = wrap_text(probe, body, small, cw - 56)[:2] + body_block_h = measure_text_block(blines, small, line_gap=GAP_LINE_SM) + GAP_SECTION + + stack_bottom = content_limit + if url: + stack_bottom -= url_block_h + + box_y1 = stack_bottom + box_y0 = box_y1 - box_h + if box_y0 < cy0 + GAP_BLOCK: + box_y0 = cy0 + GAP_BLOCK + box_y1 = box_y0 + box_h + if url and box_y1 + url_block_h > content_limit: + box_y1 = max(box_y0 + box_h, content_limit - url_block_h) + box_y0 = box_y1 - box_h + + upper_stack_bottom = box_y0 - GAP_SECTION + if body: + upper_stack_bottom -= body_block_h + if term_guides: + upper_stack_bottom -= term_block_h + y_max_for_titles = max(cy0 + GAP_BLOCK, upper_stack_bottom - GAP_BLOCK) + + def _highlighter(draw_obj: ImageDraw.ImageDraw, **kwargs: Any) -> int: + return _draw_header_highlighter(draw_obj, **kwargs) + + y = draw_template_header( + draw, + frame=frame, + palette=palette, + eyebrow=eyebrow, + headline=headline, + bottom_limit=y_max_for_titles, + fit_font_lines_fn=lambda d, t, mw, **kw: fit_font_lines(d, t, mw, load_font_fn=_load_font, **kw), + line_height_fn=line_height, + draw_highlighter_title_fn=_highlighter, + fill_scale_fn=fill_scale, + scaled_size_fn=scaled_size, + gap_block=GAP_BLOCK, + gap_line_md=GAP_LINE_MD, + gap_line_lg=GAP_LINE_LG, + gap_section=GAP_SECTION, + ) + + if term_guides and y < y_max_for_titles: + y = _draw_term_guide_panel( + draw, + cx0=cx0, + cx1=cx1, + y=y, + guides=term_guides, + bottom_limit=min(y_max_for_titles, box_y0 - GAP_BLOCK), + panel_inset=panel_inset, + title=str(term_cfg.get("title", "쉬운 말로")), + title_size=int(fonts_cfg.get("term_title", 22)), + body_size=int(fonts_cfg.get("term_body", 20)), + panel_radius=int(term_cfg.get("panel_radius", 12)), + max_guides=int(term_cfg.get("max_guides", 2)), + max_body_lines=int(term_cfg.get("max_body_lines", 3)), + load_font_fn=_load_font, + ) + + if body and y < box_y0 - GAP_BLOCK: + blines = wrap_text(draw, body, small, panel_x1 - panel_x0 - panel_inset)[:2] + for line in blines: + if y + line_height(small, GAP_LINE_SM) > box_y0 - GAP_BLOCK: + break + draw.text((panel_x0, y), line, font=small, fill=INK_BODY) + y += line_height(small, GAP_LINE_SM) + + if y < box_y0 - GAP_SECTION: + sep_y = box_y0 - GAP_SECTION // 2 + draw.line( + [(panel_x0, sep_y), (panel_x1, sep_y)], + fill=DOT_GRAY, + width=int(layout_cfg.get("separator_width", 2)), + ) + + cta_label = cta or str(defaults.get("cta", "원문 뉴스 보기")) + draw_cta_action_panel( + draw, + x0=panel_x0, + x1=panel_x1, + y0=box_y0, + y1=box_y1, + label=cta_label, + fill=tuple(palette.accent), + text_fill=BRAND_ACCENT, + radius=int(panel_cfg.get("radius", 14)), + inset_panel=int(panel_cfg.get("inset_panel", panel_inset)), + gap_line_sm=GAP_LINE_SM, + fit_font_lines_fn=lambda d, t, mw, **kw: fit_font_lines( + d, + t, + mw, + start_size=int(panel_cfg.get("label_start", 40)), + min_size=int(panel_cfg.get("label_min", 28)), + load_font_fn=_load_font, + **kw, + ), + measure_text_block_fn=lambda _d, ls, f: measure_text_block(ls, f, line_gap=GAP_LINE_SM), + line_height_fn=line_height, + ) + + if url: + display_max = int(url_cfg.get("display_max", 34)) + display = (url.replace("https://", "").replace("http://", ""))[:display_max] + if len(ctx.source_url or "") > display_max: + display = display[: display_max - 3] + "..." + ut = f"{url_cfg.get('prefix', '앱에서 링크 · ')}{display}" + url_y = max(box_y1 + GAP_LINE_LG, box_y0 + box_h + GAP_LINE_SM) + url_y = min(url_y, content_limit - url_line_h - GAP_BLOCK) + draw.text((panel_x0, url_y), ut, font=uf, fill=INK_BODY) + + if has_mascot and ctx.mascot is not None: + return _paste_mascot_zone( + canvas, + ctx.mascot, + speech, + zone_top=mascot_top, + zone_bottom=mascot_bottom, + align="center", + ) + return canvas.convert("RGB") + + +def render_template_numbered(ctx: Any) -> Image.Image: + """`template_numbered.json` 기반 번호 목록 슬라이드.""" + from app.policy_cardnews.template.dispatch import ( + _content_and_mascot_bounds, + _load_font, + _paste_mascot_zone, + ) + + config, canvas, draw, frame, cx0, cy0, cx1, cy1, cw = _new_canvas(ctx, "template_numbered") + slide = ctx.slide + palette = ctx.palette + defaults = config.get("defaults") or {} + layout = config.get("layout") or {} + fonts = config.get("fonts") or {} + + max_items = int(layout.get("max_items", 4)) + panel_inset = int(layout.get("panel_inset", INSET_PANEL)) + gap_row = int(layout.get("gap_row", GAP_ROW)) + items = _slide_items(slide)[:max_items] + has_mascot = ctx.mascot is not None and len(items) <= int(layout.get("mascot_max_items", 3)) + _, body_bottom, mascot_top, mascot_bottom = _content_and_mascot_bounds(cy0, cy1, has_mascot) + + eyebrow = str(slide.get("eyebrow") or defaults.get("eyebrow", "한 장 요약")).strip() + headline = str(slide.get("headline") or "").strip() + y = _draw_slide_header( + draw, + frame=frame, + palette=palette, + eyebrow=eyebrow, + headline=headline, + bottom_limit=body_bottom, + load_font_fn=_load_font, + ) + + n = max(len(items), 1) + body_h = body_bottom - y + row_h = max(int(layout.get("row_min_h", 48)), (body_h - gap_row * max(0, n - 1)) // n) + scale = fill_scale( + int(layout.get("scale_base_per_row", 64)) * n, + body_h, + max_scale=float(layout.get("scale_max", 1.65)), + ) + title_f = _load_font( + scaled_size( + int(fonts.get("row_title_start", 36)), + scale, + min_size=int(fonts.get("row_title_min", 28)), + max_size=int(fonts.get("row_title_max", 48)), + ), + bold=True, + ) + max_wrap = int(layout.get("max_wrap_lines", 3)) + + for index, item in enumerate(items): + row_y0 = y + index * (row_h + gap_row) + row_y1 = min(body_bottom, row_y0 + row_h) + if row_y0 >= body_bottom - GAP_BLOCK: + break + inner_h = row_y1 - row_y0 + label = str(item.get("label") or "").strip() + text = str(item.get("text") or "").strip() + row_title = f"{label} · {text}".strip(" ·") if label else text + + num_size = scaled_size( + int(fonts.get("num_start", 30)), + scale, + min_size=int(fonts.get("num_min", 24)), + max_size=int(fonts.get("num_max", 38)), + ) + num_r = max(26, int(inner_h * 0.26)) + num_x = cx0 + panel_inset // 2 + num_y = row_y0 + (inner_h - num_r * 2) // 2 + draw.ellipse((num_x, num_y, num_x + num_r * 2, num_y + num_r * 2), fill=tuple(palette.accent)) + nf = _load_font(num_size, bold=True) + num_t = f"{index + 1}" + nw = int(draw.textlength(num_t, font=nf)) + draw.text( + (num_x + num_r - nw // 2, num_y + num_r - line_height(nf, 0) // 2), + num_t, + font=nf, + fill=BRAND_ACCENT, + ) + + text_x0 = cx0 + num_r * 2 + GAP_SECTION + text_x1 = cx1 - panel_inset + text_w = text_x1 - text_x0 + wrapped = wrap_text(draw, row_title, title_f, text_w)[:max_wrap] + block_h = measure_text_block(wrapped, title_f, line_gap=GAP_LINE_MD) + ty = row_y0 + max(GAP_BLOCK, (inner_h - block_h) // 2) + for line in wrapped: + lw = int(draw.textlength(line, font=title_f)) + draw.text((text_x0 + (text_w - lw) // 2, ty), line, font=title_f, fill=INK) + ty += line_height(title_f, GAP_LINE_MD) + + if index < len(items) - 1: + sep_y = row_y1 + gap_row // 2 + draw.line( + [ + (cx0 + int(layout.get("separator_inset_left", 56)), sep_y), + (cx1 - panel_inset, sep_y), + ], + fill=DOT_GRAY, + width=int(layout.get("separator_width", 2)), + ) + + if has_mascot: + speech = str(slide.get("speech") or defaults.get("speech", "핵심만!"))[ + : int(defaults.get("speech_max_len", 18)) + ] + return _paste_mascot_zone( + canvas, + ctx.mascot, + speech, + zone_top=mascot_top, + zone_bottom=mascot_bottom, + align="left", + ) + return canvas.convert("RGB") + + +def render_template_three_col(ctx: Any) -> Image.Image: + """`template_three_col.json` 기반 3열 슬라이드.""" + from app.policy_cardnews.template.dispatch import ( + _content_and_mascot_bounds, + _load_font, + _paste_mascot_zone, + ) + + config, canvas, draw, frame, cx0, cy0, cx1, cy1, cw = _new_canvas(ctx, "template_three_col") + slide = ctx.slide + palette = ctx.palette + defaults = config.get("defaults") or {} + layout = config.get("layout") or {} + fonts = config.get("fonts") or {} + + max_items = int(layout.get("max_items", 3)) + panel_inset = int(layout.get("panel_inset", INSET_PANEL)) + col_gap = int(layout.get("col_gap", GAP_COL)) + items = _slide_items(slide)[:max_items] + has_mascot = ctx.mascot is not None + _, body_bottom, mascot_top, mascot_bottom = _content_and_mascot_bounds(cy0, cy1, has_mascot) + + eyebrow = str(slide.get("eyebrow") or "").strip() + headline = str(slide.get("headline") or defaults.get("headline", "핵심만 정리")).strip() + y = _draw_slide_header( + draw, + frame=frame, + palette=palette, + eyebrow=eyebrow, + headline=headline, + bottom_limit=body_bottom, + load_font_fn=_load_font, + ) + + n_cols = max(len(items), 1) + col_w = (cw - col_gap * (n_cols - 1)) // n_cols + grid_w = n_cols * col_w + col_gap * (n_cols - 1) + grid_x0 = _center_block_x(grid_w, cx0, cx1) + col_top = y + GAP_BLOCK + col_bottom = body_bottom + col_h = col_bottom - col_top + label_prefix = str(defaults.get("label_prefix", "항목")) + + for col_index, item in enumerate(items): + col_x0 = grid_x0 + col_index * (col_w + col_gap) + draw.rounded_rectangle( + (col_x0, col_top, col_x0 + col_w, col_bottom), + radius=int(layout.get("panel_radius", 14)), + fill=GRAY_PANEL, + ) + pad = panel_inset + inner_w = col_w - pad * 2 + + label = str(item.get("label") or f"{label_prefix}{col_index + 1}").strip() + text = str(item.get("text") or "").strip() + + badge = max( + int(layout.get("badge_min", 44)), + min(int(layout.get("badge_max", 56)), int(col_h * float(layout.get("badge_ratio", 0.13)))), + ) + badge_y = col_top + pad + cx_center = col_x0 + col_w // 2 + draw.ellipse( + (cx_center - badge // 2, badge_y, cx_center + badge // 2, badge_y + badge), + fill=tuple(palette.accent), + ) + nf = _load_font(int(fonts.get("badge_num_start", 22)), bold=True) + num = f"{col_index + 1:02d}" + nw = int(draw.textlength(num, font=nf)) + draw.text((cx_center - nw // 2, badge_y + badge // 4), num, font=nf, fill=BRAND_ACCENT) + + label_h = max(int(layout.get("label_h_min", 36)), int(col_h * float(layout.get("label_h_ratio", 0.18)))) + label_font, label_lines = _fit_text_in_rect( + draw, + label, + inner_w, + label_h, + start_size=int(fonts.get("label_start", 32)), + min_size=int(fonts.get("label_min", 24)), + line_gap=GAP_LINE_SM, + load_font_fn=_load_font, + bold=True, + ) + ly = badge_y + badge + GAP_LINE_MD + _draw_centered_lines( + draw, + lines=label_lines, + font=label_font, + x0=col_x0 + pad, + width=inner_w, + y=ly, + fill=INK, + line_gap=GAP_LINE_SM, + ) + + content_top = ly + label_h + GAP_LINE_SM + content_h = max(40, col_bottom - content_top - pad) + body_font, body_lines = _fit_text_in_rect( + draw, + text, + inner_w, + content_h, + start_size=int(fonts.get("body_start", 30)), + min_size=int(fonts.get("body_min", 22)), + line_gap=GAP_LINE_MD, + load_font_fn=_load_font, + ) + body_block_h = measure_text_block(body_lines, body_font, line_gap=GAP_LINE_MD) + ty_text = content_top + max(0, (content_h - body_block_h) // 2) + _draw_centered_lines( + draw, + lines=body_lines, + font=body_font, + x0=col_x0 + pad, + width=inner_w, + y=ty_text, + fill=INK_BODY, + line_gap=GAP_LINE_MD, + ) + + if has_mascot: + speech = str(slide.get("speech") or defaults.get("speech", "핵심포인트!"))[ + : int(defaults.get("speech_max_len", 18)) + ] + return _paste_mascot_zone( + canvas, + ctx.mascot, + speech, + zone_top=mascot_top, + zone_bottom=mascot_bottom, + align="left", + ) + return canvas.convert("RGB") + + +def render_template_grid(ctx: Any) -> Image.Image: + """`template_grid.json` 기반 2×2 그리드 슬라이드.""" + config, canvas, draw, frame, cx0, cy0, cx1, cy1, cw = _new_canvas(ctx, "template_grid") + slide = ctx.slide + palette = ctx.palette + defaults = config.get("defaults") or {} + layout = config.get("layout") or {} + fonts = config.get("fonts") or {} + + frame_cfg = config.get("frame") or {} + content_pad = int(frame_cfg.get("content_pad", CONTENT_PAD)) + max_items = int(layout.get("max_items", 4)) + gap = int(layout.get("gap_tile", GAP_TILE)) + tile_pad = int(layout.get("tile_pad", INSET_TILE)) + items = _slide_items(slide)[:max_items] + body_bottom = cy1 - content_pad // 2 + + eyebrow = str(slide.get("eyebrow") or "").strip() + headline = str(slide.get("headline") or defaults.get("headline", "이렇게 확인하세요")).strip() + from app.policy_cardnews.template.dispatch import _load_font + + y = _draw_slide_header( + draw, + frame=frame, + palette=palette, + eyebrow=eyebrow, + headline=headline, + bottom_limit=body_bottom, + load_font_fn=_load_font, + ) + + tile_w = (cw - gap) // 2 + rows = 2 + grid_h = body_bottom - y + tile_h = max(int(layout.get("tile_min_h", 120)), (grid_h - gap) // rows) + grid_total_h = tile_h * rows + gap + grid_y0 = y + max(0, (grid_h - grid_total_h) // 2) + grid_w = tile_w * 2 + gap + grid_x0 = _center_block_x(grid_w, cx0, cx1) + label_prefix = str(defaults.get("label_prefix", "포인트")) + + for index, item in enumerate(items): + col = index % 2 + row = index // 2 + tx0 = grid_x0 + col * (tile_w + gap) + ty0 = grid_y0 + row * (tile_h + gap) + tx1 = tx0 + tile_w + ty1 = min(body_bottom, ty0 + tile_h) + draw.rounded_rectangle( + (tx0, ty0, tx1, ty1), + radius=int(layout.get("panel_radius", 14)), + fill=GRAY_PANEL, + ) + + label = str(item.get("label") or f"{label_prefix}{index + 1}").strip() + text = str(item.get("text") or "").strip() + inner_x0 = tx0 + tile_pad + inner_x1 = tx1 - tile_pad + inner_w = inner_x1 - inner_x0 + + ribbon_h = max(int(layout.get("ribbon_min_h", 52)), int((ty1 - ty0) * float(layout.get("ribbon_ratio", 0.28)))) + draw.rectangle((tx0, ty0, tx1, ty0 + ribbon_h), fill=tuple(palette.accent)) + + label_font, label_lines = _fit_text_in_rect( + draw, + label, + inner_w, + ribbon_h - tile_pad, + start_size=int(fonts.get("label_start", 42)), + min_size=int(fonts.get("label_min", 30)), + line_gap=GAP_LINE_SM, + load_font_fn=_load_font, + bold=True, + ) + label_block_h = measure_text_block(label_lines, label_font, line_gap=GAP_LINE_SM) + ly = ty0 + max(tile_pad // 2, (ribbon_h - label_block_h) // 2) + _draw_centered_lines( + draw, + lines=label_lines, + font=label_font, + x0=inner_x0, + width=inner_w, + y=ly, + fill=BRAND_ACCENT, + line_gap=GAP_LINE_SM, + ) + + content_top = ty0 + ribbon_h + tile_pad + content_bottom = ty1 - tile_pad + content_h = max(40, content_bottom - content_top) + body_font, body_lines = _fit_text_in_rect( + draw, + text, + inner_w, + content_h, + start_size=int(fonts.get("body_start", 44)), + min_size=int(fonts.get("body_min", 30)), + line_gap=GAP_LINE_MD, + load_font_fn=_load_font, + ) + body_block_h = measure_text_block(body_lines, body_font, line_gap=GAP_LINE_MD) + ty_text = content_top + max(0, (content_h - body_block_h) // 2) + _draw_centered_lines( + draw, + lines=body_lines, + font=body_font, + x0=inner_x0, + width=inner_w, + y=ty_text, + fill=INK, + line_gap=GAP_LINE_MD, + ) + + return canvas.convert("RGB") diff --git a/app/policy_cardnews/template/metrics.py b/app/policy_cardnews/template/metrics.py new file mode 100644 index 0000000..7e391b5 --- /dev/null +++ b/app/policy_cardnews/template/metrics.py @@ -0,0 +1,16 @@ +# 정책 카드뉴스 템플릿 전용 수치 모음 + +from __future__ import annotations + +# mascot / layout separation +GAP_MASCOT = 20 +MIN_MASCOT_ZONE_H = 220 + +# cover +COVER_MASCOT_MIN_H = 420 +COVER_MASCOT_ZONE_RATIO = 0.52 +COVER_TEXT_RATIO_WITH_HERO = 0.44 + +# CTA (template) +CTA_MIN_MASCOT_ZONE = 300 + diff --git a/app/policy_cardnews/terms.py b/app/policy_cardnews/terms.py new file mode 100644 index 0000000..2f7ded7 --- /dev/null +++ b/app/policy_cardnews/terms.py @@ -0,0 +1,169 @@ +"""정책 카드뉴스 — 어려운 용어를 쉬운 표현으로 바꾸고 설명란 문구 생성.""" + +from __future__ import annotations + +import re +from typing import Any + +# 본문·슬라이드 문구에서 바로 치환 (긴 표현부터 적용) +_TERM_REPLACEMENTS: tuple[tuple[str, str], ...] = ( + ("개인정보 침해", "내 정보(이름·연락처 등)가 새나는 일"), + ("개인정보", "내 정보(이름·연락처 등)"), + ("정보주체", "정보가 있는 본인"), + ("가명정보", "이름을 바꾼 정보"), + ("익명정보", "누구인지 알 수 없게 만든 정보"), + ("처리방침", "개인정보 다루는 규칙"), + ("동의철회", "동의 취소"), + ("수집·이용", "모으고 쓰기"), + ("제3자 제공", "다른 기관에 넘기기"), + ("위탁", "다른 곳에 맡기기"), + ("실태조사", "실제로 어떻게 되는지 점검"), + ("실태 점검", "실제로 어떻게 되는지 점검"), + ("예방 중심", "미리 막는 것을 우선"), + ("이행 점검", "약속대로 하는지 확인"), + ("시행령", "대통령령(세부 규칙)"), + ("고시", "부처가 정한 세부 기준"), + ("부칙", "시행 날짜 등 부가 규정"), + ("시행일", "시작 날짜"), + ("무주택", "집이 없는"), + ("차상위계층", "생활이 어려운 가구"), + ("기초생활수급", "생활비를 국가에서 받는"), + ("소득기준", "벌어들이는 돈 기준"), + ("재산기준", "가진 재산 기준"), + ("신청자격", "신청할 수 있는 조건"), + ("지원대상", "도움받을 수 있는 사람"), + ("온라인 접수", "인터넷으로 신청"), + ("오프라인", "직접 방문·우편"), + ("공고", "정부·기관 안내"), + ("보도자료", "언론에 낸 공식 안내"), +) + +# 설명란에 넣을 '용어 → 쉬운 말' (본문에 등장할 때만) +_TERM_GUIDE_DEFS: tuple[tuple[str, str, str], ...] = ( + ("개인정보", "개인정보", "이름·연락처처럼 나를 알 수 있는 정보"), + ("정보주체", "정보주체", "그 정보의 주인(본인)"), + ("실태조사", "실태조사", "실제로 잘 지키는지 현장에서 확인"), + ("실태 점검", "실태 점검", "실제로 잘 지키는지 확인"), + ("예방 중심", "예방 중심", "문제가 생기기 전에 막는 것을 우선"), + ("위탁", "위탁", "다른 기관에 업무를 맡김"), + ("제3자 제공", "제3자 제공", "다른 기관·회사에 정보를 넘김"), + ("무주택", "무주택", "본인·가족 명의 집이 없음"), + ("차상위", "차상위", "생활이 어려운 가구"), + ("기초생활", "기초생활", "생활비를 국가에서 지원받는 경우"), + ("소득·재산", "소득·재산", "벌어들이는 돈과 가진 재산"), + ("시행령", "시행령", "법을 실행하기 위한 대통령령"), + ("가명정보", "가명정보", "이름 등을 바꿔 놓은 정보"), +) + +_GUIDE_LABEL_MARKERS = ("쉬운말", "쉬운 말", "용어", "알아두기", "설명") + + +def simplify_policy_text(text: str) -> str: + value = (text or "").strip() + if not value: + return "" + for src, dst in _TERM_REPLACEMENTS: + if src in value: + value = value.replace(src, dst) + return re.sub(r"\s{2,}", " ", value).strip() + + +def extract_term_guides(*texts: str, max_items: int = 3) -> list[str]: + # 본문에서 찾은 어려운 용어에 대한 짧은 설명 줄 + blob = " ".join(t for t in texts if (t or "").strip()) + if not blob: + return [] + + found: list[str] = [] + seen: set[str] = set() + for needle, label, plain in _TERM_GUIDE_DEFS: + if needle not in blob and label not in blob: + continue + key = label + if key in seen: + continue + seen.add(key) + found.append(f"{label} → {plain}") + if len(found) >= max_items: + break + return found + + +def _simplify_item(item: Any) -> dict[str, str]: + if isinstance(item, dict): + return { + "label": simplify_policy_text(str(item.get("label") or "")), + "text": simplify_policy_text(str(item.get("text") or "")), + } + text = simplify_policy_text(str(item)) + return {"label": "", "text": text} + + +def apply_terms_to_slide(slide: dict[str, Any]) -> dict[str, Any]: + row = dict(slide) + for key in ("eyebrow", "headline", "highlight", "subtext", "body", "cta", "speech"): + row[key] = simplify_policy_text(str(row.get(key) or "")) + + items = [_simplify_item(i) for i in list(row.get("items") or []) if isinstance(i, (dict, str))] + row["items"] = [i for i in items if str(i.get("text") or "").strip()] + + raw_guides = row.get("term_guides") + guides: list[str] = [] + if isinstance(raw_guides, list): + for entry in raw_guides: + if isinstance(entry, dict): + term = simplify_policy_text(str(entry.get("term") or entry.get("label") or "")) + plain = simplify_policy_text(str(entry.get("plain") or entry.get("text") or "")) + if term and plain: + guides.append(f"{term} → {plain}") + else: + line = simplify_policy_text(str(entry)) + if line: + guides.append(line) + row["term_guides"] = guides[:3] + return row + + +def enrich_cardnews_terminology( + slides: list[dict[str, Any]], + *, + pin_content: str = "", +) -> list[dict[str, Any]]: + # 슬라이드 문구 쉬운 표현화 + 마무리 슬라이드에 용어 설명란 + if not slides: + return [] + + blob_parts = [pin_content] + enriched: list[dict[str, Any]] = [] + for slide in slides: + row = apply_terms_to_slide(slide) + for key in ("eyebrow", "headline", "highlight", "body", "cta"): + blob_parts.append(str(row.get(key) or "")) + for item in row.get("items") or []: + if isinstance(item, dict): + blob_parts.append(str(item.get("label") or "")) + blob_parts.append(str(item.get("text") or "")) + enriched.append(row) + + auto_guides = extract_term_guides(*blob_parts, max_items=3) + if not auto_guides: + return enriched + + for row in enriched: + layout = str(row.get("layout_type") or "") + if layout in {"cta", "template_cta"}: + existing = list(row.get("term_guides") or []) + if not existing: + row["term_guides"] = auto_guides + break + else: + last = dict(enriched[-1]) + if str(last.get("layout_type") or "").endswith("cta") or not last.get("term_guides"): + merged = list(last.get("term_guides") or []) + for g in auto_guides: + if g not in merged: + merged.append(g) + last["term_guides"] = merged[:3] + enriched[-1] = last + + return enriched diff --git a/app/policy_cardnews/visual.py b/app/policy_cardnews/visual.py new file mode 100644 index 0000000..01fe30f --- /dev/null +++ b/app/policy_cardnews/visual.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +from PIL import Image, ImageDraw + + +def paste_rounded_image_fit( + canvas: Image.Image, + photo: Image.Image, + *, + box: tuple[int, int, int, int], + radius: int = 24, + plate_fill: tuple[int, int, int] = (248, 250, 252), +) -> Image.Image: + x0, y0, x1, y1 = box + w, h = x1 - x0, y1 - y0 + if w <= 0 or h <= 0: + return canvas + + thumb = photo.copy().convert("RGBA") + tw, th = thumb.size + if tw <= 0 or th <= 0: + return canvas + + scale = min(w / tw, h / th) + nw = max(1, int(tw * scale)) + nh = max(1, int(th * scale)) + thumb = thumb.resize((nw, nh), Image.Resampling.LANCZOS) + + base = canvas.convert("RGBA") + shadow = Image.new("RGBA", base.size, (0, 0, 0, 0)) + ImageDraw.Draw(shadow).rounded_rectangle( + (x0 + 4, y0 + 6, x1 + 4, y1 + 6), + radius=radius, + fill=(0, 0, 0, 50), + ) + base = Image.alpha_composite(base, shadow) + + plate = Image.new("RGBA", (w, h), (*plate_fill, 255)) + plate_mask = Image.new("L", (w, h), 0) + ImageDraw.Draw(plate_mask).rounded_rectangle((0, 0, w, h), radius=radius, fill=255) + base.paste(plate, (x0, y0), plate_mask) + + px = x0 + (w - nw) // 2 + py = y0 + (h - nh) // 2 + base.paste(thumb, (px, py), thumb) + return base + + +def paste_rounded_image_cover( + canvas: Image.Image, + photo: Image.Image, + *, + box: tuple[int, int, int, int], + radius: int = 24, +) -> Image.Image: + """표지 등 — 비율 유지하며 영역을 가득 채움(중앙 크롭).""" + x0, y0, x1, y1 = box + w, h = x1 - x0, y1 - y0 + if w <= 0 or h <= 0: + return canvas + + thumb = photo.copy().convert("RGBA") + tw, th = thumb.size + if tw <= 0 or th <= 0: + return canvas + + scale = max(w / tw, h / th) + nw = max(1, int(tw * scale)) + nh = max(1, int(th * scale)) + thumb = thumb.resize((nw, nh), Image.Resampling.LANCZOS) + left = max(0, (nw - w) // 2) + top = max(0, (nh - h) // 2) + thumb = thumb.crop((left, top, left + w, top + h)) + + base = canvas.convert("RGBA") + shadow = Image.new("RGBA", base.size, (0, 0, 0, 0)) + ImageDraw.Draw(shadow).rounded_rectangle( + (x0 + 4, y0 + 6, x1 + 4, y1 + 6), + radius=radius, + fill=(0, 0, 0, 50), + ) + base = Image.alpha_composite(base, shadow) + + mask = Image.new("L", (w, h), 0) + ImageDraw.Draw(mask).rounded_rectangle((0, 0, w, h), radius=radius, fill=255) + base.paste(thumb, (x0, y0), mask) + return base diff --git a/app/repositories/CardnewsImageS3Repo.py b/app/repositories/CardnewsImageS3Repo.py new file mode 100644 index 0000000..4eab67d --- /dev/null +++ b/app/repositories/CardnewsImageS3Repo.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from sqlalchemy import delete, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.CardnewsImageS3 import CardnewsImageS3 +from app.repositories.BaseRepo import BaseRepo + + +class CardnewsImageS3Repo(BaseRepo[CardnewsImageS3]): + def __init__(self, session: AsyncSession) -> None: + super().__init__(session, CardnewsImageS3) + + async def list_by_community_id(self, community_id: int) -> list[CardnewsImageS3]: + result = await self.session.execute( + select(CardnewsImageS3) + .where(CardnewsImageS3.community_id == community_id) + .order_by(CardnewsImageS3.cardnews_image_s3_id.asc()), + ) + return list(result.scalars().all()) + + async def delete_by_community_id(self, community_id: int) -> int: + result = await self.session.execute( + delete(CardnewsImageS3).where(CardnewsImageS3.community_id == community_id), + ) + await self.session.flush() + return int(result.rowcount or 0) diff --git a/app/repositories/CommunityRepo.py b/app/repositories/CommunityRepo.py index 217de87..20d1897 100644 --- a/app/repositories/CommunityRepo.py +++ b/app/repositories/CommunityRepo.py @@ -19,3 +19,9 @@ async def get_community_id_by_pin_id(self, pin_id: int) -> int | None: ) value = result.scalar_one_or_none() return int(value) if value is not None else None + + async def get_by_pin_id(self, pin_id: int) -> Community | None: + result = await self.session.execute( + select(Community).where(Community.pin_id == pin_id).limit(1), + ) + return result.scalar_one_or_none() diff --git a/app/repositories/EventPinRepo.py b/app/repositories/EventPinRepo.py index 8f13ddb..4aa165d 100644 --- a/app/repositories/EventPinRepo.py +++ b/app/repositories/EventPinRepo.py @@ -39,3 +39,25 @@ async def count_festival_pins(self) -> int: ), ) return result.scalar_one() + + async def get_by_policy_api_id(self, policy_api_id: int) -> EventPin | None: + result = await self.session.execute( + select(EventPin) + .where(EventPin.policy_api_id == policy_api_id) + .options(*_EVENT_PIN_LOAD_OPTIONS), + ) + return result.scalar_one_or_none() + + async def list_policy_api_ids(self) -> set[int]: + result = await self.session.execute( + select(EventPin.policy_api_id).where(EventPin.policy_api_id.is_not(None)), + ) + return {int(row) for row in result.scalars().all() if row is not None} + + async def count_policy_pins(self) -> int: + result = await self.session.execute( + select(func.count(EventPin.event_pin_id)).where( + EventPin.policy_api_id.is_not(None), + ), + ) + return result.scalar_one() diff --git a/app/repositories/UserRepo.py b/app/repositories/UserRepo.py index b1418a0..c46d651 100644 --- a/app/repositories/UserRepo.py +++ b/app/repositories/UserRepo.py @@ -21,5 +21,23 @@ async def get_by_email(self, email: str) -> User | None: result = await self.session.execute(select(User).where(User.email == email)) return result.scalar_one_or_none() + async def get_by_nickname(self, nickname: str) -> User | None: + normalized = (nickname or "").strip() + if not normalized: + return None + result = await self.session.execute( + select(User).where(User.nickname == normalized).limit(1), + ) + return result.scalar_one_or_none() + + async def get_by_user_name(self, user_name: str) -> User | None: + normalized = (user_name or "").strip() + if not normalized: + return None + result = await self.session.execute( + select(User).where(User.user_name == normalized).limit(1), + ) + return result.scalar_one_or_none() + async def get_all_users(self) -> list[User]: return await self.get_all() diff --git a/app/routes/PolicyAdminRoute.py b/app/routes/PolicyAdminRoute.py new file mode 100644 index 0000000..7499404 --- /dev/null +++ b/app/routes/PolicyAdminRoute.py @@ -0,0 +1,191 @@ +from __future__ import annotations + +from fastapi import APIRouter, HTTPException, Query + +from app.core.codes import ErrorCode, SuccessCode +from app.core.config import settings +from app.core.deps import ( + AdminUserIdDep, + PolicyEventIngestServiceDep, + PolicyPinSchedulerDep, + PolicyPinServiceDep, + S3UtilDep, +) +from app.core.responses import SuccessEnvelope, success_response +from app.schemas.PolicyAdminDTO import ( + PolicyImportBatchResult, + PolicyPipelineStatusResult, + PolicySyncResult, + PolicyTransformBatchResult, +) +from app.utils.policy_news_parse import validate_yyyymmdd + +router = APIRouter(prefix="/policy-admin", tags=["policy-admin"]) + +_MAX_BATCH = 25 + + +def _runtime_error_response(exc: RuntimeError) -> HTTPException: + detail = str(exc) + if "GEMINI" in detail or "POLICY" in detail or "S3" in detail: + status = ErrorCode.VLM_NOT_CONFIGURED.http_status + else: + status = ErrorCode.INTERNAL_SERVER_ERROR.http_status + return HTTPException(status_code=status, detail=detail) + + +def _clamp_batch(value: int | None) -> int: + if value is None: + return settings.policy_sync_batch_size + return min(max(value, 1), _MAX_BATCH) + + +def _validate_optional_date_range( + start_date: str | None, + end_date: str | None, +) -> tuple[str | None, str | None]: + if start_date is None and end_date is None: + return None, None + if start_date is None or end_date is None: + raise HTTPException( + status_code=ErrorCode.BAD_REQUEST.http_status, + detail="start_date와 end_date는 함께 지정해야 합니다.", + ) + parsed_start = validate_yyyymmdd(start_date, label="start_date") + parsed_end = validate_yyyymmdd(end_date, label="end_date") + if parsed_start > parsed_end: + raise HTTPException( + status_code=ErrorCode.BAD_REQUEST.http_status, + detail="start_date는 end_date보다 이후일 수 없습니다.", + ) + return parsed_start, parsed_end + + +@router.post( + "/sync", + response_model=SuccessEnvelope[PolicySyncResult], + summary="정책 핀 3일 lookback 수집 → 배치 transform → 배치 DB 적재", + description=( + "오늘 포함 최근 POLICY_SYNC_LOOKBACK_DAYS(기본 3일) 구간을 수집하고, " + "신규 NewsItemId만 POLICY_SYNC_BATCH_SIZE 단위로 LLM 가공·DB INSERT합니다." + ), +) +async def sync_policy_pins( + service: PolicyPinServiceDep, + ingest_service: PolicyEventIngestServiceDep, + s3_util: S3UtilDep, + _admin_uid: AdminUserIdDep, + start_date: str | None = Query(default=None, min_length=8, max_length=8, description="YYYYMMDD"), + end_date: str | None = Query(default=None, min_length=8, max_length=8, description="YYYYMMDD"), + transform_limit: int | None = Query(default=None, ge=1, le=100, description="가공 최대 건수"), + batch_size: int | None = Query(default=None, ge=1, le=25, description="배치 크기"), +) -> SuccessEnvelope[PolicySyncResult]: + try: + validated_start, validated_end = _validate_optional_date_range(start_date, end_date) + body = await service.sync_pipeline( + ingest_service=ingest_service, + s3_util=s3_util, + start_date=validated_start, + end_date=validated_end, + transform_limit=transform_limit, + batch_size=_clamp_batch(batch_size), + ) + except ValueError as exc: + raise HTTPException(status_code=ErrorCode.BAD_REQUEST.http_status, detail=str(exc)) from exc + except FileNotFoundError as exc: + raise HTTPException(status_code=ErrorCode.NOT_FOUND.http_status, detail=str(exc)) from exc + except RuntimeError as exc: + raise _runtime_error_response(exc) from exc + + return success_response(result=body, success_code=SuccessCode.CREATED) + + +@router.post( + "/transform-batch", + response_model=SuccessEnvelope[PolicyTransformBatchResult], + summary="원문 JSONL 배치 가공 (신규 건만)", +) +async def transform_policy_batch( + service: PolicyPinServiceDep, + ingest_service: PolicyEventIngestServiceDep, + s3_util: S3UtilDep, + _admin_uid: AdminUserIdDep, + batch_size: int | None = Query(default=None, ge=1, le=25, description="이번 배치 가공 건수"), +) -> SuccessEnvelope[PolicyTransformBatchResult]: + try: + db_ids = await ingest_service.get_imported_policy_api_ids() + body = await service.transform_batch( + batch_size=_clamp_batch(batch_size), + s3_util=s3_util, + db_policy_api_ids=db_ids, + ) + except FileNotFoundError as exc: + raise HTTPException(status_code=ErrorCode.NOT_FOUND.http_status, detail=str(exc)) from exc + except RuntimeError as exc: + raise _runtime_error_response(exc) from exc + + return success_response(result=body, success_code=SuccessCode.CREATED) + + +@router.post( + "/import-batch", + response_model=SuccessEnvelope[PolicyImportBatchResult], + summary="handoff JSONL 배치 DB 적재", +) +async def import_policy_batch( + ingest_service: PolicyEventIngestServiceDep, + _admin_uid: AdminUserIdDep, + batch_size: int | None = Query(default=None, ge=1, le=25, description="이번 배치 INSERT 건수"), +) -> SuccessEnvelope[PolicyImportBatchResult]: + try: + body = await ingest_service.import_handoff_batch( + import_all=False, + limit=_clamp_batch(batch_size), + ) + except FileNotFoundError as exc: + raise HTTPException(status_code=ErrorCode.NOT_FOUND.http_status, detail=str(exc)) from exc + except RuntimeError as exc: + raise _runtime_error_response(exc) from exc + + return success_response(result=body, success_code=SuccessCode.CREATED) + + +@router.get( + "/status", + response_model=SuccessEnvelope[PolicyPipelineStatusResult], + summary="정책 핀 파이프라인 상태", +) +async def policy_pipeline_status( + ingest_service: PolicyEventIngestServiceDep, + _admin_uid: AdminUserIdDep, +) -> SuccessEnvelope[PolicyPipelineStatusResult]: + body = await ingest_service.get_pipeline_status() + return success_response(result=body, success_code=SuccessCode.OK) + + +@router.post( + "/scheduler/run-once", + response_model=SuccessEnvelope[PolicySyncResult], + summary="정책 핀 스케줄러 즉시 1회 실행 (테스트용, 간격 무시)", +) +async def run_policy_scheduler_once( + service: PolicyPinServiceDep, + ingest_service: PolicyEventIngestServiceDep, + s3_util: S3UtilDep, + scheduler: PolicyPinSchedulerDep, + _admin_uid: AdminUserIdDep, +) -> SuccessEnvelope[PolicySyncResult]: + try: + if scheduler is not None: + body = await scheduler.run_once_now(force=True) + else: + body = await service.sync_pipeline( + ingest_service=ingest_service, + s3_util=s3_util, + ) + except FileNotFoundError as exc: + raise HTTPException(status_code=ErrorCode.NOT_FOUND.http_status, detail=str(exc)) from exc + except RuntimeError as exc: + raise _runtime_error_response(exc) from exc + + return success_response(result=body, success_code=SuccessCode.CREATED) diff --git a/app/routes/PolicyPinRoute.py b/app/routes/PolicyPinRoute.py new file mode 100644 index 0000000..bd4095e --- /dev/null +++ b/app/routes/PolicyPinRoute.py @@ -0,0 +1,125 @@ +from __future__ import annotations + +from fastapi import APIRouter, HTTPException, Query + +from app.core.codes import ErrorCode, SuccessCode +from app.core.deps import PolicyPinServiceDep, S3UtilDep +from app.core.responses import SuccessEnvelope, success_response +from app.schemas.PolicyPinDTO import ( + PolicyPinHandoffResult, + PolicyPinSearchResult, + PolicyPinTransformResult, +) +from app.utils.policy_news_parse import validate_yyyymmdd + +router = APIRouter(prefix="/policy-pins", tags=["policy-pins"]) + + +def _runtime_error_response(exc: RuntimeError) -> HTTPException: + detail = str(exc) + if "GEMINI" in detail or "POLICY" in detail: + status = ErrorCode.VLM_NOT_CONFIGURED.http_status + else: + status = ErrorCode.INTERNAL_SERVER_ERROR.http_status + return HTTPException(status_code=status, detail=detail) + + +@router.get( + "/search", + response_model=SuccessEnvelope[PolicyPinSearchResult], + summary="정책뉴스 OpenAPI 수집 → 원문 JSONL 저장", + description=( + "정책뉴스 API를 승인일(start_date~end_date) 기준으로 조회해 " + "policy_documents.jsonl에 저장합니다. 기간 필터는 이 단계에서만 적용됩니다." + ), +) +async def search_policy_news( + service: PolicyPinServiceDep, + start_date: str = Query(..., min_length=8, max_length=8, description="YYYYMMDD"), + end_date: str = Query(..., min_length=8, max_length=8, description="YYYYMMDD"), + limit: int = Query(default=10, ge=1, le=50, description="최대 수집 건수"), +) -> SuccessEnvelope[PolicyPinSearchResult]: + try: + parsed_start = validate_yyyymmdd(start_date, label="start_date") + parsed_end = validate_yyyymmdd(end_date, label="end_date") + except ValueError as exc: + raise HTTPException( + status_code=ErrorCode.BAD_REQUEST.http_status, + detail=str(exc), + ) from exc + if parsed_start > parsed_end: + raise HTTPException( + status_code=ErrorCode.BAD_REQUEST.http_status, + detail="start_date는 end_date보다 이후일 수 없습니다.", + ) + + try: + body = await service.search_and_save( + start_date=parsed_start, + end_date=parsed_end, + limit=limit, + ) + except RuntimeError as exc: + raise _runtime_error_response(exc) from exc + + return success_response(result=body, success_code=SuccessCode.OK) + + +@router.post( + "/transform", + response_model=SuccessEnvelope[PolicyPinTransformResult], + summary="원문 JSONL → AI 가공 → DB용 JSONL 저장", + description=( + "search로 저장된 policy_documents.jsonl을 가공해 " + "policy_pins_for_db.jsonl을 만듭니다." + ), +) +async def transform_policy_documents( + service: PolicyPinServiceDep, + s3_util: S3UtilDep, + limit: int | None = Query( + default=None, + ge=1, + le=50, + description="가공 최대 건수 (미지정 시 원문 파일 전체)", + ), +) -> SuccessEnvelope[PolicyPinTransformResult]: + try: + body = await service.transform_and_save(limit=limit, s3_util=s3_util) + except FileNotFoundError as exc: + raise HTTPException( + status_code=ErrorCode.NOT_FOUND.http_status, + detail=str(exc), + ) from exc + except RuntimeError as exc: + raise _runtime_error_response(exc) from exc + + return success_response(result=body, success_code=SuccessCode.CREATED) + + +@router.get( + "/handoff", + response_model=SuccessEnvelope[PolicyPinHandoffResult], + summary="DB용 JSONL 조회", + description=( + "policy_pins_for_db.jsonl을 조회합니다. " + "기간 필터는 GET /search에서 이미 반영된 뒤 transform으로 4필드만 추출된 결과입니다." + ), +) +async def list_policy_handoff( + service: PolicyPinServiceDep, + limit: int | None = Query( + default=None, + ge=1, + le=500, + description="반환 최대 건수", + ), +) -> SuccessEnvelope[PolicyPinHandoffResult]: + try: + body = service.load_from_jsonl(limit=limit) + except FileNotFoundError as exc: + raise HTTPException( + status_code=ErrorCode.NOT_FOUND.http_status, + detail=str(exc), + ) from exc + return success_response(result=body, success_code=SuccessCode.OK) diff --git a/app/routes/__init__.py b/app/routes/__init__.py index 5697fff..12d94d0 100644 --- a/app/routes/__init__.py +++ b/app/routes/__init__.py @@ -6,6 +6,8 @@ from app.routes.TestRoute import router as test_router from app.routes.VectorTestRoute import router as vector_test_router from app.routes.FestivalPinRoute import router as festival_pin_router +from app.routes.PolicyAdminRoute import router as policy_admin_router +from app.routes.PolicyPinRoute import router as policy_pin_router from app.routes.FestivalAdminRoute import router as festival_admin_router from app.core.config import settings @@ -18,6 +20,8 @@ {"router": test_router, "disabled_envs": {"dev", "prod"}}, {"router": vector_test_router, "disabled_envs": {"dev", "prod"}}, {"router": festival_pin_router, "disabled_envs": {"prod"}}, + {"router": policy_pin_router, "disabled_envs": {"prod"}}, + {"router": policy_admin_router, "disabled_envs": {"prod"}}, {"router": festival_admin_router, "disabled_envs": set()}, ) diff --git a/app/schemas/PolicyAdminDTO.py b/app/schemas/PolicyAdminDTO.py new file mode 100644 index 0000000..535a5d7 --- /dev/null +++ b/app/schemas/PolicyAdminDTO.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +from enum import Enum +from typing import Any + +from pydantic import BaseModel, Field + +from app.schemas.PolicyPinDTO import PolicyPinHandoffDTO, PolicyPinSearchResult, PolicyPinTransformResult + + +class PolicyBatchAction(str, Enum): + CREATED = "created" + SKIPPED = "skipped" + ERROR = "error" + + +class PolicyBatchItemResult(BaseModel): + policy_api_id: int | None = None + pin_title: str | None = None + action: PolicyBatchAction + message: str | None = None + + +class PolicyImportBatchResult(BaseModel): + inserted_count: int + skipped_duplicate_count: int + pending_import_count: int + error_count: int + errors: list[dict[str, Any]] = Field(default_factory=list) + items: list[PolicyBatchItemResult] = Field(default_factory=list) + pin_ids: list[int] = Field(default_factory=list) + requested_batch_size: int | None = None + hint: str | None = None + + +class PolicyTransformBatchResult(PolicyPinTransformResult): + requested_batch_size: int | None = None + batches_run: int = 1 + + +class PolicyPipelineStatusResult(BaseModel): + query_start_date: str | None = None + query_end_date: str | None = None + last_sync_at: str | None = None + documents_count: int + handoff_count: int + db_policy_count: int + pending_transform_count: int + pending_import_count: int + is_caught_up: bool = Field( + default=False, + description="미가공·미적재 건이 없어 DB와 파이프라인이 동기화된 상태", + ) + hint: str | None = None + + +class PolicySyncResult(BaseModel): + query_start_date: str + query_end_date: str + search: PolicyPinSearchResult + transform: PolicyPinTransformResult + import_result: PolicyImportBatchResult + hint: str | None = None diff --git a/app/schemas/PolicyPinDTO.py b/app/schemas/PolicyPinDTO.py new file mode 100644 index 0000000..120d566 --- /dev/null +++ b/app/schemas/PolicyPinDTO.py @@ -0,0 +1,166 @@ +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict, Field + + +class PolicyPinSourceDTO(BaseModel): + # 정책뉴스 API 수집 1건 (transform 전 원문) + + contentid: str = Field(description="NewsItemId") + pin_title: str + pin_content: str = Field(description="본문 (HTML 제거 또는 TEXT)") + pin_content_raw: str = Field(description="DataContents 원문") + minister: str = "" + grouping_code: str = "" + contents_type: str = "H" + approve_date: str = "" + event_start_time: str | None = Field( + default=None, + description="승인일 YYYYMMDD → event_pin.event_start_time", + ) + event_end_time: str | None = Field( + default=None, + description="승인일 YYYYMMDD → event_pin.event_end_time", + ) + original_image_urls: list[str] = Field( + default_factory=list, + description="원본 대표 이미지 (OriginalimgUrl 등) → pin_image is_main", + ) + cardnews_image_urls: list[str] = Field( + default_factory=list, + description="카드뉴스 슬라이드/API 인라인 이미지 → pin_image", + ) + image_urls: list[str] = Field( + default_factory=list, + description="original + cardnews 통합 (하위 호환)", + ) + source_url: str = "" + subtitles: str = "" + contents_status: str = "" + + +class PolicyPinHandoffDTO(BaseModel): + # DB 전달용 policy_pins_for_db.jsonl 1행 스키마 + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "title": "올 하반기부터 개인정보 침해 위험 실태 점검", + "pin_content": "개인정보, 이제 위험도에 따라...\n\n#정책 #개인정보보호", + "cardnews_image_urls": [ + "rag/output/policy_cardnews/148965005/slide_01.png", + "rag/output/policy_cardnews/148965005/slide_02.png", + ], + "source_url": "https://www.korea.kr/news/policyNewsView.do?newsId=148965005", + }, + }, + ) + + title: str = Field(description="pin.pin_title") + pin_content: str = Field(description="AI 가공 본문 → pin.pin_content") + cardnews_image_urls: list[str] = Field( + default_factory=list, + description="카드뉴스 슬라이드 경로/URL → pin_image", + ) + source_url: str = Field(default="", description="기사 원문 URL") + + @classmethod + def from_row(cls, row: dict) -> PolicyPinHandoffDTO: + data = dict(row) if isinstance(row, dict) else {} + if not str(data.get("title") or "").strip(): + legacy = str(data.get("pin_title") or "").strip() + if legacy: + data["title"] = legacy + return cls.model_validate(data) + + +# 하위 호환 별칭 +PolicyPinDTO = PolicyPinHandoffDTO + + +class PolicyPinTransformResult(BaseModel): + input_path: str + output_path: str = Field(description="DB용 JSONL 경로 (pins와 동일 내용)") + processed_count: int + error_count: int + errors: list[dict] = Field(default_factory=list) + pins: list[PolicyPinHandoffDTO] = Field( + description="output_path JSONL에 저장된 행과 동일 (DB 전달 4필드)", + ) + hint: str | None = None + skipped_duplicate_count: int = 0 + pending_count: int = 0 + remaining_pending_count: int = Field( + default=0, + description="가공 후에도 handoff·DB에 없는 원문 건수", + ) + + +class PolicyPinHandoffResult(BaseModel): + output_path: str = Field(description="policy_pins_for_db.jsonl (transform과 동일 파일)") + total_in_file: int = Field(description="JSONL 전체 행 수") + count: int = Field(description="반환 건수 (limit 적용 후)") + pins: list[PolicyPinHandoffDTO] = Field( + description="JSONL 내용 (transform 응답 pins와 동일, limit만 다를 수 있음)", + ) + hint: str | None = Field(default=None, description="조회 안내") + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "output_path": "rag/output/policy_pins_for_db.jsonl", + "total_in_file": 5, + "count": 5, + "hint": None, + "pins": [ + { + "title": "올 하반기부터 개인정보 침해 위험 실태 점검", + "pin_content": "개인정보, 이제 위험도에 따라...", + "cardnews_image_urls": [ + "rag/output/policy_cardnews/148965005/slide_01.png", + ], + "source_url": "https://www.korea.kr/news/policyNewsView.do?newsId=148965005", + }, + ], + }, + }, + ) + + +class PolicyPinSearchResult(BaseModel): + query_start_date: str + query_end_date: str + count: int + saved_documents_path: str + pins: list[PolicyPinSourceDTO] + stats: dict[str, int] = Field(default_factory=dict) + hint: str | None = None + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "query_start_date": "20260522", + "query_end_date": "20260524", + "count": 2, + "saved_documents_path": "rag/output/policy_documents.jsonl", + "stats": {"documents": 2, "chunks": 1, "api_errors": 0}, + "hint": "다음: POST /policy-pins/transform", + "pins": [ + { + "contentid": "148965005", + "pin_title": "정책 뉴스 제목", + "pin_content": "본문 요약 텍스트...", + "pin_content_raw": "
...
", + "minister": "개인정보보호위원회", + "approve_date": "05/22/2026 17:32:00", + "event_start_time": "20260522", + "event_end_time": "20260522", + "image_urls": [ + "https://www.korea.kr/newsWeb/resources/attaches/2026.05/22/example.jpg", + ], + "source_url": "https://www.korea.kr/news/policyNewsView.do?newsId=148965005", + }, + ], + }, + }, + ) diff --git a/app/services/PolicyEventIngestService.py b/app/services/PolicyEventIngestService.py new file mode 100644 index 0000000..6510653 --- /dev/null +++ b/app/services/PolicyEventIngestService.py @@ -0,0 +1,362 @@ +from __future__ import annotations + +import json +import logging +from datetime import datetime, time +from pathlib import Path +from typing import Any +from zoneinfo import ZoneInfo + +from anyio import to_thread +from app.core.config import settings +from app.models.CardnewsImageS3 import CardnewsImageS3 +from app.models.Community import Community +from app.models.EventPin import EventPin +from app.models.Pin import Pin +from app.models.enum.PinType import PinType +from app.models.enum.ToneType import ToneType +from app.repositories.CardnewsImageS3Repo import CardnewsImageS3Repo +from app.repositories.CommunityRepo import CommunityRepo +from app.repositories.EventPinRepo import EventPinRepo +from app.repositories.PinRepo import PinRepo +from app.repositories.UserRepo import UserRepo +from app.schemas.PolicyAdminDTO import ( + PolicyBatchAction, + PolicyBatchItemResult, + PolicyImportBatchResult, + PolicyPipelineStatusResult, +) +from app.services.policy_pipeline_cleanup import cleanup_after_policy_import +from app.services.policy_pin_transform import ( + POLICY_DOCUMENTS_PATH, + POLICY_HANDOFF_PATH, + POLICY_SYNC_META_PATH, + count_pending_transform, + load_jsonl_rows, + load_rows_by_content_id, + parse_policy_api_id, +) +from app.utils.policy_news_parse import approve_date_to_yyyymmdd, parse_policy_datetime + +logger = logging.getLogger(__name__) + +_KST = ZoneInfo("Asia/Seoul") +_IMPORT_REPORT_PATH = POLICY_HANDOFF_PATH.with_name("policy_import_batch_report.json") +_COMMUNITY_TYPE_POLICY = "POLICY" + + +def _parse_event_datetime(value: Any) -> datetime: + if value is None: + raise ValueError("event datetime 없음") + if isinstance(value, datetime): + return value.replace(tzinfo=None) + text = str(value).strip() + if not text: + raise ValueError("event datetime 비어 있음") + if len(text) == 8 and text.isdigit(): + year, month, day = int(text[:4]), int(text[4:6]), int(text[6:8]) + return datetime(year, month, day) + parsed = parse_policy_datetime(text) + if parsed is not None: + return parsed.replace(tzinfo=None) + raise ValueError(f"event datetime 파싱 실패: {value!r}") + + +def _event_window_from_row(row: dict[str, Any]) -> tuple[datetime, datetime]: + start_raw = row.get("event_start_time") or approve_date_to_yyyymmdd(row.get("approve_date")) + end_raw = row.get("event_end_time") or start_raw + start_dt = _parse_event_datetime(start_raw) + end_dt = _parse_event_datetime(end_raw) + if end_dt < start_dt: + end_dt = start_dt + start_at = datetime.combine(start_dt.date(), time.min) + end_at = datetime.combine(end_dt.date(), time.max.replace(microsecond=0)) + return start_at, end_at + + +class PolicyEventIngestService: + def __init__( + self, + *, + pin_repo: PinRepo, + event_pin_repo: EventPinRepo, + community_repo: CommunityRepo, + cardnews_image_s3_repo: CardnewsImageS3Repo, + user_repo: UserRepo, + ) -> None: + self._pin_repo = pin_repo + self._event_pin_repo = event_pin_repo + self._community_repo = community_repo + self._cardnews_image_s3_repo = cardnews_image_s3_repo + self._user_repo = user_repo + + async def commit(self) -> None: + await self._pin_repo.commit() + + async def rollback(self) -> None: + await self._pin_repo.rollback() + + async def get_imported_policy_api_ids(self) -> set[int]: + return await self._event_pin_repo.list_policy_api_ids() + + async def resolve_admin_uid(self) -> str: + user_name = settings.policy_admin_user_name.strip() + user = await self._user_repo.get_by_user_name(user_name) + if user is None: + raise RuntimeError( + f"정책 핀 등록용 사용자를 찾을 수 없습니다 (user_name={user_name!r}).", + ) + return str(user.uid) + + async def import_handoff_batch( + self, + *, + admin_uid: str | None = None, + import_all: bool = True, + limit: int | None = None, + ) -> PolicyImportBatchResult: + handoff_rows = await to_thread.run_sync(load_jsonl_rows, POLICY_HANDOFF_PATH) + db_ids = await self.get_imported_policy_api_ids() + if not handoff_rows: + documents = await to_thread.run_sync(load_jsonl_rows, POLICY_DOCUMENTS_PATH) + pending_transform = count_pending_transform(documents, {}, db_policy_api_ids=db_ids) + if pending_transform > 0: + raise FileNotFoundError( + f"핸드오프 JSONL 없음: {POLICY_HANDOFF_PATH}. " + f"transform을 먼저 실행하세요. (미가공 {pending_transform}건)", + ) + effective_batch = None if import_all else limit + return PolicyImportBatchResult( + inserted_count=0, + skipped_duplicate_count=0, + pending_import_count=0, + error_count=0, + requested_batch_size=effective_batch, + hint=( + f"DB에 policy 핀 {len(db_ids)}건이 있어 적재 완료 상태입니다. " + "handoff JSONL이 비어 있는 것은 import 후 캐시 정리로 정상입니다." + ), + ) + + uid = admin_uid or await self.resolve_admin_uid() + inserted_count = 0 + skipped_duplicate_count = 0 + errors: list[dict[str, Any]] = [] + items: list[PolicyBatchItemResult] = [] + pin_ids: list[int] = [] + prune_policy_api_ids: set[int] = set() + + pending_rows: list[dict[str, Any]] = [] + for row in handoff_rows: + policy_api_id = parse_policy_api_id(row) + if policy_api_id is None: + continue + if policy_api_id in db_ids: + skipped_duplicate_count += 1 + prune_policy_api_ids.add(policy_api_id) + items.append( + PolicyBatchItemResult( + policy_api_id=policy_api_id, + pin_title=str(row.get("title") or row.get("pin_title") or ""), + action=PolicyBatchAction.SKIPPED, + message="DB에 이미 존재", + ), + ) + else: + pending_rows.append(row) + + target_rows = pending_rows if import_all else pending_rows[: (limit or len(pending_rows))] + if limit is not None and import_all: + target_rows = pending_rows[:limit] + + for row in target_rows: + policy_api_id = parse_policy_api_id(row) + if policy_api_id is None: + errors.append({"row": row, "error": "policy_api_id 없음"}) + continue + if policy_api_id in db_ids: + skipped_duplicate_count += 1 + prune_policy_api_ids.add(policy_api_id) + continue + try: + async with self._pin_repo.session.begin_nested(): + pin_id = await self._insert_policy_pin(admin_uid=uid, row=row, policy_api_id=policy_api_id) + inserted_count += 1 + db_ids.add(policy_api_id) + prune_policy_api_ids.add(policy_api_id) + pin_ids.append(pin_id) + items.append( + PolicyBatchItemResult( + policy_api_id=policy_api_id, + pin_title=str(row.get("title") or row.get("pin_title") or ""), + action=PolicyBatchAction.CREATED, + ), + ) + except Exception as exc: + logger.exception("policy import failed policy_api_id=%s", policy_api_id) + errors.append( + { + "policy_api_id": policy_api_id, + "pin_title": row.get("title") or row.get("pin_title"), + "error": str(exc), + }, + ) + items.append( + PolicyBatchItemResult( + policy_api_id=policy_api_id, + pin_title=str(row.get("title") or row.get("pin_title") or ""), + action=PolicyBatchAction.ERROR, + message=str(exc), + ), + ) + + await self._pin_repo.commit() + + if settings.policy_prune_pipeline_after_import and prune_policy_api_ids: + await to_thread.run_sync(cleanup_after_policy_import, prune_policy_api_ids) + + pending_import = 0 + for row in handoff_rows: + policy_api_id = parse_policy_api_id(row) + if policy_api_id is not None and policy_api_id not in db_ids: + pending_import += 1 + + report = { + "inserted_count": inserted_count, + "skipped_duplicate_count": skipped_duplicate_count, + "errors": errors, + } + _IMPORT_REPORT_PATH.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8") + + effective_batch = None if import_all else limit + return PolicyImportBatchResult( + inserted_count=inserted_count, + skipped_duplicate_count=skipped_duplicate_count, + pending_import_count=pending_import, + error_count=len(errors), + errors=errors, + items=items, + pin_ids=pin_ids, + requested_batch_size=effective_batch, + ) + + async def _insert_policy_pin( + self, + *, + admin_uid: str, + row: dict[str, Any], + policy_api_id: int, + ) -> int: + title = str(row.get("title") or row.get("pin_title") or "").strip()[:100] + content = str(row.get("pin_content") or "").strip() + if not title or not content: + raise ValueError("title 또는 pin_content가 비어 있음") + + start_at, end_at = _event_window_from_row(row) + + pin = Pin( + uid=admin_uid, + pin_type=PinType.POLICY, + pin_title=title, + pin_content=content, + tone_type=ToneType.NONE, + like_count=0, + view_count=0, + ) + await self._pin_repo.save(pin, flush_immediately=True) + + event_pin = EventPin( + pin_id=pin.pin_id, + festival_api_id=None, + policy_api_id=policy_api_id, + event_start_time=start_at, + event_end_time=end_at, + discount=None, + ) + await self._event_pin_repo.save(event_pin, flush_immediately=True) + + community = Community( + pin_id=pin.pin_id, + community_type=_COMMUNITY_TYPE_POLICY, + popularity=0.0, + ) + await self._community_repo.save(community, flush_immediately=True) + + cardnews_images = row.get("cardnews_images") or [] + + for image in cardnews_images: + if not isinstance(image, dict): + continue + key = str(image.get("key") or "").strip() + url = str(image.get("url") or "").strip() + if not key or not url: + continue + entity = CardnewsImageS3( + community_id=community.community_id, + cardnews_image_s3_key=key, + cardnews_image_s3_url=url, + ) + await self._cardnews_image_s3_repo.save(entity, flush_immediately=True) + + return int(pin.pin_id) + + async def get_pipeline_status(self) -> PolicyPipelineStatusResult: + meta = self._load_sync_meta() + documents = load_jsonl_rows(POLICY_DOCUMENTS_PATH) + handoff_by_id = load_rows_by_content_id(POLICY_HANDOFF_PATH) + db_ids = await self.get_imported_policy_api_ids() + + pending_transform = count_pending_transform(documents, handoff_by_id, db_policy_api_ids=db_ids) + pending_import = 0 + for row in handoff_by_id.values(): + policy_api_id = parse_policy_api_id(row) + if policy_api_id is not None and policy_api_id not in db_ids: + pending_import += 1 + + is_caught_up = pending_transform == 0 and pending_import == 0 + hint: str | None = None + if is_caught_up and len(db_ids) > 0: + hint = ( + f"DB에 policy 핀 {len(db_ids)}건 반영 완료. " + "handoff_count=0은 import 후 JSONL 캐시 정리로 정상입니다." + ) + elif pending_transform > 0: + hint = f"미가공 {pending_transform}건 — transform-batch 또는 sync를 실행하세요." + elif pending_import > 0: + hint = f"미적재 {pending_import}건 — import-batch 또는 sync를 실행하세요." + + return PolicyPipelineStatusResult( + query_start_date=meta.get("query_start_date"), + query_end_date=meta.get("query_end_date"), + last_sync_at=meta.get("last_sync_at"), + documents_count=len(documents), + handoff_count=len(handoff_by_id), + db_policy_count=len(db_ids), + pending_transform_count=pending_transform, + pending_import_count=pending_import, + is_caught_up=is_caught_up, + hint=hint, + ) + + @staticmethod + def _load_sync_meta() -> dict[str, Any]: + if not POLICY_SYNC_META_PATH.is_file(): + return {} + try: + return json.loads(POLICY_SYNC_META_PATH.read_text(encoding="utf-8")) + except json.JSONDecodeError: + return {} + + @staticmethod + def write_sync_meta( + *, + query_start_date: str, + query_end_date: str, + ) -> None: + meta = { + "last_sync_at": datetime.now(_KST).isoformat(timespec="seconds"), + "query_start_date": query_start_date, + "query_end_date": query_end_date, + } + POLICY_SYNC_META_PATH.parent.mkdir(parents=True, exist_ok=True) + POLICY_SYNC_META_PATH.write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8") diff --git a/app/services/PolicyPinService.py b/app/services/PolicyPinService.py new file mode 100644 index 0000000..f1ab01e --- /dev/null +++ b/app/services/PolicyPinService.py @@ -0,0 +1,374 @@ +from __future__ import annotations + +import asyncio +import json +from datetime import date, timedelta +from pathlib import Path +from typing import Any + +from app.clients.PolicyNewsClient import PolicyNewsClient +from app.core.config import settings +from app.schemas.PolicyAdminDTO import ( + PolicyBatchAction, + PolicyBatchItemResult, + PolicyImportBatchResult, + PolicySyncResult, + PolicyTransformBatchResult, +) +from app.schemas.PolicyPinDTO import ( + PolicyPinHandoffDTO, + PolicyPinHandoffResult, + PolicyPinSearchResult, + PolicyPinSourceDTO, + PolicyPinTransformResult, +) +from app.services.PolicyEventIngestService import PolicyEventIngestService +from app.services.policy_pipeline_cleanup import prune_pipeline_imported +from app.services.policy_pin_transform import ( + POLICY_DOCUMENTS_PATH, + POLICY_HANDOFF_PATH, + load_jsonl_rows, + load_rows_by_content_id, + merge_documents, + transform_documents_jsonl, +) +from app.utils.S3Util import S3Util +from rag.scripts.chunk_module import write_jsonl +from rag.scripts.fetch_policy_news import fetch_policy_documents + +_MAX_HANDOFF_ITEMS = 500 + + +async def _enrich_cover_image_urls(documents: list[dict[str, Any]]) -> list[dict[str, Any]]: + from app.utils.policy_news_parse import enrich_cover_image_urls as resolve_cover_image_urls + + async def enrich_one(doc: dict[str, Any]) -> dict[str, Any]: + row = dict(doc) + if row.get("image_urls"): + return row + urls = await resolve_cover_image_urls([], source_url=str(row.get("source_url") or "")) + if urls: + row["original_image_urls"] = urls[:1] + row["image_urls"] = urls + return row + + return list(await asyncio.gather(*(enrich_one(doc) for doc in documents))) + + +class PolicyPinService: + @staticmethod + def documents_path() -> Path: + return POLICY_DOCUMENTS_PATH + + @staticmethod + def handoff_path() -> Path: + return POLICY_HANDOFF_PATH + + async def search_and_save( + self, + *, + start_date: str, + end_date: str, + limit: int | None = 10, + merge: bool = True, + ) -> PolicyPinSearchResult: + fetch_limit: int | None + if limit is None: + fetch_limit = None + else: + fetch_limit = min(max(limit, 1), 50) + + async with PolicyNewsClient.from_settings() as client: + documents, stats = await fetch_policy_documents( + client=client, + start_date=start_date, + end_date=end_date, + limit=fetch_limit, + ) + + documents = await _enrich_cover_image_urls(documents) + + path = self.documents_path() + path.parent.mkdir(parents=True, exist_ok=True) + if merge: + existing = load_rows_by_content_id(path) + merged = merge_documents(existing, documents) + write_jsonl(path, merged) + total_in_file = len(merged) + else: + write_jsonl(path, documents) + total_in_file = len(documents) + + pins = [PolicyPinSourceDTO.model_validate(doc) for doc in documents] + hint: str | None + if len(pins) == 0: + hint = ( + f"수집 0건 (API 호출 {stats.get('chunks', 0)}회). " + "정책뉴스 API는 승인일 기준이며 미래 날짜 구간은 비어 있습니다. " + f"JSONL 총 {total_in_file}건 유지." + ) + else: + hint = ( + f"이번 수집 {len(pins)}건, {path.name} 총 {total_in_file}건. " + "다음: POST /policy-pins/transform 또는 /policy-admin/sync" + ) + return PolicyPinSearchResult( + query_start_date=start_date, + query_end_date=end_date, + count=len(pins), + pins=pins, + saved_documents_path=str(path), + stats={k: int(v) for k, v in stats.items()}, + hint=hint, + ) + + async def transform_and_save( + self, + *, + limit: int | None = None, + model: str | None = None, + s3_util: S3Util | None = None, + db_policy_api_ids: set[int] | None = None, + ) -> PolicyPinTransformResult: + return await transform_documents_jsonl( + limit=limit, + model=model, + s3_util=s3_util, + db_policy_api_ids=db_policy_api_ids, + merge_handoff=True, + ) + + async def transform_batch( + self, + *, + batch_size: int | None = None, + model: str | None = None, + s3_util: S3Util | None = None, + db_policy_api_ids: set[int] | None = None, + ) -> PolicyTransformBatchResult: + size = batch_size or settings.policy_sync_batch_size + result = await self.transform_and_save( + limit=size, + model=model, + s3_util=s3_util, + db_policy_api_ids=db_policy_api_ids, + ) + return PolicyTransformBatchResult( + **result.model_dump(), + requested_batch_size=size, + batches_run=1, + ) + + async def sync_pipeline( + self, + *, + ingest_service: PolicyEventIngestService, + s3_util: S3Util, + start_date: str | None = None, + end_date: str | None = None, + transform_limit: int | None = None, + batch_size: int | None = None, + ) -> PolicySyncResult: + if start_date is None or end_date is None: + start_date, end_date = self.default_date_range() + + effective_batch = batch_size or settings.policy_sync_batch_size + + if settings.policy_prune_pipeline_after_import: + db_ids_before = await ingest_service.get_imported_policy_api_ids() + prune_pipeline_imported(db_ids_before) + + search = await self.search_and_save( + start_date=start_date, + end_date=end_date, + limit=None, + merge=settings.policy_sync_merge_documents, + ) + + total_processed = 0 + total_imported = 0 + total_skipped_import = 0 + total_skipped_transform = 0 + transform_errors: list[dict] = [] + transform_pins: list = [] + import_errors: list[dict] = [] + import_items: list[PolicyBatchItemResult] = [] + import_pin_ids: list[int] = [] + batches_run = 0 + last_transform_pending = 0 + last_import = PolicyImportBatchResult( + inserted_count=0, + skipped_duplicate_count=0, + pending_import_count=0, + error_count=0, + ) + + def _accumulate_import(import_batch: PolicyImportBatchResult) -> None: + nonlocal total_imported, total_skipped_import, last_import + total_imported += import_batch.inserted_count + total_skipped_import += import_batch.skipped_duplicate_count + import_errors.extend(import_batch.errors) + import_items.extend(import_batch.items) + import_pin_ids.extend(import_batch.pin_ids) + last_import = import_batch + + db_ids = await ingest_service.get_imported_policy_api_ids() + + def _track_imported_ids(import_batch: PolicyImportBatchResult) -> None: + for item in import_batch.items: + if item.action == PolicyBatchAction.CREATED and item.policy_api_id is not None: + db_ids.add(item.policy_api_id) + + while True: + if transform_limit is not None and total_processed >= transform_limit: + break + + batch_limit = effective_batch + if transform_limit is not None: + batch_limit = min(effective_batch, transform_limit - total_processed) + + transform_batch = await self.transform_and_save( + limit=batch_limit, + s3_util=s3_util, + db_policy_api_ids=db_ids, + ) + batches_run += 1 + total_processed += transform_batch.processed_count + total_skipped_transform += transform_batch.skipped_duplicate_count + transform_errors.extend(transform_batch.errors) + transform_pins.extend(transform_batch.pins) + last_transform_pending = transform_batch.remaining_pending_count + + if transform_batch.processed_count > 0: + import_batch = await ingest_service.import_handoff_batch( + import_all=False, + limit=effective_batch, + ) + _accumulate_import(import_batch) + _track_imported_ids(import_batch) + + if transform_batch.remaining_pending_count == 0: + break + if transform_batch.processed_count == 0: + break + + while load_jsonl_rows(self.handoff_path()): + prev_pending = last_import.pending_import_count + import_batch = await ingest_service.import_handoff_batch( + import_all=False, + limit=effective_batch, + ) + _accumulate_import(import_batch) + _track_imported_ids(import_batch) + if import_batch.pending_import_count == 0: + break + if import_batch.inserted_count == 0 and import_batch.pending_import_count >= prev_pending: + break + + aggregated_transform = PolicyTransformBatchResult( + input_path=str(self.documents_path()), + output_path=str(self.handoff_path()), + processed_count=total_processed, + error_count=len(transform_errors), + errors=transform_errors, + pins=transform_pins, + hint=( + f"배치 {batches_run}회, 가공 {total_processed}건 " + f"(배치 크기 {effective_batch}, 동시성 {settings.policy_transform_concurrency})" + ), + skipped_duplicate_count=total_skipped_transform, + pending_count=last_transform_pending, + requested_batch_size=effective_batch, + batches_run=batches_run, + ) + import_result = PolicyImportBatchResult( + inserted_count=total_imported, + skipped_duplicate_count=total_skipped_import, + pending_import_count=last_import.pending_import_count, + error_count=len(import_errors), + errors=import_errors, + items=import_items, + pin_ids=import_pin_ids, + requested_batch_size=effective_batch, + ) + + PolicyEventIngestService.write_sync_meta( + query_start_date=start_date, + query_end_date=end_date, + ) + hint = ( + f"sync 완료: 수집 {search.count}건, 가공 {total_processed}건({batches_run}배치), " + f"DB INSERT {total_imported}건." + ) + if ( + total_processed == 0 + and total_imported == 0 + and last_transform_pending == 0 + and len(db_ids) > 0 + ): + hint = ( + f"이미 DB 반영 완료 (policy 핀 {len(db_ids)}건). " + "handoff JSONL이 비어 있는 것은 import 후 캐시 정리로 정상입니다." + ) + elif total_processed == 0 and last_transform_pending > 0: + hint = ( + f"수집 {search.count}건 저장됐으나 가공 0건입니다. " + f"미가공 {last_transform_pending}건 — GEMINI_API_KEY·transform 오류를 확인하세요." + ) + elif total_processed > 0 and last_transform_pending > 0: + hint = ( + f"sync 부분 완료: 가공 {total_processed}건, DB INSERT {total_imported}건. " + f"미가공 {last_transform_pending}건 남음 — /policy-admin/sync 재실행 또는 transform-batch." + ) + return PolicySyncResult( + query_start_date=start_date, + query_end_date=end_date, + search=search, + transform=aggregated_transform, + import_result=import_result, + hint=hint, + ) + + def load_from_jsonl( + self, + *, + file_path: Path | None = None, + limit: int | None = None, + ) -> PolicyPinHandoffResult: + path = file_path or self.handoff_path() + if not path.is_file(): + raise FileNotFoundError( + f"핸드오프 JSONL 없음: {path}. POST /policy-pins/transform 를 먼저 실행하세요.", + ) + + effective_limit = _MAX_HANDOFF_ITEMS if limit is None else min(limit, _MAX_HANDOFF_ITEMS) + + pins: list[PolicyPinHandoffDTO] = [] + with path.open("r", encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line: + continue + pins.append(PolicyPinHandoffDTO.from_row(json.loads(line))) + + total_in_file = len(pins) + pins = pins[:effective_limit] + + hint: str | None = None + if total_in_file == 0: + hint = "JSONL이 비어 있습니다. GET /search → POST /transform 순서로 실행하세요." + + return PolicyPinHandoffResult( + output_path=str(path), + total_in_file=total_in_file, + count=len(pins), + pins=pins, + hint=hint, + ) + + @staticmethod + def default_date_range() -> tuple[str, str]: + today = date.today() + lookback = max(1, settings.policy_sync_lookback_days) + start = today - timedelta(days=lookback - 1) + return start.strftime("%Y%m%d"), today.strftime("%Y%m%d") diff --git a/app/services/internal/PolicyPinSchedulerService.py b/app/services/internal/PolicyPinSchedulerService.py new file mode 100644 index 0000000..9309756 --- /dev/null +++ b/app/services/internal/PolicyPinSchedulerService.py @@ -0,0 +1,137 @@ +from __future__ import annotations + +import asyncio +import json +import logging +from datetime import datetime, timedelta +from zoneinfo import ZoneInfo + +from app.core.config import settings +from app.core.database import AsyncSessionLocal +from app.repositories.CardnewsImageS3Repo import CardnewsImageS3Repo +from app.repositories.CommunityRepo import CommunityRepo +from app.repositories.EventPinRepo import EventPinRepo +from app.repositories.PinRepo import PinRepo +from app.repositories.UserRepo import UserRepo +from app.schemas.PolicyAdminDTO import PolicySyncResult +from app.services.PolicyEventIngestService import PolicyEventIngestService, POLICY_SYNC_META_PATH +from app.services.PolicyPinService import PolicyPinService +from app.utils.S3Util import S3Util + +logger = logging.getLogger(__name__) +_KST = ZoneInfo("Asia/Seoul") + + +class PolicyPinSchedulerService: + def __init__(self, *, s3_util: S3Util) -> None: + self._s3_util = s3_util + self._task: asyncio.Task[None] | None = None + self._pin_service = PolicyPinService() + + def start(self) -> None: + if self._task is not None and not self._task.done(): + return + self._task = asyncio.create_task(self._run_loop(), name="policy_pin_scheduler") + logger.info("정책 핀 sync 스케줄러 시작") + + async def stop(self) -> None: + if self._task is None: + return + self._task.cancel() + try: + await self._task + except asyncio.CancelledError: + pass + finally: + self._task = None + logger.info("정책 핀 sync 스케줄러 종료") + + async def run_once_now(self, *, force: bool = False) -> PolicySyncResult: + if not force and not self._should_run_sync(): + start, end = self._pin_service.default_date_range() + from app.schemas.PolicyAdminDTO import PolicyImportBatchResult + from app.schemas.PolicyPinDTO import PolicyPinSearchResult, PolicyPinTransformResult + + return PolicySyncResult( + query_start_date=start, + query_end_date=end, + search=PolicyPinSearchResult( + query_start_date=start, + query_end_date=end, + count=0, + pins=[], + saved_documents_path=str(self._pin_service.documents_path()), + stats={}, + hint="POLICY_SYNC_INTERVAL_DAYS 미경과로 sync 생략", + ), + transform=PolicyPinTransformResult( + input_path=str(self._pin_service.documents_path()), + output_path=str(self._pin_service.handoff_path()), + processed_count=0, + error_count=0, + pins=[], + hint="sync 생략", + ), + import_result=PolicyImportBatchResult( + inserted_count=0, + skipped_duplicate_count=0, + pending_import_count=0, + error_count=0, + ), + hint="POLICY_SYNC_INTERVAL_DAYS 미경과로 sync 생략", + ) + + async with AsyncSessionLocal() as session: + ingest = PolicyEventIngestService( + pin_repo=PinRepo(session), + event_pin_repo=EventPinRepo(session), + community_repo=CommunityRepo(session), + cardnews_image_s3_repo=CardnewsImageS3Repo(session), + user_repo=UserRepo(session), + ) + return await self._pin_service.sync_pipeline( + ingest_service=ingest, + s3_util=self._s3_util, + ) + + def _should_run_sync(self) -> bool: + if not POLICY_SYNC_META_PATH.is_file(): + return True + try: + meta = json.loads(POLICY_SYNC_META_PATH.read_text(encoding="utf-8")) + last_sync_at = str(meta.get("last_sync_at") or "").strip() + if not last_sync_at: + return True + last_run = datetime.fromisoformat(last_sync_at) + if last_run.tzinfo is None: + last_run = last_run.replace(tzinfo=_KST) + elapsed = datetime.now(_KST) - last_run.astimezone(_KST) + # 스케줄 시각 오차로 하루 밀리는 것 방지 + return elapsed >= timedelta(days=settings.policy_sync_interval_days) - timedelta(hours=1) + except (json.JSONDecodeError, ValueError): + return True + + async def _run_loop(self) -> None: + while True: + wait_seconds = self._seconds_until_next_schedule_kst() + logger.info("정책 핀 sync 스케줄러 대기 %.1fs", wait_seconds) + await asyncio.sleep(wait_seconds) + try: + result = await self.run_once_now(force=False) + logger.info( + "정책 핀 sync 실행: 가공 %d건, INSERT %d건", + result.transform.processed_count, + result.import_result.inserted_count, + ) + except Exception: + logger.exception("정책 핀 sync 스케줄 실행 실패") + + @staticmethod + def _seconds_until_next_schedule_kst() -> float: + now = datetime.now(_KST) + hour = settings.policy_sync_schedule_hour_kst + next_run = datetime.combine(now.date(), datetime.min.time(), tzinfo=_KST) + timedelta(hours=hour) + if now >= next_run: + next_run += timedelta(days=1) + delta = next_run - now + return max(delta.total_seconds(), 1.0) diff --git a/app/services/internal/ai/PolicyCardnewsImageService.py b/app/services/internal/ai/PolicyCardnewsImageService.py new file mode 100644 index 0000000..6244250 --- /dev/null +++ b/app/services/internal/ai/PolicyCardnewsImageService.py @@ -0,0 +1,146 @@ +from __future__ import annotations + +import asyncio +import logging +from dataclasses import dataclass, field + +from google import genai +from google.genai import types + +from app.core.config import settings +from app.services.internal.ai.gemini_retry import ( + is_retryable_gemini_error, + parse_gemini_model_list, + should_skip_gemini_model, +) + +logger = logging.getLogger(__name__) + +DEFAULT_MAX_ATTEMPTS_PER_MODEL = 3 +DEFAULT_BASE_DELAY_SECONDS = 2.0 + + +@dataclass(slots=True) +class PolicyCardnewsImageService: + # 정책 카드뉴스 슬라이드 이미지 생성 (텍스트 모델과 분리) + api_key: str + model_name: str = "gemini-2.5-flash-image" + client: genai.Client = field(init=False, repr=False) + fallback_models: tuple[str, ...] = ("gemini-3-pro-image-preview",) + + def __post_init__(self) -> None: + self.client = genai.Client(api_key=self.api_key) + + @classmethod + def from_settings(cls) -> PolicyCardnewsImageService: + secret = settings.gemini_api_key + if secret is None: + raise RuntimeError("GEMINI_API_KEY가 설정되어 있지 않습니다.") + fallbacks = parse_gemini_model_list(settings.gemini_cardnews_image_fallback_models) + return cls( + api_key=secret.get_secret_value(), + model_name=settings.gemini_cardnews_image_model.strip(), + fallback_models=fallbacks, + ) + + async def generate_slide_image_bytes(self, *, prompt: str) -> bytes: + text = (prompt or "").strip() + if not text: + raise ValueError("카드뉴스 이미지 프롬프트가 비어 있음") + + model_candidates = [self.model_name] + model_candidates.extend(m for m in self.fallback_models if m != self.model_name) + last_error: Exception | None = None + + for model_index, model in enumerate(model_candidates): + if model_index > 0: + logger.warning( + "CardnewsImage fallback switch: model=%s (%d/%d)", + model, + model_index + 1, + len(model_candidates), + ) + + for attempt in range(1, DEFAULT_MAX_ATTEMPTS_PER_MODEL + 1): + try: + if model.lower().startswith("imagen"): + return await self._generate_with_imagen(model=model, prompt=text) + return await self._generate_with_gemini_image(model=model, prompt=text) + except Exception as exc: + last_error = exc + if should_skip_gemini_model(exc): + logger.warning( + "CardnewsImage skip model=%s err=%s", + model, + exc, + ) + break + if attempt >= DEFAULT_MAX_ATTEMPTS_PER_MODEL or not is_retryable_gemini_error(exc): + break + delay = DEFAULT_BASE_DELAY_SECONDS * attempt + logger.warning( + "CardnewsImage retry model=%s attempt=%d/%d delay=%.1fs err=%s", + model, + attempt, + DEFAULT_MAX_ATTEMPTS_PER_MODEL, + delay, + exc, + ) + await asyncio.sleep(delay) + + raise RuntimeError( + f"카드뉴스 이미지 생성 실패 (models={model_candidates}): {last_error}", + ) from last_error + + async def _generate_with_gemini_image(self, *, model: str, prompt: str) -> bytes: + response = await self.client.aio.models.generate_content( + model=model, + contents=prompt, + config=types.GenerateContentConfig( + response_modalities=[types.Modality.IMAGE], + ), + ) + return self._extract_image_bytes(response) + + async def _generate_with_imagen(self, *, model: str, prompt: str) -> bytes: + response = await self.client.aio.models.generate_images( + model=model, + prompt=prompt, + config=types.GenerateImagesConfig( + number_of_images=1, + aspect_ratio="3:4", + output_mime_type="image/png", + ), + ) + generated = response.generated_images or [] + if not generated: + raise RuntimeError("Imagen 응답에 generated_images가 없음") + data = generated[0].image.image_bytes + if not data: + raise RuntimeError("Imagen image_bytes가 비어 있음") + if isinstance(data, str): + import base64 + + return base64.b64decode(data) + return bytes(data) + + @staticmethod + def _extract_image_bytes(response: object) -> bytes: + candidates = getattr(response, "candidates", None) or [] + if not candidates: + raise RuntimeError("이미지 생성 응답 candidates가 없음") + + content = candidates[0].content + parts = getattr(content, "parts", None) or [] + for part in parts: + inline = getattr(part, "inline_data", None) + if inline is None or not inline.data: + continue + data = inline.data + if isinstance(data, str): + import base64 + + return base64.b64decode(data) + return bytes(data) + + raise RuntimeError("이미지 생성 응답에 inline_data가 없음") diff --git a/app/services/policy_cardnews.py b/app/services/policy_cardnews.py new file mode 100644 index 0000000..be070d7 --- /dev/null +++ b/app/services/policy_cardnews.py @@ -0,0 +1,281 @@ +from __future__ import annotations + +import logging +from pathlib import Path +from typing import Any, TypedDict + +from anyio import to_thread + +from app.core.config import settings +from app.policy_cardnews import ( + parse_cardnews_slides_json, + render_policy_cardnews_slides, + save_slide_image_bytes, +) +from app.services.internal.ai.IssuePinLLMService import IssuePinLLMService +from app.services.internal.ai.PolicyCardnewsImageService import PolicyCardnewsImageService +from app.services.prompts.policy_cardnews import ( + build_policy_cardnews_image_prompt, + build_policy_cardnews_slide_prompt, +) +from app.utils.S3Util import S3Util + +logger = logging.getLogger(__name__) + +POLICY_CARDNEWS_OUTPUT_DIR = ( + Path(__file__).resolve().parents[2] / "rag" / "output" / "policy_cardnews" +) +_REPO_ROOT = Path(__file__).resolve().parents[2] + + +class CardnewsS3Image(TypedDict): + key: str + url: str + + +def cleanup_local_cardnews_dir(content_id: str) -> None: + normalized = str(content_id or "").strip() + if not normalized: + return + target = POLICY_CARDNEWS_OUTPUT_DIR / normalized + if not target.is_dir(): + return + import shutil + + shutil.rmtree(target, ignore_errors=True) + logger.debug("로컬 카드뉴스 디렉터리 삭제: %s", target) + + +def _maybe_cleanup_local_cardnews(content_id: str) -> None: + if not settings.policy_cardnews_keep_local_files: + cleanup_local_cardnews_dir(content_id) + + +def _slide_object_key(content_id: str, slide_no: int) -> str: + prefix = settings.policy_cardnews_s3_prefix.strip("/") + return f"{prefix}/{content_id}/slide_{slide_no:02d}.png" + + +async def _upload_slide_bytes( + s3_util: S3Util, + *, + content_id: str, + slide_no: int, + image_bytes: bytes, +) -> CardnewsS3Image: + key = _slide_object_key(content_id, slide_no) + result = await s3_util.upload_bytes( + image_bytes, + filename=f"slide_{slide_no:02d}.png", + content_type="image/png", + object_key=key, + ) + return {"key": result["key"], "url": result["url"]} + + +async def _upload_local_handoff_path( + s3_util: S3Util, + *, + content_id: str, + handoff_path: str, +) -> CardnewsS3Image: + local_path = Path(handoff_path) + if not local_path.is_absolute(): + local_path = (_REPO_ROOT / handoff_path.strip().lstrip("/")).resolve() + if not local_path.is_file(): + raise FileNotFoundError(f"카드뉴스 로컬 파일 없음: {local_path}") + slide_name = local_path.stem + slide_no = 1 + if slide_name.startswith("slide_"): + try: + slide_no = int(slide_name.split("_", 1)[1]) + except ValueError: + slide_no = 1 + image_bytes = await to_thread.run_sync(local_path.read_bytes) + return await _upload_slide_bytes( + s3_util, + content_id=content_id, + slide_no=slide_no, + image_bytes=image_bytes, + ) + + +async def _generate_slide_bytes_with_image_model( + *, + image_service: PolicyCardnewsImageService, + content_id: str, + pin_title: str, + minister: str, + slides: list[dict[str, Any]], +) -> list[tuple[int, bytes]]: + out: list[tuple[int, bytes]] = [] + total = len(slides) + for index, slide in enumerate(slides, start=1): + prompt = build_policy_cardnews_image_prompt( + pin_title=pin_title, + minister=minister, + slide=slide, + slide_index=index, + slide_total=total, + ) + image_bytes = await image_service.generate_slide_image_bytes(prompt=prompt) + slide_no = int(slide.get("slide") or index) + out.append((slide_no, image_bytes)) + return out + + +async def _generate_local_paths_with_pillow( + *, + content_id: str, + minister: str, + slides: list[dict[str, Any]], + output_dir: Path, + image_urls: list[str], + source_url: str = "", + pin_content: str = "", +) -> list[str]: + return await render_policy_cardnews_slides( + contentid=content_id, + slides=slides, + output_dir=output_dir, + minister=minister, + image_urls=image_urls, + source_url=source_url, + pin_content=pin_content, + ) + + +async def generate_cardnews_s3_images( + text_llm: IssuePinLLMService, + *, + row: dict, + easy_read_content: str, + s3_util: S3Util, + output_dir: Path | None = None, + image_service: PolicyCardnewsImageService | None = None, +) -> list[CardnewsS3Image]: + content_id = str(row.get("contentid") or "").strip() + if not content_id: + return [] + + prompt = build_policy_cardnews_slide_prompt( + pin_title=str(row.get("pin_title") or ""), + pin_content=easy_read_content or str(row.get("pin_content") or ""), + minister=str(row.get("minister") or ""), + ) + raw = await text_llm.generate_pin_text(prompt=prompt) + slides = parse_cardnews_slides_json(raw) + + target_dir = output_dir or POLICY_CARDNEWS_OUTPUT_DIR + pin_title = str(row.get("pin_title") or "") + minister = str(row.get("minister") or "") + from app.utils.policy_news_parse import enrich_cover_image_urls, merge_policy_image_urls + + source_url = str(row.get("source_url") or "") + image_urls = merge_policy_image_urls( + original_image_urls=row.get("original_image_urls"), + cardnews_image_urls=row.get("cardnews_image_urls"), + ) + if not image_urls: + image_urls = [str(u).strip() for u in (row.get("image_urls") or []) if str(u).strip()] + image_urls = await enrich_cover_image_urls(image_urls, source_url=source_url) + if image_urls and slides: + first = dict(slides[0]) + first["use_image"] = True + slides[0] = first + + uploaded: list[CardnewsS3Image] = [] + + if settings.policy_cardnews_use_image_model: + img_service = image_service or PolicyCardnewsImageService.from_settings() + try: + slide_bytes_list = await _generate_slide_bytes_with_image_model( + image_service=img_service, + content_id=content_id, + pin_title=pin_title, + minister=minister, + slides=slides, + ) + for slide_no, image_bytes in slide_bytes_list: + if settings.policy_cardnews_keep_local_files: + save_slide_image_bytes( + contentid=content_id, + slide_no=slide_no, + image_bytes=image_bytes, + output_dir=target_dir, + ) + uploaded.append( + await _upload_slide_bytes( + s3_util, + content_id=content_id, + slide_no=slide_no, + image_bytes=image_bytes, + ), + ) + except Exception: + if not settings.policy_cardnews_pillow_fallback: + raise + logger.exception( + "카드뉴스 이미지 모델 생성 실패로 Pillow 폴백 contentid=%s", + content_id, + ) + local_paths = await _generate_local_paths_with_pillow( + content_id=content_id, + minister=minister, + slides=slides, + output_dir=target_dir, + image_urls=image_urls, + source_url=source_url, + pin_content=easy_read_content or str(row.get("pin_content") or ""), + ) + for path in local_paths: + uploaded.append( + await _upload_local_handoff_path( + s3_util, + content_id=content_id, + handoff_path=path, + ), + ) + else: + local_paths = await _generate_local_paths_with_pillow( + content_id=content_id, + minister=minister, + slides=slides, + output_dir=target_dir, + image_urls=image_urls, + source_url=source_url, + pin_content=easy_read_content or str(row.get("pin_content") or ""), + ) + for path in local_paths: + uploaded.append( + await _upload_local_handoff_path( + s3_util, + content_id=content_id, + handoff_path=path, + ), + ) + + _maybe_cleanup_local_cardnews(content_id) + return uploaded + + +async def generate_cardnews_image_paths( + text_llm: IssuePinLLMService, + *, + row: dict, + easy_read_content: str, + output_dir: Path | None = None, + image_service: PolicyCardnewsImageService | None = None, + s3_util: S3Util | None = None, +) -> list[str]: + """하위 호환: S3 URL 문자열 목록만 반환.""" + s3 = s3_util or S3Util() + images = await generate_cardnews_s3_images( + text_llm, + row=row, + easy_read_content=easy_read_content, + s3_util=s3, + output_dir=output_dir, + image_service=image_service, + ) + return [img["url"] for img in images if img.get("url")] diff --git a/app/services/policy_pin_transform.py b/app/services/policy_pin_transform.py new file mode 100644 index 0000000..ef2f4d3 --- /dev/null +++ b/app/services/policy_pin_transform.py @@ -0,0 +1,285 @@ +from __future__ import annotations + +import asyncio +from pathlib import Path +from typing import Any + +from app.core.config import settings +from app.schemas.PolicyPinDTO import PolicyPinHandoffDTO, PolicyPinTransformResult +from app.services.internal.ai.IssuePinLLMService import IssuePinLLMService +from app.services.internal.ai.gemini_retry import parse_gemini_model_list +from app.services.policy_cardnews import CardnewsS3Image, generate_cardnews_s3_images +from app.services.prompts.policy_pin import build_policy_easy_read_prompt +from app.utils.S3Util import S3Util +from rag.scripts.chunk_module import iter_jsonl, write_jsonl + +POLICY_DOCUMENTS_PATH = ( + Path(__file__).resolve().parents[2] / "rag" / "output" / "policy_documents.jsonl" +) +POLICY_HANDOFF_PATH = ( + Path(__file__).resolve().parents[2] / "rag" / "output" / "policy_pins_for_db.jsonl" +) +POLICY_SYNC_META_PATH = ( + Path(__file__).resolve().parents[2] / "rag" / "output" / "policy_sync_meta.json" +) + + +def parse_policy_api_id(row: dict[str, Any]) -> int | None: + raw = row.get("policy_api_id") or row.get("contentid") + if raw is None: + return None + text = str(raw).strip() + if not text.isdigit(): + return None + return int(text) + + +def row_content_id(row: dict[str, Any]) -> str: + return str(row.get("contentid") or row.get("policy_api_id") or "").strip() + + +def row_raw_content(row: dict[str, Any]) -> str: + return (row.get("pin_content_raw") or row.get("pin_content") or row.get("text") or "").strip() + + +def normalize_raw_for_compare(text: str) -> str: + return " ".join((text or "").split()).strip() + + +def load_jsonl_rows(path: Path) -> list[dict[str, Any]]: + if not path.is_file(): + return [] + return [row for row in iter_jsonl(path) if isinstance(row, dict)] + + +def load_rows_by_content_id(path: Path) -> dict[str, dict[str, Any]]: + out: dict[str, dict[str, Any]] = {} + for row in load_jsonl_rows(path): + content_id = row_content_id(row) + if content_id: + out[content_id] = row + return out + + +def merge_documents( + existing: dict[str, dict[str, Any]], + new_rows: list[dict[str, Any]], +) -> list[dict[str, Any]]: + merged = dict(existing) + for row in new_rows: + content_id = row_content_id(row) + if content_id: + merged[content_id] = row + return list(merged.values()) + + +def write_handoff_map(handoff_by_id: dict[str, dict[str, Any]], path: Path | None = None) -> None: + dst = path or POLICY_HANDOFF_PATH + write_jsonl(dst, list(handoff_by_id.values())) + + +def build_handoff_row( + source: dict[str, Any], + *, + easy_read_content: str, + cardnews_images: list[CardnewsS3Image] | None = None, +) -> dict[str, Any]: + content_id = row_content_id(source) + policy_api_id = parse_policy_api_id(source) + images = cardnews_images or [] + cardnews_urls = [img["url"] for img in images if str(img.get("url") or "").strip()] + source_url = (source.get("source_url") or "").strip() + event_start = source.get("event_start_time") + event_end = source.get("event_end_time") + title = (source.get("pin_title") or source.get("title") or "").strip() + return { + "contentid": content_id, + "policy_api_id": policy_api_id, + "title": title, + "pin_content": (easy_read_content or "").strip(), + "pin_content_raw": row_raw_content(source), + "cardnews_image_urls": cardnews_urls, + "cardnews_images": images, + "source_url": source_url, + "event_start_time": event_start, + "event_end_time": event_end, + "approve_date": (source.get("approve_date") or "").strip(), + } + + +def build_llm_service(*, model: str | None = None) -> IssuePinLLMService: + secret = settings.gemini_api_key + if secret is None: + raise RuntimeError("GEMINI_API_KEY가 설정되어 있지 않습니다.") + model_name = (model or settings.gemini_pin_text_model).strip() + fallbacks = parse_gemini_model_list(settings.gemini_pin_text_fallback_models) + return IssuePinLLMService( + api_key=secret.get_secret_value(), + model_name=model_name, + fallback_models=fallbacks, + ) + + +async def transform_one_row( + llm: IssuePinLLMService, + row: dict[str, Any], + *, + s3_util: S3Util, +) -> dict[str, Any]: + raw_content = row_raw_content(row) + if not raw_content: + raise ValueError("pin_content가 비어 있음") + + prompt = build_policy_easy_read_prompt( + pin_title=str(row.get("pin_title") or ""), + pin_content=raw_content, + minister=str(row.get("minister") or ""), + subtitles=str(row.get("subtitles") or ""), + approve_date=str(row.get("approve_date") or ""), + ) + easy_read_text = await llm.generate_pin_text(prompt=prompt) + cardnews_images = await generate_cardnews_s3_images( + text_llm=llm, + row=row, + easy_read_content=easy_read_text, + s3_util=s3_util, + ) + source = {**row, "pin_content_raw": raw_content} + return build_handoff_row( + source, + easy_read_content=easy_read_text, + cardnews_images=cardnews_images, + ) + + +def list_pending_transform_rows( + documents: list[dict[str, Any]], + handoff_by_id: dict[str, dict[str, Any]], + *, + db_policy_api_ids: set[int] | None = None, +) -> list[dict[str, Any]]: + db_ids = db_policy_api_ids or set() + pending: list[dict[str, Any]] = [] + for row in documents: + content_id = row_content_id(row) + if not content_id: + continue + policy_id = parse_policy_api_id(row) + if policy_id is not None and policy_id in db_ids: + continue + if content_id in handoff_by_id: + continue + pending.append(row) + return pending + + +def count_pending_transform( + documents: list[dict[str, Any]], + handoff_by_id: dict[str, dict[str, Any]], + *, + db_policy_api_ids: set[int] | None = None, +) -> int: + return len( + list_pending_transform_rows( + documents, + handoff_by_id, + db_policy_api_ids=db_policy_api_ids, + ), + ) + + +async def transform_documents_jsonl( + *, + input_path: Path | None = None, + output_path: Path | None = None, + limit: int | None = None, + model: str | None = None, + s3_util: S3Util | None = None, + db_policy_api_ids: set[int] | None = None, + merge_handoff: bool = True, +) -> PolicyPinTransformResult: + src = input_path or POLICY_DOCUMENTS_PATH + dst = output_path or POLICY_HANDOFF_PATH + if not src.is_file(): + raise FileNotFoundError( + f"원문 JSONL 없음: {src}. 먼저 GET /policy-pins/search 또는 fetch 스크립트를 실행하세요.", + ) + + documents = load_jsonl_rows(src) + handoff_by_id = load_rows_by_content_id(dst) if merge_handoff else {} + db_ids = db_policy_api_ids or set() + pending = list_pending_transform_rows( + documents, + handoff_by_id, + db_policy_api_ids=db_ids, + ) + + llm = build_llm_service(model=model) + s3 = s3_util or S3Util() + processed_rows: list[dict[str, Any]] = [] + errors: list[dict[str, Any]] = [] + skipped_duplicate_count = 0 + + rows_to_process = pending if limit is None else pending[:limit] + concurrency = min(settings.policy_transform_concurrency, len(rows_to_process) or 1) + semaphore = asyncio.Semaphore(max(1, concurrency)) + + async def _transform_row(row: dict[str, Any]) -> tuple[str, dict[str, Any] | None, dict[str, Any] | None]: + content_id = row_content_id(row) + async with semaphore: + try: + handoff_row = await transform_one_row(llm, row, s3_util=s3) + return content_id, handoff_row, None + except Exception as exc: + return content_id, None, { + "contentid": content_id, + "pin_title": row.get("pin_title"), + "error": str(exc), + } + + for content_id, handoff_row, err_row in await asyncio.gather( + *(_transform_row(row) for row in rows_to_process), + ): + if handoff_row is not None and content_id: + handoff_by_id[content_id] = handoff_row + processed_rows.append(handoff_row) + elif err_row is not None: + errors.append(err_row) + + for row in documents: + content_id = row_content_id(row) + if not content_id or content_id in handoff_by_id: + continue + policy_id = parse_policy_api_id(row) + if policy_id is not None and policy_id in db_ids: + skipped_duplicate_count += 1 + + write_handoff_map(handoff_by_id, dst) + remaining_pending = count_pending_transform( + documents, + handoff_by_id, + db_policy_api_ids=db_ids, + ) + pins = [PolicyPinHandoffDTO.from_row(item) for item in processed_rows] + + hint: str | None + if processed_rows or handoff_by_id: + hint = ( + f"이번 가공 {len(processed_rows)}건, handoff 총 {len(handoff_by_id)}건. " + "기간 필터는 search(policy_documents.jsonl) 단계에서 적용된 범위입니다." + ) + else: + hint = "가공 성공 건이 없습니다. GEMINI_API_KEY·원문 pin_content를 확인하세요." + + return PolicyPinTransformResult( + input_path=str(src), + output_path=str(dst), + processed_count=len(processed_rows), + error_count=len(errors), + errors=errors, + pins=pins, + hint=hint, + skipped_duplicate_count=skipped_duplicate_count, + pending_count=len(pending), + remaining_pending_count=remaining_pending, + ) diff --git a/app/services/policy_pipeline_cleanup.py b/app/services/policy_pipeline_cleanup.py new file mode 100644 index 0000000..38ffb16 --- /dev/null +++ b/app/services/policy_pipeline_cleanup.py @@ -0,0 +1,109 @@ +from __future__ import annotations + +import logging +import shutil +from pathlib import Path + +from app.services.policy_cardnews import POLICY_CARDNEWS_OUTPUT_DIR +from app.services.policy_pin_transform import ( + POLICY_DOCUMENTS_PATH, + POLICY_HANDOFF_PATH, + load_jsonl_rows, + parse_policy_api_id, + row_content_id, + write_handoff_map, +) +from rag.scripts.chunk_module import write_jsonl + +logger = logging.getLogger(__name__) + + +def prune_jsonl_by_policy_api_ids( + path: Path, + policy_api_ids: set[int], +) -> int: + if not policy_api_ids or not path.is_file(): + return 0 + + kept: list[dict] = [] + removed = 0 + for row in load_jsonl_rows(path): + policy_id = parse_policy_api_id(row) + if policy_id is not None and policy_id in policy_api_ids: + removed += 1 + continue + kept.append(row) + + if removed > 0: + write_jsonl(path, kept) + logger.info("JSONL 정리 %s: %d건 제거, %d건 유지", path.name, removed, len(kept)) + return removed + + +def prune_pipeline_imported( + policy_api_ids: set[int], + *, + documents_path: Path | None = None, + handoff_path: Path | None = None, +) -> dict[str, int]: + if not policy_api_ids: + return {"documents_removed": 0, "handoff_removed": 0} + + docs = documents_path or POLICY_DOCUMENTS_PATH + handoff = handoff_path or POLICY_HANDOFF_PATH + return { + "documents_removed": prune_jsonl_by_policy_api_ids(docs, policy_api_ids), + "handoff_removed": prune_jsonl_by_policy_api_ids(handoff, policy_api_ids), + } + + +def prune_handoff_pending_only( + *, + db_policy_api_ids: set[int], + handoff_path: Path | None = None, +) -> int: + """DB에 이미 있는 항목을 handoff에서 제거 (가공 완료·적재 완료 캐시 정리).""" + path = handoff_path or POLICY_HANDOFF_PATH + if not path.is_file() or not db_policy_api_ids: + return 0 + return prune_jsonl_by_policy_api_ids(path, db_policy_api_ids) + + +def cleanup_local_cardnews_dirs(content_ids: set[str]) -> int: + removed = 0 + for raw_id in content_ids: + content_id = str(raw_id or "").strip() + if not content_id: + continue + target = POLICY_CARDNEWS_OUTPUT_DIR / content_id + if not target.is_dir(): + continue + shutil.rmtree(target, ignore_errors=True) + removed += 1 + logger.debug("로컬 카드뉴스 삭제: %s", target) + return removed + + +def policy_api_ids_to_content_ids(policy_api_ids: set[int]) -> set[str]: + return {str(policy_id) for policy_id in policy_api_ids} + + +def cleanup_after_policy_import(policy_api_ids: set[int]) -> dict[str, int]: + stats = prune_pipeline_imported(policy_api_ids) + stats["cardnews_dirs_removed"] = cleanup_local_cardnews_dirs( + policy_api_ids_to_content_ids(policy_api_ids), + ) + return stats + + +def reset_handoff_map(handoff_path: Path | None = None) -> None: + write_handoff_map({}, handoff_path) + + +def content_ids_from_rows(rows: list[dict]) -> set[str]: + out: set[str] = set() + for row in rows: + content_id = row_content_id(row) + if content_id: + out.add(content_id) + return out diff --git a/app/services/prompts/policy_cardnews.py b/app/services/prompts/policy_cardnews.py new file mode 100644 index 0000000..ef76ce9 --- /dev/null +++ b/app/services/prompts/policy_cardnews.py @@ -0,0 +1,109 @@ +from __future__ import annotations + +POLICY_CARDNEWS_SLIDE_PROMPT = """ +[역할] +정책 내용을 고정 카드뉴스 템플릿에 넣을 문구만 작성한다. + +[출력] +JSON 배열만. 2~3장. +- 1장: template_cover (표지) +- 중간(있으면): template_numbered / items 4개 이하, template_three_col / items 3개, template_grid / items 4개 +- 마지막: template_cta + +[필드] +slide, layout_type, eyebrow, headline, highlight, subtext, body, items[{label,text}], term_guides[{term,plain}], cta, speech, use_image + +[규칙] +- 어려운 정책·법률 용어는 쉬운 말로 바꿔 쓰기 (예: 개인정보→내 정보, 실태조사→현장 점검) +- 본문에 꼭 필요한 전문 용어가 있으면 term_guides에 최대 2개 (term+plain 한 줄 설명) +- cover: eyebrow(짧은 질문/카테고리), headline+highlight(핵심 키워드), speech 10~18자 +- 정책에 관련 사진·이미지가 있으면 1장 cover의 use_image=true (표지에 사진 크게 표시) +- 이미지가 없을 때만 cover에 speech(캐릭터 말풍선용) 작성 +- theme: cream_warm|mint_fresh|slate_modern|peach_soft|lavender_light|snow_clean 중 **카드뉴스 전체에 하나** (1장만 넣어도 됨, 모든 슬라이드 동일 색) +- 중간: headline(한 줄 제목), items 3~4개(라벨+짧은 설명), body 비우기 +- cta: headline, cta(버튼 문구), body(1줄 안내), term_guides(선택), speech 10~18자(캐릭터 말풍선, 친근하게) +- 입력에 없는 정보 생성 금지 +- PPT/보도자료체 금지 + +[예시 3장] +[ + { + "slide": 1, + "layout_type": "template_cover", + "eyebrow": "청년 정책", + "headline": "정부 지원", + "highlight": "월 20만원", + "body": "", + "items": [], + "cta": "", + "speech": "이거 꼭 봐!", + "use_image": false, + "theme": "mint_fresh" + }, + { + "slide": 2, + "layout_type": "template_grid", + "eyebrow": "한 장 요약", + "headline": "이렇게 지원해요", + "highlight": "", + "body": "", + "items": [ + {"label": "대상", "text": "만 19~34세 무주택 청년"}, + {"label": "지원금", "text": "월 20만원"}, + {"label": "기간", "text": "2025년 하반기~"}, + {"label": "신청", "text": "온라인 접수"} + ], + "cta": "", + "speech": "", + "use_image": false + }, + { + "slide": 3, + "layout_type": "template_cta", + "eyebrow": "마무리", + "headline": "자세한 조건은 원문에서", + "highlight": "", + "body": "소득·재산 기준은 공식 뉴스를 확인해 주세요.", + "items": [], + "cta": "원문 뉴스 보기", + "speech": "원문 확인해!", + "use_image": false + } +] +""" + +_POLICY_INPUT = """ +[입력] +제목: {pin_title} +부처: {minister} +본문: {pin_content} +""" + + +def build_policy_cardnews_slide_prompt( + *, + pin_title: str, + pin_content: str, + minister: str = "", +) -> str: + return POLICY_CARDNEWS_SLIDE_PROMPT + _POLICY_INPUT.format( + pin_title=(pin_title or "").strip(), + pin_content=(pin_content or "").strip(), + minister=(minister or "").strip(), + ) + + +def build_policy_cardnews_image_prompt( + *, + pin_title: str, + minister: str, + slide: dict, + slide_index: int, + slide_total: int, +) -> str: + return ( + f"Policy cardnews slide {slide_index}/{slide_total}. " + f"Title: {pin_title}. Minister: {minister}. " + f"Layout: {slide.get('layout_type')}. " + f"Copy: {slide}" + ) diff --git a/app/services/prompts/policy_pin.py b/app/services/prompts/policy_pin.py new file mode 100644 index 0000000..5d9859b --- /dev/null +++ b/app/services/prompts/policy_pin.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +POLICY_EASY_READ_PROMPT = """ +[역할] +정부 정책 뉴스를 일반 국민이 30초 안에 이해할 수 있는 쉬운 글로 바꾸는 콘텐츠 에디터다. + +[목표] +공식 정책 기사를, 핵심만 짚어 주는 친근한 설명글로 변환한다. +어려운 행정·법률 용어는 쉬운 말로 풀되, 사실 관계는 정확히 유지한다. + +[작성 규칙] +* 전체 분량은 최소 8줄 이상으로 작성한다. +* 문장마다 자연스럽게 줄바꿈하여 읽기 쉽게 작성한다. +* 첫 1~2줄은 "이 정책이 나에게 무슨 의미인지" 바로 이해되게 시작한다. +* 말투는 친근하고 자연스럽지만, 가볍게 농담하거나 과장하지 않는다. +* "누구에게", "무엇이", "언제부터", "어떻게"를 본문에 자연스럽게 포함한다. +* 입력 데이터에 없는 혜택·금액·일정·대상은 절대 추가하지 않는다. +* 부처명·정책명은 자연스럽게 활용 가능하다. +* 승인일은 본문에 굳이 직접 쓰지 않아도 된다. +* 정책의 핵심 변화, 국민에게 미치는 영향, 확인하면 좋은 포인트를 강조한다. +* 이모지는 문맥에 맞게 적절히 사용한다. +* 이모지는 과하지 않게 사용하되, 글 분위기를 살릴 정도로 활용한다. +* 문단 시작, 강조 포인트, 분위기 전환 등에 이모지를 사용할 수 있다. +* 너무 반복적인 이모지 사용은 피한다. +* 마지막 1~2줄은 "더 자세한 내용은 원문 기사에서 확인하세요" 뉘앙스로 마무리한다. +* 원문 URL은 본문에 직접 넣지 않는다 (시스템이 본문 마지막에 자동으로 붙인다). +* 마지막에는 해시태그를 3~5개 작성한다. +* 해시태그는 정책 주제, 부처명, 키워드 기반으로 작성한다. +* 출력은 본문만 작성한다. +* JSON, 설명, 마크다운 문법은 출력하지 않는다. + +[입력 데이터] +* 제목: {pin_title} +* 부처: {minister} +* 부제: {subtitles} +* 승인일: {approve_date} +* 원문: {pin_content} + +[출력 예시] +개인정보, 이제 위험도에 따라 다르게 관리돼요 🔐 + +정부가 개인정보 침해 위험을 미리 점검하는 +'예방 중심' 관리체계로 바꾼다고 해요. + +플랫폼·금융·공공기관처럼 +많은 정보를 다루는 곳은 점검이 더 꼼꼼해질 예정이에요. + +일반 기업·단체는 +스스로 보호 수준을 점검할 수 있는 도구도 받을 수 있다고 합니다. + +혼자 모든 걸 외우기 어렵다면 +핵심만 기억해 두셔도 충분해요 📌 + +더 자세한 내용은 원문 기사에서 확인해 보세요. + +#정책 #개인정보보호 #정부정책 #생활정책 +""" + + +def build_policy_easy_read_prompt( + *, + pin_title: str, + pin_content: str, + minister: str = "", + subtitles: str = "", + approve_date: str = "", +) -> str: + return POLICY_EASY_READ_PROMPT.format( + pin_title=(pin_title or "").strip(), + pin_content=(pin_content or "").strip(), + minister=(minister or "").strip() or "정보 없음", + subtitles=(subtitles or "").strip() or "정보 없음", + approve_date=(approve_date or "").strip() or "정보 없음", + ) diff --git a/app/utils/policy_news_parse.py b/app/utils/policy_news_parse.py new file mode 100644 index 0000000..7ebca9f --- /dev/null +++ b/app/utils/policy_news_parse.py @@ -0,0 +1,466 @@ +from __future__ import annotations + +import asyncio +import logging +import re +from datetime import date, datetime, timedelta +from zoneinfo import ZoneInfo +from html import unescape +from typing import Any +from urllib.parse import urljoin, urlparse +from xml.etree import ElementTree as ET + +import httpx + +logger = logging.getLogger(__name__) + +_KST = ZoneInfo("Asia/Seoul") +_POLICY_DATE_FMT = "%m/%d/%Y %H:%M:%S" +_POLICY_DATE_FMT_SHORT = "%m/%d/%Y" +_IMG_SRC_RE = re.compile( + r"""