Merge commit '8c700b1e080a29a02375664e856bc6e5b757c7c3' into feature/negodata

This commit is contained in:
Mina Choi 2026-07-07 16:19:52 +09:00
commit 9ac8fe6363
26 changed files with 530 additions and 62 deletions

View 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

View 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."""
...

View File

@ -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

View File

@ -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_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

View File

@ -4,25 +4,39 @@
- 탐색 순서: 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).
"""
import json
import os
import re
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"
# `{name}` 단일 토큰만 매칭(색 마커 `{{강조|...}}` 는 내부 `|` 로 인해 비매칭 → 보존).
_VAR_TOKEN_RE = re.compile(r"\{(\w+)\}")
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 +83,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")
@ -89,21 +128,23 @@ class ScriptRepository:
}
def format_script(self, text: str, variables: Optional[Dict[str, Any]] = None) -> str:
"""{company_name}/{service_name} + 협상 변수 치환. 누락 변수는 원형 유지(KeyError 방지)."""
"""{company_name}/{service_name} + 협상 변수 치환. 누락 변수는 원형 유지.
정규식으로 `{name}` 단일 토큰만 치환한다 — str.format_map 은 `{{`·`}}` 를 이스케이프로
해석해 색 마커 `{{강조|...}}` 를 `{강조|...}` 로 붕괴시키므로 쓰지 않는다.
색 마커는 내부에 `|`(비-\\w)가 있어 `\\{(\\w+)\\}` 에 매칭되지 않아 그대로 보존된다.
"""
if not text:
return text
ctx = self._brand_vars()
if variables:
ctx.update({k: v for k, v in variables.items() if v is not None})
class _Safe(dict):
def __missing__(self, key):
return "{" + key + "}"
def _repl(m):
key = m.group(1)
return str(ctx[key]) if key in ctx else m.group(0) # 미등록 토큰은 원형
try:
return text.format_map(_Safe(ctx))
except (ValueError, IndexError):
return text # 형식 토큰 충돌 시 원형
return _VAR_TOKEN_RE.sub(_repl, text)
def get_step(self, step: str, rq_type: str = "재협상", variables: Optional[Dict[str, Any]] = None) -> Optional[dict]:
"""step 정의를 반환하되 script 를 치환해서 돌려준다."""

View File

@ -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 으로 리싱크하게 한다.

View File

@ -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

View File

@ -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}%는 의미 있는 진전입니다. 다만 거래를 확정하려면 조금 더 협조가 필요합니다. 한 차례 더 조정해 주시겠어요?",

View File

@ -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": ["예", "아니오"],

View File

@ -91,7 +91,7 @@
"chat_end": false
},
"가격협상_확인": {
"script": "{input_price}원으로 제안하시겠습니까?",
"script": "**{input_price}원**으로 제안하시겠습니까?",
"editor_script_id": "가격협상_확인",
"next_input_mode": "yes_no",
"input_options": ["예", "아니오"],

View File

@ -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",

View File

@ -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: {}

View File

@ -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

View File

@ -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

View File

@ -87,3 +87,67 @@ def test_client_step_and_variable_mapping_load():
assert csm["가격협상_확인"] == "가격협상"
vm = repo.variable_mapping()
assert vm["인터넷 최저가"] == "internet_min_price"
def test_format_script_preserves_color_markers():
"""변수 치환이 색 마커 {{강조|...}} 를 보존해야 한다(회귀: format_map 이 {{}} 를 {} 로 붕괴시킴)."""
repo = _repo()
out = repo.format_script("**{input_price}원**·{{안내|{target}원}}·{unknown}",
{"input_price": 9800, "target": 10000})
assert out == "**9800원**·{{안내|10000원}}·{unknown}" # 마커 보존 + 변수 치환 + 미등록 원형
@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 # 파일 폴백 멘트

View File

@ -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]">

View 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>
}

View File

@ -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 = {
@ -67,7 +68,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>
),
},

View File

@ -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';

View File

@ -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 를 레퍼런스로 직접 변경하므로 항상 새 배열을 돌려준다

View File

@ -12,7 +12,7 @@ import type { ResCard } from '@/api/generated/model/resCard';
import type { NegotiationCard } from '@/types';
import type { BulkFailure } from '@/lib/excel';
import { mapCardData, toCardStatusCode } from '../types';
import { serializeToText } from '../editor';
import { serializeToMarker } from '../editor';
// 카드 폼이 넘기는 입력값(편집/생성 공통).
// 스크립트는 Slate JSON(editorScript)을 정본으로 받고, 평문 script 는 저장 시 직렬화로 파생한다.
@ -43,8 +43,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,

View File

@ -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

View 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>;
}

View File

@ -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;

View File

@ -657,12 +657,3 @@ ORDER BY company_id, supplier_type, price_range_index, adjustment_id DESC;
-- 새 스키마 변경 시 위 테이블 정의와 이 섹션을 동시에 갱신한다 (구 04-alter*.sql 의 역할).
-- 기준선: 2026-07-07 main 스키마. 그보다 오래된 DB 는 git 이력의 04-alter*.sql 을 먼저 적용.
-- [2026-07-07] 협상 카드: script 길이 제한 해제(TEXT) + 톤·전략 분류 컬럼
ALTER TABLE card.nego_cards ALTER COLUMN script TYPE TEXT;
ALTER TABLE card.wild_cards ALTER COLUMN script TYPE TEXT;
ALTER TABLE card.nego_cards
ADD COLUMN IF NOT EXISTS tone SMALLINT NULL, -- 카드 톤(CardTone): 1=강경, 2=정중, 3=우호, 4=중립, 5=단호
ADD COLUMN IF NOT EXISTS strategy_type SMALLINT NULL; -- 전략 유형(CardStrategyType): 1=경쟁, 2=수용, 3=고수, 4=협력, 5=선점, 6=종결
ALTER TABLE card.wild_cards
ADD COLUMN IF NOT EXISTS tone SMALLINT NULL, -- 카드 톤(CardTone): 1=강경, 2=정중, 3=우호, 4=중립, 5=단호
ADD COLUMN IF NOT EXISTS strategy_type SMALLINT NULL; -- 전략 유형(CardStrategyType): 1=경쟁, 2=수용, 3=고수, 4=협력, 5=선점, 6=종결