o2o-negosium-original/negodata/backend/crud/learning_crud.py
Mina Choi e816bfbba4 [feat] negodata: 협상 학습 메뉴 신설 — 카드별 학습 성적 · 앵커링 현황/조정 이력
agent(협상카드 강화학습)와 anchoring(구간별 자동 조정)이 쌓아온 결과를 볼 수 있는 화면이
없어 DB 를 직접 열어야 확인이 됐다. 두 축은 성격이 달라 한 화면에 섞지 않고 탭으로 나눈다.

백엔드(읽기 전용 — 값의 주인은 agent·anchoring):
- crud/learning_crud.py: learning·anchoring 스키마 경량 조회(negodata ORM 미매핑 테이블)
- services/learning_service.py + router/v1/learning: /v1/learning/cards · /anchoring

프론트:
- 사이드바 '협상 학습'(/learning) 추가, 탭 = 협상카드 | 앵커링
- 협상카드: 학습 협상·기록·타결 비율 요약 + 카드별 사용/타결/평균 점수(0 기준 좌우 막대)
- 앵커링: 구간별 현재 인하폭 + 조정 이력(표본·성공률·값 변화)

검증: 로컬 실협상 3건으로 데이터 생성 후 두 탭 실화면 확인, tsc·eslint 통과.
2026-08-10 13:24:32 +09:00

154 lines
7.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.

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"),
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_performance(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_performance(self, cdb: AsyncSession, company_id) -> Tuple[ErrorType, list]:
"""카드별 (카드번호, 사용 협상 수, 사용 횟수, 평균 보상, 타결 협상 수).
보상(reward)은 agent 가 협상 결과로 매긴 성적이라 카드의 실제 효과를 비교하는 축이 된다.
"""
query = (
select(
_EXPERIENCE_LOGS.c.card_id,
func.count(func.distinct(_EXPERIENCE_LOGS.c.session_id)),
func.count(),
func.avg(_EXPERIENCE_LOGS.c.reward),
func.count(func.distinct(_EXPERIENCE_LOGS.c.session_id))
.filter(_EXPERIENCE_LOGS.c.settled_price.isnot(None)),
)
.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_performance 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]:
"""카드번호 → (번호, 이름, 와일드 여부). 학습 로그의 card_id 가 카드번호 문자열이다.
UNION 은 실행기가 SELECT 로 인정하지 않아 두 번 나눠 조회한다.
"""
out = []
for model, is_wild in ((nego_cards, 0), (wild_cards, 1)):
query = select(model.number, model.name).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) for number, name 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 [])