feat: 뉴스 전용 페이지 + 분류 격리 + 읽음 상태

- /news 전용 페이지: 신문사 필터, 읽지않음 필터, 시간순 리스트, 미리보기
- 뉴스 분류 격리: ai_domain='News', classify 제거, embed만 등록
- is_read: 클릭 시 자동 읽음, 전체 읽음 API
- documents 목록에서 뉴스 제외 (source_channel != 'news')
- nav에 뉴스 링크 추가
- GET /api/news/articles, POST /api/news/mark-all-read

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-04-06 14:16:00 +09:00
parent cd5f1c526d
commit 7ca3abf17c
7 changed files with 280 additions and 9 deletions

View File

@@ -43,6 +43,7 @@ class DocumentResponse(BaseModel):
derived_path: str | None
original_format: str | None
conversion_status: str | None
is_read: bool | None
review_status: str | None
edit_url: str | None
preview_status: str | None
@@ -146,8 +147,8 @@ async def list_documents(
source: str | None = None,
format: str | None = None,
):
"""문서 목록 조회 (페이지네이션 + 필터)"""
query = select(Document).where(Document.deleted_at == None)
"""문서 목록 조회 (페이지네이션 + 필터, 뉴스 제외)"""
query = select(Document).where(Document.deleted_at == None, Document.source_channel != "news")
if domain:
# prefix 매칭: Industrial_Safety 클릭 시 하위 전부 포함

View File

@@ -98,6 +98,62 @@ async def delete_source(
return {"message": f"소스 {source_id} 삭제됨"}
@router.get("/articles")
async def list_articles(
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
source: str | None = None,
unread_only: bool = False,
page: int = 1,
page_size: int = 30,
):
"""뉴스 기사 목록"""
from sqlalchemy import func
from models.document import Document
query = select(Document).where(
Document.source_channel == "news",
Document.deleted_at == None,
)
if source:
query = query.where(Document.ai_sub_group == source)
if unread_only:
query = query.where(Document.is_read == False)
count_q = select(func.count()).select_from(query.subquery())
total = (await session.execute(count_q)).scalar()
query = query.order_by(Document.is_read.asc(), Document.created_at.desc())
query = query.offset((page - 1) * page_size).limit(page_size)
result = await session.execute(query)
items = result.scalars().all()
from api.documents import DocumentResponse
return {
"items": [DocumentResponse.model_validate(doc) for doc in items],
"total": total,
"page": page,
}
@router.post("/mark-all-read")
async def mark_all_read(
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
):
"""전체 읽음 처리"""
from sqlalchemy import update
from models.document import Document
result = await session.execute(
update(Document)
.where(Document.source_channel == "news", Document.is_read == False)
.values(is_read=True)
)
await session.commit()
return {"marked": result.rowcount}
@router.post("/collect")
async def trigger_collect(
user: Annotated[User, Depends(get_current_user)],

View File

@@ -3,7 +3,7 @@
from datetime import datetime
from pgvector.sqlalchemy import Vector
from sqlalchemy import BigInteger, DateTime, Enum, String, Text
from sqlalchemy import BigInteger, Boolean, DateTime, Enum, String, Text
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
@@ -55,6 +55,9 @@ class Document(Base):
original_format: Mapped[str | None] = mapped_column(String(20))
conversion_status: Mapped[str | None] = mapped_column(String(20), default="none")
# 읽음 상태 (뉴스용)
is_read: Mapped[bool | None] = mapped_column(Boolean, default=False)
# 승인/삭제
review_status: Mapped[str | None] = mapped_column(String(20), default="pending")
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))

View File

@@ -139,6 +139,7 @@ async def _fetch_rss(session, source: NewsSource) -> int:
continue
category = _normalize_category(source.category or "")
source_short = source.name.split(" ")[0] # "경향신문 문화" → "경향신문"
doc = Document(
file_path=f"news/{source.name}/{article_id}",
@@ -154,14 +155,14 @@ async def _fetch_rss(session, source: NewsSource) -> int:
data_origin="external",
edit_url=link,
review_status="approved",
ai_domain="News",
ai_sub_group=source_short,
ai_tags=[f"News/{source_short}/{category}"],
)
session.add(doc)
await session.flush()
# classify + embed 등록 (extract 불필요)
session.add(ProcessingQueue(document_id=doc.id, stage="classify", status="pending"))
# 30일 이내만 embed
# embed 등록 (classify 불필요 — 소스/분야 이미 확정)
days_old = (datetime.now(timezone.utc) - pub_dt).days
if days_old <= 30:
session.add(ProcessingQueue(document_id=doc.id, stage="embed", status="pending"))
@@ -219,6 +220,7 @@ async def _fetch_api(session, source: NewsSource) -> int:
continue
category = _normalize_category(article.get("section", source.category or ""))
source_short = source.name.split(" ")[0]
doc = Document(
file_path=f"news/{source.name}/{article_id}",
@@ -234,12 +236,13 @@ async def _fetch_api(session, source: NewsSource) -> int:
data_origin="external",
edit_url=link,
review_status="approved",
ai_domain="News",
ai_sub_group=source_short,
ai_tags=[f"News/{source_short}/{category}"],
)
session.add(doc)
await session.flush()
session.add(ProcessingQueue(document_id=doc.id, stage="classify", status="pending"))
days_old = (datetime.now(timezone.utc) - pub_dt).days
if days_old <= 30:
session.add(ProcessingQueue(document_id=doc.id, stage="embed", status="pending"))

View File

@@ -76,6 +76,7 @@
<a href="/documents" class="text-xs hover:text-[var(--accent)]">문서</a>
</div>
<div class="flex items-center gap-3">
<a href="/news" class="text-xs text-[var(--text-dim)] hover:text-[var(--text)]">뉴스</a>
<a href="/inbox" class="text-xs text-[var(--text-dim)] hover:text-[var(--text)]">Inbox</a>
<a href="/settings" class="text-xs text-[var(--text-dim)] hover:text-[var(--text)]">설정</a>
<button

View File

@@ -0,0 +1,198 @@
<script>
import { onMount } from 'svelte';
import { api } from '$lib/api';
import { addToast } from '$lib/stores/ui';
import { marked } from 'marked';
import DOMPurify from 'dompurify';
function renderMd(text) {
return DOMPurify.sanitize(marked(text), {
USE_PROFILES: { html: true },
FORBID_TAGS: ['style', 'script'],
FORBID_ATTR: ['onerror', 'onclick'],
ALLOW_UNKNOWN_PROTOCOLS: false,
});
}
let articles = $state([]);
let total = $state(0);
let loading = $state(true);
let selectedArticle = $state(null);
let filterSource = $state('');
let showUnreadOnly = $state(false);
let sources = $state([]);
let currentPage = $state(1);
onMount(async () => {
try {
const srcData = await api('/news/sources');
// 신문사별 유니크
const names = new Set();
srcData.forEach(s => names.add(s.name.split(' ')[0]));
sources = [...names];
} catch (e) {}
loadArticles();
});
async function loadArticles() {
loading = true;
try {
const params = new URLSearchParams();
params.set('page', String(currentPage));
params.set('page_size', '30');
if (filterSource) params.set('source', filterSource);
if (showUnreadOnly) params.set('unread_only', 'true');
const data = await api(`/news/articles?${params}`);
articles = data.items;
total = data.total;
} catch (err) {
addToast('error', '뉴스 로딩 실패');
} finally {
loading = false;
}
}
function selectArticle(article) {
selectedArticle = article;
if (!article.is_read) markRead(article);
}
async function markRead(article) {
try {
await api(`/documents/${article.id}`, {
method: 'PATCH',
body: JSON.stringify({ is_read: true }),
});
article.is_read = true;
articles = [...articles];
} catch (e) {}
}
async function markAllRead() {
try {
const result = await api('/news/mark-all-read', { method: 'POST' });
addToast('success', `${result.marked}건 읽음 처리`);
articles = articles.map(a => ({ ...a, is_read: true }));
} catch (e) {
addToast('error', '실패');
}
}
function timeAgo(dateStr) {
const diff = Date.now() - new Date(dateStr).getTime();
const mins = Math.floor(diff / 60000);
if (mins < 60) return `${mins}분 전`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}시간 전`;
const days = Math.floor(hours / 24);
return `${days}일 전`;
}
$effect(() => {
const _s = filterSource;
const _u = showUnreadOnly;
currentPage = 1;
loadArticles();
});
</script>
<div class="flex h-full">
<!-- 좌측 필터 -->
<div class="w-48 shrink-0 border-r border-[var(--border)] bg-[var(--sidebar-bg)] p-3 overflow-y-auto">
<h2 class="text-xs font-semibold text-[var(--text-dim)] uppercase mb-3">필터</h2>
<button
onclick={() => { filterSource = ''; }}
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}
<hr class="my-3 border-[var(--border)]">
<label class="flex items-center gap-2 px-2 text-xs text-[var(--text-dim)]">
<input type="checkbox" bind:checked={showUnreadOnly} class="rounded">
읽지 않음만
</label>
</div>
<!-- 메인 -->
<div class="flex-1 flex flex-col min-h-0">
<!-- 상단 바 -->
<div class="flex items-center justify-between px-4 py-2 border-b border-[var(--border)] shrink-0">
<span class="text-xs text-[var(--text-dim)]">{total}</span>
<button
onclick={markAllRead}
class="text-xs text-[var(--text-dim)] hover:text-[var(--accent)] px-2 py-1 rounded border border-[var(--border)]"
>전체 읽음</button>
</div>
<!-- 상단: 기사 리스트 -->
<div class="overflow-y-auto {selectedArticle ? 'h-[40%] shrink-0 border-b border-[var(--border)]' : 'flex-1'}">
{#if loading}
<div class="p-4 space-y-2">
{#each Array(5) as _}
<div class="h-12 bg-[var(--surface)] rounded animate-pulse"></div>
{/each}
</div>
{:else if articles.length === 0}
<div class="text-center py-16 text-[var(--text-dim)]">
<p class="text-sm">뉴스가 없습니다</p>
</div>
{:else}
{#each articles as article}
<button
onclick={() => selectArticle(article)}
class="w-full text-left px-4 py-2.5 border-b border-[var(--border)]/30 hover:bg-[var(--surface)] transition-colors
{selectedArticle?.id === article.id ? 'bg-[var(--accent)]/5 border-l-2 border-l-[var(--accent)]' : ''}"
>
<div class="flex items-start gap-2">
<span class="mt-1 text-[10px] {article.is_read ? 'text-[var(--text-dim)]' : 'text-[var(--accent)]'}">
{article.is_read ? '○' : '●'}
</span>
<div class="min-w-0">
<p class="text-sm truncate {article.is_read ? 'text-[var(--text-dim)]' : 'font-medium'}">{article.title}</p>
<div class="flex items-center gap-2 mt-0.5 text-[10px] text-[var(--text-dim)]">
<span>{article.ai_sub_group || ''}</span>
{#if article.ai_tags?.length}
<span>{article.ai_tags[0]?.split('/').pop()}</span>
{/if}
<span>{timeAgo(article.created_at)}</span>
</div>
</div>
</div>
</button>
{/each}
{/if}
</div>
<!-- 하단: 기사 미리보기 -->
{#if selectedArticle}
<div class="flex-1 overflow-y-auto p-5 bg-[var(--surface)]">
<h2 class="text-lg font-bold mb-2">{selectedArticle.title}</h2>
<div class="flex items-center gap-2 mb-4 text-xs text-[var(--text-dim)]">
<span>{selectedArticle.ai_sub_group}</span>
<span>·</span>
<span>{timeAgo(selectedArticle.created_at)}</span>
</div>
<div class="markdown-body mb-4">
{@html renderMd(selectedArticle.extracted_text || '')}
</div>
{#if selectedArticle.edit_url}
<a
href={selectedArticle.edit_url}
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-2 px-4 py-2 bg-[var(--accent)] text-white rounded-lg hover:bg-[var(--accent-hover)] text-sm"
>원문 보기 →</a>
{/if}
</div>
{/if}
</div>
</div>

View File

@@ -0,0 +1,9 @@
-- 읽음 상태
ALTER TABLE documents ADD COLUMN IF NOT EXISTS is_read BOOLEAN DEFAULT false;
-- 뉴스 인덱스
CREATE INDEX IF NOT EXISTS idx_documents_is_read ON documents(is_read) WHERE source_channel = 'news';
CREATE INDEX IF NOT EXISTS idx_news_feed ON documents(source_channel, created_at DESC);
-- 기존 뉴스 재분류 (taxonomy에서 분리)
UPDATE documents SET ai_domain = 'News', ai_sub_group = '' WHERE source_channel = 'news';