Merge pull request 'fix/chat' (#6) from fix/chat into main

Reviewed-on: Negosium/o2o-negosium#6
This commit is contained in:
minheon 2026-06-22 00:24:11 +00:00
commit 830d1aa6de
19 changed files with 690 additions and 150 deletions

186
AGENT_HANDOFF_CHAT_FIXES.md Normal file
View File

@ -0,0 +1,186 @@
# Chat 오류 수정 — agent 측 핸드오프
> 작성: backend/frontend 담당. 대상: **agent(9500) 담당자**.
> 협상 진행 중 발생하던 오류(대화 막힘·오진행·desync)를 분석해 ①~⑤로 정리했다.
> backend/frontend 에서 고칠 수 있는 부분은 이미 반영했고(아래 "backend/front 완료"),
> **agent 코드 수정이 필요한 항목만 이 문서로 넘긴다.** 관련 계약은 [AGENT_INTEGRATION.md](AGENT_INTEGRATION.md) 도 함께 참고.
## 배경 — 무엇이 문제였나
backend 와 agent 가 **세션 상태를 각자 독립적으로** 관리한다(backend=`negotiation.chats`, agent=`learning.chat_sessions`).
그런데 backend 는 매 턴 `session_id` 만 보내고 `step` 은 보내지 않아, 한쪽 상태가 어긋나면(리셋/타임아웃/재시도)
**영구 desync** 가 발생한다(예: backend 는 오프닝인 줄 아는데 agent 는 가격협상 단계부터 재개).
선행 시스템(KT be_v2 → chat_server)은 매 턴 `step`+`index` 를 보내 클라이언트가 상태를 쥐는 방식이라 이 문제가 없었다.
엔진 자체는 정상이다 — 양쪽 상태가 맞고 입력 모드가 맞으면 흐름(서비스안내→담당자확인→협상품목안내→기존가격제시→가격협상_확인→협상완료→협상종료)도 합의가 기록도 정상.
---
## ① 세션 step desync — agent 가 `client_step` 을 검증/동기화해야 함
**backend/front 완료**
- backend 가 매 턴 요청 body 에 `client_step`(backend 가 보는 직전 봇 step)을 함께 보낸다.
(`backend/services/agent_client.py` HttpAgentClient body, `chat_service._agent_context`)
- frontend 는 desync 의심 에러(1404/1405) 시 `messages` 를 다시 불러와 화면을 서버 기준으로 리싱크한다.
**agent 가 해줘야 할 일** ⚠️
1. `POST /v1/chat` 요청에 새로 추가된 `client_step`(Optional[str]) 을 받는다.
2. agent 세션(`learning.chat_sessions.step`)과 `client_step` 이 다르면 **desync** 다. 다음 중 하나로 대응:
- (권장) agent 가 자기 step 을 정답으로 보고 응답하되, 응답에 현재 `step`/`client_step` 을 정확히 실어 보내 backend/front 가 따라오게 한다. (이미 `Res_Chat.step` 있음 — 항상 신뢰 가능한 값으로 채울 것)
- 또는 desync 가 크면 명시적 에러/리셋 신호를 주어 backend 가 해당 세션을 재동기화하게 한다.
3. (대안) **세션 상태 조회 API** 를 제공하면 backend 가 타임아웃/재진입 시 정합을 맞출 수 있다:
`GET /v1/chat/sessions/{session_id}` → `{ step, ended, ... }`.
---
## ② agent 타임아웃 시 재시도 desync — `/chat` 멱등성 필요
**backend/front 완료**
- 타임아웃을 일반 실패와 구분한다(에러코드 `CHAT_AGENT_TIMEOUT=1405`). backend 는 타임아웃 시 선점 유저 메시지를 롤백하고 경고 로깅한다(`chat_service.send`).
- frontend 는 1405 시 메시지를 리싱크한다.
**문제의 본질**: 타임아웃은 "agent 가 이미 턴을 처리(step 전진)했는데 응답만 늦은" 경우일 수 있다.
이때 backend 가 롤백하면 backend 는 "안 일어난 일", agent 는 "전진" → desync. 단순 재시도하면 **중복 전진**.
**agent 가 해줘야 할 일** ⚠️
1. `POST /v1/chat` 을 **멱등(idempotent)** 하게 만든다. 같은 `session_id` + 같은 턴(예: backend 가 보낼 `client_step` 또는 멱등키)에 대한
재요청은 **이미 처리한 결과를 그대로 반환**하고 step 을 다시 전진시키지 않는다.
- 멱등키 후보: `(session_id, client_step, user_input)` 또는 backend 가 헤더로 보낼 `Idempotency-Key`(요청 시 합의 필요).
2. 멱등이 어렵다면 ①-3 의 **세션 상태 조회 API** 만이라도 제공. backend 가 타임아웃 후 현재 step 을 읽어 재시도 여부를 판단한다.
> backend 의 agent 타임아웃 한계는 `config.local.toml [AgentConfig] timeout_sec`(현재 10초). 필요 시 조정 가능.
---
## ③ 입력-모드 검증 — ✅ backend/front 에서 완결 (agent 작업 없음)
- backend 가 직전 봇 메시지의 `input_mode` 와 이번 유저 입력을 대조해, 어긋나면 agent 로 넘기지 않고 `CHAT_INPUT_MODE_MISMATCH=1404` 반환.
(`chat_service._input_matches_mode`) — price 단계에 버튼텍스트, yes_no 단계에 가격 같은 케이스를 막아 "제자리걸음/오진행"을 차단.
- frontend 는 1404 시 리싱크.
- **단, agent 응답의 `input_mode`/`input_options` 가 정확해야** 이 검증이 옳게 동작한다. agent 는 각 step 의 `input_mode`(confirm·yes_no·percent·price·delivery_type)와 `input_options`(버튼 라벨)를 **정확히** 채워 보낼 것.
---
## ④ agent 로 가는 협상 컨텍스트 부족 + RL 미작동 ⚠️ (가장 중요)
**확인된 현상**: 정상 완료된 협상에서도 `learning.experience_logs=0`, `chat_sessions.used_action_ids=[]`.
즉 **Q-learning 카드선택(RL)이 한 번도 작동하지 않았다.** 흐름이 `기존가격제시 → 가격협상_확인` 으로 바로 가며 RL `가격협상` 단계를 건너뛴다.
**backend/front 진행 상황** — agent state 입력 5개 차원의 데이터 소스 현황:
| 필드 | agent 차원 | DB 소스 | 상태 |
|---|---|---|---|
| `target_price` | price_zone | `negotiation.sessions.target_price` | ✅ 실데이터 |
| `anchor_price` | price_zone | `quotation_settings.anchoring_value` → `round(target*(1-value))` | ✅ 실데이터 (배선 완료) |
| `partner_count` | partner | 견적당 `negotiation.sessions` 개수 | ✅ 실데이터 (배선 완료) |
| `revenue_amount` | revenue | ❌ 스키마에 컬럼 없음 | ⚠️ **기본값 20,000,000 — 논의 필요** |
| `distribution_code` | distribution | ❌ 컬럼 없음 (code_map 키여야 함) | ⚠️ **기본값 "A" — 논의 필요** |
| `acceptance_ratio` | acceptance | ❌ 컬럼 없음 | ⚠️ **기본값 0.05 — 논의 필요** |
→ `anchor_price`/`partner_count` 는 실 DB 값으로 배선했다(`chat_service._agent_context`). 조회 실패 시 각각 `target*0.99` / `1` 폴백(항상 양수 보장 → agent state `ValueError` 방지).
**🔴 논의가 필요한 부분 — `revenue_amount` / `distribution_code` / `acceptance_ratio`**
이 셋은 현재 우리 스키마(`quotation`, `quotation_settings`, `partner.items`, `partner.suppliers` 등)에 **대응 컬럼이 전혀 없다.**
**일단 기본값으로 고정해 둔다**(20,000,000 / "A" / 0.05). 단, 이 상태에서는 agent 가 모든 협상을 같은 state 로 보아
RL 이 상황을 구분하지 못하므로, 아래를 **agent 담당자와 합의한 뒤** backend 스키마/시드/배선을 확정해야 한다:
1. **각 필드의 정확한 의미·단위·출처 정의**
- `revenue_amount`(매출액): *누구의* 매출인가(거래처/공급사/품목 단위?), 단위(원), 어느 시점 값인가.
- `distribution_code`(유통 코드): 우리가 어떤 분류로 채울지 + **agent `config code_map` 의 유효 키 목록**(없는 코드면 agent 가 `ValueError` → RL skip). 코드맵 공유 필수.
- `acceptance_ratio`(가격 수용률 0~1): 산출 정의(과거 협상 이력 집계? 어느 기간/단위?) — 집계 로직 주체(backend/agent) 합의.
2. **합의 후 backend 작업**: 위 정의에 맞춰 스키마 컬럼 추가(예: `quotation_settings` / 신규 테이블) + 시드 + `_agent_context` 배선.
3. **(별개) RL 단계가 왜 안 타는지** 점검: 재협상(1:1) 단일라운드 흐름에서 `가격협상`(카드선택) 단계가 실행되도록 step 라우팅 확인. 의도적으로 안 타는 거라면 그 조건(예: 재견적/멀티라운드에서만)을 backend 에 알려줄 것.
---
## ⑤ 합의가 기록 — ✅ backend/front 에서 완결 (agent 작업 없음)
- frontend 는 가격 입력 턴에 `user_input_type:"price"` 를 정확히 보낸다(확인됨).
- backend 는 종료(success) 시 입찰가를 `이번 턴 가격 → 마지막 제시가 → 목표가` 순으로 확정하며,
**협상 중 제시가가 하나도 기록되지 않아 목표가로 폴백하면 경고 로깅**한다(`chat_service.send`). 운영 로그에서 이 경고가 보이면 가격 캡처 누락을 의심할 것.
---
## ⑥ be_v2↔chat_server 경우의 수 대조 결과 (선행 시스템 전수 비교)
선행 시스템(be_v2↔chat_server)의 채팅 전체 경우의 수를 우리 backend↔agent 가 소화하는지 대조했다.
**핵심 종료 폼(summaryRSP/CM, rejectRSP/CM, 정보변경)은 정합하게 소화**되며(우리 backend `_resolve_bot_chat_type`
= chat_server step별 `type` 매핑과 일치, be_v2 의 reject success=True 버그도 미승계), 아래만 후속 처리한다.
| 항목 | 처리 방향 |
|---|---|
| 재견적 1:N 교차집계(입찰종료/동가입찰/선호공급사/견적마감) | **negodata 에서 추후 처리** (현 chat 흐름엔 세션 단위 확정만 있음) |
| `delivery_type` 미영속 | ✅ **backend 수정 완료** — 재견적 `배송형태선택` 값을 summaryCM 요약(`delivery_type`)에 담는다(`chat_service._delivery_choice`). |
| `indicator` 협상 지표 | ⚠️ **agent 작업 요청** (⑦) |
| 폼 타입(`bot_chat_type`) 직접 전달 | ⚠️ **agent 작업 요청** (⑦) — backend passthrough 준비 완료 |
| `card_data`(경쟁사 가격차 등 카드 표시 데이터) | 🔵 **추후 공동 논의** |
| `mbti` / `is_new_quote` | ✅ 우리 프로젝트에서 불필요 — 제외 확정 |
---
## ⑦ agent 가 표현 계약을 직접 책임지도록 (chat 단순화 P1)
**배경/문제**: 선행 chat_server 는 step JSON 의 `type`(summaryRSP/CM·rejectRSP/CM·indicator)을 엔진이 직접 응답에 실었다.
우리는 이 책임을 backend 로 옮겨, backend 가 **agent 의 내부 step 문자열**(`협상완료`/`협상실패`/`결과안내`/`결과제출`)을
하드코딩 집합과 대조해 폼을 역유도한다(`chat_service._resolve_bot_chat_type`). agent 가 step 이름을 하나만 바꿔도
backend 가 **조용히 폼을 None 으로** 떨구는 취약 결합이다. indicator 게이지도 agent 가 값을 안 보내 통째 비활성.
**backend/front 완료** ✅
- backend 는 이제 agent 응답의 `bot_chat_type` / `indicator_value` 를 **있으면 그대로 신뢰**하고,
없으면 기존 step 기반(`_resolve_bot_chat_type`)으로 **폴백**한다(`agent_client.AgentTurn`, `chat_service.send`).
→ agent 가 보내기 시작하면 backend 코드 변경 없이 자동 전환되고, step-이름 결합은 폴백으로만 남는다.
- backend 는 `indicator_value` 를 `negotiation.chats.indicator_value` 컬럼에 영속 + `ChatMessage.indicator_value` 로 전달한다.
- frontend 는 이미 `bot_chat_type` 분기(요약/거부/지표)와 **indicator 게이지 컴포넌트**가 구현·연결돼 있어, **agent 가 값만 채우면 즉시 표시**된다.
**agent 가 해줘야 할 일** ⚠️
1. `Res_Chat` 에 **`bot_chat_type: Optional[str]`** 추가하고 각 step 에서 정확히 채울 것
(`summaryRSP`/`summaryCM`/`rejectRSP`/`rejectCM`/`indicator`, 일반 텍스트는 None/`text`).
- 이러면 backend 가 step 이름을 추측하지 않으므로, agent 가 step 명을 바꿔도 폼이 안 깨진다.
2. `Res_Chat` 에 **`indicator_value: Optional[float]`(1~99)** 추가하고 `가격협상`(카드선택) 턴에 채울 것.
⚠️ backend 컬럼이 `NUMERIC(8,6)`(절대값 <100)이라 **반드시 1~99 범위**(100 금지). (가능하면 `indicator_range` PZ1/2/3 도)
3. (선택) 종료 폼 단계(`협상완료` 등)는 `chat_end=False` + `bot_chat_type=summaryXXX` 로, 실제 종료는 다음 `협상종료` 턴 `chat_end=True` 로 — 현 흐름 유지면 OK.
---
## ⑧ backend 수정으로 생긴 agent 계약 의존성 (agent 가 깨지 말아야 할 것)
이번 backend 정리(use_mock 제거, anchor/배송 배선, 응답 passthrough)로 agent 응답에 대한 **묵시적 의존성**이 생겼다.
agent 가 아래를 바꾸면 backend 기능이 조용히 깨진다 — **업데이트라기보단 "유지 필요" 항목**이다.
1. **`배송형태선택` 단계는 응답 `input_mode` 를 정확히 `"delivery_type"` 으로 보낼 것.**
- backend 는 재견적 요약(summaryCM)의 배송형태(`delivery_type`)를, **`input_mode=="delivery_type"` 인 봇 메시지 직후의 유저 선택 라벨**로 캡처한다(`chat_service._delivery_choice`).
- 이 step 의 `input_mode` 를 다른 값으로 바꾸면 배송형태가 요약에 안 담긴다. `input_options` 라벨(협력사배송/지정택배배송/픽업배송)도 유지 권장(프론트 표시·매핑 기준).
2. **`anchor_price` 는 backend 가 계산해 보내므로 agent 는 받은 값을 그대로 쓸 것(재계산/덮어쓰기 금지).**
- backend 가 견적설정 `quotation_settings.anchoring_value` 로 `anchor = round(target*(1-value))` 를 계산해 요청 body 에 넣는다.
- agent 가 자체 `anchor_for(target)` 로 다시 계산하면 backend 와 어긋난다. 요청의 `anchor_price` 를 신뢰할 것.
3. **backend 에 더 이상 mock 이 없다 → agent 가 반드시 떠 있어야 한다.**
- `use_mock`/`MockAgentClient` 제거됨. agent 미기동/오류 시 chat 은 `CHAT_AGENT_UNAVAILABLE(1402)` 로 degrade(프론트 toast). 로컬/CI 에서도 실제 agent 연동 전제.
4. **(재확인) `partner_count` 는 backend 가 '견적당 세션 수'로 산출해 보낸다** — agent 는 받은 값으로 partner 차원(single/multiple/none)만 판정.
---
## 변경된 `POST /v1/chat` 요청 body (backend → agent)
```jsonc
{
"session_id": "<negotiation.sessions.session_id>", // 그대로 세션 키로 사용 (핸드오프 #1, 기존)
"rq_type": "재협상 | 재견적",
"user_input": "<버튼텍스트 | 가격문자열 | null(오프닝)>",
"target_price": 100000,
"anchor_price": 99000, // ④ quotation_settings.anchoring_value 기반 (실데이터)
// ▼ RL state 입력 (④)
"revenue_amount": 20000000, // ④ 🔴 기본값 고정 — DB 소스 없음, 논의 필요
"distribution_code": "A", // ④ 🔴 기본값 고정 — DB 소스 없음, code_map 키여야 함, 논의 필요
"partner_count": 1, // ④ 견적당 세션 수 (실데이터)
"acceptance_ratio": 0.05, // ④ 🔴 기본값 고정 — DB 소스 없음, 논의 필요
"client_step": "기존가격제시" // ① backend 가 보는 직전 봇 step (desync 감지용)
}
```
헤더: `X-Tenant-ID: <견적(갑) company_id>` (기존)
요약: **agent 작업 필요 = ①(step 동기화/응답 step 신뢰), ②(/chat 멱등 또는 세션상태 조회 API), ④(RL 단계 라우팅 + state 데이터 소스 합의), ⑦(`bot_chat_type`+`indicator_value` 응답 추가).**
③⑤ 는 backend/front 에서 완결. ⑥ 의 `delivery_type`·⑦ 의 passthrough/게이지는 backend·front 준비 완료(agent 가 값만 채우면 동작), 재견적 교차집계는 negodata 에서 별도 처리.

View File

@ -1,3 +1,5 @@
.venv/
venv/
__pycache__/
*.pyc
.pytest_cache/

View File

@ -155,6 +155,25 @@ class quotations(MAIN_BASE):
deleted = Column(Boolean, nullable=False, server_default=text("false")) # 소프트 삭제 여부
class quotation_settings(MAIN_BASE):
# quotation.quotation_settings (견적 설정). 앵커링값(anchoring_value) 조회용 — agent RL state 입력.
@staticmethod
def DBType():
return DBType.QUOTATION.value
__tablename__ = "quotation_settings"
__table_args__ = {"schema": "quotation"}
qt_setting_id = Column(UUID(as_uuid=True), primary_key=True, server_default=text("gen_random_uuid()")) # 견적 설정 식별자(PK)
user_id = Column(UUID(as_uuid=True), nullable=False) # 생성 유저(company.users.user_id)
target_margin_rate = Column(Numeric(8, 6), nullable=False) # 목표 마진율
anchoring_value = Column(Numeric(8, 6), nullable=False, server_default=text("0.01")) # 앵커링 값(비율) — anchor=round(target*(1-value))
card_count = Column(Integer, nullable=False, server_default=text("3")) # 협상 내 협상카드 사용 횟수
created_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')")) # 생성 시각(UTC)
updated_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')")) # 수정 시각(UTC, 앱에서 갱신)
deleted = Column(Boolean, nullable=False, server_default=text("false")) # 소프트 삭제 여부
class chats(MAIN_BASE):
# negotiation.chats (협상 채팅 메시지 로그). session 1 : N chats. (session_id, seq) 유니크.
@staticmethod

View File

@ -49,6 +49,8 @@ class ErrorType(Enum):
CHAT_PRICE_OUT_OF_RANGE = auto() # 1401 제시가가 허용 범위를 벗어남
CHAT_AGENT_UNAVAILABLE = auto() # 1402 협상 에이전트(agent) 호출 실패
CHAT_IN_PROGRESS = auto() # 1403 직전 턴 처리 중(동시 전송 가드)
CHAT_INPUT_MODE_MISMATCH = auto() # 1404 직전 봇이 요구한 입력 모드와 보낸 입력이 불일치(잘못된 버튼/타입) → 화면 리싱크 필요
CHAT_AGENT_TIMEOUT = auto() # 1405 agent 응답 타임아웃(처리됐을 수 있음 — 롤백/재시도 시 desync 위험)
# ErrorType 의 HTTP_* 값과 status_code 를 맞춰 router 단에서 raise 한다.
@ -140,3 +142,18 @@ class ChatSender(Enum):
BOT = 1 # 갑(바이어/agent) — bot 메시지
USER = 2 # 공급사(을) — user 입력
class DeliveryType(Enum):
"""배송 유형 코드. partner.items.delivery_type / negotiation.sessions.reject_delivery_type.
재견적(CM) 협상의 '배송형태선택' 단계 라벨과 1:1 (SHARED_ENUMS §6, negodata 정의 채택).
"""
SUPPLIER = 1 # 협력사배송
COURIER = 2 # 지정택배배송
PICKUP = 3 # 픽업배송
@classmethod
def label_of(cls, code) -> str:
"""코드(1~3) → 한글 라벨. 알 수 없으면 빈 문자열."""
return {1: "협력사배송", 2: "지정택배배송", 3: "픽업배송"}.get(code, "")

View File

@ -49,4 +49,3 @@ class JwtToken(ConfigModel):
class AgentConfig(ConfigModel):
base_url: str = "http://127.0.0.1:9500" # agent 서비스 베이스 URL
timeout_sec: float = 10.0 # 호출 타임아웃(초)
use_mock: bool = True # True 면 agent 미연동 — 내장 mock 응답 사용(agent 개발 중 통합 테스트용)

View File

@ -25,8 +25,6 @@ agent_config: AgentConfig = configs.get(AgentConfig)
# agent 접속 env override (도커/배포에서 host 만 교체). 로컬은 env 미설정 → toml 그대로.
if os.environ.get("AGENT_BASE_URL"):
agent_config.base_url = os.environ["AGENT_BASE_URL"]
if os.environ.get("AGENT_USE_MOCK"):
agent_config.use_mock = os.environ["AGENT_USE_MOCK"].lower() in ("1", "true", "yes")
# DB 접속 env override (config.local.toml 유지, 도커에서 host 만 교체). 로컬은 env 미설정 → toml 그대로.

View File

@ -2,12 +2,12 @@ from abc import ABC, abstractmethod
from datetime import datetime, timezone
from typing import Optional, Tuple
from sqlalchemy import asc, desc, func, select, update
from sqlalchemy import asc, desc, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import chats, items, sessions
from common.enums import ChatSender, ErrorType
from common.enums import ErrorType
from common.logger import LOG
@ -19,8 +19,9 @@ class IChatCRUD(ABC):
pass
@abstractmethod
async def get_last(self, cdb: AsyncSession, session_id) -> Tuple[ErrorType, Tuple[int, Optional[int]]]:
"""마지막 메시지의 (seq, sender). 없으면 (0, None). 동시전송 가드 + seq 채번에 사용."""
async def get_last(self, cdb: AsyncSession, session_id) -> Tuple[ErrorType, Tuple[int, Optional[int], Optional[dict]]]:
"""마지막 메시지의 (seq, sender, meta). 없으면 (0, None, None).
동시전송 가드 + seq 채번 + 입력-모드 검증(직전 봇 meta.input_mode)에 사용."""
pass
@abstractmethod
@ -31,10 +32,6 @@ class IChatCRUD(ABC):
async def soft_delete_message(self, cdb: AsyncSession, chat_id) -> ErrorType:
pass
@abstractmethod
async def count_bot_messages(self, cdb: AsyncSession, session_id) -> Tuple[ErrorType, int]:
pass
@abstractmethod
async def get_item_by_id(self, cdb: AsyncSession, item_id) -> Tuple[ErrorType, items]:
pass
@ -64,23 +61,23 @@ class ChatCRUD(IChatCRUD):
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, []
async def get_last(self, cdb: AsyncSession, session_id) -> Tuple[ErrorType, Tuple[int, Optional[int]]]:
async def get_last(self, cdb: AsyncSession, session_id) -> Tuple[ErrorType, Tuple[int, Optional[int], Optional[dict]]]:
try:
query = (
select(chats.seq, chats.sender)
select(chats.seq, chats.sender, chats.meta)
.where(chats.session_id == session_id, chats.deleted == False) # noqa: E712
.order_by(desc(chats.seq))
.limit(1)
)
err_type, rows = await DB_SESSION_MNG.execute(cdb, query, "get_last failed.")
if err_type != ErrorType.SUCCESS:
return err_type, (0, None)
return err_type, (0, None, None)
if not rows:
return ErrorType.SUCCESS, (0, None)
return ErrorType.SUCCESS, (rows[0][0], rows[0][1])
return ErrorType.SUCCESS, (0, None, None)
return ErrorType.SUCCESS, (rows[0][0], rows[0][1], rows[0][2])
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, (0, None)
return ErrorType.DB_RUN_FAILED, (0, None, None)
async def insert_message(self, cdb: AsyncSession, message: chats) -> ErrorType:
try:
@ -98,22 +95,6 @@ class ChatCRUD(IChatCRUD):
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED
async def count_bot_messages(self, cdb: AsyncSession, session_id) -> Tuple[ErrorType, int]:
# mock agent 진행(turn) 계산용. 실제 agent 는 자체 세션 상태로 진행하므로 무시한다.
try:
query = select(func.count()).select_from(chats).where(
chats.session_id == session_id,
chats.sender == ChatSender.BOT.value,
chats.deleted == False, # noqa: E712
)
err_type, rows = await DB_SESSION_MNG.execute(cdb, query, "count_bot_messages failed.")
if err_type != ErrorType.SUCCESS:
return err_type, 0
return ErrorType.SUCCESS, (rows[0] if rows else 0)
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, 0
async def get_item_by_id(self, cdb: AsyncSession, item_id) -> Tuple[ErrorType, items]:
try:
query = select(items).where(items.item_id == item_id, items.deleted == False).limit(1) # noqa: E712

View File

@ -2,8 +2,9 @@
agent Res_Chat → 이 ChatMessage 매핑:
step→step, client_step→display_step, script→script,
input_mode→next_input_mode, input_options→next_input_type, chat_end→chat_end.
indicator/summary/reject 는 이번 범위 외(예약 필드, 기본 None).
input_mode→next_input_mode, input_options→next_input_type, chat_end→chat_end,
bot_chat_type→bot_chat_type, indicator_value→indicator_value.
summary(요약카드 데이터)는 backend 가 비즈니스 데이터로 조립해 채운다.
"""
from typing import Optional
@ -49,7 +50,7 @@ class ChatMessage(WebPacketProtocol):
next_input_mode: Optional[str] = None # confirm|yes_no|percent|price|delivery_type
next_input_type: Optional[list[str]] = None # 다음 입력 선택지
chat_end: bool = False
indicator_value: Optional[float] = None # (범위 외 예약) 협상 지표
indicator_value: Optional[float] = None # 협상 지표(1~99). agent 가 가격협상 턴에 내려주면 표시.
bot_chat_type: Optional[str] = None # summaryRSP|summaryCM|rejectRSP|rejectCM|indicator
summary: Optional[ChatSummary] = None # summaryRSP/summaryCM 일 때만 채워짐

View File

@ -1,13 +1,11 @@
"""협상 에이전트(agent, 포트 9500) 호출 클라이언트.
backend 는 /chat 한 턴을 agent 로 위임한다(README: "backend 가 /chat 을 agent 로 위임").
agent 의 계약(Req_Chat/Res_Chat)에 맞춘 어댑터. agent 가 아직 없거나 로컬에서 미연동일 때를 위해
mock 구현을 두고 config(AgentConfig.use_mock) 로 선택한다 — 이 격리 덕에 backend/프론트를
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.
chat_end, outcome, card_id, indicator_value, bot_chat_type.
"""
from abc import ABC, abstractmethod
@ -32,7 +30,10 @@ class AgentTurn:
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
@ -42,8 +43,16 @@ class AgentChatContext:
tenant_id: str # X-Tenant-ID = 견적(갑) 회사 company_id
rq_type: str = "재협상" # 재협상 | 재견적
target_price: int = 0 # 갑 목표 매입가(원)
anchor_price: int = 0 # 앵커링가(목표가보다 낮음)
turn: int = 0 # 직전까지의 봇 턴 수(mock 진행용; 실제 agent 는 무시)
anchor_price: int = 0 # 앵커링가(목표가보다 낮음). quotation_settings.anchoring_value 로 계산.
# 핸드오프 #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)
@ -58,7 +67,7 @@ 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 의존을 강제하지 않는다.
import httpx
body = {
"session_id": session_id, # 핸드오프 #1: agent 가 이 값을 세션 키로 그대로 사용해야 함
@ -66,6 +75,13 @@ class HttpAgentClient(IAgentClient):
"user_input": user_input,
"target_price": ctx.target_price,
"anchor_price": ctx.anchor_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:
@ -73,8 +89,12 @@ class HttpAgentClient(IAgentClient):
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 호출 실패: {ex}")
LOG.e_no_callstack(f"[AgentClient] agent 호출 실패 session_id={session_id} step={ctx.client_step}: {ex}")
return AgentTurn(ok=False)
return AgentTurn(
@ -88,66 +108,11 @@ class HttpAgentClient(IAgentClient):
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,
)
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()
"""실제 agent(9500) 위임 클라이언트를 반환한다(FastAPI Depends 용)."""
return HttpAgentClient()

View File

@ -15,11 +15,12 @@ from typing import Optional
from fastapi import Depends
from sqlalchemy import select
from sqlalchemy import func, select
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import chats, items, quotations, sessions, supplier_users, suppliers
from common.enums import ChatSender, DBWRType, ErrorType, QuotationStatus, SessionStatus
from common.database.model.models import chats, items, quotation_settings, quotations, sessions, supplier_users, suppliers
from common.enums import ChatSender, DBWRType, DeliveryType, ErrorType, QuotationStatus, SessionStatus
from common.logger import LOG
from common.models.gmodel import UserInfo
from crud.chat_crud import ChatCRUD, IChatCRUD
from crud.session_crud import ISessionCRUD, SessionCRUD
@ -50,6 +51,31 @@ PRICE_FLOOR_RATIO = 0.3
PRICE_CEIL_RATIO = 1.7
def _input_matches_mode(last_meta: Optional[dict], user_input: str, user_input_type: Optional[str]) -> bool:
"""직전 봇이 요구한 입력 모드(meta.input_mode)와 이번 유저 입력의 '타입'이 정합한지 검사.
불일치(예: price 단계인데 버튼/텍스트, yes_no 단계인데 가격/퍼센트 숫자)면 False
→ agent 로 넘기지 않고 CHAT_INPUT_MODE_MISMATCH(제자리걸음/오진행 방지).
직전 봇 메시지/모드가 없으면(제약 없음) True.
주의: 버튼 선택형(confirm/yes_no/delivery_type)에서 '선택지 텍스트 일치'까지는 강제하지 않는다.
- 프론트 버튼은 항상 올바른 라벨을 보내고, 거부 폼 등은 자유 텍스트(사유)를 보내기 때문.
- 타입(가격/퍼센트 숫자) 오입력만 막아도 실제 stuck/오진행 케이스는 차단된다.
"""
if not last_meta:
return True
mode = last_meta.get("input_mode")
if not mode:
return True
if mode == "price":
return user_input_type == "price"
if mode == "percent":
return user_input_type == "percent"
if mode in ("confirm", "yes_no", "delivery_type"):
# 버튼 선택형 단계에 가격/퍼센트 '숫자 입력'이 오면 오입력 → 차단. 그 외 텍스트는 허용.
return user_input_type not in ("price", "percent")
return True
class ChatService:
def __init__(
self,
@ -166,11 +192,11 @@ class ChatService:
async def _seed_opening(self, sess) -> Optional[ChatMessage]:
"""오프닝(턴0) 봇 메시지를 agent 로 생성하고 seq=1 로 저장한다. 동시 진입 충돌은 무시(유니크가 방어)."""
ctx = await self._agent_context(sess, turn=0)
ctx = await self._agent_context(sess)
turn = await self.agent.chat(session_id=str(sess.session_id), user_input=None, ctx=ctx)
if not turn.ok:
return None
bot = self._build_bot_chat(sess, seq=1, turn=turn)
bot = self._build_bot_chat(sess, seq=1, turn=turn, bot_chat_type=turn.bot_chat_type)
await DB_SESSION_MNG.execute_lambda_run(
[chats.DBType()], [lambda s: self.chat_crud.insert_message(s, bot)]
)
@ -215,8 +241,8 @@ class ChatService:
res.result.SetResult(ErrorType.CHAT_PRICE_OUT_OF_RANGE)
return res
# 직전 메시지(seq/sender) — 동시전송 가드 + seq 채번
err_type, (max_seq, last_sender) = await DB_SESSION_MNG.execute_lambda(
# 직전 메시지(seq/sender/meta) — 동시전송 가드 + seq 채번 + 입력-모드 검증
err_type, (max_seq, last_sender, last_meta) = await DB_SESSION_MNG.execute_lambda(
chats.DBType(), DBWRType.DB_READ.value,
lambda s: self.chat_crud.get_last(s, sess.session_id),
)
@ -227,13 +253,11 @@ class ChatService:
if last_sender == ChatSender.USER.value:
res.result.SetResult(ErrorType.CHAT_IN_PROGRESS)
return res
err_type, turn_no = await DB_SESSION_MNG.execute_lambda(
chats.DBType(), DBWRType.DB_READ.value,
lambda s: self.chat_crud.count_bot_messages(s, sess.session_id),
)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
# ③ 입력-모드 검증: 직전 봇이 요구한 모드와 보낸 입력이 어긋나면 agent 로 넘기지 않는다(제자리걸음/오진행 방지).
if not _input_matches_mode(last_meta, user_input, user_input_type):
LOG.i(f"[chat] 입력-모드 불일치 session_id={sess.session_id} "
f"mode={last_meta.get('input_mode') if last_meta else None} input_type={user_input_type} input={user_input!r}")
res.result.SetResult(ErrorType.CHAT_INPUT_MODE_MISMATCH)
return res
# 유저 메시지 선점(pre-claim): (session_id, seq) 부분 유니크로 동시 전송을 직렬화한다.
@ -250,21 +274,41 @@ class ChatService:
return res
# agent 위임 (한 턴). 실패 시 선점한 유저 메시지를 롤백 → 재시도 가능.
ctx = await self._agent_context(sess, turn=turn_no)
# ① client_step: backend 가 보는 직전 봇 step 을 agent 에 전달(desync 감지 힌트).
last_step = (last_meta or {}).get("step") if last_meta else None
# 상품은 이번 턴에서 1회만 로드해 agent 컨텍스트/요약 조립에 재사용한다(중복 조회 제거).
err_type, item = await DB_SESSION_MNG.execute_lambda(
items.DBType(), DBWRType.DB_READ.value,
lambda s: self.chat_crud.get_item_by_id(s, sess.item_id),
)
item = item if err_type == ErrorType.SUCCESS else None
ctx = await self._agent_context(sess, client_step=last_step, item=item)
turn = await self.agent.chat(session_id=str(sess.session_id), user_input=user_input, ctx=ctx)
if not turn.ok:
# 선점 유저 메시지 롤백(backend 일관성 유지).
await DB_SESSION_MNG.execute_lambda_run(
[chats.DBType()], [lambda s: self.chat_crud.soft_delete_message(s, user_msg.chat_id)]
)
res.result.SetResult(ErrorType.CHAT_AGENT_UNAVAILABLE)
# ② 타임아웃은 별도 코드. agent 가 이미 턴을 처리(step 전진)했을 수 있어, 단순 재시도 시 desync 위험.
# 근본 해결(agent 멱등 재시도 / 세션상태 조회 후 정합)은 agent 측 작업 — HANDOFF.md 참고.
if turn.timed_out:
LOG.w(f"[chat] agent 타임아웃 롤백 session_id={sess.session_id} step={last_step} "
f"— agent 가 이미 진행했을 수 있음(desync 위험). HANDOFF #2 참고")
res.result.SetResult(ErrorType.CHAT_AGENT_TIMEOUT)
else:
res.result.SetResult(ErrorType.CHAT_AGENT_UNAVAILABLE)
return res
# 종료 스텝이면 폼 종류(summary/reject)를 부여하고, 요약카드면 데이터까지 조립한다.
bot_chat_type = _resolve_bot_chat_type(sess.qt_type, turn.step)
# 폼 종류: agent 가 직접 내려주면(bot_chat_type) 신뢰하고, 없으면 step+qt_type 으로 폴백 유도.
# → agent 가 표현 계약을 책임지면 backend 의 step-이름 결합(_resolve_bot_chat_type)은 폴백으로만 남는다.
bot_chat_type = turn.bot_chat_type or _resolve_bot_chat_type(sess.qt_type, turn.step)
# 마지막 유저 제시가: 요약(표시가)·종료 입찰가 양쪽에 쓰이므로 이번 턴 1회만 조회한다.
need_last_price = bot_chat_type in ("summaryRSP", "summaryCM") or (turn.chat_end and turn.outcome == "success")
last_price = await self._last_user_price(sess) if need_last_price else None
summary = None
if bot_chat_type in ("summaryRSP", "summaryCM"):
final_price = price if price is not None else (sess.bid_price or sess.target_price)
summary = await self._build_summary(sess, quote, final_price)
summary = await self._build_summary(sess, quote, item, final_price, last_price)
# 봇 메시지 + 종료 시 확정(성공=DONE+입찰가 / 실패=REJECTED+거부사유·제시가). 한 트랜잭션.
bot_msg = self._build_bot_chat(sess, seq=max_seq + 2, turn=turn, bot_chat_type=bot_chat_type, summary=summary)
@ -274,8 +318,11 @@ class ChatService:
if turn.outcome == "success":
new_status = SessionStatus.DONE.value
# 입찰가 = 이번 턴 가격(보통 None) → 마지막 제시가 → 목표가 순으로 확정.
last_price = await self._last_user_price(sess)
bid = price if price is not None else (last_price if last_price else sess.target_price)
# ⑤ 협상된 제시가가 하나도 없어 목표가로 폴백하면, 합의가가 실제 협상과 다를 수 있어 경고.
if price is None and not last_price:
LOG.w(f"[chat] 합의가 폴백→목표가 session_id={sess.session_id} bid={bid} "
f"— 협상 중 가격 제시가 기록되지 않음(프론트 user_input_type='price' 누락 의심)")
funcs.append(lambda s: self.chat_crud.finalize_session(s, sess.session_id, new_status, bid_price=bid))
else:
new_status = SessionStatus.REJECTED.value
@ -298,23 +345,72 @@ class ChatService:
return res
# ---- 빌더 / 매퍼 ----------------------------------------------------
async def _agent_context(self, sess, turn: int) -> AgentChatContext:
# 핸드오프 #2/#5: X-Tenant-ID 는 견적(갑) 회사 company_id 여야 한다.
async def _agent_context(self, sess, client_step: Optional[str] = None, item=None) -> AgentChatContext:
# 핸드오프 #2: X-Tenant-ID 는 견적(갑) 회사 company_id 여야 한다.
# 상품(partner.items)의 소유 회사가 갑(buyer)이므로 item.company_id 로 해석한다.
# item 은 호출부(send)에서 1회 로드해 넘겨주면 재사용한다(오프닝 seed 는 미전달 → 여기서 로드).
if item is None:
err_type, item = await DB_SESSION_MNG.execute_lambda(
items.DBType(), DBWRType.DB_READ.value,
lambda s: self.chat_crud.get_item_by_id(s, sess.item_id),
)
item = item if err_type == ErrorType.SUCCESS else None
tenant_id = "" # 해석 실패 시 빈 값(agent 가 400) — 로깅으로 추적
err_type, item = await DB_SESSION_MNG.execute_lambda(
items.DBType(), DBWRType.DB_READ.value,
lambda s: self.chat_crud.get_item_by_id(s, sess.item_id),
)
if err_type == ErrorType.SUCCESS and item is not None and item.company_id:
if item is not None and item.company_id:
tenant_id = str(item.company_id)
else:
LOG.w(f"[chat] tenant_id 해석 실패(item.company_id 없음) session_id={sess.session_id} — agent 400 위험")
rq_type = "재협상" if sess.qt_type == 1 else "재견적"
anchor = int(sess.target_price * 0.99) if sess.target_price else 0
target_price = int(sess.target_price or 0)
# 앵커가: 견적설정(quotation_settings.anchoring_value) 비율로 계산 → agent NegotiationConfig.anchor_for 와 동일식.
# anchor = round(target * (1 - value)). 설정 조회 실패 시 1% 폴백(항상 양수 보장 — agent state ValueError 방지).
anchor = await self._resolve_anchor_price(sess, target_price)
# 공급사 수: 같은 견적에 속한 세션 수(재협상=1, 재견적=N). agent partner 차원(single/multiple/none) 입력.
partner_count = await self._count_partners(sess)
# revenue_amount / distribution_code / acceptance_ratio 는 현재 스키마에 데이터 소스가 없어
# AgentChatContext 기본값으로 보낸다(HANDOFF #4 — 컬럼 추가/소스 합의 필요).
return AgentChatContext(
tenant_id=tenant_id, rq_type=rq_type,
target_price=int(sess.target_price or 0), anchor_price=anchor, turn=turn,
target_price=target_price, anchor_price=anchor, partner_count=partner_count,
client_step=client_step,
)
async def _resolve_anchor_price(self, sess, target_price: int) -> int:
"""견적설정 anchoring_value(비율) → anchor=round(target*(1-value)). 실패 시 target*0.99 폴백."""
if not target_price:
return 0
fallback = int(round(target_price * 0.99))
def _q(s):
stmt = (
select(quotation_settings.anchoring_value)
.join(quotations, quotations.qt_setting_id == quotation_settings.qt_setting_id)
.where(quotations.qt_id == sess.quotation_id, quotation_settings.deleted == False) # noqa: E712
.limit(1)
)
return DB_SESSION_MNG.execute(s, stmt)
err_type, rows = await DB_SESSION_MNG.execute_lambda(quotation_settings.DBType(), DBWRType.DB_READ.value, _q)
if err_type != ErrorType.SUCCESS or not rows or rows[0] is None:
LOG.w(f"[chat] anchoring_value 조회 실패 session_id={sess.session_id} — anchor=target*0.99 폴백")
return fallback
return int(round(target_price * (1.0 - float(rows[0]))))
async def _count_partners(self, sess) -> int:
"""같은 견적(quotation_id)에 속한 협상 세션 수 = 참여 공급사 수. 실패 시 1 폴백."""
def _q(s):
stmt = (
select(func.count())
.select_from(sessions)
.where(sessions.quotation_id == sess.quotation_id, sessions.deleted == False) # noqa: E712
)
return DB_SESSION_MNG.execute(s, stmt)
err_type, rows = await DB_SESSION_MNG.execute_lambda(sessions.DBType(), DBWRType.DB_READ.value, _q)
if err_type != ErrorType.SUCCESS or not rows or not rows[0]:
return 1
return int(rows[0])
def _build_user_chat(self, sess, seq: int, user_input: str, user_input_type: Optional[str], price: Optional[int]) -> chats:
return chats(
chat_id=uuid.uuid4(), session_id=sess.session_id, seq=seq,
@ -325,10 +421,12 @@ class ChatService:
def _build_bot_chat(self, sess, seq: int, turn, bot_chat_type: Optional[str] = None, summary: Optional[dict] = None) -> chats:
# bot_chat_type/summary 도 meta 에 영속화 → 히스토리 복원(messages)에서도 폼이 재현된다.
# indicator_value 는 전용 컬럼(분석/replay용)에도 적재. meta 는 순수 표시용.
return chats(
chat_id=uuid.uuid4(), session_id=sess.session_id, seq=seq,
sender=ChatSender.BOT.value,
target_price=int(sess.target_price or 0),
indicator_value=turn.indicator_value,
meta={
"script": turn.script, "step": turn.step, "client_step": turn.client_step,
"input_mode": turn.input_mode, "input_options": turn.input_options,
@ -350,6 +448,7 @@ class ChatService:
next_input_mode=meta.get("input_mode"),
next_input_type=meta.get("input_options"),
chat_end=bool(meta.get("chat_end", False)),
indicator_value=float(c.indicator_value) if c.indicator_value is not None else None,
bot_chat_type=meta.get("bot_chat_type"),
summary=ChatSummary(**summary_d) if summary_d else None,
)
@ -368,15 +467,38 @@ class ChatService:
err_type, rows = await DB_SESSION_MNG.execute_lambda(chats.DBType(), DBWRType.DB_READ.value, _q)
return int(rows[0]) if err_type == ErrorType.SUCCESS and rows and rows[0] else None
async def _build_summary(self, sess, quote, final_price: Optional[int]) -> dict:
"""협상 결과 요약 카드 데이터 조립(item + 견적 담당 MD + 공급사/담당자 + 최종 제시가).
종료 스텝에서 1회만 호출."""
err_type, item = await DB_SESSION_MNG.execute_lambda(
items.DBType(), DBWRType.DB_READ.value,
lambda s: self.chat_crud.get_item_by_id(s, sess.item_id),
)
item = item if err_type == ErrorType.SUCCESS else None
async def _delivery_choice(self, sess) -> Optional[str]:
"""재견적 '배송형태선택' 봇 단계(meta.input_mode='delivery_type') 직후 유저가 고른 배송형태 라벨.
없으면 None. (봇 프롬프트 seq 이후 첫 비삭제 유저 메시지의 script)"""
def _bot_seq(s):
stmt = (
select(chats.seq)
.where(chats.session_id == sess.session_id, chats.sender == ChatSender.BOT.value,
chats.meta["input_mode"].astext == "delivery_type", chats.deleted == False) # noqa: E712
.order_by(chats.seq.desc()).limit(1)
)
return DB_SESSION_MNG.execute(s, stmt)
err_type, rows = await DB_SESSION_MNG.execute_lambda(chats.DBType(), DBWRType.DB_READ.value, _bot_seq)
if err_type != ErrorType.SUCCESS or not rows:
return None
bot_seq = rows[0]
def _user_after(s):
stmt = (
select(chats.meta["script"].astext)
.where(chats.session_id == sess.session_id, chats.sender == ChatSender.USER.value,
chats.seq > bot_seq, chats.deleted == False) # noqa: E712
.order_by(chats.seq.asc()).limit(1)
)
return DB_SESSION_MNG.execute(s, stmt)
err_type, rows = await DB_SESSION_MNG.execute_lambda(chats.DBType(), DBWRType.DB_READ.value, _user_after)
return rows[0] if err_type == ErrorType.SUCCESS and rows and rows[0] else None
async def _build_summary(self, sess, quote, item, final_price: Optional[int], last_price: Optional[int]) -> dict:
"""협상 결과 요약 카드 데이터 조립(item + 견적 담당 MD + 공급사/담당자 + 최종 제시가).
종료 스텝에서 1회만 호출. item/last_price 는 호출부(send)에서 1회 조회해 넘겨준다(중복 조회 제거)."""
def _supplier_name(s):
stmt = select(suppliers.name).where(suppliers.supplier_id == sess.supplier_id).limit(1)
return DB_SESSION_MNG.execute(s, stmt)
@ -396,10 +518,14 @@ class ChatService:
err_type, su_rows = await DB_SESSION_MNG.execute_lambda(supplier_users.DBType(), DBWRType.DB_READ.value, _supplier_user)
sup_mgr_name, sup_mgr_email = (su_rows[0][0], su_rows[0][1]) if err_type == ErrorType.SUCCESS and su_rows else ("", "")
# 최종 제시가: 가장 최근 유저 제시가(없으면 입찰가/목표가 폴백)
last_price = await self._last_user_price(sess)
# 최종 제시가: 가장 최근 유저 제시가(없으면 입찰가/목표가 폴백). last_price 는 호출부에서 전달.
resolved_price = int(last_price if last_price else (final_price or 0))
# 배송형태: 재견적(CM)의 '배송형태선택' 단계에서 공급사가 고른 라벨. 재협상엔 단계가 없어 None.
delivery_label = await self._delivery_choice(sess) if sess.qt_type == 2 else None
# 상품 기본 배송유형(코드→라벨). 선택값이 없으면 표시에 폴백으로 쓸 수 있다.
item_delivery_label = DeliveryType.label_of(item.delivery_type) if item and item.delivery_type is not None else ""
def _iso(dt):
if dt is None:
return ""
@ -420,14 +546,14 @@ class ChatService:
item_isVAT=bool(item.vat_yn) if item and item.vat_yn is not None else False,
item_lead_time=(str(item.lead_time) if item and item.lead_time is not None else ""),
item_display_date=_iso(quote.start_time),
item_delivery_type="",
item_delivery_type=item_delivery_label,
final_price=resolved_price,
nego_start_date=_iso(quote.start_time),
nego_end_date=_iso(quote.end_time),
supplier_name=supplier_name or "",
supplier_manager_name=sup_mgr_name or "",
supplier_manager_email=sup_mgr_email or "",
delivery_type=None,
delivery_type=delivery_label,
).model_dump()
def _row_to_message(self, r) -> ChatMessage:

View File

@ -1,21 +1,106 @@
"""채팅(chat) 도메인 e2e 테스트 — init / messages(오프닝 seed) / send(협상 진행~종료).
agent 는 config.use_mock=true 로 내장 MockAgentClient 를 쓴다(결정론적 플로우).
실제 agent(9500) 대신, 결정론적 테스트 더블(_FakeAgentClient)을 FastAPI 의존성 오버라이드로 주입한다.
(프로덕션 코드에는 mock 이 없다 — 테스트 전용 double 이다.) 더블은 backend 가 매 턴 보내는
client_step(직전 봇 step)과 user_input 으로 단계를 진행한다.
dev negosium_db 를 그대로 쓰므로 전용 테스트 행만 시드/정리한다.
"""
import uuid
import bcrypt
import pytest
import pytest_asyncio
from sqlalchemy import text
from services.agent_client import AgentTurn, IAgentClient, get_agent_client
TEST_LOGIN_ID = "pytest_chat_user"
TEST_PW = "pytest1234"
TEST_SUPPLIER_NAME = "파이테스트채팅공급사"
MARK = "PYTESTCHAT-"
def _parse_price(text_):
if not text_:
return None
digits = "".join(ch for ch in text_ if ch.isdigit())
return int(digits) if digits else None
class _FakeAgentClient(IAgentClient):
"""결정론적 테스트 더블. ctx.client_step(직전 봇 step)+user_input 으로 단계를 진행한다.
플로우: (오프닝)서비스안내 → 협상품목안내 → 가격협상 → 가격제시 시 목표가 이하면 성공 종료.
'포기'/'거부' 입력은 언제든 실패 종료.
"""
async def chat(self, session_id, user_input, ctx) -> AgentTurn:
sid = session_id or "fake-session"
if user_input and ("포기" in user_input or "거부" in user_input):
return AgentTurn(
session_id=sid, step="협상종료", client_step="협상종료",
script="협상이 종료되었습니다.", chat_end=True, outcome="failure",
)
if user_input is None: # 오프닝(턴0) — 양쪽 공통
return AgentTurn(session_id=sid, step="서비스안내", client_step="서비스안내",
script="협상에 참여해 주셔서 감사합니다. 시작하시겠어요?",
input_mode="confirm", input_options=["네, 시작할게요"])
if ctx.rq_type == "재견적":
return self._requote(sid, user_input, ctx)
return self._renego(sid, user_input, ctx)
def _renego(self, sid, user_input, ctx) -> AgentTurn:
if ctx.client_step == "서비스안내":
return AgentTurn(session_id=sid, step="협상품목안내", client_step="협상품목안내",
script="협상 품목을 확인해 주세요. 가격 협상을 진행할까요?",
input_mode="confirm", input_options=["가격 협상 진행"])
if ctx.client_step == "협상품목안내":
return AgentTurn(session_id=sid, step="가격협상", client_step="가격협상",
script="희망 공급가를 입력해 주세요.", input_mode="price")
# 가격협상 단계: 목표가 이하면 합의 종료, 아니면 한 번 더 요청
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:,}원으로 합의되었습니다. 감사합니다.",
chat_end=True, outcome="success", indicator_value=99.0)
return AgentTurn(session_id=sid, step="가격협상", client_step="가격협상",
script="조금 더 조정된 가격을 제안해 주시겠어요?", input_mode="price",
indicator_value=50.0)
def _requote(self, sid, user_input, ctx) -> AgentTurn:
# 서비스안내 → 가격제안 → 배송형태선택 → 가격협상_입력 → 결과안내(summaryCM)
if ctx.client_step == "서비스안내":
return AgentTurn(session_id=sid, step="가격제안", client_step="가격제안",
script="제시 목표가로 진행하시겠어요?", input_mode="yes_no",
input_options=["예", "아니오"])
if ctx.client_step == "가격제안":
return AgentTurn(session_id=sid, step="배송형태선택", client_step="배송형태선택",
script="배송형태를 선택해 주세요.", input_mode="delivery_type",
input_options=["협력사배송", "지정택배배송", "픽업배송"])
if ctx.client_step == "배송형태선택":
return AgentTurn(session_id=sid, step="가격협상_입력", client_step="가격협상_입력",
script="희망 공급가를 입력해 주세요.", input_mode="price")
if ctx.client_step == "가격협상_입력":
return AgentTurn(session_id=sid, step="결과안내", client_step="결과안내",
script="투찰 결과를 확인해 주세요.", input_mode="yes_no",
input_options=["투찰확정", "정보수정"])
# 결과안내 "투찰확정" → 결과제출 → 협상종료
return AgentTurn(session_id=sid, step="협상종료", client_step="협상종료",
script="투찰이 확정되었습니다. 감사합니다.",
chat_end=True, outcome="success")
@pytest.fixture(autouse=True)
def _fake_agent():
"""모든 chat 테스트에서 실제 agent 대신 결정론적 더블을 주입(의존성 오버라이드)."""
from router.router import app
app.dependency_overrides[get_agent_client] = lambda: _FakeAgentClient()
yield
app.dependency_overrides.pop(get_agent_client, None)
@pytest_asyncio.fixture
async def chat_seed(db_engine):
"""공급사 + 유저 + 세션 2건(본인: 협상중 P / 협상생성 C) + 1건(타 공급사 X) 시드."""
@ -28,6 +113,7 @@ async def chat_seed(db_engine):
("P", 2, 1, 2, 2, supplier_id), # 협상중 / 재협상 / +2h / 견적진행중
("C", 1, 1, 2, 1, supplier_id), # 협상생성 / 재협상 / +2h / 견적생성
("X", 2, 1, 2, 2, other_supplier_id), # 타 공급사 → 차단
("Q", 2, 2, 2, 2, supplier_id), # 협상중 / 재견적 / +2h / 견적진행중
]
sids, qids = {}, {}
@ -174,6 +260,45 @@ async def test_send_flow_to_completion(client, chat_seed, db_engine):
assert await _session_bid(db_engine, sid) == 90000 # 입찰가 확정
async def test_requote_summary_captures_delivery_type(client, chat_seed):
"""재견적: 배송형태선택에서 고른 값이 summaryCM 요약(delivery_type)에 담긴다."""
token = await _login_token(client)
sid = chat_seed["sids"]["Q"]
await _messages(client, token, sid) # 오프닝(서비스안내)
await _send(client, token, sid, "네, 시작할게요") # → 가격제안
await _send(client, token, sid, "예") # → 배송형태선택
await _send(client, token, sid, "협력사배송") # → 가격협상_입력
r = (await _send(client, token, sid, "90000", user_input_type="price")).json() # → 결과안내(summaryCM)
assert r["result"]["success"] is True
msg = r["message"]
assert msg["bot_chat_type"] == "summaryCM"
assert msg["summary"]["delivery_type"] == "협력사배송"
async def test_agent_provided_bot_chat_type_and_indicator_passthrough(client, chat_seed):
"""agent 가 bot_chat_type/indicator_value 를 직접 주면 backend 는 step 추측 없이 그대로 전달한다."""
from router.router import app
class _T(IAgentClient):
async def chat(self, session_id, user_input, ctx):
if user_input is None:
return AgentTurn(session_id=session_id, step="서비스안내", client_step="서비스안내",
script="안녕하세요", input_mode="confirm", input_options=["확인"])
return AgentTurn(session_id=session_id, step="가격협상", client_step="가격협상",
script="지표를 확인하세요", input_mode="price",
indicator_value=55.0, bot_chat_type="indicator")
app.dependency_overrides[get_agent_client] = lambda: _T()
token = await _login_token(client)
sid = chat_seed["sids"]["P"]
await _messages(client, token, sid) # 오프닝
r = (await _send(client, token, sid, "확인")).json() # → 가격협상(indicator)
assert r["result"]["success"] is True
msg = r["message"]
assert msg["bot_chat_type"] == "indicator" # agent 값 그대로
assert msg["indicator_value"] == 55.0 # 지표 전달
async def test_send_price_out_of_range(client, chat_seed):
token = await _login_token(client)
sid = chat_seed["sids"]["P"]

3
frontend/.gitignore vendored
View File

@ -27,3 +27,6 @@ dist-ssr
*.njsproj
*.sln
*.sw?
# Vite 캐시
.vite/

View File

@ -41,6 +41,8 @@ export const ErrorCode = {
CHAT_PRICE_OUT_OF_RANGE: 1401,
CHAT_AGENT_UNAVAILABLE: 1402,
CHAT_IN_PROGRESS: 1403,
CHAT_INPUT_MODE_MISMATCH: 1404,
CHAT_AGENT_TIMEOUT: 1405,
} as const
export type ErrorCode = (typeof ErrorCode)[keyof typeof ErrorCode]
@ -62,6 +64,8 @@ const API_ERROR_MESSAGES: Record<number, string> = {
[ErrorCode.CHAT_PRICE_OUT_OF_RANGE]: '제시 가격이 허용 범위를 벗어났습니다.',
[ErrorCode.CHAT_AGENT_UNAVAILABLE]: '협상 처리 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.',
[ErrorCode.CHAT_IN_PROGRESS]: '이전 메시지를 처리 중입니다. 잠시만 기다려주세요.',
[ErrorCode.CHAT_INPUT_MODE_MISMATCH]: '입력 형식이 맞지 않습니다. 최신 대화 상태로 다시 불러옵니다.',
[ErrorCode.CHAT_AGENT_TIMEOUT]: '협상 응답이 지연되고 있습니다. 최신 상태를 다시 불러옵니다.',
}
/** API 에러: result.code(비즈니스) 또는 HTTP status 를 code 로 담는다 */

View File

@ -0,0 +1,37 @@
import { AlertTriangle } from 'lucide-react'
import { Button } from '@/components/Button'
export interface ErrorPageProps {
/** 상단 메시지(굵게). 기본: 일반 오류 문구. */
message?: string
/** 보조 설명. 기본: 재시도 안내. */
description?: string
/** 재시도 버튼 핸들러. 없으면 버튼 미표시. */
onRetry?: () => void
}
// 데이터 로드 실패(서버/네트워크 오류) 시 보여주는 에러 화면. 컨테이너를 가득 채운다(h-full).
export function ErrorPage({
message = '문제가 발생했습니다.',
description = '잠시 후 다시 시도해주세요.',
onRetry,
}: ErrorPageProps) {
return (
<div className="flex h-full w-full items-center justify-center">
<div className="flex flex-col items-center gap-6 p-12">
<div className="flex size-20 items-center justify-center rounded-full bg-destructive/10">
<AlertTriangle className="size-10 text-destructive" />
</div>
<div className="flex flex-col items-center gap-2 text-center">
<p className="title-2 text-destructive">{message}</p>
<p className="body-1 text-neutral-70">{description}</p>
</div>
{onRetry && (
<Button variant="primary" size="lg" onClick={onRetry}>
다시 시도
</Button>
)}
</div>
</div>
)
}

View File

@ -5,3 +5,5 @@ export { Modal } from '@/components/Modal'
export type { ModalProps } from '@/components/Modal'
export { Logo } from '@/components/Logo'
export type { LogoProps, LogoVariant } from '@/components/Logo'
export { ErrorPage } from '@/components/ErrorPage'
export type { ErrorPageProps } from '@/components/ErrorPage'

View File

@ -21,10 +21,12 @@ export function ChatMessage() {
function ChatList() {
const bottomRef = useRef<HTMLDivElement | null>(null)
const chats = useChatStore((s) => s.messages)
const isLoading = useChatStore((s) => s.isLoading)
// 메시지 추가/타이핑 표시 시 항상 맨 아래로 스크롤
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [chats])
}, [chats, isLoading])
if (!chats || chats.length === 0) {
return (
@ -39,11 +41,25 @@ function ChatList() {
{chats.map((message, index) => (
<MessageItem key={message.chat_id || index} message={message} isFirst={index === 0} messages={chats} currentIndex={index} />
))}
{isLoading && <TypingBubble />}
<div ref={bottomRef} />
</div>
)
}
// agent 응답을 기다리는 동안(협상 중) 대화 흐름에 표시하는 타이핑 인디케이터.
function TypingBubble() {
return (
<div className="mb-[56px] pt-[36px]" aria-label="협상 중" role="status">
<div className="inline-flex items-center gap-[6px]">
<span className="size-[8px] rounded-full bg-neutral-50 animate-bounce [animation-delay:-0.3s]" />
<span className="size-[8px] rounded-full bg-neutral-50 animate-bounce [animation-delay:-0.15s]" />
<span className="size-[8px] rounded-full bg-neutral-50 animate-bounce" />
</div>
</div>
)
}
const MessageItem = memo(function MessageItem({
message,
isFirst,

View File

@ -1,5 +1,6 @@
import { useNavigate } from 'react-router'
import { List, Loader2 } from 'lucide-react'
import { List } from 'lucide-react'
import { cn } from '@/lib'
import { useChatStore } from '@/features/chat/stores/useChatStore'
import { Percent, Price } from '@/features/chat/components/userInputs'
import { GO_TO_LIST_TEXT } from '@/features/chat/lib/userButtonConfig'
@ -31,7 +32,7 @@ export function UserButton({ type, text, textList, priceErrorMessage }: UserButt
<ThreeBlack textList={[textList?.[0] || '협력사배송', textList?.[1] || '지정택배배송', textList?.[2] || '픽업배송']} />
)}
{type === 'price' && <Price priceErrorMessage={priceErrorMessage} />}
{type === 'loading' && <Loader2 className="size-8 animate-spin text-neutral-60" />}
{type === 'loading' && <LoadingDots />}
</div>
<div className="flex justify-end">
<GoToList />
@ -41,6 +42,19 @@ export function UserButton({ type, text, textList, priceErrorMessage }: UserButt
)
}
// agent 응답 대기 중(협상 중) 버튼 자리에 표시하는 "..." 점 애니메이션. 비활성(클릭 불가).
function LoadingDots() {
return (
<div className={cn(style.black, 'cursor-default pointer-events-none')} aria-label="협상 중" role="status">
<span className="flex gap-[6px]">
<span className="size-[8px] rounded-full bg-primary-foreground animate-bounce [animation-delay:-0.3s]" />
<span className="size-[8px] rounded-full bg-primary-foreground animate-bounce [animation-delay:-0.15s]" />
<span className="size-[8px] rounded-full bg-primary-foreground animate-bounce" />
</span>
</div>
)
}
function GoToList() {
const navigate = useNavigate()
return (

View File

@ -1,10 +1,30 @@
import { Loader2 } from 'lucide-react'
import { ErrorPage } from '@/components'
import { useChatController } from '@/features/chat/hooks/useChatController'
import { ChatSection } from '@/features/chat/components/ChatSection'
import { MenuSection } from '@/features/chat/components/menu/MenuSection'
// 콘텐츠 영역: 채팅 + 우측 메뉴. session_id 로 init/messages 를 적재하고 전송을 주입한다.
// 진입 로드(init/messages) 상태를 직접 그린다: 로딩 → 스피너, 실패(서버/네트워크) → ErrorPage(재시도).
export function ChatContainer({ sessionId }: { sessionId: string }) {
useChatController(sessionId)
const { isInitLoading, initError, refetchInit } = useChatController(sessionId)
if (isInitLoading) {
return (
<div className="flex flex-1 items-center justify-center w-full">
<Loader2 className="size-10 animate-spin text-neutral-50" aria-label="불러오는 중" />
</div>
)
}
if (initError) {
return (
<div className="flex flex-1 w-full">
<ErrorPage message="채팅을 불러오는 중 오류가 발생했습니다." onRetry={refetchInit} />
</div>
)
}
return (
<div className="flex flex-1 min-h-0 w-full">
<ChatSection />

View File

@ -1,6 +1,13 @@
import { useEffect } from 'react'
import { useNavigate } from 'react-router'
import { useChatInitQuery, useChatMessagesQuery, useChatSendMutation, mapMessage } from '@/apis/chat'
import { useQueryClient } from '@tanstack/react-query'
import {
useChatInitQuery,
useChatMessagesQuery,
useChatSendMutation,
mapMessage,
chatKeys,
} from '@/apis/chat'
import { ErrorCode, getApiErrorMessage, isApiError } from '@/apis/types'
import { toast } from '@/lib'
import { useChatStore } from '@/features/chat/stores/useChatStore'
@ -16,6 +23,12 @@ const TERMINAL_CODES = new Set<number>([
ErrorCode.NEGO_NOT_FOUND,
])
// 화면이 보는 대화 상태가 서버와 어긋났을 수 있는 코드 → 서버 기준으로 메시지를 다시 불러와 리싱크.
const RESYNC_CODES = new Set<number>([
ErrorCode.CHAT_INPUT_MODE_MISMATCH, // 보낸 입력이 직전 봇이 요구한 모드와 불일치
ErrorCode.CHAT_AGENT_TIMEOUT, // agent 타임아웃(서버/agent 상태가 앞서 있을 수 있음)
])
let tempSeq = 0
// 낙관적 유저 말풍선 생성 (전송 즉시 표시). 서버 확정 메시지는 send 응답으로 append 한다.
@ -42,6 +55,7 @@ function makeUserMessage(text: string, inputType: UserInputType): ChatMessage {
*/
export function useChatController(sessionId: string) {
const navigate = useNavigate()
const queryClient = useQueryClient()
const setInitData = useChatInitStore((s) => s.setInitData)
const initQuery = useChatInitQuery(sessionId)
const messagesQuery = useChatMessagesQuery(sessionId)
@ -94,6 +108,12 @@ export function useChatController(sessionId: string) {
toast.error(getApiErrorMessage(error, '협상 처리 중 오류가 발생했습니다.'))
// 입력-모드 불일치/타임아웃: 서버 기준으로 대화를 다시 불러와 화면을 리싱크한다.
// (messages 쿼리가 갱신되면 위 effect 가 스토어 messages 를 덮어써 버튼/입력창이 서버 상태에 맞춰진다.)
if (RESYNC_CODES.has(code)) {
queryClient.invalidateQueries({ queryKey: chatKeys.messages(sessionId) })
}
// 마감/종료/권한 등 더 진행 불가한 상태면 잠시 후 목록으로 복귀
if (TERMINAL_CODES.has(code)) {
s.bindSend(null) // 입력 잠금(추가 전송 차단)
@ -107,10 +127,15 @@ export function useChatController(sessionId: string) {
return () => {
useChatStore.getState().reset()
}
}, [sessionId, sendMutate, navigate])
}, [sessionId, sendMutate, navigate, queryClient])
return {
isInitLoading: initQuery.isLoading || messagesQuery.isLoading,
initError: initQuery.error ?? messagesQuery.error,
// 로드 실패(서버/네트워크) 시 init·messages 를 함께 재조회한다.
refetchInit: () => {
void initQuery.refetch()
void messagesQuery.refetch()
},
}
}