a6d5734f6c
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>
111 lines
5.0 KiB
Python
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()
|