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