feat(chat): agent 연동 정리 — mock 제거·DB 데이터 연동·표현 passthrough

- MockAgentClient/use_mock 분기와 mock 전용 turn/count_bot_messages 제거 (항상 HttpAgentClient 위임)
- agent RL state 입력을 실 DB 로 연동: anchor_price=quotation_settings.anchoring_value 기반 계산, partner_count=견적당 세션 수 (quotation_settings 모델 추가)
- 재견적 배송형태(delivery_type) 캡처: 배송형태선택 단계 선택값을 summaryCM 요약에 반영 (DeliveryType enum 추가)
- 표현 계약 passthrough: agent 응답의 bot_chat_type/indicator_value 를 우선 사용하고 없으면 step+qt_type 으로 폴백, indicator_value 컬럼 영속/전달
- 턴당 중복 조회 제거: item/last_price 를 1회 로드해 재사용
- 관련 테스트 추가(배송형태 캡처, passthrough)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
민헌 2026-06-19 17:32:50 +09:00
parent 6fcb4d4e8c
commit 8f070e362b
7 changed files with 375 additions and 141 deletions

View File

@ -155,6 +155,25 @@ class quotations(MAIN_BASE):
deleted = Column(Boolean, nullable=False, server_default=text("false")) # 소프트 삭제 여부
class quotation_settings(MAIN_BASE):
# quotation.quotation_settings (견적 설정). 앵커링값(anchoring_value) 조회용 — agent RL state 입력.
@staticmethod
def DBType():
return DBType.QUOTATION.value
__tablename__ = "quotation_settings"
__table_args__ = {"schema": "quotation"}
qt_setting_id = Column(UUID(as_uuid=True), primary_key=True, server_default=text("gen_random_uuid()")) # 견적 설정 식별자(PK)
user_id = Column(UUID(as_uuid=True), nullable=False) # 생성 유저(company.users.user_id)
target_margin_rate = Column(Numeric(8, 6), nullable=False) # 목표 마진율
anchoring_value = Column(Numeric(8, 6), nullable=False, server_default=text("0.01")) # 앵커링 값(비율) — anchor=round(target*(1-value))
card_count = Column(Integer, nullable=False, server_default=text("3")) # 협상 내 협상카드 사용 횟수
created_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')")) # 생성 시각(UTC)
updated_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')")) # 수정 시각(UTC, 앱에서 갱신)
deleted = Column(Boolean, nullable=False, server_default=text("false")) # 소프트 삭제 여부
class chats(MAIN_BASE):
# negotiation.chats (협상 채팅 메시지 로그). session 1 : N chats. (session_id, seq) 유니크.
@staticmethod

View File

@ -49,6 +49,8 @@ class ErrorType(Enum):
CHAT_PRICE_OUT_OF_RANGE = auto() # 1401 제시가가 허용 범위를 벗어남
CHAT_AGENT_UNAVAILABLE = auto() # 1402 협상 에이전트(agent) 호출 실패
CHAT_IN_PROGRESS = auto() # 1403 직전 턴 처리 중(동시 전송 가드)
CHAT_INPUT_MODE_MISMATCH = auto() # 1404 직전 봇이 요구한 입력 모드와 보낸 입력이 불일치(잘못된 버튼/타입) → 화면 리싱크 필요
CHAT_AGENT_TIMEOUT = auto() # 1405 agent 응답 타임아웃(처리됐을 수 있음 — 롤백/재시도 시 desync 위험)
# ErrorType 의 HTTP_* 값과 status_code 를 맞춰 router 단에서 raise 한다.
@ -140,3 +142,18 @@ class ChatSender(Enum):
BOT = 1 # 갑(바이어/agent) — bot 메시지
USER = 2 # 공급사(을) — user 입력
class DeliveryType(Enum):
"""배송 유형 코드. partner.items.delivery_type / negotiation.sessions.reject_delivery_type.
재견적(CM) 협상의 '배송형태선택' 단계 라벨과 1:1 (SHARED_ENUMS §6, negodata 정의 채택).
"""
SUPPLIER = 1 # 협력사배송
COURIER = 2 # 지정택배배송
PICKUP = 3 # 픽업배송
@classmethod
def label_of(cls, code) -> str:
"""코드(1~3) → 한글 라벨. 알 수 없으면 빈 문자열."""
return {1: "협력사배송", 2: "지정택배배송", 3: "픽업배송"}.get(code, "")

View File

@ -2,12 +2,12 @@ from abc import ABC, abstractmethod
from datetime import datetime, timezone
from typing import Optional, Tuple
from sqlalchemy import asc, desc, func, select, update
from sqlalchemy import asc, desc, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import chats, items, sessions
from common.enums import ChatSender, ErrorType
from common.enums import ErrorType
from common.logger import LOG
@ -19,8 +19,9 @@ class IChatCRUD(ABC):
pass
@abstractmethod
async def get_last(self, cdb: AsyncSession, session_id) -> Tuple[ErrorType, Tuple[int, Optional[int]]]:
"""마지막 메시지의 (seq, sender). 없으면 (0, None). 동시전송 가드 + seq 채번에 사용."""
async def get_last(self, cdb: AsyncSession, session_id) -> Tuple[ErrorType, Tuple[int, Optional[int], Optional[dict]]]:
"""마지막 메시지의 (seq, sender, meta). 없으면 (0, None, None).
동시전송 가드 + seq 채번 + 입력-모드 검증(직전 meta.input_mode) 사용."""
pass
@abstractmethod
@ -31,10 +32,6 @@ class IChatCRUD(ABC):
async def soft_delete_message(self, cdb: AsyncSession, chat_id) -> ErrorType:
pass
@abstractmethod
async def count_bot_messages(self, cdb: AsyncSession, session_id) -> Tuple[ErrorType, int]:
pass
@abstractmethod
async def get_item_by_id(self, cdb: AsyncSession, item_id) -> Tuple[ErrorType, items]:
pass
@ -64,23 +61,23 @@ class ChatCRUD(IChatCRUD):
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, []
async def get_last(self, cdb: AsyncSession, session_id) -> Tuple[ErrorType, Tuple[int, Optional[int]]]:
async def get_last(self, cdb: AsyncSession, session_id) -> Tuple[ErrorType, Tuple[int, Optional[int], Optional[dict]]]:
try:
query = (
select(chats.seq, chats.sender)
select(chats.seq, chats.sender, chats.meta)
.where(chats.session_id == session_id, chats.deleted == False) # noqa: E712
.order_by(desc(chats.seq))
.limit(1)
)
err_type, rows = await DB_SESSION_MNG.execute(cdb, query, "get_last failed.")
if err_type != ErrorType.SUCCESS:
return err_type, (0, None)
return err_type, (0, None, None)
if not rows:
return ErrorType.SUCCESS, (0, None)
return ErrorType.SUCCESS, (rows[0][0], rows[0][1])
return ErrorType.SUCCESS, (0, None, None)
return ErrorType.SUCCESS, (rows[0][0], rows[0][1], rows[0][2])
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, (0, None)
return ErrorType.DB_RUN_FAILED, (0, None, None)
async def insert_message(self, cdb: AsyncSession, message: chats) -> ErrorType:
try:
@ -98,22 +95,6 @@ class ChatCRUD(IChatCRUD):
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED
async def count_bot_messages(self, cdb: AsyncSession, session_id) -> Tuple[ErrorType, int]:
# mock agent 진행(turn) 계산용. 실제 agent 는 자체 세션 상태로 진행하므로 무시한다.
try:
query = select(func.count()).select_from(chats).where(
chats.session_id == session_id,
chats.sender == ChatSender.BOT.value,
chats.deleted == False, # noqa: E712
)
err_type, rows = await DB_SESSION_MNG.execute(cdb, query, "count_bot_messages failed.")
if err_type != ErrorType.SUCCESS:
return err_type, 0
return ErrorType.SUCCESS, (rows[0] if rows else 0)
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, 0
async def get_item_by_id(self, cdb: AsyncSession, item_id) -> Tuple[ErrorType, items]:
try:
query = select(items).where(items.item_id == item_id, items.deleted == False).limit(1) # noqa: E712

View File

@ -2,8 +2,9 @@
agent Res_Chat ChatMessage 매핑:
stepstep, client_stepdisplay_step, scriptscript,
input_modenext_input_mode, input_optionsnext_input_type, chat_endchat_end.
indicator/summary/reject 이번 범위 (예약 필드, 기본 None).
input_modenext_input_mode, input_optionsnext_input_type, chat_endchat_end,
bot_chat_typebot_chat_type, indicator_valueindicator_value.
summary(요약카드 데이터) backend 비즈니스 데이터로 조립해 채운다.
"""
from typing import Optional
@ -49,7 +50,7 @@ class ChatMessage(WebPacketProtocol):
next_input_mode: Optional[str] = None # confirm|yes_no|percent|price|delivery_type
next_input_type: Optional[list[str]] = None # 다음 입력 선택지
chat_end: bool = False
indicator_value: Optional[float] = None # (범위 외 예약) 협상 지표
indicator_value: Optional[float] = None # 협상 지표(1~99). agent 가 가격협상 턴에 내려주면 표시.
bot_chat_type: Optional[str] = None # summaryRSP|summaryCM|rejectRSP|rejectCM|indicator
summary: Optional[ChatSummary] = None # summaryRSP/summaryCM 일 때만 채워짐

View File

@ -1,13 +1,11 @@
"""협상 에이전트(agent, 포트 9500) 호출 클라이언트.
backend /chat 턴을 agent 위임한다(README: "backend 가 /chat 을 agent 로 위임").
agent 계약(Req_Chat/Res_Chat) 맞춘 어댑터. agent 아직 없거나 로컬에서 미연동일 때를 위해
mock 구현을 두고 config(AgentConfig.use_mock) 선택한다 격리 덕에 backend/프론트를
agent 완성 여부와 무관하게 통합 테스트할 있다.
agent 계약(Req_Chat/Res_Chat) 맞춘 어댑터.
agent 응답(Res_Chat) AgentTurn 매핑:
step, client_step, script, input_mode(=next_input_mode), input_options(=next_input_type),
chat_end, outcome, card_id, indicator_value.
chat_end, outcome, card_id, indicator_value, bot_chat_type.
"""
from abc import ABC, abstractmethod
@ -32,7 +30,10 @@ class AgentTurn:
outcome: Optional[str] = None # "success" | "failure" (종료 시)
card_id: Optional[str] = None
indicator_value: Optional[float] = None
# agent 가 직접 내려주는 표현 폼(summaryRSP/CM·rejectRSP/CM·indicator). 없으면 backend 가 step+qt_type 으로 폴백.
bot_chat_type: Optional[str] = None
ok: bool = True # agent 호출 성공 여부 (False 면 CHAT_AGENT_UNAVAILABLE)
timed_out: bool = False # 타임아웃 여부. True 면 agent 가 이미 진행했을 수 있어 desync 위험 → 별도 처리.
@dataclass
@ -42,8 +43,16 @@ class AgentChatContext:
tenant_id: str # X-Tenant-ID = 견적(갑) 회사 company_id
rq_type: str = "재협상" # 재협상 | 재견적
target_price: int = 0 # 갑 목표 매입가(원)
anchor_price: int = 0 # 앵커링가(목표가보다 낮음)
turn: int = 0 # 직전까지의 봇 턴 수(mock 진행용; 실제 agent 는 무시)
anchor_price: int = 0 # 앵커링가(목표가보다 낮음). quotation_settings.anchoring_value 로 계산.
# 핸드오프 #4: agent 의 RL 상태(state) 계산 입력.
# partner_count 는 견적당 세션 수로 산출(실데이터). 나머지 3개는 우리 스키마에 데이터 소스가 없어
# 기본값으로 보낸다 → agent 가 실제값을 받으려면 backend 스키마에 컬럼 추가 필요(HANDOFF.md ④).
revenue_amount: float = 20_000_000 # 매출액(원) — DB 소스 없음(기본값)
distribution_code: str = "A" # 유통 코드(agent config code_map 키) — DB 소스 없음(기본값)
partner_count: int = 1 # 공급사 수 — 견적당 세션 수로 산출
acceptance_ratio: float = 0.05 # 가격 수용률 0~1 — DB 소스 없음(기본값)
# 핸드오프 #1: backend 가 보는 현재 step(직전 봇 step). agent 가 자기 세션 step 과 대조해 desync 감지에 쓸 수 있다.
client_step: Optional[str] = None
extra: dict = field(default_factory=dict)
@ -58,7 +67,7 @@ class HttpAgentClient(IAgentClient):
"""실제 agent(9500) 위임 구현. agent POST /v1/chat 호출."""
async def chat(self, session_id: Optional[str], user_input: Optional[str], ctx: AgentChatContext) -> AgentTurn:
import httpx # 지연 import — mock 모드에서는 httpx 의존을 강제하지 않는다.
import httpx
body = {
"session_id": session_id, # 핸드오프 #1: agent 가 이 값을 세션 키로 그대로 사용해야 함
@ -66,6 +75,13 @@ class HttpAgentClient(IAgentClient):
"user_input": user_input,
"target_price": ctx.target_price,
"anchor_price": ctx.anchor_price,
# 핸드오프 #4: RL state 입력 (agent Req_Chat 이 받는 필드). 현재 기본값.
"revenue_amount": ctx.revenue_amount,
"distribution_code": ctx.distribution_code,
"partner_count": ctx.partner_count,
"acceptance_ratio": ctx.acceptance_ratio,
# 핸드오프 #1: backend 가 보는 직전 step. agent 가 desync 감지에 사용(미구현 시 무시됨).
"client_step": ctx.client_step,
}
headers = {"X-Tenant-ID": ctx.tenant_id} # 핸드오프 #2
try:
@ -73,8 +89,12 @@ class HttpAgentClient(IAgentClient):
resp = await cli.post("/v1/chat", json=body, headers=headers)
resp.raise_for_status()
data = resp.json()
except httpx.TimeoutException as ex:
# 타임아웃: agent 가 이미 턴을 처리(세션 step 전진)했을 수 있다 → 단순 롤백/재시도는 desync 위험.
LOG.e_no_callstack(f"[AgentClient] agent 타임아웃 session_id={session_id} step={ctx.client_step}: {ex}")
return AgentTurn(ok=False, timed_out=True)
except Exception as ex:
LOG.e_no_callstack(f"[AgentClient] agent 호출 실패: {ex}")
LOG.e_no_callstack(f"[AgentClient] agent 호출 실패 session_id={session_id} step={ctx.client_step}: {ex}")
return AgentTurn(ok=False)
return AgentTurn(
@ -88,66 +108,11 @@ class HttpAgentClient(IAgentClient):
outcome=data.get("outcome"),
card_id=data.get("card_id"),
indicator_value=data.get("indicator_value"),
bot_chat_type=data.get("bot_chat_type"),
ok=True,
)
class MockAgentClient(IAgentClient):
"""agent 미연동용 결정론적 mock. ctx.turn(직전 봇 턴 수)으로 협상 단계를 진행한다.
플로우(핵심만): 0=인사(확인) 1=품목안내(확인) 2=가격협상(가격입력) 3+=수락/종료.
"""
_SCRIPT = [
("서비스안내", "협상에 참여해 주셔서 감사합니다. 시작하시겠어요?", "confirm", ["네, 시작할게요"]),
("협상품목안내", "협상 품목을 확인해 주세요. 가격 협상을 진행할까요?", "confirm", ["가격 협상 진행"]),
("가격협상", "희망 공급가를 입력해 주세요.", "price", None),
]
async def chat(self, session_id: Optional[str], user_input: Optional[str], ctx: AgentChatContext) -> AgentTurn:
sid = session_id or "mock-session"
turn = ctx.turn
# 공급사가 협상 포기/거부 의사를 밝히면 실패로 종료(거부)한다.
if user_input and ("포기" in user_input or "거부" in user_input):
return AgentTurn(
session_id=sid, step="협상종료", client_step="협상종료",
script="협상이 종료되었습니다.", input_mode=None, input_options=None,
chat_end=True, outcome="failure",
)
if turn < len(self._SCRIPT):
step, script, mode, options = self._SCRIPT[turn]
return AgentTurn(
session_id=sid, step=step, client_step=step, script=script,
input_mode=mode, input_options=options, chat_end=False,
)
# 가격 제시 이후: 목표가 이하면 수락 종료, 아니면 한 번 더 제안 요청
price = _parse_price(user_input)
if price is not None and ctx.target_price and price <= ctx.target_price:
return AgentTurn(
session_id=sid, step="협상종료", client_step="협상종료",
script=f"제안하신 {price:,}원으로 합의되었습니다. 감사합니다.",
input_mode=None, input_options=None, chat_end=True, outcome="success",
card_id="NGC-MOCK", indicator_value=100.0,
)
return AgentTurn(
session_id=sid, step="가격협상", client_step="가격협상",
script="조금 더 조정된 가격을 제안해 주시겠어요?",
input_mode="price", input_options=None, chat_end=False,
card_id="NGC-MOCK", indicator_value=50.0,
)
def _parse_price(text: Optional[str]) -> Optional[int]:
"""'1,500원' / '1500' 등에서 정수 가격을 파싱한다. 실패 시 None."""
if not text:
return None
digits = "".join(ch for ch in text if ch.isdigit())
return int(digits) if digits else None
def get_agent_client() -> IAgentClient:
"""config 에 따라 mock/실제 클라이언트를 반환한다(FastAPI Depends 용)."""
return MockAgentClient() if agent_config.use_mock else HttpAgentClient()
"""실제 agent(9500) 위임 클라이언트를 반환한다(FastAPI Depends 용)."""
return HttpAgentClient()

View File

@ -15,11 +15,12 @@ from typing import Optional
from fastapi import Depends
from sqlalchemy import select
from sqlalchemy import func, select
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import chats, items, quotations, sessions, supplier_users, suppliers
from common.enums import ChatSender, DBWRType, ErrorType, QuotationStatus, SessionStatus
from common.database.model.models import chats, items, quotation_settings, quotations, sessions, supplier_users, suppliers
from common.enums import ChatSender, DBWRType, DeliveryType, ErrorType, QuotationStatus, SessionStatus
from common.logger import LOG
from common.models.gmodel import UserInfo
from crud.chat_crud import ChatCRUD, IChatCRUD
from crud.session_crud import ISessionCRUD, SessionCRUD
@ -50,6 +51,31 @@ PRICE_FLOOR_RATIO = 0.3
PRICE_CEIL_RATIO = 1.7
def _input_matches_mode(last_meta: Optional[dict], user_input: str, user_input_type: Optional[str]) -> bool:
"""직전 봇이 요구한 입력 모드(meta.input_mode)와 이번 유저 입력의 '타입'이 정합한지 검사.
불일치(: price 단계인데 버튼/텍스트, yes_no 단계인데 가격/퍼센트 숫자) False
agent 넘기지 않고 CHAT_INPUT_MODE_MISMATCH(제자리걸음/오진행 방지).
직전 메시지/모드가 없으면(제약 없음) True.
주의: 버튼 선택형(confirm/yes_no/delivery_type)에서 '선택지 텍스트 일치'까지는 강제하지 않는다.
- 프론트 버튼은 항상 올바른 라벨을 보내고, 거부 등은 자유 텍스트(사유) 보내기 때문.
- 타입(가격/퍼센트 숫자) 오입력만 막아도 실제 stuck/오진행 케이스는 차단된다.
"""
if not last_meta:
return True
mode = last_meta.get("input_mode")
if not mode:
return True
if mode == "price":
return user_input_type == "price"
if mode == "percent":
return user_input_type == "percent"
if mode in ("confirm", "yes_no", "delivery_type"):
# 버튼 선택형 단계에 가격/퍼센트 '숫자 입력'이 오면 오입력 → 차단. 그 외 텍스트는 허용.
return user_input_type not in ("price", "percent")
return True
class ChatService:
def __init__(
self,
@ -166,11 +192,11 @@ class ChatService:
async def _seed_opening(self, sess) -> Optional[ChatMessage]:
"""오프닝(턴0) 봇 메시지를 agent 로 생성하고 seq=1 로 저장한다. 동시 진입 충돌은 무시(유니크가 방어)."""
ctx = await self._agent_context(sess, turn=0)
ctx = await self._agent_context(sess)
turn = await self.agent.chat(session_id=str(sess.session_id), user_input=None, ctx=ctx)
if not turn.ok:
return None
bot = self._build_bot_chat(sess, seq=1, turn=turn)
bot = self._build_bot_chat(sess, seq=1, turn=turn, bot_chat_type=turn.bot_chat_type)
await DB_SESSION_MNG.execute_lambda_run(
[chats.DBType()], [lambda s: self.chat_crud.insert_message(s, bot)]
)
@ -215,8 +241,8 @@ class ChatService:
res.result.SetResult(ErrorType.CHAT_PRICE_OUT_OF_RANGE)
return res
# 직전 메시지(seq/sender) — 동시전송 가드 + seq 채번
err_type, (max_seq, last_sender) = await DB_SESSION_MNG.execute_lambda(
# 직전 메시지(seq/sender/meta) — 동시전송 가드 + seq 채번 + 입력-모드 검증
err_type, (max_seq, last_sender, last_meta) = await DB_SESSION_MNG.execute_lambda(
chats.DBType(), DBWRType.DB_READ.value,
lambda s: self.chat_crud.get_last(s, sess.session_id),
)
@ -227,13 +253,11 @@ class ChatService:
if last_sender == ChatSender.USER.value:
res.result.SetResult(ErrorType.CHAT_IN_PROGRESS)
return res
err_type, turn_no = await DB_SESSION_MNG.execute_lambda(
chats.DBType(), DBWRType.DB_READ.value,
lambda s: self.chat_crud.count_bot_messages(s, sess.session_id),
)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
# ③ 입력-모드 검증: 직전 봇이 요구한 모드와 보낸 입력이 어긋나면 agent 로 넘기지 않는다(제자리걸음/오진행 방지).
if not _input_matches_mode(last_meta, user_input, user_input_type):
LOG.i(f"[chat] 입력-모드 불일치 session_id={sess.session_id} "
f"mode={last_meta.get('input_mode') if last_meta else None} input_type={user_input_type} input={user_input!r}")
res.result.SetResult(ErrorType.CHAT_INPUT_MODE_MISMATCH)
return res
# 유저 메시지 선점(pre-claim): (session_id, seq) 부분 유니크로 동시 전송을 직렬화한다.
@ -250,21 +274,41 @@ class ChatService:
return res
# agent 위임 (한 턴). 실패 시 선점한 유저 메시지를 롤백 → 재시도 가능.
ctx = await self._agent_context(sess, turn=turn_no)
# ① client_step: backend 가 보는 직전 봇 step 을 agent 에 전달(desync 감지 힌트).
last_step = (last_meta or {}).get("step") if last_meta else None
# 상품은 이번 턴에서 1회만 로드해 agent 컨텍스트/요약 조립에 재사용한다(중복 조회 제거).
err_type, item = await DB_SESSION_MNG.execute_lambda(
items.DBType(), DBWRType.DB_READ.value,
lambda s: self.chat_crud.get_item_by_id(s, sess.item_id),
)
item = item if err_type == ErrorType.SUCCESS else None
ctx = await self._agent_context(sess, client_step=last_step, item=item)
turn = await self.agent.chat(session_id=str(sess.session_id), user_input=user_input, ctx=ctx)
if not turn.ok:
# 선점 유저 메시지 롤백(backend 일관성 유지).
await DB_SESSION_MNG.execute_lambda_run(
[chats.DBType()], [lambda s: self.chat_crud.soft_delete_message(s, user_msg.chat_id)]
)
res.result.SetResult(ErrorType.CHAT_AGENT_UNAVAILABLE)
# ② 타임아웃은 별도 코드. agent 가 이미 턴을 처리(step 전진)했을 수 있어, 단순 재시도 시 desync 위험.
# 근본 해결(agent 멱등 재시도 / 세션상태 조회 후 정합)은 agent 측 작업 — HANDOFF.md 참고.
if turn.timed_out:
LOG.w(f"[chat] agent 타임아웃 롤백 session_id={sess.session_id} step={last_step} "
f"— agent 가 이미 진행했을 수 있음(desync 위험). HANDOFF #2 참고")
res.result.SetResult(ErrorType.CHAT_AGENT_TIMEOUT)
else:
res.result.SetResult(ErrorType.CHAT_AGENT_UNAVAILABLE)
return res
# 종료 스텝이면 폼 종류(summary/reject)를 부여하고, 요약카드면 데이터까지 조립한다.
bot_chat_type = _resolve_bot_chat_type(sess.qt_type, turn.step)
# 폼 종류: agent 가 직접 내려주면(bot_chat_type) 신뢰하고, 없으면 step+qt_type 으로 폴백 유도.
# → agent 가 표현 계약을 책임지면 backend 의 step-이름 결합(_resolve_bot_chat_type)은 폴백으로만 남는다.
bot_chat_type = turn.bot_chat_type or _resolve_bot_chat_type(sess.qt_type, turn.step)
# 마지막 유저 제시가: 요약(표시가)·종료 입찰가 양쪽에 쓰이므로 이번 턴 1회만 조회한다.
need_last_price = bot_chat_type in ("summaryRSP", "summaryCM") or (turn.chat_end and turn.outcome == "success")
last_price = await self._last_user_price(sess) if need_last_price else None
summary = None
if bot_chat_type in ("summaryRSP", "summaryCM"):
final_price = price if price is not None else (sess.bid_price or sess.target_price)
summary = await self._build_summary(sess, quote, final_price)
summary = await self._build_summary(sess, quote, item, final_price, last_price)
# 봇 메시지 + 종료 시 확정(성공=DONE+입찰가 / 실패=REJECTED+거부사유·제시가). 한 트랜잭션.
bot_msg = self._build_bot_chat(sess, seq=max_seq + 2, turn=turn, bot_chat_type=bot_chat_type, summary=summary)
@ -274,8 +318,11 @@ class ChatService:
if turn.outcome == "success":
new_status = SessionStatus.DONE.value
# 입찰가 = 이번 턴 가격(보통 None) → 마지막 제시가 → 목표가 순으로 확정.
last_price = await self._last_user_price(sess)
bid = price if price is not None else (last_price if last_price else sess.target_price)
# ⑤ 협상된 제시가가 하나도 없어 목표가로 폴백하면, 합의가가 실제 협상과 다를 수 있어 경고.
if price is None and not last_price:
LOG.w(f"[chat] 합의가 폴백→목표가 session_id={sess.session_id} bid={bid} "
f"— 협상 중 가격 제시가 기록되지 않음(프론트 user_input_type='price' 누락 의심)")
funcs.append(lambda s: self.chat_crud.finalize_session(s, sess.session_id, new_status, bid_price=bid))
else:
new_status = SessionStatus.REJECTED.value
@ -298,23 +345,72 @@ class ChatService:
return res
# ---- 빌더 / 매퍼 ----------------------------------------------------
async def _agent_context(self, sess, turn: int) -> AgentChatContext:
# 핸드오프 #2/#5: X-Tenant-ID 는 견적(갑) 회사 company_id 여야 한다.
async def _agent_context(self, sess, client_step: Optional[str] = None, item=None) -> AgentChatContext:
# 핸드오프 #2: X-Tenant-ID 는 견적(갑) 회사 company_id 여야 한다.
# 상품(partner.items)의 소유 회사가 갑(buyer)이므로 item.company_id 로 해석한다.
# item 은 호출부(send)에서 1회 로드해 넘겨주면 재사용한다(오프닝 seed 는 미전달 → 여기서 로드).
if item is None:
err_type, item = await DB_SESSION_MNG.execute_lambda(
items.DBType(), DBWRType.DB_READ.value,
lambda s: self.chat_crud.get_item_by_id(s, sess.item_id),
)
item = item if err_type == ErrorType.SUCCESS else None
tenant_id = "" # 해석 실패 시 빈 값(agent 가 400) — 로깅으로 추적
err_type, item = await DB_SESSION_MNG.execute_lambda(
items.DBType(), DBWRType.DB_READ.value,
lambda s: self.chat_crud.get_item_by_id(s, sess.item_id),
)
if err_type == ErrorType.SUCCESS and item is not None and item.company_id:
if item is not None and item.company_id:
tenant_id = str(item.company_id)
else:
LOG.w(f"[chat] tenant_id 해석 실패(item.company_id 없음) session_id={sess.session_id} — agent 400 위험")
rq_type = "재협상" if sess.qt_type == 1 else "재견적"
anchor = int(sess.target_price * 0.99) if sess.target_price else 0
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 방지).
anchor = await self._resolve_anchor_price(sess, target_price)
# 공급사 수: 같은 견적에 속한 세션 수(재협상=1, 재견적=N). agent partner 차원(single/multiple/none) 입력.
partner_count = await self._count_partners(sess)
# revenue_amount / distribution_code / acceptance_ratio 는 현재 스키마에 데이터 소스가 없어
# AgentChatContext 기본값으로 보낸다(HANDOFF #4 — 컬럼 추가/소스 합의 필요).
return AgentChatContext(
tenant_id=tenant_id, rq_type=rq_type,
target_price=int(sess.target_price or 0), anchor_price=anchor, turn=turn,
target_price=target_price, anchor_price=anchor, partner_count=partner_count,
client_step=client_step,
)
async def _resolve_anchor_price(self, sess, target_price: int) -> int:
"""견적설정 anchoring_value(비율) → anchor=round(target*(1-value)). 실패 시 target*0.99 폴백."""
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]))))
async def _count_partners(self, sess) -> int:
"""같은 견적(quotation_id)에 속한 협상 세션 수 = 참여 공급사 수. 실패 시 1 폴백."""
def _q(s):
stmt = (
select(func.count())
.select_from(sessions)
.where(sessions.quotation_id == sess.quotation_id, sessions.deleted == False) # noqa: E712
)
return DB_SESSION_MNG.execute(s, stmt)
err_type, rows = await DB_SESSION_MNG.execute_lambda(sessions.DBType(), DBWRType.DB_READ.value, _q)
if err_type != ErrorType.SUCCESS or not rows or not rows[0]:
return 1
return int(rows[0])
def _build_user_chat(self, sess, seq: int, user_input: str, user_input_type: Optional[str], price: Optional[int]) -> chats:
return chats(
chat_id=uuid.uuid4(), session_id=sess.session_id, seq=seq,
@ -325,10 +421,12 @@ class ChatService:
def _build_bot_chat(self, sess, seq: int, turn, bot_chat_type: Optional[str] = None, summary: Optional[dict] = None) -> chats:
# bot_chat_type/summary 도 meta 에 영속화 → 히스토리 복원(messages)에서도 폼이 재현된다.
# indicator_value 는 전용 컬럼(분석/replay용)에도 적재. meta 는 순수 표시용.
return chats(
chat_id=uuid.uuid4(), session_id=sess.session_id, seq=seq,
sender=ChatSender.BOT.value,
target_price=int(sess.target_price or 0),
indicator_value=turn.indicator_value,
meta={
"script": turn.script, "step": turn.step, "client_step": turn.client_step,
"input_mode": turn.input_mode, "input_options": turn.input_options,
@ -350,6 +448,7 @@ class ChatService:
next_input_mode=meta.get("input_mode"),
next_input_type=meta.get("input_options"),
chat_end=bool(meta.get("chat_end", False)),
indicator_value=float(c.indicator_value) if c.indicator_value is not None else None,
bot_chat_type=meta.get("bot_chat_type"),
summary=ChatSummary(**summary_d) if summary_d else None,
)
@ -368,15 +467,38 @@ class ChatService:
err_type, rows = await DB_SESSION_MNG.execute_lambda(chats.DBType(), DBWRType.DB_READ.value, _q)
return int(rows[0]) if err_type == ErrorType.SUCCESS and rows and rows[0] else None
async def _build_summary(self, sess, quote, final_price: Optional[int]) -> dict:
"""협상 결과 요약 카드 데이터 조립(item + 견적 담당 MD + 공급사/담당자 + 최종 제시가).
종료 스텝에서 1회만 호출."""
err_type, item = await DB_SESSION_MNG.execute_lambda(
items.DBType(), DBWRType.DB_READ.value,
lambda s: self.chat_crud.get_item_by_id(s, sess.item_id),
)
item = item if err_type == ErrorType.SUCCESS else None
async def _delivery_choice(self, sess) -> Optional[str]:
"""재견적 '배송형태선택' 봇 단계(meta.input_mode='delivery_type') 직후 유저가 고른 배송형태 라벨.
없으면 None. ( 프롬프트 seq 이후 비삭제 유저 메시지의 script)"""
def _bot_seq(s):
stmt = (
select(chats.seq)
.where(chats.session_id == sess.session_id, chats.sender == ChatSender.BOT.value,
chats.meta["input_mode"].astext == "delivery_type", chats.deleted == False) # noqa: E712
.order_by(chats.seq.desc()).limit(1)
)
return DB_SESSION_MNG.execute(s, stmt)
err_type, rows = await DB_SESSION_MNG.execute_lambda(chats.DBType(), DBWRType.DB_READ.value, _bot_seq)
if err_type != ErrorType.SUCCESS or not rows:
return None
bot_seq = rows[0]
def _user_after(s):
stmt = (
select(chats.meta["script"].astext)
.where(chats.session_id == sess.session_id, chats.sender == ChatSender.USER.value,
chats.seq > bot_seq, chats.deleted == False) # noqa: E712
.order_by(chats.seq.asc()).limit(1)
)
return DB_SESSION_MNG.execute(s, stmt)
err_type, rows = await DB_SESSION_MNG.execute_lambda(chats.DBType(), DBWRType.DB_READ.value, _user_after)
return rows[0] if err_type == ErrorType.SUCCESS and rows and rows[0] else None
async def _build_summary(self, sess, quote, item, final_price: Optional[int], last_price: Optional[int]) -> dict:
"""협상 결과 요약 카드 데이터 조립(item + 견적 담당 MD + 공급사/담당자 + 최종 제시가).
종료 스텝에서 1회만 호출. item/last_price 호출부(send)에서 1 조회해 넘겨준다(중복 조회 제거)."""
def _supplier_name(s):
stmt = select(suppliers.name).where(suppliers.supplier_id == sess.supplier_id).limit(1)
return DB_SESSION_MNG.execute(s, stmt)
@ -396,10 +518,14 @@ class ChatService:
err_type, su_rows = await DB_SESSION_MNG.execute_lambda(supplier_users.DBType(), DBWRType.DB_READ.value, _supplier_user)
sup_mgr_name, sup_mgr_email = (su_rows[0][0], su_rows[0][1]) if err_type == ErrorType.SUCCESS and su_rows else ("", "")
# 최종 제시가: 가장 최근 유저 제시가(없으면 입찰가/목표가 폴백)
last_price = await self._last_user_price(sess)
# 최종 제시가: 가장 최근 유저 제시가(없으면 입찰가/목표가 폴백). last_price 는 호출부에서 전달.
resolved_price = int(last_price if last_price else (final_price or 0))
# 배송형태: 재견적(CM)의 '배송형태선택' 단계에서 공급사가 고른 라벨. 재협상엔 단계가 없어 None.
delivery_label = await self._delivery_choice(sess) if sess.qt_type == 2 else None
# 상품 기본 배송유형(코드→라벨). 선택값이 없으면 표시에 폴백으로 쓸 수 있다.
item_delivery_label = DeliveryType.label_of(item.delivery_type) if item and item.delivery_type is not None else ""
def _iso(dt):
if dt is None:
return ""
@ -420,14 +546,14 @@ class ChatService:
item_isVAT=bool(item.vat_yn) if item and item.vat_yn is not None else False,
item_lead_time=(str(item.lead_time) if item and item.lead_time is not None else ""),
item_display_date=_iso(quote.start_time),
item_delivery_type="",
item_delivery_type=item_delivery_label,
final_price=resolved_price,
nego_start_date=_iso(quote.start_time),
nego_end_date=_iso(quote.end_time),
supplier_name=supplier_name or "",
supplier_manager_name=sup_mgr_name or "",
supplier_manager_email=sup_mgr_email or "",
delivery_type=None,
delivery_type=delivery_label,
).model_dump()
def _row_to_message(self, r) -> ChatMessage:

View File

@ -1,21 +1,106 @@
"""채팅(chat) 도메인 e2e 테스트 — init / messages(오프닝 seed) / send(협상 진행~종료).
agent config.use_mock=true 내장 MockAgentClient 쓴다(결정론적 플로우).
실제 agent(9500) 대신, 결정론적 테스트 더블(_FakeAgentClient) FastAPI 의존성 오버라이드로 주입한다.
(프로덕션 코드에는 mock 없다 테스트 전용 double 이다.) 더블은 backend 보내는
client_step(직전 step) user_input 으로 단계를 진행한다.
dev negosium_db 그대로 쓰므로 전용 테스트 행만 시드/정리한다.
"""
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_chat_user"
TEST_PW = "pytest1234"
TEST_SUPPLIER_NAME = "파이테스트채팅공급사"
MARK = "PYTESTCHAT-"
def _parse_price(text_):
if not text_:
return None
digits = "".join(ch for ch in text_ if ch.isdigit())
return int(digits) if digits else None
class _FakeAgentClient(IAgentClient):
"""결정론적 테스트 더블. ctx.client_step(직전 봇 step)+user_input 으로 단계를 진행한다.
플로우: (오프닝)서비스안내 협상품목안내 가격협상 가격제시 목표가 이하면 성공 종료.
'포기'/'거부' 입력은 언제든 실패 종료.
"""
async def chat(self, session_id, user_input, ctx) -> AgentTurn:
sid = session_id or "fake-session"
if user_input and ("포기" in user_input or "거부" in user_input):
return AgentTurn(
session_id=sid, step="협상종료", client_step="협상종료",
script="협상이 종료되었습니다.", chat_end=True, outcome="failure",
)
if user_input is None: # 오프닝(턴0) — 양쪽 공통
return AgentTurn(session_id=sid, step="서비스안내", client_step="서비스안내",
script="협상에 참여해 주셔서 감사합니다. 시작하시겠어요?",
input_mode="confirm", input_options=["네, 시작할게요"])
if ctx.rq_type == "재견적":
return self._requote(sid, user_input, ctx)
return self._renego(sid, user_input, ctx)
def _renego(self, sid, user_input, ctx) -> AgentTurn:
if ctx.client_step == "서비스안내":
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="희망 공급가를 입력해 주세요.", input_mode="price")
# 가격협상 단계: 목표가 이하면 합의 종료, 아니면 한 번 더 요청
price = _parse_price(user_input)
if price is not None and ctx.target_price and price <= ctx.target_price:
return AgentTurn(session_id=sid, step="협상종료", client_step="협상종료",
script=f"제안하신 {price:,}원으로 합의되었습니다. 감사합니다.",
chat_end=True, outcome="success", indicator_value=99.0)
return AgentTurn(session_id=sid, step="가격협상", client_step="가격협상",
script="조금 더 조정된 가격을 제안해 주시겠어요?", input_mode="price",
indicator_value=50.0)
def _requote(self, sid, user_input, ctx) -> AgentTurn:
# 서비스안내 → 가격제안 → 배송형태선택 → 가격협상_입력 → 결과안내(summaryCM)
if ctx.client_step == "서비스안내":
return AgentTurn(session_id=sid, step="가격제안", client_step="가격제안",
script="제시 목표가로 진행하시겠어요?", input_mode="yes_no",
input_options=["", "아니오"])
if ctx.client_step == "가격제안":
return AgentTurn(session_id=sid, step="배송형태선택", client_step="배송형태선택",
script="배송형태를 선택해 주세요.", input_mode="delivery_type",
input_options=["협력사배송", "지정택배배송", "픽업배송"])
if ctx.client_step == "배송형태선택":
return AgentTurn(session_id=sid, step="가격협상_입력", client_step="가격협상_입력",
script="희망 공급가를 입력해 주세요.", input_mode="price")
if ctx.client_step == "가격협상_입력":
return AgentTurn(session_id=sid, step="결과안내", client_step="결과안내",
script="투찰 결과를 확인해 주세요.", input_mode="yes_no",
input_options=["투찰확정", "정보수정"])
# 결과안내 "투찰확정" → 결과제출 → 협상종료
return AgentTurn(session_id=sid, step="협상종료", client_step="협상종료",
script="투찰이 확정되었습니다. 감사합니다.",
chat_end=True, outcome="success")
@pytest.fixture(autouse=True)
def _fake_agent():
"""모든 chat 테스트에서 실제 agent 대신 결정론적 더블을 주입(의존성 오버라이드)."""
from router.router import app
app.dependency_overrides[get_agent_client] = lambda: _FakeAgentClient()
yield
app.dependency_overrides.pop(get_agent_client, None)
@pytest_asyncio.fixture
async def chat_seed(db_engine):
"""공급사 + 유저 + 세션 2건(본인: 협상중 P / 협상생성 C) + 1건(타 공급사 X) 시드."""
@ -28,6 +113,7 @@ async def chat_seed(db_engine):
("P", 2, 1, 2, 2, supplier_id), # 협상중 / 재협상 / +2h / 견적진행중
("C", 1, 1, 2, 1, supplier_id), # 협상생성 / 재협상 / +2h / 견적생성
("X", 2, 1, 2, 2, other_supplier_id), # 타 공급사 → 차단
("Q", 2, 2, 2, 2, supplier_id), # 협상중 / 재견적 / +2h / 견적진행중
]
sids, qids = {}, {}
@ -174,6 +260,45 @@ async def test_send_flow_to_completion(client, chat_seed, db_engine):
assert await _session_bid(db_engine, sid) == 90000 # 입찰가 확정
async def test_requote_summary_captures_delivery_type(client, chat_seed):
"""재견적: 배송형태선택에서 고른 값이 summaryCM 요약(delivery_type)에 담긴다."""
token = await _login_token(client)
sid = chat_seed["sids"]["Q"]
await _messages(client, token, sid) # 오프닝(서비스안내)
await _send(client, token, sid, "네, 시작할게요") # → 가격제안
await _send(client, token, sid, "") # → 배송형태선택
await _send(client, token, sid, "협력사배송") # → 가격협상_입력
r = (await _send(client, token, sid, "90000", user_input_type="price")).json() # → 결과안내(summaryCM)
assert r["result"]["success"] is True
msg = r["message"]
assert msg["bot_chat_type"] == "summaryCM"
assert msg["summary"]["delivery_type"] == "협력사배송"
async def test_agent_provided_bot_chat_type_and_indicator_passthrough(client, chat_seed):
"""agent 가 bot_chat_type/indicator_value 를 직접 주면 backend 는 step 추측 없이 그대로 전달한다."""
from router.router import app
class _T(IAgentClient):
async def chat(self, session_id, user_input, ctx):
if user_input is None:
return AgentTurn(session_id=session_id, step="서비스안내", client_step="서비스안내",
script="안녕하세요", input_mode="confirm", input_options=["확인"])
return AgentTurn(session_id=session_id, step="가격협상", client_step="가격협상",
script="지표를 확인하세요", input_mode="price",
indicator_value=55.0, bot_chat_type="indicator")
app.dependency_overrides[get_agent_client] = lambda: _T()
token = await _login_token(client)
sid = chat_seed["sids"]["P"]
await _messages(client, token, sid) # 오프닝
r = (await _send(client, token, sid, "확인")).json() # → 가격협상(indicator)
assert r["result"]["success"] is True
msg = r["message"]
assert msg["bot_chat_type"] == "indicator" # agent 값 그대로
assert msg["indicator_value"] == 55.0 # 지표 전달
async def test_send_price_out_of_range(client, chat_seed):
token = await _login_token(client)
sid = chat_seed["sids"]["P"]