feat(backend): 앵커링 박제값 소비·가격 흔적 기록으로 채팅 경로 전환

- _resolve_anchor_price: quotation_settings.anchoring_value 스칼라 계산(float)
  삭제 → 세션 생성 시 박제된 target_anchoring_price 를 그대로 사용(협상 중
  불변). 박제 없으면 무할인 폴백(anchor=target)+WARN — 미박제라 집계 자동 제외
- 가격 입력 턴마다 sessions.last_offered_price 를 봇 메시지와 같은 트랜잭션으로
  갱신(chat_crud.update_last_offered_price) — 앵커링 표본 판정의 "가격 흔적".
  가격 쓰고 중간 이탈 → 일괄마감된 세션도 실패 표본으로 측정 가능
- sessions 모델에 anchor_rate_permille / last_offered_price /
  anchoring_adjustment_id 3컬럼 추가 (DDL 은 schedules/anchoring/schema.sql 소유)
- e2e 테스트 2종(박제값 소비·제시가 기록 / NULL 폴백) — fake agent 더블

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
민헌 2026-07-02 16:29:46 +09:00
parent f29e9723b0
commit fd8a2425c5
4 changed files with 231 additions and 21 deletions

View File

@ -111,7 +111,10 @@ class sessions(MAIN_BASE):
qt_round = Column(Integer, nullable=False) # 견적 라운드(스냅샷) qt_round = Column(Integer, nullable=False) # 견적 라운드(스냅샷)
qt_type = Column(SmallInteger, nullable=False) # 견적 유형: 1=재협상, 2=재견적, 3=신규협상, 4=신규견적 (QtType) qt_type = Column(SmallInteger, nullable=False) # 견적 유형: 1=재협상, 2=재견적, 3=신규협상, 4=신규견적 (QtType)
target_price = Column(BigInteger, nullable=False) # 목표가(원) 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 코드) status = Column(SmallInteger, nullable=False) # 진행 상태 (SessionStatus 코드)
bid_price = Column(BigInteger, nullable=True) # 입찰가(원) bid_price = Column(BigInteger, nullable=True) # 입찰가(원)
bid_at = Column(DateTime(timezone=True), nullable=True) # 입찰 시각 bid_at = Column(DateTime(timezone=True), nullable=True) # 입찰 시각

View File

@ -43,6 +43,10 @@ class IChatCRUD(ABC):
) -> ErrorType: ) -> ErrorType:
pass pass
@abstractmethod
async def update_last_offered_price(self, cdb: AsyncSession, session_id, price: int) -> ErrorType:
pass
class ChatCRUD(IChatCRUD): class ChatCRUD(IChatCRUD):
async def list_by_session(self, cdb: AsyncSession, session_id) -> Tuple[ErrorType, list]: async def list_by_session(self, cdb: AsyncSession, session_id) -> Tuple[ErrorType, list]:
@ -126,3 +130,16 @@ class ChatCRUD(IChatCRUD):
except Exception as ex: except Exception as ex:
LOG.e_no_callstack(ex) LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED 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

View File

@ -18,7 +18,7 @@ from fastapi import Depends
from sqlalchemy import func, select from sqlalchemy import func, select
from common.database.db_session_manager import DB_SESSION_MNG 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.enums import ChatSender, DBWRType, DeliveryType, ErrorType, QuotationStatus, SessionStatus
from common.logger import LOG from common.logger import LOG
from common.models.gmodel import UserInfo from common.models.gmodel import UserInfo
@ -37,6 +37,7 @@ PRICE_FLOOR_RATIO = 0.3
PRICE_CEIL_RATIO = 1.7 PRICE_CEIL_RATIO = 1.7
class ChatService: class ChatService:
def __init__( def __init__(
self, self,
@ -365,6 +366,10 @@ class ChatService:
# 봇 메시지 + 종료 시 확정(성공=DONE+입찰가 / 실패=REJECTED+거부사유·제시가). 한 트랜잭션. # 봇 메시지 + 종료 시 확정(성공=DONE+입찰가 / 실패=REJECTED+거부사유·제시가). 한 트랜잭션.
bot_msg = self._build_bot_chat(sess, seq=max_seq + 2, turn=turn, bot_chat_type=bot_chat_type, summary=summary) 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)] 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 new_status = sess.status
if turn.chat_end: if turn.chat_end:
if turn.outcome == "success": 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 위험") LOG.w(f"[chat] tenant_id 해석 실패(item.company_id 없음) session_id={sess.session_id} — agent 400 위험")
rq_type = "재협상" if sess.qt_type == 1 else "재견적" rq_type = "재협상" if sess.qt_type == 1 else "재견적"
target_price = int(sess.target_price or 0) target_price = int(sess.target_price or 0)
# 앵커가: 견적설정(quotation_settings.anchoring_value) 비율로 계산 → agent NegotiationConfig.anchor_for 와 동일식. # 앵커가: 세션 생성 시 박제된 값(target_anchoring_price)을 그대로 사용 — 협상 중 불변.
# anchor = round(target * (1 - value)). 설정 조회 실패 시 1% 폴백(항상 양수 보장 — agent state ValueError 방지).
anchor = await self._resolve_anchor_price(sess, target_price) anchor = await self._resolve_anchor_price(sess, target_price)
# 공급사 수: 같은 견적에 속한 세션 수(재협상=1, 재견적=N). agent partner 차원(single/multiple/none) 입력. # 공급사 수: 같은 견적에 속한 세션 수(재협상=1, 재견적=N). agent partner 차원(single/multiple/none) 입력.
partner_count = await self._count_partners(sess) partner_count = await self._count_partners(sess)
@ -430,25 +434,19 @@ class ChatService:
) )
async def _resolve_anchor_price(self, sess, target_price: int) -> int: 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: if not target_price:
return 0 return 0
fallback = int(round(target_price * 0.99)) if sess.target_anchoring_price is not None:
return int(sess.target_anchoring_price)
def _q(s): LOG.w(f"[chat] 앵커가 박제 없음 session_id={sess.session_id} — 무할인 폴백(anchor=target), 집계 제외")
stmt = ( return target_price
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]))))
async def _count_partners(self, sess) -> int: async def _count_partners(self, sess) -> int:
"""같은 견적(quotation_id)에 속한 협상 세션 수 = 참여 공급사 수. 실패 시 1 폴백.""" """같은 견적(quotation_id)에 속한 협상 세션 수 = 참여 공급사 수. 실패 시 1 폴백."""

View File

@ -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 # 가격 흔적 기록은 정상 동작