카드 재설계("멘트 카드 → 전술 카드"):
- tactics.py 신설: 카드번호→전술(카운터 산식) 레지스트리, min(counter,target) 클램프
- 카운터 수락=즉시 타결(pending_counter_price 일반화, 구 offer_1pct 흡수)
- 목표가 초과 타결 금지(성공스텝 진입 가드) + "카드 소진=실패" 폐지→종결 국면
- 선택형 와일드카드(WC-*) 발동 + card.wild_cards 멘트 DB 어댑터
LLM 계층:
- Phase 2 표현층 ScriptNaturalizer(카드 멘트 자연화, 마커·치환자·숫자 보존 검증)
- Phase 3 이해층 InputInterpreter(자유발화 NLU→기대입력, 한국어 가격 파서)
- OPENAI_API_KEY env override(server_configs) + 전역 자격증명 게이트
결정 스택(Phase 1):
- 협상 규칙 데이터화(negotiation.wildcard_*_ratio/max_counter_rounds)
- 선택카드 우선순위 prior(UCB 방문수 감쇠, Q-table 오염 없음)
버그픽스:
- 인하율 음수 표기 제거 + 인상/동일/인하 구분(discount_phrase)
- 자연화 강조마커 보존(볼드/색 소실 시 원본 폴백)
- 카드 시드 가격변수(prev_partner_price·target_mid_price·middle_price 등) 치환
정리:
- ktcommerce 테넌트 삭제 + 테스트 21파일 imarketkorea/_base 로 마이그레이션
- 실 LLM 호출 차단 conftest 가드
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
150 lines
7.6 KiB
Python
150 lines
7.6 KiB
Python
"""InputInterpreter — 협력사 자유 발화를 대화 단계 기대 입력으로 구조화 (Phase 3 이해층).
|
|
|
|
원칙: "숫자와 결정은 결정론이, 말은 LLM 이" (ScriptNaturalizer 와 동일 철학) —
|
|
- LLM 의 역할은 ① 의도 분류(choice|price|unknown) ② 가격 '표현의 위치' 찾기까지다.
|
|
가격 숫자 계산은 LLM 출력이 아니라 결정론 한국어 가격 파서(parse_korean_price)가 수행한다.
|
|
- 검증: choice 는 허용 선택지 목록에 철자 그대로 있어야 하고, price_text 는 사용자 원문의
|
|
부분 문자열이어야 한다(환각 차단). 하나라도 어긋나면 None → 호출부가 원문 그대로 폴백
|
|
(기존 엔진의 재질문 흐름 유지 — 협상은 절대 멈추지 않는다).
|
|
|
|
게이트: tenant llm.enabled + 전역 자격증명(ScriptNaturalizer 와 동일). 미설정이면 무동작 —
|
|
기존 버튼/정형 입력 경로는 그대로 두고, 자유 텍스트일 때만 해석을 시도한다.
|
|
"""
|
|
|
|
import asyncio
|
|
import json
|
|
import re
|
|
from dataclasses import dataclass
|
|
from typing import Callable, List, Optional
|
|
|
|
from common.logger import LOG
|
|
from negotiation.profiling.config import LlmCredentials
|
|
|
|
# 정형 가격 입력(버튼/필드) — LLM 없이 기존 결정론 경로로 처리 가능한 형태.
|
|
SIMPLE_PRICE_RE = re.compile(r"\s*[\d,]+(\.\d+)?\s*원?\s*")
|
|
|
|
# 한국어 단위 (큰 단위 → 작은 단위 순서로 등장한다고 가정: "1만 2천 500원")
|
|
_KOREAN_UNITS = {"억": 100_000_000, "만": 10_000, "천": 1_000, "백": 100}
|
|
_PRICE_TOKEN_RE = re.compile(r"[\d.억만천백]+")
|
|
|
|
|
|
def parse_korean_price(text: str) -> Optional[float]:
|
|
"""가격 표현 문자열 → 숫자 (결정론). "10,500원"→10500, "1만 500원"→10500, "만원"→10000,
|
|
"1.5만"→15000, "3만2천원"→32000. 해석 불가/0 이하 → None.
|
|
"""
|
|
t = (text or "").replace(",", "").replace(" ", "").replace("원", "").strip()
|
|
if not t:
|
|
return None
|
|
if re.fullmatch(r"\d+(\.\d+)?", t):
|
|
v = float(t)
|
|
return v if v > 0 else None
|
|
if not re.fullmatch(r"[\d.억만천백]+", t):
|
|
return None # 단위·숫자 외 문자 포함 → 해석 불가(안전 폴백)
|
|
total, num = 0.0, ""
|
|
for ch in t:
|
|
if ch.isdigit() or ch == ".":
|
|
num += ch
|
|
else: # 단위 문자
|
|
try:
|
|
n = float(num) if num else 1.0 # "만원" = 1만
|
|
except ValueError:
|
|
return None
|
|
total += n * _KOREAN_UNITS[ch]
|
|
num = ""
|
|
if num:
|
|
try:
|
|
total += float(num) # 잔여 숫자: "1만500" 의 500
|
|
except ValueError:
|
|
return None
|
|
return total if total > 0 else None
|
|
|
|
|
|
@dataclass
|
|
class InterpretedInput:
|
|
kind: str # "choice" | "price"
|
|
value: str # ChatEngine.advance 에 그대로 전달할 문자열 ("예" / "10500")
|
|
source: str # 판단 근거(선택지 원문 또는 가격 표현 원문) — 로깅/투명성용
|
|
|
|
|
|
_SYSTEM = """너는 B2B 구매 협상 챗봇의 입력 해석기다. 협력사(사용자)의 자유 발화를 현재 대화 단계가 기대하는 입력으로 구조화한다.
|
|
|
|
규칙 (하나라도 어기면 출력은 폐기된다):
|
|
- 출력은 JSON 하나만: {"intent": "choice"|"price"|"unknown", "choice": "<선택지 철자 그대로>"|null, "price_text": "<원문 속 가격 표현 그대로>"|null}
|
|
- choice 는 발화의 '의미'를 선택지 중 하나에 대응시켜, 그 선택지 문자열을 철자 그대로 복사한다.
|
|
예) 선택지 ["예","아니오"]: "네 접니다"/"맞습니다"/"진행해주세요"/"동의합니다" → "예",
|
|
"아닌데요"/"제가 아닙니다"/"어렵습니다"/"거절하겠습니다" → "아니오"
|
|
- 사용자가 구체적 가격을 제시/역제안하면 intent=price. price_text 는 반드시 사용자 원문에 등장한
|
|
표현을 그대로 복사한다(예: "10,500원", "1만 500원"). 숫자를 계산하거나 변형하지 마라.
|
|
- 발화가 어느 선택지의 의미인지 정말 판단할 수 없거나 주제와 무관할 때만 intent=unknown."""
|
|
|
|
|
|
def _default_llm_call(messages: List[dict]) -> dict:
|
|
"""기본 LLM 호출(동기) — 전역 자격증명으로 chat_json. 테스트에서 주입 대체 지점."""
|
|
from negotiation.profiling.infra.llm_adapter import chat_json
|
|
|
|
return chat_json(messages, temperature=0.0, max_tokens=200)
|
|
|
|
|
|
class InputInterpreter:
|
|
def __init__(self, llm_call: Optional[Callable[[List[dict]], dict]] = None,
|
|
timeout_seconds: float = 6.0):
|
|
self._llm_call = llm_call or _default_llm_call
|
|
self._timeout = timeout_seconds
|
|
|
|
@staticmethod
|
|
def available() -> bool:
|
|
"""전역 LLM 자격증명이 설정돼 있는가 (테넌트 enabled 게이트는 호출부 몫)."""
|
|
return LlmCredentials.from_config().is_configured()
|
|
|
|
async def interpret(self, user_input: str, *, input_mode: str,
|
|
input_options: Optional[List[str]] = None,
|
|
step_script: Optional[str] = None) -> Optional[InterpretedInput]:
|
|
"""자유 발화 → 기대 입력. 검증 불통과/실패/타임아웃 시 None(호출부 원문 폴백).
|
|
|
|
step_script: 직전 봇 질문(맥락) — "네 접니다" 같은 발화는 질문 없이는 의도 판단이
|
|
애매해 unknown 이 되므로, 무엇에 대한 답인지 함께 준다.
|
|
"""
|
|
text = (user_input or "").strip()
|
|
options = [str(o) for o in (input_options or [])]
|
|
if not text:
|
|
return None
|
|
payload = {
|
|
"현재 단계 기대 입력": "가격(숫자)" if input_mode == "price" else "선택지 중 하나",
|
|
"선택지": options,
|
|
"사용자 발화": text,
|
|
}
|
|
if step_script:
|
|
payload["직전 봇 질문"] = step_script[:300]
|
|
messages = [
|
|
{"role": "system", "content": _SYSTEM},
|
|
{"role": "user", "content": json.dumps(payload, ensure_ascii=False) + "\n\nJSON 으로만 답하라."},
|
|
]
|
|
try:
|
|
result = await asyncio.wait_for(asyncio.to_thread(self._llm_call, messages), self._timeout)
|
|
except Exception as ex: # 타임아웃 포함 — 원문 폴백
|
|
LOG.w(f"[InputInterpreter] LLM 호출 실패(원문 폴백): {ex}")
|
|
return None
|
|
if not isinstance(result, dict):
|
|
return None
|
|
|
|
intent = result.get("intent")
|
|
if intent == "choice":
|
|
choice = result.get("choice")
|
|
# 결정: 선택지 목록에 철자 그대로 있어야만 채택 (LLM 이 지어낸 분기 차단).
|
|
if isinstance(choice, str) and choice in options:
|
|
return InterpretedInput(kind="choice", value=choice, source=choice)
|
|
LOG.w(f"[InputInterpreter] 검증 실패: choice={choice!r} ∉ {options}")
|
|
return None
|
|
if intent == "price":
|
|
span = result.get("price_text")
|
|
# 환각 차단: 가격 표현은 사용자 원문의 부분 문자열이어야 한다.
|
|
if not isinstance(span, str) or not span.strip() or span.strip() not in text:
|
|
LOG.w(f"[InputInterpreter] 검증 실패: price_text={span!r} 가 원문에 없음")
|
|
return None
|
|
price = parse_korean_price(span.strip()) # 숫자 계산은 결정론 파서가
|
|
if price is None:
|
|
LOG.w(f"[InputInterpreter] 검증 실패: 가격 해석 불가 span={span!r}")
|
|
return None
|
|
return InterpretedInput(kind="price", value=str(int(price)), source=span.strip())
|
|
return None # unknown → 원문 폴백(엔진 재질문)
|