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