"""TenantConfig 로더 (계획서 A: YAML → env 주입 → DB 오버레이 merge, inherits_base deep-merge, TTL 캐시). P1 범위: YAML 로드 + _base deep-merge + TTL 캐시. DB 오버레이(action_to_card, 활성 Q-Table 버전 포인터)는 P5/P6 에서 추가한다. env 주입(llm.api_key_ref → 실제 키)은 P7 에서 LlmCredentials 해석으로 처리한다. """ import os import time from typing import Any, Dict, Optional import yaml from common.logger import LOG from config.server_configs import agent_config from tenancy.config import TenantConfig _BASE_TENANT_ID = "_base" def _deep_merge(base: Dict[str, Any], override: Dict[str, Any]) -> Dict[str, Any]: """override 를 base 위에 재귀 병합. dict 는 키 단위 병합, 그 외(리스트/스칼라)는 override 우선. 리스트는 통째로 교체한다(임계값/가중치 배열은 부분 병합이 의미 없으므로). """ result = dict(base) for key, ov in override.items(): bv = result.get(key) if isinstance(bv, dict) and isinstance(ov, dict): result[key] = _deep_merge(bv, ov) else: result[key] = ov return result class TenantConfigLoader: """tenants//tenant.yaml 을 읽어 TenantConfig 로 만든다. TTL 캐시.""" def __init__(self, tenants_dir: Optional[str] = None, cache_ttl_seconds: Optional[int] = None): self._tenants_dir = tenants_dir or agent_config.tenants_dir self._ttl = agent_config.config_cache_ttl_seconds if cache_ttl_seconds is None else cache_ttl_seconds self._cache: Dict[str, tuple[float, TenantConfig]] = {} def _yaml_path(self, tenant_id: str) -> str: return os.path.join(self._tenants_dir, tenant_id, "tenant.yaml") def _read_yaml(self, tenant_id: str) -> Dict[str, Any]: path = self._yaml_path(tenant_id) if not os.path.exists(path): raise FileNotFoundError(f"tenant.yaml 없음: {path} (tenant_id={tenant_id})") with open(path, "r", encoding="utf-8") as f: return yaml.safe_load(f) or {} def is_registered(self, tenant_id: str) -> bool: return os.path.exists(self._yaml_path(tenant_id)) def load(self, tenant_id: str, use_cache: bool = True) -> TenantConfig: if use_cache and self._ttl > 0: hit = self._cache.get(tenant_id) if hit and (time.monotonic() - hit[0]) < self._ttl: return hit[1] raw = self._read_yaml(tenant_id) # inherits_base 이면 _base 를 deep-merge 한다 (자기 자신이 _base 면 스킵). inherits = raw.get("inherits_base", True) if inherits and tenant_id != _BASE_TENANT_ID and self.is_registered(_BASE_TENANT_ID): base_raw = self._read_yaml(_BASE_TENANT_ID) # _base 의 식별 필드는 병합하지 않는다. base_raw = {k: v for k, v in base_raw.items() if k not in ("tenant_id", "company_id", "name")} raw = _deep_merge(base_raw, raw) raw.setdefault("tenant_id", tenant_id) config = TenantConfig.model_validate(raw) if self._ttl > 0: self._cache[tenant_id] = (time.monotonic(), config) LOG.i(f"[TenantConfigLoader] loaded tenant_id={tenant_id} state_space_size={config.state.state_space_size} action_space_size={config.action_mapping.action_space_size}") return config def invalidate(self, tenant_id: Optional[str] = None): if tenant_id is None: self._cache.clear() else: self._cache.pop(tenant_id, None)