o2o-negosium-original/agent/negotiation/chat/service/ment_generator.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

189 lines
11 KiB
Python

"""MentGenerator — 자율 협상 행동을 LLM 이 자연어 멘트로 표현 (v2: 행동은 RL, 문장은 LLM).
역할 분리(안전 설계):
- 무엇을 말할지(수락/역제안 금액/압박 전략/결렬)는 RL 정책이 결정 — LLM 은 표현만 담당.
- 가드레일: 역제안 멘트에 제안 금액이 정확히 포함되지 않으면 폐기, 예외/미설정 시 None
→ 호출부(ChatService)가 기존 템플릿 멘트로 폴백한다. LLM 이 죽어도 협상은 계속된다.
설정: config.local.toml [OpenAIConfig] (Gemini 는 OpenAI 호환 base_url 로 접속).
비활성화: AUTONOMY_LLM=0.
"""
import asyncio
import os
import re
from typing import Optional
from common.logger import LOG
from negotiation.profiling.config import LlmCredentials
_STRATEGY_TONE = {
1: "경쟁 압박형 — 복수 공급처와 비교 검토 중임을 암시하며 긴장감을 준다",
2: "수용 공감형 — 상대 제안의 취지에 공감하며 부드럽게 조정을 요청한다",
3: "기준 고수형 — 내부 산정 기준과 목표가를 근거로 원칙을 지킨다",
4: "협력 파트너형 — 장기 파트너십과 후속 거래 확대 가능성을 강조한다",
}
_SYSTEM = """너는 대기업 구매팀의 가격 협상 챗봇이다. 주어진 '전달 의도'를 자연스러운 한국어 협상 멘트로 바꿔 쓴다.
규칙 (위반 시 출력은 폐기된다):
- 1~3문장, 정중하되 간결하게. 출력은 멘트 텍스트만 (따옴표·설명 없이).
- 금액 숫자는 주어진 그대로 정확히 포함하고 단위는 '원'을 쓴다. 주어지지 않은 숫자·비율을 절대 만들지 않는다.
- 지정된 '화법' 전략 안에서만 말한다. 그 외의 협상 전술(물량·기간 약속, 조건 교환, 거래 연계,
독점 제안, 시장가·최저가 주장, 할인 약속 등)을 지어내지 않는다.
- 회사의 정책·사실을 단정하지 않는다. 주어진 의도에 없는 정보는 말하지 않는다.
- 상대는 협력사(판매자)이고 우리는 구매자다."""
# 생성문 금지어 — 승인되지 않은 커밋/주장 계열. 걸리면 템플릿 폴백(협상은 계속).
_FORBIDDEN = ("보장", "물량", "독점", "무조건", "최저가", "시장 가격", "시장가", "계약 기간",
"법적", "위약", "%")
def _configured() -> bool:
if os.getenv("AUTONOMY_LLM", "1").lower() in ("0", "false", "no"):
return False
try:
return LlmCredentials.from_config().is_configured()
except Exception:
return False
def _digits(s) -> str:
return re.sub(r"[^\d]", "", str(s))
def _history_hints(ctx: dict) -> str:
"""대화 기억 힌트 — 무기억 생성이 '매번 같은 멘트'를 만들던 문제의 해법.
① 직전 우리 제안이 거절된 사실과 이번 제안과의 관계(양보/입장유지)를 짚게 하고
② 직전 봇 멘트를 보여주며 같은 문장 구조·표현의 반복을 금지한다."""
hints = []
prev = ctx.get("autonomy_prev")
if prev and prev.get("kind") == "counter":
anchor, target = float(ctx.get("anchor_price") or 0), float(ctx.get("target_price") or 0)
prev_offer = int(round(anchor + float(prev.get("q", 0.0)) * max(target - anchor, 1.0)))
cur_offer = int(ctx.get("autonomy_offer") or 0)
if cur_offer > prev_offer:
hints.append(f"참고: 직전 라운드에 우리가 {prev_offer:,}원을 제안했으나 거절당했고, "
f"이번에는 {cur_offer - prev_offer:,}원 더 양보한 제안이다. 이 진전을 자연스럽게 짚어라.")
elif cur_offer == prev_offer and cur_offer > 0:
hints.append(f"참고: 직전에 제안한 {prev_offer:,}원을 거절당했지만 같은 금액을 유지한다. "
f"입장이 확고함을 정중하게 전하라.")
elif prev_offer > 0:
hints.append(f"참고: 직전 제안({prev_offer:,}원)이 거절된 뒤의 재제안이다.")
last_ment = ctx.get("autonomy_last_ment")
if last_ment:
hints.append(f'직전 봇 멘트: "{last_ment}" — 이와 같은 문장 구조·표현을 반복하지 말고 다르게 써라.')
return " ".join(hints)
def _prompt_for(step: str, ctx: dict) -> Optional[str]:
price = int(ctx.get("input_price") or 0)
rnd = ctx.get("round", 1)
if step in ("자율_역제안", "자율_최종제안"):
offer = int(ctx.get("autonomy_offer") or 0)
if offer <= 0:
return None
strategy = int((ctx.get("autonomy_last") or {}).get("s") or 3)
tone = _STRATEGY_TONE.get(strategy, _STRATEGY_TONE[3])
final = ("이번이 우리가 제시할 수 있는 마지막 제안이며, 거절하시면 이번 협상은 종료됨을 "
"분명하되 정중하게 밝혀라. " if step == "자율_최종제안" else "")
return (f"상황: 협력사가 {price:,}원을 제시했다(협상 {rnd}라운드). "
f"전달 의도: 우리는 **{offer:,}원**이면 즉시 수락하고 우선협상 대상으로 확정할 수 있다 — "
f"이 핵심 의미는 유지하되 문장 표현은 자유롭게 새로 써라. {final}화법: {tone}. "
f"{_history_hints(ctx)} 마지막에 수락 여부를 물어라.")
if step.startswith("자율_압박_"):
strategy = int(step.rsplit("_", 1)[1])
tone = _STRATEGY_TONE.get(strategy, _STRATEGY_TONE[3])
# 주의: 목표가는 프롬프트에 넣지 않는다 — 압박 중 목표가 노출은 우리 상한을 까는 것
# (상대가 그 밑으로 내려올 이유가 사라진다). 숫자 커밋은 역제안/최종제안에서만.
base = (f"상황: 협력사가 {price:,}원을 제시했다(협상 {rnd}라운드). "
f"전달 의도: 어떤 금액도 언급하지 말고(내부 기준·목표가 숫자 금지), 제시가와 우리 기준의 "
f"거리가 있다는 취지로 가격 재제안을 요청한다. 화법: {tone}. "
f"{_history_hints(ctx)}")
# 시장가 근거 (구 NGC-008 의 자율 버전): 수집된 인터넷최저가가 실재하고 제시가가 그보다
# 높을 때만 사실 근거로 인용을 허용한다 — 미수집 품목에서 지어내는 주장은 가드가 차단.
if _market_evidence(ctx):
il = int(ctx["internet_lowest_price"])
base += (f" 참고 사실(인용 허용되는 유일한 금액): 동일 품목의 인터넷 최저가가 {il:,}원으로 "
f"확인된다. 현재 제시가가 이보다 높다는 점을 근거로 조정 여지를 정중히 짚어라.")
return base
return None
def _market_evidence(ctx: dict) -> bool:
"""시장가 근거 인용 가능 조건: 인터넷최저가 수집됨 + 제시가가 그보다 높음."""
il = int(ctx.get("internet_lowest_price") or 0)
return il > 0 and float(ctx.get("input_price") or 0) > il
def _allowed_amounts(ctx: dict) -> set:
"""멘트에 등장해도 되는 숫자 집합 — 우리가 프롬프트로 준 값들뿐. 이 밖의 금액 = 할루시네이션."""
# 목표가는 화이트리스트에 없다 — 압박 멘트가 목표가를 새면(상한 노출) 즉시 폐기된다.
# 역제안·최종제안의 제안가(autonomy_offer)가 목표가와 같은 경우만 그 값으로 허용된다.
anchor, target = float(ctx.get("anchor_price") or 0), float(ctx.get("target_price") or 0)
out = {int(ctx.get("input_price") or 0), int(ctx.get("autonomy_offer") or 0),
int(ctx.get("round") or 0)}
if _market_evidence(ctx):
out.add(int(ctx["internet_lowest_price"])) # 시장가 근거 인용 시 그 수치만 허용
prev = ctx.get("autonomy_prev")
if prev and prev.get("kind") == "counter":
prev_offer = int(round(anchor + float(prev.get("q", 0.0)) * max(target - anchor, 1.0)))
out |= {prev_offer, abs(int(ctx.get("autonomy_offer") or 0) - prev_offer)}
return {str(v) for v in out if v}
def _guard(step: str, ctx: dict, text: str) -> bool:
"""LLM 출력 검증(할루시네이션 차단) — 실패 시 템플릿 폴백.
① 길이/문장 완결 ② 금지어(승인 안 된 커밋·주장) ③ 숫자 화이트리스트: 멘트의 모든
3자리+ 숫자는 우리가 준 값(제시가·제안가·목표가·직전제안가)이어야 한다 — 지어낸 금액 즉시 폐기.
④ 역제안은 제안 금액 포함 필수."""
if not text or len(text) < 10 or len(text) > 600:
return False
if not text.rstrip().endswith(("다.", "요.", "요?", "까?", "니까?", ".", "?")):
return False # 문장 중간 잘림(thinking 토큰에 한도 소진 등) → 템플릿 폴백
forbidden = _FORBIDDEN
if _market_evidence(ctx):
# 시장가 근거가 정당한 턴에는 '최저가/시장가' 언급을 허용 (수치는 아래 화이트리스트가 검증).
forbidden = tuple(w for w in _FORBIDDEN if w not in ("최저가", "시장가", "시장 가격"))
if any(w in text for w in forbidden):
return False
allowed = _allowed_amounts(ctx)
for num in re.findall(r"\d{3,}", text.replace(",", "")):
if num not in allowed:
return False # 프롬프트에 없던 금액 생성 = 할루시네이션
if step in ("자율_역제안", "자율_최종제안"):
return _digits(ctx.get("autonomy_offer")) in _digits(text)
return True
async def generate(step: str, ctx: dict) -> Optional[str]:
"""자율 스텝 멘트 생성. 미설정/실패/검증불통과 → None (호출부 템플릿 유지)."""
if not _configured():
return None
prompt = _prompt_for(step, ctx)
if prompt is None:
return None
try:
from negotiation.profiling.infra.llm_adapter import chat_complete
# openai SDK 는 동기 — 이벤트루프 블로킹 방지 위해 스레드로 넘긴다.
# max_tokens 넉넉히 — Gemini 2.5 계열은 thinking 토큰이 한도에 포함돼 짧으면 본문이 잘린다.
# 시간 상한: backend→agent 타임아웃(10s)보다 확실히 짧아야 한다 — 초과 시 템플릿 폴백으로
# 협상은 즉시 계속된다("협상 응답 지연" 토스트 방지). LLM_TIMEOUT_S 로 조절.
text = await asyncio.wait_for(
asyncio.to_thread(
chat_complete,
[{"role": "system", "content": _SYSTEM}, {"role": "user", "content": prompt}],
None, False, 0.9, 2048, # temperature 0.9 — 표현 다양성 (의미는 프롬프트 가드)
),
timeout=float(os.getenv("LLM_TIMEOUT_S", "6")),
)
text = (text or "").strip().strip('"')
if _guard(step, ctx, text):
return text
LOG.w(f"[MentGenerator] 가드레일 불통과 → 템플릿 폴백 (step={step})")
return None
except Exception as ex:
LOG.e_no_callstack(f"[MentGenerator] LLM 실패 → 템플릿 폴백: {ex}")
return None