Compare commits

...

5 Commits

Author SHA1 Message Date
hyungi 96bd849bcb fix(documents): 절뷰(hasSections) 본문을 MarkdownDoc로 렌더 — 수식·이미지 살림
★진짜 원인: 절 있는 문서(useSectionView)는 절 본문을 plain marked(renderMd)로 렌더해
수식(katex 없음 → raw $$)·이미지(docimg → DOMPurify 미지원프로토콜 제거 → 사라짐)가
전부 깨졌다. 앞선 renderDocMarkdown 수정들은 !hasSections 경로뿐이라 절뷰 문서엔 미적용.
절 bodyText 에 docimg·$$ 실재 확인(3791: docimg 21·blockmath). 데스크탑/모바일 절 본문
{@html renderMd} → <MarkdownDoc documentId mdContent={bodyText}> 로 교체 → pre-render
(수식·이미지 placeholder) + swap(실제 이미지) 적용.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 06:42:24 +09:00
hyungi db7ede04b7 fix(markdown): 이미지 ref pre-render — 렌더러 미발화 시에도 placeholder 표시
docMarked image 렌더러가 런타임 미발화 시 ![](docimg:img_NNN) 가 기본 <img src=docimg:>
로 떨어지고 DOMPurify(미지원 프로토콜)가 제거 → placeholder·이미지 둘 다 사라지던 문제
(수식 토크나이저 미발화와 동형). marked 이전에 image ref 를 placeholder figure 로 직접
pre-render(슬롯 보호, 수식과 동일 우회). 이후 MarkdownDoc swap effect 가 실제 <img> 로 교체.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 16:34:34 +09:00
hyungi ac7de71ecd feat(review): 검토 대기 자동검토 워커 — 고신뢰 자동승인 + 저신뢰 잔류
auto_review_worker(interval 3분·배치 300): review_status='pending' + ai_domain
+ ai_confidence>=0.9 인 문서를 review_status='approved' 자동승인 + audit
(source_metadata.auto_reviewed). 저신뢰/미분류는 수동 큐 잔류. 재-LLM 호출 없음
(classify confidence 게이트 = 맥미니 부하 0). review_status 는 검색/RAG/digest 필터
미사용(게이트 실측) → 노출 변동 없이 검토 큐만 드레인. 되돌리기=audit 마커로 식별.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 15:36:56 +09:00
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
hyungi fe8235d726 feat(memos): 자료로 보내기 — 메모를 문서함 정식 문서로 승격 (P1)
새 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) <noreply@anthropic.com>
2026-06-15 14:32:04 +09:00
7 changed files with 305 additions and 5 deletions
+51
View File
@@ -688,6 +688,57 @@ 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,
# P2: memo_draft_worker 가 집어 26B 로 구조화 마크다운(md_content) 생성.
"needs_draft": True,
}
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 ───
+6
View File
@@ -77,6 +77,8 @@ 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
from workers.auto_review_worker import run as auto_review_run
# 시작: DB 연결 확인
await init_db()
@@ -105,6 +107,10 @@ 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)
# 검토 대기 자동검토: 고신뢰(ai_confidence>=0.9) 자동승인 + 저신뢰 수동 잔류. 순수 DB(LLM 없음).
scheduler.add_job(auto_review_run, "interval", minutes=3, id="auto_review", 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")
+72
View File
@@ -0,0 +1,72 @@
"""검토 대기(review_status='pending') 자동 검토 — 고신뢰 자동승인 + 저신뢰 수동 잔류.
classify 가 이미 부여한 ai_confidence 를 게이트로 사용 — **재-LLM 호출 없음**(대량 2천건에
맥미니/GPU 부하 0, 분류 confidence 가 곧 AI 의 자기-신뢰도). ai_domain 보유 +
ai_confidence >= THRESHOLD 인 pending 문서를 review_status='approved' 로 자동승인하고
audit(source_metadata.auto_reviewed)를 남긴다. 저신뢰/미분류는 그대로 두어 수동 검토
큐(/inbox)에 잔류.
설계 근거(게이트 실측):
- review_status 는 inbox 카운트(dashboard) + 수집기 ingest 에서만 사용, 검색/RAG/digest/
ask 경로 필터에 **미사용** → 자동승인은 노출(검색결과) 변동 없이 검토 큐만 비운다.
- pending 2,161 중 ai_suggestion 보유 0 → 이 큐는 '분류 변경 제안'(accept_suggestion)이
아니라 '미검토 자동분류'. 승인 = review_status 플립.
배치·interval 점진 드레인(관찰·중단 가능). 되돌리기 = source_metadata.auto_reviewed 마커로
대상 식별 후 review_status='pending' 복원.
"""
import logging
from datetime import datetime, timezone
from sqlalchemy import select
from core.database import async_session
from models.document import Document
logger = logging.getLogger(__name__)
# 고신뢰 자동승인 바 (튜닝 가능). 실측 분포: >=0.9 → 1,981건 자동 / 저신뢰·미분류 ~180건 수동 잔류.
_CONFIDENCE_THRESHOLD = 0.9
# 한 틱 처리량 — 순수 DB UPDATE(LLM 없음)라 가볍지만, 2천 행 일괄 락 회피 위해 배치.
_BATCH = 300
async def run() -> None:
"""pending 고신뢰 문서를 배치 자동승인 (interval job, no-arg)."""
async with async_session() as session:
rows = (
await session.execute(
select(Document)
.where(
Document.review_status == "pending",
Document.deleted_at.is_(None),
Document.ai_domain.isnot(None),
Document.ai_confidence.isnot(None),
Document.ai_confidence >= _CONFIDENCE_THRESHOLD,
)
.order_by(Document.id)
.limit(_BATCH)
)
).scalars().all()
if not rows:
return
now = datetime.now(timezone.utc)
for doc in rows:
doc.review_status = "approved"
doc.source_metadata = {
**(doc.source_metadata or {}),
"auto_reviewed": {
"by": "confidence_gate",
"confidence": float(doc.ai_confidence),
"threshold": _CONFIDENCE_THRESHOLD,
"at": now.isoformat(),
},
}
doc.updated_at = now
await session.commit()
logger.info(
"auto_review: approved %d pending docs (ai_confidence >= %.2f)",
len(rows),
_CONFIDENCE_THRESHOLD,
)
+110
View File
@@ -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()
+39 -1
View File
@@ -126,11 +126,49 @@ function _protectMath(text: string, slots: string[]): string {
});
}
// ── 이미지 pre-render ─────────────────────────────────────────────────────────
// docMarked 의 image 렌더러(.use renderer)가 런타임에 미발화하면 `![](docimg:img_NNN)` 가
// 기본 `<img src="docimg:..">` 로 떨어지고, DOMPurify(ALLOW_UNKNOWN_PROTOCOLS:false)가
// `docimg:` 를 미지원 프로토콜로 제거 → placeholder 도 이미지도 둘 다 사라진다(수식 토크나이저
// 미발화와 동형 증상). → marked 가 손대기 전에 image ref 를 placeholder figure 로 직접 변환해
// 슬롯 보호(렌더러 발화 여부와 무관). 슬롯/복원 메커니즘은 수식과 공유.
const _IMG_RE = /!\[([^\]]*)\]\(([^)\s]+)\)/g;
function _imagePlaceholder(alt: string, href: string): string {
const isInternal = href.startsWith('docimg:');
const basename = href.split('/').pop() ?? href;
const labelSrc = alt || basename || '이미지';
const safeHref = escAttr(href);
const safeAlt = escAttr(alt);
const safeLabel = escText(`[이미지: ${labelSrc} — 아직 표시되지 않음]`);
const internalFlag = isInternal ? '1' : '0';
return (
`<figure class="md-image-placeholder" data-md-img="1" data-md-image-src="${safeHref}" data-md-image-internal="${internalFlag}" data-md-image-alt="${safeAlt}">` +
`<div class="md-image-placeholder-card">` +
`<span class="md-image-placeholder-icon" aria-hidden="true">🖼️</span>` +
`<span class="md-image-placeholder-label">${safeLabel}</span>` +
`</div>` +
`</figure>`
);
}
function _protectImages(text: string, slots: string[]): string {
return text.replace(_IMG_RE, (m, alt, href) => {
try {
slots.push(_imagePlaceholder(String(alt ?? ''), String(href ?? '')));
return _MATH_SLOT(slots.length - 1);
} catch {
return m;
}
});
}
export function renderDocMarkdown(text: string | null | undefined): string {
if (!text) return '';
try {
const slots: string[] = [];
const protectedText = _protectMath(text, slots);
// 이미지 먼저 placeholder 로 pre-render(렌더러 우회) → 그 다음 수식. 슬롯 공유.
const protectedText = _protectMath(_protectImages(text, slots), slots);
let html = docMarked.parse(protectedText) as string;
if (slots.length) {
// 블록 수식이 단독 문단이면 marked 가 <p> 로 감싸므로 그 <p> 를 벗겨 블록 수식이 문단에
@@ -239,8 +239,8 @@
{/if}
</div>
{/if}
{#if selectedBodyHtml}
<div class="prose prose-base max-w-none text-text">{@html selectedBodyHtml}</div>
{#if selectedItem?.bodyText}
<MarkdownDoc documentId={doc.id} mdContent={selectedItem.bodyText} mdStatus={null} class="prose prose-base max-w-none text-text" />
{:else}
<p style="color:#9aa090;font-size:14px;font-style:italic;">이 절의 본문은 추출되지 않았습니다. 헤더의 '원본'에서 확인하세요.</p>
{/if}
@@ -339,7 +339,7 @@
{#if it.bodyText}
<details class="m-secbody" ontoggle={(e) => { if (e.currentTarget.open) mBodyOpen[s.chunk_id] = true; }}>
<summary style="cursor:pointer;list-style:none;font-size:12px;color:#697061;padding:5px 0;user-select:none;display:flex;align-items:center;gap:5px;">본문 보기 <span class="m-chev" style="transition:transform .16s;color:#9aa090;"></span></summary>
{#if mBodyOpen[s.chunk_id]}<div class="prose prose-sm max-w-none text-text" style="margin-top:6px;">{@html bodyHtml(it)}</div>{/if}
{#if mBodyOpen[s.chunk_id]}<div style="margin-top:6px;"><MarkdownDoc documentId={doc.id} mdContent={it.bodyText} mdStatus={null} class="prose prose-sm max-w-none text-text" /></div>{/if}
</details>
{/if}
</div>
+24 -1
View File
@@ -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 @@
</div>
{/if}
<!-- 자료로 보내기 — 모든 메모(지식 메모 포함)에서 항상 노출 → 문서함 승격 + AI 처리 -->
{#if editingId !== memo.id && !showArchived}
<div class="mt-2">
<button onclick={() => promoteToDocument(memo.id)}
class="inline-flex items-center gap-1 px-2 py-1 rounded text-[11px] bg-surface text-dim hover:bg-accent hover:text-white transition-colors"
title="이 메모를 문서함으로 보내고 AI가 확인·정리·요약·심층분석·도메인 부여를 진행합니다">
<FolderInput size={11} /> 자료로 보내기
</button>
</div>
{/if}
<!-- 태그 + 하단 -->
{#if editingId !== memo.id}
{#if memo.user_tags?.length || memo.ai_tags?.length}