diff --git a/app/api/documents.py b/app/api/documents.py index a867120..862b9cd 100644 --- a/app/api/documents.py +++ b/app/api/documents.py @@ -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 클릭 시 하위 전부 포함 diff --git a/app/api/news.py b/app/api/news.py index dfabe96..6da312a 100644 --- a/app/api/news.py +++ b/app/api/news.py @@ -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)], diff --git a/app/models/document.py b/app/models/document.py index 250980f..c262620 100644 --- a/app/models/document.py +++ b/app/models/document.py @@ -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)) diff --git a/app/workers/news_collector.py b/app/workers/news_collector.py index 350bc7a..3e7533e 100644 --- a/app/workers/news_collector.py +++ b/app/workers/news_collector.py @@ -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")) diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index ad89d8c..1cb7e0b 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -76,6 +76,7 @@ 문서