"""ChatService — 채팅 페이지 오케스트레이션. backend 가 협상 한 턴을 agent(9500) 로 위임하고, 말풍선 로그(negotiation.chats)를 영속화하며, 종료 시 세션 상태(negotiation.sessions)를 전이한다. agent 는 외부 고정 계약(agent_client 어댑터). - init : 상품/견적 메타 + 현재 세션 상태 (타이머용 마감 시각 포함) - messages : 대화 히스토리 복원. 비어 있고 협상중이면 agent 오프닝 한 턴을 seed(지연 생성). - send : (검증 → 유저 메시지 저장 → agent 위임 → 봇 메시지 저장 → 종료 시 입찰 확정) 단일 트랜잭션. append-only — 새 봇 메시지 1건만 반환(전체 refetch 회피). """ import uuid from datetime import datetime, timezone from typing import Optional from fastapi import Depends from common.database.db_session_manager import DB_SESSION_MNG from common.database.model.models import chats, items, quotations, sessions from common.enums import ChatSender, DBWRType, ErrorType, QuotationStatus, SessionStatus from common.models.gmodel import UserInfo from crud.chat_crud import ChatCRUD, IChatCRUD from crud.session_crud import ISessionCRUD, SessionCRUD from router.v1.negotiation.chat_protocol import ChatMessage, Res_ChatInit, Res_ChatMessages, Res_ChatSend from services.agent_client import AgentChatContext, IAgentClient, get_agent_client from services.auth_service import AuthService # 가격 허용 범위 배수(목표가 기준). 범위를 벗어난 제시가는 CHAT_PRICE_OUT_OF_RANGE 로 막는다. PRICE_FLOOR_RATIO = 0.3 PRICE_CEIL_RATIO = 1.7 class ChatService: def __init__( self, auth: AuthService = Depends(AuthService), session_crud: ISessionCRUD = Depends(SessionCRUD), chat_crud: IChatCRUD = Depends(ChatCRUD), agent: IAgentClient = Depends(get_agent_client), ): self.auth = auth self.session_crud = session_crud self.chat_crud = chat_crud self.agent = agent # ---- 공통 전처리 ---------------------------------------------------- async def _auth_and_own_session(self, user_info: UserInfo, access_token: str, session_id_str: str): """인증 → 세션 로드 → 소유(공급사) 검증. (SUCCESS, sess) 또는 (err, None).""" err_type, info = await self.auth.authenticate(user_info, access_token) if err_type != ErrorType.SUCCESS: return err_type, None try: session_id = uuid.UUID(session_id_str) except (ValueError, TypeError): return ErrorType.NEGO_NOT_FOUND, None err_type, sess = await DB_SESSION_MNG.execute_lambda( sessions.DBType(), DBWRType.DB_READ.value, lambda s: self.session_crud.get_session_by_id(s, session_id), ) if err_type != ErrorType.SUCCESS or sess is None: return ErrorType.NEGO_NOT_FOUND, None if str(sess.supplier_id) != info.supplier_id: return ErrorType.NEGO_FORBIDDEN, None return ErrorType.SUCCESS, sess # ---- init ----------------------------------------------------------- async def init(self, user_info: UserInfo, access_token: str, session_id_str: str) -> Res_ChatInit: res = Res_ChatInit() err_type, sess = await self._auth_and_own_session(user_info, access_token, session_id_str) if err_type != ErrorType.SUCCESS: res.result.SetResult(err_type) return res err_type, quote = await DB_SESSION_MNG.execute_lambda( quotations.DBType(), DBWRType.DB_READ.value, lambda s: self.session_crud.get_quotation_by_id(s, sess.quotation_id), ) if err_type != ErrorType.SUCCESS or quote is None: res.result.SetResult(ErrorType.NEGO_NOT_FOUND) return res 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 or item is None: res.result.SetResult(ErrorType.NEGO_NOT_FOUND) return res # 마감 시간 초과 + 협상생성이면 미참여로 정리 (participate 와 동일 일관성). end = quote.end_time if end is not None and end.tzinfo is None: end = end.replace(tzinfo=timezone.utc) if end is not None and end < datetime.now(timezone.utc) and sess.status == SessionStatus.CREATED.value: await DB_SESSION_MNG.execute_lambda_run( [sessions.DBType()], [lambda s: self.session_crud.update_session_status(s, sess.session_id, SessionStatus.NOT_PARTICIPATED.value)], ) sess.status = SessionStatus.NOT_PARTICIPATED.value res.session_id = str(sess.session_id) res.session_status = sess.status res.quotation_id = str(sess.quotation_id) res.quotation_end_time = quote.end_time.isoformat(timespec="seconds") if quote.end_time else "" res.quotation_memo = quote.memo or "" res.item_id = str(item.item_id) res.item_name = item.name or "" res.item_code = item.code or "" res.item_image = item.image_url or "" res.item_price = item.price or 0 res.item_model_name = item.model_name or "" res.item_maker_name = item.manufacturer or "" res.item_spec = item.spec or "" res.item_lead_time = str(item.lead_time) if item.lead_time is not None else "" res.item_min_order_quantity = item.moq or "" res.item_vat_yn = item.vat_yn res.item_delivery_fee_yn = item.delivery_fee_yn return res # ---- messages ------------------------------------------------------- async def messages(self, user_info: UserInfo, access_token: str, session_id_str: str) -> Res_ChatMessages: res = Res_ChatMessages() err_type, sess = await self._auth_and_own_session(user_info, access_token, session_id_str) if err_type != ErrorType.SUCCESS: res.result.SetResult(err_type) return res err_type, rows = await DB_SESSION_MNG.execute_lambda( chats.DBType(), DBWRType.DB_READ.value, lambda s: self.chat_crud.list_by_session(s, sess.session_id), ) if err_type != ErrorType.SUCCESS: res.result.SetResult(err_type) return res # 비어 있고 협상중이면 agent 오프닝 한 턴을 seed (재진입 시 인사 메시지 보존) if not rows and sess.status == SessionStatus.IN_PROGRESS.value: opening = await self._seed_opening(sess) if opening is not None: res.items = [opening] return res res.items = [self._row_to_message(r) for r in rows] return res async def _seed_opening(self, sess) -> Optional[ChatMessage]: """오프닝(턴0) 봇 메시지를 agent 로 생성하고 seq=1 로 저장한다. 동시 진입 충돌은 무시(유니크가 방어).""" ctx = self._agent_context(sess, turn=0) 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) await DB_SESSION_MNG.execute_lambda_run( [chats.DBType()], [lambda s: self.chat_crud.insert_message(s, bot)] ) return self._chat_to_message(bot) # ---- send (핵심) ---------------------------------------------------- async def send(self, user_info: UserInfo, access_token: str, session_id_str: str, user_input_type: Optional[str], user_input: str) -> Res_ChatSend: res = Res_ChatSend() err_type, sess = await self._auth_and_own_session(user_info, access_token, session_id_str) if err_type != ErrorType.SUCCESS: res.result.SetResult(err_type) return res # 협상중이 아니면 대화 불가 if sess.status != SessionStatus.IN_PROGRESS.value: res.result.SetResult(ErrorType.CHAT_NOT_IN_PROGRESS) return res # 견적 마감/시간 검증 err_type, quote = await DB_SESSION_MNG.execute_lambda( quotations.DBType(), DBWRType.DB_READ.value, lambda s: self.session_crud.get_quotation_by_id(s, sess.quotation_id), ) if err_type != ErrorType.SUCCESS or quote is None: res.result.SetResult(ErrorType.NEGO_NOT_FOUND) return res if quote.status == QuotationStatus.CLOSED.value: res.result.SetResult(ErrorType.NEGO_QUOTATION_CLOSED) return res end = quote.end_time if end is not None and end.tzinfo is None: end = end.replace(tzinfo=timezone.utc) if end is not None and end < datetime.now(timezone.utc): res.result.SetResult(ErrorType.NEGO_DEADLINE_PASSED) return res # 가격 입력이면 범위 검증 price = _parse_price(user_input) if user_input_type == "price" else None if user_input_type == "price": if price is None or not _in_price_range(price, sess.target_price): 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( chats.DBType(), DBWRType.DB_READ.value, lambda s: self.chat_crud.get_last(s, sess.session_id), ) if err_type != ErrorType.SUCCESS: res.result.SetResult(err_type) return res # 직전이 유저 메시지면 이전 턴이 아직 처리 중(봇 응답 미도착) → 중복 전송 거절 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) return res # 유저 메시지 선점(pre-claim): (session_id, seq) 부분 유니크로 동시 전송을 직렬화한다. # 경합에서 밀리면(같은 seq 충돌) agent 를 호출하지 않고 CHAT_IN_PROGRESS 로 거절 → 중복 진행 방지. user_msg = self._build_user_chat(sess, seq=max_seq + 1, user_input=user_input, user_input_type=user_input_type, price=price) err_type = await DB_SESSION_MNG.execute_lambda_run( [chats.DBType()], [lambda s: self.chat_crud.insert_message(s, user_msg)] ) if err_type == ErrorType.DB_ALREADY_SAME_KEY: res.result.SetResult(ErrorType.CHAT_IN_PROGRESS) return res if err_type != ErrorType.SUCCESS: res.result.SetResult(err_type) return res # agent 위임 (한 턴). 실패 시 선점한 유저 메시지를 롤백 → 재시도 가능. ctx = self._agent_context(sess, turn=turn_no) turn = await self.agent.chat(session_id=str(sess.session_id), user_input=user_input, ctx=ctx) if not turn.ok: 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) return res # 봇 메시지 + 종료 시 확정(성공=DONE+입찰가 / 실패=REJECTED+거부사유·제시가). 한 트랜잭션. bot_msg = self._build_bot_chat(sess, seq=max_seq + 2, turn=turn) funcs = [lambda s: self.chat_crud.insert_message(s, bot_msg)] new_status = sess.status if turn.chat_end: if turn.outcome == "success": new_status = SessionStatus.DONE.value bid = price if price is not None else sess.target_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 funcs.append(lambda s: self.chat_crud.finalize_session( s, sess.session_id, new_status, reject_reason=(user_input or None), reject_price=price, )) err_type = await DB_SESSION_MNG.execute_lambda_run([chats.DBType()], funcs) if err_type != ErrorType.SUCCESS: # 봇 저장 실패 시에도 선점 유저 메시지를 롤백해 stuck(CHAT_IN_PROGRESS) 방지. 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(err_type) return res res.message = self._chat_to_message(bot_msg) res.session_status = new_status return res # ---- 빌더 / 매퍼 ---------------------------------------------------- def _agent_context(self, sess, turn: int) -> AgentChatContext: # 핸드오프 #2/#5: X-Tenant-ID 는 견적(갑) 회사 company_id 여야 한다. # 현재 quotation.user_id 만 보유 → 정확한 company_id 해석(company.users 조회)은 agent 연동 시 보완. tenant_id = "" # mock 은 무시. 실제 연동 시 quotation 의 buyer company_id 로 채운다. rq_type = "재협상" if sess.qt_type == 1 else "재견적" anchor = int(sess.target_price * 0.99) if sess.target_price else 0 return AgentChatContext( tenant_id=tenant_id, rq_type=rq_type, target_price=int(sess.target_price or 0), anchor_price=anchor, turn=turn, ) 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, sender=ChatSender.USER.value, target_price=int(price) if price is not None else 0, meta={"script": user_input, "user_input_type": user_input_type}, ) def _build_bot_chat(self, sess, seq: int, turn) -> chats: 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), meta={ "script": turn.script, "step": turn.step, "client_step": turn.client_step, "input_mode": turn.input_mode, "input_options": turn.input_options, "chat_end": turn.chat_end, "card_id": turn.card_id, }, ) def _chat_to_message(self, c: chats) -> ChatMessage: """방금 만든 chats 객체 → 응답 ChatMessage (DB 재조회 없이).""" meta = c.meta or {} return ChatMessage( chat_id=str(c.chat_id), session_id=str(c.session_id), seq=c.seq, sender=c.sender, script=meta.get("script") or "", user_input_type=meta.get("user_input_type"), step=meta.get("step") or "", display_step=meta.get("client_step") or "", next_input_mode=meta.get("input_mode"), next_input_type=meta.get("input_options"), chat_end=bool(meta.get("chat_end", False)), ) def _row_to_message(self, r) -> ChatMessage: """DB 행(chats) → 응답 ChatMessage.""" return self._chat_to_message(r) # ---- 가격 유틸 ---------------------------------------------------------- def _parse_price(text: Optional[str]) -> Optional[int]: if not text: return None digits = "".join(ch for ch in text if ch.isdigit()) return int(digits) if digits else None def _in_price_range(price: int, target_price: Optional[int]) -> bool: if not target_price: return price > 0 return int(target_price * PRICE_FLOOR_RATIO) <= price <= int(target_price * PRICE_CEIL_RATIO)