"""메모 → 문서 승격 시 거친 메모를 구조화된 마크다운 문서로 정리 (26B, P2). `POST /memos/{id}/promote-to-document` 가 `source_metadata.needs_draft=true` 마커를 찍으면 본 스케줄 워커가 집어 AIClient.call_primary(26B Mac mini = 로컬, 과금규칙 부합)로 md_content 를 생성한다. markdown canonical Phase 1A 스키마 재사용: - content_origin='ai_drafted' + md_draft_status='draft' (migration 212 제약: md_draft_status NOT NULL → content_origin='ai_drafted' 필수) - md_status='success', md_extraction_engine='ai_draft' 원본 메모는 extracted_text 에 보존(검색/청크는 원문 사용). "필요시" = 이미 정돈된 메모는 프롬프트가 형식만 다듬고, 거친 메모는 구조화하도록 지시(사실 추가 금지). """ import logging from datetime import datetime, timezone from sqlalchemy import select from ai.client import AIClient, strip_thinking from core.database import async_session from models.document import Document from services.search.llm_gate import Priority, acquire_mlx_gate logger = logging.getLogger(__name__) # 한 번에 처리할 승격 문서 수 (26B 콜 = 무겁다 → 소량 순차). interval 잡이라 다음 틱에 이어 처리. _BATCH = 2 # 너무 짧은 메모는 문서화 의미 없음 — 마커만 정리하고 md 생성 스킵. _MIN_CHARS = 20 _DRAFT_SYSTEM = ( "당신은 사용자의 거친 메모를 사실 추가 없이 깔끔한 마크다운 문서로 정리하는 도우미입니다." ) _DRAFT_PROMPT = """다음은 사용자가 빠르게 적은 메모입니다. 이를 정식 자료 문서로 정리하세요. 규칙: - 메모에 있는 정보만 사용하고, 내용·사실을 추가하거나 추측하지 마세요. - 이미 잘 정돈돼 있으면 형식만 다듬고, 거친 메모면 제목·소제목·목록으로 구조화하세요. - 원문 언어를 유지하세요(한국어는 한국어, 영어는 영어). - 출력은 마크다운 본문만. 인사말·메타 설명 없이 문서 내용만 출력하세요. --- 메모 --- {content} --- 끝 ---""" async def _ids_needing_draft() -> list[int]: async with async_session() as session: rows = ( await session.execute( select(Document.id) .where( Document.deleted_at.is_(None), # JSONB 마커 (json/jsonb 공통 ->> 연산자). promote 가 needs_draft=true 세팅. Document.source_metadata.op("->>")("needs_draft") == "true", ) .order_by(Document.id) .limit(_BATCH) ) ).scalars().all() return list(rows) async def run() -> None: """needs_draft 마커가 찍힌 승격 문서를 26B로 문서화 (interval job, no-arg).""" ids = await _ids_needing_draft() if not ids: return client = AIClient() for doc_id in ids: # 문서별 독립 세션·트랜잭션 — 1건 실패가 나머지를 막지 않게. async with async_session() as session: try: doc = await session.get(Document, doc_id) if doc is None or not (doc.source_metadata or {}).get("needs_draft"): continue # 경합/이미 처리됨 source = (doc.extracted_text or "").strip() now = datetime.now(timezone.utc) meta = dict(doc.source_metadata or {}) md = "" if len(source) >= _MIN_CHARS: # 26B 호출은 반드시 mlx gate(Semaphore 1) 안에서 — 동시 호출 pile-up 방지 # ([[feedback_llm_verification_load_pileup]]). BACKGROUND = 사용자 대면보다 양보. async with acquire_mlx_gate(Priority.BACKGROUND): raw = await client.call_primary( _DRAFT_PROMPT.format(content=source), system=_DRAFT_SYSTEM ) md = strip_thinking(raw or "").strip() if md: doc.md_content = md # 제약(212): md_draft_status NOT NULL 이면 content_origin='ai_drafted' 여야 함. doc.content_origin = "ai_drafted" doc.md_draft_status = "draft" doc.md_status = "success" doc.md_extraction_engine = "ai_draft" doc.md_generated_at = now meta["drafted_at"] = now.isoformat() # 성공/스킵 모두 마커 해제(무한 재시도 방지). 26B 호출 자체가 예외면 except 로 빠져 마커 유지. meta["needs_draft"] = False doc.source_metadata = meta doc.updated_at = now await session.commit() logger.info("memo_draft doc=%s md_len=%d", doc_id, len(md)) except Exception: logger.exception("memo_draft 실패 doc=%s (다음 틱 재시도)", doc_id) await session.rollback()