Merge remote-tracking branch 'origin/main' into feature/backend-frontend
This commit is contained in:
commit
7bfd77f6c3
43
agent/negotiation/cards/adapters/card_script_db.py
Normal file
43
agent/negotiation/cards/adapters/card_script_db.py
Normal file
@ -0,0 +1,43 @@
|
||||
"""CardScriptDbRepository — card.nego_cards 에서 카드 멘트 read-only 조회.
|
||||
|
||||
스키마 소유권: card 스키마는 backend/negodata 소유 — agent 는 read-only 로만 접근한다.
|
||||
ORM 모델 중복 정의를 피하려고 sqlalchemy 경량 table()/column() 구성을 쓴다(select 전용).
|
||||
|
||||
매칭 키: agent action_to_card(config)의 카드코드 = card.nego_cards.number.
|
||||
같은 number 가 여러 버전에 존재할 수 있어 최신(created_at desc) 1건을 취한다
|
||||
— 버전/회사 스코프 세분화는 후속(현재는 카드코드가 안정적이라는 전제).
|
||||
"""
|
||||
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from sqlalchemy import column, desc, select, table
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from common.database.db_session_manager import DB_SESSION_MNG
|
||||
from common.enums import ErrorType
|
||||
from common.logger import LOG
|
||||
from negotiation.cards.ports.card_script_port import ICardScriptRepository
|
||||
|
||||
_NEGO_CARDS = table(
|
||||
"nego_cards",
|
||||
column("number"), column("script"), column("created_at"), column("deleted"),
|
||||
schema="card",
|
||||
)
|
||||
|
||||
|
||||
class CardScriptDbRepository(ICardScriptRepository):
|
||||
async def get_script_by_number(self, cdb: AsyncSession, number: str) -> Tuple[ErrorType, Optional[str]]:
|
||||
try:
|
||||
query = (
|
||||
select(_NEGO_CARDS.c.script)
|
||||
.where(_NEGO_CARDS.c.number == number, _NEGO_CARDS.c.deleted == False) # noqa: E712
|
||||
.order_by(desc(_NEGO_CARDS.c.created_at))
|
||||
.limit(1)
|
||||
)
|
||||
err_type, rows = await DB_SESSION_MNG.execute(cdb, query, "get_card_script failed.", raise_error=False)
|
||||
if err_type != ErrorType.SUCCESS or not rows or not rows[0]:
|
||||
return err_type, None
|
||||
return ErrorType.SUCCESS, str(rows[0])
|
||||
except Exception as ex:
|
||||
LOG.e_no_callstack(ex)
|
||||
return ErrorType.DB_RUN_FAILED, None
|
||||
23
agent/negotiation/cards/ports/card_script_port.py
Normal file
23
agent/negotiation/cards/ports/card_script_port.py
Normal file
@ -0,0 +1,23 @@
|
||||
"""ICardScriptRepository — 카드 멘트 DB 조회 포트 (backoffice_db 소스용).
|
||||
|
||||
카드 멘트의 정본은 백오피스(negodata)가 편집하는 card.nego_cards 이다. agent 는
|
||||
평문/마커 텍스트(script 컬럼)만 읽어 변수 치환 후 그대로 전달한다 — Slate 원본
|
||||
(edit_script)은 재편집 전용이라 읽지 않는다(표현 규칙은 프론트 소유).
|
||||
|
||||
backend crud 패턴: 세션(cdb) 주입 + (ErrorType, data) 반환. 트랜잭션/세션 경계는
|
||||
호출부(ScriptRepository)가 DB_SESSION_MNG.execute_lambda 로 관리한다.
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from common.enums import ErrorType
|
||||
|
||||
|
||||
class ICardScriptRepository(ABC):
|
||||
@abstractmethod
|
||||
async def get_script_by_number(self, cdb: AsyncSession, number: str) -> Tuple[ErrorType, Optional[str]]:
|
||||
"""카드코드(card.nego_cards.number)로 멘트(script 평문/마커)를 조회. 없으면 None."""
|
||||
...
|
||||
@ -29,6 +29,12 @@ _SESSIONS = table(
|
||||
_ITEMS = table("items", column("item_id"), column("price"), column("deleted"), schema="partner")
|
||||
_SUPPLIERS = table("suppliers", column("supplier_id"), column("total_revenue"), column("deleted"), schema="partner")
|
||||
_QUOTATIONS = table("quotations", column("qt_id"), column("supplier_type"), column("deleted"), schema="quotation")
|
||||
# 상품↔협력사 매핑 (2026-07-07 신설): supply_type = 이 협력사가 이 상품을 공급하는 방식(SupplierType).
|
||||
_SUPPLIER_ITEMS = table(
|
||||
"supplier_items",
|
||||
column("supplier_id"), column("item_id"), column("supply_type"), column("deleted"),
|
||||
schema="partner",
|
||||
)
|
||||
|
||||
|
||||
class INegoContextCRUD(ABC):
|
||||
@ -47,14 +53,25 @@ class INegoContextCRUD(ABC):
|
||||
"""협력사 총매출액(suppliers.total_revenue — KTC 미러). 없으면 0.0."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def get_supply_type(self, cdb: AsyncSession, supplier_id, item_id) -> Tuple[ErrorType, Optional[int]]:
|
||||
"""이 협력사가 이 상품을 공급하는 방식(supplier_items.supply_type: 0=none/1=유통/2=제조/3=총판).
|
||||
매핑이 없으면 None."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def get_quotation_supplier_type(self, cdb: AsyncSession, quotation_id) -> Tuple[ErrorType, Optional[int]]:
|
||||
"""견적의 협력사 유형(quotations.supplier_type: 0=none/1=유통/2=제조/3=총판). 미지정 시 None."""
|
||||
"""견적의 협력사 유형(quotations.supplier_type — supplier_items 매핑 부재 시 폴백). 미지정 시 None."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def count_item_suppliers(self, cdb: AsyncSession, item_id) -> Tuple[ErrorType, int]:
|
||||
"""상품에 연결된 협력사 수(협상 세션 이력 기준 distinct supplier)."""
|
||||
"""상품에 연결된 협력사 수 — supplier_items 매핑 기준 distinct supplier."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def count_item_session_suppliers(self, cdb: AsyncSession, item_id) -> Tuple[ErrorType, int]:
|
||||
"""상품에 연결된 협력사 수 — 협상 세션 이력 기준(supplier_items 매핑 부재 시 폴백)."""
|
||||
pass
|
||||
|
||||
|
||||
@ -105,6 +122,23 @@ class NegoContextCRUD(INegoContextCRUD):
|
||||
LOG.e_no_callstack(ex)
|
||||
return ErrorType.DB_RUN_FAILED, 0.0
|
||||
|
||||
async def get_supply_type(self, cdb: AsyncSession, supplier_id, item_id) -> Tuple[ErrorType, Optional[int]]:
|
||||
try:
|
||||
query = (
|
||||
select(_SUPPLIER_ITEMS.c.supply_type)
|
||||
.where(_SUPPLIER_ITEMS.c.supplier_id == supplier_id,
|
||||
_SUPPLIER_ITEMS.c.item_id == item_id,
|
||||
_SUPPLIER_ITEMS.c.deleted == False) # noqa: E712
|
||||
.limit(1)
|
||||
)
|
||||
err_type, rows = await DB_SESSION_MNG.execute(cdb, query, "get_supply_type failed.", raise_error=False)
|
||||
if err_type != ErrorType.SUCCESS or not rows or rows[0] is None:
|
||||
return err_type, None
|
||||
return ErrorType.SUCCESS, int(rows[0])
|
||||
except Exception as ex:
|
||||
LOG.e_no_callstack(ex)
|
||||
return ErrorType.DB_RUN_FAILED, None
|
||||
|
||||
async def get_quotation_supplier_type(self, cdb: AsyncSession, quotation_id) -> Tuple[ErrorType, Optional[int]]:
|
||||
try:
|
||||
query = (
|
||||
@ -123,8 +157,8 @@ class NegoContextCRUD(INegoContextCRUD):
|
||||
async def count_item_suppliers(self, cdb: AsyncSession, item_id) -> Tuple[ErrorType, int]:
|
||||
try:
|
||||
query = (
|
||||
select(func.count(distinct(_SESSIONS.c.supplier_id)))
|
||||
.where(_SESSIONS.c.item_id == item_id, _SESSIONS.c.deleted == False) # noqa: E712
|
||||
select(func.count(distinct(_SUPPLIER_ITEMS.c.supplier_id)))
|
||||
.where(_SUPPLIER_ITEMS.c.item_id == item_id, _SUPPLIER_ITEMS.c.deleted == False) # noqa: E712
|
||||
)
|
||||
err_type, rows = await DB_SESSION_MNG.execute(cdb, query, "count_item_suppliers failed.", raise_error=False)
|
||||
if err_type != ErrorType.SUCCESS or not rows:
|
||||
@ -133,3 +167,17 @@ class NegoContextCRUD(INegoContextCRUD):
|
||||
except Exception as ex:
|
||||
LOG.e_no_callstack(ex)
|
||||
return ErrorType.DB_RUN_FAILED, 0
|
||||
|
||||
async def count_item_session_suppliers(self, cdb: AsyncSession, item_id) -> Tuple[ErrorType, int]:
|
||||
try:
|
||||
query = (
|
||||
select(func.count(distinct(_SESSIONS.c.supplier_id)))
|
||||
.where(_SESSIONS.c.item_id == item_id, _SESSIONS.c.deleted == False) # noqa: E712
|
||||
)
|
||||
err_type, rows = await DB_SESSION_MNG.execute(cdb, query, "count_item_session_suppliers failed.", raise_error=False)
|
||||
if err_type != ErrorType.SUCCESS or not rows:
|
||||
return err_type, 0
|
||||
return ErrorType.SUCCESS, int(rows[0] or 0)
|
||||
except Exception as ex:
|
||||
LOG.e_no_callstack(ex)
|
||||
return ErrorType.DB_RUN_FAILED, 0
|
||||
|
||||
@ -25,8 +25,10 @@ from negotiation.qtable.domain.model.snapshot import PartnerType
|
||||
# 1:1 견적유형 → 재협상 스크립트. QuotationType: 1=renego, 3=new_nego (2=requote, 4=new_quote 는 1:N 재견적).
|
||||
_ONE_TO_ONE_QT_TYPES = (1, 3)
|
||||
|
||||
# 유통 코드: quotations.supplier_type(1=distribution 유통, 2=manufacture 제조, 3=sole_agency 총판)
|
||||
# 유통 코드: SupplierType(1=distribution 유통, 2=manufacture 제조, 3=sole_agency 총판)
|
||||
# → 테넌트 code_map 키(A/B/C). 제조→A, 총판→B, 유통→C (0=none/NULL 은 미지정 → 호출부 기본값).
|
||||
# 소스 우선순위: partner.supplier_items.supply_type(이 협력사×이 상품 매핑, 2026-07-07 신설)
|
||||
# → quotations.supplier_type(재견적 1:1 견적 기록 — 매핑 부재 시 폴백).
|
||||
_SUPPLIER_TYPE_TO_CODE = {2: "A", 3: "B", 1: "C"}
|
||||
|
||||
|
||||
@ -38,9 +40,9 @@ class NegotiationDbContext:
|
||||
target_price: int # 목표 매입가(원) — sessions.target_price
|
||||
anchor_price: int # 앵커링가 — sessions.anchoring_price(생성 시 박제). 없으면 target(무할인 폴백)
|
||||
item_price: int # 기존 공급가(품목 기준가, items.price) — 인하율 멘트용. 없으면 0
|
||||
partner_type: PartnerType # 상품에 연결된 협력사 수(distinct supplier) → NONE/SINGLE/MULTIPLE
|
||||
partner_type: PartnerType # 상품에 연결된 협력사 수(supplier_items 매핑, 없으면 세션 이력) → NONE/SINGLE/MULTIPLE
|
||||
revenue_amount: float # 매출액(원) — suppliers.total_revenue(KTC 미러). 없으면 0
|
||||
distribution_code: Optional[str] # 유통 코드(A/B/C) — quotations.supplier_type 매핑. 미지정 시 None
|
||||
distribution_code: Optional[str] # 유통 코드(A/B/C) — supplier_items.supply_type → quotations.supplier_type. 미지정 시 None
|
||||
|
||||
|
||||
class NegotiationContextLoader:
|
||||
@ -74,14 +76,20 @@ class NegotiationContextLoader:
|
||||
# 매출액: 협력사 총매출(KTC total_revenue 미러). 미기재 시 0 → 호출부 기본값.
|
||||
_, revenue_amount = await self.crud.get_supplier_total_revenue(s, supplier_id)
|
||||
|
||||
# 유통 코드: 견적의 협력사 유형(supplier_type) 매핑. 미지정 시 None → 호출부 기본값.
|
||||
_, supplier_type = await self.crud.get_quotation_supplier_type(s, quotation_id)
|
||||
# 유통 코드: 이 협력사×이 상품의 공급 방식(supplier_items.supply_type) 우선.
|
||||
# 매핑이 없으면 견적 기록(quotations.supplier_type) 폴백. 미지정 시 None → 호출부 기본값.
|
||||
_, supplier_type = await self.crud.get_supply_type(s, supplier_id, item_id)
|
||||
if not supplier_type:
|
||||
_, supplier_type = await self.crud.get_quotation_supplier_type(s, quotation_id)
|
||||
|
||||
# 기존 공급가(품목 기준가) — 없으면 0(인하율 멘트 미표시).
|
||||
_, item_price = await self.crud.get_item_price(s, item_id)
|
||||
|
||||
# 파트너사 유형: 상품에 연결된 협력사 수(협상 세션 이력 기준 distinct supplier). 실패 시 SINGLE.
|
||||
# 파트너사 유형: 상품에 연결된 협력사 수 — supplier_items 매핑(등록 기준) 우선.
|
||||
# 매핑이 아직 없으면 협상 세션 이력 기준 폴백(더미보다 항상 낫다). 실패 시 SINGLE.
|
||||
err, supplier_count = await self.crud.count_item_suppliers(s, item_id)
|
||||
if err == ErrorType.SUCCESS and supplier_count == 0:
|
||||
err, supplier_count = await self.crud.count_item_session_suppliers(s, item_id)
|
||||
if err != ErrorType.SUCCESS:
|
||||
supplier_count = 1
|
||||
|
||||
|
||||
@ -4,6 +4,9 @@
|
||||
- 탐색 순서: tenants/<id>/resources/ → 없으면 tenants/_base/resources/ 폴백.
|
||||
- 브랜드({company_name}/{service_name})는 TenantConfig.resources 에서 주입(클린룸: 특정사 브랜드 비포함).
|
||||
- 협상 변수({input_price}, {target}, {offer_1pct} 등)는 format_script 에서 치환.
|
||||
- 표현 규칙: 스크립트는 의미(텍스트)만 소유한다. 문장 내 강조는 `**굵게**` 경량 마커만 허용
|
||||
— 렌더(굵기·색)는 프론트 소유(frontend emphasis.tsx). 선행 Chat_server 의 리치텍스트 JSON
|
||||
이중 관리(response_scripts.json)는 채택하지 않는다.
|
||||
|
||||
Chat_server 구조 참고, 동적 import 해킹/특정사 표현은 제거(CLEANROOM.md).
|
||||
"""
|
||||
@ -12,17 +15,25 @@ import json
|
||||
import os
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from common.database.db_session_manager import DB_SESSION_MNG
|
||||
from common.enums import DBType, DBWRType, ErrorType
|
||||
from common.logger import LOG
|
||||
from negotiation.cards.adapters.card_script_db import CardScriptDbRepository
|
||||
from negotiation.cards.ports.card_script_port import ICardScriptRepository
|
||||
from tenancy.config import TenantConfig
|
||||
|
||||
_RQ_FILES = {"재협상": "scripts_renegotiation.json", "재견적": "scripts_requote.json"}
|
||||
_CARD_SOURCE_DB = "backoffice_db"
|
||||
|
||||
|
||||
class ScriptRepository:
|
||||
def __init__(self, config: TenantConfig, tenants_dir: str):
|
||||
def __init__(self, config: TenantConfig, tenants_dir: str,
|
||||
card_repo: Optional[ICardScriptRepository] = None):
|
||||
self._config = config
|
||||
self._tenants_dir = tenants_dir
|
||||
self._cache: Dict[str, Any] = {}
|
||||
# 카드 멘트 DB 소스(backoffice_db). file 모드면 미사용.
|
||||
self._card_repo: ICardScriptRepository = card_repo or CardScriptDbRepository()
|
||||
|
||||
# ---- 경로 해석 (_base 폴백) ---------------------------------------
|
||||
def _resource_path(self, filename: str) -> Optional[str]:
|
||||
@ -69,12 +80,37 @@ class ScriptRepository:
|
||||
return self._load_json("scripts_cards.json")
|
||||
|
||||
def card_script(self, action_id: int, variables: Optional[Dict[str, Any]] = None) -> Optional[str]:
|
||||
"""선택된 카드(action_id)의 스크립트를 변수 치환해서 반환. 없으면 None(호출부가 기본 문구 유지)."""
|
||||
"""선택된 카드(action_id)의 파일 스크립트를 변수 치환해서 반환. 없으면 None(호출부가 기본 문구 유지)."""
|
||||
text = self.card_scripts().get(str(action_id))
|
||||
if not text:
|
||||
return None
|
||||
return self.format_script(text, variables)
|
||||
|
||||
async def resolve_card_script(self, action_id: int, card_id: Optional[str],
|
||||
variables: Optional[Dict[str, Any]] = None) -> Optional[str]:
|
||||
"""카드 멘트 해석. cards.source_type == 'backoffice_db' 면 card.nego_cards.script(DB)를
|
||||
우선 조회하고, 없거나 file 모드면 scripts_cards.json(파일) 폴백. 변수 치환 후 반환.
|
||||
|
||||
DB 멘트는 백오피스(negodata)가 편집한 정본이라 파일보다 우선한다. 마커(**굵게** 등)가
|
||||
섞여 있어도 agent 는 불투명 텍스트로 취급 — 표현 렌더는 프론트 소유.
|
||||
"""
|
||||
if self._config.cards.source_type == _CARD_SOURCE_DB and card_id:
|
||||
db_text = await self._fetch_card_script_db(card_id)
|
||||
if db_text:
|
||||
return self.format_script(db_text, variables)
|
||||
return self.card_script(action_id, variables) # 파일 폴백
|
||||
|
||||
async def _fetch_card_script_db(self, card_id: str) -> Optional[str]:
|
||||
async def _q(s):
|
||||
_, text = await self._card_repo.get_script_by_number(s, card_id)
|
||||
return text
|
||||
|
||||
try:
|
||||
return await DB_SESSION_MNG.execute_lambda(DBType.MAIN.value, DBWRType.DB_READ.value, _q)
|
||||
except Exception as ex: # DB 불가 등 — 파일 폴백으로 협상은 계속되어야 함
|
||||
LOG.e_no_callstack(f"[ScriptRepository] 카드 멘트 DB 조회 실패 card_id={card_id}: {ex}")
|
||||
return None
|
||||
|
||||
def client_step_mapping(self) -> dict:
|
||||
return self._load_json("client_step_mapping.json")
|
||||
|
||||
|
||||
@ -15,11 +15,12 @@ class Req_Chat(Req_WebPacketProtocol):
|
||||
negotiation.sessions(qt_type·target_price·anchoring_price), partner.items(price),
|
||||
partner.suppliers(total_revenue), quotation.quotations(supplier_type), 상품별 협력사 수.
|
||||
행이 없으면(데모/테스트 직접 호출) 기본값 폴백.
|
||||
가격 수용률은 세션 내 라운드별 제시가로 매 턴 동적 계산: max(0, (첫 제시가−현재가)/첫 제시가).
|
||||
가격 수용률은 세션 내 라운드별 제시가로 매 턴 동적 계산: max(0, (기존 공급가−현재가)/기존 공급가)
|
||||
— 첫 제시가부터 기존 공급가 대비 인하가 반영되므로 첫 라운드도 실값. 기존 공급가 없으면 첫 제시가 기준.
|
||||
"""
|
||||
|
||||
session_id: Optional[str] = Field(None, description="없으면 새 세션 생성. 운영 경로는 negotiation.sessions.session_id 를 그대로 사용")
|
||||
user_input: Optional[str] = Field(None, description="버튼 선택 텍스트 또는 가격(price 모드)")
|
||||
user_input: Optional[str] = Field(None, description="가격 정보, 예/아니오, 배송, 담당자 변경, 종료, 기타, 특수 케이스 등")
|
||||
# ① desync 감지: backend 가 보는 직전 봇 step(내부 step 또는 표시 step). 없으면 검사 생략.
|
||||
# agent 는 자기 세션 step 을 정답으로 보고 진행하되, 불일치 시 경고 로깅하고 응답에 desynced 를 실어
|
||||
# backend/front 가 agent 응답의 step/client_step 으로 리싱크하게 한다.
|
||||
|
||||
@ -139,16 +139,20 @@ class ChatService:
|
||||
# ---- 학습 ----------------------------------------------------------
|
||||
@staticmethod
|
||||
def _acceptance_ratio(context: dict) -> float:
|
||||
"""가격 수용률 동적 계산 — 협력사 첫 제시가 대비 현재 제시가의 양보율 (설계서 공식).
|
||||
"""가격 수용률 동적 계산 — 기준가 대비 현재 제시가의 양보율.
|
||||
|
||||
acceptance = max(0, (첫 제시가 − 현재 제시가) / 첫 제시가).
|
||||
첫 제시 라운드(양보 없음)·첫 제시가 미기록(과거 세션 호환)이면 0(low 버킷).
|
||||
기준가 = 기존 공급가(item_price) 우선. 스크립트 구조상 협력사의 첫 제시가부터
|
||||
기존 공급가 대비 인하가 반영되므로(가격협상_확인 멘트의 discount_rate 와 동일 기준)
|
||||
첫 라운드부터 실값이 나온다 — 첫 제시가 기준 0 아님.
|
||||
기존 공급가가 없으면(신규 협상 등) 협력사 첫 제시가 기준 폴백(첫 라운드 0).
|
||||
|
||||
acceptance = max(0, (기준가 − 현재 제시가) / 기준가). 가격 미입력이면 0(low 버킷).
|
||||
"""
|
||||
first = context.get("first_offer_price") or 0
|
||||
base = context.get("item_price") or context.get("first_offer_price") or 0
|
||||
current = context.get("input_price") or 0
|
||||
if first <= 0 or current <= 0:
|
||||
if base <= 0 or current <= 0:
|
||||
return 0.0
|
||||
return max(0.0, (first - current) / first)
|
||||
return max(0.0, (base - current) / base)
|
||||
|
||||
def _snapshot(self, session: ChatSession, outcome: NegotiationOutcome) -> NegotiationSnapshot:
|
||||
c = session.context
|
||||
@ -190,7 +194,8 @@ class ChatService:
|
||||
# 가격협상 턴 연출(선행 chat_server 의 dynamic step type=indicator 재현):
|
||||
# ① 선택된 카드의 스크립트를 봇 메시지(script)로 출력 ② 협상지표 게이지(indicator_value) 동봉.
|
||||
# backend/front 가 indicator/bot_chat_type 패스스루·게이지 렌더 준비 완료 → 값만 채우면 표시된다.
|
||||
card_script = scripts.card_script(decision.action_id, chat_engine.vars_for(session))
|
||||
# 카드 멘트: backoffice_db 모드면 card.nego_cards.script(negodata 편집 정본), 아니면 파일 폴백.
|
||||
card_script = await scripts.resolve_card_script(decision.action_id, card_id, chat_engine.vars_for(session))
|
||||
if card_script:
|
||||
res.script = card_script
|
||||
c = session.context
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"_comment": "가격협상(카드선택) 턴에 출력할 협상 카드 스크립트. action_id(0~8) → 멘트. 선행 chat_server 의 nego_card_scripts 를 대체하는 중립 기본값(CLEANROOM.md). 실제 운영 시 card.nego_cards.script 로 override(내부 소스만 변경, 흐름 동일). 변수: {target}=목표 매입가, {input_price}=직전 제시가, {anchor}=앵커가, {discount_rate}=기존가 대비 인하율(%).",
|
||||
"0": "제안해 주신 {input_price}원, 감사합니다. 다만 동일 품목의 시장 거래가를 감안하면 추가 조정 여력이 있어 보입니다. 한 번 더 검토해 가격을 제안해 주시겠어요?",
|
||||
"1": "적극적으로 협조해 주셔서 감사합니다. 현재 제시가는 목표 매입가({target}원)와는 아직 차이가 있습니다. 조금만 더 좁혀 주시면 우선협상 대상으로 검토하겠습니다.",
|
||||
"0": "제안해 주신 **{input_price}원**, 감사합니다. 다만 동일 품목의 시장 거래가를 감안하면 추가 조정 여력이 있어 보입니다. 한 번 더 검토해 가격을 제안해 주시겠어요?",
|
||||
"1": "적극적으로 협조해 주셔서 감사합니다. 현재 제시가는 목표 매입가(**{target}원**)와는 아직 차이가 있습니다. 조금만 더 좁혀 주시면 우선협상 대상으로 검토하겠습니다.",
|
||||
"2": "좋은 제안 감사합니다. 다른 협력사들의 제안 수준을 고려할 때, 현재 금액으로는 경쟁력이 다소 부족합니다. 재검토된 가격을 부탁드립니다.",
|
||||
"3": "협상에 성실히 임해 주셔서 감사합니다. 내부 승인 기준에 맞추려면 앵커가({anchor}원) 수준에 가까운 제안이 필요합니다. 가능하신 범위에서 다시 제안해 주세요.",
|
||||
"4": "제시해 주신 인하율 약 {discount_rate}%는 의미 있는 진전입니다. 다만 거래를 확정하려면 조금 더 협조가 필요합니다. 한 차례 더 조정해 주시겠어요?",
|
||||
|
||||
@ -73,7 +73,7 @@
|
||||
"chat_end": false
|
||||
},
|
||||
"가격협상_확인": {
|
||||
"script": "제시하신 가격은 {input_price}원으로, 기존 공급가 대비 약 {discount_rate}% 인하된 금액입니다. 이 금액으로 제안하시겠습니까? 수정하시려면 [아니오]를 선택해 주세요.",
|
||||
"script": "제시하신 가격은 **{input_price}원**으로, 기존 공급가 대비 약 **{discount_rate}%** 인하된 금액입니다. 이 금액으로 제안하시겠습니까? 수정하시려면 [아니오]를 선택해 주세요.",
|
||||
"editor_script_id": "가격협상_확인",
|
||||
"next_input_mode": "yes_no",
|
||||
"input_options": ["예", "아니오"],
|
||||
@ -91,7 +91,7 @@
|
||||
"chat_end": false
|
||||
},
|
||||
"가격협상_확인_버짓": {
|
||||
"script": "제시하신 금액은 {input_price}원입니다. 이 금액으로 견적을 제출하시겠습니까? 수정하시려면 [아니오]를 선택해 주세요.",
|
||||
"script": "제시하신 금액은 **{input_price}원**입니다. 이 금액으로 견적을 제출하시겠습니까? 수정하시려면 [아니오]를 선택해 주세요.",
|
||||
"editor_script_id": "가격협상_확인_버짓",
|
||||
"next_input_mode": "yes_no",
|
||||
"input_options": ["예", "아니오"],
|
||||
|
||||
@ -91,7 +91,7 @@
|
||||
"chat_end": false
|
||||
},
|
||||
"가격협상_확인": {
|
||||
"script": "{input_price}원으로 제안하시겠습니까?",
|
||||
"script": "**{input_price}원**으로 제안하시겠습니까?",
|
||||
"editor_script_id": "가격협상_확인",
|
||||
"next_input_mode": "yes_no",
|
||||
"input_options": ["예", "아니오"],
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"_comment": "와일드카드 분기 스크립트(1%인하 / 재원부족). 가격협상_확인의 check_wildcard_entry 조건에서 진입. 중립 재작성(CLEANROOM.md).",
|
||||
"_comment": "와일드카드 분기 스크립트(1%인하 / 재원부족). 가격협상_확인의 check_wildcard_entry 조건에서 진입. 중립 재작성(CLEANROOM.md). **텍스트** 는 경량 강조 마커(프론트가 굵게 렌더 — frontend emphasis.tsx).",
|
||||
"wild_card_1pct": {
|
||||
"script": "제안해 주신 가격에 감사드립니다. 적극적으로 협조해 주신 덕분에 긍정적으로 검토되고 있습니다. 다만 내부 승인을 위해 조금 더 명분이 필요한 상황입니다. 약 1% 미만 추가 인하하여 {offer_1pct}원에 가능하실까요? 수락해 주신다면 즉시 우선협상 대상으로 검토하겠습니다.",
|
||||
"script": "제안해 주신 가격에 감사드립니다. 적극적으로 협조해 주신 덕분에 긍정적으로 검토되고 있습니다. 다만 내부 승인을 위해 조금 더 명분이 필요한 상황입니다. 약 1% 미만 추가 인하하여 **{offer_1pct}원**에 가능하실까요? 수락해 주신다면 즉시 우선협상 대상으로 검토하겠습니다.",
|
||||
"type": "text",
|
||||
"chat_end": false,
|
||||
"next_input_mode": "yes_no",
|
||||
@ -10,7 +10,7 @@
|
||||
"editor_script_id": "wild_card_1pct"
|
||||
},
|
||||
"wild_card_budget": {
|
||||
"script": "솔직히 말씀드리면 현재 내부 예산(재원) 사정상 제안을 그대로 수용하기 어렵습니다. 목표 매입가는 {target}원입니다. 이 가격에 맞춰 주신다면 즉시 계약을 진행하고자 합니다. 마지막으로 한 번 더 제안 부탁드립니다.",
|
||||
"script": "솔직히 말씀드리면 현재 내부 예산(재원) 사정상 제안을 그대로 수용하기 어렵습니다. 목표 매입가는 **{target}원**입니다. 이 가격에 맞춰 주신다면 즉시 계약을 진행하고자 합니다. 마지막으로 한 번 더 제안 부탁드립니다.",
|
||||
"type": "text",
|
||||
"chat_end": false,
|
||||
"next_input_mode": "price",
|
||||
|
||||
@ -64,6 +64,8 @@ action_mapping:
|
||||
"8": "NGC-009"
|
||||
|
||||
cards:
|
||||
# file: scripts_cards.json(파일) 사용. backoffice_db: card.nego_cards.script(negodata 편집 정본)를
|
||||
# 카드코드(action_to_card)로 조회, 없으면 파일 폴백. 테넌트가 자사 카드를 편집하면 backoffice_db 로 전환.
|
||||
source_type: file
|
||||
sync_interval_seconds: 300
|
||||
connection: {}
|
||||
|
||||
@ -47,6 +47,11 @@ _T_SUPPLIERS = table(
|
||||
column("supplier_id"), column("company_id"), column("user_id"), column("name"), column("total_revenue"),
|
||||
schema="partner",
|
||||
)
|
||||
_T_SUPPLIER_ITEMS = table(
|
||||
"supplier_items",
|
||||
column("supplier_id"), column("item_id"), column("supply_type"),
|
||||
schema="partner",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@ -87,11 +92,19 @@ async def test_context_loaded_from_db(db_engine):
|
||||
status=2, end_time=now + timedelta(days=1),
|
||||
))
|
||||
|
||||
def _ins_mapping(s, supplier_id, supply_type):
|
||||
return DB_SESSION_MNG.add(s, insert(_T_SUPPLIER_ITEMS).values(
|
||||
supplier_id=supplier_id, item_id=iid, supply_type=supply_type,
|
||||
))
|
||||
|
||||
err = await DB_SESSION_MNG.execute_lambda_run(
|
||||
[DBType.MAIN.value],
|
||||
[_ins_item, _ins_supplier, _ins_quote,
|
||||
lambda s: _ins_sess(s, sid, sup1),
|
||||
lambda s: _ins_sess(s, sid2, sup2)], # 같은 상품에 공급사 2곳 → MULTIPLE
|
||||
lambda s: _ins_sess(s, sid2, sup2),
|
||||
# 상품↔협력사 매핑(supplier_items): 협상 상대 sup1 은 총판(3), sup2 는 유통(1) → 취급 2곳
|
||||
lambda s: _ins_mapping(s, sup1, 3),
|
||||
lambda s: _ins_mapping(s, sup2, 1)],
|
||||
)
|
||||
assert err == ErrorType.SUCCESS
|
||||
|
||||
@ -109,14 +122,26 @@ async def test_context_loaded_from_db(db_engine):
|
||||
assert c["anchor_price"] == 19000 # sessions.anchoring_price(박제)
|
||||
assert c["item_price"] == 5000
|
||||
assert c["revenue_amount"] == 55_000_000.0 # 매출액 = suppliers.total_revenue
|
||||
assert c["distribution_code"] == "A" # supplier_type=2(제조) → A
|
||||
assert c["partner_count"] == 2 # 공급사 2곳 → MULTIPLE
|
||||
assert c["distribution_code"] == "B" # supplier_items.supply_type=3(총판) → B (매핑 우선)
|
||||
assert c["partner_count"] == 2 # 매핑 기준 취급 협력사 2곳 → MULTIPLE
|
||||
|
||||
# 매핑 삭제 후 새 세션(sid2) → 폴백 경로: 유통코드=quotations.supplier_type, 파트너=세션 이력
|
||||
err = await DB_SESSION_MNG.execute_lambda_run(
|
||||
[DBType.MAIN.value],
|
||||
[lambda s: DB_SESSION_MNG.add(s, delete(_T_SUPPLIER_ITEMS).where(_T_SUPPLIER_ITEMS.c.item_id == iid))],
|
||||
)
|
||||
assert err == ErrorType.SUCCESS
|
||||
await ChatService().chat(eng, Req_Chat(session_id=str(sid2)))
|
||||
c2 = (await ChatSessionRepository(eng.company_id).get(str(sid2))).context
|
||||
assert c2["distribution_code"] == "A" # 폴백: quotations.supplier_type=2(제조) → A
|
||||
assert c2["partner_count"] == 2 # 폴백: 세션 이력 distinct supplier 2곳
|
||||
finally:
|
||||
await DB_SESSION_MNG.execute_lambda_run(
|
||||
[DBType.MAIN.value],
|
||||
[lambda s: DB_SESSION_MNG.add(s, delete(_T_SESSIONS).where(_T_SESSIONS.c.quotation_id == qid)),
|
||||
lambda s: DB_SESSION_MNG.add(s, delete(_T_QUOTATIONS).where(_T_QUOTATIONS.c.qt_id == qid)),
|
||||
lambda s: DB_SESSION_MNG.add(s, delete(_T_ITEMS).where(_T_ITEMS.c.item_id == iid)),
|
||||
lambda s: DB_SESSION_MNG.add(s, delete(_T_SUPPLIER_ITEMS).where(_T_SUPPLIER_ITEMS.c.item_id == iid)),
|
||||
lambda s: DB_SESSION_MNG.add(s, delete(_T_SUPPLIERS).where(_T_SUPPLIERS.c.supplier_id == sup1))],
|
||||
)
|
||||
|
||||
@ -171,11 +196,17 @@ async def test_loader_with_crud_double(db_engine):
|
||||
async def get_supplier_total_revenue(self, cdb, supplier_id):
|
||||
return ErrorType.SUCCESS, 12_000_000.0
|
||||
|
||||
async def get_supply_type(self, cdb, supplier_id, item_id):
|
||||
return ErrorType.SUCCESS, None # 매핑 없음 → 견적 기록 폴백
|
||||
|
||||
async def get_quotation_supplier_type(self, cdb, quotation_id):
|
||||
return ErrorType.SUCCESS, 3 # sole_agency(총판) → "B"
|
||||
|
||||
async def count_item_suppliers(self, cdb, item_id):
|
||||
return ErrorType.SUCCESS, 0 # 연결 협력사 없음 → NONE
|
||||
return ErrorType.SUCCESS, 0 # 매핑 없음 → 세션 이력 폴백
|
||||
|
||||
async def count_item_session_suppliers(self, cdb, item_id):
|
||||
return ErrorType.SUCCESS, 0 # 이력도 없음 → NONE
|
||||
|
||||
ctx = await NegotiationContextLoader(crud=_FakeCRUD()).load(str(uuid.uuid4()))
|
||||
assert ctx is not None
|
||||
|
||||
@ -109,13 +109,17 @@ def test_partner_type_enum_mapping():
|
||||
|
||||
|
||||
def test_acceptance_ratio_dynamic_calc():
|
||||
"""가격 수용률 동적 계산 — 첫 제시가 대비 양보율. 첫 제시/미기록=0, 인상 시 0 클립."""
|
||||
"""가격 수용률 동적 계산 — 기존 공급가(item_price) 기준 양보율. 첫 라운드부터 실값."""
|
||||
calc = ChatService._acceptance_ratio
|
||||
assert calc({}) == 0.0 # 가격 입력 전
|
||||
assert calc({"first_offer_price": 11000, "input_price": 11000}) == 0.0 # 첫 제시(양보 0)
|
||||
assert calc({"first_offer_price": 11000, "input_price": 10200}) == pytest.approx(800 / 11000)
|
||||
assert calc({"first_offer_price": 10000, "input_price": 12000}) == 0.0 # 인상(비정상) → 0
|
||||
assert calc({"input_price": 9800}) == 0.0 # 과거 세션 호환(첫 제시가 미기록)
|
||||
# 기존 공급가 12000 기준: 첫 제시 11000 도 첫 라운드부터 양보율 실값 (스크립트 discount_rate 와 동일 기준)
|
||||
assert calc({"item_price": 12000, "input_price": 11000}) == pytest.approx(1000 / 12000)
|
||||
assert calc({"item_price": 12000, "first_offer_price": 11000, "input_price": 10200}) == pytest.approx(1800 / 12000)
|
||||
assert calc({"item_price": 10000, "input_price": 12000}) == 0.0 # 기존가보다 인상(비정상) → 0
|
||||
# 기존 공급가 없음(신규 협상) → 첫 제시가 기준 폴백: 첫 제시 0, 이후 양보율
|
||||
assert calc({"item_price": 0, "first_offer_price": 11000, "input_price": 11000}) == 0.0
|
||||
assert calc({"item_price": 0, "first_offer_price": 11000, "input_price": 10200}) == pytest.approx(800 / 11000)
|
||||
assert calc({"input_price": 9800}) == 0.0 # 기준가 전무(과거 세션 호환)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@ -87,3 +87,59 @@ def test_client_step_and_variable_mapping_load():
|
||||
assert csm["가격협상_확인"] == "가격협상"
|
||||
vm = repo.variable_mapping()
|
||||
assert vm["인터넷 최저가"] == "internet_min_price"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_card_script_file_mode_default():
|
||||
"""기본(source_type='file'): resolve_card_script 가 파일 카드 멘트를 반환(하위호환)."""
|
||||
repo = _repo()
|
||||
assert repo._config.cards.source_type == "file"
|
||||
# action 0 파일 멘트가 변수 치환되어 나온다 (DB 무접근)
|
||||
out = await repo.resolve_card_script(0, "NGC-A001", {"input_price": 9800})
|
||||
assert out and "9800" in out
|
||||
|
||||
|
||||
class _FakeCardRepo:
|
||||
"""ICardScriptRepository 더블 — DB 없이 카드코드→멘트 매핑만 흉내(세션 인자 무시)."""
|
||||
|
||||
def __init__(self, by_number: dict):
|
||||
self._by = by_number
|
||||
|
||||
async def get_script_by_number(self, cdb, number):
|
||||
from common.enums import ErrorType
|
||||
return ErrorType.SUCCESS, self._by.get(number)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_card_script_db_mode_prefers_db(monkeypatch):
|
||||
"""source_type='backoffice_db': card.nego_cards.script(정본)를 파일보다 우선 사용 + 마커 보존."""
|
||||
cfg = TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0).load("ktcommerce")
|
||||
cfg.cards.source_type = "backoffice_db"
|
||||
fake = _FakeCardRepo({"NGC-A001": "DB 편집 멘트 **{input_price}원** 검토 중입니다."})
|
||||
repo = ScriptRepository(cfg, _TENANTS_DIR, card_repo=fake)
|
||||
|
||||
# execute_lambda 를 세션 없이 콜백만 실행하도록 대체(순수 단위검증)
|
||||
async def _fake_lambda(_db, _wr, func):
|
||||
return await func(None)
|
||||
from negotiation.chat.service import script_repository as _sr
|
||||
monkeypatch.setattr(_sr.DB_SESSION_MNG, "execute_lambda", _fake_lambda)
|
||||
|
||||
out = await repo.resolve_card_script(0, "NGC-A001", {"input_price": 9800})
|
||||
assert out == "DB 편집 멘트 **9800원** 검토 중입니다." # DB 우선 + 마커(**) 불투명 보존 + 변수 치환
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_card_script_db_mode_falls_back_to_file(monkeypatch):
|
||||
"""DB 에 해당 카드 멘트가 없으면 파일(scripts_cards.json)로 폴백."""
|
||||
cfg = TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0).load("ktcommerce")
|
||||
cfg.cards.source_type = "backoffice_db"
|
||||
fake = _FakeCardRepo({}) # DB 미보유
|
||||
repo = ScriptRepository(cfg, _TENANTS_DIR, card_repo=fake)
|
||||
|
||||
async def _fake_lambda(_db, _wr, func):
|
||||
return await func(None)
|
||||
from negotiation.chat.service import script_repository as _sr
|
||||
monkeypatch.setattr(_sr.DB_SESSION_MNG, "execute_lambda", _fake_lambda)
|
||||
|
||||
out = await repo.resolve_card_script(0, "NGC-A001", {"input_price": 9800})
|
||||
assert out and "9800" in out # 파일 폴백 멘트
|
||||
|
||||
@ -4,6 +4,7 @@ import { useChatStore } from '@/features/chat/stores/useChatStore'
|
||||
import { useChatInitStore } from '@/features/chat/stores/useChatInitStore'
|
||||
import { SessionStatus } from '@/apis/negotiation/negotiation.type'
|
||||
import type { ChatMessage as ChatMessageType } from '@/features/chat/types'
|
||||
import { renderEmphasis } from '@/features/chat/lib/emphasis'
|
||||
import { Indicator } from '@/features/chat/components/templates/Indicator'
|
||||
import { Summary } from '@/features/chat/components/templates/Summary'
|
||||
import { BidSummary } from '@/features/chat/components/templates/BidSummary'
|
||||
@ -99,7 +100,8 @@ const BotMessage = memo(function BotMessage({ message, isFirst }: { message: Cha
|
||||
return (
|
||||
<div className="mb-[56px]">
|
||||
<div className={cn('flex flex-col', !isFirst && 'pt-[36px]')}>
|
||||
<div className="body-1-read-r">{message.script || ''}</div>
|
||||
{/* 봇 스크립트의 `**굵게**` 경량 마크업 해석 (표현 규칙은 프론트 소유 — emphasis.tsx) */}
|
||||
<div className="body-1-read-r">{renderEmphasis(message.script || '')}</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4 w-full mt-[32px]">
|
||||
|
||||
88
frontend/src/features/chat/lib/emphasis.tsx
Normal file
88
frontend/src/features/chat/lib/emphasis.tsx
Normal file
@ -0,0 +1,88 @@
|
||||
import { Fragment, type ReactNode } from 'react'
|
||||
|
||||
/**
|
||||
* 봇 스크립트 경량 마크업(마크다운 서브셋) 렌더러.
|
||||
*
|
||||
* 원칙: agent 스크립트는 의미(텍스트)만 소유하고 표현(굵기·밑줄·색)은 프론트가 결정한다.
|
||||
* 지원 문법(중첩 가능):
|
||||
* **굵게** → <strong>
|
||||
* __밑줄__ → <u>
|
||||
* {{강조|빨강}} → 시맨틱 색 토큰(강조=negative, 안내=info). 검정은 기본이라 마커 없음.
|
||||
*
|
||||
* 색 토큰명은 시맨틱(강조/안내)이며 실제 색값은 이 프론트의 디자인 토큰(index.css)이 결정한다
|
||||
* — hex 를 스크립트에 싣지 않으므로 다크모드·리브랜딩에 안전(선행 Chat_server 의 리치텍스트
|
||||
* JSON 이중 관리를 채택하지 않는다). innerHTML 없이 React 노드로 분해해 XSS 안전.
|
||||
* 짝이 맞지 않는 마커는 해석하지 않고 평문으로 출력한다. 봇 메시지에만 적용.
|
||||
*/
|
||||
|
||||
// 시맨틱 색 토큰 → 이 프론트의 Tailwind 색 클래스(index.css --color-negative/--color-info).
|
||||
const COLOR_CLASS: Record<string, string> = {
|
||||
강조: 'text-negative',
|
||||
안내: 'text-info',
|
||||
}
|
||||
|
||||
export function renderEmphasis(text: string): ReactNode {
|
||||
if (!text || !/\*\*|__|\{\{/.test(text)) return text
|
||||
return <Fragment>{parseInline(text, 'e')}</Fragment>
|
||||
}
|
||||
|
||||
// 각 레벨에서 가장 먼저 등장하는 마커를 찾아 감싸고, 안쪽은 재귀 파싱한다(중첩 허용).
|
||||
function parseInline(text: string, keyPrefix: string): ReactNode[] {
|
||||
const nodes: ReactNode[] = []
|
||||
let pos = 0
|
||||
let seq = 0
|
||||
while (pos < text.length) {
|
||||
const hit = nextMarker(text, pos)
|
||||
if (!hit) {
|
||||
nodes.push(text.slice(pos))
|
||||
break
|
||||
}
|
||||
if (hit.start > pos) nodes.push(text.slice(pos, hit.start))
|
||||
const key = `${keyPrefix}-${seq++}`
|
||||
const inner = parseInline(hit.inner, key)
|
||||
nodes.push(wrap(hit.kind, hit.token, inner, key))
|
||||
pos = hit.end
|
||||
}
|
||||
return nodes
|
||||
}
|
||||
|
||||
type Hit = { kind: 'bold' | 'underline' | 'color'; start: number; end: number; inner: string; token?: string }
|
||||
|
||||
// pos 이후 가장 이른 마커 1건. 닫힘이 없으면 무시(평문 처리).
|
||||
function nextMarker(text: string, pos: number): Hit | null {
|
||||
let best: Hit | null = null
|
||||
const consider = (h: Hit | null) => {
|
||||
if (h && (!best || h.start < best.start)) best = h
|
||||
}
|
||||
consider(matchDelim(text, pos, '**', 'bold'))
|
||||
consider(matchDelim(text, pos, '__', 'underline'))
|
||||
consider(matchColor(text, pos))
|
||||
return best
|
||||
}
|
||||
|
||||
function matchDelim(text: string, pos: number, delim: '**' | '__', kind: 'bold' | 'underline'): Hit | null {
|
||||
const open = text.indexOf(delim, pos)
|
||||
if (open < 0) return null
|
||||
const close = text.indexOf(delim, open + delim.length)
|
||||
if (close < 0) return null
|
||||
return { kind, start: open, end: close + delim.length, inner: text.slice(open + delim.length, close) }
|
||||
}
|
||||
|
||||
// {{토큰|내용}} — 내용에 }} 는 등장하지 않는다는 전제(협상 멘트: 한글·금액).
|
||||
function matchColor(text: string, pos: number): Hit | null {
|
||||
const open = text.indexOf('{{', pos)
|
||||
if (open < 0) return null
|
||||
const bar = text.indexOf('|', open + 2)
|
||||
if (bar < 0) return null
|
||||
const close = text.indexOf('}}', bar + 1)
|
||||
if (close < 0) return null
|
||||
const token = text.slice(open + 2, bar).trim()
|
||||
if (!(token in COLOR_CLASS)) return null // 미등록 토큰은 마커로 취급하지 않음
|
||||
return { kind: 'color', start: open, end: close + 2, inner: text.slice(bar + 1, close), token }
|
||||
}
|
||||
|
||||
function wrap(kind: Hit['kind'], token: string | undefined, inner: ReactNode[], key: string): ReactNode {
|
||||
if (kind === 'bold') return <strong key={key} className="font-semibold">{inner}</strong>
|
||||
if (kind === 'underline') return <u key={key}>{inner}</u>
|
||||
return <span key={key} className={token ? COLOR_CLASS[token] : undefined}>{inner}</span>
|
||||
}
|
||||
@ -1,5 +1,6 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { DataTable } from '@/components/ui/data-table';
|
||||
import { renderEmphasis } from '@/lib/emphasis';
|
||||
import type { NegotiationCard } from '../types';
|
||||
|
||||
type CardTableProps = {
|
||||
@ -59,7 +60,8 @@ export function CardTable({ data, onEdit, footer }: CardTableProps) {
|
||||
mobileBlock: true, // 긴 미리보기 블록 → 모바일 카드뷰에서 라벨 아래 풀폭
|
||||
cell: (card) => (
|
||||
<div className="max-w-[22rem] truncate bg-muted/20 px-2 py-1 rounded border border-border/30 text-[11px] leading-snug">
|
||||
{card.scriptPreview}
|
||||
{/* 마커(**굵게**·{{색}})를 스타일로 렌더 — 표 미리보기에 원시 마커가 보이지 않게 */}
|
||||
{renderEmphasis(card.scriptPreview)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
export { CardScriptEditor } from './CardScriptEditor';
|
||||
// deserialize() 는 항상 새 배열을 반환 → Slate initialValue 로 안전. 공용 mutable 상수는 export 하지 않는다.
|
||||
export { deserialize, serializeToText } from './slate';
|
||||
export { deserialize, serializeToText, serializeToMarker } from './slate';
|
||||
export { CARD_VARIABLES } from './variables';
|
||||
|
||||
@ -96,6 +96,38 @@ function serializeNode(node: Descendant): string {
|
||||
return node.text;
|
||||
}
|
||||
|
||||
// ── 직렬화: Slate 값 → 마커 문자열(script) ──────────────────
|
||||
// bold/underline/color 마크를 경량 마커로 인코딩해 저장한다(edit_script=Slate 원본은 재편집용).
|
||||
// agent 는 이 문자열을 불투명 텍스트로 전달하고, 각 프론트 emphasis.tsx 가 마커를 렌더한다.
|
||||
// 고정 팔레트(검정/빨강/파랑) → 시맨틱 토큰. 검정(#151515)은 기본이라 마커 없음.
|
||||
const COLOR_TO_TOKEN: Record<string, string> = {
|
||||
'#ED2024': '강조', // 빨강
|
||||
'#4880EF': '안내', // 파랑
|
||||
};
|
||||
|
||||
export function serializeToMarker(nodes: Descendant[]): string {
|
||||
return nodes.map(serializeMarkerNode).join('\n');
|
||||
}
|
||||
|
||||
function serializeMarkerNode(node: Descendant): string {
|
||||
if (SlateElement.isElement(node)) {
|
||||
if (node.type === 'variable') return applyMarks(`{${node.name}}`, node.children[0]);
|
||||
return node.children.map(serializeMarkerNode).join('');
|
||||
}
|
||||
return applyMarks(node.text, node);
|
||||
}
|
||||
|
||||
// 텍스트/변수 리프의 마크를 마커로 감싼다. 중첩 순서: 색(바깥) → 굵게 → 밑줄(안). 빈 문자열은 그대로.
|
||||
function applyMarks(text: string, leaf?: CustomText): string {
|
||||
if (!text || !leaf) return text;
|
||||
let s = text;
|
||||
if (leaf.underline) s = `__${s}__`;
|
||||
if (leaf.bold) s = `**${s}**`;
|
||||
const token = leaf.color ? COLOR_TO_TOKEN[leaf.color] : undefined;
|
||||
if (token) s = `{{${token}|${s}}}`;
|
||||
return s;
|
||||
}
|
||||
|
||||
// ── 역직렬화: edit_script(JSON) 또는 평문(script) → Slate 값 ──
|
||||
// 저장된 edit_script 가 있으면 그대로, 없으면(레거시 평문 카드) {name} 토큰을 변수 노드로 복원.
|
||||
// Slate 는 initialValue 를 레퍼런스로 직접 변경하므로 항상 새 배열을 돌려준다
|
||||
|
||||
@ -11,7 +11,7 @@ import type { ReqCreateCard } from '@/api/generated/model/reqCreateCard';
|
||||
import type { ResCard } from '@/api/generated/model/resCard';
|
||||
import type { NegotiationCard } from '@/types';
|
||||
import { mapCardData, toCardStatusCode } from '../types';
|
||||
import { serializeToText } from '../editor';
|
||||
import { serializeToMarker } from '../editor';
|
||||
|
||||
// 카드 폼이 넘기는 입력값(편집/생성 공통).
|
||||
// 스크립트는 Slate JSON(editorScript)을 정본으로 받고, 평문 script 는 저장 시 직렬화로 파생한다.
|
||||
@ -40,8 +40,8 @@ function toReq(input: CardInput): ReqCreateCard {
|
||||
usage_type: input.usageType,
|
||||
name: input.title,
|
||||
number: input.code,
|
||||
script: serializeToText(input.editorScript), // 평문 미리보기({변수} 토큰 포함)
|
||||
edit_script: input.editorScript, // Slate JSON 원본
|
||||
script: serializeToMarker(input.editorScript), // 마커 문자열(굵게·색 인코딩 + {변수} 토큰)
|
||||
edit_script: input.editorScript, // Slate JSON 원본(재편집 전용)
|
||||
status: toCardStatusCode(input.status),
|
||||
condition: input.isWildcard ? input.triggerCondition : undefined,
|
||||
memo: input.isWildcard ? input.memo : undefined,
|
||||
|
||||
@ -8,6 +8,7 @@ import { Typography } from '@/components/ui/typography';
|
||||
import { StatusPill, sessionStatusTone } from './StatusPill';
|
||||
import { type Product, type Partner, sessionStatusLabel } from '../../types';
|
||||
import { maskPrices } from '@/lib/utils';
|
||||
import { renderEmphasis } from '@/lib/emphasis';
|
||||
|
||||
export function ChatTab({
|
||||
serverSessions,
|
||||
@ -158,7 +159,7 @@ function BotBubble({
|
||||
)}
|
||||
{m.script && (
|
||||
<Typography as="p" variant="small" className="text-xs whitespace-pre-line leading-relaxed text-inherit">
|
||||
{maskPrices(m.script)}
|
||||
{renderEmphasis(maskPrices(m.script))}
|
||||
</Typography>
|
||||
)}
|
||||
<UsedCardBox
|
||||
|
||||
79
negodata/front/src/lib/emphasis.tsx
Normal file
79
negodata/front/src/lib/emphasis.tsx
Normal file
@ -0,0 +1,79 @@
|
||||
import { Fragment, type ReactNode } from 'react';
|
||||
|
||||
/**
|
||||
* 봇 스크립트 경량 마크업(마크다운 서브셋) 렌더러 — 공급사 프론트(frontend emphasis.tsx)와 동일 규칙.
|
||||
*
|
||||
* agent 스크립트는 의미(텍스트)만 소유하고 표현(굵기·밑줄·색)은 각 프론트가 결정한다.
|
||||
* 지원 문법(중첩 가능): **굵게** __밑줄__ {{강조|빨강}} {{안내|파랑}} (검정은 기본, 마커 없음).
|
||||
* 색 토큰명은 시맨틱이며 실제 색값은 이 프론트의 디자인 토큰(tokens.css)이 결정 — 다크모드 안전.
|
||||
* innerHTML 없이 React 노드로 분해(XSS 안전). 짝이 안 맞는 마커는 평문으로 출력.
|
||||
*/
|
||||
|
||||
// 시맨틱 색 토큰 → 이 프론트의 Tailwind 색 클래스(tokens.css --color-destructive/--color-info).
|
||||
const COLOR_CLASS: Record<string, string> = {
|
||||
강조: 'text-destructive',
|
||||
안내: 'text-info',
|
||||
};
|
||||
|
||||
export function renderEmphasis(text: string): ReactNode {
|
||||
if (!text || !/\*\*|__|\{\{/.test(text)) return text;
|
||||
return <Fragment>{parseInline(text, 'e')}</Fragment>;
|
||||
}
|
||||
|
||||
function parseInline(text: string, keyPrefix: string): ReactNode[] {
|
||||
const nodes: ReactNode[] = [];
|
||||
let pos = 0;
|
||||
let seq = 0;
|
||||
while (pos < text.length) {
|
||||
const hit = nextMarker(text, pos);
|
||||
if (!hit) {
|
||||
nodes.push(text.slice(pos));
|
||||
break;
|
||||
}
|
||||
if (hit.start > pos) nodes.push(text.slice(pos, hit.start));
|
||||
const key = `${keyPrefix}-${seq++}`;
|
||||
const inner = parseInline(hit.inner, key);
|
||||
nodes.push(wrap(hit.kind, hit.token, inner, key));
|
||||
pos = hit.end;
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
|
||||
type Hit = { kind: 'bold' | 'underline' | 'color'; start: number; end: number; inner: string; token?: string };
|
||||
|
||||
function nextMarker(text: string, pos: number): Hit | null {
|
||||
let best: Hit | null = null;
|
||||
const consider = (h: Hit | null) => {
|
||||
if (h && (!best || h.start < best.start)) best = h;
|
||||
};
|
||||
consider(matchDelim(text, pos, '**', 'bold'));
|
||||
consider(matchDelim(text, pos, '__', 'underline'));
|
||||
consider(matchColor(text, pos));
|
||||
return best;
|
||||
}
|
||||
|
||||
function matchDelim(text: string, pos: number, delim: '**' | '__', kind: 'bold' | 'underline'): Hit | null {
|
||||
const open = text.indexOf(delim, pos);
|
||||
if (open < 0) return null;
|
||||
const close = text.indexOf(delim, open + delim.length);
|
||||
if (close < 0) return null;
|
||||
return { kind, start: open, end: close + delim.length, inner: text.slice(open + delim.length, close) };
|
||||
}
|
||||
|
||||
function matchColor(text: string, pos: number): Hit | null {
|
||||
const open = text.indexOf('{{', pos);
|
||||
if (open < 0) return null;
|
||||
const bar = text.indexOf('|', open + 2);
|
||||
if (bar < 0) return null;
|
||||
const close = text.indexOf('}}', bar + 1);
|
||||
if (close < 0) return null;
|
||||
const token = text.slice(open + 2, bar).trim();
|
||||
if (!(token in COLOR_CLASS)) return null;
|
||||
return { kind: 'color', start: open, end: close + 2, inner: text.slice(bar + 1, close), token };
|
||||
}
|
||||
|
||||
function wrap(kind: Hit['kind'], token: string | undefined, inner: ReactNode[], key: string): ReactNode {
|
||||
if (kind === 'bold') return <strong key={key} className="font-semibold">{inner}</strong>;
|
||||
if (kind === 'underline') return <u key={key}>{inner}</u>;
|
||||
return <span key={key} className={token ? COLOR_CLASS[token] : undefined}>{inner}</span>;
|
||||
}
|
||||
@ -31,6 +31,7 @@
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--color-success: var(--success);
|
||||
--color-warning: var(--warning);
|
||||
--color-info: var(--info);
|
||||
--color-ring: var(--ring);
|
||||
|
||||
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
|
||||
@ -55,6 +56,7 @@
|
||||
--destructive-foreground: #fafafa;
|
||||
--success: #10b981;
|
||||
--warning: #f59e0b;
|
||||
--info: #4880ef;
|
||||
--ring: #a1a1a1;
|
||||
--popover: #ffffff;
|
||||
--popover-foreground: #0a0a0a;
|
||||
@ -94,6 +96,7 @@
|
||||
--destructive-foreground: #fafafa;
|
||||
--success: #10b981;
|
||||
--warning: #f59e0b;
|
||||
--info: #6ea8ff;
|
||||
--ring: #737373;
|
||||
--popover: #171717;
|
||||
--popover-foreground: #fafafa;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user