- Req_Chat 을 session_id/user_input/client_step 3필드로 축소 — rq_type·목표가·앵커·품목가·
매출액·유통코드·파트너 유형·수용률 필드 전부 제거
- NegotiationContextLoader 신설: 세션 시작 시 공유 DB 1회 조회로 컨텍스트 확정
· rq_type = sessions.qt_type ({1,3}→재협상 / {2,4}→재견적)
· anchor = sessions.anchoring_price(박제) — NULL 이면 무할인 폴백 anchor=target (v1.2 정책 승계)
· 매출액 = suppliers.total_revenue(KTC 미러), 유통코드 = quotations.supplier_type 매핑
· 파트너 유형 = 상품별 distinct supplier 수 → PartnerType enum(0=NONE/1=SINGLE/2=MULTIPLE)
- 가격 수용률은 세션 내 동적 계산: max(0, (첫 제시가−현재가)/첫 제시가)
- DB 쿼리를 backend crud 패턴으로 분리: INegoContextCRUD(ABC)+NegoContextCRUD,
IChatSessionRepository 인터페이스 추가 (테스트 더블 주입 가능)
- 와일드카드 1% 수락 시 합의가=offer_1pct 반영 + Res_Chat.settled_price 신설 —
backend 요약/입찰가가 이를 최우선 사용 (19,800원 수락이 20,000원으로 기록되던 버그 수정)
- backend: agent 전송 바디 3필드로 축소, 앵커/파트너 조회 메서드 제거,
test_anchoring_chat 을 새 구조로 재작업(박제 소비/폴백 검증은 agent 테스트로 이관)
- 데모 페이지(/demo·negotiation_demo.html) 제거 — 컨텍스트 주입 경로 폐지로 무의미
- 테스트: agent 83/83, backend 57/57 (컨텍스트 로더 실데이터 왕복 4종 + CRUD 더블 검증 포함)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
113 lines
5.6 KiB
Python
113 lines
5.6 KiB
Python
"""협상 에이전트(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()
|