o2o-negosium-original/agent/negotiation/chat/service/input_interpreter.py
hbyang 1682481b01 [feat] agent: 협상 고도화 — LLM 표현/이해층 + 카드 전술 실행계층 + ktcommerce 정리
카드 재설계("멘트 카드 → 전술 카드"):
- 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>
2026-07-10 13:37:36 +09:00

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 → 원문 폴백(엔진 재질문)