feat(search): /ask backend dispatcher (qwen-macbook opt-in, no silent fallback)

PR-MacBook-RAG-Backend-1 — /api/search/ask 의 명시 backend 선택 진입점.

핵심 invariant (정정 4):
- backend 미지정 = Gemma Mac mini default, 응답 contract 변동 0
- backend="qwen-macbook" 명시 opt-in 만 MacBook M5 Max mlx-vlm.server 호출
- MacBook unavailable 시 HTTP 503 + error_reason=macbook_unavailable
- 자동 fallback 절대 금지 — 실패 path 에서 Gemma backend.generate() 호출 0

backend dispatcher (services/llm/):
- BackendBase / GemmaMacMiniBackend / QwenMacBookBackend / BackendUnavailable
- Qwen backend 는 Mac mini llm_gate 점유 X, 별 Semaphore(1) — llm_gate
  docstring 의 single-inference 영구 룰은 같은 endpoint 한정으로 scope 명시
- httpx Connect/Read/Pool/Timeout/5xx → BackendUnavailable, 4xx 전파

synthesis_service.py:
- backend 인자 추가, status="backend_unavailable" 신규
- cache key 에 backend_name 포함 (qwen ↔ gemma 캐시 충돌 차단)

config:
- search.ask.backend.{macmini_url, macbook_url, macbook_model,
  timeout_connect_s=1, timeout_read_s=30}
- MacBook endpoint = http://100.118.112.84:8810 (M5 Max Tailscale bind)

tests (14 신규):
- tests/services/test_backend_dispatcher.py (9): dispatcher 정합성 + Qwen
  generate path (mock 200 / dead port / 5xx / 4xx) + cache identity
- tests/api/test_search_ask_macbook_503.py (5): 정정 4 핵심 invariant.
  backend=qwen-macbook 비가용 시 gemma.generate.assert_not_called()

기존 ask 회귀 0 (test_ask_eval_auth 9건 등 85건 모두 PASS).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
hyungi
2026-05-22 12:38:48 +00:00
parent 224843ba25
commit a7b8f15870
9 changed files with 910 additions and 42 deletions
+34
View File
@@ -35,6 +35,29 @@ class DeepSummaryBacklogConfig(BaseModel):
window_minutes: int = 30
class SearchAskBackendConfig(BaseModel):
"""PR-MacBook-RAG-Backend-1: /api/search/ask backend dispatcher.
backend 미지정 = Gemma Mac mini (settings.ai.primary 경로 그대로).
backend="qwen-macbook" 명시 opt-in = MacBook M5 Max mlx-vlm.server.
MacBook unavailable 시 503 + error_reason=macbook_unavailable (자동 fallback 없음).
"""
macmini_url: str = "http://100.76.254.116:8801"
macbook_url: str = "http://100.118.112.84:8810"
macbook_model: str = "mlx-community/Qwen3.6-27B-8bit"
timeout_connect_s: int = 1
timeout_read_s: int = 30
class SearchAskConfig(BaseModel):
backend: SearchAskBackendConfig = SearchAskBackendConfig()
class SearchConfig(BaseModel):
ask: SearchAskConfig = SearchAskConfig()
class AIConfig(BaseModel):
gateway_endpoint: str
# B-0: 3-tier routing. triage/primary = Mac mini 26B MLX (PR #20 endpoint 통합). fallback = Claude Sonnet 4 API.
@@ -62,6 +85,9 @@ class Settings(BaseModel):
# AI
ai: AIConfig | None = None
# PR-MacBook-RAG-Backend-1: /api/search/ask backend dispatcher
search: SearchConfig = SearchConfig()
# NAS
nas_mount_path: str = "/documents"
nas_pkm_root: str = "/documents/PKM"
@@ -171,6 +197,13 @@ def load_settings() -> Settings:
nas_mount = raw["nas"].get("mount_path", nas_mount)
nas_pkm = raw["nas"].get("pkm_root", nas_pkm)
search_cfg = SearchConfig()
if config_path.exists() and raw and "search" in raw:
sb = (raw.get("search") or {}).get("ask", {}).get("backend", {}) or {}
search_cfg = SearchConfig(
ask=SearchAskConfig(backend=SearchAskBackendConfig(**sb))
)
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 = (
@@ -182,6 +215,7 @@ def load_settings() -> Settings:
return Settings(
database_url=database_url,
ai=ai_config,
search=search_cfg,
nas_mount_path=nas_mount,
nas_pkm_root=nas_pkm,
jwt_secret=jwt_secret,