feat: 뉴스 필터 트리 (신문사 → 분야) + ai_summary 우선 표시

- 좌측 필터: 신문사 펼침 → 분야별 필터 (News/경향신문/문화)
- API: source 파라미터 '신문사' 또는 '신문사/분야' 지원
- 리스트: ai_summary 있으면 우선, 없으면 extracted_text fallback

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-04-06 15:08:50 +09:00
parent 2eeed41f5c
commit 557165db11
2 changed files with 37 additions and 12 deletions

View File

@@ -4,7 +4,7 @@ from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy import String, select
from sqlalchemy.ext.asyncio import AsyncSession
from core.auth import get_current_user
@@ -116,7 +116,12 @@ async def list_articles(
Document.deleted_at == None,
)
if source:
query = query.where(Document.ai_sub_group == source)
if '/' in source:
# 신문사/분야 형태 → ai_tags로 필터
query = query.where(Document.ai_tags.cast(String).contains(source))
else:
# 신문사만 → ai_sub_group
query = query.where(Document.ai_sub_group == source)
if unread_only:
query = query.where(Document.is_read == False)

View File

@@ -20,18 +20,23 @@
let selectedArticle = $state(null);
let filterSource = $state('');
let showUnreadOnly = $state(false);
let sources = $state([]);
let sourceTree = $state({}); // { 경향신문: ['문화', '사회'], NYT: ['World'] }
let currentPage = $state(1);
let noteEditing = $state(false);
let noteText = $state('');
let expandedSources = $state({});
onMount(async () => {
try {
const srcData = await api('/news/sources');
// 신문사별 유니크
const names = new Set();
srcData.forEach(s => names.add(s.name.split(' ')[0]));
sources = [...names];
const tree = {};
srcData.forEach(s => {
const paper = s.name.split(' ')[0];
const cat = s.category || '';
if (!tree[paper]) tree[paper] = [];
if (cat && !tree[paper].includes(cat)) tree[paper].push(cat);
});
sourceTree = tree;
} catch (e) {}
loadArticles();
});
@@ -127,11 +132,26 @@
class="w-full text-left px-2 py-1.5 rounded text-sm mb-1 {filterSource === '' ? 'bg-[var(--accent)]/15 text-[var(--accent)]' : 'text-[var(--text-dim)] hover:bg-[var(--surface)]'}"
>📰 전체</button>
{#each sources as src}
<button
onclick={() => { filterSource = src; }}
class="w-full text-left px-2 py-1.5 rounded text-sm mb-0.5 {filterSource === src ? 'bg-[var(--accent)]/15 text-[var(--accent)]' : 'text-[var(--text-dim)] hover:bg-[var(--surface)]'}"
>{src}</button>
{#each Object.entries(sourceTree) as [paper, categories]}
<div class="mb-0.5">
<button
onclick={() => { filterSource = paper; expandedSources[paper] = !expandedSources[paper]; }}
class="w-full text-left px-2 py-1.5 rounded text-sm flex items-center justify-between
{filterSource === paper ? 'bg-[var(--accent)]/15 text-[var(--accent)]' : 'text-[var(--text-dim)] hover:bg-[var(--surface)]'}"
>
<span>{paper}</span>
<span class="text-[10px]">{expandedSources[paper] ? '▼' : '▶'}</span>
</button>
{#if expandedSources[paper] && categories.length > 0}
{#each categories as cat}
<button
onclick={() => { filterSource = `${paper}/${cat}`; }}
class="w-full text-left pl-6 pr-2 py-1 rounded text-xs
{filterSource === `${paper}/${cat}` ? 'bg-[var(--accent)]/15 text-[var(--accent)]' : 'text-[var(--text-dim)] hover:bg-[var(--surface)]'}"
>{cat}</button>
{/each}
{/if}
</div>
{/each}
<hr class="my-3 border-[var(--border)]">