[fix] agent·negodata/front: 중간값 멘트 모순 수정(갑 포지션 갱신을 치환 뒤로·원값 인용가 반올림 제외·1% 카드 역행 금지) + 계정 전환 쿼리캐시 초기화·ChatTab 카드 렌더 정리 + font-mono 한글 폴백(Windows 한글깨짐)
This commit is contained in:
parent
7dc925bcd4
commit
a405e00bf9
@ -145,7 +145,12 @@ def compute_offer(spec: CardSpec, context: Dict[str, Any]) -> Optional[int]:
|
||||
return None # 재료 부족(앵커 미박제·직전 제안 없음)
|
||||
if value > settle_ceiling(context):
|
||||
return None # 타결 상한 초과 — 받아줄 수 없는 금액이라 지금 못 쓴다
|
||||
offer = int(value / 10 + 0.5) * 10 # 10원 단위 반올림 — 앵커가·목표가 산정과 표기 통일
|
||||
if variable in ("target_price", "anchoring_price", "anchor_price"):
|
||||
# 원값 인용 변수 — 멘트엔 {target_price} 등 저장 원값이 그대로 나가므로, 반올림하면
|
||||
# 표시가≠타결가 미스매치가 난다(목표가 7652 멘트 → 7650 타결). 저장값 그대로 제시.
|
||||
offer = int(value)
|
||||
else:
|
||||
offer = int(value / 10 + 0.5) * 10 # 파생가(절충·중간) 10원 반올림 — 앵커·목표가 산정과 표기 통일
|
||||
if offer >= price:
|
||||
return None # 제시가가 이미 그 값 이하 → 부를 이유 없음
|
||||
if prev_customer and offer < prev_customer:
|
||||
|
||||
@ -152,7 +152,13 @@ class ChatEngine:
|
||||
pending = session.context.pop("pending_counter_price", None)
|
||||
if pending and user_input in _ACCEPT_INPUTS:
|
||||
session.context["input_price"] = float(pending)
|
||||
return self._render(session, nxt)
|
||||
view = self._render(session, nxt)
|
||||
# 갑의 포지션 갱신은 멘트 치환 뒤 — 치환 전에 덮으면 {prev_customer_price}(당사 직전 제안)가
|
||||
# 이번 카운터 값으로 찍혀 "당사 7700과 귀사 7900의 절반 = 7700" 같은 모순 멘트가 된다.
|
||||
new_pos = session.context.pop("_customer_position_after_render", None)
|
||||
if new_pos is not None:
|
||||
session.context["prev_customer_price"] = new_pos
|
||||
return view
|
||||
|
||||
# ---- transition ----------------------------------------------------
|
||||
def _default_next(self, node: dict) -> Optional[str]:
|
||||
@ -268,15 +274,16 @@ class ChatEngine:
|
||||
target = ctx.get("target_price", 0)
|
||||
if anchor > 0 and price <= anchor * self.rules.wildcard_1pct_ratio:
|
||||
offer_1pct = int(price * 0.99 / 10 + 0.5) * 10 # 1% 인하가 — 10원 반올림(앵커·카운터와 통일)
|
||||
# 제안가 공통 유효조건(≤목표가 · <제시가)은 시스템 1% 카드에도 동일하게 건다.
|
||||
# 기본 앵커 밴드에선 수학적으로 항상 통과하지만, 앵커율 0 등 극단 데이터를 방어한다.
|
||||
if 0 < offer_1pct < price and (target <= 0 or offer_1pct <= target):
|
||||
# 제안가 공통 유효조건(≤목표가 · <제시가 · 직전 당사 제안 이상=역행 금지)은 시스템 1% 카드에도
|
||||
# 동일하게 건다. 기본 앵커 밴드에선 수학적으로 항상 통과하지만 극단 데이터를 방어한다.
|
||||
prev_customer = ctx.get("prev_customer_price") or 0
|
||||
if 0 < offer_1pct < price and (target <= 0 or offer_1pct <= target) and offer_1pct >= prev_customer:
|
||||
# 와일드카드는 실제로 노출할 때만 '사용됨'으로 마킹한다 — 가격협상으로 돌아가는
|
||||
# 경우에도 마킹하면 이후 라운드에서 정당한 1% 카드까지 억제된다.
|
||||
ctx["wildcard_used"] = True
|
||||
ctx["offer_1pct"] = offer_1pct
|
||||
ctx["pending_counter_price"] = offer_1pct # 수락 시 이 가격으로 타결
|
||||
ctx["prev_customer_price"] = offer_1pct # 갑의 최신 포지션 — 이후 절충가 계산 기준
|
||||
ctx["_customer_position_after_render"] = offer_1pct # 갑의 최신 포지션 — 렌더 뒤 반영(advance 말미)
|
||||
return "wild_card_1pct"
|
||||
# 1.02 초과 ~ entry(1.05) 구간: 견적에서 선택한 와일드카드의 전술로 카운터 제시.
|
||||
# (구현 전에는 이 구간이 일반 가격협상으로 회귀해 선택형 WC 가 영영 발동하지 않던 갭.)
|
||||
@ -292,7 +299,7 @@ class ChatEngine:
|
||||
if offer is not None:
|
||||
ctx["wildcard_used"] = True
|
||||
ctx["pending_counter_price"] = offer
|
||||
ctx["prev_customer_price"] = offer # 갑의 최신 포지션 — "당사 제안 ○원" 멘트가 실제 이력과 일치
|
||||
ctx["_customer_position_after_render"] = offer # 갑의 최신 포지션 — 렌더 뒤 반영(advance 말미)
|
||||
ctx["active_wild_card_number"] = number
|
||||
mark_played(ctx, number)
|
||||
return "wild_card_dynamic"
|
||||
|
||||
@ -312,7 +312,6 @@ class ChatService:
|
||||
counter = compute_offer(spec, session.context) if available(spec, session.context) else None
|
||||
if counter is not None:
|
||||
session.context["pending_counter_price"] = counter
|
||||
session.context["prev_customer_price"] = counter # 갑의 최신 포지션(절충가 계산 기준)
|
||||
reward = RewardCalculator(engine.config.reward, engine.config.state).calculate(snap)
|
||||
policy.update(Transition(state_index=idx, action_id=decision.action_id, reward=reward.total, done=False))
|
||||
await QTablePolicyStore.persist_cell(repo, version_id, policy, idx, decision.action_id)
|
||||
@ -359,6 +358,9 @@ class ChatService:
|
||||
res.input_mode, res.input_options = view2.input_mode, view2.input_options
|
||||
if not card_script:
|
||||
res.script = view2.script # 카드 멘트 없으면 스텝 기본 카운터 멘트
|
||||
# 갑의 최신 포지션(절충가 계산 기준) — 멘트 치환 뒤에 갱신해야 {prev_customer_price}가
|
||||
# 이번 카운터가 아니라 직전 제안으로 나간다.
|
||||
session.context["prev_customer_price"] = counter
|
||||
|
||||
async def _play_closing_tactic(self, engine: TenantEngine, chat_engine: ChatEngine,
|
||||
scripts: ScriptRepository, session: ChatSession, res: Res_Chat):
|
||||
@ -388,7 +390,6 @@ class ChatService:
|
||||
return # 컨텍스트 이상 — 기존 가격협상 스텝 그대로(재제안 요구)
|
||||
mark_played(ctx, closing_number) # None(폴백 최후통첩)이면 no-op
|
||||
ctx["pending_counter_price"] = counter
|
||||
ctx["prev_customer_price"] = counter
|
||||
|
||||
template = None
|
||||
if closing_number:
|
||||
@ -400,6 +401,9 @@ class ChatService:
|
||||
res.step, res.client_step = view2.step, view2.client_step
|
||||
res.input_mode, res.input_options = view2.input_mode, view2.input_options
|
||||
res.script = scripts.format_script(template, chat_engine.vars_for(session)) if template else view2.script
|
||||
# 갑의 최신 포지션 갱신은 멘트 치환 뒤 — WC-05 중간값 멘트의 {prev_customer_price}(당사 직전 제안)가
|
||||
# 이번 절충가로 찍히던 버그(IMK 0807) 방지.
|
||||
ctx["prev_customer_price"] = counter
|
||||
res.card_id = closing_number
|
||||
|
||||
async def _terminal_learn(self, engine: TenantEngine, session: ChatSession, outcome: str, res: Res_Chat):
|
||||
|
||||
@ -1,21 +1,10 @@
|
||||
import {QueryClient, QueryClientProvider} from '@tanstack/react-query';
|
||||
import {useState, type ReactNode} from 'react';
|
||||
import {QueryClientProvider} from '@tanstack/react-query';
|
||||
import {type ReactNode} from 'react';
|
||||
import {Toaster} from 'sonner';
|
||||
import {ConfirmHost} from '@/lib/confirm';
|
||||
import {queryClient} from '@/lib/query-client';
|
||||
|
||||
export function Providers({children}: {children: ReactNode}) {
|
||||
const [queryClient] = useState(
|
||||
() =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
|
||||
@ -49,14 +49,11 @@ export function ActionBanner() {
|
||||
const queryClient = useQueryClient();
|
||||
const readOne = useReadOne();
|
||||
|
||||
const { data: notif } = useListNotifications(
|
||||
{ size: 20 },
|
||||
{ query: { refetchInterval: 30_000, staleTime: 10_000 } },
|
||||
);
|
||||
const { data: notif } = useListNotifications({ size: 20 }, { query: { refetchInterval: 30_000 } });
|
||||
// 재협상 요청 토스트는 '아직 완료 안 된(대기중)' 것만 — 내가 처리 가능한(can_act) 대기 세션 집합.
|
||||
const { data: renego } = useListRequests(
|
||||
{ status: 1, page: 1, size: 50 },
|
||||
{ query: { refetchInterval: 30_000, staleTime: 10_000 } },
|
||||
{ query: { refetchInterval: 30_000 } },
|
||||
);
|
||||
const pendingSessions = new Set(
|
||||
(renego?.requests ?? []).filter((r) => r.can_act !== false).map((r) => r.session_id),
|
||||
|
||||
@ -10,6 +10,7 @@ import {useAuthStore, type AuthUser, type UserRole} from '../../stores/auth';
|
||||
import {UserRole as UserRoleCode} from '../../api/generated/model';
|
||||
import {USER_ROLE_LABEL} from '../../lib/enumLabels';
|
||||
import {toMessage, resultMessage} from '../../lib/apiError';
|
||||
import {queryClient} from '../../lib/query-client';
|
||||
|
||||
const ACCESS_KEY = 'negodata.accessToken';
|
||||
const REFRESH_KEY = 'negodata.refreshToken';
|
||||
@ -69,6 +70,8 @@ export function initAuth(): Promise<void> {
|
||||
|
||||
export async function login(loginId: string, password: string): Promise<ResMe> {
|
||||
const tokens = ensureOk(await loginRequest({id: loginId, password}));
|
||||
// 직전 계정(다른 회사)의 캐시가 남아 브랜딩·목록이 그대로 보이는 것 방지
|
||||
queryClient.clear();
|
||||
localStorage.setItem(ACCESS_KEY, tokens.access_token ?? '');
|
||||
localStorage.setItem(REFRESH_KEY, tokens.refresh_token ?? '');
|
||||
setAccessToken(tokens.access_token ?? null);
|
||||
@ -105,4 +108,5 @@ export async function logout(): Promise<void> {
|
||||
localStorage.removeItem(REFRESH_KEY);
|
||||
setAccessToken(null);
|
||||
useAuthStore.getState().setUser(null);
|
||||
queryClient.clear();
|
||||
}
|
||||
|
||||
@ -4,13 +4,11 @@ import type { SessionData } from '@/api/generated/model/sessionData';
|
||||
import type { QuotationCardData } from '@/api/generated/model/quotationCardData';
|
||||
import type { ChatMessageData } from '@/api/generated/model/chatMessageData';
|
||||
import { ChatSender, CardType } from '@/api/generated/model';
|
||||
import SlateRenderer from '@/components/SlateRenderer';
|
||||
import { Typography } from '@/components/ui/typography';
|
||||
import { StatusPill, sessionStatusTone } from './StatusPill';
|
||||
import { type Product, type Partner, sessionStatusLabel } from '../../types';
|
||||
import { useCompanySettings } from '@/features/settings/useCompanySettings';
|
||||
import { renderEmphasis } from '@/lib/emphasis';
|
||||
import { renderCardScriptPreview } from '@/features/cards/editor';
|
||||
|
||||
// 협상로그 JSON 다운로드(IMK #9). 가격·인하율은 실값 그대로 내보낸다 — 이 탭을 열 수 있는 사람이
|
||||
// 이미 공개 대상(해당 견적 담당자∪최고관리자, 조회 게이팅)뿐이라 마스킹이 하던 차단 역할이 없다.
|
||||
@ -186,19 +184,12 @@ export function ChatTab({
|
||||
) : (
|
||||
chatMessages.map((m) =>
|
||||
m.sender === ChatSender.BOT ? (
|
||||
<BotBubble
|
||||
key={m.chat_id}
|
||||
message={m}
|
||||
currentSupplierName={currentSupplierName}
|
||||
currentProduct={currentProduct}
|
||||
serverCards={serverCards}
|
||||
/>
|
||||
<BotBubble key={m.chat_id} message={m} serverCards={serverCards} />
|
||||
) : (
|
||||
<PartnerBubble
|
||||
key={m.chat_id}
|
||||
message={m}
|
||||
currentSupplierName={currentSupplierName}
|
||||
currentProduct={currentProduct}
|
||||
serverCards={serverCards}
|
||||
/>
|
||||
),
|
||||
@ -211,22 +202,16 @@ export function ChatTab({
|
||||
);
|
||||
}
|
||||
|
||||
// 봇(좌측) 말풍선. 진행 단계·협상 스크립트(가격 마스킹)·사용 협상카드를 보여준다.
|
||||
// 봇(좌측) 말풍선. 진행 단계·협상 스크립트·사용 협상카드를 보여준다.
|
||||
// 간격은 부모(flex flex-col gap)에서 주고, 말풍선 박스는 block 으로 둬 긴 텍스트 줄바꿈이 깨지지 않게 한다.
|
||||
function BotBubble({
|
||||
message,
|
||||
currentSupplierName,
|
||||
currentProduct,
|
||||
serverCards,
|
||||
}: {
|
||||
message: ChatMessageData;
|
||||
currentSupplierName: string;
|
||||
currentProduct: Product | undefined;
|
||||
serverCards: QuotationCardData[];
|
||||
}) {
|
||||
const m = message;
|
||||
// 카드 사용 메시지는 같은 멘트가 아래 카드 박스에 그대로 나오므로 본문 script 는 중복 → 카드 없을 때만 노출.
|
||||
const usedCard = findUsedCard(m, serverCards);
|
||||
return (
|
||||
<div className="flex justify-start">
|
||||
<div className="flex flex-col gap-1.5 max-w-[85%]">
|
||||
@ -241,18 +226,13 @@ function BotBubble({
|
||||
{m.step}
|
||||
</Typography>
|
||||
)}
|
||||
{m.script && !usedCard && (
|
||||
{/* 본문은 실제 발화(chats.meta.script) — 카드 멘트도 변수 치환·자연화가 끝난 최종본이 여기 담긴다. */}
|
||||
{m.script && (
|
||||
<Typography as="p" variant="small" className="text-xs whitespace-pre-line leading-relaxed text-inherit">
|
||||
{renderEmphasis(m.script)}
|
||||
</Typography>
|
||||
)}
|
||||
<UsedCardBox
|
||||
message={m}
|
||||
isBot
|
||||
currentSupplierName={currentSupplierName}
|
||||
currentProduct={currentProduct}
|
||||
serverCards={serverCards}
|
||||
/>
|
||||
<UsedCardBox message={m} isBot serverCards={serverCards} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -263,12 +243,10 @@ function BotBubble({
|
||||
function PartnerBubble({
|
||||
message,
|
||||
currentSupplierName,
|
||||
currentProduct,
|
||||
serverCards,
|
||||
}: {
|
||||
message: ChatMessageData;
|
||||
currentSupplierName: string;
|
||||
currentProduct: Product | undefined;
|
||||
serverCards: QuotationCardData[];
|
||||
}) {
|
||||
const m = message;
|
||||
@ -291,13 +269,7 @@ function PartnerBubble({
|
||||
{m.script}
|
||||
</Typography>
|
||||
)}
|
||||
<UsedCardBox
|
||||
message={m}
|
||||
isBot={false}
|
||||
currentSupplierName={currentSupplierName}
|
||||
currentProduct={currentProduct}
|
||||
serverCards={serverCards}
|
||||
/>
|
||||
<UsedCardBox message={m} isBot={false} serverCards={serverCards} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -305,24 +277,20 @@ function PartnerBubble({
|
||||
}
|
||||
|
||||
// 말풍선에 붙는 협상카드 박스(봇/협력사 공용). 카드 미사용 메시지면 아무것도 렌더하지 않는다.
|
||||
// 톤(amber/primary)만 isBot 으로 가르고, 멘트/조건/메모 렌더 로직은 공유한다.
|
||||
// 멘트 본문은 위 말풍선(실발화 m.script)이 담당 — 여기선 카드 메타(번호·이름·종류·와일드 조건/메모)만.
|
||||
// 톤은 카드 종류(와일드=amber, 협상=primary)로 가른다.
|
||||
function UsedCardBox({
|
||||
message,
|
||||
isBot,
|
||||
currentSupplierName,
|
||||
currentProduct,
|
||||
serverCards,
|
||||
}: {
|
||||
message: ChatMessageData;
|
||||
isBot: boolean;
|
||||
currentSupplierName: string;
|
||||
currentProduct: Product | undefined;
|
||||
serverCards: QuotationCardData[];
|
||||
}) {
|
||||
const m = message;
|
||||
const usedCard = findUsedCard(m, serverCards);
|
||||
if (!usedCard) return null;
|
||||
const cardNodes = Array.isArray(usedCard.edit_script) ? (usedCard.edit_script as unknown[]) : null;
|
||||
const isWildCard = usedCard.type === CardType.WILD;
|
||||
const cardDetailId = usedCard.nego_card_id ?? usedCard.wild_card_id ?? null; // 협상카드 상세(/cards?detail=) 링크용
|
||||
|
||||
@ -330,12 +298,20 @@ function UsedCardBox({
|
||||
<div
|
||||
className={`rounded border p-2 ${
|
||||
isBot
|
||||
? 'bg-amber-50/70 border-amber-200 dark:bg-amber-950/20 dark:border-amber-900/40'
|
||||
: 'bg-white/10 border-white/20'
|
||||
? isWildCard
|
||||
? 'bg-amber-50/70 border-amber-200 dark:bg-amber-950/20 dark:border-amber-900/40'
|
||||
: 'bg-primary/5 border-primary/25 dark:bg-primary/10 dark:border-primary/30'
|
||||
: isWildCard
|
||||
? 'bg-amber-300/15 border-amber-200/40'
|
||||
: 'bg-white/10 border-white/20'
|
||||
}`}
|
||||
>
|
||||
{/* 헤더: 어떤 카드인지(번호·이름·종류) */}
|
||||
<div className={`flex items-center gap-1 ${isBot ? 'text-amber-800 dark:text-amber-300' : 'text-primary-foreground'}`}>
|
||||
<div
|
||||
className={`flex items-center gap-1 ${
|
||||
isBot ? (isWildCard ? 'text-amber-800 dark:text-amber-300' : 'text-primary') : 'text-primary-foreground'
|
||||
}`}
|
||||
>
|
||||
<Sparkles size={10} />
|
||||
<Typography as="span" variant="small" className="text-[10px] font-semibold text-inherit">협상카드</Typography>
|
||||
{cardDetailId ? (
|
||||
@ -374,23 +350,6 @@ function UsedCardBox({
|
||||
</Typography>
|
||||
</div>
|
||||
|
||||
{/* 멘트 본문: 서식본(edit_script) 우선, 없으면 평문 script. 가격 변수는 매핑하지 않아(값 미주입) 실가격이 노출되지 않는다. */}
|
||||
{cardNodes ? (
|
||||
<div className="mt-1.5">
|
||||
<SlateRenderer
|
||||
nodes={cardNodes}
|
||||
variables={{
|
||||
partner_name: currentSupplierName,
|
||||
product_name: currentProduct?.name ?? '',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : usedCard.script ? (
|
||||
<Typography as="p" variant="small" className="mt-1.5 text-xs leading-relaxed whitespace-pre-line text-foreground/85">
|
||||
{renderCardScriptPreview(usedCard.script)}
|
||||
</Typography>
|
||||
) : null}
|
||||
|
||||
{/* 와일드카드 부가 정보: 사용 조건 / 메모 */}
|
||||
{isWildCard && (usedCard.condition || usedCard.memo) && (
|
||||
<div className="mt-1.5 pt-1.5 border-t border-amber-200/60 dark:border-amber-900/40 space-y-0.5 text-muted-foreground">
|
||||
|
||||
@ -6,7 +6,7 @@ import { LABEL_DEFAULTS, DEFAULT_VAT_MODE, type CompanySettings, type VatMode }
|
||||
// 조회는 전 유저(브랜딩/라벨 렌더용), 저장은 백엔드가 OWNER 로 게이트한다.
|
||||
export function useCompanySettings() {
|
||||
const queryClient = useQueryClient();
|
||||
const query = useGetSettings({ query: { staleTime: 5 * 60 * 1000 } });
|
||||
const query = useGetSettings();
|
||||
const settings: CompanySettings = (query.data?.settings as CompanySettings) ?? {};
|
||||
|
||||
const save = async (next: CompanySettings) => {
|
||||
|
||||
16
negodata/front/src/lib/query-client.ts
Normal file
16
negodata/front/src/lib/query-client.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import {QueryClient} from '@tanstack/react-query';
|
||||
|
||||
// negodata 는 서버 응답을 캐시하지 않는다 — 화면에 뜨는 값은 항상 그 시점의 서버 값이다.
|
||||
// (캐시를 두면 계정을 갈아탔을 때 앞 회사 CI·라벨·목록이 그대로 그려진다.)
|
||||
// staleTime 0 = 받는 즉시 stale 로 보고 다시 읽는다, gcTime 0 = 화면에서 내려가면 바로 버린다.
|
||||
// 페이지네이션 깜빡임은 각 훅의 placeholderData(keepPreviousData)가 막는다 — 캐시가 아니라 직전 렌더값이다.
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 0,
|
||||
gcTime: 0,
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
@ -36,7 +36,8 @@
|
||||
--color-ring: var(--ring);
|
||||
|
||||
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
|
||||
--font-mono: "JetBrains Mono", Consolas, "Segoe UI Mono", ui-monospace, SFMono-Regular, "Menlo", monospace;
|
||||
/* 모노 폰트엔 한글 글리프가 없다 — Pretendard 를 넣어야 한글이 OS 기본(Windows 굴림)으로 떨어지지 않는다 */
|
||||
--font-mono: "JetBrains Mono", Consolas, "Segoe UI Mono", ui-monospace, SFMono-Regular, "Menlo", "Pretendard Variable", monospace;
|
||||
}
|
||||
|
||||
:root {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user