o2o-negosium-original/agent/negotiation/cards/domain/tactics.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

121 lines
6.0 KiB
Python
Raw 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.

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