Files
hyungi a6d5734f6c feat(memos): 자료로 보내기 P2 — 메모→문서 26B 문서화 워커
memo_draft_worker(interval 2분): promote 가 찍은 source_metadata.needs_draft=true
문서를 26B(call_primary, acquire_mlx_gate BACKGROUND)로 구조화 마크다운(md_content)
생성. content_origin='ai_drafted'+md_draft_status='draft'(mig212 제약 준수), 원본은
extracted_text 보존. promote 엔드포인트에 needs_draft 마커 + main.py add_job.
큐 enum/컨슈머 무변경(derived-worker 패턴) = 저위험.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 14:50:44 +09:00

111 lines
5.0 KiB
Python

"""메모 → 문서 승격 시 거친 메모를 구조화된 마크다운 문서로 정리 (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()