카드 재설계("멘트 카드 → 전술 카드"):
- 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>
121 lines
6.0 KiB
Python
121 lines
6.0 KiB
Python
"""협상카드 전술 레지스트리 — "멘트 카드 → 전술 카드" 승격 (가격 행동 실행 계층).
|
||
|
||
카드 멘트에 이미 설계된 카운터 가격 제시({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
|