"""협상카드 전술 레지스트리 — "멘트 카드 → 전술 카드" 승격 (가격 행동 실행 계층). 카드 멘트에 이미 설계된 카운터 가격 제시({target_price}·{middle_price} 등)를 시스템 상태로 실행한다: 카드가 카운터가를 제시하면 pending_counter_price 로 적재되고, 협력사가 수락하면 그 가격으로 즉시 타결된다(기존 wild_card_1pct 의 offer_1pct 패턴을 일반화). 원칙: - 구매자(갑) 대리이므로 카운터는 항상 min(counter, target_price) 클램프 — 목표가 초과 제시 금지. - 협력사 제시가가 이미 카운터 이하면 카운터가 무의미 → None(HOLD 강등, 순수 설득). - 미등록 카드번호(테넌트 데모 NGC-B*, 회사 커스텀 COMP-* 등)는 HOLD 폴백 → 기존 동작 그대로. 전술 정본은 이 코드 레지스트리다(v1). negodata 카드 편집은 멘트만 담당하고, 전술을 negodata 에서 편집할 필요가 생기면 v2 에서 card.nego_cards 컬럼로 승격해 "DB 우선, 코드 폴백"으로 바꾼다. """ from dataclasses import dataclass from enum import Enum from typing import Any, Dict, Optional class PriceAction(str, Enum): HOLD = "hold" # 카운터 없음 — 재제안 요구(순수 설득, 기존 동작) COUNTER_TARGET = "counter_target" # 목표가 제시 COUNTER_ANCHOR = "counter_anchor" # 앵커가 제시 (예산 상한 프레이밍) COUNTER_TARGET_MID = "counter_target_mid" # (anchor+target)/2 — 시드 {target_mid_price} COUNTER_MID = "counter_mid" # (갑 직전 포지션+협력사 제시가)/2 — 시드 {middle_price} ONE_PCT = "one_pct" # 제시가 1% 인하 (기존 offer_1pct) @dataclass(frozen=True) class TacticSpec: """카드 1장의 전술 명세. min_round: 발동 가능 최소 라운드(협력사 가격 입력 횟수 기준). max_price_ratio: input_price ≤ anchor×ratio 일 때만 발동 (None=무제한). closing: 종결 국면(라운드 만료·카드 소진) 우선 전술. """ price_action: PriceAction = PriceAction.HOLD min_round: int = 1 max_price_ratio: Optional[float] = None closing: bool = False _DEFAULT = TacticSpec() # HOLD — 미등록 카드 폴백 # 카드번호 → 전술. 시드(init-data.sql) 멘트의 가격 변수와 1:1 정합. # NGC-001~006: 순수 설득(경쟁 압박/승인 핑계/관계/명분/공정성/TCO) — 가격 변수 없음. # NGC-008: {internet_lowest_price} 인용이나 데이터 소스 미보유 → v1 HOLD (소스 확보 시 승격). _TACTICS: Dict[str, TacticSpec] = { "NGC-007": TacticSpec(PriceAction.COUNTER_ANCHOR), # 예산 상한 안내 "NGC-009": TacticSpec(PriceAction.COUNTER_TARGET), # 조건부 가격 조정 "NGC-010": TacticSpec(PriceAction.COUNTER_TARGET), # 향후 거래 연계 "NGC-011": TacticSpec(PriceAction.COUNTER_TARGET), # 양보 가치 강조 "WC-01": TacticSpec(PriceAction.COUNTER_TARGET, min_round=1), # 목표가 선제안 "WC-02": TacticSpec(PriceAction.COUNTER_TARGET_MID), # 역제안가 제시 "WC-03": TacticSpec(PriceAction.COUNTER_TARGET, closing=True), # 최종 통보(최후통첩) "WC-04": TacticSpec(PriceAction.COUNTER_TARGET, min_round=2), # 단계적 인하 제안 "WC-05": TacticSpec(PriceAction.COUNTER_MID, closing=True), # 중간값 절충(종결) } def tactic_for(card_number: Optional[str]) -> TacticSpec: """카드번호의 전술. 미등록/None 은 HOLD(기존 동작).""" return _TACTICS.get(str(card_number), _DEFAULT) if card_number else _DEFAULT def tactic_available(spec: TacticSpec, context: Dict[str, Any]) -> bool: """발동 조건 평가 — action space 마스킹용. HOLD(설득)는 언제나 가능.""" if spec.price_action is PriceAction.HOLD: return True rnd = int(context.get("round") or 0) if rnd < spec.min_round: return False if spec.max_price_ratio is not None: price = float(context.get("input_price") or 0) anchor = float(context.get("anchor_price") or 0) if anchor > 0 and price > anchor * spec.max_price_ratio: return False return True def compute_counter(spec: TacticSpec, context: Dict[str, Any]) -> Optional[int]: """전술의 카운터 제시가 계산 (결정론). - 항상 min(counter, target) 클램프 — 구매자는 목표가 초과로 제시하지 않는다. - counter ≥ 협력사 제시가(input_price)면 카운터가 무의미(이미 더 싸게 제시받음) → None. - 필요한 컨텍스트(target/anchor/제시가)가 없으면 None → 호출부가 HOLD 로 강등. """ action = spec.price_action if action is PriceAction.HOLD: return None target = float(context.get("target_price") or 0) anchor = float(context.get("anchor_price") or 0) price = float(context.get("input_price") or 0) if target <= 0 or price <= 0: return None if action is PriceAction.COUNTER_TARGET: counter = target elif action is PriceAction.COUNTER_ANCHOR: counter = anchor elif action is PriceAction.COUNTER_TARGET_MID: counter = (anchor + target) / 2 if anchor > 0 else target elif action is PriceAction.COUNTER_MID: # 갑의 직전 포지션(직전 카운터). 첫 카운터 전에는 앵커가 갑의 포지션이다. prev_customer = float(context.get("prev_customer_price") or anchor or target) counter = (prev_customer + price) / 2 elif action is PriceAction.ONE_PCT: counter = price * 0.99 else: return None if counter <= 0: return None counter = min(counter, target) # 목표가 초과 제시 금지 (가드레일) counter_i = int(round(counter)) if counter_i >= price: return None # 제시가가 이미 카운터 이하 → 카운터 무의미 return counter_i