카드 카탈로그(negodata)가 Q-table action space 를 정의하는 정본이 되고, 카드 변경이
config 수정·학습 손실 없이 agent 에 자동 반영되는 고리를 완성.
- action space 정리: 카탈로그 전체(NGC-001~011, 11장) 고정, 견적별 선택은 축소가 아니라
available_mask(_selection_mask) 로 처리 — action_id↔카드 대응을 견적마다 일정하게 유지해
Q-table 학습 일관성 보장. 구 인덱스 방식(selected[action_id]) 폐기.
- ① 카탈로그 DB 정본화: action_mapping.type=db 면 registry 가 card.nego_cards(user_id NULL,
number 순) 조회로 action_to_card 동적 구성(파일은 폴백). port/adapter(card_catalog_*).
_base=type:db. → negodata 카드 추가/삭제 시 config 수정 불필요.
- ② 차원 변경 학습 보존 마이그레이션: migrate_active_version_dim — 겹치는 셀 복사
(append/truncate 안전) + 새 카드 fresh. model_store.load 가 차원 불일치 시 호출.
- ③ reload 엔드포인트: /v1/catalog-refresh(테넌트) · /v1/catalog-refresh-all(전역, 화이트리스트).
- ④ 브랜드: company_profile_repo — 자동 온보딩 고객사(company_id UUID)는
company.companies.name 으로 {company_name} 채움. 데모 테넌트는 파일 유지.
- 크로스서비스: negodata card_service 가 공용 nego 카드 변경 시 agent_notify 로 전역 리로드 알림
(best-effort, is_test skip). config 에 agent_base_url.
- 하니스 episodes 400→600(action 11 수렴). 테스트 갱신·추가로 agent 98/98.
알려진 갭(후속): per-company 카탈로그 스코프(회사 카드도 action space 포함), 카탈로그 중간
삭제 시 카드번호 기반 마이그레이션.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
109 lines
4.8 KiB
Python
109 lines
4.8 KiB
Python
"""P1 검증 (계획서 P1, CLEANROOM.md 반영).
|
||
|
||
검증 기준 변경: "Chat_server 하드코딩과 1:1 일치"(독점값 복제)를 폐기하고,
|
||
"우리 플랫폼 중립 기본값이 정확히 로드 + deep-merge + 차원 산출이 동작"으로 대체한다.
|
||
|
||
1. 데모 테넌트 config 가 플랫폼 중립 기본값으로 로드된다(합성 카드 코드/중립 라벨).
|
||
2. _base deep-merge 단위테스트 (상속 + 부분 오버라이드).
|
||
3. state_space_size 자동 산출 (3×3×3×3×2 = 162), action_space_size = 9.
|
||
4. 두 번째 테넌트 오버라이드가 base 위에 정확히 병합 + 차원 동일(warm-start 호환).
|
||
5. 독점 카드 코드(NC26-*)·verbatim 라벨이 레포 config 에 없다(클린룸 가드).
|
||
"""
|
||
|
||
import os
|
||
|
||
from tenancy.config_loader import TenantConfigLoader, _deep_merge
|
||
|
||
_TENANTS_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "tenants")
|
||
|
||
|
||
def _loader() -> TenantConfigLoader:
|
||
return TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0)
|
||
|
||
|
||
def test_platform_neutral_defaults_load():
|
||
cfg = _loader().load("ktcommerce")
|
||
|
||
# 우리 플랫폼 중립 기본값 (CLEANROOM.md)
|
||
assert cfg.state.revenue.thresholds == [10_000_000, 50_000_000]
|
||
assert cfg.state.revenue.weights == [0.3, 0.6, 1.0]
|
||
assert cfg.state.revenue.descriptions == ["low", "mid", "high"]
|
||
assert cfg.state.distribution.code_map == {"A": 0, "B": 1, "C": 2}
|
||
assert cfg.state.partner.weights == [0.5, 1.0, 0.3]
|
||
assert cfg.state.acceptance.thresholds == [0.03, 0.09]
|
||
assert cfg.state.price_zone.weights == [1.0, 0.5]
|
||
|
||
# reward: 균등 가중치 중립 기본값
|
||
assert cfg.reward.beta == 0.2
|
||
assert cfg.reward.success_reward == 1.0
|
||
assert cfg.reward.failure_penalty == -0.5
|
||
assert (cfg.reward.w1, cfg.reward.w2, cfg.reward.w3, cfg.reward.w4, cfg.reward.w5) == (0.2, 0.2, 0.2, 0.2, 0.2)
|
||
|
||
# policy: 표준 UCB 기본값
|
||
assert cfg.policy.type == "ucb"
|
||
assert cfg.policy.learning_rate == 0.1
|
||
assert cfg.policy.gamma == 0.95
|
||
assert abs(cfg.policy.params["exploration_constant"] - 2 ** 0.5) < 1e-12
|
||
|
||
|
||
def test_state_space_and_action_space_size():
|
||
cfg = _loader().load("ktcommerce")
|
||
assert cfg.state.state_space_size == 162 # 3×3×3×3×2 (차원 구성은 기능적 설계)
|
||
assert cfg.action_mapping.action_space_size == 11
|
||
# 합성 데모 카드 코드 (우리 스킴)
|
||
assert cfg.action_mapping.action_to_card["0"] == "NGC-A001"
|
||
assert cfg.action_mapping.action_to_card["8"] == "NGC-A009"
|
||
|
||
|
||
def test_base_deep_merge_unit():
|
||
base = {"a": 1, "nested": {"x": 1, "y": 2}, "list": [1, 2]}
|
||
override = {"b": 2, "nested": {"y": 20, "z": 30}, "list": [9]}
|
||
merged = _deep_merge(base, override)
|
||
assert merged["a"] == 1
|
||
assert merged["b"] == 2
|
||
assert merged["nested"] == {"x": 1, "y": 20, "z": 30} # dict 키 단위 병합
|
||
assert merged["list"] == [9] # 리스트는 통째 교체
|
||
|
||
|
||
def test_second_tenant_overrides_merged_on_base():
|
||
cfg = _loader().load("imarketkorea")
|
||
# 오버라이드된 값
|
||
assert cfg.state.revenue.thresholds == [30_000_000, 100_000_000]
|
||
assert cfg.reward.failure_penalty == -0.7
|
||
assert cfg.reward.beta == 0.25
|
||
# 오버라이드 안 한 값은 base 상속
|
||
assert cfg.state.distribution.code_map == {"A": 0, "B": 1, "C": 2}
|
||
assert cfg.reward.success_reward == 1.0
|
||
assert cfg.policy.type == "ucb"
|
||
# 차원은 데모 테넌트 A 와 동일(162) → base warm-start 호환(P5)
|
||
assert cfg.state.state_space_size == 162
|
||
assert cfg.action_mapping.action_space_size == 11
|
||
assert cfg.action_mapping.action_to_card["0"] == "NGC-B001"
|
||
|
||
|
||
def test_base_self_does_not_inherit():
|
||
cfg = _loader().load("_base")
|
||
assert cfg.tenant_id == "_base"
|
||
# _base 는 기본 11카드(자동 온보딩 테넌트가 물려받음, base 정책 162×11 정합)
|
||
assert cfg.action_mapping.action_space_size == 11
|
||
|
||
|
||
def test_is_registered():
|
||
loader = _loader()
|
||
assert loader.is_registered("ktcommerce") is True
|
||
assert loader.is_registered("imarketkorea") is True
|
||
# 미등록 company_id(uuid 등)는 _base 자동 온보딩 대상이라 '등록됨'으로 본다. 빈 키만 미등록.
|
||
assert loader.is_registered("00000000-0000-0000-0000-000000000001") is True
|
||
assert loader.is_registered("") is False
|
||
|
||
|
||
def test_no_proprietary_card_codes_or_labels_in_repo():
|
||
"""클린룸 가드: 독점 카드 코드/ verbatim 라벨이 로드된 config 에 존재하지 않는다."""
|
||
for tid in ("_base", "ktcommerce", "imarketkorea"):
|
||
cfg = _loader().load(tid)
|
||
cards = " ".join(cfg.action_mapping.action_to_card.values())
|
||
assert "NC26" not in cards # 참고 엔진의 고유 카드 코드
|
||
# verbatim 한글 라벨이 아닌 중립 라벨 사용
|
||
for d in cfg.state.revenue.descriptions:
|
||
assert "원" not in d
|