"""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