From a6d5734f6ca1f85ca1c79c275bbcaba5ccd45437 Mon Sep 17 00:00:00 2001 From: hyungi Date: Mon, 15 Jun 2026 14:50:44 +0900 Subject: [PATCH] =?UTF-8?q?feat(memos):=20=EC=9E=90=EB=A3=8C=EB=A1=9C=20?= =?UTF-8?q?=EB=B3=B4=EB=82=B4=EA=B8=B0=20P2=20=E2=80=94=20=EB=A9=94?= =?UTF-8?q?=EB=AA=A8=E2=86=92=EB=AC=B8=EC=84=9C=2026B=20=EB=AC=B8=EC=84=9C?= =?UTF-8?q?=ED=99=94=20=EC=9B=8C=EC=BB=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- app/api/memos.py | 2 + app/main.py | 3 + app/workers/memo_draft_worker.py | 110 +++++++++++++++++++++++++++++++ 3 files changed, 115 insertions(+) create mode 100644 app/workers/memo_draft_worker.py diff --git a/app/api/memos.py b/app/api/memos.py index e4e40eb..e5d22b5 100644 --- a/app/api/memos.py +++ b/app/api/memos.py @@ -718,6 +718,8 @@ async def promote_memo_to_document( "promoted_from_memo": True, "promoted_at": now.isoformat(), "original_source_channel": doc.source_channel, + # P2: memo_draft_worker 가 집어 26B 로 구조화 마크다운(md_content) 생성. + "needs_draft": True, } doc.source_channel = "manual" doc.file_type = "editable" diff --git a/app/main.py b/app/main.py index f4ee993..fadb1bb 100644 --- a/app/main.py +++ b/app/main.py @@ -77,6 +77,7 @@ async def lifespan(app: FastAPI): ) from workers.tier_backfill import run as tier_backfill_run from workers.upload_cleanup import cleanup_orphan_uploads + from workers.memo_draft_worker import run as memo_draft_run # 시작: DB 연결 확인 await init_db() @@ -105,6 +106,8 @@ async def lifespan(app: FastAPI): scheduler.add_job(consume_deep_queue, "interval", minutes=1, id="deep_queue_consumer") scheduler.add_job(watch_inbox, "interval", minutes=5, id="file_watcher") scheduler.add_job(cleanup_orphan_uploads, "interval", minutes=10, id="upload_cleanup") + # P2: 메모→문서 승격분 26B 문서화 (needs_draft 마커 → md_content). 26B 콜이라 소량·2분 간격. + scheduler.add_job(memo_draft_run, "interval", minutes=2, id="memo_draft", max_instances=1) # PR-4: study_questions 자동 임베딩 (status='none/failed/stale' 행을 batch=10 처리). # 별도 큐 테이블 없이 status 자체가 큐. backfill 도 cron 이 'none' 행을 자연스럽게 처리. scheduler.add_job(study_q_embed_run, "interval", minutes=1, id="study_q_embed") diff --git a/app/workers/memo_draft_worker.py b/app/workers/memo_draft_worker.py new file mode 100644 index 0000000..3f9c6f9 --- /dev/null +++ b/app/workers/memo_draft_worker.py @@ -0,0 +1,110 @@ +"""메모 → 문서 승격 시 거친 메모를 구조화된 마크다운 문서로 정리 (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()