- 카드 상세 "협상 전술"의 제시 가격을 표시 전용 → 셀렉트로: 기본 '자동(멘트의 마지막 가격 변수)', 가격 변수가 여럿인 카드만 명시 선택이 의미. 선택지는 멘트에 실제 꽂힌 변수로 제한하고 멘트 수정으로 선택 변수가 사라지면 자동으로 리셋 — 문구≠계산 어긋남 원천 차단. 명시 선택 시에만 tactic.offer_variable 저장(agent build_card_spec 이 파싱보다 우선 적용, 기존 경로). - 종결 전용 토글은 와일드카드 폼에만 노출 — 종결 국면이 와일드카드 목록에서만 카드를 뽑으므로 협상카드에 켜면 어느 경로에서도 발동하지 않는 죽은 카드가 된다. 협상카드는 저장 시 항상 false. - 퍼즈 하네스 100케이스로 확대(시드 고정) — 100/100 불변식 위반 0(타결 94/결렬 6). - override 우선순위 단위 테스트 추가(자동=마지막 변수, tactic.offer_variable 지정 시 지정 변수). 검증: agent 179 통과 · front tsc+eslint 통과 · 랜덤 협상 100회 완주 위반 0
158 lines
6.9 KiB
Python
158 lines
6.9 KiB
Python
"""협상 퍼즈 하네스 — 랜덤 조건·랜덤 협력사 행동으로 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"(?<!\{)\{([a-z_0-9]+)\}(?!\})")
|
|
|
|
|
|
class Supplier:
|
|
"""랜덤 협력사 — 높은 시작가에서 점진 양보, 카운터는 확률적으로 수락/거절."""
|
|
|
|
def __init__(self, rng, anchor):
|
|
self.rng = rng
|
|
self.anchor = anchor
|
|
self.price = TARGET * rng.uniform(1.02, 1.30)
|
|
self.accept_p = rng.uniform(0.15, 0.5)
|
|
|
|
def next_price(self):
|
|
p = int(self.price)
|
|
# 다음 라운드를 위해 양보 — 가끔 앵커 밑까지 다이브(우선협상 유도).
|
|
self.price *= self.rng.uniform(0.90, 0.99)
|
|
if self.rng.random() < 0.15:
|
|
self.price = self.anchor * self.rng.uniform(0.95, 1.04)
|
|
return str(max(p, 100))
|
|
|
|
def choose(self, options):
|
|
if "수락" in options:
|
|
return "수락" if self.rng.random() < self.accept_p else "다른 가격 제시"
|
|
if set(options) >= {"예", "아니오"}:
|
|
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())
|