diff --git a/app/assets/fonts/Hakgyoansim Dunggeunmiso TTF B.ttf b/app/assets/fonts/Hakgyoansim Dunggeunmiso TTF B.ttf new file mode 100644 index 0000000..560ac1f Binary files /dev/null and b/app/assets/fonts/Hakgyoansim Dunggeunmiso TTF B.ttf differ diff --git a/app/assets/fonts/Hakgyoansim Dunggeunmiso TTF R.ttf b/app/assets/fonts/Hakgyoansim Dunggeunmiso TTF R.ttf new file mode 100644 index 0000000..9955169 Binary files /dev/null and b/app/assets/fonts/Hakgyoansim Dunggeunmiso TTF R.ttf differ diff --git a/app/contest_cardnews/__init__.py b/app/contest_cardnews/__init__.py new file mode 100644 index 0000000..ddf880b --- /dev/null +++ b/app/contest_cardnews/__init__.py @@ -0,0 +1,4 @@ +from app.contest_cardnews.render import render_contest_cardnews_slides +from app.contest_cardnews.slides import parse_contest_cardnews_slides_json + +__all__ = ["parse_contest_cardnews_slides_json", "render_contest_cardnews_slides"] diff --git a/app/contest_cardnews/constants.py b/app/contest_cardnews/constants.py new file mode 100644 index 0000000..0761d96 --- /dev/null +++ b/app/contest_cardnews/constants.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +# 정책 카드뉴스와 동일 (app.policy_cardnews.constants) +CANVAS_WIDTH = 1080 +CANVAS_HEIGHT = 1350 + +CARD_INSET = 28 +CONTENT_PAD = 40 +INNER_PAD_X = 20 +INNER_PAD_Y = 12 +CHROME_HEIGHT = 118 + +GAP_SM = 8 +GAP_MD = 14 +GAP_LG = 22 +# 슬라이드 내 요소 간 여백 +ELEMENT_GAP = 20 +ROW_GAP = 22 +TITLE_TOP_PAD = 28 +CARD_INNER_PAD = 16 +LABEL_STRIP_H = 58 + +INK = (28, 32, 40) +INK_SOFT = (72, 80, 96) +STAR_PEACH = (255, 190, 160) +NOTE_PINK = (255, 160, 190) + +# 둥근미소는 동일 pt에서 작게 보여 전체 스케일 업 +FONT_SCALE = 1.28 + +# 레이아웃 겹침 방지용 예약 높이 +POINT_FOOTER_H = 56 +MASCOT_ZONE_H = 300 +MASCOT_GAP = 24 +SPEECH_ICON_GAP = 22 diff --git a/app/contest_cardnews/copy.py b/app/contest_cardnews/copy.py new file mode 100644 index 0000000..6aba3df --- /dev/null +++ b/app/contest_cardnews/copy.py @@ -0,0 +1,146 @@ +from __future__ import annotations + +from typing import Any + +from app.contest_cardnews.template.dispatch import ( + LAYOUT_CHECKLIST, + LAYOUT_COVER, + LAYOUT_CTA, + LAYOUT_TABLE, + normalize_layout_type, +) +from app.policy_cardnews.copy import is_filler_text, polish_korean_text + +_SPEECH_MAX = 14 +_TARGET_SLIDES = 3 +_MIDDLE_LAYOUTS = (LAYOUT_TABLE, LAYOUT_CHECKLIST) + + +def _content_score(slide: dict[str, Any]) -> int: + score = 0 + for key in ("eyebrow", "headline", "highlight", "body", "cta", "point"): + 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 "")) + return score + + +def _middle_layout_bonus(layout: str) -> int: + if layout == LAYOUT_TABLE: + return 40 + if layout == LAYOUT_CHECKLIST: + return 30 + return 0 + + +def compact_contest_deck(slides: list[dict[str, Any]]) -> list[dict[str, Any]]: + """표지 + 요약 1장 + CTA = 3장. 중간은 table/checklist 우선.""" + rows = [s for s in slides if isinstance(s, dict)] + if len(rows) <= _TARGET_SLIDES: + return _renumber_slides(rows) + + cover = rows[0] + cta = rows[-1] + middles = rows[1:-1] + + def rank(slide: dict[str, Any]) -> tuple[int, int]: + layout = normalize_layout_type(str(slide.get("layout_type") or "")) + prefer = 0 if layout in _MIDDLE_LAYOUTS else 1 + return (prefer, -(_content_score(slide) + _middle_layout_bonus(layout))) + + best_middle = min(middles, key=rank) + merged_layout = normalize_layout_type(str(best_middle.get("layout_type") or "")) + if merged_layout not in _MIDDLE_LAYOUTS: + if len(best_middle.get("items") or []) >= 3: + best_middle = {**best_middle, "layout_type": LAYOUT_CHECKLIST} + elif best_middle.get("items"): + best_middle = {**best_middle, "layout_type": LAYOUT_TABLE} + + return _renumber_slides([cover, best_middle, cta]) + + +def _renumber_slides(slides: list[dict[str, Any]]) -> list[dict[str, Any]]: + out: list[dict[str, Any]] = [] + for index, slide in enumerate(slides, start=1): + row = dict(slide) + row["slide"] = index + out.append(row) + return out + + +def normalize_contest_slide_copy(slide: dict[str, Any]) -> dict[str, Any]: + row = dict(slide) + layout = str(row.get("layout_type") or "").strip() + + for key in ("eyebrow", "headline", "highlight", "subtext", "body", "cta", "speech", "point"): + if key in row: + row[key] = 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": polish_korean_text(str(item.get("label") or item.get("title") or "")), + "text": polish_korean_text( + str(item.get("text") or item.get("value") or item.get("content") or ""), + ), + }, + ) + elif str(item).strip(): + simplified_items.append({"label": "", "text": polish_korean_text(str(item))}) + if simplified_items: + row["items"] = simplified_items[:5] + + headline = row.get("headline", "") + highlight = row.get("highlight", "") + if highlight and headline and highlight == headline and "cover" in layout: + row["headline"] = "" + + body = row.get("body", "") + if body and is_filler_text(body) and "cover" not in layout: + row["body"] = "" + + if not row.get("speech"): + row["speech"] = _derive_contest_speech(row, layout=layout) + + row["use_image"] = False + return row + + +def _derive_contest_speech(slide: dict[str, Any], *, layout: str) -> str: + explicit = str(slide.get("speech") or "").strip() + if explicit and len(explicit) <= _SPEECH_MAX: + return explicit + if "cta" in layout: + cta = str(slide.get("cta") or "").strip() + if cta and len(cta) <= _SPEECH_MAX: + return cta.rstrip(".!") + "!" + if "cover" in layout: + return "놓치지 마!" + return "" + + +def is_contest_slide_empty(slide: dict[str, Any]) -> bool: + layout = str(slide.get("layout_type") or "") + if "cover" in layout: + return not bool( + str(slide.get("headline") or "").strip() + or str(slide.get("highlight") or "").strip() + or str(slide.get("body") or "").strip() + or str(slide.get("eyebrow") or "").strip() + ) + if "cta" in layout: + return not ( + bool(str(slide.get("cta") or "").strip()) + or bool(str(slide.get("headline") or "").strip()) + ) + if slide.get("items"): + return len([i for i in slide["items"] if str(i.get("text") or "").strip()]) == 0 + return not bool(str(slide.get("body") or slide.get("headline") or "").strip()) + + +def prepare_contest_slides(slides: list[dict[str, Any]]) -> list[dict[str, Any]]: + rows = compact_contest_deck(slides) + return [normalize_contest_slide_copy(s) for s in rows if not is_contest_slide_empty(s)] diff --git a/app/contest_cardnews/render.py b/app/contest_cardnews/render.py new file mode 100644 index 0000000..fa938ff --- /dev/null +++ b/app/contest_cardnews/render.py @@ -0,0 +1,118 @@ +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.contest_cardnews.template import ( + LAYOUT_COVER, + LAYOUT_CTA, + apply_deck_palette, + normalize_contest_slide, + render_contest_slide, + resolve_palette, +) +from app.contest_cardnews.copy import ( + is_contest_slide_empty, + normalize_contest_slide_copy, + prepare_contest_slides, +) +from app.policy_cardnews.mascot import pick_mascot, pick_pin_mascot + +logger = logging.getLogger(__name__) + +_REPO_ROOT = Path(__file__).resolve().parents[2] + + +@dataclass +class ContestSlideRenderContext: + slide: dict[str, Any] + host_org: str + 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 _render_slide_image(*, ctx: ContestSlideRenderContext) -> Image.Image: + slide_no = int(ctx.slide.get("slide") or 1) + slide = normalize_contest_slide(ctx.slide, index=slide_no, total=ctx.slide_total) + palette = resolve_palette(str(slide.get("template_palette") or "pastel_mint")) + return render_contest_slide( + slide, + palette=palette, + mascot=ctx.mascot, + source_url=ctx.source_url, + ) + + +async def render_contest_cardnews_slides( + *, + contentid: str, + slides: list[dict[str, Any]], + output_dir: Path, + host_org: str = "", + source_url: str = "", +) -> list[str]: + """공모전 전용 브라우저형 템플릿 + 캐릭터 PNG (정책 템플릿·크롤 이미지 미사용).""" + target_dir = output_dir / contentid + target_dir.mkdir(parents=True, exist_ok=True) + + rng = random.Random(contentid) + prepared = prepare_contest_slides(slides) + for row in prepared: + row["use_image"] = False + prepared = apply_deck_palette(prepared, rng=rng, contentid=contentid) + prepared = [normalize_contest_slide_copy(s) for s in prepared] + slides_to_render = [s for s in prepared if not is_contest_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["use_image"] = False + layout = str(slide.get("layout_type") or LAYOUT_COVER) + is_cover = layout == LAYOUT_COVER or index == 1 + is_cta = index == slide_total or layout == LAYOUT_CTA + + mascot_name = "" + mascot: Image.Image | None = None + if is_cover: + mascot_pick = pick_mascot(rng) + if mascot_pick: + mascot_name, mascot = mascot_pick[0], mascot_pick[1] + elif is_cta: + mascot_pick = pick_pin_mascot(rng) + if mascot_pick: + mascot_name, mascot = mascot_pick[0], mascot_pick[1] + ctx = ContestSlideRenderContext( + slide=slide, + host_org=host_org, + mascot=mascot, + mascot_name=mascot_name, + slide_total=slide_total, + source_url=source_url, + ) + out_path = target_dir / f"slide_{index:02d}.png" + image = _render_slide_image(ctx=ctx) + image.save(out_path, format="PNG") + saved_paths.append(_to_handoff_path(out_path)) + if mascot_name: + logger.info("공모전 카드뉴스 slide_%02d 캐릭터: %s", index, mascot_name) + + return saved_paths diff --git a/app/contest_cardnews/slides.py b/app/contest_cardnews/slides.py new file mode 100644 index 0000000..21ebfdb --- /dev/null +++ b/app/contest_cardnews/slides.py @@ -0,0 +1,137 @@ +from __future__ import annotations + +import json +import re +from typing import Any + +from app.contest_cardnews.copy import compact_contest_deck +from app.contest_cardnews.template.dispatch import ( + LAYOUT_CHECKLIST, + LAYOUT_COVER, + LAYOUT_CTA, + LAYOUT_TABLE, + normalize_layout_type, + pick_middle_layout, +) +from app.contest_cardnews.template.palette import palette_names +from app.policy_cardnews.copy import polish_korean_text + +CONTEST_LAYOUTS = frozenset({ + LAYOUT_COVER, + LAYOUT_TABLE, + LAYOUT_CHECKLIST, + LAYOUT_CTA, +}) + +_JSON_ARRAY_RE = re.compile(r"\[[\s\S]*\]") +_MAX_SLIDES = 4 + + +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 _resolve_layout(item: dict[str, Any], *, index: int, total: int) -> str: + raw = str(item.get("layout_type") or "").strip() + layout = normalize_layout_type(raw) + if layout in CONTEST_LAYOUTS: + return layout + if index == 1: + return LAYOUT_COVER + if index == total: + return LAYOUT_CTA + return pick_middle_layout(item) + + +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("eyebrow") or "").strip(): + return True + if str(item.get("cta") or "").strip(): + return True + return bool(_parse_items(item.get("items"))) + + +def _resolve_palette(item: dict[str, Any]) -> str: + raw = str(item.get("template_palette") or "").strip() + if raw in palette_names(): + return raw + return "" + + +def parse_contest_cardnews_slides_json(raw: str) -> list[dict[str, Any]]: + """공모전 카드뉴스 슬라이드 JSON — layout_type·palette 보존 (정책 파서 사용 금지).""" + 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("유효한 카드뉴스 슬라이드가 없음") + + if len(raw_slides) > _MAX_SLIDES: + raw_slides = [raw_slides[0], *raw_slides[1 : _MAX_SLIDES - 1], raw_slides[-1]] + + raw_slides = compact_contest_deck( + [{"slide": i + 1, **s} for i, s in enumerate(raw_slides)], + ) + + deck_palette = "" + for item in raw_slides: + deck_palette = _resolve_palette(item) + if deck_palette: + break + + total = len(raw_slides) + slides: list[dict[str, Any]] = [] + for index, item in enumerate(raw_slides, start=1): + layout = _resolve_layout(item, index=index, total=total) + palette = _resolve_palette(item) or deck_palette + slides.append( + { + "slide": int(item.get("slide") or index), + "layout_type": layout, + "template_palette": palette, + "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 "")), + "point": polish_korean_text(str(item.get("point") or "")), + "use_image": False, + }, + ) + return slides diff --git a/app/contest_cardnews/template/__init__.py b/app/contest_cardnews/template/__init__.py new file mode 100644 index 0000000..7ed7651 --- /dev/null +++ b/app/contest_cardnews/template/__init__.py @@ -0,0 +1,28 @@ +from app.contest_cardnews.template.dispatch import ( + LAYOUT_BODY, + LAYOUT_CHECKLIST, + LAYOUT_COVER, + LAYOUT_CTA, + LAYOUT_HEADLINE, + LAYOUT_TABLE, + LAYOUT_THREE_COL, + MASCOT_LAYOUTS, + normalize_contest_slide, + render_contest_slide, +) +from app.contest_cardnews.template.palette import apply_deck_palette, resolve_palette + +__all__ = [ + "LAYOUT_BODY", + "LAYOUT_CHECKLIST", + "LAYOUT_COVER", + "LAYOUT_CTA", + "LAYOUT_HEADLINE", + "LAYOUT_TABLE", + "LAYOUT_THREE_COL", + "MASCOT_LAYOUTS", + "apply_deck_palette", + "normalize_contest_slide", + "render_contest_slide", + "resolve_palette", +] diff --git a/app/contest_cardnews/template/base.py b/app/contest_cardnews/template/base.py new file mode 100644 index 0000000..e6afc36 --- /dev/null +++ b/app/contest_cardnews/template/base.py @@ -0,0 +1,610 @@ +from __future__ import annotations + +from dataclasses import dataclass +from functools import lru_cache +from pathlib import Path +from typing import Any + +from PIL import Image, ImageDraw, ImageFont + +from app.contest_cardnews.constants import ( + CANVAS_HEIGHT, + CANVAS_WIDTH, + CARD_INSET, + CONTENT_PAD, + ELEMENT_GAP, + FONT_SCALE, + GAP_LG, + GAP_MD, + GAP_SM, + INK, + INK_SOFT, + INNER_PAD_X, + INNER_PAD_Y, + MASCOT_GAP, + MASCOT_ZONE_H, + SPEECH_ICON_GAP, + NOTE_PINK, + POINT_FOOTER_H, + STAR_PEACH, +) +from app.policy_cardnews.mascot import BUBBLE_FILL, BUBBLE_OUTLINE, draw_classic_speech_bubble +from app.contest_cardnews.template.chrome import draw_browser_chrome +from app.contest_cardnews.template.palette import ContestPalette +from app.policy_cardnews.template.draw import ( + fit_font_lines, + line_height, + measure_text_block, + wrap_text, +) + +from app.policy_cardnews.paths import contest_cardnews_font_dir + + +_FONT_DIR = contest_cardnews_font_dir() +_CONTEST_FONT_REGULAR = "Hakgyoansim Dunggeunmiso TTF R.ttf" +_CONTEST_FONT_BOLD = "Hakgyoansim Dunggeunmiso TTF B.ttf" + + +@dataclass +class ContestRenderContext: + slide: dict[str, Any] + palette: ContestPalette + mascot: Image.Image | None = None + source_url: str = "" + + +def content_bottom_y( + cy1: int, + *, + has_mascot: bool = False, + has_point: bool = False, +) -> int: + """본문·도형이 들어갈 하한 (캐릭터·POINT 영역 제외).""" + y = cy1 + if has_point: + y -= POINT_FOOTER_H + if has_mascot: + y -= MASCOT_ZONE_H + MASCOT_GAP + return y + + +def mascot_zone(cx0: int, cy0: int, cx1: int, cy1: int) -> tuple[int, int, int, int]: + """하단 캐릭터 전용 슬롯 (본문과 겹치지 않음).""" + zone_y1 = cy1 - POINT_FOOTER_H if cy1 > POINT_FOOTER_H + MASCOT_ZONE_H else cy1 + zone_y0 = zone_y1 - MASCOT_ZONE_H + return (cx0, max(cy0, zone_y0), cx1, zone_y1) + + +@lru_cache(maxsize=64) +def load_font(size: int, *, bold: bool = False, extra_bold: bool = False) -> ImageFont.FreeTypeFont: + """공모전 카드뉴스 전용: 학교안심 둥근미소 R/B만 사용.""" + size = max(18, int(size * FONT_SCALE)) + name = _CONTEST_FONT_BOLD if (bold or extra_bold) else _CONTEST_FONT_REGULAR + path = _FONT_DIR / name + if path.is_file(): + return ImageFont.truetype(str(path), size=size) + # 전용 폰트가 없을 때 한글 지원 가능한 Pretendard를 우선 폴백으로 사용한다. + for fallback_name in ( + "Pretendard-Bold.otf", + "Pretendard-Bold.ttf", + "Pretendard-Medium.otf", + "Pretendard-Regular.otf", + ): + fallback_path = _FONT_DIR / fallback_name + if fallback_path.is_file(): + return ImageFont.truetype(str(fallback_path), size=size) + return ImageFont.load_default() + + +def new_canvas(palette: ContestPalette) -> tuple[Image.Image, ImageDraw.ImageDraw, tuple[int, int, int, int]]: + canvas = Image.new("RGB", (CANVAS_WIDTH, CANVAS_HEIGHT), palette.outer) + draw = ImageDraw.Draw(canvas) + wx0 = CARD_INSET + wy0 = CARD_INSET + wx1 = CANVAS_WIDTH - CARD_INSET + wy1 = CANVAS_HEIGHT - CARD_INSET + draw.rectangle((wx0, wy0, wx1, wy1), fill=(255, 255, 255)) + content_top = draw_browser_chrome(draw, (wx0, wy0, wx1, wy1), palette) + cx0 = wx0 + CONTENT_PAD + cy0 = content_top + GAP_SM + cx1 = wx1 - CONTENT_PAD + cy1 = wy1 - CONTENT_PAD + return canvas, draw, (cx0, cy0, cx1, cy1) + + +def inner_content_rect( + rect: tuple[int, int, int, int], +) -> tuple[int, int, int, int]: + """본문을 화면 중앙으로 모으기 위한 추가 인셋.""" + x0, y0, x1, y1 = rect + return ( + x0 + INNER_PAD_X, + y0 + INNER_PAD_Y, + x1 - INNER_PAD_X, + y1 - INNER_PAD_Y, + ) + + +def centered_column( + rect: tuple[int, int, int, int], + *, + width_ratio: float = 0.9, +) -> tuple[int, int, int, int]: + """가로 중앙 정렬된 본문 컬럼.""" + x0, y0, x1, y1 = rect + w = max(200, int((x1 - x0) * width_ratio)) + cx = (x0 + x1) // 2 + return (cx - w // 2, y0, cx + w // 2, y1) + + +def vertical_center_y(y0: int, y1: int, block_h: int) -> int: + return y0 + max(0, (y1 - y0 - block_h) // 2) + + +def fit_font_single_line( + draw: ImageDraw.ImageDraw, + text: str, + max_w: int, + *, + start_size: int, + min_size: int, + bold: bool = False, + max_lines: int = 2, +) -> tuple[ImageFont.FreeTypeFont, list[str]]: + value = (text or "").strip() + if not value: + return load_font(min_size, bold=bold), [] + for size in range(start_size, min_size - 1, -2): + font, lines = fit_font_lines( + draw, + value, + max_w, + start_size=size, + min_size=size, + max_lines=max_lines, + load_font_fn=load_font, + bold=bold, + extra_bold=bold, + ) + if lines: + return font, lines + font = load_font(min_size, bold=bold) + return font, fit_font_lines( + draw, value, max_w, start_size=min_size, min_size=min_size, + max_lines=max_lines, load_font_fn=load_font, bold=bold, extra_bold=bold, + )[1] + + +def draw_centered_lines( + draw: ImageDraw.ImageDraw, + *, + cx0: int, + cx1: int, + y: int, + lines: list[str], + font: ImageFont.FreeTypeFont, + fill: tuple[int, int, int], + max_y: int, +) -> int: + cy = y + for line in lines: + lh = line_height(font, GAP_SM) + if cy + lh > max_y: + break + lw = int(draw.textlength(line, font=font)) + tx = cx0 + (cx1 - cx0 - lw) // 2 + draw.text((tx, cy), line, font=font, fill=fill) + cy += lh + return cy + + +def draw_centered_in_band( + draw: ImageDraw.ImageDraw, + *, + cx0: int, + cx1: int, + y0: int, + y1: int, + lines: list[str], + font: ImageFont.FreeTypeFont, + fill: tuple[int, int, int], +) -> int: + """밴드(y0~y1) 안에 텍스트 블록을 세로·가로 중앙 정렬.""" + if not lines or y1 <= y0: + return y0 + block_h = measure_text_block(lines, font, line_gap=GAP_SM) + cy = y0 + max(0, (y1 - y0 - block_h) // 2) + for line in lines: + lh = line_height(font, GAP_SM) + if cy + lh > y1: + break + lw = int(draw.textlength(line, font=font)) + tx = cx0 + (cx1 - cx0 - lw) // 2 + draw.text((tx, cy), line, font=font, fill=fill) + cy += lh + return cy + + +def draw_centered_title( + draw: ImageDraw.ImageDraw, + *, + cx0: int, + cx1: int, + y: int, + text: str, + palette: ContestPalette, + size: int = 40, + max_y: int | None = None, +) -> int: + msg = (text or "").strip() + if not msg: + return y + limit = max_y if max_y is not None else y + 200 + font, lines = fit_font_single_line( + draw, msg, cx1 - cx0, start_size=size, min_size=max(22, size - 14), bold=True, max_lines=2, + ) + return draw_centered_lines( + draw, cx0=cx0, cx1=cx1, y=y, lines=lines, font=font, fill=palette.accent, max_y=limit, + ) + 10 + + +def draw_contest_speech_bubble( + draw: ImageDraw.ImageDraw, + *, + bx0: int, + by0: int, + bx1: int, + by1: int, + tail_tip: tuple[int, int], + tail_from_side: str = "bottom", +) -> None: + """흰색 라운드 말풍선 + 캐릭터 방향 꼬리.""" + draw_classic_speech_bubble( + draw, + bx0=bx0, + by0=by0, + bx1=bx1, + by1=by1, + tail_tip=tail_tip, + tail_from_side=tail_from_side, + fill=BUBBLE_FILL, + outline=BUBBLE_OUTLINE, + outline_width=2, + radius=22, + ) + + +def draw_section_title( + draw: ImageDraw.ImageDraw, + *, + x0: int, + y: int, + accent: str, + suffix: str, + palette: ContestPalette, +) -> int: + bar_w = 6 + font = load_font(38, bold=True) + accent_text = (accent or "").strip() + suffix_text = (suffix or "").strip() + x = x0 + draw.rectangle((x, y + 4, x + bar_w, y + 38), fill=palette.accent) + x += bar_w + 12 + if accent_text: + draw.text((x, y), accent_text, font=font, fill=palette.accent) + x += int(draw.textlength(accent_text, font=font)) + if suffix_text: + draw.text((x, y), suffix_text, font=font, fill=INK) + return y + 48 + + +def draw_point_footer( + draw: ImageDraw.ImageDraw, + *, + cx0: int, + cx1: int, + cy1: int, + text: str, + palette: ContestPalette, +) -> None: + msg = (text or "").strip() + if not msg: + return + if not msg.upper().startswith("POINT"): + msg = f"POINT. {msg}" + font = load_font(32, bold=True) + lw = int(draw.textlength(msg, font=font)) + cx = cx0 + (cx1 - cx0 - lw) // 2 + draw.text((cx, cy1 - POINT_FOOTER_H + 8), msg, font=font, fill=palette.accent) + + +def draw_four_point_star( + draw: ImageDraw.ImageDraw, + cx: int, + cy: int, + size: int, + fill: tuple[int, int, int], +) -> None: + s = size + draw.polygon( + [ + (cx, cy - s), + (cx + s // 3, cy - s // 3), + (cx + s, cy), + (cx + s // 3, cy + s // 3), + (cx, cy + s), + (cx - s // 3, cy + s // 3), + (cx - s, cy), + (cx - s // 3, cy - s // 3), + ], + fill=fill, + ) + + +def draw_music_note(draw: ImageDraw.ImageDraw, x: int, y: int) -> None: + draw.ellipse((x, y + 18, x + 14, y + 32), fill=NOTE_PINK) + draw.ellipse((x + 16, y + 10, x + 30, y + 24), fill=NOTE_PINK) + draw.rectangle((x + 12, y, x + 18, y + 22), fill=NOTE_PINK) + draw.rectangle((x + 28, y - 8, x + 34, y + 14), fill=NOTE_PINK) + + +def _bubble_metrics( + draw: ImageDraw.ImageDraw, + speech: str, + max_w: int, +) -> tuple[list[str], int, int, ImageFont.FreeTypeFont]: + font, lines = fit_font_single_line( + draw, speech, max_w, start_size=28, min_size=22, bold=True, max_lines=2, + ) + if not lines: + return [], 0, 0, font + pad_x, pad_y = 24, 16 + line_h = line_height(font, GAP_SM) + bh = pad_y * 2 + line_h * len(lines) + bw = max((int(draw.textlength(line, font=font)) for line in lines), default=0) + pad_x * 2 + return lines, bw, bh, font + + +def draw_accent_pill( + draw: ImageDraw.ImageDraw, + *, + cx0: int, + cx1: int, + y0: int, + y1: int, + text: str, + palette: ContestPalette, + start_size: int = 36, + min_size: int = 28, +) -> bool: + """텍스트가 있을 때만 CTA/필 버튼 도형을 그림.""" + label = (text or "").strip() + if not label: + return False + font, lines = fit_font_single_line( + draw, label, cx1 - cx0 - 96, start_size=start_size, min_size=min_size, bold=True, max_lines=2, + ) + if not lines: + return False + lh = measure_text_block(lines, font, line_gap=GAP_SM) + pad_y = 18 + inner_h = lh + pad_y * 2 + by0 = y1 - inner_h + if by0 < y0: + by0 = y0 + inner_h = y1 - by0 + draw.rounded_rectangle((cx0, by0, cx1, y1), radius=26, fill=palette.accent) + ty = by0 + max(pad_y, (inner_h - lh) // 2) + draw_centered_lines( + draw, cx0=cx0 + 12, cx1=cx1 - 12, y=ty, lines=lines, + font=font, fill=(255, 255, 255), max_y=y1 - 8, + ) + return True + + +def paste_mascot_in_zone( + canvas: Image.Image, + mascot: Image.Image | None, + speech: str, + zone: tuple[int, int, int, int], + *, + palette: ContestPalette, + corner: str = "right", +) -> Image.Image: + """말풍선(위) → 캐릭터(아래) 순으로 배치, 서로·본문과 겹치지 않음.""" + if mascot is None: + return canvas + zx0, zy0, zx1, zy1 = zone + zone_h = zy1 - zy0 + zone_w = zx1 - zx0 + if zone_h < 140 or zone_w < 120: + return canvas + + speech = (speech or "").strip()[:14] + lines: list[str] = [] + bw = bh = 0 + font = load_font(28, bold=True) + probe = ImageDraw.Draw(Image.new("RGB", (400, 400))) + if speech: + lines, bw, bh, font = _bubble_metrics(probe, speech, max(180, zone_w - 56)) + if not lines: + bw = bh = 0 + + pad_x, pad_y = 24, 16 + line_h = line_height(font, GAP_SM) if lines else 0 + + icon = mascot.copy() + max_icon_h = zone_h - bh - SPEECH_ICON_GAP - 24 + max_icon_h = max(100, min(200, max_icon_h)) + icon.thumbnail((int(max_icon_h * 1.15), max_icon_h), Image.Resampling.LANCZOS) + + my = zy1 - icon.height - 12 + if corner == "left": + mx = zx0 + 16 + else: + mx = zx1 - icon.width - 16 + + base = canvas.convert("RGBA") + layer = Image.new("RGBA", base.size, (0, 0, 0, 0)) + draw = ImageDraw.Draw(layer) + + if lines and bh > 0 and bw > 0: + by1 = my - SPEECH_ICON_GAP + by0 = by1 - bh + if by0 < zy0 + 8: + by0 = zy0 + 8 + by1 = by0 + bh + my = min(zy1 - icon.height - 12, by1 + SPEECH_ICON_GAP + icon.height) + my = max(my, zy0 + bh + SPEECH_ICON_GAP + 8) + + bubble_cx = mx + icon.width // 2 + bx0 = bubble_cx - bw // 2 + bx1 = bx0 + bw + if bx0 < zx0 + 8: + shift = zx0 + 8 - bx0 + bx0 += shift + bx1 += shift + if bx1 > zx1 - 8: + shift = bx1 - (zx1 - 8) + bx0 -= shift + bx1 -= shift + + tail_x = bubble_cx + tail_y = my + 8 + draw_contest_speech_bubble( + draw, + bx0=bx0, + by0=by0, + bx1=bx1, + by1=by1, + tail_tip=(tail_x, tail_y), + tail_from_side="bottom", + ) + ty = by0 + pad_y + for line in lines: + lw = int(draw.textlength(line, font=font)) + tx = bx0 + (bw - lw) // 2 + draw.text((tx, ty), line, font=font, fill=INK) + ty += line_h + + layer.paste(icon, (mx, my), icon) + return Image.alpha_composite(base, layer).convert("RGB") + + +def draw_source_url_footer( + draw: ImageDraw.ImageDraw, + *, + cx0: int, + cx1: int, + y: int, + url: str, + palette: ContestPalette, + max_y: int, +) -> None: + value = (url or "").strip() + if not value or y >= max_y - 8: + return + font = load_font(24) + probe = ImageDraw.Draw(Image.new("RGB", (10, 10))) + lines = wrap_text(probe, value, font, cx1 - cx0 - 32)[:2] + lh = line_height(font, 6) + cy = y + for line in lines: + if cy + lh > max_y: + break + lw = int(draw.textlength(line, font=font)) + tx = cx0 + (cx1 - cx0 - lw) // 2 + draw.text((tx, cy), line, font=font, fill=palette.accent) + cy += lh + + +def item_text(item: dict[str, Any]) -> str: + return str( + item.get("text") or item.get("value") or item.get("content") or item.get("body") or "", + ).strip() + + +def slide_items(slide: dict[str, Any], *, max_items: int = 6) -> list[dict[str, str]]: + items: list[dict[str, str]] = [] + for raw in list(slide.get("items") or []): + if not isinstance(raw, dict): + continue + text = item_text(raw) + if not text: + continue + items.append( + { + "label": str(raw.get("label") or raw.get("title") or "").strip(), + "text": text, + }, + ) + if not items and slide.get("body"): + items = [{"label": "", "text": t} for t in str(slide["body"]).split("\n") if t.strip()] + return items[:max_items] + + +def draw_wrapped_block( + draw: ImageDraw.ImageDraw, + text: str, + *, + x0: int, + x1: int, + y: int, + max_y: int, + start_size: int = 38, + min_size: int = 22, + bold: bool = False, + fill: tuple[int, int, int] = INK, + center: bool = False, + max_lines: int = 12, +) -> int: + cw = x1 - x0 + available = max_y - y + if cw < 40 or available < 12: + return y + value = (text or "").strip() + if not value: + return y + + font = load_font(min_size, bold=bold, extra_bold=bold) + lines: list[str] = [] + for size in range(start_size, min_size - 1, -2): + candidate, candidate_lines = fit_font_lines( + draw, + value, + cw, + start_size=size, + min_size=size, + max_lines=max_lines, + load_font_fn=load_font, + bold=bold, + extra_bold=bold, + ) + block_h = measure_text_block(candidate_lines, candidate, line_gap=GAP_SM) + if block_h <= available: + font, lines = candidate, candidate_lines + break + if not lines: + font, lines = fit_font_lines( + draw, + value, + cw, + start_size=min_size, + min_size=min_size, + max_lines=max_lines, + load_font_fn=load_font, + bold=bold, + extra_bold=bold, + ) + + cy = y + for line in lines: + lh = line_height(font, GAP_SM) + if cy + lh > max_y: + break + lw = int(draw.textlength(line, font=font)) + tx = x0 + (cw - lw) // 2 if center else x0 + draw.text((tx, cy), line, font=font, fill=fill) + cy += lh + return cy diff --git a/app/contest_cardnews/template/chrome.py b/app/contest_cardnews/template/chrome.py new file mode 100644 index 0000000..bbda021 --- /dev/null +++ b/app/contest_cardnews/template/chrome.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +from typing import Any + +from PIL import ImageDraw + +from app.contest_cardnews.constants import CHROME_HEIGHT, INK_SOFT +from app.contest_cardnews.template.palette import ContestPalette + +_TRAFFIC = ((235, 120, 110), (248, 210, 100), (130, 198, 130)) + + +def draw_browser_chrome( + draw: ImageDraw.ImageDraw, + white_rect: tuple[int, int, int, int], + palette: ContestPalette, +) -> int: + """흰 카드 상단에 브라우저 UI. 반환: 콘텐츠 시작 y.""" + wx0, wy0, wx1, _wy1 = white_rect + w = wx1 - wx0 + title_h = 44 + nav_h = CHROME_HEIGHT - title_h + + draw.rectangle((wx0, wy0, wx1, wy0 + title_h), fill=palette.chrome) + dot_y = wy0 + title_h // 2 + for i, color in enumerate(_TRAFFIC): + cx = wx0 + 22 + i * 22 + draw.ellipse((cx - 6, dot_y - 6, cx + 6, dot_y + 6), fill=color) + + tab_x0 = wx0 + w // 2 - 70 + tab_y0 = wy0 + 10 + draw.rounded_rectangle( + (tab_x0, tab_y0, tab_x0 + 140, wy0 + title_h - 4), + radius=10, + fill=(255, 255, 255), + ) + draw.text((tab_x0 + 118, tab_y0 + 6), "×", fill=INK_SOFT) + + nav_y0 = wy0 + title_h + draw.rectangle((wx0, nav_y0, wx1, nav_y0 + nav_h), fill=(248, 250, 252)) + bar_y0 = nav_y0 + 12 + bar_h = nav_h - 24 + bar_x0 = wx0 + 88 + bar_x1 = wx1 - 52 + draw.rounded_rectangle( + (bar_x0, bar_y0, bar_x1, bar_y0 + bar_h), + radius=bar_h // 2, + fill=(255, 255, 255), + outline=palette.panel_border, + width=2, + ) + + icon_x = wx0 + 18 + for dx in (0, 22, 44): + _draw_nav_icon(draw, icon_x + dx, nav_y0 + nav_h // 2, palette.chrome_dark) + draw.ellipse( + (bar_x0 + 14, bar_y0 + bar_h // 2 - 7, bar_x0 + 28, bar_y0 + bar_h // 2 + 7), + fill=palette.accent, + ) + menu_x = wx1 - 36 + for row in range(3): + cy = nav_y0 + 16 + row * 10 + draw.ellipse((menu_x, cy, menu_x + 6, cy + 6), fill=INK_SOFT) + + return wy0 + CHROME_HEIGHT + + +def _draw_nav_icon(draw: ImageDraw.ImageDraw, x: int, cy: int, fill: tuple[int, int, int]) -> None: + draw.polygon([(x, cy), (x + 8, cy - 5), (x + 8, cy + 5)], fill=fill) + draw.polygon([(x + 14, cy), (x + 6, cy - 5), (x + 6, cy + 5)], fill=fill) + diff --git a/app/contest_cardnews/template/dispatch.py b/app/contest_cardnews/template/dispatch.py new file mode 100644 index 0000000..82d2cda --- /dev/null +++ b/app/contest_cardnews/template/dispatch.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +from typing import Any, Callable + +from PIL import Image + +from app.contest_cardnews.template.base import ContestRenderContext +from app.contest_cardnews.template.layouts import ( + render_contest_body, + render_contest_checklist, + render_contest_cover, + render_contest_cta, + render_contest_headline, + render_contest_table, + render_contest_three_col, +) +from app.contest_cardnews.template.palette import ContestPalette, resolve_palette + +LAYOUT_COVER = "contest_cover" +LAYOUT_HEADLINE = "contest_headline" +LAYOUT_BODY = "contest_body" +LAYOUT_TABLE = "contest_table" +LAYOUT_CHECKLIST = "contest_checklist" +LAYOUT_THREE_COL = "contest_three_col" +LAYOUT_CTA = "contest_cta" + +# 캐릭터는 표지·CTA만 (중간 슬라이드와 겹침 방지) +MASCOT_LAYOUTS = {LAYOUT_COVER, LAYOUT_CTA} + +_LEGACY_LAYOUT_MAP = { + "template_cover": LAYOUT_COVER, + "template_numbered": LAYOUT_CHECKLIST, + "template_grid": LAYOUT_TABLE, + "template_three_col": LAYOUT_THREE_COL, + "template_cta": LAYOUT_CTA, +} + +_RENDERERS: dict[str, Callable[[ContestRenderContext], Image.Image]] = { + LAYOUT_COVER: render_contest_cover, + LAYOUT_HEADLINE: render_contest_headline, + LAYOUT_BODY: render_contest_body, + LAYOUT_TABLE: render_contest_table, + LAYOUT_CHECKLIST: render_contest_checklist, + LAYOUT_THREE_COL: render_contest_three_col, + LAYOUT_CTA: render_contest_cta, +} + + +def normalize_layout_type(raw: str) -> str: + key = (raw or "").strip() + if key in _RENDERERS: + return key + return _LEGACY_LAYOUT_MAP.get(key, key) + + +def pick_middle_layout(slide: dict[str, Any]) -> str: + items = [ + i + for i in list(slide.get("items") or []) + if str(i.get("text") or "").strip() + ] + n = len(items) + if n == 3: + return LAYOUT_THREE_COL + if n >= 4: + return LAYOUT_TABLE + if n >= 1: + return LAYOUT_CHECKLIST + body = str(slide.get("body") or "").strip() + if len(body) > 80: + return LAYOUT_BODY + return LAYOUT_HEADLINE + + +def normalize_contest_slide(slide: dict[str, Any], *, index: int, total: int) -> dict[str, Any]: + row = dict(slide) + layout = normalize_layout_type(str(row.get("layout_type") or "")) + if not layout or layout not in _RENDERERS: + if index == 1: + layout = LAYOUT_COVER + elif index == total: + layout = LAYOUT_CTA + else: + layout = pick_middle_layout(row) + row["layout_type"] = layout + return row + + +def render_contest_slide( + slide: dict[str, Any], + *, + palette: ContestPalette | None = None, + mascot: Image.Image | None = None, + source_url: str = "", +) -> Image.Image: + pal = palette or resolve_palette(str(slide.get("template_palette") or "pastel_mint")) + layout = normalize_layout_type(str(slide.get("layout_type") or LAYOUT_COVER)) + renderer = _RENDERERS.get(layout, render_contest_headline) + url = (source_url or str(slide.get("source_url") or "")).strip() + ctx = ContestRenderContext( + slide=slide, + palette=pal, + mascot=mascot, + source_url=url, + ) + return renderer(ctx) diff --git a/app/contest_cardnews/template/layouts.py b/app/contest_cardnews/template/layouts.py new file mode 100644 index 0000000..a3c3581 --- /dev/null +++ b/app/contest_cardnews/template/layouts.py @@ -0,0 +1,476 @@ +from __future__ import annotations + +from typing import Any + +from PIL import ImageDraw + +from app.contest_cardnews.constants import ( + CARD_INNER_PAD, + ELEMENT_GAP, + GAP_MD, + GAP_SM, + INK, + INK_SOFT, + LABEL_STRIP_H, + ROW_GAP, + STAR_PEACH, + TITLE_TOP_PAD, +) +from app.contest_cardnews.template.base import ( + ContestRenderContext, + centered_column, + content_bottom_y, + draw_accent_pill, + draw_centered_in_band, + draw_centered_lines, + draw_centered_title, + draw_four_point_star, + draw_point_footer, + draw_source_url_footer, + draw_wrapped_block, + fit_font_single_line, + inner_content_rect, + load_font, + mascot_zone, + new_canvas, + paste_mascot_in_zone, + slide_items, + vertical_center_y, +) +from app.policy_cardnews.template.draw import line_height, measure_text_block + + +def _has_mascot(ctx: ContestRenderContext) -> bool: + return ctx.mascot is not None + + +def _finish( + canvas, + ctx: ContestRenderContext, + content_rect: tuple[int, int, int, int], + *, + corner: str = "right", +): + if _has_mascot(ctx): + cx0, cy0, cx1, cy1 = content_rect + zone = mascot_zone(cx0, cy0, cx1, cy1) + canvas = paste_mascot_in_zone( + canvas, + ctx.mascot, + str(ctx.slide.get("speech") or ""), + zone, + palette=ctx.palette, + corner=corner, + ) + return canvas + + +def _estimate_centered_stack_height( + draw: ImageDraw.ImageDraw, + parts: list[tuple[str, int, int, bool, tuple[int, int, int]]], + width: int, +) -> int: + """parts: (text, start_size, min_size, bold, fill_ignored).""" + total = 0 + for text, start, min_sz, bold, _ in parts: + if not text: + continue + _, lines = fit_font_single_line( + draw, text, width, start_size=start, min_size=min_sz, bold=bold, max_lines=3, + ) + font = load_font(min_sz, bold=bold, extra_bold=bold) + total += measure_text_block(lines, font, line_gap=GAP_SM) + ELEMENT_GAP + return max(0, total - ELEMENT_GAP) + + +def render_contest_cover(ctx: ContestRenderContext): + slide = ctx.slide + palette = ctx.palette + canvas, draw, rect = new_canvas(palette) + col = centered_column(inner_content_rect(rect)) + ix0, iy0, ix1, iy1 = col + cw = ix1 - ix0 + has_mascot = _has_mascot(ctx) + bottom = content_bottom_y(iy1, has_mascot=has_mascot, has_point=False) + + 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() + pill = str(slide.get("body") or slide.get("cta") or "").strip() + + pill_h = 0 + if pill: + _, plines = fit_font_single_line( + draw, pill, cw - 100, start_size=34, min_size=26, bold=True, max_lines=2, + ) + pf = load_font(26, bold=True) + pill_h = measure_text_block(plines, pf, line_gap=8) + 36 + + text_bottom = bottom - pill_h - ELEMENT_GAP + parts: list[tuple[str, int, int, bool, tuple[int, int, int]]] = [] + if eyebrow: + parts.append((eyebrow, 38, 28, False, INK)) + if headline: + parts.append((headline, 72, 40, True, palette.accent)) + if highlight and highlight != headline: + parts.append((highlight, 72, 40, True, INK)) + + stack_h = _estimate_centered_stack_height(draw, parts, cw) + y = vertical_center_y(iy0, text_bottom, stack_h) + + draw_four_point_star(draw, ix0 + 4, iy0 + 2, 12, STAR_PEACH) + draw_four_point_star(draw, ix1 - 28, iy0 + 6, 10, STAR_PEACH) + + if eyebrow: + font, lines = fit_font_single_line(draw, eyebrow, cw, start_size=38, min_size=28, max_lines=2) + y = draw_centered_lines(draw, cx0=ix0, cx1=ix1, y=y, lines=lines, font=font, fill=INK, max_y=text_bottom) + ELEMENT_GAP + + if headline: + font, lines = fit_font_single_line( + draw, headline, cw - 8, start_size=72, min_size=40, bold=True, max_lines=2, + ) + y = draw_centered_lines( + draw, cx0=ix0, cx1=ix1, y=y, lines=lines, font=font, fill=palette.accent, max_y=text_bottom, + ) + ELEMENT_GAP + + if highlight and highlight != headline: + font, lines = fit_font_single_line( + draw, highlight, cw - 8, start_size=72, min_size=40, bold=True, max_lines=2, + ) + y = draw_centered_lines( + draw, cx0=ix0, cx1=ix1, y=y, lines=lines, font=font, fill=INK, max_y=text_bottom, + ) + ELEMENT_GAP + + if pill: + draw_accent_pill( + draw, cx0=ix0 + 36, cx1=ix1 - 36, y0=iy0, y1=bottom - ELEMENT_GAP, + text=pill, palette=palette, start_size=34, min_size=26, + ) + + return _finish(canvas, ctx, rect, corner="right") + + +def render_contest_headline(ctx: ContestRenderContext): + slide = ctx.slide + palette = ctx.palette + canvas, draw, rect = new_canvas(palette) + col = centered_column(inner_content_rect(rect)) + ix0, iy0, ix1, iy1 = col + bottom = content_bottom_y(iy1, has_point=True) + + main = str(slide.get("headline") or "").strip() + highlight = str(slide.get("highlight") or "").strip() + if highlight and highlight not in main: + main = f"{main} {highlight}".strip() + if not main: + return canvas + + title_h = 52 + _, main_lines = fit_font_single_line( + draw, main, ix1 - ix0, start_size=54, min_size=36, bold=True, max_lines=5, + ) + mf = load_font(36, bold=True) + main_h = measure_text_block(main_lines, mf, line_gap=GAP_SM) + y = vertical_center_y(iy0 + title_h, bottom - 60, main_h) + + draw_centered_title(draw, cx0=ix0, cx1=ix1, y=iy0 + 8, text="핵심 내용", palette=palette, size=42) + draw_wrapped_block( + draw, main, x0=ix0, x1=ix1, y=y, max_y=bottom - 50, + start_size=54, min_size=36, bold=True, center=True, max_lines=6, + ) + + body = str(slide.get("body") or "").strip() + if body: + draw_wrapped_block( + draw, body, x0=ix0, x1=ix1, y=bottom - 90, max_y=bottom - 12, + start_size=34, min_size=28, fill=INK_SOFT, center=True, max_lines=2, + ) + + point = str(slide.get("point") or "").strip() + if point: + draw_point_footer(draw, cx0=ix0, cx1=ix1, cy1=iy1, text=point, palette=palette) + return canvas + + +def render_contest_body(ctx: ContestRenderContext): + slide = ctx.slide + palette = ctx.palette + canvas, draw, rect = new_canvas(palette) + col = centered_column(inner_content_rect(rect)) + ix0, iy0, ix1, iy1 = col + bottom = content_bottom_y(iy1, has_point=True) + + body = str(slide.get("body") or slide.get("headline") or "").strip() + if not body: + return canvas + + title_h = 56 + body_font, body_lines = fit_font_single_line( + draw, body, ix1 - ix0 - 48, start_size=38, min_size=30, max_lines=12, + ) + body_h = measure_text_block(body_lines, body_font, line_gap=GAP_SM) + 48 + box_y0 = vertical_center_y(iy0 + title_h, bottom, body_h) + + draw_centered_title(draw, cx0=ix0, cx1=ix1, y=iy0 + 6, text="공고 요약", palette=palette, size=42) + draw.rounded_rectangle( + (ix0, box_y0, ix1, box_y0 + body_h), + radius=20, + outline=palette.panel_border, + width=2, + fill=(255, 255, 255), + ) + draw_wrapped_block( + draw, body, x0=ix0 + 28, x1=ix1 - 28, y=box_y0 + 24, max_y=box_y0 + body_h - 20, + start_size=38, min_size=30, center=True, max_lines=12, + ) + + point = str(slide.get("point") or "").strip() + if point: + draw_point_footer(draw, cx0=ix0, cx1=ix1, cy1=iy1, text=point, palette=palette) + return canvas + + +def _draw_summary_cards( + draw: ImageDraw.ImageDraw, + *, + items: list[dict[str, str]], + ix0: int, + ix1: int, + y0: int, + y1: int, + palette: Any, +) -> None: + """라벨+내용이 한 카드(네모) 안에 들어가는 요약 행.""" + rows: list[tuple[str, str]] = [] + for item in items: + label = str(item.get("label") or "").strip() + text = str(item.get("text") or "").strip() + if text: + rows.append((label or "안내", text)) + + if not rows: + return + + cw = ix1 - ix0 + strip_h = LABEL_STRIP_H + pad = CARD_INNER_PAD + row_heights: list[int] = [] + label_fonts: list = [] + label_lines_list: list[list[str]] = [] + value_fonts: list = [] + value_lines_list: list[list[str]] = [] + for label, text in rows: + label_font, label_lines = fit_font_single_line( + draw, label, cw - pad * 2, start_size=28, min_size=22, bold=True, max_lines=1, + ) + value_font, tlines = fit_font_single_line( + draw, text, cw - pad * 2, start_size=34, min_size=28, max_lines=3, + ) + vlh = line_height(value_font, GAP_SM) + vth = max(measure_text_block(tlines, value_font, line_gap=GAP_SM), vlh * max(1, len(tlines))) + label_fonts.append(label_font) + label_lines_list.append(label_lines) + value_fonts.append(value_font) + value_lines_list.append(tlines) + row_heights.append(strip_h + pad + vth + pad) + + total_h = sum(row_heights) + ROW_GAP * (len(rows) - 1) + y = vertical_center_y(y0, y1, total_h) + + for (label, _text), rh, label_font, label_lines, value_font, tlines in zip( + rows, row_heights, label_fonts, label_lines_list, value_fonts, value_lines_list, + ): + card_y1 = y + rh + draw.rounded_rectangle( + (ix0, y, ix1, card_y1), + radius=16, + fill=(255, 255, 255), + outline=palette.panel_border, + width=2, + ) + draw.rounded_rectangle( + (ix0, y, ix1, y + strip_h), + radius=16, + fill=palette.accent, + ) + if strip_h < rh: + draw.rectangle((ix0, y + strip_h - 14, ix1, y + strip_h), fill=palette.accent) + + draw_centered_in_band( + draw, cx0=ix0 + pad, cx1=ix1 - pad, y0=y, y1=y + strip_h, + lines=label_lines, font=label_font, fill=(255, 255, 255), + ) + draw_centered_in_band( + draw, cx0=ix0 + pad, cx1=ix1 - pad, y0=y + strip_h + pad // 2, y1=card_y1 - pad // 2, + lines=tlines, font=value_font, fill=INK, + ) + y = card_y1 + ROW_GAP + + +def render_contest_table(ctx: ContestRenderContext): + slide = ctx.slide + palette = ctx.palette + canvas, draw, rect = new_canvas(palette) + col = centered_column(inner_content_rect(rect)) + ix0, iy0, ix1, iy1 = col + items = slide_items(slide, max_items=4) + bottom = content_bottom_y(iy1, has_point=True) + + title = str(slide.get("headline") or "").strip() or "한눈에 보기" + title_bottom = draw_centered_title( + draw, cx0=ix0, cx1=ix1, y=iy0 + TITLE_TOP_PAD, text=title, palette=palette, size=44, max_y=bottom, + ) + cards_y0 = title_bottom + ELEMENT_GAP + + _draw_summary_cards( + draw, items=items, ix0=ix0, ix1=ix1, y0=cards_y0, y1=bottom - ELEMENT_GAP, palette=palette, + ) + + point = str(slide.get("point") or "").strip() + if point: + draw_point_footer(draw, cx0=ix0, cx1=ix1, cy1=iy1, text=point, palette=palette) + return canvas + + +def _draw_check_icon(draw: ImageDraw.ImageDraw, x: int, y: int, palette: Any) -> None: + draw.rounded_rectangle((x, y, x + 34, y + 34), radius=6, outline=palette.accent, width=2) + draw.line((x + 8, y + 18, x + 15, y + 25), fill=palette.accent, width=3) + draw.line((x + 15, y + 25, x + 26, y + 11), fill=palette.accent, width=3) + + +def render_contest_checklist(ctx: ContestRenderContext): + slide = ctx.slide + palette = ctx.palette + canvas, draw, rect = new_canvas(palette) + col = centered_column(inner_content_rect(rect)) + ix0, iy0, ix1, iy1 = col + items = slide_items(slide, max_items=4) + bottom = content_bottom_y(iy1, has_point=True) + + if not items: + return canvas + + title = str(slide.get("headline") or "").strip() or "지원 전 확인" + title_bottom = draw_centered_title( + draw, cx0=ix0, cx1=ix1, y=iy0 + TITLE_TOP_PAD, text=title, palette=palette, size=44, + ) + y0 = title_bottom + ELEMENT_GAP + + row_heights: list[int] = [] + row_fonts: list = [] + row_lines: list[list[str]] = [] + text_x0 = ix0 + 62 + text_x1 = ix1 - CARD_INNER_PAD + for item in items: + text = str(item.get("text") or "").strip() + font, lines = fit_font_single_line( + draw, text, text_x1 - text_x0, start_size=32, min_size=26, max_lines=2, + ) + lh = line_height(font, GAP_SM) + th = max(measure_text_block(lines, font, line_gap=GAP_SM), lh * max(1, len(lines))) + row_heights.append(max(64, th + CARD_INNER_PAD * 2)) + row_fonts.append(font) + row_lines.append(lines) + + total_h = sum(row_heights) + ROW_GAP * (len(items) - 1) + y = vertical_center_y(y0, bottom - ELEMENT_GAP, total_h) + + for item, rh, font, lines in zip(items, row_heights, row_fonts, row_lines): + text = str(item.get("text") or "").strip() + if not text or not lines: + continue + row_y1 = y + rh + draw.rounded_rectangle( + (ix0, y, ix1, row_y1), + radius=14, + fill=palette.panel, + outline=palette.panel_border, + width=2, + ) + _draw_check_icon(draw, ix0 + 18, y + (rh - 34) // 2, palette) + draw_centered_in_band( + draw, cx0=text_x0, cx1=text_x1, y0=y + CARD_INNER_PAD // 2, y1=row_y1 - CARD_INNER_PAD // 2, + lines=lines, font=font, fill=INK, + ) + y = row_y1 + ROW_GAP + + point = str(slide.get("point") or "").strip() + if point: + draw_point_footer(draw, cx0=ix0, cx1=ix1, cy1=iy1, text=point, palette=palette) + return canvas + + +def render_contest_three_col(ctx: ContestRenderContext): + return render_contest_table(ctx) + + +def render_contest_cta(ctx: ContestRenderContext): + slide = ctx.slide + palette = ctx.palette + canvas, draw, rect = new_canvas(palette) + col = centered_column(inner_content_rect(rect)) + ix0, iy0, ix1, iy1 = col + cw = ix1 - ix0 + has_mascot = _has_mascot(ctx) + bottom = content_bottom_y(iy1, has_mascot=has_mascot, has_point=False) + + headline = str(slide.get("headline") or "").strip() + highlight = str(slide.get("highlight") or "").strip() + body = str(slide.get("body") or "").strip() + cta = str(slide.get("cta") or "").strip() + if not cta: + cta = "공고 보러가기" + source_url = (ctx.source_url or str(slide.get("source_url") or "")).strip() + + url_h = 54 if source_url else 0 + cta_probe_font, cta_lines = fit_font_single_line( + draw, cta, cw - 120, start_size=36, min_size=28, bold=True, max_lines=2, + ) + cta_h = 0 + if cta_lines: + cta_h = measure_text_block(cta_lines, cta_probe_font, line_gap=GAP_SM) + 52 + + content_bottom = bottom - url_h - cta_h - ELEMENT_GAP + + parts: list[tuple[str, int, int, bool, tuple[int, int, int]]] = [] + if headline: + parts.append((headline, 50, 34, True, INK)) + if highlight: + parts.append((highlight, 50, 34, True, palette.accent)) + if body: + parts.append((body, 34, 28, False, INK_SOFT)) + + stack_h = _estimate_centered_stack_height(draw, parts, cw) + y = vertical_center_y(iy0, content_bottom, stack_h) + + if headline: + y = draw_wrapped_block( + draw, headline, x0=ix0, x1=ix1, y=y, max_y=content_bottom, + start_size=50, min_size=34, bold=True, center=True, max_lines=2, + ) + GAP_MD + if highlight: + y = draw_wrapped_block( + draw, highlight, x0=ix0, x1=ix1, y=y, max_y=content_bottom, + start_size=50, min_size=34, bold=True, fill=palette.accent, center=True, max_lines=2, + ) + GAP_MD + if body: + y = draw_wrapped_block( + draw, body, x0=ix0, x1=ix1, y=y, max_y=content_bottom, + start_size=34, min_size=28, fill=INK_SOFT, center=True, max_lines=3, + ) + + if cta_lines and cta_h > 0: + by1 = bottom - url_h - ELEMENT_GAP + by0 = by1 - (cta_h - 12) + draw_accent_pill( + draw, cx0=ix0 + 40, cx1=ix1 - 40, y0=by0, y1=by1, + text=cta, palette=palette, start_size=36, min_size=28, + ) + + if source_url: + draw_source_url_footer( + draw, cx0=ix0, cx1=ix1, y=bottom - url_h - 4, url=source_url, + palette=palette, max_y=bottom - 6, + ) + + return _finish(canvas, ctx, rect, corner="right") diff --git a/app/contest_cardnews/template/palette.py b/app/contest_cardnews/template/palette.py new file mode 100644 index 0000000..0e094e1 --- /dev/null +++ b/app/contest_cardnews/template/palette.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +import random +from dataclasses import dataclass +from typing import Any + + +@dataclass(frozen=True) +class ContestPalette: + name: str + outer: tuple[int, int, int] + chrome: tuple[int, int, int] + chrome_dark: tuple[int, int, int] + accent: tuple[int, int, int] + panel: tuple[int, int, int] + panel_border: tuple[int, int, int] + + +CONTEST_PALETTES: dict[str, ContestPalette] = { + "pastel_mint": ContestPalette( + "pastel_mint", + outer=(198, 228, 208), + chrome=(156, 205, 168), + chrome_dark=(108, 158, 122), + accent=(72, 138, 98), + panel=(232, 246, 236), + panel_border=(156, 205, 168), + ), + "pastel_pink": ContestPalette( + "pastel_pink", + outer=(255, 218, 228), + chrome=(245, 175, 195), + chrome_dark=(210, 120, 148), + accent=(198, 88, 118), + panel=(255, 238, 242), + panel_border=(245, 175, 195), + ), + "pastel_lavender": ContestPalette( + "pastel_lavender", + outer=(222, 214, 248), + chrome=(188, 172, 230), + chrome_dark=(140, 118, 198), + accent=(108, 82, 178), + panel=(238, 232, 252), + panel_border=(188, 172, 230), + ), + "pastel_peach": ContestPalette( + "pastel_peach", + outer=(255, 228, 208), + chrome=(248, 188, 148), + chrome_dark=(210, 138, 88), + accent=(198, 108, 58), + panel=(255, 242, 230), + panel_border=(248, 188, 148), + ), + "pastel_sky": ContestPalette( + "pastel_sky", + outer=(208, 232, 252), + chrome=(148, 192, 232), + chrome_dark=(88, 142, 198), + accent=(58, 118, 188), + panel=(228, 242, 255), + panel_border=(148, 192, 232), + ), + "pastel_lemon": ContestPalette( + "pastel_lemon", + outer=(255, 248, 198), + chrome=(238, 218, 118), + chrome_dark=(198, 168, 58), + accent=(168, 138, 28), + panel=(255, 252, 228), + panel_border=(238, 218, 118), + ), +} + + +def palette_names() -> list[str]: + return list(CONTEST_PALETTES.keys()) + + +def resolve_palette(name: str) -> ContestPalette: + key = (name or "pastel_mint").strip() + return CONTEST_PALETTES.get(key, CONTEST_PALETTES["pastel_mint"]) + + +def apply_deck_palette( + slides: list[dict[str, Any]], + *, + rng: random.Random, + contentid: str = "", +) -> list[dict[str, Any]]: + deck_rng = random.Random(contentid) if contentid else rng + names = palette_names() + palette = "" + for slide in slides: + candidate = str(slide.get("template_palette") or "").strip() + if candidate in CONTEST_PALETTES: + palette = candidate + break + if not palette: + palette = deck_rng.choice(names) + out: list[dict[str, Any]] = [] + for slide in slides: + row = dict(slide) + row["template_palette"] = palette + out.append(row) + return out diff --git a/app/core/config.py b/app/core/config.py index 3ec72f9..6f3ce80 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -106,7 +106,7 @@ class Settings(BaseSettings): alias="GEMINI_PIN_TEXT_MODEL", ) rag_enable_rerank: bool = Field( - default=True, + default=False, alias="RAG_ENABLE_RERANK", ) rag_vector_query_mode: str = Field( @@ -114,15 +114,15 @@ class Settings(BaseSettings): alias="RAG_VECTOR_QUERY_MODE", ) rag_retrieve_top_k: int = Field( - default=8, + default=10, ge=1, - le=50, + le=100, alias="RAG_RETRIEVE_TOP_K", ) rag_rerank_top_k: int = Field( default=5, ge=1, - le=20, + le=100, alias="RAG_RERANK_TOP_K", ) gemini_pin_text_fallback_models: str = Field( @@ -263,10 +263,6 @@ class Settings(BaseSettings): 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", @@ -320,10 +316,65 @@ class Settings(BaseSettings): alias="FESTIVAL_TRANSFORM_CONCURRENCY", description="축제 pin_content Gemini 가공 동시 호출 수 (Cron/API 공통)", ) + festival_batch_size: int = Field( + default=10, + ge=1, + le=50, + alias="FESTIVAL_BATCH_SIZE", + description="admin fetch/transform/import 기본 배치 크기", + ) policy_cardnews_mascot_dir: str | None = Field( - default="app/assets/mascots", + default="../assets/mascots", alias="POLICY_CARDNEWS_MASCOT_DIR", - description="핀 캐릭터 PNG 폴더. mascots.json files 목록에 있는 PNG만 사용", + description="핀 캐릭터 PNG 폴더 (app/policy_cardnews 기준 상대 경로). mascots.json files 목록에 있는 PNG만 사용", + ) + contest_sync_schedule_hour_kst: int = Field( + default=12, + ge=0, + le=23, + alias="CONTEST_SYNC_SCHEDULE_HOUR_KST", + description="공모전 핀 sync 스케줄 시각 (KST)", + ) + contest_crawl_max_pages: int = Field( + default=1, + ge=1, + le=50, + alias="CONTEST_CRAWL_MAX_PAGES", + description="sync/crawl 기본 목록 페이지 수", + ) + contest_sync_batch_size: int = Field( + default=5, + ge=1, + le=25, + alias="CONTEST_SYNC_BATCH_SIZE", + description="sync/transform/import 1회 배치 건수", + ) + contest_transform_concurrency: int = Field( + default=3, + ge=1, + le=10, + alias="CONTEST_TRANSFORM_CONCURRENCY", + description="공모전 pin_content·카드뉴스 Gemini 가공 동시 호출 수", + ) + contest_admin_user_name: str = Field( + default="admin", + validation_alias=AliasChoices("CONTEST_ADMIN_USER_NAME", "CONTEST_ADMIN_NICKNAME"), + description="공모전 핀 등록에 사용할 user.user_name", + ) + contest_prune_pipeline_after_import: bool = Field( + default=True, + alias="CONTEST_PRUNE_PIPELINE_AFTER_IMPORT", + description="DB INSERT 성공 후 JSONL·로컬 카드뉴스 캐시 제거", + ) + contest_cardnews_keep_local_files: bool = Field( + default=False, + alias="CONTEST_CARDNEWS_KEEP_LOCAL_FILES", + description="True면 S3 업로드 후에도 rag/output/contest_cardnews 유지", + ) + contest_cardnews_s3_prefix: str = Field( + default="contest-cardnews", + alias="CONTEST_CARDNEWS_S3_PREFIX", + description="공모전 카드뉴스 S3 object key prefix", ) @field_validator("policy_cardnews_font_dir", mode="before") @@ -337,15 +388,8 @@ def _empty_string_policy_cardnews_font_dir(cls, value: object) -> object: @classmethod def _empty_string_policy_cardnews_mascot_dir(cls, value: object) -> object: if value == "": - return None + return "../assets/mascots" return value - festival_batch_size: int = Field( - default=10, - ge=1, - le=50, - alias="FESTIVAL_BATCH_SIZE", - description="admin fetch/transform/import 기본 배치 크기", - ) @field_validator("gemini_embedding_batch_size", mode="before") @classmethod diff --git a/app/core/deps.py b/app/core/deps.py index 3bbefe5..4bf6826 100644 --- a/app/core/deps.py +++ b/app/core/deps.py @@ -29,21 +29,26 @@ from app.services.internal.IssuePinBackgroundRunner import IssuePinBackgroundRunner from app.services.UserService import UserService from app.services.ComplaintEmailService import ComplaintEmailService +from app.services.ContestPinService import ContestPinService +from app.services.ContestEventIngestService import ContestEventIngestService from app.services.FestivalPinService import FestivalPinService from app.services.PolicyEventIngestService import PolicyEventIngestService from app.services.PolicyPinService import PolicyPinService +from app.services.internal.ContestPinSchedulerService import ContestPinSchedulerService 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 -from app.services.ComplaintEmailVlmService import ComplaintEmailVlmService from app.services.VectorStoreService import VectorStoreService -from app.services.internal.ai.ComplaintEmailLLMService import ComplaintEmailLLMService from app.services.internal.ai.IssuePinLLMService import IssuePinLLMService -from app.services.internal.ai.gemini_retry import parse_gemini_model_list from app.services.internal.ai.IssueRagPlannerService import IssueRagPlannerService from app.services.internal.ai.VLMService import VLMService +from app.services.internal.ai.gemini_factory import ( + build_issue_pin_llm_service, + build_issue_rag_planner_service, + build_vlm_service, + require_gemini_api_key, +) +from app.services.internal.complaint_wiring import build_complaint_email_service from app.services.internal.geo.ImageExifLocationResolveService import ImageExifLocationResolveService from app.services.internal.geo.ImageMultipartGeoService import ImageMultipartGeoService from app.services.internal.geo.LocationResolveClient import LocationResolveClient @@ -107,43 +112,24 @@ def get_image_exif_location_resolve_service( def get_vlm_service() -> VLMService: - api_key_secret = settings.gemini_api_key - if api_key_secret is None: - raise_business_exception(ErrorCode.VLM_NOT_CONFIGURED) - return VLMService( - api_key=api_key_secret.get_secret_value(), - model_name=settings.gemini_vlm_model, - fallback_models=parse_gemini_model_list(settings.gemini_vlm_fallback_models), - ) + return build_vlm_service() VLMServiceDep = Annotated[VLMService, Depends(get_vlm_service)] def get_issue_pin_llm_service() -> IssuePinLLMService: - api_key_secret = settings.gemini_api_key - if api_key_secret is None: + try: + return build_issue_pin_llm_service() + except RuntimeError: raise_business_exception(ErrorCode.VLM_NOT_CONFIGURED) - pin_fallbacks = parse_gemini_model_list(settings.gemini_pin_text_fallback_models) - return IssuePinLLMService( - api_key=api_key_secret.get_secret_value(), - model_name=settings.gemini_pin_text_model, - fallback_models=pin_fallbacks, - ) IssuePinLLMServiceDep = Annotated[IssuePinLLMService, Depends(get_issue_pin_llm_service)] def get_issue_rag_planner_service() -> IssueRagPlannerService: - api_key_secret = settings.gemini_api_key - if api_key_secret is None: - raise_business_exception(ErrorCode.VLM_NOT_CONFIGURED) - return IssueRagPlannerService( - api_key=api_key_secret.get_secret_value(), - model_name=settings.gemini_rag_planner_model, - fallback_models=parse_gemini_model_list(settings.gemini_rag_planner_fallback_models), - ) + return build_issue_rag_planner_service() IssueRagPlannerServiceDep = Annotated[ @@ -232,81 +218,12 @@ def get_complaint_petition_repo(session: DbSessionDep) -> ComplaintPetitionRepo: ComplaintPetitionRepoDep = Annotated[ComplaintPetitionRepo, Depends(get_complaint_petition_repo)] -def get_complaint_email_vlm_service() -> ComplaintEmailVlmService: - api_key_secret = settings.gemini_api_key - if api_key_secret is None: - raise_business_exception(ErrorCode.VLM_NOT_CONFIGURED) - return ComplaintEmailVlmService( - api_key=api_key_secret.get_secret_value(), - model=settings.gemini_vlm_model, - ) - - -ComplaintEmailVlmServiceDep = Annotated[ - ComplaintEmailVlmService, - Depends(get_complaint_email_vlm_service), -] - - -def get_complaint_email_llm_service() -> ComplaintEmailLLMService: - api_key_secret = settings.gemini_api_key - if api_key_secret is None: - raise_business_exception(ErrorCode.VLM_NOT_CONFIGURED) - return ComplaintEmailLLMService( - api_key=api_key_secret.get_secret_value(), - model_name=settings.gemini_pin_text_model, - ) - - -ComplaintEmailLLMServiceDep = Annotated[ - ComplaintEmailLLMService, - Depends(get_complaint_email_llm_service), -] - - -def get_rag_rerank_service() -> RagRerankService: - api_key_secret = settings.gemini_api_key - if api_key_secret is None: - raise_business_exception(ErrorCode.VLM_NOT_CONFIGURED) - return RagRerankService( - api_key=api_key_secret.get_secret_value(), - embedding_model=settings.gemini_embedding_model, - embed_dim=settings.vector_embed_dim, - embedding_batch_size=settings.gemini_embedding_batch_size, - ) - - -RagRerankServiceDep = Annotated[RagRerankService, Depends(get_rag_rerank_service)] - - -def get_rag_retrieval_service( - vector_store_service: VectorStoreServiceDep, - rag_rerank_service: RagRerankServiceDep, -) -> RagRetrievalService: - return RagRetrievalService( - vector_store_service=vector_store_service, - rerank_service=rag_rerank_service, - retrieve_top_k=settings.rag_retrieve_top_k, - rerank_top_k=settings.rag_rerank_top_k, - enable_rerank=settings.rag_enable_rerank, - vector_query_mode=settings.rag_vector_query_mode, - ) - - -RagRetrievalServiceDep = Annotated[RagRetrievalService, Depends(get_rag_retrieval_service)] - - def get_complaint_email_service( - complaint_vlm_service: ComplaintEmailVlmServiceDep, - pin_validation_vlm_service: VLMServiceDep, - complaint_llm_service: ComplaintEmailLLMServiceDep, - rag_retrieval_service: RagRetrievalServiceDep, + vector_store_service: VectorStoreServiceDep, ) -> ComplaintEmailService: - return ComplaintEmailService( - complaint_vlm_service=complaint_vlm_service, - pin_validation_vlm_service=pin_validation_vlm_service, - complaint_llm_service=complaint_llm_service, - rag_retrieval_service=rag_retrieval_service, + return build_complaint_email_service( + api_key=require_gemini_api_key(), + vector_store_service=vector_store_service, ) @@ -384,10 +301,6 @@ def get_issue_pin_daily_rate_limit_service(request: Request) -> IssuePinDailyRat ] -# backward-compatible alias -AiPinGenerationRateLimitServiceDep = IssuePinDailyRateLimitServiceDep - - def get_issue_service( vector_store_service: VectorStoreServiceDep, issue_rag_planner_service: IssueRagPlannerServiceDep, @@ -514,6 +427,16 @@ def get_festival_event_ingest_service( ] +def get_contest_pin_service() -> ContestPinService: + return ContestPinService() + + +ContestPinServiceDep = Annotated[ + ContestPinService, + Depends(get_contest_pin_service), +] + + def get_policy_pin_service() -> PolicyPinService: return PolicyPinService() @@ -566,3 +489,42 @@ def get_policy_pin_scheduler(request: Request) -> PolicyPinSchedulerService | No PolicyPinSchedulerService | None, Depends(get_policy_pin_scheduler), ] + + +def get_contest_event_ingest_service( + pin_repo: PinRepoDep, + event_pin_repo: EventPinRepoDep, + community_repo: CommunityRepoDep, + cardnews_image_s3_repo: CardnewsImageS3RepoDep, + pin_image_repo: PinImageRepoDep, + user_repo: UserRepoDep, +) -> ContestEventIngestService: + return ContestEventIngestService( + pin_repo=pin_repo, + event_pin_repo=event_pin_repo, + community_repo=community_repo, + cardnews_image_s3_repo=cardnews_image_s3_repo, + pin_image_repo=pin_image_repo, + user_repo=user_repo, + ) + + +ContestEventIngestServiceDep = Annotated[ + ContestEventIngestService, + Depends(get_contest_event_ingest_service), +] + + +def get_contest_pin_scheduler(request: Request) -> ContestPinSchedulerService | None: + scheduler = getattr(request.app.state, "contest_pin_scheduler", None) + if scheduler is None: + return None + if not isinstance(scheduler, ContestPinSchedulerService): + raise RuntimeError("contest_pin_scheduler is not initialized correctly.") + return scheduler + + +ContestPinSchedulerDep = Annotated[ + ContestPinSchedulerService | None, + Depends(get_contest_pin_scheduler), +] diff --git a/app/core/handlers.py b/app/core/handlers.py index 2939416..dd42dbe 100644 --- a/app/core/handlers.py +++ b/app/core/handlers.py @@ -1,4 +1,5 @@ import json +import logging from typing import Any from fastapi import FastAPI, Request, status @@ -7,9 +8,12 @@ from starlette.exceptions import HTTPException as StarletteHTTPException from app.core.codes import ErrorCode +from app.core.config import settings from app.core.exceptions import CustomException, create_http_exception from app.core.responses import failure_response +logger = logging.getLogger(__name__) + def _error_response(http_status: int, code: ErrorCode, result: Any = None, message: str | None = None) -> JSONResponse: response = failure_response(error_code=code, result=result, status_code=http_status) @@ -58,8 +62,17 @@ async def validation_exception_handler( async def unhandled_exception_handler(request: Request, exc: Exception) -> JSONResponse: + logger.exception("Unhandled exception on %s %s", request.method, request.url.path) ec = ErrorCode.INTERNAL_SERVER_ERROR - return _error_response(http_status=status.HTTP_500_INTERNAL_SERVER_ERROR, code=ec) + message = ec.message + if settings.env in ("local", "dev"): + detail = str(exc).strip() or repr(exc) + message = f"{ec.message}: {detail}" + return _error_response( + http_status=status.HTTP_500_INTERNAL_SERVER_ERROR, + code=ec, + message=message, + ) def register_exception_handlers(app: FastAPI) -> None: diff --git a/app/main.py b/app/main.py index c274e21..5060cd0 100644 --- a/app/main.py +++ b/app/main.py @@ -17,21 +17,12 @@ from app.core.config import settings from app.routes import enabled_routers from app.services.internal.ComplaintEmailPdfService import ComplaintEmailPdfService -from app.services.ComplaintEmailService import ComplaintEmailService -from app.services.ComplaintEmailVlmService import ComplaintEmailVlmService -from app.services.RagRerankService import RagRerankService -from app.services.RagRetrievalService import RagRetrievalService -from app.services.VectorStoreService import VectorStoreService from app.services.internal.ComplaintPetitionSchedulerService import ComplaintPetitionSchedulerService +from app.services.internal.ContestPinSchedulerService import ContestPinSchedulerService 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 ( - DomainVectorConfig, - VectorDomain, - build_vector_domain_configs, -) +from app.services.internal.complaint_wiring import build_complaint_email_service +from app.services.VectorStoreService import VectorStoreService +from app.services.vector_domains import build_vector_domain_configs from app.schemas.IssueDTO import ( CreateIssuePinMultipartRequest, PinImageIsMainItem, @@ -161,38 +152,9 @@ async def lifespan(app: FastAPI): and getattr(app.state, "vector_store_service", None) is not None ): try: - complaint_vlm_service = ComplaintEmailVlmService( - api_key=gemini_api_key_secret.get_secret_value(), - model=settings.gemini_vlm_model, - ) - validation_vlm_service = VLMService( - api_key=gemini_api_key_secret.get_secret_value(), - model_name=settings.gemini_vlm_model, - fallback_models=parse_gemini_model_list(settings.gemini_vlm_fallback_models), - ) - complaint_llm_service = ComplaintEmailLLMService( - api_key=gemini_api_key_secret.get_secret_value(), - model_name=settings.gemini_pin_text_model, - ) - rag_rerank_service = RagRerankService( + complaint_email_service = build_complaint_email_service( api_key=gemini_api_key_secret.get_secret_value(), - embedding_model=settings.gemini_embedding_model, - embed_dim=settings.vector_embed_dim, - embedding_batch_size=settings.gemini_embedding_batch_size, - ) - rag_retrieval_service = RagRetrievalService( vector_store_service=app.state.vector_store_service, - rerank_service=rag_rerank_service, - retrieve_top_k=settings.rag_retrieve_top_k, - rerank_top_k=settings.rag_rerank_top_k, - enable_rerank=settings.rag_enable_rerank, - vector_query_mode=settings.rag_vector_query_mode, - ) - complaint_email_service = ComplaintEmailService( - complaint_vlm_service=complaint_vlm_service, - pin_validation_vlm_service=validation_vlm_service, - complaint_llm_service=complaint_llm_service, - rag_retrieval_service=rag_retrieval_service, ) complaint_scheduler = ComplaintPetitionSchedulerService( complaint_email_service=complaint_email_service, @@ -212,6 +174,15 @@ async def lifespan(app: FastAPI): except Exception as exc: logger.warning("Policy pin scheduler initialization failed: %s", exc) + contest_pin_scheduler = None + if gemini_api_key_secret is not None: + try: + contest_pin_scheduler = ContestPinSchedulerService(s3_util=app.state.s3_util) + contest_pin_scheduler.start() + app.state.contest_pin_scheduler = contest_pin_scheduler + except Exception as exc: + logger.warning("Contest pin scheduler initialization failed: %s", exc) + try: yield finally: @@ -227,6 +198,12 @@ async def lifespan(app: FastAPI): await policy_pin_scheduler.stop() except Exception as exc: logger.warning("Policy pin scheduler stop failed: %s", exc) + contest_pin_scheduler = getattr(app.state, "contest_pin_scheduler", None) + if contest_pin_scheduler is not None: + try: + await contest_pin_scheduler.stop() + except Exception as exc: + logger.warning("Contest pin scheduler stop failed: %s", exc) try: await ComplaintEmailPdfService.stop_playwright_browser() except Exception as exc: diff --git a/app/models/ComplaintPetition.py b/app/models/ComplaintPetition.py index daa1285..6b83794 100644 --- a/app/models/ComplaintPetition.py +++ b/app/models/ComplaintPetition.py @@ -47,7 +47,7 @@ class ComplaintPetition(BaseEntity): reliability_basis: Mapped[str] = mapped_column("reliability_basis", Text, nullable=False) status: Mapped[str] = mapped_column( "status", - String(32), + String(255), nullable=False, default=ComplaintPetitionStatus.CREATED.value, ) @@ -64,4 +64,3 @@ class ComplaintPetition(BaseEntity): foreign_keys=[issue_pin_id], lazy="selectin", ) - diff --git a/app/models/EventPin.py b/app/models/EventPin.py index 519d918..1fcbada 100644 --- a/app/models/EventPin.py +++ b/app/models/EventPin.py @@ -43,6 +43,12 @@ class EventPin(BaseEntity): unique=True, nullable=True, ) + contest_api_id: Mapped[int | None] = mapped_column( + "contest_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/policy_cardnews/mascot.py b/app/policy_cardnews/mascot.py index 6389bd0..884249d 100644 --- a/app/policy_cardnews/mascot.py +++ b/app/policy_cardnews/mascot.py @@ -7,15 +7,14 @@ from PIL import Image, ImageDraw, ImageFont +from app.policy_cardnews.paths import cardnews_mascot_dir + 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) @@ -27,12 +26,7 @@ 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 + return cardnews_mascot_dir() def _manifest_mtime(directory: Path) -> float: diff --git a/app/policy_cardnews/paths.py b/app/policy_cardnews/paths.py new file mode 100644 index 0000000..226ce99 --- /dev/null +++ b/app/policy_cardnews/paths.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +from pathlib import Path + + +def resolve_package_relative_path(package_dir: Path, raw: str) -> Path: + """패키지 디렉터리 기준 상대 경로를 해석한다. 절대 경로면 그대로 반환.""" + path = Path(raw) + if path.is_absolute(): + return path + return (package_dir / path).resolve() + + +_POLICY_CARDNEWS_DIR = Path(__file__).resolve().parent +_CONTEST_CARDNEWS_DIR = _POLICY_CARDNEWS_DIR.parent / "contest_cardnews" + + +def cardnews_font_dir(*, package_dir: Path | None = None) -> Path: + from app.core.config import settings + + base = package_dir or _POLICY_CARDNEWS_DIR + return resolve_package_relative_path(base, settings.policy_cardnews_font_dir) + + +def contest_cardnews_font_dir() -> Path: + return cardnews_font_dir(package_dir=_CONTEST_CARDNEWS_DIR) + + +def cardnews_mascot_dir() -> Path: + from app.core.config import settings + + configured = (settings.policy_cardnews_mascot_dir or "").strip() + if not configured: + return resolve_package_relative_path(_POLICY_CARDNEWS_DIR, "../assets/mascots") + return resolve_package_relative_path(_POLICY_CARDNEWS_DIR, configured) diff --git a/app/policy_cardnews/template/dispatch.py b/app/policy_cardnews/template/dispatch.py index b9d5e48..af52ede 100644 --- a/app/policy_cardnews/template/dispatch.py +++ b/app/policy_cardnews/template/dispatch.py @@ -56,17 +56,11 @@ 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] +from app.policy_cardnews.paths import cardnews_font_dir 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() + return cardnews_font_dir() @dataclass(frozen=True) diff --git a/app/repositories/EventPinRepo.py b/app/repositories/EventPinRepo.py index 4aa165d..58c4f51 100644 --- a/app/repositories/EventPinRepo.py +++ b/app/repositories/EventPinRepo.py @@ -61,3 +61,25 @@ async def count_policy_pins(self) -> int: ), ) return result.scalar_one() + + async def get_by_contest_api_id(self, contest_api_id: int) -> EventPin | None: + result = await self.session.execute( + select(EventPin) + .where(EventPin.contest_api_id == contest_api_id) + .options(*_EVENT_PIN_LOAD_OPTIONS), + ) + return result.scalar_one_or_none() + + async def list_contest_api_ids(self) -> set[int]: + result = await self.session.execute( + select(EventPin.contest_api_id).where(EventPin.contest_api_id.is_not(None)), + ) + return {int(row) for row in result.scalars().all() if row is not None} + + async def count_contest_pins(self) -> int: + result = await self.session.execute( + select(func.count(EventPin.event_pin_id)).where( + EventPin.contest_api_id.is_not(None), + ), + ) + return result.scalar_one() diff --git a/app/routes/ContestAdminRoute.py b/app/routes/ContestAdminRoute.py new file mode 100644 index 0000000..6c7f11b --- /dev/null +++ b/app/routes/ContestAdminRoute.py @@ -0,0 +1,227 @@ +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, + ContestEventIngestServiceDep, + ContestPinSchedulerDep, + ContestPinServiceDep, + S3UtilDep, +) +from app.core.responses import SuccessEnvelope, success_response +from app.schemas.ContestAdminDTO import ( + ContestImportBatchResult, + ContestPipelineStatusResult, + ContestSyncResult, + ContestTransformBatchResult, +) +from app.schemas.ContestPinDTO import ContestCrawlResult + +router = APIRouter(prefix="/contest-admin", tags=["contest-admin"]) + +_MAX_BATCH = 25 + + +def _runtime_error_response(exc: RuntimeError) -> HTTPException: + detail = str(exc) + if "GEMINI" 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.contest_sync_batch_size + return min(max(value, 1), _MAX_BATCH) + + +@router.post( + "/sync", + response_model=SuccessEnvelope[ContestSyncResult], + summary="공모전 핀 크롤 → 배치 transform → 배치 DB 적재", + description=( + "Linkareer 목록을 크롤합니다. " + "수동 실행 시 start_page·max_pages로 순회 구간을 지정할 수 있습니다 " + "(미지정 시 CONTEST_CRAWL_MAX_PAGES=1, start_page=1). " + "신규 contentid만 CONTEST_SYNC_BATCH_SIZE 단위로 LLM 가공·DB INSERT합니다." + ), +) +async def sync_contest_pins( + service: ContestPinServiceDep, + ingest_service: ContestEventIngestServiceDep, + s3_util: S3UtilDep, + _admin_uid: AdminUserIdDep, + start_page: int | None = Query( + default=None, + ge=1, + le=50, + description="목록 시작 페이지 (미지정 시 1)", + ), + max_pages: int | None = Query( + default=None, + ge=1, + le=50, + description="시작 페이지부터 순회할 페이지 수 (미지정 시 CONTEST_CRAWL_MAX_PAGES)", + ), + 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[ContestSyncResult]: + try: + body = await service.sync_pipeline( + ingest_service=ingest_service, + s3_util=s3_util, + transform_limit=transform_limit, + batch_size=_clamp_batch(batch_size), + max_pages=max_pages, + start_page=start_page, + ) + 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( + "/crawl", + response_model=SuccessEnvelope[ContestCrawlResult], + summary="Linkareer 목록만 크롤 (수동)", + description="sync 없이 크롤만 실행합니다. start_page·max_pages로 순회 구간을 지정하세요.", +) +async def crawl_contest_pins( + service: ContestPinServiceDep, + _admin_uid: AdminUserIdDep, + start_page: int = Query(default=1, ge=1, le=50, description="목록 시작 페이지 번호"), + max_pages: int = Query(default=1, ge=1, le=50, description="시작 페이지부터 순회할 페이지 수"), + limit: int | None = Query(default=None, ge=1, le=100, description="상세 수집 최대 건수"), + delay: float = Query(default=1.0, ge=0.0, le=10.0, description="요청 간 대기(초)"), + force: bool = Query(default=False, description="기존 contentid도 재수집"), +) -> SuccessEnvelope[ContestCrawlResult]: + try: + body = await service.crawl_and_save( + max_pages=max_pages, + start_page=start_page, + limit=limit, + delay=delay, + force=force, + ) + except RuntimeError as exc: + raise _runtime_error_response(exc) from exc + + code = SuccessCode.CREATED if body.new_count else SuccessCode.OK + return success_response(result=body, success_code=code) + + +@router.post( + "/transform-batch", + response_model=SuccessEnvelope[ContestTransformBatchResult], + summary="원문 JSONL 배치 가공 (신규 건만)", +) +async def transform_contest_batch( + service: ContestPinServiceDep, + ingest_service: ContestEventIngestServiceDep, + s3_util: S3UtilDep, + _admin_uid: AdminUserIdDep, + batch_size: int | None = Query(default=None, ge=1, le=25, description="이번 배치 가공 건수"), +) -> SuccessEnvelope[ContestTransformBatchResult]: + try: + db_ids = await ingest_service.get_imported_contest_api_ids() + body = await service.transform_batch( + batch_size=_clamp_batch(batch_size), + s3_util=s3_util, + db_contest_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[ContestImportBatchResult], + summary="handoff JSONL 배치 DB 적재", +) +async def import_contest_batch( + ingest_service: ContestEventIngestServiceDep, + _admin_uid: AdminUserIdDep, + batch_size: int | None = Query(default=None, ge=1, le=25, description="이번 배치 INSERT 건수"), +) -> SuccessEnvelope[ContestImportBatchResult]: + 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[ContestPipelineStatusResult], + summary="공모전 핀 파이프라인 상태", +) +async def contest_pipeline_status( + ingest_service: ContestEventIngestServiceDep, + _admin_uid: AdminUserIdDep, +) -> SuccessEnvelope[ContestPipelineStatusResult]: + body = await ingest_service.get_pipeline_status() + return success_response(result=body, success_code=SuccessCode.OK) + + +@router.post( + "/scheduler/run-once", + response_model=SuccessEnvelope[ContestSyncResult], + summary="공모전 핀 스케줄러 즉시 1회 실행 (테스트용)", +) +async def run_contest_scheduler_once( + service: ContestPinServiceDep, + ingest_service: ContestEventIngestServiceDep, + s3_util: S3UtilDep, + scheduler: ContestPinSchedulerDep, + _admin_uid: AdminUserIdDep, + start_page: int | None = Query( + default=None, + ge=1, + le=50, + description="목록 시작 페이지 (미지정 시 1)", + ), + max_pages: int | None = Query( + default=None, + ge=1, + le=50, + description="시작 페이지부터 순회할 페이지 수 (미지정 시 CONTEST_CRAWL_MAX_PAGES)", + ), +) -> SuccessEnvelope[ContestSyncResult]: + try: + if scheduler is not None: + body = await scheduler.run_once_now( + force=True, + max_pages=max_pages, + start_page=start_page, + ) + else: + body = await service.sync_pipeline( + ingest_service=ingest_service, + s3_util=s3_util, + max_pages=max_pages, + start_page=start_page, + ) + 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/ContestPinRoute.py b/app/routes/ContestPinRoute.py new file mode 100644 index 0000000..793fdb9 --- /dev/null +++ b/app/routes/ContestPinRoute.py @@ -0,0 +1,234 @@ +from __future__ import annotations + +from fastapi import APIRouter, HTTPException, Query +from pydantic import ValidationError + +from app.core.codes import ErrorCode, SuccessCode +from app.core.deps import ContestEventIngestServiceDep, ContestPinServiceDep, S3UtilDep +from app.core.responses import SuccessEnvelope, success_response +from app.schemas.ContestPinDTO import ( + ContestCrawlResult, + ContestDocumentsListResult, + ContestPinHandoffResult, + ContestPinTransformResult, +) +from app.utils.festival_date_filter import validate_yyyymmdd + +router = APIRouter(prefix="/contest-pins", tags=["contest-pins"]) + + +@router.post( + "/crawl", + response_model=SuccessEnvelope[ContestCrawlResult], + summary="1단계: Linkareer 크롤링 → contest_documents.jsonl 저장", + description=( + "Playwright로 Linkareer 공모전 목록·상세를 수집해 " + "rag/output/contest_documents.jsonl에 저장합니다. " + "서버에 Chromium(playwright install chromium)이 필요하며 요청이 수 분 걸릴 수 있습니다." + ), +) +async def crawl_contests_from_linkareer( + service: ContestPinServiceDep, + start_page: int = Query(default=1, ge=1, le=50, description="목록 시작 페이지 번호"), + max_pages: int = Query(default=1, ge=1, le=50, description="시작 페이지부터 순회할 페이지 수"), + limit: int | None = Query( + default=None, + ge=1, + le=100, + description="상세 수집 최대 건수 (미지정 시 목록에서 찾은 신규 전체)", + ), + delay: float = Query(default=1.0, ge=0.0, le=10.0, description="요청 간 대기(초)"), + force: bool = Query(default=False, description="기존 contentid도 재수집"), +) -> SuccessEnvelope[ContestCrawlResult]: + try: + body = await service.crawl_and_save( + max_pages=max_pages, + start_page=start_page, + limit=limit, + delay=delay, + force=force, + ) + except RuntimeError as exc: + msg = str(exc) + status = ( + 503 + if "playwright" in msg.lower() + else ErrorCode.INTERNAL_SERVER_ERROR.http_status + ) + raise HTTPException(status_code=status, detail=msg) from exc + except Exception as exc: + raise HTTPException( + status_code=ErrorCode.INTERNAL_SERVER_ERROR.http_status, + detail=str(exc) or repr(exc), + ) from exc + + code = SuccessCode.CREATED if body.new_count else SuccessCode.OK + return success_response(result=body, success_code=code) + + +@router.get( + "/documents", + response_model=SuccessEnvelope[ContestDocumentsListResult], + summary="크롤 원문 JSONL 조회 (Swagger 확인용)", + description="contest_documents.jsonl 내용을 JSON으로 반환합니다.", +) +async def list_contest_documents( + service: ContestPinServiceDep, + contentid: str | None = Query( + default=None, + description="특정 activity ID만 조회", + ), + start_date: str | None = Query( + default=None, + min_length=8, + max_length=8, + description="기간 필터 시작 YYYYMMDD (end_date와 함께)", + ), + end_date: str | None = Query( + default=None, + min_length=8, + max_length=8, + description="기간 필터 종료 YYYYMMDD", + ), + limit: int | None = Query( + default=None, + ge=1, + le=500, + description="반환 최대 건수", + ), +) -> SuccessEnvelope[ContestDocumentsListResult]: + if (start_date is None) ^ (end_date is None): + raise HTTPException( + status_code=ErrorCode.BAD_REQUEST.http_status, + detail="start_date와 end_date는 함께 지정하거나 둘 다 생략해야 합니다.", + ) + + parsed_start: str | None = None + parsed_end: str | None = None + if start_date is not None and end_date is not None: + 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 = service.load_documents_from_jsonl( + start_date=parsed_start, + end_date=parsed_end, + limit=limit, + contentid=contentid, + ) + except FileNotFoundError as exc: + raise HTTPException( + status_code=ErrorCode.NOT_FOUND.http_status, + detail=str(exc), + ) from exc + except ValueError as exc: + raise HTTPException( + status_code=ErrorCode.BAD_REQUEST.http_status, + detail=str(exc), + ) from exc + except ValidationError as exc: + raise HTTPException( + status_code=ErrorCode.BAD_REQUEST.http_status, + detail=str(exc), + ) from exc + + return success_response(result=body, success_code=SuccessCode.OK) + + +def _runtime_error_response(exc: RuntimeError) -> HTTPException: + return HTTPException( + status_code=ErrorCode.INTERNAL_SERVER_ERROR.http_status, + detail=str(exc), + ) + + +@router.post( + "/cardnews", + response_model=SuccessEnvelope[ContestPinTransformResult], + summary="2단계: 원문 → 텍스트 카드뉴스 PNG + DB용 JSONL", + description=( + "contest_documents.jsonl의 pin_content_raw로 Gemini 슬라이드 문구를 만들고, " + "브라우저형 파스텔 템플릿·저장된 캐릭터 PNG로 slide_XX.png를 렌더합니다. " + "크롤한 공고 이미지는 사용하지 않습니다." + ), +) +async def generate_contest_cardnews( + service: ContestPinServiceDep, + ingest_service: ContestEventIngestServiceDep, + s3_util: S3UtilDep, + limit: int | None = Query( + default=None, + ge=1, + le=20, + description="가공 최대 건수 (미지정 시 원문 파일 전체, 최대 20 권장)", + ), + contentid: str | None = Query( + default=None, + description="특정 activity ID만 카드뉴스 생성", + ), + with_caption: bool = Query( + default=True, + description="인스타용 캡션을 pin_content에 사용", + ), + skip_db_duplicates: bool = Query( + default=True, + description="DB에 이미 있는 contest_api_id는 LLM 가공 스킵", + ), +) -> SuccessEnvelope[ContestPinTransformResult]: + try: + db_ids: set[int] | None = None + if skip_db_duplicates: + db_ids = await ingest_service.get_imported_contest_api_ids() + body = await service.cardnews_and_save( + limit=limit, + with_caption=with_caption, + contentid=contentid, + s3_util=s3_util, + db_contest_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 + + code = SuccessCode.CREATED if body.processed_count else SuccessCode.OK + return success_response(result=body, success_code=code) + + +@router.get( + "/handoff", + response_model=SuccessEnvelope[ContestPinHandoffResult], + summary="DB용 JSONL 조회 (카드뉴스 핸드오프)", + description="contest_pins_for_db.jsonl을 조회합니다.", +) +async def list_contest_handoff( + service: ContestPinServiceDep, + limit: int | None = Query( + default=None, + ge=1, + le=500, + description="반환 최대 건수", + ), +) -> SuccessEnvelope[ContestPinHandoffResult]: + try: + body = service.load_handoff_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 12d94d0..4c737cd 100644 --- a/app/routes/__init__.py +++ b/app/routes/__init__.py @@ -5,10 +5,12 @@ from app.routes.UserRoute import router as user_router from app.routes.TestRoute import router as test_router from app.routes.VectorTestRoute import router as vector_test_router +from app.routes.ContestPinRoute import router as contest_pin_router +from app.routes.ContestAdminRoute import router as contest_admin_router from app.routes.FestivalPinRoute import router as festival_pin_router +from app.routes.FestivalAdminRoute import router as festival_admin_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 ROUTER_REGISTRY = ( @@ -20,9 +22,11 @@ {"router": test_router, "disabled_envs": {"dev", "prod"}}, {"router": vector_test_router, "disabled_envs": {"dev", "prod"}}, {"router": festival_pin_router, "disabled_envs": {"prod"}}, + {"router": festival_admin_router, "disabled_envs": set()}, + {"router": contest_pin_router, "disabled_envs": {"prod"}}, + {"router": contest_admin_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/ContestAdminDTO.py b/app/schemas/ContestAdminDTO.py new file mode 100644 index 0000000..6f53184 --- /dev/null +++ b/app/schemas/ContestAdminDTO.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +from enum import Enum +from typing import Any + +from pydantic import BaseModel, Field + +from app.schemas.ContestPinDTO import ContestCrawlResult, ContestPinTransformResult + + +class ContestBatchAction(str, Enum): + CREATED = "created" + SKIPPED = "skipped" + ERROR = "error" + + +class ContestBatchItemResult(BaseModel): + contest_api_id: int | None = None + pin_title: str | None = None + action: ContestBatchAction + message: str | None = None + + +class ContestImportBatchResult(BaseModel): + inserted_count: int + skipped_duplicate_count: int + skipped_expired_count: int = 0 + pending_import_count: int + error_count: int + errors: list[dict[str, Any]] = Field(default_factory=list) + items: list[ContestBatchItemResult] = Field(default_factory=list) + pin_ids: list[int] = Field(default_factory=list) + requested_batch_size: int | None = None + hint: str | None = None + + +class ContestTransformBatchResult(ContestPinTransformResult): + requested_batch_size: int | None = None + batches_run: int = 1 + + +class ContestPipelineStatusResult(BaseModel): + last_sync_at: str | None = None + documents_count: int + handoff_count: int + db_contest_count: int + pending_transform_count: int + pending_import_count: int + is_caught_up: bool = Field( + default=False, + description="미가공·미적재 건이 없어 DB와 파이프라인이 동기화된 상태", + ) + hint: str | None = None + + +class ContestSyncResult(BaseModel): + crawl: ContestCrawlResult + transform: ContestTransformBatchResult + import_result: ContestImportBatchResult + hint: str | None = None diff --git a/app/schemas/ContestPinDTO.py b/app/schemas/ContestPinDTO.py new file mode 100644 index 0000000..33f6f58 --- /dev/null +++ b/app/schemas/ContestPinDTO.py @@ -0,0 +1,130 @@ +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict, Field + + +class ContestDocumentDTO(BaseModel): + """Linkareer 크롤 원문 1건 (contest_documents.jsonl).""" + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "contentid": "319419", + "pin_title": "[농심] 농심 메밀소바 짧은 글 공모전", + "pin_content_raw": "■ 공모 주제\n- 여름 또는 메밀\n...", + "source_url": "https://linkareer.com/activity/319419", + "image_urls": [ + "https://api.linkareer.com/attachments/828465", + ], + "event_start_time": "20260501", + "event_end_time": "20260630", + "host_org": "농심", + "crawled_at": "2026-05-29T10:55:22", + } + } + ) + + contentid: str = Field(description="Linkareer activity ID") + pin_title: str + pin_content_raw: str = Field(description="상세 본문 원문") + source_url: str = Field(description="원문 URL") + image_urls: list[str] = Field(default_factory=list) + event_start_time: str | None = Field(default=None, description="YYYYMMDD") + event_end_time: str | None = Field(default=None, description="YYYYMMDD") + host_org: str = "" + crawled_at: str | None = None + + +class ContestDocumentsListResult(BaseModel): + """contest_documents.jsonl 조회 결과.""" + + filter_start_date: str | None = Field( + default=None, + description="접수/행사 기간 필터 시작 YYYYMMDD", + ) + filter_end_date: str | None = Field( + default=None, + description="접수/행사 기간 필터 종료 YYYYMMDD", + ) + saved_documents_path: str + total_in_file: int + matched_count: int + count: int + documents: list[ContestDocumentDTO] + hint: str | None = None + + +class ContestCrawlResult(BaseModel): + """POST /contest-pins/crawl 실행 결과.""" + + saved_documents_path: str + new_count: int + skipped_expired: int + skipped_duplicate: int + errors: int + total_count: int + start_page: int = Field(default=1, description="크롤 시작 목록 페이지") + max_pages: int = Field(default=1, description="시작 페이지부터 순회한 페이지 수") + hint: str | None = None + + +class ContestPinHandoffDTO(BaseModel): + """DB 전달용 contest_pins_for_db.jsonl 1행.""" + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "contentid": "319419", + "title": "[농심] 농심 메밀소바 짧은 글 공모전", + "pin_content": "여름 메밀소바 주제로...\n\n#공모전 #대외활동", + "cardnews_image_urls": [ + "rag/output/contest_cardnews/319419/slide_01.png", + "rag/output/contest_cardnews/319419/slide_02.png", + ], + "source_url": "https://linkareer.com/activity/319419", + }, + }, + ) + + contentid: str + title: str = Field(description="pin.pin_title") + pin_content: str = Field(description="인스타 캡션 또는 정리 본문 + 원문 링크") + cardnews_image_urls: list[str] = Field( + default_factory=list, + description="템플릿 카드뉴스 슬라이드 경로 → pin_image", + ) + source_url: str = Field(default="", description="Linkareer 원문 URL") + + @classmethod + def from_row(cls, row: dict) -> ContestPinHandoffDTO: + 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) + + +class ContestPinTransformResult(BaseModel): + input_path: str + output_path: str + processed_count: int + error_count: int + errors: list[dict] = Field(default_factory=list) + pins: list[ContestPinHandoffDTO] = Field(default_factory=list) + hint: str | None = None + skipped_duplicate_count: int = 0 + skipped_expired_count: int = 0 + pending_count: int = 0 + remaining_pending_count: int = Field( + default=0, + description="가공 후에도 handoff·DB에 없는 원문 건수", + ) + + +class ContestPinHandoffResult(BaseModel): + output_path: str + total_in_file: int + count: int + pins: list[ContestPinHandoffDTO] = Field(default_factory=list) + hint: str | None = None diff --git a/app/services/ContestEventIngestService.py b/app/services/ContestEventIngestService.py new file mode 100644 index 0000000..bdf4eb7 --- /dev/null +++ b/app/services/ContestEventIngestService.py @@ -0,0 +1,404 @@ +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.PinImage import PinImage +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.PinImageRepo import PinImageRepo +from app.repositories.PinRepo import PinRepo +from app.repositories.UserRepo import UserRepo +from app.schemas.ContestAdminDTO import ( + ContestBatchAction, + ContestBatchItemResult, + ContestImportBatchResult, + ContestPipelineStatusResult, +) +from app.services.contest_pipeline_cleanup import cleanup_after_contest_import +from app.services.contest_pin_transform import ( + CONTEST_DOCUMENTS_PATH, + CONTEST_HANDOFF_PATH, + CONTEST_SYNC_META_PATH, + count_pending_transform, + load_jsonl_rows, + load_rows_by_content_id, + parse_contest_api_id, +) +from app.utils.contest_images import CONTEST_IMAGE_S3_KEY, pin_images_for_db_row +from rag.scripts.fetch_linkareer_contests import is_contest_row_expired + +logger = logging.getLogger(__name__) + +_KST = ZoneInfo("Asia/Seoul") +_IMPORT_REPORT_PATH = CONTEST_HANDOFF_PATH.with_name("contest_import_batch_report.json") +_COMMUNITY_TYPE_CONTEST = "CONTEST" + + +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) + 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") + end_raw = row.get("event_end_time") or start_raw + if not start_raw: + today = datetime.now(_KST).date() + start_raw = today.strftime("%Y%m%d") + end_raw = end_raw 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 ContestEventIngestService: + def __init__( + self, + *, + pin_repo: PinRepo, + event_pin_repo: EventPinRepo, + community_repo: CommunityRepo, + cardnews_image_s3_repo: CardnewsImageS3Repo, + pin_image_repo: PinImageRepo, + 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._pin_image_repo = pin_image_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_contest_api_ids(self) -> set[int]: + return await self._event_pin_repo.list_contest_api_ids() + + async def resolve_admin_uid(self) -> str: + user_name = settings.contest_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, + ) -> ContestImportBatchResult: + handoff_rows = await to_thread.run_sync(load_jsonl_rows, CONTEST_HANDOFF_PATH) + db_ids = await self.get_imported_contest_api_ids() + if not handoff_rows: + documents = await to_thread.run_sync(load_jsonl_rows, CONTEST_DOCUMENTS_PATH) + pending_transform = count_pending_transform(documents, {}, db_contest_api_ids=db_ids) + if pending_transform > 0: + raise FileNotFoundError( + f"핸드오프 JSONL 없음: {CONTEST_HANDOFF_PATH}. " + f"transform을 먼저 실행하세요. (미가공 {pending_transform}건)", + ) + effective_batch = None if import_all else limit + return ContestImportBatchResult( + inserted_count=0, + skipped_duplicate_count=0, + skipped_expired_count=0, + pending_import_count=0, + error_count=0, + requested_batch_size=effective_batch, + hint=( + f"DB에 contest 핀 {len(db_ids)}건이 있어 적재 완료 상태입니다. " + "handoff JSONL이 비어 있는 것은 import 후 캐시 정리로 정상입니다." + ), + ) + + uid = admin_uid or await self.resolve_admin_uid() + inserted_count = 0 + skipped_duplicate_count = 0 + skipped_expired_count = 0 + errors: list[dict[str, Any]] = [] + items: list[ContestBatchItemResult] = [] + pin_ids: list[int] = [] + prune_contest_api_ids: set[int] = set() + + pending_rows: list[dict[str, Any]] = [] + for row in handoff_rows: + contest_api_id = parse_contest_api_id(row) + if contest_api_id is None: + continue + if is_contest_row_expired(row): + skipped_expired_count += 1 + prune_contest_api_ids.add(contest_api_id) + items.append( + ContestBatchItemResult( + contest_api_id=contest_api_id, + pin_title=str(row.get("title") or row.get("pin_title") or ""), + action=ContestBatchAction.SKIPPED, + message="종료일 경과", + ), + ) + continue + if contest_api_id in db_ids: + skipped_duplicate_count += 1 + prune_contest_api_ids.add(contest_api_id) + items.append( + ContestBatchItemResult( + contest_api_id=contest_api_id, + pin_title=str(row.get("title") or row.get("pin_title") or ""), + action=ContestBatchAction.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: + contest_api_id = parse_contest_api_id(row) + if contest_api_id is None: + errors.append({"row": row, "error": "contest_api_id 없음"}) + continue + if is_contest_row_expired(row): + skipped_expired_count += 1 + prune_contest_api_ids.add(contest_api_id) + items.append( + ContestBatchItemResult( + contest_api_id=contest_api_id, + pin_title=str(row.get("title") or row.get("pin_title") or ""), + action=ContestBatchAction.SKIPPED, + message="종료일 경과", + ), + ) + continue + if contest_api_id in db_ids: + skipped_duplicate_count += 1 + prune_contest_api_ids.add(contest_api_id) + continue + try: + async with self._pin_repo.session.begin_nested(): + pin_id = await self._insert_contest_pin( + admin_uid=uid, + row=row, + contest_api_id=contest_api_id, + ) + inserted_count += 1 + db_ids.add(contest_api_id) + prune_contest_api_ids.add(contest_api_id) + pin_ids.append(pin_id) + items.append( + ContestBatchItemResult( + contest_api_id=contest_api_id, + pin_title=str(row.get("title") or row.get("pin_title") or ""), + action=ContestBatchAction.CREATED, + ), + ) + except Exception as exc: + logger.exception("contest import failed contest_api_id=%s", contest_api_id) + errors.append( + { + "contest_api_id": contest_api_id, + "pin_title": row.get("title") or row.get("pin_title"), + "error": str(exc), + }, + ) + items.append( + ContestBatchItemResult( + contest_api_id=contest_api_id, + pin_title=str(row.get("title") or row.get("pin_title") or ""), + action=ContestBatchAction.ERROR, + message=str(exc), + ), + ) + + await self._pin_repo.commit() + + if settings.contest_prune_pipeline_after_import and prune_contest_api_ids: + await to_thread.run_sync(cleanup_after_contest_import, prune_contest_api_ids) + + pending_import = 0 + for row in handoff_rows: + contest_api_id = parse_contest_api_id(row) + if contest_api_id is not None and contest_api_id not in db_ids and not is_contest_row_expired(row): + pending_import += 1 + + report = { + "inserted_count": inserted_count, + "skipped_duplicate_count": skipped_duplicate_count, + "skipped_expired_count": skipped_expired_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 ContestImportBatchResult( + inserted_count=inserted_count, + skipped_duplicate_count=skipped_duplicate_count, + skipped_expired_count=skipped_expired_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_contest_pin( + self, + *, + admin_uid: str, + row: dict[str, Any], + contest_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.CONTEST, + 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=None, + contest_api_id=contest_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_CONTEST, + popularity=0.0, + ) + await self._community_repo.save(community, flush_immediately=True) + + for spec in pin_images_for_db_row(row): + url = str(spec.get("pin_image_url") or "").strip() + if not url: + continue + pin_image = PinImage( + pin_id=pin.pin_id, + pin_s3_key=CONTEST_IMAGE_S3_KEY, + pin_s3_url=url, + is_main=bool(spec.get("is_main")), + ) + await self._pin_image_repo.save(pin_image, 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) -> ContestPipelineStatusResult: + meta = self._load_sync_meta() + documents = load_jsonl_rows(CONTEST_DOCUMENTS_PATH) + handoff_by_id = load_rows_by_content_id(CONTEST_HANDOFF_PATH) + db_ids = await self.get_imported_contest_api_ids() + + pending_transform = count_pending_transform(documents, handoff_by_id, db_contest_api_ids=db_ids) + pending_import = 0 + for row in handoff_by_id.values(): + contest_api_id = parse_contest_api_id(row) + if contest_api_id is not None and contest_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에 contest 핀 {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 ContestPipelineStatusResult( + last_sync_at=meta.get("last_sync_at"), + documents_count=len(documents), + handoff_count=len(handoff_by_id), + db_contest_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 CONTEST_SYNC_META_PATH.is_file(): + return {} + try: + return json.loads(CONTEST_SYNC_META_PATH.read_text(encoding="utf-8")) + except json.JSONDecodeError: + return {} + + @staticmethod + def write_sync_meta() -> None: + meta = { + "last_sync_at": datetime.now(_KST).isoformat(timespec="seconds"), + } + CONTEST_SYNC_META_PATH.parent.mkdir(parents=True, exist_ok=True) + CONTEST_SYNC_META_PATH.write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8") diff --git a/app/services/ContestPinService.py b/app/services/ContestPinService.py new file mode 100644 index 0000000..770adec --- /dev/null +++ b/app/services/ContestPinService.py @@ -0,0 +1,455 @@ +from __future__ import annotations + +import asyncio +import json +import sys +from pathlib import Path +from typing import Any + +from pydantic import ValidationError + +from app.core.config import settings +from app.schemas.ContestAdminDTO import ( + ContestBatchAction, + ContestBatchItemResult, + ContestImportBatchResult, + ContestSyncResult, + ContestTransformBatchResult, +) +from app.schemas.ContestPinDTO import ( + ContestCrawlResult, + ContestDocumentDTO, + ContestDocumentsListResult, + ContestPinHandoffResult, + ContestPinTransformResult, +) +from app.services.ContestEventIngestService import ContestEventIngestService +from app.services.contest_pipeline_cleanup import prune_pipeline_imported +from app.services.contest_pin_transform import ( + CONTEST_HANDOFF_PATH, + load_jsonl_rows, + transform_documents_jsonl, + load_handoff_from_jsonl, +) +from app.utils.S3Util import S3Util +from app.utils.festival_date_filter import festival_overlaps_range +from rag.scripts.fetch_linkareer_contests import ( + CONTEST_DOCUMENTS_PATH, + normalize_contest_row, + run_crawl, +) + +_MAX_LIST_ITEMS = 500 + + +def _ensure_playwright_ready() -> None: + try: + import playwright # noqa: F401 + except ImportError as exc: + raise RuntimeError( + "playwright 패키지가 없습니다. " + "서버 venv에서: pip install playwright && python -m playwright install chromium" + ) from exc + + +def _run_crawl_in_proactor_thread( + *, + max_pages: int, + start_page: int, + limit: int | None, + delay: float, + force: bool, +) -> dict[str, Any]: + if sys.platform == "win32": + asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) + loop = asyncio.new_event_loop() + try: + asyncio.set_event_loop(loop) + return loop.run_until_complete( + run_crawl( + max_pages=max_pages, + start_page=start_page, + limit=limit, + delay=delay, + force=force, + ) + ) + finally: + try: + loop.run_until_complete(loop.shutdown_asyncgens()) + except Exception: + pass + loop.close() + asyncio.set_event_loop(None) + + +class ContestPinService: + @staticmethod + def documents_path() -> Path: + return CONTEST_DOCUMENTS_PATH + + @staticmethod + def handoff_path() -> Path: + return CONTEST_HANDOFF_PATH + + async def crawl_and_save( + self, + *, + max_pages: int | None = None, + start_page: int | None = None, + limit: int | None = None, + delay: float = 1.0, + force: bool = False, + ) -> ContestCrawlResult: + effective_max_pages = max_pages if max_pages is not None else settings.contest_crawl_max_pages + effective_start_page = start_page if start_page is not None else 1 + _ensure_playwright_ready() + if sys.platform == "win32": + stats = await asyncio.to_thread( + _run_crawl_in_proactor_thread, + max_pages=effective_max_pages, + start_page=effective_start_page, + limit=limit, + delay=delay, + force=force, + ) + else: + stats = await run_crawl( + max_pages=effective_max_pages, + start_page=effective_start_page, + limit=limit, + delay=delay, + force=force, + ) + hint = ( + f"{stats['new_count']}건 신규 저장 (목록 page {effective_start_page}" + f"~{effective_start_page + effective_max_pages - 1}). " + f"다음: POST /contest-admin/sync 또는 POST /contest-pins/cardnews" + ) + if stats["errors"]: + hint = f"일부 오류 발생({stats['errors']}건). " + hint + return ContestCrawlResult( + saved_documents_path=stats["saved_documents_path"], + new_count=stats["new_count"], + skipped_expired=stats["skipped_expired"], + skipped_duplicate=stats["skipped_duplicate"], + errors=stats["errors"], + total_count=stats["total_count"], + start_page=effective_start_page, + max_pages=effective_max_pages, + hint=hint, + ) + + def load_documents_from_jsonl( + self, + *, + file_path: Path | None = None, + start_date: str | None = None, + end_date: str | None = None, + limit: int | None = None, + contentid: str | None = None, + ) -> ContestDocumentsListResult: + path = file_path or self.documents_path() + if not path.is_file(): + raise FileNotFoundError( + f"원문 JSONL 없음: {path}. " + "POST /contest-pins/crawl 또는 fetch_linkareer_contests 스크립트를 먼저 실행하세요.", + ) + + effective_limit = _MAX_LIST_ITEMS if limit is None else min(limit, _MAX_LIST_ITEMS) + use_date_filter = start_date is not None and end_date is not None + cid_filter = (contentid or "").strip() + + matched: list[ContestDocumentDTO] = [] + total_in_file = 0 + + with path.open("r", encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line: + continue + total_in_file += 1 + row = normalize_contest_row(json.loads(line)) + try: + item = ContestDocumentDTO.model_validate(row) + except ValidationError as exc: + raise ValueError( + f"JSONL 형식 오류 (line {total_in_file}, contentid={row.get('contentid')}): {exc}" + ) from exc + + if cid_filter and item.contentid != cid_filter: + continue + + if use_date_filter and not festival_overlaps_range( + event_start=item.event_start_time, + event_end=item.event_end_time, + query_start=start_date, + query_end=end_date, + ): + continue + + matched.append(item) + + documents = matched[:effective_limit] + + hint: str | None = None + if total_in_file == 0: + hint = "JSONL이 비어 있습니다. POST /contest-pins/crawl 을 실행하세요." + elif len(documents) == 0: + if cid_filter: + hint = f"contentid={cid_filter} 에 해당하는 공모전이 없습니다." + elif use_date_filter: + hint = "기간 필터에 맞는 공모전이 없습니다. start_date/end_date를 비우세요." + else: + hint = "조회 결과가 없습니다." + + return ContestDocumentsListResult( + filter_start_date=start_date, + filter_end_date=end_date, + saved_documents_path=str(path), + total_in_file=total_in_file, + matched_count=len(matched), + count=len(documents), + documents=documents, + hint=hint, + ) + + async def transform_and_save( + self, + *, + limit: int | None = None, + model: str | None = None, + s3_util: S3Util | None = None, + db_contest_api_ids: set[int] | None = None, + with_caption: bool = True, + contentid: str | None = None, + ) -> ContestPinTransformResult: + return await transform_documents_jsonl( + limit=limit, + model=model, + s3_util=s3_util, + db_contest_api_ids=db_contest_api_ids, + merge_handoff=True, + with_caption=with_caption, + contentid=contentid, + ) + + async def cardnews_and_save( + self, + *, + limit: int | None = None, + model: str | None = None, + s3_util: S3Util | None = None, + db_contest_api_ids: set[int] | None = None, + with_caption: bool = True, + contentid: str | None = None, + ) -> ContestPinTransformResult: + return await self.transform_and_save( + limit=limit, + model=model, + s3_util=s3_util, + db_contest_api_ids=db_contest_api_ids, + with_caption=with_caption, + contentid=contentid, + ) + + async def transform_batch( + self, + *, + batch_size: int | None = None, + model: str | None = None, + s3_util: S3Util | None = None, + db_contest_api_ids: set[int] | None = None, + ) -> ContestTransformBatchResult: + size = batch_size or settings.contest_sync_batch_size + result = await self.transform_and_save( + limit=size, + model=model, + s3_util=s3_util, + db_contest_api_ids=db_contest_api_ids, + ) + return ContestTransformBatchResult( + **result.model_dump(), + requested_batch_size=size, + batches_run=1, + ) + + async def sync_pipeline( + self, + *, + ingest_service: ContestEventIngestService, + s3_util: S3Util, + transform_limit: int | None = None, + batch_size: int | None = None, + max_pages: int | None = None, + start_page: int | None = None, + ) -> ContestSyncResult: + effective_batch = batch_size or settings.contest_sync_batch_size + + if settings.contest_prune_pipeline_after_import: + db_ids_before = await ingest_service.get_imported_contest_api_ids() + prune_pipeline_imported(db_ids_before) + + crawl = await self.crawl_and_save( + max_pages=max_pages, + start_page=start_page, + force=False, + ) + + 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[ContestBatchItemResult] = [] + import_pin_ids: list[int] = [] + batches_run = 0 + last_transform_pending = 0 + last_import = ContestImportBatchResult( + inserted_count=0, + skipped_duplicate_count=0, + pending_import_count=0, + error_count=0, + ) + + def _accumulate_import(import_batch: ContestImportBatchResult) -> 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_contest_api_ids() + + def _track_imported_ids(import_batch: ContestImportBatchResult) -> None: + for item in import_batch.items: + if item.action == ContestBatchAction.CREATED and item.contest_api_id is not None: + db_ids.add(item.contest_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_contest_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 = ContestTransformBatchResult( + 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.contest_transform_concurrency})" + ), + skipped_duplicate_count=total_skipped_transform, + pending_count=last_transform_pending, + remaining_pending_count=last_transform_pending, + requested_batch_size=effective_batch, + batches_run=batches_run, + ) + import_result = ContestImportBatchResult( + 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, + ) + + ContestEventIngestService.write_sync_meta() + hint = ( + f"sync 완료: 크롤 신규 {crawl.new_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 반영 완료 (contest 핀 {len(db_ids)}건). " + "handoff JSONL이 비어 있는 것은 import 후 캐시 정리로 정상입니다." + ) + elif total_processed == 0 and last_transform_pending > 0: + hint = ( + f"크롤 신규 {crawl.new_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}건 남음 — /contest-admin/sync 재실행 또는 transform-batch." + ) + + return ContestSyncResult( + crawl=crawl, + transform=aggregated_transform, + import_result=import_result, + hint=hint, + ) + + def load_handoff_from_jsonl( + self, + *, + limit: int | None = None, + ) -> ContestPinHandoffResult: + path, pins, total_in_file = load_handoff_from_jsonl(limit=limit) + hint: str | None = None + if total_in_file == 0: + hint = "JSONL이 비어 있습니다. POST /contest-pins/cardnews 를 먼저 실행하세요." + return ContestPinHandoffResult( + output_path=str(path), + total_in_file=total_in_file, + count=len(pins), + pins=pins, + hint=hint, + ) diff --git a/app/services/contest_cardnews.py b/app/services/contest_cardnews.py new file mode 100644 index 0000000..04c4328 --- /dev/null +++ b/app/services/contest_cardnews.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +import logging +from pathlib import Path +from typing import Any + +from app.contest_cardnews import render_contest_cardnews_slides +from app.contest_cardnews.slides import parse_contest_cardnews_slides_json +from app.services.internal.ai.IssuePinLLMService import IssuePinLLMService +from app.services.prompts.contest_cardnews import ( + build_contest_cardnews_caption_prompt, + build_contest_cardnews_slide_prompt, +) +from rag.scripts.fetch_linkareer_contests import clean_contest_body + +logger = logging.getLogger(__name__) + +CONTEST_CARDNEWS_OUTPUT_DIR = ( + Path(__file__).resolve().parents[2] / "rag" / "output" / "contest_cardnews" +) + + +async def generate_contest_cardnews_paths( + text_llm: IssuePinLLMService, + *, + row: dict[str, Any], + output_dir: Path | None = None, + with_caption: bool = True, +) -> tuple[list[str], str]: + """크롤 본문 → Gemini 슬라이드 JSON → 공모전 전용 브라우저 템플릿 PNG.""" + content_id = str(row.get("contentid") or "").strip() + if not content_id: + return [], "" + + pin_title = str(row.get("pin_title") or "").strip() + host_org = str(row.get("host_org") or "").strip() + source_url = str(row.get("source_url") or "").strip() + raw = clean_contest_body( + str(row.get("pin_content_raw") or row.get("pin_content") or ""), + pin_title=pin_title, + ) + if not raw: + raise ValueError("pin_content_raw가 비어 있음") + + slide_prompt = build_contest_cardnews_slide_prompt( + pin_title=pin_title, + pin_content_raw=raw, + host_org=host_org, + event_start_time=row.get("event_start_time"), + event_end_time=row.get("event_end_time"), + source_url=source_url, + ) + raw_slides = await text_llm.generate_pin_text(prompt=slide_prompt) + slides = parse_contest_cardnews_slides_json(raw_slides) + + target_dir = output_dir or CONTEST_CARDNEWS_OUTPUT_DIR + paths = await render_contest_cardnews_slides( + contentid=content_id, + slides=slides, + output_dir=target_dir, + host_org=host_org, + source_url=source_url, + ) + + caption = "" + if with_caption: + cap_prompt = build_contest_cardnews_caption_prompt( + pin_title=pin_title, + pin_content_raw=raw, + host_org=host_org, + source_url=source_url, + ) + try: + caption = (await text_llm.generate_pin_text(prompt=cap_prompt)).strip() + except Exception: + logger.exception("공모전 캡션 생성 실패 contentid=%s", content_id) + + return paths, caption diff --git a/app/services/contest_cardnews_s3.py b/app/services/contest_cardnews_s3.py new file mode 100644 index 0000000..d309b4d --- /dev/null +++ b/app/services/contest_cardnews_s3.py @@ -0,0 +1,121 @@ +from __future__ import annotations + +import logging +from pathlib import Path +from typing import TypedDict + +from anyio import to_thread + +from app.core.config import settings +from app.services.contest_cardnews import CONTEST_CARDNEWS_OUTPUT_DIR, generate_contest_cardnews_paths +from app.services.internal.ai.IssuePinLLMService import IssuePinLLMService +from app.utils.S3Util import S3Util + +logger = logging.getLogger(__name__) + +_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 = CONTEST_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.contest_cardnews_keep_local_files: + cleanup_local_cardnews_dir(content_id) + + +def _slide_object_key(content_id: str, slide_no: int) -> str: + prefix = settings.contest_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 upload_contest_cardnews_s3_images( + text_llm: IssuePinLLMService, + *, + row: dict[str, Any], + s3_util: S3Util, + with_caption: bool = True, + output_dir: Path | None = None, +) -> tuple[list[CardnewsS3Image], str]: + """로컬 PNG 생성 후 S3 업로드.""" + cardnews_paths, caption = await generate_contest_cardnews_paths( + text_llm, + row=row, + output_dir=output_dir, + with_caption=with_caption, + ) + if not cardnews_paths: + raise ValueError("카드뉴스 이미지가 생성되지 않음") + + content_id = str(row.get("contentid") or "").strip() + uploaded: list[CardnewsS3Image] = [] + for path in cardnews_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, caption diff --git a/app/services/contest_pin_transform.py b/app/services/contest_pin_transform.py new file mode 100644 index 0000000..c3da33e --- /dev/null +++ b/app/services/contest_pin_transform.py @@ -0,0 +1,316 @@ +from __future__ import annotations + +import asyncio +import json +from pathlib import Path +from typing import Any + +from app.core.config import settings +from app.schemas.ContestPinDTO import ContestPinHandoffDTO, ContestPinTransformResult +from app.services.contest_cardnews_s3 import CardnewsS3Image, upload_contest_cardnews_s3_images +from app.services.internal.ai.IssuePinLLMService import IssuePinLLMService +from app.services.internal.ai.gemini_factory import build_issue_pin_llm_service +from app.utils.contest_images import pin_images_for_db_row +from app.utils.pin_content import append_source_link_to_pin_content +from app.utils.S3Util import S3Util +from rag.scripts.chunk_module import iter_jsonl, write_jsonl +from rag.scripts.fetch_linkareer_contests import CONTEST_DOCUMENTS_PATH, clean_contest_body, is_contest_row_expired + +CONTEST_HANDOFF_PATH = ( + Path(__file__).resolve().parents[2] / "rag" / "output" / "contest_pins_for_db.jsonl" +) +CONTEST_SYNC_META_PATH = ( + Path(__file__).resolve().parents[2] / "rag" / "output" / "contest_sync_meta.json" +) + + +def parse_contest_api_id(row: dict[str, Any]) -> int | None: + raw = row.get("contest_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("contest_api_id") or "").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 write_handoff_map(handoff_by_id: dict[str, dict[str, Any]], path: Path | None = None) -> None: + dst = path or CONTEST_HANDOFF_PATH + write_jsonl(dst, list(handoff_by_id.values())) + + +def build_handoff_row( + source: dict[str, Any], + *, + pin_content: str, + cardnews_images: list[CardnewsS3Image] | None = None, + cardnews_image_urls: list[str] | None = None, +) -> dict[str, Any]: + content_id = row_content_id(source) + contest_api_id = parse_contest_api_id(source) + images = cardnews_images or [] + urls = cardnews_image_urls or [img["url"] for img in images if str(img.get("url") or "").strip()] + source_url = (source.get("source_url") or "").strip() + body = append_source_link_to_pin_content(pin_content, source_url) + pin_images = pin_images_for_db_row(source) + return { + "contentid": content_id, + "contest_api_id": contest_api_id, + "title": (source.get("pin_title") or source.get("title") or "").strip(), + "pin_content": body, + "pin_images": pin_images, + "cardnews_image_urls": [str(u).strip() for u in urls if str(u).strip()], + "cardnews_images": images, + "source_url": source_url, + "event_start_time": source.get("event_start_time"), + "event_end_time": source.get("event_end_time"), + "host_org": (source.get("host_org") or "").strip(), + } + + +async def transform_one_row( + llm: IssuePinLLMService, + row: dict[str, Any], + *, + s3_util: S3Util, + with_caption: bool = True, +) -> dict[str, Any]: + pin_title = str(row.get("pin_title") or "").strip() + raw = clean_contest_body( + str(row.get("pin_content_raw") or row.get("pin_content") or ""), + pin_title=pin_title, + ) + if not raw: + raise ValueError("pin_content_raw가 비어 있음") + + cardnews_images, caption = await upload_contest_cardnews_s3_images( + llm, + row={**row, "pin_content_raw": raw}, + s3_util=s3_util, + with_caption=with_caption, + ) + pin_content = caption if caption else raw + return build_handoff_row( + row, + pin_content=pin_content, + cardnews_images=cardnews_images, + ) + + +def list_pending_transform_rows( + documents: list[dict[str, Any]], + handoff_by_id: dict[str, dict[str, Any]], + *, + db_contest_api_ids: set[int] | None = None, + contentid: str | None = None, +) -> list[dict[str, Any]]: + db_ids = db_contest_api_ids or set() + cid_filter = (contentid or "").strip() + pending: list[dict[str, Any]] = [] + for row in documents: + content_id = row_content_id(row) + if not content_id: + continue + if cid_filter and content_id != cid_filter: + continue + contest_id = parse_contest_api_id(row) + if contest_id is not None and contest_id in db_ids: + continue + if is_contest_row_expired(row): + continue + if content_id in handoff_by_id: + continue + pending.append(row) + return pending + + +def count_skipped_expired_transform_rows( + documents: list[dict[str, Any]], + handoff_by_id: dict[str, dict[str, Any]], + *, + db_contest_api_ids: set[int] | None = None, + contentid: str | None = None, +) -> int: + db_ids = db_contest_api_ids or set() + cid_filter = (contentid or "").strip() + count = 0 + for row in documents: + content_id = row_content_id(row) + if not content_id: + continue + if cid_filter and content_id != cid_filter: + continue + if not is_contest_row_expired(row): + continue + contest_id = parse_contest_api_id(row) + if contest_id is not None and contest_id in db_ids: + continue + if content_id in handoff_by_id: + continue + count += 1 + return count + + +def count_pending_transform( + documents: list[dict[str, Any]], + handoff_by_id: dict[str, dict[str, Any]], + *, + db_contest_api_ids: set[int] | None = None, +) -> int: + return len( + list_pending_transform_rows( + documents, + handoff_by_id, + db_contest_api_ids=db_contest_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_contest_api_ids: set[int] | None = None, + merge_handoff: bool = True, + with_caption: bool = True, + contentid: str | None = None, +) -> ContestPinTransformResult: + src = input_path or CONTEST_DOCUMENTS_PATH + dst = output_path or CONTEST_HANDOFF_PATH + if not src.is_file(): + raise FileNotFoundError( + f"원문 JSONL 없음: {src}. POST /contest-pins/crawl 을 먼저 실행하세요.", + ) + + documents = load_jsonl_rows(src) + handoff_by_id = load_rows_by_content_id(dst) if merge_handoff else {} + db_ids = db_contest_api_ids or set() + pending = list_pending_transform_rows( + documents, + handoff_by_id, + db_contest_api_ids=db_ids, + contentid=contentid, + ) + + llm = build_issue_pin_llm_service(model=model) + s3 = s3_util or S3Util() + processed_rows: list[dict[str, Any]] = [] + errors: list[dict[str, Any]] = [] + skipped_duplicate_count = 0 + skipped_expired_count = count_skipped_expired_transform_rows( + documents, + handoff_by_id, + db_contest_api_ids=db_ids, + contentid=contentid, + ) + + rows_to_process = pending if limit is None else pending[:limit] + concurrency = min(settings.contest_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, with_caption=with_caption) + 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 + contest_id = parse_contest_api_id(row) + if contest_id is not None and contest_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_contest_api_ids=db_ids, + ) + pins = [ContestPinHandoffDTO.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)}건. " + "카드뉴스는 텍스트·캐릭터만 사용(크롤 이미지 미포함)." + ) + else: + hint = "가공 성공 건이 없습니다. GEMINI_API_KEY·pin_content_raw를 확인하세요." + + return ContestPinTransformResult( + 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, + skipped_expired_count=skipped_expired_count, + pending_count=len(pending), + remaining_pending_count=remaining_pending, + ) + + +def load_handoff_from_jsonl( + *, + file_path: Path | None = None, + limit: int | None = None, +) -> tuple[Path, list[ContestPinHandoffDTO], int]: + path = file_path or CONTEST_HANDOFF_PATH + if not path.is_file(): + raise FileNotFoundError( + f"핸드오프 JSONL 없음: {path}. POST /contest-pins/cardnews 를 먼저 실행하세요.", + ) + + max_items = 500 if limit is None else min(limit, 500) + pins: list[ContestPinHandoffDTO] = [] + with path.open("r", encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line: + continue + pins.append(ContestPinHandoffDTO.from_row(json.loads(line))) + + total_in_file = len(pins) + return path, pins[:max_items], total_in_file diff --git a/app/services/contest_pipeline_cleanup.py b/app/services/contest_pipeline_cleanup.py new file mode 100644 index 0000000..889962d --- /dev/null +++ b/app/services/contest_pipeline_cleanup.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +import logging +import shutil +from pathlib import Path +from typing import Any, TypedDict + +from anyio import to_thread + +from app.core.config import settings +from app.services.contest_cardnews import CONTEST_CARDNEWS_OUTPUT_DIR +from app.services.contest_pin_transform import ( + CONTEST_HANDOFF_PATH, + load_jsonl_rows, + parse_contest_api_id, + row_content_id, +) +from rag.scripts.chunk_module import write_jsonl +from rag.scripts.fetch_linkareer_contests import CONTEST_DOCUMENTS_PATH + +logger = logging.getLogger(__name__) + + +def prune_jsonl_by_contest_api_ids( + path: Path, + contest_api_ids: set[int], +) -> int: + if not contest_api_ids or not path.is_file(): + return 0 + + kept: list[dict] = [] + removed = 0 + for row in load_jsonl_rows(path): + contest_id = parse_contest_api_id(row) + if contest_id is not None and contest_id in contest_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( + contest_api_ids: set[int], + *, + documents_path: Path | None = None, + handoff_path: Path | None = None, +) -> dict[str, int]: + if not contest_api_ids: + return {"documents_removed": 0, "handoff_removed": 0} + + docs = documents_path or CONTEST_DOCUMENTS_PATH + handoff = handoff_path or CONTEST_HANDOFF_PATH + return { + "documents_removed": prune_jsonl_by_contest_api_ids(docs, contest_api_ids), + "handoff_removed": prune_jsonl_by_contest_api_ids(handoff, contest_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 = CONTEST_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 contest_api_ids_to_content_ids(contest_api_ids: set[int]) -> set[str]: + return {str(contest_id) for contest_id in contest_api_ids} + + +def cleanup_after_contest_import(contest_api_ids: set[int]) -> dict[str, int]: + stats = prune_pipeline_imported(contest_api_ids) + stats["cardnews_dirs_removed"] = cleanup_local_cardnews_dirs( + contest_api_ids_to_content_ids(contest_api_ids), + ) + return stats diff --git a/app/services/festival_pin_transform.py b/app/services/festival_pin_transform.py index a066304..7ca2290 100644 --- a/app/services/festival_pin_transform.py +++ b/app/services/festival_pin_transform.py @@ -8,7 +8,7 @@ from app.utils.visitkorea_area import infer_area_code_from_addr, resolve_row_area_code, row_matches_area_filter from app.schemas.FestivalPinDTO import FestivalPinDTO, FestivalPinTransformResult from app.services.internal.ai.IssuePinLLMService import IssuePinLLMService -from app.services.internal.ai.gemini_retry import parse_gemini_model_list +from app.services.internal.ai.gemini_factory import build_issue_pin_llm_service from app.services.prompts.festival_pin import build_festival_instagram_prompt from rag.scripts.chunk_module import iter_jsonl, write_jsonl @@ -158,19 +158,6 @@ def build_handoff_row( } -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]) -> dict[str, Any]: raw_content = row_raw_content(row) if not raw_content: @@ -322,7 +309,7 @@ async def transform_documents_batch( llm_rows = llm_pending[:effective_batch] if llm_rows: - llm = build_llm_service(model=model) + llm = build_issue_pin_llm_service(model=model) concurrency = min(settings.festival_transform_concurrency, len(llm_rows)) semaphore = asyncio.Semaphore(max(1, concurrency)) diff --git a/app/services/internal/ContestPinSchedulerService.py b/app/services/internal/ContestPinSchedulerService.py new file mode 100644 index 0000000..65a3f36 --- /dev/null +++ b/app/services/internal/ContestPinSchedulerService.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +import asyncio +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.PinImageRepo import PinImageRepo +from app.repositories.PinRepo import PinRepo +from app.repositories.UserRepo import UserRepo +from app.schemas.ContestAdminDTO import ContestSyncResult +from app.services.ContestEventIngestService import ContestEventIngestService +from app.services.ContestPinService import ContestPinService +from app.utils.S3Util import S3Util + +logger = logging.getLogger(__name__) +_KST = ZoneInfo("Asia/Seoul") + + +class ContestPinSchedulerService: + def __init__(self, *, s3_util: S3Util) -> None: + self._s3_util = s3_util + self._task: asyncio.Task[None] | None = None + self._pin_service = ContestPinService() + + 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="contest_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, + max_pages: int | None = None, + start_page: int | None = None, + ) -> ContestSyncResult: + async with AsyncSessionLocal() as session: + ingest = ContestEventIngestService( + pin_repo=PinRepo(session), + event_pin_repo=EventPinRepo(session), + community_repo=CommunityRepo(session), + cardnews_image_s3_repo=CardnewsImageS3Repo(session), + pin_image_repo=PinImageRepo(session), + user_repo=UserRepo(session), + ) + return await self._pin_service.sync_pipeline( + ingest_service=ingest, + s3_util=self._s3_util, + max_pages=max_pages, + start_page=start_page, + ) + + 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.contest_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/gemini_factory.py b/app/services/internal/ai/gemini_factory.py new file mode 100644 index 0000000..00f03d2 --- /dev/null +++ b/app/services/internal/ai/gemini_factory.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +from app.core.codes import ErrorCode +from app.core.config import settings +from app.core.exceptions import raise_business_exception +from app.services.ComplaintEmailVlmService import ComplaintEmailVlmService +from app.services.RagRerankService import RagRerankService +from app.services.internal.ai.ComplaintEmailLLMService import ComplaintEmailLLMService +from app.services.internal.ai.IssuePinLLMService import IssuePinLLMService +from app.services.internal.ai.IssueRagPlannerService import IssueRagPlannerService +from app.services.internal.ai.VLMService import VLMService +from app.services.internal.ai.gemini_retry import parse_gemini_model_list + + +def _gemini_api_key_or_none() -> str | None: + secret = settings.gemini_api_key + if secret is None: + return None + return secret.get_secret_value() + + +def require_gemini_api_key() -> str: + api_key = _gemini_api_key_or_none() + if api_key is None: + raise_business_exception(ErrorCode.VLM_NOT_CONFIGURED) + return api_key + + +def build_vlm_service(*, api_key: str | None = None) -> VLMService: + key = api_key or require_gemini_api_key() + return VLMService( + api_key=key, + model_name=settings.gemini_vlm_model, + fallback_models=parse_gemini_model_list(settings.gemini_vlm_fallback_models), + ) + + +def build_issue_pin_llm_service(*, model: str | None = None) -> IssuePinLLMService: + api_key = _gemini_api_key_or_none() + if api_key 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=api_key, + model_name=model_name, + fallback_models=fallbacks, + ) + + +def build_issue_rag_planner_service(*, api_key: str | None = None) -> IssueRagPlannerService: + key = api_key or require_gemini_api_key() + return IssueRagPlannerService( + api_key=key, + model_name=settings.gemini_rag_planner_model, + fallback_models=parse_gemini_model_list(settings.gemini_rag_planner_fallback_models), + ) + + +def build_complaint_email_vlm_service(*, api_key: str | None = None) -> ComplaintEmailVlmService: + key = api_key or require_gemini_api_key() + return ComplaintEmailVlmService( + api_key=key, + model=settings.gemini_vlm_model, + ) + + +def build_complaint_email_llm_service(*, api_key: str | None = None) -> ComplaintEmailLLMService: + key = api_key or require_gemini_api_key() + return ComplaintEmailLLMService( + api_key=key, + model_name=settings.gemini_pin_text_model, + ) + + +def build_rag_rerank_service(*, api_key: str | None = None) -> RagRerankService: + key = api_key or require_gemini_api_key() + return RagRerankService( + api_key=key, + embedding_model=settings.gemini_embedding_model, + embed_dim=settings.vector_embed_dim, + embedding_batch_size=settings.gemini_embedding_batch_size, + ) diff --git a/app/services/internal/complaint_wiring.py b/app/services/internal/complaint_wiring.py new file mode 100644 index 0000000..295cd7f --- /dev/null +++ b/app/services/internal/complaint_wiring.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +from app.core.config import settings +from app.services.ComplaintEmailService import ComplaintEmailService +from app.services.RagRetrievalService import RagRetrievalService +from app.services.VectorStoreService import VectorStoreService +from app.services.internal.ai.gemini_factory import ( + build_complaint_email_llm_service, + build_complaint_email_vlm_service, + build_rag_rerank_service, + build_vlm_service, +) + + +def build_complaint_email_service( + *, + api_key: str, + vector_store_service: VectorStoreService, +) -> ComplaintEmailService: + complaint_vlm_service = build_complaint_email_vlm_service(api_key=api_key) + validation_vlm_service = build_vlm_service(api_key=api_key) + complaint_llm_service = build_complaint_email_llm_service(api_key=api_key) + rag_rerank_service = build_rag_rerank_service(api_key=api_key) + rag_retrieval_service = RagRetrievalService( + vector_store_service=vector_store_service, + rerank_service=rag_rerank_service, + retrieve_top_k=settings.rag_retrieve_top_k, + rerank_top_k=settings.rag_rerank_top_k, + enable_rerank=settings.rag_enable_rerank, + vector_query_mode=settings.rag_vector_query_mode, + ) + return ComplaintEmailService( + complaint_vlm_service=complaint_vlm_service, + pin_validation_vlm_service=validation_vlm_service, + complaint_llm_service=complaint_llm_service, + rag_retrieval_service=rag_retrieval_service, + ) diff --git a/app/services/policy_pin_transform.py b/app/services/policy_pin_transform.py index ef2f4d3..5997b73 100644 --- a/app/services/policy_pin_transform.py +++ b/app/services/policy_pin_transform.py @@ -7,7 +7,7 @@ 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.internal.ai.gemini_factory import build_issue_pin_llm_service 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 @@ -107,19 +107,6 @@ def build_handoff_row( } -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], @@ -214,7 +201,7 @@ async def transform_documents_jsonl( db_policy_api_ids=db_ids, ) - llm = build_llm_service(model=model) + llm = build_issue_pin_llm_service(model=model) s3 = s3_util or S3Util() processed_rows: list[dict[str, Any]] = [] errors: list[dict[str, Any]] = [] diff --git a/app/services/prompts/contest_cardnews.py b/app/services/prompts/contest_cardnews.py new file mode 100644 index 0000000..a39c517 --- /dev/null +++ b/app/services/prompts/contest_cardnews.py @@ -0,0 +1,133 @@ +from __future__ import annotations + +CONTEST_CARDNEWS_SLIDE_PROMPT = """ +[역할] +공모전·대외활동 공고를 브라우저형 카드뉴스 템플릿 문구로 작성한다. +원문에 없는 정보는 만들지 않는다. 크롤 이미지는 사용하지 않는다. + +[출력] +JSON 배열 **정확히 3개**만. + +[3장 구성 — 필수] +1. contest_cover — 표지 +2. contest_table **또는** contest_checklist — 한눈 요약 (둘 중 하나만) +3. contest_cta — 원문 링크 유도 + +[사용 금지] +contest_headline, contest_body, contest_three_col 및 그 외 layout + +[필드] +slide, layout_type, eyebrow, headline, highlight, body, items[{label,text}], +cta, speech, point, use_image, template_palette + +[규칙] +- use_image: false +- template_palette: 전 슬라이드 동일 1개 + pastel_mint, pastel_pink, pastel_lavender, pastel_peach, pastel_sky, pastel_lemon +- cover: eyebrow, headline+highlight(제목), body(하단 한 줄), speech 8~12자 +- contest_table: items 3~4개 (주최·대상·접수·혜택 등), point 1줄 +- contest_checklist: items 3~4개(짧은 확인 문장), point 1줄 +- cta: headline, highlight, body 1줄, cta(버튼), speech 8~12자 (원문 URL은 렌더 시 자동 표기 — JSON에 넣지 않음) +- 문장은 짧고 큼직하게 + +[예시] +[ + { + "slide": 1, + "layout_type": "contest_cover", + "eyebrow": "대외활동", + "headline": "OO", + "highlight": "공모전", + "body": "지금 바로 확인해 보세요", + "speech": "놓치지 마!", + "use_image": false, + "template_palette": "pastel_mint" + }, + { + "slide": 2, + "layout_type": "contest_table", + "items": [ + {"label": "주최", "text": "OO재단"}, + {"label": "대상", "text": "대학생"}, + {"label": "접수", "text": "6월 30일"}, + {"label": "혜택", "text": "상금"} + ], + "point": "마감 전 꼭 확인", + "use_image": false + }, + { + "slide": 3, + "layout_type": "contest_cta", + "headline": "자세한 공고는", + "highlight": "원문에서", + "body": "링크에서 확인하세요", + "cta": "공고 보러가기", + "speech": "링크 확인!", + "use_image": false + } +] +""" + +_CONTEST_INPUT = """ +[입력] +제목: {pin_title} +주최: {host_org} +접수 시작: {event_start_time} +접수 마감: {event_end_time} +원문 URL: {source_url} +본문: +{pin_content_raw} +""" + + +def build_contest_cardnews_slide_prompt( + *, + pin_title: str, + pin_content_raw: str, + host_org: str = "", + event_start_time: str | None = None, + event_end_time: str | None = None, + source_url: str = "", +) -> str: + return CONTEST_CARDNEWS_SLIDE_PROMPT + _CONTEST_INPUT.format( + pin_title=(pin_title or "").strip(), + pin_content_raw=(pin_content_raw or "").strip(), + host_org=(host_org or "").strip(), + event_start_time=(event_start_time or "").strip() or "미상", + event_end_time=(event_end_time or "").strip() or "미상", + source_url=(source_url or "").strip(), + ) + + +CONTEST_CARDNEWS_CAPTION_PROMPT = """ +[역할] +인스타그램 카드뉴스 캡션 2~4문장 작성. + +[규칙] +- 친근한 톤, 이모지 1~2개 +- 마지막 줄에 해시태그 3~5개 +- 원문에 없는 정보 금지 +- 본문만 출력 + +[입력] +제목: {pin_title} +주최: {host_org} +원문: {source_url} +요약 본문: +{pin_content_raw} +""" + + +def build_contest_cardnews_caption_prompt( + *, + pin_title: str, + pin_content_raw: str, + host_org: str = "", + source_url: str = "", +) -> str: + return CONTEST_CARDNEWS_CAPTION_PROMPT.format( + pin_title=(pin_title or "").strip(), + pin_content_raw=(pin_content_raw or "").strip()[:2000], + host_org=(host_org or "").strip(), + source_url=(source_url or "").strip(), + ) diff --git a/app/utils/contest_images.py b/app/utils/contest_images.py new file mode 100644 index 0000000..17ebac3 --- /dev/null +++ b/app/utils/contest_images.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +from typing import Any + +from rag.scripts.fetch_visitkorea import normalize_pin_images_for_db + +ATTACHMENT_PREFIX = "https://api.linkareer.com/attachments/" +MEDIA_CDN_MARKER = "media-cdn.linkareer.com" +CONTEST_IMAGE_S3_KEY = "contest" + + +def _is_attachment_url(url: str) -> bool: + return url.startswith(ATTACHMENT_PREFIX) + + +def _is_media_cdn_url(url: str) -> bool: + return MEDIA_CDN_MARKER in url + + +def collect_contest_pin_image_specs(image_urls: list[str]) -> list[dict[str, Any]]: + """attachments 첫 URL만 is_main=True, 이후 attachments 스킵, media-cdn은 전부 is_main=False.""" + specs: list[dict[str, Any]] = [] + main_assigned = False + + for raw in image_urls: + url = str(raw or "").strip() + if not url: + continue + if _is_attachment_url(url): + if main_assigned: + continue + main_assigned = True + specs.append({"pin_image_url": url, "is_main": True}) + elif _is_media_cdn_url(url): + specs.append({"pin_image_url": url, "is_main": False}) + + return normalize_pin_images_for_db(specs) + + +def pin_images_for_db_row(row: dict[str, Any]) -> list[dict[str, Any]]: + raw_specs = row.get("pin_images") + if isinstance(raw_specs, list) and raw_specs: + return normalize_pin_images_for_db(raw_specs) + image_urls = [str(url).strip() for url in row.get("image_urls") or [] if str(url).strip()] + return collect_contest_pin_image_specs(image_urls) diff --git a/app/utils/pin_content.py b/app/utils/pin_content.py new file mode 100644 index 0000000..cdbffb3 --- /dev/null +++ b/app/utils/pin_content.py @@ -0,0 +1,22 @@ +from __future__ import annotations + + +def append_source_link_to_pin_content(body: str, source_url: str) -> str: + """가공 본문 끝에 원문 기사 URL을 붙인다 (중복 방지).""" + text = (body or "").strip() + url = (source_url or "").strip() + if not url or not text: + return text or url + + if url in text: + return text + + normalized = url.rstrip("/") + for line in text.splitlines(): + line = line.strip() + if line == url or line.rstrip("/") == normalized: + return text + if line.startswith("http") and normalized in line: + return text + + return f"{text}\n\n원문 기사: {url}" diff --git a/docs/contest_pin_handoff.md b/docs/contest_pin_handoff.md new file mode 100644 index 0000000..f8004d6 --- /dev/null +++ b/docs/contest_pin_handoff.md @@ -0,0 +1,35 @@ +# 공모전 핀 · 카드뉴스 핸드오프 + +## 흐름 + +| 순서 | API / 스크립트 | 결과 | +|------|----------------|------| +| 1 | `POST /contest-pins/crawl` | `rag/output/contest_documents.jsonl` | +| 2 | `POST /contest-pins/cardnews` | `rag/output/contest_cardnews/{contentid}/slide_XX.png` + `contest_pins_for_db.jsonl` | +| 3 | `GET /contest-pins/handoff` | DB UPSERT용 JSON 확인 | + +CLI: + +```bash +python -m rag.scripts.run_contest_cardnews --limit 1 --contentid 319419 +python scripts/preview_contest_cardnews.py # Gemini 없이 템플릿만 확인 +``` + +## 2차 저작물 (이미지) + +- Linkareer에서 가져온 **공고 이미지 URL은 카드뉴스에 넣지 않음** +- **공모전 전용** 브라우저형 Pillow 템플릿 (`app/contest_cardnews/template/`, 정책 템플릿과 분리) +- `app/assets/mascots/` **캐릭터 PNG**만 합성 +- `template_palette`: `pastel_mint` / `pastel_pink` / `pastel_lavender` / `pastel_peach` / `pastel_sky` / `pastel_lemon` (1건당 1색) + +## 핸드오프 JSONL 필드 + +| 필드 | 용도 | +|------|------| +| `contentid` | Linkareer activity ID | +| `title` | pin 제목 | +| `pin_content` | 인스타 캡션(기본) + 원문 URL | +| `cardnews_image_urls` | 슬라이드 상대 경로 | +| `source_url` | 원문 링크 | + +환경변수: `GEMINI_API_KEY` (슬라이드 문구·캡션) diff --git a/rag/scripts/fetch_linkareer_contests.py b/rag/scripts/fetch_linkareer_contests.py new file mode 100644 index 0000000..54231bd --- /dev/null +++ b/rag/scripts/fetch_linkareer_contests.py @@ -0,0 +1,535 @@ +""" +Linkareer 공모전 목록/상세 크롤링 → contest_documents.jsonl + +참고: + - https://dev-studyingblog.tistory.com/104 (목록·상세 수집, robots.txt, Selenium) + - https://github.com/software-gathering/gather-be2/blob/main/crawling/crawler.py + (상세 진입, organization-name, YYYY.MM.DD 날짜, card-image) + - https://sanseo.tistory.com/entry/공모전-크롤링-4-데이터-수집-스크래핑-링커리어 + (list/contest?page= 페이지네이션) + +실행 (프로젝트 루트): + python -m rag.scripts.fetch_linkareer_contests + python -m rag.scripts.fetch_linkareer_contests --max-pages 2 --limit 5 -v + +사전 준비: + pip install playwright + python -m playwright install chromium +""" +from __future__ import annotations + +import argparse +import asyncio +import logging +import re +import sys +from datetime import date, datetime +from pathlib import Path +from typing import Any +from zoneinfo import ZoneInfo +_SCRIPT_DIR = Path(__file__).resolve().parent +_REPO_ROOT = _SCRIPT_DIR.parent.parent +if str(_REPO_ROOT) not in sys.path: + sys.path.insert(0, str(_REPO_ROOT)) + +from rag.scripts.chunk_module import iter_jsonl, write_jsonl +from rag.scripts.preprocess_module import OUTPUT_DIR, clean_text, normalize_unicode_whitespace + +from app.utils.contest_images import collect_contest_pin_image_specs + +logger = logging.getLogger(__name__) + +DEFAULT_OUTPUT = OUTPUT_DIR / "contest_documents.jsonl" +LIST_URL = "https://linkareer.com/list/contest" +ACTIVITY_ID_RE = re.compile(r"/activity/(\d+)") +DATE_DOT_RE = re.compile(r"(\d{4})\.(\d{2})\.(\d{2})") +DATE_START_RE = re.compile(r"시작일\s*(\d{4}\.\d{2}\.\d{2})") +DATE_END_RE = re.compile(r"마감일\s*(\d{4}\.\d{2}\.\d{2})") +# 본문 접수기간 등 (시작일/마감일 라벨이 없을 때 fallback) +DATE_IN_TEXT_RE = re.compile( + r"(20\d{2})\s*[.\-/년]\s*(\d{1,2})\s*[.\-/월]?\s*(\d{1,2})", +) +UI_NOISE_LINE_RE = re.compile( + r"^(\+\d+|상세내용|더보기|공유하기|스크랩|좋아요|댓글\s*\d*)$", + re.IGNORECASE, +) + +USER_AGENT = ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36" +) + +JS_LIST_ACTIVITY_IDS = """() => [...new Set( + Array.from(document.querySelectorAll('a[href*="/activity/"]')) + .map(a => (a.href || '').match(/\\/activity\\/(\\d+)/)) + .filter(Boolean) + .map(m => m[1]) +)]""" + +JS_DETAIL = """() => { + const info = + document.querySelector('[class*="ActivityInfo-desktop"]') || + document.querySelector('.activity-info') || + document.querySelector('[class*="ActivityInfo"]'); + const infoText = info ? info.innerText : ''; + const startMatch = infoText.match(/시작일\\s*(\\d{4}\\.\\d{2}\\.\\d{2})/); + const endMatch = infoText.match(/마감일\\s*(\\d{4}\\.\\d{2}\\.\\d{2})/); + const tab = document.querySelector('[class*="ActivityDetailTabContent"]'); + let body = tab ? tab.innerText : ''; + if (body.startsWith('상세내용')) body = body.replace(/^상세내용\\s*/,'').trim(); + + const orgEl = document.querySelector('.organization-name'); + const host = orgEl ? orgEl.innerText.trim() : ''; + + const imgs = new Set(); + const og = document.querySelector('meta[property="og:image"]')?.content; + if (og && og.startsWith('http')) imgs.add(og); + const poster = document.querySelector('[class*="card-image"] img'); + if (poster?.src && poster.src.startsWith('http') && !poster.src.includes('data:image')) { + imgs.add(poster.src); + } + if (tab) { + for (const img of tab.querySelectorAll('img')) { + const src = img.currentSrc || img.src; + if (src && src.startsWith('http') && !src.includes('data:image')) imgs.add(src); + } + } + + return { + title: (document.querySelector('h1')?.innerText || '').trim(), + infoText, + startDot: startMatch ? startMatch[1] : null, + endDot: endMatch ? endMatch[1] : null, + body, + host, + images: [...imgs].filter(u => !u.includes('/static/images/new_main/icon/')), + }; +}""" + + +def parse_contentid(url: str) -> str | None: + match = ACTIVITY_ID_RE.search(url) + return match.group(1) if match else None + + +def dot_date_to_yyyymmdd(value: str | None) -> str | None: + if not value: + return None + match = DATE_DOT_RE.search(value.strip()) + if not match: + return None + y, m, d = match.groups() + return f"{y}{m}{d}" + + +def _dates_from_text_blob(text: str) -> tuple[str | None, str | None]: + found: list[str] = [] + for y, m, d in DATE_IN_TEXT_RE.findall(text): + found.append(f"{y}{int(m):02d}{int(d):02d}") + if not found: + return None, None + if len(found) == 1: + return found[0], found[0] + return found[0], found[-1] + + +def clean_contest_body(text: str, *, pin_title: str = "") -> str: + """Linkareer UI 잡음(+3, NBSP/ZWSP 등) 제거.""" + if not text: + return "" + text = normalize_unicode_whitespace(text) + lines: list[str] = [] + title_norm = pin_title.strip() + for line in text.split("\n"): + stripped = line.strip() + if not stripped: + continue + if UI_NOISE_LINE_RE.match(stripped): + continue + if title_norm and stripped == title_norm and not lines: + continue + lines.append(stripped) + return "\n".join(lines).strip() + + +def parse_period_from_info(info_text: str) -> tuple[str | None, str | None]: + start_match = DATE_START_RE.search(info_text) + end_match = DATE_END_RE.search(info_text) + start = dot_date_to_yyyymmdd(start_match.group(1)) if start_match else None + end = dot_date_to_yyyymmdd(end_match.group(1)) if end_match else None + if start or end: + return start, end + found = DATE_DOT_RE.findall(info_text.replace(" ", "")) + if not found: + return None, None + pairs = [f"{y}{m}{d}" for y, m, d in found] + if len(pairs) == 1: + return pairs[0], pairs[0] + return pairs[0], pairs[1] + + +def resolve_event_period( + *, + info_text: str, + body_text: str, + start_dot: str | None, + end_dot: str | None, +) -> tuple[str | None, str | None]: + if start_dot or end_dot: + return ( + dot_date_to_yyyymmdd(str(start_dot or "")), + dot_date_to_yyyymmdd(str(end_dot or "")), + ) + start, end = parse_period_from_info(info_text) + if start or end: + return start, end + return _dates_from_text_blob(body_text[:4000]) + + +def contest_today_kst() -> date: + return datetime.now(ZoneInfo("Asia/Seoul")).date() + + +def is_expired(end_yyyymmdd: str | None, *, today: date) -> bool: + if not end_yyyymmdd or len(end_yyyymmdd) != 8: + return False + try: + end = datetime.strptime(end_yyyymmdd, "%Y%m%d").date() + except ValueError: + return False + return end < today + + +def is_contest_row_expired(row: dict[str, Any]) -> bool: + return is_expired(row.get("event_end_time"), today=contest_today_kst()) + + +def load_existing_documents(path: Path) -> dict[str, dict[str, Any]]: + if not path.is_file(): + return {} + out: dict[str, dict[str, Any]] = {} + for row in iter_jsonl(path): + if not isinstance(row, dict): + continue + cid = str(row.get("contentid") or "").strip() + if cid: + out[cid] = row + return out + + +def normalize_source_url(contentid: str) -> str: + return f"https://linkareer.com/activity/{contentid}" + + +def normalize_contest_row(row: dict[str, Any]) -> dict[str, Any]: + """JSONL 1건 필드 정규화 (기존 파일 재크롤 없이 정리할 때 사용).""" + title = clean_text(str(row.get("pin_title") or "")) + raw = clean_contest_body( + clean_text(str(row.get("pin_content_raw") or "")), + pin_title=title, + ) + return { + **row, + "pin_title": title, + "pin_content_raw": raw, + "host_org": clean_text(str(row.get("host_org") or "")), + } + + +def build_document( + *, + contentid: str, + pin_title: str, + pin_content_raw: str, + source_url: str, + image_urls: list[str], + event_start_time: str | None, + event_end_time: str | None, + host_org: str, + crawled_at: str, + pin_images: list[dict[str, Any]] | None = None, +) -> dict[str, Any]: + specs = pin_images if pin_images is not None else collect_contest_pin_image_specs(image_urls) + return { + "contentid": contentid, + "pin_title": pin_title, + "pin_content_raw": pin_content_raw, + "source_url": source_url, + "image_urls": image_urls, + "pin_images": specs, + "event_start_time": event_start_time, + "event_end_time": event_end_time, + "host_org": host_org, + "crawled_at": crawled_at, + } + + +async def collect_list_contentids(page: Any, *, page_num: int) -> list[str]: + url = f"{LIST_URL}?page={page_num}" + await page.goto(url, wait_until="domcontentloaded", timeout=60_000) + await page.wait_for_timeout(2_500) + ids = await page.evaluate(JS_LIST_ACTIVITY_IDS) + return [str(i) for i in ids if str(i).isdigit()] + + +async def scrape_detail(page: Any, contentid: str) -> dict[str, Any] | None: + source_url = normalize_source_url(contentid) + await page.goto(source_url, wait_until="domcontentloaded", timeout=60_000) + await page.wait_for_timeout(2_000) + + try: + await page.wait_for_selector("h1", timeout=15_000) + except Exception: + logger.warning("h1 없음: %s", source_url) + return None + + payload = await page.evaluate(JS_DETAIL) + pin_title = clean_text(str(payload.get("title") or "")) + info_text = clean_text(str(payload.get("infoText") or "")) + pin_content_raw = clean_contest_body( + clean_text(str(payload.get("body") or "")), + pin_title=pin_title, + ) + host_org = clean_text(str(payload.get("host") or "")) + + if not pin_title: + logger.warning("제목 없음: %s", source_url) + return None + if not pin_content_raw: + pin_content_raw = clean_contest_body(info_text, pin_title=pin_title) or pin_title + + event_start_time, event_end_time = resolve_event_period( + info_text=info_text, + body_text=pin_content_raw, + start_dot=payload.get("startDot"), + end_dot=payload.get("endDot"), + ) + image_urls = [] + seen_img: set[str] = set() + for raw in payload.get("images") or []: + url = str(raw).strip() + if not url or url in seen_img: + continue + if "linkareer.com/_next/image" in url and "icon" in url: + continue + seen_img.add(url) + image_urls.append(url) + + return build_document( + contentid=contentid, + pin_title=pin_title, + pin_content_raw=pin_content_raw, + source_url=source_url, + image_urls=image_urls, + event_start_time=event_start_time, + event_end_time=event_end_time, + host_org=host_org, + crawled_at=datetime.now().isoformat(timespec="seconds"), + ) + + +CONTEST_DOCUMENTS_PATH = DEFAULT_OUTPUT + + +async def run_crawl( + *, + output_path: Path | None = None, + max_pages: int = 5, + start_page: int = 1, + limit: int | None = None, + delay: float = 1.0, + force: bool = False, + headed: bool = False, +) -> dict[str, Any]: + """API·서비스에서 호출하는 크롤 진입점.""" + args = argparse.Namespace( + output=output_path or DEFAULT_OUTPUT, + max_pages=max_pages, + start_page=start_page, + limit=limit, + delay=delay, + headed=headed, + force=force, + verbose=False, + ) + return await _run_impl(args) + + +async def run(args: argparse.Namespace) -> int: + stats = await _run_impl(args) + return 1 if stats.get("errors") else 0 + + +async def _run_impl(args: argparse.Namespace) -> dict[str, Any]: + from playwright.async_api import async_playwright + + output_path: Path = args.output + today = contest_today_kst() + existing_by_id = load_existing_documents(output_path) + if existing_by_id and not args.force: + logger.info("기존 JSONL %d건 — 중복 contentid skip", len(existing_by_id)) + + queued: list[str] = [] + global_seen: set[str] = set(existing_by_id.keys()) + + async with async_playwright() as playwright: + browser = await playwright.chromium.launch(headless=not args.headed) + context = await browser.new_context(user_agent=USER_AGENT, locale="ko-KR") + page = await context.new_page() + + start_page = max(1, int(getattr(args, "start_page", 1) or 1)) + end_page = start_page + max(0, args.max_pages - 1) + for page_num in range(start_page, end_page + 1): + try: + page_ids = await collect_list_contentids(page, page_num=page_num) + except Exception as exc: + logger.error("목록 page=%s 실패: %s", page_num, exc) + break + + new_on_page = 0 + for cid in page_ids: + if cid in global_seen: + continue + global_seen.add(cid) + queued.append(cid) + new_on_page += 1 + + logger.info( + "목록 page=%s: 카드 %d건, 신규 ID %d건 (누적 대기 %d건)", + page_num, + len(page_ids), + new_on_page, + len(queued), + ) + if new_on_page == 0: + logger.info("신규 ID 없음 — 목록 순회 종료") + break + + if args.delay > 0: + await asyncio.sleep(args.delay) + + if args.limit is not None: + queued = queued[: args.limit] + + new_docs: list[dict[str, Any]] = [] + skipped_expired = 0 + skipped_duplicate = 0 + errors = 0 + + for idx, contentid in enumerate(queued, start=1): + if contentid in existing_by_id and not args.force: + skipped_duplicate += 1 + continue + + logger.info("[%d/%d] 상세 수집 %s", idx, len(queued), contentid) + try: + doc = await scrape_detail(page, contentid) + except Exception as exc: + errors += 1 + logger.error("상세 실패 %s: %s", contentid, exc) + continue + + if doc is None: + errors += 1 + continue + + if is_expired(doc.get("event_end_time"), today=today): + skipped_expired += 1 + logger.info( + "마감 제외 %s (end=%s)", + contentid, + doc.get("event_end_time"), + ) + continue + + new_docs.append(doc) + existing_by_id[contentid] = doc + + if args.delay > 0: + await asyncio.sleep(args.delay) + + await browser.close() + + all_rows = [ + normalize_contest_row(existing_by_id[k]) + for k in sorted(existing_by_id.keys()) + ] + write_jsonl(output_path, all_rows) + logger.info( + "저장 완료: %s (이번 신규 %d건, 마감제외 %d, 중복skip %d, 오류 %d, 총 %d건)", + output_path, + len(new_docs), + skipped_expired, + skipped_duplicate, + errors, + len(all_rows), + ) + return { + "saved_documents_path": str(output_path), + "new_count": len(new_docs), + "skipped_expired": skipped_expired, + "skipped_duplicate": skipped_duplicate, + "errors": errors, + "total_count": len(all_rows), + } + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Linkareer 공모전 크롤링 → contest_documents.jsonl", + ) + parser.add_argument( + "--output", + type=Path, + default=DEFAULT_OUTPUT, + help=f"출력 JSONL (기본: {DEFAULT_OUTPUT})", + ) + parser.add_argument( + "--start-page", + type=int, + default=1, + help="목록 시작 페이지 번호 (기본 1)", + ) + parser.add_argument( + "--max-pages", + type=int, + default=5, + help="시작 페이지부터 순회할 페이지 수 (신규 ID 없으면 조기 종료)", + ) + parser.add_argument( + "--limit", + type=int, + default=None, + help="상세 수집 최대 건수 (테스트용)", + ) + parser.add_argument( + "--delay", + type=float, + default=1.0, + help="요청 간 대기(초)", + ) + parser.add_argument( + "--headed", + action="store_true", + help="브라우저 UI 표시", + ) + parser.add_argument( + "--force", + action="store_true", + help="이미 JSONL에 있는 contentid도 상세 재수집", + ) + parser.add_argument("-v", "--verbose", action="store_true") + return parser.parse_args() + + +def main() -> None: + args = parse_args() + logging.basicConfig( + level=logging.DEBUG if args.verbose else logging.INFO, + format="%(levelname)s %(message)s", + ) + raise SystemExit(asyncio.run(run(args))) + + +if __name__ == "__main__": + main() diff --git a/rag/scripts/normalize_contest_jsonl.py b/rag/scripts/normalize_contest_jsonl.py new file mode 100644 index 0000000..750ff58 --- /dev/null +++ b/rag/scripts/normalize_contest_jsonl.py @@ -0,0 +1,41 @@ +""" +기존 contest_documents.jsonl 의 NBSP/ZWSP·UI 잡음 정리 (재크롤 없이). + + python -m rag.scripts.normalize_contest_jsonl + python -m rag.scripts.normalize_contest_jsonl --path rag/output/contest_documents.jsonl +""" +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +_SCRIPT_DIR = Path(__file__).resolve().parent +_REPO_ROOT = _SCRIPT_DIR.parent.parent +if str(_REPO_ROOT) not in sys.path: + sys.path.insert(0, str(_REPO_ROOT)) + +from rag.scripts.chunk_module import iter_jsonl, write_jsonl +from rag.scripts.fetch_linkareer_contests import CONTEST_DOCUMENTS_PATH, normalize_contest_row + + +def main() -> None: + parser = argparse.ArgumentParser(description="contest_documents.jsonl 유니코드 공백 정리") + parser.add_argument( + "--path", + type=Path, + default=CONTEST_DOCUMENTS_PATH, + help="대상 JSONL", + ) + args = parser.parse_args() + path: Path = args.path + if not path.is_file(): + raise SystemExit(f"파일 없음: {path}") + + rows = [normalize_contest_row(row) for row in iter_jsonl(path) if isinstance(row, dict)] + write_jsonl(path, rows) + print(f"정리 완료: {path} ({len(rows)}건)") + + +if __name__ == "__main__": + main() diff --git a/rag/scripts/preprocess_module.py b/rag/scripts/preprocess_module.py index ed47eb6..00159c5 100644 --- a/rag/scripts/preprocess_module.py +++ b/rag/scripts/preprocess_module.py @@ -1,4 +1,6 @@ import json +import re +import unicodedata from pathlib import Path @@ -10,10 +12,32 @@ OUTPUT_DIR.mkdir(parents=True, exist_ok=True) +def normalize_unicode_whitespace(text: str) -> str: + """NBSP(U+00A0), ZWSP(U+200B) 등 유니코드 공백·제로폭 문자 정리.""" + if not text: + return "" + out: list[str] = [] + for ch in text: + if ch in "\n\r\t": + out.append(ch) + continue + code = ord(ch) + cat = unicodedata.category(ch) + if cat == "Zs" or 0x2000 <= code <= 0x200A or code == 0x00A0: + out.append(" ") + elif code in (0x200B, 0x200C, 0x200D, 0xFEFF): + continue + else: + out.append(ch) + collapsed = "".join(out) + return re.sub(r" +", " ", collapsed) + + def clean_text(text: str) -> str: if not text: return "" + text = normalize_unicode_whitespace(text) text = text.replace("_x000D_", "\n") text = text.replace("\r\n", "\n").replace("\r", "\n") diff --git a/rag/scripts/run_contest_cardnews.py b/rag/scripts/run_contest_cardnews.py new file mode 100644 index 0000000..f44623e --- /dev/null +++ b/rag/scripts/run_contest_cardnews.py @@ -0,0 +1,46 @@ +""" +공모전 원문 JSONL → 텍스트 카드뉴스 PNG + contest_pins_for_db.jsonl + + python -m rag.scripts.run_contest_cardnews --limit 1 + python -m rag.scripts.run_contest_cardnews --contentid 319419 --no-caption +""" +from __future__ import annotations + +import argparse +import asyncio +import sys + +from app.services.contest_pin_transform import transform_documents_jsonl + + +def main() -> int: + parser = argparse.ArgumentParser(description="공모전 카드뉴스 배치 생성") + parser.add_argument("--limit", type=int, default=None, help="최대 처리 건수") + parser.add_argument("--contentid", type=str, default=None, help="특정 activity ID만") + parser.add_argument( + "--no-caption", + action="store_true", + help="pin_content에 인스타 캡션 대신 정리된 원문 사용", + ) + parser.add_argument("--model", type=str, default=None, help="Gemini 모델명") + args = parser.parse_args() + + result = asyncio.run( + transform_documents_jsonl( + limit=args.limit, + model=args.model, + with_caption=not args.no_caption, + contentid=args.contentid, + ), + ) + print( + f"완료: {result.processed_count}건 성공, {result.error_count}건 오류 → {result.output_path}", + ) + if result.errors: + for err in result.errors: + print(f" - {err.get('contentid')}: {err.get('error')}", file=sys.stderr) + return 0 if result.processed_count else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/preview_contest_cardnews.py b/scripts/preview_contest_cardnews.py new file mode 100644 index 0000000..cf79d56 --- /dev/null +++ b/scripts/preview_contest_cardnews.py @@ -0,0 +1,58 @@ +"""Gemini 없이 공모전 3장 템플릿 미리보기 (표지·요약·CTA).""" +from __future__ import annotations + +import asyncio +from pathlib import Path + +from app.contest_cardnews import render_contest_cardnews_slides + +SAMPLE_DECK = [ + { + "slide": 1, + "layout_type": "contest_cover", + "eyebrow": "대외활동", + "headline": "귀여운", + "highlight": "카드뉴스", + "body": "다양한 홍보에 활용해 보세요", + "speech": "한번 봐!", + "use_image": False, + "template_palette": "pastel_mint", + }, + { + "slide": 2, + "layout_type": "contest_table", + "items": [ + {"label": "주최", "text": "OO재단"}, + {"label": "지원자격", "text": "대학생·대학원생"}, + {"label": "접수", "text": "6월 30일"}, + {"label": "혜택", "text": "상금 500만"}, + ], + "point": "마감 전 꼭 확인하세요", + "use_image": False, + }, + { + "slide": 3, + "layout_type": "contest_cta", + "headline": "자세한 공고는", + "highlight": "원문에서", + "body": "링크에서 확인하세요", + "cta": "공고 보러가기", + "speech": "링크 확인!", + "use_image": False, + }, +] + + +async def main() -> None: + paths = await render_contest_cardnews_slides( + contentid="_preview_3deck", + slides=SAMPLE_DECK, + output_dir=Path("rag/output/contest_cardnews"), + source_url="https://linkareer.com/activity/319419", + ) + for path in paths: + print(path) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/tests/test_contest_admin_batch.py b/tests/test_contest_admin_batch.py new file mode 100644 index 0000000..b5ddff9 --- /dev/null +++ b/tests/test_contest_admin_batch.py @@ -0,0 +1,215 @@ +from __future__ import annotations + +import tempfile +import unittest +from datetime import datetime +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch +from zoneinfo import ZoneInfo + +import app.models # noqa: F401 — SQLAlchemy mapper registry + +from app.models.enum.PinType import PinType +from app.schemas.ContestAdminDTO import ContestBatchAction +from app.services.ContestEventIngestService import ContestEventIngestService +from app.services.contest_pin_transform import ( + count_pending_transform, + count_skipped_expired_transform_rows, + list_pending_transform_rows, + parse_contest_api_id, +) +from app.services.internal.ContestPinSchedulerService import ContestPinSchedulerService +from app.utils.contest_images import collect_contest_pin_image_specs, pin_images_for_db_row + +_KST = ZoneInfo("Asia/Seoul") + + +class ContestImageSpecsTest(unittest.TestCase): + def test_collect_contest_pin_image_specs_attachment_and_media_cdn(self) -> None: + image_urls = [ + "https://api.linkareer.com/attachments/854194", + "https://api.linkareer.com/attachments/852372", + "https://media-cdn.linkareer.com//se2editor/image/852371", + ] + specs = collect_contest_pin_image_specs(image_urls) + self.assertEqual( + specs, + [ + {"pin_image_url": image_urls[0], "is_main": True}, + {"pin_image_url": image_urls[2], "is_main": False}, + ], + ) + db_specs = pin_images_for_db_row({"pin_images": specs}) + self.assertEqual(sum(1 for s in db_specs if s["is_main"]), 1) + + def test_parse_contest_api_id(self) -> None: + self.assertEqual(parse_contest_api_id({"contentid": "319419"}), 319419) + self.assertEqual(parse_contest_api_id({"contest_api_id": 42}), 42) + self.assertIsNone(parse_contest_api_id({"contentid": "abc"})) + + +class ContestTransformPendingTest(unittest.TestCase): + def test_list_pending_transform_rows_skips_db_and_handoff(self) -> None: + documents = [ + {"contentid": "1", "pin_content_raw": "a"}, + {"contentid": "2", "pin_content_raw": "b"}, + {"contentid": "3", "pin_content_raw": "c"}, + ] + handoff = { + "2": {"contentid": "2", "pin_content": "done"}, + } + pending = list_pending_transform_rows( + documents, + handoff, + db_contest_api_ids={1}, + ) + self.assertEqual([row["contentid"] for row in pending], ["3"]) + self.assertEqual( + count_pending_transform(documents, handoff, db_contest_api_ids={1}), + 1, + ) + + def test_list_pending_transform_rows_skips_expired(self) -> None: + documents = [ + {"contentid": "1", "pin_content_raw": "a", "event_end_time": "20200101"}, + {"contentid": "2", "pin_content_raw": "b", "event_end_time": "20991231"}, + ] + with patch( + "app.services.contest_pin_transform.is_contest_row_expired", + side_effect=lambda row: row.get("contentid") == "1", + ): + pending = list_pending_transform_rows(documents, {}) + self.assertEqual([row["contentid"] for row in pending], ["2"]) + self.assertEqual( + count_skipped_expired_transform_rows(documents, {}), + 1, + ) + + +def _make_ingest_service() -> ContestEventIngestService: + session = MagicMock() + session.begin_nested = MagicMock(return_value=MagicMock(__aenter__=AsyncMock(), __aexit__=AsyncMock())) + pin_repo = MagicMock() + pin_repo.session = session + pin_repo.commit = AsyncMock() + pin_repo.rollback = AsyncMock() + pin_repo.save = AsyncMock(side_effect=lambda entity, **_: setattr(entity, "pin_id", 101)) + event_pin_repo = MagicMock() + event_pin_repo.list_contest_api_ids = AsyncMock(return_value=set()) + event_pin_repo.save = AsyncMock() + community_repo = MagicMock() + community_repo.save = AsyncMock(side_effect=lambda entity, **_: setattr(entity, "community_id", 201)) + cardnews_repo = MagicMock() + cardnews_repo.save = AsyncMock() + pin_image_repo = MagicMock() + pin_image_repo.save = AsyncMock() + user_repo = MagicMock() + user_repo.get_by_user_name = AsyncMock(return_value=MagicMock(uid="admin-uid")) + return ContestEventIngestService( + pin_repo=pin_repo, + event_pin_repo=event_pin_repo, + community_repo=community_repo, + cardnews_image_s3_repo=cardnews_repo, + pin_image_repo=pin_image_repo, + user_repo=user_repo, + ) + + +class ContestEventIngestServiceTest(unittest.IsolatedAsyncioTestCase): + async def test_insert_contest_pin_uses_contest_types(self) -> None: + service = _make_ingest_service() + row = { + "title": "공모전", + "pin_content": "본문", + "event_start_time": "20260101", + "event_end_time": "20260201", + "pin_images": [ + {"pin_image_url": "https://api.linkareer.com/attachments/1", "is_main": True}, + {"pin_image_url": "https://media-cdn.linkareer.com/a", "is_main": False}, + ], + "cardnews_images": [{"key": "contest-cardnews/1/slide_01.png", "url": "https://s3.example/1.png"}], + } + pin_id = await service._insert_contest_pin(admin_uid="admin-uid", row=row, contest_api_id=319419) + self.assertEqual(pin_id, 101) + + pin_entity = service._pin_repo.save.await_args_list[0].args[0] + self.assertEqual(pin_entity.pin_type, PinType.CONTEST) + + community_entity = service._community_repo.save.await_args_list[0].args[0] + self.assertEqual(community_entity.community_type, "CONTEST") + + event_pin_entity = service._event_pin_repo.save.await_args_list[0].args[0] + self.assertEqual(event_pin_entity.contest_api_id, 319419) + + pin_image_calls = service._pin_image_repo.save.await_args_list + self.assertEqual(len(pin_image_calls), 2) + self.assertTrue(pin_image_calls[0].args[0].is_main) + self.assertFalse(pin_image_calls[1].args[0].is_main) + + cardnews_entity = service._cardnews_image_s3_repo.save.await_args_list[0].args[0] + self.assertEqual(cardnews_entity.community_id, 201) + + async def test_import_handoff_batch_skips_duplicate(self) -> None: + service = _make_ingest_service() + service._event_pin_repo.list_contest_api_ids = AsyncMock(return_value={100}) + + with tempfile.TemporaryDirectory() as tmp: + handoff_path = Path(tmp) / "contest_pins_for_db.jsonl" + handoff_path.write_text( + '{"contentid":"100","contest_api_id":100,"title":"A","pin_content":"B",' + '"event_start_time":"20260101","event_end_time":"20261231",' + '"pin_images":[],"cardnews_images":[]}\n', + encoding="utf-8", + ) + with unittest.mock.patch( + "app.services.ContestEventIngestService.CONTEST_HANDOFF_PATH", + handoff_path, + ): + result = await service.import_handoff_batch(import_all=True) + + self.assertEqual(result.inserted_count, 0) + self.assertEqual(result.skipped_duplicate_count, 1) + self.assertEqual(result.items[0].action, ContestBatchAction.SKIPPED) + + async def test_import_handoff_batch_skips_expired(self) -> None: + service = _make_ingest_service() + expired_end = (datetime.now(_KST).date().replace(year=2020)).strftime("%Y%m%d") + + with tempfile.TemporaryDirectory() as tmp: + handoff_path = Path(tmp) / "contest_pins_for_db.jsonl" + handoff_path.write_text( + f'{{"contentid":"200","contest_api_id":200,"title":"Expired","pin_content":"B",' + f'"event_start_time":"20200101","event_end_time":"{expired_end}",' + f'"pin_images":[],"cardnews_images":[]}}\n', + encoding="utf-8", + ) + with unittest.mock.patch( + "app.services.ContestEventIngestService.CONTEST_HANDOFF_PATH", + handoff_path, + ): + result = await service.import_handoff_batch(import_all=True) + + self.assertEqual(result.inserted_count, 0) + self.assertEqual(result.skipped_expired_count, 1) + self.assertEqual(result.items[0].action, ContestBatchAction.SKIPPED) + self.assertEqual(result.items[0].message, "종료일 경과") + + +class ContestPinSchedulerTest(unittest.TestCase): + def test_seconds_until_next_schedule_kst_at_noon(self) -> None: + scheduler = ContestPinSchedulerService(s3_util=MagicMock()) + now = datetime(2026, 6, 13, 10, 0, 0, tzinfo=_KST) + + with unittest.mock.patch( + "app.services.internal.ContestPinSchedulerService.datetime", + ) as mock_dt: + mock_dt.now.return_value = now + mock_dt.combine = datetime.combine + mock_dt.min = datetime.min + seconds = scheduler._seconds_until_next_schedule_kst() + + self.assertAlmostEqual(seconds, 2 * 60 * 60, delta=1.0) + + +if __name__ == "__main__": + unittest.main()