diff --git a/agent/negotiation/cards/domain/tactics.py b/agent/negotiation/cards/domain/tactics.py index add1c62..81ccf02 100644 --- a/agent/negotiation/cards/domain/tactics.py +++ b/agent/negotiation/cards/domain/tactics.py @@ -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: diff --git a/agent/negotiation/chat/service/chat_engine.py b/agent/negotiation/chat/service/chat_engine.py index 89e72dd..4a7f433 100644 --- a/agent/negotiation/chat/service/chat_engine.py +++ b/agent/negotiation/chat/service/chat_engine.py @@ -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" diff --git a/agent/services/chat_service.py b/agent/services/chat_service.py index ff0cb7d..335fc1f 100644 --- a/agent/services/chat_service.py +++ b/agent/services/chat_service.py @@ -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): diff --git a/negodata/front/src/app/provider.tsx b/negodata/front/src/app/provider.tsx index fab2fec..f3e7e16 100644 --- a/negodata/front/src/app/provider.tsx +++ b/negodata/front/src/app/provider.tsx @@ -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 ( {children} diff --git a/negodata/front/src/components/layout/ActionBanner.tsx b/negodata/front/src/components/layout/ActionBanner.tsx index 01b0d54..9c4754e 100644 --- a/negodata/front/src/components/layout/ActionBanner.tsx +++ b/negodata/front/src/components/layout/ActionBanner.tsx @@ -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), diff --git a/negodata/front/src/features/auth/service.ts b/negodata/front/src/features/auth/service.ts index 529c074..6eaf166 100644 --- a/negodata/front/src/features/auth/service.ts +++ b/negodata/front/src/features/auth/service.ts @@ -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 { export async function login(loginId: string, password: string): Promise { 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 { localStorage.removeItem(REFRESH_KEY); setAccessToken(null); useAuthStore.getState().setUser(null); + queryClient.clear(); } diff --git a/negodata/front/src/features/quotations/components/QuotationDetailSheet/ChatTab.tsx b/negodata/front/src/features/quotations/components/QuotationDetailSheet/ChatTab.tsx index e1b3457..7d786aa 100644 --- a/negodata/front/src/features/quotations/components/QuotationDetailSheet/ChatTab.tsx +++ b/negodata/front/src/features/quotations/components/QuotationDetailSheet/ChatTab.tsx @@ -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 ? ( - + ) : ( ), @@ -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 (
@@ -241,18 +226,13 @@ function BotBubble({ {m.step} )} - {m.script && !usedCard && ( + {/* 본문은 실제 발화(chats.meta.script) — 카드 멘트도 변수 치환·자연화가 끝난 최종본이 여기 담긴다. */} + {m.script && ( {renderEmphasis(m.script)} )} - +
@@ -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} )} - + @@ -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({
{/* 헤더: 어떤 카드인지(번호·이름·종류) */} -
+
협상카드 {cardDetailId ? ( @@ -374,23 +350,6 @@ function UsedCardBox({
- {/* 멘트 본문: 서식본(edit_script) 우선, 없으면 평문 script. 가격 변수는 매핑하지 않아(값 미주입) 실가격이 노출되지 않는다. */} - {cardNodes ? ( -
- -
- ) : usedCard.script ? ( - - {renderCardScriptPreview(usedCard.script)} - - ) : null} - {/* 와일드카드 부가 정보: 사용 조건 / 메모 */} {isWildCard && (usedCard.condition || usedCard.memo) && (
diff --git a/negodata/front/src/features/settings/useCompanySettings.ts b/negodata/front/src/features/settings/useCompanySettings.ts index 0239cf0..65be304 100644 --- a/negodata/front/src/features/settings/useCompanySettings.ts +++ b/negodata/front/src/features/settings/useCompanySettings.ts @@ -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) => { diff --git a/negodata/front/src/lib/query-client.ts b/negodata/front/src/lib/query-client.ts new file mode 100644 index 0000000..59b7859 --- /dev/null +++ b/negodata/front/src/lib/query-client.ts @@ -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, + }, + }, +}); diff --git a/negodata/front/src/tokens.css b/negodata/front/src/tokens.css index eb5b47f..f5d7aa1 100644 --- a/negodata/front/src/tokens.css +++ b/negodata/front/src/tokens.css @@ -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 {