"""TenantEngineRegistry & EngineFactory (계획서 B: 싱글톤 제거, 테넌트별 엔진 지연생성+캐시). Chat_server 는 `chat_engine = ChatEngine()` 전역 무인자 싱글톤이라 멀티테넌트가 불가능했다. 여기서는 테넌트별로 config 를 주입해 엔진을 조립하고(lock 보호 지연생성), 캐시한다. 카드/모델 갱신 시 reload(tenant_id)로 해당 테넌트만 재조립한다. 엔진(TenantEngine)은 요청 간 공유되지만 **가변 episode 상태를 갖지 않는다**(EpisodeState 외부화). """ import asyncio from collections import defaultdict from typing import Dict, Optional from common.database.db_session_manager import DB_SESSION_MNG from common.enums import DBType, DBWRType, ErrorType from common.logger import LOG from negotiation.cards.action_card_mapper import ActionCardMapper from negotiation.cards.adapters.card_catalog_db import CardCatalogDbRepository from negotiation.cards.ports.card_catalog_port import ICardCatalogRepository from tenancy.company_profile_repo import ( CompanyProfileRepository, ICompanyProfileRepository, resolve_company_name, ) from tenancy.config import TenantConfig from tenancy.config_loader import TenantConfigLoader _CARD_SOURCE_DB = "db" class TenantEngine: """한 테넌트의 협상 엔진 조립체 (불변 협력자 보관). P4 범위: config + ActionCardMapper. policy/orchestrator/chat_engine 은 후속 단계에서 이 팩토리 조립 라인에 추가된다(P5 ModelRepository warm-start, P7 ChatEngine). """ def __init__(self, config: TenantConfig, mapper: ActionCardMapper): self.tenant_id = config.tenant_id self.company_id = config.company_id or config.tenant_id # _base/미시드 시 tenant_id 사용 self.config = config self.mapper = mapper @property def action_space_size(self) -> int: return self.mapper.action_space_size @property def state_space_size(self) -> int: return self.config.state.state_space_size class EngineFactory: """TenantConfig → TenantEngine 조립. (PolicyFactory/ModelRepository 는 후속 단계 결합)""" @staticmethod def build(config: TenantConfig) -> TenantEngine: mapper = ActionCardMapper(config.action_mapping) return TenantEngine(config, mapper) class TenantEngineRegistry: def __init__(self, loader: Optional[TenantConfigLoader] = None, factory: type[EngineFactory] = EngineFactory, catalog_repo: Optional[ICardCatalogRepository] = None, company_repo: Optional[ICompanyProfileRepository] = None): self._loader = loader or TenantConfigLoader() self._factory = factory self._catalog_repo = catalog_repo or CardCatalogDbRepository() self._company_repo = company_repo or CompanyProfileRepository() self._engines: Dict[str, TenantEngine] = {} self._locks: Dict[str, asyncio.Lock] = defaultdict(asyncio.Lock) def is_registered(self, tenant_id: str) -> bool: return self._loader.is_registered(tenant_id) async def get_engine(self, tenant_id: str) -> TenantEngine: cached = self._engines.get(tenant_id) if cached is not None: return cached # 테넌트별 lock 으로 동시 첫 요청에서 1회만 조립 (double-checked). async with self._locks[tenant_id]: cached = self._engines.get(tenant_id) if cached is not None: return cached if not self.is_registered(tenant_id): raise KeyError(f"unregistered tenant: {tenant_id}") engine = await self._build(tenant_id) self._engines[tenant_id] = engine LOG.i(f"[TenantEngineRegistry] built engine tenant_id={tenant_id} " f"state={engine.state_space_size} action={engine.action_space_size}") return engine async def reload(self, tenant_id: str) -> Optional[TenantEngine]: """해당 테넌트만 재조립 (카드/모델 갱신 시). 미등록이면 None.""" async with self._locks[tenant_id]: self._loader.invalidate(tenant_id) self._engines.pop(tenant_id, None) if not self.is_registered(tenant_id): return None engine = await self._build(tenant_id) self._engines[tenant_id] = engine return engine # ---- 조립 (카탈로그 DB 유래 반영) ---------------------------------- async def _build(self, tenant_id: str) -> TenantEngine: config = self._loader.load(tenant_id) # action_mapping.type == "db" 면 카드 카탈로그(card.nego_cards)에서 action_to_card 를 동적 구성. # Q-table action 축을 config 파일이 아니라 negodata 카탈로그가 정의한다(결합 제거). if config.action_mapping.type == _CARD_SOURCE_DB: await self._apply_db_catalog(config) # 브랜드: 자동 온보딩 고객사(company_id=UUID)면 company.companies.name 으로 {company_name} 을 덮어쓴다. # 데모 테넌트(비-UUID)·미조회 시 파일 resources.company_name 유지. await self._apply_company_brand(config, tenant_id) return self._factory.build(config) async def _apply_company_brand(self, config: TenantConfig, tenant_id: str) -> None: name = await resolve_company_name(self._company_repo, config.company_id or tenant_id) if name: config.resources.company_name = name async def _apply_db_catalog(self, config: TenantConfig) -> None: """DB 카탈로그로 config.action_mapping.action_to_card 를 덮어쓴다(성공 시). 비었거나 DB 불가면 config 의 파일 action_to_card 를 그대로 폴백 사용한다.""" async def _q(s): _, numbers = await self._catalog_repo.get_nego_catalog(s) return numbers try: numbers = await DB_SESSION_MNG.execute_lambda(DBType.MAIN.value, DBWRType.DB_READ.value, _q) except Exception as ex: LOG.e_no_callstack(f"[TenantEngineRegistry] 카탈로그 조회 실패 tenant={config.tenant_id}: {ex} → 파일 폴백") return if not numbers: LOG.w(f"[TenantEngineRegistry] 카탈로그 비어있음 tenant={config.tenant_id} → 파일 action_to_card 폴백") return config.action_mapping.action_to_card = {str(i): num for i, num in enumerate(numbers)} def clear_all(self) -> int: """캐시된 엔진 전체를 비운다(공용 카탈로그 변경 등 전역 반영용). 반환: 비운 엔진 수. 다음 요청에서 각 테넌트 엔진이 최신 카탈로그/config 로 재조립된다.""" n = len(self._engines) self._engines.clear() self._loader.invalidate() # 인자 없이 = 전체 config 캐시 무효화 return n def cached_tenants(self) -> list[str]: return list(self._engines.keys()) # 앱 전역 레지스트리 (backend 의 모듈 싱글톤 컨벤션). 미들웨어/deps 가 참조한다. tenant_registry = TenantEngineRegistry()