From fe8235d7269a722870ae076374d153ccee17811e Mon Sep 17 00:00:00 2001 From: hyungi Date: Mon, 15 Jun 2026 14:32:04 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat(memos):=20=EC=9E=90=EB=A3=8C=EB=A1=9C?= =?UTF-8?q?=20=EB=B3=B4=EB=82=B4=EA=B8=B0=20=E2=80=94=20=EB=A9=94=EB=AA=A8?= =?UTF-8?q?=EB=A5=BC=20=EB=AC=B8=EC=84=9C=ED=95=A8=20=EC=A0=95=EC=8B=9D=20?= =?UTF-8?q?=EB=AC=B8=EC=84=9C=EB=A1=9C=20=EC=8A=B9=EA=B2=A9=20(P1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 새 POST /memos/{id}/promote-to-document: in-place 승격(별 row X) — source_channel→manual, file_type note→editable, category=library, content_origin=manual + classify/embed/chunk 재큐(도메인 재부여·요약·심층분석). 메모 카드에 always-visible '자료로 보내기' 버튼(지식 메모=ai_event_kind note 포함). P2(거친 메모→구조화 마크다운 draft 워커)는 후속. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/api/memos.py | 49 ++++++++++++++++++++++++++ frontend/src/routes/memos/+page.svelte | 25 ++++++++++++- 2 files changed, 73 insertions(+), 1 deletion(-) diff --git a/app/api/memos.py b/app/api/memos.py index 9c43496..e4e40eb 100644 --- a/app/api/memos.py +++ b/app/api/memos.py @@ -688,6 +688,55 @@ async def dismiss_event_suggestion( return _to_memo_response(doc) +@router.post("/{memo_id}/promote-to-document", status_code=201) +async def promote_memo_to_document( + memo_id: int, + user: Annotated[User, Depends(get_current_user)], + session: Annotated[AsyncSession, Depends(get_session)], +): + """메모 1건 → 문서함 정식 Document 로 승격 ("자료로 보내기", P1). + + 동작 (in-place 변환 — 별 row 생성 X, extracted_text/태그/이력 보존): + - source_channel memo/voice/hermes → 'manual' (메모 목록서 빠지고 문서함 진입) + - file_type 'note' → 'editable' (문서함 목록 필터 `file_type != 'note'` 통과) + - category='library' (자료실), content_origin='manual' + - classify/embed/chunk 재큐 → 도메인 재부여 + 요약/심층분석(26B escalate) + 임베딩/청크 갱신 + P2 'draft' 워커(후속)가 거친 메모를 구조화 마크다운(md_content)으로 정리 예정. + """ + 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", "hermes") + or doc.file_type != "note" + ): + raise HTTPException(status_code=404, detail="승격할 메모를 찾을 수 없습니다") + + now = datetime.now(timezone.utc) + doc.source_metadata = { + **(doc.source_metadata or {}), + "promoted_from_memo": True, + "promoted_at": now.isoformat(), + "original_source_channel": doc.source_channel, + } + doc.source_channel = "manual" + doc.file_type = "editable" + doc.category = "library" + doc.content_origin = "manual" + doc.updated_at = now + + # 문서 컨텍스트로 재처리 — 도메인 재부여 + 요약/심층분석 + 임베딩/청크 갱신. + await _enqueue_ai_stages(session, doc.id) + await session.commit() + await session.refresh(doc) + + return { + "document_id": doc.id, + "category": doc.category, + "message": "문서함으로 보냈습니다. AI 분류·요약·심층분석을 진행합니다.", + } + + # ─── Memo Intake Upgrade PR-2C: voice upload ─── diff --git a/frontend/src/routes/memos/+page.svelte b/frontend/src/routes/memos/+page.svelte index ac7348e..162da61 100644 --- a/frontend/src/routes/memos/+page.svelte +++ b/frontend/src/routes/memos/+page.svelte @@ -3,7 +3,7 @@ import { api } from '$lib/api'; import { addToast } from '$lib/stores/toast'; import { renderMemoHtml, todayIso, countHiddenTasks, DEFAULT_HIDE_AFTER_MS } from '$lib/utils/memoRenderer'; - import { Pin, PinOff, Pencil, Trash2, Eye, EyeOff, X, Check, Archive, ArchiveRestore, ListChecks, Bold, Heading, CalendarDays, Mic, Calendar, Activity, ArrowRight, FileText, BookOpen } from 'lucide-svelte'; + import { Pin, PinOff, Pencil, Trash2, Eye, EyeOff, X, Check, Archive, ArchiveRestore, ListChecks, Bold, Heading, CalendarDays, Mic, Calendar, Activity, ArrowRight, FileText, BookOpen, FolderInput } from 'lucide-svelte'; import { getAccessToken } from '$lib/api'; import Button from '$lib/components/ui/Button.svelte'; import Card from '$lib/components/ui/Card.svelte'; @@ -276,6 +276,18 @@ } } + // 자료로 보내기 — 메모를 문서함 정식 문서로 승격(이동) + AI 분류/요약/심층/도메인. + async function promoteToDocument(memoId) { + try { + const res = await api(`/memos/${memoId}/promote-to-document`, { method: 'POST' }); + addToast('success', '문서함으로 보냈습니다 · AI 분석 진행 중'); + // in-place 승격이라 더는 메모가 아님 → 목록에서 제거 + memos = memos.filter((m) => m.id !== memoId); + } catch (err) { + addToast('error', err?.detail || '자료로 보내기 실패'); + } + } + // voice 메모 audio URL — /api/documents/{id}/file?token= 패턴 재사용 function voiceAudioUrl(memoId) { const token = getAccessToken(); @@ -601,6 +613,17 @@ {/if} + + {#if editingId !== memo.id && !showArchived} +
+ +
+ {/if} + {#if editingId !== memo.id} {#if memo.user_tags?.length || memo.ai_tags?.length} -- 2.52.0 From a6d5734f6ca1f85ca1c79c275bbcaba5ccd45437 Mon Sep 17 00:00:00 2001 From: hyungi Date: Mon, 15 Jun 2026 14:50:44 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat(memos):=20=EC=9E=90=EB=A3=8C=EB=A1=9C?= =?UTF-8?q?=20=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() -- 2.52.0 From fabbca64e94ab30bbbd06be5caae47fe20d0935a Mon Sep 17 00:00:00 2001 From: hyungi Date: Mon, 15 Jun 2026 15:06:58 +0900 Subject: [PATCH 3/3] =?UTF-8?q?feat(markdown):=20=EC=99=B8=EB=B6=80=20?= =?UTF-8?q?=EB=A7=81=ED=81=AC=20=EC=83=88=20=ED=83=AD=20+=20rel=3Dnoopener?= =?UTF-8?q?=20noreferrer=20(P0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docMarked link 렌더러: http/https 링크에 target=_blank rel=noopener noreferrer (탭내빙 차단, 코퍼스 521건). 내부/'#'프래그먼트/상대/mailto 는 무손 — outline gfmHeadingId 경로 유지(클릭 인터셉터 없음=충돌 0). marked15 토큰객체 시그니처. SANITIZE_OPTS ADD_ATTR 에 target/rel. load-bearing 게이트: 상대 .md=코퍼스 0건·doc_key 부재 → path→id prop/document_links 미구현(dead). [[..]]=13건 대부분 인용 노이즈([[3\]]) → resolution/스트립 미구현. 외부 링크 하드닝만 정당화됨. Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/src/lib/utils/docMarkdown.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/frontend/src/lib/utils/docMarkdown.ts b/frontend/src/lib/utils/docMarkdown.ts index 9af6d30..cbf7173 100644 --- a/frontend/src/lib/utils/docMarkdown.ts +++ b/frontend/src/lib/utils/docMarkdown.ts @@ -65,6 +65,19 @@ docMarked.use({ `` ); }, + // 외부 링크(http/https) → 새 탭 + rel=noopener noreferrer (탭내빙 차단). 521건 실재. + // 내부/프래그먼트/상대 링크는 손대지 않음 — `#` anchor 는 gfmHeadingId/outline 경로 유지 + // (클릭 인터셉터 없음 → 충돌 0), 상대 .md(코퍼스 0건)는 기본 동작(inert). marked 15 토큰객체 시그니처. + link(token: any): string { + const href = (token?.href ?? '') as string; + const text = this.parser.parseInline(token?.tokens ?? []); + const titleAttr = token?.title ? ` title="${escAttr(token.title as string)}"` : ''; + const safeHref = escAttr(href); + if (/^https?:\/\//i.test(href)) { + return `${text}`; + } + return `${text}`; + }, }, }); @@ -82,6 +95,8 @@ const SANITIZE_OPTS = { 'data-md-image-internal', 'data-md-image-alt', 'loading', + 'target', + 'rel', ], ADD_TAGS: ['figure', 'figcaption'], FORBID_TAGS: ['script', 'iframe', 'object', 'embed', 'link', 'meta'], -- 2.52.0