o2o-negosium-original/agent/negotiation/policies/base.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

112 lines
4.2 KiB
Python

"""정책 컨텍스트/결정 + 요청 스코프 episode 상태 (계획서 B 동시성, F 추상화 출발점).
핵심(계획서 리스크): Chat_server 의 UCBPolicy._episode_actions 는 **인스턴스 멤버**라,
테넌트 엔진을 여러 요청이 공유하면 세션 간 오염이 발생한다. 여기서는 episode 상태를
**요청 스코프 객체(EpisodeState)** 로 외부화하고, 정책은 stateless 하게 이를 주입받는다.
NegotiationPolicy(상위 추상)의 train/predict_action_dist 등 전체 인터페이스는 H0 에서 확장한다.
P4 는 동시성에 필요한 PolicyContext/ActionDecision/EpisodeState 만 정의한다.
"""
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import List, Optional, Set
import numpy as np
from negotiation.qtable.domain.model.snapshot import NegotiationSnapshot
@dataclass
class EpisodeState:
"""한 협상 세션(요청 흐름)의 가변 상태. 절대 정책/엔진 인스턴스에 두지 않는다.
used_action_ids: 이 에피소드에서 이미 제시한 action(중복 방지 마스킹용).
"""
used_action_ids: Set[int] = field(default_factory=set)
def mark_used(self, action_id: int):
self.used_action_ids.add(action_id)
@dataclass
class PolicyContext:
"""정책이 행동을 고르기 위해 받는 입력. state_index(이산)와 snapshot(연속) 모두 포함
→ Q-Table/LinUCB/Offline RL 이 같은 컨텍스트를 공유한다(계획서 F).
"""
state_index: int
snapshot: NegotiationSnapshot
action_space_size: int
episode: EpisodeState
available_mask: Optional[np.ndarray] = None # None 이면 used_action_ids 로 산출
# 의도층 prior(Phase 1): 갑이 견적에서 고른 카드 순서 등 사전 선호. 방문수로 감쇠되어
# 콜드 스타트 선택만 편향하고 학습(Q)이 쌓이면 영향이 소멸한다 — Q-table 오염 없음.
prior_bonus: Optional[np.ndarray] = None
@dataclass
class ActionDecision:
"""정책의 출력. propensity(행동확률)는 OPE 의 전제(계획서 H0)."""
action_id: int
propensity: float
card_id: Optional[str] = None
q_value: Optional[float] = None
ucb_score: Optional[float] = None
available_actions: Optional[List[int]] = None
@dataclass
class Transition:
"""학습용 (s, a, r, s', done) + OPE 메타. experience_logs 한 row 에 대응."""
state_index: int
action_id: int
reward: float
next_state_index: Optional[int] = None
done: bool = False
propensity: Optional[float] = None
class NegotiationPolicy(ABC):
"""상위 정책 추상 (계획서 F). Q-Table/LinUCB/Offline RL 이 모두 이 인터페이스를 구현한다.
Chat_server 의 Policy.select_action(state_index, q_values: np.ndarray, ...) 는 Q값 배열에
결합돼 LinUCB/CQL 을 감쌀 수 없었다. 여기서는 PolicyContext(state_index+snapshot)를 받아
ActionDecision(propensity 포함)을 돌려주는 알고리즘-중립 인터페이스로 둔다.
"""
name: str = "base"
@abstractmethod
def select(self, ctx: PolicyContext) -> ActionDecision:
"""행동 선택. propensity(선택확률)를 반드시 채운다(OPE 전제)."""
@abstractmethod
def update(self, transition: Transition) -> None:
"""온라인 1-스텝 갱신 (Q-learning 등). 배치 학습은 train()."""
def train(self, transitions: List[Transition]) -> None:
"""오프라인 배치 학습 (기본: update 반복). 알고리즘별 override."""
for t in transitions:
self.update(t)
def predict_action_dist(self, ctx: PolicyContext) -> np.ndarray:
"""상태에서의 행동 분포 (OPE/시뮬레이터용). 기본: 선택 액션에 1.0."""
dist = np.zeros(ctx.action_space_size)
dist[self.select(ctx).action_id] = 1.0
return dist
def warm_start(self, other: "NegotiationPolicy") -> None:
"""베이스 정책으로부터 초기화 (계획서 D). 기본: 미지원."""
raise NotImplementedError
def snapshot(self) -> dict:
"""직렬화 가능한 파라미터 스냅샷(저장용). 알고리즘별 구현."""
raise NotImplementedError
def load_snapshot(self, data: dict) -> None:
raise NotImplementedError