o2o-negosium-original/agent/negotiation/policies/autonomy_actions.py
jwkim 51b3820cc9 [feat] agent: 완전 자율 협상 모드 (AUTONOMY_MODE) + LLM 멘트
판정 룰(앵커타결/와일드존/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>
2026-07-10 16:57:38 +09:00

86 lines
4.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""완전 자율 협상 행동 공간 (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()