"""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