Compare commits
86 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 624b9d523d | |||
| 5cabf728e6 | |||
| cd694e7386 | |||
| 7247d242a2 | |||
| 5efe19b5a3 | |||
| 9434017114 | |||
| 753a432c25 | |||
| 66f3287564 | |||
| a850745f85 | |||
| 513c6507bc | |||
| 677a59b422 | |||
| af74312a57 | |||
| 381fcfc675 | |||
| 3ff1d7c65d | |||
| 884ea1e669 | |||
| 523c509954 | |||
| 205a7bf3d5 | |||
| 4d5f35b26e | |||
| df4b07d29c | |||
| 3729083dc0 | |||
| 455a5a66ff | |||
| 124b50af53 | |||
| 0d3c841577 | |||
| 690b22fe58 | |||
| 3565ef9ac4 | |||
| 719c35afbc | |||
| e664d7b187 | |||
| 3ba9537515 | |||
| d58565ef38 | |||
| 70f90bc914 | |||
| 688532b1fa | |||
| 3a22d225a0 | |||
| 8a625bfb27 | |||
| 844a5e0204 | |||
| 456dfaa9f2 | |||
| cb7c0fdc4f | |||
| 2e19dc3d37 | |||
| 2ad32c5c84 | |||
| c11f113cf1 | |||
| 9c22337647 | |||
| d8ad097a3a | |||
| 3a780c0d06 | |||
| ac7de71ecd | |||
| 35d7c7eab7 | |||
| ffe4c776e9 | |||
| 60f3b259df | |||
| fabbca64e9 | |||
| a6d5734f6c | |||
| fe8235d726 | |||
| 4927c585c7 | |||
| b0a73f8506 | |||
| 2d6d1b8e8a | |||
| 4c111ca7f2 | |||
| f325bd0509 | |||
| d4e1f76e81 | |||
| a82b0724df | |||
| b2949d26ff | |||
| 151c1ee518 | |||
| ebbcaf86d8 | |||
| 6d978289b8 | |||
| 73c6f123b8 | |||
| 57c1805a8d | |||
| cbdd4a3df7 | |||
| bf0348a3e0 | |||
| 244d526ae2 | |||
| c5bc1f773d | |||
| fdabca2a2f | |||
| 1fbb341e28 | |||
| d007ad5492 | |||
| 6167e03625 | |||
| b6a4821cac | |||
| ba943d703a | |||
| 345e2cedf0 | |||
| b461559d2f | |||
| 9b9790f05d | |||
| b49596135e | |||
| 0a82a5b1bc | |||
| 74e29e510e | |||
| c1555fd6ab | |||
| 1d5755b279 | |||
| a3e0d30569 | |||
| 540bc00dba | |||
| 30c235e4c1 | |||
| cd439b0ff4 | |||
| 595f4b7d5e | |||
| b630c31077 |
+11
-8
@@ -264,7 +264,7 @@ class AIClient:
|
||||
"""벡터 임베딩 — GPU 서버 전용"""
|
||||
response = await self._http.post(
|
||||
self.ai.embedding.endpoint,
|
||||
json={"model": self.ai.embedding.model, "prompt": text},
|
||||
json={"model": self.ai.embedding.model, "prompt": text, "keep_alive": -1}, # bge-m3 GPU 상주(홈랩 sparse 검색 cold reload ~6s 방지)
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()["embedding"]
|
||||
@@ -289,13 +289,16 @@ class AIClient:
|
||||
return response.json()
|
||||
|
||||
async def _call_chat(self, model_config, prompt: str) -> str:
|
||||
"""OpenAI 호환 API 호출 + 자동 폴백"""
|
||||
try:
|
||||
return await self._request(model_config, prompt)
|
||||
except (httpx.TimeoutException, httpx.ConnectError):
|
||||
if model_config == self.ai.primary:
|
||||
return await self._request(self.ai.fallback, prompt)
|
||||
raise
|
||||
"""OpenAI 호환 API 호출 (R6: 무동의 클라우드 폴백 제거).
|
||||
|
||||
이전엔 primary(맥미니) TimeoutException/ConnectError 시 동의·과금 통제 없이
|
||||
self.ai.fallback(Claude API)로 자동 전환 → 개인 문서/쿼리/메모가 Anthropic 으로
|
||||
silent egress. on-prem 추론 프라이버시 계약 위반이라 봉쇄한다. 실패는 그대로 전파:
|
||||
배치 워커는 재시도/StageDeferred(R3·queue_consumer), interactive 호출자는 5xx 표면화
|
||||
(documents.analyze 등 이미 502/504 변환). 클라우드는 premium explicit-trigger
|
||||
(summarize force_premium) 또는 call_fallback 명시 호출로만 — 자동 진입 금지.
|
||||
"""
|
||||
return await self._request(model_config, prompt)
|
||||
|
||||
async def _request(self, model_config, prompt: str, system: str | None = None) -> str:
|
||||
"""단일 모델 API 호출 (OpenAI 호환 + Anthropic Messages API).
|
||||
|
||||
@@ -195,8 +195,14 @@ async def regenerate(
|
||||
date 미지정 시 오늘 KST. 같은 날 row 존재 시 transaction 안에서 삭제 후 신규 생성.
|
||||
응답 status='success' | 'partial' | 'failed' | 'empty'.
|
||||
"""
|
||||
from core.config import settings
|
||||
from workers.briefing_worker import run
|
||||
|
||||
# held(정책상 정상 보류)를 409 로 표면화 (R8) — digest.py 정본 대칭. 이전엔 briefing_worker.run()
|
||||
# 이 held/timeout/exception 셋 다 None 반환 → API 가 셋 다 500 으로 오보(silent-state-conflation).
|
||||
if "briefing" in settings.pipeline_held_stages:
|
||||
raise HTTPException(status_code=409, detail="briefing 단계가 일시 보류(held) 상태입니다")
|
||||
|
||||
result = await run(target_date=date)
|
||||
if result is None:
|
||||
raise HTTPException(status_code=500, detail="briefing 워커 실행 실패 (로그 확인)")
|
||||
|
||||
+42
-27
@@ -69,6 +69,19 @@ def _upload_error(status_code: int, error_code: str, message: str) -> HTTPExcept
|
||||
)
|
||||
|
||||
|
||||
async def get_live_document(session: AsyncSession, doc_id: int) -> Document:
|
||||
"""soft-delete(deleted_at) 가드 포함 문서 조회 — 없거나 삭제됐으면 404 (R7).
|
||||
|
||||
조회/수정 경로는 deleted_at 을 일관 가드하나 파일/콘텐츠 서빙 엔드포인트가 누락 →
|
||||
삭제 문서의 원본/preview/전문이 doc_id(+유효 토큰)만으로 노출되던 비대칭. '경로마다
|
||||
deleted_at 기억'에 의존하지 않게 헬퍼로 구조 강제(추가될 서빙 경로도 자동 보호).
|
||||
"""
|
||||
doc = await session.get(Document, doc_id)
|
||||
if not doc or doc.deleted_at is not None:
|
||||
raise HTTPException(status_code=404, detail="문서를 찾을 수 없습니다")
|
||||
return doc
|
||||
|
||||
|
||||
async def _near_dup_scan_bg(doc_id: int) -> None:
|
||||
"""B-3: post-upload near_duplicate 스캔 (BackgroundTask). 자체 세션, best-effort.
|
||||
|
||||
@@ -680,7 +693,12 @@ class SectionItem(BaseModel):
|
||||
level: int | None = None
|
||||
node_type: str | None = None # window | chapter_split | clause_split | section_split | null
|
||||
is_leaf: bool
|
||||
parent_id: int | None = None # 트리 부모 chunk_id. window child 의 parent_id = 그 split-parent.
|
||||
# 프런트 collapseWindows 가 비인접 window 를 split-parent 에 흡수할 때 사용.
|
||||
char_start: int | None = None # md_content 내 heading offset(UTF-16). jump-target 만 값, 그 외 None (Path B)
|
||||
text: str | None = None # 절 본문 = 청크 원문. 대형 split 문서는 md_content 가 앞 5만 자만 보존
|
||||
# (marker LARGE_DOC_MD_CONTENT_HEAD_CHARS)이고 char_start 도 NULL 이라
|
||||
# md_content 슬라이스로는 본문이 비므로, 청크 text 를 직접 렌더한다.
|
||||
section_type: str | None = None
|
||||
summary: str | None = None # status='summarized' 인 분석행에만, 그 외 None
|
||||
confidence: float | None = None
|
||||
@@ -719,12 +737,12 @@ async def get_document_sections(
|
||||
await session.execute(
|
||||
sql_text(
|
||||
"""
|
||||
SELECT chunk_id, section_title, heading_path, level, node_type, is_leaf, char_start,
|
||||
section_type, summary, confidence
|
||||
SELECT chunk_id, section_title, heading_path, level, node_type, is_leaf, parent_id, char_start,
|
||||
text, section_type, summary, confidence
|
||||
FROM (
|
||||
SELECT DISTINCT ON (c.id)
|
||||
c.id AS chunk_id, c.chunk_index, c.section_title, c.heading_path,
|
||||
c.level, c.node_type, c.is_leaf, c.char_start,
|
||||
c.level, c.node_type, c.is_leaf, c.parent_id, c.char_start, c.text,
|
||||
a.section_type,
|
||||
CASE WHEN a.status = 'summarized' THEN a.summary ELSE NULL END AS summary,
|
||||
a.confidence
|
||||
@@ -833,9 +851,7 @@ async def get_document_file(
|
||||
# 일반 Bearer 헤더 인증 시도
|
||||
raise HTTPException(status_code=401, detail="토큰이 필요합니다")
|
||||
|
||||
doc = await session.get(Document, doc_id)
|
||||
if not doc:
|
||||
raise HTTPException(status_code=404, detail="문서를 찾을 수 없습니다")
|
||||
doc = await get_live_document(session, doc_id)
|
||||
|
||||
# note(메모)는 물리 파일이 없음
|
||||
if not doc.file_path:
|
||||
@@ -938,10 +954,8 @@ async def get_document_image_raw(
|
||||
if not payload or payload.get("type") != "access":
|
||||
raise HTTPException(status_code=401, detail="유효하지 않은 토큰")
|
||||
|
||||
# 문서 존재 확인 (image_key 만 있고 doc 가 사라진 케이스 차단)
|
||||
doc = await session.get(Document, doc_id)
|
||||
if doc is None:
|
||||
raise HTTPException(status_code=404, detail="문서를 찾을 수 없습니다")
|
||||
# 문서 존재 확인 (image_key 만 있고 doc 가 사라진 케이스 차단 + soft-delete 가드)
|
||||
doc = await get_live_document(session, doc_id)
|
||||
|
||||
img = await session.scalar(
|
||||
select(DocumentImage).where(
|
||||
@@ -1352,9 +1366,8 @@ async def save_document_content(
|
||||
body: dict = None,
|
||||
):
|
||||
"""Markdown 원본 파일 저장 + extracted_text 갱신"""
|
||||
doc = await session.get(Document, doc_id)
|
||||
if not doc:
|
||||
raise HTTPException(status_code=404, detail="문서를 찾을 수 없습니다")
|
||||
# soft-delete 문서엔 쓰기 차단 (R7 — 삭제 문서 resurrect / NAS 재기록 방지)
|
||||
doc = await get_live_document(session, doc_id)
|
||||
|
||||
if doc.file_format not in ("md", "txt"):
|
||||
raise HTTPException(status_code=400, detail="편집 가능한 포맷이 아닙니다 (md, txt만 가능)")
|
||||
@@ -1394,9 +1407,7 @@ async def get_document_preview(
|
||||
else:
|
||||
raise HTTPException(status_code=401, detail="토큰이 필요합니다")
|
||||
|
||||
doc = await session.get(Document, doc_id)
|
||||
if not doc:
|
||||
raise HTTPException(status_code=404, detail="문서를 찾을 수 없습니다")
|
||||
doc = await get_live_document(session, doc_id)
|
||||
|
||||
preview_path = Path(settings.nas_mount_path) / "PKM" / ".preview" / f"{doc_id}.pdf"
|
||||
if not preview_path.exists():
|
||||
@@ -1422,18 +1433,24 @@ async def delete_document(
|
||||
doc_id: int,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
delete_file: bool = Query(False, description="NAS 파일도 함께 삭제"),
|
||||
delete_file: bool = Query(False, description="NAS 원본도 삭제 (grace 후 retention sweep 이 물리삭제)"),
|
||||
):
|
||||
"""문서 삭제 (기본: DB만 삭제, 파일 유지)"""
|
||||
doc = await session.get(Document, doc_id)
|
||||
if not doc:
|
||||
raise HTTPException(status_code=404, detail="문서를 찾을 수 없습니다")
|
||||
"""문서 삭제. 기본: soft-delete(숨김, 파일 보존). delete_file=true: purge 예약 (R7)."""
|
||||
doc = await get_live_document(session, doc_id)
|
||||
|
||||
# soft-delete (물리 파일은 cleanup job에서 나중에 정리)
|
||||
doc.deleted_at = datetime.now(timezone.utc)
|
||||
# soft-delete(숨김). delete_file=true 면 purge_requested_at 마커를 추가로 set —
|
||||
# retention sweep cron(document_purge_sweep)이 grace(30일) 경과 후 NAS 원본 물리삭제
|
||||
# + audit-log. ★일반 숨김(delete_file=false)은 파일 보존 = undelete 가능. sweep 는
|
||||
# deleted_at 이 아니라 purge_requested_at 기준이라 단순 숨김이 영구삭제되지 않는다.
|
||||
now = datetime.now(timezone.utc)
|
||||
doc.deleted_at = now
|
||||
if delete_file:
|
||||
doc.purge_requested_at = now
|
||||
await session.commit()
|
||||
|
||||
return {"message": f"문서 {doc_id} soft-delete 완료"}
|
||||
if delete_file:
|
||||
return {"message": f"문서 {doc_id} 삭제 — NAS 원본은 30일 후 정리 예약"}
|
||||
return {"message": f"문서 {doc_id} soft-delete 완료 (파일 보존)"}
|
||||
|
||||
|
||||
@router.get("/{doc_id}/content")
|
||||
@@ -1443,9 +1460,7 @@ async def get_document_content(
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
):
|
||||
"""문서 전문 텍스트 반환 (서비스 호출용)."""
|
||||
doc = await session.get(Document, doc_id)
|
||||
if not doc:
|
||||
raise HTTPException(status_code=404, detail="문서를 찾을 수 없습니다")
|
||||
doc = await get_live_document(session, doc_id)
|
||||
|
||||
raw_text = doc.extracted_text or ""
|
||||
content = raw_text[:15000]
|
||||
|
||||
+5
-5
@@ -21,7 +21,7 @@ from zoneinfo import ZoneInfo
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import and_, or_, select
|
||||
from sqlalchemy import and_, func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from core.auth import get_current_user
|
||||
@@ -388,10 +388,10 @@ async def list_events(
|
||||
)
|
||||
|
||||
base = select(Event).where(and_(*where))
|
||||
total_q = await session.execute(
|
||||
select(Event.id).where(and_(*where))
|
||||
)
|
||||
total = len(total_q.scalars().all())
|
||||
# R10: 전체 ID 로딩 후 len() 대신 DB COUNT 푸시다운 (행 수 선형 메모리/전송 비용 제거).
|
||||
total = (
|
||||
await session.execute(select(func.count(Event.id)).where(and_(*where)))
|
||||
).scalar() or 0
|
||||
|
||||
rows = await session.execute(
|
||||
base.order_by(Event.created_at.desc())
|
||||
|
||||
@@ -6,6 +6,7 @@ Bearer token 보호 (settings.internal_worker_token).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Path, Response, status
|
||||
@@ -28,7 +29,10 @@ def _verify_token(authorization: str | None = Header(default=None)) -> None:
|
||||
if not authorization or not authorization.lower().startswith("bearer "):
|
||||
raise HTTPException(status_code=401, detail="missing Bearer token")
|
||||
token = authorization[7:].strip()
|
||||
if token != settings.internal_worker_token:
|
||||
# 상수시간 비교 (R7) — 일반 != 는 첫 불일치에서 단락돼 prefix 길이로 바이트 추정 가능한
|
||||
# timing side-channel. 이 토큰이 RAG 정답 포함 endpoint 를 보호하므로 compare_digest 로
|
||||
# 통일(search.py 정본과 일치).
|
||||
if not hmac.compare_digest(token, settings.internal_worker_token):
|
||||
raise HTTPException(status_code=403, detail="invalid token")
|
||||
|
||||
|
||||
|
||||
+27
-64
@@ -473,72 +473,35 @@ async def get_facet_counts(
|
||||
|
||||
result = FacetCountsResponse(company=[], topic=[], year=[], doctype=[])
|
||||
|
||||
# company counts (다른 facet 필터 적용, 자기 자신 제외)
|
||||
q_company = base_query()
|
||||
if facet_topic:
|
||||
q_company = q_company.where(Document.facet_topic == facet_topic)
|
||||
if facet_year:
|
||||
q_company = q_company.where(Document.facet_year == facet_year)
|
||||
if facet_doctype:
|
||||
q_company = q_company.where(Document.facet_doctype == facet_doctype)
|
||||
rows = await session.execute(
|
||||
select(Document.facet_company, func.count())
|
||||
.where(Document.facet_company != None) # noqa: E711
|
||||
.where(Document.id.in_(q_company.with_only_columns(Document.id).subquery().select()))
|
||||
.group_by(Document.facet_company)
|
||||
.order_by(func.count().desc())
|
||||
)
|
||||
result.company = [FacetCountItem(value=r[0], count=r[1]) for r in rows]
|
||||
|
||||
# topic counts
|
||||
q_topic = base_query()
|
||||
# R10: 4 facet 블록 중복 제거 — 적용된 facet 필터(값 있는 것만)를 모아 각 축 집계 시
|
||||
# '자기 자신 축'만 제외하고 적용하는 헬퍼로. 쿼리/자기제외/order_by/value 매핑 모두 동일.
|
||||
applied: dict = {}
|
||||
if facet_company:
|
||||
q_topic = q_topic.where(Document.facet_company == facet_company)
|
||||
if facet_year:
|
||||
q_topic = q_topic.where(Document.facet_year == facet_year)
|
||||
if facet_doctype:
|
||||
q_topic = q_topic.where(Document.facet_doctype == facet_doctype)
|
||||
rows = await session.execute(
|
||||
select(Document.facet_topic, func.count())
|
||||
.where(Document.facet_topic != None) # noqa: E711
|
||||
.where(Document.id.in_(q_topic.with_only_columns(Document.id).subquery().select()))
|
||||
.group_by(Document.facet_topic)
|
||||
.order_by(func.count().desc())
|
||||
)
|
||||
result.topic = [FacetCountItem(value=r[0], count=r[1]) for r in rows]
|
||||
|
||||
# year counts
|
||||
q_year = base_query()
|
||||
if facet_company:
|
||||
q_year = q_year.where(Document.facet_company == facet_company)
|
||||
applied["company"] = Document.facet_company == facet_company
|
||||
if facet_topic:
|
||||
q_year = q_year.where(Document.facet_topic == facet_topic)
|
||||
if facet_doctype:
|
||||
q_year = q_year.where(Document.facet_doctype == facet_doctype)
|
||||
rows = await session.execute(
|
||||
select(Document.facet_year, func.count())
|
||||
.where(Document.facet_year != None) # noqa: E711
|
||||
.where(Document.id.in_(q_year.with_only_columns(Document.id).subquery().select()))
|
||||
.group_by(Document.facet_year)
|
||||
.order_by(Document.facet_year.desc())
|
||||
)
|
||||
result.year = [FacetCountItem(value=str(r[0]), count=r[1]) for r in rows]
|
||||
|
||||
# doctype counts
|
||||
q_doctype = base_query()
|
||||
if facet_company:
|
||||
q_doctype = q_doctype.where(Document.facet_company == facet_company)
|
||||
if facet_topic:
|
||||
q_doctype = q_doctype.where(Document.facet_topic == facet_topic)
|
||||
applied["topic"] = Document.facet_topic == facet_topic
|
||||
if facet_year:
|
||||
q_doctype = q_doctype.where(Document.facet_year == facet_year)
|
||||
rows = await session.execute(
|
||||
select(Document.facet_doctype, func.count())
|
||||
.where(Document.facet_doctype != None) # noqa: E711
|
||||
.where(Document.id.in_(q_doctype.with_only_columns(Document.id).subquery().select()))
|
||||
.group_by(Document.facet_doctype)
|
||||
.order_by(func.count().desc())
|
||||
)
|
||||
result.doctype = [FacetCountItem(value=r[0], count=r[1]) for r in rows]
|
||||
applied["year"] = Document.facet_year == facet_year
|
||||
if facet_doctype:
|
||||
applied["doctype"] = Document.facet_doctype == facet_doctype
|
||||
|
||||
async def _facet_count(name, facet_col, order_by, value_fn):
|
||||
q = base_query()
|
||||
for k, cond in applied.items():
|
||||
if k != name: # 자기 자신 facet 필터는 제외 (다른 축만 적용)
|
||||
q = q.where(cond)
|
||||
rows = await session.execute(
|
||||
select(facet_col, func.count())
|
||||
.where(facet_col != None) # noqa: E711
|
||||
.where(Document.id.in_(q.with_only_columns(Document.id).subquery().select()))
|
||||
.group_by(facet_col)
|
||||
.order_by(order_by)
|
||||
)
|
||||
return [FacetCountItem(value=value_fn(r[0]), count=r[1]) for r in rows]
|
||||
|
||||
result.company = await _facet_count("company", Document.facet_company, func.count().desc(), lambda v: v)
|
||||
result.topic = await _facet_count("topic", Document.facet_topic, func.count().desc(), lambda v: v)
|
||||
result.year = await _facet_count("year", Document.facet_year, Document.facet_year.desc(), lambda v: str(v))
|
||||
result.doctype = await _facet_count("doctype", Document.facet_doctype, func.count().desc(), lambda v: v)
|
||||
|
||||
return result
|
||||
|
||||
+57
-2
@@ -300,9 +300,13 @@ async def list_memos(
|
||||
base = base.where(Document.pinned == pinned)
|
||||
|
||||
if tag:
|
||||
# 파라미터 바인딩 (R7) — f-string 으로 사용자 tag 를 JSON 배열 리터럴에 직접 삽입하면
|
||||
# tag 안 " 나 ] 가 JSON 을 깨 500 + 필터 의미 변형. jsonb_build_array 로 tag 를
|
||||
# 바인드 파라미터로 전달(@> JSONB containment).
|
||||
tag_arr = func.jsonb_build_array(tag)
|
||||
base = base.where(
|
||||
Document.user_tags.op("@>")(f'["{tag}"]')
|
||||
| Document.ai_tags.op("@>")(f'["{tag}"]')
|
||||
Document.user_tags.op("@>")(tag_arr)
|
||||
| Document.ai_tags.op("@>")(tag_arr)
|
||||
)
|
||||
|
||||
count_query = select(func.count()).select_from(base.subquery())
|
||||
@@ -688,6 +692,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 ───
|
||||
|
||||
|
||||
|
||||
+10
-2
@@ -65,7 +65,8 @@ async def create_source(
|
||||
):
|
||||
from core.url_validator import validate_feed_url
|
||||
try:
|
||||
validate_feed_url(body.feed_url)
|
||||
# getaddrinfo(DNS) 는 blocking — 이벤트 루프 점유 방지 위해 off-thread (R5)
|
||||
await asyncio.to_thread(validate_feed_url, body.feed_url)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=422, detail=f"feed_url 검증 실패: {e}")
|
||||
source = NewsSource(**body.model_dump())
|
||||
@@ -194,10 +195,17 @@ async def trigger_collect(
|
||||
if _collect_lock.locked():
|
||||
raise HTTPException(status_code=429, detail="수집이 이미 진행 중입니다")
|
||||
|
||||
# TOCTOU 제거 (R9) — 기존엔 locked() 체크 후 실제 acquire 가 별도 task 안에서 일어나, 그
|
||||
# 사이 다른 요청이 끼어들어 이중 수집 task 가 생길 수 있었다. 핸들러에서 동기적으로(uncontended
|
||||
# Lock.acquire 는 이벤트루프 양보 없이 즉시 완료) acquire 하고 task 의 finally 에서 release.
|
||||
await _collect_lock.acquire()
|
||||
|
||||
async def _run_with_lock():
|
||||
async with _collect_lock:
|
||||
try:
|
||||
from workers.news_collector import run
|
||||
await run()
|
||||
finally:
|
||||
_collect_lock.release()
|
||||
|
||||
asyncio.create_task(_run_with_lock())
|
||||
return {"message": "뉴스 수집 시작됨"}
|
||||
|
||||
@@ -59,6 +59,20 @@ class SummarizeEta(BaseModel):
|
||||
eta_minutes: int | None
|
||||
|
||||
|
||||
class MachineDone(BaseModel):
|
||||
"""머신 1대의 summarize 완료 실적 (분담 표시용)."""
|
||||
done_1h: int
|
||||
done_today: int
|
||||
|
||||
|
||||
class SummarizeByMachine(BaseModel):
|
||||
"""summarize 풀의 머신별 완료 실적 분담 — 보드 레인의 '맥미니 vs 맥북'
|
||||
오프로드 가시화용. rows_to_summarize_split 이 이미 계산하던 값의 노출
|
||||
(ds-board-merged A-1, 신규 수집 SQL 0)."""
|
||||
macmini: MachineDone
|
||||
macbook: MachineDone
|
||||
|
||||
|
||||
class TrendBucket(BaseModel):
|
||||
"""summarize 24h 추이 버킷 — hour 는 KST "HH:00" 라벨."""
|
||||
hour: str
|
||||
@@ -89,12 +103,29 @@ class StageRow(BaseModel):
|
||||
oldest_pending_age_sec: int | None
|
||||
|
||||
|
||||
class BackgroundJobItem(BaseModel):
|
||||
"""큐 밖 관리 스크립트(백필 등) 작업 — processing_queue 가 못 보는 사각지대 노출.
|
||||
stale = running 인데 heartbeat 가 오래 끊김(프로세스 사망 추정)."""
|
||||
id: int
|
||||
kind: str
|
||||
machine: str
|
||||
label: str | None
|
||||
state: Literal["running", "done", "failed"]
|
||||
processed: int
|
||||
total: int | None
|
||||
elapsed_sec: int
|
||||
stale: bool
|
||||
error: str | None
|
||||
|
||||
|
||||
class QueueOverviewResponse(BaseModel):
|
||||
machines: list[MachineCard]
|
||||
stages: list[StageRow]
|
||||
summarize_eta: SummarizeEta
|
||||
summarize_by_machine: SummarizeByMachine
|
||||
trend_24h: list[TrendBucket]
|
||||
totals: Totals
|
||||
background_jobs: list[BackgroundJobItem] = []
|
||||
|
||||
|
||||
class FailedItem(BaseModel):
|
||||
|
||||
+8
-4
@@ -282,7 +282,7 @@ async def search(
|
||||
content={
|
||||
"error_reason": "unknown_reranker_backend",
|
||||
"backend_requested": reranker_backend,
|
||||
"allowed": ["baseline", "cand_gte_ml_base"],
|
||||
"allowed": ["baseline"],
|
||||
"detail": msg,
|
||||
},
|
||||
)
|
||||
@@ -291,7 +291,7 @@ async def search(
|
||||
content={
|
||||
"error_reason": "unknown_embedding_backend",
|
||||
"backend_requested": embedding_backend,
|
||||
"allowed": ["baseline", "cand_me5_large_inst", "cand_snowflake_l_v2"],
|
||||
"allowed": ["baseline"],
|
||||
"detail": msg,
|
||||
},
|
||||
)
|
||||
@@ -710,7 +710,9 @@ async def ask(
|
||||
# 30s 로 align → classifier 동작 안정. ask 응답 latency 상한 ↑ 의도.
|
||||
try:
|
||||
classifier_result = await asyncio.wait_for(classifier_task, timeout=30.0)
|
||||
except (asyncio.TimeoutError, Exception):
|
||||
except asyncio.CancelledError:
|
||||
raise # 요청 취소는 전파 — broad except 가 삼키지 않게 명시 (R3)
|
||||
except Exception:
|
||||
classifier_result = ClassifierResult("timeout", None, [], [], 0.0)
|
||||
|
||||
defense_log["classifier"] = {
|
||||
@@ -872,7 +874,9 @@ async def ask(
|
||||
# → classifier 와 동일 패턴 (search.py:522 가 6s→15s swap 했던 case). 10s 로 align.
|
||||
try:
|
||||
verifier_result = await asyncio.wait_for(verifier_task, timeout=10.0)
|
||||
except (asyncio.TimeoutError, Exception):
|
||||
except asyncio.CancelledError:
|
||||
raise # 요청 취소는 전파 — broad except 가 삼키지 않게 명시 (R3)
|
||||
except Exception:
|
||||
verifier_result = VerifierResult("timeout", [], 0.0)
|
||||
|
||||
# Verifier contradictions → grounding flags 머지 (prefix 로 구분, severity 3단계)
|
||||
|
||||
@@ -1009,7 +1009,16 @@ async def submit_attempt(
|
||||
# PR-10: 세션 연동. 기본은 None.
|
||||
quiz_session: StudyQuizSession | None = None
|
||||
if body.quiz_session_id is not None:
|
||||
quiz_session = await session.get(StudyQuizSession, body.quiz_session_id)
|
||||
# FOR UPDATE 로 행 잠금 (R9) — 모바일 더블탭/재시도로 같은 세션에 동시 제출이 들어오면
|
||||
# 둘 다 cursor=N 을 읽고 둘 다 cursor+1·count 가산하는 race(이중 가산). 잠금으로 직렬화 →
|
||||
# 두 번째 제출은 첫 commit 후 cursor=N+1 을 보고 cursor 불일치 409 로 거부된다.
|
||||
quiz_session = (
|
||||
await session.execute(
|
||||
select(StudyQuizSession)
|
||||
.where(StudyQuizSession.id == body.quiz_session_id)
|
||||
.with_for_update()
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if quiz_session is None or quiz_session.user_id != user.id:
|
||||
raise HTTPException(status_code=404, detail="quiz_session 을 찾을 수 없습니다")
|
||||
if quiz_session.study_topic_id != q.study_topic_id:
|
||||
|
||||
@@ -169,6 +169,14 @@ class Settings(BaseModel):
|
||||
# 1 = 구 single-inference 동작. 2 = continuous batching 활용 (llm_gate docstring 참조).
|
||||
mlx_gate_concurrency: int = 1
|
||||
|
||||
# digest/briefing 생성 LLM 호출 파라미터 (2026-06-15, 모델 교체 후 타임아웃 단일소스화).
|
||||
# 구 하드코딩 25s(빠른 Gemma 기준)가 Qwen3.6-27B-6bit(콜당 ~90~300s) 교체 sweep 에서
|
||||
# 누락돼 digest 600s 하드캡 초과·briefing 4/4 폴백을 유발 → config 단일소스로 이관.
|
||||
# 동시성은 별 키 아님 — 전역 mlx_gate_concurrency(게이트 단일 budget)가 담당.
|
||||
digest_llm_timeout_s: int = 200
|
||||
digest_llm_attempts: int = 2
|
||||
digest_pipeline_hard_cap_s: int = 1800
|
||||
|
||||
# PR-MacMini-Derived-Worker-1: study explanation owner = Mac mini
|
||||
# GPU 측은 false 로 설정 (.env), explanation 분기 skip guard 트리거.
|
||||
study_explanation_enabled: bool = True
|
||||
@@ -257,6 +265,9 @@ def load_settings() -> Settings:
|
||||
|
||||
pipeline_held_stages: list[str] = []
|
||||
mlx_gate_concurrency = 1
|
||||
digest_llm_timeout_s = 200
|
||||
digest_llm_attempts = 2
|
||||
digest_pipeline_hard_cap_s = 1800
|
||||
if config_path.exists() and raw and "pipeline" in raw:
|
||||
held_raw = (raw.get("pipeline") or {}).get("held_stages") or []
|
||||
# 스칼라(문자열) 오기입 시 char-split 방지 — 단일 항목 리스트로 수용.
|
||||
@@ -269,6 +280,19 @@ def load_settings() -> Settings:
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
mlx_gate_concurrency = 1
|
||||
_pl = raw.get("pipeline") or {}
|
||||
try:
|
||||
digest_llm_timeout_s = max(1, int(_pl.get("digest_llm_timeout_s", 200)))
|
||||
except (TypeError, ValueError):
|
||||
digest_llm_timeout_s = 200
|
||||
try:
|
||||
digest_llm_attempts = max(1, int(_pl.get("digest_llm_attempts", 2)))
|
||||
except (TypeError, ValueError):
|
||||
digest_llm_attempts = 2
|
||||
try:
|
||||
digest_pipeline_hard_cap_s = max(60, int(_pl.get("digest_pipeline_hard_cap_s", 1800)))
|
||||
except (TypeError, ValueError):
|
||||
digest_pipeline_hard_cap_s = 1800
|
||||
|
||||
taxonomy = raw.get("taxonomy", {}) if config_path.exists() and raw else {}
|
||||
document_types = raw.get("document_types", []) if config_path.exists() and raw else []
|
||||
@@ -300,6 +324,9 @@ def load_settings() -> Settings:
|
||||
internal_worker_token=internal_worker_token,
|
||||
pipeline_held_stages=pipeline_held_stages,
|
||||
mlx_gate_concurrency=mlx_gate_concurrency,
|
||||
digest_llm_timeout_s=digest_llm_timeout_s,
|
||||
digest_llm_attempts=digest_llm_attempts,
|
||||
digest_pipeline_hard_cap_s=digest_pipeline_hard_cap_s,
|
||||
)
|
||||
|
||||
|
||||
|
||||
+58
-4
@@ -72,6 +72,55 @@ def _validate_sql_content(name: str, sql: str) -> None:
|
||||
)
|
||||
|
||||
|
||||
# R1: baseline 스냅샷이 대표하는 마지막 마이그레이션 버전 (이하 버전은 baseline 에 포함).
|
||||
# 새 baseline 재생성 시 이 값을 갱신한다 (migrations/_baseline/<cutoff>_schema_baseline.sql).
|
||||
_BASELINE_CUTOFF = 358
|
||||
|
||||
|
||||
async def _load_baseline_if_fresh(conn, migrations_dir: Path) -> None:
|
||||
"""fresh DB(documents 부재)면 baseline 스키마 스냅샷 적재 + schema_migrations 1..cutoff 스탬프.
|
||||
|
||||
기존 DB(documents 존재)는 즉시 반환 — baseline 미적재, 무영향. baseline 파일 부재 시도
|
||||
기존 replay 경로 유지(하위호환).
|
||||
"""
|
||||
from sqlalchemy import text
|
||||
|
||||
baseline_dir = migrations_dir / "_baseline"
|
||||
baseline_files = (
|
||||
sorted(baseline_dir.glob("*_schema_baseline.sql")) if baseline_dir.is_dir() else []
|
||||
)
|
||||
if not baseline_files:
|
||||
return
|
||||
|
||||
docs_exists = (
|
||||
await conn.execute(text("SELECT to_regclass('public.documents') IS NOT NULL"))
|
||||
).scalar()
|
||||
if docs_exists:
|
||||
return # 기존 DB — baseline skip
|
||||
|
||||
baseline_path = baseline_files[-1]
|
||||
logger.info(f"[migration] fresh DB 감지 — baseline 적재: {baseline_path.name}")
|
||||
# baseline 은 multi-statement 덤프 — exec_driver_sql(asyncpg prepared)은 multi-statement
|
||||
# 불허("cannot insert multiple commands into a prepared statement"). raw asyncpg 의 simple
|
||||
# 프로토콜 execute() 로 적재한다(같은 connection = 현재 트랜잭션 내). psql 스모크는 이 제약을
|
||||
# 못 잡으므로 init_db 런타임 검증으로 확인됨.
|
||||
raw = await conn.get_raw_connection()
|
||||
await raw.driver_connection.execute(baseline_path.read_text(encoding="utf-8"))
|
||||
# baseline = cutoff 까지의 스키마 → 실제 파일 버전 기준으로 schema_migrations 스탬프.
|
||||
versions = [v for v, _, _ in _parse_migration_files(migrations_dir) if v <= _BASELINE_CUTOFF]
|
||||
for v in versions:
|
||||
await conn.execute(
|
||||
text(
|
||||
"INSERT INTO schema_migrations (version, name) "
|
||||
"VALUES (:v, :n) ON CONFLICT DO NOTHING"
|
||||
),
|
||||
{"v": v, "n": f"baseline:{v}"},
|
||||
)
|
||||
logger.info(
|
||||
f"[migration] baseline 적재 + schema_migrations {len(versions)}건 스탬프 (cutoff {_BASELINE_CUTOFF})"
|
||||
)
|
||||
|
||||
|
||||
async def _run_migrations(conn) -> None:
|
||||
"""미적용 migration 실행 (호출자가 트랜잭션 관리)"""
|
||||
from sqlalchemy import text
|
||||
@@ -90,10 +139,6 @@ async def _run_migrations(conn) -> None:
|
||||
f"SELECT pg_advisory_xact_lock({_MIGRATION_LOCK_KEY})"
|
||||
))
|
||||
|
||||
# 적용 이력 조회
|
||||
result = await conn.execute(text("SELECT version FROM schema_migrations"))
|
||||
applied = {row[0] for row in result}
|
||||
|
||||
# migration 파일 스캔
|
||||
# /app/core/database.py → parent.parent = /app → /app/migrations (volume mount 위치)
|
||||
migrations_dir = Path(__file__).resolve().parent.parent / "migrations"
|
||||
@@ -101,6 +146,15 @@ async def _run_migrations(conn) -> None:
|
||||
logger.info("[migration] migrations/ 디렉토리 없음, 스킵")
|
||||
return
|
||||
|
||||
# R1: fresh DB(documents 부재)면 baseline 스냅샷 먼저 적재 + schema_migrations 스탬프.
|
||||
# migrations/ 전체 replay 는 누적 비-replayable(011 view 의존·326 enum-same-txn 등)로
|
||||
# 깨지므로 신규/DR 환경은 prod 스키마 스냅샷에서 출발한다. 기존 DB 는 skip(무영향).
|
||||
await _load_baseline_if_fresh(conn, migrations_dir)
|
||||
|
||||
# 적용 이력 조회 (baseline 스탬프 반영 — fresh DB 는 1..cutoff 가 이미 applied)
|
||||
result = await conn.execute(text("SELECT version FROM schema_migrations"))
|
||||
applied = {row[0] for row in result}
|
||||
|
||||
files = _parse_migration_files(migrations_dir)
|
||||
pending = [(v, name, path) for v, name, path in files if v not in applied]
|
||||
|
||||
|
||||
+34
-4
@@ -51,17 +51,21 @@ async def lifespan(app: FastAPI):
|
||||
from workers.briefing_worker import run as morning_briefing_run
|
||||
from workers.daily_digest import run as daily_digest_run
|
||||
from workers.dedup_reconcile import run as dedup_reconcile_run
|
||||
from workers.document_purge_sweep import run as purge_sweep_run
|
||||
from workers.digest_worker import run as global_digest_run
|
||||
from workers.file_watcher import watch_inbox
|
||||
from workers.mailplus_archive import run as mailplus_run
|
||||
from workers.statute_collector import run as statute_run
|
||||
from workers.news_collector import run as news_collector_run
|
||||
from workers.arxiv_collector import run as arxiv_collector_run
|
||||
from workers.openalex_collector import run as openalex_collector_run
|
||||
from workers.paper_doi_reconcile import run as paper_doi_reconcile_run
|
||||
from workers.fulltext_worker import reconcile_unresolved as fulltext_reconcile_run
|
||||
from workers.kosha_collector import run as kosha_collector_run
|
||||
from workers.csb_collector import run as csb_collector_run
|
||||
from workers.api_standards_collector import run as api_standards_run
|
||||
from workers.ccps_collector import run as ccps_collector_run
|
||||
from workers.queue_consumer import consume_queue, consume_fast_queue, consume_markdown_queue
|
||||
from workers.queue_consumer import consume_queue, consume_fast_queue, consume_markdown_queue, consume_deep_queue
|
||||
from workers.study_queue_consumer import consume_study_queue
|
||||
from workers.study_session_queue_consumer import consume_study_session_queue
|
||||
from workers.study_memo_card_jobs_consumer import consume_study_memo_card_queue
|
||||
@@ -74,6 +78,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()
|
||||
@@ -98,8 +104,14 @@ async def lifespan(app: FastAPI):
|
||||
# 2026-06-12 fast-consumer split: embed/chunk(건당 <1s)를 LLM 사이클에서 분리 —
|
||||
# classify(~190s×3)가 사이클을 점유해 벡터 적재가 굶던 구조 캡 해소 (markdown 선례).
|
||||
scheduler.add_job(consume_fast_queue, "interval", minutes=1, id="fast_queue_consumer")
|
||||
# 2026-06-15 deep-consumer split: deep_summary(70~300s)를 메인 루프에서 분리 (markdown/fast 선례).
|
||||
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")
|
||||
@@ -139,6 +151,12 @@ async def lifespan(app: FastAPI):
|
||||
# plan ds-s1-backend-1 B-4: dedup 컬럼(duplicate_of/duplicate_count) 야간 절대 재계산.
|
||||
# soft-delete 잔여 드리프트 정리(멱등, 드리프트 없으면 no-op). cron 03:30 (다른 잡과 비충돌).
|
||||
scheduler.add_job(dedup_reconcile_run, CronTrigger(hour=3, minute=30, timezone=KST), id="dedup_reconcile")
|
||||
# R7: delete_file=true purge 요청 문서의 NAS 원본 grace(30일) 후 물리삭제 + audit.
|
||||
# purge_requested_at 마커 기준(단순 숨김은 보존). 03:20 = 다른 새벽 잡과 비충돌 슬롯.
|
||||
scheduler.add_job(purge_sweep_run, CronTrigger(hour=3, minute=20, timezone=KST), id="purge_sweep")
|
||||
# B-3 PR4: 레거시 paper 행 arXiv DataCite DOI 스탬프(재유입 차단). keyless·in-DB·enqueue 0.
|
||||
# dedup_reconcile(03:30)·fulltext_reconcile(03:40) 와 별 worker·비충돌 슬롯.
|
||||
scheduler.add_job(paper_doi_reconcile_run, CronTrigger(hour=3, minute=50, timezone=KST), id="paper_doi_reconcile")
|
||||
# crawl-24x7 C-2: KOSHA 재해사례 diff + GUIDE 점진 백필 (daily, 새벽 잡들과 비충돌 슬롯).
|
||||
scheduler.add_job(kosha_collector_run, CronTrigger(hour=6, minute=40, timezone=KST), id="kosha_collector")
|
||||
# 사이클 3 C-2 잔여: CSB sitemap lastmod diff (weekly 월, cap 40 + 워터마크 점진 백필).
|
||||
@@ -147,6 +165,12 @@ async def lifespan(app: FastAPI):
|
||||
scheduler.add_job(api_standards_run, CronTrigger(day=5, hour=7, minute=5, timezone=KST), id="api_standards_collector")
|
||||
# 사이클 3 C-2 잔여: CCPS Beacon 월간 PDF (playwright 익명 경유 — WAF 차단 시 health 로 가시화).
|
||||
scheduler.add_job(ccps_collector_run, CronTrigger(day=5, hour=7, minute=20, timezone=KST), id="ccps_collector")
|
||||
# B-3 PR2: arXiv 키워드 필터 수집기 (daily 07:30 KST — statute 07:00 직후 빈 슬롯).
|
||||
# signal-only 초록 색인, per-run cap 으로 임베드 큐 보호. keyless.
|
||||
scheduler.add_job(arxiv_collector_run, CronTrigger(hour=7, minute=30, timezone=KST), id="arxiv_collector")
|
||||
# B-3 PR3: OpenAlex 백본 수집기 (daily 07:45 KST). scaffold-first(키 부재 explicit-skip),
|
||||
# signal-only 초록 색인, per-run cap + cursor watermark. 키=OPENALEX_API_KEY(credentials.env).
|
||||
scheduler.add_job(openalex_collector_run, CronTrigger(hour=7, minute=45, timezone=KST), id="openalex_collector")
|
||||
scheduler.start()
|
||||
|
||||
# Phase 2.1 (async 구조): QueryAnalyzer prewarm.
|
||||
@@ -216,21 +240,27 @@ SETUP_BYPASS_PREFIXES = (
|
||||
"/api/setup", "/api/config", "/setup", "/health", "/docs", "/openapi.json", "/redoc",
|
||||
)
|
||||
|
||||
# R10: 셋업 완료(user 존재)는 단조(monotonic) — 한 번 확인되면 영구. 매 요청 COUNT 쿼리
|
||||
# 대신 캐시 플래그로 전환 (setup 후 모든 요청이 users COUNT 하던 per-request 비용 제거).
|
||||
_setup_complete = False
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def setup_redirect_middleware(request: Request, call_next):
|
||||
global _setup_complete # 함수 내 read+assign 둘 다 모듈 전역 참조 (UnboundLocalError 방지)
|
||||
path = request.url.path
|
||||
# 바이패스 경로는 항상 통과
|
||||
if any(path.startswith(p) for p in SETUP_BYPASS_PREFIXES):
|
||||
# 셋업 완료됐거나 바이패스 경로면 즉시 통과 (DB 쿼리 없음)
|
||||
if _setup_complete or any(path.startswith(p) for p in SETUP_BYPASS_PREFIXES):
|
||||
return await call_next(request)
|
||||
|
||||
# 유저 존재 여부 확인
|
||||
# 유저 존재 여부 확인 (셋업 완료 전 1회성 — 완료 확인되면 플래그 set 후 영구 skip)
|
||||
try:
|
||||
async with async_session() as session:
|
||||
result = await session.execute(select(func.count(User.id)))
|
||||
user_count = result.scalar()
|
||||
if user_count == 0:
|
||||
return RedirectResponse(url="/setup")
|
||||
_setup_complete = True
|
||||
except Exception:
|
||||
pass # DB 연결 실패 시 통과 (health에서 확인 가능)
|
||||
|
||||
|
||||
@@ -52,7 +52,8 @@ class Document(Base):
|
||||
|
||||
# 2계층: AI 가공
|
||||
ai_summary: Mapped[str | None] = mapped_column(Text)
|
||||
ai_tags: Mapped[dict | None] = mapped_column(JSONB, default=[])
|
||||
# R11a: 주석 dict→list 정정(실제 list 적재), 공유 가변 default=[] → callable default=list.
|
||||
ai_tags: Mapped[list | None] = mapped_column(JSONB, default=list)
|
||||
ai_domain: Mapped[str | None] = mapped_column(String(100))
|
||||
ai_sub_group: Mapped[str | None] = mapped_column(String(100))
|
||||
ai_model_version: Mapped[str | None] = mapped_column(String(50))
|
||||
@@ -79,7 +80,7 @@ class Document(Base):
|
||||
user_note: Mapped[str | None] = mapped_column(Text)
|
||||
|
||||
# 사용자 태그 (ai_tags와 분리, #태그 파싱 결과 또는 수동 입력)
|
||||
user_tags: Mapped[list | None] = mapped_column(JSONB, default=[])
|
||||
user_tags: Mapped[list | None] = mapped_column(JSONB, default=list) # R11a: 공유 가변 default 제거
|
||||
|
||||
# 핀 고정
|
||||
pinned: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
@@ -105,6 +106,9 @@ class Document(Base):
|
||||
# 승인/삭제
|
||||
review_status: Mapped[str | None] = mapped_column(String(20), default="pending")
|
||||
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
# delete_file=true 명시 삭제 요청 마커 (R7) — retention sweep(document_purge_sweep)이
|
||||
# grace 후 NAS 원본 물리삭제. deleted_at(단순 숨김, 파일 보존)과 분리.
|
||||
purge_requested_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
# 외부 편집 URL
|
||||
edit_url: Mapped[str | None] = mapped_column(Text)
|
||||
|
||||
@@ -7,7 +7,7 @@ PR-2 가드레일:
|
||||
- correct_choice 변경 시 기존 attempt.is_correct 재계산 안 함 (기록은 그 시점의 사실).
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from pgvector.sqlalchemy import Vector
|
||||
from sqlalchemy import BigInteger, Boolean, DateTime, ForeignKey, Integer, SmallInteger, String, Text
|
||||
@@ -128,7 +128,9 @@ class StudyQuestionAttempt(Base):
|
||||
# PR-9: outcome 권장값 (correct/wrong/unsure). 강한 enum 미사용.
|
||||
outcome: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||
answered_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=datetime.now, nullable=False
|
||||
# TZ-aware 명시 (R8) — naive datetime.now() 는 컨테이너 TZ 의존. 현 컨테이너=UTC 라
|
||||
# 값 동일(백필 불요)이나, 컨테이너 TZ 가 바뀌면 9시간 어긋나는 잠복 의존 제거.
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False
|
||||
)
|
||||
# PR-10: 어떤 quiz 세션의 attempt 인지 (NULL = 세션 외 직접 입력 또는 세션 삭제됨).
|
||||
quiz_session_id: Mapped[int | None] = mapped_column(
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
"""off-queue 관리 스크립트(백필 등) 진행 가시화 — background_jobs (migration 357).
|
||||
|
||||
processing_queue 는 파이프라인 stage 전용이라 hier_overnight_backfill /
|
||||
section_summary_pilot 같은 스크립트 작업은 대시보드 보드에 안 잡힌다. 이 모듈로
|
||||
스크립트가 진행상황을 남기면 queue_overview 가 "백그라운드 작업" 패널로 노출한다.
|
||||
|
||||
설계 불변식:
|
||||
- **자율 트랜잭션**: 각 기록은 engine.begin() 짧은 트랜잭션으로 즉시 commit한다.
|
||||
스크립트 본 작업은 별도 세션(긴 트랜잭션)이라, 같이 묶으면 commit 전까지 안 보여
|
||||
실시간 가시화가 깨진다. 그래서 전용 connection 으로 독립 commit.
|
||||
- **best-effort**: 관측 기록 실패가 본 작업을 깨면 안 된다 — 모든 함수 try/except,
|
||||
실패 시 warning 로그만. job_id=None 이면 조용히 no-op (start 실패해도 이어서 동작).
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def start_job(
|
||||
engine: AsyncEngine, kind: str, label: str | None = None, total: int | None = None
|
||||
) -> int | None:
|
||||
"""작업 시작 기록 → background_jobs.id (실패 시 None — 호출측은 그대로 진행)."""
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
row = (
|
||||
await conn.execute(
|
||||
text(
|
||||
"INSERT INTO background_jobs (kind, label, total) "
|
||||
"VALUES (:k, :l, :t) RETURNING id"
|
||||
),
|
||||
{"k": kind, "l": label, "t": total},
|
||||
)
|
||||
).first()
|
||||
return int(row[0]) if row else None
|
||||
except Exception as exc: # noqa: BLE001 — 관측은 부가, 본작업 보호
|
||||
logger.warning(f"[background_jobs] start 실패(무시): {type(exc).__name__}: {exc}")
|
||||
return None
|
||||
|
||||
|
||||
async def heartbeat(
|
||||
engine: AsyncEngine,
|
||||
job_id: int | None,
|
||||
*,
|
||||
processed: int | None = None,
|
||||
total: int | None = None,
|
||||
detail: dict | None = None,
|
||||
) -> None:
|
||||
"""진행 갱신(processed/total/detail). job_id=None 또는 실패 시 no-op."""
|
||||
if job_id is None:
|
||||
return
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(
|
||||
text(
|
||||
"UPDATE background_jobs SET "
|
||||
"processed = COALESCE(:p, processed), "
|
||||
"total = COALESCE(:t, total), "
|
||||
"detail = COALESCE(CAST(:d AS jsonb), detail), "
|
||||
"updated_at = now() WHERE id = :id"
|
||||
),
|
||||
{
|
||||
"id": job_id,
|
||||
"p": processed,
|
||||
"t": total,
|
||||
"d": json.dumps(detail, ensure_ascii=False) if detail is not None else None,
|
||||
},
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning(f"[background_jobs] heartbeat 실패(무시): {type(exc).__name__}: {exc}")
|
||||
|
||||
|
||||
async def finish_job(
|
||||
engine: AsyncEngine, job_id: int | None, *, state: str = "done", error: str | None = None
|
||||
) -> None:
|
||||
"""종료 기록(done/failed). job_id=None 또는 실패 시 no-op."""
|
||||
if job_id is None:
|
||||
return
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(
|
||||
text(
|
||||
"UPDATE background_jobs SET state = :s, error = :e, "
|
||||
"finished_at = now(), updated_at = now() WHERE id = :id"
|
||||
),
|
||||
{"id": job_id, "s": state, "e": (error or None)},
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning(f"[background_jobs] finish 실패(무시): {type(exc).__name__}: {exc}")
|
||||
@@ -18,12 +18,14 @@ from typing import Any
|
||||
import numpy as np
|
||||
|
||||
from ai.client import parse_json_response
|
||||
from core.config import settings
|
||||
from core.utils import setup_logger
|
||||
from services.clustering_common import normalize_vector
|
||||
from services.search.llm_gate import Priority, acquire_mlx_gate
|
||||
|
||||
logger = setup_logger("briefing_comparator")
|
||||
|
||||
LLM_CALL_TIMEOUT = 25 # 초. Phase 4 와 동일
|
||||
LLM_CALL_TIMEOUT = settings.digest_llm_timeout_s # 2026-06-15 config 단일소스 (Phase 4 와 동일 키)
|
||||
HISTORICAL_TOP_K = 5
|
||||
HISTORICAL_SIMILARITY_MIN = 0.70
|
||||
HISTORICAL_WINDOW_DAYS = 30
|
||||
@@ -39,7 +41,6 @@ MAX_ARTICLE_IDS_PER_COUNTRY = 5 # country_perspectives[].article_ids 후
|
||||
FALLBACK_HEADLINE = "LLM 분석 실패로 원문 기사 묶음만 표시합니다."
|
||||
FALLBACK_TOPIC_LABEL = "주요 뉴스 묶음"
|
||||
|
||||
_llm_sem = asyncio.Semaphore(1)
|
||||
_PROMPT_PATH = Path(__file__).resolve().parent.parent.parent / "prompts" / "briefing_comparative.txt"
|
||||
_PROMPT_TEMPLATE: str | None = None
|
||||
|
||||
@@ -112,7 +113,8 @@ def retrieve_historical(
|
||||
|
||||
|
||||
async def _try_call_llm(client: Any, prompt: str) -> str:
|
||||
async with _llm_sem:
|
||||
# 전역 MLX gate(BACKGROUND) 경유 — 영구 룰(llm_gate): 새 Semaphore 금지, timeout 은 gate 안쪽.
|
||||
async with acquire_mlx_gate(Priority.BACKGROUND):
|
||||
return await asyncio.wait_for(
|
||||
client.call_primary(prompt),
|
||||
timeout=LLM_CALL_TIMEOUT,
|
||||
@@ -282,7 +284,7 @@ async def compare_cluster_with_fallback(
|
||||
historical_docs = historical_docs or []
|
||||
prompt = build_prompt(selected, historical_docs)
|
||||
|
||||
for attempt in range(2):
|
||||
for attempt in range(settings.digest_llm_attempts): # 2026-06-15 config 단일소스
|
||||
try:
|
||||
raw = await _try_call_llm(client, prompt)
|
||||
except asyncio.TimeoutError:
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
regenerate 정책: briefing_date UNIQUE 충돌 시 transaction 안에서 DELETE+INSERT.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
@@ -15,7 +16,9 @@ from sqlalchemy import delete
|
||||
|
||||
from ai.client import AIClient
|
||||
from core.database import async_session
|
||||
from core.database import engine as db_engine
|
||||
from core.utils import setup_logger
|
||||
from services import background_jobs as bgj
|
||||
from models.briefing import BriefingTopic, MorningBriefing
|
||||
from services.briefing.clustering import LAMBDA, cluster_global
|
||||
from services.briefing.comparator import (
|
||||
@@ -33,7 +36,6 @@ KST = ZoneInfo("Asia/Seoul")
|
||||
NIGHT_WINDOW_HOURS = 5 # KST 00:00 ~ 05:00
|
||||
SELECT_K = 7 # Plan §"Clustering 파라미터" briefing K_PER_CLUSTER=7
|
||||
SELECT_LAMBDA_MMR = 0.6 # Plan briefing MMR lambda 0.6
|
||||
PIPELINE_HARD_CAP = 600 # 초. Phase 4 와 동일
|
||||
|
||||
|
||||
def _compute_window(target_date: date | None = None) -> tuple[datetime, datetime, date]:
|
||||
@@ -143,7 +145,7 @@ async def _save_briefing(
|
||||
return new.id
|
||||
|
||||
|
||||
async def run_briefing_pipeline(target_date: date | None = None) -> dict[str, Any]:
|
||||
async def run_briefing_pipeline(target_date: date | None = None, job_id: int | None = None) -> dict[str, Any]:
|
||||
"""야간 뉴스 브리핑 1회 실행. cron 또는 수동 regenerate API 에서 호출.
|
||||
|
||||
Returns:
|
||||
@@ -206,16 +208,36 @@ async def run_briefing_pipeline(target_date: date | None = None) -> dict[str, An
|
||||
usable_count = 0
|
||||
|
||||
try:
|
||||
# 2026-06-15: cluster 호출 gather 동시 실행. 실동시성 = 전역 MLX gate
|
||||
# (config.mlx_gate_concurrency, BACKGROUND 우선순위). rank/순서 보존.
|
||||
jobs = []
|
||||
for rank, cluster in enumerate(clusters, start=1):
|
||||
selected = select_for_llm(cluster, k=SELECT_K, lambda_mmr=SELECT_LAMBDA_MMR)
|
||||
historical_docs = (
|
||||
retrieve_historical(cluster, historical_candidates)
|
||||
if historical_enabled() else []
|
||||
)
|
||||
llm_calls += 1
|
||||
envelope = await compare_cluster_with_fallback(
|
||||
jobs.append((rank, cluster, selected, historical_docs))
|
||||
|
||||
if job_id is not None:
|
||||
await bgj.heartbeat(db_engine, job_id, total=len(jobs))
|
||||
_prog = {"n": 0}
|
||||
|
||||
async def _run_one(cluster, selected, historical_docs):
|
||||
r = await compare_cluster_with_fallback(
|
||||
client, cluster, selected, historical_docs=historical_docs
|
||||
)
|
||||
if job_id is not None:
|
||||
_prog["n"] += 1
|
||||
await bgj.heartbeat(db_engine, job_id, processed=_prog["n"])
|
||||
return r
|
||||
|
||||
results = await asyncio.gather(
|
||||
*[_run_one(c, s, h) for (_, c, s, h) in jobs]
|
||||
)
|
||||
|
||||
for (rank, cluster, selected, historical_docs), envelope in zip(jobs, results):
|
||||
llm_calls += 1
|
||||
if envelope.get("llm_fallback_used"):
|
||||
llm_failures += 1
|
||||
if _is_usable_topic(envelope, envelope["topic_label"]):
|
||||
|
||||
@@ -10,6 +10,7 @@ Step:
|
||||
7. start/end 로그 + generation_ms + fallback 비율 health metric
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
@@ -19,7 +20,9 @@ from sqlalchemy import delete
|
||||
|
||||
from ai.client import AIClient
|
||||
from core.database import async_session
|
||||
from core.database import engine as db_engine
|
||||
from core.utils import setup_logger
|
||||
from services import background_jobs as bgj
|
||||
from models.digest import DigestTopic, GlobalDigest
|
||||
|
||||
from .clustering import LAMBDA, cluster_country
|
||||
@@ -73,7 +76,7 @@ def _build_topic_row(
|
||||
)
|
||||
|
||||
|
||||
async def run_digest_pipeline() -> dict:
|
||||
async def run_digest_pipeline(job_id: int | None = None) -> dict:
|
||||
"""전체 파이프라인 실행. worker entry 에서 호출.
|
||||
|
||||
Returns:
|
||||
@@ -107,20 +110,37 @@ async def run_digest_pipeline() -> dict:
|
||||
stats = {"llm_calls": 0, "fallback_used": 0}
|
||||
|
||||
try:
|
||||
# 2026-06-15: cluster 호출을 gather 로 동시 실행. 실제 동시성은 전역 MLX gate
|
||||
# (config.mlx_gate_concurrency, BACKGROUND 우선순위) 가 제한한다. rank/순서 보존.
|
||||
jobs = []
|
||||
for country, docs in docs_by_country.items():
|
||||
clusters = cluster_country(country, docs)
|
||||
if not clusters:
|
||||
continue # sparse country 자동 제외
|
||||
|
||||
for rank, cluster in enumerate(clusters, start=1):
|
||||
selected = select_for_llm(cluster)
|
||||
stats["llm_calls"] += 1
|
||||
llm_result = await summarize_cluster_with_fallback(client, cluster, selected)
|
||||
if llm_result["llm_fallback_used"]:
|
||||
stats["fallback_used"] += 1
|
||||
all_topic_rows.append(
|
||||
_build_topic_row(country, rank, cluster, selected, llm_result, primary_model)
|
||||
)
|
||||
jobs.append((country, rank, cluster, selected))
|
||||
|
||||
if job_id is not None:
|
||||
await bgj.heartbeat(db_engine, job_id, total=len(jobs))
|
||||
_prog = {"n": 0}
|
||||
|
||||
async def _run_one(cluster, selected):
|
||||
r = await summarize_cluster_with_fallback(client, cluster, selected)
|
||||
if job_id is not None:
|
||||
_prog["n"] += 1
|
||||
await bgj.heartbeat(db_engine, job_id, processed=_prog["n"])
|
||||
return r
|
||||
|
||||
results = await asyncio.gather(*[_run_one(c, s) for (_, _, c, s) in jobs])
|
||||
|
||||
for (country, rank, cluster, selected), llm_result in zip(jobs, results):
|
||||
stats["llm_calls"] += 1
|
||||
if llm_result["llm_fallback_used"]:
|
||||
stats["fallback_used"] += 1
|
||||
all_topic_rows.append(
|
||||
_build_topic_row(country, rank, cluster, selected, llm_result, primary_model)
|
||||
)
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
핵심 결정:
|
||||
- AIClient._call_chat 직접 호출 (client.py 수정 회피, fallback 로직 재사용)
|
||||
- Semaphore(1) 로 MLX 과부하 회피
|
||||
- Per-call timeout 25초 (asyncio.wait_for) — MLX hang / fallback Claude API stall 방어
|
||||
- 전역 MLX gate(BACKGROUND) 경유로 동시성 제어 (services.search.llm_gate 단일 게이트)
|
||||
- Per-call timeout = config.digest_llm_timeout_s (asyncio.wait_for, gate 안쪽)
|
||||
- JSON 파싱 실패 → 1회 재시도 → 그래도 실패 시 minimal fallback (drop 금지)
|
||||
- fallback: topic_label="주요 뉴스 묶음", summary = top member ai_summary[:200]
|
||||
"""
|
||||
@@ -13,15 +13,16 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from ai.client import parse_json_response
|
||||
from core.config import settings
|
||||
from core.utils import setup_logger
|
||||
from services.search.llm_gate import Priority, acquire_mlx_gate
|
||||
|
||||
logger = setup_logger("digest_summarizer")
|
||||
|
||||
LLM_CALL_TIMEOUT = 25 # 초. MLX 평균 5초 + tail latency 마진
|
||||
# 2026-06-15: config 단일소스 (구 하드코딩 25s = 빠른 Gemma 기준, Qwen 27B 교체 후 누락).
|
||||
LLM_CALL_TIMEOUT = settings.digest_llm_timeout_s
|
||||
FALLBACK_SUMMARY_LIMIT = 200
|
||||
|
||||
_llm_sem = asyncio.Semaphore(1)
|
||||
|
||||
_PROMPT_PATH = Path(__file__).resolve().parent.parent.parent / "prompts" / "digest_topic.txt"
|
||||
_PROMPT_TEMPLATE: str | None = None
|
||||
|
||||
@@ -48,8 +49,12 @@ def build_prompt(selected: list[dict]) -> str:
|
||||
|
||||
|
||||
async def _try_call_llm(client: Any, prompt: str) -> str:
|
||||
"""Semaphore + per-call timeout 으로 감싼 단일 호출."""
|
||||
async with _llm_sem:
|
||||
"""전역 MLX gate(BACKGROUND) + per-call timeout 으로 감싼 단일 호출.
|
||||
|
||||
영구 룰(llm_gate): Mac mini endpoint 는 단일 게이트 공유, 새 Semaphore 금지.
|
||||
동시성 lever = config.mlx_gate_concurrency. timeout 은 gate 안쪽에서만.
|
||||
"""
|
||||
async with acquire_mlx_gate(Priority.BACKGROUND):
|
||||
return await asyncio.wait_for(
|
||||
client._call_chat(client.ai.primary, prompt),
|
||||
timeout=LLM_CALL_TIMEOUT,
|
||||
@@ -86,7 +91,7 @@ async def summarize_cluster_with_fallback(
|
||||
"""
|
||||
prompt = build_prompt(selected)
|
||||
|
||||
for attempt in range(2): # 1회 재시도 포함
|
||||
for attempt in range(settings.digest_llm_attempts): # config 단일소스 (기본 2 = 1회 재시도)
|
||||
try:
|
||||
raw = await _try_call_llm(client, prompt)
|
||||
except asyncio.TimeoutError:
|
||||
|
||||
@@ -26,7 +26,16 @@ _ATX = re.compile(r'^(#{1,6})\s+(?P<title>\S.*?)\s*#*\s*$')
|
||||
_KO_JANG = re.compile(r'^\s*(?P<title>제\s*\d+\s*장\b.*)$')
|
||||
_KO_JEOL = re.compile(r'^\s*(?P<title>제\s*\d+\s*절\b.*)$')
|
||||
_KO_JO = re.compile(r'^\s*(?P<title>제\s*\d+\s*조\b.*)$')
|
||||
_ENG = re.compile(r'^\s*(?P<title>(?:Chapter|Section|Article|Part|PART)\s+[\dIVXLA-Z]+\b.*)$')
|
||||
# _ENG: 영문 구조 헤딩(ATX 미사용 문서용). ASME 파트는 보통 ATX(`# PART PG`)로 잡혀 _ENG 의존 낮음.
|
||||
# D1: 식별자 뒤가 소문자 문장연속이면("Part III to demonstrate to the satisfaction…") 본문이므로
|
||||
# 미탐지 — 가짜 절 차단. 선택 제목은 대문자/괄호/숫자로 시작해야 헤딩 인정(소문자 시작=문장으로 봄).
|
||||
# 식별자는 번호/PG/3.31/UHX/A-1 등 (.·- 소수·하이픈 확장 허용).
|
||||
_ENG = re.compile(
|
||||
r'^\s*(?P<title>(?:Chapter|Section|Article|Part|PART)\s+'
|
||||
r'[\dIVXLA-Z]+(?:[.\-][\dA-Za-z]+)*'
|
||||
r'(?:\s+[A-Z(\d][^\n]*)?'
|
||||
r')\s*$'
|
||||
)
|
||||
|
||||
# 코드펜스 경계 (FE outlineAnchors.ts:60 `/^\s{0,3}(```|~~~)/` 와 동일). 펜스 내부 라인은
|
||||
# heading 미탐지 — 코드블록 안 '# foo' 가 가짜 절을 만들지 않게(O3).
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
"""B-3 논문 수집 트랙 공유 모듈 (plan safety-library-b3-1).
|
||||
|
||||
doi — DOI 정규화·dedup 키·2-Document(holder/parent_doi child) extract_meta 계약 (순수).
|
||||
holder — 서지 holder 공유 dedup 조회 (DB).
|
||||
"""
|
||||
@@ -0,0 +1,141 @@
|
||||
"""B-3 논문 DOI 코어 — 정규화·dedup 키·2-Document(서지 holder / parent_doi child) 계약.
|
||||
|
||||
plan safety-library-b3-1 PR1 (keyless·마이그 0).
|
||||
|
||||
핵심 계약(모든 논문 수집기·reconcile·구매 PDF 스탬프가 공유):
|
||||
- DOI 정규화는 이 단일 함수(normalize_doi) 경유 — **저장=조회 동일 함수**
|
||||
(migration 351 주석 명시, news_collector._normalize_url 의 store=lookup 불변식 선례).
|
||||
같은 논문이 다른 표기(https://doi.org/ vs doi: vs 대문자)로 들어와도 한 holder 로 붕괴.
|
||||
- dedup 키 = lower(extract_meta #>> '{paper,doi}') — 라이브 partial-unique 인덱스
|
||||
uq_documents_paper_doi(WHERE material_type='paper' AND ... IS NOT NULL)가 강제.
|
||||
- 2-Document(R2-B1): paper.doi 는 **서지 Document 단일 보유**. OA/구매 전문 PDF 는
|
||||
doi 없이 paper.parent_doi 로 holder 링크(NULL doi 라 인덱스 밖 → 다중행 무충돌).
|
||||
holder 와 child 는 doi/parent_doi 를 **상호 배타**로 가진다.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import re
|
||||
|
||||
# 소문자화 후 비교하므로 전부 소문자 prefix. 긴 것부터(dx.doi.org 가 doi.org 보다 먼저).
|
||||
_DOI_PREFIXES = (
|
||||
"https://dx.doi.org/",
|
||||
"http://dx.doi.org/",
|
||||
"https://doi.org/",
|
||||
"http://doi.org/",
|
||||
"dx.doi.org/",
|
||||
"doi.org/",
|
||||
"doi:",
|
||||
)
|
||||
|
||||
|
||||
def normalize_doi(raw: str | None) -> str | None:
|
||||
"""DOI 정규화 — 소문자 + URL/doi: prefix 제거 + 양끝 공백·잡음 제거. 단일 함수(저장=조회).
|
||||
|
||||
유효 DOI(10. 으로 시작)가 아니면 None. 저장측·조회측·dedup 키 생성이 모두 이 함수를
|
||||
공유해야 dedup 이 성립한다(raw 를 그대로 저장하고 정규화로 조회하면 영구 미스).
|
||||
"""
|
||||
if not raw:
|
||||
return None
|
||||
s = raw.strip().lower()
|
||||
for p in _DOI_PREFIXES:
|
||||
if s.startswith(p):
|
||||
s = s[len(p):]
|
||||
break
|
||||
s = s.strip()
|
||||
# 인용문 끝 잡음(마침표/쉼표/세미콜론)만 제거. 괄호 '()' 는 DOI 일부일 수 있어 보존한다
|
||||
# (예: 10.1016/s0010-8650(00)80003-2) — 과삭제는 서로 다른 논문을 한 holder 로 병합하는
|
||||
# 데이터 손상이라 near-dup(과소삭제)보다 위험. API 소스(OpenAlex/arXiv)의 doi 는 이미 깨끗.
|
||||
s = s.rstrip(".,;")
|
||||
if not s.startswith("10."):
|
||||
return None
|
||||
return s
|
||||
|
||||
|
||||
# arXiv id: 신형 'YYMM.NNNNN'(+vN) 또는 구형 'archive(.SUBJ)/NNNNNNN'. 'arXiv:' 접두 흡수.
|
||||
_ARXIV_ID_RE = re.compile(
|
||||
r"arxiv:\s*([a-z\-]+(?:\.[a-z]{2})?/\d{7}|\d{4}\.\d{4,5})(v\d+)?", re.IGNORECASE
|
||||
)
|
||||
|
||||
|
||||
def parse_arxiv_id(text: str | None) -> str | None:
|
||||
"""본문/제목에서 arXiv id(versionless) 추출. 없으면 None. 레거시 reconcile 의 입력."""
|
||||
if not text:
|
||||
return None
|
||||
m = _ARXIV_ID_RE.search(text)
|
||||
return m.group(1) if m else None
|
||||
|
||||
|
||||
def arxiv_doi(arxiv_id: str | None) -> str | None:
|
||||
"""arXiv DataCite DOI = 10.48550/arxiv.{id} (정규화). 저널 DOI 없는 프리프린트의 canonical
|
||||
paper.doi 통일 키 — OpenAlex 가 프리프린트에 동일 DOI 부여(실측 확인). 모든 수집기·reconcile 가
|
||||
같은 함수로 같은 DOI 를 써야 교차소스 dedup 이 성립."""
|
||||
if not arxiv_id:
|
||||
return None
|
||||
return normalize_doi(f"10.48550/arXiv.{arxiv_id}")
|
||||
|
||||
|
||||
_DOI_IN_TEXT_RE = re.compile(r"10\.\d{4,9}/[^\s\"'<>]+", re.IGNORECASE)
|
||||
|
||||
|
||||
def parse_doi_from_text(text: str | None) -> str | None:
|
||||
"""본문에서 첫 DOI 추출(정규화). 구매 PDF 의 paper.parent_doi 링크용(PDF 구조 무관 — 전체 스캔).
|
||||
DOI 끝 구두점은 normalize_doi 가 정리. 없으면 None."""
|
||||
if not text:
|
||||
return None
|
||||
m = _DOI_IN_TEXT_RE.search(text)
|
||||
return normalize_doi(m.group(0)) if m else None
|
||||
|
||||
|
||||
def paper_doi_hash(normalized_doi: str) -> str:
|
||||
"""서지 holder 의 Document.file_hash — sha256('paper|{doi}')[:32].
|
||||
|
||||
statute 의 'statute|{jur}|{native_id}|{version_key}' 다중부 키 선례를 따른다.
|
||||
인자는 normalize_doi() 출력(정규화 완료값)이어야 한다 — raw 를 넣으면 dedup 이 깨진다.
|
||||
"""
|
||||
if not normalized_doi:
|
||||
raise ValueError("paper_doi_hash 는 정규화된 DOI 필요 (normalize_doi 먼저)")
|
||||
return hashlib.sha256(f"paper|{normalized_doi}".encode()).hexdigest()[:32]
|
||||
|
||||
|
||||
def read_paper_doi(extract_meta: dict | None) -> str | None:
|
||||
"""holder 의 정규화 DOI 읽기 — 인덱스 식 lower(extract_meta #>> '{paper,doi}') 의 조회측 거울.
|
||||
|
||||
방어적 재정규화(이미 정규화돼 저장되지만 레거시·외부 주입 대비).
|
||||
"""
|
||||
if not extract_meta:
|
||||
return None
|
||||
paper = extract_meta.get("paper")
|
||||
if not isinstance(paper, dict):
|
||||
return None
|
||||
return normalize_doi(paper.get("doi"))
|
||||
|
||||
|
||||
def with_paper_doi(extract_meta: dict | None, normalized_doi: str) -> dict:
|
||||
"""서지 holder 의 extract_meta 에 paper.doi 주입 (merge-safe, 타 키 보존).
|
||||
|
||||
holder 전용 — parent_doi 는 제거(상호 배타). 반환값은 새 dict(입력 비변경).
|
||||
"""
|
||||
if not normalized_doi:
|
||||
raise ValueError("with_paper_doi 는 정규화된 DOI 필요")
|
||||
meta = dict(extract_meta or {})
|
||||
paper = dict(meta.get("paper") or {})
|
||||
paper["doi"] = normalized_doi
|
||||
paper.pop("parent_doi", None)
|
||||
meta["paper"] = paper
|
||||
return meta
|
||||
|
||||
|
||||
def with_parent_doi(extract_meta: dict | None, parent_normalized_doi: str) -> dict:
|
||||
"""child(OA/구매 전문 PDF)의 extract_meta 에 paper.parent_doi 주입 (merge-safe, 타 키 보존).
|
||||
|
||||
child 는 paper.doi 를 갖지 않는다(NULL → partial-unique 인덱스 밖, 2-Document 무충돌).
|
||||
반환값은 새 dict(입력 비변경).
|
||||
"""
|
||||
if not parent_normalized_doi:
|
||||
raise ValueError("with_parent_doi 는 정규화된 DOI 필요")
|
||||
meta = dict(extract_meta or {})
|
||||
paper = dict(meta.get("paper") or {})
|
||||
paper["parent_doi"] = parent_normalized_doi
|
||||
paper.pop("doi", None)
|
||||
meta["paper"] = paper
|
||||
return meta
|
||||
@@ -0,0 +1,39 @@
|
||||
"""B-3 논문 서지 holder 공유 dedup 조회.
|
||||
|
||||
모든 논문 수집기(OpenAlex/arXiv/KoreaScience/J-STAGE)·reconcile·구매 PDF 스탬프가
|
||||
ingest 전 이 함수로 holder 존재를 확인한다(있으면 skip 또는 child 링크).
|
||||
|
||||
- 조회 키 = lower(extract_meta #>> '{paper,doi}') == normalize_doi(...) — 라이브 partial-unique
|
||||
인덱스 uq_documents_paper_doi 와 동일 식(인덱스 사용).
|
||||
- .scalars().first() — 교차게시·다중 landing-page 로 2행 이상 매칭 시 MultipleResultsFound
|
||||
raise 방지(scalar_one_or_none 금지, 2026-06 BBC 수집 중단 선례 / news_collector 동일 규율).
|
||||
- 서지 holder Document 의 **생성**은 각 수집기/스탬프 경로가 소유한다(초록 signal 문서 vs 구매
|
||||
최소 holder 로 shape 가 다름). 이 모듈은 dedup 조회만 공유한다.
|
||||
|
||||
DB 조회라 본 모듈은 PR2(arXiv 실수집)에서 라이브 검증한다 — PR1 단위 테스트 대상은 doi.py(순수).
|
||||
"""
|
||||
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from models.document import Document
|
||||
from services.papers.doi import normalize_doi
|
||||
|
||||
# 인덱스 식과 동일: lower(extract_meta #>> '{paper,doi}')
|
||||
_DOI_EXPR = func.lower(Document.extract_meta[("paper", "doi")].astext)
|
||||
|
||||
|
||||
async def find_paper_holder(session, raw_or_normalized_doi):
|
||||
"""정규화 DOI 로 서지 holder Document 조회. 없으면 None.
|
||||
|
||||
인자는 raw 든 정규화든 받아 normalize_doi 로 통일(저장=조회 동일 함수 보장).
|
||||
"""
|
||||
doi = normalize_doi(raw_or_normalized_doi)
|
||||
if not doi:
|
||||
return None
|
||||
result = await session.execute(
|
||||
select(Document)
|
||||
.where(Document.material_type == "paper", _DOI_EXPR == doi,
|
||||
Document.deleted_at.is_(None))
|
||||
.limit(1)
|
||||
)
|
||||
return result.scalars().first()
|
||||
@@ -213,6 +213,16 @@ def build_summarize_eta(stage_stats: dict[str, dict]) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def build_summarize_by_machine(summarize_split: dict[str, dict]) -> dict:
|
||||
"""summarize 머신별 완료 실적 분담 (macmini vs macbook) — 보드 레인의
|
||||
오프로드 가시화용. rows_to_summarize_split 이 이미 만든 값을 응답 형태로
|
||||
투영(done_1h/done_today 만, done_15m 은 내부 state 판정 전용이라 제외)."""
|
||||
def m(key: str) -> dict:
|
||||
s = summarize_split.get(key, {})
|
||||
return {"done_1h": int(s.get("done_1h", 0)), "done_today": int(s.get("done_today", 0))}
|
||||
return {"macmini": m("macmini"), "macbook": m("macbook")}
|
||||
|
||||
|
||||
def build_trend(
|
||||
inflow_buckets: dict[str, int],
|
||||
done_buckets: dict[str, int],
|
||||
@@ -292,6 +302,7 @@ def compose_overview(
|
||||
),
|
||||
"stages": build_stages(stage_stats),
|
||||
"summarize_eta": build_summarize_eta(stage_stats),
|
||||
"summarize_by_machine": build_summarize_by_machine(summarize_split),
|
||||
"trend_24h": build_trend(inflow_buckets, done_buckets, now_kst),
|
||||
"totals": build_totals(stage_stats),
|
||||
}
|
||||
@@ -401,7 +412,7 @@ async def build_overview(session: AsyncSession) -> dict:
|
||||
for row in current_result
|
||||
]
|
||||
|
||||
return compose_overview(
|
||||
result = compose_overview(
|
||||
rows_to_stage_stats(stage_rows),
|
||||
rows_to_summarize_split(split_rows),
|
||||
{row[0]: int(row[1]) for row in inflow_rows},
|
||||
@@ -410,6 +421,55 @@ async def build_overview(session: AsyncSession) -> dict:
|
||||
deep_enabled=deep_enabled,
|
||||
now_kst=now_kst,
|
||||
)
|
||||
# 큐 밖 관리 스크립트(백필 등) = background_jobs (migration 357). 테이블 부재 시 graceful([]).
|
||||
result["background_jobs"] = await _fetch_background_jobs(session)
|
||||
return result
|
||||
|
||||
|
||||
# kind -> 처리 머신 (보드 머신 카드 귀속용). 미상 kind = gpu(오케스트레이션 호스트).
|
||||
_BG_JOB_MACHINE = {
|
||||
"global_digest": "macmini",
|
||||
"morning_briefing": "macmini",
|
||||
"section_summary": "macmini",
|
||||
"hier_backfill": "gpu",
|
||||
"hier_redecompose": "gpu",
|
||||
}
|
||||
|
||||
|
||||
_BACKGROUND_JOBS_SQL = """
|
||||
SELECT id, kind, label, state, processed, total,
|
||||
EXTRACT(EPOCH FROM (now() - started_at))::int AS elapsed_sec,
|
||||
(state = 'running' AND updated_at < now() - interval '5 minutes') AS stale,
|
||||
error
|
||||
FROM background_jobs
|
||||
WHERE state = 'running' OR finished_at > now() - interval '6 hours'
|
||||
ORDER BY (state = 'running') DESC, started_at DESC
|
||||
LIMIT 20
|
||||
"""
|
||||
|
||||
|
||||
async def _fetch_background_jobs(session: AsyncSession) -> list[dict]:
|
||||
"""running + 최근 6h 완료 background_jobs. 테이블 없거나 오류면 [] (보드 무영향).
|
||||
|
||||
요청 세션과 **별도 connection**으로 조회한다 — 테이블 부재(마이그 357 미적용 등) 시
|
||||
SELECT 실패가 요청 세션의 트랜잭션을 오염시키지 않도록 물리적으로 분리(실패 시 그
|
||||
임시 connection만 폐기). 관측은 부가 기능이라 보드 본체를 절대 깨면 안 된다.
|
||||
"""
|
||||
try:
|
||||
async with session.bind.connect() as conn: # 풀에서 독립 connection
|
||||
rows = (await conn.execute(text(_BACKGROUND_JOBS_SQL))).mappings().all()
|
||||
except Exception: # noqa: BLE001 — 관측 부가, 보드 본체 보호
|
||||
return []
|
||||
return [
|
||||
{
|
||||
"id": r["id"], "kind": r["kind"], "label": r["label"], "state": r["state"],
|
||||
"processed": int(r["processed"] or 0), "total": r["total"],
|
||||
"elapsed_sec": int(r["elapsed_sec"] or 0), "stale": bool(r["stale"]),
|
||||
"error": r["error"],
|
||||
"machine": _BG_JOB_MACHINE.get(r["kind"], "gpu"),
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
# ─── 실패 처리 (plan ds-board-engines-1) ─────────────────────────────────────
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Time-aware retrieval freshness decay (PR-RAG-Time-1).
|
||||
|
||||
뉴스(source_channel='news') / 재해사례(material_type='incident', KOSHA) 도메인은
|
||||
뉴스(source_channel='news') / 법령 알림(source_channel='law_monitor') 도메인은
|
||||
시간이 중요한 문서. 단순 relevance score 만으로는 오래된 문서가 상위에 머물러
|
||||
검색 품질이 떨어짐. 본 모듈은 reranker 이후 final score 합성 단계에서
|
||||
soft multiplier 로 시간 가중치 적용. 삭제는 없음 — ranking 만 demote.
|
||||
@@ -9,10 +9,9 @@ soft multiplier 로 시간 가중치 적용. 삭제는 없음 — ranking 만 de
|
||||
- reranker = 의미 관련도, freshness decay = 운영 정책. 두 단계 분리 유지.
|
||||
- floor 0.7 (multiplier 가 0.7 미만으로 안 떨어짐) — 오래되어도 죽지 않음.
|
||||
- 일반 업로드 / 학습 자료 / KGS Code 원문 / ai_drafted 는 비적용 (no-op).
|
||||
- ★법령(law)은 C-1 후속에서 freshness 제외 — 현행성은 version_status(B-1 버전체인)가 처리.
|
||||
|
||||
published_date 컬럼이 documents 에 없음 → created_at(수집 시점) 을 임시 proxy.
|
||||
news/KOSHA 워커가 수집 즉시 indexing 하므로 created_at ≈ published_date.
|
||||
news/law_monitor 워커가 수집 즉시 indexing 하므로 created_at ≈ published_date.
|
||||
정확도 향상은 후속 PR (worker 가 published_date 메타 채우기) 로 분리.
|
||||
"""
|
||||
|
||||
@@ -33,10 +32,10 @@ if TYPE_CHECKING:
|
||||
# ─── Policy ────────────────────────────────────────────────────────
|
||||
|
||||
# half-life (일). 90 일: 한 달 ~0.79 / 6개월 ~0.25.
|
||||
# C-1 후속(2026-06-13): law_365d 폐기 — 법령 현행성은 version_status(B-1 버전체인)가 처리,
|
||||
# age-decay 는 current 법령을 부당 강등(의도 변경 기록). 재해사례(incident)는 news_90d 흡수.
|
||||
# 365 일: 1년 ~0.5 / 3년 ~0.13.
|
||||
HALF_LIFE_DAYS: dict[str, int] = {
|
||||
"news_90d": 90,
|
||||
"law_365d": 365,
|
||||
}
|
||||
|
||||
# soft multiplier — final = base * (FLOOR + (1-FLOOR) * decay).
|
||||
@@ -53,35 +52,32 @@ class _DocMeta:
|
||||
source_channel: str | None
|
||||
content_origin: str | None
|
||||
created_at: datetime | None
|
||||
material_type: str | None = None
|
||||
|
||||
|
||||
def freshness_policy(meta: _DocMeta | None) -> str | None:
|
||||
"""문서 메타 → freshness 정책 이름 또는 None (no-op).
|
||||
|
||||
적용:
|
||||
- material_type='incident' (KOSHA 재해사례/사망사고) → news_90d (C-1 후속 흡수, 시간 민감)
|
||||
- source_channel='news' → news_90d
|
||||
- source_channel='news' → news_90d
|
||||
- source_channel='law_monitor' → law_365d
|
||||
|
||||
비적용 (None 반환):
|
||||
- meta 자체가 None
|
||||
- content_origin='ai_drafted' (생성 시점 = 가치 시점, 시간 demote 부적합)
|
||||
- ★법령(source_channel='law_monitor'/material_type='law'): C-1 후속에서 law_365d 폐기.
|
||||
법령 현행성은 version_status(B-1 버전체인 current/superseded)가 처리 — age-decay 는
|
||||
current 법령을 부당 강등(의도 변경 기록). law 검색 ranking = version_status decorate.
|
||||
- 그 외 모든 source_channel (manual, drive_sync, inbox_route, memo 등 — 자연 비적용)
|
||||
- 그 외 모든 source_channel (manual, drive_sync, inbox_route, memo,
|
||||
Study/Manual/Reference/Academic/Checklist 류 — 자연 비적용)
|
||||
"""
|
||||
if meta is None:
|
||||
return None
|
||||
# 가드 2: content_origin='ai_drafted' 비적용
|
||||
if meta.content_origin == "ai_drafted":
|
||||
return None
|
||||
# 재해사례/사망사고 = 시간 민감 → news 와 동일 90d (source 무관, 업로드 incident 도 포함)
|
||||
if meta.material_type == "incident":
|
||||
sc = meta.source_channel
|
||||
if sc == "news":
|
||||
return "news_90d"
|
||||
if meta.source_channel == "news":
|
||||
return "news_90d"
|
||||
# 법령 law_365d 폐기 + unknown source_channel → no decay
|
||||
if sc == "law_monitor":
|
||||
return "law_365d"
|
||||
# 가드 6: unknown source_channel → no decay
|
||||
return None
|
||||
|
||||
|
||||
@@ -133,7 +129,7 @@ async def _fetch_meta(
|
||||
text(
|
||||
"""
|
||||
SELECT id, source_channel::text AS source_channel,
|
||||
content_origin, material_type, created_at
|
||||
content_origin, created_at
|
||||
FROM documents
|
||||
WHERE id = ANY(:ids)
|
||||
"""
|
||||
@@ -145,7 +141,6 @@ async def _fetch_meta(
|
||||
source_channel=row.source_channel,
|
||||
content_origin=row.content_origin,
|
||||
created_at=row.created_at,
|
||||
material_type=getattr(row, "material_type", None),
|
||||
)
|
||||
for row in rows
|
||||
}
|
||||
|
||||
@@ -44,11 +44,10 @@ RERANK_TIMEOUT = 5.0
|
||||
# server-side allowlist map. query parameter 가 raw endpoint URL 받지 않음.
|
||||
RERANKER_BACKEND_MAP: dict[str, dict[str, str] | None] = {
|
||||
"baseline": None, # production reranker (config.yaml endpoint via AIClient.rerank)
|
||||
"cand_gte_ml_base": {
|
||||
"endpoint": "http://rerank-cand-gte-ml-base:80/rerank",
|
||||
},
|
||||
# mxbai_large 후보 (deberta-v2 → TEI 1.7 미지원) Phase 2B-Extended 이관
|
||||
# bge_v2_gemma_2b 후보 (LLM-based reranker, 1_Pooling/config.json 부재) Phase 2B-Extended 이관
|
||||
# Phase 2B 후보 reranker 전부 NO-GO 종결 (2026-06-18 teardown):
|
||||
# - cand_gte_ml_base : 컨테이너·DB 테이블(마이그 360)·override.rerank-cand.yml 제거됨
|
||||
# - mxbai_large (deberta-v2 → TEI 1.7 미지원) / bge_v2_gemma_2b (1_Pooling 부재) 미진입
|
||||
# dispatcher scaffold(_resolve_reranker)는 향후 후보 재진입 위해 보존.
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -54,42 +54,10 @@ QUERY_EMBED_MAXSIZE = 500
|
||||
# server-side allowlist map. query parameter 가 raw table name 받지 않음.
|
||||
CANDIDATE_BACKEND_MAP: dict[str, dict[str, str] | None] = {
|
||||
"baseline": None,
|
||||
"cand_me5_large_inst": {
|
||||
"docs_table": "documents_cand_me5_large_inst",
|
||||
"chunks_table": "document_chunks_cand_me5_large_inst",
|
||||
"embed_endpoint": "http://embedding-cand-me5-inst:80/embed",
|
||||
},
|
||||
"cand_snowflake_l_v2": {
|
||||
"docs_table": "documents_cand_snowflake_l_v2",
|
||||
"chunks_table": "document_chunks_cand_snowflake_l_v2",
|
||||
"embed_endpoint": "http://embedding-cand-snowflake-l-v2:80/embed",
|
||||
},
|
||||
# ─── Phase 2A (embedding-phase2a-1, 2026-06-12): Qwen3-Embedding 후보 3종 ───
|
||||
# embed_kind="ollama" = /api/embed 호출 + 쿼리측 instruct prefix (비대칭 사용,
|
||||
# G-1 fixture 실측: prefix 가 관련쌍 cos +0.016). 문서측은 backfill 이 plain 으로 적재.
|
||||
# qwen4m = 4B 의 MRL 1024d (dimensions 옵션 — Ollama 가 truncate+재정규화 수행, G-1 실측).
|
||||
"cand_qwen06": {
|
||||
"docs_table": "documents_cand_qwen06",
|
||||
"chunks_table": "document_chunks_cand_qwen06",
|
||||
"embed_endpoint": "http://ollama:11434/api/embed",
|
||||
"embed_kind": "ollama",
|
||||
"embed_model": "qwen3-embedding:0.6b",
|
||||
},
|
||||
"cand_qwen4": {
|
||||
"docs_table": "documents_cand_qwen4",
|
||||
"chunks_table": "document_chunks_cand_qwen4",
|
||||
"embed_endpoint": "http://ollama:11434/api/embed",
|
||||
"embed_kind": "ollama",
|
||||
"embed_model": "qwen3-embedding:4b",
|
||||
},
|
||||
"cand_qwen4m": {
|
||||
"docs_table": "documents_cand_qwen4m",
|
||||
"chunks_table": "document_chunks_cand_qwen4m",
|
||||
"embed_endpoint": "http://ollama:11434/api/embed",
|
||||
"embed_kind": "ollama",
|
||||
"embed_model": "qwen3-embedding:4b",
|
||||
"embed_dimensions": 1024,
|
||||
},
|
||||
# Phase 2A 임베딩 후보(me5_large_inst·snowflake_l_v2·qwen06·qwen4·qwen4m) 전량 no-go
|
||||
# 종결(2026-06-12, 후보 전부 -0.03~-0.04) → cand 슬러그·테이블 제거 (R13, 마이그 360
|
||||
# DROP). read-path 슬러그를 먼저 빼야 embedding_backend=cand_X /search 가 dropped 테이블을
|
||||
# 읽어 500 나지 않는다. baseline(production)만 잔존.
|
||||
}
|
||||
|
||||
# G-1 핀 고정 instruct 문자열 (inventory 2026-06-12-c 기록과 동일해야 함 —
|
||||
@@ -361,7 +329,7 @@ async def search_text(
|
||||
+ similarity(coalesce(d.ai_tags::text, ''), :q) * 2.5
|
||||
+ similarity(coalesce(d.user_note, ''), :q) * 2.0
|
||||
+ similarity(coalesce(d.ai_summary, ''), :q) * 1.5
|
||||
+ similarity(coalesce(d.extracted_text, ''), :q) * 1.0
|
||||
+ similarity(left(coalesce(d.extracted_text, ''), 2000), :q) * 1.0
|
||||
-- FTS 보너스 (idx_documents_fts_full 활용)
|
||||
+ coalesce(ts_rank(
|
||||
to_tsvector('simple',
|
||||
@@ -369,7 +337,7 @@ async def search_text(
|
||||
coalesce(d.ai_tags::text, '') || ' ' ||
|
||||
coalesce(d.ai_summary, '') || ' ' ||
|
||||
coalesce(d.user_note, '') || ' ' ||
|
||||
coalesce(d.extracted_text, '')
|
||||
left(coalesce(d.extracted_text, ''), 2000)
|
||||
),
|
||||
plainto_tsquery('simple', :q)
|
||||
), 0) * 2.0
|
||||
@@ -380,7 +348,7 @@ async def search_text(
|
||||
WHEN similarity(coalesce(d.ai_tags::text, ''), :q) >= 0.3 THEN 'tags'
|
||||
WHEN similarity(coalesce(d.user_note, ''), :q) >= 0.3 THEN 'note'
|
||||
WHEN similarity(coalesce(d.ai_summary, ''), :q) >= 0.3 THEN 'summary'
|
||||
WHEN similarity(coalesce(d.extracted_text, ''), :q) >= 0.3 THEN 'content'
|
||||
WHEN similarity(left(coalesce(d.extracted_text, ''), 2000), :q) >= 0.3 THEN 'content'
|
||||
ELSE 'fts'
|
||||
END AS match_reason,
|
||||
d.material_type, d.jurisdiction, d.published_date
|
||||
|
||||
@@ -32,6 +32,8 @@ from typing import TYPE_CHECKING, Literal
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from core.database import async_session
|
||||
|
||||
from . import query_analyzer, query_rewriter
|
||||
from .fusion_service import (
|
||||
DEFAULT_FUSION,
|
||||
@@ -188,6 +190,7 @@ async def run_search(
|
||||
snapshot_chunk_id_max=snapshot_chunk_id_max,
|
||||
reranker_backend=reranker_backend,
|
||||
rewrite_backend=rewrite_backend,
|
||||
axis=axis,
|
||||
)
|
||||
|
||||
timing: dict[str, float] = {}
|
||||
@@ -536,6 +539,7 @@ async def search_with_rewrite(
|
||||
snapshot_chunk_id_max: int | None,
|
||||
reranker_backend: str | None,
|
||||
rewrite_backend: str,
|
||||
axis: "AxisFilter | None" = None,
|
||||
) -> PipelineResult:
|
||||
"""Phase 2Q multi-query retrieval 합성 path (plan v6 §5.5).
|
||||
|
||||
@@ -579,13 +583,20 @@ async def search_with_rewrite(
|
||||
async def _variant_retrieve(
|
||||
v: str,
|
||||
) -> "tuple[list[SearchResult], list[SearchResult], dict[int, list[SearchResult]]]":
|
||||
text = await search_text(session, v, per_variant_k)
|
||||
raw_chunks = await search_vector(
|
||||
session, v, per_variant_k,
|
||||
embedding_backend=embedding_backend,
|
||||
snapshot_doc_id_max=snapshot_doc_id_max,
|
||||
snapshot_chunk_id_max=snapshot_chunk_id_max,
|
||||
)
|
||||
# 변형별 독립 AsyncSession (fan-out). 공유 session 을 asyncio.gather 로 동시
|
||||
# execute 에 넘기면 SQLAlchemy async 가 'another operation in progress' 로
|
||||
# 부하 의존적 비결정 크래시 — variant 마다 독립 연결로 분리한다.
|
||||
# axis(material_type/jurisdiction/year) 도 single-query path 와 동일하게 전달
|
||||
# (rewrite 경로가 axis 필터를 조용히 누락하던 결함 수정).
|
||||
async with async_session() as vsession:
|
||||
text = await search_text(vsession, v, per_variant_k, axis=axis)
|
||||
raw_chunks = await search_vector(
|
||||
vsession, v, per_variant_k,
|
||||
embedding_backend=embedding_backend,
|
||||
snapshot_doc_id_max=snapshot_doc_id_max,
|
||||
snapshot_chunk_id_max=snapshot_chunk_id_max,
|
||||
axis=axis,
|
||||
)
|
||||
vector, chunks_by_doc = compress_chunks_to_docs(raw_chunks, per_variant_k)
|
||||
return text, vector, chunks_by_doc
|
||||
|
||||
|
||||
@@ -95,8 +95,10 @@ except FileNotFoundError:
|
||||
)
|
||||
|
||||
|
||||
# ─── in-memory LRU (FIFO 근사, query_analyzer 패턴 복제) ─
|
||||
_CACHE: dict[str, SynthesisResult] = {}
|
||||
# ─── in-memory 캐시 (FIFO eviction + TTL, query_analyzer 패턴 복제) ─
|
||||
# R10: (ts, result) 저장 — TTL 미적용으로 원문 수정돼도 CACHE_MAXSIZE 찰 때까지 stale answer
|
||||
# 반환하던 결함 수정. query_rewriter 의 expire_at TTL enforce 정본 복제.
|
||||
_CACHE: dict[str, tuple[float, SynthesisResult]] = {}
|
||||
|
||||
|
||||
def _model_version() -> str:
|
||||
@@ -122,10 +124,11 @@ def get_cached(query: str, chunk_ids: list[int], backend_name: str = "gemma-macm
|
||||
entry = _CACHE.get(key)
|
||||
if entry is None:
|
||||
return None
|
||||
# TTL 체크는 elapsed_ms 를 악용할 수 없으므로 별도 저장
|
||||
# 여기서는 단순 policy 로 처리: entry 가 있으면 반환 (eviction 은 FIFO 시점)
|
||||
# 정확한 TTL 이 필요하면 (ts, result) tuple 로 저장해야 함.
|
||||
return entry
|
||||
ts, result = entry
|
||||
if time.time() - ts > CACHE_TTL:
|
||||
_CACHE.pop(key, None) # 만료 — 삭제 후 miss
|
||||
return None
|
||||
return result
|
||||
|
||||
|
||||
def _should_cache(result: SynthesisResult) -> bool:
|
||||
@@ -143,8 +146,9 @@ def set_cached(query: str, chunk_ids: list[int], result: SynthesisResult, backen
|
||||
if not _should_cache(result):
|
||||
return
|
||||
key = _cache_key(query, chunk_ids, backend_name)
|
||||
now = time.time()
|
||||
if key in _CACHE:
|
||||
_CACHE[key] = result
|
||||
_CACHE[key] = (now, result)
|
||||
return
|
||||
if len(_CACHE) >= CACHE_MAXSIZE:
|
||||
try:
|
||||
@@ -152,7 +156,7 @@ def set_cached(query: str, chunk_ids: list[int], result: SynthesisResult, backen
|
||||
_CACHE.pop(oldest, None)
|
||||
except StopIteration:
|
||||
pass
|
||||
_CACHE[key] = result
|
||||
_CACHE[key] = (now, result)
|
||||
|
||||
|
||||
def cache_stats() -> dict[str, int]:
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from collections.abc import AsyncIterator
|
||||
from pathlib import Path
|
||||
@@ -42,7 +43,7 @@ class LocalBackend(StorageBackend):
|
||||
to_read = _STREAM_CHUNK if remaining is None else min(_STREAM_CHUNK, remaining)
|
||||
if to_read <= 0:
|
||||
break
|
||||
data = f.read(to_read)
|
||||
data = await asyncio.to_thread(f.read, to_read)
|
||||
if not data:
|
||||
break
|
||||
yield data
|
||||
|
||||
@@ -252,12 +252,15 @@ async def gather_explanation_context(
|
||||
client = AIClient()
|
||||
query = _build_query(question)
|
||||
try:
|
||||
# 두 조회 병렬화 (rerank 호출이 별개라 lock 충돌 없음)
|
||||
docs, questions = await asyncio.gather(
|
||||
_gather_document_evidence(session, user_id, question.study_topic_id, query, client),
|
||||
_gather_question_evidence(
|
||||
session, user_id, question.study_topic_id, question.id, query, client
|
||||
),
|
||||
# 같은 AsyncSession 을 asyncio.gather 로 동시 execute 에 넘기면 SQLAlchemy async 가
|
||||
# 'another operation in progress' 로 부하 의존적 비결정 크래시(이전 주석 'lock 충돌
|
||||
# 없음' 은 rerank HTTP 만 보고 DB execute 동시성을 간과한 오인). 백그라운드 prefetch
|
||||
# 라 순차 직렬화 — 사용자 대면 rewrite 경로(독립 세션 fan-out)와는 다른 처방.
|
||||
docs = await _gather_document_evidence(
|
||||
session, user_id, question.study_topic_id, query, client
|
||||
)
|
||||
questions = await _gather_question_evidence(
|
||||
session, user_id, question.study_topic_id, question.id, query, client
|
||||
)
|
||||
return ExplanationContext(documents=docs, questions=questions)
|
||||
finally:
|
||||
|
||||
@@ -238,9 +238,13 @@ async def gather_subject_note_context(
|
||||
client = AIClient()
|
||||
query = _build_query(subject, scope)
|
||||
try:
|
||||
docs, questions = await asyncio.gather(
|
||||
_gather_document_evidence(session, user_id, study_topic_id, query, client),
|
||||
_gather_question_evidence(session, user_id, study_topic_id, subject, scope, query, client),
|
||||
# 같은 AsyncSession 동시 execute 회피 — 순차 직렬화(백그라운드 prefetch).
|
||||
# explanation_rag.gather_explanation_context 와 동형(R2 공유세션 동시성 수정).
|
||||
docs = await _gather_document_evidence(
|
||||
session, user_id, study_topic_id, query, client
|
||||
)
|
||||
questions = await _gather_question_evidence(
|
||||
session, user_id, study_topic_id, subject, scope, query, client
|
||||
)
|
||||
return SubjectNoteContext(documents=docs, questions=questions)
|
||||
finally:
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
"""arXiv 키워드 필터 수집기 — B-3 PR2 (plan safety-library-b3-1).
|
||||
|
||||
bespoke arXiv API(Atom) 수집기. 카테고리 RSS 통째(firehose)가 아니라
|
||||
cat:{category} AND (abs:키워드 ...) 로 안전/신뢰성/압력용기 관련분만 좁혀 수집한다.
|
||||
|
||||
- signal-only: 초록만 색인(embed+chunk), summarize 절대 미enqueue — 맥미니 Qwen 큐 무접촉.
|
||||
- DOI 보유 → paper.doi(서지 holder, partial-unique 인덱스 진입). 없으면 versionless arXiv id 로
|
||||
dedup(향후 PR4 reconcile 가 DOI 백필).
|
||||
- etiquette: 요청 간 ≥3s + HTTP 429 지수 백오프. 카테고리별 submittedDate 워터마크로 증분.
|
||||
- per-run insert cap(_RUN_CAP) — 광역 수집이 GPU bge-m3 embed 큐를 범람시키지 않게(적대리뷰 A major).
|
||||
잔여는 silent-cap 금지(csb idiom): 누락 건수 로깅.
|
||||
- keyless. enabled=False news_sources 행(6h 뉴스 사이클 비대상) + main.py CronTrigger(자체 폴링).
|
||||
- arXiv API 는 https 필수(http=301). UA = CRAWL_UA.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import re
|
||||
import xml.etree.ElementTree as ET
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import select
|
||||
|
||||
from core.crawl_politeness import CRAWL_UA
|
||||
from core.database import async_session
|
||||
from core.utils import setup_logger
|
||||
from models.document import Document
|
||||
from models.news_source import NewsSource
|
||||
from models.queue import enqueue_stage
|
||||
from services.papers.doi import arxiv_doi, normalize_doi
|
||||
from services.papers.holder import find_paper_holder
|
||||
from workers.news_collector import (
|
||||
FeedError,
|
||||
_get_or_create_health,
|
||||
_record_failure,
|
||||
_record_success,
|
||||
)
|
||||
|
||||
logger = setup_logger("arxiv_collector")
|
||||
|
||||
_ARXIV_API = "https://export.arxiv.org/api/query"
|
||||
_SOURCE_NAME = "arXiv 안전·공학 (keyword)"
|
||||
|
||||
# 신규 카테고리만 — 기존 RSS 행(id 62 physics.app-ph, id 64 cond-mat.mtrl-sci)과 비중복.
|
||||
_CATEGORIES = (
|
||||
"eess.SY", # systems & control
|
||||
"physics.flu-dyn", # 유체 — 압력/유동
|
||||
"physics.comp-ph", # 전산물리
|
||||
"math.OC", # 최적화·제어
|
||||
"math.NA", # 수치해석 (FEM 등)
|
||||
"stat.AP", # 응용통계 — 신뢰성
|
||||
"cs.CE", # computational engineering
|
||||
)
|
||||
# 압력용기·공정안전·구조건전성 도메인 키워드(abs: OR 게이트). 좁게 유지 = 관련성↑·볼륨↓ (튜너블).
|
||||
_KEYWORDS = (
|
||||
"pressure vessel",
|
||||
"process safety",
|
||||
"structural integrity",
|
||||
"fracture mechanics",
|
||||
"fatigue life",
|
||||
"corrosion",
|
||||
)
|
||||
|
||||
_RUN_CAP = 80 # 1회 run 신규 적재 상한(임베드 큐 보호). bulk 시 해제.
|
||||
_PAGE_SIZE = 50 # max_results per request
|
||||
_MAX_PAGES_PER_CAT = 4 # 카테고리당 최대 페이지(증분이라 보통 1페이지에 워터마크 도달)
|
||||
_REQ_SLEEP = 3.0 # arXiv etiquette ≥3s
|
||||
_MAX_RETRY = 4
|
||||
_BACKOFF_BASE = 5.0
|
||||
|
||||
_NS = {
|
||||
"a": "http://www.w3.org/2005/Atom",
|
||||
"arxiv": "http://arxiv.org/schemas/atom",
|
||||
"opensearch": "http://a9.com/-/spec/opensearch/1.1/",
|
||||
}
|
||||
_ABS_ID_RE = re.compile(r"arxiv\.org/abs/(.+?)(v\d+)?$")
|
||||
_WS_RE = re.compile(r"\s+")
|
||||
|
||||
|
||||
# ───────────────────────── 순수 파서 (fixture 단위 테스트 대상) ─────────────────────────
|
||||
|
||||
@dataclass
|
||||
class ArxivEntry:
|
||||
arxiv_id: str # versionless, 예: "1209.2405"
|
||||
version: str | None # "v1" 또는 None
|
||||
title: str
|
||||
summary: str # 초록
|
||||
published: datetime | None
|
||||
doi: str | None # normalize_doi 적용
|
||||
journal_ref: str | None
|
||||
primary_category: str | None
|
||||
categories: list = field(default_factory=list)
|
||||
abs_url: str | None = None
|
||||
pdf_url: str | None = None
|
||||
|
||||
|
||||
def _clean(text: str | None) -> str:
|
||||
return _WS_RE.sub(" ", text).strip() if text else ""
|
||||
|
||||
|
||||
def _parse_id(raw_id: str | None) -> tuple[str | None, str | None]:
|
||||
"""'http://arxiv.org/abs/1209.2405v1' → ('1209.2405', 'v1'). versionless id 가 dedup 키."""
|
||||
m = _ABS_ID_RE.search((raw_id or "").strip())
|
||||
if not m:
|
||||
return None, None
|
||||
return m.group(1), m.group(2)
|
||||
|
||||
|
||||
def _parse_dt(s: str | None) -> datetime | None:
|
||||
if not s:
|
||||
return None
|
||||
try:
|
||||
return datetime.fromisoformat(s.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def build_search_query(category: str, keywords=_KEYWORDS) -> str:
|
||||
"""cat:{category} AND (abs:kw1 OR abs:"kw with space" ...). 공백 키워드는 따옴표 구절."""
|
||||
kw = " OR ".join(f'abs:"{k}"' if " " in k else f"abs:{k}" for k in keywords)
|
||||
return f"cat:{category} AND ({kw})"
|
||||
|
||||
|
||||
def parse_arxiv_feed(xml_text: str) -> tuple[int, list[ArxivEntry]]:
|
||||
"""arXiv Atom 응답 → (total_results, [ArxivEntry]). 순수 함수."""
|
||||
root = ET.fromstring(xml_text)
|
||||
raw_total = root.findtext("opensearch:totalResults", default="0", namespaces=_NS)
|
||||
try:
|
||||
total = int(raw_total)
|
||||
except (TypeError, ValueError):
|
||||
total = 0
|
||||
entries: list[ArxivEntry] = []
|
||||
for e in root.findall("a:entry", _NS):
|
||||
aid, ver = _parse_id(e.findtext("a:id", namespaces=_NS))
|
||||
if not aid:
|
||||
continue
|
||||
prim = e.find("arxiv:primary_category", _NS)
|
||||
abs_url = pdf_url = None
|
||||
for ln in e.findall("a:link", _NS):
|
||||
if ln.get("rel") == "alternate" and (ln.get("type") or "").startswith("text/html"):
|
||||
abs_url = ln.get("href")
|
||||
elif ln.get("title") == "pdf":
|
||||
pdf_url = ln.get("href")
|
||||
entries.append(ArxivEntry(
|
||||
arxiv_id=aid,
|
||||
version=ver,
|
||||
title=_clean(e.findtext("a:title", namespaces=_NS)),
|
||||
summary=_clean(e.findtext("a:summary", namespaces=_NS)),
|
||||
published=_parse_dt(e.findtext("a:published", namespaces=_NS)),
|
||||
doi=normalize_doi(e.findtext("arxiv:doi", namespaces=_NS)),
|
||||
journal_ref=_clean(e.findtext("arxiv:journal_ref", namespaces=_NS)) or None,
|
||||
primary_category=prim.get("term") if prim is not None else None,
|
||||
categories=[c.get("term") for c in e.findall("a:category", _NS)],
|
||||
abs_url=abs_url,
|
||||
pdf_url=pdf_url,
|
||||
))
|
||||
return total, entries
|
||||
|
||||
|
||||
# ───────────────────────── 적재 (DB — PR2 라이브 검증) ─────────────────────────
|
||||
|
||||
def _build_paper_meta(source: NewsSource, entry: ArxivEntry, doi: str | None) -> dict:
|
||||
"""extract_meta — license + source + paper 식별. 서지 holder 는 paper.doi(있으면) 보유."""
|
||||
paper: dict = {"arxiv_id": entry.arxiv_id}
|
||||
if doi:
|
||||
paper["doi"] = doi # partial-unique 인덱스 진입 (교차소스 dedup)
|
||||
if entry.journal_ref:
|
||||
paper["journal_ref"] = entry.journal_ref
|
||||
if entry.primary_category:
|
||||
paper["primary_category"] = entry.primary_category
|
||||
meta: dict = {
|
||||
"source_id": source.id,
|
||||
"source_name": source.name,
|
||||
"source_region": "INT", # arXiv = 국제 preprint. paper.jurisdiction 은 NULL 유지(A-2).
|
||||
"paper": paper,
|
||||
# arXiv 기본 라이선스 = 비배포(보수적). restricted 부재 → 초록은 RAG 사용 가능.
|
||||
# (명시 CC 검출은 OAI 인터페이스 필요 — Atom API 미포함, PR 후속/관찰.)
|
||||
"license": {"scheme": "arxiv", "redistribute": False, "attribution": "arXiv"},
|
||||
}
|
||||
if entry.published:
|
||||
meta["published_at"] = entry.published.isoformat()
|
||||
return meta
|
||||
|
||||
|
||||
async def _ingest_entry(session, source: NewsSource, entry: ArxivEntry) -> bool:
|
||||
"""1건 적재. 반환 = 신규 여부. signal-only(embed+chunk, summarize 없음)."""
|
||||
arxiv_hash = hashlib.sha256(f"arxiv|{entry.arxiv_id}".encode()).hexdigest()[:32]
|
||||
# 재수집 dedup(arXiv id) — .first()(다중행 방어)
|
||||
dup = await session.execute(
|
||||
select(Document.id).where(Document.file_hash == arxiv_hash).limit(1)
|
||||
)
|
||||
if dup.scalars().first():
|
||||
return False
|
||||
# arXiv canonical DOI = 저널 DOI 또는 arXiv DataCite DOI(프리프린트도 paper.doi 보유 → PR3 와 dedup)
|
||||
doi = entry.doi or arxiv_doi(entry.arxiv_id)
|
||||
# 교차소스 dedup(DOI holder 이미 존재 — partial-unique 인덱스 백스톱 선제 회피)
|
||||
if doi and await find_paper_holder(session, doi):
|
||||
return False
|
||||
|
||||
body = entry.summary or entry.title
|
||||
doc = Document(
|
||||
file_path=f"crawl/arxiv/{entry.arxiv_id}",
|
||||
file_hash=arxiv_hash,
|
||||
file_format="article",
|
||||
file_size=len(body.encode()),
|
||||
file_type="note",
|
||||
title=entry.title,
|
||||
extracted_text=f"{entry.title}\n\n{body}",
|
||||
extracted_at=datetime.now(timezone.utc),
|
||||
extractor_version="arxiv-api-signal",
|
||||
md_status="skipped",
|
||||
md_extraction_error="arXiv abstract: signal-only, markdown 비대상",
|
||||
source_channel="crawl",
|
||||
data_origin="external",
|
||||
edit_url=entry.abs_url,
|
||||
review_status="approved",
|
||||
material_type="paper",
|
||||
jurisdiction=None, # paper = NULL 불변(A-2). 지역은 extract_meta.paper.source_region.
|
||||
published_date=entry.published.date() if entry.published else None,
|
||||
extract_meta=_build_paper_meta(source, entry, doi),
|
||||
)
|
||||
session.add(doc)
|
||||
await session.flush()
|
||||
# signal-only: 검색 색인만. summarize/fulltext 절대 enqueue 안 함(맥미니 큐 무접촉).
|
||||
await enqueue_stage(session, doc.id, "embed")
|
||||
await enqueue_stage(session, doc.id, "chunk")
|
||||
return True
|
||||
|
||||
|
||||
async def _get_or_create_source(session) -> NewsSource:
|
||||
result = await session.execute(
|
||||
select(NewsSource).where(NewsSource.name == _SOURCE_NAME)
|
||||
)
|
||||
source = result.scalars().first()
|
||||
if source is None:
|
||||
source = NewsSource(
|
||||
name=_SOURCE_NAME, feed_url=_ARXIV_API, feed_type="atom",
|
||||
fetch_method="signal-only", fulltext_policy="none",
|
||||
source_channel="crawl", category="Engineering", language="en",
|
||||
country=None, # paper → jurisdiction NULL (country 미전파)
|
||||
material_type="paper",
|
||||
license_scheme="arxiv", license_redistribute=False,
|
||||
enabled=False, # 6h 뉴스 사이클 비대상 — 본 워커가 자체 폴링
|
||||
)
|
||||
session.add(source)
|
||||
await session.flush()
|
||||
return source
|
||||
|
||||
|
||||
def _watermark(source: NewsSource, category: str) -> datetime | None:
|
||||
raw = (source.selector_override or {}).get("arxiv_watermark", {}).get(category)
|
||||
if not raw:
|
||||
return None
|
||||
return _parse_dt(raw)
|
||||
|
||||
|
||||
def _set_watermark(source: NewsSource, category: str, value: datetime) -> None:
|
||||
cfg = dict(source.selector_override or {})
|
||||
wm = dict(cfg.get("arxiv_watermark") or {})
|
||||
wm[category] = value.isoformat()
|
||||
cfg["arxiv_watermark"] = wm
|
||||
source.selector_override = cfg # JSONB 변경 감지 위해 재할당
|
||||
|
||||
|
||||
async def _fetch(client: httpx.AsyncClient, query: str, start: int) -> str:
|
||||
params = {
|
||||
"search_query": query, "start": start, "max_results": _PAGE_SIZE,
|
||||
"sortBy": "submittedDate", "sortOrder": "descending",
|
||||
}
|
||||
for attempt in range(_MAX_RETRY):
|
||||
resp = await client.get(_ARXIV_API, params=params)
|
||||
if resp.status_code == 429:
|
||||
await asyncio.sleep(_BACKOFF_BASE * (2 ** attempt))
|
||||
continue
|
||||
resp.raise_for_status()
|
||||
return resp.text
|
||||
raise FeedError(f"arXiv 429 재시도 초과: {query[:48]}")
|
||||
|
||||
|
||||
async def run(bulk: bool = False, limit: int = 0) -> None:
|
||||
"""daily 진입점(스케줄러). bulk/limit 은 CLI 전용(bulk=cap 해제·깊은 페이징)."""
|
||||
now = datetime.now(timezone.utc)
|
||||
async with async_session() as session:
|
||||
source = await _get_or_create_source(session)
|
||||
await session.commit()
|
||||
source_id = source.id
|
||||
|
||||
run_cap = (limit or 10**9) if bulk else (min(limit, _RUN_CAP) if limit else _RUN_CAP)
|
||||
inserted = 0
|
||||
seen = 0
|
||||
failures: list[str] = []
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
timeout=30.0, headers={"User-Agent": CRAWL_UA}, follow_redirects=True
|
||||
) as client:
|
||||
for category in _CATEGORIES:
|
||||
if inserted >= run_cap:
|
||||
break
|
||||
query = build_search_query(category)
|
||||
async with async_session() as session:
|
||||
src = await session.get(NewsSource, source_id)
|
||||
watermark = _watermark(src, category)
|
||||
newest_seen: datetime | None = None
|
||||
capped = False # 이번 run 이 cap 으로 카테고리 중도 절단됐는지 (R4)
|
||||
max_pages = (10**6 if bulk else _MAX_PAGES_PER_CAT)
|
||||
try:
|
||||
for page in range(max_pages):
|
||||
if inserted >= run_cap:
|
||||
capped = True
|
||||
break
|
||||
xml_text = await _fetch(client, query, page * _PAGE_SIZE)
|
||||
total, entries = parse_arxiv_feed(xml_text)
|
||||
if not entries:
|
||||
break
|
||||
stop = False
|
||||
for entry in entries:
|
||||
seen += 1
|
||||
if entry.published:
|
||||
newest_seen = max(newest_seen or entry.published, entry.published)
|
||||
# 증분: 워터마크 이하 도달 시 이 카테고리 종료(이미 본 구간)
|
||||
if watermark and not bulk and entry.published <= watermark:
|
||||
stop = True
|
||||
break
|
||||
async with async_session() as session:
|
||||
src = await session.get(NewsSource, source_id)
|
||||
if await _ingest_entry(session, src, entry):
|
||||
inserted += 1
|
||||
await session.commit()
|
||||
else:
|
||||
await session.rollback()
|
||||
if inserted >= run_cap:
|
||||
capped = True
|
||||
break
|
||||
await asyncio.sleep(_REQ_SLEEP)
|
||||
if stop or (page + 1) * _PAGE_SIZE >= total:
|
||||
break
|
||||
# 카테고리 워터마크 전진 — cap 으로 절단된 run 은 미전진 (R4).
|
||||
# 절단 시 newest_seen 으로 전진하면 [oldest-ingested, 옛 watermark] 사이
|
||||
# 미적재 항목이 다음 run 의 watermark 필터(entry.published <= watermark)에
|
||||
# 영구 배제(silent data loss). 미전진하면 다음 run 이 최신부터 재스캔하며
|
||||
# 적재분은 dedup-skip(_ingest_entry False, cap 미소모)하고 gap 까지 내려가
|
||||
# 이어 적재 → 백로그가 run 당 cap 씩 소화(livelock 회피). bulk 은 cap 무관.
|
||||
if newest_seen and not capped:
|
||||
async with async_session() as session:
|
||||
src = await session.get(NewsSource, source_id)
|
||||
_set_watermark(src, category, newest_seen)
|
||||
await session.commit()
|
||||
except (httpx.HTTPError, FeedError, ET.ParseError) as e:
|
||||
msg = f"[{category}] {e or repr(e)}"
|
||||
logger.error(f"[arxiv] {msg}")
|
||||
failures.append(msg)
|
||||
|
||||
async with async_session() as session:
|
||||
health = await _get_or_create_health(session, source_id)
|
||||
if failures and inserted == 0:
|
||||
_record_failure(health, "; ".join(failures)[:500], now)
|
||||
else:
|
||||
_record_success(health, inserted, False, now)
|
||||
await session.commit()
|
||||
|
||||
deferred = "" if inserted < run_cap else f" (cap {run_cap} 도달 — 잔여는 다음 run 이월)"
|
||||
logger.info(
|
||||
f"[arxiv] {len(_CATEGORIES)}개 카테고리 스캔 {seen}건 → 신규 {inserted}건{deferred}"
|
||||
+ (f" / 실패 {len(failures)}건" if failures else "")
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# CLI = 수동/백필 전용. --bulk = cap 해제·깊은 페이징, --limit N = 상한 N(라이브 검증용).
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description="arXiv 안전·공학 키워드 수집기")
|
||||
parser.add_argument("--bulk", action="store_true", help="cap 해제 + 깊은 페이징 백필")
|
||||
parser.add_argument("--limit", type=int, default=0, help="신규 적재 상한(0=기본 cap)")
|
||||
args = parser.parse_args()
|
||||
asyncio.run(run(bulk=args.bulk, limit=args.limit))
|
||||
@@ -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,
|
||||
)
|
||||
@@ -9,12 +9,15 @@ import asyncio
|
||||
from datetime import date
|
||||
|
||||
from core.config import settings
|
||||
from core.database import engine as db_engine
|
||||
from core.utils import setup_logger
|
||||
from services.background_jobs import finish_job, start_job
|
||||
from services.briefing.pipeline import run_briefing_pipeline
|
||||
|
||||
logger = setup_logger("briefing_worker")
|
||||
|
||||
PIPELINE_HARD_CAP = 600
|
||||
# 2026-06-15: config 단일소스 (digest 와 공유 키). 구 600s = 빠른 Gemma 기준.
|
||||
PIPELINE_HARD_CAP = settings.digest_pipeline_hard_cap_s
|
||||
|
||||
|
||||
async def run(target_date: date | None = None) -> dict | None:
|
||||
@@ -26,19 +29,24 @@ async def run(target_date: date | None = None) -> dict | None:
|
||||
if "briefing" in settings.pipeline_held_stages:
|
||||
logger.info("[briefing] 보류 (pipeline.held_stages) — 이번 실행 skip")
|
||||
return None
|
||||
# 보드 가시화: 큐 밖 cron 생성 작업이라 background_jobs 로 노출 (best-effort, 맥미니 귀속)
|
||||
job_id = await start_job(db_engine, "morning_briefing", label="조간 브리핑 생성")
|
||||
try:
|
||||
result = await asyncio.wait_for(
|
||||
run_briefing_pipeline(target_date),
|
||||
run_briefing_pipeline(target_date, job_id=job_id),
|
||||
timeout=PIPELINE_HARD_CAP,
|
||||
)
|
||||
await finish_job(db_engine, job_id, state="done")
|
||||
logger.info(f"[briefing] 워커 완료: {result}")
|
||||
return result
|
||||
except asyncio.TimeoutError:
|
||||
await finish_job(db_engine, job_id, state="failed", error=f"HARD CAP {PIPELINE_HARD_CAP}s 초과")
|
||||
logger.error(
|
||||
f"[briefing] HARD CAP {PIPELINE_HARD_CAP}s 초과 — 워커 강제 중단. "
|
||||
f"기존 briefing 은 commit 시점에만 갱신되므로 그대로 유지됨."
|
||||
)
|
||||
except Exception as e:
|
||||
await finish_job(db_engine, job_id, state="failed", error=str(e)[:300])
|
||||
logger.exception(f"[briefing] 워커 실패: {e}")
|
||||
return None
|
||||
|
||||
|
||||
@@ -272,15 +272,20 @@ async def _lookup_news_source(
|
||||
if not source_name:
|
||||
return None, None, None
|
||||
|
||||
# news_sources에서 이름이 일치하는 레코드 찾기 (prefix match)
|
||||
result = await session.execute(select(NewsSource))
|
||||
sources = result.scalars().all()
|
||||
for src in sources:
|
||||
if source_name and (
|
||||
src.name.split(" ")[0] == source_name
|
||||
or src.name.startswith(source_name + " ")
|
||||
):
|
||||
return src.country, src.name, src.language
|
||||
# news_sources prefix 매칭 — R10: 전체 로드+Python 루프 대신 DB 필터 푸시다운.
|
||||
# (name == source_name) OR (name 이 "source_name " 로 시작) = 기존 split[0]==source_name 동치
|
||||
# (첫 토큰 일치 = 정확일치 또는 'source_name ' prefix). autoescape 로 %/_ 안전.
|
||||
result = await session.execute(
|
||||
select(NewsSource)
|
||||
.where(
|
||||
(NewsSource.name == source_name)
|
||||
| NewsSource.name.startswith(source_name + " ", autoescape=True)
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
src = result.scalars().first()
|
||||
if src is not None:
|
||||
return src.country, src.name, src.language
|
||||
|
||||
logger.warning(
|
||||
f"[chunk] news_source 매핑 실패: doc_id={doc.id} ai_sub_group={source_name!r} "
|
||||
|
||||
@@ -411,6 +411,15 @@ async def process(
|
||||
logger.info(f"doc {document_id}: devonagent → classify skip")
|
||||
return
|
||||
|
||||
# 논문(material_type='paper') — 요약/분류 LLM 스킵(맥미니 큐 무접촉, B-3 signal-only 유지).
|
||||
# embed/chunk/markdown 은 queue_consumer 가 chain (early-return 후에도 다음 stage enqueue).
|
||||
if doc.material_type == "paper":
|
||||
if not doc.ai_domain:
|
||||
doc.ai_domain = "논문"
|
||||
await session.commit()
|
||||
logger.info(f"doc {document_id}: paper → classify skip (no summarize)")
|
||||
return
|
||||
|
||||
if not doc.extracted_text:
|
||||
raise ValueError(f"문서 ID {document_id}: extracted_text가 비어있음")
|
||||
|
||||
@@ -554,7 +563,9 @@ async def process(
|
||||
doc.facet_doctype = ai_doctype
|
||||
|
||||
# ─── ai_suggestion 저장 (자료실 승인 대기함 제안, §1) ───
|
||||
if ai_doctype in LIBRARY_SUGGESTION_DOCTYPES:
|
||||
# R9: 기존 제안(material_type 제안 등) 우선 — doc.ai_suggestion is None 가드 추가
|
||||
# (material 제안 블록과 대칭). 없으면 거래문서 제안이 기존 제안을 clobber('기존 제안 우선' 위반).
|
||||
if ai_doctype in LIBRARY_SUGGESTION_DOCTYPES and doc.ai_suggestion is None:
|
||||
year = doc.facet_year or datetime.now(timezone.utc).year
|
||||
doc.ai_suggestion = {
|
||||
"proposed_category": "library",
|
||||
|
||||
+31
-19
@@ -5,7 +5,8 @@ DEVONthink/OmniFocus → PostgreSQL/CalDAV 쿼리로 전환.
|
||||
SMTP 발송은 2026-06-10 제거 (한 번도 전달 성공한 적 없는 기능 — 폐기 결정).
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
import asyncio
|
||||
from datetime import datetime, time, timedelta, timezone
|
||||
from zoneinfo import ZoneInfo
|
||||
from pathlib import Path
|
||||
|
||||
@@ -20,17 +21,36 @@ from models.queue import ProcessingQueue
|
||||
logger = setup_logger("daily_digest")
|
||||
|
||||
|
||||
def _write_and_rotate(digest_dir: Path, today: str, markdown: str) -> Path:
|
||||
"""digest 파일 저장 + 90일 초과 아카이브 이동 (blocking — caller 가 to_thread, R8)."""
|
||||
digest_dir.mkdir(parents=True, exist_ok=True)
|
||||
digest_path = digest_dir / f"{today}_digest.md"
|
||||
digest_path.write_text(markdown, encoding="utf-8")
|
||||
archive_dir = digest_dir / "archive"
|
||||
archive_dir.mkdir(exist_ok=True)
|
||||
cutoff = datetime.now(timezone.utc).timestamp() - (90 * 86400)
|
||||
for old in digest_dir.glob("*_digest.md"):
|
||||
if old.stat().st_mtime < cutoff:
|
||||
old.rename(archive_dir / old.name)
|
||||
return digest_path
|
||||
|
||||
|
||||
async def run():
|
||||
"""일일 다이제스트 생성 + 저장 + 발송"""
|
||||
# KST 기준 오늘 (cron 이 KST timezone fix 후 20:00 KST 에 fire). date 객체로 비교 — Document.created_at::date 와 직접 매칭.
|
||||
today = datetime.now(ZoneInfo("Asia/Seoul")).date()
|
||||
# KST 기준 오늘 (cron 이 KST timezone fix 후 20:00 KST 에 fire).
|
||||
kst = ZoneInfo("Asia/Seoul")
|
||||
today = datetime.now(kst).date()
|
||||
# KST 하루를 UTC 범위로 변환 (R8) — func.date(created_at)는 pg TimeZone(UTC) 기준 날짜라
|
||||
# KST 0~9시 생성 문서(UTC 전날)가 누락되던 경계 버그. created_at(UTC저장) 범위 비교로.
|
||||
start_utc = datetime.combine(today, time.min, tzinfo=kst).astimezone(timezone.utc)
|
||||
end_utc = start_utc + timedelta(days=1)
|
||||
sections = []
|
||||
|
||||
async with async_session() as session:
|
||||
# ─── 1. 오늘 추가된 문서 ───
|
||||
added = await session.execute(
|
||||
select(Document.ai_domain, func.count(Document.id))
|
||||
.where(func.date(Document.created_at) == today)
|
||||
.where(Document.created_at >= start_utc, Document.created_at < end_utc)
|
||||
.group_by(Document.ai_domain)
|
||||
)
|
||||
added_rows = added.all()
|
||||
@@ -49,7 +69,8 @@ async def run():
|
||||
select(Document.title)
|
||||
.where(
|
||||
Document.source_channel == "law_monitor",
|
||||
func.date(Document.created_at) == today,
|
||||
Document.created_at >= start_utc,
|
||||
Document.created_at < end_utc,
|
||||
)
|
||||
)
|
||||
law_rows = law_docs.scalars().all()
|
||||
@@ -66,7 +87,8 @@ async def run():
|
||||
select(func.count(Document.id))
|
||||
.where(
|
||||
Document.source_channel == "email",
|
||||
func.date(Document.created_at) == today,
|
||||
Document.created_at >= start_utc,
|
||||
Document.created_at < end_utc,
|
||||
)
|
||||
)
|
||||
email_total = email_count.scalar() or 0
|
||||
@@ -101,7 +123,7 @@ async def run():
|
||||
)
|
||||
failed_count = failed.scalar() or 0
|
||||
if failed_count > 0:
|
||||
section += f"\n⚠️ **실패 {failed_count}건** — 수동 확인 필요\n"
|
||||
section += f"\n**[주의] 실패 {failed_count}건** — 수동 확인 필요\n"
|
||||
sections.append(section)
|
||||
|
||||
# ─── 5. Inbox 미분류 ───
|
||||
@@ -119,18 +141,8 @@ async def run():
|
||||
markdown += "\n".join(sections)
|
||||
markdown += f"\n---\n*생성: {datetime.now(timezone.utc).isoformat()}*\n"
|
||||
|
||||
# ─── NAS 저장 ───
|
||||
# ─── NAS 저장 + 90일 아카이브 (blocking 파일 I/O off-thread, R8/R5 일관) ───
|
||||
digest_dir = Path(settings.nas_mount_path) / "PKM" / "Archive" / "digests"
|
||||
digest_dir.mkdir(parents=True, exist_ok=True)
|
||||
digest_path = digest_dir / f"{today}_digest.md"
|
||||
digest_path.write_text(markdown, encoding="utf-8")
|
||||
|
||||
# ─── 90일 초과 아카이브 ───
|
||||
archive_dir = digest_dir / "archive"
|
||||
archive_dir.mkdir(exist_ok=True)
|
||||
cutoff = datetime.now(timezone.utc).timestamp() - (90 * 86400)
|
||||
for old in digest_dir.glob("*_digest.md"):
|
||||
if old.stat().st_mtime < cutoff:
|
||||
old.rename(archive_dir / old.name)
|
||||
digest_path = await asyncio.to_thread(_write_and_rotate, digest_dir, str(today), markdown)
|
||||
|
||||
logger.info(f"다이제스트 생성 완료: {digest_path}")
|
||||
|
||||
@@ -144,9 +144,13 @@ async def process(
|
||||
logger.info(f"[deep] id={document_id} 맥북 일시 불가 — 보류 (deferred)")
|
||||
raise
|
||||
except Exception as exc:
|
||||
# 호출 실패(네트워크/API 5xx 등)는 삼키지 않고 전파 (R3) — queue_consumer 가
|
||||
# attempts 소진까지 재시도 후 status=failed(dead-letter)로 가시화한다. 삼키면
|
||||
# worker_fn 이 정상 반환 → 큐가 completed 로 확정 → ai_detail_summary 영구 누락 +
|
||||
# tier 가 triage 에 고착(silent 영구 손실). extract/marker/fulltext/stt 정본과 일치.
|
||||
# 완주 전 doc 쓰기(168~)는 일어나지 않으므로 부분 쓰기 0 (sleep-안전).
|
||||
logger.warning(f"[deep] 호출 실패 id={document_id} model={used_cfg.model}: {exc}")
|
||||
parse_error = "call_failed"
|
||||
raw = ""
|
||||
raise
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
|
||||
@@ -11,12 +11,15 @@ global_digests / digest_topics 테이블에 저장한다.
|
||||
import asyncio
|
||||
|
||||
from core.config import settings
|
||||
from core.database import engine as db_engine
|
||||
from core.utils import setup_logger
|
||||
from services.background_jobs import finish_job, start_job
|
||||
from services.digest.pipeline import run_digest_pipeline
|
||||
|
||||
logger = setup_logger("digest_worker")
|
||||
|
||||
PIPELINE_HARD_CAP = 600 # 10분 hard cap
|
||||
# 2026-06-15: config 단일소스 (구 600s = 빠른 Gemma 기준, Qwen 27B 교체 후 누락 → 초과).
|
||||
PIPELINE_HARD_CAP = settings.digest_pipeline_hard_cap_s
|
||||
|
||||
|
||||
async def run() -> None:
|
||||
@@ -28,19 +31,24 @@ async def run() -> None:
|
||||
if "digest" in settings.pipeline_held_stages:
|
||||
logger.info("[global_digest] 보류 (pipeline.held_stages) — 이번 실행 skip")
|
||||
return
|
||||
# 보드 가시화: 큐 밖 cron 생성 작업이라 background_jobs 로 노출 (best-effort, 맥미니 귀속)
|
||||
job_id = await start_job(db_engine, "global_digest", label="글로벌 다이제스트 생성")
|
||||
try:
|
||||
result = await asyncio.wait_for(
|
||||
run_digest_pipeline(),
|
||||
run_digest_pipeline(job_id=job_id),
|
||||
timeout=PIPELINE_HARD_CAP,
|
||||
)
|
||||
await finish_job(db_engine, job_id, state="done")
|
||||
logger.info(f"[global_digest] 워커 완료: {result}")
|
||||
except asyncio.TimeoutError:
|
||||
await finish_job(db_engine, job_id, state="failed", error=f"HARD CAP {PIPELINE_HARD_CAP}s 초과")
|
||||
logger.error(
|
||||
f"[global_digest] HARD CAP {PIPELINE_HARD_CAP}s 초과 — 워커 강제 중단. "
|
||||
f"기존 digest 는 commit 시점에만 갱신되므로 그대로 유지됨. "
|
||||
f"다음 cron 실행에서 재시도."
|
||||
)
|
||||
except Exception as e:
|
||||
await finish_job(db_engine, job_id, state="failed", error=str(e)[:300])
|
||||
logger.exception(f"[global_digest] 워커 실패: {e}")
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
"""delete_file=true 로 요청된 문서의 NAS 원본을 grace 후 물리삭제 (R7 retention sweep).
|
||||
|
||||
purge_requested_at 마커 기준(deleted_at 아님 — 일반 soft-delete/숨김은 파일 보존, undelete
|
||||
가능). grace(30일) 경과 + 파일 존재 시 unlink + AUDIT 로그. 파일 존재 체크로 멱등
|
||||
(재실행 시 이미 삭제된 건 skip). 요청 경로(DELETE)엔 동기 비가역 op 0 — 모두 이 cron 으로.
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from core.config import settings
|
||||
from core.database import async_session
|
||||
from models.document import Document
|
||||
|
||||
logger = logging.getLogger("purge_sweep")
|
||||
|
||||
PURGE_GRACE_DAYS = 30
|
||||
|
||||
|
||||
def _unlink_if_exists(p: Path) -> bool:
|
||||
"""파일이 있으면 unlink (blocking — caller 가 to_thread). 존재 여부 반환(멱등)."""
|
||||
if p.exists():
|
||||
p.unlink()
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
async def run() -> int:
|
||||
"""purge 요청 + grace 경과 문서의 NAS 원본 물리삭제. 삭제 건수 반환."""
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(days=PURGE_GRACE_DAYS)
|
||||
async with async_session() as session:
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(Document.id, Document.file_path, Document.purge_requested_at).where(
|
||||
Document.purge_requested_at.is_not(None),
|
||||
Document.purge_requested_at < cutoff,
|
||||
Document.file_path.is_not(None),
|
||||
)
|
||||
)
|
||||
).all()
|
||||
|
||||
purged = 0
|
||||
for doc_id, file_path, requested_at in rows:
|
||||
nas_path = Path(settings.nas_mount_path) / file_path
|
||||
try:
|
||||
existed = await asyncio.to_thread(_unlink_if_exists, nas_path)
|
||||
if existed:
|
||||
purged += 1
|
||||
# AUDIT — 물리삭제 기록 (가시화). doc_id / 경로 / 요청일 / grace.
|
||||
logger.warning(
|
||||
"PURGE doc_id=%s file=%s requested_at=%s grace_days=%s",
|
||||
doc_id,
|
||||
file_path,
|
||||
requested_at.isoformat() if requested_at else None,
|
||||
PURGE_GRACE_DAYS,
|
||||
)
|
||||
except OSError as e:
|
||||
logger.error("PURGE 실패 doc_id=%s file=%s: %s", doc_id, file_path, e)
|
||||
|
||||
if purged:
|
||||
logger.info("[purge_sweep] NAS 원본 %d건 물리삭제 (grace %d일)", purged, PURGE_GRACE_DAYS)
|
||||
return purged
|
||||
@@ -17,6 +17,7 @@ Web/Blog ingest (devonagent 트랙, plan db-snuggly-petal.md):
|
||||
- sidecar (.json) 누락 시: skip 안 하고 ingest, web_meta.sidecar_missing=true
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
@@ -136,6 +137,10 @@ def _canonicalize_url(url: str) -> str:
|
||||
|
||||
같은 글의 utm 변형 (`?utm_source=foo`) 과 fragment 변형 (`#section`) 을
|
||||
한 row 로 수렴시키기 위해 file_hash 산출 전 반드시 거친다.
|
||||
|
||||
★R11c: news_collector._normalize_url(news 채널)과 의도적으로 다르다 — 이쪽(web_clip)은
|
||||
query-sort/trailing-slash/소문자화로 공격적 정규화하지만, news 쪽은 query-식별 사이트의
|
||||
별개 기사 붕괴 방지를 위해 보수적이다. 두 함수 통합 금지(채널별 dedup 의도가 다름).
|
||||
"""
|
||||
if not url:
|
||||
return ""
|
||||
@@ -246,7 +251,8 @@ async def watch_inbox():
|
||||
async with async_session() as session:
|
||||
# ─── Web/ 트랙 (devonagent) — DEVONthink Smart Rule 이 떨군 .html 만 진입 ───
|
||||
if web_root.exists():
|
||||
for file_path in web_root.rglob("*.html"):
|
||||
# rglob NFS 디렉토리 walk(blocking stat 다발)를 off-thread 로 수집 (R5).
|
||||
for file_path in await asyncio.to_thread(lambda: list(web_root.rglob("*.html"))):
|
||||
if not file_path.is_file() or should_skip(file_path):
|
||||
continue
|
||||
rel_path = str(file_path.relative_to(nas_root))
|
||||
@@ -264,7 +270,8 @@ async def watch_inbox():
|
||||
Path(sub).name, (None, None, None)
|
||||
)
|
||||
|
||||
for file_path in scan_root.rglob("*"):
|
||||
# NFS 디렉토리 walk(blocking) off-thread 수집 (R5).
|
||||
for file_path in await asyncio.to_thread(lambda: list(scan_root.rglob("*"))):
|
||||
if not file_path.is_file() or should_skip(file_path):
|
||||
continue
|
||||
|
||||
@@ -278,7 +285,11 @@ async def watch_inbox():
|
||||
continue
|
||||
|
||||
rel_path = str(file_path.relative_to(nas_root))
|
||||
fhash = file_hash(file_path)
|
||||
# GB 파일 SHA-256 은 이벤트 루프를 점유 → 같은 루프의 모든 1분 주기 consumer
|
||||
# + FastAPI 요청이 수십초~분 동시 정지. to_thread 오프로드. 스캔 루프가 이미
|
||||
# 순차라 file_hash 는 한 번에 하나만 실행(직렬화) — 병렬 해싱 X = NFS 2.5GbE
|
||||
# 대역폭·버퍼 메모리 blowup 방지 (R5).
|
||||
fhash = await asyncio.to_thread(file_hash, file_path)
|
||||
|
||||
result = await session.execute(
|
||||
select(Document).where(Document.file_path == rel_path)
|
||||
|
||||
@@ -297,6 +297,10 @@ async def collect_disaster_cases(session) -> int:
|
||||
await _ingest_attachment(session, boardno, filenm, filepath)
|
||||
except FeedError as e:
|
||||
logger.warning(f"[kosha] 첨부 실패 skip ({boardno}/{filenm}): {e}")
|
||||
|
||||
# 케이스 단위 commit (R4) — 이후 페이지/케이스의 _api_get 실패가 앞서 적재한
|
||||
# 케이스까지 전체 rollback 하지 않게 부분 적재 보존 (csb/api_standards idiom).
|
||||
await session.commit()
|
||||
if page_all_dup:
|
||||
break # 등록일 역순 — 페이지 전체가 기존이면 이후 페이지도 기존
|
||||
|
||||
@@ -374,6 +378,8 @@ async def collect_fatal_accidents(session) -> int:
|
||||
await enqueue_stage(session, doc.id, "embed")
|
||||
await enqueue_stage(session, doc.id, "chunk")
|
||||
new_count += 1
|
||||
# 케이스 단위 commit (R4) — 이후 페이지 실패가 앞 케이스 전체 rollback 방지.
|
||||
await session.commit()
|
||||
if page_all_dup:
|
||||
break # 등록일 역순 — 페이지 전체가 기존이면 이후 페이지도 기존
|
||||
|
||||
@@ -450,6 +456,8 @@ async def collect_kosha_guide(session, cap: int = _GUIDE_DAILY_CAP) -> int:
|
||||
await session.flush()
|
||||
await enqueue_stage(session, doc.id, "extract")
|
||||
ingested += 1
|
||||
# 항목 단위 commit (R4) — 다운로드 실패가 앞서 적재한 GUIDE 항목 전체 rollback 방지.
|
||||
await session.commit()
|
||||
|
||||
# silent cap 금지 — 잔량 가시화 (자동 점진 백필: 내일 cap 만큼 또 소화)
|
||||
logger.info(f"[kosha] GUIDE 신규/개정 {len(new_specs)}건 중 {ingested}건 ingest"
|
||||
|
||||
@@ -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()
|
||||
+65
-101
@@ -83,6 +83,10 @@ def _normalize_url(url: str) -> str:
|
||||
query 전체 제거 금지: hada.io/topic?id= · aitimes articleView.html?idxno= ·
|
||||
HN item?id= 등 query-식별 사이트에서 별개 기사가 같은 URL 로 붕괴된다.
|
||||
저장(edit_url)·조회 양쪽이 이 함수를 공유해야 dedup 이 성립.
|
||||
|
||||
★R11c: file_watcher._canonicalize_url(web_clip 채널)과 의도적으로 다르다 — 이쪽은 콘텐츠
|
||||
식별 query 보존(별개 기사 붕괴 방지)이 핵심이라 query-sort/trailing-slash/소문자화를 안 한다.
|
||||
두 함수 통합 금지(news dedup 가 깨짐). 채널별 normalization 은 의도된 설계.
|
||||
"""
|
||||
parsed = urlparse(url)
|
||||
kept = [
|
||||
@@ -397,6 +401,55 @@ def _doc_identity(source: NewsSource, source_short: str, category: str) -> dict:
|
||||
}
|
||||
|
||||
|
||||
async def _already_ingested(session, article_id: str, normalized_url: str, link: str) -> bool:
|
||||
"""이미 적재된 기사인지 — file_hash 또는 정규화/raw edit_url 매칭 (3 fetch 공통, R11c).
|
||||
|
||||
레거시 raw URL + 교차 게시 다중 매칭 내성(first). _fetch_rss/_fetch_api_guardian/
|
||||
_fetch_api_nyt 가 복제하던 동일 존재체크를 단일화.
|
||||
"""
|
||||
existing = await session.execute(
|
||||
select(Document).where(
|
||||
(Document.file_hash == article_id)
|
||||
| (Document.edit_url.in_([normalized_url, link]))
|
||||
).limit(1)
|
||||
)
|
||||
return existing.scalars().first() is not None
|
||||
|
||||
|
||||
def _build_news_doc(source, ident, source_short, article_id, title, body,
|
||||
extractor_version, normalized_url, pub_dt) -> Document:
|
||||
"""3 fetch 공통 뉴스 Document 빌더 (R11c). 채널별 차이는 인자로만 — body(NYT=summary)·
|
||||
extractor_version·ident(category 계산 차이 흡수)만 다르고 22 필드 구조는 정적 동일.
|
||||
edit_url 은 조회와 동일 정규화 저장(raw 저장 시 URL dedup 무력화)."""
|
||||
return Document(
|
||||
file_path=f"{ident['path_prefix']}/{source.name}/{article_id}",
|
||||
file_hash=article_id,
|
||||
file_format="article",
|
||||
file_size=len(body.encode()),
|
||||
file_type="note",
|
||||
title=title,
|
||||
extracted_text=f"{title}\n\n{body}",
|
||||
extracted_at=datetime.now(timezone.utc),
|
||||
extractor_version=extractor_version,
|
||||
# article = 텍스트 네이티브 → 생성 시점 terminal 'skipped' 명시(markdown 변환 비대상,
|
||||
# 미명시 시 'pending' 영구 비수렴 → backlog 지표 오염). page 정책은 fulltext_worker 승격.
|
||||
md_status="skipped",
|
||||
md_extraction_error="news article: 텍스트 네이티브, markdown 변환 비대상",
|
||||
source_channel=source.source_channel,
|
||||
data_origin="external",
|
||||
edit_url=normalized_url,
|
||||
review_status="approved",
|
||||
ai_domain=ident["ai_domain"],
|
||||
ai_sub_group=source_short,
|
||||
ai_tags=ident["ai_tags"],
|
||||
# 안전 자료실 A-2 — 레지스트리 deterministic (classify-skip 경로라 ingest 시점 필수)
|
||||
material_type=ident["material_type"],
|
||||
jurisdiction=ident["jurisdiction"],
|
||||
published_date=pub_dt.date() if pub_dt else None,
|
||||
extract_meta=_build_extract_meta(source, pub_dt),
|
||||
)
|
||||
|
||||
|
||||
async def _fetch_rss(session, source: NewsSource) -> tuple[int, str]:
|
||||
"""RSS 피드 수집 — redirect 재검증 + 크기/content-type 제한 + 조건부 GET (A-1).
|
||||
|
||||
@@ -515,13 +568,7 @@ async def _fetch_rss(session, source: NewsSource) -> tuple[int, str]:
|
||||
article_id = _article_hash(title, pub_dt.strftime("%Y%m%d"), source.name)
|
||||
normalized_url = _normalize_url(link)
|
||||
|
||||
existing = await session.execute(
|
||||
select(Document).where(
|
||||
(Document.file_hash == article_id) |
|
||||
(Document.edit_url.in_([normalized_url, link]))
|
||||
).limit(1)
|
||||
)
|
||||
if existing.scalars().first():
|
||||
if await _already_ingested(session, article_id, normalized_url, link):
|
||||
continue
|
||||
|
||||
# A-6 2차: 포털 전재 dedup (first-wins — 먼저 적재된 쪽이 정본)
|
||||
@@ -533,35 +580,9 @@ async def _fetch_rss(session, source: NewsSource) -> tuple[int, str]:
|
||||
source_short = source.name.split(" ")[0] # "경향신문 문화" → "경향신문"
|
||||
ident = _doc_identity(source, source_short, category)
|
||||
|
||||
doc = Document(
|
||||
file_path=f"{ident['path_prefix']}/{source.name}/{article_id}",
|
||||
file_hash=article_id,
|
||||
file_format="article",
|
||||
file_size=len(body.encode()),
|
||||
file_type="note",
|
||||
title=title,
|
||||
extracted_text=f"{title}\n\n{body}",
|
||||
extracted_at=datetime.now(timezone.utc),
|
||||
extractor_version=extractor_version,
|
||||
# article = 텍스트 네이티브(본문=extracted_text). markdown 단계 미enqueue 라
|
||||
# 기본값 'pending' 이면 영구 비수렴 → backlog 지표 오염 + md_status_pending partial
|
||||
# 인덱스 비대. 생성 시점에 terminal 'skipped' 로 명시(변환 비대상).
|
||||
# fulltext_policy='page' 소스는 fulltext_worker 가 승격 시 success 로 갱신.
|
||||
md_status="skipped",
|
||||
md_extraction_error="news article: 텍스트 네이티브, markdown 변환 비대상",
|
||||
source_channel=source.source_channel,
|
||||
data_origin="external",
|
||||
# 조회와 동일하게 정규화해 저장 — raw(tracking param 포함) 저장 시 URL dedup 무력화
|
||||
edit_url=normalized_url,
|
||||
review_status="approved",
|
||||
ai_domain=ident["ai_domain"],
|
||||
ai_sub_group=source_short,
|
||||
ai_tags=ident["ai_tags"],
|
||||
# 안전 자료실 A-2 — 레지스트리 deterministic (classify-skip 경로라 ingest 시점 필수)
|
||||
material_type=ident["material_type"],
|
||||
jurisdiction=ident["jurisdiction"],
|
||||
published_date=pub_dt.date() if pub_dt else None,
|
||||
extract_meta=_build_extract_meta(source, pub_dt),
|
||||
doc = _build_news_doc(
|
||||
source, ident, source_short, article_id, title, body,
|
||||
extractor_version, normalized_url, pub_dt,
|
||||
)
|
||||
session.add(doc)
|
||||
await session.flush()
|
||||
@@ -658,13 +679,7 @@ async def _fetch_api_guardian(session, source: NewsSource) -> tuple[int, str]:
|
||||
normalized_url = _normalize_url(link)
|
||||
|
||||
# RSS 수집부와 동일: 레거시 raw URL + 교차 게시 다중 매칭 내성 (first)
|
||||
existing = await session.execute(
|
||||
select(Document).where(
|
||||
(Document.file_hash == article_id) |
|
||||
(Document.edit_url.in_([normalized_url, link]))
|
||||
).limit(1)
|
||||
)
|
||||
if existing.scalars().first():
|
||||
if await _already_ingested(session, article_id, normalized_url, link):
|
||||
continue
|
||||
|
||||
if await _is_portal_duplicate(session, title):
|
||||
@@ -675,30 +690,9 @@ async def _fetch_api_guardian(session, source: NewsSource) -> tuple[int, str]:
|
||||
source_short = source.name.split(" ")[0]
|
||||
ident = _doc_identity(source, source_short, category)
|
||||
|
||||
doc = Document(
|
||||
file_path=f"{ident['path_prefix']}/{source.name}/{article_id}",
|
||||
file_hash=article_id,
|
||||
file_format="article",
|
||||
file_size=len(body.encode()),
|
||||
file_type="note",
|
||||
title=title,
|
||||
extracted_text=f"{title}\n\n{body}",
|
||||
extracted_at=datetime.now(timezone.utc),
|
||||
extractor_version="guardian_api_full" if is_full else "guardian_api",
|
||||
md_status="skipped",
|
||||
md_extraction_error="news article: 텍스트 네이티브, markdown 변환 비대상",
|
||||
source_channel=source.source_channel,
|
||||
data_origin="external",
|
||||
edit_url=normalized_url,
|
||||
review_status="approved",
|
||||
ai_domain=ident["ai_domain"],
|
||||
ai_sub_group=source_short,
|
||||
ai_tags=ident["ai_tags"],
|
||||
# 안전 자료실 A-2 — 레지스트리 deterministic (classify-skip 경로라 ingest 시점 필수)
|
||||
material_type=ident["material_type"],
|
||||
jurisdiction=ident["jurisdiction"],
|
||||
published_date=pub_dt.date() if pub_dt else None,
|
||||
extract_meta=_build_extract_meta(source, pub_dt),
|
||||
doc = _build_news_doc(
|
||||
source, ident, source_short, article_id, title, body,
|
||||
"guardian_api_full" if is_full else "guardian_api", normalized_url, pub_dt,
|
||||
)
|
||||
session.add(doc)
|
||||
await session.flush()
|
||||
@@ -755,13 +749,7 @@ async def _fetch_api_nyt(session, source: NewsSource) -> tuple[int, str]:
|
||||
normalized_url = _normalize_url(link)
|
||||
|
||||
# RSS 수집부와 동일: 레거시 raw URL + 교차 게시 다중 매칭 내성 (first)
|
||||
existing = await session.execute(
|
||||
select(Document).where(
|
||||
(Document.file_hash == article_id) |
|
||||
(Document.edit_url.in_([normalized_url, link]))
|
||||
).limit(1)
|
||||
)
|
||||
if existing.scalars().first():
|
||||
if await _already_ingested(session, article_id, normalized_url, link):
|
||||
continue
|
||||
|
||||
if await _is_portal_duplicate(session, title):
|
||||
@@ -772,33 +760,9 @@ async def _fetch_api_nyt(session, source: NewsSource) -> tuple[int, str]:
|
||||
source_short = source.name.split(" ")[0]
|
||||
|
||||
ident = _doc_identity(source, source_short, category)
|
||||
doc = Document(
|
||||
file_path=f"{ident['path_prefix']}/{source.name}/{article_id}",
|
||||
file_hash=article_id,
|
||||
file_format="article",
|
||||
file_size=len(summary.encode()),
|
||||
file_type="note",
|
||||
title=title,
|
||||
extracted_text=f"{title}\n\n{summary}",
|
||||
extracted_at=datetime.now(timezone.utc),
|
||||
extractor_version="nyt_api",
|
||||
# article = 텍스트 네이티브(본문=extracted_text). markdown 단계 미enqueue 라
|
||||
# 기본값 'pending' 이면 영구 비수렴 → backlog 지표 오염 + md_status_pending partial
|
||||
# 인덱스 비대. 생성 시점에 terminal 'skipped' 로 명시(변환 비대상).
|
||||
md_status="skipped",
|
||||
md_extraction_error="news article: 텍스트 네이티브, markdown 변환 비대상",
|
||||
source_channel=source.source_channel,
|
||||
data_origin="external",
|
||||
edit_url=normalized_url,
|
||||
review_status="approved",
|
||||
ai_domain=ident["ai_domain"],
|
||||
ai_sub_group=source_short,
|
||||
ai_tags=ident["ai_tags"],
|
||||
# 안전 자료실 A-2 — 레지스트리 deterministic (classify-skip 경로라 ingest 시점 필수)
|
||||
material_type=ident["material_type"],
|
||||
jurisdiction=ident["jurisdiction"],
|
||||
published_date=pub_dt.date() if pub_dt else None,
|
||||
extract_meta=_build_extract_meta(source, pub_dt),
|
||||
doc = _build_news_doc(
|
||||
source, ident, source_short, article_id, title, summary,
|
||||
"nyt_api", normalized_url, pub_dt,
|
||||
)
|
||||
session.add(doc)
|
||||
await session.flush()
|
||||
|
||||
@@ -0,0 +1,400 @@
|
||||
"""OpenAlex 백본 수집기 — B-3 PR3 (plan safety-library-b3-1).
|
||||
|
||||
OpenAlex = 발견+dedup 글로벌 백본(JP/EU/US 논문 다 색인 + 정본 DOI). 전문은 안 줌(oa_url 포인터만).
|
||||
- scaffold-first: OPENALEX_API_KEY 부재 시 FeedError(explicit-skip, silent fallback 금지). 키=무료.
|
||||
- signal-only: 초록(inverted-index 복원)만 색인(embed+chunk), summarize 절대 미enqueue(맥미니 큐 무접촉).
|
||||
PDF 는 절대 OpenAlex 경유로 안 받음(oa_url 은 링크/신호일 뿐).
|
||||
- 관련성 사전필터 = title_and_abstract.search 키워드(서버측) + per-run insert cap(임베드 firehose 차단,
|
||||
적대리뷰 A major). cursor 페이징 + from_publication_date 워터마크로 증분.
|
||||
- 초록 없는 thin 레코드(주로 비-OA 메타)는 skip — Phase-1 재료 품질 유지.
|
||||
- DOI → paper.doi(holder, partial-unique 인덱스, 교차소스 dedup). 없으면 openalex id fallback.
|
||||
- license: 명시 CC → redistribute=true / 그 외 OA·closed → false(restricted 부재 = 초록 RAG 사용 가능).
|
||||
- enabled=False news_sources 행 + main.py CronTrigger(자체 폴링). list+filter 비용 미미($1/일 크레딧).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime, timezone
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import select
|
||||
|
||||
from core.crawl_politeness import CRAWL_UA
|
||||
from core.database import async_session
|
||||
from core.utils import setup_logger
|
||||
from models.document import Document
|
||||
from models.news_source import NewsSource
|
||||
from models.queue import enqueue_stage
|
||||
from services.papers.doi import normalize_doi
|
||||
from services.papers.holder import find_paper_holder
|
||||
from workers.news_collector import (
|
||||
FeedError,
|
||||
_get_or_create_health,
|
||||
_record_failure,
|
||||
_record_success,
|
||||
)
|
||||
|
||||
logger = setup_logger("openalex_collector")
|
||||
|
||||
_API = "https://api.openalex.org/works"
|
||||
_SOURCE_NAME = "OpenAlex 안전·공학 (keyword)"
|
||||
_ENV_KEY = "OPENALEX_API_KEY"
|
||||
|
||||
# 압력용기·공정안전·구조건전성 도메인 키워드(키워드별 1쿼리 = 관련성 사전필터).
|
||||
_KEYWORDS = (
|
||||
"pressure vessel safety",
|
||||
"process safety",
|
||||
"structural integrity",
|
||||
"fracture mechanics",
|
||||
"fatigue life assessment",
|
||||
)
|
||||
|
||||
# 도메인 직결 저널 ISSN 시드(OpenAlex sources 실측 확인) — 키워드 매칭 누락분까지 전수 커버.
|
||||
# KR 안전/가스/기계 + JP 고압. KR/JP 관심 = OpenAlex 깨끗한 API 로 직접(KoreaScience/J-STAGE 전용
|
||||
# 스크래퍼 불요 — Phase-1 메타는 OpenAlex 와 중복, 전용 수집기의 유니크 가치=무료 전문 PDF=Phase-2).
|
||||
_JOURNAL_ISSNS = (
|
||||
("한국안전학회지", "1738-3803"),
|
||||
("한국가스학회지", "1226-8402"),
|
||||
("대한기계학회논문집 A", "1226-4873"),
|
||||
("대한기계학회논문집 B", "1226-4881"),
|
||||
("KSME International J.", "1226-4865"),
|
||||
("Review of High Pressure Sci&Tech (JP)", "0917-639X"),
|
||||
)
|
||||
|
||||
_RUN_CAP = 60 # 1회 run 신규 적재 상한(임베드 큐 보호). bulk 시 해제.
|
||||
_PER_PAGE = 50
|
||||
_MAX_PAGES_PER_KW = 4 # 키워드당 최대 페이지(증분이라 보통 1페이지에 워터마크 도달)
|
||||
_REQ_SLEEP = 1.0 # 페이지 간 polite 간격
|
||||
_MAX_RETRY = 4
|
||||
_BACKOFF_BASE = 5.0
|
||||
|
||||
|
||||
# ───────────────────────── 순수 파서 (fixture 단위 테스트 대상) ─────────────────────────
|
||||
|
||||
@dataclass
|
||||
class OpenAlexWork:
|
||||
openalex_id: str # "W2910511816"
|
||||
doi: str | None # normalize_doi 적용
|
||||
title: str
|
||||
abstract: str # inverted-index 복원 (없으면 "")
|
||||
publication_date: str | None
|
||||
oa_status: str | None # closed/green/bronze/hybrid/gold/diamond
|
||||
oa_url: str | None
|
||||
is_oa: bool
|
||||
license: str | None # cc-by / cc-by-nc-nd / None
|
||||
source_name: str | None
|
||||
primary_topic: str | None
|
||||
work_type: str | None
|
||||
|
||||
|
||||
def _clean(text):
|
||||
return " ".join(text.split()).strip() if text else ""
|
||||
|
||||
|
||||
def _reconstruct_abstract(inv: dict | None) -> str:
|
||||
"""abstract_inverted_index({word:[positions]}) → 평문 초록. 없으면 ''."""
|
||||
if not inv:
|
||||
return ""
|
||||
positions = [(pos, word) for word, idxs in inv.items() for pos in idxs]
|
||||
positions.sort()
|
||||
return " ".join(w for _, w in positions)
|
||||
|
||||
|
||||
def license_meta(license_str: str | None, is_oa: bool, source_name: str | None) -> dict:
|
||||
"""extract_meta.license — 명시 CC/public-domain 만 redistribute=true. restricted 부재(초록 색인 자유).
|
||||
|
||||
redistribute=false 라도 restricted 가 없으면 RAG 사용 가능(초록). 비-CC 전문의 RAG verbatim 차단은
|
||||
Phase-2 전문 승격 단계가 restricted=true 로 처리(L-1) — Phase-1(초록)은 무해.
|
||||
"""
|
||||
attribution = source_name or "OpenAlex"
|
||||
if license_str and (license_str.startswith("cc") or license_str == "public-domain"):
|
||||
return {"scheme": license_str, "redistribute": True, "attribution": attribution}
|
||||
return {
|
||||
"scheme": "open-unspecified" if is_oa else "proprietary",
|
||||
"redistribute": False,
|
||||
"attribution": attribution,
|
||||
}
|
||||
|
||||
|
||||
def parse_openalex_works(json_text: str) -> tuple[int, str | None, list[OpenAlexWork]]:
|
||||
"""OpenAlex /works 응답 → (count, next_cursor, [OpenAlexWork]). 순수 함수."""
|
||||
d = json.loads(json_text)
|
||||
meta = d.get("meta") or {}
|
||||
count = meta.get("count") or 0
|
||||
next_cursor = meta.get("next_cursor")
|
||||
works: list[OpenAlexWork] = []
|
||||
for w in d.get("results") or []:
|
||||
oid = (w.get("id") or "").rstrip("/").rsplit("/", 1)[-1]
|
||||
if not oid:
|
||||
continue
|
||||
oa = w.get("open_access") or {}
|
||||
pl = w.get("primary_location") or {}
|
||||
pt = w.get("primary_topic") or {}
|
||||
works.append(OpenAlexWork(
|
||||
openalex_id=oid,
|
||||
doi=normalize_doi(w.get("doi")),
|
||||
title=_clean(w.get("title")),
|
||||
abstract=_reconstruct_abstract(w.get("abstract_inverted_index")),
|
||||
publication_date=w.get("publication_date"),
|
||||
oa_status=oa.get("oa_status"),
|
||||
oa_url=oa.get("oa_url") or None,
|
||||
is_oa=bool(oa.get("is_oa")),
|
||||
license=pl.get("license"),
|
||||
source_name=(pl.get("source") or {}).get("display_name"),
|
||||
primary_topic=pt.get("display_name"),
|
||||
work_type=w.get("type"),
|
||||
))
|
||||
return count, next_cursor, works
|
||||
|
||||
|
||||
def build_filter(keyword: str, from_date: str | None = None) -> str:
|
||||
f = f"title_and_abstract.search:{keyword}"
|
||||
if from_date:
|
||||
f += f",from_publication_date:{from_date}"
|
||||
return f
|
||||
|
||||
|
||||
def build_issn_filter(issn: str, from_date: str | None = None) -> str:
|
||||
f = f"primary_location.source.issn:{issn}"
|
||||
if from_date:
|
||||
f += f",from_publication_date:{from_date}"
|
||||
return f
|
||||
|
||||
|
||||
def _seeds() -> list[tuple[str, str, str]]:
|
||||
"""수집 시드 = (라벨, 워터마크키, 종류). 도메인 저널 ISSN 우선(cap 우선권) → 키워드."""
|
||||
s: list[tuple[str, str, str]] = [(label, issn, "issn") for label, issn in _JOURNAL_ISSNS]
|
||||
s += [(kw, kw, "kw") for kw in _KEYWORDS]
|
||||
return s
|
||||
|
||||
|
||||
# ───────────────────────── 적재 (DB — PR3 라이브 검증) ─────────────────────────
|
||||
|
||||
def _build_paper_meta(source: NewsSource, w: OpenAlexWork) -> dict:
|
||||
paper: dict = {"openalex_id": w.openalex_id}
|
||||
if w.doi:
|
||||
paper["doi"] = w.doi # partial-unique 인덱스 진입(교차소스 dedup)
|
||||
if w.oa_status:
|
||||
paper["oa_status"] = w.oa_status
|
||||
if w.oa_url:
|
||||
paper["oa_url"] = w.oa_url # 링크/신호 — 자동 fetch 안 함
|
||||
if w.primary_topic:
|
||||
paper["topic"] = w.primary_topic
|
||||
meta: dict = {
|
||||
"source_id": source.id,
|
||||
"source_name": source.name,
|
||||
"source_region": "INT", # OpenAlex = 글로벌. paper.jurisdiction 은 NULL 유지(A-2).
|
||||
"paper": paper,
|
||||
"license": license_meta(w.license, w.is_oa, w.source_name),
|
||||
}
|
||||
if w.publication_date:
|
||||
meta["published_at"] = w.publication_date
|
||||
return meta
|
||||
|
||||
|
||||
async def _ingest_work(session, source: NewsSource, w: OpenAlexWork) -> bool:
|
||||
"""1건 적재. 반환 = 신규 여부. signal-only. 초록 없으면 skip(thin 레코드 배제)."""
|
||||
if not w.abstract:
|
||||
return False # 초록 없는 thin 레코드(주로 비-OA 메타) — Phase-1 재료 품질 유지
|
||||
oid_hash = hashlib.sha256(f"openalex|{w.openalex_id}".encode()).hexdigest()[:32]
|
||||
dup = await session.execute(
|
||||
select(Document.id).where(Document.file_hash == oid_hash).limit(1)
|
||||
)
|
||||
if dup.scalars().first():
|
||||
return False
|
||||
if w.doi and await find_paper_holder(session, w.doi):
|
||||
return False # 교차소스 dedup(arXiv 등이 이미 holder 보유)
|
||||
|
||||
pub_date = None
|
||||
if w.publication_date:
|
||||
try:
|
||||
pub_date = date.fromisoformat(w.publication_date)
|
||||
except ValueError:
|
||||
pub_date = None
|
||||
body = w.abstract
|
||||
doc = Document(
|
||||
file_path=f"crawl/openalex/{w.openalex_id}",
|
||||
file_hash=oid_hash,
|
||||
file_format="article",
|
||||
file_size=len(body.encode()),
|
||||
file_type="note",
|
||||
title=w.title,
|
||||
extracted_text=f"{w.title}\n\n{body}",
|
||||
extracted_at=datetime.now(timezone.utc),
|
||||
extractor_version="openalex-signal",
|
||||
md_status="skipped",
|
||||
md_extraction_error="OpenAlex abstract: signal-only, markdown 비대상",
|
||||
source_channel="crawl",
|
||||
data_origin="external",
|
||||
edit_url=w.oa_url or f"https://openalex.org/{w.openalex_id}",
|
||||
review_status="approved",
|
||||
material_type="paper",
|
||||
jurisdiction=None,
|
||||
published_date=pub_date,
|
||||
extract_meta=_build_paper_meta(source, w),
|
||||
)
|
||||
session.add(doc)
|
||||
await session.flush()
|
||||
await enqueue_stage(session, doc.id, "embed")
|
||||
await enqueue_stage(session, doc.id, "chunk")
|
||||
return True
|
||||
|
||||
|
||||
async def _get_or_create_source(session) -> NewsSource:
|
||||
result = await session.execute(
|
||||
select(NewsSource).where(NewsSource.name == _SOURCE_NAME)
|
||||
)
|
||||
source = result.scalars().first()
|
||||
if source is None:
|
||||
source = NewsSource(
|
||||
name=_SOURCE_NAME, feed_url=_API, feed_type="json",
|
||||
fetch_method="signal-only", fulltext_policy="none",
|
||||
source_channel="crawl", category="Engineering", language="en",
|
||||
country=None, material_type="paper",
|
||||
license_scheme="openalex", license_redistribute=False,
|
||||
enabled=False,
|
||||
)
|
||||
session.add(source)
|
||||
await session.flush()
|
||||
return source
|
||||
|
||||
|
||||
def _api_key() -> str:
|
||||
key = os.getenv(_ENV_KEY, "").strip()
|
||||
if not key:
|
||||
raise FeedError(f"{_ENV_KEY} 미설정 — OpenAlex 수집 불가 (scaffold-first explicit-skip)")
|
||||
return key
|
||||
|
||||
|
||||
def _watermark(source: NewsSource, keyword: str) -> str | None:
|
||||
return (source.selector_override or {}).get("openalex_watermark", {}).get(keyword)
|
||||
|
||||
|
||||
def _set_watermark(source: NewsSource, keyword: str, value: str) -> None:
|
||||
cfg = dict(source.selector_override or {})
|
||||
wm = dict(cfg.get("openalex_watermark") or {})
|
||||
wm[keyword] = value
|
||||
cfg["openalex_watermark"] = wm
|
||||
source.selector_override = cfg
|
||||
|
||||
|
||||
async def _fetch(client: httpx.AsyncClient, key: str, filter_str: str, cursor: str) -> str:
|
||||
params = {
|
||||
"filter": filter_str, "per-page": _PER_PAGE, "cursor": cursor,
|
||||
"sort": "publication_date:desc", "api_key": key,
|
||||
}
|
||||
for attempt in range(_MAX_RETRY):
|
||||
resp = await client.get(_API, params=params)
|
||||
if resp.status_code == 429:
|
||||
await asyncio.sleep(_BACKOFF_BASE * (2 ** attempt))
|
||||
continue
|
||||
resp.raise_for_status()
|
||||
return resp.text
|
||||
raise FeedError(f"OpenAlex 429 재시도 초과: {filter_str[:48]}")
|
||||
|
||||
|
||||
async def run(bulk: bool = False, limit: int = 0) -> None:
|
||||
"""daily 진입점(스케줄러). 키 부재 = explicit-skip(health 실패 기록)."""
|
||||
now = datetime.now(timezone.utc)
|
||||
async with async_session() as session:
|
||||
source = await _get_or_create_source(session)
|
||||
await session.commit()
|
||||
source_id = source.id
|
||||
|
||||
try:
|
||||
key = _api_key()
|
||||
except FeedError as e:
|
||||
logger.warning(f"[openalex] {e}")
|
||||
async with async_session() as session:
|
||||
health = await _get_or_create_health(session, source_id)
|
||||
_record_failure(health, str(e), now)
|
||||
await session.commit()
|
||||
return
|
||||
|
||||
run_cap = (limit or 10**9) if bulk else (min(limit, _RUN_CAP) if limit else _RUN_CAP)
|
||||
inserted = 0
|
||||
seen = 0
|
||||
failures: list[str] = []
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
timeout=30.0, headers={"User-Agent": CRAWL_UA}, follow_redirects=True
|
||||
) as client:
|
||||
for label, wm_key, kind in _seeds():
|
||||
if inserted >= run_cap:
|
||||
break
|
||||
async with async_session() as session:
|
||||
src = await session.get(NewsSource, source_id)
|
||||
watermark = None if bulk else _watermark(src, wm_key)
|
||||
filter_str = (build_issn_filter(wm_key, watermark) if kind == "issn"
|
||||
else build_filter(wm_key, watermark))
|
||||
newest: str | None = None
|
||||
capped = False # 이번 run 이 cap 으로 시드 중도 절단됐는지 (R4)
|
||||
cursor = "*"
|
||||
max_pages = (10**6 if bulk else _MAX_PAGES_PER_KW)
|
||||
try:
|
||||
for _page in range(max_pages):
|
||||
if inserted >= run_cap:
|
||||
capped = True
|
||||
break
|
||||
text = await _fetch(client, key, filter_str, cursor)
|
||||
_count, next_cursor, works = parse_openalex_works(text)
|
||||
if not works:
|
||||
break
|
||||
for w in works:
|
||||
seen += 1
|
||||
if w.publication_date and (newest is None or w.publication_date > newest):
|
||||
newest = w.publication_date
|
||||
async with async_session() as session:
|
||||
src = await session.get(NewsSource, source_id)
|
||||
if await _ingest_work(session, src, w):
|
||||
inserted += 1
|
||||
await session.commit()
|
||||
else:
|
||||
await session.rollback()
|
||||
if inserted >= run_cap:
|
||||
capped = True
|
||||
break
|
||||
await asyncio.sleep(_REQ_SLEEP)
|
||||
if not next_cursor:
|
||||
break
|
||||
cursor = next_cursor
|
||||
# cap 절단 시 워터마크 미전진 — 미페치 works 가 다음 run 의 watermark 필터
|
||||
# (publication_date > watermark)에 영구 배제되는 silent loss 방지. 미전진하면
|
||||
# 다음 run 이 옛 watermark 부터 재페치하며 적재분 dedup-skip(cap 미소모) 후
|
||||
# 이어 적재 → 백로그 run 당 cap 소화 (R4). bulk 은 cap 무관.
|
||||
if newest and not capped:
|
||||
async with async_session() as session:
|
||||
src = await session.get(NewsSource, source_id)
|
||||
_set_watermark(src, wm_key, newest)
|
||||
await session.commit()
|
||||
except (httpx.HTTPError, FeedError, ValueError) as e:
|
||||
msg = f"[{label}] {e or repr(e)}"
|
||||
logger.error(f"[openalex] {msg}")
|
||||
failures.append(msg)
|
||||
|
||||
async with async_session() as session:
|
||||
health = await _get_or_create_health(session, source_id)
|
||||
if failures and inserted == 0:
|
||||
_record_failure(health, "; ".join(failures)[:500], now)
|
||||
else:
|
||||
_record_success(health, inserted, False, now)
|
||||
await session.commit()
|
||||
|
||||
deferred = "" if inserted < run_cap else f" (cap {run_cap} 도달 — 잔여 다음 run 이월)"
|
||||
logger.info(
|
||||
f"[openalex] {len(_seeds())}개 시드(ISSN+키워드) 스캔 {seen}건 → 신규 {inserted}건{deferred}"
|
||||
+ (f" / 실패 {len(failures)}건" if failures else "")
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description="OpenAlex 안전·공학 키워드 백본 수집기")
|
||||
parser.add_argument("--bulk", action="store_true", help="cap 해제 + 깊은 cursor 페이징 백필")
|
||||
parser.add_argument("--limit", type=int, default=0, help="신규 적재 상한(0=기본 cap)")
|
||||
args = parser.parse_args()
|
||||
asyncio.run(run(bulk=args.bulk, limit=args.limit))
|
||||
@@ -0,0 +1,102 @@
|
||||
"""paper DOI reconcile — B-3 PR4(레거시 arXiv) + PR5(구매 PDF) (plan safety-library-b3-1).
|
||||
|
||||
paper.doi/parent_doi 둘 다 없는 paper 행을 두 갈래로 정리:
|
||||
- 레거시 arXiv 초록(holder): arXiv id → arxiv_doi(10.48550/arxiv.{id}) 스탬프 → partial-unique
|
||||
인덱스 편입 → 재유입 차단('동일-DOI 재유입 차단만').
|
||||
- 구매 PDF(child, license.restricted=true — Papers_Purchased 드롭): 본문 DOI 파싱 → paper.parent_doi
|
||||
링크(서지 holder 와 DOI 공유로 연결). child 는 doi 미보유(인덱스 밖) → unique 무충돌.
|
||||
|
||||
- KEYLESS·결정적(OpenAlex 호출 0)·in-DB·enqueue 0(콘텐츠 무변경). dedup_reconcile(file_hash 캐시)와
|
||||
별 worker(적대리뷰 B·C major). 선재 DOI holder 존재 시 arXiv 행도 parent_doi 마킹(unique 위반 회피).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from core.database import async_session
|
||||
from core.utils import setup_logger
|
||||
from models.document import Document
|
||||
from services.papers.doi import (
|
||||
arxiv_doi,
|
||||
parse_arxiv_id,
|
||||
parse_doi_from_text,
|
||||
with_paper_doi,
|
||||
with_parent_doi,
|
||||
)
|
||||
from services.papers.holder import find_paper_holder
|
||||
|
||||
logger = setup_logger("paper_doi_reconcile")
|
||||
|
||||
_DOI_TEXT = Document.extract_meta[("paper", "doi")].astext
|
||||
_PARENT_DOI_TEXT = Document.extract_meta[("paper", "parent_doi")].astext
|
||||
|
||||
|
||||
def _is_restricted(meta: dict) -> bool:
|
||||
return (meta.get("license") or {}).get("restricted") in (True, "true")
|
||||
|
||||
|
||||
async def run(limit: int = 0) -> None:
|
||||
"""paper.doi/parent_doi 없는 paper 행 reconcile(멱등). limit=0 = 전건."""
|
||||
stamped = marked_dup = skipped_no_arxiv = 0
|
||||
linked_purchased = skipped_purchased_no_doi = 0
|
||||
async with async_session() as session:
|
||||
q = (
|
||||
select(Document)
|
||||
.where(
|
||||
Document.material_type == "paper",
|
||||
_DOI_TEXT.is_(None),
|
||||
_PARENT_DOI_TEXT.is_(None),
|
||||
)
|
||||
.order_by(Document.id)
|
||||
)
|
||||
if limit:
|
||||
q = q.limit(limit)
|
||||
rows = (await session.execute(q)).scalars().all()
|
||||
|
||||
for row in rows:
|
||||
meta = dict(row.extract_meta or {})
|
||||
paper = dict(meta.get("paper") or {})
|
||||
|
||||
# PR5: 구매 PDF(restricted) = child → 본문 DOI 파싱 → parent_doi 링크
|
||||
if _is_restricted(meta):
|
||||
doi = parse_doi_from_text(row.extracted_text)
|
||||
if not doi:
|
||||
skipped_purchased_no_doi += 1
|
||||
continue
|
||||
row.extract_meta = with_parent_doi(meta, doi)
|
||||
linked_purchased += 1
|
||||
continue
|
||||
|
||||
# PR4: 레거시 arXiv 초록(holder) = arXiv DataCite DOI 스탬프
|
||||
arxiv_id = paper.get("arxiv_id") or parse_arxiv_id(row.extracted_text)
|
||||
doi = arxiv_doi(arxiv_id)
|
||||
if not doi:
|
||||
skipped_no_arxiv += 1
|
||||
continue
|
||||
paper["arxiv_id"] = arxiv_id
|
||||
meta["paper"] = paper
|
||||
holder = await find_paper_holder(session, doi)
|
||||
if holder is not None and holder.id != row.id:
|
||||
row.extract_meta = with_parent_doi(meta, doi) # 선재 중복 → child 마킹
|
||||
marked_dup += 1
|
||||
else:
|
||||
row.extract_meta = with_paper_doi(meta, doi) # holder 스탬프, 인덱스 진입
|
||||
stamped += 1
|
||||
# 콘텐츠 무변경 → enqueue 없음(summarize/embed/chunk 0)
|
||||
await session.commit()
|
||||
|
||||
logger.info(
|
||||
f"[paper_doi_reconcile] {len(rows)}행 → arXiv 스탬프 {stamped} · 선재중복 {marked_dup} · "
|
||||
f"arXiv id 없음 skip {skipped_no_arxiv} / 구매PDF parent_doi 링크 {linked_purchased} · "
|
||||
f"구매PDF DOI 없음 skip {skipped_purchased_no_doi}"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description="paper DOI reconcile (arXiv 레거시 + 구매 PDF, keyless)")
|
||||
parser.add_argument("--limit", type=int, default=0, help="처리 상한(0=전건)")
|
||||
args = parser.parse_args()
|
||||
asyncio.run(run(limit=args.limit))
|
||||
@@ -0,0 +1,123 @@
|
||||
"""논문 arXiv 전문 승격 (in-place) — B-3 Phase-2 P2-PR1 (plan safety-library-b3-1).
|
||||
|
||||
arXiv 프리프린트 초록 행(file_format='article', signal-only)을 전문 PDF로 **in-place 승격**:
|
||||
PDF 다운로드 → file_format/file_type/file_path/md_status 갱신 → 'extract' enqueue → 기존 파이프라인
|
||||
(extract → classify[paper skip summarize] → embed/chunk/markdown)이 전문 검색 청크 + md_content(marker 표시)
|
||||
+ hier 절구조를 생성. 1-Document(2행 분리 회피, 기존 display 스택 재사용).
|
||||
|
||||
- arXiv = 공개 프리프린트(arxiv.org/pdf/{id}, friendly host) → 전문 검색/RAG 무난, restricted 불요.
|
||||
(유료 구매 논문은 Papers_Purchased 경로가 restricted=true 로 별개 처리.)
|
||||
- per-run cap (marker GPU ~10GB + embed 부하 보호, 4070 16GB 빡빡 → idle-unload·증분). keyless.
|
||||
- 요약 0 (classify paper-skip 가드). file_hash·extract_meta.paper 보존(수집기 dedup 무영향).
|
||||
- CLI 전용(Phase-2 deliberate 승격, GPU 부하 사용자 통제). 스케줄 잡 미등록.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import random
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import or_, select
|
||||
|
||||
from core.config import settings
|
||||
from core.crawl_politeness import CRAWL_UA
|
||||
from core.database import async_session
|
||||
from core.utils import setup_logger
|
||||
from models.document import Document
|
||||
from models.queue import enqueue_stage
|
||||
|
||||
logger = setup_logger("paper_fulltext_promote")
|
||||
|
||||
_ARXIV_PDF = "https://arxiv.org/pdf/{id}"
|
||||
_MAX_FILE_BYTES = 50 * 1024 * 1024
|
||||
_DOWNLOAD_DELAY = (2.0, 5.0)
|
||||
_RUN_CAP = 10 # 1회 승격 상한(marker/embed GPU 보호). bulk 시 해제.
|
||||
|
||||
_ARXIV_ID_EXPR = Document.extract_meta[("paper", "arxiv_id")].astext
|
||||
_OA_URL_EXPR = Document.extract_meta[("paper", "oa_url")].astext
|
||||
_OA_STATUS_EXPR = Document.extract_meta[("paper", "oa_status")].astext
|
||||
_REAL_OA = ("gold", "hybrid", "green", "diamond")
|
||||
|
||||
|
||||
async def _download(url: str, dest: Path) -> int:
|
||||
"""arXiv PDF 다운로드 — 크기 cap + PDF 헤더 검증 + 연속 간격(kosha 패턴)."""
|
||||
await asyncio.sleep(random.uniform(*_DOWNLOAD_DELAY))
|
||||
async with httpx.AsyncClient(timeout=60, follow_redirects=True) as client:
|
||||
resp = await client.get(url, headers={"User-Agent": CRAWL_UA})
|
||||
if resp.status_code != 200:
|
||||
raise RuntimeError(f"arXiv PDF {resp.status_code}: {url}")
|
||||
if len(resp.content) > _MAX_FILE_BYTES:
|
||||
raise RuntimeError(f"크기 초과 {len(resp.content)}b: {url}")
|
||||
if resp.content[:5] != b"%PDF-":
|
||||
raise RuntimeError(f"PDF 아님(헤더 {resp.content[:8]!r}): {url}")
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
dest.write_bytes(resp.content)
|
||||
return len(resp.content)
|
||||
|
||||
|
||||
async def run(bulk: bool = False, limit: int = 0) -> None:
|
||||
"""미승격 arXiv 논문(file_format='article')을 전문 PDF로 in-place 승격."""
|
||||
cap = (limit or 10**9) if bulk else (min(limit, _RUN_CAP) if limit else _RUN_CAP)
|
||||
async with async_session() as session:
|
||||
q = (
|
||||
select(Document.id)
|
||||
.where(
|
||||
Document.material_type == "paper",
|
||||
Document.file_format == "article",
|
||||
or_(
|
||||
_ARXIV_ID_EXPR.isnot(None),
|
||||
Document.extract_meta[("paper", "oa_url")].astext.isnot(None),
|
||||
),
|
||||
)
|
||||
.order_by(Document.id.desc())
|
||||
.limit(cap)
|
||||
)
|
||||
ids = [r[0] for r in (await session.execute(q)).all()]
|
||||
|
||||
promoted = failed = 0
|
||||
for doc_id in ids:
|
||||
async with async_session() as session:
|
||||
doc = await session.get(Document, doc_id)
|
||||
if doc is None or doc.file_format != "article":
|
||||
continue
|
||||
paper = (doc.extract_meta or {}).get("paper") or {}
|
||||
arxiv_id = paper.get("arxiv_id")
|
||||
oa_status = (paper.get("oa_status") or "").lower()
|
||||
if arxiv_id:
|
||||
url = _ARXIV_PDF.format(id=arxiv_id)
|
||||
key = arxiv_id.replace("/", "_")
|
||||
elif paper.get("oa_url") and oa_status in _REAL_OA:
|
||||
url = paper["oa_url"] # doi.org/KISTI/PMC (friendly OA). 비-OA·paywall 은 헤더검증서 skip
|
||||
key = (paper.get("openalex_id") or paper.get("doi") or "oa").replace("/", "_")
|
||||
else:
|
||||
continue
|
||||
rel_path = f"crawl_raw/papers/{key}.pdf"
|
||||
dest = Path(settings.nas_mount_path) / rel_path
|
||||
try:
|
||||
size = await _download(url, dest)
|
||||
except Exception as e: # noqa: BLE001 — 다운로드 실패 격리
|
||||
logger.error(f"[promote] {key} 다운로드 실패: {e}")
|
||||
failed += 1
|
||||
continue
|
||||
# in-place 승격: 초록 행 → 전문 PDF 행 (file_hash·extract_meta.paper 보존)
|
||||
doc.file_path = rel_path
|
||||
doc.file_format = "pdf"
|
||||
doc.file_type = "immutable"
|
||||
doc.file_size = size
|
||||
doc.md_status = "pending" # marker 재실행(기존 'skipped' 해제)
|
||||
doc.md_extraction_error = None
|
||||
await enqueue_stage(session, doc.id, "extract")
|
||||
await session.commit()
|
||||
promoted += 1
|
||||
logger.info(f"[promote] {key} → 전문 PDF in-place (doc {doc.id}, {size}b)")
|
||||
|
||||
logger.info(f"[paper_fulltext_promote] 승격 {promoted} · 실패 {failed} (cap {cap})")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="논문 arXiv 전문 승격 (in-place, keyless)")
|
||||
parser.add_argument("--bulk", action="store_true", help="cap 해제(전건 백필 — GPU 부하 주의)")
|
||||
parser.add_argument("--limit", type=int, default=0, help="승격 상한(0=기본 cap 10)")
|
||||
args = parser.parse_args()
|
||||
asyncio.run(run(bulk=args.bulk, limit=args.limit))
|
||||
@@ -1,142 +0,0 @@
|
||||
"""Phase 2A 후보 임베딩 백필 CLI (embedding-phase2a-1 E-1).
|
||||
|
||||
docker compose exec -T fastapi python -m workers.phase2a_cand_backfill \
|
||||
--target qwen06 --doc-id-max 41944 --chunk-id-max 104140 [--batch 32]
|
||||
|
||||
설계 원칙 (plan r3):
|
||||
- resumable/idempotent: 대상 = NOT EXISTS(후보 테이블) — 중단/재실행 시 이어서.
|
||||
배치 단위 커밋. C-1 백필 게이트 = "후보 카운트 == 동결셋 카운트".
|
||||
- 동결셋: id <= *_id_max AND 베이스라인 embedding IS NOT NULL (AND docs.deleted_at IS NULL).
|
||||
cand 테이블은 동결 범위로만 INSERT (retrieval cand path 가 snapshot filter 를 안 타는 전제).
|
||||
- 문서/청크 입력 = production 경로와 동일 구성(embed_worker._build_embed_input /
|
||||
chunk_worker 의 [제목][섹션][본문]) + plain (instruct prefix 는 쿼리 측 전용 — G-1 불변식).
|
||||
- 임베딩 = Ollama /api/embed 배치 호출 (G-1 fixture: 정규화 출력).
|
||||
- qwen4m 은 본 CLI 대상이 아님 — qwen4 적재 후 SQL 파생(subvector+l2_normalize), plan E-1.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import hashlib
|
||||
import time
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import text
|
||||
|
||||
from core.database import async_session
|
||||
from core.utils import setup_logger
|
||||
from models.document import Document
|
||||
from workers.embed_worker import _build_embed_input
|
||||
|
||||
logger = setup_logger("phase2a_cand_backfill")
|
||||
|
||||
OLLAMA_EMBED = "http://ollama:11434/api/embed"
|
||||
|
||||
TARGETS = {
|
||||
"qwen06": {
|
||||
"model": "qwen3-embedding:0.6b", "dim": 1024,
|
||||
"docs": "documents_cand_qwen06", "chunks": "document_chunks_cand_qwen06",
|
||||
},
|
||||
"qwen4": {
|
||||
"model": "qwen3-embedding:4b", "dim": 2560,
|
||||
"docs": "documents_cand_qwen4", "chunks": "document_chunks_cand_qwen4",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
async def _embed_batch(client: httpx.AsyncClient, model: str, texts: list[str]) -> list[list[float]]:
|
||||
r = await client.post(OLLAMA_EMBED, json={"model": model, "input": texts}, timeout=600)
|
||||
r.raise_for_status()
|
||||
embs = r.json()["embeddings"]
|
||||
if len(embs) != len(texts):
|
||||
raise RuntimeError(f"embed count mismatch: {len(embs)} != {len(texts)}")
|
||||
return embs
|
||||
|
||||
|
||||
async def backfill_docs(target: dict, doc_id_max: int, batch: int, http: httpx.AsyncClient) -> int:
|
||||
total = 0
|
||||
while True:
|
||||
async with async_session() as session:
|
||||
rows = (await session.execute(text(f"""
|
||||
SELECT d.id FROM documents d
|
||||
WHERE d.id <= :m AND d.embedding IS NOT NULL AND d.deleted_at IS NULL
|
||||
AND NOT EXISTS (SELECT 1 FROM {target['docs']} c WHERE c.doc_id = d.id)
|
||||
ORDER BY d.id LIMIT :b
|
||||
"""), {"m": doc_id_max, "b": batch})).scalars().all()
|
||||
if not rows:
|
||||
break
|
||||
docs = [(await session.get(Document, i)) for i in rows]
|
||||
inputs = [_build_embed_input(d) for d in docs]
|
||||
embs = await _embed_batch(http, target["model"], inputs)
|
||||
for d, inp, e in zip(docs, inputs, embs):
|
||||
await session.execute(text(f"""
|
||||
INSERT INTO {target['docs']} (doc_id, embed_input_hash, embedding)
|
||||
VALUES (:i, :h, cast(:e AS vector))
|
||||
ON CONFLICT (doc_id) DO NOTHING
|
||||
"""), {"i": d.id, "h": hashlib.sha256(inp.encode()).hexdigest()[:16], "e": str(e)})
|
||||
await session.commit()
|
||||
total += len(rows)
|
||||
if total % (batch * 10) < batch:
|
||||
logger.info(f"[{target['docs']}] +{total} (last id={rows[-1]})")
|
||||
return total
|
||||
|
||||
|
||||
async def backfill_chunks(target: dict, chunk_id_max: int, batch: int, http: httpx.AsyncClient) -> int:
|
||||
total = 0
|
||||
while True:
|
||||
async with async_session() as session:
|
||||
rows = (await session.execute(text(f"""
|
||||
SELECT c.id, c.doc_id, c.chunk_index, c.section_title, c.text, d.title
|
||||
FROM corpus_chunks c JOIN documents d ON d.id = c.doc_id
|
||||
WHERE c.id <= :m AND c.embedding IS NOT NULL AND d.deleted_at IS NULL
|
||||
AND NOT EXISTS (SELECT 1 FROM {target['chunks']} k WHERE k.id = c.id)
|
||||
ORDER BY c.id LIMIT :b
|
||||
"""), {"m": chunk_id_max, "b": batch})).all()
|
||||
if not rows:
|
||||
break
|
||||
inputs = [
|
||||
f"[제목] {r.title or ''}\n[섹션] {r.section_title or ''}\n[본문] {r.text}"
|
||||
for r in rows
|
||||
]
|
||||
embs = await _embed_batch(http, target["model"], inputs)
|
||||
for r, e in zip(rows, embs):
|
||||
await session.execute(text(f"""
|
||||
INSERT INTO {target['chunks']} (id, doc_id, chunk_index, section_title, text, embedding)
|
||||
VALUES (:i, :d, :x, :s, :t, cast(:e AS vector))
|
||||
ON CONFLICT (id) DO NOTHING
|
||||
"""), {"i": r.id, "d": r.doc_id, "x": r.chunk_index,
|
||||
"s": r.section_title, "t": r.text, "e": str(e)})
|
||||
await session.commit()
|
||||
total += len(rows)
|
||||
if total % (batch * 10) < batch:
|
||||
logger.info(f"[{target['chunks']}] +{total} (last id={rows[-1]})")
|
||||
return total
|
||||
|
||||
|
||||
async def run(target_key: str, doc_id_max: int, chunk_id_max: int, batch: int) -> None:
|
||||
target = TARGETS[target_key]
|
||||
start = time.monotonic()
|
||||
async with httpx.AsyncClient() as http:
|
||||
nd = await backfill_docs(target, doc_id_max, batch, http)
|
||||
nc = await backfill_chunks(target, chunk_id_max, batch, http)
|
||||
mins = (time.monotonic() - start) / 60
|
||||
async with async_session() as session:
|
||||
cd = (await session.execute(text(f"SELECT count(*) FROM {target['docs']}"))).scalar_one()
|
||||
cc = (await session.execute(text(f"SELECT count(*) FROM {target['chunks']}"))).scalar_one()
|
||||
logger.info(
|
||||
f"[{target_key}] 완료 — 이번 run docs +{nd} chunks +{nc} ({mins:.1f}분) · "
|
||||
f"누적 docs {cd} / chunks {cc} (동결 게이트 = 베이스라인 동결셋 카운트와 일치 확인)"
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
p = argparse.ArgumentParser(description="Phase 2A 후보 임베딩 백필 (resumable)")
|
||||
p.add_argument("--target", required=True, choices=sorted(TARGETS))
|
||||
p.add_argument("--doc-id-max", type=int, required=True)
|
||||
p.add_argument("--chunk-id-max", type=int, required=True)
|
||||
p.add_argument("--batch", type=int, default=32)
|
||||
a = p.parse_args()
|
||||
asyncio.run(run(a.target, a.doc_id_max, a.chunk_id_max, a.batch))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -47,10 +47,15 @@ MARKDOWN_STALE_THRESHOLD_MINUTES = int(os.getenv("MARKDOWN_STALE_MINUTES", "120"
|
||||
# STT 도 장기 작업 가능성이 있으나 본 PR 범위 밖 — main 에 유지(follow-up).
|
||||
MAIN_QUEUE_STAGES = [
|
||||
"extract", "classify", "summarize",
|
||||
"preview", "stt", "thumbnail", "deep_summary", "fulltext",
|
||||
"preview", "stt", "thumbnail", "fulltext",
|
||||
]
|
||||
MARKDOWN_QUEUE_STAGES = ["markdown"]
|
||||
|
||||
# 2026-06-15: deep_summary(26B, 콜당 70~300s)를 메인 루프에서 분리 (markdown/fast 선례).
|
||||
# 단일 deep 호출이 1분 틱을 초과해 메인 consume_queue 가 영구 coalesce 되고 extract/
|
||||
# classify 등 경량 stage 까지 굶던 문제 제거. 집합 disjoint(자기 집합만 stale reset).
|
||||
DEEP_QUEUE_STAGES = ["deep_summary"]
|
||||
|
||||
# 고속(비-LLM·경량 GPU) stage — LLM 사이클(분 단위)에서 분리해 1분 잡 전용 소비.
|
||||
# embed/chunk 는 건당 <1s 라 main 루프에 두면 classify(~190s×3) 뒤에서 굶는다
|
||||
# (2026-06-12 실측: 적체 3,570 · 4070 가동률 0%). markdown 분리(05-01)와 동일 패턴.
|
||||
@@ -270,7 +275,15 @@ async def _process_stage(stage, worker_fn):
|
||||
item.status = "completed"
|
||||
item.completed_at = datetime.now(timezone.utc)
|
||||
await skip_session.commit()
|
||||
await enqueue_next_stage(document_id, stage)
|
||||
# 완료 커밋 후 enqueue — 실패가 outer except 로 전파돼 completed 재오픈
|
||||
# 되지 않게 격리 (R3, 정상 완료 경로와 동일 처리).
|
||||
try:
|
||||
await enqueue_next_stage(document_id, stage)
|
||||
except Exception as enq_err:
|
||||
logger.error(
|
||||
f"[{stage}] document_id={document_id} skip(note) 완료됐으나 "
|
||||
f"다음 단계 enqueue 실패: {enq_err}"
|
||||
)
|
||||
logger.info(f"[{stage}] document_id={document_id} skip (note)")
|
||||
continue
|
||||
|
||||
@@ -288,7 +301,15 @@ async def _process_stage(stage, worker_fn):
|
||||
item.completed_at = datetime.now(timezone.utc)
|
||||
await session.commit()
|
||||
|
||||
await enqueue_next_stage(document_id, stage)
|
||||
# 완료는 이미 커밋됨. enqueue_next_stage 실패가 outer except 로 전파되면
|
||||
# completed 항목을 재오픈(pending/failed)해 같은 단계를 재실행 = 비싼 작업 중복
|
||||
# + 부분 재쓰기. 자체 try 로 격리하고 ERROR 로 가시화한다 (R3).
|
||||
try:
|
||||
await enqueue_next_stage(document_id, stage)
|
||||
except Exception as enq_err:
|
||||
logger.error(
|
||||
f"[{stage}] document_id={document_id} 완료됐으나 다음 단계 enqueue 실패: {enq_err}"
|
||||
)
|
||||
logger.info(f"[{stage}] document_id={document_id} 완료")
|
||||
|
||||
except StageDeferred as defer:
|
||||
@@ -405,3 +426,24 @@ async def consume_markdown_queue():
|
||||
|
||||
for stage in MARKDOWN_QUEUE_STAGES:
|
||||
await _process_stage(stage, workers[stage])
|
||||
|
||||
|
||||
async def consume_deep_queue():
|
||||
"""deep_summary 전용 큐 소비자 (2026-06-15) — 26B 심층요약을 메인 파이프라인과 분리.
|
||||
|
||||
deep_summary 1콜이 70~300s(맥미니 Qwen 27B 폴백)라 메인 consume_queue(1분 틱) 안에
|
||||
있으면 매 틱이 interval 을 초과해 영구 "maximum running instances" coalesce 되고
|
||||
extract/classify 등 경량 stage 까지 함께 굶었다. 분리 후 = deep 만 자기 1분 잡에서
|
||||
coalesce, 나머지 메인 루프는 틱 내 완료. max_instances=1 로 동시 deep 2건은 방지.
|
||||
"""
|
||||
workers = _load_workers()
|
||||
|
||||
try:
|
||||
await reset_stale_items(DEEP_QUEUE_STAGES, STALE_THRESHOLD_MINUTES)
|
||||
except Exception:
|
||||
logger.exception("deep stale reset failed, but continuing queue consumption")
|
||||
|
||||
for stage in DEEP_QUEUE_STAGES:
|
||||
if stage in settings.pipeline_held_stages:
|
||||
continue
|
||||
await _process_stage(stage, workers[stage])
|
||||
|
||||
@@ -102,7 +102,9 @@ async def _process_one(session: AsyncSession, qid: int, client: AIClient) -> boo
|
||||
try:
|
||||
async with asyncio.timeout(EMBED_TIMEOUT_S):
|
||||
vec = await client.embed(text)
|
||||
except (asyncio.TimeoutError, Exception) as e:
|
||||
except asyncio.CancelledError:
|
||||
raise # 취소는 전파 — broad except 가 삼키지 않게 명시 (R3)
|
||||
except Exception as e:
|
||||
logger.warning("study_q_embed_failed qid=%s err=%s: %s", qid, type(e).__name__, e)
|
||||
# 실패 — status='failed'. 직전 embedding 보존.
|
||||
q.embedding_status = "failed"
|
||||
|
||||
@@ -121,7 +121,12 @@ async def process(document_id: int, session: AsyncSession) -> None:
|
||||
|
||||
ok = _extract_thumbnail(source, output, seek)
|
||||
if not ok:
|
||||
return
|
||||
# 썸네일 추출 실패(ffmpeg)는 삼키지 않고 raise (R3) — queue_consumer 가 attempts
|
||||
# 소진까지 재시도 후 status=failed 로 가시화. silent return 이면 큐가 completed 로
|
||||
# 확정 + 썸네일 영구 누락 + 재시도/추적 0 (silent skip). 손상 영상이면 failed 로 안착.
|
||||
raise RuntimeError(
|
||||
f"thumbnail 추출 실패: document_id={document_id} source={source}"
|
||||
)
|
||||
|
||||
doc.thumbnail_path = str(output)
|
||||
doc.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
@@ -52,6 +52,11 @@ DOMAIN_PRIORITY: list[tuple[str, str]] = [
|
||||
("manual", "source_channel = 'manual'"),
|
||||
]
|
||||
|
||||
# R12: filter_clause 는 SQL 에 직접 보간되므로 이 allowlist(DOMAIN_PRIORITY 출처) 통과분만
|
||||
# 허용 — 현재 모듈 상수라 injection 경로 0 이나, 외부 입력화 시 즉시 차단하는 final gate
|
||||
# (retrieval_service 의 _VALID_DOCS_TABLE allowlist 정본 대비 비대칭 해소).
|
||||
_ALLOWED_FILTER_CLAUSES: frozenset[str] = frozenset(c for _, c in DOMAIN_PRIORITY)
|
||||
|
||||
|
||||
async def _classify_pending(session: AsyncSession) -> int:
|
||||
return int(await session.scalar(text("""
|
||||
@@ -66,6 +71,9 @@ async def _enqueue_domain(session: AsyncSession, filter_clause: str, limit: int)
|
||||
extracted_text 빈 문자열 (LENGTH=0) 도 제외 — classify_worker 는 not doc.extracted_text
|
||||
truthy 체크라 빈 문자열에서 ValueError raise. 무한 retry 루프 방지.
|
||||
"""
|
||||
# R12: SQL 직접 보간 전 allowlist final gate.
|
||||
if filter_clause not in _ALLOWED_FILTER_CLAUSES:
|
||||
raise ValueError(f"비허용 filter_clause (allowlist 외): {filter_clause!r}")
|
||||
sql = text(f"""
|
||||
INSERT INTO processing_queue (document_id, stage, status, attempts, max_attempts)
|
||||
SELECT id, 'classify', 'pending', 0, 3
|
||||
|
||||
+13
-9
@@ -1,8 +1,6 @@
|
||||
# hyungi_Document_Server 설정
|
||||
|
||||
ai:
|
||||
gateway:
|
||||
endpoint: "http://ai-gateway:8080"
|
||||
|
||||
models:
|
||||
# ─── 단일 generation 호스트 routing (2026-05-14 GPU LLM 제거) ───
|
||||
@@ -13,7 +11,7 @@ ai:
|
||||
|
||||
# triage: 상시 분류·요약·근거 선별. Mac mini Qwen 27B (primary 와 동일 endpoint, 짧은 max_tokens).
|
||||
triage:
|
||||
endpoint: "http://100.76.254.116:8801/v1/chat/completions"
|
||||
endpoint: "http://100.76.254.116:8890/v1/chat/completions"
|
||||
model: "mlx-community/Qwen3.6-27B-6bit"
|
||||
max_tokens: 4096
|
||||
timeout: 480 # 프리필 실측 ~112 tok/s — 120K자 장문 커버 (2026-06-11)
|
||||
@@ -22,7 +20,7 @@ ai:
|
||||
|
||||
# primary: 에스컬레이션 전용. Qwen 27B MLX (맥미니 Semaphore(1) 보호 대상).
|
||||
primary:
|
||||
endpoint: "http://100.76.254.116:8801/v1/chat/completions"
|
||||
endpoint: "http://100.76.254.116:8890/v1/chat/completions"
|
||||
model: "mlx-community/Qwen3.6-27B-6bit"
|
||||
max_tokens: 8192
|
||||
timeout: 900 # 프리필 실측 ~112 tok/s — 260K자 상한 장문 커버 (2026-06-11)
|
||||
@@ -72,7 +70,7 @@ ai:
|
||||
# Phase 3.5a answerability classifier. 2026-05-14 GPU LLM 제거 후 Mac mini 26B 로 swap.
|
||||
# classifier_service 가 hasattr 체크로 optional 이므로 이 섹션 제거 시 classifier gate 는 자동 skip (score-only).
|
||||
classifier:
|
||||
endpoint: "http://100.76.254.116:8801/v1/chat/completions"
|
||||
endpoint: "http://100.76.254.116:8890/v1/chat/completions"
|
||||
model: "mlx-community/Qwen3.6-27B-6bit" # 2026-06-11 B안 동승 — gemma id 잔존 시 mlx 서버가 Gemma 를 재로드(이중 적재) 위험
|
||||
max_tokens: 512
|
||||
timeout: 30 # 2026-05-17: 15s 도 동시 부하 시 elapsed 14.4s 직전이라 tight — 30s 로 2x 마진. classifier_service.LLM_TIMEOUT_MS=30000 와 align (초과 = score-only skip, graceful)
|
||||
@@ -199,8 +197,14 @@ schedule:
|
||||
# 이력: 2026-06-11 맥미니 모델 확정까지 8키 홀드 → 同日 Qwen3.6-27B-6bit 전환과 함께 해제([]).
|
||||
pipeline:
|
||||
held_stages: []
|
||||
# mlx gate 동시 실행 상한 (2026-06-12 fair-share): 구 "1 고정" 룰의 전제(single-inference
|
||||
# 서버)가 소멸 — 현 mlx_vlm 은 continuous batching (2026-06-11 밤 6~8 concurrent 실측 정상).
|
||||
# 2 = 워커 LLM 호출과 인터랙티브(ask/eid)가 서로 안 막힘 + 집계 throughput ~1.8배.
|
||||
# 게이트(상한+우선순위)는 유지 — thundering herd 방지. 1 로 되돌리면 구 동작.
|
||||
# mlx gate 동시 실행 상한 (config.mlx_gate_concurrency). 현 mlx_vlm = continuous batching
|
||||
# (2026-06-11 밤 6~8 concurrent 실측 정상). 2026-06-15: 2→4 — digest/briefing 합성을
|
||||
# 이 단일 게이트(BACKGROUND 우선순위)로 라우팅하며 digest(클러스터 44~68)가 하드캡 내
|
||||
# 완료되도록 동시성 확보. ask/eid(FOREGROUND)는 큐 점프라 영향 최소. 되돌리면 구 동작.
|
||||
mlx_gate_concurrency: 2
|
||||
# 2026-06-15: digest/briefing 생성 LLM 파라미터 (모델 교체 후 단일소스, 상세 = config.py).
|
||||
# 구 하드코딩 25s(빠른 Gemma)가 Qwen 27B(콜당 ~90~300s) 교체 sweep 누락 → digest 600s
|
||||
# 초과·briefing 4/4 폴백. 동시성은 위 mlx_gate_concurrency 가 담당(별 키 없음).
|
||||
digest_llm_timeout_s: 300
|
||||
digest_llm_attempts: 2
|
||||
digest_pipeline_hard_cap_s: 5400
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
# Phase 2A — Embedding candidate compose override (Diagnose only)
|
||||
#
|
||||
# Profile-isolated: `--profile embed-cand` 명시 opt-in. default up 시 미기동.
|
||||
# production fastapi/postgres/reranker 에 영향 0.
|
||||
# 본 PR 종료 시 별 chore (PR-2A-Chunks-Cand-Cleanup-1) 에서 제거.
|
||||
#
|
||||
# 후보 상태 (2026-05-23):
|
||||
# - me5_large_inst : ✅ smoke PASS (dim 1024)
|
||||
# - bge_mgemma2 : ❌ Phase 2A-Extended 별 PR 이관 (9B FP16 → VRAM OOM risk + 다운로드 cost)
|
||||
# - me5_ko : ❌ 폐기 (401 Unauthorized, gated/모델명 부정확)
|
||||
# - snowflake_l_v2 : 신규 추가 (Snowflake/snowflake-arctic-embed-l-v2.0, 2024-12, multilingual 강화)
|
||||
#
|
||||
# 사용:
|
||||
# docker compose -f docker-compose.yml -f docker-compose.override.cand.yml \
|
||||
# --profile embed-cand up -d embedding-cand-me5-inst
|
||||
#
|
||||
# 호출 (DS network 내부):
|
||||
# http://embedding-cand-me5-inst:80/embed
|
||||
# http://embedding-cand-snowflake-l-v2:80/embed
|
||||
|
||||
services:
|
||||
embedding-cand-me5-inst:
|
||||
image: ghcr.io/huggingface/text-embeddings-inference:1.7
|
||||
restart: unless-stopped
|
||||
container_name: hyungi_document_server-embedding-cand-me5-inst-1
|
||||
expose:
|
||||
- "80"
|
||||
environment:
|
||||
- MODEL_ID=intfloat/multilingual-e5-large-instruct
|
||||
- MAX_BATCH_TOKENS=8192
|
||||
- MAX_CONCURRENT_REQUESTS=4
|
||||
volumes:
|
||||
- embedding_cand_me5_inst_cache:/data
|
||||
deploy:
|
||||
resources:
|
||||
reservations:
|
||||
devices:
|
||||
- driver: nvidia
|
||||
count: 1
|
||||
capabilities: [gpu]
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-fsS", "http://localhost/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
start_period: 60s
|
||||
profiles: ["embed-cand"]
|
||||
|
||||
embedding-cand-snowflake-l-v2:
|
||||
image: ghcr.io/huggingface/text-embeddings-inference:1.7
|
||||
restart: unless-stopped
|
||||
container_name: hyungi_document_server-embedding-cand-snowflake-l-v2-1
|
||||
expose:
|
||||
- "80"
|
||||
environment:
|
||||
- MODEL_ID=Snowflake/snowflake-arctic-embed-l-v2.0
|
||||
- MAX_BATCH_TOKENS=8192
|
||||
- MAX_CONCURRENT_REQUESTS=4
|
||||
volumes:
|
||||
- embedding_cand_snowflake_l_v2_cache:/data
|
||||
deploy:
|
||||
resources:
|
||||
reservations:
|
||||
devices:
|
||||
- driver: nvidia
|
||||
count: 1
|
||||
capabilities: [gpu]
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-fsS", "http://localhost/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
start_period: 60s
|
||||
profiles: ["embed-cand"]
|
||||
|
||||
# ===== 비활성 후보 (Phase 2A-Extended 별 PR 이관 또는 폐기) =====
|
||||
# 진단 박제만 보존. 본 PR scope 외.
|
||||
|
||||
embedding-cand-bge-mgemma2:
|
||||
image: ghcr.io/huggingface/text-embeddings-inference:1.7
|
||||
container_name: hyungi_document_server-embedding-cand-bge-mgemma2-1
|
||||
expose:
|
||||
- "80"
|
||||
environment:
|
||||
- MODEL_ID=BAAI/bge-multilingual-gemma2
|
||||
- MAX_BATCH_TOKENS=8192
|
||||
- MAX_CONCURRENT_REQUESTS=4
|
||||
volumes:
|
||||
- embedding_cand_bge_mgemma2_cache:/data
|
||||
deploy:
|
||||
resources:
|
||||
reservations:
|
||||
devices:
|
||||
- driver: nvidia
|
||||
count: 1
|
||||
capabilities: [gpu]
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-fsS", "http://localhost/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
start_period: 300s
|
||||
profiles: ["embed-cand-extended"] # 본 PR 미사용. extended 별 profile.
|
||||
|
||||
embedding-cand-me5-ko:
|
||||
image: ghcr.io/huggingface/text-embeddings-inference:1.7
|
||||
container_name: hyungi_document_server-embedding-cand-me5-ko-1
|
||||
expose:
|
||||
- "80"
|
||||
environment:
|
||||
- MODEL_ID=dragonkue/multilingual-e5-large-ko
|
||||
- MAX_BATCH_TOKENS=8192
|
||||
- MAX_CONCURRENT_REQUESTS=4
|
||||
volumes:
|
||||
- embedding_cand_me5_ko_cache:/data
|
||||
deploy:
|
||||
resources:
|
||||
reservations:
|
||||
devices:
|
||||
- driver: nvidia
|
||||
count: 1
|
||||
capabilities: [gpu]
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-fsS", "http://localhost/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
start_period: 60s
|
||||
profiles: ["embed-cand-disabled"] # 401 fail. 사용 X.
|
||||
|
||||
volumes:
|
||||
embedding_cand_me5_inst_cache:
|
||||
embedding_cand_snowflake_l_v2_cache:
|
||||
embedding_cand_bge_mgemma2_cache:
|
||||
embedding_cand_me5_ko_cache:
|
||||
@@ -1,101 +0,0 @@
|
||||
# Phase 2B — Reranker candidate compose override (Diagnose only)
|
||||
#
|
||||
# Profile-isolated: `--profile rerank-cand` 명시 opt-in. default up 시 미기동.
|
||||
# production fastapi/postgres/reranker(bge-reranker-v2-m3) 에 영향 0.
|
||||
# 본 PR 종료 후 별 chore (PR-2B-Rerank-Cand-Cleanup-1) 에서 제거.
|
||||
#
|
||||
# 후보 상태 (2026-05-23):
|
||||
# - gte_ml_base : Apache 2.0, 305M, smoke 대기
|
||||
# - mxbai_large : Apache 2.0, ~435M, safetensors 부재 — TEI smoke risk
|
||||
# - bge_v2_gemma_2b : Gemma 라이센스, 2.5B FP16 ~5GB, smoke 대기
|
||||
#
|
||||
# 사용:
|
||||
# docker compose -f docker-compose.yml -f docker-compose.override.rerank-cand.yml \
|
||||
# --profile rerank-cand up -d rerank-cand-gte-ml-base
|
||||
|
||||
services:
|
||||
rerank-cand-gte-ml-base:
|
||||
image: ghcr.io/huggingface/text-embeddings-inference:1.7
|
||||
restart: unless-stopped
|
||||
container_name: hyungi_document_server-rerank-cand-gte-ml-base-1
|
||||
expose:
|
||||
- "80"
|
||||
environment:
|
||||
- MODEL_ID=Alibaba-NLP/gte-multilingual-reranker-base
|
||||
- MAX_BATCH_TOKENS=8192
|
||||
- MAX_CONCURRENT_REQUESTS=4
|
||||
volumes:
|
||||
- rerank_cand_gte_ml_base_cache:/data
|
||||
deploy:
|
||||
resources:
|
||||
reservations:
|
||||
devices:
|
||||
- driver: nvidia
|
||||
count: 1
|
||||
capabilities: [gpu]
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-fsS", "http://localhost/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
start_period: 60s
|
||||
profiles: ["rerank-cand"]
|
||||
|
||||
rerank-cand-mxbai-large:
|
||||
image: ghcr.io/huggingface/text-embeddings-inference:1.7
|
||||
restart: unless-stopped
|
||||
container_name: hyungi_document_server-rerank-cand-mxbai-large-1
|
||||
expose:
|
||||
- "80"
|
||||
environment:
|
||||
- MODEL_ID=mixedbread-ai/mxbai-rerank-large-v1
|
||||
- MAX_BATCH_TOKENS=8192
|
||||
- MAX_CONCURRENT_REQUESTS=4
|
||||
volumes:
|
||||
- rerank_cand_mxbai_large_cache:/data
|
||||
deploy:
|
||||
resources:
|
||||
reservations:
|
||||
devices:
|
||||
- driver: nvidia
|
||||
count: 1
|
||||
capabilities: [gpu]
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-fsS", "http://localhost/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
start_period: 60s
|
||||
profiles: ["rerank-cand"]
|
||||
|
||||
rerank-cand-bge-v2-gemma-2b:
|
||||
image: ghcr.io/huggingface/text-embeddings-inference:1.7
|
||||
restart: unless-stopped
|
||||
container_name: hyungi_document_server-rerank-cand-bge-v2-gemma-2b-1
|
||||
expose:
|
||||
- "80"
|
||||
environment:
|
||||
- MODEL_ID=BAAI/bge-reranker-v2-gemma
|
||||
- MAX_BATCH_TOKENS=8192
|
||||
- MAX_CONCURRENT_REQUESTS=2
|
||||
volumes:
|
||||
- rerank_cand_bge_v2_gemma_2b_cache:/data
|
||||
deploy:
|
||||
resources:
|
||||
reservations:
|
||||
devices:
|
||||
- driver: nvidia
|
||||
count: 1
|
||||
capabilities: [gpu]
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-fsS", "http://localhost/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
start_period: 120s
|
||||
profiles: ["rerank-cand"]
|
||||
|
||||
volumes:
|
||||
rerank_cand_gte_ml_base_cache:
|
||||
rerank_cand_mxbai_large_cache:
|
||||
rerank_cand_bge_v2_gemma_2b_cache:
|
||||
+1
-14
@@ -149,7 +149,7 @@ services:
|
||||
# → 32 한도 초과 → 413. 64 로 늘림.
|
||||
# GPU VRAM free 6199MiB 충분. baseline path (MAX_RERANK_INPUT=200) 영향 0.
|
||||
- MAX_BATCH_TOKENS=16384
|
||||
- MAX_CLIENT_BATCH_SIZE=64
|
||||
- MAX_CLIENT_BATCH_SIZE=256 # 2026-06-18 fix: 64→256, MAX_RERANK_INPUT=200 커버 (batch>64 ERROR=RRF silent fallback 해소; MAX_BATCH_TOKENS가 VRAM 상한이라 entries 증가는 VRAM 무관)
|
||||
- MAX_CONCURRENT_REQUESTS=4
|
||||
volumes:
|
||||
- reranker_cache:/data
|
||||
@@ -168,19 +168,6 @@ services:
|
||||
start_period: 120s
|
||||
restart: unless-stopped
|
||||
|
||||
ai-gateway:
|
||||
build: ./gpu-server/services/ai-gateway
|
||||
ports:
|
||||
- "127.0.0.1:8081:8080"
|
||||
environment:
|
||||
- PRIMARY_ENDPOINT=http://100.76.254.116:8801/v1/chat/completions
|
||||
- FALLBACK_ENDPOINT=http://ollama:11434/v1/chat/completions
|
||||
- CLAUDE_API_KEY=${CLAUDE_API_KEY:-}
|
||||
- DAILY_BUDGET_USD=${DAILY_BUDGET_USD:-5.00}
|
||||
# depends_on: ollama 제거 (2026-06-08) — ollama 서비스가 standalone 으로 이관됨.
|
||||
# FALLBACK_ENDPOINT 의 ollama:11434 는 standalone(동일 hostname, DS 망 부착)으로 해소.
|
||||
restart: unless-stopped
|
||||
|
||||
fastapi:
|
||||
build: ./app
|
||||
ports:
|
||||
|
||||
@@ -213,3 +213,14 @@ body {
|
||||
|
||||
/* Phase 1C: frontmatter 박스 — 본문 위 메타 표시 */
|
||||
.md-frontmatter dt { font-weight: 500; }
|
||||
|
||||
/* AI 요약(TL;DR 등) 마크다운 렌더 — 좁은 카드에 맞게 문단/리스트 마진 압축 */
|
||||
.summary-md > :first-child { margin-top: 0; }
|
||||
.summary-md > :last-child { margin-bottom: 0; }
|
||||
.summary-md p { margin: 0 0 0.45em; }
|
||||
.summary-md ul, .summary-md ol { margin: 0.25em 0; padding-left: 1.2em; }
|
||||
.summary-md ul { list-style: disc; }
|
||||
.summary-md ol { list-style: decimal; }
|
||||
.summary-md li { margin: 0.1em 0; }
|
||||
.summary-md strong { font-weight: 700; }
|
||||
.summary-md code { background: rgba(0, 0, 0, 0.05); padding: 0 0.3em; border-radius: 3px; }
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { api } from '$lib/api';
|
||||
import { renderDocMarkdown } from '$lib/utils/docMarkdown';
|
||||
import Badge from '$lib/components/ui/Badge.svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import Skeleton from '$lib/components/ui/Skeleton.svelte';
|
||||
@@ -104,9 +105,7 @@
|
||||
</div>
|
||||
|
||||
{#if tldr}
|
||||
<p class="text-xs font-medium text-text leading-relaxed mb-2">
|
||||
{tldr}
|
||||
</p>
|
||||
<div class="summary-md text-xs font-medium text-text leading-relaxed mb-2">{@html renderDocMarkdown(tldr)}</div>
|
||||
{/if}
|
||||
|
||||
{#if bullets && bullets.length > 0}
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
<script lang="ts">
|
||||
// 처리 머신 보드 v2 — 파이프라인 흐름 뷰 (plan ds-board-engines-1, R2 통합안).
|
||||
// 메인 = 좌→우 흐름 노드(병목 amber·실패 뱃지), 노드 클릭 = 상세 패널(안1 변형),
|
||||
// 실패 뱃지 클릭 = 실패 처리 드로어 (재시도/건너뛰기 — 영구 실패의 유일한 조치 경로).
|
||||
// 데이터 = GET /api/queue/overview (60s 폴링 store) + GET /api/queue/failed (드로어 열 때).
|
||||
// 처리 머신 보드 v3 — 통합안 (plan ds-board-merged: C2 머신레인 + C3 번다운/정직ETA).
|
||||
// · 머신 3레인(GPU/맥미니/맥북) = "누가 일하나" + 요약 오프로드(맥북 합류) 가시화
|
||||
// · 지배 백로그 번다운 패널 = "언제 끝나나" + 유입 차감한 정직 ETA(summarize_eta)
|
||||
// · 신선도 '갱신 N초 전' + stale 경고 / 실패 드로어·상세 패널은 v2 자산 재사용.
|
||||
// 데이터 = GET /api/queue/overview (60s 폴링 store) + GET /api/queue/failed (드로어).
|
||||
import { api } from '$lib/api';
|
||||
import { refreshQueueOverview } from '$lib/stores/queueOverview';
|
||||
import { refreshQueueOverview, queueUpdatedAt } from '$lib/stores/queueOverview';
|
||||
import { addToast } from '$lib/stores/toast';
|
||||
import {
|
||||
AUX_NODES,
|
||||
FLOW_NODES,
|
||||
MACHINE_META,
|
||||
type FlowNodeDef,
|
||||
type FlowMachine,
|
||||
etaShort,
|
||||
flowStageLabel,
|
||||
formatAgeSec,
|
||||
@@ -20,6 +22,7 @@
|
||||
FailedItem,
|
||||
FailedListResponse,
|
||||
MachineCurrentItem,
|
||||
MachineOverview,
|
||||
QueueOverview,
|
||||
QueueStageRow,
|
||||
RetryResponse,
|
||||
@@ -82,14 +85,6 @@
|
||||
);
|
||||
const totalFailed = $derived(overview.totals.failed);
|
||||
|
||||
// 머신 스트립 — overview.machines 의 state/처리율 + 정적 모델 메타
|
||||
const machineStrip = $derived(
|
||||
overview.machines.map((m) => ({
|
||||
...m,
|
||||
meta: MACHINE_META[m.key],
|
||||
})),
|
||||
);
|
||||
|
||||
// ─── 선택 상태 (노드 상세 / 실패 드로어 — 동시에 하나만) ───
|
||||
let selected = $state<string | null>(null);
|
||||
let failOpen = $state(false);
|
||||
@@ -194,22 +189,122 @@
|
||||
await Promise.all([loadFailures(), refreshQueueOverview()]);
|
||||
}
|
||||
|
||||
// ─── trend_24h 스파크라인 (summarize 유입 vs 소화 — API 가 주는데 미렌더이던 슬롯) ───
|
||||
const spark = $derived.by(() => {
|
||||
// ─── 머신 레인 (C2) — mainNodes 를 머신별로 그룹 + 머신 카드(state/처리율) 결합 ───
|
||||
const machineByKey = $derived(
|
||||
new Map<FlowMachine, MachineOverview>(overview.machines.map((m) => [m.key as FlowMachine, m])),
|
||||
);
|
||||
const LANE_ORDER: FlowMachine[] = ['gpu', 'macmini', 'macbook'];
|
||||
const lanes = $derived(
|
||||
LANE_ORDER.map((key) => ({
|
||||
key,
|
||||
meta: MACHINE_META[key],
|
||||
card: machineByKey.get(key) ?? null,
|
||||
nodes: mainNodes.filter((n) => n.def.machine === key),
|
||||
})),
|
||||
);
|
||||
|
||||
// 요약 오프로드 분담 — 맥미니 vs 맥북 (A-1 summarize_by_machine)
|
||||
const split = $derived(overview.summarize_by_machine);
|
||||
const splitTotal1h = $derived(Math.max(1, split.macmini.done_1h + split.macbook.done_1h));
|
||||
const macbookSharePct = $derived(Math.round((split.macbook.done_1h / splitTotal1h) * 100));
|
||||
// 맥북이 요약을 실제로 가져가는 중인가 (합류 표식 게이트)
|
||||
const offloadActive = $derived(split.macbook.done_1h > 0);
|
||||
|
||||
// ─── 백그라운드 작업 (큐 밖 스크립트 backfill) — processing_queue 사각지대 노출 ───
|
||||
const bgJobs = $derived(overview.background_jobs ?? []);
|
||||
const runningBg = $derived(bgJobs.filter((j) => j.state === 'running'));
|
||||
function bgForMachine(key: string) {
|
||||
return runningBg.filter((j) => j.machine === key);
|
||||
}
|
||||
function fmtElapsed(s: number): string {
|
||||
if (s < 60) return `${s}s`;
|
||||
if (s < 3600) return `${Math.floor(s / 60)}m`;
|
||||
return `${Math.floor(s / 3600)}h${Math.floor((s % 3600) / 60)}m`;
|
||||
}
|
||||
function bgDot(j: { state: string; stale: boolean }): string {
|
||||
if (j.state === 'running') return j.stale ? 'bg-warning' : 'bg-success';
|
||||
if (j.state === 'failed') return 'bg-error';
|
||||
return 'bg-faint';
|
||||
}
|
||||
|
||||
// ─── 지배 백로그 = 요약. 정직 ETA(유입 차감) — summarize_eta ───
|
||||
const eta = $derived(overview.summarize_eta);
|
||||
// 정직 ETA 라벨: eta_minutes null = 유입이 소화를 앞섬(소진 불가)
|
||||
const honestEtaLabel = $derived(
|
||||
eta.pending === 0
|
||||
? '비어 있음'
|
||||
: eta.eta_minutes != null
|
||||
? etaShort(eta.eta_minutes)
|
||||
: '소진 불가',
|
||||
);
|
||||
const honestEtaWarn = $derived(eta.pending > 0 && eta.eta_minutes == null);
|
||||
|
||||
/** 단계별 정직 ETA(순소화율) — 노드용. 유입>소화면 null(소진 불가) */
|
||||
function netEtaLabel(n: NodeStats): string | null {
|
||||
if (n.pending === 0) return '한가';
|
||||
const net = n.done1h - n.created1h;
|
||||
if (net > 0) return etaShort(Math.round((n.pending / net) * 60));
|
||||
if (n.created1h > n.done1h) return '유입 우세';
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── 신선도 (B-4) — '갱신 N초 전' + stale 경고 (폴링 60s) ───
|
||||
let now = $state(Date.now());
|
||||
$effect(() => {
|
||||
const id = setInterval(() => (now = Date.now()), 1000);
|
||||
return () => clearInterval(id);
|
||||
});
|
||||
const ageSec = $derived(
|
||||
$queueUpdatedAt != null ? Math.max(0, Math.round((now - $queueUpdatedAt) / 1000)) : null,
|
||||
);
|
||||
const stale = $derived(ageSec != null && ageSec > 90);
|
||||
const freshLabel = $derived(
|
||||
ageSec == null
|
||||
? '갱신 대기'
|
||||
: ageSec < 60
|
||||
? `갱신 ${ageSec}초 전`
|
||||
: `갱신 ${Math.round(ageSec / 60)}분 전`,
|
||||
);
|
||||
|
||||
// ─── 24h 번다운 (C3) — 요약 유입 vs 소화 + 맥북 합류 변곡점 마커 ───
|
||||
const burn = $derived.by(() => {
|
||||
const t = overview.trend_24h;
|
||||
if (!t || t.length === 0) return null;
|
||||
const max = Math.max(1, ...t.map((b) => Math.max(b.inflow, b.done)));
|
||||
const w = 120;
|
||||
const h = 24;
|
||||
const w = 300;
|
||||
const h = 64;
|
||||
const step = w / Math.max(1, t.length - 1);
|
||||
const pts = (sel: (b: (typeof t)[number]) => number) =>
|
||||
t.map((b, i) => `${(i * step).toFixed(1)},${(h - (sel(b) / max) * (h - 3) + 1).toFixed(1)}`).join(' ');
|
||||
return { inflow: pts((b) => b.inflow), done: pts((b) => b.done) };
|
||||
const y = (v: number) => (h - (v / max) * (h - 8) + 4).toFixed(1);
|
||||
const line = (sel: (b: (typeof t)[number]) => number) =>
|
||||
t.map((b, i) => `${(i * step).toFixed(1)},${y(sel(b))}`).join(' ');
|
||||
const doneLine = line((b) => b.done);
|
||||
const area = `0,${h} ${doneLine} ${w.toFixed(1)},${h}`;
|
||||
// 합류 변곡점 = done 최대 버킷 (맥북 야간 drain 합류 추정)
|
||||
let mi = 0;
|
||||
t.forEach((b, i) => {
|
||||
if (b.done > t[mi].done) mi = i;
|
||||
});
|
||||
return {
|
||||
w,
|
||||
h,
|
||||
area,
|
||||
doneLine,
|
||||
inflowLine: line((b) => b.inflow),
|
||||
markX: (mi * step).toFixed(1),
|
||||
markHour: t[mi].hour,
|
||||
markDone: t[mi].done,
|
||||
peak: max,
|
||||
};
|
||||
});
|
||||
|
||||
// 머신 상태 dot 색 클래스
|
||||
function dotClass(state: string): string {
|
||||
return state === 'active' ? 'bg-success' : state === 'deferred' ? 'bg-warning' : 'bg-faint';
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mt-5">
|
||||
<!-- 헤더: 타이틀 + 요약 24h 스파크라인 + 실패 합계 -->
|
||||
<!-- 헤더: 타이틀 + 신선도 + 실패 합계 -->
|
||||
<div class="flex items-center justify-between gap-3 mb-3">
|
||||
<div class="text-[11px] font-bold text-dim uppercase tracking-wider">처리 머신</div>
|
||||
<div class="flex items-center gap-3">
|
||||
@@ -219,80 +314,108 @@
|
||||
onclick={openFailures}
|
||||
>실패 {totalFailed}건 처리</button>
|
||||
{/if}
|
||||
{#if spark}
|
||||
<div class="flex items-center gap-2 text-[10px] text-faint tabular-nums" title="요약(summarize) 단계 24시간 — 유입(회색) vs 소화(녹색)">
|
||||
<svg width="120" height="24" viewBox="0 0 120 24" class="block">
|
||||
<polyline points={spark.inflow} fill="none" stroke="currentColor" stroke-width="1.5" class="text-faint" />
|
||||
<polyline points={spark.done} fill="none" stroke="currentColor" stroke-width="1.5" class="text-success" />
|
||||
</svg>
|
||||
<span>요약 24h 유입/소화</span>
|
||||
</div>
|
||||
{/if}
|
||||
<span class="flex items-center gap-1.5 text-[10px] tabular-nums {stale ? 'text-warning' : 'text-faint'}" title="60초 폴링">
|
||||
<span class="w-1.5 h-1.5 rounded-full {stale ? 'bg-warning' : 'bg-success'}"></span>
|
||||
{freshLabel}{#if stale} · 갱신 지연{/if}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 머신 스트립 -->
|
||||
<div class="flex flex-wrap gap-2 mb-3">
|
||||
{#each machineStrip as m (m.key)}
|
||||
<div class="flex items-center gap-2 bg-surface border border-default rounded-full px-3.5 py-1.5 text-xs">
|
||||
<span class="w-2 h-2 rounded-full shrink-0 {m.state === 'active' ? 'bg-success' : m.state === 'deferred' ? 'bg-warning' : 'bg-faint'}"></span>
|
||||
<span class="font-bold text-text">{m.meta?.label ?? m.label}</span>
|
||||
<span class="text-[10px] text-faint font-mono">{m.meta?.model}</span>
|
||||
<span class="text-[11px] text-dim tabular-nums">{formatRate(m.done_1h)}/h</span>
|
||||
{#if m.key === 'macbook' && m.deferred_pending > 0}
|
||||
<span class="text-[10px] font-semibold text-warning tabular-nums">보류 {m.deferred_pending}</span>
|
||||
{/if}
|
||||
<!-- 지배 백로그 스트립 (요약) + 정직 ETA -->
|
||||
<div class="flex items-center flex-wrap gap-x-3 gap-y-1 bg-surface border border-warning/50 rounded-card px-3.5 py-2 mb-3">
|
||||
<span class="text-[9px] font-bold text-warning border border-warning/60 rounded-full px-2 py-px">지배 백로그</span>
|
||||
<span class="text-xs font-bold text-text">요약</span>
|
||||
<span class="text-[11px] text-dim tabular-nums">대기 <b class="text-text">{eta.pending.toLocaleString()}</b> · 순소화 <b class="text-text">{formatRate(eta.done_rate_1h)}</b>/h · 유입 {formatRate(eta.inflow_rate_1h)}/h</span>
|
||||
<span class="ml-auto flex items-center gap-1.5 border rounded-full px-2.5 py-0.5 {honestEtaWarn ? 'border-warning text-warning' : 'border-accent text-accent'}">
|
||||
<span class="text-[10px] font-semibold">정직 ETA</span>
|
||||
<span class="text-xs font-bold tabular-nums">{honestEtaLabel}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 머신 레인 (누가 일하나 + 요약 오프로드) -->
|
||||
<div class="grid gap-2 mb-3">
|
||||
{#each lanes as lane (lane.key)}
|
||||
<div class="bg-surface border border-default rounded-card px-3.5 py-2.5">
|
||||
<div class="flex items-center gap-2 flex-wrap mb-2">
|
||||
<span class="w-2 h-2 rounded-full shrink-0 {dotClass(bgForMachine(lane.key).length > 0 ? 'active' : (lane.card?.state ?? 'idle'))}"></span>
|
||||
<span class="text-[9px] font-bold rounded px-1.5 py-px mtag-{lane.key}">{lane.meta.label}</span>
|
||||
<span class="text-[10px] text-faint font-mono">{lane.meta.model}</span>
|
||||
<span class="text-[11px] text-dim tabular-nums ml-1">{formatRate(lane.card?.done_1h ?? 0)}/h</span>
|
||||
{#each bgForMachine(lane.key) as j (j.id)}<span class="text-[10px] font-semibold text-success tabular-nums ml-1">생성 중: {j.label ?? j.kind}{#if j.total} {j.processed}/{j.total}{/if}</span>{/each}
|
||||
{#if lane.key === 'macbook' && (lane.card?.deferred_pending ?? 0) > 0}
|
||||
<span class="text-[10px] font-semibold text-warning tabular-nums">보류 {lane.card?.deferred_pending}</span>
|
||||
{/if}
|
||||
{#if lane.card?.state === 'deferred'}
|
||||
<span class="text-[9px] text-warning">잠듦 — 요약은 맥미니로 복귀</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-stretch gap-1.5 flex-wrap">
|
||||
{#each lane.nodes as n (n.def.key)}
|
||||
{@const idle = n.pending + n.processing + n.doneToday + n.failed === 0}
|
||||
<button
|
||||
class="relative text-left rounded-lg border px-2.5 py-1.5 transition-colors cursor-pointer hover:bg-surface-hover min-w-[96px]
|
||||
{idle ? 'border-dashed border-default opacity-55' : n.inflowDominant ? 'border-warning' : 'border-default'}
|
||||
{selected === n.def.key ? 'node-sel' : ''}"
|
||||
onclick={() => toggleNode(n.def.key)}
|
||||
title="{n.def.label} — 클릭하면 상세"
|
||||
>
|
||||
{#if n.failed > 0}
|
||||
<span class="absolute -top-1.5 -right-1 text-[9px] font-extrabold bg-error text-white rounded-full px-1.5">{n.failed}</span>
|
||||
{/if}
|
||||
<div class="flex items-center gap-1 text-[11px] font-semibold text-text whitespace-nowrap">
|
||||
{n.def.label}
|
||||
{#if n.processing > 0}<span class="inline-block w-1.5 h-1.5 rounded-full bg-accent animate-pulse"></span>{/if}
|
||||
</div>
|
||||
<div class="text-sm font-extrabold tabular-nums leading-tight text-text">{n.pending.toLocaleString()}<span class="text-[9px] text-faint font-normal ml-0.5">대기</span></div>
|
||||
<div class="text-[9px] text-dim tabular-nums whitespace-nowrap">{formatRate(n.done1h)}/h · 오늘 {n.doneToday.toLocaleString()}</div>
|
||||
{#if n.def.key === 'summarize'}
|
||||
<div class="mt-1 h-1 w-full rounded-full overflow-hidden flex" title="맥미니 {split.macmini.done_1h}/h · 맥북 {split.macbook.done_1h}/h">
|
||||
<span class="block h-full mtag-macmini-bar" style="width:{100 - macbookSharePct}%"></span>
|
||||
<span class="block h-full mtag-macbook-bar" style="width:{macbookSharePct}%"></span>
|
||||
</div>
|
||||
<div class="text-[9px] text-faint tabular-nums whitespace-nowrap mt-0.5">맥미니 {split.macmini.done_1h} · 맥북 {split.macbook.done_1h}/h</div>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
{#if lane.key === 'macbook' && offloadActive}
|
||||
<button
|
||||
class="text-left rounded-lg border border-dashed border-warning/50 px-2.5 py-1.5 cursor-pointer hover:bg-surface-hover min-w-[96px]"
|
||||
onclick={() => toggleNode('summarize')}
|
||||
title="맥북이 요약을 맥미니에서 가져와 처리 중"
|
||||
>
|
||||
<div class="flex items-center gap-1 text-[11px] font-semibold text-text whitespace-nowrap">요약 합류 <span class="text-[8px] font-bold text-warning">OFFLOAD</span></div>
|
||||
<div class="text-sm font-extrabold tabular-nums leading-tight text-text">{split.macbook.done_1h}<span class="text-[9px] text-faint font-normal ml-0.5">/h</span></div>
|
||||
<div class="text-[9px] text-dim tabular-nums whitespace-nowrap">요약의 {macbookSharePct}% 담당</div>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- 흐름 노드 — pt/px 헤드룸 = 실패 뱃지(-top/-right 돌출)가 스크롤 컨테이너에 잘리지 않게 -->
|
||||
<div class="flex items-stretch overflow-x-auto pt-2.5 pb-1 px-2 -mx-2">
|
||||
{#each mainNodes as n, i (n.def.key)}
|
||||
{#if i > 0}
|
||||
<div class="flex items-center text-faint text-sm px-1.5 shrink-0" aria-hidden="true">→</div>
|
||||
{/if}
|
||||
<div
|
||||
class="relative bg-surface border-[1.5px] rounded-card px-3 py-2.5 min-w-[124px] shrink-0 text-left transition-colors cursor-pointer hover:bg-surface-hover
|
||||
{n.inflowDominant ? 'border-warning' : n.etaMinutes != null && n.def.stages.includes('chunk') ? 'border-success' : 'border-default'}
|
||||
{selected === n.def.key ? 'node-sel' : ''}"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={() => toggleNode(n.def.key)}
|
||||
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggleNode(n.def.key); } }}
|
||||
title="{n.def.label} — 클릭하면 상세"
|
||||
>
|
||||
{#if n.failed > 0}
|
||||
<button
|
||||
class="absolute -top-2 -right-1.5 text-[9px] font-extrabold bg-error text-white rounded-full px-1.5 py-px shadow cursor-pointer"
|
||||
onclick={(e) => { e.stopPropagation(); openFailures(); }}
|
||||
title="실패 {n.failed}건 — 클릭하면 실패 처리"
|
||||
>{n.failed}</button>
|
||||
{/if}
|
||||
<span class="inline-block text-[9px] font-bold rounded px-1.5 py-px mb-1.5 mtag-{n.def.machine}">
|
||||
{MACHINE_META[n.def.machine].label} · {n.def.engine}
|
||||
</span>
|
||||
<div class="text-xs font-bold text-text flex items-center gap-1.5">
|
||||
{n.def.label}
|
||||
{#if n.processing > 0}
|
||||
<span class="inline-block w-1.5 h-1.5 rounded-full bg-accent animate-pulse" title="처리 중 {n.processing}"></span>
|
||||
{/if}
|
||||
{#if n.inflowDominant}
|
||||
<span class="text-[9px] font-bold text-warning">유입 우세</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="text-base font-extrabold tabular-nums tracking-tight leading-tight mt-0.5 text-text">
|
||||
{n.pending.toLocaleString()}
|
||||
</div>
|
||||
<div class="text-[10px] text-dim tabular-nums">
|
||||
{formatRate(n.done1h)}/h · 오늘 {n.doneToday.toLocaleString()}
|
||||
{#if n.etaMinutes != null && !n.inflowDominant && n.pending > 0}
|
||||
· <span class="text-accent font-semibold">{etaShort(n.etaMinutes)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<!-- 번다운 / ETA 패널 -->
|
||||
{#if burn}
|
||||
<div class="bg-surface border border-default rounded-card px-3.5 py-3 mb-1">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span class="text-[11px] font-bold text-text">요약 백로그 24시간</span>
|
||||
<span class="text-[9px] text-faint">유입(회색) vs 소화(녹색)</span>
|
||||
{#if offloadActive}<span class="text-[9px] text-warning ml-auto">맥북 합류 {burn.markHour} — 소화 급증</span>{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<svg viewBox="0 0 {burn.w} {burn.h}" class="block w-full" style="height:64px" preserveAspectRatio="none" role="img" aria-label="요약 백로그 24시간 번다운">
|
||||
<polygon points={burn.area} fill="currentColor" class="text-success" opacity="0.12" />
|
||||
<polyline points={burn.inflowLine} fill="none" stroke="currentColor" stroke-width="1.2" class="text-faint" />
|
||||
<polyline points={burn.doneLine} fill="none" stroke="currentColor" stroke-width="1.6" class="text-success" />
|
||||
{#if offloadActive}
|
||||
<line x1={burn.markX} y1="0" x2={burn.markX} y2={burn.h} stroke="currentColor" stroke-width="1" stroke-dasharray="2 2" class="text-warning" opacity="0.7" />
|
||||
{/if}
|
||||
</svg>
|
||||
<div class="flex flex-wrap gap-x-4 gap-y-1 mt-2 pt-2 border-t border-default text-[10px] text-dim tabular-nums">
|
||||
{#each mainNodes.filter((n) => n.pending > 0 && n.def.key !== 'summarize') as n (n.def.key)}
|
||||
<span class="whitespace-nowrap">{n.def.label} 대기 <b class="text-text">{n.pending.toLocaleString()}</b>{#if netEtaLabel(n)} · <span class="text-accent font-semibold">{netEtaLabel(n)}</span>{/if}</span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- 보조 라인 -->
|
||||
<p class="text-[10px] text-faint mt-1.5 tabular-nums">
|
||||
@@ -361,6 +484,32 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- 백그라운드 작업 (큐 밖 스크립트 backfill 등 — processing_queue 가 못 보는 사각지대) -->
|
||||
{#if bgJobs.length > 0}
|
||||
<div class="mt-3">
|
||||
<div class="text-[11px] font-bold text-dim uppercase tracking-wider mb-2">백그라운드 작업</div>
|
||||
<div class="grid gap-2">
|
||||
{#each bgJobs as j (j.id)}
|
||||
<div class="bg-surface border rounded-card px-3.5 py-2.5 {j.stale ? 'border-warning' : j.state === 'failed' ? 'border-error' : 'border-default'}">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<span class="w-2 h-2 rounded-full shrink-0 {bgDot(j)}"></span>
|
||||
<span class="text-[9px] font-bold rounded px-1.5 py-px bg-default text-dim font-mono">{j.kind}</span>
|
||||
<span class="text-xs font-semibold text-text truncate">{j.label ?? '작업'}</span>
|
||||
<span class="text-[11px] text-dim tabular-nums ml-auto">
|
||||
{#if j.total}{j.processed.toLocaleString()}/{j.total.toLocaleString()}{:else}{j.processed.toLocaleString()}건{/if} · {fmtElapsed(j.elapsed_sec)}
|
||||
</span>
|
||||
</div>
|
||||
{#if j.stale}
|
||||
<div class="text-[10px] text-warning mt-1.5">heartbeat 끊김 — 프로세스 중단 추정 (재개 필요할 수 있음)</div>
|
||||
{:else if j.state === 'failed'}
|
||||
<div class="text-[10px] text-error mt-1.5 truncate">실패{#if j.error} · {j.error}{/if}</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- 실패 처리 드로어 -->
|
||||
{#if failOpen}
|
||||
<div class="border border-error/40 rounded-card mt-3 overflow-hidden bg-surface">
|
||||
@@ -413,6 +562,9 @@
|
||||
.mtag-gpu { background: #e7eef6; color: #3b6ea5; }
|
||||
.mtag-macmini { background: #efe9f7; color: #8a5fbf; }
|
||||
.mtag-macbook { background: #f7eedd; color: #b07a10; }
|
||||
/* 요약 오프로드 분담 막대 채움 (맥미니 보라 / 맥북 황) */
|
||||
.mtag-macmini-bar { background: #8a5fbf; }
|
||||
.mtag-macbook-bar { background: #b07a10; }
|
||||
.node-sel { outline: 2px solid #3b6ea5; outline-offset: 1px; }
|
||||
.detail-frame { border-color: #3b6ea5; }
|
||||
.detail-head { background: #e7eef6; }
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
<script lang="ts">
|
||||
// 문서 상세 좌측 절(section) 목차 (PR-DocSrv-Hier-Section-UI-1).
|
||||
// - groupOrFlat 로 per-doc 동적 (top-segment 1단 그룹 vs flat).
|
||||
// - ASME 등 구조화 코드(buildPartOutline.hasParts): front-matter 단일 접이그룹 + PART 접이
|
||||
// (기본 접힘, 1030 flat → ~14 top-level). scroll-spy/딥링크 진입 시 조상 PART auto-expand. (D8)
|
||||
// - 그 외(per-doc): groupOrFlat 폴백 — top-segment 1단 그룹 vs flat(5140/5186/비-ASME 무회귀).
|
||||
// - 항목 클릭 → 인라인 아코디언으로 요약/section_type/heading_path breadcrumb 표시.
|
||||
// - 본문 스크롤 점프 없음(§Q2, deep-link 는 follow-up). summary=NULL 은 "요약 없음" 문구.
|
||||
import { untrack } from 'svelte';
|
||||
import Badge from '$lib/components/ui/Badge.svelte';
|
||||
import {
|
||||
cleanHeading,
|
||||
pathSegments,
|
||||
groupOrFlat,
|
||||
buildPartOutline,
|
||||
partGroupViews,
|
||||
groupKeyByChunkId,
|
||||
sectionTypeLabel,
|
||||
type DocumentSection,
|
||||
type OutlineItem,
|
||||
@@ -17,14 +22,38 @@
|
||||
sections: DocumentSection[];
|
||||
/** 항목 클릭 시 본문 점프 콜백(부모가 #sec-{chunkId} scrollIntoView). 없으면 아코디언만. */
|
||||
onJump?: (chunkId: number) => void;
|
||||
/** scroll-spy 현재 절(chunk_id) — 강조용. */
|
||||
/** scroll-spy 현재 절(chunk_id) — 강조 + Part auto-expand. */
|
||||
activeKey?: number | null;
|
||||
}
|
||||
let { sections, onJump, activeKey = null }: Props = $props();
|
||||
|
||||
let layout = $derived(groupOrFlat(sections));
|
||||
let partOutline = $derived(buildPartOutline(sections));
|
||||
// hasParts(ASME 등): Part 접이 모드. 아니면 partViews=null → groupOrFlat 폴백.
|
||||
let partViews = $derived(partOutline.hasParts ? partGroupViews(partOutline) : null);
|
||||
let layout = $derived.by(() => (partOutline.hasParts ? null : groupOrFlat(sections)));
|
||||
let groupIndex = $derived(partViews ? groupKeyByChunkId(partViews) : null);
|
||||
let total = $derived(sections.length);
|
||||
|
||||
let selectedId = $state<number | null>(null);
|
||||
// Part 그룹 접이 상태: key 없으면 접힘(기본 전부 접힘). $state Record = Svelte5 deep-proxy 반응형.
|
||||
let expanded = $state<Record<string, boolean>>({});
|
||||
function toggleGroup(key: string) {
|
||||
expanded[key] = !expanded[key];
|
||||
}
|
||||
// 문서 전환(DocumentViewer 가 sections prop 교체) 시 접이/선택 리셋 — 문서 간 PART 라벨/chunk_id 가
|
||||
// 우연히 겹쳐 이전 펼침/선택이 이월되는 것 차단(기본 전부 접힘 불변식 보존). untrack=쓰기 자기재발화 차단.
|
||||
$effect(() => {
|
||||
void sections;
|
||||
untrack(() => { expanded = {}; selectedId = null; });
|
||||
});
|
||||
// scroll-spy/딥링크 활성 절의 조상 Part 를 펼침(다른 그룹은 건드리지 않음). untrack=쓰기 자기재발화 차단.
|
||||
$effect(() => {
|
||||
const ak = activeKey;
|
||||
const idx = groupIndex;
|
||||
if (ak == null || !idx) return;
|
||||
const gk = idx.get(ak);
|
||||
if (gk) untrack(() => { expanded[gk] = true; });
|
||||
});
|
||||
|
||||
function toggle(item: OutlineItem) {
|
||||
const id = item.section.chunk_id;
|
||||
@@ -43,14 +72,17 @@
|
||||
{@const open = selectedId === s.chunk_id}
|
||||
{@const active = activeKey != null && activeKey === s.chunk_id}
|
||||
{@const typeLabel = sectionTypeLabel(s.section_type)}
|
||||
{@const depth = Math.max(0, (s.level ?? 1) - 1)}
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { toggle(item); onJump?.(s.chunk_id); }}
|
||||
aria-expanded={open}
|
||||
aria-current={active ? 'true' : undefined}
|
||||
style="padding-left:{8 + depth * 13}px"
|
||||
class={[
|
||||
'w-full text-left px-2 py-1.5 rounded-md text-xs flex items-start gap-1.5 transition-colors border-l-2',
|
||||
'w-full text-left pr-2 py-1.5 rounded-md text-xs flex items-start gap-1.5 transition-colors border-l-2',
|
||||
depth > 0 ? 'text-[11px]' : '',
|
||||
open ? 'bg-surface-active text-text border-accent' : active ? 'bg-surface text-accent-hover border-accent' : 'text-dim hover:bg-surface hover:text-text border-transparent',
|
||||
].join(' ')}
|
||||
>
|
||||
@@ -92,7 +124,37 @@
|
||||
<span class="text-faint font-normal">{total}</span>
|
||||
</h3>
|
||||
|
||||
{#if layout.mode === 'group'}
|
||||
{#if partViews}
|
||||
<!-- Part 접이 모드 (ASME 등): front-matter 단일 그룹 + PART 접이, 기본 접힘 -->
|
||||
<div class="space-y-1">
|
||||
{#each partViews as g (g.key)}
|
||||
{@const isOpen = !!expanded[g.key]}
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => toggleGroup(g.key)}
|
||||
aria-expanded={isOpen}
|
||||
class={[
|
||||
'w-full flex items-center gap-1.5 px-2 py-1.5 rounded-md text-[11px] font-semibold uppercase tracking-wide transition-colors',
|
||||
g.isFrontMatter ? 'text-faint' : 'text-dim',
|
||||
'hover:bg-surface hover:text-text',
|
||||
].join(' ')}
|
||||
>
|
||||
<span class="shrink-0 transition-transform duration-150 {isOpen ? 'rotate-90' : ''}">›</span>
|
||||
<span class="flex-1 min-w-0 text-left truncate normal-case">{g.label}</span>
|
||||
<span class="font-normal text-faint">{g.items.length}</span>
|
||||
</button>
|
||||
{#if isOpen}
|
||||
<ul class="space-y-0.5 mt-0.5">
|
||||
{#each g.items as item (item.section.chunk_id)}
|
||||
{@render itemRow(item)}
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if layout?.mode === 'group'}
|
||||
<div class="space-y-3">
|
||||
{#each layout.groups as g (g.key)}
|
||||
<div>
|
||||
@@ -115,7 +177,7 @@
|
||||
</div>
|
||||
{:else}
|
||||
<ul class="space-y-0.5">
|
||||
{#each layout.items as item (item.section.chunk_id)}
|
||||
{#each layout?.items ?? [] as item (item.section.chunk_id)}
|
||||
{@render itemRow(item)}
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
@@ -17,6 +17,11 @@ let pollHandle: ReturnType<typeof setInterval> | null = null;
|
||||
let subscriberCount = 0;
|
||||
let inFlight: Promise<void> | null = null;
|
||||
|
||||
// 마지막 성공 갱신 시각(epoch ms) — 보드 신선도 '갱신 N초 전' + stale 경고용
|
||||
// (ds-board-merged B-4). 실패(null 수렴) 시엔 갱신 안 함 → age 가 늘어 stale 로 드러남.
|
||||
const updatedAt = writable<number | null>(null);
|
||||
export const queueUpdatedAt = { subscribe: updatedAt.subscribe };
|
||||
|
||||
const internal = writable<QueueOverview | null>(null, (_set) => {
|
||||
subscriberCount += 1;
|
||||
if (subscriberCount === 1 && browser) {
|
||||
@@ -54,7 +59,9 @@ export async function refreshQueueOverview(): Promise<void> {
|
||||
if (inFlight) return inFlight;
|
||||
inFlight = (async () => {
|
||||
try {
|
||||
internal.set(await fetchOverview());
|
||||
const ov = await fetchOverview();
|
||||
internal.set(ov);
|
||||
if (ov) updatedAt.set(Date.now()); // 성공 시에만 신선도 갱신 (실패=stale 유지)
|
||||
} finally {
|
||||
inFlight = null;
|
||||
}
|
||||
|
||||
@@ -43,13 +43,19 @@ export interface SummarizeEta {
|
||||
eta_minutes: number | null;
|
||||
}
|
||||
|
||||
/** 시간당 유입 vs 소화 (이번 트랙 미렌더 — 후속 추세 위젯 슬롯) */
|
||||
/** 시간당 유입 vs 소화 (요약 24h 추이) */
|
||||
export interface TrendPoint {
|
||||
hour: string;
|
||||
inflow: number;
|
||||
done: number;
|
||||
}
|
||||
|
||||
/** summarize 머신별 완료 실적 분담 (오프로드 가시화 — ds-board-merged A-1) */
|
||||
export interface SummarizeByMachine {
|
||||
macmini: { done_1h: number; done_today: number };
|
||||
macbook: { done_1h: number; done_today: number };
|
||||
}
|
||||
|
||||
export interface QueueTotals {
|
||||
pending: number;
|
||||
processing: number;
|
||||
@@ -69,12 +75,29 @@ export interface QueueStageRow {
|
||||
oldest_pending_age_sec: number | null;
|
||||
}
|
||||
|
||||
/** 큐 밖 관리 스크립트(백필 등) 작업 — processing_queue 가 못 보는 사각지대.
|
||||
* stale = running 인데 heartbeat 끊김(프로세스 사망 추정). */
|
||||
export interface BackgroundJob {
|
||||
id: number;
|
||||
kind: string;
|
||||
label: string | null;
|
||||
state: 'running' | 'done' | 'failed';
|
||||
machine: string;
|
||||
processed: number;
|
||||
total: number | null;
|
||||
elapsed_sec: number;
|
||||
stale: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface QueueOverview {
|
||||
machines: MachineOverview[];
|
||||
summarize_eta: SummarizeEta;
|
||||
summarize_by_machine: SummarizeByMachine;
|
||||
trend_24h: TrendPoint[];
|
||||
stages: QueueStageRow[];
|
||||
totals: QueueTotals;
|
||||
background_jobs?: BackgroundJob[];
|
||||
}
|
||||
|
||||
/** ─── 실패 처리 (ds-board-engines-1) — GET /api/queue/failed · POST /retry|/skip ─── */
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
|
||||
import DOMPurify from 'dompurify';
|
||||
import { Marked } from 'marked';
|
||||
import katex from 'katex';
|
||||
// @ts-ignore — 타입 정의 누락 시 무시
|
||||
import markedKatex from 'marked-katex-extension';
|
||||
// @ts-ignore — 타입 정의 누락 시 무시
|
||||
@@ -64,6 +65,19 @@ docMarked.use({
|
||||
`</figure>`
|
||||
);
|
||||
},
|
||||
// 외부 링크(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 `<a href="${safeHref}"${titleAttr} target="_blank" rel="noopener noreferrer">${text}</a>`;
|
||||
}
|
||||
return `<a href="${safeHref}"${titleAttr}>${text}</a>`;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -81,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'],
|
||||
@@ -88,10 +104,59 @@ const SANITIZE_OPTS = {
|
||||
ALLOW_UNKNOWN_PROTOCOLS: false,
|
||||
} as const;
|
||||
|
||||
// ── 수식 pre-render ──────────────────────────────────────────────────────────
|
||||
// marked-katex-extension 의 토크나이저는 `$$` 가 블록 선두에 있어야 발화하는데,
|
||||
// (1) 개요 anchor splice 가 `$$` 직전에 <span id="sec-N"> 를 끼우면 `$$` 가 문단 중간으로
|
||||
// 밀려 블록 규칙이 깨지고, (2) 빌드/런타임 환경에 따라 확장 토크나이저가 발화하지 않으면
|
||||
// `$$` 가 평문으로 새어 marked 의 백슬래시 이스케이프(\% → %, \, → ,)에 망가진다.
|
||||
// → marked 가 손대기 *전에* 수식을 katex 로 직접 렌더해 placeholder 로 보호한 뒤 복원한다.
|
||||
// 위치·인접 상황과 무관(전역 정규식)하므로 위 두 경우를 모두 우회한다.
|
||||
const _MATH_SLOT = (i: number) => `KX0MATHSLOT${i}MATHKX0`; // marked-안전(영숫자) + 충돌 불가
|
||||
const _MATH_SLOT_RE = /KX0MATHSLOT(\d+)MATHKX0/g;
|
||||
const _BLOCK_MATH_RE = /\$\$([\s\S]+?)\$\$/g;
|
||||
// 인라인 $...$ — 통화($5)·이스케이프(\$)·`$$` 회피. $ 직후 비공백, $ 직전 비공백.
|
||||
const _INLINE_MATH_RE = /(?<![\\$\d])\$(?!\s)([^$\n]*?[^$\n\s])\$(?!\d)/g;
|
||||
|
||||
function _protectMath(text: string, slots: string[]): string {
|
||||
const render = (tex: string, displayMode: boolean): string => {
|
||||
slots.push(
|
||||
katex.renderToString(tex.trim(), { displayMode, throwOnError: false, output: 'html' }),
|
||||
);
|
||||
return _MATH_SLOT(slots.length - 1);
|
||||
};
|
||||
return text
|
||||
.replace(_BLOCK_MATH_RE, (m, tex) => {
|
||||
try {
|
||||
return render(String(tex), true);
|
||||
} catch {
|
||||
return m;
|
||||
}
|
||||
})
|
||||
.replace(_INLINE_MATH_RE, (m, tex) => {
|
||||
try {
|
||||
return render(String(tex), false);
|
||||
} catch {
|
||||
return m;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function renderDocMarkdown(text: string | null | undefined): string {
|
||||
if (!text) return '';
|
||||
try {
|
||||
const html = docMarked.parse(text) as string;
|
||||
const slots: string[] = [];
|
||||
const protectedText = _protectMath(text, slots);
|
||||
let html = docMarked.parse(protectedText) as string;
|
||||
if (slots.length) {
|
||||
// 블록 수식이 단독 문단이면 marked 가 <p> 로 감싸므로 그 <p> 를 벗겨 블록 수식이 문단에
|
||||
// 매몰되지 않게 한다. (katex-display 는 block 이라 <p> 안에 두면 브라우저가 자동 분리.)
|
||||
html = html
|
||||
.replace(
|
||||
new RegExp(`<p>\\s*KX0MATHSLOT(\\d+)MATHKX0\\s*</p>`, 'g'),
|
||||
(m, i) => slots[Number(i)] ?? m,
|
||||
)
|
||||
.replace(_MATH_SLOT_RE, (m, i) => slots[Number(i)] ?? m);
|
||||
}
|
||||
return DOMPurify.sanitize(html, SANITIZE_OPTS);
|
||||
} catch {
|
||||
// 마지막 안전망: 모든 태그 제거 후 escape
|
||||
|
||||
@@ -7,6 +7,12 @@ import {
|
||||
pathSegments,
|
||||
collapseWindows,
|
||||
groupOrFlat,
|
||||
buildPartOutline,
|
||||
partitionOutlineItems,
|
||||
partGroupViews,
|
||||
groupKeyByChunkId,
|
||||
FRONT_MATTER_KEY,
|
||||
FRONT_MATTER_LABEL,
|
||||
sectionTypeLabel,
|
||||
type DocumentSection,
|
||||
} from './headingPath.ts';
|
||||
@@ -83,6 +89,74 @@ test('[C2] collapseWindows: split-parent + window 들 → rail 1행, 대표=spli
|
||||
assert.equal(out[0].fragmentCount, 2, 'window 조각 수 = 2 (split-parent 자신 제외)');
|
||||
});
|
||||
|
||||
test('collapseWindows: bodyText — 정상 leaf 는 자기 본문, split-parent 는 window 본문만 이어붙임', () => {
|
||||
// 정상 leaf → 자기 text 가 본문
|
||||
const leaf = collapseWindows([sec({ heading_path: 'Intro', node_type: null, text: '서론 본문' })]);
|
||||
assert.equal(leaf[0].bodyText, '서론 본문');
|
||||
|
||||
// split-parent(heading 줄뿐) + window 2개 → window 본문만 순서대로 합침(헤딩 제외)
|
||||
const split = collapseWindows([
|
||||
sec({ heading_path: 'Article 5', node_type: 'chapter_split', is_leaf: false, char_start: 120, text: '# Article 5' }),
|
||||
sec({ heading_path: 'Article 5', node_type: 'window', is_leaf: true, text: '본문 조각1' }),
|
||||
sec({ heading_path: 'Article 5', node_type: 'window', is_leaf: true, text: '본문 조각2' }),
|
||||
]);
|
||||
assert.equal(split.length, 1);
|
||||
assert.equal(split[0].bodyText, '본문 조각1\n\n본문 조각2', 'split-parent heading 제외, window 본문만 합침');
|
||||
|
||||
// legacy window 런(선행 split-parent 없음) → 첫 window 자기 본문 + 흡수 조각
|
||||
const legacy = collapseWindows([
|
||||
sec({ heading_path: 'Pearson', node_type: 'window', text: 'p1' }),
|
||||
sec({ heading_path: 'Pearson', node_type: 'window', text: 'p2' }),
|
||||
]);
|
||||
assert.equal(legacy.length, 1);
|
||||
assert.equal(legacy[0].bodyText, 'p1\n\np2');
|
||||
});
|
||||
|
||||
test('collapseWindows: 절-레벨 분석 집계 — windowed 절은 window 멤버에서 type 다수결/conf 평균/summaries 합본', () => {
|
||||
// split-parent(분석 없음) + window 3개(요약·유형·신뢰도 보유) → 대표에 집계
|
||||
const out = collapseWindows([
|
||||
sec({ heading_path: 'Sec A', node_type: 'section_split', is_leaf: false, char_start: 10, text: '# Sec A', section_type: null, summary: null, confidence: null }),
|
||||
sec({ heading_path: 'Sec A', node_type: 'window', text: 'b1', section_type: 'requirement', summary: '요약1', confidence: 0.9 }),
|
||||
sec({ heading_path: 'Sec A', node_type: 'window', text: 'b2', section_type: 'requirement', summary: '요약2', confidence: 0.8 }),
|
||||
sec({ heading_path: 'Sec A', node_type: 'window', text: 'b3', section_type: 'overview', summary: '', confidence: 1.0 }),
|
||||
]);
|
||||
assert.equal(out.length, 1);
|
||||
assert.equal(out[0].sectionType, 'requirement', '다수결 = requirement(2) > overview(1)');
|
||||
assert.ok(Math.abs(out[0].confidence! - 0.9) < 1e-9, '평균 (0.9+0.8+1.0)/3 = 0.9');
|
||||
assert.deepEqual(out[0].summaries, ['요약1', '요약2'], '빈 요약 제외, 순서 유지');
|
||||
|
||||
// 단일 leaf 는 대표 자신의 분석
|
||||
const single = collapseWindows([sec({ heading_path: 'X', node_type: null, text: 'body', section_type: 'definition', summary: '정의 요약', confidence: 0.7 })]);
|
||||
assert.equal(single[0].sectionType, 'definition');
|
||||
assert.equal(single[0].confidence, 0.7);
|
||||
assert.deepEqual(single[0].summaries, ['정의 요약']);
|
||||
|
||||
// 분석 전혀 없는 절 → null/빈
|
||||
const none = collapseWindows([sec({ heading_path: 'Y', node_type: null, text: 'body' })]);
|
||||
assert.equal(none[0].sectionType, null);
|
||||
assert.equal(none[0].confidence, null);
|
||||
assert.deepEqual(none[0].summaries, []);
|
||||
});
|
||||
|
||||
test('collapseWindows: 비인접 window 도 parent_id 로 split-parent 에 흡수 (빈 split 행 방지)', () => {
|
||||
// 실데이터 버그: split-parent(chunk_index 1143)와 그 window(1233~)가 비인접 → 인접 흡수 실패로
|
||||
// 빈 split 행 + 별도 window-그룹 행 2개로 쪼개짐. parent_id 링크로 정확히 합친다.
|
||||
const out = collapseWindows([
|
||||
sec({ chunk_id: 10, heading_path: 'FOREWORD', node_type: 'section_split', is_leaf: false, char_start: 5, text: '# FOREWORD' }),
|
||||
sec({ chunk_id: 11, heading_path: 'POLICY', node_type: null, text: '정책 본문' }), // 사이에 낀 다른 절
|
||||
sec({ chunk_id: 12, heading_path: 'FOREWORD', node_type: 'window', parent_id: 10, text: '서문 조각1', section_type: 'overview', summary: '요약A', confidence: 0.9 }),
|
||||
sec({ chunk_id: 13, heading_path: 'FOREWORD', node_type: 'window', parent_id: 10, text: '서문 조각2', section_type: 'overview', summary: '요약B', confidence: 0.8 }),
|
||||
]);
|
||||
assert.equal(out.length, 2, 'FOREWORD(split, window 흡수) + POLICY = 2행 (빈 split 행 없음)');
|
||||
assert.equal(out[0].section.chunk_id, 10, '대표 = split-parent(char_start 보유)');
|
||||
assert.equal(out[0].bodyText, '서문 조각1\n\n서문 조각2', '비인접 window 본문을 split-parent 에 흡수');
|
||||
assert.equal(out[0].fragmentCount, 2);
|
||||
assert.equal(out[0].sectionType, 'overview');
|
||||
assert.deepEqual(out[0].summaries, ['요약A', '요약B']);
|
||||
assert.equal(out[1].section.chunk_id, 11, '사이 낀 절은 별도 행 유지');
|
||||
assert.equal(out[1].bodyText, '정책 본문');
|
||||
});
|
||||
|
||||
test('groupOrFlat: 적은 그룹 + 낮은 기타% → group (5140-류)', () => {
|
||||
// 3 top segment × 4 = 12절, window 없음 → group_count 3, 기타 0%
|
||||
const sections: DocumentSection[] = [];
|
||||
@@ -122,3 +196,211 @@ test('groupOrFlat: 빈 입력 → flat, 항목 0', () => {
|
||||
assert.equal(layout.mode, 'flat');
|
||||
assert.equal(layout.items.length, 0);
|
||||
});
|
||||
|
||||
// ── D9: cleanHeading ASME 개정바 ðNÞ strip ──
|
||||
test('cleanHeading: ASME 개정바 ðNÞ 통째 제거 (가운데 25 안 남김)', () => {
|
||||
assert.equal(
|
||||
cleanHeading('<sup>ð</sup>**25**<sup>Þ</sup> **PG-5.4 Size Limits**'),
|
||||
'PG-5.4 Size Limits',
|
||||
);
|
||||
// 개정바 없는 일반 제목은 그대로 (회귀)
|
||||
assert.equal(cleanHeading('#### **PG-2 SERVICE LIMITATIONS**'.replace(/^#+\s*/, '')), 'PG-2 SERVICE LIMITATIONS');
|
||||
});
|
||||
|
||||
// ── D7: buildPartOutline — front-matter 분리 + PART 그룹 ──
|
||||
test('buildPartOutline: front-matter 분리 + PART 그룹', () => {
|
||||
const sections = [
|
||||
sec({ heading_path: 'TABLE OF CONTENTS', section_title: 'TABLE OF CONTENTS' }),
|
||||
sec({ heading_path: 'Honors and Awards Committee', section_title: 'Honors and Awards Committee' }),
|
||||
sec({ heading_path: 'PART PG GENERAL > PG-1 SCOPE', section_title: 'PG-1 SCOPE' }),
|
||||
sec({ heading_path: 'PART PG GENERAL > PG-2 SERVICE', section_title: 'PG-2 SERVICE' }),
|
||||
sec({ heading_path: 'PART PW > PW-1 SCOPE', section_title: 'PW-1 SCOPE' }),
|
||||
];
|
||||
const o = buildPartOutline(sections);
|
||||
assert.equal(o.hasParts, true);
|
||||
assert.equal(o.frontMatter.length, 2); // TOC + Committee
|
||||
assert.equal(o.groups.length, 2); // PART PG, PART PW
|
||||
assert.equal(o.groups[0].key, 'PART PG GENERAL');
|
||||
assert.equal(o.groups[0].items.length, 2); // PG-1, PG-2
|
||||
assert.equal(o.groups[1].key, 'PART PW');
|
||||
assert.equal(o.groups[1].items.length, 1);
|
||||
});
|
||||
|
||||
test('buildPartOutline: split-parent + window 가 같은 PART 그룹에서 1항목으로 흡수', () => {
|
||||
const sections = [
|
||||
sec({ heading_path: 'PART PG GENERAL > PG-27 CYL', section_title: 'PG-27 CYL', node_type: 'section_split', chunk_id: 100, text: 'PG-27 CYL' }),
|
||||
sec({ heading_path: 'PART PG GENERAL > PG-27 CYL', section_title: 'PG-27 CYL', node_type: 'window', parent_id: 100, text: 'body part 1' }),
|
||||
sec({ heading_path: 'PART PG GENERAL > PG-27 CYL', section_title: 'PG-27 CYL', node_type: 'window', parent_id: 100, text: 'body part 2' }),
|
||||
];
|
||||
const o = buildPartOutline(sections);
|
||||
assert.equal(o.hasParts, true);
|
||||
assert.equal(o.groups.length, 1);
|
||||
assert.equal(o.groups[0].items.length, 1); // split-parent + 2 window → 1 항목
|
||||
assert.equal(o.groups[0].items[0].fragmentCount, 2);
|
||||
});
|
||||
|
||||
test('buildPartOutline: content part 없으면 hasParts=false (폴백 신호)', () => {
|
||||
const o = buildPartOutline([sec({ heading_path: 'Intro', section_title: 'Intro' })]);
|
||||
assert.equal(o.hasParts, false);
|
||||
assert.equal(o.groups.length, 0);
|
||||
});
|
||||
|
||||
test('buildPartOutline: PART/SUBSECTION 마커 없으면(항목코드만) hasParts=false → 폴백', () => {
|
||||
// 실 ASME 코드(5180/5210)는 PART/SUBSECTION 마커를 갖는다. PART 가 0 인 문서(항목코드만)는
|
||||
// 접을 PART 가 없으므로 hasParts=false → 호출자가 groupOrFlat/flat 으로 폴백.
|
||||
const o = buildPartOutline([
|
||||
sec({ heading_path: 'FOREWORD', section_title: 'FOREWORD' }),
|
||||
sec({ heading_path: null, section_title: 'U-1 적용범위' }),
|
||||
]);
|
||||
assert.equal(o.hasParts, false);
|
||||
assert.equal(o.groups.length, 0);
|
||||
});
|
||||
|
||||
test('buildPartOutline: (NON)MANDATORY APPENDIX 도 최상위 섹션 경계 — 마지막 PART 흡수 방지', () => {
|
||||
// 5180 실측: 부록을 마커로 안 잡으면 마지막 PART(PHRSG)가 부록 289항목을 carry-forward 흡수(=300).
|
||||
const o = buildPartOutline([
|
||||
sec({ heading_path: 'PART PHRSG REQUIREMENTS > PHRSG-1', section_title: 'PHRSG-1' }),
|
||||
sec({ heading_path: 'PHRSG-2 SCOPE', section_title: 'PHRSG-2' }), // PHRSG 로 carry
|
||||
sec({ heading_path: 'MANDATORY APPENDIX IV LOCAL THIN AREAS', section_title: '...' }),
|
||||
sec({ heading_path: 'IV-1 GENERAL', section_title: 'IV-1' }), // APPENDIX IV 로 carry
|
||||
sec({ heading_path: 'NONMANDATORY APPENDIX A EXPLANATION', section_title: '...' }),
|
||||
]);
|
||||
assert.deepEqual(o.groups.map((g) => [g.key.slice(0, 24), g.items.length]), [
|
||||
['PART PHRSG REQUIREMENTS', 2], // PHRSG-1 + PHRSG-2(carry), 부록 안 섞임
|
||||
['MANDATORY APPENDIX IV LO', 2], // 부록 헤딩 + IV-1(carry)
|
||||
['NONMANDATORY APPENDIX A ', 1],
|
||||
]);
|
||||
});
|
||||
|
||||
test('buildPartOutline: 본문 cross-ref/문장 false PART 차단 (5210 stale 패턴)', () => {
|
||||
// 혼합대소문자 'Part D…' · 코드 뒤 비대문자(한글) 문장 'PART UW 규정은…' · 비대문자 코드 'PART 층이…'
|
||||
// = 전부 본문이라 PART 아님. 깨끗한 PART 0 → hasParts=false → flat 폴백(가짜 그룹 0).
|
||||
const o = buildPartOutline([
|
||||
sec({ heading_path: 'Part D, Subpart 3의 해당 재료', section_title: 'Part D…' }),
|
||||
sec({ heading_path: 'PART UW 규정은 용접에 의해 제작되는', section_title: 'PART UW 규정은…' }),
|
||||
sec({ heading_path: 'PART 층이 진 구조로 조립되는', section_title: 'PART 층이…' }),
|
||||
]);
|
||||
assert.equal(o.hasParts, false);
|
||||
});
|
||||
|
||||
test('buildPartOutline: SUBSECTION 마커도 PART 경계로 인식(Sec VIII)', () => {
|
||||
const o = buildPartOutline([
|
||||
sec({ heading_path: 'TOC', section_title: 'TOC' }),
|
||||
sec({ heading_path: 'SUBSECTION A GENERAL > UG-1', section_title: 'UG-1' }),
|
||||
sec({ heading_path: 'SUBSECTION B > UW-1', section_title: 'UW-1' }),
|
||||
]);
|
||||
assert.equal(o.hasParts, true);
|
||||
assert.equal(o.frontMatter.length, 1);
|
||||
assert.deepEqual(o.groups.map((g) => g.key), ['SUBSECTION A GENERAL', 'SUBSECTION B']);
|
||||
});
|
||||
|
||||
// ── D8: partitionOutlineItems — 이미 collapse 된 OutlineItem 재배치(인스턴스 보존) ──
|
||||
test('partitionOutlineItems: flat outline 의 인스턴스를 그대로 재배치(재-collapse 없음)', () => {
|
||||
const sections = [
|
||||
sec({ heading_path: 'TABLE OF CONTENTS', section_title: 'TABLE OF CONTENTS' }),
|
||||
sec({ heading_path: 'PART PG GENERAL > PG-1 SCOPE', section_title: 'PG-1 SCOPE' }),
|
||||
sec({ heading_path: 'PART PG GENERAL > PG-2 SERVICE', section_title: 'PG-2 SERVICE' }),
|
||||
sec({ heading_path: 'PART PW > PW-1 SCOPE', section_title: 'PW-1 SCOPE' }),
|
||||
];
|
||||
const flat = collapseWindows(sections); // 컴포넌트의 outline 과 동일 경로
|
||||
const o = partitionOutlineItems(flat);
|
||||
assert.equal(o.hasParts, true);
|
||||
assert.equal(o.frontMatter.length, 1);
|
||||
assert.equal(o.groups.length, 2);
|
||||
// ★ 인스턴스 동일성: 재배치된 item 이 flat outline 의 바로 그 객체여야 selectedSectionId 정합.
|
||||
assert.ok(o.frontMatter[0] === flat[0], 'front-matter item = flat[0] 인스턴스');
|
||||
assert.ok(o.groups[0].items[0] === flat[1], 'PART PG 첫 item = flat[1] 인스턴스');
|
||||
assert.ok(o.groups[1].items[0] === flat[3], 'PART PW item = flat[3] 인스턴스');
|
||||
// chunk_id 집합이 flat 과 정확히 일치(클릭→selectedSectionId 조회 실패 없음).
|
||||
const flatIds = flat.map((it) => it.section.chunk_id).sort();
|
||||
const partIds = [...o.frontMatter, ...o.groups.flatMap((g) => g.items)]
|
||||
.map((it) => it.section.chunk_id).sort();
|
||||
assert.deepEqual(partIds, flatIds);
|
||||
});
|
||||
|
||||
test('partitionOutlineItems: 비-PART top-segment 항목은 직전 PART 로 carry-forward (marker 트리 불규칙 흡수)', () => {
|
||||
// ★ 5180 실측 패턴: PART 아래 직접 중첩 안 된 항목('PG-28'·'GENERAL')의 top-segment 가 PART 가
|
||||
// 아니다 → 단순 segs[0] 그룹핑이면 가짜 그룹 폭발. carry-forward 가 직전 PART 로 흡수해야 한다.
|
||||
const items = collapseWindows([
|
||||
sec({ heading_path: 'TOC', section_title: 'TOC' }),
|
||||
sec({ heading_path: 'PART PG GENERAL > PG-1', section_title: 'PG-1' }),
|
||||
sec({ heading_path: 'PG-28 EXTERNAL PRESSURE', section_title: 'PG-28' }), // top-seg ≠ PART → carry
|
||||
sec({ heading_path: 'OPENINGS AND COMPENSATION', section_title: 'OPENINGS' }), // carry
|
||||
sec({ heading_path: 'PART PW > PW-1', section_title: 'PW-1' }),
|
||||
sec({ heading_path: 'GENERAL', section_title: 'GENERAL' }), // PART PW 로 carry
|
||||
]);
|
||||
const o = partitionOutlineItems(items);
|
||||
assert.equal(o.hasParts, true);
|
||||
assert.equal(o.frontMatter.length, 1);
|
||||
assert.equal(o.groups.length, 2, 'PART PG / PART PW 단 2그룹(가짜 그룹 0)');
|
||||
assert.equal(o.groups[0].key, 'PART PG GENERAL');
|
||||
assert.equal(o.groups[0].items.length, 3, 'PG-1 + PG-28 + OPENINGS carry');
|
||||
assert.equal(o.groups[1].key, 'PART PW');
|
||||
assert.equal(o.groups[1].items.length, 2, 'PW-1 + GENERAL carry');
|
||||
// carry 된 항목도 인스턴스 보존(클릭 정합)
|
||||
assert.ok(o.groups[0].items[1].section.section_title === 'PG-28');
|
||||
});
|
||||
|
||||
test('partitionOutlineItems: buildPartOutline 과 그룹 구조 동치(collapse→partition == partition∘collapse)', () => {
|
||||
const sections = [
|
||||
sec({ heading_path: 'PART PG > PG-27 CYL', section_title: 'PG-27 CYL', node_type: 'section_split', chunk_id: 100, text: 'PG-27 CYL' }),
|
||||
sec({ heading_path: 'PART PG > PG-27 CYL', section_title: 'PG-27 CYL', node_type: 'window', parent_id: 100, text: 'b1' }),
|
||||
sec({ heading_path: 'PART PG > PG-27 CYL', section_title: 'PG-27 CYL', node_type: 'window', parent_id: 100, text: 'b2' }),
|
||||
sec({ heading_path: 'PART PW > PW-1', section_title: 'PW-1' }),
|
||||
];
|
||||
const viaBuild = buildPartOutline(sections);
|
||||
const viaPartition = partitionOutlineItems(collapseWindows(sections));
|
||||
assert.equal(viaBuild.hasParts, viaPartition.hasParts);
|
||||
assert.deepEqual(viaBuild.groups.map((g) => [g.key, g.items.length]), viaPartition.groups.map((g) => [g.key, g.items.length]));
|
||||
// window 흡수 후 PART PG 는 1 항목(fragmentCount 2).
|
||||
assert.equal(viaPartition.groups[0].items.length, 1);
|
||||
assert.equal(viaPartition.groups[0].items[0].fragmentCount, 2);
|
||||
});
|
||||
|
||||
// ── D8: partGroupViews / groupKeyByChunkId — 렌더 그룹 평탄화 + auto-expand 역인덱스 ──
|
||||
test('partGroupViews: front-matter 를 첫 그룹(sentinel key)으로, 이어 PART 그룹', () => {
|
||||
const sections = [
|
||||
sec({ heading_path: 'TOC', section_title: 'TOC' }),
|
||||
sec({ heading_path: 'PART PG > PG-1', section_title: 'PG-1' }),
|
||||
sec({ heading_path: 'PART PW > PW-1', section_title: 'PW-1' }),
|
||||
];
|
||||
const views = partGroupViews(buildPartOutline(sections));
|
||||
assert.equal(views.length, 3);
|
||||
assert.equal(views[0].key, FRONT_MATTER_KEY);
|
||||
assert.equal(views[0].label, FRONT_MATTER_LABEL);
|
||||
assert.equal(views[0].isFrontMatter, true);
|
||||
assert.equal(views[1].key, 'PART PG');
|
||||
assert.equal(views[1].label, 'PART PG');
|
||||
assert.equal(views[1].isFrontMatter, false);
|
||||
assert.equal(views[2].key, 'PART PW');
|
||||
// 모든 key 유일(Svelte each key 안전)
|
||||
const keys = views.map((v) => v.key);
|
||||
assert.equal(new Set(keys).size, keys.length);
|
||||
});
|
||||
|
||||
test('partGroupViews: front-matter 없으면 PART 그룹만(첫 그룹 sentinel 없음)', () => {
|
||||
const sections = [
|
||||
sec({ heading_path: 'PART PG > PG-1', section_title: 'PG-1' }),
|
||||
sec({ heading_path: 'PART PW > PW-1', section_title: 'PW-1' }),
|
||||
];
|
||||
const views = partGroupViews(buildPartOutline(sections));
|
||||
assert.equal(views.length, 2);
|
||||
assert.ok(views.every((v) => !v.isFrontMatter));
|
||||
assert.equal(views[0].key, 'PART PG');
|
||||
});
|
||||
|
||||
test('groupKeyByChunkId: 대표 chunk_id → 소속 group key (auto-expand 역인덱스)', () => {
|
||||
const sections = [
|
||||
sec({ chunk_id: 1, heading_path: 'TOC', section_title: 'TOC' }),
|
||||
sec({ chunk_id: 2, heading_path: 'PART PG > PG-1', section_title: 'PG-1' }),
|
||||
sec({ chunk_id: 3, heading_path: 'PART PG > PG-2', section_title: 'PG-2' }),
|
||||
sec({ chunk_id: 4, heading_path: 'PART PW > PW-1', section_title: 'PW-1' }),
|
||||
];
|
||||
const views = partGroupViews(buildPartOutline(sections));
|
||||
const idx = groupKeyByChunkId(views);
|
||||
assert.equal(idx.get(1), FRONT_MATTER_KEY);
|
||||
assert.equal(idx.get(2), 'PART PG');
|
||||
assert.equal(idx.get(3), 'PART PG');
|
||||
assert.equal(idx.get(4), 'PART PW');
|
||||
assert.equal(idx.get(999), undefined);
|
||||
});
|
||||
|
||||
@@ -14,8 +14,12 @@ export interface DocumentSection {
|
||||
level: number | null;
|
||||
node_type: string | null; // 'window' | 'chapter_split' | 'clause_split' | 'section_split' | null
|
||||
is_leaf: boolean;
|
||||
/** 트리 부모 chunk_id. window child 의 parent_id = 그 split-parent (비인접 흡수에 사용). */
|
||||
parent_id?: number | null;
|
||||
/** md_content 내 heading offset(UTF-16). jump-target 만 값, window-child/preamble/Path A = null (Path B). */
|
||||
char_start?: number | null;
|
||||
/** 절 본문 = 청크 원문. split-parent 는 heading 줄뿐, window child 가 실 본문 보유. */
|
||||
text?: string | null;
|
||||
section_type: string | null;
|
||||
summary: string | null;
|
||||
confidence: number | null;
|
||||
@@ -25,6 +29,17 @@ export interface DocumentSection {
|
||||
export interface OutlineItem {
|
||||
section: DocumentSection;
|
||||
fragmentCount: number; // >1 이면 "(n조각)" 배지
|
||||
/** 대표 + 흡수된 window child 들의 본문을 순서대로 이어붙인 논리 절 전체 본문.
|
||||
* split-parent 는 heading 줄(text)을 본문에서 제외(제목과 중복) — window 본문만 합친다. */
|
||||
bodyText: string;
|
||||
/** 집계된 절-레벨 분석. windowed 절은 분석이 window child(chunk_section_analysis)에 붙고
|
||||
* 대표=split-parent 엔 없으므로 멤버에서 집계한다. 단일 절은 대표 자신의 값.
|
||||
* - sectionType: 멤버 section_type 다수결(동률=첫 등장)
|
||||
* - confidence: 멤버 confidence 평균
|
||||
* - summaries: 멤버 요약(빈 것 제외, chunk_index 순) — 단일=1개, windowed=N개(부분별 요약) */
|
||||
sectionType: string | null;
|
||||
confidence: number | null;
|
||||
summaries: string[];
|
||||
}
|
||||
|
||||
export interface OutlineGroup {
|
||||
@@ -69,6 +84,9 @@ export function sectionTypeLabel(t: string | null | undefined): string | null {
|
||||
export function cleanHeading(raw: string | null | undefined): string {
|
||||
if (!raw) return '';
|
||||
return raw
|
||||
// D9(read-time): ASME 개정바 ðNÞ(`<sup>ð</sup>**25**<sup>Þ</sup>`) 통째 제거 — 개별 sup strip 전에.
|
||||
// (일반 sup strip 이 먼저면 가운데 '25'(개정 연도)만 남아 'ð25Þ PG-5.4' → '25 PG-5.4' 오염)
|
||||
.replace(/<sup>\s*ð\s*<\/sup>.*?<sup>\s*Þ\s*<\/sup>/gi, '')
|
||||
.replace(/<sup>.*?<\/sup>/gi, '') // 각주 위첨자
|
||||
.replace(/<sub>.*?<\/sub>/gi, '')
|
||||
.replace(/<[^>]+>/g, '') // 잔여 HTML 태그
|
||||
@@ -107,22 +125,78 @@ function topSegment(s: DocumentSection): string {
|
||||
* fragmentCount: split-parent 대표는 0 에서 시작(자신은 조각 아님) + 흡수 child 수 = 실제 조각 수;
|
||||
* legacy window 대표는 1 에서 시작(자신이 첫 조각).
|
||||
*/
|
||||
/** 멤버 section_type 다수결(동률은 첫 등장 우선). 비어있으면 null. */
|
||||
function majorityType(types: (string | null)[]): string | null {
|
||||
const vals = types.filter((t): t is string => !!t);
|
||||
if (!vals.length) return null;
|
||||
const count = new Map<string, number>();
|
||||
for (const t of vals) count.set(t, (count.get(t) ?? 0) + 1);
|
||||
let best: string | null = null;
|
||||
let bestN = -1;
|
||||
for (const t of vals) {
|
||||
const n = count.get(t)!;
|
||||
if (n > bestN) { bestN = n; best = t; } // 첫 등장 우선 tie-break
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
export function collapseWindows(sections: DocumentSection[]): OutlineItem[] {
|
||||
const out: OutlineItem[] = [];
|
||||
const members: DocumentSection[][] = []; // out[i] 의 멤버(대표 + 흡수된 window child)
|
||||
const repByChunkId = new Map<number, number>(); // split-parent chunk_id → out index (window 가 parent_id 로 흡수)
|
||||
|
||||
// window child 본문/멤버를 out[idx] 대표에 흡수.
|
||||
const absorb = (idx: number, s: DocumentSection) => {
|
||||
out[idx].fragmentCount += 1;
|
||||
const t = (s.text ?? '').trim();
|
||||
if (t) out[idx].bodyText = out[idx].bodyText ? `${out[idx].bodyText}\n\n${t}` : t;
|
||||
members[idx].push(s);
|
||||
};
|
||||
|
||||
for (const s of sections) {
|
||||
const prev = out[out.length - 1];
|
||||
const h = cleanHeading(s.heading_path);
|
||||
const prevAbsorbs =
|
||||
prev &&
|
||||
(prev.section.node_type === 'window' || !!prev.section.node_type?.endsWith('_split')) &&
|
||||
h !== '' &&
|
||||
cleanHeading(prev.section.heading_path) === h;
|
||||
if (s.node_type === 'window' && prevAbsorbs) {
|
||||
prev!.fragmentCount += 1; // window child 흡수 — 대표(split-parent 우선)는 그대로 유지
|
||||
if (s.node_type === 'window') {
|
||||
// 1) parent_id 로 split-parent 대표에 흡수 — split-parent 와 window 가 chunk_index 상 비인접일 수
|
||||
// 있으므로(예: 헤딩 1143, window 1233) 인접 가정 대신 트리 부모 링크로 정확히 연결한다.
|
||||
let idx = s.parent_id != null ? repByChunkId.get(s.parent_id) ?? -1 : -1;
|
||||
// 2) fallback: 인접 대표(legacy window run / 같은 heading split)면 흡수
|
||||
if (idx < 0) {
|
||||
const prev = out[out.length - 1];
|
||||
const h = cleanHeading(s.heading_path);
|
||||
if (
|
||||
prev &&
|
||||
(prev.section.node_type === 'window' || !!prev.section.node_type?.endsWith('_split')) &&
|
||||
h !== '' &&
|
||||
cleanHeading(prev.section.heading_path) === h
|
||||
) {
|
||||
idx = out.length - 1;
|
||||
}
|
||||
}
|
||||
if (idx >= 0) {
|
||||
absorb(idx, s);
|
||||
continue;
|
||||
}
|
||||
// 3) legacy: 부모 없는 window → 자기 대표(자기 본문으로 시작)
|
||||
out.push({ section: s, fragmentCount: 1, bodyText: s.text ?? '', sectionType: null, confidence: null, summaries: [] });
|
||||
members.push([s]);
|
||||
} else {
|
||||
out.push({ section: s, fragmentCount: s.node_type?.endsWith('_split') ? 0 : 1 });
|
||||
const isSplit = !!s.node_type?.endsWith('_split');
|
||||
// split-parent 의 text 는 heading 줄뿐 → 본문에서 제외(window 가 본문 보유). 그 외엔 자기 본문으로 시작.
|
||||
out.push({
|
||||
section: s, fragmentCount: isSplit ? 0 : 1, bodyText: isSplit ? '' : (s.text ?? ''),
|
||||
sectionType: null, confidence: null, summaries: [],
|
||||
});
|
||||
members.push([s]);
|
||||
if (isSplit) repByChunkId.set(s.chunk_id, out.length - 1); // window 가 parent_id 로 찾아 흡수
|
||||
}
|
||||
}
|
||||
// 멤버에서 절-레벨 분석 집계 (windowed 절: 대표 split-parent 엔 분석 없고 window 들이 보유).
|
||||
for (let i = 0; i < out.length; i++) {
|
||||
const mem = members[i];
|
||||
out[i].sectionType = majorityType(mem.map((m) => m.section_type));
|
||||
const confs = mem.map((m) => m.confidence).filter((c): c is number => c != null);
|
||||
out[i].confidence = confs.length ? confs.reduce((a, b) => a + b, 0) / confs.length : null;
|
||||
out[i].summaries = mem.map((m) => (m.summary ?? '').trim()).filter((x) => x !== '');
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -160,3 +234,129 @@ export function groupOrFlat(sections: DocumentSection[]): OutlineLayout {
|
||||
}));
|
||||
return { mode: 'group', items: [], groups };
|
||||
}
|
||||
|
||||
// ── D7/D8 (asme-item-decomp read-time): front-matter 억제 + Part 계층 그룹 ──
|
||||
// 긴 구조화 코드(ASME)의 절뷰가 flat 1030 으로 길어지는 문제(front-matter 240 + 다중 PART)를
|
||||
// 표현 계층에서 해결. 빌더/재분해 무접촉 — sections 엔드포인트가 주는 heading_path 만으로 산출.
|
||||
|
||||
/**
|
||||
* 최상위 섹션 경계 top-segment 패턴: 대문자 'PART'/'SUBSECTION'/'(MANDATORY|NONMANDATORY) APPENDIX'
|
||||
* + 대문자 코드(PG/UW/IV/A) + 선택 제목(대문자/숫자/괄호 시작).
|
||||
* 예: 'PART PG GENERAL REQUIREMENTS…', 'SUBSECTION A GENERAL', 'NONMANDATORY APPENDIX A EXPLANATION…'.
|
||||
* 부록(APPENDIX)도 ASME 최상위 섹션(파트와 동격)이라 별 그룹으로 — 안 그러면 마지막 PART 가 부록 전체를
|
||||
* carry-forward 로 흡수(5180 실측: PART PHRSG 11항목 → 부록 289 흡수 = 300).
|
||||
*
|
||||
* ★ case-sensitive + 제목-대문자 가드 = 본문 cross-ref/문장 false match 차단(5210 실측):
|
||||
* 'Part D, Subpart 3의 …'(혼합대소문자) · 'PART UW 규정은 용접에 …'(코드 뒤 한글 문장) · 'PART 층이 진 …'
|
||||
* (코드 비대문자) 전부 거부. D1 빌더 _ENG 가드의 read-time 대응([[feedback_docstring_invariant_swap_audit]]).
|
||||
* ⚠ 알려진 트레이드오프(D3 재검토): 제목-대문자 가드는 비영문(한글) 제목으로 시작하는 PART 도 거부한다
|
||||
* (예: 'PART PG 일반 요건'). false-negative(→flat 폴백)는 false-positive(→가짜 그룹)보다 안전한 방향이라
|
||||
* 파일럿(5180 영문)엔 옳고 5210(D3 재분해 전 한글 stale)은 flat 폴백된다. **5210 D3 재분해 후 실 PART
|
||||
* 제목 형태(영문/한글/코드만)를 보고 가드를 정련** — read-time 라 마이그 0. [[project_hierarchical_decomposition]] D3.
|
||||
*/
|
||||
const PART_MARKER_RE = /^((MANDATORY |NONMANDATORY )?APPENDIX|PART|SUBSECTION)\s+[A-Z][A-Z0-9.\-]*(\s+[A-Z0-9(].*)?$/;
|
||||
|
||||
/** top-segment 문자열이 PART/SUBSECTION/APPENDIX 헤딩인가 (마커 판정 단일 소스 — 경계·carry 공용). */
|
||||
function isPartMarkerSeg(seg0: string): boolean {
|
||||
return PART_MARKER_RE.test(seg0);
|
||||
}
|
||||
|
||||
/** 절의 heading_path 첫 세그먼트가 PART/SUBSECTION/APPENDIX 헤딩 = 새 최상위 섹션 경계. */
|
||||
function isPartMarker(s: DocumentSection): boolean {
|
||||
const segs = pathSegments(s.heading_path);
|
||||
return segs.length > 0 && isPartMarkerSeg(segs[0]);
|
||||
}
|
||||
|
||||
export interface PartOutline {
|
||||
/** PART PG / PART PW … 전(前) front-matter(TOC·위원회·인명) — 단일 접이 그룹용. */
|
||||
frontMatter: OutlineItem[];
|
||||
/** 본문 Part 그룹들(heading_path 첫 세그먼트 = PART 기준). 기본 접힘은 렌더(D8)에서. */
|
||||
groups: OutlineGroup[];
|
||||
/** content part 경계를 못 찾으면 false → 기존 groupOrFlat 폴백 권장. */
|
||||
hasParts: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 이미 collapseWindows 된 OutlineItem[] 를 front-matter(첫 PART 마커 전) 분리 + 본문을 PART 로
|
||||
* **순서 기반 carry-forward** 그룹. 정렬(chunk_index) 유지.
|
||||
*
|
||||
* ★ carry-forward 가 핵심: 실 ASME md 는 marker 추출 트리가 불규칙해 'PG-28'·'GENERAL' 등 다수
|
||||
* 항목의 heading_path 첫 세그먼트가 PART 가 아니다(자기 자신/중간 헤딩). 단순 segs[0] 그룹핑은
|
||||
* 250+ 가짜 그룹을 낳는다(5180 실측). → PART/SUBSECTION 마커를 만나면 새 그룹을 열고, 비-마커
|
||||
* 항목은 직전 PART 로 흡수 = 실제 ~13 PART 로 수렴.
|
||||
* ★ 같은 OutlineItem 인스턴스를 재배치만 한다(재-collapse 없음) → 호출자의 flat outline 과
|
||||
* chunk_id·인스턴스가 1:1 일치(상세페이지 treeNav 가 selectedSectionId/focusView 와 정합).
|
||||
* PART 마커가 0 이면 hasParts=false → 호출자가 groupOrFlat/flat 으로 폴백.
|
||||
*/
|
||||
export function partitionOutlineItems(items: OutlineItem[]): PartOutline {
|
||||
let boundary = -1;
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
if (isPartMarker(items[i].section)) { boundary = i; break; }
|
||||
}
|
||||
if (boundary < 0) {
|
||||
return { frontMatter: [], groups: [], hasParts: false };
|
||||
}
|
||||
const frontMatter = items.slice(0, boundary);
|
||||
|
||||
const order: string[] = [];
|
||||
const map = new Map<string, OutlineItem[]>();
|
||||
let current = ''; // 현재 PART 키 — boundary 가 PART 마커라 첫 본문 항목에서 즉시 설정됨.
|
||||
for (let i = boundary; i < items.length; i++) {
|
||||
const it = items[i];
|
||||
const segs = pathSegments(it.section.heading_path);
|
||||
if (segs.length && isPartMarkerSeg(segs[0])) current = segs[0]; // 새 PART 경계(경계 루프와 동일 판정 = '' 누출 불가)
|
||||
if (!map.has(current)) { map.set(current, []); order.push(current); }
|
||||
map.get(current)!.push(it);
|
||||
}
|
||||
const groups: OutlineGroup[] = order.map((key) => ({ key, isOther: false, items: map.get(key)! }));
|
||||
return { frontMatter, groups, hasParts: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* front-matter 경계(첫 content part) 분리 + 본문을 PART(heading_path 첫 세그먼트)로 그룹.
|
||||
* = collapseWindows 후 partitionOutlineItems (절뷰 rail/treeNav 공용 진입점, sections 기반).
|
||||
*/
|
||||
export function buildPartOutline(sections: DocumentSection[]): PartOutline {
|
||||
return partitionOutlineItems(collapseWindows(sections));
|
||||
}
|
||||
|
||||
// ── D8: Part 접이 렌더용 — front-matter 를 첫 그룹으로 평탄화 + auto-expand 역인덱스 ──
|
||||
|
||||
/** front-matter 접이 그룹의 안정 key/라벨(실 PART 키와 충돌 불가능한 sentinel). */
|
||||
export const FRONT_MATTER_KEY = '__front_matter__';
|
||||
export const FRONT_MATTER_LABEL = '문서 정보·서문';
|
||||
|
||||
/** 접이 그룹 1개(front-matter 또는 PART) 의 렌더 뷰. */
|
||||
export interface PartGroupView {
|
||||
/** Svelte each key + 접이 상태 key. front-matter = FRONT_MATTER_KEY. */
|
||||
key: string;
|
||||
/** 헤더 표시 라벨. */
|
||||
label: string;
|
||||
isFrontMatter: boolean;
|
||||
items: OutlineItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* PartOutline → 렌더 그룹 배열. front-matter(있으면)를 항상 첫 그룹으로,
|
||||
* 이어서 PART 그룹들. 기본 접힘/auto-expand 는 컴포넌트가 key 로 관리.
|
||||
*/
|
||||
export function partGroupViews(outline: PartOutline): PartGroupView[] {
|
||||
const views: PartGroupView[] = [];
|
||||
if (outline.frontMatter.length) {
|
||||
views.push({ key: FRONT_MATTER_KEY, label: FRONT_MATTER_LABEL, isFrontMatter: true, items: outline.frontMatter });
|
||||
}
|
||||
for (const g of outline.groups) {
|
||||
views.push({ key: g.key, label: g.key, isFrontMatter: false, items: g.items });
|
||||
}
|
||||
return views;
|
||||
}
|
||||
|
||||
/**
|
||||
* 대표 OutlineItem 의 chunk_id → 소속 group key 역인덱스(딥링크/스크롤스파이 진입 시
|
||||
* 조상 그룹 auto-expand 용). activeKey/selectedSectionId 는 대표 chunk_id 라 대표만 매핑.
|
||||
*/
|
||||
export function groupKeyByChunkId(views: PartGroupView[]): Map<number, string> {
|
||||
const m = new Map<number, string>();
|
||||
for (const v of views) for (const it of v.items) m.set(it.section.chunk_id, v.key);
|
||||
return m;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { browser } from '$app/environment';
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { Menu, EllipsisVertical, ChevronDown, FileText, Newspaper, HelpCircle, StickyNote, Inbox, PanelLeft, MessageCircle } from 'lucide-svelte';
|
||||
import { Menu, EllipsisVertical, ChevronDown, FileText, Newspaper, StickyNote, Inbox, PanelLeft } from 'lucide-svelte';
|
||||
import { isAuthenticated, user, tryRefresh, logout } from '$lib/stores/auth';
|
||||
import { toasts, removeToast } from '$lib/stores/toast';
|
||||
import { refresh as refreshPublicConfig } from '$lib/stores/config';
|
||||
@@ -151,8 +151,7 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<a href="/ask" class="px-3 py-1.5 rounded-md text-sm font-semibold transition-colors {isActive('/ask') ? 'text-accent bg-accent/12' : 'text-dim hover:text-text hover:bg-surface'}">질문</a>
|
||||
<a href="/chat" class="px-3 py-1.5 rounded-md text-sm font-semibold transition-colors {isActive('/chat') ? 'text-accent bg-accent/12' : 'text-dim hover:text-text hover:bg-surface'}">이드</a>
|
||||
<a href="/memos" class="px-3 py-1.5 rounded-md text-sm font-semibold transition-colors {isActive('/memos') ? 'text-accent bg-accent/12' : 'text-dim hover:text-text hover:bg-surface'}">메모</a>
|
||||
<SystemStatusDot />
|
||||
</div>
|
||||
|
||||
@@ -212,8 +211,6 @@
|
||||
<nav class="lg:hidden shrink-0 flex border-t border-default bg-sidebar" aria-label="하단 탭">
|
||||
<a href="/documents" aria-current={docsActive ? 'page' : undefined} class="flex-1 flex flex-col items-center justify-center gap-1 py-2 text-[10px] font-semibold transition-colors {docsActive ? 'text-accent' : 'text-dim'}"><FileText size={18} strokeWidth={1.9} /> 문서</a>
|
||||
<a href="/news" aria-current={newsActive ? 'page' : undefined} class="flex-1 flex flex-col items-center justify-center gap-1 py-2 text-[10px] font-semibold transition-colors {newsActive ? 'text-accent' : 'text-dim'}"><Newspaper size={18} strokeWidth={1.9} /> 뉴스</a>
|
||||
<a href="/ask" aria-current={isActive('/ask') ? 'page' : undefined} class="flex-1 flex flex-col items-center justify-center gap-1 py-2 text-[10px] font-semibold transition-colors {isActive('/ask') ? 'text-accent' : 'text-dim'}"><HelpCircle size={18} strokeWidth={1.9} /> 질문</a>
|
||||
<a href="/chat" aria-current={isActive('/chat') ? 'page' : undefined} class="flex-1 flex flex-col items-center justify-center gap-1 py-2 text-[10px] font-semibold transition-colors {isActive('/chat') ? 'text-accent' : 'text-dim'}"><MessageCircle size={18} strokeWidth={1.9} /> 이드</a>
|
||||
<a href="/memos" aria-current={isActive('/memos') ? 'page' : undefined} class="flex-1 flex flex-col items-center justify-center gap-1 py-2 text-[10px] font-semibold transition-colors {isActive('/memos') ? 'text-accent' : 'text-dim'}"><StickyNote size={18} strokeWidth={1.9} /> 메모</a>
|
||||
<button onclick={() => ui.openDrawer('sidebar')} class="flex-1 flex flex-col items-center justify-center gap-1 py-2 text-[10px] font-semibold text-dim"><Menu size={18} strokeWidth={1.9} /> 더보기</button>
|
||||
</nav>
|
||||
|
||||
@@ -8,8 +8,7 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import { api } from '$lib/api';
|
||||
import { addToast } from '$lib/stores/toast';
|
||||
import { Info, X, Plus, Trash2, Tag, FolderTree, Sparkles, ChevronLeft, ArrowUpDown } from 'lucide-svelte';
|
||||
import DocumentViewer from '$lib/components/DocumentViewer.svelte';
|
||||
import { X, Plus, Trash2, Tag, FolderTree, Sparkles, ArrowUpDown } from 'lucide-svelte';
|
||||
import MarkdownStatusBadge from '$lib/components/MarkdownStatusBadge.svelte';
|
||||
import { isMdStatusVisible } from '$lib/utils/mdStatus';
|
||||
import UploadDropzone from '$lib/components/UploadDropzone.svelte';
|
||||
@@ -233,15 +232,12 @@
|
||||
goto(`/documents${qs ? '?' + qs : ''}`, { noScroll: true });
|
||||
}
|
||||
|
||||
async function selectDoc(doc) {
|
||||
if (selectedDoc?.id === doc.id) { selectedDoc = null; return; }
|
||||
selectedDoc = doc; // 즉시 표시(리더 + 기본 인스펙터)
|
||||
// 인스펙터 풀 메타 하이드레이션 — 검색 결과(SearchResult)는 메타가 빈약(태그/크기/하위/md상태/읽음 없음).
|
||||
// 풀 문서를 조회해 채운다(기존 GET /documents/{id}, 백엔드 무변). 리스트 모드도 md상태 등 보강.
|
||||
try {
|
||||
const full = await api(`/documents/${doc.id}`);
|
||||
if (selectedDoc?.id === doc.id) selectedDoc = { ...doc, ...full };
|
||||
} catch { /* 실패 시 기본 정보 유지 */ }
|
||||
// 문서 열기 = 개선된 상세 페이지(D3 절 구조 탐색기)로 이동.
|
||||
// 사용자 결정: "개선된 페이지가 앞으로 표시되야지" — 인라인 미리보기 폐기.
|
||||
// /documents = 브라우즈/검색/필터/일괄 목록, 문서 열기 = /documents/[id] D3 리더.
|
||||
function selectDoc(doc) {
|
||||
if (!doc) return;
|
||||
goto(`/documents/${doc.id}`);
|
||||
}
|
||||
|
||||
// bulk 선택
|
||||
@@ -386,8 +382,8 @@
|
||||
|
||||
<div class="flex h-full min-h-0">
|
||||
|
||||
<!-- ═══ 좌: 리스트 컬럼 ═══ -->
|
||||
<div class="{selectedDoc ? 'hidden lg:flex' : 'flex'} flex-col w-full lg:w-[340px] lg:shrink-0 lg:border-r border-default min-h-0">
|
||||
<!-- ═══ 문서 목록 (풀폭 중앙) — 클릭 시 D3 상세로 이동 ═══ -->
|
||||
<div class="flex flex-col w-full max-w-5xl mx-auto min-h-0">
|
||||
<UploadDropzone onupload={loadDocuments} />
|
||||
|
||||
<!-- 검색바 -->
|
||||
@@ -487,6 +483,19 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- AI 답변 (질문형 검색) — 목록 상단 고정, 아래로 목록 스크롤 -->
|
||||
{#if showAskCard}
|
||||
<div class="px-3 py-2 shrink-0 border-b border-default max-h-[55vh] overflow-y-auto">
|
||||
<AskAnswerCard
|
||||
data={askData}
|
||||
loading={askLoading}
|
||||
error={askError}
|
||||
onCitationClick={(docId) => goto(`/documents/${docId}`)}
|
||||
onDismiss={() => { askDismissed = true; }}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- 선택 toolbar -->
|
||||
{#if selectionCount > 0}
|
||||
<div class="flex flex-wrap items-center gap-2 px-3 py-2 shrink-0 bg-accent/10 border-y border-accent/30">
|
||||
@@ -587,47 +596,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ 중앙: 리더 ═══ -->
|
||||
<div class="{selectedDoc ? 'flex' : 'hidden lg:flex'} flex-1 min-w-0 flex-col min-h-0">
|
||||
{#if selectedDoc}
|
||||
<!-- 리더 상단 바: (모바일) 뒤로 / (lg) 인스펙터 토글 -->
|
||||
<div class="flex items-center gap-2 px-3 py-1.5 shrink-0 border-b border-default bg-sidebar">
|
||||
<button type="button" onclick={() => { selectedDoc = null; if (ui.isDrawerOpen('meta')) ui.closeDrawer(); }}
|
||||
class="lg:hidden flex items-center gap-1 text-xs text-accent-hover font-medium" aria-label="목록으로">
|
||||
<ChevronLeft size={15} /> 문서
|
||||
</button>
|
||||
<div class="flex-1"></div>
|
||||
<button type="button" onclick={toggleInfoPanel} aria-pressed={isPanelActive} title="문서 정보"
|
||||
class="p-1.5 rounded-lg border transition-colors {isPanelActive ? 'border-accent text-accent bg-accent/10' : 'border-default text-dim hover:text-accent hover:border-accent'}">
|
||||
<Info size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex-1 min-h-0">
|
||||
<DocumentViewer doc={selectedDoc} />
|
||||
</div>
|
||||
{:else if showAskCard}
|
||||
<div class="p-4 lg:p-6 overflow-y-auto">
|
||||
<AskAnswerCard
|
||||
data={askData}
|
||||
loading={askLoading}
|
||||
error={askError}
|
||||
onCitationClick={(docId) => goto(`/documents/${docId}`)}
|
||||
onDismiss={() => { askDismissed = true; }}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="hidden lg:flex flex-1 items-center justify-center text-dim text-sm">
|
||||
왼쪽에서 문서를 선택하세요
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- ═══ 우: 인스펙터 (xl+ inline) ═══ -->
|
||||
{#if selectedDoc && inspectorOpen}
|
||||
<aside class="hidden xl:flex flex-col w-[300px] shrink-0 border-l border-default bg-sidebar overflow-y-auto" aria-label="문서 정보">
|
||||
{@render inspector(selectedDoc)}
|
||||
</aside>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- < xl 폴백: Drawer (정보 하단/측면 시트) -->
|
||||
|
||||
@@ -1,119 +1,90 @@
|
||||
<script>
|
||||
// Phase E.2 — detail 페이지 inline 편집.
|
||||
// 기존 read-only 메타 패널(L138–201)을 editors/* 스택으로 교체.
|
||||
// + E.3 관련 문서 stub, + 헤더 affordance row.
|
||||
// 문서 상세 /documents/[id] — 확정 시안(d3-deepened) 스타일을 그대로 포팅, 데이터만 바인딩.
|
||||
// 데스크탑: 상단 헤더 띠 + [좌 절 트리(색바+연결선)][중 절 집중 뷰][우 슬림 레일]. 절 없으면 fallback.
|
||||
// 모바일: 헤더 + 나란한 토글 pill(절구조|인사이트) + 본문 절 카드 연속(+탭 이동). 편집/필기/네비 보존.
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { api, getAccessToken } from '$lib/api';
|
||||
import { isMdSuccess } from '$lib/utils/mdStatus';
|
||||
import { resolveAnchorMap } from '$lib/utils/resolveAnchorMap';
|
||||
import { addToast } from '$lib/stores/toast';
|
||||
import { marked } from 'marked';
|
||||
import DOMPurify from 'dompurify';
|
||||
import { ExternalLink, Download, Link2, FileText, PenLine, X, ChevronLeft, ChevronRight, Check } from 'lucide-svelte';
|
||||
import { ChevronRight, FileText } from 'lucide-svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import Card from '$lib/components/ui/Card.svelte';
|
||||
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
||||
import Skeleton from '$lib/components/ui/Skeleton.svelte';
|
||||
import HandwriteCanvas from '$lib/components/HandwriteCanvas.svelte';
|
||||
import MarkdownDoc from '$lib/components/MarkdownDoc.svelte';
|
||||
import { renderDocMarkdown } from '$lib/utils/docMarkdown';
|
||||
import MarkdownStatusBadge from '$lib/components/MarkdownStatusBadge.svelte';
|
||||
import NoteEditor from '$lib/components/editors/NoteEditor.svelte';
|
||||
import EditUrlEditor from '$lib/components/editors/EditUrlEditor.svelte';
|
||||
import TagsEditor from '$lib/components/editors/TagsEditor.svelte';
|
||||
import AIClassificationEditor from '$lib/components/editors/AIClassificationEditor.svelte';
|
||||
import FileInfoView from '$lib/components/editors/FileInfoView.svelte';
|
||||
import ProcessingStatusView from '$lib/components/editors/ProcessingStatusView.svelte';
|
||||
import LibraryPathEditor from '$lib/components/editors/LibraryPathEditor.svelte';
|
||||
import DocumentDangerZone from '$lib/components/editors/DocumentDangerZone.svelte';
|
||||
import AnalysisPanel from '$lib/components/AnalysisPanel.svelte';
|
||||
import ReadCounter from '$lib/components/ReadCounter.svelte';
|
||||
import SectionOutline from '$lib/components/SectionOutline.svelte';
|
||||
import Tabs from '$lib/components/ui/Tabs.svelte';
|
||||
import { untrack } from 'svelte';
|
||||
import { cleanHeading, pathSegments, sectionTypeLabel, collapseWindows, partitionOutlineItems, partGroupViews, groupKeyByChunkId } from '$lib/utils/headingPath';
|
||||
import { domainLabel } from '$lib/utils/domainSlug';
|
||||
|
||||
marked.use({ mangle: false, headerIds: false });
|
||||
function renderMd(text) {
|
||||
return DOMPurify.sanitize(marked(text), {
|
||||
USE_PROFILES: { html: true },
|
||||
FORBID_TAGS: ['style', 'script'],
|
||||
FORBID_ATTR: ['onerror', 'onclick'],
|
||||
ALLOW_UNKNOWN_PROTOCOLS: false,
|
||||
return DOMPurify.sanitize(marked(text || ''), {
|
||||
USE_PROFILES: { html: true }, FORBID_TAGS: ['style', 'script'], FORBID_ATTR: ['onerror', 'onclick'], ALLOW_UNKNOWN_PROTOCOLS: false,
|
||||
});
|
||||
}
|
||||
|
||||
let doc = $state(null);
|
||||
let loading = $state(true);
|
||||
let error = $state(null); // 'not_found' | 'network' | null
|
||||
let rawMarkdown = $state(''); // fallback: extracted_text 없을 때 원본 .md
|
||||
|
||||
let error = $state(null);
|
||||
let rawMarkdown = $state('');
|
||||
let docId = $derived($page.params.id);
|
||||
|
||||
// 손글씨 노트 (자료별 1:1) — "필기" 토글 시 사이드 캔버스 띄움.
|
||||
// 필기
|
||||
let noteOpen = $state(false);
|
||||
let noteStrokes = $state(null); // { version, strokes }
|
||||
let noteStrokes = $state(null);
|
||||
let noteLoaded = $state(false);
|
||||
async function ensureNoteLoaded() {
|
||||
if (noteLoaded) return;
|
||||
try {
|
||||
const r = await api(`/documents/${docId}/note`);
|
||||
noteStrokes = r.strokes_json && r.strokes_json.strokes ? r.strokes_json : { version: 1, strokes: [] };
|
||||
} catch {
|
||||
noteStrokes = { version: 1, strokes: [] };
|
||||
}
|
||||
try { const r = await api(`/documents/${docId}/note`); noteStrokes = r.strokes_json && r.strokes_json.strokes ? r.strokes_json : { version: 1, strokes: [] }; }
|
||||
catch { noteStrokes = { version: 1, strokes: [] }; }
|
||||
noteLoaded = true;
|
||||
}
|
||||
async function saveNote(strokesJson) {
|
||||
try {
|
||||
await api(`/documents/${docId}/note`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ strokes_json: strokesJson }),
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn('필기 저장 실패', err);
|
||||
}
|
||||
}
|
||||
async function toggleNote() {
|
||||
if (!noteOpen) await ensureNoteLoaded();
|
||||
noteOpen = !noteOpen;
|
||||
}
|
||||
async function saveNote(s) { try { await api(`/documents/${docId}/note`, { method: 'PUT', body: JSON.stringify({ strokes_json: s }) }); } catch (e) { console.warn(e); } }
|
||||
async function toggleNote() { if (!noteOpen) await ensureNoteLoaded(); noteOpen = !noteOpen; }
|
||||
|
||||
// 인접 자료 (같은 library_path 내 이전/다음) — 학습 흐름 네비게이션
|
||||
// 인접 자료
|
||||
let neighbors = $state({ prev: null, next: null });
|
||||
async function loadNeighbors() {
|
||||
try {
|
||||
neighbors = await api(`/documents/${docId}/library-neighbors`);
|
||||
} catch {
|
||||
neighbors = { prev: null, next: null };
|
||||
}
|
||||
async function loadNeighbors() { try { neighbors = await api(`/documents/${docId}/library-neighbors`); } catch { neighbors = { prev: null, next: null }; } }
|
||||
async function readAndGoNext() {
|
||||
try { await api(`/documents/${docId}/read`, { method: 'POST' }); addToast('success', '1회독 완료'); }
|
||||
catch (err) { addToast('error', err?.detail || '회독 기록 실패'); return; }
|
||||
if (neighbors.next) goto(`/documents/${neighbors.next.id}`);
|
||||
}
|
||||
|
||||
// 절(hier section) 목차 — 본문 로드와 독립, 실패(404 포함) 무해.
|
||||
// reqId guard: 문서 전환 race 시 stale 결과가 새 문서에 붙지 않게.
|
||||
// 절 목차
|
||||
let sections = $state([]);
|
||||
let hasSections = $derived(sections.length > 0);
|
||||
// 과대 절은 builder 가 window 조각(같은 제목·is_leaf)으로 분해하고 부모를 heading 만 남긴 split-parent 로
|
||||
// 강등한다(예: 5180 = 27개 논리 절 → 562 window). raw sections 를 그대로 그리면 동일 제목 수백 행으로
|
||||
// 파편화되므로, collapseWindows 로 논리 절 1개(대표=split-parent, bodyText=window 본문 합본)로 합친다.
|
||||
let outline = $derived(collapseWindows(sections));
|
||||
// Part 접이 트리(ASME 등 hasParts): 같은 outline 인스턴스를 front-matter/PART 로 재배치(재-collapse 없음
|
||||
// → selectedSectionId/focusView 정합). flat 1030 → front-matter 단일그룹 + ~14 PART 접이. (D8)
|
||||
let treePart = $derived(partitionOutlineItems(outline));
|
||||
let treeGroups = $derived(treePart.hasParts ? partGroupViews(treePart) : null);
|
||||
let treeGroupIndex = $derived(treeGroups ? groupKeyByChunkId(treeGroups) : null);
|
||||
let treeExpanded = $state({}); // key 없으면 접힘(기본 전부 접힘). Svelte5 deep-proxy 반응형.
|
||||
function toggleTreeGroup(key) { treeExpanded[key] = !treeExpanded[key]; }
|
||||
// sections 로딩 완료 플래그 — 미완 동안 fallback 풀-문서 뷰어를 띄우면, 곧 절뷰로 교체되며
|
||||
// 풀-문서 이미지가 '살짝 보였다 사라지는' 플래시가 난다(절 보유 문서). 로딩 중엔 skeleton.
|
||||
let sectionsLoaded = $state(false);
|
||||
async function loadSections() {
|
||||
const reqId = docId;
|
||||
try {
|
||||
const r = await api(`/documents/${reqId}/sections`);
|
||||
if (reqId === docId) sections = r?.sections ?? [];
|
||||
} catch {
|
||||
if (reqId === docId) sections = []; // Phase 1 미배포 시 404 → 목차 숨김(graceful)
|
||||
}
|
||||
}
|
||||
|
||||
// "1회독 완료 + 다음 자료로" 한 번에
|
||||
async function readAndGoNext() {
|
||||
try {
|
||||
await api(`/documents/${docId}/read`, { method: 'POST' });
|
||||
addToast('success', '1회독 완료');
|
||||
} catch (err) {
|
||||
addToast('error', err?.detail || '회독 기록 실패');
|
||||
return;
|
||||
}
|
||||
if (neighbors.next) {
|
||||
goto(`/documents/${neighbors.next.id}`);
|
||||
}
|
||||
try { const r = await api(`/documents/${reqId}/sections`); if (reqId === docId) sections = r?.sections ?? []; }
|
||||
catch { if (reqId === docId) sections = []; }
|
||||
finally { if (reqId === docId) sectionsLoaded = true; }
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
@@ -121,87 +92,26 @@
|
||||
doc = await api(`/documents/${docId}`);
|
||||
const vt = doc.source_channel === 'news' ? 'article' : getViewerType(doc.file_format);
|
||||
if ((vt === 'markdown' || vt === 'hwp-markdown') && !doc.extracted_text) {
|
||||
try {
|
||||
const resp = await fetch(`/api/documents/${docId}/file?token=${getAccessToken()}`);
|
||||
if (resp.ok) rawMarkdown = await resp.text();
|
||||
} catch (e) {
|
||||
rawMarkdown = '';
|
||||
}
|
||||
try { const resp = await fetch(`/api/documents/${docId}/file?token=${getAccessToken()}`); if (resp.ok) rawMarkdown = await resp.text(); } catch { rawMarkdown = ''; }
|
||||
}
|
||||
} catch (err) {
|
||||
error = err?.status === 404 ? 'not_found' : 'network';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
// 자료실 자료면 인접 자료 미리 fetch (학습 흐름 네비)
|
||||
} catch (err) { error = err?.status === 404 ? 'not_found' : 'network'; }
|
||||
finally { loading = false; }
|
||||
if (doc && doc.category === 'library') loadNeighbors();
|
||||
if (doc) loadSections();
|
||||
});
|
||||
|
||||
let viewerType = $derived(
|
||||
doc ? (doc.source_channel === 'news' ? 'article' : getViewerType(doc.file_format)) : 'none'
|
||||
);
|
||||
let viewerType = $derived(doc ? (doc.source_channel === 'news' ? 'article' : getViewerType(doc.file_format)) : 'none');
|
||||
let canShowMarkdown = $derived(!!(isMdSuccess(doc?.md_status) && doc?.md_content?.trim()));
|
||||
// 절 본문은 청크 text(절별 원문)에서 오므로 md_content 성공/존재와 무관.
|
||||
// hasSections 만으로 절뷰 사용 → partial / 대형 split(md_content 5만 자 절단) 문서도 절뷰 표시.
|
||||
let useSectionView = $derived(hasSections);
|
||||
|
||||
// PDF 분기 전용: marker_worker 가 만든 canonical markdown 이 있으면 기본으로 그것을 보여줌.
|
||||
// Phase 1B 산출물의 95% 가 PDF 라 1D pilot 평가가 실사용 화면 기반이 되도록 markdown-first.
|
||||
// 사용자가 "PDF 원본" 토글하면 iframe. lastDocId 로 문서 전환만 감지해서 사용자 토글이
|
||||
// reactive cycle 에 덮이지 않도록 보호.
|
||||
let pdfViewMode = $state('markdown'); // 'markdown' | 'pdf'
|
||||
let pdfViewMode = $state('markdown');
|
||||
let lastDocId = $state(null);
|
||||
let canShowMarkdown = $derived(
|
||||
!!(isMdSuccess(doc?.md_status) && doc?.md_content?.trim())
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
if (!doc) return;
|
||||
if (doc.id !== lastDocId) {
|
||||
lastDocId = doc.id;
|
||||
pdfViewMode = canShowMarkdown ? 'markdown' : 'pdf';
|
||||
}
|
||||
// 같은 문서 안에서 markdown 이 사라지면 (success → failed 재처리 등) PDF 로 보호.
|
||||
if (!canShowMarkdown && pdfViewMode === 'markdown') {
|
||||
pdfViewMode = 'pdf';
|
||||
}
|
||||
});
|
||||
|
||||
// ── 개요 점프 (경로 B: BE char_start primary + string-match 폴백) ──
|
||||
// 이 사이트는 항상 md_content basis(canShowMarkdown && doc.md_content) → trustBE=true.
|
||||
// BE char_start 가 있으면 채택, 비면(non-PASS/미백필) resolveAnchorMap 내부에서 buildAnchorMap 로 폴백.
|
||||
let anchorMap = $derived(
|
||||
hasSections && canShowMarkdown && doc?.md_content
|
||||
? resolveAnchorMap(doc.md_content, sections, { trustBE: true }).anchors
|
||||
: {}
|
||||
);
|
||||
let activeKey = $state(null);
|
||||
function jumpToSection(chunkId) {
|
||||
const el = document.getElementById(`sec-${chunkId}`);
|
||||
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
// scroll-spy: 화면 상단(120px)을 지난 마지막 .md-anchor = 현재 절. [id] 는 window 스크롤.
|
||||
$effect(() => {
|
||||
void anchorMap; // 문서/섹션 변화 시 재바인딩
|
||||
if (typeof window === 'undefined') return;
|
||||
let raf = 0;
|
||||
const onScroll = () => {
|
||||
if (raf) return;
|
||||
raf = requestAnimationFrame(() => {
|
||||
raf = 0;
|
||||
let cur = null;
|
||||
document.querySelectorAll('.md-anchor').forEach((a) => {
|
||||
if (a.getBoundingClientRect().top <= 120) cur = a;
|
||||
});
|
||||
if (cur) {
|
||||
const m = cur.id.match(/^sec-(\d+)$/);
|
||||
if (m) activeKey = Number(m[1]);
|
||||
}
|
||||
});
|
||||
};
|
||||
window.addEventListener('scroll', onScroll, { passive: true });
|
||||
onScroll();
|
||||
return () => {
|
||||
window.removeEventListener('scroll', onScroll);
|
||||
if (raf) cancelAnimationFrame(raf);
|
||||
};
|
||||
if (doc.id !== lastDocId) { lastDocId = doc.id; pdfViewMode = canShowMarkdown ? 'markdown' : 'pdf'; }
|
||||
if (!canShowMarkdown && pdfViewMode === 'markdown') pdfViewMode = 'pdf';
|
||||
});
|
||||
|
||||
function getViewerType(format) {
|
||||
@@ -213,338 +123,425 @@
|
||||
return 'unsupported';
|
||||
}
|
||||
|
||||
// E.2 affordance row 핸들러
|
||||
// 절 집중/모바일 상태
|
||||
let selectedSectionId = $state(null);
|
||||
let mTree = $state(false);
|
||||
let mIns = $state(false);
|
||||
let manageOpen = $state(false);
|
||||
// 기본 선택 = 첫 본문 Part 의 첫 절(front-matter TOC 가 아니라 실제 내용으로 진입, front-matter 접힘 유지).
|
||||
let defaultSelId = $derived.by(() => {
|
||||
if (treeGroups) {
|
||||
const body = treeGroups.find((g) => !g.isFrontMatter);
|
||||
if (body && body.items.length) return body.items[0].section.chunk_id;
|
||||
}
|
||||
return outline[0]?.section.chunk_id ?? null;
|
||||
});
|
||||
$effect(() => { if (outline.length && !outline.some((it) => it.section.chunk_id === selectedSectionId)) selectedSectionId = defaultSelId; });
|
||||
// 문서가 바뀌면(sections 교체) Part 접이·모바일 본문 펼침 상태 리셋 — 문서 간 PART 라벨/chunk_id 가
|
||||
// 겹쳐 이전 상태가 이월되는 것 차단(기본 전부 접힘 보존). ※ 같은 컴포넌트 인스턴스로 client 네비 시
|
||||
// sections 가 재로딩될 때만 발화 — 현재 [id] 페이지는 onMount 1회 로딩이라 SPA prev/next 미reload 는
|
||||
// 선존 별도 이슈(D8 범위 밖, 사용자 보고 대상).
|
||||
$effect(() => {
|
||||
void sections;
|
||||
untrack(() => { treeExpanded = {}; mBodyOpen = {}; });
|
||||
});
|
||||
// 선택 절의 조상 Part 를 펼침(prev/next·딥링크 진입 시 트리에서 자동 노출). untrack=쓰기 자기재발화 차단.
|
||||
$effect(() => {
|
||||
const sel = selectedSectionId;
|
||||
const idx = treeGroupIndex;
|
||||
if (sel == null || !idx) return;
|
||||
const gk = idx.get(sel);
|
||||
if (gk) untrack(() => { treeExpanded[gk] = true; });
|
||||
});
|
||||
// selectedSectionId 미설정(초기) 시 defaultSelId(첫 본문 Part)로 바로 해석 — outline[0](표지/front-matter)
|
||||
// 를 잠깐 렌더했다 effect 가 defaultSelId 로 바꾸는 절뷰 내부 플래시 차단.
|
||||
let selectedItem = $derived(outline.find((it) => it.section.chunk_id === (selectedSectionId ?? defaultSelId)) ?? outline[0] ?? null);
|
||||
let selectedSection = $derived(selectedItem?.section ?? null);
|
||||
let selIdx = $derived(outline.findIndex((it) => it.section.chunk_id === selectedItem?.section?.chunk_id));
|
||||
// 절 본문 = 청크 원문(it.bodyText, window 조각 합본) 직접 렌더. 과거 char_start 로 md_content 를
|
||||
// 슬라이스했으나, 대형 split 문서는 md_content 가 앞 5만 자만 보존되고 char_start 도 NULL 이라 본문이
|
||||
// 비었다. 청크 text 는 절 전체를 담으므로(절 보유 문서 344개, 본문 합 평균 68KB·max 1.6MB) 그대로 렌더.
|
||||
function bodyHtml(it) { return it?.bodyText ? renderMd(it.bodyText) : ''; }
|
||||
let selectedBodyHtml = $derived(bodyHtml(selectedItem));
|
||||
// 모바일 연속 카드: 본문은 '본문 보기' 펼칠 때만 파싱(논리 절 수백 개 × marked 즉시 파싱 회피).
|
||||
let mBodyOpen = $state({});
|
||||
|
||||
// 절 유형 색 (시안: 정의 청 / 절차 올리브 / 요건 황)
|
||||
const TYPE_META = {
|
||||
definition: { label: '정의', en: 'definition', color: '#2f7d8f' },
|
||||
procedure: { label: '절차', en: 'procedure', color: '#7a8b3f' },
|
||||
requirement: { label: '요건', en: 'requirement', color: '#b5840a' },
|
||||
};
|
||||
function typeMeta(t) { return TYPE_META[t] ?? { label: sectionTypeLabel(t) || '', en: t || '', color: '#9aa090' }; }
|
||||
function isLowConf(c) { return c != null && c < 0.5; }
|
||||
function isMidLow(c) { return c != null && c < 0.6; }
|
||||
function confColor(c) { return c == null ? '#9aa090' : c < 0.6 ? '#b5840a' : '#1f9d6b'; }
|
||||
function secTitle(s) { return cleanHeading(s.section_title) || pathSegments(s.heading_path).at(-1) || '(제목 없음)'; }
|
||||
function secDepth(s) { return Math.max(0, (s.level ?? 1) - 1); }
|
||||
function confPct(c) { return c == null ? 0 : Math.round(c * 100); }
|
||||
|
||||
// 도메인 색 (시안 도메인 팔레트)
|
||||
const DOMAIN_COLOR = { Industrial_Safety: '#b5840a', Engineering: '#2f7d8f', Programming: '#3d7256', General: '#7a8b3f', Reference: '#8a6a3f', Philosophy: '#7a6a9b' };
|
||||
function domainColor(d) { return DOMAIN_COLOR[(d || '').split('/')[0]] ?? '#697061'; }
|
||||
function fmtColor(f) { return f === 'pdf' ? '#c0564a' : f === 'md' ? '#5a8f7a' : ['m4a', 'mp3', 'wav'].includes(f) ? '#8a6aa5' : f === 'html' ? '#c2911f' : '#697061'; }
|
||||
|
||||
let quality = $derived(doc?.md_extraction_quality?.metrics ?? doc?.md_extraction_quality ?? null);
|
||||
|
||||
function copyLink() {
|
||||
const url = `${window.location.origin}/documents/${docId}`;
|
||||
navigator.clipboard
|
||||
.writeText(url)
|
||||
.then(() => addToast('success', '링크 복사됨'))
|
||||
.catch(() => addToast('error', '복사 실패'));
|
||||
}
|
||||
|
||||
function downloadOriginal() {
|
||||
window.open(`/api/documents/${docId}/file?token=${getAccessToken()}&download=true`);
|
||||
}
|
||||
|
||||
function downloadPdf() {
|
||||
window.open(`/api/documents/${docId}/preview?token=${getAccessToken()}&download=true`);
|
||||
}
|
||||
|
||||
function handleDocDelete() {
|
||||
addToast('success', '문서가 삭제되어 목록으로 이동합니다.');
|
||||
goto('/documents');
|
||||
navigator.clipboard.writeText(`${window.location.origin}/documents/${docId}`).then(() => addToast('success', '링크 복사됨')).catch(() => addToast('error', '복사 실패'));
|
||||
}
|
||||
function downloadOriginal() { window.open(`/api/documents/${docId}/file?token=${getAccessToken()}&download=true`); }
|
||||
function handleDocDelete() { addToast('success', '문서가 삭제되어 목록으로 이동합니다.'); goto('/documents'); }
|
||||
</script>
|
||||
|
||||
<div class="p-4 lg:p-6">
|
||||
<!-- ════ 좌 트리 (시안: 색바 + 연결선 + 활성 + 저신뢰 경고) ════ -->
|
||||
{#snippet treeNav(jumpMode)}
|
||||
<div class="d3tree" style="font-size:14px;">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:9px;">
|
||||
<div style="font-size:12px;font-weight:700;color:#697061;letter-spacing:.4px;">절 구조</div>
|
||||
<span style="font-size:10.5px;color:#9aa090;font-variant-numeric:tabular-nums;">{outline.length}절</span>
|
||||
</div>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:6px 8px;margin-bottom:11px;padding-bottom:10px;border-bottom:1px solid #dde3d6;">
|
||||
<span style="display:inline-flex;align-items:center;gap:4px;font-size:10px;color:#697061;"><span style="width:8px;height:8px;border-radius:2px;background:#2f7d8f;"></span>정의</span>
|
||||
<span style="display:inline-flex;align-items:center;gap:4px;font-size:10px;color:#697061;"><span style="width:8px;height:8px;border-radius:2px;background:#7a8b3f;"></span>절차</span>
|
||||
<span style="display:inline-flex;align-items:center;gap:4px;font-size:10px;color:#697061;"><span style="width:8px;height:8px;border-radius:2px;background:#b5840a;"></span>요건</span>
|
||||
</div>
|
||||
{#snippet treeNode(it)}
|
||||
{@const s = it.section}
|
||||
{@const tm = typeMeta(it.sectionType)}
|
||||
{@const active = !jumpMode && s.chunk_id === selectedSection?.chunk_id}
|
||||
{@const child = secDepth(s) > 0}
|
||||
{@const low = isMidLow(it.confidence)}
|
||||
<svelte:element this={jumpMode ? 'a' : 'div'} href={jumpMode ? `#m-sec-${s.chunk_id}` : undefined}
|
||||
role={jumpMode ? undefined : 'button'} tabindex={jumpMode ? undefined : 0}
|
||||
onclick={() => !jumpMode && (selectedSectionId = s.chunk_id)}
|
||||
onkeydown={(e) => { if (!jumpMode && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); selectedSectionId = s.chunk_id; } }}
|
||||
class="d3node {child ? 'd3child' : ''} {active ? 'd3active' : ''}"
|
||||
style="display:block;border:1px solid {active ? '#4f8a6b' : low ? '#e7d49a' : 'transparent'};border-radius:9px;padding:{child ? '6px 8px' : '7px 8px'};margin-bottom:2px;{low ? 'background:#fbf6e6;' : ''}text-decoration:none;cursor:pointer;">
|
||||
<div style="display:flex;align-items:center;gap:7px;">
|
||||
<span style="width:3px;height:{child ? '13px' : '16px'};border-radius:2px;background:{tm.color};flex-shrink:0;"></span>
|
||||
<span class="d3title" style="font-size:{child ? '11.5px' : '12.5px'};flex:1;min-width:0;{child ? 'color:#697061;' : ''}{active ? 'color:#3d7256;font-weight:600;' : ''}overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">{secTitle(s)}</span>
|
||||
{#if low}
|
||||
<span class="d3warn" title="저신뢰 절" style="display:inline-flex;width:14px;height:14px;border-radius:50%;background:#b5840a;color:#fff;align-items:center;justify-content:center;font-size:9px;font-weight:700;flex-shrink:0;">!</span>
|
||||
{:else if !child}
|
||||
<span title="신뢰도 {it.confidence != null ? it.confidence.toFixed(2) : '—'}" style="width:7px;height:7px;border-radius:50%;background:{confColor(it.confidence)};flex-shrink:0;"></span>
|
||||
{/if}
|
||||
</div>
|
||||
</svelte:element>
|
||||
{/snippet}
|
||||
|
||||
{#if treeGroups}
|
||||
<!-- Part 접이(ASME 등): front-matter 단일그룹 + PART 접이, 기본 접힘. 선택/딥링크 시 조상 Part auto-expand. -->
|
||||
{#each treeGroups as g (g.key)}
|
||||
{@const isOpen = !!treeExpanded[g.key]}
|
||||
<button type="button" class="d3grp" aria-expanded={isOpen} onclick={() => toggleTreeGroup(g.key)}
|
||||
style="display:flex;align-items:center;gap:7px;width:100%;text-align:left;background:none;border:none;cursor:pointer;border-radius:8px;padding:6px 8px;margin:4px 0 1px;">
|
||||
<span style="transition:transform .16s;transform:rotate({isOpen ? 90 : 0}deg);color:#9aa090;font-weight:700;font-size:12px;flex-shrink:0;">›</span>
|
||||
<span style="flex:1;min-width:0;font-size:11px;font-weight:700;color:{g.isFrontMatter ? '#9aa090' : '#697061'};letter-spacing:.3px;text-transform:uppercase;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">{g.label}</span>
|
||||
<span style="font-size:10px;color:#9aa090;font-variant-numeric:tabular-nums;flex-shrink:0;">{g.items.length}</span>
|
||||
</button>
|
||||
{#if isOpen}
|
||||
{#each g.items as it (it.section.chunk_id)}{@render treeNode(it)}{/each}
|
||||
{/if}
|
||||
{/each}
|
||||
{:else}
|
||||
{#each outline as it (it.section.chunk_id)}{@render treeNode(it)}{/each}
|
||||
{/if}
|
||||
{#if quality}
|
||||
<div style="margin-top:12px;padding-top:10px;border-top:1px solid #dde3d6;">
|
||||
<div style="font-size:10.5px;font-weight:700;color:#697061;margin-bottom:7px;letter-spacing:.3px;">추출 품질</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:5px;font-size:10.5px;color:#697061;font-variant-numeric:tabular-nums;">
|
||||
{#if quality.headings != null}<span>headings <b style="color:#23291f;">{quality.headings}</b></span>{/if}
|
||||
{#if quality.tables != null}<span>tables <b style="color:#23291f;">{quality.tables}</b></span>{/if}
|
||||
{#if quality.images != null}<span>images <b style="color:#23291f;">{quality.images}</b></span>{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<!-- ════ 절 집중 뷰 (데스크탑 중앙) ════ -->
|
||||
{#snippet focusView()}
|
||||
{#if selectedSection}
|
||||
{@const tm = typeMeta(selectedItem?.sectionType)}
|
||||
{@const conf = selectedItem?.confidence ?? null}
|
||||
{@const summaries = selectedItem?.summaries ?? []}
|
||||
<div style="display:flex;align-items:center;gap:6px;font-size:12px;color:#9aa090;margin-bottom:12px;flex-wrap:wrap;">
|
||||
<span class="truncate" style="max-width:200px;">{doc.title}</span>
|
||||
{#each pathSegments(selectedSection.heading_path) as seg}<span style="color:#c8d6c0;">/</span><span style="color:#697061;font-weight:600;">{seg}</span>{/each}
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:9px;flex-wrap:wrap;margin-bottom:13px;">
|
||||
<h2 style="margin:0;font-size:22px;font-weight:700;color:#23291f;line-height:1.3;flex:1;min-width:180px;">{secTitle(selectedSection)}</h2>
|
||||
{#if tm.label}<span style="display:inline-flex;align-items:center;gap:5px;padding:4px 11px;border-radius:999px;background:{tm.color}1a;border:1px solid {tm.color}55;font-size:12px;color:{tm.color};font-weight:600;"><span style="width:8px;height:8px;border-radius:2px;background:{tm.color};"></span>{tm.label} {tm.en}</span>{/if}
|
||||
</div>
|
||||
{#if conf != null}
|
||||
<div style="display:flex;align-items:center;gap:9px;margin-bottom:18px;">
|
||||
<span style="font-size:11px;color:#697061;font-weight:600;flex-shrink:0;">신뢰도</span>
|
||||
<div style="flex:1;max-width:300px;height:7px;border-radius:999px;background:#e3ebdf;overflow:hidden;"><div style="width:{confPct(conf)}%;height:100%;background:{confColor(conf)};border-radius:999px;"></div></div>
|
||||
<span style="font-size:13px;font-weight:700;color:{confColor(conf)};font-variant-numeric:tabular-nums;flex-shrink:0;">{conf.toFixed(2)}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if isLowConf(conf)}
|
||||
<div style="display:flex;align-items:flex-start;gap:8px;background:#faf3e2;border:1px solid #ecdca3;border-radius:10px;padding:10px 12px;margin-bottom:16px;font-size:12.5px;color:#8a6306;"><span style="flex-shrink:0;width:16px;height:16px;border-radius:50%;border:1.5px solid #b5840a;color:#b5840a;font-size:10px;font-weight:800;display:inline-flex;align-items:center;justify-content:center;margin-top:1px;">!</span><span>저신뢰 절 — 표·수식 추출이 불완전할 수 있습니다. 정확한 내용은 원본을 확인하세요.</span></div>
|
||||
{/if}
|
||||
{#if summaries.length}
|
||||
<div style="background:#ecf0e8;border-left:3px solid #4f8a6b;border-radius:0 10px 10px 0;padding:14px 16px;margin-bottom:20px;">
|
||||
<div style="font-size:10.5px;font-weight:700;color:#3d7256;letter-spacing:.6px;margin-bottom:6px;">절 요약{#if summaries.length > 1} · {summaries.length}개 부분{/if}</div>
|
||||
{#if summaries.length === 1}
|
||||
<div style="font-size:15.5px;line-height:1.6;color:#23291f;white-space:pre-line;">{summaries[0]}</div>
|
||||
{:else}
|
||||
<ul style="margin:0;padding:0;list-style:none;display:flex;flex-direction:column;gap:8px;">
|
||||
{#each summaries as sm, i}<li style="font-size:13.5px;line-height:1.55;color:#23291f;display:flex;gap:8px;"><span style="flex-shrink:0;color:#7a8b3f;font-weight:700;font-variant-numeric:tabular-nums;">{i + 1}</span><span style="white-space:pre-line;">{sm}</span></li>{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#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}
|
||||
<div style="display:flex;justify-content:space-between;gap:10px;margin-top:20px;padding-top:14px;border-top:1px solid #dde3d6;">
|
||||
{#if selIdx > 0}
|
||||
{@const pv = outline[selIdx - 1].section}
|
||||
<button type="button" onclick={() => (selectedSectionId = pv.chunk_id)} style="font-size:12px;color:#697061;border:1px solid #dde3d6;border-radius:9px;padding:8px 12px;background:#fff;cursor:pointer;">← {secTitle(pv)}</button>
|
||||
{:else}<span></span>{/if}
|
||||
{#if selIdx >= 0 && selIdx < outline.length - 1}
|
||||
{@const nxIt = outline[selIdx + 1]}
|
||||
{@const nx = nxIt.section}
|
||||
<button type="button" onclick={() => (selectedSectionId = nx.chunk_id)} style="font-size:12px;color:{isMidLow(nxIt.confidence) ? '#8a6306' : '#697061'};border:1px solid {isMidLow(nxIt.confidence) ? '#e7d49a' : '#dde3d6'};border-radius:9px;padding:8px 12px;background:#fff;cursor:pointer;display:inline-flex;align-items:center;gap:6px;">{#if isMidLow(nxIt.confidence)}<span style="display:inline-flex;width:13px;height:13px;border-radius:50%;background:#b5840a;color:#fff;align-items:center;justify-content:center;font-size:8px;font-weight:700;">!</span>{/if}{secTitle(nx)} →</button>
|
||||
{:else}<span></span>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
<!-- ════ 우 슬림 레일 (시안 카드 스타일) ════ -->
|
||||
{#snippet rail()}
|
||||
<div style="display:flex;flex-direction:column;gap:11px;font-size:14px;">
|
||||
{#if doc.ai_tldr || doc.ai_summary}
|
||||
<div style="background:#f4f7f1;border:1px solid #dde3d6;border-radius:14px;padding:13px;">
|
||||
<div style="font-size:10.5px;font-weight:700;color:#697061;letter-spacing:.4px;margin-bottom:7px;">TL;DR</div>
|
||||
<div class="summary-md" style="font-size:12px;line-height:1.5;color:#23291f;">{@html renderDocMarkdown(doc.ai_tldr || doc.ai_summary)}</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if doc.ai_bullets && doc.ai_bullets.length}
|
||||
<div style="background:#f4f7f1;border:1px solid #dde3d6;border-radius:14px;padding:13px;">
|
||||
<div style="font-size:10.5px;font-weight:700;color:#697061;letter-spacing:.4px;margin-bottom:8px;">핵심점</div>
|
||||
<ul style="margin:0;padding:0;list-style:none;display:flex;flex-direction:column;gap:7px;">
|
||||
{#each doc.ai_bullets as b}<li style="font-size:12px;line-height:1.4;display:flex;gap:6px;"><span style="color:#b5840a;font-weight:700;flex-shrink:0;">·</span><span style="flex:1;min-width:0;color:#23291f;">{b}</span></li>{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
{#if doc.ai_detail_summary}
|
||||
<div style="background:#f4f7f1;border:1px solid #c8d6c0;border-radius:14px;padding:13px;">
|
||||
<div style="display:flex;align-items:center;gap:6px;margin-bottom:7px;">
|
||||
<span style="font-size:10.5px;font-weight:700;color:#3d7256;letter-spacing:.4px;">심층</span>
|
||||
{#if doc.ai_analysis_tier === 'deep'}<span style="font-size:9px;color:#fff;background:#4f8a6b;border-radius:999px;padding:1px 7px;font-weight:600;">DEEP</span>{/if}
|
||||
</div>
|
||||
<div style="font-size:11.5px;line-height:1.5;color:#23291f;white-space:pre-line;">{doc.ai_detail_summary}</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if doc.ai_inconsistencies && doc.ai_inconsistencies.length}
|
||||
<div style="background:#fbf6e6;border:1px solid #e7d49a;border-radius:14px;padding:13px;">
|
||||
<div style="font-size:10.5px;font-weight:700;color:#8a6306;letter-spacing:.4px;margin-bottom:7px;">불일치 {doc.ai_inconsistencies.length}</div>
|
||||
<ul style="margin:0;padding:0;list-style:none;display:flex;flex-direction:column;gap:5px;">{#each doc.ai_inconsistencies as inc}<li style="font-size:11.5px;line-height:1.45;color:#23291f;">· {typeof inc === 'string' ? inc : inc.desc || inc.kind}</li>{/each}</ul>
|
||||
</div>
|
||||
{/if}
|
||||
{#if doc.ai_domain}
|
||||
<div style="background:#f4f7f1;border:1px solid #dde3d6;border-radius:14px;padding:13px;">
|
||||
<div style="font-size:10.5px;font-weight:700;color:#697061;letter-spacing:.4px;margin-bottom:8px;">분류</div>
|
||||
<div style="display:flex;flex-direction:column;gap:6px;font-size:11.5px;">
|
||||
<div style="display:flex;justify-content:space-between;gap:8px;"><span style="color:#697061;">도메인</span><span style="display:inline-flex;align-items:center;gap:5px;color:#23291f;font-weight:600;text-align:right;"><span style="width:7px;height:7px;border-radius:50%;background:{domainColor(doc.ai_domain)};"></span>{domainLabel(doc.ai_domain)}</span></div>
|
||||
{#if doc.ai_sub_group}<div style="display:flex;justify-content:space-between;gap:8px;"><span style="color:#697061;">하위</span><span style="color:#23291f;font-weight:600;">{doc.ai_sub_group}</span></div>{/if}
|
||||
{#if doc.ai_analysis_tier}<div style="display:flex;justify-content:space-between;gap:8px;"><span style="color:#697061;">tier</span><span style="color:#3d7256;font-weight:600;">{doc.ai_analysis_tier}</span></div>{/if}
|
||||
{#if doc.ai_confidence != null}<div style="display:flex;justify-content:space-between;gap:8px;"><span style="color:#697061;">신뢰도</span><span style="color:#1f9d6b;font-weight:700;font-variant-numeric:tabular-nums;">{doc.ai_confidence.toFixed(2)}</span></div>{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if doc.ai_tags && doc.ai_tags.length}
|
||||
<div style="background:#f4f7f1;border:1px solid #dde3d6;border-radius:14px;padding:13px;">
|
||||
<div style="font-size:10.5px;font-weight:700;color:#697061;letter-spacing:.4px;margin-bottom:8px;">태그</div>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:5px;">{#each doc.ai_tags as t}<span style="font-size:11px;padding:3px 8px;border-radius:999px;background:#fff;border:1px solid #dde3d6;color:#697061;">{t}</span>{/each}</div>
|
||||
</div>
|
||||
{/if}
|
||||
<div style="background:#f4f7f1;border:1px solid #dde3d6;border-radius:14px;padding:13px;">
|
||||
<div style="font-size:10.5px;font-weight:700;color:#697061;letter-spacing:.4px;margin-bottom:6px;">관련 문서</div>
|
||||
<div style="font-size:11px;color:#9aa090;line-height:1.5;">벡터 유사도 기반 — 준비 중</div>
|
||||
</div>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<!-- ════ 절 카드 (모바일 연속 본문) ════ -->
|
||||
{#snippet sectionCard(it)}
|
||||
{@const s = it.section}
|
||||
{@const tm = typeMeta(it.sectionType)}
|
||||
<div id="m-sec-{s.chunk_id}" style="scroll-margin-top:12px;background:#f4f7f1;border:1px solid {isLowConf(it.confidence) ? '#e7d49a' : '#dde3d6'};border-radius:14px;padding:14px 15px;">
|
||||
<div style="display:flex;align-items:center;gap:7px;margin-bottom:7px;">
|
||||
<h2 style="margin:0;font-size:16px;font-weight:700;color:#23291f;flex:1;min-width:0;line-height:1.3;">{secTitle(s)}</h2>
|
||||
{#if tm.label}<span style="flex-shrink:0;font-size:10.5px;font-weight:650;padding:2px 8px;border-radius:999px;background:{tm.color}1a;color:{tm.color};white-space:nowrap;">{tm.label}</span>{/if}
|
||||
</div>
|
||||
{#if isLowConf(it.confidence)}
|
||||
<div style="display:flex;align-items:flex-start;gap:7px;background:#faf3e2;border:1px solid #ecdca3;border-radius:9px;padding:8px 10px;margin-bottom:10px;font-size:12px;color:#8a6306;"><span style="flex-shrink:0;width:15px;height:15px;border-radius:50%;border:1.5px solid #b5840a;color:#b5840a;font-size:10px;font-weight:800;display:inline-flex;align-items:center;justify-content:center;margin-top:1px;">!</span><span>저신뢰 — 표·수식 추출 불완전, 원본 확인 권장</span></div>
|
||||
{/if}
|
||||
{#if it.summaries.length}
|
||||
<div style="border-left:3px solid #4f8a6b;background:#ecf0e8;border-radius:0 8px 8px 0;padding:9px 12px;margin-bottom:12px;">
|
||||
<div style="font-size:9.5px;font-weight:700;color:#3d7256;letter-spacing:.5px;margin-bottom:3px;">절 요약{#if it.summaries.length > 1} · {it.summaries.length}개 부분{/if}</div>
|
||||
{#if it.summaries.length === 1}
|
||||
<div style="font-size:13.5px;line-height:1.55;color:#23291f;white-space:pre-line;">{it.summaries[0]}</div>
|
||||
{:else}
|
||||
<ul style="margin:0;padding:0;list-style:none;display:flex;flex-direction:column;gap:6px;">{#each it.summaries as sm, i}<li style="font-size:12.5px;line-height:1.5;color:#23291f;display:flex;gap:6px;"><span style="flex-shrink:0;color:#7a8b3f;font-weight:700;font-variant-numeric:tabular-nums;">{i + 1}</span><span style="white-space:pre-line;">{sm}</span></li>{/each}</ul>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#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 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>
|
||||
{/snippet}
|
||||
|
||||
<div style="background:#e7ebe4;min-height:100%;" class="p-4 lg:p-6">
|
||||
<div style="max-width:1360px;margin:0 auto;">
|
||||
<!-- breadcrumb -->
|
||||
<div class="flex items-center gap-2 text-sm mb-4 text-dim">
|
||||
<a href="/documents" class="hover:text-text">문서</a>
|
||||
<span class="text-faint">/</span>
|
||||
<div class="flex items-center gap-2 text-sm mb-3 text-dim">
|
||||
<a href="/documents" class="hover:text-text">문서</a><span class="text-faint">/</span>
|
||||
<span class="truncate max-w-md text-text">{doc?.title || '로딩...'}</span>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="max-w-6xl mx-auto">
|
||||
<Skeleton h="h-96" rounded="card" />
|
||||
</div>
|
||||
<Skeleton h="h-96" rounded="card" />
|
||||
{:else if error === 'not_found'}
|
||||
<EmptyState
|
||||
icon={FileText}
|
||||
title="문서를 찾을 수 없습니다"
|
||||
description="삭제되었거나 접근 권한이 없을 수 있습니다."
|
||||
>
|
||||
<Button variant="ghost" size="sm" href="/documents">목록으로 돌아가기</Button>
|
||||
</EmptyState>
|
||||
<EmptyState icon={FileText} title="문서를 찾을 수 없습니다" description="삭제되었거나 접근 권한이 없을 수 있습니다."><Button variant="ghost" size="sm" href="/documents">목록으로</Button></EmptyState>
|
||||
{:else if error === 'network'}
|
||||
<EmptyState
|
||||
icon={FileText}
|
||||
title="문서를 불러올 수 없습니다"
|
||||
description="네트워크 오류가 발생했습니다."
|
||||
>
|
||||
<Button variant="secondary" size="sm" onclick={() => location.reload()}>다시 시도</Button>
|
||||
</EmptyState>
|
||||
<EmptyState icon={FileText} title="문서를 불러올 수 없습니다" description="네트워크 오류"><Button variant="secondary" size="sm" onclick={() => location.reload()}>다시 시도</Button></EmptyState>
|
||||
{:else if doc}
|
||||
<div class="mx-auto grid grid-cols-1 gap-6 {hasSections ? 'max-w-7xl xl:grid-cols-[18rem_minmax(0,1fr)_20rem]' : 'max-w-6xl lg:grid-cols-3'}">
|
||||
{#if hasSections}
|
||||
<!-- 좌측 절 목차 — xl+ sticky rail (그 아래 viewport 는 본문 상단 collapsible) -->
|
||||
<aside class="hidden xl:block xl:sticky xl:top-6 xl:self-start xl:max-h-[calc(100vh-3rem)] xl:overflow-y-auto">
|
||||
<Card>
|
||||
<SectionOutline {sections} onJump={jumpToSection} {activeKey} />
|
||||
</Card>
|
||||
</aside>
|
||||
{/if}
|
||||
|
||||
<!-- 본문 (좌측 목차 없을 때 lg 2/3) -->
|
||||
<div class="{hasSections ? '' : 'lg:col-span-2'} space-y-4">
|
||||
{#if hasSections}
|
||||
<!-- xl 미만: 절 목차 접이식 -->
|
||||
<details class="xl:hidden">
|
||||
<summary class="cursor-pointer text-sm text-dim px-1 py-2 select-none">절 목차 ({sections.length})</summary>
|
||||
<Card class="mt-2"><SectionOutline {sections} onJump={jumpToSection} {activeKey} /></Card>
|
||||
</details>
|
||||
{/if}
|
||||
<!-- Affordance row -->
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
{#if doc.edit_url}
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon={ExternalLink}
|
||||
href={doc.edit_url}
|
||||
target="_blank"
|
||||
>
|
||||
Synology 편집
|
||||
</Button>
|
||||
{/if}
|
||||
<Button variant="secondary" size="sm" icon={Download} onclick={downloadOriginal}>
|
||||
원본 다운로드
|
||||
</Button>
|
||||
{#if doc.preview_status === 'ready'}
|
||||
<Button variant="secondary" size="sm" icon={FileText} onclick={downloadPdf}>
|
||||
PDF 다운로드
|
||||
</Button>
|
||||
{/if}
|
||||
<Button variant="secondary" size="sm" icon={Link2} onclick={copyLink}>
|
||||
링크 복사
|
||||
</Button>
|
||||
{#if doc.category === 'library'}
|
||||
<Button
|
||||
variant={noteOpen ? 'primary' : 'secondary'}
|
||||
size="sm"
|
||||
icon={noteOpen ? X : PenLine}
|
||||
onclick={toggleNote}
|
||||
>
|
||||
{noteOpen ? '필기 닫기' : '필기'}
|
||||
</Button>
|
||||
{/if}
|
||||
<!-- ════ 상단 띠: 문서 헤더 (시안) ════ -->
|
||||
<div style="background:#f4f7f1;border:1px solid #dde3d6;border-radius:14px;padding:16px 18px;margin-bottom:14px;">
|
||||
<div style="display:flex;align-items:flex-start;gap:13px;flex-wrap:wrap;">
|
||||
<div style="width:40px;height:40px;border-radius:10px;background:{fmtColor(doc.file_format)};color:#fff;display:flex;align-items:center;justify-content:center;font-weight:700;font-size:10.5px;letter-spacing:.5px;flex-shrink:0;text-transform:uppercase;">{doc.file_format}</div>
|
||||
<div style="flex:1;min-width:0;">
|
||||
<div style="font-size:17px;font-weight:700;line-height:1.35;color:#23291f;">{doc.title}</div>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:6px;margin-top:8px;align-items:center;">
|
||||
{#if doc.ai_domain}<span style="display:inline-flex;align-items:center;gap:5px;padding:3px 9px;border-radius:999px;background:#fff;border:1px solid #dde3d6;font-size:11.5px;color:#23291f;"><span style="width:7px;height:7px;border-radius:50%;background:{domainColor(doc.ai_domain)};"></span>{domainLabel(doc.ai_domain)}</span>{/if}
|
||||
{#if doc.ai_sub_group}<span style="padding:3px 9px;border-radius:999px;background:#fff;border:1px solid #dde3d6;font-size:11.5px;color:#697061;">{doc.ai_sub_group}</span>{/if}
|
||||
{#if doc.ai_analysis_tier === 'deep'}<span style="padding:3px 9px;border-radius:999px;background:#4f8a6b;color:#fff;font-size:11.5px;font-weight:600;letter-spacing:.3px;">tier DEEP</span>{/if}
|
||||
{#if doc.ai_confidence != null}<span style="padding:3px 9px;border-radius:999px;background:#e3ebdf;border:1px solid #c8d6c0;font-size:11.5px;color:#3d7256;font-variant-numeric:tabular-nums;">신뢰도 {doc.ai_confidence.toFixed(2)}</span>{/if}
|
||||
{#if canShowMarkdown}<span style="padding:3px 9px;border-radius:999px;background:#eafaf2;border:1px solid #b8e3cc;font-size:11.5px;color:#1f9d6b;">PDF→MD success</span>{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:6px;flex-shrink:0;flex-wrap:wrap;">
|
||||
{#if doc.edit_url}<button type="button" onclick={() => window.open(doc.edit_url, '_blank')} style="font-size:11.5px;color:#697061;border:1px solid #dde3d6;border-radius:8px;padding:5px 9px;background:#fff;cursor:pointer;">Synology</button>{/if}
|
||||
<button type="button" onclick={downloadOriginal} style="font-size:11.5px;color:#697061;border:1px solid #dde3d6;border-radius:8px;padding:5px 9px;background:#fff;cursor:pointer;">원본</button>
|
||||
<button type="button" onclick={copyLink} style="font-size:11.5px;color:#697061;border:1px solid #dde3d6;border-radius:8px;padding:5px 9px;background:#fff;cursor:pointer;">링크</button>
|
||||
{#if doc.category === 'library'}<button type="button" onclick={toggleNote} style="font-size:11.5px;color:{noteOpen ? '#fff' : '#697061'};border:1px solid {noteOpen ? '#4f8a6b' : '#dde3d6'};border-radius:8px;padding:5px 9px;background:{noteOpen ? '#4f8a6b' : '#fff'};cursor:pointer;">{noteOpen ? '필기 닫기' : '필기'}</button>{/if}
|
||||
<button type="button" onclick={() => (manageOpen = !manageOpen)} style="font-size:11.5px;color:#697061;border:1px solid #dde3d6;border-radius:8px;padding:5px 9px;background:#fff;cursor:pointer;">관리</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 뷰어 — 모바일 가독성: 본문 폰트 키우고 line-height 늘림 -->
|
||||
<Card class="min-h-[500px]">
|
||||
{#if !sectionsLoaded}
|
||||
<!-- sections 로딩 중: fallback 풀-문서(이미지)→절뷰 교체 플래시 방지용 skeleton -->
|
||||
<Skeleton h="h-96" rounded="card" />
|
||||
{:else if useSectionView}
|
||||
<!-- 데스크탑(xl+): 3영역 -->
|
||||
<div class="hidden xl:grid" style="grid-template-columns:252px minmax(0,1fr) 336px;gap:13px;align-items:start;">
|
||||
<div style="background:#f4f7f1;border:1px solid #dde3d6;border-radius:14px;padding:13px 11px;position:sticky;top:14px;max-height:calc(100vh - 2rem);overflow-y:auto;">{@render treeNav(false)}</div>
|
||||
<div style="min-width:0;"><div style="background:#f4f7f1;border:1px solid #dde3d6;border-radius:14px;padding:20px 22px;">{@render focusView()}</div></div>
|
||||
<div style="position:sticky;top:14px;">{@render rail()}</div>
|
||||
</div>
|
||||
|
||||
<!-- 모바일(<xl): 나란한 토글 pill + 패널 + 본문 연속 -->
|
||||
<div class="xl:hidden">
|
||||
<div style="display:flex;gap:8px;margin-bottom:10px;position:sticky;top:0;z-index:5;background:#e7ebe4;padding:6px 0;">
|
||||
<button type="button" onclick={() => (mTree = !mTree)} style="flex:1;display:flex;align-items:center;justify-content:space-between;gap:6px;border-radius:10px;padding:9px 12px;font-size:12.5px;font-weight:600;cursor:pointer;background:{mTree ? '#e3ebdf' : '#f4f7f1'};border:1px solid {mTree ? '#4f8a6b' : '#dde3d6'};color:{mTree ? '#23291f' : '#697061'};">절 구조 <span style="font-size:10px;color:#9aa090;font-weight:500;">{outline.length}절</span><span style="transition:transform .16s;transform:rotate({mTree ? 90 : 0}deg);color:#9aa090;font-weight:700;">›</span></button>
|
||||
<button type="button" onclick={() => (mIns = !mIns)} style="flex:1;display:flex;align-items:center;justify-content:space-between;gap:6px;border-radius:10px;padding:9px 12px;font-size:12.5px;font-weight:600;cursor:pointer;background:{mIns ? '#e3ebdf' : '#f4f7f1'};border:1px solid {mIns ? '#4f8a6b' : '#dde3d6'};color:{mIns ? '#23291f' : '#697061'};">인사이트<span style="transition:transform .16s;transform:rotate({mIns ? 90 : 0}deg);color:#9aa090;font-weight:700;">›</span></button>
|
||||
</div>
|
||||
{#if mTree}<div style="background:#f4f7f1;border:1px solid #dde3d6;border-radius:12px;padding:6px;margin-bottom:10px;">{@render treeNav(true)}</div>{/if}
|
||||
{#if mIns}<div style="background:#f4f7f1;border:1px solid #dde3d6;border-radius:12px;padding:13px 14px;margin-bottom:10px;">{@render rail()}</div>{/if}
|
||||
<!-- D8 스코프 한계(의도적): 모바일 본문은 전체 outline(~1030)을 연속 카드로 eager 마운트한다.
|
||||
Part 접이는 위 treeNav(앵커 점프 네비)에만 적용 — 본문 롱스크롤은 줄이지 않는다. 데스크탑은
|
||||
focusView 가 단일 절만 렌더하므로 무관. 모바일 본문 분할/가상화는 별 follow-up. -->
|
||||
<div style="display:flex;flex-direction:column;gap:10px;">{#each outline as it (it.section.chunk_id)}{@render sectionCard(it)}{/each}</div>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- 절 없음 fallback: 절이 없어도 인사이트는 항상 보이게 (모바일=인사이트 상단 / 데스크탑=우측 레일) -->
|
||||
{#snippet fbViewer()}
|
||||
<div style="min-width:0;background:#f4f7f1;border:1px solid #dde3d6;border-radius:14px;padding:18px 20px;min-height:360px;">
|
||||
{#if !hasSections && canShowMarkdown}<p style="font-size:11px;color:#9aa090;margin-bottom:12px;">이 문서는 절 분석이 없어 전체 본문으로 표시합니다. 위/옆 인사이트는 그대로 제공됩니다.</p>{/if}
|
||||
{#if viewerType === 'markdown' || viewerType === 'hwp-markdown'}
|
||||
<MarkdownDoc
|
||||
documentId={doc.id}
|
||||
mdContent={doc.md_content}
|
||||
mdFrontmatter={doc.md_frontmatter}
|
||||
mdStatus={doc.md_status}
|
||||
mdExtractionError={doc.md_extraction_error}
|
||||
mdExtractionQuality={doc.md_extraction_quality}
|
||||
anchorMap={anchorMap}
|
||||
extractedText={doc.extracted_text || rawMarkdown}
|
||||
class="prose prose-invert prose-base lg:prose-sm max-w-none"
|
||||
/>
|
||||
<MarkdownDoc documentId={doc.id} mdContent={doc.md_content} mdFrontmatter={doc.md_frontmatter} mdStatus={doc.md_status} mdExtractionError={doc.md_extraction_error} mdExtractionQuality={doc.md_extraction_quality} extractedText={doc.extracted_text || rawMarkdown} class="prose prose-base max-w-none" />
|
||||
{:else if viewerType === 'pdf'}
|
||||
<div class="mb-2 flex items-center gap-2">
|
||||
<MarkdownStatusBadge
|
||||
mdStatus={doc.md_status}
|
||||
mdExtractionError={doc.md_extraction_error}
|
||||
mdExtractionQuality={doc.md_extraction_quality}
|
||||
/>
|
||||
{#if canShowMarkdown}
|
||||
<Button
|
||||
size="sm"
|
||||
variant={pdfViewMode === 'markdown' ? 'primary' : 'secondary'}
|
||||
onclick={() => (pdfViewMode = 'markdown')}
|
||||
>
|
||||
Markdown
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={pdfViewMode === 'pdf' ? 'primary' : 'secondary'}
|
||||
onclick={() => (pdfViewMode = 'pdf')}
|
||||
>
|
||||
PDF 원본
|
||||
</Button>
|
||||
{/if}
|
||||
<MarkdownStatusBadge mdStatus={doc.md_status} mdExtractionError={doc.md_extraction_error} mdExtractionQuality={doc.md_extraction_quality} />
|
||||
{#if canShowMarkdown}<Button size="sm" variant={pdfViewMode === 'markdown' ? 'primary' : 'secondary'} onclick={() => (pdfViewMode = 'markdown')}>Markdown</Button><Button size="sm" variant={pdfViewMode === 'pdf' ? 'primary' : 'secondary'} onclick={() => (pdfViewMode = 'pdf')}>PDF 원본</Button>{/if}
|
||||
</div>
|
||||
{#if pdfViewMode === 'markdown' && canShowMarkdown}
|
||||
<MarkdownDoc
|
||||
documentId={doc.id}
|
||||
mdContent={doc.md_content}
|
||||
mdFrontmatter={doc.md_frontmatter}
|
||||
mdStatus={doc.md_status}
|
||||
mdExtractionError={doc.md_extraction_error}
|
||||
mdExtractionQuality={doc.md_extraction_quality}
|
||||
extractedText={doc.extracted_text}
|
||||
class="prose prose-invert prose-base lg:prose-sm max-w-none"
|
||||
/>
|
||||
{:else}
|
||||
<iframe
|
||||
src="/api/documents/{doc.id}/file?token={getAccessToken()}"
|
||||
class="w-full h-[80vh] rounded"
|
||||
title={doc.title}
|
||||
></iframe>
|
||||
{/if}
|
||||
<MarkdownDoc documentId={doc.id} mdContent={doc.md_content} mdFrontmatter={doc.md_frontmatter} mdStatus={doc.md_status} mdExtractionError={doc.md_extraction_error} mdExtractionQuality={doc.md_extraction_quality} extractedText={doc.extracted_text} class="prose prose-base max-w-none" />
|
||||
{:else}<iframe src="/api/documents/{doc.id}/file?token={getAccessToken()}" class="w-full h-[80vh] rounded" title={doc.title}></iframe>{/if}
|
||||
{:else if viewerType === 'image'}
|
||||
<img
|
||||
src="/api/documents/{doc.id}/file?token={getAccessToken()}"
|
||||
alt={doc.title}
|
||||
class="max-w-full rounded"
|
||||
/>
|
||||
<img src="/api/documents/{doc.id}/file?token={getAccessToken()}" alt={doc.title} class="max-w-full rounded" />
|
||||
{:else if viewerType === 'synology'}
|
||||
<EmptyState
|
||||
icon={ExternalLink}
|
||||
title="Synology Office 문서"
|
||||
description="외부 편집기에서 열어야 합니다."
|
||||
>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
href={doc.edit_url || 'https://link.hyungi.net'}
|
||||
target="_blank"
|
||||
>
|
||||
새 창에서 열기
|
||||
</Button>
|
||||
</EmptyState>
|
||||
<EmptyState icon={FileText} title="Synology Office 문서" description="외부 편집기에서 열어야 합니다."><Button variant="primary" size="sm" href={doc.edit_url || 'https://link.hyungi.net'} target="_blank">새 창에서 열기</Button></EmptyState>
|
||||
{:else if viewerType === 'article'}
|
||||
<div>
|
||||
<h1 class="text-xl font-bold text-text mb-3">{doc.title}</h1>
|
||||
<div class="flex items-center gap-2 mb-4 text-xs text-dim">
|
||||
<span>출처: {doc.source_channel}</span>
|
||||
<span class="text-faint">·</span>
|
||||
<span>
|
||||
{new Date(doc.created_at).toLocaleDateString('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
{#if doc.md_content || doc.extracted_text}
|
||||
<!-- article = 텍스트 네이티브(markdown 변환 비대상). md_status='skipped' 라도
|
||||
"Markdown 제외" badge 를 띄우지 않도록 mdStatus 미전달(badge 는 mdStatus 로만 구동). -->
|
||||
<MarkdownDoc
|
||||
documentId={doc.id}
|
||||
mdContent={doc.md_content}
|
||||
mdFrontmatter={doc.md_frontmatter}
|
||||
mdStatus={null}
|
||||
mdExtractionError={doc.md_extraction_error}
|
||||
mdExtractionQuality={doc.md_extraction_quality}
|
||||
extractedText={doc.extracted_text}
|
||||
class="mb-6"
|
||||
/>
|
||||
{/if}
|
||||
{#if doc.edit_url}
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
icon={ExternalLink}
|
||||
href={doc.edit_url}
|
||||
target="_blank"
|
||||
>
|
||||
원문 보기
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<EmptyState
|
||||
icon={FileText}
|
||||
title="인앱 미리보기 미지원"
|
||||
description="포맷: {doc.file_format}"
|
||||
/>
|
||||
{/if}
|
||||
</Card>
|
||||
{#if doc.md_content || doc.extracted_text}<MarkdownDoc documentId={doc.id} mdContent={doc.md_content} mdFrontmatter={doc.md_frontmatter} mdStatus={null} mdExtractionError={doc.md_extraction_error} mdExtractionQuality={doc.md_extraction_quality} extractedText={doc.extracted_text} class="prose prose-base max-w-none" />{/if}
|
||||
{#if doc.edit_url}<div class="mt-4"><Button variant="primary" size="sm" href={doc.edit_url} target="_blank">원문 보기</Button></div>{/if}
|
||||
{:else}<EmptyState icon={FileText} title="인앱 미리보기 미지원" description="포맷: {doc.file_format}" />{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<!-- 손글씨 노트 패드 (자료실 자료, "필기" 토글 시) -->
|
||||
{#if noteOpen && doc.category === 'library' && noteLoaded}
|
||||
<Card class="overflow-hidden p-0">
|
||||
<div class="h-[60vh] min-h-[400px] flex flex-col">
|
||||
<HandwriteCanvas
|
||||
sessionId={doc.id}
|
||||
initialStrokes={noteStrokes}
|
||||
onChange={(strokes) => saveNote(strokes)}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
{/if}
|
||||
<!-- 데스크탑: 본문 | 인사이트 레일 -->
|
||||
<div class="hidden xl:grid xl:grid-cols-[minmax(0,1fr)_336px] gap-3.5 items-start">
|
||||
{@render fbViewer()}
|
||||
<div style="position:sticky;top:14px;">{@render rail()}</div>
|
||||
</div>
|
||||
<!-- 모바일: 인사이트(상단 상시) + 본문 -->
|
||||
<div class="xl:hidden">
|
||||
<div style="margin-bottom:12px;">{@render rail()}</div>
|
||||
{@render fbViewer()}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- 오른쪽 — 메타 Tabs [정보 | AI | 관리] (카드 11개 수직 스프롤 해소) -->
|
||||
<aside class="min-w-0">
|
||||
<Card>
|
||||
<Tabs
|
||||
tabs={[
|
||||
{ id: 'info', label: '정보' },
|
||||
{ id: 'ai', label: 'AI' },
|
||||
{ id: 'manage', label: '관리' },
|
||||
]}
|
||||
>
|
||||
{#snippet children(tab)}
|
||||
<div class="pt-3 space-y-4">
|
||||
{#if tab === 'info'}
|
||||
{#if doc.category === 'library'}
|
||||
<ReadCounter
|
||||
documentId={doc.id}
|
||||
initialCount={doc.read_count ?? 0}
|
||||
initialLastReadAt={doc.last_read_at ?? null}
|
||||
/>
|
||||
{/if}
|
||||
<FileInfoView {doc} />
|
||||
<ProcessingStatusView {doc} />
|
||||
{:else if tab === 'ai'}
|
||||
<AnalysisPanel docId={doc.id} doc={doc} />
|
||||
<AIClassificationEditor {doc} />
|
||||
<div>
|
||||
<h4 class="text-xs font-semibold text-dim uppercase mb-1.5">관련 문서</h4>
|
||||
<!-- TODO(backend): GET /documents/{id}/related?limit=10 (벡터 유사도) -->
|
||||
<EmptyState
|
||||
icon={FileText}
|
||||
title="추후 지원"
|
||||
description="관련 문서 추천은 backend 연동 후 제공됩니다."
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<LibraryPathEditor {doc} />
|
||||
<NoteEditor {doc} />
|
||||
<EditUrlEditor {doc} />
|
||||
<TagsEditor {doc} />
|
||||
<div class="pt-2 border-t border-default">
|
||||
<DocumentDangerZone {doc} ondelete={handleDocDelete} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
</Tabs>
|
||||
</Card>
|
||||
</aside>
|
||||
</div>
|
||||
<!-- 관리 (편집/삭제) — 헤더 '관리'로 토글 -->
|
||||
{#if manageOpen}
|
||||
<div style="background:#f4f7f1;border:1px solid #dde3d6;border-radius:14px;padding:16px 18px;margin-top:14px;">
|
||||
<div style="font-size:12px;font-weight:700;color:#697061;margin-bottom:12px;letter-spacing:.3px;">관리 · 분류 편집</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<AIClassificationEditor {doc} />
|
||||
<LibraryPathEditor {doc} />
|
||||
<NoteEditor {doc} />
|
||||
<EditUrlEditor {doc} />
|
||||
<TagsEditor {doc} />
|
||||
</div>
|
||||
<div class="pt-3 mt-3 border-t border-default"><DocumentDangerZone {doc} ondelete={handleDocDelete} /></div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if noteOpen && doc.category === 'library' && noteLoaded}
|
||||
<div style="background:#f4f7f1;border:1px solid #dde3d6;border-radius:14px;overflow:hidden;margin-top:14px;"><div class="h-[60vh] min-h-[400px] flex flex-col"><HandwriteCanvas sessionId={doc.id} initialStrokes={noteStrokes} onChange={(s) => saveNote(s)} /></div></div>
|
||||
{/if}
|
||||
|
||||
<!-- 모바일 sticky 하단 바 — 자료실 자료의 학습 흐름 네비게이션 -->
|
||||
{#if doc.category === 'library'}
|
||||
<div class="lg:hidden fixed bottom-0 inset-x-0 z-30 bg-surface border-t border-default px-3 py-2 flex items-center gap-2 shadow-lg">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => neighbors.prev && goto(`/documents/${neighbors.prev.id}`)}
|
||||
disabled={!neighbors.prev}
|
||||
class="px-2 py-2 rounded text-dim disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
aria-label="이전 자료"
|
||||
><ChevronLeft size={20} /></button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={readAndGoNext}
|
||||
disabled={!neighbors.next}
|
||||
class="flex-1 flex items-center justify-center gap-1.5 px-3 py-2.5 rounded-lg bg-accent text-white text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
<Check size={16} />
|
||||
{#if neighbors.next}
|
||||
1회독 완료 + 다음
|
||||
{:else}
|
||||
1회독 완료 (마지막 자료)
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => neighbors.next && goto(`/documents/${neighbors.next.id}`)}
|
||||
disabled={!neighbors.next}
|
||||
class="px-2 py-2 rounded text-dim disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
aria-label="다음 자료 (회독 카운트 안 함)"
|
||||
><ChevronRight size={20} /></button>
|
||||
<button type="button" onclick={() => neighbors.prev && goto(`/documents/${neighbors.prev.id}`)} disabled={!neighbors.prev} class="px-3 py-2 rounded text-dim disabled:opacity-30" aria-label="이전">‹</button>
|
||||
<button type="button" onclick={readAndGoNext} disabled={!neighbors.next} class="flex-1 px-3 py-2.5 rounded-lg bg-accent text-white text-sm font-medium disabled:opacity-50">{#if neighbors.next}1회독 완료 + 다음{:else}1회독 완료 (마지막){/if}</button>
|
||||
<button type="button" onclick={() => neighbors.next && goto(`/documents/${neighbors.next.id}`)} disabled={!neighbors.next} class="px-3 py-2 rounded text-dim disabled:opacity-30" aria-label="다음">›</button>
|
||||
</div>
|
||||
<!-- 본문이 sticky 바 뒤에 가리지 않도록 패딩 -->
|
||||
<div class="lg:hidden h-20"></div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.d3node:hover { background: #ecf0e8; }
|
||||
.d3active:hover { background: #e3ebdf; }
|
||||
.d3grp:hover { background: #ecf0e8; }
|
||||
.d3child { position: relative; }
|
||||
.d3child::before { content: ""; position: absolute; left: 2px; top: -3px; bottom: 50%; width: 1px; background: #cdd6c4; }
|
||||
.d3child::after { content: ""; position: absolute; left: 2px; top: 50%; width: 7px; height: 1px; background: #cdd6c4; }
|
||||
.m-secbody[open] .m-chev { transform: rotate(90deg); }
|
||||
.d3warn { animation: d3pulse 2.4s ease-in-out infinite; }
|
||||
@keyframes d3pulse { 0%, 100% { box-shadow: 0 0 0 0 rgba(181, 132, 10, .35); } 50% { box-shadow: 0 0 0 3px rgba(181, 132, 10, 0); } }
|
||||
</style>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
-- 2026-06-14 PR-Background-Jobs-Observability: 큐 밖 관리 스크립트(백필 등) 진행 가시화.
|
||||
-- processing_queue 는 파이프라인 stage 전용 — hier_overnight_backfill / section_summary_pilot
|
||||
-- 같은 off-queue 관리 스크립트는 여기에 진행상황을 남겨 대시보드 보드가 노출한다.
|
||||
-- worker_jobs(user_id NOT NULL, worker-pool 전용)와 별개 — 이건 owner 없는 관리 작업 heartbeat.
|
||||
-- 단일 statement (asyncpg multi-statement 불허 컨벤션). 인덱스는 소량 테이블이라 생략.
|
||||
CREATE TABLE IF NOT EXISTS background_jobs (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
kind TEXT NOT NULL, -- 'hier_redecompose' | 'section_summary' | ...
|
||||
label TEXT, -- 사람이 읽는 대상 표기 (예: 'doc 5210 (Sec VIII)')
|
||||
state TEXT NOT NULL DEFAULT 'running'
|
||||
CHECK (state IN ('running', 'done', 'failed')),
|
||||
processed INTEGER NOT NULL DEFAULT 0, -- 처리한 단위 수 (절/leaf 등)
|
||||
total INTEGER, -- 전체 단위 수 (미상이면 NULL)
|
||||
detail JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
error TEXT,
|
||||
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
finished_at TIMESTAMPTZ
|
||||
);
|
||||
@@ -0,0 +1,11 @@
|
||||
-- 358: documents.embedding HNSW 벡터 인덱스 + hnsw.ef_search (검색 latency T3, 2026-06-15)
|
||||
-- PROD 적용 = CREATE INDEX CONCURRENTLY 로 수동 빌드(40k rows 무중단, /dev/shm 회피 위해 단일 스레드)
|
||||
-- + schema_migrations(358) 수동 기록 완료. runner 는 단일 트랜잭션이라 CONCURRENTLY 불가.
|
||||
-- 본 파일 = fresh-init/재현용: non-concurrent IF NOT EXISTS (빈 테이블 init 시 즉시, 기존 index 존재 시 no-op).
|
||||
CREATE INDEX IF NOT EXISTS idx_documents_embedding_hnsw
|
||||
ON documents USING hnsw (embedding vector_cosine_ops)
|
||||
WHERE (deleted_at IS NULL AND embedding IS NOT NULL);
|
||||
|
||||
-- docs vector leg LIMIT = limit*4 (기본 80) → HNSW recall 위해 ef_search >= 80 필요.
|
||||
-- ivfflat.probes=20 과 동일하게 DB 레벨 GUC (ALTER DATABASE) 로 설정.
|
||||
ALTER DATABASE pkm SET hnsw.ef_search = 100;
|
||||
@@ -0,0 +1,6 @@
|
||||
-- 359: delete_file=true 명시 삭제 요청 마커 (R7 delete_file 큐드삭제).
|
||||
-- retention sweep(document_purge_sweep) 이 이 컬럼 + grace(30일) 기준으로 NAS 원본을
|
||||
-- 물리삭제한다. deleted_at(단순 숨김)과 분리 — 숨김(delete_file=false)은 파일 보존(undelete
|
||||
-- 가능). sweep 가 deleted_at 기준이면 모든 숨김이 30일 후 물리삭제되는 데이터 손실이 되므로
|
||||
-- 명시 purge 요청만 대상으로 한다.
|
||||
ALTER TABLE documents ADD COLUMN IF NOT EXISTS purge_requested_at TIMESTAMPTZ;
|
||||
@@ -0,0 +1,11 @@
|
||||
-- 360: Phase 2A 임베딩 후보 cand 섀도 테이블 제거 (R13).
|
||||
-- Phase 2A no-go 종결(2026-06-12, 후보 전부 -0.03~-0.04) + phase2a_cand_backfill 워커 dormant.
|
||||
-- retrieval_service.CANDIDATE_BACKEND_MAP / api.search allowed 슬러그 선제거 후 DROP.
|
||||
-- ★single statement(콤마 구분) — init_db 의 exec_driver_sql(asyncpg)은 multi-statement 불허.
|
||||
-- IF EXISTS — me5/snowflake 는 ad-hoc 생성분이라 환경별 존재 여부 다를 수 있음(멱등).
|
||||
DROP TABLE IF EXISTS
|
||||
document_chunks_cand_me5_large_inst, documents_cand_me5_large_inst,
|
||||
document_chunks_cand_snowflake_l_v2, documents_cand_snowflake_l_v2,
|
||||
document_chunks_cand_qwen06, documents_cand_qwen06,
|
||||
document_chunks_cand_qwen4, documents_cand_qwen4,
|
||||
document_chunks_cand_qwen4m, documents_cand_qwen4m;
|
||||
@@ -0,0 +1,9 @@
|
||||
-- 361: quiz 세션 내 같은 문제 이중 attempt 방지 partial UNIQUE (R9).
|
||||
-- submit_attempt 의 FOR UPDATE 행잠금이 1차 방어, 이 제약은 DB 레벨 belt-and-suspenders.
|
||||
-- prod 실측 중복 0 (GROUP BY (quiz_session_id, study_question_id) HAVING count>1 = 0) + fresh DB
|
||||
-- 빈 테이블이라 dedup DELETE 불요 → ★single statement(init_db exec_driver_sql 은 multi-statement
|
||||
-- 불허). 혹시 중복이 생긴 환경이면 이 마이그가 실패하므로(IntegrityError) 수동 dedup 후 재적용.
|
||||
-- quiz_session_id IS NULL(세션 외 직접 입력)은 비대상 → partial index.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_attempt_session_question
|
||||
ON study_question_attempts (quiz_session_id, study_question_id)
|
||||
WHERE quiz_session_id IS NOT NULL;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,122 @@
|
||||
"""전체 app 부팅 런타임 스모크 (GPU 격리) — deploy-blocker 게이트.
|
||||
|
||||
init_db 자체는 initdb_runtime_test.py(R1)·migration_smoke.sh 가 검증한다.
|
||||
본 스모크는 그 위에서 **실제 컨테이너 부팅 경로**(main:app + lifespan startup)를 실행해
|
||||
py_compile 이 못 잡는 deploy-blocker 클래스를 잡는다:
|
||||
|
||||
① `import main` = 전 router import + FastAPI app 빌드 (router 심볼누락·순환 검출)
|
||||
② lifespan startup = lifespan 안의 전 worker import(≈35) + init_db + 전 add_job 실행
|
||||
(worker import-time 오류·잡 등록 오류 검출, **drift 0** = 실제 경로)
|
||||
③ /health (health_check 직접 호출) = DB connected
|
||||
|
||||
prod/AI/NAS 무접촉을 위해 부작용 3개만 외과적으로 중립화한다 (검증 대상 로직은 그대로):
|
||||
- NAS 마운트 체크 → 임시 디렉토리(+PKM/) 로 통과 (실 NAS 의존 제거)
|
||||
- scheduler.start() → no-op (잡은 등록되지만 실행 안 됨 = 워커 폴링·외부 API 호출 0)
|
||||
- scheduler.shutdown() → no-op (start 안 했으니 __aexit__ 의 shutdown 이 raise 안 하도록)
|
||||
- prewarm_analyzer() → no-op (AI 라우터 :8890 미호출 = 검색실험 soft-lock 안전)
|
||||
|
||||
실행 (worktree 루트를 마운트한 prod fastapi 이미지 컨테이너 안):
|
||||
docker run --rm --network <net> -v <worktree>:/work -w /work \
|
||||
-e PYTHONPATH=/work/app -e BOOT_SMOKE=1 \
|
||||
-e DATABASE_URL="postgresql+asyncpg://postgres@ds-bootsmoke-pg:5432/pkm" \
|
||||
<fastapi_image> python scripts/ci/boot_smoke.py
|
||||
|
||||
기대: IMPORTS OK → LIFESPAN startup OK (jobs=N, purge_sweep 포함) → schema OK → HEALTH ok → PASS
|
||||
"""
|
||||
import asyncio
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
# ── 0) 안전 가드: prod DB 오접속 차단 ─────────────────────────────────
|
||||
from core.config import settings
|
||||
|
||||
url = settings.database_url
|
||||
print("DATABASE_URL:", url)
|
||||
assert os.getenv("BOOT_SMOKE") == "1", "SAFETY ABORT: BOOT_SMOKE=1 미설정"
|
||||
# prod = '...@postgres:5432/pkm' (user pkm). ephemeral = bootsmoke 호스트 / localhost / postgres user.
|
||||
assert "@postgres:" not in url and "@postgres/" not in url, f"SAFETY ABORT: prod DB 로 보임: {url}"
|
||||
assert ("bootsmoke" in url) or ("localhost" in url) or ("127.0.0.1" in url), \
|
||||
f"SAFETY ABORT: ephemeral 마커(bootsmoke/localhost) 없음: {url}"
|
||||
|
||||
# ── 1) 부작용 3개 중립화 (검증 대상 로직 보존) ───────────────────────
|
||||
# prewarm: AI 라우터 미호출
|
||||
import services.search.query_analyzer as qa
|
||||
|
||||
async def _noop_prewarm(*a, **k):
|
||||
return None
|
||||
|
||||
qa.prewarm_analyzer = _noop_prewarm
|
||||
|
||||
# scheduler.start/shutdown no-op + start 캡처로 잡 개수 집계
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
|
||||
captured: dict = {}
|
||||
_orig_init = AsyncIOScheduler.__init__
|
||||
|
||||
def _init(self, *a, **k):
|
||||
_orig_init(self, *a, **k)
|
||||
captured["sched"] = self
|
||||
|
||||
AsyncIOScheduler.__init__ = _init
|
||||
AsyncIOScheduler.start = lambda self, *a, **k: None
|
||||
AsyncIOScheduler.shutdown = lambda self, *a, **k: None
|
||||
|
||||
# NAS 체크 통과용 임시 마운트
|
||||
tmp = tempfile.mkdtemp(prefix="bootsmoke-nas-")
|
||||
(Path(tmp) / "PKM").mkdir(parents=True, exist_ok=True)
|
||||
settings.nas_mount_path = tmp
|
||||
print("nas_mount_path(override):", tmp)
|
||||
|
||||
# ── 2) import main = 전 router import + app 빌드 ──────────────────────
|
||||
import main
|
||||
|
||||
route_count = len(main.app.routes)
|
||||
print(f"IMPORTS OK — main 빌드, app.routes={route_count}")
|
||||
assert route_count > 50, f"라우트 수 비정상({route_count}) — 라우터 누락 의심"
|
||||
|
||||
# ── 3) lifespan startup 실행 (init_db + 전 worker import + 전 add_job) ─
|
||||
cm = main.lifespan(main.app)
|
||||
await cm.__aenter__()
|
||||
sched = captured.get("sched")
|
||||
jobs = sched.get_jobs() if sched else []
|
||||
job_ids = sorted(j.id for j in jobs)
|
||||
print(f"LIFESPAN startup OK — 등록 잡 {len(jobs)}건")
|
||||
print(" job_ids:", ", ".join(job_ids))
|
||||
assert len(jobs) >= 30, f"잡 등록 수 비정상({len(jobs)})"
|
||||
for required in ("purge_sweep", "auto_review", "queue_consumer", "statute_collector"):
|
||||
assert required in job_ids, f"필수 잡 누락: {required}"
|
||||
|
||||
# ── 4) 스키마 상태 (lifespan 의 실 init_db 가 359/360/361 적용했는지) ──
|
||||
from core.database import async_session, engine
|
||||
|
||||
async with async_session() as s:
|
||||
docs = (await s.execute(text("SELECT to_regclass('public.documents') IS NOT NULL"))).scalar()
|
||||
purge = (await s.execute(text(
|
||||
"SELECT count(*) FROM information_schema.columns "
|
||||
"WHERE table_name='documents' AND column_name='purge_requested_at'"))).scalar()
|
||||
cand = (await s.execute(text(
|
||||
"SELECT count(*) FROM information_schema.tables "
|
||||
"WHERE table_name LIKE 'documents_cand_qwen%'"))).scalar()
|
||||
uq = (await s.execute(text(
|
||||
"SELECT count(*) FROM pg_indexes WHERE indexname='uq_attempt_session_question'"))).scalar()
|
||||
mx = (await s.execute(text("SELECT max(version) FROM schema_migrations"))).scalar()
|
||||
print(f"SCHEMA OK — max_migration={mx} documents={docs} purge_col={purge} cand_qwen={cand} attempt_uq={uq}")
|
||||
assert docs and purge == 1 and cand == 0 and uq == 1 and mx == 361, "FAIL: 기대 스키마 상태 불일치"
|
||||
|
||||
# ── 5) /health 직접 호출 ──────────────────────────────────────────────
|
||||
health = await main.health_check()
|
||||
print("HEALTH:", health)
|
||||
assert health["status"] == "ok" and health["database"] == "connected", "FAIL: health degraded"
|
||||
|
||||
# ── 6) 정리 ───────────────────────────────────────────────────────────
|
||||
await cm.__aexit__(None, None, None)
|
||||
await engine.dispose()
|
||||
print("RESULT: PASS — 전체 app 부팅(import·init_db·잡등록·health) 검증")
|
||||
|
||||
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,51 @@
|
||||
"""init_db() baseline 부팅 런타임 검증 (R1) — psql migration_smoke 가 못 잡는 asyncpg 경로 확인.
|
||||
|
||||
migration_smoke.sh(psql)는 SQL 유효성만 검증한다. init_db 는 asyncpg exec_driver_sql(prepared)
|
||||
경로라 ① multi-statement 불허 ② baseline 의 raw asyncpg 적재 ③ skip/stamp/멱등 — 이걸 실측한다.
|
||||
|
||||
실행 (worktree 루트):
|
||||
python3.11 -m venv /tmp/v && /tmp/v/bin/pip install -q "sqlalchemy[asyncio]>=2" asyncpg pydantic pyyaml
|
||||
docker run -d --name idb -p 55432:5432 -e POSTGRES_HOST_AUTH_METHOD=trust pgvector/pgvector:pg16
|
||||
docker exec idb psql -U postgres -c "CREATE DATABASE pkm"
|
||||
ln -sfn ../migrations app/migrations # Docker 의 /app/migrations 레이아웃 모사 (테스트 후 rm)
|
||||
PYTHONPATH=app DATABASE_URL="postgresql+asyncpg://postgres@localhost:55432/pkm" /tmp/v/bin/python scripts/ci/initdb_runtime_test.py
|
||||
rm -f app/migrations; docker rm -f idb
|
||||
|
||||
기대: 1st OK(documents=True·purge_col=1·cand_qwen=0·attempt_unique=1), 2nd 멱등동일=True.
|
||||
"""
|
||||
import asyncio
|
||||
from sqlalchemy import text
|
||||
|
||||
|
||||
async def main():
|
||||
from core.config import settings
|
||||
url = settings.database_url
|
||||
print("effective DATABASE_URL:", url)
|
||||
assert "localhost" in url or "127.0.0.1" in url, f"SAFETY ABORT non-local: {url}"
|
||||
from core.database import init_db, async_session, engine
|
||||
|
||||
print("=== 1st init_db (fresh DB) ===")
|
||||
await init_db()
|
||||
async with async_session() as s:
|
||||
cnt = (await s.execute(text("SELECT count(*) FROM schema_migrations"))).scalar()
|
||||
mx = (await s.execute(text("SELECT max(version) FROM schema_migrations"))).scalar()
|
||||
bl = (await s.execute(text("SELECT count(*) FROM schema_migrations WHERE name LIKE 'baseline:%'"))).scalar()
|
||||
docs = (await s.execute(text("SELECT to_regclass('public.documents') IS NOT NULL"))).scalar()
|
||||
purge = (await s.execute(text("SELECT count(*) FROM information_schema.columns WHERE table_name='documents' AND column_name='purge_requested_at'"))).scalar()
|
||||
cand = (await s.execute(text("SELECT count(*) FROM information_schema.tables WHERE table_name LIKE 'documents_cand_qwen%'"))).scalar()
|
||||
uq = (await s.execute(text("SELECT count(*) FROM pg_indexes WHERE indexname='uq_attempt_session_question'"))).scalar()
|
||||
print(f" schema_migrations count={cnt} max={mx} baseline_stamped={bl}")
|
||||
print(f" documents={docs} purge_col={purge} cand_qwen_tables={cand} attempt_unique={uq}")
|
||||
assert docs and purge == 1 and cand == 0 and uq == 1, "FAIL: 기대 스키마 상태 불일치"
|
||||
|
||||
print("=== 2nd init_db (rerun = baseline skip + 멱등) ===")
|
||||
await init_db()
|
||||
async with async_session() as s:
|
||||
cnt2 = (await s.execute(text("SELECT count(*) FROM schema_migrations"))).scalar()
|
||||
assert cnt == cnt2, "FAIL: 멱등 아님 (재실행이 schema_migrations 변경)"
|
||||
print(f" count={cnt2} 멱등동일={cnt == cnt2}")
|
||||
print("RESULT: PASS — init_db baseline 부팅/멱등 검증")
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
asyncio.run(main())
|
||||
Executable
+138
@@ -0,0 +1,138 @@
|
||||
#!/usr/bin/env bash
|
||||
# migration_smoke.sh — fresh-DB + DR enum-same-txn 게이트 (plan ds-backend-audit-1 R0)
|
||||
#
|
||||
# app/core/database.py 의 init_db() 는 모든 pending migration 을 단일 트랜잭션
|
||||
# (`async with engine.begin()`) 으로 적용한다. 이 스크립트는 그 경로를 미러해
|
||||
# migrations/ 전체가 빈 DB / DR 업그레이드에서 한 트랜잭션으로 적용 가능한지 검증한다.
|
||||
#
|
||||
# 시나리오:
|
||||
# FRESH — 빈 DB 에 migrations/ 전체를 단일 트랜잭션으로 적용 (신규 환경 부팅 경로)
|
||||
# DR — 001~319 를 커밋(과거 운영 DB 모사) 후 320~end 를 단일 트랜잭션으로 적용
|
||||
# (pre-320 백업/지연 복제를 320 경계 너머로 catch-up 업그레이드하는 재해복구 경로)
|
||||
#
|
||||
# enum-same-txn 결함(ALTER TYPE ADD VALUE 한 값을 같은 트랜잭션에서 사용)이 있으면
|
||||
# 두 시나리오 모두 'unsafe use of new value' 로 abort 한다.
|
||||
# R1(enum-barrier) fix 후에는 두 시나리오 모두 PASS 해야 한다.
|
||||
#
|
||||
# prod 동일 이미지(pg16)로 핀. 의존: docker.
|
||||
# 사용: scripts/ci/migration_smoke.sh (ephemeral 컨테이너 자동 기동/정리)
|
||||
set -uo pipefail
|
||||
|
||||
IMAGE="pgvector/pgvector:pg16"
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
MIG_DIR="$(cd "$SCRIPT_DIR/../../migrations" && pwd)"
|
||||
CNAME="ds-mig-smoke-$$"
|
||||
DB="pkm" # 358 의 ALTER DATABASE pkm 가 이 이름을 요구
|
||||
|
||||
cleanup() { docker rm -f "$CNAME" >/dev/null 2>&1 || true; }
|
||||
trap cleanup EXIT
|
||||
|
||||
# 버전순 마이그레이션 파일 목록 (NNN_ 3자리 zero-pad → lexical = numeric)
|
||||
# bash 3.2(macOS) 호환 — mapfile 미사용
|
||||
MIGS=()
|
||||
while IFS= read -r _line; do MIGS+=("$_line"); done < <(ls "$MIG_DIR"/[0-9]*.sql | sort)
|
||||
[ "${#MIGS[@]}" -gt 0 ] || { echo "FATAL: migrations 없음 ($MIG_DIR)"; exit 2; }
|
||||
echo "migrations: ${#MIGS[@]}건 ($(basename "${MIGS[0]}") ~ $(basename "${MIGS[$((${#MIGS[@]}-1))]}"))"
|
||||
|
||||
psql_exec() { docker exec -i "$CNAME" psql -U postgres -v ON_ERROR_STOP=1 "$@"; }
|
||||
|
||||
# 주어진 파일 범위를 단일 트랜잭션 스트림으로 묶어 출력 (psql stdin 용)
|
||||
# 각 파일 앞에 \echo 마커 — 실패 시 마지막 마커가 깨진 마이그레이션.
|
||||
emit_single_txn() {
|
||||
echo '\set ON_ERROR_STOP on'
|
||||
echo 'BEGIN;'
|
||||
for f in "$@"; do
|
||||
echo "\\echo >>>APPLY $(basename "$f")"
|
||||
cat "$f"; echo
|
||||
done
|
||||
echo 'COMMIT;'
|
||||
}
|
||||
|
||||
# 자동커밋(파일별 즉시 커밋) 스트림 — DR phase1 (기존 운영 DB 모사)
|
||||
emit_autocommit() {
|
||||
echo '\set ON_ERROR_STOP on'
|
||||
for f in "$@"; do
|
||||
echo "\\echo >>>APPLY $(basename "$f")"
|
||||
cat "$f"; echo
|
||||
done
|
||||
}
|
||||
|
||||
reset_db() {
|
||||
psql_exec -d postgres -c "DROP DATABASE IF EXISTS $DB" >/dev/null 2>&1
|
||||
psql_exec -d postgres -c "CREATE DATABASE $DB" >/dev/null
|
||||
}
|
||||
|
||||
run_scenario() {
|
||||
local name="$1"; shift
|
||||
local out rc last_apply
|
||||
out="$( "$@" 2>&1 )"; rc=$?
|
||||
last_apply="$(printf '%s\n' "$out" | grep '>>>APPLY' | tail -1 | sed 's/>>>APPLY //')"
|
||||
if [ "$rc" -eq 0 ]; then
|
||||
echo " [$name] PASS — 전체 적용 성공"
|
||||
return 0
|
||||
else
|
||||
echo " [$name] FAIL — 깨진 지점: ${last_apply:-?}"
|
||||
printf '%s\n' "$out" | grep -iE 'ERROR|unsafe|HINT' | head -3 | sed 's/^/ /'
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
BASELINE_CUTOFF=358
|
||||
BASELINE_FILE="$MIG_DIR/_baseline/0358_schema_baseline.sql"
|
||||
|
||||
# post-baseline(버전 > cutoff) 마이그 파일만 출력
|
||||
_post_baseline() {
|
||||
local f base ver
|
||||
for f in "${MIGS[@]}"; do
|
||||
base="$(basename "$f")"; ver="${base%%_*}"; ver="$((10#$ver))"
|
||||
[ "$ver" -gt "$BASELINE_CUTOFF" ] && printf '%s\n' "$f"
|
||||
done
|
||||
}
|
||||
|
||||
# FRESH — init_db fresh 경로 미러: baseline 적재 + post-baseline 을 단일 트랜잭션
|
||||
scenario_fresh() {
|
||||
reset_db
|
||||
local post=(); while IFS= read -r f; do post+=("$f"); done < <(_post_baseline)
|
||||
{
|
||||
echo '\set ON_ERROR_STOP on'; echo 'BEGIN;'
|
||||
echo "\\echo >>>APPLY _baseline"
|
||||
cat "$BASELINE_FILE"; echo
|
||||
for f in "${post[@]}"; do
|
||||
echo "\\echo >>>APPLY $(basename "$f")"; cat "$f"; echo
|
||||
done
|
||||
echo 'COMMIT;'
|
||||
} | psql_exec -d "$DB"
|
||||
}
|
||||
|
||||
# INCREMENTAL — 기존 운영 DB(at cutoff) 모사: baseline 커밋 후 post-baseline 을 별 트랜잭션
|
||||
scenario_dr() {
|
||||
reset_db
|
||||
if ! { echo '\set ON_ERROR_STOP on'; cat "$BASELINE_FILE"; } | psql_exec -d "$DB" >/dev/null 2>&1; then
|
||||
printf '%s\n' ">>>APPLY _baseline"; echo "baseline 적재 실패"; return 1
|
||||
fi
|
||||
local post=(); while IFS= read -r f; do post+=("$f"); done < <(_post_baseline)
|
||||
emit_single_txn "${post[@]}" 2>/dev/null | psql_exec -d "$DB"
|
||||
}
|
||||
|
||||
# ── 컨테이너 기동 ──
|
||||
echo "기동: $IMAGE ($CNAME)"
|
||||
docker run -d --name "$CNAME" -e POSTGRES_PASSWORD=x -e POSTGRES_HOST_AUTH_METHOD=trust "$IMAGE" >/dev/null
|
||||
for _ in $(seq 1 40); do docker exec "$CNAME" pg_isready -U postgres -q 2>/dev/null && break; sleep 0.5; done
|
||||
echo "pg: $(docker exec "$CNAME" psql -U postgres -tAc 'show server_version' 2>/dev/null)"
|
||||
echo
|
||||
|
||||
fail=0
|
||||
echo "── FRESH (baseline 적재 + post-baseline 단일 트랜잭션 = init_db fresh 경로) ──"
|
||||
run_scenario FRESH scenario_fresh || fail=1
|
||||
echo
|
||||
echo "── INCREMENTAL (baseline 커밋 후 post-baseline 별 트랜잭션 = 기존 DB 증분) ──"
|
||||
run_scenario DR scenario_dr || fail=1
|
||||
echo
|
||||
|
||||
if [ "$fail" -eq 0 ]; then
|
||||
echo "RESULT: PASS — fresh/incremental 모두 baseline+post-baseline 적용 가능"
|
||||
exit 0
|
||||
else
|
||||
echo "RESULT: FAIL — baseline/post-baseline 적용 불가 (위 지점)"
|
||||
exit 1
|
||||
fi
|
||||
@@ -32,6 +32,7 @@ from core.config import settings
|
||||
from services.hier_decomp.builder import build_hier_tree
|
||||
from services.hier_decomp.persist import persist_hier_tree
|
||||
from services.search.llm_gate import Priority, acquire_mlx_gate
|
||||
from services.background_jobs import finish_job, heartbeat, start_job
|
||||
|
||||
# 단일 진실: 절 분석 상수/헬퍼 (PROMPT_VERSION 일치 = 멱등 보존)
|
||||
from section_summary_pilot import (
|
||||
@@ -140,8 +141,10 @@ def _make_engine():
|
||||
return create_async_engine(os.environ["DATABASE_URL"], pool_pre_ping=True)
|
||||
|
||||
|
||||
async def _analyze_doc_leaves(session, client, doc_id, doc_domain, model_name, stop_at):
|
||||
"""doc 의 미분석 hier leaf 분석 → upsert. stop_at(epoch) 넘으면 leaf 경계 중단."""
|
||||
async def _analyze_doc_leaves(session, client, doc_id, doc_domain, model_name, stop_at,
|
||||
engine=None, job_id=None, base_processed=0):
|
||||
"""doc 의 미분석 hier leaf 분석 → upsert. stop_at(epoch) 넘으면 leaf 경계 중단.
|
||||
engine/job_id 주어지면 background_jobs 에 ~10절마다 진행 heartbeat(보드 가시화)."""
|
||||
rows = (await session.execute(LEAF_SQL, {"doc": doc_id, "pv": PROMPT_VERSION})).mappings().all()
|
||||
ok = fail = skip = 0
|
||||
timings, types = [], []
|
||||
@@ -187,6 +190,8 @@ async def _analyze_doc_leaves(session, client, doc_id, doc_domain, model_name, s
|
||||
"content_hash": r["content_hash"], "error": err,
|
||||
})
|
||||
await session.commit()
|
||||
if job_id and (ok + fail + skip) % 10 == 0:
|
||||
await heartbeat(engine, job_id, processed=base_processed + ok + fail + skip)
|
||||
await session.commit()
|
||||
return {"ok": ok, "fail": fail, "skip": skip, "leaves": len(rows),
|
||||
"timings": timings, "types": types, "aborted": aborted}
|
||||
@@ -256,6 +261,12 @@ async def cmd_run(args):
|
||||
_candidate_params(allowlist, doc_ids))).mappings().all()
|
||||
_log(f"후보 doc {len(cands)} 선별. 시작.")
|
||||
|
||||
# 관측: 큐 밖 작업이라 대시보드 보드가 못 보므로 background_jobs 에 진행 노출(best-effort)
|
||||
_job_kind = "hier_redecompose" if reprocess else "hier_backfill"
|
||||
_job_label = (f"doc {args.doc} {'재분해' if reprocess else '분해'}" if doc_ids
|
||||
else f"{len(cands)}개 문서 {'재분해' if reprocess else '분해'}")
|
||||
job_id = await start_job(engine, _job_kind, _job_label, total=None)
|
||||
|
||||
for c in cands:
|
||||
if time.time() >= stop_at:
|
||||
_log(f"⏰ deadline 버퍼 도달 — doc 경계에서 중단 (처리 {tot_docs} doc)")
|
||||
@@ -272,7 +283,10 @@ async def cmd_run(args):
|
||||
"timings": [], "types": [], "aborted": False}
|
||||
else:
|
||||
async with sm() as session:
|
||||
astat = await _analyze_doc_leaves(session, client, doc_id, doc_domain, model_name, stop_at)
|
||||
astat = await _analyze_doc_leaves(
|
||||
session, client, doc_id, doc_domain, model_name, stop_at,
|
||||
engine=engine, job_id=job_id,
|
||||
base_processed=(tot_ok + tot_fail + tot_skip))
|
||||
except Exception as exc:
|
||||
_log(f" ✗ doc={doc_id} 처리 실패(건너뜀): {type(exc).__name__}: {repr(exc)[:160]}")
|
||||
continue
|
||||
@@ -280,6 +294,8 @@ async def cmd_run(args):
|
||||
tot_docs += 1
|
||||
tot_ok += astat["ok"]; tot_fail += astat["fail"]; tot_skip += astat["skip"]
|
||||
all_timings += astat["timings"]; all_types += astat["types"]
|
||||
await heartbeat(engine, job_id, processed=(tot_ok + tot_fail + tot_skip),
|
||||
total=tot_leaves_created)
|
||||
avg = statistics.mean(astat["timings"]) if astat["timings"] else 0
|
||||
_log(f" ✓ doc={doc_id} ({len(body):,}자 {doc_domain.split('/')[0]}) "
|
||||
f"leaf생성={leaves_created} 분석ok={astat['ok']} fail={astat['fail']} skip={astat['skip']} "
|
||||
@@ -287,6 +303,7 @@ async def cmd_run(args):
|
||||
if astat["aborted"]:
|
||||
_log("⏰ leaf 분석 중 deadline 도달 — 중단")
|
||||
break
|
||||
await finish_job(engine, job_id, state="done")
|
||||
finally:
|
||||
await client.close()
|
||||
await engine.dispose()
|
||||
|
||||
+383
@@ -0,0 +1,383 @@
|
||||
<?xml version='1.0' encoding='UTF-8'?>
|
||||
<feed xmlns:opensearch="http://a9.com/-/spec/opensearch/1.1/" xmlns:arxiv="http://arxiv.org/schemas/atom" xmlns="http://www.w3.org/2005/Atom">
|
||||
<id>https://arxiv.org/api/m9A/71G4hH6NGyarIQjqA3n6Zzk</id>
|
||||
<title>arXiv Query: search_query=abs:"pressure vessel"&id_list=&start=0&max_results=10</title>
|
||||
<updated>2026-06-13T21:57:59Z</updated>
|
||||
<link href="https://arxiv.org/api/query?search_query=abs:%22pressure+vessel%22&start=0&max_results=10&id_list=" type="application/atom+xml"/>
|
||||
<opensearch:itemsPerPage>10</opensearch:itemsPerPage>
|
||||
<opensearch:totalResults>89</opensearch:totalResults>
|
||||
<opensearch:startIndex>0</opensearch:startIndex>
|
||||
<entry>
|
||||
<id>http://arxiv.org/abs/1209.2405v1</id>
|
||||
<title>A Survey of Pressure Vessel Code Compliance for Superconducting RF Cryomodules</title>
|
||||
<updated>2012-09-11T19:34:46Z</updated>
|
||||
<link href="https://arxiv.org/abs/1209.2405v1" rel="alternate" type="text/html"/>
|
||||
<link href="https://arxiv.org/pdf/1209.2405v1" rel="related" type="application/pdf" title="pdf"/>
|
||||
<summary>Superconducting radio frequency (SRF) cavities made from niobium and cooled with liquid helium are becoming key components of many particle accelerators. The helium vessels surrounding the RF cavities, portions of the niobium cavities themselves, and also possibly the vacuum vessels containing these assemblies, generally fall under the scope of local and national pressure vessel codes. In the U.S., Department of Energy rules require national laboratories to follow national consensus pressure vessel standards or to show "a level of safety greater than or equal to" that of the applicable standard. Thus, while used for its superconducting properties, niobium ends up being treated as a low-temperature pressure vessel material. Niobium material is not a code listed material and therefore requires the designer to understand the mechanical properties for material used in each pressure vessel fabrication; compliance with pressure vessel codes therefore becomes a problem. This report summarizes the approaches that various institutions have taken in order to bring superconducting RF cryomodules into compliance with pressure vessel codes.</summary>
|
||||
<category term="physics.acc-ph" scheme="http://arxiv.org/schemas/atom"/>
|
||||
<published>2012-09-11T19:34:46Z</published>
|
||||
<arxiv:comment>7 pp</arxiv:comment>
|
||||
<arxiv:primary_category term="physics.acc-ph"/>
|
||||
<author>
|
||||
<name>Thomas Peterson</name>
|
||||
<arxiv:affiliation>Fermilab</arxiv:affiliation>
|
||||
</author>
|
||||
<author>
|
||||
<name>Arkadiy Klebaner</name>
|
||||
<arxiv:affiliation>Fermilab</arxiv:affiliation>
|
||||
</author>
|
||||
<author>
|
||||
<name>Tom Nicol</name>
|
||||
<arxiv:affiliation>Fermilab</arxiv:affiliation>
|
||||
</author>
|
||||
<author>
|
||||
<name>Jay Theilacker</name>
|
||||
<arxiv:affiliation>Fermilab</arxiv:affiliation>
|
||||
</author>
|
||||
<author>
|
||||
<name>Hitoshi Hayano</name>
|
||||
<arxiv:affiliation>KEK, Tsukuba</arxiv:affiliation>
|
||||
</author>
|
||||
<author>
|
||||
<name>Eiji Kako</name>
|
||||
<arxiv:affiliation>KEK, Tsukuba</arxiv:affiliation>
|
||||
</author>
|
||||
<author>
|
||||
<name>Hirotaka Nakai</name>
|
||||
<arxiv:affiliation>KEK, Tsukuba</arxiv:affiliation>
|
||||
</author>
|
||||
<author>
|
||||
<name>Akira Yamamoto</name>
|
||||
<arxiv:affiliation>KEK, Tsukuba</arxiv:affiliation>
|
||||
</author>
|
||||
<author>
|
||||
<name>Kay Jensch</name>
|
||||
<arxiv:affiliation>DESY</arxiv:affiliation>
|
||||
</author>
|
||||
<author>
|
||||
<name>Axel Matheisen</name>
|
||||
<arxiv:affiliation>DESY</arxiv:affiliation>
|
||||
</author>
|
||||
<author>
|
||||
<name>John Mammosser</name>
|
||||
<arxiv:affiliation>Jefferson Lab</arxiv:affiliation>
|
||||
</author>
|
||||
<arxiv:doi>10.1063/1.4707088</arxiv:doi>
|
||||
<link rel="related" href="https://doi.org/10.1063/1.4707088" title="doi"/>
|
||||
</entry>
|
||||
<entry>
|
||||
<id>http://arxiv.org/abs/2003.02057v1</id>
|
||||
<title>Investigation of Unit-1 Nuclear Reactor of the Fukushima Daiichi by Cosmic Muon Radiography</title>
|
||||
<updated>2020-03-03T03:21:53Z</updated>
|
||||
<link href="https://arxiv.org/abs/2003.02057v1" rel="alternate" type="text/html"/>
|
||||
<link href="https://arxiv.org/pdf/2003.02057v1" rel="related" type="application/pdf" title="pdf"/>
|
||||
<summary>We have investigated the status of the nuclear fuel assemblies in Unit-1 reactor of the Fukushima Daiichi Nuclear Power plant by the method called Cosmic Muon Radiography. In this study, muon tracking detectors were placed outside of the reactor building. We succeeded in identifying the inner structure of the reactor complex such as the reactor containment vessel, pressure vessel, and other structures of the reactor building, through the concrete wall of the reactor building. We found that a large amount of fuel assemblies was missing in the original fuel loading zone inside the pressure vessel. It can be naturally interpreted that most of the nuclear fuel was melt and dropped down to the bottom of the pressure vessel or even below.</summary>
|
||||
<category term="physics.ins-det" scheme="http://arxiv.org/schemas/atom"/>
|
||||
<category term="hep-ex" scheme="http://arxiv.org/schemas/atom"/>
|
||||
<published>2020-03-03T03:21:53Z</published>
|
||||
<arxiv:comment>14 pages, 17 figures</arxiv:comment>
|
||||
<arxiv:primary_category term="physics.ins-det"/>
|
||||
<author>
|
||||
<name>Hirofumi Fujii</name>
|
||||
<arxiv:affiliation>High Energy Accelerator Research Organization</arxiv:affiliation>
|
||||
</author>
|
||||
<author>
|
||||
<name>Kazuhiko Hara</name>
|
||||
<arxiv:affiliation>University of Tsukuba</arxiv:affiliation>
|
||||
</author>
|
||||
<author>
|
||||
<name>Kohei Hayashi</name>
|
||||
<arxiv:affiliation>High Energy Accelerator Research Organization</arxiv:affiliation>
|
||||
</author>
|
||||
<author>
|
||||
<name>Hidekazu Kakuno</name>
|
||||
<arxiv:affiliation>Tokyo Metropolitan University</arxiv:affiliation>
|
||||
</author>
|
||||
<author>
|
||||
<name>Hideyo Kodama</name>
|
||||
<arxiv:affiliation>High Energy Accelerator Research Organization</arxiv:affiliation>
|
||||
</author>
|
||||
<author>
|
||||
<name>Kanetada Nagamine</name>
|
||||
<arxiv:affiliation>High Energy Accelerator Research Organization</arxiv:affiliation>
|
||||
</author>
|
||||
<author>
|
||||
<name>Kotaro Sato</name>
|
||||
<arxiv:affiliation>High Energy Accelerator Research Organization</arxiv:affiliation>
|
||||
</author>
|
||||
<author>
|
||||
<name>Shin-Hong Kim</name>
|
||||
<arxiv:affiliation>University of Tsukuba</arxiv:affiliation>
|
||||
</author>
|
||||
<author>
|
||||
<name>Atsuto Suzuki</name>
|
||||
<arxiv:affiliation>High Energy Accelerator Research Organization</arxiv:affiliation>
|
||||
</author>
|
||||
<author>
|
||||
<name>Takayuki Sumiyoshi</name>
|
||||
<arxiv:affiliation>Tokyo Metropolitan University</arxiv:affiliation>
|
||||
</author>
|
||||
<author>
|
||||
<name>Kazuki Takahashi</name>
|
||||
<arxiv:affiliation>University of Tsukuba</arxiv:affiliation>
|
||||
</author>
|
||||
<author>
|
||||
<name>Fumihiko Takasaki</name>
|
||||
<arxiv:affiliation>High Energy Accelerator Research Organization</arxiv:affiliation>
|
||||
</author>
|
||||
<author>
|
||||
<name>Shuji Tanaka</name>
|
||||
<arxiv:affiliation>High Energy Accelerator Research Organization</arxiv:affiliation>
|
||||
</author>
|
||||
<author>
|
||||
<name>Satoru Yamashita</name>
|
||||
<arxiv:affiliation>University of Tokyo</arxiv:affiliation>
|
||||
</author>
|
||||
</entry>
|
||||
<entry>
|
||||
<id>http://arxiv.org/abs/1609.07515v1</id>
|
||||
<title>Low Background Stainless Steel for the Pressure Vessel in the PandaX-II Dark Matter Experiment</title>
|
||||
<updated>2016-09-21T10:33:04Z</updated>
|
||||
<link href="https://arxiv.org/abs/1609.07515v1" rel="alternate" type="text/html"/>
|
||||
<link href="https://arxiv.org/pdf/1609.07515v1" rel="related" type="application/pdf" title="pdf"/>
|
||||
<summary>We report on the custom produced low radiation background stainless steel and the welding rod for the PandaX experiment, one of the deep underground experiments to search for dark matter and neutrinoless double beta decay using xenon. The anthropogenic 60 Co concentration in these samples is at the range of 1 mBq/kg or lower. We also discuss the radioactivity of nuclear-grade stainless steel from TISCO which has a similar background rate. The PandaX-II pressure vessel was thus fabricated using the stainless steel from CISRI and TISCO. Based on the analysis of the radioactivity data, we also made discussions on potential candidate for low background metal materials for future pressure vessel development.</summary>
|
||||
<category term="physics.ins-det" scheme="http://arxiv.org/schemas/atom"/>
|
||||
<category term="hep-ex" scheme="http://arxiv.org/schemas/atom"/>
|
||||
<published>2016-09-21T10:33:04Z</published>
|
||||
<arxiv:primary_category term="physics.ins-det"/>
|
||||
<author>
|
||||
<name>Tao Zhang</name>
|
||||
</author>
|
||||
<author>
|
||||
<name>Changbo Fu</name>
|
||||
</author>
|
||||
<author>
|
||||
<name>Xiangdong Ji</name>
|
||||
</author>
|
||||
<author>
|
||||
<name>Jianglai Liu</name>
|
||||
</author>
|
||||
<author>
|
||||
<name>Xiang Liu</name>
|
||||
</author>
|
||||
<author>
|
||||
<name>Xuming Wang</name>
|
||||
</author>
|
||||
<author>
|
||||
<name>Chunfa Yao</name>
|
||||
</author>
|
||||
<author>
|
||||
<name>Xunhua Yuan</name>
|
||||
</author>
|
||||
<arxiv:doi>10.1088/1748-0221/11/09/T09004</arxiv:doi>
|
||||
<link rel="related" href="https://doi.org/10.1088/1748-0221/11/09/T09004" title="doi"/>
|
||||
</entry>
|
||||
<entry>
|
||||
<id>http://arxiv.org/abs/2308.09786v1</id>
|
||||
<title>Mechanical design of the optical modules intended for IceCube-Gen2</title>
|
||||
<updated>2023-08-18T19:20:09Z</updated>
|
||||
<link href="https://arxiv.org/abs/2308.09786v1" rel="alternate" type="text/html"/>
|
||||
<link href="https://arxiv.org/pdf/2308.09786v1" rel="related" type="application/pdf" title="pdf"/>
|
||||
<summary>IceCube-Gen2 is an expansion of the IceCube neutrino observatory at the South Pole that aims to increase the sensitivity to high-energy neutrinos by an order of magnitude. To this end, about 10,000 new optical modules will be installed, instrumenting a fiducial volume of about 8 km^3. Two newly developed optical module types increase current sensitivity per module by a factor of three by integrating 16 and 18 newly developed four-inch PMTs in specially designed 12.5-inch diameter pressure vessels. Both designs use conical silicone gel pads to optically couple the PMTs to the pressure vessel to increase photon collection efficiency. The outside portion of gel pads are pre-cast onto each PMT prior to integration, while the interiors are filled and cast after the PMT assemblies are installed in the pressure vessel via a pushing mechanism. This paper presents both the mechanical design, as well as the performance of prototype modules at high pressure (70 MPa) and low temperature (-40 degree Celsius), characteristic of the environment inside the South Pole ice.</summary>
|
||||
<category term="astro-ph.IM" scheme="http://arxiv.org/schemas/atom"/>
|
||||
<category term="astro-ph.HE" scheme="http://arxiv.org/schemas/atom"/>
|
||||
<published>2023-08-18T19:20:09Z</published>
|
||||
<arxiv:comment>Presented at the 38th International Cosmic Ray Conference (ICRC2023). See arXiv:2307.13048 for all IceCube-Gen2 contributions</arxiv:comment>
|
||||
<arxiv:primary_category term="astro-ph.IM"/>
|
||||
<author>
|
||||
<name>Yuya Makino</name>
|
||||
<arxiv:affiliation>for the IceCube-Gen2 Collaboration</arxiv:affiliation>
|
||||
</author>
|
||||
</entry>
|
||||
<entry>
|
||||
<id>http://arxiv.org/abs/0804.0261v1</id>
|
||||
<title>Circulation in Blowdown Flows</title>
|
||||
<updated>2008-04-01T22:22:32Z</updated>
|
||||
<link href="https://arxiv.org/abs/0804.0261v1" rel="alternate" type="text/html"/>
|
||||
<link href="https://arxiv.org/pdf/0804.0261v1" rel="related" type="application/pdf" title="pdf"/>
|
||||
<summary> The blowdown of high pressure gas in a pressure vessel produces rapid adiabatic cooling of the gas remaining in the vessel. The gas near the wall is warmed by conduction from the wall, producing radial temperature and density gradients that affect the flow, the mass efflux rate and the thermodynamic states of both the outflowing and the contained gas. The resulting buoyancy-driven flow circulates gas through the vessel and reduces, but does not eliminate, these gradients. The purpose of this note is to estimate when blowdown cooling is rapid enough that the gas in the pressure vessel is neither isothermal nor isopycnic, though it remains isobaric. I define a dimensionless number, the buoyancy circulation number BC, that parametrizes these effects.</summary>
|
||||
<category term="physics.flu-dyn" scheme="http://arxiv.org/schemas/atom"/>
|
||||
<published>2008-04-01T22:22:32Z</published>
|
||||
<arxiv:comment>5 pp., no figures</arxiv:comment>
|
||||
<arxiv:primary_category term="physics.flu-dyn"/>
|
||||
<arxiv:journal_ref>J. Pressure Vessel Tech. 131, 034501 (2009)</arxiv:journal_ref>
|
||||
<author>
|
||||
<name>J. I. Katz</name>
|
||||
</author>
|
||||
</entry>
|
||||
<entry>
|
||||
<id>http://arxiv.org/abs/1204.0234v1</id>
|
||||
<title>Substantiation of Thermodynamic Criteria of Explosion Safety in Process of Severe Accidents in Pressure Vessel Reactors</title>
|
||||
<updated>2012-03-27T11:21:14Z</updated>
|
||||
<link href="https://arxiv.org/abs/1204.0234v1" rel="alternate" type="text/html"/>
|
||||
<link href="https://arxiv.org/pdf/1204.0234v1" rel="related" type="application/pdf" title="pdf"/>
|
||||
<summary>The paper represents original development of thermodynamic criteria of occurrence conditions of steam-gas explosions in the process of severe accidents. The received results can be used for modelling of processes of severe accidents in pressure vessel reactors.</summary>
|
||||
<category term="physics.gen-ph" scheme="http://arxiv.org/schemas/atom"/>
|
||||
<published>2012-03-27T11:21:14Z</published>
|
||||
<arxiv:comment>5 pages, 1 figure</arxiv:comment>
|
||||
<arxiv:primary_category term="physics.gen-ph"/>
|
||||
<author>
|
||||
<name>V. I. Skalozubov</name>
|
||||
</author>
|
||||
<author>
|
||||
<name>V. N. Vashchenko</name>
|
||||
</author>
|
||||
<author>
|
||||
<name>S. S. Jarovoj</name>
|
||||
</author>
|
||||
<author>
|
||||
<name>V. Yu. Kochnyeva</name>
|
||||
</author>
|
||||
</entry>
|
||||
<entry>
|
||||
<id>http://arxiv.org/abs/2511.11485v1</id>
|
||||
<title>Data-efficient U-Net for Segmentation of Carbide Microstructures in SEM Images of Steel Alloys</title>
|
||||
<updated>2025-11-14T17:01:02Z</updated>
|
||||
<link href="https://arxiv.org/abs/2511.11485v1" rel="alternate" type="text/html"/>
|
||||
<link href="https://arxiv.org/pdf/2511.11485v1" rel="related" type="application/pdf" title="pdf"/>
|
||||
<summary>Understanding reactor-pressure-vessel steel microstructure is crucial for predicting mechanical properties, as carbide precipitates both strengthen the alloy and can initiate cracks. In scanning electron microscopy images, gray-value overlap between carbides and matrix makes simple thresholding ineffective. We present a data-efficient segmentation pipeline using a lightweight U-Net (30.7~M parameters) trained on just \textbf{10 annotated scanning electron microscopy images}. Despite limited data, our model achieves a \textbf{Dice-Sørensen coefficient of 0.98}, significantly outperforming the state-of-the-art in the field of metallurgy (classical image analysis: 0.85), while reducing annotation effort by one order of magnitude compared to the state-of-the-art data efficient segmentation model. This approach enables rapid, automated carbide quantification for alloy design and generalizes to other steel types, demonstrating the potential of data-efficient deep learning in reactor-pressure-vessel steel analysis.</summary>
|
||||
<category term="cs.LG" scheme="http://arxiv.org/schemas/atom"/>
|
||||
<category term="cond-mat.mtrl-sci" scheme="http://arxiv.org/schemas/atom"/>
|
||||
<published>2025-11-14T17:01:02Z</published>
|
||||
<arxiv:primary_category term="cs.LG"/>
|
||||
<arxiv:journal_ref>Machine Learning and the Physical Sciences Workshop @ NeurIPS 2025 https://openreview.net/forum?id=xYY5pn4f8N</arxiv:journal_ref>
|
||||
<author>
|
||||
<name>Alinda Ezgi Gerçek</name>
|
||||
</author>
|
||||
<author>
|
||||
<name>Till Korten</name>
|
||||
</author>
|
||||
<author>
|
||||
<name>Paul Chekhonin</name>
|
||||
</author>
|
||||
<author>
|
||||
<name>Maleeha Hassan</name>
|
||||
</author>
|
||||
<author>
|
||||
<name>Peter Steinbach</name>
|
||||
</author>
|
||||
</entry>
|
||||
<entry>
|
||||
<id>http://arxiv.org/abs/2511.09689v1</id>
|
||||
<title>An ASME-Compliant Helium-4 Evaporation Refrigerator for the SpinQuest Experiment</title>
|
||||
<updated>2025-11-12T19:45:47Z</updated>
|
||||
<link href="https://arxiv.org/abs/2511.09689v1" rel="alternate" type="text/html"/>
|
||||
<link href="https://arxiv.org/pdf/2511.09689v1" rel="related" type="application/pdf" title="pdf"/>
|
||||
<summary>This paper presents the design, safety basis, and commissioning results of a 1 K liquid helium-4 (4He) evaporation refrigerator developed for the Fermilab SpinQuest Experiment (E1039). The system represents the first high power helium evaporation refrigerator operated in a fixed target scattering experiment at Fermilab and was engineered to comply with the Fermilab ES\&H Manual (FESHM) requirements governing pressure vessels, piping, cryogenic systems, and vacuum vessels. The design is mapped to ASME B31.3 (Process Piping) and the ASME Boiler and Pressure Vessel Code (BPVC) for pressure boundary integrity and overpressure protection, with documented compliance to FESHM Chapters 5031 (Pressure Vessels), 5031.1 (Piping Systems), and 5033 (Vacuum Vessels). This work documents the methodology used to reach compliance and approval for the 4He evaporation refrigerator at Fermilab which the field lacks. Design considerations specific to the high radiation target-cave environment including remotely located instrumentation approximately 20 m from the cryostat are summarized, together with the relief-system sizing methodology used to accommodate transient heat loads from dynamic nuclear polarization microwaves and the high-intensity proton beam. Commissioning data from July 2024 confirms that the system satisfies all thermal performance and safety objectives.</summary>
|
||||
<category term="physics.ins-det" scheme="http://arxiv.org/schemas/atom"/>
|
||||
<published>2025-11-12T19:45:47Z</published>
|
||||
<arxiv:comment>For IEEE Transactions in Nuclear Physics, 11 pages, 14 figures</arxiv:comment>
|
||||
<arxiv:primary_category term="physics.ins-det"/>
|
||||
<author>
|
||||
<name>Jordan D. Roberts</name>
|
||||
</author>
|
||||
<author>
|
||||
<name>Vibodha Bandara</name>
|
||||
</author>
|
||||
<author>
|
||||
<name>Kenichi Nakano</name>
|
||||
</author>
|
||||
<author>
|
||||
<name>Dustin Keller</name>
|
||||
</author>
|
||||
</entry>
|
||||
<entry>
|
||||
<id>http://arxiv.org/abs/1507.04072v1</id>
|
||||
<title>High-Voltage Terminal Test of Test Stand for 1-MV Electrostatic Accelerator</title>
|
||||
<updated>2015-07-15T02:41:11Z</updated>
|
||||
<link href="https://arxiv.org/abs/1507.04072v1" rel="alternate" type="text/html"/>
|
||||
<link href="https://arxiv.org/pdf/1507.04072v1" rel="related" type="application/pdf" title="pdf"/>
|
||||
<summary>The Korea Multipurpose Accelerator Complex (KOMAC) has been developing a 300-kV test stand for a 1-MV electrostatic accelerator ion source. The ion source and accelerating tube will be installed in a high-pressure vessel. The ion source in the high-pressure vessel is required to have a high reliability. The test stand has been proposed and developed to confirm the stable operating conditions of the ion source. The ion source will be tested at the test stand to verify the long-time operating conditions. The test stand comprises a 300-kV high-voltage terminal, a battery for the ion-source power, a 60-Hz inverter, 200-MHz RF power, a 5-kV extraction power supply, a 300-kV accelerating tube, and a vacuum system. The results of the 300-kV high-voltage terminal tests are presented in this paper.</summary>
|
||||
<category term="physics.acc-ph" scheme="http://arxiv.org/schemas/atom"/>
|
||||
<published>2015-07-15T02:41:11Z</published>
|
||||
<arxiv:comment>International Conference on Accelerators and Beam Utilization (ICABU2014)</arxiv:comment>
|
||||
<arxiv:primary_category term="physics.acc-ph"/>
|
||||
<arxiv:journal_ref>Yong-Sub Cho KNS (2014); W. Sima IEEE (2004) 480-483; LA-UR-87-126 (1987); Jeong-tae Kim KNS (2014)</arxiv:journal_ref>
|
||||
<author>
|
||||
<name>Sae-Hoon Park</name>
|
||||
</author>
|
||||
<author>
|
||||
<name>Yu-Seok Kim</name>
|
||||
</author>
|
||||
<arxiv:doi>10.3938/jkps</arxiv:doi>
|
||||
<link rel="related" href="https://doi.org/10.3938/jkps" title="doi"/>
|
||||
</entry>
|
||||
<entry>
|
||||
<id>http://arxiv.org/abs/2005.05585v1</id>
|
||||
<title>Investigation of the Status of Unit 2 Nuclear Reactor of the Fukushima Daiichi by the Cosmic Muon Radiography</title>
|
||||
<updated>2020-05-12T07:26:37Z</updated>
|
||||
<link href="https://arxiv.org/abs/2005.05585v1" rel="alternate" type="text/html"/>
|
||||
<link href="https://arxiv.org/pdf/2005.05585v1" rel="related" type="application/pdf" title="pdf"/>
|
||||
<summary>We have investigated the status of the nuclear debris in the Unit-2 Nuclear Reactor of the Fukushima Daiichi Nuclear Power plant by the method called Cosmic Muon Radiography. In this measurement, the muon detector was placed outside of the reactor building as was the case of the measurement for the Unit-1 Reactor. Compared to the previous measurements, the detector was down-sized, which made us possible to locate it closer to the reactor and to investigate especially the lower part of the fuel loading zone. We identified the inner structures of the reactor such as the containment vessel, pressure vessel and other objects through the thick concrete wall of the reactor building. Furthermore, the observation showed existence of heavy material at the bottom of the pressure vessel, which can be interpreted as the debris of melted nuclear fuel dropped from the loading zone.</summary>
|
||||
<category term="physics.ins-det" scheme="http://arxiv.org/schemas/atom"/>
|
||||
<published>2020-05-12T07:26:37Z</published>
|
||||
<arxiv:comment>11 figures and 2 tables</arxiv:comment>
|
||||
<arxiv:primary_category term="physics.ins-det"/>
|
||||
<author>
|
||||
<name>Hirofumi Fujii</name>
|
||||
</author>
|
||||
<author>
|
||||
<name>Kazuhiko Hara</name>
|
||||
</author>
|
||||
<author>
|
||||
<name>Shugo Hashimoto</name>
|
||||
</author>
|
||||
<author>
|
||||
<name>Kohei Hayashi</name>
|
||||
</author>
|
||||
<author>
|
||||
<name>Hidekazu Kakuno</name>
|
||||
</author>
|
||||
<author>
|
||||
<name>Hideyo Kodama</name>
|
||||
</author>
|
||||
<author>
|
||||
<name>Gi Meiki</name>
|
||||
</author>
|
||||
<author>
|
||||
<name>Masato Mizokami</name>
|
||||
</author>
|
||||
<author>
|
||||
<name>Shinya Mizokami</name>
|
||||
</author>
|
||||
<author>
|
||||
<name>Kanetada Nagamine</name>
|
||||
</author>
|
||||
<author>
|
||||
<name>Kotaro Sato</name>
|
||||
</author>
|
||||
<author>
|
||||
<name>Shunsuke Sekita</name>
|
||||
</author>
|
||||
<author>
|
||||
<name>Hiroshi Shirai</name>
|
||||
</author>
|
||||
<author>
|
||||
<name>Shin-Hong Kim</name>
|
||||
</author>
|
||||
<author>
|
||||
<name>Takayuki Sumiyoshi</name>
|
||||
</author>
|
||||
<author>
|
||||
<name>Atsuto Suzuki</name>
|
||||
</author>
|
||||
<author>
|
||||
<name>Yoshihisa Takada</name>
|
||||
</author>
|
||||
<author>
|
||||
<name>Kazuki Takahashi</name>
|
||||
</author>
|
||||
<author>
|
||||
<name>Yu Takahashi</name>
|
||||
</author>
|
||||
<author>
|
||||
<name>Fumihiko Takasaki</name>
|
||||
</author>
|
||||
<author>
|
||||
<name>Daichi Yamada</name>
|
||||
</author>
|
||||
<author>
|
||||
<name>Satoru Yamashita</name>
|
||||
</author>
|
||||
</entry>
|
||||
</feed>
|
||||
+1
File diff suppressed because one or more lines are too long
@@ -0,0 +1,106 @@
|
||||
"""_ENG 매처 노이즈 차단 단위테스트 (asme-item-decomp-1 D1).
|
||||
|
||||
핵심 불변식: 영문 구조 헤딩 매처(_ENG)가
|
||||
- (음성) 본문 중간 'Part III to demonstrate…' 같은 소문자 문장연속을 가짜 절로 잡지 않고,
|
||||
- (양성) 진짜 영문 구조 헤딩(PART PG / Part 1 / Section 3.31 / Part UHX …)은 탐지하며,
|
||||
- (ATX 보존) _ENG 축소가 ATX 파트(`# PART PG`)·항목(`#### PG-1`)을 떨구지 않는다(ATX 우선).
|
||||
|
||||
pytest + 단독 실행 양쪽 지원:
|
||||
PYTHONPATH=. python3 tests/hier_decomp/test_eng_matcher.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
try: # pytest 경로 (앱 패키지)
|
||||
from app.services.hier_decomp.builder import _detect_heading, build_hier_tree
|
||||
except Exception: # 단독 실행 (앱 deps 없이 builder.py 직접 로드 — stdlib only)
|
||||
import importlib.util
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
_bp = pathlib.Path(__file__).resolve().parents[2] / "app/services/hier_decomp/builder.py"
|
||||
_spec = importlib.util.spec_from_file_location("_hier_builder_t", _bp)
|
||||
_m = importlib.util.module_from_spec(_spec)
|
||||
sys.modules[_spec.name] = _m # dataclass __module__ 해소
|
||||
_spec.loader.exec_module(_m)
|
||||
_detect_heading, build_hier_tree = _m._detect_heading, _m.build_hier_tree
|
||||
|
||||
|
||||
# ── 음성: 본문 문장은 헤딩 아님 (가짜 절 차단 — D1 회귀의 핵심) ──
|
||||
NEG = [
|
||||
"Part III to demonstrate to the satisfaction of the represen-",
|
||||
"Section V of the agreement applies to all parties",
|
||||
"Part IV is hereby amended as follows",
|
||||
"Article II shall be interpreted broadly",
|
||||
"Chapter 3 describes the general method used here",
|
||||
]
|
||||
|
||||
# ── 양성: 진짜 영문 구조 헤딩 ──
|
||||
POS = [
|
||||
"PART PG GENERAL REQUIREMENTS FOR ALL METHODS OF CONSTRUCTION",
|
||||
"Part 1",
|
||||
"Part PFH",
|
||||
"Part UHX (TUBESHEET CALCULATION)",
|
||||
"Section 3.31",
|
||||
"Chapter 1 Introduction",
|
||||
"Article 5 Definitions",
|
||||
]
|
||||
|
||||
|
||||
def test_eng_negatives_not_detected():
|
||||
for line in NEG:
|
||||
assert _detect_heading(line) is None, f"가짜 절로 잡힘: {line!r}"
|
||||
|
||||
|
||||
def test_eng_positives_detected_as_chapter():
|
||||
for line in POS:
|
||||
r = _detect_heading(line)
|
||||
assert r is not None, f"진짜 헤딩 미탐지: {line!r}"
|
||||
_lvl, _title, nt = r
|
||||
assert nt == "chapter", f"{line!r} node_type={nt}"
|
||||
|
||||
|
||||
def test_atx_part_and_item_still_detected():
|
||||
# _ENG 축소가 진짜 ATX 파트/항목을 떨구지 않음 (ATX 우선 탐지)
|
||||
r = _detect_heading("# PART PG GENERAL REQUIREMENTS FOR ALL METHODS OF CONSTRUCTION")
|
||||
assert r is not None
|
||||
lvl, title, nt = r
|
||||
assert lvl == 1 and nt is None, r # ATX = level(# 수), node_type None
|
||||
assert title.startswith("PART PG")
|
||||
r2 = _detect_heading("#### PG-1 SCOPE")
|
||||
assert r2 is not None and r2[0] == 4 and r2[2] is None, r2
|
||||
|
||||
|
||||
def test_build_hier_tree_drops_false_part_section():
|
||||
# 본문에 'Part III to demonstrate…' 가 섞여도 가짜 절이 생기지 않음
|
||||
md = (
|
||||
"# PART PG GENERAL REQUIREMENTS\n"
|
||||
"#### PG-1 SCOPE\n"
|
||||
"The rules cover power boilers.\n"
|
||||
"Part III to demonstrate to the satisfaction of the representative\n"
|
||||
"that the requirements are met, the manufacturer shall proceed...\n"
|
||||
"#### PG-2 SERVICE LIMITATIONS\n"
|
||||
"body of pg-2 here.\n"
|
||||
)
|
||||
titles = [n.section_title for n in build_hier_tree(md) if n.section_title]
|
||||
assert any(t.startswith("PART PG") for t in titles), titles
|
||||
assert any(t.startswith("PG-1") for t in titles), titles
|
||||
assert any(t.startswith("PG-2") for t in titles), titles
|
||||
assert not any("demonstrate" in (t or "") for t in titles), f"가짜 절 누출: {titles}"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
import traceback
|
||||
|
||||
fns = [(k, v) for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)]
|
||||
failed = 0
|
||||
for name, fn in fns:
|
||||
try:
|
||||
fn()
|
||||
print(f"PASS {name}")
|
||||
except Exception as e:
|
||||
failed += 1
|
||||
print(f"FAIL {name}: {e}")
|
||||
traceback.print_exc()
|
||||
print(f"\n{len(fns) - failed}/{len(fns)} passed")
|
||||
sys.exit(1 if failed else 0)
|
||||
@@ -1394,7 +1394,7 @@ def main() -> int:
|
||||
"--reranker-backend",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Phase 2B Diagnose reranker dispatcher slug (baseline | cand_gte_ml_base). 미지정 = production.",
|
||||
help="Phase 2B Diagnose reranker dispatcher slug (baseline). 후보 cand_gte_ml_base = NO-GO 종결·teardown(2026-06-18). 미지정 = production.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--rewrite-backend",
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
"""B-3 PR2 — arXiv 파서·쿼리빌더 순수 단위 테스트 (plan safety-library-b3-1).
|
||||
|
||||
fixture = arXiv API 실응답 박제(abs:"pressure vessel" relevance 10건 —
|
||||
DOI 보유 / journal_ref 만 보유 / 둘 다 없음 3경로 포함). run()/적재(DB)는 PR2 라이브 검증.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "app"))
|
||||
|
||||
from workers.arxiv_collector import ( # noqa: E402
|
||||
build_search_query,
|
||||
parse_arxiv_feed,
|
||||
)
|
||||
|
||||
FIX = Path(__file__).parent / "fixtures" / "arxiv_search_pressure_vessel.xml"
|
||||
|
||||
|
||||
def _entries():
|
||||
total, entries = parse_arxiv_feed(FIX.read_text(encoding="utf-8"))
|
||||
return total, {e.arxiv_id: e for e in entries}, entries
|
||||
|
||||
|
||||
# ─── 피드 레벨 ───
|
||||
|
||||
def test_feed_total_and_count():
|
||||
total, by_id, entries = _entries()
|
||||
assert total == 89 # fixture totalResults (페이징 재료)
|
||||
assert len(entries) == 10
|
||||
|
||||
|
||||
def test_versionless_ids():
|
||||
_, by_id, entries = _entries()
|
||||
# arxiv_id 는 versionless (버전 접미는 .version 으로 분리)
|
||||
assert all("/" not in e.arxiv_id for e in entries)
|
||||
assert "1209.2405" in by_id and by_id["1209.2405"].version == "v1"
|
||||
|
||||
|
||||
# ─── DOI 보유 entry ───
|
||||
|
||||
def test_entry_with_doi():
|
||||
_, by_id, _ = _entries()
|
||||
e = by_id["1209.2405"]
|
||||
assert e.doi == "10.1063/1.4707088" # normalize_doi 적용(소문자·정규화)
|
||||
assert e.journal_ref is None
|
||||
assert e.primary_category == "physics.acc-ph"
|
||||
assert e.title.startswith("A Survey of Pressure Vessel")
|
||||
assert len(e.summary) > 200 # 초록 본문
|
||||
assert e.published is not None
|
||||
assert e.abs_url and "/abs/" in e.abs_url
|
||||
assert e.pdf_url and "pdf" in e.pdf_url
|
||||
|
||||
|
||||
# ─── journal_ref 만 (DOI 없음) — 압력용기 저널 출판분 ───
|
||||
|
||||
def test_entry_journal_ref_without_doi():
|
||||
_, by_id, _ = _entries()
|
||||
e = by_id["0804.0261"]
|
||||
assert e.doi is None
|
||||
assert e.journal_ref and "Pressure Vessel" in e.journal_ref
|
||||
|
||||
|
||||
# ─── 둘 다 없음(최근 preprint) 경로도 존재 ───
|
||||
|
||||
def test_entry_neither_doi_nor_journal_ref_exists():
|
||||
_, _, entries = _entries()
|
||||
assert any(e.doi is None and e.journal_ref is None for e in entries)
|
||||
|
||||
|
||||
# ─── 쿼리 빌더 ───
|
||||
|
||||
def test_build_search_query():
|
||||
q = build_search_query("eess.SY", ["pressure vessel", "safety"])
|
||||
assert q == 'cat:eess.SY AND (abs:"pressure vessel" OR abs:safety)'
|
||||
@@ -37,8 +37,7 @@ from services.search.freshness_decay import (
|
||||
NOW = datetime(2026, 5, 3, 12, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def _meta(channel: str | None, *, days_ago: float | None = 30.0, origin: str | None = None,
|
||||
material_type: str | None = None) -> _DocMeta:
|
||||
def _meta(channel: str | None, *, days_ago: float | None = 30.0, origin: str | None = None) -> _DocMeta:
|
||||
if days_ago is None:
|
||||
created = None
|
||||
elif days_ago < 0:
|
||||
@@ -46,8 +45,7 @@ def _meta(channel: str | None, *, days_ago: float | None = 30.0, origin: str | N
|
||||
created = NOW + timedelta(days=-days_ago)
|
||||
else:
|
||||
created = NOW - timedelta(days=days_ago)
|
||||
return _DocMeta(source_channel=channel, content_origin=origin, created_at=created,
|
||||
material_type=material_type)
|
||||
return _DocMeta(source_channel=channel, content_origin=origin, created_at=created)
|
||||
|
||||
|
||||
# ─── policy dispatcher ────────────────────────────────────────────
|
||||
@@ -57,15 +55,8 @@ def test_policy_news():
|
||||
assert freshness_policy(_meta("news")) == "news_90d"
|
||||
|
||||
|
||||
def test_policy_law_monitor_now_unaffected():
|
||||
# C-1 후속: law_365d 폐기 → law_monitor 비적용 (현행성은 version_status 가 처리)
|
||||
assert freshness_policy(_meta("law_monitor")) is None
|
||||
|
||||
|
||||
def test_policy_incident():
|
||||
# C-1 후속: 재해사례/사망사고(material_type='incident') → news_90d 흡수 (source 무관)
|
||||
assert freshness_policy(_meta("crawl", material_type="incident")) == "news_90d"
|
||||
assert freshness_policy(_meta("inbox_route", material_type="incident")) == "news_90d"
|
||||
def test_policy_law_monitor():
|
||||
assert freshness_policy(_meta("law_monitor")) == "law_365d"
|
||||
|
||||
|
||||
def test_policy_manual_unaffected():
|
||||
@@ -132,9 +123,8 @@ def test_decay_at_half_life_news():
|
||||
assert compute_decay(90.0, "news_90d") == pytest.approx(0.5, rel=1e-6)
|
||||
|
||||
|
||||
def test_decay_law_365d_removed_returns_one():
|
||||
# C-1 후속: law_365d 폐기 → HALF_LIFE_DAYS 미등록 policy → decay 1.0 (no-op)
|
||||
assert compute_decay(365.0, "law_365d") == 1.0
|
||||
def test_decay_at_half_life_law():
|
||||
assert compute_decay(365.0, "law_365d") == pytest.approx(0.5, rel=1e-6)
|
||||
|
||||
|
||||
def test_decay_age_zero_full():
|
||||
@@ -222,38 +212,22 @@ async def test_apply_news_recent_vs_old_recent_higher():
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_law_monitor_now_unaffected():
|
||||
# C-1 후속: law_monitor freshness 폐기 → recent/old 동일 score (재정렬 없음)
|
||||
async def test_apply_law_monitor_recent_vs_old_recent_higher():
|
||||
# 가드 2: law_monitor recent 가 위
|
||||
base = 0.50
|
||||
rows = [
|
||||
{"id": 1, "source_channel": "law_monitor", "content_origin": "extracted",
|
||||
"material_type": "law", "created_at": NOW - timedelta(days=10)},
|
||||
"created_at": NOW - timedelta(days=10)},
|
||||
{"id": 2, "source_channel": "law_monitor", "content_origin": "extracted",
|
||||
"material_type": "law", "created_at": NOW - timedelta(days=730)},
|
||||
]
|
||||
session = _MockSession(rows)
|
||||
results = [_result(1, base), _result(2, base)]
|
||||
out = await apply_freshness_decay(results, session, now=NOW)
|
||||
assert out[0].score == base and out[1].score == base
|
||||
assert out[0].freshness_debug["freshness_policy"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_incident_recent_vs_old_recent_higher():
|
||||
# C-1 후속: 재해사례(incident) recent 가 위 (news_90d 흡수, source_channel='crawl')
|
||||
base = 0.50
|
||||
rows = [
|
||||
{"id": 1, "source_channel": "crawl", "content_origin": "extracted",
|
||||
"material_type": "incident", "created_at": NOW - timedelta(days=5)},
|
||||
{"id": 2, "source_channel": "crawl", "content_origin": "extracted",
|
||||
"material_type": "incident", "created_at": NOW - timedelta(days=400)},
|
||||
"created_at": NOW - timedelta(days=730)}, # 2년
|
||||
]
|
||||
session = _MockSession(rows)
|
||||
results = [_result(1, base), _result(2, base)]
|
||||
out = await apply_freshness_decay(results, session, now=NOW)
|
||||
assert out[0].id == 1
|
||||
assert out[0].score > out[1].score
|
||||
assert out[0].freshness_debug["freshness_policy"] == "news_90d"
|
||||
assert out[0].freshness_debug["freshness_policy"] == "law_365d"
|
||||
# 2년 → law_365d 반감기 1년 → decay ~0.25 → multiplier ~ 0.775
|
||||
assert out[1].score < out[0].score
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
"""B-3 PR3 — OpenAlex 파서·초록복원·license 순수 단위 테스트 (plan safety-library-b3-1).
|
||||
|
||||
fixture = OpenAlex /works 실응답 박제(process safety/pressure vessel OA 5건 —
|
||||
cc-by/cc-by-nc-nd/license None, 초록 있음/없음). run()/적재(DB)는 PR3 라이브 검증.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "app"))
|
||||
|
||||
from workers.openalex_collector import ( # noqa: E402
|
||||
_reconstruct_abstract,
|
||||
_seeds,
|
||||
build_filter,
|
||||
build_issn_filter,
|
||||
license_meta,
|
||||
parse_openalex_works,
|
||||
)
|
||||
|
||||
FIX = Path(__file__).parent / "fixtures" / "openalex_works_response.json"
|
||||
|
||||
|
||||
def _works():
|
||||
count, cursor, works = parse_openalex_works(FIX.read_text(encoding="utf-8"))
|
||||
return count, {w.openalex_id: w for w in works}, works
|
||||
|
||||
|
||||
# ─── 피드 레벨 ───
|
||||
|
||||
def test_count_and_results():
|
||||
count, by_id, works = _works()
|
||||
assert count == 1111
|
||||
assert len(works) == 5
|
||||
assert all(w.openalex_id.startswith("W") and "/" not in w.openalex_id for w in works)
|
||||
|
||||
|
||||
# ─── 초록 보유 + CC 라이선스 ───
|
||||
|
||||
def test_work_with_abstract_and_cc():
|
||||
_, by_id, _ = _works()
|
||||
w = by_id["W2910511816"]
|
||||
assert w.doi and w.doi.startswith("10.") and w.doi == w.doi.lower() # normalize_doi
|
||||
assert len(w.abstract) > 50 # inverted-index 복원
|
||||
assert w.oa_status == "diamond" and w.is_oa is True
|
||||
assert w.license == "cc-by"
|
||||
assert license_meta(w.license, w.is_oa, w.source_name)["redistribute"] is True
|
||||
|
||||
|
||||
# ─── 초록 없는 thin 레코드(skip 대상) ───
|
||||
|
||||
def test_work_without_abstract():
|
||||
_, by_id, _ = _works()
|
||||
w = by_id["W3107397139"]
|
||||
assert w.abstract == "" # inverted-index 부재 → 빈 초록
|
||||
lm = license_meta(w.license, w.is_oa, w.source_name)
|
||||
assert lm["redistribute"] is False # license None → 비배포
|
||||
|
||||
|
||||
# ─── cc-by-nc-nd 도 CC 계열 → redistribute True ───
|
||||
|
||||
def test_cc_variant_redistribute():
|
||||
_, by_id, _ = _works()
|
||||
w = by_id["W4391130399"]
|
||||
assert w.license == "cc-by-nc-nd"
|
||||
assert license_meta(w.license, w.is_oa, w.source_name)["redistribute"] is True
|
||||
|
||||
|
||||
# ─── 초록 inverted-index 복원 순서 ───
|
||||
|
||||
def test_reconstruct_abstract_order():
|
||||
inv = {"Safety": [0], "of": [1, 4], "pressure": [2], "vessels": [3], "design": [5]}
|
||||
assert _reconstruct_abstract(inv) == "Safety of pressure vessels of design"
|
||||
assert _reconstruct_abstract(None) == ""
|
||||
assert _reconstruct_abstract({}) == ""
|
||||
|
||||
|
||||
# ─── license_meta 분기 ───
|
||||
|
||||
def test_license_meta_branches():
|
||||
assert license_meta("cc-by", True, "X")["redistribute"] is True
|
||||
assert license_meta("cc0", True, "X")["redistribute"] is True
|
||||
none_oa = license_meta(None, True, "X")
|
||||
assert none_oa["redistribute"] is False and none_oa["scheme"] == "open-unspecified"
|
||||
closed = license_meta(None, False, "X")
|
||||
assert closed["redistribute"] is False and closed["scheme"] == "proprietary"
|
||||
|
||||
|
||||
# ─── 쿼리 빌더 ───
|
||||
|
||||
def test_build_filter():
|
||||
assert build_filter("process safety") == "title_and_abstract.search:process safety"
|
||||
assert build_filter("process safety", "2026-06-01") == \
|
||||
"title_and_abstract.search:process safety,from_publication_date:2026-06-01"
|
||||
|
||||
|
||||
# ─── PR6: ISSN 소스 시드 (KR/JP 안전 저널 직접 커버) ───
|
||||
|
||||
def test_build_issn_filter_and_seeds():
|
||||
assert build_issn_filter("1738-3803") == "primary_location.source.issn:1738-3803"
|
||||
assert build_issn_filter("1738-3803", "2026-01-01") == \
|
||||
"primary_location.source.issn:1738-3803,from_publication_date:2026-01-01"
|
||||
seeds = _seeds()
|
||||
kinds = [k for _, _, k in seeds]
|
||||
assert kinds[0] == "issn" # ISSN 시드가 키워드보다 먼저(cap 우선권)
|
||||
assert any(v == "1738-3803" and k == "issn" for _, v, k in seeds) # 한국안전학회지 포함
|
||||
@@ -0,0 +1,141 @@
|
||||
"""B-3 PR1 — 논문 DOI 코어 순수 단위 테스트 (plan safety-library-b3-1).
|
||||
|
||||
holder.find_paper_holder(DB 조회)는 PR2 arXiv 실수집 시 라이브 검증 — 여기선 순수 함수만.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "app"))
|
||||
|
||||
from services.papers.doi import ( # noqa: E402
|
||||
arxiv_doi,
|
||||
normalize_doi,
|
||||
paper_doi_hash,
|
||||
parse_arxiv_id,
|
||||
parse_doi_from_text,
|
||||
read_paper_doi,
|
||||
with_paper_doi,
|
||||
with_parent_doi,
|
||||
)
|
||||
|
||||
|
||||
# ─── normalize_doi: 단일 함수(저장=조회) ───
|
||||
|
||||
def test_normalize_strips_url_and_lowercases():
|
||||
assert normalize_doi("https://doi.org/10.1585/PFR.15.2402039") == "10.1585/pfr.15.2402039"
|
||||
assert normalize_doi("http://dx.doi.org/10.1115/1.4045678") == "10.1115/1.4045678"
|
||||
assert normalize_doi("doi:10.1016/j.jlp.2020.104321") == "10.1016/j.jlp.2020.104321"
|
||||
assert normalize_doi("DOI: 10.1234/ABC") == "10.1234/abc"
|
||||
|
||||
|
||||
def test_normalize_trims_whitespace_and_citation_noise():
|
||||
assert normalize_doi(" https://doi.org/10.1234/abc ") == "10.1234/abc"
|
||||
assert normalize_doi("10.1234/abc.") == "10.1234/abc"
|
||||
assert normalize_doi("10.1234/abc;") == "10.1234/abc"
|
||||
|
||||
|
||||
def test_normalize_preserves_parens_in_doi():
|
||||
# 괄호는 DOI 일부일 수 있어 보존 (과삭제 = 다른 논문 병합 = 데이터 손상, near-dup 보다 위험)
|
||||
assert normalize_doi("10.1016/s0010-8650(00)80003-2") == "10.1016/s0010-8650(00)80003-2"
|
||||
assert normalize_doi("https://doi.org/10.1016/S0010-8650(00)80003-2") == "10.1016/s0010-8650(00)80003-2"
|
||||
|
||||
|
||||
def test_normalize_rejects_non_doi():
|
||||
assert normalize_doi(None) is None
|
||||
assert normalize_doi("") is None
|
||||
assert normalize_doi(" ") is None
|
||||
assert normalize_doi("not-a-doi") is None
|
||||
assert normalize_doi("arXiv:2606.08108") is None # arXiv id 는 DOI 아님
|
||||
|
||||
|
||||
def test_normalize_is_idempotent_store_equals_lookup():
|
||||
# 저장측·조회측이 같은 함수를 거치면 표기 차이가 한 값으로 붕괴 (dedup 성립 조건)
|
||||
forms = [
|
||||
"https://doi.org/10.1/X",
|
||||
"doi:10.1/x",
|
||||
"10.1/X",
|
||||
" HTTPS://DOI.ORG/10.1/x ",
|
||||
]
|
||||
assert {normalize_doi(f) for f in forms} == {"10.1/x"}
|
||||
assert normalize_doi(normalize_doi("https://doi.org/10.1/X")) == "10.1/x" # 멱등
|
||||
|
||||
|
||||
# ─── paper_doi_hash: holder file_hash 키 ───
|
||||
|
||||
def test_paper_doi_hash_deterministic_len32():
|
||||
h = paper_doi_hash("10.1234/abc")
|
||||
assert len(h) == 32
|
||||
assert h == paper_doi_hash("10.1234/abc")
|
||||
|
||||
|
||||
def test_paper_doi_hash_distinct_per_doi():
|
||||
assert paper_doi_hash("10.1/a") != paper_doi_hash("10.1/b")
|
||||
|
||||
|
||||
# ─── 2-Document extract_meta 계약 (holder doi / child parent_doi 상호 배타) ───
|
||||
|
||||
def test_with_paper_doi_holder_shape_and_merge_safe():
|
||||
meta = with_paper_doi({"license": {"scheme": "cc_by"}, "source_id": 7}, "10.1/x")
|
||||
assert meta["paper"]["doi"] == "10.1/x"
|
||||
assert "parent_doi" not in meta["paper"]
|
||||
assert meta["license"]["scheme"] == "cc_by" # 타 키 보존
|
||||
assert meta["source_id"] == 7
|
||||
|
||||
|
||||
def test_with_parent_doi_child_shape_no_doi():
|
||||
meta = with_parent_doi({"license": {"scheme": "proprietary"}}, "10.1/holder")
|
||||
assert meta["paper"]["parent_doi"] == "10.1/holder"
|
||||
assert "doi" not in meta["paper"] # child 는 doi 미보유 (partial-unique 인덱스 밖)
|
||||
assert meta["license"]["scheme"] == "proprietary"
|
||||
|
||||
|
||||
def test_holder_child_mutually_exclusive():
|
||||
child = with_parent_doi({}, "10.1/p")
|
||||
promoted = with_paper_doi(child, "10.1/self")
|
||||
assert promoted["paper"]["doi"] == "10.1/self"
|
||||
assert "parent_doi" not in promoted["paper"]
|
||||
|
||||
|
||||
def test_input_not_mutated():
|
||||
src = {"paper": {"doi": "10.1/old"}}
|
||||
with_parent_doi(src, "10.1/new")
|
||||
assert src["paper"]["doi"] == "10.1/old" # 원본 dict 불변
|
||||
|
||||
|
||||
# ─── read_paper_doi: 인덱스 식의 조회측 거울 ───
|
||||
|
||||
def test_read_paper_doi():
|
||||
assert read_paper_doi({"paper": {"doi": "10.1/x"}}) == "10.1/x"
|
||||
assert read_paper_doi({"paper": {"doi": "https://doi.org/10.1/X"}}) == "10.1/x" # 방어적 재정규화
|
||||
assert read_paper_doi({}) is None
|
||||
assert read_paper_doi(None) is None
|
||||
assert read_paper_doi({"paper": {"parent_doi": "10.1/p"}}) is None # child 는 doi 없음
|
||||
assert read_paper_doi({"paper": {}}) is None
|
||||
|
||||
|
||||
# ─── PR4: arXiv id 파싱 + arXiv DataCite DOI (교차소스 dedup 통일 키) ───
|
||||
|
||||
def test_parse_arxiv_id():
|
||||
assert parse_arxiv_id("Title arXiv:2606.10236v1 Announce Type: new Abstract") == "2606.10236"
|
||||
assert parse_arxiv_id("see arXiv:2601.02852 for details") == "2601.02852"
|
||||
assert parse_arxiv_id("arXiv:cond-mat/0703470v2") == "cond-mat/0703470"
|
||||
assert parse_arxiv_id("no arxiv here") is None
|
||||
assert parse_arxiv_id(None) is None
|
||||
|
||||
|
||||
def test_arxiv_doi_canonical():
|
||||
# OpenAlex canonical 실측 일치: 10.48550/arxiv.{id} (소문자)
|
||||
assert arxiv_doi("2606.10236") == "10.48550/arxiv.2606.10236"
|
||||
assert arxiv_doi(None) is None
|
||||
# 수집기·reconcile 가 같은 함수 → 같은 paper.doi (교차소스 dedup 성립)
|
||||
assert arxiv_doi(parse_arxiv_id("x arXiv:2606.10236v1 y")) == "10.48550/arxiv.2606.10236"
|
||||
|
||||
|
||||
# ─── PR5: 구매 PDF 본문 DOI 파싱 (parent_doi 링크용, PDF 구조 무관) ───
|
||||
|
||||
def test_parse_doi_from_text():
|
||||
assert parse_doi_from_text("ref https://doi.org/10.1016/j.jlp.2024.105474 end") == "10.1016/j.jlp.2024.105474"
|
||||
assert parse_doi_from_text("DOI 10.1115/1.4045678. Next.") == "10.1115/1.4045678"
|
||||
assert parse_doi_from_text("no doi here") is None
|
||||
assert parse_doi_from_text(None) is None
|
||||
@@ -26,7 +26,8 @@ def _fake_consumer_env(monkeypatch, held):
|
||||
lambda: {
|
||||
s: object()
|
||||
for s in (queue_consumer.MAIN_QUEUE_STAGES
|
||||
+ queue_consumer.FAST_QUEUE_STAGES + ["markdown"])
|
||||
+ queue_consumer.FAST_QUEUE_STAGES
|
||||
+ queue_consumer.DEEP_QUEUE_STAGES + ["markdown"])
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(queue_consumer, "_hold_logged", False)
|
||||
@@ -83,13 +84,37 @@ async def test_fast_consumer_respects_hold(monkeypatch):
|
||||
assert processed == ["chunk"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_deep_consumer_processes_deep_only(monkeypatch):
|
||||
"""deep 컨슈머(2026-06-15 분리) = deep_summary 전용 (메인 루프와 디커플)."""
|
||||
processed = _fake_consumer_env(monkeypatch, [])
|
||||
|
||||
await queue_consumer.consume_deep_queue()
|
||||
|
||||
assert processed == ["deep_summary"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_deep_consumer_respects_hold(monkeypatch):
|
||||
"""deep_summary 홀드 시 deep 컨슈머가 claim 안 함."""
|
||||
processed = _fake_consumer_env(monkeypatch, ["deep_summary"])
|
||||
|
||||
await queue_consumer.consume_deep_queue()
|
||||
|
||||
assert processed == []
|
||||
|
||||
|
||||
def test_fast_split_invariants():
|
||||
"""세 컨슈머 stage 집합 disjoint + embed/chunk 배치 상향 회귀 가드."""
|
||||
"""네 컨슈머 stage 집합 disjoint + embed/chunk 배치 상향 + deep split 회귀 가드."""
|
||||
main = set(queue_consumer.MAIN_QUEUE_STAGES)
|
||||
fast = set(queue_consumer.FAST_QUEUE_STAGES)
|
||||
md = set(queue_consumer.MARKDOWN_QUEUE_STAGES)
|
||||
deep = set(queue_consumer.DEEP_QUEUE_STAGES)
|
||||
assert not (main & fast) and not (main & md) and not (fast & md)
|
||||
assert not (main & deep) and not (fast & deep) and not (md & deep)
|
||||
assert fast == {"embed", "chunk"}
|
||||
assert deep == {"deep_summary"}
|
||||
assert "deep_summary" not in main # 2026-06-15 split 회귀 가드
|
||||
assert queue_consumer.BATCH_SIZE["embed"] >= 10
|
||||
assert queue_consumer.BATCH_SIZE["chunk"] >= 10
|
||||
|
||||
|
||||
@@ -103,6 +103,32 @@ def test_summarize_pool_split_attribution():
|
||||
assert macbook["pending"] == 0 # 풀 pending 은 macmini 만
|
||||
|
||||
|
||||
def test_summarize_by_machine_projection():
|
||||
"""build_summarize_by_machine = split 의 done_1h/done_today 를 머신별로 투영
|
||||
(done_15m 은 제외 — 내부 state 판정 전용)."""
|
||||
from services.queue_overview import build_summarize_by_machine
|
||||
split = _split(
|
||||
macbook={"done_1h": 226, "done_today": 312, "done_15m": 60},
|
||||
macmini={"done_1h": 37, "done_today": 94, "done_15m": 9},
|
||||
)
|
||||
sbm = build_summarize_by_machine(split)
|
||||
assert sbm == {
|
||||
"macmini": {"done_1h": 37, "done_today": 94},
|
||||
"macbook": {"done_1h": 226, "done_today": 312},
|
||||
}
|
||||
assert "done_15m" not in sbm["macbook"]
|
||||
|
||||
|
||||
def test_compose_overview_includes_summarize_by_machine():
|
||||
"""compose_overview 응답 계약에 summarize_by_machine 포함 (FE 레인 분담 재료)."""
|
||||
now_kst = datetime(2026, 6, 13, 13, 0, tzinfo=KST)
|
||||
stats = {"summarize": _stage(pending=1317, done_1h=264)}
|
||||
split = _split(macbook={"done_1h": 226, "done_today": 312}, macmini={"done_1h": 37, "done_today": 94})
|
||||
ov = compose_overview(stats, split, {}, {}, [], deep_enabled=True, now_kst=now_kst)
|
||||
assert ov["summarize_by_machine"]["macbook"]["done_1h"] == 226
|
||||
assert ov["summarize_by_machine"]["macmini"]["done_today"] == 94
|
||||
|
||||
|
||||
def test_deep_disabled_deep_summary_counts_to_macmini():
|
||||
stats = {"deep_summary": _stage(pending=2, processing=1, done_1h=3, done_today=4)}
|
||||
machines = build_machines(stats, _split(), [], deep_enabled=False)
|
||||
|
||||
Reference in New Issue
Block a user