[feat] agent: 협상 고도화 — LLM 표현/이해층 + 카드 전술 실행계층 + ktcommerce 정리

카드 재설계("멘트 카드 → 전술 카드"):
- tactics.py 신설: 카드번호→전술(카운터 산식) 레지스트리, min(counter,target) 클램프
- 카운터 수락=즉시 타결(pending_counter_price 일반화, 구 offer_1pct 흡수)
- 목표가 초과 타결 금지(성공스텝 진입 가드) + "카드 소진=실패" 폐지→종결 국면
- 선택형 와일드카드(WC-*) 발동 + card.wild_cards 멘트 DB 어댑터

LLM 계층:
- Phase 2 표현층 ScriptNaturalizer(카드 멘트 자연화, 마커·치환자·숫자 보존 검증)
- Phase 3 이해층 InputInterpreter(자유발화 NLU→기대입력, 한국어 가격 파서)
- OPENAI_API_KEY env override(server_configs) + 전역 자격증명 게이트

결정 스택(Phase 1):
- 협상 규칙 데이터화(negotiation.wildcard_*_ratio/max_counter_rounds)
- 선택카드 우선순위 prior(UCB 방문수 감쇠, Q-table 오염 없음)

버그픽스:
- 인하율 음수 표기 제거 + 인상/동일/인하 구분(discount_phrase)
- 자연화 강조마커 보존(볼드/색 소실 시 원본 폴백)
- 카드 시드 가격변수(prev_partner_price·target_mid_price·middle_price 등) 치환

정리:
- ktcommerce 테넌트 삭제 + 테스트 21파일 imarketkorea/_base 로 마이그레이션
- 실 LLM 호출 차단 conftest 가드

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
hbyang 2026-07-10 13:37:36 +09:00
parent b368a19f79
commit 1682481b01
46 changed files with 2091 additions and 240 deletions

View File

@ -1,6 +1,6 @@
# Negosium Agent
범용 멀티테넌트 협상 솔루션 PoC. 여러 회사(ktcommerce·imarketkorea 등)가 **각자 데이터로 분기 학습**하는
범용 멀티테넌트 협상 솔루션 PoC. 여러 회사(imarketkorea 등)가 **각자 데이터로 분기 학습**하는
협상 카드 선택 에이전트. Q-Learning(UCB) 기반 `Chat_server`(단일 테넌트)를 참고해 신규 구축한다.
PoC 목표 두 가지:
@ -99,10 +99,10 @@ APP_ENV=test python -m pytest # 테스트 (config.local.toml 사
**1) 의사결정 루프 데모** — 테넌트별 config 주입·상태분류·보상·DB 격리를 눈으로 확인:
```bash
APP_ENV=local python -m tools.console_demo --tenant ktcommerce # 기본 3턴 시나리오
APP_ENV=local python -m tools.console_demo --tenant imarketkorea # 기본 3턴 시나리오
APP_ENV=local python -m tools.console_demo --tenant imarketkorea # 다른 테넌트(다른 카드셋·임계값)
APP_ENV=local python -m tools.console_demo --tenant ktcommerce --interactive # 직접 입력
APP_ENV=local python -m tools.console_demo --tenant ktcommerce --no-db # DB 없이
APP_ENV=local python -m tools.console_demo --tenant imarketkorea --interactive # 직접 입력
APP_ENV=local python -m tools.console_demo --tenant imarketkorea --no-db # DB 없이
```
> ⚠️ 카드선택은 임시 placeholder 정책(실제 UCB Q-Table 은 H1/P5). 학습은 아직 일어나지 않는다.
@ -113,13 +113,13 @@ curl localhost:9500/healthz # 200
curl localhost:9500/v1/health # {"status":"ok",...}
curl localhost:9500/v1/foo # 400 TENANT_HEADER_MISSING (헤더 없음)
curl localhost:9500/v1/foo -H 'X-Tenant-ID: nonexistent' # 404 TENANT_NOT_REGISTERED
curl localhost:9500/v1/foo -H 'X-Tenant-ID: ktcommerce' # 통과(라우트 미존재라 404 Not Found)
curl localhost:9500/v1/foo -H 'X-Tenant-ID: imarketkorea' # 통과(라우트 미존재라 404 Not Found)
```
**2-1) 협상 한 라운드 (HTTP 프리뷰)** — `POST /v1/negotiation/step` (Swagger: http://localhost:9500/docs):
```bash
curl -s -X POST localhost:9500/v1/negotiation/step \
-H 'X-Tenant-ID: ktcommerce' -H 'Content-Type: application/json' \
-H 'X-Tenant-ID: imarketkorea' -H 'Content-Type: application/json' \
-d '{"revenue_amount":20000000,"distribution_code":"A","partner_count":1,
"acceptance_ratio":0.11,"input_price":990,"anchor_price":800,"target_price":1000,
"round_number":3,"outcome":"success"}'
@ -156,7 +156,7 @@ APP_ENV=local python -m tools.show_logs # learning.experience_logs 를 co
**가격협상 턴 UCB 카드선택·학습 + 종료보상 역전파**. 브라우저 채팅 UI. (`tests/test_p7_chat.py` 5/5)
- ✅ **H5 학습검증 하네스 (PoC 본체)**: 카드별 효과가 다른 시뮬 구매자(`eval_harness/`) → 정책 비교.
**학습형(qtable_ucb)이 random/static 대비 평균보상 우위(95%CI 분리)·좋은카드 적중 0.9 vs 0.32**
"학습하면 성과가 오른다" 정량 입증. `python -m eval_harness.runner --config configs/exp_default.yaml --tenant ktcommerce`. (`tests/test_h5_*` 5/5)
"학습하면 성과가 오른다" 정량 입증. `python -m eval_harness.runner --config configs/exp_default.yaml --tenant imarketkorea`. (`tests/test_h5_*` 5/5)
- ✅ **P7 14개 API**: chat / q-table(versions·switch·current) / experience-logs / reset-learning ·
reset-all(타테넌트 무영향) / invalidate-session / **train**(오프라인 Q-learning) / verification-report /
card-update · card-search. 전부 X-Tenant-ID 격리. (`tests/test_p7_apis.py` 5/5)
@ -177,7 +177,7 @@ APP_ENV=local python -m tools.show_logs # learning.experience_logs 를 co
### PoC 본체 결과 (H5, 위 경제모델 기준 / target=10000·anchor=8000 시나리오)
```
python -m eval_harness.runner --config configs/exp_default.yaml --tenant ktcommerce
python -m eval_harness.runner --config configs/exp_default.yaml --tenant imarketkorea
policy success settled/tgt turns mean_rwd ±95%CI good_hit
random 0.988 0.904 2.02 1.1176 0.0206 0.350
static 1.000 0.923 2.56 0.9995 0.0020 0.000

View File

@ -40,3 +40,20 @@ def _apply_db_env_override(cfg: MainDBConfig):
_apply_db_env_override(main_db_config)
# LLM 키/설정 env override (DB 와 동일 패턴). 로컬은 config.local.toml [OpenAIConfig] 에 기재,
# Docker/CI/운영은 toml 없이 env 로 주입한다(docker-compose 가 OPENAI_API_KEY passthrough).
# env 미설정 시 no-op → toml 값 그대로.
def _apply_llm_env_override(cfg: OpenAIConfig):
if os.environ.get("OPENAI_API_KEY"):
cfg.api_key = os.environ["OPENAI_API_KEY"]
if os.environ.get("OPENAI_MODEL"):
cfg.model = os.environ["OPENAI_MODEL"]
if os.environ.get("OPENAI_BASE_URL"):
cfg.base_url = os.environ["OPENAI_BASE_URL"]
if os.environ.get("OPENAI_PROVIDER"):
cfg.provider = os.environ["OPENAI_PROVIDER"]
_apply_llm_env_override(openai_config)

View File

@ -4,10 +4,23 @@ import os
os.environ.setdefault("APP_ENV", "local")
import pytest
import pytest_asyncio
from httpx import ASGITransport, AsyncClient
@pytest.fixture(autouse=True)
def _no_real_llm(monkeypatch):
"""실 LLM 호출 차단 — imarketkorea 가 llm.enabled=true 라 로컬에 실키가 있으면
플로우가 자연화/NLU(과금·비결정 응답) 시도한다. LLM 검증 테스트는
available 테스트 안에서 직접 덮어써 가드를 우회한다."""
from negotiation.chat.service.input_interpreter import InputInterpreter
from negotiation.chat.service.script_naturalizer import ScriptNaturalizer
monkeypatch.setattr(ScriptNaturalizer, "available", staticmethod(lambda: False))
monkeypatch.setattr(InputInterpreter, "available", staticmethod(lambda: False))
@pytest_asyncio.fixture(scope="session", autouse=True)
async def _dispose_app_engines():
"""테스트 세션 종료 시 앱 싱글톤 엔진 정리 ('Event loop is closed' 경고 제거)."""

View File

@ -1,4 +1,4 @@
# 알고리즘 비교 실험 기본 설정 (H5). E2E: python -m eval_harness.runner --config configs/exp_default.yaml --tenant ktcommerce
# 알고리즘 비교 실험 기본 설정 (H5). E2E: python -m eval_harness.runner --config configs/exp_default.yaml --tenant imarketkorea
episodes: 600 # 정책당 협상 에피소드 수 (action 11장 탐색 수렴 위해 상향)
seed: 42 # 재현용 (구매자 randomness 페어드)
max_turns: 5 # 협상 라운드 상한

View File

@ -1,6 +1,6 @@
"""eval_harness 러너 — 정책 비교 + 학습곡선 (H5, PoC 본체).
E2E: python -m eval_harness.runner --config configs/exp_default.yaml --tenant ktcommerce
E2E: python -m eval_harness.runner --config configs/exp_default.yaml --tenant imarketkorea
판정: 학습형(qtable_ucb) random/static 대비 평균보상·성공률 우상향이면 "학습 루프 유효".
구매자는 카드별 효과가 다른 시뮬(HeuristicBuyer) 학습 정책만 좋은 카드를 알아내 성과가 오른다.
@ -143,7 +143,7 @@ def _print(report: dict):
def main():
ap = argparse.ArgumentParser(description="협상 정책 비교 하네스 (H5)")
ap.add_argument("--config", default="configs/exp_default.yaml")
ap.add_argument("--tenant", default="ktcommerce")
ap.add_argument("--tenant", default="imarketkorea")
ap.add_argument("--save", action="store_true", help="reports/ 에 JSON 저장")
args = ap.parse_args()

View File

@ -20,6 +20,13 @@ from negotiation.cards.ports.card_script_port import ICardScriptRepository
_NEGO_CARDS = table(
"nego_cards",
column("number"), column("script"), column("tone"), column("strategy_type"),
column("created_at"), column("deleted"),
schema="card",
)
_WILD_CARDS = table(
"wild_cards",
column("number"), column("script"), column("created_at"), column("deleted"),
schema="card",
)
@ -27,17 +34,40 @@ _NEGO_CARDS = table(
class CardScriptDbRepository(ICardScriptRepository):
async def get_script_by_number(self, cdb: AsyncSession, number: str) -> Tuple[ErrorType, Optional[str]]:
err, card = await self.get_card_by_number(cdb, number)
return err, (card[0] if card else None)
async def get_wild_card_by_number(self, cdb: AsyncSession, number: str) -> Tuple[ErrorType, Optional[str]]:
"""card.wild_cards 멘트 조회 (WC-01~05 선택형 와일드카드/종결 전술 발동용)."""
try:
query = (
select(_NEGO_CARDS.c.script)
select(_WILD_CARDS.c.script)
.where(_WILD_CARDS.c.number == number, _WILD_CARDS.c.deleted == False) # noqa: E712
.order_by(desc(_WILD_CARDS.c.created_at))
.limit(1)
)
err_type, rows = await DB_SESSION_MNG.execute(cdb, query, "get_wild_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]) if not isinstance(rows[0], tuple) else str(rows[0][0])
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, None
async def get_card_by_number(self, cdb: AsyncSession, number: str) -> Tuple[ErrorType, Optional[tuple]]:
try:
query = (
select(_NEGO_CARDS.c.script, _NEGO_CARDS.c.tone, _NEGO_CARDS.c.strategy_type)
.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]:
if err_type != ErrorType.SUCCESS or not rows or not rows[0] or not rows[0][0]:
return err_type, None
return ErrorType.SUCCESS, str(rows[0])
script, tone, strategy = rows[0]
return ErrorType.SUCCESS, (str(script), int(tone) if tone is not None else None,
int(strategy) if strategy is not None else None)
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, None

View File

@ -0,0 +1,120 @@
"""협상카드 전술 레지스트리 — "멘트 카드 → 전술 카드" 승격 (가격 행동 실행 계층).
카드 멘트에 이미 설계된 카운터 가격 제시({target_price}·{middle_price} ) 시스템 상태로
실행한다: 카드가 카운터가를 제시하면 pending_counter_price 적재되고, 협력사가 수락하면
가격으로 즉시 타결된다(기존 wild_card_1pct offer_1pct 패턴을 일반화).
원칙:
- 구매자() 대리이므로 카운터는 항상 min(counter, target_price) 클램프 목표가 초과 제시 금지.
- 협력사 제시가가 이미 카운터 이하면 카운터가 무의미 None(HOLD 강등, 순수 설득).
- 미등록 카드번호(테넌트 데모 NGC-B*, 회사 커스텀 COMP-* ) HOLD 폴백 기존 동작 그대로.
전술 정본은 코드 레지스트리다(v1). negodata 카드 편집은 멘트만 담당하고, 전술을 negodata
에서 편집할 필요가 생기면 v2 에서 card.nego_cards 컬럼로 승격해 "DB 우선, 코드 폴백"으로 바꾼다.
"""
from dataclasses import dataclass
from enum import Enum
from typing import Any, Dict, Optional
class PriceAction(str, Enum):
HOLD = "hold" # 카운터 없음 — 재제안 요구(순수 설득, 기존 동작)
COUNTER_TARGET = "counter_target" # 목표가 제시
COUNTER_ANCHOR = "counter_anchor" # 앵커가 제시 (예산 상한 프레이밍)
COUNTER_TARGET_MID = "counter_target_mid" # (anchor+target)/2 — 시드 {target_mid_price}
COUNTER_MID = "counter_mid" # (갑 직전 포지션+협력사 제시가)/2 — 시드 {middle_price}
ONE_PCT = "one_pct" # 제시가 1% 인하 (기존 offer_1pct)
@dataclass(frozen=True)
class TacticSpec:
"""카드 1장의 전술 명세.
min_round: 발동 가능 최소 라운드(협력사 가격 입력 횟수 기준).
max_price_ratio: input_price anchor×ratio 때만 발동 (None=무제한).
closing: 종결 국면(라운드 만료·카드 소진) 우선 전술.
"""
price_action: PriceAction = PriceAction.HOLD
min_round: int = 1
max_price_ratio: Optional[float] = None
closing: bool = False
_DEFAULT = TacticSpec() # HOLD — 미등록 카드 폴백
# 카드번호 → 전술. 시드(init-data.sql) 멘트의 가격 변수와 1:1 정합.
# NGC-001~006: 순수 설득(경쟁 압박/승인 핑계/관계/명분/공정성/TCO) — 가격 변수 없음.
# NGC-008: {internet_lowest_price} 인용이나 데이터 소스 미보유 → v1 HOLD (소스 확보 시 승격).
_TACTICS: Dict[str, TacticSpec] = {
"NGC-007": TacticSpec(PriceAction.COUNTER_ANCHOR), # 예산 상한 안내
"NGC-009": TacticSpec(PriceAction.COUNTER_TARGET), # 조건부 가격 조정
"NGC-010": TacticSpec(PriceAction.COUNTER_TARGET), # 향후 거래 연계
"NGC-011": TacticSpec(PriceAction.COUNTER_TARGET), # 양보 가치 강조
"WC-01": TacticSpec(PriceAction.COUNTER_TARGET, min_round=1), # 목표가 선제안
"WC-02": TacticSpec(PriceAction.COUNTER_TARGET_MID), # 역제안가 제시
"WC-03": TacticSpec(PriceAction.COUNTER_TARGET, closing=True), # 최종 통보(최후통첩)
"WC-04": TacticSpec(PriceAction.COUNTER_TARGET, min_round=2), # 단계적 인하 제안
"WC-05": TacticSpec(PriceAction.COUNTER_MID, closing=True), # 중간값 절충(종결)
}
def tactic_for(card_number: Optional[str]) -> TacticSpec:
"""카드번호의 전술. 미등록/None 은 HOLD(기존 동작)."""
return _TACTICS.get(str(card_number), _DEFAULT) if card_number else _DEFAULT
def tactic_available(spec: TacticSpec, context: Dict[str, Any]) -> bool:
"""발동 조건 평가 — action space 마스킹용. HOLD(설득)는 언제나 가능."""
if spec.price_action is PriceAction.HOLD:
return True
rnd = int(context.get("round") or 0)
if rnd < spec.min_round:
return False
if spec.max_price_ratio is not None:
price = float(context.get("input_price") or 0)
anchor = float(context.get("anchor_price") or 0)
if anchor > 0 and price > anchor * spec.max_price_ratio:
return False
return True
def compute_counter(spec: TacticSpec, context: Dict[str, Any]) -> Optional[int]:
"""전술의 카운터 제시가 계산 (결정론).
- 항상 min(counter, target) 클램프 구매자는 목표가 초과로 제시하지 않는다.
- counter 협력사 제시가(input_price) 카운터가 무의미(이미 싸게 제시받음) None.
- 필요한 컨텍스트(target/anchor/제시가) 없으면 None 호출부가 HOLD 강등.
"""
action = spec.price_action
if action is PriceAction.HOLD:
return None
target = float(context.get("target_price") or 0)
anchor = float(context.get("anchor_price") or 0)
price = float(context.get("input_price") or 0)
if target <= 0 or price <= 0:
return None
if action is PriceAction.COUNTER_TARGET:
counter = target
elif action is PriceAction.COUNTER_ANCHOR:
counter = anchor
elif action is PriceAction.COUNTER_TARGET_MID:
counter = (anchor + target) / 2 if anchor > 0 else target
elif action is PriceAction.COUNTER_MID:
# 갑의 직전 포지션(직전 카운터). 첫 카운터 전에는 앵커가 갑의 포지션이다.
prev_customer = float(context.get("prev_customer_price") or anchor or target)
counter = (prev_customer + price) / 2
elif action is PriceAction.ONE_PCT:
counter = price * 0.99
else:
return None
if counter <= 0:
return None
counter = min(counter, target) # 목표가 초과 제시 금지 (가드레일)
counter_i = int(round(counter))
if counter_i >= price:
return None # 제시가가 이미 카운터 이하 → 카운터 무의미
return counter_i

View File

@ -21,3 +21,18 @@ class ICardScriptRepository(ABC):
async def get_script_by_number(self, cdb: AsyncSession, number: str) -> Tuple[ErrorType, Optional[str]]:
"""카드코드(card.nego_cards.number)로 멘트(script 평문/마커)를 조회. 없으면 None."""
...
async def get_card_by_number(self, cdb: AsyncSession, number: str) -> Tuple[ErrorType, Optional[tuple]]:
"""멘트 + 메타 (script, tone, strategy_type) 조회 — LLM 표현층의 톤/전략 지시용.
기본 구현은 script 조회하고 메타는 None (파일 소스·구형 더블 호환).
"""
err, script = await self.get_script_by_number(cdb, number)
return err, ((script, None, None) if script else None)
async def get_wild_card_by_number(self, cdb: AsyncSession, number: str) -> Tuple[ErrorType, Optional[str]]:
"""와일드카드(card.wild_cards.number) 멘트 조회 — 선택형 WC/종결 전술 발동용.
기본 구현은 미보유(None) DB 어댑터만 실조회한다(더블 호환).
"""
return ErrorType.SUCCESS, None

View File

@ -26,8 +26,8 @@ _SESSIONS = table(
column("deleted"),
schema="negotiation",
)
_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")
_ITEMS = table("items", column("item_id"), column("name"), column("price"), column("deleted"), schema="partner")
_SUPPLIERS = table("suppliers", column("supplier_id"), column("name"), column("total_revenue"), column("deleted"), schema="partner")
_QUOTATIONS = table(
"quotations",
column("qt_id"), column("version_id"), column("supplier_type"), column("deleted"),
@ -77,6 +77,16 @@ class INegoContextCRUD(ABC):
"""협력사 총매출액(suppliers.total_revenue — KTC 미러). 없으면 0.0."""
pass
@abstractmethod
async def get_item_name(self, cdb: AsyncSession, item_id) -> Tuple[ErrorType, Optional[str]]:
"""상품명(items.name). 없으면 None — 카드 스크립트 {product_name} 치환용."""
pass
@abstractmethod
async def get_supplier_name(self, cdb: AsyncSession, supplier_id) -> Tuple[ErrorType, Optional[str]]:
"""협력사명(suppliers.name). 없으면 None — 카드 스크립트 {partner_name} 치환용."""
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=총판).
@ -146,6 +156,36 @@ class NegoContextCRUD(INegoContextCRUD):
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, 0.0
async def get_item_name(self, cdb: AsyncSession, item_id) -> Tuple[ErrorType, Optional[str]]:
try:
query = (
select(_ITEMS.c.name)
.where(_ITEMS.c.item_id == item_id, _ITEMS.c.deleted == False) # noqa: E712
.limit(1)
)
err_type, rows = await DB_SESSION_MNG.execute(cdb, query, "get_item_name 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
async def get_supplier_name(self, cdb: AsyncSession, supplier_id) -> Tuple[ErrorType, Optional[str]]:
try:
query = (
select(_SUPPLIERS.c.name)
.where(_SUPPLIERS.c.supplier_id == supplier_id, _SUPPLIERS.c.deleted == False) # noqa: E712
.limit(1)
)
err_type, rows = await DB_SESSION_MNG.execute(cdb, query, "get_supplier_name 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
async def get_supply_type(self, cdb: AsyncSession, supplier_id, item_id) -> Tuple[ErrorType, Optional[int]]:
try:
query = (

View File

@ -10,9 +10,10 @@ import re
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
from negotiation.cards.domain.tactics import compute_counter, tactic_for
from negotiation.chat.service.script_repository import ScriptRepository
MAX_ROUNDS = 3
MAX_ROUNDS = 3 # config 미주입 시 폴백 (규칙 정본은 tenant config negotiation.max_counter_rounds)
_PRICE_MODES = ("price",)
_CHOICE_MODES = ("yes_no", "confirm", "delivery_type")
@ -22,6 +23,9 @@ _CHOICE_MODES = ("yes_no", "confirm", "delivery_type")
_SUCCESS_STEPS = ("협상완료", "결과제출")
_FAILURE_STEPS = ("협상실패",)
# 카운터 제안(pending_counter_price) 수락으로 인정하는 선택 입력.
_ACCEPT_INPUTS = ("", "수락")
# 프론트는 표시용 문자열로 가격을 보낸다(예: "530,000원"). 천단위 콤마·통화기호("원")·공백 등
# 숫자 외 문자를 제거하고 파싱한다. (콤마만 지우면 "원" 때문에 float() 가 실패해 가격 입력이
# 영영 저장되지 않고 같은 step 에 머무는 버그가 났었다.)
@ -72,6 +76,9 @@ class ChatEngine:
self.rq_type = rq_type
self.scripts = scripts_repo.load_scripts(rq_type)
self.step_map = scripts_repo.client_step_mapping()
# 결정 스택 규칙층(Phase 1): 와일드카드 진입 임계·라운드 상한을 테넌트 config 에서 읽는다.
# (하드코딩 1.02/1.05/3 을 데이터화 — 고객사별로 튜닝 가능, 코드 수정 불필요)
self.rules = scripts_repo.config.negotiation
# ---- public --------------------------------------------------------
def start(self, session: ChatSession) -> StepView:
@ -89,21 +96,27 @@ class ChatEngine:
if price is None:
return self._error(session, "가격을 숫자로 입력해 주세요.")
session.context["input_price"] = price
# 새 가격 제시 = 직전 카운터 제안 거절 확정 → 대기 중 카운터 폐기.
session.context.pop("pending_counter_price", None)
session.context["prev_partner_price"] = price
# 협력사 첫 제시가 — 가격 수용률(첫 제시가 대비 양보율) 동적 계산의 기준값.
session.context.setdefault("first_offer_price", price)
session.context["round"] = session.context.get("round", 0) + 1
nxt = self._default_next(node)
elif mode in _CHOICE_MODES:
# 와일드카드 1% 인하 제안을 수락("예")하면 합의가를 제안가(offer_1pct)로 확정한다.
# (멘트에만 쓰이던 offer_1pct 가 input_price 에 반영되지 않아, 요약/입찰가가
# 직전 제시가로 잡히던 버그 수정 — 수락 시 실제 합의가는 인하가다.)
if session.step == "wild_card_1pct" and user_input == "" and session.context.get("offer_1pct"):
session.context["input_price"] = float(session.context["offer_1pct"])
nxt = self._choice_next(node, user_input, session)
else:
nxt = self._default_next(node)
nxt = self._resolve(nxt, session)
# 카운터 수락 일반 메커니즘: 카드/와일드카드가 제시한 카운터가(pending_counter_price)를
# 협력사가 수락("예"/"수락")한 채 성공 스텝으로 전이하면 합의가 = 카운터가.
# (구 offer_1pct 특수 분기의 일반화. 거절인데 성공 스텝으로 가는 경로 — 1% 거절 시
# 원 제시가 수락 종결 — 는 카운터를 버리고 기존 input_price 로 타결한다.)
if mode in _CHOICE_MODES and nxt in _SUCCESS_STEPS:
pending = session.context.pop("pending_counter_price", None)
if pending and user_input in _ACCEPT_INPUTS:
session.context["input_price"] = float(pending)
return self._render(session, nxt)
# ---- transition ----------------------------------------------------
@ -131,9 +144,9 @@ class ChatEngine:
return nxt
def _eval_conditions(self, conds: List[dict], session: ChatSession) -> Optional[str]:
"""KT 구매자 관점 조건 평가.
"""KT 구매자 관점 조건 평가 (임계값은 config negotiation.* — 규칙층 데이터화).
- 협력사 제시가 anchor 우선협상(협상완료).
- anchor 살짝 초과( anchor*1.05) + 와일드카드 미사용 와일드카드로 인하 압박.
- anchor 살짝 초과( anchor×wildcard_entry_ratio) + 와일드카드 미사용 와일드카드로 인하 압박.
- 설정 카드(action_space) 모두 소진 협상실패.
- 가격협상(카드 1 플레이 재제안).
"""
@ -151,21 +164,31 @@ class ChatEngine:
and anchor > 0
and anchor < price
and (
price <= anchor * 1.02
or (bool(ctx.get("allow_selected_wildcards", True)) and price <= anchor * 1.05)
price <= anchor * self.rules.wildcard_1pct_ratio
or (bool(ctx.get("allow_selected_wildcards", True))
and price <= anchor * self.rules.wildcard_entry_ratio)
)
)
elif cond == "check_is_supplier_type_c":
ok = False # 공급사 유형 미보유 (PoC 단순화)
elif cond == "check_price_match": # = 우선협상: 제시가가 앵커가 이하
ok = anchor > 0 and price <= anchor
elif cond == "check_iteration_limit": # 협상 라운드 상한 또는 카드 소진
# round 는 매 가격입력마다 증가(기존가격제시=1). 그 이후 카운터제안이 MAX_ROUNDS 회를
# 넘으면 종료한다. 카드선택(RL)이 실패(state ValueError)해도 used_action_ids 가 안 늘어
# 카드 소진 조건만으로는 종료되지 않으므로, 라운드 상한을 독립적으로 둬 무한 가격입력을 막는다.
# (선행 chat_server 의 `iteration >= 3` 와 동일한 안전장치.)
elif cond == "check_iteration_limit": # 협상 라운드 상한 또는 카드 소진 → 종결 국면
# round 는 매 가격입력마다 증가(기존가격제시=1). 카운터제안이 상한을 넘거나 카드가
# 소진되면 곧장 실패가 아니라 **종결 국면**으로 처리한다(실제 MD 협상 방식):
# ① 아직 종결 전술을 안 썼으면 → 가격협상으로 보내되 force_closing 마킹
# (ChatService 가 종결 전술 — 중간값 절충/최후통첩 — 을 강제 발동)
# ② 종결 전술까지 소진(closing_played)이면 → 최종 제시가 ≤ target 은 타결,
# 초과는 결렬(협상실패) — "목표가 초과 타결 금지" 가드레일과 정합.
counter_rounds = max(0, ctx.get("round", 0) - 1)
ok = counter_rounds >= MAX_ROUNDS or (cards_total > 0 and cards_used >= cards_total)
exhausted = counter_rounds >= self.rules.max_counter_rounds or (cards_total > 0 and cards_used >= cards_total)
if exhausted:
target = ctx.get("target_price", 0)
if not ctx.get("closing_played"):
ctx["force_closing"] = True
return "가격협상"
return "협상완료" if (target > 0 and price <= target) else c.get("next")
ok = False
elif cond == "default":
ok = True
if ok:
@ -173,7 +196,7 @@ class ChatEngine:
return "가격협상"
def _pick_wildcard(self, session: ChatSession) -> str:
"""앵커가에 아주 근접(≤ anchor*1.02)한 구간에서만 1% 인하 요청(wild_card_1pct)으로
"""앵커가에 아주 근접(≤ anchor×wildcard_1pct_ratio)한 구간에서만 1% 인하 요청(wild_card_1pct)으로
앵커가 이하로 유도한다. 구간은 일반 가격협상(카드 플레이)으로 돌린다.
과거 여기서 반환하던 '재원부족'(wild_card_budget) 하드코딩 카드는 제거했다
@ -184,12 +207,23 @@ class ChatEngine:
ctx = session.context
price = ctx.get("input_price", 0)
anchor = ctx.get("anchor_price", 0)
if anchor > 0 and price <= anchor * 1.02:
if anchor > 0 and price <= anchor * self.rules.wildcard_1pct_ratio:
# 와일드카드는 실제로 노출할 때만 '사용됨'으로 마킹한다 — 가격협상으로 돌아가는
# 경우에도 마킹하면 이후 라운드에서 정당한 1% 카드까지 억제된다.
ctx["wildcard_used"] = True
ctx["offer_1pct"] = int(round(price * 0.99)) # 1% 인하가
ctx["offer_1pct"] = int(round(price * 0.99)) # 1% 인하가 (멘트 변수)
ctx["pending_counter_price"] = ctx["offer_1pct"] # 수락 시 이 가격으로 타결
return "wild_card_1pct"
# 1.02 초과 ~ entry(1.05) 구간: 견적에서 선택한 와일드카드의 전술로 카운터 제시.
# (구현 전에는 이 구간이 일반 가격협상으로 회귀해 선택형 WC 가 영영 발동하지 않던 갭.)
if anchor > 0 and price <= anchor * self.rules.wildcard_entry_ratio:
for number in (ctx.get("selected_wild_card_numbers") or []):
counter = compute_counter(tactic_for(str(number)), ctx)
if counter is not None:
ctx["wildcard_used"] = True
ctx["pending_counter_price"] = counter
ctx["active_wild_card_number"] = str(number)
return "wild_card_dynamic"
return "가격협상"
# ---- render --------------------------------------------------------
@ -199,26 +233,73 @@ class ChatEngine:
out = {}
if "input_price" in ctx:
out["input_price"] = int(ctx["input_price"])
# 목표가/앵커가: 엔진 내부 파일 스크립트는 {target}/{anchor}, negodata 카드 에디터는
# {target_price}/{anchor_price}(variables.ts) 를 쓴다 — 양쪽 이름 모두 채워 치환 누락 방지.
if "target_price" in ctx:
out["target"] = int(ctx["target_price"])
out["target"] = out["target_price"] = int(ctx["target_price"])
if "anchor_price" in ctx:
out["anchor"] = int(ctx["anchor_price"])
# anchoring_price = DB 시드 기본 카드/sessions 컬럼 표기, anchor_price = 카드 에디터 표기.
out["anchor"] = out["anchor_price"] = out["anchoring_price"] = int(ctx["anchor_price"])
# 카드 에디터 카탈로그의 협력사명/상품명(partner_name·product_name) 치환.
if ctx.get("partner_name"):
out["partner_name"] = str(ctx["partner_name"])
if ctx.get("product_name"):
out["product_name"] = str(ctx["product_name"])
if "offer_1pct" in ctx:
out["offer_1pct"] = int(ctx["offer_1pct"])
# 인하율 = (기존 공급가 - 제시가) / 기존 공급가 * 100. 기존가 없으면 미표시(0.0).
# 전술 카운터 변수(카드 시드 멘트의 가격 변수) — tactics.compute_counter 산식과 동일 정의.
anchor = ctx.get("anchor_price") or 0
target = ctx.get("target_price") or 0
if "input_price" in ctx:
out["prev_partner_price"] = int(ctx.get("prev_partner_price") or ctx["input_price"])
prev_customer = ctx.get("prev_customer_price") or anchor
if prev_customer:
out["prev_customer_price"] = int(prev_customer)
if anchor and target:
out["target_mid_price"] = int(round((anchor + target) / 2))
if prev_customer and "input_price" in ctx:
out["middle_price"] = int(round((prev_customer + ctx["input_price"]) / 2))
if ctx.get("pending_counter_price"):
out["counter_price"] = int(ctx["pending_counter_price"])
# 인하율 = (기존 공급가(상품단가) - 제시가) / 기존 공급가 * 100. 기존가 없으면 미표시(0.0).
# 제시가가 기존가보다 높으면(인상 제시) 음수가 나오는데, "-1.3% 인하된 금액" 같은
# 모순 표현이 되므로 discount_rate 는 0 미만 금지하고, 인상/동일/인하를 구분한
# 문구는 discount_phrase 로 별도 제공한다(가격협상_확인 멘트가 사용).
base = ctx.get("item_price") or 0
if base > 1 and "input_price" in ctx:
out["discount_rate"] = f"{((base - ctx['input_price']) / base) * 100:.1f}"
rate = ((base - ctx["input_price"]) / base) * 100
out["discount_rate"] = f"{max(0.0, rate):.1f}"
if rate >= 0.05:
out["discount_phrase"] = f"기존 공급가 대비 약 **{rate:.1f}%** 인하된 금액입니다. "
elif rate <= -0.05:
out["discount_phrase"] = (
f"기존 공급가(**{int(base)}원**)보다 약 **{abs(rate):.1f}%** 높은 금액입니다. ")
else:
out["discount_phrase"] = "기존 공급가와 동일한 수준의 금액입니다. "
else:
out["discount_rate"] = "0.0"
out["discount_phrase"] = ""
return out
def _vars(self, session: ChatSession) -> Dict[str, Any]:
return self.vars_for(session)
def render_step(self, session: ChatSession, step_key: str) -> StepView:
"""지정 스텝으로 전이·렌더 (공개) — ChatService 가 카드 카운터 제시 시
가격협상 가격협상_카운터로 스텝을 전환할 사용한다."""
return self._render(session, step_key)
def _render(self, session: ChatSession, step_key: Optional[str]) -> StepView:
if not step_key or step_key not in self.scripts:
return self._error(session, f"다음 단계를 찾을 수 없습니다: {step_key}")
# 가드레일(최후 방어선): 구매자 대리는 목표가 초과로 절대 타결하지 않는다.
# 카운터 클램프·종결 규칙이 정상이면 도달하지 않지만, 스크립트 편집 실수 등으로
# 성공 스텝에 초과가로 진입하면 결렬로 강제 전환한다. (재협상 흐름 한정)
if step_key in _SUCCESS_STEPS and self.rq_type == "재협상":
ctx = session.context
target = ctx.get("target_price") or 0
if target > 0 and ctx.get("input_price", 0) > target:
step_key = "협상실패"
node = self.scripts[step_key]
session.step = step_key
chat_end = bool(node.get("chat_end"))

View File

@ -0,0 +1,149 @@
"""InputInterpreter — 협력사 자유 발화를 대화 단계 기대 입력으로 구조화 (Phase 3 이해층).
원칙: "숫자와 결정은 결정론이, 말은 LLM 이" (ScriptNaturalizer 동일 철학)
- LLM 역할은 의도 분류(choice|price|unknown) 가격 '표현의 위치' 찾기까지다.
가격 숫자 계산은 LLM 출력이 아니라 결정론 한국어 가격 파서(parse_korean_price) 수행한다.
- 검증: choice 허용 선택지 목록에 철자 그대로 있어야 하고, price_text 사용자 원문의
부분 문자열이어야 한다(환각 차단). 하나라도 어긋나면 None 호출부가 원문 그대로 폴백
(기존 엔진의 재질문 흐름 유지 협상은 절대 멈추지 않는다).
게이트: tenant llm.enabled + 전역 자격증명(ScriptNaturalizer 동일). 미설정이면 무동작
기존 버튼/정형 입력 경로는 그대로 두고, 자유 텍스트일 때만 해석을 시도한다.
"""
import asyncio
import json
import re
from dataclasses import dataclass
from typing import Callable, List, Optional
from common.logger import LOG
from negotiation.profiling.config import LlmCredentials
# 정형 가격 입력(버튼/필드) — LLM 없이 기존 결정론 경로로 처리 가능한 형태.
SIMPLE_PRICE_RE = re.compile(r"\s*[\d,]+(\.\d+)?\s*원?\s*")
# 한국어 단위 (큰 단위 → 작은 단위 순서로 등장한다고 가정: "1만 2천 500원")
_KOREAN_UNITS = {"": 100_000_000, "": 10_000, "": 1_000, "": 100}
_PRICE_TOKEN_RE = re.compile(r"[\d.억만천백]+")
def parse_korean_price(text: str) -> Optional[float]:
"""가격 표현 문자열 → 숫자 (결정론). "10,500원"→10500, "1만 500원"→10500, "만원"→10000,
"1.5만"15000, "3만2천원"32000. 해석 불가/0 이하 None.
"""
t = (text or "").replace(",", "").replace(" ", "").replace("", "").strip()
if not t:
return None
if re.fullmatch(r"\d+(\.\d+)?", t):
v = float(t)
return v if v > 0 else None
if not re.fullmatch(r"[\d.억만천백]+", t):
return None # 단위·숫자 외 문자 포함 → 해석 불가(안전 폴백)
total, num = 0.0, ""
for ch in t:
if ch.isdigit() or ch == ".":
num += ch
else: # 단위 문자
try:
n = float(num) if num else 1.0 # "만원" = 1만
except ValueError:
return None
total += n * _KOREAN_UNITS[ch]
num = ""
if num:
try:
total += float(num) # 잔여 숫자: "1만500" 의 500
except ValueError:
return None
return total if total > 0 else None
@dataclass
class InterpretedInput:
kind: str # "choice" | "price"
value: str # ChatEngine.advance 에 그대로 전달할 문자열 ("예" / "10500")
source: str # 판단 근거(선택지 원문 또는 가격 표현 원문) — 로깅/투명성용
_SYSTEM = """너는 B2B 구매 협상 챗봇의 입력 해석기다. 협력사(사용자)의 자유 발화를 현재 대화 단계가 기대하는 입력으로 구조화한다.
규칙 (하나라도 어기면 출력은 폐기된다):
- 출력은 JSON 하나만: {"intent": "choice"|"price"|"unknown", "choice": "<선택지 철자 그대로>"|null, "price_text": "<원문 속 가격 표현 그대로>"|null}
- choice 발화의 '의미' 선택지 하나에 대응시켜, 선택지 문자열을 철자 그대로 복사한다.
) 선택지 ["","아니오"]: "네 접니다"/"맞습니다"/"진행해주세요"/"동의합니다" "",
"아닌데요"/"제가 아닙니다"/"어렵습니다"/"거절하겠습니다" "아니오"
- 사용자가 구체적 가격을 제시/역제안하면 intent=price. price_text 반드시 사용자 원문에 등장한
표현을 그대로 복사한다(: "10,500원", "1만 500원"). 숫자를 계산하거나 변형하지 마라.
- 발화가 어느 선택지의 의미인지 정말 판단할 없거나 주제와 무관할 때만 intent=unknown."""
def _default_llm_call(messages: List[dict]) -> dict:
"""기본 LLM 호출(동기) — 전역 자격증명으로 chat_json. 테스트에서 주입 대체 지점."""
from negotiation.profiling.infra.llm_adapter import chat_json
return chat_json(messages, temperature=0.0, max_tokens=200)
class InputInterpreter:
def __init__(self, llm_call: Optional[Callable[[List[dict]], dict]] = None,
timeout_seconds: float = 6.0):
self._llm_call = llm_call or _default_llm_call
self._timeout = timeout_seconds
@staticmethod
def available() -> bool:
"""전역 LLM 자격증명이 설정돼 있는가 (테넌트 enabled 게이트는 호출부 몫)."""
return LlmCredentials.from_config().is_configured()
async def interpret(self, user_input: str, *, input_mode: str,
input_options: Optional[List[str]] = None,
step_script: Optional[str] = None) -> Optional[InterpretedInput]:
"""자유 발화 → 기대 입력. 검증 불통과/실패/타임아웃 시 None(호출부 원문 폴백).
step_script: 직전 질문(맥락) "네 접니다" 같은 발화는 질문 없이는 의도 판단이
애매해 unknown 되므로, 무엇에 대한 답인지 함께 준다.
"""
text = (user_input or "").strip()
options = [str(o) for o in (input_options or [])]
if not text:
return None
payload = {
"현재 단계 기대 입력": "가격(숫자)" if input_mode == "price" else "선택지 중 하나",
"선택지": options,
"사용자 발화": text,
}
if step_script:
payload["직전 봇 질문"] = step_script[:300]
messages = [
{"role": "system", "content": _SYSTEM},
{"role": "user", "content": json.dumps(payload, ensure_ascii=False) + "\n\nJSON 으로만 답하라."},
]
try:
result = await asyncio.wait_for(asyncio.to_thread(self._llm_call, messages), self._timeout)
except Exception as ex: # 타임아웃 포함 — 원문 폴백
LOG.w(f"[InputInterpreter] LLM 호출 실패(원문 폴백): {ex}")
return None
if not isinstance(result, dict):
return None
intent = result.get("intent")
if intent == "choice":
choice = result.get("choice")
# 결정: 선택지 목록에 철자 그대로 있어야만 채택 (LLM 이 지어낸 분기 차단).
if isinstance(choice, str) and choice in options:
return InterpretedInput(kind="choice", value=choice, source=choice)
LOG.w(f"[InputInterpreter] 검증 실패: choice={choice!r}{options}")
return None
if intent == "price":
span = result.get("price_text")
# 환각 차단: 가격 표현은 사용자 원문의 부분 문자열이어야 한다.
if not isinstance(span, str) or not span.strip() or span.strip() not in text:
LOG.w(f"[InputInterpreter] 검증 실패: price_text={span!r} 가 원문에 없음")
return None
price = parse_korean_price(span.strip()) # 숫자 계산은 결정론 파서가
if price is None:
LOG.w(f"[InputInterpreter] 검증 실패: 가격 해석 불가 span={span!r}")
return None
return InterpretedInput(kind="price", value=str(int(price)), source=span.strip())
return None # unknown → 원문 폴백(엔진 재질문)

View File

@ -39,6 +39,8 @@ class NegotiationDbContext:
target_price: int # 목표 매입가(원) — sessions.target_price
anchor_price: int # 앵커링가 — sessions.anchoring_price(생성 시 박제). 없으면 target(무할인 폴백)
item_price: int # 기존 공급가(품목 기준가, items.price) — 인하율 멘트용. 없으면 0
partner_name: Optional[str] # 협력사명(suppliers.name) — 카드 {partner_name} 치환용. 없으면 None
product_name: Optional[str] # 상품명(items.name) — 카드 {product_name} 치환용. 없으면 None
partner_type: PartnerType # 상품에 연결된 협력사 수(supplier_items 매핑, 없으면 세션 이력) → NONE/SINGLE/MULTIPLE
revenue_amount: float # 매출액(원) — suppliers.total_revenue(KTC 미러). 없으면 0
distribution_code: Optional[str] # 유통 코드(A/B/C) — supplier_items.supply_type. 미지정 시 None
@ -84,6 +86,10 @@ class NegotiationContextLoader:
# 기존 공급가(품목 기준가) — 없으면 0(인하율 멘트 미표시).
_, item_price = await self.crud.get_item_price(s, item_id)
# 카드 스크립트 치환용 이름 — 협력사명/상품명. 없으면 None(호출부 기본값 폴백).
_, partner_name = await self.crud.get_supplier_name(s, supplier_id)
_, product_name = await self.crud.get_item_name(s, item_id)
# 파트너사 유형: 상품에 연결된 협력사 수 — supplier_items 매핑(등록 기준) 우선.
# 매핑이 아직 없으면 협상 세션 이력 기준 폴백(더미보다 항상 낫다). 실패 시 SINGLE.
err, supplier_count = await self.crud.count_item_suppliers(s, item_id)
@ -102,6 +108,8 @@ class NegotiationContextLoader:
target_price=target,
anchor_price=anchor,
item_price=item_price,
partner_name=partner_name,
product_name=product_name,
partner_type=PartnerType.from_count(supplier_count),
revenue_amount=revenue_amount,
distribution_code=_SUPPLIER_TYPE_TO_CODE.get(supplier_type) if supplier_type else None,

View File

@ -0,0 +1,144 @@
"""ScriptNaturalizer — 협상 카드 멘트를 LLM 으로 상황에 맞게 자연화 (Phase 2 표현층).
원칙: "숫자와 결정은 결정론이, 말은 LLM 이"
- 입력은 **치환 템플릿**({input_price} placeholder 유지 상태). LLM 숫자를 절대 만들지 않는다.
- 상황(라운드·가격구간·수용률) **정성 라벨**로만 전달(수치 미노출 숫자 환각 원천 차단).
- 검증 실패/타임아웃/미설정 None 호출부가 원본 템플릿 폴백(협상은 절대 멈춤).
검증(ScriptVerifier 철학의 플레인 텍스트판):
{placeholder} 집합이 원본과 정확히 동일(누락·추가 금지)
원본에 없던 숫자 등장 금지(가격 환각 차단)
비어있지 않고 길이 폭주 금지
게이트: tenant llm.enabled(기본 false) + 전역 LLM 자격증명(config.local.toml [OpenAIConfig]
또는 OPENAI_API_KEY env). 호출은 스레드로 넘겨 이벤트루프 비차단 + 타임아웃.
"""
import asyncio
import json
import re
from typing import Any, Callable, Dict, List, Optional
from common.logger import LOG
from negotiation.profiling.config import LlmCredentials
_PLACEHOLDER_RE = re.compile(r"\{(\w+)\}")
_DIGITS_RE = re.compile(r"\d+")
# 강조 마커(negodata Slate 편집 정본) — 고객사가 지정한 표시라 자연화가 보존해야 한다.
# 색 마커 {{강조|..}} {{안내|..}} 는 여는 토큰 개수로, 굵게/밑줄은 구분자 개수(짝수=쌍)로 센다.
_COLOR_OPEN_RE = re.compile(r"\{\{(강조|안내)\|")
# 카드 메타 코드 → 프롬프트 라벨 (init-data.sql CardTone/CardStrategyType 정의와 동일)
_TONE_LABEL = {1: "강경", 2: "정중", 3: "우호", 4: "중립", 5: "단호"}
_STRATEGY_LABEL = {1: "경쟁", 2: "수용", 3: "고수", 4: "협력", 5: "선점", 6: "종결"}
_SYSTEM = """너는 B2B 구매 협상 챗봇의 문장 작성기다. 주어진 협상 카드 멘트 '템플릿'을 협상 상황에 맞게 자연스럽게 다시 쓴다.
규칙 (하나라도 어기면 출력은 폐기된다):
- {변수명} 치환자는 철자 그대로 유지한다. 추가/삭제/변경 금지.
- 숫자를 직접 쓰지 마라. 가격·비율 모든 수치는 치환자로만 표현한다.
- 강조 마커(**굵게** __밑줄__ {{강조|...}} {{안내|...}}) **개수와 종류를 그대로 유지**한다.
고객사가 지정한 강조 표시이므로 삭제·추가·종류변경 금지. 감싼 문구는 자연스럽게 바꿔도 되지만
강조된 구절 수만큼 같은 마커로 반드시 다시 감싼다(: **굵게** 2개면 결과도 **** 2).
- 새로운 약속·할인 조건·법적 표현을 만들지 마라. 템플릿의 협상 의도(전술) 유지한다.
- 한국어 존댓말, 2~5문장, 채팅 말풍선에 어울리게 간결히.
- 출력은 JSON 하나만: {"script": "다시 쓴 멘트"}"""
def _default_llm_call(messages: List[dict]) -> dict:
"""기본 LLM 호출(동기) — 전역 자격증명으로 chat_json. 테스트에서 monkeypatch 지점."""
from negotiation.profiling.infra.llm_adapter import chat_json
return chat_json(messages, temperature=0.5, max_tokens=600)
class ScriptNaturalizer:
def __init__(self, llm_call: Optional[Callable[[List[dict]], dict]] = None,
timeout_seconds: float = 8.0):
self._llm_call = llm_call or _default_llm_call
self._timeout = timeout_seconds
@staticmethod
def available() -> bool:
"""전역 LLM 자격증명이 설정돼 있는가 (테넌트 enabled 게이트는 호출부 몫)."""
return LlmCredentials.from_config().is_configured()
async def naturalize(self, template: str, *, situation: Optional[Dict[str, Any]] = None,
tone: Optional[int] = None, strategy: Optional[int] = None) -> Optional[str]:
"""템플릿(치환 전)을 상황 맞춤 문장으로 재작성. 실패/검증불통과 시 None(호출부 폴백)."""
if not template or not template.strip():
return None
ctx = dict(situation or {})
if tone in _TONE_LABEL:
ctx[""] = _TONE_LABEL[tone]
if strategy in _STRATEGY_LABEL:
ctx["전략"] = _STRATEGY_LABEL[strategy]
messages = [
{"role": "system", "content": _SYSTEM},
{"role": "user", "content":
"템플릿:\n" + template +
"\n\n협상 상황:\n" + json.dumps(ctx, ensure_ascii=False) +
'\n\n규칙대로 다시 써서 {"script": "..."} 로만 출력.'},
]
try:
result = await asyncio.wait_for(asyncio.to_thread(self._llm_call, messages), self._timeout)
except Exception as ex: # 타임아웃 포함 — 폴백
LOG.w(f"[ScriptNaturalizer] LLM 호출 실패(폴백): {ex}")
return None
text = result.get("script") if isinstance(result, dict) else None
if not isinstance(text, str) or not text.strip():
return None
return text if self._verify(template, text) else None
# ---- 검증 ----------------------------------------------------------
@staticmethod
def _verify(template: str, rewritten: str) -> bool:
orig = set(_PLACEHOLDER_RE.findall(template))
new = set(_PLACEHOLDER_RE.findall(rewritten))
if orig != new:
LOG.w(f"[ScriptNaturalizer] 검증 실패: 치환자 불일치 (누락={orig - new}, 추가={new - orig})")
return False
# 원본에 없던 숫자 금지 — 가격/비율 환각 차단 (수치는 치환자로만).
orig_digits = set(_DIGITS_RE.findall(template))
new_digits = set(_DIGITS_RE.findall(rewritten)) - orig_digits
if new_digits:
LOG.w(f"[ScriptNaturalizer] 검증 실패: 새 숫자 등장 {new_digits}")
return False
if len(rewritten) > max(600, len(template) * 4):
LOG.w("[ScriptNaturalizer] 검증 실패: 길이 폭주")
return False
# 강조 마커 보존 — 고객사가 지정한 볼드/밑줄/색을 LLM 이 떨어뜨리면 폐기(원본 폴백).
# 굵게/밑줄: 구분자 총 개수가 같아야 짝(쌍)이 보존됨. 색: 여는 토큰 개수 동일.
if (template.count("**") != rewritten.count("**")
or template.count("__") != rewritten.count("__")
or len(_COLOR_OPEN_RE.findall(template)) != len(_COLOR_OPEN_RE.findall(rewritten))):
LOG.w("[ScriptNaturalizer] 검증 실패: 강조 마커 불일치(볼드/색 소실) → 원본 유지")
return False
return True
def build_situation(context: Dict[str, Any]) -> Dict[str, Any]:
"""세션 컨텍스트 → 정성 상황 라벨 (수치 미노출 — 숫자 환각 차단의 핵심).
가격구간: 제시가 vs 앵커/목표 관계, 라운드: 협상 진행 단계, 인하 진행: 기존 공급가 대비.
"""
out: Dict[str, Any] = {}
rnd = context.get("round") or 0
if rnd:
out["라운드"] = "첫 제안" if rnd <= 1 else ("초반 조율" if rnd == 2 else "막바지 조율")
price = context.get("input_price") or 0
anchor = context.get("anchor_price") or 0
target = context.get("target_price") or 0
if price and anchor and target:
if price <= anchor:
out["가격구간"] = "목표 범위 도달(마무리 국면)"
elif price <= target:
out["가격구간"] = "목표 범위 근접(조율 국면)"
else:
out["가격구간"] = "목표 상회(추가 인하 필요)"
base = context.get("item_price") or 0
if base and price:
rate = (base - price) / base
out["인하 진행"] = "아직 미미" if rate < 0.01 else ("일부 진행" if rate < 0.05 else "상당히 진행")
return out

View File

@ -38,6 +38,11 @@ class ScriptRepository:
# 카드 멘트 DB 소스(backoffice_db). file 모드면 미사용.
self._card_repo: ICardScriptRepository = card_repo or CardScriptDbRepository()
@property
def config(self) -> TenantConfig:
"""테넌트 config 노출 — ChatEngine 이 협상 규칙(negotiation.*)을 읽는다."""
return self._config
# ---- 경로 해석 (_base 폴백) ---------------------------------------
def _resource_path(self, filename: str) -> Optional[str]:
scripts_dir = self._config.resources.scripts_dir
@ -89,25 +94,47 @@ class ScriptRepository:
return None
return self.format_script(text, variables)
async def resolve_card_template(self, action_id: int, card_id: Optional[str],
prefer_db: bool = False) -> tuple:
"""카드 멘트의 **치환 전 템플릿**과 메타를 해석 → (template, tone, strategy_type).
cards.source_type == 'backoffice_db' card.nego_cards(DB, tone/strategy 포함) 우선,
없거나 file 모드면 scripts_cards.json(파일, 메타 None) 폴백. 없으면 (None, None, None).
치환 템플릿을 그대로 주는 이유: LLM 표현층이 placeholder 유지한 재작성한
format_script 치환해야 숫자를 LLM 절대 만지지 않기 때문.
"""
if (prefer_db or self._config.cards.source_type == _CARD_SOURCE_DB) and card_id:
card = await self._fetch_card_db(card_id)
if card:
return card # (script, tone, strategy)
return self.card_scripts().get(str(action_id)), None, None # 파일 폴백(메타 없음)
async def resolve_card_script(self, action_id: int, card_id: Optional[str],
variables: Optional[Dict[str, Any]] = None,
prefer_db: bool = False) -> Optional[str]:
"""카드 멘트 해석. cards.source_type == 'backoffice_db' 면 card.nego_cards.script(DB)를
우선 조회하고, 없거나 file 모드면 scripts_cards.json(파일) 폴백. 변수 치환 반환.
"""카드 멘트 해석(템플릿 + 변수 치환). DB(정본) 우선 → 파일 폴백. 마커는 불투명 텍스트."""
template, _, _ = await self.resolve_card_template(action_id, card_id, prefer_db=prefer_db)
if not template:
return None
return self.format_script(template, variables)
DB 멘트는 백오피스(negodata) 편집한 정본이라 파일보다 우선한다. 마커(**굵게** )
섞여 있어도 agent 불투명 텍스트로 취급 표현 렌더는 프론트 소유.
"""
if (prefer_db or 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 resolve_wild_card_template(self, number: str) -> Optional[str]:
"""선택형 와일드카드(WC-*) 멘트 템플릿 — card.wild_cards(DB, negodata 편집 정본) 조회.
없거나 DB 불가면 None(호출부가 스텝 기본 멘트 폴백)."""
async def _q(s):
_, text = await self._card_repo.get_script_by_number(s, card_id)
return text
_, script = await self._card_repo.get_wild_card_by_number(s, number)
return script
try:
return await DB_SESSION_MNG.execute_lambda(DBType.MAIN.value, DBWRType.DB_READ.value, _q)
except Exception as ex:
LOG.e_no_callstack(f"[ScriptRepository] 와일드카드 멘트 DB 조회 실패 number={number}: {ex}")
return None
async def _fetch_card_db(self, card_id: str) -> Optional[tuple]:
async def _q(s):
_, card = await self._card_repo.get_card_by_number(s, card_id)
return card
try:
return await DB_SESSION_MNG.execute_lambda(DBType.MAIN.value, DBWRType.DB_READ.value, _q)

View File

@ -41,6 +41,9 @@ class PolicyContext:
action_space_size: int
episode: EpisodeState
available_mask: Optional[np.ndarray] = None # None 이면 used_action_ids 로 산출
# 의도층 prior(Phase 1): 갑이 견적에서 고른 카드 순서 등 사전 선호. 방문수로 감쇠되어
# 콜드 스타트 선택만 편향하고 학습(Q)이 쌓이면 영향이 소멸한다 — Q-table 오염 없음.
prior_bonus: Optional[np.ndarray] = None
@dataclass

View File

@ -36,7 +36,8 @@ class UCBQTablePolicy(NegotiationPolicy):
avail = [a for a in range(ctx.action_space_size) if a not in used]
return avail or list(range(ctx.action_space_size)) # 다 썼으면 전체 허용
def _ucb_scores(self, state_index: int, available: List[int]) -> np.ndarray:
def _ucb_scores(self, state_index: int, available: List[int],
prior: "np.ndarray | None" = None) -> np.ndarray:
q = self.qtable.row(state_index)
visits = self.qtable.visit_row(state_index)
total = self.qtable.state_visits(state_index)
@ -45,11 +46,15 @@ class UCBQTablePolicy(NegotiationPolicy):
for a in available:
bonus = self.c * math.sqrt(ln / (visits[a] + 1e-6))
scores[a] = q[a] + bonus
if prior is not None:
# 의도층 prior — 방문수 감쇠: 콜드 스타트 동점(전부 Q=0·bonus=0)일 때만 순서를
# 결정하고, 학습이 쌓이면 1/(1+visits) 로 사라진다.
scores[a] += float(prior[a]) / (1.0 + visits[a])
return scores
def select(self, ctx: PolicyContext) -> ActionDecision:
available = self._available(ctx)
scores = self._ucb_scores(ctx.state_index, available)
scores = self._ucb_scores(ctx.state_index, available, prior=ctx.prior_bonus)
action_id = int(np.argmax(scores))
n = len(available)
# ε-greedy 근사 propensity (greedy 액션)

View File

@ -53,6 +53,9 @@ class Res_Chat(Res_WebPacketProtocol):
# 성공 확정 이후 턴(협상완료 요약·협상종료)에 내려주는 합의가. 와일드카드 1% 인하 수락 등
# 유저가 직접 입력하지 않은 가격으로 타결될 수 있어, backend 요약/입찰가는 이 값을 최우선 사용한다.
settled_price: Optional[int] = None
# Phase 3 이해층: 자유 발화를 NLU 로 해석해 진행한 경우, 엔진에 실제 전달된 입력.
# (예: "만원까지는 어렵고 10,500원이면 가능합니다" → "10500") 미해석/정형 입력이면 None.
interpreted_input: Optional[str] = None
class Res_ChatSession(Res_WebPacketProtocol):

View File

@ -13,10 +13,15 @@ from common.enums import DBType, ErrorType
from common.database.db_session_manager import DB_SESSION_MNG
from common.logger import LOG
from config.server_configs import agent_config
from negotiation.chat.service.chat_engine import ChatEngine, ChatSession, StepView
from negotiation.cards.domain.tactics import compute_counter, tactic_available, tactic_for
from negotiation.chat.service.chat_engine import (
_CHOICE_MODES, _PRICE_MODES, ChatEngine, ChatSession, StepView,
)
from negotiation.chat.service.indicator import compute_indicator
from negotiation.chat.service.input_interpreter import SIMPLE_PRICE_RE, InputInterpreter
from negotiation.chat.service.chat_session_repository import ChatSessionRepository
from negotiation.chat.service.negotiation_context_loader import NegotiationContextLoader
from negotiation.chat.service.script_naturalizer import ScriptNaturalizer, build_situation
from negotiation.chat.service.script_repository import ScriptRepository
from negotiation.policies.base import EpisodeState, PolicyContext, Transition
from negotiation.policy.model_store import QTablePolicyStore
@ -34,9 +39,17 @@ _DEFAULT_TARGET_PRICE = 10000 # KT 목표 매입가
_DEFAULT_ANCHOR_PRICE = 9900 # 앵커링가(목표가보다 낮음). 제시가 ≤ anchor → 우선협상
_DEFAULT_REVENUE_AMOUNT = 20_000_000 # 매출액(원) — suppliers.total_revenue 미기재 시 폴백
_DEFAULT_DISTRIBUTION_CODE = "A" # 유통 코드 — supplier_items.supply_type 미지정 시 폴백
_DEFAULT_PARTNER_NAME = "귀사" # 협력사명 — suppliers.name 미기재/데모 시 폴백(카드 {partner_name})
_DEFAULT_PRODUCT_NAME = "본 상품" # 상품명 — items.name 미기재/데모 시 폴백(카드 {product_name})
class ChatService:
# Phase 2 표현층 / Phase 3 이해층 — 테넌트 llm.enabled + 전역 자격증명일 때만 사용(기본 무동작).
# 클래스 속성인 이유: FastAPI 가 ChatService 를 Depends 로 쓰므로 __init__ 파라미터를 두면
# 쿼리 파라미터로 해석된다. 테스트는 인스턴스 속성으로 덮어 주입한다.
_naturalizer = ScriptNaturalizer()
_interpreter = InputInterpreter()
async def chat(self, engine: TenantEngine, req: Req_Chat) -> Res_Chat:
res = Res_Chat()
repo = ScriptRepository(engine.config, agent_config.tenants_dir)
@ -91,6 +104,9 @@ class ChatService:
# 목표가/앵커링가: sessions 행(생성 시 박제된 anchoring_price) → 박제 ‰ → 1% 폴백 (loader).
"anchor_price": db_ctx.anchor_price if db_ctx else _DEFAULT_ANCHOR_PRICE,
"target_price": db_ctx.target_price if db_ctx else _DEFAULT_TARGET_PRICE,
# 협력사명/상품명 — 카드 스크립트 {partner_name}·{product_name} 치환용(loader). 없으면 폴백.
"partner_name": (db_ctx.partner_name if db_ctx and db_ctx.partner_name else _DEFAULT_PARTNER_NAME),
"product_name": (db_ctx.product_name if db_ctx and db_ctx.product_name else _DEFAULT_PRODUCT_NAME),
"round": 0,
# 기존 공급가(품목 기준가) — 가격협상_확인 인하율 산출용.
"item_price": db_ctx.item_price if db_ctx else 0,
@ -105,7 +121,10 @@ class ChatService:
)
view = chat_engine.start(session)
else:
view = chat_engine.advance(session, req.user_input)
# Phase 3 이해층: 자유 발화(버튼/정형 입력이 아닌 텍스트)를 기대 입력으로 해석.
# 해석 실패/미설정 시 원문 그대로 → 기존 엔진 재질문 흐름 유지.
user_input = await self._interpret_input(engine, chat_engine, session, req.user_input, res)
view = chat_engine.advance(session, user_input)
# 2) 응답 기본 채움 (학습 블록이 가격협상 턴에서 script/indicator 를 덮어쓸 수 있어 먼저 채운다)
res.session_id = session.session_id
@ -122,6 +141,18 @@ class ChatService:
if session.context.get("final_outcome") == "success" and session.context.get("input_price"):
res.settled_price = int(session.context["input_price"])
# 선택형 와일드카드 턴(wild_card_dynamic): 멘트 정본은 card.wild_cards(negodata 편집).
# DB 멘트가 있으면 스텝 기본 멘트를 대체하고, 없으면 기본 멘트(counter_price 치환)로 진행.
if view.step == "wild_card_dynamic" and session.context.get("active_wild_card_number"):
number = session.context["active_wild_card_number"]
template = await repo.resolve_wild_card_template(number)
if template:
if engine.config.llm.enabled and ScriptNaturalizer.available():
template = (await self._naturalizer.naturalize(
template, situation=build_situation(session.context))) or template
res.script = repo.format_script(template, chat_engine.vars_for(session))
res.card_id = number
# 3) 학습 결합 (가격협상 카드선택 → 카드 스크립트·협상지표 / 종료 보상)
if view.error is None and session.action_space_size > 0:
if view.needs_card_selection:
@ -154,6 +185,53 @@ class ChatService:
res.found = True
return res
# ---- Phase 3 이해층 (자유 발화 NLU) ---------------------------------
async def _interpret_input(self, engine: TenantEngine, chat_engine: ChatEngine,
session: ChatSession, user_input: Optional[str], res: Res_Chat) -> Optional[str]:
"""자유 발화를 현재 step 의 기대 입력으로 해석해 엔진에 넘길 문자열을 돌려준다.
결정론 fast path 우선: 정형 가격([\\d,]+?)·버튼 그대로면 LLM 부르지 않는다.
자유 텍스트 + llm.enabled + 자격증명일 때만 InputInterpreter 호출. 해석 실패/미설정이면
원문 그대로 반환 기존 엔진의 재질문/기본분기 흐름이 그대로 동작(협상 불중단).
"""
if user_input is None:
return user_input
raw = str(user_input).strip()
if not raw:
return user_input
node = chat_engine.scripts.get(session.step, {})
mode = node.get("next_input_mode", "null")
options: list = []
if mode in _PRICE_MODES:
if SIMPLE_PRICE_RE.fullmatch(raw):
return user_input # 정형 가격 — 기존 결정론 파서 경로
elif mode in _CHOICE_MODES:
options = self._step_options(node)
if raw in options:
return user_input # 버튼 값 그대로 — 결정론 경로
else:
return user_input # 입력을 받지 않는 스텝
if not (engine.config.llm.enabled and InputInterpreter.available()):
return user_input
out = await self._interpreter.interpret(raw, input_mode=mode, input_options=options,
step_script=node.get("script"))
if out is None:
return user_input
LOG.i(f"[ChatService] NLU: {raw!r}{out.kind}={out.value!r} (근거={out.source!r}) session={session.session_id}")
res.interpreted_input = out.value # 투명성: backend/front 가 해석 결과를 표시할 수 있게
return out.value
@staticmethod
def _step_options(node: dict) -> list:
"""현재 step 이 허용하는 선택지 — input_options 우선, next_step 분기 키 보강."""
opts = [str(o) for o in (node.get("input_options") or [])]
ns = node.get("next_step")
if isinstance(ns, dict):
for k in ns.keys():
if k != "default" and str(k) not in opts:
opts.append(str(k))
return opts
# ---- 학습 ----------------------------------------------------------
@staticmethod
def _acceptance_ratio(context: dict) -> float:
@ -183,6 +261,11 @@ class ChatService:
async def _select_and_learn(self, engine: TenantEngine, chat_engine: ChatEngine,
scripts: ScriptRepository, session: ChatSession, res: Res_Chat):
# 종결 국면(라운드 만료·카드 소진, 엔진 check_iteration_limit 이 마킹): 규칙층이
# 종결 전술(중간값 절충/최후통첩)을 강제 발동한다 — RL 선택·학습 대상이 아니다.
if session.context.pop("force_closing", False):
await self._play_closing_tactic(engine, chat_engine, scripts, session, res)
return
snap = self._snapshot(session, NegotiationOutcome.ONGOING)
try:
idx = state_index(snap, engine.config.state)
@ -192,13 +275,22 @@ class ChatService:
policy, version_id, repo = await QTablePolicyStore.load(engine)
# action space 는 카탈로그 전체(engine.action_space_size)로 고정 — action_id↔카드 대응을
# 견적마다 일정하게 유지해 Q-table 학습 일관성을 지킨다. 견적 선택은 축소가 아니라
# available_mask 로 처리한다(선택 카드만 pickable, 사용분 제외).
# available_mask 로 처리한다(선택 카드만 pickable, 사용분 제외 + 전술 발동조건 AND).
ctx = PolicyContext(state_index=idx, snapshot=snap, action_space_size=engine.action_space_size,
available_mask=self._selection_mask(engine, session),
available_mask=self._combined_mask(engine, session),
prior_bonus=self._selection_prior(engine, session),
episode=EpisodeState(used_action_ids=set(session.used_action_ids)))
decision = policy.select(ctx)
session.used_action_ids.add(decision.action_id)
card_id = self._card_id_for_action(engine, session, decision.action_id)
# 전술 실행(재설계): 카드의 가격 행동 — 카운터 제시가를 계산해 세션에 적재한다.
# pending 이 있으면 이 턴은 수락/거절 스텝(가격협상_카운터)으로 전환되고,
# 협력사가 수락하면 이 가격으로 즉시 타결된다(chat_engine 의 수락 메커니즘).
spec = tactic_for(card_id)
counter = compute_counter(spec, session.context) if tactic_available(spec, session.context) else None
if counter is not None:
session.context["pending_counter_price"] = counter
session.context["prev_customer_price"] = counter # 갑의 최신 포지션(middle_price 기준)
reward = RewardCalculator(engine.config.reward, engine.config.state).calculate(snap)
policy.update(Transition(state_index=idx, action_id=decision.action_id, reward=reward.total, done=False))
await QTablePolicyStore.persist_cell(repo, version_id, policy, idx, decision.action_id)
@ -217,12 +309,17 @@ class ChatService:
# ① 선택된 카드의 스크립트를 봇 메시지(script)로 출력 ② 협상지표 게이지(indicator_value) 동봉.
# backend/front 가 indicator/bot_chat_type 패스스루·게이지 렌더 준비 완료 → 값만 채우면 표시된다.
# 카드 멘트: backoffice_db 모드면 card.nego_cards.script(negodata 편집 정본), 아니면 파일 폴백.
card_script = await scripts.resolve_card_script(
decision.action_id,
card_id,
chat_engine.vars_for(session),
template, tone, strategy = await scripts.resolve_card_template(
decision.action_id, card_id,
prefer_db=bool(session.context.get("selected_nego_card_numbers")),
)
# Phase 2 표현층: llm.enabled(테넌트) + 자격증명 있으면 템플릿을 상황 맞춤 자연화.
# placeholder 유지 상태로 재작성 → 검증(치환자/숫자) → 실패·타임아웃 시 원본 폴백.
if template and engine.config.llm.enabled and ScriptNaturalizer.available():
naturalized = await self._naturalizer.naturalize(
template, situation=build_situation(session.context), tone=tone, strategy=strategy)
template = naturalized or template
card_script = scripts.format_script(template, chat_engine.vars_for(session)) if template else None
if card_script:
res.script = card_script
c = session.context
@ -232,6 +329,49 @@ class ChatService:
res.indicator_range = ind[1]
res.bot_chat_type = "indicator"
# 카운터 제시 카드면 수락/거절 스텝(가격협상_카운터)으로 전환 — 카드 멘트({target_price} 등
# 카운터가 포함)는 그대로 두고, 입력만 [수락|다른 가격 제시] 버튼으로 바꾼다.
if counter is not None:
view2 = chat_engine.render_step(session, "가격협상_카운터")
res.step, res.client_step = view2.step, view2.client_step
res.input_mode, res.input_options = view2.input_mode, view2.input_options
if not card_script:
res.script = view2.script # 카드 멘트 없으면 스텝 기본 카운터 멘트
async def _play_closing_tactic(self, engine: TenantEngine, chat_engine: ChatEngine,
scripts: ScriptRepository, session: ChatSession, res: Res_Chat):
"""종결 국면 강제 전술 — 견적에서 선택한 종결 와일드카드(WC-05 중간값 절충 등) 우선,
없으면 목표가 최후통첩. 최종 카운터를 제시하고 수락/거절 스텝으로 전환한다.
규칙층의 강제 결정이므로 RL 선택/학습을 우회한다."""
ctx = session.context
ctx["closing_played"] = True
# 선택 와일드카드 중 종결 전술 (WC-05/WC-03) — 순서대로 첫 매치.
closing_number = next(
(str(n) for n in (ctx.get("selected_wild_card_numbers") or []) if tactic_for(str(n)).closing),
None,
)
counter = compute_counter(tactic_for(closing_number), ctx) if closing_number else None
if counter is None:
# 폴백 최후통첩: 목표가 제시 (여기 도달 = 제시가 > target 이므로 항상 유효한 카운터).
target = int(ctx.get("target_price") or 0)
counter = target if 0 < target < ctx.get("input_price", 0) else None
if counter is None:
return # 컨텍스트 이상 — 기존 가격협상 스텝 그대로(재제안 요구)
ctx["pending_counter_price"] = counter
ctx["prev_customer_price"] = counter
template = None
if closing_number:
template = await scripts.resolve_wild_card_template(closing_number)
if template and engine.config.llm.enabled and ScriptNaturalizer.available():
template = (await self._naturalizer.naturalize(
template, situation=build_situation(ctx))) or template
view2 = chat_engine.render_step(session, "가격협상_카운터")
res.step, res.client_step = view2.step, view2.client_step
res.input_mode, res.input_options = view2.input_mode, view2.input_options
res.script = scripts.format_script(template, chat_engine.vars_for(session)) if template else view2.script
res.card_id = closing_number
async def _terminal_learn(self, engine: TenantEngine, session: ChatSession, outcome: str, res: Res_Chat):
oc = NegotiationOutcome.SUCCESS if outcome == "success" else NegotiationOutcome.FAILURE
snap = self._snapshot(session, oc)
@ -276,6 +416,52 @@ class ChatService:
dtype=bool,
)
@classmethod
def _combined_mask(cls, engine: TenantEngine, session: ChatSession) -> Optional[np.ndarray]:
"""견적 선택 마스크 AND 전술 발동조건 마스크.
결합 결과가 전부 False (선택 카드가 모두 발동 불가) 선택 마스크 단독으로 폴백
협상은 멈추지 않고(HOLD 설득으로라도 진행), 종결은 라운드 규칙이 처리한다.
"""
sel = cls._selection_mask(engine, session)
tac = cls._tactic_mask(engine, session)
if tac is None:
return sel
if sel is None:
return tac if tac.any() else None
both = sel & tac
return both if both.any() else sel
@staticmethod
def _tactic_mask(engine: TenantEngine, session: ChatSession) -> Optional[np.ndarray]:
"""전술 발동조건(min_round·가격구간)을 만족하는 action 만 True. HOLD(설득)는 항상 True.
전부 True None(마스크 불필요)."""
mask = np.array(
[tactic_available(tactic_for(engine.mapper.get_card_id(a)), session.context)
for a in range(engine.action_space_size)],
dtype=bool,
)
return None if mask.all() else mask
@staticmethod
def _selection_prior(engine: TenantEngine, session: ChatSession) -> Optional[np.ndarray]:
"""의도층 prior(Phase 1) — 견적에서 고른 카드 순서를 콜드 스타트 선호로 반영.
갑이 먼저 고른 카드일수록 높은 보너스(최대 0.3, 순위 선형 감소). UCB 점수에
1/(1+visits) 감쇠로 더해지므로 학습이 쌓이면 Q 지배한다(오염 없음).
선택이 2 미만이면 순서 정보가 무의미 None.
"""
selected = session.context.get("selected_nego_card_numbers") or []
if len(selected) < 2:
return None
prior = np.zeros(engine.action_space_size)
n = len(selected)
for rank, num in enumerate(selected):
a = engine.mapper.get_action_id(str(num))
if a is not None and a < engine.action_space_size:
prior[a] = 0.3 * (n - rank) / n
return prior if prior.any() else None
async def _log(self, repo: LearningRepository, session, state_index, action_id, card_id, snap, reward, propensity, done):
data = {
"session_id": session.session_id, "state_index": state_index, "action_id": action_id,

View File

@ -51,7 +51,7 @@ async def resolve_company_name(repo: ICompanyProfileRepository, tenant_key: str)
try:
cid = uuid.UUID(tenant_key)
except (ValueError, TypeError):
return None # ktcommerce 등 데모 테넌트명 → 파일 브랜드 유지
return None # imarketkorea 등 데모 테넌트명 → 파일 브랜드 유지
async def _q(s):
_, name = await repo.get_company_name(s, cid)

View File

@ -148,6 +148,11 @@ class NegotiationConfig(BaseModel):
anchor_rate: float = 0.01 # 목표가 대비 앵커링 인하율 (기본 1%)
max_rounds: int = 5 # 라운드 상한(보조). 실제 종료는 '카드 소진' 기준.
# 결정 스택 규칙층(Phase 1) — ChatEngine 하드코딩을 테넌트별 데이터로.
wildcard_1pct_ratio: float = 1.02 # 제시가 ≤ anchor×비율 → 1% 인하 와일드카드로 마무리 유도
wildcard_entry_ratio: float = 1.05 # 선택 와일드카드 허용 시 와일드카드 진입 상한(anchor×비율)
max_counter_rounds: int = 3 # 에이전트 카운터 제안 상한(초과 시 협상실패 종료)
def anchor_for(self, target_price: float) -> float:
return round(target_price * (1.0 - self.anchor_rate))

View File

@ -15,5 +15,7 @@
"가격협상_와일드": "가격협상",
"협상완료": "협상종료",
"협상실패": "협상종료",
"협상종료": "협상종료"
}
"협상종료": "협상종료",
"가격협상_카운터": "가격협상",
"wild_card_dynamic": "가격협상"
}

View File

@ -5,7 +5,9 @@
"editor_script_id": "시작",
"next_input_mode": "null",
"input_options": [],
"next_step": { "default": "서비스안내" },
"next_step": {
"default": "서비스안내"
},
"type": "null",
"chat_end": false
},
@ -13,8 +15,12 @@
"script": "안녕하세요. {company_name} {service_name}입니다. 본 서비스는 {company_name}와 협력사 간 물품 공급 가격 협상을 위한 것으로, 귀사가 공급 중인 품목의 새로운 가격 협상을 진행합니다. 안내 사항을 확인하신 뒤, 다음 단계로 넘어가려면 [확인]을 눌러 주세요.",
"editor_script_id": "서비스안내",
"next_input_mode": "confirm",
"input_options": ["확인"],
"next_step": { "default": "담당자확인" },
"input_options": [
"확인"
],
"next_step": {
"default": "담당자확인"
},
"type": "text",
"chat_end": false
},
@ -22,8 +28,14 @@
"script": "본 안내는 협력사 포털에 등록된 담당자에게 발송되었습니다. 구매 협상 담당자가 맞는지 다시 한 번 확인 부탁드립니다. 담당자가 맞다면 [예], 맞지 않다면 [아니오]를 선택해 주세요.",
"editor_script_id": "담당자확인",
"next_input_mode": "yes_no",
"input_options": ["예", "아니오"],
"next_step": { "예": "협상품목안내", "아니오": "담당자확인_아니오" },
"input_options": [
"예",
"아니오"
],
"next_step": {
"예": "협상품목안내",
"아니오": "담당자확인_아니오"
},
"type": "text",
"chat_end": false
},
@ -31,8 +43,14 @@
"script": "[아니오]를 선택하셨습니다. 담당자가 변경되어 정보를 수정하시려면 [정보변경]을, 실수로 선택하신 경우 다시 진행하려면 [돌아가기]를 선택해 주세요.",
"editor_script_id": "담당자확인_아니오",
"next_input_mode": "yes_no",
"input_options": ["돌아가기", "정보변경"],
"next_step": { "돌아가기": "담당자확인", "정보변경": "정보변경_완료" },
"input_options": [
"돌아가기",
"정보변경"
],
"next_step": {
"돌아가기": "담당자확인",
"정보변경": "정보변경_완료"
},
"type": "text",
"chat_end": false
},
@ -49,8 +67,12 @@
"script": "{company_name}는 귀사의 협력에 진심으로 감사드립니다. 이번 가격 협상 품목과 기본 정보를 안내드립니다. 좌측의 상품 정보를 확인해 주세요. 협상이 원만히 마무리되면 더 많은 협력 기회가 마련될 수 있습니다.",
"editor_script_id": "협상품목안내",
"next_input_mode": "confirm",
"input_options": ["확인"],
"next_step": { "확인": "기존가격제시" },
"input_options": [
"확인"
],
"next_step": {
"확인": "기존가격제시"
},
"type": "text",
"chat_end": false
},
@ -59,7 +81,9 @@
"editor_script_id": "기존가격제시",
"next_input_mode": "price",
"input_options": [],
"next_step": { "default": "가격협상_확인" },
"next_step": {
"default": "가격협상_확인"
},
"type": "text",
"chat_end": false
},
@ -68,22 +92,42 @@
"editor_script_id": "가격협상_재입력",
"next_input_mode": "price",
"input_options": [],
"next_step": { "default": "가격협상_확인" },
"next_step": {
"default": "가격협상_확인"
},
"type": "text",
"chat_end": false
},
"가격협상_확인": {
"script": "제시하신 가격은 **{input_price}원**으로, 기존 공급가 대비 약 **{discount_rate}%** 인하된 금액입니다. 이 금액으로 제안하시겠습니까? 수정하시려면 [아니오]를 선택해 주세요.",
"script": "제시하신 가격은 **{input_price}원**입니다. {discount_phrase}이 금액으로 제안하시겠습니까? 수정하시려면 [아니오]를 선택해 주세요.",
"editor_script_id": "가격협상_확인",
"next_input_mode": "yes_no",
"input_options": ["예", "아니오"],
"input_options": [
"예",
"아니오"
],
"next_step": {
"예": [
{ "condition": "check_wildcard_entry", "next": "가격협상_와일드" },
{ "condition": "check_is_supplier_type_c", "next": "협상완료" },
{ "condition": "check_price_match", "next": "협상완료" },
{ "condition": "check_iteration_limit", "next": "협상실패" },
{ "condition": "default", "next": "가격협상" }
{
"condition": "check_wildcard_entry",
"next": "가격협상_와일드"
},
{
"condition": "check_is_supplier_type_c",
"next": "협상완료"
},
{
"condition": "check_price_match",
"next": "협상완료"
},
{
"condition": "check_iteration_limit",
"next": "협상실패"
},
{
"condition": "default",
"next": "가격협상"
}
],
"아니오": "가격협상_재입력"
},
@ -94,8 +138,14 @@
"script": "제시하신 금액은 **{input_price}원**입니다. 이 금액으로 견적을 제출하시겠습니까? 수정하시려면 [아니오]를 선택해 주세요.",
"editor_script_id": "가격협상_확인_버짓",
"next_input_mode": "yes_no",
"input_options": ["예", "아니오"],
"next_step": { "예": "협상완료", "아니오": "가격협상_재입력" },
"input_options": [
"예",
"아니오"
],
"next_step": {
"예": "협상완료",
"아니오": "가격협상_재입력"
},
"type": "text",
"chat_end": false
},
@ -104,7 +154,9 @@
"editor_script_id": "가격협상",
"next_input_mode": "price",
"input_options": [],
"next_step": { "default": "가격협상_확인" },
"next_step": {
"default": "가격협상_확인"
},
"type": "text",
"chat_end": false
},
@ -112,8 +164,12 @@
"script": "협조해 주신 덕분에 원만히 협상이 완료되었습니다. 협상 결과를 확인하신 뒤 동의해 주세요. 거래 약정에 따라 일부 조건이 조정될 수 있는 점 참고 부탁드립니다. 성실히 응해 주셔서 감사합니다.",
"editor_script_id": "협상완료",
"next_input_mode": "confirm",
"input_options": ["협상 내용을 확인했으며, 이의가 없음에 동의합니다."],
"next_step": { "default": "협상종료" },
"input_options": [
"협상 내용을 확인했으며, 이의가 없음에 동의합니다."
],
"next_step": {
"default": "협상종료"
},
"type": "text",
"chat_end": false
},
@ -122,7 +178,9 @@
"editor_script_id": "협상실패",
"next_input_mode": "null",
"input_options": [],
"next_step": { "default": "협상종료" },
"next_step": {
"default": "협상종료"
},
"type": "text",
"chat_end": false
},
@ -134,5 +192,21 @@
"next_step": null,
"type": "text",
"chat_end": true
},
"가격협상_카운터": {
"script": "제시해 주신 **{input_price}원** 검토했습니다. 저희는 **{counter_price}원**을 제안드립니다. 이 가격으로 진행 가능하시면 '수락'을, 어려우시면 '다른 가격 제시'를 선택해 주세요.",
"editor_script_id": "가격협상_카운터",
"next_input_mode": "yes_no",
"input_options": [
"수락",
"다른 가격 제시"
],
"next_step": {
"수락": "협상완료",
"다른 가격 제시": "가격협상_재입력",
"default": "가격협상_재입력"
},
"type": "text",
"chat_end": false
}
}
}

View File

@ -5,8 +5,13 @@
"type": "text",
"chat_end": false,
"next_input_mode": "yes_no",
"input_options": ["예", "아니오"],
"next_step": { "default": "협상완료" },
"input_options": [
"예",
"아니오"
],
"next_step": {
"default": "협상완료"
},
"editor_script_id": "wild_card_1pct"
},
"wild_card_budget": {
@ -15,7 +20,24 @@
"chat_end": false,
"next_input_mode": "price",
"input_options": [],
"next_step": { "default": "가격협상_확인_버짓" },
"next_step": {
"default": "가격협상_확인_버짓"
},
"editor_script_id": "wild_card_budget"
},
"wild_card_dynamic": {
"script": "저희는 **{counter_price}원**이면 즉시 진행이 가능합니다. 이 가격으로 진행 가능하시면 '수락'을, 어려우시면 '다른 가격 제시'를 선택해 주세요.",
"next_input_mode": "yes_no",
"input_options": [
"수락",
"다른 가격 제시"
],
"next_step": {
"수락": "협상완료",
"다른 가격 제시": "가격협상_재입력",
"default": "가격협상_재입력"
},
"type": "text",
"chat_end": false
}
}
}

View File

@ -78,7 +78,7 @@ cards:
connection: {}
llm:
enabled: false
enabled: true
resources:
language: ko

View File

@ -37,7 +37,9 @@ action_mapping:
"10": "NGC-B011"
llm:
enabled: false
enabled: true
# api_key_ref 는 미구현(dead) — 현재 LLM 키는 전역 config.local.toml [OpenAIConfig].api_key
# (또는 OPENAI_API_KEY env) 를 사용한다. 테넌트별 키 분리는 표현층(Phase 2) 본작업에서 구현.
api_key_ref: TENANT_B_OPENAI_API_KEY
resources:

View File

@ -1,32 +0,0 @@
# 데모 테넌트 프로파일 (합성/중립값 — CLEANROOM.md).
# tenant_id 는 라우팅 키일 뿐이며, 아래 값은 해당 회사의 실제 운영값이 아니다.
# 실제 운영 시 카드 카탈로그(card.nego_cards)·튜닝값은 테넌트 비공개 소스에서 주입한다.
tenant_id: ktcommerce
inherits_base: true
name: "Demo Tenant A"
company_id: null # P3 시드 시 company.companies.company_id(uuid) 로 채움
action_mapping:
type: file
action_to_card: # 우리 중립 데모 카드 코드(합성). 실제 카드 코드 아님. 카탈로그 11장(_base 와 정합).
"0": "NGC-A001"
"1": "NGC-A002"
"2": "NGC-A003"
"3": "NGC-A004"
"4": "NGC-A005"
"5": "NGC-A006"
"6": "NGC-A007"
"7": "NGC-A008"
"8": "NGC-A009"
"9": "NGC-A010"
"10": "NGC-A011"
llm:
enabled: false # P7 에서 테넌트별 자격증명 주입
api_key_ref: TENANT_A_OPENAI_API_KEY
resources:
language: ko
scripts_dir: resources # 없으면 _base/resources 폴백
company_name: "데모상사 A" # 스크립트 {company_name} 치환값 (합성)
service_name: "Negosium"

View File

@ -29,7 +29,7 @@ def _reg():
@pytest.mark.asyncio
async def test_4_1_honors_backend_session_id(db_engine):
reset_sessions()
eng = await _reg().get_engine("ktcommerce")
eng = await _reg().get_engine("imarketkorea")
svc = ChatService()
# 첫 턴: backend 의 session_id 를 그대로 키로 써야 함 (새 uuid 발급 X)

View File

@ -0,0 +1,154 @@
"""협상카드 선택 E2E — 실 DB 왕복으로 "견적에서 고른 카드만 뽑히는지" 검증.
시나리오: 견적 생성 협상카드 2(NGC-003, NGC-007) 선택
version_nego_cards 연결 협상 세션 시작 가격협상 2 진행.
검증: 뽑힌 카드가 선택 2 안에서만 나옴(선택 마스크) 세션 중복 없음(사용 마스크)
카탈로그(DB, NGC-001~011) 기준 action space 선택 없으면 전체 카탈로그 허용(폴백).
"""
import os
import uuid as _uuid
from datetime import datetime, timedelta, timezone
import pytest
from sqlalchemy import column, delete, insert, select, table
from common.database.db_session_manager import DB_SESSION_MNG
from common.enums import DBType, DBWRType, ErrorType
from router.v1.chat.protocol import Req_Chat
from services.chat_service import ChatService, reset_sessions
from tenancy.config_loader import TenantConfigLoader
from tenancy.registry import TenantEngineRegistry
_TENANTS_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "tenants")
_T_SESSIONS = table(
"sessions",
column("session_id"), column("quotation_id"), column("item_id"), column("supplier_id"),
column("qt_number"), column("qt_round"), column("qt_type"), column("target_price"),
column("anchoring_price"), column("status"), column("end_time"),
schema="negotiation",
)
_T_QUOTATIONS = table(
"quotations",
column("qt_id"), column("user_id"), column("qt_setting_id"), column("version_id"),
column("name"), column("number"), column("type"), column("status"),
column("start_time"), column("end_time"),
schema="quotation",
)
_T_VNC = table(
"version_nego_cards",
column("vnc_id"), column("version_id"), column("nego_card_id"),
schema="card",
)
_T_NEGO = table("nego_cards", column("nego_card_id"), column("number"), column("deleted"), schema="card")
async def _card_uuid(number: str):
def _q(s):
return DB_SESSION_MNG.execute(
s, select(_T_NEGO.c.nego_card_id).where(
_T_NEGO.c.number == number, _T_NEGO.c.deleted == False).limit(1)) # noqa: E712
_, rows = await DB_SESSION_MNG.execute_lambda(DBType.MAIN.value, DBWRType.DB_READ.value, _q)
return rows[0] if rows else None
@pytest.mark.asyncio
async def test_selected_cards_only_are_played(db_engine):
reset_sessions()
sid, qid, ver_id = _uuid.uuid4(), _uuid.uuid4(), _uuid.uuid4()
iid, sup = _uuid.uuid4(), _uuid.uuid4()
now = datetime.now(timezone.utc)
selected = ["NGC-003", "NGC-007"]
card_ids = {}
for n in selected:
card_ids[n] = await _card_uuid(n)
assert card_ids[n] is not None, f"카탈로그에 {n} 없음(시드 확인)"
def _seed(s_):
async def run(s):
e = await DB_SESSION_MNG.add(s, insert(_T_QUOTATIONS).values(
qt_id=qid, user_id=_uuid.uuid4(), qt_setting_id=_uuid.uuid4(), version_id=ver_id,
name="카드선택E2E", number="QT-CARDSEL-E2E", type=1, status=2,
start_time=now, end_time=now + timedelta(days=1)))
if e != ErrorType.SUCCESS:
return e
for n in selected: # 견적 생성 시 선택한 카드 2장
e = await DB_SESSION_MNG.add(s, insert(_T_VNC).values(
vnc_id=_uuid.uuid4(), version_id=ver_id, nego_card_id=card_ids[n]))
if e != ErrorType.SUCCESS:
return e
return await DB_SESSION_MNG.add(s, insert(_T_SESSIONS).values(
session_id=sid, quotation_id=qid, item_id=iid, supplier_id=sup,
qt_number="QT-CARDSEL-E2E", qt_round=1, qt_type=1,
target_price=10000, anchoring_price=9900, status=2,
end_time=now + timedelta(days=1)))
return run(s_)
err = await DB_SESSION_MNG.execute_lambda_run([DBType.MAIN.value], [_seed])
assert err == ErrorType.SUCCESS
try:
reg = TenantEngineRegistry(loader=TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0))
eng = await reg.get_engine(str(_uuid.uuid4())) # 자동 온보딩(_base type:db → 실 DB 카탈로그)
assert eng.action_space_size == 11 # 카탈로그 11장(NGC-001~011)
svc = ChatService()
played = []
session_id = str(sid)
# 적응형 진행: 카드 전술 재설계 후 카운터 제시 카드(NGC-007 등)는 수락/거절 스텝
# (가격협상_카운터)으로 전환된다 — 거절하고 새 가격을 제시하며 카드 2턴을 유도한다.
prices = iter(["11000", "10600", "10400"])
r = await svc.chat(eng, Req_Chat(session_id=session_id))
for _ in range(14):
if r.step in ("가격협상", "가격협상_카운터") and r.card_id:
played.append(r.card_id)
if len(played) == 2:
break
if r.chat_end:
break
if r.input_mode == "price":
ui = next(prices)
elif r.step == "가격협상_카운터":
ui = "다른 가격 제시"
elif r.input_options:
ui = "" if "" in r.input_options else r.input_options[0]
else:
ui = "확인"
r = await svc.chat(eng, Req_Chat(session_id=session_id, user_input=ui))
assert len(played) == 2, f"가격협상 카드 턴 2회 기대, 실제 {played}"
# ① 선택한 카드 안에서만 뽑힘 ② 세션 내 중복 없음
assert set(played) <= set(selected), f"선택 밖 카드 발동: {played}"
assert len(set(played)) == 2, f"카드 중복 사용: {played}"
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.session_id == sid)),
lambda s: DB_SESSION_MNG.add(s, delete(_T_VNC).where(_T_VNC.c.version_id == ver_id)),
lambda s: DB_SESSION_MNG.add(s, delete(_T_QUOTATIONS).where(_T_QUOTATIONS.c.qt_id == qid))],
)
@pytest.mark.asyncio
async def test_no_selection_allows_full_catalog(db_engine):
"""선택 카드가 없으면(직접호출/데모) 전체 카탈로그가 허용된다 — 카드가 정상적으로 뽑히는지 기본 검증."""
reset_sessions()
reg = TenantEngineRegistry(loader=TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0))
eng = await reg.get_engine(str(_uuid.uuid4())) # _base type:db → DB 카탈로그
catalog = {eng.mapper.get_card_id(a) for a in range(eng.action_space_size)}
svc = ChatService()
played = []
sid = None
for ui in [None, "확인", "", "확인", "11000", "", "10600", "", "10600", ""]:
r = await svc.chat(eng, Req_Chat(session_id=sid, user_input=ui))
sid = r.session_id
if r.step == "가격협상" and r.card_id:
played.append(r.card_id)
if r.chat_end:
break
assert played, "가격협상 카드 턴이 발생해야 함"
assert set(played) <= catalog # 카탈로그(NGC-001~011) 내에서만
assert len(played) == len(set(played)) # 세션 내 중복 없음

View File

@ -0,0 +1,315 @@
"""카드 전술 재설계 검증 — "멘트 카드 → 전술 카드" (가격 행동 실행 계층).
카운터 산식 결정론 + min(counter, target) 클램프 + 무의미 카운터(HOLD 강등)
카운터 수락 = 즉시 타결 / 거절 = 재입력 + pending 폐기
목표가 초과 타결 금지 가드(성공 스텝 진입 차단)
선택형 와일드카드(WC-05 중간값 절충) 발동 1.02~1.05 구간 해소
E2E: 견적 선택 카드(NGC-009 조건부 가격 조정) 카운터를 수락하면 settled=target
E2E: 협력사가 target 초과를 고수하면 종결 전술(최후통첩) 결렬 고객사 이득 가드레일
"""
import os
import uuid as _uuid
from datetime import datetime, timedelta, timezone
import pytest
from negotiation.cards.domain.tactics import (
PriceAction, TacticSpec, compute_counter, tactic_available, tactic_for,
)
from negotiation.chat.service.chat_engine import ChatEngine, ChatSession
from negotiation.chat.service.script_repository import ScriptRepository
from tenancy.config_loader import TenantConfigLoader
_TENANTS_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "tenants")
def _engine() -> ChatEngine:
cfg = TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0).load("imarketkorea")
return ChatEngine(ScriptRepository(cfg, _TENANTS_DIR), rq_type="재협상")
def _session(step="가격협상_확인", **ctx_over):
ctx = {"input_price": 10300, "anchor_price": 10000, "target_price": 10100,
"round": 1, "allow_selected_wildcards": False}
ctx.update(ctx_over)
return ChatSession(session_id="00000000-0000-0000-0000-00000000e001", tenant_id="imarketkorea",
company_id="imarketkorea", step=step, action_space_size=0, context=ctx)
# ---- ① 카운터 산식 (결정론 + 가드레일 클램프) --------------------------------
def test_counter_formulas_and_clamp():
ctx = {"input_price": 11000, "anchor_price": 9900, "target_price": 10000}
assert compute_counter(tactic_for("NGC-009"), ctx) == 10000 # COUNTER_TARGET
assert compute_counter(tactic_for("NGC-007"), ctx) == 9900 # COUNTER_ANCHOR
assert compute_counter(tactic_for("WC-02"), ctx) == 9950 # (anchor+target)/2
# COUNTER_MID: 갑 직전 포지션 폴백 = anchor → (9900+11000)/2 = 10450 → target 클램프
assert compute_counter(tactic_for("WC-05"), ctx) == 10000
# 갑 직전 포지션이 있으면 그 기준: (9800+11000)/2 = 10400 → 역시 클램프 10000
assert compute_counter(tactic_for("WC-05"), dict(ctx, prev_customer_price=9800)) == 10000
# 클램프 미발동 구간: (9900+10050)/2 = 9975 ≤ target
assert compute_counter(tactic_for("WC-05"), dict(ctx, input_price=10050)) == 9975
def test_counter_meaningless_degrades_to_hold():
"""협력사 제시가가 이미 카운터 이하면 카운터가 무의미 → None(순수 설득 유지)."""
ctx = {"input_price": 9950, "anchor_price": 9900, "target_price": 10000}
assert compute_counter(tactic_for("NGC-009"), ctx) is None # target(10000) ≥ 제시가
assert compute_counter(TacticSpec(PriceAction.COUNTER_ANCHOR), dict(ctx, input_price=9900)) is None
def test_unknown_card_falls_back_to_hold():
"""미등록 카드번호(데모 NGC-B*, 커스텀 COMP-*)는 HOLD — 기존 동작 그대로."""
spec = tactic_for("NGC-B003")
assert spec.price_action is PriceAction.HOLD
assert compute_counter(spec, {"input_price": 11000, "target_price": 10000}) is None
assert tactic_available(spec, {}) is True
def test_tactic_availability_conditions():
assert tactic_available(tactic_for("WC-04"), {"round": 1}) is False # min_round=2
assert tactic_available(tactic_for("WC-04"), {"round": 2}) is True
# ---- ② 카운터 수락/거절 메커니즘 (엔진) ---------------------------------------
def test_accept_counter_settles_at_counter_price():
eng = _engine()
s = _session(step="가격협상_카운터", pending_counter_price=10000)
view = eng.advance(s, "수락")
assert view.step == "협상완료"
assert s.context["input_price"] == 10000 # 합의가 = 카운터가
assert "pending_counter_price" not in s.context
def test_reject_counter_reenters_price_and_discards_pending():
eng = _engine()
s = _session(step="가격협상_카운터", pending_counter_price=10000)
view = eng.advance(s, "다른 가격 제시")
assert view.step == "가격협상_재입력"
# 새 가격 입력이 pending 을 폐기한다 — 이후 우선협상 타결이 옛 카운터로 오염되지 않음
view = eng.advance(s, "9900")
assert "pending_counter_price" not in s.context
assert s.context["input_price"] == 9900
def test_wildcard_1pct_decline_keeps_original_price():
"""1% 인하 거절('아니오')도 협상완료로 가지만 합의가는 원 제시가 — pending 미적용 회귀."""
eng = _engine()
s = _session(step="wild_card_1pct", input_price=10000,
offer_1pct=9900, pending_counter_price=9900)
view = eng.advance(s, "아니오")
assert view.step == "협상완료"
assert s.context["input_price"] == 10000 # 거절 → 카운터 미적용
# ---- ③ 목표가 초과 타결 금지 가드 --------------------------------------------
def test_success_step_guard_rejects_over_target():
eng = _engine()
s = _session(input_price=10800, target_price=10000)
view = eng.render_step(s, "협상완료")
assert view.step == "협상실패" # 초과가 성공 진입 → 결렬 강제
# ---- ④ 선택형 와일드카드 발동 (1.02~1.05 구간 갭 해소) -------------------------
def test_selected_wildcard_fires_in_entry_zone():
eng = _engine()
# 10300: 1pct 존(≤10200) 밖, entry 존(≤10500) 안 + WC-05 선택
s = _session(input_price=10300, allow_selected_wildcards=True,
selected_wild_card_numbers=["WC-05"])
view = eng.advance(s, "")
assert view.step == "wild_card_dynamic"
# 카운터 = (anchor 10000 + 10300)/2 = 10150 → target(10100) 클램프
assert s.context["pending_counter_price"] == 10100
assert s.context["active_wild_card_number"] == "WC-05"
# 수락 → 그 가격으로 타결
view = eng.advance(s, "수락")
assert view.step == "협상완료" and s.context["input_price"] == 10100
def test_unselected_wildcard_zone_still_falls_to_nego():
"""와일드카드 미선택이면 entry 존이라도 일반 가격협상 — 기존 동작 보존."""
eng = _engine()
s = _session(input_price=10300, allow_selected_wildcards=True, selected_wild_card_numbers=[])
view = eng.advance(s, "")
assert view.step == "가격협상"
# ---- 인하율 표기 (회귀: 인상 제시가 "-1.3% 인하"로 표기되던 버그) -----------------
def test_discount_never_negative_and_phrase_matches_direction():
eng = _engine()
# 인상 제시(기존 공급가 78000 < 제시 79000): 음수 인하율 금지 + "높은 금액" 문구
s = _session(item_price=78000, input_price=79000)
v = eng.vars_for(s)
assert v["discount_rate"] == "0.0" # 마이너스 인하 표기 금지
assert "높은 금액" in v["discount_phrase"] and "78000원" in v["discount_phrase"]
assert "-" not in v["discount_phrase"]
# 인하 제시: 상품단가(item_price) 기준 인하율
v = eng.vars_for(_session(item_price=78000, input_price=77000))
assert v["discount_rate"] == "1.3"
assert "인하된 금액" in v["discount_phrase"]
# 동일가
v = eng.vars_for(_session(item_price=78000, input_price=78000))
assert "동일한 수준" in v["discount_phrase"]
# 기존가 미보유(신규 협상) → 문구 생략
v = eng.vars_for(_session(item_price=0, input_price=79000))
assert v["discount_phrase"] == "" and v["discount_rate"] == "0.0"
def test_price_confirm_script_renders_raise_correctly():
"""가격협상_확인 멘트 E2E — 인상 제시에 '인하' 표현이 나오지 않는다."""
eng = _engine()
s = _session(step="기존가격제시", item_price=78000, input_price=None, round=0)
s.context.pop("input_price")
view = eng.advance(s, "79000")
assert view.step == "가격협상_확인"
assert "인하" not in view.script # 인상인데 '인하' 금지
assert "높은 금액" in view.script and "79000원" in view.script
# ---- vars_for 전술 변수 치환 ---------------------------------------------------
def test_vars_for_supplies_tactic_variables():
eng = _engine()
s = _session(input_price=10300, prev_customer_price=10000, pending_counter_price=10100)
v = eng.vars_for(s)
assert v["prev_partner_price"] == 10300
assert v["prev_customer_price"] == 10000
assert v["target_mid_price"] == 10050 # (10000+10100)/2
assert v["middle_price"] == 10150 # (10000+10300)/2
assert v["counter_price"] == 10100
# ---- ⑤⑥ E2E (실 DB — 견적 선택 카드 + 서비스 레이어) ---------------------------
from sqlalchemy import column, delete, insert, select, table # noqa: E402
from common.database.db_session_manager import DB_SESSION_MNG # noqa: E402
from common.enums import DBType, DBWRType, ErrorType # noqa: E402
from router.v1.chat.protocol import Req_Chat # noqa: E402
from services.chat_service import ChatService, reset_sessions # noqa: E402
from tenancy.registry import TenantEngineRegistry # noqa: E402
_T_SESSIONS = table(
"sessions",
column("session_id"), column("quotation_id"), column("item_id"), column("supplier_id"),
column("qt_number"), column("qt_round"), column("qt_type"), column("target_price"),
column("anchoring_price"), column("status"), column("end_time"),
schema="negotiation",
)
_T_QUOTATIONS = table(
"quotations",
column("qt_id"), column("user_id"), column("qt_setting_id"), column("version_id"),
column("name"), column("number"), column("type"), column("status"),
column("start_time"), column("end_time"),
schema="quotation",
)
_T_VNC = table("version_nego_cards", column("vnc_id"), column("version_id"), column("nego_card_id"), schema="card")
_T_NEGO = table("nego_cards", column("nego_card_id"), column("number"), column("deleted"), schema="card")
async def _card_uuid(number: str):
def _q(s):
return DB_SESSION_MNG.execute(
s, select(_T_NEGO.c.nego_card_id).where(
_T_NEGO.c.number == number, _T_NEGO.c.deleted == False).limit(1)) # noqa: E712
_, rows = await DB_SESSION_MNG.execute_lambda(DBType.MAIN.value, DBWRType.DB_READ.value, _q)
return rows[0] if rows else None
async def _seed_quote_session(sid, selected_numbers, target=10000, anchor=9900):
qid, ver_id, iid, sup = _uuid.uuid4(), _uuid.uuid4(), _uuid.uuid4(), _uuid.uuid4()
now = datetime.now(timezone.utc)
card_ids = {}
for n in selected_numbers:
card_ids[n] = await _card_uuid(n)
assert card_ids[n] is not None, f"카탈로그에 {n} 없음(시드 확인)"
def _seed(s_):
async def run(s):
e = await DB_SESSION_MNG.add(s, insert(_T_QUOTATIONS).values(
qt_id=qid, user_id=_uuid.uuid4(), qt_setting_id=_uuid.uuid4(), version_id=ver_id,
name="전술E2E", number=f"QT-TACTIC-{str(sid)[:8]}", type=1, status=2,
start_time=now, end_time=now + timedelta(days=1)))
if e != ErrorType.SUCCESS:
return e
for n in selected_numbers:
e = await DB_SESSION_MNG.add(s, insert(_T_VNC).values(
vnc_id=_uuid.uuid4(), version_id=ver_id, nego_card_id=card_ids[n]))
if e != ErrorType.SUCCESS:
return e
return await DB_SESSION_MNG.add(s, insert(_T_SESSIONS).values(
session_id=sid, quotation_id=qid, item_id=iid, supplier_id=sup,
qt_number=f"QT-TACTIC-{str(sid)[:8]}", qt_round=1, qt_type=1,
target_price=target, anchoring_price=anchor, status=2,
end_time=now + timedelta(days=1)))
return run(s_)
err = await DB_SESSION_MNG.execute_lambda_run([DBType.MAIN.value], [_seed])
assert err == ErrorType.SUCCESS
return qid, ver_id
async def _cleanup(sid, qid, ver_id):
await DB_SESSION_MNG.execute_lambda_run(
[DBType.MAIN.value],
[lambda s: DB_SESSION_MNG.add(s, delete(_T_SESSIONS).where(_T_SESSIONS.c.session_id == sid)),
lambda s: DB_SESSION_MNG.add(s, delete(_T_VNC).where(_T_VNC.c.version_id == ver_id)),
lambda s: DB_SESSION_MNG.add(s, delete(_T_QUOTATIONS).where(_T_QUOTATIONS.c.qt_id == qid))],
)
@pytest.mark.asyncio
async def test_e2e_counter_accept_settles_at_target(db_engine):
"""견적 선택 카드 NGC-009(조건부 가격 조정 → COUNTER_TARGET)의 카운터를 수락하면
합의가 = 목표가(10000) '수락 즉시 타결' 기획 결정의 E2E 검증."""
reset_sessions()
sid = _uuid.uuid4()
qid, ver_id = await _seed_quote_session(sid, ["NGC-009"])
try:
reg = TenantEngineRegistry(loader=TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0))
eng = await reg.get_engine(str(_uuid.uuid4()))
svc = ChatService()
session_id = str(sid)
r = None
for ui in [None, "확인", "", "확인", "11000", ""]:
r = await svc.chat(eng, Req_Chat(session_id=session_id, user_input=ui))
# 가격협상 카드 턴 → NGC-009 카운터(target) 제시 스텝
assert r.step == "가격협상_카운터", f"카운터 스텝 기대, 실제 {r.step}"
assert r.card_id == "NGC-009"
assert r.input_options == ["수락", "다른 가격 제시"]
r = await svc.chat(eng, Req_Chat(session_id=session_id, user_input="수락"))
assert r.step == "협상완료"
assert r.settled_price == 10000 # 합의가 = 목표가 (고객사 이득)
finally:
await _cleanup(sid, qid, ver_id)
@pytest.mark.asyncio
async def test_e2e_over_target_ends_in_failure_after_closing(db_engine):
"""협력사가 목표가 초과(11000)를 고수하면: 카드 소진 → 종결 전술(목표가 최후통첩) →
그래도 거절 결렬(협상실패). 목표가 초과로는 절대 타결되지 않는다."""
reset_sessions()
sid = _uuid.uuid4()
qid, ver_id = await _seed_quote_session(sid, ["NGC-003"]) # HOLD 카드 1장 → 빠른 소진
try:
reg = TenantEngineRegistry(loader=TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0))
eng = await reg.get_engine(str(_uuid.uuid4()))
svc = ChatService()
session_id = str(sid)
steps, r = [], None
# 고수 시나리오: 가격은 항상 11000, 카운터는 전부 거절
for ui in [None, "확인", "", "확인", "11000", "", "11000", ""]:
r = await svc.chat(eng, Req_Chat(session_id=session_id, user_input=ui))
steps.append(r.step)
# 카드(NGC-003) 소진 → 종결 국면: 목표가 최후통첩 카운터 스텝
assert r.step == "가격협상_카운터", f"종결 카운터 기대, 실제 {steps}"
assert "10000" in r.script # 최후통첩 = 목표가 제시
r = await svc.chat(eng, Req_Chat(session_id=session_id, user_input="다른 가격 제시"))
r = await svc.chat(eng, Req_Chat(session_id=session_id, user_input="11000"))
r = await svc.chat(eng, Req_Chat(session_id=session_id, user_input=""))
assert r.step == "협상실패" # target 초과 고수 → 결렬
r = await svc.chat(eng, Req_Chat(session_id=session_id, user_input="확인"))
assert r.chat_end and r.outcome == "failure" # backend REJECTED → 개찰 이관
assert r.settled_price is None # 초과가 타결 없음
finally:
await _cleanup(sid, qid, ver_id)

View File

@ -109,7 +109,7 @@ async def test_context_loaded_from_db(db_engine):
try:
reg = TenantEngineRegistry(loader=TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0))
eng = await reg.get_engine("ktcommerce")
eng = await reg.get_engine("imarketkorea")
r = await ChatService().chat(eng, Req_Chat(session_id=str(sid)))
assert r.session_id == str(sid) and r.step == "서비스안내"
@ -164,7 +164,7 @@ async def test_null_anchoring_falls_back_to_target(db_engine):
assert err == ErrorType.SUCCESS
try:
reg = TenantEngineRegistry(loader=TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0))
eng = await reg.get_engine("ktcommerce")
eng = await reg.get_engine("imarketkorea")
await ChatService().chat(eng, Req_Chat(session_id=str(sid)))
saved = await ChatSessionRepository(eng.company_id).get(str(sid))
assert saved is not None
@ -195,6 +195,12 @@ 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_item_name(self, cdb, item_id):
return ErrorType.SUCCESS, "테스트상품"
async def get_supplier_name(self, cdb, supplier_id):
return ErrorType.SUCCESS, "테스트협력사"
async def get_supply_type(self, cdb, supplier_id, item_id):
return ErrorType.SUCCESS, 3 # sole_agency(총판) → "B"
@ -213,6 +219,8 @@ async def test_loader_with_crud_double(db_engine):
assert ctx.target_price == 50000
assert ctx.anchor_price == 50000 # 미박제 → 무할인 폴백(anchor=target)
assert ctx.item_price == 7000
assert ctx.partner_name == "테스트협력사"
assert ctx.product_name == "테스트상품"
assert ctx.revenue_amount == 12_000_000.0
assert ctx.distribution_code == "B" # supply_type=3(총판) → B
assert ctx.partner_type is PartnerType.NONE
@ -225,7 +233,7 @@ async def test_context_falls_back_without_db_row(db_engine):
"""DB 에 세션 행이 없으면(데모/직접 호출) 기본 컨텍스트로 폴백한다."""
reset_sessions()
reg = TenantEngineRegistry(loader=TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0))
eng = await reg.get_engine("ktcommerce")
eng = await reg.get_engine("imarketkorea")
r = await ChatService().chat(eng, Req_Chat())
saved = await ChatSessionRepository(eng.company_id).get(r.session_id)
assert saved is not None

View File

@ -0,0 +1,140 @@
"""Phase 1 결정 스택 잔여분 검증 — 규칙 데이터화 + 선택카드 우선순위 prior.
1. 와일드카드 진입 임계(wildcard_1pct_ratio/entry_ratio)·카운터 라운드 상한(max_counter_rounds)
하드코딩이 아니라 테넌트 config(negotiation.*) 주입된다.
2. 의도층 prior: 갑이 견적에서 고른 카드 순서가 콜드 스타트 선택을 결정하고,
학습(Q·방문수) 쌓이면 영향이 소멸한다 Q-table 오염 없음.
"""
import os
import numpy as np
import pytest
from negotiation.chat.service.chat_engine import ChatEngine, ChatSession
from negotiation.chat.service.script_repository import ScriptRepository
from negotiation.policies.base import EpisodeState, PolicyContext
from negotiation.policies.qtable_policy import UCBQTablePolicy
from negotiation.qtable.domain.model.q_table import QTable
from negotiation.qtable.domain.model.snapshot import NegotiationSnapshot
from tenancy.config_loader import TenantConfigLoader
_TENANTS_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "tenants")
def _engine(**rule_overrides) -> ChatEngine:
cfg = TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0).load("imarketkorea")
for k, v in rule_overrides.items():
setattr(cfg.negotiation, k, v)
return ChatEngine(ScriptRepository(cfg, _TENANTS_DIR), rq_type="재협상")
def _session(price, anchor=10000, rnd=1, **ctx_over):
ctx = {"input_price": price, "anchor_price": anchor, "target_price": anchor + 100,
"round": rnd, "allow_selected_wildcards": False}
ctx.update(ctx_over)
return ChatSession(session_id="00000000-0000-0000-0000-00000000d001", tenant_id="imarketkorea",
company_id="imarketkorea", step="가격협상_확인", action_space_size=0, context=ctx)
# ---- 규칙 데이터화 -----------------------------------------------------------
def test_default_rules_loaded_from_config():
eng = _engine()
assert eng.rules.wildcard_1pct_ratio == 1.02
assert eng.rules.wildcard_entry_ratio == 1.05
assert eng.rules.max_counter_rounds == 3
def test_wildcard_threshold_is_config_driven():
# 기본(1.02): anchor 10000, 제시 10800 → 임계 밖 → 일반 가격협상
view = _engine().advance(_session(10800), "")
assert view.step == "가격협상"
# 임계를 1.10 으로 완화한 테넌트 → 같은 가격에서 1% 인하 와일드카드 발동
view = _engine(wildcard_1pct_ratio=1.10).advance(_session(10800), "")
assert view.step == "wild_card_1pct"
def test_max_counter_rounds_is_config_driven():
# round=3(카운터 2회 경과), 제시 11000: 기본 상한 3 → 아직 협상 지속
view = _engine().advance(_session(11000, rnd=3), "")
assert view.step == "가격협상"
# 상한 1 → 종결 국면 진입: 곧장 실패가 아니라 종결 전술 발동 지점(force_closing)으로
s = _engine(max_counter_rounds=1), _session(11000, rnd=3)
view = s[0].advance(s[1], "")
assert view.step == "가격협상" and s[1].context.get("force_closing") is True
# 종결 전술까지 소진(closing_played) 후에도 target(10100) 초과 → 결렬
view = _engine(max_counter_rounds=1).advance(_session(11000, rnd=3, closing_played=True), "")
assert view.step == "협상실패"
# 종결 후 제시가가 target 이하로 내려오면 결렬이 아니라 타결 (새 규칙 — 구현 전엔 무조건 실패)
view = _engine(max_counter_rounds=1).advance(
_session(10050, rnd=3, closing_played=True, wildcard_used=True), "")
assert view.step == "협상완료"
# ---- 선택카드 우선순위 prior ---------------------------------------------------
def _snap():
return NegotiationSnapshot(revenue_amount=1, distribution_code="A", partner_count=1,
acceptance_ratio=0.1, input_price=900, anchor_price=800, target_price=1000)
def _ctx(prior=None, mask=None, n=11):
return PolicyContext(state_index=0, snapshot=_snap(), action_space_size=n,
available_mask=mask, prior_bonus=prior, episode=EpisodeState())
def test_prior_decides_cold_start_order():
"""콜드 스타트(Q=0·방문 0)에서는 갑이 먼저 고른 카드(높은 prior)가 먼저 나간다."""
qt = QTable(2, 11)
prior = np.zeros(11)
prior[7], prior[2] = 0.3, 0.15 # 선택 순서: action7 → action2
mask = np.zeros(11, dtype=bool)
mask[2] = mask[7] = True
p = UCBQTablePolicy(qt, mark_visits=False)
assert p.select(_ctx(prior=prior, mask=mask)).action_id == 7
def test_prior_decays_as_learning_accumulates():
"""학습이 쌓이면(Q·방문수) prior 는 1/(1+visits) 로 감쇠 — Q 가 지배한다."""
qt = QTable(2, 11)
qt.q[0, 2] = 1.0 # action2 가 학습상 우월
qt.visits[0, 2] = 5
qt.visits[0, 7] = 5 # 탐색 보너스 동률
prior = np.zeros(11)
prior[7] = 0.3 # 갑 선호는 action7
mask = np.zeros(11, dtype=bool)
mask[2] = mask[7] = True
p = UCBQTablePolicy(qt, mark_visits=False)
assert p.select(_ctx(prior=prior, mask=mask)).action_id == 2
def test_no_prior_keeps_existing_behavior():
"""prior 미주입(None) 시 기존 UCB 동작 그대로 — 회귀 없음."""
qt = QTable(2, 4)
qt.q[0] = np.array([1.0, 9.0, 2.0, 0.0])
qt.visits[0] = np.array([5, 5, 5, 5])
p = UCBQTablePolicy(qt, exploration_constant=0.1, mark_visits=False)
assert p.select(_ctx(n=4)).action_id == 1
def test_selection_prior_built_from_quotation_order():
"""ChatService._selection_prior — 견적 선택 순서 → prior 배열 (앞선 선택일수록 큼)."""
from services.chat_service import ChatService
class _Mapper:
_m = {i: f"NGC-B{i + 1:03d}" for i in range(11)}
def get_action_id(self, num):
return next((a for a, c in self._m.items() if c == num), None)
class _Engine:
mapper = _Mapper()
action_space_size = 11
session = ChatSession(session_id="00000000-0000-0000-0000-00000000d002", tenant_id="t", company_id="t",
context={"selected_nego_card_numbers": ["NGC-B008", "NGC-B003"]})
prior = ChatService._selection_prior(_Engine(), session)
assert prior is not None
assert prior[7] > prior[2] > 0 # 먼저 고른 NGC-B008(action7) 이 더 큼
assert prior[[0, 1, 4, 10]].sum() == 0 # 미선택 카드는 0
# 선택이 1장이면 순서 정보가 없어 None
session.context["selected_nego_card_numbers"] = ["NGC-B008"]
assert ChatService._selection_prior(_Engine(), session) is None

View File

@ -110,7 +110,7 @@ async def test_version_and_cell_persistence(db_engine):
@pytest.mark.asyncio
async def test_service_step_learns_and_isolates(db_engine):
reg = TenantEngineRegistry(loader=TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0))
eng = await reg.get_engine("ktcommerce")
eng = await reg.get_engine("imarketkorea")
svc = NegotiationService()
def req():
@ -126,20 +126,21 @@ async def test_service_step_learns_and_isolates(db_engine):
assert r1.learned is True and r1.policy == "qtable_ucb"
assert r1.updated_q > 0.0 # 성공 보상으로 Q 상승
# DB 에서 state 전체 방문 누적 확인 (state 58 = ktcommerce 의 이 snapshot)
# DB 에서 state 전체 방문 누적 확인 (state index 는 imarketkorea config 로 동적 계산)
from negotiation.qtable.domain.service.state_calculator import state_index
sidx = state_index(_snap(revenue_amount=20_000_000, acceptance_ratio=0.11, input_price=990, round_number=3), eng.config.state)
repo_kt = LearningRepository("ktcommerce")
vid = await repo_kt.get_or_create_active_version(state_space_size=162, action_space_size=9, learning_rate=0.1, discount_factor=0.95)
_, vcells = await repo_kt.load_cells(vid)
repo_a = LearningRepository("imarketkorea")
vid = await repo_a.get_or_create_active_version(state_space_size=162, action_space_size=9, learning_rate=0.1, discount_factor=0.95)
_, vcells = await repo_a.load_cells(vid)
state_total = sum(c for s, a, c in vcells if s == sidx)
assert state_total == 3 # 3회 호출 → state 누적 방문 3
# 테넌트 격리: imarketkorea 는 별도 학습/별도 state
eng2 = await reg.get_engine("imarketkorea")
# 테넌트 격리: 자동 온보딩 고객사(UUID)는 별도 학습/별도 state
other = "00000000-0000-0000-0000-0000000000c1"
eng2 = await reg.get_engine(other)
ri = await svc.step(eng2, req())
assert ri.visit_count == 1
err, ck = await repo_kt.read(lambda s: repo_kt.count_experience(s))
err, ci = await LearningRepository("imarketkorea").read(lambda s: LearningRepository("imarketkorea").count_experience(s))
err, ck = await repo_a.read(lambda s: repo_a.count_experience(s))
err, ci = await LearningRepository(other).read(lambda s: LearningRepository(other).count_experience(s))
assert ck == 3 and ci == 1 # experience 격리

View File

@ -35,7 +35,7 @@ def test_card_effectiveness_has_good_cards():
assert len(good) >= 3 # 효과 좋은 카드 존재 → 학습 대상 신호
@pytest.mark.parametrize("tenant", ["ktcommerce", "imarketkorea"])
@pytest.mark.parametrize("tenant", ["_base", "imarketkorea"])
def test_learning_beats_baseline(tenant):
report = run("configs/exp_default.yaml", tenant)
pols = report["policies"]
@ -53,7 +53,7 @@ def test_learning_beats_baseline(tenant):
def test_static_does_not_learn():
report = run("configs/exp_default.yaml", "ktcommerce")
report = run("configs/exp_default.yaml", "imarketkorea")
static = report["policies"]["static"]
# 정적 정책은 항상 고정 카드 → 좋은카드 적중 학습 없음(우연 일치만)
assert static["good_card_hit_rate"] <= report["policies"]["qtable_ucb"]["good_card_hit_rate"]

View File

@ -0,0 +1,161 @@
"""Phase 3 이해층 검증 — InputInterpreter (자유 발화 NLU → 기대 입력 구조화).
원칙 검증: LLM 의도 분류·가격 표현 위치만 찾고, 숫자 계산은 결정론 파서가 한다.
한국어 가격 파서 결정론 choice 선택지 목록 검증 price_text 원문 부분문자열 검증
실패/타임아웃 None(원문 폴백) 플로우: 자유 발화로 분기·가격 입력이 진행된다
LLM 미설정 기존(정형 입력) 동작 그대로 회귀 없음.
"""
import os
import pytest
from negotiation.chat.service.input_interpreter import (
InputInterpreter, InterpretedInput, parse_korean_price,
)
_TENANTS_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "tenants")
# ---- 결정론 한국어 가격 파서 -------------------------------------------------
@pytest.mark.parametrize("text,expected", [
("10,500원", 10500),
("10500", 10500),
("1만 500원", 10500),
("1만500원", 10500),
("만원", 10000),
("1.5만", 15000),
("3만2천원", 32000),
("2억", 200_000_000),
("0", None), # 0 이하 무효
("그건 어렵습니다", None), # 가격 아님
("만원에 3개", None), # 잡문자 혼입 → 해석 불가(안전 폴백)
])
def test_parse_korean_price(text, expected):
got = parse_korean_price(text)
assert (got == expected) if expected is not None else (got is None)
# ---- LLM 출력 검증 (환각 차단) ----------------------------------------------
def _fake(reply: dict):
def call(messages):
return reply
return call
@pytest.mark.asyncio
async def test_choice_mapped_to_option():
nat = InputInterpreter(llm_call=_fake({"intent": "choice", "choice": "", "price_text": None}))
out = await nat.interpret("네 접니다, 말씀하세요", input_mode="yes_no", input_options=["", "아니오"])
assert out == InterpretedInput(kind="choice", value="", source="")
@pytest.mark.asyncio
async def test_choice_outside_options_rejected():
nat = InputInterpreter(llm_call=_fake({"intent": "choice", "choice": "글쎄요", "price_text": None}))
assert await nat.interpret("음...", input_mode="yes_no", input_options=["", "아니오"]) is None
@pytest.mark.asyncio
async def test_price_span_verified_and_parsed_deterministically():
nat = InputInterpreter(llm_call=_fake({"intent": "price", "choice": None, "price_text": "1만 500원"}))
out = await nat.interpret("저희 마진상 1만 500원까지는 맞춰드릴 수 있습니다", input_mode="price")
assert out is not None and out.kind == "price"
assert out.value == "10500" # 숫자는 결정론 파서 산출(LLM 계산 아님)
assert out.source == "1만 500원"
@pytest.mark.asyncio
async def test_price_span_not_in_text_rejected():
"""LLM 이 원문에 없는 가격 표현을 지어내면 폐기(환각 차단)."""
nat = InputInterpreter(llm_call=_fake({"intent": "price", "choice": None, "price_text": "9,000원"}))
assert await nat.interpret("만원이면 가능합니다", input_mode="price") is None
@pytest.mark.asyncio
async def test_unknown_and_failures_fall_back():
assert await InputInterpreter(llm_call=_fake({"intent": "unknown"})).interpret(
"글쎄요 검토해 볼게요", input_mode="yes_no", input_options=["", "아니오"]) is None
def boom(messages):
raise RuntimeError("LLM down")
assert await InputInterpreter(llm_call=boom).interpret("", input_mode="yes_no", input_options=[""]) is None
def slow(messages):
import time
time.sleep(0.5)
return {"intent": "choice", "choice": ""}
nat = InputInterpreter(llm_call=slow, timeout_seconds=0.05)
assert await nat.interpret("", input_mode="yes_no", input_options=[""]) is None
# ---- 챗 플로우 E2E (fake LLM) ------------------------------------------------
def _routing_fake(messages):
"""단계별 fake — 기대 입력이 가격이면 price, 아니면 '' choice 로 응답."""
user = messages[-1]["content"]
if "가격(숫자)" in user:
return {"intent": "price", "choice": None, "price_text": "1만 500원"}
return {"intent": "choice", "choice": "", "price_text": None}
@pytest.mark.asyncio
async def test_chat_flow_free_text_negotiation(db_engine):
"""자유 발화만으로 담당자확인 분기 + 가격 입력이 진행된다 (버튼 없는 '진짜 대화')."""
from router.v1.chat.protocol import Req_Chat
from services.chat_service import ChatService, reset_sessions
from tenancy.config_loader import TenantConfigLoader
from tenancy.registry import TenantEngineRegistry
reset_sessions()
reg = TenantEngineRegistry(loader=TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0))
eng = await reg.get_engine("imarketkorea")
eng.config.llm.enabled = True
svc = ChatService()
svc._interpreter = InputInterpreter(llm_call=_routing_fake)
orig = InputInterpreter.available
InputInterpreter.available = staticmethod(lambda: True)
try:
sid = None
r = await svc.chat(eng, Req_Chat(session_id=sid)) # 서비스안내
sid = r.session_id
r = await svc.chat(eng, Req_Chat(session_id=sid, user_input="확인")) # 담당자확인 (fast path)
assert r.step == "담당자확인" and r.interpreted_input is None
# 자유 발화 → NLU 가 "예" 로 매핑 → 협상품목안내
r = await svc.chat(eng, Req_Chat(session_id=sid, user_input="네 접니다, 말씀하세요"))
assert r.step == "협상품목안내"
assert r.interpreted_input == ""
r = await svc.chat(eng, Req_Chat(session_id=sid, user_input="확인")) # 기존가격제시(price)
# 자유 발화 가격 → span "1만 500원" → 결정론 파서 10500 → 가격 저장 후 확인 단계
r = await svc.chat(eng, Req_Chat(session_id=sid, user_input="저희 마진상 1만 500원까지는 맞춰드릴 수 있습니다"))
assert r.step == "가격협상_확인"
assert r.interpreted_input == "10500"
assert "10500" in r.script # 멘트 치환도 해석된 가격으로
finally:
InputInterpreter.available = orig
eng.config.llm.enabled = False
@pytest.mark.asyncio
async def test_chat_flow_without_llm_keeps_legacy_behavior(db_engine):
"""LLM 미설정(available=False)이면 자유 발화는 원문 그대로 엔진에 전달 — 기존 동작 회귀 없음."""
from router.v1.chat.protocol import Req_Chat
from services.chat_service import ChatService, reset_sessions
from tenancy.config_loader import TenantConfigLoader
from tenancy.registry import TenantEngineRegistry
reset_sessions()
reg = TenantEngineRegistry(loader=TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0))
eng = await reg.get_engine("imarketkorea")
svc = ChatService()
sid = None
r = await svc.chat(eng, Req_Chat(session_id=sid))
sid = r.session_id
r = await svc.chat(eng, Req_Chat(session_id=sid, user_input="확인"))
assert r.step == "담당자확인"
# conftest 가드로 available=False → NLU 미동작, interpreted_input 없음
r = await svc.chat(eng, Req_Chat(session_id=sid, user_input="네 접니다"))
assert r.interpreted_input is None

View File

@ -29,12 +29,12 @@ async def test_step_requires_tenant_header(client):
@pytest.mark.asyncio
async def test_step_tenant_divergence(client):
rk = await client.post("/v1/negotiation/step", headers={"X-Tenant-ID": "ktcommerce"}, json=_BODY)
rk = await client.post("/v1/negotiation/step", headers={"X-Tenant-ID": "_base"}, json=_BODY)
ri = await client.post("/v1/negotiation/step", headers={"X-Tenant-ID": "imarketkorea"}, json=_BODY)
assert rk.status_code == 200 and ri.status_code == 200
dk, di = rk.json(), ri.json()
# 같은 입력이 테넌트 config 에 따라 다른 상태/카드로 갈린다
assert dk["card_id"].startswith("NGC-A")
# 같은 입력이 테넌트 config 에 따라 다른 상태/카드로 갈린다 (_base=공용 NGC-0xx, imk=NGC-Bxxx)
assert dk["card_id"].startswith("NGC-0")
assert di["card_id"].startswith("NGC-B")
assert dk["state_index"] != di["state_index"]
# 응답 형태
@ -49,7 +49,7 @@ async def test_step_tenant_divergence(client):
@pytest.mark.asyncio
async def test_step_invalid_distribution_code_is_domain_error(client):
body = dict(_BODY, distribution_code="Z")
r = await client.post("/v1/negotiation/step", headers={"X-Tenant-ID": "ktcommerce"}, json=body)
r = await client.post("/v1/negotiation/step", headers={"X-Tenant-ID": "imarketkorea"}, json=body)
assert r.status_code == 200 # HTTP 는 200, 결과코드로 에러 전달(backend 규약)
assert r.json()["result"]["desc"] == "NEGO_INVALID_STEP"
@ -57,5 +57,5 @@ async def test_step_invalid_distribution_code_is_domain_error(client):
@pytest.mark.asyncio
async def test_step_invalid_outcome(client):
body = dict(_BODY, outcome="maybe")
r = await client.post("/v1/negotiation/step", headers={"X-Tenant-ID": "ktcommerce"}, json=body)
r = await client.post("/v1/negotiation/step", headers={"X-Tenant-ID": "imarketkorea"}, json=body)
assert r.json()["result"]["desc"] == "INVALID_REQUEST_DATA"

View File

@ -62,5 +62,5 @@ async def test_tenant_header_required(client):
assert r.json()["result"]["desc"] == "TENANT_HEADER_MISSING"
# 헤더가 있으면 미들웨어 통과 (라우트 미존재라 404).
r = await client.get("/v1/some-protected-path", headers={"X-Tenant-ID": "ktcommerce"})
r = await client.get("/v1/some-protected-path", headers={"X-Tenant-ID": "imarketkorea"})
assert r.status_code == 404

View File

@ -22,7 +22,7 @@ def _loader() -> TenantConfigLoader:
def test_platform_neutral_defaults_load():
cfg = _loader().load("ktcommerce")
cfg = _loader().load("_base")
# 우리 플랫폼 중립 기본값 (CLEANROOM.md)
assert cfg.state.revenue.thresholds == [10_000_000, 50_000_000]
@ -47,12 +47,12 @@ def test_platform_neutral_defaults_load():
def test_state_space_and_action_space_size():
cfg = _loader().load("ktcommerce")
cfg = _loader().load("_base")
assert cfg.state.state_space_size == 162 # 3×3×3×3×2 (차원 구성은 기능적 설계)
assert cfg.action_mapping.action_space_size == 11
# 합성 데모 카드 코드 (우리 스킴)
assert cfg.action_mapping.action_to_card["0"] == "NGC-A001"
assert cfg.action_mapping.action_to_card["8"] == "NGC-A009"
# 공용 카드 코드 (파일 폴백 스냅샷 — 정본은 DB 카탈로그)
assert cfg.action_mapping.action_to_card["0"] == "NGC-001"
assert cfg.action_mapping.action_to_card["8"] == "NGC-009"
def test_base_deep_merge_unit():
@ -90,7 +90,6 @@ def test_base_self_does_not_inherit():
def test_is_registered():
loader = _loader()
assert loader.is_registered("ktcommerce") is True
assert loader.is_registered("imarketkorea") is True
# 미등록 company_id(uuid 등)는 _base 자동 온보딩 대상이라 '등록됨'으로 본다. 빈 키만 미등록.
assert loader.is_registered("00000000-0000-0000-0000-000000000001") is True
@ -99,7 +98,7 @@ def test_is_registered():
def test_no_proprietary_card_codes_or_labels_in_repo():
"""클린룸 가드: 독점 카드 코드/ verbatim 라벨이 로드된 config 에 존재하지 않는다."""
for tid in ("_base", "ktcommerce", "imarketkorea"):
for tid in ("_base", "imarketkorea"):
cfg = _loader().load(tid)
cards = " ".join(cfg.action_mapping.action_to_card.values())
assert "NC26" not in cards # 참고 엔진의 고유 카드 코드

View File

@ -46,7 +46,7 @@ def _snapshot(**over) -> NegotiationSnapshot:
def test_build_state_deterministic_and_in_range():
cfg = _cfg("ktcommerce")
cfg = _cfg("_base")
snap = _snapshot()
s1 = build_state(snap, cfg.state)
s2 = build_state(snap, cfg.state)
@ -65,7 +65,7 @@ def test_encode_index_known_example():
def test_mixed_radix_bijection_over_full_space():
cfg = _cfg("ktcommerce")
cfg = _cfg("_base")
dims = state_dims(cfg.state)
assert dims == [3, 3, 3, 3, 2]
seen = set()
@ -77,17 +77,17 @@ def test_mixed_radix_bijection_over_full_space():
def test_config_injection_changes_classification():
# revenue=20,000,000 원: ktcommerce(th=[10M,50M]) → mid(1), imarketkorea(th=[30M,100M]) → low(0)
# revenue=20,000,000 원: _base(th=[10M,50M]) → mid(1), imarketkorea(th=[30M,100M]) → low(0)
snap = _snapshot(revenue_amount=20_000_000)
kt = build_state(snap, _cfg("ktcommerce").state)
base = build_state(snap, _cfg("_base").state)
imk = build_state(snap, _cfg("imarketkorea").state)
assert kt.revenue_idx == 1
assert base.revenue_idx == 1
assert imk.revenue_idx == 0
assert kt != imk # 같은 입력이 테넌트 config 에 따라 다른 상태
assert base != imk # 같은 입력이 테넌트 config 에 따라 다른 상태
def test_distribution_unknown_code_raises():
cfg = _cfg("ktcommerce")
cfg = _cfg("_base")
snap = _snapshot(distribution_code="Z") # code_map 에 없음
try:
build_state(snap, cfg.state)
@ -97,7 +97,7 @@ def test_distribution_unknown_code_raises():
def test_price_zone_and_partner_buckets():
cfg = _cfg("ktcommerce").state
cfg = _cfg("_base").state
# 제시가 ≤ 앵커가(9900) → 우선협상 구간(0)
assert build_state(_snapshot(input_price=9800), cfg).price_zone_idx == 0
# 제시가 > 앵커가 → 협상 지속 구간(1)
@ -110,28 +110,28 @@ def test_price_zone_and_partner_buckets():
def test_reward_deterministic_and_config_driven():
snap = _snapshot(outcome=NegotiationOutcome.FAILURE, round_number=2)
kt = _cfg("ktcommerce")
base = _cfg("_base")
imk = _cfg("imarketkorea")
kt_rc = RewardCalculator(kt.reward, kt.state) # failure_penalty -0.5
imk_rc = RewardCalculator(imk.reward, imk.state) # failure_penalty -0.7
base_rc = RewardCalculator(base.reward, base.state) # failure_penalty -0.5
imk_rc = RewardCalculator(imk.reward, imk.state) # failure_penalty -0.7
r1 = kt_rc.calculate(snap)
r2 = kt_rc.calculate(snap)
r1 = base_rc.calculate(snap)
r2 = base_rc.calculate(snap)
assert r1 == r2 # 결정론
assert r1.end_reward == -0.5 # config 반영
assert imk_rc.calculate(snap).end_reward == -0.7
# 성공 라운드 보상이 실패보다 크다 (방향성)
success = _snapshot(outcome=NegotiationOutcome.SUCCESS, round_number=0)
assert kt_rc.calculate(success).total > kt_rc.calculate(_snapshot(outcome=NegotiationOutcome.FAILURE, round_number=0)).total
assert base_rc.calculate(success).total > base_rc.calculate(_snapshot(outcome=NegotiationOutcome.FAILURE, round_number=0)).total
def test_action_card_mapper_roundtrip_and_mask():
cfg = _cfg("ktcommerce")
cfg = _cfg("_base")
mapper = ActionCardMapper(cfg.action_mapping)
assert mapper.action_space_size == 11
assert mapper.get_card_id(0) == "NGC-A001"
assert mapper.get_action_id("NGC-A001") == 0
assert mapper.get_card_id(0) == "NGC-001"
assert mapper.get_action_id("NGC-001") == 0
assert mapper.get_card_id(99) is None
# 중복방지 마스킹: 사용한 action 제외

View File

@ -27,12 +27,12 @@ def _registry() -> TenantEngineRegistry:
@pytest.mark.asyncio
async def test_two_tenants_distinct_engines():
reg = _registry()
e1 = await reg.get_engine("ktcommerce")
e1 = await reg.get_engine("_base")
e2 = await reg.get_engine("imarketkorea")
assert e1 is not e2
assert e1.tenant_id == "ktcommerce" and e2.tenant_id == "imarketkorea"
# 서로 다른 카드매핑 (다른 카드셋)
assert e1.mapper.get_card_id(0) == "NGC-A001"
assert e1.tenant_id == "_base" and e2.tenant_id == "imarketkorea"
# 서로 다른 카드매핑 (다른 카드셋 — _base=공용 카탈로그, imk=파일 오버라이드)
assert e1.mapper.get_card_id(0) == "NGC-001"
assert e2.mapper.get_card_id(0) == "NGC-B001"
# 차원
assert e1.state_space_size == 162 and e1.action_space_size == 11
@ -41,8 +41,8 @@ async def test_two_tenants_distinct_engines():
@pytest.mark.asyncio
async def test_engine_cached():
reg = _registry()
a = await reg.get_engine("ktcommerce")
b = await reg.get_engine("ktcommerce")
a = await reg.get_engine("imarketkorea")
b = await reg.get_engine("imarketkorea")
assert a is b # 캐시 — 동일 인스턴스
@ -58,7 +58,7 @@ async def test_concurrent_first_build_once():
return EngineFactory.build(config)
reg = TenantEngineRegistry(loader=TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0), factory=CountingFactory)
results = await asyncio.gather(*[reg.get_engine("ktcommerce") for _ in range(12)])
results = await asyncio.gather(*[reg.get_engine("imarketkorea") for _ in range(12)])
# 모두 같은 인스턴스 + 1회만 조립
assert all(r is results[0] for r in results)
assert builds["n"] == 1
@ -71,7 +71,7 @@ async def test_unregistered_company_id_auto_onboards():
eng = await reg.get_engine("00000000-0000-0000-0000-000000000001")
assert eng.action_space_size == 11 and eng.state_space_size == 162
assert eng.company_id == "00000000-0000-0000-0000-000000000001"
assert reg.is_registered("ktcommerce") is True
assert reg.is_registered("imarketkorea") is True
# 빈 키만 미등록 → KeyError
with pytest.raises(KeyError):
await reg.get_engine("")
@ -80,9 +80,9 @@ async def test_unregistered_company_id_auto_onboards():
@pytest.mark.asyncio
async def test_reload_rebuilds_only_that_tenant():
reg = _registry()
a = await reg.get_engine("ktcommerce")
a = await reg.get_engine("_base")
b = await reg.get_engine("imarketkorea")
reloaded = await reg.reload("ktcommerce")
reloaded = await reg.reload("_base")
assert reloaded is not a # 재조립됨
assert await reg.get_engine("imarketkorea") is b # 타테넌트는 그대로
@ -171,8 +171,8 @@ async def test_demo_tenant_keeps_file_brand(db_engine):
loader=TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0),
company_repo=_FakeCompany(),
)
eng = await reg.get_engine("ktcommerce") # 비-UUID → 조회 안 함
assert eng.config.resources.company_name == "데모상사 A"
eng = await reg.get_engine("imarketkorea") # 비-UUID → 조회 안 함
assert eng.config.resources.company_name == "데모상사 B"
@pytest.mark.asyncio
@ -236,6 +236,6 @@ async def test_middleware_header_missing_unregistered_registered(client):
assert r.json().get("result", {}).get("desc") != "TENANT_NOT_REGISTERED"
# 등록 테넌트 → 미들웨어 통과
r = await client.get("/v1/protected", headers={"X-Tenant-ID": "ktcommerce"})
r = await client.get("/v1/protected", headers={"X-Tenant-ID": "imarketkorea"})
assert r.status_code == 404
assert r.json().get("result", {}).get("desc") != "TENANT_NOT_REGISTERED"

View File

@ -58,8 +58,8 @@ async def test_warm_start_copies_base_with_decayed_visits(db_engine):
@pytest.mark.asyncio
async def test_cold_start_creates_warmstart_version(db_engine):
await _seed_base(A=11) # ktcommerce action_space=11 과 차원 일치해야 warm-start
eng = await _reg().get_engine("ktcommerce") # 활성 버전 없음 → cold-start
await _seed_base(A=11) # imarketkorea action_space=11 과 차원 일치해야 warm-start
eng = await _reg().get_engine("imarketkorea") # 활성 버전 없음 → cold-start
policy, version_id, repo = await QTablePolicyStore.load(eng)
err, ver = await repo.read(lambda s: repo.get_active_version(s))
assert ver.version_name == "v000_warmstart_from_base"
@ -140,7 +140,7 @@ async def test_no_base_returns_none(db_engine):
@pytest.mark.asyncio
async def test_existing_version_not_warmstarted(db_engine):
await _seed_base()
eng = await _reg().get_engine("ktcommerce")
eng = await _reg().get_engine("imarketkorea")
# 첫 로드 → warm-start 버전 생성
await QTablePolicyStore.load(eng)
# 둘째 로드 → 기존 활성 버전 재사용(중복 warm-start 안 함)

View File

@ -7,7 +7,7 @@ reset-learning, reset-all, q-table/{versions,switch,current}, experience-logs, t
import pytest
H = {"X-Tenant-ID": "ktcommerce"}
H = {"X-Tenant-ID": "imarketkorea"}
@pytest.mark.asyncio
@ -97,7 +97,7 @@ async def test_invalidate_and_reset_scoped(client, db_engine):
sid = cr.json()["session_id"]
if cr.json().get("chat_end"):
break
H2 = {"X-Tenant-ID": "imarketkorea"}
H2 = {"X-Tenant-ID": "00000000-0000-0000-0000-0000000000b2"} # 실고객사 모사(자동 온보딩)
sid2 = None
for ui in convo:
cr = await client.post("/v1/chat", headers=H2, json={"session_id": sid2, "user_input": ui})
@ -108,7 +108,7 @@ async def test_invalidate_and_reset_scoped(client, db_engine):
before2 = (await client.get("/v1/experience-logs", headers=H2)).json()["total"]
assert before2 >= 1
# ktcommerce reset-all → imarketkorea 무영향
# imarketkorea reset-all → 타테넌트(H2) 무영향
assert (await client.post("/v1/reset-all", headers=H)).json()["success"]
assert (await client.get("/v1/experience-logs", headers=H)).json()["total"] == 0
assert (await client.get("/v1/experience-logs", headers=H2)).json()["total"] == before2

View File

@ -3,7 +3,7 @@
1. 전체 대화: 서비스안내담당자확인협상품목안내가격협상와일드카드협상완료협상종료(chat_end).
2. 가격협상 턴에서 카드 선택 + 학습(card_id, updated_q).
3. 와일드카드 발동(wild_card_1pct) + 종료 보상(success).
4. 브랜드 치환 테넌트별(데모상사 A/B), 클린룸(스크립트에 KT 흔적 없음).
4. 브랜드 치환(데모상사 B), 클린룸(스크립트에 KT 흔적 없음).
5. 경험로그 적재 + 종료 진행 에러.
6. (HTTP) 헤더로 세션 시작 + 진행.
"""
@ -44,7 +44,7 @@ async def _run(svc, eng, turns):
@pytest.mark.asyncio
async def test_full_conversation_reaches_completion(db_engine):
reset_sessions()
eng = await _reg().get_engine("ktcommerce")
eng = await _reg().get_engine("imarketkorea")
svc = ChatService()
# anchor=9900, target=10000(기본). 11000(>anchor*1.02)→가격협상(카드),
# 10000(anchor<p≤anchor*1.02)→1% 와일드카드→수락하면 협상완료.
@ -59,7 +59,7 @@ async def test_full_conversation_reaches_completion(db_engine):
# 가격협상 카드선택 + 학습
nego = [r for r in out if r.step == "가격협상"]
assert nego and nego[0].card_id and nego[0].card_id.startswith("NGC-A")
assert nego and nego[0].card_id and nego[0].card_id.startswith("NGC-B")
assert nego[0].updated_q is not None
# 와일드카드 발동 (1% 인하)
@ -70,11 +70,11 @@ async def test_full_conversation_reaches_completion(db_engine):
assert done and done[0].reward_total is not None
# 브랜드 치환 + 클린룸
assert "데모상사 A" in out[0].script
assert "데모상사 B" in out[0].script
assert not _KT.search(out[0].script)
# 경험로그 적재
repo = LearningRepository("ktcommerce")
repo = LearningRepository("imarketkorea")
err, cnt = await repo.read(lambda s: repo.count_experience(s))
assert cnt >= 1
@ -87,7 +87,7 @@ async def test_wildcard_1pct_accept_settles_at_offer_price(db_engine):
잡히던 버그 settled_price 인하가(: 19800) 내려와야 한다.
"""
reset_sessions()
eng = await _reg().get_engine("ktcommerce")
eng = await _reg().get_engine("imarketkorea")
svc = ChatService()
# anchor=9900(기본). 10000 은 anchor*1.02(10098) 이내 → wild_card_1pct 발동, offer_1pct=9900.
out = await _run(svc, eng, [None, "확인", "", "확인", "10000", "", "",
@ -129,7 +129,7 @@ def test_card_id_fixed_mapping_and_selection_mask():
"""카드 정리 후: action_id↔카드는 테넌트 매핑으로 고정, 견적 선택은 available_mask 로 걸러진다.
( 인덱스 방식 폐기 selected[action_id] 인덱싱은 견적마다 action_id 의미가 달라져 Q-table 오염.)"""
class _Mapper:
_m = {i: f"NGC-A{i + 1:03d}" for i in range(11)}
_m = {i: f"NGC-B{i + 1:03d}" for i in range(11)}
def get_card_id(self, a):
return self._m.get(a)
def get_action_id(self, num):
@ -139,15 +139,15 @@ def test_card_id_fixed_mapping_and_selection_mask():
action_space_size = 11
eng = _Engine()
session = ChatSession(
session_id="00000000-0000-0000-0000-000000000001", tenant_id="ktcommerce", company_id="ktcommerce",
context={"selected_nego_card_numbers": ["NGC-A003", "NGC-A008"]}, action_space_size=11,
session_id="00000000-0000-0000-0000-000000000001", tenant_id="imarketkorea", company_id="imarketkorea",
context={"selected_nego_card_numbers": ["NGC-B003", "NGC-B008"]}, action_space_size=11,
)
# ① card_id 는 고정 매핑 (선택 리스트 인덱싱 아님)
assert ChatService._card_id_for_action(eng, session, 0) == "NGC-A001"
assert ChatService._card_id_for_action(eng, session, 2) == "NGC-A003"
assert ChatService._card_id_for_action(eng, session, 0) == "NGC-B001"
assert ChatService._card_id_for_action(eng, session, 2) == "NGC-B003"
# ② 선택은 mask 로 — NGC-A003(action 2), NGC-A008(action 7) 만 pickable
# ② 선택은 mask 로 — NGC-B003(action 2), NGC-B008(action 7) 만 pickable
mask = ChatService._selection_mask(eng, session)
assert mask is not None and mask[2] and mask[7]
assert not mask[0] and not mask[5] and mask.sum() == 2
@ -164,13 +164,13 @@ def test_card_id_fixed_mapping_and_selection_mask():
def test_default_1pct_wildcard_still_runs_without_selected_wildcard():
"""1% 인하는 기본 제공 카드라 DB 견적에서 와일드카드를 선택하지 않아도 발동한다."""
cfg = TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0).load("ktcommerce")
cfg = TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0).load("imarketkorea")
repo = ScriptRepository(cfg, _TENANTS_DIR)
engine = ChatEngine(repo, rq_type="재협상")
session = ChatSession(
session_id="00000000-0000-0000-0000-000000000002",
tenant_id="ktcommerce",
company_id="ktcommerce",
tenant_id="imarketkorea",
company_id="imarketkorea",
step="가격협상_확인",
action_space_size=0,
context={
@ -188,13 +188,13 @@ def test_default_1pct_wildcard_still_runs_without_selected_wildcard():
def test_budget_wildcard_requires_selected_wildcard_for_db_context():
"""재원부족 구간은 DB 견적에서 와일드카드를 선택했을 때만 발동한다."""
cfg = TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0).load("ktcommerce")
cfg = TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0).load("imarketkorea")
repo = ScriptRepository(cfg, _TENANTS_DIR)
engine = ChatEngine(repo, rq_type="재협상")
session = ChatSession(
session_id="00000000-0000-0000-0000-000000000003",
tenant_id="ktcommerce",
company_id="ktcommerce",
tenant_id="imarketkorea",
company_id="imarketkorea",
step="가격협상_확인",
action_space_size=0,
context={
@ -214,7 +214,7 @@ def test_budget_wildcard_requires_selected_wildcard_for_db_context():
@pytest.mark.asyncio
async def test_priority_completes_without_wildcard(db_engine):
reset_sessions()
eng = await _reg().get_engine("ktcommerce")
eng = await _reg().get_engine("imarketkorea")
svc = ChatService()
# 첫 제시가가 앵커가(9900) 이하 → 우선협상 → 바로 협상완료(카드/와일드카드 없이)
out = await _run(svc, eng, [None, "확인", "", "확인", "9800", "",
@ -237,7 +237,7 @@ async def test_tenant_brand_isolation(db_engine):
@pytest.mark.asyncio
async def test_advance_after_end_errors(db_engine):
reset_sessions()
eng = await _reg().get_engine("ktcommerce")
eng = await _reg().get_engine("imarketkorea")
svc = ChatService()
out = await _run(svc, eng, [None, "확인", "", "확인", "1000", "",
"협상 내용을 확인했으며, 이의가 없음에 동의합니다."])
@ -248,12 +248,12 @@ async def test_advance_after_end_errors(db_engine):
@pytest.mark.asyncio
async def test_http_chat_start(client):
r = await client.post("/v1/chat", headers={"X-Tenant-ID": "ktcommerce"}, json={"rq_type": "재협상"})
r = await client.post("/v1/chat", headers={"X-Tenant-ID": "imarketkorea"}, json={"rq_type": "재협상"})
assert r.status_code == 200
d = r.json()
assert d["step"] == "서비스안내"
assert d["input_options"] == ["확인"]
assert "데모상사 A" in d["script"]
assert "데모상사 B" in d["script"]
# 헤더 없으면 400
r2 = await client.post("/v1/chat", json={"rq_type": "재협상"})
assert r2.status_code == 400

View File

@ -25,7 +25,7 @@ def _eng():
@pytest.mark.asyncio
async def test_session_persists_and_resumes_across_instances(db_engine):
reg = _eng()
eng = await reg.get_engine("ktcommerce")
eng = await reg.get_engine("imarketkorea")
# 인스턴스 1: 협상 시작 + 몇 턴 진행 (컨텍스트는 DB 조회 — 행이 없으므로 기본값 폴백)
svc1 = ChatService()
@ -51,19 +51,19 @@ async def test_session_persists_and_resumes_across_instances(db_engine):
@pytest.mark.asyncio
async def test_session_company_scoped(db_engine):
reg = _eng()
eng = await reg.get_engine("ktcommerce")
eng = await reg.get_engine("imarketkorea")
svc = ChatService()
r = await svc.chat(eng, Req_Chat())
sid = r.session_id
# 자사(ktcommerce)로는 조회됨
# 자사(imarketkorea)로는 조회됨
assert await ChatSessionRepository(eng.company_id).get(sid) is not None
# 타테넌트(imarketkorea) company_id 로는 조회 안 됨 (격리)
assert await ChatSessionRepository("imarketkorea").get(sid) is None
# 타테넌트 company_id 로는 조회 안 됨 (격리)
assert await ChatSessionRepository("00000000-0000-0000-0000-0000000000aa").get(sid) is None
@pytest.mark.asyncio
async def test_get_none_for_missing(db_engine):
repo = ChatSessionRepository("ktcommerce")
repo = ChatSessionRepository("imarketkorea")
assert await repo.get(None) is None
assert await repo.get("00000000-0000-0000-0000-000000000000") is None

View File

@ -0,0 +1,157 @@
"""Phase 2 표현층 검증 — ScriptNaturalizer (LLM 카드 멘트 자연화).
원칙 검증: LLM 말만 다듬고 숫자는 절대 만들지 않는다.
치환자 보존 성공 경로 치환자 누락/추가 폐기 숫자 폐기
타임아웃/예외 폐기(폴백) 상황 라벨은 정성(수치 미노출)
플로우: llm.enabled=True 카드 멘트가 자연화본으로, 실패 원본으로.
"""
import asyncio
import os
import pytest
from negotiation.chat.service.script_naturalizer import ScriptNaturalizer, build_situation
_TEMPLATE = "제안해 주신 **{input_price}원**, 감사합니다. 한 번 더 검토해 가격을 제안해 주시겠어요?"
def _fake(reply):
"""llm_call 더블 — 고정 응답."""
def call(messages):
return {"script": reply}
return call
@pytest.mark.asyncio
async def test_naturalize_success_preserves_placeholders():
nat = ScriptNaturalizer(llm_call=_fake(
"긍정적으로 검토 중입니다. 다만 **{input_price}원**은 조정 여지가 있어 보입니다. 재제안 부탁드립니다."))
out = await nat.naturalize(_TEMPLATE, situation={"라운드": "초반 조율"}, tone=2, strategy=1)
assert out and "{input_price}" in out and out != _TEMPLATE
@pytest.mark.asyncio
async def test_naturalize_rejects_missing_placeholder():
nat = ScriptNaturalizer(llm_call=_fake("가격 재검토 부탁드립니다.")) # 치환자 삭제됨
assert await nat.naturalize(_TEMPLATE) is None
@pytest.mark.asyncio
async def test_naturalize_rejects_added_placeholder():
nat = ScriptNaturalizer(llm_call=_fake("{input_price}원과 {secret_discount}까지 드리겠습니다."))
assert await nat.naturalize(_TEMPLATE) is None # 원본에 없던 변수 환각
@pytest.mark.asyncio
async def test_naturalize_rejects_new_digits():
nat = ScriptNaturalizer(llm_call=_fake("{input_price}원에서 5% 더 인하해 주시면 즉시 계약하겠습니다."))
assert await nat.naturalize(_TEMPLATE) is None # LLM 이 만든 숫자(5) 금지
@pytest.mark.asyncio
async def test_naturalize_rejects_dropped_emphasis_markers():
"""고객사가 지정한 볼드/색 마커를 LLM 이 떨어뜨리면 폐기 → 원본(스타일 보존) 폴백."""
tmpl = "제안해 주신 **{input_price}원**, {{강조|재검토}} 부탁드립니다."
# 볼드·색 마커를 모두 지운 재작성 → 검증 실패
nat = ScriptNaturalizer(llm_call=_fake("제안해 주신 {input_price}원, 재검토 부탁드립니다."))
assert await nat.naturalize(tmpl) is None
# 마커를 그대로 유지한 재작성 → 통과
nat = ScriptNaturalizer(llm_call=_fake("제시하신 **{input_price}원** 관련, {{강조|재검토}}를 요청드립니다."))
out = await nat.naturalize(tmpl)
assert out and out.count("**") == 2 and "{{강조|" in out
@pytest.mark.asyncio
async def test_naturalize_timeout_falls_back():
def slow(messages):
import time
time.sleep(0.5)
return {"script": _TEMPLATE}
nat = ScriptNaturalizer(llm_call=slow, timeout_seconds=0.05)
assert await nat.naturalize(_TEMPLATE) is None
@pytest.mark.asyncio
async def test_naturalize_exception_falls_back():
def boom(messages):
raise RuntimeError("LLM down")
nat = ScriptNaturalizer(llm_call=boom)
assert await nat.naturalize(_TEMPLATE) is None
def test_build_situation_is_qualitative_only():
"""상황 라벨에 실제 수치가 노출되지 않는다(숫자 환각 차단의 전제)."""
ctx = {"round": 2, "input_price": 10200, "anchor_price": 9900, "target_price": 10000, "item_price": 11000}
s = build_situation(ctx)
assert s["라운드"] == "초반 조율"
assert s["가격구간"] == "목표 상회(추가 인하 필요)"
joined = str(s)
for n in ("10200", "9900", "10000", "11000"):
assert n not in joined # 수치 미노출
@pytest.mark.asyncio
async def test_chat_flow_uses_naturalized_script_when_llm_enabled(db_engine):
"""llm.enabled=True + 자격증명 존재 시 가격협상 카드 멘트가 자연화본(치환 완료)으로 나온다."""
from router.v1.chat.protocol import Req_Chat
from services.chat_service import ChatService, reset_sessions
from tenancy.config_loader import TenantConfigLoader
from tenancy.registry import TenantEngineRegistry
_TENANTS_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "tenants")
reset_sessions()
reg = TenantEngineRegistry(loader=TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0))
eng = await reg.get_engine("imarketkorea")
eng.config.llm.enabled = True # 테넌트 게이트 on
svc = ChatService()
svc._naturalizer = ScriptNaturalizer(llm_call=_fake(
"【자연화】 제시해 주신 **{input_price}원** 잘 검토했습니다. 초반 조율 단계이니 한 걸음 더 부탁드립니다."))
# 자격증명 게이트 우회(테스트 환경에 키가 없어도 동작 검증)
orig_available = ScriptNaturalizer.available
ScriptNaturalizer.available = staticmethod(lambda: True)
try:
sid = None
for ui in [None, "확인", "", "확인", "11000", ""]:
r = await svc.chat(eng, Req_Chat(session_id=sid, user_input=ui))
sid = r.session_id
assert r.step == "가격협상" and r.card_id
assert r.script.startswith("【자연화】") # LLM 재작성본 사용
assert "11000" in r.script # 치환은 엔진이 수행(숫자 정확)
assert "{input_price}" not in r.script # 치환 완료
finally:
ScriptNaturalizer.available = orig_available
eng.config.llm.enabled = False
@pytest.mark.asyncio
async def test_chat_flow_falls_back_to_template_on_llm_failure(db_engine):
"""LLM 실패 시 카드 원본 멘트로 폴백 — 협상은 절대 멈추지 않는다."""
from router.v1.chat.protocol import Req_Chat
from services.chat_service import ChatService, reset_sessions
from tenancy.config_loader import TenantConfigLoader
from tenancy.registry import TenantEngineRegistry
_TENANTS_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "tenants")
reset_sessions()
reg = TenantEngineRegistry(loader=TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0))
eng = await reg.get_engine("imarketkorea")
eng.config.llm.enabled = True
def boom(messages):
raise RuntimeError("LLM down")
svc = ChatService()
svc._naturalizer = ScriptNaturalizer(llm_call=boom)
orig_available = ScriptNaturalizer.available
ScriptNaturalizer.available = staticmethod(lambda: True)
try:
sid = None
for ui in [None, "확인", "", "확인", "11000", ""]:
r = await svc.chat(eng, Req_Chat(session_id=sid, user_input=ui))
sid = r.session_id
assert r.step == "가격협상" and r.card_id
assert r.script and "11000" in r.script # 원본 템플릿 + 치환으로 정상 응답
finally:
ScriptNaturalizer.available = orig_available
eng.config.llm.enabled = False

View File

@ -20,7 +20,7 @@ _TENANTS_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__fi
_FORBIDDEN = re.compile(r"kt\s*commerce|케이티|커머스|nego-?wiz", re.IGNORECASE)
def _repo(tenant_id="ktcommerce"):
def _repo(tenant_id="imarketkorea"):
cfg = TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0).load(tenant_id)
return ScriptRepository(cfg, _TENANTS_DIR)
@ -65,11 +65,9 @@ def test_cleanroom_no_proprietary_brand_in_any_resource():
def test_brand_substitution_per_tenant():
kt = _repo("ktcommerce").get_step("서비스안내", "재협상")
im = _repo("imarketkorea").get_step("서비스안내", "재협상")
assert "데모상사 A" in kt["script"] and "Negosium" in kt["script"]
assert "데모상사 B" in im["script"]
assert "{company_name}" not in kt["script"] # 치환 완료
assert "데모상사 B" in im["script"] and "Negosium" in im["script"]
assert "{company_name}" not in im["script"] # 치환 완료
def test_variable_substitution_and_missing_kept():
@ -103,12 +101,16 @@ async def test_resolve_card_script_file_mode_default():
repo = _repo()
assert repo._config.cards.source_type == "file"
# action 0 파일 멘트가 변수 치환되어 나온다 (DB 무접근)
out = await repo.resolve_card_script(0, "NGC-A001", {"input_price": 9800})
out = await repo.resolve_card_script(0, "NGC-B001", {"input_price": 9800})
assert out and "9800" in out
class _FakeCardRepo:
"""ICardScriptRepository 더블 — DB 없이 카드코드→멘트 매핑만 흉내(세션 인자 무시)."""
from negotiation.cards.ports.card_script_port import ICardScriptRepository
class _FakeCardRepo(ICardScriptRepository):
"""ICardScriptRepository 더블 — DB 없이 카드코드→멘트 매핑만 흉내(세션 인자 무시).
포트 상속으로 get_card_by_number 기본 구현(메타 None) 물려받는다."""
def __init__(self, by_number: dict):
self._by = by_number
@ -121,9 +123,9 @@ class _FakeCardRepo:
@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 = TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0).load("imarketkorea")
cfg.cards.source_type = "backoffice_db"
fake = _FakeCardRepo({"NGC-A001": "DB 편집 멘트 **{input_price}원** 검토 중입니다."})
fake = _FakeCardRepo({"NGC-B001": "DB 편집 멘트 **{input_price}원** 검토 중입니다."})
repo = ScriptRepository(cfg, _TENANTS_DIR, card_repo=fake)
# execute_lambda 를 세션 없이 콜백만 실행하도록 대체(순수 단위검증)
@ -132,14 +134,14 @@ async def test_resolve_card_script_db_mode_prefers_db(monkeypatch):
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})
out = await repo.resolve_card_script(0, "NGC-B001", {"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 = TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0).load("imarketkorea")
cfg.cards.source_type = "backoffice_db"
fake = _FakeCardRepo({}) # DB 미보유
repo = ScriptRepository(cfg, _TENANTS_DIR, card_repo=fake)
@ -149,14 +151,14 @@ async def test_resolve_card_script_db_mode_falls_back_to_file(monkeypatch):
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})
out = await repo.resolve_card_script(0, "NGC-B001", {"input_price": 9800})
assert out and "9800" in out # 파일 폴백 멘트
@pytest.mark.asyncio
async def test_resolve_card_script_prefer_db_for_selected_cards(monkeypatch):
"""견적에서 선택된 백오피스 카드 번호는 file 모드여도 DB 멘트를 우선한다."""
cfg = TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0).load("ktcommerce")
cfg = TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0).load("imarketkorea")
assert cfg.cards.source_type == "file"
fake = _FakeCardRepo({"2": "선택 카드 DB 멘트 **{input_price}원**"})
repo = ScriptRepository(cfg, _TENANTS_DIR, card_repo=fake)

View File

@ -9,9 +9,9 @@
실행:
cd agent
APP_ENV=local python -m tools.console_demo --tenant ktcommerce # 기본 시나리오
APP_ENV=local python -m tools.console_demo --tenant imarketkorea --no-db # DB 로깅 없이
APP_ENV=local python -m tools.console_demo --tenant ktcommerce --interactive
APP_ENV=local python -m tools.console_demo --tenant imarketkorea # 기본 시나리오
APP_ENV=local python -m tools.console_demo --tenant imarketkorea --no-db # DB 로깅 없이
APP_ENV=local python -m tools.console_demo --tenant imarketkorea --interactive
"""
import argparse
@ -74,7 +74,7 @@ def _scenario():
async def run(tenant_id: str, use_db: bool, interactive: bool):
loader = TenantConfigLoader(tenants_dir=_TENANTS_DIR, cache_ttl_seconds=0)
if not loader.is_registered(tenant_id):
print(f"[!] 미등록 테넌트: {tenant_id}. 등록된 테넌트: ktcommerce, imarketkorea, _base")
print(f"[!] 미등록 테넌트: {tenant_id}. 등록된 테넌트: imarketkorea, _base")
return
registry = TenantEngineRegistry(loader=loader)
engine = await registry.get_engine(tenant_id)
@ -172,7 +172,7 @@ def _interactive_turns():
def main():
ap = argparse.ArgumentParser(description="협상 의사결정 루프 콘솔 데모 (P0~P4)")
ap.add_argument("--tenant", default="ktcommerce", help="테넌트 id (ktcommerce|imarketkorea)")
ap.add_argument("--tenant", default="imarketkorea", help="테넌트 id (imarketkorea|_base)")
ap.add_argument("--no-db", action="store_true", help="DB 로깅 비활성화")
ap.add_argument("--interactive", action="store_true", help="턴마다 직접 입력")
args = ap.parse_args()