"""협상 에이전트(agent, 포트 9500) 호출 클라이언트. backend 는 /chat 한 턴을 agent 로 위임한다(README: "backend 가 /chat 을 agent 로 위임"). agent 의 계약(Req_Chat/Res_Chat)에 맞춘 어댑터. agent 가 아직 없거나 로컬에서 미연동일 때를 위해 mock 구현을 두고 config(AgentConfig.use_mock) 로 선택한다 — 이 격리 덕에 backend/프론트를 agent 완성 여부와 무관하게 통합 테스트할 수 있다. 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. """ 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 ok: bool = True # agent 호출 성공 여부 (False 면 CHAT_AGENT_UNAVAILABLE) @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 # 앵커링가(목표가보다 낮음) turn: int = 0 # 직전까지의 봇 턴 수(mock 진행용; 실제 agent 는 무시) 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 # 지연 import — mock 모드에서는 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, } 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 Exception as ex: LOG.e_no_callstack(f"[AgentClient] agent 호출 실패: {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"), 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()