9d344c87ea
체크박스 체크 후 10초 경과 항목을 대시보드 핀 메모 / /memos 에서
자동 숨김, 메모 푸터 "완료 N개 보기" 버튼으로 토글.
- migration 161: documents.memo_task_state JSONB — {"<idx>":{"checked_at":"ISO"}}
- PATCH /memos/{id}/tasks/{task_index} 전용 엔드포인트:
· SELECT FOR UPDATE 로 동시 토글 race 차단
· task_index drift 시 stale state 자동 정리 (400 대신 200)
· AI 재처리/큐 enqueue 의도적 스킵 + memo_task_toggle_skip_ai 로그
- renderMemoHtml(taskStates, now) → 경과 항목에 memo-task-hidden 클래스
- Svelte 5 $effect cleanup 으로 setInterval 누수 방지
449 lines
15 KiB
Python
449 lines
15 KiB
Python
"""메모 CRUD API — 파일 없는 문서(file_type='note')"""
|
|
|
|
import hashlib
|
|
import logging
|
|
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, enqueue_stage
|
|
from models.user import User
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter()
|
|
|
|
# markdown task line: "- [ ] ..." 또는 "- [x] ..."
|
|
TASK_LINE_RE = re.compile(r"^(\s*- \[)([ xX])(\].*)$")
|
|
|
|
# #태그 파싱 패턴: 한글/영문/숫자/밑줄, 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()
|
|
|
|
|
|
def _auto_title(content: str) -> str:
|
|
"""첫 줄에서 제목 자동 생성 (80자 절단, 마크다운 헤딩 제거)"""
|
|
first_line = content.split("\n", 1)[0].strip()
|
|
title = re.sub(r"^#+\s*", "", first_line)[:80] or "메모"
|
|
return title
|
|
|
|
|
|
def _toggle_task_line(content: str, target_index: int, checked: bool) -> tuple[str, bool]:
|
|
"""N번째 markdown task line을 찾아 checked/unchecked 상태로 설정.
|
|
|
|
(new_content, found) 반환. found=False면 target_index에 해당하는 task line이 없음
|
|
(본문 편집으로 drift된 경우).
|
|
"""
|
|
lines = content.split("\n")
|
|
ti = 0
|
|
found = False
|
|
for i, line in enumerate(lines):
|
|
m = TASK_LINE_RE.match(line)
|
|
if not m:
|
|
continue
|
|
if ti == target_index:
|
|
mark = "x" if checked else " "
|
|
lines[i] = m.group(1) + mark + m.group(3)
|
|
found = True
|
|
break
|
|
ti += 1
|
|
return "\n".join(lines), found
|
|
|
|
|
|
async def _enqueue_ai_stages(session: AsyncSession, document_id: int):
|
|
"""classify + embed + chunk 큐 등록. 기존 pending 건 정리 (중복 방지)."""
|
|
stages = ["classify", "embed", "chunk"]
|
|
await session.execute(
|
|
delete(ProcessingQueue).where(
|
|
ProcessingQueue.document_id == document_id,
|
|
ProcessingQueue.stage.in_(stages),
|
|
ProcessingQueue.status == "pending",
|
|
)
|
|
)
|
|
for stage in stages:
|
|
await enqueue_stage(session, document_id, stage)
|
|
|
|
|
|
# ─── 스키마 ───
|
|
|
|
|
|
class MemoCreate(BaseModel):
|
|
content: str
|
|
title: str | None = None # 선택적 제목 (없으면 첫 줄 자동 생성)
|
|
ask_includable: bool = True
|
|
|
|
|
|
class MemoUpdate(BaseModel):
|
|
content: str
|
|
title: str | None = None # 명시 제목 변경 (None이면 자동 생성)
|
|
|
|
|
|
class ArchiveSet(BaseModel):
|
|
archived: bool
|
|
|
|
|
|
class TaskToggle(BaseModel):
|
|
checked: bool
|
|
|
|
|
|
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
|
|
archived: bool
|
|
ask_includable: bool
|
|
memo_task_state: dict # {"<task_index>": {"checked_at": "<ISO8601>"}}
|
|
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,
|
|
archived=doc.archived,
|
|
ask_includable=doc.ask_includable,
|
|
memo_task_state=dict(doc.memo_task_state or {}),
|
|
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="메모 내용이 비어있습니다")
|
|
|
|
doc = Document(
|
|
file_path=None,
|
|
file_hash=_content_hash(content),
|
|
file_format="md",
|
|
file_size=len(content.encode("utf-8")),
|
|
file_type="note",
|
|
title=body.title.strip() if body.title and body.title.strip() else _auto_title(content),
|
|
extracted_text=content,
|
|
review_status="approved",
|
|
source_channel="memo",
|
|
user_tags=_parse_hashtags(content),
|
|
pinned=False,
|
|
archived=False,
|
|
ask_includable=body.ask_includable,
|
|
)
|
|
session.add(doc)
|
|
await session.flush()
|
|
|
|
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 필터"),
|
|
archived: bool = Query(False, description="true면 아카이브 목록"),
|
|
pinned: bool | None = Query(None, description="true면 핀 고정된 메모만"),
|
|
):
|
|
"""메모 목록 — 활성: 핀 우선 + 최신순 / 아카이브: 최신순 (핀 무시)"""
|
|
base = select(Document).where(
|
|
Document.file_type == "note",
|
|
Document.source_channel == "memo",
|
|
Document.deleted_at == None, # noqa: E711
|
|
Document.archived == archived,
|
|
)
|
|
|
|
if pinned is not None:
|
|
base = base.where(Document.pinned == pinned)
|
|
|
|
if tag:
|
|
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
|
|
|
|
# 활성: pinned DESC + created_at DESC / 아카이브: created_at DESC (핀 무시)
|
|
if archived:
|
|
query = base.order_by(Document.created_at.desc())
|
|
else:
|
|
query = base.order_by(Document.pinned.desc(), Document.created_at.desc())
|
|
|
|
query = query.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="메모를 찾을 수 없습니다")
|
|
|
|
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"))
|
|
# PATCH semantics: title 필드를 명시적으로 보낸 경우만 덮어쓴다.
|
|
# 체크박스 토글 경로처럼 {content}만 PATCH 하면 기존 title을 보존해야 함
|
|
# (이전엔 None→_auto_title(content)로 제목이 체크박스 라인으로 덮어씌워지는 버그).
|
|
if "title" in body.model_fields_set:
|
|
doc.title = body.title.strip() if body.title and body.title.strip() else _auto_title(content)
|
|
elif not (doc.title or "").strip():
|
|
# 기존 title이 비어 있던 경우만 보강
|
|
doc.title = _auto_title(content)
|
|
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.doc_id == memo_id)
|
|
)
|
|
|
|
# 재처리 큐 등록
|
|
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.patch("/{memo_id}/tasks/{task_index}", response_model=MemoResponse)
|
|
async def toggle_memo_task(
|
|
memo_id: int,
|
|
task_index: int,
|
|
body: TaskToggle,
|
|
user: Annotated[User, Depends(get_current_user)],
|
|
session: Annotated[AsyncSession, Depends(get_session)],
|
|
):
|
|
"""메모 체크박스 토글 전용 엔드포인트.
|
|
|
|
N번째 markdown task line의 체크 상태를 설정하고 memo_task_state에 시각 기록.
|
|
AI 재처리(classify/embed/chunk)는 **의도적으로 스킵** — 체크박스 한 번에 재분석을 트리거하는 건 과하다.
|
|
같은 row를 동시에 토글하는 race 방지를 위해 SELECT ... FOR UPDATE 사용.
|
|
"""
|
|
# ❶ FOR UPDATE: 같은 row 동시 토글 race 차단 (JSONB 전체 replace라 필수)
|
|
doc = await session.get(Document, memo_id, with_for_update=True)
|
|
if not doc or doc.file_type != "note" or doc.deleted_at is not None:
|
|
raise HTTPException(status_code=404, detail="메모를 찾을 수 없습니다")
|
|
|
|
state = dict(doc.memo_task_state or {})
|
|
key = str(task_index)
|
|
|
|
# ❷ content의 N번째 task line 토글
|
|
new_content, found = _toggle_task_line(doc.extracted_text or "", task_index, body.checked)
|
|
if not found:
|
|
# drift: 사용자가 본문 편집으로 task_index 매칭이 깨짐 → stale state만 정리하고 200 OK
|
|
stale_removed = key in state
|
|
if stale_removed:
|
|
state.pop(key, None)
|
|
doc.memo_task_state = state
|
|
await session.commit()
|
|
await session.refresh(doc)
|
|
logger.info(
|
|
"memo_task_toggle_drift memo_id=%s task_index=%s stale_removed=%s",
|
|
memo_id, task_index, stale_removed,
|
|
)
|
|
return _to_memo_response(doc)
|
|
|
|
doc.extracted_text = new_content
|
|
doc.file_hash = _content_hash(new_content)
|
|
doc.file_size = len(new_content.encode("utf-8"))
|
|
|
|
# ❸ task_state 갱신 (JSONB 전체 replace — FOR UPDATE lock 아래라 race safe)
|
|
if body.checked:
|
|
state[key] = {"checked_at": datetime.now(timezone.utc).isoformat()}
|
|
else:
|
|
state.pop(key, None)
|
|
doc.memo_task_state = state
|
|
|
|
doc.updated_at = datetime.now(timezone.utc)
|
|
# AI 재처리 / user_tags 재파싱 / chunks 삭제 / queue enqueue — 모두 의도적 스킵.
|
|
# 왜 스킵하는지 나중에 디버깅하지 않아도 되도록 명시 로그.
|
|
logger.info(
|
|
"memo_task_toggle_skip_ai memo_id=%s task_index=%s checked=%s",
|
|
memo_id, task_index, body.checked,
|
|
)
|
|
|
|
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}/archive", response_model=MemoResponse)
|
|
async def set_archive(
|
|
memo_id: int,
|
|
body: ArchiveSet,
|
|
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.archived = body.archived
|
|
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)
|