"""TenantConfig — 테넌트별 협상 설정 단일 소스 (계획서 설계 A). Chat_server 에서 23곳에 하드코딩돼 있던 KT 가정(state 임계값·가중치·유통코드, reward, policy, 카드 매핑, LLM 배포)을 한 pydantic 모델로 외부화한다. 도메인 코드는 이 config 를 주입받아 tenant-agnostic 하게 동작한다(P2). 저장 위치 (YAML + DB 하이브리드, 계획서 A): - YAML(tenants//tenant.yaml, git 형상관리): state·reward·policy·language·resource 경로. - env/시크릿(llm.api_key_ref): LLM api_key (YAML 평문 금지). - DB: action_to_card 매핑(P6 tenant_action_cards), 활성 Q-Table 버전 포인터(P5). 테넌트 식별자는 company.companies.company_id(uuid)에 매핑된다. 공유 베이스는 예약어 "_base". """ from typing import Any, Dict, List, Optional from pydantic import BaseModel, Field # ---- state: 이산 상태 차원 정의 ------------------------------------------- # 모든 기본값은 우리 플랫폼의 중립 데모 기본값이다(CLEANROOM.md). 특정 고객의 튜닝값을 # 복제하지 않으며, 실제 운영값은 테넌트 YAML/DB 에서 주입한다. 차원 구성(개수)은 기능적 # 설계이고, 값/라벨은 우리 자체 선택이다. class RevenueConfig(BaseModel): """매출 가격구간. thresholds[i] 이하이면 i번째 구간. 마지막 구간은 초과분.""" thresholds: List[float] = [10_000_000, 50_000_000] # 플랫폼 중립 기본값 weights: List[float] = [0.3, 0.6, 1.0] descriptions: List[str] = ["low", "mid", "high"] class DistributionConfig(BaseModel): """유통 구조. code_map: 테넌트 외부 코드 → 구간 인덱스(테넌트가 자사 코드로 정의).""" code_map: Dict[str, int] = {"A": 0, "B": 1, "C": 2} # 중립 예시 코드(테넌트가 오버라이드) weights: List[float] = [0.3, 0.6, 1.0] descriptions: List[str] = ["channel_a", "channel_b", "channel_c"] class PartnerConfig(BaseModel): """파트너사 수 → 구간. count==0:none, ==1:single, >=2:multiple. 인덱스 규약: SINGLE=0, MULTIPLE=1, NONE=2. """ weights: List[float] = [0.5, 1.0, 0.3] # single, multiple, none descriptions: List[str] = ["single", "multiple", "none"] class AcceptanceConfig(BaseModel): """가격 수용률 구간(0~1). < thresholds[0]: low, <= thresholds[1]: mid, else high.""" thresholds: List[float] = [0.03, 0.09] weights: List[float] = [0.3, 0.6, 1.0] descriptions: List[str] = ["low", "mid", "high"] class PriceZoneConfig(BaseModel): """입력가격 구간 (KT 구매자: anchor 앵커링가 < target 목표 매입가). zone0: 제시가 ≤ anchor(우선협상 = 즉시 타결), zone1: > anchor(협상 지속).""" weights: List[float] = [1.0, 0.5] # at_or_below_anchor, above_anchor descriptions: List[str] = ["at_or_below_anchor", "above_anchor"] class StateConfig(BaseModel): revenue: RevenueConfig = Field(default_factory=RevenueConfig) distribution: DistributionConfig = Field(default_factory=DistributionConfig) partner: PartnerConfig = Field(default_factory=PartnerConfig) acceptance: AcceptanceConfig = Field(default_factory=AcceptanceConfig) price_zone: PriceZoneConfig = Field(default_factory=PriceZoneConfig) @property def state_space_size(self) -> int: """차원 곱으로 자동 산출 (KT: 3×3×3×3×2 = 162). 회사별 차원이 다르면 값이 달라진다. → warm-start 차원 호환 체크의 기준이 된다(P5). """ revenue_dim = len(self.revenue.weights) dist_dim = len(self.distribution.weights) partner_dim = len(self.partner.weights) accept_dim = len(self.acceptance.weights) price_dim = len(self.price_zone.weights) return revenue_dim * dist_dim * partner_dim * accept_dim * price_dim # ---- reward: 보상 공식 파라미터 (공식 형태는 기능적, 값은 우리 자체 중립 기본값) ---------- class RewardConfig(BaseModel): """보상 계산 파라미터. 필드 구성은 P2 RewardCalculator 주입용이며, 기본값은 플랫폼 중립값(균등 가중치)이다 — 특정 고객 튜닝값 복제 아님(CLEANROOM.md). """ beta: float = 0.2 # 앵커 초과달성 보너스 계수 (명세 v4 식(9): P int: return len(self.action_to_card) class PolicyConfig(BaseModel): """정책 종류 + 하이퍼파라미터. params 는 알고리즘별 불투명 dict(LinUCB/CQL 등).""" type: str = "ucb" # ucb | linucb | cql (eval_harness registry 키) learning_rate: float = 0.1 gamma: float = 0.95 # discount_factor params: Dict[str, Any] = {"exploration_constant": 1.4142135623730951, "epsilon": 1e-6} class CardsConfig(BaseModel): source_type: str = "file" # file | backoffice_db (card.* 스키마) sync_interval_seconds: int = 300 connection: Dict[str, Any] = {} class LlmConfig(BaseModel): enabled: bool = False endpoint: Optional[str] = None deployment: Optional[str] = None api_version: Optional[str] = None api_key_ref: Optional[str] = None # env 변수명 (평문 금지). P7 에서 LlmCredentials 로 해석. class NegotiationConfig(BaseModel): """협상 가격 정책. 앵커링값은 목표가에서 자동 산출한다(KT 구매자: anchor < target). anchor = round(target * (1 - anchor_rate)). 예) target=10000, rate=0.01 → anchor=9900. 협력사 제시가 ≤ anchor → 우선협상. """ anchor_rate: float = 0.01 # 목표가 대비 앵커링 인하율 (기본 1%) max_rounds: int = 5 # 라운드 상한(보조). 실제 종료는 '카드 소진' 기준. def anchor_for(self, target_price: float) -> float: return round(target_price * (1.0 - self.anchor_rate)) class ResourcesConfig(BaseModel): language: str = "ko" scripts_dir: str = "resources" # tenants//resources/ (없으면 _base/resources/ 폴백) variable_mapping: Optional[str] = None # 스크립트 템플릿의 브랜드 치환값 (클린룸: 특정사 브랜드 대신 테넌트별 주입). company_name: str = "당사" service_name: str = "Negosium" class TenantConfig(BaseModel): """한 테넌트의 협상 설정 전체.""" tenant_id: str company_id: Optional[str] = None # company.companies.company_id (uuid). _base 는 None. name: str = "" inherits_base: bool = True state: StateConfig = Field(default_factory=StateConfig) reward: RewardConfig = Field(default_factory=RewardConfig) negotiation: NegotiationConfig = Field(default_factory=NegotiationConfig) action_mapping: ActionMappingConfig = Field(default_factory=ActionMappingConfig) policy: PolicyConfig = Field(default_factory=PolicyConfig) cards: CardsConfig = Field(default_factory=CardsConfig) llm: LlmConfig = Field(default_factory=LlmConfig) resources: ResourcesConfig = Field(default_factory=ResourcesConfig)