o2o-negosium-original/backend/tests/test_anchoring_chat.py
hbyang ed175c5b65 [feat] agent: Req_Chat 슬림화 — 협상 컨텍스트를 DB 조회로 전환 + CRUD 계층 도입
- Req_Chat 을 session_id/user_input/client_step 3필드로 축소 — rq_type·목표가·앵커·품목가·
  매출액·유통코드·파트너 유형·수용률 필드 전부 제거
- NegotiationContextLoader 신설: 세션 시작 시 공유 DB 1회 조회로 컨텍스트 확정
  · rq_type = sessions.qt_type ({1,3}→재협상 / {2,4}→재견적)
  · anchor = sessions.anchoring_price(박제) — NULL 이면 무할인 폴백 anchor=target (v1.2 정책 승계)
  · 매출액 = suppliers.total_revenue(KTC 미러), 유통코드 = quotations.supplier_type 매핑
  · 파트너 유형 = 상품별 distinct supplier 수 → PartnerType enum(0=NONE/1=SINGLE/2=MULTIPLE)
- 가격 수용률은 세션 내 동적 계산: max(0, (첫 제시가−현재가)/첫 제시가)
- DB 쿼리를 backend crud 패턴으로 분리: INegoContextCRUD(ABC)+NegoContextCRUD,
  IChatSessionRepository 인터페이스 추가 (테스트 더블 주입 가능)
- 와일드카드 1% 수락 시 합의가=offer_1pct 반영 + Res_Chat.settled_price 신설 —
  backend 요약/입찰가가 이를 최우선 사용 (19,800원 수락이 20,000원으로 기록되던 버그 수정)
- backend: agent 전송 바디 3필드로 축소, 앵커/파트너 조회 메서드 제거,
  test_anchoring_chat 을 새 구조로 재작업(박제 소비/폴백 검증은 agent 테스트로 이관)
- 데모 페이지(/demo·negotiation_demo.html) 제거 — 컨텍스트 주입 경로 폐지로 무의미
- 테스트: agent 83/83, backend 57/57 (컨텍스트 로더 실데이터 왕복 4종 + CRUD 더블 검증 포함)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 09:44:21 +09:00

199 lines
10 KiB
Python

"""앵커링 채팅 연동 테스트 — 박제값 소비 / 무할인 폴백 / 마지막 제시가(가격 흔적) 기록.
배치·조정 로직은 schedules/anchoring/tests 소관 — 여기는 backend 채팅 경로만 검증한다.
실제 agent 대신 결정론적 더블(_AnchorAgent)을 주입하고, dev negosium_db 에 전용 행만 시드/정리한다.
(규범: schedules/anchoring/docs/개발용.md §9.2 — 표본 기준은 노출이 아니라 "가격을 써냈는가")
"""
import uuid
import bcrypt
import pytest
import pytest_asyncio
from sqlalchemy import text
from services.agent_client import AgentTurn, IAgentClient, get_agent_client
TEST_LOGIN_ID = "pytest_anchor_user"
TEST_PW = "pytest1234"
TEST_SUPPLIER_NAME = "파이테스트앵커공급사"
MARK = "PYTESTANCHOR-"
TARGET = 100_000
ANCHOR = 99_000 # negodata 가 생성 시 박제하는 값(rate 10‰) 시뮬레이션
def _parse_price(text_):
digits = "".join(ch for ch in (text_ or "") if ch.isdigit())
return int(digits) if digits else None
class _AnchorAgent(IAgentClient):
"""결정론적 더블: 서비스안내(오프닝) → 가격 입력 요청 → 합의 종료.
앵커보다 높은 가격이면 같은 step 을 반복(마지막 제시가 덮어쓰기 검증용).
앵커 해석(박제값 소비/무할인 폴백)은 agent 가 DB 에서 직접 수행하도록 이관됐다
(NegotiationContextLoader — agent tests/test_context_loader.py 가 검증). 여기 더블은
자체 앵커 상수로 수락 여부만 판정하고, backend 가 보내는 ctx.target_price 를 관찰한다.
"""
def __init__(self, anchor: int = ANCHOR):
self.anchor = anchor
self.seen_targets: list[int] = []
async def chat(self, session_id, user_input, ctx) -> AgentTurn:
self.seen_targets.append(ctx.target_price)
sid = session_id or "fake-session"
if user_input is None: # 오프닝(턴0)
return AgentTurn(session_id=sid, step="서비스안내", client_step="서비스안내",
script="협상을 시작하시겠어요?", input_mode="confirm",
input_options=["네, 시작할게요"])
if ctx.client_step == "서비스안내":
return AgentTurn(session_id=sid, step="기존가격제시", client_step="기존가격제시",
script=f"저희가 제안드리는 첫 목표 가격은 {self.anchor}원입니다. "
f"제안하실 가격을 입력해 주세요.", input_mode="price")
price = _parse_price(user_input)
if price is not None and price <= self.anchor:
return AgentTurn(session_id=sid, step="협상종료", client_step="협상종료",
script=f"{price:,}원으로 합의되었습니다.", chat_end=True, outcome="success")
return AgentTurn(session_id=sid, step="기존가격제시", client_step="기존가격제시",
script="조금 더 조정된 가격을 부탁드립니다.", input_mode="price")
@pytest.fixture(autouse=True)
def _fake_agent():
from router.router import app
agent = _AnchorAgent()
app.dependency_overrides[get_agent_client] = lambda: agent
yield agent
app.dependency_overrides.pop(get_agent_client, None)
@pytest_asyncio.fixture
async def anchor_seed(db_engine):
"""공급사+유저 + 세션 2건: A(앵커 박제됨 — 정상 경로) / N(박제 NULL — 폴백 경로)."""
supplier_id = uuid.uuid4()
pw_hash = bcrypt.hashpw(TEST_PW.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
sids = {}
async def _cleanup(conn):
await conn.execute(text(f"DELETE FROM negotiation.chats WHERE session_id IN (SELECT session_id FROM negotiation.sessions WHERE qt_number LIKE '{MARK}%')"))
await conn.execute(text(f"DELETE FROM negotiation.sessions WHERE qt_number LIKE '{MARK}%'"))
await conn.execute(text(f"DELETE FROM quotation.quotations WHERE number LIKE '{MARK}%'"))
await conn.execute(text(f"DELETE FROM partner.items WHERE code LIKE '{MARK}%'"))
await conn.execute(text("DELETE FROM supplier.supplier_users WHERE id = :id"), {"id": TEST_LOGIN_ID})
await conn.execute(text("DELETE FROM partner.suppliers WHERE name = :n"), {"n": TEST_SUPPLIER_NAME})
async with db_engine.begin() as conn:
await _cleanup(conn)
await conn.execute(
text("INSERT INTO partner.suppliers (supplier_id, company_id, user_id, name) VALUES (:sid, gen_random_uuid(), gen_random_uuid(), :name)"),
{"sid": supplier_id, "name": TEST_SUPPLIER_NAME},
)
await conn.execute(
text("INSERT INTO supplier.supplier_users (supplier_id, id, password, name, last_accessed_at, status, role) "
"VALUES (:sid, :id, :pw, '앵커담당자', now(), 1, 1)"),
{"sid": supplier_id, "id": TEST_LOGIN_ID, "pw": pw_hash},
)
for code, anchor, rate in (("A", ANCHOR, 10), ("N", None, None)):
item_id, qt_id, session_id = uuid.uuid4(), uuid.uuid4(), uuid.uuid4()
sids[code] = session_id
await conn.execute(
text("INSERT INTO partner.items (item_id, company_id, user_id, name, code, price) "
"VALUES (:iid, gen_random_uuid(), gen_random_uuid(), :name, :code, 100000)"),
{"iid": item_id, "name": f"앵커상품 {code}", "code": f"{MARK}{code}"},
)
await conn.execute(
text("INSERT INTO quotation.quotations (qt_id, user_id, qt_setting_id, version_id, name, number, type, status, start_time, end_time, supplier_type) "
"VALUES (:qid, gen_random_uuid(), gen_random_uuid(), gen_random_uuid(), :name, :num, 1, 2, now(), now() + interval '2 hours', 1)"),
{"qid": qt_id, "name": f"앵커견적 {code}", "num": f"{MARK}{code}"},
)
await conn.execute(
text("INSERT INTO negotiation.sessions "
"(session_id, quotation_id, item_id, supplier_id, qt_number, qt_round, qt_type, "
" target_price, anchoring_price, anchoring_value, status, end_time) "
"VALUES (:sesid, :qid, :iid, :sup, :qtn, 1, 1, :tp, :ap, :rate, 2, now() + interval '2 hours')"),
{"sesid": session_id, "qid": qt_id, "iid": item_id, "sup": supplier_id,
"qtn": f"{MARK}{code}", "tp": TARGET, "ap": anchor, "rate": rate},
)
yield {"sids": sids}
async with db_engine.begin() as conn:
await _cleanup(conn)
async def _login_token(client):
r = await client.post("/v1/auth/login", json={"id": TEST_LOGIN_ID, "pw": TEST_PW})
return r.json()["access_token"]
def _h(token):
return {"Authorization": f"Bearer {token}"}
async def _messages(client, token, sid):
return await client.get(f"/v1/negotiation/sessions/{sid}/chat/messages", headers=_h(token))
async def _send(client, token, sid, user_input, user_input_type=None):
body = {"user_input": user_input, "user_input_type": user_input_type}
return await client.post(f"/v1/negotiation/sessions/{sid}/chat/send", headers=_h(token), json=body)
async def _anchor_columns(db_engine, session_id):
async with db_engine.begin() as conn:
row = (await conn.execute(text(
"SELECT anchoring_price, anchoring_value, last_offer_price, bid_price "
"FROM negotiation.sessions WHERE session_id = :sid"), {"sid": session_id})).one()
return row
# ── 정상 경로: 박제값 소비 + 마지막 제시가 기록(가격 흔적) ──
async def test_snapshot_consumed_and_last_offer_recorded(client, db_engine, anchor_seed, _fake_agent):
sid = str(anchor_seed["sids"]["A"])
token = await _login_token(client)
r = await _messages(client, token, sid) # 오프닝 seed
assert r.status_code == 200
r = await _send(client, token, sid, "네, 시작할게요") # → 가격 입력 요청 (아직 가격 흔적 없음)
assert r.status_code == 200 and r.json()["message"]["step"] == "기존가격제시"
row = await _anchor_columns(db_engine, sid)
assert row.last_offer_price is None
assert _fake_agent.seen_targets[-1] == TARGET # backend 로컬 컨텍스트(target) 전달 확인
r = await _send(client, token, sid, "99,500", "price") # 앵커 초과 → 같은 step 반복
assert r.json()["message"]["step"] == "기존가격제시"
row = await _anchor_columns(db_engine, sid)
assert row.last_offer_price == 99_500 # 가격 흔적 기록
# 이 시점에 이탈해 일괄마감(NOT_PARTICIPATED)돼도 last_offer_price 로 실패 표본이 된다.
r = await _send(client, token, sid, "98,000", "price") # 앵커 이하 → 합의 종료
assert r.json()["session_status"] == 3 # DONE
row = await _anchor_columns(db_engine, sid)
assert row.last_offer_price == 98_000 # 마지막 값으로 갱신
assert row.bid_price == 98_000
assert (row.anchoring_price, row.anchoring_value) == (ANCHOR, 10) # 박제 불변
# ── 폴백 경로: 박제 NULL 세션도 backend 채팅 경로가 정상 동작 + 미박제 유지 ──
# (무할인 폴백 anchor=target 자체는 agent NegotiationContextLoader 가 수행/검증 — agent 테스트 소관)
async def test_null_snapshot_falls_back_to_target(client, db_engine, anchor_seed, _fake_agent):
sid = str(anchor_seed["sids"]["N"])
token = await _login_token(client)
await _messages(client, token, sid)
r = await _send(client, token, sid, "네, 시작할게요")
assert r.json()["message"]["step"] == "기존가격제시"
r = await _send(client, token, sid, "97,000", "price") # 가격 입력(더블 앵커 이하 → 종료)
assert r.json()["session_status"] == 3
row = await _anchor_columns(db_engine, sid)
assert (row.anchoring_price, row.anchoring_value) == (None, None) # 미박제 유지(집계 제외 조건)
assert row.bid_price == 97_000
row = await _anchor_columns(db_engine, sid)
assert row.anchoring_price is None # backend 는 박제하지 않음(앵커 없음 → 집계 제외)
assert row.anchoring_value is None
assert row.last_offer_price == 97_000 # 가격 흔적 기록은 정상 동작