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:
parent
6fcb4d4e8c
commit
8f070e362b
@ -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
|
||||
|
||||
@ -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, "")
|
||||
|
||||
@ -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
|
||||
|
||||
@ -2,8 +2,9 @@
|
||||
|
||||
agent Res_Chat → 이 ChatMessage 매핑:
|
||||
step→step, client_step→display_step, script→script,
|
||||
input_mode→next_input_mode, input_options→next_input_type, chat_end→chat_end.
|
||||
indicator/summary/reject 는 이번 범위 외(예약 필드, 기본 None).
|
||||
input_mode→next_input_mode, input_options→next_input_type, chat_end→chat_end,
|
||||
bot_chat_type→bot_chat_type, indicator_value→indicator_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 일 때만 채워짐
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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"]
|
||||
|
||||
Loading…
Reference in New Issue
Block a user