Compare commits

..

2 Commits

Author SHA1 Message Date
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
3 changed files with 114 additions and 1 deletions
+3
View File
@@ -78,6 +78,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
from workers.auto_review_worker import run as auto_review_run
# 시작: DB 연결 확인
await init_db()
@@ -108,6 +109,8 @@ async def lifespan(app: FastAPI):
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,
)
+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> 를 벗겨 블록 수식이 문단에