diff --git a/app/api/briefing.py b/app/api/briefing.py index 867175d..f784a18 100644 --- a/app/api/briefing.py +++ b/app/api/briefing.py @@ -43,6 +43,7 @@ class KeyQuote(BaseModel): class TopicResponse(BaseModel): + id: int # 2026-05-13 카드 액션 (read/highlight) 호출용 식별자 topic_rank: int topic_label: str headline: str @@ -56,6 +57,11 @@ class TopicResponse(BaseModel): 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): @@ -94,6 +100,7 @@ def _build_response(b: MorningBriefing) -> BriefingResponse: 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, @@ -109,6 +116,10 @@ def _build_response(b: MorningBriefing) -> BriefingResponse: 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, ) ) @@ -201,3 +212,112 @@ async def regenerate( 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, + ) diff --git a/app/models/briefing.py b/app/models/briefing.py index 5958bf3..ff71ba6 100644 --- a/app/models/briefing.py +++ b/app/models/briefing.py @@ -90,6 +90,12 @@ class BriefingTopic(Base): llm_model: Mapped[str | None] = mapped_column(String(100)) llm_fallback_used: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + # 2026-05-13 카드별 사용자 액션 (date picker 와 동반). + is_read: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + read_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + highlighted: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + highlighted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, default=datetime.now ) diff --git a/frontend/src/routes/news/+page.svelte b/frontend/src/routes/news/+page.svelte index 8673757..1dfcc10 100644 --- a/frontend/src/routes/news/+page.svelte +++ b/frontend/src/routes/news/+page.svelte @@ -19,6 +19,7 @@ }; type BriefingTopic = { + id: number; topic_rank: number; topic_label: string; headline: string; @@ -32,6 +33,19 @@ country_count: number; importance_score: number; llm_fallback_used: boolean; + is_read: boolean; + read_at: string | null; + highlighted: boolean; + highlighted_at: string | null; + }; + + type BriefingDateSummary = { + briefing_date: string; + total_topics: number; + total_articles: number; + status: string; + read_count: number; + highlighted_count: number; }; type Briefing = { @@ -75,18 +89,71 @@ let briefing = $state(null); let loading = $state(true); let errorMsg = $state(null); + // 2026-05-13 추가 — 날짜 선택 + 카드 액션 + let availableDates = $state([]); + let selectedDate = $state(''); // YYYY-MM-DD ('' = 최신) - onMount(async () => { + async function loadBriefing(dateStr: string) { + loading = true; + errorMsg = null; try { - briefing = await api('/briefing/latest'); + const path = dateStr ? `/briefing?date=${dateStr}` : '/briefing/latest'; + briefing = await api(path); } catch (e) { const err = e as ApiError; + briefing = null; errorMsg = err?.status === 404 - ? '아직 생성된 브리핑이 없습니다. 매일 새벽 05:10 KST 에 자동 생성됩니다.' + ? (dateStr ? `${dateStr} 자에는 briefing 이 없습니다.` : '아직 생성된 브리핑이 없습니다. 매일 새벽 05:10 KST 에 자동 생성됩니다.') : (err?.detail || '브리핑을 불러오지 못했습니다.'); } finally { loading = false; } + } + + async function loadDates() { + try { + availableDates = await api('/briefing/dates'); + } catch { + availableDates = []; + } + } + + function onDateChange() { + loadBriefing(selectedDate); + } + + async function toggleRead(topic: BriefingTopic) { + if (!briefing) return; + const next = !topic.is_read; + try { + const r = await api<{ id: number; is_read: boolean; read_at: string | null; highlighted: boolean; highlighted_at: string | null }>( + `/briefing/topics/${topic.id}/read`, + { method: 'PATCH', body: JSON.stringify({ value: next }) } + ); + topic.is_read = r.is_read; + topic.read_at = r.read_at; + } catch (e) { + console.error('toggleRead failed', e); + } + } + + async function toggleHighlight(topic: BriefingTopic) { + if (!briefing) return; + const next = !topic.highlighted; + try { + const r = await api<{ id: number; is_read: boolean; read_at: string | null; highlighted: boolean; highlighted_at: string | null }>( + `/briefing/topics/${topic.id}/highlight`, + { method: 'PATCH', body: JSON.stringify({ value: next }) } + ); + topic.highlighted = r.highlighted; + topic.highlighted_at = r.highlighted_at; + } catch (e) { + console.error('toggleHighlight failed', e); + } + } + + onMount(async () => { + await Promise.all([loadDates(), loadBriefing('')]); }); const fallbackPct = $derived( @@ -97,8 +164,29 @@
-
-

야간 뉴스 브리핑

+
+
+

야간 뉴스 브리핑

+ {#if availableDates.length > 0} +
+ + +
+ {/if} +

{#if briefing} {briefing.briefing_date} 새벽 수집 · 총 {briefing.total_articles}건 / {briefing.total_countries}개국 / {briefing.total_topics}개 토픽 @@ -137,8 +225,9 @@

{/if} - {#each briefing.topics as topic (topic.topic_rank)} - + {#each briefing.topics as topic (topic.id)} +
+
#{topic.topic_rank} @@ -154,6 +243,24 @@ {topic.country_count}개국 · {topic.article_count}건

+
+ + +
{#if topic.country_perspectives.length > 0} @@ -210,6 +317,7 @@ {/if}
+ {/each} {/if} {/if} diff --git a/migrations/263_briefing_topics_is_read.sql b/migrations/263_briefing_topics_is_read.sql new file mode 100644 index 0000000..bf531cc --- /dev/null +++ b/migrations/263_briefing_topics_is_read.sql @@ -0,0 +1,3 @@ +-- 2026-05-13 briefing topic 읽음 표시 — UI 의 카드별 액션. +ALTER TABLE briefing_topics + ADD COLUMN IF NOT EXISTS is_read BOOLEAN NOT NULL DEFAULT false; diff --git a/migrations/264_briefing_topics_read_at.sql b/migrations/264_briefing_topics_read_at.sql new file mode 100644 index 0000000..9cf08a3 --- /dev/null +++ b/migrations/264_briefing_topics_read_at.sql @@ -0,0 +1,3 @@ +-- 2026-05-13 briefing topic 읽음 시각 — read 토글 시 now() 설정 / 해제 시 NULL. +ALTER TABLE briefing_topics + ADD COLUMN IF NOT EXISTS read_at TIMESTAMPTZ; diff --git a/migrations/265_briefing_topics_highlighted.sql b/migrations/265_briefing_topics_highlighted.sql new file mode 100644 index 0000000..f452b38 --- /dev/null +++ b/migrations/265_briefing_topics_highlighted.sql @@ -0,0 +1,3 @@ +-- 2026-05-13 briefing topic 하이라이트 — UI 의 카드별 액션. +ALTER TABLE briefing_topics + ADD COLUMN IF NOT EXISTS highlighted BOOLEAN NOT NULL DEFAULT false; diff --git a/migrations/266_briefing_topics_highlighted_at.sql b/migrations/266_briefing_topics_highlighted_at.sql new file mode 100644 index 0000000..ea18690 --- /dev/null +++ b/migrations/266_briefing_topics_highlighted_at.sql @@ -0,0 +1,3 @@ +-- 2026-05-13 briefing topic 하이라이트 시각 — highlight 토글 시 now() 설정 / 해제 시 NULL. +ALTER TABLE briefing_topics + ADD COLUMN IF NOT EXISTS highlighted_at TIMESTAMPTZ;