"""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.chat.service.script_repository import ScriptRepository MAX_ROUNDS = 3 _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 = ("협상실패",) # 프론트는 표시용 문자열로 가격을 보낸다(예: "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() # 완전 자율 모드: 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.setdefault("first_offer_price", price) session.context["round"] = session.context.get("round", 0) + 1 nxt = self._default_next(node) elif mode in _CHOICE_MODES: # 와일드카드 1% 인하 제안을 수락("예")하면 합의가를 제안가(offer_1pct)로 확정한다. # (멘트에만 쓰이던 offer_1pct 가 input_price 에 반영되지 않아, 요약/입찰가가 # 직전 제시가로 잡히던 버그 수정 — 수락 시 실제 합의가는 인하가다.) if session.step == "wild_card_1pct" and user_input == "예" and session.context.get("offer_1pct"): session.context["input_price"] = float(session.context["offer_1pct"]) # 자율 역제안/최종제안 수락("예") → 합의가는 에이전트 제안가다 (wild_card_1pct 와 동일 원리). 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) 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 구매자 관점 조건 평가. - 협력사 제시가 ≤ anchor → 우선협상(협상완료). - anchor 살짝 초과(≤ anchor*1.05) + 와일드카드 미사용 → 와일드카드로 인하 압박. - 설정 카드(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 * 1.02 or (bool(ctx.get("allow_selected_wildcards", True)) and price <= anchor * 1.05) ) ) 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). 그 이후 카운터제안이 MAX_ROUNDS 회를 # 넘으면 종료한다. 카드선택(RL)이 실패(state ValueError)해도 used_action_ids 가 안 늘어 # 카드 소진 조건만으로는 종료되지 않으므로, 라운드 상한을 독립적으로 둬 무한 가격입력을 막는다. # (선행 chat_server 의 `iteration >= 3` 와 동일한 안전장치.) counter_rounds = max(0, ctx.get("round", 0) - 1) ok = counter_rounds >= MAX_ROUNDS or (cards_total > 0 and cards_used >= cards_total) 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*1.02)한 구간에서만 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 * 1.02: # 와일드카드는 실제로 노출할 때만 '사용됨'으로 마킹한다 — 가격협상으로 돌아가는 # 경우에도 마킹하면 이후 라운드에서 정당한 1% 카드까지 억제된다. ctx["wildcard_used"] = True ctx["offer_1pct"] = int(round(price * 0.99)) # 1% 인하가 return "wild_card_1pct" 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"]) if "target_price" in ctx: out["target"] = int(ctx["target_price"]) # DB 카드 정본(negodata 편집, card.nego_cards.script)은 {target_price} 변수명을 쓴다 # — 파일 스크립트의 {target}과 별개로 둘 다 지원(미치환 토큰 노출 방지). out["target_price"] = int(ctx["target_price"]) if "anchor_price" in ctx: out["anchor"] = int(ctx["anchor_price"]) out["anchoring_price"] = int(ctx["anchor_price"]) # DB 카드 정본 변수명(NGC-007 등) 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): 수집값이 컨텍스트에 없으면 앵커가로 폴백 — 원형 토큰 노출 방지. # TODO: partner.item_internet_lowest_prices 최신 성공 수집값을 context loader 로 연결. 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): 런타임 소스 미구현 — 중립 문구 폴백. # TODO: 견적/카드 편집 단계에서 입력받아 컨텍스트로 전달. out["customer_condition"] = ctx.get("customer_condition") or "상호 협의된 조건" # 인하율 = (기존 공급가 - 제시가) / 기존 공급가 * 100. 기존가 없으면 미표시(0.0). base = ctx.get("item_price") or 0 if base > 1 and "input_price" in ctx: out["discount_rate"] = f"{((base - ctx['input_price']) / base) * 100:.1f}" else: out["discount_rate"] = "0.0" return out def _vars(self, session: ChatSession) -> Dict[str, Any]: return self.vars_for(session) 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}") 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, )