o2o-negosium-original/agent/tenancy/config.py
hbyang 1682481b01 [feat] agent: 협상 고도화 — LLM 표현/이해층 + 카드 전술 실행계층 + ktcommerce 정리
카드 재설계("멘트 카드 → 전술 카드"):
- 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>
2026-07-10 13:37:36 +09:00

185 lines
8.1 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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