From 436384fa36116c6ee52bafec0e82c3efd7fa2a11 Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Mon, 10 Aug 2026 09:45:30 +0900 Subject: [PATCH] =?UTF-8?q?[fix]=20agent:=20=EC=A4=91=EA=B0=84=EA=B0=92=20?= =?UTF-8?q?=EC=B9=B4=EB=93=9C=EA=B0=80=20=EB=AA=A9=ED=91=9C=EA=B0=80?= =?UTF-8?q?=EB=A1=9C=20=EC=A0=9C=EC=8B=9C=EB=90=98=EB=8D=98=20=EB=AC=B8?= =?UTF-8?q?=EC=A0=9C=20=E2=80=94=20=EC=A0=9C=EC=95=88=EA=B0=80=20=EC=82=B0?= =?UTF-8?q?=EC=8B=9D=20=EC=9D=BC=EC=9B=90=ED=99=94=C2=B7=EC=A0=88=EC=B6=A9?= =?UTF-8?q?=20=EC=B9=B4=EB=93=9C=20=EB=AA=A9=ED=91=9C=EA=B0=80=20=EC=9D=B4?= =?UTF-8?q?=EC=83=81=20=EB=AF=B8=EB=B0=9C=EB=8F=99=C2=B7=EC=A0=81=EC=9A=A9?= =?UTF-8?q?=20=EB=8C=80=EA=B8=B0=20=EC=99=80=EC=9D=BC=EB=93=9C=EC=B9=B4?= =?UTF-8?q?=EB=93=9C=20=EC=B0=A8=EB=8B=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 원인 두 겹. 1) 절충 계열 가드가 타결 상한만 봐서, 중간가((앵커+목표)/2)처럼 구조상 목표가를 넘지 못하는 값은 항상 통과했다. 앵커가 목표가에 붙으면 그 값이 곧 목표가라 목표가로 제시된다. → 파생 제안값이 목표가 이상이면 미발동(compute_offer). 목표가 카드는 영향 없음. 2) 제안가 확정 시 계산에 쓴 당사 직전가를 남기지 않고 그 자리를 제안가로 덮은 뒤, 멘트 렌더가 갱신된 값으로 재계산해 "당사 A 와 귀사 B 의 절반은 A" 자기모순이 났다. 직전 커밋은 갱신을 렌더 뒤로 미뤘으나 와일드카드는 렌더 지점이 더 뒤라 그대로 남아 있었다. → Offer/record_offer 로 금액과 재료(당사 직전가·협력사 제시가)를 함께 박제하고, vars_for 는 재계산 대신 그 기록을 읽는다. 렌더 순서에 의존하지 않으므로 직전 커밋의 순서 조정은 제거. → 산식은 tactics.OFFER_VARIABLES 한 곳만 남기고 chat_engine 의 복사본 삭제. 곁들여: 카드 설정에서 '적용 대기'로 꺼둔 와일드카드가 견적에 담겨 있으면 그대로 발동하던 것을 차단(wild_cards.available 조건). 테이블 정의에 빠져 있던 컬럼도 보강. 검증: 단위/E2E 35건, 실협상 8시나리오(멘트 토큰 노출·절반 산술 정합·카드 중복·제안가 역행· 상한 초과 자동 검사) 통과. --- agent/negotiation/cards/domain/tactics.py | 68 +++++++++++---- .../infra/repository/nego_context_crud.py | 3 + agent/negotiation/chat/service/chat_engine.py | 83 ++++++++++--------- agent/services/chat_service.py | 35 ++++---- 4 files changed, 116 insertions(+), 73 deletions(-) diff --git a/agent/negotiation/cards/domain/tactics.py b/agent/negotiation/cards/domain/tactics.py index 81ccf02..a51d741 100644 --- a/agent/negotiation/cards/domain/tactics.py +++ b/agent/negotiation/cards/domain/tactics.py @@ -8,9 +8,8 @@ 2) 변수 정의 — 그 금액을 지금 쓸 수 있는지 (OFFER_VARIABLES 의 계산식 + 유효조건) 3) tactic JSONB — 문장으로 알 수 없는 운영 규칙 (min_round·closing) -유효 조건을 카드가 아니라 '변수'에 붙이는 이유: 절충가가 목표가를 넘을 수 있는 것은 -(직전제안+제시가)/2 라는 계산식의 성질이지 특정 카드의 성질이 아니다. 같은 변수를 쓰는 -카드가 늘어도 규칙은 한 곳이고, 새 변수는 이 표에 한 줄 추가하면 코드 분기 없이 끝난다. +유효 조건은 카드가 아니라 '변수'에 붙인다 — 금액이 성립하는지는 계산식의 성질이지 카드의 +성질이 아니다. 새 변수는 OFFER_VARIABLES 에 한 줄 추가하면 코드 분기 없이 끝난다. """ import re @@ -32,6 +31,9 @@ OFFER_VARIABLES: Dict[str, Callable[[float, float, float, float], Optional[float _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: """이 협상에서 받아줄 수 있는 최고가 — 타결 판정선이자 카드 제안가의 상한. - 견적 생성 시 세션에 박제한 done_ceiling_price(= 목표가 × (1 + 타결상한율)). 목표가를 조금 - 넘더라도 기존 단가보다 인하됐으면 타결시키기 위한 값이다(IMK: 기존 17,500 / 목표 16,980 / - 최종 17,300 이 결렬되던 케이스). 박제가 없는 옛 세션·데모는 목표가로 폴백 — 종전 동작 유지. + 견적 생성 시 세션에 박제한 done_ceiling_price(= 목표가 × (1 + 타결상한율)) — 목표가를 조금 + 넘더라도 기존 단가보다 인하됐으면 타결시키기 위한 값. 박제가 없으면 목표가로 폴백한다. """ 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]: """스크립트가 제시하는 제안가 변수. 없으면 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 @@ -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]: """카드가 제시할 금액. 쓸 수 없는 상황이면 None → 호출부가 카드를 건너뛴다. 변수 공통 유효조건 (전부 만족해야 발동): - · 값 ≤ 타결 상한가 — 구매자는 받아줄 수 없는 금액을 부르지 않는다. 넘으면 클램프가 아니라 - **미발동**(깎아 부르면 "중간에서 만나자"면서 상한을 부르는 모순이 된다). - 상한은 견적 생성 시 박제한 done_ceiling_price(목표가×(1+율)), 없으면 목표가. + · 값 ≤ 타결 상한가 — 받아줄 수 없는 금액은 부르지 않는다. 넘으면 깎지 않고 미발동 · 값 < 협력사 제시가 — 이미 더 싸게 받았는데 더 비싼 값을 부를 이유가 없다 - · 값 ≥ 당사 직전 제안 — 역행 금지(IMK 논의). 16,980을 불러놓고 16,810(앵커)을 부르면 협상이 - 좁혀지지 않고 되돌아간다. 제안 시퀀스는 앵커→…→목표가로 단조 수렴해야 한다 + · 값 ≥ 당사 직전 제안 — 역행 금지. 제안 시퀀스는 앵커→…→목표가로 단조 수렴해야 한다 """ variable = spec.offer_variable if not variable: @@ -145,6 +179,10 @@ def compute_offer(spec: CardSpec, context: Dict[str, Any]) -> Optional[int]: 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 타결). 저장값 그대로 제시. @@ -178,7 +216,7 @@ def playable(spec: CardSpec, context: Dict[str, Any], *, closing_phase: bool = F 금액을 인용하는 카드(offer_variable 있음)는 그 금액을 못 부르는 상황이면 설득 폴백으로도 내보내지 않는다 — 멘트에 무효한 금액(직전 제안보다 낮은 앵커, 제시가보다 높은 목표가)이 - 글자로 박혀 나가 역행/모순 서사가 되기 때문(IMK 역행 논의). 설득 카드는 금액이 없으니 무관. + 글자로 박혀 나가 역행/모순 서사가 되기 때문. 설득 카드는 금액이 없으니 무관. """ if not available(spec, context, closing_phase=closing_phase): return False diff --git a/agent/negotiation/chat/infra/repository/nego_context_crud.py b/agent/negotiation/chat/infra/repository/nego_context_crud.py index 92783df..0530b07 100644 --- a/agent/negotiation/chat/infra/repository/nego_context_crud.py +++ b/agent/negotiation/chat/infra/repository/nego_context_crud.py @@ -84,6 +84,7 @@ _NEGO_CARDS = table( _WILD_CARDS = table( "wild_cards", column("wild_card_id"), column("number"), column("script"), column("tactic"), column("deleted"), + column("available"), schema="card", ) # 상품↔협력사 매핑 (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.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) ) diff --git a/agent/negotiation/chat/service/chat_engine.py b/agent/negotiation/chat/service/chat_engine.py index 4a7f433..f7d7a0b 100644 --- a/agent/negotiation/chat/service/chat_engine.py +++ b/agent/negotiation/chat/service/chat_engine.py @@ -11,11 +11,15 @@ from dataclasses import dataclass, field from typing import Any, Dict, List, Optional 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 MAX_ROUNDS = 3 # config 미주입 시 폴백 (규칙 정본은 tenant config negotiation.max_counter_rounds) +# 멘트에 찍히는 파생 가격 — 값이 다른 값에서 계산돼 나오는 것들(원값 인용 target/anchor 는 제외). +_DERIVED_PRICE_VARIABLES = ("target_mid_price", "middle_price") + _PRICE_MODES = ("price",) _CHOICE_MODES = ("yes_no", "confirm", "delivery_type") @@ -131,8 +135,9 @@ class ChatEngine: if price is None: return self._error(session, "가격을 숫자로 입력해 주세요.") session.context["input_price"] = price - # 새 가격 제시 = 직전 카운터 제안 거절 확정 → 대기 중 카운터 폐기. + # 새 가격 제시 = 직전 카운터 제안 거절 확정 → 대기 중 카운터·그 재료 폐기. session.context.pop("pending_counter_price", None) + session.context.pop("pending_offer", None) session.context["prev_partner_price"] = price # 협력사 첫 제시가 — 가격 수용률(첫 제시가 대비 양보율) 동적 계산의 기준값. session.context.setdefault("first_offer_price", price) @@ -150,15 +155,10 @@ class ChatEngine: # 원 제시가 수락 종결 — 는 카운터를 버리고 기존 input_price 로 타결한다.) if mode in _CHOICE_MODES and nxt in _SUCCESS_STEPS: pending = session.context.pop("pending_counter_price", None) + session.context.pop("pending_offer", None) if pending and user_input in _ACCEPT_INPUTS: session.context["input_price"] = float(pending) - view = 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 + return self._render(session, nxt) # ---- transition ---------------------------------------------------- def _default_next(self, node: dict) -> Optional[str]: @@ -246,7 +246,7 @@ class ChatEngine: ) if exhausted: # 타결선은 목표가가 아니라 타결 상한가(견적 생성 시 박제) — 목표가를 넘어도 - # 상한 이내면 타결한다(IMK: 기존 단가보다 인하됐는데 결렬되던 케이스). + # 상한 이내면 타결한다. ceiling = settle_ceiling(ctx) if not ctx.get("closing_played"): ctx["force_closing"] = True @@ -261,12 +261,8 @@ class ChatEngine: def _pick_wildcard(self, session: ChatSession) -> str: """앵커가에 아주 근접(≤ anchor×wildcard_1pct_ratio)한 구간에서만 1% 인하 요청(wild_card_1pct)으로 - 앵커가 이하로 유도한다. 그 외 구간은 일반 가격협상(카드 플레이)으로 돌린다. - - 과거 여기서 반환하던 '재원부족'(wild_card_budget) 하드코딩 카드는 제거했다 — - 견적에서 실제 선택한 와일드카드(중간값 절충·목표가 선제안 등)와 매핑되지 않은 채 - '와일드카드를 하나라도 골랐으면' 조건만으로 발동해, 선택하지도 않은 재원부족 멘트가 - 노출되는 오작동이 있었다. + 앵커가 이하로 유도한다. 그 외 구간은 견적에서 선택한 와일드카드의 전술로 카운터하고, + 낼 카드가 없으면 일반 가격협상(카드 플레이)으로 돌린다. """ ctx = session.context price = ctx.get("input_price", 0) @@ -282,8 +278,8 @@ class ChatEngine: # 경우에도 마킹하면 이후 라운드에서 정당한 1% 카드까지 억제된다. ctx["wildcard_used"] = True ctx["offer_1pct"] = offer_1pct - ctx["pending_counter_price"] = offer_1pct # 수락 시 이 가격으로 타결 - ctx["_customer_position_after_render"] = offer_1pct # 갑의 최신 포지션 — 렌더 뒤 반영(advance 말미) + record_offer(ctx, Offer(price=offer_1pct, variable="offer_1pct", + prev_customer=int(prev_customer or anchor), prev_partner=int(price))) return "wild_card_1pct" # 1.02 초과 ~ entry(1.05) 구간: 견적에서 선택한 와일드카드의 전술로 카운터 제시. # (구현 전에는 이 구간이 일반 가격협상으로 회귀해 선택형 WC 가 영영 발동하지 않던 갭.) @@ -295,11 +291,10 @@ class ChatEngine: # 이미 쓴 카드도 제외(같은 멘트 반복 방지). if not available(spec, ctx) or is_played(ctx, number): continue - offer = compute_offer(spec, ctx) + offer = compute_offer_detail(spec, ctx) if offer is not None: ctx["wildcard_used"] = True - ctx["pending_counter_price"] = offer - ctx["_customer_position_after_render"] = offer # 갑의 최신 포지션 — 렌더 뒤 반영(advance 말미) + record_offer(ctx, offer) ctx["active_wild_card_number"] = number mark_played(ctx, number) return "wild_card_dynamic" @@ -348,25 +343,31 @@ class ChatEngine: # 전술 카운터 변수(카드 시드 멘트의 가격 변수) — tactics.OFFER_VARIABLES 산식과 동일 정의. anchor = ctx.get("anchor_price") or 0 target = ctx.get("target_price") or 0 - if "input_price" in ctx: - out["prev_partner_price"] = int(ctx.get("prev_partner_price") or ctx["input_price"]) - prev_customer = ctx.get("prev_customer_price") or anchor + # 표시 기준값 — 제안이 확정된 턴이면 그 계산에 쓴 재료(pending_offer)를 쓴다. + # prev_customer_price 는 확정 즉시 새 제안가로 갱신되므로, 그대로 읽으면 멘트가 + # "당사 제안과 귀사 제안의 절반이 당사 제안" 같은 자기모순이 된다. + 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: - out["prev_customer_price"] = int(prev_customer) - if anchor and target: - out["target_mid_price"] = int(round((anchor + target) / 2)) - if prev_customer and "input_price" in ctx: - out["middle_price"] = int(round((prev_customer + ctx["input_price"]) / 2)) - if ctx.get("pending_counter_price"): - # 카운터 제시 중: 멘트에 보이는 제시가와 수락 시 타결가(pending)를 반드시 일치시킨다. - # 절충/중간 변수(middle_price·target_mid_price)는 vars_for 재계산 값이 compute_offer 의 - # target 클램프·prev_customer 갱신과 어긋나, 멘트엔 1,740,000 이 보이는데 실제로는 - # 1,700,000 으로 타결되던 버그(표시가≠투찰가)가 있었다. pending 은 이 시점 유일한 '제안가'이므로 - # 세 변수 모두 pending 으로 고정한다(카운터 제시 턴에만 적용 — 비-카운터 렌더는 원 계산값 유지). - pending_i = int(ctx["pending_counter_price"]) - out["counter_price"] = pending_i - out["middle_price"] = pending_i - out["target_mid_price"] = pending_i + out["prev_customer_price"] = prev_customer + if partner_price: + out["prev_partner_price"] = partner_price + if offer_price: + out["counter_price"] = offer_price + # 파생 가격(절충가·중간가) — 제안가로 확정된 변수는 그 금액을 그대로 쓴다(멘트에 보이는 금액과 + # 수락 시 타결가는 항상 같아야 한다). 나머지는 참고 인용이므로 tactics 산식으로 채운다. + # 산식을 여기 복사해 두면 갱신 시점 차이로 표시가와 제안가가 갈라지므로 정의를 호출만 한다. + # 어느 변수가 제안가인지 모르는 진행 중 세션(구버전 기록)은 종전대로 전부 제안가로 고정한다. + for name in _DERIVED_PRICE_VARIABLES: + if offer_price and (name == offer_variable or not offer_variable): + out[name] = offer_price + continue + value = OFFER_VARIABLES[name](target, anchor, partner_price, prev_customer) + if value: + out[name] = int(value / 10 + 0.5) * 10 # 10원 반올림 — compute_offer 와 동일 # 인하율 = (협상 기준가 - 제시가) / 기준가 * 100. 기준가 없으면 미표시(0.0). # 제시가가 기준가보다 높으면(인상 제시) 음수가 나오는데, "-1.3% 인하된 금액" 같은 # 모순 표현이 되므로 discount_rate 는 0 미만 금지하고, 인상/동일/인하를 구분한 @@ -402,8 +403,8 @@ class ChatEngine: return self._error(session, f"다음 단계를 찾을 수 없습니다: {step_key}") # 가드레일(최후 방어선): 구매자 대리는 타결 상한가를 넘겨 타결하지 않는다. # 상한 = 견적 생성 시 박제한 done_ceiling_price(목표가×(1+타결상한율)), 미박제면 목표가. - # 목표가를 조금 넘어도 상한 이내면 타결이 정상이므로(IMK: 기존 단가보다 인하됐는데 - # 결렬되던 케이스) 여기서 뒤집으면 안 된다. 상한까지 넘은 경우만 결렬로 강제 전환한다. + # 목표가를 조금 넘어도 상한 이내면 타결이 정상이므로 여기서 뒤집지 않는다. + # 상한까지 넘은 경우만 결렬로 강제 전환한다. if step_key in _SUCCESS_STEPS and self.rq_type == "재협상": ctx = session.context ceiling = settle_ceiling(ctx) diff --git a/agent/services/chat_service.py b/agent/services/chat_service.py index 335fc1f..8cb556b 100644 --- a/agent/services/chat_service.py +++ b/agent/services/chat_service.py @@ -13,7 +13,9 @@ from common.enums import DBType, ErrorType from common.database.db_session_manager import DB_SESSION_MNG from common.logger import LOG 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 ( _CHOICE_MODES, _PRICE_MODES, ChatEngine, ChatSession, StepView, ) @@ -309,9 +311,10 @@ class ChatService: # 협력사가 수락하면 이 가격으로 즉시 타결된다(chat_engine 의 수락 메커니즘). # 유효조건(≤목표가 · <제시가) 미달이면 None → 금액 없이 설득 멘트만 나간다(HOLD 강등). spec = spec_from_context(session.context, card_id) - counter = compute_offer(spec, session.context) if available(spec, session.context) else None - if counter is not None: - session.context["pending_counter_price"] = counter + offer = compute_offer_detail(spec, session.context) if available(spec, session.context) else None + counter = offer.price if offer else None + if offer is not None: + record_offer(session.context, offer) 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)) 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 if not card_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, scripts: ScriptRepository, session: ChatSession, res: Res_Chat): @@ -371,25 +371,29 @@ class ChatService: ctx["closing_played"] = True # 선택 와일드카드 중 종결 전용 카드(closing) — 이미 쓴 카드는 건너뛰고(같은 멘트 반복 방지), # 제안가 유효조건(≤목표가 · <제시가) 미달 카드도 건너뛴다(예: 절충가가 목표가 초과 → 미발동). - closing_number, counter = None, None + closing_number, closing_offer = None, None for n in (ctx.get("selected_wild_card_numbers") or []): n = str(n) spec = spec_from_context(ctx, n) if not available(spec, ctx, closing_phase=True) or is_played(ctx, n): continue - offer = compute_offer(spec, ctx) + offer = compute_offer_detail(spec, ctx) if offer is not None: - closing_number, counter = n, offer + closing_number, closing_offer = n, offer break - if counter is None: + if closing_offer is None: # 폴백 최후통첩: 목표가 제시 (여기 도달 = 제시가 > target 이므로 항상 유효한 카운터). closing_number = None target = int(ctx.get("target_price") or 0) - counter = target if 0 < target < ctx.get("input_price", 0) else None - if counter is None: + price = int(ctx.get("input_price") or 0) + 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 # 컨텍스트 이상 — 기존 가격협상 스텝 그대로(재제안 요구) mark_played(ctx, closing_number) # None(폴백 최후통첩)이면 no-op - ctx["pending_counter_price"] = counter + record_offer(ctx, closing_offer) template = None if closing_number: @@ -401,9 +405,6 @@ class ChatService: res.step, res.client_step = view2.step, view2.client_step 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 - # 갑의 최신 포지션 갱신은 멘트 치환 뒤 — WC-05 중간값 멘트의 {prev_customer_price}(당사 직전 제안)가 - # 이번 절충가로 찍히던 버그(IMK 0807) 방지. - ctx["prev_customer_price"] = counter res.card_id = closing_number async def _terminal_learn(self, engine: TenantEngine, session: ChatSession, outcome: str, res: Res_Chat):