o2o-negosium-original/agent/negotiation/cards/domain/tactics.py

203 lines
11 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 로 적재되고, 협력사가 수락하면 그 가격으로 타결된다.
세 계층으로 나뉜다.
1) 스크립트 파싱 — 이 카드가 부를 금액이 무엇인지 (parse_offer_variable)
2) 변수 정의 — 그 금액을 지금 쓸 수 있는지 (OFFER_VARIABLES 의 계산식 + 유효조건)
3) tactic JSONB — 문장으로 알 수 없는 운영 규칙 (min_round·closing)
유효 조건을 카드가 아니라 '변수'에 붙이는 이유: 절충가가 목표가를 넘을 수 있는 것은
(직전제안+제시가)/2 라는 계산식의 성질이지 특정 카드의 성질이 아니다. 같은 변수를 쓰는
카드가 늘어도 규칙은 한 곳이고, 새 변수는 이 표에 한 줄 추가하면 코드 분기 없이 끝난다.
"""
import re
from dataclasses import dataclass
from typing import Any, Callable, Dict, Optional
# 제안가 변수 — 우리가 새로 부르는 금액. 값은 (계산식, 재료 설명).
# 여기 없는 치환 변수({prev_partner_price}·{internet_lowest_price} 등)는 읽어주기 전용이라
# 제안가가 되지 않는다 — 과거값·외부값을 협력사에게 "수락하라"고 내밀 수 없기 때문.
OFFER_VARIABLES: Dict[str, Callable[[float, float, float, float], Optional[float]]] = {
# (target, anchor, price, prev_customer) -> 제안가 | None(재료 없음)
"target_price": lambda target, anchor, price, prev: target,
"anchoring_price": lambda target, anchor, price, prev: anchor or None,
# negodata 카드 에디터 칩 표기(variables.ts) — DB 시드 표기(anchoring_price)와 같은 값의 별칭.
"anchor_price": lambda target, anchor, price, prev: anchor or None,
"target_mid_price": lambda target, anchor, price, prev: (anchor + target) / 2 if anchor else None,
"middle_price": lambda target, anchor, price, prev: (prev + price) / 2 if prev else None,
}
_TOKEN_RE = re.compile(r"\{([a-z_]+)\}")
# 세션 데이터에 따라 값이 없을 수 있는 읽기 전용 변수 → 그 값을 담는 컨텍스트 키.
# 스크립트가 이런 변수를 인용하면 값이 있을 때만 카드가 나간다 — 없는데 나가면 협력사 채팅에
# {internet_lowest_price} 토큰이 원형 노출된다(vars_for 가 미수집이면 키를 안 만드는 것과 짝).
# 견적 생성 화면 게이팅(useCardGating)이 1차 방어, 여기가 2차(런타임) 방어다.
_CONTEXT_REQUIRED_VARIABLES = {
"internet_lowest_price": "internet_lowest_price",
"internet_min_price": "internet_lowest_price",
}
@dataclass(frozen=True)
class CardSpec:
"""카드 1장의 전술. 스크립트 파싱 결과 + tactic JSONB 를 합친 값.
offer_variable: 이 카드가 제시할 금액의 변수명. None 이면 순수 설득 카드(HOLD).
min_round: 발동 가능 최소 라운드(협력사 가격 입력 횟수 기준).
closing: 종결 국면 전용 — 라운드 상한·카드 소진 시의 마지막 한 방으로만 쓴다.
requires: 스크립트가 인용한 세션-의존 변수의 컨텍스트 키 — 값이 없으면 미발동(토큰 노출 방지).
"""
offer_variable: Optional[str] = None
min_round: int = 1
closing: bool = False
requires: tuple = ()
HOLD = CardSpec() # 스펙을 못 찾은 카드(테넌트 데모·회사 커스텀)의 폴백 — 기존 동작(설득만) 유지
def settle_ceiling(context: Dict[str, Any]) -> float:
"""이 협상에서 받아줄 수 있는 최고가 — 타결 판정선이자 카드 제안가의 상한.
견적 생성 시 세션에 박제한 done_ceiling_price(= 목표가 × (1 + 타결상한율)). 목표가를 조금
넘더라도 기존 단가보다 인하됐으면 타결시키기 위한 값이다(IMK: 기존 17,500 / 목표 16,980 /
최종 17,300 이 결렬되던 케이스). 박제가 없는 옛 세션·데모는 목표가로 폴백 — 종전 동작 유지.
"""
return float(context.get("done_ceiling_price") or context.get("target_price") or 0)
def parse_offer_variable(script: Optional[str]) -> Optional[str]:
"""스크립트가 제시하는 제안가 변수. 없으면 None(설득 카드).
제안가 변수가 여럿이면 **마지막에 등장하는 것**이 제안가다 — 카드 문장은 배경을 먼저 깔고
(예: "당초 검토한 적정가는 {anchoring_price}원이었으나") 실제 제안을 마지막에 하기 때문이다
(예: "이에 {target_price}원으로 조정하여 제안 드립니다").
"""
found = [m.group(1) for m in _TOKEN_RE.finditer(script or "") if m.group(1) in OFFER_VARIABLES]
return found[-1] if found else None
def build_card_spec(script: Optional[str], tactic: Optional[dict] = None) -> CardSpec:
"""스크립트 + tactic JSONB → CardSpec. tactic 이 비어 있으면 전부 기본값."""
t = tactic or {}
cited = {m.group(1) for m in _TOKEN_RE.finditer(script or "")}
return CardSpec(
# 파싱이 정본 카드 전부를 맞히므로 offer_variable 은 예외 카드용 override 로만 둔다.
offer_variable=t.get("offer_variable") or parse_offer_variable(script),
min_round=int(t.get("min_round") or 1),
closing=bool(t.get("closing")),
requires=tuple(sorted({_CONTEXT_REQUIRED_VARIABLES[v] for v in cited if v in _CONTEXT_REQUIRED_VARIABLES})),
)
def spec_from_context(context: Dict[str, Any], number: Optional[str]) -> CardSpec:
"""세션 컨텍스트에 적재된 카드 스펙(card_specs)에서 꺼낸다. 없으면 HOLD 폴백.
스펙은 협상 시작 시 1회 적재된다(negotiation_context_loader) — 진행 중인 협상은
카드 멘트가 도중에 바뀌어도 시작 시점 전술로 끝까지 간다.
"""
if not number:
return HOLD
raw = (context.get("card_specs") or {}).get(str(number))
if not raw:
return HOLD
return CardSpec(
offer_variable=raw.get("offer_variable"),
min_round=int(raw.get("min_round") or 1),
closing=bool(raw.get("closing")),
requires=tuple(raw.get("requires") or ()),
)
def compute_offer(spec: CardSpec, context: Dict[str, Any]) -> Optional[int]:
"""카드가 제시할 금액. 쓸 수 없는 상황이면 None → 호출부가 카드를 건너뛴다.
변수 공통 유효조건 (전부 만족해야 발동):
· 값 ≤ 타결 상한가 — 구매자는 받아줄 수 없는 금액을 부르지 않는다. 넘으면 클램프가 아니라
**미발동**(깎아 부르면 "중간에서 만나자"면서 상한을 부르는 모순이 된다).
상한은 견적 생성 시 박제한 done_ceiling_price(목표가×(1+율)), 없으면 목표가.
· 값 < 협력사 제시가 — 이미 더 싸게 받았는데 더 비싼 값을 부를 이유가 없다
· 값 ≥ 당사 직전 제안 — 역행 금지(IMK 논의). 16,980을 불러놓고 16,810(앵커)을 부르면 협상이
좁혀지지 않고 되돌아간다. 제안 시퀀스는 앵커→…→목표가로 단조 수렴해야 한다
"""
variable = spec.offer_variable
if not variable:
return None # 설득 카드 — 제시할 금액 없음
calc = OFFER_VARIABLES.get(variable)
if calc is None:
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)
# 갑의 직전 포지션. 첫 카운터 전에는 앵커가 갑의 포지션이다.
prev_customer = float(context.get("prev_customer_price") or anchor or 0)
if target <= 0 or price <= 0:
return None # 목표가·제시가 없이는 어떤 변수도 판정 불가
value = calc(target, anchor, price, prev_customer)
if not value or value <= 0:
return None # 재료 부족(앵커 미박제·직전 제안 없음)
if value > settle_ceiling(context):
return None # 타결 상한 초과 — 받아줄 수 없는 금액이라 지금 못 쓴다
if variable in ("target_price", "anchoring_price", "anchor_price"):
# 원값 인용 변수 — 멘트엔 {target_price} 등 저장 원값이 그대로 나가므로, 반올림하면
# 표시가≠타결가 미스매치가 난다(목표가 7652 멘트 → 7650 타결). 저장값 그대로 제시.
offer = int(value)
else:
offer = int(value / 10 + 0.5) * 10 # 파생가(절충·중간) 10원 반올림 — 앵커·목표가 산정과 표기 통일
if offer >= price:
return None # 제시가가 이미 그 값 이하 → 부를 이유 없음
if prev_customer and offer < prev_customer:
return None # 역행 금지 — 한번 부른 금액 아래로 되돌아가지 않는다(같은 금액 재제시는 허용)
return offer
def available(spec: CardSpec, context: Dict[str, Any], *, closing_phase: bool = False) -> bool:
"""지금 이 카드를 꺼낼 수 있는지 — 금액과 무관한 조건들.
· 이미 쓴 카드는 다시 안 나간다(전 카드 공통 규칙 — 협상카드/와일드카드 구분 없음)
· 종결 전용 카드는 종결 국면에서만, 종결 국면에선 종결 전용 카드만
· min_round 미만이면 아직 이르다
· 스크립트가 인용한 세션-의존 변수(인터넷 최저가 등)가 결측이면 미발동 — 토큰 원형 노출 방지
"""
if spec.closing != closing_phase:
return False
if int(context.get("round") or 0) < spec.min_round:
return False
return all(context.get(key) for key in spec.requires)
def playable(spec: CardSpec, context: Dict[str, Any], *, closing_phase: bool = False) -> bool:
"""이 카드를 지금 실제로 플레이할 수 있는지 — available + (금액 카드는) 제안가 유효까지.
금액을 인용하는 카드(offer_variable 있음)는 그 금액을 못 부르는 상황이면 설득 폴백으로도
내보내지 않는다 — 멘트에 무효한 금액(직전 제안보다 낮은 앵커, 제시가보다 높은 목표가)이
글자로 박혀 나가 역행/모순 서사가 되기 때문(IMK 역행 논의). 설득 카드는 금액이 없으니 무관.
"""
if not available(spec, context, closing_phase=closing_phase):
return False
if not spec.offer_variable:
return True
return compute_offer(spec, context) is not None
def is_played(context: Dict[str, Any], number: Optional[str]) -> bool:
"""이 카드를 이 협상에서 이미 썼는지. 와일드 진입·종결·협상카드가 같은 이력을 본다."""
return bool(number) and str(number) in (context.get("played_card_numbers") or [])
def mark_played(context: Dict[str, Any], number: Optional[str]) -> None:
"""카드를 실제로 내보낸 시점에 이력에 남긴다(노출되지 않은 후보는 남기지 않는다)."""
if not number:
return
played = list(context.get("played_card_numbers") or [])
if str(number) not in played:
played.append(str(number))
context["played_card_numbers"] = played