"""협상 퍼즈 하네스 — 랜덤 조건·랜덤 협력사 행동으로 N회 완주시키고 불변식 위반을 수집한다. 시드 고정(재현 가능). test_ 접두사 없음 — pytest 수집 대상 아님, 수동 실행 전용: docker run --rm -v $PWD/agent:/work -w /work -e APP_ENV=local -e DB_HOST=host.docker.internal \ o2o-negosium-agent sh -lc "pip install -q pytest pytest-asyncio httpx; python tests/fuzz_negotiation.py" 케이스마다 검사하는 불변식: 1. 전 턴 success 2. 같은 카드 2회 발동 금지 3. 종결 전용(WC-03·05)은 가격협상_카운터에서만 / 비종결 와일드는 wild_card_dynamic 에서만 4. 타결 시 타결가 ≤ 목표가 5. 카운터/1% 수락으로 타결하면 그 멘트에 타결가 표기 6. 멘트·버튼에 미치환 토큰({xxx}) 잔존 금지 7. 턴 상한(60) 안에 반드시 종료 """ import asyncio import random import re import sys import uuid sys.path.insert(0, "/work") from router.v1.chat.protocol import Req_Chat # noqa: E402 from services.chat_service import ChatService, reset_sessions # noqa: E402 from tenancy.config_loader import TenantConfigLoader # noqa: E402 from tenancy.registry import TenantEngineRegistry # noqa: E402 from tests.test_card_tactics import _TENANTS_DIR, _cleanup, _seed_quote_session # noqa: E402 N = 100 SEED = 20260805 TARGET = 10_000 NEGO_POOL = ["NGC-001", "NGC-002", "NGC-003", "NGC-004", "NGC-005", "NGC-007", "NGC-008", "NGC-010", "NGC-011"] WILD_POOL = ["WC-01", "WC-02", "WC-03", "WC-04", "WC-05"] CLOSING = {"WC-03", "WC-05"} TOKEN_RE = re.compile(r"(?= {"예", "아니오"}: return "예" if self.rng.random() < max(self.accept_p, 0.5) else "아니오" return options[0] if options else "확인" async def run_case(idx, rng): anchor = int(TARGET * rng.choice([0.99, 0.99, 0.97, 0.95, 1.0])) nego = rng.sample(NEGO_POOL, rng.randint(1, 5)) wild = rng.sample(WILD_POOL, rng.randint(0, 5)) sup = Supplier(rng, anchor) reset_sessions() sid = uuid.uuid4() qid, ver = await _seed_quote_session(sid, nego, wild_numbers=wild, target=TARGET, anchor=anchor) violations, fired, settled, outcome, ended = [], [], None, None, False try: reg = TenantEngineRegistry(loader=TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0)) eng = await reg.get_engine(str(uuid.uuid4())) svc = ChatService() ui, last_input = None, None for _turn in range(60): r = await svc.chat(eng, Req_Chat(session_id=str(sid), user_input=ui)) if r.result.success is not True: violations.append(f"턴 실패 input={ui} msg={r.msg}") break script, opts = r.script or "", list(r.input_options or []) if TOKEN_RE.search(script): violations.append(f"미치환 토큰(script): {TOKEN_RE.findall(script)} @ {r.step}") for o in opts: if TOKEN_RE.search(o): violations.append(f"미치환 토큰(option): {o} @ {r.step}") if r.card_id: fired.append((r.step, r.card_id)) if r.settled_price is not None: settled = r.settled_price # 카운터/1% '수락' 타결이면 마지막 카운터 멘트에 타결가가 보였어야 한다. if last_input in ("수락",) and str(settled) not in (last_counter or ""): violations.append(f"표시가≠타결가: {settled} not in counter script") if r.step in ("가격협상_카운터", "wild_card_dynamic", "wild_card_1pct"): last_counter = script if r.chat_end: outcome, ended = r.outcome, True break # 다음 입력 결정 last_input = None if r.input_mode == "price": ui = sup.next_price() elif opts: ui = sup.choose(opts) last_input = ui else: ui = "확인" if not ended: violations.append("60턴 내 미종료") # 카드 불변식 ids = [c for _, c in fired] if len(ids) != len(set(ids)): violations.append(f"카드 중복: {ids}") for step, c in fired: if c in CLOSING and step != "가격협상_카운터": violations.append(f"종결 카드 {c} 가 {step} 에서 발동") if c.startswith("WC") and c not in CLOSING and step != "wild_card_dynamic": violations.append(f"비종결 와일드 {c} 가 {step} 에서 발동") if outcome == "success": if settled is None: violations.append("성공인데 settled 없음") elif settled > TARGET: violations.append(f"목표가 초과 타결: {settled}") finally: await _cleanup(sid, qid, ver) return {"idx": idx, "anchor": anchor, "nego": nego, "wild": wild, "fired": fired, "settled": settled, "outcome": outcome, "violations": violations} async def main(): rng = random.Random(SEED) results, bad = [], [] for i in range(N): res = await run_case(i, random.Random(rng.random())) results.append(res) if res["violations"]: bad.append(res) tag = "OK " if not res["violations"] else "BAD" print(f"[{tag}] #{i:02d} anchor={res['anchor']} nego={len(res['nego'])} wild={len(res['wild'])} " f"fired={'→'.join(c for _, c in res['fired']) or '-'} settled={res['settled']} {res['outcome']}") ok = sum(1 for r in results if not r["violations"]) succ = sum(1 for r in results if r["outcome"] == "success") print(f"\n===== {ok}/{N} clean · 타결 {succ} / 결렬 {N - succ} =====") for r in bad: print(f"\n#{r['idx']} 위반: nego={r['nego']} wild={r['wild']} anchor={r['anchor']}") for v in r["violations"]: print(" -", v) from common.database.db_session_manager import DB_SESSION_MNG await DB_SESSION_MNG.dispose_all() sys.exit(0 if not bad else 1) asyncio.run(main())