diff --git a/agent/README.md b/agent/README.md index 1f587c2..0df5b68 100644 --- a/agent/README.md +++ b/agent/README.md @@ -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 diff --git a/agent/config/server_configs.py b/agent/config/server_configs.py index ad3717c..3e7a3e5 100644 --- a/agent/config/server_configs.py +++ b/agent/config/server_configs.py @@ -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) diff --git a/agent/conftest.py b/agent/conftest.py index 4215f6e..0d31922 100644 --- a/agent/conftest.py +++ b/agent/conftest.py @@ -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' 경고 제거).""" diff --git a/agent/eval_harness/configs/exp_default.yaml b/agent/eval_harness/configs/exp_default.yaml index facf8ab..0fde8d6 100644 --- a/agent/eval_harness/configs/exp_default.yaml +++ b/agent/eval_harness/configs/exp_default.yaml @@ -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 # 협상 라운드 상한 diff --git a/agent/eval_harness/runner.py b/agent/eval_harness/runner.py index 1d6f516..f64c503 100644 --- a/agent/eval_harness/runner.py +++ b/agent/eval_harness/runner.py @@ -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() diff --git a/agent/negotiation/cards/adapters/card_script_db.py b/agent/negotiation/cards/adapters/card_script_db.py index 761599d..8c0ee8a 100644 --- a/agent/negotiation/cards/adapters/card_script_db.py +++ b/agent/negotiation/cards/adapters/card_script_db.py @@ -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 diff --git a/agent/negotiation/cards/domain/tactics.py b/agent/negotiation/cards/domain/tactics.py new file mode 100644 index 0000000..7f652ae --- /dev/null +++ b/agent/negotiation/cards/domain/tactics.py @@ -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 diff --git a/agent/negotiation/cards/ports/card_script_port.py b/agent/negotiation/cards/ports/card_script_port.py index 28cf4a9..dc06ea5 100644 --- a/agent/negotiation/cards/ports/card_script_port.py +++ b/agent/negotiation/cards/ports/card_script_port.py @@ -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 diff --git a/agent/negotiation/chat/infra/repository/nego_context_crud.py b/agent/negotiation/chat/infra/repository/nego_context_crud.py index 9605ef3..c99f0ff 100644 --- a/agent/negotiation/chat/infra/repository/nego_context_crud.py +++ b/agent/negotiation/chat/infra/repository/nego_context_crud.py @@ -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 = ( diff --git a/agent/negotiation/chat/service/chat_engine.py b/agent/negotiation/chat/service/chat_engine.py index a0bcb99..343aa34 100644 --- a/agent/negotiation/chat/service/chat_engine.py +++ b/agent/negotiation/chat/service/chat_engine.py @@ -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")) diff --git a/agent/negotiation/chat/service/input_interpreter.py b/agent/negotiation/chat/service/input_interpreter.py new file mode 100644 index 0000000..dbf021f --- /dev/null +++ b/agent/negotiation/chat/service/input_interpreter.py @@ -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 → 원문 폴백(엔진 재질문) diff --git a/agent/negotiation/chat/service/negotiation_context_loader.py b/agent/negotiation/chat/service/negotiation_context_loader.py index afbdb90..5855c07 100644 --- a/agent/negotiation/chat/service/negotiation_context_loader.py +++ b/agent/negotiation/chat/service/negotiation_context_loader.py @@ -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, diff --git a/agent/negotiation/chat/service/script_naturalizer.py b/agent/negotiation/chat/service/script_naturalizer.py new file mode 100644 index 0000000..8134c82 --- /dev/null +++ b/agent/negotiation/chat/service/script_naturalizer.py @@ -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 diff --git a/agent/negotiation/chat/service/script_repository.py b/agent/negotiation/chat/service/script_repository.py index c5c78f0..3778a57 100644 --- a/agent/negotiation/chat/service/script_repository.py +++ b/agent/negotiation/chat/service/script_repository.py @@ -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) diff --git a/agent/negotiation/policies/base.py b/agent/negotiation/policies/base.py index fbb4cfa..ffd3cf8 100644 --- a/agent/negotiation/policies/base.py +++ b/agent/negotiation/policies/base.py @@ -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 diff --git a/agent/negotiation/policies/qtable_policy.py b/agent/negotiation/policies/qtable_policy.py index d0622d5..f6b8a17 100644 --- a/agent/negotiation/policies/qtable_policy.py +++ b/agent/negotiation/policies/qtable_policy.py @@ -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 액션) diff --git a/agent/router/v1/chat/protocol.py b/agent/router/v1/chat/protocol.py index 166c313..65c2d4e 100644 --- a/agent/router/v1/chat/protocol.py +++ b/agent/router/v1/chat/protocol.py @@ -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): diff --git a/agent/services/chat_service.py b/agent/services/chat_service.py index 3612c9e..77719e9 100644 --- a/agent/services/chat_service.py +++ b/agent/services/chat_service.py @@ -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, diff --git a/agent/tenancy/company_profile_repo.py b/agent/tenancy/company_profile_repo.py index ff9bc14..386af3c 100644 --- a/agent/tenancy/company_profile_repo.py +++ b/agent/tenancy/company_profile_repo.py @@ -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) diff --git a/agent/tenancy/config.py b/agent/tenancy/config.py index 0028242..c6cd8ff 100644 --- a/agent/tenancy/config.py +++ b/agent/tenancy/config.py @@ -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)) diff --git a/agent/tenants/_base/resources/client_step_mapping.json b/agent/tenants/_base/resources/client_step_mapping.json index 4655848..6cee9fc 100644 --- a/agent/tenants/_base/resources/client_step_mapping.json +++ b/agent/tenants/_base/resources/client_step_mapping.json @@ -15,5 +15,7 @@ "가격협상_와일드": "가격협상", "협상완료": "협상종료", "협상실패": "협상종료", - "협상종료": "협상종료" -} + "협상종료": "협상종료", + "가격협상_카운터": "가격협상", + "wild_card_dynamic": "가격협상" +} \ No newline at end of file diff --git a/agent/tenants/_base/resources/scripts_renegotiation.json b/agent/tenants/_base/resources/scripts_renegotiation.json index 6409cb5..17c741b 100644 --- a/agent/tenants/_base/resources/scripts_renegotiation.json +++ b/agent/tenants/_base/resources/scripts_renegotiation.json @@ -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 } -} +} \ No newline at end of file diff --git a/agent/tenants/_base/resources/scripts_wildcard.json b/agent/tenants/_base/resources/scripts_wildcard.json index fe836a6..4b5c6ea 100644 --- a/agent/tenants/_base/resources/scripts_wildcard.json +++ b/agent/tenants/_base/resources/scripts_wildcard.json @@ -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 } -} +} \ No newline at end of file diff --git a/agent/tenants/_base/tenant.yaml b/agent/tenants/_base/tenant.yaml index 7766794..d53c6cb 100644 --- a/agent/tenants/_base/tenant.yaml +++ b/agent/tenants/_base/tenant.yaml @@ -78,7 +78,7 @@ cards: connection: {} llm: - enabled: false + enabled: true resources: language: ko diff --git a/agent/tenants/imarketkorea/tenant.yaml b/agent/tenants/imarketkorea/tenant.yaml index d89d50f..a32162a 100644 --- a/agent/tenants/imarketkorea/tenant.yaml +++ b/agent/tenants/imarketkorea/tenant.yaml @@ -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: diff --git a/agent/tenants/ktcommerce/tenant.yaml b/agent/tenants/ktcommerce/tenant.yaml deleted file mode 100644 index b5a0660..0000000 --- a/agent/tenants/ktcommerce/tenant.yaml +++ /dev/null @@ -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" diff --git a/agent/tests/test_agent_integration.py b/agent/tests/test_agent_integration.py index 5fd1a21..0e8738f 100644 --- a/agent/tests/test_agent_integration.py +++ b/agent/tests/test_agent_integration.py @@ -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) diff --git a/agent/tests/test_card_selection_e2e.py b/agent/tests/test_card_selection_e2e.py new file mode 100644 index 0000000..4cc1f36 --- /dev/null +++ b/agent/tests/test_card_selection_e2e.py @@ -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)) # 세션 내 중복 없음 diff --git a/agent/tests/test_card_tactics.py b/agent/tests/test_card_tactics.py new file mode 100644 index 0000000..f1fb775 --- /dev/null +++ b/agent/tests/test_card_tactics.py @@ -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) diff --git a/agent/tests/test_context_loader.py b/agent/tests/test_context_loader.py index 9524c99..c72eaa6 100644 --- a/agent/tests/test_context_loader.py +++ b/agent/tests/test_context_loader.py @@ -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 diff --git a/agent/tests/test_decision_rules.py b/agent/tests/test_decision_rules.py new file mode 100644 index 0000000..5e475fa --- /dev/null +++ b/agent/tests/test_decision_rules.py @@ -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 diff --git a/agent/tests/test_h1_qtable_policy.py b/agent/tests/test_h1_qtable_policy.py index c04f075..c8d6bd6 100644 --- a/agent/tests/test_h1_qtable_policy.py +++ b/agent/tests/test_h1_qtable_policy.py @@ -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 격리 diff --git a/agent/tests/test_h5_harness.py b/agent/tests/test_h5_harness.py index 29437c0..a9a694c 100644 --- a/agent/tests/test_h5_harness.py +++ b/agent/tests/test_h5_harness.py @@ -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"] diff --git a/agent/tests/test_input_interpreter.py b/agent/tests/test_input_interpreter.py new file mode 100644 index 0000000..5726e54 --- /dev/null +++ b/agent/tests/test_input_interpreter.py @@ -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 diff --git a/agent/tests/test_negotiation_step_preview.py b/agent/tests/test_negotiation_step_preview.py index afb8b89..2e0aae8 100644 --- a/agent/tests/test_negotiation_step_preview.py +++ b/agent/tests/test_negotiation_step_preview.py @@ -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" diff --git a/agent/tests/test_p0_scaffold.py b/agent/tests/test_p0_scaffold.py index 8866489..8c45e68 100644 --- a/agent/tests/test_p0_scaffold.py +++ b/agent/tests/test_p0_scaffold.py @@ -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 diff --git a/agent/tests/test_p1_tenant_config.py b/agent/tests/test_p1_tenant_config.py index 6369f95..aa157f5 100644 --- a/agent/tests/test_p1_tenant_config.py +++ b/agent/tests/test_p1_tenant_config.py @@ -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 # 참고 엔진의 고유 카드 코드 diff --git a/agent/tests/test_p2_state_reward_mapper.py b/agent/tests/test_p2_state_reward_mapper.py index 2713462..28b8fa0 100644 --- a/agent/tests/test_p2_state_reward_mapper.py +++ b/agent/tests/test_p2_state_reward_mapper.py @@ -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 제외 diff --git a/agent/tests/test_p4_registry_middleware.py b/agent/tests/test_p4_registry_middleware.py index 1c399cd..3f53cdd 100644 --- a/agent/tests/test_p4_registry_middleware.py +++ b/agent/tests/test_p4_registry_middleware.py @@ -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" diff --git a/agent/tests/test_p5_warmstart.py b/agent/tests/test_p5_warmstart.py index c70407f..73f8e66 100644 --- a/agent/tests/test_p5_warmstart.py +++ b/agent/tests/test_p5_warmstart.py @@ -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 안 함) diff --git a/agent/tests/test_p7_apis.py b/agent/tests/test_p7_apis.py index 6519b84..c387ba4 100644 --- a/agent/tests/test_p7_apis.py +++ b/agent/tests/test_p7_apis.py @@ -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 diff --git a/agent/tests/test_p7_chat.py b/agent/tests/test_p7_chat.py index 82ac5c3..f2c0a2b 100644 --- a/agent/tests/test_p7_chat.py +++ b/agent/tests/test_p7_chat.py @@ -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
= 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 diff --git a/agent/tests/test_p8_session_db.py b/agent/tests/test_p8_session_db.py index f7b0f8a..380736d 100644 --- a/agent/tests/test_p8_session_db.py +++ b/agent/tests/test_p8_session_db.py @@ -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 diff --git a/agent/tests/test_script_naturalizer.py b/agent/tests/test_script_naturalizer.py new file mode 100644 index 0000000..ef8373d --- /dev/null +++ b/agent/tests/test_script_naturalizer.py @@ -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 diff --git a/agent/tests/test_scripts_resources.py b/agent/tests/test_scripts_resources.py index 418e011..d222225 100644 --- a/agent/tests/test_scripts_resources.py +++ b/agent/tests/test_scripts_resources.py @@ -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) diff --git a/agent/tools/console_demo.py b/agent/tools/console_demo.py index 26c5a43..2b2abc8 100644 --- a/agent/tools/console_demo.py +++ b/agent/tools/console_demo.py @@ -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()