o2o-negosium-original/backend/services/agent_client.py
민헌 dcce82a630 feat(backend): 협상 채팅(chat) API + agent 위임
- /v1/negotiation/sessions/{id}/chat/{init,messages,send} 추가
- agent(9500) 위임 어댑터(IAgentClient) + mock(use_mock) 격리 → agent 미연동 시 1402 graceful degrade
- negotiation.chats 메시지 영속화(meta JSONB) + 종료 시 세션 입찰/거부 확정(단일 트랜잭션)
- 동시전송 가드(유저 메시지 pre-claim/CHAT_IN_PROGRESS) + 실패 시 롤백, 마감/만료 분기, init 만료 정리
- ChatSender enum, chat 에러코드(1400~1403), chats ORM 모델, AgentConfig
- 테스트 10건(test_chat.py), AGENT_INTEGRATION.md 연동 규약 문서

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 14:24:57 +09:00

154 lines
7.0 KiB
Python

"""협상 에이전트(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()