카드 재설계("멘트 카드 → 전술 카드"):
- tactics.py 신설: 카드번호→전술(카운터 산식) 레지스트리, min(counter,target) 클램프
- 카운터 수락=즉시 타결(pending_counter_price 일반화, 구 offer_1pct 흡수)
- 목표가 초과 타결 금지(성공스텝 진입 가드) + "카드 소진=실패" 폐지→종결 국면
- 선택형 와일드카드(WC-*) 발동 + card.wild_cards 멘트 DB 어댑터
LLM 계층:
- Phase 2 표현층 ScriptNaturalizer(카드 멘트 자연화, 마커·치환자·숫자 보존 검증)
- Phase 3 이해층 InputInterpreter(자유발화 NLU→기대입력, 한국어 가격 파서)
- OPENAI_API_KEY env override(server_configs) + 전역 자격증명 게이트
결정 스택(Phase 1):
- 협상 규칙 데이터화(negotiation.wildcard_*_ratio/max_counter_rounds)
- 선택카드 우선순위 prior(UCB 방문수 감쇠, Q-table 오염 없음)
버그픽스:
- 인하율 음수 표기 제거 + 인상/동일/인하 구분(discount_phrase)
- 자연화 강조마커 보존(볼드/색 소실 시 원본 폴백)
- 카드 시드 가격변수(prev_partner_price·target_mid_price·middle_price 등) 치환
정리:
- ktcommerce 테넌트 삭제 + 테스트 21파일 imarketkorea/_base 로 마이그레이션
- 실 LLM 호출 차단 conftest 가드
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
185 lines
8.1 KiB
Python
185 lines
8.1 KiB
Python
"""TenantConfig — 테넌트별 협상 설정 단일 소스 (계획서 설계 A).
|
||
|
||
Chat_server 에서 23곳에 하드코딩돼 있던 KT 가정(state 임계값·가중치·유통코드, reward,
|
||
policy, 카드 매핑, LLM 배포)을 한 pydantic 모델로 외부화한다. 도메인 코드는 이 config 를
|
||
주입받아 tenant-agnostic 하게 동작한다(P2).
|
||
|
||
저장 위치 (YAML + DB 하이브리드, 계획서 A):
|
||
- YAML(tenants/<id>/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<anchor 시 1+β·(anchor−P)/anchor)
|
||
success_reward: float = 1.0
|
||
ongoing_reward: float = 0.0
|
||
failure_penalty: float = -0.5
|
||
penalty_lambda: float = 0.02
|
||
# 동적 가중치 W 의 메타 가중치 (명세 v4 식(12): W_raw = Σ wᵢ·Sᵢ). 균등 기본값, 테넌트 오버라이드.
|
||
w1: float = 0.2
|
||
w2: float = 0.2
|
||
w3: float = 0.2
|
||
w4: float = 0.2
|
||
w5: float = 0.2
|
||
min_weight: float = 0.2
|
||
max_weight: float = 0.8
|
||
|
||
|
||
# ---- policy / cards / llm / resources / action_mapping ----------------------
|
||
class ActionMappingConfig(BaseModel):
|
||
"""action_id ↔ card_id 매핑. PoC 는 카드 매핑 고정(차원 정합성 리스크 회피)."""
|
||
|
||
type: str = "file" # "file" | "db"(P6 tenant_action_cards)
|
||
action_to_card: Dict[str, str] = {}
|
||
|
||
@property
|
||
def action_space_size(self) -> 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 # 라운드 상한(보조). 실제 종료는 '카드 소진' 기준.
|
||
|
||
# 결정 스택 규칙층(Phase 1) — ChatEngine 하드코딩을 테넌트별 데이터로.
|
||
wildcard_1pct_ratio: float = 1.02 # 제시가 ≤ anchor×비율 → 1% 인하 와일드카드로 마무리 유도
|
||
wildcard_entry_ratio: float = 1.05 # 선택 와일드카드 허용 시 와일드카드 진입 상한(anchor×비율)
|
||
max_counter_rounds: int = 3 # 에이전트 카운터 제안 상한(초과 시 협상실패 종료)
|
||
|
||
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/<id>/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)
|