원인 두 겹.
1) 절충 계열 가드가 타결 상한만 봐서, 중간가((앵커+목표)/2)처럼 구조상 목표가를 넘지 못하는
값은 항상 통과했다. 앵커가 목표가에 붙으면 그 값이 곧 목표가라 목표가로 제시된다.
→ 파생 제안값이 목표가 이상이면 미발동(compute_offer). 목표가 카드는 영향 없음.
2) 제안가 확정 시 계산에 쓴 당사 직전가를 남기지 않고 그 자리를 제안가로 덮은 뒤, 멘트 렌더가
갱신된 값으로 재계산해 "당사 A 와 귀사 B 의 절반은 A" 자기모순이 났다. 직전 커밋은 갱신을
렌더 뒤로 미뤘으나 와일드카드는 렌더 지점이 더 뒤라 그대로 남아 있었다.
→ Offer/record_offer 로 금액과 재료(당사 직전가·협력사 제시가)를 함께 박제하고, vars_for 는
재계산 대신 그 기록을 읽는다. 렌더 순서에 의존하지 않으므로 직전 커밋의 순서 조정은 제거.
→ 산식은 tactics.OFFER_VARIABLES 한 곳만 남기고 chat_engine 의 복사본 삭제.
곁들여: 카드 설정에서 '적용 대기'로 꺼둔 와일드카드가 견적에 담겨 있으면 그대로 발동하던 것을
차단(wild_cards.available 조건). 테이블 정의에 빠져 있던 컬럼도 보강.
검증: 단위/E2E 35건, 실협상 8시나리오(멘트 토큰 노출·절반 산술 정합·카드 중복·제안가 역행·
상한 초과 자동 검사) 통과.
241 lines
13 KiB
Python
241 lines
13 KiB
Python
"""협상카드 전술 — "스크립트에 꽂힌 변수가 곧 전술" 계층.
|
||
|
||
카드 멘트가 제시하는 가격({target_price}·{middle_price} 등)을 파싱해 시스템 상태로 실행한다:
|
||
카드가 제안가를 제시하면 pending_counter_price 로 적재되고, 협력사가 수락하면 그 가격으로 타결된다.
|
||
|
||
세 계층으로 나뉜다.
|
||
1) 스크립트 파싱 — 이 카드가 부를 금액이 무엇인지 (parse_offer_variable)
|
||
2) 변수 정의 — 그 금액을 지금 쓸 수 있는지 (OFFER_VARIABLES 의 계산식 + 유효조건)
|
||
3) tactic JSONB — 문장으로 알 수 없는 운영 규칙 (min_round·closing)
|
||
|
||
유효 조건은 카드가 아니라 '변수'에 붙인다 — 금액이 성립하는지는 계산식의 성질이지 카드의
|
||
성질이 아니다. 새 변수는 OFFER_VARIABLES 에 한 줄 추가하면 코드 분기 없이 끝난다.
|
||
"""
|
||
|
||
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_]+)\}")
|
||
|
||
# 절충 계열 변수 — 양측 사이/우리 두 값 사이의 중간을 부르는 카드. 목표가 이상이면 미발동한다.
|
||
_MID_VARIABLES = ("middle_price", "target_mid_price")
|
||
|
||
|
||
# 세션 데이터에 따라 값이 없을 수 있는 읽기 전용 변수 → 그 값을 담는 컨텍스트 키.
|
||
# 스크립트가 이런 변수를 인용하면 값이 있을 때만 카드가 나간다 — 없는데 나가면 협력사 채팅에
|
||
# {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 + 타결상한율)) — 목표가를 조금
|
||
넘더라도 기존 단가보다 인하됐으면 타결시키기 위한 값. 박제가 없으면 목표가로 폴백한다.
|
||
"""
|
||
return float(context.get("done_ceiling_price") or context.get("target_price") or 0)
|
||
|
||
|
||
def parse_offer_variable(script: Optional[str]) -> Optional[str]:
|
||
"""스크립트가 제시하는 제안가 변수. 없으면 None(설득 카드).
|
||
|
||
변수가 여럿이면 마지막에 등장하는 것이 제안가다 — 카드 문장은 배경을 먼저 깔고 실제 제안을
|
||
마지막에 하기 때문이다.
|
||
"""
|
||
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 ()),
|
||
)
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class Offer:
|
||
"""확정된 제안 한 건 — 금액과 그 금액을 만든 재료를 함께 들고 다닌다.
|
||
|
||
멘트 치환이 재료를 다시 계산하지 않게 하기 위한 것 — 재계산하면 그 사이 갱신된
|
||
prev_customer 를 읽어 문장이 자기모순이 된다.
|
||
"""
|
||
|
||
price: int # 협력사에게 제시할 금액(수락 시 타결가)
|
||
variable: str # 이 금액을 만든 멘트 변수
|
||
prev_customer: int # 계산에 쓴 당사 직전 제안
|
||
prev_partner: int # 계산에 쓴 협력사 제시가
|
||
|
||
|
||
def record_offer(context: Dict[str, Any], offer: Offer) -> None:
|
||
"""확정 제안을 세션에 기록한다 — 수락 판정용 금액과 멘트 치환용 재료를 한 자리에서 쓴다.
|
||
|
||
두 키를 항상 함께 써야 표시가와 타결가가 갈라지지 않으므로 기록 지점을 여기 하나로 묶는다.
|
||
"""
|
||
context["pending_counter_price"] = offer.price
|
||
context["pending_offer"] = {
|
||
"price": offer.price, "variable": offer.variable,
|
||
"prev_customer": offer.prev_customer, "prev_partner": offer.prev_partner,
|
||
}
|
||
context["prev_customer_price"] = offer.price # 갑의 최신 포지션 — 다음 라운드 계산·역행 금지 기준
|
||
|
||
|
||
def compute_offer_detail(spec: CardSpec, context: Dict[str, Any]) -> Optional[Offer]:
|
||
"""카드가 제시할 금액 + 그 계산에 쓴 재료. 쓸 수 없는 상황이면 None."""
|
||
price = int(float(context.get("input_price") or 0))
|
||
prev_customer = int(float(context.get("prev_customer_price") or context.get("anchor_price") or 0))
|
||
value = compute_offer(spec, context)
|
||
if value is None:
|
||
return None
|
||
return Offer(price=value, variable=spec.offer_variable or "", prev_customer=prev_customer, prev_partner=price)
|
||
|
||
|
||
def compute_offer(spec: CardSpec, context: Dict[str, Any]) -> Optional[int]:
|
||
"""카드가 제시할 금액. 쓸 수 없는 상황이면 None → 호출부가 카드를 건너뛴다.
|
||
|
||
변수 공통 유효조건 (전부 만족해야 발동):
|
||
· 값 ≤ 타결 상한가 — 받아줄 수 없는 금액은 부르지 않는다. 넘으면 깎지 않고 미발동
|
||
· 값 < 협력사 제시가 — 이미 더 싸게 받았는데 더 비싼 값을 부를 이유가 없다
|
||
· 값 ≥ 당사 직전 제안 — 역행 금지. 제안 시퀀스는 앵커→…→목표가로 단조 수렴해야 한다
|
||
"""
|
||
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 _MID_VARIABLES and value >= target:
|
||
# 절충 계열은 목표가 미만일 때만 의미가 있다. 목표가 이상이면 "절반씩 나누자"면서 목표가를
|
||
# 부르는 꼴이라 미발동 — 목표가 제시는 목표가 카드(최후통첩)가 할 일이다.
|
||
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 있음)는 그 금액을 못 부르는 상황이면 설득 폴백으로도
|
||
내보내지 않는다 — 멘트에 무효한 금액(직전 제안보다 낮은 앵커, 제시가보다 높은 목표가)이
|
||
글자로 박혀 나가 역행/모순 서사가 되기 때문. 설득 카드는 금액이 없으니 무관.
|
||
"""
|
||
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
|