"""Query analyzer — 자연어 쿼리 분석 (Phase 2.1, async-only 구조). **핵심 철학** (memory `feedback_analyzer_async_only.md`): > QueryAnalyzer는 "즉시 실행하는 기능"이 아니라 "미리 준비해두는 기능"이다. Retrieval 경로에서 analyzer를 **동기 호출 금지**. 동기 호출 가능한 API는 prewarm 전용. ## Pipeline ``` query → retrieval (항상 즉시) ↘ trigger_background_analysis (fire-and-forget) → analyze() [5초+] → cache 저장 다음 호출 (동일 쿼리) → get_cached() 히트 → Phase 2 파이프라인 활성화 ``` ## 룰 (plan 영구) - `LLM_TIMEOUT_MS = 5000` (background 이므로 여유 OK) - `MAX_NORMALIZED_QUERIES = 3` (multilingual explosion 방지) - weight 합 = 1.0 정규화 필수 (fusion 왜곡 방지) - 실패/저신뢰(< 0.5) 결과는 캐시 금지 (잘못된 분석 고정 방지) - `analyzer_confidence` default `float 0.0` 강제 (None 금지) - analyze() 동기 호출 금지. retrieval 경로는 `get_cached()` + `trigger_background_analysis()` 만 사용. """ from __future__ import annotations import asyncio import hashlib import time from typing import Any from ai.client import AIClient, _load_prompt, parse_json_response from core.config import settings from core.utils import setup_logger logger = setup_logger("query_analyzer") # ─── 상수 (plan 영구 룰) ──────────────────────────────── PROMPT_VERSION = "v2" # prompts/query_analyze.txt 축소판 CACHE_TTL = 86400 # 24h CACHE_MAXSIZE = 1000 LLM_TIMEOUT_MS = 15000 # async 구조 (background), 동기 경로 금지 # ↑ 실측: gemma-4-26b-a4b-it-8bit MLX, 축소 프롬프트(prompt_tok=802) 7~11초. # generation이 dominant (max_tokens 무효, 자연 EOS ~289 tok 생성). # background 실행이라 15초도 안전. 상향 필요 시 여기서만 조정. LLM_CONCURRENCY = 1 # MLX는 single-inference, 동시 호출 시 queue 폭발. # ↑ 실측: 23 concurrent 요청 → 22개 15초 timeout. semaphore로 순차 강제. # prewarm/background/동기 호출 모두 이 semaphore 경유. MIN_CACHE_CONFIDENCE = 0.5 # 이 미만은 캐시 금지 MAX_NORMALIZED_QUERIES = 3 def _model_version() -> str: """현재 primary 모델 ID를 캐시 키에 반영.""" 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]] = {} # background task 참조 유지 (premature GC 방지) _PENDING: set[asyncio.Task[Any]] = set() # 동일 쿼리 중복 실행 방지 (진행 중인 쿼리 집합) _INFLIGHT: set[str] = set() # MLX concurrency 제한 (single-inference → 1) # 첫 호출 시 lazy init (event loop이 준비된 후) _LLM_SEMAPHORE: asyncio.Semaphore | None = None def _get_llm_semaphore() -> asyncio.Semaphore: """첫 호출 시 현재 event loop에 바인딩된 semaphore 생성.""" global _LLM_SEMAPHORE if _LLM_SEMAPHORE is None: _LLM_SEMAPHORE = asyncio.Semaphore(LLM_CONCURRENCY) return _LLM_SEMAPHORE 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. retrieval 경로에서 cache hit 판단용으로 호출. 호출 자체는 O(1). """ 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: 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/운영용 — 현재 캐시 크기 + inflight 수.""" return { "size": len(_CACHE), "maxsize": CACHE_MAXSIZE, "inflight": len(_INFLIGHT), "pending_tasks": len(_PENDING), } # ─── weight 정규화 (fusion 왜곡 방지) ─────────────────── def _normalize_weights(analysis: dict) -> dict: """normalized_queries를 MAX_NORMALIZED_QUERIES로 자르고 weight 합=1.0 정규화.""" queries = analysis.get("normalized_queries") if not isinstance(queries, list) or not queries: return analysis 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: 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 # ─── 메인 LLM 호출 (내부 사용) ────────────────────────── async def analyze(query: str, ai_client: AIClient | None = None) -> dict: """쿼리 분석 결과 반환. 실패 시 analyzer_confidence=0.0 fallback. **⚠️ 동기 검색 경로에서 직접 호출 금지**. 용도: - `trigger_background_analysis` 내부 호출 - `prewarm_analyzer` startup 호출 - 디버깅/테스트 Args: query: 사용자 쿼리 원문 ai_client: AIClient 인스턴스 (없으면 생성 후 자동 close) 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 즉시 반환 (prewarm 재호출 방지) cached = get_cached(query) if cached is not None: return cached client_owned = False if ai_client is None: ai_client = AIClient() client_owned = True t_start = time.perf_counter() semaphore = _get_llm_semaphore() # ⚠️ 중요: semaphore 대기는 timeout 포함되면 안됨 (대기만 해도 timeout 발동) # timeout은 실제 LLM 호출 구간에만 적용. try: async with semaphore: 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 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") try: conf = float(parsed.get("analyzer_confidence", 0.0) or 0.0) except (TypeError, ValueError): conf = 0.0 parsed["analyzer_confidence"] = conf 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 # ─── Background trigger (retrieval 경로에서 사용) ─────── async def _background_analyze(query: str) -> None: """Background task wrapper — inflight 집합 관리 + 예외 삼킴.""" try: await analyze(query) except Exception as exc: logger.warning("background analyze crashed query=%r err=%r", query[:80], exc) finally: _INFLIGHT.discard(query) def trigger_background_analysis(query: str) -> bool: """retrieval 경로에서 호출. cache miss 시 background task 생성. - 동기 함수. 즉시 반환. - 이미 inflight 또는 cache hit이면 아무 작업 X, False 반환. - 새 task 생성 시 True 반환. Returns: bool — task 실제로 생성되었는지 여부 """ if not query or not query.strip(): return False if query in _INFLIGHT: return False if get_cached(query) is not None: return False try: loop = asyncio.get_running_loop() except RuntimeError: logger.warning("trigger_background_analysis called outside event loop") return False _INFLIGHT.add(query) task = loop.create_task(_background_analyze(query)) _PENDING.add(task) task.add_done_callback(_PENDING.discard) return True # ─── Prewarm (app startup) ────────────────────────────── # 운영에서 자주 발생하는 쿼리 샘플. 통계 기반으로 확장 예정. DEFAULT_PREWARM_QUERIES: list[str] = [ # fixed 7 (Phase 2 평가셋 core) "산업안전보건법 제6장", "기계 사고 관련 법령", "AI 산업 동향", "Python async best practice", "유해화학물질을 다루는 회사가 지켜야 할 안전 의무", "recent AI safety news from Europe", "이세상에 존재하지 않는 문서명", # 법령 관련 "산업안전보건법 시행령", "화학물질관리법", "위험성평가 절차", # 뉴스 관련 "EU AI Act", "한국 AI 산업", # 실무 "안전보건 교육 자료", "사고 조사 보고서", "MSDS 작성 방법", ] async def prewarm_analyzer( queries: list[str] | None = None, delay_between: float = 0.2, ) -> dict[str, Any]: """app startup에서 호출. 대표 쿼리를 미리 분석해 cache에 적재. Non-blocking으로 사용: `asyncio.create_task(prewarm_analyzer())`. Args: queries: 분석할 쿼리 리스트. None이면 DEFAULT_PREWARM_QUERIES 사용. delay_between: 각 쿼리 간 대기 시간 (MLX 부하 완화). Returns: dict — {queries_total, success, failed, elapsed_ms, cache_size_after} """ if not ANALYZE_PROMPT: logger.warning("prewarm skipped — prompt not loaded") return {"status": "skipped", "reason": "prompt_not_loaded"} targets = queries if queries is not None else DEFAULT_PREWARM_QUERIES total = len(targets) success = 0 failed = 0 t_start = time.perf_counter() logger.info("prewarm_analyzer start queries=%d timeout_ms=%d", total, LLM_TIMEOUT_MS) client = AIClient() try: for i, q in enumerate(targets, 1): if get_cached(q) is not None: logger.info("prewarm skip (already cached) [%d/%d] %r", i, total, q[:40]) success += 1 continue result = await analyze(q, ai_client=client) conf = float(result.get("analyzer_confidence", 0.0) or 0.0) if conf >= MIN_CACHE_CONFIDENCE: success += 1 logger.info("prewarm ok [%d/%d] conf=%.2f q=%r", i, total, conf, q[:40]) else: failed += 1 reason = result.get("_fallback_reason", "low_conf") logger.warning( "prewarm fail [%d/%d] reason=%s q=%r", i, total, reason, q[:40] ) if delay_between > 0 and i < total: await asyncio.sleep(delay_between) finally: try: await client.close() except Exception: pass elapsed_ms = (time.perf_counter() - t_start) * 1000 stats = cache_stats() logger.info( "prewarm_analyzer done total=%d success=%d failed=%d elapsed_ms=%.0f cache_size=%d", total, success, failed, elapsed_ms, stats["size"], ) return { "status": "complete", "queries_total": total, "success": success, "failed": failed, "elapsed_ms": elapsed_ms, "cache_size_after": stats["size"], }