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 (불변식: 카드 중복 금지·종결 카드 자리·타결가≤목표가·표시가=타결가·토큰 잔존 금지·종료 보장)
146 lines
7.1 KiB
Python
146 lines
7.1 KiB
Python
"""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
|