목표가를 1원이라도 넘으면 결렬되던 탓에, 기존 단가보다 인하됐는데도 결렬되는 케이스가 있었다 (EST-202607-973E: 기존 17,500 / 목표 16,980 / 최종 17,300). 견적 생성 시 세션에 박제해 두던 done_ceiling_price(목표가×(1+타결상한율), 세팅 기본 +5%)를 협상 엔진이 실제로 읽게 배선했다. - tactics: settle_ceiling() 신설 — 타결선 판정을 한 곳으로. 박제가 없는 옛 세션·데모는 목표가 폴백 - 카드 제안가 유효조건의 상한도 목표가 → 타결 상한가 (받아줄 수 있는 금액까지는 부를 수 있어야 함) - _render 가드레일이 목표가 초과 성공을 결렬로 되돌리고 있어 같이 상한 기준으로 교정 — 타결 판정만 고치면 이 가드에서 다시 뒤집혀, 배선했는데도 결렬로 떨어졌다 - crud/loader/세션 컨텍스트에 done_ceiling_price 적재 검증(목표가 956,580 · 상한 1,004,410): 950,000·1,000,000·1,004,410 타결 / 1,004,500·1,010,000 결렬. agent 테스트 178건 통과.
198 lines
11 KiB
Python
198 lines
11 KiB
Python
"""협상카드 전술 — "스크립트에 꽂힌 변수가 곧 전술" 계층.
|
||
|
||
카드 멘트가 제시하는 가격({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 # 타결 상한 초과 — 받아줄 수 없는 금액이라 지금 못 쓴다
|
||
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
|