"""협상 에이전트(agent, 포트 9500) 호출 클라이언트. backend 는 /chat 한 턴을 agent 로 위임한다(README: "backend 가 /chat 을 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, bot_chat_type. """ from abc import ABC, abstractmethod from dataclasses import dataclass, field from typing import Optional from common.logger import LOG from config.server_configs import agent_config @dataclass class AgentTurn: """agent 한 턴 응답(Res_Chat) 의 backend 표현.""" session_id: Optional[str] = None step: str = "" client_step: str = "" script: str = "" input_mode: Optional[str] = None # 프론트 next_input_mode 로 매핑 input_options: Optional[list[str]] = None # 프론트 next_input_type 로 매핑 chat_end: bool = False 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 # 성공 확정 이후 턴에 agent 가 내려주는 합의가. 와일드카드 1% 인하 수락처럼 유저가 직접 # 입력하지 않은 가격으로 타결될 수 있어, 요약 표시가·입찰가 확정 시 이 값을 최우선 사용한다. settled_price: Optional[int] = None ok: bool = True # agent 호출 성공 여부 (False 면 CHAT_AGENT_UNAVAILABLE) timed_out: bool = False # 타임아웃 여부. True 면 agent 가 이미 진행했을 수 있어 desync 위험 → 별도 처리. @dataclass class AgentChatContext: """agent 호출 컨텍스트. 협상 컨텍스트(목표가/앵커/품목가/매출액/유통코드/파트너 유형/수용률)는 더 이상 전송하지 않는다 — agent 가 session_id 로 DB(negotiation.sessions 등)에서 직접 조회·계산한다(Req_Chat 슬림화). rq_type/target_price 는 backend 자체 로직(표현 폴백·테스트 더블)용으로만 유지하며 전송되지 않는다. """ tenant_id: str # X-Tenant-ID = 견적(갑) 회사 company_id rq_type: str = "재협상" # 재협상(1:1) | 재견적(1:N) — backend 로컬 용도 target_price: int = 0 # 갑 목표 매입가(원) — backend 로컬 용도 # 핸드오프 #1: backend 가 보는 현재 step(직전 봇 step). agent 가 자기 세션 step 과 대조해 desync 감지에 쓸 수 있다. client_step: Optional[str] = None extra: dict = field(default_factory=dict) class IAgentClient(ABC): @abstractmethod async def chat(self, session_id: Optional[str], user_input: Optional[str], ctx: AgentChatContext) -> AgentTurn: """협상 한 턴. session_id 없으면 새 세션 시작. user_input 으로 진행(버튼 텍스트/가격).""" ... 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 body = { "session_id": session_id, # 핸드오프 #1: agent 가 이 값을 세션 키로 그대로 사용해야 함 # 협상 컨텍스트(rq_type/목표가/앵커/품목가/매출액/유통코드/파트너 유형/수용률)는 보내지 # 않는다 — agent 가 session_id 로 DB 에서 직접 조회·계산한다(NegotiationContextLoader). "user_input": user_input, # 핸드오프 #1: backend 가 보는 직전 step. agent 가 desync 감지에 사용(미구현 시 무시됨). "client_step": ctx.client_step, } headers = {"X-Tenant-ID": ctx.tenant_id} # 핸드오프 #2 try: async with httpx.AsyncClient(base_url=agent_config.base_url, timeout=agent_config.timeout_sec) as cli: 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 호출 실패 session_id={session_id} step={ctx.client_step}: {ex}") return AgentTurn(ok=False) return AgentTurn( session_id=data.get("session_id"), step=data.get("step") or "", client_step=data.get("client_step") or "", script=data.get("script") or "", input_mode=data.get("input_mode"), input_options=data.get("input_options"), chat_end=bool(data.get("chat_end", False)), outcome=data.get("outcome"), card_id=data.get("card_id"), indicator_value=data.get("indicator_value"), bot_chat_type=data.get("bot_chat_type"), settled_price=data.get("settled_price"), ok=True, ) def get_agent_client() -> IAgentClient: """실제 agent(9500) 위임 클라이언트를 반환한다(FastAPI Depends 용).""" return HttpAgentClient()