o2o-negosium-original/backend/services/agent_client.py
민헌 a2c299aa14 refactor(anchoring): 도메인 이름 전면 개편 — adjustments·anchoring_value/price·price_range·sample 용어 통일
용어 체계: 값=anchoring_value(정수‰)·가격=anchoring_price·조정=adjustment·구간=price_range·표본=sample

- DB: rate_adjustments→anchoring.adjustments (id→adjustment_id, price_bracket_index→price_range_index,
  nego_count→sample_count, anchor_rate_before/after→anchoring_value_before/after,
  consumed_session_ids→used_session_ids)
- sessions: target_anchoring_price→anchoring_price, anchor_rate_permille→anchoring_value,
  last_offered_price→last_offer_price, anchoring_adjustment_id→used_by_adjustment_id
- 뷰: rate_history/current_rates→value_history/current_values, delta_permille→value_change
- 코드: calc_price_range_index·calc_anchoring_price·evaluate_samples·get_current_value·
  get_latest_adjusted_value·get_current_anchoring_value·fetch_current_values·get_base_anchoring_value·
  Adjustment(ORM)·update_last_offer_price, 상수 ANCHORING_VALUE_MIN/MAX·ADJUSTMENT_STEP·
  PRICE_RANGE_COUNT/INDEX_MAX, 배치 로그 키 bracket=→price_range=
- API: negodata protocol 필드 target_anchoring_price→anchoring_price (front 생성 모델·컴포넌트 동반)
- 기존 DB 마이그레이션 신설: schedules/anchoring/migrations/20260706_rename_anchoring.sql
  (멱등 DO 블록 — 테이블·컬럼·뷰·인덱스·PK 제약. 코드 배포와 동시 적용 필요)
- postgres-init 01·04, 문서 6종 동기화
- 실배포 전 수정 포함: main.py argparse 화(--dry-run 단독·오타 플래그 기동 전 차단),
  박제 정합식 calc_anchoring_price 재사용, clamped 지표가 실제 포화만 집계(경계값 유지 제외)

주의: sessions.anchoring_value(정수‰)와 quotation_settings.anchoring_value(구 float 비율)는
같은 이름·다른 단위 — 구 컬럼은 미변경.

검증: 모듈 20·negodata 50·backend 57 테스트 통과, front tsc·vite build 통과,
로컬 DB 마이그레이션 적용 후 배치 dry-run·상주 기동·양 서버 부팅 확인.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 11:17:01 +09:00

121 lines
6.1 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
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 # 앵커링가(목표가보다 낮음). 세션 생성 시 박제된 sessions.anchoring_price.
item_price: int = 0 # 기존 공급가(품목 기준가). agent 가격협상_확인 인하율 산출용.
# 핸드오프 #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,
"item_price": ctx.item_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()