"""협상 에이전트(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 ok: bool = True # agent 호출 성공 여부 (False 면 CHAT_AGENT_UNAVAILABLE) timed_out: bool = False # 타임아웃 여부. True 면 agent 가 이미 진행했을 수 있어 desync 위험 → 별도 처리. @dataclass class AgentChatContext: """새 세션 시작 시 agent 에 주입하는 협상 컨텍스트. 기존 세션이면 user_input 만 의미 있다.""" tenant_id: str # X-Tenant-ID = 견적(갑) 회사 company_id rq_type: str = "재협상" # 재협상 | 재견적 target_price: int = 0 # 갑 목표 매입가(원) 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) 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": ctx.rq_type, "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: 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"), ok=True, ) def get_agent_client() -> IAgentClient: """실제 agent(9500) 위임 클라이언트를 반환한다(FastAPI Depends 용).""" return HttpAgentClient()