diff --git a/AGENT_HANDOFF_CHAT_FIXES.md b/AGENT_HANDOFF_CHAT_FIXES.md new file mode 100644 index 0000000..759f79f --- /dev/null +++ b/AGENT_HANDOFF_CHAT_FIXES.md @@ -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": "", // 그대로 세션 키로 사용 (핸드오프 #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 에서 별도 처리. diff --git a/backend/.dockerignore b/backend/.dockerignore index df70479..48e887d 100644 --- a/backend/.dockerignore +++ b/backend/.dockerignore @@ -1,3 +1,5 @@ +.venv/ +venv/ __pycache__/ *.pyc .pytest_cache/ diff --git a/backend/common/database/model/models.py b/backend/common/database/model/models.py index ae6049f..7b7aecb 100644 --- a/backend/common/database/model/models.py +++ b/backend/common/database/model/models.py @@ -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 diff --git a/backend/common/enums.py b/backend/common/enums.py index 9ee216f..7811a10 100644 --- a/backend/common/enums.py +++ b/backend/common/enums.py @@ -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, "") diff --git a/backend/config/config_models.py b/backend/config/config_models.py index ea585b3..10a3196 100644 --- a/backend/config/config_models.py +++ b/backend/config/config_models.py @@ -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 개발 중 통합 테스트용) diff --git a/backend/config/server_configs.py b/backend/config/server_configs.py index b8aef41..67120d7 100644 --- a/backend/config/server_configs.py +++ b/backend/config/server_configs.py @@ -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 그대로. diff --git a/backend/crud/chat_crud.py b/backend/crud/chat_crud.py index 10a47d2..c10b2ab 100644 --- a/backend/crud/chat_crud.py +++ b/backend/crud/chat_crud.py @@ -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 diff --git a/backend/router/v1/negotiation/chat_protocol.py b/backend/router/v1/negotiation/chat_protocol.py index c6e38bc..a483c1e 100644 --- a/backend/router/v1/negotiation/chat_protocol.py +++ b/backend/router/v1/negotiation/chat_protocol.py @@ -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 일 때만 채워짐 diff --git a/backend/services/agent_client.py b/backend/services/agent_client.py index 0653e5d..055da89 100644 --- a/backend/services/agent_client.py +++ b/backend/services/agent_client.py @@ -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() diff --git a/backend/services/chat_service.py b/backend/services/chat_service.py index 8aaffb4..6a2007d 100644 --- a/backend/services/chat_service.py +++ b/backend/services/chat_service.py @@ -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: diff --git a/backend/tests/test_chat.py b/backend/tests/test_chat.py index 289fdfa..3cc5011 100644 --- a/backend/tests/test_chat.py +++ b/backend/tests/test_chat.py @@ -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"] diff --git a/frontend/.gitignore b/frontend/.gitignore index 7cd12e4..203fad6 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -27,3 +27,6 @@ dist-ssr *.njsproj *.sln *.sw? + +# Vite 캐시 +.vite/ \ No newline at end of file diff --git a/frontend/src/apis/types.ts b/frontend/src/apis/types.ts index 86d18ed..3c0f614 100644 --- a/frontend/src/apis/types.ts +++ b/frontend/src/apis/types.ts @@ -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 = { [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 로 담는다 */ diff --git a/frontend/src/components/ErrorPage.tsx b/frontend/src/components/ErrorPage.tsx new file mode 100644 index 0000000..42eccc5 --- /dev/null +++ b/frontend/src/components/ErrorPage.tsx @@ -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 ( +
+
+
+ +
+
+

{message}

+

{description}

+
+ {onRetry && ( + + )} +
+
+ ) +} diff --git a/frontend/src/components/index.ts b/frontend/src/components/index.ts index ac75f31..552fcff 100644 --- a/frontend/src/components/index.ts +++ b/frontend/src/components/index.ts @@ -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' diff --git a/frontend/src/features/chat/components/ChatMessage.tsx b/frontend/src/features/chat/components/ChatMessage.tsx index b4cea65..66fcf19 100644 --- a/frontend/src/features/chat/components/ChatMessage.tsx +++ b/frontend/src/features/chat/components/ChatMessage.tsx @@ -21,10 +21,12 @@ export function ChatMessage() { function ChatList() { const bottomRef = useRef(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) => ( ))} + {isLoading && }
) } +// agent 응답을 기다리는 동안(협상 중) 대화 흐름에 표시하는 타이핑 인디케이터. +function TypingBubble() { + return ( +
+
+ + + +
+
+ ) +} + const MessageItem = memo(function MessageItem({ message, isFirst, diff --git a/frontend/src/features/chat/components/UserButton.tsx b/frontend/src/features/chat/components/UserButton.tsx index 064f75a..3ad6a0c 100644 --- a/frontend/src/features/chat/components/UserButton.tsx +++ b/frontend/src/features/chat/components/UserButton.tsx @@ -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 )} {type === 'price' && } - {type === 'loading' && } + {type === 'loading' && }
@@ -41,6 +42,19 @@ export function UserButton({ type, text, textList, priceErrorMessage }: UserButt ) } +// agent 응답 대기 중(협상 중) 버튼 자리에 표시하는 "..." 점 애니메이션. 비활성(클릭 불가). +function LoadingDots() { + return ( +
+ + + + + +
+ ) +} + function GoToList() { const navigate = useNavigate() return ( diff --git a/frontend/src/features/chat/containers/ChatContainer.tsx b/frontend/src/features/chat/containers/ChatContainer.tsx index 1159b38..c0f4ddb 100644 --- a/frontend/src/features/chat/containers/ChatContainer.tsx +++ b/frontend/src/features/chat/containers/ChatContainer.tsx @@ -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 ( +
+ +
+ ) + } + + if (initError) { + return ( +
+ +
+ ) + } + return (
diff --git a/frontend/src/features/chat/hooks/useChatController.ts b/frontend/src/features/chat/hooks/useChatController.ts index 05c31f3..88a183a 100644 --- a/frontend/src/features/chat/hooks/useChatController.ts +++ b/frontend/src/features/chat/hooks/useChatController.ts @@ -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([ ErrorCode.NEGO_NOT_FOUND, ]) +// 화면이 보는 대화 상태가 서버와 어긋났을 수 있는 코드 → 서버 기준으로 메시지를 다시 불러와 리싱크. +const RESYNC_CODES = new Set([ + 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() + }, } }