Files
hyungi_document_server/app/api/memos.py
T
Hyungi Ahn b46a75758b feat(memos): 내장 메모 기능 — 파일 없는 문서(file_type='note')
Document Server에 Memos 앱 대체 기능 내장. 메모를 documents 테이블의
file_type='note' 레코드로 관리하여 기존 AI 파이프라인(classify/embed/
chunk/search/ask) 재활용.

Backend:
- migration 105: source_channel 'memo', file_path NULL 허용,
  user_tags/pinned/ask_includable 컬럼, 메모 인덱스
- api/memos.py: CRUD 7개 엔드포인트 + #태그 파싱 + stale AI 초기화
  + 큐 pending 중복 방지
- queue_consumer: note extract/preview skip
- documents API: file_path NULL 가드, 목록에서 메모 제외
- search /ask: ask_includable=false 문서 evidence 제외

Frontend:
- /memos 타임라인 페이지 (빠른 입력 + 피드 + 인라인 편집 + 태그 필터)
- QuickMemoButton FAB (Ctrl+M, 모든 페이지)
- Sidebar 메모 링크

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 16:00:00 +09:00

321 lines
9.7 KiB
Python

"""메모 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)