o2o-negosium-original/agent/tools/console_demo.py
hbyang 1682481b01 [feat] agent: 협상 고도화 — LLM 표현/이해층 + 카드 전술 실행계층 + ktcommerce 정리
카드 재설계("멘트 카드 → 전술 카드"):
- 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>
2026-07-10 13:37:36 +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 imarketkorea # 기본 시나리오
APP_ENV=local python -m tools.console_demo --tenant imarketkorea --no-db # DB 로깅 없이
APP_ENV=local python -m tools.console_demo --tenant imarketkorea --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}. 등록된 테넌트: 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="imarketkorea", help="테넌트 id (imarketkorea|_base)")
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()