o2o-negosium-original/agent/negotiation/cards/domain/tactics.py
Mina Choi 436384fa36 [fix] agent: 중간값 카드가 목표가로 제시되던 문제 — 제안가 산식 일원화·절충 카드 목표가 이상 미발동·적용 대기 와일드카드 차단
원인 두 겹.
1) 절충 계열 가드가 타결 상한만 봐서, 중간가((앵커+목표)/2)처럼 구조상 목표가를 넘지 못하는
   값은 항상 통과했다. 앵커가 목표가에 붙으면 그 값이 곧 목표가라 목표가로 제시된다.
   → 파생 제안값이 목표가 이상이면 미발동(compute_offer). 목표가 카드는 영향 없음.
2) 제안가 확정 시 계산에 쓴 당사 직전가를 남기지 않고 그 자리를 제안가로 덮은 뒤, 멘트 렌더가
   갱신된 값으로 재계산해 "당사 A 와 귀사 B 의 절반은 A" 자기모순이 났다. 직전 커밋은 갱신을
   렌더 뒤로 미뤘으나 와일드카드는 렌더 지점이 더 뒤라 그대로 남아 있었다.
   → Offer/record_offer 로 금액과 재료(당사 직전가·협력사 제시가)를 함께 박제하고, vars_for 는
     재계산 대신 그 기록을 읽는다. 렌더 순서에 의존하지 않으므로 직전 커밋의 순서 조정은 제거.
   → 산식은 tactics.OFFER_VARIABLES 한 곳만 남기고 chat_engine 의 복사본 삭제.

곁들여: 카드 설정에서 '적용 대기'로 꺼둔 와일드카드가 견적에 담겨 있으면 그대로 발동하던 것을
차단(wild_cards.available 조건). 테이블 정의에 빠져 있던 컬럼도 보강.

검증: 단위/E2E 35건, 실협상 8시나리오(멘트 토큰 노출·절반 산술 정합·카드 중복·제안가 역행·
상한 초과 자동 검사) 통과.
2026-08-10 09:45:30 +09:00

241 lines
13 KiB
Python
Raw Permalink 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)
유효 조건은 카드가 아니라 '변수'에 붙인다 — 금액이 성립하는지는 계산식의 성질이지 카드의
성질이 아니다. 새 변수는 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