"""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 _yaml_exists(self, tenant_id: str) -> bool: return bool(tenant_id) and os.path.exists(self._yaml_path(tenant_id)) 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: """tenant 키 = company_id(uuid) 또는 데모 테넌트명. 전용 설정이 없어도 _base 가 있으면 자동 온보딩(cold-start warm-start) 대상이므로 '등록됨'으로 본다 (빈 키만 미등록).""" if not tenant_id: return False if self._yaml_exists(tenant_id): return True return tenant_id != _BASE_TENANT_ID and self._yaml_exists(_BASE_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] if self._yaml_exists(tenant_id): 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._yaml_exists(_BASE_TENANT_ID): base_raw = self._read_yaml(_BASE_TENANT_ID) 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) elif tenant_id != _BASE_TENANT_ID and self._yaml_exists(_BASE_TENANT_ID): # 미등록 company_id(backend 가 보낸 갑 회사 uuid) → _base 설정으로 자동 온보딩. # company_id 를 tenant 키로 박고 inherits_base=true → 첫 협상에서 base warm-start 복제. raw = self._read_yaml(_BASE_TENANT_ID) raw = {k: v for k, v in raw.items() if k not in ("tenant_id", "company_id", "name")} raw["inherits_base"] = True raw["company_id"] = tenant_id LOG.i(f"[TenantConfigLoader] 미등록 tenant_id={tenant_id} → _base 자동 온보딩") else: raw = self._read_yaml(tenant_id) # _base 도 없으면 FileNotFoundError 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)