IMK QA 2건(BB9A 카드 중복·8AB0 중간값 오계산)의 근본 원인이 전술 하드코딩(_TACTICS 번호 매칭)이라 전술을 데이터로 옮기고, 발동을 유효성 검사로 바꿨다. 전술 정본 = 카드 스크립트의 마지막 가격 변수(파싱), 문장으로 알 수 없는 운영 규칙(closing·min_round)만 card.*.tactic JSONB. 세션 시작 시 card_specs 스냅샷 박제. 발동 유효성(하나라도 걸리면 그 라운드 미발동 — 클램프 폐지): 목표가 초과 / 협력사 제시가 이상 / 당사 직전 제안 미만(역행 금지, IMK 논의) / 재료 결측 / 필수 변수 결측(시장가 카드 requires — 토큰 노출 방지) / 이미 쓴 카드(played_card_numbers 공용 이력) - agent: 와일드=비종결·종결=전용 풀 분리(같은 카드 2회 구조적 차단), 발동 시 자기 제안가 기록(절충가 수렴), 진입 존 프로브(빈 덱 재사용 교착 방지), 낼 카드 전무 시 소진→종결, 무효 금액 카드는 설득 폴백도 금지(playable), 에디터 anchor_price 별칭 등록, 에러 재렌더 변수 치환 - backend: 카드 사용 기록을 step 휴리스틱→번호 prefix 판정(종결 발동 card:null 누락 해소) - negodata: 카드 상세 "협상 전술" 섹션(제시 가격 파싱 표시·종결 전용·최소 라운드) + tactic API 배선 - postgres-init: tactic 컬럼·시드(WC-03/05 closing, WC-04 min_round 2), 멱등 alter 로 dev 정본화 (번호 WC-0x 정규화, WC-01·03·NGC-010 구멘트 교체, WC-05 변수 middle_price 교정) 검증: agent 178 통과 · 시나리오 하네스 14케이스(BB9A·8AB0·역행 실수치 재현) · 랜덤 퍼즈 50협상 불변식 위반 0 (불변식: 카드 중복 금지·종결 카드 자리·타결가≤목표가·표시가=타결가·토큰 잔존 금지·종료 보장)
197 lines
9.2 KiB
Python
197 lines
9.2 KiB
Python
"""대화 스크립트 리소스 검증 (Chat_server 구조 참고 적용 + KT 중립화).
|
|
|
|
1. 재협상/재견적 스크립트 step 구조 보존 (핵심 step 키 존재, next_step/모드 형태).
|
|
2. 와일드카드(wild_card_1pct/budget) 존재 + 병합.
|
|
3. 클린룸 가드: 'kt'/'commerce'/'커머스'/'Nego-Wiz' 등 특정사 표현이 남아있지 않음.
|
|
4. 브랜드 치환: {company_name}/{service_name} 가 테넌트별 값으로 치환.
|
|
5. 변수 치환: {input_price} 등 협상 변수 치환, 누락 변수는 원형 유지.
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import re
|
|
|
|
import pytest
|
|
|
|
from negotiation.chat.service.script_repository import ScriptRepository
|
|
from tenancy.config_loader import TenantConfigLoader
|
|
|
|
_TENANTS_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "tenants")
|
|
_FORBIDDEN = re.compile(r"kt\s*commerce|케이티|커머스|nego-?wiz", re.IGNORECASE)
|
|
|
|
|
|
def _repo(tenant_id="imarketkorea"):
|
|
cfg = TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0).load(tenant_id)
|
|
return ScriptRepository(cfg, _TENANTS_DIR)
|
|
|
|
|
|
def test_renegotiation_structure_preserved():
|
|
s = _repo().load_scripts("재협상")
|
|
for key in ["서비스안내", "담당자확인", "협상품목안내", "기존가격제시", "가격협상_확인", "협상완료", "협상실패", "협상종료"]:
|
|
assert key in s, f"missing step {key}"
|
|
# 조건 분기 보존 (가격협상_확인 예 → 조건 리스트)
|
|
yes = s["가격협상_확인"]["next_step"]["예"]
|
|
conds = {c["condition"] for c in yes}
|
|
assert {"check_wildcard_entry", "check_iteration_limit", "default"} <= conds
|
|
# 담당자확인 yes/no 분기
|
|
assert s["담당자확인"]["next_step"] == {"예": "협상품목안내", "아니오": "담당자확인_아니오"}
|
|
|
|
|
|
def test_requote_structure_preserved():
|
|
s = _repo().load_scripts("재견적")
|
|
for key in ["서비스안내", "가격제안", "배송형태선택", "가격협상_확인", "결과안내", "결과제출", "협상종료"]:
|
|
assert key in s
|
|
assert s["배송형태선택"]["next_input_mode"] == "delivery_type"
|
|
# 리소스 원본은 회사 용어 토큰({label_*}) — 렌더 시 회사 라벨(없으면 기본값)로 치환된다.
|
|
assert s["배송형태선택"]["input_options"] == [
|
|
"{label_delivery_type_1}", "{label_delivery_type_2}", "{label_delivery_type_3}",
|
|
]
|
|
|
|
|
|
def test_wildcard_present_and_merged():
|
|
repo = _repo()
|
|
wc = repo.wildcard_scripts()
|
|
assert "wild_card_1pct" in wc and "wild_card_budget" in wc
|
|
# 재협상 흐름에 병합됨
|
|
merged = repo.load_scripts("재협상")
|
|
assert "wild_card_1pct" in merged
|
|
assert "{offer_1pct}" in wc["wild_card_1pct"]["script"]
|
|
|
|
|
|
def test_cleanroom_no_proprietary_brand_in_any_resource():
|
|
res_dir = os.path.join(_TENANTS_DIR, "_base", "resources")
|
|
for fn in os.listdir(res_dir):
|
|
if not fn.endswith(".json"):
|
|
continue
|
|
raw = open(os.path.join(res_dir, fn), encoding="utf-8").read()
|
|
assert not _FORBIDDEN.search(raw), f"특정사 표현 잔존: {fn}"
|
|
|
|
|
|
def test_brand_substitution_per_tenant():
|
|
im = _repo("imarketkorea").get_step("서비스안내", "재협상")
|
|
assert "데모상사 B" in im["script"] and "Negosium" in im["script"]
|
|
assert "{company_name}" not in im["script"] # 치환 완료
|
|
|
|
|
|
def test_variable_substitution_and_missing_kept():
|
|
repo = _repo()
|
|
node = repo.get_step("가격협상_확인", "재협상", variables={"input_price": 950})
|
|
assert "950" in node["script"]
|
|
# 누락 변수는 원형 유지 (KeyError 안 남)
|
|
budget = repo.get_step("wild_card_budget", "재협상", variables={})
|
|
assert "{target}" in budget["script"]
|
|
|
|
|
|
def test_client_step_and_variable_mapping_load():
|
|
repo = _repo()
|
|
csm = repo.client_step_mapping()
|
|
assert csm["가격협상_확인"] == "가격협상"
|
|
vm = repo.variable_mapping()
|
|
assert vm["인터넷 최저가"] == "internet_min_price"
|
|
|
|
|
|
def test_format_script_preserves_color_markers():
|
|
"""변수 치환이 색 마커 {{강조|...}} 를 보존해야 한다(회귀: format_map 이 {{}} 를 {} 로 붕괴시킴)."""
|
|
repo = _repo()
|
|
out = repo.format_script("**{input_price}원**·{{안내|{target}원}}·{unknown}",
|
|
{"input_price": 9800, "target": 10000})
|
|
assert out == "**9800원**·{{안내|10000원}}·{unknown}" # 마커 보존 + 변수 치환 + 미등록 원형
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resolve_card_script_file_mode_default():
|
|
"""기본(source_type='file'): resolve_card_script 가 파일 카드 멘트를 반환(하위호환)."""
|
|
repo = _repo()
|
|
assert repo._config.cards.source_type == "file"
|
|
# action 0 파일 멘트가 변수 치환되어 나온다 (DB 무접근)
|
|
out = await repo.resolve_card_script(0, "NGC-B001", {"input_price": 9800})
|
|
assert out and "9800" in out
|
|
|
|
|
|
from negotiation.cards.ports.card_script_port import ICardScriptRepository
|
|
|
|
|
|
class _FakeCardRepo(ICardScriptRepository):
|
|
"""ICardScriptRepository 더블 — DB 없이 카드코드→멘트 매핑만 흉내(세션 인자 무시).
|
|
포트 상속으로 get_card_by_number 기본 구현(메타 None)을 물려받는다."""
|
|
|
|
def __init__(self, by_number: dict):
|
|
self._by = by_number
|
|
|
|
async def get_script_by_number(self, cdb, number):
|
|
from common.enums import ErrorType
|
|
return ErrorType.SUCCESS, self._by.get(number)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resolve_card_script_db_mode_prefers_db(monkeypatch):
|
|
"""source_type='backoffice_db': card.nego_cards.script(정본)를 파일보다 우선 사용 + 마커 보존."""
|
|
cfg = TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0).load("imarketkorea")
|
|
cfg.cards.source_type = "backoffice_db"
|
|
fake = _FakeCardRepo({"NGC-B001": "DB 편집 멘트 **{input_price}원** 검토 중입니다."})
|
|
repo = ScriptRepository(cfg, _TENANTS_DIR, card_repo=fake)
|
|
|
|
# execute_lambda 를 세션 없이 콜백만 실행하도록 대체(순수 단위검증)
|
|
async def _fake_lambda(_db, _wr, func):
|
|
return await func(None)
|
|
from negotiation.chat.service import script_repository as _sr
|
|
monkeypatch.setattr(_sr.DB_SESSION_MNG, "execute_lambda", _fake_lambda)
|
|
|
|
out = await repo.resolve_card_script(0, "NGC-B001", {"input_price": 9800})
|
|
assert out == "DB 편집 멘트 **9800원** 검토 중입니다." # DB 우선 + 마커(**) 불투명 보존 + 변수 치환
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resolve_card_script_db_mode_falls_back_to_file(monkeypatch):
|
|
"""DB 에 해당 카드 멘트가 없으면 파일(scripts_cards.json)로 폴백."""
|
|
cfg = TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0).load("imarketkorea")
|
|
cfg.cards.source_type = "backoffice_db"
|
|
fake = _FakeCardRepo({}) # DB 미보유
|
|
repo = ScriptRepository(cfg, _TENANTS_DIR, card_repo=fake)
|
|
|
|
async def _fake_lambda(_db, _wr, func):
|
|
return await func(None)
|
|
from negotiation.chat.service import script_repository as _sr
|
|
monkeypatch.setattr(_sr.DB_SESSION_MNG, "execute_lambda", _fake_lambda)
|
|
|
|
out = await repo.resolve_card_script(0, "NGC-B001", {"input_price": 9800})
|
|
assert out and "9800" in out # 파일 폴백 멘트
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resolve_card_script_prefer_db_for_selected_cards(monkeypatch):
|
|
"""견적에서 선택된 백오피스 카드 번호는 file 모드여도 DB 멘트를 우선한다."""
|
|
cfg = TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0).load("imarketkorea")
|
|
assert cfg.cards.source_type == "file"
|
|
fake = _FakeCardRepo({"2": "선택 카드 DB 멘트 **{input_price}원**"})
|
|
repo = ScriptRepository(cfg, _TENANTS_DIR, card_repo=fake)
|
|
|
|
async def _fake_lambda(_db, _wr, func):
|
|
return await func(None)
|
|
from negotiation.chat.service import script_repository as _sr
|
|
monkeypatch.setattr(_sr.DB_SESSION_MNG, "execute_lambda", _fake_lambda)
|
|
|
|
out = await repo.resolve_card_script(1, "2", {"input_price": 10200}, prefer_db=True)
|
|
assert out == "선택 카드 DB 멘트 **10200원**"
|
|
|
|
|
|
def test_option_label_tokens_rendered():
|
|
"""검증: 옵션에 회사 용어 토큰({label_delivery_type_*})이 있는 스텝을 정상 렌더·에러 재렌더로 출력.
|
|
기대결과: 두 경로 모두 버튼 문자열이 기본 라벨(협력사배송 등)로 치환되고 토큰이 남지 않는다."""
|
|
import os
|
|
|
|
from negotiation.chat.service.chat_engine import ChatEngine, ChatSession
|
|
from tenancy.config_loader import TenantConfigLoader
|
|
|
|
tenants = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "tenants")
|
|
cfg = TenantConfigLoader(tenants_dir=tenants, cache_ttl_seconds=0).load("_base")
|
|
engine = ChatEngine(ScriptRepository(cfg, tenants), rq_type="재견적")
|
|
session = ChatSession(session_id="s", tenant_id="_base", company_id="_base")
|
|
|
|
view = engine.render_step(session, "배송형태선택")
|
|
assert view.input_options == ["협력사배송", "지정택배배송", "픽업배송"]
|
|
|
|
# 에러 재렌더(잘못된 입력 등)도 같은 치환을 타야 한다 — raw 옵션이면 토큰이 버튼에 노출된다.
|
|
err_view = engine._error(session, "다시 선택해 주세요.")
|
|
assert err_view.input_options == ["협력사배송", "지정택배배송", "픽업배송"]
|