o2o-negosium-original/agent/tenancy/registry.py
hbyang 18020a9e07 [feat] 카드 카탈로그 DB 정본화 — config 결합 제거 + 학습 보존 자동반영
카드 카탈로그(negodata)가 Q-table action space 를 정의하는 정본이 되고, 카드 변경이
config 수정·학습 손실 없이 agent 에 자동 반영되는 고리를 완성.

- action space 정리: 카탈로그 전체(NGC-001~011, 11장) 고정, 견적별 선택은 축소가 아니라
  available_mask(_selection_mask) 로 처리 — action_id↔카드 대응을 견적마다 일정하게 유지해
  Q-table 학습 일관성 보장. 구 인덱스 방식(selected[action_id]) 폐기.
- ① 카탈로그 DB 정본화: action_mapping.type=db 면 registry 가 card.nego_cards(user_id NULL,
  number 순) 조회로 action_to_card 동적 구성(파일은 폴백). port/adapter(card_catalog_*).
  _base=type:db. → negodata 카드 추가/삭제 시 config 수정 불필요.
- ② 차원 변경 학습 보존 마이그레이션: migrate_active_version_dim — 겹치는 셀 복사
  (append/truncate 안전) + 새 카드 fresh. model_store.load 가 차원 불일치 시 호출.
- ③ reload 엔드포인트: /v1/catalog-refresh(테넌트) · /v1/catalog-refresh-all(전역, 화이트리스트).
- ④ 브랜드: company_profile_repo — 자동 온보딩 고객사(company_id UUID)는
  company.companies.name 으로 {company_name} 채움. 데모 테넌트는 파일 유지.
- 크로스서비스: negodata card_service 가 공용 nego 카드 변경 시 agent_notify 로 전역 리로드 알림
  (best-effort, is_test skip). config 에 agent_base_url.
- 하니스 episodes 400→600(action 11 수렴). 테스트 갱신·추가로 agent 98/98.

알려진 갭(후속): per-company 카탈로그 스코프(회사 카드도 action space 포함), 카탈로그 중간
삭제 시 카드번호 기반 마이그레이션.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 14:56:28 +09:00

152 lines
7.0 KiB
Python

"""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()