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

158 lines
7.1 KiB
Python

"""Phase 2 표현층 검증 — ScriptNaturalizer (LLM 카드 멘트 자연화).
원칙 검증: LLM 은 말만 다듬고 숫자는 절대 만들지 않는다.
① 치환자 보존 성공 경로 ② 치환자 누락/추가 → 폐기 ③ 새 숫자 → 폐기
④ 타임아웃/예외 → 폐기(폴백) ⑤ 상황 라벨은 정성(수치 미노출)
⑥ 챗 플로우: llm.enabled=True 면 카드 멘트가 자연화본으로, 실패 시 원본으로.
"""
import asyncio
import os
import pytest
from negotiation.chat.service.script_naturalizer import ScriptNaturalizer, build_situation
_TEMPLATE = "제안해 주신 **{input_price}원**, 감사합니다. 한 번 더 검토해 가격을 제안해 주시겠어요?"
def _fake(reply):
"""llm_call 더블 — 고정 응답."""
def call(messages):
return {"script": reply}
return call
@pytest.mark.asyncio
async def test_naturalize_success_preserves_placeholders():
nat = ScriptNaturalizer(llm_call=_fake(
"긍정적으로 검토 중입니다. 다만 **{input_price}원**은 조정 여지가 있어 보입니다. 재제안 부탁드립니다."))
out = await nat.naturalize(_TEMPLATE, situation={"라운드": "초반 조율"}, tone=2, strategy=1)
assert out and "{input_price}" in out and out != _TEMPLATE
@pytest.mark.asyncio
async def test_naturalize_rejects_missing_placeholder():
nat = ScriptNaturalizer(llm_call=_fake("가격 재검토 부탁드립니다.")) # 치환자 삭제됨
assert await nat.naturalize(_TEMPLATE) is None
@pytest.mark.asyncio
async def test_naturalize_rejects_added_placeholder():
nat = ScriptNaturalizer(llm_call=_fake("{input_price}원과 {secret_discount}까지 드리겠습니다."))
assert await nat.naturalize(_TEMPLATE) is None # 원본에 없던 변수 환각
@pytest.mark.asyncio
async def test_naturalize_rejects_new_digits():
nat = ScriptNaturalizer(llm_call=_fake("{input_price}원에서 5% 더 인하해 주시면 즉시 계약하겠습니다."))
assert await nat.naturalize(_TEMPLATE) is None # LLM 이 만든 숫자(5) 금지
@pytest.mark.asyncio
async def test_naturalize_rejects_dropped_emphasis_markers():
"""고객사가 지정한 볼드/색 마커를 LLM 이 떨어뜨리면 폐기 → 원본(스타일 보존) 폴백."""
tmpl = "제안해 주신 **{input_price}원**, {{강조|재검토}} 부탁드립니다."
# 볼드·색 마커를 모두 지운 재작성 → 검증 실패
nat = ScriptNaturalizer(llm_call=_fake("제안해 주신 {input_price}원, 재검토 부탁드립니다."))
assert await nat.naturalize(tmpl) is None
# 마커를 그대로 유지한 재작성 → 통과
nat = ScriptNaturalizer(llm_call=_fake("제시하신 **{input_price}원** 관련, {{강조|재검토}}를 요청드립니다."))
out = await nat.naturalize(tmpl)
assert out and out.count("**") == 2 and "{{강조|" in out
@pytest.mark.asyncio
async def test_naturalize_timeout_falls_back():
def slow(messages):
import time
time.sleep(0.5)
return {"script": _TEMPLATE}
nat = ScriptNaturalizer(llm_call=slow, timeout_seconds=0.05)
assert await nat.naturalize(_TEMPLATE) is None
@pytest.mark.asyncio
async def test_naturalize_exception_falls_back():
def boom(messages):
raise RuntimeError("LLM down")
nat = ScriptNaturalizer(llm_call=boom)
assert await nat.naturalize(_TEMPLATE) is None
def test_build_situation_is_qualitative_only():
"""상황 라벨에 실제 수치가 노출되지 않는다(숫자 환각 차단의 전제)."""
ctx = {"round": 2, "input_price": 10200, "anchor_price": 9900, "target_price": 10000, "item_price": 11000}
s = build_situation(ctx)
assert s["라운드"] == "초반 조율"
assert s["가격구간"] == "목표 상회(추가 인하 필요)"
joined = str(s)
for n in ("10200", "9900", "10000", "11000"):
assert n not in joined # 수치 미노출
@pytest.mark.asyncio
async def test_chat_flow_uses_naturalized_script_when_llm_enabled(db_engine):
"""llm.enabled=True + 자격증명 존재 시 가격협상 카드 멘트가 자연화본(치환 완료)으로 나온다."""
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
_TENANTS_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "tenants")
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 # 테넌트 게이트 on
svc = ChatService()
svc._naturalizer = ScriptNaturalizer(llm_call=_fake(
"【자연화】 제시해 주신 **{input_price}원** 잘 검토했습니다. 초반 조율 단계이니 한 걸음 더 부탁드립니다."))
# 자격증명 게이트 우회(테스트 환경에 키가 없어도 동작 검증)
orig_available = ScriptNaturalizer.available
ScriptNaturalizer.available = staticmethod(lambda: True)
try:
sid = None
for ui in [None, "확인", "예", "확인", "11000", "예"]:
r = await svc.chat(eng, Req_Chat(session_id=sid, user_input=ui))
sid = r.session_id
assert r.step == "가격협상" and r.card_id
assert r.script.startswith("【자연화】") # LLM 재작성본 사용
assert "11000" in r.script # 치환은 엔진이 수행(숫자 정확)
assert "{input_price}" not in r.script # 치환 완료
finally:
ScriptNaturalizer.available = orig_available
eng.config.llm.enabled = False
@pytest.mark.asyncio
async def test_chat_flow_falls_back_to_template_on_llm_failure(db_engine):
"""LLM 실패 시 카드 원본 멘트로 폴백 — 협상은 절대 멈추지 않는다."""
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
_TENANTS_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "tenants")
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
def boom(messages):
raise RuntimeError("LLM down")
svc = ChatService()
svc._naturalizer = ScriptNaturalizer(llm_call=boom)
orig_available = ScriptNaturalizer.available
ScriptNaturalizer.available = staticmethod(lambda: True)
try:
sid = None
for ui in [None, "확인", "예", "확인", "11000", "예"]:
r = await svc.chat(eng, Req_Chat(session_id=sid, user_input=ui))
sid = r.session_id
assert r.step == "가격협상" and r.card_id
assert r.script and "11000" in r.script # 원본 템플릿 + 치환으로 정상 응답
finally:
ScriptNaturalizer.available = orig_available
eng.config.llm.enabled = False