From aa2d7814e3d5ce958747c2703e1ccde93ca13bf1 Mon Sep 17 00:00:00 2001 From: hyungi Date: Wed, 3 Jun 2026 23:39:07 +0000 Subject: [PATCH 1/2] =?UTF-8?q?feat(digest):=20date=20picker=20URL=20sync?= =?UTF-8?q?=20+=20article=E2=86=92=EB=AC=B8=EC=84=9C=20=EB=9D=BC=EC=9A=B0?= =?UTF-8?q?=ED=8C=85=20+=20country=20=EA=B5=AD=EA=B8=B0=C2=B7=ED=95=9C?= =?UTF-8?q?=EA=B5=AD=EC=96=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- app/api/digest.py | 96 ++++++++- frontend/src/lib/i18n/countries.ts | 42 ++++ frontend/src/routes/digest/+page.svelte | 266 +++++++++++++++++++----- 3 files changed, 344 insertions(+), 60 deletions(-) create mode 100644 frontend/src/lib/i18n/countries.ts diff --git a/app/api/digest.py b/app/api/digest.py index 996c830..9333dcc 100644 --- a/app/api/digest.py +++ b/app/api/digest.py @@ -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") diff --git a/frontend/src/lib/i18n/countries.ts b/frontend/src/lib/i18n/countries.ts new file mode 100644 index 0000000..bdb61f8 --- /dev/null +++ b/frontend/src/lib/i18n/countries.ts @@ -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 = { + KR: '한국', + FR: '프랑스', + HK: '홍콩', + US: '미국', + DE: '독일', + JP: '일본', + CN: '중국', + TW: '대만', + IN: '인도', + GB: '영국', +}; + +const FLAGS: Record = { + KR: ``, + US: ``, + JP: ``, + CN: ``, + FR: ``, + DE: ``, + HK: ``, + TW: ``, + IN: ``, + GB: ``, +}; + +/** 국가 코드 → {한국어 이름, 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()]; +} diff --git a/frontend/src/routes/digest/+page.svelte b/frontend/src/routes/digest/+page.svelte index 5874214..39f0293 100644 --- a/frontend/src/routes/digest/+page.svelte +++ b/frontend/src/routes/digest/+page.svelte @@ -1,18 +1,22 @@
-
-

뉴스 다이제스트

- {#if digest} - - {digest.digest_date} · {digest.countries.length}국 · {digest.total_topics}주제 - - {/if} +
+
+

뉴스 다이제스트

+ {#if digest} + + {digest.digest_date} · {digest.countries.length}국 · {digest.total_topics}주제 · 총 {digest.total_articles}건 + + {/if} +
+ + +
+ + + + + +
{#if loading} - + {:else if error} - + {:else if !digest || digest.countries.length === 0} {:else} - - {#snippet children(_activeId)} - {#if topics.length === 0} - - {:else} -
- {#each topics as t (t.topic_rank)} - -
-

- {t.topic_rank}. {t.topic_label} -

- {#if t.llm_fallback_used} - fallback - {/if} -
-

{t.summary}

-
- {t.article_count} articles · importance {t.importance_score.toFixed(2)} -
-
- {/each} -
- {/if} - {/snippet} -
+ +
+ {#each digest.countries as c (c.country)} + {@const lbl = countryLabel(c.country)} + + {/each} +
+ + {#if topics.length === 0} + + {:else} +
+ {#each topics as t (t.topic_rank)} + +
+

+ {t.topic_rank}. {t.topic_label} +

+ {#if t.llm_fallback_used} + fallback + {/if} +
+

{t.summary}

+
+ {t.article_count} articles · importance {t.importance_score.toFixed(2)} +
+ + {#if t.articles.length > 0} + + {#if t.articles.length > PREVIEW && !expanded[t.topic_rank]} + + {:else if t.articles.length > PREVIEW} + + {/if} + {/if} +
+ {/each} +
+ {/if} {/if}
-- 2.52.0 From f269e0df27c2f151ca45d304115b845ca61aba3c Mon Sep 17 00:00:00 2001 From: hyungi Date: Wed, 3 Jun 2026 23:39:14 +0000 Subject: [PATCH 2/2] =?UTF-8?q?ops(news):=20chunk=5Fworker=20news=5Fsource?= =?UTF-8?q?=20=EB=A7=A4=ED=95=91=20=EC=8B=A4=ED=8C=A8=20=EA=B0=80=EC=8B=9C?= =?UTF-8?q?=EC=84=B1=20=EA=B0=80=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _lookup_news_source prefix 미일치 시 silent (None) 반환 → warn 로그 추가. loader 의 drop 로그와 대칭, 신규 source / RSS category 오염 재발 즉시 가시. 동작 변경 0. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/workers/chunk_worker.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/workers/chunk_worker.py b/app/workers/chunk_worker.py index d7e49ed..bd5fd94 100644 --- a/app/workers/chunk_worker.py +++ b/app/workers/chunk_worker.py @@ -282,6 +282,10 @@ async def _lookup_news_source( ): return src.country, src.name, src.language + logger.warning( + f"[chunk] news_source 매핑 실패: doc_id={doc.id} ai_sub_group={source_name!r} " + f"→ country NULL (news_sources prefix 미일치). 신규 source 또는 RSS category 오염 가능." + ) return None, source_name, None -- 2.52.0