"""Morning Briefing API — read-only + 수동 regenerate. 엔드포인트: - GET /api/briefing/latest : 가장 최근 briefing - GET /api/briefing?date=YYYY-MM-DD : 특정 날짜 briefing - POST /api/briefing/regenerate?date=... : 동기 워커 트리거 (admin), DELETE+INSERT tx 응답은 topic 평면 list (axis 반대 — Phase 4 와 달리 country 그룹 X). 각 topic 안에 country_perspectives JSONB 가 들어있어 cross-country 비교 분석을 표현. """ from datetime import date as date_type from datetime import datetime from typing import Annotated from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from core.auth import get_current_user, require_admin from core.database import get_session from models.briefing import BriefingTopic, MorningBriefing from models.user import User router = APIRouter() # ─── Pydantic 응답 모델 ─── class CountryPerspective(BaseModel): country: str summary: str article_ids: list[int] = [] class KeyQuote(BaseModel): country: str = "" source: str = "" quote: str class TopicResponse(BaseModel): id: int # 2026-05-13 카드 액션 (read/highlight) 호출용 식별자 topic_rank: int topic_label: str headline: str country_perspectives: list[CountryPerspective] divergences: list[str] convergences: list[str] key_quotes: list[KeyQuote] historical_context: str | None = None cluster_members: list[int] = [] article_count: int country_count: int importance_score: float llm_fallback_used: bool # 2026-05-13 사용자 액션 — UI 의 카드별 토글 is_read: bool = False read_at: datetime | None = None highlighted: bool = False highlighted_at: datetime | None = None class BriefingResponse(BaseModel): briefing_date: date_type window_start: datetime window_end: datetime decay_lambda: float total_articles: int total_countries: int total_topics: int generation_ms: int | None llm_calls: int llm_failures: int status: str headline_oneliner: str | None = None topics: list[TopicResponse] class RegenerateResponse(BaseModel): status: str briefing_id: int | None briefing_date: date_type total_topics: int total_articles: int llm_calls: int llm_failures: int generation_ms: int regenerated: bool # ─── helpers ─── def _build_response(b: MorningBriefing) -> BriefingResponse: topics = [] for t in sorted(b.topics, key=lambda x: x.topic_rank): topics.append( TopicResponse( id=t.id, topic_rank=t.topic_rank, topic_label=t.topic_label, headline=t.headline, country_perspectives=[ CountryPerspective(**cp) for cp in (t.country_perspectives or []) ], divergences=list(t.divergences or []), convergences=list(t.convergences or []), key_quotes=[KeyQuote(**q) for q in (t.key_quotes or [])], historical_context=t.historical_context, cluster_members=list(t.cluster_members or []), article_count=t.article_count, country_count=t.country_count, importance_score=t.importance_score, llm_fallback_used=t.llm_fallback_used, is_read=t.is_read, read_at=t.read_at, highlighted=t.highlighted, highlighted_at=t.highlighted_at, ) ) return BriefingResponse( briefing_date=b.briefing_date, window_start=b.window_start, window_end=b.window_end, decay_lambda=b.decay_lambda, total_articles=b.total_articles, total_countries=b.total_countries, total_topics=b.total_topics, generation_ms=b.generation_ms, llm_calls=b.llm_calls, llm_failures=b.llm_failures, status=b.status, headline_oneliner=b.headline_oneliner, topics=topics, ) async def _load_briefing( session: AsyncSession, target_date: date_type | None, ) -> MorningBriefing | None: query = select(MorningBriefing).options(selectinload(MorningBriefing.topics)) if target_date is not None: query = query.where(MorningBriefing.briefing_date == target_date) else: query = query.order_by(MorningBriefing.briefing_date.desc()) query = query.limit(1) result = await session.execute(query) return result.scalar_one_or_none() # ─── Routes ─── @router.get("/latest", response_model=BriefingResponse) async def get_latest( user: Annotated[User, Depends(get_current_user)], session: Annotated[AsyncSession, Depends(get_session)], ): """가장 최근 morning briefing.""" b = await _load_briefing(session, target_date=None) if b is None: raise HTTPException(status_code=404, detail="아직 생성된 briefing 없음") return _build_response(b) @router.get("", response_model=BriefingResponse) async def get_briefing( user: Annotated[User, Depends(get_current_user)], session: Annotated[AsyncSession, Depends(get_session)], date: date_type | None = Query(default=None, description="YYYY-MM-DD (KST briefing_date)"), ): """특정 날짜 briefing (date 미지정 시 최신).""" b = await _load_briefing(session, target_date=date) if b is None: raise HTTPException( status_code=404, detail=f"briefing 없음 (date={date})" if date else "아직 생성된 briefing 없음", ) return _build_response(b) @router.post("/regenerate", response_model=RegenerateResponse) async def regenerate( user: Annotated[User, Depends(require_admin)], date: date_type | None = Query(default=None, description="YYYY-MM-DD KST 기준 briefing_date"), ): """수동 트리거 (admin). 동기 실행 — delete+insert transaction. date 미지정 시 오늘 KST. 같은 날 row 존재 시 transaction 안에서 삭제 후 신규 생성. 응답 status='success' | 'partial' | 'failed' | 'empty'. """ from workers.briefing_worker import run result = await run(target_date=date) if result is None: raise HTTPException(status_code=500, detail="briefing 워커 실행 실패 (로그 확인)") return RegenerateResponse( status=result["status"], briefing_id=result.get("briefing_id"), briefing_date=date or datetime.now().date(), total_topics=result["total_topics"], total_articles=result["total_articles"], llm_calls=result["llm_calls"], llm_failures=result["llm_failures"], generation_ms=result["generation_ms"], regenerated=result.get("regenerated", True), ) # ─── 2026-05-13 신규: 날짜 선택 + 카드 액션 ─── class BriefingDateSummary(BaseModel): briefing_date: date_type total_topics: int total_articles: int status: str read_count: int # 사용자가 읽음 처리한 토픽 수 highlighted_count: int class TopicActionRequest(BaseModel): value: bool class TopicActionResponse(BaseModel): id: int is_read: bool read_at: datetime | None highlighted: bool highlighted_at: datetime | None @router.get("/dates", response_model=list[BriefingDateSummary]) async def list_dates( user: Annotated[User, Depends(get_current_user)], session: Annotated[AsyncSession, Depends(get_session)], limit: int = Query(default=60, ge=1, le=365), ): """사용 가능한 briefing 날짜 목록 (최신 desc). UI date picker 의 데이터 소스.""" from sqlalchemy import func, case stmt = ( select( MorningBriefing.briefing_date, MorningBriefing.total_topics, MorningBriefing.total_articles, MorningBriefing.status, func.count(case((BriefingTopic.is_read.is_(True), 1))).label("read_count"), func.count(case((BriefingTopic.highlighted.is_(True), 1))).label("highlighted_count"), ) .outerjoin(BriefingTopic, BriefingTopic.briefing_id == MorningBriefing.id) .group_by(MorningBriefing.id) .order_by(MorningBriefing.briefing_date.desc()) .limit(limit) ) rows = (await session.execute(stmt)).all() return [ BriefingDateSummary( briefing_date=r.briefing_date, total_topics=r.total_topics, total_articles=r.total_articles, status=r.status, read_count=r.read_count or 0, highlighted_count=r.highlighted_count or 0, ) for r in rows ] @router.patch("/topics/{topic_id}/read", response_model=TopicActionResponse) async def set_topic_read( topic_id: int, body: TopicActionRequest, user: Annotated[User, Depends(get_current_user)], session: Annotated[AsyncSession, Depends(get_session)], ): """토픽 카드 읽음 토글. value=true → 읽음 + read_at=now / false → 해제 + read_at=NULL.""" topic = await session.get(BriefingTopic, topic_id) if topic is None: raise HTTPException(status_code=404, detail=f"topic 없음 id={topic_id}") topic.is_read = body.value topic.read_at = datetime.now() if body.value else None await session.commit() await session.refresh(topic) return TopicActionResponse( id=topic.id, is_read=topic.is_read, read_at=topic.read_at, highlighted=topic.highlighted, highlighted_at=topic.highlighted_at, ) @router.patch("/topics/{topic_id}/highlight", response_model=TopicActionResponse) async def set_topic_highlight( topic_id: int, body: TopicActionRequest, user: Annotated[User, Depends(get_current_user)], session: Annotated[AsyncSession, Depends(get_session)], ): """토픽 카드 하이라이트 토글. value=true → highlighted + highlighted_at=now / false → 해제.""" topic = await session.get(BriefingTopic, topic_id) if topic is None: raise HTTPException(status_code=404, detail=f"topic 없음 id={topic_id}") topic.highlighted = body.value topic.highlighted_at = datetime.now() if body.value else None await session.commit() await session.refresh(topic) return TopicActionResponse( id=topic.id, is_read=topic.is_read, read_at=topic.read_at, highlighted=topic.highlighted, highlighted_at=topic.highlighted_at, )