[fix] negodata: 협상 학습 화면 정리 — 내부 점수 제거·카드탭 사용 현황 전환·메뉴 개발자 전용

- 관측 사실 기준으로 재구성: 내부 점수 제거, 앵커링 % 표기, 탭 URL 분리
- 카드탭을 성과 지표에서 사용 현황으로 전환, 지표 설명 말풍선·카드 상세 링크
- 사이드바 devOnly + 라우트 가드로 개발자 전용 처리
This commit is contained in:
Mina Choi 2026-08-11 08:49:15 +09:00
parent e1e519c20c
commit f1e924931f
19 changed files with 310 additions and 150 deletions

View File

@ -14,6 +14,7 @@ _EXPERIENCE_LOGS = table(
"experience_logs", "experience_logs",
column("company_id"), column("session_id"), column("card_id"), column("reward"), column("company_id"), column("session_id"), column("card_id"), column("reward"),
column("settled_price"), column("is_invalidated"), column("created_at"), column("settled_price"), column("is_invalidated"), column("created_at"),
column("turn"),
schema="learning", schema="learning",
) )
_ANCHORING_CURRENT = table( _ANCHORING_CURRENT = table(
@ -45,7 +46,7 @@ class ILearningCRUD(ABC):
pass pass
@abstractmethod @abstractmethod
async def card_performance(self, cdb: AsyncSession, company_id) -> Tuple[ErrorType, list]: async def card_usage(self, cdb: AsyncSession, company_id) -> Tuple[ErrorType, list]:
pass pass
@abstractmethod @abstractmethod
@ -76,41 +77,43 @@ class LearningCRUD(ILearningCRUD):
return err_type, (0, 0, 0, None) return err_type, (0, 0, 0, None)
return ErrorType.SUCCESS, tuple(rows[0]) return ErrorType.SUCCESS, tuple(rows[0])
async def card_performance(self, cdb: AsyncSession, company_id) -> Tuple[ErrorType, list]: async def card_usage(self, cdb: AsyncSession, company_id) -> Tuple[ErrorType, list]:
"""카드별 (카드번호, 사용 협상 수, 사용 횟수, 평균 보상, 타결 협상 수). """카드별 사용 현황 — (카드번호, 사용 협상 수, 사용 횟수, 평균 라운드, 마지막 사용).
보상(reward)은 agent 가 협상 결과로 매긴 성적이라 카드의 실제 효과를 비교하는 축이 된다. 협상 결과(타결·가격)는 카드 한 장의 성과로 나눌 수 없어 다루지 않는다 — 한 협상에 여러 장이
나가 어느 장의 몫인지 가릴 근거가 없고, 카드 배정도 국면에 따라 정해져 무작위가 아니다.
평균 라운드 = 그 카드가 협상의 몇 번째 라운드에 나갔는지(설정한 국면과 실제가 맞는지 대조).
""" """
query = ( query = (
select( select(
_EXPERIENCE_LOGS.c.card_id, _EXPERIENCE_LOGS.c.card_id,
func.count(func.distinct(_EXPERIENCE_LOGS.c.session_id)), func.count(func.distinct(_EXPERIENCE_LOGS.c.session_id)),
func.count(), func.count(),
func.avg(_EXPERIENCE_LOGS.c.reward), func.avg(_EXPERIENCE_LOGS.c.turn),
func.count(func.distinct(_EXPERIENCE_LOGS.c.session_id)) func.max(_EXPERIENCE_LOGS.c.created_at),
.filter(_EXPERIENCE_LOGS.c.settled_price.isnot(None)),
) )
.where(and_(*_valid(company_id), _EXPERIENCE_LOGS.c.card_id.isnot(None))) .where(and_(*_valid(company_id), _EXPERIENCE_LOGS.c.card_id.isnot(None)))
.group_by(_EXPERIENCE_LOGS.c.card_id) .group_by(_EXPERIENCE_LOGS.c.card_id)
.order_by(desc(func.count())) .order_by(desc(func.count()))
) )
err_type, rows = await DB_SESSION_MNG.execute(cdb, query, "card_performance failed.", raise_error=False) err_type, rows = await DB_SESSION_MNG.execute(cdb, query, "card_usage failed.", raise_error=False)
if err_type != ErrorType.SUCCESS: if err_type != ErrorType.SUCCESS:
return err_type, [] return err_type, []
return ErrorType.SUCCESS, list(rows or []) return ErrorType.SUCCESS, list(rows or [])
async def card_names(self, cdb: AsyncSession) -> Tuple[ErrorType, list]: async def card_names(self, cdb: AsyncSession) -> Tuple[ErrorType, list]:
"""카드번호 → (번호, 이름, 와일드 여부). 학습 로그의 card_id 가 카드번호 문자열이다. """카드번호 → (번호, 이름, 와일드 여부, 카드 PK). 학습 로그의 card_id 가 카드번호 문자열이라
상세 화면(/cards?detail=<PK>) 으로 보내려면 PK 를 같이 들고 와야 한다.
UNION 은 실행기가 SELECT 로 인정하지 않아 두 번 나눠 조회한다. UNION 은 실행기가 SELECT 로 인정하지 않아 두 번 나눠 조회한다.
""" """
out = [] out = []
for model, is_wild in ((nego_cards, 0), (wild_cards, 1)): 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).where(model.deleted == False) # noqa: E712 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) err_type, rows = await DB_SESSION_MNG.execute(cdb, query, "card_names failed.", raise_error=False)
if err_type != ErrorType.SUCCESS: if err_type != ErrorType.SUCCESS:
return err_type, [] return err_type, []
out.extend((number, name, is_wild) for number, name in (rows or [])) out.extend((number, name, is_wild, card_id) for number, name, card_id in (rows or []))
return ErrorType.SUCCESS, out return ErrorType.SUCCESS, out
async def anchoring_current(self, cdb: AsyncSession, company_id) -> Tuple[ErrorType, list]: async def anchoring_current(self, cdb: AsyncSession, company_id) -> Tuple[ErrorType, list]:

View File

@ -13,33 +13,37 @@ class LearningProtocol(WebPacketProtocol):
class LearningKpi(LearningProtocol): class LearningKpi(LearningProtocol):
learned_sessions: int = 0 # 학습에 반영된 협상 수 learned_sessions: int = 0 # 학습에 반영된 협상 수
records: int = 0 # 학습 기록 수(카드 선택 1회 = 1건) records: int = 0 # 학습 기록 수(카드 선택 1회 = 1건)
settled_sessions: int = 0 # 그중 타결된 협상 수 used_cards: int = 0 # 한 번이라도 나간 카드 수
settle_rate: float = 0.0 # 타결 비율 unused_cards: int = 0 # 아직 한 번도 안 나간 카드 수
top3_share: float = 0.0 # 상위 3장이 차지하는 사용 비중 — 쏠림 정도
last_learned_at: Optional[datetime] = None last_learned_at: Optional[datetime] = None
class CardPerformanceRow(LearningProtocol): class CardUsageRow(LearningProtocol):
number: str # 카드번호(NGC-001 등) number: str # 카드번호(NGC-001 등)
name: Optional[str] = None name: Optional[str] = None
card_id: Optional[str] = None # 카드 PK — 협상카드 상세(/cards?detail=) 링크용
type: str = "nego" # nego | wild type: str = "nego" # nego | wild
used_sessions: int = 0 # 이 카드를 쓴 협상 수 used_sessions: int = 0 # 이 카드가 나간 협상 수
uses: int = 0 # 총 사용 횟수 uses: int = 0 # 총 사용 횟수
avg_reward: float = 0.0 # 평균 보상 — agent 가 협상 결과로 매긴 성적 share: float = 0.0 # 전체 카드 사용 중 이 카드의 비중
settled_sessions: int = 0 avg_turn: float = 0.0 # 평균 몇 번째 라운드에 나갔는지 — 설정한 국면과 대조용
settle_rate: float = 0.0 # 이 카드를 쓴 협상의 타결 비율 last_used_at: Optional[datetime] = None
class AnchoringCell(LearningProtocol): class AnchoringCell(LearningProtocol):
supplier_type: int = 0 supplier_type: int = 0
supplier_type_label: str = "미지정" supplier_type_label: str = "미지정"
price_range_index: int = 0 # 목표가 기준 가격대 구간 price_range_index: int = 0 # 목표가 기준 가격대 구간
anchoring_value: float = 0.0 # 앵커링 인하폭(‰) price_range_label: str = "" # 그 구간의 실제 금액 범위(화면 표기용)
anchoring_value: float = 0.0 # 앵커링 인하폭(‰ — 10 = 1%)
last_adjusted_at: Optional[datetime] = None last_adjusted_at: Optional[datetime] = None
class AnchoringHistoryRow(LearningProtocol): class AnchoringHistoryRow(LearningProtocol):
supplier_type_label: str = "미지정" supplier_type_label: str = "미지정"
price_range_index: int = 0 price_range_index: int = 0
price_range_label: str = ""
value_before: float = 0.0 value_before: float = 0.0
value_after: float = 0.0 value_after: float = 0.0
sample_count: int = 0 # 조정 판단에 쓴 협상 표본 수 sample_count: int = 0 # 조정 판단에 쓴 협상 표본 수
@ -50,7 +54,7 @@ class AnchoringHistoryRow(LearningProtocol):
class Res_LearningStatus(Res_WebPacketProtocol): class Res_LearningStatus(Res_WebPacketProtocol):
kpi: LearningKpi = Field(default_factory=LearningKpi) kpi: LearningKpi = Field(default_factory=LearningKpi)
cards: list[CardPerformanceRow] = [] cards: list[CardUsageRow] = []
class Res_AnchoringStatus(Res_WebPacketProtocol): class Res_AnchoringStatus(Res_WebPacketProtocol):

View File

@ -1,13 +1,16 @@
import uuid
from fastapi import Depends from fastapi import Depends
from common.database.db_session_manager import DB_SESSION_MNG from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import quotations from common.database.model.models import quotations
from common.anchoring.constants import UPPER_BOUNDS
from common.enums import DBWRType, ErrorType, SupplierType from common.enums import DBWRType, ErrorType, SupplierType
from crud.learning_crud import ILearningCRUD, LearningCRUD from crud.learning_crud import ILearningCRUD, LearningCRUD
from router.v1.learning.protocol import ( from router.v1.learning.protocol import (
AnchoringCell, AnchoringCell,
AnchoringHistoryRow, AnchoringHistoryRow,
CardPerformanceRow, CardUsageRow,
LearningKpi, LearningKpi,
Res_AnchoringStatus, Res_AnchoringStatus,
Res_LearningStatus, Res_LearningStatus,
@ -15,6 +18,14 @@ from router.v1.learning.protocol import (
HISTORY_LIMIT = 50 # 앵커링 조정 이력 표시 개수 — 한 화면에서 훑는 용도 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 = { _SUPPLIER_TYPE_LABEL = {
SupplierType.NONE.value: "미지정", SupplierType.NONE.value: "미지정",
SupplierType.DISTRIBUTION.value: "유통", SupplierType.DISTRIBUTION.value: "유통",
@ -33,41 +44,51 @@ class LearningService:
self.crud = crud self.crud = crud
async def get_learning_status(self, company_id: str) -> Res_LearningStatus: async def get_learning_status(self, company_id: str) -> Res_LearningStatus:
"""카드 사용 현황 — 담아둔 카드가 실제로 나가는지, 어느 국면에 나가는지, 쏠리지는 않는지.
협상 성과(타결·가격)는 카드별로 나누지 않는다 — 한 협상에 여러 장이 나가 어느 장의 몫인지
가릴 수 없고, 카드 배정도 무작위가 아니라 국면에 따라 정해지기 때문이다.
"""
res = Res_LearningStatus() res = Res_LearningStatus()
# learning·anchoring 의 company_id 는 agent 가 테넌트 키를 그대로 넣는 문자열 컬럼이다(UUID 타입 아님). # learning 의 company_id 는 agent 가 테넌트 키를 그대로 넣는 문자열 컬럼이다(UUID 타입 아님).
cid = str(company_id) cid = str(company_id)
summary = await self._read(lambda s: self.crud.learning_summary(s, cid), default=(0, 0, 0, None)) sessions, records, _settled, last_at = await self._read(
sessions, records, settled, last_at = summary lambda s: self.crud.learning_summary(s, cid), default=(0, 0, 0, None))
res.kpi = LearningKpi( rows = await self._read(lambda s: self.crud.card_usage(s, cid), default=[])
learned_sessions=int(sessions or 0),
records=int(records or 0),
settled_sessions=int(settled or 0),
settle_rate=round((settled or 0) / sessions, 3) if sessions else 0.0,
last_learned_at=last_at,
)
rows = await self._read(lambda s: self.crud.card_performance(s, cid), default=[])
names = await self._read(lambda s: self.crud.card_names(s), default=[]) names = await self._read(lambda s: self.crud.card_names(s), default=[])
name_map = {str(number): (name, is_wild) for number, name, is_wild in names if number} name_map = {str(number): (name, is_wild, card_pk) for number, name, is_wild, card_pk in names if number}
for card_id, used_sessions, uses, avg_reward, settled_sessions in rows:
total_uses = sum(int(uses or 0) for _n, _s, uses, _t, _l in rows) or 1
for card_id, used_sessions, uses, avg_turn, last_used in rows:
number = str(card_id) number = str(card_id)
name, is_wild = name_map.get(number, (None, 0)) name, is_wild, card_pk = name_map.get(number, (None, 0, None))
res.cards.append(CardPerformanceRow( res.cards.append(CardUsageRow(
number=number, number=number,
name=name, name=name,
card_id=str(card_pk) if card_pk else None,
type="wild" if is_wild else "nego", type="wild" if is_wild else "nego",
used_sessions=int(used_sessions or 0), used_sessions=int(used_sessions or 0),
uses=int(uses or 0), uses=int(uses or 0),
avg_reward=round(float(avg_reward), 3) if avg_reward is not None else 0.0, share=round(int(uses or 0) / total_uses, 3),
settled_sessions=int(settled_sessions or 0), avg_turn=round(float(avg_turn), 1) if avg_turn is not None else 0.0,
settle_rate=round((settled_sessions or 0) / used_sessions, 3) if used_sessions else 0.0, last_used_at=last_used,
)) ))
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 return res
async def get_anchoring_status(self, company_id: str) -> Res_AnchoringStatus: async def get_anchoring_status(self, company_id: str) -> Res_AnchoringStatus:
res = Res_AnchoringStatus() res = Res_AnchoringStatus()
cid = str(company_id) # anchoring 스키마의 company_id 는 UUID 타입 — learning(문자열 테넌트 키)과 다르다.
cid = uuid.UUID(str(company_id))
cells = await self._read(lambda s: self.crud.anchoring_current(s, cid), default=[]) cells = await self._read(lambda s: self.crud.anchoring_current(s, cid), default=[])
for supplier_type, price_range_index, value, adjusted_at in cells: for supplier_type, price_range_index, value, adjusted_at in cells:
@ -75,6 +96,7 @@ class LearningService:
supplier_type=int(supplier_type or 0), supplier_type=int(supplier_type or 0),
supplier_type_label=_SUPPLIER_TYPE_LABEL.get(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_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, anchoring_value=float(value) if value is not None else 0.0,
last_adjusted_at=adjusted_at, last_adjusted_at=adjusted_at,
)) ))
@ -84,6 +106,7 @@ class LearningService:
res.history.append(AnchoringHistoryRow( res.history.append(AnchoringHistoryRow(
supplier_type_label=_SUPPLIER_TYPE_LABEL.get(int(st or 0), "미지정"), supplier_type_label=_SUPPLIER_TYPE_LABEL.get(int(st or 0), "미지정"),
price_range_index=int(pri 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_before=float(before) if before is not None else 0.0,
value_after=float(after) if after is not None else 0.0, value_after=float(after) if after is not None else 0.0,
sample_count=int(sample or 0), sample_count=int(sample or 0),

View File

@ -10,6 +10,7 @@ export interface AnchoringCell {
supplier_type?: number; supplier_type?: number;
supplier_type_label?: string; supplier_type_label?: string;
price_range_index?: number; price_range_index?: number;
price_range_label?: string;
anchoring_value?: number; anchoring_value?: number;
last_adjusted_at?: AnchoringCellLastAdjustedAt; last_adjusted_at?: AnchoringCellLastAdjustedAt;
} }

View File

@ -9,6 +9,7 @@ import type { AnchoringHistoryRowCreatedAt } from './anchoringHistoryRowCreatedA
export interface AnchoringHistoryRow { export interface AnchoringHistoryRow {
supplier_type_label?: string; supplier_type_label?: string;
price_range_index?: number; price_range_index?: number;
price_range_label?: string;
value_before?: number; value_before?: number;
value_after?: number; value_after?: number;
sample_count?: number; sample_count?: number;

View File

@ -1,18 +0,0 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
import type { CardPerformanceRowName } from './cardPerformanceRowName';
export interface CardPerformanceRow {
number: string;
name?: CardPerformanceRowName;
type?: string;
used_sessions?: number;
uses?: number;
avg_reward?: number;
settled_sessions?: number;
settle_rate?: number;
}

View File

@ -0,0 +1,21 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
import type { CardUsageRowName } from './cardUsageRowName';
import type { CardUsageRowCardId } from './cardUsageRowCardId';
import type { CardUsageRowLastUsedAt } from './cardUsageRowLastUsedAt';
export interface CardUsageRow {
number: string;
name?: CardUsageRowName;
card_id?: CardUsageRowCardId;
type?: string;
used_sessions?: number;
uses?: number;
share?: number;
avg_turn?: number;
last_used_at?: CardUsageRowLastUsedAt;
}

View File

@ -5,4 +5,4 @@
* OpenAPI spec version: 0.1.0 * OpenAPI spec version: 0.1.0
*/ */
export type CardPerformanceRowName = string | null; export type CardUsageRowCardId = string | null;

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
export type CardUsageRowLastUsedAt = string | null;

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
export type CardUsageRowName = string | null;

View File

@ -22,10 +22,12 @@ export * from './cardDataScript';
export * from './cardDataTactic'; export * from './cardDataTactic';
export * from './cardDataUpdatedAt'; export * from './cardDataUpdatedAt';
export * from './cardDataUserId'; export * from './cardDataUserId';
export * from './cardPerformanceRow';
export * from './cardPerformanceRowName';
export * from './cardStatus'; export * from './cardStatus';
export * from './cardType'; export * from './cardType';
export * from './cardUsageRow';
export * from './cardUsageRowCardId';
export * from './cardUsageRowLastUsedAt';
export * from './cardUsageRowName';
export * from './cardUsageType'; export * from './cardUsageType';
export * from './chatMessageData'; export * from './chatMessageData';
export * from './chatMessageDataCardId'; export * from './chatMessageDataCardId';

View File

@ -9,7 +9,8 @@ import type { LearningKpiLastLearnedAt } from './learningKpiLastLearnedAt';
export interface LearningKpi { export interface LearningKpi {
learned_sessions?: number; learned_sessions?: number;
records?: number; records?: number;
settled_sessions?: number; used_cards?: number;
settle_rate?: number; unused_cards?: number;
top3_share?: number;
last_learned_at?: LearningKpiLastLearnedAt; last_learned_at?: LearningKpiLastLearnedAt;
} }

View File

@ -7,11 +7,11 @@
import type { ErrorInfo } from './errorInfo'; import type { ErrorInfo } from './errorInfo';
import type { ResLearningStatusMsg } from './resLearningStatusMsg'; import type { ResLearningStatusMsg } from './resLearningStatusMsg';
import type { LearningKpi } from './learningKpi'; import type { LearningKpi } from './learningKpi';
import type { CardPerformanceRow } from './cardPerformanceRow'; import type { CardUsageRow } from './cardUsageRow';
export interface ResLearningStatus { export interface ResLearningStatus {
result?: ErrorInfo; result?: ErrorInfo;
msg?: ResLearningStatusMsg; msg?: ResLearningStatusMsg;
kpi?: LearningKpi; kpi?: LearningKpi;
cards?: CardPerformanceRow[]; cards?: CardUsageRow[];
} }

View File

@ -70,7 +70,15 @@ export const router = createBrowserRouter([
children: [ children: [
{path: 'dashboard', Component: DashboardPage}, {path: 'dashboard', Component: DashboardPage},
{path: 'statistics', Component: StatisticsPage}, {path: 'statistics', Component: StatisticsPage},
{path: 'learning', Component: LearningPage}, {
// 개발자 전용. 학습·앵커링 내부 동작을 드러내는 화면이라 고객사 관리자에게 열지 않는다.
path: 'learning',
loader: async () => {
await initAuth();
return hasRole('개발자') ? null : redirect('/forbidden');
},
Component: LearningPage,
},
{path: 'products', Component: ProductsPage}, {path: 'products', Component: ProductsPage},
{path: 'partners', Component: PartnersPage}, {path: 'partners', Component: PartnersPage},
{path: 'quotation', Component: QuotationPage}, {path: 'quotation', Component: QuotationPage},

View File

@ -57,7 +57,6 @@ const menuGroups: { label?: string; items: MenuItem[] }[] = [
items: [ items: [
{ type: 'DASHBOARD', label: '대시보드', icon: LayoutDashboard, id: 'sidebar-dashboard' }, { type: 'DASHBOARD', label: '대시보드', icon: LayoutDashboard, id: 'sidebar-dashboard' },
{ type: 'STATISTICS', label: '통계', icon: BarChart3, id: 'sidebar-statistics' }, { type: 'STATISTICS', label: '통계', icon: BarChart3, id: 'sidebar-statistics' },
{ type: 'LEARNING', label: '협상 학습', icon: Brain, id: 'sidebar-learning' },
], ],
}, },
{ {
@ -80,6 +79,7 @@ const menuGroups: { label?: string; items: MenuItem[] }[] = [
{ {
label: '개발자', label: '개발자',
items: [ items: [
{ type: 'LEARNING', label: '협상 학습', icon: Brain, id: 'sidebar-learning', devOnly: true },
{ type: 'DEV_SETTINGS', label: '고급 설정', icon: SlidersHorizontal, id: 'sidebar-dev-settings', devOnly: true }, { type: 'DEV_SETTINGS', label: '고급 설정', icon: SlidersHorizontal, id: 'sidebar-dev-settings', devOnly: true },
{ type: 'DESIGN', label: '디자인 시스템', icon: Palette, id: 'sidebar-design', devOnly: true }, { type: 'DESIGN', label: '디자인 시스템', icon: Palette, id: 'sidebar-design', devOnly: true },
], ],

View File

@ -1,4 +1,7 @@
import { Bot, CheckCircle2, Layers, Ruler } from 'lucide-react'; import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { Link } from 'react-router';
import { Bot, CheckCircle2, HelpCircle, Layers, Ruler } from 'lucide-react';
import { Panel } from '@/features/statistics/components/Panel'; import { Panel } from '@/features/statistics/components/Panel';
import { StatTile } from '@/features/statistics/components/StatTile'; import { StatTile } from '@/features/statistics/components/StatTile';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
@ -10,30 +13,36 @@ import type { AnchoringData, LearningData } from './types';
// 두 값 모두 agent·anchoring 서비스가 쌓은 결과를 읽기만 한다. // 두 값 모두 agent·anchoring 서비스가 쌓은 결과를 읽기만 한다.
export function CardLearningView({ data }: { data: LearningData }) { export function CardLearningView({ data }: { data: LearningData }) {
const best = data.cards.filter((c) => c.uses > 0);
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4"> <div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
<StatTile label="학습한 협상" value={`${data.learnedSessions}건`} icon={Bot} tone="purple" /> <StatTile label="학습한 협상" value={`${data.learnedSessions}건`} icon={Bot} tone="purple" />
<StatTile label="학습 기록" value={`${data.records}건`} icon={Layers} tone="blue" /> <StatTile label="카드 사용" value={`${data.records}회`} icon={Layers} tone="blue" />
<StatTile label="타결 협상" value={`${data.settledSessions}건`} icon={CheckCircle2} tone="emerald" /> <StatTile label="써본 카드" value={`${data.usedCards}종`} icon={CheckCircle2} tone="emerald" />
<StatTile label="타결 비율" value={pct(data.settleRate)} icon={Ruler} tone="amber" /> <StatTile label="아직 안 쓴 카드" value={`${data.unusedCards}종`} icon={Ruler} tone="amber" />
</div> </div>
<Panel <Panel
title="카드별 학습 성적" title="카드 사용 현황"
subtitle="AI가 협상 결과로 매긴 점수입니다. 점수가 높을수록 그 카드를 쓴 협상이 잘 풀렸다는 뜻입니다." subtitle="담아둔 카드가 실제로 나가는지, 어느 국면에 나가는지 봅니다. 협상 결과는 여러 장이 함께 만든 것이라 카드별 성과로 나누지 않습니다."
right={ right={
data.lastLearnedAt ? ( data.lastLearnedAt ? (
<Typography as="span" variant="small" className="text-[11px] text-muted-foreground"> <Typography as="span" variant="small" className="text-[11px] text-muted-foreground">
최근 학습 {fmtDateTime(data.lastLearnedAt)} 최근 사용 {fmtDateTime(data.lastLearnedAt)}
</Typography> </Typography>
) : undefined ) : undefined
} }
> >
{best.length === 0 ? ( {data.cards.length === 0 ? (
<EmptyRow text="아직 학습된 협상이 없습니다. 협상이 진행되면 카드별 성적이 쌓입니다." /> <EmptyRow text="아직 사용된 카드가 없습니다. 협상이 진행되면 여기에 쌓입니다." />
) : ( ) : (
<>
{data.top3Share >= 0.8 && data.usedCards > 3 && (
<Typography as="div" variant="small" className="mb-2 text-[11px] text-amber-700 dark:text-amber-400">
상위 3장이 전체 사용의 {pct(data.top3Share)}를 차지합니다 — 나머지 카드는 조건이 맞지 않아
밀리고 있을 수 있습니다.
</Typography>
)}
<Table className="text-xs"> <Table className="text-xs">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
@ -42,28 +51,47 @@ export function CardLearningView({ data }: { data: LearningData }) {
<TableHead className="w-20 text-center">종류</TableHead> <TableHead className="w-20 text-center">종류</TableHead>
<TableHead className="w-24 text-right">사용 협상</TableHead> <TableHead className="w-24 text-right">사용 협상</TableHead>
<TableHead className="w-24 text-right">사용 횟수</TableHead> <TableHead className="w-24 text-right">사용 횟수</TableHead>
<TableHead className="w-24 text-right">타결 비율</TableHead> <TableHead className="w-28 text-right">
<TableHead className="w-28 text-right">평균 점수</TableHead> <HeadWithHelp label="사용 비중" help="전체 카드 사용 횟수 중 이 카드가 차지하는 비율입니다. 한두 장에 몰려 있으면 나머지 카드가 발동 조건에 걸려 밀리고 있을 수 있습니다." />
</TableHead>
<TableHead className="w-32 text-right">
<HeadWithHelp label="평균 라운드" help="이 카드가 협상의 몇 번째 라운드에 나갔는지의 평균입니다. 값이 작으면 초반에, 크면 종결 무렵에 나간다는 뜻입니다. 종결 전용으로 설정한 카드가 초반에 나오고 있지는 않은지 확인할 때 봅니다." />
</TableHead>
<TableHead className="w-36 text-right">마지막 사용</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{best.map((c) => ( {data.cards.map((c) => (
<TableRow key={c.number}> <TableRow key={c.number}>
<TableCell className="font-mono">{c.number}</TableCell> <TableCell className="font-mono">{c.number}</TableCell>
<TableCell className="font-medium">{c.name}</TableCell> <TableCell className="font-medium">
{c.cardId ? (
<Link
to={`/cards?detail=${c.cardId}`}
className="underline decoration-1 underline-offset-2 hover:text-primary"
title={`${c.name} — 협상카드 상세로 이동`}
>
{c.name}
</Link>
) : (
c.name
)}
</TableCell>
<TableCell className="text-center"> <TableCell className="text-center">
<TypeBadge isWild={c.isWild} /> <TypeBadge isWild={c.isWild} />
</TableCell> </TableCell>
<TableCell className="text-right">{c.usedSessions}</TableCell> <TableCell className="text-right">{c.usedSessions}</TableCell>
<TableCell className="text-right">{c.uses}</TableCell> <TableCell className="text-right">{c.uses}</TableCell>
<TableCell className="text-right">{pct(c.settleRate)}</TableCell> <TableCell className="text-right">{pct(c.share)}</TableCell>
<TableCell className="text-right"> <TableCell className="text-right">{c.avgTurn ? `${c.avgTurn}R` : '-'}</TableCell>
<RewardBar value={c.avgReward} /> <TableCell className="text-right text-muted-foreground">
{c.lastUsedAt ? fmtDateTime(c.lastUsedAt) : '-'}
</TableCell> </TableCell>
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>
</Table> </Table>
</>
)} )}
</Panel> </Panel>
</div> </div>
@ -80,12 +108,13 @@ export function AnchoringStatusView({ data }: { data: AnchoringData }) {
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4"> <div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
<StatTile label="관리 중인 구간" value={`${data.cells.length}개`} icon={Layers} tone="purple" /> <StatTile label="관리 중인 구간" value={`${data.cells.length}개`} icon={Layers} tone="purple" />
<StatTile label="조정된 구간" value={`${adjusted}개`} icon={CheckCircle2} tone="emerald" /> <StatTile label="조정된 구간" value={`${adjusted}개`} icon={CheckCircle2} tone="emerald" />
<StatTile label="평균 인하폭" value={permille(avg)} icon={Ruler} tone="blue" /> <StatTile label="평균 인하폭 (목표가 대비)" value={rate(avg)} icon={Ruler} tone="blue" />
<StatTile label="조정 이력" value={`${data.history.length}건`} icon={Bot} tone="amber" /> <StatTile label="조정 이력" value={`${data.history.length}건`} icon={Bot} tone="amber" />
</div> </div>
<div className="grid gap-4 xl:grid-cols-2"> <div className="grid gap-4 xl:grid-cols-2">
<Panel title="현재 앵커링 값" subtitle="협력사 유형과 가격대 구간마다 따로 관리합니다."> <Panel title="현재 앵커링 값"
subtitle="협력사 유형과 가격대가 이 조합이면 이 인하폭을 적용합니다. 앵커가 = 목표가 − (목표가 × 인하폭).">
{data.cells.length === 0 ? ( {data.cells.length === 0 ? (
<EmptyRow text="아직 설정된 구간이 없습니다. 협상이 쌓이면 구간별로 값이 만들어집니다." /> <EmptyRow text="아직 설정된 구간이 없습니다. 협상이 쌓이면 구간별로 값이 만들어집니다." />
) : ( ) : (
@ -93,7 +122,7 @@ export function AnchoringStatusView({ data }: { data: AnchoringData }) {
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead className="w-24">협력사 유형</TableHead> <TableHead className="w-24">협력사 유형</TableHead>
<TableHead className="w-24 text-right">가격대</TableHead> <TableHead>가격대</TableHead>
<TableHead className="w-24 text-right">인하폭</TableHead> <TableHead className="w-24 text-right">인하폭</TableHead>
<TableHead className="text-right">마지막 조정</TableHead> <TableHead className="text-right">마지막 조정</TableHead>
</TableRow> </TableRow>
@ -102,8 +131,8 @@ export function AnchoringStatusView({ data }: { data: AnchoringData }) {
{data.cells.map((c) => ( {data.cells.map((c) => (
<TableRow key={`${c.supplierType}-${c.priceRange}`}> <TableRow key={`${c.supplierType}-${c.priceRange}`}>
<TableCell>{c.supplierType}</TableCell> <TableCell>{c.supplierType}</TableCell>
<TableCell className="text-right font-mono">{c.priceRange}</TableCell> <TableCell className="font-mono">{c.priceRange}</TableCell>
<TableCell className="text-right font-mono">{permille(c.value)}</TableCell> <TableCell className="text-right font-mono">{rate(c.value)}</TableCell>
<TableCell className="text-right text-muted-foreground"> <TableCell className="text-right text-muted-foreground">
{c.adjustedAt ? fmtDateTime(c.adjustedAt) : '조정 전'} {c.adjustedAt ? fmtDateTime(c.adjustedAt) : '조정 전'}
</TableCell> </TableCell>
@ -114,7 +143,8 @@ export function AnchoringStatusView({ data }: { data: AnchoringData }) {
)} )}
</Panel> </Panel>
<Panel title="조정 이력" subtitle="표본이 충분히 쌓인 구간만 성공률을 보고 값을 조정합니다."> <Panel title="조정 이력"
subtitle="구간마다 협상 10건이 모이면 평가합니다. 앵커가 이하로 합의된 비율이 높으면 인하폭을 올리고, 낮으면 내립니다.">
{data.history.length === 0 ? ( {data.history.length === 0 ? (
<EmptyRow text="아직 조정된 이력이 없습니다." /> <EmptyRow text="아직 조정된 이력이 없습니다." />
) : ( ) : (
@ -122,7 +152,7 @@ export function AnchoringStatusView({ data }: { data: AnchoringData }) {
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead className="w-24">협력사 유형</TableHead> <TableHead className="w-24">협력사 유형</TableHead>
<TableHead className="w-16 text-right">가격대</TableHead> <TableHead>가격대</TableHead>
<TableHead className="w-28 text-right">변화</TableHead> <TableHead className="w-28 text-right">변화</TableHead>
<TableHead className="w-20 text-right">표본</TableHead> <TableHead className="w-20 text-right">표본</TableHead>
<TableHead className="w-20 text-right">성공률</TableHead> <TableHead className="w-20 text-right">성공률</TableHead>
@ -133,9 +163,9 @@ export function AnchoringStatusView({ data }: { data: AnchoringData }) {
{data.history.map((h, i) => ( {data.history.map((h, i) => (
<TableRow key={`${h.supplierType}-${h.priceRange}-${h.at ?? i}`}> <TableRow key={`${h.supplierType}-${h.priceRange}-${h.at ?? i}`}>
<TableCell>{h.supplierType}</TableCell> <TableCell>{h.supplierType}</TableCell>
<TableCell className="text-right font-mono">{h.priceRange}</TableCell> <TableCell className="font-mono">{h.priceRange}</TableCell>
<TableCell className="text-right font-mono"> <TableCell className="text-right font-mono">
{permille(h.before)} → <span className="font-semibold">{permille(h.after)}</span> {rate(h.before)} → <span className="font-semibold">{rate(h.after)}</span>
</TableCell> </TableCell>
<TableCell className="text-right">{h.samples}</TableCell> <TableCell className="text-right">{h.samples}</TableCell>
<TableCell className="text-right">{pct(h.successRate)}</TableCell> <TableCell className="text-right">{pct(h.successRate)}</TableCell>
@ -153,21 +183,80 @@ export function AnchoringStatusView({ data }: { data: AnchoringData }) {
); );
} }
// 평균 점수는 −1~1 범위라 숫자만으론 감이 안 온다 — 0 기준 좌우 막대로 방향과 크기를 같이 보여준다. // 표 헤더의 ? — 지표 정의를 눌러서 확인한다. 부제에 정의를 길게 늘어놓지 않기 위한 것이고,
function RewardBar({ value }: { value: number }) { // 여러 줄 설명이라 마우스를 떼면 사라지는 hover 툴팁 대신 클릭으로 여닫는다.
const width = Math.min(Math.abs(value), 1) * 50; function HeadWithHelp({ label, help }: { label: string; help: string }) {
const positive = value >= 0; // pinned = 클릭으로 고정(다른 곳 누를 때까지 유지), hovered = 마우스 올린 동안만 노출.
const [pinned, setPinned] = useState(false);
const [hovered, setHovered] = useState(false);
const open = pinned || hovered;
const [pos, setPos] = useState({ top: 0, right: 0 });
const ref = useRef<HTMLSpanElement>(null);
// 표 컨테이너가 overflow-x-auto 라 말풍선을 그 안에 두면 잘리거나 가로 스크롤이 생긴다.
// body 로 띄우고 트리거 좌표에 맞춰 붙인다.
useLayoutEffect(() => {
if (!open || !ref.current) return;
const r = ref.current.getBoundingClientRect();
setPos({ top: r.bottom + 6, right: window.innerWidth - r.right });
}, [open]);
useEffect(() => {
if (!pinned) return;
const close = (e: Event) => {
if (!ref.current?.contains(e.target as Node)) setPinned(false);
};
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && setPinned(false);
document.addEventListener('pointerdown', close);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('pointerdown', close);
document.removeEventListener('keydown', onKey);
};
}, [pinned]);
return ( return (
<div className="flex items-center justify-end gap-2"> <span
<div className="relative h-2 w-24 rounded bg-muted"> ref={ref}
className="inline-flex items-center justify-end gap-1"
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
{label}
<button
type="button"
aria-label={`${label} 설명`}
aria-expanded={open}
onFocus={() => setHovered(true)}
onBlur={() => setHovered(false)}
// pointerdown 으로 받는다 — click 은 표 행 리렌더 사이에 끼면 눌러도 안 열리는 경우가 있다.
// 전파를 끊지 않으면 방금 등록된 바깥클릭 핸들러가 같은 이벤트로 곧바로 닫는다.
onPointerDown={(e) => {
e.stopPropagation();
setPinned((v) => !v);
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
setPinned((v) => !v);
}
}}
className="text-muted-foreground hover:text-foreground cursor-pointer"
>
<HelpCircle size={12} />
</button>
{open &&
createPortal(
<div <div
className={`absolute top-0 h-2 ${positive ? 'bg-emerald-500' : 'bg-rose-500'}`} style={{ top: pos.top, right: pos.right }}
style={{ left: positive ? '50%' : `${50 - width}%`, width: `${width}%` }} className="fixed z-50 w-72 whitespace-normal break-keep rounded-lg border border-border bg-popover px-3 py-2 text-left text-xs font-normal leading-relaxed text-popover-foreground normal-case shadow-lg"
/> >
<div className="absolute left-1/2 top-0 h-2 w-px bg-border" /> {help}
</div> </div>,
<span className="w-12 text-right font-mono">{value.toFixed(2)}</span> document.body,
</div> )}
</span>
); );
} }
@ -197,7 +286,7 @@ function pct(v: number): string {
return `${(v * 100).toFixed(0)}%`; return `${(v * 100).toFixed(0)}%`;
} }
// 앵커링 값은 천분율(‰) — 목표가에서 이만큼 낮춘 가격이 앵커가가 된다. // 앵커링 값은 천분율(‰)로 저장된다 — 10 = 1%. 화면은 담당자가 쓰는 단위(%)로 보여준다.
function permille(v: number): string { function rate(v: number): string {
return `${v.toFixed(1)}‰`; return `${(v / 10).toFixed(1)}%`;
} }

View File

@ -10,17 +10,20 @@ export function useLearningData(): { data: LearningData; isLoading: boolean } {
data: { data: {
learnedSessions: k.learned_sessions ?? 0, learnedSessions: k.learned_sessions ?? 0,
records: k.records ?? 0, records: k.records ?? 0,
settledSessions: k.settled_sessions ?? 0, usedCards: k.used_cards ?? 0,
settleRate: k.settle_rate ?? 0, unusedCards: k.unused_cards ?? 0,
top3Share: k.top3_share ?? 0,
lastLearnedAt: k.last_learned_at ?? null, lastLearnedAt: k.last_learned_at ?? null,
cards: (data?.cards ?? []).map((c) => ({ cards: (data?.cards ?? []).map((c) => ({
number: c.number, number: c.number,
name: c.name ?? c.number, name: c.name ?? c.number,
cardId: c.card_id ?? null,
isWild: c.type === 'wild', isWild: c.type === 'wild',
usedSessions: c.used_sessions ?? 0, usedSessions: c.used_sessions ?? 0,
uses: c.uses ?? 0, uses: c.uses ?? 0,
avgReward: c.avg_reward ?? 0, share: c.share ?? 0,
settleRate: c.settle_rate ?? 0, avgTurn: c.avg_turn ?? 0,
lastUsedAt: c.last_used_at ?? null,
})), })),
}, },
}; };
@ -33,13 +36,13 @@ export function useAnchoringData(): { data: AnchoringData; isLoading: boolean }
data: { data: {
cells: (data?.cells ?? []).map((c) => ({ cells: (data?.cells ?? []).map((c) => ({
supplierType: c.supplier_type_label ?? '미지정', supplierType: c.supplier_type_label ?? '미지정',
priceRange: c.price_range_index ?? 0, priceRange: c.price_range_label ?? '',
value: c.anchoring_value ?? 0, value: c.anchoring_value ?? 0,
adjustedAt: c.last_adjusted_at ?? null, adjustedAt: c.last_adjusted_at ?? null,
})), })),
history: (data?.history ?? []).map((h) => ({ history: (data?.history ?? []).map((h) => ({
supplierType: h.supplier_type_label ?? '미지정', supplierType: h.supplier_type_label ?? '미지정',
priceRange: h.price_range_index ?? 0, priceRange: h.price_range_label ?? '',
before: h.value_before ?? 0, before: h.value_before ?? 0,
after: h.value_after ?? 0, after: h.value_after ?? 0,
samples: h.sample_count ?? 0, samples: h.sample_count ?? 0,

View File

@ -3,32 +3,35 @@
export type CardRow = { export type CardRow = {
number: string; number: string;
name: string; name: string;
cardId: string | null; // 협상카드 상세 링크용 PK
isWild: boolean; isWild: boolean;
usedSessions: number; // 이 카드를 쓴 협상 수 usedSessions: number; // 이 카드가 나간 협상 수
uses: number; // 총 사용 횟수 uses: number; // 총 사용 횟수
avgReward: number; // 평균 보상 — 협상 결과로 매겨진 성적 share: number; // 전체 카드 사용 중 비중
settleRate: number; // 이 카드를 쓴 협상의 타결 비율 avgTurn: number; // 평균 몇 번째 라운드에 나갔는지
lastUsedAt: string | null;
}; };
export type LearningData = { export type LearningData = {
learnedSessions: number; learnedSessions: number;
records: number; records: number;
settledSessions: number; usedCards: number;
settleRate: number; unusedCards: number;
top3Share: number; // 상위 3장 사용 비중 — 쏠림
lastLearnedAt: string | null; lastLearnedAt: string | null;
cards: CardRow[]; cards: CardRow[];
}; };
export type AnchorCell = { export type AnchorCell = {
supplierType: string; // 유통/제조/총판/미지정 supplierType: string; // 유통/제조/총판/미지정
priceRange: number; // 가격대 구간 priceRange: string; // 가격대 구간(실제 금액 범위)
value: number; // 앵커링 인하폭(‰) value: number; // 앵커링 인하폭 — 천분율 저장값(10 = 1%)
adjustedAt: string | null; adjustedAt: string | null;
}; };
export type AnchorHistoryRow = { export type AnchorHistoryRow = {
supplierType: string; supplierType: string;
priceRange: number; priceRange: string;
before: number; before: number;
after: number; after: number;
samples: number; // 조정 판단에 쓴 협상 표본 수 samples: number; // 조정 판단에 쓴 협상 표본 수

View File

@ -1,4 +1,4 @@
import { useState } from 'react'; import { useSearchParams } from 'react-router';
import { PageContainer } from '@/components/layout/PageContainer'; import { PageContainer } from '@/components/layout/PageContainer';
import { Typography } from '@/components/ui/typography'; import { Typography } from '@/components/ui/typography';
import { AnchoringStatusView, CardLearningView } from '@/features/learning/LearningView'; import { AnchoringStatusView, CardLearningView } from '@/features/learning/LearningView';
@ -10,7 +10,10 @@ import { useAnchoringData, useLearningData } from '@/features/learning/api';
type Tab = 'cards' | 'anchoring'; type Tab = 'cards' | 'anchoring';
export default function LearningPage() { export default function LearningPage() {
const [tab, setTab] = useState<Tab>('cards'); // 탭은 쿼리(?tab=)에 둔다 — 새로고침·뒤로가기·링크 공유가 그대로 되도록.
const [params, setParams] = useSearchParams();
const tab: Tab = params.get('tab') === 'anchoring' ? 'anchoring' : 'cards';
const setTab = (next: Tab) => setParams(next === 'cards' ? {} : { tab: next }, { replace: true });
const learning = useLearningData(); const learning = useLearningData();
const anchoring = useAnchoringData(); const anchoring = useAnchoringData();
const isLoading = tab === 'cards' ? learning.isLoading : anchoring.isLoading; const isLoading = tab === 'cards' ? learning.isLoading : anchoring.isLoading;