3e6866b4ae
phase-2q-query-rewrite-diagnose.md v6 plan Phase 1 의 fixture 외 잔여.
Phase 1A 446ba82 위 dispatcher + cache + LLM call + API param + eval flag + 21 unit test.
retrieval 합성 (search_with_rewrite) 은 Phase 2 별 commit.
신규:
- app/services/search/query_rewriter.py — LLM_BACKEND_MAP + _resolve + cache + rewrite()
· slug-based allowlist (no silent fallback), httpx 직접, Priority.FOREGROUND semaphore
· sampling 박제 (gemma response_format json_object / qwen prompt rule only — Phase 0 inspect 9)
· manual TTL cache (query_analyzer 패턴 1:1, sha256[:32] NFKC key, LLM_REWRITE_TIMEOUT_MS=15000)
- tests/test_query_rewriter.py — 21 test PASS (resolve / cache key / parser / cache TTL / constants)
수정:
- app/api/search.py — ?rewrite_backend= query param + 400 unknown / 503 unavailable.
scaffold = call but discard variants (retrieval path 영향 0). Phase 2 에서 합성.
- tests/search_eval/run_eval.py — --rewrite-backend flag + 4 hot spot wire-up.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
213 lines
7.4 KiB
Python
213 lines
7.4 KiB
Python
"""Phase 2Q Diagnose Phase 1B — query_rewriter scaffold + dispatcher 단위 테스트.
|
|
|
|
가드레일 (plan v6 §5 + §7 Phase 1):
|
|
1. `_resolve_rewrite_backend` — slug resolve, unknown ValueError, baseline → None
|
|
2. `_cache_key` — deterministic + NFKC normalize + backend slug 분리
|
|
3. `_extract_variants` — valid shape / wrong count / type mismatch / empty / non-list
|
|
4. cache set/get/TTL (LRU evict 시뮬레이션)
|
|
5. `allowed_slugs` — LLM_BACKEND_MAP keys 1:1
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
import os
|
|
import sys
|
|
import time
|
|
|
|
import pytest
|
|
|
|
# logs/llm_gate.log 가 root 소유 (운영 fastapi daemon write) → pytest 가 hyungi user 로
|
|
# import 시 PermissionError. 본 test 한정 FileHandler safe-wrap (다른 test 영향 0).
|
|
_orig_file_handler = logging.FileHandler
|
|
|
|
def _safe_file_handler(filename, *args, **kwargs): # type: ignore
|
|
try:
|
|
return _orig_file_handler(filename, *args, **kwargs)
|
|
except PermissionError:
|
|
return logging.NullHandler()
|
|
|
|
logging.FileHandler = _safe_file_handler # type: ignore[assignment]
|
|
|
|
# tests/ → 프로젝트 루트 → app/
|
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "app"))
|
|
|
|
from services.search import query_rewriter
|
|
from services.search.query_rewriter import (
|
|
EXPECTED_N_VARIANTS,
|
|
LLM_BACKEND_MAP,
|
|
PROMPT_VERSION,
|
|
_cache_key,
|
|
_extract_variants,
|
|
_resolve_rewrite_backend,
|
|
allowed_slugs,
|
|
)
|
|
|
|
|
|
# ─── 1. _resolve_rewrite_backend ──────────────────────────
|
|
|
|
|
|
def test_resolve_baseline_returns_none():
|
|
assert _resolve_rewrite_backend(None) is None
|
|
assert _resolve_rewrite_backend("baseline") is None
|
|
|
|
|
|
def test_resolve_known_slugs():
|
|
cfg = _resolve_rewrite_backend("cand_multi_query_macmini")
|
|
assert cfg is not None
|
|
assert "endpoint" in cfg and "model" in cfg and "sampling" in cfg
|
|
assert cfg["model"] == "gemma-4-26b-a4b-it-8bit"
|
|
|
|
cfg = _resolve_rewrite_backend("cand_multi_query_macbook")
|
|
assert cfg is not None
|
|
assert cfg["model"] == "mlx-community/Qwen3.6-27B-8bit"
|
|
# qwen sampling 에 response_format 없음 (Phase 0 inspect 9 박제)
|
|
assert "response_format" not in cfg["sampling"]
|
|
|
|
|
|
def test_resolve_unknown_slug_raises():
|
|
with pytest.raises(ValueError, match="unknown_rewrite_backend"):
|
|
_resolve_rewrite_backend("cand_bogus")
|
|
with pytest.raises(ValueError):
|
|
_resolve_rewrite_backend("cand_multi_query_other")
|
|
|
|
|
|
def test_allowed_slugs_matches_map():
|
|
assert allowed_slugs() == list(LLM_BACKEND_MAP.keys())
|
|
assert "baseline" in allowed_slugs()
|
|
assert "cand_multi_query_macmini" in allowed_slugs()
|
|
assert "cand_multi_query_macbook" in allowed_slugs()
|
|
|
|
|
|
# ─── 2. _cache_key ────────────────────────────────────────
|
|
|
|
|
|
def test_cache_key_deterministic():
|
|
k1 = _cache_key("산업안전보건법 제6장", "cand_multi_query_macmini")
|
|
k2 = _cache_key("산업안전보건법 제6장", "cand_multi_query_macmini")
|
|
assert k1 == k2
|
|
assert len(k1) == 32 # sha256[:32]
|
|
|
|
|
|
def test_cache_key_nfkc_normalize_and_strip_lower():
|
|
# whitespace + uppercase → 동일 key
|
|
base = _cache_key("ASME Section VIII", "cand_multi_query_macmini")
|
|
assert _cache_key(" asme section viii ", "cand_multi_query_macmini") == base
|
|
assert _cache_key("ASME SECTION VIII", "cand_multi_query_macmini") == base
|
|
|
|
|
|
def test_cache_key_differs_by_backend_slug():
|
|
k_a = _cache_key("query", "cand_multi_query_macmini")
|
|
k_b = _cache_key("query", "cand_multi_query_macbook")
|
|
assert k_a != k_b
|
|
|
|
|
|
def test_cache_key_includes_prompt_version():
|
|
# PROMPT_VERSION 변경 시 cache 분리 — 직접 test 어렵지만 raw 구성 확인
|
|
assert PROMPT_VERSION == "v1"
|
|
k = _cache_key("query", "cand_multi_query_macmini")
|
|
assert len(k) == 32
|
|
|
|
|
|
# ─── 3. _extract_variants ─────────────────────────────────
|
|
|
|
|
|
def test_extract_variants_valid_shape():
|
|
raw = '{"variants": ["원본", "한국어 변형", "english"]}'
|
|
out = _extract_variants(raw, expected_n=3)
|
|
assert out == ["원본", "한국어 변형", "english"]
|
|
|
|
|
|
def test_extract_variants_strips_whitespace():
|
|
raw = '{"variants": [" 원본 ", "한국어\\n", " english "]}'
|
|
out = _extract_variants(raw, expected_n=3)
|
|
assert out == ["원본", "한국어", "english"]
|
|
|
|
|
|
def test_extract_variants_wrong_count_returns_none():
|
|
raw = '{"variants": ["only_one"]}'
|
|
assert _extract_variants(raw, expected_n=3) is None
|
|
raw = '{"variants": ["a", "b", "c", "d"]}'
|
|
assert _extract_variants(raw, expected_n=3) is None
|
|
|
|
|
|
def test_extract_variants_missing_key_returns_none():
|
|
raw = '{"queries": ["a", "b", "c"]}'
|
|
assert _extract_variants(raw, expected_n=3) is None
|
|
|
|
|
|
def test_extract_variants_non_list_returns_none():
|
|
raw = '{"variants": "single string"}'
|
|
assert _extract_variants(raw, expected_n=3) is None
|
|
|
|
|
|
def test_extract_variants_empty_string_returns_none():
|
|
raw = '{"variants": ["a", "", "c"]}'
|
|
assert _extract_variants(raw, expected_n=3) is None
|
|
|
|
|
|
def test_extract_variants_non_string_element_returns_none():
|
|
raw = '{"variants": ["a", 123, "c"]}'
|
|
assert _extract_variants(raw, expected_n=3) is None
|
|
|
|
|
|
def test_extract_variants_invalid_json_returns_none():
|
|
raw = "not json at all"
|
|
assert _extract_variants(raw, expected_n=3) is None
|
|
|
|
|
|
def test_extract_variants_markdown_fence_fallback():
|
|
# parse_json_response 가 ```json fenced 블록 내부 추출 — production parser 재사용 검증
|
|
raw = '```json\n{"variants": ["a", "b", "c"]}\n```'
|
|
out = _extract_variants(raw, expected_n=3)
|
|
assert out == ["a", "b", "c"]
|
|
|
|
|
|
# ─── 4. cache set / get ───────────────────────────────────
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cache_set_get_roundtrip():
|
|
# 격리: 전역 _CACHE 초기화 (다른 테스트와 격리)
|
|
query_rewriter._CACHE.clear()
|
|
key = _cache_key("__test_unique_key__", "cand_multi_query_macmini")
|
|
assert await query_rewriter._get_cached(key) is None
|
|
await query_rewriter._set_cached(key, ["v0", "v1", "v2"])
|
|
out = await query_rewriter._get_cached(key)
|
|
assert out == ["v0", "v1", "v2"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cache_ttl_expiry():
|
|
query_rewriter._CACHE.clear()
|
|
key = "ttl_test_key"
|
|
# manual entry with past expire_at
|
|
query_rewriter._CACHE[key] = (time.time() - 1.0, ["a", "b", "c"])
|
|
assert await query_rewriter._get_cached(key) is None
|
|
# lazy delete verify
|
|
assert key not in query_rewriter._CACHE
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cache_returns_copy_not_reference():
|
|
"""_get_cached 반환 list 를 외부에서 수정해도 internal cache 안전."""
|
|
query_rewriter._CACHE.clear()
|
|
key = "copy_test_key"
|
|
await query_rewriter._set_cached(key, ["a", "b", "c"])
|
|
out = await query_rewriter._get_cached(key)
|
|
out.append("mutated")
|
|
out2 = await query_rewriter._get_cached(key)
|
|
assert out2 == ["a", "b", "c"]
|
|
|
|
|
|
# ─── 5. constants ─────────────────────────────────────────
|
|
|
|
|
|
def test_constants_match_plan_v6():
|
|
assert PROMPT_VERSION == "v1"
|
|
assert EXPECTED_N_VARIANTS == 3
|
|
assert query_rewriter.LLM_REWRITE_TIMEOUT_MS == 15000
|
|
assert query_rewriter.CACHE_TTL == 86400
|
|
assert query_rewriter.CACHE_MAXSIZE == 1000
|