[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시나리오(멘트 토큰 노출·절반 산술 정합·카드 중복·제안가 역행·
상한 초과 자동 검사) 통과.
This commit is contained in:
parent
a405e00bf9
commit
436384fa36
@ -8,9 +8,8 @@
|
|||||||
2) 변수 정의 — 그 금액을 지금 쓸 수 있는지 (OFFER_VARIABLES 의 계산식 + 유효조건)
|
2) 변수 정의 — 그 금액을 지금 쓸 수 있는지 (OFFER_VARIABLES 의 계산식 + 유효조건)
|
||||||
3) tactic JSONB — 문장으로 알 수 없는 운영 규칙 (min_round·closing)
|
3) tactic JSONB — 문장으로 알 수 없는 운영 규칙 (min_round·closing)
|
||||||
|
|
||||||
유효 조건을 카드가 아니라 '변수'에 붙이는 이유: 절충가가 목표가를 넘을 수 있는 것은
|
유효 조건은 카드가 아니라 '변수'에 붙인다 — 금액이 성립하는지는 계산식의 성질이지 카드의
|
||||||
(직전제안+제시가)/2 라는 계산식의 성질이지 특정 카드의 성질이 아니다. 같은 변수를 쓰는
|
성질이 아니다. 새 변수는 OFFER_VARIABLES 에 한 줄 추가하면 코드 분기 없이 끝난다.
|
||||||
카드가 늘어도 규칙은 한 곳이고, 새 변수는 이 표에 한 줄 추가하면 코드 분기 없이 끝난다.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import re
|
import re
|
||||||
@ -32,6 +31,9 @@ OFFER_VARIABLES: Dict[str, Callable[[float, float, float, float], Optional[float
|
|||||||
|
|
||||||
_TOKEN_RE = re.compile(r"\{([a-z_]+)\}")
|
_TOKEN_RE = re.compile(r"\{([a-z_]+)\}")
|
||||||
|
|
||||||
|
# 절충 계열 변수 — 양측 사이/우리 두 값 사이의 중간을 부르는 카드. 목표가 이상이면 미발동한다.
|
||||||
|
_MID_VARIABLES = ("middle_price", "target_mid_price")
|
||||||
|
|
||||||
|
|
||||||
# 세션 데이터에 따라 값이 없을 수 있는 읽기 전용 변수 → 그 값을 담는 컨텍스트 키.
|
# 세션 데이터에 따라 값이 없을 수 있는 읽기 전용 변수 → 그 값을 담는 컨텍스트 키.
|
||||||
# 스크립트가 이런 변수를 인용하면 값이 있을 때만 카드가 나간다 — 없는데 나가면 협력사 채팅에
|
# 스크립트가 이런 변수를 인용하면 값이 있을 때만 카드가 나간다 — 없는데 나가면 협력사 채팅에
|
||||||
@ -65,9 +67,8 @@ HOLD = CardSpec() # 스펙을 못 찾은 카드(테넌트 데모·회사 커스
|
|||||||
def settle_ceiling(context: Dict[str, Any]) -> float:
|
def settle_ceiling(context: Dict[str, Any]) -> float:
|
||||||
"""이 협상에서 받아줄 수 있는 최고가 — 타결 판정선이자 카드 제안가의 상한.
|
"""이 협상에서 받아줄 수 있는 최고가 — 타결 판정선이자 카드 제안가의 상한.
|
||||||
|
|
||||||
견적 생성 시 세션에 박제한 done_ceiling_price(= 목표가 × (1 + 타결상한율)). 목표가를 조금
|
견적 생성 시 세션에 박제한 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)
|
return float(context.get("done_ceiling_price") or context.get("target_price") or 0)
|
||||||
|
|
||||||
@ -75,9 +76,8 @@ def settle_ceiling(context: Dict[str, Any]) -> float:
|
|||||||
def parse_offer_variable(script: Optional[str]) -> Optional[str]:
|
def parse_offer_variable(script: Optional[str]) -> Optional[str]:
|
||||||
"""스크립트가 제시하는 제안가 변수. 없으면 None(설득 카드).
|
"""스크립트가 제시하는 제안가 변수. 없으면 None(설득 카드).
|
||||||
|
|
||||||
제안가 변수가 여럿이면 **마지막에 등장하는 것**이 제안가다 — 카드 문장은 배경을 먼저 깔고
|
변수가 여럿이면 마지막에 등장하는 것이 제안가다 — 카드 문장은 배경을 먼저 깔고 실제 제안을
|
||||||
(예: "당초 검토한 적정가는 {anchoring_price}원이었으나") 실제 제안을 마지막에 하기 때문이다
|
마지막에 하기 때문이다.
|
||||||
(예: "이에 {target_price}원으로 조정하여 제안 드립니다").
|
|
||||||
"""
|
"""
|
||||||
found = [m.group(1) for m in _TOKEN_RE.finditer(script or "") if m.group(1) in OFFER_VARIABLES]
|
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
|
return found[-1] if found else None
|
||||||
@ -115,16 +115,50 @@ def spec_from_context(context: Dict[str, Any], number: Optional[str]) -> CardSpe
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@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]:
|
def compute_offer(spec: CardSpec, context: Dict[str, Any]) -> Optional[int]:
|
||||||
"""카드가 제시할 금액. 쓸 수 없는 상황이면 None → 호출부가 카드를 건너뛴다.
|
"""카드가 제시할 금액. 쓸 수 없는 상황이면 None → 호출부가 카드를 건너뛴다.
|
||||||
|
|
||||||
변수 공통 유효조건 (전부 만족해야 발동):
|
변수 공통 유효조건 (전부 만족해야 발동):
|
||||||
· 값 ≤ 타결 상한가 — 구매자는 받아줄 수 없는 금액을 부르지 않는다. 넘으면 클램프가 아니라
|
· 값 ≤ 타결 상한가 — 받아줄 수 없는 금액은 부르지 않는다. 넘으면 깎지 않고 미발동
|
||||||
**미발동**(깎아 부르면 "중간에서 만나자"면서 상한을 부르는 모순이 된다).
|
|
||||||
상한은 견적 생성 시 박제한 done_ceiling_price(목표가×(1+율)), 없으면 목표가.
|
|
||||||
· 값 < 협력사 제시가 — 이미 더 싸게 받았는데 더 비싼 값을 부를 이유가 없다
|
· 값 < 협력사 제시가 — 이미 더 싸게 받았는데 더 비싼 값을 부를 이유가 없다
|
||||||
· 값 ≥ 당사 직전 제안 — 역행 금지(IMK 논의). 16,980을 불러놓고 16,810(앵커)을 부르면 협상이
|
· 값 ≥ 당사 직전 제안 — 역행 금지. 제안 시퀀스는 앵커→…→목표가로 단조 수렴해야 한다
|
||||||
좁혀지지 않고 되돌아간다. 제안 시퀀스는 앵커→…→목표가로 단조 수렴해야 한다
|
|
||||||
"""
|
"""
|
||||||
variable = spec.offer_variable
|
variable = spec.offer_variable
|
||||||
if not variable:
|
if not variable:
|
||||||
@ -145,6 +179,10 @@ def compute_offer(spec: CardSpec, context: Dict[str, Any]) -> Optional[int]:
|
|||||||
return None # 재료 부족(앵커 미박제·직전 제안 없음)
|
return None # 재료 부족(앵커 미박제·직전 제안 없음)
|
||||||
if value > settle_ceiling(context):
|
if value > settle_ceiling(context):
|
||||||
return None # 타결 상한 초과 — 받아줄 수 없는 금액이라 지금 못 쓴다
|
return None # 타결 상한 초과 — 받아줄 수 없는 금액이라 지금 못 쓴다
|
||||||
|
if variable in _MID_VARIABLES and value >= target:
|
||||||
|
# 절충 계열은 목표가 미만일 때만 의미가 있다. 목표가 이상이면 "절반씩 나누자"면서 목표가를
|
||||||
|
# 부르는 꼴이라 미발동 — 목표가 제시는 목표가 카드(최후통첩)가 할 일이다.
|
||||||
|
return None
|
||||||
if variable in ("target_price", "anchoring_price", "anchor_price"):
|
if variable in ("target_price", "anchoring_price", "anchor_price"):
|
||||||
# 원값 인용 변수 — 멘트엔 {target_price} 등 저장 원값이 그대로 나가므로, 반올림하면
|
# 원값 인용 변수 — 멘트엔 {target_price} 등 저장 원값이 그대로 나가므로, 반올림하면
|
||||||
# 표시가≠타결가 미스매치가 난다(목표가 7652 멘트 → 7650 타결). 저장값 그대로 제시.
|
# 표시가≠타결가 미스매치가 난다(목표가 7652 멘트 → 7650 타결). 저장값 그대로 제시.
|
||||||
@ -178,7 +216,7 @@ def playable(spec: CardSpec, context: Dict[str, Any], *, closing_phase: bool = F
|
|||||||
|
|
||||||
금액을 인용하는 카드(offer_variable 있음)는 그 금액을 못 부르는 상황이면 설득 폴백으로도
|
금액을 인용하는 카드(offer_variable 있음)는 그 금액을 못 부르는 상황이면 설득 폴백으로도
|
||||||
내보내지 않는다 — 멘트에 무효한 금액(직전 제안보다 낮은 앵커, 제시가보다 높은 목표가)이
|
내보내지 않는다 — 멘트에 무효한 금액(직전 제안보다 낮은 앵커, 제시가보다 높은 목표가)이
|
||||||
글자로 박혀 나가 역행/모순 서사가 되기 때문(IMK 역행 논의). 설득 카드는 금액이 없으니 무관.
|
글자로 박혀 나가 역행/모순 서사가 되기 때문. 설득 카드는 금액이 없으니 무관.
|
||||||
"""
|
"""
|
||||||
if not available(spec, context, closing_phase=closing_phase):
|
if not available(spec, context, closing_phase=closing_phase):
|
||||||
return False
|
return False
|
||||||
|
|||||||
@ -84,6 +84,7 @@ _NEGO_CARDS = table(
|
|||||||
_WILD_CARDS = table(
|
_WILD_CARDS = table(
|
||||||
"wild_cards",
|
"wild_cards",
|
||||||
column("wild_card_id"), column("number"), column("script"), column("tactic"), column("deleted"),
|
column("wild_card_id"), column("number"), column("script"), column("tactic"), column("deleted"),
|
||||||
|
column("available"),
|
||||||
schema="card",
|
schema="card",
|
||||||
)
|
)
|
||||||
# 상품↔협력사 매핑 (2026-07-07 신설): supply_type = 이 협력사가 이 상품을 공급하는 방식(SupplierType).
|
# 상품↔협력사 매핑 (2026-07-07 신설): supply_type = 이 협력사가 이 상품을 공급하는 방식(SupplierType).
|
||||||
@ -374,6 +375,8 @@ class NegoContextCRUD(INegoContextCRUD):
|
|||||||
_VERSION_WILD_CARDS.c.version_id == version_id,
|
_VERSION_WILD_CARDS.c.version_id == version_id,
|
||||||
_VERSION_WILD_CARDS.c.deleted == False, # noqa: E712
|
_VERSION_WILD_CARDS.c.deleted == False, # noqa: E712
|
||||||
_WILD_CARDS.c.deleted == False, # noqa: E712
|
_WILD_CARDS.c.deleted == False, # noqa: E712
|
||||||
|
# 협상 적용 여부(카드 설정 '적용 대기(수동)') — 꺼진 카드는 견적에 담겨 있어도 발동 금지
|
||||||
|
_WILD_CARDS.c.available == True, # noqa: E712
|
||||||
)
|
)
|
||||||
.order_by(_VERSION_WILD_CARDS.c.created_at)
|
.order_by(_VERSION_WILD_CARDS.c.created_at)
|
||||||
)
|
)
|
||||||
|
|||||||
@ -11,11 +11,15 @@ from dataclasses import dataclass, field
|
|||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
from negotiation.cards.domain.tactics import (
|
from negotiation.cards.domain.tactics import (
|
||||||
available, compute_offer, is_played, mark_played, playable, settle_ceiling, spec_from_context,
|
OFFER_VARIABLES, Offer, available, compute_offer_detail, is_played, mark_played, playable,
|
||||||
|
record_offer, settle_ceiling, spec_from_context,
|
||||||
)
|
)
|
||||||
from negotiation.chat.service.script_repository import ScriptRepository
|
from negotiation.chat.service.script_repository import ScriptRepository
|
||||||
|
|
||||||
MAX_ROUNDS = 3 # config 미주입 시 폴백 (규칙 정본은 tenant config negotiation.max_counter_rounds)
|
MAX_ROUNDS = 3 # config 미주입 시 폴백 (규칙 정본은 tenant config negotiation.max_counter_rounds)
|
||||||
|
# 멘트에 찍히는 파생 가격 — 값이 다른 값에서 계산돼 나오는 것들(원값 인용 target/anchor 는 제외).
|
||||||
|
_DERIVED_PRICE_VARIABLES = ("target_mid_price", "middle_price")
|
||||||
|
|
||||||
_PRICE_MODES = ("price",)
|
_PRICE_MODES = ("price",)
|
||||||
_CHOICE_MODES = ("yes_no", "confirm", "delivery_type")
|
_CHOICE_MODES = ("yes_no", "confirm", "delivery_type")
|
||||||
|
|
||||||
@ -131,8 +135,9 @@ class ChatEngine:
|
|||||||
if price is None:
|
if price is None:
|
||||||
return self._error(session, "가격을 숫자로 입력해 주세요.")
|
return self._error(session, "가격을 숫자로 입력해 주세요.")
|
||||||
session.context["input_price"] = price
|
session.context["input_price"] = price
|
||||||
# 새 가격 제시 = 직전 카운터 제안 거절 확정 → 대기 중 카운터 폐기.
|
# 새 가격 제시 = 직전 카운터 제안 거절 확정 → 대기 중 카운터·그 재료 폐기.
|
||||||
session.context.pop("pending_counter_price", None)
|
session.context.pop("pending_counter_price", None)
|
||||||
|
session.context.pop("pending_offer", None)
|
||||||
session.context["prev_partner_price"] = price
|
session.context["prev_partner_price"] = price
|
||||||
# 협력사 첫 제시가 — 가격 수용률(첫 제시가 대비 양보율) 동적 계산의 기준값.
|
# 협력사 첫 제시가 — 가격 수용률(첫 제시가 대비 양보율) 동적 계산의 기준값.
|
||||||
session.context.setdefault("first_offer_price", price)
|
session.context.setdefault("first_offer_price", price)
|
||||||
@ -150,15 +155,10 @@ class ChatEngine:
|
|||||||
# 원 제시가 수락 종결 — 는 카운터를 버리고 기존 input_price 로 타결한다.)
|
# 원 제시가 수락 종결 — 는 카운터를 버리고 기존 input_price 로 타결한다.)
|
||||||
if mode in _CHOICE_MODES and nxt in _SUCCESS_STEPS:
|
if mode in _CHOICE_MODES and nxt in _SUCCESS_STEPS:
|
||||||
pending = session.context.pop("pending_counter_price", None)
|
pending = session.context.pop("pending_counter_price", None)
|
||||||
|
session.context.pop("pending_offer", None)
|
||||||
if pending and user_input in _ACCEPT_INPUTS:
|
if pending and user_input in _ACCEPT_INPUTS:
|
||||||
session.context["input_price"] = float(pending)
|
session.context["input_price"] = float(pending)
|
||||||
view = self._render(session, nxt)
|
return self._render(session, nxt)
|
||||||
# 갑의 포지션 갱신은 멘트 치환 뒤 — 치환 전에 덮으면 {prev_customer_price}(당사 직전 제안)가
|
|
||||||
# 이번 카운터 값으로 찍혀 "당사 7700과 귀사 7900의 절반 = 7700" 같은 모순 멘트가 된다.
|
|
||||||
new_pos = session.context.pop("_customer_position_after_render", None)
|
|
||||||
if new_pos is not None:
|
|
||||||
session.context["prev_customer_price"] = new_pos
|
|
||||||
return view
|
|
||||||
|
|
||||||
# ---- transition ----------------------------------------------------
|
# ---- transition ----------------------------------------------------
|
||||||
def _default_next(self, node: dict) -> Optional[str]:
|
def _default_next(self, node: dict) -> Optional[str]:
|
||||||
@ -246,7 +246,7 @@ class ChatEngine:
|
|||||||
)
|
)
|
||||||
if exhausted:
|
if exhausted:
|
||||||
# 타결선은 목표가가 아니라 타결 상한가(견적 생성 시 박제) — 목표가를 넘어도
|
# 타결선은 목표가가 아니라 타결 상한가(견적 생성 시 박제) — 목표가를 넘어도
|
||||||
# 상한 이내면 타결한다(IMK: 기존 단가보다 인하됐는데 결렬되던 케이스).
|
# 상한 이내면 타결한다.
|
||||||
ceiling = settle_ceiling(ctx)
|
ceiling = settle_ceiling(ctx)
|
||||||
if not ctx.get("closing_played"):
|
if not ctx.get("closing_played"):
|
||||||
ctx["force_closing"] = True
|
ctx["force_closing"] = True
|
||||||
@ -261,12 +261,8 @@ class ChatEngine:
|
|||||||
|
|
||||||
def _pick_wildcard(self, session: ChatSession) -> str:
|
def _pick_wildcard(self, session: ChatSession) -> str:
|
||||||
"""앵커가에 아주 근접(≤ anchor×wildcard_1pct_ratio)한 구간에서만 1% 인하 요청(wild_card_1pct)으로
|
"""앵커가에 아주 근접(≤ anchor×wildcard_1pct_ratio)한 구간에서만 1% 인하 요청(wild_card_1pct)으로
|
||||||
앵커가 이하로 유도한다. 그 외 구간은 일반 가격협상(카드 플레이)으로 돌린다.
|
앵커가 이하로 유도한다. 그 외 구간은 견적에서 선택한 와일드카드의 전술로 카운터하고,
|
||||||
|
낼 카드가 없으면 일반 가격협상(카드 플레이)으로 돌린다.
|
||||||
과거 여기서 반환하던 '재원부족'(wild_card_budget) 하드코딩 카드는 제거했다 —
|
|
||||||
견적에서 실제 선택한 와일드카드(중간값 절충·목표가 선제안 등)와 매핑되지 않은 채
|
|
||||||
'와일드카드를 하나라도 골랐으면' 조건만으로 발동해, 선택하지도 않은 재원부족 멘트가
|
|
||||||
노출되는 오작동이 있었다.
|
|
||||||
"""
|
"""
|
||||||
ctx = session.context
|
ctx = session.context
|
||||||
price = ctx.get("input_price", 0)
|
price = ctx.get("input_price", 0)
|
||||||
@ -282,8 +278,8 @@ class ChatEngine:
|
|||||||
# 경우에도 마킹하면 이후 라운드에서 정당한 1% 카드까지 억제된다.
|
# 경우에도 마킹하면 이후 라운드에서 정당한 1% 카드까지 억제된다.
|
||||||
ctx["wildcard_used"] = True
|
ctx["wildcard_used"] = True
|
||||||
ctx["offer_1pct"] = offer_1pct
|
ctx["offer_1pct"] = offer_1pct
|
||||||
ctx["pending_counter_price"] = offer_1pct # 수락 시 이 가격으로 타결
|
record_offer(ctx, Offer(price=offer_1pct, variable="offer_1pct",
|
||||||
ctx["_customer_position_after_render"] = offer_1pct # 갑의 최신 포지션 — 렌더 뒤 반영(advance 말미)
|
prev_customer=int(prev_customer or anchor), prev_partner=int(price)))
|
||||||
return "wild_card_1pct"
|
return "wild_card_1pct"
|
||||||
# 1.02 초과 ~ entry(1.05) 구간: 견적에서 선택한 와일드카드의 전술로 카운터 제시.
|
# 1.02 초과 ~ entry(1.05) 구간: 견적에서 선택한 와일드카드의 전술로 카운터 제시.
|
||||||
# (구현 전에는 이 구간이 일반 가격협상으로 회귀해 선택형 WC 가 영영 발동하지 않던 갭.)
|
# (구현 전에는 이 구간이 일반 가격협상으로 회귀해 선택형 WC 가 영영 발동하지 않던 갭.)
|
||||||
@ -295,11 +291,10 @@ class ChatEngine:
|
|||||||
# 이미 쓴 카드도 제외(같은 멘트 반복 방지).
|
# 이미 쓴 카드도 제외(같은 멘트 반복 방지).
|
||||||
if not available(spec, ctx) or is_played(ctx, number):
|
if not available(spec, ctx) or is_played(ctx, number):
|
||||||
continue
|
continue
|
||||||
offer = compute_offer(spec, ctx)
|
offer = compute_offer_detail(spec, ctx)
|
||||||
if offer is not None:
|
if offer is not None:
|
||||||
ctx["wildcard_used"] = True
|
ctx["wildcard_used"] = True
|
||||||
ctx["pending_counter_price"] = offer
|
record_offer(ctx, offer)
|
||||||
ctx["_customer_position_after_render"] = offer # 갑의 최신 포지션 — 렌더 뒤 반영(advance 말미)
|
|
||||||
ctx["active_wild_card_number"] = number
|
ctx["active_wild_card_number"] = number
|
||||||
mark_played(ctx, number)
|
mark_played(ctx, number)
|
||||||
return "wild_card_dynamic"
|
return "wild_card_dynamic"
|
||||||
@ -348,25 +343,31 @@ class ChatEngine:
|
|||||||
# 전술 카운터 변수(카드 시드 멘트의 가격 변수) — tactics.OFFER_VARIABLES 산식과 동일 정의.
|
# 전술 카운터 변수(카드 시드 멘트의 가격 변수) — tactics.OFFER_VARIABLES 산식과 동일 정의.
|
||||||
anchor = ctx.get("anchor_price") or 0
|
anchor = ctx.get("anchor_price") or 0
|
||||||
target = ctx.get("target_price") or 0
|
target = ctx.get("target_price") or 0
|
||||||
if "input_price" in ctx:
|
# 표시 기준값 — 제안이 확정된 턴이면 그 계산에 쓴 재료(pending_offer)를 쓴다.
|
||||||
out["prev_partner_price"] = int(ctx.get("prev_partner_price") or ctx["input_price"])
|
# prev_customer_price 는 확정 즉시 새 제안가로 갱신되므로, 그대로 읽으면 멘트가
|
||||||
prev_customer = ctx.get("prev_customer_price") or anchor
|
# "당사 제안과 귀사 제안의 절반이 당사 제안" 같은 자기모순이 된다.
|
||||||
|
pending_offer = ctx.get("pending_offer") or {}
|
||||||
|
prev_customer = int(pending_offer.get("prev_customer") or ctx.get("prev_customer_price") or anchor or 0)
|
||||||
|
partner_price = int(pending_offer.get("prev_partner") or ctx.get("prev_partner_price") or ctx.get("input_price") or 0)
|
||||||
|
offer_price = int(pending_offer.get("price") or ctx.get("pending_counter_price") or 0)
|
||||||
|
offer_variable = pending_offer.get("variable") or ""
|
||||||
if prev_customer:
|
if prev_customer:
|
||||||
out["prev_customer_price"] = int(prev_customer)
|
out["prev_customer_price"] = prev_customer
|
||||||
if anchor and target:
|
if partner_price:
|
||||||
out["target_mid_price"] = int(round((anchor + target) / 2))
|
out["prev_partner_price"] = partner_price
|
||||||
if prev_customer and "input_price" in ctx:
|
if offer_price:
|
||||||
out["middle_price"] = int(round((prev_customer + ctx["input_price"]) / 2))
|
out["counter_price"] = offer_price
|
||||||
if ctx.get("pending_counter_price"):
|
# 파생 가격(절충가·중간가) — 제안가로 확정된 변수는 그 금액을 그대로 쓴다(멘트에 보이는 금액과
|
||||||
# 카운터 제시 중: 멘트에 보이는 제시가와 수락 시 타결가(pending)를 반드시 일치시킨다.
|
# 수락 시 타결가는 항상 같아야 한다). 나머지는 참고 인용이므로 tactics 산식으로 채운다.
|
||||||
# 절충/중간 변수(middle_price·target_mid_price)는 vars_for 재계산 값이 compute_offer 의
|
# 산식을 여기 복사해 두면 갱신 시점 차이로 표시가와 제안가가 갈라지므로 정의를 호출만 한다.
|
||||||
# target 클램프·prev_customer 갱신과 어긋나, 멘트엔 1,740,000 이 보이는데 실제로는
|
# 어느 변수가 제안가인지 모르는 진행 중 세션(구버전 기록)은 종전대로 전부 제안가로 고정한다.
|
||||||
# 1,700,000 으로 타결되던 버그(표시가≠투찰가)가 있었다. pending 은 이 시점 유일한 '제안가'이므로
|
for name in _DERIVED_PRICE_VARIABLES:
|
||||||
# 세 변수 모두 pending 으로 고정한다(카운터 제시 턴에만 적용 — 비-카운터 렌더는 원 계산값 유지).
|
if offer_price and (name == offer_variable or not offer_variable):
|
||||||
pending_i = int(ctx["pending_counter_price"])
|
out[name] = offer_price
|
||||||
out["counter_price"] = pending_i
|
continue
|
||||||
out["middle_price"] = pending_i
|
value = OFFER_VARIABLES[name](target, anchor, partner_price, prev_customer)
|
||||||
out["target_mid_price"] = pending_i
|
if value:
|
||||||
|
out[name] = int(value / 10 + 0.5) * 10 # 10원 반올림 — compute_offer 와 동일
|
||||||
# 인하율 = (협상 기준가 - 제시가) / 기준가 * 100. 기준가 없으면 미표시(0.0).
|
# 인하율 = (협상 기준가 - 제시가) / 기준가 * 100. 기준가 없으면 미표시(0.0).
|
||||||
# 제시가가 기준가보다 높으면(인상 제시) 음수가 나오는데, "-1.3% 인하된 금액" 같은
|
# 제시가가 기준가보다 높으면(인상 제시) 음수가 나오는데, "-1.3% 인하된 금액" 같은
|
||||||
# 모순 표현이 되므로 discount_rate 는 0 미만 금지하고, 인상/동일/인하를 구분한
|
# 모순 표현이 되므로 discount_rate 는 0 미만 금지하고, 인상/동일/인하를 구분한
|
||||||
@ -402,8 +403,8 @@ class ChatEngine:
|
|||||||
return self._error(session, f"다음 단계를 찾을 수 없습니다: {step_key}")
|
return self._error(session, f"다음 단계를 찾을 수 없습니다: {step_key}")
|
||||||
# 가드레일(최후 방어선): 구매자 대리는 타결 상한가를 넘겨 타결하지 않는다.
|
# 가드레일(최후 방어선): 구매자 대리는 타결 상한가를 넘겨 타결하지 않는다.
|
||||||
# 상한 = 견적 생성 시 박제한 done_ceiling_price(목표가×(1+타결상한율)), 미박제면 목표가.
|
# 상한 = 견적 생성 시 박제한 done_ceiling_price(목표가×(1+타결상한율)), 미박제면 목표가.
|
||||||
# 목표가를 조금 넘어도 상한 이내면 타결이 정상이므로(IMK: 기존 단가보다 인하됐는데
|
# 목표가를 조금 넘어도 상한 이내면 타결이 정상이므로 여기서 뒤집지 않는다.
|
||||||
# 결렬되던 케이스) 여기서 뒤집으면 안 된다. 상한까지 넘은 경우만 결렬로 강제 전환한다.
|
# 상한까지 넘은 경우만 결렬로 강제 전환한다.
|
||||||
if step_key in _SUCCESS_STEPS and self.rq_type == "재협상":
|
if step_key in _SUCCESS_STEPS and self.rq_type == "재협상":
|
||||||
ctx = session.context
|
ctx = session.context
|
||||||
ceiling = settle_ceiling(ctx)
|
ceiling = settle_ceiling(ctx)
|
||||||
|
|||||||
@ -13,7 +13,9 @@ from common.enums import DBType, ErrorType
|
|||||||
from common.database.db_session_manager import DB_SESSION_MNG
|
from common.database.db_session_manager import DB_SESSION_MNG
|
||||||
from common.logger import LOG
|
from common.logger import LOG
|
||||||
from config.server_configs import agent_config
|
from config.server_configs import agent_config
|
||||||
from negotiation.cards.domain.tactics import available, compute_offer, is_played, mark_played, playable, spec_from_context
|
from negotiation.cards.domain.tactics import (
|
||||||
|
Offer, available, compute_offer_detail, is_played, mark_played, playable, record_offer, spec_from_context,
|
||||||
|
)
|
||||||
from negotiation.chat.service.chat_engine import (
|
from negotiation.chat.service.chat_engine import (
|
||||||
_CHOICE_MODES, _PRICE_MODES, ChatEngine, ChatSession, StepView,
|
_CHOICE_MODES, _PRICE_MODES, ChatEngine, ChatSession, StepView,
|
||||||
)
|
)
|
||||||
@ -309,9 +311,10 @@ class ChatService:
|
|||||||
# 협력사가 수락하면 이 가격으로 즉시 타결된다(chat_engine 의 수락 메커니즘).
|
# 협력사가 수락하면 이 가격으로 즉시 타결된다(chat_engine 의 수락 메커니즘).
|
||||||
# 유효조건(≤목표가 · <제시가) 미달이면 None → 금액 없이 설득 멘트만 나간다(HOLD 강등).
|
# 유효조건(≤목표가 · <제시가) 미달이면 None → 금액 없이 설득 멘트만 나간다(HOLD 강등).
|
||||||
spec = spec_from_context(session.context, card_id)
|
spec = spec_from_context(session.context, card_id)
|
||||||
counter = compute_offer(spec, session.context) if available(spec, session.context) else None
|
offer = compute_offer_detail(spec, session.context) if available(spec, session.context) else None
|
||||||
if counter is not None:
|
counter = offer.price if offer else None
|
||||||
session.context["pending_counter_price"] = counter
|
if offer is not None:
|
||||||
|
record_offer(session.context, offer)
|
||||||
reward = RewardCalculator(engine.config.reward, engine.config.state).calculate(snap)
|
reward = RewardCalculator(engine.config.reward, engine.config.state).calculate(snap)
|
||||||
policy.update(Transition(state_index=idx, action_id=decision.action_id, reward=reward.total, done=False))
|
policy.update(Transition(state_index=idx, action_id=decision.action_id, reward=reward.total, done=False))
|
||||||
await QTablePolicyStore.persist_cell(repo, version_id, policy, idx, decision.action_id)
|
await QTablePolicyStore.persist_cell(repo, version_id, policy, idx, decision.action_id)
|
||||||
@ -358,9 +361,6 @@ class ChatService:
|
|||||||
res.input_mode, res.input_options = view2.input_mode, view2.input_options
|
res.input_mode, res.input_options = view2.input_mode, view2.input_options
|
||||||
if not card_script:
|
if not card_script:
|
||||||
res.script = view2.script # 카드 멘트 없으면 스텝 기본 카운터 멘트
|
res.script = view2.script # 카드 멘트 없으면 스텝 기본 카운터 멘트
|
||||||
# 갑의 최신 포지션(절충가 계산 기준) — 멘트 치환 뒤에 갱신해야 {prev_customer_price}가
|
|
||||||
# 이번 카운터가 아니라 직전 제안으로 나간다.
|
|
||||||
session.context["prev_customer_price"] = counter
|
|
||||||
|
|
||||||
async def _play_closing_tactic(self, engine: TenantEngine, chat_engine: ChatEngine,
|
async def _play_closing_tactic(self, engine: TenantEngine, chat_engine: ChatEngine,
|
||||||
scripts: ScriptRepository, session: ChatSession, res: Res_Chat):
|
scripts: ScriptRepository, session: ChatSession, res: Res_Chat):
|
||||||
@ -371,25 +371,29 @@ class ChatService:
|
|||||||
ctx["closing_played"] = True
|
ctx["closing_played"] = True
|
||||||
# 선택 와일드카드 중 종결 전용 카드(closing) — 이미 쓴 카드는 건너뛰고(같은 멘트 반복 방지),
|
# 선택 와일드카드 중 종결 전용 카드(closing) — 이미 쓴 카드는 건너뛰고(같은 멘트 반복 방지),
|
||||||
# 제안가 유효조건(≤목표가 · <제시가) 미달 카드도 건너뛴다(예: 절충가가 목표가 초과 → 미발동).
|
# 제안가 유효조건(≤목표가 · <제시가) 미달 카드도 건너뛴다(예: 절충가가 목표가 초과 → 미발동).
|
||||||
closing_number, counter = None, None
|
closing_number, closing_offer = None, None
|
||||||
for n in (ctx.get("selected_wild_card_numbers") or []):
|
for n in (ctx.get("selected_wild_card_numbers") or []):
|
||||||
n = str(n)
|
n = str(n)
|
||||||
spec = spec_from_context(ctx, n)
|
spec = spec_from_context(ctx, n)
|
||||||
if not available(spec, ctx, closing_phase=True) or is_played(ctx, n):
|
if not available(spec, ctx, closing_phase=True) or is_played(ctx, n):
|
||||||
continue
|
continue
|
||||||
offer = compute_offer(spec, ctx)
|
offer = compute_offer_detail(spec, ctx)
|
||||||
if offer is not None:
|
if offer is not None:
|
||||||
closing_number, counter = n, offer
|
closing_number, closing_offer = n, offer
|
||||||
break
|
break
|
||||||
if counter is None:
|
if closing_offer is None:
|
||||||
# 폴백 최후통첩: 목표가 제시 (여기 도달 = 제시가 > target 이므로 항상 유효한 카운터).
|
# 폴백 최후통첩: 목표가 제시 (여기 도달 = 제시가 > target 이므로 항상 유효한 카운터).
|
||||||
closing_number = None
|
closing_number = None
|
||||||
target = int(ctx.get("target_price") or 0)
|
target = int(ctx.get("target_price") or 0)
|
||||||
counter = target if 0 < target < ctx.get("input_price", 0) else None
|
price = int(ctx.get("input_price") or 0)
|
||||||
if counter is None:
|
if 0 < target < price:
|
||||||
|
closing_offer = Offer(price=target, variable="target_price",
|
||||||
|
prev_customer=int(ctx.get("prev_customer_price") or ctx.get("anchor_price") or 0),
|
||||||
|
prev_partner=price)
|
||||||
|
if closing_offer is None:
|
||||||
return # 컨텍스트 이상 — 기존 가격협상 스텝 그대로(재제안 요구)
|
return # 컨텍스트 이상 — 기존 가격협상 스텝 그대로(재제안 요구)
|
||||||
mark_played(ctx, closing_number) # None(폴백 최후통첩)이면 no-op
|
mark_played(ctx, closing_number) # None(폴백 최후통첩)이면 no-op
|
||||||
ctx["pending_counter_price"] = counter
|
record_offer(ctx, closing_offer)
|
||||||
|
|
||||||
template = None
|
template = None
|
||||||
if closing_number:
|
if closing_number:
|
||||||
@ -401,9 +405,6 @@ class ChatService:
|
|||||||
res.step, res.client_step = view2.step, view2.client_step
|
res.step, res.client_step = view2.step, view2.client_step
|
||||||
res.input_mode, res.input_options = view2.input_mode, view2.input_options
|
res.input_mode, res.input_options = view2.input_mode, view2.input_options
|
||||||
res.script = scripts.format_script(template, chat_engine.vars_for(session)) if template else view2.script
|
res.script = scripts.format_script(template, chat_engine.vars_for(session)) if template else view2.script
|
||||||
# 갑의 최신 포지션 갱신은 멘트 치환 뒤 — WC-05 중간값 멘트의 {prev_customer_price}(당사 직전 제안)가
|
|
||||||
# 이번 절충가로 찍히던 버그(IMK 0807) 방지.
|
|
||||||
ctx["prev_customer_price"] = counter
|
|
||||||
res.card_id = closing_number
|
res.card_id = closing_number
|
||||||
|
|
||||||
async def _terminal_learn(self, engine: TenantEngine, session: ChatSession, outcome: str, res: Res_Chat):
|
async def _terminal_learn(self, engine: TenantEngine, session: ChatSession, outcome: str, res: Res_Chat):
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user