"""협상카드 전술 — "스크립트에 꽂힌 변수가 곧 전술" 계층. 카드 멘트가 제시하는 가격({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