"""콘솔 데모 — 현재까지 구현된(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, engine.config.state) 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()