"""Query analyzer — 자연어 쿼리 분석 (Phase 2.1). 자연어 쿼리를 구조화된 분석 결과로 변환. 결과는 검색 보조용 (지배 X). Pipeline: 1. in-memory LRU 캐시 조회 2. miss → LLM 호출 (primary 모델, asyncio.timeout 강제) 3. JSON 파싱 → weight 정규화 → 캐시 저장 4. 실패/저신뢰 → `{"analyzer_confidence": 0.0}` (fallback 트리거) CRITICAL 룰 (plan 영구): - `LLM_TIMEOUT_MS = 800` 절대 늘리지 말 것. MLX 멈춤 시 검색 전체 멈춤 방지. - `MAX_NORMALIZED_QUERIES = 3` 절대 늘리지 말 것. multilingual explosion 방지. - weight 합 = 1.0 정규화 필수. fusion 왜곡 방지. - 실패/저신뢰(< 0.5) 결과는 캐시 금지. 잘못된 분석 고정 방지. - 호출자는 항상 `result.get("analyzer_confidence", 0.0)` 방어 패턴 사용. """ from __future__ import annotations import asyncio import hashlib import logging import time from typing import Any from ai.client import AIClient, _load_prompt, parse_json_response from core.config import settings logger = logging.getLogger("query_analyzer") # ─── 상수 (plan 영구 룰) ──────────────────────────────── PROMPT_VERSION = "v1" # prompts/query_analyze.txt 변경 시 갱신 CACHE_TTL = 86400 # 24h CACHE_MAXSIZE = 1000 LLM_TIMEOUT_MS = 800 # 절대 늘리지 말 것 (plan 영구) MIN_CACHE_CONFIDENCE = 0.5 # 이 미만은 캐시 금지 MAX_NORMALIZED_QUERIES = 3 # 절대 늘리지 말 것 (plan 영구) def _model_version() -> str: """현재 primary 모델 ID를 캐시 키에 반영. 런타임에 config.yaml이 바뀌어도 재시작 후 자동 반영. config.yaml 없을 경우 sentinel 문자열. """ if settings.ai and settings.ai.primary: return settings.ai.primary.model return "unknown-model" # ─── in-memory LRU (FIFO 근사) ────────────────────────── _CACHE: dict[str, dict[str, Any]] = {} def _cache_key(query: str) -> str: raw = f"{query}|{PROMPT_VERSION}|{_model_version()}" return hashlib.sha256(raw.encode("utf-8")).hexdigest() def get_cached(query: str) -> dict | None: """TTL 경과 entry는 자동 삭제. 없으면 None.""" key = _cache_key(query) entry = _CACHE.get(key) if not entry: return None if time.time() - entry["ts"] > CACHE_TTL: _CACHE.pop(key, None) return None return entry["value"] def set_cached(query: str, value: dict) -> None: """저신뢰(< 0.5) / 빈 값은 캐시 금지. 상한 초과 시 FIFO eviction.""" if not value: return try: conf = float(value.get("analyzer_confidence", 0.0) or 0.0) except (TypeError, ValueError): conf = 0.0 if conf < MIN_CACHE_CONFIDENCE: return key = _cache_key(query) if key in _CACHE: _CACHE[key] = {"value": value, "ts": time.time()} return if len(_CACHE) >= CACHE_MAXSIZE: # 가장 먼저 추가된 항목 제거 (dict insertion order 활용) try: oldest = next(iter(_CACHE)) _CACHE.pop(oldest, None) except StopIteration: pass _CACHE[key] = {"value": value, "ts": time.time()} def cache_stats() -> dict[str, int]: """debug용 — 현재 캐시 크기.""" return {"size": len(_CACHE), "maxsize": CACHE_MAXSIZE} # ─── weight 정규화 (fusion 왜곡 방지) ─────────────────── def _normalize_weights(analysis: dict) -> dict: """normalized_queries를 MAX_NORMALIZED_QUERIES로 자르고 weight 합=1.0 정규화. - 리스트가 없거나 비어 있으면 그대로 반환 - 각 항목의 weight가 숫자 아니면 1.0으로 치환 - 합이 0이면 균등 분배 """ queries = analysis.get("normalized_queries") if not isinstance(queries, list) or not queries: return analysis # sanitize + cap sanitized: list[dict] = [] for q in queries[:MAX_NORMALIZED_QUERIES]: if not isinstance(q, dict): continue lang = str(q.get("lang", "")).strip() or "ko" text = str(q.get("text", "")).strip() if not text: continue try: w = float(q.get("weight", 1.0)) if w < 0: w = 0.0 except (TypeError, ValueError): w = 1.0 sanitized.append({"lang": lang, "text": text, "weight": w}) if not sanitized: return analysis total = sum(q["weight"] for q in sanitized) if total <= 0: equal = 1.0 / len(sanitized) for q in sanitized: q["weight"] = equal else: for q in sanitized: q["weight"] /= total analysis["normalized_queries"] = sanitized return analysis # ─── 프롬프트 로딩 (module 초기화 1회) ────────────────── try: ANALYZE_PROMPT = _load_prompt("query_analyze.txt") except FileNotFoundError: ANALYZE_PROMPT = "" logger.warning("query_analyze.txt not found — analyzer will always return fallback") # ─── 기본 fallback 응답 (None 금지) ───────────────────── def _fallback(reason: str | None = None) -> dict: """LLM 실패/timeout/parse 실패 시 반환. analyzer_confidence는 반드시 float 0.0. 호출자는 `result.get("analyzer_confidence", 0.0)`로 방어. """ result: dict[str, Any] = { "intent": "semantic_search", "query_type": "keyword", "domain_hint": "mixed", "language_scope": "limited", "keywords": [], "must_terms": [], "optional_terms": [], "hard_filters": {}, "soft_filters": {"domain": [], "document_type": []}, "normalized_queries": [], "expanded_terms": [], "synonyms": {}, "analyzer_confidence": 0.0, } if reason: result["_fallback_reason"] = reason return result # ─── 메인 API ────────────────────────────────────────── async def analyze(query: str, ai_client: AIClient | None = None) -> dict: """쿼리 분석 결과 반환. 실패 시 반드시 analyzer_confidence=0.0 fallback. Args: query: 사용자 쿼리 원문 ai_client: AIClient 인스턴스 (호출자가 싱글톤으로 관리. None이면 생성) Returns: dict — 최소 `analyzer_confidence` 키는 항상 float로 존재. """ if not query or not query.strip(): return _fallback("empty_query") if not ANALYZE_PROMPT: return _fallback("prompt_not_loaded") # cache hit (고신뢰만 캐시되므로 그대로 반환) cached = get_cached(query) if cached is not None: return cached # LLM 호출 — timeout 강제 client_owned = False if ai_client is None: ai_client = AIClient() client_owned = True t_start = time.perf_counter() try: async with asyncio.timeout(LLM_TIMEOUT_MS / 1000): raw = await ai_client._call_chat( ai_client.ai.primary, ANALYZE_PROMPT.replace("{query}", query), ) except asyncio.TimeoutError: elapsed = (time.perf_counter() - t_start) * 1000 logger.warning( "query_analyze timeout query=%r elapsed_ms=%.0f (LLM_TIMEOUT_MS=%d)", query[:80], elapsed, LLM_TIMEOUT_MS, ) return _fallback("timeout") except Exception as exc: elapsed = (time.perf_counter() - t_start) * 1000 logger.warning( "query_analyze LLM error query=%r elapsed_ms=%.0f err=%r", query[:80], elapsed, exc, ) return _fallback(f"llm_error:{type(exc).__name__}") finally: if client_owned: try: await ai_client.close() except Exception: pass elapsed_ms = (time.perf_counter() - t_start) * 1000 # JSON 파싱 parsed = parse_json_response(raw) if not isinstance(parsed, dict): logger.warning( "query_analyze parse failed query=%r elapsed_ms=%.0f raw=%r", query[:80], elapsed_ms, (raw or "")[:200], ) return _fallback("parse_failed") # 필수 필드 방어 — analyzer_confidence는 반드시 float try: conf = float(parsed.get("analyzer_confidence", 0.0) or 0.0) except (TypeError, ValueError): conf = 0.0 parsed["analyzer_confidence"] = conf # weight 정규화 (MAX_NORMALIZED_QUERIES + sum=1.0) parsed = _normalize_weights(parsed) logger.info( "query_analyze ok query=%r conf=%.2f intent=%s domain=%s elapsed_ms=%.0f", query[:80], conf, parsed.get("intent"), parsed.get("domain_hint"), elapsed_ms, ) # 고신뢰만 캐시 저장 set_cached(query, parsed) return parsed