IMK QA 2건(BB9A 카드 중복·8AB0 중간값 오계산)의 근본 원인이 전술 하드코딩(_TACTICS 번호 매칭)이라 전술을 데이터로 옮기고, 발동을 유효성 검사로 바꿨다. 전술 정본 = 카드 스크립트의 마지막 가격 변수(파싱), 문장으로 알 수 없는 운영 규칙(closing·min_round)만 card.*.tactic JSONB. 세션 시작 시 card_specs 스냅샷 박제. 발동 유효성(하나라도 걸리면 그 라운드 미발동 — 클램프 폐지): 목표가 초과 / 협력사 제시가 이상 / 당사 직전 제안 미만(역행 금지, IMK 논의) / 재료 결측 / 필수 변수 결측(시장가 카드 requires — 토큰 노출 방지) / 이미 쓴 카드(played_card_numbers 공용 이력) - agent: 와일드=비종결·종결=전용 풀 분리(같은 카드 2회 구조적 차단), 발동 시 자기 제안가 기록(절충가 수렴), 진입 존 프로브(빈 덱 재사용 교착 방지), 낼 카드 전무 시 소진→종결, 무효 금액 카드는 설득 폴백도 금지(playable), 에디터 anchor_price 별칭 등록, 에러 재렌더 변수 치환 - backend: 카드 사용 기록을 step 휴리스틱→번호 prefix 판정(종결 발동 card:null 누락 해소) - negodata: 카드 상세 "협상 전술" 섹션(제시 가격 파싱 표시·종결 전용·최소 라운드) + tactic API 배선 - postgres-init: tactic 컬럼·시드(WC-03/05 closing, WC-04 min_round 2), 멱등 alter 로 dev 정본화 (번호 WC-0x 정규화, WC-01·03·NGC-010 구멘트 교체, WC-05 변수 middle_price 교정) 검증: agent 178 통과 · 시나리오 하네스 14케이스(BB9A·8AB0·역행 실수치 재현) · 랜덤 퍼즈 50협상 불변식 위반 0 (불변식: 카드 중복 금지·종결 카드 자리·타결가≤목표가·표시가=타결가·토큰 잔존 금지·종료 보장)
187 lines
10 KiB
Python
187 lines
10 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 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 → 호출부가 카드를 건너뛴다.
|
|
|
|
변수 공통 유효조건 (전부 만족해야 발동):
|
|
· 값 ≤ 목표가 — 구매자는 목표가를 넘겨 부르지 않는다. 넘으면 클램프가 아니라 **미발동**
|
|
(목표가로 깎아 부르면 "중간에서 만나자"면서 목표가를 부르는 모순이 된다)
|
|
· 값 < 협력사 제시가 — 이미 더 싸게 받았는데 더 비싼 값을 부를 이유가 없다
|
|
· 값 ≥ 당사 직전 제안 — 역행 금지(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 > target:
|
|
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
|