"""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