Compare commits
198 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a22b2c7647 | |||
| c44692fddc | |||
| 7487739aec | |||
| a8d3af2b62 | |||
| 51a7c96b56 | |||
| eb83d41ba5 | |||
| 62794b3857 | |||
| 8cdfe6006d | |||
| 3fb613916a | |||
| 0c7211e24b | |||
| 94b172e314 | |||
| 9357d1592d | |||
| 832ea72784 | |||
| d26b1150d8 | |||
| dcfed09530 | |||
| 7d882352b8 | |||
| 7a8aced2a9 | |||
| d50be9f2e7 | |||
| b9f9d88d99 | |||
| d030a2b7b0 | |||
| ee3b347fa7 | |||
| a826872b0d | |||
| 4cdd30950c | |||
| 495e1c786f | |||
| 86a71ec4d1 | |||
| b6717c537f | |||
| 842ad14930 | |||
| 2fedaa065b | |||
| 274d2009c4 | |||
| 61bb6f401b | |||
| 2d86683636 | |||
| 5ab85a6c1e | |||
| fb82a69c02 | |||
| 5b5353c751 | |||
| 0c99693002 | |||
| d31ea8ff25 | |||
| 85e98db71c | |||
| 631e4cd8ef | |||
| e0772cda68 | |||
| 08c5213168 | |||
| af5640ef49 | |||
| 9aa6424e28 | |||
| 63457e6afc | |||
| 8d3b648b5f | |||
| f0c55c21ff | |||
| 83c28db572 | |||
| 864928809e | |||
| 876b38bd1b | |||
| 642c1b7c36 | |||
| f66b6e2f17 | |||
| 3db351002c | |||
| 63be005c6f | |||
| 12ac18eb70 | |||
| 35af85c7f2 | |||
| dc9cbcc669 | |||
| 403b05d971 | |||
| 713db46134 | |||
| 1f0be3312b | |||
| 16f3e313da | |||
| 3e2fa16e1d | |||
| b6ce228f6e | |||
| 33ee81bf1d | |||
| e011bdb741 | |||
| 051ecfda7d | |||
| 2eda8d3bdd | |||
| 8930803a11 | |||
| 860c5c6b0c | |||
| c3d5c33813 | |||
| d75fb7adaa | |||
| a77ac38e92 | |||
| 28b8afc748 | |||
| bb929f88d0 | |||
| 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 | |||
| 8a3bea6b31 | |||
| cd439b0ff4 | |||
| a6db6c999b | |||
| ed7740beee | |||
| 595f4b7d5e | |||
| b630c31077 | |||
| 235aa648ad | |||
| 60cb48bbe4 | |||
| 79deae0644 | |||
| 9a7e231dcc | |||
| 1646617a31 | |||
| bacb36924b | |||
| a28f12b12e | |||
| 0c8fb41366 | |||
| e5ddd0e4d6 | |||
| 3feddd012b | |||
| 5da94213ec | |||
| 85304878f4 | |||
| adce639445 | |||
| d05e41128a | |||
| 2bbdf63d86 | |||
| 5581d3f1ce | |||
| 8ac1dbf4a8 | |||
| c3d237766d | |||
| 5bc68c95f6 | |||
| 5dca5b5d28 | |||
| 9c9ff6eeba | |||
| d667545185 | |||
| 235bbf9881 | |||
| 30200a4e49 | |||
| eff2c3b7d3 | |||
| 3d79002dfa | |||
| 3d60008965 | |||
| cd0040925a | |||
| fdac449a48 | |||
| 40f5b5fe9e | |||
| a410f5b65c | |||
| 7031439364 | |||
| 468804494d | |||
| 01db4816fd | |||
| e7c7a2091f | |||
| 88e5893041 | |||
| 5e8b998a11 | |||
| 53999b2825 |
@@ -47,3 +47,6 @@ caddy_data/
|
||||
*.bak_*
|
||||
*.pre-*
|
||||
.pre-*/
|
||||
|
||||
# SQLite 로컬 아티팩트 (Django/툴링 잔재)
|
||||
*.sqlite3
|
||||
|
||||
@@ -12,6 +12,13 @@ http://document.hyungi.net {
|
||||
# 명시 Content-Type match — 기본 match 의 text/* 는 text/event-stream 까지 포함해
|
||||
# SSE(/api/eid/chat)의 첫 ~512B 를 gzip 버퍼링함. SSE 제외, 기존 압축 대상은 보존.
|
||||
# (응답 매처는 header <필드> <값> 한 쌍씩 — 여러 줄 = OR. 한 줄 다중 값은 파싱 에러)
|
||||
# 2026-06-20 보안 헤더 (M: 클릭재킹·MIME 스니핑 방어). HSTS 는 TLS 종단 edge(home-caddy) 소관.
|
||||
header {
|
||||
X-Content-Type-Options nosniff
|
||||
X-Frame-Options SAMEORIGIN
|
||||
Referrer-Policy strict-origin-when-cross-origin
|
||||
-Server
|
||||
}
|
||||
encode {
|
||||
gzip
|
||||
match {
|
||||
|
||||
+101
-15
@@ -1,5 +1,6 @@
|
||||
"""AI 추상화 레이어 — 통합 클라이언트. 기본값은 항상 Qwen3.5."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
@@ -134,6 +135,49 @@ def _fix_json_string_escapes(s: str) -> str:
|
||||
i += 1
|
||||
return "".join(out)
|
||||
|
||||
def is_deferrable_error(exc: Exception) -> bool:
|
||||
"""deep(맥북 M5 Max) 호출 실패가 '보류(StageDeferred)' 대상인지 분류 (ds-macbook-offload-1).
|
||||
|
||||
보류 = 맥북 일시 불가 신호:
|
||||
- HTTP 503 (라우터 upstream_cold / editor_busy / warming — no-silent-fallback 계약)
|
||||
- HTTP 502/504 (라우터가 upstream 연결 실패·생성 도중 절단을 502 로 변환 —
|
||||
llm_router.py 실측 4곳. 맥북 sleep 절단이 라우터 경유 토폴로지에선 이걸로 표면화)
|
||||
- httpx.TransportError 전계열 (ConnectError·ReadError·RemoteProtocolError +
|
||||
ConnectTimeout·ReadTimeout 등) — 라우터 자체 불가 / DS↔라우터 구간 절단.
|
||||
그 외(400/500, 파싱/검증 오류 등)는 보류가 아니라 호출자의 기존 실패 경로.
|
||||
"""
|
||||
if isinstance(exc, httpx.HTTPStatusError):
|
||||
return exc.response.status_code in (502, 503, 504)
|
||||
return isinstance(exc, httpx.TransportError)
|
||||
|
||||
|
||||
async def call_deep_or_defer(
|
||||
client: "AIClient",
|
||||
prompt: str,
|
||||
system: str | None = None,
|
||||
cfg: "AIModelConfig | None" = None,
|
||||
) -> str:
|
||||
"""call_deep + 보류 변환 — 맥북 불가(503/연결/절단)는 StageDeferred 로 raise.
|
||||
|
||||
deep_summary_worker / summarize_worker(drain) / classify_worker(drain) 가 공유.
|
||||
StageDeferred 는 queue_consumer/queue_drain 이 attempts 미소모 + deferred_until
|
||||
백오프로 처리한다 (sleep-안전 불변식).
|
||||
|
||||
cfg: 지정 시 deep 슬롯 대신 이 config 로 호출 (classify drain — deep 슬롯의
|
||||
endpoint 는 쓰되 triage 의 temperature/max_tokens 를 적용한 변형).
|
||||
"""
|
||||
from models.queue import StageDeferred
|
||||
|
||||
try:
|
||||
if cfg is not None:
|
||||
return await client._request(cfg, prompt, system=system)
|
||||
return await client.call_deep(prompt, system=system)
|
||||
except Exception as exc:
|
||||
if is_deferrable_error(exc):
|
||||
raise StageDeferred(f"macbook_unavailable:{type(exc).__name__}") from exc
|
||||
raise
|
||||
|
||||
|
||||
# 프롬프트 로딩
|
||||
PROMPTS_DIR = Path(__file__).parent.parent / "prompts"
|
||||
|
||||
@@ -145,6 +189,25 @@ def _load_prompt(name: str) -> str:
|
||||
CLASSIFY_PROMPT = _load_prompt("classify.txt") if (PROMPTS_DIR / "classify.txt").exists() else ""
|
||||
|
||||
|
||||
# 공유 httpx 클라이언트 — 호출마다 AsyncClient 를 새로 만들던 것(30+ 사이트, 연결풀 재사용 0)을
|
||||
# 일원화해 keep-alive 재사용. 이벤트루프 바인딩이라 루프 변경(pytest 격리 등) 시 재생성한다.
|
||||
# close() 는 공유 풀이라 no-op — 프로세스 종료 시 GC.
|
||||
_shared_http: httpx.AsyncClient | None = None
|
||||
_shared_http_loop: object | None = None
|
||||
|
||||
|
||||
def _get_shared_http() -> httpx.AsyncClient:
|
||||
global _shared_http, _shared_http_loop
|
||||
try:
|
||||
loop: object | None = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
loop = None
|
||||
if _shared_http is None or _shared_http.is_closed or _shared_http_loop is not loop:
|
||||
_shared_http = httpx.AsyncClient(timeout=120)
|
||||
_shared_http_loop = loop
|
||||
return _shared_http
|
||||
|
||||
|
||||
class AIClient:
|
||||
"""AI 모델 통합 클라이언트.
|
||||
|
||||
@@ -159,7 +222,7 @@ class AIClient:
|
||||
|
||||
def __init__(self):
|
||||
self.ai = settings.ai
|
||||
self._http = httpx.AsyncClient(timeout=120)
|
||||
self._http = _get_shared_http()
|
||||
|
||||
# ─── 3-tier routing (B-0) ───────────────────────────────────────────────
|
||||
|
||||
@@ -185,28 +248,43 @@ class AIClient:
|
||||
"""triage/primary 실패 시 최후 방어선. Claude Sonnet 4 API (config.yaml ai.models.fallback) — PR #20 이후 swap 완료."""
|
||||
return await self._request(self.ai.fallback, prompt)
|
||||
|
||||
async def call_deep(self, prompt: str, system: str | None = None) -> str:
|
||||
"""심층 전용 — 맥북 M5 Max Qwen3.6-27B (config.yaml ai.models.deep, ds-macbook-offload-1).
|
||||
|
||||
llm-router :8890 경유(model=qwen-macbook alias) — 라우터의 wake preflight(~24s)·
|
||||
editor_busy 가드를 재사용한다. 맥미니 mlx gate 와 무관(게이트는 맥미니 보호 목적)이라
|
||||
gate 없이 호출. 자동 cloud/맥미니 폴백 없음 — 실패는 그대로 전파하고 보류 판단은
|
||||
호출자가 is_deferrable_error() 로 한다. 슬롯 부재 시 primary 로 처리(방어적 —
|
||||
호출자가 보통 슬롯 유무를 먼저 분기).
|
||||
"""
|
||||
cfg = self.ai.deep or self.ai.primary
|
||||
return await self._request(cfg, prompt, system=system)
|
||||
|
||||
# ─── Legacy API (classify_worker 교체 시 제거 예정) ───────────────────
|
||||
|
||||
async def classify(self, text: str) -> dict:
|
||||
async def classify(self, text: str, cfg=None) -> dict:
|
||||
"""[DEPRECATED] 기존 classify_worker 전용. B-1 에서 summary_triage 로 대체.
|
||||
|
||||
호출부 정리 전 존속. 신규 코드는 call_triage + prompt_render 를 쓸 것.
|
||||
cfg (2026-06-12 fair-share): 지정 시 primary 대신 해당 config 로 호출 —
|
||||
drain classify 가 deep 슬롯(맥북) 경유에 사용. cfg != ai.primary 라
|
||||
_call_chat 의 primary→fallback 자동 전환은 발동하지 않는다 (에러 raw 전파).
|
||||
"""
|
||||
prompt = CLASSIFY_PROMPT.replace("{document_text}", text)
|
||||
response = await self._call_chat(self.ai.primary, prompt)
|
||||
response = await self._call_chat(cfg or self.ai.primary, prompt)
|
||||
return response
|
||||
|
||||
async def summarize(self, text: str, force_premium: bool = False) -> str:
|
||||
"""[DEPRECATED] 기존 호출부용. B-1 에서 summary_triage 가 tldr 대체."""
|
||||
async def summarize(self, text: str, force_premium: bool = False, cfg=None) -> str:
|
||||
"""[DEPRECATED] 기존 호출부용. B-1 에서 summary_triage 가 tldr 대체. cfg = classify() 와 동일."""
|
||||
if force_premium:
|
||||
return await self._call_chat(self.ai.premium, f"다음 문서를 500자 이내로 요약해주세요:\n\n{text}")
|
||||
return await self._call_chat(self.ai.primary, f"다음 문서를 500자 이내로 요약해주세요:\n\n{text}")
|
||||
return await self._call_chat(cfg or self.ai.primary, f"다음 문서를 500자 이내로 요약해주세요:\n\n{text}")
|
||||
|
||||
async def embed(self, text: str) -> list[float]:
|
||||
"""벡터 임베딩 — 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"]
|
||||
@@ -231,13 +309,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).
|
||||
@@ -285,6 +366,10 @@ class AIClient:
|
||||
payload["temperature"] = model_config.temperature
|
||||
if model_config.top_p is not None:
|
||||
payload["top_p"] = model_config.top_p
|
||||
if model_config.repetition_penalty is not None:
|
||||
payload["repetition_penalty"] = model_config.repetition_penalty
|
||||
if model_config.top_k is not None:
|
||||
payload["top_k"] = model_config.top_k
|
||||
response = await self._http.post(
|
||||
model_config.endpoint,
|
||||
json=payload,
|
||||
@@ -295,4 +380,5 @@ class AIClient:
|
||||
return data["choices"][0]["message"]["content"]
|
||||
|
||||
async def close(self):
|
||||
await self._http.aclose()
|
||||
# 공유 풀(_get_shared_http) 이라 per-use close 안 함 — 연결 재사용. 프로세스 종료 시 GC.
|
||||
return None
|
||||
|
||||
@@ -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 워커 실행 실패 (로그 확인)")
|
||||
|
||||
@@ -244,7 +244,15 @@ async def regenerate(
|
||||
user: Annotated[User, Depends(require_admin)],
|
||||
):
|
||||
"""수동 트리거 — 백그라운드 태스크로 워커 실행 (admin 필요)."""
|
||||
from core.config import settings
|
||||
from workers.digest_worker import run
|
||||
|
||||
# 홀드 중 silent no-op 방지 — 워커 게이트와 동일 조건을 표면에서 명시.
|
||||
if "digest" in settings.pipeline_held_stages:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="global_digest 보류 중 (config.yaml pipeline.held_stages) — 해제 후 재시도",
|
||||
)
|
||||
|
||||
asyncio.create_task(run())
|
||||
return {"status": "started", "message": "global_digest 워커 백그라운드 실행 시작"}
|
||||
|
||||
+426
-36
@@ -28,7 +28,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from starlette.requests import ClientDisconnect
|
||||
|
||||
from ai.client import AIClient, _load_prompt, parse_json_response
|
||||
from core.auth import get_current_user
|
||||
from core.auth import get_current_user, get_egress_class
|
||||
from core.config import settings
|
||||
from core.database import async_session, get_session
|
||||
from core.utils import file_hash
|
||||
@@ -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.
|
||||
|
||||
@@ -210,8 +223,14 @@ class DocumentDetailResponse(DocumentResponse):
|
||||
|
||||
|
||||
class AcceptSuggestionRequest(BaseModel):
|
||||
"""§1 accept-suggestion 요청 body — stale payload / doc 수정 검출."""
|
||||
"""§1 accept-suggestion 요청 body — stale payload / doc 수정 검출.
|
||||
|
||||
jurisdiction: 안전 자료실 A-2 — material_type 제안 승인 시 사용자가 지정하는 관할.
|
||||
law 승인은 필수 (기본값 없음 — KR 자동 부여 시 외국 자료가 KR 법령으로 오염되는
|
||||
경로를 차단, plan A-2 계약).
|
||||
"""
|
||||
expected_source_updated_at: datetime
|
||||
jurisdiction: str | None = None
|
||||
|
||||
|
||||
class DocumentUpdate(BaseModel):
|
||||
@@ -537,6 +556,8 @@ async def list_documents(
|
||||
category: str | None = Query(None, description="doc_category enum — 지정 시 기본 news/memo 제외 해제"),
|
||||
has_suggestion: bool | None = Query(None, description="true: ai_suggestion IS NOT NULL"),
|
||||
proposed_category: str | None = Query(None, description="ai_suggestion.proposed_category 필터"),
|
||||
material_type: str | None = Query(None, description="안전 자료실 C-1: 자료유형. 지정 시 기본 exclude 해제"),
|
||||
jurisdiction: str | None = Query(None, description="안전 자료실 C-1: 관할 (KR/US/...)"),
|
||||
):
|
||||
"""문서 목록 조회 (페이지네이션 + 필터).
|
||||
|
||||
@@ -550,6 +571,10 @@ async def list_documents(
|
||||
if category:
|
||||
# 명시적 카테고리 필터 — 기본 exclude 해제
|
||||
query = query.where(Document.category == category)
|
||||
elif material_type:
|
||||
# 안전 자료실 C-1: material_type 지정 = 기본 exclude(news·law_monitor·note) 해제.
|
||||
# 안전 코퍼스 본체(KOSHA 사례·CSB·법령 등)가 전부 note/crawl 채널이라 exclude 면 빈 화면.
|
||||
query = query.where(Document.material_type == material_type)
|
||||
else:
|
||||
# 기본 목록: 뉴스/메모/법령 제외 (문서함 용도)
|
||||
query = query.where(
|
||||
@@ -558,6 +583,9 @@ async def list_documents(
|
||||
Document.file_type != "note",
|
||||
)
|
||||
|
||||
if jurisdiction:
|
||||
query = query.where(Document.jurisdiction == jurisdiction)
|
||||
|
||||
if has_suggestion is True:
|
||||
query = query.where(Document.ai_suggestion.isnot(None))
|
||||
elif has_suggestion is False:
|
||||
@@ -644,16 +672,101 @@ async def list_duplicates(
|
||||
)
|
||||
|
||||
|
||||
class ClauseHit(BaseModel):
|
||||
doc_id: int
|
||||
doc_title: str
|
||||
section_title: str | None = None
|
||||
char_start: int | None = None
|
||||
chunk_id: int
|
||||
node_type: str | None = None
|
||||
|
||||
|
||||
class ClauseLookupResponse(BaseModel):
|
||||
label: str
|
||||
hits: list[ClauseHit]
|
||||
|
||||
|
||||
# NOTE: '/{doc_id}' (int path param) 라우트보다 먼저 선언해야 '/clause-lookup' 이 doc_id 로
|
||||
# 잘못 매칭되지 않는다 (FastAPI 선언 순서 매칭). 이동 금지.
|
||||
@router.get("/clause-lookup", response_model=ClauseLookupResponse)
|
||||
async def clause_lookup(
|
||||
label: str,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
):
|
||||
"""절 식별자(예: UG-79)로 크로스-doc 절 위치 조회 — 'UG-79 보여줘' 진입점 (U-1).
|
||||
|
||||
절(node_type=clause/clause_split)은 in_corpus=false(검색 비활성)라 의미검색으론 못 찾으므로,
|
||||
라벨 prefix 정확매칭으로 (doc, char_start) 를 직접 해소해 읽기뷰 점프를 가능케 한다.
|
||||
대부분 1건; 부록(A-/E-/F-) 등 doc 간 공유 라벨만 다중 반환(에디션 선택). /sections 와 동일하게
|
||||
document_chunks 직접 조회 — corpus_chunks 우회는 retrieval 아닌 정확지목이므로 의도적 예외.
|
||||
"""
|
||||
from sqlalchemy import text as sql_text
|
||||
|
||||
lab = (label or "").strip()
|
||||
if not lab:
|
||||
return ClauseLookupResponse(label=label, hits=[])
|
||||
rows = (
|
||||
await session.execute(
|
||||
sql_text(
|
||||
"""
|
||||
SELECT c.doc_id, d.title AS doc_title, c.section_title, c.char_start, c.node_type,
|
||||
-- 점프 타깃 = outline(/sections: is_leaf 또는 %_split)에 있는 chunk 여야 딥링크 동작.
|
||||
-- 자신이 그러면 자신, 아니면(컨테이너 절: 자식 heading 보유·is_leaf=false) 문서순서상
|
||||
-- 자신 이후 첫 딥링크 가능 chunk(=그 절 내용 시작)로 해소. 그래도 없으면 자신(폴백).
|
||||
COALESCE(
|
||||
CASE WHEN c.is_leaf = true OR c.node_type LIKE '%\\_split' ESCAPE '\\' THEN c.id END,
|
||||
(SELECT ch.id FROM document_chunks ch
|
||||
WHERE ch.doc_id = c.doc_id AND ch.source_type = 'hier_section'
|
||||
AND ch.chunk_index >= c.chunk_index
|
||||
AND (ch.is_leaf = true OR ch.node_type LIKE '%\\_split' ESCAPE '\\')
|
||||
ORDER BY ch.chunk_index LIMIT 1),
|
||||
c.id
|
||||
) AS chunk_id
|
||||
FROM document_chunks c
|
||||
JOIN documents d ON d.id = c.doc_id
|
||||
WHERE c.node_type IN ('clause', 'clause_split')
|
||||
AND (c.section_title ILIKE :lab_sp OR c.section_title ILIKE :lab_eq)
|
||||
AND d.deleted_at IS NULL
|
||||
ORDER BY c.doc_id, c.char_start NULLS LAST
|
||||
LIMIT 50
|
||||
"""
|
||||
).bindparams(lab_sp=lab + " %", lab_eq=lab)
|
||||
)
|
||||
).mappings().all()
|
||||
return ClauseLookupResponse(label=lab, hits=[ClauseHit(**dict(r)) for r in rows])
|
||||
|
||||
|
||||
@router.get("/{doc_id}", response_model=DocumentDetailResponse)
|
||||
async def get_document(
|
||||
doc_id: int,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
egress_class: Annotated[str, Depends(get_egress_class)],
|
||||
):
|
||||
"""문서 단건 조회. 본문(extracted_text)·canonical markdown 동봉."""
|
||||
"""문서 단건 조회. 본문(extracted_text)·canonical markdown 동봉.
|
||||
|
||||
cloud egress(갭2): egress=cloud 토큰(예: Claude/MCP)은 search 와 동일한 cloud-eligibility
|
||||
게이트를 통과한 문서만 열람 가능 — id 직접 fetch 로 비공개/인프라/개인/restricted 문서를
|
||||
우회 열람하는 경로를 차단한다. 부적격은 404(존재 자체 비노출). local 토큰=게이트 미발동(무회귀).
|
||||
"""
|
||||
from sqlalchemy import text as sql_text
|
||||
from services.search.retrieval_service import cloud_eligible_doc_sql
|
||||
|
||||
doc = await session.get(Document, doc_id)
|
||||
if not doc or doc.deleted_at is not None:
|
||||
raise HTTPException(status_code=404, detail="문서를 찾을 수 없습니다")
|
||||
if egress_class == "cloud":
|
||||
eligible = (
|
||||
await session.execute(
|
||||
sql_text(
|
||||
"SELECT 1 FROM documents WHERE id = :doc_id AND deleted_at IS NULL"
|
||||
+ cloud_eligible_doc_sql("")
|
||||
).bindparams(doc_id=doc_id)
|
||||
)
|
||||
).first()
|
||||
if eligible is None:
|
||||
raise HTTPException(status_code=404, detail="문서를 찾을 수 없습니다")
|
||||
return DocumentDetailResponse.model_validate(doc)
|
||||
|
||||
|
||||
@@ -665,7 +778,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
|
||||
@@ -704,12 +822,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
|
||||
@@ -818,9 +936,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:
|
||||
@@ -923,10 +1039,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(
|
||||
@@ -934,6 +1048,19 @@ async def get_document_image_raw(
|
||||
DocumentImage.image_key == image_key,
|
||||
)
|
||||
)
|
||||
if img is None:
|
||||
# clause-KB: 절-문서는 부모 표준 이미지를 공유(md_content=부모 슬라이스) → parent_id 폴백.
|
||||
from sqlalchemy import text as sql_text
|
||||
_par = (await session.execute(
|
||||
sql_text("SELECT parent_id FROM documents WHERE id = :id").bindparams(id=doc_id)
|
||||
)).scalar()
|
||||
if _par is not None:
|
||||
img = await session.scalar(
|
||||
select(DocumentImage).where(
|
||||
DocumentImage.document_id == _par,
|
||||
DocumentImage.image_key == image_key,
|
||||
)
|
||||
)
|
||||
if img is None:
|
||||
raise HTTPException(status_code=404, detail="이미지를 찾을 수 없습니다")
|
||||
|
||||
@@ -1137,8 +1264,10 @@ async def upload_document(
|
||||
doc.duplicate_of = canonical.id
|
||||
canonical.duplicate_count = (canonical.duplicate_count or 0) + 1
|
||||
|
||||
# document + processing_queue 는 단일 트랜잭션으로 묶어 원자적 정리
|
||||
await enqueue_stage(session, doc.id, "extract")
|
||||
# document + processing_queue 는 단일 트랜잭션으로 묶어 원자적 정리.
|
||||
# G2: 첫 stage=presegment (extract 前 번들 PDF 분할, 후보 A 검증완료 2026-06-18).
|
||||
# 非PDF/단일은 presegment 가 무변 통과 → extract. 번들 PDF 만 N 자식 분할(worker-side gating).
|
||||
await enqueue_stage(session, doc.id, "presegment")
|
||||
await session.commit()
|
||||
except Exception:
|
||||
# DB 예외 시 session 은 get_session 컨텍스트 종료로 자동 rollback.
|
||||
@@ -1181,6 +1310,14 @@ async def update_document(
|
||||
if val is not None and val not in ("business", "knowledge"):
|
||||
raise HTTPException(status_code=400, detail="doc_purpose는 business 또는 knowledge만 가능")
|
||||
|
||||
# edit_url SSRF 가드 (2026-06-20 M1): 내부/메타데이터 주소 후속 fetch 차단 (news.py 동형 검증)
|
||||
if update_data.get("edit_url"):
|
||||
from core.url_validator import validate_feed_url
|
||||
try:
|
||||
await asyncio.to_thread(validate_feed_url, update_data["edit_url"])
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"edit_url 검증 실패: {e}")
|
||||
|
||||
for field, value in update_data.items():
|
||||
setattr(doc, field, value)
|
||||
doc.updated_at = datetime.now(timezone.utc)
|
||||
@@ -1244,11 +1381,49 @@ async def accept_suggestion(
|
||||
# payload 적용
|
||||
proposed_category = doc.ai_suggestion.get("proposed_category")
|
||||
proposed_path = doc.ai_suggestion.get("proposed_path")
|
||||
# 안전 자료실 A-2 — material_type 제안 (classify 의 document_type 결정적 매핑)
|
||||
proposed_material = doc.ai_suggestion.get("proposed_material_type")
|
||||
|
||||
if not proposed_category:
|
||||
raise HTTPException(status_code=422, detail="proposed_category 누락된 suggestion")
|
||||
if not proposed_category and not proposed_material:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="proposed_category/proposed_material_type 둘 다 누락된 suggestion",
|
||||
)
|
||||
|
||||
doc.category = proposed_category
|
||||
if proposed_category:
|
||||
doc.category = proposed_category
|
||||
|
||||
if proposed_material:
|
||||
_MATERIAL_TYPES = {"law", "paper", "book", "incident", "manual", "standard", "guide"}
|
||||
_JURISDICTIONS = {"KR", "US", "EU", "JP", "GB", "INT"}
|
||||
if proposed_material not in _MATERIAL_TYPES:
|
||||
raise HTTPException(
|
||||
status_code=422, detail=f"허용 밖 material_type: {proposed_material}"
|
||||
)
|
||||
jur = body.jurisdiction or doc.ai_suggestion.get("proposed_jurisdiction")
|
||||
if jur is not None and jur not in _JURISDICTIONS:
|
||||
raise HTTPException(status_code=422, detail=f"허용 밖 jurisdiction: {jur}")
|
||||
# law = 국가 필수 입력, 기본값 없음 (plan A-2 — KR 자동 부여 시 외국 법령 오염.
|
||||
# DB CHECK(chk_documents_law_jurisdiction) 도 거부하지만 422 로 명시 안내).
|
||||
if proposed_material == "law" and not jur:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="법령(law) 승인은 jurisdiction 필수 — body.jurisdiction 으로 국가를 지정하세요 (기본값 없음)",
|
||||
)
|
||||
doc.material_type = proposed_material
|
||||
doc.jurisdiction = jur
|
||||
# 미러 동기화 1문 — jurisdiction 부여/정정 시 청크 country 동반 UPDATE
|
||||
# (leg 간 국가 불일치 방지, plan A-2 계약. 단일 지점 = 본 승인 경로).
|
||||
if jur:
|
||||
from sqlalchemy import update as sa_update
|
||||
|
||||
from models.chunk import DocumentChunk
|
||||
|
||||
await session.execute(
|
||||
sa_update(DocumentChunk)
|
||||
.where(DocumentChunk.doc_id == doc.id)
|
||||
.values(country=jur)
|
||||
)
|
||||
|
||||
# user_tags append (중복 방지, normalize + dedup 통과)
|
||||
if proposed_path:
|
||||
@@ -1299,9 +1474,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만 가능)")
|
||||
@@ -1341,9 +1515,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():
|
||||
@@ -1369,18 +1541,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")
|
||||
@@ -1390,9 +1568,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]
|
||||
@@ -1422,7 +1598,7 @@ ANALYZE_PROMPT = (
|
||||
)
|
||||
|
||||
ANALYZE_TEXT_LIMIT = 12000 # chars (15000 → 12000, 실측 timeout 빈발)
|
||||
ANALYZE_TIMEOUT_S = 60 # 15,000자 입력 + 4층 출력. 실측 7~45초, safety margin 포함
|
||||
ANALYZE_TIMEOUT_S = settings.llm_call_timeout_s # 2026-06-20 config 단일소스 (구 60s=빠른 Gemma)
|
||||
ANALYZE_CACHE_TTL_S = 1800 # 30분
|
||||
ANALYZE_CACHE_MAXSIZE = 100
|
||||
ANALYZE_LAYER_MIN_CHARS = 50 # 이 미만이면 억지 채움으로 보고 제거
|
||||
@@ -1658,3 +1834,217 @@ async def analyze_document(
|
||||
error_code=error_code,
|
||||
source=source,
|
||||
)
|
||||
|
||||
|
||||
# ─── ASME 절-지식베이스: 유기적 책 네비 (clause-KB, doc_kind='clause' 자식 문서 기반) ───
|
||||
class ClauseTocItem(BaseModel):
|
||||
id: int
|
||||
clause_code: str | None = None
|
||||
clause_part: str | None = None
|
||||
clause_order: int | None = None
|
||||
title: str | None = None
|
||||
|
||||
|
||||
class ClauseBookResponse(BaseModel):
|
||||
parent_id: int
|
||||
parent_title: str | None = None
|
||||
clauses: list[ClauseTocItem]
|
||||
|
||||
|
||||
@router.get("/{doc_id}/clauses", response_model=ClauseBookResponse)
|
||||
async def get_document_clauses(
|
||||
doc_id: int,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
):
|
||||
"""부모 표준 doc 의 절-문서 목차(유기적 책 TOC). doc_kind='clause' 자식을 clause_order 순 반환.
|
||||
|
||||
절-문서는 in_corpus=false + doc_kind='clause'(검색 제외)라 일반 목록/검색엔 안 뜨지만,
|
||||
이 책-내 네비는 부모 표준에서 자식 절로 진입하는 전용 경로다(ASME 2025판=한 권의 책).
|
||||
"""
|
||||
from sqlalchemy import text as sql_text
|
||||
|
||||
parent = await session.get(Document, doc_id)
|
||||
if not parent or parent.deleted_at is not None:
|
||||
raise HTTPException(status_code=404, detail="문서를 찾을 수 없습니다")
|
||||
rows = (
|
||||
await session.execute(
|
||||
sql_text(
|
||||
"""
|
||||
SELECT id, clause_code, clause_part, clause_order, title
|
||||
FROM documents
|
||||
WHERE parent_id = :pid AND doc_kind = 'clause' AND deleted_at IS NULL
|
||||
ORDER BY clause_order
|
||||
"""
|
||||
).bindparams(pid=doc_id)
|
||||
)
|
||||
).mappings().all()
|
||||
return ClauseBookResponse(
|
||||
parent_id=doc_id,
|
||||
parent_title=parent.title,
|
||||
clauses=[ClauseTocItem(**dict(r)) for r in rows],
|
||||
)
|
||||
|
||||
|
||||
class BacklinkRef(BaseModel):
|
||||
code: str
|
||||
doc_id: int | None = None # 해소된 절-문서(같은 부모) — dangling 이면 None
|
||||
title: str | None = None
|
||||
anchor: str | None = None
|
||||
ctx: str | None = None
|
||||
|
||||
|
||||
class BacklinksResponse(BaseModel):
|
||||
doc_id: int
|
||||
clause_code: str | None = None
|
||||
parent_id: int | None = None
|
||||
prev: ClauseTocItem | None = None
|
||||
next: ClauseTocItem | None = None
|
||||
forward: list[BacklinkRef] # 이 절이 참조하는 절들
|
||||
back: list[BacklinkRef] # 이 절을 참조하는 절들
|
||||
|
||||
|
||||
@router.get("/{doc_id}/backlinks", response_model=BacklinksResponse)
|
||||
async def get_document_backlinks(
|
||||
doc_id: int,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
):
|
||||
"""절-문서의 양방향 백링크 + 같은 부모 내 이전/다음 절(유기적 책 흐름)."""
|
||||
from sqlalchemy import text as sql_text
|
||||
|
||||
doc = await session.get(Document, doc_id)
|
||||
if not doc or doc.deleted_at is not None:
|
||||
raise HTTPException(status_code=404, detail="문서를 찾을 수 없습니다")
|
||||
|
||||
_meta = (await session.execute(sql_text(
|
||||
"SELECT parent_id, clause_code, clause_order FROM documents WHERE id = :id"
|
||||
).bindparams(id=doc_id))).mappings().first()
|
||||
_parent_id = _meta["parent_id"] if _meta else None
|
||||
_clause_code = _meta["clause_code"] if _meta else None
|
||||
_clause_order = _meta["clause_order"] if _meta else None
|
||||
forward = (
|
||||
await session.execute(
|
||||
sql_text(
|
||||
"""
|
||||
SELECT cl.dst_code AS code, cl.dst_doc_id AS doc_id, cl.anchor, cl.ctx, d.title
|
||||
FROM clause_links cl
|
||||
LEFT JOIN documents d ON d.id = cl.dst_doc_id
|
||||
WHERE cl.src_doc_id = :id
|
||||
ORDER BY cl.char_off NULLS LAST
|
||||
LIMIT 300
|
||||
"""
|
||||
).bindparams(id=doc_id)
|
||||
)
|
||||
).mappings().all()
|
||||
back = (
|
||||
await session.execute(
|
||||
sql_text(
|
||||
"""
|
||||
SELECT s.clause_code AS code, cl.src_doc_id AS doc_id, s.title, cl.ctx
|
||||
FROM clause_links cl
|
||||
JOIN documents s ON s.id = cl.src_doc_id
|
||||
WHERE cl.dst_doc_id = :id
|
||||
ORDER BY s.clause_order NULLS LAST
|
||||
LIMIT 300
|
||||
"""
|
||||
).bindparams(id=doc_id)
|
||||
)
|
||||
).mappings().all()
|
||||
|
||||
prev = nxt = None
|
||||
if _parent_id is not None and _clause_order is not None:
|
||||
prow = (
|
||||
await session.execute(
|
||||
sql_text(
|
||||
"""
|
||||
SELECT id, clause_code, clause_part, clause_order, title FROM documents
|
||||
WHERE parent_id = :pid AND doc_kind='clause' AND deleted_at IS NULL
|
||||
AND clause_order < :ord
|
||||
ORDER BY clause_order DESC LIMIT 1
|
||||
"""
|
||||
).bindparams(pid=_parent_id, ord=_clause_order)
|
||||
)
|
||||
).mappings().first()
|
||||
nrow = (
|
||||
await session.execute(
|
||||
sql_text(
|
||||
"""
|
||||
SELECT id, clause_code, clause_part, clause_order, title FROM documents
|
||||
WHERE parent_id = :pid AND doc_kind='clause' AND deleted_at IS NULL
|
||||
AND clause_order > :ord
|
||||
ORDER BY clause_order ASC LIMIT 1
|
||||
"""
|
||||
).bindparams(pid=_parent_id, ord=_clause_order)
|
||||
)
|
||||
).mappings().first()
|
||||
prev = ClauseTocItem(**dict(prow)) if prow else None
|
||||
nxt = ClauseTocItem(**dict(nrow)) if nrow else None
|
||||
|
||||
return BacklinksResponse(
|
||||
doc_id=doc_id,
|
||||
clause_code=_clause_code,
|
||||
parent_id=_parent_id,
|
||||
prev=prev,
|
||||
next=nxt,
|
||||
forward=[BacklinkRef(**dict(r)) for r in forward],
|
||||
back=[BacklinkRef(**dict(r)) for r in back],
|
||||
)
|
||||
|
||||
|
||||
# ─── 관련 문서 (유사도, on-demand pgvector KNN — 저부하·무저장) ───
|
||||
class RelatedItem(BaseModel):
|
||||
id: int
|
||||
title: str | None = None
|
||||
ai_domain: str | None = None
|
||||
material_type: str | None = None
|
||||
year: int | None = None
|
||||
sim: float | None = None
|
||||
|
||||
|
||||
class RelatedResponse(BaseModel):
|
||||
doc_id: int
|
||||
related: list[RelatedItem]
|
||||
|
||||
|
||||
@router.get("/{doc_id}/related", response_model=RelatedResponse)
|
||||
async def get_related_documents(
|
||||
doc_id: int,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
limit: int = 8,
|
||||
same_type: bool = True,
|
||||
):
|
||||
"""문서-레벨 임베딩 코사인 최근접 = '관련 문서'. on-demand(저장/배치 없음).
|
||||
|
||||
인용그래프가 부적합한 코퍼스(업계 기술기사=인용망 부재)의 대안 연결 레이어.
|
||||
same_type=true면 같은 material_type 내, false면 전 코퍼스. doc_kind='clause'(절-문서)는 제외.
|
||||
"""
|
||||
from sqlalchemy import text as sql_text
|
||||
|
||||
lim = max(1, min(limit, 30))
|
||||
type_clause = "AND d.material_type = src.material_type" if same_type else ""
|
||||
rows = (
|
||||
await session.execute(
|
||||
sql_text(
|
||||
f"""
|
||||
WITH src AS (
|
||||
SELECT embedding, material_type FROM documents WHERE id = :id
|
||||
)
|
||||
SELECT d.id, d.title, d.ai_domain, d.material_type, d.facet_year AS year,
|
||||
round((1 - (d.embedding <=> (SELECT embedding FROM src)))::numeric, 3) AS sim
|
||||
FROM documents d, src
|
||||
WHERE d.doc_kind = 'standard' AND d.deleted_at IS NULL
|
||||
AND d.id <> :id AND d.embedding IS NOT NULL
|
||||
AND (SELECT embedding FROM src) IS NOT NULL
|
||||
{type_clause}
|
||||
ORDER BY d.embedding <=> (SELECT embedding FROM src)
|
||||
LIMIT :lim
|
||||
"""
|
||||
).bindparams(id=doc_id, lim=lim)
|
||||
)
|
||||
).mappings().all()
|
||||
return RelatedResponse(
|
||||
doc_id=doc_id,
|
||||
related=[RelatedItem(**{k: r[k] for k in ("id", "title", "ai_domain", "material_type", "year")}, sim=float(r["sim"]) if r["sim"] is not None else None) for r in rows],
|
||||
)
|
||||
|
||||
+6
-5
@@ -2,8 +2,9 @@
|
||||
|
||||
확정 결정:
|
||||
- D-1 경로 = /api/eid/chat (main.py prefix=/api/eid + 본 라우터 POST /chat)
|
||||
- D-2 mode 닫힌 어휘: daily(mac-mini-default) / deep(qwen-macbook). 클라는 mode 만 보냄 —
|
||||
claude-cloud / auto 금지 (Literal 로 422 차단). 심층(deep) 모드 무게이트.
|
||||
- D-2 mode 닫힌 어휘: daily / deep — 둘 다 mac-mini-default (맥북 백지화 2026-06-11,
|
||||
맥미니 Qwen 27B 단일 호스트. deep = ReAct 자동검색 모드 구분). 클라는 mode 만 보냄 —
|
||||
claude-cloud / auto 금지 (Literal 로 422 차단). 게이트 = alias 기준 자동 적용(무게이트 폐지).
|
||||
- D-3 독립 /chat 라우트 (frontend) — 본 모듈은 백엔드 API 만.
|
||||
- D-5 LLM 호출 = EidAIClient.call_stream 한 곳 (이드 egress 봉쇄 불변식 #5,
|
||||
RouterBackend 직접 호출 금지).
|
||||
@@ -43,7 +44,7 @@ logger = setup_logger("eid_chat")
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# ── ds-eid-ask-absorb P1: deep 모드 = ReAct 자동검색 (qwen-macbook 27B) ──
|
||||
# ── ds-eid-ask-absorb P1: deep 모드 = ReAct 자동검색 (맥미니 Qwen 27B, 2026-06-11~) ──
|
||||
# 비생성 reachability probe — router 도달만 확인(coarse). 27B(맥북) 자체 미가용은
|
||||
# 첫 generate_with_tools 호출의 BackendUnavailable → mid-stream error envelope 로 커버
|
||||
# (plan: probe 정밀도 불필요, TOCTOU 는 in-stream error 가 처리). ~2s 타임아웃·생성 슬롯 비점유.
|
||||
@@ -160,10 +161,10 @@ async def _eid_chat_deep(body: ChatRequest, session: AsyncSession) -> StreamingR
|
||||
"""
|
||||
# ① 첫 SSE 바이트(=HTTP 200 확정) 전 비생성 probe — router 도달 실패 시 503 (재매핑 가능 구간)
|
||||
if not await _probe_router_reachable():
|
||||
return _backend_unavailable_response(body, "macbook_unavailable", "qwen-macbook")
|
||||
return _backend_unavailable_response(body, "router_unreachable", "mac-mini-default")
|
||||
|
||||
query = body.messages[-1].content # 메시지 단독 처리 (마지막 user 턴)
|
||||
backend = get_backend("qwen-macbook")
|
||||
backend = get_backend("mac-mini-default")
|
||||
|
||||
async def _stream() -> AsyncIterator[bytes]:
|
||||
# ② phase:searching 방출 = HTTP 200 확정. 이후 미가용은 503 불가 → in-stream error.
|
||||
|
||||
+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())
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
"""뷰어 write-back ingest (study-to-viewer P2) — 뷰어 로컬 풀이 세션을 DS 로 흘려 finalize 재생.
|
||||
|
||||
흐름(plan study-to-viewer-slice1 P2, r2/r3 불변식):
|
||||
뷰어 outbox → POST /ingest/study/attempts (Bearer VIEWER_SYNC_TOKEN, study_ingest_enabled gate)
|
||||
→ pub_id→published.source_id→StudyQuestion 해소(부재 graceful skip) → principal=question.user_id
|
||||
→ topic 별 그룹(뷰어 subject 퀴즈가 여러 DS topic 걸칠 수 있음) → topic 마다 DS quiz_session
|
||||
(source='viewer', client_session_uuid) 생성 + attempt(derive_outcome=채점 단일 소스) + 세션 done
|
||||
→ finalize_session **무수정 재생**(SR/pattern/progress + 4-A/4-B enqueue) → finalized_at 마커
|
||||
→ 전부 1 트랜잭션(원자) 후 commit.
|
||||
|
||||
멱등(r2 P2-2): client_session_uuid 로 기존 세션 있으면 이미 적재된 것 → 캐시 요약 반환(재실행 0).
|
||||
원자 1-tx 라 'uuid 존재 ⟺ finalize 완료' → at-least-once outbox 재전송에도 SR 이중 advance 없음.
|
||||
user_id 리터럴 금지(r2): principal = 해소된 질문의 owner(단일, mixed 면 거부).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from core.config import settings
|
||||
from core.database import async_session
|
||||
from models.published import Published
|
||||
from models.study_question import StudyQuestion, StudyQuestionAttempt
|
||||
from models.study_quiz_session import StudyQuizSession
|
||||
from services.study.outcome import derive_outcome
|
||||
from services.study.publish_projection import KIND_QUESTION
|
||||
from services.study.session_finalize import finalize_session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _verify_token(authorization: str | None = Header(default=None)) -> None:
|
||||
"""뷰어↔DS 발행 채널 Bearer(read 와 동일 토큰, r3 단일토큰 수용). default-deny(미설정=503)."""
|
||||
if not settings.viewer_sync_token:
|
||||
raise HTTPException(status_code=503, detail="viewer_sync_token not configured")
|
||||
if not authorization or not authorization.lower().startswith("bearer "):
|
||||
raise HTTPException(status_code=401, detail="missing Bearer token")
|
||||
token = authorization[7:].strip()
|
||||
if not hmac.compare_digest(token, settings.viewer_sync_token):
|
||||
raise HTTPException(status_code=403, detail="invalid token")
|
||||
|
||||
|
||||
async def _session() -> AsyncSession:
|
||||
async with async_session() as s:
|
||||
yield s
|
||||
|
||||
|
||||
class IngestAttempt(BaseModel):
|
||||
question_pub_id: str
|
||||
selected_choice: int | None = None
|
||||
is_unsure: bool = False
|
||||
answered_at: str | None = None # 클라(오프라인) ISO 시각 — 미래 스큐 클램프, id 가 타이브레이커
|
||||
|
||||
|
||||
class IngestBody(BaseModel):
|
||||
client_session_uuid: str
|
||||
attempts: list[IngestAttempt]
|
||||
|
||||
|
||||
def _already_ingested(rows) -> dict:
|
||||
"""이미 적재된 세션들의 캐시 요약(멱등 응답). 최초 멱등체크 + 동시경합 흡수 양쪽에서 사용."""
|
||||
return {
|
||||
"status": "already_ingested",
|
||||
"sessions": [
|
||||
{
|
||||
"topic_id": s.study_topic_id,
|
||||
"correct": s.correct_count,
|
||||
"wrong": s.wrong_count,
|
||||
"unsure": s.unsure_count,
|
||||
}
|
||||
for s in rows
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _parse_answered_at(s: str | None, now: datetime) -> datetime:
|
||||
if not s:
|
||||
return now
|
||||
try:
|
||||
dt = datetime.fromisoformat(s.replace("Z", "+00:00"))
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
return min(dt, now) # 미래 스큐는 now 로 클램프(클라 시계 오염 방지)
|
||||
except Exception:
|
||||
return now
|
||||
|
||||
|
||||
@router.post("/attempts")
|
||||
async def ingest_attempts(
|
||||
body: IngestBody,
|
||||
_auth: None = Depends(_verify_token),
|
||||
session: AsyncSession = Depends(_session),
|
||||
):
|
||||
if not settings.study_ingest_enabled:
|
||||
raise HTTPException(status_code=503, detail="study_ingest not enabled")
|
||||
if not body.client_session_uuid or not body.attempts:
|
||||
raise HTTPException(status_code=400, detail="client_session_uuid 와 attempts 필요")
|
||||
|
||||
# 멱등: 이 uuid 로 이미 적재됐나(원자 1-tx 라 존재=완료). 있으면 캐시 요약 반환(재실행 0).
|
||||
existing = (
|
||||
await session.execute(
|
||||
select(StudyQuizSession).where(
|
||||
StudyQuizSession.client_session_uuid == body.client_session_uuid
|
||||
)
|
||||
)
|
||||
).scalars().all()
|
||||
if existing:
|
||||
return _already_ingested(existing)
|
||||
|
||||
# pub_id → source_id(내부 질문 id) 해소. deleted tombstone 제외.
|
||||
pub_ids = list({a.question_pub_id for a in body.attempts})
|
||||
pub_rows = (
|
||||
await session.execute(
|
||||
select(Published.pub_id, Published.source_id).where(
|
||||
Published.kind == KIND_QUESTION,
|
||||
Published.pub_id.in_(pub_ids),
|
||||
Published.deleted.is_(False),
|
||||
)
|
||||
)
|
||||
).all()
|
||||
src_by_pubid = {r.pub_id: r.source_id for r in pub_rows}
|
||||
|
||||
# 질문 fetch(미삭제). principal = owner(단일).
|
||||
source_ids = list(set(src_by_pubid.values()))
|
||||
q_rows = (
|
||||
await session.execute(
|
||||
select(StudyQuestion).where(
|
||||
StudyQuestion.id.in_(source_ids), StudyQuestion.deleted_at.is_(None)
|
||||
)
|
||||
)
|
||||
).scalars().all()
|
||||
q_by_id = {q.id: q for q in q_rows}
|
||||
owners = {q.user_id for q in q_by_id.values()}
|
||||
if len(owners) > 1:
|
||||
raise HTTPException(status_code=400, detail="여러 사용자 소유 질문 혼재 — 단일 principal 위반")
|
||||
if not owners:
|
||||
raise HTTPException(status_code=404, detail="해소 가능한 질문 없음")
|
||||
user_id = owners.pop()
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
# topic 별 그룹(해소 실패 attempt 는 graceful skip). 같은 (uuid, topic) 1 세션.
|
||||
by_topic: dict[int, list[tuple[IngestAttempt, StudyQuestion]]] = defaultdict(list)
|
||||
skipped: list[str] = []
|
||||
for a in body.attempts:
|
||||
src = src_by_pubid.get(a.question_pub_id)
|
||||
q = q_by_id.get(src) if src is not None else None
|
||||
if q is None:
|
||||
skipped.append(a.question_pub_id)
|
||||
continue
|
||||
by_topic[q.study_topic_id].append((a, q))
|
||||
if not by_topic:
|
||||
raise HTTPException(status_code=404, detail="해소된 attempt 없음")
|
||||
|
||||
try:
|
||||
summaries = []
|
||||
for topic_id, items in by_topic.items():
|
||||
qids = [q.id for (_, q) in items]
|
||||
qs = StudyQuizSession(
|
||||
user_id=user_id,
|
||||
study_topic_id=topic_id,
|
||||
question_ids=qids,
|
||||
subject_distribution={},
|
||||
status="done",
|
||||
cursor=len(qids),
|
||||
source="viewer",
|
||||
client_session_uuid=body.client_session_uuid,
|
||||
finished_at=now,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
session.add(qs)
|
||||
await session.flush() # qs.id
|
||||
|
||||
c = w = u = 0
|
||||
for a, q in items:
|
||||
try:
|
||||
sel, is_corr, outcome = derive_outcome(a.selected_choice, a.is_unsure, q.correct_choice)
|
||||
except ValueError:
|
||||
skipped.append(a.question_pub_id) # 선택 없고 unsure 아님 = 무효 → skip
|
||||
continue
|
||||
if outcome == "correct":
|
||||
c += 1
|
||||
elif outcome == "wrong":
|
||||
w += 1
|
||||
elif outcome == "unsure":
|
||||
u += 1
|
||||
session.add(
|
||||
StudyQuestionAttempt(
|
||||
user_id=user_id,
|
||||
study_question_id=q.id,
|
||||
study_topic_id=topic_id,
|
||||
selected_choice=sel,
|
||||
correct_choice=q.correct_choice,
|
||||
is_correct=is_corr,
|
||||
outcome=outcome,
|
||||
quiz_session_id=qs.id,
|
||||
answered_at=_parse_answered_at(a.answered_at, now),
|
||||
)
|
||||
)
|
||||
qs.correct_count, qs.wrong_count, qs.unsure_count = c, w, u
|
||||
await session.flush()
|
||||
|
||||
# finalize 무수정 재생(progress/SR/pattern + 4-A/4-B enqueue). 그 후 멱등 마커.
|
||||
summary = await finalize_session(
|
||||
session, user_id=user_id, study_topic_id=topic_id, quiz_session_id=qs.id
|
||||
)
|
||||
qs.finalized_at = now
|
||||
summaries.append(
|
||||
{
|
||||
"topic_id": topic_id,
|
||||
"quiz_session_id": qs.id,
|
||||
"correct": summary.correct,
|
||||
"wrong": summary.wrong,
|
||||
"unsure": summary.unsure,
|
||||
"newly_correct": summary.newly_correct,
|
||||
"relapsed": summary.relapsed,
|
||||
"recovered": summary.recovered,
|
||||
}
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
except IntegrityError:
|
||||
# 동시 같은 client_session_uuid 경합 — 상대가 먼저 commit → (client_session_uuid,
|
||||
# study_topic_id) uq(mig376) 위반. 데이터는 안전(원자 1-tx 전체 롤백 → SR 이중 advance
|
||||
# 없음). 승자 결과로 graceful 수렴(500 대신 already_ingested). uuid 경합이 아닌 진짜
|
||||
# 무결성 오류면 재조회가 비어 → re-raise 로 표면화.
|
||||
await session.rollback()
|
||||
winner = (
|
||||
await session.execute(
|
||||
select(StudyQuizSession).where(
|
||||
StudyQuizSession.client_session_uuid == body.client_session_uuid
|
||||
)
|
||||
)
|
||||
).scalars().all()
|
||||
if not winner:
|
||||
raise
|
||||
logger.info("study_ingest uuid=%s 동시경합 흡수 → already_ingested", body.client_session_uuid)
|
||||
return _already_ingested(winner)
|
||||
|
||||
logger.info(
|
||||
"study_ingest uuid=%s user=%s sessions=%s skipped=%s",
|
||||
body.client_session_uuid, user_id, len(summaries), len(skipped),
|
||||
)
|
||||
return {"status": "ingested", "skipped": skipped, "sessions": summaries}
|
||||
@@ -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")
|
||||
|
||||
|
||||
|
||||
+32
-69
@@ -9,7 +9,7 @@ from sqlalchemy import func, select
|
||||
from sqlalchemy import text as sql_text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from core.auth import get_current_user
|
||||
from core.auth import get_current_user, require_admin
|
||||
from core.database import get_session
|
||||
from core.library import LIBRARY_PREFIX, MAX_DEPTH, normalize_library_path
|
||||
from models.category import LibraryCategory
|
||||
@@ -78,7 +78,7 @@ async def list_categories(
|
||||
@router.post("/categories", response_model=CategoryResponse, status_code=201)
|
||||
async def create_category(
|
||||
body: CategoryCreate,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
user: Annotated[User, Depends(require_admin)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
):
|
||||
"""카테고리 생성 (조상 자동 생성 포함)"""
|
||||
@@ -133,7 +133,7 @@ async def create_category(
|
||||
@router.patch("/categories", response_model=CategoryResponse)
|
||||
async def rename_category(
|
||||
body: CategoryRename,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
user: Annotated[User, Depends(require_admin)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
):
|
||||
"""카테고리 이름 변경 (leaf only, path 기반 식별)"""
|
||||
@@ -214,7 +214,7 @@ async def rename_category(
|
||||
@router.delete("/categories", status_code=204)
|
||||
async def delete_category(
|
||||
path: str = Query(..., description="삭제할 카테고리 경로"),
|
||||
user: Annotated[User, Depends(get_current_user)] = None,
|
||||
user: Annotated[User, Depends(require_admin)] = None,
|
||||
session: Annotated[AsyncSession, Depends(get_session)] = None,
|
||||
):
|
||||
"""카테고리 삭제 (leaf only, 문서 없는 경우만)"""
|
||||
@@ -410,7 +410,7 @@ async def get_facet_values(
|
||||
@router.post("/facets", response_model=FacetValueResponse, status_code=201)
|
||||
async def add_facet_value(
|
||||
body: FacetValueResponse,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
user: Annotated[User, Depends(require_admin)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
):
|
||||
"""facet 사전에 새 값 추가"""
|
||||
@@ -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": "뉴스 수집 시작됨"}
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
"""발행 read API (docsrv-viewer-publish P0-2) — 뷰어가 pull-sync 로 당기는 feed.
|
||||
|
||||
published 테이블(발행 워커가 rev 커밋순 gapless 부여)을 rev 커서로 페이지네이션해 반환.
|
||||
뷰어 = Bearer(settings.viewer_sync_token) 인증, default-deny. read-only(SELECT 만).
|
||||
GET /published/feed?since={rev}&kind={kind}&limit={n}
|
||||
rev > since 행을 rev ASC 로 limit 만큼. kind 옵션(study_question|study_explanation|... 후속).
|
||||
tombstone(deleted=true)도 1급 이벤트로 포함 — 뷰어가 pub_id 로 로컬 삭제(stale 회피).
|
||||
|
||||
rev 커서 안전성: 워커가 pg_advisory_xact_lock 단일 라이터로 배치 rev 를 한 트랜잭션에
|
||||
부여·커밋 → 리더는 rev N 을 N-1 없이 보지 못함(부분가시 0). 뷰어는 next_since 로 반복.
|
||||
|
||||
엔벨로프 schema_version = 전송 계약 버전(payload 행별 schema_version 과 별개).
|
||||
미지원 버전 가시거부는 뷰어 책임(no-silent-fallback) — 여기선 행별 schema_version 그대로 전달.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
import logging
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from core.config import settings
|
||||
from core.database import async_session
|
||||
from models.published import Published
|
||||
from models.published import Published
|
||||
from services.queue_overview import build_overview
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# feed 엔벨로프(전송 계약) 버전 — payload schema_version 과 독립.
|
||||
FEED_SCHEMA_VERSION = 1
|
||||
DEFAULT_LIMIT = 200
|
||||
MAX_LIMIT = 500
|
||||
|
||||
|
||||
def _verify_token(authorization: str | None = Header(default=None)) -> None:
|
||||
"""뷰어↔DS 발행 채널 Bearer 인증. default-deny(미설정=503). 상수시간 비교(internal_study 정본).
|
||||
|
||||
이 토큰은 정답 포함 study payload 를 노출하므로 hmac.compare_digest 로 timing side-channel 차단.
|
||||
"""
|
||||
if not settings.viewer_sync_token:
|
||||
raise HTTPException(status_code=503, detail="viewer_sync_token not configured")
|
||||
if not authorization or not authorization.lower().startswith("bearer "):
|
||||
raise HTTPException(status_code=401, detail="missing Bearer token")
|
||||
token = authorization[7:].strip()
|
||||
if not hmac.compare_digest(token, settings.viewer_sync_token):
|
||||
raise HTTPException(status_code=403, detail="invalid token")
|
||||
|
||||
|
||||
async def _session() -> AsyncSession:
|
||||
async with async_session() as s:
|
||||
yield s
|
||||
|
||||
|
||||
class FeedItem(BaseModel):
|
||||
pub_id: str # opaque+stable = 뷰어 dedup키 = progress키
|
||||
kind: str
|
||||
source_id: int # DS 내부 소스 행 id (ingest write-back 역해소용, P2)
|
||||
rev: int
|
||||
deleted: bool # tombstone — 뷰어 로컬 삭제 트리거
|
||||
schema_version: int # payload 모양 버전(뷰어 range 수용)
|
||||
payload: dict # render-ready projection (tombstone 이면 {})
|
||||
|
||||
|
||||
class FeedResponse(BaseModel):
|
||||
schema_version: int # 엔벨로프(전송 계약) 버전
|
||||
items: list[FeedItem]
|
||||
next_since: int # 다음 호출 since (이 배치 max rev; 빈 배치면 입력 since 유지)
|
||||
has_more: bool # limit 가득 = 더 있을 수 있음(뷰어 반복)
|
||||
|
||||
|
||||
@router.get("/feed", response_model=FeedResponse)
|
||||
async def published_feed(
|
||||
since: int = Query(0, ge=0),
|
||||
kind: str | None = Query(None, max_length=40),
|
||||
limit: int = Query(DEFAULT_LIMIT, ge=1, le=MAX_LIMIT),
|
||||
_auth: None = Depends(_verify_token),
|
||||
session: AsyncSession = Depends(_session),
|
||||
):
|
||||
"""rev > since 행을 rev ASC 로 limit 만큼 반환. 뷰어가 next_since 로 incremental pull."""
|
||||
stmt = select(Published).where(Published.rev > since)
|
||||
if kind:
|
||||
stmt = stmt.where(Published.kind == kind)
|
||||
stmt = stmt.order_by(Published.rev.asc()).limit(limit)
|
||||
rows = (await session.execute(stmt)).scalars().all()
|
||||
|
||||
items = [
|
||||
FeedItem(
|
||||
pub_id=r.pub_id,
|
||||
kind=r.kind,
|
||||
source_id=r.source_id,
|
||||
rev=r.rev,
|
||||
deleted=r.deleted,
|
||||
schema_version=r.schema_version,
|
||||
payload=r.payload if r.payload is not None else {},
|
||||
)
|
||||
for r in rows
|
||||
]
|
||||
next_since = items[-1].rev if items else since
|
||||
has_more = len(rows) == limit
|
||||
logger.info(
|
||||
"published_feed since=%s kind=%s returned=%s next_since=%s has_more=%s",
|
||||
since, kind, len(items), next_since, has_more,
|
||||
)
|
||||
return FeedResponse(
|
||||
schema_version=FEED_SCHEMA_VERSION,
|
||||
items=items,
|
||||
next_since=next_since,
|
||||
has_more=has_more,
|
||||
)
|
||||
|
||||
|
||||
# ── P1-1: 뉴스/다이제스트 발행 read API (docsrv-viewer-publish) ────────────────────
|
||||
# global_digests(일간 컨테이너) + digest_topics(토픽 N, digest_id FK) -> render-ready
|
||||
# read-time projection. content-type 파라미터화(plan r2): version 커서=global_digests.id
|
||||
# (일간 단일 라이터라 gapless 불요·gap 무해) · pub_id=date-as-id(admin-gated feed 라 opacity
|
||||
# 불필요) · tombstone 없음(다이제스트 미삭제). 엔벨로프는 /feed 와 동일(FeedResponse)=뷰어 재사용.
|
||||
# scaffold-first: DIGEST_PUBLISH_ENABLED off(기본)=503(명시적 미가동, no-silent).
|
||||
DIGEST_PAYLOAD_SCHEMA_VERSION = 1
|
||||
|
||||
|
||||
@router.get("/digest", response_model=FeedResponse)
|
||||
async def published_digest(
|
||||
since: int = Query(0, ge=0),
|
||||
limit: int = Query(DEFAULT_LIMIT, ge=1, le=MAX_LIMIT),
|
||||
_auth: None = Depends(_verify_token),
|
||||
session: AsyncSession = Depends(_session),
|
||||
):
|
||||
"""global_digests.id > since 를 id ASC 로 limit 만큼. 각 digest 에 topics 조인해 render-ready 반환."""
|
||||
if not settings.digest_publish_enabled:
|
||||
raise HTTPException(status_code=503, detail="digest publish not enabled (scaffold)")
|
||||
|
||||
drows = (await session.execute(
|
||||
text(
|
||||
"SELECT id, digest_date, status, total_articles, total_topics, total_countries, created_at "
|
||||
"FROM global_digests WHERE id > :since ORDER BY id ASC LIMIT :limit"
|
||||
),
|
||||
{"since": since, "limit": limit},
|
||||
)).mappings().all()
|
||||
|
||||
if not drows:
|
||||
return FeedResponse(schema_version=FEED_SCHEMA_VERSION, items=[], next_since=since, has_more=False)
|
||||
|
||||
ids = [r["id"] for r in drows]
|
||||
trows = (await session.execute(
|
||||
text(
|
||||
"SELECT digest_id, topic_rank, topic_label, summary, country, article_count, importance_score "
|
||||
"FROM digest_topics WHERE digest_id = ANY(:ids) ORDER BY digest_id ASC, topic_rank ASC"
|
||||
),
|
||||
{"ids": ids},
|
||||
)).mappings().all()
|
||||
|
||||
topics_by_digest: dict[int, list[dict]] = {}
|
||||
for t in trows:
|
||||
topics_by_digest.setdefault(t["digest_id"], []).append({
|
||||
"rank": t["topic_rank"],
|
||||
"label": t["topic_label"],
|
||||
"summary": t["summary"],
|
||||
"country": t["country"],
|
||||
"article_count": t["article_count"],
|
||||
"importance": t["importance_score"],
|
||||
})
|
||||
|
||||
items = []
|
||||
for r in drows:
|
||||
d_date = r["digest_date"].isoformat() if r["digest_date"] else None
|
||||
items.append(FeedItem(
|
||||
pub_id=f"digest:{d_date}",
|
||||
kind="digest",
|
||||
source_id=r["id"],
|
||||
rev=r["id"],
|
||||
deleted=False,
|
||||
schema_version=DIGEST_PAYLOAD_SCHEMA_VERSION,
|
||||
payload={
|
||||
"digest_date": d_date,
|
||||
"status": r["status"],
|
||||
"total_articles": r["total_articles"],
|
||||
"total_topics": r["total_topics"],
|
||||
"total_countries": r["total_countries"],
|
||||
"generated_at": r["created_at"].isoformat() if r["created_at"] else None,
|
||||
"topics": topics_by_digest.get(r["id"], []),
|
||||
},
|
||||
))
|
||||
next_since = items[-1].rev
|
||||
has_more = len(drows) == limit
|
||||
logger.info(
|
||||
"published_digest since=%s returned=%s next_since=%s has_more=%s",
|
||||
since, len(items), next_since, has_more,
|
||||
)
|
||||
return FeedResponse(
|
||||
schema_version=FEED_SCHEMA_VERSION,
|
||||
items=items,
|
||||
next_since=next_since,
|
||||
has_more=has_more,
|
||||
)
|
||||
|
||||
|
||||
# ── P1-2: 가공현황 라이브 스냅샷 API (+P1-4 점검 플래그) ──────────────────────────
|
||||
# 뷰어 리포트 '문서 가공현황' 섹션용. build_overview(기존 서비스) 재사용 + source_health
|
||||
# 조인 요약. pull-through(저장 X) — 라이브 수치라 캐시 없음, 소비자(뷰어)가 2~3s timeout 책임
|
||||
# (plan P1-2). P1-4: maintenance 플래그 동봉 — 소프트락/점검이 워커를 멈춰 수치가 정체로
|
||||
# 보일 때 뷰어가 '점검·실험 중' 배너로 구분(표면 != 데이터). read-only.
|
||||
@router.get("/processing-status")
|
||||
async def published_processing_status(
|
||||
_auth: None = Depends(_verify_token),
|
||||
session: AsyncSession = Depends(_session),
|
||||
):
|
||||
"""가공현황 스냅샷: queue overview + source_health 요약 + maintenance 플래그."""
|
||||
overview = await build_overview(session)
|
||||
|
||||
sh_rows = (await session.execute(text(
|
||||
"SELECT ns.name, ns.category, sh.circuit_state, sh.consecutive_failures, sh.empty_streak, "
|
||||
"sh.last_success_at, sh.last_probe_ok "
|
||||
"FROM source_health sh JOIN news_sources ns ON ns.id = sh.source_id "
|
||||
"ORDER BY (sh.circuit_state <> 'closed') DESC, sh.consecutive_failures DESC"
|
||||
))).mappings().all()
|
||||
|
||||
by_state: dict[str, int] = {}
|
||||
problems: list[dict] = []
|
||||
for r in sh_rows:
|
||||
st = r["circuit_state"]
|
||||
by_state[st] = by_state.get(st, 0) + 1
|
||||
if st != "closed":
|
||||
problems.append({
|
||||
"name": r["name"],
|
||||
"category": r["category"],
|
||||
"circuit_state": st,
|
||||
"consecutive_failures": r["consecutive_failures"],
|
||||
"empty_streak": r["empty_streak"],
|
||||
"last_success_at": r["last_success_at"].isoformat() if r["last_success_at"] else None,
|
||||
"last_probe_ok": r["last_probe_ok"],
|
||||
})
|
||||
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||
"overview": overview,
|
||||
"sources": {
|
||||
"total": len(sh_rows),
|
||||
"by_circuit_state": by_state,
|
||||
"problems": problems,
|
||||
},
|
||||
"maintenance": {
|
||||
"active": settings.maintenance_mode,
|
||||
"note": settings.maintenance_note,
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
"""처리 머신 보드 API — /api/queue/* (plan ds-processing-ui-6an → ds-board-engines-1).
|
||||
|
||||
- GET /overview: 홈 stage 평면 테이블을 "머신 관점 보드(누가 일하나)"로 — 집계
|
||||
로직은 services/queue_overview.py (순수 판정부 분리). 응답 스키마는 FE 와
|
||||
계약 고정. 응답에 raw 모델명 노출 금지 — 머신 label 만 (엔진/모델 표기는
|
||||
FE 정적 맵 책임).
|
||||
- GET /failed + POST /retry|/skip: 실패 처리 (ds-board-engines-1) — 영구 실패
|
||||
(자동 재시도 3회 소진)의 유일한 사용자 조치 경로. 일괄 조치는 FE 가 그룹의
|
||||
id 목록을 모아 보낸다 (서버측 패턴 매칭 없음 — raw 식별자/패턴 미수신).
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Annotated, Literal
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from core.auth import get_current_user
|
||||
from core.database import get_session
|
||||
from models.user import User
|
||||
from services.queue_overview import (
|
||||
build_overview,
|
||||
fetch_failed_items,
|
||||
retry_failed,
|
||||
skip_failed,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class CurrentItem(BaseModel):
|
||||
"""머신이 지금 처리 중인 문서 (최대 2건)."""
|
||||
document_id: int
|
||||
title: str
|
||||
stage: str
|
||||
|
||||
|
||||
class MachineCard(BaseModel):
|
||||
"""머신 카드 — stage 귀속 합산 + 완료 실적(summarize 는 풀 분리) + state."""
|
||||
key: Literal["gpu", "macmini", "macbook"]
|
||||
label: str
|
||||
state: Literal["active", "deferred", "idle"]
|
||||
stages: list[str]
|
||||
pending: int
|
||||
processing: int
|
||||
failed: int
|
||||
done_1h: int
|
||||
done_today: int
|
||||
deferred_pending: int
|
||||
current: list[CurrentItem]
|
||||
|
||||
|
||||
class SummarizeEta(BaseModel):
|
||||
"""summarize 풀 ETA — done > inflow 일 때만 eta_minutes 산출."""
|
||||
pending: int
|
||||
done_rate_1h: int
|
||||
inflow_rate_1h: int
|
||||
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
|
||||
inflow: int
|
||||
done: int
|
||||
|
||||
|
||||
class Totals(BaseModel):
|
||||
"""전 stage 합계."""
|
||||
pending: int
|
||||
processing: int
|
||||
failed: int
|
||||
|
||||
|
||||
class StageRow(BaseModel):
|
||||
"""단계별 현황 행 — 흐름 노드/상세 패널용.
|
||||
|
||||
done_1h/created_1h = 처리율·유입률 (유입 우세 판정 + ETA 의 FE 재료,
|
||||
ds-board-engines-1 추가 — 수집 SQL 에 이미 있던 값의 노출).
|
||||
"""
|
||||
stage: str
|
||||
pending: int
|
||||
processing: int
|
||||
failed: int
|
||||
done_1h: int
|
||||
created_1h: int
|
||||
done_today: int
|
||||
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):
|
||||
"""영구 실패 행 — 실패 드로어 표시 단위."""
|
||||
id: int
|
||||
stage: str
|
||||
document_id: int
|
||||
title: str
|
||||
attempts: int
|
||||
max_attempts: int
|
||||
error_message: str | None
|
||||
failed_at: datetime | None
|
||||
|
||||
|
||||
class FailedListResponse(BaseModel):
|
||||
items: list[FailedItem]
|
||||
total: int
|
||||
|
||||
|
||||
class QueueActionRequest(BaseModel):
|
||||
"""재시도/건너뛰기 대상 — 실패 행 id 목록 (FE 가 그룹핑 후 전달)."""
|
||||
ids: list[int] = Field(min_length=1, max_length=300)
|
||||
|
||||
|
||||
class RetryResponse(BaseModel):
|
||||
requested: int
|
||||
retried: int
|
||||
not_retried: int
|
||||
|
||||
|
||||
class SkipResponse(BaseModel):
|
||||
requested: int
|
||||
skipped: int
|
||||
not_skipped: int
|
||||
|
||||
|
||||
@router.get("/overview", response_model=QueueOverviewResponse)
|
||||
async def get_queue_overview(
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
):
|
||||
"""머신 관점 처리 보드 + summarize ETA 집계 (라이브 계산, 신규 테이블 0)"""
|
||||
return QueueOverviewResponse.model_validate(await build_overview(session))
|
||||
|
||||
|
||||
@router.get("/failed", response_model=FailedListResponse)
|
||||
async def get_failed_items(
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
):
|
||||
"""영구 실패 행 목록 (문서 제목 포함, 최대 300건)"""
|
||||
items = await fetch_failed_items(session)
|
||||
return FailedListResponse(
|
||||
items=[FailedItem.model_validate(i) for i in items],
|
||||
total=len(items),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/retry", response_model=RetryResponse)
|
||||
async def retry_failed_items(
|
||||
body: QueueActionRequest,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
):
|
||||
"""실패 행 재시도 — attempts 리셋 + pending 복귀.
|
||||
|
||||
not_retried = 같은 (문서, 단계) 의 active 행 충돌(uq_queue_active) 또는
|
||||
이미 failed 가 아닌 행 (중복 클릭 등) — 건드리지 않고 건수만 보고.
|
||||
"""
|
||||
return RetryResponse.model_validate(await retry_failed(session, body.ids))
|
||||
|
||||
|
||||
@router.post("/skip", response_model=SkipResponse)
|
||||
async def skip_failed_items(
|
||||
body: QueueActionRequest,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
):
|
||||
"""실패 행 건너뛰기 — completed 마킹(payload.skipped_by_user) + 연쇄 없음"""
|
||||
return SkipResponse.model_validate(await skip_failed(session, body.ids))
|
||||
+46
-849
@@ -3,39 +3,28 @@
|
||||
실제 검색 파이프라인(retrieval → fusion → rerank → diversity → confidence)
|
||||
은 `services/search/search_pipeline.py::run_search()` 로 분리되어 있다.
|
||||
이 파일은 다음만 담당:
|
||||
- Pydantic 스키마 (SearchResult / SearchResponse / SearchDebug / DebugCandidate
|
||||
/ Citation / AskResponse / AskDebug)
|
||||
- Pydantic 스키마 (SearchResult / SearchResponse / SearchDebug / DebugCandidate)
|
||||
- `/search` endpoint wrapper (run_search 호출 + logger + telemetry + 직렬화)
|
||||
- `/ask` endpoint wrapper (Phase 3.3 에서 추가)
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import hmac
|
||||
import time
|
||||
from typing import Annotated, Literal
|
||||
from datetime import date
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, Header, Query
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, Query
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from core.auth import get_current_user
|
||||
from core.config import settings
|
||||
from core.auth import get_current_user, get_egress_class
|
||||
from core.database import get_session
|
||||
from core.utils import setup_logger
|
||||
from models.user import User
|
||||
from services.document_telemetry import sanitize_source
|
||||
from services.search.classifier_service import ClassifierResult, classify
|
||||
from services.search.evidence_service import EvidenceItem, extract_evidence
|
||||
from services.search.fusion_service import DEFAULT_FUSION
|
||||
from services.search.grounding_check import check as grounding_check
|
||||
from services.search.refusal_gate import RefusalDecision, decide as refusal_decide
|
||||
from services.search import query_rewriter
|
||||
from services.search.retrieval_service import AxisFilter
|
||||
from services.search.result_decorate import compute_facets, decorate_version_status
|
||||
from services.search.search_pipeline import PipelineResult, run_search
|
||||
from services.search.synthesis_service import SynthesisResult, synthesize
|
||||
from services.search.verifier_service import VerifierResult, verify
|
||||
from services.prompt_versions import ASK_PROMPT_VERSION, resolve_primary_model
|
||||
from services.search_telemetry import record_ask_event, record_search_event
|
||||
from services.search_telemetry import record_search_event
|
||||
|
||||
# logs/search.log + stdout 동시 출력 (Phase 0.4)
|
||||
logger = setup_logger("search")
|
||||
@@ -70,6 +59,14 @@ class SearchResult(BaseModel):
|
||||
# PR-RAG-Time-1: freshness decay 디버그 메타. apply_freshness_decay 가 채움.
|
||||
# 비적용 row 도 채워짐(freshness_policy=None). base_score 는 항상 보존.
|
||||
freshness_debug: dict | None = None
|
||||
# 안전 자료실 C-1: 분류 축 메타 (3 leg SELECT 에서 채움 — additive, ranking 무관).
|
||||
# D-1 UI 결과 카드 유형별 렌더 + 해외 법령(B-5) 가동 시 국가 무표지 혼재 차단의 선행 조건.
|
||||
material_type: str | None = None
|
||||
jurisdiction: str | None = None
|
||||
published_date: date | None = None
|
||||
# 안전 자료실 C-1 후속: 법령 버전 상태(legal_meta.version_status) — wrapper 1회 decorate.
|
||||
# law 결과만 채워짐(legal_meta 위성), 그 외/무매핑 law = None. D-1 버전 뱃지 선행.
|
||||
version_status: str | None = None
|
||||
|
||||
|
||||
# ─── Phase 0.4: 디버그 응답 스키마 ─────────────────────────
|
||||
@@ -101,6 +98,9 @@ class SearchResponse(BaseModel):
|
||||
query: str
|
||||
mode: str
|
||||
debug: SearchDebug | None = None
|
||||
# 안전 자료실 C-1 후속: facets=true 일 때만 채워짐(미요청=None, byte 불변).
|
||||
# top-K 결과 내 분류 축 분포 라벨 {axis: {label: count}}.
|
||||
facets: dict[str, dict[str, int]] | None = None
|
||||
|
||||
|
||||
def _to_debug_candidates(rows: list[SearchResult], n: int = 20) -> list[DebugCandidate]:
|
||||
@@ -139,6 +139,7 @@ def _build_search_debug(pr: PipelineResult) -> SearchDebug:
|
||||
async def search(
|
||||
q: str,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
egress_class: Annotated[str, Depends(get_egress_class)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
background_tasks: BackgroundTasks,
|
||||
mode: str = Query("hybrid", pattern="^(fts|trgm|vector|hybrid)$"),
|
||||
@@ -205,9 +206,28 @@ async def search(
|
||||
"분리용. production 검색에는 사용 금지 (latency 큼)."
|
||||
),
|
||||
),
|
||||
material_type: str | None = Query(
|
||||
None, description="안전 자료실 C-1: 자료유형 필터 CSV (law,paper,incident,...). material_type = ANY"),
|
||||
jurisdiction: str | None = Query(
|
||||
None, description="안전 자료실 C-1: 관할 필터 (KR/US/EU/JP/GB/INT)"),
|
||||
year_from: int | None = Query(None, ge=1900, le=2100, description="published_date 연도 하한 (NULL=created_at fallback)"),
|
||||
year_to: int | None = Query(None, ge=1900, le=2100, description="published_date 연도 상한"),
|
||||
domain_bucket: str | None = Query(None, description="377: domain_bucket 스코프 CSV (Safety,Engineering,Law,Philosophy,Programming,General,News). domain_bucket = ANY"),
|
||||
exclude_bucket: str | None = Query(None, description="377: domain_bucket 제외 CSV (예: News). 지식질의 시 News 기본제외용"),
|
||||
facets: bool = Query(False, description="안전 자료실 C-1 후속: top-K 결과 분류 축 분포(material_type/jurisdiction/version_status)를 응답 facets 에 집계. 미지정=계산/노출 0"),
|
||||
):
|
||||
"""문서 검색 — FTS + ILIKE + 벡터 결합 (Phase 3.1 이후 run_search wrapper)"""
|
||||
try:
|
||||
axis = AxisFilter(
|
||||
material_types=[m.strip() for m in material_type.split(",") if m.strip()]
|
||||
if material_type else None,
|
||||
jurisdiction=jurisdiction,
|
||||
year_from=year_from,
|
||||
year_to=year_to,
|
||||
domain_buckets=[b.strip() for b in domain_bucket.split(",") if b.strip()] if domain_bucket else None,
|
||||
exclude_buckets=[b.strip() for b in exclude_bucket.split(",") if b.strip()] if exclude_bucket else None,
|
||||
cloud_egress=(egress_class == "cloud"),
|
||||
)
|
||||
pr = await run_search(
|
||||
session,
|
||||
q,
|
||||
@@ -223,6 +243,7 @@ async def search(
|
||||
rewrite_backend=rewrite_backend,
|
||||
corpus_variant=corpus_variant,
|
||||
exact_knn=exact_knn,
|
||||
axis=axis,
|
||||
)
|
||||
except ValueError as e:
|
||||
# _resolve_backend / _resolve_reranker / _resolve_rewrite_backend / _resolve_corpus_variant unknown slug → HTTP 400
|
||||
@@ -262,7 +283,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,
|
||||
},
|
||||
)
|
||||
@@ -313,839 +334,15 @@ async def search(
|
||||
|
||||
debug_obj = _build_search_debug(pr) if debug else None
|
||||
|
||||
# 안전 자료실 C-1 후속 — wrapper decoration (검색 코어 무접촉, ranking 무관)
|
||||
await decorate_version_status(session, pr.results) # 법령 결과에 version_status
|
||||
facets_obj = compute_facets(pr.results) if facets else None
|
||||
|
||||
return SearchResponse(
|
||||
results=pr.results,
|
||||
total=len(pr.results),
|
||||
query=q,
|
||||
mode=pr.mode,
|
||||
debug=debug_obj,
|
||||
)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# Phase 3.3: /api/search/ask — Evidence + Grounded Synthesis
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class Citation(BaseModel):
|
||||
"""answer 본문의 [n] 에 해당하는 근거 단일 행."""
|
||||
|
||||
n: int
|
||||
chunk_id: int | None
|
||||
doc_id: int
|
||||
title: str | None
|
||||
section_title: str | None
|
||||
span_text: str # evidence LLM 이 추출한 50~300자
|
||||
full_snippet: str # 원본 800자 (citation 원문 보기 전용)
|
||||
relevance: float
|
||||
rerank_score: float
|
||||
|
||||
|
||||
class ConfirmedItem(BaseModel):
|
||||
"""Partial answer 의 개별 aspect 답변."""
|
||||
|
||||
aspect: str
|
||||
text: str
|
||||
citations: list[int]
|
||||
|
||||
|
||||
class AskDebug(BaseModel):
|
||||
"""`/ask?debug=true` 응답 확장."""
|
||||
|
||||
timing_ms: dict[str, float]
|
||||
search_notes: list[str]
|
||||
query_analysis: dict | None = None
|
||||
confidence_signal: float
|
||||
evidence_candidate_count: int
|
||||
evidence_kept_count: int
|
||||
evidence_skip_reason: str | None
|
||||
synthesis_cache_hit: bool
|
||||
synthesis_prompt_preview: str | None = None
|
||||
synthesis_raw_preview: str | None = None
|
||||
hallucination_flags: list[str] = []
|
||||
# Phase 3.5a: per-layer defense 로깅
|
||||
defense_layers: dict | None = None
|
||||
|
||||
|
||||
class AskResponse(BaseModel):
|
||||
"""`/ask` 응답. Phase 3.5a: completeness + aspects 추가."""
|
||||
|
||||
results: list[SearchResult]
|
||||
ai_answer: str | None
|
||||
citations: list[Citation]
|
||||
synthesis_status: Literal[
|
||||
"completed", "timeout", "skipped", "no_evidence", "parse_failed", "llm_error",
|
||||
# PR-MacBook-RAG-Backend-1: 200 응답에는 등장하지 않음 (해당 status 는 503 분기).
|
||||
# Literal 호환성 위해 포함.
|
||||
"backend_unavailable",
|
||||
]
|
||||
synthesis_ms: float
|
||||
confidence: Literal["high", "medium", "low"] | None
|
||||
refused: bool
|
||||
no_results_reason: str | None
|
||||
query: str
|
||||
total: int
|
||||
# Phase 3.5a
|
||||
completeness: Literal["full", "partial", "insufficient"] = "full"
|
||||
covered_aspects: list[str] | None = None
|
||||
missing_aspects: list[str] | None = None
|
||||
confirmed_items: list[ConfirmedItem] | None = None
|
||||
# PR-MacBook-RAG-Backend-1: backend dispatcher metadata.
|
||||
# backend 미지정 호출은 둘 다 None 으로 유지 (기존 호출자 호환 — Hermes docsrv_ask /
|
||||
# voice-memo-bot 응답 형식 변동 0). 명시 opt-in 시만 채워짐.
|
||||
backend_requested: str | None = None
|
||||
backend_used: str | None = None
|
||||
debug: AskDebug | None = None
|
||||
|
||||
|
||||
def _map_no_results_reason(
|
||||
pr: PipelineResult,
|
||||
evidence: list[EvidenceItem],
|
||||
ev_skip: str | None,
|
||||
sr: SynthesisResult,
|
||||
) -> str | None:
|
||||
"""사용자에게 보여줄 한국어 메시지 매핑.
|
||||
|
||||
Failure mode 표 (plan §Failure Modes) 기반.
|
||||
"""
|
||||
# LLM 자가 refused → 모델이 준 사유 그대로
|
||||
if sr.refused and sr.refuse_reason:
|
||||
return sr.refuse_reason
|
||||
|
||||
# synthesis 상태 우선
|
||||
if sr.status == "no_evidence":
|
||||
if not pr.results:
|
||||
return "검색 결과가 없습니다."
|
||||
return "관련도 높은 근거를 찾지 못했습니다."
|
||||
if sr.status == "skipped":
|
||||
return "검색 결과가 없습니다."
|
||||
if sr.status == "timeout":
|
||||
return "답변 생성이 지연되어 생략했습니다. 검색 결과를 확인해 주세요."
|
||||
if sr.status == "parse_failed":
|
||||
return "답변 형식 오류로 생략했습니다."
|
||||
if sr.status == "llm_error":
|
||||
return "AI 서버에 일시적 문제가 있습니다."
|
||||
|
||||
# evidence 단계 실패는 fallback 을 탔더라도 notes 용
|
||||
if ev_skip == "all_low_rerank":
|
||||
return "관련도 높은 근거를 찾지 못했습니다."
|
||||
if ev_skip == "empty_retrieval":
|
||||
return "검색 결과가 없습니다."
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _build_citations(
|
||||
evidence: list[EvidenceItem], used_citations: list[int]
|
||||
) -> list[Citation]:
|
||||
"""answer 본문에 실제로 등장한 n 만 Citation 으로 변환."""
|
||||
by_n = {e.n: e for e in evidence}
|
||||
out: list[Citation] = []
|
||||
for n in used_citations:
|
||||
e = by_n.get(n)
|
||||
if e is None:
|
||||
continue
|
||||
out.append(
|
||||
Citation(
|
||||
n=e.n,
|
||||
chunk_id=e.chunk_id,
|
||||
doc_id=e.doc_id,
|
||||
title=e.title,
|
||||
section_title=e.section_title,
|
||||
span_text=e.span_text,
|
||||
full_snippet=e.full_snippet,
|
||||
relevance=e.relevance,
|
||||
rerank_score=e.rerank_score,
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def _build_ask_debug(
|
||||
pr: PipelineResult,
|
||||
evidence: list[EvidenceItem],
|
||||
ev_skip: str | None,
|
||||
sr: SynthesisResult,
|
||||
ev_ms: float,
|
||||
synth_ms: float,
|
||||
total_ms: float,
|
||||
) -> AskDebug:
|
||||
timing: dict[str, float] = dict(pr.timing_ms)
|
||||
timing["evidence_ms"] = ev_ms
|
||||
timing["synthesis_ms"] = synth_ms
|
||||
timing["ask_total_ms"] = total_ms
|
||||
|
||||
# candidate count 는 rule filter 통과한 수 (recomputable from results)
|
||||
# 엄밀히는 evidence_service 내부 숫자인데, evidence 길이 ≈ kept, candidate
|
||||
# 는 관측이 어려움 → kept 는 evidence 길이, candidate 는 별도 필드 없음.
|
||||
# 단순화: candidate_count = len(evidence) 를 상한 근사로 둠 (debug 전용).
|
||||
return AskDebug(
|
||||
timing_ms=timing,
|
||||
search_notes=pr.notes,
|
||||
query_analysis=pr.query_analysis,
|
||||
confidence_signal=pr.confidence_signal,
|
||||
evidence_candidate_count=len(evidence),
|
||||
evidence_kept_count=len(evidence),
|
||||
evidence_skip_reason=ev_skip,
|
||||
synthesis_cache_hit=sr.cache_hit,
|
||||
synthesis_prompt_preview=None, # 현재 synthesis_service 에서 노출 안 함
|
||||
synthesis_raw_preview=sr.raw_preview,
|
||||
hallucination_flags=sr.hallucination_flags,
|
||||
)
|
||||
|
||||
|
||||
def _detect_synthesis_failure(sr: SynthesisResult) -> str | None:
|
||||
"""Synthesis 가 유효한 답을 못 냈으면 re_gate 라벨, 아니면 None.
|
||||
|
||||
판정 우선순위 (Phase 3.5 fix3):
|
||||
1) sr.refused → LLM self-refuse (status="completed") 또는 mechanical fail 후 refused 전파
|
||||
- status=="completed" + refused=True → "synthesis_self_refuse"
|
||||
- 그 외 → f"synthesis_failed({status})"
|
||||
2) sr.status ∈ {timeout, parse_failed, llm_error} → f"synthesis_failed({status})"
|
||||
3) answer 공백 → f"synthesis_failed({status})"
|
||||
4) 유효 → None
|
||||
"""
|
||||
if sr.refused:
|
||||
if sr.status == "completed":
|
||||
return "synthesis_self_refuse"
|
||||
return f"synthesis_failed({sr.status})"
|
||||
if sr.status in ("timeout", "parse_failed", "llm_error"):
|
||||
return f"synthesis_failed({sr.status})"
|
||||
if not (sr.answer or "").strip():
|
||||
return f"synthesis_failed({sr.status})"
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_eval_identity(
|
||||
x_source: str | None,
|
||||
x_eval_case_id: str | None,
|
||||
x_eval_token: str | None,
|
||||
) -> tuple[str, str | None]:
|
||||
"""X-Source/X-Eval-Case-Id 신뢰 검증 (Phase 3.5 fix2).
|
||||
|
||||
규칙:
|
||||
- 기본값: source='document_server', eval_case_id=None
|
||||
- X-Source=eval 또는 X-Eval-Case-Id 가 들어왔다면 eval claim 으로 간주
|
||||
- eval claim 은 X-Eval-Token == settings.eval_runner_token 일 때만 수용
|
||||
(constant-time compare, env 미설정 시 항상 거부)
|
||||
- 거부 시: 헤더 무시 + warning log + source=sanitize(non-eval) / eval_case_id=None
|
||||
- 통과 시: source='eval', eval_case_id=x_eval_case_id
|
||||
|
||||
반환: (source, eval_case_id)
|
||||
"""
|
||||
claimed_source = sanitize_source(x_source)
|
||||
is_eval_claim = (claimed_source == "eval") or bool(x_eval_case_id)
|
||||
if not is_eval_claim:
|
||||
# 일반 호출 — eval_case_id 강제 None (source != 'eval' 이면 case_id 의미 없음)
|
||||
return claimed_source, None
|
||||
|
||||
# eval claim — token 검증
|
||||
expected = settings.eval_runner_token
|
||||
presented = x_eval_token or ""
|
||||
token_valid = bool(expected) and hmac.compare_digest(presented, expected)
|
||||
if not token_valid:
|
||||
logger.warning(
|
||||
"eval header rejected: source=%s case_id=%s token_present=%s expected_set=%s",
|
||||
x_source, x_eval_case_id, bool(x_eval_token), bool(expected),
|
||||
)
|
||||
# 일반 호출로 강등 — source='eval' 주장은 무시, case_id 도 무시
|
||||
# claimed_source 가 'eval' 이면 default 'document_server' 로
|
||||
if claimed_source == "eval":
|
||||
return "document_server", None
|
||||
return claimed_source, None
|
||||
|
||||
# token OK — eval 라벨 수용
|
||||
return "eval", x_eval_case_id
|
||||
|
||||
|
||||
@router.get("/ask", response_model=AskResponse)
|
||||
async def ask(
|
||||
q: str,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
background_tasks: BackgroundTasks,
|
||||
limit: int = Query(10, ge=1, le=20, description="synthesis 입력 상한"),
|
||||
debug: bool = Query(False, description="evidence/synthesis 중간 상태 노출"),
|
||||
backend: Annotated[
|
||||
str | None,
|
||||
Query(
|
||||
pattern="^(qwen-macbook|gemma-macmini|mac-mini-default|claude-cloud|auto)$",
|
||||
description=(
|
||||
"PR-2 of DS AI routing policy (2026-05-23) — 명시 backend opt-in via llm-router. "
|
||||
"미지정 = mac-mini-default (gemma-macmini alias, default). "
|
||||
"'mac-mini-default' = router 가 tier_b (Mac mini gemma-4-26b). "
|
||||
"'qwen-macbook' = router 가 named upstream (M5 Max Qwen 3.6 27B). "
|
||||
"'claude-cloud' = router 가 503 provider_not_configured (활성화 별 PR). "
|
||||
"'auto' = router 의 rule + LLM triage. "
|
||||
"backend unavailable 시 503 + error_reason=macbook_unavailable / router_* "
|
||||
"(자동 fallback 없음 — 다시 호출하거나 backend 인자 제거 후 재시도)."
|
||||
),
|
||||
),
|
||||
] = None,
|
||||
corpus_variant: str | None = Query(
|
||||
None,
|
||||
pattern=r"^(prehier|hier_sim_raw|hier_sim_clean)$",
|
||||
description=(
|
||||
"⚠️ EVAL-ONLY (Hier-PassageRAG-Diagnose-1). evidence retrieval 의 chunk leg 를 측정 뷰로 "
|
||||
"교체 — prehier(legacy) | hier_sim_raw | hier_sim_clean. 운영 UI 미사용. "
|
||||
"미지정 = production corpus_chunks (기존 /ask 동작 동일)."
|
||||
),
|
||||
),
|
||||
exact_knn: bool = Query(
|
||||
False,
|
||||
description=(
|
||||
"⚠️ EVAL-ONLY (Hier-PassageRAG-Diagnose-1). vector leg exact KNN (ivfflat 근사 제거). "
|
||||
"passage 변종 공정 비교용. 운영 미사용. 미지정(false) = 기존 /ask 동작 동일."
|
||||
),
|
||||
),
|
||||
x_source: Annotated[str | None, Header(alias="X-Source")] = None,
|
||||
x_eval_case_id: Annotated[str | None, Header(alias="X-Eval-Case-Id")] = None,
|
||||
x_eval_token: Annotated[str | None, Header(alias="X-Eval-Token")] = None,
|
||||
):
|
||||
"""근거 기반 AI 답변 (Phase 3.5a).
|
||||
|
||||
Phase 3.3 기반 + classifier parallel + refusal gate + grounding re-gate.
|
||||
실패 경로에서도 `results` 는 항상 반환.
|
||||
|
||||
Phase 3.5 calibration trust boundary (fix2):
|
||||
- X-Source / X-Eval-Case-Id 는 X-Eval-Token 이 EVAL_RUNNER_TOKEN 와 일치하는
|
||||
trusted internal eval runner 에서만 수용된다.
|
||||
- 일반 client 의 X-Source=eval 시도는 무시되고 source='document_server' 로 강제.
|
||||
- source != 'eval' 이면 eval_case_id 항상 None.
|
||||
"""
|
||||
t_total = time.perf_counter()
|
||||
defense_log: dict = {} # per-layer flag snapshot
|
||||
source, eval_case_id = _resolve_eval_identity(x_source, x_eval_case_id, x_eval_token)
|
||||
|
||||
# 1. 검색 파이프라인 (corpus_variant/exact_knn = EVAL-ONLY, 미지정 시 기존 동작 동일)
|
||||
pr = await run_search(
|
||||
session, q, mode="hybrid", limit=limit,
|
||||
fusion=DEFAULT_FUSION, rerank=True, analyze=True,
|
||||
corpus_variant=corpus_variant, exact_knn=exact_knn,
|
||||
)
|
||||
|
||||
# 1.5. ask_includable=false 문서를 evidence 입력에서 제외
|
||||
# 검색 결과 자체는 유지 (사용자에게 보여줌), evidence만 필터
|
||||
if pr.results:
|
||||
from sqlalchemy import select as sa_select
|
||||
from models.document import Document as DocModel
|
||||
ask_doc_ids = set()
|
||||
excluded_ids = {r.id for r in pr.results}
|
||||
rows = await session.execute(
|
||||
sa_select(DocModel.id, DocModel.ask_includable).where(
|
||||
DocModel.id.in_(excluded_ids)
|
||||
)
|
||||
)
|
||||
for doc_id, includable in rows:
|
||||
if includable is False:
|
||||
ask_doc_ids.add(doc_id)
|
||||
evidence_results = [r for r in pr.results if r.id not in ask_doc_ids]
|
||||
else:
|
||||
evidence_results = pr.results
|
||||
|
||||
# 2. Evidence + Classifier 병렬
|
||||
t_ev = time.perf_counter()
|
||||
evidence_task = asyncio.create_task(extract_evidence(q, evidence_results))
|
||||
|
||||
# classifier input: top 3 chunks meta + rerank scores
|
||||
top_chunks = [
|
||||
{
|
||||
"title": r.title or "",
|
||||
"section": r.section_title or "",
|
||||
"snippet": (r.snippet or "")[:200],
|
||||
}
|
||||
for r in pr.results[:3]
|
||||
]
|
||||
rerank_scores_top = [
|
||||
r.rerank_score if r.rerank_score is not None else r.score
|
||||
for r in pr.results[:3]
|
||||
]
|
||||
classifier_task = asyncio.create_task(
|
||||
classify(q, top_chunks, rerank_scores_top)
|
||||
)
|
||||
|
||||
evidence, ev_skip = await evidence_task
|
||||
ev_ms = (time.perf_counter() - t_ev) * 1000
|
||||
|
||||
# classifier await (timeout 보호 — classifier_service 내부에도 있지만 여기서 이중 보호)
|
||||
# 2026-05-17: 6s outer wrapper 가 classifier_service.LLM_TIMEOUT_MS (30s) 를 override → 동시 부하 시
|
||||
# 거의 모든 classifier 호출 timeout → conservative_refuse(no_classifier) 경로. 15s 로 상향 — classifier
|
||||
# 가 실제 작동하도록 (단, ask 전체 응답 시간 상한 영향: ev_ms + max(classifier_wait, evidence_extract) +
|
||||
# synth_ms + verifier 누적).
|
||||
# 2026-05-17 B-3: 15s 도 동시 부하 시 부족 (classifier_service LLM_TIMEOUT_MS 30s 와 misalign).
|
||||
# 30s 로 align → classifier 동작 안정. ask 응답 latency 상한 ↑ 의도.
|
||||
try:
|
||||
classifier_result = await asyncio.wait_for(classifier_task, timeout=30.0)
|
||||
except (asyncio.TimeoutError, Exception):
|
||||
classifier_result = ClassifierResult("timeout", None, [], [], 0.0)
|
||||
|
||||
defense_log["classifier"] = {
|
||||
"status": classifier_result.status,
|
||||
"verdict": classifier_result.verdict,
|
||||
"covered_aspects": classifier_result.covered_aspects,
|
||||
"missing_aspects": classifier_result.missing_aspects,
|
||||
"elapsed_ms": classifier_result.elapsed_ms,
|
||||
}
|
||||
|
||||
# 3. Refusal gate (multi-signal fusion)
|
||||
all_rerank_scores = [
|
||||
e.rerank_score for e in evidence
|
||||
] if evidence else rerank_scores_top
|
||||
decision = refusal_decide(all_rerank_scores, classifier_result)
|
||||
|
||||
defense_log["score_gate"] = {
|
||||
"max": max(all_rerank_scores) if all_rerank_scores else 0.0,
|
||||
"agg_top3": sum(sorted(all_rerank_scores, reverse=True)[:3]),
|
||||
}
|
||||
defense_log["refusal"] = {
|
||||
"refused": decision.refused,
|
||||
"rule_triggered": decision.rule_triggered,
|
||||
}
|
||||
|
||||
if decision.refused:
|
||||
total_ms = (time.perf_counter() - t_total) * 1000
|
||||
no_reason = "관련 근거를 찾지 못했습니다."
|
||||
if not pr.results:
|
||||
no_reason = "검색 결과가 없습니다."
|
||||
logger.info(
|
||||
"ask REFUSED query=%r rule=%s max_score=%.2f total=%.0f",
|
||||
q[:80], decision.rule_triggered,
|
||||
max(all_rerank_scores) if all_rerank_scores else 0.0, total_ms,
|
||||
)
|
||||
# telemetry — search + ask_events 두 경로 동시
|
||||
background_tasks.add_task(
|
||||
record_search_event, q, user.id, pr.results, "hybrid",
|
||||
pr.confidence_signal, pr.analyzer_confidence,
|
||||
)
|
||||
# input_snapshot (디버깅/재현용)
|
||||
defense_log["input_snapshot"] = {
|
||||
"query": q,
|
||||
"top_chunks_preview": [
|
||||
{"title": c.get("title", ""), "snippet": c.get("snippet", "")[:100]}
|
||||
for c in top_chunks[:3]
|
||||
],
|
||||
"answer_preview": None,
|
||||
}
|
||||
background_tasks.add_task(
|
||||
record_ask_event,
|
||||
q, user.id, "insufficient", "skipped", None,
|
||||
True, classifier_result.verdict,
|
||||
max(all_rerank_scores) if all_rerank_scores else 0.0,
|
||||
sum(sorted(all_rerank_scores, reverse=True)[:3]),
|
||||
[], len(evidence), 0,
|
||||
defense_log, int(total_ms),
|
||||
# Phase E.1 측정 필드
|
||||
answer_length=0,
|
||||
covered_aspects=classifier_result.covered_aspects or None,
|
||||
missing_aspects=classifier_result.missing_aspects or None,
|
||||
model_name=resolve_primary_model(),
|
||||
prompt_version=ASK_PROMPT_VERSION,
|
||||
# Phase 3.5 calibration
|
||||
source=source,
|
||||
eval_case_id=eval_case_id,
|
||||
)
|
||||
debug_obj = None
|
||||
if debug:
|
||||
debug_obj = AskDebug(
|
||||
timing_ms={**pr.timing_ms, "evidence_ms": ev_ms, "ask_total_ms": total_ms},
|
||||
search_notes=pr.notes,
|
||||
confidence_signal=pr.confidence_signal,
|
||||
evidence_candidate_count=len(evidence),
|
||||
evidence_kept_count=len(evidence),
|
||||
evidence_skip_reason=ev_skip,
|
||||
synthesis_cache_hit=False,
|
||||
hallucination_flags=[],
|
||||
defense_layers=defense_log,
|
||||
)
|
||||
return AskResponse(
|
||||
results=pr.results,
|
||||
ai_answer=None,
|
||||
citations=[],
|
||||
synthesis_status="skipped",
|
||||
synthesis_ms=0.0,
|
||||
confidence=None,
|
||||
refused=True,
|
||||
no_results_reason=no_reason,
|
||||
query=q,
|
||||
total=len(pr.results),
|
||||
completeness="insufficient",
|
||||
covered_aspects=classifier_result.covered_aspects or None,
|
||||
missing_aspects=classifier_result.missing_aspects or None,
|
||||
# refusal gate 단계에서는 backend 호출 자체가 일어나지 않음 →
|
||||
# backend_used = None. backend_requested 는 호출자 의도 표시용.
|
||||
backend_requested=backend,
|
||||
backend_used=None,
|
||||
debug=debug_obj,
|
||||
)
|
||||
|
||||
# 4. Synthesis (backend dispatcher 적용 — PR-MacBook-RAG-Backend-1)
|
||||
t_synth = time.perf_counter()
|
||||
sr = await synthesize(q, evidence, debug=debug, backend=backend)
|
||||
synth_ms = (time.perf_counter() - t_synth) * 1000
|
||||
|
||||
# 4.1. backend_unavailable → 503 fail-fast (자동 fallback 금지)
|
||||
# 명시 opt-in backend (예: qwen-macbook) 가 비가용일 때만 발생. /ask wrapper 는
|
||||
# 절대 다른 backend 로 재시도하지 않음. 사용자가 backend 인자 제거 또는 wake 후 재시도.
|
||||
if sr.status == "backend_unavailable":
|
||||
backend_requested_val = backend or "gemma-macmini"
|
||||
total_ms = (time.perf_counter() - t_total) * 1000
|
||||
logger.warning(
|
||||
"ask backend_unavailable backend=%s query=%r total_ms=%.0f flags=%s",
|
||||
backend_requested_val, q[:80], total_ms,
|
||||
",".join(sr.hallucination_flags) if sr.hallucination_flags else "-",
|
||||
)
|
||||
# error_reason 명명 — macbook_unavailable 만 정착 (자동 fallback 부재).
|
||||
error_reason = (
|
||||
"macbook_unavailable"
|
||||
if backend_requested_val == "qwen-macbook"
|
||||
else "backend_unavailable"
|
||||
)
|
||||
# telemetry — search 만 기록 (ask_events 는 200 응답 path 전용)
|
||||
background_tasks.add_task(
|
||||
record_search_event, q, user.id, pr.results, "hybrid",
|
||||
pr.confidence_signal, pr.analyzer_confidence,
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=503,
|
||||
content={
|
||||
"error": "backend_unavailable",
|
||||
"error_reason": error_reason,
|
||||
"backend_requested": backend_requested_val,
|
||||
"backend_used": None,
|
||||
"query": q,
|
||||
"detail": (
|
||||
"명시 선택한 backend 가 일시적으로 응답할 수 없습니다. "
|
||||
"MacBook 깨우거나 backend 인자를 제거하고 (기본 Gemma) 다시 호출하세요."
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
# 5. Grounding check + Verifier (조건부 병렬) + re-gate (Phase 3.5b)
|
||||
grounding = grounding_check(q, sr.answer or "", evidence)
|
||||
|
||||
# verifier skip: grounding strong 2+ OR retrieval 자체가 망함
|
||||
grounding_only_strong = [
|
||||
f for f in grounding.strong_flags if not f.startswith("verifier_")
|
||||
]
|
||||
max_rerank = max(all_rerank_scores, default=0.0)
|
||||
if len(grounding_only_strong) >= 2 or max_rerank < 0.2:
|
||||
verifier_result = VerifierResult("skipped", [], 0.0)
|
||||
else:
|
||||
verifier_task = asyncio.create_task(
|
||||
verify(q, sr.answer or "", evidence)
|
||||
)
|
||||
# 2026-05-17 B-3: 4s outer wait_for 가 verifier_service LLM_TIMEOUT_MS (10s) 를 override
|
||||
# → 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):
|
||||
verifier_result = VerifierResult("timeout", [], 0.0)
|
||||
|
||||
# Verifier contradictions → grounding flags 머지 (prefix 로 구분, severity 3단계)
|
||||
for c in verifier_result.contradictions:
|
||||
if c.severity == "strong":
|
||||
grounding.strong_flags.append(f"verifier_{c.type}:{c.claim[:30]}")
|
||||
elif c.severity == "medium":
|
||||
grounding.weak_flags.append(f"verifier_{c.type}_medium:{c.claim[:30]}")
|
||||
else:
|
||||
grounding.weak_flags.append(f"verifier_{c.type}:{c.claim[:30]}")
|
||||
|
||||
defense_log["evidence"] = {
|
||||
"skip_reason": ev_skip,
|
||||
"kept_count": len(evidence),
|
||||
}
|
||||
defense_log["grounding"] = {
|
||||
"strong": grounding.strong_flags,
|
||||
"weak": grounding.weak_flags,
|
||||
}
|
||||
defense_log["verifier"] = {
|
||||
"status": verifier_result.status,
|
||||
"contradictions_count": len(verifier_result.contradictions),
|
||||
"strong_count": sum(1 for c in verifier_result.contradictions if c.severity == "strong"),
|
||||
"medium_count": sum(1 for c in verifier_result.contradictions if c.severity == "medium"),
|
||||
"elapsed_ms": verifier_result.elapsed_ms,
|
||||
}
|
||||
|
||||
# ── Re-gate: 7-tier completeness 결정 (Phase 3.5 B2 — Tier 4 신규 삽입, 재번호) ──
|
||||
# 기존 6-tier (3.5b 4차 리뷰) + Tier 4(g_strong + v_strong_numeric + low_conf → refuse).
|
||||
# 호환성: defense_layers["re_gate"] 의 string literal 들은 기존 그대로 유지.
|
||||
# 신규 "refuse(grounding+verifier_numeric)" 만 추가.
|
||||
completeness: Literal["full", "partial", "insufficient"] = "full"
|
||||
covered_aspects = classifier_result.covered_aspects or None
|
||||
missing_aspects = classifier_result.missing_aspects or None
|
||||
confirmed_items: list[ConfirmedItem] | None = None
|
||||
|
||||
# verifier/grounding strong 구분
|
||||
g_strong = [f for f in grounding.strong_flags if not f.startswith("verifier_")]
|
||||
v_strong = [f for f in grounding.strong_flags if f.startswith("verifier_")]
|
||||
v_medium = [f for f in grounding.weak_flags if f.startswith("verifier_") and "_medium:" in f]
|
||||
has_direct_negation = any("direct_negation" in f for f in v_strong)
|
||||
# Phase 3.5 B2: verifier strong flags 중 numeric_conflict 만 카운트.
|
||||
# promote(VERIFIER_NUMERIC_PROMOTE=1) 활성 시 critical numeric_conflict 가 strong 으로 승격되며
|
||||
# 여기 카운트에 잡힘. promote off 면 항상 0 → Tier 4 활성 안 됨 (기존 동작 유지).
|
||||
v_strong_numeric = sum(
|
||||
1 for f in v_strong if f.startswith("verifier_numeric_conflict")
|
||||
)
|
||||
|
||||
# ── Tier 0 (Phase 3.5 fix3): synthesis 자체 실패 처리 ──
|
||||
# LLM self-refuse, 메커니즘 실패(timeout/parse_failed/llm_error), answer 공백.
|
||||
# 빈 답에 대해 grounding/verifier flag 가 0건이라 기존 체인이 "else clean" 으로 빠지며
|
||||
# completeness="full" 초기값이 보존되던 모순을 여기서 일관되게 차단.
|
||||
# 과거 baseline(v1-400char) 에서 20(self-refuse)+4(timeout) = 24/223 (10.8%) 해당.
|
||||
tier0_label = _detect_synthesis_failure(sr)
|
||||
if tier0_label:
|
||||
completeness = "insufficient"
|
||||
sr.answer = None
|
||||
sr.refused = True
|
||||
sr.confidence = None
|
||||
defense_log["re_gate"] = tier0_label
|
||||
elif len(g_strong) >= 2:
|
||||
# Tier 1: grounding strong 2+ → refuse
|
||||
completeness = "insufficient"
|
||||
sr.answer = None
|
||||
sr.refused = True
|
||||
sr.confidence = None
|
||||
defense_log["re_gate"] = "refuse(grounding_2+strong)"
|
||||
elif g_strong and has_direct_negation:
|
||||
# Tier 2: grounding strong + verifier direct_negation → refuse
|
||||
completeness = "insufficient"
|
||||
sr.answer = None
|
||||
sr.refused = True
|
||||
sr.confidence = None
|
||||
defense_log["re_gate"] = "refuse(grounding+direct_negation)"
|
||||
elif g_strong and sr.confidence == "low" and max_rerank < 0.25:
|
||||
# Tier 3: grounding strong 1 + (low confidence AND weak evidence) → refuse
|
||||
completeness = "insufficient"
|
||||
sr.answer = None
|
||||
sr.refused = True
|
||||
sr.confidence = None
|
||||
defense_log["re_gate"] = "refuse(grounding+low_conf+weak_ev)"
|
||||
elif g_strong and v_strong_numeric >= 1 and sr.confidence == "low":
|
||||
# Tier 4 (B2 신규): grounding strong + verifier numeric_conflict strong + low conf → refuse.
|
||||
# verifier strong 단독 refuse 금지 원칙 유지 — g_strong 교차 필수.
|
||||
completeness = "insufficient"
|
||||
sr.answer = None
|
||||
sr.refused = True
|
||||
sr.confidence = None
|
||||
defense_log["re_gate"] = "refuse(grounding+verifier_numeric)"
|
||||
elif g_strong or has_direct_negation:
|
||||
# Tier 5 (기존 4): grounding strong 1 또는 verifier direct_negation 단독 → partial
|
||||
completeness = "partial"
|
||||
sr.confidence = "low"
|
||||
defense_log["re_gate"] = "partial(strong_or_negation)"
|
||||
elif v_medium:
|
||||
# Tier 6 (기존 5): verifier medium 누적 → count 기반 confidence 하향
|
||||
medium_count = len(v_medium)
|
||||
if medium_count >= 3:
|
||||
sr.confidence = "low"
|
||||
defense_log["re_gate"] = f"conf_low(medium_x{medium_count})"
|
||||
elif medium_count == 2 and sr.confidence == "high":
|
||||
sr.confidence = "medium"
|
||||
defense_log["re_gate"] = "conf_cap_medium(medium_x2)"
|
||||
else:
|
||||
defense_log["re_gate"] = f"medium_x{medium_count}(no_action)"
|
||||
elif grounding.weak_flags:
|
||||
# Tier 7 (기존 6): weak → confidence 한 단계 하향
|
||||
if sr.confidence == "high":
|
||||
sr.confidence = "medium"
|
||||
defense_log["re_gate"] = "conf_lower(weak)"
|
||||
else:
|
||||
defense_log["re_gate"] = "clean"
|
||||
|
||||
# Confidence cap from refusal gate (classifier 부재 시 conservative)
|
||||
if decision.confidence_cap and sr.confidence:
|
||||
conf_rank = {"low": 0, "medium": 1, "high": 2}
|
||||
if conf_rank.get(sr.confidence, 0) > conf_rank.get(decision.confidence_cap, 2):
|
||||
sr.confidence = decision.confidence_cap
|
||||
|
||||
# Partial 이면 max confidence = medium
|
||||
if completeness == "partial" and sr.confidence == "high":
|
||||
sr.confidence = "medium"
|
||||
|
||||
sr.hallucination_flags.extend(
|
||||
[f"strong:{f}" for f in grounding.strong_flags]
|
||||
+ [f"weak:{f}" for f in grounding.weak_flags]
|
||||
)
|
||||
|
||||
total_ms = (time.perf_counter() - t_total) * 1000
|
||||
|
||||
# 6. 응답 구성
|
||||
citations = _build_citations(evidence, sr.used_citations)
|
||||
no_reason = _map_no_results_reason(pr, evidence, ev_skip, sr)
|
||||
if completeness == "insufficient" and not no_reason:
|
||||
# Tier 0 경로: synthesis self-refuse 는 LLM 이 준 사유가 가장 정확.
|
||||
if sr.refused and sr.refuse_reason:
|
||||
no_reason = sr.refuse_reason
|
||||
else:
|
||||
no_reason = "답변 검증에서 복수 오류 감지"
|
||||
|
||||
logger.info(
|
||||
"ask query=%r results=%d evidence=%d cite=%d synth=%s conf=%s completeness=%s "
|
||||
"refused=%s grounding_strong=%d grounding_weak=%d ev_ms=%.0f synth_ms=%.0f total=%.0f",
|
||||
q[:80], len(pr.results), len(evidence), len(citations),
|
||||
sr.status, sr.confidence or "-", completeness,
|
||||
sr.refused, len(grounding.strong_flags), len(grounding.weak_flags),
|
||||
ev_ms, synth_ms, total_ms,
|
||||
)
|
||||
|
||||
# 7. telemetry — search + ask_events 두 경로 동시
|
||||
background_tasks.add_task(
|
||||
record_search_event, q, user.id, pr.results, "hybrid",
|
||||
pr.confidence_signal, pr.analyzer_confidence,
|
||||
)
|
||||
# input_snapshot (디버깅/재현용)
|
||||
defense_log["input_snapshot"] = {
|
||||
"query": q,
|
||||
"top_chunks_preview": [
|
||||
{"title": (r.title or "")[:50], "snippet": (r.snippet or "")[:100]}
|
||||
for r in pr.results[:3]
|
||||
],
|
||||
"answer_preview": (sr.answer or "")[:200],
|
||||
}
|
||||
background_tasks.add_task(
|
||||
record_ask_event,
|
||||
q, user.id, completeness, sr.status, sr.confidence,
|
||||
sr.refused, classifier_result.verdict,
|
||||
max(all_rerank_scores) if all_rerank_scores else 0.0,
|
||||
sum(sorted(all_rerank_scores, reverse=True)[:3]),
|
||||
sr.hallucination_flags, len(evidence), len(citations),
|
||||
defense_log, int(total_ms),
|
||||
# Phase E.1 측정 필드
|
||||
answer_length=len(sr.answer or ""),
|
||||
covered_aspects=covered_aspects,
|
||||
missing_aspects=missing_aspects,
|
||||
model_name=resolve_primary_model(),
|
||||
prompt_version=ASK_PROMPT_VERSION,
|
||||
# Phase 3.5 calibration
|
||||
source=source,
|
||||
eval_case_id=eval_case_id,
|
||||
)
|
||||
|
||||
debug_obj = None
|
||||
if debug:
|
||||
timing = dict(pr.timing_ms)
|
||||
timing["evidence_ms"] = ev_ms
|
||||
timing["synthesis_ms"] = synth_ms
|
||||
timing["ask_total_ms"] = total_ms
|
||||
debug_obj = AskDebug(
|
||||
timing_ms=timing,
|
||||
search_notes=pr.notes,
|
||||
query_analysis=pr.query_analysis,
|
||||
confidence_signal=pr.confidence_signal,
|
||||
evidence_candidate_count=len(evidence),
|
||||
evidence_kept_count=len(evidence),
|
||||
evidence_skip_reason=ev_skip,
|
||||
synthesis_cache_hit=sr.cache_hit,
|
||||
synthesis_raw_preview=sr.raw_preview,
|
||||
hallucination_flags=sr.hallucination_flags,
|
||||
defense_layers=defense_log,
|
||||
)
|
||||
|
||||
# backend_used: synthesize 가 실제 호출한 backend (backend 인자 그대로 신뢰 OK —
|
||||
# backend_unavailable 은 위 503 분기에서 이미 return 됨).
|
||||
backend_used_val = backend or "gemma-macmini"
|
||||
|
||||
return AskResponse(
|
||||
results=pr.results,
|
||||
ai_answer=sr.answer,
|
||||
citations=citations,
|
||||
synthesis_status=sr.status,
|
||||
synthesis_ms=sr.elapsed_ms,
|
||||
confidence=sr.confidence,
|
||||
refused=sr.refused,
|
||||
no_results_reason=no_reason,
|
||||
query=q,
|
||||
total=len(pr.results),
|
||||
completeness=completeness,
|
||||
covered_aspects=covered_aspects,
|
||||
missing_aspects=missing_aspects,
|
||||
confirmed_items=confirmed_items,
|
||||
backend_requested=backend,
|
||||
backend_used=backend_used_val,
|
||||
debug=debug_obj,
|
||||
)
|
||||
|
||||
|
||||
# ─── PR-DocSrv-Ask-ToolCalling-ReAct-1 ────────────────────────────────────
|
||||
# /api/search/ask/react — Qwen native tool calling 로 ReAct loop.
|
||||
# 본 endpoint 는 qwen-macbook only (endpoint 자체가 implicit opt-in).
|
||||
# MacBook unavailable 시 503 + error_reason=macbook_unavailable. Gemma 자동 fallback X.
|
||||
# G0-2 counter semantics: max_tool_rounds=2, max LLM calls=3, search exec ≤ 2.
|
||||
# G0-3 trace exposure: default response 의 debug_trace=None, debug=True 시만 채움.
|
||||
|
||||
|
||||
class AskReactRequest(BaseModel):
|
||||
query: str
|
||||
debug: bool = False
|
||||
|
||||
|
||||
class AskReactResponse(BaseModel):
|
||||
final_answer: str
|
||||
iterations: int
|
||||
partial: bool
|
||||
sources: list[dict]
|
||||
debug_trace: list[dict] | None = None
|
||||
|
||||
|
||||
@router.post("/ask/react", response_model=AskReactResponse)
|
||||
async def ask_react(
|
||||
payload: AskReactRequest,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
):
|
||||
"""ReAct loop endpoint (qwen-macbook only, no fallback).
|
||||
|
||||
호출자가 명시 opt-in 한 endpoint. MacBook 가 sleep / unreachable / 5xx 시
|
||||
HTTP 503 + body `{error_reason: "macbook_unavailable", backend: "qwen-macbook"}`
|
||||
를 반환한다. Gemma Mac mini 로 자동 fallback 하지 않는다 (정정 4 의 연장).
|
||||
|
||||
request body:
|
||||
- query: str (사용자 원본 질의)
|
||||
- debug: bool (default false; true 시 응답 `debug_trace` 채움)
|
||||
|
||||
response body (성공 200):
|
||||
- final_answer: str (Qwen 종합문, partial 일 수 있음)
|
||||
- iterations: int (실제 진행된 tool round 수)
|
||||
- partial: bool (max_tool_rounds 도달 후 LLM content 비었을 때 true)
|
||||
- sources: list[dict] (검색에서 모인 evidence 메타, id-기준 dedup)
|
||||
- debug_trace: list[dict] | null (debug=true 시 round 별 trace)
|
||||
"""
|
||||
# 지연 import — 순환 의존성 회피 (react_loop 가 api.search.SearchResult 사용 안 함)
|
||||
from services.llm.backends import BackendUnavailable, get_backend
|
||||
from services.search.react_loop import agentic_ask_loop
|
||||
|
||||
backend_inst = get_backend("qwen-macbook")
|
||||
# PR-2 of DS AI routing policy: backend_inst may be RouterBackend (default)
|
||||
# or QwenMacBookBackend (DS_BACKENDS_VIA_ROUTER=false rollback). Both
|
||||
# implement generate_with_tools so the ReAct loop is identical.
|
||||
assert hasattr(backend_inst, "generate_with_tools")
|
||||
|
||||
try:
|
||||
result = await agentic_ask_loop(
|
||||
session,
|
||||
payload.query,
|
||||
backend=backend_inst,
|
||||
debug=payload.debug,
|
||||
)
|
||||
except BackendUnavailable as exc:
|
||||
logger.warning(
|
||||
"ask_react backend unavailable backend=%s reason=%s",
|
||||
exc.backend_name, exc.reason,
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=503,
|
||||
content={
|
||||
"error_reason": "macbook_unavailable",
|
||||
"backend_requested": "qwen-macbook",
|
||||
"backend_used": None,
|
||||
"detail": exc.reason,
|
||||
},
|
||||
)
|
||||
|
||||
return AskReactResponse(
|
||||
final_answer=result.final_answer,
|
||||
iterations=result.iterations,
|
||||
partial=result.partial,
|
||||
sources=result.sources,
|
||||
debug_trace=result.debug_trace,
|
||||
facets=facets_obj,
|
||||
)
|
||||
|
||||
+21
-1
@@ -21,12 +21,14 @@ from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from core.auth import get_current_user
|
||||
from core.config import settings
|
||||
from core.database import get_session
|
||||
from models.study_memo_card import StudyMemoCard, StudyMemoCardEvidence, record_card_view
|
||||
from models.study_memo_card_progress import StudyMemoCardProgress, rate_card
|
||||
from models.study_question import StudyQuestion
|
||||
from models.user import User
|
||||
from services.study.card_normalize import compute_dedup_hash
|
||||
from services.study.publish_enqueue import enqueue_card_progress_publish, enqueue_card_publish
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -248,9 +250,18 @@ async def approve_batch(
|
||||
StudyMemoCard.needs_review,
|
||||
)
|
||||
.values(needs_review=False, flagged_by=None, flagged_at=None)
|
||||
.returning(StudyMemoCard.id)
|
||||
)
|
||||
approved_ids = list(result.scalars().all())
|
||||
# 방금 검수완료된 카드 발행(같은 tx, flag off 면 no-op). S-2.
|
||||
if settings.study_publish_enabled and approved_ids:
|
||||
cards = (
|
||||
await session.execute(select(StudyMemoCard).where(StudyMemoCard.id.in_(approved_ids)))
|
||||
).scalars().all()
|
||||
for c in cards:
|
||||
await enqueue_card_publish(session, c)
|
||||
await session.commit()
|
||||
return {"approved": result.rowcount or 0}
|
||||
return {"approved": len(approved_ids)}
|
||||
|
||||
|
||||
# ─── 복습(SR) 트랙 ───
|
||||
@@ -310,6 +321,9 @@ async def rate(
|
||||
if outcome is None:
|
||||
raise HTTPException(status_code=422, detail=f"invalid outcome: {body.outcome!r}")
|
||||
progress = await rate_card(session, card=card, outcome=outcome, now=datetime.now(timezone.utc))
|
||||
# 카드 SR 상태 발행(같은 tx, flag off=no-op) — ALL row(sentinel/terminal 포함). S-4.
|
||||
if settings.study_publish_enabled:
|
||||
await enqueue_card_progress_publish(session, progress)
|
||||
await session.commit()
|
||||
return RateResult(
|
||||
card_id=card.id, outcome=outcome, review_stage=progress.review_stage, due_at=progress.due_at
|
||||
@@ -392,6 +406,9 @@ async def update_card(
|
||||
card.flagged_by = None
|
||||
card.flagged_at = None
|
||||
|
||||
# 발행 재투영/tombstone(같은 tx) — 검수완료=발행·검수대기복귀=tombstone(상태 기반). S-2.
|
||||
if settings.study_publish_enabled:
|
||||
await enqueue_card_publish(session, card)
|
||||
try:
|
||||
await session.commit()
|
||||
except IntegrityError:
|
||||
@@ -414,4 +431,7 @@ async def delete_card(
|
||||
card = await session.get(StudyMemoCard, card_id)
|
||||
card = _verify_card(card, user)
|
||||
card.deleted_at = datetime.now(timezone.utc)
|
||||
# 발행 tombstone(같은 tx) — 삭제는 feed 1급 이벤트. S-2.
|
||||
if settings.study_publish_enabled:
|
||||
await enqueue_card_publish(session, card)
|
||||
await session.commit()
|
||||
|
||||
+45
-17
@@ -39,6 +39,9 @@ from services.study.explanation_rag import (
|
||||
gather_explanation_context,
|
||||
render_evidence_block,
|
||||
)
|
||||
from services.study.publish_enqueue import enqueue_publish, enqueue_question_publish
|
||||
from services.study.publish_projection import KIND_CARD, KIND_EXPLANATION, KIND_QUESTION
|
||||
from services.study.outcome import derive_outcome
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
@@ -543,6 +546,9 @@ async def create_question_in_topic(
|
||||
)
|
||||
session.add(q)
|
||||
await session.flush()
|
||||
# 발행 outbox 적재(같은 tx, flag off 면 no-op) — 신규 문항 발행. P0-1b.
|
||||
if settings.study_publish_enabled:
|
||||
await enqueue_question_publish(session, q)
|
||||
await session.commit()
|
||||
|
||||
stats = QuestionAttemptStats(attempt_count=0, correct_count=0, wrong_count=0)
|
||||
@@ -905,9 +911,16 @@ async def update_question(
|
||||
# 카드는 '구' ai_explanation 에서 추출됐으므로 정정 후 stale 가능 — 즉시 가시화 플래그.
|
||||
# 최종 stale 정리는 card_extract 워커의 supersede 가 책임(새 버전 추출 시 구버전 retire).
|
||||
if AI_STALE_TRIGGER & fields_set:
|
||||
await flag_cards_for_source(session, source_question_id=q.id, reason="source_changed")
|
||||
flagged_card_ids = await flag_cards_for_source(session, source_question_id=q.id, reason="source_changed")
|
||||
# 발행 자격 잃은(검수대기 복귀) 파생 카드 tombstone(같은 tx). S-2.
|
||||
if settings.study_publish_enabled:
|
||||
for cid in flagged_card_ids:
|
||||
await enqueue_publish(session, kind=KIND_CARD, source_id=cid, payload=None, deleted=True)
|
||||
|
||||
q.updated_at = datetime.now(timezone.utc)
|
||||
# 발행 재투영(같은 tx) — 문항 갱신 반영. 해설은 ready 일 때만 동봉, stale→tombstone 은 P1-3. P0-1b.
|
||||
if settings.study_publish_enabled:
|
||||
await enqueue_question_publish(session, q)
|
||||
await session.commit()
|
||||
|
||||
stats = await _attempt_stats(session, user.id, question_id)
|
||||
@@ -970,7 +983,16 @@ async def soft_delete_question(
|
||||
)
|
||||
# 공부 암기노트: 소스 문제 삭제 시 파생 암기카드를 검토 대기로 마킹(source_deleted).
|
||||
# study_questions 는 soft-delete 만이라 카드 FK CASCADE 는 미발동 — 이 훅이 실 경로.
|
||||
await flag_cards_for_source(session, source_question_id=q.id, reason="source_deleted")
|
||||
flagged_card_ids = await flag_cards_for_source(session, source_question_id=q.id, reason="source_deleted")
|
||||
# 발행 자격 잃은 파생 카드 tombstone(같은 tx). S-2.
|
||||
if settings.study_publish_enabled:
|
||||
for cid in flagged_card_ids:
|
||||
await enqueue_publish(session, kind=KIND_CARD, source_id=cid, payload=None, deleted=True)
|
||||
# 발행 tombstone(같은 tx) — 삭제는 feed 1급 이벤트(raw DELETE 금지·워커 경유). 해설 본문 있으면 그 kind 도. P0-1b.
|
||||
if settings.study_publish_enabled:
|
||||
await enqueue_publish(session, kind=KIND_QUESTION, source_id=q.id, payload=None, deleted=True)
|
||||
if q.ai_explanation:
|
||||
await enqueue_publish(session, kind=KIND_EXPLANATION, source_id=q.id, payload=None, deleted=True)
|
||||
await session.commit()
|
||||
|
||||
|
||||
@@ -992,24 +1014,27 @@ async def submit_attempt(
|
||||
q = await session.get(StudyQuestion, question_id)
|
||||
q = _verify_question_ownership(q, user)
|
||||
|
||||
if body.is_unsure:
|
||||
selected = None
|
||||
is_correct = False
|
||||
outcome = "unsure"
|
||||
elif body.selected_choice is None:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="selected_choice (1~4) 또는 is_unsure=true 가 필요합니다",
|
||||
# 채점 단일 소스 — 뷰어 ingest 와 동일 함수(P2). 선택 없고 unsure 아니면 422.
|
||||
try:
|
||||
selected, is_correct, outcome = derive_outcome(
|
||||
body.selected_choice, body.is_unsure, q.correct_choice
|
||||
)
|
||||
else:
|
||||
selected = body.selected_choice
|
||||
is_correct = selected == q.correct_choice
|
||||
outcome = "correct" if is_correct else "wrong"
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=422, detail=str(e))
|
||||
|
||||
# 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:
|
||||
@@ -1534,8 +1559,8 @@ async def delete_question_image(
|
||||
|
||||
# ─── PR-3: AI 풀이 생성 엔드포인트 ───
|
||||
|
||||
# MLX 호출 timeout (초). MLX gate + 26B 추론 평균 ~10s, 안전 마진.
|
||||
LLM_TIMEOUT_S = 30.0
|
||||
# 2026-06-20: config 단일소스 (구 하드코딩 30s = 빠른 Gemma 기준).
|
||||
LLM_TIMEOUT_S = settings.llm_call_timeout_s
|
||||
# 프롬프트 템플릿 lazy load
|
||||
_PROMPT_PATH = "study_question_explanation.txt"
|
||||
_prompt_cache: str | None = None
|
||||
@@ -1704,6 +1729,9 @@ async def generate_ai_explanation(
|
||||
primary_name = ai_client.ai.primary.model if hasattr(ai_client.ai.primary, "model") else "primary"
|
||||
q.ai_explanation_model = f"mlx:{primary_name}"
|
||||
q.updated_at = q.ai_explanation_generated_at
|
||||
# 발행 재투영(같은 tx) — 실시간 해설 ready → 문항+해설 발행. P0-1b.
|
||||
if settings.study_publish_enabled:
|
||||
await enqueue_question_publish(session, q)
|
||||
await session.commit()
|
||||
|
||||
return AIExplanationResponse(
|
||||
|
||||
+15
-2
@@ -33,6 +33,7 @@ from ai.client import AIClient, strip_thinking
|
||||
from eid.ai import EidAIClient
|
||||
from eid.compose import compose
|
||||
from core.auth import get_current_user
|
||||
from core.config import settings
|
||||
from core.database import get_session
|
||||
from core.library import LIBRARY_PREFIX, normalize_library_path
|
||||
from models.document import Document
|
||||
@@ -46,6 +47,8 @@ from models.eid_study_weakness import EidStudyWeakness
|
||||
from models.eid_review_set_draft import EidReviewSetDraft
|
||||
from models.user import User
|
||||
from services.search.llm_gate import Priority, acquire_mlx_gate
|
||||
from services.study.publish_enqueue import enqueue_publish, enqueue_topic_publish
|
||||
from services.study.publish_projection import KIND_TOPIC
|
||||
from services.study.subject_note_rag import (
|
||||
SubjectNoteContext,
|
||||
gather_subject_note_context,
|
||||
@@ -466,6 +469,9 @@ async def create_study_topic(
|
||||
session.add(topic)
|
||||
try:
|
||||
await session.flush()
|
||||
# 발행 outbox 적재(같은 tx, flag off 면 no-op) — 신규 주제 발행. S-1.
|
||||
if settings.study_publish_enabled:
|
||||
await enqueue_topic_publish(session, topic)
|
||||
await session.commit()
|
||||
except IntegrityError:
|
||||
await session.rollback()
|
||||
@@ -695,6 +701,10 @@ async def update_study_topic(
|
||||
topic.focused_at = datetime.now(timezone.utc) if body.focused else None
|
||||
|
||||
topic.updated_at = datetime.now(timezone.utc)
|
||||
# 발행 재투영(같은 tx) — 주제 메타 갱신 반영. payload(name·exam_round_size) 무변경(focused 등)
|
||||
# 은 워커 (payload_hash, deleted) 디둡이 rev 안 올리고 흡수 = churn 없음. S-1.
|
||||
if settings.study_publish_enabled:
|
||||
await enqueue_topic_publish(session, topic)
|
||||
try:
|
||||
await session.commit()
|
||||
except IntegrityError:
|
||||
@@ -770,6 +780,9 @@ async def delete_study_topic(
|
||||
)
|
||||
|
||||
topic.deleted_at = datetime.now(timezone.utc)
|
||||
# 발행 tombstone(같은 tx) — 삭제는 feed 1급 이벤트(raw DELETE 금지·워커 경유). S-1.
|
||||
if settings.study_publish_enabled:
|
||||
await enqueue_publish(session, kind=KIND_TOPIC, source_id=topic.id, payload=None, deleted=True)
|
||||
await session.commit()
|
||||
|
||||
|
||||
@@ -1015,7 +1028,7 @@ async def detach_session_from_topic(
|
||||
|
||||
# ─── PR-9: 분야 설명 (study_topic_subject_notes) ───
|
||||
|
||||
SUBJECT_NOTE_TIMEOUT_S = 30.0
|
||||
SUBJECT_NOTE_TIMEOUT_S = settings.llm_call_timeout_s
|
||||
_SUBJECT_NOTE_PROMPT_PATH = "study_subject_note.txt"
|
||||
_subject_note_prompt_cache: str | None = None
|
||||
|
||||
@@ -1242,7 +1255,7 @@ async def generate_subject_note(
|
||||
# 워커(study_weakness)가 산출한 최신 eid_study_weakness 스냅샷을 '학습 진단 코치'(study overlay)
|
||||
# 로 번역. 약점/태도 '판정'은 코드 derived(스냅샷) — LLM 은 스냅샷 블록 값만 인용(환각 약점 차단).
|
||||
# compose("study_diagnosis") = persona+rules+study overlay(+{placeholder}) → 표면이 블록 substitute.
|
||||
DIAGNOSIS_TIMEOUT_S = 40.0
|
||||
DIAGNOSIS_TIMEOUT_S = settings.llm_call_timeout_s
|
||||
|
||||
|
||||
class StudyDiagnosisResponse(BaseModel):
|
||||
|
||||
+11
-2
@@ -31,11 +31,11 @@ def hash_password(password: str) -> str:
|
||||
return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
|
||||
|
||||
|
||||
def create_access_token(subject: str, expires_minutes: int | None = None) -> str:
|
||||
def create_access_token(subject: str, expires_minutes: int | None = None, egress: str = "local") -> str:
|
||||
minutes = expires_minutes if expires_minutes is not None else ACCESS_TOKEN_EXPIRE_MINUTES
|
||||
now = datetime.now(timezone.utc)
|
||||
expire = now + timedelta(minutes=minutes)
|
||||
payload = {"sub": subject, "exp": expire, "iat": int(now.timestamp()), "type": "access"}
|
||||
payload = {"sub": subject, "exp": expire, "iat": int(now.timestamp()), "type": "access", "egress": egress}
|
||||
return jwt.encode(payload, settings.jwt_secret, algorithm=ALGORITHM)
|
||||
|
||||
|
||||
@@ -100,6 +100,15 @@ def verify_totp(code: str, secret: str | None = None) -> bool:
|
||||
return totp.verify(code)
|
||||
|
||||
|
||||
async def get_egress_class(
|
||||
credentials: Annotated[HTTPAuthorizationCredentials, Depends(security)],
|
||||
) -> str:
|
||||
"""토큰 egress claim -> 'cloud'|'local' (갭2 cloud-egress allowlist). claim 부재=local
|
||||
(비파괴; 기존 토큰=신뢰/로컬). 쿼리파라미터 아님 -> 호출자가 끌 수 없음(우회 차단)."""
|
||||
payload = decode_token(credentials.credentials)
|
||||
return (payload or {}).get("egress", "local")
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
credentials: Annotated[HTTPAuthorizationCredentials, Depends(security)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
|
||||
@@ -30,6 +30,11 @@ class AIModelConfig(BaseModel):
|
||||
# None = MLX/OpenAI server default. Anthropic branch 는 미적용 (별 plan 범위).
|
||||
temperature: float | None = None
|
||||
top_p: float | None = None
|
||||
# mlx 네이티브 샘플링 — 한국어 장문 코드스위칭(CJK/라틴 누수)·반복루프 억제용.
|
||||
# Qwen3 권장: top_k=20, repetition_penalty 1.05~1.1. None = 서버 기본값(주입 안 함).
|
||||
# OpenAI 호환 분기(mlx)만 적용 — Anthropic 분기는 미적용(별 범위).
|
||||
repetition_penalty: float | None = None
|
||||
top_k: int | None = None
|
||||
|
||||
|
||||
class DeepSummaryBacklogConfig(BaseModel):
|
||||
@@ -98,6 +103,10 @@ class AIConfig(BaseModel):
|
||||
classifier: AIModelConfig | None = None
|
||||
# Phase 3.5b: semantic verifier (optional — 없으면 grounding-only). PR #20 이후 Mac mini 26B MLX endpoint (initial = exaone3.5).
|
||||
verifier: AIModelConfig | None = None
|
||||
# ds-macbook-offload-1: 심층 전용 슬롯 (optional). 맥북 M5 Max Qwen3.6-27B — llm-router :8890
|
||||
# 경유(model=qwen-macbook alias, wake preflight 재사용). 부재 시 deep_summary 는 기존
|
||||
# primary(맥미니 26B) 경로 그대로 = 기능 미활성. 명시 opt-in — silent fallback 없음.
|
||||
deep: AIModelConfig | None = None
|
||||
# Legacy: vision 슬롯 (현재 사용처 0 — Document Server 는 OCR/STT 별도 서비스).
|
||||
# 제거 진행 중이므로 optional 로 관대한 로딩 유지.
|
||||
vision: AIModelConfig | None = None
|
||||
@@ -154,15 +163,47 @@ class Settings(BaseModel):
|
||||
# 업로드 한도 (authoritative policy)
|
||||
upload: UploadConfig = UploadConfig()
|
||||
|
||||
# 생성 LLM 홀드 (2026-06-11): config.yaml pipeline.held_stages 에 든 이름의
|
||||
# 컨슈머/워커는 claim 자체를 하지 않는다 (attempts 미소모, pending 적체 = 의도).
|
||||
# 유효 키 = 큐 stage 명(classify/summarize/deep_summary) + cron/컨슈머 키(digest,
|
||||
# briefing, study_explanation, study_session_analysis, study_memo_card).
|
||||
# 빈 리스트 = 무동작 (기존 동작 그대로).
|
||||
pipeline_held_stages: list[str] = []
|
||||
|
||||
# mlx gate 동시 실행 상한 (2026-06-12, config.yaml pipeline.mlx_gate_concurrency).
|
||||
# 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
|
||||
# 2026-06-20: study/analyze 단일 primary-call 타임아웃 (구 하드코딩 30~60s = 빠른 Gemma 기준,
|
||||
# Qwen 27B 교체 sweep 누락 → 사용자 대면 504 + 워커 영구 stuck). digest 와 동형 단일소스.
|
||||
llm_call_timeout_s: int = 200
|
||||
|
||||
# PR-MacMini-Derived-Worker-1: study explanation owner = Mac mini
|
||||
# GPU 측은 false 로 설정 (.env), explanation 분기 skip guard 트리거.
|
||||
study_explanation_enabled: bool = True
|
||||
# 공부 암기노트 Phase 1: card_extract 폴러/consumer 게이트. owner 분리 시 false 로.
|
||||
study_card_extract_enabled: bool = True
|
||||
# 발행 레이어(docsrv-viewer-publish): publish_outbox 워커 게이트. 저자/4-A enqueue 결선(P0-1b) 후 true.
|
||||
study_publish_enabled: bool = False
|
||||
digest_publish_enabled: bool = False # docsrv-viewer-publish P1-1 (뉴스/다이제스트 발행 feed gate)
|
||||
maintenance_mode: bool = False # P1-4: 점검/실험 중 = 가공현황 배너(표면 != 데이터)
|
||||
maintenance_note: str = ""
|
||||
# 뷰어 write-back ingest(study-to-viewer P2) 게이트. /ingest/study/attempts 활성. 기본 false=inert(503).
|
||||
study_ingest_enabled: bool = False
|
||||
|
||||
# internal endpoint Bearer token (Mac mini derived-worker 호출용)
|
||||
internal_worker_token: str = ""
|
||||
|
||||
# 뷰어↔DS 발행 채널 Bearer token (publish read API P0-2 + ingest P2). Mac mini 토큰과 분리(폭발반경 격리).
|
||||
viewer_sync_token: str = ""
|
||||
|
||||
|
||||
def load_settings() -> Settings:
|
||||
"""config.yaml + 환경변수에서 설정 로딩"""
|
||||
@@ -170,7 +211,13 @@ def load_settings() -> Settings:
|
||||
database_url = os.getenv("DATABASE_URL", "")
|
||||
study_explanation_enabled = os.getenv("STUDY_EXPLANATION_ENABLED", "true").lower() in ("1", "true", "yes")
|
||||
study_card_extract_enabled = os.getenv("STUDY_CARD_EXTRACT_ENABLED", "true").lower() in ("1", "true", "yes")
|
||||
study_publish_enabled = os.getenv("STUDY_PUBLISH_ENABLED", "false").lower() in ("1", "true", "yes")
|
||||
digest_publish_enabled = os.getenv("DIGEST_PUBLISH_ENABLED", "false").lower() in ("1", "true", "yes")
|
||||
maintenance_mode = os.getenv("MAINTENANCE_MODE", "false").lower() in ("1", "true", "yes")
|
||||
maintenance_note = os.getenv("MAINTENANCE_NOTE", "")
|
||||
study_ingest_enabled = os.getenv("STUDY_INGEST_ENABLED", "false").lower() in ("1", "true", "yes")
|
||||
internal_worker_token = os.getenv("INTERNAL_WORKER_TOKEN", "")
|
||||
viewer_sync_token = os.getenv("VIEWER_SYNC_TOKEN", "")
|
||||
jwt_secret = os.getenv("JWT_SECRET", "")
|
||||
totp_secret = os.getenv("TOTP_SECRET", "")
|
||||
eval_runner_token = os.getenv("EVAL_RUNNER_TOKEN", "")
|
||||
@@ -218,6 +265,7 @@ def load_settings() -> Settings:
|
||||
verifier=(
|
||||
AIModelConfig(**models["verifier"]) if "verifier" in models else None
|
||||
),
|
||||
deep=(AIModelConfig(**models["deep"]) if "deep" in models else None),
|
||||
deep_summary_backlog=DeepSummaryBacklogConfig(
|
||||
**ai_raw.get("deep_summary_backlog", {})
|
||||
),
|
||||
@@ -239,6 +287,42 @@ 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
|
||||
llm_call_timeout_s = 200
|
||||
if config_path.exists() and raw and "pipeline" in raw:
|
||||
held_raw = (raw.get("pipeline") or {}).get("held_stages") or []
|
||||
# 스칼라(문자열) 오기입 시 char-split 방지 — 단일 항목 리스트로 수용.
|
||||
if not isinstance(held_raw, (list, tuple)):
|
||||
held_raw = [held_raw]
|
||||
pipeline_held_stages = [str(s) for s in held_raw]
|
||||
try:
|
||||
mlx_gate_concurrency = max(
|
||||
1, int((raw.get("pipeline") or {}).get("mlx_gate_concurrency", 1))
|
||||
)
|
||||
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
|
||||
try:
|
||||
llm_call_timeout_s = max(1, int(_pl.get("llm_call_timeout_s", 200)))
|
||||
except (TypeError, ValueError):
|
||||
llm_call_timeout_s = 200
|
||||
|
||||
taxonomy = raw.get("taxonomy", {}) if config_path.exists() and raw else {}
|
||||
document_types = raw.get("document_types", []) if config_path.exists() and raw else []
|
||||
upload_cfg = (
|
||||
@@ -266,7 +350,19 @@ def load_settings() -> Settings:
|
||||
upload=upload_cfg,
|
||||
study_explanation_enabled=study_explanation_enabled,
|
||||
study_card_extract_enabled=study_card_extract_enabled,
|
||||
study_publish_enabled=study_publish_enabled,
|
||||
digest_publish_enabled=digest_publish_enabled,
|
||||
maintenance_mode=maintenance_mode,
|
||||
maintenance_note=maintenance_note,
|
||||
study_ingest_enabled=study_ingest_enabled,
|
||||
internal_worker_token=internal_worker_token,
|
||||
viewer_sync_token=viewer_sync_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,
|
||||
llm_call_timeout_s=llm_call_timeout_s,
|
||||
)
|
||||
|
||||
|
||||
|
||||
+79
-19
@@ -57,12 +57,12 @@ def _parse_migration_files(migrations_dir: Path) -> list[tuple[int, str, Path]]:
|
||||
|
||||
def _validate_sql_content(name: str, sql: str) -> None:
|
||||
"""migration SQL에 BEGIN/COMMIT이 포함되어 있으면 에러 (외부 트랜잭션 깨짐 방지)"""
|
||||
# 주석(-- ...) 라인 제거 후 검사
|
||||
lines = [
|
||||
line for line in sql.splitlines()
|
||||
if not line.strip().startswith("--")
|
||||
]
|
||||
stripped = "\n".join(lines).upper()
|
||||
# 주석(전체 줄 + 인라인 `-- ...`) 제거 후 검사. ★인라인 주석을 안 지우면 설명 주석의
|
||||
# 'commit/begin' 단어(예 365_scan_jobs 의 `-- commit 시 documents.title 로 전파`)를
|
||||
# 트랜잭션 제어문으로 false-positive 로 잡아 fresh DB/DR 부트스트랩이 깨진다(verification
|
||||
# 실측 2026-06). 줄별로 `--` 이후를 잘라 주석 텍스트를 검사에서 제외.
|
||||
cleaned = [re.sub(r"--.*$", "", line) for line in sql.splitlines()]
|
||||
stripped = "\n".join(cleaned).upper()
|
||||
for keyword in ("BEGIN", "COMMIT", "ROLLBACK"):
|
||||
# 단어 경계로 매칭 (예: BEGIN_SOMETHING은 제외)
|
||||
if re.search(rf"\b{keyword}\b", stripped):
|
||||
@@ -70,6 +70,62 @@ def _validate_sql_content(name: str, sql: str) -> None:
|
||||
f"migration {name}에 {keyword} 포함됨 — "
|
||||
f"migration SQL에는 트랜잭션 제어문을 넣지 마세요"
|
||||
)
|
||||
# schema_migrations 수정 금지 (runner 가 스탬프 관리) — 주석 제외(stripped) 검사.
|
||||
# (구: _run_migrations 의 raw `"schema_migrations" in sql.lower()` 가 주석 미제외라
|
||||
# 365 의 '-- ... schema_migrations 를 건드리지 않음' 주석을 false-positive 로 잡았음.)
|
||||
if "SCHEMA_MIGRATIONS" in stripped:
|
||||
raise RuntimeError(
|
||||
f"Migration {name} must not modify schema_migrations table"
|
||||
)
|
||||
|
||||
|
||||
# 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:
|
||||
@@ -90,10 +146,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 +153,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]
|
||||
|
||||
@@ -113,16 +174,15 @@ async def _run_migrations(conn) -> None:
|
||||
|
||||
for version, name, path in pending:
|
||||
sql = path.read_text(encoding="utf-8")
|
||||
_validate_sql_content(name, sql)
|
||||
if "schema_migrations" in sql.lower():
|
||||
raise ValueError(
|
||||
f"Migration {name} must not modify schema_migrations table"
|
||||
)
|
||||
_validate_sql_content(name, sql) # BEGIN/COMMIT + schema_migrations 검사(주석 제외)
|
||||
logger.info(f"[migration] {name} 실행 중...")
|
||||
# raw driver SQL 사용 — text() 의 :name bind parameter 해석으로
|
||||
# SQL 주석/literal 에 콜론이 들어가면 InvalidRequestError 발생.
|
||||
# exec_driver_sql 은 SQL 을 driver(asyncpg) 에 그대로 전달.
|
||||
await conn.exec_driver_sql(sql)
|
||||
# raw asyncpg simple 프로토콜로 실행 — baseline 적재(_load_baseline_if_fresh)와 동일.
|
||||
# ★exec_driver_sql 은 prepared 프로토콜이라 multi-statement 불허("cannot insert multiple
|
||||
# commands into a prepared statement"). 365_scan_jobs 처럼 테이블+시드+인덱스를 한 파일에
|
||||
# 담은 마이그(컨벤션상 1-statement 권장이나 이미 prod 적재)도 fresh DB/DR replay 되게
|
||||
# simple execute 사용. text() :name 콜론-binding 이슈도 동일하게 회피(raw 전달).
|
||||
raw = await conn.get_raw_connection()
|
||||
await raw.driver_connection.execute(sql)
|
||||
await conn.execute(
|
||||
text("INSERT INTO schema_migrations (version, name) VALUES (:v, :n)"),
|
||||
{"v": version, "n": name},
|
||||
|
||||
+4
-1
@@ -2,6 +2,7 @@
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@@ -13,7 +14,9 @@ def setup_logger(name: str, log_dir: str = "logs") -> logging.Logger:
|
||||
|
||||
if not logger.handlers:
|
||||
# 파일 핸들러
|
||||
fh = logging.FileHandler(f"{log_dir}/{name}.log", encoding="utf-8")
|
||||
fh = RotatingFileHandler(
|
||||
f"{log_dir}/{name}.log", maxBytes=10 * 1024 * 1024, backupCount=3, encoding="utf-8"
|
||||
)
|
||||
fh.setFormatter(logging.Formatter(
|
||||
"%(asctime)s [%(levelname)s] %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S"
|
||||
|
||||
+10
-7
@@ -29,16 +29,19 @@ import httpx
|
||||
from ai.client import AIClient
|
||||
from services.llm.backends import (
|
||||
MAC_MINI_DEFAULT,
|
||||
QWEN_MACBOOK,
|
||||
BackendUnavailable,
|
||||
_router_url, # router URL 단일 출처 재사용 (settings → env LLM_ROUTER_URL → MVP default)
|
||||
)
|
||||
from services.search.llm_gate import Priority, acquire_mlx_gate
|
||||
|
||||
# 이드 채팅 mode → router alias 닫힌 매핑 (D-2). 클라는 mode 만 보냄 — claude-cloud/auto 금지.
|
||||
# 2026-06-11 맥북 백지화: deep 도 mac-mini-default (맥미니 Qwen 27B 단일 호스트).
|
||||
# mode 구분은 유지 — deep = ReAct 자동검색 경로(모델이 아니라 동작이 다름).
|
||||
# 게이트는 alias==MAC_MINI_DEFAULT 조건이라 deep 도 자동으로 mlx gate 적용
|
||||
# (llm_gate "예외 없이 gate 획득 필수" invariant 충족 — 구 무게이트는 맥북 예외였음).
|
||||
_CHAT_ALIAS: dict[str, str] = {
|
||||
"daily": MAC_MINI_DEFAULT, # router tier_b → Mac mini :8801 gemma-4-26b
|
||||
"deep": QWEN_MACBOOK, # router named upstream → M5 Max Qwen3.6-27B (무게이트, D-2)
|
||||
"daily": MAC_MINI_DEFAULT, # router tier_b → Mac mini :8801
|
||||
"deep": MAC_MINI_DEFAULT, # 맥북 폐기로 동일 upstream — ReAct 검색 모드 구분만 유지
|
||||
}
|
||||
|
||||
# read 는 per-chunk 적용이라 MacBook wake(24s)+토큰 생성 간격 커버. connect 는 내부 router 라 짧게.
|
||||
@@ -161,10 +164,10 @@ class EidAIClient(AIClient):
|
||||
_rewrite_sse_line 으로 model 치환(mode 어휘)·usage 제거만 하고 프레이밍은 보존.
|
||||
취소/disconnect 시 AsyncExitStack 이 response·client 정리(upstream 닫힘 보장).
|
||||
|
||||
daily(mac-mini-default)는 Mac mini MLX 단일 inference 영구 룰(llm_gate docstring
|
||||
"예외 없이 gate 획득 필수")에 따라 acquire_mlx_gate(FOREGROUND) 안에서 스트리밍 —
|
||||
RouterBackend 의 requires_gate=True 와 동일한 client-side mutex 효과.
|
||||
deep(qwen-macbook)은 별 endpoint 라 무게이트 (D-2, RouterBackend 동형).
|
||||
daily/deep 모두 mac-mini-default(2026-06-11 맥북 백지화) → Mac mini MLX 단일
|
||||
inference 영구 룰(llm_gate docstring "예외 없이 gate 획득 필수")에 따라
|
||||
acquire_mlx_gate(FOREGROUND) 안에서 스트리밍 — 게이트 조건이 alias 기준이라
|
||||
deep 도 자동 적용 (구 무게이트는 맥북 별 endpoint 시절 예외였음).
|
||||
|
||||
중계 전체(업스트림 진입~종료)는 asyncio.timeout(_STREAM_DEADLINE_S) wall-clock
|
||||
deadline 안 — llm_gate 계약 "timeout 은 gate 안쪽" 준수(gate 대기엔 미적용).
|
||||
|
||||
+66
-8
@@ -9,6 +9,8 @@ from sqlalchemy import func, select, text
|
||||
from api.audio import router as audio_router
|
||||
from api.internal_study import router as internal_study_router
|
||||
from api.internal_worker import router as internal_worker_router
|
||||
from api.published import router as published_router
|
||||
from api.ingest_study import router as ingest_study_router
|
||||
from api.auth import router as auth_router
|
||||
from api.briefing import router as briefing_router
|
||||
from api.config import router as config_router
|
||||
@@ -22,6 +24,7 @@ from api.events import router as events_router
|
||||
from api.library import router as library_router
|
||||
from api.memos import router as memos_router
|
||||
from api.news import router as news_router
|
||||
from api.queue_overview import router as queue_overview_router
|
||||
from api.search import router as search_router
|
||||
from api.setup import router as setup_router
|
||||
from api.study_question_progress import router as study_question_progress_router
|
||||
@@ -50,21 +53,26 @@ 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.law_monitor import run as law_monitor_run
|
||||
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_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
|
||||
from workers.study_card_enqueue import run as study_card_enqueue_run
|
||||
from workers.study_publish_worker import consume_publish_outbox
|
||||
from workers.study_reminder import run as study_reminder_run
|
||||
from workers.study_weakness import run as study_weakness_run
|
||||
from workers.study_question_embed_worker import (
|
||||
@@ -73,10 +81,19 @@ 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()
|
||||
|
||||
# 2026-06-20: JWT_SECRET 빈값 fail-loud — credentials.env 미로드/누락 시 빈 키로 전 토큰
|
||||
# 서명하며 부팅하던 침묵 인증붕괴 차단 (totp_secret 은 per-user 라 미가드).
|
||||
if not settings.jwt_secret:
|
||||
raise RuntimeError(
|
||||
"JWT_SECRET 미설정 — 빈 키 서명 방지. credentials.env / 환경변수 확인."
|
||||
)
|
||||
|
||||
# NAS 마운트 확인 (NFS 미마운트 시 로컬 빈 디렉토리에 쓰는 것 방지)
|
||||
from pathlib import Path
|
||||
nas_check = Path(settings.nas_mount_path) / "PKM"
|
||||
@@ -87,15 +104,29 @@ async def lifespan(app: FastAPI):
|
||||
)
|
||||
|
||||
# APScheduler: 백그라운드 작업
|
||||
scheduler = AsyncIOScheduler(timezone="Asia/Seoul")
|
||||
scheduler = AsyncIOScheduler(
|
||||
timezone="Asia/Seoul",
|
||||
# 2026-06-20 H4: 기본 misfire_grace_time=1s 는 단일 asyncio 루프가 1초만 혼잡해도
|
||||
# 1분 컨슈머 틱을 run time missed 로 침묵 스킵(에러·failed row 0). 45s 완화 + coalesce.
|
||||
job_defaults={"misfire_grace_time": 45, "coalesce": True, "max_instances": 1},
|
||||
)
|
||||
# 상시 실행
|
||||
scheduler.add_job(consume_queue, "interval", minutes=1, id="queue_consumer")
|
||||
# PR-DocSrv-Markdown-Consumer-Split-1: markdown(marker) 전용 consumer.
|
||||
# 대형 PDF split 변환(수십 분)이 메인 consume_queue 를 점유해 전 파이프라인을
|
||||
# stall 시키던 문제 제거. max_instances=1(기본) 으로 동시 marker 변환 2건은 방지.
|
||||
scheduler.add_job(consume_markdown_queue, "interval", minutes=1, id="markdown_consumer")
|
||||
# 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")
|
||||
@@ -112,11 +143,16 @@ async def lifespan(app: FastAPI):
|
||||
# 별 테이블/별 consumer 로 기존 study queue 와 격리. settings.study_card_extract_enabled 게이트.
|
||||
scheduler.add_job(consume_study_memo_card_queue, "interval", minutes=1, id="study_memo_card_consumer")
|
||||
scheduler.add_job(study_card_enqueue_run, "interval", minutes=1, id="study_card_enqueue")
|
||||
# 발행 레이어(docsrv-viewer-publish): publish_outbox drain → published rev 부여.
|
||||
# study_publish_enabled=false(기본) 면 worker 내부 no-op. 단일 라이터(pg_advisory_xact_lock) max_instances=1.
|
||||
scheduler.add_job(consume_publish_outbox, "interval", minutes=1, id="publish_outbox_consumer", max_instances=1)
|
||||
# PR-B 레거시 tier 백필 — 30분 주기로 호출되지만 KST 00:00~06:00 시간대만 실제 enqueue.
|
||||
# safety > law > manual 우선순위로 25건씩. 6720 레거시 → 야간당 ~150건 → 약 45일 소화.
|
||||
scheduler.add_job(tier_backfill_run, "interval", minutes=30, id="tier_backfill")
|
||||
# 일일 스케줄 (KST)
|
||||
scheduler.add_job(law_monitor_run, CronTrigger(hour=7, timezone=KST), id="law_monitor")
|
||||
# statute_collector = 구 law_monitor 대체 (safety-library-1 B-1 PR②) — poll→ingest→
|
||||
# 생애주기 잡(버전 시리즈 승격·supersede·레거시 스윕·repeal) 통째 (R8-B1).
|
||||
scheduler.add_job(statute_run, CronTrigger(hour=7, timezone=KST), id="statute_collector")
|
||||
scheduler.add_job(mailplus_run, CronTrigger(hour=7, timezone=KST), id="mailplus_morning")
|
||||
scheduler.add_job(mailplus_run, CronTrigger(hour=18, timezone=KST), id="mailplus_evening")
|
||||
scheduler.add_job(daily_digest_run, CronTrigger(hour=20, timezone=KST), id="daily_digest")
|
||||
@@ -126,13 +162,19 @@ async def lifespan(app: FastAPI):
|
||||
scheduler.add_job(study_reminder_run, CronTrigger(hour="9,13,19", timezone=KST), id="study_reminder")
|
||||
# 이드 W3-2: 공부중 토픽 약점 derived 스냅샷 (nightly 04:30 KST, LLM 0). study_diagnosis 표면 source.
|
||||
scheduler.add_job(study_weakness_run, CronTrigger(hour=4, minute=30, timezone=KST), id="study_weakness")
|
||||
scheduler.add_job(news_collector_run, "interval", hours=6, id="news_collector")
|
||||
scheduler.add_job(news_collector_run, CronTrigger(hour="0,6,12,18", timezone=KST), id="news_collector")
|
||||
# crawl-24x7 A-2 안전망: fulltext 영구 실패(3회 소진) 문서를 RSS 요약 기준으로
|
||||
# 후속 enqueue (silent skip 누적 방지). 03:40 = dedup_reconcile(03:30) 직후 비충돌 슬롯.
|
||||
scheduler.add_job(fulltext_reconcile_run, CronTrigger(hour=3, minute=40, timezone=KST), id="fulltext_reconcile")
|
||||
# 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 + 워터마크 점진 백필).
|
||||
@@ -141,6 +183,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.
|
||||
@@ -183,11 +231,15 @@ app.include_router(events_router, prefix="/api/events", tags=["events"])
|
||||
app.include_router(dashboard_router, prefix="/api/dashboard", tags=["dashboard"])
|
||||
app.include_router(library_router, prefix="/api/library", tags=["library"])
|
||||
app.include_router(news_router, prefix="/api/news", tags=["news"])
|
||||
# 처리 머신 보드 (plan ds-processing-ui-6an) — GET /api/queue/overview
|
||||
app.include_router(queue_overview_router, prefix="/api/queue", tags=["queue"])
|
||||
app.include_router(digest_router, prefix="/api/digest", tags=["digest"])
|
||||
app.include_router(briefing_router, prefix="/api/briefing", tags=["briefing"])
|
||||
app.include_router(audio_router, prefix="/api/audio", tags=["audio"])
|
||||
app.include_router(internal_study_router, prefix="/internal/study", tags=["internal-study"])
|
||||
app.include_router(internal_worker_router, prefix="/internal/worker", tags=["internal-worker"])
|
||||
app.include_router(published_router, prefix="/published", tags=["published"])
|
||||
app.include_router(ingest_study_router, prefix="/ingest/study", tags=["ingest-study"])
|
||||
app.include_router(video_router, prefix="/api/video", tags=["video"])
|
||||
app.include_router(study_sessions_router, prefix="/api/study-sessions", tags=["study-sessions"])
|
||||
app.include_router(study_topics_router, prefix="/api/study-topics", tags=["study-topics"])
|
||||
@@ -208,21 +260,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에서 확인 가능)
|
||||
|
||||
|
||||
@@ -14,6 +14,11 @@ from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from core.database import Base
|
||||
|
||||
# FK("users.id") 해석에 users 테이블 메타데이터 필요 — fastapi 앱은 어차피 전 모델을
|
||||
# import 하지만, CLI 단독 실행(queue_drain 등)은 본 모듈만 끌어와 INSERT 시
|
||||
# "could not find table 'users'" 로 실패했다 (2026-06-12 drain 로그 실측). 명시 import.
|
||||
from models.user import User # noqa: F401
|
||||
|
||||
|
||||
class AnalyzeEvent(Base):
|
||||
__tablename__ = "analyze_events"
|
||||
|
||||
+26
-4
@@ -1,9 +1,9 @@
|
||||
"""documents 테이블 ORM"""
|
||||
|
||||
from datetime import datetime
|
||||
from datetime import date, datetime
|
||||
|
||||
from pgvector.sqlalchemy import Vector
|
||||
from sqlalchemy import BigInteger, Boolean, DateTime, Enum, ForeignKey, Integer, String, Text
|
||||
from sqlalchemy import BigInteger, Boolean, Date, DateTime, Enum, ForeignKey, Integer, String, Text
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
@@ -41,6 +41,14 @@ class Document(Base):
|
||||
Integer, nullable=False, default=0, server_default="0"
|
||||
)
|
||||
|
||||
# G2 pre-segmentation (migration 362): 번들 PDF → N 자식 분할.
|
||||
# presegment_role: NULL=일반 단일문서 / 'parent'=번들원본(자체 extract/embed 안 함) /
|
||||
# 'child'=논리 하위문서(부모 file_path 공유 + bundle_page_start/end 1-based inclusive 범위).
|
||||
# 부모-자식 관계 자체는 document_lineage(relation_type='segmented_from').
|
||||
bundle_page_start: Mapped[int | None] = mapped_column(Integer)
|
||||
bundle_page_end: Mapped[int | None] = mapped_column(Integer)
|
||||
presegment_role: Mapped[str | None] = mapped_column(Text)
|
||||
|
||||
# 2계층: 텍스트 추출
|
||||
extracted_text: Mapped[str | None] = mapped_column(Text)
|
||||
extracted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
@@ -52,7 +60,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 +88,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 +114,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)
|
||||
@@ -146,6 +158,16 @@ class Document(Base):
|
||||
# /accept-suggestion 승인 시에만 category / user_tags 반영 (자동 전이 금지)
|
||||
ai_suggestion: Mapped[dict | None] = mapped_column(JSONB)
|
||||
|
||||
# === 안전 자료실 분류 축 (plan safety-library-1, migrations 340~345) ===
|
||||
# 자료유형 — law/paper/book/incident/manual/standard/guide (TEXT+CHECK, enum 아님).
|
||||
# 수집기 ingest 시점 deterministic 부여 (classify-skip 경로 다수 — classify_worker 의존 금지).
|
||||
# AI 라우팅(subject_domain) 매칭 키 사용 금지 (axis separation — category 와 동일 불변식).
|
||||
material_type: Mapped[str | None] = mapped_column(Text)
|
||||
# 관할 — KR/US/EU/JP/GB/INT. law 는 CHECK 로 jurisdiction NOT NULL 구조 강제 (migration 344).
|
||||
jurisdiction: Mapped[str | None] = mapped_column(Text)
|
||||
# 유형별 대표 날짜 — 법령=COALESCE(시행일, 공포일) / 논문=발행일 / 재해=발생일
|
||||
published_date: Mapped[date | None] = mapped_column(Date)
|
||||
|
||||
# PR-B B-1: summary_triage (4B, 상시) / summary_deep (26B, 에스컬레이션) 분할 산출
|
||||
ai_tldr: Mapped[str | None] = mapped_column(Text) # ≤60자 TL;DR
|
||||
ai_bullets: Mapped[list | None] = mapped_column(JSONB) # 3~5개 핵심 bullets
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
"""document_lineage 테이블 ORM — 문서 파생 관계 이력 (migration 217).
|
||||
|
||||
G2 pre-segmentation 이 relation_type='segmented_from'(번들 → 자식) 으로 사용 (migration 363).
|
||||
이력 테이블 FK = ON DELETE RESTRICT (부모 hard delete 차단, soft delete 만 허용).
|
||||
"""
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import BigInteger, ForeignKey, Text, func
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from sqlalchemy.types import TIMESTAMP
|
||||
|
||||
from core.database import Base
|
||||
|
||||
|
||||
class DocumentLineage(Base):
|
||||
__tablename__ = "document_lineage"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||
source_document_id: Mapped[int] = mapped_column(
|
||||
BigInteger, ForeignKey("documents.id", ondelete="RESTRICT"), nullable=False
|
||||
)
|
||||
derived_document_id: Mapped[int] = mapped_column(
|
||||
BigInteger, ForeignKey("documents.id", ondelete="RESTRICT"), nullable=False
|
||||
)
|
||||
relation_type: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
# 'metadata' 는 SQLAlchemy 예약속성 → Python 속성명은 meta, DB 컬럼명은 metadata.
|
||||
meta: Mapped[dict] = mapped_column(
|
||||
"metadata", JSONB, nullable=False, default=dict, server_default="{}"
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(TIMESTAMP(timezone=True), server_default=func.now())
|
||||
@@ -0,0 +1,73 @@
|
||||
"""legal_acts / legal_meta 테이블 ORM — 법령 레지스트리(워치리스트 겸) + 버전 위성
|
||||
|
||||
plan: safety-library-1 (migrations 346~347).
|
||||
- legal_acts = 폴링 순회 대상 목록이 곧 테이블 (news_sources 패턴의 법령판).
|
||||
KOSHA GUIDE(비법령)·KGS Code(watch-폴더 단독 트랙)는 비대상.
|
||||
- legal_meta = 법령 문서 1버전(또는 별표·해석례 1건)당 1행, documents 1:0..1 위성.
|
||||
version_status 전이는 statute_collector 의 일일 잡이 유일한 코드 지점
|
||||
(전 버전 pending 적재 → 잡이 승격·supersede·repeal 을 한 트랜잭션 처리).
|
||||
"""
|
||||
|
||||
from datetime import date, datetime
|
||||
|
||||
from sqlalchemy import BigInteger, Boolean, Date, DateTime, ForeignKey, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from core.database import Base
|
||||
|
||||
|
||||
class LegalAct(Base):
|
||||
__tablename__ = "legal_acts"
|
||||
|
||||
# 'kr-law:{법령ID}' / 'us-cfr:29-1910' 형식. KGS 는 시드 비대상 (R3-M5).
|
||||
family_id: Mapped[str] = mapped_column(Text, primary_key=True)
|
||||
# 어댑터 상수 고정값 — 파싱 결과에서 추론 금지 (코어가 적재 직전 assert)
|
||||
jurisdiction: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
# statute(법률) / decree(시행령) / rule(시행규칙·부령) / admin_rule(고시·예규) / code(법정 위임 상세기준)
|
||||
law_level: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
title: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
title_ko: Mapped[str | None] = mapped_column(Text)
|
||||
# 법률 → 시행령 → 시행규칙 계층
|
||||
parent_family_id: Mapped[str | None] = mapped_column(ForeignKey("legal_acts.family_id"))
|
||||
# 법령ID / CFR part / CELEX / e-Gov law_id 등 소스 고유 식별자
|
||||
native_id: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
# 'law.go.kr' / 'ecfr' / 'cellar' / 'egov_v2' / 'leg_gov_uk'
|
||||
source_api: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
# 시드 26개 전부 true — '우선순위'는 정렬일 뿐 watch 제외 아님 (R3-B1)
|
||||
watch: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
poll_cycle: Mapped[str] = mapped_column(Text, nullable=False, default="daily")
|
||||
# 변경이력 폴링 워터마크 — 파싱 검증 통과 후에만 영속
|
||||
watermark: Mapped[str | None] = mapped_column(Text)
|
||||
# 어댑터는 폐지 감지 마킹만, repealed 전이는 일일 잡 (R3-M3)
|
||||
repeal_detected_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=datetime.now
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=datetime.now, onupdate=datetime.now
|
||||
)
|
||||
|
||||
|
||||
class LegalMeta(Base):
|
||||
__tablename__ = "legal_meta"
|
||||
__table_args__ = (
|
||||
# 버전 dedup 구조 강제 — annex 는 version_key='MST|별표N' 합성형 (R3-M4)
|
||||
UniqueConstraint("family_id", "law_doc_kind", "version_key", name="uq_legal_meta_version"),
|
||||
)
|
||||
|
||||
document_id: Mapped[int] = mapped_column(
|
||||
BigInteger, ForeignKey("documents.id", ondelete="CASCADE"), primary_key=True
|
||||
)
|
||||
family_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("legal_acts.family_id"), nullable=False
|
||||
)
|
||||
# primary(본문) / annex(별표·서식) / interpretation(해석례)
|
||||
law_doc_kind: Mapped[str] = mapped_column(Text, nullable=False, default="primary")
|
||||
version_key: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
promulgation_date: Mapped[date | None] = mapped_column(Date)
|
||||
effective_date: Mapped[date | None] = mapped_column(Date)
|
||||
# pending → current → superseded / repealed. 전이는 일일 잡 단일 지점, KST 기준.
|
||||
version_status: Mapped[str] = mapped_column(Text, nullable=False, default="pending")
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=datetime.now
|
||||
)
|
||||
@@ -53,3 +53,12 @@ class NewsSource(Base):
|
||||
name="source_channel"),
|
||||
default="news",
|
||||
)
|
||||
|
||||
# ── 안전 자료실 분류 축 (plan safety-library-1 A-2, migrations 352~355) ──
|
||||
# 자료유형 기본값 — documents.material_type 으로 ingest 시점 전파 (NULL=비대상).
|
||||
# jurisdiction 은 별도 컬럼 없이 country 전파, 단 paper 는 코드에서 NULL 강제.
|
||||
material_type: Mapped[str | None] = mapped_column(Text)
|
||||
# extract_meta.license 주입용 — kogl/ogl/public_domain/proprietary/unknown.
|
||||
# 미확정 = 보수적(unknown + redistribute=false), 근거 확보 시 완화.
|
||||
license_scheme: Mapped[str | None] = mapped_column(Text)
|
||||
license_redistribute: Mapped[bool | None] = mapped_column(Boolean)
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
"""발행 레이어 ORM (docsrv-viewer-publish) — published projection + publish_outbox.
|
||||
|
||||
관계(relationship) 없음 = 독립 테이블, configure_mappers 무영향. 마이그 367~372.
|
||||
published = 뷰어가 read API(P0-2)로 당기는 render-ready projection(kind-discriminated).
|
||||
publish_outbox = 저작/4-A 트랜잭션이 같은 tx에서 INSERT, 발행 워커가 drain 하며 rev 부여.
|
||||
|
||||
불변식(plan study-to-viewer-slice1):
|
||||
pub_id opaque+stable = dedup키 = progress키 / rev = 워커 커밋순 gapless(pg_advisory_lock 단일 라이터)
|
||||
/ (payload_hash, deleted) 디둡 / 삭제 = tombstone(deleted=true) / schema_version = 엔벨로프 버전.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import BigInteger, Boolean, DateTime, SmallInteger, String, Text
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from core.database import Base
|
||||
|
||||
|
||||
class Published(Base):
|
||||
__tablename__ = "published"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||
kind: Mapped[str] = mapped_column(String(40), nullable=False)
|
||||
source_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
|
||||
pub_id: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
payload: Mapped[dict] = mapped_column(JSONB, nullable=False)
|
||||
payload_hash: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
schema_version: Mapped[int] = mapped_column(SmallInteger, nullable=False, default=1)
|
||||
rev: Mapped[int] = mapped_column(BigInteger, nullable=False)
|
||||
deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=datetime.now, nullable=False
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=datetime.now, nullable=False
|
||||
)
|
||||
|
||||
# UNIQUE(kind, pub_id)=mig368, UNIQUE(kind, source_id)=mig369, idx(rev)=mig370.
|
||||
|
||||
|
||||
class PublishOutbox(Base):
|
||||
__tablename__ = "publish_outbox"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||
kind: Mapped[str] = mapped_column(String(40), nullable=False)
|
||||
source_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
|
||||
payload: Mapped[dict] = mapped_column(JSONB, nullable=False)
|
||||
payload_hash: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
schema_version: Mapped[int] = mapped_column(SmallInteger, nullable=False, default=1)
|
||||
deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=datetime.now, nullable=False
|
||||
)
|
||||
processed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
# mig378: 행별 격리 재시도/terminal. attempts=savepoint 실패 누적, failed_at=MAX 초과 terminal
|
||||
# (set 시 워커 select 에서 제외 → head-of-line block 방지).
|
||||
attempts: Mapped[int] = mapped_column(SmallInteger, nullable=False, default=0)
|
||||
failed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
# 미처리 부분 인덱스 idx(id) WHERE processed_at IS NULL = mig372.
|
||||
+30
-2
@@ -2,14 +2,41 @@
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import BigInteger, DateTime, Enum, ForeignKey, SmallInteger, Text, text
|
||||
from sqlalchemy import BigInteger, DateTime, Enum, ForeignKey, SmallInteger, Text, func, or_, text
|
||||
from sqlalchemy.dialects.postgresql import JSONB, insert as pg_insert
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from sqlalchemy.types import TIMESTAMP
|
||||
|
||||
from core.database import Base
|
||||
|
||||
|
||||
class StageDeferred(Exception):
|
||||
"""워커가 '지금은 처리 불가 — 자료 손상 없이 보류' 를 선언하는 신호 (ds-macbook-offload-1).
|
||||
|
||||
맥북(M5 Max) deep 슬롯 경로 전용: 503(upstream_cold/editor_busy/warming) · 연결 실패 ·
|
||||
생성 중 절단(read-timeout, 맥북 sleep) 시 raise. queue_consumer/queue_drain 이 attempts 를
|
||||
소모하지 않고 pending 복귀 + payload.deferred_until 백오프를 기록한다. 결과 쓰기는 호출
|
||||
완주 + 파싱 성공 후에만 일어나므로 어느 시점에 끊겨도 부분 쓰기 0 (sleep-안전 불변식).
|
||||
"""
|
||||
|
||||
def __init__(self, reason: str, retry_after_minutes: int = 30):
|
||||
super().__init__(reason)
|
||||
self.retry_after_minutes = retry_after_minutes
|
||||
|
||||
|
||||
def not_deferred_condition():
|
||||
"""보류 백오프(payload.deferred_until, ISO 문자열) 가 미래인 행을 claim 에서 제외.
|
||||
|
||||
payload 없음 / 키 없음 = 통과. queue_consumer 와 queue_drain 의 claim 이 공유한다.
|
||||
"""
|
||||
deferred = ProcessingQueue.payload["deferred_until"].astext
|
||||
return or_(
|
||||
deferred.is_(None),
|
||||
deferred.cast(TIMESTAMP(timezone=True)) <= func.now(),
|
||||
)
|
||||
|
||||
|
||||
class ProcessingQueue(Base):
|
||||
__tablename__ = "processing_queue"
|
||||
|
||||
@@ -19,9 +46,10 @@ class ProcessingQueue(Base):
|
||||
# 'stt' (audio): migration 150 / 'thumbnail' (video): queue_consumer 가 enqueue.
|
||||
# 'deep_summary' (PR-B B-1): classify_worker 가 에스컬레이션 시 enqueue.
|
||||
# 'fulltext' (crawl-24x7 A-2): migration 321 — 기사 페이지 fetch 후 본문 승격.
|
||||
# 'presegment' (G2): migration 364 — extract 前 번들 PDF → N 자식 분할.
|
||||
# DB enum 변경은 마이그레이션이 처리하므로 create_type=False.
|
||||
Enum(
|
||||
"extract", "classify", "summarize", "embed", "chunk", "preview",
|
||||
"presegment", "extract", "classify", "summarize", "embed", "chunk", "preview",
|
||||
"stt", "thumbnail", "deep_summary", "markdown", "fulltext",
|
||||
name="process_stage",
|
||||
create_type=False,
|
||||
|
||||
@@ -25,6 +25,7 @@ from sqlalchemy import (
|
||||
String,
|
||||
Text,
|
||||
func,
|
||||
select,
|
||||
text,
|
||||
update,
|
||||
)
|
||||
@@ -99,13 +100,25 @@ async def supersede_old_cards(
|
||||
*,
|
||||
source_question_id: int,
|
||||
keep_generated_at: datetime | None,
|
||||
) -> int:
|
||||
) -> list[int]:
|
||||
"""같은 문제의 '다른 버전' 카드를 deleted_at 마킹(retire).
|
||||
|
||||
새 source_generated_at 카드 적재 '전에' 호출 — 살아있는 구버전 카드가 dedup PARTIAL
|
||||
UNIQUE 로 새 추출을 막는 것을 방지(정정-후 stale 잔류 0). 같은 버전은 보존.
|
||||
Returns: retire 된 행 수.
|
||||
Returns: retire 되며 '발행 중이던'(needs_review=False) 카드 id 목록 — 발행 tombstone
|
||||
대상(호출측이 enqueue). 검수 안 됐던(미발행) retire 카드는 tombstone 불요라 제외.
|
||||
"""
|
||||
# 발행 중이던 retire 대상 선캡처(update 전) — 미발행 카드 스푸리어스 tombstone 회피.
|
||||
published_retired = (
|
||||
await session.execute(
|
||||
select(StudyMemoCard.id).where(
|
||||
StudyMemoCard.source_question_id == source_question_id,
|
||||
StudyMemoCard.deleted_at.is_(None),
|
||||
StudyMemoCard.source_generated_at.is_distinct_from(keep_generated_at),
|
||||
StudyMemoCard.needs_review.is_(False),
|
||||
)
|
||||
)
|
||||
).scalars().all()
|
||||
stmt = (
|
||||
update(StudyMemoCard)
|
||||
.where(
|
||||
@@ -115,8 +128,8 @@ async def supersede_old_cards(
|
||||
)
|
||||
.values(deleted_at=func.now())
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
return result.rowcount or 0
|
||||
await session.execute(stmt)
|
||||
return list(published_retired)
|
||||
|
||||
|
||||
async def append_card(
|
||||
@@ -216,13 +229,24 @@ async def flag_cards_for_source(
|
||||
*,
|
||||
source_question_id: int,
|
||||
reason: str,
|
||||
) -> int:
|
||||
) -> list[int]:
|
||||
"""소스 문제 정정/삭제 시 파생 카드를 needs_review=auto 마킹(임시 플래그).
|
||||
|
||||
최종 stale 정리는 워커 supersede 가 책임 — 이건 사용자 가시화용 즉시 플래그.
|
||||
reason: 'source_changed' | 'source_deleted'.
|
||||
Returns: 마킹된 행 수.
|
||||
Returns: 플래그로 '발행 자격을 잃은'(직전 needs_review=False) 카드 id 목록 — 발행
|
||||
tombstone 대상(호출측 enqueue). 이미 검수대기였던(미발행) 카드는 제외.
|
||||
"""
|
||||
# 발행 중이던 카드 선캡처(update 전) — 플래그로 needs_review=True 가 되면 발행 자격 상실.
|
||||
published_ids = (
|
||||
await session.execute(
|
||||
select(StudyMemoCard.id).where(
|
||||
StudyMemoCard.source_question_id == source_question_id,
|
||||
StudyMemoCard.deleted_at.is_(None),
|
||||
StudyMemoCard.needs_review.is_(False),
|
||||
)
|
||||
)
|
||||
).scalars().all()
|
||||
stmt = (
|
||||
update(StudyMemoCard)
|
||||
.where(
|
||||
@@ -231,5 +255,5 @@ async def flag_cards_for_source(
|
||||
)
|
||||
.values(needs_review=True, flagged_by=reason, flagged_at=func.now())
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
return result.rowcount or 0
|
||||
await session.execute(stmt)
|
||||
return list(published_ids)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -50,6 +50,10 @@ class StudyQuizSession(Base):
|
||||
chronic_remaining_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
|
||||
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
# study-to-viewer P2: 뷰어 ingest 멱등/출처. 라이브 세션=finalized_at·client_session_uuid NULL, source='live'.
|
||||
finalized_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) # 멱등 마커(mig 373)
|
||||
client_session_uuid: Mapped[str | None] = mapped_column(String(64)) # 뷰어 세션 UUID(mig 374, uq mig376)
|
||||
source: Mapped[str] = mapped_column(String(20), nullable=False, default="live") # live|viewer(mig 375)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=datetime.now, nullable=False
|
||||
)
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
You are an answerability judge. Given a query and evidence chunks, determine if the evidence can answer the query. Respond ONLY in JSON.
|
||||
|
||||
## CALIBRATION (CRITICAL)
|
||||
- verdict=full: evidence is SUFFICIENT to answer the CORE of the query. Missing minor details does NOT make it insufficient.
|
||||
- verdict=partial: evidence covers SOME major aspects but CLEARLY MISSES others the user explicitly asked about.
|
||||
- verdict=insufficient: evidence has NO relevant information for the query, or is completely off-topic.
|
||||
|
||||
Example: Query="제6장 주요 내용", Evidence covers 제6장 definition+scope → verdict=full (core is covered).
|
||||
Example: Query="제6장 처벌 조항", Evidence covers 제6장 definition but NOT 처벌 → verdict=partial.
|
||||
Example: Query="감귤 출하량", Evidence about 산업안전보건법 → verdict=insufficient.
|
||||
|
||||
## Rules
|
||||
1. Your "verdict" must be based ONLY on whether the CONTENT semantically answers the query. Ignore retrieval scores for this field.
|
||||
2. "covered_aspects": query aspects that evidence covers. Korean labels for Korean queries.
|
||||
3. "missing_aspects": query aspects that evidence does NOT cover. Korean labels.
|
||||
4. Keep aspects concise (2-5 words each), non-overlapping.
|
||||
|
||||
## Output Schema
|
||||
{
|
||||
"verdict": "full" | "partial" | "insufficient",
|
||||
"covered_aspects": ["aspect1"],
|
||||
"missing_aspects": ["aspect2"],
|
||||
"confidence": "high" | "medium" | "low"
|
||||
}
|
||||
|
||||
## Query
|
||||
{query}
|
||||
|
||||
## Evidence chunks:
|
||||
{chunks}
|
||||
|
||||
## Retrieval scores (for reference only, NOT for verdict):
|
||||
[{scores}]
|
||||
@@ -1,5 +1,5 @@
|
||||
[System]
|
||||
너는 한국어 문서 태거 + 짧은 요약기다. 입력 본문을 읽고 TL;DR + 핵심 bullets + tags 만 생성한다. **상세 문단·entities 는 생성하지 않는다** (깊은 요약은 26B, entity 는 P3b 담당).
|
||||
너는 한국어 문서 태거 + 요약기다. 입력 본문을 읽고 짧은 요약(ai_summary 2~3문장) + TL;DR + 핵심 bullets + tags 를 생성한다. **여러 문단의 상세 심층요약·entities 는 생성하지 않는다** (깊은 요약은 26B, entity 는 P3b 담당).
|
||||
|
||||
subject_description: {subject_description}
|
||||
|
||||
@@ -13,6 +13,7 @@ subject_description: {subject_description}
|
||||
- pii 감지 시 "pii" 추가 + confidence 감점.
|
||||
|
||||
요약 규칙:
|
||||
- **ai_summary**: 2~3문장 문단. 문서의 핵심 내용·목적을 서술 (검색·표시용 요약).
|
||||
- **TL;DR**: 1문장, 최대 60자.
|
||||
- **Bullets**: 정확히 5개, 각 30~60자.
|
||||
- 본문에 없는 정보 추가 금지 (hallucination 금지).
|
||||
@@ -20,6 +21,7 @@ subject_description: {subject_description}
|
||||
|
||||
출력 (JSON only):
|
||||
{{
|
||||
"ai_summary": "2~3문장 문단 요약",
|
||||
"tldr": "1문장 최대 60자",
|
||||
"bullets": ["...", "...", "...", "...", "..."],
|
||||
"tags": ["..."],
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
You are a document-boundary detector. Output ONLY JSON {is_bundle, segments:[{start_page,end_page,title}]}.
|
||||
|
||||
You are given a single PDF that may be a "bundle" — several independent logical documents
|
||||
concatenated into one file (for example: multiple laws, multiple reports, or multiple papers
|
||||
scanned together). Your job is to decide whether it is a bundle and, if so, where each logical
|
||||
document starts and ends.
|
||||
|
||||
You receive only a compact sample per page: the page number and the first line / heading of that
|
||||
page (text may be truncated). Use these heading/first-line signals to detect where a new logical
|
||||
document begins (a new title page, a new cover, a clearly new document title, a restart of
|
||||
numbering, etc.). You do NOT receive the full text.
|
||||
|
||||
Output rules:
|
||||
- Respond with STRICT JSON only. No prose, no markdown, no code fence.
|
||||
- Schema:
|
||||
{
|
||||
"is_bundle": true | false,
|
||||
"segments": [
|
||||
{"start_page": <int>, "end_page": <int>, "title": "<string or null>"}
|
||||
]
|
||||
}
|
||||
- Page numbers are 1-based and INCLUSIVE. start_page=1 is the first page; end_page equals the last
|
||||
page of that segment.
|
||||
- Segments MUST fully cover every page with NO gaps and NO overlaps:
|
||||
- the first segment MUST start at page 1,
|
||||
- each next segment MUST start exactly one page after the previous segment's end_page,
|
||||
- the last segment MUST end at the final page (page_count).
|
||||
- Order segments by start_page ascending.
|
||||
- title = a short title for that logical document if you can infer one from its first page,
|
||||
otherwise null.
|
||||
|
||||
If the file is NOT a bundle (it is a single logical document), respond:
|
||||
{"is_bundle": false, "segments": []}
|
||||
|
||||
Be conservative: only report is_bundle=true when the heading signals clearly indicate separate
|
||||
logical documents. When unsure, return is_bundle=false.
|
||||
|
||||
page_count: {page_count}
|
||||
|
||||
Per-page samples (one per line, "p{n}: {first line}"):
|
||||
{page_samples}
|
||||
@@ -1,42 +0,0 @@
|
||||
You are a grounding verifier. Given an answer and its evidence sources, check if the answer contradicts or fabricates information. Respond ONLY in JSON.
|
||||
|
||||
## Contradiction Types (IMPORTANT — severity depends on type)
|
||||
- **direct_negation** (CRITICAL): Answer directly contradicts evidence. Examples: evidence "의무" but answer "권고"; evidence "금지" but answer "허용"; negation reversal ("~해야 한다" vs "~할 필요 없다").
|
||||
- **numeric_conflict**: Answer states a number different from evidence. "50명" in evidence but "100명" in answer. Only flag if the same concept is referenced. severity=critical when the number is the CORE answered quantity (amount/count/rate/date/duration that the query asked for); severity=minor when the number is peripheral (e.g., example/footnote).
|
||||
- **intent_core_mismatch**: Answer addresses a fundamentally different topic than the query asked about.
|
||||
- **nuance**: Answer overgeneralizes or adds qualifiers not in evidence (e.g., "모든" when evidence says "일부").
|
||||
- **unsupported_claim**: Answer makes a factual claim with no basis in any evidence.
|
||||
|
||||
## Rules
|
||||
1. Compare each claim in the answer against the cited evidence. A claim with [n] citation should be checked against evidence [n].
|
||||
2. NOT a contradiction: Paraphrasing, summarizing, or restating the same fact in different words. Korean formal/informal style (합니다/한다) differences.
|
||||
3. Numbers must match exactly after normalization (1,000 = 1000). Range values (e.g., "100~200명") satisfy any answer within range.
|
||||
4. Legal/regulatory terms must preserve original meaning (의무 ≠ 권고, 금지 ≠ 제한, 허용 ≠ 금지).
|
||||
5. Maximum 5 contradictions (most severe first: direct_negation > numeric_conflict > intent_core_mismatch > nuance > unsupported_claim).
|
||||
|
||||
## Output Schema
|
||||
{
|
||||
"contradictions": [
|
||||
{
|
||||
"type": "direct_negation" | "numeric_conflict" | "intent_core_mismatch" | "nuance" | "unsupported_claim",
|
||||
"severity": "critical" | "minor",
|
||||
"claim": "answer 내 해당 구절 (50자 이내)",
|
||||
"evidence_ref": "대응 근거 내용 (50자 이내, [n] 포함)",
|
||||
"explanation": "모순 이유 (한국어, 30자 이내)"
|
||||
}
|
||||
],
|
||||
"verdict": "clean" | "minor_issues" | "major_issues"
|
||||
}
|
||||
|
||||
severity mapping:
|
||||
- direct_negation → "critical"
|
||||
- numeric_conflict → "critical" if the number is the CORE answered quantity, else "minor"
|
||||
- All other types → "minor"
|
||||
|
||||
If no contradictions: {"contradictions": [], "verdict": "clean"}
|
||||
|
||||
## Answer
|
||||
{answer}
|
||||
|
||||
## Evidence
|
||||
{numbered_evidence}
|
||||
@@ -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:
|
||||
|
||||
@@ -15,11 +15,12 @@ from sqlalchemy import text
|
||||
|
||||
from core.database import async_session
|
||||
from core.utils import setup_logger
|
||||
from services.search.license_filter import restricted_exclude_sql
|
||||
|
||||
logger = setup_logger("briefing_loader")
|
||||
|
||||
|
||||
_NEWS_WINDOW_SQL = text("""
|
||||
_NEWS_WINDOW_SQL = text(f"""
|
||||
SELECT
|
||||
d.id,
|
||||
d.title,
|
||||
@@ -41,6 +42,9 @@ _NEWS_WINDOW_SQL = text("""
|
||||
AND d.created_at < :window_end
|
||||
AND d.embedding IS NOT NULL
|
||||
AND d.ai_summary IS NOT NULL
|
||||
AND length(d.ai_summary) > 0
|
||||
-- 안전 자료실 B-4: licensed_restricted 발행 차단 (digest 와 동일 공유 술어, 경로 일관성)
|
||||
AND {restricted_exclude_sql("d")}
|
||||
""")
|
||||
|
||||
|
||||
@@ -49,7 +53,7 @@ _SOURCE_COUNTRY_SQL = text("""
|
||||
""")
|
||||
|
||||
|
||||
_HISTORICAL_CANDIDATES_SQL = text("""
|
||||
_HISTORICAL_CANDIDATES_SQL = text(f"""
|
||||
SELECT
|
||||
d.id,
|
||||
d.title,
|
||||
@@ -63,6 +67,9 @@ _HISTORICAL_CANDIDATES_SQL = text("""
|
||||
AND d.created_at < :hist_end
|
||||
AND d.embedding IS NOT NULL
|
||||
AND d.ai_summary IS NOT NULL
|
||||
AND length(d.ai_summary) > 0
|
||||
-- 안전 자료실 B-4: licensed_restricted 발행 차단 (공유 술어)
|
||||
AND {restricted_exclude_sql("d")}
|
||||
""")
|
||||
|
||||
|
||||
|
||||
@@ -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"]):
|
||||
|
||||
@@ -15,11 +15,12 @@ from sqlalchemy import text
|
||||
|
||||
from core.database import async_session
|
||||
from core.utils import setup_logger
|
||||
from services.search.license_filter import restricted_exclude_sql
|
||||
|
||||
logger = setup_logger("digest_loader")
|
||||
|
||||
|
||||
_NEWS_WINDOW_SQL = text("""
|
||||
_NEWS_WINDOW_SQL = text(f"""
|
||||
SELECT
|
||||
d.id,
|
||||
d.title,
|
||||
@@ -41,6 +42,10 @@ _NEWS_WINDOW_SQL = text("""
|
||||
AND d.created_at < :window_end
|
||||
AND d.embedding IS NOT NULL
|
||||
AND d.ai_summary IS NOT NULL
|
||||
AND length(d.ai_summary) > 0
|
||||
-- 안전 자료실 B-4: licensed_restricted 발행 차단 (모든 경로 공유 술어 = license_filter).
|
||||
-- news 채널엔 현재 restricted 부재 = 방어적 게이트(미래 유료 news 소스 대비, 경로 누락 방지).
|
||||
AND {restricted_exclude_sql("d")}
|
||||
""")
|
||||
|
||||
|
||||
|
||||
@@ -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,13 +26,37 @@ _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).
|
||||
_FENCE = re.compile(r'^\s{0,3}(```|~~~)')
|
||||
|
||||
|
||||
# ASME 절 식별자 (A-1): UG-79 · PG-27.4.1 · UW-11 · UCS-56 · A-69 · PFT-14
|
||||
# (대문자 1~4 + 하이픈 + 숫자[.숫자]*). _detect_heading 의 ATX 분기에서 node_type='clause' 판정에 사용.
|
||||
# 한국 법령(제N조)은 _KO_JO 가 별도 처리 — 본 패턴/정제와 무관(무회귀).
|
||||
_ASME_CLAUSE = re.compile(r'^[A-Z]{1,4}-\d+(?:\.\d+)*\b')
|
||||
|
||||
|
||||
def _clean_label(title: str) -> str:
|
||||
r"""C-4: marker 가 박는 LaTeX/markdown/페이지번호 아티팩트 제거 — 절번호 패턴 매칭의 전처리 겸 표시 라벨 정제.
|
||||
실데이터 예: '$\textbf{PG-20.1 …} \hspace{0.2cm} \textbf{(25)}$' → 'PG-20.1 …' / '(25) **A-69**' → 'A-69'.
|
||||
노이즈 없는 제목(한국 법령·일반 ATX 등)엔 inert(무회귀)."""
|
||||
t = re.sub(r'\\textbf|\\textit|\\mathbf|\\hspace\{[^}]*\}|[${}]|\*\*', '', title)
|
||||
t = re.sub(r'^\s*\(\d+\)\s*', '', t) # 선두 페이지번호 '(25) '
|
||||
return re.sub(r'\s{2,}', ' ', t).strip()
|
||||
|
||||
|
||||
def _utf16_units(s: str) -> int:
|
||||
"""JS 문자열 .length(= UTF-16 code unit 수) 와 동일. astral(BMP 밖)=surrogate pair=2 units.
|
||||
FE 의 `raw.length` / `out.slice(off)` 가 UTF-16 code unit 단위라 char_start 도 같은 단위여야 함.
|
||||
@@ -63,7 +87,9 @@ def _detect_heading(line: str) -> tuple[int, str, str] | None:
|
||||
"""(level, title, node_type) 또는 None. level 은 상대 깊이."""
|
||||
m = _ATX.match(line)
|
||||
if m:
|
||||
return (len(m.group(1)), m.group("title").strip(), None) # node_type 은 후처리에서
|
||||
title = _clean_label(m.group("title").strip()) # C-4: LaTeX/md/페이지번호 정제(전처리)
|
||||
nt = "clause" if _ASME_CLAUSE.match(title) else None # A-1: ASME 절 식별자(UG-79 등) → clause
|
||||
return (len(m.group(1)), title, nt)
|
||||
for pat, lvl, nt in ((_KO_JANG, 1, "chapter"), (_KO_JEOL, 2, "section"),
|
||||
(_KO_JO, 3, "clause"), (_ENG, 1, "chapter")):
|
||||
m = pat.match(line)
|
||||
|
||||
@@ -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()
|
||||
@@ -0,0 +1,573 @@
|
||||
"""처리 머신 보드 + ETA 집계 (plan ds-processing-ui-6an, 안2+안5/6).
|
||||
|
||||
GET /api/queue/overview 의 집계 로직. 모든 수치는 기존 processing_queue /
|
||||
documents 컬럼에서 라이브 계산 — 신규 테이블/마이그레이션 0 (HARD 제약).
|
||||
|
||||
구조: SQL 수집부(build_overview 내부 5쿼리)와 판정부(순수 함수)를 분리.
|
||||
판정부(rows_to_* / build_machines / build_summarize_eta / build_trend /
|
||||
build_totals / compute_eta_minutes)는 DB 없이 단위테스트 가능.
|
||||
|
||||
귀속 규칙 (단일 진실):
|
||||
- stage→machine 정적 맵: gpu = extract/embed/chunk/markdown/preview/thumbnail/
|
||||
fulltext/stt · macmini = classify/summarize · macbook = deep_summary
|
||||
(단, settings.ai.deep 부재 시 deep_summary 도 macmini 귀속).
|
||||
- summarize 는 풀(pool): pending/processing/failed 는 macmini 귀속이되, 완료
|
||||
실적(done_*)은 documents.ai_model_version 조인으로 분리 — 'qwen-macbook'
|
||||
이면 macbook 실적, 아니면 macmini 실적.
|
||||
- deferred_pending(payload.deferred_until 미래)은 macbook 카드 귀속
|
||||
(보류 = 맥북 불가 신호).
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from posixpath import basename
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from sqlalchemy import bindparam, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from core.config import settings
|
||||
|
||||
KST = ZoneInfo("Asia/Seoul")
|
||||
|
||||
# 내부 판별용 alias — 응답에 raw 모델명 노출 금지, 머신 label 만 노출.
|
||||
_MACBOOK_MODEL_ALIAS = "qwen-macbook"
|
||||
|
||||
# stage→machine 정적 맵 재료 (선언 순서 = 카드 stages 표시 순서)
|
||||
_GPU_STAGES = (
|
||||
"extract", "embed", "chunk", "markdown",
|
||||
"preview", "thumbnail", "fulltext", "stt",
|
||||
)
|
||||
_MACMINI_STAGES = ("classify", "summarize")
|
||||
_MACBOOK_STAGES = ("deep_summary",)
|
||||
_STAGE_ORDER = _GPU_STAGES + _MACMINI_STAGES + _MACBOOK_STAGES
|
||||
|
||||
_MACHINE_KEYS = ("gpu", "macmini", "macbook")
|
||||
_MACHINE_LABELS = {
|
||||
"gpu": "GPU 서버",
|
||||
"macmini": "맥미니",
|
||||
"macbook": "맥북 M5 Max",
|
||||
}
|
||||
|
||||
# 머신 카드당 current 표시 상한
|
||||
_CURRENT_LIMIT = 2
|
||||
|
||||
|
||||
def stage_machine_map(deep_enabled: bool) -> dict[str, str]:
|
||||
"""stage → machine key 맵. deep 슬롯 부재 시 deep_summary 는 macmini 귀속."""
|
||||
mapping: dict[str, str] = {}
|
||||
for s in _GPU_STAGES:
|
||||
mapping[s] = "gpu"
|
||||
for s in _MACMINI_STAGES:
|
||||
mapping[s] = "macmini"
|
||||
for s in _MACBOOK_STAGES:
|
||||
mapping[s] = "macbook" if deep_enabled else "macmini"
|
||||
return mapping
|
||||
|
||||
|
||||
def _zero_stage() -> dict:
|
||||
return {
|
||||
"pending": 0, "processing": 0, "failed": 0,
|
||||
"done_1h": 0, "done_today": 0, "done_15m": 0,
|
||||
"deferred_pending": 0, "created_1h": 0, "oldest_pending_at": None,
|
||||
}
|
||||
|
||||
|
||||
def rows_to_stage_stats(rows) -> dict[str, dict]:
|
||||
"""stage×status 집계 쿼리 행 → {stage: {pending, ..., created_1h}} 변환."""
|
||||
stats: dict[str, dict] = {}
|
||||
for row in rows:
|
||||
stats[row[0]] = {
|
||||
"pending": int(row[1] or 0),
|
||||
"processing": int(row[2] or 0),
|
||||
"failed": int(row[3] or 0),
|
||||
"done_1h": int(row[4] or 0),
|
||||
"done_today": int(row[5] or 0),
|
||||
"done_15m": int(row[6] or 0),
|
||||
"deferred_pending": int(row[7] or 0),
|
||||
"created_1h": int(row[8] or 0),
|
||||
"oldest_pending_at": row[9] if len(row) > 9 else None,
|
||||
}
|
||||
return stats
|
||||
|
||||
|
||||
def rows_to_summarize_split(rows) -> dict[str, dict]:
|
||||
"""summarize 완료 실적 분리 쿼리 행 → {"macbook"|"macmini": {done_*}}.
|
||||
|
||||
is_macbook = documents.ai_model_version 이 'qwen-macbook' 인지 (내부 판별 전용).
|
||||
"""
|
||||
split = {
|
||||
"macbook": {"done_1h": 0, "done_today": 0, "done_15m": 0},
|
||||
"macmini": {"done_1h": 0, "done_today": 0, "done_15m": 0},
|
||||
}
|
||||
for row in rows:
|
||||
key = "macbook" if row[0] else "macmini"
|
||||
split[key]["done_1h"] += int(row[1] or 0)
|
||||
split[key]["done_today"] += int(row[2] or 0)
|
||||
split[key]["done_15m"] += int(row[3] or 0)
|
||||
return split
|
||||
|
||||
|
||||
def display_title(row: dict) -> str:
|
||||
"""표시용 제목 — title > original_filename > file_path basename > 문서 id."""
|
||||
if row.get("title"):
|
||||
return row["title"]
|
||||
if row.get("original_filename"):
|
||||
return row["original_filename"]
|
||||
if row.get("file_path"):
|
||||
return basename(row["file_path"].rstrip("/"))
|
||||
return f"문서 #{row['document_id']}"
|
||||
|
||||
|
||||
def build_machines(
|
||||
stage_stats: dict[str, dict],
|
||||
summarize_split: dict[str, dict],
|
||||
current_rows: list[dict],
|
||||
*,
|
||||
deep_enabled: bool,
|
||||
) -> list[dict]:
|
||||
"""머신 카드 3장 (gpu / macmini / macbook) 구성 — 귀속 규칙의 판정부."""
|
||||
smap = stage_machine_map(deep_enabled)
|
||||
|
||||
def g(stage: str, field: str) -> int:
|
||||
return stage_stats.get(stage, {}).get(field, 0)
|
||||
|
||||
# current 귀속: processing 행을 머신별 최대 2건 (summarize processing → macmini)
|
||||
current_by_machine: dict[str, list[dict]] = {k: [] for k in _MACHINE_KEYS}
|
||||
for row in current_rows:
|
||||
machine = smap.get(row["stage"])
|
||||
if machine and len(current_by_machine[machine]) < _CURRENT_LIMIT:
|
||||
current_by_machine[machine].append({
|
||||
"document_id": row["document_id"],
|
||||
"title": display_title(row),
|
||||
"stage": row["stage"],
|
||||
})
|
||||
|
||||
machines = []
|
||||
for key in _MACHINE_KEYS:
|
||||
stages = [s for s in _STAGE_ORDER if smap[s] == key]
|
||||
|
||||
pending = sum(g(s, "pending") for s in stages)
|
||||
processing = sum(g(s, "processing") for s in stages)
|
||||
failed = sum(g(s, "failed") for s in stages)
|
||||
|
||||
# 완료 실적: summarize 는 풀이라 stage 합산에서 제외하고 split 로 귀속
|
||||
done_1h = sum(g(s, "done_1h") for s in stages if s != "summarize")
|
||||
done_today = sum(g(s, "done_today") for s in stages if s != "summarize")
|
||||
done_15m = sum(g(s, "done_15m") for s in stages if s != "summarize")
|
||||
if key in summarize_split:
|
||||
done_1h += summarize_split[key]["done_1h"]
|
||||
done_today += summarize_split[key]["done_today"]
|
||||
done_15m += summarize_split[key]["done_15m"]
|
||||
|
||||
# 보류 백오프 = 맥북 불가 신호 → macbook 카드 귀속 (deep 슬롯 유무 무관)
|
||||
deferred_pending = (
|
||||
g("summarize", "deferred_pending") + g("deep_summary", "deferred_pending")
|
||||
if key == "macbook" else 0
|
||||
)
|
||||
|
||||
# state 판정 — 우선순위: 가동 > 보류 > 대기 (사용자 피드백 2026-06-11).
|
||||
# 일하고 있으면(처리 중 또는 최근 15분 완료) 백오프 잔여가 있어도 "가동" —
|
||||
# 보류 건수는 카드의 deferred_pending 라인이 따로 보여준다. "보류" 칩은
|
||||
# 실제로 일이 멈춰 있고 백오프만 쌓인 상태(sleep/불가 지속)에서만.
|
||||
if processing > 0 or done_15m > 0:
|
||||
state = "active"
|
||||
elif key == "macbook" and deferred_pending > 0:
|
||||
state = "deferred"
|
||||
else:
|
||||
state = "idle"
|
||||
|
||||
machines.append({
|
||||
"key": key,
|
||||
"label": _MACHINE_LABELS[key],
|
||||
"state": state,
|
||||
"stages": stages,
|
||||
"pending": pending,
|
||||
"processing": processing,
|
||||
"failed": failed,
|
||||
"done_1h": done_1h,
|
||||
"done_today": done_today,
|
||||
"deferred_pending": deferred_pending,
|
||||
"current": current_by_machine[key],
|
||||
})
|
||||
return machines
|
||||
|
||||
|
||||
def compute_eta_minutes(pending: int, done_1h: int, inflow_1h: int) -> int | None:
|
||||
"""ETA(분) = 순소화율 기반. done > inflow 일 때만 산출, 아니면 None (소화 불가)."""
|
||||
if done_1h > inflow_1h:
|
||||
return round(pending / (done_1h - inflow_1h) * 60)
|
||||
return None
|
||||
|
||||
|
||||
def build_summarize_eta(stage_stats: dict[str, dict]) -> dict:
|
||||
"""summarize 풀 ETA — pending 은 보류(deferred) 포함 총수."""
|
||||
s = stage_stats.get("summarize", _zero_stage())
|
||||
pending = s["pending"]
|
||||
done_rate = s["done_1h"]
|
||||
inflow_rate = s["created_1h"]
|
||||
return {
|
||||
"pending": pending,
|
||||
"done_rate_1h": done_rate,
|
||||
"inflow_rate_1h": inflow_rate,
|
||||
"eta_minutes": compute_eta_minutes(pending, done_rate, inflow_rate),
|
||||
}
|
||||
|
||||
|
||||
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],
|
||||
now_kst: datetime,
|
||||
) -> list[dict]:
|
||||
"""summarize 24h 추이 — KST 시간 버킷 24개 (오래된 것부터, 빈 버킷 0).
|
||||
|
||||
버킷 key = "YYYY-MM-DD HH:00" (KST). SQL to_char 출력과 동일 포맷.
|
||||
"""
|
||||
base = now_kst.replace(minute=0, second=0, microsecond=0)
|
||||
trend = []
|
||||
for i in range(23, -1, -1):
|
||||
bucket = base - timedelta(hours=i)
|
||||
key = bucket.strftime("%Y-%m-%d %H:00")
|
||||
trend.append({
|
||||
"hour": bucket.strftime("%H:00"),
|
||||
"inflow": inflow_buckets.get(key, 0),
|
||||
"done": done_buckets.get(key, 0),
|
||||
})
|
||||
return trend
|
||||
|
||||
|
||||
def build_stages(stage_stats: dict[str, dict], now=None) -> list[dict]:
|
||||
"""단계별 현황 행 — '단계 상세' 패널용 (2026-06-11 사용자 피드백: 완료가 보여야 한다).
|
||||
|
||||
파이프라인 순서 유지, 미지 stage 는 뒤에. 숨김/강조 판단은 FE 몫 — 여기선 사실만.
|
||||
oldest_pending_age_sec = 가장 오래된 pending 의 경과 초 (pending 없으면 None).
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
now = now or datetime.now(timezone.utc)
|
||||
extra = [s for s in stage_stats if s not in _STAGE_ORDER]
|
||||
rows = []
|
||||
for stage in [*_STAGE_ORDER, *extra]:
|
||||
st = stage_stats.get(stage) or _zero_stage()
|
||||
oldest = st.get("oldest_pending_at")
|
||||
age = None
|
||||
if oldest is not None:
|
||||
if oldest.tzinfo is None:
|
||||
oldest = oldest.replace(tzinfo=timezone.utc)
|
||||
age = max(0, int((now - oldest).total_seconds()))
|
||||
rows.append({
|
||||
"stage": stage,
|
||||
"pending": st["pending"],
|
||||
"processing": st["processing"],
|
||||
"failed": st["failed"],
|
||||
"done_1h": st["done_1h"],
|
||||
"created_1h": st["created_1h"],
|
||||
"done_today": st["done_today"],
|
||||
"oldest_pending_age_sec": age,
|
||||
})
|
||||
return rows
|
||||
|
||||
|
||||
def build_totals(stage_stats: dict[str, dict]) -> dict:
|
||||
"""전 stage 합계."""
|
||||
return {
|
||||
"pending": sum(s["pending"] for s in stage_stats.values()),
|
||||
"processing": sum(s["processing"] for s in stage_stats.values()),
|
||||
"failed": sum(s["failed"] for s in stage_stats.values()),
|
||||
}
|
||||
|
||||
|
||||
def compose_overview(
|
||||
stage_stats: dict[str, dict],
|
||||
summarize_split: dict[str, dict],
|
||||
inflow_buckets: dict[str, int],
|
||||
done_buckets: dict[str, int],
|
||||
current_rows: list[dict],
|
||||
*,
|
||||
deep_enabled: bool,
|
||||
now_kst: datetime,
|
||||
) -> dict:
|
||||
"""수집된 통계 → 응답 dict (계약 shape). 순수 함수 — DB 불요."""
|
||||
return {
|
||||
"machines": build_machines(
|
||||
stage_stats, summarize_split, current_rows, deep_enabled=deep_enabled
|
||||
),
|
||||
"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),
|
||||
}
|
||||
|
||||
|
||||
# ─── SQL 수집부 (총 5쿼리) ────────────────────────────────────────────────────
|
||||
|
||||
# 1) stage×status 집계 + 시간창 완료/유입 + 보류 (1방)
|
||||
_STAGE_STATS_SQL = """
|
||||
SELECT
|
||||
stage,
|
||||
COUNT(*) FILTER (WHERE status = 'pending') AS pending,
|
||||
COUNT(*) FILTER (WHERE status = 'processing') AS processing,
|
||||
COUNT(*) FILTER (WHERE status = 'failed') AS failed,
|
||||
COUNT(*) FILTER (WHERE status = 'completed'
|
||||
AND completed_at > NOW() - INTERVAL '1 hour') AS done_1h,
|
||||
COUNT(*) FILTER (WHERE status = 'completed'
|
||||
AND completed_at > :kst_midnight) AS done_today,
|
||||
COUNT(*) FILTER (WHERE status = 'completed'
|
||||
AND completed_at > NOW() - INTERVAL '15 minutes') AS done_15m,
|
||||
COUNT(*) FILTER (WHERE status = 'pending'
|
||||
AND payload ->> 'deferred_until' IS NOT NULL
|
||||
AND (payload ->> 'deferred_until')::timestamptz > NOW())
|
||||
AS deferred_pending,
|
||||
COUNT(*) FILTER (WHERE created_at > NOW() - INTERVAL '1 hour') AS created_1h,
|
||||
MIN(created_at) FILTER (WHERE status = 'pending') AS oldest_pending_at
|
||||
FROM processing_queue
|
||||
GROUP BY stage
|
||||
"""
|
||||
|
||||
# 2) summarize 풀 완료 실적 분리 (documents.ai_model_version 조인, 1방)
|
||||
# 스캔 하한 = 오늘 0시(KST)와 1h 전 중 더 이른 시각 (자정 직후 1h 창 보전).
|
||||
_SUMMARIZE_SPLIT_SQL = """
|
||||
SELECT
|
||||
COALESCE(d.ai_model_version = :macbook_alias, false) AS is_macbook,
|
||||
COUNT(*) FILTER (WHERE q.completed_at > NOW() - INTERVAL '1 hour') AS done_1h,
|
||||
COUNT(*) FILTER (WHERE q.completed_at > :kst_midnight) AS done_today,
|
||||
COUNT(*) FILTER (WHERE q.completed_at > NOW() - INTERVAL '15 minutes') AS done_15m
|
||||
FROM processing_queue q
|
||||
JOIN documents d ON d.id = q.document_id
|
||||
WHERE q.stage = 'summarize'
|
||||
AND q.status = 'completed'
|
||||
AND q.completed_at > LEAST(:kst_midnight, NOW() - INTERVAL '1 hour')
|
||||
GROUP BY 1
|
||||
"""
|
||||
|
||||
# 3/4) summarize 24h 추이 — KST 시간 버킷 (inflow/done 각 1방)
|
||||
_TREND_INFLOW_SQL = """
|
||||
SELECT to_char(date_trunc('hour', created_at AT TIME ZONE 'Asia/Seoul'),
|
||||
'YYYY-MM-DD HH24:00') AS bucket,
|
||||
COUNT(*) AS n
|
||||
FROM processing_queue
|
||||
WHERE stage = 'summarize'
|
||||
AND created_at > NOW() - INTERVAL '24 hours'
|
||||
GROUP BY 1
|
||||
"""
|
||||
|
||||
_TREND_DONE_SQL = """
|
||||
SELECT to_char(date_trunc('hour', completed_at AT TIME ZONE 'Asia/Seoul'),
|
||||
'YYYY-MM-DD HH24:00') AS bucket,
|
||||
COUNT(*) AS n
|
||||
FROM processing_queue
|
||||
WHERE stage = 'summarize'
|
||||
AND status = 'completed'
|
||||
AND completed_at > NOW() - INTERVAL '24 hours'
|
||||
GROUP BY 1
|
||||
"""
|
||||
|
||||
# 5) processing 행 + 표시용 제목 재료 (1방 — 머신별 2건 슬라이스는 판정부에서)
|
||||
_CURRENT_SQL = """
|
||||
SELECT q.stage, q.document_id, d.title, d.original_filename, d.file_path
|
||||
FROM processing_queue q
|
||||
JOIN documents d ON d.id = q.document_id
|
||||
WHERE q.status = 'processing'
|
||||
ORDER BY q.started_at DESC NULLS LAST
|
||||
LIMIT 50
|
||||
"""
|
||||
|
||||
|
||||
async def build_overview(session: AsyncSession) -> dict:
|
||||
"""5쿼리 수집 → compose_overview 판정 → 응답 dict."""
|
||||
now_kst = datetime.now(KST)
|
||||
kst_midnight = now_kst.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
deep_enabled = settings.ai is not None and settings.ai.deep is not None
|
||||
|
||||
stage_rows = (
|
||||
await session.execute(text(_STAGE_STATS_SQL), {"kst_midnight": kst_midnight})
|
||||
).all()
|
||||
split_rows = (
|
||||
await session.execute(
|
||||
text(_SUMMARIZE_SPLIT_SQL),
|
||||
{"kst_midnight": kst_midnight, "macbook_alias": _MACBOOK_MODEL_ALIAS},
|
||||
)
|
||||
).all()
|
||||
inflow_rows = (await session.execute(text(_TREND_INFLOW_SQL))).all()
|
||||
done_rows = (await session.execute(text(_TREND_DONE_SQL))).all()
|
||||
current_result = (await session.execute(text(_CURRENT_SQL))).all()
|
||||
|
||||
current_rows = [
|
||||
{
|
||||
"stage": row[0],
|
||||
"document_id": row[1],
|
||||
"title": row[2],
|
||||
"original_filename": row[3],
|
||||
"file_path": row[4],
|
||||
}
|
||||
for row in current_result
|
||||
]
|
||||
|
||||
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},
|
||||
{row[0]: int(row[1]) for row in done_rows},
|
||||
current_rows,
|
||||
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) ─────────────────────────────────────
|
||||
# 실패 = 자동 재시도(max_attempts=3) 소진 후 영구 정지 상태. 여기 함수들은
|
||||
# 사용자 명시 조치 전용 — 자동 호출 경로 없음 (보드 실패 드로어가 유일 호출자).
|
||||
|
||||
# 실패 행은 completed_at 이 비어 있을 수 있어(소비자 실패 경로가 미기록)
|
||||
# started_at 을 시각 fallback 으로 쓴다.
|
||||
_FAILED_LIST_SQL = """
|
||||
SELECT q.id, q.stage, q.document_id, q.attempts, q.max_attempts,
|
||||
q.error_message,
|
||||
COALESCE(q.completed_at, q.started_at) AS failed_at,
|
||||
d.title, d.original_filename, d.file_path
|
||||
FROM processing_queue q
|
||||
JOIN documents d ON d.id = q.document_id
|
||||
WHERE q.status = 'failed'
|
||||
ORDER BY q.stage, COALESCE(q.completed_at, q.started_at) DESC NULLS LAST
|
||||
LIMIT 300
|
||||
"""
|
||||
|
||||
# 재시도: failed → pending (attempts 리셋 = 자동 재시도 3회 새로 부여).
|
||||
# error_message 는 감사용으로 보존 — 성공 시 완료 행에 남아도 무해.
|
||||
# uq_queue_active((doc,stage) pending/processing 부분 유니크)와 충돌하는 행 —
|
||||
# 같은 문서·단계가 이미 재enqueue 된 경우 — 는 건드리지 않고 건수만 보고.
|
||||
_RETRY_SQL = """
|
||||
UPDATE processing_queue q
|
||||
SET status = 'pending', attempts = 0,
|
||||
started_at = NULL, completed_at = NULL
|
||||
WHERE q.id IN :ids
|
||||
AND q.status = 'failed'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM processing_queue p
|
||||
WHERE p.document_id = q.document_id
|
||||
AND p.stage = q.stage
|
||||
AND p.status IN ('pending', 'processing')
|
||||
AND p.id <> q.id
|
||||
)
|
||||
RETURNING q.id
|
||||
"""
|
||||
|
||||
# 건너뛰기: failed → completed + payload 마킹 (감사 추적).
|
||||
# enqueue_next_stage 는 의도적으로 호출하지 않는다 — 실패 문서(빈 텍스트 등)가
|
||||
# 하류 단계로 흘러가는 것 방지. 후속 단계가 필요하면 재시도가 정상 경로.
|
||||
_SKIP_SQL = """
|
||||
UPDATE processing_queue
|
||||
SET status = 'completed', completed_at = NOW(),
|
||||
payload = COALESCE(payload, '{}'::jsonb)
|
||||
|| jsonb_build_object('skipped_by_user', true,
|
||||
'skipped_at', NOW()::text)
|
||||
WHERE id IN :ids AND status = 'failed'
|
||||
RETURNING id
|
||||
"""
|
||||
|
||||
|
||||
async def fetch_failed_items(session: AsyncSession) -> list[dict]:
|
||||
"""영구 실패 행 목록 (문서 제목 포함, 최대 300건)."""
|
||||
rows = (await session.execute(text(_FAILED_LIST_SQL))).all()
|
||||
return [
|
||||
{
|
||||
"id": r[0],
|
||||
"stage": r[1],
|
||||
"document_id": r[2],
|
||||
"attempts": int(r[3] or 0),
|
||||
"max_attempts": int(r[4] or 0),
|
||||
"error_message": r[5],
|
||||
"failed_at": r[6],
|
||||
"title": display_title({
|
||||
"document_id": r[2],
|
||||
"title": r[7],
|
||||
"original_filename": r[8],
|
||||
"file_path": r[9],
|
||||
}),
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
async def retry_failed(session: AsyncSession, ids: list[int]) -> dict:
|
||||
"""failed → pending 복귀. not_retried = active 충돌 + 이미 failed 아님."""
|
||||
unique_ids = list(set(ids))
|
||||
stmt = text(_RETRY_SQL).bindparams(bindparam("ids", expanding=True))
|
||||
retried = (await session.execute(stmt, {"ids": unique_ids})).all()
|
||||
await session.commit()
|
||||
return {
|
||||
"requested": len(unique_ids),
|
||||
"retried": len(retried),
|
||||
"not_retried": len(unique_ids) - len(retried),
|
||||
}
|
||||
|
||||
|
||||
async def skip_failed(session: AsyncSession, ids: list[int]) -> dict:
|
||||
"""failed → completed(건너뛰기 마킹). 후속 단계 연쇄 없음."""
|
||||
unique_ids = list(set(ids))
|
||||
stmt = text(_SKIP_SQL).bindparams(bindparam("ids", expanding=True))
|
||||
skipped = (await session.execute(stmt, {"ids": unique_ids})).all()
|
||||
await session.commit()
|
||||
return {
|
||||
"requested": len(unique_ids),
|
||||
"skipped": len(skipped),
|
||||
"not_skipped": len(unique_ids) - len(skipped),
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
"""Answerability classifier (Phase 3.5a).
|
||||
|
||||
Mac mini 26B MLX 기반 (config.yaml ai.models.classifier — PR #20 이후 triage/primary/classifier 동일 endpoint). MLX gate 밖 — evidence extraction 과 병렬 실행 (concurrent 안전성 별 검토).
|
||||
|
||||
P1 실측 결과: ternary (full/partial/insufficient) 불안정 → **binary (sufficient/insufficient)**.
|
||||
"full" vs "partial" 구분은 grounding_check 의 intent alignment 이 담당.
|
||||
|
||||
Classifier verdict 는 "relevant evidence 가 있나" 의 binary 판단.
|
||||
covered_aspects / missing_aspects 는 로깅용으로 유지 (refusal gate 에서 사용 안 함).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal
|
||||
|
||||
from ai.client import AIClient, _load_prompt, parse_json_response
|
||||
from core.config import settings
|
||||
from core.utils import setup_logger
|
||||
|
||||
from .llm_gate import Priority, acquire_mlx_gate
|
||||
|
||||
logger = setup_logger("classifier")
|
||||
|
||||
LLM_TIMEOUT_MS = 30000
|
||||
CIRCUIT_THRESHOLD = 5
|
||||
CIRCUIT_RECOVERY_SEC = 60
|
||||
|
||||
_failure_count = 0
|
||||
_circuit_open_until: float | None = None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ClassifierResult:
|
||||
status: Literal["ok", "timeout", "error", "circuit_open", "skipped"]
|
||||
verdict: Literal["sufficient", "insufficient"] | None
|
||||
covered_aspects: list[str]
|
||||
missing_aspects: list[str]
|
||||
elapsed_ms: float
|
||||
|
||||
|
||||
try:
|
||||
CLASSIFIER_PROMPT = _load_prompt("classifier.txt")
|
||||
except FileNotFoundError:
|
||||
CLASSIFIER_PROMPT = ""
|
||||
logger.warning("classifier.txt not found — classifier will always skip")
|
||||
|
||||
|
||||
def _build_input(
|
||||
query: str,
|
||||
top_chunks: list[dict],
|
||||
rerank_scores: list[float],
|
||||
) -> str:
|
||||
"""Y+ input (content + scores with role separation)."""
|
||||
chunk_block = "\n".join(
|
||||
f"[{i+1}] title: {c.get('title','')}\n"
|
||||
f" section: {c.get('section','')}\n"
|
||||
f" snippet: {c.get('snippet','')}"
|
||||
for i, c in enumerate(top_chunks[:3])
|
||||
)
|
||||
scores_str = ", ".join(f"{s:.2f}" for s in rerank_scores[:3])
|
||||
return (
|
||||
CLASSIFIER_PROMPT
|
||||
.replace("{query}", query)
|
||||
.replace("{chunks}", chunk_block)
|
||||
.replace("{scores}", scores_str)
|
||||
)
|
||||
|
||||
|
||||
async def classify(
|
||||
query: str,
|
||||
top_chunks: list[dict],
|
||||
rerank_scores: list[float],
|
||||
) -> ClassifierResult:
|
||||
"""Always-on binary classifier. Parallel with evidence extraction.
|
||||
|
||||
Returns:
|
||||
ClassifierResult with verdict=sufficient|insufficient.
|
||||
Status "ok" 이 아니면 verdict=None (caller 가 fallback 처리).
|
||||
"""
|
||||
global _failure_count, _circuit_open_until
|
||||
t_start = time.perf_counter()
|
||||
|
||||
# Circuit breaker
|
||||
if _circuit_open_until and time.time() < _circuit_open_until:
|
||||
return ClassifierResult("circuit_open", None, [], [], 0.0)
|
||||
|
||||
if not CLASSIFIER_PROMPT:
|
||||
return ClassifierResult("skipped", None, [], [], 0.0)
|
||||
|
||||
if not hasattr(settings.ai, "classifier") or settings.ai.classifier is None:
|
||||
return ClassifierResult("skipped", None, [], [], 0.0)
|
||||
|
||||
prompt = _build_input(query, top_chunks, rerank_scores)
|
||||
client = AIClient()
|
||||
try:
|
||||
# 2026-05-17: PR #20 이후 endpoint 가 Mac mini 26B → llm_gate Semaphore(1) 필수.
|
||||
# Gate 미사용 시 classifier + evidence + synthesis 가 동시에 single-inference
|
||||
# MLX 에 race → 거의 모두 timeout (실측: 8/10 fixture query). docstring 영구 룰:
|
||||
# "MLX primary 호출 경로는 예외 없이 gate 획득 필수".
|
||||
async with acquire_mlx_gate(Priority.FOREGROUND):
|
||||
async with asyncio.timeout(LLM_TIMEOUT_MS / 1000):
|
||||
raw = await client._request(settings.ai.classifier, prompt)
|
||||
_failure_count = 0
|
||||
except asyncio.TimeoutError:
|
||||
_failure_count += 1
|
||||
if _failure_count >= CIRCUIT_THRESHOLD:
|
||||
_circuit_open_until = time.time() + CIRCUIT_RECOVERY_SEC
|
||||
logger.error(f"classifier circuit OPEN for {CIRCUIT_RECOVERY_SEC}s")
|
||||
logger.warning("classifier timeout")
|
||||
return ClassifierResult(
|
||||
"timeout", None, [], [],
|
||||
(time.perf_counter() - t_start) * 1000,
|
||||
)
|
||||
except Exception as e:
|
||||
_failure_count += 1
|
||||
if _failure_count >= CIRCUIT_THRESHOLD:
|
||||
_circuit_open_until = time.time() + CIRCUIT_RECOVERY_SEC
|
||||
logger.error(f"classifier circuit OPEN for {CIRCUIT_RECOVERY_SEC}s")
|
||||
logger.warning("classifier error: type=%s repr=%r", type(e).__name__, e)
|
||||
return ClassifierResult(
|
||||
"error", None, [], [],
|
||||
(time.perf_counter() - t_start) * 1000,
|
||||
)
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
elapsed_ms = (time.perf_counter() - t_start) * 1000
|
||||
parsed = parse_json_response(raw)
|
||||
if not isinstance(parsed, dict):
|
||||
logger.warning("classifier parse failed raw=%r", (raw or "")[:200])
|
||||
return ClassifierResult("error", None, [], [], elapsed_ms)
|
||||
|
||||
# ternary → binary 매핑
|
||||
raw_verdict = parsed.get("verdict", "")
|
||||
if raw_verdict == "insufficient":
|
||||
verdict: Literal["sufficient", "insufficient"] | None = "insufficient"
|
||||
elif raw_verdict in ("full", "partial", "sufficient"):
|
||||
verdict = "sufficient"
|
||||
else:
|
||||
verdict = None
|
||||
|
||||
covered = parsed.get("covered_aspects") or []
|
||||
missing = parsed.get("missing_aspects") or []
|
||||
if not isinstance(covered, list):
|
||||
covered = []
|
||||
if not isinstance(missing, list):
|
||||
missing = []
|
||||
|
||||
logger.info(
|
||||
"classifier ok query=%r verdict=%s (raw=%s) covered=%d missing=%d elapsed_ms=%.0f",
|
||||
query[:60], verdict, raw_verdict, len(covered), len(missing), elapsed_ms,
|
||||
)
|
||||
return ClassifierResult("ok", verdict, covered, missing, elapsed_ms)
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Time-aware retrieval freshness decay (PR-RAG-Time-1).
|
||||
|
||||
뉴스(source_channel='news') / 법령 알림(source_channel='law_monitor') 도메인은
|
||||
뉴스(source_channel='news') / 재해사례(material_type='incident', KOSHA) 도메인은
|
||||
시간이 중요한 문서. 단순 relevance score 만으로는 오래된 문서가 상위에 머물러
|
||||
검색 품질이 떨어짐. 본 모듈은 reranker 이후 final score 합성 단계에서
|
||||
soft multiplier 로 시간 가중치 적용. 삭제는 없음 — ranking 만 demote.
|
||||
@@ -9,9 +9,10 @@ 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/law_monitor 워커가 수집 즉시 indexing 하므로 created_at ≈ published_date.
|
||||
news/KOSHA 워커가 수집 즉시 indexing 하므로 created_at ≈ published_date.
|
||||
정확도 향상은 후속 PR (worker 가 published_date 메타 채우기) 로 분리.
|
||||
"""
|
||||
|
||||
@@ -32,10 +33,10 @@ if TYPE_CHECKING:
|
||||
# ─── Policy ────────────────────────────────────────────────────────
|
||||
|
||||
# half-life (일). 90 일: 한 달 ~0.79 / 6개월 ~0.25.
|
||||
# 365 일: 1년 ~0.5 / 3년 ~0.13.
|
||||
# C-1 후속(2026-06-13): law_365d 폐기 — 법령 현행성은 version_status(B-1 버전체인)가 처리,
|
||||
# age-decay 는 current 법령을 부당 강등(의도 변경 기록). 재해사례(incident)는 news_90d 흡수.
|
||||
HALF_LIFE_DAYS: dict[str, int] = {
|
||||
"news_90d": 90,
|
||||
"law_365d": 365,
|
||||
}
|
||||
|
||||
# soft multiplier — final = base * (FLOOR + (1-FLOOR) * decay).
|
||||
@@ -52,32 +53,35 @@ 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).
|
||||
|
||||
적용:
|
||||
- source_channel='news' → news_90d
|
||||
- source_channel='law_monitor' → law_365d
|
||||
- material_type='incident' (KOSHA 재해사례/사망사고) → news_90d (C-1 후속 흡수, 시간 민감)
|
||||
- source_channel='news' → news_90d
|
||||
|
||||
비적용 (None 반환):
|
||||
- meta 자체가 None
|
||||
- content_origin='ai_drafted' (생성 시점 = 가치 시점, 시간 demote 부적합)
|
||||
- 그 외 모든 source_channel (manual, drive_sync, inbox_route, memo,
|
||||
Study/Manual/Reference/Academic/Checklist 류 — 자연 비적용)
|
||||
- ★법령(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 등 — 자연 비적용)
|
||||
"""
|
||||
if meta is None:
|
||||
return None
|
||||
# 가드 2: content_origin='ai_drafted' 비적용
|
||||
if meta.content_origin == "ai_drafted":
|
||||
return None
|
||||
sc = meta.source_channel
|
||||
if sc == "news":
|
||||
# 재해사례/사망사고 = 시간 민감 → news 와 동일 90d (source 무관, 업로드 incident 도 포함)
|
||||
if meta.material_type == "incident":
|
||||
return "news_90d"
|
||||
if sc == "law_monitor":
|
||||
return "law_365d"
|
||||
# 가드 6: unknown source_channel → no decay
|
||||
if meta.source_channel == "news":
|
||||
return "news_90d"
|
||||
# 법령 law_365d 폐기 + unknown source_channel → no decay
|
||||
return None
|
||||
|
||||
|
||||
@@ -129,7 +133,7 @@ async def _fetch_meta(
|
||||
text(
|
||||
"""
|
||||
SELECT id, source_channel::text AS source_channel,
|
||||
content_origin, created_at
|
||||
content_origin, material_type, created_at
|
||||
FROM documents
|
||||
WHERE id = ANY(:ids)
|
||||
"""
|
||||
@@ -141,6 +145,7 @@ 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
|
||||
}
|
||||
|
||||
@@ -72,6 +72,10 @@ class LegacyWeightedSum(FusionStrategy):
|
||||
score=existing.score + r.score * 0.5,
|
||||
snippet=existing.snippet,
|
||||
match_reason=f"{existing.match_reason}+vector",
|
||||
# C-1: 분류 축 메타 전파 (재구성 시 누락 = D-1 유형 표시 None)
|
||||
material_type=existing.material_type,
|
||||
jurisdiction=existing.jurisdiction,
|
||||
published_date=existing.published_date,
|
||||
)
|
||||
elif r.score > 0.3:
|
||||
merged[r.id] = r
|
||||
@@ -128,6 +132,10 @@ class RRFOnly(FusionStrategy):
|
||||
score=rrf_score,
|
||||
snippet=base.snippet,
|
||||
match_reason="+".join(reasons),
|
||||
# C-1: 분류 축 메타 전파 (재구성 시 누락 = D-1 유형 표시 None)
|
||||
material_type=base.material_type,
|
||||
jurisdiction=base.jurisdiction,
|
||||
published_date=base.published_date,
|
||||
)
|
||||
)
|
||||
return merged[:limit]
|
||||
|
||||
@@ -1,505 +0,0 @@
|
||||
"""Grounding check — post-synthesis 검증 (Phase 3.5a).
|
||||
|
||||
Strong/weak flag 분리:
|
||||
- **Strong** (→ partial 강등 or refuse): fabricated_number, intent_misalignment(important)
|
||||
- **Weak** (→ confidence lower only): uncited_claim, low_overlap, intent_misalignment(generic)
|
||||
|
||||
Re-gate 로직 (Phase 3.5a 9라운드 토론 결과):
|
||||
- strong 1개 → partial 강등
|
||||
- strong 2개 이상 → refuse
|
||||
- weak → confidence "low" 만
|
||||
|
||||
Intent alignment (rule-based):
|
||||
- query 의 핵심 명사가 answer 에 등장하는지 확인
|
||||
- "처벌" 같은 중요 키워드 누락은 strong
|
||||
- "주요", "관련" 같은 generic 은 무시
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from core.utils import setup_logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .evidence_service import EvidenceItem
|
||||
|
||||
logger = setup_logger("grounding")
|
||||
|
||||
# "주요", "관련" 등 intent alignment 에서 제외할 generic 단어
|
||||
GENERIC_TERMS = frozenset({
|
||||
"주요", "관련", "내용", "정의", "기준", "방법", "설명", "개요",
|
||||
"대한", "위한", "대해", "무엇", "어떤", "어떻게", "있는",
|
||||
"하는", "되는", "이런", "그런", "이것", "그것",
|
||||
})
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class GroundingResult:
|
||||
strong_flags: list[str]
|
||||
weak_flags: list[str]
|
||||
|
||||
|
||||
_UNIT_CHARS = r'명인개%년월일조항호세건원회'
|
||||
|
||||
# "이상/이하/초과/미만" — threshold 표현 (numeric conflict 에서 skip 대상)
|
||||
_THRESHOLD_SUFFIXES = re.compile(r'이상|이하|초과|미만')
|
||||
|
||||
# 약칭/근사치 prefix — 매칭 전 제거 (Phase 3.5 B1).
|
||||
# ⚠ 최대/최소 는 의도적으로 제외 — 이들은 bound operator 라 의미가 다름 (Phase 3.5 B1 fix3).
|
||||
# 약/대략/거의/얼추 만 노이즈 prefix 로 strip.
|
||||
_APPROX_PREFIX_RE = re.compile(r'(약|대략|거의|얼추)\s*')
|
||||
|
||||
# 단위 동의어 dict — 추출 직후 정규화 (Phase 3.5 B1)
|
||||
# 의미가 동일한 단위는 같은 표기로 통일해서 set 비교/range overlap 안정화.
|
||||
_UNIT_SYNONYMS: dict[str, str] = {
|
||||
"인": "명",
|
||||
"사람": "명",
|
||||
"퍼센트": "%",
|
||||
"프로": "%",
|
||||
"KRW": "원",
|
||||
"krw": "원",
|
||||
}
|
||||
|
||||
# tolerance(±1%) 허용 단위 — 양적 측정값 (Phase 3.5 B1)
|
||||
_TOLERANCE_UNITS: frozenset[str] = frozenset({"명", "원", "%", "건", "개"})
|
||||
|
||||
# tolerance 미적용 단위 — 식별자성 숫자 (연도/조문/횟수)
|
||||
_EXACT_ONLY_UNITS: frozenset[str] = frozenset({"년", "월", "일", "조", "항", "호", "회"})
|
||||
|
||||
# 최대/최소 prefix 패턴 — bound operator (Phase 3.5 B1 fix3).
|
||||
# 매칭된 숫자는 exact pool 에서 제외하고 one-sided range 로 변환.
|
||||
# 경계값 자체는 clear 대상 아님 (Codex 권장: "최대 100명" + answer "100명" → flag 유지).
|
||||
_BOUND_PATTERN_RE = re.compile(
|
||||
rf'(최대|최소)\s*(\d[\d,.]*)\s*([{_UNIT_CHARS}]|인|사람|퍼센트|프로|KRW|krw)'
|
||||
)
|
||||
_RANGE_INF = 10**18 # one-sided range 상한 sentinel
|
||||
|
||||
|
||||
def _normalize_unit(unit: str) -> str:
|
||||
"""단위 동의어 → 대표 표기."""
|
||||
return _UNIT_SYNONYMS.get(unit, unit)
|
||||
|
||||
|
||||
def _extract_unit(literal: str) -> str | None:
|
||||
"""리터럴에서 숫자 뒤 단위(한 글자 또는 동의어) 추출 + 정규화."""
|
||||
# 천단위 콤마 + 옵션 소수 + 한글 단위 한 글자 또는 동의어
|
||||
m = re.match(rf'[\d,.]+\s*([{_UNIT_CHARS}]|인|사람|퍼센트|프로|KRW|krw)', literal)
|
||||
if not m:
|
||||
return None
|
||||
return _normalize_unit(m.group(1))
|
||||
|
||||
|
||||
def _extract_numeric_corpus(text: str) -> dict:
|
||||
"""단위별 숫자 + 범위 + bound 통합 추출 (Phase 3.5 B1 fix1+fix3).
|
||||
|
||||
Returns:
|
||||
{
|
||||
"exact_by_unit": {unit_or_None: set(digits)}, # 평범한 숫자 (bound 제외)
|
||||
"ranges_by_unit": {unit: [(lo, hi), ...]}, # 양방향(A~B) + 단방향(최대/최소)
|
||||
}
|
||||
|
||||
None 키는 단위 없는 bare 숫자.
|
||||
`최대 N <unit>` → ranges[(0, N-1)] (경계값 자체는 cleared 대상 아님)
|
||||
`최소 N <unit>` → ranges[(N+1, INF)]
|
||||
"""
|
||||
cleaned = _APPROX_PREFIX_RE.sub('', text)
|
||||
|
||||
exact_by_unit: dict[str | None, set[str]] = {None: set()}
|
||||
ranges_by_unit: dict[str, list[tuple[int, int]]] = {}
|
||||
|
||||
# 1) 최대/최소 — bound. exact pool 에서 제외, one-sided range 로 변환.
|
||||
bound_spans: list[tuple[int, int]] = [] # 매칭 substring 위치 — 이후 단계에서 skip
|
||||
for m in _BOUND_PATTERN_RE.finditer(cleaned):
|
||||
bound_kind = m.group(1)
|
||||
try:
|
||||
n = int(m.group(2).replace(',', '').split('.')[0])
|
||||
except ValueError:
|
||||
continue
|
||||
unit = _normalize_unit(m.group(3))
|
||||
if bound_kind == "최대":
|
||||
ranges_by_unit.setdefault(unit, []).append((0, max(0, n - 1)))
|
||||
else: # 최소
|
||||
ranges_by_unit.setdefault(unit, []).append((n + 1, _RANGE_INF))
|
||||
bound_spans.append((m.start(), m.end()))
|
||||
|
||||
def _in_bound_span(pos: int) -> bool:
|
||||
return any(s <= pos < e for s, e in bound_spans)
|
||||
|
||||
# 2) 천단위 콤마 bare number
|
||||
for m in re.finditer(r'\d{1,3}(?:,\d{3})+(?:\.\d+)?', cleaned):
|
||||
if _in_bound_span(m.start()):
|
||||
continue
|
||||
exact_by_unit[None].add(m.group().replace(',', ''))
|
||||
|
||||
# 3) 단위 있는 숫자 (단위 동의어 포함)
|
||||
for m in re.finditer(
|
||||
rf'(\d[\d,.]*)\s*([{_UNIT_CHARS}]|인|사람|퍼센트|프로|KRW|krw)',
|
||||
cleaned,
|
||||
):
|
||||
if _in_bound_span(m.start()):
|
||||
continue
|
||||
digits = m.group(1).replace(',', '').split('.')[0]
|
||||
if not digits:
|
||||
continue
|
||||
unit = _normalize_unit(m.group(2))
|
||||
exact_by_unit.setdefault(unit, set()).add(digits)
|
||||
|
||||
# 4) 양방향 범위 표현 (A~B / A 부터 B)
|
||||
for m in re.finditer(
|
||||
rf'(\d[\d,.]*)\s*(?:[~\-–]|부터)\s*(\d[\d,.]*)\s*([{_UNIT_CHARS}]|인|사람|퍼센트|프로)',
|
||||
cleaned,
|
||||
):
|
||||
if _in_bound_span(m.start()):
|
||||
continue
|
||||
try:
|
||||
lo = int(m.group(1).replace(',', '').split('.')[0])
|
||||
hi = int(m.group(2).replace(',', '').split('.')[0])
|
||||
except ValueError:
|
||||
continue
|
||||
unit = _normalize_unit(m.group(3))
|
||||
ranges_by_unit.setdefault(unit, []).append((min(lo, hi), max(lo, hi)))
|
||||
|
||||
# 5) bare 2자리+ 단독 숫자
|
||||
for m in re.finditer(r'\b(\d{2,})\b', cleaned):
|
||||
if _in_bound_span(m.start()):
|
||||
continue
|
||||
exact_by_unit[None].add(m.group())
|
||||
|
||||
return {
|
||||
"exact_by_unit": exact_by_unit,
|
||||
"ranges_by_unit": ranges_by_unit,
|
||||
}
|
||||
|
||||
|
||||
def _within_unit_range(
|
||||
n: int, unit: str | None, ranges_by_unit: dict[str, list[tuple[int, int]]]
|
||||
) -> bool:
|
||||
"""unit-matching range 검증.
|
||||
|
||||
answer unit 이 None (bare 숫자) 면 보수적으로 False — bare 답변은 range clear 대상 아님.
|
||||
"""
|
||||
if unit is None:
|
||||
return False
|
||||
return any(lo <= n <= hi for lo, hi in ranges_by_unit.get(unit, []))
|
||||
|
||||
|
||||
def _close_to_unit_pool(
|
||||
n: int, unit: str | None, exact_by_unit: dict[str | None, set[str]], tol: float
|
||||
) -> bool:
|
||||
"""unit-matching tolerance 검증.
|
||||
|
||||
answer unit 이 None 이면 False — bare 답변은 tolerance 대상 아님.
|
||||
같은 unit bucket 안의 후보만 비교.
|
||||
"""
|
||||
if unit is None:
|
||||
return False
|
||||
candidates = exact_by_unit.get(unit, set())
|
||||
for c in candidates:
|
||||
try:
|
||||
cn = int(c)
|
||||
except ValueError:
|
||||
continue
|
||||
if cn == 0:
|
||||
continue
|
||||
if abs(n - cn) / cn <= tol:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _extract_number_literals(text: str) -> set[str]:
|
||||
"""숫자 + 단위 추출 + normalize (Phase 3.5 B1: 6단계 확장).
|
||||
|
||||
1) 약칭 prefix 제거 ("약 100명" → "100명")
|
||||
2) 천단위 콤마 bare number 우선 ("1,000" → "1000" set 등록)
|
||||
3) 한국어 단위 접미사 매칭 (기존)
|
||||
4) 범위 표현 양쪽 숫자 추출 (separator: ~, -, –, 부터)
|
||||
5) 단위 동의어 정규화 (인→명, 퍼센트→%, KRW→원)
|
||||
6) bare 2자리+ 추출 (기존)
|
||||
"""
|
||||
# 1. 약칭 prefix 제거 (전체 텍스트에서)
|
||||
cleaned = _APPROX_PREFIX_RE.sub('', text)
|
||||
|
||||
# 2. 천단위 콤마 bare number — normalize 된 값을 set 에 선등록
|
||||
normalized: set[str] = set()
|
||||
for m in re.finditer(r'\d{1,3}(?:,\d{3})+(?:\.\d+)?', cleaned):
|
||||
normalized.add(m.group().replace(',', ''))
|
||||
|
||||
# 3. 숫자 + 한국어 단위 접미사 (동의어 포함)
|
||||
raw: set[str] = set(re.findall(
|
||||
rf'\d[\d,.]*\s*(?:[{_UNIT_CHARS}]|인|사람|퍼센트|프로|KRW|krw)\w{{0,2}}',
|
||||
cleaned,
|
||||
))
|
||||
|
||||
# 4. 범위 표현 — separator 에 "부터" 추가
|
||||
for m in re.finditer(
|
||||
rf'(\d[\d,.]*)\s*(?:[~\-–]|부터)\s*(\d[\d,.]*)\s*([{_UNIT_CHARS}]|인|사람|퍼센트|프로)',
|
||||
cleaned,
|
||||
):
|
||||
unit_norm = _normalize_unit(m.group(3))
|
||||
raw.add(m.group(1) + unit_norm)
|
||||
raw.add(m.group(2) + unit_norm)
|
||||
|
||||
# 5. normalize: 단위 동의어 통일 + 콤마 제거
|
||||
for r in raw:
|
||||
# 단위 부분 정규화
|
||||
m = re.match(r'([\d,.]+)\s*([^\d\s]+)', r)
|
||||
if m:
|
||||
digits_part = m.group(1)
|
||||
unit_part = _normalize_unit(m.group(2))
|
||||
normalized.add(digits_part + unit_part)
|
||||
normalized.add(digits_part.replace(',', '') + unit_part)
|
||||
normalized.add(r.strip())
|
||||
num_only = re.match(r'[\d,.]+', r)
|
||||
if num_only:
|
||||
normalized.add(num_only.group().replace(',', ''))
|
||||
|
||||
# 6. 단독 숫자 (2자리+ 만)
|
||||
for d in re.findall(r'\b(\d{2,})\b', cleaned):
|
||||
normalized.add(d)
|
||||
return normalized
|
||||
|
||||
|
||||
def _within_evidence_range(digits: str, raw: str, evidence_text: str) -> bool:
|
||||
"""evidence 에 'A~B 단위' 가 있고 answer 의 숫자가 그 범위 안이면 True.
|
||||
|
||||
범위 단위는 무시 (단위 비교는 호출 전 단계). digits = 정수 문자열.
|
||||
"""
|
||||
try:
|
||||
n = int(digits)
|
||||
except ValueError:
|
||||
return False
|
||||
cleaned_ev = _APPROX_PREFIX_RE.sub('', evidence_text)
|
||||
for m in re.finditer(
|
||||
rf'(\d[\d,.]*)\s*(?:[~\-–]|부터)\s*(\d[\d,.]*)\s*[{_UNIT_CHARS}]',
|
||||
cleaned_ev,
|
||||
):
|
||||
try:
|
||||
lo = int(m.group(1).replace(',', '').split('.')[0])
|
||||
hi = int(m.group(2).replace(',', '').split('.')[0])
|
||||
if min(lo, hi) <= n <= max(lo, hi):
|
||||
return True
|
||||
except ValueError:
|
||||
continue
|
||||
return False
|
||||
|
||||
|
||||
def _close_to_any(n: int, candidates: set[str], tol: float) -> bool:
|
||||
"""candidates 중 하나라도 (1±tol) 배율 안에 들어오면 True.
|
||||
|
||||
n 은 정수, candidates 는 digits-only 문자열 집합.
|
||||
"""
|
||||
for c in candidates:
|
||||
try:
|
||||
cn = int(c)
|
||||
except ValueError:
|
||||
continue
|
||||
if cn == 0:
|
||||
continue
|
||||
if abs(n - cn) / cn <= tol:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _extract_content_tokens(text: str) -> set[str]:
|
||||
"""한국어 2자 이상 명사 + 영어 3자 이상 단어."""
|
||||
return set(re.findall(r'[가-힣]{2,}|[a-zA-Z]{3,}', text))
|
||||
|
||||
|
||||
def _parse_number_with_unit(literal: str) -> tuple[str, str] | None:
|
||||
"""숫자 리터럴에서 (digits_only, unit) 분리. 단위 없으면 None."""
|
||||
m = re.match(rf'([\d,.]+)\s*([{_UNIT_CHARS}])', literal)
|
||||
if not m:
|
||||
return None
|
||||
digits = m.group(1).replace(',', '')
|
||||
unit = m.group(2)
|
||||
return (digits, unit)
|
||||
|
||||
|
||||
def _check_evidence_numeric_conflicts(evidence: list["EvidenceItem"]) -> list[str]:
|
||||
"""evidence 간 숫자 충돌 감지 (Phase 3.5b). evidence >= 2 일 때만 활성.
|
||||
|
||||
동일 단위, 다른 숫자 → weak flag. "이상/이하/초과/미만" 포함 시 skip.
|
||||
bare number 는 비교 안 함 (조항 번호 등 false positive 방지).
|
||||
"""
|
||||
if len(evidence) < 2:
|
||||
return []
|
||||
|
||||
# 각 evidence 에서 단위 있는 숫자 + threshold 여부 추출
|
||||
# {evidence_idx: [(digits, unit, has_threshold), ...]}
|
||||
per_evidence: dict[int, list[tuple[str, str, bool]]] = {}
|
||||
for idx, ev in enumerate(evidence):
|
||||
nums = re.findall(
|
||||
rf'\d[\d,.]*\s*[{_UNIT_CHARS}]\w{{0,4}}',
|
||||
ev.span_text,
|
||||
)
|
||||
entries = []
|
||||
for raw in nums:
|
||||
parsed = _parse_number_with_unit(raw)
|
||||
if not parsed:
|
||||
continue
|
||||
has_thr = bool(_THRESHOLD_SUFFIXES.search(raw))
|
||||
entries.append((parsed[0], parsed[1], has_thr))
|
||||
if entries:
|
||||
per_evidence[idx] = entries
|
||||
|
||||
if len(per_evidence) < 2:
|
||||
return []
|
||||
|
||||
# 단위별로 evidence 간 숫자 비교
|
||||
# {unit: {digits: [evidence_idx, ...]}}
|
||||
unit_map: dict[str, dict[str, list[int]]] = {}
|
||||
for idx, entries in per_evidence.items():
|
||||
for digits, unit, has_thr in entries:
|
||||
if has_thr:
|
||||
continue # threshold 표현은 skip
|
||||
if unit not in unit_map:
|
||||
unit_map[unit] = {}
|
||||
if digits not in unit_map[unit]:
|
||||
unit_map[unit][digits] = []
|
||||
if idx not in unit_map[unit][digits]:
|
||||
unit_map[unit][digits].append(idx)
|
||||
|
||||
flags: list[str] = []
|
||||
for unit, digits_map in unit_map.items():
|
||||
distinct_values = list(digits_map.keys())
|
||||
if len(distinct_values) >= 2:
|
||||
# 가장 많이 등장하는 2개 비교
|
||||
top2 = sorted(distinct_values, key=lambda d: len(digits_map[d]), reverse=True)[:2]
|
||||
flags.append(
|
||||
f"evidence_numeric_conflict:{top2[0]}{unit}_vs_{top2[1]}{unit}"
|
||||
)
|
||||
|
||||
return flags
|
||||
|
||||
|
||||
def check(
|
||||
query: str,
|
||||
answer: str,
|
||||
evidence: list[EvidenceItem],
|
||||
) -> GroundingResult:
|
||||
"""답변 vs evidence grounding 검증 + query intent alignment."""
|
||||
strong: list[str] = []
|
||||
weak: list[str] = []
|
||||
|
||||
if not answer or not evidence:
|
||||
return GroundingResult([], [])
|
||||
|
||||
# ⚠ citation marker [n] 양측 제거 (대칭성 — Phase 3.5 B1)
|
||||
evidence_text = re.sub(r'\[\d+\]', '', " ".join(e.span_text for e in evidence))
|
||||
|
||||
# ── Strong 1: fabricated number (unit-aware 3단계 — Phase 3.5 B1 fix1+fix3) ──
|
||||
# Codex 지적 반영:
|
||||
# - fix1: range/tolerance/exact 모두 단위 일치 시에만 clear
|
||||
# (예: "150원" vs "100~200명" → flag 유지)
|
||||
# - fix3: 최대/최소 prefix 는 bound 의미 보존
|
||||
# (예: "최대 100명" + answer "100명" → flag 유지, "최대 100명" + answer "50명" → cleared)
|
||||
answer_clean = re.sub(r'\[\d+\]', '', answer)
|
||||
answer_corpus = _extract_numeric_corpus(answer_clean)
|
||||
evidence_corpus = _extract_numeric_corpus(evidence_text)
|
||||
ev_exact_by_unit = evidence_corpus["exact_by_unit"]
|
||||
ev_ranges_by_unit = evidence_corpus["ranges_by_unit"]
|
||||
|
||||
# cleared 는 (unit, digits) 쌍 단위로 추적 — 단위 충돌 케이스 방어
|
||||
cleared_pairs: set[tuple[str | None, str]] = set()
|
||||
|
||||
# Pass 1: 각 (unit, digits) 가 evidence 에서 정당화되는지 판정
|
||||
for unit, digits_set in answer_corpus["exact_by_unit"].items():
|
||||
for d in digits_set:
|
||||
# 1) exact match — 같은 unit bucket 내에서만
|
||||
if d in ev_exact_by_unit.get(unit, set()):
|
||||
cleared_pairs.add((unit, d))
|
||||
continue
|
||||
# bare answer (unit=None) 는 evidence bare bucket 도 보조 매칭
|
||||
if unit is None and d in ev_exact_by_unit.get(None, set()):
|
||||
cleared_pairs.add((unit, d))
|
||||
continue
|
||||
try:
|
||||
n = int(d)
|
||||
except ValueError:
|
||||
continue
|
||||
# 2) range — same-unit 만 (bare answer 는 range clear 대상 아님)
|
||||
if _within_unit_range(n, unit, ev_ranges_by_unit):
|
||||
cleared_pairs.add((unit, d))
|
||||
continue
|
||||
# 3) ±1% tolerance — 단위가 양적(_TOLERANCE_UNITS) + 4자리+ + same-unit
|
||||
if (
|
||||
unit in _TOLERANCE_UNITS
|
||||
and len(d) >= 4
|
||||
and _close_to_unit_pool(n, unit, ev_exact_by_unit, tol=0.01)
|
||||
):
|
||||
cleared_pairs.add((unit, d))
|
||||
continue
|
||||
# 식별자성 단위(_EXACT_ONLY_UNITS) 는 tolerance 패스 X.
|
||||
|
||||
# Pass 2: cleared 되지 않은 (unit, digits) 를 strong flag.
|
||||
# 1자리 무시는 unit 이 식별자성(_EXACT_ONLY_UNITS: 년/월/일/조/항/호/회) 이 아닐 때만 적용.
|
||||
# bare(None) 답변 숫자는 같은 digit 이 다른 unit 에서 cleared 됐으면 skip — 추출 부산물 방어.
|
||||
# ⚠ 단위 cross-clear (예: "원" cleared → "명" 도 skip) 은 금지: Codex unit-mismatch 케이스가 깨짐.
|
||||
unit_anchored_cleared: set[str] = {d for (u, d) in cleared_pairs if u is not None}
|
||||
flagged_keys: set[tuple[str | None, str]] = set()
|
||||
for unit, digits_set in answer_corpus["exact_by_unit"].items():
|
||||
for d in digits_set:
|
||||
if (unit, d) in cleared_pairs or (unit, d) in flagged_keys:
|
||||
continue
|
||||
# bare(None) 답변 숫자가 임의의 단위 bucket 에서 cleared 됐으면 duplicate 로 처리.
|
||||
# 사례: "1,000명" → unit bucket "명" 에 1000 + bare bucket None 에 1000 (comma normalize 부산물).
|
||||
# 이미 ("명", "1000") 가 cleared 라면 (None, "1000") 도 같은 사실을 가리키므로 skip.
|
||||
if unit is None and d in unit_anchored_cleared:
|
||||
continue
|
||||
if len(d) < 2 and unit not in _EXACT_ONLY_UNITS:
|
||||
continue
|
||||
flagged_keys.add((unit, d))
|
||||
# 사람이 읽기 좋게 "{digits}{unit}" 또는 bare 형태로 표기
|
||||
label = f"{d}{unit}" if unit else d
|
||||
strong.append(f"fabricated_number:{label}")
|
||||
|
||||
# ── Strong/Weak 2: query-answer intent alignment ──
|
||||
query_content = _extract_content_tokens(query)
|
||||
answer_content = _extract_content_tokens(answer)
|
||||
if query_content:
|
||||
missing_terms = query_content - answer_content
|
||||
important_missing = [
|
||||
t for t in missing_terms
|
||||
if t not in GENERIC_TERMS and len(t) >= 2
|
||||
]
|
||||
if important_missing:
|
||||
strong.append(
|
||||
f"intent_misalignment:{','.join(important_missing[:3])}"
|
||||
)
|
||||
elif len(missing_terms) > len(query_content) * 0.5:
|
||||
weak.append(
|
||||
f"intent_misalignment_generic:"
|
||||
f"missing({','.join(list(missing_terms)[:5])})"
|
||||
)
|
||||
|
||||
# ── Weak 1: uncited claim ──
|
||||
sentences = re.split(r'(?<=[.!?。])\s+', answer)
|
||||
for s in sentences:
|
||||
if len(s.strip()) > 20 and not re.search(r'\[\d+\]', s):
|
||||
weak.append(f"uncited_claim:{s[:40]}")
|
||||
|
||||
# ── Weak: evidence 간 숫자 충돌 (Phase 3.5b) ──
|
||||
conflicts = _check_evidence_numeric_conflicts(evidence)
|
||||
weak.extend(conflicts)
|
||||
|
||||
# ── Weak 2: token overlap ──
|
||||
answer_tokens = _extract_content_tokens(answer)
|
||||
evidence_tokens = _extract_content_tokens(evidence_text)
|
||||
if answer_tokens:
|
||||
overlap = len(answer_tokens & evidence_tokens) / len(answer_tokens)
|
||||
if overlap < 0.4:
|
||||
weak.append(f"low_overlap:{overlap:.2f}")
|
||||
|
||||
if strong or weak:
|
||||
logger.info(
|
||||
"grounding query=%r strong=%d weak=%d flags=%s",
|
||||
query[:60],
|
||||
len(strong),
|
||||
len(weak),
|
||||
",".join(strong[:3] + weak[:3]),
|
||||
)
|
||||
|
||||
return GroundingResult(strong, weak)
|
||||
@@ -0,0 +1,28 @@
|
||||
"""안전 자료실 B-4 — licensed_restricted 단일 술어 (a안 U-2①, 모든 경로 공유 정의).
|
||||
|
||||
색인은 허용하되 restricted=true(구매 전자책·유료자료)의 verbatim span 이 RAG 증거·발행물
|
||||
(검색/ask·digest·morning_briefing·study 풀이)에 들어가는 모든 경로를 구조적으로 차단.
|
||||
경로마다 술어를 복붙하지 않고 이 한 정의를 공유 — 가드 누락/드리프트 방지
|
||||
([[feedback_structural_integrity_over_path_discipline]]).
|
||||
개인 파일 열람(GET /documents/{id}?download)은 a안상 허용 = 미적용.
|
||||
|
||||
두 표현(raw SQL / ORM)은 의미 동일: restricted 부재·false·extract_meta NULL = COALESCE 로
|
||||
미제외(redistribute=false 여도 restricted 부재면 미제외 — redistribute≠restricted 가 핵심).
|
||||
"""
|
||||
|
||||
|
||||
def restricted_exclude_sql(alias: str = "") -> str:
|
||||
"""raw text() 쿼리용 bare 술어('AND' 미포함). alias='' = 컬럼 직접 참조."""
|
||||
p = (alias + ".") if alias else ""
|
||||
return f"COALESCE({p}extract_meta -> 'license' ->> 'restricted', 'false') <> 'true'"
|
||||
|
||||
|
||||
def restricted_exclude_orm():
|
||||
"""SQLAlchemy ORM .where() 절 — restricted_exclude_sql 과 동일 의미(JSONB extract_meta)."""
|
||||
from sqlalchemy import func
|
||||
|
||||
from models.document import Document
|
||||
|
||||
return func.coalesce(
|
||||
Document.extract_meta["license"]["restricted"].astext, "false"
|
||||
) != "true"
|
||||
@@ -26,8 +26,11 @@ PR-MacBook-RAG-Backend-1 부터 `services.llm.QwenMacBookBackend` 는 별 endpoi
|
||||
- **fallback(Claude Sonnet 4 API) 경로는 gate 제외**. PR #20 이후 fallback = Claude API. 단 현재
|
||||
구현상 `AIClient._call_chat` 내부에서 primary→fallback 전환이 일어나므로
|
||||
fallback도 gate 점유 상태로 실행된다. 허용 가능(fallback 빈도 낮음).
|
||||
- **MLX concurrency는 `MLX_CONCURRENCY = 1` 고정**. 모델이 바뀌어도 single-
|
||||
inference 특성이 깨지지 않는 한 이 값을 올리지 말 것.
|
||||
- ~~**MLX concurrency는 `MLX_CONCURRENCY = 1` 고정**~~ → **2026-06-12 개정**:
|
||||
구 룰의 전제(서버 = single-inference)가 소멸 — 현 mlx_vlm server 는 continuous
|
||||
batching 으로 동시 스트림 흡수(실측). 상한은 config `pipeline.mlx_gate_concurrency`
|
||||
(기본 1, 운영 2). **게이트 자체(상한+우선순위 큐)는 영구 유지** — thundering herd
|
||||
(23 concurrent → 22 timeout 사고) 방지는 계속 이 상한이 담당. 무제한 금지.
|
||||
|
||||
## 우선순위 정책 (B-1, 2026-05-17)
|
||||
|
||||
@@ -80,8 +83,22 @@ from core.utils import setup_logger
|
||||
|
||||
logger = setup_logger("llm_gate")
|
||||
|
||||
# MLX primary는 single-inference → 1
|
||||
MLX_CONCURRENCY = 1
|
||||
|
||||
def _capacity() -> int:
|
||||
"""게이트 동시 실행 상한 — config.yaml `pipeline.mlx_gate_concurrency` (기본 1).
|
||||
|
||||
2026-06-12 일반화: "MLX_CONCURRENCY = 1 고정" 영구 룰의 전제(구 서버 = single-
|
||||
inference, 23 concurrent → 22 timeout 실측)가 소멸 — 현 mlx_vlm server 는
|
||||
continuous batching 으로 동시 스트림을 흡수(2026-06-11 밤 6~8 concurrent 실측
|
||||
정상). 게이트 자체(상한 + 우선순위)는 유지하고 상한만 config 로 — thundering
|
||||
herd 재발 방지는 이 상한이 계속 담당한다. 런타임 매 acquire 시 조회라
|
||||
config 변경 + 프로세스 재기동으로 반영, 테스트는 settings monkeypatch.
|
||||
"""
|
||||
from core.config import settings
|
||||
try:
|
||||
return max(1, int(getattr(settings, "mlx_gate_concurrency", 1)))
|
||||
except (TypeError, ValueError):
|
||||
return 1
|
||||
|
||||
# Background waiter wait_ms 가 이 값 초과 시 WARN (starvation 신호, aging mitigation 은 Phase 2)
|
||||
STARVATION_WARN_MS = 300_000 # 5 min
|
||||
@@ -101,7 +118,7 @@ DEFAULT_PRIORITY: Priority = Priority.BACKGROUND
|
||||
# Tuple format: (priority: int, seq: int, future: asyncio.Future, enqueue_ts: float)
|
||||
_waiters: list[tuple[int, int, asyncio.Future, float]] = []
|
||||
_seq = itertools.count()
|
||||
_inflight: bool = False
|
||||
_inflight_n: int = 0 # 동시 실행 수 (구 bool — capacity 일반화로 카운터)
|
||||
_lock: asyncio.Lock | None = None
|
||||
|
||||
|
||||
@@ -143,7 +160,7 @@ async def acquire_mlx_gate(
|
||||
|
||||
⚠ `asyncio.timeout` 은 반드시 gate 안쪽 (Future await 후) 에 둘 것.
|
||||
"""
|
||||
global _inflight, _waiters
|
||||
global _inflight_n, _waiters
|
||||
|
||||
lock = _get_lock()
|
||||
seq = next(_seq)
|
||||
@@ -152,9 +169,9 @@ async def acquire_mlx_gate(
|
||||
fut: asyncio.Future | None = None
|
||||
|
||||
async with lock:
|
||||
if not _inflight and not _waiters:
|
||||
if _inflight_n < _capacity() and not _waiters:
|
||||
# fast path — 즉시 inflight 진입, Future 생성 안 함
|
||||
_inflight = True
|
||||
_inflight_n += 1
|
||||
else:
|
||||
# 대기열 진입
|
||||
fut = asyncio.get_event_loop().create_future()
|
||||
@@ -194,8 +211,8 @@ async def acquire_mlx_gate(
|
||||
async with lock:
|
||||
next_fut = _dispatch_next_locked()
|
||||
if next_fut is None:
|
||||
_inflight = False
|
||||
# _inflight 는 True 유지 (다음 waiter 가 진입 예정)
|
||||
_inflight_n = max(0, _inflight_n - 1)
|
||||
# next_fut 가 있으면 슬롯 handover — 카운트 유지 (다음 waiter 가 진입 예정)
|
||||
logger.debug(
|
||||
"mlx_gate release duration_ms=%.0f priority=%s seq=%d",
|
||||
duration_ms, priority.name, seq,
|
||||
@@ -226,8 +243,11 @@ def get_mlx_gate():
|
||||
|
||||
|
||||
def gate_status() -> dict:
|
||||
"""현재 gate 점유 스냅샷 (read-only, lock-free 근사치 — UI 표시용)."""
|
||||
return {"inflight": _inflight, "waiters": len(_waiters)}
|
||||
"""현재 gate 점유 스냅샷 (read-only, lock-free 근사치 — UI 표시용).
|
||||
|
||||
inflight = 동시 실행 수(int). 기존 소비자(eid status)는 bool() 캐스팅이라 호환.
|
||||
"""
|
||||
return {"inflight": _inflight_n, "waiters": len(_waiters)}
|
||||
|
||||
|
||||
# ── Test helpers (conftest reset) ────────────────────────────────────────────
|
||||
@@ -235,8 +255,8 @@ def gate_status() -> dict:
|
||||
|
||||
def _reset_for_test() -> None:
|
||||
"""테스트 fixture 가 fresh loop 마다 호출. production code 에서 사용 X."""
|
||||
global _waiters, _inflight, _lock, _seq
|
||||
global _waiters, _inflight_n, _lock, _seq
|
||||
_waiters = []
|
||||
_inflight = False
|
||||
_inflight_n = 0
|
||||
_lock = None
|
||||
_seq = itertools.count()
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
"""Refusal gate — multi-signal fusion (Phase 3.5a).
|
||||
|
||||
Score gate (deterministic) + classifier verdict (semantic, binary) 를 독립 평가 후 합성.
|
||||
Classifier 부재 시 3-tier conservative fallback.
|
||||
|
||||
P1 실측 결과: exaone ternary 불안정 → binary (sufficient/insufficient) 로 축소.
|
||||
"full" vs "partial" 구분은 grounding check (intent alignment) 가 담당.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, Literal
|
||||
|
||||
from core.utils import setup_logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .classifier_service import ClassifierResult
|
||||
|
||||
logger = setup_logger("refusal_gate")
|
||||
|
||||
# Placeholder thresholds — Phase 3.5b 에서 실측 기반 tuning
|
||||
# AND 조건이라 false refusal 방어됨 (둘 다 만족해야 refuse)
|
||||
SCORE_MAX_REFUSE = 0.25
|
||||
SCORE_AGG_REFUSE = 0.70
|
||||
|
||||
# Conservative fallback tiers (classifier 부재 시)
|
||||
CONSERVATIVE_WEAK = 0.35
|
||||
CONSERVATIVE_MID = 0.55
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class RefusalDecision:
|
||||
refused: bool
|
||||
confidence_cap: Literal["high", "medium", "low"] | None # None = no cap
|
||||
rule_triggered: str | None # 디버깅: 어느 signal 이 결정에 기여?
|
||||
|
||||
|
||||
def decide(
|
||||
rerank_scores: list[float],
|
||||
classifier: ClassifierResult | None,
|
||||
) -> RefusalDecision:
|
||||
"""Multi-signal fusion. Binary classifier verdict 기반.
|
||||
|
||||
Returns:
|
||||
RefusalDecision. refused=True 이면 synthesis skip.
|
||||
confidence_cap 은 synthesis 결과의 confidence 에 upper bound 적용.
|
||||
"""
|
||||
max_score = max(rerank_scores) if rerank_scores else 0.0
|
||||
agg_top3 = sum(sorted(rerank_scores, reverse=True)[:3])
|
||||
|
||||
score_gate_fails = (
|
||||
max_score < SCORE_MAX_REFUSE and agg_top3 < SCORE_AGG_REFUSE
|
||||
)
|
||||
|
||||
# ── Classifier 사용 가능 (정상 경로) ──
|
||||
if classifier and classifier.verdict is not None:
|
||||
if classifier.verdict == "insufficient":
|
||||
# Evidence quality override: classifier 가 insufficient 라 해도
|
||||
# evidence 가 충분히 좋으면 override (토론 8라운드 합의)
|
||||
# (evidence quality 는 이 함수 밖에서 별도 체크 — caller 에서 처리)
|
||||
logger.info(
|
||||
"refusal gate: classifier=insufficient max=%.2f agg=%.2f",
|
||||
max_score, agg_top3,
|
||||
)
|
||||
return RefusalDecision(
|
||||
refused=True,
|
||||
confidence_cap=None,
|
||||
rule_triggered="classifier_insufficient",
|
||||
)
|
||||
if score_gate_fails:
|
||||
logger.info(
|
||||
"refusal gate: score_low max=%.2f agg=%.2f classifier=%s",
|
||||
max_score, agg_top3, classifier.verdict,
|
||||
)
|
||||
return RefusalDecision(
|
||||
refused=True,
|
||||
confidence_cap=None,
|
||||
rule_triggered="score_low",
|
||||
)
|
||||
# Classifier says sufficient → proceed
|
||||
return RefusalDecision(
|
||||
refused=False,
|
||||
confidence_cap=None,
|
||||
rule_triggered=None,
|
||||
)
|
||||
|
||||
# ── Classifier 부재 → 3-tier conservative ──
|
||||
if max_score < CONSERVATIVE_WEAK:
|
||||
return RefusalDecision(
|
||||
refused=True,
|
||||
confidence_cap=None,
|
||||
rule_triggered="conservative_refuse(no_classifier)",
|
||||
)
|
||||
if max_score < CONSERVATIVE_MID:
|
||||
return RefusalDecision(
|
||||
refused=False,
|
||||
confidence_cap="low",
|
||||
rule_triggered="conservative_low(no_classifier)",
|
||||
)
|
||||
return RefusalDecision(
|
||||
refused=False,
|
||||
confidence_cap="medium",
|
||||
rule_triggered="conservative_medium(no_classifier)",
|
||||
)
|
||||
@@ -0,0 +1,55 @@
|
||||
"""안전 자료실 C-1 후속 — 검색 결과 wrapper decoration (version_status + facets).
|
||||
|
||||
엔드포인트 wrapper 에서 run_search() 결과에 1회 적용 — 검색 코어(run_search) 무접촉(r3).
|
||||
- version_status: 법령 결과(material_type='law')에 legal_meta.version_status
|
||||
(current/superseded/pending/repealed) 부착. legal_meta.document_id 1:0..1 위성 →
|
||||
매핑 없는 law(레거시 등)는 None 유지. law 결과 없으면 query skip.
|
||||
- facets: top-K 결과 내 분류 축(material_type/jurisdiction/version_status) 분포 라벨(r2-M4).
|
||||
facets=true 일 때만 계산(미요청 시 None = byte 불변·ranking 무관).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import Counter
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from api.search import SearchResult
|
||||
|
||||
|
||||
async def decorate_version_status(
|
||||
session: AsyncSession, results: list["SearchResult"]
|
||||
) -> None:
|
||||
"""법령 결과에 legal_meta.version_status 부착 (in-place). law 결과 없으면 query skip."""
|
||||
law_ids = [r.id for r in results if r.material_type == "law" and r.id is not None]
|
||||
if not law_ids:
|
||||
return
|
||||
rows = await session.execute(
|
||||
text(
|
||||
"SELECT document_id, version_status FROM legal_meta "
|
||||
"WHERE document_id = ANY(:ids)"
|
||||
),
|
||||
{"ids": law_ids},
|
||||
)
|
||||
status_by_id = {row.document_id: row.version_status for row in rows}
|
||||
for r in results:
|
||||
if r.id in status_by_id:
|
||||
r.version_status = status_by_id[r.id]
|
||||
|
||||
|
||||
def compute_facets(results: list["SearchResult"]) -> dict[str, dict[str, int]]:
|
||||
"""top-K 결과의 분류 축 분포 라벨. None 값은 제외(present 라벨만, 빈 축은 미포함)."""
|
||||
axes = {
|
||||
"material_type": [r.material_type for r in results],
|
||||
"jurisdiction": [r.jurisdiction for r in results],
|
||||
"version_status": [getattr(r, "version_status", None) for r in results],
|
||||
}
|
||||
facets: dict[str, dict[str, int]] = {}
|
||||
for axis, vals in axes.items():
|
||||
counter = Counter(v for v in vals if v is not None)
|
||||
if counter:
|
||||
facets[axis] = dict(counter.most_common())
|
||||
return facets
|
||||
@@ -24,6 +24,7 @@ import asyncio
|
||||
import hashlib
|
||||
import re
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from sqlalchemy import text
|
||||
@@ -53,18 +54,111 @@ 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 임베딩 후보(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 기록과 동일해야 함 —
|
||||
# 문구 변경 = 저장=조회 불변식 위반과 동급. 쿼리 측 전용, 문서 적재는 plain).
|
||||
QWEN3_QUERY_INSTRUCT = (
|
||||
"Instruct: Given a web search query, retrieve relevant passages that answer the query"
|
||||
"\nQuery: "
|
||||
)
|
||||
|
||||
# ─── 안전 자료실 C-1: 분류 축 명시 필터 (3 leg 동등, byte 불변) ───────────────
|
||||
# 미지정(active=False) 시 모든 SQL 절이 빈 문자열 → 기존 SQL byte 불변(run_eval 회귀 0).
|
||||
# year 는 published_date NULL fallback created_at (freshness 와 동일 COALESCE 사상).
|
||||
@dataclass
|
||||
class AxisFilter:
|
||||
material_types: list[str] | None = None # CSV → list, material_type = ANY
|
||||
jurisdiction: str | None = None
|
||||
year_from: int | None = None
|
||||
year_to: int | None = None
|
||||
domain_buckets: list[str] | None = None # 377: domain_bucket = ANY (도메인 스코프)
|
||||
exclude_buckets: list[str] | None = None # 377: domain_bucket <> ALL (예: News 제외)
|
||||
cloud_egress: bool = False # 갭2: 클라우드 소비자 cloud-eligibility allowlist 강제(토큰 claim 유래)
|
||||
|
||||
def active(self) -> bool:
|
||||
return bool(self.material_types or self.jurisdiction
|
||||
or self.year_from is not None or self.year_to is not None
|
||||
or self.domain_buckets or self.exclude_buckets
|
||||
or self.cloud_egress)
|
||||
|
||||
|
||||
def _axis_sql(alias: str, af: "AxisFilter | None", params: dict) -> str:
|
||||
"""alias 기준 axis 필터 SQL — 미지정 시 '' (byte 불변). 반환 형태 ' AND ...'.
|
||||
|
||||
alias='' 이면 컬럼 직접 참조(단일 테이블 FROM documents 경로). 파라미터는 af_ prefix
|
||||
로 호출측 기존 bind 와 충돌 방지.
|
||||
"""
|
||||
if af is None or not af.active():
|
||||
return ""
|
||||
p = (alias + ".") if alias else ""
|
||||
cl: list[str] = []
|
||||
if af.material_types:
|
||||
cl.append(f"{p}material_type = ANY(:af_mt)")
|
||||
params["af_mt"] = af.material_types
|
||||
if af.jurisdiction:
|
||||
cl.append(f"{p}jurisdiction = :af_jur")
|
||||
params["af_jur"] = af.jurisdiction
|
||||
if af.year_from is not None:
|
||||
cl.append(f"COALESCE({p}published_date, {p}created_at::date) >= make_date(:af_yf, 1, 1)")
|
||||
params["af_yf"] = af.year_from
|
||||
if af.year_to is not None:
|
||||
cl.append(f"COALESCE({p}published_date, {p}created_at::date) <= make_date(:af_yt, 12, 31)")
|
||||
params["af_yt"] = af.year_to
|
||||
if af.domain_buckets:
|
||||
cl.append(f"{p}domain_bucket = ANY(:af_db)")
|
||||
params["af_db"] = af.domain_buckets
|
||||
if af.exclude_buckets:
|
||||
cl.append(f"{p}domain_bucket <> ALL(:af_xdb)")
|
||||
params["af_xdb"] = af.exclude_buckets
|
||||
if af.cloud_egress:
|
||||
# 갭2 클라우드 egress allowlist(default-deny). restricted 는 _license_sql 별도 차단.
|
||||
cl.append(
|
||||
f"({p}data_origin = 'external' OR ("
|
||||
f"{p}data_origin = 'work' "
|
||||
f"AND {p}domain_bucket IN ('Engineering','Safety','Law') "
|
||||
f"AND ({p}source_channel IS NULL OR {p}source_channel::text NOT IN ('voice','chat','memo')) "
|
||||
f"AND {p}category::text IS DISTINCT FROM 'memo' "
|
||||
f"AND ({p}user_note IS NULL OR {p}user_note = '')))"
|
||||
)
|
||||
return " AND " + " AND ".join(cl)
|
||||
|
||||
|
||||
# ─── 안전 자료실 B-4: licensed_restricted 단일 술어 (a안 U-2① — 항상 적용) ──────
|
||||
def _license_sql(alias: str) -> str:
|
||||
"""licensed_restricted(extract_meta.license.restricted=true) 문서를 retrieval 에서 제외.
|
||||
|
||||
a안: 색인은 허용하되, 구매 전자책/유료자료의 verbatim span 이 RAG 증거·digest 발행에
|
||||
들어가는 경로를 구조적으로 차단. 이 단일 술어를 모든 retrieval leg + digest loader 가
|
||||
공유 — 경로별 가드 누락 방지([[feedback_structural_integrity_over_path_discipline]]).
|
||||
개인 파일 열람(GET /documents/{id}?download)은 a안상 허용이라 미적용.
|
||||
|
||||
axis 필터(조건부)와 달리 항상 적용. restricted 부재/false = COALESCE 로 미제외 →
|
||||
기존 코퍼스(restricted=true 0건)에서 결과 불변. 반환 ' AND ...' (alias='' = 컬럼 직접).
|
||||
술어 정의 = license_filter.restricted_exclude_sql 공유(digest/briefing/study 풀이와 단일 source).
|
||||
"""
|
||||
from services.search.license_filter import restricted_exclude_sql
|
||||
_p = (alias + ".") if alias else ""
|
||||
# ASME clause-KB(379): clause docs (doc_kind='clause') = read/nav/backlink only, excluded from retrieval/digest legs.
|
||||
return " AND " + restricted_exclude_sql(alias) + f" AND {_p}doc_kind = 'standard'"
|
||||
|
||||
|
||||
def cloud_eligible_doc_sql(alias: str = "") -> str:
|
||||
"""단일 문서가 cloud 소비자(예: Claude/MCP)에게 노출 가능한가 = search retrieval 과
|
||||
동일한 egress allowlist(갭2) + license 제한(B-4) 결합 술어. fetch_document(cloud) 가
|
||||
search 와 byte-동일 게이트를 공유하도록 단일 source([[feedback_structural_integrity_over_path_discipline]]).
|
||||
|
||||
cloud_egress·license leg 모두 bind 파라미터 없는 리터럴 술어라 호출측 추가 params 불요.
|
||||
주의: _license_sql 은 소유자 단건 다운로드엔 미적용(a안)이지만, cloud 노출은 구매 전자책
|
||||
verbatim 누출을 막아야 하므로 여기선 항상 적용 = search 와 동일(local 토큰은 이 게이트 미발동).
|
||||
반환 ' AND (egress allowlist) AND (license)' (alias='' = 컬럼 직접 참조). default-deny."""
|
||||
return _axis_sql(alias, AxisFilter(cloud_egress=True), {}) + _license_sql(alias)
|
||||
|
||||
|
||||
# 2단계 gate (R2-B1) — SQL string interpolation 직전 final allowlist.
|
||||
_VALID_DOCS_TABLE = re.compile(r"^(documents|documents_cand_[a-z0-9_]+)$")
|
||||
# corpus_chunks = document_chunks WHERE in_corpus=true 뷰 (Hier-Decomp-1 c2 choke point).
|
||||
@@ -137,6 +231,34 @@ async def _embed_query_via_tei(endpoint: str, text_: str) -> list[float] | None:
|
||||
return None
|
||||
|
||||
|
||||
async def _embed_query_via_ollama(cfg: dict, text_: str) -> list[float] | None:
|
||||
"""Phase 2A 후보 쿼리 임베딩 — Ollama /api/embed + 비대칭 instruct prefix.
|
||||
|
||||
쿼리 측 전용: QWEN3_QUERY_INSTRUCT 를 선두에 붙인다 (문서 적재 = plain).
|
||||
embed_dimensions 지정(qwen4m) 시 Ollama dimensions 옵션 = MRL truncate+재정규화
|
||||
(G-1 fixture: 1024 출력 L2=1.0 실측). cache 미사용 — slug 별 분포 상이.
|
||||
"""
|
||||
if not text_:
|
||||
return None
|
||||
import httpx
|
||||
body: dict = {"model": cfg["embed_model"], "input": [QWEN3_QUERY_INSTRUCT + text_]}
|
||||
if cfg.get("embed_dimensions"):
|
||||
body["dimensions"] = cfg["embed_dimensions"]
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=60.0) as c:
|
||||
r = await c.post(cfg["embed_endpoint"], json=body)
|
||||
r.raise_for_status()
|
||||
embs = r.json().get("embeddings")
|
||||
if not isinstance(embs, list) or not embs or not isinstance(embs[0], list):
|
||||
raise ValueError("unexpected /api/embed shape")
|
||||
return embs[0]
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"candidate ollama embed failed model=%s err=%r", cfg.get("embed_model"), exc
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _query_embed_key(text_: str) -> str:
|
||||
return hashlib.sha256(f"{text_}|bge-m3".encode("utf-8")).hexdigest()
|
||||
|
||||
@@ -174,7 +296,7 @@ def query_embed_cache_stats() -> dict[str, int]:
|
||||
|
||||
|
||||
async def search_text(
|
||||
session: AsyncSession, query: str, limit: int
|
||||
session: AsyncSession, query: str, limit: int, *, axis: "AxisFilter | None" = None
|
||||
) -> list["SearchResult"]:
|
||||
"""FTS + trigram 필드별 가중치 검색 (Phase 1.2-B UNION 분해).
|
||||
|
||||
@@ -205,8 +327,12 @@ async def search_text(
|
||||
# SQLAlchemy async session 내 두 execute는 같은 connection 사용
|
||||
await session.execute(text("SELECT set_limit(0.15)"))
|
||||
|
||||
_params: dict[str, Any] = {"q": query, "limit": limit}
|
||||
# license(항상) + axis(조건부). license 가 항상 ' AND ...' 이라 WHERE 는 늘 존재.
|
||||
_where = _license_sql("d") + _axis_sql("d", axis, _params)
|
||||
|
||||
result = await session.execute(
|
||||
text("""
|
||||
text(f"""
|
||||
WITH candidates AS (
|
||||
-- title trigram (idx_documents_title_trgm)
|
||||
SELECT id FROM documents
|
||||
@@ -238,7 +364,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',
|
||||
@@ -246,7 +372,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
|
||||
@@ -257,15 +383,17 @@ 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
|
||||
END AS match_reason,
|
||||
d.material_type, d.jurisdiction, d.published_date
|
||||
FROM documents d
|
||||
JOIN candidates c ON d.id = c.id
|
||||
WHERE{_where[4:]}
|
||||
ORDER BY score DESC
|
||||
LIMIT :limit
|
||||
"""),
|
||||
{"q": query, "limit": limit},
|
||||
_params,
|
||||
)
|
||||
return [SearchResult(**row._mapping) for row in result]
|
||||
|
||||
@@ -280,6 +408,7 @@ async def search_vector(
|
||||
snapshot_chunk_id_max: int | None = None,
|
||||
corpus_variant: str | None = None,
|
||||
exact_knn: bool = False,
|
||||
axis: "AxisFilter | None" = None,
|
||||
) -> list["SearchResult"]:
|
||||
"""Hybrid 벡터 검색 — doc + chunks 동시 retrieval (Phase 1.2-G).
|
||||
|
||||
@@ -323,7 +452,10 @@ async def search_vector(
|
||||
else:
|
||||
docs_table = cfg["docs_table"]
|
||||
chunks_table = cfg["chunks_table"]
|
||||
query_embedding = await _embed_query_via_tei(cfg["embed_endpoint"], query)
|
||||
if cfg.get("embed_kind") == "ollama":
|
||||
query_embedding = await _embed_query_via_ollama(cfg, query)
|
||||
else:
|
||||
query_embedding = await _embed_query_via_tei(cfg["embed_endpoint"], query)
|
||||
|
||||
logger.info(
|
||||
"[embedding-dispatch] backend=%s docs_table=%s chunks_table=%s snapshot_doc_id_max=%s "
|
||||
@@ -351,6 +483,7 @@ async def search_vector(
|
||||
docs_table=docs_table,
|
||||
snapshot_doc_id_max=snapshot_doc_id_max,
|
||||
exact_knn=exact_knn,
|
||||
axis=axis,
|
||||
)
|
||||
|
||||
async def _chunks_call() -> list["SearchResult"]:
|
||||
@@ -360,6 +493,7 @@ async def search_vector(
|
||||
chunks_table=chunks_table,
|
||||
snapshot_chunk_id_max=snapshot_chunk_id_max,
|
||||
exact_knn=exact_knn,
|
||||
axis=axis,
|
||||
)
|
||||
|
||||
doc_results, chunk_results = await asyncio.gather(_docs_call(), _chunks_call())
|
||||
@@ -375,6 +509,7 @@ async def _search_vector_docs(
|
||||
docs_table: str = "documents",
|
||||
snapshot_doc_id_max: int | None = None,
|
||||
exact_knn: bool = False,
|
||||
axis: "AxisFilter | None" = None,
|
||||
) -> list["SearchResult"]:
|
||||
"""documents (또는 documents_cand_<slug>).embedding 직접 검색.
|
||||
|
||||
@@ -399,28 +534,34 @@ async def _search_vector_docs(
|
||||
if snapshot_doc_id_max is not None:
|
||||
snapshot_clause = " AND id <= :snapshot_doc_id_max"
|
||||
params["snapshot_doc_id_max"] = snapshot_doc_id_max
|
||||
axis_clause = _axis_sql("", axis, params) # alias 없음 (단일 FROM documents)
|
||||
license_clause = _license_sql("") # B-4: restricted 항상 제외
|
||||
sql = f"""
|
||||
SELECT id, title, ai_domain, ai_summary, file_format,
|
||||
(1 - (embedding <=> cast(:embedding AS vector))) AS score,
|
||||
left(extracted_text, 1200) AS snippet,
|
||||
'vector_doc' AS match_reason,
|
||||
NULL::bigint AS chunk_id, NULL::integer AS chunk_index, NULL::text AS section_title
|
||||
NULL::bigint AS chunk_id, NULL::integer AS chunk_index, NULL::text AS section_title,
|
||||
material_type, jurisdiction, published_date
|
||||
FROM documents
|
||||
WHERE embedding IS NOT NULL AND deleted_at IS NULL{snapshot_clause}
|
||||
WHERE embedding IS NOT NULL AND deleted_at IS NULL{snapshot_clause}{axis_clause}{license_clause}
|
||||
ORDER BY embedding <=> cast(:embedding AS vector)
|
||||
LIMIT :limit
|
||||
"""
|
||||
else:
|
||||
# candidate: docs_table 은 (doc_id, embed_input, embed_input_hash, embedding) 만 보유 → JOIN documents
|
||||
axis_clause = _axis_sql("d", axis, params)
|
||||
license_clause = _license_sql("d") # B-4: restricted 항상 제외
|
||||
sql = f"""
|
||||
SELECT d.id, d.title, d.ai_domain, d.ai_summary, d.file_format,
|
||||
(1 - (c.embedding <=> cast(:embedding AS vector))) AS score,
|
||||
left(d.extracted_text, 1200) AS snippet,
|
||||
'vector_doc' AS match_reason,
|
||||
NULL::bigint AS chunk_id, NULL::integer AS chunk_index, NULL::text AS section_title
|
||||
NULL::bigint AS chunk_id, NULL::integer AS chunk_index, NULL::text AS section_title,
|
||||
d.material_type, d.jurisdiction, d.published_date
|
||||
FROM {docs_table} c
|
||||
JOIN documents d ON d.id = c.doc_id
|
||||
WHERE d.deleted_at IS NULL
|
||||
WHERE d.deleted_at IS NULL{axis_clause}{license_clause}
|
||||
ORDER BY c.embedding <=> cast(:embedding AS vector)
|
||||
LIMIT :limit
|
||||
"""
|
||||
@@ -436,6 +577,7 @@ async def _search_vector_chunks(
|
||||
chunks_table: str = "document_chunks",
|
||||
snapshot_chunk_id_max: int | None = None,
|
||||
exact_knn: bool = False,
|
||||
axis: "AxisFilter | None" = None,
|
||||
) -> list["SearchResult"]:
|
||||
"""document_chunks (또는 document_chunks_cand_<slug>).embedding window partition.
|
||||
|
||||
@@ -461,12 +603,25 @@ async def _search_vector_chunks(
|
||||
snapshot_clause = " AND c.id <= :snapshot_chunk_id_max"
|
||||
params["snapshot_chunk_id_max"] = snapshot_chunk_id_max
|
||||
|
||||
# C-1: axis 필터는 inner topk 에 JOIN (R6 결정 — outer post-filter 면 ANN top-:inner_k
|
||||
# 후보를 뽑은 뒤 거르므로 좁은 필터(GB 법령 등)에서 후보 붕괴). 미지정 시 JOIN 없음 = byte 불변.
|
||||
if axis and axis.active():
|
||||
chunk_join = " JOIN documents df ON df.id = c.doc_id"
|
||||
chunk_axis = _axis_sql("df", axis, params)
|
||||
else:
|
||||
chunk_join = ""
|
||||
chunk_axis = ""
|
||||
|
||||
# B-4: restricted 제외 — outer 가 documents d 를 항상 JOIN 하므로 post-rank 위치.
|
||||
# restricted 는 소수(구매자료)라 inner topk 후 제외해도 candidate collapse 없음(axis 와 상이).
|
||||
license_clause = _license_sql("d")
|
||||
|
||||
sql = f"""
|
||||
WITH topk AS (
|
||||
SELECT c.id AS chunk_id, c.doc_id, c.chunk_index, c.section_title, c.text,
|
||||
c.embedding <=> cast(:embedding AS vector) AS dist
|
||||
FROM {chunks_table} c
|
||||
WHERE c.embedding IS NOT NULL{snapshot_clause}
|
||||
FROM {chunks_table} c{chunk_join}
|
||||
WHERE c.embedding IS NOT NULL{snapshot_clause}{chunk_axis}
|
||||
ORDER BY c.embedding <=> cast(:embedding AS vector)
|
||||
LIMIT :inner_k
|
||||
),
|
||||
@@ -479,10 +634,12 @@ async def _search_vector_chunks(
|
||||
d.ai_summary AS ai_summary, d.file_format AS file_format,
|
||||
(1 - r.dist) AS score, left(r.text, 1200) AS snippet,
|
||||
'vector_chunk' AS match_reason,
|
||||
r.chunk_id AS chunk_id, r.chunk_index AS chunk_index, r.section_title AS section_title
|
||||
r.chunk_id AS chunk_id, r.chunk_index AS chunk_index, r.section_title AS section_title,
|
||||
d.material_type AS material_type, d.jurisdiction AS jurisdiction,
|
||||
d.published_date AS published_date
|
||||
FROM ranked r
|
||||
JOIN documents d ON d.id = r.doc_id
|
||||
WHERE r.rn <= 2 AND d.deleted_at IS NULL
|
||||
WHERE r.rn <= 2 AND d.deleted_at IS NULL{license_clause}
|
||||
ORDER BY r.dist
|
||||
LIMIT :limit
|
||||
"""
|
||||
|
||||
@@ -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,
|
||||
@@ -47,6 +49,7 @@ from .rerank_service import (
|
||||
rerank_chunks,
|
||||
)
|
||||
from .retrieval_service import (
|
||||
AxisFilter,
|
||||
compress_chunks_to_docs,
|
||||
search_text,
|
||||
search_vector,
|
||||
@@ -148,6 +151,7 @@ async def run_search(
|
||||
rewrite_backend: str | None = None,
|
||||
corpus_variant: str | None = None,
|
||||
exact_knn: bool = False,
|
||||
axis: AxisFilter | None = None,
|
||||
) -> PipelineResult:
|
||||
"""검색 파이프라인 실행.
|
||||
|
||||
@@ -186,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] = {}
|
||||
@@ -275,6 +280,7 @@ async def run_search(
|
||||
snapshot_chunk_id_max=snapshot_chunk_id_max,
|
||||
corpus_variant=corpus_variant,
|
||||
exact_knn=exact_knn,
|
||||
axis=axis,
|
||||
)
|
||||
timing["vector_ms"] = (time.perf_counter() - t0) * 1000
|
||||
if not raw_chunks:
|
||||
@@ -284,7 +290,7 @@ async def run_search(
|
||||
results = vector_results
|
||||
else:
|
||||
t0 = time.perf_counter()
|
||||
text_results = await search_text(session, q, limit)
|
||||
text_results = await search_text(session, q, limit, axis=axis)
|
||||
timing["text_ms"] = (time.perf_counter() - t0) * 1000
|
||||
|
||||
if mode == "hybrid":
|
||||
@@ -306,6 +312,7 @@ async def run_search(
|
||||
snapshot_chunk_id_max=snapshot_chunk_id_max,
|
||||
corpus_variant=corpus_variant,
|
||||
exact_knn=exact_knn,
|
||||
axis=axis,
|
||||
)
|
||||
timing["vector_ms"] = (time.perf_counter() - t1) * 1000
|
||||
|
||||
@@ -458,6 +465,10 @@ def _rrf_fuse_variants(
|
||||
score=rrf_score,
|
||||
snippet=doc.snippet,
|
||||
match_reason=f"{doc.match_reason}+multi_query_rrf",
|
||||
# C-1: 분류 축 메타 전파 (SearchResult 재구성 지점 — fusion 2곳과 동기)
|
||||
material_type=doc.material_type,
|
||||
jurisdiction=doc.jurisdiction,
|
||||
published_date=doc.published_date,
|
||||
))
|
||||
return fused[:limit]
|
||||
|
||||
@@ -528,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).
|
||||
|
||||
@@ -571,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
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ logger = setup_logger("synthesis")
|
||||
|
||||
# ─── 상수 (plan 영구 룰) ─────────────────────────────────
|
||||
PROMPT_VERSION = "v2"
|
||||
LLM_TIMEOUT_MS = 30000 # 2026-05-17 B-3: 15s 시 동시 부하 (Mac mini 26B classifier+evidence+synthesis serialized) 빈발 timeout — classifier (30s) 와 align
|
||||
LLM_TIMEOUT_MS = 120000 # 2026-06-11 Qwen3.6-27B-6bit 전환: 프리필 ~112 tok/s·디코드 ~11.7 tok/s 실측 — 30s 면 synthesis(답변 본체) 상시 timeout. synthesis 는 graceful skip 불가(=답변 실패)라 단독 상향, config ask.backend.timeout_read_s=120 와 align
|
||||
CACHE_TTL = 3600 # 1h (answer 는 원문 변경에 민감 → query_analyzer 24h 보다 짧게)
|
||||
CACHE_MAXSIZE = 300
|
||||
MAX_ANSWER_CHARS = 600
|
||||
@@ -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]:
|
||||
|
||||
@@ -1,194 +0,0 @@
|
||||
"""Exaone semantic verifier (Phase 3.5b).
|
||||
|
||||
답변-근거 간 의미적 모순(contradiction) 감지. rule-based grounding_check 가 못 잡는
|
||||
미묘한 모순 포착. classifier 와 동일 패턴: circuit breaker + timeout + fail open.
|
||||
|
||||
## Severity 3단계
|
||||
- strong: direct_negation (완전 모순) → re-gate 교차 자격
|
||||
- medium: numeric_conflict, intent_core_mismatch → confidence 하향 (누적 시 강제 low)
|
||||
- weak: nuance, unsupported_claim → 로깅 + mild confidence 하향
|
||||
|
||||
## 핵심 원칙
|
||||
- **Verifier strong 단독 refuse 금지** — grounding strong 과 교차해야 refuse
|
||||
- **Timeout 3s** — 느리면 없는 게 낫다 (fail open)
|
||||
- MLX gate 미사용 (PR #20 이후 Mac mini 26B endpoint — concurrent 안전성 별 검토)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, Literal
|
||||
|
||||
from ai.client import AIClient, _load_prompt, parse_json_response
|
||||
from core.config import settings
|
||||
from core.utils import setup_logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .evidence_service import EvidenceItem
|
||||
|
||||
logger = setup_logger("verifier")
|
||||
|
||||
LLM_TIMEOUT_MS = 10000 # 2026-05-17 B-3: 3s 시 동시 부하 시 verifier 빈발 skip → grounding 약화. Mac mini 26B 가 verifier-style 짧은 LLM call 도 concurrent 호출 시 3s 초과 빈번 — 10s 로 raise
|
||||
CIRCUIT_THRESHOLD = 5
|
||||
CIRCUIT_RECOVERY_SEC = 60
|
||||
|
||||
_failure_count = 0
|
||||
_circuit_open_until: float | None = None
|
||||
|
||||
# Phase 3.5 B2: numeric_conflict severity promote 실험.
|
||||
# import time 평가 — env 변경 후 process restart 필수 (docker compose restart fastapi).
|
||||
# default=0 (off). production 적용은 B3 FP 검증 통과 후만.
|
||||
_NUMERIC_PROMOTE = os.getenv("VERIFIER_NUMERIC_PROMOTE", "0") == "1"
|
||||
|
||||
# severity 매핑 (프롬프트 "critical"/"minor" → 코드 strong/medium/weak)
|
||||
# Tier 4 (B2): _NUMERIC_PROMOTE=1 일 때 numeric_conflict critical → strong 으로 격상.
|
||||
# minor 는 medium 유지 (FP 위험 분리).
|
||||
_SEVERITY_MAP: dict[str, dict[str, Literal["strong", "medium", "weak"]]] = {
|
||||
"direct_negation": {"critical": "strong", "minor": "strong"},
|
||||
"numeric_conflict": (
|
||||
{"critical": "strong", "minor": "medium"} if _NUMERIC_PROMOTE
|
||||
else {"critical": "medium", "minor": "medium"}
|
||||
),
|
||||
"intent_core_mismatch": {"critical": "medium", "minor": "medium"},
|
||||
"nuance": {"critical": "weak", "minor": "weak"},
|
||||
"unsupported_claim": {"critical": "weak", "minor": "weak"},
|
||||
}
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class Contradiction:
|
||||
"""개별 모순 발견."""
|
||||
type: str # direct_negation / numeric_conflict / intent_core_mismatch / nuance / unsupported_claim
|
||||
severity: Literal["strong", "medium", "weak"]
|
||||
claim: str
|
||||
evidence_ref: str
|
||||
explanation: str
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class VerifierResult:
|
||||
status: Literal["ok", "timeout", "error", "circuit_open", "skipped"]
|
||||
contradictions: list[Contradiction]
|
||||
elapsed_ms: float
|
||||
|
||||
|
||||
try:
|
||||
VERIFIER_PROMPT = _load_prompt("verifier.txt")
|
||||
except FileNotFoundError:
|
||||
VERIFIER_PROMPT = ""
|
||||
logger.warning("verifier.txt not found — verifier will always skip")
|
||||
|
||||
|
||||
def _build_input(
|
||||
answer: str,
|
||||
evidence: list[EvidenceItem],
|
||||
) -> str:
|
||||
"""답변 + evidence spans → 프롬프트."""
|
||||
spans = "\n\n".join(
|
||||
f"[{e.n}] {(e.title or '').strip()}\n{e.span_text}"
|
||||
for e in evidence
|
||||
)
|
||||
return (
|
||||
VERIFIER_PROMPT
|
||||
.replace("{answer}", answer)
|
||||
.replace("{numbered_evidence}", spans)
|
||||
)
|
||||
|
||||
|
||||
def _map_severity(ctype: str, raw_severity: str) -> Literal["strong", "medium", "weak"]:
|
||||
"""type + raw severity → 코드 severity 3단계."""
|
||||
type_map = _SEVERITY_MAP.get(ctype, {"critical": "weak", "minor": "weak"})
|
||||
return type_map.get(raw_severity, "weak")
|
||||
|
||||
|
||||
async def verify(
|
||||
query: str,
|
||||
answer: str,
|
||||
evidence: list[EvidenceItem],
|
||||
) -> VerifierResult:
|
||||
"""답변-근거 semantic 검증. Parallel with grounding_check.
|
||||
|
||||
Returns:
|
||||
VerifierResult. status "ok" 이 아니면 contradictions 빈 리스트 (fail open).
|
||||
"""
|
||||
global _failure_count, _circuit_open_until
|
||||
t_start = time.perf_counter()
|
||||
|
||||
if _circuit_open_until and time.time() < _circuit_open_until:
|
||||
return VerifierResult("circuit_open", [], 0.0)
|
||||
|
||||
if not VERIFIER_PROMPT:
|
||||
return VerifierResult("skipped", [], 0.0)
|
||||
|
||||
if not hasattr(settings.ai, "verifier") or settings.ai.verifier is None:
|
||||
return VerifierResult("skipped", [], 0.0)
|
||||
|
||||
if not answer or not evidence:
|
||||
return VerifierResult("skipped", [], 0.0)
|
||||
|
||||
prompt = _build_input(answer, evidence)
|
||||
client = AIClient()
|
||||
try:
|
||||
async with asyncio.timeout(LLM_TIMEOUT_MS / 1000):
|
||||
raw = await client._request(settings.ai.verifier, prompt)
|
||||
_failure_count = 0
|
||||
except asyncio.TimeoutError:
|
||||
_failure_count += 1
|
||||
if _failure_count >= CIRCUIT_THRESHOLD:
|
||||
_circuit_open_until = time.time() + CIRCUIT_RECOVERY_SEC
|
||||
logger.error(f"verifier circuit OPEN for {CIRCUIT_RECOVERY_SEC}s")
|
||||
logger.warning("verifier timeout")
|
||||
return VerifierResult(
|
||||
"timeout", [],
|
||||
(time.perf_counter() - t_start) * 1000,
|
||||
)
|
||||
except Exception as e:
|
||||
_failure_count += 1
|
||||
if _failure_count >= CIRCUIT_THRESHOLD:
|
||||
_circuit_open_until = time.time() + CIRCUIT_RECOVERY_SEC
|
||||
logger.error(f"verifier circuit OPEN for {CIRCUIT_RECOVERY_SEC}s")
|
||||
logger.warning(f"verifier error: {e}")
|
||||
return VerifierResult(
|
||||
"error", [],
|
||||
(time.perf_counter() - t_start) * 1000,
|
||||
)
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
elapsed_ms = (time.perf_counter() - t_start) * 1000
|
||||
parsed = parse_json_response(raw)
|
||||
if not isinstance(parsed, dict):
|
||||
logger.warning("verifier parse failed raw=%r", (raw or "")[:200])
|
||||
return VerifierResult("error", [], elapsed_ms)
|
||||
|
||||
# contradiction 파싱
|
||||
raw_items = parsed.get("contradictions") or []
|
||||
if not isinstance(raw_items, list):
|
||||
raw_items = []
|
||||
|
||||
results: list[Contradiction] = []
|
||||
for item in raw_items[:5]:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
ctype = item.get("type", "")
|
||||
if ctype not in _SEVERITY_MAP:
|
||||
ctype = "unsupported_claim"
|
||||
raw_sev = item.get("severity", "minor")
|
||||
severity = _map_severity(ctype, raw_sev)
|
||||
claim = str(item.get("claim", ""))[:50]
|
||||
ev_ref = str(item.get("evidence_ref", ""))[:50]
|
||||
explanation = str(item.get("explanation", ""))[:30]
|
||||
results.append(Contradiction(ctype, severity, claim, ev_ref, explanation))
|
||||
|
||||
logger.info(
|
||||
"verifier ok query=%r contradictions=%d strong=%d medium=%d elapsed_ms=%.0f",
|
||||
query[:60],
|
||||
len(results),
|
||||
sum(1 for c in results if c.severity == "strong"),
|
||||
sum(1 for c in results if c.severity == "medium"),
|
||||
elapsed_ms,
|
||||
)
|
||||
return VerifierResult("ok", results, elapsed_ms)
|
||||
@@ -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
|
||||
|
||||
@@ -24,6 +24,7 @@ from models.chunk import DocumentChunk
|
||||
from models.document import Document
|
||||
from models.study_question import StudyQuestion
|
||||
from models.study_topic import StudyTopicDocument
|
||||
from services.search.license_filter import restricted_exclude_orm
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -124,11 +125,14 @@ async def _gather_document_evidence(
|
||||
return []
|
||||
|
||||
# 매핑된 documents 메타 (제목·요약 표기)
|
||||
# B-4: licensed_restricted 제외 → valid_doc_ids 에서 빠지므로 아래 청크 쿼리(doc_id IN)도
|
||||
# 자동 차단. study 풀이 RAG 도 retrieval/digest 와 동일 단일 술어 공유(a안 U-2①).
|
||||
doc_meta_rows = (
|
||||
await session.execute(
|
||||
select(Document.id, Document.title, Document.ai_summary).where(
|
||||
Document.id.in_(doc_ids),
|
||||
Document.deleted_at.is_(None),
|
||||
restricted_exclude_orm(),
|
||||
)
|
||||
)
|
||||
).all()
|
||||
@@ -248,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:
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
"""채점(outcome) 산출 단일 소스 (study-to-viewer P2).
|
||||
|
||||
라이브 attempt 엔드포인트(submit_attempt)와 뷰어 ingest 가 **동일 함수**로 채점 →
|
||||
정오 어휘가 한 곳(서버)에서 결정(plan r2: ingest 는 raw 신호 selected+unsure 만 싣고
|
||||
DS 가 산출 = '무수정 재생'을 실제로 성립시키는 형태). correct_choice 는 항상 현재 DB 값.
|
||||
|
||||
규칙(라이브 study_questions.py:1008-1020 동일):
|
||||
is_unsure=True → (None, False, 'unsure') # unsure 가 정오 override, selected 폐기
|
||||
selected None → ValueError # 선택 없고 unsure 도 아니면 무효(엔드포인트가 처리)
|
||||
그 외 → selected==correct → (selected, is_correct, 'correct'|'wrong')
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def derive_outcome(
|
||||
selected_choice: int | None, is_unsure: bool, correct_choice: int
|
||||
) -> tuple[int | None, bool, str]:
|
||||
"""(selected, is_correct, outcome) 반환. skipped 는 여기서 안 나옴(선택 없으면 호출측이 거부/skip)."""
|
||||
if is_unsure:
|
||||
return None, False, "unsure"
|
||||
if selected_choice is None:
|
||||
raise ValueError("selected_choice (1~4) 또는 is_unsure=true 가 필요합니다")
|
||||
is_correct = selected_choice == correct_choice
|
||||
return selected_choice, is_correct, ("correct" if is_correct else "wrong")
|
||||
@@ -0,0 +1,174 @@
|
||||
"""발행 outbox enqueue + 초기 백필 (docsrv-viewer-publish).
|
||||
|
||||
enqueue_publish: 저작/4-A 트랜잭션이 같은 session(=같은 Postgres tx)에서 호출 → caller commit
|
||||
(P0-1 규율: 콘텐츠 변경과 outbox INSERT 원자성, dual-write 회피). payload/hash 스냅샷.
|
||||
enqueue_question_publish: 문항 + (ready면)해설을 함께 적재. 저작 쓰기/4-A 완료/백필 공용.
|
||||
backfill_publish_questions: 기존 active 문항을 bounded 로 1회 outbox 적재(초기 백필, P2-1 bounded page).
|
||||
멱등 = 발행 워커의 (payload_hash, deleted) 디둡이 no-op 재투영 흡수(중복 enqueue 무해).
|
||||
|
||||
★주의: 저작 엔드포인트(study_questions create/update)·4-A 워커에서의 enqueue 결선은 P0-1b
|
||||
(기존 hot 파일 수정이라 별 increment). 본 모듈은 호출 라이브러리 + 수동/백필 진입점.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from models.published import PublishOutbox
|
||||
from models.study_memo_card import StudyMemoCard
|
||||
from models.study_memo_card_progress import StudyMemoCardProgress
|
||||
from models.study_question import StudyQuestion
|
||||
from models.study_topic import StudyTopic
|
||||
from services.study.publish_projection import (
|
||||
KIND_CARD,
|
||||
KIND_CARD_PROGRESS,
|
||||
KIND_EXPLANATION,
|
||||
KIND_QUESTION,
|
||||
KIND_TOPIC,
|
||||
SCHEMA_VERSION,
|
||||
payload_hash,
|
||||
project_card,
|
||||
project_card_progress,
|
||||
project_explanation,
|
||||
project_question,
|
||||
project_topic,
|
||||
)
|
||||
|
||||
|
||||
async def enqueue_publish(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
kind: str,
|
||||
source_id: int,
|
||||
payload: dict[str, Any] | None,
|
||||
deleted: bool = False,
|
||||
) -> None:
|
||||
"""outbox 1행 INSERT. caller 가 commit (저자 tx 동봉). deleted=True 면 tombstone(payload={})."""
|
||||
body: dict[str, Any] = payload if payload is not None else {}
|
||||
session.add(
|
||||
PublishOutbox(
|
||||
kind=kind,
|
||||
source_id=source_id,
|
||||
payload=body,
|
||||
payload_hash=payload_hash(body),
|
||||
schema_version=SCHEMA_VERSION,
|
||||
deleted=deleted,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def enqueue_question_publish(session: AsyncSession, q: Any) -> None:
|
||||
"""문항 + (ready면)해설을 outbox 적재. caller commit."""
|
||||
await enqueue_publish(session, kind=KIND_QUESTION, source_id=q.id, payload=project_question(q))
|
||||
expl = project_explanation(q)
|
||||
if expl is not None:
|
||||
await enqueue_publish(session, kind=KIND_EXPLANATION, source_id=q.id, payload=expl)
|
||||
|
||||
|
||||
async def backfill_publish_questions(session: AsyncSession, *, after_id: int = 0, limit: int = 200) -> tuple[int, int]:
|
||||
"""active(미삭제) 문항을 id>after_id 부터 bounded 로 outbox 적재.
|
||||
|
||||
반환 = (enqueue 수, 마지막 처리 id). caller 는 수==limit 면 last_id 로 다음 페이지. caller commit.
|
||||
"""
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(StudyQuestion)
|
||||
.where(StudyQuestion.deleted_at.is_(None), StudyQuestion.id > after_id)
|
||||
.order_by(StudyQuestion.id.asc())
|
||||
.limit(limit)
|
||||
)
|
||||
).scalars().all()
|
||||
for q in rows:
|
||||
await enqueue_question_publish(session, q)
|
||||
return len(rows), (rows[-1].id if rows else after_id)
|
||||
|
||||
|
||||
async def enqueue_topic_publish(session: AsyncSession, topic: Any) -> None:
|
||||
"""주제 메타를 outbox 적재(S-1). caller commit. 저작 create/update 결선 + 백필 공용."""
|
||||
await enqueue_publish(session, kind=KIND_TOPIC, source_id=topic.id, payload=project_topic(topic))
|
||||
|
||||
|
||||
async def backfill_publish_topics(session: AsyncSession, *, after_id: int = 0, limit: int = 200) -> tuple[int, int]:
|
||||
"""active(미삭제) 주제를 id>after_id 부터 bounded 로 outbox 적재(S-1 초기 백필).
|
||||
|
||||
반환 = (enqueue 수, 마지막 처리 id). caller 는 수==limit 면 last_id 로 다음 페이지. caller commit.
|
||||
멱등 = 발행 워커의 (payload_hash, deleted) 디둡이 no-op 재투영 흡수(중복 enqueue 무해).
|
||||
"""
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(StudyTopic)
|
||||
.where(StudyTopic.deleted_at.is_(None), StudyTopic.id > after_id)
|
||||
.order_by(StudyTopic.id.asc())
|
||||
.limit(limit)
|
||||
)
|
||||
).scalars().all()
|
||||
for t in rows:
|
||||
await enqueue_topic_publish(session, t)
|
||||
return len(rows), (rows[-1].id if rows else after_id)
|
||||
|
||||
|
||||
async def enqueue_card_publish(session: AsyncSession, card: Any) -> None:
|
||||
"""카드 상태 기반 발행/tombstone (S-2). caller commit.
|
||||
|
||||
검수완료(needs_review=False) & 미삭제 만 발행 — 그 외(검수대기 복귀·삭제·retire)는
|
||||
tombstone(feed 1급 삭제 이벤트). 발행 자격이 카드 상태에 매여 있어 호출측은 '카드를
|
||||
건드렸다'만 알면 되고 publish/tombstone 분기는 여기 단일화(경로별 가드 기억 회피).
|
||||
"""
|
||||
if card.deleted_at is not None or card.needs_review:
|
||||
await enqueue_publish(session, kind=KIND_CARD, source_id=card.id, payload=None, deleted=True)
|
||||
else:
|
||||
await enqueue_publish(session, kind=KIND_CARD, source_id=card.id, payload=project_card(card))
|
||||
|
||||
|
||||
async def backfill_publish_cards(session: AsyncSession, *, after_id: int = 0, limit: int = 200) -> tuple[int, int]:
|
||||
"""검수완료(needs_review=False)·미삭제 카드를 id>after_id 부터 bounded 로 outbox 적재(S-2 초기 백필).
|
||||
|
||||
반환 = (enqueue 수, 마지막 처리 id). caller 는 수==limit 면 last_id 로 다음 페이지. 멱등 = 워커 디둡. caller commit.
|
||||
"""
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(StudyMemoCard)
|
||||
.where(
|
||||
StudyMemoCard.deleted_at.is_(None),
|
||||
StudyMemoCard.needs_review.is_(False),
|
||||
StudyMemoCard.id > after_id,
|
||||
)
|
||||
.order_by(StudyMemoCard.id.asc())
|
||||
.limit(limit)
|
||||
)
|
||||
).scalars().all()
|
||||
for c in rows:
|
||||
await enqueue_card_publish(session, c)
|
||||
return len(rows), (rows[-1].id if rows else after_id)
|
||||
|
||||
|
||||
async def enqueue_card_progress_publish(session: AsyncSession, progress: Any) -> None:
|
||||
"""카드 SR progress row 발행(S-4). caller commit. rate_card 결과(ALL row, sentinel/terminal 포함)."""
|
||||
await enqueue_publish(
|
||||
session,
|
||||
kind=KIND_CARD_PROGRESS,
|
||||
source_id=progress.id,
|
||||
payload=project_card_progress(progress),
|
||||
)
|
||||
|
||||
|
||||
async def backfill_publish_card_progress(session: AsyncSession, *, after_id: int = 0, limit: int = 200) -> tuple[int, int]:
|
||||
"""모든 card progress row 를 id>after_id 부터 bounded 로 outbox 적재(S-4 초기 백필).
|
||||
|
||||
★필터 없음 = ALL row(due_at NULL sentinel·terminal 포함) — due-only 백필은 sentinel 누락.
|
||||
반환 = (enqueue 수, 마지막 처리 id). caller 는 수==limit 면 last_id 로 다음 페이지. 멱등 = 워커 디둡. caller commit.
|
||||
"""
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(StudyMemoCardProgress)
|
||||
.where(StudyMemoCardProgress.id > after_id)
|
||||
.order_by(StudyMemoCardProgress.id.asc())
|
||||
.limit(limit)
|
||||
)
|
||||
).scalars().all()
|
||||
for p in rows:
|
||||
await enqueue_card_progress_publish(session, p)
|
||||
return len(rows), (rows[-1].id if rows else after_id)
|
||||
@@ -0,0 +1,112 @@
|
||||
"""발행 projection — 소스 행을 render-ready payload + 안정 해시로 변환 (순수 함수).
|
||||
|
||||
뷰어가 보는 '단일 진실'은 이 payload 까지 (DS 내부 실험 스키마는 계약 뒤 격리).
|
||||
kind 별 projector. payload_hash = 정렬된 JSON 의 sha256 = (payload_hash, deleted) 디둡 키.
|
||||
|
||||
★주의(plan study-to-viewer-slice1 r2): 과목/시험메타를 per-question payload 에 인라인 —
|
||||
bulk subject rename 시 N행 churn. 정규화(과목=별 kind subject ref)는 churn 최적화 후속(P0-1b),
|
||||
읽기 정합엔 무영향. 지금은 인라인(상관관계 단순)으로 두고 후속 PR 에서 분리.
|
||||
SCHEMA_VERSION = 엔벨로프 버전. payload 모양 진화 시 bump + 뷰어 range 수용(P0-2).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
SCHEMA_VERSION = 1
|
||||
|
||||
KIND_QUESTION = "study_question"
|
||||
KIND_EXPLANATION = "study_explanation"
|
||||
KIND_TOPIC = "study_topic"
|
||||
KIND_CARD = "study_card" # ★뷰어 pubstudy.ts 의 KIND_CARD 와 일치 필수(S-3 forward-contract).
|
||||
KIND_CARD_PROGRESS = "study_card_progress" # 카드 SR 상태 read model (S-4, viewer C-4 소비).
|
||||
|
||||
|
||||
def payload_hash(payload: dict[str, Any]) -> str:
|
||||
"""정렬 JSON 의 sha256 — (payload_hash, deleted) 디둡 키. 키 순서/공백 비의존."""
|
||||
canonical = json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def project_question(q: Any) -> dict[str, Any]:
|
||||
"""study_question → 발행 payload. 정답 포함(개인 학습툴, plan Q2). 이미지는 ref 만(P0-4, 후속)."""
|
||||
return {
|
||||
"topic_id": q.study_topic_id,
|
||||
"question_text": q.question_text,
|
||||
"choices": [q.choice_1, q.choice_2, q.choice_3, q.choice_4],
|
||||
"correct_choice": q.correct_choice,
|
||||
"subject": q.subject,
|
||||
"scope": q.scope,
|
||||
"exam_name": q.exam_name,
|
||||
"exam_round": q.exam_round,
|
||||
"exam_question_number": q.exam_question_number,
|
||||
"explanation": q.explanation, # 수동 해설(있으면). AI 해설은 별 kind.
|
||||
}
|
||||
|
||||
|
||||
def project_explanation(q: Any) -> dict[str, Any] | None:
|
||||
"""study_question 의 AI 해설 → 별 발행 kind. ready 일 때만(없으면 None=발행 안 함).
|
||||
|
||||
재조우 표시용 선발행. 신규 오답은 4-A 워커가 ~90s 후 ready→재발행(P2-3 결선, P0-1b).
|
||||
"""
|
||||
if getattr(q, "ai_explanation_status", None) != "ready" or not getattr(q, "ai_explanation", None):
|
||||
return None
|
||||
gen = getattr(q, "ai_explanation_generated_at", None)
|
||||
return {
|
||||
"question_source_id": q.id,
|
||||
"explanation_md": q.ai_explanation,
|
||||
"model": getattr(q, "ai_explanation_model", None),
|
||||
"generated_at": gen.isoformat() if gen else None,
|
||||
}
|
||||
|
||||
|
||||
def project_card(c: Any) -> dict[str, Any]:
|
||||
"""study_memo_card → 발행 payload (S-2). 순수 변환 — 발행 자격(needs_review=false &
|
||||
미삭제) 판단은 호출측(enqueue_card_publish)이 카드 상태로. payload 계약 = 뷰어
|
||||
pubstudy.ts getCards 와 동형(format·cue·fact·cloze_text·source_question_id·source_generated_at).
|
||||
"""
|
||||
gen = getattr(c, "source_generated_at", None)
|
||||
return {
|
||||
"format": c.format,
|
||||
"cue": c.cue,
|
||||
"fact": c.fact,
|
||||
"cloze_text": c.cloze_text,
|
||||
"source_question_id": c.source_question_id,
|
||||
"source_generated_at": gen.isoformat() if gen else None,
|
||||
}
|
||||
|
||||
|
||||
def project_card_progress(p: Any) -> dict[str, Any]:
|
||||
"""study_memo_card_progress → 발행 payload (S-4) = 카드 SR 상태 read model.
|
||||
|
||||
★ALL row 발행(due_at NULL sentinel=암-on-new · terminal=졸업 포함). due-only 발행하면
|
||||
sentinel 누락 → viewer 가 '미확인' 오분류. SR 계산은 DS(sr_schedule), 여긴 결과만.
|
||||
card_id = pub_card 의 source_id(=DS card.id) → viewer C-4 가 pub_card LEFT JOIN 하는 키.
|
||||
"""
|
||||
due = getattr(p, "due_at", None)
|
||||
rev = getattr(p, "last_reviewed_at", None)
|
||||
return {
|
||||
"card_id": p.card_id,
|
||||
"topic_id": p.study_topic_id,
|
||||
"last_outcome": p.last_outcome,
|
||||
"last_reviewed_at": rev.isoformat() if rev else None,
|
||||
"due_at": due.isoformat() if due else None,
|
||||
"review_stage": p.review_stage,
|
||||
}
|
||||
|
||||
|
||||
def project_topic(t: Any) -> dict[str, Any]:
|
||||
"""study_topic → 발행 payload (S-1, plan study-viewer-port).
|
||||
|
||||
topic 메타만 신규 발행 — viewer 가 주제 단위 퀴즈를 만들 최소 정보.
|
||||
회차 목록은 발행 안 함 = viewer 가 pub_content(study_question) 의 exam_name/exam_round 로
|
||||
파생(추가 발행 불요, plan S-1 결정). topic_id 는 project_question 의 topic_id(=study_topic_id)
|
||||
와 동일 DS 식별자라 viewer 가 문항→주제 상관에 사용(pub_id 는 opaque 라 상관 키 아님).
|
||||
"""
|
||||
return {
|
||||
"topic_id": t.id,
|
||||
"name": t.name,
|
||||
"exam_round_size": t.exam_round_size,
|
||||
}
|
||||
@@ -20,6 +20,7 @@ from models.chunk import DocumentChunk
|
||||
from models.document import Document
|
||||
from models.study_question import StudyQuestion
|
||||
from models.study_topic import StudyTopicDocument
|
||||
from services.search.license_filter import restricted_exclude_orm
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -113,6 +114,9 @@ async def _gather_document_evidence(
|
||||
select(Document.id, Document.title, Document.ai_summary).where(
|
||||
Document.id.in_(doc_ids),
|
||||
Document.deleted_at.is_(None),
|
||||
# B-4: licensed_restricted 제외 — explanation_rag 와 동일 술어(a안 U-2①). 누락 시
|
||||
# 구매 자료 verbatim 이 분야노트 RAG 로 새던 보안 drift(복제 과정 누락).
|
||||
restricted_exclude_orm(),
|
||||
)
|
||||
)
|
||||
).all()
|
||||
@@ -238,9 +242,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:
|
||||
|
||||
@@ -175,10 +175,16 @@ async def _ingest_detail(session, source: NewsSource, url: str) -> str:
|
||||
ai_domain="Engineering",
|
||||
ai_sub_group=_SOURCE_NAME,
|
||||
ai_tags=["Engineering/API 표준 공지"],
|
||||
# 안전 자료실 A-2 — 표준 '공지' = standard (코드 본문 아님 — ASME/API 본문은 paywall)
|
||||
material_type="standard",
|
||||
jurisdiction="US",
|
||||
published_date=pub_dt.date() if pub_dt else None,
|
||||
extract_meta={
|
||||
"source_id": source.id,
|
||||
"source_name": _SOURCE_NAME,
|
||||
"published_at": pub_dt.isoformat() if pub_dt else None,
|
||||
"license": {"scheme": "proprietary", "redistribute": False,
|
||||
"attribution": "American Petroleum Institute"},
|
||||
"fulltext": {
|
||||
"status": "api_announcement",
|
||||
"engine": engine,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -8,12 +8,16 @@
|
||||
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:
|
||||
@@ -22,19 +26,27 @@ async def run(target_date: date | None = None) -> dict | None:
|
||||
Args:
|
||||
target_date: KST 기준 briefing_date (None = 오늘). API regenerate 가 명시 지정 가능.
|
||||
"""
|
||||
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} "
|
||||
@@ -311,6 +316,10 @@ async def process(document_id: int, session: AsyncSession) -> None:
|
||||
country, source, src_lang = await _lookup_news_source(session, doc)
|
||||
if src_lang:
|
||||
language = src_lang
|
||||
# 안전 자료실 A-2 — 뉴스 lookup 미해당(crawl/law/업로드) 문서는 jurisdiction 을
|
||||
# chunk.country 미러로 (leg 간 국가 일치. EU/INT 도 이 경로로 첫 유입 — String(10) 수용).
|
||||
if country is None and doc.jurisdiction:
|
||||
country = doc.jurisdiction
|
||||
domain_category = "news" if doc.source_channel == "news" else "document"
|
||||
|
||||
# 기존 chunks 삭제 (재처리)
|
||||
|
||||
+119
-19
@@ -31,12 +31,19 @@ from pydantic import BaseModel, Field, ValidationError
|
||||
from sqlalchemy import text as sql_text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ai.client import AIClient, parse_json_response, strip_thinking
|
||||
from ai.client import (
|
||||
AIClient,
|
||||
call_deep_or_defer,
|
||||
is_deferrable_error,
|
||||
parse_json_response,
|
||||
strip_thinking,
|
||||
)
|
||||
from ai.envelope import EscalationEnvelope
|
||||
from core.config import settings
|
||||
from services.search.llm_gate import Priority, acquire_mlx_gate
|
||||
from core.utils import setup_logger
|
||||
from models.document import Document
|
||||
from models.queue import enqueue_stage
|
||||
from models.queue import StageDeferred, enqueue_stage
|
||||
from policy.prompt_render import render_4b, policy_version as compute_policy_version
|
||||
from policy.routing import decide_routing
|
||||
from services.document_telemetry import record_analyze_event
|
||||
@@ -56,6 +63,15 @@ FACET_DOCTYPES = {"발주서", "세금계산서", "명세표", "도면", "증명
|
||||
# 자료실 자동 분류 제안 대상 (거래 하위)
|
||||
LIBRARY_SUGGESTION_DOCTYPES = {"발주서", "세금계산서", "명세표"}
|
||||
|
||||
# 안전 자료실 A-2 — document_type → material_type 결정적 매핑 (제안 전용, 자동 전이 금지).
|
||||
# 모호한 doctype(Reference/Report 등)은 매핑하지 않음 — 무리한 전수 분류 시도 금지 (plan 0-1).
|
||||
_DOCTYPE_TO_MATERIAL = {
|
||||
"Law_Document": "law",
|
||||
"Academic_Paper": "paper",
|
||||
"Manual": "manual",
|
||||
"Standard": "standard",
|
||||
}
|
||||
|
||||
# PR-B prompt_version task 이름
|
||||
SUMMARY_TRIAGE_TASK = "p3a_short_summary"
|
||||
|
||||
@@ -74,6 +90,7 @@ HARD_ESCALATE_REASONS = {
|
||||
class TriageOutput(BaseModel):
|
||||
"""p3a_short_summary (4B) 응답 스키마. 파싱 실패 시 기본값 + escalate=True fallback."""
|
||||
|
||||
ai_summary: str = "" # B-1 3→2: triage 가 ai_summary 도 생산 (별 summarize 콜 대체)
|
||||
tldr: str = ""
|
||||
bullets: list[str] = Field(default_factory=list)
|
||||
tags: list[str] = Field(default_factory=list)
|
||||
@@ -345,13 +362,20 @@ _FRONTMATTER_PRESERVED_KEYS = {
|
||||
# ───────────────────────── main process ────────────────────────────────
|
||||
|
||||
|
||||
async def process(document_id: int, session: AsyncSession) -> None:
|
||||
async def process(
|
||||
document_id: int, session: AsyncSession, *, use_deep: bool = False
|
||||
) -> None:
|
||||
"""문서 분류 + 요약 + tier triage.
|
||||
|
||||
1) Legacy: classify() → ai_domain/document_type/ai_tags/ai_confidence/ai_suggestion
|
||||
2) Legacy: summarize() → ai_summary
|
||||
3) PR-B B-1: summary_triage (4B) → ai_tldr/ai_bullets/ai_analysis_tier='triage'
|
||||
|
||||
use_deep (2026-06-12 fair-share, queue_drain 전용): triage LLM 호출을 deep 슬롯
|
||||
(맥북, 라우터 경유)으로 보낸다 — sampling 은 triage 의 temperature/max_tokens 를
|
||||
유지(분류 결정성), endpoint 만 교체. 맥북 불가 = StageDeferred 전파(drain 이
|
||||
보류 처리). False(기본/consumer) = 기존 call_triage(맥미니 직접) 그대로.
|
||||
|
||||
예외 — source_channel='law_monitor':
|
||||
법령은 외부 source-of-truth (law.go.kr) 보유 + immutable + 자동 재수집.
|
||||
AI 분류는 무가치 + 본문 해석 환각 위험. 26B legacy + 4B triage 전부 skip.
|
||||
@@ -389,6 +413,15 @@ async def process(document_id: int, session: AsyncSession) -> None:
|
||||
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가 비어있음")
|
||||
|
||||
@@ -446,10 +479,20 @@ async def process(document_id: int, session: AsyncSession) -> None:
|
||||
logger.info(f"doc {document_id}: frontmatter 부분 인식 → LLM 으로 미설정 필드 보완")
|
||||
|
||||
client = AIClient()
|
||||
# fair-share (2026-06-12): use_deep 시 legacy classify/summarize 도 deep 슬롯(맥북)
|
||||
# 경유 — 그래야 drain 의 "맥북 분담" 이 실제로 성립 (triage 만 보내면 50K 요약
|
||||
# 프리필이 맥미니에 남는다). deep 슬롯 sampling = primary 와 동일(0.3/0.9/8192).
|
||||
legacy_cfg = settings.ai.deep if (use_deep and settings.ai.deep is not None) else None
|
||||
try:
|
||||
# ─── 1. Legacy classify (primary 26B) ───
|
||||
# ─── 1. Legacy classify (primary 또는 deep) ───
|
||||
truncated = doc.extracted_text[:MAX_CLASSIFY_TEXT]
|
||||
raw_response = await client.classify(truncated)
|
||||
try:
|
||||
raw_response = await client.classify(truncated, cfg=legacy_cfg)
|
||||
except Exception as exc:
|
||||
if legacy_cfg is not None and is_deferrable_error(exc):
|
||||
# 맥북 불가 — 첫 호출(최저 비용 지점)에서 보류로 전환, doc 쓰기 0
|
||||
raise StageDeferred(f"macbook_unavailable:{type(exc).__name__}") from exc
|
||||
raise
|
||||
parsed = parse_json_response(raw_response)
|
||||
|
||||
if not parsed:
|
||||
@@ -469,6 +512,24 @@ async def process(document_id: int, session: AsyncSession) -> None:
|
||||
if not doc.document_type:
|
||||
doc.document_type = doc_type if doc_type in DOCUMENT_TYPES else "Note"
|
||||
|
||||
# ─── 안전 자료실 A-2: material_type 제안 (업로드 경로 — LLM 직접 부여 금지) ───
|
||||
# document_type → material_type 결정적 매핑만 제안으로 적재 (프롬프트 변경 0).
|
||||
# 승인(accept-suggestion) 시에만 전이 — law 는 국가 필수 입력 (KR 기본값 오염 차단,
|
||||
# 자동 전이 금지 사상은 category 와 동일). 수집기 deterministic 경로는 이미 채워져
|
||||
# 있어(material_type IS NOT NULL) 본 제안 비대상. 거래문서 제안(ai_suggestion 점유)과
|
||||
# 충돌 시 기존 제안 우선 (두 제안이 겹치는 문서는 실무상 없음 — 거래 vs 안전자료).
|
||||
_mt_prop = _DOCTYPE_TO_MATERIAL.get(doc.document_type or "")
|
||||
if _mt_prop and doc.material_type is None and doc.ai_suggestion is None:
|
||||
doc.ai_suggestion = {
|
||||
"proposed_material_type": _mt_prop,
|
||||
"proposed_jurisdiction": None,
|
||||
"confidence": doc.ai_confidence,
|
||||
"source_updated_at": (
|
||||
doc.updated_at.isoformat() if doc.updated_at else None
|
||||
),
|
||||
"reason": "document_type→material_type 결정적 매핑",
|
||||
}
|
||||
|
||||
# confidence
|
||||
confidence = parsed.get("confidence", 0.5)
|
||||
doc.ai_confidence = max(0.0, min(1.0, float(confidence)))
|
||||
@@ -504,7 +565,9 @@ async def process(document_id: int, session: AsyncSession) -> None:
|
||||
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",
|
||||
@@ -517,12 +580,8 @@ async def process(document_id: int, session: AsyncSession) -> None:
|
||||
"reason": "classify pipeline",
|
||||
}
|
||||
|
||||
# ─── 2. Legacy 요약 (primary 26B) ───
|
||||
summary = await client.summarize(doc.extracted_text[:50000])
|
||||
doc.ai_summary = strip_thinking(summary)
|
||||
|
||||
# ─── 메타데이터 (legacy 완료) ───
|
||||
doc.ai_model_version = settings.ai.primary.model
|
||||
# ─── 메타데이터 (classify 완료) — 실제 처리 머신 귀속 (drain=qwen-macbook) ───
|
||||
doc.ai_model_version = (legacy_cfg or settings.ai.primary).model
|
||||
doc.ai_processed_at = datetime.now(timezone.utc)
|
||||
|
||||
logger.info(
|
||||
@@ -531,18 +590,36 @@ async def process(document_id: int, session: AsyncSession) -> None:
|
||||
f"confidence={doc.ai_confidence:.2f}, tags={doc.ai_tags}"
|
||||
)
|
||||
|
||||
# ─── 3. PR-B B-1 — tier triage (4B, 실패는 legacy 결과 보존) ───
|
||||
# ─── 2+3 통합 (B-1 3→2): tier triage 가 tldr/bullets/tier + ai_summary 생산.
|
||||
# 기존 별도 summarize 콜 제거 → 본문 prefill 1회 절감 (Mac mini 부하). 실패는 fallback.
|
||||
try:
|
||||
await _run_tier_triage(client, doc, session)
|
||||
await _run_tier_triage(client, doc, session, use_deep=use_deep)
|
||||
except StageDeferred:
|
||||
raise # 보류는 실패가 아님 — drain/consumer 가 attempts 미소모 처리
|
||||
except Exception as exc:
|
||||
logger.exception(f"[triage] id={document_id} 전체 실패 — legacy 유지: {exc}")
|
||||
logger.exception(f"[triage] id={document_id} 전체 실패: {exc}")
|
||||
|
||||
# ─── ai_summary fallback: triage 가 못 채운 경우만 summarize ───
|
||||
# (>120K long_context 는 triage 가 LLM skip, 또는 triage 파싱실패). 정상 경로는 미발동.
|
||||
if not doc.ai_summary:
|
||||
try:
|
||||
summary = await client.summarize(doc.extracted_text[:50000], cfg=legacy_cfg)
|
||||
doc.ai_summary = strip_thinking(summary)
|
||||
except Exception as exc:
|
||||
if legacy_cfg is not None and is_deferrable_error(exc):
|
||||
raise StageDeferred(f"macbook_unavailable:{type(exc).__name__}") from exc
|
||||
# ai_summary=NULL 로 완료되면 digest/briefing 이 조용히 제외 → ERROR 로 가시화
|
||||
# (best-effort 강등 자체는 유지, 운영 추적성만 보강).
|
||||
logger.error(f"[summary-fallback] id={document_id} ai_summary 미생성: {exc}")
|
||||
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
|
||||
async def _run_tier_triage(client: AIClient, doc: Document, session: AsyncSession) -> None:
|
||||
"""summary_triage (p3a_short_summary) 경로."""
|
||||
async def _run_tier_triage(
|
||||
client: AIClient, doc: Document, session: AsyncSession, *, use_deep: bool = False
|
||||
) -> None:
|
||||
"""summary_triage (p3a_short_summary) 경로. use_deep = process() 에서 전달 (drain 전용)."""
|
||||
document_id = doc.id
|
||||
text = doc.extracted_text or ""
|
||||
input_chars = len(text)
|
||||
@@ -550,6 +627,14 @@ async def _run_tier_triage(client: AIClient, doc: Document, session: AsyncSessio
|
||||
triage_start = time.perf_counter()
|
||||
parse_error: str | None = None
|
||||
triage_out = TriageOutput()
|
||||
# drain 경유 시 triage 도 deep 슬롯(맥북) — sampling 은 triage 것 유지(결정성).
|
||||
deep_triage_cfg = None
|
||||
if use_deep and settings.ai.deep is not None:
|
||||
deep_triage_cfg = settings.ai.deep.model_copy(update={
|
||||
"temperature": settings.ai.triage.temperature,
|
||||
"top_p": settings.ai.triage.top_p,
|
||||
"max_tokens": settings.ai.triage.max_tokens,
|
||||
})
|
||||
|
||||
# 입력이 triage 한도 초과면 호출 생략하고 long_context 로 escalate
|
||||
if input_chars > TRIAGE_TEXT_LIMIT:
|
||||
@@ -590,7 +675,17 @@ async def _run_tier_triage(client: AIClient, doc: Document, session: AsyncSessio
|
||||
prompt = rendered.replace("{extracted_text}", text[:TRIAGE_TEXT_LIMIT])
|
||||
|
||||
try:
|
||||
raw_triage = await client.call_triage(prompt)
|
||||
if deep_triage_cfg is not None:
|
||||
# drain 전용 — deep 슬롯 endpoint + triage sampling. 맥북 불가(StageDeferred)
|
||||
# 는 아래 generic except 에 먹히지 않게 먼저 전파.
|
||||
raw_triage = await call_deep_or_defer(client, prompt, cfg=deep_triage_cfg)
|
||||
else:
|
||||
# consumer 경로 call_triage 는 PR #20 이후 primary 와 동일 Mac mini endpoint —
|
||||
# evidence/classifier 처럼 gate 안에서 호출(영구 룰: 같은 endpoint 예외 없이 gate).
|
||||
async with acquire_mlx_gate(Priority.BACKGROUND):
|
||||
raw_triage = await client.call_triage(prompt)
|
||||
except StageDeferred:
|
||||
raise # drain 이 attempts 미소모 + 백오프로 처리 (sleep-안전)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"[triage] 4B 호출 실패 id=%s type=%s repr=%r",
|
||||
@@ -656,6 +751,7 @@ async def _run_tier_triage(client: AIClient, doc: Document, session: AsyncSessio
|
||||
escalation_reason=escalation_reason,
|
||||
parse_error=parse_error,
|
||||
routing_decision=routing_decision,
|
||||
model_name=(deep_triage_cfg.model if deep_triage_cfg is not None else None),
|
||||
)
|
||||
|
||||
|
||||
@@ -670,6 +766,7 @@ async def _apply_triage_result(
|
||||
escalation_reason: str | None,
|
||||
parse_error: str | None,
|
||||
routing_decision=None,
|
||||
model_name: str | None = None, # fair-share: 실제 호출 경로 모델 (None=triage 기본)
|
||||
) -> None:
|
||||
"""TriageOutput → Document 필드 + R2 suppression + envelope enqueue + audit.
|
||||
|
||||
@@ -683,6 +780,9 @@ async def _apply_triage_result(
|
||||
if not parse_error:
|
||||
doc.ai_tldr = (triage_out.tldr or "").strip() or None
|
||||
doc.ai_bullets = triage_out.bullets or []
|
||||
# B-1 3→2: triage 가 ai_summary 도 생산(summarize 콜 대체). 비면 process() 가 fallback.
|
||||
if triage_out.ai_summary.strip():
|
||||
doc.ai_summary = triage_out.ai_summary.strip()
|
||||
# Memo Intake Upgrade PR-2B — event kind hint (4B 가 출력했을 때만)
|
||||
# 허용 enum 외 값이면 무시 (DB enum 제약). AI worker 는 events row 직접 생성 X.
|
||||
valid_kinds = {"note", "task", "calendar_event", "activity_log", "reference"}
|
||||
@@ -760,7 +860,7 @@ async def _apply_triage_result(
|
||||
layers_returned=["tldr", "bullets"] if not parse_error else [],
|
||||
cached=False,
|
||||
latency_ms=latency_ms,
|
||||
model_name=settings.ai.triage.model,
|
||||
model_name=(model_name or settings.ai.triage.model),
|
||||
prompt_version=(f"{SUMMARY_TRIAGE_TASK}@{pv}" if pv else SUMMARY_TRIAGE_TASK),
|
||||
error_code=parse_error,
|
||||
source="document_server",
|
||||
|
||||
@@ -140,7 +140,8 @@ async def _download_pdf(url: str, dest: Path) -> int:
|
||||
if len(resp.content) > _MAX_PDF_BYTES:
|
||||
raise FeedError(f"PDF 크기 초과 ({len(resp.content)} bytes): {url}")
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
dest.write_bytes(resp.content)
|
||||
# 최대 50MB PDF write 는 동기 blocking — 이벤트루프 점유 회피 to_thread (R5 동형).
|
||||
await asyncio.to_thread(dest.write_bytes, resp.content)
|
||||
return len(resp.content)
|
||||
|
||||
|
||||
@@ -190,9 +191,11 @@ async def _ingest_pdf(session, page_slug: str, pdf_url: str) -> bool:
|
||||
|
||||
dest = Path(settings.nas_mount_path) / rel_path
|
||||
size = await _download_pdf(pdf_url, dest)
|
||||
# 50MB PDF read + sha256 는 동기 blocking(I/O+CPU) — 이벤트루프 점유 회피 to_thread (R5 동형).
|
||||
file_hash = await asyncio.to_thread(lambda: hashlib.sha256(dest.read_bytes()).hexdigest())
|
||||
doc = Document(
|
||||
file_path=rel_path,
|
||||
file_hash=hashlib.sha256(dest.read_bytes()).hexdigest(),
|
||||
file_hash=file_hash,
|
||||
file_format="pdf",
|
||||
file_size=size,
|
||||
file_type="immutable",
|
||||
@@ -202,7 +205,12 @@ async def _ingest_pdf(session, page_slug: str, pdf_url: str) -> bool:
|
||||
import_source="csb_sitemap",
|
||||
edit_url=pdf_url,
|
||||
ai_tags=["Safety/CSB/보고서"],
|
||||
extract_meta={"csb": {"page_slug": page_slug, "kind": "report_pdf"}},
|
||||
# 안전 자료실 A-2 — ingest 시점 deterministic. CSB = 미 연방기관 = public domain.
|
||||
material_type="incident",
|
||||
jurisdiction="US",
|
||||
extract_meta={"csb": {"page_slug": page_slug, "kind": "report_pdf"},
|
||||
"license": {"scheme": "public_domain", "redistribute": True,
|
||||
"attribution": "U.S. Chemical Safety Board"}},
|
||||
)
|
||||
session.add(doc)
|
||||
await session.flush()
|
||||
@@ -290,10 +298,16 @@ async def _ingest_url(session, source: NewsSource, url: str, lastmod: datetime)
|
||||
ai_domain="Safety",
|
||||
ai_sub_group=_SOURCE_NAME,
|
||||
ai_tags=["Safety/CSB"],
|
||||
# 안전 자료실 A-2 — ingest 시점 deterministic (classify-skip 경로)
|
||||
material_type="incident",
|
||||
jurisdiction="US",
|
||||
published_date=lastmod.date() if lastmod else None,
|
||||
extract_meta={
|
||||
"source_id": source.id,
|
||||
"source_name": _SOURCE_NAME,
|
||||
"published_at": lastmod.isoformat(),
|
||||
"license": {"scheme": "public_domain", "redistribute": True,
|
||||
"attribution": "U.S. Chemical Safety Board"},
|
||||
"fulltext": {
|
||||
"status": "csb_sitemap",
|
||||
"engine": engine,
|
||||
@@ -363,11 +377,17 @@ async def run(bulk: bool = False, limit: int = 0) -> None:
|
||||
|
||||
totals = {"page": 0, "pdf": 0, "skip": 0}
|
||||
for i, (url, lastmod) in enumerate(todo, 1):
|
||||
async with async_session() as session:
|
||||
src = await session.get(NewsSource, source_id)
|
||||
counts = await _ingest_url(session, src, url, lastmod)
|
||||
_set_watermark(src, lastmod)
|
||||
await session.commit()
|
||||
# 2026-06-20 C2: URL 1건 실패가 주간 run 전체를 중단(이후 URL 스킵·watermark 정지)하던 것 차단.
|
||||
# 각 iteration 은 자체 session(async with) 이라 실패 격리 — 건너뛰고 계속.
|
||||
try:
|
||||
async with async_session() as session:
|
||||
src = await session.get(NewsSource, source_id)
|
||||
counts = await _ingest_url(session, src, url, lastmod)
|
||||
_set_watermark(src, lastmod)
|
||||
await session.commit()
|
||||
except Exception as e:
|
||||
logger.error(f"[csb] URL 처리 실패 (건너뜀): {url} — {str(e) or repr(e)}")
|
||||
continue
|
||||
for k in totals:
|
||||
totals[k] += counts[k]
|
||||
if i % 10 == 0:
|
||||
|
||||
+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}")
|
||||
|
||||
@@ -20,12 +20,12 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
import json
|
||||
import re
|
||||
from ai.client import AIClient, parse_json_response, strip_thinking
|
||||
from ai.client import AIClient, call_deep_or_defer, parse_json_response, strip_thinking
|
||||
from ai.envelope import EscalationEnvelope
|
||||
from core.config import settings
|
||||
from core.utils import setup_logger
|
||||
from models.document import Document
|
||||
from models.queue import ProcessingQueue
|
||||
from models.queue import ProcessingQueue, StageDeferred
|
||||
from policy.prompt_render import render_26b, policy_version as compute_policy_version
|
||||
from services.document_telemetry import record_analyze_event
|
||||
from services.search.llm_gate import Priority, acquire_mlx_gate
|
||||
@@ -54,8 +54,18 @@ class DeepSummaryOutput(BaseModel):
|
||||
confidence: float = 0.5
|
||||
|
||||
|
||||
async def process(document_id: int, session: AsyncSession) -> None:
|
||||
"""deep_summary 큐 pickup → 26B 호출 → 필드 저장."""
|
||||
async def process(
|
||||
document_id: int, session: AsyncSession, *, defer_on_deep_unavailable: bool = False
|
||||
) -> None:
|
||||
"""deep_summary 큐 pickup → LLM 호출 → 필드 저장.
|
||||
|
||||
defer_on_deep_unavailable:
|
||||
False (기본, consumer 경로) = 맥북(deep 슬롯) 우선 시도, 불가 시 즉시
|
||||
맥미니 primary 로 처리. 2026-06-12 fair-share: 양 머신이 동일 모델
|
||||
(Qwen3.6-27B-6bit)이라 폴백 = 품질 강등이 아니라 단순 분배.
|
||||
True (queue_drain 전용) = 맥북 불가를 StageDeferred 로 올려 drain 이
|
||||
보류 후 run 을 멈춘다 (drain = 맥북 분담 전용 레버 시멘틱 유지).
|
||||
"""
|
||||
doc = await session.get(Document, document_id)
|
||||
if not doc:
|
||||
raise ValueError(f"deep_summary: document id={document_id} 없음")
|
||||
@@ -101,19 +111,46 @@ async def process(document_id: int, session: AsyncSession) -> None:
|
||||
)
|
||||
|
||||
client = AIClient()
|
||||
# ds-macbook-offload-1: deep 슬롯 구성 시 맥북 M5 Max 경유(라우터). 부재 시 기존 경로 그대로.
|
||||
deep_cfg = client.ai.deep
|
||||
used_cfg = deep_cfg or settings.ai.primary
|
||||
latency_ms = 0
|
||||
parse_error: str | None = None
|
||||
deep_out = DeepSummaryOutput()
|
||||
|
||||
try:
|
||||
start = time.perf_counter()
|
||||
async with acquire_mlx_gate(Priority.BACKGROUND): # 2026-05-17 B-1: classify-escalate worker
|
||||
raw = await client.call_primary(prompt)
|
||||
if deep_cfg is not None:
|
||||
# 맥북 우선 — 맥미니 mlx gate 미점유(별 endpoint). doc 쓰기는 완주+파싱
|
||||
# 후에만 일어나므로 어느 시점에 끊겨도 부분 쓰기 0.
|
||||
try:
|
||||
raw = await call_deep_or_defer(client, prompt)
|
||||
except StageDeferred:
|
||||
if defer_on_deep_unavailable:
|
||||
raise # drain 전용 — 맥북 레버 시멘틱 (보류 후 run 종료)
|
||||
# consumer 경로: 동일 모델이라 강등 아님 — 맥미니가 즉시 처리 (2026-06-12)
|
||||
logger.info(
|
||||
f"[deep] id={document_id} 맥북 불가 → 맥미니 primary 처리 (fair-share)"
|
||||
)
|
||||
used_cfg = settings.ai.primary
|
||||
async with acquire_mlx_gate(Priority.BACKGROUND):
|
||||
raw = await client.call_primary(prompt)
|
||||
else:
|
||||
async with acquire_mlx_gate(Priority.BACKGROUND): # 2026-05-17 B-1: classify-escalate worker
|
||||
raw = await client.call_primary(prompt)
|
||||
latency_ms = int((time.perf_counter() - start) * 1000)
|
||||
except StageDeferred:
|
||||
# 보류는 실패가 아님 — analyze_event 미기록(가짜 완료 방지), drain 이 백오프 기록.
|
||||
logger.info(f"[deep] id={document_id} 맥북 일시 불가 — 보류 (deferred)")
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.warning(f"[deep] 26B 호출 실패 id={document_id}: {exc}")
|
||||
parse_error = "call_failed"
|
||||
raw = ""
|
||||
# 호출 실패(네트워크/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}")
|
||||
raise
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
@@ -147,12 +184,13 @@ async def process(document_id: int, session: AsyncSession) -> None:
|
||||
doc_id=document_id,
|
||||
user_id=None,
|
||||
mode="summary_deep",
|
||||
text_limit=settings.ai.primary.context_char_limit or 260000,
|
||||
text_limit=used_cfg.context_char_limit or 260000,
|
||||
truncated=False,
|
||||
layers_returned=["detail_summary", "inconsistencies"] if not parse_error else [],
|
||||
cached=False,
|
||||
latency_ms=latency_ms,
|
||||
model_name=settings.ai.primary.model,
|
||||
# deep 슬롯 사용 시 실처리 모델(qwen-macbook alias) 기록 — 어느 머신이 처리했는지 추적
|
||||
model_name=used_cfg.model,
|
||||
prompt_version=(f"{DEEP_SUMMARY_TASK}@{pv}" if pv else DEEP_SUMMARY_TASK),
|
||||
error_code=parse_error,
|
||||
source="document_server",
|
||||
|
||||
@@ -10,12 +10,16 @@ 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:
|
||||
@@ -24,19 +28,27 @@ async def run() -> None:
|
||||
pipeline 자체는 timeout 으로 감싸지 않음 (per-call timeout 은 summarizer 가 처리).
|
||||
여기서는 전체 hard cap 만 강제.
|
||||
"""
|
||||
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
|
||||
@@ -67,21 +67,45 @@ def _postprocess_ocr(text: str) -> str:
|
||||
return text.strip()
|
||||
|
||||
|
||||
def _extract_pdf_pymupdf(file_path: Path) -> str:
|
||||
"""PyMuPDF fallback — 페이지 단위 스트리밍으로 대형 PDF도 저메모리 처리"""
|
||||
def _extract_pdf_pymupdf(
|
||||
file_path: Path, start_page: int | None = None, end_page: int | None = None
|
||||
) -> str:
|
||||
"""PyMuPDF fallback — 페이지 단위 스트리밍으로 대형 PDF도 저메모리 처리.
|
||||
|
||||
G2 (PR-G2-2): start_page/end_page(1-based inclusive) 가 주어지면 그 범위만 추출
|
||||
(번들 자식 doc = 부모 파일 공유 + 자기 page 범위). 둘 다 None = 전체(기존 동작 동일).
|
||||
"""
|
||||
import fitz
|
||||
text_parts = []
|
||||
with fitz.open(str(file_path)) as doc:
|
||||
for page in doc:
|
||||
text_parts.append(page.get_text())
|
||||
if start_page is None and end_page is None:
|
||||
for page in doc:
|
||||
text_parts.append(page.get_text())
|
||||
else:
|
||||
# 1-based inclusive → 0-based range. 범위는 [0, page_count] 로 클램프(방어).
|
||||
total = doc.page_count
|
||||
lo = max(1, start_page or 1) - 1
|
||||
hi = min(total, end_page or total) # inclusive 끝 (0-based 마지막 인덱스 = hi-1)
|
||||
for i in range(lo, hi):
|
||||
text_parts.append(doc.load_page(i).get_text())
|
||||
return "\n".join(text_parts)
|
||||
|
||||
|
||||
def _get_pdf_page_count(file_path: Path) -> int:
|
||||
"""PDF 페이지 수 확인"""
|
||||
def _get_pdf_page_count(
|
||||
file_path: Path, start_page: int | None = None, end_page: int | None = None
|
||||
) -> int:
|
||||
"""PDF 페이지 수 확인. G2: 범위가 주어지면 그 범위의 페이지 수(자식 doc 밀도 계산용).
|
||||
|
||||
둘 다 None = 전체 페이지 수(기존 동작 동일).
|
||||
"""
|
||||
import fitz
|
||||
with fitz.open(str(file_path)) as doc:
|
||||
return len(doc)
|
||||
total = len(doc)
|
||||
if start_page is None and end_page is None:
|
||||
return total
|
||||
lo = max(1, start_page or 1)
|
||||
hi = min(total, end_page or total)
|
||||
return max(0, hi - lo + 1)
|
||||
|
||||
|
||||
async def _call_ocr(file_path: Path, is_image: bool, max_pages: int = 200) -> str | None:
|
||||
@@ -310,6 +334,49 @@ async def process(document_id: int, session: AsyncSession) -> None:
|
||||
doc.extracted_at = datetime.now(timezone.utc)
|
||||
return
|
||||
|
||||
# ─── G2 (PR-G2-2): 번들 자식 PDF — 부모 파일 공유 + 자기 page 범위만 추출 ───
|
||||
# kordoc 서비스는 page-range 파라미터가 없어 전체 파일을 파싱한다(자식엔 부적합) → kordoc
|
||||
# 우회, PyMuPDF 로 [bundle_page_start, bundle_page_end] 범위만 추출. range OCR 은 본 PR 범위
|
||||
# 밖(자식은 ToC 존재 = digital text layer 전제 → 대개 OCR 불필요). PyMuPDF 텍스트가 빈약해도
|
||||
# 그대로 보존하고 사유를 남긴다.
|
||||
if fmt == "pdf" and doc.bundle_page_start is not None and doc.bundle_page_end is not None:
|
||||
# 후보 A: 자식 file_path 는 합성값(`{부모}#p{s}-{e}`) → 실파일 = bundle_source_path 로 부모경로
|
||||
# 복원 + NFC/NFD resolve. (자식 file_path 는 디스크에 없음.)
|
||||
from workers.presegment_worker import _resolve_path as _resolve_bundle_path
|
||||
from workers.presegment_worker import bundle_source_path
|
||||
real_rel = bundle_source_path(doc.file_path)
|
||||
src = _resolve_bundle_path(str(Path(settings.nas_mount_path) / real_rel))
|
||||
if src is None:
|
||||
raise FileNotFoundError(f"번들 원본 파일 없음: {real_rel}")
|
||||
start, end = doc.bundle_page_start, doc.bundle_page_end
|
||||
try:
|
||||
pymupdf_text = _extract_pdf_pymupdf(src, start, end)
|
||||
page_count = _get_pdf_page_count(src, start, end)
|
||||
except Exception as e:
|
||||
logger.error(f"[pymupdf:child] {doc.file_path} pages={start}-{end} 실패: {e}")
|
||||
raise
|
||||
|
||||
meta = doc.extract_meta or {}
|
||||
meta["presegment_child_range"] = {"start_page": start, "end_page": end}
|
||||
meta["pymupdf_chars"] = len(pymupdf_text.strip())
|
||||
should, reason = _should_ocr(pymupdf_text, page_count)
|
||||
if should:
|
||||
# range OCR 미지원(후속 PR) — PyMuPDF 결과 유지 + 사유 기록(silent skip 아님).
|
||||
meta["ocr_skip_reason"] = "presegment_child_range_ocr_unsupported"
|
||||
meta["ocr_reason"] = reason
|
||||
logger.warning(
|
||||
f"[pymupdf:child] {doc.file_path} pages={start}-{end} "
|
||||
f"OCR 필요({reason})하나 range OCR 미지원 → PyMuPDF 결과 유지"
|
||||
)
|
||||
doc.extracted_text = pymupdf_text.replace("\x00", "")
|
||||
doc.extracted_at = datetime.now(timezone.utc)
|
||||
doc.extractor_version = PYMUPDF_VERSION if pymupdf_text.strip() else None
|
||||
doc.extract_meta = meta
|
||||
logger.info(
|
||||
f"[pymupdf:child] {doc.file_path} pages={start}-{end} ({len(pymupdf_text)}자)"
|
||||
)
|
||||
return
|
||||
|
||||
# ─── kordoc 파싱 (HWP/HWPX/PDF) + PyMuPDF fallback + OCR ───
|
||||
if fmt in KORDOC_FORMATS:
|
||||
container_path = f"/documents/{doc.file_path}"
|
||||
|
||||
+127
-61
@@ -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
|
||||
@@ -58,6 +59,23 @@ SCAN_TARGETS: list[tuple[str, str | None]] = [
|
||||
("Videos", "video"),
|
||||
]
|
||||
|
||||
# 안전 자료실 A-2/B-4 — watch 타깃별 (material_type, jurisdiction, license) deterministic 축.
|
||||
# 키 = 타깃 경로의 마지막 성분. license = extract_meta.license 에 그대로 주입(None=미주입).
|
||||
# restricted=true → retrieval_service._license_sql 가 RAG 증거·digest 에서 제외(a안 U-2① —
|
||||
# 구매자료 verbatim span 차단, 색인 자체는 허용. 개인 파일 열람은 미차단).
|
||||
# 사용자 결정(2026-06-13): Books/Papers=proprietary+restricted / Manuals=proprietary·restricted=false
|
||||
# (검색·RAG 활용) / KGS=법정 위임 상세기준 law/KR·KOGL 공공·restricted 아님.
|
||||
_TARGET_AXIS: dict[str, tuple[str, str | None, dict | None]] = {
|
||||
"KGS_Code": ("law", "KR", {"scheme": "kogl", "redistribute": True,
|
||||
"restricted": False, "attribution": "한국가스안전공사(KGS)"}),
|
||||
"Books": ("book", None, {"scheme": "proprietary", "redistribute": False,
|
||||
"restricted": True, "attribution": "구매 도서"}),
|
||||
"Papers_Purchased": ("paper", None, {"scheme": "proprietary", "redistribute": False,
|
||||
"restricted": True, "attribution": "구매 논문"}),
|
||||
"Manuals": ("manual", None, {"scheme": "proprietary", "redistribute": False,
|
||||
"restricted": False, "attribution": "기술 매뉴얼"}),
|
||||
}
|
||||
|
||||
|
||||
def should_skip(path: Path) -> bool:
|
||||
if path.name in SKIP_NAMES or path.name.startswith("._"):
|
||||
@@ -100,16 +118,18 @@ def _route_media(path: Path, expected_category: str | None) -> tuple[str | None,
|
||||
if expected_category == "library":
|
||||
# 외부 작성 학습 자료 (KGS Code, 시행규칙 등). 문서 확장자만 수락.
|
||||
# frontmatter 해석은 classify_worker (옵션 C) 가 담당. file_watcher 는 라우팅만.
|
||||
# G2: 첫 stage=presegment (후보 A 검증완료). 非PDF/단일 통과, 번들 PDF 만 분할.
|
||||
if ext in LIBRARY_DOC_EXTS:
|
||||
return ("library", False, "extract")
|
||||
return ("library", False, "presegment")
|
||||
if ext in AUDIO_EXTS or ext in VIDEO_DIRECT_EXTS or ext in VIDEO_QUARANTINE_EXTS:
|
||||
return (None, False, None) # audio/video 잘못 들어오면 skip
|
||||
return (None, False, None) # 기타 알 수 없는 확장자 skip
|
||||
|
||||
# Inbox: 문서 파이프 (기존). audio/video 확장자가 실수로 여기 들어오면 skip.
|
||||
# G2: 첫 stage=presegment (후보 A 검증완료). 非PDF/단일 통과, 번들 PDF 만 분할.
|
||||
if ext in AUDIO_EXTS or ext in VIDEO_DIRECT_EXTS or ext in VIDEO_QUARANTINE_EXTS:
|
||||
return (None, False, None)
|
||||
return (None, False, "extract")
|
||||
return (None, False, "presegment")
|
||||
|
||||
|
||||
# ─── Web/Blog ingest (devonagent 트랙) 헬퍼 ──────────────────────────────────
|
||||
@@ -119,6 +139,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 ""
|
||||
@@ -204,7 +228,8 @@ async def _ingest_web_file(session, file_path: Path, rel_path: str) -> tuple[int
|
||||
)
|
||||
session.add(doc)
|
||||
await session.flush()
|
||||
await enqueue_stage(session, doc.id, "extract")
|
||||
# G2: 첫 stage=presegment (후보 A 검증완료). HTML(非PDF)은 presegment 가 무변 통과 → extract.
|
||||
await enqueue_stage(session, doc.id, "presegment")
|
||||
return (1, 0)
|
||||
|
||||
|
||||
@@ -226,77 +251,118 @@ async def watch_inbox():
|
||||
for extra_path in settings.additional_watch_targets:
|
||||
targets.append((extra_path, "library"))
|
||||
|
||||
async with async_session() as session:
|
||||
# ─── Web/ 트랙 (devonagent) — DEVONthink Smart Rule 이 떨군 .html 만 진입 ───
|
||||
if web_root.exists():
|
||||
for file_path in 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))
|
||||
added, _ = await _ingest_web_file(session, file_path, rel_path)
|
||||
# 파일별 독립 세션+commit 으로 격리 — 한 파일 실패(예: rglob↔stat 사이 삭제로 FileNotFoundError,
|
||||
# flush 오류)가 watch_inbox 전체를 raise·롤백해 그 사이클 등록분을 모두 잃거나, 결정적 poison
|
||||
# 파일이 매 사이클 같은 지점에서 중단시키는 것을 차단 (news_collector/csb_collector 와 동형).
|
||||
# ─── Web/ 트랙 (devonagent) — DEVONthink Smart Rule 이 떨군 .html 만 진입 ───
|
||||
if web_root.exists():
|
||||
# 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))
|
||||
try:
|
||||
async with async_session() as session:
|
||||
added, _ = await _ingest_web_file(session, file_path, rel_path)
|
||||
await session.commit()
|
||||
new_count += added
|
||||
|
||||
# ─── PKM 트랙 (기존 drive_sync) ─────────────────────────────────────────
|
||||
for sub, expected_category in targets:
|
||||
scan_root = pkm_root / sub
|
||||
if not scan_root.exists():
|
||||
except Exception as e:
|
||||
logger.warning("[Web] 파일 처리 실패 skip path=%s: %s", rel_path, e)
|
||||
continue
|
||||
|
||||
for file_path in scan_root.rglob("*"):
|
||||
if not file_path.is_file() or should_skip(file_path):
|
||||
continue
|
||||
# ─── PKM 트랙 (기존 drive_sync) ─────────────────────────────────────────
|
||||
for sub, expected_category in targets:
|
||||
scan_root = pkm_root / sub
|
||||
if not scan_root.exists():
|
||||
continue
|
||||
|
||||
category, needs_conversion, next_stage = _route_media(
|
||||
file_path, expected_category
|
||||
)
|
||||
# 안전 자료실 A-2/B-4 — 타깃 폴더 기반 (material, jurisdiction, license)
|
||||
target_mt, target_jur, target_license = _TARGET_AXIS.get(
|
||||
Path(sub).name, (None, None, None)
|
||||
)
|
||||
|
||||
# audio/video 폴더에 엉뚱한 확장자가 들어왔거나 Inbox 에
|
||||
# audio/video 가 잘못 떨어진 경우 — 이 라운드에서 아예 skip
|
||||
if category is None and next_stage is None:
|
||||
continue
|
||||
# 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
|
||||
|
||||
rel_path = str(file_path.relative_to(nas_root))
|
||||
fhash = file_hash(file_path)
|
||||
category, needs_conversion, next_stage = _route_media(
|
||||
file_path, expected_category
|
||||
)
|
||||
|
||||
result = await session.execute(
|
||||
select(Document).where(Document.file_path == rel_path)
|
||||
)
|
||||
existing = result.scalar_one_or_none()
|
||||
# audio/video 폴더에 엉뚱한 확장자가 들어왔거나 Inbox 에
|
||||
# audio/video 가 잘못 떨어진 경우 — 이 라운드에서 아예 skip
|
||||
if category is None and next_stage is None:
|
||||
continue
|
||||
|
||||
if existing is None:
|
||||
ext = file_path.suffix.lstrip(".").lower() or "unknown"
|
||||
doc = Document(
|
||||
file_path=rel_path,
|
||||
file_hash=fhash,
|
||||
file_format=ext,
|
||||
file_size=file_path.stat().st_size,
|
||||
file_type="immutable",
|
||||
title=file_path.stem,
|
||||
source_channel="drive_sync",
|
||||
category=category,
|
||||
needs_conversion=needs_conversion,
|
||||
rel_path = str(file_path.relative_to(nas_root))
|
||||
try:
|
||||
# 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)
|
||||
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(Document).where(Document.file_path == rel_path)
|
||||
)
|
||||
session.add(doc)
|
||||
await session.flush()
|
||||
existing = result.scalar_one_or_none()
|
||||
|
||||
if next_stage:
|
||||
await enqueue_stage(session, doc.id, next_stage)
|
||||
new_count += 1
|
||||
if existing is None:
|
||||
ext = file_path.suffix.lstrip(".").lower() or "unknown"
|
||||
doc = Document(
|
||||
file_path=rel_path,
|
||||
file_hash=fhash,
|
||||
file_format=ext,
|
||||
file_size=file_path.stat().st_size,
|
||||
file_type="immutable",
|
||||
title=file_path.stem,
|
||||
source_channel="drive_sync",
|
||||
category=category,
|
||||
needs_conversion=needs_conversion,
|
||||
# 안전 자료실 A-2/B-4 — watch 타깃 매핑 (KGS=law/KR 등, 비대상=NULL)
|
||||
material_type=target_mt,
|
||||
jurisdiction=target_jur,
|
||||
)
|
||||
# B-4 — 타깃 폴더 license 주입(restricted 포함, 비대상=미주입). classify 는
|
||||
# material_type IS NULL 일 때만 제안 + extract_meta 미기록이라 주입 보존.
|
||||
if target_license:
|
||||
doc.extract_meta = {"license": dict(target_license)}
|
||||
session.add(doc)
|
||||
await session.flush()
|
||||
|
||||
elif existing.file_hash != fhash:
|
||||
existing.file_hash = fhash
|
||||
existing.file_size = file_path.stat().st_size
|
||||
# 기존 문서에 category/quarantine flag 가 비어있으면 보정
|
||||
if existing.category is None and category is not None:
|
||||
existing.category = category
|
||||
if needs_conversion and not getattr(existing, "needs_conversion", False):
|
||||
existing.needs_conversion = True
|
||||
if next_stage:
|
||||
await enqueue_stage(session, doc.id, next_stage)
|
||||
await session.commit()
|
||||
new_count += 1
|
||||
|
||||
if next_stage:
|
||||
await enqueue_stage(session, existing.id, next_stage)
|
||||
changed_count += 1
|
||||
elif existing.file_hash != fhash:
|
||||
existing.file_hash = fhash
|
||||
existing.file_size = file_path.stat().st_size
|
||||
# 기존 문서에 category/quarantine flag 가 비어있으면 보정
|
||||
if existing.category is None and category is not None:
|
||||
existing.category = category
|
||||
if needs_conversion and not getattr(existing, "needs_conversion", False):
|
||||
existing.needs_conversion = True
|
||||
# B-4 — 축/license 보정(B-4 이전 적재분이 재변경 시): material 미설정 시 주입,
|
||||
# license 부재 시에만 merge 주입(clobber 회피 — 기존 extract_meta 키 보존).
|
||||
if existing.material_type is None and target_mt is not None:
|
||||
existing.material_type = target_mt
|
||||
existing.jurisdiction = target_jur
|
||||
if target_license and not (existing.extract_meta or {}).get("license"):
|
||||
meta = dict(existing.extract_meta or {})
|
||||
meta["license"] = dict(target_license)
|
||||
existing.extract_meta = meta
|
||||
|
||||
await session.commit()
|
||||
if next_stage:
|
||||
await enqueue_stage(session, existing.id, next_stage)
|
||||
await session.commit()
|
||||
changed_count += 1
|
||||
# else: 무변경 → 쓰기 없음 (세션 자동 닫힘, commit 불요)
|
||||
except Exception as e:
|
||||
logger.warning("[PKM] 파일 처리 실패 skip path=%s: %s", rel_path, e)
|
||||
continue
|
||||
|
||||
if new_count or changed_count:
|
||||
logger.info(f"[Inbox+§3] 새 파일 {new_count}건, 변경 파일 {changed_count}건 등록")
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
"""C-2 KOSHA Open API 수집 워커 (plan crawl-24x7-1).
|
||||
|
||||
3 API (2026-06-10 실키 live 검증 + fixture 박제 — tests/fixtures/kosha_*_response.json):
|
||||
4 API (2026-06-10/06-13 실키 live 검증 + fixture 박제 — tests/fixtures/kosha_*_response.json):
|
||||
재해사례 게시판: GET /B552468/disaster_api02/getdisaster_api02 callApiId=1060
|
||||
재해사례 첨부: GET /B552468/disaster_attach_api02/Disaster_attach_api02 callApiId=1070
|
||||
KOSHA GUIDE: GET /B552468/koshaguide/getKoshaGuide callApiId=1050
|
||||
사망사고 속보: GET /B552468/news_api02/getNews_api02 callApiId=1040
|
||||
|
||||
daily 스케줄 1회 (main.py):
|
||||
재해사례 = 최근 페이지만 diff (boardno dedup) — 사례 본문 Document(텍스트 네이티브)
|
||||
+ 첨부 PDF/HWP 다운로드 → /documents/crawl_raw/kosha/{boardno}/ 저장
|
||||
→ 파일 Document + extract enqueue (kordoc HWP/PDF 기존 파이프라인 재사용).
|
||||
사망사고 = 최근 페이지만 diff (arno dedup) — 속보 본문 Document(HTML → _clean_html).
|
||||
첨부 API 없음·business 필드 없음. 등록일 = arno 접두 8자리(YYYYMMDD).
|
||||
GUIDE = 전체 레지스트리 메타 diff (1039건, 100/page = 11 call) → 신규/개정만,
|
||||
일일 ingest cap(기본 25) = backlog 자동 점진 백필(~6주) + 부하 평탄화.
|
||||
cap 으로 미처리 잔량은 매회 로그 (silent cap 금지).
|
||||
@@ -23,7 +26,7 @@ import hashlib
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from datetime import date, datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
@@ -38,6 +41,7 @@ from models.news_source import NewsSource
|
||||
from models.queue import enqueue_stage
|
||||
from workers.news_collector import (
|
||||
FeedError,
|
||||
_clean_html,
|
||||
_get_or_create_health,
|
||||
_record_failure,
|
||||
_record_success,
|
||||
@@ -49,17 +53,36 @@ _BASE = "https://apis.data.go.kr/B552468"
|
||||
_BOARD_EP = f"{_BASE}/disaster_api02/getdisaster_api02"
|
||||
_ATTACH_EP = f"{_BASE}/disaster_attach_api02/Disaster_attach_api02"
|
||||
_GUIDE_EP = f"{_BASE}/koshaguide/getKoshaGuide"
|
||||
_FATAL_EP = f"{_BASE}/news_api02/getNews_api02"
|
||||
|
||||
_CASE_SOURCE = "KOSHA 재해사례"
|
||||
_GUIDE_SOURCE = "KOSHA GUIDE"
|
||||
_FATAL_SOURCE = "KOSHA 사망사고"
|
||||
|
||||
_CASE_PAGES = 2 # daily diff 범위 (30×2 = 최근 60건 — 등록일 역순 API)
|
||||
_CASE_ROWS = 30
|
||||
_FATAL_PAGES = 2 # 사망사고 속보 daily diff (30×2 = 최근 60건 — 등록일 역순)
|
||||
_FATAL_ROWS = 30
|
||||
_GUIDE_ROWS = 100
|
||||
_GUIDE_DAILY_CAP = int(os.getenv("KOSHA_GUIDE_DAILY_CAP", "25"))
|
||||
_MAX_FILE_BYTES = 50 * 1024 * 1024
|
||||
_DOWNLOAD_DELAY = (2.0, 5.0) # portal.kosha.or.kr 파일서버 — 연속 다운로드 간격
|
||||
|
||||
# 안전 자료실 A-2 — KOSHA 산출물 라이선스 (KOGL 유형 미확정 → 보수적 redistribute=False,
|
||||
# 근거 확보 시 완화. 0-3 license 메타 deterministic 주입).
|
||||
_KOSHA_LICENSE = {"scheme": "kogl", "redistribute": False, "attribution": "한국산업안전보건공단(KOSHA)"}
|
||||
|
||||
|
||||
def _ymd_to_date(ymd: str | None) -> date | None:
|
||||
"""'YYYYMMDD'/'YYYY-MM-DD' → date. 형식 불일치는 None (fail-quiet — 날짜는 보조 축)."""
|
||||
digits = re.sub(r"\D", "", ymd or "")
|
||||
if len(digits) != 8:
|
||||
return None
|
||||
try:
|
||||
return date(int(digits[:4]), int(digits[4:6]), int(digits[6:8]))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _api_key() -> str:
|
||||
key = os.getenv("KOSHA_API_KEY", "")
|
||||
@@ -93,6 +116,29 @@ def _items(payload: dict) -> list[dict]:
|
||||
return [item] if isinstance(item, dict) else list(item)
|
||||
|
||||
|
||||
def _fatal_fields(item: dict) -> dict | None:
|
||||
"""사망사고 item(arno/keyword/contents 3필드 고정) → Document 필드 매핑.
|
||||
|
||||
순수 함수(httpx/DB 불요 — fixture 단위 테스트 대상). 필수 = arno+keyword,
|
||||
부재 시 None(skip). 날짜 전용 필드가 없어 등록 식별자 arno 접두에서 유도:
|
||||
arno = 'YYYYMMDDHHMMSS' + 임의 6자 (2019~ 라이브 전수 동형 검증). 접두 8자리=KST
|
||||
등록일 → published_date, 14자리=등록시각 → reg_dt(원문 그대로, tz 해석 미주장).
|
||||
"""
|
||||
arno = str(item.get("arno") or "").strip()
|
||||
title = (item.get("keyword") or "").strip()
|
||||
if not arno or not title:
|
||||
return None
|
||||
text = _clean_html(item.get("contents") or "", max_len=None)
|
||||
reg_dt = arno[:14] if re.fullmatch(r"\d{14}", arno[:14]) else None
|
||||
return {
|
||||
"arno": arno,
|
||||
"title": title,
|
||||
"text": text,
|
||||
"published_date": _ymd_to_date(arno[:8]),
|
||||
"reg_dt": reg_dt,
|
||||
}
|
||||
|
||||
|
||||
def _safe_filename(name: str) -> str:
|
||||
"""NAS 파일명 정화 — 경로분리자/제어문자/공백연쇄 제거 (쉘 함정 회피)."""
|
||||
name = re.sub(r"[/\\\x00-\x1f]", "_", name).strip()
|
||||
@@ -155,7 +201,11 @@ async def _ingest_attachment(session, boardno: str, filenm: str, filepath: str)
|
||||
import_source="kosha_api",
|
||||
edit_url=filepath,
|
||||
ai_tags=["Safety/KOSHA재해사례/첨부"],
|
||||
extract_meta={"kosha": {"boardno": boardno, "kind": "case_attachment"}},
|
||||
# 안전 자료실 A-2 — ingest 시점 deterministic (classify 경유해도 LLM 비의존)
|
||||
material_type="incident",
|
||||
jurisdiction="KR",
|
||||
extract_meta={"kosha": {"boardno": boardno, "kind": "case_attachment"},
|
||||
"license": dict(_KOSHA_LICENSE)},
|
||||
)
|
||||
session.add(doc)
|
||||
await session.flush()
|
||||
@@ -213,12 +263,16 @@ async def collect_disaster_cases(session) -> int:
|
||||
ai_domain="Safety",
|
||||
ai_sub_group=_CASE_SOURCE,
|
||||
ai_tags=[f"Safety/KOSHA재해사례/{business or '기타'}"],
|
||||
# 안전 자료실 A-2 — ingest 시점 deterministic (classify-skip 경로)
|
||||
material_type="incident",
|
||||
jurisdiction="KR",
|
||||
extract_meta={
|
||||
"source_id": source.id,
|
||||
"source_name": _CASE_SOURCE,
|
||||
"published_at": None,
|
||||
"kosha": {"boardno": boardno, "business": business,
|
||||
"atcflcnt": item.get("atcflcnt")},
|
||||
"license": dict(_KOSHA_LICENSE),
|
||||
},
|
||||
)
|
||||
session.add(doc)
|
||||
@@ -243,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 # 등록일 역순 — 페이지 전체가 기존이면 이후 페이지도 기존
|
||||
|
||||
@@ -250,6 +308,85 @@ async def collect_disaster_cases(session) -> int:
|
||||
return new_count
|
||||
|
||||
|
||||
async def collect_fatal_accidents(session) -> int:
|
||||
"""사망사고 속보 daily diff — 최근 _FATAL_PAGES 페이지, arno dedup.
|
||||
|
||||
재해사례(1060)와 별 채널(1040): business 필드·첨부 API 없음, contents=HTML.
|
||||
본문 = 텍스트 네이티브(_clean_html) → md 변환 비대상, summarize/embed/chunk 큐.
|
||||
"""
|
||||
key = _api_key()
|
||||
source = await _get_or_create_source(session, _FATAL_SOURCE, _FATAL_EP)
|
||||
new_count = 0
|
||||
|
||||
for page in range(1, _FATAL_PAGES + 1):
|
||||
payload = await _api_get(
|
||||
f"{_FATAL_EP}?serviceKey={key}&callApiId=1040&pageNo={page}&numOfRows={_FATAL_ROWS}"
|
||||
)
|
||||
items = _items(payload)
|
||||
if not items:
|
||||
break
|
||||
page_all_dup = True
|
||||
for item in items:
|
||||
fields = _fatal_fields(item)
|
||||
if fields is None:
|
||||
continue
|
||||
arno = fields["arno"]
|
||||
fhash = hashlib.sha256(f"kosha-fatal|{arno}".encode()).hexdigest()[:32]
|
||||
existing = await session.execute(
|
||||
select(Document).where(Document.file_hash == fhash).limit(1)
|
||||
)
|
||||
if existing.scalars().first():
|
||||
continue
|
||||
page_all_dup = False
|
||||
|
||||
text = fields["text"]
|
||||
now = datetime.now(timezone.utc)
|
||||
doc = Document(
|
||||
file_path=f"crawl/{_FATAL_SOURCE}/{arno}",
|
||||
file_hash=fhash,
|
||||
file_format="article",
|
||||
file_size=len(text.encode()),
|
||||
file_type="note",
|
||||
title=fields["title"],
|
||||
extracted_text=f"{fields['title']}\n\n{text}",
|
||||
extracted_at=now,
|
||||
extractor_version="kosha_api",
|
||||
md_status="skipped",
|
||||
md_extraction_error="kosha fatal: 텍스트 네이티브, markdown 변환 비대상",
|
||||
source_channel="crawl",
|
||||
data_origin="external",
|
||||
review_status="approved",
|
||||
ai_domain="Safety",
|
||||
ai_sub_group=_FATAL_SOURCE,
|
||||
ai_tags=["Safety/KOSHA사망사고"],
|
||||
# 안전 자료실 A-2 — ingest 시점 deterministic (classify-skip 경로)
|
||||
material_type="incident",
|
||||
jurisdiction="KR",
|
||||
published_date=fields["published_date"],
|
||||
extract_meta={
|
||||
"source_id": source.id,
|
||||
"source_name": _FATAL_SOURCE,
|
||||
"published_at": None,
|
||||
"kosha": {"arno": arno, "kind": "fatal_accident",
|
||||
"reg_dt": fields["reg_dt"]},
|
||||
"license": dict(_KOSHA_LICENSE),
|
||||
},
|
||||
)
|
||||
session.add(doc)
|
||||
await session.flush()
|
||||
await enqueue_stage(session, doc.id, "summarize")
|
||||
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 # 등록일 역순 — 페이지 전체가 기존이면 이후 페이지도 기존
|
||||
|
||||
logger.info(f"[kosha] 사망사고 신규 {new_count}건")
|
||||
return new_count
|
||||
|
||||
|
||||
async def collect_kosha_guide(session, cap: int = _GUIDE_DAILY_CAP) -> int:
|
||||
"""GUIDE 레지스트리 전체 메타 diff → 신규/개정만 다운로드 (일일 cap 점진 백필)."""
|
||||
key = _api_key()
|
||||
@@ -307,13 +444,20 @@ async def collect_kosha_guide(session, cap: int = _GUIDE_DAILY_CAP) -> int:
|
||||
import_source="kosha_api",
|
||||
edit_url=spec["url"],
|
||||
ai_tags=["Safety/KOSHA GUIDE"],
|
||||
# 안전 자료실 A-2 — GUIDE = 구속력 없는 권고 기술지침 (law 아님, plan 0-1)
|
||||
material_type="guide",
|
||||
jurisdiction="KR",
|
||||
published_date=_ymd_to_date(spec["ymd"]),
|
||||
extract_meta={"kosha": {"kind": "guide", "techGdlnNo": spec["no"],
|
||||
"ofancYmd": spec["ymd"]}},
|
||||
"ofancYmd": spec["ymd"]},
|
||||
"license": dict(_KOSHA_LICENSE)},
|
||||
)
|
||||
session.add(doc)
|
||||
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"
|
||||
@@ -325,6 +469,7 @@ async def run() -> None:
|
||||
"""daily 1회 — 소스별 실패 격리 (재해사례 실패가 GUIDE 를 막지 않게)."""
|
||||
now = datetime.now(timezone.utc)
|
||||
for name, collector in ((_CASE_SOURCE, collect_disaster_cases),
|
||||
(_FATAL_SOURCE, collect_fatal_accidents),
|
||||
(_GUIDE_SOURCE, collect_kosha_guide)):
|
||||
async with async_session() as session:
|
||||
result = await session.execute(select(NewsSource).where(NewsSource.name == name))
|
||||
|
||||
@@ -1,350 +0,0 @@
|
||||
"""법령 모니터 워커 — 국가법령정보센터 API 연동
|
||||
|
||||
26개 법령 모니터링, 편/장 단위 분할 저장, 변경 이력 추적.
|
||||
매일 07:00 실행 (APScheduler).
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import select
|
||||
|
||||
from core.config import settings
|
||||
from core.database import async_session
|
||||
from core.utils import create_caldav_todo, file_hash, setup_logger
|
||||
from models.automation import AutomationState
|
||||
from models.document import Document
|
||||
from models.queue import enqueue_stage
|
||||
|
||||
logger = setup_logger("law_monitor")
|
||||
|
||||
LAW_SEARCH_URL = "https://www.law.go.kr/DRF/lawSearch.do"
|
||||
LAW_SERVICE_URL = "https://www.law.go.kr/DRF/lawService.do"
|
||||
|
||||
# 모니터링 대상 법령 (26개)
|
||||
MONITORED_LAWS = [
|
||||
# 산업안전보건 핵심
|
||||
"산업안전보건법",
|
||||
"산업안전보건법 시행령",
|
||||
"산업안전보건법 시행규칙",
|
||||
"산업안전보건기준에 관한 규칙",
|
||||
"유해위험작업의 취업 제한에 관한 규칙",
|
||||
"중대재해 처벌 등에 관한 법률",
|
||||
"중대재해 처벌 등에 관한 법률 시행령",
|
||||
# 건설안전
|
||||
"건설기술 진흥법",
|
||||
"건설기술 진흥법 시행령",
|
||||
"건설기술 진흥법 시행규칙",
|
||||
"시설물의 안전 및 유지관리에 관한 특별법",
|
||||
# 위험물/화학
|
||||
"위험물안전관리법",
|
||||
"위험물안전관리법 시행령",
|
||||
"위험물안전관리법 시행규칙",
|
||||
"화학물질관리법",
|
||||
"화학물질관리법 시행령",
|
||||
"화학물질의 등록 및 평가 등에 관한 법률",
|
||||
# 소방/전기/가스
|
||||
"소방시설 설치 및 관리에 관한 법률",
|
||||
"소방시설 설치 및 관리에 관한 법률 시행령",
|
||||
"전기사업법",
|
||||
"전기안전관리법",
|
||||
"고압가스 안전관리법",
|
||||
"고압가스 안전관리법 시행령",
|
||||
"액화석유가스의 안전관리 및 사업법",
|
||||
# 근로/환경
|
||||
"근로기준법",
|
||||
"환경영향평가법",
|
||||
]
|
||||
|
||||
|
||||
async def run():
|
||||
"""법령 변경 모니터링 실행"""
|
||||
law_oc = os.getenv("LAW_OC", "")
|
||||
if not law_oc:
|
||||
logger.warning("LAW_OC 미설정 — 법령 API 승인 대기 중")
|
||||
return
|
||||
|
||||
async with async_session() as session:
|
||||
state = await session.execute(
|
||||
select(AutomationState).where(AutomationState.job_name == "law_monitor")
|
||||
)
|
||||
state_row = state.scalar_one_or_none()
|
||||
last_check = state_row.last_check_value if state_row else None
|
||||
|
||||
today = datetime.now(timezone.utc).strftime("%Y%m%d")
|
||||
if last_check == today:
|
||||
logger.info("오늘 이미 체크 완료")
|
||||
return
|
||||
|
||||
new_count = 0
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
for law_name in MONITORED_LAWS:
|
||||
try:
|
||||
count = await _check_law(client, law_oc, law_name, session)
|
||||
new_count += count
|
||||
except Exception as e:
|
||||
logger.error(f"[{law_name}] 체크 실패: {e}")
|
||||
|
||||
# 상태 업데이트
|
||||
if state_row:
|
||||
state_row.last_check_value = today
|
||||
state_row.last_run_at = datetime.now(timezone.utc)
|
||||
else:
|
||||
session.add(AutomationState(
|
||||
job_name="law_monitor",
|
||||
last_check_value=today,
|
||||
last_run_at=datetime.now(timezone.utc),
|
||||
))
|
||||
|
||||
await session.commit()
|
||||
logger.info(f"법령 모니터 완료: {new_count}건 신규/변경 감지")
|
||||
|
||||
|
||||
async def _check_law(
|
||||
client: httpx.AsyncClient,
|
||||
law_oc: str,
|
||||
law_name: str,
|
||||
session,
|
||||
) -> int:
|
||||
"""단일 법령 검색 → 변경 감지 → 분할 저장"""
|
||||
# 법령 검색 (lawSearch.do)
|
||||
resp = await client.get(
|
||||
LAW_SEARCH_URL,
|
||||
params={"OC": law_oc, "target": "law", "type": "XML", "query": law_name},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
|
||||
root = ET.fromstring(resp.text)
|
||||
total = root.findtext(".//totalCnt", "0")
|
||||
if total == "0":
|
||||
logger.debug(f"[{law_name}] 검색 결과 없음")
|
||||
return 0
|
||||
|
||||
# 정확히 일치하는 법령 찾기
|
||||
for law_elem in root.findall(".//law"):
|
||||
found_name = law_elem.findtext("법령명한글", "").strip()
|
||||
if found_name != law_name:
|
||||
continue
|
||||
|
||||
mst = law_elem.findtext("법령일련번호", "")
|
||||
proclamation_date = law_elem.findtext("공포일자", "")
|
||||
revision_type = law_elem.findtext("제개정구분명", "")
|
||||
|
||||
if not mst:
|
||||
continue
|
||||
|
||||
# 이미 등록된 법령인지 확인 (같은 법령명 + 공포일자)
|
||||
existing = await session.execute(
|
||||
select(Document).where(
|
||||
Document.title.like(f"{law_name}%"),
|
||||
Document.source_channel == "law_monitor",
|
||||
)
|
||||
)
|
||||
existing_docs = existing.scalars().all()
|
||||
|
||||
# 같은 공포일자 이미 있으면 skip
|
||||
for doc in existing_docs:
|
||||
if proclamation_date in (doc.title or ""):
|
||||
return 0
|
||||
|
||||
# 이전 공포일 찾기 (변경 이력용)
|
||||
prev_date = ""
|
||||
if existing_docs:
|
||||
prev_date = max(
|
||||
(re.search(r'\d{8}', doc.title or "").group() for doc in existing_docs
|
||||
if re.search(r'\d{8}', doc.title or "")),
|
||||
default=""
|
||||
)
|
||||
|
||||
# 본문 조회 (lawService.do)
|
||||
text_resp = await client.get(
|
||||
LAW_SERVICE_URL,
|
||||
params={"OC": law_oc, "target": "law", "MST": mst, "type": "XML"},
|
||||
)
|
||||
text_resp.raise_for_status()
|
||||
|
||||
# 분할 저장
|
||||
count = await _save_law_split(
|
||||
session, text_resp.text, law_name, proclamation_date,
|
||||
revision_type, prev_date,
|
||||
)
|
||||
|
||||
# DB 먼저 커밋 (알림 실패가 저장을 막지 않도록)
|
||||
await session.commit()
|
||||
|
||||
# CalDAV + SMTP 알림 (실패해도 무시)
|
||||
try:
|
||||
_send_notifications(law_name, proclamation_date, revision_type)
|
||||
except Exception as e:
|
||||
logger.warning(f"[{law_name}] 알림 발송 실패 (무시): {e}")
|
||||
|
||||
return count
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
async def _save_law_split(
|
||||
session, xml_text: str, law_name: str, proclamation_date: str,
|
||||
revision_type: str, prev_date: str,
|
||||
) -> int:
|
||||
"""법령 XML → 장(章) 단위 Markdown 분할 저장"""
|
||||
root = ET.fromstring(xml_text)
|
||||
|
||||
# 조문단위에서 장 구분자 찾기 (조문키가 000으로 끝나는 조문)
|
||||
units = root.findall(".//조문단위")
|
||||
chapters = [] # [(장제목, [조문들])]
|
||||
current_chapter = None
|
||||
current_articles = []
|
||||
|
||||
for unit in units:
|
||||
key = unit.attrib.get("조문키", "")
|
||||
content = (unit.findtext("조문내용", "") or "").strip()
|
||||
|
||||
# 장 구분자: 키가 000으로 끝나고 내용에 "제X장" 포함
|
||||
if key.endswith("000") and re.search(r"제\d+장", content):
|
||||
# 이전 장/서문 저장
|
||||
if current_articles:
|
||||
chapter_name = current_chapter or "서문"
|
||||
chapters.append((chapter_name, current_articles))
|
||||
chapter_match = re.search(r"(제\d+장\s*.+)", content)
|
||||
current_chapter = chapter_match.group(1).strip() if chapter_match else content.strip()
|
||||
current_articles = []
|
||||
else:
|
||||
current_articles.append(unit)
|
||||
|
||||
# 마지막 장 저장
|
||||
if current_articles:
|
||||
chapter_name = current_chapter or "서문"
|
||||
chapters.append((chapter_name, current_articles))
|
||||
|
||||
# 장 분할 성공
|
||||
sections = []
|
||||
if chapters:
|
||||
for chapter_title, articles in chapters:
|
||||
md_lines = [f"# {law_name}\n", f"## {chapter_title}\n"]
|
||||
for article in articles:
|
||||
title = article.findtext("조문제목", "")
|
||||
content = article.findtext("조문내용", "")
|
||||
if title:
|
||||
md_lines.append(f"\n### {title}\n")
|
||||
if content:
|
||||
md_lines.append(content.strip())
|
||||
section_name = _safe_name(chapter_title)
|
||||
sections.append((section_name, "\n".join(md_lines)))
|
||||
else:
|
||||
# 장 분할 실패 → 전체 1파일
|
||||
full_md = _law_xml_to_markdown(xml_text, law_name)
|
||||
sections.append(("전문", full_md))
|
||||
|
||||
# 각 섹션 저장
|
||||
inbox_dir = Path(settings.nas_mount_path) / "PKM" / "Inbox"
|
||||
inbox_dir.mkdir(parents=True, exist_ok=True)
|
||||
count = 0
|
||||
|
||||
for section_name, content in sections:
|
||||
filename = f"{law_name}_{proclamation_date}_{section_name}.md"
|
||||
file_path = inbox_dir / filename
|
||||
file_path.write_text(content, encoding="utf-8")
|
||||
|
||||
rel_path = str(file_path.relative_to(Path(settings.nas_mount_path)))
|
||||
|
||||
# 변경 이력 메모
|
||||
note = ""
|
||||
if prev_date:
|
||||
note = (
|
||||
f"[자동] 법령 개정 감지\n"
|
||||
f"이전 공포일: {prev_date}\n"
|
||||
f"현재 공포일: {proclamation_date}\n"
|
||||
f"개정구분: {revision_type}"
|
||||
)
|
||||
|
||||
doc = Document(
|
||||
file_path=rel_path,
|
||||
file_hash=file_hash(file_path),
|
||||
file_format="md",
|
||||
file_size=len(content.encode()),
|
||||
file_type="immutable",
|
||||
title=f"{law_name} ({proclamation_date}) {section_name}",
|
||||
source_channel="law_monitor",
|
||||
data_origin="work",
|
||||
category="law",
|
||||
user_note=note or None,
|
||||
)
|
||||
session.add(doc)
|
||||
await session.flush()
|
||||
|
||||
await enqueue_stage(session, doc.id, "extract")
|
||||
count += 1
|
||||
|
||||
logger.info(f"[법령] {law_name} ({proclamation_date}) → {count}개 섹션 저장")
|
||||
return count
|
||||
|
||||
|
||||
def _xml_section_to_markdown(elem) -> str:
|
||||
"""XML 섹션(편/장)을 Markdown으로 변환"""
|
||||
lines = []
|
||||
for article in elem.iter():
|
||||
tag = article.tag
|
||||
text = (article.text or "").strip()
|
||||
if not text:
|
||||
continue
|
||||
if "조" in tag:
|
||||
lines.append(f"\n### {text}\n")
|
||||
elif "항" in tag:
|
||||
lines.append(f"\n{text}\n")
|
||||
elif "호" in tag:
|
||||
lines.append(f"- {text}")
|
||||
elif "목" in tag:
|
||||
lines.append(f" - {text}")
|
||||
else:
|
||||
lines.append(text)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _law_xml_to_markdown(xml_text: str, law_name: str) -> str:
|
||||
"""법령 XML 전체를 Markdown으로 변환"""
|
||||
root = ET.fromstring(xml_text)
|
||||
lines = [f"# {law_name}\n"]
|
||||
|
||||
for elem in root.iter():
|
||||
tag = elem.tag
|
||||
text = (elem.text or "").strip()
|
||||
if not text:
|
||||
continue
|
||||
if "편" in tag and "제목" not in tag:
|
||||
lines.append(f"\n## {text}\n")
|
||||
elif "장" in tag and "제목" not in tag:
|
||||
lines.append(f"\n## {text}\n")
|
||||
elif "조" in tag:
|
||||
lines.append(f"\n### {text}\n")
|
||||
elif "항" in tag:
|
||||
lines.append(f"\n{text}\n")
|
||||
elif "호" in tag:
|
||||
lines.append(f"- {text}")
|
||||
elif "목" in tag:
|
||||
lines.append(f" - {text}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _safe_name(name: str) -> str:
|
||||
"""파일명 안전 변환"""
|
||||
return re.sub(r'[^\w가-힣-]', '_', name).strip("_")
|
||||
|
||||
|
||||
def _send_notifications(law_name: str, proclamation_date: str, revision_type: str):
|
||||
"""CalDAV 할일 알림 (SMTP 발송은 2026-06-10 폐기 — CalDAV 가 단일 알림 채널)"""
|
||||
caldav_url = os.getenv("CALDAV_URL", "")
|
||||
caldav_user = os.getenv("CALDAV_USER", "")
|
||||
caldav_pass = os.getenv("CALDAV_PASS", "")
|
||||
if caldav_url and caldav_user:
|
||||
create_caldav_todo(
|
||||
caldav_url, caldav_user, caldav_pass,
|
||||
title=f"법령 검토: {law_name}",
|
||||
description=f"공포일자: {proclamation_date}, 개정구분: {revision_type}",
|
||||
due_days=7,
|
||||
)
|
||||
@@ -39,7 +39,11 @@ from models.queue import ProcessingQueue
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MARKER_ENDPOINT = "http://marker-service:3300/convert"
|
||||
# 마크다운 추출 엔드포인트. compose env `MARKER_ENDPOINT`(base URL)에서 읽는다 —
|
||||
# 기본=marker(무변), 컷오버=`http://mineru-service:3301` 로 env 플립만으로 전환.
|
||||
# marker/mineru 가 동일 /convert 계약(file_path·start/end·md+base64 images)이라 워커 무변.
|
||||
_MARKDOWN_BASE = os.getenv("MARKER_ENDPOINT", "http://marker-service:3300").rstrip("/")
|
||||
MARKER_ENDPOINT = _MARKDOWN_BASE if _MARKDOWN_BASE.endswith("/convert") else _MARKDOWN_BASE + "/convert"
|
||||
MARKER_TIMEOUT = 300 # 큰 PDF 5 분 한도
|
||||
MAX_PAGES = 200 # 소형 1-shot 경로 /convert max_pages 안전장치
|
||||
|
||||
@@ -181,7 +185,10 @@ async def process(document_id: int, session: AsyncSession) -> None:
|
||||
await _fail(session, document_id, "no file_path")
|
||||
return
|
||||
|
||||
container_path = _to_marker_path(doc.file_path)
|
||||
# 후보 A: 자식(bundle cols)은 합성 file_path(`{부모}#p{s}-{e}`) → 실파일 = bundle_source_path
|
||||
# 로 부모경로 복원. 일반 doc 은 그대로(접미사 없음). marker/mineru 는 실파일 + page 범위로 변환.
|
||||
from workers.presegment_worker import bundle_source_path
|
||||
container_path = _to_marker_path(bundle_source_path(doc.file_path))
|
||||
suffix = Path(container_path).suffix.lower()
|
||||
|
||||
# ---- (3) office/hwp → md (C-2): PDF 외 지원 포맷은 office_md 하이브리드 변환 ----
|
||||
@@ -203,7 +210,21 @@ async def process(document_id: int, session: AsyncSession) -> None:
|
||||
return
|
||||
|
||||
# ---- (4) page_count gauge + 분기 (LargeDoc split) ----
|
||||
page_count = _get_page_count(container_path)
|
||||
# G2 (PR-G2-2): 번들 자식 doc 은 부모 파일 공유 + 자기 page 범위([bundle_page_start, end],
|
||||
# 1-based inclusive)만 변환해야 한다. page_offset = 절대 시작페이지(부모 파일 기준), page_count =
|
||||
# 자식 범위의 페이지 수. cols 가 NULL(일반 doc)이면 page_offset=1 + 전체 page_count = 기존 동작 동일.
|
||||
file_page_count = _get_page_count(container_path)
|
||||
is_child = doc.bundle_page_start is not None and doc.bundle_page_end is not None
|
||||
if is_child:
|
||||
page_offset = doc.bundle_page_start
|
||||
if file_page_count is not None:
|
||||
child_end = min(doc.bundle_page_end, file_page_count)
|
||||
page_count = max(0, child_end - doc.bundle_page_start + 1)
|
||||
else:
|
||||
page_count = doc.bundle_page_end - doc.bundle_page_start + 1
|
||||
else:
|
||||
page_offset = 1
|
||||
page_count = file_page_count
|
||||
|
||||
# >MAX_SPLIT_PAGES = 변환 안전상태(manual_review). silently skip 아님.
|
||||
if page_count is not None and page_count > MAX_SPLIT_PAGES:
|
||||
@@ -222,20 +243,35 @@ async def process(document_id: int, session: AsyncSession) -> None:
|
||||
|
||||
# ---- (6) 변환 분기: 소형 1-shot / 대형(>SPLIT_THRESHOLD) page-range 분할 ----
|
||||
if page_count is not None and page_count > SPLIT_THRESHOLD_PAGES:
|
||||
await _process_split(doc, document_id, container_path, page_count, session)
|
||||
await _process_split(doc, document_id, container_path, page_count, session, page_offset)
|
||||
else:
|
||||
await _process_single(doc, document_id, container_path, session)
|
||||
await _process_single(doc, document_id, container_path, session, page_count, page_offset)
|
||||
|
||||
|
||||
async def _process_single(
|
||||
doc: Document, document_id: int, container_path: str, session: AsyncSession
|
||||
doc: Document, document_id: int, container_path: str, session: AsyncSession,
|
||||
page_count: int | None = None, page_offset: int = 1,
|
||||
) -> None:
|
||||
"""소형 PDF(≤ SPLIT_THRESHOLD_PAGES) 통째 1-shot 변환 (Phase 1B/1B.5 기존 경로)."""
|
||||
"""소형 PDF(≤ SPLIT_THRESHOLD_PAGES) 통째 1-shot 변환 (Phase 1B/1B.5 기존 경로).
|
||||
|
||||
G2 (PR-G2-2): 번들 자식(page_offset>1)은 [page_offset, page_offset+page_count-1] 범위만
|
||||
변환하도록 marker 에 start_page/end_page 를 명시한다. 일반 doc(page_offset=1)은 기존과
|
||||
동일하게 max_pages 만 보낸다(payload byte-identical).
|
||||
"""
|
||||
# 일반 doc = 기존 payload 유지. 자식만 절대 page 범위를 명시(부모 파일 기준 1-based inclusive).
|
||||
if page_offset > 1 and page_count is not None:
|
||||
req_json = {
|
||||
"file_path": container_path,
|
||||
"start_page": page_offset,
|
||||
"end_page": page_offset + page_count - 1,
|
||||
}
|
||||
else:
|
||||
req_json = {"file_path": container_path, "max_pages": MAX_PAGES}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=MARKER_TIMEOUT) as client:
|
||||
resp = await client.post(
|
||||
MARKER_ENDPOINT,
|
||||
json={"file_path": container_path, "max_pages": MAX_PAGES},
|
||||
json=req_json,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
@@ -264,6 +300,11 @@ async def _process_single(
|
||||
f"[marker] transient error id={document_id} kind={type(exc).__name__}: {exc}"
|
||||
)
|
||||
raise
|
||||
except json.JSONDecodeError as exc:
|
||||
# 200 응답의 truncated/malformed body — 연결 흔들림 등 transient. _fail(non-retryable)
|
||||
# 로 박지 말고 raise → queue retry (max_attempts 까지). 진짜 손상이면 재시도 후 failed.
|
||||
logger.warning(f"[marker] malformed json body (200) id={document_id}: {exc}")
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.exception(f"[marker] unexpected error id={document_id}: {exc}")
|
||||
await _fail(session, document_id, str(exc)[:1000])
|
||||
@@ -271,6 +312,10 @@ async def _process_single(
|
||||
|
||||
# ---- (7) image persist + md_content rewrite (Phase 1B.5) ----
|
||||
md_content_raw = data["md_content"]
|
||||
# 2026-06-20 H1: 빈 추출(스캔/이미지 PDF)을 md_status=success + 빈 md 로 박제 X
|
||||
# (계약: md_status in {success,partial} => md 非공백). office arm 동형 raise → queue 재시도 후 failed.
|
||||
if not md_content_raw.strip():
|
||||
raise ValueError("empty md_content (blank extraction) — success 박제 차단")
|
||||
images_resp = data.get("images") if MARKDOWN_IMAGE_PERSIST else None
|
||||
|
||||
saved_images: list[dict[str, Any]] = []
|
||||
@@ -509,6 +554,7 @@ async def _process_split(
|
||||
container_path: str,
|
||||
page_count: int,
|
||||
session: AsyncSession,
|
||||
page_offset: int = 1,
|
||||
) -> None:
|
||||
"""대형 PDF page-range 분할 변환.
|
||||
|
||||
@@ -519,6 +565,10 @@ async def _process_split(
|
||||
|
||||
invariant: page numbering = 1-based inclusive (batch1: 1..BATCH_PAGES, ...).
|
||||
marker slug(`_page_0_*`) 는 batch 마다 재시작 → batch 별 rewrite 후 stitch (충돌 회피).
|
||||
|
||||
G2 (PR-G2-2): page_offset = 부모 파일 기준 절대 시작페이지(번들 자식). marker 에 보내는
|
||||
page 는 절대값(page_offset 가산), manifest/기록은 자식 상대값(1-based) 유지 — 일반 doc
|
||||
(page_offset=1)은 abs==rel 이라 기존 동작과 동일.
|
||||
"""
|
||||
n_batches = (page_count + BATCH_PAGES - 1) // BATCH_PAGES
|
||||
succeeded: list[dict[str, Any]] = [] # {start_page, end_page, md}
|
||||
@@ -530,15 +580,17 @@ async def _process_split(
|
||||
|
||||
async with httpx.AsyncClient(timeout=MARKER_TIMEOUT) as client:
|
||||
for b in range(n_batches):
|
||||
start_page = b * BATCH_PAGES + 1
|
||||
start_page = b * BATCH_PAGES + 1 # 자식 상대 1-based (manifest/기록용)
|
||||
end_page = min((b + 1) * BATCH_PAGES, page_count)
|
||||
abs_start = start_page + (page_offset - 1) # 부모 파일 절대 page (marker 요청용)
|
||||
abs_end = end_page + (page_offset - 1)
|
||||
try:
|
||||
resp = await client.post(
|
||||
MARKER_ENDPOINT,
|
||||
json={
|
||||
"file_path": container_path,
|
||||
"start_page": start_page,
|
||||
"end_page": end_page,
|
||||
"start_page": abs_start,
|
||||
"end_page": abs_end,
|
||||
},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
@@ -610,6 +662,8 @@ async def _process_split(
|
||||
|
||||
md_status = "success" if not failed else "partial"
|
||||
stitched = "\n\n".join(b["md"] for b in succeeded)
|
||||
if not stitched.strip():
|
||||
raise ValueError("empty stitched md_content (all batches blank) — success 박제 차단")
|
||||
md_content = _build_large_md_content(stitched[:LARGE_DOC_MD_CONTENT_HEAD_CHARS], manifest)
|
||||
|
||||
quality = _compute_quality(stitched, doc.extracted_text or "", {"page_count": page_count})
|
||||
|
||||
@@ -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()
|
||||
+120
-103
@@ -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 = [
|
||||
@@ -209,17 +213,25 @@ async def _run_locked():
|
||||
result = await session.execute(
|
||||
select(NewsSource).where(NewsSource.enabled == True)
|
||||
)
|
||||
sources = result.scalars().all()
|
||||
source_ids = [s.id for s in result.scalars().all()]
|
||||
|
||||
if not sources:
|
||||
logger.info("활성화된 뉴스 소스 없음")
|
||||
return
|
||||
if not source_ids:
|
||||
logger.info("활성화된 뉴스 소스 없음")
|
||||
return
|
||||
|
||||
total = 0
|
||||
for source in sources:
|
||||
health = await _get_or_create_health(session, source.id)
|
||||
# 2026-06-20 H3: 소스마다 독립 세션 — 한 소스의 DB 오류가 종단 단일 commit 을 깨뜨려
|
||||
# 전 소스 insert 를 잃던 것 차단. 실패 시 rollback 후 깨끗한 상태에서 failure 기록.
|
||||
# (csb_collector 의 per-iteration 세션 패턴과 동형.)
|
||||
total = 0
|
||||
for sid in source_ids:
|
||||
async with async_session() as session:
|
||||
source = await session.get(NewsSource, sid)
|
||||
if source is None:
|
||||
continue
|
||||
sname = source.name
|
||||
health = await _get_or_create_health(session, sid)
|
||||
if not _should_attempt(health, now):
|
||||
logger.info(f"[{source.name}] circuit {health.circuit_state} — 이번 사이클 skip")
|
||||
logger.info(f"[{sname}] circuit {health.circuit_state} — 이번 사이클 skip")
|
||||
continue
|
||||
try:
|
||||
if source.feed_type == "api":
|
||||
@@ -230,14 +242,18 @@ async def _run_locked():
|
||||
source.last_fetched_at = datetime.now(timezone.utc)
|
||||
_record_success(health, count, status == "not_modified", now)
|
||||
total += count
|
||||
await session.commit()
|
||||
except Exception as e:
|
||||
# str 이 빈 예외(httpx.ConnectError('')) 대비 — health 기록과 동일 규칙
|
||||
logger.error(f"[{source.name}] 수집 실패: {str(e) or repr(e)}")
|
||||
source.last_fetched_at = datetime.now(timezone.utc)
|
||||
await session.rollback()
|
||||
logger.error(f"[{sname}] 수집 실패: {str(e) or repr(e)}")
|
||||
health = await _get_or_create_health(session, sid)
|
||||
src = await session.get(NewsSource, sid)
|
||||
if src is not None:
|
||||
src.last_fetched_at = datetime.now(timezone.utc)
|
||||
_record_failure(health, str(e) or repr(e), now)
|
||||
|
||||
await session.commit()
|
||||
logger.info(f"뉴스 수집 완료: {total}건 신규")
|
||||
await session.commit()
|
||||
logger.info(f"뉴스 수집 완료: {total}건 신규")
|
||||
|
||||
|
||||
MAX_RESPONSE_SIZE = 5 * 1024 * 1024 # 5MB
|
||||
@@ -341,11 +357,35 @@ def _entry_body(source: NewsSource, entry, summary: str) -> tuple[str, str]:
|
||||
|
||||
def _build_extract_meta(source: NewsSource, pub_dt: datetime) -> dict:
|
||||
"""fulltext_worker / 패널이 쓰는 출처 메타 (documents 에 source FK 가 없어 여기 기록)."""
|
||||
return {
|
||||
meta = {
|
||||
"source_id": source.id,
|
||||
"source_name": source.name,
|
||||
"published_at": pub_dt.isoformat(),
|
||||
}
|
||||
# 안전 자료실 A-2: 소스 레지스트리의 라이선스를 deterministic 주입 (0-3 license 메타).
|
||||
# P3 다이제스트/발행류가 redistribute=false 소스를 구조적으로 제외하는 게이트 입력.
|
||||
if source.license_scheme:
|
||||
meta["license"] = {
|
||||
"scheme": source.license_scheme,
|
||||
"redistribute": bool(source.license_redistribute),
|
||||
"attribution": source.name,
|
||||
}
|
||||
return meta
|
||||
|
||||
|
||||
def _material_axis(source: NewsSource) -> tuple[str | None, str | None]:
|
||||
"""안전 자료실 분류 축 (material_type, jurisdiction) — 레지스트리 deterministic.
|
||||
|
||||
- material_type = news_sources.material_type (NULL = 비대상, 뉴스/철학 등)
|
||||
- jurisdiction = source.country 전파. 단 paper 는 NULL 강제
|
||||
(국제 학술지에 관할 개념 부적합 — plan 0-1 계약. 레지스트리 country=US 여도 미전파).
|
||||
"""
|
||||
mt = source.material_type
|
||||
if not mt:
|
||||
return None, None
|
||||
if mt == "paper":
|
||||
return mt, None
|
||||
return mt, source.country
|
||||
|
||||
|
||||
def _doc_identity(source: NewsSource, source_short: str, category: str) -> dict:
|
||||
@@ -354,20 +394,74 @@ def _doc_identity(source: NewsSource, source_short: str, category: str) -> dict:
|
||||
file_path 접두사가 곧 채널 디렉토리. ai_domain 은 다이제스트/검색 필터의 분기 축이라
|
||||
crawl 채널이 'News' 를 오염시키지 않게 분리 (0-5 채널 레벨 분리 사상).
|
||||
"""
|
||||
material_type, jurisdiction = _material_axis(source)
|
||||
if source.source_channel == "crawl":
|
||||
domain = category if category and category != "Other" else "Domain"
|
||||
return {
|
||||
"path_prefix": "crawl",
|
||||
"ai_domain": domain,
|
||||
"ai_tags": [f"{domain}/{source_short}"],
|
||||
"material_type": material_type,
|
||||
"jurisdiction": jurisdiction,
|
||||
}
|
||||
return {
|
||||
"path_prefix": "news",
|
||||
"ai_domain": "News",
|
||||
"ai_tags": [f"News/{source_short}/{category}"],
|
||||
"material_type": material_type,
|
||||
"jurisdiction": jurisdiction,
|
||||
}
|
||||
|
||||
|
||||
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).
|
||||
|
||||
@@ -486,13 +580,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 — 먼저 적재된 쪽이 정본)
|
||||
@@ -504,31 +592,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"],
|
||||
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()
|
||||
@@ -625,13 +691,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):
|
||||
@@ -642,26 +702,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"],
|
||||
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()
|
||||
@@ -718,13 +761,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):
|
||||
@@ -735,29 +772,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"],
|
||||
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))
|
||||
@@ -0,0 +1,568 @@
|
||||
"""presegment_worker — extract 前 번들 PDF(여러 논리문서 한 파일) → N 자식 분할 (G2 / PR-G2-2).
|
||||
|
||||
전 문서가 presegment stage 로 진입한다(worker-side gating):
|
||||
- 非PDF(file_format != pdf · suffix != .pdf) = 즉시 fast-exit → enqueue_next_stage 가 extract 로 흘림.
|
||||
- PDF = PyMuPDF ToC(level-1) deterministic 분석. '명확한 번들' 만 자식 분할, 나머지는 단일문서로 extract.
|
||||
|
||||
deterministic 경로(PR-G2-2): 판정이 애매하면 보수적으로 분할하지 않고 단일문서로 둔다
|
||||
(bias to NOT splitting). 분할 = '확실한 번들' 만:
|
||||
- page_count >= MIN_BUNDLE_PAGES AND level-1 ToC 항목 >= 2 AND 모든 자식 >= MIN_CHILD_PAGES
|
||||
AND 단조 증가·비중첩 AND [1, page_count] 전 범위 커버 AND 2 <= N <= MAX_CHILDREN.
|
||||
|
||||
LLM 경계 폴백(PR-G2-3, env PRESEGMENT_LLM_FALLBACK, 기본 OFF — scaffold-first): deterministic
|
||||
이 '명확한 번들' 을 못 만든 대형 PDF(ToC 없음/level-1 없음/게이트 미달)에 한해, OFF 면 오늘과
|
||||
동일(단일문서)이고 ON 이면 off-card Qwen(맥북, 라우터 :8890, model=qwen-macbook)에게 경계를
|
||||
제안받는다. compact per-page heading 샘플만 전송(본문 미전송). LLM 출력은 **동일 검증 게이트
|
||||
(_is_clear_bundle)** 통과 시에만 deterministic 과 같은 _create_children 경로로 분할 —
|
||||
is_bundle=false / 파싱·검증 실패 = 단일문서(오늘과 동일) + presegment_llm_rejected 로깅.
|
||||
맥북 불가(503/연결/절단)는 StageDeferred 로 큐 재시도(백오프, no silent fallback).
|
||||
|
||||
분할 시 ★후보 A(물리분할 없음, uq_documents_file_path 해소): 자식 file_path = unique 합성값
|
||||
`{부모경로}#p{start}-{end}` (UNIQUE 제약 통과), 실파일은 `bundle_source_path()` 로 부모 경로 복원.
|
||||
자식은 bundle_page_start/end(1-based inclusive) 로 부모 파일의 자기 page 범위만 가리킨다.
|
||||
부모-자식 관계 정본 = document_lineage(relation_type='segmented_from'). 부모(presegment_role='parent')는
|
||||
파일 홀더라 자체 extract/embed 안 함 — enqueue_next_stage 의 presegment→extract 전이가 'parent' 면
|
||||
억제된다(queue_consumer 참조). 자식의 extract 는 이 워커가 직접 enqueue. extract_worker/marker_worker
|
||||
가 자식 처리 시 bundle_source_path() 로 실파일 접근.
|
||||
|
||||
멱등: 재실행 시 같은 부모로 이미 자식이 있으면(document_lineage segmented_from) 재생성하지 않고
|
||||
수렴(각 자식이 extract 활성/완료 상태인지만 보장)한다.
|
||||
|
||||
★해결 이력 (2026-06-18): 최초 Option A(자식이 부모 file_path 그대로 공유)는 uq_documents_file_path
|
||||
UNIQUE 위반(실번들 검증서 발견) → 합성 file_path(후보 A)로 해소. 인제스트 재활성 = 합성번들 재검증 PASS 후.
|
||||
|
||||
plan: G2 pre-segmentation (PR-G2-2 deterministic ToC segmentation)
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import re
|
||||
import unicodedata
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import BaseModel, ValidationError
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ai.client import AIClient, call_deep_or_defer, parse_json_response
|
||||
from core.config import settings
|
||||
from core.utils import setup_logger
|
||||
from models.document import Document
|
||||
from models.document_lineage import DocumentLineage
|
||||
from models.queue import enqueue_stage
|
||||
|
||||
logger = setup_logger("presegment_worker")
|
||||
|
||||
# ─── 임계값 (모듈 상수, env-override 가능, 보수적 = 분할 안 하는 쪽으로 bias) ───
|
||||
# MIN_BUNDLE_PAGES: 이 미만이면 번들로 보지 않음(단일문서). 짧은 문서의 우연한 level-1 ToC 보호.
|
||||
MIN_BUNDLE_PAGES = int(os.getenv("PRESEGMENT_MIN_BUNDLE_PAGES", "60"))
|
||||
# MIN_CHILD_PAGES: 자식 하나라도 이 미만이면 분할 거부(표지/목차만 떼지는 over-split 방지).
|
||||
MIN_CHILD_PAGES = int(os.getenv("PRESEGMENT_MIN_CHILD_PAGES", "5"))
|
||||
# MAX_CHILDREN: 자식 수 상한. 초과 = ToC 가 챕터/소제목 수준이라 논리문서 경계가 아님 → 분할 거부.
|
||||
MAX_CHILDREN = int(os.getenv("PRESEGMENT_MAX_CHILDREN", "50"))
|
||||
|
||||
# marker_worker._to_marker_path 와 동일 — NAS 상대경로 → 컨테이너 절대경로 prefix.
|
||||
CONTAINER_PATH_PREFIX = os.getenv("MARKER_CONTAINER_PATH_PREFIX", "/documents")
|
||||
|
||||
# ─── PR-G2-3 LLM 경계 폴백 (scaffold-first, 기본 OFF) ───
|
||||
# PRESEGMENT_LLM_FALLBACK: 기본 "false". OFF 면 deterministic 경로만(=오늘과 동일 — 애매하면
|
||||
# 단일문서). ON 이면 deterministic 이 '명확한 번들' 을 못 만든 대형 PDF(page_count >=
|
||||
# MIN_BUNDLE_PAGES) 에 한해 off-card Qwen(맥북, 라우터 :8890 경유)에게 경계를 제안받아
|
||||
# **동일 검증 게이트(_is_clear_bundle)** 통과 시에만 deterministic 과 같은 자식 생성 경로로 분할.
|
||||
# 검증 실패/파싱 실패/is_bundle=false = 단일문서(오늘과 동일) + presegment_llm_rejected 로깅.
|
||||
PRESEGMENT_LLM_FALLBACK = os.getenv("PRESEGMENT_LLM_FALLBACK", "false").lower() in (
|
||||
"1", "true", "yes", "on",
|
||||
)
|
||||
# LLM 에 보내는 per-page 샘플의 page 당 char 상한 (heading/첫줄만 — 본문 미전송).
|
||||
PRESEGMENT_LLM_PAGE_CHARS = int(os.getenv("PRESEGMENT_LLM_PAGE_CHARS", "80"))
|
||||
# 전체 page-sample 블록의 char 상한 (수 KB 가드 — 초과 시 잘라냄, 본문 누출/페이로드 폭발 방지).
|
||||
PRESEGMENT_LLM_SAMPLE_CHARS = int(os.getenv("PRESEGMENT_LLM_SAMPLE_CHARS", "12000"))
|
||||
|
||||
# 경계 폴백 프롬프트 (app/prompts/presegment_boundaries.txt). system 지시 + 1-based inclusive·
|
||||
# 전범위 커버·무중첩 규칙. {page_count}/{page_samples} 를 str.replace 로 주입.
|
||||
_PRESEGMENT_PROMPT_PATH = Path(__file__).parent.parent / "prompts" / "presegment_boundaries.txt"
|
||||
|
||||
|
||||
class Segment(BaseModel):
|
||||
"""LLM 이 제안하는 1-based inclusive page 범위 한 조각."""
|
||||
|
||||
start_page: int
|
||||
end_page: int
|
||||
title: str | None = None
|
||||
|
||||
|
||||
class SegmentationOutput(BaseModel):
|
||||
"""presegment_boundaries 응답 스키마. parse_json_response → model_validate."""
|
||||
|
||||
is_bundle: bool = False
|
||||
segments: list[Segment] = []
|
||||
confidence: float | None = None
|
||||
|
||||
|
||||
def _resolve_path(file_path: str) -> Path | None:
|
||||
"""NFC(DB) vs NFD(NFS) 한글 경로 차이 흡수. thumbnail_worker._resolve_path 와 동일 패턴."""
|
||||
candidates = [
|
||||
file_path,
|
||||
unicodedata.normalize("NFD", file_path),
|
||||
unicodedata.normalize("NFC", file_path),
|
||||
]
|
||||
for c in candidates:
|
||||
p = Path(c)
|
||||
if p.exists():
|
||||
return p
|
||||
parent = Path(file_path).parent
|
||||
if parent.exists():
|
||||
target = unicodedata.normalize("NFC", Path(file_path).name)
|
||||
for child in parent.iterdir():
|
||||
if unicodedata.normalize("NFC", child.name) == target:
|
||||
return child
|
||||
return None
|
||||
|
||||
|
||||
def _to_container_path(file_path: str) -> str:
|
||||
"""file_path 를 컨테이너 내부 절대경로로 변환 (marker_worker._to_marker_path 와 동일)."""
|
||||
if file_path.startswith("/"):
|
||||
return file_path
|
||||
return f"{CONTAINER_PATH_PREFIX}/{file_path}"
|
||||
|
||||
|
||||
# 후보 A: 자식 합성 file_path 패턴 `{부모경로}#p{start}-{end}` (uq_documents_file_path 유일성).
|
||||
_BUNDLE_SUFFIX_RE = re.compile(r"#p\d+-\d+$")
|
||||
|
||||
|
||||
def bundle_source_path(file_path: str | None) -> str | None:
|
||||
"""자식 합성 file_path → 부모 실파일 경로 복원. 일반 doc(접미사 없음)은 그대로 반환.
|
||||
|
||||
extract_worker/marker_worker 가 자식 처리 시 실제 파일 접근에 사용 (자식 file_path 는
|
||||
합성값이라 디스크에 없음). 결정적·세션 불필요. lineage 가 부모-자식 관계의 정본 기록.
|
||||
"""
|
||||
if not file_path:
|
||||
return file_path
|
||||
return _BUNDLE_SUFFIX_RE.sub("", file_path)
|
||||
|
||||
|
||||
def _is_pdf(doc: Document) -> bool:
|
||||
"""PDF 판정 — file_format=pdf 또는 .pdf 확장자."""
|
||||
fmt = (doc.file_format or "").lower()
|
||||
if fmt == "pdf":
|
||||
return True
|
||||
if doc.file_path:
|
||||
return Path(doc.file_path).suffix.lower() == ".pdf"
|
||||
return False
|
||||
|
||||
|
||||
def _level1_segments(toc: list, page_count: int) -> list[dict]:
|
||||
"""get_toc(simple=True) 결과에서 level-1 항목만 골라 자식 후보 segment 리스트 생성.
|
||||
|
||||
toc 항목 = [level, title, page] (page 는 1-based). level==1 만 채택.
|
||||
end_page = 다음 level-1 항목의 page - 1, 마지막 = page_count.
|
||||
동일 page 에서 시작하는 level-1 이 여럿이면 정렬 후 인접 항목으로 경계 계산되며,
|
||||
그 경우 0-페이지 segment 가 생겨 후속 검증(MIN_CHILD_PAGES·단조)에서 거부된다.
|
||||
"""
|
||||
starts = []
|
||||
for entry in toc:
|
||||
# simple=True 는 [level, title, page]. 방어적으로 길이 체크.
|
||||
if not entry or len(entry) < 3:
|
||||
continue
|
||||
level, title, page = entry[0], entry[1], entry[2]
|
||||
if level != 1:
|
||||
continue
|
||||
# ToC page 가 범위 밖(0/음수/page_count 초과)이면 깨진 ToC → 후속 검증에서 거부됨.
|
||||
starts.append((int(page), (title or "").strip()))
|
||||
|
||||
# ToC 가 정렬돼 있지 않을 수 있으므로 page 기준 정렬(원본 순서 보존 위해 안정 정렬).
|
||||
starts.sort(key=lambda x: x[0])
|
||||
|
||||
segments: list[dict] = []
|
||||
for i, (start_page, title) in enumerate(starts):
|
||||
if i + 1 < len(starts):
|
||||
end_page = starts[i + 1][0] - 1
|
||||
else:
|
||||
end_page = page_count
|
||||
segments.append({"start_page": start_page, "end_page": end_page, "title": title})
|
||||
return segments
|
||||
|
||||
|
||||
def _is_clear_bundle(segments: list[dict], page_count: int) -> tuple[bool, str]:
|
||||
"""deterministic '명확한 번들' 판정. (clear, reason) 반환.
|
||||
|
||||
clear=True 면 reason="" / clear=False 면 reason 은 거부 사유(로깅용).
|
||||
모든 조건은 보수적 — 하나라도 어긋나면 단일문서로 처리(분할 안 함).
|
||||
"""
|
||||
n = len(segments)
|
||||
if n < 2:
|
||||
return False, f"too_few_level1_entries(n={n})"
|
||||
if n > MAX_CHILDREN:
|
||||
return False, f"too_many_children(n={n}>{MAX_CHILDREN})"
|
||||
|
||||
# 첫 segment 가 1페이지에서 시작 + 마지막이 page_count 에서 끝 = 전 범위 커버.
|
||||
if segments[0]["start_page"] != 1:
|
||||
return False, f"first_start_not_1(start={segments[0]['start_page']})"
|
||||
if segments[-1]["end_page"] != page_count:
|
||||
return False, f"last_end_not_page_count(end={segments[-1]['end_page']},pc={page_count})"
|
||||
|
||||
prev_end = 0
|
||||
for seg in segments:
|
||||
start, end = seg["start_page"], seg["end_page"]
|
||||
# 단조 증가 · 비중첩: 각 start 는 직전 end + 1 이어야 빈틈/겹침 없이 [1,pc] 정확 분할.
|
||||
if start != prev_end + 1:
|
||||
return False, f"non_contiguous(start={start},prev_end={prev_end})"
|
||||
if end < start:
|
||||
return False, f"non_monotonic(start={start},end={end})"
|
||||
if (end - start + 1) < MIN_CHILD_PAGES:
|
||||
return False, f"child_too_small(pages={end - start + 1}<{MIN_CHILD_PAGES})"
|
||||
prev_end = end
|
||||
|
||||
if prev_end != page_count:
|
||||
return False, f"coverage_gap(covered={prev_end},pc={page_count})"
|
||||
|
||||
return True, ""
|
||||
|
||||
|
||||
def _child_title(parent: Document, seg: dict) -> str:
|
||||
"""자식 제목 = 부모 제목 + ' — ' + (segment 제목 또는 page 범위)."""
|
||||
base = (parent.title or "").strip() or (parent.original_filename or "") or "문서"
|
||||
seg_title = (seg.get("title") or "").strip()
|
||||
suffix = seg_title if seg_title else f"p.{seg['start_page']}-{seg['end_page']}"
|
||||
return f"{base} — {suffix}"
|
||||
|
||||
|
||||
def _child_file_hash(parent_hash: str, start: int, end: int) -> str:
|
||||
"""자식 file_hash = sha256(f"{parent.file_hash}:{start}-{end}"). 결정적 → 재실행 멱등.
|
||||
|
||||
부모 file_hash 가 NULL 일 수는 없으나(NOT NULL) 방어적으로 빈 문자열 처리.
|
||||
"""
|
||||
return hashlib.sha256(f"{parent_hash or ''}:{start}-{end}".encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
async def _ensure_child_extract(session: AsyncSession, child_id: int) -> None:
|
||||
"""자식이 아직 extract 안 됐으면 extract enqueue (멱등 수렴 경로).
|
||||
|
||||
이미 extracted_text 가 채워졌거나 활성 큐 행이 있으면 enqueue_stage 가 no-op/skip.
|
||||
"""
|
||||
child = await session.get(Document, child_id)
|
||||
if child is None:
|
||||
return
|
||||
# 이미 추출 완료면 재enqueue 불필요 (큐 중복은 enqueue_stage 가 막지만 의미상으로도 skip).
|
||||
if child.extracted_at is not None and child.extracted_text is not None:
|
||||
return
|
||||
await enqueue_stage(session, child_id, "extract")
|
||||
|
||||
|
||||
async def _create_children(
|
||||
doc: Document, segments: list[dict], session: AsyncSession
|
||||
) -> int:
|
||||
"""검증된 segments 로 자식 N개 생성 + lineage + extract enqueue + 부모 표식 (멱등).
|
||||
|
||||
deterministic '명확한 번들' 경로와 LLM 폴백 경로가 공유하는 단일 자식 생성 경로.
|
||||
호출 전 segments 는 반드시 _is_clear_bundle 검증을 통과해야 한다(여기선 재검증 X).
|
||||
commit 까지 수행. 반환값 = 실제 생성한 자식 수(이미 존재해 수렴만 한 경우 0).
|
||||
"""
|
||||
# ─── 멱등 체크: 이미 자식이 있으면 수렴만 (재생성 금지) ───
|
||||
existing_children = (
|
||||
await session.execute(
|
||||
select(DocumentLineage.derived_document_id).where(
|
||||
DocumentLineage.source_document_id == doc.id,
|
||||
DocumentLineage.relation_type == "segmented_from",
|
||||
)
|
||||
)
|
||||
).scalars().all()
|
||||
|
||||
if existing_children:
|
||||
# 부모 표식이 누락된 경우 보정(이전 부분실패 복구).
|
||||
if doc.presegment_role != "parent":
|
||||
doc.presegment_role = "parent"
|
||||
for child_id in existing_children:
|
||||
await _ensure_child_extract(session, child_id)
|
||||
await session.commit()
|
||||
logger.info(
|
||||
f"[presegment] id={doc.id} children already exist "
|
||||
f"(n={len(existing_children)}) → converge(ensure extract), no re-create"
|
||||
)
|
||||
return 0
|
||||
|
||||
# ─── 자식 N개 생성 + lineage + extract enqueue ───
|
||||
created_ids: list[int] = []
|
||||
for seg in segments:
|
||||
start, end = seg["start_page"], seg["end_page"]
|
||||
child = Document(
|
||||
# 후보 A: 자식 file_path = unique 합성값 `{부모경로}#p{s}-{e}` (uq_documents_file_path
|
||||
# 충돌 회피). 실파일은 bundle_source_path() 로 복원(부모 경로). 물리 분할 없음 —
|
||||
# 자식은 bundle_page_start/end 로 부모 파일을 슬라이스.
|
||||
file_path=f"{doc.file_path}#p{start}-{end}",
|
||||
file_hash=_child_file_hash(doc.file_hash, start, end),
|
||||
file_format=doc.file_format,
|
||||
file_size=doc.file_size,
|
||||
file_type=doc.file_type,
|
||||
import_source=doc.import_source,
|
||||
original_filename=doc.original_filename,
|
||||
source_channel=doc.source_channel,
|
||||
category=doc.category,
|
||||
data_origin=doc.data_origin,
|
||||
doc_purpose=doc.doc_purpose,
|
||||
# 안전 자료실 축은 부모에서 상속(분할이 자료유형/관할을 바꾸지 않음).
|
||||
material_type=doc.material_type,
|
||||
jurisdiction=doc.jurisdiction,
|
||||
title=_child_title(doc, seg),
|
||||
bundle_page_start=start,
|
||||
bundle_page_end=end,
|
||||
presegment_role="child",
|
||||
)
|
||||
session.add(child)
|
||||
await session.flush() # child.id 확보
|
||||
created_ids.append(child.id)
|
||||
|
||||
session.add(
|
||||
DocumentLineage(
|
||||
source_document_id=doc.id,
|
||||
derived_document_id=child.id,
|
||||
relation_type="segmented_from",
|
||||
meta={"start_page": start, "end_page": end},
|
||||
)
|
||||
)
|
||||
# 자식 extract 는 워커가 직접 enqueue (부모는 'parent' 라 extract 로 흐르지 않음).
|
||||
await enqueue_stage(session, child.id, "extract")
|
||||
|
||||
# 부모 = 파일 홀더. presegment→extract 전이는 enqueue_next_stage 가 'parent' 면 억제.
|
||||
doc.presegment_role = "parent"
|
||||
await session.commit()
|
||||
|
||||
logger.info(
|
||||
f"[presegment] id={doc.id} SPLIT into {len(created_ids)} children "
|
||||
f"child_ids={created_ids}"
|
||||
)
|
||||
return len(created_ids)
|
||||
|
||||
|
||||
def _segments_from_output(out: "SegmentationOutput") -> list[dict]:
|
||||
"""SegmentationOutput.segments(Pydantic) → _is_clear_bundle / _create_children 가 쓰는 dict 형태."""
|
||||
return [
|
||||
{"start_page": s.start_page, "end_page": s.end_page, "title": (s.title or "")}
|
||||
for s in out.segments
|
||||
]
|
||||
|
||||
|
||||
def _page_samples(pdf, page_count: int) -> str:
|
||||
"""LLM 입력용 compact per-page 샘플 — page 당 heading/첫줄만(`p{n}: {firstline}`).
|
||||
|
||||
PyMuPDF page.get_text() 로 page 별 텍스트를 스트리밍하되 page 당 첫 비공백 줄만,
|
||||
PRESEGMENT_LLM_PAGE_CHARS 로 잘라 본문 누출 차단. 전체 블록은 PRESEGMENT_LLM_SAMPLE_CHARS
|
||||
가드로 상한(수 KB) — 초과 시 그 지점에서 중단(앞쪽 페이지 우선 보존).
|
||||
"""
|
||||
lines: list[str] = []
|
||||
total = 0
|
||||
for i in range(page_count):
|
||||
try:
|
||||
text = pdf[i].get_text() or ""
|
||||
except Exception:
|
||||
text = ""
|
||||
first = ""
|
||||
for ln in text.splitlines():
|
||||
ln = ln.strip()
|
||||
if ln:
|
||||
first = ln
|
||||
break
|
||||
first = first[:PRESEGMENT_LLM_PAGE_CHARS]
|
||||
entry = f"p{i + 1}: {first}"
|
||||
if total + len(entry) + 1 > PRESEGMENT_LLM_SAMPLE_CHARS:
|
||||
break
|
||||
lines.append(entry)
|
||||
total += len(entry) + 1
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
async def _llm_boundary_fallback(
|
||||
doc: Document, source: Path, page_count: int, session: AsyncSession
|
||||
) -> bool:
|
||||
"""애매 + 대형(ToC-less 등) PDF 에 대해 off-card Qwen 으로 경계 제안 → 검증 → 분할.
|
||||
|
||||
반환 True = LLM 경로가 분할을 수행(또는 멱등 수렴)했으므로 호출자는 추가 처리 없이 return.
|
||||
반환 False = is_bundle=false / 파싱 실패 / 검증 실패 → 호출자는 단일문서(오늘과 동일) 처리.
|
||||
맥북 불가(503/연결/절단)는 call_deep_or_defer 가 StageDeferred 로 raise → 큐 재시도(백오프).
|
||||
silent fallback 금지 — deep 슬롯 외 다른 backend 자동 호출 안 함.
|
||||
"""
|
||||
import fitz # PyMuPDF — deterministic 경로와 동일 의존
|
||||
|
||||
# per-page 샘플은 파일을 다시 열어 스트리밍(deterministic with 블록과 분리해 그 경로 무회귀).
|
||||
try:
|
||||
with fitz.open(str(source)) as pdf:
|
||||
samples = _page_samples(pdf, page_count)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
f"[presegment] id={doc.id} llm fallback sample 실패 "
|
||||
f"({type(exc).__name__}: {exc}) → single doc(extract)"
|
||||
)
|
||||
return False
|
||||
|
||||
try:
|
||||
template = _PRESEGMENT_PROMPT_PATH.read_text(encoding="utf-8")
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
f"[presegment] id={doc.id} prompt 로드 실패 ({type(exc).__name__}: {exc}) "
|
||||
f"→ single doc(extract)"
|
||||
)
|
||||
return False
|
||||
|
||||
prompt = template.replace("{page_count}", str(page_count)).replace(
|
||||
"{page_samples}", samples
|
||||
)
|
||||
|
||||
# off-card 호출 — call_deep_or_defer 가 deep 슬롯(맥북, 라우터 :8890, model=qwen-macbook)
|
||||
# 으로 라우팅. 맥북 불가는 StageDeferred 로 전파(여기서 잡지 않음 → 큐가 보류/백오프).
|
||||
# classify_worker 와 동일하게 AIClient() 인스턴스화.
|
||||
client = AIClient()
|
||||
try:
|
||||
raw = await call_deep_or_defer(client, prompt)
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
parsed = parse_json_response(raw)
|
||||
if not parsed:
|
||||
logger.info(
|
||||
f"[presegment] presegment_llm_rejected id={doc.id} "
|
||||
f"reason=parse_failed raw={raw[:160]!r} → single doc(extract)"
|
||||
)
|
||||
return False
|
||||
|
||||
try:
|
||||
out = SegmentationOutput.model_validate(parsed)
|
||||
except (ValidationError, ValueError, TypeError) as exc:
|
||||
logger.info(
|
||||
f"[presegment] presegment_llm_rejected id={doc.id} "
|
||||
f"reason=schema_invalid({type(exc).__name__}) → single doc(extract)"
|
||||
)
|
||||
return False
|
||||
|
||||
if not out.is_bundle:
|
||||
logger.info(
|
||||
f"[presegment] presegment_llm_rejected id={doc.id} "
|
||||
f"reason=is_bundle_false → single doc(extract)"
|
||||
)
|
||||
return False
|
||||
|
||||
segments = _segments_from_output(out)
|
||||
clear, reason = _is_clear_bundle(segments, page_count)
|
||||
if not clear:
|
||||
# LLM 출력을 그대로 믿지 않음 — deterministic 과 동일 게이트 미달이면 단일문서.
|
||||
logger.info(
|
||||
f"[presegment] presegment_llm_rejected id={doc.id} "
|
||||
f"reason={reason} n={len(segments)} pages={page_count} → single doc(extract)"
|
||||
)
|
||||
return False
|
||||
|
||||
n = await _create_children(doc, segments, session)
|
||||
logger.info(
|
||||
f"[presegment] id={doc.id} LLM-SPLIT accepted "
|
||||
f"(pages={page_count} n={len(segments)} created={n} "
|
||||
f"confidence={out.confidence})"
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
async def process(document_id: int, session: AsyncSession) -> None:
|
||||
"""presegment stage 워커 진입점. queue_consumer 가 호출.
|
||||
|
||||
전 문서가 진입하며, 非PDF·단일문서는 변경 없이 통과(presegment_role 그대로 NULL) → extract 로 흐른다.
|
||||
'명확한 번들' PDF 만 자식 분할 + 부모를 'parent' 로 표식(이 경우 부모는 extract 로 흐르지 않음).
|
||||
"""
|
||||
doc = await session.get(Document, document_id)
|
||||
if doc is None:
|
||||
logger.warning(f"[presegment] document {document_id} not found")
|
||||
return
|
||||
|
||||
# ─── (0) 非PDF — fast-exit. presegment_role 그대로 NULL → enqueue_next_stage 가 extract 로 흘림 ───
|
||||
if not _is_pdf(doc):
|
||||
logger.info(f"[presegment] id={document_id} non-pdf (fmt={doc.file_format}) → extract")
|
||||
return
|
||||
|
||||
# ─── (0.5) file_path 없음(예: note) — 분할 불가, 단일문서로 통과 ───
|
||||
if not doc.file_path:
|
||||
logger.info(f"[presegment] id={document_id} no file_path → extract")
|
||||
return
|
||||
|
||||
# ─── (1) 이미 분할된 자식 자신이 presegment 로 다시 들어온 경우 — 재분할 금지 ───
|
||||
# (정상 흐름에선 자식은 곧장 extract 로 enqueue 되지만, 재처리 스크립트 등으로 들어올 수 있음.)
|
||||
if doc.presegment_role in ("child", "parent"):
|
||||
logger.info(
|
||||
f"[presegment] id={document_id} already presegment_role={doc.presegment_role} → skip"
|
||||
)
|
||||
return
|
||||
|
||||
# ─── (2) 파일 열기 + page_count ───
|
||||
raw = str(Path(settings.nas_mount_path) / doc.file_path)
|
||||
source = _resolve_path(raw)
|
||||
if source is None:
|
||||
# 파일 부재 = extract 가 동일 상황에서 FileNotFoundError 로 처리할 사안.
|
||||
# presegment 는 분할 불가일 뿐이므로 단일문서로 통과시켜 extract 가 일관되게 처리하게 둔다.
|
||||
logger.warning(f"[presegment] id={document_id} file not found ({raw}) → extract")
|
||||
return
|
||||
|
||||
import asyncio
|
||||
|
||||
import fitz # PyMuPDF — extract_worker/marker_worker 와 동일 의존
|
||||
|
||||
def _read_toc(path: str):
|
||||
# fitz open/get_toc 는 동기 blocking — live 스테이지라 이벤트루프(같은 루프의 1분 consumer +
|
||||
# FastAPI 요청) 점유 회피 위해 to_thread 오프로드(거대/손상 PDF 파싱 수백 ms~초).
|
||||
with fitz.open(path) as pdf:
|
||||
return pdf.page_count, (pdf.get_toc(simple=True) or [])
|
||||
|
||||
try:
|
||||
page_count, toc = await asyncio.to_thread(_read_toc, str(source))
|
||||
except Exception as exc:
|
||||
# PDF 손상 등 — 분할 불가. 단일문서로 통과(extract 가 PyMuPDF/OCR 로 재시도하며 가시화).
|
||||
logger.warning(
|
||||
f"[presegment] id={document_id} fitz open/toc failed "
|
||||
f"({type(exc).__name__}: {exc}) → extract"
|
||||
)
|
||||
return
|
||||
|
||||
# ─── (3) page_count 가 임계 미만 = 단일문서 (대다수 경로) ───
|
||||
if page_count < MIN_BUNDLE_PAGES:
|
||||
logger.info(
|
||||
f"[presegment] id={document_id} single doc "
|
||||
f"(pages={page_count}<{MIN_BUNDLE_PAGES}) → extract"
|
||||
)
|
||||
return
|
||||
|
||||
# ─── (4) level-1 ToC → 자식 후보 segment ───
|
||||
segments = _level1_segments(toc, page_count)
|
||||
|
||||
if not segments:
|
||||
# 큰 PDF 인데 ToC 없음/level-1 없음 = 애매. flag ON 이면 LLM 경계 폴백(PR-G2-3),
|
||||
# OFF(기본) 이면 오늘과 동일 — 단일문서로 처리하고 사유를 남긴다.
|
||||
if PRESEGMENT_LLM_FALLBACK:
|
||||
logger.info(
|
||||
f"[presegment] presegment_ambiguous id={document_id} "
|
||||
f"reason=no_level1_toc pages={page_count} → LLM fallback"
|
||||
)
|
||||
if await _llm_boundary_fallback(doc, source, page_count, session):
|
||||
return
|
||||
# LLM 이 분할하지 않음(is_bundle=false / 검증·파싱 실패) — 단일문서.
|
||||
return
|
||||
logger.info(
|
||||
f"[presegment] presegment_ambiguous id={document_id} "
|
||||
f"reason=no_level1_toc pages={page_count} → single doc(extract)"
|
||||
)
|
||||
return
|
||||
|
||||
clear, reason = _is_clear_bundle(segments, page_count)
|
||||
if not clear:
|
||||
# 큰 PDF + ToC 는 있으나 '명확한 번들' 기준 미달 = 애매. flag ON 이면 LLM 경계 폴백,
|
||||
# OFF(기본) 이면 오늘과 동일 — 단일문서(분할 안 함).
|
||||
if PRESEGMENT_LLM_FALLBACK:
|
||||
logger.info(
|
||||
f"[presegment] presegment_ambiguous id={document_id} "
|
||||
f"reason={reason} pages={page_count} level1={len(segments)} → LLM fallback"
|
||||
)
|
||||
if await _llm_boundary_fallback(doc, source, page_count, session):
|
||||
return
|
||||
return
|
||||
logger.info(
|
||||
f"[presegment] presegment_ambiguous id={document_id} "
|
||||
f"reason={reason} pages={page_count} level1={len(segments)} → single doc(extract)"
|
||||
)
|
||||
return
|
||||
|
||||
# ─── (5) 명확한 번들 (deterministic) — 공유 자식 생성 경로 (멱등 수렴 포함) ───
|
||||
await _create_children(doc, segments, session)
|
||||
+135
-10
@@ -13,20 +13,27 @@ from sqlalchemy import select, update, delete, exists
|
||||
from sqlalchemy.exc import IntegrityError, SQLAlchemyError
|
||||
from sqlalchemy.orm import aliased
|
||||
|
||||
from core.config import settings
|
||||
from core.database import async_session
|
||||
from core.utils import setup_logger
|
||||
from models.queue import ProcessingQueue, enqueue_stage
|
||||
from models.queue import ProcessingQueue, StageDeferred, enqueue_stage, not_deferred_condition
|
||||
|
||||
logger = setup_logger("queue_consumer")
|
||||
|
||||
# pipeline.held_stages 안내 로그는 1분 사이클마다 반복하지 않고 최초 1회만.
|
||||
_hold_logged = False
|
||||
|
||||
# stage별 배치 크기
|
||||
# stt 는 GPU 단일 점유 + 회의 30분짜리도 가능 → 배치 1. thumbnail 은 ffmpeg subprocess 로 가벼움.
|
||||
# deep_summary (PR-B B-1) 는 MLX 26B 단일 Semaphore(1) 경유 → 배치 1.
|
||||
# fulltext 는 politeness 지연(같은 도메인 5–15s)이 배치 내 직렬로 걸린다 — 배치 3 이면
|
||||
# 같은 도메인 최악 ~45s/사이클, 메인 큐 1m 간격(max_instances=1, coalesce)이 흡수.
|
||||
BATCH_SIZE = {"extract": 5, "classify": 3, "summarize": 3, "embed": 1, "chunk": 1,
|
||||
"preview": 2, "stt": 1, "thumbnail": 3, "deep_summary": 1, "markdown": 1,
|
||||
"fulltext": 3}
|
||||
# embed/chunk 1→10 (2026-06-12 fast-consumer): 건당 <1s 실측 — Phase 0.1 초기 보수값이
|
||||
# LLM 사이클에 인질로 잡혀 실효 ~580/일 vs 수요 최대 2,700/일 → 적체 원인이었음.
|
||||
# 10 = TEI/marker 와 GPU 공유 고려한 보수 상향(전용 1분 잡 기준 캡 ~14,400/일).
|
||||
BATCH_SIZE = {"presegment": 3, "extract": 5, "classify": 3, "summarize": 3, "embed": 10,
|
||||
"chunk": 10, "preview": 2, "stt": 1, "thumbnail": 3, "deep_summary": 1,
|
||||
"markdown": 1, "fulltext": 3}
|
||||
STALE_THRESHOLD_MINUTES = 10
|
||||
# markdown 대형 split 변환은 한 doc 이 수십 분(5210 ≈ 40분) 동안 processing 상태로 머문다.
|
||||
# marker_worker 는 queue 행에 heartbeat 를 찍지 않으므로(started_at 고정), main 의 10분
|
||||
@@ -34,14 +41,26 @@ STALE_THRESHOLD_MINUTES = 10
|
||||
# 따라서 markdown consumer 는 별도의 generous 임계를 쓴다.
|
||||
MARKDOWN_STALE_THRESHOLD_MINUTES = int(os.getenv("MARKDOWN_STALE_MINUTES", "120"))
|
||||
|
||||
# consume_queue(메인) 가 담당하는 stage. markdown 은 consume_markdown_queue 로 분리.
|
||||
# consume_queue(메인) 가 담당하는 stage. markdown 은 consume_markdown_queue,
|
||||
# embed/chunk 는 consume_fast_queue (2026-06-12) 로 분리 — 세 집합은 disjoint
|
||||
# (reset_stale_items 가 자기 집합만 reset, 교차 시 이중 복구 위험).
|
||||
# STT 도 장기 작업 가능성이 있으나 본 PR 범위 밖 — main 에 유지(follow-up).
|
||||
MAIN_QUEUE_STAGES = [
|
||||
"extract", "classify", "summarize", "embed", "chunk",
|
||||
"preview", "stt", "thumbnail", "deep_summary", "fulltext",
|
||||
"presegment", "extract", "classify", "summarize",
|
||||
"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)와 동일 패턴.
|
||||
FAST_QUEUE_STAGES = ["embed", "chunk"]
|
||||
|
||||
|
||||
async def reset_stale_items(stages, threshold_minutes=STALE_THRESHOLD_MINUTES):
|
||||
"""processing 상태로 오래 방치된 항목 복구 (지정 stage 한정)
|
||||
@@ -146,6 +165,10 @@ async def enqueue_next_stage(document_id: int, current_stage: str):
|
||||
}
|
||||
|
||||
next_stages = {
|
||||
# G2 (PR-G2-2): 전 문서가 presegment → extract. 단, 번들 분할로 'parent' 가 된 문서는
|
||||
# 파일 홀더라 자체 extract 안 함 — 아래 suppression 으로 이 전이를 건너뛴다(자식 extract 는
|
||||
# presegment_worker 가 직접 enqueue). 단일/非PDF 문서(role NULL)는 정상적으로 extract 로 흐름.
|
||||
"presegment": ["extract"],
|
||||
"extract": ["classify", "preview"],
|
||||
"classify": ["embed", "chunk", "markdown"],
|
||||
"stt": ["classify"],
|
||||
@@ -161,6 +184,18 @@ async def enqueue_next_stage(document_id: int, current_stage: str):
|
||||
stages = extract_override_by_channel[sc]
|
||||
else:
|
||||
stages = next_stages.get(current_stage, [])
|
||||
elif current_stage == "presegment":
|
||||
# 번들 분할 parent 는 extract 로 흐르지 않게 억제 (자식이 부모 extract 에 가려지는 것 방지).
|
||||
# role NULL(단일/非PDF) / 'child' 는 정상 전이. presegment_worker 가 자식 extract 를 직접
|
||||
# enqueue 하므로 'parent' 만 여기서 no-op.
|
||||
from models.document import Document
|
||||
async with async_session() as lookup_session:
|
||||
doc = await lookup_session.get(Document, document_id)
|
||||
role = doc.presegment_role if doc else None
|
||||
if role == "parent":
|
||||
stages = []
|
||||
else:
|
||||
stages = next_stages.get(current_stage, [])
|
||||
else:
|
||||
stages = next_stages.get(current_stage, [])
|
||||
|
||||
@@ -180,6 +215,7 @@ def _load_workers():
|
||||
from workers.deep_summary_worker import process as deep_summary_process
|
||||
from workers.embed_worker import process as embed_process
|
||||
from workers.extract_worker import process as extract_process
|
||||
from workers.presegment_worker import process as presegment_process
|
||||
from workers.preview_worker import process as preview_process
|
||||
from workers.stt_worker import process as stt_process
|
||||
from workers.summarize_worker import process as summarize_process
|
||||
@@ -188,6 +224,8 @@ def _load_workers():
|
||||
from workers.fulltext_worker import process as fulltext_process
|
||||
|
||||
return {
|
||||
# G2 (PR-G2-2): extract 前 번들 PDF → N 자식 분할 (deterministic ToC). 非PDF/단일은 통과.
|
||||
"presegment": presegment_process,
|
||||
"extract": extract_process,
|
||||
"classify": classify_process,
|
||||
"summarize": summarize_process,
|
||||
@@ -216,13 +254,14 @@ async def _process_stage(stage, worker_fn):
|
||||
"""
|
||||
batch_size = BATCH_SIZE.get(stage, 3)
|
||||
|
||||
# pending 항목 조회
|
||||
# pending 항목 조회 (보류 백오프 deferred_until 미래 항목 제외 — ds-macbook-offload-1)
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(ProcessingQueue.id, ProcessingQueue.document_id)
|
||||
.where(
|
||||
ProcessingQueue.stage == stage,
|
||||
ProcessingQueue.status == "pending",
|
||||
not_deferred_condition(),
|
||||
)
|
||||
.order_by(ProcessingQueue.created_at)
|
||||
.limit(batch_size)
|
||||
@@ -255,7 +294,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
|
||||
|
||||
@@ -273,9 +320,37 @@ 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:
|
||||
# 보류 (ds-macbook-offload-1): 맥북 일시 불가(sleep/cold/editor_busy) — 실패 아님.
|
||||
# attempts 는 claim 시 선증가분을 반환(미소모)하고 deferred_until 백오프 후 자연 재개.
|
||||
# 워커는 완주 전 doc 쓰기를 하지 않으므로 이 시점의 데이터 변경 = 0 (sleep-안전).
|
||||
async with async_session() as session:
|
||||
item = await session.get(ProcessingQueue, queue_id)
|
||||
if not item:
|
||||
logger.warning(f"[{stage}] queue_id={queue_id} 없음 (삭제됨?), skip")
|
||||
continue
|
||||
item.status = "pending"
|
||||
item.started_at = None
|
||||
item.attempts = max(0, item.attempts - 1)
|
||||
until = datetime.now(timezone.utc) + timedelta(minutes=defer.retry_after_minutes)
|
||||
item.payload = {**(item.payload or {}), "deferred_until": until.isoformat()}
|
||||
await session.commit()
|
||||
logger.info(
|
||||
f"[{stage}] document_id={document_id} 보류({defer}) — "
|
||||
f"{defer.retry_after_minutes}분 후 재개"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
# 실패 처리
|
||||
async with async_session() as session:
|
||||
@@ -314,14 +389,43 @@ async def _process_stage(stage, worker_fn):
|
||||
|
||||
async def consume_queue():
|
||||
"""메인 큐 소비자 — markdown 제외 전 stage 를 1분 간격으로 처리."""
|
||||
global _hold_logged
|
||||
workers = _load_workers()
|
||||
|
||||
held = [s for s in MAIN_QUEUE_STAGES if s in settings.pipeline_held_stages]
|
||||
if held and not _hold_logged:
|
||||
logger.info(f"pipeline.held_stages 보류 중: {held} — claim 하지 않음 (pending 적체 = 의도)")
|
||||
_hold_logged = True
|
||||
|
||||
try:
|
||||
await reset_stale_items(MAIN_QUEUE_STAGES, STALE_THRESHOLD_MINUTES)
|
||||
except Exception:
|
||||
logger.exception("stale reset failed, but continuing queue consumption")
|
||||
|
||||
for stage in MAIN_QUEUE_STAGES:
|
||||
if stage in settings.pipeline_held_stages:
|
||||
continue
|
||||
await _process_stage(stage, workers[stage])
|
||||
|
||||
|
||||
async def consume_fast_queue():
|
||||
"""embed/chunk 전용 고속 소비자 — LLM 사이클과 완전 디커플 (2026-06-12).
|
||||
|
||||
main 루프는 classify/summarize/deep 가 사이클을 분 단위로 점유해 건당 <1s 짜리
|
||||
embed/chunk 가 사이클당 1번씩만 기회를 얻었다 (실효 ~60건/시 = 적체 원인).
|
||||
분리 후 = 1분 잡 × 배치 10 → 캡 ~600건/시. APScheduler max_instances=1 이라
|
||||
배치가 1분을 넘으면 다음 fire 는 coalesce (폭주 방지).
|
||||
"""
|
||||
workers = _load_workers()
|
||||
|
||||
try:
|
||||
await reset_stale_items(FAST_QUEUE_STAGES, STALE_THRESHOLD_MINUTES)
|
||||
except Exception:
|
||||
logger.exception("fast stale reset failed, but continuing queue consumption")
|
||||
|
||||
for stage in FAST_QUEUE_STAGES:
|
||||
if stage in settings.pipeline_held_stages:
|
||||
continue
|
||||
await _process_stage(stage, workers[stage])
|
||||
|
||||
|
||||
@@ -341,3 +445,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])
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
"""수동 burst-drain CLI — 맥미니 백로그를 사용자가 의도적으로 맥북(M5 Max)으로 소화.
|
||||
|
||||
ds-macbook-offload-1 P2-3. 운영 패턴 = csb_collector --bulk 와 동일 (컨테이너 내 실행,
|
||||
장기 배치 중 fastapi 재생성 = in-flight 절단이지만 멱등 재실행으로 무손실).
|
||||
|
||||
docker compose exec fastapi python -m workers.queue_drain --stage summarize --limit 200
|
||||
|
||||
설계 원칙:
|
||||
- deep 슬롯(config.yaml ai.models.deep) 필수 — 부재 시 명시 종료 (silent 강등 금지)
|
||||
- claim = FOR UPDATE SKIP LOCKED 단건 전이 → consumer(1분 주기)와 이중처리 0
|
||||
- per-item 커밋 = sleep-안전: 중단돼도 완료분 무손상, 진행 중 1건만 stale recovery
|
||||
(10분) 로 pending 복귀. 재실행 멱등 (summarize 는 ai_summary 존재 시 skip)
|
||||
- 보류(StageDeferred = 맥북 sleep/cold/editor_busy/네트워크 플랩): attempts 반환 +
|
||||
deferred_until 백오프 기록. 연속 보류 --defer-retries(기본 5)회까지 --defer-wait
|
||||
(기본 120s) 간격 재시도(분 단위 플랩 흡수), 한도 도달 = sleep 판정으로 run 종료 —
|
||||
불가 상태의 맥북을 계속 두드리지 않는다
|
||||
- 폴백 0: 맥미니/cloud 강등 없음
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from core.config import settings
|
||||
from core.database import async_session
|
||||
from core.utils import setup_logger
|
||||
from models.queue import ProcessingQueue, StageDeferred, not_deferred_condition
|
||||
|
||||
logger = setup_logger("queue_drain")
|
||||
|
||||
# summarize = 맥미니 백로그 본체 / deep_summary = 심층 / classify = triage 분류.
|
||||
# classify 는 2026-06-12 fair-share 로 합류 — 구 제외 사유(plan Q-4 "triage 경량 = 맥미니
|
||||
# 적합")는 Gemma a4b(42 tok/s) 전제. Qwen 27B 전환 후 classify 가 장문 프리필로 컨슈머
|
||||
# 사이클을 점유하는 최대 병목이라, 맥북(프리필 ~5배)이 가장 효과적인 분담처다.
|
||||
# classify 완료 시 enqueue_next_stage(embed/chunk/markdown) 필수 — 누락 = DAG 단절.
|
||||
DRAIN_STAGES = ("summarize", "deep_summary", "classify")
|
||||
|
||||
|
||||
async def _claim_one(stage: str) -> tuple[int, int] | None:
|
||||
"""pending 1건을 processing 으로 원자 전이 (SKIP LOCKED — consumer 와 경합 안전)."""
|
||||
async with async_session() as session:
|
||||
item = (await session.execute(
|
||||
select(ProcessingQueue)
|
||||
.where(
|
||||
ProcessingQueue.stage == stage,
|
||||
ProcessingQueue.status == "pending",
|
||||
not_deferred_condition(),
|
||||
)
|
||||
.order_by(ProcessingQueue.created_at)
|
||||
.limit(1)
|
||||
.with_for_update(skip_locked=True)
|
||||
)).scalar_one_or_none()
|
||||
if item is None:
|
||||
return None
|
||||
item.status = "processing"
|
||||
item.started_at = datetime.now(timezone.utc)
|
||||
item.attempts += 1
|
||||
claimed = (item.id, item.document_id)
|
||||
await session.commit()
|
||||
return claimed
|
||||
|
||||
|
||||
async def _mark_completed(queue_id: int) -> None:
|
||||
async with async_session() as session:
|
||||
item = await session.get(ProcessingQueue, queue_id)
|
||||
if item:
|
||||
item.status = "completed"
|
||||
item.completed_at = datetime.now(timezone.utc)
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def _mark_deferred(queue_id: int, defer: StageDeferred) -> None:
|
||||
"""보류: attempts 반환(미소모) + deferred_until 백오프 — consumer 의 처리와 동형."""
|
||||
async with async_session() as session:
|
||||
item = await session.get(ProcessingQueue, queue_id)
|
||||
if item:
|
||||
item.status = "pending"
|
||||
item.started_at = None
|
||||
item.attempts = max(0, item.attempts - 1)
|
||||
until = datetime.now(timezone.utc) + timedelta(minutes=defer.retry_after_minutes)
|
||||
item.payload = {**(item.payload or {}), "deferred_until": until.isoformat()}
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def _mark_failed(queue_id: int, exc: Exception) -> None:
|
||||
"""실패: consumer 와 동일 재시도 정책 (attempts >= max → failed, 아니면 pending 복귀)."""
|
||||
async with async_session() as session:
|
||||
item = await session.get(ProcessingQueue, queue_id)
|
||||
if item:
|
||||
err_text = str(exc) or repr(exc) or type(exc).__name__
|
||||
item.error_message = err_text[:500]
|
||||
if item.attempts >= item.max_attempts:
|
||||
item.status = "failed"
|
||||
else:
|
||||
item.status = "pending"
|
||||
item.started_at = None
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def drain(stage: str, limit: int, defer_retries: int = 5, defer_wait: int = 120) -> None:
|
||||
if stage not in DRAIN_STAGES:
|
||||
raise SystemExit(f"--stage 는 {DRAIN_STAGES} 만 허용")
|
||||
if settings.ai.deep is None:
|
||||
raise SystemExit(
|
||||
"config.yaml ai.models.deep 슬롯 미구성 — drain 은 맥북 분담 전용 레버라 진행하지 않음"
|
||||
" (맥미니로의 silent 강등 금지)"
|
||||
)
|
||||
|
||||
from workers.classify_worker import process as classify_process
|
||||
from workers.deep_summary_worker import process as deep_summary_process
|
||||
from workers.queue_consumer import enqueue_next_stage
|
||||
from workers.summarize_worker import process as summarize_process
|
||||
|
||||
done = failed = 0
|
||||
deferred = False
|
||||
consecutive_defers = 0
|
||||
while done + failed < limit:
|
||||
claimed = await _claim_one(stage)
|
||||
if claimed is None:
|
||||
logger.info(f"[drain:{stage}] pending 소진 — 종료")
|
||||
break
|
||||
queue_id, document_id = claimed
|
||||
try:
|
||||
async with async_session() as worker_session:
|
||||
if stage == "summarize":
|
||||
await summarize_process(document_id, worker_session, use_deep=True)
|
||||
elif stage == "classify":
|
||||
await classify_process(document_id, worker_session, use_deep=True)
|
||||
else:
|
||||
# deep_summary: drain 은 맥북 전용 레버 — 불가 시 보류(폴백은 consumer 만)
|
||||
await deep_summary_process(
|
||||
document_id, worker_session, defer_on_deep_unavailable=True
|
||||
)
|
||||
await worker_session.commit()
|
||||
await _mark_completed(queue_id)
|
||||
# 다음 stage 연쇄 — classify 는 embed/chunk/markdown enqueue (consumer 와 동형,
|
||||
# summarize/deep_summary 는 next_stages 미등록이라 no-op)
|
||||
await enqueue_next_stage(document_id, stage)
|
||||
done += 1
|
||||
consecutive_defers = 0
|
||||
logger.info(f"[drain:{stage}] {done}/{limit} doc={document_id} 완료")
|
||||
except StageDeferred as defer:
|
||||
# 일시 불가는 종류가 둘: 진짜 sleep(장시간) vs 일시 네트워크 플랩(분 단위 —
|
||||
# 2026-06-11 실측: Tailscale direct 경로 ~10분 플랩으로 32/300 조기 종료).
|
||||
# 연속 보류 한도까지 대기 후 재시도해 플랩을 흡수, 한도 도달 시 종료(sleep 판정).
|
||||
await _mark_deferred(queue_id, defer)
|
||||
consecutive_defers += 1
|
||||
if consecutive_defers >= defer_retries:
|
||||
deferred = True
|
||||
logger.warning(
|
||||
f"[drain:{stage}] doc={document_id} 맥북 불가({defer}) — 연속 보류 "
|
||||
f"{consecutive_defers}회 한도 도달, run 종료. 맥북 깨운 뒤(또는 "
|
||||
f"{defer.retry_after_minutes}분 후) 재실행"
|
||||
)
|
||||
break
|
||||
logger.warning(
|
||||
f"[drain:{stage}] doc={document_id} 맥북 일시 불가({defer}) — "
|
||||
f"{defer_wait}s 대기 후 재시도 ({consecutive_defers}/{defer_retries})"
|
||||
)
|
||||
await asyncio.sleep(defer_wait)
|
||||
except Exception as exc:
|
||||
await _mark_failed(queue_id, exc)
|
||||
failed += 1
|
||||
logger.error(f"[drain:{stage}] doc={document_id} 실패: {exc}")
|
||||
|
||||
# 종료 요약 (잔여 = 지금 시점 pending 수)
|
||||
async with async_session() as session:
|
||||
from sqlalchemy import func as sa_func
|
||||
remaining = (await session.execute(
|
||||
select(sa_func.count()).select_from(ProcessingQueue).where(
|
||||
ProcessingQueue.stage == stage, ProcessingQueue.status == "pending",
|
||||
)
|
||||
)).scalar_one()
|
||||
logger.info(
|
||||
f"[drain:{stage}] 요약 — 완료 {done} · 실패 {failed} · "
|
||||
f"보류종료 {'예' if deferred else '아니오'} · 잔여 pending {remaining}"
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="맥북(M5 Max) burst-drain — 수동 백로그 분담 레버")
|
||||
parser.add_argument("--stage", required=True, choices=DRAIN_STAGES)
|
||||
parser.add_argument("--limit", type=int, default=50, help="이번 run 최대 처리 건수 (기본 50)")
|
||||
parser.add_argument("--defer-retries", type=int, default=5,
|
||||
help="연속 보류 허용 횟수 — 네트워크 플랩 흡수 (기본 5, 한도 도달 시 종료)")
|
||||
parser.add_argument("--defer-wait", type=int, default=120,
|
||||
help="보류 재시도 간 대기 초 (기본 120)")
|
||||
args = parser.parse_args()
|
||||
asyncio.run(drain(args.stage, args.limit, args.defer_retries, args.defer_wait))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,43 @@
|
||||
"""statute_collector 나라별 어댑터 패키지 (plan safety-library-1 B-1).
|
||||
|
||||
어댑터 계약 (2함수 + 상수):
|
||||
JURISDICTION: str — 어댑터 상수 고정. 코어가 적재 직전 assert (파싱 결과 추론 금지).
|
||||
poll_changes(client, watch_rows) -> list[ChangeEvent] — 개정 감지만 (경량 호출).
|
||||
fetch_version(client, act, change) -> list[VersionPayload] — PR②.
|
||||
payload 리스트: primary + annex 각각 자기 version_key (R4-M4).
|
||||
|
||||
ChangeEvent.kind: amend / repeal / bootstrap(합성 — PR② 부트스트랩이 amend 와
|
||||
동일 ingest 경로 재사용, R6-m2).
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChangeEvent:
|
||||
"""개정 감지 이벤트 — poll_changes 산출물."""
|
||||
family_id: str
|
||||
kind: str # amend / repeal / bootstrap
|
||||
new_version_key: str # KR = MST (법령일련번호)
|
||||
title: str
|
||||
promulgation_date: str | None = None # YYYYMMDD
|
||||
effective_date: str | None = None # YYYYMMDD (목록 시행일자 — 조문별 차등 시행 주의)
|
||||
revision_type: str | None = None # 제개정구분명
|
||||
|
||||
|
||||
@dataclass
|
||||
class VersionPayload:
|
||||
"""fetch_version 산출물 1건 — primary 또는 annex 각자 자기 version_key (R4-M4).
|
||||
|
||||
전문 1콜 스냅샷 의미론(R7-M3 fixture 판정): 한 응답에서 primary + annex 전부 생성.
|
||||
annex version_key = 'MST|{별표번호}-{별표가지번호}' (zero-padded 구조화 필드 그대로 —
|
||||
suffix 문자열 파싱 아닌 필드 기반, R7-B1 a 업그레이드).
|
||||
"""
|
||||
law_doc_kind: str # primary / annex
|
||||
version_key: str
|
||||
title: str
|
||||
content: str # 조문/별표 markdown 텍스트
|
||||
promulgation_date: str | None = None # YYYYMMDD (본문 기본정보)
|
||||
effective_date: str | None = None # YYYYMMDD (본문 기본정보 — 목록값과 다를 수 있음)
|
||||
annex_label: str | None = None # '별표1' / '별표5의2' (표시용)
|
||||
meta: dict = field(default_factory=dict)
|
||||
@@ -0,0 +1,213 @@
|
||||
"""KR 법령 어댑터 — 국가법령정보센터 (law.go.kr DRF) (plan safety-library-1 B-1 PR①).
|
||||
|
||||
poll_changes = lawSearch 목록 diff: 워치리스트 행별 정식 법령명 exact 조회 →
|
||||
MST(법령일련번호) != watermark 이면 ChangeEvent. law_monitor 의 검증된 호출 형태 재사용.
|
||||
|
||||
fixture (2026-06-13 라이브 박제, tests/fixtures/statute_kr/):
|
||||
- lawsearch_*.xml — 목록 필드: 법령ID(불변)·법령일련번호(MST)·공포일자·시행일자·제개정구분명
|
||||
- lawservice_*.xml.gz — 전문 1콜 XML: 조문단위 853(산안기준규칙) + 별표단위 23 전부 포함
|
||||
= 스냅샷 의미론 확정(R7-M3 ①: annex 부분 fetch 실패 개념 없음 — 같은 응답에 없는
|
||||
별표 = 삭제 간주 가능). 별표번호+별표가지번호 = 구조화 필드(R7-M3 ② — suffix 문자열
|
||||
파싱 불요, version_key 합성은 이 필드 기반. PR② fetch_version 소관).
|
||||
- 조문 취득 방식 판정(R2-m1): 전문 1콜 + 로컬 파싱 확정 — lawjosub 조 단위 호출이면
|
||||
산안기준규칙(853조)은 개정당 호출 폭증. lawjosub fixture 는 보조 박제.
|
||||
|
||||
주의: 응답의 '법령상세링크' 필드에 OC 키가 포함됨 — fixture/로그에 raw 응답을 남길 때
|
||||
새니타이즈 필수 (repo fixture 는 __OC_REDACTED__ 처리됨).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
import httpx
|
||||
|
||||
from core.crawl_politeness import CRAWL_UA
|
||||
from core.utils import setup_logger
|
||||
from workers.statute_adapters import ChangeEvent, VersionPayload
|
||||
|
||||
logger = setup_logger("statute_kr")
|
||||
|
||||
JURISDICTION = "KR"
|
||||
SOURCE_API = "law.go.kr"
|
||||
|
||||
LAW_SEARCH_URL = "https://www.law.go.kr/DRF/lawSearch.do"
|
||||
LAW_SERVICE_URL = "https://www.law.go.kr/DRF/lawService.do"
|
||||
|
||||
# 같은 도메인 연속 호출 간격 (일 1회 x 26콜 — 보수적)
|
||||
_POLL_DELAY_S = 1.5
|
||||
|
||||
|
||||
def _oc() -> str:
|
||||
oc = os.getenv("LAW_OC", "")
|
||||
if not oc:
|
||||
raise RuntimeError("LAW_OC 미설정 — statute KR 어댑터 사용 불가")
|
||||
return oc
|
||||
|
||||
|
||||
def parse_search_hit(xml_text: str, official_title: str) -> dict | None:
|
||||
"""lawSearch XML 에서 정식 법령명 exact match 1건 추출 (순수 함수 — fixture 테스트 대상).
|
||||
|
||||
정식명 기준 exact match — 워치리스트 title 이 정식명(가운뎃점 포함)이므로 안전.
|
||||
(law_monitor 의 하드코딩 '유해위험작업...'(점 없음)이 영구 미매칭이던 함정의 교훈:
|
||||
조회 키는 반드시 레지스트리의 정식명을 쓴다.)
|
||||
"""
|
||||
root = ET.fromstring(xml_text)
|
||||
for law in root.findall(".//law"):
|
||||
if (law.findtext("법령명한글") or "").strip() != official_title:
|
||||
continue
|
||||
mst = (law.findtext("법령일련번호") or "").strip()
|
||||
if not mst:
|
||||
continue
|
||||
return {
|
||||
"mst": mst,
|
||||
"law_id": (law.findtext("법령ID") or "").strip(),
|
||||
"promulgation_date": (law.findtext("공포일자") or "").strip() or None,
|
||||
"effective_date": (law.findtext("시행일자") or "").strip() or None,
|
||||
"revision_type": (law.findtext("제개정구분명") or "").strip() or None,
|
||||
"status_code": (law.findtext("현행연혁코드") or "").strip() or None,
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
def detect_change(hit: dict | None, act_family_id: str, act_title: str,
|
||||
watermark: str | None) -> ChangeEvent | None:
|
||||
"""목록 hit + 워터마크 → ChangeEvent (순수 함수 — fixture 테스트 대상).
|
||||
|
||||
- hit 없음 = 감지 불가 (None — 호출측이 fail-loud 로그. 폐지 단정 금지:
|
||||
검색 누락/표기 변경 가능성과 구분 불가하므로 repeal 은 제개정구분명 기준만)
|
||||
- MST == watermark = 변경 없음
|
||||
- 제개정구분명에 '폐지' = repeal, 그 외 = amend
|
||||
"""
|
||||
if hit is None:
|
||||
return None
|
||||
if watermark and hit["mst"] == watermark:
|
||||
return None
|
||||
kind = "repeal" if (hit.get("revision_type") or "").find("폐지") >= 0 else "amend"
|
||||
return ChangeEvent(
|
||||
family_id=act_family_id,
|
||||
kind=kind,
|
||||
new_version_key=hit["mst"],
|
||||
title=act_title,
|
||||
promulgation_date=hit.get("promulgation_date"),
|
||||
effective_date=hit.get("effective_date"),
|
||||
revision_type=hit.get("revision_type"),
|
||||
)
|
||||
|
||||
|
||||
def _article_markdown(art: ET.Element) -> str:
|
||||
"""조문단위 1건 → 텍스트. 조문내용(이미 '제N조(제목) ...' 형태) + 항/호/목 전체.
|
||||
|
||||
메타 필드(조문번호/조문여부/조문시행일자 등)는 제외 — 조문내용과 항 서브트리만.
|
||||
"""
|
||||
parts = []
|
||||
body = (art.findtext("조문내용") or "").strip()
|
||||
if body:
|
||||
parts.append(body)
|
||||
for hang in art.findall("항"):
|
||||
text = "\n".join(t.strip() for t in hang.itertext() if t.strip())
|
||||
if text:
|
||||
parts.append(text)
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def parse_service_payloads(xml_text: str, official_title: str, mst: str) -> list[VersionPayload]:
|
||||
"""lawService 전문 XML → VersionPayload 리스트 (순수 함수 — fixture 테스트 대상).
|
||||
|
||||
스냅샷 의미론: 응답에 있는 별표가 그 버전의 별표 전체 (R7-M3 fixture 판정).
|
||||
- primary 1건: 전 조문 markdown (조문여부 != '조문' 행 = 장/절 헤더 → '## ' 처리)
|
||||
- annex N건: 별표단위별 — version_key = 'MST|{별표번호}-{가지번호}' (zero-padded 그대로)
|
||||
"""
|
||||
root = ET.fromstring(xml_text)
|
||||
base = root.find(".//기본정보")
|
||||
prom = (base.findtext("공포일자") or "").strip() or None if base is not None else None
|
||||
eff = (base.findtext("시행일자") or "").strip() or None if base is not None else None
|
||||
|
||||
lines: list[str] = [f"# {official_title}", ""]
|
||||
for art in root.findall(".//조문단위"):
|
||||
is_article = (art.findtext("조문여부") or "").strip() == "조문"
|
||||
text = _article_markdown(art)
|
||||
if not text:
|
||||
continue
|
||||
if is_article:
|
||||
lines.append(f"### {text}" if not text.startswith("제") else text)
|
||||
else:
|
||||
lines.append(f"## {text}")
|
||||
lines.append("")
|
||||
primary_content = "\n".join(lines).strip()
|
||||
|
||||
payloads = [VersionPayload(
|
||||
law_doc_kind="primary",
|
||||
version_key=mst,
|
||||
title=official_title,
|
||||
content=primary_content,
|
||||
promulgation_date=prom,
|
||||
effective_date=eff,
|
||||
)]
|
||||
|
||||
for annex in root.findall(".//별표단위"):
|
||||
no = (annex.findtext("별표번호") or "").strip()
|
||||
sub = (annex.findtext("별표가지번호") or "").strip() or "00"
|
||||
kind = (annex.findtext("별표구분") or "별표").strip() # 별표 / 서식 — 별도 차원!
|
||||
a_title = (annex.findtext("별표제목") or "").strip()
|
||||
a_body = (annex.findtext("별표내용") or "").strip()
|
||||
if not no:
|
||||
continue
|
||||
# 삭제 tombstone — KR 은 별표/서식 삭제가 absence 가 아니라 '삭제 <날짜>' 명시 행
|
||||
# (fixture 실측: 산안기준규칙 서식1·2). 내용 없는 tombstone 은 적재 skip.
|
||||
# 시리즈의 구버전 current 잔존 처리 = PR③ 관찰 후보 (absence 추론은 불요 확정).
|
||||
if a_title.startswith("삭제") and len(a_body) < 50:
|
||||
continue
|
||||
label = f"{kind}{int(no)}" + (f"의{int(sub)}" if sub not in ("", "0", "00") else "")
|
||||
payloads.append(VersionPayload(
|
||||
law_doc_kind="annex",
|
||||
# 구분 차원 포함 — (번호,가지)만으로는 별표1 vs 서식1 충돌 (fixture 실측)
|
||||
version_key=f"{mst}|{kind}{no}-{sub}",
|
||||
title=f"{official_title} {label} {a_title}".strip(),
|
||||
content=f"# {official_title} {label}\n## {a_title}\n\n{a_body}".strip(),
|
||||
promulgation_date=prom,
|
||||
effective_date=eff,
|
||||
annex_label=label,
|
||||
))
|
||||
return payloads
|
||||
|
||||
|
||||
async def fetch_version(client: httpx.AsyncClient, act, change: ChangeEvent) -> list[VersionPayload]:
|
||||
"""전문 1콜 → payload 리스트 (R2-m1 판정: lawjosub 조 단위 호출 안 함 — 853조 폭증 회피)."""
|
||||
resp = await client.get(
|
||||
LAW_SERVICE_URL,
|
||||
params={"OC": _oc(), "target": "law", "MST": change.new_version_key, "type": "XML"},
|
||||
headers={"User-Agent": CRAWL_UA},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
payloads = parse_service_payloads(resp.text, act.title, change.new_version_key)
|
||||
if not payloads or len(payloads[0].content) < 200:
|
||||
# 파싱 검증 floor — 미달 시 예외 = 워터마크 미영속 (재시도 가능 상태 유지)
|
||||
raise ValueError(f"전문 파싱 결과 빈약 ({act.family_id}): payloads={len(payloads)}")
|
||||
return payloads
|
||||
|
||||
|
||||
async def poll_changes(client: httpx.AsyncClient, watch_rows: list) -> list[ChangeEvent]:
|
||||
"""워치리스트 행별 lawSearch diff. 행 단위 실패 격리 (한 법령 실패가 나머지를 막지 않음)."""
|
||||
oc = _oc()
|
||||
events: list[ChangeEvent] = []
|
||||
for act in watch_rows:
|
||||
try:
|
||||
resp = await client.get(
|
||||
LAW_SEARCH_URL,
|
||||
params={"OC": oc, "target": "law", "type": "XML", "query": act.title},
|
||||
headers={"User-Agent": CRAWL_UA},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
hit = parse_search_hit(resp.text, act.title)
|
||||
if hit is None:
|
||||
# fail-loud: 정식명 미매칭 = 표기 변경/검색 누락 의심 — 침묵 skip 금지
|
||||
logger.warning(f"[statute-kr] 목록 미매칭: {act.family_id} {act.title!r}")
|
||||
else:
|
||||
ev = detect_change(hit, act.family_id, act.title, act.watermark)
|
||||
if ev:
|
||||
events.append(ev)
|
||||
except Exception as e:
|
||||
logger.error(f"[statute-kr] poll 실패 ({act.family_id}): {type(e).__name__}: {e!r}")
|
||||
await asyncio.sleep(_POLL_DELAY_S)
|
||||
return events
|
||||
@@ -0,0 +1,381 @@
|
||||
"""statute_collector — 법령 수집 코어 (plan safety-library-1 B-1, PR②).
|
||||
|
||||
구성 (잡 코드 통째 — R8-B1: 승격과 스윕의 PR 분리 = 배포 갭 이중 노출 윈도):
|
||||
poll_changes(어댑터) → fetch_version(전문 1콜, payload 리스트) → ingest(전 버전
|
||||
pending 적재 + 4축 주입) → 생애주기 잡(버전 시리즈 단위 승격·supersede + 상태 기반
|
||||
레거시 스윕 + repeal — 단일 트랜잭션, KST 기준).
|
||||
|
||||
핵심 계약 (카드 = 스펙):
|
||||
- 워터마크 영속 = ingest 파싱 검증 통과 후에만 (실패 시 다음 폴링이 재감지)
|
||||
- 승격·supersede 단위 = 버전 시리즈 = (family_id, law_doc_kind, annex 식별자)
|
||||
— R7-B1: family 단위 구현 금지 (annex 승격이 primary 를 소거하는 본문 소실 경로)
|
||||
- 레거시 스윕 = 상태 기반: 매 잡 실행, primary 시리즈 current 보유 + repeal 미감지
|
||||
family 의 법령명 매핑 레거시(law_monitor 스냅샷) 청크 in_corpus=false (멱등)
|
||||
- 매핑 = 정확 일치 가정 금지: title 의 '법령명 (YYYYMMDD)' 패턴에서 법령명 추출 후
|
||||
정규화(공백·가운뎃점 변형 흡수) **동등** 비교 — prefix 비교 금지 ('산업안전보건법'이
|
||||
'산업안전보건법 시행령' 레거시를 오폭하는 경로 차단)
|
||||
- ingest 4축 (R8-M1): material_type='law' / jurisdiction=어댑터 상수 /
|
||||
published_date=COALESCE(시행일, 공포일) / license=public_domain(저작권법 제7조)
|
||||
- 부트스트랩(--bootstrap) = kind='bootstrap' 합성 이벤트, amend 와 동일 경로 +
|
||||
extract_meta.backfill=true (E-1 게이트 집계 제외 마커)
|
||||
- 가시성: source_health 성공/실패 기록 (HC.io 는 2026-05-30 알림 레이어 폐기로 부재 —
|
||||
silent-skip 가드 정신은 crawl-health 보드 + health 행으로 대체)
|
||||
|
||||
실행:
|
||||
스케줄 = daily 07:00 KST (main.py — 구 law_monitor 슬롯 승계)
|
||||
수동 = docker compose exec -T fastapi python -m workers.statute_collector [--bootstrap]
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import hashlib
|
||||
import re
|
||||
import unicodedata
|
||||
from datetime import date, datetime, timezone
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import select, update
|
||||
|
||||
from core.database import async_session
|
||||
from core.utils import setup_logger
|
||||
from models.chunk import DocumentChunk
|
||||
from models.document import Document
|
||||
from models.legal_act import LegalAct, LegalMeta
|
||||
from models.news_source import NewsSource
|
||||
from models.queue import enqueue_stage
|
||||
from workers.news_collector import _get_or_create_health, _record_failure, _record_success
|
||||
from workers.statute_adapters import ChangeEvent, VersionPayload
|
||||
from workers.statute_adapters import kr
|
||||
|
||||
logger = setup_logger("statute_collector")
|
||||
|
||||
_KST = ZoneInfo("Asia/Seoul")
|
||||
_SOURCE_NAME = "KR 법령 (law.go.kr)"
|
||||
_LICENSE = {"scheme": "public_domain", "redistribute": True, "attribution": "국가법령정보센터"}
|
||||
_FETCH_DELAY_S = 2.5 # lawService 전문(최대 ~1.3MB) 연속 호출 간격
|
||||
|
||||
# jurisdiction → 어댑터 모듈 (Phase 1 = KR 단독, 해외는 B-5 게이트 뒤)
|
||||
_ADAPTERS = {"KR": kr}
|
||||
|
||||
|
||||
# ─── 법령명 매핑 (R8-m1: 정확 일치 가정 금지 — 변형 흡수 정규화 + 동등 비교) ───
|
||||
|
||||
_LEGACY_TITLE_RE = re.compile(r"^(.*?)\s*\((\d{8})\)")
|
||||
|
||||
|
||||
def normalize_law_name(name: str) -> str:
|
||||
"""공백·가운뎃점 변형 흡수 — NFC 정규화 후 공백/ㆍ·・ 제거."""
|
||||
s = unicodedata.normalize("NFC", name or "")
|
||||
return re.sub(r"[\sㆍ·・]", "", s)
|
||||
|
||||
|
||||
def legacy_law_name(title: str) -> str | None:
|
||||
"""레거시 law_monitor title('법령명 (YYYYMMDD) 섹션')에서 법령명 추출."""
|
||||
m = _LEGACY_TITLE_RE.match(title or "")
|
||||
return m.group(1).strip() if m else None
|
||||
|
||||
|
||||
def series_suffix(version_key: str) -> str | None:
|
||||
"""버전 시리즈의 annex 식별자 — version_key 'MST|NNNN-SS' 의 '|' 뒤 (primary=None)."""
|
||||
return version_key.split("|", 1)[1] if "|" in version_key else None
|
||||
|
||||
|
||||
def _to_date(ymd: str | None) -> date | None:
|
||||
digits = re.sub(r"\D", "", ymd or "")
|
||||
if len(digits) != 8:
|
||||
return None
|
||||
try:
|
||||
return date(int(digits[:4]), int(digits[4:6]), int(digits[6:8]))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
# ─── ingest (전 버전 pending 적재 — R2-B2/R3 계약) ──────────────────────────────
|
||||
|
||||
async def _ingest_payload(session, act: LegalAct, ev: ChangeEvent,
|
||||
payload: VersionPayload, backfill: bool) -> bool:
|
||||
"""payload 1건 → Document + legal_meta(pending). 반환 = 신규 여부 (dedup 멱등)."""
|
||||
fhash = hashlib.sha256(
|
||||
f"statute|{act.jurisdiction}|{act.native_id}|{payload.version_key}".encode()
|
||||
).hexdigest()[:32]
|
||||
existing = await session.execute(
|
||||
select(Document.id).where(Document.file_hash == fhash).limit(1)
|
||||
)
|
||||
if existing.scalars().first():
|
||||
return False
|
||||
|
||||
prom = _to_date(payload.promulgation_date or ev.promulgation_date)
|
||||
eff = _to_date(payload.effective_date or ev.effective_date)
|
||||
now = datetime.now(timezone.utc)
|
||||
extra = {"backfill": True} if backfill else {}
|
||||
doc = Document(
|
||||
file_path=f"crawl/statute/{act.family_id}/{payload.version_key.replace('|', '_')}",
|
||||
file_hash=fhash,
|
||||
file_format="article",
|
||||
file_size=len(payload.content.encode()),
|
||||
file_type="note",
|
||||
title=f"{payload.title} ({payload.promulgation_date or ev.promulgation_date or ''})".strip(),
|
||||
extracted_text=payload.content,
|
||||
extracted_at=now,
|
||||
extractor_version="statute_kr@law.go.kr",
|
||||
md_status="skipped",
|
||||
md_extraction_error="statute: 텍스트 네이티브, markdown 변환 비대상",
|
||||
source_channel="crawl",
|
||||
data_origin="external",
|
||||
review_status="approved",
|
||||
ai_domain="법령",
|
||||
ai_sub_group=act.title,
|
||||
ai_tags=[f"법령/KR/{act.title}"],
|
||||
# 안전 자료실 ingest 4축 (R8-M1 — classify-skip 경로라 ingest 시점 필수)
|
||||
material_type="law",
|
||||
jurisdiction=kr.JURISDICTION,
|
||||
published_date=eff or prom,
|
||||
extract_meta={
|
||||
"statute": {"family_id": act.family_id, "law_id": act.native_id,
|
||||
"kind": payload.law_doc_kind, "version_key": payload.version_key,
|
||||
"annex_label": payload.annex_label,
|
||||
"event_kind": ev.kind, "revision_type": ev.revision_type},
|
||||
"license": dict(_LICENSE),
|
||||
**extra,
|
||||
},
|
||||
)
|
||||
session.add(doc)
|
||||
await session.flush()
|
||||
|
||||
session.add(LegalMeta(
|
||||
document_id=doc.id,
|
||||
family_id=act.family_id,
|
||||
law_doc_kind=payload.law_doc_kind,
|
||||
version_key=payload.version_key,
|
||||
promulgation_date=prom,
|
||||
effective_date=eff,
|
||||
version_status="pending", # 전 버전 pending 적재 — 승격은 생애주기 잡만
|
||||
))
|
||||
# summarize 안 함 (조문 자체가 정본 — 맥미니 부하 0), embed+chunk 만
|
||||
await enqueue_stage(session, doc.id, "embed")
|
||||
await enqueue_stage(session, doc.id, "chunk")
|
||||
return True
|
||||
|
||||
|
||||
# ─── 생애주기 잡 (전이·supersede·스윕·repeal 의 유일한 코드 지점) ────────────────
|
||||
|
||||
async def _flip_chunks(session, doc_ids: list[int]) -> int:
|
||||
if not doc_ids:
|
||||
return 0
|
||||
result = await session.execute(
|
||||
update(DocumentChunk)
|
||||
.where(DocumentChunk.doc_id.in_(doc_ids), DocumentChunk.in_corpus.is_(True))
|
||||
.values(in_corpus=False)
|
||||
)
|
||||
return result.rowcount or 0
|
||||
|
||||
|
||||
async def _legacy_doc_ids(session, act: LegalAct) -> list[int]:
|
||||
"""법령명 매핑 레거시(law_monitor) 문서 id — 정규화 동등 비교 (prefix 금지)."""
|
||||
result = await session.execute(
|
||||
select(Document.id, Document.title).where(
|
||||
Document.source_channel == "law_monitor",
|
||||
Document.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
want = normalize_law_name(act.title)
|
||||
ids = []
|
||||
for doc_id, title in result.all():
|
||||
name = legacy_law_name(title or "")
|
||||
if name and normalize_law_name(name) == want:
|
||||
ids.append(doc_id)
|
||||
return ids
|
||||
|
||||
|
||||
async def run_lifecycle(session) -> dict:
|
||||
"""일 1회 생애주기 잡 — 호출측이 단일 트랜잭션 commit. KST 기준, 멱등."""
|
||||
today = datetime.now(_KST).date()
|
||||
stats = {"promoted": 0, "superseded": 0, "repealed": 0,
|
||||
"legacy_flipped_docs": 0, "legacy_flipped_chunks": 0}
|
||||
|
||||
acts_result = await session.execute(select(LegalAct).where(LegalAct.watch.is_(True)))
|
||||
acts = {a.family_id: a for a in acts_result.scalars().all()}
|
||||
|
||||
lm_result = await session.execute(
|
||||
select(LegalMeta).where(LegalMeta.family_id.in_(list(acts.keys())))
|
||||
)
|
||||
metas = lm_result.scalars().all()
|
||||
|
||||
# 1) repeal — 마킹된 family: current+pending 전부 repealed + 청크 flip + 레거시 flip (R7-M2)
|
||||
repeal_families = {fid for fid, a in acts.items() if a.repeal_detected_at is not None}
|
||||
for fid in repeal_families:
|
||||
rows = [m for m in metas if m.family_id == fid and m.version_status in ("pending", "current")]
|
||||
for m in rows:
|
||||
m.version_status = "repealed"
|
||||
stats["repealed"] += 1
|
||||
await _flip_chunks(session, [m.document_id for m in rows])
|
||||
legacy_ids = await _legacy_doc_ids(session, acts[fid])
|
||||
stats["legacy_flipped_chunks"] += await _flip_chunks(session, legacy_ids)
|
||||
|
||||
# 2) 승격 + supersede — 버전 시리즈 단위 (R7-B1 a: family 단위 금지)
|
||||
series: dict[tuple, list[LegalMeta]] = {}
|
||||
for m in metas:
|
||||
if m.family_id in repeal_families:
|
||||
continue
|
||||
series.setdefault(
|
||||
(m.family_id, m.law_doc_kind, series_suffix(m.version_key)), []
|
||||
).append(m)
|
||||
|
||||
for key, rows in series.items():
|
||||
due = sorted(
|
||||
(m for m in rows if m.version_status == "pending"
|
||||
and (m.effective_date or m.promulgation_date)
|
||||
and (m.effective_date or m.promulgation_date) <= today),
|
||||
key=lambda m: (m.effective_date or m.promulgation_date),
|
||||
)
|
||||
for m in due:
|
||||
prev = [c for c in rows if c.version_status == "current" and c is not m]
|
||||
for c in prev:
|
||||
c.version_status = "superseded"
|
||||
stats["superseded"] += 1
|
||||
await _flip_chunks(session, [c.document_id for c in prev])
|
||||
m.version_status = "current"
|
||||
stats["promoted"] += 1
|
||||
|
||||
# 3) 레거시 스윕 — 상태 기반 (R6-B1 a / R7-B1 b: primary 시리즈 current 보유 한정)
|
||||
for fid, act in acts.items():
|
||||
if fid in repeal_families:
|
||||
continue
|
||||
has_primary_current = any(
|
||||
m.family_id == fid and m.law_doc_kind == "primary" and m.version_status == "current"
|
||||
for m in metas
|
||||
)
|
||||
if not has_primary_current:
|
||||
continue # R3-B1 ② 내장 — fetch 실패 family 의 레거시 보존
|
||||
legacy_ids = await _legacy_doc_ids(session, act)
|
||||
flipped = await _flip_chunks(session, legacy_ids)
|
||||
if flipped:
|
||||
stats["legacy_flipped_docs"] += len(legacy_ids)
|
||||
stats["legacy_flipped_chunks"] += flipped
|
||||
|
||||
return stats
|
||||
|
||||
|
||||
# ─── 메인 런 ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
async def run(bootstrap: bool = False) -> None:
|
||||
"""poll → fetch → ingest(가족 단위 커밋) → 생애주기 잡. 가족 단위 실패 격리."""
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(LegalAct).where(LegalAct.watch.is_(True)).order_by(LegalAct.family_id)
|
||||
)
|
||||
rows = result.scalars().all()
|
||||
if not rows:
|
||||
logger.warning("[statute] 워치리스트 비어 있음 — 시드(migration 356) 미적용?")
|
||||
return
|
||||
source = await _get_source(session)
|
||||
await session.commit()
|
||||
source_id = source.id
|
||||
|
||||
ingested = 0
|
||||
failed = 0
|
||||
by_jur: dict[str, list] = {}
|
||||
for row in rows:
|
||||
by_jur.setdefault(row.jurisdiction, []).append(row)
|
||||
|
||||
async with httpx.AsyncClient(timeout=60) as client:
|
||||
for jur, acts in by_jur.items():
|
||||
adapter = _ADAPTERS.get(jur)
|
||||
if adapter is None:
|
||||
logger.warning(f"[statute] 어댑터 없는 jurisdiction skip: {jur}")
|
||||
continue
|
||||
assert adapter.JURISDICTION == jur, \
|
||||
f"어댑터/행 jurisdiction 불일치: {adapter.JURISDICTION} != {jur}"
|
||||
|
||||
events = await adapter.poll_changes(client, acts)
|
||||
acts_by_id = {a.family_id: a for a in acts}
|
||||
for ev in events:
|
||||
if bootstrap:
|
||||
ev.kind = "bootstrap" # 합성 이벤트 — amend 와 동일 경로 (R6-m2)
|
||||
act_ref = acts_by_id[ev.family_id]
|
||||
try:
|
||||
payloads = await adapter.fetch_version(client, act_ref, ev)
|
||||
async with async_session() as session:
|
||||
act = await session.get(LegalAct, ev.family_id)
|
||||
new_docs = 0
|
||||
for p in payloads:
|
||||
if await _ingest_payload(session, act, ev, p, backfill=bootstrap):
|
||||
new_docs += 1
|
||||
# 워터마크 영속 = 파싱 검증(payload floor) 통과 후에만
|
||||
act.watermark = ev.new_version_key
|
||||
if ev.kind == "repeal":
|
||||
act.repeal_detected_at = datetime.now(timezone.utc)
|
||||
await session.commit()
|
||||
ingested += new_docs
|
||||
logger.info(f"[statute] ingest {ev.family_id} ({ev.kind}): "
|
||||
f"payload {len(payloads)}건 중 신규 {new_docs}건")
|
||||
except Exception as e:
|
||||
failed += 1
|
||||
logger.error(f"[statute] ingest 실패 ({ev.family_id}): "
|
||||
f"{type(e).__name__}: {e!r} — 워터마크 미영속, 다음 폴링 재감지")
|
||||
await asyncio.sleep(_FETCH_DELAY_S)
|
||||
|
||||
# 생애주기 잡 — 수집 사이클 직후, 단일 트랜잭션 (0-2 ②)
|
||||
async with async_session() as session:
|
||||
stats = await run_lifecycle(session)
|
||||
await session.commit()
|
||||
logger.info(f"[statute] lifecycle: {stats}")
|
||||
|
||||
# health — fail-loud 가시성 (HC.io 폐기로 보드/health 행이 1차 관측면)
|
||||
async with async_session() as session:
|
||||
h = await _get_or_create_health(session, source_id)
|
||||
now = datetime.now(timezone.utc)
|
||||
if failed:
|
||||
_record_failure(h, f"ingest 실패 {failed}건", now)
|
||||
else:
|
||||
_record_success(h, ingested, False, now)
|
||||
await session.commit()
|
||||
|
||||
logger.info(f"[statute] run 완료 — 신규 문서 {ingested}건, 실패 {failed}건"
|
||||
+ (" (bootstrap)" if bootstrap else ""))
|
||||
|
||||
|
||||
async def _get_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=kr.LAW_SEARCH_URL, feed_type="rss",
|
||||
fetch_method="api", fulltext_policy="none", source_channel="crawl",
|
||||
category="Safety", language="ko", country="KR",
|
||||
enabled=False, # 6h 뉴스 사이클 비대상 — 본 워커가 daily 폴링
|
||||
)
|
||||
session.add(source)
|
||||
await session.flush()
|
||||
return source
|
||||
|
||||
|
||||
async def poll_once() -> int:
|
||||
"""관찰 전용 폴링 (PR① 잔존 CLI — 상태 변경 0)."""
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(LegalAct).where(LegalAct.watch.is_(True)).order_by(LegalAct.family_id)
|
||||
)
|
||||
rows = result.scalars().all()
|
||||
total = 0
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
events = await kr.poll_changes(client, [r for r in rows if r.jurisdiction == "KR"])
|
||||
for ev in events:
|
||||
logger.info(f"[statute] 변경 감지 ({ev.kind}): {ev.family_id} {ev.title} "
|
||||
f"MST={ev.new_version_key}")
|
||||
total = len(events)
|
||||
logger.info(f"[statute] poll 완료 — 변경 {total}건 (관찰 전용)")
|
||||
return total
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--bootstrap", action="store_true",
|
||||
help="26 family 현행판 1회 부트스트랩 (backfill 마커, R4-M1)")
|
||||
parser.add_argument("--poll-only", action="store_true", help="관찰 전용 폴링")
|
||||
args = parser.parse_args()
|
||||
if args.poll_only:
|
||||
asyncio.run(poll_once())
|
||||
else:
|
||||
asyncio.run(run(bootstrap=args.bootstrap))
|
||||
@@ -25,6 +25,7 @@ import httpx
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ai.client import AIClient, parse_json_response
|
||||
from core.config import settings
|
||||
from models.study_question import StudyQuestion
|
||||
from models.study_question_job import StudyQuestionJob
|
||||
from services.search.llm_gate import Priority, acquire_mlx_gate
|
||||
@@ -32,11 +33,12 @@ from services.study.explanation_rag import (
|
||||
gather_explanation_context,
|
||||
render_evidence_block,
|
||||
)
|
||||
from services.study.publish_enqueue import enqueue_question_publish
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# PR-3 LLM_TIMEOUT_S 와 동일 안전 마진 (26B 평균 ~10s, gate 직렬화 고려)
|
||||
LLM_TIMEOUT_S = 30.0
|
||||
# 2026-06-20: config 단일소스 (구 하드코딩 30s = 빠른 Gemma 기준, Qwen 27B 교체 sweep 누락).
|
||||
LLM_TIMEOUT_S = settings.llm_call_timeout_s
|
||||
|
||||
# explanation_md hard cap — 운영 데이터 793/838/866자 사례에서 1200 으로 시작
|
||||
# (800 은 공식·오답·핵심개념 묶이는 기사시험 풀이에 빡빡함). 1차 운영 후 조정.
|
||||
@@ -226,6 +228,10 @@ async def run_explanation_job(session: AsyncSession, job: StudyQuestionJob) -> N
|
||||
question.ai_explanation_model = f"mlx:{primary_name}"
|
||||
question.updated_at = question.ai_explanation_generated_at
|
||||
|
||||
# 발행 재투영(같은 tx, caller commit) — 4-A 해설 ready → 문항+해설 발행. P0-1b.
|
||||
if settings.study_publish_enabled:
|
||||
await enqueue_question_publish(session, question)
|
||||
|
||||
job.status = "completed"
|
||||
job.completed_at = now()
|
||||
return
|
||||
|
||||
@@ -14,6 +14,7 @@ from datetime import datetime, timedelta, timezone
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
from core.config import settings
|
||||
from core.database import async_session
|
||||
from core.utils import setup_logger
|
||||
from models.study_memo_card_job import StudyMemoCardJob
|
||||
@@ -50,6 +51,10 @@ async def reset_stale_card_jobs() -> None:
|
||||
|
||||
async def consume_study_memo_card_queue() -> None:
|
||||
"""APScheduler 진입점. pending card_extract job 을 BATCH_SIZE 만큼 처리."""
|
||||
# 생성 LLM 홀드: claim 자체를 하지 않음 (1분 주기라 로그는 debug).
|
||||
if "study_memo_card" in settings.pipeline_held_stages:
|
||||
logger.debug("study_memo_card 보류 (pipeline.held_stages)")
|
||||
return
|
||||
await reset_stale_card_jobs()
|
||||
|
||||
async with async_session() as session:
|
||||
|
||||
@@ -24,6 +24,7 @@ import httpx
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ai.client import AIClient, parse_json_response
|
||||
from core.config import settings
|
||||
from models.study_memo_card import (
|
||||
append_card,
|
||||
append_card_evidence,
|
||||
@@ -33,6 +34,8 @@ from models.study_memo_card_job import StudyMemoCardJob
|
||||
from models.study_question import StudyQuestion
|
||||
from models.user import User # noqa: F401 (mapper 초기화 defensive)
|
||||
from services.search.llm_gate import Priority, acquire_mlx_gate
|
||||
from services.study.publish_enqueue import enqueue_publish
|
||||
from services.study.publish_projection import KIND_CARD
|
||||
from services.study.explanation_rag import (
|
||||
gather_explanation_context,
|
||||
render_evidence_block,
|
||||
@@ -41,8 +44,8 @@ from services.study.study_memo_card_guards import guard_cards
|
||||
|
||||
logger = logging.getLogger("study_memo_card_worker")
|
||||
|
||||
# 다카드 출력이라 explanation(30s)보다 여유. config primary.timeout(180, soft-lock)은 미변경.
|
||||
CARD_LLM_TIMEOUT_S = 45.0
|
||||
# 2026-06-20: config 단일소스 (구 하드코딩 45s = 빠른 Gemma 기준).
|
||||
CARD_LLM_TIMEOUT_S = settings.llm_call_timeout_s
|
||||
SOURCE_KIND_QUESTION = "question"
|
||||
|
||||
_ENVELOPE_PROMPT_FILE = "study_card_envelope.txt"
|
||||
@@ -183,9 +186,13 @@ async def run_card_extract_job(session: AsyncSession, job: StudyMemoCardJob) ->
|
||||
return
|
||||
|
||||
# 5. 성공 — 구버전 카드 retire 후 append (dedup partial unique 충돌 회피).
|
||||
await supersede_old_cards(
|
||||
retired_published_ids = await supersede_old_cards(
|
||||
session, source_question_id=question.id, keep_generated_at=source_version
|
||||
)
|
||||
# 발행 중이던 구버전 카드 tombstone(같은 tx) — 재추출 retire 후 viewer stale 잔류 0. S-2.
|
||||
if settings.study_publish_enabled:
|
||||
for cid in retired_published_ids:
|
||||
await enqueue_publish(session, kind=KIND_CARD, source_id=cid, payload=None, deleted=True)
|
||||
model_name = f"mlx:{primary_name}"
|
||||
inserted = 0
|
||||
for g in guarded:
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
"""발행 워커 — publish_outbox drain → published 에 rev 부여 (docsrv-viewer-publish).
|
||||
|
||||
APScheduler 1분(max_instances=1). pg_advisory_xact_lock 단일 라이터 → rev 커밋순 gapless
|
||||
(인플라이트 갭 차단: bigserial seq 폴링이 아니라 outbox id 순 + 단일 라이터 rev 부여).
|
||||
outbox 를 id(커밋순) 순으로 처리, (kind, source_id) 당 published upsert:
|
||||
- 기존 행과 (payload_hash, deleted) 동일 → no-op(디둡, rev 안 올림) + processed 마킹
|
||||
- 그 외 → pub_id 재사용(기존)|신규 uuid, rev = MAX(rev)+1, payload/hash/deleted 갱신
|
||||
tombstone(deleted=True)은 디둡 복합키라 안 삼켜짐. 배치 단일 트랜잭션.
|
||||
배치 내 같은 (kind, source_id) 가 두 번 오면 flush 로 직전 반영을 다음 select 가 보게 함(최신 승).
|
||||
|
||||
study_publish_enabled=False(기본) 면 no-op — 저자/4-A enqueue 결선(P0-1b) 전까지 inert.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import func, select, text
|
||||
|
||||
from core.config import settings
|
||||
from core.database import async_session
|
||||
from core.utils import setup_logger
|
||||
from models.published import Published, PublishOutbox
|
||||
|
||||
logger = setup_logger("study_publish_worker")
|
||||
|
||||
BATCH_SIZE = 500
|
||||
# pg_advisory_xact_lock 전역 단일 라이터 키(발행 워커 전용 임의 상수, 타 advisory 락과 비충돌).
|
||||
ADVISORY_LOCK_KEY = 838201
|
||||
# 행별 격리 재시도 상한 — 초과 시 failed_at 스탬프(terminal)로 select 에서 제외.
|
||||
MAX_OUTBOX_ATTEMPTS = 5
|
||||
|
||||
|
||||
async def consume_publish_outbox() -> None:
|
||||
"""APScheduler 진입점. 미처리 outbox 를 rev 부여하며 published 로 반영."""
|
||||
if not settings.study_publish_enabled:
|
||||
logger.debug("study_publish 비활성 (study_publish_enabled=false)")
|
||||
return
|
||||
|
||||
async with async_session() as session:
|
||||
try:
|
||||
# 1) 전역 단일 라이터 락(트랜잭션 스코프 — commit/rollback 시 자동 해제).
|
||||
await session.execute(
|
||||
text("SELECT pg_advisory_xact_lock(:k)").bindparams(k=ADVISORY_LOCK_KEY)
|
||||
)
|
||||
# 2) 현재 최대 rev.
|
||||
max_rev = int(
|
||||
(await session.execute(select(func.coalesce(func.max(Published.rev), 0)))).scalar() or 0
|
||||
)
|
||||
# 3) 미처리 outbox 를 커밋순(id)으로. failed_at(terminal) 은 제외 — poison 행이
|
||||
# head-of-line 을 영구 점유하지 않게 함.
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(PublishOutbox)
|
||||
.where(
|
||||
PublishOutbox.processed_at.is_(None),
|
||||
PublishOutbox.failed_at.is_(None),
|
||||
)
|
||||
.order_by(PublishOutbox.id.asc())
|
||||
.limit(BATCH_SIZE)
|
||||
)
|
||||
).scalars().all()
|
||||
if not rows:
|
||||
return
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
published_count = 0
|
||||
failed_count = 0
|
||||
for ob in rows:
|
||||
try:
|
||||
# 행 단위 savepoint 격리 — 한 행의 예외가 배치 전체(앞 행 processed_at 포함)를
|
||||
# 롤백해 poison 행이 다음 사이클에 다시 최저 id 로 선택되는 무한 재선택을 차단.
|
||||
async with session.begin_nested():
|
||||
existing = (
|
||||
await session.execute(
|
||||
select(Published).where(
|
||||
Published.kind == ob.kind,
|
||||
Published.source_id == ob.source_id,
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
|
||||
# (payload_hash, deleted) 디둡 — no-op 재투영은 rev 안 올림.
|
||||
is_noop = (
|
||||
existing is not None
|
||||
and existing.payload_hash == ob.payload_hash
|
||||
and existing.deleted == ob.deleted
|
||||
)
|
||||
if is_noop:
|
||||
ob.processed_at = now
|
||||
else:
|
||||
new_rev = max_rev + 1
|
||||
if existing is None:
|
||||
session.add(
|
||||
Published(
|
||||
kind=ob.kind,
|
||||
source_id=ob.source_id,
|
||||
pub_id=uuid.uuid4().hex,
|
||||
payload=ob.payload,
|
||||
payload_hash=ob.payload_hash,
|
||||
schema_version=ob.schema_version,
|
||||
rev=new_rev,
|
||||
deleted=ob.deleted,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
)
|
||||
else:
|
||||
existing.payload = ob.payload
|
||||
existing.payload_hash = ob.payload_hash
|
||||
existing.schema_version = ob.schema_version
|
||||
existing.deleted = ob.deleted
|
||||
existing.rev = new_rev
|
||||
existing.updated_at = now
|
||||
ob.processed_at = now
|
||||
# 배치 내 동일 (kind, source_id) 후속 행이 직전 반영을 보도록 flush(최신 승).
|
||||
await session.flush()
|
||||
except Exception as row_err:
|
||||
# savepoint 롤백 = 이 행의 쓰기(processed_at 포함) 취소. attempts/failed_at 만
|
||||
# 바깥 트랜잭션에 누적돼 최종 commit 으로 영속(영구 재선택 방지).
|
||||
ob.attempts = (ob.attempts or 0) + 1
|
||||
if ob.attempts >= MAX_OUTBOX_ATTEMPTS:
|
||||
ob.failed_at = now
|
||||
failed_count += 1
|
||||
logger.error(
|
||||
"publish_outbox_row_terminal id=%s kind=%s source_id=%s attempts=%s: %s",
|
||||
ob.id, ob.kind, ob.source_id, ob.attempts, row_err,
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"publish_outbox_row_retry id=%s kind=%s source_id=%s attempts=%s: %s",
|
||||
ob.id, ob.kind, ob.source_id, ob.attempts, row_err,
|
||||
)
|
||||
continue
|
||||
else:
|
||||
# savepoint 커밋 성공 시에만 rev 카운터 전진(실패 행은 rev 미소모 → 드물게 gap,
|
||||
# 단일 라이터·커밋순 부여라 viewer since-rev 증분 동기 정합엔 무해).
|
||||
if not is_noop:
|
||||
max_rev = new_rev
|
||||
published_count += 1
|
||||
|
||||
await session.commit()
|
||||
logger.info(
|
||||
"publish_outbox_drained scanned=%s published=%s failed=%s max_rev=%s",
|
||||
len(rows),
|
||||
published_count,
|
||||
failed_count,
|
||||
max_rev,
|
||||
)
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
logger.exception("publish_outbox_drain_failed: %s", e)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user