충돌 해결 원칙: 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>
450 lines
26 KiB
Python
450 lines
26 KiB
Python
"""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,
|
||
)
|