모듈·backend·문서 3개 관점의 적대 리뷰에서 확인된 결함 일괄 수정: [모듈] - 배치에 company_ids 스코프 옵션 추가 — 통합 테스트가 공유 dev DB 의 실세션을 소비/마킹하던 문제 해소(테스트는 시드 회사로 한정), 표적 수동 실행 옵션 겸용 - Redis 방어: compose 포트를 127.0.0.1 바인딩(무인증 공개 차단), get_rate 에 범위([10,200]) 검증 — 오염 캐시값은 미스 취급 후 자가 교정, 미스 백필은 SET NX (배치가 방금 쓴 새 값을 구값으로 덮는 write-after-read 경합 방지) - 배치: Redis ping 후 re-SET(다운 시 셀×timeout 지연 없이 즉시 스킵), 스캔 조인 ON 절에 quotations/items deleted 필터(철회 거래를 학습에서 배제), 제외 마킹을 청크별 커밋(레거시 대량 첫 실행의 장시간 단일 트랜잭션 방지) - main: SIGTERM/SIGINT 핸들러(docker stop 시 정리 로직 보장), --once 부분 실패 시 종료코드 1(런북/cron 감지 가능) [backend] - finalize_session·update_last_offered_price 에 status=IN_PROGRESS 가드 — negodata 일괄마감/중복 전송 경합이 종료된 세션을 되살리거나 가격 흔적을 사후 변경하는 것 차단(파생 판정 결정성 보호) - 신규 DB 부트스트랩: sessions 3컬럼을 postgres-init/01-schema·04-alter 에도 반영(backend 가 모듈 DDL 없이 기동) — anchoring 스키마 자체는 모듈 소유 유지 - 낡은 주석 정리(agent_client·quotation_settings 의 구 앵커 산출 서술) [테스트·문서] - 신규 테스트: 격주 게이트 골든(ISO 주차), supplier_type NULL, 가격 제시율 0% WARN — 모듈 18개·backend 57개 통과 - 문서 정합 감사 20건 반영: 잔존 33,334/노출 문구 제거, §10 SQL 을 실제 코드 (LEFT JOIN+deleted)와 일치, §11 자동/수동 검증 구분, FastAPI 오기 제거, 인수인계 reader 시그니처(db 인자), TODO 백로그 5건 기록 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
121 lines
6.1 KiB
Python
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.target_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()
|