diff --git a/app/api/documents.py b/app/api/documents.py index 44513b4..b28e67e 100644 --- a/app/api/documents.py +++ b/app/api/documents.py @@ -27,7 +27,7 @@ router = APIRouter() class DocumentResponse(BaseModel): id: int - file_path: str + file_path: str | None file_format: str file_size: int | None file_type: str @@ -40,6 +40,9 @@ class DocumentResponse(BaseModel): importance: str | None ai_confidence: float | None user_note: str | None + user_tags: list | None + pinned: bool | None + ask_includable: bool | None derived_path: str | None original_format: str | None conversion_status: str | None @@ -149,8 +152,12 @@ async def list_documents( format: str | None = None, review_status: str | None = Query(None, description="pending | approved | rejected"), ): - """문서 목록 조회 (페이지네이션 + 필터, 뉴스 제외)""" - query = select(Document).where(Document.deleted_at == None, Document.source_channel != "news") + """문서 목록 조회 (페이지네이션 + 필터, 뉴스/메모 제외)""" + query = select(Document).where( + Document.deleted_at == None, # noqa: E711 + Document.source_channel != "news", + Document.file_type != "note", + ) if domain: # prefix 매칭: Industrial_Safety 클릭 시 하위 전부 포함 @@ -216,6 +223,10 @@ async def get_document_file( if not doc: raise HTTPException(status_code=404, detail="문서를 찾을 수 없습니다") + # note(메모)는 물리 파일이 없음 + if not doc.file_path: + raise HTTPException(status_code=404, detail="파일이 없는 문서입니다 (메모)") + file_path = Path(settings.nas_mount_path) / doc.file_path if not file_path.exists(): raise HTTPException(status_code=404, detail="파일을 찾을 수 없습니다") @@ -340,6 +351,10 @@ async def save_document_content( if doc.file_format not in ("md", "txt"): raise HTTPException(status_code=400, detail="편집 가능한 포맷이 아닙니다 (md, txt만 가능)") + # note(메모)는 /api/memos/{id} PATCH로 수정 + if not doc.file_path: + raise HTTPException(status_code=400, detail="파일이 없는 문서입니다. 메모는 /api/memos 사용") + content = body.get("content", "") if body else "" file_path = Path(settings.nas_mount_path) / doc.file_path file_path.write_text(content, encoding="utf-8") diff --git a/app/api/memos.py b/app/api/memos.py new file mode 100644 index 0000000..3e889bd --- /dev/null +++ b/app/api/memos.py @@ -0,0 +1,320 @@ +"""메모 CRUD API — 파일 없는 문서(file_type='note')""" + +import hashlib +import re +from datetime import datetime, timezone +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel +from sqlalchemy import delete, func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from core.auth import get_current_user +from core.database import get_session +from models.document import Document +from models.queue import ProcessingQueue +from models.user import User + +router = APIRouter() + +# #태그 파싱 패턴: 한글/영문/숫자/밑줄, 2자 이상 +TAG_PATTERN = re.compile(r"(?:^|(?<=\s))#([가-힣a-zA-Z0-9_]{2,})") + + +def _parse_hashtags(content: str) -> list[str]: + """본문에서 #태그 추출, 중복 제거, 순서 유지""" + seen: set[str] = set() + tags: list[str] = [] + for m in TAG_PATTERN.finditer(content): + tag = m.group(1) + if tag not in seen: + seen.add(tag) + tags.append(tag) + return tags + + +def _content_hash(content: str) -> str: + """메모 본문의 SHA-256 해시 (note의 file_hash = content hash)""" + return hashlib.sha256(content.encode("utf-8")).hexdigest() + + +async def _enqueue_ai_stages(session: AsyncSession, document_id: int): + """classify + embed + chunk 큐 등록. 기존 pending 건 정리 (중복 방지).""" + stages = ["classify", "embed", "chunk"] + # 기존 pending 건 삭제 (processing은 건드리지 않음 — worker 충돌 방지) + await session.execute( + delete(ProcessingQueue).where( + ProcessingQueue.document_id == document_id, + ProcessingQueue.stage.in_(stages), + ProcessingQueue.status == "pending", + ) + ) + for stage in stages: + session.add(ProcessingQueue( + document_id=document_id, + stage=stage, + status="pending", + )) + + +# ─── 스키마 ─── + + +class MemoCreate(BaseModel): + content: str + title: str | None = None + ask_includable: bool = True + + +class MemoUpdate(BaseModel): + content: str | None = None + title: str | None = None + + +class MemoResponse(BaseModel): + id: int + title: str | None + content: str | None # extracted_text + file_format: str + user_tags: list | None + ai_tags: list | None + ai_domain: str | None + ai_sub_group: str | None + ai_summary: str | None + pinned: bool + ask_includable: bool + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +class MemoListResponse(BaseModel): + items: list[MemoResponse] + total: int + page: int + page_size: int + + +def _to_memo_response(doc: Document) -> MemoResponse: + return MemoResponse( + id=doc.id, + title=doc.title, + content=doc.extracted_text, + file_format=doc.file_format, + user_tags=doc.user_tags, + ai_tags=doc.ai_tags, + ai_domain=doc.ai_domain, + ai_sub_group=doc.ai_sub_group, + ai_summary=doc.ai_summary, + pinned=doc.pinned, + ask_includable=doc.ask_includable, + created_at=doc.created_at, + updated_at=doc.updated_at, + ) + + +# ─── 엔드포인트 ─── + + +@router.post("/", response_model=MemoResponse, status_code=201) +async def create_memo( + body: MemoCreate, + user: Annotated[User, Depends(get_current_user)], + session: Annotated[AsyncSession, Depends(get_session)], +): + """메모 생성 — file_type='note', 파일 없는 문서""" + content = body.content.strip() + if not content: + raise HTTPException(status_code=400, detail="메모 내용이 비어있습니다") + + # 제목: 명시 지정 또는 첫 줄에서 추출 + title = body.title + if not title: + first_line = content.split("\n", 1)[0].strip() + # 마크다운 헤딩 제거 + title = re.sub(r"^#+\s*", "", first_line)[:100] or "메모" + + user_tags = _parse_hashtags(content) + + doc = Document( + file_path=None, + file_hash=_content_hash(content), + file_format="md", + file_size=len(content.encode("utf-8")), + file_type="note", + title=title, + extracted_text=content, + review_status="approved", + source_channel="memo", + user_tags=user_tags, + pinned=False, + ask_includable=body.ask_includable, + ) + session.add(doc) + await session.flush() # ID 확보 + + await _enqueue_ai_stages(session, doc.id) + await session.commit() + await session.refresh(doc) + + return _to_memo_response(doc) + + +@router.get("/", response_model=MemoListResponse) +async def list_memos( + user: Annotated[User, Depends(get_current_user)], + session: Annotated[AsyncSession, Depends(get_session)], + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + tag: str | None = Query(None, description="user_tags 또는 ai_tags 필터"), +): + """메모 목록 — 핀 우선 + 최신순""" + base = select(Document).where( + Document.file_type == "note", + Document.deleted_at == None, # noqa: E711 + ) + + if tag: + # user_tags 또는 ai_tags에 포함된 태그 필터 + base = base.where( + Document.user_tags.op("@>")(f'["{tag}"]') + | Document.ai_tags.op("@>")(f'["{tag}"]') + ) + + count_query = select(func.count()).select_from(base.subquery()) + total = (await session.execute(count_query)).scalar() or 0 + + query = base.order_by( + Document.pinned.desc(), + Document.created_at.desc(), + ).offset((page - 1) * page_size).limit(page_size) + + result = await session.execute(query) + items = result.scalars().all() + + return MemoListResponse( + items=[_to_memo_response(doc) for doc in items], + total=total, + page=page, + page_size=page_size, + ) + + +@router.get("/{memo_id}", response_model=MemoResponse) +async def get_memo( + memo_id: int, + user: Annotated[User, Depends(get_current_user)], + session: Annotated[AsyncSession, Depends(get_session)], +): + """메모 단건 조회""" + doc = await session.get(Document, memo_id) + if not doc or doc.file_type != "note" or doc.deleted_at is not None: + raise HTTPException(status_code=404, detail="메모를 찾을 수 없습니다") + return _to_memo_response(doc) + + +@router.patch("/{memo_id}", response_model=MemoResponse) +async def update_memo( + memo_id: int, + body: MemoUpdate, + user: Annotated[User, Depends(get_current_user)], + session: Annotated[AsyncSession, Depends(get_session)], +): + """메모 수정 — content 변경 시 AI 데이터 초기화 + 재처리 큐 등록""" + doc = await session.get(Document, memo_id) + if not doc or doc.file_type != "note" or doc.deleted_at is not None: + raise HTTPException(status_code=404, detail="메모를 찾을 수 없습니다") + + if body.title is not None: + doc.title = body.title + + if body.content is not None: + content = body.content.strip() + if not content: + raise HTTPException(status_code=400, detail="메모 내용이 비어있습니다") + + doc.extracted_text = content + doc.file_hash = _content_hash(content) + doc.file_size = len(content.encode("utf-8")) + doc.user_tags = _parse_hashtags(content) + + # stale AI 데이터 즉시 초기화 + doc.ai_summary = None + doc.ai_domain = None + doc.ai_sub_group = None + doc.ai_tags = None + doc.ai_confidence = None + doc.ai_processed_at = None + doc.embedding = None + doc.embedded_at = None + + # 기존 chunks 삭제 + from models.chunk import DocumentChunk + await session.execute( + delete(DocumentChunk).where(DocumentChunk.document_id == memo_id) + ) + + # 재처리 큐 등록 (pending만 정리, processing은 건드리지 않음) + await _enqueue_ai_stages(session, memo_id) + + doc.updated_at = datetime.now(timezone.utc) + await session.commit() + await session.refresh(doc) + + return _to_memo_response(doc) + + +@router.delete("/{memo_id}", status_code=204) +async def delete_memo( + memo_id: int, + user: Annotated[User, Depends(get_current_user)], + session: Annotated[AsyncSession, Depends(get_session)], +): + """메모 soft delete""" + doc = await session.get(Document, memo_id) + if not doc or doc.file_type != "note" or doc.deleted_at is not None: + raise HTTPException(status_code=404, detail="메모를 찾을 수 없습니다") + + doc.deleted_at = datetime.now(timezone.utc) + await session.commit() + + +@router.patch("/{memo_id}/pin", response_model=MemoResponse) +async def toggle_pin( + memo_id: int, + user: Annotated[User, Depends(get_current_user)], + session: Annotated[AsyncSession, Depends(get_session)], +): + """메모 핀 토글""" + doc = await session.get(Document, memo_id) + if not doc or doc.file_type != "note" or doc.deleted_at is not None: + raise HTTPException(status_code=404, detail="메모를 찾을 수 없습니다") + + doc.pinned = not doc.pinned + doc.updated_at = datetime.now(timezone.utc) + await session.commit() + await session.refresh(doc) + + return _to_memo_response(doc) + + +@router.patch("/{memo_id}/ask-includable", response_model=MemoResponse) +async def toggle_ask_includable( + memo_id: int, + user: Annotated[User, Depends(get_current_user)], + session: Annotated[AsyncSession, Depends(get_session)], +): + """/ask 합성 포함 여부 토글""" + doc = await session.get(Document, memo_id) + if not doc or doc.file_type != "note" or doc.deleted_at is not None: + raise HTTPException(status_code=404, detail="메모를 찾을 수 없습니다") + + doc.ask_includable = not doc.ask_includable + doc.updated_at = datetime.now(timezone.utc) + await session.commit() + await session.refresh(doc) + + return _to_memo_response(doc) diff --git a/app/api/search.py b/app/api/search.py index da46c34..39acca1 100644 --- a/app/api/search.py +++ b/app/api/search.py @@ -389,9 +389,28 @@ async def ask( fusion=DEFAULT_FUSION, rerank=True, analyze=True, ) + # 1.5. ask_includable=false 문서를 evidence 입력에서 제외 + # 검색 결과 자체는 유지 (사용자에게 보여줌), evidence만 필터 + if pr.results: + from sqlalchemy import select as sa_select + from models.document import Document as DocModel + ask_doc_ids = set() + excluded_ids = {r.id for r in pr.results} + rows = await session.execute( + sa_select(DocModel.id, DocModel.ask_includable).where( + DocModel.id.in_(excluded_ids) + ) + ) + for doc_id, includable in rows: + if includable is False: + ask_doc_ids.add(doc_id) + evidence_results = [r for r in pr.results if r.id not in ask_doc_ids] + else: + evidence_results = pr.results + # 2. Evidence + Classifier 병렬 t_ev = time.perf_counter() - evidence_task = asyncio.create_task(extract_evidence(q, pr.results)) + evidence_task = asyncio.create_task(extract_evidence(q, evidence_results)) # classifier input: top 3 chunks meta + rerank scores top_chunks = [ diff --git a/app/main.py b/app/main.py index 35381a3..fe40f9f 100644 --- a/app/main.py +++ b/app/main.py @@ -10,6 +10,7 @@ from api.auth import router as auth_router from api.dashboard import router as dashboard_router from api.digest import router as digest_router from api.documents import router as documents_router +from api.memos import router as memos_router from api.news import router as news_router from api.search import router as search_router from api.setup import router as setup_router @@ -89,6 +90,7 @@ app.include_router(auth_router, prefix="/api/auth", tags=["auth"]) app.include_router(documents_router, prefix="/api/documents", tags=["documents"]) app.include_router(search_router, prefix="/api/search", tags=["search"]) +app.include_router(memos_router, prefix="/api/memos", tags=["memos"]) app.include_router(dashboard_router, prefix="/api/dashboard", tags=["dashboard"]) app.include_router(news_router, prefix="/api/news", tags=["news"]) app.include_router(digest_router, prefix="/api/digest", tags=["digest"]) diff --git a/app/models/document.py b/app/models/document.py index c262620..4c4f28d 100644 --- a/app/models/document.py +++ b/app/models/document.py @@ -7,6 +7,8 @@ from sqlalchemy import BigInteger, Boolean, DateTime, Enum, String, Text from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.orm import Mapped, mapped_column +# Note: file_type='note' (메모) 문서는 file_path=NULL, file_hash=content SHA-256 + from core.database import Base @@ -16,7 +18,7 @@ class Document(Base): id: Mapped[int] = mapped_column(BigInteger, primary_key=True) # 1계층: 원본 파일 - file_path: Mapped[str] = mapped_column(Text, unique=True, nullable=False) + file_path: Mapped[str | None] = mapped_column(Text, nullable=True) file_hash: Mapped[str] = mapped_column(String(64), nullable=False) file_format: Mapped[str] = mapped_column(String(20), nullable=False) file_size: Mapped[int | None] = mapped_column(BigInteger) @@ -50,6 +52,15 @@ class Document(Base): # 사용자 메모 user_note: Mapped[str | None] = mapped_column(Text) + # 사용자 태그 (ai_tags와 분리, #태그 파싱 결과 또는 수동 입력) + user_tags: Mapped[list | None] = mapped_column(JSONB, default=[]) + + # 핀 고정 + pinned: Mapped[bool] = mapped_column(Boolean, default=False) + + # /ask 합성 포함 여부 (false면 검색은 되지만 evidence에서 제외) + ask_includable: Mapped[bool] = mapped_column(Boolean, default=True) + # ODF 변환 derived_path: Mapped[str | None] = mapped_column(Text) # 변환본 경로 (.derived/) original_format: Mapped[str | None] = mapped_column(String(20)) @@ -73,7 +84,7 @@ class Document(Base): # 메타데이터 source_channel: Mapped[str | None] = mapped_column( Enum("law_monitor", "devonagent", "email", "web_clip", - "tksafety", "inbox_route", "manual", "drive_sync", "news", + "tksafety", "inbox_route", "manual", "drive_sync", "news", "memo", name="source_channel") ) data_origin: Mapped[str | None] = mapped_column( diff --git a/app/workers/queue_consumer.py b/app/workers/queue_consumer.py index 9ac74eb..175e453 100644 --- a/app/workers/queue_consumer.py +++ b/app/workers/queue_consumer.py @@ -109,6 +109,22 @@ async def consume_queue(): # 워커 실행 (독립 세션) try: + # note(메모)는 이미 extracted_text가 있으므로 extract/preview skip + if stage in ("extract", "preview"): + from models.document import Document + async with async_session() as check_session: + doc = await check_session.get(Document, document_id) + if doc and doc.file_type == "note": + async with async_session() as skip_session: + item = await skip_session.get(ProcessingQueue, queue_id) + if item: + item.status = "completed" + item.completed_at = datetime.now(timezone.utc) + await skip_session.commit() + await enqueue_next_stage(document_id, stage) + logger.info(f"[{stage}] document_id={document_id} skip (note)") + continue + async with async_session() as worker_session: await worker_fn(document_id, worker_session) await worker_session.commit() diff --git a/frontend/src/lib/components/QuickMemoButton.svelte b/frontend/src/lib/components/QuickMemoButton.svelte new file mode 100644 index 0000000..0571cad --- /dev/null +++ b/frontend/src/lib/components/QuickMemoButton.svelte @@ -0,0 +1,127 @@ + + + + + +{#if !open} + +{/if} + + +{#if open} + + + + +
+
+ 빠른 메모 + +
+ +
+ + +
+ #태그 지원 +
+ + +
+
+
+
+{/if} diff --git a/frontend/src/lib/components/Sidebar.svelte b/frontend/src/lib/components/Sidebar.svelte index d5c6c88..15e4c29 100644 --- a/frontend/src/lib/components/Sidebar.svelte +++ b/frontend/src/lib/components/Sidebar.svelte @@ -2,7 +2,7 @@ import { page } from '$app/stores'; import { goto } from '$app/navigation'; import { api } from '$lib/api'; - import { ChevronRight, ChevronDown, FolderOpen, Inbox, Clock, Mail, Scale } from 'lucide-svelte'; + import { ChevronRight, ChevronDown, FolderOpen, Inbox, Clock, Mail, Scale, StickyNote } from 'lucide-svelte'; let tree = $state([]); let loading = $state(true); @@ -190,8 +190,18 @@ - +
+ + + + 메모 + +
+ + + {:else} diff --git a/frontend/src/routes/memos/+page.svelte b/frontend/src/routes/memos/+page.svelte new file mode 100644 index 0000000..8810b9a --- /dev/null +++ b/frontend/src/routes/memos/+page.svelte @@ -0,0 +1,421 @@ + + + + 메모 — Document Server + + +
+ +
+

메모

+ {#if total > 0} + {total}개 + {/if} +
+ + + {#if activeTag} +
+ 필터: + +
+ {/if} + + + + +
+ 마크다운 지원 + +
+
+ + + {#if loading} + {#each Array(3) as _} +
+ +
+ {/each} + {:else if memos.length === 0} + + {#if activeTag} +

#{activeTag} 태그의 메모가 없습니다

+ {:else} +

아직 메모가 없습니다

+

위 입력창에서 첫 메모를 작성해보세요

+ {/if} +
+ {:else} +
+ {#each memos as memo (memo.id)} + + + {#if memo.pinned} +
+ +
+ {/if} + +
+ {#if editingId === memo.id} + + +
+ + +
+ {:else} + + {#if memo.title} +

{memo.title}

+ {/if} +
+ {@html renderMd(memo.content || '')} +
+ {/if} + + + {#if (memo.user_tags?.length || memo.ai_tags?.length) && editingId !== memo.id} +
+ {#each memo.user_tags || [] as tag} + + {/each} + {#each memo.ai_tags || [] as tag} + + {/each} +
+ {/if} + + + {#if editingId !== memo.id} +
+
+ {formatTime(memo.created_at)} + {#if memo.ai_domain} + | {memo.ai_domain} + {/if} + {#if !memo.ask_includable} + + AI 제외 + + {/if} +
+ +
+ + + + +
+
+ {/if} +
+
+ {/each} +
+ + + {#if total > 20} +
+ + {page} / {Math.ceil(total / 20)} + +
+ {/if} + {/if} +
+ + diff --git a/migrations/105_memo_support.sql b/migrations/105_memo_support.sql new file mode 100644 index 0000000..3e1da89 --- /dev/null +++ b/migrations/105_memo_support.sql @@ -0,0 +1,28 @@ +-- 105: 메모(note) 기능 지원 +-- 메모 = file_type='note'인 document (파일 없는 문서) +-- file_hash는 note에서 content SHA-256 (파일 해시가 아닌 본문 버전 해시) + +-- source_channel enum에 'memo' 추가 (유입 경로: 내장 메모 UI) +ALTER TYPE source_channel ADD VALUE IF NOT EXISTS 'memo'; + +-- file_path: NOT NULL 제거 (메모는 파일 없음) +ALTER TABLE documents ALTER COLUMN file_path DROP NOT NULL; + +-- file_path: 기존 UNIQUE → partial unique (NULL 허용, 값 있으면 유니크) +DROP INDEX IF EXISTS documents_file_path_key; +CREATE UNIQUE INDEX IF NOT EXISTS uq_documents_file_path + ON documents(file_path) WHERE file_path IS NOT NULL; + +-- user_tags: 사용자 수동 태그 (ai_tags와 분리, list[str]) +ALTER TABLE documents ADD COLUMN IF NOT EXISTS user_tags JSONB DEFAULT '[]'::jsonb; + +-- pinned: 메모 핀 고정 +ALTER TABLE documents ADD COLUMN IF NOT EXISTS pinned BOOLEAN DEFAULT false; + +-- ask_includable: /ask 합성 포함 여부 (false면 검색은 되지만 /ask evidence에서 제외) +ALTER TABLE documents ADD COLUMN IF NOT EXISTS ask_includable BOOLEAN DEFAULT true; + +-- 메모 목록 최적화 인덱스 (핀 우선 + 최신순) +CREATE INDEX IF NOT EXISTS idx_documents_notes + ON documents(pinned DESC, created_at DESC) + WHERE file_type = 'note' AND deleted_at IS NULL;