From 8f070e362bfed12cf0dcc2c81fd886ab7e827555 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=AF=BC=ED=97=8C?= Date: Fri, 19 Jun 2026 17:32:50 +0900 Subject: [PATCH] =?UTF-8?q?feat(chat):=20agent=20=EC=97=B0=EB=8F=99=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC=20=E2=80=94=20mock=20=EC=A0=9C=EA=B1=B0?= =?UTF-8?q?=C2=B7DB=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=97=B0=EB=8F=99?= =?UTF-8?q?=C2=B7=ED=91=9C=ED=98=84=20passthrough?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- backend/common/database/model/models.py | 19 ++ backend/common/enums.py | 17 ++ backend/crud/chat_crud.py | 41 +--- .../router/v1/negotiation/chat_protocol.py | 7 +- backend/services/agent_client.py | 97 +++----- backend/services/chat_service.py | 208 ++++++++++++++---- backend/tests/test_chat.py | 127 ++++++++++- 7 files changed, 375 insertions(+), 141 deletions(-) diff --git a/backend/common/database/model/models.py b/backend/common/database/model/models.py index ae6049f..7b7aecb 100644 --- a/backend/common/database/model/models.py +++ b/backend/common/database/model/models.py @@ -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 diff --git a/backend/common/enums.py b/backend/common/enums.py index 9ee216f..7811a10 100644 --- a/backend/common/enums.py +++ b/backend/common/enums.py @@ -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, "") diff --git a/backend/crud/chat_crud.py b/backend/crud/chat_crud.py index 10a47d2..c10b2ab 100644 --- a/backend/crud/chat_crud.py +++ b/backend/crud/chat_crud.py @@ -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 diff --git a/backend/router/v1/negotiation/chat_protocol.py b/backend/router/v1/negotiation/chat_protocol.py index c6e38bc..a483c1e 100644 --- a/backend/router/v1/negotiation/chat_protocol.py +++ b/backend/router/v1/negotiation/chat_protocol.py @@ -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 일 때만 채워짐 diff --git a/backend/services/agent_client.py b/backend/services/agent_client.py index 0653e5d..055da89 100644 --- a/backend/services/agent_client.py +++ b/backend/services/agent_client.py @@ -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() diff --git a/backend/services/chat_service.py b/backend/services/chat_service.py index 8aaffb4..6a2007d 100644 --- a/backend/services/chat_service.py +++ b/backend/services/chat_service.py @@ -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: diff --git a/backend/tests/test_chat.py b/backend/tests/test_chat.py index 289fdfa..3cc5011 100644 --- a/backend/tests/test_chat.py +++ b/backend/tests/test_chat.py @@ -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"]