"""메모 CRUD API — text 메모(file_type='note') + voice 메모 (file_type='immutable', category='audio', source_channel='voice') doc_type enum = (immutable, editable, note). 기존 audio 파일이 file_type='immutable' + category='audio' 패턴을 사용하므로 voice 메모도 같은 패턴 따름 (enum 확장 회피). """ import hashlib import logging import os import re import uuid from datetime import datetime, timezone from pathlib import Path from typing import Annotated, Any from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile from pydantic import BaseModel, Field from sqlalchemy import delete, func, select from sqlalchemy.ext.asyncio import AsyncSession from core.auth import get_current_user from core.config import settings from core.database import get_session from models.document import Document from models.event import Event from models.event_history import EventHistory from models.queue import ProcessingQueue, enqueue_stage from models.user import User # Voice upload 제한 (plan v9 결정 — 10분 / 50MB) VOICE_MAX_BYTES = 50 * 1024 * 1024 VOICE_ALLOWED_EXTS = {".m4a", ".mp3", ".wav", ".webm", ".ogg", ".opus", ".aac"} VOICE_ALLOWED_CONTENT_PREFIXES = ("audio/",) VOICE_NAS_SUBDIR = "PKM/Recordings" # /mnt/nas/Document_Server/PKM/Recordings/{YYYY-MM}/{uuid}.{ext} 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 def _sync_task_state_with_content(content: str, existing_state: dict | None) -> dict: """content 의 체크리스트 상태를 memo_task_state 와 동기화. - content 의 `- [x]` 중 state 에 checked_at 이 없으면 현재 시각으로 기록 → 본문에 `- [x]` 로 직접 입력된 legacy 항목도 저장 시각 기준으로 10초 후 숨김 동작. - content 의 `- [ ]` 에 해당하는 index 는 state 에서 제거. - content 에 task 가 줄어들어 사라진 index 도 정리. """ state = dict(existing_state or {}) current_keys: set[str] = set() task_idx = 0 now_iso = datetime.now(timezone.utc).isoformat() for line in (content or "").split("\n"): m = TASK_LINE_RE.match(line) if not m: continue key = str(task_idx) is_checked = m.group(2).lower() == "x" if is_checked: current_keys.add(key) entry = state.get(key) or {} if not entry.get("checked_at"): state[key] = {"checked_at": now_iso} # unchecked 는 current_keys 에 넣지 않음 → 아래에서 제거 task_idx += 1 # content 에서 unchecked 가 됐거나 아예 사라진 index 의 state 정리 for k in list(state.keys()): if k not in current_keys: state.pop(k, None) return state 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 # PR-Hermes-Docsrv-Bridge-1: 외부 채널 진입점 식별. default='memo' (web UI 호환). # 허용 값: memo / voice / hermes / ... (app/models/document.py source_channel enum). source_channel: str | None = None # PR-Hermes-Docsrv-Bridge-1: channel/user/message_id/timestamp 등 채널 메타. source_metadata: dict | None = None 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 # {"": {"checked_at": ""}} # Memo Intake Upgrade PR-2B — AI 추천 분류 (사용자 1-click promote 의 hint) ai_event_kind: str | None = None ai_event_confidence: float | None = None source_channel: str | None = None # voice/memo/hermes 등 진입점 식별 (UI 배지) source_metadata: dict = {} # PR-Hermes-Docsrv-Bridge-1: channel/user/message_id/timestamp file_type: str | None = None # audio (voice 메모) vs note (text 메모) file_path: str | None = None # voice 메모의 NAS audio 경로 (audio player 용) 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 {}), ai_event_kind=doc.ai_event_kind, ai_event_confidence=doc.ai_event_confidence, source_channel=doc.source_channel, source_metadata=dict(doc.source_metadata or {}), file_type=doc.file_type, file_path=doc.file_path, 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="메모 내용이 비어있습니다") # PR-Hermes-Docsrv-Bridge-1: source_channel/metadata override 가능. default='memo' (기존 web UI 호환). channel = body.source_channel or "memo" if channel not in ("memo", "voice", "hermes"): raise HTTPException( status_code=400, detail=f"source_channel '{channel}' 허용 안 됨 (memo/voice/hermes 만)", ) 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=channel, source_metadata=body.source_metadata or {}, user_tags=_parse_hashtags(content), pinned=False, archived=False, ask_includable=body.ask_includable, # 본문에 `- [x]` 로 입력된 체크 항목도 생성 시각 기준 10초 후 자동 숨김 대상이 되도록 sync. memo_task_state=_sync_task_state_with_content(content, None), ) 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면 핀 고정된 메모만"), ): """메모 목록 — 활성: 핀 우선 + 최신순 / 아카이브: 최신순 (핀 무시) PR-2C: source_channel='voice' (음성 메모) 도 포함. 사용자 의도 = 메모는 모든 입력의 inbox. voice 메모는 file_type='immutable' + category='audio' + source_channel='voice' 패턴. source_channel 만으로 분리 (file_type 필터는 immutable 다른 binary 까지 끌어옴 — 회피). PR-Hermes-Docsrv-Bridge-1: source_channel='hermes' (Hermes Discord 등 외부 채널 진입) 도 inbox 포함. """ base = select(Document).where( Document.source_channel.in_(("memo", "voice", "hermes")), 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")) # 본문 편집으로 task 순서/추가/삭제가 일어났을 수 있으니 state 재동기화. # `- [x]` 에 checked_at 없으면 이번 수정 시각으로 기록 → 10초 후 자동 숨김 동작. doc.memo_task_state = _sync_task_state_with_content(content, doc.memo_task_state) # 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) # ─── Memo Intake Upgrade PR-2B: promote to event ─── class PromotePayload(BaseModel): """메모 → events 승급. kind 미지정 시 documents.ai_event_kind 사용. AI worker 는 events row 직접 생성 X — 본 endpoint 만이 사용자 의도 channel. """ kind: str | None = None # 'task' | 'calendar_event' | 'activity_log' due_at: datetime | None = None start_at: datetime | None = None end_at: datetime | None = None started_at: datetime | None = None ended_at: datetime | None = None priority: int | None = None project_tag: str | None = None _PROMOTE_KIND_MAP = { # AI 추천 (event_kind_hint) → events.kind "task": "task", "calendar_event": "calendar_event", "activity_log": "activity_log", # 'note' / 'reference' 는 promote 대상 아님 (사용자가 명시 kind 지정 필요) } @router.post("/{memo_id}/promote-to-event", status_code=201) async def promote_memo_to_event( memo_id: int, body: PromotePayload, user: Annotated[User, Depends(get_current_user)], session: Annotated[AsyncSession, Depends(get_session)], ): """메모 1건 → events row 1건 생성. memo_document_id 자동 link. kind 결정 순서: body.kind > documents.ai_event_kind > 400 거부. 한 메모 → N events 가능 (정책: dedup 없음, 사용자 의도 따라). """ doc = await session.get(Document, memo_id) if ( not doc or doc.deleted_at is not None or doc.source_channel not in ("memo", "voice") ): raise HTTPException(status_code=404, detail="메모를 찾을 수 없습니다") # kind 결정 requested = (body.kind or "").strip().lower() or None ai_hint = (doc.ai_event_kind or "").strip().lower() or None chosen = requested or ai_hint event_kind = _PROMOTE_KIND_MAP.get(chosen or "") if not event_kind: raise HTTPException( status_code=400, detail="promote 할 kind 가 명확하지 않습니다 (task/calendar_event/activity_log 중 1개 지정 또는 ai_event_kind 필요)", ) # 시간 필드 default — activity_log 는 빠른 행동 기록 UX 그대로 now = datetime.now(timezone.utc) started_at = body.started_at ended_at = body.ended_at completed_at: datetime | None = None status_val = "inbox" if event_kind == "activity_log": ended_at = ended_at or now started_at = started_at or ended_at completed_at = now status_val = "done" elif event_kind == "calendar_event": status_val = "scheduled" if body.start_at else "inbox" title = (doc.title or "").strip() or "메모" description = doc.extracted_text ev = Event( title=title, description=description, kind=event_kind, status=status_val, due_at=body.due_at, start_at=body.start_at, end_at=body.end_at, started_at=started_at, ended_at=ended_at, completed_at=completed_at, priority=body.priority, project_tag=body.project_tag, source="memo", source_ref=str(doc.id), # 같은 메모 N promote 시 별 row → dedup 의도 X raw_metadata={ "memo_id": doc.id, "ai_event_kind": doc.ai_event_kind, "ai_event_confidence": doc.ai_event_confidence, "promoted_at": now.isoformat(), }, memo_document_id=doc.id, user_id=user.id, created_by="manual", ) session.add(ev) await session.flush() # events_history.create row (events 도메인 패턴 — events/api/events.py 의 _record_history 와 동일 형태) history = EventHistory( event_id=ev.id, changed_by="manual", change_kind="create", before=None, after={ "id": ev.id, "title": ev.title, "kind": ev.kind, "status": ev.status, "source": ev.source, "memo_document_id": ev.memo_document_id, }, ) session.add(history) await session.commit() await session.refresh(ev) return { "event_id": ev.id, "kind": ev.kind, "status": ev.status, "memo_document_id": ev.memo_document_id, } @router.post("/{memo_id}/dismiss-event-suggestion", response_model=MemoResponse) async def dismiss_event_suggestion( memo_id: int, user: Annotated[User, Depends(get_current_user)], session: Annotated[AsyncSession, Depends(get_session)], ): """'그냥 메모' — AI 추천 무시 + ai_event_kind='note' 강제. 4 버튼 숨김 신호. MVP: AI 추천값과 사용자 확정값을 같은 컬럼에 저장 (정확도 측정 흐려짐 가능). 백로그: user_event_kind 별 컬럼 분리 (plan Memo Intake Upgrade 백로그). """ doc = await session.get(Document, memo_id) if ( not doc or doc.deleted_at is not None or doc.source_channel not in ("memo", "voice") ): raise HTTPException(status_code=404, detail="메모를 찾을 수 없습니다") doc.ai_event_kind = "note" doc.updated_at = datetime.now(timezone.utc) await session.commit() await session.refresh(doc) return _to_memo_response(doc) # ─── Memo Intake Upgrade PR-2C: voice upload ─── @router.post("/voice", response_model=MemoResponse, status_code=201) async def upload_voice_memo( user: Annotated[User, Depends(get_current_user)], session: Annotated[AsyncSession, Depends(get_session)], audio: UploadFile = File(...), recorded_at: str | None = Form(None), device_hint: str | None = Form(None), ): """애플워치 / 모바일 / 기타 음성 메모 업로드 → STT 큐 → 자동 분류. PR-2C: source_channel='voice' + file_type='audio'. 기존 stt_worker → classify 파이프라인 자동 통과. plan 원칙: AI worker 는 events 직접 생성 X. """ # Content-Type 검증 if audio.content_type and not audio.content_type.startswith(VOICE_ALLOWED_CONTENT_PREFIXES): raise HTTPException(status_code=415, detail=f"지원되지 않는 Content-Type: {audio.content_type}") # 확장자 결정 orig_name = audio.filename or "" ext = (Path(orig_name).suffix or "").lower() if ext and ext not in VOICE_ALLOWED_EXTS: raise HTTPException(status_code=415, detail=f"지원되지 않는 확장자: {ext}") if not ext: # content_type 으로 추정 (audio/m4a 등) ext = ".m4a" # 본문 읽기 + size 검증 payload: bytes = await audio.read() if len(payload) > VOICE_MAX_BYTES: raise HTTPException(status_code=413, detail=f"50MB 초과 ({len(payload)//1024//1024}MB)") if len(payload) == 0: raise HTTPException(status_code=400, detail="빈 audio") # 저장 경로 (NAS) — fastapi 컨테이너 안 /documents = NAS mount nas_root = Path(settings.nas_mount_path) yyyy_mm = datetime.now(timezone.utc).astimezone().strftime("%Y-%m") target_dir = nas_root / VOICE_NAS_SUBDIR / yyyy_mm target_dir.mkdir(parents=True, exist_ok=True) file_uuid = uuid.uuid4().hex target_path = target_dir / f"{file_uuid}{ext}" # fsync + rename(atomic) 패턴 — NAS soft mount 안전 (feedback_nfs_korean_path_normalize 결) tmp_path = target_path.with_suffix(target_path.suffix + ".tmp") try: with open(tmp_path, "wb") as fh: fh.write(payload) fh.flush() os.fsync(fh.fileno()) os.replace(tmp_path, target_path) except OSError as e: # NAS 쓰기 실패 graceful — DB row 미생성 if tmp_path.exists(): try: tmp_path.unlink() except OSError: pass logger.error("voice upload NAS write 실패: %s", e) raise HTTPException(status_code=503, detail="NAS 저장 실패 (재시도 권장)") # recorded_at 파싱 rec_at: datetime | None = None if recorded_at: try: rec_at = datetime.fromisoformat(recorded_at.replace("Z", "+00:00")) except ValueError: rec_at = None raw_metadata: dict[str, Any] = {} if device_hint: raw_metadata["device_hint"] = device_hint if rec_at: raw_metadata["recorded_at"] = rec_at.isoformat() # file_path 는 NAS root 기준 상대 경로 (다른 documents 컨벤션, /api/documents/{id}/file endpoint 호환) relative_path = target_path.relative_to(nas_root) # Document row — file_type='immutable' (binary, doc_type enum 제약) + category='audio' + source_channel='voice' # 기존 audio 컨테이너 인입과 같은 패턴. source_channel='voice' 로 일반 audio 와 구분. title_seed = (orig_name or "음성 메모").rsplit(".", 1)[0] doc = Document( file_path=str(relative_path), file_hash=hashlib.sha256(payload).hexdigest(), file_format=ext.lstrip(".") or "m4a", file_size=len(payload), file_type="immutable", title=title_seed[:80] or "음성 메모", extracted_text=None, # STT 후 채움 review_status="approved", source_channel="voice", category="audio", ask_includable=True, pinned=False, archived=False, memo_task_state={}, extract_meta=raw_metadata or None, ) session.add(doc) await session.flush() # STT 큐 등록 — 기존 stt_worker → classify → embed → chunk 파이프라인 자동 await enqueue_stage(session, doc.id, "stt") await session.commit() await session.refresh(doc) return _to_memo_response(doc)