o2o-negosium-original/negodata/backend/services/learning_service.py
Mina Choi 6eb4dc26f8 [feat] negosium·negodata: 협상 거부 흐름 정리 + 오프라인 계약가 낙찰 + 견적 상세 리모델링
협상 불가 사유를 내면 500 이 나고, 거부 폼은 목록·채팅이 따로 놀았으며,
제출한 내용을 다시 볼 방법이 없었다. 결렬 건은 협력사가 낸 거부가를 계약가로
간주해 낙찰시켜 절감 통계가 음수로 뒤집힐 수 있었고, 견적 상세는 판정 가격이
세 곳에 흩어져 대화 탭에선 아예 보이지 않았다.

agent
- 결렬 종료 로깅 크래시 수정 — _log 를 action_id 기반으로 되돌리고 선택 근거
  (Q·UCB·방문수)는 decision/policy 가 있을 때만 채운다. 종료 행은 카드 선택이
  없고 policy.update 뒤라 값을 넣으면 학습 화면 집계가 오염된다

negosium
- 거부 폼을 목록·채팅 공용 컴포넌트 하나로 통일(사유 3종 + 공급 희망가·의견 선택)
- 거부 사유 열람 — 목록에 '거부 사유 보기'(부가정보 보기와 같은 규격), 채팅
  재진입 시 대화 끝에 거부 내역 카드. 목록·채팅 init 응답에 reject_reason·reject_price 추가
- 자유 입력 거부("협상 포기합니다")가 사유 NULL 로 저장되던 문제 수정 — 폼 마커가
  없으면 원문을 사유로 쓰고, 문장 속 숫자를 희망가로 오인하지 않는다
- koreanNumber 를 전역 lib 으로 이동(공용 폼이 쓴다)

negodata
- 직접 낙찰에 계약가 입력 — 결렬·미응찰 건을 오프라인으로 다시 협상한 결과를
  담당자가 확정해 넣는다. 후보는 초청 협력사 전부(가격 미제출도 포함),
  계약가는 sessions.custom.offline_award 에 근거·작성자·시각과 함께 남긴다
- 통계 계약가 = 담당자 확정가 우선, 없으면 투찰가. 거부가를 계약가로 치던 파생 제거.
  KPI 에 오프라인 반영 건수 추가
- 견적 상세 리모델링 — 가격 레일(앵커링가/투찰현황 · 목표가 · 타결 상한가 · 결과가)을
  시트에 고정해 접힘·탭 전환에도 남기고, 스펙트럼에 타결 판정선과 구간색 추가.
  라벨은 폭을 실측해 두 레인으로 배치(겹침 불가). 상품·마감시각 등 전 행 동일 컬럼 제거,
  협상현황에 부가정보 노출, 1:1 은 협력사·세션상태를 결과 밴드로 올림

테스트: negosium 58 · negodata 110 통과. 프론트 빌드/린트 통과.
2026-08-12 10:25:26 +09:00

130 lines
6.2 KiB
Python

import uuid
from fastapi import Depends
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import quotations
from common.anchoring.constants import UPPER_BOUNDS
from common.enums import DBWRType, ErrorType, SupplierType
from crud.learning_crud import ILearningCRUD, LearningCRUD
from router.v1.learning.protocol import (
AnchoringCell,
AnchoringHistoryRow,
CardUsageRow,
LearningKpi,
Res_AnchoringStatus,
Res_LearningStatus,
)
HISTORY_LIMIT = 50 # 앵커링 조정 이력 표시 개수 — 한 화면에서 훑는 용도
def _price_range_label(index: int) -> str:
"""가격대 인덱스 → 실제 금액 범위. 사다리는 common.anchoring 정본(UPPER_BOUNDS)만 참조한다."""
if index < 0 or index >= len(UPPER_BOUNDS):
return ""
low = UPPER_BOUNDS[index - 1] if index else 0
return f"{low:,}~{UPPER_BOUNDS[index]:,}"
_SUPPLIER_TYPE_LABEL = {
SupplierType.NONE.value: "미지정",
SupplierType.DISTRIBUTION.value: "유통",
SupplierType.MANUFACTURE.value: "제조",
SupplierType.SOLE_AGENCY.value: "총판",
}
class LearningService:
"""협상 학습 현황 — 협상카드 학습(agent Q-learning)과 앵커링 조정 이력을 읽어 보여준다.
두 값 모두 negodata 가 만드는 값이 아니라 agent·anchoring 서비스가 쌓은 결과다(읽기 전용).
"""
def __init__(self, crud: ILearningCRUD = Depends(LearningCRUD)):
self.crud = crud
async def get_learning_status(self, company_id: str) -> Res_LearningStatus:
"""카드 사용 현황 — 담아둔 카드가 실제로 나가는지, 어느 국면에 나가는지, 쏠리지는 않는지.
협상 성과(타결·가격)는 카드별로 나누지 않는다 — 한 협상에 여러 장이 나가 어느 장의 몫인지
가릴 수 없고, 카드 배정도 무작위가 아니라 국면에 따라 정해지기 때문이다.
"""
res = Res_LearningStatus()
# learning 의 company_id 는 agent 가 테넌트 키를 그대로 넣는 문자열 컬럼이다(UUID 타입 아님).
cid = str(company_id)
sessions, records, _settled, last_at = await self._read(
lambda s: self.crud.learning_summary(s, cid), default=(0, 0, 0, None))
rows = await self._read(lambda s: self.crud.card_usage(s, cid), default=[])
names = await self._read(lambda s: self.crud.card_names(s), default=[])
name_map = {str(number): (name, is_wild, card_pk) for number, name, is_wild, card_pk in names if number}
total_uses = sum(int(r[2] or 0) for r in rows) or 1
for card_id, used_sessions, uses, avg_turn, last_used, avg_q, avg_ucb, visits in rows:
number = str(card_id)
name, is_wild, card_pk = name_map.get(number, (None, 0, None))
res.cards.append(CardUsageRow(
number=number,
name=name,
card_id=str(card_pk) if card_pk else None,
type="wild" if is_wild else "nego",
used_sessions=int(used_sessions or 0),
uses=int(uses or 0),
share=round(int(uses or 0) / total_uses, 3),
avg_turn=round(float(avg_turn), 1) if avg_turn is not None else 0.0,
last_used_at=last_used,
avg_q=round(float(avg_q), 4) if avg_q is not None else None,
avg_ucb=round(float(avg_ucb), 4) if avg_ucb is not None else None,
# 탐색보너스는 두 값이 다 있을 때만 — 한쪽만 있으면 차이가 의미를 잃는다.
explore_bonus=(round(float(avg_ucb) - float(avg_q), 4)
if avg_q is not None and avg_ucb is not None else None),
visits=int(visits or 0),
))
res.kpi = LearningKpi(
learned_sessions=int(sessions or 0),
records=int(records or 0),
used_cards=len(res.cards),
unused_cards=max(0, len(name_map) - len(res.cards)),
top3_share=round(sum(c.uses for c in res.cards[:3]) / total_uses, 3),
last_learned_at=last_at,
)
return res
async def get_anchoring_status(self, company_id: str) -> Res_AnchoringStatus:
res = Res_AnchoringStatus()
# anchoring 스키마의 company_id 는 UUID 타입 — learning(문자열 테넌트 키)과 다르다.
cid = uuid.UUID(str(company_id))
cells = await self._read(lambda s: self.crud.anchoring_current(s, cid), default=[])
for supplier_type, price_range_index, value, adjusted_at in cells:
res.cells.append(AnchoringCell(
supplier_type=int(supplier_type or 0),
supplier_type_label=_SUPPLIER_TYPE_LABEL.get(int(supplier_type or 0), "미지정"),
price_range_index=int(price_range_index or 0),
price_range_label=_price_range_label(int(price_range_index or 0)),
anchoring_value=float(value) if value is not None else 0.0,
last_adjusted_at=adjusted_at,
))
history = await self._read(lambda s: self.crud.anchoring_history(s, cid, HISTORY_LIMIT), default=[])
for st, pri, before, after, sample, success, rate, created_at in history:
res.history.append(AnchoringHistoryRow(
supplier_type_label=_SUPPLIER_TYPE_LABEL.get(int(st or 0), "미지정"),
price_range_index=int(pri or 0),
price_range_label=_price_range_label(int(pri or 0)),
value_before=float(before) if before is not None else 0.0,
value_after=float(after) if after is not None else 0.0,
sample_count=int(sample or 0),
success_count=int(success or 0),
success_rate=round(float(rate), 3) if rate is not None else 0.0,
created_at=created_at,
))
res.adjusted_count = len(res.history)
return res
async def _read(self, fn, default):
"""crud 한 건 실행 — 실패해도 화면은 떠야 하므로 기본값으로 떨어진다."""
err, rows = await DB_SESSION_MNG.execute_lambda(quotations.DBType(), DBWRType.DB_READ.value, fn)
return rows if err == ErrorType.SUCCESS else default