a7b8f15870
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>
292 lines
9.2 KiB
Python
292 lines
9.2 KiB
Python
"""PR-MacBook-RAG-Backend-1 정정 4 핵심 테스트.
|
|
|
|
검증 invariant (synthesize 함수 레벨 — /ask wrapper 의 503 매핑은 search.py 의
|
|
status="backend_unavailable" 분기로 1:1 deterministic):
|
|
|
|
1. backend="qwen-macbook" + MacBook URL 죽은 포트
|
|
→ synthesize() 가 SynthesisResult(status="backend_unavailable", ...) 반환
|
|
→ Gemma backend 의 generate() 가 **단 1번도 호출되지 않음** (자동 fallback 부재)
|
|
|
|
2. backend 미지정 (None)
|
|
→ Gemma backend.generate() 호출, Qwen backend.generate() 호출 0
|
|
→ 기존 호출자 (Hermes docsrv_ask / voice-memo-bot) 회귀 0
|
|
|
|
3. backend="qwen-macbook" + MacBook 정상 응답
|
|
→ status="completed" + answer 채워짐, Gemma backend 호출 0
|
|
|
|
테스트 전략:
|
|
- synthesize() 가 호출하는 backend dispatcher (services.llm.get_backend) 를
|
|
monkeypatch 해서 mock backend 주입.
|
|
- Gemma backend 의 generate AsyncMock 호출 횟수를 추적.
|
|
- 정정 4 의 핵심 가드: `gemma_backend.generate.assert_not_called()`
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import os
|
|
import sys
|
|
from dataclasses import dataclass
|
|
from unittest.mock import AsyncMock
|
|
|
|
import pytest
|
|
|
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "app"))
|
|
|
|
|
|
# ── 가짜 evidence (synthesize 의 no_evidence 분기 회피용 최소 객체) ─────────
|
|
|
|
|
|
@dataclass
|
|
class _FakeEvidence:
|
|
n: int = 1
|
|
doc_id: int = 100
|
|
chunk_id: int | None = 200
|
|
title: str | None = "fake doc"
|
|
span_text: str = "이것은 짧은 근거 텍스트입니다."
|
|
source: str = "llm"
|
|
|
|
|
|
def _make_evidence():
|
|
return [_FakeEvidence()]
|
|
|
|
|
|
# ── backend mock ───────────────────────────────────────────────────────────
|
|
|
|
|
|
def _gemma_mock(content: str = "GEMMA_SHOULD_NEVER_BE_CALLED"):
|
|
m = AsyncMock()
|
|
m.name = "gemma-macmini"
|
|
m.generate = AsyncMock(return_value=content)
|
|
return m
|
|
|
|
|
|
def _qwen_mock_success(content: str):
|
|
m = AsyncMock()
|
|
m.name = "qwen-macbook"
|
|
m.generate = AsyncMock(return_value=content)
|
|
return m
|
|
|
|
|
|
def _qwen_mock_unavailable():
|
|
from services.llm import BackendUnavailable
|
|
|
|
m = AsyncMock()
|
|
m.name = "qwen-macbook"
|
|
m.generate = AsyncMock(
|
|
side_effect=BackendUnavailable("qwen-macbook", "ConnectError")
|
|
)
|
|
return m
|
|
|
|
|
|
# ── 공통 fixture: synthesis_service 에 mock backend 주입 ───────────────────
|
|
|
|
|
|
@pytest.fixture
|
|
def patched_backends(monkeypatch):
|
|
"""services.llm.get_backend 를 mock dispatcher 로 치환.
|
|
|
|
Returns (gemma_mock, qwen_mock, set_qwen_unavailable_fn).
|
|
"""
|
|
from services.search import synthesis_service
|
|
|
|
gemma = _gemma_mock()
|
|
qwen_holder = {"backend": _qwen_mock_success(
|
|
'{"answer":"Qwen ok [1]","confidence":"high","refused":false}'
|
|
)}
|
|
|
|
def _fake_get_backend(name: str | None):
|
|
key = (name or "").strip().lower() or "gemma-macmini"
|
|
if key == "gemma-macmini":
|
|
return gemma
|
|
if key == "qwen-macbook":
|
|
return qwen_holder["backend"]
|
|
raise ValueError(f"unknown backend: {name!r}")
|
|
|
|
monkeypatch.setattr(synthesis_service, "get_backend", _fake_get_backend)
|
|
# synthesis_service 캐시 비움 (qwen vs gemma 캐시 분리 invariant)
|
|
synthesis_service._CACHE.clear()
|
|
|
|
def _swap_qwen_unavailable():
|
|
qwen_holder["backend"] = _qwen_mock_unavailable()
|
|
|
|
return gemma, qwen_holder, _swap_qwen_unavailable
|
|
|
|
|
|
# ── 정정 4 핵심: backend=qwen-macbook + MacBook 비가용 → Gemma 호출 0 ─────
|
|
|
|
|
|
def test_qwen_unavailable_yields_backend_unavailable_status_and_gemma_not_called(
|
|
patched_backends,
|
|
):
|
|
"""**정정 4 의 핵심 invariant**.
|
|
|
|
backend="qwen-macbook" 명시 + Qwen 호출이 BackendUnavailable 로 실패 →
|
|
synthesize() 는 status="backend_unavailable" 반환. Gemma backend 의
|
|
generate() 는 **단 한 번도 호출되지 않음** (silent fallback 금지).
|
|
"""
|
|
from services.search.synthesis_service import synthesize
|
|
|
|
gemma, qwen_holder, swap_qwen_unavailable = patched_backends
|
|
swap_qwen_unavailable()
|
|
qwen = qwen_holder["backend"]
|
|
|
|
result = asyncio.run(
|
|
synthesize(
|
|
query="압력용기 최대허용응력은?",
|
|
evidence=_make_evidence(),
|
|
backend="qwen-macbook",
|
|
)
|
|
)
|
|
|
|
# 1. status
|
|
assert result.status == "backend_unavailable"
|
|
assert result.answer is None
|
|
assert result.confidence is None
|
|
assert result.refused is False
|
|
|
|
# 2. flag 에 backend 비가용 사유 기록
|
|
assert any(
|
|
f.startswith("backend_unavailable:qwen-macbook:") for f in result.hallucination_flags
|
|
), f"expected backend_unavailable flag, got {result.hallucination_flags}"
|
|
|
|
# 3. ★ 핵심 가드 ★ — Gemma backend 자동 fallback 금지
|
|
gemma.generate.assert_not_called()
|
|
|
|
# 4. Qwen 은 1회만 호출 (재시도 없음)
|
|
assert qwen.generate.call_count == 1
|
|
|
|
|
|
def test_qwen_unavailable_result_not_cached(patched_backends):
|
|
"""비가용 결과는 캐시 X — 다음 호출이 다시 Qwen 시도해야 함."""
|
|
from services.search.synthesis_service import synthesize
|
|
|
|
gemma, qwen_holder, swap_qwen_unavailable = patched_backends
|
|
swap_qwen_unavailable()
|
|
qwen = qwen_holder["backend"]
|
|
|
|
asyncio.run(
|
|
synthesize(
|
|
query="동일 쿼리",
|
|
evidence=_make_evidence(),
|
|
backend="qwen-macbook",
|
|
)
|
|
)
|
|
asyncio.run(
|
|
synthesize(
|
|
query="동일 쿼리",
|
|
evidence=_make_evidence(),
|
|
backend="qwen-macbook",
|
|
)
|
|
)
|
|
|
|
# 두 번 모두 실제 호출 (캐시 적중 X) — Gemma 는 여전히 0
|
|
assert qwen.generate.call_count == 2
|
|
gemma.generate.assert_not_called()
|
|
|
|
|
|
# ── 정정 4: backend 미지정 → 기존 Gemma path (회귀 0) ─────────────────────
|
|
|
|
|
|
def test_default_backend_calls_gemma_not_qwen(patched_backends):
|
|
"""backend 미지정 = 기본 Gemma. Qwen 호출 0."""
|
|
from services.search.synthesis_service import synthesize
|
|
|
|
gemma, qwen_holder, _ = patched_backends
|
|
qwen = qwen_holder["backend"]
|
|
gemma.generate.return_value = (
|
|
'{"answer":"Gemma 답변 [1]","confidence":"high","refused":false}'
|
|
)
|
|
|
|
result = asyncio.run(
|
|
synthesize(
|
|
query="기본 호출",
|
|
evidence=_make_evidence(),
|
|
backend=None, # 명시 None = default
|
|
)
|
|
)
|
|
|
|
assert result.status == "completed"
|
|
assert result.answer is not None and "Gemma" in result.answer
|
|
|
|
# Qwen 은 호출 0
|
|
qwen.generate.assert_not_called()
|
|
# Gemma 는 1회
|
|
assert gemma.generate.call_count == 1
|
|
|
|
|
|
# ── backend="qwen-macbook" + 정상 응답 ──────────────────────────────────────
|
|
|
|
|
|
def test_qwen_success_does_not_call_gemma(patched_backends):
|
|
"""Qwen 정상 응답 시 Gemma 는 호출되지 않음 (대칭 invariant)."""
|
|
from services.search.synthesis_service import synthesize
|
|
|
|
gemma, qwen_holder, _ = patched_backends
|
|
qwen = qwen_holder["backend"]
|
|
|
|
result = asyncio.run(
|
|
synthesize(
|
|
query="정상 호출",
|
|
evidence=_make_evidence(),
|
|
backend="qwen-macbook",
|
|
)
|
|
)
|
|
|
|
assert result.status == "completed"
|
|
assert result.answer is not None and "Qwen" in result.answer
|
|
|
|
# Gemma 는 0회
|
|
gemma.generate.assert_not_called()
|
|
# Qwen 은 1회
|
|
assert qwen.generate.call_count == 1
|
|
|
|
|
|
# ── 캐시 분리 (qwen vs gemma 키 충돌 없음) ─────────────────────────────────
|
|
|
|
|
|
def test_qwen_and_gemma_have_separate_caches(patched_backends):
|
|
"""같은 query 라도 backend 다르면 캐시 분리 — Qwen 결과가 Gemma 호출 답으로 둔갑하지 않음."""
|
|
from services.search.synthesis_service import synthesize
|
|
|
|
gemma, qwen_holder, _ = patched_backends
|
|
qwen = qwen_holder["backend"]
|
|
gemma.generate.return_value = (
|
|
'{"answer":"GEMMA_ANSWER [1]","confidence":"high","refused":false}'
|
|
)
|
|
qwen.generate.return_value = (
|
|
'{"answer":"QWEN_ANSWER [1]","confidence":"high","refused":false}'
|
|
)
|
|
|
|
r_qwen_1 = asyncio.run(
|
|
synthesize(
|
|
query="같은 query",
|
|
evidence=_make_evidence(),
|
|
backend="qwen-macbook",
|
|
)
|
|
)
|
|
r_gemma_1 = asyncio.run(
|
|
synthesize(
|
|
query="같은 query",
|
|
evidence=_make_evidence(),
|
|
backend=None,
|
|
)
|
|
)
|
|
r_qwen_2 = asyncio.run(
|
|
synthesize(
|
|
query="같은 query",
|
|
evidence=_make_evidence(),
|
|
backend="qwen-macbook",
|
|
)
|
|
)
|
|
|
|
assert "QWEN_ANSWER" in (r_qwen_1.answer or "")
|
|
assert "GEMMA_ANSWER" in (r_gemma_1.answer or "")
|
|
# 두 번째 Qwen 호출은 캐시 적중 — 결과는 동일하지만 generate 추가 호출 X
|
|
assert "QWEN_ANSWER" in (r_qwen_2.answer or "")
|
|
assert r_qwen_2.cache_hit is True
|
|
|
|
# generate 호출 횟수: Qwen 1 (두번째는 캐시), Gemma 1
|
|
assert qwen.generate.call_count == 1
|
|
assert gemma.generate.call_count == 1
|