[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:
parent
b368a19f79
commit
1682481b01
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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' 경고 제거)."""
|
||||
|
||||
@ -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 # 협상 라운드 상한
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
120
agent/negotiation/cards/domain/tactics.py
Normal file
120
agent/negotiation/cards/domain/tactics.py
Normal 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
|
||||
@ -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
|
||||
|
||||
@ -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 = (
|
||||
|
||||
@ -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"))
|
||||
|
||||
149
agent/negotiation/chat/service/input_interpreter.py
Normal file
149
agent/negotiation/chat/service/input_interpreter.py
Normal 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 → 원문 폴백(엔진 재질문)
|
||||
@ -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,
|
||||
|
||||
144
agent/negotiation/chat/service/script_naturalizer.py
Normal file
144
agent/negotiation/chat/service/script_naturalizer.py
Normal 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
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 액션)
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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))
|
||||
|
||||
|
||||
@ -15,5 +15,7 @@
|
||||
"가격협상_와일드": "가격협상",
|
||||
"협상완료": "협상종료",
|
||||
"협상실패": "협상종료",
|
||||
"협상종료": "협상종료"
|
||||
"협상종료": "협상종료",
|
||||
"가격협상_카운터": "가격협상",
|
||||
"wild_card_dynamic": "가격협상"
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -78,7 +78,7 @@ cards:
|
||||
connection: {}
|
||||
|
||||
llm:
|
||||
enabled: false
|
||||
enabled: true
|
||||
|
||||
resources:
|
||||
language: ko
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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"
|
||||
@ -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)
|
||||
|
||||
154
agent/tests/test_card_selection_e2e.py
Normal file
154
agent/tests/test_card_selection_e2e.py
Normal 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)) # 세션 내 중복 없음
|
||||
315
agent/tests/test_card_tactics.py
Normal file
315
agent/tests/test_card_tactics.py
Normal 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)
|
||||
@ -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
|
||||
|
||||
140
agent/tests/test_decision_rules.py
Normal file
140
agent/tests/test_decision_rules.py
Normal 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
|
||||
@ -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 격리
|
||||
|
||||
@ -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"]
|
||||
|
||||
161
agent/tests/test_input_interpreter.py
Normal file
161
agent/tests/test_input_interpreter.py
Normal 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
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 # 참고 엔진의 고유 카드 코드
|
||||
|
||||
@ -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 제외
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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 안 함)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
157
agent/tests/test_script_naturalizer.py
Normal file
157
agent/tests/test_script_naturalizer.py
Normal 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
|
||||
@ -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)
|
||||
|
||||
@ -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()
|
||||
|
||||
Loading…
Reference in New Issue
Block a user