판정 룰(앵커타결/와일드존/3라운드결렬)과 카드 선택을 학습 정책으로 대체: 수락/역제안 금액/압박 화법/결렬 전부 행동 30개(수락1+결렬1+역제안 6단x화법4+압박4)에서 선택. - autonomy_actions: 행동 공간·특징 인코딩 (학습/서빙 공유) - autonomy_store: numpy 서빙 + 행동 봉투 7개(수락<=목표가 / 역제안 단조 / 역제시·결렬은 설득 2회 후 해금 / 마무리국면 압박 금지 / 첫 역제안 앵커 이하 / 최종제안 1회 보장) - chat_engine: 자율 스텝(역제안/최종제안/압박1~4), 최종제안 금액=목표가, 턴캡 12 - ment_generator: Gemini 멘트 생성 + 가드(숫자 화이트리스트·목표가 비공개·금지어·문장완결, 실패시 템플릿 폴백, 6초 컷), 인터넷최저가 근거 인용(수집됨+제시가 초과시만), 대화 기억 - chat_service: 자율 행동 experience_logs 로깅(AUT|종류|위치|전략), 대화기억 ctx 관리 - context loader/CRUD: 인터넷최저가·견적기간·협력사 이력 로드 (v3 상태 21차원) - train_full_autonomy: 시뮬 15k ep — 앵커율 0.8~6% 정합, 협력사 현실화(컷반발·반복짜증· 양보 상호성), 관측성 마스크(마감 40% 미관측·15% 전부미상 — 서빙 중립값 분포 정합), 보상 수정(목표가 초과 타결=결렬 취급) - 서빙 v3.5 (v3.3 목표가 즉시지르기 퇴화, v3.4 소액지형 첫턴 통보 퇴화 — 게이트 반려 이력 보관) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
86 lines
4.2 KiB
Python
86 lines
4.2 KiB
Python
"""완전 자율 협상 행동 공간 (numpy 전용 — 학습(tools)과 서빙(policy)이 공유).
|
||
|
||
카드 카탈로그 대신 행동의 '의미'만 남긴다:
|
||
ACCEPT 현재 제시가로 타결
|
||
WALK 협상 결렬 선언
|
||
COUNTER(q, s) "C원이면 수락" 역제안. C = anchor + q×(target−anchor), s = 화법 전략
|
||
PRESS(s) 설득 압박 (카드의 일반화 — 전략 1경쟁/2수용/3고수/4협력)
|
||
|
||
특징 벡터(ACTION_DIM=8) = 유형 one-hot(3) + 가격 위치(1) + 전략 one-hot(4).
|
||
ScoreNet(상태 + 행동특징) → 스칼라 점수로 후보 30개를 채점해 argmax 한다.
|
||
"""
|
||
|
||
from dataclasses import dataclass
|
||
|
||
import numpy as np
|
||
|
||
COUNTER_GRID = [-0.05, 0.0, 0.25, 0.5, 0.75, 1.0] # C = anchor + q×(target−anchor)
|
||
ACTION_DIM = 3 + 1 + 4 + 1 # 유형(3) + 위치(1) + 전략(4) + 컷폭(1: 현 제시가 대비 인하 요구율)
|
||
|
||
# 자율 전용 추가 상태 (v3):
|
||
# [0] 직전 역제안 존재(0/1) [1] 직전 역제안 위치 q ← 에피소드 기억(같은 숫자 반복 방지)
|
||
# [2] 마감 잔여율(남은시간/전체, 미상 0.5) ← 견적 마감(quotations.end_time)
|
||
# [3] 과거 협상 횟수 min(n,5)/5 [4] 과거 성사율(미상 0.5)
|
||
# [5] 과거 평균 타결수준 norm((타결가/목표가−0.8)/0.4, 미상 0.5) ← 이 협력사와의 이력(experience_logs)
|
||
# [6] 인터넷최저가 갭 clip((최저가−앵커)/앵커/0.1, ±1, 미상 0) ← 숨은 하한가의 관측 가능한 힌트
|
||
# 특징은 학습 시뮬에도 동일하게 존재해야 한다(train_full_autonomy 가 대응물을 생성).
|
||
EXTRA_STATE_DIM = 7
|
||
|
||
|
||
def extra_state(last_kind: str = "", last_q: float = 0.0, deadline: float = 0.5,
|
||
hist_n: float = 0.0, hist_success: float = 0.5, hist_settle: float = 0.5,
|
||
internet_gap: float = 0.0) -> np.ndarray:
|
||
has_counter = 1.0 if last_kind == "counter" else 0.0
|
||
return np.array([
|
||
has_counter,
|
||
float(np.clip(last_q, -1.0, 1.0)) * has_counter,
|
||
float(np.clip(deadline, 0.0, 1.0)),
|
||
float(np.clip(hist_n, 0.0, 1.0)),
|
||
float(np.clip(hist_success, 0.0, 1.0)),
|
||
float(np.clip(hist_settle, 0.0, 1.0)),
|
||
float(np.clip(internet_gap, -1.0, 1.0)),
|
||
], dtype=np.float32)
|
||
|
||
|
||
def settle_norm(avg_settle_ratio: float) -> float:
|
||
"""평균 (타결가/목표가) → 0~1 정규화 (0.8→0, 1.0→0.5, 1.2→1)."""
|
||
return float(np.clip((avg_settle_ratio - 0.8) / 0.4, 0.0, 1.0))
|
||
|
||
|
||
def internet_gap_feat(internet_lowest: float, anchor: float) -> float:
|
||
"""인터넷최저가의 앵커 대비 갭 (±10% 스케일). 최저가 없으면 0을 쓴다."""
|
||
if not internet_lowest or anchor <= 0:
|
||
return 0.0
|
||
return float(np.clip((internet_lowest - anchor) / anchor / 0.1, -1.0, 1.0))
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class Action:
|
||
kind: str # accept | walk | counter | press
|
||
counter_q: float = 0.0 # counter 위치 (anchor~target 스팬 비율)
|
||
strategy: int = 0 # press/counter 의 화법 전략 (1~4, 0=없음)
|
||
|
||
def feat(self, price_pos: float, cut: float = 0.0) -> np.ndarray:
|
||
"""cut: 이 행동이 요구하는 인하폭 (현 제시가 대비, counter 만 >0) — 대형컷의 무례함을
|
||
정책이 지각하게 한다. 갭이 크면 역제안 대신 압박이 낫다는 걸 배우는 근거 특징."""
|
||
t = {"accept": [1, 0, 0], "walk": [0, 1, 0]}.get(self.kind, [0, 0, 1])
|
||
pos = price_pos if self.kind == "accept" else self.counter_q
|
||
s = np.zeros(4, dtype=np.float32)
|
||
if self.strategy:
|
||
s[self.strategy - 1] = 1.0
|
||
return np.concatenate([np.array(t, dtype=np.float32),
|
||
np.array([float(np.clip(pos, -1.0, 2.0)),
|
||
], dtype=np.float32), s,
|
||
np.array([float(np.clip(cut, 0.0, 1.0))], dtype=np.float32)])
|
||
|
||
|
||
def candidate_actions():
|
||
"""전 행동 후보: 수락 1 + 결렬 1 + 역제안 6×전략4 + 압박 4 = 30."""
|
||
out = [Action("accept"), Action("walk")]
|
||
out += [Action("counter", q, s) for q in COUNTER_GRID for s in (1, 2, 3, 4)]
|
||
out += [Action("press", 0.0, s) for s in (1, 2, 3, 4)]
|
||
return out
|
||
|
||
|
||
ACTIONS = candidate_actions()
|