카드 재설계("멘트 카드 → 전술 카드"):
- tactics.py 신설: 카드번호→전술(카운터 산식) 레지스트리, min(counter,target) 클램프
- 카운터 수락=즉시 타결(pending_counter_price 일반화, 구 offer_1pct 흡수)
- 목표가 초과 타결 금지(성공스텝 진입 가드) + "카드 소진=실패" 폐지→종결 국면
- 선택형 와일드카드(WC-*) 발동 + card.wild_cards 멘트 DB 어댑터
LLM 계층:
- Phase 2 표현층 ScriptNaturalizer(카드 멘트 자연화, 마커·치환자·숫자 보존 검증)
- Phase 3 이해층 InputInterpreter(자유발화 NLU→기대입력, 한국어 가격 파서)
- OPENAI_API_KEY env override(server_configs) + 전역 자격증명 게이트
결정 스택(Phase 1):
- 협상 규칙 데이터화(negotiation.wildcard_*_ratio/max_counter_rounds)
- 선택카드 우선순위 prior(UCB 방문수 감쇠, Q-table 오염 없음)
버그픽스:
- 인하율 음수 표기 제거 + 인상/동일/인하 구분(discount_phrase)
- 자연화 강조마커 보존(볼드/색 소실 시 원본 폴백)
- 카드 시드 가격변수(prev_partner_price·target_mid_price·middle_price 등) 치환
정리:
- ktcommerce 테넌트 삭제 + 테스트 21파일 imarketkorea/_base 로 마이그레이션
- 실 LLM 호출 차단 conftest 가드
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
147 lines
5.7 KiB
Python
147 lines
5.7 KiB
Python
"""P2 검증 (계획서 P2 _검증_, 클린룸 반영).
|
|
|
|
기준 변경: "신규 결과 == Chat_server 결과"(독점 복제)가 아니라
|
|
"동일 config + 동일 입력 → 결정론적 동일 출력 + config 주입이 실제로 반영"으로 검증한다.
|
|
|
|
1. build_state/state_index 결정론 + 범위 [0, state_space_size).
|
|
2. mixed-radix 인코딩이 전 차원조합에 대해 [0,162) 전단사(bijection).
|
|
3. config 주입 효과: 테넌트별 임계값이 다르면 같은 입력이 다른 상태로 분류된다.
|
|
4. reward 결정론 + config(failure_penalty 등) 반영.
|
|
5. ActionCardMapper 라운드트립 + 중복방지 마스킹.
|
|
"""
|
|
|
|
import itertools
|
|
import os
|
|
|
|
import numpy as np
|
|
|
|
from negotiation.cards.action_card_mapper import ActionCardMapper
|
|
from negotiation.qtable.domain.model.snapshot import NegotiationOutcome, NegotiationSnapshot
|
|
from negotiation.qtable.domain.model.state import encode_index
|
|
from negotiation.qtable.domain.service.reward_calculator import RewardCalculator
|
|
from negotiation.qtable.domain.service.state_calculator import build_state, state_dims, state_index
|
|
from tenancy.config_loader import TenantConfigLoader
|
|
|
|
_TENANTS_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "tenants")
|
|
|
|
|
|
def _cfg(tid: str):
|
|
return TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0).load(tid)
|
|
|
|
|
|
def _snapshot(**over) -> NegotiationSnapshot:
|
|
base = dict(
|
|
revenue_amount=5_000_000,
|
|
distribution_code="A",
|
|
partner_count=1,
|
|
acceptance_ratio=0.05,
|
|
input_price=9800,
|
|
anchor_price=9900, # KT 앵커링가 (anchor < target)
|
|
target_price=10000, # KT 목표 매입가
|
|
round_number=1,
|
|
outcome=NegotiationOutcome.ONGOING,
|
|
)
|
|
base.update(over)
|
|
return NegotiationSnapshot(**base)
|
|
|
|
|
|
def test_build_state_deterministic_and_in_range():
|
|
cfg = _cfg("_base")
|
|
snap = _snapshot()
|
|
s1 = build_state(snap, cfg.state)
|
|
s2 = build_state(snap, cfg.state)
|
|
assert s1 == s2 # 결정론
|
|
idx = state_index(snap, cfg.state)
|
|
assert idx == state_index(snap, cfg.state)
|
|
assert 0 <= idx < cfg.state.state_space_size
|
|
|
|
|
|
def test_encode_index_known_example():
|
|
# dims=[3,3,3,3,2], 인덱스 (1,0,0,0,1) → ((((1)*3+0)*3+0)*3+0)*2+1 = 54*1 +1 = 55
|
|
assert encode_index([1, 0, 0, 0, 1], [3, 3, 3, 3, 2]) == 55
|
|
# 최소/최대
|
|
assert encode_index([0, 0, 0, 0, 0], [3, 3, 3, 3, 2]) == 0
|
|
assert encode_index([2, 2, 2, 2, 1], [3, 3, 3, 3, 2]) == 161
|
|
|
|
|
|
def test_mixed_radix_bijection_over_full_space():
|
|
cfg = _cfg("_base")
|
|
dims = state_dims(cfg.state)
|
|
assert dims == [3, 3, 3, 3, 2]
|
|
seen = set()
|
|
for combo in itertools.product(*[range(d) for d in dims]):
|
|
idx = encode_index(list(combo), dims)
|
|
seen.add(idx)
|
|
# 162개 조합이 0..161 에 1:1
|
|
assert seen == set(range(cfg.state.state_space_size))
|
|
|
|
|
|
def test_config_injection_changes_classification():
|
|
# revenue=20,000,000 원: _base(th=[10M,50M]) → mid(1), imarketkorea(th=[30M,100M]) → low(0)
|
|
snap = _snapshot(revenue_amount=20_000_000)
|
|
base = build_state(snap, _cfg("_base").state)
|
|
imk = build_state(snap, _cfg("imarketkorea").state)
|
|
assert base.revenue_idx == 1
|
|
assert imk.revenue_idx == 0
|
|
assert base != imk # 같은 입력이 테넌트 config 에 따라 다른 상태
|
|
|
|
|
|
def test_distribution_unknown_code_raises():
|
|
cfg = _cfg("_base")
|
|
snap = _snapshot(distribution_code="Z") # code_map 에 없음
|
|
try:
|
|
build_state(snap, cfg.state)
|
|
assert False, "unknown distribution code should raise"
|
|
except ValueError:
|
|
pass
|
|
|
|
|
|
def test_price_zone_and_partner_buckets():
|
|
cfg = _cfg("_base").state
|
|
# 제시가 ≤ 앵커가(9900) → 우선협상 구간(0)
|
|
assert build_state(_snapshot(input_price=9800), cfg).price_zone_idx == 0
|
|
# 제시가 > 앵커가 → 협상 지속 구간(1)
|
|
assert build_state(_snapshot(input_price=10500), cfg).price_zone_idx == 1
|
|
# partner: 0->none(2), 1->single(0), 3->multiple(1)
|
|
assert build_state(_snapshot(partner_count=0), cfg).partner_idx == 2
|
|
assert build_state(_snapshot(partner_count=1), cfg).partner_idx == 0
|
|
assert build_state(_snapshot(partner_count=3), cfg).partner_idx == 1
|
|
|
|
|
|
def test_reward_deterministic_and_config_driven():
|
|
snap = _snapshot(outcome=NegotiationOutcome.FAILURE, round_number=2)
|
|
base = _cfg("_base")
|
|
imk = _cfg("imarketkorea")
|
|
base_rc = RewardCalculator(base.reward, base.state) # failure_penalty -0.5
|
|
imk_rc = RewardCalculator(imk.reward, imk.state) # failure_penalty -0.7
|
|
|
|
r1 = base_rc.calculate(snap)
|
|
r2 = base_rc.calculate(snap)
|
|
assert r1 == r2 # 결정론
|
|
assert r1.end_reward == -0.5 # config 반영
|
|
assert imk_rc.calculate(snap).end_reward == -0.7
|
|
|
|
# 성공 라운드 보상이 실패보다 크다 (방향성)
|
|
success = _snapshot(outcome=NegotiationOutcome.SUCCESS, round_number=0)
|
|
assert base_rc.calculate(success).total > base_rc.calculate(_snapshot(outcome=NegotiationOutcome.FAILURE, round_number=0)).total
|
|
|
|
|
|
def test_action_card_mapper_roundtrip_and_mask():
|
|
cfg = _cfg("_base")
|
|
mapper = ActionCardMapper(cfg.action_mapping)
|
|
assert mapper.action_space_size == 11
|
|
assert mapper.get_card_id(0) == "NGC-001"
|
|
assert mapper.get_action_id("NGC-001") == 0
|
|
assert mapper.get_card_id(99) is None
|
|
|
|
# 중복방지 마스킹: 사용한 action 제외
|
|
mask = mapper.available_mask(used_action_ids={0, 3})
|
|
assert mask.dtype == np.bool_
|
|
assert mask[0] == False and mask[3] == False
|
|
assert mask[1] == True
|
|
assert mask.sum() == 9
|
|
|
|
# reload 로 다른 테넌트 카드셋 교체
|
|
mapper.reload(_cfg("imarketkorea").action_mapping)
|
|
assert mapper.get_card_id(0) == "NGC-B001"
|