판정 룰(앵커타결/와일드존/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>
189 lines
11 KiB
Python
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
|