diff --git a/backend/common/database/model/models.py b/backend/common/database/model/models.py index 8726e34..80fe568 100644 --- a/backend/common/database/model/models.py +++ b/backend/common/database/model/models.py @@ -111,7 +111,10 @@ class sessions(MAIN_BASE): qt_round = Column(Integer, nullable=False) # 견적 라운드(스냅샷) qt_type = Column(SmallInteger, nullable=False) # 견적 유형: 1=재협상, 2=재견적, 3=신규협상, 4=신규견적 (QtType) target_price = Column(BigInteger, nullable=False) # 목표가(원) - target_anchoring_price = Column(BigInteger, nullable=True) + target_anchoring_price = Column(BigInteger, nullable=True) # 앵커링가(원) — 생성 시 박제(negodata), 사후 수정 금지 + anchor_rate_permille = Column(SmallInteger, nullable=True) # 제안 당시 앵커링 값(‰) 박제 — 사후 수정 금지 + last_offered_price = Column(BigInteger, nullable=True) # 협력사 마지막 제시가(원) — 가격 입력마다 갱신, 종료 후 불변. 앵커링 표본 판정의 "가격 흔적" + anchoring_adjustment_id = Column(BigInteger, nullable=True) # 앵커링 배치 소비 마킹(NULL=미처리 0=제외 >0=조정 id) — schedules/anchoring 전용 status = Column(SmallInteger, nullable=False) # 진행 상태 (SessionStatus 코드) bid_price = Column(BigInteger, nullable=True) # 입찰가(원) bid_at = Column(DateTime(timezone=True), nullable=True) # 입찰 시각 diff --git a/backend/crud/chat_crud.py b/backend/crud/chat_crud.py index c10b2ab..2d97bf8 100644 --- a/backend/crud/chat_crud.py +++ b/backend/crud/chat_crud.py @@ -43,6 +43,10 @@ class IChatCRUD(ABC): ) -> ErrorType: pass + @abstractmethod + async def update_last_offered_price(self, cdb: AsyncSession, session_id, price: int) -> ErrorType: + pass + class ChatCRUD(IChatCRUD): async def list_by_session(self, cdb: AsyncSession, session_id) -> Tuple[ErrorType, list]: @@ -126,3 +130,16 @@ class ChatCRUD(IChatCRUD): except Exception as ex: LOG.e_no_callstack(ex) return ErrorType.DB_RUN_FAILED + + async def update_last_offered_price(self, cdb: AsyncSession, session_id, price: int) -> ErrorType: + """협력사 마지막 제시가 갱신 — 가격 입력 턴의 봇 메시지 저장과 같은 트랜잭션에서 호출. + + 진행 중엔 매 가격 입력마다 덮어쓰고 종료 후엔 자연히 불변. 앵커링 표본 판정에서 + "가격을 한 번이라도 써낸 협상"을 가르는 기준값(NULL=가격 흔적 없음 → 집계 제외). + """ + try: + query = update(sessions).where(sessions.session_id == session_id).values(last_offered_price=price) + return await DB_SESSION_MNG.add(cdb, query) + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED diff --git a/backend/services/chat_service.py b/backend/services/chat_service.py index 3753bb9..cad5a00 100644 --- a/backend/services/chat_service.py +++ b/backend/services/chat_service.py @@ -18,7 +18,7 @@ from fastapi import Depends from sqlalchemy import func, select from common.database.db_session_manager import DB_SESSION_MNG -from common.database.model.models import chats, items, quotation_settings, quotations, sessions, suppliers +from common.database.model.models import chats, items, quotations, sessions, suppliers from common.enums import ChatSender, DBWRType, DeliveryType, ErrorType, QuotationStatus, SessionStatus from common.logger import LOG from common.models.gmodel import UserInfo @@ -37,6 +37,7 @@ PRICE_FLOOR_RATIO = 0.3 PRICE_CEIL_RATIO = 1.7 + class ChatService: def __init__( self, @@ -365,6 +366,10 @@ class ChatService: # 봇 메시지 + 종료 시 확정(성공=DONE+입찰가 / 실패=REJECTED+거부사유·제시가). 한 트랜잭션. bot_msg = self._build_bot_chat(sess, seq=max_seq + 2, turn=turn, bot_chat_type=bot_chat_type, summary=summary) funcs = [lambda s: self.chat_crud.insert_message(s, bot_msg)] + # 가격 입력 턴 → 마지막 제시가를 봇 메시지 저장과 같은 트랜잭션으로 갱신. + # 앵커링 표본 판정의 "가격 흔적"(가격을 써낸 협상만 집계 — 중간 이탈해도 실패로 측정 가능). + if price is not None: + funcs.append(lambda s: self.chat_crud.update_last_offered_price(s, sess.session_id, price)) new_status = sess.status if turn.chat_end: if turn.outcome == "success": @@ -414,8 +419,7 @@ class ChatService: LOG.w(f"[chat] tenant_id 해석 실패(item.company_id 없음) session_id={sess.session_id} — agent 400 위험") rq_type = "재협상" if sess.qt_type == 1 else "재견적" target_price = int(sess.target_price or 0) - # 앵커가: 견적설정(quotation_settings.anchoring_value) 비율로 계산 → agent NegotiationConfig.anchor_for 와 동일식. - # anchor = round(target * (1 - value)). 설정 조회 실패 시 1% 폴백(항상 양수 보장 — agent state ValueError 방지). + # 앵커가: 세션 생성 시 박제된 값(target_anchoring_price)을 그대로 사용 — 협상 중 불변. anchor = await self._resolve_anchor_price(sess, target_price) # 공급사 수: 같은 견적에 속한 세션 수(재협상=1, 재견적=N). agent partner 차원(single/multiple/none) 입력. partner_count = await self._count_partners(sess) @@ -430,25 +434,19 @@ class ChatService: ) async def _resolve_anchor_price(self, sess, target_price: int) -> int: - """견적설정 anchoring_value(비율) → anchor=round(target*(1-value)). 실패 시 target*0.99 폴백.""" + """세션에 박제된 앵커가(target_anchoring_price — negodata 가 생성 시 기록)를 그대로 사용. + + 박제값 사용이 정상 경로다: 협상 진행 중 앵커링 배치 조정·재기동이 껴도 앵커가 흔들리지 않는다 + ("제안 당시 값" 판정의 전제 — schedules/anchoring/docs/개발용.md §9.2). backend 는 앵커를 계산하지 않는다. + 박제가 없으면(데이터 이상 — 사실상 발생하지 않음) 무할인 폴백 anchor=target + WARN. + 이때 박제하지 않으므로 해당 세션은 앵커링 집계에서 자동 제외(EXCLUDED)된다 — 학습 무오염. + """ if not target_price: return 0 - fallback = int(round(target_price * 0.99)) - - def _q(s): - stmt = ( - select(quotation_settings.anchoring_value) - .join(quotations, quotations.qt_setting_id == quotation_settings.qt_setting_id) - .where(quotations.qt_id == sess.quotation_id, quotation_settings.deleted == False) # noqa: E712 - .limit(1) - ) - return DB_SESSION_MNG.execute(s, stmt) - - err_type, rows = await DB_SESSION_MNG.execute_lambda(quotation_settings.DBType(), DBWRType.DB_READ.value, _q) - if err_type != ErrorType.SUCCESS or not rows or rows[0] is None: - LOG.w(f"[chat] anchoring_value 조회 실패 session_id={sess.session_id} — anchor=target*0.99 폴백") - return fallback - return int(round(target_price * (1.0 - float(rows[0])))) + if sess.target_anchoring_price is not None: + return int(sess.target_anchoring_price) + LOG.w(f"[chat] 앵커가 박제 없음 session_id={sess.session_id} — 무할인 폴백(anchor=target), 집계 제외") + return target_price async def _count_partners(self, sess) -> int: """같은 견적(quotation_id)에 속한 협상 세션 수 = 참여 공급사 수. 실패 시 1 폴백.""" diff --git a/backend/tests/test_anchoring_chat.py b/backend/tests/test_anchoring_chat.py new file mode 100644 index 0000000..bcfac85 --- /dev/null +++ b/backend/tests/test_anchoring_chat.py @@ -0,0 +1,192 @@ +"""앵커링 채팅 연동 테스트 — 박제값 소비 / 무할인 폴백 / 마지막 제시가(가격 흔적) 기록. + +배치·조정 로직은 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 을 반복(노출 기록 1회성 검증용). + 매 턴 수신한 ctx.anchor_price 를 기록해 backend 의 앵커 해석을 관찰한다. + """ + + def __init__(self): + self.seen_anchors: list[int] = [] + + async def chat(self, session_id, user_input, ctx) -> AgentTurn: + self.seen_anchors.append(ctx.anchor_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"저희가 제안드리는 첫 목표 가격은 {ctx.anchor_price}원입니다. " + f"제안하실 가격을 입력해 주세요.", input_mode="price") + price = _parse_price(user_input) + if price is not None and price <= ctx.anchor_price: + 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, target_anchoring_price, anchor_rate_permille, 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 target_anchoring_price, anchor_rate_permille, last_offered_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_offered_price is None + assert _fake_agent.seen_anchors[-1] == ANCHOR # backend 가 박제값을 그대로 전달 + + 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_offered_price == 99_500 # 가격 흔적 기록 + # 이 시점에 이탈해 일괄마감(NOT_PARTICIPATED)돼도 last_offered_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_offered_price == 98_000 # 마지막 값으로 갱신 + assert row.bid_price == 98_000 + assert (row.target_anchoring_price, row.anchor_rate_permille) == (ANCHOR, 10) # 박제 불변 + + +# ── 폴백 경로: 박제 NULL → 무할인(anchor=target) + 미박제 유지 ── +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"] == "기존가격제시" + assert _fake_agent.seen_anchors[-1] == TARGET # 무할인 폴백: anchor = target + + r = await _send(client, token, sid, "97,000", "price") # 가격 입력(폴백 앵커 이하 → 종료) + assert r.json()["session_status"] == 3 + row = await _anchor_columns(db_engine, sid) + assert row.target_anchoring_price is None # backend 는 박제하지 않음(앵커 없음 → 집계 제외) + assert row.anchor_rate_permille is None + assert row.last_offered_price == 97_000 # 가격 흔적 기록은 정상 동작