diff --git a/agent/negotiation/cards/adapters/card_script_db.py b/agent/negotiation/cards/adapters/card_script_db.py new file mode 100644 index 0000000..761599d --- /dev/null +++ b/agent/negotiation/cards/adapters/card_script_db.py @@ -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 diff --git a/agent/negotiation/cards/ports/card_script_port.py b/agent/negotiation/cards/ports/card_script_port.py new file mode 100644 index 0000000..28cf4a9 --- /dev/null +++ b/agent/negotiation/cards/ports/card_script_port.py @@ -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.""" + ... diff --git a/agent/negotiation/chat/infra/repository/nego_context_crud.py b/agent/negotiation/chat/infra/repository/nego_context_crud.py index 39402f8..24e5d81 100644 --- a/agent/negotiation/chat/infra/repository/nego_context_crud.py +++ b/agent/negotiation/chat/infra/repository/nego_context_crud.py @@ -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 diff --git a/agent/negotiation/chat/service/negotiation_context_loader.py b/agent/negotiation/chat/service/negotiation_context_loader.py index ccb6177..0bc7bb4 100644 --- a/agent/negotiation/chat/service/negotiation_context_loader.py +++ b/agent/negotiation/chat/service/negotiation_context_loader.py @@ -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 diff --git a/agent/negotiation/chat/service/script_repository.py b/agent/negotiation/chat/service/script_repository.py index 8205ea4..61a1d5d 100644 --- a/agent/negotiation/chat/service/script_repository.py +++ b/agent/negotiation/chat/service/script_repository.py @@ -4,25 +4,39 @@ - 탐색 순서: tenants//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 를 치환해서 돌려준다.""" diff --git a/agent/router/v1/chat/protocol.py b/agent/router/v1/chat/protocol.py index e8bfaa3..64e4187 100644 --- a/agent/router/v1/chat/protocol.py +++ b/agent/router/v1/chat/protocol.py @@ -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 으로 리싱크하게 한다. diff --git a/agent/services/chat_service.py b/agent/services/chat_service.py index 935edad..88df879 100644 --- a/agent/services/chat_service.py +++ b/agent/services/chat_service.py @@ -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 diff --git a/agent/tenants/_base/resources/scripts_cards.json b/agent/tenants/_base/resources/scripts_cards.json index a49edf4..acdc323 100644 --- a/agent/tenants/_base/resources/scripts_cards.json +++ b/agent/tenants/_base/resources/scripts_cards.json @@ -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}%는 의미 있는 진전입니다. 다만 거래를 확정하려면 조금 더 협조가 필요합니다. 한 차례 더 조정해 주시겠어요?", diff --git a/agent/tenants/_base/resources/scripts_renegotiation.json b/agent/tenants/_base/resources/scripts_renegotiation.json index 8066802..6409cb5 100644 --- a/agent/tenants/_base/resources/scripts_renegotiation.json +++ b/agent/tenants/_base/resources/scripts_renegotiation.json @@ -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": ["예", "아니오"], diff --git a/agent/tenants/_base/resources/scripts_requote.json b/agent/tenants/_base/resources/scripts_requote.json index 2b654aa..678e1b7 100644 --- a/agent/tenants/_base/resources/scripts_requote.json +++ b/agent/tenants/_base/resources/scripts_requote.json @@ -91,7 +91,7 @@ "chat_end": false }, "가격협상_확인": { - "script": "{input_price}원으로 제안하시겠습니까?", + "script": "**{input_price}원**으로 제안하시겠습니까?", "editor_script_id": "가격협상_확인", "next_input_mode": "yes_no", "input_options": ["예", "아니오"], diff --git a/agent/tenants/_base/resources/scripts_wildcard.json b/agent/tenants/_base/resources/scripts_wildcard.json index 82cfb30..fe836a6 100644 --- a/agent/tenants/_base/resources/scripts_wildcard.json +++ b/agent/tenants/_base/resources/scripts_wildcard.json @@ -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", diff --git a/agent/tenants/_base/tenant.yaml b/agent/tenants/_base/tenant.yaml index 615dec4..b34b7d3 100644 --- a/agent/tenants/_base/tenant.yaml +++ b/agent/tenants/_base/tenant.yaml @@ -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: {} diff --git a/agent/tests/test_context_loader.py b/agent/tests/test_context_loader.py index 9d6a3a5..034e597 100644 --- a/agent/tests/test_context_loader.py +++ b/agent/tests/test_context_loader.py @@ -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 diff --git a/agent/tests/test_p7_chat.py b/agent/tests/test_p7_chat.py index 6d991b6..7c15fd3 100644 --- a/agent/tests/test_p7_chat.py +++ b/agent/tests/test_p7_chat.py @@ -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 diff --git a/agent/tests/test_scripts_resources.py b/agent/tests/test_scripts_resources.py index ef5b23c..d977b32 100644 --- a/agent/tests/test_scripts_resources.py +++ b/agent/tests/test_scripts_resources.py @@ -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 # 파일 폴백 멘트 diff --git a/frontend/src/features/chat/components/ChatMessage.tsx b/frontend/src/features/chat/components/ChatMessage.tsx index 2888fab..473d57f 100644 --- a/frontend/src/features/chat/components/ChatMessage.tsx +++ b/frontend/src/features/chat/components/ChatMessage.tsx @@ -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 (
-
{message.script || ''}
+ {/* 봇 스크립트의 `**굵게**` 경량 마크업 해석 (표현 규칙은 프론트 소유 — emphasis.tsx) */} +
{renderEmphasis(message.script || '')}
diff --git a/frontend/src/features/chat/lib/emphasis.tsx b/frontend/src/features/chat/lib/emphasis.tsx new file mode 100644 index 0000000..28ce853 --- /dev/null +++ b/frontend/src/features/chat/lib/emphasis.tsx @@ -0,0 +1,88 @@ +import { Fragment, type ReactNode } from 'react' + +/** + * 봇 스크립트 경량 마크업(마크다운 서브셋) 렌더러. + * + * 원칙: agent 스크립트는 의미(텍스트)만 소유하고 표현(굵기·밑줄·색)은 프론트가 결정한다. + * 지원 문법(중첩 가능): + * **굵게** → + * __밑줄__ → + * {{강조|빨강}} → 시맨틱 색 토큰(강조=negative, 안내=info). 검정은 기본이라 마커 없음. + * + * 색 토큰명은 시맨틱(강조/안내)이며 실제 색값은 이 프론트의 디자인 토큰(index.css)이 결정한다 + * — hex 를 스크립트에 싣지 않으므로 다크모드·리브랜딩에 안전(선행 Chat_server 의 리치텍스트 + * JSON 이중 관리를 채택하지 않는다). innerHTML 없이 React 노드로 분해해 XSS 안전. + * 짝이 맞지 않는 마커는 해석하지 않고 평문으로 출력한다. 봇 메시지에만 적용. + */ + +// 시맨틱 색 토큰 → 이 프론트의 Tailwind 색 클래스(index.css --color-negative/--color-info). +const COLOR_CLASS: Record = { + 강조: 'text-negative', + 안내: 'text-info', +} + +export function renderEmphasis(text: string): ReactNode { + if (!text || !/\*\*|__|\{\{/.test(text)) return text + return {parseInline(text, 'e')} +} + +// 각 레벨에서 가장 먼저 등장하는 마커를 찾아 감싸고, 안쪽은 재귀 파싱한다(중첩 허용). +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 {inner} + if (kind === 'underline') return {inner} + return {inner} +} diff --git a/negodata/front/src/features/cards/components/CardTable.tsx b/negodata/front/src/features/cards/components/CardTable.tsx index eac4ca7..1fa930a 100644 --- a/negodata/front/src/features/cards/components/CardTable.tsx +++ b/negodata/front/src/features/cards/components/CardTable.tsx @@ -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) => (
- {card.scriptPreview} + {/* 마커(**굵게**·{{색}})를 스타일로 렌더 — 표 미리보기에 원시 마커가 보이지 않게 */} + {renderEmphasis(card.scriptPreview)}
), }, diff --git a/negodata/front/src/features/cards/editor/index.ts b/negodata/front/src/features/cards/editor/index.ts index dfcca4a..6e093ef 100644 --- a/negodata/front/src/features/cards/editor/index.ts +++ b/negodata/front/src/features/cards/editor/index.ts @@ -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'; diff --git a/negodata/front/src/features/cards/editor/slate.ts b/negodata/front/src/features/cards/editor/slate.ts index dce2586..b8872da 100644 --- a/negodata/front/src/features/cards/editor/slate.ts +++ b/negodata/front/src/features/cards/editor/slate.ts @@ -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 = { + '#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 를 레퍼런스로 직접 변경하므로 항상 새 배열을 돌려준다 diff --git a/negodata/front/src/features/cards/hooks/useCards.ts b/negodata/front/src/features/cards/hooks/useCards.ts index b53a5a5..97db251 100644 --- a/negodata/front/src/features/cards/hooks/useCards.ts +++ b/negodata/front/src/features/cards/hooks/useCards.ts @@ -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, diff --git a/negodata/front/src/features/quotations/components/QuotationDetailSheet/ChatTab.tsx b/negodata/front/src/features/quotations/components/QuotationDetailSheet/ChatTab.tsx index f05c778..c4795cf 100644 --- a/negodata/front/src/features/quotations/components/QuotationDetailSheet/ChatTab.tsx +++ b/negodata/front/src/features/quotations/components/QuotationDetailSheet/ChatTab.tsx @@ -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 && ( - {maskPrices(m.script)} + {renderEmphasis(maskPrices(m.script))} )} = { + 강조: 'text-destructive', + 안내: 'text-info', +}; + +export function renderEmphasis(text: string): ReactNode { + if (!text || !/\*\*|__|\{\{/.test(text)) return text; + return {parseInline(text, 'e')}; +} + +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 {inner}; + if (kind === 'underline') return {inner}; + return {inner}; +} diff --git a/negodata/front/src/tokens.css b/negodata/front/src/tokens.css index 9d7016f..fef5193 100644 --- a/negodata/front/src/tokens.css +++ b/negodata/front/src/tokens.css @@ -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; diff --git a/postgres-init/temp-data.sql b/postgres-init/init-data/init-data.sql similarity index 100% rename from postgres-init/temp-data.sql rename to postgres-init/init-data/init-data.sql diff --git a/postgres-init/00-init.sql b/postgres-init/init-data/init.sql similarity index 98% rename from postgres-init/00-init.sql rename to postgres-init/init-data/init.sql index 1cee41b..1b130cc 100644 --- a/postgres-init/00-init.sql +++ b/postgres-init/init-data/init.sql @@ -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=종결