o2o-negosium-original/agent/tests/test_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

162 lines
7.3 KiB
Python

"""Phase 3 이해층 검증 — InputInterpreter (자유 발화 NLU → 기대 입력 구조화).
원칙 검증: LLM 은 의도 분류·가격 표현 위치만 찾고, 숫자 계산은 결정론 파서가 한다.
① 한국어 가격 파서 결정론 ② choice 는 선택지 목록 검증 ③ price_text 는 원문 부분문자열 검증
④ 실패/타임아웃 → None(원문 폴백) ⑤ 챗 플로우: 자유 발화로 분기·가격 입력이 진행된다
⑥ LLM 미설정 시 기존(정형 입력) 동작 그대로 — 회귀 없음.
"""
import os
import pytest
from negotiation.chat.service.input_interpreter import (
InputInterpreter, InterpretedInput, parse_korean_price,
)
_TENANTS_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "tenants")
# ---- 결정론 한국어 가격 파서 -------------------------------------------------
@pytest.mark.parametrize("text,expected", [
("10,500원", 10500),
("10500", 10500),
("1만 500원", 10500),
("1만500원", 10500),
("만원", 10000),
("1.5만", 15000),
("3만2천원", 32000),
("2억", 200_000_000),
("0", None), # 0 이하 무효
("그건 어렵습니다", None), # 가격 아님
("만원에 3개", None), # 잡문자 혼입 → 해석 불가(안전 폴백)
])
def test_parse_korean_price(text, expected):
got = parse_korean_price(text)
assert (got == expected) if expected is not None else (got is None)
# ---- LLM 출력 검증 (환각 차단) ----------------------------------------------
def _fake(reply: dict):
def call(messages):
return reply
return call
@pytest.mark.asyncio
async def test_choice_mapped_to_option():
nat = InputInterpreter(llm_call=_fake({"intent": "choice", "choice": "예", "price_text": None}))
out = await nat.interpret("네 접니다, 말씀하세요", input_mode="yes_no", input_options=["예", "아니오"])
assert out == InterpretedInput(kind="choice", value="예", source="예")
@pytest.mark.asyncio
async def test_choice_outside_options_rejected():
nat = InputInterpreter(llm_call=_fake({"intent": "choice", "choice": "글쎄요", "price_text": None}))
assert await nat.interpret("음...", input_mode="yes_no", input_options=["예", "아니오"]) is None
@pytest.mark.asyncio
async def test_price_span_verified_and_parsed_deterministically():
nat = InputInterpreter(llm_call=_fake({"intent": "price", "choice": None, "price_text": "1만 500원"}))
out = await nat.interpret("저희 마진상 1만 500원까지는 맞춰드릴 수 있습니다", input_mode="price")
assert out is not None and out.kind == "price"
assert out.value == "10500" # 숫자는 결정론 파서 산출(LLM 계산 아님)
assert out.source == "1만 500원"
@pytest.mark.asyncio
async def test_price_span_not_in_text_rejected():
"""LLM 이 원문에 없는 가격 표현을 지어내면 폐기(환각 차단)."""
nat = InputInterpreter(llm_call=_fake({"intent": "price", "choice": None, "price_text": "9,000원"}))
assert await nat.interpret("만원이면 가능합니다", input_mode="price") is None
@pytest.mark.asyncio
async def test_unknown_and_failures_fall_back():
assert await InputInterpreter(llm_call=_fake({"intent": "unknown"})).interpret(
"글쎄요 검토해 볼게요", input_mode="yes_no", input_options=["예", "아니오"]) is None
def boom(messages):
raise RuntimeError("LLM down")
assert await InputInterpreter(llm_call=boom).interpret("네", input_mode="yes_no", input_options=["예"]) is None
def slow(messages):
import time
time.sleep(0.5)
return {"intent": "choice", "choice": "예"}
nat = InputInterpreter(llm_call=slow, timeout_seconds=0.05)
assert await nat.interpret("네", input_mode="yes_no", input_options=["예"]) is None
# ---- 챗 플로우 E2E (fake LLM) ------------------------------------------------
def _routing_fake(messages):
"""단계별 fake — 기대 입력이 가격이면 price, 아니면 '예' choice 로 응답."""
user = messages[-1]["content"]
if "가격(숫자)" in user:
return {"intent": "price", "choice": None, "price_text": "1만 500원"}
return {"intent": "choice", "choice": "예", "price_text": None}
@pytest.mark.asyncio
async def test_chat_flow_free_text_negotiation(db_engine):
"""자유 발화만으로 담당자확인 분기 + 가격 입력이 진행된다 (버튼 없는 '진짜 대화')."""
from router.v1.chat.protocol import Req_Chat
from services.chat_service import ChatService, reset_sessions
from tenancy.config_loader import TenantConfigLoader
from tenancy.registry import TenantEngineRegistry
reset_sessions()
reg = TenantEngineRegistry(loader=TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0))
eng = await reg.get_engine("imarketkorea")
eng.config.llm.enabled = True
svc = ChatService()
svc._interpreter = InputInterpreter(llm_call=_routing_fake)
orig = InputInterpreter.available
InputInterpreter.available = staticmethod(lambda: True)
try:
sid = None
r = await svc.chat(eng, Req_Chat(session_id=sid)) # 서비스안내
sid = r.session_id
r = await svc.chat(eng, Req_Chat(session_id=sid, user_input="확인")) # 담당자확인 (fast path)
assert r.step == "담당자확인" and r.interpreted_input is None
# 자유 발화 → NLU 가 "예" 로 매핑 → 협상품목안내
r = await svc.chat(eng, Req_Chat(session_id=sid, user_input="네 접니다, 말씀하세요"))
assert r.step == "협상품목안내"
assert r.interpreted_input == "예"
r = await svc.chat(eng, Req_Chat(session_id=sid, user_input="확인")) # 기존가격제시(price)
# 자유 발화 가격 → span "1만 500원" → 결정론 파서 10500 → 가격 저장 후 확인 단계
r = await svc.chat(eng, Req_Chat(session_id=sid, user_input="저희 마진상 1만 500원까지는 맞춰드릴 수 있습니다"))
assert r.step == "가격협상_확인"
assert r.interpreted_input == "10500"
assert "10500" in r.script # 멘트 치환도 해석된 가격으로
finally:
InputInterpreter.available = orig
eng.config.llm.enabled = False
@pytest.mark.asyncio
async def test_chat_flow_without_llm_keeps_legacy_behavior(db_engine):
"""LLM 미설정(available=False)이면 자유 발화는 원문 그대로 엔진에 전달 — 기존 동작 회귀 없음."""
from router.v1.chat.protocol import Req_Chat
from services.chat_service import ChatService, reset_sessions
from tenancy.config_loader import TenantConfigLoader
from tenancy.registry import TenantEngineRegistry
reset_sessions()
reg = TenantEngineRegistry(loader=TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0))
eng = await reg.get_engine("imarketkorea")
svc = ChatService()
sid = None
r = await svc.chat(eng, Req_Chat(session_id=sid))
sid = r.session_id
r = await svc.chat(eng, Req_Chat(session_id=sid, user_input="확인"))
assert r.step == "담당자확인"
# conftest 가드로 available=False → NLU 미동작, interpreted_input 없음
r = await svc.chat(eng, Req_Chat(session_id=sid, user_input="네 접니다"))
assert r.interpreted_input is None