o2o-negosium-original/agent/negotiation/chat/service/chat_engine.py
jwkim 8f4c82a282 Merge feat/switch-model into feat/switch-model-v2 (main 1cce752 기준)
충돌 해결 원칙: main 의 협상 고도화(결정 스택 규칙층·ScriptNaturalizer 표현층·카드 전술·
LPS 최저가 연동)와 switch-model 의 완전 자율 모드(DQN·봉투·ment_generator)를 모두 유지.
- chat_engine: rules(config 규칙층) + autonomy_decider 공존, vars_for 는 main 전술 변수 +
  자율 변수(autonomy_offer/internet_lowest_price/customer_condition) 합집합,
  wild_card_1pct 수락 분기는 main 의 pending_counter_price 일반화로 대체(자율 분기만 유지)
- chat_service: tactics/naturalizer import + ment_generator import 병존,
  _play_closing_tactic(main) + _autonomy_learn(자율 로깅) 메서드 병존
- nego_context_crud: _ITEMS/_SUPPLIERS 컬럼 합집합 (name + internet_lowest_price)
- docker-compose: OPENAI_API_KEY passthrough + DQN_SERVING/AUTONOMY_MODE 플래그 병존
검증: py_compile + 결함 회귀 게이트 78/78 통과

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 17:03:04 +09:00

450 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 compute_counter, tactic_for
from negotiation.chat.service.script_repository import ScriptRepository
MAX_ROUNDS = 3 # config 미주입 시 폴백 (규칙 정본은 tenant config negotiation.max_counter_rounds)
_PRICE_MODES = ("price",)
_CHOICE_MODES = ("yes_no", "confirm", "delivery_type")
# ---- 완전 자율 모드 (AUTONOMY_MODE, autonomy_store) --------------------------------
# 가격협상 판정 룰(check_price_match/wildcard_entry/iteration_limit)과 카드 선택을
# 정책 행동(수락/역제안/압박/결렬)으로 대체할 때 쓰는 스텝들. autonomy_decider 미주입이면 도달 불가.
_AUTONOMY_TURN_CAP = 12 # 엔지니어링 타임아웃(무한 세션 방지) — 협상 룰이 아니다
_AUTONOMY_PRESS_SCRIPTS = {
1: "동일 품목에 대해 복수 공급처의 견적이 함께 검토되고 있습니다. 현재 제시가로는 우선순위 확보가 어려운 상황입니다. 경쟁력 있는 가격으로 다시 제안해 주시겠어요?",
2: "제안하신 조건의 취지는 충분히 이해했습니다. 저희도 최대한 맞춰보려 합니다. 조금만 더 조정해 주시면 내부 설득이 가능할 것 같습니다. 다시 제안해 주시겠어요?",
3: "내부 산정 기준과 현재 제시가 사이에 아직 차이가 있습니다. 기준에 부합하는 수준으로 재검토하여 다시 제안해 주시기를 부탁드립니다.",
4: "귀사를 장기적으로 함께할 파트너로 검토하고 있습니다. 이번 협상이 원만히 마무리되면 후속 거래 확대도 논의하고 싶습니다. 서로 만족할 수 있는 가격으로 다시 제안해 주시겠어요?",
}
_AUTONOMY_STEPS = {
"자율_역제안": {
"script": "제안해 주신 **{input_price}원**, 내부 검토를 마쳤습니다. **{autonomy_offer}원**이라면 즉시 수락하고 우선협상 대상으로 확정하겠습니다. 수락하시겠습니까?",
"next_input_mode": "yes_no",
"input_options": ["예", "아니오"],
"next_step": {"예": "협상완료", "아니오": "가격협상_재입력"},
"type": "text",
"chat_end": False,
},
# 최종 통보(WC-03 의 자율 버전): 정책이 직전과 같은 금액을 다시 부르는 순간(단조 봉투상
# 더 올릴 수 없음 = 탄약 소진) 발동. 거절하면 협상을 정리한다 — 어정쩡한 반복 대신 명확한 마무리.
"자율_최종제안": {
"script": "지금까지 협의에 성실히 임해 주셔서 감사합니다. **{autonomy_offer}원**은 저희가 제시할 수 있는 마지막 제안입니다. 수락해 주시면 즉시 우선협상 대상으로 확정되며, 어려우시다면 이번 협상은 여기서 마무리하겠습니다.",
"next_input_mode": "yes_no",
"input_options": ["예", "아니오"],
"next_step": {"예": "협상완료", "아니오": "협상실패"},
"type": "text",
"chat_end": False,
},
**{
f"자율_압박_{s}": {
"script": t,
"next_input_mode": "price",
"input_options": [],
"next_step": {"default": "가격협상_확인"},
"type": "text",
"chat_end": False,
}
for s, t in _AUTONOMY_PRESS_SCRIPTS.items()
},
}
# 최종 타결/결렬 스텝. 재협상=협상완료(우선협상 타결), 재견적=결과제출(투찰확정). 둘 다 협상실패=결렬.
# 이 스텝들은 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
@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
# 자율 스텝은 병합만 해둔다(repo 캐시 오염 방지 위해 새 dict) — decider 미주입 시 도달 불가.
self.scripts = {**scripts_repo.load_scripts(rq_type), **_AUTONOMY_STEPS}
self.step_map = scripts_repo.client_step_mapping()
# 결정 스택 규칙층(Phase 1): 와일드카드 진입 임계·라운드 상한을 테넌트 config 에서 읽는다.
# (하드코딩 1.02/1.05/3 을 데이터화 — 고객사별로 튜닝 가능, 코드 수정 불필요)
self.rules = scripts_repo.config.negotiation
# 완전 자율 모드: ChatService 가 AutonomyStore 정책을 주입하면 가격협상 판정 룰을 대체한다.
self.autonomy_decider = None # Callable[[dict], autonomy_actions.Action]
# ---- 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["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:
# 자율 역제안/최종제안 수락("예") → 합의가는 에이전트 제안가다.
# (카드/와일드카드 경로는 아래 pending_counter_price 일반 메커니즘이 처리하지만
# 자율 스텝은 pending_counter 를 쓰지 않으므로 명시 분기 유지.)
if session.step in ("자율_역제안", "자율_최종제안") and user_input == "예" \
and session.context.get("autonomy_offer"):
session.context["input_price"] = float(session.context["autonomy_offer"])
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)
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장 플레이 후 재제안).
완전 자율 모드(autonomy_decider 주입)에서는 위 룰 전체를 정책 행동으로 대체한다.
"""
# 가격협상 판정 지점(check_price_match 포함 조건 리스트)에서만 자율 정책이 개입한다.
if self.autonomy_decider is not None and any(
c.get("condition") == "check_price_match" for c in conds):
nxt = self._autonomy_next(session)
if nxt is not None:
return nxt # 정책 실패(예외) 시에만 아래 룰로 폴백
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)
)
)
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)
exhausted = counter_rounds >= self.rules.max_counter_rounds or (cards_total > 0 and cards_used >= cards_total)
if exhausted:
target = ctx.get("target_price", 0)
if not ctx.get("closing_played"):
ctx["force_closing"] = True
return "가격협상"
return "협상완료" if (target > 0 and price <= target) else c.get("next")
ok = False
elif cond == "default":
ok = True
if ok:
return c.get("next")
return "가격협상"
def _autonomy_next(self, session: ChatSession) -> Optional[str]:
"""완전 자율: 정책 행동 → 스텝. 수락/역제안 금액/압박 화법/결렬 타이밍 전부 정책이 결정.
유일한 강제 종료는 턴 상한(_AUTONOMY_TURN_CAP) — 무한 세션 방지용 엔지니어링 타임아웃.
정책 호출이 실패하면 None 을 반환해 기존 룰 평가로 폴백한다(서비스 연속성).
"""
ctx = session.context
if ctx.get("round", 0) > _AUTONOMY_TURN_CAP:
# 턴 상한도 최종제안 보장(봉투 ⑥)을 우회하지 않는다 — 어떤 경로로 끝나든
# "끝내기 전에 한 번 더"(제품 결정)를 거친다. 최종 거절 후에만 협상실패.
if not ctx.get("autonomy_final_asked"):
ctx["autonomy_final_asked"] = True
ctx["autonomy_offer"] = int(ctx.get("target_price", 0))
return "자율_최종제안"
return "협상실패"
try:
act = self.autonomy_decider(ctx)
except Exception: # 정책 오류 → 룰 폴백 (호출부에서 로깅)
return None
session.context["autonomy_action"] = f"{act.kind}:{act.strategy}:{act.counter_q}"
span = max(ctx.get("target_price", 0) - ctx.get("anchor_price", 0), 1.0)
# 탄약소진(같은 금액 재호출) 판정은 '마지막 역제안' 기준 — autonomy_last(마지막 행동)는
# 사이에 낀 설득이 덮어써 판정이 리셋된다 (chat_service 가 counter 마다 별도 보존).
last = ctx.get("autonomy_last_counter") or {}
if act.kind == "accept":
return "협상완료"
if act.kind == "walk":
# 결렬 전 마지막 제안 1회 보장 — "끝내기 전에 한 번 더 물어보고 종료" (제품 결정).
# 최종제안을 이미 거쳤으면(autonomy_final_asked) 그대로 종료한다.
if not ctx.get("autonomy_final_asked"):
ctx["autonomy_final_asked"] = True
# 최종제안 금액 = 목표가. 마지막 기회에 직전 역제안 금액을 반복하면 승인 범위의
# 여지(목표가까지)를 남긴 채 결렬된다 — 최종에는 우리가 수락 가능한 최대치를 부른다.
ctx["autonomy_offer"] = int(ctx.get("target_price", 0))
return "자율_최종제안"
return "협상실패"
if act.kind == "counter":
ctx["autonomy_offer"] = int(round(ctx.get("anchor_price", 0) + act.counter_q * span))
# 직전과 같은 금액을 다시 부름 = 단조 봉투상 더 올릴 수 없음(탄약 소진) → 최종 통보로 전환.
if last.get("kind") == "counter" and act.counter_q <= float(last.get("q", -9)) + 1e-9:
ctx["autonomy_final_asked"] = True
ctx["autonomy_offer"] = int(ctx.get("target_price", 0))
return "자율_최종제안"
return "자율_역제안"
return f"자율_압박_{act.strategy or 3}"
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)
anchor = ctx.get("anchor_price", 0)
if anchor > 0 and price <= anchor * self.rules.wildcard_1pct_ratio:
# 와일드카드는 실제로 노출할 때만 '사용됨'으로 마킹한다 — 가격협상으로 돌아가는
# 경우에도 마킹하면 이후 라운드에서 정당한 1% 카드까지 억제된다.
ctx["wildcard_used"] = True
ctx["offer_1pct"] = int(round(price * 0.99)) # 1% 인하가 (멘트 변수)
ctx["pending_counter_price"] = ctx["offer_1pct"] # 수락 시 이 가격으로 타결
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 []):
counter = compute_counter(tactic_for(str(number)), ctx)
if counter is not None:
ctx["wildcard_used"] = True
ctx["pending_counter_price"] = counter
ctx["active_wild_card_number"] = str(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"])
# 카드 에디터 카탈로그의 협력사명/상품명(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"])
if "autonomy_offer" in ctx:
out["autonomy_offer"] = int(ctx["autonomy_offer"])
# 인터넷 최저가(NGC-008): 수집값이 컨텍스트에 없으면 앵커가로 폴백 — 원형 토큰 노출 방지.
if ctx.get("internet_lowest_price"):
out["internet_lowest_price"] = int(ctx["internet_lowest_price"])
elif "anchor_price" in ctx:
out["internet_lowest_price"] = int(ctx["anchor_price"])
# 고객사 교환·요구 조건(NGC-009/010): 런타임 소스 미구현 — 중립 문구 폴백.
out["customer_condition"] = ctx.get("customer_condition") or "상호 협의된 조건"
# 전술 카운터 변수(카드 시드 멘트의 가격 변수) — tactics.compute_counter 산식과 동일 정의.
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
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"):
out["counter_price"] = int(ctx["pending_counter_price"])
# 인하율 = (기존 공급가(상품단가) - 제시가) / 기존 공급가 * 100. 기존가 없으면 미표시(0.0).
# 제시가가 기존가보다 높으면(인상 제시) 음수가 나오는데, "-1.3% 인하된 금액" 같은
# 모순 표현이 되므로 discount_rate 는 0 미만 금지하고, 인상/동일/인하를 구분한
# 문구는 discount_phrase 로 별도 제공한다(가격협상_확인 멘트가 사용).
base = ctx.get("item_price") or 0
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"기존 공급가 대비 약 **{rate:.1f}%** 인하된 금액입니다. "
elif rate <= -0.05:
out["discount_phrase"] = (
f"기존 공급가(**{int(base)}원**)보다 약 **{abs(rate):.1f}%** 높은 금액입니다. ")
else:
out["discount_phrase"] = "기존 공급가와 동일한 수준의 금액입니다. "
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}")
# 가드레일(최후 방어선): 구매자 대리는 목표가 초과로 절대 타결하지 않는다.
# 카운터 클램프·종결 규칙이 정상이면 도달하지 않지만, 스크립트 편집 실수 등으로
# 성공 스텝에 초과가로 진입하면 결렬로 강제 전환한다. (재협상 흐름 한정)
if step_key in _SUCCESS_STEPS and self.rq_type == "재협상":
ctx = session.context
target = ctx.get("target_price") or 0
if target > 0 and ctx.get("input_price", 0) > target:
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
return StepView(
step=step_key,
script=self.repo.format_script(node.get("script", ""), self._vars(session)),
input_mode=node.get("next_input_mode", "null"),
input_options=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:
node = self.scripts.get(session.step, {})
return StepView(
step=session.step, script=node.get("script", ""),
input_mode=node.get("next_input_mode", "null"), input_options=node.get("input_options", []),
chat_end=session.ended, client_step=self.step_map.get(session.step, session.step), error=msg,
)