184 lines
8.8 KiB
Python
184 lines
8.8 KiB
Python
"""콘솔 데모 — 현재까지 구현된(P0~P4) 협상 의사결정 루프를 화면 없이 콘솔에서 돌린다.
|
|
|
|
흐름: TenantConfig 로드 → 엔진 조립 → (협상 관측치) → build_state/state_index
|
|
→ 카드 선택(※임시 placeholder 정책) → reward 계산 → learning.experience_logs 로깅.
|
|
|
|
주의: 실제 Q-Table UCB 정책/대화 step 체계는 아직 미구현(H1/P5/P7).
|
|
여기 카드선택은 '가용 액션 중 최소 인덱스' 임시 정책이며 학습하지 않는다.
|
|
이 데모의 목적은 "테넌트별 config 주입·상태분류·보상·DB 격리"를 눈으로 확인하는 것.
|
|
|
|
실행:
|
|
cd agent
|
|
APP_ENV=local python -m tools.console_demo --tenant ktcommerce # 기본 시나리오
|
|
APP_ENV=local python -m tools.console_demo --tenant imarketkorea --no-db # DB 로깅 없이
|
|
APP_ENV=local python -m tools.console_demo --tenant ktcommerce --interactive
|
|
"""
|
|
|
|
import argparse
|
|
import asyncio
|
|
import os
|
|
import uuid
|
|
|
|
from common.database.db_session_manager import DB_SESSION_MNG
|
|
from common.enums import DBType, DBWRType, ErrorType
|
|
from negotiation.policies.base import ActionDecision, EpisodeState
|
|
from negotiation.qtable.domain.model.snapshot import NegotiationOutcome, NegotiationSnapshot
|
|
from negotiation.qtable.domain.service.reward_calculator import RewardCalculator
|
|
from negotiation.qtable.domain.service.state_calculator import build_state, state_index
|
|
from negotiation.qtable.infra.repository.learning_repository import LearningRepository
|
|
from tenancy.config_loader import TenantConfigLoader
|
|
from tenancy.registry import TenantEngineRegistry
|
|
|
|
_TENANTS_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "tenants")
|
|
|
|
|
|
def _placeholder_select(engine, ctx_state_index, episode: EpisodeState) -> ActionDecision:
|
|
"""임시 정책: 가용(미사용) 액션 중 최소 인덱스. propensity 는 균등분포 가정.
|
|
(실제 UCB Q-Table 정책은 H1/P5 에서 대체된다.)
|
|
"""
|
|
mask = engine.mapper.available_mask(episode.used_action_ids)
|
|
available = [a for a in engine.mapper.action_ids() if mask[a]]
|
|
if not available:
|
|
available = engine.mapper.action_ids() # 다 썼으면 리셋
|
|
action_id = available[0]
|
|
propensity = 1.0 / len(available)
|
|
return ActionDecision(
|
|
action_id=action_id,
|
|
propensity=propensity,
|
|
card_id=engine.mapper.get_card_id(action_id),
|
|
available_actions=available,
|
|
)
|
|
|
|
|
|
def _print_turn(turn, snap, st, idx, decision, reward):
|
|
print(f"\n── turn {turn} " + "─" * 40)
|
|
print(f" 관측: 매출={snap.revenue_amount:,.0f} 유통={snap.distribution_code} 파트너={snap.partner_count} "
|
|
f"수용률={snap.acceptance_ratio:.2f} 입력가={snap.input_price:,.0f} (앵커 {snap.anchor_price:,.0f}~목표 {snap.target_price:,.0f})")
|
|
print(f" 상태: revenue={st.revenue_idx} dist={st.distribution_idx} partner={st.partner_idx} "
|
|
f"accept={st.acceptance_idx} pricezone={st.price_zone_idx} → state_index={idx}")
|
|
print(f" 선택: action={decision.action_id} card={decision.card_id} "
|
|
f"propensity={decision.propensity:.3f} (가용 {decision.available_actions})")
|
|
print(f" 보상: total={reward.total:+.4f} (price={reward.price_reward:.3f} end={reward.end_reward:+.2f} "
|
|
f"penalty={reward.penalty:.3f} weight={reward.weight:.2f}) outcome={snap.outcome.value}")
|
|
|
|
|
|
def _scenario():
|
|
"""기본 3턴 시나리오 (KT 구매자: 협력사 제시가가 11000→10200→9800 으로 내려와 앵커가(9900) 이하에서 타결)."""
|
|
return [
|
|
dict(input_price=11000, acceptance_ratio=0.02, round_number=1, outcome=NegotiationOutcome.ONGOING),
|
|
dict(input_price=10200, acceptance_ratio=0.05, round_number=2, outcome=NegotiationOutcome.ONGOING),
|
|
dict(input_price=9800, acceptance_ratio=0.11, round_number=3, outcome=NegotiationOutcome.SUCCESS),
|
|
]
|
|
|
|
|
|
async def run(tenant_id: str, use_db: bool, interactive: bool):
|
|
loader = TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0)
|
|
if not loader.is_registered(tenant_id):
|
|
print(f"[!] 미등록 테넌트: {tenant_id}. 등록된 테넌트: ktcommerce, imarketkorea, _base")
|
|
return
|
|
registry = TenantEngineRegistry(loader=loader)
|
|
engine = await registry.get_engine(tenant_id)
|
|
reward_calc = RewardCalculator(engine.config.reward)
|
|
repo = LearningRepository(engine.company_id)
|
|
episode = EpisodeState()
|
|
session_id = uuid.uuid4()
|
|
|
|
print("=" * 56)
|
|
print(f" 콘솔 데모 — tenant={tenant_id} company_id={engine.company_id}")
|
|
print(f" state_space={engine.state_space_size} action_space={engine.action_space_size}")
|
|
print(f" 카드셋 예: action0={engine.mapper.get_card_id(0)} ... action{engine.action_space_size-1}={engine.mapper.get_card_id(engine.action_space_size-1)}")
|
|
print(f" DB 로깅: {'ON (learning.experience_logs)' if use_db else 'OFF'}")
|
|
print(" ※ 카드선택은 임시 placeholder 정책 (실제 UCB Q-Table 은 H1/P5)")
|
|
print("=" * 56)
|
|
|
|
turns = _interactive_turns() if interactive else _scenario()
|
|
logged = 0
|
|
for i, params in enumerate(turns, start=1):
|
|
snap = NegotiationSnapshot(
|
|
revenue_amount=params.get("revenue_amount", 20_000_000),
|
|
distribution_code=params.get("distribution_code", "A"),
|
|
partner_count=params.get("partner_count", 1),
|
|
acceptance_ratio=params["acceptance_ratio"],
|
|
input_price=params["input_price"],
|
|
anchor_price=params.get("anchor_price", 9900),
|
|
target_price=params.get("target_price", 10000),
|
|
round_number=params["round_number"],
|
|
outcome=params["outcome"],
|
|
)
|
|
try:
|
|
st = build_state(snap, engine.config.state)
|
|
idx = state_index(snap, engine.config.state)
|
|
except ValueError as ex:
|
|
print(f"[!] 상태 산출 실패: {ex}")
|
|
continue
|
|
decision = _placeholder_select(engine, idx, episode)
|
|
episode.mark_used(decision.action_id)
|
|
reward = reward_calc.calculate(snap)
|
|
_print_turn(i, snap, st, idx, decision, reward)
|
|
|
|
if use_db:
|
|
data = {
|
|
"session_id": session_id,
|
|
"state_index": idx,
|
|
"action_id": decision.action_id,
|
|
"card_id": decision.card_id,
|
|
"snapshot": snap.to_dict(),
|
|
"propensity": decision.propensity,
|
|
"turn": snap.round_number,
|
|
"available_actions": decision.available_actions,
|
|
"reward": reward.total,
|
|
"done": snap.outcome != NegotiationOutcome.ONGOING,
|
|
"settled_price": int(snap.input_price) if snap.outcome == NegotiationOutcome.SUCCESS else None,
|
|
}
|
|
err = await DB_SESSION_MNG.execute_lambda_run([DBType.MAIN.value], [lambda s: repo.log_transition(s, data)])
|
|
logged += 1 if err == ErrorType.SUCCESS else 0
|
|
|
|
if use_db:
|
|
err, cnt = await DB_SESSION_MNG.execute_lambda(
|
|
DBType.MAIN.value, DBWRType.DB_READ.value, lambda s: repo.count_experience(s)
|
|
)
|
|
print(f"\n[DB] 이번 실행에서 {logged}건 로깅. company_id={engine.company_id} 누적 experience={cnt}건")
|
|
print(" (다른 테넌트로 실행해도 서로 섞이지 않음 — company_id 논리격리 확인용)")
|
|
|
|
await DB_SESSION_MNG.dispose_all()
|
|
|
|
|
|
def _interactive_turns():
|
|
print("\n[대화형] 빈 줄(엔터)이면 기본값. outcome: o(ongoing)/s(success)/f(failure). 'q' 입력 시 종료.\n")
|
|
turns = []
|
|
rnd = 1
|
|
while True:
|
|
raw = input(f"turn {rnd} - 입력가(예 930) [q종료]: ").strip()
|
|
if raw.lower() == "q":
|
|
break
|
|
try:
|
|
input_price = float(raw) if raw else 900
|
|
except ValueError:
|
|
print(" 숫자를 입력하세요."); continue
|
|
acc = input(" 수용률(0~1, 예 0.05): ").strip()
|
|
oc = input(" 결과 o/s/f: ").strip().lower()
|
|
outcome = {"s": NegotiationOutcome.SUCCESS, "f": NegotiationOutcome.FAILURE}.get(oc, NegotiationOutcome.ONGOING)
|
|
turns.append(dict(
|
|
input_price=input_price,
|
|
acceptance_ratio=float(acc) if acc else 0.05,
|
|
round_number=rnd,
|
|
outcome=outcome,
|
|
))
|
|
rnd += 1
|
|
if outcome != NegotiationOutcome.ONGOING:
|
|
break
|
|
return turns
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser(description="협상 의사결정 루프 콘솔 데모 (P0~P4)")
|
|
ap.add_argument("--tenant", default="ktcommerce", help="테넌트 id (ktcommerce|imarketkorea)")
|
|
ap.add_argument("--no-db", action="store_true", help="DB 로깅 비활성화")
|
|
ap.add_argument("--interactive", action="store_true", help="턴마다 직접 입력")
|
|
args = ap.parse_args()
|
|
asyncio.run(run(args.tenant, use_db=not args.no_db, interactive=args.interactive))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|