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 @@
+
+
+
#{activeTag} 태그의 메모가 없습니다
+ {:else} +아직 메모가 없습니다
+위 입력창에서 첫 메모를 작성해보세요
+ {/if} +