"""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, )