o2o-negosium-original/agent/negotiation/chat/service/chat_engine.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

451 lines
26 KiB
Python
Raw 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.

"""ChatEngine — 대화 step 전이 엔진 (동기 순수 로직, P7 슬라이스).
Chat_server 의 step 체계(서비스안내→담당자확인→협상품목안내→가격협상→협상완료/실패→협상종료)와
조건 분기(check_wildcard_entry/price_match/iteration_limit)·와일드카드 진입을 우리 구현으로 재작성.
DB/정책(카드선택·학습)은 여기 두지 않는다 — ChatService(async)가 StepView 의 신호를 보고 처리한다.
"""
import re
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
from negotiation.cards.domain.tactics import (
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")
# 최종 타결/결렬 스텝. 재협상=협상완료(우선협상 타결), 재견적=결과제출(투찰확정). 둘 다 협상실패=결렬.
# 이 스텝들은 chat_end=False(뒤에 협상종료가 옴)라, outcome 을 컨텍스트에 적재했다가
# 실제 종료(chat_end=협상종료) 시점에 확정 보고한다 → backend 가 chat_end 에서 DONE/REJECTED 를 옳게 가른다.
_SUCCESS_STEPS = ("협상완료", "결과제출")
_FAILURE_STEPS = ("협상실패",)
# 카운터 제안(pending_counter_price) 수락으로 인정하는 선택 입력.
_ACCEPT_INPUTS = ("", "수락")
# 프론트는 표시용 문자열로 가격을 보낸다(예: "530,000원"). 천단위 콤마·통화기호("원")·공백 등
# 숫자 외 문자를 제거하고 파싱한다. (콤마만 지우면 "원" 때문에 float() 가 실패해 가격 입력이
# 영영 저장되지 않고 같은 step 에 머무는 버그가 났었다.)
_PRICE_CLEAN_RE = re.compile(r"[^\d.]")
def _parse_price(user_input: Any) -> Optional[float]:
cleaned = _PRICE_CLEAN_RE.sub("", str(user_input if user_input is not None else ""))
if not cleaned or cleaned == ".":
return None
try:
price = float(cleaned)
except ValueError:
return None
return price if price > 0 else None
# 협상 스크립트가 쓰는 용어 토큰: {label_*} = 회사 용어(없으면 기본값).
# 값은 negodata 용어 카탈로그(LABEL_CATALOG)의 base 와 같아야 화면·멘트 표기가 갈리지 않는다.
_SCRIPT_LABELS = {
"label_supplier": ("supplier", "협력사"),
"label_target_price": ("target_price", "목표가"),
"label_delivery_type": ("item.delivery_type", "배송 형태"),
"label_delivery_type_1": ("delivery_type.1", "협력사배송"),
"label_delivery_type_2": ("delivery_type.2", "지정택배배송"),
"label_delivery_type_3": ("delivery_type.3", "픽업배송"),
"label_product": ("item.name", "상품명"),
}
# 협상 기준가 호칭 — 공급사 화면 고정 용어. 회사 용어 사전(labels)을 타지 않는다(그건 관리자 화면 전용).
# 실제 값은 loader 가 컨텍스트에 박제하고, DB 컨텍스트가 없는 데모/직접호출 경로만 이 폴백을 쓴다.
_SUPPLIER_PRICE_LABEL = "공급가"
# 조사 자동 보정: 토큰 뒤에 조사가 붙는 자리는 {label_supplier_를} 처럼 대표형을 적는다.
# 회사가 바꾼 용어의 받침을 예측할 수 없어 스크립트에 조사를 고정할 수 없다("협력사를"/"공급업체을").
_JOSA = {"": ("", ""), "": ("", ""), "": ("", ""), "": ("", ""),
"": ("", ""), "": ("", ""), "": ("", ""), "": ("", "")}
def _has_batchim(word: str) -> bool:
last = word[-1] if word else ""
return "" <= last <= "" and (ord(last) - 0xAC00) % 28 != 0
def _josa(word: str, form: str) -> str:
"""단어 + 받침에 맞는 조사. form 은 대표형(''·''·''·'')."""
pair = _JOSA.get(form)
if not pair:
return word
return word + (pair[0] if _has_batchim(word) else pair[1])
@dataclass
class ChatSession:
session_id: str
tenant_id: str
company_id: str
rq_type: str = "재협상"
step: str = "시작"
context: Dict[str, Any] = field(default_factory=dict)
used_action_ids: set = field(default_factory=set)
action_space_size: int = 0 # 카드 소진 판정용 (ChatService 가 주입)
ended: bool = False
@dataclass
class StepView:
step: str
script: str
input_mode: str
input_options: List[str]
chat_end: bool
client_step: Optional[str] = None
needs_card_selection: bool = False # 가격협상(에이전트 카운터) → UCB 카드선택+학습
outcome: Optional[str] = None # 협상완료="success" / 협상실패="failure" → 종료보상
wildcard: Optional[str] = None # 발동한 와일드카드 key
error: Optional[str] = None
class ChatEngine:
def __init__(self, scripts_repo: ScriptRepository, rq_type: str = "재협상"):
self.repo = scripts_repo
self.rq_type = rq_type
self.scripts = scripts_repo.load_scripts(rq_type)
self.step_map = scripts_repo.client_step_mapping()
# 결정 스택 규칙층(Phase 1): 와일드카드 진입 임계·라운드 상한을 테넌트 config 에서 읽는다.
# (하드코딩 1.02/1.05/3 을 데이터화 — 고객사별로 튜닝 가능, 코드 수정 불필요)
self.rules = scripts_repo.config.negotiation
# ---- public --------------------------------------------------------
def start(self, session: ChatSession) -> StepView:
first = self._default_next(self.scripts.get("시작", {})) or "서비스안내"
return self._render(session, first)
def advance(self, session: ChatSession, user_input: Optional[str]) -> StepView:
if session.ended:
return self._error(session, "협상이 종료되었습니다. 새 협상을 시작하세요.")
node = self.scripts.get(session.step, {})
mode = node.get("next_input_mode", "null")
if mode in _PRICE_MODES:
price = _parse_price(user_input)
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)
session.context["round"] = session.context.get("round", 0) + 1
nxt = self._default_next(node)
elif mode in _CHOICE_MODES:
nxt = self._choice_next(node, user_input, session)
else:
nxt = self._default_next(node)
nxt = self._resolve(nxt, session)
# 카운터 수락 일반 메커니즘: 카드/와일드카드가 제시한 카운터가(pending_counter_price)를
# 협력사가 수락("예"/"수락")한 채 성공 스텝으로 전이하면 합의가 = 카운터가.
# (구 offer_1pct 특수 분기의 일반화. 거절인데 성공 스텝으로 가는 경로 — 1% 거절 시
# 원 제시가 수락 종결 — 는 카운터를 버리고 기존 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)
return self._render(session, nxt)
# ---- transition ----------------------------------------------------
def _default_next(self, node: dict) -> Optional[str]:
ns = node.get("next_step")
if isinstance(ns, dict):
return ns.get("default") or next(iter(ns.values()), None)
return ns
def _choice_next(self, node: dict, choice: Optional[str], session: ChatSession):
ns = node.get("next_step") or {}
if not isinstance(ns, dict):
return ns
val = ns.get(choice)
if val is None:
val = ns.get("default") or next(iter(ns.values()), None)
return val
def _resolve(self, nxt, session: ChatSession) -> Optional[str]:
"""조건 리스트 평가 + 가격협상_와일드 가상스텝 → 실제 와일드카드 key 로 해석."""
if isinstance(nxt, list):
nxt = self._eval_conditions(nxt, session)
if nxt == "가격협상_와일드":
nxt = self._pick_wildcard(session)
return nxt
def _eval_conditions(self, conds: List[dict], session: ChatSession) -> Optional[str]:
"""KT 구매자 관점 조건 평가 (임계값은 config negotiation.* — 규칙층 데이터화).
- 협력사 제시가 ≤ anchor → 우선협상(협상완료).
- anchor 살짝 초과(≤ anchor×wildcard_entry_ratio) + 와일드카드 미사용 → 와일드카드로 인하 압박.
- 설정 카드(action_space) 모두 소진 → 협상실패.
- 그 외 → 가격협상(카드 1장 플레이 후 재제안).
"""
ctx = session.context
price = ctx.get("input_price", 0)
anchor = ctx.get("anchor_price", 0)
cards_used = len(session.used_action_ids)
cards_total = session.action_space_size or 0
for c in conds:
cond = c.get("condition")
ok = False
if cond == "check_wildcard_entry":
ok = (
(not ctx.get("wildcard_used"))
and anchor > 0
and anchor < price
and (
price <= anchor * self.rules.wildcard_1pct_ratio
or (bool(ctx.get("allow_selected_wildcards", True))
and price <= anchor * self.rules.wildcard_entry_ratio)
)
)
# 구간에 들어와도 실제로 낼 카드가 없으면(전부 종결 전용·사용됨·유효조건 미달) 이 조건은
# 불충족으로 두고 다음 조건(우선협상·소진 판정)을 평가한다 — 여기서 매칭돼 버리면
# 카드 소진 판정이 영영 돌지 않아, 빈 덱에서 쓴 카드를 또 꺼내는 무한 협상이 된다.
if ok:
probe = ChatSession(
session_id=session.session_id, tenant_id=session.tenant_id,
company_id=session.company_id, context=dict(ctx),
)
ok = self._pick_wildcard(probe) != "가격협상"
elif cond == "check_is_supplier_type_c":
ok = False # 공급사 유형 미보유 (PoC 단순화)
elif cond == "check_price_match": # = 우선협상: 제시가가 앵커가 이하
ok = anchor > 0 and price <= anchor
elif cond == "check_iteration_limit": # 협상 라운드 상한 또는 카드 소진 → 종결 국면
# round 는 매 가격입력마다 증가(기존가격제시=1). 카운터제안이 상한을 넘거나 카드가
# 소진되면 곧장 실패가 아니라 **종결 국면**으로 처리한다(실제 MD 협상 방식):
# ① 아직 종결 전술을 안 썼으면 → 가격협상으로 보내되 force_closing 마킹
# (ChatService 가 종결 전술 — 중간값 절충/최후통첩 — 을 강제 발동)
# ② 종결 전술까지 소진(closing_played)이면 → 최종 제시가 ≤ target 은 타결,
# 초과는 결렬(협상실패) — "목표가 초과 타결 금지" 가드레일과 정합.
counter_rounds = max(0, ctx.get("round", 0) - 1)
# 담은 협상카드 중 지금 낼 수 있는 게 하나도 없으면(사용됨·발동조건 미달 — 예:
# 시장가 인용 카드인데 최저가 결측) 장수와 무관하게 소진으로 본다 — 안 그러면
# 선택 마스크가 전부 막힌 채 폴백이 부적합 카드를 억지로 꺼낸다(토큰 노출).
selected = ctx.get("selected_nego_card_numbers") or []
none_playable = bool(selected) and not any(
not is_played(ctx, n) and playable(spec_from_context(ctx, n), ctx)
for n in selected
)
exhausted = (
counter_rounds >= self.rules.max_counter_rounds
or (cards_total > 0 and cards_used >= cards_total)
or none_playable
)
if exhausted:
# 타결선은 목표가가 아니라 타결 상한가(견적 생성 시 박제) — 목표가를 넘어도
# 상한 이내면 타결한다.
ceiling = settle_ceiling(ctx)
if not ctx.get("closing_played"):
ctx["force_closing"] = True
return "가격협상"
return "협상완료" if (ceiling > 0 and price <= ceiling) else c.get("next")
ok = False
elif cond == "default":
ok = True
if ok:
return c.get("next")
return "가격협상"
def _pick_wildcard(self, session: ChatSession) -> str:
"""앵커가에 아주 근접(≤ anchor×wildcard_1pct_ratio)한 구간에서만 1% 인하 요청(wild_card_1pct)으로
앵커가 이하로 유도한다. 그 외 구간은 견적에서 선택한 와일드카드의 전술로 카운터하고,
낼 카드가 없으면 일반 가격협상(카드 플레이)으로 돌린다.
"""
ctx = session.context
price = ctx.get("input_price", 0)
anchor = ctx.get("anchor_price", 0)
target = ctx.get("target_price", 0)
if anchor > 0 and price <= anchor * self.rules.wildcard_1pct_ratio:
offer_1pct = int(price * 0.99 / 10 + 0.5) * 10 # 1% 인하가 — 10원 반올림(앵커·카운터와 통일)
# 제안가 공통 유효조건(≤목표가 · <제시가 · 직전 당사 제안 이상=역행 금지)은 시스템 1% 카드에도
# 동일하게 건다. 기본 앵커 밴드에선 수학적으로 항상 통과하지만 극단 데이터를 방어한다.
prev_customer = ctx.get("prev_customer_price") or 0
if 0 < offer_1pct < price and (target <= 0 or offer_1pct <= target) and offer_1pct >= prev_customer:
# 와일드카드는 실제로 노출할 때만 '사용됨'으로 마킹한다 — 가격협상으로 돌아가는
# 경우에도 마킹하면 이후 라운드에서 정당한 1% 카드까지 억제된다.
ctx["wildcard_used"] = True
ctx["offer_1pct"] = offer_1pct
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 가 영영 발동하지 않던 갭.)
if anchor > 0 and price <= anchor * self.rules.wildcard_entry_ratio:
for number in (ctx.get("selected_wild_card_numbers") or []):
number = str(number)
spec = spec_from_context(ctx, number)
# 종결 전용 카드(최종 통보·중간값 절충)는 여기서 안 꺼낸다 — 종결 국면의 마지막 한 방으로 예약.
# 이미 쓴 카드도 제외(같은 멘트 반복 방지).
if not available(spec, ctx) or is_played(ctx, number):
continue
offer = compute_offer_detail(spec, ctx)
if offer is not None:
ctx["wildcard_used"] = True
record_offer(ctx, offer)
ctx["active_wild_card_number"] = number
mark_played(ctx, number)
return "wild_card_dynamic"
return "가격협상"
# ---- render --------------------------------------------------------
def vars_for(self, session: ChatSession) -> Dict[str, Any]:
"""스크립트 치환 변수. 가격협상_확인 멘트(할인율)·카드 스크립트가 공유한다."""
ctx = session.context
out = {}
if "input_price" in ctx:
out["input_price"] = int(ctx["input_price"])
# 목표가/앵커가: 엔진 내부 파일 스크립트는 {target}/{anchor}, negodata 카드 에디터는
# {target_price}/{anchor_price}(variables.ts) 를 쓴다 — 양쪽 이름 모두 채워 치환 누락 방지.
if "target_price" in ctx:
out["target"] = out["target_price"] = int(ctx["target_price"])
if "anchor_price" in ctx:
# anchoring_price = DB 시드 기본 카드/sessions 컬럼 표기, anchor_price = 카드 에디터 표기.
out["anchor"] = out["anchor_price"] = out["anchoring_price"] = int(ctx["anchor_price"])
# 용어 토큰 — 회사 용어 사전(labels)이 있으면 그 단어, 없으면 카탈로그 기본값.
# 조사가 붙는 자리를 위해 {label_supplier_를} 같은 파생 키도 함께 만든다.
labels = ctx.get("labels") or {}
for token, (label_key, fallback) in _SCRIPT_LABELS.items():
word = labels.get(label_key) or fallback
out[token] = word
for form in ("", "", "", ""):
out[f"{token}_{form}"] = _josa(word, form)
# 기준가 호칭은 회사 용어가 아니라 공급사 관점 고정 — loader 박제값(없으면 '공급가').
price_word = str(ctx.get("item_price_label") or _SUPPLIER_PRICE_LABEL)
out["label_item_price"] = price_word
for form in ("", "", "", ""):
out[f"label_item_price_{form}"] = _josa(price_word, form)
# 카드 에디터 카탈로그의 협력사명/상품명(partner_name·product_name) 치환.
if ctx.get("partner_name"):
out["partner_name"] = str(ctx["partner_name"])
if ctx.get("product_name"):
out["product_name"] = str(ctx["product_name"])
if "offer_1pct" in ctx:
out["offer_1pct"] = int(ctx["offer_1pct"])
# 인터넷 최저가: LPS 대표값(items.internet_lowest_price). 카드는 {internet_lowest_price},
# 라벨 매핑(variable_mapping.json)은 internet_min_price 를 쓰므로 target/anchor 처럼 양쪽 이름 모두 채운다.
# 미수집(0/없음)이면 키를 만들지 않는다 — 원형 유지 → 허위 시장가 인용 방지(NGC-008 은 값 있을 때만 유효).
ilp = ctx.get("internet_lowest_price") or 0
if ilp > 0:
out["internet_lowest_price"] = out["internet_min_price"] = int(ilp)
# 전술 카운터 변수(카드 시드 멘트의 가격 변수) — tactics.OFFER_VARIABLES 산식과 동일 정의.
anchor = ctx.get("anchor_price") or 0
target = ctx.get("target_price") or 0
# 표시 기준값 — 제안이 확정된 턴이면 그 계산에 쓴 재료(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"] = 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 미만 금지하고, 인상/동일/인하를 구분한
# 문구는 discount_phrase 로 별도 제공한다(가격협상_확인 멘트가 사용).
# 기준가 호칭은 공급사 화면 고정 용어('공급가') — loader 가 박제한 값.
base = ctx.get("item_price") or 0
label = ctx.get("item_price_label") or _SUPPLIER_PRICE_LABEL
if base > 1 and "input_price" in ctx:
rate = ((base - ctx["input_price"]) / base) * 100
out["discount_rate"] = f"{max(0.0, rate):.1f}"
if rate >= 0.05:
out["discount_phrase"] = f"기존 {label} 대비 약 **{rate:.1f}%** 인하된 금액입니다. "
elif rate <= -0.05:
out["discount_phrase"] = (
f"기존 {label}(**{int(base)}원**)보다 약 **{abs(rate):.1f}%** 높은 금액입니다. ")
else:
out["discount_phrase"] = f"기존 {_josa(label, '')} 동일한 수준의 금액입니다. "
else:
out["discount_rate"] = "0.0"
out["discount_phrase"] = ""
return out
def _vars(self, session: ChatSession) -> Dict[str, Any]:
return self.vars_for(session)
def render_step(self, session: ChatSession, step_key: str) -> StepView:
"""지정 스텝으로 전이·렌더 (공개) — ChatService 가 카드 카운터 제시 시
가격협상 → 가격협상_카운터로 스텝을 전환할 때 사용한다."""
return self._render(session, step_key)
def _render(self, session: ChatSession, step_key: Optional[str]) -> StepView:
if not step_key or step_key not in self.scripts:
return self._error(session, f"다음 단계를 찾을 수 없습니다: {step_key}")
# 가드레일(최후 방어선): 구매자 대리는 타결 상한가를 넘겨 타결하지 않는다.
# 상한 = 견적 생성 시 박제한 done_ceiling_price(목표가×(1+타결상한율)), 미박제면 목표가.
# 목표가를 조금 넘어도 상한 이내면 타결이 정상이므로 여기서 뒤집지 않는다.
# 상한까지 넘은 경우만 결렬로 강제 전환한다.
if step_key in _SUCCESS_STEPS and self.rq_type == "재협상":
ctx = session.context
ceiling = settle_ceiling(ctx)
if ceiling > 0 and ctx.get("input_price", 0) > ceiling:
step_key = "협상실패"
node = self.scripts[step_key]
session.step = step_key
chat_end = bool(node.get("chat_end"))
session.ended = chat_end
# 최종 성공/실패를 통과 시점에 기록하고, 실제 종료(chat_end) 시점에만 outcome 으로 확정 보고.
# (중간 성공/실패 스텝에서 보고하면 backend 종료확정 타이밍(chat_end)과 어긋나고,
# ChatService 종료학습도 두 번 도는 문제가 생긴다.)
if step_key in _SUCCESS_STEPS:
session.context["final_outcome"] = "success"
elif step_key in _FAILURE_STEPS:
session.context["final_outcome"] = "failure"
outcome = session.context.get("final_outcome") if chat_end else None
# 선택지도 스크립트와 같은 변수 치환을 태운다 — 배송형태 보기가 회사 용어({label_delivery_type_1} 등)라
# 치환을 건너뛰면 사용자에게 토큰 원문이 그대로 보인다.
step_vars = self._vars(session)
return StepView(
step=step_key,
script=self.repo.format_script(node.get("script", ""), step_vars),
input_mode=node.get("next_input_mode", "null"),
input_options=[self.repo.format_script(o, step_vars) for o in node.get("input_options", [])],
chat_end=bool(node.get("chat_end")),
client_step=self.step_map.get(step_key, step_key),
needs_card_selection=(step_key == "가격협상"),
outcome=outcome,
wildcard=step_key if step_key.startswith("wild_card_") else None,
)
def _error(self, session: ChatSession, msg: str) -> StepView:
# 에러 재렌더도 정상 렌더와 같은 변수 치환을 태운다 — 여기만 raw 로 두면
# 가격 오입력 시 옵션 버튼에 {label_*} 토큰이 그대로 노출된다.
node = self.scripts.get(session.step, {})
step_vars = self._vars(session)
return StepView(
step=session.step, script=self.repo.format_script(node.get("script", ""), step_vars),
input_mode=node.get("next_input_mode", "null"),
input_options=[self.repo.format_script(o, step_vars) for o in node.get("input_options", [])],
chat_end=session.ended, client_step=self.step_map.get(session.step, session.step), error=msg,
)