o2o-negosium-original/negodata/backend/crud/learning_crud.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

169 lines
8.2 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.

from abc import ABC, abstractmethod
from typing import Tuple
from sqlalchemy import and_, asc, column, desc, func, select, table
from sqlalchemy.ext.asyncio import AsyncSession
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import nego_cards, wild_cards
from common.enums import ErrorType
# 학습(learning)·앵커링(anchoring) 스키마는 agent·anchoring 서비스 소유라 negodata ORM 에 없다.
# 조회 전용이므로 필요한 컬럼만 경량 정의한다 — 이 값들의 주인은 negodata 가 아니다(쓰기 금지).
_EXPERIENCE_LOGS = table(
"experience_logs",
column("company_id"), column("session_id"), column("card_id"), column("reward"),
column("settled_price"), column("is_invalidated"), column("created_at"),
column("turn"),
# 선택 시점 박제값 — 카드가 왜 뽑혔는지의 근거. UCB = Q + 탐색보너스 라
# 둘의 차이가 "학습으로 뽑혔나 / 덜 써봐서 뽑혔나"를 가른다.
column("q_value_at_selection"), column("ucb_score_at_selection"),
column("visit_count_at_selection"),
schema="learning",
)
_ANCHORING_CURRENT = table(
"current_values",
column("company_id"), column("supplier_type"), column("price_range_index"),
column("anchoring_value"), column("last_adjusted_at"),
schema="anchoring",
)
_ANCHORING_HISTORY = table(
"value_history",
column("company_id"), column("supplier_type"), column("price_range_index"),
column("anchoring_value_before"), column("anchoring_value_after"),
column("sample_count"), column("success_count"), column("success_rate"), column("created_at"),
schema="anchoring",
)
def _valid(company_id) -> list:
"""유효 학습 기록 — 무효화 표시된 행은 뺀다(협상 취소·재생성 시 agent 가 표시)."""
return [
_EXPERIENCE_LOGS.c.company_id == company_id,
_EXPERIENCE_LOGS.c.is_invalidated == False, # noqa: E712
]
class ILearningCRUD(ABC):
@abstractmethod
async def learning_summary(self, cdb: AsyncSession, company_id) -> Tuple[ErrorType, tuple]:
pass
@abstractmethod
async def card_usage(self, cdb: AsyncSession, company_id) -> Tuple[ErrorType, list]:
pass
@abstractmethod
async def card_names(self, cdb: AsyncSession) -> Tuple[ErrorType, list]:
pass
@abstractmethod
async def anchoring_current(self, cdb: AsyncSession, company_id) -> Tuple[ErrorType, list]:
pass
@abstractmethod
async def anchoring_history(self, cdb: AsyncSession, company_id, limit: int) -> Tuple[ErrorType, list]:
pass
class LearningCRUD(ILearningCRUD):
async def learning_summary(self, cdb: AsyncSession, company_id) -> Tuple[ErrorType, tuple]:
"""(학습 협상 수, 기록 수, 타결 협상 수, 마지막 학습 시각)."""
query = select(
func.count(func.distinct(_EXPERIENCE_LOGS.c.session_id)),
func.count(),
func.count(func.distinct(_EXPERIENCE_LOGS.c.session_id))
.filter(_EXPERIENCE_LOGS.c.settled_price.isnot(None)),
func.max(_EXPERIENCE_LOGS.c.created_at),
).where(and_(*_valid(company_id)))
err_type, rows = await DB_SESSION_MNG.execute(cdb, query, "learning_summary failed.", raise_error=False)
if err_type != ErrorType.SUCCESS or not rows:
return err_type, (0, 0, 0, None)
return ErrorType.SUCCESS, tuple(rows[0])
async def card_usage(self, cdb: AsyncSession, company_id) -> Tuple[ErrorType, list]:
"""카드별 사용 현황 — (카드번호, 사용 협상 수, 사용 횟수, 평균 라운드, 마지막 사용,
평균 Q값, 평균 UCB, 누적 방문수).
협상 결과(타결·가격)는 카드 한 장의 성과로 나눌 수 없어 다루지 않는다 — 한 협상에 여러 장이
나가 어느 장의 몫인지 가릴 근거가 없고, 카드 배정도 국면에 따라 정해져 무작위가 아니다.
평균 라운드 = 그 카드가 협상의 몇 번째 라운드에 나갔는지(설정한 국면과 실제가 맞는지 대조).
Q값/방문수는 선택 시점 박제값(experience_logs)이라 q_values 테이블을 따로 안 읽는다 —
"그때 무슨 근거로 뽑혔나"가 사후 현재값보다 사용 현황에 맞는 기준이다.
방문수는 마지막 선택 시점 누적값(단조 증가라 max 가 곧 최신).
"""
query = (
select(
_EXPERIENCE_LOGS.c.card_id,
func.count(func.distinct(_EXPERIENCE_LOGS.c.session_id)),
func.count(),
func.avg(_EXPERIENCE_LOGS.c.turn),
func.max(_EXPERIENCE_LOGS.c.created_at),
func.avg(_EXPERIENCE_LOGS.c.q_value_at_selection),
func.avg(_EXPERIENCE_LOGS.c.ucb_score_at_selection),
func.max(_EXPERIENCE_LOGS.c.visit_count_at_selection),
)
.where(and_(*_valid(company_id), _EXPERIENCE_LOGS.c.card_id.isnot(None)))
.group_by(_EXPERIENCE_LOGS.c.card_id)
.order_by(desc(func.count()))
)
err_type, rows = await DB_SESSION_MNG.execute(cdb, query, "card_usage failed.", raise_error=False)
if err_type != ErrorType.SUCCESS:
return err_type, []
return ErrorType.SUCCESS, list(rows or [])
async def card_names(self, cdb: AsyncSession) -> Tuple[ErrorType, list]:
"""카드번호 → (번호, 이름, 와일드 여부, 카드 PK). 학습 로그의 card_id 가 카드번호 문자열이라
상세 화면(/cards?detail=<PK>) 으로 보내려면 PK 를 같이 들고 와야 한다.
UNION 은 실행기가 SELECT 로 인정하지 않아 두 번 나눠 조회한다.
"""
out = []
for model, pk, is_wild in ((nego_cards, nego_cards.nego_card_id, 0), (wild_cards, wild_cards.wild_card_id, 1)):
query = select(model.number, model.name, pk).where(model.deleted == False) # noqa: E712
err_type, rows = await DB_SESSION_MNG.execute(cdb, query, "card_names failed.", raise_error=False)
if err_type != ErrorType.SUCCESS:
return err_type, []
out.extend((number, name, is_wild, card_id) for number, name, card_id in (rows or []))
return ErrorType.SUCCESS, out
async def anchoring_current(self, cdb: AsyncSession, company_id) -> Tuple[ErrorType, list]:
"""현재 앵커링 값 — (협력사유형, 가격대 구간, 값, 마지막 조정 시각). 셀 = 유형 × 가격대."""
query = (
select(
_ANCHORING_CURRENT.c.supplier_type,
_ANCHORING_CURRENT.c.price_range_index,
_ANCHORING_CURRENT.c.anchoring_value,
_ANCHORING_CURRENT.c.last_adjusted_at,
)
.where(_ANCHORING_CURRENT.c.company_id == company_id)
.order_by(asc(_ANCHORING_CURRENT.c.supplier_type), asc(_ANCHORING_CURRENT.c.price_range_index))
)
err_type, rows = await DB_SESSION_MNG.execute(cdb, query, "anchoring_current failed.", raise_error=False)
if err_type != ErrorType.SUCCESS:
return err_type, []
return ErrorType.SUCCESS, list(rows or [])
async def anchoring_history(self, cdb: AsyncSession, company_id, limit: int) -> Tuple[ErrorType, list]:
"""앵커링 조정 이력(최근순) — 조정마다 표본 수·성공률·값 변화가 남는다."""
query = (
select(
_ANCHORING_HISTORY.c.supplier_type,
_ANCHORING_HISTORY.c.price_range_index,
_ANCHORING_HISTORY.c.anchoring_value_before,
_ANCHORING_HISTORY.c.anchoring_value_after,
_ANCHORING_HISTORY.c.sample_count,
_ANCHORING_HISTORY.c.success_count,
_ANCHORING_HISTORY.c.success_rate,
_ANCHORING_HISTORY.c.created_at,
)
.where(_ANCHORING_HISTORY.c.company_id == company_id)
.order_by(desc(_ANCHORING_HISTORY.c.created_at))
.limit(limit)
)
err_type, rows = await DB_SESSION_MNG.execute(cdb, query, "anchoring_history failed.", raise_error=False)
if err_type != ErrorType.SUCCESS:
return err_type, []
return ErrorType.SUCCESS, list(rows or [])