"""FeatureBuyer — 카드 '내용(전략)'과 협력사 '프로필'에 반응하는 시뮬 협력사 (Phase 2·3). 효과를 2축으로 분리한다(성향 조건화가 의미를 가지려면 트레이드오프가 필요): - 양보력(concession power): 이 카드가 가격을 얼마나 끌어내리는가 - 수락력(accept power) : 이 카드가 합의(수락) 확률을 얼마나 높이는가 전략별 기본 프로필(트레이드오프): 경쟁(1): 양보력↑↑ 수락력↓ — 세게 깎지만 결렬 위험 수용(2): 양보력↓ 수락력↑ 고수(3): 양보력·수락력 중간 협력(4): 양보력↓ 수락력↑↑ — 잘 성사되지만 덜 깎임 여기에 협력사 세그먼트 적합도(AFFINITY)가 곱해진다: 전략이 그 협력사에 안 맞으면 둘 다 죽는다. 소형·경쟁多 → 경쟁압박이 잘 먹힘 / 대형·단독 → 협력이 잘 먹힘(압박 역효과) → '가격 중시' 고객사는 경쟁 카드(많이 깎음, 결렬 감수), '성사 중시' 고객사는 협력 카드가 정답이 되는 구조. 에이전트는 카드 특징 + 협력사 특징 + 고객사 성향으로 이를 학습해야 한다. """ from dataclasses import dataclass from typing import Dict, Tuple import numpy as np from eval_harness.buyer import BuyerResponse, Scenario # strategy_type: 1=경쟁, 2=수용, 3=고수, 4=협력 (card.nego_cards) # 전략별 (양보력, 수락력) 기본 프로필 — 트레이드오프의 원천 STRATEGY_PROFILE: Dict[int, Tuple[float, float]] = { 1: (0.90, 0.25), # 경쟁: 세게 깎지만 성사 어려움 2: (0.35, 0.70), # 수용 3: (0.55, 0.50), # 고수: 중간 4: (0.30, 0.90), # 협력: 잘 성사되지만 덜 깎임 } # 세그먼트별 전략 적합도 m ∈ [0,1] — 전략이 그 협력사에 얼마나 '먹히는가' AFFINITY: Dict[Tuple[str, str], Dict[int, float]] = { ("small", "multi"): {1: 0.90, 2: 0.45, 3: 0.60, 4: 0.40}, # 소형·경쟁多 → 경쟁압박 ("small", "single"): {1: 0.35, 2: 0.60, 3: 0.80, 4: 0.55}, # 소형·단독 → 고수/논리 ("big", "multi"): {1: 0.65, 2: 0.50, 3: 0.70, 4: 0.60}, ("big", "single"): {1: 0.20, 2: 0.70, 3: 0.50, 4: 0.90}, # 대형·단독 → 협력 (압박 역효과) } REVENUE_BIG = 50_000_000 # state config 'high' 경계와 정합 @dataclass class SupplierProfile: """협력사 프로필 — 에피소드마다 달라지는 협상 상대. snapshot 필드와 정합.""" revenue_amount: float partner_count: int # 이 품목의 대안 협력사 수 (BATNA. 1:1 채팅이어도 다양) distribution_code: str @property def segment(self) -> Tuple[str, str]: size = "big" if self.revenue_amount > REVENUE_BIG else "small" comp = "multi" if self.partner_count >= 2 else "single" return (size, comp) def sample_supplier(rng: np.random.Generator) -> SupplierProfile: """무작위 협력사 생성 (학습 데이터 다양성).""" return SupplierProfile( revenue_amount=float(rng.choice([5_000_000, 20_000_000, 80_000_000, 200_000_000])), partner_count=int(rng.choice([1, 1, 2, 3])), # 단독 비중 높게 distribution_code=str(rng.choice(["A", "B", "C"])), ) class FeatureBuyer: """전략 프로필 x 세그먼트 적합도 기반 협력사 모델. (양보력, 수락력) 2축.""" def __init__(self, supplier: SupplierProfile, card_strategy: Dict[str, int], seed: int = 0, accept_base: float = 0.08, max_turns: int = 5, jitter: float = 0.05): self.supplier = supplier self.card_strategy = card_strategy # {card_number: strategy_type} self.rng = np.random.default_rng(seed) self.accept_base = accept_base self.max_turns = max_turns self.jitter = jitter # 숨은 하한가(reservation): 앵커의 94~110%. 앵커보다 높으면(약 60%) 가격만으로는 # 타결 불가 → 수락을 받아내야 함 → 수락력 낮은(경쟁) 카드에 진짜 결렬 위험이 생긴다. self.floor_ratio = float(self.rng.uniform(0.94, 1.10)) def powers(self, card_number: str) -> Tuple[float, float]: """숨은 (양보력, 수락력). 전략 프로필 × 세그먼트 적합도 + 카드별 결정론적 지터.""" strat = self.card_strategy.get(card_number, 3) conc_base, acc_base = STRATEGY_PROFILE.get(strat, (0.5, 0.5)) m = AFFINITY[self.supplier.segment].get(strat, 0.5) scale = 0.35 + 0.85 * m # 적합도: 안 맞으면 둘 다 죽음 (0.35~1.2) j = (hash(card_number) % 1000 / 1000.0 - 0.5) * 2 * self.jitter c_pow = float(np.clip(conc_base * scale + j, 0.02, 0.98)) a_pow = float(np.clip(acc_base * scale + j, 0.02, 0.98)) return c_pow, a_pow def respond(self, card_number: str, scenario: Scenario, turn: int, current_price: float) -> BuyerResponse: c_pow, a_pow = self.powers(card_number) floor = scenario.anchor_price * self.floor_ratio # 숨은 하한가 (앵커 이하 보장 없음) concession = (current_price - floor) * (0.10 + 0.55 * c_pow) new_price = max(floor, current_price - concession) p_accept = min(0.97, self.accept_base + 0.80 * a_pow + 0.05 * (turn - 1)) accept = bool(self.rng.random() < p_accept) walked = (not accept) and (turn >= self.max_turns) return BuyerResponse(accept=accept, new_price=new_price, walked=walked)