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")) # 소프트 삭제 여부 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): class chats(MAIN_BASE):
# negotiation.chats (협상 채팅 메시지 로그). session 1 : N chats. (session_id, seq) 유니크. # negotiation.chats (협상 채팅 메시지 로그). session 1 : N chats. (session_id, seq) 유니크.
@staticmethod @staticmethod

View File

@ -49,6 +49,8 @@ class ErrorType(Enum):
CHAT_PRICE_OUT_OF_RANGE = auto() # 1401 제시가가 허용 범위를 벗어남 CHAT_PRICE_OUT_OF_RANGE = auto() # 1401 제시가가 허용 범위를 벗어남
CHAT_AGENT_UNAVAILABLE = auto() # 1402 협상 에이전트(agent) 호출 실패 CHAT_AGENT_UNAVAILABLE = auto() # 1402 협상 에이전트(agent) 호출 실패
CHAT_IN_PROGRESS = auto() # 1403 직전 턴 처리 중(동시 전송 가드) CHAT_IN_PROGRESS = auto() # 1403 직전 턴 처리 중(동시 전송 가드)
CHAT_INPUT_MODE_MISMATCH = auto() # 1404 직전 봇이 요구한 입력 모드와 보낸 입력이 불일치(잘못된 버튼/타입) → 화면 리싱크 필요
CHAT_AGENT_TIMEOUT = auto() # 1405 agent 응답 타임아웃(처리됐을 수 있음 — 롤백/재시도 시 desync 위험)
# ErrorType 의 HTTP_* 값과 status_code 를 맞춰 router 단에서 raise 한다. # ErrorType 의 HTTP_* 값과 status_code 를 맞춰 router 단에서 raise 한다.
@ -140,3 +142,18 @@ class ChatSender(Enum):
BOT = 1 # 갑(바이어/agent) — bot 메시지 BOT = 1 # 갑(바이어/agent) — bot 메시지
USER = 2 # 공급사(을) — user 입력 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 datetime import datetime, timezone
from typing import Optional, Tuple 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 sqlalchemy.ext.asyncio import AsyncSession
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, sessions 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 from common.logger import LOG
@ -19,8 +19,9 @@ class IChatCRUD(ABC):
pass pass
@abstractmethod @abstractmethod
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]]]:
"""마지막 메시지의 (seq, sender). 없으면 (0, None). 동시전송 가드 + seq 채번에 사용.""" """마지막 메시지의 (seq, sender, meta). 없으면 (0, None, None).
동시전송 가드 + seq 채번 + 입력-모드 검증(직전 봇 meta.input_mode)에 사용."""
pass pass
@abstractmethod @abstractmethod
@ -31,10 +32,6 @@ class IChatCRUD(ABC):
async def soft_delete_message(self, cdb: AsyncSession, chat_id) -> ErrorType: async def soft_delete_message(self, cdb: AsyncSession, chat_id) -> ErrorType:
pass pass
@abstractmethod
async def count_bot_messages(self, cdb: AsyncSession, session_id) -> Tuple[ErrorType, int]:
pass
@abstractmethod @abstractmethod
async def get_item_by_id(self, cdb: AsyncSession, item_id) -> Tuple[ErrorType, items]: async def get_item_by_id(self, cdb: AsyncSession, item_id) -> Tuple[ErrorType, items]:
pass pass
@ -64,23 +61,23 @@ class ChatCRUD(IChatCRUD):
LOG.e_no_callstack(ex) LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, [] 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: try:
query = ( query = (
select(chats.seq, chats.sender) select(chats.seq, chats.sender, chats.meta)
.where(chats.session_id == session_id, chats.deleted == False) # noqa: E712 .where(chats.session_id == session_id, chats.deleted == False) # noqa: E712
.order_by(desc(chats.seq)) .order_by(desc(chats.seq))
.limit(1) .limit(1)
) )
err_type, rows = await DB_SESSION_MNG.execute(cdb, query, "get_last failed.") err_type, rows = await DB_SESSION_MNG.execute(cdb, query, "get_last failed.")
if err_type != ErrorType.SUCCESS: if err_type != ErrorType.SUCCESS:
return err_type, (0, None) return err_type, (0, None, None)
if not rows: if not rows:
return ErrorType.SUCCESS, (0, None) return ErrorType.SUCCESS, (0, None, None)
return ErrorType.SUCCESS, (rows[0][0], rows[0][1]) return ErrorType.SUCCESS, (rows[0][0], rows[0][1], rows[0][2])
except Exception as ex: except Exception as ex:
LOG.e_no_callstack(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: async def insert_message(self, cdb: AsyncSession, message: chats) -> ErrorType:
try: try:
@ -98,22 +95,6 @@ class ChatCRUD(IChatCRUD):
LOG.e_no_callstack(ex) LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED 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]: async def get_item_by_id(self, cdb: AsyncSession, item_id) -> Tuple[ErrorType, items]:
try: try:
query = select(items).where(items.item_id == item_id, items.deleted == False).limit(1) # noqa: E712 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 매핑: agent Res_Chat → 이 ChatMessage 매핑:
step→step, client_step→display_step, script→script, step→step, client_step→display_step, script→script,
input_mode→next_input_mode, input_options→next_input_type, chat_end→chat_end. input_mode→next_input_mode, input_options→next_input_type, chat_end→chat_end,
indicator/summary/reject 는 이번 범위 외(예약 필드, 기본 None). bot_chat_type→bot_chat_type, indicator_value→indicator_value.
summary(요약카드 데이터)는 backend 가 비즈니스 데이터로 조립해 채운다.
""" """
from typing import Optional 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_mode: Optional[str] = None # confirm|yes_no|percent|price|delivery_type
next_input_type: Optional[list[str]] = None # 다음 입력 선택지 next_input_type: Optional[list[str]] = None # 다음 입력 선택지
chat_end: bool = False 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 bot_chat_type: Optional[str] = None # summaryRSP|summaryCM|rejectRSP|rejectCM|indicator
summary: Optional[ChatSummary] = None # summaryRSP/summaryCM 일 때만 채워짐 summary: Optional[ChatSummary] = None # summaryRSP/summaryCM 일 때만 채워짐

View File

@ -1,13 +1,11 @@
"""협상 에이전트(agent, 포트 9500) 호출 클라이언트. """협상 에이전트(agent, 포트 9500) 호출 클라이언트.
backend 는 /chat 한 턴을 agent 로 위임한다(README: "backend 가 /chat 을 agent 로 위임"). backend 는 /chat 한 턴을 agent 로 위임한다(README: "backend 가 /chat 을 agent 로 위임").
agent 의 계약(Req_Chat/Res_Chat)에 맞춘 어댑터. agent 가 아직 없거나 로컬에서 미연동일 때를 위해 agent 의 계약(Req_Chat/Res_Chat)에 맞춘 어댑터.
mock 구현을 두고 config(AgentConfig.use_mock) 로 선택한다 — 이 격리 덕에 backend/프론트를
agent 완성 여부와 무관하게 통합 테스트할 수 있다.
agent 응답(Res_Chat) → AgentTurn 매핑: agent 응답(Res_Chat) → AgentTurn 매핑:
step, client_step, script, input_mode(=next_input_mode), input_options(=next_input_type), 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 from abc import ABC, abstractmethod
@ -32,7 +30,10 @@ class AgentTurn:
outcome: Optional[str] = None # "success" | "failure" (종료 시) outcome: Optional[str] = None # "success" | "failure" (종료 시)
card_id: Optional[str] = None card_id: Optional[str] = None
indicator_value: Optional[float] = 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) ok: bool = True # agent 호출 성공 여부 (False 면 CHAT_AGENT_UNAVAILABLE)
timed_out: bool = False # 타임아웃 여부. True 면 agent 가 이미 진행했을 수 있어 desync 위험 → 별도 처리.
@dataclass @dataclass
@ -42,8 +43,16 @@ class AgentChatContext:
tenant_id: str # X-Tenant-ID = 견적(갑) 회사 company_id tenant_id: str # X-Tenant-ID = 견적(갑) 회사 company_id
rq_type: str = "재협상" # 재협상 | 재견적 rq_type: str = "재협상" # 재협상 | 재견적
target_price: int = 0 # 갑 목표 매입가(원) target_price: int = 0 # 갑 목표 매입가(원)
anchor_price: int = 0 # 앵커링가(목표가보다 낮음) anchor_price: int = 0 # 앵커링가(목표가보다 낮음). quotation_settings.anchoring_value 로 계산.
turn: int = 0 # 직전까지의 봇 턴 수(mock 진행용; 실제 agent 는 무시) # 핸드오프 #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) extra: dict = field(default_factory=dict)
@ -58,7 +67,7 @@ class HttpAgentClient(IAgentClient):
"""실제 agent(9500) 위임 구현. agent POST /v1/chat 호출.""" """실제 agent(9500) 위임 구현. agent POST /v1/chat 호출."""
async def chat(self, session_id: Optional[str], user_input: Optional[str], ctx: AgentChatContext) -> AgentTurn: async def chat(self, session_id: Optional[str], user_input: Optional[str], ctx: AgentChatContext) -> AgentTurn:
import httpx # 지연 import — mock 모드에서는 httpx 의존을 강제하지 않는다. import httpx
body = { body = {
"session_id": session_id, # 핸드오프 #1: agent 가 이 값을 세션 키로 그대로 사용해야 함 "session_id": session_id, # 핸드오프 #1: agent 가 이 값을 세션 키로 그대로 사용해야 함
@ -66,6 +75,13 @@ class HttpAgentClient(IAgentClient):
"user_input": user_input, "user_input": user_input,
"target_price": ctx.target_price, "target_price": ctx.target_price,
"anchor_price": ctx.anchor_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 headers = {"X-Tenant-ID": ctx.tenant_id} # 핸드오프 #2
try: try:
@ -73,8 +89,12 @@ class HttpAgentClient(IAgentClient):
resp = await cli.post("/v1/chat", json=body, headers=headers) resp = await cli.post("/v1/chat", json=body, headers=headers)
resp.raise_for_status() resp.raise_for_status()
data = resp.json() 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: 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(ok=False)
return AgentTurn( return AgentTurn(
@ -88,66 +108,11 @@ class HttpAgentClient(IAgentClient):
outcome=data.get("outcome"), outcome=data.get("outcome"),
card_id=data.get("card_id"), card_id=data.get("card_id"),
indicator_value=data.get("indicator_value"), indicator_value=data.get("indicator_value"),
bot_chat_type=data.get("bot_chat_type"),
ok=True, 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: def get_agent_client() -> IAgentClient:
"""config 에 따라 mock/실제 클라이언트를 반환한다(FastAPI Depends 용).""" """실제 agent(9500) 위임 클라이언트를 반환한다(FastAPI Depends 용)."""
return MockAgentClient() if agent_config.use_mock else HttpAgentClient() return HttpAgentClient()

View File

@ -15,11 +15,12 @@ from typing import Optional
from fastapi import Depends 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.db_session_manager import DB_SESSION_MNG
from common.database.model.models import chats, items, quotations, sessions, supplier_users, suppliers from common.database.model.models import chats, items, quotation_settings, quotations, sessions, supplier_users, suppliers
from common.enums import ChatSender, DBWRType, ErrorType, QuotationStatus, SessionStatus from common.enums import ChatSender, DBWRType, DeliveryType, ErrorType, QuotationStatus, SessionStatus
from common.logger import LOG
from common.models.gmodel import UserInfo from common.models.gmodel import UserInfo
from crud.chat_crud import ChatCRUD, IChatCRUD from crud.chat_crud import ChatCRUD, IChatCRUD
from crud.session_crud import ISessionCRUD, SessionCRUD from crud.session_crud import ISessionCRUD, SessionCRUD
@ -50,6 +51,31 @@ PRICE_FLOOR_RATIO = 0.3
PRICE_CEIL_RATIO = 1.7 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: class ChatService:
def __init__( def __init__(
self, self,
@ -166,11 +192,11 @@ class ChatService:
async def _seed_opening(self, sess) -> Optional[ChatMessage]: async def _seed_opening(self, sess) -> Optional[ChatMessage]:
"""오프닝(턴0) 봇 메시지를 agent 로 생성하고 seq=1 로 저장한다. 동시 진입 충돌은 무시(유니크가 방어).""" """오프닝(턴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) turn = await self.agent.chat(session_id=str(sess.session_id), user_input=None, ctx=ctx)
if not turn.ok: if not turn.ok:
return None 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( await DB_SESSION_MNG.execute_lambda_run(
[chats.DBType()], [lambda s: self.chat_crud.insert_message(s, bot)] [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) res.result.SetResult(ErrorType.CHAT_PRICE_OUT_OF_RANGE)
return res return res
# 직전 메시지(seq/sender) — 동시전송 가드 + seq 채번 # 직전 메시지(seq/sender/meta) — 동시전송 가드 + seq 채번 + 입력-모드 검증
err_type, (max_seq, last_sender) = await DB_SESSION_MNG.execute_lambda( err_type, (max_seq, last_sender, last_meta) = await DB_SESSION_MNG.execute_lambda(
chats.DBType(), DBWRType.DB_READ.value, chats.DBType(), DBWRType.DB_READ.value,
lambda s: self.chat_crud.get_last(s, sess.session_id), lambda s: self.chat_crud.get_last(s, sess.session_id),
) )
@ -227,13 +253,11 @@ class ChatService:
if last_sender == ChatSender.USER.value: if last_sender == ChatSender.USER.value:
res.result.SetResult(ErrorType.CHAT_IN_PROGRESS) res.result.SetResult(ErrorType.CHAT_IN_PROGRESS)
return res return res
# ③ 입력-모드 검증: 직전 봇이 요구한 모드와 보낸 입력이 어긋나면 agent 로 넘기지 않는다(제자리걸음/오진행 방지).
err_type, turn_no = await DB_SESSION_MNG.execute_lambda( if not _input_matches_mode(last_meta, user_input, user_input_type):
chats.DBType(), DBWRType.DB_READ.value, LOG.i(f"[chat] 입력-모드 불일치 session_id={sess.session_id} "
lambda s: self.chat_crud.count_bot_messages(s, 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)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
return res return res
# 유저 메시지 선점(pre-claim): (session_id, seq) 부분 유니크로 동시 전송을 직렬화한다. # 유저 메시지 선점(pre-claim): (session_id, seq) 부분 유니크로 동시 전송을 직렬화한다.
@ -250,21 +274,41 @@ class ChatService:
return res return res
# agent 위임 (한 턴). 실패 시 선점한 유저 메시지를 롤백 → 재시도 가능. # 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) turn = await self.agent.chat(session_id=str(sess.session_id), user_input=user_input, ctx=ctx)
if not turn.ok: if not turn.ok:
# 선점 유저 메시지 롤백(backend 일관성 유지).
await DB_SESSION_MNG.execute_lambda_run( await DB_SESSION_MNG.execute_lambda_run(
[chats.DBType()], [lambda s: self.chat_crud.soft_delete_message(s, user_msg.chat_id)] [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 return res
# 종료 스텝이면 폼 종류(summary/reject)를 부여하고, 요약카드면 데이터까지 조립한다. # 폼 종류: agent 가 직접 내려주면(bot_chat_type) 신뢰하고, 없으면 step+qt_type 으로 폴백 유도.
bot_chat_type = _resolve_bot_chat_type(sess.qt_type, turn.step) # → 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 summary = None
if bot_chat_type in ("summaryRSP", "summaryCM"): if bot_chat_type in ("summaryRSP", "summaryCM"):
final_price = price if price is not None else (sess.bid_price or sess.target_price) 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+거부사유·제시가). 한 트랜잭션. # 봇 메시지 + 종료 시 확정(성공=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)
@ -274,8 +318,11 @@ class ChatService:
if turn.outcome == "success": if turn.outcome == "success":
new_status = SessionStatus.DONE.value new_status = SessionStatus.DONE.value
# 입찰가 = 이번 턴 가격(보통 None) → 마지막 제시가 → 목표가 순으로 확정. # 입찰가 = 이번 턴 가격(보통 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) 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)) funcs.append(lambda s: self.chat_crud.finalize_session(s, sess.session_id, new_status, bid_price=bid))
else: else:
new_status = SessionStatus.REJECTED.value new_status = SessionStatus.REJECTED.value
@ -298,23 +345,72 @@ class ChatService:
return res return res
# ---- 빌더 / 매퍼 ---------------------------------------------------- # ---- 빌더 / 매퍼 ----------------------------------------------------
async def _agent_context(self, sess, turn: int) -> AgentChatContext: async def _agent_context(self, sess, client_step: Optional[str] = None, item=None) -> AgentChatContext:
# 핸드오프 #2/#5: X-Tenant-ID 는 견적(갑) 회사 company_id 여야 한다. # 핸드오프 #2: X-Tenant-ID 는 견적(갑) 회사 company_id 여야 한다.
# 상품(partner.items)의 소유 회사가 갑(buyer)이므로 item.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) — 로깅으로 추적 tenant_id = "" # 해석 실패 시 빈 값(agent 가 400) — 로깅으로 추적
err_type, item = await DB_SESSION_MNG.execute_lambda( if item is not None and item.company_id:
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:
tenant_id = str(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 "재견적" 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( return AgentChatContext(
tenant_id=tenant_id, rq_type=rq_type, 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: def _build_user_chat(self, sess, seq: int, user_input: str, user_input_type: Optional[str], price: Optional[int]) -> chats:
return chats( return chats(
chat_id=uuid.uuid4(), session_id=sess.session_id, seq=seq, 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: 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)에서도 폼이 재현된다. # bot_chat_type/summary 도 meta 에 영속화 → 히스토리 복원(messages)에서도 폼이 재현된다.
# indicator_value 는 전용 컬럼(분석/replay용)에도 적재. meta 는 순수 표시용.
return chats( return chats(
chat_id=uuid.uuid4(), session_id=sess.session_id, seq=seq, chat_id=uuid.uuid4(), session_id=sess.session_id, seq=seq,
sender=ChatSender.BOT.value, sender=ChatSender.BOT.value,
target_price=int(sess.target_price or 0), target_price=int(sess.target_price or 0),
indicator_value=turn.indicator_value,
meta={ meta={
"script": turn.script, "step": turn.step, "client_step": turn.client_step, "script": turn.script, "step": turn.step, "client_step": turn.client_step,
"input_mode": turn.input_mode, "input_options": turn.input_options, "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_mode=meta.get("input_mode"),
next_input_type=meta.get("input_options"), next_input_type=meta.get("input_options"),
chat_end=bool(meta.get("chat_end", False)), 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"), bot_chat_type=meta.get("bot_chat_type"),
summary=ChatSummary(**summary_d) if summary_d else None, 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) 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 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: async def _delivery_choice(self, sess) -> Optional[str]:
"""협상 결과 요약 카드 데이터 조립(item + 견적 담당 MD + 공급사/담당자 + 최종 제시가). """재견적 '배송형태선택' 봇 단계(meta.input_mode='delivery_type') 직후 유저가 고른 배송형태 라벨.
종료 스텝에서 1회만 호출.""" 없으면 None. (봇 프롬프트 seq 이후 첫 비삭제 유저 메시지의 script)"""
err_type, item = await DB_SESSION_MNG.execute_lambda( def _bot_seq(s):
items.DBType(), DBWRType.DB_READ.value, stmt = (
lambda s: self.chat_crud.get_item_by_id(s, sess.item_id), select(chats.seq)
) .where(chats.session_id == sess.session_id, chats.sender == ChatSender.BOT.value,
item = item if err_type == ErrorType.SUCCESS else None 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): def _supplier_name(s):
stmt = select(suppliers.name).where(suppliers.supplier_id == sess.supplier_id).limit(1) stmt = select(suppliers.name).where(suppliers.supplier_id == sess.supplier_id).limit(1)
return DB_SESSION_MNG.execute(s, stmt) 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) 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 ("", "") 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 는 호출부에서 전달.
last_price = await self._last_user_price(sess)
resolved_price = int(last_price if last_price else (final_price or 0)) 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): def _iso(dt):
if dt is None: if dt is None:
return "" 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_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_lead_time=(str(item.lead_time) if item and item.lead_time is not None else ""),
item_display_date=_iso(quote.start_time), item_display_date=_iso(quote.start_time),
item_delivery_type="", item_delivery_type=item_delivery_label,
final_price=resolved_price, final_price=resolved_price,
nego_start_date=_iso(quote.start_time), nego_start_date=_iso(quote.start_time),
nego_end_date=_iso(quote.end_time), nego_end_date=_iso(quote.end_time),
supplier_name=supplier_name or "", supplier_name=supplier_name or "",
supplier_manager_name=sup_mgr_name or "", supplier_manager_name=sup_mgr_name or "",
supplier_manager_email=sup_mgr_email or "", supplier_manager_email=sup_mgr_email or "",
delivery_type=None, delivery_type=delivery_label,
).model_dump() ).model_dump()
def _row_to_message(self, r) -> ChatMessage: def _row_to_message(self, r) -> ChatMessage:

View File

@ -1,21 +1,106 @@
"""채팅(chat) 도메인 e2e 테스트 — init / messages(오프닝 seed) / send(협상 진행~종료). """채팅(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 를 그대로 쓰므로 전용 테스트 행만 시드/정리한다. dev negosium_db 를 그대로 쓰므로 전용 테스트 행만 시드/정리한다.
""" """
import uuid import uuid
import bcrypt import bcrypt
import pytest
import pytest_asyncio import pytest_asyncio
from sqlalchemy import text from sqlalchemy import text
from services.agent_client import AgentTurn, IAgentClient, get_agent_client
TEST_LOGIN_ID = "pytest_chat_user" TEST_LOGIN_ID = "pytest_chat_user"
TEST_PW = "pytest1234" TEST_PW = "pytest1234"
TEST_SUPPLIER_NAME = "파이테스트채팅공급사" TEST_SUPPLIER_NAME = "파이테스트채팅공급사"
MARK = "PYTESTCHAT-" 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 @pytest_asyncio.fixture
async def chat_seed(db_engine): async def chat_seed(db_engine):
"""공급사 + 유저 + 세션 2건(본인: 협상중 P / 협상생성 C) + 1건(타 공급사 X) 시드.""" """공급사 + 유저 + 세션 2건(본인: 협상중 P / 협상생성 C) + 1건(타 공급사 X) 시드."""
@ -28,6 +113,7 @@ async def chat_seed(db_engine):
("P", 2, 1, 2, 2, supplier_id), # 협상중 / 재협상 / +2h / 견적진행중 ("P", 2, 1, 2, 2, supplier_id), # 협상중 / 재협상 / +2h / 견적진행중
("C", 1, 1, 2, 1, supplier_id), # 협상생성 / 재협상 / +2h / 견적생성 ("C", 1, 1, 2, 1, supplier_id), # 협상생성 / 재협상 / +2h / 견적생성
("X", 2, 1, 2, 2, other_supplier_id), # 타 공급사 → 차단 ("X", 2, 1, 2, 2, other_supplier_id), # 타 공급사 → 차단
("Q", 2, 2, 2, 2, supplier_id), # 협상중 / 재견적 / +2h / 견적진행중
] ]
sids, qids = {}, {} 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 # 입찰가 확정 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): async def test_send_price_out_of_range(client, chat_seed):
token = await _login_token(client) token = await _login_token(client)
sid = chat_seed["sids"]["P"] sid = chat_seed["sids"]["P"]