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

147 lines
9.0 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.

"""NegotiationContextLoader — 협상 시작 컨텍스트를 DB 에서 1회 조회 (Req_Chat 슬림화).
backend 가 요청마다 실어 보내던 협상 컨텍스트(rq_type/목표가/앵커링가/품목가/매출액/유통코드/
파트너 유형)를 세션 시작 시 agent 가 직접 조회한다. session_id 는 backend 와 공유하는
negotiation.sessions.session_id. 행이 없으면(데모/테스트 직접 호출) None 을 반환하고
호출부(ChatService)가 기본값으로 폴백한다.
DB 쿼리는 INegoContextCRUD(negotiation/chat/infra/repository/nego_context_crud.py)에 위임
— backend crud 패턴 준용(인터페이스 + 함수 호출). 여기는 판정 로직(rq_type·앵커 폴백·
코드 매핑)과 세션 경계(execute_lambda)만 담당한다.
가격 수용률은 여기서 다루지 않는다 — 세션 내 라운드별 제시가로 매 턴 동적 계산(ChatService).
"""
import uuid
from dataclasses import dataclass
from typing import Optional
from common.database.db_session_manager import DB_SESSION_MNG
from common.enums import DBType, DBWRType, ErrorType
from common.logger import LOG
from negotiation.chat.infra.repository.nego_context_crud import INegoContextCRUD, NegoContextCRUD
from negotiation.qtable.domain.model.snapshot import PartnerType
# 1:1 견적유형 → 재협상 스크립트. QuotationType: 1=renego, 3=new_nego (2=requote, 4=new_quote 는 1:N 재견적).
_ONE_TO_ONE_QT_TYPES = (1, 3)
# 유통 코드: SupplierType(1=distribution 유통, 2=manufacture 제조, 3=sole_agency 총판)
# → 테넌트 code_map 키(A/B/C). 제조→A, 총판→B, 유통→C (0=none/NULL 은 미지정 → 호출부 기본값).
# 소스: partner.supplier_items.supply_type(이 협력사×이 상품 매핑).
_SUPPLIER_TYPE_TO_CODE = {2: "A", 3: "B", 1: "C"}
@dataclass(frozen=True)
class NegotiationDbContext:
"""세션 시작 시 DB 에서 확정되는 협상 컨텍스트 (라운드 진행 중 불변)."""
rq_type: str # 재협상(1:1) | 재견적(1:N) — sessions.qt_type 으로 판별
target_price: int # 목표 매입가(원) — sessions.target_price
anchor_price: int # 앵커링가 — sessions.anchoring_price(생성 시 박제). 없으면 target(무할인 폴백)
item_price: int # 기존 공급가(품목 기준가, items.price) — 인하율 멘트용. 없으면 0
partner_type: PartnerType # 상품에 연결된 협력사 수(supplier_items 매핑, 없으면 세션 이력) → NONE/SINGLE/MULTIPLE
revenue_amount: float # 매출액(원) — suppliers.total_revenue(KTC 미러). 없으면 0
distribution_code: Optional[str] # 유통 코드(A/B/C) — supplier_items.supply_type. 미지정 시 None
selected_nego_card_numbers: list[str] # 견적 생성 시 선택된 일반 협상카드 번호(card.nego_cards.number)
selected_wild_card_numbers: list[str] # 견적 생성 시 선택된 와일드카드 번호(card.wild_cards.number)
# ---- 자율 에이전트 v3 상태 특징 소스 (없으면 0/None — 특징은 중립 기본값으로 폴백) ----
internet_lowest_price: int = 0 # items.internet_lowest_price (미수집 0)
deadline_end_ts: Optional[float] = None # 견적 마감(epoch 초) — quotations.end_time
deadline_total_s: Optional[float] = None # 협상 전체 기간(초) — end−start
hist_n: int = 0 # 이 협력사와의 과거 협상 횟수
hist_success: Optional[float] = None # 과거 성사율 (이력 없으면 None)
hist_settle_ratio: Optional[float] = None # 과거 평균 타결가/목표가 (성사 이력 없으면 None)
class NegotiationContextLoader:
def __init__(self, crud: Optional[INegoContextCRUD] = None):
self.crud: INegoContextCRUD = crud or NegoContextCRUD()
async def load(self, session_id: Optional[str],
company_id: Optional[str] = None) -> Optional[NegotiationDbContext]:
"""session_id 로 협상 컨텍스트 조회. 행이 없거나 조회 실패 시 None(호출부 기본값 폴백).
company_id 는 협력사 이력 집계(experience_logs 테넌트 스코프)용 — 없으면 이력 특징 생략."""
if not session_id:
return None
try:
sid = uuid.UUID(session_id)
except ValueError:
return None # 데모/테스트의 비-UUID 세션 키
async def _load(s) -> Optional[NegotiationDbContext]:
err, row = await self.crud.get_session_row(s, sid)
if err != ErrorType.SUCCESS or row is None:
return None
qt_type, target_price, anchoring_price, item_id, quotation_id, supplier_id = row
target = int(target_price or 0)
# 앵커링가: 세션 생성 시 박제된 값(anchoring_price)을 그대로 사용 — 협상 중 불변.
# 박제가 없으면(데이터 이상) 무할인 폴백 anchor=target + WARN — 앵커링 v1.2 정책상
# 앵커를 재계산하지 않으며, 해당 세션은 앵커링 집계에서 자동 제외된다.
anchor = int(anchoring_price or 0)
if anchor <= 0:
LOG.w(f"[NegotiationContextLoader] 앵커가 박제 없음 session_id={session_id} — 무할인 폴백(anchor=target)")
anchor = target
# 매출액: 협력사 총매출(KTC total_revenue 미러). 미기재 시 0 → 호출부 기본값.
_, revenue_amount = await self.crud.get_supplier_total_revenue(s, supplier_id)
# 유통 코드: 이 협력사×이 상품의 공급 방식(supplier_items.supply_type).
# 매핑이 없거나 미지정이면 None → 호출부 기본값.
_, supplier_type = await self.crud.get_supply_type(s, supplier_id, item_id)
# 기존 공급가(품목 기준가) — 없으면 0(인하율 멘트 미표시).
_, item_price = await self.crud.get_item_price(s, item_id)
# 파트너사 유형: 상품에 연결된 협력사 수 — supplier_items 매핑(등록 기준) 우선.
# 매핑이 아직 없으면 협상 세션 이력 기준 폴백(더미보다 항상 낫다). 실패 시 SINGLE.
err, supplier_count = await self.crud.count_item_suppliers(s, item_id)
if err == ErrorType.SUCCESS and supplier_count == 0:
err, supplier_count = await self.crud.count_item_session_suppliers(s, item_id)
if err != ErrorType.SUCCESS:
supplier_count = 1
# 견적 생성 모달에서 고른 카드셋. 값이 없으면 운영 DB 기준으로 "선택 카드 없음"이다.
# 데모/직접호출 경로(DB context 없음)만 ChatService 에서 기존 기본 카드셋으로 폴백한다.
_, selected_cards = await self.crud.get_quotation_card_numbers(s, quotation_id)
selected_nego_cards, selected_wild_cards = selected_cards
# ---- 자율 에이전트 v3 특징 소스 (조회 실패는 전부 중립 폴백 — 협상은 계속돼야 한다) ----
_, internet_lowest = await self.crud.get_item_internet_lowest(s, item_id)
_, period = await self.crud.get_quotation_period(s, quotation_id)
deadline_end_ts = deadline_total_s = None
if period and period[1] is not None:
end_ts = period[1].timestamp()
start_ts = period[0].timestamp() if period[0] is not None else None
total = (end_ts - start_ts) if start_ts else None
if total and total > 0:
deadline_end_ts, deadline_total_s = end_ts, total
hist_n, hist_success, hist_settle = 0, None, None
if company_id:
_, hist = await self.crud.get_supplier_history(s, company_id, supplier_id, sid)
hist_n, hist_success, hist_settle = hist
return NegotiationDbContext(
rq_type="재협상" if int(qt_type) in _ONE_TO_ONE_QT_TYPES else "재견적",
target_price=target,
anchor_price=anchor,
item_price=item_price,
partner_type=PartnerType.from_count(supplier_count),
revenue_amount=revenue_amount,
distribution_code=_SUPPLIER_TYPE_TO_CODE.get(supplier_type) if supplier_type else None,
selected_nego_card_numbers=selected_nego_cards,
selected_wild_card_numbers=selected_wild_cards,
internet_lowest_price=internet_lowest,
deadline_end_ts=deadline_end_ts,
deadline_total_s=deadline_total_s,
hist_n=hist_n,
hist_success=hist_success,
hist_settle_ratio=hist_settle,
)
try:
return await DB_SESSION_MNG.execute_lambda(DBType.MAIN.value, DBWRType.DB_READ.value, _load)
except Exception as ex: # DB 불가 등 — 컨텍스트 없이 기본값으로 진행(협상 자체는 가능해야 함)
LOG.e_no_callstack(f"[NegotiationContextLoader] 컨텍스트 조회 실패 session_id={session_id}: {ex}")
return None