o2o-negosium-original/agent/tests/test_decision_rules.py
Mina Choi 6f5d69b5cb [feat] agent·negodata: 협상카드 전술 데이터화(tactic JSONB·스크립트 파싱) + 발동규칙 정비
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
(불변식: 카드 중복 금지·종결 카드 자리·타결가≤목표가·표시가=타결가·토큰 잔존 금지·종료 보장)
2026-08-05 11:48:14 +09:00

146 lines
7.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Phase 1 결정 스택 잔여분 검증 — 규칙 데이터화 + 선택카드 우선순위 prior.
1. 와일드카드 진입 임계(wildcard_1pct_ratio/entry_ratio)·카운터 라운드 상한(max_counter_rounds)이
하드코딩이 아니라 테넌트 config(negotiation.*)로 주입된다.
2. 의도층 prior: 갑이 견적에서 고른 카드 순서가 콜드 스타트 선택을 결정하고,
학습(Q·방문수)이 쌓이면 영향이 소멸한다 — Q-table 오염 없음.
"""
import os
import numpy as np
import pytest
from negotiation.chat.service.chat_engine import ChatEngine, ChatSession
from negotiation.chat.service.script_repository import ScriptRepository
from negotiation.policies.base import EpisodeState, PolicyContext
from negotiation.policies.qtable_policy import UCBQTablePolicy
from negotiation.qtable.domain.model.q_table import QTable
from negotiation.qtable.domain.model.snapshot import NegotiationSnapshot
from tenancy.config_loader import TenantConfigLoader
_TENANTS_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "tenants")
def _engine(**rule_overrides) -> ChatEngine:
cfg = TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0).load("imarketkorea")
for k, v in rule_overrides.items():
setattr(cfg.negotiation, k, v)
return ChatEngine(ScriptRepository(cfg, _TENANTS_DIR), rq_type="재협상")
def _session(price, anchor=10000, rnd=1, **ctx_over):
ctx = {"input_price": price, "anchor_price": anchor, "target_price": anchor + 100,
"round": rnd, "allow_selected_wildcards": False}
ctx.update(ctx_over)
return ChatSession(session_id="00000000-0000-0000-0000-00000000d001", tenant_id="imarketkorea",
company_id="imarketkorea", step="가격협상_확인", action_space_size=0, context=ctx)
# ---- 규칙 데이터화 -----------------------------------------------------------
def test_default_rules_loaded_from_config():
eng = _engine()
assert eng.rules.wildcard_1pct_ratio == 1.02
assert eng.rules.wildcard_entry_ratio == 1.05
assert eng.rules.max_counter_rounds == 3
def test_wildcard_threshold_is_config_driven():
# 기본(1.02): anchor 10000, 제시 10800 → 임계 밖 → 일반 가격협상
view = _engine().advance(_session(10800), "예")
assert view.step == "가격협상"
# 임계를 1.10 으로 완화한 테넌트 → 같은 가격에서 1% 인하 와일드카드 발동.
# 1%가(10800×0.99=10692)도 제안가 공통 유효조건(≤목표가)을 타므로 목표가를 그 위로 둔다 —
# 기본 target(10100)이면 초과 제시 금지 규칙에 걸려 발동하지 않는 게 새 정답.
view = _engine(wildcard_1pct_ratio=1.10).advance(_session(10800, target_price=11000), "예")
assert view.step == "wild_card_1pct"
# 목표가가 1%가 아래면(초과 제시 금지) 완화 임계라도 미발동 — 수락해도 결렬되는 모순 제안 차단.
view = _engine(wildcard_1pct_ratio=1.10).advance(_session(10800), "예")
assert view.step == "가격협상"
def test_max_counter_rounds_is_config_driven():
# round=3(카운터 2회 경과), 제시 11000: 기본 상한 3 → 아직 협상 지속
view = _engine().advance(_session(11000, rnd=3), "예")
assert view.step == "가격협상"
# 상한 1 → 종결 국면 진입: 곧장 실패가 아니라 종결 전술 발동 지점(force_closing)으로
s = _engine(max_counter_rounds=1), _session(11000, rnd=3)
view = s[0].advance(s[1], "예")
assert view.step == "가격협상" and s[1].context.get("force_closing") is True
# 종결 전술까지 소진(closing_played) 후에도 target(10100) 초과 → 결렬
view = _engine(max_counter_rounds=1).advance(_session(11000, rnd=3, closing_played=True), "예")
assert view.step == "협상실패"
# 종결 후 제시가가 target 이하로 내려오면 결렬이 아니라 타결 (새 규칙 — 구현 전엔 무조건 실패)
view = _engine(max_counter_rounds=1).advance(
_session(10050, rnd=3, closing_played=True, wildcard_used=True), "예")
assert view.step == "협상완료"
# ---- 선택카드 우선순위 prior ---------------------------------------------------
def _snap():
return NegotiationSnapshot(revenue_amount=1, distribution_code="A", partner_count=1,
acceptance_ratio=0.1, input_price=900, anchor_price=800, target_price=1000)
def _ctx(prior=None, mask=None, n=11):
return PolicyContext(state_index=0, snapshot=_snap(), action_space_size=n,
available_mask=mask, prior_bonus=prior, episode=EpisodeState())
def test_prior_decides_cold_start_order():
"""콜드 스타트(Q=0·방문 0)에서는 갑이 먼저 고른 카드(높은 prior)가 먼저 나간다."""
qt = QTable(2, 11)
prior = np.zeros(11)
prior[7], prior[2] = 0.3, 0.15 # 선택 순서: action7 → action2
mask = np.zeros(11, dtype=bool)
mask[2] = mask[7] = True
p = UCBQTablePolicy(qt, mark_visits=False)
assert p.select(_ctx(prior=prior, mask=mask)).action_id == 7
def test_prior_decays_as_learning_accumulates():
"""학습이 쌓이면(Q·방문수) prior 는 1/(1+visits) 로 감쇠 — Q 가 지배한다."""
qt = QTable(2, 11)
qt.q[0, 2] = 1.0 # action2 가 학습상 우월
qt.visits[0, 2] = 5
qt.visits[0, 7] = 5 # 탐색 보너스 동률
prior = np.zeros(11)
prior[7] = 0.3 # 갑 선호는 action7
mask = np.zeros(11, dtype=bool)
mask[2] = mask[7] = True
p = UCBQTablePolicy(qt, mark_visits=False)
assert p.select(_ctx(prior=prior, mask=mask)).action_id == 2
def test_no_prior_keeps_existing_behavior():
"""prior 미주입(None) 시 기존 UCB 동작 그대로 — 회귀 없음."""
qt = QTable(2, 4)
qt.q[0] = np.array([1.0, 9.0, 2.0, 0.0])
qt.visits[0] = np.array([5, 5, 5, 5])
p = UCBQTablePolicy(qt, exploration_constant=0.1, mark_visits=False)
assert p.select(_ctx(n=4)).action_id == 1
def test_selection_prior_built_from_quotation_order():
"""ChatService._selection_prior — 견적 선택 순서 → prior 배열 (앞선 선택일수록 큼)."""
from services.chat_service import ChatService
class _Mapper:
_m = {i: f"NGC-B{i + 1:03d}" for i in range(11)}
def get_action_id(self, num):
return next((a for a, c in self._m.items() if c == num), None)
class _Engine:
mapper = _Mapper()
action_space_size = 11
session = ChatSession(session_id="00000000-0000-0000-0000-00000000d002", tenant_id="t", company_id="t",
context={"selected_nego_card_numbers": ["NGC-B008", "NGC-B003"]})
prior = ChatService._selection_prior(_Engine(), session)
assert prior is not None
assert prior[7] > prior[2] > 0 # 먼저 고른 NGC-B008(action7) 이 더 큼
assert prior[[0, 1, 4, 10]].sum() == 0 # 미선택 카드는 0
# 선택이 1장이면 순서 정보가 없어 None
session.context["selected_nego_card_numbers"] = ["NGC-B008"]
assert ChatService._selection_prior(_Engine(), session) is None