Merge pull request 'fix/chat' (#6) from fix/chat into main
Reviewed-on: Negosium/o2o-negosium#6
This commit is contained in:
commit
830d1aa6de
186
AGENT_HANDOFF_CHAT_FIXES.md
Normal file
186
AGENT_HANDOFF_CHAT_FIXES.md
Normal 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 에서 별도 처리.
|
||||
@ -1,3 +1,5 @@
|
||||
.venv/
|
||||
venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.pytest_cache/
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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, "")
|
||||
|
||||
@ -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 개발 중 통합 테스트용)
|
||||
|
||||
@ -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 그대로.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 일 때만 채워짐
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
3
frontend/.gitignore
vendored
@ -27,3 +27,6 @@ dist-ssr
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# Vite 캐시
|
||||
.vite/
|
||||
@ -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 로 담는다 */
|
||||
|
||||
37
frontend/src/components/ErrorPage.tsx
Normal file
37
frontend/src/components/ErrorPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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'
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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 />
|
||||
|
||||
@ -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()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user