IMK QA 2건(BB9A 카드 중복·8AB0 중간값 오계산)의 근본 원인이 전술 하드코딩(_TACTICS 번호 매칭)이라 전술을 데이터로 옮기고, 발동을 유효성 검사로 바꿨다. 전술 정본 = 카드 스크립트의 마지막 가격 변수(파싱), 문장으로 알 수 없는 운영 규칙(closing·min_round)만 card.*.tactic JSONB. 세션 시작 시 card_specs 스냅샷 박제. 발동 유효성(하나라도 걸리면 그 라운드 미발동 — 클램프 폐지): 목표가 초과 / 협력사 제시가 이상 / 당사 직전 제안 미만(역행 금지, IMK 논의) / 재료 결측 / 필수 변수 결측(시장가 카드 requires — 토큰 노출 방지) / 이미 쓴 카드(played_card_numbers 공용 이력) - agent: 와일드=비종결·종결=전용 풀 분리(같은 카드 2회 구조적 차단), 발동 시 자기 제안가 기록(절충가 수렴), 진입 존 프로브(빈 덱 재사용 교착 방지), 낼 카드 전무 시 소진→종결, 무효 금액 카드는 설득 폴백도 금지(playable), 에디터 anchor_price 별칭 등록, 에러 재렌더 변수 치환 - backend: 카드 사용 기록을 step 휴리스틱→번호 prefix 판정(종결 발동 card:null 누락 해소) - negodata: 카드 상세 "협상 전술" 섹션(제시 가격 파싱 표시·종결 전용·최소 라운드) + tactic API 배선 - postgres-init: tactic 컬럼·시드(WC-03/05 closing, WC-04 min_round 2), 멱등 alter 로 dev 정본화 (번호 WC-0x 정규화, WC-01·03·NGC-010 구멘트 교체, WC-05 변수 middle_price 교정) 검증: agent 178 통과 · 시나리오 하네스 14케이스(BB9A·8AB0·역행 실수치 재현) · 랜덤 퍼즈 50협상 불변식 위반 0 (불변식: 카드 중복 금지·종결 카드 자리·타결가≤목표가·표시가=타결가·토큰 잔존 금지·종료 보장)
155 lines
7.2 KiB
Python
155 lines
7.2 KiB
Python
"""협상카드 선택 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 == 9 # 카탈로그 9장(NGC-006·009 소프트삭제)
|
|
|
|
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)) # 세션 내 중복 없음
|