Compare commits
70 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d89f046121 | |||
| 91a540d533 | |||
| c79bf41a76 | |||
| f527c63232 | |||
| b2949d26ff | |||
| 151c1ee518 | |||
| ebbcaf86d8 | |||
| 6d978289b8 | |||
| 73c6f123b8 | |||
| 57c1805a8d | |||
| cbdd4a3df7 | |||
| bf0348a3e0 | |||
| 244d526ae2 | |||
| c5bc1f773d | |||
| fdabca2a2f | |||
| 1fbb341e28 | |||
| d007ad5492 | |||
| 6167e03625 | |||
| b6a4821cac | |||
| ba943d703a | |||
| 345e2cedf0 | |||
| b461559d2f | |||
| 9b9790f05d | |||
| b49596135e | |||
| 0a82a5b1bc | |||
| 74e29e510e | |||
| c1555fd6ab | |||
| 1d5755b279 | |||
| a3e0d30569 | |||
| 540bc00dba | |||
| 30c235e4c1 | |||
| 8a3bea6b31 | |||
| cd439b0ff4 | |||
| a6db6c999b | |||
| ed7740beee | |||
| 595f4b7d5e | |||
| b630c31077 | |||
| 235aa648ad | |||
| 60cb48bbe4 | |||
| 79deae0644 | |||
| 9a7e231dcc | |||
| 1646617a31 | |||
| bacb36924b | |||
| a28f12b12e | |||
| 0c8fb41366 | |||
| e5ddd0e4d6 | |||
| 3feddd012b | |||
| 5da94213ec | |||
| 85304878f4 | |||
| adce639445 | |||
| d05e41128a | |||
| 2bbdf63d86 | |||
| 5581d3f1ce | |||
| 8ac1dbf4a8 | |||
| c3d237766d | |||
| 5bc68c95f6 | |||
| 5dca5b5d28 | |||
| 9c9ff6eeba | |||
| d667545185 | |||
| 235bbf9881 | |||
| 30200a4e49 | |||
| eff2c3b7d3 | |||
| 3d79002dfa | |||
| 3d60008965 | |||
| cd0040925a | |||
| fdac449a48 | |||
| 40f5b5fe9e | |||
| 250896cdfa | |||
| 5e8b998a11 | |||
| 53999b2825 |
@@ -150,15 +150,26 @@ def is_deferrable_error(exc: Exception) -> bool:
|
||||
return isinstance(exc, httpx.TransportError)
|
||||
|
||||
|
||||
async def call_deep_or_defer(client: "AIClient", prompt: str, system: str | None = None) -> str:
|
||||
async def call_deep_or_defer(
|
||||
client: "AIClient",
|
||||
prompt: str,
|
||||
system: str | None = None,
|
||||
cfg: "AIModelConfig | None" = None,
|
||||
) -> str:
|
||||
"""call_deep + 보류 변환 — 맥북 불가(503/연결/절단)는 StageDeferred 로 raise.
|
||||
|
||||
deep_summary_worker / summarize_worker(drain) 가 공유. StageDeferred 는 queue_consumer/
|
||||
queue_drain 이 attempts 미소모 + deferred_until 백오프로 처리한다 (sleep-안전 불변식).
|
||||
deep_summary_worker / summarize_worker(drain) / classify_worker(drain) 가 공유.
|
||||
StageDeferred 는 queue_consumer/queue_drain 이 attempts 미소모 + deferred_until
|
||||
백오프로 처리한다 (sleep-안전 불변식).
|
||||
|
||||
cfg: 지정 시 deep 슬롯 대신 이 config 로 호출 (classify drain — deep 슬롯의
|
||||
endpoint 는 쓰되 triage 의 temperature/max_tokens 를 적용한 변형).
|
||||
"""
|
||||
from models.queue import StageDeferred
|
||||
|
||||
try:
|
||||
if cfg is not None:
|
||||
return await client._request(cfg, prompt, system=system)
|
||||
return await client.call_deep(prompt, system=system)
|
||||
except Exception as exc:
|
||||
if is_deferrable_error(exc):
|
||||
@@ -231,26 +242,29 @@ class AIClient:
|
||||
|
||||
# ─── Legacy API (classify_worker 교체 시 제거 예정) ───────────────────
|
||||
|
||||
async def classify(self, text: str) -> dict:
|
||||
async def classify(self, text: str, cfg=None) -> dict:
|
||||
"""[DEPRECATED] 기존 classify_worker 전용. B-1 에서 summary_triage 로 대체.
|
||||
|
||||
호출부 정리 전 존속. 신규 코드는 call_triage + prompt_render 를 쓸 것.
|
||||
cfg (2026-06-12 fair-share): 지정 시 primary 대신 해당 config 로 호출 —
|
||||
drain classify 가 deep 슬롯(맥북) 경유에 사용. cfg != ai.primary 라
|
||||
_call_chat 의 primary→fallback 자동 전환은 발동하지 않는다 (에러 raw 전파).
|
||||
"""
|
||||
prompt = CLASSIFY_PROMPT.replace("{document_text}", text)
|
||||
response = await self._call_chat(self.ai.primary, prompt)
|
||||
response = await self._call_chat(cfg or self.ai.primary, prompt)
|
||||
return response
|
||||
|
||||
async def summarize(self, text: str, force_premium: bool = False) -> str:
|
||||
"""[DEPRECATED] 기존 호출부용. B-1 에서 summary_triage 가 tldr 대체."""
|
||||
async def summarize(self, text: str, force_premium: bool = False, cfg=None) -> str:
|
||||
"""[DEPRECATED] 기존 호출부용. B-1 에서 summary_triage 가 tldr 대체. cfg = classify() 와 동일."""
|
||||
if force_premium:
|
||||
return await self._call_chat(self.ai.premium, f"다음 문서를 500자 이내로 요약해주세요:\n\n{text}")
|
||||
return await self._call_chat(self.ai.primary, f"다음 문서를 500자 이내로 요약해주세요:\n\n{text}")
|
||||
return await self._call_chat(cfg or self.ai.primary, f"다음 문서를 500자 이내로 요약해주세요:\n\n{text}")
|
||||
|
||||
async def embed(self, text: str) -> list[float]:
|
||||
"""벡터 임베딩 — GPU 서버 전용"""
|
||||
response = await self._http.post(
|
||||
self.ai.embedding.endpoint,
|
||||
json={"model": self.ai.embedding.model, "prompt": text},
|
||||
json={"model": self.ai.embedding.model, "prompt": text, "keep_alive": -1}, # bge-m3 GPU 상주(홈랩 sparse 검색 cold reload ~6s 방지)
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()["embedding"]
|
||||
|
||||
@@ -244,7 +244,15 @@ async def regenerate(
|
||||
user: Annotated[User, Depends(require_admin)],
|
||||
):
|
||||
"""수동 트리거 — 백그라운드 태스크로 워커 실행 (admin 필요)."""
|
||||
from core.config import settings
|
||||
from workers.digest_worker import run
|
||||
|
||||
# 홀드 중 silent no-op 방지 — 워커 게이트와 동일 조건을 표면에서 명시.
|
||||
if "digest" in settings.pipeline_held_stages:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="global_digest 보류 중 (config.yaml pipeline.held_stages) — 해제 후 재시도",
|
||||
)
|
||||
|
||||
asyncio.create_task(run())
|
||||
return {"status": "started", "message": "global_digest 워커 백그라운드 실행 시작"}
|
||||
|
||||
@@ -210,8 +210,14 @@ class DocumentDetailResponse(DocumentResponse):
|
||||
|
||||
|
||||
class AcceptSuggestionRequest(BaseModel):
|
||||
"""§1 accept-suggestion 요청 body — stale payload / doc 수정 검출."""
|
||||
"""§1 accept-suggestion 요청 body — stale payload / doc 수정 검출.
|
||||
|
||||
jurisdiction: 안전 자료실 A-2 — material_type 제안 승인 시 사용자가 지정하는 관할.
|
||||
law 승인은 필수 (기본값 없음 — KR 자동 부여 시 외국 자료가 KR 법령으로 오염되는
|
||||
경로를 차단, plan A-2 계약).
|
||||
"""
|
||||
expected_source_updated_at: datetime
|
||||
jurisdiction: str | None = None
|
||||
|
||||
|
||||
class DocumentUpdate(BaseModel):
|
||||
@@ -537,6 +543,8 @@ async def list_documents(
|
||||
category: str | None = Query(None, description="doc_category enum — 지정 시 기본 news/memo 제외 해제"),
|
||||
has_suggestion: bool | None = Query(None, description="true: ai_suggestion IS NOT NULL"),
|
||||
proposed_category: str | None = Query(None, description="ai_suggestion.proposed_category 필터"),
|
||||
material_type: str | None = Query(None, description="안전 자료실 C-1: 자료유형. 지정 시 기본 exclude 해제"),
|
||||
jurisdiction: str | None = Query(None, description="안전 자료실 C-1: 관할 (KR/US/...)"),
|
||||
):
|
||||
"""문서 목록 조회 (페이지네이션 + 필터).
|
||||
|
||||
@@ -550,6 +558,10 @@ async def list_documents(
|
||||
if category:
|
||||
# 명시적 카테고리 필터 — 기본 exclude 해제
|
||||
query = query.where(Document.category == category)
|
||||
elif material_type:
|
||||
# 안전 자료실 C-1: material_type 지정 = 기본 exclude(news·law_monitor·note) 해제.
|
||||
# 안전 코퍼스 본체(KOSHA 사례·CSB·법령 등)가 전부 note/crawl 채널이라 exclude 면 빈 화면.
|
||||
query = query.where(Document.material_type == material_type)
|
||||
else:
|
||||
# 기본 목록: 뉴스/메모/법령 제외 (문서함 용도)
|
||||
query = query.where(
|
||||
@@ -558,6 +570,9 @@ async def list_documents(
|
||||
Document.file_type != "note",
|
||||
)
|
||||
|
||||
if jurisdiction:
|
||||
query = query.where(Document.jurisdiction == jurisdiction)
|
||||
|
||||
if has_suggestion is True:
|
||||
query = query.where(Document.ai_suggestion.isnot(None))
|
||||
elif has_suggestion is False:
|
||||
@@ -665,7 +680,12 @@ class SectionItem(BaseModel):
|
||||
level: int | None = None
|
||||
node_type: str | None = None # window | chapter_split | clause_split | section_split | null
|
||||
is_leaf: bool
|
||||
parent_id: int | None = None # 트리 부모 chunk_id. window child 의 parent_id = 그 split-parent.
|
||||
# 프런트 collapseWindows 가 비인접 window 를 split-parent 에 흡수할 때 사용.
|
||||
char_start: int | None = None # md_content 내 heading offset(UTF-16). jump-target 만 값, 그 외 None (Path B)
|
||||
text: str | None = None # 절 본문 = 청크 원문. 대형 split 문서는 md_content 가 앞 5만 자만 보존
|
||||
# (marker LARGE_DOC_MD_CONTENT_HEAD_CHARS)이고 char_start 도 NULL 이라
|
||||
# md_content 슬라이스로는 본문이 비므로, 청크 text 를 직접 렌더한다.
|
||||
section_type: str | None = None
|
||||
summary: str | None = None # status='summarized' 인 분석행에만, 그 외 None
|
||||
confidence: float | None = None
|
||||
@@ -704,12 +724,12 @@ async def get_document_sections(
|
||||
await session.execute(
|
||||
sql_text(
|
||||
"""
|
||||
SELECT chunk_id, section_title, heading_path, level, node_type, is_leaf, char_start,
|
||||
section_type, summary, confidence
|
||||
SELECT chunk_id, section_title, heading_path, level, node_type, is_leaf, parent_id, char_start,
|
||||
text, section_type, summary, confidence
|
||||
FROM (
|
||||
SELECT DISTINCT ON (c.id)
|
||||
c.id AS chunk_id, c.chunk_index, c.section_title, c.heading_path,
|
||||
c.level, c.node_type, c.is_leaf, c.char_start,
|
||||
c.level, c.node_type, c.is_leaf, c.parent_id, c.char_start, c.text,
|
||||
a.section_type,
|
||||
CASE WHEN a.status = 'summarized' THEN a.summary ELSE NULL END AS summary,
|
||||
a.confidence
|
||||
@@ -1244,11 +1264,49 @@ async def accept_suggestion(
|
||||
# payload 적용
|
||||
proposed_category = doc.ai_suggestion.get("proposed_category")
|
||||
proposed_path = doc.ai_suggestion.get("proposed_path")
|
||||
# 안전 자료실 A-2 — material_type 제안 (classify 의 document_type 결정적 매핑)
|
||||
proposed_material = doc.ai_suggestion.get("proposed_material_type")
|
||||
|
||||
if not proposed_category:
|
||||
raise HTTPException(status_code=422, detail="proposed_category 누락된 suggestion")
|
||||
if not proposed_category and not proposed_material:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="proposed_category/proposed_material_type 둘 다 누락된 suggestion",
|
||||
)
|
||||
|
||||
doc.category = proposed_category
|
||||
if proposed_category:
|
||||
doc.category = proposed_category
|
||||
|
||||
if proposed_material:
|
||||
_MATERIAL_TYPES = {"law", "paper", "book", "incident", "manual", "standard", "guide"}
|
||||
_JURISDICTIONS = {"KR", "US", "EU", "JP", "GB", "INT"}
|
||||
if proposed_material not in _MATERIAL_TYPES:
|
||||
raise HTTPException(
|
||||
status_code=422, detail=f"허용 밖 material_type: {proposed_material}"
|
||||
)
|
||||
jur = body.jurisdiction or doc.ai_suggestion.get("proposed_jurisdiction")
|
||||
if jur is not None and jur not in _JURISDICTIONS:
|
||||
raise HTTPException(status_code=422, detail=f"허용 밖 jurisdiction: {jur}")
|
||||
# law = 국가 필수 입력, 기본값 없음 (plan A-2 — KR 자동 부여 시 외국 법령 오염.
|
||||
# DB CHECK(chk_documents_law_jurisdiction) 도 거부하지만 422 로 명시 안내).
|
||||
if proposed_material == "law" and not jur:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="법령(law) 승인은 jurisdiction 필수 — body.jurisdiction 으로 국가를 지정하세요 (기본값 없음)",
|
||||
)
|
||||
doc.material_type = proposed_material
|
||||
doc.jurisdiction = jur
|
||||
# 미러 동기화 1문 — jurisdiction 부여/정정 시 청크 country 동반 UPDATE
|
||||
# (leg 간 국가 불일치 방지, plan A-2 계약. 단일 지점 = 본 승인 경로).
|
||||
if jur:
|
||||
from sqlalchemy import update as sa_update
|
||||
|
||||
from models.chunk import DocumentChunk
|
||||
|
||||
await session.execute(
|
||||
sa_update(DocumentChunk)
|
||||
.where(DocumentChunk.doc_id == doc.id)
|
||||
.values(country=jur)
|
||||
)
|
||||
|
||||
# user_tags append (중복 방지, normalize + dedup 통과)
|
||||
if proposed_path:
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
|
||||
확정 결정:
|
||||
- D-1 경로 = /api/eid/chat (main.py prefix=/api/eid + 본 라우터 POST /chat)
|
||||
- D-2 mode 닫힌 어휘: daily(mac-mini-default) / deep(qwen-macbook). 클라는 mode 만 보냄 —
|
||||
claude-cloud / auto 금지 (Literal 로 422 차단). 심층(deep) 모드 무게이트.
|
||||
- D-2 mode 닫힌 어휘: daily / deep — 둘 다 mac-mini-default (맥북 백지화 2026-06-11,
|
||||
맥미니 Qwen 27B 단일 호스트. deep = ReAct 자동검색 모드 구분). 클라는 mode 만 보냄 —
|
||||
claude-cloud / auto 금지 (Literal 로 422 차단). 게이트 = alias 기준 자동 적용(무게이트 폐지).
|
||||
- D-3 독립 /chat 라우트 (frontend) — 본 모듈은 백엔드 API 만.
|
||||
- D-5 LLM 호출 = EidAIClient.call_stream 한 곳 (이드 egress 봉쇄 불변식 #5,
|
||||
RouterBackend 직접 호출 금지).
|
||||
@@ -18,24 +19,58 @@ backend 실패는 /api/search/ask 와 동일 shape 의 503 + error_reason 매핑
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import Annotated, Literal
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
from pydantic import BaseModel, Field, field_validator, model_validator
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from core.auth import get_current_user
|
||||
from core.database import get_session
|
||||
from core.utils import setup_logger
|
||||
from eid import compose as eid_compose
|
||||
from eid.ai import EidAIClient
|
||||
from models.user import User
|
||||
from services.llm.backends import BackendUnavailable
|
||||
from services.llm.backends import BackendUnavailable, _router_url, get_backend
|
||||
from services.search import llm_gate
|
||||
from services.search.react_loop import agentic_ask_loop
|
||||
|
||||
logger = setup_logger("eid_chat")
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# ── ds-eid-ask-absorb P1: deep 모드 = ReAct 자동검색 (맥미니 Qwen 27B, 2026-06-11~) ──
|
||||
# 비생성 reachability probe — router 도달만 확인(coarse). 27B(맥북) 자체 미가용은
|
||||
# 첫 generate_with_tools 호출의 BackendUnavailable → mid-stream error envelope 로 커버
|
||||
# (plan: probe 정밀도 불필요, TOCTOU 는 in-stream error 가 처리). ~2s 타임아웃·생성 슬롯 비점유.
|
||||
_DEEP_PROBE_TIMEOUT = httpx.Timeout(connect=2.0, read=2.0, write=2.0, pool=2.0)
|
||||
# heartbeat: ReAct 다회 tool call 시 수십초 무출력 → 프록시 idle timeout 차단.
|
||||
# `{"phase":"ping"}` no-op 이벤트 (프론트 envelope 파서가 자연 스킵 — `: ping` comment 는
|
||||
# POST SSE fetch 파서가 처리 보장 안 됨).
|
||||
_HEARTBEAT_INTERVAL_S = 10.0
|
||||
|
||||
|
||||
async def _probe_router_reachable() -> bool:
|
||||
"""router(:8890) /v1/models GET — 도달 확인(비생성). 실패/비200 = 미가용."""
|
||||
url = f"{_router_url().rstrip('/')}/v1/models"
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=_DEEP_PROBE_TIMEOUT) as client:
|
||||
resp = await client.get(url)
|
||||
return resp.status_code == 200
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _sse(obj: dict) -> bytes:
|
||||
"""SSE 이벤트 1건 — data: <json>\\n\\n. final_answer 는 OpenAI 호환 choices.delta.content
|
||||
로, sources/phase 는 별 envelope 키로(프론트가 분기). model/usage 머신 메타 미포함."""
|
||||
return b"data: " + json.dumps(obj, ensure_ascii=False).encode("utf-8") + b"\n\n"
|
||||
|
||||
|
||||
class ChatMessage(BaseModel):
|
||||
"""채팅 턴 1건. role=system 은 Literal 밖 → 422 (system 합본은 서버 compose 만 주입)."""
|
||||
@@ -71,16 +106,130 @@ class ChatRequest(BaseModel):
|
||||
return self
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
async def eid_status(
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
):
|
||||
"""이드 backend 점유 상태 스냅샷 — GET /api/eid/status (UI 의 "대기 vs 고장" 구분용).
|
||||
|
||||
daily(맥미니 MLX) 의 DS 프로세스 내부 llm_gate 점유만 본다 — 외부 소비자
|
||||
(맥미니 자체 derived-worker·Hermes 등)의 endpoint 점유는 미포착.
|
||||
따라서 busy=true 는 확실(지금 줄이 있다), false 는 근사(외부 점유 가능성 잔존).
|
||||
|
||||
가벼움 보장: DB 0 / LLM 0 / 본문 로깅 0 — 폴링 대상으로 안전.
|
||||
자동 fallback 판단 근거로 쓰지 않는다 (모드 전환 = 명시 버튼만, 정책).
|
||||
"""
|
||||
snap = llm_gate.gate_status()
|
||||
inflight = bool(snap["inflight"])
|
||||
waiters = int(snap["waiters"])
|
||||
return {
|
||||
"daily": {
|
||||
"busy": inflight or waiters > 0,
|
||||
"inflight": inflight,
|
||||
"waiters": waiters,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def _backend_unavailable_response(body: ChatRequest, reason: str, backend_name: str) -> JSONResponse:
|
||||
"""스트림 시작 전 27B 미가용 → ask 컨벤션과 동일 shape 503 (자동 fallback 0)."""
|
||||
logger.warning(
|
||||
"eid_chat backend_unavailable mode=%s turns=%d status=503 reason=%s",
|
||||
body.mode, len(body.messages), reason,
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=503,
|
||||
content={
|
||||
"error": "backend_unavailable",
|
||||
"error_reason": reason,
|
||||
"backend_requested": backend_name,
|
||||
"detail": (
|
||||
"심층 엔진(검색)이 일시적으로 응답할 수 없습니다. "
|
||||
"잠시 후 다시 시도하거나 일상 모드로 물어보세요."
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def _eid_chat_deep(body: ChatRequest, session: AsyncSession) -> StreamingResponse | JSONResponse:
|
||||
"""deep 모드 = ReAct 자동검색. ReAct(`tool_choice=auto`)가 검색 여부를 LLM 자율 판단 —
|
||||
검색 불요 질문은 early-exit 으로 대화 답변. substrate(persona+rules+react_ask task)는
|
||||
agentic_ask_loop 내부 compose("react_ask") 가 주입(evidence-first 자동 상속).
|
||||
|
||||
멀티턴 = 1단계는 마지막 user 메시지 단독 처리(agentic_ask_loop 가 query: str — history
|
||||
미지원). 후속 질문 대명사 해소는 2단계 백로그.
|
||||
"""
|
||||
# ① 첫 SSE 바이트(=HTTP 200 확정) 전 비생성 probe — router 도달 실패 시 503 (재매핑 가능 구간)
|
||||
if not await _probe_router_reachable():
|
||||
return _backend_unavailable_response(body, "router_unreachable", "mac-mini-default")
|
||||
|
||||
query = body.messages[-1].content # 메시지 단독 처리 (마지막 user 턴)
|
||||
backend = get_backend("mac-mini-default")
|
||||
|
||||
async def _stream() -> AsyncIterator[bytes]:
|
||||
# ② phase:searching 방출 = HTTP 200 확정. 이후 미가용은 503 불가 → in-stream error.
|
||||
yield _sse({"phase": "searching"})
|
||||
task = asyncio.create_task(agentic_ask_loop(session, query, backend=backend))
|
||||
try:
|
||||
# heartbeat: task 미완 동안 ~10s 마다 ping (shield 로 wait_for 취소가 task 안 죽임)
|
||||
while not task.done():
|
||||
try:
|
||||
await asyncio.wait_for(asyncio.shield(task), timeout=_HEARTBEAT_INTERVAL_S)
|
||||
except asyncio.TimeoutError:
|
||||
yield _sse({"phase": "ping"})
|
||||
result = task.result() # BackendUnavailable 은 여기서 raise (mid-stream)
|
||||
# final_answer = OpenAI 호환 1청크(프론트 기존 content 누적 경로 재사용)
|
||||
yield _sse({"choices": [{"delta": {"content": result.final_answer}}]})
|
||||
# 근거 = 별 envelope (citation 번호 없음 — 프론트가 순서 기반). partial = 근거 부족 표식
|
||||
yield _sse({"eid_sources": result.sources, "partial": result.partial})
|
||||
yield b"data: [DONE]\n\n"
|
||||
logger.info(
|
||||
"eid_chat deep ok turns=%d sources=%d partial=%s iters=%d",
|
||||
len(body.messages), len(result.sources), result.partial, result.iterations,
|
||||
)
|
||||
except BackendUnavailable as exc:
|
||||
# mid-stream 미가용(검색 중 AC 분리·뚜껑 닫힘) — 200 이미 송신, in-stream error envelope.
|
||||
# error 뒤 [DONE] = 프론트 sawDone 로 '중단' 오경보 방지(명시 error notice 유지).
|
||||
logger.warning(
|
||||
"eid_chat deep mid-stream unavailable turns=%d reason=%s",
|
||||
len(body.messages), exc.reason,
|
||||
)
|
||||
yield _sse({"phase": "error", "error_reason": exc.reason})
|
||||
yield b"data: [DONE]\n\n"
|
||||
except asyncio.CancelledError:
|
||||
raise # 클라 disconnect — finally 가 task 정리
|
||||
except Exception:
|
||||
logger.exception("eid_chat deep stream failed turns=%d", len(body.messages))
|
||||
yield _sse({"phase": "error", "error_reason": "deep_failed"})
|
||||
yield b"data: [DONE]\n\n"
|
||||
finally:
|
||||
# 클라 disconnect 시 ReAct task 고아화 방지 — cancel + await(전파 완료 보장).
|
||||
# 안 하면 27B 가 닫힌 연결 위해 수분 점유, router 동시성상 다음 검색 대기.
|
||||
if not task.done():
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except (asyncio.CancelledError, Exception):
|
||||
pass
|
||||
|
||||
return StreamingResponse(
|
||||
_stream(),
|
||||
media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-store", "X-Accel-Buffering": "no"},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/chat")
|
||||
async def eid_chat(
|
||||
body: ChatRequest,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
):
|
||||
"""이드 채팅 — router SSE 스트리밍 pass-through.
|
||||
"""이드 채팅 — daily = router SSE pass-through(대화) / deep = ReAct 자동검색(근거).
|
||||
|
||||
503 두 경로 (둘 다 자동 fallback 없음):
|
||||
503 경로 (모두 자동 fallback 없음):
|
||||
- substrate_degraded: rules.md 부재 (D-6 fail-closed, 채팅 진행 거부)
|
||||
- backend_unavailable: 스트림 시작 전 backend 실패 (ask 컨벤션과 동일 shape)
|
||||
- backend_unavailable: 스트림 시작 전 backend 실패 (daily/deep 공통, ask 컨벤션 shape)
|
||||
"""
|
||||
# D-6: rules 부재 = fail-closed. 채팅은 안전·정책 가드 없이 진행하지 않는다(배너 X).
|
||||
if not eid_compose.rules_present():
|
||||
@@ -99,6 +248,11 @@ async def eid_chat(
|
||||
},
|
||||
)
|
||||
|
||||
# deep = ReAct 자동검색 (별 흐름 — probe + 동기 ReAct → SSE 변환)
|
||||
if body.mode == "deep":
|
||||
return await _eid_chat_deep(body, session)
|
||||
|
||||
# daily = 순수 대화 SSE pass-through (기존)
|
||||
system = eid_compose.compose("eid_chat", task="")
|
||||
client = EidAIClient()
|
||||
stream = client.call_stream(
|
||||
|
||||
@@ -1,20 +1,30 @@
|
||||
"""처리 머신 보드 API — GET /api/queue/overview (plan ds-processing-ui-6an).
|
||||
"""처리 머신 보드 API — /api/queue/* (plan ds-processing-ui-6an → ds-board-engines-1).
|
||||
|
||||
홈 stage 평면 테이블을 "머신 관점 보드(누가 일하나)"로 — 집계 로직은
|
||||
services/queue_overview.py (순수 판정부 분리). 응답 스키마는 FE 와 계약 고정.
|
||||
응답에 raw 모델명 노출 금지 — 머신 label 만.
|
||||
- GET /overview: 홈 stage 평면 테이블을 "머신 관점 보드(누가 일하나)"로 — 집계
|
||||
로직은 services/queue_overview.py (순수 판정부 분리). 응답 스키마는 FE 와
|
||||
계약 고정. 응답에 raw 모델명 노출 금지 — 머신 label 만 (엔진/모델 표기는
|
||||
FE 정적 맵 책임).
|
||||
- GET /failed + POST /retry|/skip: 실패 처리 (ds-board-engines-1) — 영구 실패
|
||||
(자동 재시도 3회 소진)의 유일한 사용자 조치 경로. 일괄 조치는 FE 가 그룹의
|
||||
id 목록을 모아 보낸다 (서버측 패턴 매칭 없음 — raw 식별자/패턴 미수신).
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Annotated, Literal
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from core.auth import get_current_user
|
||||
from core.database import get_session
|
||||
from models.user import User
|
||||
from services.queue_overview import build_overview
|
||||
from services.queue_overview import (
|
||||
build_overview,
|
||||
fetch_failed_items,
|
||||
retry_failed,
|
||||
skip_failed,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -49,6 +59,20 @@ class SummarizeEta(BaseModel):
|
||||
eta_minutes: int | None
|
||||
|
||||
|
||||
class MachineDone(BaseModel):
|
||||
"""머신 1대의 summarize 완료 실적 (분담 표시용)."""
|
||||
done_1h: int
|
||||
done_today: int
|
||||
|
||||
|
||||
class SummarizeByMachine(BaseModel):
|
||||
"""summarize 풀의 머신별 완료 실적 분담 — 보드 레인의 '맥미니 vs 맥북'
|
||||
오프로드 가시화용. rows_to_summarize_split 이 이미 계산하던 값의 노출
|
||||
(ds-board-merged A-1, 신규 수집 SQL 0)."""
|
||||
macmini: MachineDone
|
||||
macbook: MachineDone
|
||||
|
||||
|
||||
class TrendBucket(BaseModel):
|
||||
"""summarize 24h 추이 버킷 — hour 는 KST "HH:00" 라벨."""
|
||||
hour: str
|
||||
@@ -64,21 +88,77 @@ class Totals(BaseModel):
|
||||
|
||||
|
||||
class StageRow(BaseModel):
|
||||
"""단계별 현황 행 — '단계 상세' 패널용 (완료 가시화)."""
|
||||
"""단계별 현황 행 — 흐름 노드/상세 패널용.
|
||||
|
||||
done_1h/created_1h = 처리율·유입률 (유입 우세 판정 + ETA 의 FE 재료,
|
||||
ds-board-engines-1 추가 — 수집 SQL 에 이미 있던 값의 노출).
|
||||
"""
|
||||
stage: str
|
||||
pending: int
|
||||
processing: int
|
||||
failed: int
|
||||
done_1h: int
|
||||
created_1h: int
|
||||
done_today: int
|
||||
oldest_pending_age_sec: int | None
|
||||
|
||||
|
||||
class BackgroundJobItem(BaseModel):
|
||||
"""큐 밖 관리 스크립트(백필 등) 작업 — processing_queue 가 못 보는 사각지대 노출.
|
||||
stale = running 인데 heartbeat 가 오래 끊김(프로세스 사망 추정)."""
|
||||
id: int
|
||||
kind: str
|
||||
label: str | None
|
||||
state: Literal["running", "done", "failed"]
|
||||
processed: int
|
||||
total: int | None
|
||||
elapsed_sec: int
|
||||
stale: bool
|
||||
error: str | None
|
||||
|
||||
|
||||
class QueueOverviewResponse(BaseModel):
|
||||
machines: list[MachineCard]
|
||||
stages: list[StageRow]
|
||||
summarize_eta: SummarizeEta
|
||||
summarize_by_machine: SummarizeByMachine
|
||||
trend_24h: list[TrendBucket]
|
||||
totals: Totals
|
||||
background_jobs: list[BackgroundJobItem] = []
|
||||
|
||||
|
||||
class FailedItem(BaseModel):
|
||||
"""영구 실패 행 — 실패 드로어 표시 단위."""
|
||||
id: int
|
||||
stage: str
|
||||
document_id: int
|
||||
title: str
|
||||
attempts: int
|
||||
max_attempts: int
|
||||
error_message: str | None
|
||||
failed_at: datetime | None
|
||||
|
||||
|
||||
class FailedListResponse(BaseModel):
|
||||
items: list[FailedItem]
|
||||
total: int
|
||||
|
||||
|
||||
class QueueActionRequest(BaseModel):
|
||||
"""재시도/건너뛰기 대상 — 실패 행 id 목록 (FE 가 그룹핑 후 전달)."""
|
||||
ids: list[int] = Field(min_length=1, max_length=300)
|
||||
|
||||
|
||||
class RetryResponse(BaseModel):
|
||||
requested: int
|
||||
retried: int
|
||||
not_retried: int
|
||||
|
||||
|
||||
class SkipResponse(BaseModel):
|
||||
requested: int
|
||||
skipped: int
|
||||
not_skipped: int
|
||||
|
||||
|
||||
@router.get("/overview", response_model=QueueOverviewResponse)
|
||||
@@ -88,3 +168,40 @@ async def get_queue_overview(
|
||||
):
|
||||
"""머신 관점 처리 보드 + summarize ETA 집계 (라이브 계산, 신규 테이블 0)"""
|
||||
return QueueOverviewResponse.model_validate(await build_overview(session))
|
||||
|
||||
|
||||
@router.get("/failed", response_model=FailedListResponse)
|
||||
async def get_failed_items(
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
):
|
||||
"""영구 실패 행 목록 (문서 제목 포함, 최대 300건)"""
|
||||
items = await fetch_failed_items(session)
|
||||
return FailedListResponse(
|
||||
items=[FailedItem.model_validate(i) for i in items],
|
||||
total=len(items),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/retry", response_model=RetryResponse)
|
||||
async def retry_failed_items(
|
||||
body: QueueActionRequest,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
):
|
||||
"""실패 행 재시도 — attempts 리셋 + pending 복귀.
|
||||
|
||||
not_retried = 같은 (문서, 단계) 의 active 행 충돌(uq_queue_active) 또는
|
||||
이미 failed 가 아닌 행 (중복 클릭 등) — 건드리지 않고 건수만 보고.
|
||||
"""
|
||||
return RetryResponse.model_validate(await retry_failed(session, body.ids))
|
||||
|
||||
|
||||
@router.post("/skip", response_model=SkipResponse)
|
||||
async def skip_failed_items(
|
||||
body: QueueActionRequest,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
):
|
||||
"""실패 행 건너뛰기 — completed 마킹(payload.skipped_by_user) + 연쇄 없음"""
|
||||
return SkipResponse.model_validate(await skip_failed(session, body.ids))
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
import asyncio
|
||||
import hmac
|
||||
import time
|
||||
from datetime import date
|
||||
from typing import Annotated, Literal
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, Header, Query
|
||||
@@ -31,6 +32,8 @@ 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
|
||||
@@ -70,6 +73,14 @@ class SearchResult(BaseModel):
|
||||
# PR-RAG-Time-1: freshness decay 디버그 메타. apply_freshness_decay 가 채움.
|
||||
# 비적용 row 도 채워짐(freshness_policy=None). base_score 는 항상 보존.
|
||||
freshness_debug: dict | None = None
|
||||
# 안전 자료실 C-1: 분류 축 메타 (3 leg SELECT 에서 채움 — additive, ranking 무관).
|
||||
# D-1 UI 결과 카드 유형별 렌더 + 해외 법령(B-5) 가동 시 국가 무표지 혼재 차단의 선행 조건.
|
||||
material_type: str | None = None
|
||||
jurisdiction: str | None = None
|
||||
published_date: date | None = None
|
||||
# 안전 자료실 C-1 후속: 법령 버전 상태(legal_meta.version_status) — wrapper 1회 decorate.
|
||||
# law 결과만 채워짐(legal_meta 위성), 그 외/무매핑 law = None. D-1 버전 뱃지 선행.
|
||||
version_status: str | None = None
|
||||
|
||||
|
||||
# ─── Phase 0.4: 디버그 응답 스키마 ─────────────────────────
|
||||
@@ -101,6 +112,9 @@ class SearchResponse(BaseModel):
|
||||
query: str
|
||||
mode: str
|
||||
debug: SearchDebug | None = None
|
||||
# 안전 자료실 C-1 후속: facets=true 일 때만 채워짐(미요청=None, byte 불변).
|
||||
# top-K 결과 내 분류 축 분포 라벨 {axis: {label: count}}.
|
||||
facets: dict[str, dict[str, int]] | None = None
|
||||
|
||||
|
||||
def _to_debug_candidates(rows: list[SearchResult], n: int = 20) -> list[DebugCandidate]:
|
||||
@@ -205,9 +219,23 @@ async def search(
|
||||
"분리용. production 검색에는 사용 금지 (latency 큼)."
|
||||
),
|
||||
),
|
||||
material_type: str | None = Query(
|
||||
None, description="안전 자료실 C-1: 자료유형 필터 CSV (law,paper,incident,...). material_type = ANY"),
|
||||
jurisdiction: str | None = Query(
|
||||
None, description="안전 자료실 C-1: 관할 필터 (KR/US/EU/JP/GB/INT)"),
|
||||
year_from: int | None = Query(None, ge=1900, le=2100, description="published_date 연도 하한 (NULL=created_at fallback)"),
|
||||
year_to: int | None = Query(None, ge=1900, le=2100, description="published_date 연도 상한"),
|
||||
facets: bool = Query(False, description="안전 자료실 C-1 후속: top-K 결과 분류 축 분포(material_type/jurisdiction/version_status)를 응답 facets 에 집계. 미지정=계산/노출 0"),
|
||||
):
|
||||
"""문서 검색 — FTS + ILIKE + 벡터 결합 (Phase 3.1 이후 run_search wrapper)"""
|
||||
try:
|
||||
axis = AxisFilter(
|
||||
material_types=[m.strip() for m in material_type.split(",") if m.strip()]
|
||||
if material_type else None,
|
||||
jurisdiction=jurisdiction,
|
||||
year_from=year_from,
|
||||
year_to=year_to,
|
||||
)
|
||||
pr = await run_search(
|
||||
session,
|
||||
q,
|
||||
@@ -223,6 +251,7 @@ async def search(
|
||||
rewrite_backend=rewrite_backend,
|
||||
corpus_variant=corpus_variant,
|
||||
exact_knn=exact_knn,
|
||||
axis=axis,
|
||||
)
|
||||
except ValueError as e:
|
||||
# _resolve_backend / _resolve_reranker / _resolve_rewrite_backend / _resolve_corpus_variant unknown slug → HTTP 400
|
||||
@@ -313,12 +342,17 @@ async def search(
|
||||
|
||||
debug_obj = _build_search_debug(pr) if debug else None
|
||||
|
||||
# 안전 자료실 C-1 후속 — wrapper decoration (검색 코어 무접촉, ranking 무관)
|
||||
await decorate_version_status(session, pr.results) # 법령 결과에 version_status
|
||||
facets_obj = compute_facets(pr.results) if facets else None
|
||||
|
||||
return SearchResponse(
|
||||
results=pr.results,
|
||||
total=len(pr.results),
|
||||
query=q,
|
||||
mode=pr.mode,
|
||||
debug=debug_obj,
|
||||
facets=facets_obj,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -158,6 +158,17 @@ class Settings(BaseModel):
|
||||
# 업로드 한도 (authoritative policy)
|
||||
upload: UploadConfig = UploadConfig()
|
||||
|
||||
# 생성 LLM 홀드 (2026-06-11): config.yaml pipeline.held_stages 에 든 이름의
|
||||
# 컨슈머/워커는 claim 자체를 하지 않는다 (attempts 미소모, pending 적체 = 의도).
|
||||
# 유효 키 = 큐 stage 명(classify/summarize/deep_summary) + cron/컨슈머 키(digest,
|
||||
# briefing, study_explanation, study_session_analysis, study_memo_card).
|
||||
# 빈 리스트 = 무동작 (기존 동작 그대로).
|
||||
pipeline_held_stages: list[str] = []
|
||||
|
||||
# mlx gate 동시 실행 상한 (2026-06-12, config.yaml pipeline.mlx_gate_concurrency).
|
||||
# 1 = 구 single-inference 동작. 2 = continuous batching 활용 (llm_gate docstring 참조).
|
||||
mlx_gate_concurrency: int = 1
|
||||
|
||||
# PR-MacMini-Derived-Worker-1: study explanation owner = Mac mini
|
||||
# GPU 측은 false 로 설정 (.env), explanation 분기 skip guard 트리거.
|
||||
study_explanation_enabled: bool = True
|
||||
@@ -244,6 +255,21 @@ def load_settings() -> Settings:
|
||||
)
|
||||
)
|
||||
|
||||
pipeline_held_stages: list[str] = []
|
||||
mlx_gate_concurrency = 1
|
||||
if config_path.exists() and raw and "pipeline" in raw:
|
||||
held_raw = (raw.get("pipeline") or {}).get("held_stages") or []
|
||||
# 스칼라(문자열) 오기입 시 char-split 방지 — 단일 항목 리스트로 수용.
|
||||
if not isinstance(held_raw, (list, tuple)):
|
||||
held_raw = [held_raw]
|
||||
pipeline_held_stages = [str(s) for s in held_raw]
|
||||
try:
|
||||
mlx_gate_concurrency = max(
|
||||
1, int((raw.get("pipeline") or {}).get("mlx_gate_concurrency", 1))
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
mlx_gate_concurrency = 1
|
||||
|
||||
taxonomy = raw.get("taxonomy", {}) if config_path.exists() and raw else {}
|
||||
document_types = raw.get("document_types", []) if config_path.exists() and raw else []
|
||||
upload_cfg = (
|
||||
@@ -272,6 +298,8 @@ def load_settings() -> Settings:
|
||||
study_explanation_enabled=study_explanation_enabled,
|
||||
study_card_extract_enabled=study_card_extract_enabled,
|
||||
internal_worker_token=internal_worker_token,
|
||||
pipeline_held_stages=pipeline_held_stages,
|
||||
mlx_gate_concurrency=mlx_gate_concurrency,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -29,16 +29,19 @@ import httpx
|
||||
from ai.client import AIClient
|
||||
from services.llm.backends import (
|
||||
MAC_MINI_DEFAULT,
|
||||
QWEN_MACBOOK,
|
||||
BackendUnavailable,
|
||||
_router_url, # router URL 단일 출처 재사용 (settings → env LLM_ROUTER_URL → MVP default)
|
||||
)
|
||||
from services.search.llm_gate import Priority, acquire_mlx_gate
|
||||
|
||||
# 이드 채팅 mode → router alias 닫힌 매핑 (D-2). 클라는 mode 만 보냄 — claude-cloud/auto 금지.
|
||||
# 2026-06-11 맥북 백지화: deep 도 mac-mini-default (맥미니 Qwen 27B 단일 호스트).
|
||||
# mode 구분은 유지 — deep = ReAct 자동검색 경로(모델이 아니라 동작이 다름).
|
||||
# 게이트는 alias==MAC_MINI_DEFAULT 조건이라 deep 도 자동으로 mlx gate 적용
|
||||
# (llm_gate "예외 없이 gate 획득 필수" invariant 충족 — 구 무게이트는 맥북 예외였음).
|
||||
_CHAT_ALIAS: dict[str, str] = {
|
||||
"daily": MAC_MINI_DEFAULT, # router tier_b → Mac mini :8801 gemma-4-26b
|
||||
"deep": QWEN_MACBOOK, # router named upstream → M5 Max Qwen3.6-27B (무게이트, D-2)
|
||||
"daily": MAC_MINI_DEFAULT, # router tier_b → Mac mini :8801
|
||||
"deep": MAC_MINI_DEFAULT, # 맥북 폐기로 동일 upstream — ReAct 검색 모드 구분만 유지
|
||||
}
|
||||
|
||||
# read 는 per-chunk 적용이라 MacBook wake(24s)+토큰 생성 간격 커버. connect 는 내부 router 라 짧게.
|
||||
@@ -161,10 +164,10 @@ class EidAIClient(AIClient):
|
||||
_rewrite_sse_line 으로 model 치환(mode 어휘)·usage 제거만 하고 프레이밍은 보존.
|
||||
취소/disconnect 시 AsyncExitStack 이 response·client 정리(upstream 닫힘 보장).
|
||||
|
||||
daily(mac-mini-default)는 Mac mini MLX 단일 inference 영구 룰(llm_gate docstring
|
||||
"예외 없이 gate 획득 필수")에 따라 acquire_mlx_gate(FOREGROUND) 안에서 스트리밍 —
|
||||
RouterBackend 의 requires_gate=True 와 동일한 client-side mutex 효과.
|
||||
deep(qwen-macbook)은 별 endpoint 라 무게이트 (D-2, RouterBackend 동형).
|
||||
daily/deep 모두 mac-mini-default(2026-06-11 맥북 백지화) → Mac mini MLX 단일
|
||||
inference 영구 룰(llm_gate docstring "예외 없이 gate 획득 필수")에 따라
|
||||
acquire_mlx_gate(FOREGROUND) 안에서 스트리밍 — 게이트 조건이 alias 기준이라
|
||||
deep 도 자동 적용 (구 무게이트는 맥북 별 endpoint 시절 예외였음).
|
||||
|
||||
중계 전체(업스트림 진입~종료)는 asyncio.timeout(_STREAM_DEADLINE_S) wall-clock
|
||||
deadline 안 — llm_gate 계약 "timeout 은 gate 안쪽" 준수(gate 대기엔 미적용).
|
||||
|
||||
@@ -53,15 +53,18 @@ async def lifespan(app: FastAPI):
|
||||
from workers.dedup_reconcile import run as dedup_reconcile_run
|
||||
from workers.digest_worker import run as global_digest_run
|
||||
from workers.file_watcher import watch_inbox
|
||||
from workers.law_monitor import run as law_monitor_run
|
||||
from workers.mailplus_archive import run as mailplus_run
|
||||
from workers.statute_collector import run as statute_run
|
||||
from workers.news_collector import run as news_collector_run
|
||||
from workers.arxiv_collector import run as arxiv_collector_run
|
||||
from workers.openalex_collector import run as openalex_collector_run
|
||||
from workers.paper_doi_reconcile import run as paper_doi_reconcile_run
|
||||
from workers.fulltext_worker import reconcile_unresolved as fulltext_reconcile_run
|
||||
from workers.kosha_collector import run as kosha_collector_run
|
||||
from workers.csb_collector import run as csb_collector_run
|
||||
from workers.api_standards_collector import run as api_standards_run
|
||||
from workers.ccps_collector import run as ccps_collector_run
|
||||
from workers.queue_consumer import consume_queue, consume_markdown_queue
|
||||
from workers.queue_consumer import consume_queue, consume_fast_queue, consume_markdown_queue
|
||||
from workers.study_queue_consumer import consume_study_queue
|
||||
from workers.study_session_queue_consumer import consume_study_session_queue
|
||||
from workers.study_memo_card_jobs_consumer import consume_study_memo_card_queue
|
||||
@@ -95,6 +98,9 @@ async def lifespan(app: FastAPI):
|
||||
# 대형 PDF split 변환(수십 분)이 메인 consume_queue 를 점유해 전 파이프라인을
|
||||
# stall 시키던 문제 제거. max_instances=1(기본) 으로 동시 marker 변환 2건은 방지.
|
||||
scheduler.add_job(consume_markdown_queue, "interval", minutes=1, id="markdown_consumer")
|
||||
# 2026-06-12 fast-consumer split: embed/chunk(건당 <1s)를 LLM 사이클에서 분리 —
|
||||
# classify(~190s×3)가 사이클을 점유해 벡터 적재가 굶던 구조 캡 해소 (markdown 선례).
|
||||
scheduler.add_job(consume_fast_queue, "interval", minutes=1, id="fast_queue_consumer")
|
||||
scheduler.add_job(watch_inbox, "interval", minutes=5, id="file_watcher")
|
||||
scheduler.add_job(cleanup_orphan_uploads, "interval", minutes=10, id="upload_cleanup")
|
||||
# PR-4: study_questions 자동 임베딩 (status='none/failed/stale' 행을 batch=10 처리).
|
||||
@@ -117,7 +123,9 @@ async def lifespan(app: FastAPI):
|
||||
# safety > law > manual 우선순위로 25건씩. 6720 레거시 → 야간당 ~150건 → 약 45일 소화.
|
||||
scheduler.add_job(tier_backfill_run, "interval", minutes=30, id="tier_backfill")
|
||||
# 일일 스케줄 (KST)
|
||||
scheduler.add_job(law_monitor_run, CronTrigger(hour=7, timezone=KST), id="law_monitor")
|
||||
# statute_collector = 구 law_monitor 대체 (safety-library-1 B-1 PR②) — poll→ingest→
|
||||
# 생애주기 잡(버전 시리즈 승격·supersede·레거시 스윕·repeal) 통째 (R8-B1).
|
||||
scheduler.add_job(statute_run, CronTrigger(hour=7, timezone=KST), id="statute_collector")
|
||||
scheduler.add_job(mailplus_run, CronTrigger(hour=7, timezone=KST), id="mailplus_morning")
|
||||
scheduler.add_job(mailplus_run, CronTrigger(hour=18, timezone=KST), id="mailplus_evening")
|
||||
scheduler.add_job(daily_digest_run, CronTrigger(hour=20, timezone=KST), id="daily_digest")
|
||||
@@ -134,6 +142,9 @@ async def lifespan(app: FastAPI):
|
||||
# plan ds-s1-backend-1 B-4: dedup 컬럼(duplicate_of/duplicate_count) 야간 절대 재계산.
|
||||
# soft-delete 잔여 드리프트 정리(멱등, 드리프트 없으면 no-op). cron 03:30 (다른 잡과 비충돌).
|
||||
scheduler.add_job(dedup_reconcile_run, CronTrigger(hour=3, minute=30, timezone=KST), id="dedup_reconcile")
|
||||
# B-3 PR4: 레거시 paper 행 arXiv DataCite DOI 스탬프(재유입 차단). keyless·in-DB·enqueue 0.
|
||||
# dedup_reconcile(03:30)·fulltext_reconcile(03:40) 와 별 worker·비충돌 슬롯.
|
||||
scheduler.add_job(paper_doi_reconcile_run, CronTrigger(hour=3, minute=50, timezone=KST), id="paper_doi_reconcile")
|
||||
# crawl-24x7 C-2: KOSHA 재해사례 diff + GUIDE 점진 백필 (daily, 새벽 잡들과 비충돌 슬롯).
|
||||
scheduler.add_job(kosha_collector_run, CronTrigger(hour=6, minute=40, timezone=KST), id="kosha_collector")
|
||||
# 사이클 3 C-2 잔여: CSB sitemap lastmod diff (weekly 월, cap 40 + 워터마크 점진 백필).
|
||||
@@ -142,6 +153,12 @@ async def lifespan(app: FastAPI):
|
||||
scheduler.add_job(api_standards_run, CronTrigger(day=5, hour=7, minute=5, timezone=KST), id="api_standards_collector")
|
||||
# 사이클 3 C-2 잔여: CCPS Beacon 월간 PDF (playwright 익명 경유 — WAF 차단 시 health 로 가시화).
|
||||
scheduler.add_job(ccps_collector_run, CronTrigger(day=5, hour=7, minute=20, timezone=KST), id="ccps_collector")
|
||||
# B-3 PR2: arXiv 키워드 필터 수집기 (daily 07:30 KST — statute 07:00 직후 빈 슬롯).
|
||||
# signal-only 초록 색인, per-run cap 으로 임베드 큐 보호. keyless.
|
||||
scheduler.add_job(arxiv_collector_run, CronTrigger(hour=7, minute=30, timezone=KST), id="arxiv_collector")
|
||||
# B-3 PR3: OpenAlex 백본 수집기 (daily 07:45 KST). scaffold-first(키 부재 explicit-skip),
|
||||
# signal-only 초록 색인, per-run cap + cursor watermark. 키=OPENALEX_API_KEY(credentials.env).
|
||||
scheduler.add_job(openalex_collector_run, CronTrigger(hour=7, minute=45, timezone=KST), id="openalex_collector")
|
||||
scheduler.start()
|
||||
|
||||
# Phase 2.1 (async 구조): QueryAnalyzer prewarm.
|
||||
|
||||
@@ -14,6 +14,11 @@ from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from core.database import Base
|
||||
|
||||
# FK("users.id") 해석에 users 테이블 메타데이터 필요 — fastapi 앱은 어차피 전 모델을
|
||||
# import 하지만, CLI 단독 실행(queue_drain 등)은 본 모듈만 끌어와 INSERT 시
|
||||
# "could not find table 'users'" 로 실패했다 (2026-06-12 drain 로그 실측). 명시 import.
|
||||
from models.user import User # noqa: F401
|
||||
|
||||
|
||||
class AnalyzeEvent(Base):
|
||||
__tablename__ = "analyze_events"
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"""documents 테이블 ORM"""
|
||||
|
||||
from datetime import datetime
|
||||
from datetime import date, datetime
|
||||
|
||||
from pgvector.sqlalchemy import Vector
|
||||
from sqlalchemy import BigInteger, Boolean, DateTime, Enum, ForeignKey, Integer, String, Text
|
||||
from sqlalchemy import BigInteger, Boolean, Date, DateTime, Enum, ForeignKey, Integer, String, Text
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
@@ -146,6 +146,16 @@ class Document(Base):
|
||||
# /accept-suggestion 승인 시에만 category / user_tags 반영 (자동 전이 금지)
|
||||
ai_suggestion: Mapped[dict | None] = mapped_column(JSONB)
|
||||
|
||||
# === 안전 자료실 분류 축 (plan safety-library-1, migrations 340~345) ===
|
||||
# 자료유형 — law/paper/book/incident/manual/standard/guide (TEXT+CHECK, enum 아님).
|
||||
# 수집기 ingest 시점 deterministic 부여 (classify-skip 경로 다수 — classify_worker 의존 금지).
|
||||
# AI 라우팅(subject_domain) 매칭 키 사용 금지 (axis separation — category 와 동일 불변식).
|
||||
material_type: Mapped[str | None] = mapped_column(Text)
|
||||
# 관할 — KR/US/EU/JP/GB/INT. law 는 CHECK 로 jurisdiction NOT NULL 구조 강제 (migration 344).
|
||||
jurisdiction: Mapped[str | None] = mapped_column(Text)
|
||||
# 유형별 대표 날짜 — 법령=COALESCE(시행일, 공포일) / 논문=발행일 / 재해=발생일
|
||||
published_date: Mapped[date | None] = mapped_column(Date)
|
||||
|
||||
# PR-B B-1: summary_triage (4B, 상시) / summary_deep (26B, 에스컬레이션) 분할 산출
|
||||
ai_tldr: Mapped[str | None] = mapped_column(Text) # ≤60자 TL;DR
|
||||
ai_bullets: Mapped[list | None] = mapped_column(JSONB) # 3~5개 핵심 bullets
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
"""legal_acts / legal_meta 테이블 ORM — 법령 레지스트리(워치리스트 겸) + 버전 위성
|
||||
|
||||
plan: safety-library-1 (migrations 346~347).
|
||||
- legal_acts = 폴링 순회 대상 목록이 곧 테이블 (news_sources 패턴의 법령판).
|
||||
KOSHA GUIDE(비법령)·KGS Code(watch-폴더 단독 트랙)는 비대상.
|
||||
- legal_meta = 법령 문서 1버전(또는 별표·해석례 1건)당 1행, documents 1:0..1 위성.
|
||||
version_status 전이는 statute_collector 의 일일 잡이 유일한 코드 지점
|
||||
(전 버전 pending 적재 → 잡이 승격·supersede·repeal 을 한 트랜잭션 처리).
|
||||
"""
|
||||
|
||||
from datetime import date, datetime
|
||||
|
||||
from sqlalchemy import BigInteger, Boolean, Date, DateTime, ForeignKey, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from core.database import Base
|
||||
|
||||
|
||||
class LegalAct(Base):
|
||||
__tablename__ = "legal_acts"
|
||||
|
||||
# 'kr-law:{법령ID}' / 'us-cfr:29-1910' 형식. KGS 는 시드 비대상 (R3-M5).
|
||||
family_id: Mapped[str] = mapped_column(Text, primary_key=True)
|
||||
# 어댑터 상수 고정값 — 파싱 결과에서 추론 금지 (코어가 적재 직전 assert)
|
||||
jurisdiction: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
# statute(법률) / decree(시행령) / rule(시행규칙·부령) / admin_rule(고시·예규) / code(법정 위임 상세기준)
|
||||
law_level: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
title: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
title_ko: Mapped[str | None] = mapped_column(Text)
|
||||
# 법률 → 시행령 → 시행규칙 계층
|
||||
parent_family_id: Mapped[str | None] = mapped_column(ForeignKey("legal_acts.family_id"))
|
||||
# 법령ID / CFR part / CELEX / e-Gov law_id 등 소스 고유 식별자
|
||||
native_id: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
# 'law.go.kr' / 'ecfr' / 'cellar' / 'egov_v2' / 'leg_gov_uk'
|
||||
source_api: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
# 시드 26개 전부 true — '우선순위'는 정렬일 뿐 watch 제외 아님 (R3-B1)
|
||||
watch: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
poll_cycle: Mapped[str] = mapped_column(Text, nullable=False, default="daily")
|
||||
# 변경이력 폴링 워터마크 — 파싱 검증 통과 후에만 영속
|
||||
watermark: Mapped[str | None] = mapped_column(Text)
|
||||
# 어댑터는 폐지 감지 마킹만, repealed 전이는 일일 잡 (R3-M3)
|
||||
repeal_detected_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=datetime.now
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=datetime.now, onupdate=datetime.now
|
||||
)
|
||||
|
||||
|
||||
class LegalMeta(Base):
|
||||
__tablename__ = "legal_meta"
|
||||
__table_args__ = (
|
||||
# 버전 dedup 구조 강제 — annex 는 version_key='MST|별표N' 합성형 (R3-M4)
|
||||
UniqueConstraint("family_id", "law_doc_kind", "version_key", name="uq_legal_meta_version"),
|
||||
)
|
||||
|
||||
document_id: Mapped[int] = mapped_column(
|
||||
BigInteger, ForeignKey("documents.id", ondelete="CASCADE"), primary_key=True
|
||||
)
|
||||
family_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("legal_acts.family_id"), nullable=False
|
||||
)
|
||||
# primary(본문) / annex(별표·서식) / interpretation(해석례)
|
||||
law_doc_kind: Mapped[str] = mapped_column(Text, nullable=False, default="primary")
|
||||
version_key: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
promulgation_date: Mapped[date | None] = mapped_column(Date)
|
||||
effective_date: Mapped[date | None] = mapped_column(Date)
|
||||
# pending → current → superseded / repealed. 전이는 일일 잡 단일 지점, KST 기준.
|
||||
version_status: Mapped[str] = mapped_column(Text, nullable=False, default="pending")
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=datetime.now
|
||||
)
|
||||
@@ -53,3 +53,12 @@ class NewsSource(Base):
|
||||
name="source_channel"),
|
||||
default="news",
|
||||
)
|
||||
|
||||
# ── 안전 자료실 분류 축 (plan safety-library-1 A-2, migrations 352~355) ──
|
||||
# 자료유형 기본값 — documents.material_type 으로 ingest 시점 전파 (NULL=비대상).
|
||||
# jurisdiction 은 별도 컬럼 없이 country 전파, 단 paper 는 코드에서 NULL 강제.
|
||||
material_type: Mapped[str | None] = mapped_column(Text)
|
||||
# extract_meta.license 주입용 — kogl/ogl/public_domain/proprietary/unknown.
|
||||
# 미확정 = 보수적(unknown + redistribute=false), 근거 확보 시 완화.
|
||||
license_scheme: Mapped[str | None] = mapped_column(Text)
|
||||
license_redistribute: Mapped[bool | None] = mapped_column(Boolean)
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
"""off-queue 관리 스크립트(백필 등) 진행 가시화 — background_jobs (migration 357).
|
||||
|
||||
processing_queue 는 파이프라인 stage 전용이라 hier_overnight_backfill /
|
||||
section_summary_pilot 같은 스크립트 작업은 대시보드 보드에 안 잡힌다. 이 모듈로
|
||||
스크립트가 진행상황을 남기면 queue_overview 가 "백그라운드 작업" 패널로 노출한다.
|
||||
|
||||
설계 불변식:
|
||||
- **자율 트랜잭션**: 각 기록은 engine.begin() 짧은 트랜잭션으로 즉시 commit한다.
|
||||
스크립트 본 작업은 별도 세션(긴 트랜잭션)이라, 같이 묶으면 commit 전까지 안 보여
|
||||
실시간 가시화가 깨진다. 그래서 전용 connection 으로 독립 commit.
|
||||
- **best-effort**: 관측 기록 실패가 본 작업을 깨면 안 된다 — 모든 함수 try/except,
|
||||
실패 시 warning 로그만. job_id=None 이면 조용히 no-op (start 실패해도 이어서 동작).
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def start_job(
|
||||
engine: AsyncEngine, kind: str, label: str | None = None, total: int | None = None
|
||||
) -> int | None:
|
||||
"""작업 시작 기록 → background_jobs.id (실패 시 None — 호출측은 그대로 진행)."""
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
row = (
|
||||
await conn.execute(
|
||||
text(
|
||||
"INSERT INTO background_jobs (kind, label, total) "
|
||||
"VALUES (:k, :l, :t) RETURNING id"
|
||||
),
|
||||
{"k": kind, "l": label, "t": total},
|
||||
)
|
||||
).first()
|
||||
return int(row[0]) if row else None
|
||||
except Exception as exc: # noqa: BLE001 — 관측은 부가, 본작업 보호
|
||||
logger.warning(f"[background_jobs] start 실패(무시): {type(exc).__name__}: {exc}")
|
||||
return None
|
||||
|
||||
|
||||
async def heartbeat(
|
||||
engine: AsyncEngine,
|
||||
job_id: int | None,
|
||||
*,
|
||||
processed: int | None = None,
|
||||
total: int | None = None,
|
||||
detail: dict | None = None,
|
||||
) -> None:
|
||||
"""진행 갱신(processed/total/detail). job_id=None 또는 실패 시 no-op."""
|
||||
if job_id is None:
|
||||
return
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(
|
||||
text(
|
||||
"UPDATE background_jobs SET "
|
||||
"processed = COALESCE(:p, processed), "
|
||||
"total = COALESCE(:t, total), "
|
||||
"detail = COALESCE(CAST(:d AS jsonb), detail), "
|
||||
"updated_at = now() WHERE id = :id"
|
||||
),
|
||||
{
|
||||
"id": job_id,
|
||||
"p": processed,
|
||||
"t": total,
|
||||
"d": json.dumps(detail, ensure_ascii=False) if detail is not None else None,
|
||||
},
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning(f"[background_jobs] heartbeat 실패(무시): {type(exc).__name__}: {exc}")
|
||||
|
||||
|
||||
async def finish_job(
|
||||
engine: AsyncEngine, job_id: int | None, *, state: str = "done", error: str | None = None
|
||||
) -> None:
|
||||
"""종료 기록(done/failed). job_id=None 또는 실패 시 no-op."""
|
||||
if job_id is None:
|
||||
return
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(
|
||||
text(
|
||||
"UPDATE background_jobs SET state = :s, error = :e, "
|
||||
"finished_at = now(), updated_at = now() WHERE id = :id"
|
||||
),
|
||||
{"id": job_id, "s": state, "e": (error or None)},
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning(f"[background_jobs] finish 실패(무시): {type(exc).__name__}: {exc}")
|
||||
@@ -15,11 +15,12 @@ from sqlalchemy import text
|
||||
|
||||
from core.database import async_session
|
||||
from core.utils import setup_logger
|
||||
from services.search.license_filter import restricted_exclude_sql
|
||||
|
||||
logger = setup_logger("briefing_loader")
|
||||
|
||||
|
||||
_NEWS_WINDOW_SQL = text("""
|
||||
_NEWS_WINDOW_SQL = text(f"""
|
||||
SELECT
|
||||
d.id,
|
||||
d.title,
|
||||
@@ -41,6 +42,8 @@ _NEWS_WINDOW_SQL = text("""
|
||||
AND d.created_at < :window_end
|
||||
AND d.embedding IS NOT NULL
|
||||
AND d.ai_summary IS NOT NULL
|
||||
-- 안전 자료실 B-4: licensed_restricted 발행 차단 (digest 와 동일 공유 술어, 경로 일관성)
|
||||
AND {restricted_exclude_sql("d")}
|
||||
""")
|
||||
|
||||
|
||||
@@ -49,7 +52,7 @@ _SOURCE_COUNTRY_SQL = text("""
|
||||
""")
|
||||
|
||||
|
||||
_HISTORICAL_CANDIDATES_SQL = text("""
|
||||
_HISTORICAL_CANDIDATES_SQL = text(f"""
|
||||
SELECT
|
||||
d.id,
|
||||
d.title,
|
||||
@@ -63,6 +66,8 @@ _HISTORICAL_CANDIDATES_SQL = text("""
|
||||
AND d.created_at < :hist_end
|
||||
AND d.embedding IS NOT NULL
|
||||
AND d.ai_summary IS NOT NULL
|
||||
-- 안전 자료실 B-4: licensed_restricted 발행 차단 (공유 술어)
|
||||
AND {restricted_exclude_sql("d")}
|
||||
""")
|
||||
|
||||
|
||||
|
||||
@@ -15,11 +15,12 @@ from sqlalchemy import text
|
||||
|
||||
from core.database import async_session
|
||||
from core.utils import setup_logger
|
||||
from services.search.license_filter import restricted_exclude_sql
|
||||
|
||||
logger = setup_logger("digest_loader")
|
||||
|
||||
|
||||
_NEWS_WINDOW_SQL = text("""
|
||||
_NEWS_WINDOW_SQL = text(f"""
|
||||
SELECT
|
||||
d.id,
|
||||
d.title,
|
||||
@@ -41,6 +42,9 @@ _NEWS_WINDOW_SQL = text("""
|
||||
AND d.created_at < :window_end
|
||||
AND d.embedding IS NOT NULL
|
||||
AND d.ai_summary IS NOT NULL
|
||||
-- 안전 자료실 B-4: licensed_restricted 발행 차단 (모든 경로 공유 술어 = license_filter).
|
||||
-- news 채널엔 현재 restricted 부재 = 방어적 게이트(미래 유료 news 소스 대비, 경로 누락 방지).
|
||||
AND {restricted_exclude_sql("d")}
|
||||
""")
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
"""B-3 논문 수집 트랙 공유 모듈 (plan safety-library-b3-1).
|
||||
|
||||
doi — DOI 정규화·dedup 키·2-Document(holder/parent_doi child) extract_meta 계약 (순수).
|
||||
holder — 서지 holder 공유 dedup 조회 (DB).
|
||||
"""
|
||||
@@ -0,0 +1,141 @@
|
||||
"""B-3 논문 DOI 코어 — 정규화·dedup 키·2-Document(서지 holder / parent_doi child) 계약.
|
||||
|
||||
plan safety-library-b3-1 PR1 (keyless·마이그 0).
|
||||
|
||||
핵심 계약(모든 논문 수집기·reconcile·구매 PDF 스탬프가 공유):
|
||||
- DOI 정규화는 이 단일 함수(normalize_doi) 경유 — **저장=조회 동일 함수**
|
||||
(migration 351 주석 명시, news_collector._normalize_url 의 store=lookup 불변식 선례).
|
||||
같은 논문이 다른 표기(https://doi.org/ vs doi: vs 대문자)로 들어와도 한 holder 로 붕괴.
|
||||
- dedup 키 = lower(extract_meta #>> '{paper,doi}') — 라이브 partial-unique 인덱스
|
||||
uq_documents_paper_doi(WHERE material_type='paper' AND ... IS NOT NULL)가 강제.
|
||||
- 2-Document(R2-B1): paper.doi 는 **서지 Document 단일 보유**. OA/구매 전문 PDF 는
|
||||
doi 없이 paper.parent_doi 로 holder 링크(NULL doi 라 인덱스 밖 → 다중행 무충돌).
|
||||
holder 와 child 는 doi/parent_doi 를 **상호 배타**로 가진다.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import re
|
||||
|
||||
# 소문자화 후 비교하므로 전부 소문자 prefix. 긴 것부터(dx.doi.org 가 doi.org 보다 먼저).
|
||||
_DOI_PREFIXES = (
|
||||
"https://dx.doi.org/",
|
||||
"http://dx.doi.org/",
|
||||
"https://doi.org/",
|
||||
"http://doi.org/",
|
||||
"dx.doi.org/",
|
||||
"doi.org/",
|
||||
"doi:",
|
||||
)
|
||||
|
||||
|
||||
def normalize_doi(raw: str | None) -> str | None:
|
||||
"""DOI 정규화 — 소문자 + URL/doi: prefix 제거 + 양끝 공백·잡음 제거. 단일 함수(저장=조회).
|
||||
|
||||
유효 DOI(10. 으로 시작)가 아니면 None. 저장측·조회측·dedup 키 생성이 모두 이 함수를
|
||||
공유해야 dedup 이 성립한다(raw 를 그대로 저장하고 정규화로 조회하면 영구 미스).
|
||||
"""
|
||||
if not raw:
|
||||
return None
|
||||
s = raw.strip().lower()
|
||||
for p in _DOI_PREFIXES:
|
||||
if s.startswith(p):
|
||||
s = s[len(p):]
|
||||
break
|
||||
s = s.strip()
|
||||
# 인용문 끝 잡음(마침표/쉼표/세미콜론)만 제거. 괄호 '()' 는 DOI 일부일 수 있어 보존한다
|
||||
# (예: 10.1016/s0010-8650(00)80003-2) — 과삭제는 서로 다른 논문을 한 holder 로 병합하는
|
||||
# 데이터 손상이라 near-dup(과소삭제)보다 위험. API 소스(OpenAlex/arXiv)의 doi 는 이미 깨끗.
|
||||
s = s.rstrip(".,;")
|
||||
if not s.startswith("10."):
|
||||
return None
|
||||
return s
|
||||
|
||||
|
||||
# arXiv id: 신형 'YYMM.NNNNN'(+vN) 또는 구형 'archive(.SUBJ)/NNNNNNN'. 'arXiv:' 접두 흡수.
|
||||
_ARXIV_ID_RE = re.compile(
|
||||
r"arxiv:\s*([a-z\-]+(?:\.[a-z]{2})?/\d{7}|\d{4}\.\d{4,5})(v\d+)?", re.IGNORECASE
|
||||
)
|
||||
|
||||
|
||||
def parse_arxiv_id(text: str | None) -> str | None:
|
||||
"""본문/제목에서 arXiv id(versionless) 추출. 없으면 None. 레거시 reconcile 의 입력."""
|
||||
if not text:
|
||||
return None
|
||||
m = _ARXIV_ID_RE.search(text)
|
||||
return m.group(1) if m else None
|
||||
|
||||
|
||||
def arxiv_doi(arxiv_id: str | None) -> str | None:
|
||||
"""arXiv DataCite DOI = 10.48550/arxiv.{id} (정규화). 저널 DOI 없는 프리프린트의 canonical
|
||||
paper.doi 통일 키 — OpenAlex 가 프리프린트에 동일 DOI 부여(실측 확인). 모든 수집기·reconcile 가
|
||||
같은 함수로 같은 DOI 를 써야 교차소스 dedup 이 성립."""
|
||||
if not arxiv_id:
|
||||
return None
|
||||
return normalize_doi(f"10.48550/arXiv.{arxiv_id}")
|
||||
|
||||
|
||||
_DOI_IN_TEXT_RE = re.compile(r"10\.\d{4,9}/[^\s\"'<>]+", re.IGNORECASE)
|
||||
|
||||
|
||||
def parse_doi_from_text(text: str | None) -> str | None:
|
||||
"""본문에서 첫 DOI 추출(정규화). 구매 PDF 의 paper.parent_doi 링크용(PDF 구조 무관 — 전체 스캔).
|
||||
DOI 끝 구두점은 normalize_doi 가 정리. 없으면 None."""
|
||||
if not text:
|
||||
return None
|
||||
m = _DOI_IN_TEXT_RE.search(text)
|
||||
return normalize_doi(m.group(0)) if m else None
|
||||
|
||||
|
||||
def paper_doi_hash(normalized_doi: str) -> str:
|
||||
"""서지 holder 의 Document.file_hash — sha256('paper|{doi}')[:32].
|
||||
|
||||
statute 의 'statute|{jur}|{native_id}|{version_key}' 다중부 키 선례를 따른다.
|
||||
인자는 normalize_doi() 출력(정규화 완료값)이어야 한다 — raw 를 넣으면 dedup 이 깨진다.
|
||||
"""
|
||||
if not normalized_doi:
|
||||
raise ValueError("paper_doi_hash 는 정규화된 DOI 필요 (normalize_doi 먼저)")
|
||||
return hashlib.sha256(f"paper|{normalized_doi}".encode()).hexdigest()[:32]
|
||||
|
||||
|
||||
def read_paper_doi(extract_meta: dict | None) -> str | None:
|
||||
"""holder 의 정규화 DOI 읽기 — 인덱스 식 lower(extract_meta #>> '{paper,doi}') 의 조회측 거울.
|
||||
|
||||
방어적 재정규화(이미 정규화돼 저장되지만 레거시·외부 주입 대비).
|
||||
"""
|
||||
if not extract_meta:
|
||||
return None
|
||||
paper = extract_meta.get("paper")
|
||||
if not isinstance(paper, dict):
|
||||
return None
|
||||
return normalize_doi(paper.get("doi"))
|
||||
|
||||
|
||||
def with_paper_doi(extract_meta: dict | None, normalized_doi: str) -> dict:
|
||||
"""서지 holder 의 extract_meta 에 paper.doi 주입 (merge-safe, 타 키 보존).
|
||||
|
||||
holder 전용 — parent_doi 는 제거(상호 배타). 반환값은 새 dict(입력 비변경).
|
||||
"""
|
||||
if not normalized_doi:
|
||||
raise ValueError("with_paper_doi 는 정규화된 DOI 필요")
|
||||
meta = dict(extract_meta or {})
|
||||
paper = dict(meta.get("paper") or {})
|
||||
paper["doi"] = normalized_doi
|
||||
paper.pop("parent_doi", None)
|
||||
meta["paper"] = paper
|
||||
return meta
|
||||
|
||||
|
||||
def with_parent_doi(extract_meta: dict | None, parent_normalized_doi: str) -> dict:
|
||||
"""child(OA/구매 전문 PDF)의 extract_meta 에 paper.parent_doi 주입 (merge-safe, 타 키 보존).
|
||||
|
||||
child 는 paper.doi 를 갖지 않는다(NULL → partial-unique 인덱스 밖, 2-Document 무충돌).
|
||||
반환값은 새 dict(입력 비변경).
|
||||
"""
|
||||
if not parent_normalized_doi:
|
||||
raise ValueError("with_parent_doi 는 정규화된 DOI 필요")
|
||||
meta = dict(extract_meta or {})
|
||||
paper = dict(meta.get("paper") or {})
|
||||
paper["parent_doi"] = parent_normalized_doi
|
||||
paper.pop("doi", None)
|
||||
meta["paper"] = paper
|
||||
return meta
|
||||
@@ -0,0 +1,38 @@
|
||||
"""B-3 논문 서지 holder 공유 dedup 조회.
|
||||
|
||||
모든 논문 수집기(OpenAlex/arXiv/KoreaScience/J-STAGE)·reconcile·구매 PDF 스탬프가
|
||||
ingest 전 이 함수로 holder 존재를 확인한다(있으면 skip 또는 child 링크).
|
||||
|
||||
- 조회 키 = lower(extract_meta #>> '{paper,doi}') == normalize_doi(...) — 라이브 partial-unique
|
||||
인덱스 uq_documents_paper_doi 와 동일 식(인덱스 사용).
|
||||
- .scalars().first() — 교차게시·다중 landing-page 로 2행 이상 매칭 시 MultipleResultsFound
|
||||
raise 방지(scalar_one_or_none 금지, 2026-06 BBC 수집 중단 선례 / news_collector 동일 규율).
|
||||
- 서지 holder Document 의 **생성**은 각 수집기/스탬프 경로가 소유한다(초록 signal 문서 vs 구매
|
||||
최소 holder 로 shape 가 다름). 이 모듈은 dedup 조회만 공유한다.
|
||||
|
||||
DB 조회라 본 모듈은 PR2(arXiv 실수집)에서 라이브 검증한다 — PR1 단위 테스트 대상은 doi.py(순수).
|
||||
"""
|
||||
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from models.document import Document
|
||||
from services.papers.doi import normalize_doi
|
||||
|
||||
# 인덱스 식과 동일: lower(extract_meta #>> '{paper,doi}')
|
||||
_DOI_EXPR = func.lower(Document.extract_meta[("paper", "doi")].astext)
|
||||
|
||||
|
||||
async def find_paper_holder(session, raw_or_normalized_doi):
|
||||
"""정규화 DOI 로 서지 holder Document 조회. 없으면 None.
|
||||
|
||||
인자는 raw 든 정규화든 받아 normalize_doi 로 통일(저장=조회 동일 함수 보장).
|
||||
"""
|
||||
doi = normalize_doi(raw_or_normalized_doi)
|
||||
if not doi:
|
||||
return None
|
||||
result = await session.execute(
|
||||
select(Document)
|
||||
.where(Document.material_type == "paper", _DOI_EXPR == doi)
|
||||
.limit(1)
|
||||
)
|
||||
return result.scalars().first()
|
||||
@@ -22,7 +22,7 @@ from datetime import datetime, timedelta
|
||||
from posixpath import basename
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy import bindparam, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from core.config import settings
|
||||
@@ -213,6 +213,16 @@ def build_summarize_eta(stage_stats: dict[str, dict]) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def build_summarize_by_machine(summarize_split: dict[str, dict]) -> dict:
|
||||
"""summarize 머신별 완료 실적 분담 (macmini vs macbook) — 보드 레인의
|
||||
오프로드 가시화용. rows_to_summarize_split 이 이미 만든 값을 응답 형태로
|
||||
투영(done_1h/done_today 만, done_15m 은 내부 state 판정 전용이라 제외)."""
|
||||
def m(key: str) -> dict:
|
||||
s = summarize_split.get(key, {})
|
||||
return {"done_1h": int(s.get("done_1h", 0)), "done_today": int(s.get("done_today", 0))}
|
||||
return {"macmini": m("macmini"), "macbook": m("macbook")}
|
||||
|
||||
|
||||
def build_trend(
|
||||
inflow_buckets: dict[str, int],
|
||||
done_buckets: dict[str, int],
|
||||
@@ -258,6 +268,8 @@ def build_stages(stage_stats: dict[str, dict], now=None) -> list[dict]:
|
||||
"pending": st["pending"],
|
||||
"processing": st["processing"],
|
||||
"failed": st["failed"],
|
||||
"done_1h": st["done_1h"],
|
||||
"created_1h": st["created_1h"],
|
||||
"done_today": st["done_today"],
|
||||
"oldest_pending_age_sec": age,
|
||||
})
|
||||
@@ -290,6 +302,7 @@ def compose_overview(
|
||||
),
|
||||
"stages": build_stages(stage_stats),
|
||||
"summarize_eta": build_summarize_eta(stage_stats),
|
||||
"summarize_by_machine": build_summarize_by_machine(summarize_split),
|
||||
"trend_24h": build_trend(inflow_buckets, done_buckets, now_kst),
|
||||
"totals": build_totals(stage_stats),
|
||||
}
|
||||
@@ -399,7 +412,7 @@ async def build_overview(session: AsyncSession) -> dict:
|
||||
for row in current_result
|
||||
]
|
||||
|
||||
return compose_overview(
|
||||
result = compose_overview(
|
||||
rows_to_stage_stats(stage_rows),
|
||||
rows_to_summarize_split(split_rows),
|
||||
{row[0]: int(row[1]) for row in inflow_rows},
|
||||
@@ -408,3 +421,142 @@ async def build_overview(session: AsyncSession) -> dict:
|
||||
deep_enabled=deep_enabled,
|
||||
now_kst=now_kst,
|
||||
)
|
||||
# 큐 밖 관리 스크립트(백필 등) = background_jobs (migration 357). 테이블 부재 시 graceful([]).
|
||||
result["background_jobs"] = await _fetch_background_jobs(session)
|
||||
return result
|
||||
|
||||
|
||||
_BACKGROUND_JOBS_SQL = """
|
||||
SELECT id, kind, label, state, processed, total,
|
||||
EXTRACT(EPOCH FROM (now() - started_at))::int AS elapsed_sec,
|
||||
(state = 'running' AND updated_at < now() - interval '5 minutes') AS stale,
|
||||
error
|
||||
FROM background_jobs
|
||||
WHERE state = 'running' OR finished_at > now() - interval '6 hours'
|
||||
ORDER BY (state = 'running') DESC, started_at DESC
|
||||
LIMIT 20
|
||||
"""
|
||||
|
||||
|
||||
async def _fetch_background_jobs(session: AsyncSession) -> list[dict]:
|
||||
"""running + 최근 6h 완료 background_jobs. 테이블 없거나 오류면 [] (보드 무영향).
|
||||
|
||||
요청 세션과 **별도 connection**으로 조회한다 — 테이블 부재(마이그 357 미적용 등) 시
|
||||
SELECT 실패가 요청 세션의 트랜잭션을 오염시키지 않도록 물리적으로 분리(실패 시 그
|
||||
임시 connection만 폐기). 관측은 부가 기능이라 보드 본체를 절대 깨면 안 된다.
|
||||
"""
|
||||
try:
|
||||
async with session.bind.connect() as conn: # 풀에서 독립 connection
|
||||
rows = (await conn.execute(text(_BACKGROUND_JOBS_SQL))).mappings().all()
|
||||
except Exception: # noqa: BLE001 — 관측 부가, 보드 본체 보호
|
||||
return []
|
||||
return [
|
||||
{
|
||||
"id": r["id"], "kind": r["kind"], "label": r["label"], "state": r["state"],
|
||||
"processed": int(r["processed"] or 0), "total": r["total"],
|
||||
"elapsed_sec": int(r["elapsed_sec"] or 0), "stale": bool(r["stale"]),
|
||||
"error": r["error"],
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
# ─── 실패 처리 (plan ds-board-engines-1) ─────────────────────────────────────
|
||||
# 실패 = 자동 재시도(max_attempts=3) 소진 후 영구 정지 상태. 여기 함수들은
|
||||
# 사용자 명시 조치 전용 — 자동 호출 경로 없음 (보드 실패 드로어가 유일 호출자).
|
||||
|
||||
# 실패 행은 completed_at 이 비어 있을 수 있어(소비자 실패 경로가 미기록)
|
||||
# started_at 을 시각 fallback 으로 쓴다.
|
||||
_FAILED_LIST_SQL = """
|
||||
SELECT q.id, q.stage, q.document_id, q.attempts, q.max_attempts,
|
||||
q.error_message,
|
||||
COALESCE(q.completed_at, q.started_at) AS failed_at,
|
||||
d.title, d.original_filename, d.file_path
|
||||
FROM processing_queue q
|
||||
JOIN documents d ON d.id = q.document_id
|
||||
WHERE q.status = 'failed'
|
||||
ORDER BY q.stage, COALESCE(q.completed_at, q.started_at) DESC NULLS LAST
|
||||
LIMIT 300
|
||||
"""
|
||||
|
||||
# 재시도: failed → pending (attempts 리셋 = 자동 재시도 3회 새로 부여).
|
||||
# error_message 는 감사용으로 보존 — 성공 시 완료 행에 남아도 무해.
|
||||
# uq_queue_active((doc,stage) pending/processing 부분 유니크)와 충돌하는 행 —
|
||||
# 같은 문서·단계가 이미 재enqueue 된 경우 — 는 건드리지 않고 건수만 보고.
|
||||
_RETRY_SQL = """
|
||||
UPDATE processing_queue q
|
||||
SET status = 'pending', attempts = 0,
|
||||
started_at = NULL, completed_at = NULL
|
||||
WHERE q.id IN :ids
|
||||
AND q.status = 'failed'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM processing_queue p
|
||||
WHERE p.document_id = q.document_id
|
||||
AND p.stage = q.stage
|
||||
AND p.status IN ('pending', 'processing')
|
||||
AND p.id <> q.id
|
||||
)
|
||||
RETURNING q.id
|
||||
"""
|
||||
|
||||
# 건너뛰기: failed → completed + payload 마킹 (감사 추적).
|
||||
# enqueue_next_stage 는 의도적으로 호출하지 않는다 — 실패 문서(빈 텍스트 등)가
|
||||
# 하류 단계로 흘러가는 것 방지. 후속 단계가 필요하면 재시도가 정상 경로.
|
||||
_SKIP_SQL = """
|
||||
UPDATE processing_queue
|
||||
SET status = 'completed', completed_at = NOW(),
|
||||
payload = COALESCE(payload, '{}'::jsonb)
|
||||
|| jsonb_build_object('skipped_by_user', true,
|
||||
'skipped_at', NOW()::text)
|
||||
WHERE id IN :ids AND status = 'failed'
|
||||
RETURNING id
|
||||
"""
|
||||
|
||||
|
||||
async def fetch_failed_items(session: AsyncSession) -> list[dict]:
|
||||
"""영구 실패 행 목록 (문서 제목 포함, 최대 300건)."""
|
||||
rows = (await session.execute(text(_FAILED_LIST_SQL))).all()
|
||||
return [
|
||||
{
|
||||
"id": r[0],
|
||||
"stage": r[1],
|
||||
"document_id": r[2],
|
||||
"attempts": int(r[3] or 0),
|
||||
"max_attempts": int(r[4] or 0),
|
||||
"error_message": r[5],
|
||||
"failed_at": r[6],
|
||||
"title": display_title({
|
||||
"document_id": r[2],
|
||||
"title": r[7],
|
||||
"original_filename": r[8],
|
||||
"file_path": r[9],
|
||||
}),
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
async def retry_failed(session: AsyncSession, ids: list[int]) -> dict:
|
||||
"""failed → pending 복귀. not_retried = active 충돌 + 이미 failed 아님."""
|
||||
unique_ids = list(set(ids))
|
||||
stmt = text(_RETRY_SQL).bindparams(bindparam("ids", expanding=True))
|
||||
retried = (await session.execute(stmt, {"ids": unique_ids})).all()
|
||||
await session.commit()
|
||||
return {
|
||||
"requested": len(unique_ids),
|
||||
"retried": len(retried),
|
||||
"not_retried": len(unique_ids) - len(retried),
|
||||
}
|
||||
|
||||
|
||||
async def skip_failed(session: AsyncSession, ids: list[int]) -> dict:
|
||||
"""failed → completed(건너뛰기 마킹). 후속 단계 연쇄 없음."""
|
||||
unique_ids = list(set(ids))
|
||||
stmt = text(_SKIP_SQL).bindparams(bindparam("ids", expanding=True))
|
||||
skipped = (await session.execute(stmt, {"ids": unique_ids})).all()
|
||||
await session.commit()
|
||||
return {
|
||||
"requested": len(unique_ids),
|
||||
"skipped": len(skipped),
|
||||
"not_skipped": len(unique_ids) - len(skipped),
|
||||
}
|
||||
|
||||
@@ -72,6 +72,10 @@ class LegacyWeightedSum(FusionStrategy):
|
||||
score=existing.score + r.score * 0.5,
|
||||
snippet=existing.snippet,
|
||||
match_reason=f"{existing.match_reason}+vector",
|
||||
# C-1: 분류 축 메타 전파 (재구성 시 누락 = D-1 유형 표시 None)
|
||||
material_type=existing.material_type,
|
||||
jurisdiction=existing.jurisdiction,
|
||||
published_date=existing.published_date,
|
||||
)
|
||||
elif r.score > 0.3:
|
||||
merged[r.id] = r
|
||||
@@ -128,6 +132,10 @@ class RRFOnly(FusionStrategy):
|
||||
score=rrf_score,
|
||||
snippet=base.snippet,
|
||||
match_reason="+".join(reasons),
|
||||
# C-1: 분류 축 메타 전파 (재구성 시 누락 = D-1 유형 표시 None)
|
||||
material_type=base.material_type,
|
||||
jurisdiction=base.jurisdiction,
|
||||
published_date=base.published_date,
|
||||
)
|
||||
)
|
||||
return merged[:limit]
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
"""안전 자료실 B-4 — licensed_restricted 단일 술어 (a안 U-2①, 모든 경로 공유 정의).
|
||||
|
||||
색인은 허용하되 restricted=true(구매 전자책·유료자료)의 verbatim span 이 RAG 증거·발행물
|
||||
(검색/ask·digest·morning_briefing·study 풀이)에 들어가는 모든 경로를 구조적으로 차단.
|
||||
경로마다 술어를 복붙하지 않고 이 한 정의를 공유 — 가드 누락/드리프트 방지
|
||||
([[feedback_structural_integrity_over_path_discipline]]).
|
||||
개인 파일 열람(GET /documents/{id}?download)은 a안상 허용 = 미적용.
|
||||
|
||||
두 표현(raw SQL / ORM)은 의미 동일: restricted 부재·false·extract_meta NULL = COALESCE 로
|
||||
미제외(redistribute=false 여도 restricted 부재면 미제외 — redistribute≠restricted 가 핵심).
|
||||
"""
|
||||
|
||||
|
||||
def restricted_exclude_sql(alias: str = "") -> str:
|
||||
"""raw text() 쿼리용 bare 술어('AND' 미포함). alias='' = 컬럼 직접 참조."""
|
||||
p = (alias + ".") if alias else ""
|
||||
return f"COALESCE({p}extract_meta -> 'license' ->> 'restricted', 'false') <> 'true'"
|
||||
|
||||
|
||||
def restricted_exclude_orm():
|
||||
"""SQLAlchemy ORM .where() 절 — restricted_exclude_sql 과 동일 의미(JSONB extract_meta)."""
|
||||
from sqlalchemy import func
|
||||
|
||||
from models.document import Document
|
||||
|
||||
return func.coalesce(
|
||||
Document.extract_meta["license"]["restricted"].astext, "false"
|
||||
) != "true"
|
||||
@@ -26,8 +26,11 @@ PR-MacBook-RAG-Backend-1 부터 `services.llm.QwenMacBookBackend` 는 별 endpoi
|
||||
- **fallback(Claude Sonnet 4 API) 경로는 gate 제외**. PR #20 이후 fallback = Claude API. 단 현재
|
||||
구현상 `AIClient._call_chat` 내부에서 primary→fallback 전환이 일어나므로
|
||||
fallback도 gate 점유 상태로 실행된다. 허용 가능(fallback 빈도 낮음).
|
||||
- **MLX concurrency는 `MLX_CONCURRENCY = 1` 고정**. 모델이 바뀌어도 single-
|
||||
inference 특성이 깨지지 않는 한 이 값을 올리지 말 것.
|
||||
- ~~**MLX concurrency는 `MLX_CONCURRENCY = 1` 고정**~~ → **2026-06-12 개정**:
|
||||
구 룰의 전제(서버 = single-inference)가 소멸 — 현 mlx_vlm server 는 continuous
|
||||
batching 으로 동시 스트림 흡수(실측). 상한은 config `pipeline.mlx_gate_concurrency`
|
||||
(기본 1, 운영 2). **게이트 자체(상한+우선순위 큐)는 영구 유지** — thundering herd
|
||||
(23 concurrent → 22 timeout 사고) 방지는 계속 이 상한이 담당. 무제한 금지.
|
||||
|
||||
## 우선순위 정책 (B-1, 2026-05-17)
|
||||
|
||||
@@ -80,8 +83,22 @@ from core.utils import setup_logger
|
||||
|
||||
logger = setup_logger("llm_gate")
|
||||
|
||||
# MLX primary는 single-inference → 1
|
||||
MLX_CONCURRENCY = 1
|
||||
|
||||
def _capacity() -> int:
|
||||
"""게이트 동시 실행 상한 — config.yaml `pipeline.mlx_gate_concurrency` (기본 1).
|
||||
|
||||
2026-06-12 일반화: "MLX_CONCURRENCY = 1 고정" 영구 룰의 전제(구 서버 = single-
|
||||
inference, 23 concurrent → 22 timeout 실측)가 소멸 — 현 mlx_vlm server 는
|
||||
continuous batching 으로 동시 스트림을 흡수(2026-06-11 밤 6~8 concurrent 실측
|
||||
정상). 게이트 자체(상한 + 우선순위)는 유지하고 상한만 config 로 — thundering
|
||||
herd 재발 방지는 이 상한이 계속 담당한다. 런타임 매 acquire 시 조회라
|
||||
config 변경 + 프로세스 재기동으로 반영, 테스트는 settings monkeypatch.
|
||||
"""
|
||||
from core.config import settings
|
||||
try:
|
||||
return max(1, int(getattr(settings, "mlx_gate_concurrency", 1)))
|
||||
except (TypeError, ValueError):
|
||||
return 1
|
||||
|
||||
# Background waiter wait_ms 가 이 값 초과 시 WARN (starvation 신호, aging mitigation 은 Phase 2)
|
||||
STARVATION_WARN_MS = 300_000 # 5 min
|
||||
@@ -101,7 +118,7 @@ DEFAULT_PRIORITY: Priority = Priority.BACKGROUND
|
||||
# Tuple format: (priority: int, seq: int, future: asyncio.Future, enqueue_ts: float)
|
||||
_waiters: list[tuple[int, int, asyncio.Future, float]] = []
|
||||
_seq = itertools.count()
|
||||
_inflight: bool = False
|
||||
_inflight_n: int = 0 # 동시 실행 수 (구 bool — capacity 일반화로 카운터)
|
||||
_lock: asyncio.Lock | None = None
|
||||
|
||||
|
||||
@@ -143,7 +160,7 @@ async def acquire_mlx_gate(
|
||||
|
||||
⚠ `asyncio.timeout` 은 반드시 gate 안쪽 (Future await 후) 에 둘 것.
|
||||
"""
|
||||
global _inflight, _waiters
|
||||
global _inflight_n, _waiters
|
||||
|
||||
lock = _get_lock()
|
||||
seq = next(_seq)
|
||||
@@ -152,9 +169,9 @@ async def acquire_mlx_gate(
|
||||
fut: asyncio.Future | None = None
|
||||
|
||||
async with lock:
|
||||
if not _inflight and not _waiters:
|
||||
if _inflight_n < _capacity() and not _waiters:
|
||||
# fast path — 즉시 inflight 진입, Future 생성 안 함
|
||||
_inflight = True
|
||||
_inflight_n += 1
|
||||
else:
|
||||
# 대기열 진입
|
||||
fut = asyncio.get_event_loop().create_future()
|
||||
@@ -194,8 +211,8 @@ async def acquire_mlx_gate(
|
||||
async with lock:
|
||||
next_fut = _dispatch_next_locked()
|
||||
if next_fut is None:
|
||||
_inflight = False
|
||||
# _inflight 는 True 유지 (다음 waiter 가 진입 예정)
|
||||
_inflight_n = max(0, _inflight_n - 1)
|
||||
# next_fut 가 있으면 슬롯 handover — 카운트 유지 (다음 waiter 가 진입 예정)
|
||||
logger.debug(
|
||||
"mlx_gate release duration_ms=%.0f priority=%s seq=%d",
|
||||
duration_ms, priority.name, seq,
|
||||
@@ -222,13 +239,24 @@ def get_mlx_gate():
|
||||
return acquire_mlx_gate(DEFAULT_PRIORITY)
|
||||
|
||||
|
||||
# ── Read-only status (UI 표시용) ─────────────────────────────────────────────
|
||||
|
||||
|
||||
def gate_status() -> dict:
|
||||
"""현재 gate 점유 스냅샷 (read-only, lock-free 근사치 — UI 표시용).
|
||||
|
||||
inflight = 동시 실행 수(int). 기존 소비자(eid status)는 bool() 캐스팅이라 호환.
|
||||
"""
|
||||
return {"inflight": _inflight_n, "waiters": len(_waiters)}
|
||||
|
||||
|
||||
# ── Test helpers (conftest reset) ────────────────────────────────────────────
|
||||
|
||||
|
||||
def _reset_for_test() -> None:
|
||||
"""테스트 fixture 가 fresh loop 마다 호출. production code 에서 사용 X."""
|
||||
global _waiters, _inflight, _lock, _seq
|
||||
global _waiters, _inflight_n, _lock, _seq
|
||||
_waiters = []
|
||||
_inflight = False
|
||||
_inflight_n = 0
|
||||
_lock = None
|
||||
_seq = itertools.count()
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
"""안전 자료실 C-1 후속 — 검색 결과 wrapper decoration (version_status + facets).
|
||||
|
||||
엔드포인트 wrapper 에서 run_search() 결과에 1회 적용 — 검색 코어(run_search) 무접촉(r3).
|
||||
- version_status: 법령 결과(material_type='law')에 legal_meta.version_status
|
||||
(current/superseded/pending/repealed) 부착. legal_meta.document_id 1:0..1 위성 →
|
||||
매핑 없는 law(레거시 등)는 None 유지. law 결과 없으면 query skip.
|
||||
- facets: top-K 결과 내 분류 축(material_type/jurisdiction/version_status) 분포 라벨(r2-M4).
|
||||
facets=true 일 때만 계산(미요청 시 None = byte 불변·ranking 무관).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import Counter
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from api.search import SearchResult
|
||||
|
||||
|
||||
async def decorate_version_status(
|
||||
session: AsyncSession, results: list["SearchResult"]
|
||||
) -> None:
|
||||
"""법령 결과에 legal_meta.version_status 부착 (in-place). law 결과 없으면 query skip."""
|
||||
law_ids = [r.id for r in results if r.material_type == "law" and r.id is not None]
|
||||
if not law_ids:
|
||||
return
|
||||
rows = await session.execute(
|
||||
text(
|
||||
"SELECT document_id, version_status FROM legal_meta "
|
||||
"WHERE document_id = ANY(:ids)"
|
||||
),
|
||||
{"ids": law_ids},
|
||||
)
|
||||
status_by_id = {row.document_id: row.version_status for row in rows}
|
||||
for r in results:
|
||||
if r.id in status_by_id:
|
||||
r.version_status = status_by_id[r.id]
|
||||
|
||||
|
||||
def compute_facets(results: list["SearchResult"]) -> dict[str, dict[str, int]]:
|
||||
"""top-K 결과의 분류 축 분포 라벨. None 값은 제외(present 라벨만, 빈 축은 미포함)."""
|
||||
axes = {
|
||||
"material_type": [r.material_type for r in results],
|
||||
"jurisdiction": [r.jurisdiction for r in results],
|
||||
"version_status": [getattr(r, "version_status", None) for r in results],
|
||||
}
|
||||
facets: dict[str, dict[str, int]] = {}
|
||||
for axis, vals in axes.items():
|
||||
counter = Counter(v for v in vals if v is not None)
|
||||
if counter:
|
||||
facets[axis] = dict(counter.most_common())
|
||||
return facets
|
||||
@@ -24,6 +24,7 @@ import asyncio
|
||||
import hashlib
|
||||
import re
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from sqlalchemy import text
|
||||
@@ -63,8 +64,98 @@ CANDIDATE_BACKEND_MAP: dict[str, dict[str, str] | None] = {
|
||||
"chunks_table": "document_chunks_cand_snowflake_l_v2",
|
||||
"embed_endpoint": "http://embedding-cand-snowflake-l-v2:80/embed",
|
||||
},
|
||||
# ─── Phase 2A (embedding-phase2a-1, 2026-06-12): Qwen3-Embedding 후보 3종 ───
|
||||
# embed_kind="ollama" = /api/embed 호출 + 쿼리측 instruct prefix (비대칭 사용,
|
||||
# G-1 fixture 실측: prefix 가 관련쌍 cos +0.016). 문서측은 backfill 이 plain 으로 적재.
|
||||
# qwen4m = 4B 의 MRL 1024d (dimensions 옵션 — Ollama 가 truncate+재정규화 수행, G-1 실측).
|
||||
"cand_qwen06": {
|
||||
"docs_table": "documents_cand_qwen06",
|
||||
"chunks_table": "document_chunks_cand_qwen06",
|
||||
"embed_endpoint": "http://ollama:11434/api/embed",
|
||||
"embed_kind": "ollama",
|
||||
"embed_model": "qwen3-embedding:0.6b",
|
||||
},
|
||||
"cand_qwen4": {
|
||||
"docs_table": "documents_cand_qwen4",
|
||||
"chunks_table": "document_chunks_cand_qwen4",
|
||||
"embed_endpoint": "http://ollama:11434/api/embed",
|
||||
"embed_kind": "ollama",
|
||||
"embed_model": "qwen3-embedding:4b",
|
||||
},
|
||||
"cand_qwen4m": {
|
||||
"docs_table": "documents_cand_qwen4m",
|
||||
"chunks_table": "document_chunks_cand_qwen4m",
|
||||
"embed_endpoint": "http://ollama:11434/api/embed",
|
||||
"embed_kind": "ollama",
|
||||
"embed_model": "qwen3-embedding:4b",
|
||||
"embed_dimensions": 1024,
|
||||
},
|
||||
}
|
||||
|
||||
# G-1 핀 고정 instruct 문자열 (inventory 2026-06-12-c 기록과 동일해야 함 —
|
||||
# 문구 변경 = 저장=조회 불변식 위반과 동급. 쿼리 측 전용, 문서 적재는 plain).
|
||||
QWEN3_QUERY_INSTRUCT = (
|
||||
"Instruct: Given a web search query, retrieve relevant passages that answer the query"
|
||||
"\nQuery: "
|
||||
)
|
||||
|
||||
# ─── 안전 자료실 C-1: 분류 축 명시 필터 (3 leg 동등, byte 불변) ───────────────
|
||||
# 미지정(active=False) 시 모든 SQL 절이 빈 문자열 → 기존 SQL byte 불변(run_eval 회귀 0).
|
||||
# year 는 published_date NULL fallback created_at (freshness 와 동일 COALESCE 사상).
|
||||
@dataclass
|
||||
class AxisFilter:
|
||||
material_types: list[str] | None = None # CSV → list, material_type = ANY
|
||||
jurisdiction: str | None = None
|
||||
year_from: int | None = None
|
||||
year_to: int | None = None
|
||||
|
||||
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)
|
||||
|
||||
|
||||
def _axis_sql(alias: str, af: "AxisFilter | None", params: dict) -> str:
|
||||
"""alias 기준 axis 필터 SQL — 미지정 시 '' (byte 불변). 반환 형태 ' AND ...'.
|
||||
|
||||
alias='' 이면 컬럼 직접 참조(단일 테이블 FROM documents 경로). 파라미터는 af_ prefix
|
||||
로 호출측 기존 bind 와 충돌 방지.
|
||||
"""
|
||||
if af is None or not af.active():
|
||||
return ""
|
||||
p = (alias + ".") if alias else ""
|
||||
cl: list[str] = []
|
||||
if af.material_types:
|
||||
cl.append(f"{p}material_type = ANY(:af_mt)")
|
||||
params["af_mt"] = af.material_types
|
||||
if af.jurisdiction:
|
||||
cl.append(f"{p}jurisdiction = :af_jur")
|
||||
params["af_jur"] = af.jurisdiction
|
||||
if af.year_from is not None:
|
||||
cl.append(f"COALESCE({p}published_date, {p}created_at::date) >= make_date(:af_yf, 1, 1)")
|
||||
params["af_yf"] = af.year_from
|
||||
if af.year_to is not None:
|
||||
cl.append(f"COALESCE({p}published_date, {p}created_at::date) <= make_date(:af_yt, 12, 31)")
|
||||
params["af_yt"] = af.year_to
|
||||
return " AND " + " AND ".join(cl)
|
||||
|
||||
|
||||
# ─── 안전 자료실 B-4: licensed_restricted 단일 술어 (a안 U-2① — 항상 적용) ──────
|
||||
def _license_sql(alias: str) -> str:
|
||||
"""licensed_restricted(extract_meta.license.restricted=true) 문서를 retrieval 에서 제외.
|
||||
|
||||
a안: 색인은 허용하되, 구매 전자책/유료자료의 verbatim span 이 RAG 증거·digest 발행에
|
||||
들어가는 경로를 구조적으로 차단. 이 단일 술어를 모든 retrieval leg + digest loader 가
|
||||
공유 — 경로별 가드 누락 방지([[feedback_structural_integrity_over_path_discipline]]).
|
||||
개인 파일 열람(GET /documents/{id}?download)은 a안상 허용이라 미적용.
|
||||
|
||||
axis 필터(조건부)와 달리 항상 적용. restricted 부재/false = COALESCE 로 미제외 →
|
||||
기존 코퍼스(restricted=true 0건)에서 결과 불변. 반환 ' AND ...' (alias='' = 컬럼 직접).
|
||||
술어 정의 = license_filter.restricted_exclude_sql 공유(digest/briefing/study 풀이와 단일 source).
|
||||
"""
|
||||
from services.search.license_filter import restricted_exclude_sql
|
||||
return " AND " + restricted_exclude_sql(alias)
|
||||
|
||||
|
||||
# 2단계 gate (R2-B1) — SQL string interpolation 직전 final allowlist.
|
||||
_VALID_DOCS_TABLE = re.compile(r"^(documents|documents_cand_[a-z0-9_]+)$")
|
||||
# corpus_chunks = document_chunks WHERE in_corpus=true 뷰 (Hier-Decomp-1 c2 choke point).
|
||||
@@ -137,6 +228,34 @@ async def _embed_query_via_tei(endpoint: str, text_: str) -> list[float] | None:
|
||||
return None
|
||||
|
||||
|
||||
async def _embed_query_via_ollama(cfg: dict, text_: str) -> list[float] | None:
|
||||
"""Phase 2A 후보 쿼리 임베딩 — Ollama /api/embed + 비대칭 instruct prefix.
|
||||
|
||||
쿼리 측 전용: QWEN3_QUERY_INSTRUCT 를 선두에 붙인다 (문서 적재 = plain).
|
||||
embed_dimensions 지정(qwen4m) 시 Ollama dimensions 옵션 = MRL truncate+재정규화
|
||||
(G-1 fixture: 1024 출력 L2=1.0 실측). cache 미사용 — slug 별 분포 상이.
|
||||
"""
|
||||
if not text_:
|
||||
return None
|
||||
import httpx
|
||||
body: dict = {"model": cfg["embed_model"], "input": [QWEN3_QUERY_INSTRUCT + text_]}
|
||||
if cfg.get("embed_dimensions"):
|
||||
body["dimensions"] = cfg["embed_dimensions"]
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=60.0) as c:
|
||||
r = await c.post(cfg["embed_endpoint"], json=body)
|
||||
r.raise_for_status()
|
||||
embs = r.json().get("embeddings")
|
||||
if not isinstance(embs, list) or not embs or not isinstance(embs[0], list):
|
||||
raise ValueError("unexpected /api/embed shape")
|
||||
return embs[0]
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"candidate ollama embed failed model=%s err=%r", cfg.get("embed_model"), exc
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _query_embed_key(text_: str) -> str:
|
||||
return hashlib.sha256(f"{text_}|bge-m3".encode("utf-8")).hexdigest()
|
||||
|
||||
@@ -174,7 +293,7 @@ def query_embed_cache_stats() -> dict[str, int]:
|
||||
|
||||
|
||||
async def search_text(
|
||||
session: AsyncSession, query: str, limit: int
|
||||
session: AsyncSession, query: str, limit: int, *, axis: "AxisFilter | None" = None
|
||||
) -> list["SearchResult"]:
|
||||
"""FTS + trigram 필드별 가중치 검색 (Phase 1.2-B UNION 분해).
|
||||
|
||||
@@ -205,8 +324,12 @@ async def search_text(
|
||||
# SQLAlchemy async session 내 두 execute는 같은 connection 사용
|
||||
await session.execute(text("SELECT set_limit(0.15)"))
|
||||
|
||||
_params: dict[str, Any] = {"q": query, "limit": limit}
|
||||
# license(항상) + axis(조건부). license 가 항상 ' AND ...' 이라 WHERE 는 늘 존재.
|
||||
_where = _license_sql("d") + _axis_sql("d", axis, _params)
|
||||
|
||||
result = await session.execute(
|
||||
text("""
|
||||
text(f"""
|
||||
WITH candidates AS (
|
||||
-- title trigram (idx_documents_title_trgm)
|
||||
SELECT id FROM documents
|
||||
@@ -238,7 +361,7 @@ async def search_text(
|
||||
+ similarity(coalesce(d.ai_tags::text, ''), :q) * 2.5
|
||||
+ similarity(coalesce(d.user_note, ''), :q) * 2.0
|
||||
+ similarity(coalesce(d.ai_summary, ''), :q) * 1.5
|
||||
+ similarity(coalesce(d.extracted_text, ''), :q) * 1.0
|
||||
+ similarity(left(coalesce(d.extracted_text, ''), 2000), :q) * 1.0
|
||||
-- FTS 보너스 (idx_documents_fts_full 활용)
|
||||
+ coalesce(ts_rank(
|
||||
to_tsvector('simple',
|
||||
@@ -246,7 +369,7 @@ async def search_text(
|
||||
coalesce(d.ai_tags::text, '') || ' ' ||
|
||||
coalesce(d.ai_summary, '') || ' ' ||
|
||||
coalesce(d.user_note, '') || ' ' ||
|
||||
coalesce(d.extracted_text, '')
|
||||
left(coalesce(d.extracted_text, ''), 2000)
|
||||
),
|
||||
plainto_tsquery('simple', :q)
|
||||
), 0) * 2.0
|
||||
@@ -257,15 +380,17 @@ async def search_text(
|
||||
WHEN similarity(coalesce(d.ai_tags::text, ''), :q) >= 0.3 THEN 'tags'
|
||||
WHEN similarity(coalesce(d.user_note, ''), :q) >= 0.3 THEN 'note'
|
||||
WHEN similarity(coalesce(d.ai_summary, ''), :q) >= 0.3 THEN 'summary'
|
||||
WHEN similarity(coalesce(d.extracted_text, ''), :q) >= 0.3 THEN 'content'
|
||||
WHEN similarity(left(coalesce(d.extracted_text, ''), 2000), :q) >= 0.3 THEN 'content'
|
||||
ELSE 'fts'
|
||||
END AS match_reason
|
||||
END AS match_reason,
|
||||
d.material_type, d.jurisdiction, d.published_date
|
||||
FROM documents d
|
||||
JOIN candidates c ON d.id = c.id
|
||||
WHERE{_where[4:]}
|
||||
ORDER BY score DESC
|
||||
LIMIT :limit
|
||||
"""),
|
||||
{"q": query, "limit": limit},
|
||||
_params,
|
||||
)
|
||||
return [SearchResult(**row._mapping) for row in result]
|
||||
|
||||
@@ -280,6 +405,7 @@ async def search_vector(
|
||||
snapshot_chunk_id_max: int | None = None,
|
||||
corpus_variant: str | None = None,
|
||||
exact_knn: bool = False,
|
||||
axis: "AxisFilter | None" = None,
|
||||
) -> list["SearchResult"]:
|
||||
"""Hybrid 벡터 검색 — doc + chunks 동시 retrieval (Phase 1.2-G).
|
||||
|
||||
@@ -323,7 +449,10 @@ async def search_vector(
|
||||
else:
|
||||
docs_table = cfg["docs_table"]
|
||||
chunks_table = cfg["chunks_table"]
|
||||
query_embedding = await _embed_query_via_tei(cfg["embed_endpoint"], query)
|
||||
if cfg.get("embed_kind") == "ollama":
|
||||
query_embedding = await _embed_query_via_ollama(cfg, query)
|
||||
else:
|
||||
query_embedding = await _embed_query_via_tei(cfg["embed_endpoint"], query)
|
||||
|
||||
logger.info(
|
||||
"[embedding-dispatch] backend=%s docs_table=%s chunks_table=%s snapshot_doc_id_max=%s "
|
||||
@@ -351,6 +480,7 @@ async def search_vector(
|
||||
docs_table=docs_table,
|
||||
snapshot_doc_id_max=snapshot_doc_id_max,
|
||||
exact_knn=exact_knn,
|
||||
axis=axis,
|
||||
)
|
||||
|
||||
async def _chunks_call() -> list["SearchResult"]:
|
||||
@@ -360,6 +490,7 @@ async def search_vector(
|
||||
chunks_table=chunks_table,
|
||||
snapshot_chunk_id_max=snapshot_chunk_id_max,
|
||||
exact_knn=exact_knn,
|
||||
axis=axis,
|
||||
)
|
||||
|
||||
doc_results, chunk_results = await asyncio.gather(_docs_call(), _chunks_call())
|
||||
@@ -375,6 +506,7 @@ async def _search_vector_docs(
|
||||
docs_table: str = "documents",
|
||||
snapshot_doc_id_max: int | None = None,
|
||||
exact_knn: bool = False,
|
||||
axis: "AxisFilter | None" = None,
|
||||
) -> list["SearchResult"]:
|
||||
"""documents (또는 documents_cand_<slug>).embedding 직접 검색.
|
||||
|
||||
@@ -399,28 +531,34 @@ async def _search_vector_docs(
|
||||
if snapshot_doc_id_max is not None:
|
||||
snapshot_clause = " AND id <= :snapshot_doc_id_max"
|
||||
params["snapshot_doc_id_max"] = snapshot_doc_id_max
|
||||
axis_clause = _axis_sql("", axis, params) # alias 없음 (단일 FROM documents)
|
||||
license_clause = _license_sql("") # B-4: restricted 항상 제외
|
||||
sql = f"""
|
||||
SELECT id, title, ai_domain, ai_summary, file_format,
|
||||
(1 - (embedding <=> cast(:embedding AS vector))) AS score,
|
||||
left(extracted_text, 1200) AS snippet,
|
||||
'vector_doc' AS match_reason,
|
||||
NULL::bigint AS chunk_id, NULL::integer AS chunk_index, NULL::text AS section_title
|
||||
NULL::bigint AS chunk_id, NULL::integer AS chunk_index, NULL::text AS section_title,
|
||||
material_type, jurisdiction, published_date
|
||||
FROM documents
|
||||
WHERE embedding IS NOT NULL AND deleted_at IS NULL{snapshot_clause}
|
||||
WHERE embedding IS NOT NULL AND deleted_at IS NULL{snapshot_clause}{axis_clause}{license_clause}
|
||||
ORDER BY embedding <=> cast(:embedding AS vector)
|
||||
LIMIT :limit
|
||||
"""
|
||||
else:
|
||||
# candidate: docs_table 은 (doc_id, embed_input, embed_input_hash, embedding) 만 보유 → JOIN documents
|
||||
axis_clause = _axis_sql("d", axis, params)
|
||||
license_clause = _license_sql("d") # B-4: restricted 항상 제외
|
||||
sql = f"""
|
||||
SELECT d.id, d.title, d.ai_domain, d.ai_summary, d.file_format,
|
||||
(1 - (c.embedding <=> cast(:embedding AS vector))) AS score,
|
||||
left(d.extracted_text, 1200) AS snippet,
|
||||
'vector_doc' AS match_reason,
|
||||
NULL::bigint AS chunk_id, NULL::integer AS chunk_index, NULL::text AS section_title
|
||||
NULL::bigint AS chunk_id, NULL::integer AS chunk_index, NULL::text AS section_title,
|
||||
d.material_type, d.jurisdiction, d.published_date
|
||||
FROM {docs_table} c
|
||||
JOIN documents d ON d.id = c.doc_id
|
||||
WHERE d.deleted_at IS NULL
|
||||
WHERE d.deleted_at IS NULL{axis_clause}{license_clause}
|
||||
ORDER BY c.embedding <=> cast(:embedding AS vector)
|
||||
LIMIT :limit
|
||||
"""
|
||||
@@ -436,6 +574,7 @@ async def _search_vector_chunks(
|
||||
chunks_table: str = "document_chunks",
|
||||
snapshot_chunk_id_max: int | None = None,
|
||||
exact_knn: bool = False,
|
||||
axis: "AxisFilter | None" = None,
|
||||
) -> list["SearchResult"]:
|
||||
"""document_chunks (또는 document_chunks_cand_<slug>).embedding window partition.
|
||||
|
||||
@@ -461,12 +600,25 @@ async def _search_vector_chunks(
|
||||
snapshot_clause = " AND c.id <= :snapshot_chunk_id_max"
|
||||
params["snapshot_chunk_id_max"] = snapshot_chunk_id_max
|
||||
|
||||
# C-1: axis 필터는 inner topk 에 JOIN (R6 결정 — outer post-filter 면 ANN top-:inner_k
|
||||
# 후보를 뽑은 뒤 거르므로 좁은 필터(GB 법령 등)에서 후보 붕괴). 미지정 시 JOIN 없음 = byte 불변.
|
||||
if axis and axis.active():
|
||||
chunk_join = " JOIN documents df ON df.id = c.doc_id"
|
||||
chunk_axis = _axis_sql("df", axis, params)
|
||||
else:
|
||||
chunk_join = ""
|
||||
chunk_axis = ""
|
||||
|
||||
# B-4: restricted 제외 — outer 가 documents d 를 항상 JOIN 하므로 post-rank 위치.
|
||||
# restricted 는 소수(구매자료)라 inner topk 후 제외해도 candidate collapse 없음(axis 와 상이).
|
||||
license_clause = _license_sql("d")
|
||||
|
||||
sql = f"""
|
||||
WITH topk AS (
|
||||
SELECT c.id AS chunk_id, c.doc_id, c.chunk_index, c.section_title, c.text,
|
||||
c.embedding <=> cast(:embedding AS vector) AS dist
|
||||
FROM {chunks_table} c
|
||||
WHERE c.embedding IS NOT NULL{snapshot_clause}
|
||||
FROM {chunks_table} c{chunk_join}
|
||||
WHERE c.embedding IS NOT NULL{snapshot_clause}{chunk_axis}
|
||||
ORDER BY c.embedding <=> cast(:embedding AS vector)
|
||||
LIMIT :inner_k
|
||||
),
|
||||
@@ -479,10 +631,12 @@ async def _search_vector_chunks(
|
||||
d.ai_summary AS ai_summary, d.file_format AS file_format,
|
||||
(1 - r.dist) AS score, left(r.text, 1200) AS snippet,
|
||||
'vector_chunk' AS match_reason,
|
||||
r.chunk_id AS chunk_id, r.chunk_index AS chunk_index, r.section_title AS section_title
|
||||
r.chunk_id AS chunk_id, r.chunk_index AS chunk_index, r.section_title AS section_title,
|
||||
d.material_type AS material_type, d.jurisdiction AS jurisdiction,
|
||||
d.published_date AS published_date
|
||||
FROM ranked r
|
||||
JOIN documents d ON d.id = r.doc_id
|
||||
WHERE r.rn <= 2 AND d.deleted_at IS NULL
|
||||
WHERE r.rn <= 2 AND d.deleted_at IS NULL{license_clause}
|
||||
ORDER BY r.dist
|
||||
LIMIT :limit
|
||||
"""
|
||||
|
||||
@@ -47,6 +47,7 @@ from .rerank_service import (
|
||||
rerank_chunks,
|
||||
)
|
||||
from .retrieval_service import (
|
||||
AxisFilter,
|
||||
compress_chunks_to_docs,
|
||||
search_text,
|
||||
search_vector,
|
||||
@@ -148,6 +149,7 @@ async def run_search(
|
||||
rewrite_backend: str | None = None,
|
||||
corpus_variant: str | None = None,
|
||||
exact_knn: bool = False,
|
||||
axis: AxisFilter | None = None,
|
||||
) -> PipelineResult:
|
||||
"""검색 파이프라인 실행.
|
||||
|
||||
@@ -275,6 +277,7 @@ async def run_search(
|
||||
snapshot_chunk_id_max=snapshot_chunk_id_max,
|
||||
corpus_variant=corpus_variant,
|
||||
exact_knn=exact_knn,
|
||||
axis=axis,
|
||||
)
|
||||
timing["vector_ms"] = (time.perf_counter() - t0) * 1000
|
||||
if not raw_chunks:
|
||||
@@ -284,7 +287,7 @@ async def run_search(
|
||||
results = vector_results
|
||||
else:
|
||||
t0 = time.perf_counter()
|
||||
text_results = await search_text(session, q, limit)
|
||||
text_results = await search_text(session, q, limit, axis=axis)
|
||||
timing["text_ms"] = (time.perf_counter() - t0) * 1000
|
||||
|
||||
if mode == "hybrid":
|
||||
@@ -306,6 +309,7 @@ async def run_search(
|
||||
snapshot_chunk_id_max=snapshot_chunk_id_max,
|
||||
corpus_variant=corpus_variant,
|
||||
exact_knn=exact_knn,
|
||||
axis=axis,
|
||||
)
|
||||
timing["vector_ms"] = (time.perf_counter() - t1) * 1000
|
||||
|
||||
@@ -458,6 +462,10 @@ def _rrf_fuse_variants(
|
||||
score=rrf_score,
|
||||
snippet=doc.snippet,
|
||||
match_reason=f"{doc.match_reason}+multi_query_rrf",
|
||||
# C-1: 분류 축 메타 전파 (SearchResult 재구성 지점 — fusion 2곳과 동기)
|
||||
material_type=doc.material_type,
|
||||
jurisdiction=doc.jurisdiction,
|
||||
published_date=doc.published_date,
|
||||
))
|
||||
return fused[:limit]
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ logger = setup_logger("synthesis")
|
||||
|
||||
# ─── 상수 (plan 영구 룰) ─────────────────────────────────
|
||||
PROMPT_VERSION = "v2"
|
||||
LLM_TIMEOUT_MS = 30000 # 2026-05-17 B-3: 15s 시 동시 부하 (Mac mini 26B classifier+evidence+synthesis serialized) 빈발 timeout — classifier (30s) 와 align
|
||||
LLM_TIMEOUT_MS = 120000 # 2026-06-11 Qwen3.6-27B-6bit 전환: 프리필 ~112 tok/s·디코드 ~11.7 tok/s 실측 — 30s 면 synthesis(답변 본체) 상시 timeout. synthesis 는 graceful skip 불가(=답변 실패)라 단독 상향, config ask.backend.timeout_read_s=120 와 align
|
||||
CACHE_TTL = 3600 # 1h (answer 는 원문 변경에 민감 → query_analyzer 24h 보다 짧게)
|
||||
CACHE_MAXSIZE = 300
|
||||
MAX_ANSWER_CHARS = 600
|
||||
|
||||
@@ -24,6 +24,7 @@ from models.chunk import DocumentChunk
|
||||
from models.document import Document
|
||||
from models.study_question import StudyQuestion
|
||||
from models.study_topic import StudyTopicDocument
|
||||
from services.search.license_filter import restricted_exclude_orm
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -124,11 +125,14 @@ async def _gather_document_evidence(
|
||||
return []
|
||||
|
||||
# 매핑된 documents 메타 (제목·요약 표기)
|
||||
# B-4: licensed_restricted 제외 → valid_doc_ids 에서 빠지므로 아래 청크 쿼리(doc_id IN)도
|
||||
# 자동 차단. study 풀이 RAG 도 retrieval/digest 와 동일 단일 술어 공유(a안 U-2①).
|
||||
doc_meta_rows = (
|
||||
await session.execute(
|
||||
select(Document.id, Document.title, Document.ai_summary).where(
|
||||
Document.id.in_(doc_ids),
|
||||
Document.deleted_at.is_(None),
|
||||
restricted_exclude_orm(),
|
||||
)
|
||||
)
|
||||
).all()
|
||||
|
||||
@@ -175,10 +175,16 @@ async def _ingest_detail(session, source: NewsSource, url: str) -> str:
|
||||
ai_domain="Engineering",
|
||||
ai_sub_group=_SOURCE_NAME,
|
||||
ai_tags=["Engineering/API 표준 공지"],
|
||||
# 안전 자료실 A-2 — 표준 '공지' = standard (코드 본문 아님 — ASME/API 본문은 paywall)
|
||||
material_type="standard",
|
||||
jurisdiction="US",
|
||||
published_date=pub_dt.date() if pub_dt else None,
|
||||
extract_meta={
|
||||
"source_id": source.id,
|
||||
"source_name": _SOURCE_NAME,
|
||||
"published_at": pub_dt.isoformat() if pub_dt else None,
|
||||
"license": {"scheme": "proprietary", "redistribute": False,
|
||||
"attribution": "American Petroleum Institute"},
|
||||
"fulltext": {
|
||||
"status": "api_announcement",
|
||||
"engine": engine,
|
||||
|
||||
@@ -0,0 +1,370 @@
|
||||
"""arXiv 키워드 필터 수집기 — B-3 PR2 (plan safety-library-b3-1).
|
||||
|
||||
bespoke arXiv API(Atom) 수집기. 카테고리 RSS 통째(firehose)가 아니라
|
||||
cat:{category} AND (abs:키워드 ...) 로 안전/신뢰성/압력용기 관련분만 좁혀 수집한다.
|
||||
|
||||
- signal-only: 초록만 색인(embed+chunk), summarize 절대 미enqueue — 맥미니 Qwen 큐 무접촉.
|
||||
- DOI 보유 → paper.doi(서지 holder, partial-unique 인덱스 진입). 없으면 versionless arXiv id 로
|
||||
dedup(향후 PR4 reconcile 가 DOI 백필).
|
||||
- etiquette: 요청 간 ≥3s + HTTP 429 지수 백오프. 카테고리별 submittedDate 워터마크로 증분.
|
||||
- per-run insert cap(_RUN_CAP) — 광역 수집이 GPU bge-m3 embed 큐를 범람시키지 않게(적대리뷰 A major).
|
||||
잔여는 silent-cap 금지(csb idiom): 누락 건수 로깅.
|
||||
- keyless. enabled=False news_sources 행(6h 뉴스 사이클 비대상) + main.py CronTrigger(자체 폴링).
|
||||
- arXiv API 는 https 필수(http=301). UA = CRAWL_UA.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import re
|
||||
import xml.etree.ElementTree as ET
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import select
|
||||
|
||||
from core.crawl_politeness import CRAWL_UA
|
||||
from core.database import async_session
|
||||
from core.utils import setup_logger
|
||||
from models.document import Document
|
||||
from models.news_source import NewsSource
|
||||
from models.queue import enqueue_stage
|
||||
from services.papers.doi import arxiv_doi, normalize_doi
|
||||
from services.papers.holder import find_paper_holder
|
||||
from workers.news_collector import (
|
||||
FeedError,
|
||||
_get_or_create_health,
|
||||
_record_failure,
|
||||
_record_success,
|
||||
)
|
||||
|
||||
logger = setup_logger("arxiv_collector")
|
||||
|
||||
_ARXIV_API = "https://export.arxiv.org/api/query"
|
||||
_SOURCE_NAME = "arXiv 안전·공학 (keyword)"
|
||||
|
||||
# 신규 카테고리만 — 기존 RSS 행(id 62 physics.app-ph, id 64 cond-mat.mtrl-sci)과 비중복.
|
||||
_CATEGORIES = (
|
||||
"eess.SY", # systems & control
|
||||
"physics.flu-dyn", # 유체 — 압력/유동
|
||||
"physics.comp-ph", # 전산물리
|
||||
"math.OC", # 최적화·제어
|
||||
"math.NA", # 수치해석 (FEM 등)
|
||||
"stat.AP", # 응용통계 — 신뢰성
|
||||
"cs.CE", # computational engineering
|
||||
)
|
||||
# 압력용기·공정안전·구조건전성 도메인 키워드(abs: OR 게이트). 좁게 유지 = 관련성↑·볼륨↓ (튜너블).
|
||||
_KEYWORDS = (
|
||||
"pressure vessel",
|
||||
"process safety",
|
||||
"structural integrity",
|
||||
"fracture mechanics",
|
||||
"fatigue life",
|
||||
"corrosion",
|
||||
)
|
||||
|
||||
_RUN_CAP = 80 # 1회 run 신규 적재 상한(임베드 큐 보호). bulk 시 해제.
|
||||
_PAGE_SIZE = 50 # max_results per request
|
||||
_MAX_PAGES_PER_CAT = 4 # 카테고리당 최대 페이지(증분이라 보통 1페이지에 워터마크 도달)
|
||||
_REQ_SLEEP = 3.0 # arXiv etiquette ≥3s
|
||||
_MAX_RETRY = 4
|
||||
_BACKOFF_BASE = 5.0
|
||||
|
||||
_NS = {
|
||||
"a": "http://www.w3.org/2005/Atom",
|
||||
"arxiv": "http://arxiv.org/schemas/atom",
|
||||
"opensearch": "http://a9.com/-/spec/opensearch/1.1/",
|
||||
}
|
||||
_ABS_ID_RE = re.compile(r"arxiv\.org/abs/(.+?)(v\d+)?$")
|
||||
_WS_RE = re.compile(r"\s+")
|
||||
|
||||
|
||||
# ───────────────────────── 순수 파서 (fixture 단위 테스트 대상) ─────────────────────────
|
||||
|
||||
@dataclass
|
||||
class ArxivEntry:
|
||||
arxiv_id: str # versionless, 예: "1209.2405"
|
||||
version: str | None # "v1" 또는 None
|
||||
title: str
|
||||
summary: str # 초록
|
||||
published: datetime | None
|
||||
doi: str | None # normalize_doi 적용
|
||||
journal_ref: str | None
|
||||
primary_category: str | None
|
||||
categories: list = field(default_factory=list)
|
||||
abs_url: str | None = None
|
||||
pdf_url: str | None = None
|
||||
|
||||
|
||||
def _clean(text: str | None) -> str:
|
||||
return _WS_RE.sub(" ", text).strip() if text else ""
|
||||
|
||||
|
||||
def _parse_id(raw_id: str | None) -> tuple[str | None, str | None]:
|
||||
"""'http://arxiv.org/abs/1209.2405v1' → ('1209.2405', 'v1'). versionless id 가 dedup 키."""
|
||||
m = _ABS_ID_RE.search((raw_id or "").strip())
|
||||
if not m:
|
||||
return None, None
|
||||
return m.group(1), m.group(2)
|
||||
|
||||
|
||||
def _parse_dt(s: str | None) -> datetime | None:
|
||||
if not s:
|
||||
return None
|
||||
try:
|
||||
return datetime.fromisoformat(s.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def build_search_query(category: str, keywords=_KEYWORDS) -> str:
|
||||
"""cat:{category} AND (abs:kw1 OR abs:"kw with space" ...). 공백 키워드는 따옴표 구절."""
|
||||
kw = " OR ".join(f'abs:"{k}"' if " " in k else f"abs:{k}" for k in keywords)
|
||||
return f"cat:{category} AND ({kw})"
|
||||
|
||||
|
||||
def parse_arxiv_feed(xml_text: str) -> tuple[int, list[ArxivEntry]]:
|
||||
"""arXiv Atom 응답 → (total_results, [ArxivEntry]). 순수 함수."""
|
||||
root = ET.fromstring(xml_text)
|
||||
raw_total = root.findtext("opensearch:totalResults", default="0", namespaces=_NS)
|
||||
try:
|
||||
total = int(raw_total)
|
||||
except (TypeError, ValueError):
|
||||
total = 0
|
||||
entries: list[ArxivEntry] = []
|
||||
for e in root.findall("a:entry", _NS):
|
||||
aid, ver = _parse_id(e.findtext("a:id", namespaces=_NS))
|
||||
if not aid:
|
||||
continue
|
||||
prim = e.find("arxiv:primary_category", _NS)
|
||||
abs_url = pdf_url = None
|
||||
for ln in e.findall("a:link", _NS):
|
||||
if ln.get("rel") == "alternate" and (ln.get("type") or "").startswith("text/html"):
|
||||
abs_url = ln.get("href")
|
||||
elif ln.get("title") == "pdf":
|
||||
pdf_url = ln.get("href")
|
||||
entries.append(ArxivEntry(
|
||||
arxiv_id=aid,
|
||||
version=ver,
|
||||
title=_clean(e.findtext("a:title", namespaces=_NS)),
|
||||
summary=_clean(e.findtext("a:summary", namespaces=_NS)),
|
||||
published=_parse_dt(e.findtext("a:published", namespaces=_NS)),
|
||||
doi=normalize_doi(e.findtext("arxiv:doi", namespaces=_NS)),
|
||||
journal_ref=_clean(e.findtext("arxiv:journal_ref", namespaces=_NS)) or None,
|
||||
primary_category=prim.get("term") if prim is not None else None,
|
||||
categories=[c.get("term") for c in e.findall("a:category", _NS)],
|
||||
abs_url=abs_url,
|
||||
pdf_url=pdf_url,
|
||||
))
|
||||
return total, entries
|
||||
|
||||
|
||||
# ───────────────────────── 적재 (DB — PR2 라이브 검증) ─────────────────────────
|
||||
|
||||
def _build_paper_meta(source: NewsSource, entry: ArxivEntry, doi: str | None) -> dict:
|
||||
"""extract_meta — license + source + paper 식별. 서지 holder 는 paper.doi(있으면) 보유."""
|
||||
paper: dict = {"arxiv_id": entry.arxiv_id}
|
||||
if doi:
|
||||
paper["doi"] = doi # partial-unique 인덱스 진입 (교차소스 dedup)
|
||||
if entry.journal_ref:
|
||||
paper["journal_ref"] = entry.journal_ref
|
||||
if entry.primary_category:
|
||||
paper["primary_category"] = entry.primary_category
|
||||
meta: dict = {
|
||||
"source_id": source.id,
|
||||
"source_name": source.name,
|
||||
"source_region": "INT", # arXiv = 국제 preprint. paper.jurisdiction 은 NULL 유지(A-2).
|
||||
"paper": paper,
|
||||
# arXiv 기본 라이선스 = 비배포(보수적). restricted 부재 → 초록은 RAG 사용 가능.
|
||||
# (명시 CC 검출은 OAI 인터페이스 필요 — Atom API 미포함, PR 후속/관찰.)
|
||||
"license": {"scheme": "arxiv", "redistribute": False, "attribution": "arXiv"},
|
||||
}
|
||||
if entry.published:
|
||||
meta["published_at"] = entry.published.isoformat()
|
||||
return meta
|
||||
|
||||
|
||||
async def _ingest_entry(session, source: NewsSource, entry: ArxivEntry) -> bool:
|
||||
"""1건 적재. 반환 = 신규 여부. signal-only(embed+chunk, summarize 없음)."""
|
||||
arxiv_hash = hashlib.sha256(f"arxiv|{entry.arxiv_id}".encode()).hexdigest()[:32]
|
||||
# 재수집 dedup(arXiv id) — .first()(다중행 방어)
|
||||
dup = await session.execute(
|
||||
select(Document.id).where(Document.file_hash == arxiv_hash).limit(1)
|
||||
)
|
||||
if dup.scalars().first():
|
||||
return False
|
||||
# arXiv canonical DOI = 저널 DOI 또는 arXiv DataCite DOI(프리프린트도 paper.doi 보유 → PR3 와 dedup)
|
||||
doi = entry.doi or arxiv_doi(entry.arxiv_id)
|
||||
# 교차소스 dedup(DOI holder 이미 존재 — partial-unique 인덱스 백스톱 선제 회피)
|
||||
if doi and await find_paper_holder(session, doi):
|
||||
return False
|
||||
|
||||
body = entry.summary or entry.title
|
||||
doc = Document(
|
||||
file_path=f"crawl/arxiv/{entry.arxiv_id}",
|
||||
file_hash=arxiv_hash,
|
||||
file_format="article",
|
||||
file_size=len(body.encode()),
|
||||
file_type="note",
|
||||
title=entry.title,
|
||||
extracted_text=f"{entry.title}\n\n{body}",
|
||||
extracted_at=datetime.now(timezone.utc),
|
||||
extractor_version="arxiv-api-signal",
|
||||
md_status="skipped",
|
||||
md_extraction_error="arXiv abstract: signal-only, markdown 비대상",
|
||||
source_channel="crawl",
|
||||
data_origin="external",
|
||||
edit_url=entry.abs_url,
|
||||
review_status="approved",
|
||||
material_type="paper",
|
||||
jurisdiction=None, # paper = NULL 불변(A-2). 지역은 extract_meta.paper.source_region.
|
||||
published_date=entry.published.date() if entry.published else None,
|
||||
extract_meta=_build_paper_meta(source, entry, doi),
|
||||
)
|
||||
session.add(doc)
|
||||
await session.flush()
|
||||
# signal-only: 검색 색인만. summarize/fulltext 절대 enqueue 안 함(맥미니 큐 무접촉).
|
||||
await enqueue_stage(session, doc.id, "embed")
|
||||
await enqueue_stage(session, doc.id, "chunk")
|
||||
return True
|
||||
|
||||
|
||||
async def _get_or_create_source(session) -> NewsSource:
|
||||
result = await session.execute(
|
||||
select(NewsSource).where(NewsSource.name == _SOURCE_NAME)
|
||||
)
|
||||
source = result.scalars().first()
|
||||
if source is None:
|
||||
source = NewsSource(
|
||||
name=_SOURCE_NAME, feed_url=_ARXIV_API, feed_type="atom",
|
||||
fetch_method="signal-only", fulltext_policy="none",
|
||||
source_channel="crawl", category="Engineering", language="en",
|
||||
country=None, # paper → jurisdiction NULL (country 미전파)
|
||||
material_type="paper",
|
||||
license_scheme="arxiv", license_redistribute=False,
|
||||
enabled=False, # 6h 뉴스 사이클 비대상 — 본 워커가 자체 폴링
|
||||
)
|
||||
session.add(source)
|
||||
await session.flush()
|
||||
return source
|
||||
|
||||
|
||||
def _watermark(source: NewsSource, category: str) -> datetime | None:
|
||||
raw = (source.selector_override or {}).get("arxiv_watermark", {}).get(category)
|
||||
if not raw:
|
||||
return None
|
||||
return _parse_dt(raw)
|
||||
|
||||
|
||||
def _set_watermark(source: NewsSource, category: str, value: datetime) -> None:
|
||||
cfg = dict(source.selector_override or {})
|
||||
wm = dict(cfg.get("arxiv_watermark") or {})
|
||||
wm[category] = value.isoformat()
|
||||
cfg["arxiv_watermark"] = wm
|
||||
source.selector_override = cfg # JSONB 변경 감지 위해 재할당
|
||||
|
||||
|
||||
async def _fetch(client: httpx.AsyncClient, query: str, start: int) -> str:
|
||||
params = {
|
||||
"search_query": query, "start": start, "max_results": _PAGE_SIZE,
|
||||
"sortBy": "submittedDate", "sortOrder": "descending",
|
||||
}
|
||||
for attempt in range(_MAX_RETRY):
|
||||
resp = await client.get(_ARXIV_API, params=params)
|
||||
if resp.status_code == 429:
|
||||
await asyncio.sleep(_BACKOFF_BASE * (2 ** attempt))
|
||||
continue
|
||||
resp.raise_for_status()
|
||||
return resp.text
|
||||
raise FeedError(f"arXiv 429 재시도 초과: {query[:48]}")
|
||||
|
||||
|
||||
async def run(bulk: bool = False, limit: int = 0) -> None:
|
||||
"""daily 진입점(스케줄러). bulk/limit 은 CLI 전용(bulk=cap 해제·깊은 페이징)."""
|
||||
now = datetime.now(timezone.utc)
|
||||
async with async_session() as session:
|
||||
source = await _get_or_create_source(session)
|
||||
await session.commit()
|
||||
source_id = source.id
|
||||
|
||||
run_cap = (limit or 10**9) if bulk else (min(limit, _RUN_CAP) if limit else _RUN_CAP)
|
||||
inserted = 0
|
||||
seen = 0
|
||||
failures: list[str] = []
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
timeout=30.0, headers={"User-Agent": CRAWL_UA}, follow_redirects=True
|
||||
) as client:
|
||||
for category in _CATEGORIES:
|
||||
if inserted >= run_cap:
|
||||
break
|
||||
query = build_search_query(category)
|
||||
async with async_session() as session:
|
||||
src = await session.get(NewsSource, source_id)
|
||||
watermark = _watermark(src, category)
|
||||
newest_seen: datetime | None = None
|
||||
max_pages = (10**6 if bulk else _MAX_PAGES_PER_CAT)
|
||||
try:
|
||||
for page in range(max_pages):
|
||||
if inserted >= run_cap:
|
||||
break
|
||||
xml_text = await _fetch(client, query, page * _PAGE_SIZE)
|
||||
total, entries = parse_arxiv_feed(xml_text)
|
||||
if not entries:
|
||||
break
|
||||
stop = False
|
||||
for entry in entries:
|
||||
seen += 1
|
||||
if entry.published:
|
||||
newest_seen = max(newest_seen or entry.published, entry.published)
|
||||
# 증분: 워터마크 이하 도달 시 이 카테고리 종료(이미 본 구간)
|
||||
if watermark and not bulk and entry.published <= watermark:
|
||||
stop = True
|
||||
break
|
||||
async with async_session() as session:
|
||||
src = await session.get(NewsSource, source_id)
|
||||
if await _ingest_entry(session, src, entry):
|
||||
inserted += 1
|
||||
await session.commit()
|
||||
else:
|
||||
await session.rollback()
|
||||
if inserted >= run_cap:
|
||||
break
|
||||
await asyncio.sleep(_REQ_SLEEP)
|
||||
if stop or (page + 1) * _PAGE_SIZE >= total:
|
||||
break
|
||||
# 카테고리 워터마크 전진(이번 run 최신 발행일)
|
||||
if newest_seen:
|
||||
async with async_session() as session:
|
||||
src = await session.get(NewsSource, source_id)
|
||||
_set_watermark(src, category, newest_seen)
|
||||
await session.commit()
|
||||
except (httpx.HTTPError, FeedError, ET.ParseError) as e:
|
||||
msg = f"[{category}] {e or repr(e)}"
|
||||
logger.error(f"[arxiv] {msg}")
|
||||
failures.append(msg)
|
||||
|
||||
async with async_session() as session:
|
||||
health = await _get_or_create_health(session, source_id)
|
||||
if failures and inserted == 0:
|
||||
_record_failure(health, "; ".join(failures)[:500], now)
|
||||
else:
|
||||
_record_success(health, inserted, False, now)
|
||||
await session.commit()
|
||||
|
||||
deferred = "" if inserted < run_cap else f" (cap {run_cap} 도달 — 잔여는 다음 run 이월)"
|
||||
logger.info(
|
||||
f"[arxiv] {len(_CATEGORIES)}개 카테고리 스캔 {seen}건 → 신규 {inserted}건{deferred}"
|
||||
+ (f" / 실패 {len(failures)}건" if failures else "")
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# CLI = 수동/백필 전용. --bulk = cap 해제·깊은 페이징, --limit N = 상한 N(라이브 검증용).
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description="arXiv 안전·공학 키워드 수집기")
|
||||
parser.add_argument("--bulk", action="store_true", help="cap 해제 + 깊은 페이징 백필")
|
||||
parser.add_argument("--limit", type=int, default=0, help="신규 적재 상한(0=기본 cap)")
|
||||
args = parser.parse_args()
|
||||
asyncio.run(run(bulk=args.bulk, limit=args.limit))
|
||||
@@ -8,6 +8,7 @@
|
||||
import asyncio
|
||||
from datetime import date
|
||||
|
||||
from core.config import settings
|
||||
from core.utils import setup_logger
|
||||
from services.briefing.pipeline import run_briefing_pipeline
|
||||
|
||||
@@ -22,6 +23,9 @@ async def run(target_date: date | None = None) -> dict | None:
|
||||
Args:
|
||||
target_date: KST 기준 briefing_date (None = 오늘). API regenerate 가 명시 지정 가능.
|
||||
"""
|
||||
if "briefing" in settings.pipeline_held_stages:
|
||||
logger.info("[briefing] 보류 (pipeline.held_stages) — 이번 실행 skip")
|
||||
return None
|
||||
try:
|
||||
result = await asyncio.wait_for(
|
||||
run_briefing_pipeline(target_date),
|
||||
|
||||
@@ -311,6 +311,10 @@ async def process(document_id: int, session: AsyncSession) -> None:
|
||||
country, source, src_lang = await _lookup_news_source(session, doc)
|
||||
if src_lang:
|
||||
language = src_lang
|
||||
# 안전 자료실 A-2 — 뉴스 lookup 미해당(crawl/law/업로드) 문서는 jurisdiction 을
|
||||
# chunk.country 미러로 (leg 간 국가 일치. EU/INT 도 이 경로로 첫 유입 — String(10) 수용).
|
||||
if country is None and doc.jurisdiction:
|
||||
country = doc.jurisdiction
|
||||
domain_category = "news" if doc.source_channel == "news" else "document"
|
||||
|
||||
# 기존 chunks 삭제 (재처리)
|
||||
|
||||
@@ -31,12 +31,18 @@ from pydantic import BaseModel, Field, ValidationError
|
||||
from sqlalchemy import text as sql_text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ai.client import AIClient, parse_json_response, strip_thinking
|
||||
from ai.client import (
|
||||
AIClient,
|
||||
call_deep_or_defer,
|
||||
is_deferrable_error,
|
||||
parse_json_response,
|
||||
strip_thinking,
|
||||
)
|
||||
from ai.envelope import EscalationEnvelope
|
||||
from core.config import settings
|
||||
from core.utils import setup_logger
|
||||
from models.document import Document
|
||||
from models.queue import enqueue_stage
|
||||
from models.queue import StageDeferred, enqueue_stage
|
||||
from policy.prompt_render import render_4b, policy_version as compute_policy_version
|
||||
from policy.routing import decide_routing
|
||||
from services.document_telemetry import record_analyze_event
|
||||
@@ -56,6 +62,15 @@ FACET_DOCTYPES = {"발주서", "세금계산서", "명세표", "도면", "증명
|
||||
# 자료실 자동 분류 제안 대상 (거래 하위)
|
||||
LIBRARY_SUGGESTION_DOCTYPES = {"발주서", "세금계산서", "명세표"}
|
||||
|
||||
# 안전 자료실 A-2 — document_type → material_type 결정적 매핑 (제안 전용, 자동 전이 금지).
|
||||
# 모호한 doctype(Reference/Report 등)은 매핑하지 않음 — 무리한 전수 분류 시도 금지 (plan 0-1).
|
||||
_DOCTYPE_TO_MATERIAL = {
|
||||
"Law_Document": "law",
|
||||
"Academic_Paper": "paper",
|
||||
"Manual": "manual",
|
||||
"Standard": "standard",
|
||||
}
|
||||
|
||||
# PR-B prompt_version task 이름
|
||||
SUMMARY_TRIAGE_TASK = "p3a_short_summary"
|
||||
|
||||
@@ -345,13 +360,20 @@ _FRONTMATTER_PRESERVED_KEYS = {
|
||||
# ───────────────────────── main process ────────────────────────────────
|
||||
|
||||
|
||||
async def process(document_id: int, session: AsyncSession) -> None:
|
||||
async def process(
|
||||
document_id: int, session: AsyncSession, *, use_deep: bool = False
|
||||
) -> None:
|
||||
"""문서 분류 + 요약 + tier triage.
|
||||
|
||||
1) Legacy: classify() → ai_domain/document_type/ai_tags/ai_confidence/ai_suggestion
|
||||
2) Legacy: summarize() → ai_summary
|
||||
3) PR-B B-1: summary_triage (4B) → ai_tldr/ai_bullets/ai_analysis_tier='triage'
|
||||
|
||||
use_deep (2026-06-12 fair-share, queue_drain 전용): triage LLM 호출을 deep 슬롯
|
||||
(맥북, 라우터 경유)으로 보낸다 — sampling 은 triage 의 temperature/max_tokens 를
|
||||
유지(분류 결정성), endpoint 만 교체. 맥북 불가 = StageDeferred 전파(drain 이
|
||||
보류 처리). False(기본/consumer) = 기존 call_triage(맥미니 직접) 그대로.
|
||||
|
||||
예외 — source_channel='law_monitor':
|
||||
법령은 외부 source-of-truth (law.go.kr) 보유 + immutable + 자동 재수집.
|
||||
AI 분류는 무가치 + 본문 해석 환각 위험. 26B legacy + 4B triage 전부 skip.
|
||||
@@ -389,6 +411,15 @@ async def process(document_id: int, session: AsyncSession) -> None:
|
||||
logger.info(f"doc {document_id}: devonagent → classify skip")
|
||||
return
|
||||
|
||||
# 논문(material_type='paper') — 요약/분류 LLM 스킵(맥미니 큐 무접촉, B-3 signal-only 유지).
|
||||
# embed/chunk/markdown 은 queue_consumer 가 chain (early-return 후에도 다음 stage enqueue).
|
||||
if doc.material_type == "paper":
|
||||
if not doc.ai_domain:
|
||||
doc.ai_domain = "논문"
|
||||
await session.commit()
|
||||
logger.info(f"doc {document_id}: paper → classify skip (no summarize)")
|
||||
return
|
||||
|
||||
if not doc.extracted_text:
|
||||
raise ValueError(f"문서 ID {document_id}: extracted_text가 비어있음")
|
||||
|
||||
@@ -446,10 +477,20 @@ async def process(document_id: int, session: AsyncSession) -> None:
|
||||
logger.info(f"doc {document_id}: frontmatter 부분 인식 → LLM 으로 미설정 필드 보완")
|
||||
|
||||
client = AIClient()
|
||||
# fair-share (2026-06-12): use_deep 시 legacy classify/summarize 도 deep 슬롯(맥북)
|
||||
# 경유 — 그래야 drain 의 "맥북 분담" 이 실제로 성립 (triage 만 보내면 50K 요약
|
||||
# 프리필이 맥미니에 남는다). deep 슬롯 sampling = primary 와 동일(0.3/0.9/8192).
|
||||
legacy_cfg = settings.ai.deep if (use_deep and settings.ai.deep is not None) else None
|
||||
try:
|
||||
# ─── 1. Legacy classify (primary 26B) ───
|
||||
# ─── 1. Legacy classify (primary 또는 deep) ───
|
||||
truncated = doc.extracted_text[:MAX_CLASSIFY_TEXT]
|
||||
raw_response = await client.classify(truncated)
|
||||
try:
|
||||
raw_response = await client.classify(truncated, cfg=legacy_cfg)
|
||||
except Exception as exc:
|
||||
if legacy_cfg is not None and is_deferrable_error(exc):
|
||||
# 맥북 불가 — 첫 호출(최저 비용 지점)에서 보류로 전환, doc 쓰기 0
|
||||
raise StageDeferred(f"macbook_unavailable:{type(exc).__name__}") from exc
|
||||
raise
|
||||
parsed = parse_json_response(raw_response)
|
||||
|
||||
if not parsed:
|
||||
@@ -469,6 +510,24 @@ async def process(document_id: int, session: AsyncSession) -> None:
|
||||
if not doc.document_type:
|
||||
doc.document_type = doc_type if doc_type in DOCUMENT_TYPES else "Note"
|
||||
|
||||
# ─── 안전 자료실 A-2: material_type 제안 (업로드 경로 — LLM 직접 부여 금지) ───
|
||||
# document_type → material_type 결정적 매핑만 제안으로 적재 (프롬프트 변경 0).
|
||||
# 승인(accept-suggestion) 시에만 전이 — law 는 국가 필수 입력 (KR 기본값 오염 차단,
|
||||
# 자동 전이 금지 사상은 category 와 동일). 수집기 deterministic 경로는 이미 채워져
|
||||
# 있어(material_type IS NOT NULL) 본 제안 비대상. 거래문서 제안(ai_suggestion 점유)과
|
||||
# 충돌 시 기존 제안 우선 (두 제안이 겹치는 문서는 실무상 없음 — 거래 vs 안전자료).
|
||||
_mt_prop = _DOCTYPE_TO_MATERIAL.get(doc.document_type or "")
|
||||
if _mt_prop and doc.material_type is None and doc.ai_suggestion is None:
|
||||
doc.ai_suggestion = {
|
||||
"proposed_material_type": _mt_prop,
|
||||
"proposed_jurisdiction": None,
|
||||
"confidence": doc.ai_confidence,
|
||||
"source_updated_at": (
|
||||
doc.updated_at.isoformat() if doc.updated_at else None
|
||||
),
|
||||
"reason": "document_type→material_type 결정적 매핑",
|
||||
}
|
||||
|
||||
# confidence
|
||||
confidence = parsed.get("confidence", 0.5)
|
||||
doc.ai_confidence = max(0.0, min(1.0, float(confidence)))
|
||||
@@ -517,12 +576,17 @@ async def process(document_id: int, session: AsyncSession) -> None:
|
||||
"reason": "classify pipeline",
|
||||
}
|
||||
|
||||
# ─── 2. Legacy 요약 (primary 26B) ───
|
||||
summary = await client.summarize(doc.extracted_text[:50000])
|
||||
# ─── 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 완료) ───
|
||||
doc.ai_model_version = settings.ai.primary.model
|
||||
# ─── 메타데이터 (legacy 완료) — 실제 처리 머신 귀속 (drain=qwen-macbook) ───
|
||||
doc.ai_model_version = (legacy_cfg or settings.ai.primary).model
|
||||
doc.ai_processed_at = datetime.now(timezone.utc)
|
||||
|
||||
logger.info(
|
||||
@@ -533,7 +597,9 @@ async def process(document_id: int, session: AsyncSession) -> None:
|
||||
|
||||
# ─── 3. PR-B B-1 — tier triage (4B, 실패는 legacy 결과 보존) ───
|
||||
try:
|
||||
await _run_tier_triage(client, doc, session)
|
||||
await _run_tier_triage(client, doc, session, use_deep=use_deep)
|
||||
except StageDeferred:
|
||||
raise # 보류는 실패가 아님 — drain/consumer 가 attempts 미소모 처리
|
||||
except Exception as exc:
|
||||
logger.exception(f"[triage] id={document_id} 전체 실패 — legacy 유지: {exc}")
|
||||
|
||||
@@ -541,8 +607,10 @@ async def process(document_id: int, session: AsyncSession) -> None:
|
||||
await client.close()
|
||||
|
||||
|
||||
async def _run_tier_triage(client: AIClient, doc: Document, session: AsyncSession) -> None:
|
||||
"""summary_triage (p3a_short_summary) 경로."""
|
||||
async def _run_tier_triage(
|
||||
client: AIClient, doc: Document, session: AsyncSession, *, use_deep: bool = False
|
||||
) -> None:
|
||||
"""summary_triage (p3a_short_summary) 경로. use_deep = process() 에서 전달 (drain 전용)."""
|
||||
document_id = doc.id
|
||||
text = doc.extracted_text or ""
|
||||
input_chars = len(text)
|
||||
@@ -550,6 +618,14 @@ async def _run_tier_triage(client: AIClient, doc: Document, session: AsyncSessio
|
||||
triage_start = time.perf_counter()
|
||||
parse_error: str | None = None
|
||||
triage_out = TriageOutput()
|
||||
# drain 경유 시 triage 도 deep 슬롯(맥북) — sampling 은 triage 것 유지(결정성).
|
||||
deep_triage_cfg = None
|
||||
if use_deep and settings.ai.deep is not None:
|
||||
deep_triage_cfg = settings.ai.deep.model_copy(update={
|
||||
"temperature": settings.ai.triage.temperature,
|
||||
"top_p": settings.ai.triage.top_p,
|
||||
"max_tokens": settings.ai.triage.max_tokens,
|
||||
})
|
||||
|
||||
# 입력이 triage 한도 초과면 호출 생략하고 long_context 로 escalate
|
||||
if input_chars > TRIAGE_TEXT_LIMIT:
|
||||
@@ -590,7 +666,14 @@ async def _run_tier_triage(client: AIClient, doc: Document, session: AsyncSessio
|
||||
prompt = rendered.replace("{extracted_text}", text[:TRIAGE_TEXT_LIMIT])
|
||||
|
||||
try:
|
||||
raw_triage = await client.call_triage(prompt)
|
||||
if deep_triage_cfg is not None:
|
||||
# drain 전용 — deep 슬롯 endpoint + triage sampling. 맥북 불가(StageDeferred)
|
||||
# 는 아래 generic except 에 먹히지 않게 먼저 전파.
|
||||
raw_triage = await call_deep_or_defer(client, prompt, cfg=deep_triage_cfg)
|
||||
else:
|
||||
raw_triage = await client.call_triage(prompt)
|
||||
except StageDeferred:
|
||||
raise # drain 이 attempts 미소모 + 백오프로 처리 (sleep-안전)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"[triage] 4B 호출 실패 id=%s type=%s repr=%r",
|
||||
@@ -656,6 +739,7 @@ async def _run_tier_triage(client: AIClient, doc: Document, session: AsyncSessio
|
||||
escalation_reason=escalation_reason,
|
||||
parse_error=parse_error,
|
||||
routing_decision=routing_decision,
|
||||
model_name=(deep_triage_cfg.model if deep_triage_cfg is not None else None),
|
||||
)
|
||||
|
||||
|
||||
@@ -670,6 +754,7 @@ async def _apply_triage_result(
|
||||
escalation_reason: str | None,
|
||||
parse_error: str | None,
|
||||
routing_decision=None,
|
||||
model_name: str | None = None, # fair-share: 실제 호출 경로 모델 (None=triage 기본)
|
||||
) -> None:
|
||||
"""TriageOutput → Document 필드 + R2 suppression + envelope enqueue + audit.
|
||||
|
||||
@@ -760,7 +845,7 @@ async def _apply_triage_result(
|
||||
layers_returned=["tldr", "bullets"] if not parse_error else [],
|
||||
cached=False,
|
||||
latency_ms=latency_ms,
|
||||
model_name=settings.ai.triage.model,
|
||||
model_name=(model_name or settings.ai.triage.model),
|
||||
prompt_version=(f"{SUMMARY_TRIAGE_TASK}@{pv}" if pv else SUMMARY_TRIAGE_TASK),
|
||||
error_code=parse_error,
|
||||
source="document_server",
|
||||
|
||||
@@ -202,7 +202,12 @@ async def _ingest_pdf(session, page_slug: str, pdf_url: str) -> bool:
|
||||
import_source="csb_sitemap",
|
||||
edit_url=pdf_url,
|
||||
ai_tags=["Safety/CSB/보고서"],
|
||||
extract_meta={"csb": {"page_slug": page_slug, "kind": "report_pdf"}},
|
||||
# 안전 자료실 A-2 — ingest 시점 deterministic. CSB = 미 연방기관 = public domain.
|
||||
material_type="incident",
|
||||
jurisdiction="US",
|
||||
extract_meta={"csb": {"page_slug": page_slug, "kind": "report_pdf"},
|
||||
"license": {"scheme": "public_domain", "redistribute": True,
|
||||
"attribution": "U.S. Chemical Safety Board"}},
|
||||
)
|
||||
session.add(doc)
|
||||
await session.flush()
|
||||
@@ -290,10 +295,16 @@ async def _ingest_url(session, source: NewsSource, url: str, lastmod: datetime)
|
||||
ai_domain="Safety",
|
||||
ai_sub_group=_SOURCE_NAME,
|
||||
ai_tags=["Safety/CSB"],
|
||||
# 안전 자료실 A-2 — ingest 시점 deterministic (classify-skip 경로)
|
||||
material_type="incident",
|
||||
jurisdiction="US",
|
||||
published_date=lastmod.date() if lastmod else None,
|
||||
extract_meta={
|
||||
"source_id": source.id,
|
||||
"source_name": _SOURCE_NAME,
|
||||
"published_at": lastmod.isoformat(),
|
||||
"license": {"scheme": "public_domain", "redistribute": True,
|
||||
"attribution": "U.S. Chemical Safety Board"},
|
||||
"fulltext": {
|
||||
"status": "csb_sitemap",
|
||||
"engine": engine,
|
||||
|
||||
@@ -54,8 +54,18 @@ class DeepSummaryOutput(BaseModel):
|
||||
confidence: float = 0.5
|
||||
|
||||
|
||||
async def process(document_id: int, session: AsyncSession) -> None:
|
||||
"""deep_summary 큐 pickup → 26B 호출 → 필드 저장."""
|
||||
async def process(
|
||||
document_id: int, session: AsyncSession, *, defer_on_deep_unavailable: bool = False
|
||||
) -> None:
|
||||
"""deep_summary 큐 pickup → LLM 호출 → 필드 저장.
|
||||
|
||||
defer_on_deep_unavailable:
|
||||
False (기본, consumer 경로) = 맥북(deep 슬롯) 우선 시도, 불가 시 즉시
|
||||
맥미니 primary 로 처리. 2026-06-12 fair-share: 양 머신이 동일 모델
|
||||
(Qwen3.6-27B-6bit)이라 폴백 = 품질 강등이 아니라 단순 분배.
|
||||
True (queue_drain 전용) = 맥북 불가를 StageDeferred 로 올려 drain 이
|
||||
보류 후 run 을 멈춘다 (drain = 맥북 분담 전용 레버 시멘틱 유지).
|
||||
"""
|
||||
doc = await session.get(Document, document_id)
|
||||
if not doc:
|
||||
raise ValueError(f"deep_summary: document id={document_id} 없음")
|
||||
@@ -111,16 +121,26 @@ async def process(document_id: int, session: AsyncSession) -> None:
|
||||
try:
|
||||
start = time.perf_counter()
|
||||
if deep_cfg is not None:
|
||||
# 맥북 경유 — 맥미니 mlx gate 미점유(게이트는 맥미니 보호 목적). 맥북 불가
|
||||
# (503/연결/생성 중 sleep 절단)는 StageDeferred = 보류, 맥미니 강등 없음.
|
||||
# doc 쓰기는 완주+파싱 후에만 일어나므로 어느 시점에 끊겨도 부분 쓰기 0.
|
||||
raw = await call_deep_or_defer(client, prompt)
|
||||
# 맥북 우선 — 맥미니 mlx gate 미점유(별 endpoint). doc 쓰기는 완주+파싱
|
||||
# 후에만 일어나므로 어느 시점에 끊겨도 부분 쓰기 0.
|
||||
try:
|
||||
raw = await call_deep_or_defer(client, prompt)
|
||||
except StageDeferred:
|
||||
if defer_on_deep_unavailable:
|
||||
raise # drain 전용 — 맥북 레버 시멘틱 (보류 후 run 종료)
|
||||
# consumer 경로: 동일 모델이라 강등 아님 — 맥미니가 즉시 처리 (2026-06-12)
|
||||
logger.info(
|
||||
f"[deep] id={document_id} 맥북 불가 → 맥미니 primary 처리 (fair-share)"
|
||||
)
|
||||
used_cfg = settings.ai.primary
|
||||
async with acquire_mlx_gate(Priority.BACKGROUND):
|
||||
raw = await client.call_primary(prompt)
|
||||
else:
|
||||
async with acquire_mlx_gate(Priority.BACKGROUND): # 2026-05-17 B-1: classify-escalate worker
|
||||
raw = await client.call_primary(prompt)
|
||||
latency_ms = int((time.perf_counter() - start) * 1000)
|
||||
except StageDeferred:
|
||||
# 보류는 실패가 아님 — analyze_event 미기록(가짜 완료 방지), consumer 가 백오프 기록.
|
||||
# 보류는 실패가 아님 — analyze_event 미기록(가짜 완료 방지), drain 이 백오프 기록.
|
||||
logger.info(f"[deep] id={document_id} 맥북 일시 불가 — 보류 (deferred)")
|
||||
raise
|
||||
except Exception as exc:
|
||||
|
||||
@@ -10,6 +10,7 @@ global_digests / digest_topics 테이블에 저장한다.
|
||||
|
||||
import asyncio
|
||||
|
||||
from core.config import settings
|
||||
from core.utils import setup_logger
|
||||
from services.digest.pipeline import run_digest_pipeline
|
||||
|
||||
@@ -24,6 +25,9 @@ async def run() -> None:
|
||||
pipeline 자체는 timeout 으로 감싸지 않음 (per-call timeout 은 summarizer 가 처리).
|
||||
여기서는 전체 hard cap 만 강제.
|
||||
"""
|
||||
if "digest" in settings.pipeline_held_stages:
|
||||
logger.info("[global_digest] 보류 (pipeline.held_stages) — 이번 실행 skip")
|
||||
return
|
||||
try:
|
||||
result = await asyncio.wait_for(
|
||||
run_digest_pipeline(),
|
||||
|
||||
@@ -58,6 +58,23 @@ SCAN_TARGETS: list[tuple[str, str | None]] = [
|
||||
("Videos", "video"),
|
||||
]
|
||||
|
||||
# 안전 자료실 A-2/B-4 — watch 타깃별 (material_type, jurisdiction, license) deterministic 축.
|
||||
# 키 = 타깃 경로의 마지막 성분. license = extract_meta.license 에 그대로 주입(None=미주입).
|
||||
# restricted=true → retrieval_service._license_sql 가 RAG 증거·digest 에서 제외(a안 U-2① —
|
||||
# 구매자료 verbatim span 차단, 색인 자체는 허용. 개인 파일 열람은 미차단).
|
||||
# 사용자 결정(2026-06-13): Books/Papers=proprietary+restricted / Manuals=proprietary·restricted=false
|
||||
# (검색·RAG 활용) / KGS=법정 위임 상세기준 law/KR·KOGL 공공·restricted 아님.
|
||||
_TARGET_AXIS: dict[str, tuple[str, str | None, dict | None]] = {
|
||||
"KGS_Code": ("law", "KR", {"scheme": "kogl", "redistribute": True,
|
||||
"restricted": False, "attribution": "한국가스안전공사(KGS)"}),
|
||||
"Books": ("book", None, {"scheme": "proprietary", "redistribute": False,
|
||||
"restricted": True, "attribution": "구매 도서"}),
|
||||
"Papers_Purchased": ("paper", None, {"scheme": "proprietary", "redistribute": False,
|
||||
"restricted": True, "attribution": "구매 논문"}),
|
||||
"Manuals": ("manual", None, {"scheme": "proprietary", "redistribute": False,
|
||||
"restricted": False, "attribution": "기술 매뉴얼"}),
|
||||
}
|
||||
|
||||
|
||||
def should_skip(path: Path) -> bool:
|
||||
if path.name in SKIP_NAMES or path.name.startswith("._"):
|
||||
@@ -242,6 +259,11 @@ async def watch_inbox():
|
||||
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)
|
||||
)
|
||||
|
||||
for file_path in scan_root.rglob("*"):
|
||||
if not file_path.is_file() or should_skip(file_path):
|
||||
continue
|
||||
@@ -275,7 +297,14 @@ async def watch_inbox():
|
||||
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()
|
||||
|
||||
@@ -291,6 +320,15 @@ async def watch_inbox():
|
||||
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, existing.id, next_stage)
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
"""C-2 KOSHA Open API 수집 워커 (plan crawl-24x7-1).
|
||||
|
||||
3 API (2026-06-10 실키 live 검증 + fixture 박제 — tests/fixtures/kosha_*_response.json):
|
||||
4 API (2026-06-10/06-13 실키 live 검증 + fixture 박제 — tests/fixtures/kosha_*_response.json):
|
||||
재해사례 게시판: GET /B552468/disaster_api02/getdisaster_api02 callApiId=1060
|
||||
재해사례 첨부: GET /B552468/disaster_attach_api02/Disaster_attach_api02 callApiId=1070
|
||||
KOSHA GUIDE: GET /B552468/koshaguide/getKoshaGuide callApiId=1050
|
||||
사망사고 속보: GET /B552468/news_api02/getNews_api02 callApiId=1040
|
||||
|
||||
daily 스케줄 1회 (main.py):
|
||||
재해사례 = 최근 페이지만 diff (boardno dedup) — 사례 본문 Document(텍스트 네이티브)
|
||||
+ 첨부 PDF/HWP 다운로드 → /documents/crawl_raw/kosha/{boardno}/ 저장
|
||||
→ 파일 Document + extract enqueue (kordoc HWP/PDF 기존 파이프라인 재사용).
|
||||
사망사고 = 최근 페이지만 diff (arno dedup) — 속보 본문 Document(HTML → _clean_html).
|
||||
첨부 API 없음·business 필드 없음. 등록일 = arno 접두 8자리(YYYYMMDD).
|
||||
GUIDE = 전체 레지스트리 메타 diff (1039건, 100/page = 11 call) → 신규/개정만,
|
||||
일일 ingest cap(기본 25) = backlog 자동 점진 백필(~6주) + 부하 평탄화.
|
||||
cap 으로 미처리 잔량은 매회 로그 (silent cap 금지).
|
||||
@@ -23,7 +26,7 @@ import hashlib
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from datetime import date, datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
@@ -38,6 +41,7 @@ from models.news_source import NewsSource
|
||||
from models.queue import enqueue_stage
|
||||
from workers.news_collector import (
|
||||
FeedError,
|
||||
_clean_html,
|
||||
_get_or_create_health,
|
||||
_record_failure,
|
||||
_record_success,
|
||||
@@ -49,17 +53,36 @@ _BASE = "https://apis.data.go.kr/B552468"
|
||||
_BOARD_EP = f"{_BASE}/disaster_api02/getdisaster_api02"
|
||||
_ATTACH_EP = f"{_BASE}/disaster_attach_api02/Disaster_attach_api02"
|
||||
_GUIDE_EP = f"{_BASE}/koshaguide/getKoshaGuide"
|
||||
_FATAL_EP = f"{_BASE}/news_api02/getNews_api02"
|
||||
|
||||
_CASE_SOURCE = "KOSHA 재해사례"
|
||||
_GUIDE_SOURCE = "KOSHA GUIDE"
|
||||
_FATAL_SOURCE = "KOSHA 사망사고"
|
||||
|
||||
_CASE_PAGES = 2 # daily diff 범위 (30×2 = 최근 60건 — 등록일 역순 API)
|
||||
_CASE_ROWS = 30
|
||||
_FATAL_PAGES = 2 # 사망사고 속보 daily diff (30×2 = 최근 60건 — 등록일 역순)
|
||||
_FATAL_ROWS = 30
|
||||
_GUIDE_ROWS = 100
|
||||
_GUIDE_DAILY_CAP = int(os.getenv("KOSHA_GUIDE_DAILY_CAP", "25"))
|
||||
_MAX_FILE_BYTES = 50 * 1024 * 1024
|
||||
_DOWNLOAD_DELAY = (2.0, 5.0) # portal.kosha.or.kr 파일서버 — 연속 다운로드 간격
|
||||
|
||||
# 안전 자료실 A-2 — KOSHA 산출물 라이선스 (KOGL 유형 미확정 → 보수적 redistribute=False,
|
||||
# 근거 확보 시 완화. 0-3 license 메타 deterministic 주입).
|
||||
_KOSHA_LICENSE = {"scheme": "kogl", "redistribute": False, "attribution": "한국산업안전보건공단(KOSHA)"}
|
||||
|
||||
|
||||
def _ymd_to_date(ymd: str | None) -> date | None:
|
||||
"""'YYYYMMDD'/'YYYY-MM-DD' → date. 형식 불일치는 None (fail-quiet — 날짜는 보조 축)."""
|
||||
digits = re.sub(r"\D", "", ymd or "")
|
||||
if len(digits) != 8:
|
||||
return None
|
||||
try:
|
||||
return date(int(digits[:4]), int(digits[4:6]), int(digits[6:8]))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _api_key() -> str:
|
||||
key = os.getenv("KOSHA_API_KEY", "")
|
||||
@@ -93,6 +116,29 @@ def _items(payload: dict) -> list[dict]:
|
||||
return [item] if isinstance(item, dict) else list(item)
|
||||
|
||||
|
||||
def _fatal_fields(item: dict) -> dict | None:
|
||||
"""사망사고 item(arno/keyword/contents 3필드 고정) → Document 필드 매핑.
|
||||
|
||||
순수 함수(httpx/DB 불요 — fixture 단위 테스트 대상). 필수 = arno+keyword,
|
||||
부재 시 None(skip). 날짜 전용 필드가 없어 등록 식별자 arno 접두에서 유도:
|
||||
arno = 'YYYYMMDDHHMMSS' + 임의 6자 (2019~ 라이브 전수 동형 검증). 접두 8자리=KST
|
||||
등록일 → published_date, 14자리=등록시각 → reg_dt(원문 그대로, tz 해석 미주장).
|
||||
"""
|
||||
arno = str(item.get("arno") or "").strip()
|
||||
title = (item.get("keyword") or "").strip()
|
||||
if not arno or not title:
|
||||
return None
|
||||
text = _clean_html(item.get("contents") or "", max_len=None)
|
||||
reg_dt = arno[:14] if re.fullmatch(r"\d{14}", arno[:14]) else None
|
||||
return {
|
||||
"arno": arno,
|
||||
"title": title,
|
||||
"text": text,
|
||||
"published_date": _ymd_to_date(arno[:8]),
|
||||
"reg_dt": reg_dt,
|
||||
}
|
||||
|
||||
|
||||
def _safe_filename(name: str) -> str:
|
||||
"""NAS 파일명 정화 — 경로분리자/제어문자/공백연쇄 제거 (쉘 함정 회피)."""
|
||||
name = re.sub(r"[/\\\x00-\x1f]", "_", name).strip()
|
||||
@@ -155,7 +201,11 @@ async def _ingest_attachment(session, boardno: str, filenm: str, filepath: str)
|
||||
import_source="kosha_api",
|
||||
edit_url=filepath,
|
||||
ai_tags=["Safety/KOSHA재해사례/첨부"],
|
||||
extract_meta={"kosha": {"boardno": boardno, "kind": "case_attachment"}},
|
||||
# 안전 자료실 A-2 — ingest 시점 deterministic (classify 경유해도 LLM 비의존)
|
||||
material_type="incident",
|
||||
jurisdiction="KR",
|
||||
extract_meta={"kosha": {"boardno": boardno, "kind": "case_attachment"},
|
||||
"license": dict(_KOSHA_LICENSE)},
|
||||
)
|
||||
session.add(doc)
|
||||
await session.flush()
|
||||
@@ -213,12 +263,16 @@ async def collect_disaster_cases(session) -> int:
|
||||
ai_domain="Safety",
|
||||
ai_sub_group=_CASE_SOURCE,
|
||||
ai_tags=[f"Safety/KOSHA재해사례/{business or '기타'}"],
|
||||
# 안전 자료실 A-2 — ingest 시점 deterministic (classify-skip 경로)
|
||||
material_type="incident",
|
||||
jurisdiction="KR",
|
||||
extract_meta={
|
||||
"source_id": source.id,
|
||||
"source_name": _CASE_SOURCE,
|
||||
"published_at": None,
|
||||
"kosha": {"boardno": boardno, "business": business,
|
||||
"atcflcnt": item.get("atcflcnt")},
|
||||
"license": dict(_KOSHA_LICENSE),
|
||||
},
|
||||
)
|
||||
session.add(doc)
|
||||
@@ -250,6 +304,83 @@ async def collect_disaster_cases(session) -> int:
|
||||
return new_count
|
||||
|
||||
|
||||
async def collect_fatal_accidents(session) -> int:
|
||||
"""사망사고 속보 daily diff — 최근 _FATAL_PAGES 페이지, arno dedup.
|
||||
|
||||
재해사례(1060)와 별 채널(1040): business 필드·첨부 API 없음, contents=HTML.
|
||||
본문 = 텍스트 네이티브(_clean_html) → md 변환 비대상, summarize/embed/chunk 큐.
|
||||
"""
|
||||
key = _api_key()
|
||||
source = await _get_or_create_source(session, _FATAL_SOURCE, _FATAL_EP)
|
||||
new_count = 0
|
||||
|
||||
for page in range(1, _FATAL_PAGES + 1):
|
||||
payload = await _api_get(
|
||||
f"{_FATAL_EP}?serviceKey={key}&callApiId=1040&pageNo={page}&numOfRows={_FATAL_ROWS}"
|
||||
)
|
||||
items = _items(payload)
|
||||
if not items:
|
||||
break
|
||||
page_all_dup = True
|
||||
for item in items:
|
||||
fields = _fatal_fields(item)
|
||||
if fields is None:
|
||||
continue
|
||||
arno = fields["arno"]
|
||||
fhash = hashlib.sha256(f"kosha-fatal|{arno}".encode()).hexdigest()[:32]
|
||||
existing = await session.execute(
|
||||
select(Document).where(Document.file_hash == fhash).limit(1)
|
||||
)
|
||||
if existing.scalars().first():
|
||||
continue
|
||||
page_all_dup = False
|
||||
|
||||
text = fields["text"]
|
||||
now = datetime.now(timezone.utc)
|
||||
doc = Document(
|
||||
file_path=f"crawl/{_FATAL_SOURCE}/{arno}",
|
||||
file_hash=fhash,
|
||||
file_format="article",
|
||||
file_size=len(text.encode()),
|
||||
file_type="note",
|
||||
title=fields["title"],
|
||||
extracted_text=f"{fields['title']}\n\n{text}",
|
||||
extracted_at=now,
|
||||
extractor_version="kosha_api",
|
||||
md_status="skipped",
|
||||
md_extraction_error="kosha fatal: 텍스트 네이티브, markdown 변환 비대상",
|
||||
source_channel="crawl",
|
||||
data_origin="external",
|
||||
review_status="approved",
|
||||
ai_domain="Safety",
|
||||
ai_sub_group=_FATAL_SOURCE,
|
||||
ai_tags=["Safety/KOSHA사망사고"],
|
||||
# 안전 자료실 A-2 — ingest 시점 deterministic (classify-skip 경로)
|
||||
material_type="incident",
|
||||
jurisdiction="KR",
|
||||
published_date=fields["published_date"],
|
||||
extract_meta={
|
||||
"source_id": source.id,
|
||||
"source_name": _FATAL_SOURCE,
|
||||
"published_at": None,
|
||||
"kosha": {"arno": arno, "kind": "fatal_accident",
|
||||
"reg_dt": fields["reg_dt"]},
|
||||
"license": dict(_KOSHA_LICENSE),
|
||||
},
|
||||
)
|
||||
session.add(doc)
|
||||
await session.flush()
|
||||
await enqueue_stage(session, doc.id, "summarize")
|
||||
await enqueue_stage(session, doc.id, "embed")
|
||||
await enqueue_stage(session, doc.id, "chunk")
|
||||
new_count += 1
|
||||
if page_all_dup:
|
||||
break # 등록일 역순 — 페이지 전체가 기존이면 이후 페이지도 기존
|
||||
|
||||
logger.info(f"[kosha] 사망사고 신규 {new_count}건")
|
||||
return new_count
|
||||
|
||||
|
||||
async def collect_kosha_guide(session, cap: int = _GUIDE_DAILY_CAP) -> int:
|
||||
"""GUIDE 레지스트리 전체 메타 diff → 신규/개정만 다운로드 (일일 cap 점진 백필)."""
|
||||
key = _api_key()
|
||||
@@ -307,8 +438,13 @@ async def collect_kosha_guide(session, cap: int = _GUIDE_DAILY_CAP) -> int:
|
||||
import_source="kosha_api",
|
||||
edit_url=spec["url"],
|
||||
ai_tags=["Safety/KOSHA GUIDE"],
|
||||
# 안전 자료실 A-2 — GUIDE = 구속력 없는 권고 기술지침 (law 아님, plan 0-1)
|
||||
material_type="guide",
|
||||
jurisdiction="KR",
|
||||
published_date=_ymd_to_date(spec["ymd"]),
|
||||
extract_meta={"kosha": {"kind": "guide", "techGdlnNo": spec["no"],
|
||||
"ofancYmd": spec["ymd"]}},
|
||||
"ofancYmd": spec["ymd"]},
|
||||
"license": dict(_KOSHA_LICENSE)},
|
||||
)
|
||||
session.add(doc)
|
||||
await session.flush()
|
||||
@@ -325,6 +461,7 @@ async def run() -> None:
|
||||
"""daily 1회 — 소스별 실패 격리 (재해사례 실패가 GUIDE 를 막지 않게)."""
|
||||
now = datetime.now(timezone.utc)
|
||||
for name, collector in ((_CASE_SOURCE, collect_disaster_cases),
|
||||
(_FATAL_SOURCE, collect_fatal_accidents),
|
||||
(_GUIDE_SOURCE, collect_kosha_guide)):
|
||||
async with async_session() as session:
|
||||
result = await session.execute(select(NewsSource).where(NewsSource.name == name))
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from datetime import date, datetime, timezone
|
||||
from pathlib import Path
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
@@ -262,6 +262,16 @@ async def _save_law_split(
|
||||
f"개정구분: {revision_type}"
|
||||
)
|
||||
|
||||
# 안전 자료실 A-2 — 공포일 파싱 (law published_date = COALESCE(시행일, 공포일) 계약,
|
||||
# 본 레거시 워커는 공포일만 보유 — 시행일 기반 버전 체인은 B-1 statute_collector 소관)
|
||||
_digits = re.sub(r"\D", "", str(proclamation_date or ""))
|
||||
pub_date = None
|
||||
if len(_digits) == 8:
|
||||
try:
|
||||
pub_date = date(int(_digits[:4]), int(_digits[4:6]), int(_digits[6:8]))
|
||||
except ValueError:
|
||||
pub_date = None
|
||||
|
||||
doc = Document(
|
||||
file_path=rel_path,
|
||||
file_hash=file_hash(file_path),
|
||||
@@ -272,6 +282,13 @@ async def _save_law_split(
|
||||
source_channel="law_monitor",
|
||||
data_origin="work",
|
||||
category="law",
|
||||
# 안전 자료실 A-2 — ingest 시점 deterministic. 법령 텍스트 = 저작권법 제7조
|
||||
# 비보호 저작물 (public domain). 본 워커는 휴면(LAW_OC 미설정)이나 코드 경로 유지.
|
||||
material_type="law",
|
||||
jurisdiction="KR",
|
||||
published_date=pub_date,
|
||||
extract_meta={"license": {"scheme": "public_domain", "redistribute": True,
|
||||
"attribution": "국가법령정보센터"}},
|
||||
user_note=note or None,
|
||||
)
|
||||
session.add(doc)
|
||||
|
||||
@@ -341,11 +341,35 @@ def _entry_body(source: NewsSource, entry, summary: str) -> tuple[str, str]:
|
||||
|
||||
def _build_extract_meta(source: NewsSource, pub_dt: datetime) -> dict:
|
||||
"""fulltext_worker / 패널이 쓰는 출처 메타 (documents 에 source FK 가 없어 여기 기록)."""
|
||||
return {
|
||||
meta = {
|
||||
"source_id": source.id,
|
||||
"source_name": source.name,
|
||||
"published_at": pub_dt.isoformat(),
|
||||
}
|
||||
# 안전 자료실 A-2: 소스 레지스트리의 라이선스를 deterministic 주입 (0-3 license 메타).
|
||||
# P3 다이제스트/발행류가 redistribute=false 소스를 구조적으로 제외하는 게이트 입력.
|
||||
if source.license_scheme:
|
||||
meta["license"] = {
|
||||
"scheme": source.license_scheme,
|
||||
"redistribute": bool(source.license_redistribute),
|
||||
"attribution": source.name,
|
||||
}
|
||||
return meta
|
||||
|
||||
|
||||
def _material_axis(source: NewsSource) -> tuple[str | None, str | None]:
|
||||
"""안전 자료실 분류 축 (material_type, jurisdiction) — 레지스트리 deterministic.
|
||||
|
||||
- material_type = news_sources.material_type (NULL = 비대상, 뉴스/철학 등)
|
||||
- jurisdiction = source.country 전파. 단 paper 는 NULL 강제
|
||||
(국제 학술지에 관할 개념 부적합 — plan 0-1 계약. 레지스트리 country=US 여도 미전파).
|
||||
"""
|
||||
mt = source.material_type
|
||||
if not mt:
|
||||
return None, None
|
||||
if mt == "paper":
|
||||
return mt, None
|
||||
return mt, source.country
|
||||
|
||||
|
||||
def _doc_identity(source: NewsSource, source_short: str, category: str) -> dict:
|
||||
@@ -354,17 +378,22 @@ def _doc_identity(source: NewsSource, source_short: str, category: str) -> dict:
|
||||
file_path 접두사가 곧 채널 디렉토리. ai_domain 은 다이제스트/검색 필터의 분기 축이라
|
||||
crawl 채널이 'News' 를 오염시키지 않게 분리 (0-5 채널 레벨 분리 사상).
|
||||
"""
|
||||
material_type, jurisdiction = _material_axis(source)
|
||||
if source.source_channel == "crawl":
|
||||
domain = category if category and category != "Other" else "Domain"
|
||||
return {
|
||||
"path_prefix": "crawl",
|
||||
"ai_domain": domain,
|
||||
"ai_tags": [f"{domain}/{source_short}"],
|
||||
"material_type": material_type,
|
||||
"jurisdiction": jurisdiction,
|
||||
}
|
||||
return {
|
||||
"path_prefix": "news",
|
||||
"ai_domain": "News",
|
||||
"ai_tags": [f"News/{source_short}/{category}"],
|
||||
"material_type": material_type,
|
||||
"jurisdiction": jurisdiction,
|
||||
}
|
||||
|
||||
|
||||
@@ -528,6 +557,10 @@ async def _fetch_rss(session, source: NewsSource) -> tuple[int, str]:
|
||||
ai_domain=ident["ai_domain"],
|
||||
ai_sub_group=source_short,
|
||||
ai_tags=ident["ai_tags"],
|
||||
# 안전 자료실 A-2 — 레지스트리 deterministic (classify-skip 경로라 ingest 시점 필수)
|
||||
material_type=ident["material_type"],
|
||||
jurisdiction=ident["jurisdiction"],
|
||||
published_date=pub_dt.date() if pub_dt else None,
|
||||
extract_meta=_build_extract_meta(source, pub_dt),
|
||||
)
|
||||
session.add(doc)
|
||||
@@ -661,6 +694,10 @@ async def _fetch_api_guardian(session, source: NewsSource) -> tuple[int, str]:
|
||||
ai_domain=ident["ai_domain"],
|
||||
ai_sub_group=source_short,
|
||||
ai_tags=ident["ai_tags"],
|
||||
# 안전 자료실 A-2 — 레지스트리 deterministic (classify-skip 경로라 ingest 시점 필수)
|
||||
material_type=ident["material_type"],
|
||||
jurisdiction=ident["jurisdiction"],
|
||||
published_date=pub_dt.date() if pub_dt else None,
|
||||
extract_meta=_build_extract_meta(source, pub_dt),
|
||||
)
|
||||
session.add(doc)
|
||||
@@ -757,6 +794,10 @@ async def _fetch_api_nyt(session, source: NewsSource) -> tuple[int, str]:
|
||||
ai_domain=ident["ai_domain"],
|
||||
ai_sub_group=source_short,
|
||||
ai_tags=ident["ai_tags"],
|
||||
# 안전 자료실 A-2 — 레지스트리 deterministic (classify-skip 경로라 ingest 시점 필수)
|
||||
material_type=ident["material_type"],
|
||||
jurisdiction=ident["jurisdiction"],
|
||||
published_date=pub_dt.date() if pub_dt else None,
|
||||
extract_meta=_build_extract_meta(source, pub_dt),
|
||||
)
|
||||
session.add(doc)
|
||||
|
||||
@@ -0,0 +1,393 @@
|
||||
"""OpenAlex 백본 수집기 — B-3 PR3 (plan safety-library-b3-1).
|
||||
|
||||
OpenAlex = 발견+dedup 글로벌 백본(JP/EU/US 논문 다 색인 + 정본 DOI). 전문은 안 줌(oa_url 포인터만).
|
||||
- scaffold-first: OPENALEX_API_KEY 부재 시 FeedError(explicit-skip, silent fallback 금지). 키=무료.
|
||||
- signal-only: 초록(inverted-index 복원)만 색인(embed+chunk), summarize 절대 미enqueue(맥미니 큐 무접촉).
|
||||
PDF 는 절대 OpenAlex 경유로 안 받음(oa_url 은 링크/신호일 뿐).
|
||||
- 관련성 사전필터 = title_and_abstract.search 키워드(서버측) + per-run insert cap(임베드 firehose 차단,
|
||||
적대리뷰 A major). cursor 페이징 + from_publication_date 워터마크로 증분.
|
||||
- 초록 없는 thin 레코드(주로 비-OA 메타)는 skip — Phase-1 재료 품질 유지.
|
||||
- DOI → paper.doi(holder, partial-unique 인덱스, 교차소스 dedup). 없으면 openalex id fallback.
|
||||
- license: 명시 CC → redistribute=true / 그 외 OA·closed → false(restricted 부재 = 초록 RAG 사용 가능).
|
||||
- enabled=False news_sources 행 + main.py CronTrigger(자체 폴링). list+filter 비용 미미($1/일 크레딧).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime, timezone
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import select
|
||||
|
||||
from core.crawl_politeness import CRAWL_UA
|
||||
from core.database import async_session
|
||||
from core.utils import setup_logger
|
||||
from models.document import Document
|
||||
from models.news_source import NewsSource
|
||||
from models.queue import enqueue_stage
|
||||
from services.papers.doi import normalize_doi
|
||||
from services.papers.holder import find_paper_holder
|
||||
from workers.news_collector import (
|
||||
FeedError,
|
||||
_get_or_create_health,
|
||||
_record_failure,
|
||||
_record_success,
|
||||
)
|
||||
|
||||
logger = setup_logger("openalex_collector")
|
||||
|
||||
_API = "https://api.openalex.org/works"
|
||||
_SOURCE_NAME = "OpenAlex 안전·공학 (keyword)"
|
||||
_ENV_KEY = "OPENALEX_API_KEY"
|
||||
|
||||
# 압력용기·공정안전·구조건전성 도메인 키워드(키워드별 1쿼리 = 관련성 사전필터).
|
||||
_KEYWORDS = (
|
||||
"pressure vessel safety",
|
||||
"process safety",
|
||||
"structural integrity",
|
||||
"fracture mechanics",
|
||||
"fatigue life assessment",
|
||||
)
|
||||
|
||||
# 도메인 직결 저널 ISSN 시드(OpenAlex sources 실측 확인) — 키워드 매칭 누락분까지 전수 커버.
|
||||
# KR 안전/가스/기계 + JP 고압. KR/JP 관심 = OpenAlex 깨끗한 API 로 직접(KoreaScience/J-STAGE 전용
|
||||
# 스크래퍼 불요 — Phase-1 메타는 OpenAlex 와 중복, 전용 수집기의 유니크 가치=무료 전문 PDF=Phase-2).
|
||||
_JOURNAL_ISSNS = (
|
||||
("한국안전학회지", "1738-3803"),
|
||||
("한국가스학회지", "1226-8402"),
|
||||
("대한기계학회논문집 A", "1226-4873"),
|
||||
("대한기계학회논문집 B", "1226-4881"),
|
||||
("KSME International J.", "1226-4865"),
|
||||
("Review of High Pressure Sci&Tech (JP)", "0917-639X"),
|
||||
)
|
||||
|
||||
_RUN_CAP = 60 # 1회 run 신규 적재 상한(임베드 큐 보호). bulk 시 해제.
|
||||
_PER_PAGE = 50
|
||||
_MAX_PAGES_PER_KW = 4 # 키워드당 최대 페이지(증분이라 보통 1페이지에 워터마크 도달)
|
||||
_REQ_SLEEP = 1.0 # 페이지 간 polite 간격
|
||||
_MAX_RETRY = 4
|
||||
_BACKOFF_BASE = 5.0
|
||||
|
||||
|
||||
# ───────────────────────── 순수 파서 (fixture 단위 테스트 대상) ─────────────────────────
|
||||
|
||||
@dataclass
|
||||
class OpenAlexWork:
|
||||
openalex_id: str # "W2910511816"
|
||||
doi: str | None # normalize_doi 적용
|
||||
title: str
|
||||
abstract: str # inverted-index 복원 (없으면 "")
|
||||
publication_date: str | None
|
||||
oa_status: str | None # closed/green/bronze/hybrid/gold/diamond
|
||||
oa_url: str | None
|
||||
is_oa: bool
|
||||
license: str | None # cc-by / cc-by-nc-nd / None
|
||||
source_name: str | None
|
||||
primary_topic: str | None
|
||||
work_type: str | None
|
||||
|
||||
|
||||
def _clean(text):
|
||||
return " ".join(text.split()).strip() if text else ""
|
||||
|
||||
|
||||
def _reconstruct_abstract(inv: dict | None) -> str:
|
||||
"""abstract_inverted_index({word:[positions]}) → 평문 초록. 없으면 ''."""
|
||||
if not inv:
|
||||
return ""
|
||||
positions = [(pos, word) for word, idxs in inv.items() for pos in idxs]
|
||||
positions.sort()
|
||||
return " ".join(w for _, w in positions)
|
||||
|
||||
|
||||
def license_meta(license_str: str | None, is_oa: bool, source_name: str | None) -> dict:
|
||||
"""extract_meta.license — 명시 CC/public-domain 만 redistribute=true. restricted 부재(초록 색인 자유).
|
||||
|
||||
redistribute=false 라도 restricted 가 없으면 RAG 사용 가능(초록). 비-CC 전문의 RAG verbatim 차단은
|
||||
Phase-2 전문 승격 단계가 restricted=true 로 처리(L-1) — Phase-1(초록)은 무해.
|
||||
"""
|
||||
attribution = source_name or "OpenAlex"
|
||||
if license_str and (license_str.startswith("cc") or license_str == "public-domain"):
|
||||
return {"scheme": license_str, "redistribute": True, "attribution": attribution}
|
||||
return {
|
||||
"scheme": "open-unspecified" if is_oa else "proprietary",
|
||||
"redistribute": False,
|
||||
"attribution": attribution,
|
||||
}
|
||||
|
||||
|
||||
def parse_openalex_works(json_text: str) -> tuple[int, str | None, list[OpenAlexWork]]:
|
||||
"""OpenAlex /works 응답 → (count, next_cursor, [OpenAlexWork]). 순수 함수."""
|
||||
d = json.loads(json_text)
|
||||
meta = d.get("meta") or {}
|
||||
count = meta.get("count") or 0
|
||||
next_cursor = meta.get("next_cursor")
|
||||
works: list[OpenAlexWork] = []
|
||||
for w in d.get("results") or []:
|
||||
oid = (w.get("id") or "").rstrip("/").rsplit("/", 1)[-1]
|
||||
if not oid:
|
||||
continue
|
||||
oa = w.get("open_access") or {}
|
||||
pl = w.get("primary_location") or {}
|
||||
pt = w.get("primary_topic") or {}
|
||||
works.append(OpenAlexWork(
|
||||
openalex_id=oid,
|
||||
doi=normalize_doi(w.get("doi")),
|
||||
title=_clean(w.get("title")),
|
||||
abstract=_reconstruct_abstract(w.get("abstract_inverted_index")),
|
||||
publication_date=w.get("publication_date"),
|
||||
oa_status=oa.get("oa_status"),
|
||||
oa_url=oa.get("oa_url") or None,
|
||||
is_oa=bool(oa.get("is_oa")),
|
||||
license=pl.get("license"),
|
||||
source_name=(pl.get("source") or {}).get("display_name"),
|
||||
primary_topic=pt.get("display_name"),
|
||||
work_type=w.get("type"),
|
||||
))
|
||||
return count, next_cursor, works
|
||||
|
||||
|
||||
def build_filter(keyword: str, from_date: str | None = None) -> str:
|
||||
f = f"title_and_abstract.search:{keyword}"
|
||||
if from_date:
|
||||
f += f",from_publication_date:{from_date}"
|
||||
return f
|
||||
|
||||
|
||||
def build_issn_filter(issn: str, from_date: str | None = None) -> str:
|
||||
f = f"primary_location.source.issn:{issn}"
|
||||
if from_date:
|
||||
f += f",from_publication_date:{from_date}"
|
||||
return f
|
||||
|
||||
|
||||
def _seeds() -> list[tuple[str, str, str]]:
|
||||
"""수집 시드 = (라벨, 워터마크키, 종류). 도메인 저널 ISSN 우선(cap 우선권) → 키워드."""
|
||||
s: list[tuple[str, str, str]] = [(label, issn, "issn") for label, issn in _JOURNAL_ISSNS]
|
||||
s += [(kw, kw, "kw") for kw in _KEYWORDS]
|
||||
return s
|
||||
|
||||
|
||||
# ───────────────────────── 적재 (DB — PR3 라이브 검증) ─────────────────────────
|
||||
|
||||
def _build_paper_meta(source: NewsSource, w: OpenAlexWork) -> dict:
|
||||
paper: dict = {"openalex_id": w.openalex_id}
|
||||
if w.doi:
|
||||
paper["doi"] = w.doi # partial-unique 인덱스 진입(교차소스 dedup)
|
||||
if w.oa_status:
|
||||
paper["oa_status"] = w.oa_status
|
||||
if w.oa_url:
|
||||
paper["oa_url"] = w.oa_url # 링크/신호 — 자동 fetch 안 함
|
||||
if w.primary_topic:
|
||||
paper["topic"] = w.primary_topic
|
||||
meta: dict = {
|
||||
"source_id": source.id,
|
||||
"source_name": source.name,
|
||||
"source_region": "INT", # OpenAlex = 글로벌. paper.jurisdiction 은 NULL 유지(A-2).
|
||||
"paper": paper,
|
||||
"license": license_meta(w.license, w.is_oa, w.source_name),
|
||||
}
|
||||
if w.publication_date:
|
||||
meta["published_at"] = w.publication_date
|
||||
return meta
|
||||
|
||||
|
||||
async def _ingest_work(session, source: NewsSource, w: OpenAlexWork) -> bool:
|
||||
"""1건 적재. 반환 = 신규 여부. signal-only. 초록 없으면 skip(thin 레코드 배제)."""
|
||||
if not w.abstract:
|
||||
return False # 초록 없는 thin 레코드(주로 비-OA 메타) — Phase-1 재료 품질 유지
|
||||
oid_hash = hashlib.sha256(f"openalex|{w.openalex_id}".encode()).hexdigest()[:32]
|
||||
dup = await session.execute(
|
||||
select(Document.id).where(Document.file_hash == oid_hash).limit(1)
|
||||
)
|
||||
if dup.scalars().first():
|
||||
return False
|
||||
if w.doi and await find_paper_holder(session, w.doi):
|
||||
return False # 교차소스 dedup(arXiv 등이 이미 holder 보유)
|
||||
|
||||
pub_date = None
|
||||
if w.publication_date:
|
||||
try:
|
||||
pub_date = date.fromisoformat(w.publication_date)
|
||||
except ValueError:
|
||||
pub_date = None
|
||||
body = w.abstract
|
||||
doc = Document(
|
||||
file_path=f"crawl/openalex/{w.openalex_id}",
|
||||
file_hash=oid_hash,
|
||||
file_format="article",
|
||||
file_size=len(body.encode()),
|
||||
file_type="note",
|
||||
title=w.title,
|
||||
extracted_text=f"{w.title}\n\n{body}",
|
||||
extracted_at=datetime.now(timezone.utc),
|
||||
extractor_version="openalex-signal",
|
||||
md_status="skipped",
|
||||
md_extraction_error="OpenAlex abstract: signal-only, markdown 비대상",
|
||||
source_channel="crawl",
|
||||
data_origin="external",
|
||||
edit_url=w.oa_url or f"https://openalex.org/{w.openalex_id}",
|
||||
review_status="approved",
|
||||
material_type="paper",
|
||||
jurisdiction=None,
|
||||
published_date=pub_date,
|
||||
extract_meta=_build_paper_meta(source, w),
|
||||
)
|
||||
session.add(doc)
|
||||
await session.flush()
|
||||
await enqueue_stage(session, doc.id, "embed")
|
||||
await enqueue_stage(session, doc.id, "chunk")
|
||||
return True
|
||||
|
||||
|
||||
async def _get_or_create_source(session) -> NewsSource:
|
||||
result = await session.execute(
|
||||
select(NewsSource).where(NewsSource.name == _SOURCE_NAME)
|
||||
)
|
||||
source = result.scalars().first()
|
||||
if source is None:
|
||||
source = NewsSource(
|
||||
name=_SOURCE_NAME, feed_url=_API, feed_type="json",
|
||||
fetch_method="signal-only", fulltext_policy="none",
|
||||
source_channel="crawl", category="Engineering", language="en",
|
||||
country=None, material_type="paper",
|
||||
license_scheme="openalex", license_redistribute=False,
|
||||
enabled=False,
|
||||
)
|
||||
session.add(source)
|
||||
await session.flush()
|
||||
return source
|
||||
|
||||
|
||||
def _api_key() -> str:
|
||||
key = os.getenv(_ENV_KEY, "").strip()
|
||||
if not key:
|
||||
raise FeedError(f"{_ENV_KEY} 미설정 — OpenAlex 수집 불가 (scaffold-first explicit-skip)")
|
||||
return key
|
||||
|
||||
|
||||
def _watermark(source: NewsSource, keyword: str) -> str | None:
|
||||
return (source.selector_override or {}).get("openalex_watermark", {}).get(keyword)
|
||||
|
||||
|
||||
def _set_watermark(source: NewsSource, keyword: str, value: str) -> None:
|
||||
cfg = dict(source.selector_override or {})
|
||||
wm = dict(cfg.get("openalex_watermark") or {})
|
||||
wm[keyword] = value
|
||||
cfg["openalex_watermark"] = wm
|
||||
source.selector_override = cfg
|
||||
|
||||
|
||||
async def _fetch(client: httpx.AsyncClient, key: str, filter_str: str, cursor: str) -> str:
|
||||
params = {
|
||||
"filter": filter_str, "per-page": _PER_PAGE, "cursor": cursor,
|
||||
"sort": "publication_date:desc", "api_key": key,
|
||||
}
|
||||
for attempt in range(_MAX_RETRY):
|
||||
resp = await client.get(_API, params=params)
|
||||
if resp.status_code == 429:
|
||||
await asyncio.sleep(_BACKOFF_BASE * (2 ** attempt))
|
||||
continue
|
||||
resp.raise_for_status()
|
||||
return resp.text
|
||||
raise FeedError(f"OpenAlex 429 재시도 초과: {filter_str[:48]}")
|
||||
|
||||
|
||||
async def run(bulk: bool = False, limit: int = 0) -> None:
|
||||
"""daily 진입점(스케줄러). 키 부재 = explicit-skip(health 실패 기록)."""
|
||||
now = datetime.now(timezone.utc)
|
||||
async with async_session() as session:
|
||||
source = await _get_or_create_source(session)
|
||||
await session.commit()
|
||||
source_id = source.id
|
||||
|
||||
try:
|
||||
key = _api_key()
|
||||
except FeedError as e:
|
||||
logger.warning(f"[openalex] {e}")
|
||||
async with async_session() as session:
|
||||
health = await _get_or_create_health(session, source_id)
|
||||
_record_failure(health, str(e), now)
|
||||
await session.commit()
|
||||
return
|
||||
|
||||
run_cap = (limit or 10**9) if bulk else (min(limit, _RUN_CAP) if limit else _RUN_CAP)
|
||||
inserted = 0
|
||||
seen = 0
|
||||
failures: list[str] = []
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
timeout=30.0, headers={"User-Agent": CRAWL_UA}, follow_redirects=True
|
||||
) as client:
|
||||
for label, wm_key, kind in _seeds():
|
||||
if inserted >= run_cap:
|
||||
break
|
||||
async with async_session() as session:
|
||||
src = await session.get(NewsSource, source_id)
|
||||
watermark = None if bulk else _watermark(src, wm_key)
|
||||
filter_str = (build_issn_filter(wm_key, watermark) if kind == "issn"
|
||||
else build_filter(wm_key, watermark))
|
||||
newest: str | None = None
|
||||
cursor = "*"
|
||||
max_pages = (10**6 if bulk else _MAX_PAGES_PER_KW)
|
||||
try:
|
||||
for _page in range(max_pages):
|
||||
if inserted >= run_cap:
|
||||
break
|
||||
text = await _fetch(client, key, filter_str, cursor)
|
||||
_count, next_cursor, works = parse_openalex_works(text)
|
||||
if not works:
|
||||
break
|
||||
for w in works:
|
||||
seen += 1
|
||||
if w.publication_date and (newest is None or w.publication_date > newest):
|
||||
newest = w.publication_date
|
||||
async with async_session() as session:
|
||||
src = await session.get(NewsSource, source_id)
|
||||
if await _ingest_work(session, src, w):
|
||||
inserted += 1
|
||||
await session.commit()
|
||||
else:
|
||||
await session.rollback()
|
||||
if inserted >= run_cap:
|
||||
break
|
||||
await asyncio.sleep(_REQ_SLEEP)
|
||||
if not next_cursor:
|
||||
break
|
||||
cursor = next_cursor
|
||||
if newest:
|
||||
async with async_session() as session:
|
||||
src = await session.get(NewsSource, source_id)
|
||||
_set_watermark(src, wm_key, newest)
|
||||
await session.commit()
|
||||
except (httpx.HTTPError, FeedError, ValueError) as e:
|
||||
msg = f"[{label}] {e or repr(e)}"
|
||||
logger.error(f"[openalex] {msg}")
|
||||
failures.append(msg)
|
||||
|
||||
async with async_session() as session:
|
||||
health = await _get_or_create_health(session, source_id)
|
||||
if failures and inserted == 0:
|
||||
_record_failure(health, "; ".join(failures)[:500], now)
|
||||
else:
|
||||
_record_success(health, inserted, False, now)
|
||||
await session.commit()
|
||||
|
||||
deferred = "" if inserted < run_cap else f" (cap {run_cap} 도달 — 잔여 다음 run 이월)"
|
||||
logger.info(
|
||||
f"[openalex] {len(_seeds())}개 시드(ISSN+키워드) 스캔 {seen}건 → 신규 {inserted}건{deferred}"
|
||||
+ (f" / 실패 {len(failures)}건" if failures else "")
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description="OpenAlex 안전·공학 키워드 백본 수집기")
|
||||
parser.add_argument("--bulk", action="store_true", help="cap 해제 + 깊은 cursor 페이징 백필")
|
||||
parser.add_argument("--limit", type=int, default=0, help="신규 적재 상한(0=기본 cap)")
|
||||
args = parser.parse_args()
|
||||
asyncio.run(run(bulk=args.bulk, limit=args.limit))
|
||||
@@ -0,0 +1,102 @@
|
||||
"""paper DOI reconcile — B-3 PR4(레거시 arXiv) + PR5(구매 PDF) (plan safety-library-b3-1).
|
||||
|
||||
paper.doi/parent_doi 둘 다 없는 paper 행을 두 갈래로 정리:
|
||||
- 레거시 arXiv 초록(holder): arXiv id → arxiv_doi(10.48550/arxiv.{id}) 스탬프 → partial-unique
|
||||
인덱스 편입 → 재유입 차단('동일-DOI 재유입 차단만').
|
||||
- 구매 PDF(child, license.restricted=true — Papers_Purchased 드롭): 본문 DOI 파싱 → paper.parent_doi
|
||||
링크(서지 holder 와 DOI 공유로 연결). child 는 doi 미보유(인덱스 밖) → unique 무충돌.
|
||||
|
||||
- KEYLESS·결정적(OpenAlex 호출 0)·in-DB·enqueue 0(콘텐츠 무변경). dedup_reconcile(file_hash 캐시)와
|
||||
별 worker(적대리뷰 B·C major). 선재 DOI holder 존재 시 arXiv 행도 parent_doi 마킹(unique 위반 회피).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from core.database import async_session
|
||||
from core.utils import setup_logger
|
||||
from models.document import Document
|
||||
from services.papers.doi import (
|
||||
arxiv_doi,
|
||||
parse_arxiv_id,
|
||||
parse_doi_from_text,
|
||||
with_paper_doi,
|
||||
with_parent_doi,
|
||||
)
|
||||
from services.papers.holder import find_paper_holder
|
||||
|
||||
logger = setup_logger("paper_doi_reconcile")
|
||||
|
||||
_DOI_TEXT = Document.extract_meta[("paper", "doi")].astext
|
||||
_PARENT_DOI_TEXT = Document.extract_meta[("paper", "parent_doi")].astext
|
||||
|
||||
|
||||
def _is_restricted(meta: dict) -> bool:
|
||||
return (meta.get("license") or {}).get("restricted") in (True, "true")
|
||||
|
||||
|
||||
async def run(limit: int = 0) -> None:
|
||||
"""paper.doi/parent_doi 없는 paper 행 reconcile(멱등). limit=0 = 전건."""
|
||||
stamped = marked_dup = skipped_no_arxiv = 0
|
||||
linked_purchased = skipped_purchased_no_doi = 0
|
||||
async with async_session() as session:
|
||||
q = (
|
||||
select(Document)
|
||||
.where(
|
||||
Document.material_type == "paper",
|
||||
_DOI_TEXT.is_(None),
|
||||
_PARENT_DOI_TEXT.is_(None),
|
||||
)
|
||||
.order_by(Document.id)
|
||||
)
|
||||
if limit:
|
||||
q = q.limit(limit)
|
||||
rows = (await session.execute(q)).scalars().all()
|
||||
|
||||
for row in rows:
|
||||
meta = dict(row.extract_meta or {})
|
||||
paper = dict(meta.get("paper") or {})
|
||||
|
||||
# PR5: 구매 PDF(restricted) = child → 본문 DOI 파싱 → parent_doi 링크
|
||||
if _is_restricted(meta):
|
||||
doi = parse_doi_from_text(row.extracted_text)
|
||||
if not doi:
|
||||
skipped_purchased_no_doi += 1
|
||||
continue
|
||||
row.extract_meta = with_parent_doi(meta, doi)
|
||||
linked_purchased += 1
|
||||
continue
|
||||
|
||||
# PR4: 레거시 arXiv 초록(holder) = arXiv DataCite DOI 스탬프
|
||||
arxiv_id = paper.get("arxiv_id") or parse_arxiv_id(row.extracted_text)
|
||||
doi = arxiv_doi(arxiv_id)
|
||||
if not doi:
|
||||
skipped_no_arxiv += 1
|
||||
continue
|
||||
paper["arxiv_id"] = arxiv_id
|
||||
meta["paper"] = paper
|
||||
holder = await find_paper_holder(session, doi)
|
||||
if holder is not None and holder.id != row.id:
|
||||
row.extract_meta = with_parent_doi(meta, doi) # 선재 중복 → child 마킹
|
||||
marked_dup += 1
|
||||
else:
|
||||
row.extract_meta = with_paper_doi(meta, doi) # holder 스탬프, 인덱스 진입
|
||||
stamped += 1
|
||||
# 콘텐츠 무변경 → enqueue 없음(summarize/embed/chunk 0)
|
||||
await session.commit()
|
||||
|
||||
logger.info(
|
||||
f"[paper_doi_reconcile] {len(rows)}행 → arXiv 스탬프 {stamped} · 선재중복 {marked_dup} · "
|
||||
f"arXiv id 없음 skip {skipped_no_arxiv} / 구매PDF parent_doi 링크 {linked_purchased} · "
|
||||
f"구매PDF DOI 없음 skip {skipped_purchased_no_doi}"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description="paper DOI reconcile (arXiv 레거시 + 구매 PDF, keyless)")
|
||||
parser.add_argument("--limit", type=int, default=0, help="처리 상한(0=전건)")
|
||||
args = parser.parse_args()
|
||||
asyncio.run(run(limit=args.limit))
|
||||
@@ -0,0 +1,123 @@
|
||||
"""논문 arXiv 전문 승격 (in-place) — B-3 Phase-2 P2-PR1 (plan safety-library-b3-1).
|
||||
|
||||
arXiv 프리프린트 초록 행(file_format='article', signal-only)을 전문 PDF로 **in-place 승격**:
|
||||
PDF 다운로드 → file_format/file_type/file_path/md_status 갱신 → 'extract' enqueue → 기존 파이프라인
|
||||
(extract → classify[paper skip summarize] → embed/chunk/markdown)이 전문 검색 청크 + md_content(marker 표시)
|
||||
+ hier 절구조를 생성. 1-Document(2행 분리 회피, 기존 display 스택 재사용).
|
||||
|
||||
- arXiv = 공개 프리프린트(arxiv.org/pdf/{id}, friendly host) → 전문 검색/RAG 무난, restricted 불요.
|
||||
(유료 구매 논문은 Papers_Purchased 경로가 restricted=true 로 별개 처리.)
|
||||
- per-run cap (marker GPU ~10GB + embed 부하 보호, 4070 16GB 빡빡 → idle-unload·증분). keyless.
|
||||
- 요약 0 (classify paper-skip 가드). file_hash·extract_meta.paper 보존(수집기 dedup 무영향).
|
||||
- CLI 전용(Phase-2 deliberate 승격, GPU 부하 사용자 통제). 스케줄 잡 미등록.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import random
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import or_, select
|
||||
|
||||
from core.config import settings
|
||||
from core.crawl_politeness import CRAWL_UA
|
||||
from core.database import async_session
|
||||
from core.utils import setup_logger
|
||||
from models.document import Document
|
||||
from models.queue import enqueue_stage
|
||||
|
||||
logger = setup_logger("paper_fulltext_promote")
|
||||
|
||||
_ARXIV_PDF = "https://arxiv.org/pdf/{id}"
|
||||
_MAX_FILE_BYTES = 50 * 1024 * 1024
|
||||
_DOWNLOAD_DELAY = (2.0, 5.0)
|
||||
_RUN_CAP = 10 # 1회 승격 상한(marker/embed GPU 보호). bulk 시 해제.
|
||||
|
||||
_ARXIV_ID_EXPR = Document.extract_meta[("paper", "arxiv_id")].astext
|
||||
_OA_URL_EXPR = Document.extract_meta[("paper", "oa_url")].astext
|
||||
_OA_STATUS_EXPR = Document.extract_meta[("paper", "oa_status")].astext
|
||||
_REAL_OA = ("gold", "hybrid", "green", "diamond")
|
||||
|
||||
|
||||
async def _download(url: str, dest: Path) -> int:
|
||||
"""arXiv PDF 다운로드 — 크기 cap + PDF 헤더 검증 + 연속 간격(kosha 패턴)."""
|
||||
await asyncio.sleep(random.uniform(*_DOWNLOAD_DELAY))
|
||||
async with httpx.AsyncClient(timeout=60, follow_redirects=True) as client:
|
||||
resp = await client.get(url, headers={"User-Agent": CRAWL_UA})
|
||||
if resp.status_code != 200:
|
||||
raise RuntimeError(f"arXiv PDF {resp.status_code}: {url}")
|
||||
if len(resp.content) > _MAX_FILE_BYTES:
|
||||
raise RuntimeError(f"크기 초과 {len(resp.content)}b: {url}")
|
||||
if resp.content[:5] != b"%PDF-":
|
||||
raise RuntimeError(f"PDF 아님(헤더 {resp.content[:8]!r}): {url}")
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
dest.write_bytes(resp.content)
|
||||
return len(resp.content)
|
||||
|
||||
|
||||
async def run(bulk: bool = False, limit: int = 0) -> None:
|
||||
"""미승격 arXiv 논문(file_format='article')을 전문 PDF로 in-place 승격."""
|
||||
cap = (limit or 10**9) if bulk else (min(limit, _RUN_CAP) if limit else _RUN_CAP)
|
||||
async with async_session() as session:
|
||||
q = (
|
||||
select(Document.id)
|
||||
.where(
|
||||
Document.material_type == "paper",
|
||||
Document.file_format == "article",
|
||||
or_(
|
||||
_ARXIV_ID_EXPR.isnot(None),
|
||||
Document.extract_meta[("paper", "oa_url")].astext.isnot(None),
|
||||
),
|
||||
)
|
||||
.order_by(Document.id.desc())
|
||||
.limit(cap)
|
||||
)
|
||||
ids = [r[0] for r in (await session.execute(q)).all()]
|
||||
|
||||
promoted = failed = 0
|
||||
for doc_id in ids:
|
||||
async with async_session() as session:
|
||||
doc = await session.get(Document, doc_id)
|
||||
if doc is None or doc.file_format != "article":
|
||||
continue
|
||||
paper = (doc.extract_meta or {}).get("paper") or {}
|
||||
arxiv_id = paper.get("arxiv_id")
|
||||
oa_status = (paper.get("oa_status") or "").lower()
|
||||
if arxiv_id:
|
||||
url = _ARXIV_PDF.format(id=arxiv_id)
|
||||
key = arxiv_id.replace("/", "_")
|
||||
elif paper.get("oa_url") and oa_status in _REAL_OA:
|
||||
url = paper["oa_url"] # doi.org/KISTI/PMC (friendly OA). 비-OA·paywall 은 헤더검증서 skip
|
||||
key = (paper.get("openalex_id") or paper.get("doi") or "oa").replace("/", "_")
|
||||
else:
|
||||
continue
|
||||
rel_path = f"crawl_raw/papers/{key}.pdf"
|
||||
dest = Path(settings.nas_mount_path) / rel_path
|
||||
try:
|
||||
size = await _download(url, dest)
|
||||
except Exception as e: # noqa: BLE001 — 다운로드 실패 격리
|
||||
logger.error(f"[promote] {key} 다운로드 실패: {e}")
|
||||
failed += 1
|
||||
continue
|
||||
# in-place 승격: 초록 행 → 전문 PDF 행 (file_hash·extract_meta.paper 보존)
|
||||
doc.file_path = rel_path
|
||||
doc.file_format = "pdf"
|
||||
doc.file_type = "immutable"
|
||||
doc.file_size = size
|
||||
doc.md_status = "pending" # marker 재실행(기존 'skipped' 해제)
|
||||
doc.md_extraction_error = None
|
||||
await enqueue_stage(session, doc.id, "extract")
|
||||
await session.commit()
|
||||
promoted += 1
|
||||
logger.info(f"[promote] {key} → 전문 PDF in-place (doc {doc.id}, {size}b)")
|
||||
|
||||
logger.info(f"[paper_fulltext_promote] 승격 {promoted} · 실패 {failed} (cap {cap})")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="논문 arXiv 전문 승격 (in-place, keyless)")
|
||||
parser.add_argument("--bulk", action="store_true", help="cap 해제(전건 백필 — GPU 부하 주의)")
|
||||
parser.add_argument("--limit", type=int, default=0, help="승격 상한(0=기본 cap 10)")
|
||||
args = parser.parse_args()
|
||||
asyncio.run(run(bulk=args.bulk, limit=args.limit))
|
||||
@@ -0,0 +1,142 @@
|
||||
"""Phase 2A 후보 임베딩 백필 CLI (embedding-phase2a-1 E-1).
|
||||
|
||||
docker compose exec -T fastapi python -m workers.phase2a_cand_backfill \
|
||||
--target qwen06 --doc-id-max 41944 --chunk-id-max 104140 [--batch 32]
|
||||
|
||||
설계 원칙 (plan r3):
|
||||
- resumable/idempotent: 대상 = NOT EXISTS(후보 테이블) — 중단/재실행 시 이어서.
|
||||
배치 단위 커밋. C-1 백필 게이트 = "후보 카운트 == 동결셋 카운트".
|
||||
- 동결셋: id <= *_id_max AND 베이스라인 embedding IS NOT NULL (AND docs.deleted_at IS NULL).
|
||||
cand 테이블은 동결 범위로만 INSERT (retrieval cand path 가 snapshot filter 를 안 타는 전제).
|
||||
- 문서/청크 입력 = production 경로와 동일 구성(embed_worker._build_embed_input /
|
||||
chunk_worker 의 [제목][섹션][본문]) + plain (instruct prefix 는 쿼리 측 전용 — G-1 불변식).
|
||||
- 임베딩 = Ollama /api/embed 배치 호출 (G-1 fixture: 정규화 출력).
|
||||
- qwen4m 은 본 CLI 대상이 아님 — qwen4 적재 후 SQL 파생(subvector+l2_normalize), plan E-1.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import hashlib
|
||||
import time
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import text
|
||||
|
||||
from core.database import async_session
|
||||
from core.utils import setup_logger
|
||||
from models.document import Document
|
||||
from workers.embed_worker import _build_embed_input
|
||||
|
||||
logger = setup_logger("phase2a_cand_backfill")
|
||||
|
||||
OLLAMA_EMBED = "http://ollama:11434/api/embed"
|
||||
|
||||
TARGETS = {
|
||||
"qwen06": {
|
||||
"model": "qwen3-embedding:0.6b", "dim": 1024,
|
||||
"docs": "documents_cand_qwen06", "chunks": "document_chunks_cand_qwen06",
|
||||
},
|
||||
"qwen4": {
|
||||
"model": "qwen3-embedding:4b", "dim": 2560,
|
||||
"docs": "documents_cand_qwen4", "chunks": "document_chunks_cand_qwen4",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
async def _embed_batch(client: httpx.AsyncClient, model: str, texts: list[str]) -> list[list[float]]:
|
||||
r = await client.post(OLLAMA_EMBED, json={"model": model, "input": texts}, timeout=600)
|
||||
r.raise_for_status()
|
||||
embs = r.json()["embeddings"]
|
||||
if len(embs) != len(texts):
|
||||
raise RuntimeError(f"embed count mismatch: {len(embs)} != {len(texts)}")
|
||||
return embs
|
||||
|
||||
|
||||
async def backfill_docs(target: dict, doc_id_max: int, batch: int, http: httpx.AsyncClient) -> int:
|
||||
total = 0
|
||||
while True:
|
||||
async with async_session() as session:
|
||||
rows = (await session.execute(text(f"""
|
||||
SELECT d.id FROM documents d
|
||||
WHERE d.id <= :m AND d.embedding IS NOT NULL AND d.deleted_at IS NULL
|
||||
AND NOT EXISTS (SELECT 1 FROM {target['docs']} c WHERE c.doc_id = d.id)
|
||||
ORDER BY d.id LIMIT :b
|
||||
"""), {"m": doc_id_max, "b": batch})).scalars().all()
|
||||
if not rows:
|
||||
break
|
||||
docs = [(await session.get(Document, i)) for i in rows]
|
||||
inputs = [_build_embed_input(d) for d in docs]
|
||||
embs = await _embed_batch(http, target["model"], inputs)
|
||||
for d, inp, e in zip(docs, inputs, embs):
|
||||
await session.execute(text(f"""
|
||||
INSERT INTO {target['docs']} (doc_id, embed_input_hash, embedding)
|
||||
VALUES (:i, :h, cast(:e AS vector))
|
||||
ON CONFLICT (doc_id) DO NOTHING
|
||||
"""), {"i": d.id, "h": hashlib.sha256(inp.encode()).hexdigest()[:16], "e": str(e)})
|
||||
await session.commit()
|
||||
total += len(rows)
|
||||
if total % (batch * 10) < batch:
|
||||
logger.info(f"[{target['docs']}] +{total} (last id={rows[-1]})")
|
||||
return total
|
||||
|
||||
|
||||
async def backfill_chunks(target: dict, chunk_id_max: int, batch: int, http: httpx.AsyncClient) -> int:
|
||||
total = 0
|
||||
while True:
|
||||
async with async_session() as session:
|
||||
rows = (await session.execute(text(f"""
|
||||
SELECT c.id, c.doc_id, c.chunk_index, c.section_title, c.text, d.title
|
||||
FROM corpus_chunks c JOIN documents d ON d.id = c.doc_id
|
||||
WHERE c.id <= :m AND c.embedding IS NOT NULL AND d.deleted_at IS NULL
|
||||
AND NOT EXISTS (SELECT 1 FROM {target['chunks']} k WHERE k.id = c.id)
|
||||
ORDER BY c.id LIMIT :b
|
||||
"""), {"m": chunk_id_max, "b": batch})).all()
|
||||
if not rows:
|
||||
break
|
||||
inputs = [
|
||||
f"[제목] {r.title or ''}\n[섹션] {r.section_title or ''}\n[본문] {r.text}"
|
||||
for r in rows
|
||||
]
|
||||
embs = await _embed_batch(http, target["model"], inputs)
|
||||
for r, e in zip(rows, embs):
|
||||
await session.execute(text(f"""
|
||||
INSERT INTO {target['chunks']} (id, doc_id, chunk_index, section_title, text, embedding)
|
||||
VALUES (:i, :d, :x, :s, :t, cast(:e AS vector))
|
||||
ON CONFLICT (id) DO NOTHING
|
||||
"""), {"i": r.id, "d": r.doc_id, "x": r.chunk_index,
|
||||
"s": r.section_title, "t": r.text, "e": str(e)})
|
||||
await session.commit()
|
||||
total += len(rows)
|
||||
if total % (batch * 10) < batch:
|
||||
logger.info(f"[{target['chunks']}] +{total} (last id={rows[-1]})")
|
||||
return total
|
||||
|
||||
|
||||
async def run(target_key: str, doc_id_max: int, chunk_id_max: int, batch: int) -> None:
|
||||
target = TARGETS[target_key]
|
||||
start = time.monotonic()
|
||||
async with httpx.AsyncClient() as http:
|
||||
nd = await backfill_docs(target, doc_id_max, batch, http)
|
||||
nc = await backfill_chunks(target, chunk_id_max, batch, http)
|
||||
mins = (time.monotonic() - start) / 60
|
||||
async with async_session() as session:
|
||||
cd = (await session.execute(text(f"SELECT count(*) FROM {target['docs']}"))).scalar_one()
|
||||
cc = (await session.execute(text(f"SELECT count(*) FROM {target['chunks']}"))).scalar_one()
|
||||
logger.info(
|
||||
f"[{target_key}] 완료 — 이번 run docs +{nd} chunks +{nc} ({mins:.1f}분) · "
|
||||
f"누적 docs {cd} / chunks {cc} (동결 게이트 = 베이스라인 동결셋 카운트와 일치 확인)"
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
p = argparse.ArgumentParser(description="Phase 2A 후보 임베딩 백필 (resumable)")
|
||||
p.add_argument("--target", required=True, choices=sorted(TARGETS))
|
||||
p.add_argument("--doc-id-max", type=int, required=True)
|
||||
p.add_argument("--chunk-id-max", type=int, required=True)
|
||||
p.add_argument("--batch", type=int, default=32)
|
||||
a = p.parse_args()
|
||||
asyncio.run(run(a.target, a.doc_id_max, a.chunk_id_max, a.batch))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -13,18 +13,25 @@ from sqlalchemy import select, update, delete, exists
|
||||
from sqlalchemy.exc import IntegrityError, SQLAlchemyError
|
||||
from sqlalchemy.orm import aliased
|
||||
|
||||
from core.config import settings
|
||||
from core.database import async_session
|
||||
from core.utils import setup_logger
|
||||
from models.queue import ProcessingQueue, StageDeferred, enqueue_stage, not_deferred_condition
|
||||
|
||||
logger = setup_logger("queue_consumer")
|
||||
|
||||
# pipeline.held_stages 안내 로그는 1분 사이클마다 반복하지 않고 최초 1회만.
|
||||
_hold_logged = False
|
||||
|
||||
# stage별 배치 크기
|
||||
# stt 는 GPU 단일 점유 + 회의 30분짜리도 가능 → 배치 1. thumbnail 은 ffmpeg subprocess 로 가벼움.
|
||||
# deep_summary (PR-B B-1) 는 MLX 26B 단일 Semaphore(1) 경유 → 배치 1.
|
||||
# fulltext 는 politeness 지연(같은 도메인 5–15s)이 배치 내 직렬로 걸린다 — 배치 3 이면
|
||||
# 같은 도메인 최악 ~45s/사이클, 메인 큐 1m 간격(max_instances=1, coalesce)이 흡수.
|
||||
BATCH_SIZE = {"extract": 5, "classify": 3, "summarize": 3, "embed": 1, "chunk": 1,
|
||||
# embed/chunk 1→10 (2026-06-12 fast-consumer): 건당 <1s 실측 — Phase 0.1 초기 보수값이
|
||||
# LLM 사이클에 인질로 잡혀 실효 ~580/일 vs 수요 최대 2,700/일 → 적체 원인이었음.
|
||||
# 10 = TEI/marker 와 GPU 공유 고려한 보수 상향(전용 1분 잡 기준 캡 ~14,400/일).
|
||||
BATCH_SIZE = {"extract": 5, "classify": 3, "summarize": 3, "embed": 10, "chunk": 10,
|
||||
"preview": 2, "stt": 1, "thumbnail": 3, "deep_summary": 1, "markdown": 1,
|
||||
"fulltext": 3}
|
||||
STALE_THRESHOLD_MINUTES = 10
|
||||
@@ -34,14 +41,21 @@ STALE_THRESHOLD_MINUTES = 10
|
||||
# 따라서 markdown consumer 는 별도의 generous 임계를 쓴다.
|
||||
MARKDOWN_STALE_THRESHOLD_MINUTES = int(os.getenv("MARKDOWN_STALE_MINUTES", "120"))
|
||||
|
||||
# consume_queue(메인) 가 담당하는 stage. markdown 은 consume_markdown_queue 로 분리.
|
||||
# consume_queue(메인) 가 담당하는 stage. markdown 은 consume_markdown_queue,
|
||||
# embed/chunk 는 consume_fast_queue (2026-06-12) 로 분리 — 세 집합은 disjoint
|
||||
# (reset_stale_items 가 자기 집합만 reset, 교차 시 이중 복구 위험).
|
||||
# STT 도 장기 작업 가능성이 있으나 본 PR 범위 밖 — main 에 유지(follow-up).
|
||||
MAIN_QUEUE_STAGES = [
|
||||
"extract", "classify", "summarize", "embed", "chunk",
|
||||
"extract", "classify", "summarize",
|
||||
"preview", "stt", "thumbnail", "deep_summary", "fulltext",
|
||||
]
|
||||
MARKDOWN_QUEUE_STAGES = ["markdown"]
|
||||
|
||||
# 고속(비-LLM·경량 GPU) stage — LLM 사이클(분 단위)에서 분리해 1분 잡 전용 소비.
|
||||
# embed/chunk 는 건당 <1s 라 main 루프에 두면 classify(~190s×3) 뒤에서 굶는다
|
||||
# (2026-06-12 실측: 적체 3,570 · 4070 가동률 0%). markdown 분리(05-01)와 동일 패턴.
|
||||
FAST_QUEUE_STAGES = ["embed", "chunk"]
|
||||
|
||||
|
||||
async def reset_stale_items(stages, threshold_minutes=STALE_THRESHOLD_MINUTES):
|
||||
"""processing 상태로 오래 방치된 항목 복구 (지정 stage 한정)
|
||||
@@ -335,14 +349,43 @@ async def _process_stage(stage, worker_fn):
|
||||
|
||||
async def consume_queue():
|
||||
"""메인 큐 소비자 — markdown 제외 전 stage 를 1분 간격으로 처리."""
|
||||
global _hold_logged
|
||||
workers = _load_workers()
|
||||
|
||||
held = [s for s in MAIN_QUEUE_STAGES if s in settings.pipeline_held_stages]
|
||||
if held and not _hold_logged:
|
||||
logger.info(f"pipeline.held_stages 보류 중: {held} — claim 하지 않음 (pending 적체 = 의도)")
|
||||
_hold_logged = True
|
||||
|
||||
try:
|
||||
await reset_stale_items(MAIN_QUEUE_STAGES, STALE_THRESHOLD_MINUTES)
|
||||
except Exception:
|
||||
logger.exception("stale reset failed, but continuing queue consumption")
|
||||
|
||||
for stage in MAIN_QUEUE_STAGES:
|
||||
if stage in settings.pipeline_held_stages:
|
||||
continue
|
||||
await _process_stage(stage, workers[stage])
|
||||
|
||||
|
||||
async def consume_fast_queue():
|
||||
"""embed/chunk 전용 고속 소비자 — LLM 사이클과 완전 디커플 (2026-06-12).
|
||||
|
||||
main 루프는 classify/summarize/deep 가 사이클을 분 단위로 점유해 건당 <1s 짜리
|
||||
embed/chunk 가 사이클당 1번씩만 기회를 얻었다 (실효 ~60건/시 = 적체 원인).
|
||||
분리 후 = 1분 잡 × 배치 10 → 캡 ~600건/시. APScheduler max_instances=1 이라
|
||||
배치가 1분을 넘으면 다음 fire 는 coalesce (폭주 방지).
|
||||
"""
|
||||
workers = _load_workers()
|
||||
|
||||
try:
|
||||
await reset_stale_items(FAST_QUEUE_STAGES, STALE_THRESHOLD_MINUTES)
|
||||
except Exception:
|
||||
logger.exception("fast stale reset failed, but continuing queue consumption")
|
||||
|
||||
for stage in FAST_QUEUE_STAGES:
|
||||
if stage in settings.pipeline_held_stages:
|
||||
continue
|
||||
await _process_stage(stage, workers[stage])
|
||||
|
||||
|
||||
|
||||
@@ -30,9 +30,12 @@ from models.queue import ProcessingQueue, StageDeferred, not_deferred_condition
|
||||
|
||||
logger = setup_logger("queue_drain")
|
||||
|
||||
# summarize = 맥미니 백로그 본체 / deep_summary = 심층 (consumer 도 deep 슬롯 시 맥북 경유).
|
||||
# classify 는 triage 경량 호출이라 맥미니 적합 — 대상에서 제외 (plan Q-4).
|
||||
DRAIN_STAGES = ("summarize", "deep_summary")
|
||||
# summarize = 맥미니 백로그 본체 / deep_summary = 심층 / classify = triage 분류.
|
||||
# classify 는 2026-06-12 fair-share 로 합류 — 구 제외 사유(plan Q-4 "triage 경량 = 맥미니
|
||||
# 적합")는 Gemma a4b(42 tok/s) 전제. Qwen 27B 전환 후 classify 가 장문 프리필로 컨슈머
|
||||
# 사이클을 점유하는 최대 병목이라, 맥북(프리필 ~5배)이 가장 효과적인 분담처다.
|
||||
# classify 완료 시 enqueue_next_stage(embed/chunk/markdown) 필수 — 누락 = DAG 단절.
|
||||
DRAIN_STAGES = ("summarize", "deep_summary", "classify")
|
||||
|
||||
|
||||
async def _claim_one(stage: str) -> tuple[int, int] | None:
|
||||
@@ -98,14 +101,16 @@ async def _mark_failed(queue_id: int, exc: Exception) -> None:
|
||||
|
||||
async def drain(stage: str, limit: int, defer_retries: int = 5, defer_wait: int = 120) -> None:
|
||||
if stage not in DRAIN_STAGES:
|
||||
raise SystemExit(f"--stage 는 {DRAIN_STAGES} 만 허용 (classify 등은 맥미니 적합 — plan Q-4)")
|
||||
raise SystemExit(f"--stage 는 {DRAIN_STAGES} 만 허용")
|
||||
if settings.ai.deep is None:
|
||||
raise SystemExit(
|
||||
"config.yaml ai.models.deep 슬롯 미구성 — drain 은 맥북 분담 전용 레버라 진행하지 않음"
|
||||
" (맥미니로의 silent 강등 금지)"
|
||||
)
|
||||
|
||||
from workers.classify_worker import process as classify_process
|
||||
from workers.deep_summary_worker import process as deep_summary_process
|
||||
from workers.queue_consumer import enqueue_next_stage
|
||||
from workers.summarize_worker import process as summarize_process
|
||||
|
||||
done = failed = 0
|
||||
@@ -121,11 +126,18 @@ async def drain(stage: str, limit: int, defer_retries: int = 5, defer_wait: int
|
||||
async with async_session() as worker_session:
|
||||
if stage == "summarize":
|
||||
await summarize_process(document_id, worker_session, use_deep=True)
|
||||
elif stage == "classify":
|
||||
await classify_process(document_id, worker_session, use_deep=True)
|
||||
else:
|
||||
# deep_summary 는 deep 슬롯 구성 시 워커가 자체적으로 맥북 경유
|
||||
await deep_summary_process(document_id, worker_session)
|
||||
# deep_summary: drain 은 맥북 전용 레버 — 불가 시 보류(폴백은 consumer 만)
|
||||
await deep_summary_process(
|
||||
document_id, worker_session, defer_on_deep_unavailable=True
|
||||
)
|
||||
await worker_session.commit()
|
||||
await _mark_completed(queue_id)
|
||||
# 다음 stage 연쇄 — classify 는 embed/chunk/markdown enqueue (consumer 와 동형,
|
||||
# summarize/deep_summary 는 next_stages 미등록이라 no-op)
|
||||
await enqueue_next_stage(document_id, stage)
|
||||
done += 1
|
||||
consecutive_defers = 0
|
||||
logger.info(f"[drain:{stage}] {done}/{limit} doc={document_id} 완료")
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
"""statute_collector 나라별 어댑터 패키지 (plan safety-library-1 B-1).
|
||||
|
||||
어댑터 계약 (2함수 + 상수):
|
||||
JURISDICTION: str — 어댑터 상수 고정. 코어가 적재 직전 assert (파싱 결과 추론 금지).
|
||||
poll_changes(client, watch_rows) -> list[ChangeEvent] — 개정 감지만 (경량 호출).
|
||||
fetch_version(client, act, change) -> list[VersionPayload] — PR②.
|
||||
payload 리스트: primary + annex 각각 자기 version_key (R4-M4).
|
||||
|
||||
ChangeEvent.kind: amend / repeal / bootstrap(합성 — PR② 부트스트랩이 amend 와
|
||||
동일 ingest 경로 재사용, R6-m2).
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChangeEvent:
|
||||
"""개정 감지 이벤트 — poll_changes 산출물."""
|
||||
family_id: str
|
||||
kind: str # amend / repeal / bootstrap
|
||||
new_version_key: str # KR = MST (법령일련번호)
|
||||
title: str
|
||||
promulgation_date: str | None = None # YYYYMMDD
|
||||
effective_date: str | None = None # YYYYMMDD (목록 시행일자 — 조문별 차등 시행 주의)
|
||||
revision_type: str | None = None # 제개정구분명
|
||||
|
||||
|
||||
@dataclass
|
||||
class VersionPayload:
|
||||
"""fetch_version 산출물 1건 — primary 또는 annex 각자 자기 version_key (R4-M4).
|
||||
|
||||
전문 1콜 스냅샷 의미론(R7-M3 fixture 판정): 한 응답에서 primary + annex 전부 생성.
|
||||
annex version_key = 'MST|{별표번호}-{별표가지번호}' (zero-padded 구조화 필드 그대로 —
|
||||
suffix 문자열 파싱 아닌 필드 기반, R7-B1 a 업그레이드).
|
||||
"""
|
||||
law_doc_kind: str # primary / annex
|
||||
version_key: str
|
||||
title: str
|
||||
content: str # 조문/별표 markdown 텍스트
|
||||
promulgation_date: str | None = None # YYYYMMDD (본문 기본정보)
|
||||
effective_date: str | None = None # YYYYMMDD (본문 기본정보 — 목록값과 다를 수 있음)
|
||||
annex_label: str | None = None # '별표1' / '별표5의2' (표시용)
|
||||
meta: dict = field(default_factory=dict)
|
||||
@@ -0,0 +1,213 @@
|
||||
"""KR 법령 어댑터 — 국가법령정보센터 (law.go.kr DRF) (plan safety-library-1 B-1 PR①).
|
||||
|
||||
poll_changes = lawSearch 목록 diff: 워치리스트 행별 정식 법령명 exact 조회 →
|
||||
MST(법령일련번호) != watermark 이면 ChangeEvent. law_monitor 의 검증된 호출 형태 재사용.
|
||||
|
||||
fixture (2026-06-13 라이브 박제, tests/fixtures/statute_kr/):
|
||||
- lawsearch_*.xml — 목록 필드: 법령ID(불변)·법령일련번호(MST)·공포일자·시행일자·제개정구분명
|
||||
- lawservice_*.xml.gz — 전문 1콜 XML: 조문단위 853(산안기준규칙) + 별표단위 23 전부 포함
|
||||
= 스냅샷 의미론 확정(R7-M3 ①: annex 부분 fetch 실패 개념 없음 — 같은 응답에 없는
|
||||
별표 = 삭제 간주 가능). 별표번호+별표가지번호 = 구조화 필드(R7-M3 ② — suffix 문자열
|
||||
파싱 불요, version_key 합성은 이 필드 기반. PR② fetch_version 소관).
|
||||
- 조문 취득 방식 판정(R2-m1): 전문 1콜 + 로컬 파싱 확정 — lawjosub 조 단위 호출이면
|
||||
산안기준규칙(853조)은 개정당 호출 폭증. lawjosub fixture 는 보조 박제.
|
||||
|
||||
주의: 응답의 '법령상세링크' 필드에 OC 키가 포함됨 — fixture/로그에 raw 응답을 남길 때
|
||||
새니타이즈 필수 (repo fixture 는 __OC_REDACTED__ 처리됨).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
import httpx
|
||||
|
||||
from core.crawl_politeness import CRAWL_UA
|
||||
from core.utils import setup_logger
|
||||
from workers.statute_adapters import ChangeEvent, VersionPayload
|
||||
|
||||
logger = setup_logger("statute_kr")
|
||||
|
||||
JURISDICTION = "KR"
|
||||
SOURCE_API = "law.go.kr"
|
||||
|
||||
LAW_SEARCH_URL = "https://www.law.go.kr/DRF/lawSearch.do"
|
||||
LAW_SERVICE_URL = "https://www.law.go.kr/DRF/lawService.do"
|
||||
|
||||
# 같은 도메인 연속 호출 간격 (일 1회 x 26콜 — 보수적)
|
||||
_POLL_DELAY_S = 1.5
|
||||
|
||||
|
||||
def _oc() -> str:
|
||||
oc = os.getenv("LAW_OC", "")
|
||||
if not oc:
|
||||
raise RuntimeError("LAW_OC 미설정 — statute KR 어댑터 사용 불가")
|
||||
return oc
|
||||
|
||||
|
||||
def parse_search_hit(xml_text: str, official_title: str) -> dict | None:
|
||||
"""lawSearch XML 에서 정식 법령명 exact match 1건 추출 (순수 함수 — fixture 테스트 대상).
|
||||
|
||||
정식명 기준 exact match — 워치리스트 title 이 정식명(가운뎃점 포함)이므로 안전.
|
||||
(law_monitor 의 하드코딩 '유해위험작업...'(점 없음)이 영구 미매칭이던 함정의 교훈:
|
||||
조회 키는 반드시 레지스트리의 정식명을 쓴다.)
|
||||
"""
|
||||
root = ET.fromstring(xml_text)
|
||||
for law in root.findall(".//law"):
|
||||
if (law.findtext("법령명한글") or "").strip() != official_title:
|
||||
continue
|
||||
mst = (law.findtext("법령일련번호") or "").strip()
|
||||
if not mst:
|
||||
continue
|
||||
return {
|
||||
"mst": mst,
|
||||
"law_id": (law.findtext("법령ID") or "").strip(),
|
||||
"promulgation_date": (law.findtext("공포일자") or "").strip() or None,
|
||||
"effective_date": (law.findtext("시행일자") or "").strip() or None,
|
||||
"revision_type": (law.findtext("제개정구분명") or "").strip() or None,
|
||||
"status_code": (law.findtext("현행연혁코드") or "").strip() or None,
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
def detect_change(hit: dict | None, act_family_id: str, act_title: str,
|
||||
watermark: str | None) -> ChangeEvent | None:
|
||||
"""목록 hit + 워터마크 → ChangeEvent (순수 함수 — fixture 테스트 대상).
|
||||
|
||||
- hit 없음 = 감지 불가 (None — 호출측이 fail-loud 로그. 폐지 단정 금지:
|
||||
검색 누락/표기 변경 가능성과 구분 불가하므로 repeal 은 제개정구분명 기준만)
|
||||
- MST == watermark = 변경 없음
|
||||
- 제개정구분명에 '폐지' = repeal, 그 외 = amend
|
||||
"""
|
||||
if hit is None:
|
||||
return None
|
||||
if watermark and hit["mst"] == watermark:
|
||||
return None
|
||||
kind = "repeal" if (hit.get("revision_type") or "").find("폐지") >= 0 else "amend"
|
||||
return ChangeEvent(
|
||||
family_id=act_family_id,
|
||||
kind=kind,
|
||||
new_version_key=hit["mst"],
|
||||
title=act_title,
|
||||
promulgation_date=hit.get("promulgation_date"),
|
||||
effective_date=hit.get("effective_date"),
|
||||
revision_type=hit.get("revision_type"),
|
||||
)
|
||||
|
||||
|
||||
def _article_markdown(art: ET.Element) -> str:
|
||||
"""조문단위 1건 → 텍스트. 조문내용(이미 '제N조(제목) ...' 형태) + 항/호/목 전체.
|
||||
|
||||
메타 필드(조문번호/조문여부/조문시행일자 등)는 제외 — 조문내용과 항 서브트리만.
|
||||
"""
|
||||
parts = []
|
||||
body = (art.findtext("조문내용") or "").strip()
|
||||
if body:
|
||||
parts.append(body)
|
||||
for hang in art.findall("항"):
|
||||
text = "\n".join(t.strip() for t in hang.itertext() if t.strip())
|
||||
if text:
|
||||
parts.append(text)
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def parse_service_payloads(xml_text: str, official_title: str, mst: str) -> list[VersionPayload]:
|
||||
"""lawService 전문 XML → VersionPayload 리스트 (순수 함수 — fixture 테스트 대상).
|
||||
|
||||
스냅샷 의미론: 응답에 있는 별표가 그 버전의 별표 전체 (R7-M3 fixture 판정).
|
||||
- primary 1건: 전 조문 markdown (조문여부 != '조문' 행 = 장/절 헤더 → '## ' 처리)
|
||||
- annex N건: 별표단위별 — version_key = 'MST|{별표번호}-{가지번호}' (zero-padded 그대로)
|
||||
"""
|
||||
root = ET.fromstring(xml_text)
|
||||
base = root.find(".//기본정보")
|
||||
prom = (base.findtext("공포일자") or "").strip() or None if base is not None else None
|
||||
eff = (base.findtext("시행일자") or "").strip() or None if base is not None else None
|
||||
|
||||
lines: list[str] = [f"# {official_title}", ""]
|
||||
for art in root.findall(".//조문단위"):
|
||||
is_article = (art.findtext("조문여부") or "").strip() == "조문"
|
||||
text = _article_markdown(art)
|
||||
if not text:
|
||||
continue
|
||||
if is_article:
|
||||
lines.append(f"### {text}" if not text.startswith("제") else text)
|
||||
else:
|
||||
lines.append(f"## {text}")
|
||||
lines.append("")
|
||||
primary_content = "\n".join(lines).strip()
|
||||
|
||||
payloads = [VersionPayload(
|
||||
law_doc_kind="primary",
|
||||
version_key=mst,
|
||||
title=official_title,
|
||||
content=primary_content,
|
||||
promulgation_date=prom,
|
||||
effective_date=eff,
|
||||
)]
|
||||
|
||||
for annex in root.findall(".//별표단위"):
|
||||
no = (annex.findtext("별표번호") or "").strip()
|
||||
sub = (annex.findtext("별표가지번호") or "").strip() or "00"
|
||||
kind = (annex.findtext("별표구분") or "별표").strip() # 별표 / 서식 — 별도 차원!
|
||||
a_title = (annex.findtext("별표제목") or "").strip()
|
||||
a_body = (annex.findtext("별표내용") or "").strip()
|
||||
if not no:
|
||||
continue
|
||||
# 삭제 tombstone — KR 은 별표/서식 삭제가 absence 가 아니라 '삭제 <날짜>' 명시 행
|
||||
# (fixture 실측: 산안기준규칙 서식1·2). 내용 없는 tombstone 은 적재 skip.
|
||||
# 시리즈의 구버전 current 잔존 처리 = PR③ 관찰 후보 (absence 추론은 불요 확정).
|
||||
if a_title.startswith("삭제") and len(a_body) < 50:
|
||||
continue
|
||||
label = f"{kind}{int(no)}" + (f"의{int(sub)}" if sub not in ("", "0", "00") else "")
|
||||
payloads.append(VersionPayload(
|
||||
law_doc_kind="annex",
|
||||
# 구분 차원 포함 — (번호,가지)만으로는 별표1 vs 서식1 충돌 (fixture 실측)
|
||||
version_key=f"{mst}|{kind}{no}-{sub}",
|
||||
title=f"{official_title} {label} {a_title}".strip(),
|
||||
content=f"# {official_title} {label}\n## {a_title}\n\n{a_body}".strip(),
|
||||
promulgation_date=prom,
|
||||
effective_date=eff,
|
||||
annex_label=label,
|
||||
))
|
||||
return payloads
|
||||
|
||||
|
||||
async def fetch_version(client: httpx.AsyncClient, act, change: ChangeEvent) -> list[VersionPayload]:
|
||||
"""전문 1콜 → payload 리스트 (R2-m1 판정: lawjosub 조 단위 호출 안 함 — 853조 폭증 회피)."""
|
||||
resp = await client.get(
|
||||
LAW_SERVICE_URL,
|
||||
params={"OC": _oc(), "target": "law", "MST": change.new_version_key, "type": "XML"},
|
||||
headers={"User-Agent": CRAWL_UA},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
payloads = parse_service_payloads(resp.text, act.title, change.new_version_key)
|
||||
if not payloads or len(payloads[0].content) < 200:
|
||||
# 파싱 검증 floor — 미달 시 예외 = 워터마크 미영속 (재시도 가능 상태 유지)
|
||||
raise ValueError(f"전문 파싱 결과 빈약 ({act.family_id}): payloads={len(payloads)}")
|
||||
return payloads
|
||||
|
||||
|
||||
async def poll_changes(client: httpx.AsyncClient, watch_rows: list) -> list[ChangeEvent]:
|
||||
"""워치리스트 행별 lawSearch diff. 행 단위 실패 격리 (한 법령 실패가 나머지를 막지 않음)."""
|
||||
oc = _oc()
|
||||
events: list[ChangeEvent] = []
|
||||
for act in watch_rows:
|
||||
try:
|
||||
resp = await client.get(
|
||||
LAW_SEARCH_URL,
|
||||
params={"OC": oc, "target": "law", "type": "XML", "query": act.title},
|
||||
headers={"User-Agent": CRAWL_UA},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
hit = parse_search_hit(resp.text, act.title)
|
||||
if hit is None:
|
||||
# fail-loud: 정식명 미매칭 = 표기 변경/검색 누락 의심 — 침묵 skip 금지
|
||||
logger.warning(f"[statute-kr] 목록 미매칭: {act.family_id} {act.title!r}")
|
||||
else:
|
||||
ev = detect_change(hit, act.family_id, act.title, act.watermark)
|
||||
if ev:
|
||||
events.append(ev)
|
||||
except Exception as e:
|
||||
logger.error(f"[statute-kr] poll 실패 ({act.family_id}): {type(e).__name__}: {e!r}")
|
||||
await asyncio.sleep(_POLL_DELAY_S)
|
||||
return events
|
||||
@@ -0,0 +1,381 @@
|
||||
"""statute_collector — 법령 수집 코어 (plan safety-library-1 B-1, PR②).
|
||||
|
||||
구성 (잡 코드 통째 — R8-B1: 승격과 스윕의 PR 분리 = 배포 갭 이중 노출 윈도):
|
||||
poll_changes(어댑터) → fetch_version(전문 1콜, payload 리스트) → ingest(전 버전
|
||||
pending 적재 + 4축 주입) → 생애주기 잡(버전 시리즈 단위 승격·supersede + 상태 기반
|
||||
레거시 스윕 + repeal — 단일 트랜잭션, KST 기준).
|
||||
|
||||
핵심 계약 (카드 = 스펙):
|
||||
- 워터마크 영속 = ingest 파싱 검증 통과 후에만 (실패 시 다음 폴링이 재감지)
|
||||
- 승격·supersede 단위 = 버전 시리즈 = (family_id, law_doc_kind, annex 식별자)
|
||||
— R7-B1: family 단위 구현 금지 (annex 승격이 primary 를 소거하는 본문 소실 경로)
|
||||
- 레거시 스윕 = 상태 기반: 매 잡 실행, primary 시리즈 current 보유 + repeal 미감지
|
||||
family 의 법령명 매핑 레거시(law_monitor 스냅샷) 청크 in_corpus=false (멱등)
|
||||
- 매핑 = 정확 일치 가정 금지: title 의 '법령명 (YYYYMMDD)' 패턴에서 법령명 추출 후
|
||||
정규화(공백·가운뎃점 변형 흡수) **동등** 비교 — prefix 비교 금지 ('산업안전보건법'이
|
||||
'산업안전보건법 시행령' 레거시를 오폭하는 경로 차단)
|
||||
- ingest 4축 (R8-M1): material_type='law' / jurisdiction=어댑터 상수 /
|
||||
published_date=COALESCE(시행일, 공포일) / license=public_domain(저작권법 제7조)
|
||||
- 부트스트랩(--bootstrap) = kind='bootstrap' 합성 이벤트, amend 와 동일 경로 +
|
||||
extract_meta.backfill=true (E-1 게이트 집계 제외 마커)
|
||||
- 가시성: source_health 성공/실패 기록 (HC.io 는 2026-05-30 알림 레이어 폐기로 부재 —
|
||||
silent-skip 가드 정신은 crawl-health 보드 + health 행으로 대체)
|
||||
|
||||
실행:
|
||||
스케줄 = daily 07:00 KST (main.py — 구 law_monitor 슬롯 승계)
|
||||
수동 = docker compose exec -T fastapi python -m workers.statute_collector [--bootstrap]
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import hashlib
|
||||
import re
|
||||
import unicodedata
|
||||
from datetime import date, datetime, timezone
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import select, update
|
||||
|
||||
from core.database import async_session
|
||||
from core.utils import setup_logger
|
||||
from models.chunk import DocumentChunk
|
||||
from models.document import Document
|
||||
from models.legal_act import LegalAct, LegalMeta
|
||||
from models.news_source import NewsSource
|
||||
from models.queue import enqueue_stage
|
||||
from workers.news_collector import _get_or_create_health, _record_failure, _record_success
|
||||
from workers.statute_adapters import ChangeEvent, VersionPayload
|
||||
from workers.statute_adapters import kr
|
||||
|
||||
logger = setup_logger("statute_collector")
|
||||
|
||||
_KST = ZoneInfo("Asia/Seoul")
|
||||
_SOURCE_NAME = "KR 법령 (law.go.kr)"
|
||||
_LICENSE = {"scheme": "public_domain", "redistribute": True, "attribution": "국가법령정보센터"}
|
||||
_FETCH_DELAY_S = 2.5 # lawService 전문(최대 ~1.3MB) 연속 호출 간격
|
||||
|
||||
# jurisdiction → 어댑터 모듈 (Phase 1 = KR 단독, 해외는 B-5 게이트 뒤)
|
||||
_ADAPTERS = {"KR": kr}
|
||||
|
||||
|
||||
# ─── 법령명 매핑 (R8-m1: 정확 일치 가정 금지 — 변형 흡수 정규화 + 동등 비교) ───
|
||||
|
||||
_LEGACY_TITLE_RE = re.compile(r"^(.*?)\s*\((\d{8})\)")
|
||||
|
||||
|
||||
def normalize_law_name(name: str) -> str:
|
||||
"""공백·가운뎃점 변형 흡수 — NFC 정규화 후 공백/ㆍ·・ 제거."""
|
||||
s = unicodedata.normalize("NFC", name or "")
|
||||
return re.sub(r"[\sㆍ·・]", "", s)
|
||||
|
||||
|
||||
def legacy_law_name(title: str) -> str | None:
|
||||
"""레거시 law_monitor title('법령명 (YYYYMMDD) 섹션')에서 법령명 추출."""
|
||||
m = _LEGACY_TITLE_RE.match(title or "")
|
||||
return m.group(1).strip() if m else None
|
||||
|
||||
|
||||
def series_suffix(version_key: str) -> str | None:
|
||||
"""버전 시리즈의 annex 식별자 — version_key 'MST|NNNN-SS' 의 '|' 뒤 (primary=None)."""
|
||||
return version_key.split("|", 1)[1] if "|" in version_key else None
|
||||
|
||||
|
||||
def _to_date(ymd: str | None) -> date | None:
|
||||
digits = re.sub(r"\D", "", ymd or "")
|
||||
if len(digits) != 8:
|
||||
return None
|
||||
try:
|
||||
return date(int(digits[:4]), int(digits[4:6]), int(digits[6:8]))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
# ─── ingest (전 버전 pending 적재 — R2-B2/R3 계약) ──────────────────────────────
|
||||
|
||||
async def _ingest_payload(session, act: LegalAct, ev: ChangeEvent,
|
||||
payload: VersionPayload, backfill: bool) -> bool:
|
||||
"""payload 1건 → Document + legal_meta(pending). 반환 = 신규 여부 (dedup 멱등)."""
|
||||
fhash = hashlib.sha256(
|
||||
f"statute|{act.jurisdiction}|{act.native_id}|{payload.version_key}".encode()
|
||||
).hexdigest()[:32]
|
||||
existing = await session.execute(
|
||||
select(Document.id).where(Document.file_hash == fhash).limit(1)
|
||||
)
|
||||
if existing.scalars().first():
|
||||
return False
|
||||
|
||||
prom = _to_date(payload.promulgation_date or ev.promulgation_date)
|
||||
eff = _to_date(payload.effective_date or ev.effective_date)
|
||||
now = datetime.now(timezone.utc)
|
||||
extra = {"backfill": True} if backfill else {}
|
||||
doc = Document(
|
||||
file_path=f"crawl/statute/{act.family_id}/{payload.version_key.replace('|', '_')}",
|
||||
file_hash=fhash,
|
||||
file_format="article",
|
||||
file_size=len(payload.content.encode()),
|
||||
file_type="note",
|
||||
title=f"{payload.title} ({payload.promulgation_date or ev.promulgation_date or ''})".strip(),
|
||||
extracted_text=payload.content,
|
||||
extracted_at=now,
|
||||
extractor_version="statute_kr@law.go.kr",
|
||||
md_status="skipped",
|
||||
md_extraction_error="statute: 텍스트 네이티브, markdown 변환 비대상",
|
||||
source_channel="crawl",
|
||||
data_origin="external",
|
||||
review_status="approved",
|
||||
ai_domain="법령",
|
||||
ai_sub_group=act.title,
|
||||
ai_tags=[f"법령/KR/{act.title}"],
|
||||
# 안전 자료실 ingest 4축 (R8-M1 — classify-skip 경로라 ingest 시점 필수)
|
||||
material_type="law",
|
||||
jurisdiction=kr.JURISDICTION,
|
||||
published_date=eff or prom,
|
||||
extract_meta={
|
||||
"statute": {"family_id": act.family_id, "law_id": act.native_id,
|
||||
"kind": payload.law_doc_kind, "version_key": payload.version_key,
|
||||
"annex_label": payload.annex_label,
|
||||
"event_kind": ev.kind, "revision_type": ev.revision_type},
|
||||
"license": dict(_LICENSE),
|
||||
**extra,
|
||||
},
|
||||
)
|
||||
session.add(doc)
|
||||
await session.flush()
|
||||
|
||||
session.add(LegalMeta(
|
||||
document_id=doc.id,
|
||||
family_id=act.family_id,
|
||||
law_doc_kind=payload.law_doc_kind,
|
||||
version_key=payload.version_key,
|
||||
promulgation_date=prom,
|
||||
effective_date=eff,
|
||||
version_status="pending", # 전 버전 pending 적재 — 승격은 생애주기 잡만
|
||||
))
|
||||
# summarize 안 함 (조문 자체가 정본 — 맥미니 부하 0), embed+chunk 만
|
||||
await enqueue_stage(session, doc.id, "embed")
|
||||
await enqueue_stage(session, doc.id, "chunk")
|
||||
return True
|
||||
|
||||
|
||||
# ─── 생애주기 잡 (전이·supersede·스윕·repeal 의 유일한 코드 지점) ────────────────
|
||||
|
||||
async def _flip_chunks(session, doc_ids: list[int]) -> int:
|
||||
if not doc_ids:
|
||||
return 0
|
||||
result = await session.execute(
|
||||
update(DocumentChunk)
|
||||
.where(DocumentChunk.doc_id.in_(doc_ids), DocumentChunk.in_corpus.is_(True))
|
||||
.values(in_corpus=False)
|
||||
)
|
||||
return result.rowcount or 0
|
||||
|
||||
|
||||
async def _legacy_doc_ids(session, act: LegalAct) -> list[int]:
|
||||
"""법령명 매핑 레거시(law_monitor) 문서 id — 정규화 동등 비교 (prefix 금지)."""
|
||||
result = await session.execute(
|
||||
select(Document.id, Document.title).where(
|
||||
Document.source_channel == "law_monitor",
|
||||
Document.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
want = normalize_law_name(act.title)
|
||||
ids = []
|
||||
for doc_id, title in result.all():
|
||||
name = legacy_law_name(title or "")
|
||||
if name and normalize_law_name(name) == want:
|
||||
ids.append(doc_id)
|
||||
return ids
|
||||
|
||||
|
||||
async def run_lifecycle(session) -> dict:
|
||||
"""일 1회 생애주기 잡 — 호출측이 단일 트랜잭션 commit. KST 기준, 멱등."""
|
||||
today = datetime.now(_KST).date()
|
||||
stats = {"promoted": 0, "superseded": 0, "repealed": 0,
|
||||
"legacy_flipped_docs": 0, "legacy_flipped_chunks": 0}
|
||||
|
||||
acts_result = await session.execute(select(LegalAct).where(LegalAct.watch.is_(True)))
|
||||
acts = {a.family_id: a for a in acts_result.scalars().all()}
|
||||
|
||||
lm_result = await session.execute(
|
||||
select(LegalMeta).where(LegalMeta.family_id.in_(list(acts.keys())))
|
||||
)
|
||||
metas = lm_result.scalars().all()
|
||||
|
||||
# 1) repeal — 마킹된 family: current+pending 전부 repealed + 청크 flip + 레거시 flip (R7-M2)
|
||||
repeal_families = {fid for fid, a in acts.items() if a.repeal_detected_at is not None}
|
||||
for fid in repeal_families:
|
||||
rows = [m for m in metas if m.family_id == fid and m.version_status in ("pending", "current")]
|
||||
for m in rows:
|
||||
m.version_status = "repealed"
|
||||
stats["repealed"] += 1
|
||||
await _flip_chunks(session, [m.document_id for m in rows])
|
||||
legacy_ids = await _legacy_doc_ids(session, acts[fid])
|
||||
stats["legacy_flipped_chunks"] += await _flip_chunks(session, legacy_ids)
|
||||
|
||||
# 2) 승격 + supersede — 버전 시리즈 단위 (R7-B1 a: family 단위 금지)
|
||||
series: dict[tuple, list[LegalMeta]] = {}
|
||||
for m in metas:
|
||||
if m.family_id in repeal_families:
|
||||
continue
|
||||
series.setdefault(
|
||||
(m.family_id, m.law_doc_kind, series_suffix(m.version_key)), []
|
||||
).append(m)
|
||||
|
||||
for key, rows in series.items():
|
||||
due = sorted(
|
||||
(m for m in rows if m.version_status == "pending"
|
||||
and (m.effective_date or m.promulgation_date)
|
||||
and (m.effective_date or m.promulgation_date) <= today),
|
||||
key=lambda m: (m.effective_date or m.promulgation_date),
|
||||
)
|
||||
for m in due:
|
||||
prev = [c for c in rows if c.version_status == "current" and c is not m]
|
||||
for c in prev:
|
||||
c.version_status = "superseded"
|
||||
stats["superseded"] += 1
|
||||
await _flip_chunks(session, [c.document_id for c in prev])
|
||||
m.version_status = "current"
|
||||
stats["promoted"] += 1
|
||||
|
||||
# 3) 레거시 스윕 — 상태 기반 (R6-B1 a / R7-B1 b: primary 시리즈 current 보유 한정)
|
||||
for fid, act in acts.items():
|
||||
if fid in repeal_families:
|
||||
continue
|
||||
has_primary_current = any(
|
||||
m.family_id == fid and m.law_doc_kind == "primary" and m.version_status == "current"
|
||||
for m in metas
|
||||
)
|
||||
if not has_primary_current:
|
||||
continue # R3-B1 ② 내장 — fetch 실패 family 의 레거시 보존
|
||||
legacy_ids = await _legacy_doc_ids(session, act)
|
||||
flipped = await _flip_chunks(session, legacy_ids)
|
||||
if flipped:
|
||||
stats["legacy_flipped_docs"] += len(legacy_ids)
|
||||
stats["legacy_flipped_chunks"] += flipped
|
||||
|
||||
return stats
|
||||
|
||||
|
||||
# ─── 메인 런 ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
async def run(bootstrap: bool = False) -> None:
|
||||
"""poll → fetch → ingest(가족 단위 커밋) → 생애주기 잡. 가족 단위 실패 격리."""
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(LegalAct).where(LegalAct.watch.is_(True)).order_by(LegalAct.family_id)
|
||||
)
|
||||
rows = result.scalars().all()
|
||||
if not rows:
|
||||
logger.warning("[statute] 워치리스트 비어 있음 — 시드(migration 356) 미적용?")
|
||||
return
|
||||
source = await _get_source(session)
|
||||
await session.commit()
|
||||
source_id = source.id
|
||||
|
||||
ingested = 0
|
||||
failed = 0
|
||||
by_jur: dict[str, list] = {}
|
||||
for row in rows:
|
||||
by_jur.setdefault(row.jurisdiction, []).append(row)
|
||||
|
||||
async with httpx.AsyncClient(timeout=60) as client:
|
||||
for jur, acts in by_jur.items():
|
||||
adapter = _ADAPTERS.get(jur)
|
||||
if adapter is None:
|
||||
logger.warning(f"[statute] 어댑터 없는 jurisdiction skip: {jur}")
|
||||
continue
|
||||
assert adapter.JURISDICTION == jur, \
|
||||
f"어댑터/행 jurisdiction 불일치: {adapter.JURISDICTION} != {jur}"
|
||||
|
||||
events = await adapter.poll_changes(client, acts)
|
||||
acts_by_id = {a.family_id: a for a in acts}
|
||||
for ev in events:
|
||||
if bootstrap:
|
||||
ev.kind = "bootstrap" # 합성 이벤트 — amend 와 동일 경로 (R6-m2)
|
||||
act_ref = acts_by_id[ev.family_id]
|
||||
try:
|
||||
payloads = await adapter.fetch_version(client, act_ref, ev)
|
||||
async with async_session() as session:
|
||||
act = await session.get(LegalAct, ev.family_id)
|
||||
new_docs = 0
|
||||
for p in payloads:
|
||||
if await _ingest_payload(session, act, ev, p, backfill=bootstrap):
|
||||
new_docs += 1
|
||||
# 워터마크 영속 = 파싱 검증(payload floor) 통과 후에만
|
||||
act.watermark = ev.new_version_key
|
||||
if ev.kind == "repeal":
|
||||
act.repeal_detected_at = datetime.now(timezone.utc)
|
||||
await session.commit()
|
||||
ingested += new_docs
|
||||
logger.info(f"[statute] ingest {ev.family_id} ({ev.kind}): "
|
||||
f"payload {len(payloads)}건 중 신규 {new_docs}건")
|
||||
except Exception as e:
|
||||
failed += 1
|
||||
logger.error(f"[statute] ingest 실패 ({ev.family_id}): "
|
||||
f"{type(e).__name__}: {e!r} — 워터마크 미영속, 다음 폴링 재감지")
|
||||
await asyncio.sleep(_FETCH_DELAY_S)
|
||||
|
||||
# 생애주기 잡 — 수집 사이클 직후, 단일 트랜잭션 (0-2 ②)
|
||||
async with async_session() as session:
|
||||
stats = await run_lifecycle(session)
|
||||
await session.commit()
|
||||
logger.info(f"[statute] lifecycle: {stats}")
|
||||
|
||||
# health — fail-loud 가시성 (HC.io 폐기로 보드/health 행이 1차 관측면)
|
||||
async with async_session() as session:
|
||||
h = await _get_or_create_health(session, source_id)
|
||||
now = datetime.now(timezone.utc)
|
||||
if failed:
|
||||
_record_failure(h, f"ingest 실패 {failed}건", now)
|
||||
else:
|
||||
_record_success(h, ingested, False, now)
|
||||
await session.commit()
|
||||
|
||||
logger.info(f"[statute] run 완료 — 신규 문서 {ingested}건, 실패 {failed}건"
|
||||
+ (" (bootstrap)" if bootstrap else ""))
|
||||
|
||||
|
||||
async def _get_source(session) -> NewsSource:
|
||||
result = await session.execute(select(NewsSource).where(NewsSource.name == _SOURCE_NAME))
|
||||
source = result.scalars().first()
|
||||
if source is None:
|
||||
source = NewsSource(
|
||||
name=_SOURCE_NAME, feed_url=kr.LAW_SEARCH_URL, feed_type="rss",
|
||||
fetch_method="api", fulltext_policy="none", source_channel="crawl",
|
||||
category="Safety", language="ko", country="KR",
|
||||
enabled=False, # 6h 뉴스 사이클 비대상 — 본 워커가 daily 폴링
|
||||
)
|
||||
session.add(source)
|
||||
await session.flush()
|
||||
return source
|
||||
|
||||
|
||||
async def poll_once() -> int:
|
||||
"""관찰 전용 폴링 (PR① 잔존 CLI — 상태 변경 0)."""
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(LegalAct).where(LegalAct.watch.is_(True)).order_by(LegalAct.family_id)
|
||||
)
|
||||
rows = result.scalars().all()
|
||||
total = 0
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
events = await kr.poll_changes(client, [r for r in rows if r.jurisdiction == "KR"])
|
||||
for ev in events:
|
||||
logger.info(f"[statute] 변경 감지 ({ev.kind}): {ev.family_id} {ev.title} "
|
||||
f"MST={ev.new_version_key}")
|
||||
total = len(events)
|
||||
logger.info(f"[statute] poll 완료 — 변경 {total}건 (관찰 전용)")
|
||||
return total
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--bootstrap", action="store_true",
|
||||
help="26 family 현행판 1회 부트스트랩 (backfill 마커, R4-M1)")
|
||||
parser.add_argument("--poll-only", action="store_true", help="관찰 전용 폴링")
|
||||
args = parser.parse_args()
|
||||
if args.poll_only:
|
||||
asyncio.run(poll_once())
|
||||
else:
|
||||
asyncio.run(run(bootstrap=args.bootstrap))
|
||||
@@ -14,6 +14,7 @@ from datetime import datetime, timedelta, timezone
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
from core.config import settings
|
||||
from core.database import async_session
|
||||
from core.utils import setup_logger
|
||||
from models.study_memo_card_job import StudyMemoCardJob
|
||||
@@ -50,6 +51,10 @@ async def reset_stale_card_jobs() -> None:
|
||||
|
||||
async def consume_study_memo_card_queue() -> None:
|
||||
"""APScheduler 진입점. pending card_extract job 을 BATCH_SIZE 만큼 처리."""
|
||||
# 생성 LLM 홀드: claim 자체를 하지 않음 (1분 주기라 로그는 debug).
|
||||
if "study_memo_card" in settings.pipeline_held_stages:
|
||||
logger.debug("study_memo_card 보류 (pipeline.held_stages)")
|
||||
return
|
||||
await reset_stale_card_jobs()
|
||||
|
||||
async with async_session() as session:
|
||||
|
||||
@@ -59,6 +59,11 @@ async def reset_stale_study_jobs() -> None:
|
||||
|
||||
async def consume_study_queue() -> None:
|
||||
"""APScheduler 진입점. pending job BATCH_SIZE 만큼 처리."""
|
||||
# 생성 LLM 홀드: env(study_explanation_enabled) 와 별개의 self-contained 게이트.
|
||||
# pending 은 그대로 유지 (Mac mini derived-worker 흡수 경로도 본 게이트와 무관).
|
||||
if "study_explanation" in settings.pipeline_held_stages:
|
||||
logger.debug("study_explanation 보류 (pipeline.held_stages)")
|
||||
return
|
||||
await reset_stale_study_jobs()
|
||||
|
||||
async with async_session() as session:
|
||||
|
||||
@@ -12,6 +12,7 @@ from datetime import datetime, timedelta, timezone
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
from core.config import settings
|
||||
from core.database import async_session
|
||||
from core.utils import setup_logger
|
||||
from models.study_quiz_session_job import StudyQuizSessionJob
|
||||
@@ -48,6 +49,10 @@ async def reset_stale_session_jobs() -> None:
|
||||
|
||||
async def consume_study_session_queue() -> None:
|
||||
"""APScheduler 진입점. pending session_jobs 를 BATCH_SIZE 만큼 처리."""
|
||||
# 생성 LLM 홀드: claim 자체를 하지 않음 (1분 주기라 로그는 debug).
|
||||
if "study_session_analysis" in settings.pipeline_held_stages:
|
||||
logger.debug("study_session_analysis 보류 (pipeline.held_stages)")
|
||||
return
|
||||
await reset_stale_session_jobs()
|
||||
|
||||
async with async_session() as session:
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import AppKit
|
||||
import Foundation
|
||||
|
||||
/// macOS 파일 패널 + 네이티브 다운로드 헬퍼. AppKit(NSOpenPanel/NSSavePanel) 의존이라 AppFeature
|
||||
/// (맥OS UI 계층)에 둔다 — DSKit 은 크로스플랫폼 유지(향후 iOS/watchOS). 모두 @MainActor.
|
||||
@MainActor
|
||||
enum FilePanels {
|
||||
/// 업로드할 파일 1개 선택. 취소 시 nil.
|
||||
static func pickFileToUpload() -> URL? {
|
||||
let panel = NSOpenPanel()
|
||||
panel.allowsMultipleSelection = false
|
||||
panel.canChooseDirectories = false
|
||||
panel.canChooseFiles = true
|
||||
panel.message = "업로드할 문서를 선택하세요"
|
||||
panel.prompt = "업로드"
|
||||
return panel.runModal() == .OK ? panel.url : nil
|
||||
}
|
||||
|
||||
/// 저장 위치 선택. 취소 시 nil. 사용자가 고른 위치 = 샌드박스 쓰기 권한 부여(files.user-selected).
|
||||
static func pickSaveDestination(suggestedName: String) -> URL? {
|
||||
let panel = NSSavePanel()
|
||||
panel.nameFieldStringValue = suggestedName
|
||||
panel.message = "원본 파일을 저장할 위치"
|
||||
panel.prompt = "저장"
|
||||
return panel.runModal() == .OK ? panel.url : nil
|
||||
}
|
||||
}
|
||||
|
||||
/// 원본 파일 네이티브 다운로드. 인증은 URL 쿼리의 ?token= 으로만 이뤄지므로(헤더 아님), 토큰이 든
|
||||
/// URL 은 절대 로깅/에러 메시지에 노출하지 않는다. 저장 위치는 사용자가 NSSavePanel 로 선택.
|
||||
@MainActor
|
||||
enum FileDownloader {
|
||||
enum Outcome: Equatable {
|
||||
case saved(URL)
|
||||
case cancelled
|
||||
case failed(String)
|
||||
}
|
||||
|
||||
/// `url` = DSDownload.fileURL 로 만든 ?token= 인증 URL. `suggestedName` = 원본 파일명.
|
||||
static func download(from url: URL, suggestedName: String) async -> Outcome {
|
||||
guard let dest = FilePanels.pickSaveDestination(suggestedName: suggestedName) else {
|
||||
return .cancelled
|
||||
}
|
||||
do {
|
||||
let (temp, response) = try await URLSession.shared.download(from: url)
|
||||
// 다운로드된 임시 파일은 호출자 책임(async download 변형은 자동삭제 안 함) — 모든 종료
|
||||
// 경로에서 정리. 성공 시 move 가 temp 를 옮긴 뒤라 removeItem 은 무해한 no-op.
|
||||
defer { try? FileManager.default.removeItem(at: temp) }
|
||||
if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) {
|
||||
// 상태 코드만 노출 — URL/토큰은 절대 포함하지 않는다.
|
||||
return .failed("다운로드 실패 (HTTP \(http.statusCode))")
|
||||
}
|
||||
if FileManager.default.fileExists(atPath: dest.path) {
|
||||
try FileManager.default.removeItem(at: dest)
|
||||
}
|
||||
try FileManager.default.moveItem(at: temp, to: dest)
|
||||
return .saved(dest)
|
||||
} catch {
|
||||
// URLError/파일 오류의 localizedDescription 엔 URL 이 포함되지 않는다.
|
||||
return .failed("저장 실패: \((error as NSError).localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
import SwiftUI
|
||||
import AIFabric
|
||||
|
||||
/// RAG proof page: routes corpusAsk through AIService (-> AIRouter -> MockAIProvider). Explicit backend
|
||||
/// pick sets explicitProvider; an explicit-unavailable result renders a visible, non-retrying error.
|
||||
struct AskView: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
@State private var backend: BackendChoice = .auto
|
||||
|
||||
var body: some View {
|
||||
@Bindable var model = model
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
Picker("백엔드", selection: $backend) {
|
||||
ForEach(BackendChoice.allCases) { Text($0.label).tag($0) }
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
|
||||
HStack(spacing: 8) {
|
||||
TextField("코퍼스 전체에 질문", text: $model.askQuery)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.onSubmit { Task { await model.runAsk(backend: backend.provider) } }
|
||||
Button("질문") { Task { await model.runAsk(backend: backend.provider) } }
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
|
||||
if let result = model.askResult {
|
||||
switch result {
|
||||
case .success(let response):
|
||||
AICompletionView(response: response) { docID in
|
||||
model.section = .documents
|
||||
Task { await model.openDocument(docID) }
|
||||
}
|
||||
if let meta = model.askMeta {
|
||||
HStack(spacing: 6) {
|
||||
Chip("완성도 \(meta.completeness)", Sage.muted)
|
||||
if let aspects = meta.coveredAspects {
|
||||
ForEach(aspects, id: \.self) { Chip($0, Sage.brand) }
|
||||
}
|
||||
}
|
||||
}
|
||||
case .failure(let err):
|
||||
ErrorBanner(text: message(for: err))
|
||||
}
|
||||
} else {
|
||||
EmptyState(text: "질문을 입력하세요").frame(minHeight: 160)
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
}
|
||||
.background(Sage.surface)
|
||||
}
|
||||
|
||||
private func message(for error: AIServiceError) -> String {
|
||||
switch error {
|
||||
case .explicitUnavailable(let id):
|
||||
return "\(id.displayName) 백엔드를 쓸 수 없습니다 — 다른 백엔드로 자동 전환하지 않았습니다. 다른 백엔드를 고르세요."
|
||||
case .notConfigured(let id): return "\(id.displayName) 백엔드 미구성"
|
||||
case .noneAvailable: return "응답 가능한 백엔드가 없습니다."
|
||||
case .providerFailed(let s): return "응답 실패: \(s)"
|
||||
case .unknown(let s): return "오류: \(s)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum BackendChoice: String, CaseIterable, Identifiable {
|
||||
case auto, onDevice, localMLX, remoteDS
|
||||
var id: String { rawValue }
|
||||
var label: String {
|
||||
switch self {
|
||||
case .auto: return "자동"
|
||||
case .onDevice: return "온디바이스"
|
||||
case .localMLX: return "맥미니"
|
||||
case .remoteDS: return "원격 DS"
|
||||
}
|
||||
}
|
||||
var provider: AIProviderID? {
|
||||
switch self {
|
||||
case .auto: return nil
|
||||
case .onDevice: return .onDevice
|
||||
case .localMLX: return .localMLX
|
||||
case .remoteDS: return .remoteDS
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,51 +1,386 @@
|
||||
import SwiftUI
|
||||
import DSKit
|
||||
|
||||
/// Corpus-health overview (not a dumped table). Stat hero + domain distribution bars; tapping a
|
||||
/// domain jumps to Documents (cross-page nav proof).
|
||||
/// 홈 = 풀폭 데일리 코크핏 (시안 안1). detail 전폭을 받아 1000pt 캔버스로 좌측 정렬, 내부 2칼럼.
|
||||
/// 인사 → 오늘 스트립(검토 큐 + 속보 + 스탯) → 좌(빠른캡처·최근활동)/우(도메인분포·고정).
|
||||
struct DashboardView: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
ScrollView(.vertical) {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
if let s = model.stats {
|
||||
LazyVGrid(columns: [GridItem(.adaptive(minimum: 150), spacing: 12)], spacing: 12) {
|
||||
StatCard(title: "전체", value: s.total, color: Sage.brand)
|
||||
StatCard(title: "문서", value: s.counts["document"] ?? 0, color: Sage.brand)
|
||||
StatCard(title: "승인 대기", value: s.libraryPendingSuggestions, color: Sage.amber)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("카테고리 분포").font(.headline).foregroundStyle(Sage.ink)
|
||||
ForEach(s.counts.sorted { $0.value > $1.value }, id: \.key) { key, value in
|
||||
DomainBar(name: Self.categoryLabel(key), count: value, max: s.counts.values.max() ?? 1)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { model.section = .documents }
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.background(Sage.card, in: RoundedRectangle(cornerRadius: 14))
|
||||
.overlay(RoundedRectangle(cornerRadius: 14).stroke(Sage.line))
|
||||
} else {
|
||||
GreetingHeader()
|
||||
if model.stats == nil && model.tree.isEmpty {
|
||||
ProgressView().frame(maxWidth: .infinity, minHeight: 200)
|
||||
} else {
|
||||
TodayStrip()
|
||||
HStack(alignment: .top, spacing: 18) {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
CaptureCard()
|
||||
ActivityTimeline()
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
DomainDistribution()
|
||||
PinnedItems()
|
||||
}
|
||||
.frame(width: 312)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
.frame(maxWidth: 1000, alignment: .leading)
|
||||
.padding(.horizontal, 30)
|
||||
.padding(.vertical, 26)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||
.background(Sage.surface)
|
||||
}
|
||||
}
|
||||
|
||||
/// 서버 category enum → 표시명 (미등록 키는 raw 노출 — 신규 카테고리 추가에 안전).
|
||||
static func categoryLabel(_ key: String) -> String {
|
||||
switch key {
|
||||
case "document": return "문서"
|
||||
case "library": return "자료실"
|
||||
case "news": return "뉴스"
|
||||
case "law": return "법령"
|
||||
case "memo": return "메모"
|
||||
case "audio": return "오디오"
|
||||
case "video": return "비디오"
|
||||
default: return key
|
||||
// MARK: - Greeting
|
||||
|
||||
private struct GreetingHeader: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 10) {
|
||||
Text("안녕하세요, \(model.currentUser?.username ?? "사용자")")
|
||||
.font(.system(size: 22, weight: .bold)).kerning(-0.4).foregroundStyle(Sage.ink)
|
||||
Text("오늘도 지식 쌓는 날.").font(.callout).foregroundStyle(Sage.muted)
|
||||
}
|
||||
Text(Self.today).font(.caption).foregroundStyle(Sage.muted.opacity(0.8))
|
||||
}
|
||||
.padding(.bottom, 4)
|
||||
}
|
||||
|
||||
static var today: String {
|
||||
let f = DateFormatter()
|
||||
f.locale = Locale(identifier: "ko_KR")
|
||||
f.dateFormat = "y년 M월 d일 EEEE"
|
||||
return f.string(from: Date())
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Today strip (hero)
|
||||
|
||||
private struct TodayStrip: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 14) {
|
||||
HStack(alignment: .top, spacing: 0) {
|
||||
reviewQueue
|
||||
.frame(minWidth: 150, alignment: .leading)
|
||||
Rectangle().fill(Sage.line).frame(width: 1).padding(.horizontal, 22)
|
||||
digestTeaser
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
Divider().overlay(Sage.line)
|
||||
statRow
|
||||
}
|
||||
.dashCard(padding: 20)
|
||||
}
|
||||
|
||||
private var reviewQueue: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(model.reviewPendingCount.map(String.init) ?? "—")
|
||||
.font(.system(size: 38, weight: .bold)).kerning(-1.5).monospacedDigit()
|
||||
.foregroundStyle(Sage.amber)
|
||||
Text("검토 대기 문서").font(.caption).foregroundStyle(Sage.muted)
|
||||
Button { model.section = .documents } label: {
|
||||
Text("검토 시작 →").font(.caption.weight(.semibold)).foregroundStyle(Sage.brand)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private var digestTeaser: some View {
|
||||
if let t = topTopic {
|
||||
Button { model.section = .digest } label: {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(spacing: 8) {
|
||||
Chip("속보", Sage.danger)
|
||||
Text("\(model.digest?.digestDateDisplay ?? "") 브리핑")
|
||||
.font(.caption2).foregroundStyle(Sage.muted)
|
||||
}
|
||||
Text(t.label).font(.system(size: 15)).foregroundStyle(Sage.ink)
|
||||
.lineLimit(2).fixedSize(horizontal: false, vertical: true)
|
||||
.multilineTextAlignment(.leading)
|
||||
Text(t.meta).font(.caption2).foregroundStyle(Sage.muted)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
} else {
|
||||
Text("오늘 브리핑이 아직 없습니다").font(.callout).foregroundStyle(Sage.muted)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
private var statRow: some View {
|
||||
HStack(spacing: 0) {
|
||||
StatCell(value: model.stats?.total ?? 0, label: "전체", color: Sage.brand)
|
||||
StatCell(value: model.stats?.counts["document"] ?? 0, label: "문서")
|
||||
StatCell(value: domainCount("Industrial_Safety"), label: "산업안전",
|
||||
color: Sage.domainColor("Industrial_Safety"))
|
||||
StatCell(value: domainCount("Engineering"), label: "엔지니어링",
|
||||
color: Sage.domainColor("Engineering"))
|
||||
StatCell(value: domainCount("General"), label: "자료실", color: Sage.domainColor("General"))
|
||||
StatCell(value: model.stats?.counts["memo"] ?? model.memoList.count, label: "메모")
|
||||
}
|
||||
}
|
||||
|
||||
private func domainCount(_ name: String) -> Int {
|
||||
model.tree.first { $0.name == name }?.count ?? 0
|
||||
}
|
||||
|
||||
private var topTopic: (label: String, meta: String)? {
|
||||
guard let digest = model.digest else { return nil }
|
||||
var best: (TopicResponse, String)?
|
||||
for c in digest.countries {
|
||||
for t in c.topics where best == nil || (t.importanceScore ?? 0) > (best!.0.importanceScore ?? 0) {
|
||||
best = (t, c.country)
|
||||
}
|
||||
}
|
||||
guard let (t, country) = best else { return nil }
|
||||
let arts = t.articleCount ?? t.articles.count
|
||||
var meta = "관련 기사 \(arts)건"
|
||||
if let imp = t.importanceScore { meta += " · 중요도 \(String(format: "%.0f", imp))" }
|
||||
if !country.isEmpty { meta += " · \(country)" }
|
||||
return (t.topicLabel, meta)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Left column
|
||||
|
||||
private struct CaptureCard: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
|
||||
var body: some View {
|
||||
@Bindable var m = model
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
SectionLabel("빠른 캡처")
|
||||
HStack(spacing: 8) {
|
||||
TextField("메모 한 줄 남기기…", text: $m.captureText)
|
||||
.textFieldStyle(.plain)
|
||||
.padding(.horizontal, 14).frame(height: 38)
|
||||
.background(Sage.surface, in: RoundedRectangle(cornerRadius: 8))
|
||||
.overlay(RoundedRectangle(cornerRadius: 8).stroke(Sage.line))
|
||||
.onSubmit { Task { await model.saveMemo() } }
|
||||
Button { Task { await model.saveMemo() } } label: {
|
||||
Text("저장").font(.callout.weight(.semibold)).foregroundStyle(.white)
|
||||
.padding(.horizontal, 18).frame(height: 38)
|
||||
.background(Sage.brand, in: RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(model.captureText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
}
|
||||
Button {
|
||||
guard let url = FilePanels.pickFileToUpload() else { return }
|
||||
Task { await model.uploadPicked(url) }
|
||||
} label: {
|
||||
Text("+ 파일 업로드").font(.caption.weight(.semibold)).foregroundStyle(Sage.brand)
|
||||
.padding(.horizontal, 10).padding(.vertical, 5)
|
||||
.background(Sage.brand.opacity(0.12), in: Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.dashCard()
|
||||
}
|
||||
}
|
||||
|
||||
private struct ActivityTimeline: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
|
||||
private var recent: [DocumentResponse] {
|
||||
model.documentList
|
||||
.sorted { ($0.updatedAt ?? .distantPast) > ($1.updatedAt ?? .distantPast) }
|
||||
.prefix(5).map { $0 }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
SectionLabel("최근 활동")
|
||||
Spacer()
|
||||
Button { model.section = .documents } label: {
|
||||
Text("전체 보기 →").font(.caption.weight(.semibold)).foregroundStyle(Sage.brand)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
if recent.isEmpty {
|
||||
Text("최근 활동이 없습니다").font(.caption).foregroundStyle(Sage.muted)
|
||||
} else {
|
||||
VStack(spacing: 0) {
|
||||
ForEach(Array(recent.enumerated()), id: \.element.id) { idx, doc in
|
||||
ActivityRow(doc: doc, isLast: idx == recent.count - 1)
|
||||
if idx != recent.count - 1 { Divider().overlay(Sage.line) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.dashCard()
|
||||
}
|
||||
}
|
||||
|
||||
private struct ActivityRow: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
let doc: DocumentResponse
|
||||
let isLast: Bool
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Text(Self.relative(doc.updatedAt))
|
||||
.font(.caption2).foregroundStyle(Sage.muted)
|
||||
.frame(width: 54, alignment: .trailing)
|
||||
VStack(spacing: 0) {
|
||||
Circle().fill(Sage.domainColor(doc.aiDomain)).frame(width: 8, height: 8).padding(.top, 4)
|
||||
if !isLast { Rectangle().fill(Sage.line).frame(width: 1).frame(maxHeight: .infinity) }
|
||||
}
|
||||
.frame(width: 14)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text("\(localizedDomain(doc.aiDomain)) · \(doc.displayFormat.uppercased())")
|
||||
.font(.caption2.weight(.bold)).foregroundStyle(Sage.domainColor(doc.aiDomain))
|
||||
Text(doc.title ?? doc.downloadLabel).font(.callout).foregroundStyle(Sage.ink).lineLimit(2)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.bottom, isLast ? 0 : 10)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { model.section = .documents; Task { await model.openDocument(doc.id) } }
|
||||
}
|
||||
|
||||
static func relative(_ date: Date?) -> String {
|
||||
guard let date else { return "" }
|
||||
let f = RelativeDateTimeFormatter()
|
||||
f.locale = Locale(identifier: "ko_KR")
|
||||
f.unitsStyle = .short
|
||||
return f.localizedString(for: date, relativeTo: Date())
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Right column
|
||||
|
||||
private struct DomainDistribution: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
|
||||
private var domains: [DomainTreeNode] { model.tree.sorted { $0.count > $1.count } }
|
||||
private var domainTotal: Int { domains.reduce(0) { $0 + $1.count } }
|
||||
private var sum: Int { max(1, domainTotal) } // 0-나눗셈 가드 (막대 폭 분모 전용)
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
SectionLabel("도메인 분포")
|
||||
// 헤드라인 합계 = 막대/범례와 동일 분모(도메인 트리 합) — 사용자가 범례를 더해 같은 값에 도달.
|
||||
HStack(alignment: .firstTextBaseline, spacing: 3) {
|
||||
Text("분류").font(.caption).foregroundStyle(Sage.muted)
|
||||
Text("\(domainTotal)").font(.system(size: 18, weight: .semibold))
|
||||
.monospacedDigit().foregroundStyle(Sage.ink)
|
||||
Text("건").font(.caption).foregroundStyle(Sage.muted)
|
||||
}
|
||||
GeometryReader { geo in
|
||||
HStack(spacing: 2) {
|
||||
ForEach(domains) { d in
|
||||
Rectangle().fill(Sage.domainColor(d.name))
|
||||
.frame(width: max(2, geo.size.width * CGFloat(d.count) / CGFloat(sum)))
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(height: 8)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 4))
|
||||
VStack(spacing: 7) {
|
||||
ForEach(domains) { d in
|
||||
Button {
|
||||
model.section = .documents
|
||||
Task { await model.loadDocuments(domain: d.path) }
|
||||
} label: {
|
||||
HStack(spacing: 8) {
|
||||
RoundedRectangle(cornerRadius: 2).fill(Sage.domainColor(d.name)).frame(width: 10, height: 10)
|
||||
Text(localizedDomain(d.name)).font(.caption).foregroundStyle(Sage.ink)
|
||||
.lineLimit(1).frame(maxWidth: .infinity, alignment: .leading)
|
||||
Text("\(d.count)").font(.caption.monospacedDigit()).foregroundStyle(Sage.muted)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.dashCard()
|
||||
}
|
||||
}
|
||||
|
||||
private struct PinnedItems: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
|
||||
private var docs: [DocumentResponse] { model.documentList.filter { $0.pinned == true } }
|
||||
private var memos: [MemoResponse] { model.memoList.filter { $0.isPinned } }
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
SectionLabel("고정 항목")
|
||||
Spacer()
|
||||
Button { model.section = .documents } label: {
|
||||
Text("관리 →").font(.caption.weight(.semibold)).foregroundStyle(Sage.brand)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
if docs.isEmpty && memos.isEmpty {
|
||||
Text("고정된 항목이 없습니다").font(.caption).foregroundStyle(Sage.muted)
|
||||
} else {
|
||||
VStack(spacing: 8) {
|
||||
ForEach(docs) { d in
|
||||
PinRow(kind: "문서", kindColor: Sage.domainColor("Engineering"),
|
||||
title: d.title ?? d.downloadLabel, date: d.updatedAtRaw) {
|
||||
model.section = .documents; Task { await model.openDocument(d.id) }
|
||||
}
|
||||
}
|
||||
ForEach(memos) { m in
|
||||
PinRow(kind: "메모", kindColor: Sage.brand,
|
||||
title: m.title ?? (m.content ?? "메모"), date: m.updatedAtRaw ?? "") {
|
||||
model.section = .memos; Task { await model.openMemo(m.id) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.dashCard()
|
||||
}
|
||||
}
|
||||
|
||||
private struct PinRow: View {
|
||||
let kind: String
|
||||
let kindColor: Color
|
||||
let title: String
|
||||
let date: String
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
Chip(kind, kindColor)
|
||||
Text(title).font(.caption).foregroundStyle(Sage.ink).lineLimit(2)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
Text(date.prefix(10)).font(.caption2.monospacedDigit()).foregroundStyle(Sage.muted)
|
||||
}
|
||||
.padding(10)
|
||||
.background(Sage.surface, in: RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
#Preview("Dashboard") {
|
||||
@Previewable @State var model = AppModel.preview
|
||||
DashboardView()
|
||||
.environment(model)
|
||||
.frame(width: 1100, height: 760)
|
||||
.task { await model.bootstrap() }
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -1,91 +1,367 @@
|
||||
import SwiftUI
|
||||
import DSKit
|
||||
|
||||
struct DocumentListView: View {
|
||||
/// 문서 = DEVONthink식 컬럼 브라우저. 소스트리(분류)는 글로벌 사이드바에 있고, 이 페이지는 detail
|
||||
/// 전폭 안에서 내부 HSplitView 3-pane = 컬럼 리스트 | MD 리더 | 인스펙터(토글). 도메인 필터는
|
||||
/// 사이드바가 model.loadDocuments(domain:) 로 서버 재조회.
|
||||
struct DocumentsBrowser: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
@State private var showInspector = true
|
||||
@State private var sortOrder = [KeyPathComparator(\DocumentResponse.sortUpdated, order: .reverse)]
|
||||
|
||||
var body: some View {
|
||||
HSplitView {
|
||||
DocumentListTable(sortOrder: $sortOrder)
|
||||
.frame(minWidth: 300, idealWidth: 360, maxWidth: 460)
|
||||
DocumentReader(showInspector: $showInspector)
|
||||
.frame(minWidth: 420, maxWidth: .infinity)
|
||||
if showInspector, let d = model.documentDetail {
|
||||
DocumentInspector(detail: d)
|
||||
.frame(minWidth: 280, idealWidth: 320, maxWidth: 360)
|
||||
}
|
||||
}
|
||||
.task { await model.ensureDocumentsLoaded() } // 진입 시 현재 필터 전체 문서 load-all
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Column list (sortable Table)
|
||||
|
||||
private extension DocumentResponse {
|
||||
var sortTitle: String { title ?? downloadLabel }
|
||||
var sortFormat: String { (originalFormat ?? fileFormat ?? "").lowercased() }
|
||||
var sortUpdated: String { updatedAtRaw }
|
||||
/// "PDF→MD" / "MD" 식 종류 배지 라벨.
|
||||
var formatBadge: String {
|
||||
if let orig = originalFormat, orig.lowercased() != (fileFormat ?? "").lowercased() {
|
||||
return "\(orig.uppercased())→MD"
|
||||
}
|
||||
return displayFormat.uppercased()
|
||||
}
|
||||
}
|
||||
|
||||
struct DocumentListTable: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
@Binding var sortOrder: [KeyPathComparator<DocumentResponse>]
|
||||
|
||||
private var documents: [DocumentResponse] { model.documentList.sorted(using: sortOrder) }
|
||||
|
||||
var body: some View {
|
||||
let selection = Binding<Int?>(
|
||||
get: { model.selectedDocumentID },
|
||||
set: { if let id = $0 { Task { await model.openDocument(id) } } }
|
||||
)
|
||||
List(model.documentList, selection: selection) { doc in
|
||||
DocumentRow(doc: doc)
|
||||
}
|
||||
.listStyle(.inset)
|
||||
.background(Sage.surface)
|
||||
}
|
||||
}
|
||||
|
||||
struct DocumentRow: View {
|
||||
let doc: DocumentResponse
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(spacing: 6) {
|
||||
Chip(doc.displayFormat.uppercased(), Sage.formatColor(doc.displayFormat))
|
||||
Text(doc.title ?? doc.downloadLabel)
|
||||
.font(.callout.weight(.medium)).foregroundStyle(Sage.ink).lineLimit(1)
|
||||
Spacer()
|
||||
if doc.pinned == true { Text("고정").font(.caption2).foregroundStyle(Sage.amber) }
|
||||
}
|
||||
HStack(spacing: 6) {
|
||||
if let d = doc.aiDomain { Chip(d, Sage.domainColor(d)) }
|
||||
if let r = doc.reviewStatus {
|
||||
Text(r).font(.caption2).foregroundStyle(Sage.reviewStatusColor(r))
|
||||
Group {
|
||||
if model.documentList.isEmpty {
|
||||
EmptyState(text: "문서가 없습니다")
|
||||
} else {
|
||||
Table(documents, selection: selection, sortOrder: $sortOrder) {
|
||||
TableColumn("제목", value: \.sortTitle) { doc in
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(doc.title ?? doc.downloadLabel)
|
||||
.font(.system(size: 12.5, weight: .semibold)).foregroundStyle(Sage.ink).lineLimit(1)
|
||||
Text(localizedDomain(doc.aiDomain))
|
||||
.font(.system(size: 11)).foregroundStyle(Sage.muted).lineLimit(1)
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
TableColumn("종류", value: \.sortFormat) { doc in
|
||||
Chip(doc.formatBadge, Sage.formatColor(doc.originalFormat ?? doc.displayFormat))
|
||||
}
|
||||
.width(min: 66, ideal: 74, max: 96)
|
||||
TableColumn("수정", value: \.sortUpdated) { doc in
|
||||
Text(doc.updatedAtRaw.prefix(10))
|
||||
.font(.caption2.monospacedDigit()).foregroundStyle(Sage.muted)
|
||||
}
|
||||
.width(min: 78, ideal: 86, max: 110)
|
||||
}
|
||||
Spacer()
|
||||
Text(doc.updatedAtRaw.prefix(10)).font(.caption2.monospacedDigit()).foregroundStyle(Sage.muted)
|
||||
.tint(Sage.brand)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
.background(Sage.card)
|
||||
}
|
||||
}
|
||||
|
||||
/// MD-first detail: render md_content when renderable, else extracted_text fallback + 'MD 변환 대기'
|
||||
/// badge + emphasized original-download button. (Download builds a real-shaped ?token= URL.)
|
||||
struct DocumentDetailView: View {
|
||||
// MARK: - Reader
|
||||
|
||||
struct DocumentReader: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
@Binding var showInspector: Bool
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let detail = model.documentDetail {
|
||||
VStack(spacing: 0) {
|
||||
ReaderHeader(detail: detail, showInspector: $showInspector)
|
||||
ReaderBody(detail: detail)
|
||||
}
|
||||
} else {
|
||||
EmptyState(text: "문서를 선택하세요")
|
||||
}
|
||||
}
|
||||
.background(Sage.card)
|
||||
}
|
||||
}
|
||||
|
||||
private struct ReaderHeader: View {
|
||||
let detail: DocumentDetailResponse
|
||||
@Binding var showInspector: Bool
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(crumb).font(.system(size: 11)).foregroundStyle(Sage.muted).lineLimit(1)
|
||||
HStack(alignment: .firstTextBaseline, spacing: 10) {
|
||||
Text(detail.base.title ?? detail.base.downloadLabel)
|
||||
.font(.system(size: 18, weight: .heavy)).foregroundStyle(Sage.ink).lineLimit(2)
|
||||
Spacer()
|
||||
DownloadButton(doc: detail.base, compact: true)
|
||||
inspectorToggle
|
||||
}
|
||||
metaBadges
|
||||
tagRow
|
||||
}
|
||||
.padding(.horizontal, 26).padding(.vertical, 14)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Sage.card)
|
||||
.overlay(alignment: .bottom) { Rectangle().fill(Sage.line).frame(height: 1) }
|
||||
}
|
||||
|
||||
private var crumb: String {
|
||||
let dom = localizedDomain(detail.base.aiDomain)
|
||||
if let sub = detail.base.aiSubGroup, !sub.isEmpty { return "\(dom) › \(sub)" }
|
||||
return dom
|
||||
}
|
||||
|
||||
/// 웹 상세 페이지 헤더 배지: 도메인 · 문서유형 · tier DEEP · 신뢰도 · PDF→MD success.
|
||||
@ViewBuilder private var metaBadges: some View {
|
||||
let b = detail.base
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 6) {
|
||||
if let d = b.aiDomain { Chip(localizedDomain(d), Sage.domainColor(d)) }
|
||||
if let t = b.documentType, !t.isEmpty { Chip(t, Sage.muted) }
|
||||
if b.aiAnalysisTier == "deep" { Chip("tier DEEP", Sage.brand) }
|
||||
if let c = b.aiConfidence { Chip("신뢰도 \(String(format: "%.2f", c))", Sage.brandDark) }
|
||||
if detail.mdIsRenderable { Chip("PDF→MD success", Sage.mdStatusColor("completed")) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var inspectorToggle: some View {
|
||||
Button { withAnimation(.easeInOut(duration: 0.2)) { showInspector.toggle() } } label: {
|
||||
Image(systemName: "info.circle").font(.system(size: 15))
|
||||
.foregroundStyle(showInspector ? Sage.brandDark : Sage.muted)
|
||||
.frame(width: 30, height: 30)
|
||||
.background(showInspector ? Sage.brand.opacity(0.14) : Sage.card, in: RoundedRectangle(cornerRadius: 8))
|
||||
.overlay(RoundedRectangle(cornerRadius: 8).stroke(showInspector ? Sage.brand : Sage.line))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.help("인스펙터")
|
||||
}
|
||||
|
||||
@ViewBuilder private var tagRow: some View {
|
||||
let tags = detail.base.aiTags ?? []
|
||||
if detail.mdStatus != nil || !tags.isEmpty {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 6) {
|
||||
if let st = detail.mdStatus { Chip("MD \(st)", Sage.mdStatusColor(st)) }
|
||||
ForEach(tags, id: \.self) { Chip($0, Sage.brand) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ReaderBody: View {
|
||||
let detail: DocumentDetailResponse
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
Text(detail.base.title ?? detail.base.downloadLabel)
|
||||
.font(.title2.weight(.bold)).foregroundStyle(Sage.ink)
|
||||
|
||||
HStack(spacing: 8) {
|
||||
if let d = detail.base.aiDomain { Chip(d, Sage.domainColor(d)) }
|
||||
Chip(detail.base.displayFormat.uppercased(), Sage.formatColor(detail.base.displayFormat))
|
||||
if let conf = detail.base.aiConfidence {
|
||||
Chip("AI \(String(format: "%.0f%%", conf * 100))", Sage.muted)
|
||||
}
|
||||
Spacer()
|
||||
if let url = model.downloadURL(for: detail.base) {
|
||||
Link(detail.base.downloadLabel, destination: url).font(.callout.weight(.semibold))
|
||||
}
|
||||
}
|
||||
|
||||
if let tags = detail.base.aiTags, !tags.isEmpty {
|
||||
HStack(spacing: 6) { ForEach(tags, id: \.self) { Chip($0, Sage.brand) } }
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
if detail.mdIsRenderable, let md = detail.mdContent {
|
||||
MarkdownView(md)
|
||||
} else {
|
||||
HStack { Chip("MD 변환 대기", Sage.amber); Spacer() }
|
||||
Text(detail.extractedText ?? "본문 없음")
|
||||
.font(.body).foregroundStyle(Sage.muted)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
if let url = model.downloadURL(for: detail.base) {
|
||||
Link("원본 다운로드 — \(detail.base.downloadLabel)", destination: url)
|
||||
.font(.callout.weight(.semibold))
|
||||
HStack(spacing: 0) {
|
||||
Spacer(minLength: 0)
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
if detail.mdIsRenderable, let md = detail.mdContent {
|
||||
MarkdownView(md)
|
||||
} else {
|
||||
HStack { Chip("MD 변환 대기", Sage.amber); Spacer() }
|
||||
Text(detail.extractedText ?? "본문 없음")
|
||||
.font(.body).foregroundStyle(Sage.muted)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
DownloadButton(doc: detail.base, compact: false)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: 700, alignment: .leading)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(.horizontal, 28).padding(.top, 22).padding(.bottom, 44)
|
||||
}
|
||||
.background(Sage.card)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Inspector
|
||||
|
||||
struct DocumentInspector: View {
|
||||
let detail: DocumentDetailResponse
|
||||
|
||||
private var base: DocumentResponse { detail.base }
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
// 인사이트 (웹 상세 페이지 양식: TL;DR · 핵심점 · 심층 · 불일치)
|
||||
if let tldr = (base.aiTldr ?? base.aiSummary), !tldr.isEmpty {
|
||||
InspectorSection("TL;DR") {
|
||||
Text(tldr).font(.system(size: 12)).foregroundStyle(Sage.ink).lineSpacing(2)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
if let bullets = base.aiBullets, !bullets.isEmpty {
|
||||
InspectorSection("핵심점") {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
ForEach(bullets, id: \.self) { b in
|
||||
HStack(alignment: .top, spacing: 6) {
|
||||
Text("·").font(.system(size: 12, weight: .bold)).foregroundStyle(Sage.amber)
|
||||
Text(b).font(.system(size: 12)).foregroundStyle(Sage.ink)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let deep = base.aiDetailSummary, !deep.isEmpty {
|
||||
InspectorSection("심층") {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
if base.aiAnalysisTier == "deep" { Chip("DEEP", Sage.brand) }
|
||||
Text(deep).font(.system(size: 11.5)).foregroundStyle(Sage.ink).lineSpacing(2)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
}
|
||||
if let inc = base.aiInconsistencies, !inc.isEmpty {
|
||||
InspectorSection("불일치 \(inc.count)") {
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
ForEach(inc, id: \.self) { x in
|
||||
Text("· \(x)").font(.system(size: 11.5)).foregroundStyle(Sage.ink)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 정보
|
||||
InspectorSection("정보") {
|
||||
VStack(spacing: 0) {
|
||||
KV("종류", base.formatBadge)
|
||||
KV("도메인", localizedDomain(base.aiDomain))
|
||||
KV("하위", base.aiSubGroup ?? "—")
|
||||
KV("수정", String(base.updatedAtRaw.prefix(10)))
|
||||
if let size = base.fileSize {
|
||||
KV("원본", ByteCountFormatter.string(fromByteCount: Int64(size), countStyle: .file))
|
||||
}
|
||||
if let st = detail.mdStatus { KV("md 상태", st, color: Sage.mdStatusColor(st)) }
|
||||
if let tier = base.aiAnalysisTier { KV("tier", tier, color: Sage.brandDark) }
|
||||
if let c = base.aiConfidence { KV("신뢰도", String(format: "%.2f", c), color: Sage.brand) }
|
||||
KV("읽음", "\(base.reads)회")
|
||||
}
|
||||
}
|
||||
if let tags = base.aiTags, !tags.isEmpty {
|
||||
InspectorSection("태그") { TagWrap(tags: tags) }
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16).padding(.vertical, 18)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Sage.sidebar)
|
||||
.overlay(alignment: .leading) { Rectangle().fill(Sage.line).frame(width: 1) }
|
||||
}
|
||||
}
|
||||
|
||||
private struct InspectorSection<Content: View>: View {
|
||||
let title: String
|
||||
@ViewBuilder let content: Content
|
||||
init(_ title: String, @ViewBuilder content: () -> Content) { self.title = title; self.content = content() }
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(title).font(.system(size: 10, weight: .heavy)).tracking(0.8)
|
||||
.textCase(.uppercase).foregroundStyle(Sage.muted.opacity(0.8))
|
||||
content
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
private struct KV: View {
|
||||
let k: String
|
||||
let v: String
|
||||
var color: Color = Sage.ink
|
||||
init(_ k: String, _ v: String, color: Color = Sage.ink) { self.k = k; self.v = v; self.color = color }
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text(k).font(.system(size: 12)).foregroundStyle(Sage.muted)
|
||||
Spacer()
|
||||
Text(v).font(.system(size: 12, weight: .semibold)).foregroundStyle(color)
|
||||
.multilineTextAlignment(.trailing)
|
||||
}
|
||||
.padding(.vertical, 3)
|
||||
}
|
||||
}
|
||||
|
||||
/// 좁은 인스펙터용 태그 줄바꿈 (2개씩 한 줄 — 커스텀 Layout 없이 결정적).
|
||||
private struct TagWrap: View {
|
||||
let tags: [String]
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
ForEach(Array(stride(from: 0, to: tags.count, by: 2)), id: \.self) { i in
|
||||
HStack(spacing: 6) {
|
||||
Chip(tags[i], Sage.brand)
|
||||
if i + 1 < tags.count { Chip(tags[i + 1], Sage.brand) }
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Native download button (preserved)
|
||||
|
||||
/// 원본 파일 네이티브 다운로드 버튼. ?token= 인증 URL 을 NSSavePanel 로 고른 위치에 저장(브라우저
|
||||
/// 핸드오프 아님). 진행 스피너 + 저장 결과/오류를 인라인 표시. note 문서는 다운로드 대상 없음 → 숨김.
|
||||
struct DownloadButton: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
let doc: DocumentResponse
|
||||
/// compact = 헤더용 짧은 라벨(파일명만) / false = 본문 폴백용 긴 라벨.
|
||||
var compact: Bool
|
||||
|
||||
@State private var busy = false
|
||||
@State private var status: String?
|
||||
@State private var isError = false
|
||||
|
||||
var body: some View {
|
||||
if let url = model.downloadURL(for: doc) {
|
||||
HStack(spacing: 8) {
|
||||
Button {
|
||||
Task {
|
||||
busy = true; status = nil; isError = false
|
||||
let outcome = await FileDownloader.download(from: url, suggestedName: doc.downloadLabel)
|
||||
busy = false
|
||||
switch outcome {
|
||||
case .saved(let dest): status = "저장됨: \(dest.lastPathComponent)"; isError = false
|
||||
case .cancelled: status = nil
|
||||
case .failed(let msg): status = msg; isError = true
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Label(compact ? doc.downloadLabel : "원본 다운로드 — \(doc.downloadLabel)",
|
||||
systemImage: "arrow.down.circle")
|
||||
.font(.callout.weight(.semibold))
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.disabled(busy)
|
||||
if busy { ProgressView().controlSize(.small) }
|
||||
if let s = status {
|
||||
Text(s).font(.caption)
|
||||
.foregroundStyle(isError ? Sage.danger : Sage.muted)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
}
|
||||
.background(Sage.surface)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,11 +13,10 @@ struct MemoListView: View {
|
||||
.textFieldStyle(.roundedBorder)
|
||||
Button("저장") {
|
||||
let content = draft
|
||||
draft = ""
|
||||
Task { _ = try? await model.client.createMemo(MemoCreate(content: content)) }
|
||||
Task { if await model.saveMemo(content) { draft = "" } }
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(draft.isEmpty)
|
||||
.disabled(draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
}
|
||||
.padding(12)
|
||||
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
import SwiftUI
|
||||
import DSKit
|
||||
|
||||
/// Distinct from the Documents table: relevance-forward result cards (score bar + match_reason).
|
||||
struct SearchView: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
|
||||
var body: some View {
|
||||
@Bindable var model = model
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HStack(spacing: 8) {
|
||||
TextField("검색어를 입력하세요", text: $model.searchQuery)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.onSubmit { Task { await model.runSearch() } }
|
||||
Button("검색") { Task { await model.runSearch() } }
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
.padding(12)
|
||||
|
||||
if let response = model.searchResponse {
|
||||
List(response.results) { result in
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
HStack(spacing: 6) {
|
||||
if let d = result.aiDomain { Chip(d, Sage.domainColor(d)) }
|
||||
Text(result.title ?? "문서 \(result.id)")
|
||||
.font(.callout.weight(.medium)).foregroundStyle(Sage.ink).lineLimit(1)
|
||||
Spacer()
|
||||
if let m = result.matchReason {
|
||||
Text(m).font(.caption2).foregroundStyle(Sage.muted)
|
||||
}
|
||||
}
|
||||
Text(result.snippet ?? result.aiSummary ?? "")
|
||||
.font(.caption).foregroundStyle(Sage.muted).lineLimit(2)
|
||||
if let score = result.score { ScoreBar(score: score) }
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
model.section = .documents
|
||||
Task { await model.openDocument(result.id) }
|
||||
}
|
||||
}
|
||||
.listStyle(.inset)
|
||||
} else {
|
||||
EmptyState(text: "검색어를 입력하세요")
|
||||
}
|
||||
}
|
||||
.background(Sage.surface)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,58 @@
|
||||
import SwiftUI
|
||||
|
||||
/// 도메인 raw 값(영문/한자 enum 키) → 한글 표시 라벨. 색은 Sage.domainColor(raw) 가 raw 로 키잉하므로
|
||||
/// 색에는 raw, 표시에만 이 라벨을 쓴다. 미매핑은 원본 그대로.
|
||||
func localizedDomain(_ raw: String?) -> String {
|
||||
guard let raw, !raw.isEmpty else { return "미분류" }
|
||||
// 경로형(Philosophy/Aesthetics)이면 leaf 만 매핑 시도, 없으면 leaf 원본
|
||||
let leaf = raw.split(separator: "/").last.map(String.init) ?? raw
|
||||
let map: [String: String] = [
|
||||
"Engineering": "엔지니어링", "Industrial_Safety": "산업안전", "General": "자료실",
|
||||
"Programming": "프로그래밍", "법령": "법령", "Philosophy": "철학",
|
||||
]
|
||||
return map[raw] ?? map[leaf] ?? leaf
|
||||
}
|
||||
|
||||
/// 카드/섹션 머리말 라벨 (대문자·heavy·muted) — 대시보드/인스펙터 공용.
|
||||
struct SectionLabel: View {
|
||||
let text: String
|
||||
init(_ text: String) { self.text = text }
|
||||
var body: some View {
|
||||
Text(text)
|
||||
.font(.caption.weight(.heavy))
|
||||
.textCase(.uppercase)
|
||||
.kerning(0.7)
|
||||
.foregroundStyle(Sage.muted)
|
||||
}
|
||||
}
|
||||
|
||||
/// 공용 카드 크롬 (Sage.card + corner 12 + Sage.line stroke + 패딩).
|
||||
struct DashCard: ViewModifier {
|
||||
var padding: CGFloat = 18
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.padding(padding)
|
||||
.background(Sage.card, in: RoundedRectangle(cornerRadius: 12))
|
||||
.overlay(RoundedRectangle(cornerRadius: 12).stroke(Sage.line))
|
||||
}
|
||||
}
|
||||
extension View { func dashCard(padding: CGFloat = 18) -> some View { modifier(DashCard(padding: padding)) } }
|
||||
|
||||
/// 보더리스 인라인 통계 셀 (대시보드 스탯 스트립). StatCard 와 달리 카드 테두리 없음.
|
||||
struct StatCell: View {
|
||||
let value: Int
|
||||
let label: String
|
||||
var color: Color = Sage.ink
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text("\(value)").font(.system(size: 20, weight: .semibold)).kerning(-0.6)
|
||||
.monospacedDigit().foregroundStyle(color)
|
||||
Text(label).font(.caption2).foregroundStyle(Sage.muted)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
struct StatCard: View {
|
||||
let title: String
|
||||
let value: Int
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import SwiftUI
|
||||
import DSKit
|
||||
|
||||
/// DEVONthink-style 3-column shell. RootView only ROUTES; each page owns its own interior treatment
|
||||
/// (no shell-level auto-inherit). macOS-only target.
|
||||
/// 인증 게이트: checking(부팅 시 refresh 쿠키 복귀 시도) → loggedOut(LoginView) → ready(3-pane 셸).
|
||||
/// 2-column 셸 (사이드바 + 단일 detail). 각 섹션이 detail 전폭을 받아 자기 내부 레이아웃을 소유한다
|
||||
/// (개요=풀폭 캔버스 / 문서=내부 HSplitView 3-pane / 메모=리스트+상세). 이전 3-column 이 대시보드를
|
||||
/// 좁은 가운데칸에 욱여넣어 깨지던 문제를 구조적으로 제거. macOS-only.
|
||||
/// 인증 게이트: checking(refresh 쿠키 복귀) → loggedOut(LoginView) → ready(셸).
|
||||
public struct RootView: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
@State private var columnVisibility: NavigationSplitViewVisibility = .all
|
||||
@@ -29,38 +30,45 @@ public struct RootView: View {
|
||||
private var shell: some View {
|
||||
NavigationSplitView(columnVisibility: $columnVisibility) {
|
||||
Sidebar()
|
||||
.navigationSplitViewColumnWidth(min: 220, ideal: 250)
|
||||
} content: {
|
||||
ContentColumn()
|
||||
.navigationSplitViewColumnWidth(min: 300, ideal: 380)
|
||||
.navigationSplitViewColumnWidth(min: 200, ideal: 215, max: 270)
|
||||
} detail: {
|
||||
DetailColumn()
|
||||
SectionDetail()
|
||||
}
|
||||
.navigationSplitViewStyle(.balanced)
|
||||
.tint(Sage.brand)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .primaryAction) { UploadToolbarButton() }
|
||||
ToolbarItem(placement: .primaryAction) { AccountMenu() }
|
||||
}
|
||||
.safeAreaInset(edge: .bottom) {
|
||||
// 라이브 데이터 호출 실패 가시화 (no-silent-fallback) — 닫기 전까지 유지.
|
||||
if let err = model.errorText {
|
||||
HStack(spacing: 10) {
|
||||
Text(err)
|
||||
.font(.callout)
|
||||
.foregroundStyle(.white)
|
||||
.lineLimit(2)
|
||||
Spacer()
|
||||
Button("닫기") { model.errorText = nil }
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(.white.opacity(0.85))
|
||||
VStack(spacing: 0) {
|
||||
UploadStatusBar()
|
||||
// 라이브 데이터 호출 실패 가시화 (no-silent-fallback) — 닫기 전까지 유지.
|
||||
if let err = model.errorText {
|
||||
HStack(spacing: 10) {
|
||||
Text(err)
|
||||
.font(.callout)
|
||||
.foregroundStyle(.white)
|
||||
.lineLimit(2)
|
||||
Spacer()
|
||||
Button("닫기") { model.errorText = nil }
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(.white.opacity(0.85))
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 8)
|
||||
.background(Sage.danger)
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 8)
|
||||
.background(Sage.danger)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Sidebar
|
||||
|
||||
struct Sidebar: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
private let navSections: [AppModel.Section] = [.dashboard, .documents, .digest, .memos]
|
||||
|
||||
var body: some View {
|
||||
let selection = Binding<AppModel.Section?>(
|
||||
@@ -68,73 +76,132 @@ struct Sidebar: View {
|
||||
set: { if let v = $0 { model.section = v } }
|
||||
)
|
||||
List(selection: selection) {
|
||||
BrandRow().selectionDisabled()
|
||||
Section {
|
||||
ForEach(AppModel.Section.allCases) { s in
|
||||
Text(s.title).tag(s)
|
||||
ForEach(navSections) { s in
|
||||
Label(s.title, systemImage: Self.icon(s)).tag(s)
|
||||
}
|
||||
}
|
||||
if model.section == .documents, !model.tree.isEmpty {
|
||||
Section("도메인") {
|
||||
ForEach(model.tree) { node in
|
||||
DomainRow(node: node)
|
||||
}
|
||||
}
|
||||
// 문서 섹션일 때만 분류 소스트리 노출 (다른 섹션은 4-섹션만 보임).
|
||||
if model.section == .documents {
|
||||
DocumentsSourceSidebar()
|
||||
}
|
||||
}
|
||||
.listStyle(.sidebar)
|
||||
.background(Sage.sidebar)
|
||||
}
|
||||
}
|
||||
|
||||
struct DomainRow: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
let node: DomainTreeNode
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
Circle().fill(Sage.domainColor(node.name)).frame(width: 8, height: 8)
|
||||
Text(node.name).font(.callout).foregroundStyle(Sage.ink)
|
||||
Spacer()
|
||||
Text("\(node.count)").font(.caption).foregroundStyle(Sage.muted)
|
||||
static func icon(_ s: AppModel.Section) -> String {
|
||||
switch s {
|
||||
case .dashboard: return "house"
|
||||
case .documents: return "folder"
|
||||
case .digest: return "newspaper"
|
||||
case .memos: return "note.text"
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { model.section = .documents }
|
||||
}
|
||||
}
|
||||
|
||||
struct ContentColumn: View {
|
||||
struct BrandRow: View {
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
RoundedRectangle(cornerRadius: 7).fill(Sage.brand).frame(width: 26, height: 26)
|
||||
.overlay(Text("DS").font(.system(size: 10, weight: .heavy)).foregroundStyle(.white))
|
||||
Text("Document Server").font(.system(size: 13.5, weight: .heavy)).foregroundStyle(Sage.ink)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
|
||||
/// 문서 전용 소스트리: 분류(도메인 필터 = 실데이터) + 스마트그룹/태그(데이터 미연결 placeholder).
|
||||
struct DocumentsSourceSidebar: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
|
||||
var body: some View {
|
||||
Section("분류") {
|
||||
SourceRow(label: "전체 문서", color: nil, count: model.stats?.total,
|
||||
selected: model.documentDomainFilter == nil) {
|
||||
Task { await model.loadDocuments(domain: nil) }
|
||||
}
|
||||
ForEach(model.tree) { node in
|
||||
SourceRow(label: localizedDomain(node.name), color: Sage.domainColor(node.name),
|
||||
count: node.count, selected: model.documentDomainFilter == node.path) {
|
||||
Task { await model.loadDocuments(domain: node.path) }
|
||||
}
|
||||
}
|
||||
}
|
||||
// 데이터 미연결 — IA 만 맞추고 비활성(가짜 카운트 금지).
|
||||
Section("스마트 그룹") {
|
||||
ForEach(["최근 7일", "검토 대기", "법령 알림"], id: \.self) { t in
|
||||
Text(t).font(.callout).foregroundStyle(Sage.muted).opacity(0.5)
|
||||
}
|
||||
}
|
||||
Section("태그") {
|
||||
ForEach(["압력용기", "ASME", "받은편지함"], id: \.self) { t in
|
||||
Text("#\(t)").font(.callout).foregroundStyle(Sage.muted).opacity(0.5)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 소스트리 행 (분류). 선택 시 brand-soft 배경 — List 시스템 선택과 분리(수동 하이라이트).
|
||||
struct SourceRow: View {
|
||||
let label: String
|
||||
let color: Color?
|
||||
let count: Int?
|
||||
let selected: Bool
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
if let color { RoundedRectangle(cornerRadius: 3).fill(color).frame(width: 8, height: 8) }
|
||||
Text(label).font(.callout)
|
||||
.foregroundStyle(selected ? Sage.brandDark : Sage.ink)
|
||||
.fontWeight(selected ? .bold : .regular)
|
||||
.lineLimit(1)
|
||||
Spacer()
|
||||
if let count { Text("\(count)").font(.caption.monospacedDigit()).foregroundStyle(Sage.muted) }
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture(perform: action)
|
||||
.listRowBackground(selected ? Sage.brand.opacity(0.14) : Color.clear)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Section router
|
||||
|
||||
/// 선택 섹션을 detail 전폭으로 라우팅. 셸 차원 inspector/list 칼럼 없음 — 각 페이지가 내부에서 소유.
|
||||
struct SectionDetail: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
switch model.section {
|
||||
case .dashboard: DashboardView()
|
||||
case .documents: DocumentListView()
|
||||
case .search: SearchView()
|
||||
case .ask: AskView()
|
||||
case .memos: MemoListView()
|
||||
case .digest: DigestView()
|
||||
case .dashboard: DashboardView() // 풀폭 캔버스
|
||||
case .documents: DocumentsBrowser() // 내부 HSplitView 3-pane
|
||||
case .digest: DigestView() // 풀폭 (뉴스 — 후속 모닝브리핑 재구성)
|
||||
case .memos: MemosBoard() // 리스트 + 상세 (후속 버킷 트리아지)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Sage.surface)
|
||||
.navigationTitle(model.section.title)
|
||||
}
|
||||
}
|
||||
|
||||
struct DetailColumn: View {
|
||||
/// 메모 — v1 리스트+상세 split (확정 버킷 트리아지는 후속 트랙).
|
||||
struct MemosBoard: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
switch model.section {
|
||||
case .documents:
|
||||
if let d = model.documentDetail { DocumentDetailView(detail: d) }
|
||||
else { EmptyState(text: "문서를 선택하세요") }
|
||||
case .memos:
|
||||
HSplitView {
|
||||
MemoListView()
|
||||
.frame(minWidth: 300, idealWidth: 360, maxWidth: 460)
|
||||
Group {
|
||||
if let m = model.memoDetail { MemoDetailView(memo: m) }
|
||||
else { EmptyState(text: "메모를 선택하세요") }
|
||||
default:
|
||||
EmptyState(text: model.section.title)
|
||||
}
|
||||
.frame(minWidth: 360, maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -149,11 +216,96 @@ struct EmptyState: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Toolbar items
|
||||
|
||||
/// 툴바 업로드 버튼 — NSOpenPanel 로 파일 선택 → 멀티파트 업로드. 진행 중 비활성.
|
||||
struct UploadToolbarButton: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
|
||||
var body: some View {
|
||||
Button {
|
||||
guard let fileURL = FilePanels.pickFileToUpload() else { return }
|
||||
Task { await model.uploadPicked(fileURL) }
|
||||
} label: {
|
||||
Label("업로드", systemImage: "square.and.arrow.up")
|
||||
}
|
||||
.help("문서 업로드")
|
||||
.disabled(isUploading)
|
||||
}
|
||||
|
||||
private var isUploading: Bool {
|
||||
if case .uploading = model.uploadState { return true }
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// 계정 메뉴 — 사용자명 표시 + 로그아웃(확인 대화상자).
|
||||
struct AccountMenu: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
@State private var confirmLogout = false
|
||||
|
||||
var body: some View {
|
||||
Menu {
|
||||
Button("로그아웃", role: .destructive) { confirmLogout = true }
|
||||
} label: {
|
||||
Label(model.currentUser?.username ?? "계정", systemImage: "person.crop.circle")
|
||||
}
|
||||
.help("계정")
|
||||
.confirmationDialog("로그아웃하시겠습니까?", isPresented: $confirmLogout, titleVisibility: .visible) {
|
||||
Button("로그아웃", role: .destructive) { Task { await model.logout() } }
|
||||
Button("취소", role: .cancel) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 업로드 진행/결과 상태바. uploading=스피너(닫기 없음) / done=성공(처리 대기 안내)+닫기 / failed=오류+닫기.
|
||||
struct UploadStatusBar: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
|
||||
var body: some View {
|
||||
switch model.uploadState {
|
||||
case .idle:
|
||||
EmptyView()
|
||||
case .uploading(let name):
|
||||
row(bg: Sage.brand) {
|
||||
ProgressView().controlSize(.small).tint(.white)
|
||||
Text("업로드 중 — \(name)").font(.callout).foregroundStyle(.white).lineLimit(1)
|
||||
Spacer()
|
||||
}
|
||||
case .done(let title):
|
||||
row(bg: Sage.brand) {
|
||||
Text("업로드 완료 — \(title) (처리 대기 중)").font(.callout).foregroundStyle(.white).lineLimit(1)
|
||||
Spacer()
|
||||
closeButton
|
||||
}
|
||||
case .failed(let msg):
|
||||
row(bg: Sage.danger) {
|
||||
Text("업로드 실패 — \(msg)").font(.callout).foregroundStyle(.white).lineLimit(2)
|
||||
Spacer()
|
||||
closeButton
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var closeButton: some View {
|
||||
Button("닫기") { model.dismissUploadStatus() }
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(.white.opacity(0.85))
|
||||
}
|
||||
|
||||
private func row<Content: View>(bg: Color, @ViewBuilder _ content: () -> Content) -> some View {
|
||||
HStack(spacing: 10) { content() }
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 8)
|
||||
.background(bg)
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
#Preview("DS App — full shell") {
|
||||
@Previewable @State var model = AppModel.preview
|
||||
RootView()
|
||||
.environment(model)
|
||||
.frame(minWidth: 1000, minHeight: 660)
|
||||
.frame(minWidth: 1100, minHeight: 700)
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -2,23 +2,24 @@ import SwiftUI
|
||||
import Observation
|
||||
import DSKit
|
||||
import AIFabric
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
/// The single app-state store driving the 3-pane shell. @MainActor @Observable: mutations are
|
||||
/// main-isolated; the DSClient returns Sendable models; AIService is an actor.
|
||||
@MainActor
|
||||
@Observable
|
||||
public final class AppModel {
|
||||
/// 표시 순서 = 홈·문서·뉴스·메모. 질문(ask)·이드(AI chat)는 v1 macOS 표면에서 제거(2026-06-15) —
|
||||
/// AIFabric(S2) 코드는 향후 iPhone/Watch 이드용으로 보존, UI 섹션만 미노출.
|
||||
public enum Section: String, CaseIterable, Identifiable, Hashable {
|
||||
case dashboard, documents, search, ask, memos, digest
|
||||
case dashboard, documents, digest, memos
|
||||
public var id: String { rawValue }
|
||||
public var title: String {
|
||||
switch self {
|
||||
case .dashboard: return "대시보드"
|
||||
case .dashboard: return "홈"
|
||||
case .documents: return "문서"
|
||||
case .search: return "검색"
|
||||
case .ask: return "질문"
|
||||
case .memos: return "메모"
|
||||
case .digest: return "뉴스"
|
||||
case .memos: return "메모"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,19 +28,33 @@ public final class AppModel {
|
||||
/// → 성공 시 셸(ready). Fixture 클라이언트는 refresh 가 fixture 토큰을 돌려줘 곧장 ready.
|
||||
public enum AuthPhase: Equatable { case checking, loggedOut, ready }
|
||||
|
||||
/// 업로드 진행/결과 — 셸 하단 상태바 + 툴바 버튼 스피너용. done/failed 는 닫기 또는 다음 업로드로 소거.
|
||||
public enum UploadState: Equatable, Sendable {
|
||||
case idle
|
||||
case uploading(name: String)
|
||||
case done(title: String)
|
||||
case failed(String)
|
||||
}
|
||||
|
||||
public var section: Section = .dashboard
|
||||
public var selectedDocumentID: Int?
|
||||
public var selectedMemoID: Int?
|
||||
|
||||
public var tree: [DomainTreeNode] = []
|
||||
public var stats: CategoryCounts?
|
||||
/// 검토 대기 문서 총수 (홈 검토 큐 히어로). loadInitial 에서 count 쿼리로 채움. nil=미로드.
|
||||
public var reviewPendingCount: Int?
|
||||
/// 로그인 사용자 (계정 메뉴 표시용). loadInitial 에서 me() 로 채움.
|
||||
public var currentUser: UserResponse?
|
||||
public private(set) var uploadState: UploadState = .idle
|
||||
/// 홈 빠른 캡처 입력 (CaptureCard 바인딩, saveMemo 후 비움).
|
||||
public var captureText: String = ""
|
||||
public var documentList: [DocumentResponse] = []
|
||||
public var documentDetail: DocumentDetailResponse?
|
||||
public var searchQuery: String = ""
|
||||
public var searchResponse: SearchResponse?
|
||||
public var askQuery: String = ""
|
||||
public var askResult: AIResult?
|
||||
public var askMeta: DSKit.AskResponse? // qualified: AIFabric also defines an AskResponse
|
||||
/// 문서 사이드바 분류 필터 (선택된 도메인 path, nil = 전체 문서).
|
||||
public var documentDomainFilter: String?
|
||||
/// 현재 필터의 전체 문서를 다 불러왔는지 (페이지네이션 load-all 완료). 섹션 재진입 중복로드 방지.
|
||||
public private(set) var documentsFullyLoaded = false
|
||||
public var memoList: [MemoResponse] = []
|
||||
public var memoDetail: MemoResponse?
|
||||
public var digest: DigestResponse?
|
||||
@@ -129,11 +144,16 @@ public final class AppModel {
|
||||
}
|
||||
|
||||
public func loadInitial() async {
|
||||
await guarded { self.currentUser = try await self.client.me() }
|
||||
await guarded { self.tree = try await self.client.documentTree() }
|
||||
await guarded { self.stats = try await self.client.categoryCounts() }
|
||||
await guarded { self.documentList = try await self.client.documents(DocumentListQuery()).items }
|
||||
await guarded { self.memoList = try await self.client.memos(MemoListQuery()).items }
|
||||
await guarded { self.digest = try await self.client.digest(date: nil, country: nil) }
|
||||
await guarded {
|
||||
var q = DocumentListQuery(); q.reviewStatus = "pending"; q.pageSize = 1
|
||||
self.reviewPendingCount = try await self.client.documents(q).total
|
||||
}
|
||||
}
|
||||
|
||||
public func openDocument(_ id: Int) async {
|
||||
@@ -141,15 +161,60 @@ public final class AppModel {
|
||||
await guarded { self.documentDetail = try await self.client.document(id: id) }
|
||||
}
|
||||
|
||||
public func runSearch() async {
|
||||
guard !searchQuery.isEmpty else { return }
|
||||
await guarded { self.searchResponse = try await self.client.search(q: self.searchQuery, mode: .hybrid, page: 1, debug: false) }
|
||||
/// 문서 섹션 진입 시 현재 필터의 전체 문서 확보 (중복로드 방지). 미로드 상태일 때만 load-all.
|
||||
public func ensureDocumentsLoaded() async {
|
||||
if !documentsFullyLoaded { await loadDocuments(domain: documentDomainFilter) }
|
||||
}
|
||||
|
||||
public func runAsk(backend: AIProviderID?) async {
|
||||
guard !askQuery.isEmpty else { return }
|
||||
askResult = await ai.corpusAsk(question: askQuery, explicit: backend)
|
||||
await guarded { self.askMeta = try await self.client.ask(q: self.askQuery, limit: nil, backend: nil, debug: false) }
|
||||
/// 사이드바 분류 선택 → 도메인 필터로 **전체** 문서 load-all (서버 page_size 상한 100을 페이지네이션으로
|
||||
/// 모두 수집 — 1582건도 전부 노출). 페이지마다 append 라 목록이 점진적으로 채워진다. 재조회 후
|
||||
/// 선택 문서가 새 목록에 없으면 선택/상세를 비워 3-pane 정합 유지.
|
||||
public func loadDocuments(domain: String?) async {
|
||||
documentDomainFilter = domain
|
||||
documentsFullyLoaded = false
|
||||
documentList = []
|
||||
let pageSize = 100
|
||||
var page = 1
|
||||
do {
|
||||
while page <= 80 { // 안전 상한 ~8000건
|
||||
var q = DocumentListQuery(); q.domain = domain; q.page = page; q.pageSize = pageSize
|
||||
let resp = try await client.documents(q)
|
||||
documentList.append(contentsOf: resp.items)
|
||||
if resp.items.count < pageSize || documentList.count >= resp.total { break }
|
||||
page += 1
|
||||
}
|
||||
documentsFullyLoaded = true
|
||||
} catch let e as DSError where e.isAuthExpired {
|
||||
authPhase = .loggedOut
|
||||
loginError = "세션이 만료되었습니다. 다시 로그인하세요."
|
||||
} catch {
|
||||
errorText = (error as? LocalizedError)?.errorDescription ?? "\(error)"
|
||||
}
|
||||
await syncAccessToken()
|
||||
if let sel = selectedDocumentID, !documentList.contains(where: { $0.id == sel }) {
|
||||
selectedDocumentID = nil
|
||||
documentDetail = nil
|
||||
}
|
||||
}
|
||||
|
||||
/// 텍스트로 메모 생성 후 목록 맨 앞 반영. 성공 시 true. 빈/공백 입력은 무시(false). 에러는
|
||||
/// guarded 깔때기로 errorText 노출(삼키지 않음).
|
||||
@discardableResult
|
||||
public func saveMemo(_ text: String) async -> Bool {
|
||||
let t = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !t.isEmpty else { return false }
|
||||
var ok = false
|
||||
await guarded {
|
||||
let memo = try await self.client.createMemo(MemoCreate(content: t))
|
||||
self.memoList.insert(memo, at: 0)
|
||||
ok = true
|
||||
}
|
||||
return ok
|
||||
}
|
||||
|
||||
/// 홈 빠른 캡처 — captureText 사용, 성공 시 입력 비움.
|
||||
public func saveMemo() async {
|
||||
if await saveMemo(captureText) { captureText = "" }
|
||||
}
|
||||
|
||||
public func openMemo(_ id: Int) async {
|
||||
@@ -162,6 +227,67 @@ public final class AppModel {
|
||||
return DSDownload.fileURL(base: base, documentID: doc.id, accessToken: accessToken)
|
||||
}
|
||||
|
||||
/// 로그아웃: 서버 쿠키/토큰 폐기(best-effort) 후 세션 상태 전체 초기화 → loggedOut. 다음 로그인이
|
||||
/// stale 데이터 없이 깨끗하게 시작하도록 로드 상태를 비운다. 실패해도 로컬은 무조건 로그아웃 처리.
|
||||
public func logout() async {
|
||||
try? await client.logout()
|
||||
accessToken = ""
|
||||
currentUser = nil
|
||||
tree = []
|
||||
stats = nil
|
||||
reviewPendingCount = nil
|
||||
captureText = ""
|
||||
documentList = []
|
||||
documentDetail = nil
|
||||
documentDomainFilter = nil
|
||||
documentsFullyLoaded = false
|
||||
memoList = []
|
||||
memoDetail = nil
|
||||
digest = nil
|
||||
selectedDocumentID = nil
|
||||
selectedMemoID = nil
|
||||
section = .dashboard // 다음 로그인은 홈에서 시작 (리뷰 LOW: 이전 사용자 마지막 페이지 잔류 방지)
|
||||
errorText = nil
|
||||
uploadState = .idle
|
||||
authPhase = .loggedOut
|
||||
}
|
||||
|
||||
/// 사용자가 고른 파일(NSOpenPanel 보안 스코프 URL)을 읽어 업로드. 파일 IO 실패는 uploadState 로 노출.
|
||||
public func uploadPicked(_ fileURL: URL) async {
|
||||
let accessed = fileURL.startAccessingSecurityScopedResource()
|
||||
defer { if accessed { fileURL.stopAccessingSecurityScopedResource() } }
|
||||
let filename = fileURL.lastPathComponent
|
||||
let data: Data
|
||||
do {
|
||||
data = try Data(contentsOf: fileURL)
|
||||
} catch {
|
||||
uploadState = .failed("파일을 읽을 수 없습니다: \((error as NSError).localizedDescription)")
|
||||
return
|
||||
}
|
||||
let mime = UTType(filenameExtension: fileURL.pathExtension)?.preferredMIMEType
|
||||
await upload(DocumentUpload(filename: filename, data: data, mimeType: mime))
|
||||
}
|
||||
|
||||
/// 멀티파트 업로드 실행 + 결과 반영. 성공 시 목록 재로드(신규 문서 = 처리 대기 상태로 노출).
|
||||
public func upload(_ payload: DocumentUpload) async {
|
||||
uploadState = .uploading(name: payload.filename)
|
||||
do {
|
||||
let doc = try await client.uploadDocument(payload)
|
||||
uploadState = .done(title: doc.title ?? doc.downloadLabel)
|
||||
await guarded { self.documentList = try await self.client.documents(DocumentListQuery()).items }
|
||||
} catch let e as DSError where e.isAuthExpired {
|
||||
authPhase = .loggedOut
|
||||
loginError = "세션이 만료되었습니다. 다시 로그인하세요."
|
||||
uploadState = .failed("세션이 만료되었습니다.")
|
||||
} catch {
|
||||
uploadState = .failed((error as? LocalizedError)?.errorDescription ?? "\(error)")
|
||||
}
|
||||
await syncAccessToken()
|
||||
}
|
||||
|
||||
/// 업로드 상태바 닫기 (done/failed 소거).
|
||||
public func dismissUploadStatus() { uploadState = .idle }
|
||||
|
||||
private func guarded(_ work: () async throws -> Void) async {
|
||||
do {
|
||||
try await work()
|
||||
|
||||
@@ -23,6 +23,8 @@ public protocol DSClient: Sendable {
|
||||
func patchDocument(id: Int, _ update: DocumentUpdate) async throws -> DocumentResponse
|
||||
func putContent(id: Int, content: String) async throws
|
||||
func deleteDocument(id: Int) async throws
|
||||
/// 멀티파트 업로드 (POST /documents/) → Inbox 저장 + 처리 큐 등록. 201 DocumentResponse.
|
||||
func uploadDocument(_ upload: DocumentUpload) async throws -> DocumentResponse
|
||||
|
||||
// Search / Ask
|
||||
func search(q: String, mode: SearchMode?, page: Int?, debug: Bool?) async throws -> SearchResponse
|
||||
|
||||
@@ -53,6 +53,9 @@ public struct FixtureDSClient: DSClient {
|
||||
}
|
||||
public func putContent(id: Int, content: String) async throws {}
|
||||
public func deleteDocument(id: Int) async throws {}
|
||||
public func uploadDocument(_ upload: DocumentUpload) async throws -> DocumentResponse {
|
||||
try load("document_detail", as: DocumentDetailResponse.self).base
|
||||
}
|
||||
|
||||
// Search / Ask
|
||||
public func search(q: String, mode: SearchMode?, page: Int?, debug: Bool?) async throws -> SearchResponse {
|
||||
|
||||
@@ -64,15 +64,26 @@ public final class LiveDSClient: DSClient, @unchecked Sendable {
|
||||
}
|
||||
|
||||
private func perform(_ endpoint: DSEndpoint) async throws -> Data {
|
||||
let request = try makeRequest(endpoint, token: await tokens.current())
|
||||
try await performWithRetry(requiresBearer: endpoint.requiresBearer) { token in
|
||||
try self.makeRequest(endpoint, token: token)
|
||||
}
|
||||
}
|
||||
|
||||
/// 401 단일-비행 refresh + 1회 재시도의 공용 경로. `build` 가 (현 토큰)→URLRequest 를 만들고,
|
||||
/// 401 이면 새 토큰으로 한 번 더 빌드해 재전송한다. JSON 경로(perform)와 멀티파트 업로드가 공유.
|
||||
private func performWithRetry(
|
||||
requiresBearer: Bool,
|
||||
_ build: (_ token: String?) throws -> URLRequest
|
||||
) async throws -> Data {
|
||||
let request = try build(await tokens.current())
|
||||
let (data, response) = try await dataOrTransport(request)
|
||||
guard let http = response as? HTTPURLResponse else {
|
||||
throw DSError.transport(underlying: "no HTTP response")
|
||||
}
|
||||
if http.statusCode == 401, endpoint.requiresBearer {
|
||||
if http.statusCode == 401, requiresBearer {
|
||||
// Single-flight refresh + one retry.
|
||||
let newToken = try await tokens.refreshOnce()
|
||||
let retry = try makeRequest(endpoint, token: newToken)
|
||||
let retry = try build(newToken)
|
||||
let (data2, response2) = try await dataOrTransport(retry)
|
||||
guard let http2 = response2 as? HTTPURLResponse else {
|
||||
throw DSError.transport(underlying: "no HTTP response")
|
||||
@@ -122,6 +133,44 @@ public final class LiveDSClient: DSClient, @unchecked Sendable {
|
||||
public func putContent(id: Int, content: String) async throws { try await sendVoid(.putContent(id, content)) }
|
||||
public func deleteDocument(id: Int) async throws { try await sendVoid(.deleteDocument(id)) }
|
||||
|
||||
public func uploadDocument(_ upload: DocumentUpload) async throws -> DocumentResponse {
|
||||
let boundary = "DSBoundary-\(UUID().uuidString)"
|
||||
let body = LiveDSClient.multipartBody(for: upload, boundary: boundary)
|
||||
// 트레일링 슬래시 유지(POST /documents/) — base 문자열 결합 (appendingPathComponent 는 슬래시 strip).
|
||||
let raw = base.url.absoluteString + "/documents/"
|
||||
guard let url = URL(string: raw) else { throw DSError.transport(underlying: "bad URL \(raw)") }
|
||||
let data = try await performWithRetry(requiresBearer: true) { token in
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
if let token { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") }
|
||||
request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
|
||||
request.httpBody = body
|
||||
return request
|
||||
}
|
||||
do { return try decoder.decode(DocumentResponse.self, from: data) }
|
||||
catch { throw DSError.decoding("documents/ upload: \(error)") }
|
||||
}
|
||||
|
||||
/// multipart/form-data 본문 생성. file 파트 + 선택 form 필드(doc_purpose/library_path).
|
||||
/// internal(테스트 가시) — 한글 파일명은 UTF-8 바이트 그대로(Starlette 가 디코드).
|
||||
static func multipartBody(for upload: DocumentUpload, boundary: String) -> Data {
|
||||
var body = Data()
|
||||
func appendField(_ name: String, _ value: String) {
|
||||
body.append(Data("--\(boundary)\r\n".utf8))
|
||||
body.append(Data("Content-Disposition: form-data; name=\"\(name)\"\r\n\r\n".utf8))
|
||||
body.append(Data("\(value)\r\n".utf8))
|
||||
}
|
||||
if let p = upload.docPurpose { appendField("doc_purpose", p) }
|
||||
if let lp = upload.libraryPath { appendField("library_path", lp) }
|
||||
body.append(Data("--\(boundary)\r\n".utf8))
|
||||
body.append(Data("Content-Disposition: form-data; name=\"file\"; filename=\"\(upload.filename)\"\r\n".utf8))
|
||||
body.append(Data("Content-Type: \(upload.mimeType ?? "application/octet-stream")\r\n\r\n".utf8))
|
||||
body.append(upload.data)
|
||||
body.append(Data("\r\n".utf8))
|
||||
body.append(Data("--\(boundary)--\r\n".utf8))
|
||||
return body
|
||||
}
|
||||
|
||||
public func search(q: String, mode: SearchMode?, page: Int?, debug: Bool?) async throws -> SearchResponse { try await send(.search(q, mode, page, debug), as: SearchResponse.self) }
|
||||
public func ask(q: String, limit: Int?, backend: String?, debug: Bool?) async throws -> AskResponse { try await send(.ask(q, limit, backend, debug), as: AskResponse.self) }
|
||||
|
||||
|
||||
@@ -24,6 +24,25 @@ public struct MemoListQuery: Sendable {
|
||||
public init() {}
|
||||
}
|
||||
|
||||
/// 멀티파트 업로드 페이로드 (POST /documents/). `file` 파트 + 선택 form 필드.
|
||||
/// `data` 는 메모리 적재(개인 문서 규모 가정) — 대용량 디스크 스트리밍은 후속.
|
||||
public struct DocumentUpload: Sendable {
|
||||
public var filename: String
|
||||
public var data: Data
|
||||
public var mimeType: String?
|
||||
/// "business" | "knowledge" | nil. business 는 서버가 @library 로 자동 태깅.
|
||||
public var docPurpose: String?
|
||||
public var libraryPath: String?
|
||||
public init(filename: String, data: Data, mimeType: String? = nil,
|
||||
docPurpose: String? = nil, libraryPath: String? = nil) {
|
||||
self.filename = filename
|
||||
self.data = data
|
||||
self.mimeType = mimeType
|
||||
self.docPurpose = docPurpose
|
||||
self.libraryPath = libraryPath
|
||||
}
|
||||
}
|
||||
|
||||
public struct DocumentUpdate: Codable, Sendable {
|
||||
public var title: String?
|
||||
public var userNote: String?
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import XCTest
|
||||
@testable import AppFeature
|
||||
import DSKit
|
||||
|
||||
/// 로그아웃 상태 초기화 + 업로드 결과 반영 — 네트워크 0 (Fixture).
|
||||
final class AppModelActionsTests: XCTestCase {
|
||||
|
||||
// ready 세션에서 로그아웃 → loggedOut + 토큰/사용자/로드상태 전부 초기화
|
||||
@MainActor
|
||||
func testLogoutResetsStateAndLogsOut() async {
|
||||
let model = AppModel.preview
|
||||
await model.bootstrap()
|
||||
XCTAssertEqual(model.authPhase, .ready)
|
||||
XCTAssertFalse(model.documentList.isEmpty)
|
||||
XCTAssertNotNil(model.currentUser, "loadInitial 이 me() 로 사용자 채움")
|
||||
|
||||
await model.logout()
|
||||
|
||||
XCTAssertEqual(model.authPhase, .loggedOut)
|
||||
XCTAssertTrue(model.accessToken.isEmpty)
|
||||
XCTAssertNil(model.currentUser)
|
||||
XCTAssertTrue(model.documentList.isEmpty)
|
||||
XCTAssertNil(model.documentDetail)
|
||||
XCTAssertTrue(model.tree.isEmpty)
|
||||
XCTAssertEqual(model.uploadState, .idle)
|
||||
}
|
||||
|
||||
// 업로드 성공 → uploadState=.done + 목록 재로드
|
||||
@MainActor
|
||||
func testUploadSuccessSetsDoneAndReloads() async {
|
||||
let model = AppModel.preview
|
||||
await model.bootstrap()
|
||||
await model.upload(DocumentUpload(filename: "x.pdf", data: Data("x".utf8), mimeType: "application/pdf"))
|
||||
|
||||
if case .done = model.uploadState {} else {
|
||||
XCTFail("기대 .done, 실제 \(model.uploadState)")
|
||||
}
|
||||
XCTAssertFalse(model.documentList.isEmpty)
|
||||
}
|
||||
|
||||
// 업로드 진행 상태 전이 표현 (Equatable 동작 확인 — 상태바 분기 근거)
|
||||
@MainActor
|
||||
func testDismissUploadStatusReturnsToIdle() async {
|
||||
let model = AppModel.preview
|
||||
await model.bootstrap()
|
||||
await model.upload(DocumentUpload(filename: "x.pdf", data: Data("x".utf8)))
|
||||
model.dismissUploadStatus()
|
||||
XCTAssertEqual(model.uploadState, .idle)
|
||||
}
|
||||
}
|
||||
@@ -168,6 +168,7 @@ final class AuthStubClient: DSClient, @unchecked Sendable {
|
||||
func patchDocument(id: Int, _ update: DocumentUpdate) async throws -> DocumentResponse { try await inner.patchDocument(id: id, update) }
|
||||
func putContent(id: Int, content: String) async throws { try await inner.putContent(id: id, content: content) }
|
||||
func deleteDocument(id: Int) async throws { try await inner.deleteDocument(id: id) }
|
||||
func uploadDocument(_ upload: DocumentUpload) async throws -> DocumentResponse { try await inner.uploadDocument(upload) }
|
||||
func search(q: String, mode: SearchMode?, page: Int?, debug: Bool?) async throws -> SearchResponse { try await inner.search(q: q, mode: mode, page: page, debug: debug) }
|
||||
func ask(q: String, limit: Int?, backend: String?, debug: Bool?) async throws -> AskResponse { try await inner.ask(q: q, limit: limit, backend: backend, debug: debug) }
|
||||
func memos(_ query: MemoListQuery) async throws -> MemoListResponse { try await inner.memos(query) }
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import XCTest
|
||||
@testable import DSKit
|
||||
|
||||
/// 멀티파트 업로드 — Fixture 에코 + multipart 본문 형태(경계/디스포지션/한글 파일명/필드/파일 데이터).
|
||||
final class UploadTests: XCTestCase {
|
||||
|
||||
func testFixtureUploadReturnsDocument() async throws {
|
||||
let doc = try await FixtureDSClient().uploadDocument(
|
||||
DocumentUpload(filename: "a.pdf", data: Data("x".utf8), mimeType: "application/pdf"))
|
||||
XCTAssertGreaterThan(doc.id, 0)
|
||||
}
|
||||
|
||||
func testMultipartBodyShape() throws {
|
||||
let upload = DocumentUpload(
|
||||
filename: "보고서.pdf",
|
||||
data: Data("PDFDATA".utf8),
|
||||
mimeType: "application/pdf",
|
||||
docPurpose: "knowledge"
|
||||
)
|
||||
let boundary = "TESTBOUNDARY"
|
||||
let body = LiveDSClient.multipartBody(for: upload, boundary: boundary)
|
||||
let s = try XCTUnwrap(String(data: body, encoding: .utf8))
|
||||
|
||||
XCTAssertTrue(s.contains("--TESTBOUNDARY\r\n"), "경계 마커")
|
||||
XCTAssertTrue(s.contains(#"Content-Disposition: form-data; name="file"; filename="보고서.pdf""#),
|
||||
"file 파트 + 한글 파일명")
|
||||
XCTAssertTrue(s.contains("Content-Type: application/pdf"), "파일 mime")
|
||||
XCTAssertTrue(s.contains(#"Content-Disposition: form-data; name="doc_purpose""#), "선택 form 필드")
|
||||
XCTAssertTrue(s.contains("knowledge"))
|
||||
XCTAssertTrue(s.contains("PDFDATA"), "파일 데이터")
|
||||
XCTAssertTrue(s.hasSuffix("--TESTBOUNDARY--\r\n"), "종료 경계")
|
||||
}
|
||||
|
||||
func testMultipartOmitsAbsentOptionalFields() throws {
|
||||
let upload = DocumentUpload(filename: "x.txt", data: Data("a".utf8))
|
||||
let body = LiveDSClient.multipartBody(for: upload, boundary: "B")
|
||||
let s = try XCTUnwrap(String(data: body, encoding: .utf8))
|
||||
XCTAssertFalse(s.contains("doc_purpose"), "미지정 doc_purpose 는 본문에 없어야 함")
|
||||
XCTAssertFalse(s.contains("library_path"), "미지정 library_path 는 본문에 없어야 함")
|
||||
XCTAssertTrue(s.contains("Content-Type: application/octet-stream"), "mime 미지정 = octet-stream 폴백")
|
||||
}
|
||||
}
|
||||
@@ -54,7 +54,7 @@ UserResponse { id: Int, username: String, is_active: Bool, totp_enabled: Bool, l
|
||||
| GET | `/documents/{id}/content` | — | 경량 텍스트(`content` 15k cap) | `document_content.json` |
|
||||
| GET | `/documents/tree` | — | 도메인 트리(사이드바) | `documents_tree.json` |
|
||||
| GET | `/documents/stats/category-counts` | — | `{counts: {category: n}, library_pending_suggestions}` — **raw dict 반환(Pydantic 모델 없음), 2026-06-07 라이브 재캡처로 정정**(초기 추출이 shape 합성 오류) | `documents_stats.json` |
|
||||
| POST | `/documents/` (multipart) | 파일 업로드 | `DocumentResponse` (201) | `document_detail.json` |
|
||||
| POST | `/documents/` (multipart/form-data) | `file`(필수) + `doc_purpose?`(business\|knowledge) `library_path?` `facet_*?` | `DocumentResponse` (201) | `document_detail.json` |
|
||||
| PATCH | `/documents/{id}` | `DocumentUpdate` | `DocumentResponse` | — |
|
||||
| PUT | `/documents/{id}/content` | `{content}` (md 편집 저장) | `{}` | — |
|
||||
| POST | `/documents/{id}/accept-suggestion` | `{expected_source_updated_at}` | `DocumentResponse` | — |
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
DSShell.xcodeproj/
|
||||
Support/
|
||||
.build/
|
||||
*.xcuserstate
|
||||
@@ -0,0 +1,74 @@
|
||||
{
|
||||
"info" : {
|
||||
"version" : 1,
|
||||
"author" : "xcode"
|
||||
},
|
||||
"images" : [
|
||||
{
|
||||
"scale" : "1x",
|
||||
"filename" : "mac_16.png",
|
||||
"idiom" : "mac",
|
||||
"size" : "16x16"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"size" : "16x16",
|
||||
"scale" : "2x",
|
||||
"filename" : "mac_32.png"
|
||||
},
|
||||
{
|
||||
"filename" : "mac_32.png",
|
||||
"size" : "32x32",
|
||||
"scale" : "1x",
|
||||
"idiom" : "mac"
|
||||
},
|
||||
{
|
||||
"scale" : "2x",
|
||||
"idiom" : "mac",
|
||||
"size" : "32x32",
|
||||
"filename" : "mac_64.png"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"size" : "128x128",
|
||||
"filename" : "mac_128.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "128x128",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"filename" : "mac_256.png"
|
||||
},
|
||||
{
|
||||
"filename" : "mac_256.png",
|
||||
"scale" : "1x",
|
||||
"idiom" : "mac",
|
||||
"size" : "256x256"
|
||||
},
|
||||
{
|
||||
"filename" : "mac_512.png",
|
||||
"scale" : "2x",
|
||||
"size" : "256x256",
|
||||
"idiom" : "mac"
|
||||
},
|
||||
{
|
||||
"filename" : "mac_512.png",
|
||||
"size" : "512x512",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "mac_1024.png",
|
||||
"size" : "512x512",
|
||||
"scale" : "2x",
|
||||
"idiom" : "mac"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "ios_1024.png",
|
||||
"size" : "1024x1024",
|
||||
"platform" : "ios"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 569 B |
|
After Width: | Height: | Size: 8.3 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"info" : { "author" : "xcode", "version" : 1 }
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import SwiftUI
|
||||
|
||||
/// DS 웹 래퍼 — document.hyungi.net 을 네이티브 창에 로드. 로그인은 WKWebsiteDataStore.default()
|
||||
/// 영속 쿠키로 유지(브라우저처럼). 맥·iOS 공용 @main.
|
||||
@main
|
||||
struct DSShellApp: App {
|
||||
private let url = URL(string: "https://document.hyungi.net")!
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
RootWeb(url: url)
|
||||
}
|
||||
#if os(macOS)
|
||||
.windowStyle(.automatic)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
struct RootWeb: View {
|
||||
let url: URL
|
||||
var body: some View {
|
||||
WebView(url: url)
|
||||
.ignoresSafeArea()
|
||||
#if os(macOS)
|
||||
.frame(minWidth: 900, minHeight: 600)
|
||||
.background(WindowOnScreenGuard()) // 분리된 모니터 좌표 저장 시 화면 밖 방지
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import SwiftUI
|
||||
import WebKit
|
||||
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
#else
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
/// document.hyungi.net 을 로드하는 WKWebView 래퍼 (맥=NSViewRepresentable / iOS=UIViewRepresentable).
|
||||
/// 영속 데이터스토어 = 로그인 쿠키 유지. 첨부(Content-Disposition: attachment) 응답은 다운로드 처리.
|
||||
/// 파일 업로드(file input)는 WKWebView 가 네이티브 피커로 자동 처리.
|
||||
struct WebView {
|
||||
let url: URL
|
||||
|
||||
func makeCoordinator() -> Coordinator { Coordinator() }
|
||||
|
||||
@MainActor
|
||||
fileprivate func makeWebView(coordinator: Coordinator) -> WKWebView {
|
||||
let cfg = WKWebViewConfiguration()
|
||||
cfg.websiteDataStore = .default() // 영속 쿠키 → 로그인 유지(브라우저처럼)
|
||||
let wv = WKWebView(frame: .zero, configuration: cfg)
|
||||
wv.navigationDelegate = coordinator
|
||||
wv.allowsBackForwardNavigationGestures = true
|
||||
wv.load(URLRequest(url: url))
|
||||
return wv
|
||||
}
|
||||
|
||||
final class Coordinator: NSObject, WKNavigationDelegate, WKDownloadDelegate {
|
||||
// 첨부 응답이면 다운로드, 아니면 일반 표시(PDF 등 인라인).
|
||||
func webView(_ webView: WKWebView,
|
||||
decidePolicyFor navigationResponse: WKNavigationResponse,
|
||||
decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void) {
|
||||
if let http = navigationResponse.response as? HTTPURLResponse,
|
||||
let cd = http.value(forHTTPHeaderField: "Content-Disposition"),
|
||||
cd.lowercased().contains("attachment") {
|
||||
decisionHandler(.download)
|
||||
} else {
|
||||
decisionHandler(.allow)
|
||||
}
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, navigationResponse: WKNavigationResponse, didBecome download: WKDownload) {
|
||||
download.delegate = self
|
||||
}
|
||||
func webView(_ webView: WKWebView, navigationAction: WKNavigationAction, didBecome download: WKDownload) {
|
||||
download.delegate = self
|
||||
}
|
||||
|
||||
func download(_ download: WKDownload,
|
||||
decideDestinationUsing response: URLResponse,
|
||||
suggestedFilename: String) async -> URL? {
|
||||
#if os(macOS)
|
||||
let folder = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first
|
||||
#else
|
||||
let folder = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first
|
||||
#endif
|
||||
let dir = folder ?? FileManager.default.temporaryDirectory
|
||||
var dest = dir.appendingPathComponent(suggestedFilename.isEmpty ? "download" : suggestedFilename)
|
||||
// 충돌 회피 (name_1.ext …)
|
||||
let base = dest.deletingPathExtension().lastPathComponent
|
||||
let ext = dest.pathExtension
|
||||
var n = 1
|
||||
while FileManager.default.fileExists(atPath: dest.path) {
|
||||
let name = ext.isEmpty ? "\(base)_\(n)" : "\(base)_\(n).\(ext)"
|
||||
dest = dir.appendingPathComponent(name); n += 1
|
||||
}
|
||||
return dest
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
extension WebView: NSViewRepresentable {
|
||||
func makeNSView(context: Context) -> WKWebView { makeWebView(coordinator: context.coordinator) }
|
||||
func updateNSView(_ nsView: WKWebView, context: Context) {}
|
||||
}
|
||||
|
||||
/// 창이 어느 화면과도 안 겹치면(분리된 외부모니터 좌표 저장 등) 메인 화면 중앙으로 복귀 — "창 안 뜸" 방지.
|
||||
struct WindowOnScreenGuard: NSViewRepresentable {
|
||||
func makeNSView(context: Context) -> NSView { OnScreenView() }
|
||||
func updateNSView(_ nsView: NSView, context: Context) {}
|
||||
final class OnScreenView: NSView {
|
||||
override func viewDidMoveToWindow() {
|
||||
super.viewDidMoveToWindow()
|
||||
guard let win = window else { return }
|
||||
if !NSScreen.screens.contains(where: { $0.visibleFrame.intersects(win.frame) }) { win.center() }
|
||||
}
|
||||
}
|
||||
}
|
||||
#else
|
||||
extension WebView: UIViewRepresentable {
|
||||
func makeUIView(context: Context) -> WKWebView { makeWebView(coordinator: context.coordinator) }
|
||||
func updateUIView(_ uiView: WKWebView, context: Context) {}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,88 @@
|
||||
# DS 웹 래퍼 — document.hyungi.net 을 WKWebView 로 감싼 네이티브 앱(맥 + iOS).
|
||||
# 웹 UI 100% 재사용·항상 최신·코드 1벌(2026-06-15 결정). 순수 네이티브는 워치(clients/ds-watch)만.
|
||||
# project.yml = source of truth, *.xcodeproj/Support = 생성물(gitignore).
|
||||
name: DSShell
|
||||
options:
|
||||
bundleIdPrefix: net.hyungi
|
||||
deploymentTarget:
|
||||
macOS: "14.0"
|
||||
iOS: "17.0"
|
||||
createIntermediateGroups: true
|
||||
minimumXcodeGenVersion: "2.40.0"
|
||||
|
||||
settings:
|
||||
base:
|
||||
SWIFT_VERSION: "6.0"
|
||||
CODE_SIGN_STYLE: Automatic
|
||||
CODE_SIGNING_ALLOWED: "NO"
|
||||
CODE_SIGNING_REQUIRED: "NO"
|
||||
GENERATE_INFOPLIST_FILE: "NO"
|
||||
|
||||
targets:
|
||||
DSShellMac:
|
||||
type: application
|
||||
platform: macOS
|
||||
deploymentTarget: "14.0"
|
||||
sources:
|
||||
- path: Sources
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: net.hyungi.dsshell
|
||||
PRODUCT_NAME: DS
|
||||
MARKETING_VERSION: "0.1"
|
||||
CURRENT_PROJECT_VERSION: "1"
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
||||
info:
|
||||
path: Support/Mac-Info.plist
|
||||
properties:
|
||||
CFBundleName: DS
|
||||
CFBundleDisplayName: DS
|
||||
CFBundleShortVersionString: "0.1"
|
||||
CFBundleVersion: "1"
|
||||
CFBundlePackageType: APPL
|
||||
LSMinimumSystemVersion: "14.0"
|
||||
LSApplicationCategoryType: public.app-category.productivity
|
||||
entitlements:
|
||||
path: Support/Mac.entitlements
|
||||
properties:
|
||||
com.apple.security.app-sandbox: true
|
||||
com.apple.security.network.client: true
|
||||
com.apple.security.files.downloads.read-write: true # 원본 다운로드 저장
|
||||
com.apple.security.files.user-selected.read-write: true # 업로드 파일 선택
|
||||
|
||||
DSShelliOS:
|
||||
type: application
|
||||
platform: iOS
|
||||
deploymentTarget: "17.0"
|
||||
sources:
|
||||
- path: Sources
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: net.hyungi.dsshell
|
||||
PRODUCT_NAME: DS
|
||||
MARKETING_VERSION: "0.1"
|
||||
CURRENT_PROJECT_VERSION: "1"
|
||||
TARGETED_DEVICE_FAMILY: "1,2"
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
||||
info:
|
||||
path: Support/iOS-Info.plist
|
||||
properties:
|
||||
CFBundleName: DS
|
||||
CFBundleDisplayName: DS
|
||||
CFBundleShortVersionString: "0.1"
|
||||
CFBundleVersion: "1"
|
||||
UILaunchScreen: {}
|
||||
UISupportedInterfaceOrientations:
|
||||
- UIInterfaceOrientationPortrait
|
||||
- UIInterfaceOrientationLandscapeLeft
|
||||
- UIInterfaceOrientationLandscapeRight
|
||||
|
||||
schemes:
|
||||
DSShellMac:
|
||||
build:
|
||||
targets: { DSShellMac: all }
|
||||
run: { config: Debug }
|
||||
DSShelliOS:
|
||||
build:
|
||||
targets: { DSShelliOS: all }
|
||||
run: { config: Debug }
|
||||
@@ -0,0 +1,5 @@
|
||||
# xcodegen 생성물 (project.yml 이 source of truth)
|
||||
DSWatch.xcodeproj/
|
||||
Support/
|
||||
.build/
|
||||
*.xcuserstate
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"images" : [
|
||||
{ "idiom" : "universal", "platform" : "watchos", "size" : "1024x1024", "filename" : "watch_1024.png" }
|
||||
],
|
||||
"info" : { "author" : "xcode", "version" : 1 }
|
||||
}
|
||||
|
After Width: | Height: | Size: 38 KiB |
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"info" : { "author" : "xcode", "version" : 1 }
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import SwiftUI
|
||||
|
||||
/// DS 애플워치 앱 (standalone). 4기능 = 이드(AI채팅)·공부(암기카드)·할 일·브리핑.
|
||||
/// 공부 = 라이브 결선(/study-cards/due·rate) / 나머지 = 스캐폴드. 다크 OLED.
|
||||
@main
|
||||
struct DSWatchApp: App {
|
||||
@State private var model = WatchModel()
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
RootGate()
|
||||
.environment(model)
|
||||
.task { await model.bootstrap() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 인증 게이트: checking(쿠키 복귀) → loggedOut(로그인) → ready(메뉴).
|
||||
struct RootGate: View {
|
||||
@Environment(WatchModel.self) private var model
|
||||
var body: some View {
|
||||
switch model.phase {
|
||||
case .checking: ProgressView()
|
||||
case .loggedOut: LoginView()
|
||||
case .ready: RootMenu()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import WatchKit
|
||||
|
||||
/// 워치 햅틱 — 평정/완료의 손목 탭 피드백(워치 고유 감각).
|
||||
@MainActor
|
||||
enum Haptics {
|
||||
static func success() { WKInterfaceDevice.current().play(.success) }
|
||||
static func retry() { WKInterfaceDevice.current().play(.retry) }
|
||||
static func click() { WKInterfaceDevice.current().play(.click) }
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
import Foundation
|
||||
|
||||
/// 워치 전용 경량 API 클라이언트. DS 공개 TLS(document.hyungi.net) 직접 도달 — 워치는 Tailscale 불가.
|
||||
/// access 토큰=메모리 / refresh 쿠키=HTTPCookieStorage(7일 영속) 라 1회 로그인 후 자동 유지.
|
||||
/// 계약은 백엔드 Pydantic 모델에서 추출(study_cards.py CardItem/RateBody) — 지어내지 않음.
|
||||
enum WatchAPI {
|
||||
static let baseString = "https://document.hyungi.net/api"
|
||||
}
|
||||
|
||||
/// GET /study-cards/due 의 CardItem (워치가 쓰는 필드만).
|
||||
struct WCard: Decodable, Identifiable, Sendable {
|
||||
let id: Int
|
||||
let format: String
|
||||
let cue: String
|
||||
let fact: String
|
||||
let clozeText: String?
|
||||
let needsReview: Bool
|
||||
let reviewStage: Int?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, format, cue, fact
|
||||
case clozeText = "cloze_text"
|
||||
case needsReview = "needs_review"
|
||||
case reviewStage = "review_stage"
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /events/today 의 EventResponse (워치 할일이 쓰는 필드만).
|
||||
struct WEvent: Decodable, Identifiable, Sendable {
|
||||
let id: Int
|
||||
let title: String
|
||||
let status: String
|
||||
let dueAt: String?
|
||||
let completedAt: String?
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, title, status
|
||||
case dueAt = "due_at"
|
||||
case completedAt = "completed_at"
|
||||
}
|
||||
var isDone: Bool { status == "completed" || completedAt != nil }
|
||||
}
|
||||
private struct WEventList: Decodable { let items: [WEvent] }
|
||||
|
||||
/// GET /briefing/latest 의 토픽/국가관점 (워치 글랜스용 부분집합).
|
||||
struct WPerspective: Decodable, Identifiable, Sendable {
|
||||
let country: String
|
||||
let summary: String
|
||||
var id: String { country }
|
||||
}
|
||||
struct WTopic: Decodable, Identifiable, Sendable {
|
||||
let id: Int
|
||||
let topicLabel: String
|
||||
let headline: String
|
||||
let countryPerspectives: [WPerspective]
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, headline
|
||||
case topicLabel = "topic_label"
|
||||
case countryPerspectives = "country_perspectives"
|
||||
}
|
||||
}
|
||||
struct WBriefing: Decodable, Sendable {
|
||||
let status: String
|
||||
let headlineOneliner: String?
|
||||
let topics: [WTopic]
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case status, topics
|
||||
case headlineOneliner = "headline_oneliner"
|
||||
}
|
||||
}
|
||||
|
||||
/// 이드 채팅 결과 — SSE 누적 답변 또는 unavailable(맥미니 대기/고장).
|
||||
struct ChatResult: Sendable {
|
||||
let answer: String
|
||||
let unavailable: Bool
|
||||
let reason: String?
|
||||
}
|
||||
|
||||
private struct AccessTokenBody: Decodable { let accessToken: String
|
||||
enum CodingKeys: String, CodingKey { case accessToken = "access_token" } }
|
||||
|
||||
enum WCError: Error, LocalizedError {
|
||||
case transport(String)
|
||||
case http(Int, String?)
|
||||
case decoding(String)
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .transport(let m): return "네트워크 오류: \(m)"
|
||||
case .http(let s, let m): return m ?? "서버 오류 (\(s))"
|
||||
case .decoding(let m): return "응답 해석 실패: \(m)"
|
||||
}
|
||||
}
|
||||
var isUnauthorized: Bool { if case .http(401, _) = self { return true }; return false }
|
||||
}
|
||||
|
||||
actor WatchClient {
|
||||
private let session: URLSession
|
||||
private var accessToken: String?
|
||||
|
||||
init() {
|
||||
let cfg = URLSessionConfiguration.default
|
||||
cfg.httpCookieStorage = .shared
|
||||
cfg.httpShouldSetCookies = true
|
||||
cfg.waitsForConnectivity = true
|
||||
session = URLSession(configuration: cfg)
|
||||
}
|
||||
|
||||
private func url(_ path: String) -> URL { URL(string: WatchAPI.baseString + "/" + path)! }
|
||||
|
||||
private func send(_ req: URLRequest) async throws -> (Data, HTTPURLResponse) {
|
||||
do {
|
||||
let (d, r) = try await session.data(for: req)
|
||||
guard let h = r as? HTTPURLResponse else { throw WCError.transport("no HTTP response") }
|
||||
return (d, h)
|
||||
} catch let e as WCError { throw e }
|
||||
catch { throw WCError.transport("\(error.localizedDescription)") }
|
||||
}
|
||||
|
||||
private static func decodeMessage(_ data: Data) -> String? {
|
||||
guard let o = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil }
|
||||
if let s = o["detail"] as? String { return s }
|
||||
if let d = o["detail"] as? [String: Any] { return d["message"] as? String }
|
||||
return nil
|
||||
}
|
||||
|
||||
// MARK: auth
|
||||
|
||||
func login(username: String, password: String, totp: String?) async throws {
|
||||
var req = URLRequest(url: url("auth/login"))
|
||||
req.httpMethod = "POST"
|
||||
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
var body: [String: Any] = ["username": username, "password": password]
|
||||
if let totp, !totp.isEmpty { body["totp_code"] = totp }
|
||||
req.httpBody = try JSONSerialization.data(withJSONObject: body)
|
||||
let (data, http) = try await send(req)
|
||||
guard (200..<300).contains(http.statusCode) else { throw WCError.http(http.statusCode, Self.decodeMessage(data)) }
|
||||
accessToken = try decodeToken(data)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func refresh() async throws -> String {
|
||||
var req = URLRequest(url: url("auth/refresh"))
|
||||
req.httpMethod = "POST"
|
||||
let (data, http) = try await send(req)
|
||||
guard (200..<300).contains(http.statusCode) else { throw WCError.http(http.statusCode, Self.decodeMessage(data)) }
|
||||
let t = try decodeToken(data)
|
||||
accessToken = t
|
||||
return t
|
||||
}
|
||||
|
||||
func logout() async {
|
||||
accessToken = nil
|
||||
var req = URLRequest(url: url("auth/logout")); req.httpMethod = "POST"
|
||||
_ = try? await send(req)
|
||||
}
|
||||
|
||||
private func decodeToken(_ data: Data) throws -> String {
|
||||
do { return try JSONDecoder().decode(AccessTokenBody.self, from: data).accessToken }
|
||||
catch { throw WCError.decoding("token: \(error)") }
|
||||
}
|
||||
|
||||
// MARK: authed request (401 → single refresh + retry)
|
||||
|
||||
private func authed(_ path: String, method: String = "GET", json: [String: Any]? = nil) async throws -> Data {
|
||||
func make(_ token: String?) throws -> URLRequest {
|
||||
var r = URLRequest(url: url(path))
|
||||
r.httpMethod = method
|
||||
if let token { r.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") }
|
||||
if let json { r.httpBody = try JSONSerialization.data(withJSONObject: json); r.setValue("application/json", forHTTPHeaderField: "Content-Type") }
|
||||
return r
|
||||
}
|
||||
let (data, http) = try await send(make(accessToken))
|
||||
if http.statusCode == 401 {
|
||||
let newToken = try await refresh()
|
||||
let (d2, h2) = try await send(make(newToken))
|
||||
guard (200..<300).contains(h2.statusCode) else { throw WCError.http(h2.statusCode, Self.decodeMessage(d2)) }
|
||||
return d2
|
||||
}
|
||||
guard (200..<300).contains(http.statusCode) else { throw WCError.http(http.statusCode, Self.decodeMessage(data)) }
|
||||
return data
|
||||
}
|
||||
|
||||
// MARK: study cards
|
||||
|
||||
func dueCards() async throws -> [WCard] {
|
||||
let data = try await authed("study-cards/due")
|
||||
do { return try JSONDecoder().decode([WCard].self, from: data) }
|
||||
catch { throw WCError.decoding("due: \(error)") }
|
||||
}
|
||||
|
||||
func rate(cardId: Int, outcome: String) async throws {
|
||||
_ = try await authed("study-cards/\(cardId)/rate", method: "POST", json: ["outcome": outcome])
|
||||
}
|
||||
|
||||
func flag(cardId: Int) async throws {
|
||||
_ = try await authed("study-cards/\(cardId)", method: "PATCH", json: ["needs_review": true])
|
||||
}
|
||||
|
||||
// MARK: events (할일)
|
||||
|
||||
func events() async throws -> [WEvent] {
|
||||
let data = try await authed("events/today")
|
||||
do { return try JSONDecoder().decode(WEventList.self, from: data).items }
|
||||
catch { throw WCError.decoding("events: \(error)") }
|
||||
}
|
||||
|
||||
func completeEvent(id: Int) async throws {
|
||||
_ = try await authed("events/\(id)/complete", method: "POST")
|
||||
}
|
||||
|
||||
// MARK: briefing (모닝 브리핑)
|
||||
|
||||
func briefing() async throws -> WBriefing {
|
||||
let data = try await authed("briefing/latest")
|
||||
do { return try JSONDecoder().decode(WBriefing.self, from: data) }
|
||||
catch { throw WCError.decoding("briefing: \(error)") }
|
||||
}
|
||||
|
||||
// MARK: eid chat (SSE 누적 — 맥미니 26B via DS 프록시)
|
||||
|
||||
func chat(_ text: String) async throws -> ChatResult {
|
||||
let payload: [String: Any] = ["mode": "daily", "messages": [["role": "user", "content": text]]]
|
||||
func make(_ token: String?) throws -> URLRequest {
|
||||
var r = URLRequest(url: url("eid/chat"))
|
||||
r.httpMethod = "POST"
|
||||
r.timeoutInterval = 120
|
||||
if let token { r.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") }
|
||||
r.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
r.setValue("text/event-stream", forHTTPHeaderField: "Accept")
|
||||
r.httpBody = try JSONSerialization.data(withJSONObject: payload)
|
||||
return r
|
||||
}
|
||||
var (stream, resp) = try await session.bytes(for: make(accessToken))
|
||||
if (resp as? HTTPURLResponse)?.statusCode == 401 {
|
||||
let t = try await refresh()
|
||||
(stream, resp) = try await session.bytes(for: make(t))
|
||||
}
|
||||
guard let http = resp as? HTTPURLResponse else { throw WCError.transport("no HTTP response") }
|
||||
let ctype = http.value(forHTTPHeaderField: "Content-Type") ?? ""
|
||||
|
||||
if ctype.contains("text/event-stream") {
|
||||
var answer = ""
|
||||
for try await line in stream.lines {
|
||||
guard line.hasPrefix("data:") else { continue }
|
||||
let body = line.dropFirst(5).trimmingCharacters(in: .whitespaces)
|
||||
if body == "[DONE]" || body.isEmpty { continue }
|
||||
if let d = body.data(using: .utf8),
|
||||
let obj = try? JSONSerialization.jsonObject(with: d) as? [String: Any],
|
||||
let choices = obj["choices"] as? [[String: Any]],
|
||||
let delta = choices.first?["delta"] as? [String: Any],
|
||||
let content = delta["content"] as? String {
|
||||
answer += content
|
||||
}
|
||||
}
|
||||
return ChatResult(answer: answer, unavailable: answer.isEmpty,
|
||||
reason: answer.isEmpty ? "빈 응답" : nil)
|
||||
}
|
||||
// 비-스트림 = unavailable JSONResponse (맥미니 대기/고장) — 사유 추출.
|
||||
var raw = Data()
|
||||
for try await b in stream { raw.append(b) }
|
||||
return ChatResult(answer: "", unavailable: true, reason: Self.decodeMessage(raw) ?? "이드 연결 불가")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import SwiftUI
|
||||
|
||||
/// 워치 홈 = 4기능 메뉴. 작은 화면이라 큰 탭타깃 리스트.
|
||||
struct RootMenu: View {
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
NavigationLink { EidView() } label: {
|
||||
MenuRow(symbol: "bubble.left.and.bubble.right.fill", title: "이드", sub: "AI 채팅")
|
||||
}
|
||||
NavigationLink { StudyView() } label: {
|
||||
MenuRow(symbol: "rectangle.on.rectangle.angled.fill", title: "공부", sub: "암기 카드")
|
||||
}
|
||||
NavigationLink { TodoView() } label: {
|
||||
MenuRow(symbol: "checklist", title: "할 일", sub: "오늘")
|
||||
}
|
||||
NavigationLink { BriefingView() } label: {
|
||||
MenuRow(symbol: "newspaper.fill", title: "브리핑", sub: "모닝")
|
||||
}
|
||||
}
|
||||
.navigationTitle("DS")
|
||||
}
|
||||
.tint(WT.accent)
|
||||
}
|
||||
}
|
||||
|
||||
struct MenuRow: View {
|
||||
let symbol: String
|
||||
let title: String
|
||||
let sub: String
|
||||
var body: some View {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: symbol)
|
||||
.font(.system(size: 16))
|
||||
.foregroundStyle(WT.accent)
|
||||
.frame(width: 24)
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(title).font(.system(size: 16, weight: .semibold)).foregroundStyle(WT.ink)
|
||||
Text(sub).font(.system(size: 11)).foregroundStyle(WT.muted)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 3)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview { RootMenu() }
|
||||
@@ -0,0 +1,160 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - 할 일 (Todo) — GET /events/today + 탭하면 POST /complete
|
||||
|
||||
struct TodoView: View {
|
||||
@Environment(WatchModel.self) private var model
|
||||
@State private var loaded = false
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if model.eventsLoading && model.events.isEmpty {
|
||||
ProgressView()
|
||||
} else if let e = model.eventsError, model.events.isEmpty {
|
||||
retry("불러오기 실패\n\(e)") { await model.loadEvents() }
|
||||
} else if model.events.isEmpty {
|
||||
retry("오늘 할 일이 없어요", color: WT.muted) { await model.loadEvents() }
|
||||
} else {
|
||||
List(model.events) { ev in
|
||||
Button {
|
||||
if !ev.isDone { Haptics.success() }
|
||||
Task { await model.completeEvent(ev.id) }
|
||||
} label: {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: ev.isDone ? "checkmark.circle.fill" : "circle")
|
||||
.font(.system(size: 17))
|
||||
.foregroundStyle(ev.isDone ? WT.accent : WT.muted)
|
||||
Text(ev.title)
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(ev.isDone ? WT.muted : WT.ink)
|
||||
.strikethrough(ev.isDone, color: WT.muted)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("할 일")
|
||||
.task { if !loaded { loaded = true; await model.loadEvents() } }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 브리핑 (모닝) — GET /briefing/latest, 글랜스→정독 스크롤
|
||||
|
||||
struct BriefingView: View {
|
||||
@Environment(WatchModel.self) private var model
|
||||
@State private var loaded = false
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
if model.briefingLoading && model.briefing == nil {
|
||||
ProgressView().padding(.top, 20)
|
||||
} else if let e = model.briefingError, model.briefing == nil {
|
||||
retry("불러오기 실패\n\(e)") { await model.loadBriefing() }
|
||||
} else if let b = model.briefing, !b.topics.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
if let one = b.headlineOneliner, !one.isEmpty {
|
||||
Text(one).font(.system(size: 15, weight: .semibold)).foregroundStyle(WT.ink)
|
||||
}
|
||||
ForEach(b.topics) { t in
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
Text(t.headline).font(.system(size: 13, weight: .semibold)).foregroundStyle(WT.ink)
|
||||
ForEach(t.countryPerspectives) { p in
|
||||
HStack(alignment: .top, spacing: 5) {
|
||||
Text(p.country.uppercased())
|
||||
.font(.system(size: 9, weight: .bold)).foregroundStyle(WT.accent)
|
||||
.frame(minWidth: 22, alignment: .leading)
|
||||
Text(p.summary).font(.system(size: 11)).foregroundStyle(WT.muted).lineLimit(4)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(8)
|
||||
.background(WT.card, in: RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
retry("오늘 브리핑이 아직 없어요", color: WT.muted) { await model.loadBriefing() }
|
||||
}
|
||||
}
|
||||
.navigationTitle("브리핑")
|
||||
.task { if !loaded { loaded = true; await model.loadBriefing() } }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 이드 (AI 채팅) — POST /eid/chat (맥미니 26B via DS 프록시)
|
||||
|
||||
struct EidView: View {
|
||||
@Environment(WatchModel.self) private var model
|
||||
@State private var draft = ""
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 8) {
|
||||
HStack(spacing: 6) {
|
||||
TextField("물어보기…", text: $draft)
|
||||
.textFieldStyle(.plain)
|
||||
.padding(8)
|
||||
.background(WT.card, in: RoundedRectangle(cornerRadius: 10))
|
||||
Button {
|
||||
let t = draft; draft = ""
|
||||
Task { await model.sendChat(t) }
|
||||
} label: {
|
||||
Image(systemName: "arrow.up.circle.fill").font(.system(size: 22))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(WT.accent)
|
||||
.disabled(draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || model.chatSending)
|
||||
}
|
||||
if model.chatSending {
|
||||
HStack(spacing: 6) {
|
||||
ProgressView().controlSize(.small)
|
||||
Text("이드 생각 중…").font(.system(size: 11)).foregroundStyle(WT.muted)
|
||||
}
|
||||
}
|
||||
ForEach(model.chatTurns.reversed()) { turn in
|
||||
ChatBubble(turn: turn)
|
||||
}
|
||||
if model.chatTurns.isEmpty && !model.chatSending {
|
||||
Text("음성·키보드로 묻고\n맥미니 26B 가 답합니다")
|
||||
.font(.system(size: 11)).foregroundStyle(WT.muted)
|
||||
.multilineTextAlignment(.center).padding(.top, 8)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("이드")
|
||||
}
|
||||
}
|
||||
|
||||
private struct ChatBubble: View {
|
||||
let turn: WatchModel.ChatTurn
|
||||
var body: some View {
|
||||
let isUser = turn.role == "user"
|
||||
let isError = turn.role == "error"
|
||||
HStack {
|
||||
if isUser { Spacer(minLength: 24) }
|
||||
Text(turn.text)
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(isUser ? .black : (isError ? WT.danger : WT.ink))
|
||||
.frame(maxWidth: .infinity, alignment: isUser ? .trailing : .leading)
|
||||
.padding(8)
|
||||
.background(isUser ? WT.accent : (isError ? WT.danger.opacity(0.15) : WT.card),
|
||||
in: RoundedRectangle(cornerRadius: 10))
|
||||
if !isUser { Spacer(minLength: 24) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 공용 상태/재시도
|
||||
|
||||
@MainActor
|
||||
private func retry(_ text: String, color: Color = WT.danger, _ action: @escaping () async -> Void) -> some View {
|
||||
VStack(spacing: 10) {
|
||||
Text(text).font(.system(size: 13)).foregroundStyle(color).multilineTextAlignment(.center)
|
||||
Button("다시 불러오기") { Task { await action() } }.tint(WT.accent)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.horizontal, 6).padding(.top, 16)
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import SwiftUI
|
||||
|
||||
/// 암기 카드 학습 (라이브) — 능동 회상(앞면 cue → 답 보기 → 뒷면 fact) + 2단 평정(다시/알아요).
|
||||
/// 확정 워치 설계(B5): 2단 평정만(애매는 웹), '이 카드 이상해요' 플래그(교정은 웹/폰), 다크 OLED.
|
||||
/// 데이터 = GET /study-cards/due, 평정 = POST /{id}/rate (correct/wrong), 플래그 = PATCH needs_review.
|
||||
struct StudyView: View {
|
||||
@Environment(WatchModel.self) private var model
|
||||
@State private var index = 0
|
||||
@State private var revealed = false
|
||||
@State private var correctCount = 0
|
||||
@State private var flagged = false
|
||||
@State private var loaded = false
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if model.studyLoading && model.cards.isEmpty {
|
||||
ProgressView()
|
||||
} else if let err = model.studyError, model.cards.isEmpty {
|
||||
stateText("불러오기 실패\n\(err)", color: WT.danger, retry: true)
|
||||
} else if model.cards.isEmpty {
|
||||
stateText("복습할 카드가 없어요", color: WT.muted, retry: true)
|
||||
} else if index >= model.cards.count {
|
||||
ResultView(total: model.cards.count, correct: correctCount) { Task { await reload() } }
|
||||
} else {
|
||||
cardScreen(model.cards[index])
|
||||
}
|
||||
}
|
||||
.navigationTitle("공부")
|
||||
.task { if !loaded { loaded = true; await model.loadDue(); reset() } }
|
||||
}
|
||||
|
||||
private func cardScreen(_ c: WCard) -> some View {
|
||||
VStack(spacing: 8) {
|
||||
HStack {
|
||||
Text("\(index + 1) / \(model.cards.count)").font(.system(size: 11)).foregroundStyle(WT.muted)
|
||||
Spacer()
|
||||
Button {
|
||||
flagged = true
|
||||
Haptics.click()
|
||||
Task { await model.flag(cardId: c.id) }
|
||||
} label: {
|
||||
Image(systemName: flagged ? "flag.fill" : "flag")
|
||||
.font(.system(size: 11)).foregroundStyle(flagged ? WT.amber : WT.muted)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
ScrollView {
|
||||
VStack(spacing: 10) {
|
||||
Text(c.cue)
|
||||
.font(.system(size: 17, weight: .semibold)).foregroundStyle(WT.ink)
|
||||
.multilineTextAlignment(.center)
|
||||
if revealed {
|
||||
Divider().overlay(WT.muted.opacity(0.4))
|
||||
Text(c.fact)
|
||||
.font(.system(size: 15)).foregroundStyle(WT.accent)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(12)
|
||||
.background(WT.card, in: RoundedRectangle(cornerRadius: 14))
|
||||
}
|
||||
|
||||
if revealed {
|
||||
HStack(spacing: 8) {
|
||||
rateButton("다시", sub: "내일", color: WT.danger) { advance(c, correct: false) }
|
||||
rateButton("알아요", sub: nil, color: WT.accent) { advance(c, correct: true) }
|
||||
}
|
||||
} else {
|
||||
Button { withAnimation(.easeOut(duration: 0.15)) { revealed = true } } label: {
|
||||
Text("답 보기").frame(maxWidth: .infinity)
|
||||
}
|
||||
.tint(WT.accent)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
|
||||
private func rateButton(_ title: String, sub: String?, color: Color, _ action: @escaping () -> Void) -> some View {
|
||||
Button(action: action) {
|
||||
VStack(spacing: 1) {
|
||||
Text(title).font(.system(size: 14, weight: .semibold))
|
||||
if let sub { Text(sub).font(.system(size: 9)).opacity(0.8) }
|
||||
}
|
||||
.frame(maxWidth: .infinity).padding(.vertical, 2)
|
||||
}
|
||||
.tint(color)
|
||||
}
|
||||
|
||||
private func stateText(_ text: String, color: Color, retry: Bool) -> some View {
|
||||
VStack(spacing: 10) {
|
||||
Text(text).font(.system(size: 13)).foregroundStyle(color).multilineTextAlignment(.center)
|
||||
if retry { Button("다시 불러오기") { Task { await reload() } }.tint(WT.accent) }
|
||||
}
|
||||
.padding(.horizontal, 6)
|
||||
}
|
||||
|
||||
private func advance(_ c: WCard, correct: Bool) {
|
||||
if correct { correctCount += 1 }
|
||||
Haptics.success() // 평정 손목 탭 (다시/알아요 동일 확정 피드백)
|
||||
Task { await model.rate(cardId: c.id, outcome: correct ? "correct" : "wrong") }
|
||||
flagged = false
|
||||
revealed = false
|
||||
index += 1
|
||||
}
|
||||
|
||||
private func reload() async { await model.loadDue(); reset() }
|
||||
private func reset() { index = 0; revealed = false; correctCount = 0; flagged = false }
|
||||
}
|
||||
|
||||
/// 세션 결과 — 정직한 tally만(서버 미제공 streak 등 날조 X).
|
||||
struct ResultView: View {
|
||||
let total: Int
|
||||
let correct: Int
|
||||
let onRestart: () -> Void
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 10) {
|
||||
Image(systemName: "checkmark.seal.fill").font(.system(size: 30)).foregroundStyle(WT.accent)
|
||||
Text("오늘 복습 완료").font(.system(size: 16, weight: .semibold)).foregroundStyle(WT.ink)
|
||||
Text("\(correct) / \(total) 알아요").font(.system(size: 13)).foregroundStyle(WT.muted)
|
||||
Text("애매하거나 몰랐던 카드는 내일 다시 만나요")
|
||||
.font(.system(size: 11)).foregroundStyle(WT.muted).multilineTextAlignment(.center)
|
||||
Button("다시 불러오기", action: onRestart).tint(WT.accent).padding(.top, 4)
|
||||
}
|
||||
.frame(maxWidth: .infinity).padding(.vertical, 6)
|
||||
}
|
||||
.navigationTitle("결과")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import SwiftUI
|
||||
import Observation
|
||||
|
||||
/// 워치 앱 상태. 부팅 시 refresh 쿠키로 무로그인 복귀 시도 → 실패 시 로그인. 공부 카드 라이브 결선.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class WatchModel {
|
||||
enum Phase: Equatable { case checking, loggedOut, ready }
|
||||
|
||||
var phase: Phase = .checking
|
||||
var loginError: String?
|
||||
|
||||
// 공부(study)
|
||||
var cards: [WCard] = []
|
||||
var studyLoading = false
|
||||
var studyError: String?
|
||||
|
||||
// 할일(events)
|
||||
var events: [WEvent] = []
|
||||
var eventsLoading = false
|
||||
var eventsError: String?
|
||||
|
||||
// 브리핑
|
||||
var briefing: WBriefing?
|
||||
var briefingLoading = false
|
||||
var briefingError: String?
|
||||
|
||||
// 이드(chat)
|
||||
struct ChatTurn: Identifiable, Sendable { let id: Int; let role: String; let text: String }
|
||||
var chatTurns: [ChatTurn] = []
|
||||
var chatSending = false
|
||||
private var chatSeq = 0
|
||||
|
||||
private let client = WatchClient()
|
||||
|
||||
func bootstrap() async {
|
||||
do { _ = try await client.refresh(); phase = .ready }
|
||||
catch { phase = .loggedOut } // 쿠키 없음/만료 = 정상 로그인 흐름
|
||||
}
|
||||
|
||||
func login(username: String, password: String, totp: String?) async {
|
||||
loginError = nil
|
||||
let code = totp?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
do {
|
||||
try await client.login(username: username, password: password,
|
||||
totp: (code?.isEmpty ?? true) ? nil : code)
|
||||
phase = .ready
|
||||
} catch {
|
||||
loginError = (error as? LocalizedError)?.errorDescription ?? "\(error)"
|
||||
}
|
||||
}
|
||||
|
||||
func logout() async {
|
||||
await client.logout()
|
||||
cards = []; studyError = nil
|
||||
phase = .loggedOut
|
||||
}
|
||||
|
||||
func loadDue() async {
|
||||
studyLoading = true; studyError = nil
|
||||
do { cards = try await client.dueCards() }
|
||||
catch let e as WCError where e.isUnauthorized { phase = .loggedOut }
|
||||
catch { studyError = (error as? LocalizedError)?.errorDescription ?? "\(error)" }
|
||||
studyLoading = false
|
||||
}
|
||||
|
||||
/// 평정 전송 (correct/wrong). 실패해도 학습 흐름은 진행(다음 카드) — 오류만 표시.
|
||||
func rate(cardId: Int, outcome: String) async {
|
||||
do { try await client.rate(cardId: cardId, outcome: outcome) }
|
||||
catch let e as WCError where e.isUnauthorized { phase = .loggedOut }
|
||||
catch { studyError = (error as? LocalizedError)?.errorDescription ?? "\(error)" }
|
||||
}
|
||||
|
||||
func flag(cardId: Int) async {
|
||||
do { try await client.flag(cardId: cardId) }
|
||||
catch { studyError = (error as? LocalizedError)?.errorDescription ?? "\(error)" }
|
||||
}
|
||||
|
||||
// MARK: 할일(events)
|
||||
|
||||
func loadEvents() async {
|
||||
eventsLoading = true; eventsError = nil
|
||||
do { events = try await client.events() }
|
||||
catch let e as WCError where e.isUnauthorized { phase = .loggedOut }
|
||||
catch { eventsError = (error as? LocalizedError)?.errorDescription ?? "\(error)" }
|
||||
eventsLoading = false
|
||||
}
|
||||
|
||||
func completeEvent(_ id: Int) async {
|
||||
do { try await client.completeEvent(id: id); await loadEvents() }
|
||||
catch let e as WCError where e.isUnauthorized { phase = .loggedOut }
|
||||
catch { eventsError = (error as? LocalizedError)?.errorDescription ?? "\(error)" }
|
||||
}
|
||||
|
||||
// MARK: 브리핑
|
||||
|
||||
func loadBriefing() async {
|
||||
briefingLoading = true; briefingError = nil
|
||||
do { briefing = try await client.briefing() }
|
||||
catch let e as WCError where e.isUnauthorized { phase = .loggedOut }
|
||||
catch { briefingError = (error as? LocalizedError)?.errorDescription ?? "\(error)" }
|
||||
briefingLoading = false
|
||||
}
|
||||
|
||||
// MARK: 이드(chat)
|
||||
|
||||
func sendChat(_ text: String) async {
|
||||
let t = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !t.isEmpty, !chatSending else { return }
|
||||
chatSeq += 1; chatTurns.append(.init(id: chatSeq, role: "user", text: t))
|
||||
chatSending = true
|
||||
do {
|
||||
let result = try await client.chat(t)
|
||||
chatSeq += 1
|
||||
if result.unavailable {
|
||||
chatTurns.append(.init(id: chatSeq, role: "error", text: result.reason ?? "이드 연결 불가"))
|
||||
} else {
|
||||
chatTurns.append(.init(id: chatSeq, role: "assistant", text: result.answer))
|
||||
}
|
||||
} catch let e as WCError where e.isUnauthorized {
|
||||
phase = .loggedOut
|
||||
} catch {
|
||||
chatSeq += 1
|
||||
chatTurns.append(.init(id: chatSeq, role: "error",
|
||||
text: (error as? LocalizedError)?.errorDescription ?? "\(error)"))
|
||||
}
|
||||
chatSending = false
|
||||
}
|
||||
}
|
||||
|
||||
/// 워치 1회 로그인 (refresh 쿠키 7일 → 사실상 주1회). TOTP 사용 계정이라 6자리 코드 입력란 포함.
|
||||
struct LoginView: View {
|
||||
@Environment(WatchModel.self) private var model
|
||||
@State private var username = ""
|
||||
@State private var password = ""
|
||||
@State private var totp = ""
|
||||
@State private var busy = false
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 8) {
|
||||
Text("DS 로그인").font(.system(size: 16, weight: .semibold)).foregroundStyle(WT.ink)
|
||||
TextField("아이디", text: $username)
|
||||
.textContentType(.username)
|
||||
SecureField("비밀번호", text: $password)
|
||||
TextField("OTP 6자리", text: $totp)
|
||||
if let err = model.loginError {
|
||||
Text(err).font(.system(size: 11)).foregroundStyle(WT.danger).multilineTextAlignment(.center)
|
||||
}
|
||||
Button {
|
||||
busy = true
|
||||
Task { await model.login(username: username, password: password, totp: totp); busy = false }
|
||||
} label: {
|
||||
if busy { ProgressView() } else { Text("로그인").frame(maxWidth: .infinity) }
|
||||
}
|
||||
.tint(WT.accent)
|
||||
.disabled(busy || username.isEmpty || password.isEmpty)
|
||||
}
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import SwiftUI
|
||||
|
||||
/// 워치 다크 OLED 토큰 (시안 watch-app: --wgreen #37d67a). 검정 배경 = OLED 절전·대비.
|
||||
enum WT {
|
||||
static let bg = Color.black
|
||||
static let card = Color(white: 0.12)
|
||||
static let accent = Color(red: 0x37 / 255, green: 0xd6 / 255, blue: 0x7a / 255) // #37d67a
|
||||
static let ink = Color.white
|
||||
static let muted = Color(white: 0.62)
|
||||
static let amber = Color(red: 0xf2 / 255, green: 0xb6 / 255, blue: 0x3c / 255)
|
||||
static let danger = Color(red: 0xe5 / 255, green: 0x6a / 255, blue: 0x5a / 255)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
# DS Apple Watch 앱 (단일 타깃 standalone watchOS, WKApplication). 맥/아이폰은 웹 래퍼로 가고
|
||||
# 순수 네이티브는 워치 전용(2026-06-15 사용자 결정). 시뮬레이터 빌드·스크린샷으로 검증, 실기기
|
||||
# 설치는 사용자 Xcode 서명. project.yml = source of truth, *.xcodeproj/Support 는 생성물(gitignore).
|
||||
name: DSWatch
|
||||
options:
|
||||
bundleIdPrefix: net.hyungi
|
||||
deploymentTarget:
|
||||
watchOS: "11.0"
|
||||
createIntermediateGroups: true
|
||||
minimumXcodeGenVersion: "2.40.0"
|
||||
|
||||
settings:
|
||||
base:
|
||||
SWIFT_VERSION: "6.0"
|
||||
SWIFT_STRICT_CONCURRENCY: complete
|
||||
WATCHOS_DEPLOYMENT_TARGET: "11.0"
|
||||
CODE_SIGN_STYLE: Automatic
|
||||
# 실기기 설치 시 Xcode 에서 Signing → 본인 Apple ID 팀 선택하면 자동 서명.
|
||||
# (헤드리스 시뮬 빌드는 xcodebuild 에 CODE_SIGNING_ALLOWED=NO 를 CLI 로 전달)
|
||||
|
||||
targets:
|
||||
DSWatch:
|
||||
type: application
|
||||
platform: watchOS
|
||||
deploymentTarget: "11.0"
|
||||
sources:
|
||||
- path: Sources
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: net.hyungi.dswatch
|
||||
PRODUCT_NAME: DS
|
||||
GENERATE_INFOPLIST_FILE: "NO"
|
||||
MARKETING_VERSION: "0.1"
|
||||
CURRENT_PROJECT_VERSION: "1"
|
||||
TARGETED_DEVICE_FAMILY: "4" # Apple Watch
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
||||
info:
|
||||
path: Support/Info.plist
|
||||
properties:
|
||||
CFBundleDisplayName: DS
|
||||
CFBundleName: DS
|
||||
CFBundleVersion: "1"
|
||||
CFBundleShortVersionString: "0.1"
|
||||
WKApplication: true # 단일 타깃 standalone 워치 앱 (컴패니언 불요)
|
||||
WKWatchOnly: true # 컴패니언 iOS 앱 없는 watch-only (설치 필수 키)
|
||||
UISupportedInterfaceOrientations:
|
||||
- UIInterfaceOrientationPortrait
|
||||
|
||||
schemes:
|
||||
DSWatch:
|
||||
build:
|
||||
targets:
|
||||
DSWatch: all
|
||||
run:
|
||||
config: Debug
|
||||
@@ -6,25 +6,40 @@ ai:
|
||||
|
||||
models:
|
||||
# ─── 단일 generation 호스트 routing (2026-05-14 GPU LLM 제거) ───
|
||||
# GPU Ollama gemma4:e4b-it-q8_0 제거. Mac mini 26B-A4B 가 triage + primary + classifier 모두 흡수.
|
||||
# fallback 은 Claude Sonnet 4 API (Mac mini 다운 시 자동 trigger, premium 과 budget 공유).
|
||||
# plan: ~/.claude/plans/rosy-launching-otter.md §C/§D/§E
|
||||
# 2026-06-11 B안: 맥미니 모델 = Gemma 26B-A4B → Qwen3.6-27B-6bit 풀교체 (사용자 결정).
|
||||
# dense 27B 라 디코드 ~13 tok/s 급 (a4b ~42 대비 감속) → timeout 상향 (triage 30→120, primary 180→300).
|
||||
# fallback 은 Claude Sonnet 4 API (CLAUDE_API_KEY 미주입 = 비활성).
|
||||
# plan: ~/.claude/plans/rosy-launching-otter.md §C/§D/§E + project_macmini_model_decision
|
||||
|
||||
# triage: 상시 분류·요약·근거 선별. Mac mini 26B (primary 와 동일 endpoint, 짧은 max_tokens).
|
||||
# triage: 상시 분류·요약·근거 선별. Mac mini Qwen 27B (primary 와 동일 endpoint, 짧은 max_tokens).
|
||||
triage:
|
||||
endpoint: "http://100.76.254.116:8801/v1/chat/completions"
|
||||
model: "mlx-community/gemma-4-26b-a4b-it-8bit"
|
||||
model: "mlx-community/Qwen3.6-27B-6bit"
|
||||
max_tokens: 4096
|
||||
timeout: 30
|
||||
timeout: 480 # 프리필 실측 ~112 tok/s — 120K자 장문 커버 (2026-06-11)
|
||||
context_char_limit: 120000
|
||||
temperature: 0.0
|
||||
|
||||
# primary: 에스컬레이션 전용. 26B MLX (맥미니 Semaphore(1) 보호 대상).
|
||||
# primary: 에스컬레이션 전용. Qwen 27B MLX (맥미니 Semaphore(1) 보호 대상).
|
||||
primary:
|
||||
endpoint: "http://100.76.254.116:8801/v1/chat/completions"
|
||||
model: "mlx-community/gemma-4-26b-a4b-it-8bit"
|
||||
model: "mlx-community/Qwen3.6-27B-6bit"
|
||||
max_tokens: 8192
|
||||
timeout: 180
|
||||
timeout: 900 # 프리필 실측 ~112 tok/s — 260K자 상한 장문 커버 (2026-06-11)
|
||||
context_char_limit: 260000
|
||||
temperature: 0.3
|
||||
top_p: 0.9
|
||||
|
||||
# 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
|
||||
@@ -58,9 +73,9 @@ ai:
|
||||
# classifier_service 가 hasattr 체크로 optional 이므로 이 섹션 제거 시 classifier gate 는 자동 skip (score-only).
|
||||
classifier:
|
||||
endpoint: "http://100.76.254.116:8801/v1/chat/completions"
|
||||
model: "mlx-community/gemma-4-26b-a4b-it-8bit"
|
||||
model: "mlx-community/Qwen3.6-27B-6bit" # 2026-06-11 B안 동승 — gemma id 잔존 시 mlx 서버가 Gemma 를 재로드(이중 적재) 위험
|
||||
max_tokens: 512
|
||||
timeout: 30 # 2026-05-17: 15s 도 동시 부하 시 elapsed 14.4s 직전이라 tight — 30s 로 2x 마진 (Mac mini 26B concurrent load). classifier_service.LLM_TIMEOUT_MS=30000 와 align
|
||||
timeout: 30 # 2026-05-17: 15s 도 동시 부하 시 elapsed 14.4s 직전이라 tight — 30s 로 2x 마진. classifier_service.LLM_TIMEOUT_MS=30000 와 align (초과 = score-only skip, graceful)
|
||||
# 제거: vision (미사용)
|
||||
|
||||
# ─── deep_summary enqueue 폭발 억제 (B-1 R2) ───
|
||||
@@ -84,7 +99,7 @@ search:
|
||||
macbook_url: "http://100.118.112.84:8810" # MacBook M5 Max Tailscale interface bind
|
||||
macbook_model: "mlx-community/Qwen3.6-27B-8bit"
|
||||
timeout_connect_s: 1 # MacBook sleep/wake 빠른 감지 (자동 fallback 부재 → 빠른 503)
|
||||
timeout_read_s: 30 # synthesis_service.LLM_TIMEOUT_MS=30000 와 align
|
||||
timeout_read_s: 120 # 2026-06-11 Qwen 27B(디코드 ~11.7 tok/s) — synthesis_service.LLM_TIMEOUT_MS=120000 와 align
|
||||
# PR-DocSrv-Ask-ToolCalling-ReAct-1: /api/search/ask/react ReAct loop (qwen-macbook only)
|
||||
react:
|
||||
enabled: true
|
||||
@@ -176,3 +191,16 @@ schedule:
|
||||
daily_digest: "20:00"
|
||||
file_watcher_interval_minutes: 5
|
||||
queue_consumer_interval_minutes: 10
|
||||
|
||||
# 생성 LLM 홀드 게이트 (2026-06-11 신설): held_stages 에 든 이름의 컨슈머/워커는 claim 자체를
|
||||
# 하지 않는다 (attempts 미소모, pending 적체). 유효 키 8 = classify/summarize/deep_summary(큐) +
|
||||
# digest/briefing(cron) + study_explanation/study_session_analysis/study_memo_card(컨슈머).
|
||||
# 그 외 문자열은 무동작(오타 주의). 적용/해제 = 리스트 수정 후 fastapi 재기동.
|
||||
# 이력: 2026-06-11 맥미니 모델 확정까지 8키 홀드 → 同日 Qwen3.6-27B-6bit 전환과 함께 해제([]).
|
||||
pipeline:
|
||||
held_stages: []
|
||||
# mlx gate 동시 실행 상한 (2026-06-12 fair-share): 구 "1 고정" 룰의 전제(single-inference
|
||||
# 서버)가 소멸 — 현 mlx_vlm 은 continuous batching (2026-06-11 밤 6~8 concurrent 실측 정상).
|
||||
# 2 = 워커 LLM 호출과 인터랙티브(ask/eid)가 서로 안 막힘 + 집계 throughput ~1.8배.
|
||||
# 게이트(상한+우선순위)는 유지 — thundering herd 방지. 1 로 되돌리면 구 동작.
|
||||
mlx_gate_concurrency: 2
|
||||
|
||||
@@ -0,0 +1,566 @@
|
||||
<script lang="ts">
|
||||
// 처리 머신 보드 v3 — 통합안 (plan ds-board-merged: C2 머신레인 + C3 번다운/정직ETA).
|
||||
// · 머신 3레인(GPU/맥미니/맥북) = "누가 일하나" + 요약 오프로드(맥북 합류) 가시화
|
||||
// · 지배 백로그 번다운 패널 = "언제 끝나나" + 유입 차감한 정직 ETA(summarize_eta)
|
||||
// · 신선도 '갱신 N초 전' + stale 경고 / 실패 드로어·상세 패널은 v2 자산 재사용.
|
||||
// 데이터 = GET /api/queue/overview (60s 폴링 store) + GET /api/queue/failed (드로어).
|
||||
import { api } from '$lib/api';
|
||||
import { refreshQueueOverview, queueUpdatedAt } from '$lib/stores/queueOverview';
|
||||
import { addToast } from '$lib/stores/toast';
|
||||
import {
|
||||
AUX_NODES,
|
||||
FLOW_NODES,
|
||||
MACHINE_META,
|
||||
type FlowNodeDef,
|
||||
type FlowMachine,
|
||||
etaShort,
|
||||
flowStageLabel,
|
||||
formatAgeSec,
|
||||
formatRate,
|
||||
} from '$lib/utils/queueDisplay';
|
||||
import type {
|
||||
FailedItem,
|
||||
FailedListResponse,
|
||||
MachineCurrentItem,
|
||||
MachineOverview,
|
||||
QueueOverview,
|
||||
QueueStageRow,
|
||||
RetryResponse,
|
||||
SkipResponse,
|
||||
} from '$lib/types/queue';
|
||||
|
||||
let { overview }: { overview: QueueOverview } = $props();
|
||||
|
||||
// ─── 노드 통계 합성 ───
|
||||
interface NodeStats {
|
||||
def: FlowNodeDef;
|
||||
/** 다중 stage 노드(청크·임베딩)는 같은 문서가 양쪽 큐에 있어 max — 합산 = 이중계산 */
|
||||
pending: number;
|
||||
processing: number;
|
||||
failed: number; // 실패는 행 단위 사실이라 합산
|
||||
done1h: number;
|
||||
created1h: number;
|
||||
doneToday: number;
|
||||
oldestAgeSec: number | null;
|
||||
etaMinutes: number | null;
|
||||
inflowDominant: boolean;
|
||||
perStage: QueueStageRow[];
|
||||
}
|
||||
|
||||
const stageBy = $derived(new Map(overview.stages.map((s) => [s.stage, s])));
|
||||
|
||||
function nodeStats(def: FlowNodeDef): NodeStats {
|
||||
const rows = def.stages
|
||||
.map((s) => stageBy.get(s))
|
||||
.filter((r): r is QueueStageRow => r != null);
|
||||
const pending = rows.reduce((m, r) => Math.max(m, r.pending), 0);
|
||||
const done1h = rows.reduce((m, r) => Math.max(m, r.done_1h), 0);
|
||||
const created1h = rows.reduce((m, r) => Math.max(m, r.created_1h), 0);
|
||||
const oldest = rows.reduce<number | null>(
|
||||
(m, r) => (r.oldest_pending_age_sec == null ? m : Math.max(m ?? 0, r.oldest_pending_age_sec)),
|
||||
null,
|
||||
);
|
||||
return {
|
||||
def,
|
||||
pending,
|
||||
processing: rows.reduce((s, r) => s + r.processing, 0),
|
||||
failed: rows.reduce((s, r) => s + r.failed, 0),
|
||||
done1h,
|
||||
created1h,
|
||||
doneToday: rows.reduce((m, r) => Math.max(m, r.done_today), 0),
|
||||
oldestAgeSec: oldest,
|
||||
etaMinutes: pending > 0 && done1h > 0 ? Math.round((pending / done1h) * 60) : null,
|
||||
inflowDominant: pending > 0 && created1h > done1h,
|
||||
perStage: rows,
|
||||
};
|
||||
}
|
||||
|
||||
const mainNodes = $derived(FLOW_NODES.map(nodeStats));
|
||||
const auxAll = $derived(AUX_NODES.map(nodeStats));
|
||||
const auxActive = $derived(
|
||||
auxAll.filter((n) => n.pending + n.processing + n.failed + n.doneToday > 0),
|
||||
);
|
||||
const auxIdle = $derived(
|
||||
auxAll.filter((n) => n.pending + n.processing + n.failed + n.doneToday === 0),
|
||||
);
|
||||
const totalFailed = $derived(overview.totals.failed);
|
||||
|
||||
// ─── 선택 상태 (노드 상세 / 실패 드로어 — 동시에 하나만) ───
|
||||
let selected = $state<string | null>(null);
|
||||
let failOpen = $state(false);
|
||||
|
||||
function toggleNode(key: string) {
|
||||
selected = selected === key ? null : key;
|
||||
if (selected) failOpen = false;
|
||||
}
|
||||
|
||||
const selectedNode = $derived(
|
||||
[...mainNodes, ...auxAll].find((n) => n.def.key === selected) ?? null,
|
||||
);
|
||||
|
||||
function nodeCurrent(def: FlowNodeDef): MachineCurrentItem[] {
|
||||
return overview.machines.flatMap((m) => m.current.filter((c) => def.stages.includes(c.stage)));
|
||||
}
|
||||
|
||||
// ─── 실패 드로어 ───
|
||||
let failItems = $state<FailedItem[]>([]);
|
||||
let failLoading = $state(false);
|
||||
let busy = $state(false);
|
||||
let expanded = $state<Record<string, boolean>>({});
|
||||
|
||||
async function openFailures() {
|
||||
failOpen = true;
|
||||
selected = null;
|
||||
await loadFailures();
|
||||
}
|
||||
|
||||
async function loadFailures() {
|
||||
failLoading = true;
|
||||
try {
|
||||
const r = await api<FailedListResponse>('/queue/failed');
|
||||
failItems = r.items;
|
||||
} catch {
|
||||
addToast('error', '실패 목록을 불러오지 못했습니다');
|
||||
} finally {
|
||||
failLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
interface FailGroup {
|
||||
key: string;
|
||||
stage: string;
|
||||
pattern: string;
|
||||
items: FailedItem[];
|
||||
}
|
||||
|
||||
// 그룹핑 = stage + 에러 메시지 prefix(36자) — 같은 원인(ReadTimeout 등) 묶음
|
||||
const failGroups = $derived.by(() => {
|
||||
const map = new Map<string, FailGroup>();
|
||||
for (const it of failItems) {
|
||||
const pattern = (it.error_message ?? '(메시지 없음)').slice(0, 36);
|
||||
const key = `${it.stage}::${pattern}`;
|
||||
const g = map.get(key);
|
||||
if (g) g.items.push(it);
|
||||
else map.set(key, { key, stage: it.stage, pattern, items: [it] });
|
||||
}
|
||||
return [...map.values()].sort(
|
||||
(a, b) => a.stage.localeCompare(b.stage) || b.items.length - a.items.length,
|
||||
);
|
||||
});
|
||||
|
||||
async function retryIds(ids: number[]) {
|
||||
if (busy || ids.length === 0) return;
|
||||
busy = true;
|
||||
try {
|
||||
const r = await api<RetryResponse>('/queue/retry', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ids }),
|
||||
});
|
||||
addToast(
|
||||
'success',
|
||||
`재시도 ${r.retried}건 큐 재진입${r.not_retried > 0 ? ` (${r.not_retried}건 제외 — 이미 활성/처리됨)` : ''}`,
|
||||
);
|
||||
await afterAction();
|
||||
} catch {
|
||||
addToast('error', '재시도 요청 실패');
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function skipIds(ids: number[]) {
|
||||
if (busy || ids.length === 0) return;
|
||||
busy = true;
|
||||
try {
|
||||
const r = await api<SkipResponse>('/queue/skip', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ids }),
|
||||
});
|
||||
addToast('success', `건너뛰기 ${r.skipped}건 처리 (해당 단계 제외)`);
|
||||
await afterAction();
|
||||
} catch {
|
||||
addToast('error', '건너뛰기 요청 실패');
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function afterAction() {
|
||||
await Promise.all([loadFailures(), refreshQueueOverview()]);
|
||||
}
|
||||
|
||||
// ─── 머신 레인 (C2) — mainNodes 를 머신별로 그룹 + 머신 카드(state/처리율) 결합 ───
|
||||
const machineByKey = $derived(
|
||||
new Map<FlowMachine, MachineOverview>(overview.machines.map((m) => [m.key as FlowMachine, m])),
|
||||
);
|
||||
const LANE_ORDER: FlowMachine[] = ['gpu', 'macmini', 'macbook'];
|
||||
const lanes = $derived(
|
||||
LANE_ORDER.map((key) => ({
|
||||
key,
|
||||
meta: MACHINE_META[key],
|
||||
card: machineByKey.get(key) ?? null,
|
||||
nodes: mainNodes.filter((n) => n.def.machine === key),
|
||||
})),
|
||||
);
|
||||
|
||||
// 요약 오프로드 분담 — 맥미니 vs 맥북 (A-1 summarize_by_machine)
|
||||
const split = $derived(overview.summarize_by_machine);
|
||||
const splitTotal1h = $derived(Math.max(1, split.macmini.done_1h + split.macbook.done_1h));
|
||||
const macbookSharePct = $derived(Math.round((split.macbook.done_1h / splitTotal1h) * 100));
|
||||
// 맥북이 요약을 실제로 가져가는 중인가 (합류 표식 게이트)
|
||||
const offloadActive = $derived(split.macbook.done_1h > 0);
|
||||
|
||||
// ─── 백그라운드 작업 (큐 밖 스크립트 backfill) — processing_queue 사각지대 노출 ───
|
||||
const bgJobs = $derived(overview.background_jobs ?? []);
|
||||
function fmtElapsed(s: number): string {
|
||||
if (s < 60) return `${s}s`;
|
||||
if (s < 3600) return `${Math.floor(s / 60)}m`;
|
||||
return `${Math.floor(s / 3600)}h${Math.floor((s % 3600) / 60)}m`;
|
||||
}
|
||||
function bgDot(j: { state: string; stale: boolean }): string {
|
||||
if (j.state === 'running') return j.stale ? 'bg-warning' : 'bg-success';
|
||||
if (j.state === 'failed') return 'bg-error';
|
||||
return 'bg-faint';
|
||||
}
|
||||
|
||||
// ─── 지배 백로그 = 요약. 정직 ETA(유입 차감) — summarize_eta ───
|
||||
const eta = $derived(overview.summarize_eta);
|
||||
// 정직 ETA 라벨: eta_minutes null = 유입이 소화를 앞섬(소진 불가)
|
||||
const honestEtaLabel = $derived(
|
||||
eta.pending === 0
|
||||
? '비어 있음'
|
||||
: eta.eta_minutes != null
|
||||
? etaShort(eta.eta_minutes)
|
||||
: '소진 불가',
|
||||
);
|
||||
const honestEtaWarn = $derived(eta.pending > 0 && eta.eta_minutes == null);
|
||||
|
||||
/** 단계별 정직 ETA(순소화율) — 노드용. 유입>소화면 null(소진 불가) */
|
||||
function netEtaLabel(n: NodeStats): string | null {
|
||||
if (n.pending === 0) return '한가';
|
||||
const net = n.done1h - n.created1h;
|
||||
if (net > 0) return etaShort(Math.round((n.pending / net) * 60));
|
||||
if (n.created1h > n.done1h) return '유입 우세';
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── 신선도 (B-4) — '갱신 N초 전' + stale 경고 (폴링 60s) ───
|
||||
let now = $state(Date.now());
|
||||
$effect(() => {
|
||||
const id = setInterval(() => (now = Date.now()), 1000);
|
||||
return () => clearInterval(id);
|
||||
});
|
||||
const ageSec = $derived(
|
||||
$queueUpdatedAt != null ? Math.max(0, Math.round((now - $queueUpdatedAt) / 1000)) : null,
|
||||
);
|
||||
const stale = $derived(ageSec != null && ageSec > 90);
|
||||
const freshLabel = $derived(
|
||||
ageSec == null
|
||||
? '갱신 대기'
|
||||
: ageSec < 60
|
||||
? `갱신 ${ageSec}초 전`
|
||||
: `갱신 ${Math.round(ageSec / 60)}분 전`,
|
||||
);
|
||||
|
||||
// ─── 24h 번다운 (C3) — 요약 유입 vs 소화 + 맥북 합류 변곡점 마커 ───
|
||||
const burn = $derived.by(() => {
|
||||
const t = overview.trend_24h;
|
||||
if (!t || t.length === 0) return null;
|
||||
const max = Math.max(1, ...t.map((b) => Math.max(b.inflow, b.done)));
|
||||
const w = 300;
|
||||
const h = 64;
|
||||
const step = w / Math.max(1, t.length - 1);
|
||||
const y = (v: number) => (h - (v / max) * (h - 8) + 4).toFixed(1);
|
||||
const line = (sel: (b: (typeof t)[number]) => number) =>
|
||||
t.map((b, i) => `${(i * step).toFixed(1)},${y(sel(b))}`).join(' ');
|
||||
const doneLine = line((b) => b.done);
|
||||
const area = `0,${h} ${doneLine} ${w.toFixed(1)},${h}`;
|
||||
// 합류 변곡점 = done 최대 버킷 (맥북 야간 drain 합류 추정)
|
||||
let mi = 0;
|
||||
t.forEach((b, i) => {
|
||||
if (b.done > t[mi].done) mi = i;
|
||||
});
|
||||
return {
|
||||
w,
|
||||
h,
|
||||
area,
|
||||
doneLine,
|
||||
inflowLine: line((b) => b.inflow),
|
||||
markX: (mi * step).toFixed(1),
|
||||
markHour: t[mi].hour,
|
||||
markDone: t[mi].done,
|
||||
peak: max,
|
||||
};
|
||||
});
|
||||
|
||||
// 머신 상태 dot 색 클래스
|
||||
function dotClass(state: string): string {
|
||||
return state === 'active' ? 'bg-success' : state === 'deferred' ? 'bg-warning' : 'bg-faint';
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mt-5">
|
||||
<!-- 헤더: 타이틀 + 신선도 + 실패 합계 -->
|
||||
<div class="flex items-center justify-between gap-3 mb-3">
|
||||
<div class="text-[11px] font-bold text-dim uppercase tracking-wider">처리 머신</div>
|
||||
<div class="flex items-center gap-3">
|
||||
{#if totalFailed > 0}
|
||||
<button
|
||||
class="text-[11px] font-semibold text-error hover:underline cursor-pointer"
|
||||
onclick={openFailures}
|
||||
>실패 {totalFailed}건 처리</button>
|
||||
{/if}
|
||||
<span class="flex items-center gap-1.5 text-[10px] tabular-nums {stale ? 'text-warning' : 'text-faint'}" title="60초 폴링">
|
||||
<span class="w-1.5 h-1.5 rounded-full {stale ? 'bg-warning' : 'bg-success'}"></span>
|
||||
{freshLabel}{#if stale} · 갱신 지연{/if}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 지배 백로그 스트립 (요약) + 정직 ETA -->
|
||||
<div class="flex items-center flex-wrap gap-x-3 gap-y-1 bg-surface border border-warning/50 rounded-card px-3.5 py-2 mb-3">
|
||||
<span class="text-[9px] font-bold text-warning border border-warning/60 rounded-full px-2 py-px">지배 백로그</span>
|
||||
<span class="text-xs font-bold text-text">요약</span>
|
||||
<span class="text-[11px] text-dim tabular-nums">대기 <b class="text-text">{eta.pending.toLocaleString()}</b> · 순소화 <b class="text-text">{formatRate(eta.done_rate_1h)}</b>/h · 유입 {formatRate(eta.inflow_rate_1h)}/h</span>
|
||||
<span class="ml-auto flex items-center gap-1.5 border rounded-full px-2.5 py-0.5 {honestEtaWarn ? 'border-warning text-warning' : 'border-accent text-accent'}">
|
||||
<span class="text-[10px] font-semibold">정직 ETA</span>
|
||||
<span class="text-xs font-bold tabular-nums">{honestEtaLabel}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 머신 레인 (누가 일하나 + 요약 오프로드) -->
|
||||
<div class="grid gap-2 mb-3">
|
||||
{#each lanes as lane (lane.key)}
|
||||
<div class="bg-surface border border-default rounded-card px-3.5 py-2.5">
|
||||
<div class="flex items-center gap-2 flex-wrap mb-2">
|
||||
<span class="w-2 h-2 rounded-full shrink-0 {dotClass(lane.card?.state ?? 'idle')}"></span>
|
||||
<span class="text-[9px] font-bold rounded px-1.5 py-px mtag-{lane.key}">{lane.meta.label}</span>
|
||||
<span class="text-[10px] text-faint font-mono">{lane.meta.model}</span>
|
||||
<span class="text-[11px] text-dim tabular-nums ml-1">{formatRate(lane.card?.done_1h ?? 0)}/h</span>
|
||||
{#if lane.key === 'macbook' && (lane.card?.deferred_pending ?? 0) > 0}
|
||||
<span class="text-[10px] font-semibold text-warning tabular-nums">보류 {lane.card?.deferred_pending}</span>
|
||||
{/if}
|
||||
{#if lane.card?.state === 'deferred'}
|
||||
<span class="text-[9px] text-warning">잠듦 — 요약은 맥미니로 복귀</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-stretch gap-1.5 flex-wrap">
|
||||
{#each lane.nodes as n (n.def.key)}
|
||||
{@const idle = n.pending + n.processing + n.doneToday + n.failed === 0}
|
||||
<button
|
||||
class="relative text-left rounded-lg border px-2.5 py-1.5 transition-colors cursor-pointer hover:bg-surface-hover min-w-[96px]
|
||||
{idle ? 'border-dashed border-default opacity-55' : n.inflowDominant ? 'border-warning' : 'border-default'}
|
||||
{selected === n.def.key ? 'node-sel' : ''}"
|
||||
onclick={() => toggleNode(n.def.key)}
|
||||
title="{n.def.label} — 클릭하면 상세"
|
||||
>
|
||||
{#if n.failed > 0}
|
||||
<span class="absolute -top-1.5 -right-1 text-[9px] font-extrabold bg-error text-white rounded-full px-1.5">{n.failed}</span>
|
||||
{/if}
|
||||
<div class="flex items-center gap-1 text-[11px] font-semibold text-text whitespace-nowrap">
|
||||
{n.def.label}
|
||||
{#if n.processing > 0}<span class="inline-block w-1.5 h-1.5 rounded-full bg-accent animate-pulse"></span>{/if}
|
||||
</div>
|
||||
<div class="text-sm font-extrabold tabular-nums leading-tight text-text">{n.pending.toLocaleString()}<span class="text-[9px] text-faint font-normal ml-0.5">대기</span></div>
|
||||
<div class="text-[9px] text-dim tabular-nums whitespace-nowrap">{formatRate(n.done1h)}/h · 오늘 {n.doneToday.toLocaleString()}</div>
|
||||
{#if n.def.key === 'summarize'}
|
||||
<div class="mt-1 h-1 w-full rounded-full overflow-hidden flex" title="맥미니 {split.macmini.done_1h}/h · 맥북 {split.macbook.done_1h}/h">
|
||||
<span class="block h-full mtag-macmini-bar" style="width:{100 - macbookSharePct}%"></span>
|
||||
<span class="block h-full mtag-macbook-bar" style="width:{macbookSharePct}%"></span>
|
||||
</div>
|
||||
<div class="text-[9px] text-faint tabular-nums whitespace-nowrap mt-0.5">맥미니 {split.macmini.done_1h} · 맥북 {split.macbook.done_1h}/h</div>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
{#if lane.key === 'macbook' && offloadActive}
|
||||
<button
|
||||
class="text-left rounded-lg border border-dashed border-warning/50 px-2.5 py-1.5 cursor-pointer hover:bg-surface-hover min-w-[96px]"
|
||||
onclick={() => toggleNode('summarize')}
|
||||
title="맥북이 요약을 맥미니에서 가져와 처리 중"
|
||||
>
|
||||
<div class="flex items-center gap-1 text-[11px] font-semibold text-text whitespace-nowrap">요약 합류 <span class="text-[8px] font-bold text-warning">OFFLOAD</span></div>
|
||||
<div class="text-sm font-extrabold tabular-nums leading-tight text-text">{split.macbook.done_1h}<span class="text-[9px] text-faint font-normal ml-0.5">/h</span></div>
|
||||
<div class="text-[9px] text-dim tabular-nums whitespace-nowrap">요약의 {macbookSharePct}% 담당</div>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- 번다운 / ETA 패널 -->
|
||||
{#if burn}
|
||||
<div class="bg-surface border border-default rounded-card px-3.5 py-3 mb-1">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span class="text-[11px] font-bold text-text">요약 백로그 24시간</span>
|
||||
<span class="text-[9px] text-faint">유입(회색) vs 소화(녹색)</span>
|
||||
{#if offloadActive}<span class="text-[9px] text-warning ml-auto">맥북 합류 {burn.markHour} — 소화 급증</span>{/if}
|
||||
</div>
|
||||
<svg viewBox="0 0 {burn.w} {burn.h}" class="block w-full" style="height:64px" preserveAspectRatio="none" role="img" aria-label="요약 백로그 24시간 번다운">
|
||||
<polygon points={burn.area} fill="currentColor" class="text-success" opacity="0.12" />
|
||||
<polyline points={burn.inflowLine} fill="none" stroke="currentColor" stroke-width="1.2" class="text-faint" />
|
||||
<polyline points={burn.doneLine} fill="none" stroke="currentColor" stroke-width="1.6" class="text-success" />
|
||||
{#if offloadActive}
|
||||
<line x1={burn.markX} y1="0" x2={burn.markX} y2={burn.h} stroke="currentColor" stroke-width="1" stroke-dasharray="2 2" class="text-warning" opacity="0.7" />
|
||||
{/if}
|
||||
</svg>
|
||||
<div class="flex flex-wrap gap-x-4 gap-y-1 mt-2 pt-2 border-t border-default text-[10px] text-dim tabular-nums">
|
||||
{#each mainNodes.filter((n) => n.pending > 0 && n.def.key !== 'summarize') as n (n.def.key)}
|
||||
<span class="whitespace-nowrap">{n.def.label} 대기 <b class="text-text">{n.pending.toLocaleString()}</b>{#if netEtaLabel(n)} · <span class="text-accent font-semibold">{netEtaLabel(n)}</span>{/if}</span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- 보조 라인 -->
|
||||
<p class="text-[10px] text-faint mt-1.5 tabular-nums">
|
||||
{#each auxActive as n, i (n.def.key)}
|
||||
{i > 0 ? ' · ' : '보조: '}{n.def.label}({n.def.engine}) 대기 {n.pending.toLocaleString()} · {formatRate(n.done1h)}/h{n.failed > 0 ? ` · 실패 ${n.failed}` : ''}
|
||||
{/each}
|
||||
{#if auxIdle.length > 0}
|
||||
{auxActive.length > 0 ? ' — ' : ''}한가: {auxIdle.map((n) => n.def.label).join(' · ')}
|
||||
{/if}
|
||||
— 뉴스 등 일부 소스는 분류/추출을 건너뜀 (흐름 그림은 대표 경로)
|
||||
</p>
|
||||
|
||||
<!-- 상세 패널 (노드 클릭) -->
|
||||
{#if selectedNode}
|
||||
<div class="border rounded-card mt-3 overflow-hidden bg-surface detail-frame">
|
||||
<div class="flex items-center gap-2.5 px-4 py-2.5 text-xs font-bold detail-head">
|
||||
{selectedNode.def.label} — {selectedNode.def.engine}
|
||||
<span class="text-[10px] font-mono font-medium text-dim bg-surface border border-default rounded px-1.5">{selectedNode.def.sub} · {MACHINE_META[selectedNode.def.machine].label}</span>
|
||||
<button class="ml-auto text-[11px] text-dim font-normal cursor-pointer hover:text-text" onclick={() => (selected = null)}>닫기</button>
|
||||
</div>
|
||||
<div class="px-4 pb-3.5">
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-2.5 my-2.5">
|
||||
<div class="bg-bg border border-default rounded-card px-3 py-2">
|
||||
<div class="text-[9px] text-faint uppercase tracking-wide">대기</div>
|
||||
<div class="text-lg font-extrabold tabular-nums text-text">{selectedNode.pending.toLocaleString()}</div>
|
||||
</div>
|
||||
<div class="bg-bg border border-default rounded-card px-3 py-2">
|
||||
<div class="text-[9px] text-faint uppercase tracking-wide">처리율 (1h)</div>
|
||||
<div class="text-lg font-extrabold tabular-nums text-text">{formatRate(selectedNode.done1h)}<span class="text-[11px] text-dim font-semibold">/h</span></div>
|
||||
</div>
|
||||
<div class="bg-bg border border-default rounded-card px-3 py-2">
|
||||
<div class="text-[9px] text-faint uppercase tracking-wide">오늘 완료</div>
|
||||
<div class="text-lg font-extrabold tabular-nums text-text">{selectedNode.doneToday.toLocaleString()}</div>
|
||||
</div>
|
||||
<div class="bg-bg border border-default rounded-card px-3 py-2">
|
||||
<div class="text-[9px] text-faint uppercase tracking-wide">소진 예상</div>
|
||||
<div class="text-lg font-extrabold tabular-nums {selectedNode.inflowDominant ? 'text-warning' : 'text-accent'}">
|
||||
{#if selectedNode.inflowDominant}유입 우세{:else if selectedNode.etaMinutes != null}{etaShort(selectedNode.etaMinutes)}{:else if selectedNode.pending === 0}한가{:else}—{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{#if selectedNode.perStage.length > 1}
|
||||
{#each selectedNode.perStage as row (row.stage)}
|
||||
<div class="flex items-center gap-2.5 py-1.5 border-t border-default text-xs">
|
||||
<span class="font-semibold text-text min-w-[72px]">{flowStageLabel(row.stage)}</span>
|
||||
<span class="ml-auto text-dim tabular-nums">
|
||||
대기 <strong class="text-text">{row.pending.toLocaleString()}</strong>
|
||||
· {formatRate(row.done_1h)}/h · 오늘 {row.done_today.toLocaleString()}
|
||||
{#if row.failed > 0}· <span class="text-error font-semibold">실패 {row.failed}</span>{/if}
|
||||
</span>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
<div class="text-[11px] text-dim border-t border-dashed border-default mt-2 pt-2 tabular-nums">
|
||||
{#if selectedNode.oldestAgeSec != null && selectedNode.oldestAgeSec > 600}
|
||||
가장 오래 기다린 항목 {formatAgeSec(selectedNode.oldestAgeSec)}
|
||||
{/if}
|
||||
{#each nodeCurrent(selectedNode.def) as c, i (c.document_id + c.stage)}
|
||||
{i === 0 && !(selectedNode.oldestAgeSec != null && selectedNode.oldestAgeSec > 600) ? '' : ' · '}지금: {c.title} ({flowStageLabel(c.stage)})
|
||||
{/each}
|
||||
{#if selectedNode.failed > 0}
|
||||
· <button class="text-error font-semibold cursor-pointer hover:underline" onclick={openFailures}>실패 {selectedNode.failed}건 처리</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- 백그라운드 작업 (큐 밖 스크립트 backfill 등 — processing_queue 가 못 보는 사각지대) -->
|
||||
{#if bgJobs.length > 0}
|
||||
<div class="mt-3">
|
||||
<div class="text-[11px] font-bold text-dim uppercase tracking-wider mb-2">백그라운드 작업</div>
|
||||
<div class="grid gap-2">
|
||||
{#each bgJobs as j (j.id)}
|
||||
<div class="bg-surface border rounded-card px-3.5 py-2.5 {j.stale ? 'border-warning' : j.state === 'failed' ? 'border-error' : 'border-default'}">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<span class="w-2 h-2 rounded-full shrink-0 {bgDot(j)}"></span>
|
||||
<span class="text-[9px] font-bold rounded px-1.5 py-px bg-default text-dim font-mono">{j.kind}</span>
|
||||
<span class="text-xs font-semibold text-text truncate">{j.label ?? '작업'}</span>
|
||||
<span class="text-[11px] text-dim tabular-nums ml-auto">
|
||||
{#if j.total}{j.processed.toLocaleString()}/{j.total.toLocaleString()}{:else}{j.processed.toLocaleString()}건{/if} · {fmtElapsed(j.elapsed_sec)}
|
||||
</span>
|
||||
</div>
|
||||
{#if j.stale}
|
||||
<div class="text-[10px] text-warning mt-1.5">heartbeat 끊김 — 프로세스 중단 추정 (재개 필요할 수 있음)</div>
|
||||
{:else if j.state === 'failed'}
|
||||
<div class="text-[10px] text-error mt-1.5 truncate">실패{#if j.error} · {j.error}{/if}</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- 실패 처리 드로어 -->
|
||||
{#if failOpen}
|
||||
<div class="border border-error/40 rounded-card mt-3 overflow-hidden bg-surface">
|
||||
<div class="flex items-center gap-2.5 px-4 py-2.5 bg-error/5 text-xs font-bold text-text">
|
||||
실패 처리
|
||||
<span class="text-[10px] font-semibold text-error">영구 실패 {failItems.length}건 — 자동 재시도 3회 소진, 수동 조치 대기</span>
|
||||
<button class="ml-auto text-[11px] text-dim font-normal cursor-pointer hover:text-text" onclick={() => (failOpen = false)}>닫기</button>
|
||||
</div>
|
||||
{#if failLoading}
|
||||
<p class="text-xs text-dim text-center py-4">불러오는 중…</p>
|
||||
{:else if failItems.length === 0}
|
||||
<p class="text-xs text-dim text-center py-4">영구 실패 항목 없음</p>
|
||||
{:else}
|
||||
{#each failGroups as g (g.key)}
|
||||
<div class="px-4 py-2.5 border-t border-default">
|
||||
<div class="flex items-center gap-2 flex-wrap text-xs font-bold text-text mb-1">
|
||||
{flowStageLabel(g.stage)} {g.items.length}건
|
||||
<span class="text-[10px] font-mono font-medium text-error bg-error/10 rounded px-1.5 py-px">{g.pattern}{g.items[0]?.error_message && g.items[0].error_message.length > 36 ? '…' : ''}</span>
|
||||
</div>
|
||||
{#each expanded[g.key] ? g.items : g.items.slice(0, 4) as it (it.id)}
|
||||
<div class="flex items-center gap-2.5 py-1 border-t border-dashed border-default/60 text-xs">
|
||||
<span class="flex-1 min-w-0 truncate text-text" title={it.title}>{it.title}</span>
|
||||
<span class="text-[10px] font-mono text-faint shrink-0 tabular-nums">시도 {it.attempts}/{it.max_attempts}</span>
|
||||
<span class="text-[10px] font-mono text-error shrink-0 max-w-[260px] truncate" title={it.error_message ?? ''}>{it.error_message ?? ''}</span>
|
||||
<button class="text-[10px] font-bold border border-accent text-accent rounded px-2 py-0.5 shrink-0 cursor-pointer hover:bg-accent/10 disabled:opacity-40" disabled={busy} onclick={() => retryIds([it.id])}>재시도</button>
|
||||
<button class="text-[10px] font-bold border border-default text-faint rounded px-2 py-0.5 shrink-0 cursor-pointer hover:bg-surface-hover disabled:opacity-40" disabled={busy} onclick={() => skipIds([it.id])}>건너뛰기</button>
|
||||
</div>
|
||||
{/each}
|
||||
{#if g.items.length > 4 && !expanded[g.key]}
|
||||
<button class="text-[10px] text-dim cursor-pointer hover:text-text mt-1" onclick={() => (expanded = { ...expanded, [g.key]: true })}>… 외 {g.items.length - 4}건 펼치기</button>
|
||||
{/if}
|
||||
{#if g.items.length > 1}
|
||||
<div class="flex gap-2 mt-1.5">
|
||||
<button class="text-[10px] font-bold border border-accent text-accent rounded px-2.5 py-0.5 cursor-pointer hover:bg-accent/10 disabled:opacity-40" disabled={busy} onclick={() => retryIds(g.items.map((x) => x.id))}>그룹 전체 재시도 ({g.items.length})</button>
|
||||
<button class="text-[10px] font-bold border border-default text-faint rounded px-2.5 py-0.5 cursor-pointer hover:bg-surface-hover disabled:opacity-40" disabled={busy} onclick={() => skipIds(g.items.map((x) => x.id))}>그룹 전체 건너뛰기</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
<p class="text-[10px] text-faint px-4 py-2 border-t border-default">
|
||||
재시도 = 시도 횟수 리셋 후 큐 재진입 (자동 재시도 3회 새로 부여) · 건너뛰기 = 이 단계 완료 처리(후속 단계 연쇄 없음, 감사 마킹) · 같은 오류가 반복되는 항목(빈 텍스트 등)은 건너뛰기 권장
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* 머신 색 — 디자인 토큰 외 3색 (gpu 청/macmini 보라/macbook 황) — 이 컴포넌트 한정 */
|
||||
.mtag-gpu { background: #e7eef6; color: #3b6ea5; }
|
||||
.mtag-macmini { background: #efe9f7; color: #8a5fbf; }
|
||||
.mtag-macbook { background: #f7eedd; color: #b07a10; }
|
||||
/* 요약 오프로드 분담 막대 채움 (맥미니 보라 / 맥북 황) */
|
||||
.mtag-macmini-bar { background: #8a5fbf; }
|
||||
.mtag-macbook-bar { background: #b07a10; }
|
||||
.node-sel { outline: 2px solid #3b6ea5; outline-offset: 1px; }
|
||||
.detail-frame { border-color: #3b6ea5; }
|
||||
.detail-head { background: #e7eef6; }
|
||||
</style>
|
||||
@@ -43,14 +43,17 @@
|
||||
{@const open = selectedId === s.chunk_id}
|
||||
{@const active = activeKey != null && activeKey === s.chunk_id}
|
||||
{@const typeLabel = sectionTypeLabel(s.section_type)}
|
||||
{@const depth = Math.max(0, (s.level ?? 1) - 1)}
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { toggle(item); onJump?.(s.chunk_id); }}
|
||||
aria-expanded={open}
|
||||
aria-current={active ? 'true' : undefined}
|
||||
style="padding-left:{8 + depth * 13}px"
|
||||
class={[
|
||||
'w-full text-left px-2 py-1.5 rounded-md text-xs flex items-start gap-1.5 transition-colors border-l-2',
|
||||
'w-full text-left pr-2 py-1.5 rounded-md text-xs flex items-start gap-1.5 transition-colors border-l-2',
|
||||
depth > 0 ? 'text-[11px]' : '',
|
||||
open ? 'bg-surface-active text-text border-accent' : active ? 'bg-surface text-accent-hover border-accent' : 'text-dim hover:bg-surface hover:text-text border-transparent',
|
||||
].join(' ')}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<!--
|
||||
EidEvidenceCard — 이드 채팅 deep(검색) 답변의 근거 카드 (ds-eid-ask-absorb P1).
|
||||
|
||||
ReactResult.sources = {id, doc_id, title, score} (citation 번호 n 없음 — /ask 의 Citation 과
|
||||
다름) → 순서 기반 번호([1],[2]...). 1단계 카드 = 제목·출처·점수 (스니펫은 react_loop
|
||||
_result_payload items_src 에 없음 — 2단계 후보). 접이식 <details> 로 채팅 흐름 보존.
|
||||
디자인 토큰만 (CLAUDE.md lint:tokens).
|
||||
-->
|
||||
<script lang="ts">
|
||||
type EidSource = { id?: number; doc_id?: number; title?: string; score?: number };
|
||||
let { sources, partial = false }: { sources: EidSource[]; partial?: boolean } = $props();
|
||||
</script>
|
||||
|
||||
{#if sources.length}
|
||||
<details class="mt-2 rounded-lg border border-default bg-surface text-xs max-w-[85%] sm:max-w-[75%]">
|
||||
<summary class="cursor-pointer px-3 py-2 text-dim hover:text-text select-none font-semibold">
|
||||
근거 {sources.length}개{partial ? ' · 부분 답변 (확정 근거 부족)' : ''}
|
||||
</summary>
|
||||
<ul class="px-3 pb-2.5 flex flex-col gap-1.5">
|
||||
{#each sources as src, i (src.id ?? i)}
|
||||
<li class="flex items-start gap-2">
|
||||
<span class="text-accent font-bold shrink-0">[{i + 1}]</span>
|
||||
<span class="flex-1 min-w-0 text-text break-words">{src.title || `문서 ${src.doc_id ?? '?'}`}</span>
|
||||
{#if typeof src.score === 'number'}
|
||||
<span class="text-faint shrink-0 tabular-nums">{src.score.toFixed(2)}</span>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</details>
|
||||
{/if}
|
||||