Compare commits
49 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a55bb3453d | |||
| 9061f2e25c | |||
| 33427d4a42 | |||
| b91b05e889 | |||
| 304a2b9c0f | |||
| d53fcc2b36 | |||
| 43594620b1 | |||
| b73a5cc601 | |||
| 3b7fd900e4 | |||
| c2077b3108 | |||
| 51e8034759 | |||
| 61e70864e4 | |||
| a182def9e6 | |||
| 6d447f9cba | |||
| f38ec177d7 | |||
| da4a2e81c3 | |||
| 966a4315c8 | |||
| 3c42b7b97a | |||
| 91ce54c1cd | |||
| 9ec0a184a0 | |||
| 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 |
@@ -19,6 +19,14 @@ http://document.hyungi.net {
|
||||
Referrer-Policy strict-origin-when-cross-origin
|
||||
-Server
|
||||
}
|
||||
|
||||
# 2노드 이관(2026-07-02): 업로드 100MB 한도 집행을 edge(home-caddy)에서 DS 내부로 재홈.
|
||||
# 인그레스가 DSM 리버스 프록시(한도 GUI 미노출)로 바뀌어도 413 단일 소스 유지.
|
||||
# config.yaml upload.max_bytes(100000000)와 정합.
|
||||
request_body {
|
||||
max_size 100MB
|
||||
}
|
||||
|
||||
encode {
|
||||
gzip
|
||||
match {
|
||||
|
||||
+2
-2
@@ -11,8 +11,8 @@ RUN apt-get update && \
|
||||
ffmpeg && \
|
||||
apt-get clean && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY requirements.txt requirements.lock ./
|
||||
RUN pip install --no-cache-dir -r requirements.lock
|
||||
|
||||
COPY . .
|
||||
|
||||
|
||||
+29
-26
@@ -260,23 +260,6 @@ class AIClient:
|
||||
cfg = self.ai.deep or self.ai.primary
|
||||
return await self._request(cfg, prompt, system=system)
|
||||
|
||||
async def call_classifier(self, prompt: str) -> str:
|
||||
"""answerability classifier (config ai.classifier, Mac mini 26B MLX).
|
||||
|
||||
private _request 직접 호출(classifier_service)을 봉인하는 public 진입점. gate 는
|
||||
caller(classifier_service)가 acquire_mlx_gate 로 관리 — call_primary 와 동일한
|
||||
caller-managed 계약(여기서 self-gate 하면 caller 와 double-acquire 데드락).
|
||||
"""
|
||||
return await self._request(self.ai.classifier, prompt)
|
||||
|
||||
async def call_verifier(self, prompt: str) -> str:
|
||||
"""semantic verifier (config ai.verifier, Mac mini 26B MLX).
|
||||
|
||||
private _request 직접 호출(verifier_service)을 봉인. gate 는 caller(verifier_service)
|
||||
가 관리(caller-managed — self-gate 금지).
|
||||
"""
|
||||
return await self._request(self.ai.verifier, prompt)
|
||||
|
||||
# ─── Legacy API (classify_worker 교체 시 제거 예정) ───────────────────
|
||||
|
||||
async def classify(self, text: str, cfg=None) -> dict:
|
||||
@@ -307,23 +290,43 @@ class AIClient:
|
||||
return response.json()["embedding"]
|
||||
|
||||
async def rerank(self, query: str, texts: list[str]) -> list[dict]:
|
||||
"""TEI bge-reranker-v2-m3 호출 (Phase 1.3).
|
||||
"""리랭커 호출 — ai.models.rerank.protocol 로 백엔드 분기 (2노드 이관 2026-07-02).
|
||||
|
||||
TEI POST /rerank API:
|
||||
공통 반환 계약: [{"index": int, "score": float}, ...] (score 내림차순)
|
||||
|
||||
"tei" (기본, 무회귀) — TEI POST /rerank:
|
||||
request: {"query": str, "texts": [str, ...]}
|
||||
response: [{"index": int, "score": float}, ...] (정렬됨)
|
||||
"llamacpp" — llama.cpp POST /v1/rerank (bge-reranker GGUF, 맥미니 :8807):
|
||||
request: {"model": str, "query": str, "documents": [str, ...]}
|
||||
response: {"results": [{"index": int, "relevance_score": float}, ...]}
|
||||
→ normalize_llamacpp_rerank 로 TEI 형태 정규화.
|
||||
|
||||
미지원 protocol = ValueError (명시 실패 — silent fallback 금지).
|
||||
timeout은 self.ai.rerank.timeout (config.yaml).
|
||||
호출자(rerank_service)가 asyncio.Semaphore + try/except로 감쌈.
|
||||
"""
|
||||
protocol = getattr(self.ai.rerank, "protocol", "tei") or "tei"
|
||||
timeout = float(self.ai.rerank.timeout) if self.ai.rerank.timeout else 5.0
|
||||
response = await self._http.post(
|
||||
self.ai.rerank.endpoint,
|
||||
json={"query": query, "texts": texts},
|
||||
timeout=timeout,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
if protocol == "tei":
|
||||
response = await self._http.post(
|
||||
self.ai.rerank.endpoint,
|
||||
json={"query": query, "texts": texts},
|
||||
timeout=timeout,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
if protocol == "llamacpp":
|
||||
from ai.rerank_protocol import normalize_llamacpp_rerank
|
||||
|
||||
response = await self._http.post(
|
||||
self.ai.rerank.endpoint,
|
||||
json={"model": self.ai.rerank.model, "query": query, "documents": texts},
|
||||
timeout=timeout,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return normalize_llamacpp_rerank(response.json())
|
||||
raise ValueError(f"unknown rerank protocol: {protocol}")
|
||||
|
||||
async def _call_chat(self, model_config, prompt: str) -> str:
|
||||
"""OpenAI 호환 API 호출 (R6: 무동의 클라우드 폴백 제거).
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
"""rerank 백엔드 응답 정규화 — 2노드 이관 (2026-07-02, main-server-retirement-1 P1-4).
|
||||
|
||||
TEI(/rerank)와 llama.cpp(/v1/rerank)는 요청/응답 스키마가 다르다.
|
||||
소비자(rerank_service)는 TEI 형태 [{"index": int, "score": float}]를 기대하므로
|
||||
llama.cpp 응답을 여기서 정규화한다. 순수 함수(stdlib only) — 단위 테스트 대상.
|
||||
"""
|
||||
|
||||
|
||||
def normalize_llamacpp_rerank(payload: dict) -> list[dict]:
|
||||
"""llama.cpp /v1/rerank 응답을 TEI 형태로 정규화.
|
||||
|
||||
입력: {"results": [{"index": int, "relevance_score": float}, ...], ...}
|
||||
반환: [{"index": int, "score": float}, ...] (score 내림차순 — TEI '정렬됨' 계약 유지)
|
||||
|
||||
index/relevance_score 가 없는 항목은 버린다 (소비자 측 idx/sc None 가드와 동일 방어).
|
||||
"""
|
||||
results = payload.get("results") or []
|
||||
normalized = [
|
||||
{"index": r["index"], "score": float(r["relevance_score"])}
|
||||
for r in results
|
||||
if r.get("index") is not None and r.get("relevance_score") is not None
|
||||
]
|
||||
normalized.sort(key=lambda r: -r["score"])
|
||||
return normalized
|
||||
+337
-2
@@ -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
|
||||
@@ -742,11 +742,31 @@ 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)
|
||||
|
||||
|
||||
@@ -1028,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="이미지를 찾을 수 없습니다")
|
||||
|
||||
@@ -1801,3 +1834,305 @@ 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],
|
||||
)
|
||||
|
||||
|
||||
# ─── 절 공부도구 (노트/형광펜/암기카드) — clause_study ───
|
||||
class StudyItem(BaseModel):
|
||||
id: int
|
||||
kind: str
|
||||
payload: dict = {}
|
||||
created_at: datetime | None = None
|
||||
|
||||
|
||||
class StudyListResponse(BaseModel):
|
||||
doc_id: int
|
||||
items: list[StudyItem]
|
||||
|
||||
|
||||
class StudyCreate(BaseModel):
|
||||
kind: str # note | highlight | card
|
||||
payload: dict = {}
|
||||
|
||||
|
||||
def _parse_payload(p):
|
||||
import json
|
||||
if isinstance(p, str):
|
||||
try:
|
||||
return json.loads(p)
|
||||
except Exception:
|
||||
return {}
|
||||
return p or {}
|
||||
|
||||
|
||||
@router.get("/{doc_id}/study", response_model=StudyListResponse)
|
||||
async def list_study(
|
||||
doc_id: int,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
):
|
||||
"""절-문서의 공부도구 항목(노트/형광펜/암기카드) 목록."""
|
||||
from sqlalchemy import text as sql_text
|
||||
rows = (
|
||||
await session.execute(
|
||||
sql_text("SELECT id, kind, payload, created_at FROM clause_study "
|
||||
"WHERE doc_id = :id ORDER BY created_at DESC").bindparams(id=doc_id)
|
||||
)
|
||||
).mappings().all()
|
||||
return StudyListResponse(
|
||||
doc_id=doc_id,
|
||||
items=[StudyItem(id=r["id"], kind=r["kind"], payload=_parse_payload(r["payload"]),
|
||||
created_at=r["created_at"]) for r in rows],
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{doc_id}/study", response_model=StudyItem, status_code=201)
|
||||
async def add_study(
|
||||
doc_id: int,
|
||||
body: StudyCreate,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
):
|
||||
"""노트/형광펜/암기카드 1건 추가."""
|
||||
import json
|
||||
from sqlalchemy import text as sql_text
|
||||
if body.kind not in ("note", "highlight", "card"):
|
||||
raise HTTPException(status_code=400, detail="kind 는 note/highlight/card")
|
||||
row = (
|
||||
await session.execute(
|
||||
sql_text("INSERT INTO clause_study(doc_id, kind, payload) "
|
||||
"VALUES (:d, :k, cast(:p AS jsonb)) RETURNING id, kind, payload, created_at")
|
||||
.bindparams(d=doc_id, k=body.kind, p=json.dumps(body.payload, ensure_ascii=False))
|
||||
)
|
||||
).mappings().first()
|
||||
await session.commit()
|
||||
return StudyItem(id=row["id"], kind=row["kind"], payload=_parse_payload(row["payload"]),
|
||||
created_at=row["created_at"])
|
||||
|
||||
|
||||
@router.delete("/{doc_id}/study/{study_id}", status_code=204)
|
||||
async def delete_study(
|
||||
doc_id: int,
|
||||
study_id: int,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
):
|
||||
from sqlalchemy import text as sql_text
|
||||
await session.execute(
|
||||
sql_text("DELETE FROM clause_study WHERE id = :s AND doc_id = :d")
|
||||
.bindparams(s=study_id, d=doc_id)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
+100
-75
@@ -23,6 +23,7 @@ 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
|
||||
@@ -66,6 +67,22 @@ class IngestBody(BaseModel):
|
||||
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
|
||||
@@ -98,18 +115,7 @@ async def ingest_attempts(
|
||||
)
|
||||
).scalars().all()
|
||||
if existing:
|
||||
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 existing
|
||||
],
|
||||
}
|
||||
return _already_ingested(existing)
|
||||
|
||||
# pub_id → source_id(내부 질문 id) 해소. deleted tombstone 제외.
|
||||
pub_ids = list({a.question_pub_id for a in body.attempts})
|
||||
@@ -156,73 +162,92 @@ async def ingest_attempts(
|
||||
if not by_topic:
|
||||
raise HTTPException(status_code=404, detail="해소된 attempt 없음")
|
||||
|
||||
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
|
||||
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),
|
||||
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
|
||||
)
|
||||
)
|
||||
qs.correct_count, qs.wrong_count, qs.unsure_count = c, w, u
|
||||
await session.flush()
|
||||
).scalars().all()
|
||||
if not winner:
|
||||
raise
|
||||
logger.info("study_ingest uuid=%s 동시경합 흡수 → already_ingested", body.client_session_uuid)
|
||||
return _already_ingested(winner)
|
||||
|
||||
# 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()
|
||||
logger.info(
|
||||
"study_ingest uuid=%s user=%s sessions=%s skipped=%s",
|
||||
body.client_session_uuid, user_id, len(summaries), len(skipped),
|
||||
|
||||
@@ -37,8 +37,8 @@ class CurrentItem(BaseModel):
|
||||
|
||||
|
||||
class MachineCard(BaseModel):
|
||||
"""머신 카드 — stage 귀속 합산 + 완료 실적(summarize 는 풀 분리) + state."""
|
||||
key: Literal["gpu", "macmini", "macbook"]
|
||||
"""머신 카드 — stage 귀속 합산 + 완료 실적 + state (나스/맥미니 2노드)."""
|
||||
key: Literal["nas", "macmini"]
|
||||
label: str
|
||||
state: Literal["active", "deferred", "idle"]
|
||||
stages: list[str]
|
||||
@@ -59,20 +59,6 @@ class SummarizeEta(BaseModel):
|
||||
eta_minutes: int | None
|
||||
|
||||
|
||||
class MachineDone(BaseModel):
|
||||
"""머신 1대의 summarize 완료 실적 (분담 표시용)."""
|
||||
done_1h: int
|
||||
done_today: int
|
||||
|
||||
|
||||
class SummarizeByMachine(BaseModel):
|
||||
"""summarize 풀의 머신별 완료 실적 분담 — 보드 레인의 '맥미니 vs 맥북'
|
||||
오프로드 가시화용. rows_to_summarize_split 이 이미 계산하던 값의 노출
|
||||
(ds-board-merged A-1, 신규 수집 SQL 0)."""
|
||||
macmini: MachineDone
|
||||
macbook: MachineDone
|
||||
|
||||
|
||||
class TrendBucket(BaseModel):
|
||||
"""summarize 24h 추이 버킷 — hour 는 KST "HH:00" 라벨."""
|
||||
hour: str
|
||||
@@ -122,7 +108,6 @@ 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] = []
|
||||
|
||||
+11
-852
@@ -3,42 +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 datetime import date
|
||||
from typing import Annotated, Literal
|
||||
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")
|
||||
@@ -153,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)$"),
|
||||
@@ -225,6 +212,8 @@ async def search(
|
||||
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)"""
|
||||
@@ -235,6 +224,9 @@ async def search(
|
||||
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,
|
||||
@@ -354,836 +346,3 @@ async def search(
|
||||
debug=debug_obj,
|
||||
facets=facets_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.CancelledError:
|
||||
raise # 요청 취소는 전파 — broad except 가 삼키지 않게 명시 (R3)
|
||||
except 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.CancelledError:
|
||||
raise # 요청 취소는 전파 — broad except 가 삼키지 않게 명시 (R3)
|
||||
except 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,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
"""study_concepts API — 이론공부 홈(오늘의 개념 · 진도 · 회독 SR). prefix = /api/study.
|
||||
|
||||
문제풀이 표면 무접촉. 개념문서(가스기사 태그) 읽기 집계 + 회독 SR write 만. 단일 토픽(가스기사=4).
|
||||
경로: GET /curriculum · GET /today-concepts · POST /concepts/{doc_id}/read.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
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.study import concept_curriculum as cc
|
||||
from services.study import concept_links as cl
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# 가스기사 단일 토픽 운영(현행). 다토픽 확장 시 쿼리 파라미터로 승격.
|
||||
DEFAULT_TOPIC_ID = 4
|
||||
|
||||
|
||||
@router.get("/curriculum")
|
||||
async def get_curriculum(
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
topic_id: int = DEFAULT_TOPIC_ID,
|
||||
):
|
||||
"""과목별 회독 진도 + 개념/문항 복습 due 요약."""
|
||||
return await cc.curriculum(session, user.id, topic_id)
|
||||
|
||||
|
||||
@router.get("/today-concepts")
|
||||
async def get_today_concepts(
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
topic_id: int = DEFAULT_TOPIC_ID,
|
||||
limit: int = 6,
|
||||
):
|
||||
"""오늘 공부할 개념(재복습 → 미독 빈출순)."""
|
||||
return await cc.today_concepts(session, user.id, topic_id, limit)
|
||||
|
||||
|
||||
@router.get("/concepts/weakness-map")
|
||||
async def get_weakness_map(
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
topic_id: int = DEFAULT_TOPIC_ID,
|
||||
limit: int = 12,
|
||||
):
|
||||
"""개념 약점 지도 — 링크된 기출 정답률로 약점 개념(정답률<60%) 우선(이론↔문제)."""
|
||||
name = await cc._topic_name(session, topic_id)
|
||||
if not name:
|
||||
return {"weak": [], "weak_total": 0, "evaluated_total": 0}
|
||||
return await cl.weakness_map(session, user.id, name, limit)
|
||||
|
||||
|
||||
@router.get("/concepts/{doc_id}")
|
||||
async def get_concept_detail(
|
||||
doc_id: int,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
topic_id: int = DEFAULT_TOPIC_ID,
|
||||
):
|
||||
"""개념 리더 재료 — 구조 파싱(요약/본문/빈출/관련) + 백링크 해소 + 회독/SR + 이전/다음."""
|
||||
detail = await cc.concept_detail(session, user.id, topic_id, doc_id)
|
||||
if detail is None:
|
||||
raise HTTPException(status_code=404, detail="concept not found")
|
||||
return detail
|
||||
|
||||
|
||||
@router.get("/concepts/{doc_id}/questions")
|
||||
async def get_concept_questions(
|
||||
doc_id: int,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
limit: int = 20,
|
||||
):
|
||||
"""개념 관련 기출 + 내 정답률 (이론↔문제 브리지)."""
|
||||
return await cl.related_questions(session, user.id, doc_id, limit)
|
||||
|
||||
|
||||
@router.post("/concepts/{doc_id}/read")
|
||||
async def post_concept_read(
|
||||
doc_id: int,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
topic_id: int = DEFAULT_TOPIC_ID,
|
||||
):
|
||||
"""개념 회독 처리 → 회독 플래그 + SR 입고/전진."""
|
||||
return await cc.mark_read(session, user.id, topic_id, doc_id)
|
||||
+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)],
|
||||
|
||||
@@ -35,6 +35,12 @@ class AIModelConfig(BaseModel):
|
||||
# OpenAI 호환 분기(mlx)만 적용 — Anthropic 분기는 미적용(별 범위).
|
||||
repetition_penalty: float | None = None
|
||||
top_k: int | None = None
|
||||
# 2노드 이관 (2026-07-02): rerank 백엔드 프로토콜 판별자.
|
||||
# "tei" = TEI POST /rerank {"query","texts"} → [{"index","score"}] (기본, 무회귀)
|
||||
# "llamacpp" = llama.cpp POST /v1/rerank {"model","query","documents"}
|
||||
# → {"results":[{"index","relevance_score"}]} (맥미니 :8807)
|
||||
# 미지원 값 = client.rerank 가 ValueError (silent fallback 금지). rerank 블록 외 무시.
|
||||
protocol: str = "tei"
|
||||
|
||||
|
||||
class DeepSummaryBacklogConfig(BaseModel):
|
||||
@@ -145,6 +151,12 @@ class Settings(BaseModel):
|
||||
# STT (faster-whisper, §3)
|
||||
stt_endpoint: str = "http://stt-service:3300"
|
||||
|
||||
# 2노드 이관 (2026-07-02): GPU CUDA 서비스(Surya OCR / faster-whisper) 폐기 대응 명시 게이트.
|
||||
# false = 해당 경로 명시 비활성 — OCR 은 _call_ocr 이 경고 로그 후 None(기존 soft-fail 의미론),
|
||||
# STT 는 터미널 skip + extract_meta 기록. silent 저품질 fallback 아님 (로그/메타로 가시).
|
||||
ocr_enabled: bool = True
|
||||
stt_enabled: bool = True
|
||||
|
||||
# §3 file_watcher: Roon 음원 경로 (prefix match 로 skip).
|
||||
# 빈 문자열이면 skip 없음. 예: "/documents/PKM/../Music/roon-library" 또는
|
||||
# NFS 경유 별도 마운트된 Roon 라이브러리.
|
||||
@@ -224,6 +236,8 @@ def load_settings() -> Settings:
|
||||
kordoc_endpoint = os.getenv("KORDOC_ENDPOINT", "http://kordoc-service:3100")
|
||||
ocr_endpoint = os.getenv("OCR_ENDPOINT", "http://ocr-service:3200")
|
||||
stt_endpoint = os.getenv("STT_ENDPOINT", "http://stt-service:3300")
|
||||
ocr_enabled = os.getenv("OCR_ENABLED", "true").lower() in ("1", "true", "yes")
|
||||
stt_enabled = os.getenv("STT_ENABLED", "true").lower() in ("1", "true", "yes")
|
||||
roon_library_path = os.getenv("ROON_LIBRARY_PATH", "")
|
||||
|
||||
# ADDITIONAL_WATCH_TARGETS — 쉼표 구분 (공백 제거)
|
||||
@@ -343,6 +357,8 @@ def load_settings() -> Settings:
|
||||
kordoc_endpoint=kordoc_endpoint,
|
||||
ocr_endpoint=ocr_endpoint,
|
||||
stt_endpoint=stt_endpoint,
|
||||
ocr_enabled=ocr_enabled,
|
||||
stt_enabled=stt_enabled,
|
||||
roon_library_path=roon_library_path,
|
||||
additional_watch_targets=additional_watch_targets,
|
||||
taxonomy=taxonomy,
|
||||
|
||||
+21
-15
@@ -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,13 @@ 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 에 포함).
|
||||
@@ -167,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},
|
||||
|
||||
@@ -33,6 +33,7 @@ from api.study_sessions import router as study_sessions_router
|
||||
from api.study_topics import router as study_topics_router
|
||||
from api.study_reminders import router as study_reminders_router
|
||||
from api.study_cards import router as study_cards_router
|
||||
from api.study_concepts import router as study_concepts_router
|
||||
from api.video import router as video_router
|
||||
from core.config import settings
|
||||
from core.database import async_session, engine, init_db
|
||||
@@ -249,6 +250,8 @@ app.include_router(study_reminders_router, prefix="/api/study-reminders", tags=[
|
||||
app.include_router(study_cards_router, prefix="/api/study-cards", tags=["study-cards"])
|
||||
# Phase 1: 학습 진행 상태 (review-complete + review-queue). prefix=/api/study-topics 안에 정의됨.
|
||||
app.include_router(study_question_progress_router, prefix="/api", tags=["study-progress"])
|
||||
# 이론공부 홈: 오늘의 개념·진도·회독 SR (개념문서 소비 표면, 문제풀이 무접촉).
|
||||
app.include_router(study_concepts_router, prefix="/api/study", tags=["study-theory"])
|
||||
|
||||
# TODO: Phase 5에서 추가
|
||||
# app.include_router(tasks.router, prefix="/api/tasks", tags=["tasks"])
|
||||
|
||||
@@ -56,5 +56,9 @@ class PublishOutbox(Base):
|
||||
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.
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
"""study_concept_progress — 사용자 × 개념문서 단위 간격반복(SR) 진행 (이론공부 홈).
|
||||
|
||||
문제 SR(study_question_progress)의 개념(이론)판. '개념문서' = documents 한 건(가스기사 태그).
|
||||
회독(첫 read) → 복습 큐 진입, 이후 회독마다 sr_schedule 산술(1·3·7·14·졸업) 공용 전진.
|
||||
concept_doc_id 는 documents.id 를 가리키나 FK 미설정 — hot 테이블(documents) 락 회피(clause_study 선례).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import BigInteger, DateTime, ForeignKey, SmallInteger, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from core.database import Base
|
||||
|
||||
|
||||
class StudyConceptProgress(Base):
|
||||
__tablename__ = "study_concept_progress"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"user_id", "concept_doc_id", name="uq_concept_progress_user_doc"
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
BigInteger, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
study_topic_id: Mapped[int] = mapped_column(
|
||||
BigInteger, ForeignKey("study_topics.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
# documents.id 참조 — FK 없음(락 회피). 개념문서 삭제 시 고아 행은 read 집계에서 자연 제외.
|
||||
concept_doc_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
|
||||
|
||||
# 복습 큐 (sr_schedule 공용): stage 0~3 = 1·3·7·14일, 4 = 졸업(due_at NULL)
|
||||
review_stage: Mapped[int | None] = mapped_column(SmallInteger)
|
||||
due_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
last_read_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
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, onupdate=datetime.now, nullable=False
|
||||
)
|
||||
@@ -36,6 +36,8 @@ KNOWN_4B_TASKS = {
|
||||
}
|
||||
KNOWN_26B_TASKS = {
|
||||
"p3c_deep_summary",
|
||||
# presegment PR2 — 거대문서 map-reduce 의 reduce 단계 (요약들의 요약)
|
||||
"p3c_deep_summary_reduce",
|
||||
"p4b_synthesis",
|
||||
}
|
||||
|
||||
|
||||
@@ -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,44 @@
|
||||
[System]
|
||||
너는 긴 문서·문서 묶음 분석가다. 이 문서는 한 번에 처리하기에 너무 커서, 원문을 순서대로 유닛으로 나눠 각 유닛을 먼저 요약했다(map 단계). 아래 "유닛 요약"들은 원문 순서 그대로이며 문서 전체를 빠짐없이 커버한다. 너는 이를 종합해 문서 전체의 최종 분석을 작성한다(reduce 단계).
|
||||
|
||||
subject_description: {subject_description}
|
||||
|
||||
{forbidden_block}
|
||||
|
||||
envelope 를 읽는 순서:
|
||||
1. risk_flags 를 먼저 본다. 어떤 위험 때문에 올라온 것인지 파악.
|
||||
2. synthesis_directives 를 system 지시로 간주하여 반드시 준수.
|
||||
3. distilled_context 는 "참고 요지"일 뿐, 근거는 유닛 요약에서 재확인.
|
||||
|
||||
작성 규칙:
|
||||
- TL;DR (1문장, 최대 60자)
|
||||
- 핵심 (bullets 5개, 각 30~80자)
|
||||
- 상세 (2~4 문단, 각 3~5문장) — 유닛(섹션) 순서의 논리 흐름을 보전하며 문서 전체를 관통하는 서술. 특정 유닛만 편식하지 말 것.
|
||||
- 유닛 요약에 없는 정보 금지 (hallucination 금지). 숫자·조문·인용은 유닛 요약에 있는 것만 사용.
|
||||
- 유닛 요약의 "불일치(...)" 줄들은 중복 제거해 inconsistencies 로 보전 — 임의로 버리지 않는다.
|
||||
- synthesis_directives 의 문구 규칙 ("원인은 ~" 금지 등) 반드시 준수.
|
||||
- multi_reference_synthesis flag 있으면 레퍼런스별 입장 분리 기술, 종합 권고 금지.
|
||||
|
||||
출력 (JSON only):
|
||||
{{
|
||||
"mode": "single|bundle",
|
||||
"tldr": "...",
|
||||
"bullets": ["..."],
|
||||
"detail": "...\\n\\n...",
|
||||
"bundle_flow": ["..."] | null,
|
||||
"inconsistencies": ["..."] | null,
|
||||
"entities_confirmed": {{
|
||||
"people": [{{"name": "...", "evidence": "..."}}],
|
||||
"orgs": [...],
|
||||
"projects": [...]
|
||||
}},
|
||||
"directives_applied": ["..."],
|
||||
"confidence": 0.0~1.0
|
||||
}}
|
||||
|
||||
[User]
|
||||
Envelope:
|
||||
{{escalation_envelope_json}}
|
||||
|
||||
유닛 요약 (총 {{unit_count}}개, 원문 순서 — 각 블록 = 원문 한 구간의 요약):
|
||||
{{unit_summaries}}
|
||||
@@ -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,104 @@
|
||||
# requirements.lock — 라이브 fastapi 컨테이너 pip freeze 스냅샷 (2026-07-02, 101 pkgs, CVE-clear known-good)
|
||||
# 재생성: docker exec hyungi_document_server-fastapi-1 pip freeze > app/requirements.lock (헤더 재부착)
|
||||
# requirements.txt = 사람이 편집하는 floor 사양(>=) / 본 lock = Dockerfile 이 실제 설치하는 정본(==)
|
||||
annotated-doc==0.0.4
|
||||
annotated-types==0.7.0
|
||||
anthropic==0.109.1
|
||||
anyio==4.13.0
|
||||
APScheduler==3.11.2
|
||||
asyncpg==0.31.0
|
||||
babel==2.18.0
|
||||
bcrypt==5.0.0
|
||||
beautifulsoup4==4.15.0
|
||||
caldav==3.2.1
|
||||
certifi==2026.5.20
|
||||
cffi==2.0.0
|
||||
chardet==7.4.3
|
||||
charset-normalizer==3.4.7
|
||||
click==8.4.1
|
||||
cobble==0.1.4
|
||||
courlan==1.4.0
|
||||
cryptography==48.0.1
|
||||
cssselect==1.4.0
|
||||
dateparser==1.4.0
|
||||
defusedxml==0.7.1
|
||||
distro==1.9.0
|
||||
dnspython==2.8.0
|
||||
docstring_parser==0.18.0
|
||||
ecdsa==0.19.2
|
||||
et_xmlfile==2.0.0
|
||||
fastapi==0.136.3
|
||||
feedparser==6.0.12
|
||||
flatbuffers==25.12.19
|
||||
greenlet==3.5.1
|
||||
h11==0.16.0
|
||||
htmldate==1.10.0
|
||||
httpcore==1.0.9
|
||||
httptools==0.8.0
|
||||
httpx==0.28.1
|
||||
icalendar==7.1.2
|
||||
icalendar-searcher==1.0.6
|
||||
idna==3.18
|
||||
jh2==5.0.13
|
||||
Jinja2==3.1.6
|
||||
jiter==0.15.0
|
||||
jusText==3.0.2
|
||||
lxml==6.1.1
|
||||
lxml_html_clean==0.4.5
|
||||
magika==0.6.3
|
||||
mammoth==1.11.0
|
||||
Markdown==3.10.2
|
||||
markdownify==1.2.2
|
||||
markitdown==0.1.6
|
||||
MarkupSafe==3.0.3
|
||||
niquests==3.19.1
|
||||
numpy==2.4.6
|
||||
olefile==0.47
|
||||
onnxruntime==1.26.0
|
||||
openpyxl==3.1.5
|
||||
packaging==26.2
|
||||
pandas==3.0.3
|
||||
pgvector==0.4.2
|
||||
pillow==12.2.0
|
||||
protobuf==7.35.0
|
||||
pyasn1==0.6.3
|
||||
pycparser==3.0
|
||||
pydantic==2.13.4
|
||||
pydantic_core==2.46.4
|
||||
pyhwp==0.1b15
|
||||
PyMuPDF==1.27.2.3
|
||||
pyotp==2.9.0
|
||||
python-dateutil==2.9.0.post0
|
||||
python-dotenv==1.2.2
|
||||
python-jose==3.5.0
|
||||
python-multipart==0.0.32
|
||||
python-pptx==1.0.2
|
||||
pytz==2026.2
|
||||
PyYAML==6.0.3
|
||||
qh3==1.9.2
|
||||
readability-lxml==0.8.4.1
|
||||
recurring-ical-events==3.8.2
|
||||
regex==2026.5.9
|
||||
requests==2.34.2
|
||||
rsa==4.9.1
|
||||
sgmllib3k==1.0.0
|
||||
six==1.17.0
|
||||
sniffio==1.3.1
|
||||
soupsieve==2.8.4
|
||||
SQLAlchemy==2.0.50
|
||||
starlette==1.2.1
|
||||
tld==0.13.2
|
||||
trafilatura==2.1.0
|
||||
typing-inspection==0.4.2
|
||||
typing_extensions==4.15.0
|
||||
tzdata==2026.2
|
||||
tzlocal==5.3.1
|
||||
urllib3==2.7.0
|
||||
urllib3-future==2.21.902
|
||||
uvicorn==0.49.0
|
||||
uvloop==0.22.1
|
||||
wassima==2.1.1
|
||||
watchfiles==1.2.0
|
||||
websockets==16.0
|
||||
x-wr-timezone==2.0.1
|
||||
xlsxwriter==3.2.9
|
||||
@@ -0,0 +1,69 @@
|
||||
"""운영 알람 webhook (presegment PR3 — HOLD 유인 전환 게이트).
|
||||
|
||||
deep_summary HOLD(awaiting_split) 처럼 "사람이 개입해야 풀리는" 상태를 웹훅으로 발화한다.
|
||||
환경변수:
|
||||
ALERT_WEBHOOK_URL — 미설정 = no-op (프로세스당 1회 INFO 로그만).
|
||||
ALERT_WEBHOOK_KIND — 'synochat' | 'ntfy' (기본 synochat).
|
||||
synochat: Synology Chat incoming webhook — POST form `payload={"text": "..."}`.
|
||||
ntfy: POST body=message. 제목은 query param(?title=) — HTTP 헤더는 latin-1
|
||||
한정이라 한글 제목이 깨진다 (ntfy 는 query param title 을 공식 지원).
|
||||
|
||||
불변식: 알람은 절대 raise 하지 않는다 — 실패는 WARNING 로그만. 알람이 워커를
|
||||
죽이면 본전(요약 파이프라인)이 무너진다. 호출부는 반환값(bool)에 의존하지 말 것.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
import httpx
|
||||
|
||||
from core.utils import setup_logger
|
||||
|
||||
logger = setup_logger("alerts")
|
||||
|
||||
ALERT_TIMEOUT_SECONDS = 5.0
|
||||
|
||||
# 프로세스당 1회만 "미설정 no-op" 로그 — 매 HOLD 마다 로그 오염 방지.
|
||||
_noop_logged = False
|
||||
|
||||
|
||||
async def send_alert(title: str, message: str) -> bool:
|
||||
"""webhook 으로 알람 1건 발화. 성공 True / no-op·실패 False. 절대 raise 금지."""
|
||||
global _noop_logged
|
||||
url = (os.getenv("ALERT_WEBHOOK_URL") or "").strip()
|
||||
if not url:
|
||||
if not _noop_logged:
|
||||
logger.info("ALERT_WEBHOOK_URL 미설정 — 알람 no-op (이 로그는 프로세스당 1회)")
|
||||
_noop_logged = True
|
||||
return False
|
||||
|
||||
kind = (os.getenv("ALERT_WEBHOOK_KIND") or "synochat").strip().lower()
|
||||
if kind not in ("synochat", "ntfy"):
|
||||
logger.warning(f"ALERT_WEBHOOK_KIND={kind!r} 미지원 — synochat 으로 폴백")
|
||||
kind = "synochat"
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=ALERT_TIMEOUT_SECONDS) as client:
|
||||
if kind == "ntfy":
|
||||
resp = await client.post(
|
||||
url,
|
||||
params={"title": title},
|
||||
content=message.encode("utf-8"),
|
||||
)
|
||||
else: # synochat
|
||||
text = f"{title}\n{message}" if title else message
|
||||
resp = await client.post(
|
||||
url,
|
||||
data={"payload": json.dumps({"text": text}, ensure_ascii=False)},
|
||||
)
|
||||
if resp.status_code >= 400:
|
||||
logger.warning(
|
||||
f"알람 webhook({kind}) HTTP {resp.status_code}: {resp.text[:200]}"
|
||||
)
|
||||
return False
|
||||
return True
|
||||
except Exception as exc: # noqa: BLE001 — 알람 실패가 워커를 죽이면 안 됨
|
||||
logger.warning(f"알람 webhook({kind}) 발화 실패: {exc}")
|
||||
return False
|
||||
+34
-112
@@ -3,19 +3,16 @@
|
||||
GET /api/queue/overview 의 집계 로직. 모든 수치는 기존 processing_queue /
|
||||
documents 컬럼에서 라이브 계산 — 신규 테이블/마이그레이션 0 (HARD 제약).
|
||||
|
||||
구조: SQL 수집부(build_overview 내부 5쿼리)와 판정부(순수 함수)를 분리.
|
||||
구조: SQL 수집부(build_overview 내부 4쿼리)와 판정부(순수 함수)를 분리.
|
||||
판정부(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 카드 귀속
|
||||
(보류 = 맥북 불가 신호).
|
||||
귀속 규칙 (단일 진실 — 2026-07-02 컷오버 후 나스+맥미니 2노드):
|
||||
- stage→machine 정적 맵: nas = extract/embed/chunk/markdown/preview/thumbnail/
|
||||
fulltext/stt (DS 본체 Docker — 임베딩·리랭크 모델 콜은 맥미니로 나감) ·
|
||||
macmini = classify/summarize/deep_summary (단일 생성 LLM 허브).
|
||||
- deferred_pending(payload.deferred_until 미래)은 LLM 백오프 신호 —
|
||||
summarize/deep_summary 소속인 macmini 카드 귀속.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
@@ -25,42 +22,33 @@ 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 = (
|
||||
_NAS_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
|
||||
_MACMINI_STAGES = ("classify", "summarize", "deep_summary")
|
||||
_STAGE_ORDER = _NAS_STAGES + _MACMINI_STAGES
|
||||
|
||||
_MACHINE_KEYS = ("gpu", "macmini", "macbook")
|
||||
_MACHINE_KEYS = ("nas", "macmini")
|
||||
_MACHINE_LABELS = {
|
||||
"gpu": "GPU 서버",
|
||||
"nas": "나스",
|
||||
"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 귀속."""
|
||||
def stage_machine_map() -> dict[str, str]:
|
||||
"""stage → machine key 맵 (정적 — 나스/맥미니 2노드)."""
|
||||
mapping: dict[str, str] = {}
|
||||
for s in _GPU_STAGES:
|
||||
mapping[s] = "gpu"
|
||||
for s in _NAS_STAGES:
|
||||
mapping[s] = "nas"
|
||||
for s in _MACMINI_STAGES:
|
||||
mapping[s] = "macmini"
|
||||
for s in _MACBOOK_STAGES:
|
||||
mapping[s] = "macbook" if deep_enabled else "macmini"
|
||||
return mapping
|
||||
|
||||
|
||||
@@ -90,23 +78,6 @@ def rows_to_stage_stats(rows) -> dict[str, dict]:
|
||||
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"):
|
||||
@@ -120,13 +91,10 @@ def display_title(row: dict) -> str:
|
||||
|
||||
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)
|
||||
"""머신 카드 2장 (nas / macmini) 구성 — 귀속 규칙의 판정부."""
|
||||
smap = stage_machine_map()
|
||||
|
||||
def g(stage: str, field: str) -> int:
|
||||
return stage_stats.get(stage, {}).get(field, 0)
|
||||
@@ -149,29 +117,23 @@ def build_machines(
|
||||
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)
|
||||
done_1h = sum(g(s, "done_1h") for s in stages)
|
||||
done_today = sum(g(s, "done_today") for s in stages)
|
||||
done_15m = sum(g(s, "done_15m") 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 슬롯 유무 무관)
|
||||
# 보류 백오프 = LLM 불가 신호 → LLM stage 소속인 macmini 카드 귀속
|
||||
deferred_pending = (
|
||||
g("summarize", "deferred_pending") + g("deep_summary", "deferred_pending")
|
||||
if key == "macbook" else 0
|
||||
if key == "macmini" else 0
|
||||
)
|
||||
|
||||
# state 판정 — 우선순위: 가동 > 보류 > 대기 (사용자 피드백 2026-06-11).
|
||||
# 일하고 있으면(처리 중 또는 최근 15분 완료) 백오프 잔여가 있어도 "가동" —
|
||||
# 보류 건수는 카드의 deferred_pending 라인이 따로 보여준다. "보류" 칩은
|
||||
# 실제로 일이 멈춰 있고 백오프만 쌓인 상태(sleep/불가 지속)에서만.
|
||||
# 실제로 일이 멈춰 있고 백오프만 쌓인 상태(LLM 허브 불가 지속)에서만.
|
||||
if processing > 0 or done_15m > 0:
|
||||
state = "active"
|
||||
elif key == "macbook" and deferred_pending > 0:
|
||||
elif deferred_pending > 0:
|
||||
state = "deferred"
|
||||
else:
|
||||
state = "idle"
|
||||
@@ -213,16 +175,6 @@ def build_summarize_eta(stage_stats: dict[str, dict]) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def build_summarize_by_machine(summarize_split: dict[str, dict]) -> dict:
|
||||
"""summarize 머신별 완료 실적 분담 (macmini vs macbook) — 보드 레인의
|
||||
오프로드 가시화용. rows_to_summarize_split 이 이미 만든 값을 응답 형태로
|
||||
투영(done_1h/done_today 만, done_15m 은 내부 state 판정 전용이라 제외)."""
|
||||
def m(key: str) -> dict:
|
||||
s = summarize_split.get(key, {})
|
||||
return {"done_1h": int(s.get("done_1h", 0)), "done_today": int(s.get("done_today", 0))}
|
||||
return {"macmini": m("macmini"), "macbook": m("macbook")}
|
||||
|
||||
|
||||
def build_trend(
|
||||
inflow_buckets: dict[str, int],
|
||||
done_buckets: dict[str, int],
|
||||
@@ -287,28 +239,23 @@ def build_totals(stage_stats: dict[str, dict]) -> dict:
|
||||
|
||||
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
|
||||
),
|
||||
"machines": build_machines(stage_stats, current_rows),
|
||||
"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쿼리) ────────────────────────────────────────────────────
|
||||
# ─── SQL 수집부 (총 4쿼리) ────────────────────────────────────────────────────
|
||||
|
||||
# 1) stage×status 집계 + 시간창 완료/유입 + 보류 (1방)
|
||||
_STAGE_STATS_SQL = """
|
||||
@@ -333,23 +280,7 @@ _STAGE_STATS_SQL = """
|
||||
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방)
|
||||
# 2/3) 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,
|
||||
@@ -371,7 +302,7 @@ _TREND_DONE_SQL = """
|
||||
GROUP BY 1
|
||||
"""
|
||||
|
||||
# 5) processing 행 + 표시용 제목 재료 (1방 — 머신별 2건 슬라이스는 판정부에서)
|
||||
# 4) processing 행 + 표시용 제목 재료 (1방 — 머신별 2건 슬라이스는 판정부에서)
|
||||
_CURRENT_SQL = """
|
||||
SELECT q.stage, q.document_id, d.title, d.original_filename, d.file_path
|
||||
FROM processing_queue q
|
||||
@@ -383,20 +314,13 @@ _CURRENT_SQL = """
|
||||
|
||||
|
||||
async def build_overview(session: AsyncSession) -> dict:
|
||||
"""5쿼리 수집 → compose_overview 판정 → 응답 dict."""
|
||||
"""4쿼리 수집 → 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()
|
||||
@@ -414,11 +338,9 @@ async def build_overview(session: AsyncSession) -> dict:
|
||||
|
||||
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([]).
|
||||
@@ -426,13 +348,13 @@ async def build_overview(session: AsyncSession) -> dict:
|
||||
return result
|
||||
|
||||
|
||||
# kind -> 처리 머신 (보드 머신 카드 귀속용). 미상 kind = gpu(오케스트레이션 호스트).
|
||||
# kind -> 처리 머신 (보드 머신 카드 귀속용). 미상 kind = nas(오케스트레이션 호스트).
|
||||
_BG_JOB_MACHINE = {
|
||||
"global_digest": "macmini",
|
||||
"morning_briefing": "macmini",
|
||||
"section_summary": "macmini",
|
||||
"hier_backfill": "gpu",
|
||||
"hier_redecompose": "gpu",
|
||||
"hier_backfill": "nas",
|
||||
"hier_redecompose": "nas",
|
||||
}
|
||||
|
||||
|
||||
@@ -466,7 +388,7 @@ async def _fetch_background_jobs(session: AsyncSession) -> list[dict]:
|
||||
"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"),
|
||||
"machine": _BG_JOB_MACHINE.get(r["kind"], "nas"),
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
@@ -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.call_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,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)
|
||||
@@ -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)",
|
||||
)
|
||||
@@ -17,6 +17,7 @@ snippet 생성:
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import re
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
@@ -33,8 +34,11 @@ logger = setup_logger("rerank")
|
||||
# 동시 rerank 호출 제한 (GPU saturation 방지)
|
||||
RERANK_SEMAPHORE = asyncio.Semaphore(2)
|
||||
|
||||
# rerank input 크기 제한 (latency / VRAM hard cap)
|
||||
MAX_RERANK_INPUT = 200
|
||||
# rerank input 크기 제한 (latency / VRAM hard cap).
|
||||
# 2노드 이관(2026-07-02): env MAX_RERANK_INPUT 로 조정 가능 — 맥미니 llama.cpp 리랭크는
|
||||
# 후보 수에 선형(NAS발 실측 50=0.60s / 100=0.95s / 200=1.89s)이라 NAS 배포는 50 권장.
|
||||
# 기본 200 = 현행(GPU TEI) 무회귀.
|
||||
MAX_RERANK_INPUT = int(os.getenv("MAX_RERANK_INPUT", "200"))
|
||||
MAX_CHUNKS_PER_DOC = 2
|
||||
|
||||
# Soft timeout (초)
|
||||
|
||||
@@ -76,10 +76,15 @@ class AxisFilter:
|
||||
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.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:
|
||||
@@ -104,6 +109,22 @@ def _axis_sql(alias: str, af: "AxisFilter | None", params: dict) -> str:
|
||||
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)
|
||||
|
||||
|
||||
@@ -121,7 +142,21 @@ def _license_sql(alias: str) -> str:
|
||||
술어 정의 = license_filter.restricted_exclude_sql 공유(digest/briefing/study 풀이와 단일 source).
|
||||
"""
|
||||
from services.search.license_filter import restricted_exclude_sql
|
||||
return " AND " + restricted_exclude_sql(alias)
|
||||
_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.
|
||||
|
||||
@@ -1,196 +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 사용 (Mac mini 26B endpoint — classifier/evidence 와 동일 gate 공유, 동시 race 방지)
|
||||
"""
|
||||
|
||||
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
|
||||
from .llm_gate import Priority, acquire_mlx_gate
|
||||
|
||||
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 acquire_mlx_gate(Priority.FOREGROUND):
|
||||
async with asyncio.timeout(LLM_TIMEOUT_MS / 1000):
|
||||
raw = await client.call_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)
|
||||
@@ -0,0 +1,284 @@
|
||||
"""concept_curriculum — 이론공부 홈 재료 (오늘의 개념 · 진도 · 회독 SR).
|
||||
|
||||
개념문서 = documents (user_tags = @library/{topic}/{과목}/... , 가스기사). is_read = 회독,
|
||||
md_content 의 ★ 개수 = 빈출 tier(★★★=3 / ★★=2 / else 1). 회독 SR = study_concept_progress
|
||||
+ sr_schedule(문제 SR 공용 산술). 읽기 전용 집계 + mark_read(회독+SR 입고)만 write. LLM 0.
|
||||
|
||||
문제풀이 표면 무접촉 — 여기서 읽는 study_question_progress 는 '문항 due 카운트'만(홈 표시용).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import func, or_, select, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from models.document_read import DocumentRead
|
||||
from models.study_concept_progress import StudyConceptProgress
|
||||
from models.study_question_progress import StudyQuestionProgress
|
||||
from models.study_topic import StudyTopic
|
||||
from services.study.concept_parser import parse_concept, resolve_related
|
||||
from services.study.sr_schedule import advance, first_due
|
||||
|
||||
# 개념 행 조회 — 태그로 개념문서 필터 + 회독 진행 LEFT JOIN. md_content 는 전송 안 하고
|
||||
# ★ 유무만 서버측 boolean 으로(홈이 자주 호출돼도 페이로드 최소).
|
||||
# is_read = document_reads(회독 정본, is_read 컬럼 아님) EXISTS. library unread 와 동일 기준.
|
||||
_CONCEPT_ROWS_SQL = text(
|
||||
"""
|
||||
SELECT d.id AS doc_id,
|
||||
d.title AS title,
|
||||
EXISTS (
|
||||
SELECT 1 FROM document_reads r
|
||||
WHERE r.document_id = d.id AND r.user_id = :uid
|
||||
) AS is_read,
|
||||
(d.md_content LIKE '%★★★%') AS f3,
|
||||
(d.md_content LIKE '%★★%') AS f2,
|
||||
split_part(replace(d.user_tags::text, '"', ''), '/', 3) AS subject,
|
||||
p.review_stage AS review_stage,
|
||||
p.due_at AS due_at,
|
||||
p.last_read_at AS last_read_at
|
||||
FROM documents d
|
||||
LEFT JOIN study_concept_progress p
|
||||
ON p.concept_doc_id = d.id AND p.user_id = :uid
|
||||
WHERE d.user_tags::text LIKE :like
|
||||
AND d.deleted_at IS NULL
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
async def _topic_name(session: AsyncSession, topic_id: int) -> str | None:
|
||||
return (
|
||||
await session.execute(select(StudyTopic.name).where(StudyTopic.id == topic_id))
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
async def _concept_rows(session: AsyncSession, user_id: int, topic_name: str):
|
||||
like = f"%@library/{topic_name}/%"
|
||||
return (
|
||||
await session.execute(_CONCEPT_ROWS_SQL, {"uid": user_id, "like": like})
|
||||
).mappings().all()
|
||||
|
||||
|
||||
def _freq(row) -> int:
|
||||
if row["f3"]:
|
||||
return 3
|
||||
if row["f2"]:
|
||||
return 2
|
||||
return 1
|
||||
|
||||
|
||||
def _is_due(row, now: datetime) -> bool:
|
||||
return (
|
||||
row["due_at"] is not None
|
||||
and row["due_at"] <= now
|
||||
and (row["review_stage"] or 0) < 4
|
||||
)
|
||||
|
||||
|
||||
def _item(row) -> dict:
|
||||
return {
|
||||
"doc_id": row["doc_id"],
|
||||
"title": row["title"],
|
||||
"subject": row["subject"],
|
||||
"freq": _freq(row),
|
||||
"review_stage": row["review_stage"],
|
||||
"due_at": row["due_at"],
|
||||
}
|
||||
|
||||
|
||||
async def _question_due_count(session: AsyncSession, user_id: int, topic_id: int, now: datetime) -> int:
|
||||
"""문항 복습 due (기존 study_question_progress 엔진 재사용, 홈 표시용)."""
|
||||
return (
|
||||
await session.execute(
|
||||
select(func.count())
|
||||
.select_from(StudyQuestionProgress)
|
||||
.where(
|
||||
StudyQuestionProgress.user_id == user_id,
|
||||
StudyQuestionProgress.study_topic_id == topic_id,
|
||||
StudyQuestionProgress.due_at.is_not(None),
|
||||
StudyQuestionProgress.due_at <= now,
|
||||
or_(
|
||||
StudyQuestionProgress.review_stage.is_(None),
|
||||
StudyQuestionProgress.review_stage < 4,
|
||||
),
|
||||
)
|
||||
)
|
||||
).scalar_one()
|
||||
|
||||
|
||||
async def curriculum(session: AsyncSession, user_id: int, topic_id: int) -> dict:
|
||||
"""과목별 회독 진도 + 개념/문항 복습 due 요약 (진도 대시보드)."""
|
||||
name = await _topic_name(session, topic_id)
|
||||
rows = await _concept_rows(session, user_id, name) if name else []
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
subj: dict[str, dict] = {}
|
||||
for r in rows:
|
||||
s = subj.setdefault(r["subject"], {"subject": r["subject"], "total": 0, "read": 0})
|
||||
s["total"] += 1
|
||||
if r["is_read"]:
|
||||
s["read"] += 1
|
||||
|
||||
total = len(rows)
|
||||
read = sum(1 for r in rows if r["is_read"])
|
||||
concept_due = sum(1 for r in rows if _is_due(r, now))
|
||||
question_due = await _question_due_count(session, user_id, topic_id, now)
|
||||
|
||||
return {
|
||||
"topic_id": topic_id,
|
||||
"topic_name": name,
|
||||
"subjects": sorted(subj.values(), key=lambda x: x["subject"]),
|
||||
"total": total,
|
||||
"read": read,
|
||||
"concept_due": concept_due,
|
||||
"question_due": question_due,
|
||||
}
|
||||
|
||||
|
||||
async def today_concepts(
|
||||
session: AsyncSession, user_id: int, topic_id: int, limit: int = 6
|
||||
) -> dict:
|
||||
"""오늘 공부할 개념 = 재복습(SR due) 먼저 → 미독(빈출 우선). 졸업/재복습대기 제외."""
|
||||
name = await _topic_name(session, topic_id)
|
||||
rows = await _concept_rows(session, user_id, name) if name else []
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
due = [r for r in rows if _is_due(r, now)]
|
||||
due.sort(key=lambda r: r["due_at"])
|
||||
|
||||
# 미독 & 아직 SR 큐 진입 전(due_at NULL) → 빈출 높은 순
|
||||
unread = [r for r in rows if not r["is_read"] and r["due_at"] is None]
|
||||
unread.sort(key=lambda r: (-_freq(r), r["subject"], r["title"]))
|
||||
|
||||
picked = [{**_item(r), "reason": "재복습"} for r in due]
|
||||
picked += [{**_item(r), "reason": "신규"} for r in unread]
|
||||
|
||||
return {
|
||||
"concepts": picked[:limit],
|
||||
"due_total": len(due),
|
||||
"unread_total": len(unread),
|
||||
}
|
||||
|
||||
|
||||
async def mark_read(
|
||||
session: AsyncSession, user_id: int, topic_id: int, doc_id: int, now: datetime | None = None
|
||||
) -> dict:
|
||||
"""개념 회독 처리 = document_reads(+1) + 회독 SR 입고/전진.
|
||||
|
||||
회독 정본 = document_reads(append-only), documents.is_read 컬럼 아님(library unread 와 정합).
|
||||
첫 회독 → first_due(stage 0, 내일). 이후 회독은 'due 도래(due_at<=now)' 때만 correct 로 전진
|
||||
(이른 재열람/다중클릭 과전진 방지). stage 4 졸업 후엔 due_at NULL 이라 전진 없음.
|
||||
"""
|
||||
now = now or datetime.now(timezone.utc)
|
||||
|
||||
# 회독 로그 append (+1) — 사용자 명시 회독. 자동 아님(엔드포인트 = 명시 POST).
|
||||
session.add(DocumentRead(user_id=user_id, document_id=doc_id, read_at=now))
|
||||
|
||||
prog = (
|
||||
await session.execute(
|
||||
select(StudyConceptProgress).where(
|
||||
StudyConceptProgress.user_id == user_id,
|
||||
StudyConceptProgress.concept_doc_id == doc_id,
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
|
||||
if prog is None:
|
||||
stage, due = first_due(now)
|
||||
prog = StudyConceptProgress(
|
||||
user_id=user_id,
|
||||
study_topic_id=topic_id,
|
||||
concept_doc_id=doc_id,
|
||||
review_stage=stage,
|
||||
due_at=due,
|
||||
last_read_at=now,
|
||||
)
|
||||
session.add(prog)
|
||||
else:
|
||||
# due 도래 시에만 전진 — 미래 due(재열람 이른 클릭)는 stage 불변, last_read_at 만 갱신.
|
||||
if prog.due_at is not None and prog.due_at <= now:
|
||||
res = advance(prog.review_stage, "correct", now)
|
||||
if res is not None:
|
||||
prog.review_stage, prog.due_at = res
|
||||
prog.last_read_at = now
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(prog)
|
||||
return {"ok": True, "review_stage": prog.review_stage, "due_at": prog.due_at}
|
||||
|
||||
|
||||
_CONCEPT_ONE_SQL = text(
|
||||
"""
|
||||
SELECT d.id AS doc_id, d.title AS title, d.md_content AS md_content,
|
||||
split_part(replace(d.user_tags::text, '"', ''), '/', 3) AS subject,
|
||||
(d.md_content LIKE '%★★★%') AS f3,
|
||||
(d.md_content LIKE '%★★%') AS f2,
|
||||
EXISTS (
|
||||
SELECT 1 FROM document_reads r
|
||||
WHERE r.document_id = d.id AND r.user_id = :uid
|
||||
) AS is_read,
|
||||
p.review_stage AS review_stage,
|
||||
p.due_at AS due_at
|
||||
FROM documents d
|
||||
LEFT JOIN study_concept_progress p ON p.concept_doc_id = d.id AND p.user_id = :uid
|
||||
WHERE d.id = :doc_id AND d.deleted_at IS NULL AND d.user_tags::text LIKE :like
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
async def concept_detail(
|
||||
session: AsyncSession, user_id: int, topic_id: int, doc_id: int
|
||||
) -> dict | None:
|
||||
"""개념 리더 재료 — md 구조 파싱 + 관련개념 백링크 해소 + 회독/SR 상태 + 같은 과목 이전/다음."""
|
||||
name = await _topic_name(session, topic_id)
|
||||
if not name:
|
||||
return None
|
||||
like = f"%@library/{name}/%"
|
||||
row = (
|
||||
await session.execute(
|
||||
_CONCEPT_ONE_SQL, {"uid": user_id, "doc_id": doc_id, "like": like}
|
||||
)
|
||||
).mappings().first()
|
||||
if row is None:
|
||||
return None
|
||||
|
||||
parsed = parse_concept(row["md_content"] or "")
|
||||
|
||||
# 백링크 해소 + 이전/다음 = 같은 토픽 개념 title 인덱스(회독 rows 재사용)
|
||||
idx = await _concept_rows(session, user_id, name)
|
||||
title_index = [(r["doc_id"], r["title"], r["subject"]) for r in idx]
|
||||
resolved = resolve_related(parsed["related"], title_index)
|
||||
|
||||
# 이전/다음 = 같은 과목, title 순
|
||||
same = sorted(
|
||||
[(r["doc_id"], r["title"]) for r in idx if r["subject"] == row["subject"]],
|
||||
key=lambda x: (x[1] or "", x[0]),
|
||||
)
|
||||
ids = [d for d, _ in same]
|
||||
prev_id = next_id = None
|
||||
if doc_id in ids:
|
||||
pos = ids.index(doc_id)
|
||||
if pos > 0:
|
||||
prev_id = ids[pos - 1]
|
||||
if pos < len(ids) - 1:
|
||||
next_id = ids[pos + 1]
|
||||
|
||||
freq = 3 if row["f3"] else (2 if row["f2"] else 1)
|
||||
|
||||
return {
|
||||
"doc_id": row["doc_id"],
|
||||
"db_title": row["title"],
|
||||
"title": parsed["title"] or row["title"],
|
||||
"subject": row["subject"],
|
||||
"freq": freq,
|
||||
"summary": parsed["summary"],
|
||||
"body": parsed["body"],
|
||||
"bincheol": parsed["bincheol"],
|
||||
"related": resolved,
|
||||
"is_read": row["is_read"],
|
||||
"review_stage": row["review_stage"],
|
||||
"due_at": row["due_at"],
|
||||
"prev_id": prev_id,
|
||||
"next_id": next_id,
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
"""concept_links — 이론↔문제 브리지 롤업 (Stage B).
|
||||
|
||||
study_concept_links(개념 doc ↔ 기출문항, 임베딩 코사인) + study_question_progress(내 풀이상태)를
|
||||
조인해 (a) 개념별 관련 기출 + 내 정답률(related_questions), (b) 개념 약점 지도(weakness_map) 산출.
|
||||
읽기 전용 집계 · LLM 0. 링크 적재는 scripts/concept_links_backfill.sql(임베딩) 배치.
|
||||
정답률 = 링크된 문항 중 progress.last_outcome 기준(attempted=풀이이력 보유, correct=최근정답).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
_ACCURACY_WEAK_PCT = 60 # 정답률 < 60% = 약점(attempted>0 일 때만)
|
||||
|
||||
_AGG_SQL = text(
|
||||
"""
|
||||
SELECT count(*) AS linked,
|
||||
count(pr.study_question_id) FILTER (WHERE pr.last_outcome IS NOT NULL) AS attempted,
|
||||
count(*) FILTER (WHERE pr.last_outcome = 'correct') AS correct
|
||||
FROM study_concept_links l
|
||||
LEFT JOIN study_question_progress pr
|
||||
ON pr.study_question_id = l.question_id AND pr.user_id = :uid
|
||||
WHERE l.concept_doc_id = :doc_id AND l.link_source = 'embedding'
|
||||
"""
|
||||
)
|
||||
|
||||
_QROWS_SQL = text(
|
||||
"""
|
||||
SELECT q.id AS id, q.subject AS subject, q.exam_round AS exam_round,
|
||||
q.exam_question_number AS qnum, l.score AS score,
|
||||
pr.last_outcome AS last_outcome, pr.review_stage AS review_stage
|
||||
FROM study_concept_links l
|
||||
JOIN study_questions q ON q.id = l.question_id AND q.deleted_at IS NULL AND q.is_active
|
||||
LEFT JOIN study_question_progress pr
|
||||
ON pr.study_question_id = q.id AND pr.user_id = :uid
|
||||
WHERE l.concept_doc_id = :doc_id AND l.link_source = 'embedding'
|
||||
ORDER BY l.score DESC
|
||||
LIMIT :limit
|
||||
"""
|
||||
)
|
||||
|
||||
_WEAKNESS_SQL = text(
|
||||
"""
|
||||
SELECT d.id AS doc_id, d.title AS title,
|
||||
split_part(replace(d.user_tags::text, '"', ''), '/', 3) AS subject,
|
||||
count(l.id) AS linked,
|
||||
count(pr.study_question_id) FILTER (WHERE pr.last_outcome IS NOT NULL) AS attempted,
|
||||
count(*) FILTER (WHERE pr.last_outcome = 'correct') AS correct
|
||||
FROM documents d
|
||||
JOIN study_concept_links l ON l.concept_doc_id = d.id AND l.link_source = 'embedding'
|
||||
LEFT JOIN study_question_progress pr
|
||||
ON pr.study_question_id = l.question_id AND pr.user_id = :uid
|
||||
WHERE d.user_tags::text LIKE :like AND d.deleted_at IS NULL
|
||||
GROUP BY d.id, d.title, subject
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
async def related_questions(
|
||||
session: AsyncSession, user_id: int, doc_id: int, limit: int = 20
|
||||
) -> dict:
|
||||
"""개념 doc 의 관련 기출 + 내 정답률(전체 링크 기준 집계 + 상위 N 표시용)."""
|
||||
agg = (
|
||||
await session.execute(_AGG_SQL, {"uid": user_id, "doc_id": doc_id})
|
||||
).mappings().first()
|
||||
rows = (
|
||||
await session.execute(
|
||||
_QROWS_SQL, {"uid": user_id, "doc_id": doc_id, "limit": limit}
|
||||
)
|
||||
).mappings().all()
|
||||
|
||||
linked = (agg["linked"] if agg else 0) or 0
|
||||
attempted = (agg["attempted"] if agg else 0) or 0
|
||||
correct = (agg["correct"] if agg else 0) or 0
|
||||
accuracy = round(100 * correct / attempted) if attempted else None
|
||||
|
||||
return {
|
||||
"linked": linked,
|
||||
"attempted": attempted,
|
||||
"correct": correct,
|
||||
"accuracy": accuracy,
|
||||
"questions": [
|
||||
{
|
||||
"id": r["id"],
|
||||
"subject": r["subject"],
|
||||
"exam_round": r["exam_round"],
|
||||
"qnum": r["qnum"],
|
||||
"score": round(r["score"], 3) if r["score"] is not None else None,
|
||||
"last_outcome": r["last_outcome"],
|
||||
"review_stage": r["review_stage"],
|
||||
}
|
||||
for r in rows
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
async def weakness_map(
|
||||
session: AsyncSession, user_id: int, topic_name: str, limit: int = 12
|
||||
) -> dict:
|
||||
"""개념 약점 지도 — 링크된 기출 정답률로 개념 채색. 약점(attempted>0·정답률<60%) 우선 정렬."""
|
||||
like = f"%@library/{topic_name}/%"
|
||||
rows = (
|
||||
await session.execute(_WEAKNESS_SQL, {"uid": user_id, "like": like})
|
||||
).mappings().all()
|
||||
|
||||
concepts = []
|
||||
for r in rows:
|
||||
attempted = r["attempted"] or 0
|
||||
correct = r["correct"] or 0
|
||||
accuracy = round(100 * correct / attempted) if attempted else None
|
||||
if accuracy is None:
|
||||
state = "unattempted"
|
||||
elif accuracy < _ACCURACY_WEAK_PCT:
|
||||
state = "weak"
|
||||
else:
|
||||
state = "ok"
|
||||
concepts.append(
|
||||
{
|
||||
"doc_id": r["doc_id"],
|
||||
"title": r["title"],
|
||||
"subject": r["subject"],
|
||||
"linked": r["linked"] or 0,
|
||||
"attempted": attempted,
|
||||
"accuracy": accuracy,
|
||||
"state": state,
|
||||
}
|
||||
)
|
||||
|
||||
# 약점 우선(정답률 오름차순) → 미평가는 뒤로. 홈 위젯용 상위 N.
|
||||
weak = sorted(
|
||||
[c for c in concepts if c["state"] == "weak"],
|
||||
key=lambda c: (c["accuracy"], -c["attempted"], c["doc_id"]),
|
||||
)
|
||||
return {
|
||||
"weak": weak[:limit],
|
||||
"weak_total": len(weak),
|
||||
"evaluated_total": sum(1 for c in concepts if c["state"] != "unattempted"),
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
"""concept_parser — 개념노트 markdown 구조 파서 + 관련개념 백링크 해소 (이론 리더용).
|
||||
|
||||
정찰 실측 불변식(273/273): 개념노트는 고정 골격을 100% 따름 —
|
||||
# {H1 제목} (첫 줄, DB title 과 다른 표시용 제목)
|
||||
> **한 줄 요약**: {요약} (blockquote, 라벨 고정)
|
||||
## {본문 라벨} ... (BODY, 자유 라벨 H2 0~N, 트레일 ★ 가능)
|
||||
## 빈출 포인트 (항상, 관련개념 직전)
|
||||
## 관련 개념 (항상, 문서 최종 섹션)
|
||||
|
||||
코드펜스(``` ASCII 도식) 내부의 ##/- 는 무시. 헤딩 트레일 ★ 는 스트립(라벨 정규화).
|
||||
'빈출 포인트'/'관련 개념' 앵커만 이름으로 잡고 나머지 BODY 는 순서·위치로 처리(라벨 화이트리스트 금지).
|
||||
순수 함수 · LLM 0.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
_FENCE = re.compile(r"^\s*```")
|
||||
_H1 = re.compile(r"^#\s+(.+?)\s*$")
|
||||
_H2 = re.compile(r"^##\s+(.+?)\s*$") # ### 는 매칭 안 됨(## 뒤 \s 요구)
|
||||
_SUMMARY = re.compile(r"^>\s*\*\*한 줄 요약\*\*:\s*(.+)$")
|
||||
_STAR_SUFFIX = re.compile(r"\s*★+\s*$")
|
||||
_TRAIL_STARS = re.compile(r"★+\s*$")
|
||||
_BINCHEOL_ITEM = re.compile(r"^\s*-\s+(★*)\s*(.+)$")
|
||||
_RELATED_ITEM = re.compile(r"^\s*-\s+(.+)$")
|
||||
_PAREN = re.compile(r"\s*\(.*$") # 괄호부터 끝(clarifier 힌트 절단)
|
||||
_NUM_PREFIX = re.compile(r"^\d+_")
|
||||
_STRIP_SYM = re.compile(r"[\s_·,./()\-]")
|
||||
|
||||
_ANCHOR_BINCHEOL = "빈출 포인트"
|
||||
_ANCHOR_RELATED = "관련 개념"
|
||||
|
||||
|
||||
def parse_concept(md: str) -> dict:
|
||||
"""개념노트 md → {title, summary, body[{label,stars,md}], bincheol[{tier,text}], related[{raw,phrase,hint}]}."""
|
||||
lines = (md or "").split("\n")
|
||||
title: str | None = None
|
||||
summary: str | None = None
|
||||
body: list[dict] = []
|
||||
bincheol_lines: list[str] = []
|
||||
related_lines: list[str] = []
|
||||
|
||||
in_fence = False
|
||||
zone = "pre" # pre | body | bincheol | related
|
||||
body_cur: dict | None = None
|
||||
|
||||
def emit(line: str) -> None:
|
||||
if body_cur is not None:
|
||||
body_cur["_lines"].append(line)
|
||||
elif zone == "bincheol":
|
||||
bincheol_lines.append(line)
|
||||
elif zone == "related":
|
||||
related_lines.append(line)
|
||||
# pre-zone 내용(요약 앞 잡음)은 버림
|
||||
|
||||
for ln in lines:
|
||||
if _FENCE.match(ln):
|
||||
in_fence = not in_fence
|
||||
emit(ln)
|
||||
continue
|
||||
if in_fence:
|
||||
emit(ln)
|
||||
continue
|
||||
|
||||
if title is None:
|
||||
m = _H1.match(ln)
|
||||
if m:
|
||||
title = m.group(1).strip()
|
||||
continue
|
||||
if summary is None:
|
||||
m = _SUMMARY.match(ln)
|
||||
if m:
|
||||
summary = m.group(1).strip()
|
||||
continue
|
||||
|
||||
m2 = _H2.match(ln)
|
||||
if m2:
|
||||
raw_label = m2.group(1).strip()
|
||||
star_m = _TRAIL_STARS.search(raw_label)
|
||||
stars = len(star_m.group(0).strip()) if star_m else 0
|
||||
label = _STAR_SUFFIX.sub("", raw_label).strip()
|
||||
if label == _ANCHOR_BINCHEOL:
|
||||
zone = "bincheol"
|
||||
body_cur = None
|
||||
continue
|
||||
if label == _ANCHOR_RELATED:
|
||||
zone = "related"
|
||||
body_cur = None
|
||||
continue
|
||||
body_cur = {"label": label, "stars": stars, "_lines": []}
|
||||
body.append(body_cur)
|
||||
zone = "body"
|
||||
continue
|
||||
|
||||
emit(ln)
|
||||
|
||||
body_out = []
|
||||
for s in body:
|
||||
text = "\n".join(s["_lines"]).strip()
|
||||
if text or s["label"]:
|
||||
body_out.append({"label": s["label"], "stars": s["stars"], "md": text})
|
||||
|
||||
bincheol = []
|
||||
for ln in bincheol_lines:
|
||||
m = _BINCHEOL_ITEM.match(ln)
|
||||
if m:
|
||||
bincheol.append({"tier": len(m.group(1)), "text": m.group(2).strip()})
|
||||
|
||||
related = []
|
||||
for ln in related_lines:
|
||||
m = _RELATED_ITEM.match(ln)
|
||||
if m:
|
||||
raw = m.group(1).strip()
|
||||
phrase = _PAREN.sub("", raw).strip()
|
||||
hint = raw[len(phrase):].strip() if len(raw) > len(phrase) else ""
|
||||
if phrase:
|
||||
related.append({"raw": raw, "phrase": phrase, "hint": hint})
|
||||
|
||||
return {
|
||||
"title": title,
|
||||
"summary": summary,
|
||||
"body": body_out,
|
||||
"bincheol": bincheol,
|
||||
"related": related,
|
||||
}
|
||||
|
||||
|
||||
def _normalize(s: str) -> str:
|
||||
"""해소용 정규화: NN_ 접두 제거 → 소문자 → 공백/기호 제거. 영문은 lowercase 유지."""
|
||||
s = _NUM_PREFIX.sub("", s or "")
|
||||
s = s.lower()
|
||||
s = _STRIP_SYM.sub("", s)
|
||||
return s
|
||||
|
||||
|
||||
def resolve_related(related: list[dict], title_index: list[tuple]) -> list[dict]:
|
||||
"""관련개념 구절 → 개념 doc 해소. title_index = [(doc_id, title, subject), ...].
|
||||
|
||||
다단 fallback(정찰 ~79%): 정규화 exact → 양방향 substring(≥2자 가드) → 미해소=dangling(doc_id None).
|
||||
"""
|
||||
norm_exact: dict[str, int] = {}
|
||||
norm_list: list[tuple[str, int, str]] = []
|
||||
for did, ttl, _subj in title_index:
|
||||
n = _normalize(ttl)
|
||||
if n:
|
||||
norm_exact.setdefault(n, did)
|
||||
norm_list.append((n, did, ttl))
|
||||
|
||||
out = []
|
||||
for it in related:
|
||||
pn = _normalize(it["phrase"])
|
||||
did: int | None = None
|
||||
rtitle: str | None = None
|
||||
if pn and len(pn) >= 2:
|
||||
if pn in norm_exact:
|
||||
did = norm_exact[pn]
|
||||
else:
|
||||
# substring 폴백: title-norm ⊆ phrase-norm 방향만(짧은 phrase 가 더 큰 title 을
|
||||
# 삼키는 오결선 방지, 예: '염산'→'염산나트륨' X) + 길이차 최소(가장 구체적) +
|
||||
# doc_id tiebreak(순서 무관 결정성). 후보 없으면 dangling(doc_id None).
|
||||
cands = [
|
||||
(abs(len(n) - len(pn)), cand, ttl)
|
||||
for n, cand, ttl in norm_list
|
||||
if len(n) >= 2 and n in pn
|
||||
]
|
||||
if cands:
|
||||
cands.sort(key=lambda c: (c[0], c[1]))
|
||||
_, did, rtitle = cands[0]
|
||||
if did is not None and rtitle is None:
|
||||
rtitle = next((t for d, t, _ in title_index if d == did), None)
|
||||
out.append(
|
||||
{"phrase": it["phrase"], "hint": it["hint"], "doc_id": did, "title": rtitle}
|
||||
)
|
||||
return out
|
||||
@@ -68,10 +68,10 @@ async def enqueue_question_publish(session: AsyncSession, q: Any) -> 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) -> int:
|
||||
async def backfill_publish_questions(session: AsyncSession, *, after_id: int = 0, limit: int = 200) -> tuple[int, int]:
|
||||
"""active(미삭제) 문항을 id>after_id 부터 bounded 로 outbox 적재.
|
||||
|
||||
반환 = enqueue 한 문항 수(0 이면 끝). 큰 셋은 마지막 id 로 페이지 반복. caller commit.
|
||||
반환 = (enqueue 수, 마지막 처리 id). caller 는 수==limit 면 last_id 로 다음 페이지. caller commit.
|
||||
"""
|
||||
rows = (
|
||||
await session.execute(
|
||||
@@ -83,7 +83,7 @@ async def backfill_publish_questions(session: AsyncSession, *, after_id: int = 0
|
||||
).scalars().all()
|
||||
for q in rows:
|
||||
await enqueue_question_publish(session, q)
|
||||
return len(rows)
|
||||
return len(rows), (rows[-1].id if rows else after_id)
|
||||
|
||||
|
||||
async def enqueue_topic_publish(session: AsyncSession, topic: Any) -> None:
|
||||
@@ -91,10 +91,10 @@ async def enqueue_topic_publish(session: AsyncSession, topic: Any) -> None:
|
||||
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) -> int:
|
||||
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 한 주제 수(0 이면 끝). 큰 셋은 마지막 id 로 페이지 반복. caller commit.
|
||||
반환 = (enqueue 수, 마지막 처리 id). caller 는 수==limit 면 last_id 로 다음 페이지. caller commit.
|
||||
멱등 = 발행 워커의 (payload_hash, deleted) 디둡이 no-op 재투영 흡수(중복 enqueue 무해).
|
||||
"""
|
||||
rows = (
|
||||
@@ -107,7 +107,7 @@ async def backfill_publish_topics(session: AsyncSession, *, after_id: int = 0, l
|
||||
).scalars().all()
|
||||
for t in rows:
|
||||
await enqueue_topic_publish(session, t)
|
||||
return len(rows)
|
||||
return len(rows), (rows[-1].id if rows else after_id)
|
||||
|
||||
|
||||
async def enqueue_card_publish(session: AsyncSession, card: Any) -> None:
|
||||
@@ -123,10 +123,10 @@ async def enqueue_card_publish(session: AsyncSession, card: Any) -> None:
|
||||
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) -> int:
|
||||
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 한 카드 수(0 이면 끝). 멱등 = 워커 (payload_hash, deleted) 디둡. caller commit.
|
||||
반환 = (enqueue 수, 마지막 처리 id). caller 는 수==limit 면 last_id 로 다음 페이지. 멱등 = 워커 디둡. caller commit.
|
||||
"""
|
||||
rows = (
|
||||
await session.execute(
|
||||
@@ -142,7 +142,7 @@ async def backfill_publish_cards(session: AsyncSession, *, after_id: int = 0, li
|
||||
).scalars().all()
|
||||
for c in rows:
|
||||
await enqueue_card_publish(session, c)
|
||||
return len(rows)
|
||||
return len(rows), (rows[-1].id if rows else after_id)
|
||||
|
||||
|
||||
async def enqueue_card_progress_publish(session: AsyncSession, progress: Any) -> None:
|
||||
@@ -155,11 +155,11 @@ async def enqueue_card_progress_publish(session: AsyncSession, progress: Any) ->
|
||||
)
|
||||
|
||||
|
||||
async def backfill_publish_card_progress(session: AsyncSession, *, after_id: int = 0, limit: int = 200) -> int:
|
||||
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 한 row 수(0 이면 끝). 멱등 = 워커 디둡. caller commit.
|
||||
반환 = (enqueue 수, 마지막 처리 id). caller 는 수==limit 면 last_id 로 다음 페이지. 멱등 = 워커 디둡. caller commit.
|
||||
"""
|
||||
rows = (
|
||||
await session.execute(
|
||||
@@ -171,4 +171,4 @@ async def backfill_publish_card_progress(session: AsyncSession, *, after_id: int
|
||||
).scalars().all()
|
||||
for p in rows:
|
||||
await enqueue_card_progress_publish(session, p)
|
||||
return len(rows)
|
||||
return len(rows), (rows[-1].id if rows else after_id)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -0,0 +1,414 @@
|
||||
"""summarize_units — 거대문서 요약 전용 분할(map-reduce 유닛) 순수함수 (presegment PR1).
|
||||
|
||||
plan ds-presegment-mapreduce-2 (2026-06-29 설계 합의 · PR0 실측 봉인):
|
||||
- CAP_TOKENS = 12,000 tok/unit — greedy-pack 상한 (PR0: giant 236건 실측 캘리브레이션)
|
||||
- TRIGGER_TOKENS = 25,000 tok — 이하는 단일콜 유지, 초과 시 map-reduce
|
||||
- 3-way over% 게이트 (단독 CAP 초과 섹션의 토큰 비중. 헤딩 개수는 무의미 — ASME 1,494개):
|
||||
over% == 0 → 'auto' (TIER1: 로컬 자동 분할, PR0 실측 78%)
|
||||
0 < over% <= 40 → 'hybrid' (패킹분 로컬 + 초과 섹션만 클로드, 8%)
|
||||
over% > 40 → 'whole' (TIER2: 클로드 전체 분할, 14%)
|
||||
- 토큰 추정 = PR0 실 Qwen 토크나이저 캘리브레이션: 한글 0.529 tok/char · 기타 0.217.
|
||||
구 휴리스틱(0.625/0.25)은 ~15% 과대라 폐기.
|
||||
|
||||
불변식:
|
||||
- 순수함수 — DB/네트워크/파일 접촉 0. 분할 = 요약 전용 아티팩트(문서 아님·검색/임베딩 미편입).
|
||||
- leaf 추출 = hier_decomp.builder 재사용, leaf_hard_max=∞ 로 window-split 억제
|
||||
(헤딩 leaf 만 — PR0 측정환경과 동일). 인접 섹션만 greedy-pack(순서 보존·중간 폐기 0
|
||||
— 구 deep_summary 의 head/mid/tail 가운데 폐기 버그를 커버리지로 대체).
|
||||
- 배선(deep_summary 분기·HOLD·클로드 알람)은 PR2/PR3 — 본 모듈은 계획만 산출.
|
||||
|
||||
호출: plan_summarize_units(md_text) -> UnitPlan
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
# 상대 import — 컨테이너(services.*)와 repo-root 테스트(app.services.*) 양쪽에서 동작.
|
||||
# (구 `from app.services...` 절대 import 는 컨테이너에 app 패키지가 없어 ModuleNotFoundError —
|
||||
# PR1 은 소비자 0 이라 잠복했던 버그, PR2 배선 시점에 수정.)
|
||||
from .hier_decomp.builder import HierNode, build_hier_tree
|
||||
|
||||
CAP_TOKENS = 12_000
|
||||
TRIGGER_TOKENS = 25_000
|
||||
HYBRID_MAX_OVER_PCT = 40.0
|
||||
|
||||
# PR0 실 Qwen tokenizer 캘리브레이션 (tok/char)
|
||||
KO_TOK_PER_CHAR = 0.529
|
||||
OTHER_TOK_PER_CHAR = 0.217
|
||||
|
||||
_HANGUL_RANGES = (
|
||||
(0xAC00, 0xD7A3), # 완성형 음절
|
||||
(0x1100, 0x11FF), # 자모
|
||||
(0x3130, 0x318F), # 호환 자모
|
||||
)
|
||||
|
||||
|
||||
def _is_hangul(ch: str) -> bool:
|
||||
cp = ord(ch)
|
||||
return any(lo <= cp <= hi for lo, hi in _HANGUL_RANGES)
|
||||
|
||||
|
||||
def estimate_tokens(text: str) -> int:
|
||||
"""PR0 캘리브레이션 기반 토큰 추정 (한글 0.529 · 기타 0.217 tok/char)."""
|
||||
if not text:
|
||||
return 0
|
||||
ko = sum(1 for ch in text if _is_hangul(ch))
|
||||
other = len(text) - ko
|
||||
return round(ko * KO_TOK_PER_CHAR + other * OTHER_TOK_PER_CHAR)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SummarizeUnit:
|
||||
"""map-reduce 1유닛 — 인접 leaf 섹션들의 greedy-pack (요약 전용, 문서 아님)."""
|
||||
index: int
|
||||
section_titles: list[str | None] = field(default_factory=list)
|
||||
text: str = ""
|
||||
est_tokens: int = 0
|
||||
over_cap: bool = False # 단독 섹션이 CAP 초과 (hybrid 시 클로드 대상)
|
||||
# PR3: 이 유닛을 구성한 leaf 의 서수(extract_leaves 순서) — export CLI 가
|
||||
# leaf_spans 와 결합해 유닛 (start,end) 스팬을 계산한다. 페이로드 미기록.
|
||||
leaf_indexes: list[int] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class UnitPlan:
|
||||
mode: str # 'single' | 'map_reduce'
|
||||
tier: str | None # map_reduce 시 'auto' | 'hybrid' | 'whole'
|
||||
total_est_tokens: int = 0
|
||||
over_pct: float = 0.0
|
||||
units: list[SummarizeUnit] = field(default_factory=list)
|
||||
|
||||
|
||||
def extract_leaves(md_text: str) -> list[HierNode]:
|
||||
"""헤딩 leaf 만 추출 — leaf_hard_max=∞ 로 window-split 억제 (PR0 측정환경 동일)."""
|
||||
nodes = build_hier_tree(
|
||||
md_text,
|
||||
leaf_target_max=sys.maxsize,
|
||||
leaf_hard_max=sys.maxsize,
|
||||
)
|
||||
return [n for n in nodes if n.is_leaf]
|
||||
|
||||
|
||||
def greedy_pack(leaves: list[HierNode], cap: int = CAP_TOKENS) -> list[SummarizeUnit]:
|
||||
"""인접 leaf 를 순서 보존하며 est_tokens<=cap 으로 pack. 단독 초과 leaf = 전용 유닛(over_cap)."""
|
||||
units: list[SummarizeUnit] = []
|
||||
cur_titles: list[str | None] = []
|
||||
cur_texts: list[str] = []
|
||||
cur_indexes: list[int] = []
|
||||
cur_tokens = 0
|
||||
|
||||
def _flush() -> None:
|
||||
nonlocal cur_titles, cur_texts, cur_indexes, cur_tokens
|
||||
if cur_texts:
|
||||
units.append(SummarizeUnit(
|
||||
index=len(units),
|
||||
section_titles=cur_titles,
|
||||
text="\n\n".join(cur_texts),
|
||||
est_tokens=cur_tokens,
|
||||
leaf_indexes=cur_indexes,
|
||||
))
|
||||
cur_titles, cur_texts, cur_indexes, cur_tokens = [], [], [], 0
|
||||
|
||||
for li, leaf in enumerate(leaves):
|
||||
t = estimate_tokens(leaf.text)
|
||||
if t > cap:
|
||||
_flush()
|
||||
units.append(SummarizeUnit(
|
||||
index=len(units),
|
||||
section_titles=[leaf.section_title],
|
||||
text=leaf.text,
|
||||
est_tokens=t,
|
||||
over_cap=True,
|
||||
leaf_indexes=[li],
|
||||
))
|
||||
continue
|
||||
if cur_tokens + t > cap:
|
||||
_flush()
|
||||
cur_titles.append(leaf.section_title)
|
||||
cur_texts.append(leaf.text)
|
||||
cur_indexes.append(li)
|
||||
cur_tokens += t
|
||||
_flush()
|
||||
return units
|
||||
|
||||
|
||||
def over_pct(leaves: list[HierNode], cap: int = CAP_TOKENS) -> float:
|
||||
"""단독 CAP 초과 섹션들의 토큰 비중(%) — 3-way 게이트 입력."""
|
||||
total = 0
|
||||
over = 0
|
||||
for leaf in leaves:
|
||||
t = estimate_tokens(leaf.text)
|
||||
total += t
|
||||
if t > cap:
|
||||
over += t
|
||||
if total == 0:
|
||||
return 0.0
|
||||
return over * 100.0 / total
|
||||
|
||||
|
||||
def gate(over: float) -> str:
|
||||
"""over% → tier. 0=auto / (0,40]=hybrid / >40=whole. 클로드 결과 재검증에도 재사용."""
|
||||
if over <= 0.0:
|
||||
return "auto"
|
||||
if over <= HYBRID_MAX_OVER_PCT:
|
||||
return "hybrid"
|
||||
return "whole"
|
||||
|
||||
|
||||
def plan_summarize_units(
|
||||
md_text: str, *,
|
||||
cap: int = CAP_TOKENS,
|
||||
trigger: int = TRIGGER_TOKENS,
|
||||
) -> UnitPlan:
|
||||
"""문서 → 요약 실행 계획. trigger 이하=single(현행 단일콜), 초과=map_reduce(tier+units)."""
|
||||
total = estimate_tokens(md_text)
|
||||
if total <= trigger:
|
||||
return UnitPlan(mode="single", tier=None, total_est_tokens=total)
|
||||
leaves = extract_leaves(md_text)
|
||||
pct = over_pct(leaves, cap)
|
||||
return UnitPlan(
|
||||
mode="map_reduce",
|
||||
tier=gate(pct),
|
||||
total_est_tokens=total,
|
||||
over_pct=round(pct, 2),
|
||||
units=greedy_pack(leaves, cap),
|
||||
)
|
||||
|
||||
|
||||
# ─── PR2 — map/reduce 프롬프트 조립 순수함수 (deep_summary_worker 가 소비) ───
|
||||
|
||||
def render_map_slice(unit: SummarizeUnit, total_units: int) -> str:
|
||||
"""map 콜의 {original_text_slices} 대체 — 유닛 위치·섹션 라벨 + 본문."""
|
||||
titles = " · ".join(t for t in unit.section_titles if t) or "(무제 구간)"
|
||||
return f"[유닛 {unit.index + 1}/{total_units} — 섹션: {titles}]\n{unit.text}"
|
||||
|
||||
|
||||
def _format_unit_summary(res: dict, total_units: int) -> str:
|
||||
"""map 결과 1건 → reduce 입력 블록. res 키 = index/titles/tldr/detail/inconsistencies."""
|
||||
titles = " · ".join(t for t in (res.get("titles") or []) if t) or "(무제 구간)"
|
||||
lines = [f"[유닛 {int(res.get('index', 0)) + 1}/{total_units} — 섹션: {titles}]"]
|
||||
if res.get("tldr"):
|
||||
lines.append(f"TLDR: {res['tldr']}")
|
||||
if res.get("detail"):
|
||||
lines.append(str(res["detail"]))
|
||||
for inc in res.get("inconsistencies") or []:
|
||||
if isinstance(inc, dict):
|
||||
lines.append(f"불일치({inc.get('kind', '')}): {inc.get('desc', '')}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def build_reduce_units_block(
|
||||
results: list[dict],
|
||||
budget_tokens: int,
|
||||
*,
|
||||
min_detail_chars: int = 200,
|
||||
) -> tuple[str, bool]:
|
||||
"""reduce 입력 블록 조립 — budget_tokens 이하 보장(캡 초과 0 검증 게이트의 reduce 측).
|
||||
|
||||
초과 시 detail 만 비례 절단(라벨·TLDR·불일치 보전, 원문 순서 유지). 반환 (block, truncated).
|
||||
"""
|
||||
total_units = len(results)
|
||||
work = [dict(r) for r in results]
|
||||
truncated = False
|
||||
for _ in range(4):
|
||||
block = "\n\n".join(_format_unit_summary(r, total_units) for r in work)
|
||||
est = estimate_tokens(block)
|
||||
if est <= budget_tokens:
|
||||
return block, truncated
|
||||
ratio = budget_tokens / est
|
||||
for r in work:
|
||||
detail = str(r.get("detail") or "")
|
||||
keep = max(min_detail_chars, int(len(detail) * ratio * 0.9))
|
||||
if len(detail) > keep:
|
||||
r["detail"] = detail[:keep] + "…(절단)"
|
||||
truncated = True
|
||||
# 최후 방어 — 비례 절단이 floor(min_detail_chars)에 막히면 문자 하드 컷(KO 최악 비율 가정)
|
||||
block = "\n\n".join(_format_unit_summary(r, total_units) for r in work)
|
||||
if estimate_tokens(block) > budget_tokens:
|
||||
block = block[: max(1, int(budget_tokens / KO_TOK_PER_CHAR))]
|
||||
truncated = True
|
||||
return block, truncated
|
||||
|
||||
|
||||
# ─── PR3 — 유인 분할(units_override) 경계 순수함수 (worker + attended CLI 공용) ───
|
||||
#
|
||||
# HOLD(hybrid/whole) 문서를 사람이(유인 클로드 세션) 분할한 경계
|
||||
# [(start, end, title)] 로 재개하는 경로. 오프셋 = 소스 텍스트의 Python 문자
|
||||
# (code point) 인덱스 — export 가 덤프한 파일과 apply/워커의 슬라이스가 같은
|
||||
# 기준을 공유해야 한다 (builder 의 char_start 는 UTF-16 단위라 여기서 미사용).
|
||||
|
||||
OVERRIDE_MIN_COVERAGE_PCT = 90.0 # apply 게이트 — 전체 본문의 90%+ 커버 필수
|
||||
OVERRIDE_GAP_WARN_CHARS = 1_000 # 이보다 큰 공백 구간은 경고로 노출
|
||||
|
||||
|
||||
@dataclass
|
||||
class OverrideCheck:
|
||||
"""validate_override_boundaries 결과 — ok=False 면 errors 에 사유."""
|
||||
ok: bool
|
||||
errors: list[str] = field(default_factory=list)
|
||||
warnings: list[str] = field(default_factory=list)
|
||||
coverage_pct: float = 0.0
|
||||
# 정규화된 (start, end, title) — units_from_boundaries 입력으로 그대로 사용
|
||||
boundaries: list[tuple[int, int, str | None]] = field(default_factory=list)
|
||||
unit_tokens: list[int] = field(default_factory=list)
|
||||
|
||||
|
||||
def choose_override_source(
|
||||
md_content: str | None, extracted_text: str | None
|
||||
) -> tuple[str, str]:
|
||||
"""units_override 오프셋 기준 텍스트 선택 — (source_name, text).
|
||||
|
||||
canonical markdown(md_content) 우선, 부재/공백 시 extracted_text 폴백.
|
||||
export CLI · apply CLI · 워커 재개가 반드시 같은 규칙을 공유해야
|
||||
(start,end) 오프셋이 일치한다. 선택 결과는 units_override.source 에 박제.
|
||||
"""
|
||||
if md_content and md_content.strip():
|
||||
return "md_content", md_content
|
||||
return "extracted_text", extracted_text or ""
|
||||
|
||||
|
||||
def _normalize_boundary(entry, idx: int, errors: list[str]) -> tuple[int, int, str | None] | None:
|
||||
"""boundaries 1건 정규화 — [start,end,title?] 배열 또는 {start,end,title} 객체.
|
||||
|
||||
export 템플릿의 초과 스팬은 "todo" 키를 달고 나온다 — 미해결(todo 잔존) 상태로
|
||||
apply 하면 여기서 에러 (사람이 CAP 이하 경계로 분할을 완성해야 통과).
|
||||
"""
|
||||
if isinstance(entry, dict):
|
||||
if entry.get("todo"):
|
||||
errors.append(
|
||||
f"유닛 {idx}: TODO 미해결 — 초과 스팬을 CAP 이하 경계들로 분할한 뒤 todo 키를 제거해야 함"
|
||||
)
|
||||
return None
|
||||
start, end, title = entry.get("start"), entry.get("end"), entry.get("title")
|
||||
elif isinstance(entry, (list, tuple)) and 2 <= len(entry) <= 3:
|
||||
start, end = entry[0], entry[1]
|
||||
title = entry[2] if len(entry) == 3 else None
|
||||
else:
|
||||
errors.append(f"유닛 {idx}: 형식 오류 — [start, end, title] 또는 {{start, end, title}} 여야 함")
|
||||
return None
|
||||
if (
|
||||
isinstance(start, bool) or isinstance(end, bool)
|
||||
or not isinstance(start, int) or not isinstance(end, int)
|
||||
):
|
||||
errors.append(f"유닛 {idx}: start/end 는 정수여야 함 (start={start!r}, end={end!r})")
|
||||
return None
|
||||
if title is not None and not isinstance(title, str):
|
||||
title = str(title)
|
||||
return start, end, title
|
||||
|
||||
|
||||
def validate_override_boundaries(
|
||||
text: str,
|
||||
raw_boundaries,
|
||||
*,
|
||||
cap: int = CAP_TOKENS,
|
||||
min_coverage_pct: float = OVERRIDE_MIN_COVERAGE_PCT,
|
||||
gap_warn_chars: int = OVERRIDE_GAP_WARN_CHARS,
|
||||
) -> OverrideCheck:
|
||||
"""units_override 경계 검증 — 단조증가·비중첩·본문 범위 내·커버리지·유닛별 캡.
|
||||
|
||||
apply CLI = 기본 게이트(cap=CAP_TOKENS, coverage>=90%).
|
||||
워커 방어 = cap 슬랙(CAP*1.1)·coverage 0 으로 완화 호출 — 잘못된 override 가
|
||||
900s 콜을 재생산하는 것만 차단하고, 품질 게이트는 apply 시점에 이미 통과했다고 본다.
|
||||
"""
|
||||
errors: list[str] = []
|
||||
warnings: list[str] = []
|
||||
|
||||
if not isinstance(raw_boundaries, (list, tuple)) or not raw_boundaries:
|
||||
return OverrideCheck(ok=False, errors=["boundaries 가 비어있거나 리스트가 아님"])
|
||||
|
||||
normalized: list[tuple[int, int, str | None]] = []
|
||||
for i, entry in enumerate(raw_boundaries):
|
||||
norm = _normalize_boundary(entry, i, errors)
|
||||
if norm is not None:
|
||||
normalized.append(norm)
|
||||
if errors:
|
||||
return OverrideCheck(ok=False, errors=errors, warnings=warnings)
|
||||
|
||||
n = len(text)
|
||||
prev_end = None
|
||||
unit_tokens: list[int] = []
|
||||
covered = 0
|
||||
for i, (start, end, title) in enumerate(normalized):
|
||||
label = f"유닛 {i}" + (f" ({title})" if title else "")
|
||||
if start < 0 or end > n:
|
||||
errors.append(f"{label}: 본문 범위 밖 — [{start}, {end}) vs len={n}")
|
||||
continue
|
||||
if start >= end:
|
||||
errors.append(f"{label}: start >= end ([{start}, {end}))")
|
||||
continue
|
||||
if prev_end is not None and start < prev_end:
|
||||
errors.append(f"{label}: 직전 유닛과 중첩/역순 — start={start} < 직전 end={prev_end}")
|
||||
if prev_end is not None and start - prev_end > gap_warn_chars:
|
||||
warnings.append(f"{label} 앞 공백 구간 {start - prev_end:,}자 ([{prev_end}, {start})) — 의도 확인")
|
||||
est = estimate_tokens(text[start:end])
|
||||
unit_tokens.append(est)
|
||||
if est > cap:
|
||||
errors.append(f"{label}: 추정 {est:,} tok > cap {cap:,} — 이 스팬을 더 분할해야 함")
|
||||
covered += end - start
|
||||
prev_end = max(prev_end or 0, end)
|
||||
|
||||
if not errors:
|
||||
head_gap = normalized[0][0]
|
||||
tail_gap = n - normalized[-1][1]
|
||||
if head_gap > gap_warn_chars:
|
||||
warnings.append(f"문서 선두 공백 구간 {head_gap:,}자 ([0, {normalized[0][0]})) — 의도 확인")
|
||||
if tail_gap > gap_warn_chars:
|
||||
warnings.append(f"문서 말미 공백 구간 {tail_gap:,}자 ([{normalized[-1][1]}, {n})) — 의도 확인")
|
||||
|
||||
coverage_pct = round(covered * 100.0 / n, 2) if n else 0.0
|
||||
if not errors and coverage_pct < min_coverage_pct:
|
||||
errors.append(
|
||||
f"커버리지 {coverage_pct}% < {min_coverage_pct}% — 경계가 본문 대부분을 덮어야 함"
|
||||
)
|
||||
|
||||
return OverrideCheck(
|
||||
ok=not errors,
|
||||
errors=errors,
|
||||
warnings=warnings,
|
||||
coverage_pct=coverage_pct,
|
||||
boundaries=normalized if not errors else [],
|
||||
unit_tokens=unit_tokens,
|
||||
)
|
||||
|
||||
|
||||
def units_from_boundaries(
|
||||
text: str, boundaries: list[tuple[int, int, str | None]]
|
||||
) -> list[SummarizeUnit]:
|
||||
"""정규화·검증 통과한 (start,end,title) 리스트 → SummarizeUnit 리스트.
|
||||
|
||||
유닛 index = 경계 서수 — boundaries 는 payload 에 박제되므로 attempt 간 안정
|
||||
(map_results 멱등 재개 키와 정합).
|
||||
"""
|
||||
units: list[SummarizeUnit] = []
|
||||
for i, (start, end, title) in enumerate(boundaries):
|
||||
seg = text[start:end]
|
||||
units.append(SummarizeUnit(
|
||||
index=i,
|
||||
section_titles=[title],
|
||||
text=seg,
|
||||
est_tokens=estimate_tokens(seg),
|
||||
))
|
||||
return units
|
||||
|
||||
|
||||
def leaf_spans(text: str, leaves: list[HierNode]) -> list[tuple[int, int]]:
|
||||
"""extract_leaves 결과 leaf 들의 원문 (start,end) 문자 스팬.
|
||||
|
||||
_segment 가 원문을 연속 파티션(빈 preamble 만 폐기)으로 자르므로, 커서 순차
|
||||
탐색이 항상 정확한 위치를 찾는다 (동일 본문 반복이 있어도 순서가 앞선 leaf 가
|
||||
앞 오프셋을 가져간다).
|
||||
"""
|
||||
spans: list[tuple[int, int]] = []
|
||||
cursor = 0
|
||||
for leaf in leaves:
|
||||
pos = text.find(leaf.text, cursor)
|
||||
if pos < 0:
|
||||
# 이론상 불가(연속 파티션) — 방어적으로 전체 재탐색
|
||||
pos = text.find(leaf.text)
|
||||
if pos < 0:
|
||||
raise ValueError(f"leaf 본문을 원문에서 찾지 못함 (title={leaf.section_title!r})")
|
||||
spans.append((pos, pos + len(leaf.text)))
|
||||
cursor = pos + len(leaf.text)
|
||||
return spans
|
||||
@@ -90,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)
|
||||
@@ -579,16 +580,7 @@ async def process(
|
||||
"reason": "classify pipeline",
|
||||
}
|
||||
|
||||
# ─── 2. Legacy 요약 (primary 또는 deep) ───
|
||||
try:
|
||||
summary = await client.summarize(doc.extracted_text[:50000], cfg=legacy_cfg)
|
||||
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
|
||||
raise
|
||||
doc.ai_summary = strip_thinking(summary)
|
||||
|
||||
# ─── 메타데이터 (legacy 완료) — 실제 처리 머신 귀속 (drain=qwen-macbook) ───
|
||||
# ─── 메타데이터 (classify 완료) — 실제 처리 머신 귀속 (drain=qwen-macbook) ───
|
||||
doc.ai_model_version = (legacy_cfg or settings.ai.primary).model
|
||||
doc.ai_processed_at = datetime.now(timezone.utc)
|
||||
|
||||
@@ -598,13 +590,27 @@ async def process(
|
||||
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, 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()
|
||||
@@ -774,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"}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -10,9 +10,11 @@ EscalationEnvelope + subject_domain 을 읽어, PR-A policy 템플릿 `p3c_deep_
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from pydantic import BaseModel, Field, ValidationError
|
||||
from sqlalchemy import desc, select
|
||||
@@ -27,12 +29,38 @@ from core.utils import setup_logger
|
||||
from models.document import Document
|
||||
from models.queue import ProcessingQueue, StageDeferred
|
||||
from policy.prompt_render import render_26b, policy_version as compute_policy_version
|
||||
from services.alerts import send_alert
|
||||
from services.document_telemetry import record_analyze_event
|
||||
from services.search.llm_gate import Priority, acquire_mlx_gate
|
||||
from services.summarize_units import (
|
||||
CAP_TOKENS,
|
||||
UnitPlan,
|
||||
build_reduce_units_block,
|
||||
choose_override_source,
|
||||
estimate_tokens,
|
||||
plan_summarize_units,
|
||||
render_map_slice,
|
||||
units_from_boundaries,
|
||||
validate_override_boundaries,
|
||||
)
|
||||
|
||||
logger = setup_logger("deep_summary_worker")
|
||||
|
||||
DEEP_SUMMARY_TASK = "p3c_deep_summary"
|
||||
# presegment PR2 (plan ds-presegment-mapreduce-2) — 거대문서 map-reduce
|
||||
REDUCE_TASK = "p3c_deep_summary_reduce"
|
||||
# HYBRID/TIER2(클로드 유인 분할 필요) HOLD 재확인 간격 — attempts 미소모(StageDeferred)라
|
||||
# 영구 failed 없음. PR3: HOLD 시 웹훅 알람 + units_override 주입 시 즉시 재개.
|
||||
HOLD_RETRY_MINUTES = int(os.getenv("DEEP_SUMMARY_HOLD_RETRY_MINUTES", "1440"))
|
||||
# HOLD 알람 dedupe — payload.presegment.alerted_at 이 이 일수 이내면 재발화 억제
|
||||
# (매 24h 재보류마다 재알람 방지). apply CLI 가 override 기록 시 alerted_at 을 지워
|
||||
# 다음 이벤트(예: override 거부)는 신선하게 발화된다.
|
||||
ALERT_DEDUPE_DAYS = 7
|
||||
# units_override 방어 캡 슬랙 — apply 게이트(CAP)보다 10% 여유. 초과 유닛은 실패
|
||||
# 대신 재-HOLD + 알람 (잘못된 override 가 900s 콜을 재생산하는 것 차단).
|
||||
OVERRIDE_CAP_SLACK = 1.1
|
||||
# reduce 프롬프트 오버헤드가 비정상적으로 커도 유닛 블록 예산은 이 밑으로 안 내려감(방어).
|
||||
REDUCE_BUDGET_FLOOR_TOKENS = 1_000
|
||||
|
||||
# inconsistencies kind 허용 목록 (feedback_document_server_domain_scope.md — 구매/계약 제외)
|
||||
ALLOWED_INCONSISTENCY_KINDS = {
|
||||
@@ -94,6 +122,38 @@ async def process(
|
||||
|
||||
envelope = EscalationEnvelope.from_json(json.dumps(envelope_raw))
|
||||
|
||||
# ─── presegment PR3 — units_override 재개 경로 (유인 분할 경계 주입) ───
|
||||
# apply CLI(scripts/presegment_attended.py) 가 payload.presegment.units_override 를
|
||||
# 기록한 문서는 tier 재판정·HOLD 없이 그 경계로 유닛을 구성해 기존 PR2
|
||||
# map-reduce 경로를 그대로 탄다. override 없는 문서는 아래 기존 경로와 바이트 동일.
|
||||
if (payload.get("presegment") or {}).get("units_override"):
|
||||
await _process_units_override(
|
||||
doc, queue_row, envelope, subject_domain, session,
|
||||
defer_on_deep_unavailable=defer_on_deep_unavailable,
|
||||
)
|
||||
return
|
||||
|
||||
# ─── presegment PR2 게이트 (plan ds-presegment-mapreduce-2) ───
|
||||
# TRIGGER(25K tok) 이하 = 아래 기존 단일콜 경로 그대로(무회귀). 초과 시 3-way:
|
||||
# auto(over%==0) → 로컬 map-reduce (유닛별 26B → reduce)
|
||||
# hybrid/whole → HOLD(awaiting_split) — 맥미니 미전송, 클로드 유인 분할은 PR3
|
||||
# 게이트/유닛은 전체 extracted_text 기준 — 단일콜의 head/mid/tail "가운데 폐기"를
|
||||
# 전 유닛 커버리지로 대체한다. build_hier_tree 가 거대 md 에서 초 단위 CPU 라
|
||||
# 이벤트루프 점유 회피 위해 to_thread (presegment_worker._read_toc 와 동일 패턴).
|
||||
unit_plan = await asyncio.to_thread(plan_summarize_units, doc.extracted_text or "")
|
||||
if unit_plan.mode == "map_reduce":
|
||||
# units 빈 auto 는 이론상 불가(비어있지 않은 텍스트 = leaf >= 1)지만, 빈 reduce
|
||||
# 단일콜(환각 위험)로 흐르지 않게 방어적으로 HOLD 로 보낸다.
|
||||
if unit_plan.tier != "auto" or not unit_plan.units:
|
||||
await _hold_awaiting_split(
|
||||
session, queue_row, unit_plan, document_id, doc_title=doc.title
|
||||
)
|
||||
await _process_map_reduce(
|
||||
doc, queue_row, envelope, subject_domain, unit_plan, session,
|
||||
defer_on_deep_unavailable=defer_on_deep_unavailable,
|
||||
)
|
||||
return
|
||||
|
||||
# 원문 슬라이스 추출 (envelope.original_pointers.text_ranges 기반)
|
||||
slices = _build_text_slices(doc.extracted_text or "", envelope.original_pointers)
|
||||
|
||||
@@ -214,6 +274,420 @@ async def process(
|
||||
)
|
||||
|
||||
|
||||
def _hold_alert_due(preseg: dict, now: datetime) -> bool:
|
||||
"""HOLD 알람 dedupe — alerted_at 이 없거나 ALERT_DEDUPE_DAYS 초과 시에만 발화."""
|
||||
ts = preseg.get("alerted_at")
|
||||
if not ts:
|
||||
return True
|
||||
try:
|
||||
prev = datetime.fromisoformat(str(ts))
|
||||
except ValueError:
|
||||
return True # 깨진 타임스탬프 = 기록 신뢰 불가 → 발화하고 재기록
|
||||
if prev.tzinfo is None:
|
||||
prev = prev.replace(tzinfo=timezone.utc)
|
||||
return (now - prev) >= timedelta(days=ALERT_DEDUPE_DAYS)
|
||||
|
||||
|
||||
async def _hold_awaiting_split(
|
||||
session: AsyncSession,
|
||||
queue_row: ProcessingQueue,
|
||||
plan: UnitPlan,
|
||||
document_id: int,
|
||||
doc_title: str | None = None,
|
||||
) -> None:
|
||||
"""HYBRID/TIER2 — 클로드 유인 분할 대기(HOLD). 맥미니 미전송, StageDeferred 보류.
|
||||
|
||||
payload.presegment.awaiting_split 마킹을 먼저 commit — StageDeferred 핸들러
|
||||
(queue_consumer)는 새 세션에서 행을 다시 읽어 deferred_until 만 병합하므로 유실 없음.
|
||||
PR3: 유인 전환 게이트 웹훅 알람 발화(alerted_at dedupe — 매 24h 재보류마다 재알람 방지).
|
||||
무인 자동 cloud 호출 금지 룰 준수(클로드 경로는 항상 유인 게이트).
|
||||
"""
|
||||
payload = dict(queue_row.payload or {})
|
||||
preseg = dict(payload.get("presegment") or {})
|
||||
now = datetime.now(timezone.utc)
|
||||
alert_due = _hold_alert_due(preseg, now)
|
||||
oversized = [
|
||||
(u.section_titles[0] if u.section_titles else None)
|
||||
for u in plan.units if u.over_cap
|
||||
][:20]
|
||||
preseg.update({
|
||||
"awaiting_split": True,
|
||||
"tier": plan.tier,
|
||||
"over_pct": plan.over_pct,
|
||||
"total_est_tokens": plan.total_est_tokens,
|
||||
"units": len(plan.units),
|
||||
# 클로드가 분할해야 할 초과 섹션 표본 (알람 본문 + export CLI 안내용)
|
||||
"oversized_sections": oversized,
|
||||
})
|
||||
if alert_due:
|
||||
preseg["alerted_at"] = now.isoformat()
|
||||
payload["presegment"] = preseg
|
||||
queue_row.payload = payload # 재할당 = JSONB 변경 감지
|
||||
await session.commit()
|
||||
logger.info(
|
||||
f"[deep] id={document_id} awaiting_split tier={plan.tier} over_pct={plan.over_pct} "
|
||||
f"total_est_tokens={plan.total_est_tokens} units={len(plan.units)} "
|
||||
f"→ HOLD ({HOLD_RETRY_MINUTES}분 후 재확인, alert={'발화' if alert_due else 'dedupe'})"
|
||||
)
|
||||
if alert_due:
|
||||
# commit 이후 발화 — 알람이 5s 행에 걸려도 payload 마킹은 이미 영속.
|
||||
resume_at = now + timedelta(minutes=HOLD_RETRY_MINUTES)
|
||||
top3 = ", ".join(t for t in oversized[:3] if t) or "(제목 없음)"
|
||||
await send_alert(
|
||||
f"[DS] deep_summary HOLD — doc {document_id} 유인 분할 필요",
|
||||
(
|
||||
f"문서: {doc_title or '(제목 없음)'} (id={document_id})\n"
|
||||
f"tier={plan.tier} / over%={plan.over_pct} / "
|
||||
f"total_est_tokens={plan.total_est_tokens:,} / units={len(plan.units)}\n"
|
||||
f"초과 섹션(상위 3): {top3}\n"
|
||||
f"재개 예정(UTC): {resume_at.isoformat(timespec='minutes')}\n"
|
||||
f"유인 분할: scripts/presegment_attended.py export --doc {document_id}"
|
||||
),
|
||||
)
|
||||
raise StageDeferred(
|
||||
f"awaiting_split:{plan.tier}", retry_after_minutes=HOLD_RETRY_MINUTES
|
||||
)
|
||||
|
||||
|
||||
async def _rehold_bad_override(
|
||||
session: AsyncSession, queue_row: ProcessingQueue, doc: Document, reason: str
|
||||
) -> None:
|
||||
"""잘못된 units_override — 실패 대신 재-HOLD + 알람 (900s 콜 재생산 차단).
|
||||
|
||||
units_override 는 payload 에 보존(사람이 원인 조사) + override_rejected 사유 기록.
|
||||
apply CLI 가 alerted_at 을 지워두므로 첫 거부는 즉시 발화되고, 이후 24h 재보류
|
||||
루프는 ALERT_DEDUPE_DAYS dedupe 로 억제된다.
|
||||
"""
|
||||
document_id = doc.id
|
||||
payload = dict(queue_row.payload or {})
|
||||
preseg = dict(payload.get("presegment") or {})
|
||||
now = datetime.now(timezone.utc)
|
||||
alert_due = _hold_alert_due(preseg, now)
|
||||
preseg.update({
|
||||
"awaiting_split": True,
|
||||
"override_rejected": reason,
|
||||
"override_rejected_at": now.isoformat(),
|
||||
})
|
||||
if alert_due:
|
||||
preseg["alerted_at"] = now.isoformat()
|
||||
payload["presegment"] = preseg
|
||||
queue_row.payload = payload # 재할당 = JSONB 변경 감지
|
||||
await session.commit()
|
||||
logger.warning(f"[deep] id={document_id} units_override 거부 → 재-HOLD: {reason}")
|
||||
if alert_due:
|
||||
resume_at = now + timedelta(minutes=HOLD_RETRY_MINUTES)
|
||||
await send_alert(
|
||||
f"[DS] deep_summary 유인 분할 경계 거부 — doc {document_id}",
|
||||
(
|
||||
f"문서: {doc.title or '(제목 없음)'} (id={document_id})\n"
|
||||
f"사유: {reason}\n"
|
||||
f"재개 예정(UTC): {resume_at.isoformat(timespec='minutes')}\n"
|
||||
f"수정: scripts/presegment_attended.py export --doc {document_id} "
|
||||
f"→ apply --doc {document_id} --boundaries FILE"
|
||||
),
|
||||
)
|
||||
raise StageDeferred(
|
||||
f"override_rejected:{reason[:80]}", retry_after_minutes=HOLD_RETRY_MINUTES
|
||||
)
|
||||
|
||||
|
||||
async def _process_units_override(
|
||||
doc: Document,
|
||||
queue_row: ProcessingQueue,
|
||||
envelope: EscalationEnvelope,
|
||||
subject_domain: str,
|
||||
session: AsyncSession,
|
||||
*,
|
||||
defer_on_deep_unavailable: bool,
|
||||
) -> None:
|
||||
"""PR3 — apply CLI 가 기록한 유인 분할 경계로 map-reduce 재개.
|
||||
|
||||
경계 = units_override.source 텍스트의 (start, end) 문자 오프셋. 방어 검증
|
||||
(source_len 일치·단조·비중첩·범위·유닛 캡*1.1) 실패 시 재-HOLD + 알람 —
|
||||
apply 이후 본문이 재생성됐거나 수기 주입이 깨진 경우 900s 콜로 흐르지 않는다.
|
||||
통과 시 기존 PR2 _process_map_reduce 를 그대로 탄다(맵 결과 유닛 단위 commit·
|
||||
reduce·ai_detail_summary 기록 — 유닛 index 는 payload 박제 경계 서수라 안정).
|
||||
"""
|
||||
document_id = doc.id
|
||||
preseg = dict((queue_row.payload or {}).get("presegment") or {})
|
||||
override = preseg.get("units_override")
|
||||
if isinstance(override, (list, tuple)):
|
||||
# 수기 주입 호환 — bare [(start,end,title)] 리스트도 허용
|
||||
override = {"boundaries": list(override)}
|
||||
if not isinstance(override, dict):
|
||||
await _rehold_bad_override(
|
||||
session, queue_row, doc, f"units_override 형식 오류 (type={type(override).__name__})"
|
||||
)
|
||||
|
||||
source = override.get("source")
|
||||
if source is None:
|
||||
source, text_src = choose_override_source(doc.md_content, doc.extracted_text)
|
||||
elif source in ("md_content", "extracted_text"):
|
||||
text_src = (doc.md_content if source == "md_content" else doc.extracted_text) or ""
|
||||
else:
|
||||
await _rehold_bad_override(
|
||||
session, queue_row, doc, f"units_override.source={source!r} 미지원"
|
||||
)
|
||||
|
||||
expected_len = override.get("source_len")
|
||||
if expected_len is not None and expected_len != len(text_src):
|
||||
await _rehold_bad_override(
|
||||
session, queue_row, doc,
|
||||
f"source_len 불일치 — override={expected_len:,} vs 현재 {source}={len(text_src):,}"
|
||||
" (본문 재생성됨 — export 부터 재실행)",
|
||||
)
|
||||
|
||||
check = validate_override_boundaries(
|
||||
text_src,
|
||||
override.get("boundaries") or [],
|
||||
cap=int(CAP_TOKENS * OVERRIDE_CAP_SLACK),
|
||||
min_coverage_pct=0.0, # 커버리지 품질 게이트는 apply CLI 가 이미 통과시킴
|
||||
)
|
||||
if not check.ok:
|
||||
await _rehold_bad_override(session, queue_row, doc, "; ".join(check.errors[:5]))
|
||||
|
||||
units = units_from_boundaries(text_src, check.boundaries)
|
||||
plan = UnitPlan(
|
||||
mode="map_reduce",
|
||||
tier="override",
|
||||
total_est_tokens=estimate_tokens(text_src),
|
||||
over_pct=float(preseg.get("over_pct") or 0.0),
|
||||
units=units,
|
||||
)
|
||||
logger.info(
|
||||
f"[deep] id={document_id} units_override 재개 — source={source} units={len(units)} "
|
||||
f"coverage={check.coverage_pct}% max_unit_tokens={max(check.unit_tokens, default=0)}"
|
||||
)
|
||||
await _process_map_reduce(
|
||||
doc, queue_row, envelope, subject_domain, plan, session,
|
||||
defer_on_deep_unavailable=defer_on_deep_unavailable,
|
||||
)
|
||||
|
||||
|
||||
async def _call_26b(
|
||||
client: AIClient, prompt: str, *, defer_on_deep_unavailable: bool, document_id: int
|
||||
):
|
||||
"""map/reduce 공용 26B 호출 — 단일콜 경로와 동일한 deep 슬롯 우선 + fair-share 폴백.
|
||||
|
||||
반환 (raw, used_cfg). 맥북(deep) 불가 시 consumer 경로는 맥미니 primary 로 즉시
|
||||
처리(동일 모델 — 강등 아님), drain 경로는 StageDeferred 전파(맥북 레버 시멘틱).
|
||||
"""
|
||||
deep_cfg = client.ai.deep
|
||||
if deep_cfg is not None:
|
||||
try:
|
||||
return await call_deep_or_defer(client, prompt), deep_cfg
|
||||
except StageDeferred:
|
||||
if defer_on_deep_unavailable:
|
||||
raise
|
||||
logger.info(f"[deep] id={document_id} 맥북 불가 → 맥미니 primary 처리 (fair-share)")
|
||||
async with acquire_mlx_gate(Priority.BACKGROUND):
|
||||
return await client.call_primary(prompt), settings.ai.primary
|
||||
|
||||
|
||||
def _parse_deep_output(raw: str) -> tuple[DeepSummaryOutput | None, str | None]:
|
||||
"""raw → DeepSummaryOutput. 단일콜 경로와 동일한 3단 파서. 실패 시 (None, parse_error)."""
|
||||
try:
|
||||
parsed = _parse_outermost_json(raw) or parse_json_response(raw)
|
||||
if not parsed:
|
||||
parsed = _regex_extract_fields(raw)
|
||||
return DeepSummaryOutput.model_validate(parsed or {}), None
|
||||
except (ValidationError, ValueError, TypeError) as exc:
|
||||
return None, f"parse:{type(exc).__name__}"
|
||||
|
||||
|
||||
async def _process_map_reduce(
|
||||
doc: Document,
|
||||
queue_row: ProcessingQueue,
|
||||
envelope: EscalationEnvelope,
|
||||
subject_domain: str,
|
||||
plan: UnitPlan,
|
||||
session: AsyncSession,
|
||||
*,
|
||||
defer_on_deep_unavailable: bool,
|
||||
) -> None:
|
||||
"""TIER1 자동 — 유닛별 map(26B) → reduce(26B) → 단일콜과 동일 필드 기록.
|
||||
|
||||
멱등 재개: 성공 유닛은 payload.presegment.map_results 에 즉시 commit —
|
||||
502/defer/재시작 후 재클레임 시 완료 유닛은 건너뛴다. 유닛 인덱스는
|
||||
plan_summarize_units 가 같은 extracted_text 에 결정적이라 attempt 간 안정.
|
||||
파싱 실패 유닛이 남으면 raise → queue_consumer 의 기존 attempts/백오프 재사용
|
||||
(실패 유닛만 재호출되므로 재시도 비용 = 잔여 유닛뿐).
|
||||
"""
|
||||
document_id = doc.id
|
||||
units = plan.units
|
||||
n = len(units)
|
||||
payload = dict(queue_row.payload or {})
|
||||
preseg = dict(payload.get("presegment") or {})
|
||||
preseg.pop("awaiting_split", None) # 재계획으로 auto 가 된 경우 HOLD 마킹 해제
|
||||
map_results: dict = dict(preseg.get("map_results") or {})
|
||||
|
||||
logger.info(
|
||||
f"[deep] id={document_id} map_reduce 시작 units={n} over_pct={plan.over_pct} "
|
||||
f"total_est_tokens={plan.total_est_tokens} resume={len(map_results)}/{n}"
|
||||
)
|
||||
|
||||
rendered = render_26b(DEEP_SUMMARY_TASK, subject_domain)
|
||||
envelope_injection = envelope.to_system_injection()
|
||||
|
||||
client = AIClient()
|
||||
start = time.perf_counter()
|
||||
used_cfg = client.ai.deep or settings.ai.primary
|
||||
failed_units: list[int] = []
|
||||
try:
|
||||
# ── map: 유닛별 26B (콜 사이마다 gate 를 놓아 짧은 인터랙티브 요청이 끼어든다) ──
|
||||
for unit in units:
|
||||
key = str(unit.index)
|
||||
if key in map_results:
|
||||
continue
|
||||
prompt = (
|
||||
rendered
|
||||
.replace("{escalation_envelope_json}", envelope_injection)
|
||||
.replace("{original_text_slices}", render_map_slice(unit, n))
|
||||
)
|
||||
# 검증 게이트 "모든 LLM 콜 캡 초과 0" 을 로그로 단정 가능하게 남긴다.
|
||||
logger.info(
|
||||
f"[deep] id={document_id} map {unit.index + 1}/{n} "
|
||||
f"unit_tokens={unit.est_tokens} prompt_est_tokens={estimate_tokens(prompt)} "
|
||||
f"cap={CAP_TOKENS}"
|
||||
)
|
||||
raw, used_cfg = await _call_26b(
|
||||
client, prompt,
|
||||
defer_on_deep_unavailable=defer_on_deep_unavailable,
|
||||
document_id=document_id,
|
||||
)
|
||||
out, perr = _parse_deep_output(raw)
|
||||
if out is None or not (out.detail or out.tldr):
|
||||
# 실패 유닛은 persist 하지 않음 — 재시도가 이 유닛만 다시 호출한다.
|
||||
failed_units.append(unit.index)
|
||||
logger.warning(
|
||||
f"[deep] id={document_id} map {unit.index + 1}/{n} 결과 비었음/파싱 실패"
|
||||
f"({perr}) — 유닛 재시도 대상"
|
||||
)
|
||||
continue
|
||||
# ★매 유닛 새 dict 로 재구성 (in-place 변경 금지) — 직전 commit 의 committed
|
||||
# 스냅샷이 같은 중첩 객체를 참조하면 old==new 로 보여 SQLAlchemy 가 UPDATE 를
|
||||
# 스킵한다(60254 라이브에서 unit 0 만 persist 된 aliasing 버그의 fix).
|
||||
map_results = {
|
||||
**map_results,
|
||||
key: {
|
||||
"index": unit.index,
|
||||
"titles": [t for t in unit.section_titles if t][:8],
|
||||
"tldr": out.tldr,
|
||||
"detail": out.detail,
|
||||
"inconsistencies": _filter_inconsistencies(out.inconsistencies or []),
|
||||
},
|
||||
}
|
||||
preseg = {
|
||||
**preseg,
|
||||
"tier": plan.tier,
|
||||
"over_pct": plan.over_pct,
|
||||
"total_est_tokens": plan.total_est_tokens,
|
||||
"units": n,
|
||||
"map_results": map_results,
|
||||
}
|
||||
payload = {**payload, "presegment": preseg}
|
||||
queue_row.payload = payload # 재할당 = JSONB 변경 감지
|
||||
await session.commit() # 유닛 단위 멱등 재개 지점
|
||||
|
||||
if failed_units:
|
||||
raise ValueError(
|
||||
f"map 유닛 {len(failed_units)}/{n}건 결과 없음 — 재시도 대상: {failed_units[:10]}"
|
||||
)
|
||||
|
||||
# ── reduce: 요약들의 요약 1콜 (유닛 블록도 캡 이하로 절단 보장) ──
|
||||
reduce_rendered = render_26b(REDUCE_TASK, subject_domain)
|
||||
base_prompt = (
|
||||
reduce_rendered
|
||||
.replace("{escalation_envelope_json}", envelope_injection)
|
||||
.replace("{unit_count}", str(n))
|
||||
)
|
||||
budget = max(
|
||||
REDUCE_BUDGET_FLOOR_TOKENS, CAP_TOKENS - estimate_tokens(base_prompt)
|
||||
)
|
||||
ordered = [map_results[str(u.index)] for u in units]
|
||||
block, reduce_truncated = build_reduce_units_block(ordered, budget)
|
||||
reduce_prompt = base_prompt.replace("{unit_summaries}", block)
|
||||
logger.info(
|
||||
f"[deep] id={document_id} reduce units={n} "
|
||||
f"prompt_est_tokens={estimate_tokens(reduce_prompt)} cap={CAP_TOKENS} "
|
||||
f"truncated={reduce_truncated}"
|
||||
)
|
||||
raw, used_cfg = await _call_26b(
|
||||
client, reduce_prompt,
|
||||
defer_on_deep_unavailable=defer_on_deep_unavailable,
|
||||
document_id=document_id,
|
||||
)
|
||||
except StageDeferred:
|
||||
logger.info(
|
||||
f"[deep] id={document_id} map_reduce 보류 — 완료 유닛 {len(map_results)}/{n} 보존"
|
||||
)
|
||||
raise
|
||||
except Exception as exc:
|
||||
# 단일콜 경로와 동일 — 호출 실패는 전파해 queue_consumer 가 재시도/dead-letter 처리.
|
||||
logger.warning(f"[deep] id={document_id} map_reduce 실패: {exc}")
|
||||
raise
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
latency_ms = int((time.perf_counter() - start) * 1000)
|
||||
deep_out, parse_error = _parse_deep_output(raw)
|
||||
if deep_out is None:
|
||||
# 단일콜 경로와 동일 시멘틱 — doc 미기록(legacy 결과 보존), 이벤트로 가시화.
|
||||
deep_out = DeepSummaryOutput()
|
||||
logger.warning(f"[deep] id={document_id} reduce 파싱 실패 ({parse_error}) — doc 미기록")
|
||||
|
||||
if not parse_error:
|
||||
doc.ai_detail_summary = (deep_out.detail or "").strip() or None
|
||||
# 불일치 = reduce 출력 + map 유닛 합본 dedup — reduce 가 떨궈도 유닛 발견분 보전.
|
||||
merged = _filter_inconsistencies(deep_out.inconsistencies or [])
|
||||
seen = {(i["kind"], i["desc"]) for i in merged}
|
||||
for res in ordered:
|
||||
for inc in res.get("inconsistencies") or []:
|
||||
k = (inc.get("kind"), inc.get("desc"))
|
||||
if k not in seen:
|
||||
seen.add(k)
|
||||
merged.append(inc)
|
||||
doc.ai_inconsistencies = merged
|
||||
doc.ai_analysis_tier = "deep"
|
||||
doc.ai_processed_at = datetime.now(timezone.utc)
|
||||
|
||||
try:
|
||||
pv = compute_policy_version(REDUCE_TASK)
|
||||
except Exception:
|
||||
pv = None
|
||||
|
||||
await record_analyze_event(
|
||||
doc_id=document_id,
|
||||
user_id=None,
|
||||
mode="summary_deep",
|
||||
text_limit=used_cfg.context_char_limit or 260000,
|
||||
truncated=reduce_truncated,
|
||||
layers_returned=["detail_summary", "inconsistencies"] if not parse_error else [],
|
||||
cached=False,
|
||||
latency_ms=latency_ms,
|
||||
model_name=used_cfg.model,
|
||||
prompt_version=(f"{REDUCE_TASK}@{pv}" if pv else REDUCE_TASK),
|
||||
error_code=parse_error,
|
||||
source="document_server",
|
||||
subject_domain=subject_domain,
|
||||
risk_flags=list(envelope.risk_flags),
|
||||
high_impact_task=None,
|
||||
escalation_reasons=list(envelope.escalation_reasons),
|
||||
confidence=deep_out.confidence,
|
||||
policy_version=pv,
|
||||
shadow_would_route_to="primary",
|
||||
tier="primary",
|
||||
escalated_to_26b=True,
|
||||
suppressed_reason=None,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"[deep] id={document_id} map_reduce 완료 units={n} "
|
||||
f"detail_len={len(doc.ai_detail_summary or '')} inc={len(doc.ai_inconsistencies or [])} "
|
||||
f"latency_ms={latency_ms} parse_error={parse_error}"
|
||||
)
|
||||
|
||||
|
||||
def _build_text_slices(text: str, pointers: dict) -> str:
|
||||
"""original_pointers.text_ranges 의 [{start, end}] 를 실제 본문 슬라이스로 합친다.
|
||||
|
||||
|
||||
@@ -110,6 +110,11 @@ def _get_pdf_page_count(
|
||||
|
||||
async def _call_ocr(file_path: Path, is_image: bool, max_pages: int = 200) -> str | None:
|
||||
"""OCR 서비스 호출 — 타임아웃 페이지 수 비례"""
|
||||
if not settings.ocr_enabled:
|
||||
# 2노드 이관(2026-07-02): GPU Surya 폐기 — 명시 비활성. None 반환 = 기존 soft-fail
|
||||
# 의미론(호출자가 ocr_attempted/skip_reason 메타 기록). 스캔 문서는 비전 배치 경로 별도.
|
||||
logger.warning("[ocr] OCR_ENABLED=false — skip (스캔·이미지 추출은 비전 배치 경로)")
|
||||
return None
|
||||
container_path = f"/documents/{file_path.relative_to(Path(settings.nas_mount_path))}"
|
||||
timeout = 60 if is_image else min(600, max(120, max_pages * 3))
|
||||
try:
|
||||
|
||||
+96
-82
@@ -251,104 +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():
|
||||
# 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))
|
||||
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
|
||||
|
||||
# 안전 자료실 A-2/B-4 — 타깃 폴더 기반 (material, jurisdiction, license)
|
||||
target_mt, target_jur, target_license = _TARGET_AXIS.get(
|
||||
Path(sub).name, (None, None, None)
|
||||
# ─── PKM 트랙 (기존 drive_sync) ─────────────────────────────────────────
|
||||
for sub, expected_category in targets:
|
||||
scan_root = pkm_root / sub
|
||||
if not scan_root.exists():
|
||||
continue
|
||||
|
||||
# 안전 자료실 A-2/B-4 — 타깃 폴더 기반 (material, jurisdiction, license)
|
||||
target_mt, target_jur, target_license = _TARGET_AXIS.get(
|
||||
Path(sub).name, (None, None, None)
|
||||
)
|
||||
|
||||
# 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
|
||||
|
||||
category, needs_conversion, next_stage = _route_media(
|
||||
file_path, expected_category
|
||||
)
|
||||
|
||||
# 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
|
||||
# audio/video 폴더에 엉뚱한 확장자가 들어왔거나 Inbox 에
|
||||
# audio/video 가 잘못 떨어진 경우 — 이 라운드에서 아예 skip
|
||||
if category is None and next_stage is None:
|
||||
continue
|
||||
|
||||
category, needs_conversion, next_stage = _route_media(
|
||||
file_path, expected_category
|
||||
)
|
||||
|
||||
# audio/video 폴더에 엉뚱한 확장자가 들어왔거나 Inbox 에
|
||||
# audio/video 가 잘못 떨어진 경우 — 이 라운드에서 아예 skip
|
||||
if category is None and next_stage is None:
|
||||
continue
|
||||
|
||||
rel_path = str(file_path.relative_to(nas_root))
|
||||
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).
|
||||
# 대역폭·버퍼 메모리 blowup 방지 (R5). 세션 밖에서 계산(커넥션 미점유).
|
||||
fhash = await asyncio.to_thread(file_hash, file_path)
|
||||
|
||||
result = await session.execute(
|
||||
select(Document).where(Document.file_path == rel_path)
|
||||
)
|
||||
existing = result.scalar_one_or_none()
|
||||
|
||||
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,
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(Document).where(Document.file_path == rel_path)
|
||||
)
|
||||
# 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()
|
||||
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
|
||||
# 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
|
||||
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}건 등록")
|
||||
|
||||
@@ -300,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])
|
||||
|
||||
@@ -497,12 +497,18 @@ async def process(document_id: int, session: AsyncSession) -> None:
|
||||
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:
|
||||
with fitz.open(str(source)) as pdf:
|
||||
page_count = pdf.page_count
|
||||
toc = pdf.get_toc(simple=True) or []
|
||||
page_count, toc = await asyncio.to_thread(_read_toc, str(source))
|
||||
except Exception as exc:
|
||||
# PDF 손상 등 — 분할 불가. 단일문서로 통과(extract 가 PyMuPDF/OCR 로 재시도하며 가시화).
|
||||
logger.warning(
|
||||
|
||||
@@ -42,6 +42,14 @@ async def process(document_id: int, session: AsyncSession) -> None:
|
||||
logger.warning(f"[stt] id={document_id} file_path 없음 — skip")
|
||||
return
|
||||
|
||||
if not settings.stt_enabled:
|
||||
# 2노드 이관(2026-07-02): GPU stt-service 폐기 — 명시 비활성. silent 금지:
|
||||
# 경고 로그 + extract_meta 터미널 기록 (재시도 안 함, 상태 가시).
|
||||
doc.extract_meta = {**(doc.extract_meta or {}), "stt_skip_reason": "disabled", "stt_terminal": True}
|
||||
await session.commit()
|
||||
logger.warning(f"[stt] id={document_id} STT_ENABLED=false — 터미널 skip (전사 없음)")
|
||||
return
|
||||
|
||||
# NAS 마운트 경로로 절대화 (services/stt 컨테이너도 동일 경로에 bind mount)
|
||||
container_path = str(Path(settings.nas_mount_path) / doc.file_path)
|
||||
|
||||
|
||||
@@ -28,6 +28,8 @@ 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:
|
||||
@@ -46,11 +48,15 @@ async def consume_publish_outbox() -> None:
|
||||
max_rev = int(
|
||||
(await session.execute(select(func.coalesce(func.max(Published.rev), 0)))).scalar() or 0
|
||||
)
|
||||
# 3) 미처리 outbox 를 커밋순(id)으로.
|
||||
# 3) 미처리 outbox 를 커밋순(id)으로. failed_at(terminal) 은 제외 — poison 행이
|
||||
# head-of-line 을 영구 점유하지 않게 함.
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(PublishOutbox)
|
||||
.where(PublishOutbox.processed_at.is_(None))
|
||||
.where(
|
||||
PublishOutbox.processed_at.is_(None),
|
||||
PublishOutbox.failed_at.is_(None),
|
||||
)
|
||||
.order_by(PublishOutbox.id.asc())
|
||||
.limit(BATCH_SIZE)
|
||||
)
|
||||
@@ -60,59 +66,86 @@ async def consume_publish_outbox() -> None:
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
published_count = 0
|
||||
failed_count = 0
|
||||
for ob in rows:
|
||||
existing = (
|
||||
await session.execute(
|
||||
select(Published).where(
|
||||
Published.kind == ob.kind,
|
||||
Published.source_id == ob.source_id,
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
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 안 올림.
|
||||
if (
|
||||
existing is not None
|
||||
and existing.payload_hash == ob.payload_hash
|
||||
and existing.deleted == ob.deleted
|
||||
):
|
||||
ob.processed_at = now
|
||||
# (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
|
||||
|
||||
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=max_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 = max_rev
|
||||
existing.updated_at = now
|
||||
|
||||
ob.processed_at = now
|
||||
# 배치 내 동일 (kind, source_id) 후속 행이 직전 반영을 보도록 flush(최신 승).
|
||||
await session.flush()
|
||||
published_count += 1
|
||||
# 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 max_rev=%s",
|
||||
"publish_outbox_drained scanned=%s published=%s failed=%s max_rev=%s",
|
||||
len(rows),
|
||||
published_count,
|
||||
failed_count,
|
||||
max_rev,
|
||||
)
|
||||
except Exception as e:
|
||||
|
||||
+8
-15
@@ -30,21 +30,11 @@ ai:
|
||||
repetition_penalty: 1.05 # 한국어 장문 반복/코드스위칭(CJK·라틴 누수) 억제 (보수적 시작값)
|
||||
top_k: 20 # Qwen3 권장
|
||||
|
||||
# deep: 야간 night-drain 전용 — 맥북 M5 Max Qwen3.6-27B-6bit (llm-router :8890 경유,
|
||||
# model=qwen-macbook alias). 2026-06-11 재도입 (사용자: 자기 전 night-drain 으로 백로그 분담).
|
||||
# 맥북 불가(503/연결/절단) = StageDeferred 보류 — 맥미니/cloud 강등 없음, attempts 미소모.
|
||||
# consumer 의 deep_summary 도 슬롯 존재 시 맥북 경유 (잠들어 있으면 30분 백오프 보류 = 무해).
|
||||
# 슬롯 제거 시 deep_summary 는 primary(맥미니) 경로 복귀.
|
||||
deep:
|
||||
endpoint: "http://100.76.254.116:8890/v1/chat/completions"
|
||||
model: "qwen-macbook"
|
||||
max_tokens: 8192
|
||||
timeout: 900
|
||||
context_char_limit: 260000
|
||||
temperature: 0.3
|
||||
top_p: 0.9
|
||||
repetition_penalty: 1.05 # 한국어 장문 반복/코드스위칭 억제 (보수적 시작값)
|
||||
top_k: 20
|
||||
# deep: ★2026-06-29 잠정 보류 (사용자 "맥북 night-drain 의미없어 → 맥미니 일원화").
|
||||
# 슬롯 제거 → deep_summary 가 primary(맥미니) 경로 복귀 + use_deep/drain 도 맥미니 폴백
|
||||
# (맥북 라우팅 0). drain-keeper(GPU cron)도 비활성. 맥북 mlx-vlm-server 는 OpenCode 로컬용 보존.
|
||||
# 복원(night-drain 재개 시): git history 에서 deep 슬롯(qwen-macbook :8890, max_tokens 8192,
|
||||
# timeout 900, context_char_limit 260000, temp 0.3 / top_p 0.9 / rep 1.05 / top_k 20) 부활 + drain-keeper 재활성.
|
||||
|
||||
# fallback: primary 장애 시 최후 방어선. Claude Sonnet 4 API (소액 한도, 자동 trigger).
|
||||
# 호출 빈도 낮음 가정 (Mac mini 가 거의 항상 up) → premium 과 budget 공유 OK.
|
||||
@@ -70,6 +60,9 @@ ai:
|
||||
rerank:
|
||||
endpoint: "http://reranker:80/rerank"
|
||||
model: "bge-reranker-v2-m3"
|
||||
# 2노드 이관: "tei"(GPU TEI /rerank, 기본) | "llamacpp"(맥미니 llama.cpp,
|
||||
# 예: endpoint http://100.76.254.116:8807/v1/rerank). 미지원 값 = 기동 시 ValueError.
|
||||
protocol: "tei"
|
||||
|
||||
# Phase 3.5a answerability classifier. 2026-05-14 GPU LLM 제거 후 Mac mini 26B 로 swap.
|
||||
# classifier_service 가 hasattr 체크로 optional 이므로 이 섹션 제거 시 classifier gate 는 자동 skip (score-only).
|
||||
|
||||
+7
-1
@@ -3,7 +3,13 @@ services:
|
||||
image: pgvector/pgvector:pg16
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
- ./migrations:/docker-entrypoint-initdb.d
|
||||
# ★ 2026-06-29 fresh-DB/DR 부팅 fix: initdb.d 마운트 제거(기존 `./migrations:/docker-entrypoint-initdb.d`).
|
||||
# 빈 볼륨 첫 기동 시 postgres 엔트리포인트가 migrations/*.sql(001~) 을 psql autocommit 으로 실행해
|
||||
# 스키마는 만들되 schema_migrations 스탬프는 안 남김(runner 만 생성) → fastapi init_db 가 documents
|
||||
# 존재로 'fresh' 를 오판해 baseline(_load_baseline_if_fresh) 로드를 건너뛰고, 빈 schema_migrations
|
||||
# 로 001 부터 재replay → `CREATE TABLE users`(IF NOT EXISTS 없음) 충돌 → 부팅 크래시(DR/신규환경).
|
||||
# fresh-boot 은 init_db 의 baseline 적재 + migration runner 단일 경로로 일원화(설계 의도). 기존 prod
|
||||
# 볼륨은 비어있지 않아 init scripts 가 애초에 미발동 → 무영향.
|
||||
environment:
|
||||
POSTGRES_DB: pkm
|
||||
POSTGRES_USER: pkm
|
||||
|
||||
@@ -1094,7 +1094,7 @@ services:
|
||||
image: pgvector/pgvector:pg16
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
- ./migrations:/docker-entrypoint-initdb.d
|
||||
# initdb.d 마운트 제거(2026-06-29): fresh-boot 은 fastapi init_db+baseline 단일 경로.
|
||||
environment:
|
||||
POSTGRES_DB: pkm
|
||||
POSTGRES_USER: pkm
|
||||
|
||||
@@ -71,7 +71,7 @@ GPU 서버의 NFS mount (`/proc/mounts` 실측):
|
||||
|
||||
| 컨테이너 | 마운트 | 모드 | 비고 |
|
||||
|---|---|---|---|
|
||||
| postgres | `pgdata:/var/lib/postgresql/data` + `./migrations:/docker-entrypoint-initdb.d` | rw | DB 본체 named volume |
|
||||
| postgres | `pgdata:/var/lib/postgresql/data` | rw | DB 본체 named volume (initdb.d 마운트는 2026-06-29 제거 — 아래 관찰) |
|
||||
| kordoc-service | `${NAS}/Document_Server:/documents` | **ro** | PDF/HWP parse |
|
||||
| ocr-service | `${NAS}/Document_Server:/documents` + `ocr_models:/root/.cache` | **ro** + rw | |
|
||||
| marker-service | `${NAS}/Document_Server:/documents` + `marker_models:/models` | **ro** + rw | PDF→markdown |
|
||||
@@ -84,7 +84,7 @@ GPU 서버의 NFS mount (`/proc/mounts` 실측):
|
||||
**관찰**:
|
||||
- worker 컨테이너 (kordoc/ocr/marker/stt) 는 모두 NAS **read-only** 마운트 → 원본 안전.
|
||||
- fastapi 만 NAS **rw** → 업로드/preview/extracted_images 쓰기 단일 책임.
|
||||
- `./migrations` 이 postgres 의 `docker-entrypoint-initdb.d` 와 fastapi 의 `/app/migrations` 양쪽에 마운트. 단 실제 migration runner 는 fastapi `init_db()` 만 사용 (postgres init scripts 는 첫 생성 시만 실행 → 효과 X, 안전).
|
||||
- `./migrations` 은 fastapi 의 `/app/migrations` 에만 마운트. migration runner 는 fastapi `init_db()` 단일 경로. (~2026-06-29: postgres `docker-entrypoint-initdb.d` 마운트 제거. 기존엔 "첫 생성 시만 실행 → 효과 X" 로 봤으나, 빈 볼륨 첫 기동 시 postgres 가 migrations/*.sql 을 실제 실행해 스키마는 만들되 schema_migrations 스탬프를 안 남겨 → init_db 의 baseline fresh 판정을 깨고 부팅 크래시 유발. fresh-DB/DR 부팅을 init_db+baseline 단일 경로로 일원화.)
|
||||
|
||||
## 정책 정리
|
||||
|
||||
|
||||
@@ -1,160 +0,0 @@
|
||||
<!--
|
||||
AskAnswerCard.svelte — 검색 결과 페이지 상단 AI 답변 카드 (컴팩트).
|
||||
|
||||
/ask 페이지의 AskAnswer.svelte와 달리, 검색 결과를 가리지 않는
|
||||
보조 영역으로 설계. 출처 목록 클릭이 must-have, 본문 [n] 클릭은 nice-to-have.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import Badge from '$lib/components/ui/Badge.svelte';
|
||||
import Skeleton from '$lib/components/ui/Skeleton.svelte';
|
||||
import { Sparkles, X, FileText } from 'lucide-svelte';
|
||||
import type { AskResponse, Confidence } from '$lib/types/ask';
|
||||
|
||||
interface Props {
|
||||
data: AskResponse | null;
|
||||
loading: boolean;
|
||||
error: boolean;
|
||||
onCitationClick: (docId: number) => void;
|
||||
onDismiss: () => void;
|
||||
}
|
||||
|
||||
let { data, loading, error, onCitationClick, onDismiss }: Props = $props();
|
||||
|
||||
// [n] 파싱 (AskAnswer.svelte에서 가져옴)
|
||||
type Token =
|
||||
| { type: 'text'; value: string }
|
||||
| { type: 'cite'; n: number; raw: string };
|
||||
|
||||
function splitAnswer(text: string): Token[] {
|
||||
return text
|
||||
.split(/(\[\d+\])/g)
|
||||
.filter(Boolean)
|
||||
.map((tok): Token => {
|
||||
const m = tok.match(/^\[(\d+)\]$/);
|
||||
return m
|
||||
? { type: 'cite', n: Number(m[1]), raw: tok }
|
||||
: { type: 'text', value: tok };
|
||||
});
|
||||
}
|
||||
|
||||
function confidenceTone(c: Confidence | null): 'success' | 'warning' | 'error' | 'neutral' {
|
||||
if (c === 'high') return 'success';
|
||||
if (c === 'medium') return 'warning';
|
||||
if (c === 'low') return 'error';
|
||||
return 'neutral';
|
||||
}
|
||||
|
||||
function confidenceLabel(c: Confidence | null): string {
|
||||
if (c === 'high') return '높음';
|
||||
if (c === 'medium') return '중간';
|
||||
if (c === 'low') return '낮음';
|
||||
return '';
|
||||
}
|
||||
|
||||
let tokens = $derived(data?.ai_answer ? splitAnswer(data.ai_answer) : []);
|
||||
|
||||
// 출처 중복 제거 (같은 doc_id)
|
||||
let uniqueCitations = $derived.by(() => {
|
||||
if (!data?.citations?.length) return [];
|
||||
const seen = new Set<number>();
|
||||
return data.citations.filter((c) => {
|
||||
if (seen.has(c.doc_id)) return false;
|
||||
seen.add(c.doc_id);
|
||||
return true;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="bg-surface border border-default rounded-card p-4">
|
||||
<!-- 헤더 -->
|
||||
<div class="flex items-center justify-between gap-2 mb-2">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Sparkles size={12} class="text-accent" />
|
||||
<span class="text-[10px] font-semibold tracking-wider uppercase text-dim">
|
||||
내 자료 기준 답변
|
||||
</span>
|
||||
{#if data?.confidence}
|
||||
<Badge tone={confidenceTone(data.confidence)} size="sm">
|
||||
신뢰도 {confidenceLabel(data.confidence)}
|
||||
</Badge>
|
||||
{/if}
|
||||
{#if data?.completeness === 'partial'}
|
||||
<Badge tone="warning" size="sm">일부 답변</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={onDismiss}
|
||||
class="p-0.5 rounded text-dim hover:text-text transition-colors"
|
||||
aria-label="답변 카드 접기"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 본문 -->
|
||||
{#if loading}
|
||||
<div class="space-y-2">
|
||||
<Skeleton w="w-full" h="h-3" />
|
||||
<Skeleton w="w-4/5" h="h-3" />
|
||||
</div>
|
||||
<p class="mt-3 text-[10px] text-dim flex items-center gap-1.5">
|
||||
<span class="inline-block w-2.5 h-2.5 rounded-full border-2 border-dim border-t-accent animate-spin"></span>
|
||||
근거 기반 답변 생성 중…
|
||||
</p>
|
||||
{:else if error}
|
||||
<p class="text-xs text-dim">답변을 가져오지 못했습니다.</p>
|
||||
{:else if data?.ai_answer}
|
||||
<!-- 답변 텍스트 -->
|
||||
<div class="text-sm leading-6 text-text">
|
||||
{#each tokens as tok}
|
||||
{#if tok.type === 'cite'}
|
||||
{@const citation = data?.citations?.find((c) => c.n === tok.n)}
|
||||
{#if citation}
|
||||
<button
|
||||
type="button"
|
||||
class="inline text-accent font-semibold hover:underline px-0.5"
|
||||
onclick={() => onCitationClick(citation.doc_id)}
|
||||
title={citation.title || `문서 #${citation.doc_id}`}
|
||||
>
|
||||
{tok.raw}
|
||||
</button>
|
||||
{:else}
|
||||
<span class="text-dim">{tok.raw}</span>
|
||||
{/if}
|
||||
{:else}
|
||||
<span>{tok.value}</span>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- partial: 누락 측면 -->
|
||||
{#if data.completeness === 'partial' && data.missing_aspects?.length}
|
||||
<p class="mt-2 text-[10px] text-dim">
|
||||
다루지 못한 부분: {data.missing_aspects.join(', ')}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<!-- 출처 목록 (must-have) -->
|
||||
{#if uniqueCitations.length > 0}
|
||||
<div class="mt-3 pt-2 border-t border-default">
|
||||
<p class="text-[10px] font-medium text-dim mb-1.5">출처</p>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
{#each uniqueCitations as citation}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => onCitationClick(citation.doc_id)}
|
||||
class="inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded bg-surface text-text border border-default hover:border-accent hover:text-accent transition-colors"
|
||||
title={citation.span_text}
|
||||
>
|
||||
<FileText size={10} />
|
||||
<span class="max-w-[200px] truncate">
|
||||
{citation.title || `문서 #${citation.doc_id}`}
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
// 처리 머신 보드 v3 — 통합안 (plan ds-board-merged: C2 머신레인 + C3 번다운/정직ETA).
|
||||
// · 머신 3레인(GPU/맥미니/맥북) = "누가 일하나" + 요약 오프로드(맥북 합류) 가시화
|
||||
// 처리 머신 보드 v4 — 2026-07-02 컷오버 후 2노드 (나스+맥미니).
|
||||
// · 머신 2레인(나스/맥미니) = "누가 일하나" — 나스=DS 본체 Docker(추출/마크다운/
|
||||
// 청크·임베딩 등), 맥미니=단일 생성 LLM 허브(분류/요약/심층분석 + bge-m3/리랭크)
|
||||
// · 지배 백로그 번다운 패널 = "언제 끝나나" + 유입 차감한 정직 ETA(summarize_eta)
|
||||
// · 신선도 '갱신 N초 전' + stale 경고 / 실패 드로어·상세 패널은 v2 자산 재사용.
|
||||
// 데이터 = GET /api/queue/overview (60s 폴링 store) + GET /api/queue/failed (드로어).
|
||||
@@ -193,7 +194,7 @@
|
||||
const machineByKey = $derived(
|
||||
new Map<FlowMachine, MachineOverview>(overview.machines.map((m) => [m.key as FlowMachine, m])),
|
||||
);
|
||||
const LANE_ORDER: FlowMachine[] = ['gpu', 'macmini', 'macbook'];
|
||||
const LANE_ORDER: FlowMachine[] = ['nas', 'macmini'];
|
||||
const lanes = $derived(
|
||||
LANE_ORDER.map((key) => ({
|
||||
key,
|
||||
@@ -203,13 +204,6 @@
|
||||
})),
|
||||
);
|
||||
|
||||
// 요약 오프로드 분담 — 맥미니 vs 맥북 (A-1 summarize_by_machine)
|
||||
const split = $derived(overview.summarize_by_machine);
|
||||
const splitTotal1h = $derived(Math.max(1, split.macmini.done_1h + split.macbook.done_1h));
|
||||
const macbookSharePct = $derived(Math.round((split.macbook.done_1h / splitTotal1h) * 100));
|
||||
// 맥북이 요약을 실제로 가져가는 중인가 (합류 표식 게이트)
|
||||
const offloadActive = $derived(split.macbook.done_1h > 0);
|
||||
|
||||
// ─── 백그라운드 작업 (큐 밖 스크립트 backfill) — processing_queue 사각지대 노출 ───
|
||||
const bgJobs = $derived(overview.background_jobs ?? []);
|
||||
const runningBg = $derived(bgJobs.filter((j) => j.state === 'running'));
|
||||
@@ -266,7 +260,7 @@
|
||||
: `갱신 ${Math.round(ageSec / 60)}분 전`,
|
||||
);
|
||||
|
||||
// ─── 24h 번다운 (C3) — 요약 유입 vs 소화 + 맥북 합류 변곡점 마커 ───
|
||||
// ─── 24h 번다운 (C3) — 요약 유입 vs 소화 ───
|
||||
const burn = $derived.by(() => {
|
||||
const t = overview.trend_24h;
|
||||
if (!t || t.length === 0) return null;
|
||||
@@ -279,20 +273,12 @@
|
||||
t.map((b, i) => `${(i * step).toFixed(1)},${y(sel(b))}`).join(' ');
|
||||
const doneLine = line((b) => b.done);
|
||||
const area = `0,${h} ${doneLine} ${w.toFixed(1)},${h}`;
|
||||
// 합류 변곡점 = done 최대 버킷 (맥북 야간 drain 합류 추정)
|
||||
let mi = 0;
|
||||
t.forEach((b, i) => {
|
||||
if (b.done > t[mi].done) mi = i;
|
||||
});
|
||||
return {
|
||||
w,
|
||||
h,
|
||||
area,
|
||||
doneLine,
|
||||
inflowLine: line((b) => b.inflow),
|
||||
markX: (mi * step).toFixed(1),
|
||||
markHour: t[mi].hour,
|
||||
markDone: t[mi].done,
|
||||
peak: max,
|
||||
};
|
||||
});
|
||||
@@ -332,7 +318,7 @@
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 머신 레인 (누가 일하나 + 요약 오프로드) -->
|
||||
<!-- 머신 레인 (누가 일하나) -->
|
||||
<div class="grid gap-2 mb-3">
|
||||
{#each lanes as lane (lane.key)}
|
||||
<div class="bg-surface border border-default rounded-card px-3.5 py-2.5">
|
||||
@@ -342,11 +328,8 @@
|
||||
<span class="text-[10px] text-faint font-mono">{lane.meta.model}</span>
|
||||
<span class="text-[11px] text-dim tabular-nums ml-1">{formatRate(lane.card?.done_1h ?? 0)}/h</span>
|
||||
{#each bgForMachine(lane.key) as j (j.id)}<span class="text-[10px] font-semibold text-success tabular-nums ml-1">생성 중: {j.label ?? j.kind}{#if j.total} {j.processed}/{j.total}{/if}</span>{/each}
|
||||
{#if lane.key === 'macbook' && (lane.card?.deferred_pending ?? 0) > 0}
|
||||
<span class="text-[10px] font-semibold text-warning tabular-nums">보류 {lane.card?.deferred_pending}</span>
|
||||
{/if}
|
||||
{#if lane.card?.state === 'deferred'}
|
||||
<span class="text-[9px] text-warning">잠듦 — 요약은 맥미니로 복귀</span>
|
||||
{#if (lane.card?.deferred_pending ?? 0) > 0}
|
||||
<span class="text-[10px] font-semibold text-warning tabular-nums" title="LLM 백오프 — 자동 재개 대기">보류 {lane.card?.deferred_pending}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-stretch gap-1.5 flex-wrap">
|
||||
@@ -368,26 +351,8 @@
|
||||
</div>
|
||||
<div class="text-sm font-extrabold tabular-nums leading-tight text-text">{n.pending.toLocaleString()}<span class="text-[9px] text-faint font-normal ml-0.5">대기</span></div>
|
||||
<div class="text-[9px] text-dim tabular-nums whitespace-nowrap">{formatRate(n.done1h)}/h · 오늘 {n.doneToday.toLocaleString()}</div>
|
||||
{#if n.def.key === 'summarize'}
|
||||
<div class="mt-1 h-1 w-full rounded-full overflow-hidden flex" title="맥미니 {split.macmini.done_1h}/h · 맥북 {split.macbook.done_1h}/h">
|
||||
<span class="block h-full mtag-macmini-bar" style="width:{100 - macbookSharePct}%"></span>
|
||||
<span class="block h-full mtag-macbook-bar" style="width:{macbookSharePct}%"></span>
|
||||
</div>
|
||||
<div class="text-[9px] text-faint tabular-nums whitespace-nowrap mt-0.5">맥미니 {split.macmini.done_1h} · 맥북 {split.macbook.done_1h}/h</div>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
{#if lane.key === 'macbook' && offloadActive}
|
||||
<button
|
||||
class="text-left rounded-lg border border-dashed border-warning/50 px-2.5 py-1.5 cursor-pointer hover:bg-surface-hover min-w-[96px]"
|
||||
onclick={() => toggleNode('summarize')}
|
||||
title="맥북이 요약을 맥미니에서 가져와 처리 중"
|
||||
>
|
||||
<div class="flex items-center gap-1 text-[11px] font-semibold text-text whitespace-nowrap">요약 합류 <span class="text-[8px] font-bold text-warning">OFFLOAD</span></div>
|
||||
<div class="text-sm font-extrabold tabular-nums leading-tight text-text">{split.macbook.done_1h}<span class="text-[9px] text-faint font-normal ml-0.5">/h</span></div>
|
||||
<div class="text-[9px] text-dim tabular-nums whitespace-nowrap">요약의 {macbookSharePct}% 담당</div>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
@@ -399,15 +364,11 @@
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span class="text-[11px] font-bold text-text">요약 백로그 24시간</span>
|
||||
<span class="text-[9px] text-faint">유입(회색) vs 소화(녹색)</span>
|
||||
{#if offloadActive}<span class="text-[9px] text-warning ml-auto">맥북 합류 {burn.markHour} — 소화 급증</span>{/if}
|
||||
</div>
|
||||
<svg viewBox="0 0 {burn.w} {burn.h}" class="block w-full" style="height:64px" preserveAspectRatio="none" role="img" aria-label="요약 백로그 24시간 번다운">
|
||||
<polygon points={burn.area} fill="currentColor" class="text-success" opacity="0.12" />
|
||||
<polyline points={burn.inflowLine} fill="none" stroke="currentColor" stroke-width="1.2" class="text-faint" />
|
||||
<polyline points={burn.doneLine} fill="none" stroke="currentColor" stroke-width="1.6" class="text-success" />
|
||||
{#if offloadActive}
|
||||
<line x1={burn.markX} y1="0" x2={burn.markX} y2={burn.h} stroke="currentColor" stroke-width="1" stroke-dasharray="2 2" class="text-warning" opacity="0.7" />
|
||||
{/if}
|
||||
</svg>
|
||||
<div class="flex flex-wrap gap-x-4 gap-y-1 mt-2 pt-2 border-t border-default text-[10px] text-dim tabular-nums">
|
||||
{#each mainNodes.filter((n) => n.pending > 0 && n.def.key !== 'summarize') as n (n.def.key)}
|
||||
@@ -558,13 +519,9 @@
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* 머신 색 — 디자인 토큰 외 3색 (gpu 청/macmini 보라/macbook 황) — 이 컴포넌트 한정 */
|
||||
.mtag-gpu { background: #e7eef6; color: #3b6ea5; }
|
||||
/* 머신 색 — 디자인 토큰 외 2색 (nas 청/macmini 보라) — 이 컴포넌트 한정 */
|
||||
.mtag-nas { background: #e7eef6; color: #3b6ea5; }
|
||||
.mtag-macmini { background: #efe9f7; color: #8a5fbf; }
|
||||
.mtag-macbook { background: #f7eedd; color: #b07a10; }
|
||||
/* 요약 오프로드 분담 막대 채움 (맥미니 보라 / 맥북 황) */
|
||||
.mtag-macmini-bar { background: #8a5fbf; }
|
||||
.mtag-macbook-bar { background: #b07a10; }
|
||||
.node-sel { outline: 2px solid #3b6ea5; outline-offset: 1px; }
|
||||
.detail-frame { border-color: #3b6ea5; }
|
||||
.detail-head { background: #e7eef6; }
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
// 처리 현황 드로어 (안6 라이트) — 전 페이지 상태 스트립 클릭 시 우측에서 열림.
|
||||
// 머신 미니카드 3 + ETA 한 줄 + 실패 합계 + 홈 링크 축약본. 상세는 홈 보드가 담당.
|
||||
// 머신 미니카드 2(나스/맥미니) + ETA 한 줄 + 실패 합계 + 홈 링크 축약본. 상세는 홈 보드가 담당.
|
||||
// 데이터 = queueOverview store 공유 (60s 폴링, 실패 시 null → 안내문으로 degrade).
|
||||
// 열림 상태는 uiState 단일 drawer slot('queue') — 사이드바 드로어와 동시 오픈 차단.
|
||||
import { X } from 'lucide-svelte';
|
||||
@@ -51,7 +51,7 @@
|
||||
|
||||
<div class="p-4 space-y-3">
|
||||
{#if data}
|
||||
<!-- 머신 미니카드 3 -->
|
||||
<!-- 머신 미니카드 (나스/맥미니) -->
|
||||
{#each data.machines as m (m.key)}
|
||||
<div class="bg-surface border border-default rounded-lg px-3.5 py-2.5">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
<script>
|
||||
// 관련 문서 (유사도) — 문서 레벨 임베딩 KNN. 자기완결: docId 받아 /related 조회.
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '$lib/api';
|
||||
|
||||
let { documentId } = $props();
|
||||
let items = $state([]);
|
||||
let loaded = $state(false);
|
||||
|
||||
const KIND = { law: '법령', guide: '지침', paper: '논문', standard: '표준', incident: '사례' };
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const r = await api(`/documents/${documentId}/related?limit=6`);
|
||||
items = r?.related ?? [];
|
||||
} catch (e) { /* silent */ }
|
||||
finally { loaded = true; }
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if items.length}
|
||||
<div class="rel">
|
||||
<div class="lab">관련 문서</div>
|
||||
{#each items as it (it.id)}
|
||||
<a class="ri" href={`/documents/${it.id}`}>
|
||||
<span class="rt">{it.title}</span>
|
||||
<span class="rm">
|
||||
{#if it.material_type && KIND[it.material_type]}<span class="kind">{KIND[it.material_type]}</span>{/if}
|
||||
<span class="rs">{Math.round((it.sim ?? 0) * 100)}</span>
|
||||
</span>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.rel { background: var(--surface); border: 1px solid var(--border); border-radius: 14px; padding: 13px; }
|
||||
.lab { font-size: 10.5px; font-weight: 700; color: var(--text-dim); letter-spacing: .4px; margin-bottom: 8px; }
|
||||
.ri { display: flex; align-items: baseline; gap: 8px; padding: 5px 6px; border-radius: 7px; text-decoration: none; }
|
||||
.ri:hover { background: var(--surface-hover, #ecf0e8); }
|
||||
.rt { flex: 1; font-size: 12px; line-height: 1.4; color: var(--text); overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
|
||||
.rm { flex-shrink: 0; display: flex; align-items: center; gap: 5px; }
|
||||
.kind { font-size: 9px; font-weight: 700; color: var(--accent-hover, #3d7256); background: #e3efe2; border: 1px solid #cfe3cd; border-radius: 4px; padding: 0 4px; }
|
||||
.rs { font-size: 10.5px; font-family: ui-monospace, Menlo, monospace; color: var(--faint, #9aa090); }
|
||||
</style>
|
||||
@@ -2,7 +2,7 @@
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { api } from '$lib/api';
|
||||
import { ChevronRight, ChevronDown, FolderOpen, FolderTree, Inbox, Clock, Mail, Scale, StickyNote, GraduationCap, CalendarCheck, MessageCircle, Hash } from 'lucide-svelte';
|
||||
import { ChevronRight, ChevronDown, FolderOpen, FolderTree, Inbox, Clock, Mail, Scale, StickyNote, GraduationCap, CalendarCheck, MessageCircle, Hash, HardHat } from 'lucide-svelte';
|
||||
|
||||
let tree = $state([]);
|
||||
let loading = $state(true);
|
||||
@@ -195,6 +195,13 @@
|
||||
>
|
||||
<FolderTree size={14} /> 자료실
|
||||
</a>
|
||||
<a
|
||||
href="/safety"
|
||||
class="w-full flex items-center gap-2 px-3 py-1.5 rounded-md text-sm transition-colors
|
||||
{$page.url.pathname.startsWith('/safety') ? 'bg-accent/15 text-accent' : 'text-dim hover:bg-surface hover:text-text'}"
|
||||
>
|
||||
<HardHat size={14} /> 안전 자료실
|
||||
</a>
|
||||
<a
|
||||
href="/clause"
|
||||
class="w-full flex items-center gap-2 px-3 py-1.5 rounded-md text-sm transition-colors
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
<script>
|
||||
// 시안 B — 글로벌 네비 슬림 아이콘 레일 (분류 사이드바 접힘 상태). 앱 토큰 사용.
|
||||
import { page } from '$app/stores';
|
||||
import { Home, FolderTree, Newspaper, StickyNote, Hash, GraduationCap, MessageCircle, Inbox, CalendarCheck } from 'lucide-svelte';
|
||||
|
||||
const items = [
|
||||
{ href: '/', icon: Home, label: '홈', exact: true },
|
||||
{ href: '/library', icon: FolderTree, label: '문서' },
|
||||
{ href: '/news', icon: Newspaper, label: '뉴스' },
|
||||
{ href: '/memos', icon: StickyNote, label: '메모' },
|
||||
{ href: '/clause', icon: Hash, label: '절' },
|
||||
{ href: '/events', icon: CalendarCheck, label: '일정' },
|
||||
{ href: '/study', icon: GraduationCap, label: '공부' },
|
||||
{ href: '/chat', icon: MessageCircle, label: '이드' },
|
||||
{ href: '/inbox', icon: Inbox, label: '편지함' },
|
||||
];
|
||||
let path = $derived($page.url.pathname);
|
||||
const active = (it) => (it.exact ? path === it.href : path.startsWith(it.href));
|
||||
</script>
|
||||
|
||||
<nav class="flex flex-col items-center gap-1 py-2 h-full overflow-y-auto bg-sidebar">
|
||||
{#each items as it (it.href)}
|
||||
{@const Icon = it.icon}
|
||||
<a
|
||||
href={it.href}
|
||||
title={it.label}
|
||||
class="flex flex-col items-center justify-center gap-0.5 w-12 h-[46px] rounded-lg text-dim hover:bg-surface-hover hover:text-accent transition-colors {active(it) ? 'bg-surface-active text-accent font-semibold' : ''}"
|
||||
>
|
||||
<Icon size={17} strokeWidth={1.75} />
|
||||
<span class="text-[8.5px] leading-none tracking-tight">{it.label}</span>
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
@@ -1,228 +0,0 @@
|
||||
<!--
|
||||
AskAnswer.svelte — /ask 페이지 상단 패널.
|
||||
|
||||
Answer 본문 + clickable [n] citations + 신뢰도/상태 Badge.
|
||||
status != completed 또는 refused=true → warning empty state +
|
||||
no_results_reason + "검색 결과 확인하기" 역링크.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import Badge from '$lib/components/ui/Badge.svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
||||
import Skeleton from '$lib/components/ui/Skeleton.svelte';
|
||||
import { AlertTriangle, Sparkles } from 'lucide-svelte';
|
||||
import type { AskResponse, Confidence, SynthesisStatus } from '$lib/types/ask';
|
||||
|
||||
interface Props {
|
||||
data: AskResponse | null;
|
||||
loading: boolean;
|
||||
onCitationClick: (n: number) => void;
|
||||
}
|
||||
|
||||
let { data, loading, onCitationClick }: Props = $props();
|
||||
|
||||
type Token =
|
||||
| { type: 'text'; value: string }
|
||||
| { type: 'cite'; n: number; raw: string };
|
||||
|
||||
function splitAnswer(text: string): Token[] {
|
||||
return text
|
||||
.split(/(\[\d+\])/g)
|
||||
.filter(Boolean)
|
||||
.map((tok): Token => {
|
||||
const m = tok.match(/^\[(\d+)\]$/);
|
||||
return m
|
||||
? { type: 'cite', n: Number(m[1]), raw: tok }
|
||||
: { type: 'text', value: tok };
|
||||
});
|
||||
}
|
||||
|
||||
function confidenceTone(
|
||||
c: Confidence | null,
|
||||
): 'success' | 'warning' | 'error' | 'neutral' {
|
||||
if (c === 'high') return 'success';
|
||||
if (c === 'medium') return 'warning';
|
||||
if (c === 'low') return 'error';
|
||||
return 'neutral';
|
||||
}
|
||||
|
||||
function confidenceLabel(c: Confidence | null): string {
|
||||
if (c === 'high') return '높음';
|
||||
if (c === 'medium') return '중간';
|
||||
if (c === 'low') return '낮음';
|
||||
return '없음';
|
||||
}
|
||||
|
||||
const STATUS_LABEL: Record<SynthesisStatus, string> = {
|
||||
completed: '답변 완료',
|
||||
timeout: '답변 지연',
|
||||
skipped: '답변 생략',
|
||||
no_evidence: '근거 없음',
|
||||
parse_failed: '형식 오류',
|
||||
llm_error: 'AI 오류',
|
||||
backend_unavailable: 'Backend 비가용',
|
||||
};
|
||||
|
||||
/**
|
||||
* backend chip label — `backend_requested` 가 명시 opt-in 인 경우만 표시.
|
||||
* 미지정 (null/undefined) default 호출은 chip 없음 (시각 noise 회피).
|
||||
*/
|
||||
function backendChipLabel(backend: string | null | undefined): string | null {
|
||||
if (!backend) return null;
|
||||
if (backend === 'qwen-macbook') return 'Qwen 27B (MacBook)';
|
||||
if (backend === 'gemma-macmini') return 'Gemma 26B (Mac mini)';
|
||||
return backend;
|
||||
}
|
||||
|
||||
let tokens = $derived(data?.ai_answer ? splitAnswer(data.ai_answer) : []);
|
||||
let showFullAnswer = $derived(
|
||||
!!data && !!data.ai_answer && data.completeness === 'full'
|
||||
&& data.synthesis_status === 'completed' && !data.refused,
|
||||
);
|
||||
let showPartial = $derived(
|
||||
!!data && data.completeness === 'partial' && !data.refused,
|
||||
);
|
||||
let showWarning = $derived(!!data && !showFullAnswer && !showPartial);
|
||||
</script>
|
||||
|
||||
<section class="bg-surface border border-default rounded-card p-5">
|
||||
<!-- 헤더 -->
|
||||
<div class="flex items-start justify-between gap-3 mb-4">
|
||||
<div>
|
||||
<p class="text-[10px] font-semibold tracking-wider uppercase text-dim flex items-center gap-1.5">
|
||||
<Sparkles size={12} /> AI Answer
|
||||
</p>
|
||||
<h2 class="mt-1 text-base font-semibold text-text">근거 기반 답변</h2>
|
||||
</div>
|
||||
|
||||
{#if data && !loading}
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<Badge tone={confidenceTone(data.confidence)} size="sm">
|
||||
신뢰도 {confidenceLabel(data.confidence)}
|
||||
</Badge>
|
||||
{#if backendChipLabel(data.backend_requested)}
|
||||
<span title={`backend_requested=${data.backend_requested} / backend_used=${data.backend_used ?? 'null'}`}>
|
||||
<Badge tone="neutral" size="sm">
|
||||
{backendChipLabel(data.backend_requested)}
|
||||
</Badge>
|
||||
</span>
|
||||
{/if}
|
||||
<Badge tone="neutral" size="sm">
|
||||
{STATUS_LABEL[data.synthesis_status]}
|
||||
</Badge>
|
||||
{#if data.synthesis_ms > 0}
|
||||
<Badge tone="neutral" size="sm">
|
||||
{Math.round(data.synthesis_ms)}ms
|
||||
</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- 본문 -->
|
||||
{#if loading}
|
||||
<div class="space-y-3">
|
||||
<Skeleton w="w-3/4" h="h-4" />
|
||||
<Skeleton w="w-full" h="h-4" />
|
||||
<Skeleton w="w-5/6" h="h-4" />
|
||||
<p class="mt-4 text-xs text-dim flex items-center gap-2">
|
||||
<span class="inline-block w-3 h-3 rounded-full border-2 border-dim border-t-accent animate-spin"></span>
|
||||
근거 기반 답변 생성 중… 약 15초 소요
|
||||
</p>
|
||||
</div>
|
||||
{:else if showFullAnswer && data}
|
||||
<div class="text-sm leading-7 text-text">
|
||||
{#each tokens as tok}
|
||||
{#if tok.type === 'cite'}
|
||||
<button
|
||||
type="button"
|
||||
class="inline-block align-baseline text-accent font-semibold hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-ring rounded px-0.5"
|
||||
onclick={() => onCitationClick(tok.n)}
|
||||
aria-label={`인용 ${tok.n}번 보기`}
|
||||
>
|
||||
{tok.raw}
|
||||
</button>
|
||||
{:else}
|
||||
<span>{tok.value}</span>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{:else if showPartial && data}
|
||||
<!-- Phase 3.5a: question-aligned partial structure -->
|
||||
<div>
|
||||
<Badge tone="warning" size="sm">일부 답변</Badge>
|
||||
|
||||
{#if data.ai_answer}
|
||||
<div class="mt-3 text-sm leading-7 text-text">
|
||||
{#each tokens as tok}
|
||||
{#if tok.type === 'cite'}
|
||||
<button
|
||||
type="button"
|
||||
class="inline-block align-baseline text-accent font-semibold hover:underline rounded px-0.5"
|
||||
onclick={() => onCitationClick(tok.n)}
|
||||
>{tok.raw}</button>
|
||||
{:else}
|
||||
<span>{tok.value}</span>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{:else if data.confirmed_items?.length}
|
||||
<div class="mt-3">
|
||||
<h4 class="text-xs font-semibold text-dim uppercase tracking-wider">✓ 답변 가능</h4>
|
||||
<ul class="mt-2 space-y-2">
|
||||
{#each data.confirmed_items as item}
|
||||
<li class="text-sm text-text">
|
||||
<strong class="text-accent">{item.aspect}:</strong>
|
||||
<span>{item.text}</span>
|
||||
{#each item.citations as n}
|
||||
<button
|
||||
type="button"
|
||||
class="text-accent font-semibold hover:underline px-0.5"
|
||||
onclick={() => onCitationClick(n)}
|
||||
>[{n}]</button>
|
||||
{/each}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if data.missing_aspects?.length}
|
||||
<div class="mt-4 border-t border-default pt-3">
|
||||
<h4 class="text-xs font-semibold text-dim uppercase tracking-wider">✗ 답변 불가</h4>
|
||||
<ul class="mt-2 space-y-1">
|
||||
{#each data.missing_aspects as aspect}
|
||||
<li class="text-sm text-dim">{aspect} <span class="text-[10px]">(근거 없음)</span></li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="mt-4">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
href={`/documents?q=${encodeURIComponent(data.query)}`}
|
||||
>
|
||||
검색 결과 확인하기
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{:else if showWarning && data}
|
||||
<EmptyState
|
||||
icon={AlertTriangle}
|
||||
title={data.refused && data.no_results_reason
|
||||
? data.no_results_reason
|
||||
: (data.no_results_reason ?? '관련 근거를 찾지 못했습니다.')}
|
||||
description="검색 결과를 직접 확인해 보세요."
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
href={`/documents?q=${encodeURIComponent(data.query)}`}
|
||||
>
|
||||
검색 결과 확인하기
|
||||
</Button>
|
||||
</EmptyState>
|
||||
{/if}
|
||||
</section>
|
||||
@@ -1,91 +0,0 @@
|
||||
<!--
|
||||
AskEvidence.svelte — /ask 페이지 우측 sticky 패널.
|
||||
|
||||
⚠ 영구 룰 (Phase 3.4 plan):
|
||||
`citation.full_snippet` 은 UI 에 직접 렌더 금지. debug 모드(`?debug=1`)
|
||||
에서 hover tooltip 으로만 조건부 노출 가능.
|
||||
|
||||
이 규칙이 깨지면 backend span-precision UX 가치가 사라진다. 코드 리뷰에서
|
||||
반드시 reject. span_text 만 본문으로 노출한다.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import Badge from '$lib/components/ui/Badge.svelte';
|
||||
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
||||
import Skeleton from '$lib/components/ui/Skeleton.svelte';
|
||||
import { BookOpen } from 'lucide-svelte';
|
||||
import type { AskResponse } from '$lib/types/ask';
|
||||
|
||||
interface Props {
|
||||
data: AskResponse | null;
|
||||
loading: boolean;
|
||||
activeCitation: number | null;
|
||||
registerCitation: (n: number, node: HTMLElement) => { destroy: () => void };
|
||||
}
|
||||
|
||||
let { data, loading, activeCitation, registerCitation }: Props = $props();
|
||||
|
||||
let citations = $derived(data?.citations ?? []);
|
||||
</script>
|
||||
|
||||
<section class="bg-surface border border-default rounded-card p-5">
|
||||
<div class="flex items-start justify-between gap-3 mb-4">
|
||||
<div>
|
||||
<p class="text-[10px] font-semibold tracking-wider uppercase text-dim flex items-center gap-1.5">
|
||||
<BookOpen size={12} /> Evidence Highlights
|
||||
</p>
|
||||
<h3 class="mt-1 text-sm font-semibold text-text">인용 근거</h3>
|
||||
</div>
|
||||
{#if data && !loading}
|
||||
<Badge tone="neutral" size="sm">{citations.length}개</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="space-y-3">
|
||||
{#each Array(2) as _}
|
||||
<div class="border border-default rounded-card p-4 space-y-2">
|
||||
<Skeleton w="w-24" h="h-3" />
|
||||
<Skeleton w="w-full" h="h-3" />
|
||||
<Skeleton w="w-5/6" h="h-3" />
|
||||
<Skeleton w="w-3/4" h="h-3" />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if citations.length === 0}
|
||||
<EmptyState title="표시할 근거가 없습니다." class="py-6" />
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
{#each citations as citation (citation.n)}
|
||||
{@const isActive = activeCitation === citation.n}
|
||||
<article
|
||||
class="border rounded-card p-4 transition-colors {isActive
|
||||
? 'border-accent ring-2 ring-accent/20 bg-accent/5'
|
||||
: 'border-default'}"
|
||||
use:registerCitation={citation.n}
|
||||
>
|
||||
<div class="flex items-start gap-2">
|
||||
<span class="text-accent font-bold text-sm shrink-0">[{citation.n}]</span>
|
||||
<div class="flex-1 min-w-0">
|
||||
<strong class="block text-sm text-text truncate">
|
||||
{citation.title ?? `문서 ${citation.doc_id}`}
|
||||
</strong>
|
||||
{#if citation.section_title}
|
||||
<p class="mt-0.5 text-xs text-dim truncate">{citation.section_title}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ⚠ span_text 만 렌더. full_snippet 금지 -->
|
||||
<p class="mt-3 text-sm leading-relaxed text-text whitespace-pre-wrap">
|
||||
{citation.span_text}
|
||||
</p>
|
||||
|
||||
<div class="mt-3 flex gap-2 text-[10px] text-dim">
|
||||
<span>relevance {citation.relevance.toFixed(2)}</span>
|
||||
<span>rerank {citation.rerank_score.toFixed(2)}</span>
|
||||
</div>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
@@ -1,78 +0,0 @@
|
||||
<!--
|
||||
AskResults.svelte — /ask 페이지 하단 패널.
|
||||
|
||||
검색 결과 리스트. DocumentCard 재사용 X — SearchResult 필드 셋이 달라서
|
||||
의존성 리스크 회피. inline 간단 카드로 title/score/snippet/section_title 표시.
|
||||
클릭 시 `/documents/{id}` 로 이동.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import Badge from '$lib/components/ui/Badge.svelte';
|
||||
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
||||
import Skeleton from '$lib/components/ui/Skeleton.svelte';
|
||||
import { FileText } from 'lucide-svelte';
|
||||
import type { AskResponse } from '$lib/types/ask';
|
||||
|
||||
interface Props {
|
||||
data: AskResponse | null;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
let { data, loading }: Props = $props();
|
||||
|
||||
let results = $derived(data?.results ?? []);
|
||||
</script>
|
||||
|
||||
<section class="bg-surface border border-default rounded-card p-5">
|
||||
<div class="flex items-start justify-between gap-3 mb-4">
|
||||
<div>
|
||||
<p class="text-[10px] font-semibold tracking-wider uppercase text-dim flex items-center gap-1.5">
|
||||
<FileText size={12} /> Search Results
|
||||
</p>
|
||||
<h3 class="mt-1 text-sm font-semibold text-text">검색 결과</h3>
|
||||
</div>
|
||||
{#if data && !loading}
|
||||
<Badge tone="neutral" size="sm">{data.total}개</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="space-y-3">
|
||||
{#each Array(5) as _}
|
||||
<div class="border border-default rounded-card p-4 space-y-2">
|
||||
<Skeleton w="w-2/3" h="h-4" />
|
||||
<Skeleton w="w-full" h="h-3" />
|
||||
<Skeleton w="w-4/5" h="h-3" />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if results.length === 0}
|
||||
<EmptyState title="검색 결과가 없습니다." class="py-6" />
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
{#each results as result (result.id)}
|
||||
<a
|
||||
href={`/documents/${result.id}`}
|
||||
class="block border border-default rounded-card p-4 hover:border-accent hover:bg-surface-hover transition-colors"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<strong class="text-sm text-text flex-1 min-w-0 truncate">
|
||||
{result.title ?? `문서 ${result.id}`}
|
||||
</strong>
|
||||
<div class="flex gap-1.5 text-[10px] text-dim shrink-0">
|
||||
<span>score {result.score.toFixed(2)}</span>
|
||||
{#if result.rerank_score != null}
|
||||
<span>rerank {result.rerank_score.toFixed(2)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if result.section_title}
|
||||
<p class="mt-1 text-xs text-dim truncate">{result.section_title}</p>
|
||||
{/if}
|
||||
{#if result.snippet}
|
||||
<p class="mt-2 text-xs text-dim line-clamp-2">{result.snippet}</p>
|
||||
{/if}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
@@ -1,84 +0,0 @@
|
||||
/**
|
||||
* Phase 3.4: `/api/search/ask` 응답 타입.
|
||||
*
|
||||
* Backend Pydantic 모델 (`app/api/search.py::AskResponse`) 과 1:1 매칭.
|
||||
* 필드 변경 시 양쪽 동시 수정 필수.
|
||||
*/
|
||||
|
||||
export type SynthesisStatus =
|
||||
| 'completed'
|
||||
| 'timeout'
|
||||
| 'skipped'
|
||||
| 'no_evidence'
|
||||
| 'parse_failed'
|
||||
| 'llm_error'
|
||||
| 'backend_unavailable';
|
||||
|
||||
export type Confidence = 'high' | 'medium' | 'low';
|
||||
|
||||
export interface Citation {
|
||||
n: number;
|
||||
chunk_id: number | null;
|
||||
doc_id: number;
|
||||
title: string | null;
|
||||
section_title: string | null;
|
||||
/** LLM이 추출한 50~300자 핵심 span. UI에서 이것만 노출. */
|
||||
span_text: string;
|
||||
/**
|
||||
* 원본 800자 window.
|
||||
*
|
||||
* ⚠ UI 기본 경로에서 절대 렌더 금지. debug 모드에서 hover tooltip 용도로만
|
||||
* 조건부 노출 가능. full_snippet을 보여주면 backend span-precision UX
|
||||
* 가치가 사라진다 (plan §Evidence 표시 규칙).
|
||||
*/
|
||||
full_snippet: string;
|
||||
relevance: number;
|
||||
rerank_score: number;
|
||||
}
|
||||
|
||||
export interface SearchResult {
|
||||
id: number;
|
||||
title: string | null;
|
||||
ai_domain: string | null;
|
||||
ai_summary: string | null;
|
||||
file_format: string;
|
||||
score: number;
|
||||
snippet: string | null;
|
||||
match_reason: string | null;
|
||||
chunk_id: number | null;
|
||||
chunk_index: number | null;
|
||||
section_title: string | null;
|
||||
rerank_score: number | null;
|
||||
}
|
||||
|
||||
export type Completeness = 'full' | 'partial' | 'insufficient';
|
||||
|
||||
export interface ConfirmedItem {
|
||||
aspect: string;
|
||||
text: string;
|
||||
citations: number[];
|
||||
}
|
||||
|
||||
export interface AskResponse {
|
||||
results: SearchResult[];
|
||||
ai_answer: string | null;
|
||||
citations: Citation[];
|
||||
synthesis_status: SynthesisStatus;
|
||||
synthesis_ms: number;
|
||||
confidence: Confidence | null;
|
||||
refused: boolean;
|
||||
no_results_reason: string | null;
|
||||
query: string;
|
||||
total: number;
|
||||
/** Phase 3.5a */
|
||||
completeness: Completeness;
|
||||
covered_aspects: string[] | null;
|
||||
missing_aspects: string[] | null;
|
||||
confirmed_items: ConfirmedItem[] | null;
|
||||
/**
|
||||
* PR-MacBook-RAG-Backend-1: backend dispatcher metadata.
|
||||
* backend 미지정 호출은 둘 다 null (기존 호출자 호환). 명시 opt-in 시만 채워짐.
|
||||
*/
|
||||
backend_requested?: string | null;
|
||||
backend_used?: string | null;
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
* 필드 변경 시 양쪽 동시 수정 필수.
|
||||
*/
|
||||
|
||||
export type MachineKey = 'gpu' | 'macmini' | 'macbook';
|
||||
export type MachineKey = 'nas' | 'macmini';
|
||||
|
||||
/** 머신 상태 — active(가동) / deferred(보류) / idle(대기) */
|
||||
export type MachineState = 'active' | 'deferred' | 'idle';
|
||||
@@ -29,7 +29,7 @@ export interface MachineOverview {
|
||||
/** 최근 1시간 완료 건수 (처리율 N/h 표기) */
|
||||
done_1h: number;
|
||||
done_today: number;
|
||||
/** 보류 건수 — 맥북 sleep 등으로 자동 재개 대기 중 */
|
||||
/** 보류 건수 — LLM 허브 백오프 등으로 자동 재개 대기 중 */
|
||||
deferred_pending: number;
|
||||
current: MachineCurrentItem[];
|
||||
}
|
||||
@@ -50,12 +50,6 @@ export interface TrendPoint {
|
||||
done: number;
|
||||
}
|
||||
|
||||
/** summarize 머신별 완료 실적 분담 (오프로드 가시화 — ds-board-merged A-1) */
|
||||
export interface SummarizeByMachine {
|
||||
macmini: { done_1h: number; done_today: number };
|
||||
macbook: { done_1h: number; done_today: number };
|
||||
}
|
||||
|
||||
export interface QueueTotals {
|
||||
pending: number;
|
||||
processing: number;
|
||||
@@ -93,7 +87,6 @@ export interface BackgroundJob {
|
||||
export interface QueueOverview {
|
||||
machines: MachineOverview[];
|
||||
summarize_eta: SummarizeEta;
|
||||
summarize_by_machine: SummarizeByMachine;
|
||||
trend_24h: TrendPoint[];
|
||||
stages: QueueStageRow[];
|
||||
totals: QueueTotals;
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
/**
|
||||
* 검색 쿼리가 "질문형"인지 판별 (규칙 기반).
|
||||
* true이면 검색 결과 페이지에서 /api/search/ask 병렬 호출.
|
||||
*
|
||||
* false positive: /ask가 refused=true 반환하면 카드 숨김 → 무해.
|
||||
* false negative: 기존 검색 결과만 표시 → 무해.
|
||||
*/
|
||||
export function isQuestion(q: string): boolean {
|
||||
const trimmed = q.trim();
|
||||
if (trimmed.length === 0) return false;
|
||||
|
||||
// 1. ?로 끝나면 단일 단어라도 허용 (왜?, 절차?)
|
||||
if (trimmed.endsWith('?')) return true;
|
||||
|
||||
// ? 없으면 단일 단어 / 4자 미만 제외 (키워드 보호)
|
||||
if (trimmed.length < 4) return false;
|
||||
if (trimmed.split(/\s+/).length < 2) return false;
|
||||
|
||||
// 2. 한국어 질문 어미
|
||||
const KO_ENDINGS = [
|
||||
'인가요', '인가', '인지', '있나요', '있나',
|
||||
'할까요', '할까', '될까요', '될까',
|
||||
'뭐야', '뭔가', '뭘까', '뭔지', '뭐지', '뭐죠',
|
||||
'는지', '은지', '일까',
|
||||
'어때', '어떤가',
|
||||
'됩니까', '합니까', '입니까', '나요', '까요',
|
||||
'주세요', '알려줘', '설명해', '비교해',
|
||||
];
|
||||
for (const ending of KO_ENDINGS) {
|
||||
if (trimmed.endsWith(ending)) return true;
|
||||
}
|
||||
|
||||
// 3. 한국어 질문 시작어
|
||||
const KO_STARTS = [
|
||||
'어떻게', '왜', '무엇', '무슨', '뭐가', '누가',
|
||||
'어디', '언제', '몇', '얼마나', '어떤', '어느',
|
||||
];
|
||||
for (const start of KO_STARTS) {
|
||||
if (trimmed.startsWith(start)) return true;
|
||||
}
|
||||
|
||||
// 4. 영어 질문 시작어 (대소문자 무시)
|
||||
const EN_STARTS = [
|
||||
'what', 'how', 'why', 'when', 'where', 'who', 'which',
|
||||
'is', 'are', 'do', 'does', 'can', 'could', 'should', 'would',
|
||||
'explain', 'describe', 'compare', 'tell me',
|
||||
];
|
||||
const lower = trimmed.toLowerCase();
|
||||
for (const start of EN_STARTS) {
|
||||
if (lower.startsWith(start + ' ')) return true;
|
||||
}
|
||||
|
||||
// 5. 의미 패턴 (끝 단어)
|
||||
const SEMANTIC_ENDINGS = ['차이', '비교', '설명', '요약', '정리', '방법', '절차'];
|
||||
const lastWord = trimmed.split(/\s+/).pop() || '';
|
||||
for (const pat of SEMANTIC_ENDINGS) {
|
||||
if (lastWord === pat || lastWord.endsWith(pat)) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -62,7 +62,7 @@ export function formatAgeSec(sec: number): string {
|
||||
* ★ 모델/엔진 교체 시 이 블록 1곳만 수정 (예: 맥미니 모델 스왑).
|
||||
*/
|
||||
|
||||
export type FlowMachine = 'gpu' | 'macmini' | 'macbook';
|
||||
export type FlowMachine = 'nas' | 'macmini';
|
||||
|
||||
export interface FlowNodeDef {
|
||||
key: string;
|
||||
@@ -79,26 +79,25 @@ export interface FlowNodeDef {
|
||||
|
||||
/** 메인 흐름 (문서 진행 순서). 뉴스 등 소스별 스킵 경로는 그림에 안 그림 — 단순화 한계. */
|
||||
export const FLOW_NODES: FlowNodeDef[] = [
|
||||
{ key: 'extract', label: '추출', stages: ['extract'], machine: 'gpu', engine: 'Surya OCR', sub: 'ocr-service' },
|
||||
{ key: 'markdown', label: '마크다운', stages: ['markdown'], machine: 'gpu', engine: 'Marker', sub: 'marker-service' },
|
||||
{ key: 'extract', label: '추출', stages: ['extract'], machine: 'nas', engine: 'kordoc', sub: 'kordoc' },
|
||||
{ key: 'markdown', label: '마크다운', stages: ['markdown'], machine: 'nas', engine: 'Marker', sub: 'marker-service' },
|
||||
{ key: 'classify', label: '분류', stages: ['classify'], machine: 'macmini', engine: 'Qwen3.6-27B', sub: 'classify + triage' },
|
||||
{ key: 'summarize', label: '요약', stages: ['summarize'], machine: 'macmini', engine: 'Qwen3.6-27B', sub: 'summarize' },
|
||||
{ key: 'chunkembed', label: '청크 · 임베딩', stages: ['chunk', 'embed'], machine: 'gpu', engine: 'TEI bge-m3', sub: 'text-embeddings-inference' },
|
||||
{ key: 'deep', label: '심층분석', stages: ['deep_summary'], machine: 'macbook', engine: 'Qwen3.6-27B', sub: 'deep_summary' },
|
||||
{ key: 'chunkembed', label: '청크 · 임베딩', stages: ['chunk', 'embed'], machine: 'nas', engine: 'bge-m3 (맥미니 콜)', sub: 'embed worker' },
|
||||
{ key: 'deep', label: '심층분석', stages: ['deep_summary'], machine: 'macmini', engine: 'Qwen3.6-27B', sub: 'deep_summary' },
|
||||
];
|
||||
|
||||
/** 보조 노드 — 메인 흐름 밖 (활동 있을 때만 보조 라인에 표시) */
|
||||
export const AUX_NODES: FlowNodeDef[] = [
|
||||
{ key: 'fulltext', label: '전문 수집', stages: ['fulltext'], machine: 'gpu', engine: 'Playwright', sub: 'playwright-fetcher' },
|
||||
{ key: 'stt', label: '전사', stages: ['stt'], machine: 'gpu', engine: 'Whisper', sub: 'stt-service' },
|
||||
{ key: 'util', label: '미리보기 · 썸네일', stages: ['preview', 'thumbnail'], machine: 'gpu', engine: '유틸', sub: 'ffmpeg' },
|
||||
{ key: 'fulltext', label: '전문 수집', stages: ['fulltext'], machine: 'nas', engine: 'Playwright', sub: 'playwright-fetcher' },
|
||||
{ key: 'stt', label: '전사', stages: ['stt'], machine: 'nas', engine: 'Whisper', sub: 'stt-service' },
|
||||
{ key: 'util', label: '미리보기 · 썸네일', stages: ['preview', 'thumbnail'], machine: 'nas', engine: '유틸', sub: 'ffmpeg' },
|
||||
];
|
||||
|
||||
/** 머신 스트립 메타 — 모델 표기 단일 지점 */
|
||||
/** 머신 스트립 메타 — 모델 표기 단일 지점 (2026-07-02 컷오버: 나스+맥미니 2노드) */
|
||||
export const MACHINE_META: Record<FlowMachine, { label: string; model: string }> = {
|
||||
gpu: { label: 'GPU 서버', model: '특화 엔진' },
|
||||
macmini: { label: '맥미니', model: 'Qwen3.6-27B-6bit · 24/7' },
|
||||
macbook: { label: '맥북 M5 Max', model: 'Qwen3.6-27B · 야간 drain' },
|
||||
nas: { label: '나스', model: 'DS 본체 Docker · 특화 엔진' },
|
||||
macmini: { label: '맥미니', model: 'Qwen3.6-27B-6bit · bge-m3 · 24/7' },
|
||||
};
|
||||
|
||||
/** 흐름 보드 단계 라벨 (드로어/상세 행 표기) */
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
import { queueOverview } from '$lib/stores/queueOverview';
|
||||
import { MACHINE_STATE_LABEL, machineChipClass } from '$lib/utils/queueDisplay';
|
||||
import Sidebar from '$lib/components/Sidebar.svelte';
|
||||
import SlimRail from '$lib/components/SlimRail.svelte';
|
||||
import SystemStatusDot from '$lib/components/SystemStatusDot.svelte';
|
||||
import QueueDrawer from '$lib/components/QueueDrawer.svelte';
|
||||
import QuickMemoButton from '$lib/components/QuickMemoButton.svelte';
|
||||
@@ -21,7 +22,7 @@
|
||||
const PUBLIC_PATHS = ['/login', '/setup', '/__styleguide'];
|
||||
const NO_CHROME_PATHS = ['/login', '/setup', '/__styleguide'];
|
||||
// /news = 풀스크린 브리핑 → 데스크탑 상시 사이드바 없음
|
||||
const NO_SIDEBAR_PATHS = ['/news'];
|
||||
const NO_SIDEBAR_PATHS = ['/news', '/book']; // /book = 책 몰입(글로벌 분류 트리 숨김, 상단 네비 유지)
|
||||
|
||||
// toast 의미 토큰 매핑 (A-8 B3)
|
||||
const TOAST_CLASS = {
|
||||
@@ -71,7 +72,7 @@
|
||||
// 처리 현황 스트립 (안6 라이트) — 60s 폴링 store 공유. fetch 실패/401 시
|
||||
// store 가 null → 스트립 자체를 숨김 (silent 비차단, 로그인 페이지 동일).
|
||||
let queue = $derived($queueOverview);
|
||||
let queueMacbook = $derived(queue?.machines?.find((m) => m.key === 'macbook') ?? null);
|
||||
let queueMacmini = $derived(queue?.machines?.find((m) => m.key === 'macmini') ?? null);
|
||||
function toggleQueueDrawer() {
|
||||
if (ui.isDrawerOpen('queue')) ui.closeDrawer();
|
||||
else ui.openDrawer('queue');
|
||||
@@ -188,8 +189,8 @@
|
||||
</span>
|
||||
<span class="tabular-nums shrink-0">대기 <strong class="text-text">{queue.totals.pending.toLocaleString()}</strong></span>
|
||||
<span class="tabular-nums shrink-0 {queue.totals.failed > 0 ? 'text-error font-semibold' : ''}">실패 <strong class={queue.totals.failed > 0 ? '' : 'text-text'}>{queue.totals.failed.toLocaleString()}</strong></span>
|
||||
{#if queueMacbook}
|
||||
<span class="text-[10px] font-bold rounded-full px-2 py-0.5 shrink-0 {machineChipClass(queueMacbook.state)}">맥북 {MACHINE_STATE_LABEL[queueMacbook.state]}</span>
|
||||
{#if queueMacmini}
|
||||
<span class="text-[10px] font-bold rounded-full px-2 py-0.5 shrink-0 {machineChipClass(queueMacmini.state)}">맥미니 {MACHINE_STATE_LABEL[queueMacmini.state]}</span>
|
||||
{/if}
|
||||
<span class="ml-auto flex items-center gap-0.5 text-faint shrink-0">자세히 <ChevronDown size={11} /></span>
|
||||
</button>
|
||||
@@ -198,8 +199,8 @@
|
||||
<!-- 메인: 데스크탑 상시 사이드바 + 콘텐츠 -->
|
||||
<div class="flex-1 min-h-0 flex">
|
||||
{#if showSidebar}
|
||||
<aside class="hidden lg:block shrink-0 overflow-hidden transition-[width] duration-200 ease-out {sidebarCollapsed ? 'w-0 border-r-0' : 'w-sidebar border-r border-default'}">
|
||||
<Sidebar />
|
||||
<aside class="hidden lg:block shrink-0 overflow-hidden transition-[width] duration-200 ease-out {sidebarCollapsed ? 'w-14 border-r border-default' : 'w-sidebar border-r border-default'}">
|
||||
{#if sidebarCollapsed}<SlimRail />{:else}<Sidebar />{/if}
|
||||
</aside>
|
||||
{/if}
|
||||
<main class="flex-1 min-w-0 overflow-auto">
|
||||
|
||||
@@ -1,305 +0,0 @@
|
||||
<!--
|
||||
/ask — Phase 3.4 Ask Pipeline Frontend.
|
||||
|
||||
URL-driven: `/ask?q=<encoded>[&backend=<alias>]` 가 진입점.
|
||||
$effect 로 (q, backend) 변화 감지 → `/api/search/ask` 호출 →
|
||||
3-panel 렌더 (Answer / Evidence / Results).
|
||||
|
||||
중복 호출 방지: lastKey (q+backend) 가드.
|
||||
|
||||
Backend selector (PR-3 of DS AI routing policy, 2026-05-23,
|
||||
[[document-server-ai-routing-policy]]) — PR-DocSrv-Web-Ask-Selector-1 확장:
|
||||
- `auto` (기본, URL param 없음 → router 의 rule + LLM triage)
|
||||
- `mac-mini-default` (명시, Mac mini gemma-4-26b)
|
||||
- `qwen-macbook` (명시, M5 Max Qwen3.6-27B; "This device" 토글 on 시만)
|
||||
- `claude-cloud` (명시, 503 scaffold; VITE_ENABLE_CLOUD_BACKEND_DEV=true 시만)
|
||||
- "This device" 토글: localStorage `ds_device_self_label = 'macbook-m5-max' | null`.
|
||||
source IP 의존 0 (PR-0 round 2 발견: caddy 2-hop + X-Forwarded-For 미설정 →
|
||||
DS 가 보는 source IP = LAN gateway, 신뢰 불가).
|
||||
- 503 + error_reason ∈ {macbook_unavailable, provider_not_configured, router_*}
|
||||
시 자동 fallback 금지. UI 가 친절한 메시지 + "Mac mini default 로 재요청" 버튼.
|
||||
- legacy URL `?backend=default|gemma-macmini` 는 그대로 받아서 mac-mini-default 와 동등 처리.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { api, type ApiError } from '$lib/api';
|
||||
import { addToast } from '$lib/stores/toast';
|
||||
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import AskAnswer from '$lib/components/ask/AskAnswer.svelte';
|
||||
import AskEvidence from '$lib/components/ask/AskEvidence.svelte';
|
||||
import AskResults from '$lib/components/ask/AskResults.svelte';
|
||||
import { Sparkles, Search, AlertCircle } from 'lucide-svelte';
|
||||
import type { AskResponse } from '$lib/types/ask';
|
||||
|
||||
type BackendChoice = 'auto' | 'mac-mini-default' | 'qwen-macbook' | 'claude-cloud';
|
||||
|
||||
function parseBackend(raw: string | null): BackendChoice {
|
||||
if (raw === 'qwen-macbook') return 'qwen-macbook';
|
||||
if (raw === 'mac-mini-default') return 'mac-mini-default';
|
||||
if (raw === 'claude-cloud') return 'claude-cloud';
|
||||
// legacy aliases (?backend=default | gemma-macmini) → mac-mini-default 와 동등
|
||||
if (raw === 'default' || raw === 'gemma-macmini') return 'mac-mini-default';
|
||||
return 'auto';
|
||||
}
|
||||
|
||||
// Build-time feature flag — Claude Cloud UI 노출 여부 (default false).
|
||||
// VITE_ENABLE_CLOUD_BACKEND_DEV=true npm run build 시만 활성. 운영 토글 X
|
||||
// (build-time 한계). DS runtime feature flag API migrate 는 후속 PR.
|
||||
const CLOUD_DEV_ENABLED = import.meta.env.VITE_ENABLE_CLOUD_BACKEND_DEV === 'true';
|
||||
const DEVICE_LABEL_KEY = 'ds_device_self_label';
|
||||
const DEVICE_LABEL_M5_MAX = 'macbook-m5-max';
|
||||
|
||||
// ── state ───────────────────────────────────────────
|
||||
let queryInput = $state('');
|
||||
let selectedBackend = $state<BackendChoice>('auto');
|
||||
let data = $state<AskResponse | null>(null);
|
||||
let loading = $state(false);
|
||||
let backendUnavailable = $state(false);
|
||||
let backendUnavailableMessage = $state('');
|
||||
// "I am on MacBook M5 Max" 토글. mount 시 localStorage 에서 복원.
|
||||
let isMacBookM5Max = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
const stored = window.localStorage.getItem(DEVICE_LABEL_KEY);
|
||||
isMacBookM5Max = stored === DEVICE_LABEL_M5_MAX;
|
||||
});
|
||||
|
||||
function toggleMacBookM5Max() {
|
||||
isMacBookM5Max = !isMacBookM5Max;
|
||||
if (typeof window === 'undefined') return;
|
||||
if (isMacBookM5Max) {
|
||||
window.localStorage.setItem(DEVICE_LABEL_KEY, DEVICE_LABEL_M5_MAX);
|
||||
} else {
|
||||
window.localStorage.removeItem(DEVICE_LABEL_KEY);
|
||||
// 토글 off 시 qwen-macbook 선택돼 있었으면 auto 로 복귀 (선택권 박탈 X 명시 신호).
|
||||
if (selectedBackend === 'qwen-macbook') {
|
||||
selectedBackend = 'auto';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 중복 호출 방지 가드 (hydration + reactive trigger 이중 발동 방지)
|
||||
let lastKey = '';
|
||||
|
||||
// citation scroll 연동: Answer 가 [n] 클릭 → Evidence 카드로 이동 + highlight
|
||||
const citationNodes = new Map<number, HTMLElement>();
|
||||
let activeCitation = $state<number | null>(null);
|
||||
|
||||
function registerCitation(n: number, node: HTMLElement) {
|
||||
citationNodes.set(n, node);
|
||||
return {
|
||||
destroy() {
|
||||
citationNodes.delete(n);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function scrollToCitation(n: number) {
|
||||
activeCitation = n;
|
||||
const node = citationNodes.get(n);
|
||||
node?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
|
||||
// ── URL 빌더: backend !== 'auto' 일 때만 param 추가 ─────
|
||||
function buildAskUrl(q: string, backend: BackendChoice): string {
|
||||
const params = new URLSearchParams({ q });
|
||||
if (backend !== 'auto') params.set('backend', backend);
|
||||
return `/ask?${params.toString()}`;
|
||||
}
|
||||
|
||||
// ── submit (URL-driven, back 자동) ──────────────────
|
||||
function submit() {
|
||||
const q = queryInput.trim();
|
||||
if (!q) return;
|
||||
goto(buildAskUrl(q, selectedBackend));
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter' && !e.isComposing) {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
}
|
||||
}
|
||||
|
||||
// 503 후 "Mac mini default 로 재요청" — auto 로 reset (param 명시 제거).
|
||||
function retryWithMacMiniDefault() {
|
||||
const q = $page.url.searchParams.get('q') ?? queryInput.trim();
|
||||
if (!q) return;
|
||||
goto(`/ask?q=${encodeURIComponent(q)}`);
|
||||
}
|
||||
|
||||
// PR-3 of routing policy — error_reason → 친절 메시지 매핑.
|
||||
// silent fallback 금지 ([[feedback_no_silent_fallback_explicit_opt_in]]):
|
||||
// 모든 503 case 는 명시 표시, 다른 backend 자동 재호출 X.
|
||||
function friendlyErrorMessage(reason: string | undefined, detail: string): string {
|
||||
switch (reason) {
|
||||
case 'macbook_unavailable':
|
||||
return 'MacBook M5 Max 가 응답하지 않습니다. 깨우거나 (launchctl start) Mac mini default 로 다시 요청하세요.';
|
||||
case 'provider_not_configured':
|
||||
return 'Claude Cloud 백엔드는 아직 구성되지 않았습니다 (2026-06-15 이후 별 PR 활성화 예정).';
|
||||
default:
|
||||
if (reason && reason.startsWith('router_')) {
|
||||
return `라우터 호출 실패 (${reason}). Mac mini default 로 다시 요청하거나 잠시 후 재시도하세요.`;
|
||||
}
|
||||
if (reason && reason.startsWith('upstream_')) {
|
||||
return `Upstream backend 가 응답하지 않습니다 (${reason}). 잠시 후 재시도하세요.`;
|
||||
}
|
||||
return detail || '답변 생성 실패';
|
||||
}
|
||||
}
|
||||
|
||||
// ── API 호출 ───────────────────────────────────────
|
||||
async function runAsk(q: string, backend: BackendChoice) {
|
||||
loading = true;
|
||||
activeCitation = null;
|
||||
backendUnavailable = false;
|
||||
backendUnavailableMessage = '';
|
||||
const path = backend !== 'auto'
|
||||
? `/search/ask?q=${encodeURIComponent(q)}&backend=${encodeURIComponent(backend)}&limit=10`
|
||||
: `/search/ask?q=${encodeURIComponent(q)}&limit=10`;
|
||||
try {
|
||||
data = await api<AskResponse>(path);
|
||||
} catch (err) {
|
||||
const apiErr = err as ApiError;
|
||||
if (apiErr.status === 503) {
|
||||
backendUnavailable = true;
|
||||
backendUnavailableMessage = friendlyErrorMessage(apiErr.errorReason, apiErr.detail);
|
||||
data = null;
|
||||
} else {
|
||||
addToast('error', apiErr.detail || '답변 생성 실패');
|
||||
data = null;
|
||||
}
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── URL → runAsk (중복 가드) ────────────────────────
|
||||
$effect(() => {
|
||||
const q = $page.url.searchParams.get('q') ?? '';
|
||||
const backend = parseBackend($page.url.searchParams.get('backend'));
|
||||
queryInput = q;
|
||||
selectedBackend = backend;
|
||||
if (!q) {
|
||||
data = null;
|
||||
loading = false;
|
||||
backendUnavailable = false;
|
||||
lastKey = '';
|
||||
return;
|
||||
}
|
||||
const key = `${q}|${backend}`;
|
||||
if (key === lastKey) return;
|
||||
lastKey = key;
|
||||
runAsk(q, backend);
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>질문 - PKM</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="h-full overflow-auto">
|
||||
<!-- 상단 검색바 (sticky) -->
|
||||
<div class="sticky top-0 z-10 bg-bg/80 backdrop-blur border-b border-default px-4 py-3">
|
||||
<div class="flex flex-wrap items-center gap-2 max-w-[1680px] mx-auto">
|
||||
<div class="relative flex-1 min-w-0">
|
||||
<Search
|
||||
size={14}
|
||||
class="absolute left-3 top-1/2 -translate-y-1/2 text-dim pointer-events-none"
|
||||
/>
|
||||
<input
|
||||
data-search-input
|
||||
type="text"
|
||||
bind:value={queryInput}
|
||||
onkeydown={handleKeydown}
|
||||
placeholder="질문을 입력하세요 (/ 키로 포커스)"
|
||||
class="w-full pl-9 pr-3 py-2 bg-surface border border-default rounded-lg text-text text-sm focus:border-accent outline-none"
|
||||
/>
|
||||
</div>
|
||||
<label
|
||||
class="flex items-center gap-1.5 text-xs text-dim cursor-pointer select-none"
|
||||
title="이 디바이스가 MacBook M5 Max 인 경우 체크 — This device (qwen-macbook) 옵션 활성화됩니다. localStorage 저장."
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isMacBookM5Max}
|
||||
onchange={toggleMacBookM5Max}
|
||||
class="accent-accent"
|
||||
/>
|
||||
<span>This is M5 Max</span>
|
||||
</label>
|
||||
<select
|
||||
bind:value={selectedBackend}
|
||||
title="Backend 선택 — silent fallback 0 정책 (선택한 backend 만 시도, 실패 시 503)."
|
||||
class="py-2 px-2 bg-surface border border-default rounded-lg text-text text-xs focus:border-accent outline-none min-w-0 max-w-[42vw] truncate"
|
||||
>
|
||||
<option value="auto">Auto (router)</option>
|
||||
<option value="mac-mini-default">Mac mini (default)</option>
|
||||
<option
|
||||
value="qwen-macbook"
|
||||
disabled={!isMacBookM5Max}
|
||||
title={isMacBookM5Max
|
||||
? 'MacBook M5 Max Qwen3.6-27B (직접 호출)'
|
||||
: 'Check "This is M5 Max" toggle to enable'}
|
||||
>
|
||||
{isMacBookM5Max ? 'This device (Qwen MacBook)' : 'This device (unavailable)'}
|
||||
</option>
|
||||
<option
|
||||
value="claude-cloud"
|
||||
disabled={!CLOUD_DEV_ENABLED}
|
||||
title={CLOUD_DEV_ENABLED
|
||||
? 'Claude Cloud (dev mode — returns 503 until activation PR)'
|
||||
: 'Cloud backend not configured yet'}
|
||||
>
|
||||
Claude Cloud {CLOUD_DEV_ENABLED ? '(dev)' : '(unavailable)'}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 본문 -->
|
||||
<div class="max-w-[1680px] mx-auto p-4">
|
||||
{#if backendUnavailable}
|
||||
<div class="py-16">
|
||||
<EmptyState
|
||||
icon={AlertCircle}
|
||||
title="Backend 가 응답하지 않습니다"
|
||||
description={backendUnavailableMessage}
|
||||
>
|
||||
<Button variant="primary" size="sm" onclick={retryWithMacMiniDefault}>
|
||||
Mac mini (default) 로 재요청
|
||||
</Button>
|
||||
</EmptyState>
|
||||
</div>
|
||||
{:else if !queryInput && !loading && !data}
|
||||
<div class="py-16">
|
||||
<EmptyState
|
||||
icon={Sparkles}
|
||||
title="근거 기반 답변을 받아보세요"
|
||||
description="질문을 입력하면 문서에서 근거를 찾아 인용 기반 답변을 생성합니다."
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid gap-4 lg:grid-cols-[1.2fr_0.9fr] items-start">
|
||||
<!-- 좌: Answer + Results -->
|
||||
<div class="flex flex-col gap-4 min-w-0">
|
||||
<AskAnswer {data} {loading} onCitationClick={scrollToCitation} />
|
||||
<AskResults {data} {loading} />
|
||||
</div>
|
||||
|
||||
<!-- 우: Evidence (lg 이상 sticky) -->
|
||||
<div class="lg:sticky lg:top-20 lg:self-start min-w-0">
|
||||
<AskEvidence
|
||||
{data}
|
||||
{loading}
|
||||
{activeCitation}
|
||||
{registerCitation}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,281 @@
|
||||
<script>
|
||||
// ASME/법령 절-KB — 코드북·공부 리더 (r2). parent 표준/법령을 한 권의 책처럼.
|
||||
// 좌 인덱스(Part/章→절/조) · 중 본문(MarkdownDoc=공식·표·이미지) · breadcrumb·이전다음·양방향 백링크.
|
||||
import { onMount, tick } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { api } from '$lib/api';
|
||||
import MarkdownDoc from '$lib/components/MarkdownDoc.svelte';
|
||||
|
||||
let parentId = $state(null);
|
||||
let parentTitle = $state('');
|
||||
let clauses = $state([]);
|
||||
let selectedId = $state(null);
|
||||
let clauseDoc = $state(null);
|
||||
let links = $state(null);
|
||||
let expanded = $state({});
|
||||
let loading = $state(false);
|
||||
let q = $state('');
|
||||
|
||||
// 공부도구 (노트/형광펜/암기카드) — clause_study
|
||||
let studyItems = $state([]);
|
||||
let studyOpen = $state(false);
|
||||
let noteDraft = $state('');
|
||||
const KLABEL = { note: '노트', highlight: '형광펜', card: '암기카드' };
|
||||
async function loadStudy(id) {
|
||||
try { const r = await api(`/documents/${id}/study`); studyItems = r?.items ?? []; }
|
||||
catch { studyItems = []; }
|
||||
}
|
||||
async function addStudy(kind, payload) {
|
||||
if (!selectedId) return;
|
||||
try { await api(`/documents/${selectedId}/study`, { method: 'POST', body: JSON.stringify({ kind, payload }) }); await loadStudy(selectedId); }
|
||||
catch (e) { console.warn(e); }
|
||||
}
|
||||
function selText() { return (typeof window !== 'undefined' && window.getSelection ? window.getSelection().toString() : '').trim(); }
|
||||
function addNote() { const t = noteDraft.trim(); if (!t) return; addStudy('note', { text: t }); noteDraft = ''; }
|
||||
function addHighlight() { const s = selText(); if (!s) { studyOpen = true; alert('본문에서 형광펜 칠할 부분을 먼저 드래그하세요'); return; } addStudy('highlight', { text: s }); studyOpen = true; }
|
||||
function addCard() {
|
||||
const s = selText();
|
||||
const code = links?.clause_code ?? selMeta?.clause_code ?? '';
|
||||
addStudy('card', { cue: `${code} ${strip(clauseDoc?.title, code)}`.trim(), fact: s || (clauseDoc?.md_content ?? clauseDoc?.extracted_text ?? '').replace(/[#*>]/g, '').slice(0, 280).trim() });
|
||||
studyOpen = true;
|
||||
}
|
||||
async function delStudy(id) {
|
||||
try { await api(`/documents/${selectedId}/study/${id}`, { method: 'DELETE' }); await loadStudy(selectedId); } catch {}
|
||||
}
|
||||
|
||||
let parts = $derived.by(() => {
|
||||
const out = [], idx = {};
|
||||
for (const c of clauses) {
|
||||
const p = c.clause_part || '·';
|
||||
if (!(p in idx)) { idx[p] = out.length; out.push({ part: p, items: [] }); }
|
||||
out[idx[p]].items.push(c);
|
||||
}
|
||||
return out;
|
||||
});
|
||||
let visibleParts = $derived.by(() => {
|
||||
const term = q.trim().toLowerCase();
|
||||
if (!term) return parts;
|
||||
return parts.map(g => ({ part: g.part, items: g.items.filter(c =>
|
||||
(c.clause_code || '').toLowerCase().includes(term) || (c.title || '').toLowerCase().includes(term)) }))
|
||||
.filter(g => g.items.length);
|
||||
});
|
||||
let selMeta = $derived(clauses.find((c) => c.id === selectedId) || null);
|
||||
const strip = (t, c) => (t || '').replace(c || '', '').replace(/^[(\s)]+|[(\s)]+$/g, '').trim();
|
||||
|
||||
async function loadBook() {
|
||||
const r = await api(`/documents/${parentId}/clauses`);
|
||||
parentTitle = r?.parent_title ?? '';
|
||||
clauses = r?.clauses ?? [];
|
||||
const e = {};
|
||||
for (const c of clauses) e[c.clause_part || '·'] = true;
|
||||
expanded = e;
|
||||
}
|
||||
async function loadClause(id) {
|
||||
if (!id) return;
|
||||
loading = true;
|
||||
selectedId = id;
|
||||
try {
|
||||
const [d, l] = await Promise.all([api(`/documents/${id}`), api(`/documents/${id}/backlinks`)]);
|
||||
clauseDoc = d; links = l;
|
||||
loadStudy(id);
|
||||
const sel = clauses.find((c) => c.id === id);
|
||||
if (sel) expanded = { ...expanded, [sel.clause_part || '·']: true };
|
||||
goto(`/book/${parentId}?c=${id}`, { replaceState: true, keepFocus: true, noScroll: true });
|
||||
await tick(); window.scrollTo({ top: 0 });
|
||||
} finally { loading = false; }
|
||||
}
|
||||
onMount(async () => {
|
||||
parentId = Number($page.params.id);
|
||||
await loadBook();
|
||||
const c = Number($page.url.searchParams.get('c'));
|
||||
await loadClause(c && clauses.find((x) => x.id === c) ? c : clauses[0]?.id);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="book">
|
||||
<!-- top bar -->
|
||||
<div class="bar">
|
||||
<span class="brand">절-KB</span>
|
||||
<span class="crumbs">{parentTitle} {#if selMeta}<b class="sep">›</b> {selMeta.clause_part} <b class="sep">›</b> <b>{links?.clause_code ?? selMeta.clause_code}</b>{/if}</span>
|
||||
<div class="search"><input placeholder="절·조 번호 또는 키워드" bind:value={q} /></div>
|
||||
<div class="tools"><span class="tool on">읽기</span><span class="tool">형광펜</span><span class="tool">노트</span><span class="tool">암기카드</span></div>
|
||||
</div>
|
||||
|
||||
<div class="main">
|
||||
<!-- left index -->
|
||||
<aside class="idx">
|
||||
<a class="btitle" href={`/documents/${parentId}`}>{parentTitle || '표준'}</a>
|
||||
<div class="bmeta">절 {clauses.length} · 한 권의 책처럼 탐색</div>
|
||||
{#each visibleParts as g (g.part)}
|
||||
<div class="parttab" role="button" tabindex="0" onclick={() => (expanded = { ...expanded, [g.part]: !expanded[g.part] })}>
|
||||
<span class="bar2"></span><span class="pname">{g.part}</span><span class="ct">{g.items.length}</span>
|
||||
</div>
|
||||
{#if expanded[g.part] || q.trim()}
|
||||
{#each g.items as c (c.id)}
|
||||
<div class="ci" class:on={c.id === selectedId} role="button" tabindex="0" onclick={() => loadClause(c.id)}>
|
||||
<span class="no">{c.clause_code}</span><span class="tt">{strip(c.title, c.clause_code)}</span>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
{/each}
|
||||
</aside>
|
||||
|
||||
<!-- reader -->
|
||||
<section class="read">
|
||||
<div class="col">
|
||||
{#if clauseDoc}
|
||||
<div class="studybar">
|
||||
<button class="sbtn" title="선택 형광펜" onclick={addHighlight}>▰</button>
|
||||
<button class="sbtn" class:on={studyOpen} title="노트/공부" onclick={() => (studyOpen = !studyOpen)}>✎</button>
|
||||
<button class="sbtn" title="암기카드 추가" onclick={addCard}>+</button>
|
||||
{#if studyItems.length}<span class="scount">{studyItems.length}</span>{/if}
|
||||
</div>
|
||||
<div class="kicker"><span class="pth">{selMeta?.clause_part}</span></div>
|
||||
<div class="h-no">{links?.clause_code ?? selMeta?.clause_code}</div>
|
||||
<h1 class="h-title">{strip(clauseDoc.title, links?.clause_code ?? '')}</h1>
|
||||
|
||||
<div class="flow">
|
||||
<button class="fl" disabled={!links?.prev} onclick={() => loadClause(links?.prev?.id)}>← {links?.prev?.clause_code ?? ''}</button>
|
||||
<button class="fl next" disabled={!links?.next} onclick={() => loadClause(links?.next?.id)}>{links?.next?.clause_code ?? ''} →</button>
|
||||
</div>
|
||||
|
||||
{#key clauseDoc.id}
|
||||
<div class="docbody">
|
||||
<MarkdownDoc documentId={clauseDoc.id} mdContent={clauseDoc.md_content ?? clauseDoc.extracted_text} mdStatus={null} class="prose prose-base max-w-none" />
|
||||
</div>
|
||||
{/key}
|
||||
|
||||
{#if links && (links.forward.length || links.back.length)}
|
||||
<section class="conn">
|
||||
{#if links.forward.length}
|
||||
<div><h4>이 절이 참조 <span>{links.forward.length}</span></h4>
|
||||
<div class="chiprow">{#each links.forward as f}
|
||||
{#if f.doc_id}<button class="ref" onclick={() => loadClause(f.doc_id)}>{f.code}</button>
|
||||
{:else}<span class="ref dg" title="외부/미분해">{f.code}</span>{/if}
|
||||
{/each}</div></div>
|
||||
{/if}
|
||||
{#if links.back.length}
|
||||
<div><h4>이 절을 참조 <span>{links.back.length}</span></h4>
|
||||
<div class="chiprow">{#each links.back as b}<button class="ref" onclick={() => loadClause(b.doc_id)}>{b.code}</button>{/each}</div></div>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if studyOpen}
|
||||
<section class="study">
|
||||
<div class="slab">공부 — 노트 · 형광펜 · 암기카드{#if studyItems.length} <span>{studyItems.length}</span>{/if}</div>
|
||||
<div class="noteadd">
|
||||
<textarea bind:value={noteDraft} placeholder="이 절에 노트…" rows="2"></textarea>
|
||||
<button class="nbtn" onclick={addNote}>노트 저장</button>
|
||||
</div>
|
||||
{#if studyItems.length}
|
||||
<ul class="slist">
|
||||
{#each studyItems as it (it.id)}
|
||||
<li class="sitem">
|
||||
<span class="skind k-{it.kind}">{KLABEL[it.kind] ?? it.kind}</span>
|
||||
<span class="stext">{it.payload?.text ?? it.payload?.cue ?? ''}</span>
|
||||
<button class="sdel" title="삭제" onclick={() => delStudy(it.id)}>×</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
<p class="shint">본문을 드래그한 뒤 형광펜(▰)/암기카드(+), 또는 위에 노트를 적으세요.</p>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<div class="pager">
|
||||
<button class="pg" disabled={!links?.prev} onclick={() => loadClause(links?.prev?.id)}>
|
||||
<div class="d">← 이전</div><div class="t"><span class="pno">{links?.prev?.clause_code ?? '—'}</span> {strip(links?.prev?.title, links?.prev?.clause_code)}</div></button>
|
||||
<button class="pg next" disabled={!links?.next} onclick={() => loadClause(links?.next?.id)}>
|
||||
<div class="d">다음 →</div><div class="t"><span class="pno">{links?.next?.clause_code ?? '—'}</span> {strip(links?.next?.title, links?.next?.clause_code)}</div></button>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="empty">{loading ? '불러오는 중…' : '왼쪽에서 절을 선택하세요'}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
:global(body) { background: var(--bg); }
|
||||
.book { --paper:#fbfcf9; --serif:"Iowan Old Style","Palatino Linotype","Noto Serif KR",Georgia,serif;
|
||||
display:flex; flex-direction:column; min-height:100vh; }
|
||||
.bar { display:flex; align-items:center; gap:14px; height:50px; padding:0 18px; background:var(--paper); border-bottom:1px solid var(--border); }
|
||||
.brand { font-weight:700; font-size:13.5px; color:var(--text); }
|
||||
.crumbs { color:var(--text-dim); font-size:12.5px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; max-width:46%; }
|
||||
.crumbs b { color:var(--text); font-weight:600; } .crumbs .sep { color:#c8d6c0; margin:0 5px; }
|
||||
.search { margin-left:auto; }
|
||||
.search input { width:280px; background:var(--surface); border:1px solid var(--border); border-radius:9px; padding:7px 12px; font-size:13px; color:var(--text); outline:none; }
|
||||
.search input:focus { border-color:var(--accent); }
|
||||
.tools { display:flex; gap:2px; }
|
||||
.tool { font-size:12px; color:var(--text-dim); padding:6px 10px; border-radius:8px; border:1px solid transparent; cursor:pointer; }
|
||||
.tool:hover { background:var(--surface); } .tool.on { background:#ecf0e8; border-color:var(--border); color:var(--accent-hover); font-weight:600; }
|
||||
|
||||
.main { display:flex; align-items:flex-start; flex:1; }
|
||||
.idx { width:264px; flex-shrink:0; align-self:stretch; border-right:1px solid var(--border);
|
||||
background:linear-gradient(180deg,#f6f8f3,#f1f4ec); padding:16px 10px 30px 16px; position:sticky; top:0; max-height:100vh; overflow:auto; }
|
||||
.btitle { display:block; font-family:var(--serif); font-size:15.5px; font-weight:600; color:var(--text); text-decoration:none; line-height:1.32; }
|
||||
.btitle:hover { text-decoration:underline; }
|
||||
.bmeta { font-size:11px; color:#9aa090; margin:3px 0 14px; }
|
||||
.parttab { display:flex; align-items:center; gap:8px; margin:11px 0 4px; padding:3px 4px; border-radius:6px; cursor:pointer;
|
||||
font-size:11px; font-weight:700; letter-spacing:.5px; color:var(--text-dim); text-transform:uppercase; }
|
||||
.parttab:hover { background:#fff; } .parttab .bar2 { width:3px; height:12px; border-radius:2px; background:var(--domain-engineering); }
|
||||
.parttab .pname { flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } .parttab .ct { color:#9aa090; font-weight:600; letter-spacing:0; }
|
||||
.ci { display:flex; gap:9px; align-items:baseline; padding:4px 9px; border-radius:7px; cursor:pointer; line-height:1.4; }
|
||||
.ci .no { font-family:ui-monospace,Menlo,monospace; font-size:11px; color:var(--accent); font-weight:600; min-width:52px; white-space:nowrap; }
|
||||
.ci .tt { font-size:12.5px; color:var(--text-dim); overflow:hidden; text-overflow:ellipsis; }
|
||||
.ci:hover { background:#fff; }
|
||||
.ci.on { background:#fff; box-shadow:inset 3px 0 0 var(--accent), 0 1px 2px rgba(35,41,31,.05); }
|
||||
.ci.on .no { color:var(--accent-hover); font-weight:700; } .ci.on .tt { color:var(--text); font-weight:600; }
|
||||
|
||||
.read { flex:1; min-width:0; padding:34px 40px 80px; }
|
||||
.col { max-width:680px; margin:0 auto; position:relative; }
|
||||
.studybar { position:absolute; right:-30px; top:4px; display:flex; flex-direction:column; gap:6px; }
|
||||
.sbtn { width:34px; height:34px; border-radius:9px; border:1px solid var(--border); background:var(--paper); color:var(--text-dim); font-size:13px; cursor:pointer; }
|
||||
.sbtn:hover { background:var(--surface); color:var(--accent-hover); }
|
||||
.kicker { margin-bottom:5px; } .kicker .pth { font-size:11.5px; color:#9aa090; font-weight:600; letter-spacing:.3px; }
|
||||
.h-no { font-family:ui-monospace,Menlo,monospace; font-size:13px; color:var(--accent); font-weight:700; letter-spacing:.5px; }
|
||||
.h-title { font-family:var(--serif); font-size:26px; line-height:1.24; font-weight:600; margin:2px 0 14px; letter-spacing:-.2px; color:var(--text); }
|
||||
.flow { display:flex; justify-content:space-between; gap:8px; margin-bottom:18px; }
|
||||
.flow .fl { font-size:11.5px; color:var(--text-dim); background:var(--surface); border:1px solid var(--border); border-radius:8px; padding:5px 11px; cursor:pointer; }
|
||||
.flow .fl:hover:not(:disabled) { background:#ecf0e8; } .flow .fl:disabled { opacity:.35; cursor:default; }
|
||||
.docbody { font-size:15.5px; }
|
||||
.docbody :global(.prose) { color:#2a3024; line-height:1.78; }
|
||||
.docbody :global(.prose h1), .docbody :global(.prose h2), .docbody :global(.prose h3) { font-family:var(--serif); }
|
||||
.docbody :global(a) { color:var(--accent-hover); }
|
||||
.conn { margin-top:34px; padding-top:18px; border-top:1px solid var(--border); display:grid; grid-template-columns:1fr 1fr; gap:22px; }
|
||||
.conn h4 { font-size:11px; font-weight:700; color:var(--text-dim); letter-spacing:.4px; margin:0 0 9px; } .conn h4 span { color:#9aa090; font-weight:500; }
|
||||
.chiprow { display:flex; flex-wrap:wrap; gap:5px; }
|
||||
.ref { font-family:ui-monospace,Menlo,monospace; font-size:11.5px; font-weight:600; color:var(--accent-hover); background:#eef4ec; border:1px solid #d9e6d8; border-radius:6px; padding:2px 8px; cursor:pointer; }
|
||||
.ref:hover { background:#e2efe0; } .ref.dg { color:#9aa090; background:var(--surface); border-color:var(--border); cursor:default; }
|
||||
.pager { display:flex; gap:10px; margin-top:30px; }
|
||||
.pg { flex:1; text-align:left; border:1px solid var(--border); border-radius:11px; padding:11px 14px; background:var(--paper); cursor:pointer; }
|
||||
.pg.next { text-align:right; } .pg:hover:not(:disabled) { border-color:#cfd7c6; background:#fff; } .pg:disabled { opacity:.4; cursor:default; }
|
||||
.pg .d { font-size:10.5px; color:#9aa090; } .pg .t { font-size:12.5px; color:var(--text-dim); font-weight:600; margin-top:1px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.pg .pno { font-family:ui-monospace,Menlo,monospace; color:var(--accent); }
|
||||
.empty { color:#9aa090; text-align:center; padding:80px 0; }
|
||||
.sbtn.on { background:#ecf0e8; color:var(--accent-hover,#3d7256); border-color:var(--border); }
|
||||
.scount { font-size:9px; font-weight:700; color:#fff; background:var(--accent,#4f8a6b); border-radius:8px; padding:1px 5px; text-align:center; }
|
||||
.study { margin-top:24px; padding:14px; border:1px solid var(--border); border-radius:12px; background:var(--surface); }
|
||||
.slab { font-size:11px; font-weight:700; color:var(--text-dim); letter-spacing:.3px; margin-bottom:9px; }
|
||||
.slab span { color:var(--accent-hover,#3d7256); }
|
||||
.noteadd { display:flex; gap:8px; align-items:flex-end; margin-bottom:10px; }
|
||||
.noteadd textarea { flex:1; resize:vertical; border:1px solid var(--border); border-radius:8px; padding:7px 9px; font-size:12.5px; font-family:inherit; color:var(--text); background:var(--paper,#fbfcf9); outline:none; }
|
||||
.noteadd textarea:focus { border-color:var(--accent); }
|
||||
.nbtn { flex-shrink:0; font-size:12px; color:#fff; background:var(--accent,#4f8a6b); border:0; border-radius:8px; padding:8px 12px; cursor:pointer; }
|
||||
.nbtn:hover { background:var(--accent-hover,#3d7256); }
|
||||
.slist { list-style:none; margin:0; padding:0; display:flex; flex-direction:column; gap:5px; }
|
||||
.sitem { display:flex; align-items:baseline; gap:8px; padding:6px 8px; border-radius:8px; background:var(--paper,#fbfcf9); border:1px solid var(--border); }
|
||||
.skind { flex-shrink:0; font-size:9.5px; font-weight:700; border-radius:4px; padding:1px 6px; }
|
||||
.k-note { color:#3d7256; background:#e3efe2; border:1px solid #cfe3cd; }
|
||||
.k-highlight { color:#8a6306; background:#faf3e2; border:1px solid #ecdca3; }
|
||||
.k-card { color:#1d4ed8; background:#eef4fc; border:1px solid #d7e4f7; }
|
||||
.stext { flex:1; font-size:12px; line-height:1.5; color:var(--text); white-space:pre-wrap; word-break:break-word; }
|
||||
.sdel { flex-shrink:0; background:none; border:0; color:var(--faint,#9aa090); cursor:pointer; font-size:14px; }
|
||||
.sdel:hover { color:var(--error,#c0392b); }
|
||||
.shint { font-size:11.5px; color:var(--faint,#9aa090); margin:0; }
|
||||
@media(max-width:820px){ .idx{display:none} .read{padding:24px 18px} .conn{grid-template-columns:1fr} .studybar{position:static;flex-direction:row} .crumbs{max-width:30%} .search input{width:150px} }
|
||||
</style>
|
||||
@@ -8,7 +8,7 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import { api } from '$lib/api';
|
||||
import { addToast } from '$lib/stores/toast';
|
||||
import { X, Plus, Trash2, Tag, FolderTree, Sparkles, ArrowUpDown } from 'lucide-svelte';
|
||||
import { X, Plus, Trash2, Tag, FolderTree, ArrowUpDown } from 'lucide-svelte';
|
||||
import MarkdownStatusBadge from '$lib/components/MarkdownStatusBadge.svelte';
|
||||
import { isMdStatusVisible } from '$lib/utils/mdStatus';
|
||||
import UploadDropzone from '$lib/components/UploadDropzone.svelte';
|
||||
@@ -22,9 +22,7 @@
|
||||
import { useIsXl } from '$lib/composables/useMedia.svelte';
|
||||
import { useListKeyboardNav } from '$lib/composables/useListKeyboardNav.svelte';
|
||||
import { pLimit } from '$lib/utils/pLimit';
|
||||
import { isQuestion } from '$lib/utils/isQuestion';
|
||||
import { domainBgClass, domainLabel } from '$lib/utils/domainSlug';
|
||||
import AskAnswerCard from '$lib/components/AskAnswerCard.svelte';
|
||||
|
||||
const FORMATS = ['pdf', 'hwp', 'hwpx', 'md', 'docx', 'xlsx', 'png', 'jpg'];
|
||||
|
||||
@@ -48,30 +46,6 @@
|
||||
let searchResults = $state(null);
|
||||
let selectedDoc = $state(null);
|
||||
|
||||
// 이드 답변 상태 (질문형 검색)
|
||||
let askData = $state(null);
|
||||
let askLoading = $state(false);
|
||||
let askError = $state(false);
|
||||
let askDismissed = $state(false);
|
||||
|
||||
async function askSearch(q) {
|
||||
askLoading = true; askError = false; askData = null;
|
||||
try {
|
||||
askData = await api(`/search/ask?q=${encodeURIComponent(q)}&limit=10`);
|
||||
} catch {
|
||||
askError = true; askData = null;
|
||||
} finally {
|
||||
askLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
let showAskCard = $derived(
|
||||
!askDismissed && (
|
||||
askLoading ||
|
||||
(askData && !askData.refused && askData.ai_answer && askData.synthesis_status === 'completed')
|
||||
)
|
||||
);
|
||||
|
||||
// 인스펙터(우측) 토글 — xl+ inline, < xl Drawer.
|
||||
const isXl = useIsXl();
|
||||
let inspectorOpen = $state(
|
||||
@@ -145,7 +119,6 @@
|
||||
selectedDoc = null;
|
||||
selectedIds = new Set();
|
||||
kbIndex = 0;
|
||||
askData = null; askLoading = false; askError = false; askDismissed = false;
|
||||
if (ui.isDrawerOpen('meta')) ui.closeDrawer();
|
||||
if (urlQ) doSearch(urlQ, urlMode);
|
||||
else loadDocuments();
|
||||
@@ -191,7 +164,6 @@
|
||||
|
||||
async function doSearch(q, mode) {
|
||||
loading = true;
|
||||
if (isQuestion(q)) askSearch(q);
|
||||
try {
|
||||
const data = await api(`/search/?q=${encodeURIComponent(q)}&mode=${mode}&limit=50`);
|
||||
searchResults = data.results;
|
||||
@@ -406,13 +378,6 @@
|
||||
<option value="trgm">부분매칭</option>
|
||||
<option value="vector">의미검색</option>
|
||||
</select>
|
||||
{#if searchQuery.trim()}
|
||||
<a
|
||||
href={`/ask?q=${encodeURIComponent(searchQuery.trim())}`}
|
||||
class="flex items-center px-2 py-1.5 rounded-lg border border-default text-dim hover:text-accent hover:border-accent transition-colors"
|
||||
title="이 쿼리로 AI 답변"
|
||||
><Sparkles size={14} /></a>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- 필터 칩 row -->
|
||||
@@ -483,19 +448,6 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- AI 답변 (질문형 검색) — 목록 상단 고정, 아래로 목록 스크롤 -->
|
||||
{#if showAskCard}
|
||||
<div class="px-3 py-2 shrink-0 border-b border-default max-h-[55vh] overflow-y-auto">
|
||||
<AskAnswerCard
|
||||
data={askData}
|
||||
loading={askLoading}
|
||||
error={askError}
|
||||
onCitationClick={(docId) => goto(`/documents/${docId}`)}
|
||||
onDismiss={() => { askDismissed = true; }}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- 선택 toolbar -->
|
||||
{#if selectionCount > 0}
|
||||
<div class="flex flex-wrap items-center gap-2 px-3 py-2 shrink-0 bg-accent/10 border-y border-accent/30">
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
import Skeleton from '$lib/components/ui/Skeleton.svelte';
|
||||
import HandwriteCanvas from '$lib/components/HandwriteCanvas.svelte';
|
||||
import MarkdownDoc from '$lib/components/MarkdownDoc.svelte';
|
||||
import RelatedDocs from '$lib/components/RelatedDocs.svelte';
|
||||
import { renderDocMarkdown } from '$lib/utils/docMarkdown';
|
||||
import MarkdownStatusBadge from '$lib/components/MarkdownStatusBadge.svelte';
|
||||
import NoteEditor from '$lib/components/editors/NoteEditor.svelte';
|
||||
@@ -321,6 +322,7 @@
|
||||
<!-- ════ 우 슬림 레일 (시안 카드 스타일) ════ -->
|
||||
{#snippet rail()}
|
||||
<div style="display:flex;flex-direction:column;gap:11px;font-size:14px;">
|
||||
<RelatedDocs documentId={doc.id} />
|
||||
{#if doc.ai_tldr || doc.ai_summary}
|
||||
<div style="background:#f4f7f1;border:1px solid #dde3d6;border-radius:14px;padding:13px;">
|
||||
<div style="font-size:10.5px;font-weight:700;color:#697061;letter-spacing:.4px;margin-bottom:7px;">TL;DR</div>
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<script>
|
||||
// 안전 자료실 (safety-library-1 Phase 3) — 재해/법령·지침/서적·표준·매뉴얼 3탭.
|
||||
import { page } from '$app/stores';
|
||||
|
||||
const TABS = [
|
||||
{ href: '/safety/incidents', label: '재해사례' },
|
||||
{ href: '/safety/laws', label: '법령·지침' },
|
||||
{ href: '/safety/materials', label: '서적·표준·매뉴얼' },
|
||||
];
|
||||
</script>
|
||||
|
||||
<div class="max-w-5xl mx-auto px-4 py-5 flex flex-col gap-4">
|
||||
<header>
|
||||
<h1 class="text-lg font-bold text-text">안전 자료실</h1>
|
||||
<p class="text-xs text-dim mt-0.5">재해사례·법령·지침·표준 — 자료유형(material_type) 축 기반</p>
|
||||
</header>
|
||||
|
||||
<nav class="flex gap-1 border-b border-default" aria-label="안전 자료실 탭">
|
||||
{#each TABS as tab}
|
||||
<a
|
||||
href={tab.href}
|
||||
aria-current={$page.url.pathname === tab.href ? 'page' : undefined}
|
||||
class="px-3 py-2 text-sm font-medium border-b-2 -mb-px transition-colors
|
||||
{$page.url.pathname === tab.href
|
||||
? 'border-accent text-accent'
|
||||
: 'border-transparent text-dim hover:text-text'}"
|
||||
>
|
||||
{tab.label}
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
<slot />
|
||||
</div>
|
||||
@@ -0,0 +1,9 @@
|
||||
<script>
|
||||
// /safety 진입 = 재해 탭 redirect (plan: +page=재해 탭 redirect)
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
onMount(() => {
|
||||
goto('/safety/incidents', { replaceState: true });
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,75 @@
|
||||
<script>
|
||||
// 안전 자료실 공용 목록 — material_type + jurisdiction 필터로 GET /documents/ 조회.
|
||||
// C-1 계약: material_type 지정 = 기본 exclude(news·law_monitor·note) 해제 (documents.py list_documents).
|
||||
import { api } from '$lib/api';
|
||||
import { addToast } from '$lib/stores/toast';
|
||||
import DocumentCard from '$lib/components/DocumentCard.svelte';
|
||||
|
||||
let { materialType, jurisdiction = '' } = $props();
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
let docs = $state([]);
|
||||
let total = $state(0);
|
||||
let nextPage = $state(1);
|
||||
let loading = $state(false);
|
||||
|
||||
async function load(reset = false) {
|
||||
loading = true;
|
||||
const pageToLoad = reset ? 1 : nextPage;
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
params.set('material_type', materialType);
|
||||
if (jurisdiction) params.set('jurisdiction', jurisdiction);
|
||||
params.set('page', String(pageToLoad));
|
||||
params.set('page_size', String(PAGE_SIZE));
|
||||
const result = await api(`/documents/?${params}`);
|
||||
docs = reset ? result.items : [...docs, ...result.items];
|
||||
total = result.total;
|
||||
nextPage = pageToLoad + 1;
|
||||
} catch {
|
||||
addToast('error', '안전 자료 로딩 실패');
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
// 필터 변경 시 1페이지부터 재조회 (materialType/jurisdiction 읽기 = 반응 트리거)
|
||||
void materialType;
|
||||
void jurisdiction;
|
||||
docs = [];
|
||||
load(true);
|
||||
});
|
||||
|
||||
let hasMore = $derived(docs.length < total);
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
{#if !loading || docs.length > 0}
|
||||
<p class="text-xs text-dim tabular-nums">총 {total.toLocaleString()}건</p>
|
||||
{/if}
|
||||
|
||||
{#if docs.length > 0}
|
||||
<div class="flex flex-col gap-2">
|
||||
{#each docs as doc (doc.id)}
|
||||
<DocumentCard {doc} />
|
||||
{/each}
|
||||
</div>
|
||||
{:else if !loading}
|
||||
<div class="py-12 text-center text-sm text-dim">
|
||||
해당 조건의 자료가 없습니다.
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if loading}
|
||||
<div class="py-6 text-center text-sm text-dim">불러오는 중…</div>
|
||||
{:else if hasMore}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => load(false)}
|
||||
class="self-center px-4 py-1.5 rounded-md text-sm text-dim border border-default hover:bg-surface hover:text-text transition-colors"
|
||||
>
|
||||
더 보기 ({docs.length}/{total.toLocaleString()})
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,29 @@
|
||||
<script>
|
||||
// 재해사례 탭 — material_type=incident (KOSHA 사고사망·재해사례·CSB 등).
|
||||
// 케이스 그룹핑(boardno 본문+첨부 1카드)은 API 확장 필요라 후속(DS freeze 하 백엔드 무변경).
|
||||
import SafetyDocList from '../SafetyDocList.svelte';
|
||||
|
||||
const JURISDICTIONS = [
|
||||
{ value: '', label: '전체' },
|
||||
{ value: 'KR', label: 'KR' },
|
||||
{ value: 'US', label: 'US' },
|
||||
];
|
||||
let jurisdiction = $state('');
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex items-center gap-1.5" role="group" aria-label="관할 필터">
|
||||
{#each JURISDICTIONS as j}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (jurisdiction = j.value)}
|
||||
class="px-2.5 py-1 rounded-full text-xs font-medium transition-colors
|
||||
{jurisdiction === j.value ? 'bg-accent/15 text-accent' : 'text-dim hover:bg-surface hover:text-text'}"
|
||||
>
|
||||
{j.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<SafetyDocList materialType="incident" {jurisdiction} />
|
||||
</div>
|
||||
@@ -0,0 +1,48 @@
|
||||
<script>
|
||||
// 법령·지침 탭 — 법령(law, 버전체인 current 만 코퍼스 노출) / 지침(guide, KOSHA GUIDE 등).
|
||||
// 법령 기본 관할 = KR (plan: country 누락 = KR 정규화). version_status 뱃지는 API 확장 후속.
|
||||
import SafetyDocList from '../SafetyDocList.svelte';
|
||||
|
||||
const KINDS = [
|
||||
{ value: 'law', label: '법령' },
|
||||
{ value: 'guide', label: '지침' },
|
||||
];
|
||||
const JURISDICTIONS = [
|
||||
{ value: 'KR', label: 'KR' },
|
||||
{ value: 'US', label: 'US' },
|
||||
{ value: '', label: '전체' },
|
||||
];
|
||||
let kind = $state('law');
|
||||
let jurisdiction = $state('KR');
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex items-center justify-between flex-wrap gap-2">
|
||||
<div class="flex items-center gap-1" role="group" aria-label="자료유형">
|
||||
{#each KINDS as k}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (kind = k.value)}
|
||||
class="px-3 py-1 rounded-md text-sm font-medium transition-colors
|
||||
{kind === k.value ? 'bg-accent/15 text-accent' : 'text-dim hover:bg-surface hover:text-text'}"
|
||||
>
|
||||
{k.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5" role="group" aria-label="관할 필터">
|
||||
{#each JURISDICTIONS as j}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (jurisdiction = j.value)}
|
||||
class="px-2.5 py-1 rounded-full text-xs font-medium transition-colors
|
||||
{jurisdiction === j.value ? 'bg-accent/15 text-accent' : 'text-dim hover:bg-surface hover:text-text'}"
|
||||
>
|
||||
{j.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SafetyDocList materialType={kind} {jurisdiction} />
|
||||
</div>
|
||||
@@ -0,0 +1,29 @@
|
||||
<script>
|
||||
// 서적·표준·매뉴얼 탭 — 필터 프리셋(전용 뷰는 50건+ 게이트 뒤, plan Phase 3).
|
||||
import SafetyDocList from '../SafetyDocList.svelte';
|
||||
|
||||
const KINDS = [
|
||||
{ value: 'standard', label: '표준 (NB 등)' },
|
||||
{ value: 'book', label: '서적' },
|
||||
{ value: 'manual', label: '매뉴얼' },
|
||||
{ value: 'paper', label: '논문' },
|
||||
];
|
||||
let kind = $state('standard');
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex items-center gap-1" role="group" aria-label="자료유형">
|
||||
{#each KINDS as k}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (kind = k.value)}
|
||||
class="px-3 py-1 rounded-md text-sm font-medium transition-colors
|
||||
{kind === k.value ? 'bg-accent/15 text-accent' : 'text-dim hover:bg-surface hover:text-text'}"
|
||||
>
|
||||
{k.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<SafetyDocList materialType={kind} />
|
||||
</div>
|
||||
@@ -1,13 +1,58 @@
|
||||
<script>
|
||||
// /study — 학습 hub.
|
||||
// 주제로 보기(퀴즈·복습·통계) / 자료 학습 / 필사 세션 / 암기카드 검수.
|
||||
// /study — 학습 hub + 데일리 랜딩('오늘의 공부' 대시보드).
|
||||
// 상단 = 이론 홈(진도·오늘의 개념·복습 due, 재노출 트리거). 하단 = 기존 모드 진입.
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '$lib/api';
|
||||
import { BookOpen, PenLine, GraduationCap, FolderKanban, Layers, Repeat, Flag, Inbox, Activity } from 'lucide-svelte';
|
||||
import { addToast } from '$lib/stores/toast';
|
||||
import { BookOpen, PenLine, GraduationCap, FolderKanban, Layers, Repeat, Flag, Inbox, Activity, CalendarCheck, Target } from 'lucide-svelte';
|
||||
|
||||
let cardReviewCount = $state(0);
|
||||
let questionFlagCount = $state(0);
|
||||
|
||||
// 오늘의 공부 (이론 홈)
|
||||
let curriculum = $state(null);
|
||||
let todayConcepts = $state([]);
|
||||
let weakConcepts = $state([]); // 약점 개념(관련 기출 정답률 낮음)
|
||||
let dashLoading = $state(true);
|
||||
|
||||
let readPct = $derived(
|
||||
curriculum && curriculum.total ? Math.round((curriculum.read / curriculum.total) * 100) : 0
|
||||
);
|
||||
|
||||
async function loadDashboard() {
|
||||
dashLoading = true;
|
||||
try {
|
||||
const [cur, today] = await Promise.all([
|
||||
api('/study/curriculum'),
|
||||
api('/study/today-concepts?limit=6'),
|
||||
]);
|
||||
curriculum = cur;
|
||||
todayConcepts = today?.concepts ?? [];
|
||||
} catch {
|
||||
// 코어 대시보드 실패해도 허브 나머지는 동작 (조용히)
|
||||
} finally {
|
||||
dashLoading = false;
|
||||
}
|
||||
// 약점 개념 = 비차단(신규 엔드포인트 실패해도 코어 대시보드 블랙아웃 방지)
|
||||
try {
|
||||
const weak = await api('/study/concepts/weakness-map?limit=5');
|
||||
weakConcepts = weak?.weak ?? [];
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function markRead(doc) {
|
||||
try {
|
||||
await api(`/study/concepts/${doc.doc_id}/read`, { method: 'POST' });
|
||||
todayConcepts = todayConcepts.filter((c) => c.doc_id !== doc.doc_id);
|
||||
addToast('success', `회독: ${doc.title}`);
|
||||
loadDashboard(); // 진도 갱신
|
||||
} catch {
|
||||
addToast('error', '회독 처리 실패');
|
||||
}
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
loadDashboard();
|
||||
try {
|
||||
const r = await api('/study-cards/needs-review/count');
|
||||
cardReviewCount = r?.count ?? 0;
|
||||
@@ -27,6 +72,80 @@
|
||||
<p class="text-sm text-dim mt-1">주제별 퀴즈·복습(SRS)·통계 / 학습 자료 회독 / 손글씨 필사 세션.</p>
|
||||
</header>
|
||||
|
||||
<!-- 오늘의 공부 (이론 홈 대시보드 = 데일리 트리거) -->
|
||||
<section class="mb-5 rounded-lg border border-default bg-surface p-4 md:p-5">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<CalendarCheck size={18} class="text-accent" />
|
||||
<h2 class="text-base font-semibold text-text">오늘의 공부</h2>
|
||||
{#if curriculum}
|
||||
<span class="ml-auto text-xs text-dim">이론 회독 <span class="text-text font-medium">{curriculum.read}</span> / {curriculum.total} ({readPct}%)</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if dashLoading}
|
||||
<p class="text-xs text-dim">불러오는 중…</p>
|
||||
{:else}
|
||||
{#if curriculum}
|
||||
<div class="h-2 rounded-full bg-bg overflow-hidden mb-3">
|
||||
<div class="h-full bg-accent" style="width: {readPct}%"></div>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-x-4 gap-y-1 mb-4 text-xs text-dim">
|
||||
{#each curriculum.subjects as s}
|
||||
<span>{s.subject} <span class="text-text">{s.read}/{s.total}</span></span>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2 mb-4">
|
||||
<a
|
||||
href="/study/topics/{curriculum.topic_id}/review-queue"
|
||||
class="flex items-center gap-1.5 rounded border border-default px-3 py-1.5 text-xs text-dim hover:border-accent hover:text-text transition-colors"
|
||||
>
|
||||
<Repeat size={13} /> 문항 복습 <span class="font-semibold text-text">{curriculum.question_due}</span>
|
||||
</a>
|
||||
<span class="flex items-center gap-1.5 rounded border border-default px-3 py-1.5 text-xs text-dim">
|
||||
<BookOpen size={13} /> 개념 재복습 <span class="font-semibold text-text">{curriculum.concept_due}</span>
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="text-xs text-dim mb-2">오늘의 개념</div>
|
||||
{#if todayConcepts.length === 0}
|
||||
<p class="text-xs text-dim">오늘 볼 개념이 없습니다. 잘 하고 있어요.</p>
|
||||
{:else}
|
||||
<ul class="space-y-1.5">
|
||||
{#each todayConcepts as c (c.doc_id)}
|
||||
<li class="flex items-center gap-2 rounded border border-default px-3 py-2">
|
||||
<span class="text-accent shrink-0 text-xs" title="빈출">{#each Array(c.freq) as _}★{/each}</span>
|
||||
<a href="/study/read/{c.doc_id}" class="text-sm text-text hover:text-accent truncate flex-1">{c.title}</a>
|
||||
<span class="shrink-0 text-[10px] rounded-full px-2 py-0.5 {c.reason === '재복습' ? 'bg-accent/15 text-accent' : 'bg-surface border border-default text-dim'}">{c.reason}</span>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => markRead(c)}
|
||||
class="shrink-0 text-xs rounded border border-default px-2 py-1 text-dim hover:border-accent hover:text-accent transition-colors"
|
||||
>읽음</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
{#if weakConcepts.length > 0}
|
||||
<div class="mt-4 pt-3 border-t border-default">
|
||||
<div class="text-xs text-dim mb-2 flex items-center gap-1.5">
|
||||
<Target size={13} class="text-error" /> 약점 개념 <span class="text-faint">(관련 기출 정답률 낮음)</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each weakConcepts as w (w.doc_id)}
|
||||
<a href="/study/read/{w.doc_id}"
|
||||
class="text-xs rounded-full border border-error/40 bg-error/10 text-error px-3 py-1 hover:bg-error/20 transition-colors">
|
||||
{w.title.replace(/^\d+_/, '')} <span class="font-semibold">{w.accuracy}%</span>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<a
|
||||
href="/study/topics"
|
||||
class="block mb-3 p-5 rounded-lg border border-default bg-surface hover:border-accent hover:bg-accent/5 transition-colors"
|
||||
@@ -126,7 +245,8 @@
|
||||
<div class="mt-6 p-4 rounded-lg border border-dashed border-default/60 text-xs text-dim">
|
||||
<div class="font-medium text-dim mb-1">예정</div>
|
||||
<ul class="list-disc list-inside space-y-0.5">
|
||||
<li>애플워치 빠른복습 + 공부 알람(push)</li>
|
||||
<li>개념 학습 리더 (가리고 떠올리기 · 빈출★ · 관련개념 백링크)</li>
|
||||
<li>이론↔문제 연결 (개념별 정답률 · 약점 개념 지도)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
<script>
|
||||
/**
|
||||
* /study/read/[docId] — 개념 학습 리더.
|
||||
* 개념노트(가스기사 documents)를 구조(요약/본문/빈출★/관련개념)로 렌더 +
|
||||
* '떠올리기' 능동 회상 토글 + 회독 SR(POST read) + 관련개념 백링크 + 이전/다음.
|
||||
* 본문 렌더 = MarkdownDoc(KaTeX + docimg 내장). 서버 파싱 = /api/study/concepts/{id}.
|
||||
*/
|
||||
import { page } from '$app/stores';
|
||||
import { api } from '$lib/api';
|
||||
import { addToast } from '$lib/stores/toast';
|
||||
import { renderMathMarkdownInline } from '$lib/utils/mathMarkdown';
|
||||
import MarkdownDoc from '$lib/components/MarkdownDoc.svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
||||
import Skeleton from '$lib/components/ui/Skeleton.svelte';
|
||||
import { BookOpen, ArrowLeft, Eye, EyeOff, Check, ChevronLeft, ChevronRight, FileQuestion } from 'lucide-svelte';
|
||||
|
||||
let docId = $derived($page.params.docId);
|
||||
|
||||
let concept = $state(null);
|
||||
let relatedQ = $state(null); // 관련 기출(이론↔문제, 비차단)
|
||||
let loading = $state(true);
|
||||
let notFound = $state(false);
|
||||
let mode = $state('read'); // 'read' | 'recall'(떠올리기)
|
||||
let revealed = $state({}); // {sectionIndex: true}
|
||||
let marking = $state(false);
|
||||
|
||||
const STAGE_LABEL = { 0: '복습 시작', 1: '복습 1단계', 2: '복습 2단계', 3: '복습 3단계', 4: '학습 완료' };
|
||||
const OUTCOME_MARK = { correct: '○', wrong: '✕', unsure: '?' };
|
||||
const OUTCOME_CLASS = { correct: 'text-success', wrong: 'text-error', unsure: 'text-warning' };
|
||||
const outcomeMark = (o) => OUTCOME_MARK[o] ?? '–';
|
||||
const outcomeClass = (o) => OUTCOME_CLASS[o] ?? 'text-faint';
|
||||
|
||||
async function load() {
|
||||
const reqId = docId; // in-flight 가드: 백링크 연타 시 stale 응답 무시
|
||||
loading = true;
|
||||
notFound = false;
|
||||
concept = null;
|
||||
relatedQ = null;
|
||||
revealed = {};
|
||||
mode = 'read';
|
||||
try {
|
||||
const data = await api(`/study/concepts/${reqId}`);
|
||||
if (reqId !== docId) return; // 그새 다른 개념으로 이동 → 폐기
|
||||
concept = data;
|
||||
} catch (e) {
|
||||
if (reqId !== docId) return;
|
||||
if (e?.status === 404) notFound = true;
|
||||
else addToast('error', '개념을 불러오지 못했습니다');
|
||||
return; // 본문 실패 → 관련기출 스킵
|
||||
} finally {
|
||||
if (reqId === docId) loading = false;
|
||||
}
|
||||
// 관련 기출(비차단 — 실패해도 본문 표시엔 영향 없음)
|
||||
try {
|
||||
const rq = await api(`/study/concepts/${reqId}/questions?limit=6`);
|
||||
if (reqId === docId) relatedQ = rq;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// $effect 가 마운트 1회 + docId 변경(백링크/이전·다음) 재로드를 모두 커버 (onMount 불필요)
|
||||
$effect(() => {
|
||||
void docId;
|
||||
load();
|
||||
});
|
||||
|
||||
function toggleMode() {
|
||||
mode = mode === 'read' ? 'recall' : 'read';
|
||||
revealed = {};
|
||||
}
|
||||
function reveal(i) {
|
||||
revealed = { ...revealed, [i]: true };
|
||||
}
|
||||
function shown(i) {
|
||||
return mode === 'read' || revealed[i];
|
||||
}
|
||||
|
||||
async function markRead() {
|
||||
marking = true;
|
||||
try {
|
||||
const r = await api(`/study/concepts/${docId}/read`, { method: 'POST' });
|
||||
if (concept) {
|
||||
concept.is_read = true;
|
||||
concept.review_stage = r?.review_stage ?? concept.review_stage;
|
||||
concept.due_at = r?.due_at ?? concept.due_at;
|
||||
}
|
||||
addToast('success', '회독 완료 — 다음 복습에 다시 나옵니다');
|
||||
} catch {
|
||||
addToast('error', '회독 처리 실패');
|
||||
} finally {
|
||||
marking = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{concept?.title ?? '개념'} — 공부</title></svelte:head>
|
||||
|
||||
<div class="p-4 md:p-6 max-w-3xl mx-auto">
|
||||
<!-- 상단 네비 -->
|
||||
<div class="flex items-center gap-2 text-xs md:text-sm mb-4 min-w-0">
|
||||
<a href="/study" class="text-dim hover:text-text flex items-center gap-1 shrink-0">
|
||||
<ArrowLeft size={14} /> 공부
|
||||
</a>
|
||||
{#if concept?.subject}
|
||||
<span class="text-faint shrink-0">/</span>
|
||||
<span class="text-dim truncate">{concept.subject}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<Skeleton h="h-10" rounded="card" />
|
||||
<div class="mt-3 space-y-2">
|
||||
{#each Array(4) as _}<Skeleton h="h-24" rounded="card" />{/each}
|
||||
</div>
|
||||
{:else if notFound}
|
||||
<EmptyState icon={BookOpen} title="개념을 찾을 수 없습니다" description="삭제되었거나 잘못된 주소입니다." />
|
||||
{:else if concept}
|
||||
<!-- 제목 + 빈출 tier -->
|
||||
<header class="mb-3">
|
||||
<div class="flex items-start gap-2">
|
||||
<h1 class="text-xl md:text-2xl font-semibold text-text flex-1">{concept.title}</h1>
|
||||
<span class="text-accent text-sm shrink-0 mt-1" title="빈출도">
|
||||
{#each Array(concept.freq) as _}★{/each}
|
||||
</span>
|
||||
</div>
|
||||
{#if concept.is_read || (concept.review_stage !== null && concept.review_stage !== undefined)}
|
||||
<div class="mt-1 text-xs text-dim">
|
||||
{#if concept.review_stage !== null && concept.review_stage !== undefined}
|
||||
{STAGE_LABEL[concept.review_stage] ?? '복습 중'}
|
||||
{:else}회독함{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
<!-- 한 줄 요약 (고정 표시) -->
|
||||
{#if concept.summary}
|
||||
<div class="mb-4 rounded-lg border-l-4 border-accent bg-accent/10 px-4 py-3 markdown-body text-sm text-text">
|
||||
{@html renderMathMarkdownInline(concept.summary)}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- 모드 토글 -->
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<Button variant={mode === 'recall' ? 'primary' : 'secondary'} size="sm" icon={mode === 'recall' ? EyeOff : Eye} onclick={toggleMode}>
|
||||
{mode === 'recall' ? '떠올리기 모드' : '읽기 모드'}
|
||||
</Button>
|
||||
{#if mode === 'recall'}
|
||||
<span class="text-xs text-dim">각 섹션을 떠올린 뒤 확인하세요</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- 본문 섹션 -->
|
||||
{#if concept.body.length > 0}
|
||||
<div class="space-y-3 mb-5">
|
||||
{#each concept.body as sec, i (i)}
|
||||
<section class="rounded-lg border border-default bg-surface overflow-hidden">
|
||||
<div class="flex items-center gap-2 px-4 py-2.5 border-b border-default bg-surface-hover">
|
||||
<h2 class="text-sm font-semibold text-text flex-1">{sec.label}</h2>
|
||||
{#if sec.stars > 0}
|
||||
<span class="text-accent text-xs shrink-0">{#each Array(sec.stars) as _}★{/each}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if shown(i)}
|
||||
<div class="px-4 py-3">
|
||||
<MarkdownDoc documentId={concept.doc_id} mdContent={sec.md} mdStatus={null}
|
||||
class="markdown-body max-w-none text-text" />
|
||||
</div>
|
||||
{:else}
|
||||
<button type="button" onclick={() => reveal(i)}
|
||||
class="w-full px-4 py-6 text-center text-sm text-dim hover:text-accent hover:bg-accent/5 transition-colors">
|
||||
<Eye size={16} class="inline mr-1" /> 떠올린 뒤 확인
|
||||
</button>
|
||||
{/if}
|
||||
</section>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- 빈출 포인트 -->
|
||||
{#if concept.bincheol.length > 0}
|
||||
<section class="mb-5 rounded-lg border border-default bg-surface p-4">
|
||||
<h2 class="text-sm font-semibold text-text mb-2 flex items-center gap-1.5">
|
||||
<span class="text-accent">★</span> 빈출 포인트
|
||||
</h2>
|
||||
<ul class="space-y-1.5">
|
||||
{#each concept.bincheol as item}
|
||||
<li class="flex gap-2 text-sm text-text">
|
||||
<span class="text-accent shrink-0 text-xs mt-0.5">{#each Array(item.tier || 1) as _}★{/each}</span>
|
||||
<span class="markdown-body flex-1">{@html renderMathMarkdownInline(item.text)}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- 관련 개념 (백링크) -->
|
||||
{#if concept.related.length > 0}
|
||||
<section class="mb-5">
|
||||
<h2 class="text-xs text-dim mb-2">관련 개념</h2>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each concept.related as rel}
|
||||
{#if rel.doc_id}
|
||||
<a href="/study/read/{rel.doc_id}"
|
||||
class="text-xs rounded-full border border-accent/40 bg-accent/10 text-accent px-3 py-1 hover:bg-accent/20 transition-colors">
|
||||
{rel.phrase}
|
||||
</a>
|
||||
{:else}
|
||||
<span class="text-xs rounded-full border border-default bg-surface text-faint px-3 py-1" title="아직 없는 개념">
|
||||
{rel.phrase}
|
||||
</span>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- 관련 기출 (이론↔문제 브리지) -->
|
||||
{#if relatedQ && relatedQ.linked > 0}
|
||||
<section class="mb-5 rounded-lg border border-default bg-surface p-4">
|
||||
<h2 class="text-sm font-semibold text-text mb-2 flex items-center gap-1.5">
|
||||
<FileQuestion size={15} class="text-accent" /> 관련 기출
|
||||
<span class="ml-1 text-xs font-normal text-dim">
|
||||
{relatedQ.linked}문항{#if relatedQ.accuracy !== null} · 정답률 <span class="{relatedQ.accuracy < 60 ? 'text-error' : 'text-text'} font-medium">{relatedQ.accuracy}%</span>{:else} · 아직 안 풂{/if}
|
||||
</span>
|
||||
</h2>
|
||||
<ul class="space-y-0.5">
|
||||
{#each relatedQ.questions as q (q.id)}
|
||||
<li>
|
||||
<a href="/study/topics/4/questions/{q.id}"
|
||||
class="flex items-center gap-2 text-xs py-1 text-dim hover:text-accent transition-colors">
|
||||
<span class="{outcomeClass(q.last_outcome)} shrink-0 w-4 text-center font-bold">{outcomeMark(q.last_outcome)}</span>
|
||||
<span class="truncate">{q.subject ?? '기출'}{#if q.exam_round} · {q.exam_round}{/if}</span>
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- 액션바 -->
|
||||
<div class="flex items-center gap-2 border-t border-default pt-4 mt-2">
|
||||
{#if concept.prev_id}
|
||||
<Button variant="ghost" size="sm" icon={ChevronLeft} href="/study/read/{concept.prev_id}">이전</Button>
|
||||
{/if}
|
||||
<div class="flex-1"></div>
|
||||
<Button variant="primary" size="sm" icon={Check} onclick={markRead} loading={marking}>
|
||||
{concept.is_read ? '다시 회독' : '회독 완료'}
|
||||
</Button>
|
||||
{#if concept.next_id}
|
||||
<Button variant="secondary" size="sm" icon={ChevronRight} href="/study/read/{concept.next_id}">다음 개념</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,23 @@
|
||||
-- 377_domain_bucket.sql
|
||||
-- ai_domain(반자유 AI 분류, 드리프트 존재)을 검색 스코프용 7버킷으로 결정적 롤업.
|
||||
-- 축: ai_domain(routing/해석 축)의 coarsening — category(UI축) 아님 (feedback_category_vs_ai_domain_axis 준수).
|
||||
-- 버킷: News / Safety / Law / Engineering / General / Philosophy / Programming.
|
||||
-- STORED generated → 신규/재분류 문서도 ai_domain 붙으면 자동 버킷. ai_domain 원본 보존(하위 검색 유지).
|
||||
-- 롤백: ALTER TABLE documents DROP COLUMN domain_bucket;
|
||||
ALTER TABLE documents ADD COLUMN IF NOT EXISTS domain_bucket text
|
||||
GENERATED ALWAYS AS (
|
||||
CASE
|
||||
WHEN ai_domain LIKE 'News%' THEN 'News'
|
||||
WHEN ai_domain = '법령' OR ai_domain LIKE 'Industrial_Safety/Legislation%' THEN 'Law'
|
||||
WHEN ai_domain = 'Safety' OR ai_domain LIKE 'Safety/%'
|
||||
OR ai_domain LIKE 'Industrial_Safety%'
|
||||
OR ai_domain = 'Knowledge/Industrial_Safety' THEN 'Safety'
|
||||
WHEN ai_domain LIKE 'Engineering%' OR ai_domain = 'Knowledge/Engineering' THEN 'Engineering'
|
||||
WHEN ai_domain LIKE 'Philosophy%' THEN 'Philosophy'
|
||||
WHEN ai_domain LIKE 'Programming%' THEN 'Programming'
|
||||
ELSE 'General'
|
||||
END
|
||||
) STORED;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS documents_domain_bucket_idx
|
||||
ON documents (domain_bucket) WHERE deleted_at IS NULL;
|
||||
@@ -0,0 +1,9 @@
|
||||
-- 378_publish_outbox_attempts_failed.sql
|
||||
-- (번호: 멀티세션 중 prod 가 377_domain_bucket 을 선점 → 378 로 리넘버.)
|
||||
-- publish_outbox poison row head-of-line block 차단. 발행 워커가 행별 savepoint 격리 후
|
||||
-- 예외 시 attempts++ 하고 MAX 초과 시 failed_at 스탬프(terminal) → 그 행을 select 에서 제외해
|
||||
-- 후속 발행이 막히지 않게 함. 기존 미처리 행은 attempts=0 / failed_at=NULL 로 정상 재처리.
|
||||
-- (단일 ALTER = 1 statement = asyncpg prepared 호환.)
|
||||
ALTER TABLE publish_outbox
|
||||
ADD COLUMN IF NOT EXISTS attempts SMALLINT NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS failed_at TIMESTAMPTZ;
|
||||
@@ -0,0 +1,37 @@
|
||||
-- 379_asme_clause_kb.sql
|
||||
-- ASME 절-지식베이스: 절 = 개별 documents 행(parent_id) + 절↔절 백링크 + 태깅 (additive, idempotent)
|
||||
-- 검색 무접촉: 절 doc 은 embedding NULL(벡터 제외) + doc_kind='clause'(retrieval doc-leg 필터로 제외).
|
||||
|
||||
ALTER TABLE documents
|
||||
ADD COLUMN IF NOT EXISTS parent_id bigint REFERENCES documents(id),
|
||||
ADD COLUMN IF NOT EXISTS doc_kind text NOT NULL DEFAULT 'standard',
|
||||
ADD COLUMN IF NOT EXISTS clause_code text,
|
||||
ADD COLUMN IF NOT EXISTS clause_part text,
|
||||
ADD COLUMN IF NOT EXISTS clause_order int;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_documents_parent_id ON documents(parent_id) WHERE parent_id IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_documents_doc_kind ON documents(doc_kind);
|
||||
CREATE INDEX IF NOT EXISTS idx_documents_clause_code ON documents(clause_code) WHERE clause_code IS NOT NULL;
|
||||
|
||||
-- 절↔절 백링크 (dangling 허용: dst_doc_id nullable)
|
||||
CREATE TABLE IF NOT EXISTS clause_links (
|
||||
id bigserial PRIMARY KEY,
|
||||
src_doc_id bigint NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
||||
dst_code text NOT NULL,
|
||||
dst_doc_id bigint REFERENCES documents(id) ON DELETE SET NULL,
|
||||
anchor text,
|
||||
ctx text,
|
||||
char_off int
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_clause_links_src ON clause_links(src_doc_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_clause_links_dst ON clause_links(dst_doc_id) WHERE dst_doc_id IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_clause_links_dstcode ON clause_links(dst_code);
|
||||
|
||||
-- 태깅 (Part 자동 + 주제)
|
||||
CREATE TABLE IF NOT EXISTS document_tags (
|
||||
doc_id bigint NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
||||
tag text NOT NULL,
|
||||
tag_kind text NOT NULL DEFAULT 'topic',
|
||||
PRIMARY KEY (doc_id, tag)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_document_tags_tag ON document_tags(tag);
|
||||
@@ -0,0 +1,9 @@
|
||||
-- 380_clause_study.sql — 절-문서 공부도구(노트/형광펜/암기카드) 저장. FK 없음(documents 락 회피).
|
||||
CREATE TABLE IF NOT EXISTS clause_study (
|
||||
id bigserial PRIMARY KEY,
|
||||
doc_id bigint NOT NULL,
|
||||
kind text NOT NULL, -- 'note' | 'highlight' | 'card'
|
||||
payload jsonb NOT NULL DEFAULT '{}',
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_clause_study_doc ON clause_study(doc_id, kind);
|
||||
@@ -0,0 +1,16 @@
|
||||
-- 381_study_concept_progress.sql — 이론 개념(문서) 간격반복(SR) 진행. 이론공부 홈 트리거.
|
||||
-- concept_doc_id 는 documents.id 를 가리키나 FK 미설정(hot 테이블 락 회피, clause_study 380 선례).
|
||||
-- SR 산술은 study_question_progress 와 동일(sr_schedule 공용): stage 0→1→2→3(1·3·7·14일)→4 졸업.
|
||||
CREATE TABLE IF NOT EXISTS study_concept_progress (
|
||||
id bigserial PRIMARY KEY,
|
||||
user_id bigint NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
study_topic_id bigint NOT NULL REFERENCES study_topics(id) ON DELETE CASCADE,
|
||||
concept_doc_id bigint NOT NULL,
|
||||
review_stage smallint,
|
||||
due_at timestamptz,
|
||||
last_read_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
CONSTRAINT uq_concept_progress_user_doc UNIQUE (user_id, concept_doc_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_concept_progress_due ON study_concept_progress(user_id, due_at) WHERE due_at IS NOT NULL;
|
||||
@@ -0,0 +1,15 @@
|
||||
-- 382_study_concept_links.sql — 개념문서 ↔ 기출문항 링크 (이론↔문제 브리지, Stage B).
|
||||
-- concept_doc_id=documents.id, question_id=study_questions.id — FK 없음(hot 테이블 락 회피, 선례).
|
||||
-- link_source: 'embedding'(bge-m3 코사인 top-k, 주력) | 'ref'(해설 .md 참조, 후속 enrichment).
|
||||
-- score=코사인 유사도(0~1). UNIQUE(doc,question,source) — source별 공존 허용(재튜닝=source 전삭제 후 재삽입).
|
||||
CREATE TABLE IF NOT EXISTS study_concept_links (
|
||||
id bigserial PRIMARY KEY,
|
||||
concept_doc_id bigint NOT NULL,
|
||||
question_id bigint NOT NULL,
|
||||
link_source text NOT NULL,
|
||||
score double precision,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
CONSTRAINT uq_concept_link UNIQUE (concept_doc_id, question_id, link_source)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_concept_links_doc ON study_concept_links(concept_doc_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_concept_links_q ON study_concept_links(question_id);
|
||||
@@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env python3
|
||||
"""ASME clause-KB backlinks: resolve clause-id mentions in each clause doc -> clause_links.
|
||||
dst resolved to the clause doc of the same parent (top-level code); sub-code mention -> anchor;
|
||||
unresolved (cross-standard / material spec not split) -> dangling (dst_doc_id NULL).
|
||||
Idempotent per parent. Usage: python3 asme_backlinks_persist.py <parent_id> [--commit]
|
||||
"""
|
||||
import asyncio, os, re, sys
|
||||
|
||||
MENTION_RE = re.compile(r'(?<![A-Za-z0-9])([A-Z]{1,4}-\d+(?:\.\d+)*[A-Za-z]?)(?![A-Za-z0-9])')
|
||||
def top(code): return re.match(r'^[A-Z]{1,4}-\d+', code).group(0)
|
||||
|
||||
async def main():
|
||||
parent = int(sys.argv[1]); commit = '--commit' in sys.argv
|
||||
import asyncpg
|
||||
conn = await asyncpg.connect(os.environ['DATABASE_URL'].replace('+asyncpg', ''))
|
||||
docs = await conn.fetch("SELECT id, clause_code, md_content FROM documents "
|
||||
"WHERE parent_id=$1 AND doc_kind='clause' ORDER BY clause_order", parent)
|
||||
code2id = {d['clause_code']: d['id'] for d in docs}
|
||||
edges = [] # (src_id, dst_code, dst_doc_id, anchor, ctx, char_off)
|
||||
resolved = dangling = 0
|
||||
for d in docs:
|
||||
body = d['md_content']; src_top = d['clause_code']
|
||||
seen = set()
|
||||
for m in MENTION_RE.finditer(body):
|
||||
code = m.group(1); t = top(code)
|
||||
if t == src_top: continue # self-reference
|
||||
if (d['id'], code) in seen: continue # dedup per (src,dst_code)
|
||||
seen.add((d['id'], code))
|
||||
dst_id = code2id.get(t) # resolve to same-parent clause doc
|
||||
anchor = code.lower().replace('.', '-') if code != t else None
|
||||
off = m.start()
|
||||
ctx = re.sub(r'\s+', ' ', body[max(0, off-50):off+50]).strip()
|
||||
edges.append((d['id'], code, dst_id, anchor, ctx, off))
|
||||
if dst_id: resolved += 1
|
||||
else: dangling += 1
|
||||
print(f"parent={parent} clause_docs={len(docs)} edges={len(edges)} resolved={resolved} dangling={dangling}")
|
||||
# top referenced clauses
|
||||
from collections import Counter
|
||||
tgt = Counter(top(e[1]) for e in edges if e[2])
|
||||
print("most-referenced:", tgt.most_common(8))
|
||||
if not commit:
|
||||
print("DRY-RUN. pass --commit to persist."); await conn.close(); return
|
||||
async with conn.transaction():
|
||||
ids = [d['id'] for d in docs]
|
||||
await conn.execute("DELETE FROM clause_links WHERE src_doc_id = ANY($1::bigint[])", ids)
|
||||
await conn.executemany(
|
||||
"INSERT INTO clause_links(src_doc_id,dst_code,dst_doc_id,anchor,ctx,char_off) "
|
||||
"VALUES ($1,$2,$3,$4,$5,$6)", edges)
|
||||
n = await conn.fetchval("SELECT count(*) FROM clause_links WHERE src_doc_id = ANY($1::bigint[])", ids)
|
||||
print(f"COMMITTED: {n} clause_links for parent {parent}")
|
||||
await conn.close()
|
||||
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,118 @@
|
||||
#!/usr/bin/env python3
|
||||
"""ASME clause-KB persist (v2: over-CAP pagination). Split a parent standard into per-clause
|
||||
documents (A-granularity); over-CAP clause bodies are paginated into readable page-docs.
|
||||
Idempotent per parent. doc_kind='clause', embedding NULL (search-excluded), parent_id=<parent>.
|
||||
Usage: python3 asme_clause_persist.py <parent_id> [--commit]
|
||||
"""
|
||||
import asyncio, os, re, sys, hashlib, statistics
|
||||
|
||||
CAP = 12000; PAGE_TOK = 11000
|
||||
EN, KO = 0.217, 0.529
|
||||
LINE_RE = re.compile(r'^([ \t#>*]{0,8})([A-Z]{2,4}-\d+(?:\.\d+)*[A-Za-z]?)(.*)$')
|
||||
MENTION_RE = re.compile(r'(?<![A-Za-z0-9])([A-Z]{1,4}-\d+(?:\.\d+)*[A-Za-z]?)(?![A-Za-z0-9])')
|
||||
EXACT_TOP = re.compile(r'^[A-Z]{2,4}-\d+$')
|
||||
TITLE_AFTER = re.compile(r'^[\s.]*[A-Z(]')
|
||||
REF_LEAD = re.compile(r'^[\s.]*(and|or|to|of|in|on|the|as|is|are|shall|through|per|see|with|'
|
||||
r'for|by|that|which|such|또는|및|등|의|은|는|에|을|를|과|와)\b', re.I)
|
||||
|
||||
def tok(s):
|
||||
ko = sum(1 for c in s if '가' <= c <= '힣'); return int((len(s)-ko)*EN + ko*KO)
|
||||
|
||||
def clean_title(rest):
|
||||
t = re.sub(r'<sup>ð</sup>\s*\**\d*\**\s*<sup>Þ</sup>', '', rest)
|
||||
t = re.sub(r'ð\**\d*\**Þ', '', t)
|
||||
t = t.replace('**', '').replace('#', '')
|
||||
return re.sub(r'\s+', ' ', t).strip(' *:—-')
|
||||
|
||||
def is_header(markup, rest):
|
||||
if '#' in markup or '*' in markup: return True
|
||||
rs = rest.strip()
|
||||
if rs == '': return True
|
||||
if REF_LEAD.match(rest): return False
|
||||
if rs[0] in ',;.)': return False
|
||||
if '가' <= rs[0] <= '힣': return False
|
||||
if rs[0].islower(): return False
|
||||
return bool(TITLE_AFTER.match(rs))
|
||||
|
||||
def paginate(body):
|
||||
"""split an over-CAP body into <=MAX_PAGES line-aligned pages of ~PAGE_TOK tokens."""
|
||||
pages, cur, ct = [], [], 0
|
||||
for ln in body.split('\n'):
|
||||
lt = tok(ln) + 1
|
||||
if ct + lt > PAGE_TOK and cur:
|
||||
pages.append('\n'.join(cur)); cur, ct = [ln], lt
|
||||
else:
|
||||
cur.append(ln); ct += lt
|
||||
if cur: pages.append('\n'.join(cur))
|
||||
return pages
|
||||
|
||||
def build_clauses(text):
|
||||
lines = text.split('\n'); off = []; a = 0
|
||||
for ln in lines: off.append(a); a += len(ln) + 1
|
||||
bounds = []; seen = set()
|
||||
for i, ln in enumerate(lines):
|
||||
m = LINE_RE.match(ln)
|
||||
if not m: continue
|
||||
markup, code, rest = m.group(1), m.group(2), m.group(3)
|
||||
if not EXACT_TOP.match(code): continue
|
||||
if not is_header(markup, rest): continue
|
||||
if code in seen: continue
|
||||
seen.add(code); bounds.append((off[i], code, clean_title(rest)))
|
||||
raw = []
|
||||
for idx, (start, code, title) in enumerate(bounds):
|
||||
end = bounds[idx+1][0] if idx+1 < len(bounds) else len(text)
|
||||
body = text[start:end]
|
||||
part = re.match(r'^[A-Z]{2,4}', code).group(0)
|
||||
links = sorted(set(re.match(r'^[A-Z]{1,4}-\d+', mm).group(0)
|
||||
for mm in MENTION_RE.findall(body)) - {code})
|
||||
raw.append(dict(code=code, part=part, title=(code + (' ' + title if title else '')),
|
||||
body=body, tok=tok(body), links=links))
|
||||
# expand over-CAP into pages; assign running clause_order
|
||||
final, order = [], 0
|
||||
for c in raw:
|
||||
if c['tok'] <= CAP:
|
||||
final.append({**c, 'order': order}); order += 1; continue
|
||||
pages = paginate(c['body'])
|
||||
for pi, pb in enumerate(pages):
|
||||
code = c['code'] if pi == 0 else f"{c['code']}·p{pi+1}"
|
||||
title = c['title'] if pi == 0 else f"{c['title']} (페이지 {pi+1}/{len(pages)})"
|
||||
final.append(dict(code=code, part=c['part'], order=order, title=title,
|
||||
body=pb, tok=tok(pb), links=c['links'] if pi == 0 else []))
|
||||
order += 1
|
||||
return final
|
||||
|
||||
async def main():
|
||||
parent = int(sys.argv[1]); commit = '--commit' in sys.argv
|
||||
import asyncpg
|
||||
conn = await asyncpg.connect(os.environ['DATABASE_URL'].replace('+asyncpg', ''))
|
||||
row = await conn.fetchrow("SELECT md_content, ai_domain, data_origin FROM documents WHERE id=$1", parent)
|
||||
if not row: print(f"parent {parent} not found"); return
|
||||
clauses = build_clauses(row['md_content'])
|
||||
toks = [c['tok'] for c in clauses]
|
||||
over = [c for c in clauses if c['tok'] > CAP]
|
||||
print(f"parent={parent} clause_docs={len(clauses)} median_tok={int(statistics.median(toks))} "
|
||||
f"max_tok={max(toks)} over_cap_remaining={len(over)}")
|
||||
if over: print("still over-CAP:", [f"{c['code']}:{c['tok']}t" for c in over])
|
||||
if not commit:
|
||||
print("DRY-RUN. pass --commit to persist."); await conn.close(); return
|
||||
async with conn.transaction():
|
||||
deld = await conn.execute("DELETE FROM documents WHERE parent_id=$1 AND doc_kind='clause'", parent)
|
||||
print("deleted prior:", deld)
|
||||
for c in clauses:
|
||||
fh = hashlib.sha256(f"{parent}:{c['code']}:{c['body']}".encode()).hexdigest()
|
||||
cid = await conn.fetchval("""
|
||||
INSERT INTO documents
|
||||
(file_format, file_hash, title, md_content, parent_id, doc_kind,
|
||||
clause_code, clause_part, clause_order, ai_domain, data_origin,
|
||||
md_status, review_status, conversion_status, preview_status)
|
||||
VALUES ('md',$1,$2,$3,$4,'clause',$5,$6,$7,$8,$9,'success','approved','none','none')
|
||||
RETURNING id
|
||||
""", fh, c['title'], c['body'], parent, c['code'], c['part'], c['order'],
|
||||
row['ai_domain'], row['data_origin'] or 'external')
|
||||
await conn.execute("INSERT INTO document_tags(doc_id,tag,tag_kind) VALUES ($1,$2,'part') "
|
||||
"ON CONFLICT DO NOTHING", cid, c['part'])
|
||||
n = await conn.fetchval("SELECT count(*) FROM documents WHERE parent_id=$1 AND doc_kind='clause'", parent)
|
||||
print(f"COMMITTED: {n} clause docs for parent {parent}")
|
||||
await conn.close()
|
||||
|
||||
asyncio.run(main())
|
||||
@@ -31,8 +31,8 @@ from core.database import async_session
|
||||
from models.study_memo_card_progress import StudyMemoCardProgress
|
||||
from services.study.publish_enqueue import backfill_publish_card_progress
|
||||
|
||||
# 개인 학습툴 progress row 대비 넉넉. 도달 시 가드 경보.
|
||||
PAGE = 100000
|
||||
# 페이지 배치 크기 — after_id 루프로 전량 처리(bounded tx).
|
||||
PAGE = 5000
|
||||
|
||||
|
||||
async def run(dry_run: bool) -> None:
|
||||
@@ -50,13 +50,17 @@ async def run(dry_run: bool) -> None:
|
||||
print("[dry-run] 적재 안 함. 실제 실행은 --dry-run 제거.")
|
||||
return
|
||||
|
||||
async with async_session() as session:
|
||||
n = await backfill_publish_card_progress(session, after_id=0, limit=PAGE)
|
||||
await session.commit()
|
||||
total = 0
|
||||
after = 0
|
||||
while True:
|
||||
async with async_session() as session:
|
||||
n, after = await backfill_publish_card_progress(session, after_id=after, limit=PAGE)
|
||||
await session.commit()
|
||||
total += n
|
||||
if n < PAGE:
|
||||
break
|
||||
|
||||
print(f"\n[ok] outbox 적재 {n}건 — 발행 워커가 drain(flag on 시) 하며 rev 부여.")
|
||||
if n >= PAGE:
|
||||
print(f"[warn] PAGE({PAGE}) 도달 — progress 가 더 있을 수 있음. after_id 페이징 추가 필요.")
|
||||
print(f"\n[ok] outbox 적재 {total}건 — 발행 워커가 drain(flag on 시) 하며 rev 부여.")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
|
||||
@@ -31,8 +31,8 @@ from core.database import async_session
|
||||
from models.study_memo_card import StudyMemoCard
|
||||
from services.study.publish_enqueue import backfill_publish_cards
|
||||
|
||||
# 개인 학습툴 카드 수 대비 넉넉(단일 outbox 적재 tx, 워커는 BATCH_SIZE 로 drain). 도달 시 가드 경보.
|
||||
PAGE = 100000
|
||||
# 페이지 배치 크기 — after_id 루프로 전량 처리(bounded tx). 워커는 BATCH_SIZE 로 drain.
|
||||
PAGE = 5000
|
||||
|
||||
|
||||
async def run(dry_run: bool) -> None:
|
||||
@@ -55,13 +55,17 @@ async def run(dry_run: bool) -> None:
|
||||
print("[dry-run] 적재 안 함. 실제 실행은 --dry-run 제거.")
|
||||
return
|
||||
|
||||
async with async_session() as session:
|
||||
n = await backfill_publish_cards(session, after_id=0, limit=PAGE)
|
||||
await session.commit()
|
||||
total = 0
|
||||
after = 0
|
||||
while True:
|
||||
async with async_session() as session:
|
||||
n, after = await backfill_publish_cards(session, after_id=after, limit=PAGE)
|
||||
await session.commit()
|
||||
total += n
|
||||
if n < PAGE:
|
||||
break
|
||||
|
||||
print(f"\n[ok] outbox 적재 {n}건 — 발행 워커가 drain(flag on 시) 하며 rev 부여.")
|
||||
if n >= PAGE:
|
||||
print(f"[warn] PAGE({PAGE}) 도달 — 카드가 더 있을 수 있음. after_id 페이징 추가 필요.")
|
||||
print(f"\n[ok] outbox 적재 {total}건 — 발행 워커가 drain(flag on 시) 하며 rev 부여.")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
|
||||
@@ -37,7 +37,7 @@ from core.database import async_session
|
||||
from models.study_topic import StudyTopic
|
||||
from services.study.publish_enqueue import backfill_publish_topics
|
||||
|
||||
# 개인 학습툴 주제 수 대비 넉넉. 도달 시 overflow 가드가 경보.
|
||||
# 페이지 배치 크기 — after_id 루프로 전량 처리(bounded tx).
|
||||
PAGE = 5000
|
||||
|
||||
|
||||
@@ -58,13 +58,17 @@ async def run(dry_run: bool) -> None:
|
||||
print("[dry-run] 적재 안 함. 실제 실행은 --dry-run 제거.")
|
||||
return
|
||||
|
||||
async with async_session() as session:
|
||||
n = await backfill_publish_topics(session, after_id=0, limit=PAGE)
|
||||
await session.commit()
|
||||
total = 0
|
||||
after = 0
|
||||
while True:
|
||||
async with async_session() as session:
|
||||
n, after = await backfill_publish_topics(session, after_id=after, limit=PAGE)
|
||||
await session.commit()
|
||||
total += n
|
||||
if n < PAGE:
|
||||
break
|
||||
|
||||
print(f"\n[ok] outbox 적재 {n}건 — 발행 워커가 drain(flag on 시) 하며 rev 부여.")
|
||||
if n >= PAGE:
|
||||
print(f"[warn] PAGE({PAGE}) 도달 — 주제가 더 있을 수 있음. after_id 페이징 추가 필요.")
|
||||
print(f"\n[ok] outbox 적재 {total}건 — 발행 워커가 drain(flag on 시) 하며 rev 부여.")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
|
||||
@@ -106,7 +106,7 @@ async def main() -> None:
|
||||
"SELECT count(*) FROM pg_indexes WHERE indexname='uq_attempt_session_question'"))).scalar()
|
||||
mx = (await s.execute(text("SELECT max(version) FROM schema_migrations"))).scalar()
|
||||
print(f"SCHEMA OK — max_migration={mx} documents={docs} purge_col={purge} cand_qwen={cand} attempt_uq={uq}")
|
||||
assert docs and purge == 1 and cand == 0 and uq == 1 and mx == 361, "FAIL: 기대 스키마 상태 불일치"
|
||||
assert docs and purge == 1 and cand == 0 and uq == 1 and mx == 378, "FAIL: 기대 스키마 상태 불일치"
|
||||
|
||||
# ── 5) /health 직접 호출 ──────────────────────────────────────────────
|
||||
health = await main.health_check()
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
-- concept_links_backfill.sql — 개념↔문항 임베딩 링크 재생성 (Stage B, 멱등·재실행 안전).
|
||||
-- 정찰 확정: bge-m3 1024d 코사인, per-concept top-k=10, threshold 0.62 → ~2362링크·284/289개념·964문항.
|
||||
-- 재튜닝 시 DELETE(embedding 소스만) 후 재삽입 = ref 링크(후속) 불변. 개념 doc = 가스기사 태그.
|
||||
DELETE FROM study_concept_links WHERE link_source = 'embedding';
|
||||
INSERT INTO study_concept_links (concept_doc_id, question_id, link_source, score)
|
||||
WITH cd AS (
|
||||
SELECT id, embedding FROM documents
|
||||
WHERE user_tags::text LIKE '%@library/가스기사/%'
|
||||
AND deleted_at IS NULL AND embedding IS NOT NULL
|
||||
),
|
||||
ranked AS (
|
||||
SELECT cd.id AS concept_doc_id, q.id AS question_id,
|
||||
1 - (q.embedding <=> cd.embedding) AS score,
|
||||
row_number() OVER (PARTITION BY cd.id ORDER BY q.embedding <=> cd.embedding) AS rn
|
||||
FROM cd
|
||||
JOIN study_questions q
|
||||
ON q.study_topic_id = 4 AND q.embedding IS NOT NULL
|
||||
AND q.deleted_at IS NULL AND q.is_active
|
||||
)
|
||||
SELECT concept_doc_id, question_id, 'embedding', score
|
||||
FROM ranked
|
||||
WHERE rn <= 10 AND score >= 0.62
|
||||
ON CONFLICT (concept_doc_id, question_id, link_source) DO NOTHING;
|
||||
@@ -0,0 +1,100 @@
|
||||
#!/usr/bin/env python3
|
||||
"""기술지침(KOSHA guide) 절-KB persist: 번호섹션(# 1. 목적 / ## 4.1) 단위 분해 + 제본.
|
||||
ASME/법령과 동일 clause-KB 모델(doc_kind='clause', parent_id=지침, 검색제외, /book 리더 공용).
|
||||
Usage: python3 guide_clause_persist.py <id|all> [--commit]
|
||||
"""
|
||||
import asyncio, os, re, sys, hashlib, statistics
|
||||
|
||||
CAP = 12000; PAGE_TOK = 11000
|
||||
EN, KO = 0.217, 0.529
|
||||
# 번호섹션 헤더: '# 1. 목 적', '## 4.1 누출...' (번호 1~3자리=연도(4자리) 배제)
|
||||
ART_RE = re.compile(r'^#{1,6}\s*(\d{1,3}(?:\.\d{1,3})*)\.?\s+(\S.*)$')
|
||||
TOP_RE = re.compile(r'^\d{1,3}$')
|
||||
# 외부 표준/법규 참조(대부분 dangling): ASME B16.5 · KS B 1501 · 규칙 제N조
|
||||
EXT_RE = re.compile(r'(ASME\s+[A-Z][0-9.]+|KS\s+[A-Z]\s*[0-9]+|ISO\s+[0-9]+|제\d+조)')
|
||||
|
||||
def tok(s):
|
||||
ko = sum(1 for c in s if '가' <= c <= '힣'); return int((len(s)-ko)*EN + ko*KO)
|
||||
|
||||
def build_sections(text):
|
||||
lines = text.split('\n'); off = []; a = 0
|
||||
for ln in lines: off.append(a); a += len(ln) + 1
|
||||
bounds = []; seen = set()
|
||||
for i, ln in enumerate(lines):
|
||||
m = ART_RE.match(ln)
|
||||
if not m: continue
|
||||
code, name = m.group(1), m.group(2).strip()
|
||||
if not TOP_RE.match(code): continue # top-level 번호섹션만 경계
|
||||
if code in seen: continue
|
||||
if len(name) < 1: continue
|
||||
seen.add(code); bounds.append((off[i], code, name))
|
||||
out = []
|
||||
for idx, (start, code, name) in enumerate(bounds):
|
||||
end = bounds[idx+1][0] if idx+1 < len(bounds) else len(text)
|
||||
body = text[start:end].strip()
|
||||
ext = sorted(set(EXT_RE.findall(body)))[:8]
|
||||
out.append(dict(code=code, part='본문', order=0, title=f"{code}. {name}"[:120],
|
||||
body=body, tok=tok(body), links=[], ext=ext))
|
||||
# over-CAP 페이지네이션 + 순번
|
||||
final, order = [], 0
|
||||
for c in out:
|
||||
if c['tok'] <= CAP:
|
||||
final.append({**c, 'order': order}); order += 1; continue
|
||||
pages, cur, ct = [], [], 0
|
||||
for ln in c['body'].split('\n'):
|
||||
lt = tok(ln)+1
|
||||
if ct+lt > PAGE_TOK and cur: pages.append('\n'.join(cur)); cur=[ln]; ct=lt
|
||||
else: cur.append(ln); ct+=lt
|
||||
if cur: pages.append('\n'.join(cur))
|
||||
for pi, pb in enumerate(pages):
|
||||
final.append(dict(code=c['code'] if pi==0 else f"{c['code']}·p{pi+1}", part='본문',
|
||||
order=order, title=c['title'] if pi==0 else f"{c['title']} (p{pi+1})",
|
||||
body=pb, tok=tok(pb), links=[], ext=[]))
|
||||
order += 1
|
||||
return final
|
||||
|
||||
async def process_one(conn, gid, commit, verbose=True):
|
||||
row = await conn.fetchrow("SELECT title, md_content, ai_domain, data_origin FROM documents WHERE id=$1", gid)
|
||||
if not row: return ('notfound', 0)
|
||||
if not row['md_content']: return ('nullmd', 0)
|
||||
secs = build_sections(row['md_content'])
|
||||
if len(secs) < 2: return ('few', len(secs)) # 섹션 2 미만 = 번호구조 아님
|
||||
toks = [c['tok'] for c in secs]
|
||||
if verbose:
|
||||
print(f"guide={gid} «{(row['title'] or '')[:40]}» 섹션={len(secs)} median={int(statistics.median(toks))} max={max(toks)}")
|
||||
print(" 샘플:", [c['title'][:26] for c in secs[:7]])
|
||||
if not commit: return ('dry', len(secs))
|
||||
async with conn.transaction():
|
||||
await conn.execute("DELETE FROM clause_links WHERE src_doc_id IN (SELECT id FROM documents WHERE parent_id=$1 AND doc_kind='clause')", gid)
|
||||
await conn.execute("DELETE FROM documents WHERE parent_id=$1 AND doc_kind='clause'", gid)
|
||||
for c in secs:
|
||||
fh = hashlib.sha256(f"{gid}:{c['code']}:{c['body']}".encode()).hexdigest()
|
||||
cid = await conn.fetchval("""
|
||||
INSERT INTO documents (file_format,file_hash,title,md_content,parent_id,doc_kind,
|
||||
clause_code,clause_part,clause_order,ai_domain,data_origin,
|
||||
md_status,review_status,conversion_status,preview_status)
|
||||
VALUES ('md',$1,$2,$3,$4,'clause',$5,$6,$7,$8,$9,'success','approved','none','none') RETURNING id
|
||||
""", fh, c['title'], c['body'], gid, c['code'], c['part'], c['order'], row['ai_domain'], row['data_origin'] or 'external')
|
||||
await conn.execute("INSERT INTO document_tags(doc_id,tag,tag_kind) VALUES ($1,'기술지침','kind') ON CONFLICT DO NOTHING", cid)
|
||||
n = await conn.fetchval("SELECT count(*) FROM documents WHERE parent_id=$1 AND doc_kind='clause'", gid)
|
||||
print(f" COMMITTED: {n} 섹션 for guide {gid}")
|
||||
return ('committed', len(secs))
|
||||
|
||||
async def main():
|
||||
import asyncpg
|
||||
arg = sys.argv[1]; commit = '--commit' in sys.argv
|
||||
conn = await asyncpg.connect(os.environ['DATABASE_URL'].replace('+asyncpg', ''))
|
||||
if arg == 'all':
|
||||
gs = await conn.fetch("SELECT id FROM documents WHERE material_type='guide' AND doc_kind='standard' "
|
||||
"AND deleted_at IS NULL AND md_content IS NOT NULL ORDER BY id")
|
||||
agg = {}; tot = 0
|
||||
for i, r in enumerate(gs):
|
||||
st, n = await process_one(conn, r['id'], commit, verbose=False)
|
||||
agg[st] = agg.get(st, 0)+1; tot += n if st in ('dry','committed') else 0
|
||||
if commit and (i+1) % 40 == 0: print(f" …{i+1}/{len(gs)} (누적섹션 {tot})")
|
||||
print(f"BATCH {'COMMIT' if commit else 'DRY'} guides={len(gs)} status={agg} 총섹션={tot}")
|
||||
else:
|
||||
await process_one(conn, int(arg), commit, verbose=True)
|
||||
await conn.close()
|
||||
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,146 @@
|
||||
#!/usr/bin/env python3
|
||||
"""법령 조-KB persist: 법령을 조(條) 단위 개별 문서로 분해 + 조↔조 백링크 + 장(章) 태그.
|
||||
ASME clause-KB와 동일 모델(doc_kind='clause', parent_id=법령, embedding NULL, 검색제외).
|
||||
법령 추출 노이즈(조 앞 ### 메타 반복) 트림. Usage: python3 law_clause_persist.py <law_id> [--commit]
|
||||
"""
|
||||
import asyncio, os, re, sys, hashlib, statistics
|
||||
|
||||
CAP = 12000; PAGE_TOK = 11000
|
||||
EN, KO = 0.217, 0.529
|
||||
# 조 헤더: '### 제3조의2(가스안전관리...) 본문'
|
||||
ART_RE = re.compile(r'^#{0,6}\s*(제\d+조(?:의\d+)?)\s*\(([^)]*)\)\s*(.*)$')
|
||||
CHAP_RE = re.compile(r'^#{1,6}\s*(제\d+장(?:의\d+)?)\s*(.*)$') # 장 = part
|
||||
# 같은-법 조 멘션(백링크)
|
||||
MENTION_RE = re.compile(r'제\d+조(?:의\d+)?')
|
||||
# 타법 참조: 「법명」 ... 제N조
|
||||
EXTLAW_RE = re.compile(r'「([^」]+)」')
|
||||
|
||||
def tok(s):
|
||||
ko = sum(1 for c in s if '가' <= c <= '힣'); return int((len(s)-ko)*EN + ko*KO)
|
||||
def art_code(c): return c # '제3조의2'
|
||||
|
||||
def build_articles(text):
|
||||
lines = text.split('\n'); off = []; a = 0
|
||||
for ln in lines: off.append(a); a += len(ln) + 1
|
||||
arts = [] # (line_idx, code, name, part)
|
||||
cur_part = None
|
||||
for i, ln in enumerate(lines):
|
||||
ch = CHAP_RE.match(ln)
|
||||
if ch and not ART_RE.match(ln):
|
||||
cur_part = (ch.group(1) + (' ' + ch.group(2).strip() if ch.group(2).strip() else '')).strip()
|
||||
continue
|
||||
m = ART_RE.match(ln)
|
||||
if m:
|
||||
arts.append((i, m.group(1), m.group(2).strip(), cur_part))
|
||||
# 본문 슬라이스 + 다음 조 앞 메타 노이즈 트림
|
||||
out = []
|
||||
for idx, (li, code, name, part) in enumerate(arts):
|
||||
end_li = arts[idx+1][0] if idx+1 < len(arts) else len(lines)
|
||||
body_lines = lines[li:end_li]
|
||||
# 트림: 끝에서부터 '### {짧은 메타}' (조번호/조문/날짜/제목, [개정] 제N조 아님) 제거
|
||||
while len(body_lines) > 1:
|
||||
last = body_lines[-1].strip()
|
||||
if last == '':
|
||||
body_lines.pop(); continue
|
||||
mh = re.match(r'^#{1,6}\s+(.*)$', last)
|
||||
if mh:
|
||||
c = mh.group(1).strip()
|
||||
if not c.startswith('[') and not c.startswith('제') and (
|
||||
c in ('조문', 'N') or re.fullmatch(r'\d+', c) or re.fullmatch(r'\d{8}', c) or len(c) <= 30):
|
||||
body_lines.pop(); continue
|
||||
break
|
||||
body = '\n'.join(body_lines).strip()
|
||||
links = sorted(set(MENTION_RE.findall(body)) - {code})
|
||||
ext = sorted(set(EXTLAW_RE.findall(body)))[:6]
|
||||
out.append(dict(code=code, part=part or '본칙', order=0,
|
||||
title=f"{code}({name})" if name else code,
|
||||
body=body, tok=tok(body), links=links, ext=ext))
|
||||
# 페이지네이션(over-CAP) + 순번
|
||||
final, order = [], 0
|
||||
for c in out:
|
||||
if c['tok'] <= CAP:
|
||||
final.append({**c, 'order': order}); order += 1; continue
|
||||
# 11K 토큰 라인 단위 분할
|
||||
pages, cur, ct = [], [], 0
|
||||
for ln in c['body'].split('\n'):
|
||||
lt = tok(ln)+1
|
||||
if ct+lt > PAGE_TOK and cur: pages.append('\n'.join(cur)); cur=[ln]; ct=lt
|
||||
else: cur.append(ln); ct+=lt
|
||||
if cur: pages.append('\n'.join(cur))
|
||||
for pi, pb in enumerate(pages):
|
||||
final.append(dict(code=c['code'] if pi==0 else f"{c['code']}·p{pi+1}", part=c['part'],
|
||||
order=order, title=c['title'] if pi==0 else f"{c['title']} (p{pi+1}/{len(pages)})",
|
||||
body=pb, tok=tok(pb), links=c['links'] if pi==0 else [], ext=[]))
|
||||
order += 1
|
||||
return final
|
||||
|
||||
async def process_one(conn, law, commit, verbose=True):
|
||||
row = await conn.fetchrow("SELECT title, coalesce(md_content, extracted_text) AS md_content, ai_domain, data_origin FROM documents WHERE id=$1", law)
|
||||
if not row: return ('notfound', 0, 0)
|
||||
if not row['md_content']: return ('nullmd', 0, 0)
|
||||
arts = build_articles(row['md_content'])
|
||||
if not arts: return ('noart', 0, 0)
|
||||
toks = [c['tok'] for c in arts]
|
||||
nlink = sum(len(c['links']) for c in arts)
|
||||
if verbose:
|
||||
parts = {}
|
||||
for c in arts: parts[c['part']] = parts.get(c['part'], 0)+1
|
||||
print(f"law={law} «{(row['title'] or '')[:34]}» 조문={len(arts)} median={int(statistics.median(toks))} "
|
||||
f"max={max(toks)} 장={len(parts)} 백링크={nlink}")
|
||||
print(" 샘플:", [c['title'][:22] for c in arts[:6]])
|
||||
if not commit:
|
||||
return ('dry', len(arts), nlink)
|
||||
async with conn.transaction():
|
||||
await conn.execute(
|
||||
"DELETE FROM clause_links WHERE src_doc_id IN (SELECT id FROM documents WHERE parent_id=$1 AND doc_kind='clause')", law)
|
||||
await conn.execute("DELETE FROM documents WHERE parent_id=$1 AND doc_kind='clause'", law)
|
||||
code2id = {}
|
||||
for c in arts:
|
||||
fh = hashlib.sha256(f"{law}:{c['code']}:{c['body']}".encode()).hexdigest()
|
||||
cid = await conn.fetchval("""
|
||||
INSERT INTO documents (file_format,file_hash,title,md_content,parent_id,doc_kind,
|
||||
clause_code,clause_part,clause_order,ai_domain,data_origin,
|
||||
md_status,review_status,conversion_status,preview_status)
|
||||
VALUES ('md',$1,$2,$3,$4,'clause',$5,$6,$7,$8,$9,'success','approved','none','none') RETURNING id
|
||||
""", fh, c['title'], c['body'], law, c['code'], c['part'], c['order'],
|
||||
row['ai_domain'], row['data_origin'] or 'external')
|
||||
code2id[c['code']] = cid
|
||||
await conn.execute("INSERT INTO document_tags(doc_id,tag,tag_kind) VALUES ($1,$2,'chapter') ON CONFLICT DO NOTHING", cid, c['part'])
|
||||
# 조↔조 백링크 (같은 법 내부; 타법 참조는 dangling)
|
||||
edges = []
|
||||
for c in arts:
|
||||
src = code2id[c['code']]
|
||||
for dst in c['links']:
|
||||
edges.append((src, dst, code2id.get(dst), None, None, None))
|
||||
if edges:
|
||||
await conn.executemany(
|
||||
"INSERT INTO clause_links(src_doc_id,dst_code,dst_doc_id,anchor,ctx,char_off) VALUES ($1,$2,$3,$4,$5,$6)", edges)
|
||||
n = await conn.fetchval("SELECT count(*) FROM documents WHERE parent_id=$1 AND doc_kind='clause'", law)
|
||||
print(f" COMMITTED: {n} 조문 + {len(edges)} 백링크 for law {law}")
|
||||
return ('committed', n, len(edges))
|
||||
|
||||
|
||||
async def main():
|
||||
import asyncpg
|
||||
arg = sys.argv[1]; commit = '--commit' in sys.argv
|
||||
conn = await asyncpg.connect(os.environ['DATABASE_URL'].replace('+asyncpg', ''))
|
||||
if arg == 'all':
|
||||
laws = await conn.fetch("SELECT lm.document_id AS id FROM legal_meta lm "
|
||||
"JOIN documents d ON d.id=lm.document_id "
|
||||
"WHERE lm.law_doc_kind='primary' AND lm.version_status='current' "
|
||||
"AND coalesce(d.md_content, d.extracted_text) IS NOT NULL "
|
||||
"ORDER BY lm.document_id")
|
||||
agg = {}; tot_art = tot_link = 0; zero = []
|
||||
for i, r in enumerate(laws):
|
||||
st, na, nl = await process_one(conn, r['id'], commit, verbose=False)
|
||||
agg[st] = agg.get(st, 0) + 1
|
||||
tot_art += na; tot_link += nl
|
||||
if st == 'noart': zero.append(r['id'])
|
||||
if commit and (i + 1) % 30 == 0: print(f" …{i+1}/{len(laws)} (누적 조 {tot_art})")
|
||||
print(f"BATCH {'COMMIT' if commit else 'DRY'} laws={len(laws)} status={agg} 총조문={tot_art} 총백링크={tot_link}")
|
||||
if zero: print(f" 0-조(추출구조 이질) {len(zero)}건: {zero[:20]}")
|
||||
else:
|
||||
await process_one(conn, int(arg), commit, verbose=True)
|
||||
await conn.close()
|
||||
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env python3
|
||||
"""논문 인용그래프 가능성 측정(read-only) — 본문 DOI로 코퍼스내 인용 엣지 추정.
|
||||
own_doi = 헤더(앞 2500자) 첫 DOI / cited = References 이후(또는 전체) DOI. owner 맵 → 엣지.
|
||||
"""
|
||||
import asyncio, os, re, sys
|
||||
|
||||
DOI_RE = re.compile(r'10\.\d{4,9}/[^\s"<>)\]\},;]+')
|
||||
REF_RE = re.compile(r'(references|참고문헌|bibliography|reference\s*list)', re.I)
|
||||
|
||||
def norm(d): return d.rstrip('.').lower()
|
||||
|
||||
async def main():
|
||||
import asyncpg
|
||||
conn = await asyncpg.connect(os.environ['DATABASE_URL'].replace('+asyncpg', ''))
|
||||
rows = await conn.fetch("SELECT id, title, coalesce(md_content, extracted_text) AS txt FROM documents "
|
||||
"WHERE material_type='paper' AND doc_kind='standard' AND deleted_at IS NULL "
|
||||
"AND coalesce(md_content, extracted_text) IS NOT NULL")
|
||||
owner = {} # doi -> paper id (헤더 DOI = 그 논문 소유)
|
||||
cited = {} # paper id -> set(cited doi)
|
||||
n_own = n_refsec = 0
|
||||
for r in rows:
|
||||
txt = r['txt']
|
||||
head = txt[:2500]
|
||||
hdois = [norm(d) for d in DOI_RE.findall(head)]
|
||||
if hdois:
|
||||
owner.setdefault(hdois[0], r['id']); n_own += 1
|
||||
m = REF_RE.search(txt)
|
||||
body = txt[m.start():] if m else ''
|
||||
if m: n_refsec += 1
|
||||
cds = set(norm(d) for d in DOI_RE.findall(body))
|
||||
if cds: cited[r['id']] = cds
|
||||
# 엣지: paper -> owner(cited doi)
|
||||
edges = []
|
||||
for pid, cds in cited.items():
|
||||
for d in cds:
|
||||
o = owner.get(d)
|
||||
if o and o != pid: edges.append((pid, o, d))
|
||||
cited_papers = set(e[0] for e in edges)
|
||||
target_papers = set(e[1] for e in edges)
|
||||
print(f"papers={len(rows)} 헤더DOI보유={n_own} References보유={n_refsec} owner_map={len(owner)}")
|
||||
print(f"인용엣지(코퍼스내)={len(edges)} 인용하는논문={len(cited_papers)} 피인용논문={len(target_papers)}")
|
||||
# 피인용 top
|
||||
from collections import Counter
|
||||
top = Counter(e[1] for e in edges).most_common(6)
|
||||
if top:
|
||||
idmap = {r['id']: r['title'] for r in rows}
|
||||
print("피인용 top:")
|
||||
for pid, c in top: print(f" {c}회 ← {(idmap.get(pid) or '')[:48]}")
|
||||
await conn.close()
|
||||
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env python3
|
||||
"""OpenAlex 고신뢰 매치율 측정 — References 보유 논문(학술 추정) 표본."""
|
||||
import asyncio, os, re
|
||||
|
||||
def toks(s):
|
||||
return set(re.findall(r'[a-z0-9]+', (s or '').lower()))
|
||||
def sim(a, b):
|
||||
ta, tb = toks(a), toks(b)
|
||||
if not ta or not tb: return 0.0
|
||||
return len(ta & tb) / len(ta | tb)
|
||||
|
||||
async def main():
|
||||
import asyncpg, httpx
|
||||
conn = await asyncpg.connect(os.environ['DATABASE_URL'].replace('+asyncpg', ''))
|
||||
rows = await conn.fetch("SELECT id, title FROM documents WHERE material_type='paper' "
|
||||
"AND doc_kind='standard' AND deleted_at IS NULL AND title IS NOT NULL "
|
||||
"AND coalesce(md_content,extracted_text) ~* 'references|참고문헌' "
|
||||
"ORDER BY id LIMIT 40")
|
||||
hi = mid = lo = 0; hits = []
|
||||
async with httpx.AsyncClient(timeout=20) as client:
|
||||
for r in rows:
|
||||
title = re.sub(r'\s+', ' ', r['title']).strip()
|
||||
try:
|
||||
resp = await client.get("https://api.openalex.org/works",
|
||||
params={"search": title[:200], "per_page": 1, "mailto": "hyun49196@gmail.com"})
|
||||
res = (resp.json().get("results") or [])
|
||||
if not res: lo += 1; continue
|
||||
s = sim(title, res[0].get("title"))
|
||||
if s >= 0.6: hi += 1; hits.append((s, title[:40], (res[0].get('title') or '')[:40], res[0].get('cited_by_count'), len(res[0].get('referenced_works') or [])))
|
||||
elif s >= 0.4: mid += 1
|
||||
else: lo += 1
|
||||
except Exception: lo += 1
|
||||
print(f"표본={len(rows)} 고신뢰(≥0.6)={hi} 중간(0.4~0.6)={mid} 저신뢰/무매치={lo}")
|
||||
print("고신뢰 매치 샘플:")
|
||||
for s, a, b, cb, rf in hits[:8]:
|
||||
print(f" sim={s:.2f} cited={cb} refs={rf} | {a} ≈ {b}")
|
||||
await conn.close()
|
||||
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,30 @@
|
||||
#!/usr/bin/env python3
|
||||
"""OpenAlex 보강 타당성 테스트 — 소수 논문 제목으로 매칭/메타 확인 (외부 API)."""
|
||||
import asyncio, os, re
|
||||
|
||||
async def main():
|
||||
import asyncpg, httpx
|
||||
conn = await asyncpg.connect(os.environ['DATABASE_URL'].replace('+asyncpg', ''))
|
||||
rows = await conn.fetch("SELECT id, title FROM documents WHERE material_type='paper' "
|
||||
"AND doc_kind='standard' AND deleted_at IS NULL AND title IS NOT NULL "
|
||||
"AND length(title) > 15 ORDER BY id LIMIT 6")
|
||||
async with httpx.AsyncClient(timeout=20) as client:
|
||||
for r in rows:
|
||||
title = re.sub(r'\s+', ' ', r['title']).strip()
|
||||
try:
|
||||
resp = await client.get("https://api.openalex.org/works",
|
||||
params={"search": title[:200], "per_page": 1, "mailto": "hyun49196@gmail.com"})
|
||||
js = resp.json()
|
||||
res = (js.get("results") or [])
|
||||
if not res:
|
||||
print(f"[{r['id']}] NO MATCH | {title[:50]}"); continue
|
||||
w = res[0]
|
||||
oid = (w.get("id") or "").split("/")[-1]
|
||||
print(f"[{r['id']}] {title[:46]}")
|
||||
print(f" → OA {oid} | {(w.get('title') or '')[:46]} | {w.get('publication_year')} | "
|
||||
f"cited_by={w.get('cited_by_count')} | refs={len(w.get('referenced_works') or [])} | doi={w.get('doi')}")
|
||||
except Exception as e:
|
||||
print(f"[{r['id']}] ERROR {type(e).__name__}: {e}")
|
||||
await conn.close()
|
||||
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,456 @@
|
||||
"""presegment PR3 — HOLD 거대문서 유인 분할 CLI (plan ds-presegment-mapreduce-2).
|
||||
|
||||
deep_summary 워커가 HOLD(payload.presegment.awaiting_split=true) 로 보류한
|
||||
hybrid/whole tier 거대문서를, 사람이(유인 클로드 세션) 경계를 완성해 재개시키는 도구.
|
||||
|
||||
사용법 (fastapi 컨테이너 안에서 실행):
|
||||
docker compose exec fastapi python /app/scripts/presegment_attended.py list
|
||||
docker compose exec fastapi python /app/scripts/presegment_attended.py export --doc 44443 --out /app/logs/preseg_44443
|
||||
docker compose exec fastapi python /app/scripts/presegment_attended.py apply --doc 44443 --boundaries /app/logs/preseg_44443/boundaries_template.json --dry-run
|
||||
docker compose exec fastapi python /app/scripts/presegment_attended.py apply --doc 44443 --boundaries /app/logs/preseg_44443/boundaries_template.json
|
||||
|
||||
워크플로우:
|
||||
1. list — awaiting_split 문서 확인.
|
||||
2. export — 문서 통계·hier 개요·자동 pack 유닛 제안·초과 섹션 본문 덤프·boundaries
|
||||
템플릿 JSON 생성. 유인 클로드 세션은 이 파일들만 읽고 TODO 스팬을
|
||||
CAP 이하 경계들로 분할해 템플릿을 완성한다.
|
||||
3. apply — 완성된 boundaries 검증(단조·비중첩·범위·커버리지 90%+·유닛 캡) 후
|
||||
payload.presegment.units_override 기록 + awaiting_split 해제 +
|
||||
deferred_until 제거(즉시 재개). 워커가 다음 사이클에 map-reduce 재개.
|
||||
|
||||
stdout 규약: 사람이 읽는 요약 행 + '{' 로 시작하는 기계 파싱용 JSON 라인(1건 1라인).
|
||||
사람용 행은 절대 '{' 로 시작하지 않는다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "app"))
|
||||
|
||||
from sqlalchemy import text as sql_text # noqa: E402
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine # noqa: E402
|
||||
|
||||
from services.hier_decomp.builder import build_hier_tree # noqa: E402
|
||||
from services.summarize_units import ( # noqa: E402
|
||||
CAP_TOKENS,
|
||||
OVERRIDE_MIN_COVERAGE_PCT,
|
||||
TRIGGER_TOKENS,
|
||||
choose_override_source,
|
||||
estimate_tokens,
|
||||
extract_leaves,
|
||||
leaf_spans,
|
||||
plan_summarize_units,
|
||||
validate_override_boundaries,
|
||||
)
|
||||
|
||||
# 초과 섹션 본문 덤프 분할 단위 (유인 세션 컨텍스트 보호)
|
||||
DUMP_CHUNK_CHARS = 200_000
|
||||
|
||||
|
||||
def _jsonl(obj: dict) -> None:
|
||||
"""기계 파싱용 JSON 라인 — 반드시 '{' 로 시작하는 단독 라인."""
|
||||
print(json.dumps(obj, ensure_ascii=False, default=str))
|
||||
|
||||
|
||||
def _session_factory():
|
||||
database_url = os.getenv(
|
||||
"DATABASE_URL",
|
||||
"postgresql+asyncpg://pkm:pkm@postgres:5432/pkm",
|
||||
)
|
||||
engine = create_async_engine(database_url)
|
||||
return engine, async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
|
||||
# ─── list ────────────────────────────────────────────────────────────────────
|
||||
|
||||
LIST_SQL = """
|
||||
SELECT q.id AS queue_id, q.document_id, q.status, q.attempts,
|
||||
q.payload::text AS payload_text,
|
||||
LEFT(COALESCE(d.title, '(제목 없음)'), 80) AS title
|
||||
FROM processing_queue q
|
||||
JOIN documents d ON d.id = q.document_id
|
||||
WHERE q.stage = 'deep_summary'
|
||||
AND q.status IN ('pending', 'processing', 'failed')
|
||||
AND (q.payload -> 'presegment' ->> 'awaiting_split') = 'true'
|
||||
ORDER BY q.id
|
||||
"""
|
||||
|
||||
|
||||
async def cmd_list() -> int:
|
||||
engine, factory = _session_factory()
|
||||
try:
|
||||
async with factory() as session:
|
||||
rows = (await session.execute(sql_text(LIST_SQL))).mappings().all()
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
print(f"awaiting_split 보류 문서 {len(rows)}건")
|
||||
for r in rows:
|
||||
payload = json.loads(r["payload_text"] or "{}")
|
||||
preseg = payload.get("presegment") or {}
|
||||
oversized = preseg.get("oversized_sections") or []
|
||||
print(
|
||||
f" doc {r['document_id']} [{r['title']}] queue={r['queue_id']} status={r['status']} "
|
||||
f"tier={preseg.get('tier')} over%={preseg.get('over_pct')} "
|
||||
f"tokens={preseg.get('total_est_tokens'):,} units={preseg.get('units')}"
|
||||
if isinstance(preseg.get("total_est_tokens"), int)
|
||||
else f" doc {r['document_id']} [{r['title']}] queue={r['queue_id']} status={r['status']}"
|
||||
)
|
||||
print(f" 초과 섹션 {len(oversized)}건: {', '.join(str(t) for t in oversized[:3] if t)}")
|
||||
print(
|
||||
f" 보류 알람={preseg.get('alerted_at') or '-'} / "
|
||||
f"재개 예정={payload.get('deferred_until') or '(즉시)'}"
|
||||
+ (f" / 거부 사유={preseg.get('override_rejected')}" if preseg.get("override_rejected") else "")
|
||||
)
|
||||
_jsonl({
|
||||
"cmd": "list",
|
||||
"queue_id": r["queue_id"],
|
||||
"doc_id": r["document_id"],
|
||||
"title": r["title"],
|
||||
"status": r["status"],
|
||||
"tier": preseg.get("tier"),
|
||||
"over_pct": preseg.get("over_pct"),
|
||||
"total_est_tokens": preseg.get("total_est_tokens"),
|
||||
"units": preseg.get("units"),
|
||||
"oversized_sections": oversized,
|
||||
"alerted_at": preseg.get("alerted_at"),
|
||||
"deferred_until": payload.get("deferred_until"),
|
||||
"override_rejected": preseg.get("override_rejected"),
|
||||
})
|
||||
return 0
|
||||
|
||||
|
||||
# ─── export ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def _safe_name(title: str | None, fallback: str) -> str:
|
||||
t = re.sub(r"[^0-9A-Za-z가-힣._-]+", "_", (title or fallback)).strip("_")
|
||||
return (t or fallback)[:60]
|
||||
|
||||
|
||||
def _build_outline(text: str) -> str:
|
||||
"""hier_decomp builder 재사용 — window-split 억제(요약 계획과 동일 환경) 개요."""
|
||||
nodes = build_hier_tree(text, leaf_target_max=sys.maxsize, leaf_hard_max=sys.maxsize)
|
||||
lines = []
|
||||
for n in nodes:
|
||||
indent = " " * max(n.level, 0)
|
||||
title = n.section_title or "(preamble)"
|
||||
tok = estimate_tokens(n.text)
|
||||
mark = " [CAP 초과]" if n.is_leaf and tok > CAP_TOKENS else ""
|
||||
lines.append(f"{indent}- L{n.level} {title} — {tok:,} tok{mark}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
async def cmd_export(doc_id: int, out_dir: str) -> int:
|
||||
engine, factory = _session_factory()
|
||||
try:
|
||||
async with factory() as session:
|
||||
row = (await session.execute(
|
||||
sql_text(
|
||||
"SELECT id, title, md_content, extracted_text FROM documents WHERE id = :d"
|
||||
),
|
||||
{"d": doc_id},
|
||||
)).mappings().first()
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
if not row:
|
||||
print(f"[error] 문서 id={doc_id} 없음")
|
||||
_jsonl({"cmd": "export", "ok": False, "doc_id": doc_id, "error": "document_not_found"})
|
||||
return 1
|
||||
|
||||
source, text = choose_override_source(row["md_content"], row["extracted_text"])
|
||||
if not text.strip():
|
||||
print(f"[error] 문서 id={doc_id} 본문 비어있음 (md_content/extracted_text 둘 다)")
|
||||
_jsonl({"cmd": "export", "ok": False, "doc_id": doc_id, "error": "empty_text"})
|
||||
return 1
|
||||
|
||||
plan = plan_summarize_units(text)
|
||||
leaves = extract_leaves(text)
|
||||
spans = leaf_spans(text, leaves)
|
||||
|
||||
out = Path(out_dir)
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
files: list[str] = []
|
||||
now_iso = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
|
||||
# ① 통계 + hier 개요
|
||||
oversized_units = [u for u in plan.units if u.over_cap]
|
||||
overview = [
|
||||
f"# presegment export — doc {doc_id}",
|
||||
"",
|
||||
f"- 제목: {row['title'] or '(제목 없음)'}",
|
||||
f"- source: {source} (len={len(text):,}자, 오프셋 기준 텍스트)",
|
||||
f"- 추정 토큰: {plan.total_est_tokens:,} (trigger={TRIGGER_TOKENS:,} / cap={CAP_TOKENS:,})",
|
||||
f"- plan: mode={plan.mode} tier={plan.tier} over%={plan.over_pct}",
|
||||
f"- 유닛: 자동 pack {len(plan.units) - len(oversized_units)}개 + CAP 초과 {len(oversized_units)}개",
|
||||
f"- 생성: {now_iso}",
|
||||
"",
|
||||
"## 유닛 제안 (summarize_units greedy-pack)",
|
||||
"",
|
||||
]
|
||||
for u in plan.units:
|
||||
if not u.leaf_indexes:
|
||||
continue
|
||||
s = spans[u.leaf_indexes[0]][0]
|
||||
e = spans[u.leaf_indexes[-1]][1]
|
||||
titles = " · ".join(t for t in u.section_titles if t) or "(무제 구간)"
|
||||
flag = " ★CAP 초과 — 분할 필요" if u.over_cap else ""
|
||||
overview.append(f"- 유닛 {u.index}: [{s}, {e}) {u.est_tokens:,} tok — {titles[:120]}{flag}")
|
||||
overview += ["", "## hier 개요", "", _build_outline(text), ""]
|
||||
(out / "overview.md").write_text("\n".join(overview), encoding="utf-8")
|
||||
files.append("overview.md")
|
||||
|
||||
# ③ 초과 섹션 본문 덤프 (섹션당 파일 · 200K자 단위 분할 · 파일명에 절대 스팬)
|
||||
boundaries: list[dict] = []
|
||||
for u in plan.units:
|
||||
if not u.leaf_indexes:
|
||||
continue
|
||||
s = spans[u.leaf_indexes[0]][0]
|
||||
e = spans[u.leaf_indexes[-1]][1]
|
||||
title = next((t for t in u.section_titles if t), None)
|
||||
if not u.over_cap:
|
||||
# ④ 자동 pack 유닛은 템플릿에 채워둔다
|
||||
boundaries.append({"start": s, "end": e, "title": title or f"유닛 {u.index}"})
|
||||
continue
|
||||
boundaries.append({
|
||||
"start": s, "end": e, "title": title or f"유닛 {u.index}",
|
||||
"todo": (
|
||||
f"CAP 초과({u.est_tokens:,} tok > {CAP_TOKENS:,}) — 이 스팬을 cap 이하 "
|
||||
"경계 여러 개로 교체하고 todo 키를 제거할 것"
|
||||
),
|
||||
})
|
||||
seg = text[s:e]
|
||||
base = f"oversized_{u.index:03d}_{_safe_name(title, f'unit{u.index}')}"
|
||||
for k in range(0, len(seg), DUMP_CHUNK_CHARS):
|
||||
cs, ce = s + k, s + min(k + DUMP_CHUNK_CHARS, len(seg))
|
||||
fname = f"{base}.{cs}_{ce}.md"
|
||||
# 본문 원문 그대로 (헤더 미부착 — 파일 내 로컬 오프셋 + 파일명 cs 로 절대 오프셋 계산)
|
||||
(out / fname).write_text(text[cs:ce], encoding="utf-8")
|
||||
files.append(fname)
|
||||
|
||||
# ④ boundaries 템플릿 JSON
|
||||
template = {
|
||||
"doc_id": doc_id,
|
||||
"source": source,
|
||||
"source_len": len(text),
|
||||
"cap_tokens": CAP_TOKENS,
|
||||
"generated_at": now_iso,
|
||||
"boundaries": boundaries,
|
||||
}
|
||||
(out / "boundaries_template.json").write_text(
|
||||
json.dumps(template, ensure_ascii=False, indent=2), encoding="utf-8"
|
||||
)
|
||||
files.append("boundaries_template.json")
|
||||
|
||||
# 유인 세션용 작업 안내
|
||||
readme = f"""# doc {doc_id} 유인 분할 안내
|
||||
|
||||
1. overview.md 로 구조 파악 (유닛 제안 + hier 개요).
|
||||
2. oversized_*.md 본문을 읽고 의미 경계를 정한다.
|
||||
- 파일명 `..._<cs>_<ce>.md` 의 cs = 파일 첫 문자의 절대 오프셋.
|
||||
- 절대 오프셋 = cs + 파일 내 로컬 오프셋.
|
||||
3. boundaries_template.json 의 todo 항목을 cap({CAP_TOKENS:,} tok) 이하 경계 여러 개로
|
||||
교체하고 todo 키를 제거한다. 나머지 자동 pack 항목은 그대로 둬도 된다.
|
||||
- 토큰 추정: 한글 0.529 tok/자 · 기타 0.217 tok/자 (services/summarize_units.py).
|
||||
- 규칙: start 단조증가 · 비중첩 · 전체 커버리지 {OVERRIDE_MIN_COVERAGE_PCT:.0f}%+ · 유닛당 cap 이하.
|
||||
4. 검증/적용:
|
||||
python /app/scripts/presegment_attended.py apply --doc {doc_id} --boundaries <FILE> --dry-run
|
||||
python /app/scripts/presegment_attended.py apply --doc {doc_id} --boundaries <FILE>
|
||||
"""
|
||||
(out / "README.md").write_text(readme, encoding="utf-8")
|
||||
files.append("README.md")
|
||||
|
||||
print(f"doc {doc_id} [{row['title'] or '(제목 없음)'}] export 완료 → {out}")
|
||||
print(
|
||||
f" source={source} len={len(text):,}자 tokens={plan.total_est_tokens:,} "
|
||||
f"tier={plan.tier} over%={plan.over_pct}"
|
||||
)
|
||||
print(f" 자동 pack 유닛 {len(plan.units) - len(oversized_units)}개 / TODO(초과) {len(oversized_units)}개")
|
||||
print(f" 파일 {len(files)}개: {', '.join(files[:6])}{' ...' if len(files) > 6 else ''}")
|
||||
_jsonl({
|
||||
"cmd": "export", "ok": True, "doc_id": doc_id, "out": str(out),
|
||||
"source": source, "source_len": len(text),
|
||||
"total_est_tokens": plan.total_est_tokens, "tier": plan.tier,
|
||||
"over_pct": plan.over_pct,
|
||||
"units_auto": len(plan.units) - len(oversized_units),
|
||||
"units_todo": len(oversized_units),
|
||||
"files": files,
|
||||
})
|
||||
return 0
|
||||
|
||||
|
||||
# ─── apply ───────────────────────────────────────────────────────────────────
|
||||
|
||||
QUEUE_ROW_SQL = """
|
||||
SELECT id, status, attempts, payload::text AS payload_text
|
||||
FROM processing_queue
|
||||
WHERE document_id = :d AND stage = 'deep_summary'
|
||||
AND status IN ('pending', 'processing', 'failed')
|
||||
ORDER BY id DESC
|
||||
LIMIT 1
|
||||
"""
|
||||
|
||||
APPLY_UPDATE_SQL = """
|
||||
UPDATE processing_queue
|
||||
SET payload = CAST(:payload AS JSONB),
|
||||
status = 'pending',
|
||||
attempts = 0,
|
||||
error_message = NULL
|
||||
WHERE id = :qid
|
||||
"""
|
||||
|
||||
|
||||
async def cmd_apply(doc_id: int, boundaries_file: str, dry_run: bool) -> int:
|
||||
raw = json.loads(Path(boundaries_file).read_text(encoding="utf-8"))
|
||||
if isinstance(raw, dict):
|
||||
boundaries = raw.get("boundaries") or []
|
||||
declared_source = raw.get("source")
|
||||
declared_len = raw.get("source_len")
|
||||
if raw.get("doc_id") not in (None, doc_id):
|
||||
print(f"[error] boundaries 파일 doc_id={raw.get('doc_id')} != --doc {doc_id}")
|
||||
_jsonl({"cmd": "apply", "ok": False, "doc_id": doc_id, "error": "doc_id_mismatch"})
|
||||
return 1
|
||||
else:
|
||||
boundaries, declared_source, declared_len = raw, None, None
|
||||
|
||||
engine, factory = _session_factory()
|
||||
try:
|
||||
async with factory() as session:
|
||||
doc = (await session.execute(
|
||||
sql_text(
|
||||
"SELECT id, title, md_content, extracted_text FROM documents WHERE id = :d"
|
||||
),
|
||||
{"d": doc_id},
|
||||
)).mappings().first()
|
||||
if not doc:
|
||||
print(f"[error] 문서 id={doc_id} 없음")
|
||||
_jsonl({"cmd": "apply", "ok": False, "doc_id": doc_id, "error": "document_not_found"})
|
||||
return 1
|
||||
|
||||
qrow = (await session.execute(sql_text(QUEUE_ROW_SQL), {"d": doc_id})).mappings().first()
|
||||
if not qrow:
|
||||
print(f"[error] doc {doc_id} 의 활성 deep_summary 큐 행 없음 (pending/processing/failed)")
|
||||
_jsonl({"cmd": "apply", "ok": False, "doc_id": doc_id, "error": "queue_row_not_found"})
|
||||
return 1
|
||||
if qrow["status"] == "processing":
|
||||
print(f"[error] queue {qrow['id']} 가 processing 중 — 워커 완료/보류 후 재시도")
|
||||
_jsonl({"cmd": "apply", "ok": False, "doc_id": doc_id, "error": "queue_processing"})
|
||||
return 1
|
||||
|
||||
# 오프셋 기준 텍스트 — export 와 동일 규칙 (파일에 source 선언 시 그 선언 우선)
|
||||
if declared_source in ("md_content", "extracted_text"):
|
||||
source = declared_source
|
||||
text = (doc["md_content"] if source == "md_content" else doc["extracted_text"]) or ""
|
||||
else:
|
||||
source, text = choose_override_source(doc["md_content"], doc["extracted_text"])
|
||||
if declared_len is not None and declared_len != len(text):
|
||||
print(
|
||||
f"[error] source_len 불일치 — 파일={declared_len:,} vs 현재 {source}={len(text):,}"
|
||||
" (본문 재생성됨 — export 부터 재실행)"
|
||||
)
|
||||
_jsonl({"cmd": "apply", "ok": False, "doc_id": doc_id, "error": "source_len_mismatch"})
|
||||
return 1
|
||||
|
||||
check = validate_override_boundaries(text, boundaries)
|
||||
for w in check.warnings:
|
||||
print(f" [warn] {w}")
|
||||
if not check.ok:
|
||||
print(f"[error] 경계 검증 실패 — {len(check.errors)}건:")
|
||||
for e in check.errors:
|
||||
print(f" - {e}")
|
||||
_jsonl({
|
||||
"cmd": "apply", "ok": False, "doc_id": doc_id,
|
||||
"error": "validation_failed", "errors": check.errors,
|
||||
"warnings": check.warnings, "coverage_pct": check.coverage_pct,
|
||||
})
|
||||
return 1
|
||||
|
||||
print(
|
||||
f"doc {doc_id} [{doc['title'] or '(제목 없음)'}] 경계 검증 통과 — "
|
||||
f"유닛 {len(check.boundaries)}개 / 커버리지 {check.coverage_pct}% / "
|
||||
f"최대 유닛 {max(check.unit_tokens):,} tok (cap {CAP_TOKENS:,})"
|
||||
)
|
||||
for i, ((s, e, t), tok) in enumerate(zip(check.boundaries, check.unit_tokens)):
|
||||
print(f" 유닛 {i}: [{s}, {e}) {tok:,} tok — {t or '(무제)'}")
|
||||
|
||||
payload = json.loads(qrow["payload_text"] or "{}")
|
||||
preseg = dict(payload.get("presegment") or {})
|
||||
preseg["units_override"] = {
|
||||
"source": source,
|
||||
"source_len": len(text),
|
||||
"boundaries": [[s, e, t] for s, e, t in check.boundaries],
|
||||
"applied_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
||||
}
|
||||
preseg["awaiting_split"] = False
|
||||
# 알람 dedupe 리셋(다음 이벤트는 신선하게 발화) + 이전 거부/맵 잔재 제거
|
||||
for k in ("alerted_at", "override_rejected", "override_rejected_at", "map_results"):
|
||||
preseg.pop(k, None)
|
||||
payload["presegment"] = preseg
|
||||
payload.pop("deferred_until", None) # 즉시 재개
|
||||
|
||||
if dry_run:
|
||||
print(f" [dry-run] queue {qrow['id']} 미변경 — 위 경계로 적용 가능")
|
||||
_jsonl({
|
||||
"cmd": "apply", "ok": True, "dry_run": True, "doc_id": doc_id,
|
||||
"queue_id": qrow["id"], "units": len(check.boundaries),
|
||||
"coverage_pct": check.coverage_pct, "unit_tokens": check.unit_tokens,
|
||||
})
|
||||
return 0
|
||||
|
||||
await session.execute(
|
||||
sql_text(APPLY_UPDATE_SQL),
|
||||
{"payload": json.dumps(payload, ensure_ascii=False), "qid": qrow["id"]},
|
||||
)
|
||||
await session.commit()
|
||||
print(
|
||||
f" 적용 완료 — queue {qrow['id']} status=pending, deferred_until 제거 "
|
||||
f"(다음 queue_consumer 사이클에 재개)"
|
||||
)
|
||||
_jsonl({
|
||||
"cmd": "apply", "ok": True, "dry_run": False, "doc_id": doc_id,
|
||||
"queue_id": qrow["id"], "units": len(check.boundaries),
|
||||
"coverage_pct": check.coverage_pct, "unit_tokens": check.unit_tokens,
|
||||
})
|
||||
return 0
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
# ─── main ────────────────────────────────────────────────────────────────────
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="presegment 유인 분할 CLI (PR3)")
|
||||
sub = parser.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
sub.add_parser("list", help="awaiting_split 보류 문서 목록")
|
||||
|
||||
p_export = sub.add_parser("export", help="유인 분할 작업 패키지 덤프")
|
||||
p_export.add_argument("--doc", type=int, required=True)
|
||||
p_export.add_argument("--out", default=None, help="출력 디렉토리 (기본 ./preseg_export_<doc>)")
|
||||
|
||||
p_apply = sub.add_parser("apply", help="완성된 boundaries 검증·적용(재개)")
|
||||
p_apply.add_argument("--doc", type=int, required=True)
|
||||
p_apply.add_argument("--boundaries", required=True, help="boundaries JSON 파일 경로")
|
||||
p_apply.add_argument("--dry-run", action="store_true", help="검증만 하고 DB 미변경")
|
||||
|
||||
args = parser.parse_args()
|
||||
if args.cmd == "list":
|
||||
return asyncio.run(cmd_list())
|
||||
if args.cmd == "export":
|
||||
out = args.out or f"./preseg_export_{args.doc}"
|
||||
return asyncio.run(cmd_export(args.doc, out))
|
||||
if args.cmd == "apply":
|
||||
return asyncio.run(cmd_apply(args.doc, args.boundaries, args.dry_run))
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -59,6 +59,11 @@ MAX_IMAGES_PER_DOC = int(os.getenv("MINERU_MAX_IMAGES_PER_DOC", "200"))
|
||||
MAX_BYTES_PER_IMAGE = int(os.getenv("MINERU_MAX_BYTES_PER_IMAGE", str(10 * 1024 * 1024)))
|
||||
MAX_PAGES_HARD = int(os.getenv("MINERU_MAX_PAGES_HARD", "200")) # 1-shot max_pages 안전장치
|
||||
|
||||
# self-timeout — 변환/워밍이 vLLM 행으로 _engine_lock 을 영구 점유해 서비스가 wedge 되는 것을 차단.
|
||||
# (클라이언트 marker_worker 는 300s 로 포기하나 서버측 inflight 는 자동 취소 안 됨 → 서버 자체 상한 필요.)
|
||||
PARSE_TIMEOUT_S = float(os.getenv("MINERU_PARSE_TIMEOUT_S", "600"))
|
||||
WARMUP_TIMEOUT_S = float(os.getenv("MINERU_WARMUP_TIMEOUT_S", "1200")) # 최초 모델 다운로드(~2.4GB) 여유
|
||||
|
||||
_PRELOAD = os.getenv("MINERU_PRELOAD", "1") != "0"
|
||||
|
||||
# ---- 엔진 상태 ---------------------------------------------------------------
|
||||
@@ -68,6 +73,15 @@ _warmup_error: str | None = None
|
||||
_engine_lock = asyncio.Lock()
|
||||
|
||||
|
||||
def _is_engine_fatal(exc: BaseException) -> bool:
|
||||
"""OOM/CUDA 류 = 엔진 상태 오염 가능 → 재워밍 강제 대상(타임아웃은 호출측에서 별도 판정)."""
|
||||
s = f"{type(exc).__name__} {exc}".lower()
|
||||
return any(
|
||||
k in s
|
||||
for k in ("out of memory", "oom", "cuda", "cublas", "device-side", "illegal memory")
|
||||
)
|
||||
|
||||
|
||||
async def _run_mineru(pdf_bytes: bytes, lang: str) -> tuple[str, list[dict]]:
|
||||
"""슬라이스된 PDF bytes → (markdown, 이미지 dict 리스트). **async 엔진 경로.**
|
||||
|
||||
@@ -148,7 +162,7 @@ async def _ensure_warmup() -> None:
|
||||
page.insert_text((72, 72), "MinerU warmup.")
|
||||
warmup_bytes = doc.tobytes()
|
||||
doc.close()
|
||||
await _run_mineru(warmup_bytes, MINERU_LANG)
|
||||
await asyncio.wait_for(_run_mineru(warmup_bytes, MINERU_LANG), timeout=WARMUP_TIMEOUT_S)
|
||||
_warmup_done = True
|
||||
_warmup_error = None
|
||||
logger.info(f"[mineru-service] warmup done engine_version={_engine_version}")
|
||||
@@ -274,6 +288,7 @@ def _serialize_images(images: list[dict], src_path: str) -> tuple[list[ConvertIm
|
||||
|
||||
@app.post("/convert", response_model=ConvertResponse)
|
||||
async def convert(req: ConvertRequest):
|
||||
global _warmup_done
|
||||
p = _resolve_path(req.file_path)
|
||||
if p is None or not p.is_file():
|
||||
raise HTTPException(404, detail={"code": "file_not_found", "message": req.file_path})
|
||||
@@ -288,10 +303,18 @@ async def convert(req: ConvertRequest):
|
||||
async with _engine_lock: # 실제 변환 직렬화(단일 GPU)
|
||||
start = time.monotonic()
|
||||
try:
|
||||
md_text, raw_images = await _run_mineru(pdf_bytes, MINERU_LANG)
|
||||
md_text, raw_images = await asyncio.wait_for(
|
||||
_run_mineru(pdf_bytes, MINERU_LANG), timeout=PARSE_TIMEOUT_S
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
# 타임아웃(엔진 행) 또는 OOM/CUDA 류면 엔진 오염 가능 → 다음 요청이 재워밍하도록 리셋.
|
||||
# 재워밍까지 실패하면 _ensure_warmup 이 _warmup_error 설정 → /ready 503 → healthcheck
|
||||
# 재시작으로 escalate(영구 degradation 차단). 일시 OOM 이면 재워밍 성공 후 정상화.
|
||||
if isinstance(exc, (asyncio.TimeoutError, TimeoutError)) or _is_engine_fatal(exc):
|
||||
_warmup_done = False
|
||||
logger.error("[mineru-service] engine reset (timeout/fatal) path=%s: %s", p, exc)
|
||||
logger.exception(f"[mineru-service] conversion failed path={p}: {exc}")
|
||||
raise HTTPException(422, detail={"code": "conversion_failed",
|
||||
"message": f"{type(exc).__name__}: {exc}"}) from exc
|
||||
|
||||
@@ -1,291 +0,0 @@
|
||||
"""PR-MacBook-RAG-Backend-1 정정 4 핵심 테스트.
|
||||
|
||||
검증 invariant (synthesize 함수 레벨 — /ask wrapper 의 503 매핑은 search.py 의
|
||||
status="backend_unavailable" 분기로 1:1 deterministic):
|
||||
|
||||
1. backend="qwen-macbook" + MacBook URL 죽은 포트
|
||||
→ synthesize() 가 SynthesisResult(status="backend_unavailable", ...) 반환
|
||||
→ Gemma backend 의 generate() 가 **단 1번도 호출되지 않음** (자동 fallback 부재)
|
||||
|
||||
2. backend 미지정 (None)
|
||||
→ Gemma backend.generate() 호출, Qwen backend.generate() 호출 0
|
||||
→ 기존 호출자 (Hermes docsrv_ask / voice-memo-bot) 회귀 0
|
||||
|
||||
3. backend="qwen-macbook" + MacBook 정상 응답
|
||||
→ status="completed" + answer 채워짐, Gemma backend 호출 0
|
||||
|
||||
테스트 전략:
|
||||
- synthesize() 가 호출하는 backend dispatcher (services.llm.get_backend) 를
|
||||
monkeypatch 해서 mock backend 주입.
|
||||
- Gemma backend 의 generate AsyncMock 호출 횟수를 추적.
|
||||
- 정정 4 의 핵심 가드: `gemma_backend.generate.assert_not_called()`
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "app"))
|
||||
|
||||
|
||||
# ── 가짜 evidence (synthesize 의 no_evidence 분기 회피용 최소 객체) ─────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class _FakeEvidence:
|
||||
n: int = 1
|
||||
doc_id: int = 100
|
||||
chunk_id: int | None = 200
|
||||
title: str | None = "fake doc"
|
||||
span_text: str = "이것은 짧은 근거 텍스트입니다."
|
||||
source: str = "llm"
|
||||
|
||||
|
||||
def _make_evidence():
|
||||
return [_FakeEvidence()]
|
||||
|
||||
|
||||
# ── backend mock ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _gemma_mock(content: str = "GEMMA_SHOULD_NEVER_BE_CALLED"):
|
||||
m = AsyncMock()
|
||||
m.name = "gemma-macmini"
|
||||
m.generate = AsyncMock(return_value=content)
|
||||
return m
|
||||
|
||||
|
||||
def _qwen_mock_success(content: str):
|
||||
m = AsyncMock()
|
||||
m.name = "qwen-macbook"
|
||||
m.generate = AsyncMock(return_value=content)
|
||||
return m
|
||||
|
||||
|
||||
def _qwen_mock_unavailable():
|
||||
from services.llm import BackendUnavailable
|
||||
|
||||
m = AsyncMock()
|
||||
m.name = "qwen-macbook"
|
||||
m.generate = AsyncMock(
|
||||
side_effect=BackendUnavailable("qwen-macbook", "ConnectError")
|
||||
)
|
||||
return m
|
||||
|
||||
|
||||
# ── 공통 fixture: synthesis_service 에 mock backend 주입 ───────────────────
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def patched_backends(monkeypatch):
|
||||
"""services.llm.get_backend 를 mock dispatcher 로 치환.
|
||||
|
||||
Returns (gemma_mock, qwen_mock, set_qwen_unavailable_fn).
|
||||
"""
|
||||
from services.search import synthesis_service
|
||||
|
||||
gemma = _gemma_mock()
|
||||
qwen_holder = {"backend": _qwen_mock_success(
|
||||
'{"answer":"Qwen ok [1]","confidence":"high","refused":false}'
|
||||
)}
|
||||
|
||||
def _fake_get_backend(name: str | None):
|
||||
key = (name or "").strip().lower() or "gemma-macmini"
|
||||
if key == "gemma-macmini":
|
||||
return gemma
|
||||
if key == "qwen-macbook":
|
||||
return qwen_holder["backend"]
|
||||
raise ValueError(f"unknown backend: {name!r}")
|
||||
|
||||
monkeypatch.setattr(synthesis_service, "get_backend", _fake_get_backend)
|
||||
# synthesis_service 캐시 비움 (qwen vs gemma 캐시 분리 invariant)
|
||||
synthesis_service._CACHE.clear()
|
||||
|
||||
def _swap_qwen_unavailable():
|
||||
qwen_holder["backend"] = _qwen_mock_unavailable()
|
||||
|
||||
return gemma, qwen_holder, _swap_qwen_unavailable
|
||||
|
||||
|
||||
# ── 정정 4 핵심: backend=qwen-macbook + MacBook 비가용 → Gemma 호출 0 ─────
|
||||
|
||||
|
||||
def test_qwen_unavailable_yields_backend_unavailable_status_and_gemma_not_called(
|
||||
patched_backends,
|
||||
):
|
||||
"""**정정 4 의 핵심 invariant**.
|
||||
|
||||
backend="qwen-macbook" 명시 + Qwen 호출이 BackendUnavailable 로 실패 →
|
||||
synthesize() 는 status="backend_unavailable" 반환. Gemma backend 의
|
||||
generate() 는 **단 한 번도 호출되지 않음** (silent fallback 금지).
|
||||
"""
|
||||
from services.search.synthesis_service import synthesize
|
||||
|
||||
gemma, qwen_holder, swap_qwen_unavailable = patched_backends
|
||||
swap_qwen_unavailable()
|
||||
qwen = qwen_holder["backend"]
|
||||
|
||||
result = asyncio.run(
|
||||
synthesize(
|
||||
query="압력용기 최대허용응력은?",
|
||||
evidence=_make_evidence(),
|
||||
backend="qwen-macbook",
|
||||
)
|
||||
)
|
||||
|
||||
# 1. status
|
||||
assert result.status == "backend_unavailable"
|
||||
assert result.answer is None
|
||||
assert result.confidence is None
|
||||
assert result.refused is False
|
||||
|
||||
# 2. flag 에 backend 비가용 사유 기록
|
||||
assert any(
|
||||
f.startswith("backend_unavailable:qwen-macbook:") for f in result.hallucination_flags
|
||||
), f"expected backend_unavailable flag, got {result.hallucination_flags}"
|
||||
|
||||
# 3. ★ 핵심 가드 ★ — Gemma backend 자동 fallback 금지
|
||||
gemma.generate.assert_not_called()
|
||||
|
||||
# 4. Qwen 은 1회만 호출 (재시도 없음)
|
||||
assert qwen.generate.call_count == 1
|
||||
|
||||
|
||||
def test_qwen_unavailable_result_not_cached(patched_backends):
|
||||
"""비가용 결과는 캐시 X — 다음 호출이 다시 Qwen 시도해야 함."""
|
||||
from services.search.synthesis_service import synthesize
|
||||
|
||||
gemma, qwen_holder, swap_qwen_unavailable = patched_backends
|
||||
swap_qwen_unavailable()
|
||||
qwen = qwen_holder["backend"]
|
||||
|
||||
asyncio.run(
|
||||
synthesize(
|
||||
query="동일 쿼리",
|
||||
evidence=_make_evidence(),
|
||||
backend="qwen-macbook",
|
||||
)
|
||||
)
|
||||
asyncio.run(
|
||||
synthesize(
|
||||
query="동일 쿼리",
|
||||
evidence=_make_evidence(),
|
||||
backend="qwen-macbook",
|
||||
)
|
||||
)
|
||||
|
||||
# 두 번 모두 실제 호출 (캐시 적중 X) — Gemma 는 여전히 0
|
||||
assert qwen.generate.call_count == 2
|
||||
gemma.generate.assert_not_called()
|
||||
|
||||
|
||||
# ── 정정 4: backend 미지정 → 기존 Gemma path (회귀 0) ─────────────────────
|
||||
|
||||
|
||||
def test_default_backend_calls_gemma_not_qwen(patched_backends):
|
||||
"""backend 미지정 = 기본 Gemma. Qwen 호출 0."""
|
||||
from services.search.synthesis_service import synthesize
|
||||
|
||||
gemma, qwen_holder, _ = patched_backends
|
||||
qwen = qwen_holder["backend"]
|
||||
gemma.generate.return_value = (
|
||||
'{"answer":"Gemma 답변 [1]","confidence":"high","refused":false}'
|
||||
)
|
||||
|
||||
result = asyncio.run(
|
||||
synthesize(
|
||||
query="기본 호출",
|
||||
evidence=_make_evidence(),
|
||||
backend=None, # 명시 None = default
|
||||
)
|
||||
)
|
||||
|
||||
assert result.status == "completed"
|
||||
assert result.answer is not None and "Gemma" in result.answer
|
||||
|
||||
# Qwen 은 호출 0
|
||||
qwen.generate.assert_not_called()
|
||||
# Gemma 는 1회
|
||||
assert gemma.generate.call_count == 1
|
||||
|
||||
|
||||
# ── backend="qwen-macbook" + 정상 응답 ──────────────────────────────────────
|
||||
|
||||
|
||||
def test_qwen_success_does_not_call_gemma(patched_backends):
|
||||
"""Qwen 정상 응답 시 Gemma 는 호출되지 않음 (대칭 invariant)."""
|
||||
from services.search.synthesis_service import synthesize
|
||||
|
||||
gemma, qwen_holder, _ = patched_backends
|
||||
qwen = qwen_holder["backend"]
|
||||
|
||||
result = asyncio.run(
|
||||
synthesize(
|
||||
query="정상 호출",
|
||||
evidence=_make_evidence(),
|
||||
backend="qwen-macbook",
|
||||
)
|
||||
)
|
||||
|
||||
assert result.status == "completed"
|
||||
assert result.answer is not None and "Qwen" in result.answer
|
||||
|
||||
# Gemma 는 0회
|
||||
gemma.generate.assert_not_called()
|
||||
# Qwen 은 1회
|
||||
assert qwen.generate.call_count == 1
|
||||
|
||||
|
||||
# ── 캐시 분리 (qwen vs gemma 키 충돌 없음) ─────────────────────────────────
|
||||
|
||||
|
||||
def test_qwen_and_gemma_have_separate_caches(patched_backends):
|
||||
"""같은 query 라도 backend 다르면 캐시 분리 — Qwen 결과가 Gemma 호출 답으로 둔갑하지 않음."""
|
||||
from services.search.synthesis_service import synthesize
|
||||
|
||||
gemma, qwen_holder, _ = patched_backends
|
||||
qwen = qwen_holder["backend"]
|
||||
gemma.generate.return_value = (
|
||||
'{"answer":"GEMMA_ANSWER [1]","confidence":"high","refused":false}'
|
||||
)
|
||||
qwen.generate.return_value = (
|
||||
'{"answer":"QWEN_ANSWER [1]","confidence":"high","refused":false}'
|
||||
)
|
||||
|
||||
r_qwen_1 = asyncio.run(
|
||||
synthesize(
|
||||
query="같은 query",
|
||||
evidence=_make_evidence(),
|
||||
backend="qwen-macbook",
|
||||
)
|
||||
)
|
||||
r_gemma_1 = asyncio.run(
|
||||
synthesize(
|
||||
query="같은 query",
|
||||
evidence=_make_evidence(),
|
||||
backend=None,
|
||||
)
|
||||
)
|
||||
r_qwen_2 = asyncio.run(
|
||||
synthesize(
|
||||
query="같은 query",
|
||||
evidence=_make_evidence(),
|
||||
backend="qwen-macbook",
|
||||
)
|
||||
)
|
||||
|
||||
assert "QWEN_ANSWER" in (r_qwen_1.answer or "")
|
||||
assert "GEMMA_ANSWER" in (r_gemma_1.answer or "")
|
||||
# 두 번째 Qwen 호출은 캐시 적중 — 결과는 동일하지만 generate 추가 호출 X
|
||||
assert "QWEN_ANSWER" in (r_qwen_2.answer or "")
|
||||
assert r_qwen_2.cache_hit is True
|
||||
|
||||
# generate 호출 횟수: Qwen 1 (두번째는 캐시), Gemma 1
|
||||
assert qwen.generate.call_count == 1
|
||||
assert gemma.generate.call_count == 1
|
||||
@@ -1,218 +0,0 @@
|
||||
"""PR-DocSrv-Ask-ToolCalling-ReAct-1: /api/search/ask/react endpoint integration.
|
||||
|
||||
검증 항목 (G0-3 trace exposure + 정정 4 invariant):
|
||||
- backend unavailable → HTTP 503 + error_reason=macbook_unavailable
|
||||
+ ★ `run_search` mock 호출 횟수 == 0 (search 단계 진입 자체 차단)
|
||||
- 정상 응답 → 200 + final_answer + sources + debug_trace=null (default)
|
||||
- debug=true → debug_trace 채워짐
|
||||
- max rounds 도달 → iterations=2 + partial=false (final content 정상)
|
||||
|
||||
endpoint 함수 (`api.search.ask_react`) 를 직접 호출하는 lightweight 패턴.
|
||||
TestClient 없이 FastAPI deps 를 MagicMock 으로 우회. (priority_gate / backend_dispatcher
|
||||
test 와 동일 service-layer 패턴.)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "app"))
|
||||
|
||||
|
||||
# ── helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _msg_with_tool_call(q: str, tc_id: str = "tc-1") -> dict:
|
||||
return {
|
||||
"role": "assistant",
|
||||
"content": None,
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": tc_id,
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "search",
|
||||
"arguments": json.dumps({"q": q}, ensure_ascii=False),
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _msg_with_content(text: str) -> dict:
|
||||
return {"role": "assistant", "content": text, "tool_calls": None}
|
||||
|
||||
|
||||
def _fake_chunk(chunk_id: int, doc_id: int = 100):
|
||||
m = MagicMock()
|
||||
m.id = chunk_id
|
||||
m.chunk_id = chunk_id
|
||||
m.doc_id = doc_id
|
||||
m.title = f"doc {doc_id}"
|
||||
m.score = 0.9
|
||||
m.snippet = f"snippet {chunk_id}"
|
||||
m.text = None
|
||||
return m
|
||||
|
||||
|
||||
def _fake_pr(chunks: list):
|
||||
pr = MagicMock()
|
||||
pr.results = chunks
|
||||
return pr
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def patched_backend_and_search(monkeypatch):
|
||||
"""get_backend + run_search 둘 다 mock. backend 의 generate_with_tools 는
|
||||
각 테스트가 side_effect 설정.
|
||||
|
||||
Returns: (backend_mock, run_search_mock, set_backend_unavailable_fn).
|
||||
"""
|
||||
from services.llm.backends import BackendUnavailable, QwenMacBookBackend
|
||||
from services.llm import backends as backends_mod
|
||||
from services.search import react_loop
|
||||
|
||||
backend = MagicMock(spec=QwenMacBookBackend)
|
||||
backend.name = "qwen-macbook"
|
||||
backend.generate_with_tools = AsyncMock()
|
||||
|
||||
def _fake_get_backend(name):
|
||||
# endpoint 가 qwen-macbook 만 호출하므로 단일 backend 반환
|
||||
return backend
|
||||
|
||||
monkeypatch.setattr(backends_mod, "get_backend", _fake_get_backend)
|
||||
# search.py 의 ask_react 안에서 `from services.llm.backends import ... get_backend`
|
||||
# 로 import 하므로 module-level patch 만으로 충분 (지연 import 라 매번 fresh).
|
||||
|
||||
run_search_mock = AsyncMock(return_value=_fake_pr([_fake_chunk(1)]))
|
||||
monkeypatch.setattr(react_loop, "run_search", run_search_mock)
|
||||
|
||||
def _make_unavailable():
|
||||
backend.generate_with_tools.side_effect = BackendUnavailable(
|
||||
"qwen-macbook", "ConnectError"
|
||||
)
|
||||
|
||||
return backend, run_search_mock, _make_unavailable
|
||||
|
||||
|
||||
def _call_endpoint(payload):
|
||||
"""ask_react 를 직접 호출. user/session 은 MagicMock 으로 우회."""
|
||||
from api.search import ask_react
|
||||
|
||||
user = MagicMock()
|
||||
session = MagicMock()
|
||||
return asyncio.run(ask_react(payload, user=user, session=session))
|
||||
|
||||
|
||||
# ── ★ 정정 4 invariant: backend unavailable → 503 + run_search 호출 0 ──────
|
||||
|
||||
|
||||
def test_qwen_unavailable_returns_503(patched_backend_and_search):
|
||||
"""backend BackendUnavailable → HTTP 503 + error_reason=macbook_unavailable."""
|
||||
from api.search import AskReactRequest
|
||||
|
||||
backend, run_search_mock, make_unavailable = patched_backend_and_search
|
||||
make_unavailable()
|
||||
|
||||
response = _call_endpoint(AskReactRequest(query="Q"))
|
||||
|
||||
# JSONResponse instance
|
||||
assert response.status_code == 503
|
||||
body = json.loads(response.body)
|
||||
assert body["error_reason"] == "macbook_unavailable"
|
||||
assert body["backend_used"] is None
|
||||
assert body["backend_requested"] == "qwen-macbook"
|
||||
|
||||
# ★ run_search 호출 0 (search 진입 자체 차단)
|
||||
assert run_search_mock.call_count == 0
|
||||
|
||||
|
||||
# ── 정상 200 + G0-3 default debug_trace=null ──────────────────────────────
|
||||
|
||||
|
||||
def test_successful_response_default_no_debug_trace(patched_backend_and_search):
|
||||
"""debug 미지정 (default false) → 200 + debug_trace == null."""
|
||||
from api.search import AskReactRequest, AskReactResponse
|
||||
|
||||
backend, run_search_mock, _ = patched_backend_and_search
|
||||
backend.generate_with_tools.side_effect = [
|
||||
_msg_with_tool_call("q1"),
|
||||
_msg_with_content("최종 답입니다"),
|
||||
]
|
||||
|
||||
response = _call_endpoint(AskReactRequest(query="Q"))
|
||||
|
||||
# Pydantic instance (FastAPI response_model 적용 전 raw return)
|
||||
assert isinstance(response, AskReactResponse)
|
||||
assert response.final_answer == "최종 답입니다"
|
||||
assert response.iterations == 2
|
||||
assert response.partial is False
|
||||
assert response.debug_trace is None # ★ G0-3
|
||||
assert len(response.sources) == 1
|
||||
|
||||
|
||||
# ── G0-3: debug=true → debug_trace 채워짐 ──────────────────────────────────
|
||||
|
||||
|
||||
def test_debug_true_populates_trace(patched_backend_and_search):
|
||||
from api.search import AskReactRequest
|
||||
|
||||
backend, run_search_mock, _ = patched_backend_and_search
|
||||
backend.generate_with_tools.side_effect = [
|
||||
_msg_with_content("바로 답"),
|
||||
]
|
||||
|
||||
response = _call_endpoint(AskReactRequest(query="Q", debug=True))
|
||||
|
||||
assert response.debug_trace is not None
|
||||
assert isinstance(response.debug_trace, list)
|
||||
assert len(response.debug_trace) >= 1
|
||||
|
||||
|
||||
# ── max rounds → final content 정상 → partial=false ──────────────────────
|
||||
|
||||
|
||||
def test_max_rounds_with_final_content(patched_backend_and_search):
|
||||
from api.search import AskReactRequest
|
||||
|
||||
backend, run_search_mock, _ = patched_backend_and_search
|
||||
backend.generate_with_tools.side_effect = [
|
||||
_msg_with_tool_call("q1"),
|
||||
_msg_with_tool_call("q2", tc_id="tc-2"),
|
||||
_msg_with_content("정리된 최종 답"),
|
||||
]
|
||||
|
||||
response = _call_endpoint(AskReactRequest(query="Q"))
|
||||
|
||||
assert response.iterations == 2
|
||||
assert response.partial is False
|
||||
assert response.final_answer == "정리된 최종 답"
|
||||
# LLM 호출 3회, search 2회 (G0-2 cap)
|
||||
assert backend.generate_with_tools.call_count == 3
|
||||
assert run_search_mock.call_count == 2
|
||||
|
||||
|
||||
# ── max rounds + final content 빈 string → partial=true ──────────────────
|
||||
|
||||
|
||||
def test_max_rounds_with_empty_final_partial(patched_backend_and_search):
|
||||
from api.search import AskReactRequest
|
||||
|
||||
backend, run_search_mock, _ = patched_backend_and_search
|
||||
backend.generate_with_tools.side_effect = [
|
||||
_msg_with_tool_call("q1"),
|
||||
_msg_with_tool_call("q2", tc_id="tc-2"),
|
||||
_msg_with_content(""),
|
||||
]
|
||||
|
||||
response = _call_endpoint(AskReactRequest(query="Q"))
|
||||
|
||||
assert response.iterations == 2
|
||||
assert response.partial is True
|
||||
assert response.final_answer == ""
|
||||
@@ -0,0 +1,80 @@
|
||||
"""summarize_units PR2 헬퍼 단위테스트 — map/reduce 프롬프트 조립 순수함수.
|
||||
|
||||
핵심 불변식:
|
||||
- render_map_slice: 유닛 위치(1-based)/섹션 라벨 + 본문 그대로 (손실 0).
|
||||
- build_reduce_units_block: 어떤 입력에도 반환 블록 est_tokens <= budget (캡 초과 0
|
||||
검증 게이트의 reduce 측). 절단은 detail 만 — 라벨/TLDR/불일치/순서 보존.
|
||||
|
||||
pytest + 단독 실행 양쪽 지원:
|
||||
PYTHONPATH=. pytest tests/summarize_units/ -q
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from app.services.summarize_units import (
|
||||
SummarizeUnit,
|
||||
build_reduce_units_block,
|
||||
estimate_tokens,
|
||||
render_map_slice,
|
||||
)
|
||||
|
||||
|
||||
def _result(idx: int, detail: str, *, tldr: str = "요약", inc: list | None = None) -> dict:
|
||||
return {
|
||||
"index": idx,
|
||||
"titles": [f"섹션{idx}"],
|
||||
"tldr": tldr,
|
||||
"detail": detail,
|
||||
"inconsistencies": inc or [],
|
||||
}
|
||||
|
||||
|
||||
# ---------- render_map_slice ----------
|
||||
|
||||
def test_render_map_slice_label_and_body():
|
||||
unit = SummarizeUnit(index=2, section_titles=["개요", None, "본론"], text="본문입니다")
|
||||
out = render_map_slice(unit, total_units=5)
|
||||
assert out.startswith("[유닛 3/5 — 섹션: 개요 · 본론]\n")
|
||||
assert out.endswith("본문입니다")
|
||||
|
||||
|
||||
def test_render_map_slice_untitled():
|
||||
unit = SummarizeUnit(index=0, section_titles=[None], text="x")
|
||||
assert "(무제 구간)" in render_map_slice(unit, total_units=1)
|
||||
|
||||
|
||||
# ---------- build_reduce_units_block ----------
|
||||
|
||||
def test_reduce_block_within_budget_untouched():
|
||||
results = [_result(i, "가" * 100) for i in range(3)]
|
||||
block, truncated = build_reduce_units_block(results, budget_tokens=11_000)
|
||||
assert not truncated
|
||||
# 순서/라벨/TLDR 보존
|
||||
assert block.index("[유닛 1/3") < block.index("[유닛 2/3") < block.index("[유닛 3/3")
|
||||
assert "TLDR: 요약" in block
|
||||
assert "가" * 100 in block
|
||||
|
||||
|
||||
def test_reduce_block_truncates_to_budget():
|
||||
# 유닛 8개 × 한글 detail 5,000자 ≈ 21K tok — budget 5,000 으로 절단 강제
|
||||
results = [_result(i, "가" * 5_000) for i in range(8)]
|
||||
block, truncated = build_reduce_units_block(results, budget_tokens=5_000)
|
||||
assert truncated
|
||||
assert estimate_tokens(block) <= 5_000
|
||||
# 라벨(유닛 순서)은 절단 후에도 보존
|
||||
assert "[유닛 1/8" in block
|
||||
|
||||
|
||||
def test_reduce_block_hard_cut_floor():
|
||||
# min_detail_chars floor 에 막혀 비례 절단으로 불충분한 극단 케이스 — 하드 컷 발동
|
||||
results = [_result(i, "가" * 300) for i in range(50)]
|
||||
block, truncated = build_reduce_units_block(results, budget_tokens=500)
|
||||
assert truncated
|
||||
assert estimate_tokens(block) <= 500
|
||||
|
||||
|
||||
def test_reduce_block_preserves_inconsistencies():
|
||||
results = [
|
||||
_result(0, "가" * 50, inc=[{"kind": "version_drift", "desc": "개정판 차이"}]),
|
||||
]
|
||||
block, _ = build_reduce_units_block(results, budget_tokens=10_000)
|
||||
assert "불일치(version_drift): 개정판 차이" in block
|
||||
@@ -0,0 +1,180 @@
|
||||
"""summarize_units 단위테스트 (presegment PR1 — 순수함수·fixture).
|
||||
|
||||
핵심 불변식:
|
||||
- estimate_tokens = PR0 캘리브레이션(한글 0.529 · 기타 0.217 tok/char) 정확 재현.
|
||||
- greedy_pack: 순서 보존·인접만·cap 준수·단독 초과 leaf=over_cap 전용 유닛·텍스트 손실 0
|
||||
(구 deep_summary head/mid/tail 가운데 폐기 버그의 반대 성질).
|
||||
- gate 3-way: 0=auto / (0,40]=hybrid / >40=whole (경계 포함).
|
||||
- plan_summarize_units: trigger 이하=single(현행 단일콜 유지=무회귀) / 초과=map_reduce.
|
||||
|
||||
pytest + 단독 실행 양쪽 지원:
|
||||
PYTHONPATH=. .venv/bin/pytest tests/summarize_units/ -q
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from app.services.hier_decomp.builder import HierNode
|
||||
from app.services.summarize_units import (
|
||||
CAP_TOKENS,
|
||||
TRIGGER_TOKENS,
|
||||
SummarizeUnit,
|
||||
estimate_tokens,
|
||||
extract_leaves,
|
||||
gate,
|
||||
greedy_pack,
|
||||
over_pct,
|
||||
plan_summarize_units,
|
||||
)
|
||||
|
||||
|
||||
def _leaf(idx: int, text: str, title: str | None = None) -> HierNode:
|
||||
return HierNode(idx=idx, parent_idx=None, level=1, node_type=None,
|
||||
section_title=title, heading_path=title, text=text)
|
||||
|
||||
|
||||
# ---------- estimate_tokens ----------
|
||||
|
||||
def test_estimate_tokens_korean_calibration():
|
||||
# 한글 1000자 → 529 tok (PR0: 0.529 tok/char)
|
||||
assert estimate_tokens("가" * 1000) == 529
|
||||
|
||||
|
||||
def test_estimate_tokens_english_calibration():
|
||||
# 비한글 1000자 → 217 tok (PR0: 0.217 tok/char)
|
||||
assert estimate_tokens("a" * 1000) == 217
|
||||
|
||||
|
||||
def test_estimate_tokens_mixed_and_empty():
|
||||
assert estimate_tokens("") == 0
|
||||
mixed = "가" * 100 + "a" * 100
|
||||
assert estimate_tokens(mixed) == round(100 * 0.529 + 100 * 0.217)
|
||||
|
||||
|
||||
# ---------- greedy_pack ----------
|
||||
|
||||
def test_greedy_pack_adjacency_and_cap():
|
||||
# 4000tok 짜리 한글 leaf 4개 (4000/0.529 ≈ 7562자) → cap 12000 이면 [3개, 1개]... 아니
|
||||
# 4000*3=12000 = cap 정확 경계(<=cap 허용) → [1,2,3] + [4]
|
||||
body = "가" * 7562 # ≈ 3999~4000 tok
|
||||
leaves = [_leaf(i, body, f"s{i}") for i in range(4)]
|
||||
units = greedy_pack(leaves, cap=12_000)
|
||||
assert len(units) == 2
|
||||
assert [len(u.section_titles) for u in units] == [3, 1]
|
||||
# 순서 보존
|
||||
assert units[0].section_titles == ["s0", "s1", "s2"]
|
||||
assert units[1].section_titles == ["s3"]
|
||||
# cap 준수
|
||||
assert all(u.est_tokens <= 12_000 for u in units)
|
||||
|
||||
|
||||
def test_greedy_pack_oversized_leaf_gets_own_unit():
|
||||
small = "가" * 1000 # ≈ 529 tok
|
||||
big = "가" * 30_000 # ≈ 15,870 tok > CAP
|
||||
leaves = [_leaf(0, small, "a"), _leaf(1, big, "mega"), _leaf(2, small, "b")]
|
||||
units = greedy_pack(leaves, cap=CAP_TOKENS)
|
||||
assert len(units) == 3
|
||||
assert units[1].over_cap and units[1].section_titles == ["mega"]
|
||||
assert not units[0].over_cap and not units[2].over_cap
|
||||
# 인접성: 초과 leaf 가 앞뒤 pack 을 넘나들며 합쳐지지 않음
|
||||
assert units[0].section_titles == ["a"] and units[2].section_titles == ["b"]
|
||||
|
||||
|
||||
def test_greedy_pack_no_text_loss():
|
||||
leaves = [_leaf(i, f"본문{i} " + "가" * 500, f"s{i}") for i in range(7)]
|
||||
units = greedy_pack(leaves, cap=1_000)
|
||||
joined = "\n\n".join(u.text for u in units)
|
||||
for leaf in leaves:
|
||||
assert leaf.text in joined # 커버리지 — 중간 폐기 0
|
||||
|
||||
|
||||
def test_greedy_pack_empty():
|
||||
assert greedy_pack([]) == []
|
||||
|
||||
|
||||
# ---------- over_pct + gate ----------
|
||||
|
||||
def test_over_pct_and_gate_boundaries():
|
||||
assert gate(0.0) == "auto"
|
||||
assert gate(0.01) == "hybrid"
|
||||
assert gate(40.0) == "hybrid"
|
||||
assert gate(40.01) == "whole"
|
||||
assert gate(100.0) == "whole"
|
||||
|
||||
|
||||
def test_over_pct_computation():
|
||||
# leaf: 6000tok + 18000tok(초과) → over% = 18000/24000 = 75%
|
||||
l_small = _leaf(0, "가" * round(6000 / 0.529), "a")
|
||||
l_big = _leaf(1, "가" * round(18000 / 0.529), "b")
|
||||
pct = over_pct([l_small, l_big], cap=CAP_TOKENS)
|
||||
assert 74.0 < pct < 76.0
|
||||
assert over_pct([], cap=CAP_TOKENS) == 0.0
|
||||
assert over_pct([l_small], cap=CAP_TOKENS) == 0.0
|
||||
|
||||
|
||||
# ---------- plan_summarize_units (fixture md) ----------
|
||||
|
||||
def _md_doc(sections: int, chars_per_section: int, ch: str = "가") -> str:
|
||||
parts = []
|
||||
for i in range(sections):
|
||||
parts.append(f"# 제{i+1}장 섹션{i}\n\n" + ch * chars_per_section)
|
||||
return "\n\n".join(parts)
|
||||
|
||||
|
||||
def test_plan_small_doc_stays_single():
|
||||
md = _md_doc(3, 1000) # ≈ 3×529 tok ≪ trigger
|
||||
plan = plan_summarize_units(md)
|
||||
assert plan.mode == "single" and plan.tier is None and plan.units == []
|
||||
assert plan.total_est_tokens <= TRIGGER_TOKENS
|
||||
|
||||
|
||||
def test_plan_large_doc_auto_tier():
|
||||
# 섹션 20개 × ≈4000tok = ≈80K tok > trigger, 전 섹션 < cap → auto
|
||||
md = _md_doc(20, 7562)
|
||||
plan = plan_summarize_units(md)
|
||||
assert plan.mode == "map_reduce"
|
||||
assert plan.tier == "auto" and plan.over_pct == 0.0
|
||||
assert len(plan.units) >= 2
|
||||
assert all(u.est_tokens <= CAP_TOKENS for u in plan.units)
|
||||
|
||||
|
||||
def test_plan_mega_section_whole_tier():
|
||||
# 작은 섹션 2 + 초대형 1(≈53K tok — 전체의 >40%) → whole
|
||||
md = (_md_doc(2, 7562)
|
||||
+ "\n\n# 메가섹션\n\n" + "가" * 100_000)
|
||||
plan = plan_summarize_units(md)
|
||||
assert plan.mode == "map_reduce"
|
||||
assert plan.tier == "whole" and plan.over_pct > 40.0
|
||||
assert any(u.over_cap for u in plan.units)
|
||||
|
||||
|
||||
def test_plan_hybrid_tier():
|
||||
# 정상 섹션 15개(≈60K tok) + 초과 섹션 1개(≈15.9K tok) → over% ≈ 21% → hybrid
|
||||
md = _md_doc(15, 7562) + "\n\n# 초과섹션\n\n" + "가" * 30_000
|
||||
plan = plan_summarize_units(md)
|
||||
assert plan.mode == "map_reduce"
|
||||
assert plan.tier == "hybrid"
|
||||
assert 0.0 < plan.over_pct <= 40.0
|
||||
over_units = [u for u in plan.units if u.over_cap]
|
||||
assert len(over_units) == 1 # hybrid 시 클로드 대상 = 이 유닛들만
|
||||
|
||||
|
||||
def test_plan_headingless_giant_is_whole():
|
||||
# 헤딩 없는 거대 EN 문서 — leaf 1개 전체 초과 → over% 100 → whole (PR0: EN 책 다수)
|
||||
md = "x" * 200_000 # ≈ 43K tok > trigger, 단일 leaf > cap
|
||||
plan = plan_summarize_units(md)
|
||||
assert plan.mode == "map_reduce" and plan.tier == "whole"
|
||||
|
||||
|
||||
def test_plan_deterministic():
|
||||
md = _md_doc(10, 7562)
|
||||
p1, p2 = plan_summarize_units(md), plan_summarize_units(md)
|
||||
assert p1 == p2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
fns = [v for k, v in sorted(globals().items()) if k.startswith("test_")]
|
||||
for fn in fns:
|
||||
fn()
|
||||
print(f"ok {fn.__name__}")
|
||||
print(f"{len(fns)} passed (standalone)")
|
||||
sys.exit(0)
|
||||
@@ -1,92 +0,0 @@
|
||||
"""Phase 3.5 fix2: /ask 의 X-Source / X-Eval-Case-Id trust boundary.
|
||||
|
||||
`_resolve_eval_identity()` 단위 테스트.
|
||||
- token 없음/틀림 + X-Source=eval → source='document_server', eval_case_id=None
|
||||
- token 일치 + X-Source=eval + X-Eval-Case-Id=case_xxx → ('eval', 'case_xxx')
|
||||
- token 틀림 + X-Eval-Case-Id 만 (X-Source 미지정) → eval_case_id=None
|
||||
- 일반 호출 (X-Source=ui_search, no eval headers) → ('ui_search', None)
|
||||
- env 미설정 (eval_runner_token='') 시 모든 eval claim 거부
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "app"))
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def resolve_with_token(monkeypatch):
|
||||
"""settings.eval_runner_token 을 monkey-patch 해서 _resolve_eval_identity 테스트."""
|
||||
def _make(token: str):
|
||||
from core import config as cfg_mod
|
||||
from api import search as search_mod
|
||||
# 두 모듈 모두에서 settings 객체 참조하므로 직접 attr 변경
|
||||
monkeypatch.setattr(search_mod.settings, "eval_runner_token", token)
|
||||
return search_mod._resolve_eval_identity
|
||||
return _make
|
||||
|
||||
|
||||
def test_no_token_no_eval_headers_default(resolve_with_token):
|
||||
"""일반 호출 — eval 헤더 없음, source 기본값."""
|
||||
resolve = resolve_with_token("secret123")
|
||||
assert resolve(None, None, None) == ("document_server", None)
|
||||
|
||||
|
||||
def test_normal_source_with_token(resolve_with_token):
|
||||
"""ui_search 호출 — eval 클레임 아님이라 token 무관."""
|
||||
resolve = resolve_with_token("secret123")
|
||||
assert resolve("ui_search", None, None) == ("ui_search", None)
|
||||
|
||||
|
||||
def test_eval_claim_no_token_rejected(resolve_with_token):
|
||||
"""X-Source=eval 인데 token 없음 → 거부, source='document_server'."""
|
||||
resolve = resolve_with_token("secret123")
|
||||
assert resolve("eval", "case_001", None) == ("document_server", None)
|
||||
|
||||
|
||||
def test_eval_claim_wrong_token_rejected(resolve_with_token):
|
||||
"""token 틀림 → 거부."""
|
||||
resolve = resolve_with_token("secret123")
|
||||
assert resolve("eval", "case_001", "wrong_token") == ("document_server", None)
|
||||
|
||||
|
||||
def test_eval_claim_correct_token_accepted(resolve_with_token):
|
||||
"""token 일치 → 'eval' source + case_id 적재."""
|
||||
resolve = resolve_with_token("secret123")
|
||||
assert resolve("eval", "case_001", "secret123") == ("eval", "case_001")
|
||||
|
||||
|
||||
def test_eval_case_id_only_no_source_no_token(resolve_with_token):
|
||||
"""X-Eval-Case-Id 만 있고 token 없음 → 거부, case_id=None."""
|
||||
resolve = resolve_with_token("secret123")
|
||||
assert resolve(None, "case_001", None) == ("document_server", None)
|
||||
|
||||
|
||||
def test_eval_case_id_only_wrong_token(resolve_with_token):
|
||||
"""X-Eval-Case-Id 만 + token 틀림 → 거부."""
|
||||
resolve = resolve_with_token("secret123")
|
||||
assert resolve(None, "case_001", "wrong") == ("document_server", None)
|
||||
|
||||
|
||||
def test_env_unset_rejects_even_correct_format(resolve_with_token):
|
||||
"""settings.eval_runner_token='' 인 환경 → 모든 eval 클레임 거부."""
|
||||
resolve = resolve_with_token("")
|
||||
# token 헤더가 와도 server side 가 비어있으면 거부 (constant-time False)
|
||||
assert resolve("eval", "case_001", "") == ("document_server", None)
|
||||
assert resolve("eval", "case_001", "anything") == ("document_server", None)
|
||||
|
||||
|
||||
def test_non_eval_source_forces_case_id_none(resolve_with_token):
|
||||
"""X-Source=ui_detail + X-Eval-Case-Id (실수로 같이 보냄) → case_id=None.
|
||||
|
||||
eval claim 아님 (source != 'eval' 이고 case_id 가 fallback 으로 eval claim 트리거)
|
||||
이지만 source claim 이 명시적으로 non-eval 이라 token 검증 후 case_id None.
|
||||
"""
|
||||
resolve = resolve_with_token("secret123")
|
||||
# case_id 가 있으면 eval claim 으로 처리됨 → token 없으면 거부 → ('ui_detail' 클레임,
|
||||
# 하지만 거부 분기에서 claimed_source != 'eval' 이라 그대로 'ui_detail' 반환, case_id=None)
|
||||
assert resolve("ui_detail", "case_001", None) == ("ui_detail", None)
|
||||
@@ -0,0 +1,266 @@
|
||||
"""presegment PR2 — deep_summary_worker map-reduce/HOLD 배선 단위테스트.
|
||||
|
||||
worker-process 레벨(DB 필요)의 큐 상태 전이는 라이브 E2E 로 검증하고, 여기서는
|
||||
새 메커니즘의 seam 을 단위 검증한다 (test_fair_share.py 선례):
|
||||
- _hold_awaiting_split: payload 마킹 commit 후 StageDeferred(HOLD_RETRY_MINUTES).
|
||||
- _process_map_reduce: 유닛별 map → reduce → doc 필드 기록 / 모든 콜 캡 준수 /
|
||||
payload.presegment.map_results 유닛 단위 persist(멱등 재개) / 실패 유닛 raise /
|
||||
drain 보류(StageDeferred) 시 완료 유닛 보존.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "app"))
|
||||
|
||||
from ai.envelope import EscalationEnvelope # noqa: E402
|
||||
from models.queue import StageDeferred # noqa: E402
|
||||
from services.summarize_units import ( # noqa: E402
|
||||
CAP_TOKENS,
|
||||
estimate_tokens,
|
||||
plan_summarize_units,
|
||||
)
|
||||
import workers.deep_summary_worker as dsw # noqa: E402
|
||||
|
||||
|
||||
# ─── fixtures ────────────────────────────────────────────────────────────────
|
||||
|
||||
# 30 절 × 한글 2,000자 ≈ 31.7K tok (> TRIGGER 25K) · 절당 ≈ 1,060 tok (< CAP) → auto
|
||||
GIANT_AUTO_MD = "\n".join(f"# 절 {i}\n" + ("가" * 2_000) for i in range(30))
|
||||
# 헤딩 1개 + 한글 60,000자 단일 섹션 ≈ 31.7K tok (> CAP) → over% 100 → whole
|
||||
GIANT_WHOLE_MD = "# 통짜\n" + ("가" * 60_000)
|
||||
|
||||
MAP_JSON = (
|
||||
'{"mode": "single", "tldr": "유닛 요약", "detail": "유닛 상세.",'
|
||||
' "inconsistencies": [{"kind": "version_drift", "desc": "개정판 차이"}],'
|
||||
' "confidence": 0.9}'
|
||||
)
|
||||
REDUCE_JSON = (
|
||||
'{"mode": "single", "tldr": "전체 요약", "detail": "최종 상세.",'
|
||||
' "inconsistencies": [], "confidence": 0.8}'
|
||||
)
|
||||
|
||||
|
||||
class FakeSession:
|
||||
"""commit 시점의 queue_row.payload 를 **객체 참조**로 박제 — SQLAlchemy 의 committed
|
||||
스냅샷과 동일하게, 이후 in-place 변경이 과거 커밋 객체에 소급 반영되는 aliasing
|
||||
(60254 라이브에서 unit 0 만 persist 된 버그)을 검증 시점 직렬화로 탐지한다."""
|
||||
|
||||
def __init__(self, row=None):
|
||||
self.commits = 0
|
||||
self._row = row
|
||||
self.snapshots: list = []
|
||||
|
||||
async def commit(self):
|
||||
self.commits += 1
|
||||
if self._row is not None:
|
||||
self.snapshots.append(self._row.payload) # 참조 박제 — 복사 금지(의도)
|
||||
|
||||
|
||||
class FakeClient:
|
||||
"""deep 슬롯 보유 클라이언트 — call_deep_or_defer 가 call_deep 을 타게 한다."""
|
||||
|
||||
def __init__(self, responses=None, fail_indexes=frozenset(), defer_from=None):
|
||||
self.ai = SimpleNamespace(
|
||||
deep=SimpleNamespace(model="qwen-macbook", context_char_limit=260_000)
|
||||
)
|
||||
self.prompts: list[str] = []
|
||||
self._fail_indexes = fail_indexes # 이 순번(0-based) 콜은 파싱 불가 응답
|
||||
self._defer_from = defer_from # 이 순번부터 연결 실패(StageDeferred 변환 대상)
|
||||
|
||||
async def call_deep(self, prompt: str, system=None) -> str:
|
||||
import httpx
|
||||
|
||||
idx = len(self.prompts)
|
||||
if self._defer_from is not None and idx >= self._defer_from:
|
||||
raise httpx.ConnectError("macbook down")
|
||||
self.prompts.append(prompt)
|
||||
if idx in self._fail_indexes:
|
||||
return "정상 JSON 아님"
|
||||
if "유닛 요약 (총" in prompt: # reduce 프롬프트 마커
|
||||
return REDUCE_JSON
|
||||
return MAP_JSON
|
||||
|
||||
async def close(self):
|
||||
pass
|
||||
|
||||
|
||||
def _doc():
|
||||
return SimpleNamespace(
|
||||
id=999,
|
||||
extracted_text=GIANT_AUTO_MD,
|
||||
ai_detail_summary=None,
|
||||
ai_inconsistencies=None,
|
||||
ai_analysis_tier="triage",
|
||||
ai_processed_at=None,
|
||||
)
|
||||
|
||||
|
||||
def _envelope():
|
||||
return EscalationEnvelope(
|
||||
from_stage="classify",
|
||||
escalation_reasons=("long_context",),
|
||||
risk_flags=(),
|
||||
distilled_context="4B 요지",
|
||||
original_pointers={"doc_ids": [999]},
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def _patch_telemetry(monkeypatch):
|
||||
events: list[dict] = []
|
||||
|
||||
async def fake_record(**kwargs):
|
||||
events.append(kwargs)
|
||||
|
||||
monkeypatch.setattr(dsw, "record_analyze_event", fake_record)
|
||||
return events
|
||||
|
||||
|
||||
# ─── _hold_awaiting_split ────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hold_marks_payload_and_defers():
|
||||
plan = plan_summarize_units(GIANT_WHOLE_MD)
|
||||
assert plan.mode == "map_reduce" and plan.tier == "whole"
|
||||
|
||||
session, row = FakeSession(), SimpleNamespace(payload={"envelope": {"x": 1}})
|
||||
with pytest.raises(StageDeferred) as ei:
|
||||
await dsw._hold_awaiting_split(session, row, plan, document_id=999)
|
||||
|
||||
assert ei.value.retry_after_minutes == dsw.HOLD_RETRY_MINUTES
|
||||
assert session.commits == 1 # 마킹이 defer 전에 commit — consumer 재읽기에서 보존
|
||||
preseg = row.payload["presegment"]
|
||||
assert preseg["awaiting_split"] is True
|
||||
assert preseg["tier"] == "whole"
|
||||
assert preseg["units"] == len(plan.units)
|
||||
assert row.payload["envelope"] == {"x": 1} # 기존 payload 병합 보존
|
||||
|
||||
|
||||
# ─── _process_map_reduce — 정상 경로 ────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_map_reduce_end_to_end(monkeypatch, _patch_telemetry):
|
||||
plan = plan_summarize_units(GIANT_AUTO_MD)
|
||||
assert plan.mode == "map_reduce" and plan.tier == "auto"
|
||||
n = len(plan.units)
|
||||
assert n >= 2 # greedy-pack 이 실제로 유닛을 나눴는지
|
||||
|
||||
client = FakeClient()
|
||||
monkeypatch.setattr(dsw, "AIClient", lambda: client)
|
||||
doc = _doc()
|
||||
row = SimpleNamespace(payload={"envelope": {"x": 1}})
|
||||
session = FakeSession(row)
|
||||
|
||||
await dsw._process_map_reduce(
|
||||
doc, row, _envelope(), "generic", plan, session,
|
||||
defer_on_deep_unavailable=False,
|
||||
)
|
||||
|
||||
# 콜 수 = 유닛 map n + reduce 1
|
||||
assert len(client.prompts) == n + 1
|
||||
# 검증 게이트: 모든 콜 est_tokens <= CAP + 오버헤드(정책 템플릿+envelope ~3K)
|
||||
for p in client.prompts:
|
||||
assert estimate_tokens(p) <= CAP_TOKENS + 3_000
|
||||
# doc 기록 = reduce 출력, 불일치 = map 유닛 합본 dedup
|
||||
assert doc.ai_detail_summary == "최종 상세."
|
||||
assert doc.ai_analysis_tier == "deep"
|
||||
assert doc.ai_inconsistencies == [{"kind": "version_drift", "desc": "개정판 차이"}]
|
||||
# 유닛 단위 persist — 유닛마다 commit
|
||||
assert row.payload["presegment"]["units"] == n
|
||||
assert len(row.payload["presegment"]["map_results"]) == n
|
||||
assert session.commits == n
|
||||
# ★aliasing 회귀 방지: 각 commit 이 박제한 payload 객체를 사후에 봤을 때
|
||||
# map_results 가 1,2,...,n 로 단조 증가해야 한다. in-place 변경(구 버그)이면
|
||||
# 모든 스냅샷이 같은 dict 를 공유해 [n,n,...,n] 으로 보인다 = SQLAlchemy 가
|
||||
# committed 스냅샷과 new 가 같다고 판정해 UPDATE 를 스킵하는 것과 등가.
|
||||
per_commit_units = [
|
||||
len(s["presegment"]["map_results"]) for s in session.snapshots
|
||||
]
|
||||
assert per_commit_units == list(range(1, n + 1))
|
||||
# telemetry 1건 (reduce 기준)
|
||||
events = _patch_telemetry
|
||||
assert len(events) == 1 and events[0]["error_code"] is None
|
||||
|
||||
|
||||
# ─── 멱등 재개 ───────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_map_reduce_resume_skips_done_units(monkeypatch, _patch_telemetry):
|
||||
plan = plan_summarize_units(GIANT_AUTO_MD)
|
||||
n = len(plan.units)
|
||||
|
||||
client = FakeClient()
|
||||
monkeypatch.setattr(dsw, "AIClient", lambda: client)
|
||||
done_unit = {
|
||||
"index": 0, "titles": ["절 0"], "tldr": "이전 요약", "detail": "이전 상세.",
|
||||
"inconsistencies": [],
|
||||
}
|
||||
row = SimpleNamespace(payload={
|
||||
"envelope": {"x": 1},
|
||||
"presegment": {"map_results": {"0": done_unit}},
|
||||
})
|
||||
doc, session = _doc(), FakeSession()
|
||||
|
||||
await dsw._process_map_reduce(
|
||||
doc, row, _envelope(), "generic", plan, session,
|
||||
defer_on_deep_unavailable=False,
|
||||
)
|
||||
|
||||
# 유닛 0 은 재호출 안 함 — map (n-1) + reduce 1
|
||||
assert len(client.prompts) == n
|
||||
assert row.payload["presegment"]["map_results"]["0"]["detail"] == "이전 상세."
|
||||
assert doc.ai_detail_summary == "최종 상세."
|
||||
|
||||
|
||||
# ─── map 유닛 실패 → raise (성공분 persist) ─────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_map_unit_parse_failure_raises_but_persists_good_units(
|
||||
monkeypatch, _patch_telemetry
|
||||
):
|
||||
plan = plan_summarize_units(GIANT_AUTO_MD)
|
||||
n = len(plan.units)
|
||||
|
||||
client = FakeClient(fail_indexes={1}) # 두 번째 map 콜만 파싱 불가
|
||||
monkeypatch.setattr(dsw, "AIClient", lambda: client)
|
||||
doc, session = _doc(), FakeSession()
|
||||
row = SimpleNamespace(payload={"envelope": {"x": 1}})
|
||||
|
||||
with pytest.raises(ValueError, match="map 유닛"):
|
||||
await dsw._process_map_reduce(
|
||||
doc, row, _envelope(), "generic", plan, session,
|
||||
defer_on_deep_unavailable=False,
|
||||
)
|
||||
|
||||
# 성공 유닛(n-1)은 persist — 재시도 시 실패 1건만 재호출
|
||||
assert len(row.payload["presegment"]["map_results"]) == n - 1
|
||||
assert "1" not in row.payload["presegment"]["map_results"]
|
||||
assert doc.ai_detail_summary is None # doc 은 미기록
|
||||
assert _patch_telemetry == [] # 가짜 완료 이벤트 없음
|
||||
|
||||
|
||||
# ─── drain 보류 — 완료 유닛 보존 + StageDeferred 전파 ───────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_map_defer_propagates_and_keeps_progress(monkeypatch, _patch_telemetry):
|
||||
plan = plan_summarize_units(GIANT_AUTO_MD)
|
||||
|
||||
client = FakeClient(defer_from=1) # 첫 유닛 성공 후 맥북 절단
|
||||
monkeypatch.setattr(dsw, "AIClient", lambda: client)
|
||||
doc, session = _doc(), FakeSession()
|
||||
row = SimpleNamespace(payload={"envelope": {"x": 1}})
|
||||
|
||||
with pytest.raises(StageDeferred):
|
||||
await dsw._process_map_reduce(
|
||||
doc, row, _envelope(), "generic", plan, session,
|
||||
defer_on_deep_unavailable=True, # drain 시멘틱 — 보류 전파
|
||||
)
|
||||
|
||||
assert len(row.payload["presegment"]["map_results"]) == 1
|
||||
assert doc.ai_detail_summary is None
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user