diff --git a/app/ai/client.py b/app/ai/client.py index 6864e13..9ec3159 100644 --- a/app/ai/client.py +++ b/app/ai/client.py @@ -52,20 +52,56 @@ CLASSIFY_PROMPT = _load_prompt("classify.txt") if (PROMPTS_DIR / "classify.txt") class AIClient: - """AI Gateway를 통한 통합 클라이언트. 기본값은 항상 Qwen3.5.""" + """AI 모델 통합 클라이언트. + + B-0 3-tier routing: + - call_triage(): 4B Ollama, 상시 호출 (llm_gate 외부 — 병렬 OK) + - call_primary(): 26B MLX, 에스컬레이션 전용 (llm_gate Semaphore(1) 는 **caller 책임**) + - call_fallback(): triage/primary 실패 시 최후 방어선 (현재 4B 동일) + + Legacy: classify() / summarize() 는 기존 호출부(tests/eval runner)를 위해 남겨둠. + 신규 worker 경로는 전부 call_triage / call_primary 사용. + """ def __init__(self): self.ai = settings.ai self._http = httpx.AsyncClient(timeout=120) + # ─── 3-tier routing (B-0) ─────────────────────────────────────────────── + + async def call_triage(self, prompt: str) -> str: + """4B Ollama 직접 호출. llm_gate 밖 (Ollama 는 concurrent OK). + + timeout 은 config.yaml ai.models.triage.timeout (기본 30s). + 실패 시 caller 가 에스컬레이션 또는 fallback 판단. + """ + return await self._request(self.ai.triage, prompt) + + async def call_primary(self, prompt: str) -> str: + """26B MLX 호출. 에스컬레이션 전용. + + **caller 가 반드시 `async with get_mlx_gate():` 블록 안에서 호출해야 한다.** + Semaphore(1) 로 동시 호출이 1건으로 제한되어 있고, gate 는 primary 전용. + """ + return await self._request(self.ai.primary, prompt) + + async def call_fallback(self, prompt: str) -> str: + """triage/primary 실패 시 최후 방어선. 현재는 triage 와 동일 엔드포인트.""" + return await self._request(self.ai.fallback, prompt) + + # ─── Legacy API (classify_worker 교체 시 제거 예정) ─────────────────── + async def classify(self, text: str) -> dict: - """문서 분류 — 항상 primary(Qwen3.5) 사용""" + """[DEPRECATED] 기존 classify_worker 전용. B-1 에서 summary_triage 로 대체. + + 호출부 정리 전 존속. 신규 코드는 call_triage + prompt_render 를 쓸 것. + """ prompt = CLASSIFY_PROMPT.replace("{document_text}", text) response = await self._call_chat(self.ai.primary, prompt) return response async def summarize(self, text: str, force_premium: bool = False) -> str: - """문서 요약 — 기본 primary, force_premium=True 시만 Claude""" + """[DEPRECATED] 기존 호출부용. B-1 에서 summary_triage 가 tldr 대체.""" 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}") diff --git a/app/core/config.py b/app/core/config.py index 43260b4..45f90d0 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -23,20 +23,36 @@ class AIModelConfig(BaseModel): timeout: int = 60 daily_budget_usd: float | None = None require_explicit_trigger: bool = False + # B-0: 4B/26B 에 부여한 실사용 컨텍스트 상한 (char). triage=120k, primary=260k. + # classify_worker 가 에스컬레이션 판정 시 참고. 0/None 이면 상한 무시. + context_char_limit: int | None = None + + +class DeepSummaryBacklogConfig(BaseModel): + """B-1 R2 — deep_summary enqueue 폭발 억제 임계치.""" + ratio_threshold: float = 0.3 # 지난 window 의 deep_n/classify_n + pending_threshold: int = 5 # deep_summary pending+processing + window_minutes: int = 30 class AIConfig(BaseModel): gateway_endpoint: str + # B-0: 3-tier routing. triage(4B) 상시, primary(26B) escalation-only, fallback(4B) 최후. + triage: AIModelConfig primary: AIModelConfig fallback: AIModelConfig premium: AIModelConfig embedding: AIModelConfig - vision: AIModelConfig rerank: AIModelConfig # Phase 3.5a: exaone classifier (optional — 없으면 score-only gate) classifier: AIModelConfig | None = None # Phase 3.5b: exaone verifier (optional — 없으면 grounding-only) verifier: AIModelConfig | None = None + # Legacy: vision 슬롯 (현재 사용처 0 — Document Server 는 OCR/STT 별도 서비스). + # 제거 진행 중이므로 optional 로 관대한 로딩 유지. + vision: AIModelConfig | None = None + # B-1 R2: backlog guard 임계치 + deep_summary_backlog: DeepSummaryBacklogConfig = DeepSummaryBacklogConfig() class Settings(BaseModel): @@ -106,23 +122,29 @@ def load_settings() -> Settings: if "ai" in raw: ai_raw = raw["ai"] + models = ai_raw.get("models", {}) + # B-0: triage 는 config.yaml 에 없을 수도 있는 신규 슬롯. 구버전 호환을 위해 + # 없으면 fallback 를 triage 로 대체 (동일 모델 재사용). + triage_raw = models.get("triage") or models.get("fallback") + if triage_raw is None: + raise ValueError("config.yaml: ai.models.triage (or fallback) required") ai_config = AIConfig( gateway_endpoint=ai_raw.get("gateway", {}).get("endpoint", ""), - primary=AIModelConfig(**ai_raw["models"]["primary"]), - fallback=AIModelConfig(**ai_raw["models"]["fallback"]), - premium=AIModelConfig(**ai_raw["models"]["premium"]), - embedding=AIModelConfig(**ai_raw["models"]["embedding"]), - vision=AIModelConfig(**ai_raw["models"]["vision"]), - rerank=AIModelConfig(**ai_raw["models"]["rerank"]), + triage=AIModelConfig(**triage_raw), + primary=AIModelConfig(**models["primary"]), + fallback=AIModelConfig(**models["fallback"]), + premium=AIModelConfig(**models["premium"]), + embedding=AIModelConfig(**models["embedding"]), + rerank=AIModelConfig(**models["rerank"]), + vision=(AIModelConfig(**models["vision"]) if "vision" in models else None), classifier=( - AIModelConfig(**ai_raw["models"]["classifier"]) - if "classifier" in ai_raw.get("models", {}) - else None + AIModelConfig(**models["classifier"]) if "classifier" in models else None ), verifier=( - AIModelConfig(**ai_raw["models"]["verifier"]) - if "verifier" in ai_raw.get("models", {}) - else None + AIModelConfig(**models["verifier"]) if "verifier" in models else None + ), + deep_summary_backlog=DeepSummaryBacklogConfig( + **ai_raw.get("deep_summary_backlog", {}) ), ) diff --git a/config.yaml b/config.yaml index 66b7813..e56e55e 100644 --- a/config.yaml +++ b/config.yaml @@ -5,15 +5,28 @@ ai: endpoint: "http://ai-gateway:8080" models: + # ─── 2-tier routing (PR-B) ─── + # triage: 상시 분류·요약·근거 선별. GPU Ollama gemma-4b (Q8_0, ~11.6GB). + # concurrent OK — llm_gate Semaphore 경유 불필요. + triage: + endpoint: "http://ollama:11434/v1/chat/completions" + model: "gemma4:e4b-it-q8_0" + max_tokens: 4096 + timeout: 30 + context_char_limit: 120000 + + # primary: 에스컬레이션 전용. 26B MLX (맥미니 Semaphore(1) 보호 대상). primary: endpoint: "http://100.76.254.116:8801/v1/chat/completions" model: "mlx-community/gemma-4-26b-a4b-it-8bit" - max_tokens: 4096 - timeout: 60 + max_tokens: 8192 + timeout: 180 + context_char_limit: 260000 + # fallback: primary 장애 시 최후 방어선. triage 와 동일 모델 — gemma-4b 로 퇴행 허용. fallback: endpoint: "http://ollama:11434/v1/chat/completions" - model: "qwen3.5:9b-q8_0" + model: "gemma4:e4b-it-q8_0" max_tokens: 4096 timeout: 120 @@ -28,19 +41,21 @@ ai: endpoint: "http://ollama:11434/api/embeddings" model: "bge-m3" - vision: - endpoint: "http://ollama:11434/api/generate" - model: "Qwen2.5-VL-7B" - rerank: endpoint: "http://ollama:11434/api/rerank" model: "bge-reranker-v2-m3" - # Phase 3.5a: exaone answerability classifier (GPU Ollama, concurrent OK) - classifier: - endpoint: "http://ollama:11434/v1/chat/completions" - model: "exaone3.5:7.8b-instruct-q8_0" - max_tokens: 512 - timeout: 10 + # 제거: classifier (Phase 3.5a exaone 흔적 — classifier_service 가 hasattr 로 optional + # 처리하므로 제거 안전) / vision (미사용) + + # ─── deep_summary enqueue 폭발 억제 (B-1 R2) ─── + # 초기 튜닝 전 deep_summary 큐에 soft escalate 가 과발생하면 MLX 26B 가 포화된다. + # 아래 임계치 중 하나라도 초과하면 soft escalate (recommend_deep_summary 만) 를 + # suppress. hard escalate (long_context / triage_json_invalid / low_confidence)는 + # 절대 suppress 되지 않는다. + deep_summary_backlog: + ratio_threshold: 0.3 # 지난 window 의 deep_n/classify_n + pending_threshold: 5 # deep_summary stage 의 pending+processing + window_minutes: 30 nas: mount_path: "/documents"