o2o-negosium-original/agent/tools/console_demo.py
hbyang be1b4b968f [fix] agent: 보상함수를 q-table 상세 설명 v4 명세에 정합화
- 동적 가중치 W: 라운드 감쇠 → 상태 5차원 가중합 clip(Σwᵢ·Sᵢ, 0.2, 0.8) (식 12~13, 기존 w1~w5 연결)
- 종료보상에 (1−W) 적용: R = W×R_price + (1−W)×R_end − λ×round (식 8)
- R_price 3단계: P<anchor 시 1+β·(anchor−P)/anchor 초과달성 보너스 추가 (식 9~11, beta 의미 재정의)
- price zone 경계는 명세(T)와 달리 anchor 유지(우선협상 규칙이 실제 의사결정 경계) — 사유 docstring 명시
- state_calculator/config 의 낡은 반대 컨벤션(anchor≥target) 주석 정정
- RewardCalculator(RewardConfig, StateConfig) 시그니처 변경 + 호출부 5곳 갱신, 테스트 기대값 정정 (76/76 PASS)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 14:10:44 +09:00

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, 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()