feat(digest): date picker URL sync + article→문서 라우팅 + country 국기·한국어

- GET /api/digest/dates 신설 (브리핑 /briefing/dates 패턴 미러, read-only)
- topic article 제목 enrich (documents 배치 1쿼리 + dedupe(set) + map-miss=null → 프론트 '(제목 없음)')
- /digest 재작성: ?date=&country= URL sync(공유·뒤로가기), 국가 탭=인라인 SVG 국기+한국어, 기사=/documents/{id} 링크(상위5+펼치기)
- Phase 4.5(PR #22) 후속. 검증: py_compile·dates/enrich 쿼리(275 resolve·miss 0)·frontend docker build PASS. 시각 렌더 검증=preview 게이트 대기

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hyungi
2026-06-03 23:39:07 +00:00
parent cd33ded7a8
commit aa2d7814e3
3 changed files with 344 additions and 60 deletions
+91 -5
View File
@@ -2,11 +2,15 @@
엔드포인트:
- GET /api/digest/latest : 가장 최근 digest
- GET /api/digest/dates : 생성된 digest 날짜 목록 (date picker 용)
- GET /api/digest?date=YYYY-MM-DD : 특정 날짜 digest
- GET /api/digest?country=KR : 특정 국가만
- POST /api/digest/regenerate : 백그라운드 digest 워커 트리거 (auth 필요)
응답은 country → topic 2-level 구조. country 가 비어있는 경우 응답에서 자동 생략.
각 topic 은 article_ids(doc_id) 와 함께 articles([{id, title}]) 를 반환 — title 은 documents
배치 조회로 채우며(한 digest 당 1 쿼리), 매칭 없는 id(하드삭제 등)는 title=null 로 둔다
(프론트는 "(제목 없음)" 으로 렌더, 빈 링크 금지). article → /documents/{id} 라우팅용.
"""
import asyncio
@@ -23,6 +27,7 @@ from sqlalchemy.orm import selectinload
from core.auth import get_current_user, require_admin
from core.database import get_session
from models.digest import DigestTopic, GlobalDigest
from models.document import Document
from models.user import User
router = APIRouter()
@@ -31,11 +36,17 @@ router = APIRouter()
# ─── Pydantic 응답 모델 (schemas/ 디렉토리 미사용 → inline 정의) ───
class ArticleRef(BaseModel):
id: int
title: str | None = None
class TopicResponse(BaseModel):
topic_rank: int
topic_label: str
summary: str
article_ids: list[int]
articles: list[ArticleRef]
article_count: int
importance_score: float
raw_weight_sum: float
@@ -62,21 +73,65 @@ class DigestResponse(BaseModel):
countries: list[CountryGroup]
class DigestDateSummary(BaseModel):
"""date picker 용 경량 요약 (브리핑 /briefing/dates 와 동형)."""
digest_date: date_type
total_topics: int
total_countries: int
total_articles: int
status: str
# ─── helpers ───
def _build_response(digest: GlobalDigest, country_filter: str | None = None) -> DigestResponse:
"""ORM 객체 → DigestResponse. country_filter 가 주어지면 해당 국가만."""
def _collect_article_ids(digest: GlobalDigest) -> set[int]:
"""digest 의 모든 topic article_ids 를 dedupe 한 set (배치 title 조회용).
같은 기사가 여러 topic 에 걸리면 중복 id 가 생기므로 set 으로 한 번 줄인다.
"""
ids: set[int] = set()
for t in digest.topics:
for aid in t.article_ids or []:
try:
ids.add(int(aid))
except (TypeError, ValueError):
continue
return ids
async def _fetch_titles(session: AsyncSession, ids: set[int]) -> dict[int, str | None]:
"""doc_id → title 배치 조회. 매칭 없는 id 는 map 에 부재(호출부가 None 처리)."""
if not ids:
return {}
result = await session.execute(
select(Document.id, Document.title).where(Document.id.in_(ids))
)
return {row.id: row.title for row in result.all()}
def _build_response(
digest: GlobalDigest,
title_map: dict[int, str | None],
country_filter: str | None = None,
) -> DigestResponse:
"""ORM 객체 → DigestResponse. country_filter 가 주어지면 해당 국가만.
title_map miss(삭제/아카이브된 문서)는 title=None 으로 — 프론트가 "(제목 없음)" 처리.
"""
topics_by_country: dict[str, list[TopicResponse]] = {}
for t in sorted(digest.topics, key=lambda x: (x.country, x.topic_rank)):
if country_filter and t.country != country_filter:
continue
ids = [int(a) for a in (t.article_ids or [])]
topics_by_country.setdefault(t.country, []).append(
TopicResponse(
topic_rank=t.topic_rank,
topic_label=t.topic_label,
summary=t.summary,
article_ids=list(t.article_ids or []),
article_ids=ids,
articles=[ArticleRef(id=aid, title=title_map.get(aid)) for aid in ids],
article_count=t.article_count,
importance_score=t.importance_score,
raw_weight_sum=t.raw_weight_sum,
@@ -120,6 +175,12 @@ async def _load_digest(
return result.scalar_one_or_none()
async def _respond(session: AsyncSession, digest: GlobalDigest, country_filter: str | None = None) -> DigestResponse:
"""digest 1건 → article 제목 배치 enrich 후 응답 빌드."""
title_map = await _fetch_titles(session, _collect_article_ids(digest))
return _build_response(digest, title_map, country_filter=country_filter)
# ─── Routes ───
@@ -132,7 +193,32 @@ async def get_latest(
digest = await _load_digest(session, target_date=None)
if digest is None:
raise HTTPException(status_code=404, detail="아직 생성된 digest 없음")
return _build_response(digest)
return await _respond(session, digest)
@router.get("/dates", response_model=list[DigestDateSummary])
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, description="최신부터 N개"),
):
"""생성된 digest 날짜 목록 (date picker 용, 최신 내림차순)."""
query = (
select(GlobalDigest)
.order_by(GlobalDigest.digest_date.desc())
.limit(limit)
)
rows = (await session.execute(query)).scalars().all()
return [
DigestDateSummary(
digest_date=g.digest_date,
total_topics=g.total_topics,
total_countries=g.total_countries,
total_articles=g.total_articles,
status=g.status,
)
for g in rows
]
@router.get("", response_model=DigestResponse)
@@ -150,7 +236,7 @@ async def get_digest(
detail=f"digest 없음 (date={date})" if date else "아직 생성된 digest 없음",
)
country_filter = country.upper() if country else None
return _build_response(digest, country_filter=country_filter)
return await _respond(session, digest, country_filter=country_filter)
@router.post("/regenerate")
+42
View File
@@ -0,0 +1,42 @@
// 디제스트 국가 코드 → 한국어 이름 + 인라인 SVG 국기.
// 이모지 국기 금지(no-emoji 규칙) → 자체 SVG. npm 의존성 0, self-contained.
// 실데이터 국가(2026-06-04 기준): KR FR HK US DE JP CN TW IN GB.
// SVG 는 viewBox 0 0 24 16. 단순화했으나 식별 가능 수준. 정적 문자열이라 {@html} 안전.
export type CountryInfo = { ko: string; flag: string };
const NAMES: Record<string, string> = {
KR: '한국',
FR: '프랑스',
HK: '홍콩',
US: '미국',
DE: '독일',
JP: '일본',
CN: '중국',
TW: '대만',
IN: '인도',
GB: '영국',
};
const FLAGS: Record<string, string> = {
KR: `<svg viewBox="0 0 24 16" xmlns="http://www.w3.org/2000/svg"><rect width="24" height="16" fill="#fff"/><path d="M12 5a3 3 0 0 1 0 6 1.5 1.5 0 0 0 0-3 1.5 1.5 0 0 1 0-3z" fill="#cd2e3a"/><path d="M12 5a3 3 0 0 0 0 6 1.5 1.5 0 0 1 0-3 1.5 1.5 0 0 0 0-3z" fill="#0047a0"/></svg>`,
US: `<svg viewBox="0 0 24 16" xmlns="http://www.w3.org/2000/svg"><rect width="24" height="16" fill="#fff"/><g fill="#b22234"><rect width="24" height="1.23" y="0"/><rect width="24" height="1.23" y="2.46"/><rect width="24" height="1.23" y="4.92"/><rect width="24" height="1.23" y="7.38"/><rect width="24" height="1.23" y="9.84"/><rect width="24" height="1.23" y="12.3"/><rect width="24" height="1.23" y="14.77"/></g><rect width="10" height="8.6" fill="#3c3b6e"/></svg>`,
JP: `<svg viewBox="0 0 24 16" xmlns="http://www.w3.org/2000/svg"><rect width="24" height="16" fill="#fff"/><circle cx="12" cy="8" r="4.8" fill="#bc002d"/></svg>`,
CN: `<svg viewBox="0 0 24 16" xmlns="http://www.w3.org/2000/svg"><rect width="24" height="16" fill="#de2910"/><path d="M5 2.4l.86 2.65h2.78l-2.25 1.63.86 2.65L5 7.7 2.75 9.33l.86-2.65L1.36 5.05h2.78z" fill="#ffde00"/><g fill="#ffde00"><circle cx="9.6" cy="2.1" r=".6"/><circle cx="11" cy="3.6" r=".6"/><circle cx="11" cy="5.7" r=".6"/><circle cx="9.6" cy="7.1" r=".6"/></g></svg>`,
FR: `<svg viewBox="0 0 24 16" xmlns="http://www.w3.org/2000/svg"><rect width="8" height="16" fill="#0055a4"/><rect width="8" height="16" x="8" fill="#fff"/><rect width="8" height="16" x="16" fill="#ef4135"/></svg>`,
DE: `<svg viewBox="0 0 24 16" xmlns="http://www.w3.org/2000/svg"><rect width="24" height="5.33" y="0" fill="#000"/><rect width="24" height="5.33" y="5.33" fill="#dd0000"/><rect width="24" height="5.34" y="10.66" fill="#ffce00"/></svg>`,
HK: `<svg viewBox="0 0 24 16" xmlns="http://www.w3.org/2000/svg"><rect width="24" height="16" fill="#de2910"/><g fill="#fff" transform="translate(12 8)"><circle cx="0" cy="-3.2" r="1.25"/><circle cx="3.05" cy="-1" r="1.25"/><circle cx="1.88" cy="2.6" r="1.25"/><circle cx="-1.88" cy="2.6" r="1.25"/><circle cx="-3.05" cy="-1" r="1.25"/></g></svg>`,
TW: `<svg viewBox="0 0 24 16" xmlns="http://www.w3.org/2000/svg"><rect width="24" height="16" fill="#fe0000"/><rect width="12" height="8" fill="#000095"/><circle cx="6" cy="4" r="2.4" fill="#fff"/><circle cx="6" cy="4" r="1.3" fill="#000095"/></svg>`,
IN: `<svg viewBox="0 0 24 16" xmlns="http://www.w3.org/2000/svg"><rect width="24" height="5.33" y="0" fill="#ff9933"/><rect width="24" height="5.33" y="5.33" fill="#fff"/><rect width="24" height="5.34" y="10.66" fill="#138808"/><circle cx="12" cy="8" r="1.9" fill="none" stroke="#000080" stroke-width=".5"/></svg>`,
GB: `<svg viewBox="0 0 24 16" xmlns="http://www.w3.org/2000/svg"><rect width="24" height="16" fill="#012169"/><path d="M0 0L24 16M24 0L0 16" stroke="#fff" stroke-width="3.2"/><path d="M0 0L24 16M24 0L0 16" stroke="#c8102e" stroke-width="1.6"/><path d="M12 0V16M0 8H24" stroke="#fff" stroke-width="5.3"/><path d="M12 0V16M0 8H24" stroke="#c8102e" stroke-width="3.2"/></svg>`,
};
/** 국가 코드 → {한국어 이름, SVG 국기}. 미등록 코드는 ko=코드 그대로, flag='' (호출부가 코드칩 fallback). */
export function countryLabel(code: string): CountryInfo {
const c = (code || '').toUpperCase();
return { ko: NAMES[c] ?? c, flag: FLAGS[c] ?? '' };
}
export function hasFlag(code: string): boolean {
return !!FLAGS[(code || '').toUpperCase()];
}
+211 -55
View File
@@ -1,18 +1,22 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { api, type ApiError } from '$lib/api';
import Card from '$lib/components/ui/Card.svelte';
import Skeleton from '$lib/components/ui/Skeleton.svelte';
import EmptyState from '$lib/components/ui/EmptyState.svelte';
import Tabs from '$lib/components/ui/Tabs.svelte';
import Badge from '$lib/components/ui/Badge.svelte';
import Button from '$lib/components/ui/Button.svelte';
import { countryLabel } from '$lib/i18n/countries';
type ArticleRef = { id: number; title: string | null };
type Topic = {
topic_rank: number;
topic_label: string;
summary: string;
article_ids: number[];
articles: ArticleRef[];
article_count: number;
importance_score: number;
llm_fallback_used: boolean;
@@ -28,96 +32,248 @@
status: string;
countries: Country[];
};
type DateSummary = {
digest_date: string;
total_topics: number;
total_countries: number;
total_articles: number;
status: string;
};
const PREVIEW = 5; // topic 카드에서 기본 노출할 기사 수
let digest = $state<Digest | null>(null);
let dates = $state<DateSummary[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
let country = $state<string>('');
let loadedDate = $state<string | null>(null); // 어떤 date 파라미터로 로드했는지 (중복 로드 방지)
let expanded = $state<Record<number, boolean>>({}); // topic_rank → 기사 펼침
async function load() {
// URL = 단일 진실: ?date=, ?country=
let urlDate = $derived($page.url.searchParams.get('date') ?? '');
let urlCountry = $derived($page.url.searchParams.get('country') ?? '');
async function loadDates() {
try {
dates = await api<DateSummary[]>('/digest/dates');
} catch {
dates = [];
}
}
async function loadDigest(dateStr: string) {
loading = true;
error = null;
expanded = {};
try {
digest = await api<Digest>('/digest/latest');
const countries = digest?.countries ?? [];
if (!countries.some((c) => c.country === country)) {
country = countries[0]?.country ?? '';
}
const path = dateStr ? `/digest?date=${dateStr}` : '/digest/latest';
digest = await api<Digest>(path);
} catch (e) {
const err = e as ApiError;
if (err && err.status === 404) {
digest = null;
error = null;
return;
} else {
error = err?.detail ?? (e as Error)?.message ?? '알 수 없는 오류';
}
error = err?.detail ?? (e as Error)?.message ?? '알 수 없는 오류';
} finally {
loadedDate = dateStr;
loading = false;
}
}
onMount(load);
// URL date 변경(최초 포함) → 로드
$effect(() => {
const d = urlDate;
if (d !== loadedDate) loadDigest(d);
});
let tabs = $derived(
(digest?.countries ?? []).map((c) => ({ id: c.country, label: c.country })),
);
onMount(loadDates);
// 현재 country: URL 우선, 없으면(또는 해당 날짜에 부재) 첫 국가로 graceful fallback
let activeCountry = $derived.by(() => {
const list = digest?.countries ?? [];
if (list.some((c) => c.country === urlCountry)) return urlCountry;
return list[0]?.country ?? '';
});
let topics = $derived(
digest?.countries?.find((c) => c.country === country)?.topics ?? [],
digest?.countries?.find((c) => c.country === activeCountry)?.topics ?? [],
);
// 날짜 네비게이션 (dates = 최신 내림차순)
let currentDate = $derived(digest?.digest_date ?? urlDate ?? '');
let dateIdx = $derived(dates.findIndex((d) => d.digest_date === currentDate));
let hasOlder = $derived(dateIdx >= 0 && dateIdx < dates.length - 1);
let hasNewer = $derived(dateIdx > 0);
function setParams(next: { date?: string | null; country?: string | null }) {
const p = new URLSearchParams($page.url.searchParams);
if ('date' in next) next.date ? p.set('date', next.date) : p.delete('date');
if ('country' in next) next.country ? p.set('country', next.country) : p.delete('country');
const qs = p.toString();
goto(`/digest${qs ? '?' + qs : ''}`, { noScroll: true, keepFocus: true });
}
function pickDate(d: string) {
setParams({ date: d });
}
function pickCountry(c: string) {
setParams({ country: c });
}
function older() {
if (hasOlder) pickDate(dates[dateIdx + 1].digest_date);
}
function newer() {
if (hasNewer) pickDate(dates[dateIdx - 1].digest_date);
}
function onSelectDate(e: Event) {
const v = (e.currentTarget as HTMLSelectElement).value;
pickDate(v);
}
function shown(t: Topic): ArticleRef[] {
return expanded[t.topic_rank] ? t.articles : t.articles.slice(0, PREVIEW);
}
</script>
<div class="mx-auto max-w-3xl px-4 py-6 space-y-4">
<header class="flex items-baseline justify-between">
<h1 class="text-xl font-semibold text-default">뉴스 다이제스트</h1>
{#if digest}
<span class="text-xs text-dim">
{digest.digest_date} · {digest.countries.length}국 · {digest.total_topics}주제
</span>
{/if}
<header class="space-y-3">
<div class="flex items-baseline justify-between gap-2">
<h1 class="text-xl font-semibold text-default">뉴스 다이제스트</h1>
{#if digest}
<span class="text-xs text-dim">
{digest.digest_date} · {digest.countries.length}국 · {digest.total_topics}주제 · 총 {digest.total_articles}
</span>
{/if}
</div>
<!-- 날짜 picker (URL ?date= 동기화) -->
<div class="flex items-center gap-2">
<button
type="button"
onclick={older}
disabled={!hasOlder}
class="px-2 py-1 rounded-md text-sm border border-default text-dim hover:bg-surface disabled:opacity-40 disabled:cursor-not-allowed"
aria-label="이전(과거) 날짜"
> 과거</button>
<select
value={currentDate}
onchange={onSelectDate}
class="flex-1 min-w-0 rounded-md border border-default bg-surface px-2 py-1 text-sm text-default"
aria-label="다이제스트 날짜 선택"
>
{#if dates.length === 0 && currentDate}
<option value={currentDate}>{currentDate}</option>
{/if}
{#each dates as d (d.digest_date)}
<option value={d.digest_date}>{d.digest_date} · {d.total_topics}주제 · {d.total_countries}</option>
{/each}
</select>
<button
type="button"
onclick={newer}
disabled={!hasNewer}
class="px-2 py-1 rounded-md text-sm border border-default text-dim hover:bg-surface disabled:opacity-40 disabled:cursor-not-allowed"
aria-label="다음(최신) 날짜"
>최신 </button>
</div>
</header>
{#if loading}
<Skeleton class="h-32 w-full" />
<Skeleton class="h-10 w-full" />
<Skeleton class="h-32 w-full" />
<Skeleton class="h-32 w-full" />
{:else if error}
<EmptyState title="불러올 수 없음" description={error}>
<Button variant="ghost" size="sm" onclick={load}>다시 시도</Button>
<Button variant="ghost" size="sm" onclick={() => loadDigest(urlDate)}>다시 시도</Button>
</EmptyState>
{:else if !digest || digest.countries.length === 0}
<EmptyState
title=" digest 가 없습니다"
description="오늘 04:00 KST cron 이 아직 실행되지 않았거나 결과가 없습니다."
title="이 날짜의 digest 가 없습니다"
description="다른 날짜를 선택하거나, 오늘 04:00 KST cron 실행을 기다려 주세요."
/>
{:else}
<Tabs {tabs} bind:value={country}>
{#snippet children(_activeId)}
{#if topics.length === 0}
<EmptyState
title="이 국가의 topic 이 없습니다"
description="다른 country 탭을 확인해 주세요."
/>
{:else}
<div class="space-y-3 mt-4">
{#each topics as t (t.topic_rank)}
<Card class="p-4">
<div class="flex items-start justify-between gap-2">
<h3 class="text-sm font-semibold text-default">
{t.topic_rank}. {t.topic_label}
</h3>
{#if t.llm_fallback_used}
<Badge>fallback</Badge>
{/if}
</div>
<p class="mt-2 text-sm text-default">{t.summary}</p>
<div class="mt-2 text-xs text-dim">
{t.article_count} articles · importance {t.importance_score.toFixed(2)}
</div>
</Card>
{/each}
</div>
{/if}
{/snippet}
</Tabs>
<!-- 국가 탭 (국기 SVG + 한국어, URL ?country= 동기화) -->
<div class="flex flex-wrap gap-2">
{#each digest.countries as c (c.country)}
{@const lbl = countryLabel(c.country)}
<button
type="button"
onclick={() => pickCountry(c.country)}
aria-current={activeCountry === c.country ? 'true' : undefined}
class="inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-sm border transition-colors
{activeCountry === c.country
? 'border-accent bg-accent/15 text-accent'
: 'border-default text-dim hover:bg-surface hover:text-default'}"
>
{#if lbl.flag}
<span class="flag inline-block w-[18px] h-[12px] rounded-[2px] overflow-hidden ring-1 ring-black/10 shrink-0">{@html lbl.flag}</span>
{:else}
<span class="inline-block rounded-[3px] bg-surface px-1 text-[10px] font-semibold text-dim">{c.country}</span>
{/if}
<span>{lbl.ko}</span>
<span class="text-xs opacity-70">{c.topics.length}</span>
</button>
{/each}
</div>
{#if topics.length === 0}
<EmptyState
title="이 국가의 topic 이 없습니다"
description="다른 country 탭을 확인해 주세요."
/>
{:else}
<div class="space-y-3">
{#each topics as t (t.topic_rank)}
<Card class="p-4">
<div class="flex items-start justify-between gap-2">
<h3 class="text-sm font-semibold text-default">
{t.topic_rank}. {t.topic_label}
</h3>
{#if t.llm_fallback_used}
<Badge>fallback</Badge>
{/if}
</div>
<p class="mt-2 text-sm text-default">{t.summary}</p>
<div class="mt-1 text-xs text-dim">
{t.article_count} articles · importance {t.importance_score.toFixed(2)}
</div>
{#if t.articles.length > 0}
<ul class="mt-3 space-y-1 border-t border-default pt-2">
{#each shown(t) as a (a.id)}
<li>
<a
href={`/documents/${a.id}`}
class="block truncate text-sm text-dim hover:text-accent hover:underline"
title={a.title ?? '(제목 없음)'}
>
{a.title ?? '(제목 없음)'}
</a>
</li>
{/each}
</ul>
{#if t.articles.length > PREVIEW && !expanded[t.topic_rank]}
<button
type="button"
onclick={() => (expanded = { ...expanded, [t.topic_rank]: true })}
class="mt-1 text-xs text-accent hover:underline"
>
{t.articles.length - PREVIEW}건 펼치기
</button>
{:else if t.articles.length > PREVIEW}
<button
type="button"
onclick={() => (expanded = { ...expanded, [t.topic_rank]: false })}
class="mt-1 text-xs text-dim hover:underline"
>
접기
</button>
{/if}
{/if}
</Card>
{/each}
</div>
{/if}
{/if}
</div>