From 024c1b605831f183c1781e835b2da1891dc34548 Mon Sep 17 00:00:00 2001 From: suheon Date: Fri, 24 Apr 2026 15:00:24 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat(trend):=20=ED=8A=B8=EB=A0=8C=EB=93=9C?= =?UTF-8?q?=20=EB=B0=B0=EC=B9=98=20=EC=98=A4=EC=BC=80=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=ED=84=B0=20+=20=EC=8A=A4=EC=BC=80=EC=A4=84?= =?UTF-8?q?=EB=9F=AC=20=EA=B5=AC=ED=98=84=20(DP-386)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TrendOrchestrator: TagNormalizer→FrequencyAnalyzer→TfidfAnalyzer→TrendRanker →TopPostsSummaryGenerator+CollectionSummaryGenerator→TrendSnapshotRepository - compute_period(): daily/weekly/monthly 직전 기간 자동 계산 (weekly=월~월) - run_trend_batch.py: --unit/--period-start/--force CLI - run_trend_scheduler.py: APScheduler 3 cron jobs (daily 00:05 / weekly 월 00:10 / monthly 1일 00:15 KST) - TrendDataLoader: prev_contents 4번째 병렬 쿼리 추가 (FrequencyAnalyzer 입력용) - find_by_published_range: source_name(JOIN) + thumbnail_url 포함으로 확장 Co-Authored-By: Claude Sonnet 4.6 --- app/repositories/content_repository.py | 13 +- app/services/trend/data_loader.py | 12 +- app/services/trend/orchestrator.py | 248 +++++++++++++++++++++++++ scripts/run_trend_batch.py | 110 +++++++++++ scripts/run_trend_scheduler.py | 85 +++++++++ tests/test_trend_data_loader.py | 12 ++ tests/test_trend_orchestrator.py | 241 ++++++++++++++++++++++++ 7 files changed, 713 insertions(+), 8 deletions(-) create mode 100644 app/services/trend/orchestrator.py create mode 100644 scripts/run_trend_batch.py create mode 100644 scripts/run_trend_scheduler.py create mode 100644 tests/test_trend_orchestrator.py diff --git a/app/repositories/content_repository.py b/app/repositories/content_repository.py index 4df122e..8463ae4 100644 --- a/app/repositories/content_repository.py +++ b/app/repositories/content_repository.py @@ -221,12 +221,13 @@ def find_by_published_range(self, start: datetime, end: datetime) -> list[dict]: with self._engine.begin() as conn: result = conn.execute( text(""" - SELECT id, title, translated_title, category, tags, - source_id, published_at - FROM contents - WHERE published_at >= :start AND published_at < :end - AND is_available = true - ORDER BY published_at DESC + SELECT c.id, c.title, c.translated_title, c.category, c.tags, + cs.name AS source_name, c.thumbnail_url, c.published_at + FROM contents c + LEFT JOIN content_sources cs ON cs.id = c.source_id + WHERE c.published_at >= :start AND c.published_at < :end + AND c.is_available = true + ORDER BY c.published_at DESC """), {"start": start, "end": end}, ) diff --git a/app/services/trend/data_loader.py b/app/services/trend/data_loader.py index e1aa064..b4ea3df 100644 --- a/app/services/trend/data_loader.py +++ b/app/services/trend/data_loader.py @@ -18,6 +18,7 @@ class TrendRawData: cur_contents: list[dict] = field(default_factory=list) cur_view_counts: dict[str, int] = field(default_factory=dict) prev_view_counts: dict[str, int] = field(default_factory=dict) + prev_contents: list[dict] = field(default_factory=list) summary_meta: dict[str, dict] = field(default_factory=dict) @@ -45,10 +46,13 @@ def load(self, start: datetime, end: datetime) -> TrendRawData: delta = end - start prev_start = start - delta - with ThreadPoolExecutor(max_workers=3) as executor: + with ThreadPoolExecutor(max_workers=4) as executor: f_contents = executor.submit( self._content_repo.find_by_published_range, start, end ) + f_prev_contents = executor.submit( + self._content_repo.find_by_published_range, prev_start, start + ) f_cur_views = executor.submit( self._content_repo.find_view_counts_by_period, start, end ) @@ -57,6 +61,7 @@ def load(self, start: datetime, end: datetime) -> TrendRawData: ) cur_contents = f_contents.result() + prev_contents = f_prev_contents.result() cur_view_counts = f_cur_views.result() prev_view_counts = f_prev_views.result() @@ -68,8 +73,10 @@ def load(self, start: datetime, end: datetime) -> TrendRawData: ) logger.debug( - "TrendDataLoader.load 완료: contents=%d cur_views=%d prev_views=%d meta=%d", + "TrendDataLoader.load 완료: contents=%d prev_contents=%d" + " cur_views=%d prev_views=%d meta=%d", len(cur_contents), + len(prev_contents), len(cur_view_counts), len(prev_view_counts), len(summary_meta), @@ -79,5 +86,6 @@ def load(self, start: datetime, end: datetime) -> TrendRawData: cur_contents=cur_contents, cur_view_counts=cur_view_counts, prev_view_counts=prev_view_counts, + prev_contents=prev_contents, summary_meta=summary_meta, ) diff --git a/app/services/trend/orchestrator.py b/app/services/trend/orchestrator.py new file mode 100644 index 0000000..503bad6 --- /dev/null +++ b/app/services/trend/orchestrator.py @@ -0,0 +1,248 @@ +"""트렌드 분석 배치 오케스트레이터 (DP-386).""" + +from __future__ import annotations + +import json +import logging +from datetime import date, datetime, time, timedelta, timezone + +from app.core.exceptions import AITimeoutError, AIUpstreamError +from app.repositories.content_repository import ContentRepository +from app.repositories.summary_repository import SummaryRepository +from app.repositories.trend_repository import TrendSnapshotRepository +from app.schemas.trend import TopContent, TrendResponse +from app.services.trend.collection_summary import ( + CollectionSummaryGenerator, + TrendSignals, +) +from app.services.trend.data_loader import TrendDataLoader +from app.services.trend.frequency import FrequencyAnalyzer +from app.services.trend.normalize import TagNormalizer +from app.services.trend.ranking import TrendRanker +from app.services.trend.tfidf import TfidfAnalyzer +from app.services.trend.tokenizer import KoreanTokenizer +from app.services.trend.top_posts_summary import TopPostsSummaryGenerator + +logger = logging.getLogger(__name__) + + +def compute_period(unit: str, reference_date: date | None = None) -> tuple[date, date]: + """unit 기준 직전 기간의 (period_start, period_end) 를 반환한다. + + daily → 어제 자정 ~ 오늘 자정 + weekly → 지난 주 월요일 ~ 이번 주 월요일 (월요일 실행 기준) + monthly → 지난달 1일 ~ 이번달 1일 + """ + today = reference_date or date.today() + if unit == "daily": + return today - timedelta(days=1), today + if unit == "weekly": + monday = today - timedelta(days=today.weekday()) + return monday - timedelta(weeks=1), monday + if unit == "monthly": + first = today.replace(day=1) + prev_first = (first - timedelta(days=1)).replace(day=1) + return prev_first, first + raise ValueError(f"지원하지 않는 unit: {unit!r}") + + +def _extract_tags(contents: list[dict]) -> list[str]: + tags: list[str] = [] + for c in contents: + raw = c.get("tags") or [] + if isinstance(raw, str): + try: + raw = json.loads(raw) + except (json.JSONDecodeError, ValueError): + raw = [] + tags.extend(raw) + return tags + + +def _make_date_label(unit: str, period_start: date, period_end: date) -> str: + if unit == "daily": + return f"{period_start.month}월 {period_start.day}일" + if unit == "weekly": + return f"{period_start.month}월 {period_start.day}일 주간" + if unit == "monthly": + return f"{period_start.year}년 {period_start.month}월" + return str(period_start) + + +def _to_top_content( + content: dict, + view_count: int | None, + prev_view_counts: dict[str, int], +) -> TopContent: + tags = content.get("tags") or [] + if isinstance(tags, str): + try: + tags = json.loads(tags) + except (json.JSONDecodeError, ValueError): + tags = [] + + prev_vc = prev_view_counts.get(content["id"], 0) + cur_vc = view_count or 0 + change_rate = round((cur_vc - prev_vc) / prev_vc * 100, 1) if prev_vc > 0 else None + + return TopContent( + id=content["id"], + title=content.get("title", ""), + translated_title=content.get("translated_title"), + source_name=content.get("source_name") or "unknown", + tags=tags, + view_count=view_count, + thumbnail_url=content.get("thumbnail_url"), + category=content.get("category"), + change_rate=change_rate, + ) + + +class TrendOrchestrator: + """일/주/월 단위 트렌드 분석 배치 오케스트레이터. + + TagNormalizer → FrequencyAnalyzer → TfidfAnalyzer → TrendRanker → + TopPostsSummaryGenerator + CollectionSummaryGenerator → + TrendSnapshotRepository.upsert() + """ + + def __init__( + self, + database_url: str, + aws_region: str = "ap-northeast-2", + model: str = "global.anthropic.claude-sonnet-4-6", + ) -> None: + content_repo = ContentRepository(database_url) + summary_repo = SummaryRepository(aws_region=aws_region) + self._loader = TrendDataLoader(content_repo, summary_repo) + self._normalizer = TagNormalizer() + self._freq = FrequencyAnalyzer() + self._tokenizer = KoreanTokenizer() + self._tfidf = TfidfAnalyzer() + self._ranker = TrendRanker() + self._top_posts_gen = TopPostsSummaryGenerator( + aws_region=aws_region, + model=model, + summary_repo=summary_repo, + ) + self._collection_gen = CollectionSummaryGenerator( + aws_region=aws_region, + model=model, + ) + self._snapshot_repo = TrendSnapshotRepository(database_url) + + def run( + self, + unit: str, + period_start: date, + period_end: date, + force: bool = False, + ) -> TrendResponse: + """트렌드 분석을 실행하고 스냅샷을 저장한 뒤 TrendResponse를 반환한다. + + force=False 이고 동일 (unit, period_start) 스냅샷이 이미 존재하면 즉시 반환한다. + """ + if not force: + existing = self._snapshot_repo.get_by_period(unit, "global", period_start) + if existing is not None: + logger.info( + "이미 존재하는 스냅샷 — skip: unit=%s period_start=%s", + unit, + period_start, + ) + return existing + + start = datetime.combine(period_start, time.min) + end = datetime.combine(period_end, time.min) + + raw = self._loader.load(start, end) + + # 태그 정규화 + 빈도 집계 + cur_tags = self._normalizer.normalize(_extract_tags(raw.cur_contents)) + prev_tags = self._normalizer.normalize(_extract_tags(raw.prev_contents)) + tag_frequencies = self._freq.analyze(cur_tags, prev_tags) + + # TF-IDF 키워드 (제목 기반, 이름만 추출) + titles = [c.get("title", "") for c in raw.cur_contents] + tokenized = self._tokenizer.tokenize(titles) + tfidf_keywords = [kw for kw, _ in self._tfidf.extract(tokenized)[:15]] + + # Top 5 콘텐츠 랭킹 + top_contents_raw = self._ranker.rank_contents( + raw.cur_view_counts, raw.cur_contents + ) + top_posts = [ + _to_top_content(c, raw.cur_view_counts.get(c["id"]), raw.prev_view_counts) + for c in top_contents_raw + ] + + # 이전 기간 스냅샷 조회 (LLM 비교 서술용) + delta = end - start + prev_period_start = (start - delta).date() + prev_snapshot = self._snapshot_repo.get_by_period( + unit, "global", prev_period_start + ) + prev_top_posts_summary = ( + prev_snapshot.top_posts_summary if prev_snapshot else None + ) + prev_collection_summary = ( + prev_snapshot.collection_summary if prev_snapshot else None + ) + + # Top posts 서사 요약 — 실패 시 None (스냅샷 저장 계속) + top_posts_summary: str | None = None + try: + top_posts_summary = self._top_posts_gen.generate( + top_contents_raw, + unit, + str(period_start), + str(period_end), + prev_top_posts_summary, + ) + except (AIUpstreamError, AITimeoutError) as exc: + logger.warning("top_posts_summary 생성 실패 — skip: %s", exc) + except Exception as exc: + logger.warning("top_posts_summary 예외 — skip: %s", exc) + + # 수집 동향 서사 요약 — 내부에서 None 반환 (daily 포함) + signals = TrendSignals( + unit=unit, + period_start=str(period_start), + period_end=str(period_end), + cur_content_count=len(raw.cur_contents), + prev_content_count=len(raw.prev_contents), + top_tags=tag_frequencies, + tfidf_keywords=tfidf_keywords, + prev_summary=prev_collection_summary, + ) + collection_summary = self._collection_gen.generate(signals) + + date_label = _make_date_label(unit, period_start, period_end) + response = TrendResponse( + unit=unit, + period_start=period_start, + period_end=period_end, + date_label=date_label, + top_posts=top_posts, + top_posts_summary=top_posts_summary, + collection_summary=collection_summary, + ) + + self._snapshot_repo.upsert( + unit=unit, + scope="global", + period_start=period_start, + period_end=period_end, + payload=response, + generated_at=datetime.now(tz=timezone.utc), + ) + + logger.info( + "트렌드 배치 완료: unit=%s period=%s~%s contents=%d top_posts=%d", + unit, + period_start, + period_end, + len(raw.cur_contents), + len(top_posts), + ) + return response diff --git a/scripts/run_trend_batch.py b/scripts/run_trend_batch.py new file mode 100644 index 0000000..b1b3037 --- /dev/null +++ b/scripts/run_trend_batch.py @@ -0,0 +1,110 @@ +"""트렌드 분석 1회 실행 CLI (DP-386). + +Usage: + python scripts/run_trend_batch.py --unit weekly + python scripts/run_trend_batch.py --unit daily --period-start 2026-04-21 + python scripts/run_trend_batch.py --unit monthly --force +""" + +from __future__ import annotations + +import argparse +import logging +import os +import sys +from datetime import date, timedelta +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from dotenv import load_dotenv + +load_dotenv() + +from app.services.trend.orchestrator import TrendOrchestrator, compute_period + +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") +logger = logging.getLogger(__name__) + + +def _next_month_first(d: date) -> date: + year = d.year + (d.month // 12) + month = (d.month % 12) + 1 + return date(year, month, 1) + + +def main() -> None: + parser = argparse.ArgumentParser(description="트렌드 분석 1회 실행") + parser.add_argument( + "--unit", + required=True, + choices=["daily", "weekly", "monthly"], + help="트렌드 단위", + ) + parser.add_argument( + "--period-start", + type=date.fromisoformat, + default=None, + dest="period_start", + help="기간 시작 YYYY-MM-DD (미입력 시 자동 계산)", + ) + parser.add_argument( + "--period-end", + type=date.fromisoformat, + default=None, + dest="period_end", + help="기간 종료 YYYY-MM-DD (미입력 시 period-start + 단위 길이)", + ) + parser.add_argument( + "--force", + action="store_true", + help="기존 스냅샷이 있어도 재생성", + ) + args = parser.parse_args() + + database_url = os.environ.get("DATABASE_URL") + if not database_url: + logger.error("DATABASE_URL 환경변수를 설정하세요.") + sys.exit(1) + + if args.period_start: + period_start = args.period_start + if args.period_end: + period_end = args.period_end + elif args.unit == "daily": + period_end = period_start + timedelta(days=1) + elif args.unit == "weekly": + period_end = period_start + timedelta(weeks=1) + else: + period_end = _next_month_first(period_start) + else: + period_start, period_end = compute_period(args.unit) + + aws_region = os.environ.get("AWS_REGION", "ap-northeast-2") + logger.info( + "트렌드 배치 시작: unit=%s period=%s~%s force=%s", + args.unit, + period_start, + period_end, + args.force, + ) + + orchestrator = TrendOrchestrator(database_url=database_url, aws_region=aws_region) + result = orchestrator.run( + unit=args.unit, + period_start=period_start, + period_end=period_end, + force=args.force, + ) + + print( + f"완료: {result.date_label} | top_posts={len(result.top_posts)}" + f" | top_posts_summary={'있음' if result.top_posts_summary else '없음'}" + f" | collection_summary={'있음' if result.collection_summary else '없음'}" + ) + + +if __name__ == "__main__": + main() diff --git a/scripts/run_trend_scheduler.py b/scripts/run_trend_scheduler.py new file mode 100644 index 0000000..0645230 --- /dev/null +++ b/scripts/run_trend_scheduler.py @@ -0,0 +1,85 @@ +"""트렌드 분석 자동 실행 스케줄러 (DP-386). + +일/주/월 단위 트렌드를 KST 기준 자정 직후에 생성한다. +APScheduler BlockingScheduler — docker/서버 환경에서 장기 실행. + +Usage: + DATABASE_URL=postgresql://... python scripts/run_trend_scheduler.py + Ctrl+C 로 중단 +""" + +from __future__ import annotations + +import logging +import os +import sys +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from dotenv import load_dotenv + +load_dotenv() + +from apscheduler.schedulers.blocking import BlockingScheduler + +from app.services.trend.orchestrator import TrendOrchestrator, compute_period + +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") +logger = logging.getLogger(__name__) + +_DATABASE_URL = os.environ.get("DATABASE_URL", "") +_AWS_REGION = os.environ.get("AWS_REGION", "ap-northeast-2") + + +def _run(unit: str) -> None: + if not _DATABASE_URL: + logger.error("DATABASE_URL 환경변수 필요") + return + try: + orchestrator = TrendOrchestrator( + database_url=_DATABASE_URL, aws_region=_AWS_REGION + ) + period_start, period_end = compute_period(unit) + orchestrator.run(unit, period_start, period_end) + except Exception as exc: + logger.error("%s 트렌드 배치 실패: %s", unit, exc, exc_info=True) + + +def run_daily() -> None: + _run("daily") + + +def run_weekly() -> None: + _run("weekly") + + +def run_monthly() -> None: + _run("monthly") + + +scheduler = BlockingScheduler(timezone="Asia/Seoul") +# 매일 00:05 KST — 자정 조회수 집계 안정 후 실행 +scheduler.add_job(run_daily, "cron", hour=0, minute=5) +# 매주 월요일 00:10 KST — 직전 한 주(월~월) 분석 +scheduler.add_job(run_weekly, "cron", day_of_week="mon", hour=0, minute=10) +# 매월 1일 00:15 KST — 직전 월 분석 +scheduler.add_job(run_monthly, "cron", day=1, hour=0, minute=15) + +if __name__ == "__main__": + if not _DATABASE_URL: + print("DATABASE_URL 환경변수를 설정하세요.", file=sys.stderr) + sys.exit(1) + print( + "Trend scheduler started.\n" + " daily: 매일 00:05 KST\n" + " weekly: 매주 월요일 00:10 KST\n" + " monthly: 매월 1일 00:15 KST\n" + "Press Ctrl+C to stop." + ) + try: + scheduler.start() + except (KeyboardInterrupt, SystemExit): + print("Trend scheduler stopped.") diff --git a/tests/test_trend_data_loader.py b/tests/test_trend_data_loader.py index a66259d..0c85e31 100644 --- a/tests/test_trend_data_loader.py +++ b/tests/test_trend_data_loader.py @@ -81,6 +81,18 @@ def test_load_empty_contents_skips_summary_meta() -> None: assert result.summary_meta == {} +# ── prev_contents ──────────────────────────────────────────────────────────── + + +def test_load_returns_prev_contents() -> None: + loader = _make_loader(cur_contents=[{"id": "cid-1", "title": "현재"}]) + + result = loader.load(_START, _END) + + assert hasattr(result, "prev_contents") + assert isinstance(result.prev_contents, list) + + # ── 조회수 0건 ──────────────────────────────────────────────────────────────── diff --git a/tests/test_trend_orchestrator.py b/tests/test_trend_orchestrator.py new file mode 100644 index 0000000..07bc004 --- /dev/null +++ b/tests/test_trend_orchestrator.py @@ -0,0 +1,241 @@ +"""TrendOrchestrator 단위 테스트 — mock 기반, 실제 DB/API 호출 없음 (DP-386).""" + +from __future__ import annotations + +from datetime import date +from unittest.mock import MagicMock, patch + +import pytest + +from app.core.exceptions import AIUpstreamError +from app.schemas.trend import TrendResponse +from app.services.trend.data_loader import TrendRawData +from app.services.trend.orchestrator import TrendOrchestrator, compute_period + +# ── 헬퍼 ────────────────────────────────────────────────────────────────────── + +_PERIOD_START = date(2026, 4, 14) +_PERIOD_END = date(2026, 4, 21) + +_FAKE_CONTENT = { + "id": "cid-1", + "title": "Kubernetes 배포 전략", + "translated_title": None, + "tags": ["Kubernetes", "Docker"], + "source_name": "Velog", + "thumbnail_url": None, + "category": "Backend", +} + + +def _make_trend_response(unit: str = "weekly") -> TrendResponse: + return TrendResponse( + unit=unit, + period_start=_PERIOD_START, + period_end=_PERIOD_END, + date_label="4월 14일 주간", + top_posts=[], + top_posts_summary=None, + collection_summary=None, + ) + + +def _make_orchestrator() -> TrendOrchestrator: + """모든 외부 의존성을 mock 처리한 TrendOrchestrator를 반환한다.""" + with ( + patch("app.services.trend.orchestrator.ContentRepository"), + patch("app.services.trend.orchestrator.SummaryRepository"), + patch("app.services.trend.orchestrator.TrendSnapshotRepository"), + patch("app.services.trend.orchestrator.TrendDataLoader"), + patch("app.services.trend.orchestrator.KoreanTokenizer"), + patch("app.services.trend.orchestrator.TopPostsSummaryGenerator"), + patch("app.services.trend.orchestrator.CollectionSummaryGenerator"), + ): + orch = TrendOrchestrator("postgresql://test", aws_region="us-east-1") + + orch._loader = MagicMock() + orch._normalizer = MagicMock() + orch._freq = MagicMock() + orch._tokenizer = MagicMock() + orch._tfidf = MagicMock() + orch._ranker = MagicMock() + orch._top_posts_gen = MagicMock() + orch._collection_gen = MagicMock() + orch._snapshot_repo = MagicMock() + return orch + + +def _setup_defaults(orch: TrendOrchestrator) -> None: + """기본 mock 반환값 — 정상 흐름.""" + orch._snapshot_repo.get_by_period.return_value = None + orch._loader.load.return_value = TrendRawData( + cur_contents=[_FAKE_CONTENT], + cur_view_counts={"cid-1": 10}, + prev_view_counts={"cid-1": 5}, + prev_contents=[], + ) + orch._normalizer.normalize.side_effect = lambda tags: tags + orch._freq.analyze.return_value = [] + orch._tokenizer.tokenize.return_value = ["kubernetes 배포"] + orch._tfidf.extract.return_value = [("kubernetes", 0.5)] + orch._ranker.rank_contents.return_value = [_FAKE_CONTENT] + orch._top_posts_gen.generate.return_value = "Top posts 요약" + orch._collection_gen.generate.return_value = "Collection 요약" + + +# ── 정상 흐름 ────────────────────────────────────────────────────────────────── + + +def test_run_returns_trend_response() -> None: + orch = _make_orchestrator() + _setup_defaults(orch) + + result = orch.run("weekly", _PERIOD_START, _PERIOD_END) + + assert isinstance(result, TrendResponse) + assert result.unit == "weekly" + assert result.period_start == _PERIOD_START + assert result.period_end == _PERIOD_END + + +def test_run_saves_snapshot() -> None: + orch = _make_orchestrator() + _setup_defaults(orch) + + orch.run("weekly", _PERIOD_START, _PERIOD_END) + + orch._snapshot_repo.upsert.assert_called_once() + + +def test_run_top_posts_populated() -> None: + orch = _make_orchestrator() + _setup_defaults(orch) + + result = orch.run("weekly", _PERIOD_START, _PERIOD_END) + + assert len(result.top_posts) == 1 + assert result.top_posts[0].id == "cid-1" + assert result.top_posts[0].source_name == "Velog" + + +# ── 스냅샷 skip ──────────────────────────────────────────────────────────────── + + +def test_run_skips_if_snapshot_exists() -> None: + orch = _make_orchestrator() + existing = _make_trend_response() + orch._snapshot_repo.get_by_period.return_value = existing + + result = orch.run("weekly", _PERIOD_START, _PERIOD_END, force=False) + + assert result == existing + orch._loader.load.assert_not_called() + + +def test_run_force_regenerates_when_snapshot_exists() -> None: + orch = _make_orchestrator() + orch._snapshot_repo.get_by_period.return_value = None + _setup_defaults(orch) + + orch.run("weekly", _PERIOD_START, _PERIOD_END, force=True) + + orch._loader.load.assert_called_once() + orch._snapshot_repo.upsert.assert_called_once() + + +# ── LLM 실패 격리 ────────────────────────────────────────────────────────────── + + +def test_run_top_posts_summary_failure_still_saves_snapshot() -> None: + orch = _make_orchestrator() + _setup_defaults(orch) + orch._top_posts_gen.generate.side_effect = AIUpstreamError() + + result = orch.run("weekly", _PERIOD_START, _PERIOD_END) + + assert result.top_posts_summary is None + orch._snapshot_repo.upsert.assert_called_once() + + +# ── date_label ───────────────────────────────────────────────────────────────── + + +def test_run_weekly_date_label() -> None: + orch = _make_orchestrator() + _setup_defaults(orch) + + result = orch.run("weekly", _PERIOD_START, _PERIOD_END) + + assert "주간" in result.date_label + + +def test_run_monthly_date_label() -> None: + orch = _make_orchestrator() + _setup_defaults(orch) + + result = orch.run("monthly", date(2026, 3, 1), date(2026, 4, 1)) + + assert "년" in result.date_label + assert "월" in result.date_label + + +def test_run_daily_date_label() -> None: + orch = _make_orchestrator() + _setup_defaults(orch) + + result = orch.run("daily", date(2026, 4, 23), date(2026, 4, 24)) + + assert "4월" in result.date_label + assert "주간" not in result.date_label + + +# ── 빈 콘텐츠 ────────────────────────────────────────────────────────────────── + + +def test_run_empty_contents_returns_empty_top_posts() -> None: + orch = _make_orchestrator() + _setup_defaults(orch) + orch._loader.load.return_value = TrendRawData( + cur_contents=[], + cur_view_counts={}, + prev_view_counts={}, + prev_contents=[], + ) + orch._ranker.rank_contents.return_value = [] + + result = orch.run("weekly", _PERIOD_START, _PERIOD_END) + + assert result.top_posts == [] + orch._snapshot_repo.upsert.assert_called_once() + + +# ── compute_period ───────────────────────────────────────────────────────────── + + +def test_compute_period_daily() -> None: + ref = date(2026, 4, 24) + start, end = compute_period("daily", ref) + + assert start == date(2026, 4, 23) + assert end == date(2026, 4, 24) + + +def test_compute_period_weekly_on_monday() -> None: + ref = date(2026, 4, 20) # 월요일 + start, end = compute_period("weekly", ref) + + assert end == date(2026, 4, 20) + assert start == date(2026, 4, 13) + + +def test_compute_period_monthly() -> None: + ref = date(2026, 4, 24) + start, end = compute_period("monthly", ref) + + assert start == date(2026, 3, 1) + assert end == date(2026, 4, 1) + + +def test_compute_period_invalid_unit() -> None: + with pytest.raises(ValueError): + compute_period("quarterly") From 5f4d232f21e49840f945b8788467ab519b80d283 Mon Sep 17 00:00:00 2001 From: suheon Date: Fri, 24 Apr 2026 15:02:22 +0900 Subject: [PATCH 2/3] =?UTF-8?q?docs:=20CLAUDE.md=20=EC=97=85=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8=20=E2=80=94=20DP-386=20=ED=8A=B8=EB=A0=8C?= =?UTF-8?q?=EB=93=9C=20=EB=B0=B0=EC=B9=98=20=EC=98=A4=EC=BC=80=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=EB=A0=88=EC=9D=B4=ED=84=B0=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CLAUDE.md(root): 현재 상태 + 핵심 책임 + 자주 하는 작업에 트렌드 배치 추가 - scripts/CLAUDE.md: run_trend_batch.py, run_trend_scheduler.py 사용법 추가 - app/services/CLAUDE.md: trend/ 서비스 목록 전체 추가 - tests/CLAUDE.md: 트렌드 관련 테스트 파일 목록 추가 Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 13 +++++++++++++ app/services/CLAUDE.md | 9 +++++++++ scripts/CLAUDE.md | 31 +++++++++++++++++++++++++++++++ tests/CLAUDE.md | 4 ++++ 4 files changed, 57 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 11ef74a..7209907 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,6 +41,11 @@ - **퀴즈 레벨별 출제 방향 차별화 + 누락 레벨 재시도 로직 추가 (DP-265)** - **주간 인사이트 키워드 분석 강화: UserRepository(user_tags+content_tags) + 미탐색 태그 기반 추천 글 + Sonnet 전환 (DP-254)** - **영어 제목 번역 후 translated_title을 DynamoDB ai_summaries + PostgreSQL contents에 저장 (DP-328)** + - **태그 정규화(TagNormalizer) + 빈도 집계(FrequencyAnalyzer) + TF-IDF/tokenizer 구현 (DP-380, DP-381)** + - **트렌드 랭킹 TrendRanker + TrendDataLoader + TrendSnapshotRepository 구현 (DP-378, DP-379, DP-383)** + - **Top 5 콘텐츠 LLM 서사 요약 TopPostsSummaryGenerator 구현 (DP-404)** + - **수집 동향 LLM 서사 요약 CollectionSummaryGenerator + TrendSignals 구현 (DP-384)** + - **트렌드 배치 오케스트레이터 TrendOrchestrator + run_trend_batch.py + run_trend_scheduler.py 구현 (DP-386)** --- @@ -67,6 +72,7 @@ 2. **PostgreSQL 직접 저장** — `ContentRepository` (Backend push 없음) 3. **AI 요약 + 퀴즈 자동 생성** — `ContentPipeline`: 저장 직후 4레벨 요약 + 4레벨 퀴즈 생성 → DynamoDB. 요약 성공 시 tags·category → PostgreSQL UPDATE 4. **AI 질문/답변/리포트** — RefineService, AnswerService, InsightService +5. **트렌드 분석 배치** — `TrendOrchestrator`: 일/주/월 단위 태그 빈도·TF-IDF·LLM 서사 요약 → `trend_snapshots` PostgreSQL 저장 5. **출력 JSON 스키마 검증 + 파싱 실패 대응** 6. **캐시/저장/로그 기록** @@ -179,6 +185,13 @@ DATABASE_URL=postgresql://... python scripts/reprocess_quiz.py # DynamoDB → PostgreSQL tags/category 동기화 DATABASE_URL=postgresql://... python scripts/sync_ai_metadata.py +# 트렌드 분석 1회 실행 (unit: daily/weekly/monthly) +DATABASE_URL=postgresql://... python scripts/run_trend_batch.py --unit weekly +DATABASE_URL=postgresql://... python scripts/run_trend_batch.py --unit daily --force + +# 트렌드 스케줄러 (daily 00:05 / weekly 월 00:10 / monthly 1일 00:15 KST) +DATABASE_URL=postgresql://... python scripts/run_trend_scheduler.py + # 테스트 pytest -q ``` diff --git a/app/services/CLAUDE.md b/app/services/CLAUDE.md index 6709cfa..a6b1d1a 100644 --- a/app/services/CLAUDE.md +++ b/app/services/CLAUDE.md @@ -20,6 +20,15 @@ | `question_embedding_service.py` | `QuestionEmbeddingOrchestrator` | 질문 임베딩 → DynamoDB rag_questions + FAISS questions 저장 (DP-234) | | `similar_question_service.py` | `SimilarQuestionService` | FAISS questions 인덱스 유사 질문 검색 (DP-235) | | `insight_service.py` | `InsightService` | Bedrock Tool Use 기반 주간 학습 인사이트 생성 (DP-259) | +| `trend/normalize.py` | `TagNormalizer` | rapidfuzz 기반 태그 동의어 정규화 (DP-380) | +| `trend/frequency.py` | `FrequencyAnalyzer` | 태그 빈도 집계 + 증감 상태 판정 (DP-380) | +| `trend/tokenizer.py` | `KoreanTokenizer` | kiwipiepy 형태소 분석 기반 토크나이저 (DP-381) | +| `trend/tfidf.py` | `TfidfAnalyzer` | scikit-learn TF-IDF 키워드 추출 (DP-381) | +| `trend/ranking.py` | `TrendRanker` | 조회수 기반 Top 5 콘텐츠 + 복합 점수 Top 10 태그 선정 (DP-383) | +| `trend/data_loader.py` | `TrendDataLoader` | cur/prev 기간 데이터 병렬 로드 (DP-379) | +| `trend/top_posts_summary.py` | `TopPostsSummaryGenerator` | Top 5 콘텐츠 주제 흐름 LLM 서사 요약 (DP-404) | +| `trend/collection_summary.py` | `CollectionSummaryGenerator` | 수집 동향 LLM 서사 요약 + `TrendSignals` 데이터클래스 (DP-384) | +| `trend/orchestrator.py` | `TrendOrchestrator` | 일/주/월 트렌드 배치 오케스트레이터 + `compute_period()` (DP-386) | --- diff --git a/scripts/CLAUDE.md b/scripts/CLAUDE.md index 8352082..95c0883 100644 --- a/scripts/CLAUDE.md +++ b/scripts/CLAUDE.md @@ -36,6 +36,8 @@ | `run_backfill_batch.py` | 1회 수집 실행 — PostgreSQL 직접 저장 + AI 처리(요약·퀴즈·임베딩) | | `run_scheduler.py` | 6시간 간격 자동 반복 실행 (APScheduler 기반) | | `run_collect_and_save.py` | 수집 → 로컬 JSONL 저장 전용 (AI 처리 없음, 개발·디버그용) | +| `run_trend_batch.py` | 트렌드 분석 1회 실행 CLI — `--unit daily/weekly/monthly`, `--force` (DP-386) | +| `run_trend_scheduler.py` | 트렌드 분석 자동 실행 스케줄러 — daily/weekly/monthly cron (DP-386) | | `init_postgres.py` | PostgreSQL UNIQUE 인덱스 초기화 — 배포 시 1회 실행 (멱등성 보장) | | `init_vectors.py` | FAISS 벡터 디렉터리 초기화 (Bedrock Titan v2 기반) | | `reindex_vectors.py` | FAISS 인덱스 재빌드 — DynamoDB rag_documents 기반 (인덱스 유실 시) | @@ -76,6 +78,35 @@ python scripts/run_collect_and_save.py - 출력: `data/raw/normalized/{source_name}.jsonl` (append) +### `run_trend_batch.py` (DP-386) + +트렌드 분석 1회 실행 CLI. + +```bash +# period-start 미입력 시 단위별 직전 기간 자동 계산 +DATABASE_URL=postgresql://... python scripts/run_trend_batch.py --unit weekly +DATABASE_URL=postgresql://... python scripts/run_trend_batch.py --unit daily --period-start 2026-04-21 +DATABASE_URL=postgresql://... python scripts/run_trend_batch.py --unit monthly --force +``` + +- `--unit`: `daily` / `weekly` / `monthly` +- `--period-start`: 기간 시작 (YYYY-MM-DD). 미입력 시 자동 계산 +- `--force`: 기존 스냅샷이 있어도 재생성 + +### `run_trend_scheduler.py` (DP-386) + +트렌드 분석 자동 실행 스케줄러. KST 기준 자정 직후에 실행한다. + +```bash +DATABASE_URL=postgresql://... python scripts/run_trend_scheduler.py +# Ctrl+C로 중단 +``` + +- `daily`: 매일 00:05 KST +- `weekly`: 매주 월요일 00:10 KST (직전 한 주 월~월) +- `monthly`: 매월 1일 00:15 KST (직전 달) +- 각 job 독립 try/except — 개별 실패가 스케줄러 전체를 중단하지 않는다 + ### `inspect_preprocess.py` (DP-216) 전처리 출력을 확인하는 디버그용 스크립트다. diff --git a/tests/CLAUDE.md b/tests/CLAUDE.md index 8cd8f6b..075e5da 100644 --- a/tests/CLAUDE.md +++ b/tests/CLAUDE.md @@ -45,6 +45,10 @@ | `test_insight_endpoint.py` | `POST /internal/report` | 주간 리포트 엔드포인트 통합 테스트 (DP-259) | | `test_insight_prompt.py` | `build_user_prompt()` | 인사이트 프롬프트 빌더 단위 테스트 | | `test_rss_collector.py` | `xml_helpers` | xml_helpers 유틸 함수 테스트 (`compute_entry_hash`, `sha256_text`, `normalize_date` 등) — 파일명은 레거시 | +| `test_trend_data_loader.py` | `TrendDataLoader` | cur/prev 기간 병렬 로드, prev_contents 포함 확인 (DP-379, DP-386) | +| `test_trend_collection_summary.py` | `CollectionSummaryGenerator` | 수집 동향 LLM 서사 요약 mock 테스트 (DP-384) | +| `test_trend_top_posts_summary.py` | `TopPostsSummaryGenerator` | Top 5 콘텐츠 LLM 서사 요약 mock 테스트 (DP-404) | +| `test_trend_orchestrator.py` | `TrendOrchestrator` | 배치 오케스트레이터 통합 흐름, skip/force/LLM 실패 격리 (DP-386) | --- From 2593331ce584d2177ee3172fecbfbcd8031c485e Mon Sep 17 00:00:00 2001 From: suheon Date: Fri, 24 Apr 2026 15:04:41 +0900 Subject: [PATCH 3/3] =?UTF-8?q?docs:=20trend=20=EA=B4=80=EB=A0=A8=20MD=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EC=83=9D=EC=84=B1/=EC=97=85=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8=20(DP-386)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - app/services/trend/CLAUDE.md: 신규 생성 — 컴포넌트 구성, 전체 흐름, 데이터 계약 문서화 - app/core/CLAUDE.md: trend_top_posts.py, trend_collection.py 프롬프트 추가 - app/repositories/CLAUDE.md: TrendSnapshotRepository 추가 - README.md: 트렌드 배치 파이프라인 섹션 + 스크립트 목록 + 실행 명령 추가 Co-Authored-By: Claude Sonnet 4.6 --- README.md | 28 +++++++++++ app/core/CLAUDE.md | 2 + app/repositories/CLAUDE.md | 1 + app/services/trend/CLAUDE.md | 97 ++++++++++++++++++++++++++++++++++++ 4 files changed, 128 insertions(+) create mode 100644 app/services/trend/CLAUDE.md diff --git a/README.md b/README.md index 1dbda80..34b44d3 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,8 @@ ContentPipeline.process_content() | `scripts/run_backfill_batch.py` | 1회 수집 실행 — PostgreSQL 저장 + AI 처리 (요약+퀴즈+임베딩) | | `scripts/run_scheduler.py` | 6시간 간격 자동 반복 실행 | | `scripts/run_collect_and_save.py` | 로컬 JSONL 저장 전용 (AI 처리 없음, 개발용) | +| `scripts/run_trend_batch.py` | 트렌드 분석 1회 실행 (`--unit daily/weekly/monthly`, `--force`) | +| `scripts/run_trend_scheduler.py` | 트렌드 분석 자동 실행 (daily 00:05 / weekly 월 00:10 / monthly 1일 00:15 KST) | | `scripts/init_postgres.py` | PostgreSQL UNIQUE 인덱스 초기화 (배포 시 1회) | | `scripts/init_vectors.py` | FAISS 인덱스 초기화 | | `scripts/reindex_vectors.py` | FAISS 인덱스 재빌드 (인덱스 유실 시) | @@ -129,6 +131,13 @@ DATABASE_URL=postgresql://... python scripts/run_backfill_batch.py # 스케줄러 (6시간 간격 자동 반복) DATABASE_URL=postgresql://... python scripts/run_scheduler.py +# 트렌드 분석 1회 실행 +DATABASE_URL=postgresql://... python scripts/run_trend_batch.py --unit weekly +DATABASE_URL=postgresql://... python scripts/run_trend_batch.py --unit daily --force + +# 트렌드 분석 스케줄러 (daily/weekly/monthly 자동 실행) +DATABASE_URL=postgresql://... python scripts/run_trend_scheduler.py + # 개발 서버 uvicorn main:app --reload @@ -136,6 +145,25 @@ uvicorn main:app --reload python scripts/reindex_vectors.py ``` +## 트렌드 분석 배치 파이프라인 + +AI 서버가 일/주/월 단위로 태그 빈도·TF-IDF·LLM 서사 요약을 생성하고 PostgreSQL `trend_snapshots`에 저장한다. Backend는 이 테이블을 읽어 Frontend에 전달한다. + +``` +TrendOrchestrator.run(unit, period_start, period_end) + ├─ TrendDataLoader — cur/prev 콘텐츠 + 조회수 4개 병렬 쿼리 + ├─ TagNormalizer — rapidfuzz 동의어 정규화 + ├─ FrequencyAnalyzer — 태그 빈도 집계 + 증감 상태 (new/up/down/same) + ├─ KoreanTokenizer + TfidfAnalyzer — TF-IDF 키워드 추출 (제목 기반) + ├─ TrendRanker — 조회수 Top 5 콘텐츠 선정 + ├─ TopPostsSummaryGenerator — Top 5 콘텐츠 LLM 서사 요약 (Bedrock) + ├─ CollectionSummaryGenerator — 수집 동향 LLM 서사 요약 (Bedrock, weekly/monthly만) + └─ TrendSnapshotRepository — trend_snapshots upsert +``` + +- `force=False`이고 동일 기간 스냅샷이 이미 있으면 재생성 없이 즉시 반환 +- LLM 실패 시 해당 요약 필드만 `null`로 저장, 스냅샷 저장은 계속 진행 + ## DynamoDB 테이블 목록 | 테이블 | 내용 | diff --git a/app/core/CLAUDE.md b/app/core/CLAUDE.md index 5482aae..7c26b00 100644 --- a/app/core/CLAUDE.md +++ b/app/core/CLAUDE.md @@ -52,6 +52,8 @@ Anthropic Tool Use 스키마를 Bedrock Converse API `toolConfig` 형식으로 | `refine.py` | `SYSTEM_PROMPT`, `REFINE_TOOL`, `build_user_prompt()` — 레벨별 질문 개선 + 컨텍스트 청크 | | `answer.py` | `SYSTEM_PROMPT`, `ANSWER_TOOL`, `build_user_prompt()` — 아티클+RAG 컨텍스트 기반 답변 | | `insight.py` | `SYSTEM_PROMPT`, `INSIGHT_TOOL`, `build_user_prompt()` — 주간 활동/읽은글/스크랩/질문 기반 인사이트 | +| `trend_top_posts.py` | `SYSTEM_PROMPT`, `TOOL_SAVE_TOP_POSTS_SUMMARY`, `build_user_prompt()` — Top 5 콘텐츠 주제 흐름 서사 요약 (DP-404) | +| `trend_collection.py` | `SYSTEM_PROMPT`, `TOOL_SAVE_COLLECTION_SUMMARY`, `build_user_prompt()` — 수집 동향 서사 요약 (DP-384) | ### quiz.py 구성 요소 (DP-265) diff --git a/app/repositories/CLAUDE.md b/app/repositories/CLAUDE.md index a026fc5..af0391b 100644 --- a/app/repositories/CLAUDE.md +++ b/app/repositories/CLAUDE.md @@ -18,6 +18,7 @@ DynamoDB + PostgreSQL 접근 레이어. 각 도메인별 저장/조회 로직을 | `question_vector_repository.py` | `QuestionVectorRepository` | DynamoDB | `rag_questions` | 질문 임베딩 upsert 저장 (DP-234) | | `event_repository.py` | `EventRepository` | DynamoDB | `event_logs` | AI 처리 이벤트 로그 + 일별 dedup (DP-252) | | `insight_repository.py` | `InsightRepository` | DynamoDB | `weekly_report_insights` | 주간 인사이트 upsert 저장 (DP-259) | +| `trend_repository.py` | `TrendSnapshotRepository` | PostgreSQL | `trend_snapshots` | 트렌드 분석 결과 upsert/조회 (DP-378, DP-386) | --- diff --git a/app/services/trend/CLAUDE.md b/app/services/trend/CLAUDE.md new file mode 100644 index 0000000..3e79e76 --- /dev/null +++ b/app/services/trend/CLAUDE.md @@ -0,0 +1,97 @@ +# CLAUDE.md — app/services/trend/ + +트렌드 분석 파이프라인 서비스 모음. 태그 정규화 → 빈도 집계 → TF-IDF → 랭킹 → LLM 서사 요약 → 스냅샷 저장까지의 전 흐름을 담당한다. + +--- + +## 파일 구성 + +| 파일 | 클래스/함수 | 역할 | +|------|------------|------| +| `normalize.py` | `TagNormalizer` | rapidfuzz ratio 기반 태그 동의어 정규화 (DP-380) | +| `frequency.py` | `FrequencyAnalyzer`, `TagFrequency` | cur/prev 태그 빈도 집계 + 증감 상태 판정 (DP-380) | +| `tokenizer.py` | `KoreanTokenizer` | kiwipiepy NNG/NNP/SL POS 필터 기반 한국어 토크나이저 (DP-381) | +| `tfidf.py` | `TfidfAnalyzer` | scikit-learn TfidfVectorizer 기반 상위 키워드 추출 (DP-381) | +| `ranking.py` | `TrendRanker`, `RankedTag` | 기간 조회수 Top 5 콘텐츠 + 복합 점수 Top 10 태그 선정 (DP-383) | +| `data_loader.py` | `TrendDataLoader`, `TrendRawData` | cur/prev 기간 4개 쿼리 병렬 로드 (DP-379, DP-386) | +| `top_posts_summary.py` | `TopPostsSummaryGenerator` | Top 5 콘텐츠 주제 흐름 LLM 서사 요약 (DP-404) | +| `collection_summary.py` | `CollectionSummaryGenerator`, `TrendSignals` | 수집 동향 LLM 서사 요약 (DP-384) | +| `orchestrator.py` | `TrendOrchestrator`, `compute_period` | 배치 오케스트레이터 — 위 컴포넌트를 순서대로 조합 (DP-386) | + +--- + +## 트렌드 배치 전체 흐름 + +``` +compute_period(unit) → (period_start, period_end) + ↓ +TrendDataLoader.load(start, end) + ├─ find_by_published_range(start, end) → cur_contents (source_name, thumbnail_url 포함) + ├─ find_by_published_range(prev_start, start) → prev_contents + ├─ find_view_counts_by_period(start, end) → cur_view_counts + └─ find_view_counts_by_period(prev_start, start) → prev_view_counts + ↓ +TagNormalizer.normalize(cur_tags, prev_tags) + ↓ +FrequencyAnalyzer.analyze(cur_tags, prev_tags) → list[TagFrequency] + ↓ +KoreanTokenizer.tokenize(titles) → TfidfAnalyzer.extract() → tfidf_keywords[:15] + ↓ +TrendRanker.rank_contents(cur_view_counts, cur_contents) → top_contents_raw + ↓ +TopPostsSummaryGenerator.generate(top_contents_raw, unit, ..., prev_summary) → top_posts_summary +CollectionSummaryGenerator.generate(TrendSignals(...)) → collection_summary + ↓ +TrendResponse 조립 → TrendSnapshotRepository.upsert() +``` + +--- + +## TagFrequency / TrendSignals 데이터 계약 + +### TagFrequency (frequency.py) +```python +@dataclass +class TagFrequency: + keyword: str + cur_count: int + prev_count: int + delta: int + growth_rate: float | None # None = state="new" + state: str # "new" | "up" | "down" | "same" +``` + +### TrendSignals (collection_summary.py) +```python +@dataclass +class TrendSignals: + unit: str # "daily" | "weekly" | "monthly" + period_start: str # ISO 날짜 문자열 + period_end: str + cur_content_count: int + prev_content_count: int = 0 + top_tags: list[TagFrequency] = field(default_factory=list) + tfidf_keywords: list[str] = field(default_factory=list) + prev_summary: str | None = None # 이전 기간 collection_summary +``` + +--- + +## LLM 실패 처리 정책 + +| 서비스 | 실패 시 동작 | +|--------|------------| +| `TopPostsSummaryGenerator` | `AIUpstreamError` / `AITimeoutError` 전파 → 오케스트레이터에서 catch → `top_posts_summary=None` | +| `CollectionSummaryGenerator` | 내부 catch → `None` 반환 (daily 포함) | + +두 경우 모두 스냅샷 저장은 계속 진행된다. + +--- + +## compute_period 단위별 로직 + +| unit | 기준 | period_start | period_end | +|------|------|-------------|------------| +| daily | 어제 자정~오늘 자정 | `today - 1일` | `today` | +| weekly | 이전 주 월~월 | `이번 주 월요일 - 7일` | `이번 주 월요일` | +| monthly | 이전 달 1일~이번 달 1일 | `지난달 1일` | `이번달 1일` |