feat(backend): 협상 채팅(chat) API + agent 위임
- /v1/negotiation/sessions/{id}/chat/{init,messages,send} 추가
- agent(9500) 위임 어댑터(IAgentClient) + mock(use_mock) 격리 → agent 미연동 시 1402 graceful degrade
- negotiation.chats 메시지 영속화(meta JSONB) + 종료 시 세션 입찰/거부 확정(단일 트랜잭션)
- 동시전송 가드(유저 메시지 pre-claim/CHAT_IN_PROGRESS) + 실패 시 롤백, 마감/만료 분기, init 만료 정리
- ChatSender enum, chat 에러코드(1400~1403), chats ORM 모델, AgentConfig
- 테스트 10건(test_chat.py), AGENT_INTEGRATION.md 연동 규약 문서
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
d199c1dec1
commit
dcce82a630
72
AGENT_INTEGRATION.md
Normal file
72
AGENT_INTEGRATION.md
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
# Chat 연동 규약 (backend ↔ agent)
|
||||||
|
|
||||||
|
> 대상: **agent(9500) 담당자**. backend(9300)가 채팅 한 턴을 agent `POST /v1/chat` 으로 위임한다.
|
||||||
|
> backend/frontend 개발은 이 규약을 가정하고 완료했고, 현재는 `AgentConfig.use_mock=true` 로 내장 mock 을 쓴다.
|
||||||
|
> agent 가 준비되면 **아래 항목을 맞춘 뒤** backend `config.local.toml` 의 `[AgentConfig] use_mock=false` 로 전환하면 된다.
|
||||||
|
|
||||||
|
## 1. 호출 흐름
|
||||||
|
|
||||||
|
```
|
||||||
|
프론트(5173) → backend(9300) /v1/negotiation/sessions/{id}/chat/send → agent(9500) POST /v1/chat
|
||||||
|
```
|
||||||
|
|
||||||
|
- 인증·소유권·견적마감·가격범위 검증, 말풍선 영속화(negotiation.chats), 종료 시 세션 입찰확정은 **backend 책임**.
|
||||||
|
- 협상 로직(스텝 전이·카드선택·학습)은 **agent 책임**. backend 는 agent 응답을 그대로 말풍선으로 저장/전달한다.
|
||||||
|
|
||||||
|
## 2. backend → agent 요청 (`POST /v1/chat`)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"session_id": "<negotiation.sessions.session_id>",
|
||||||
|
"rq_type": "재협상 | 재견적",
|
||||||
|
"user_input": "<버튼 텍스트 또는 가격문자열, 첫 턴(오프닝)은 null>",
|
||||||
|
"target_price": 100000,
|
||||||
|
"anchor_price": 99000
|
||||||
|
}
|
||||||
|
```
|
||||||
|
헤더: `X-Tenant-ID: <견적(갑) 회사 company_id>`
|
||||||
|
|
||||||
|
## 3. agent → backend 응답 (`Res_Chat`) ↔ 프론트 ChatMessage 매핑
|
||||||
|
|
||||||
|
| agent 필드 | backend/프론트 |
|
||||||
|
|---|---|
|
||||||
|
| `session_id` | 세션 키 |
|
||||||
|
| `step` | `step` |
|
||||||
|
| `client_step` | `display_step` |
|
||||||
|
| `script` | `script` (말풍선 텍스트) |
|
||||||
|
| `input_mode` | `next_input_mode` (confirm·yes_no·percent·price·delivery_type) |
|
||||||
|
| `input_options` | `next_input_type` (버튼 라벨 배열) |
|
||||||
|
| `chat_end` | `chat_end` |
|
||||||
|
| `outcome` | "success"=협상완료(DONE)+입찰가 확정 / 그 외=협상거부(REJECTED) |
|
||||||
|
| `card_id` | (저장만, 표시 범위 외) |
|
||||||
|
|
||||||
|
## 4. agent 쪽에서 맞춰줘야 하는 항목 ⚠️
|
||||||
|
|
||||||
|
1. **session_id honoring** — 첫 턴에 backend 가 보낸 `session_id`(우리 `negotiation.sessions.session_id`)를
|
||||||
|
**새 uuid 발급 없이 그대로 세션 키로 사용**해야 한다.
|
||||||
|
- 현재 `agent/services/chat_service.py` 는 새 세션 생성 시 `session_id=str(uuid.uuid4())` 로 무시한다 → `req.session_id` 우선 사용하도록 수정 필요.
|
||||||
|
- 이미 `learning.experience_logs.session_id` 가 `negotiation.sessions` 를 가리키도록 설계돼 있어 agent 입장에서도 올바른 방향.
|
||||||
|
2. **tenant 헤더** — backend 가 `X-Tenant-ID = company_id` 로 보낸다. agent 의 TenantMiddleware 가 이 키로 엔진 해석.
|
||||||
|
3. **신규 세션 컨텍스트** — `target_price`/`anchor_price`/`rq_type` 를 backend 가 견적 데이터로 채워 보낸다(기본값 의존 X).
|
||||||
|
4. **tenant_id 정밀 해석(backend 측 TODO와 짝)** — 현재 backend 는 `X-Tenant-ID` 를 빈 값으로 보낸다.
|
||||||
|
정확히는 **견적 작성자(갑) 회사 company_id** 여야 하며, `quotation.user_id → company.users.company_id` 조회로 채울 예정.
|
||||||
|
agent 가 기대하는 tenant 키 형식(company_id uuid 문자열 / `_base`)을 확정해주면 backend 가 맞춘다.
|
||||||
|
5. **(범위 외) indicator / summary / reject** — 이번 범위 미포함. agent 응답에 협상지표·최종요약·거부폼이 생기면
|
||||||
|
backend ChatMessage 의 예약 필드(`indicator_value`/`bot_chat_type`/summary)로 확장 협의.
|
||||||
|
|
||||||
|
## 5. mock → 실제 전환 체크리스트
|
||||||
|
|
||||||
|
- [x] backend `config.local.toml` → `[AgentConfig] use_mock=false` (전환 완료 — agent 미기동 시 1402 로 graceful degrade 확인)
|
||||||
|
- [x] backend `httpx` 의존 설치(`requirements.txt` 반영됨)
|
||||||
|
- [ ] 위 4-1 ~ 4-3 반영 (agent 측)
|
||||||
|
- [ ] tenant_id 해석(4-4) 합의 후 backend `chat_service._agent_context` 의 `tenant_id` 채우기
|
||||||
|
- [ ] agent(9500) 기동 후 양 서버 라이브 E2E
|
||||||
|
|
||||||
|
> 로컬에서 agent 없이 mock 으로 개발하려면 환경변수로 덮는다: `AGENT_USE_MOCK=true`
|
||||||
|
|
||||||
|
## 6. 참고 (backend 구현 위치)
|
||||||
|
|
||||||
|
- agent 어댑터: `backend/services/agent_client.py` (IAgentClient / Http / Mock)
|
||||||
|
- 오케스트레이션: `backend/services/chat_service.py`
|
||||||
|
- 계약(프로토콜): `backend/router/v1/negotiation/chat_protocol.py`
|
||||||
|
- 엔드포인트: `backend/router/v1/negotiation/chat.py`
|
||||||
@ -1,5 +1,5 @@
|
|||||||
from sqlalchemy.orm import declarative_base
|
from sqlalchemy.orm import declarative_base
|
||||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime, SmallInteger, BigInteger
|
from sqlalchemy import Column, Integer, String, Boolean, DateTime, SmallInteger, BigInteger, Numeric
|
||||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||||
from sqlalchemy.sql import text
|
from sqlalchemy.sql import text
|
||||||
|
|
||||||
@ -155,6 +155,30 @@ class quotations(MAIN_BASE):
|
|||||||
deleted = Column(Boolean, nullable=False, server_default=text("false")) # 소프트 삭제 여부
|
deleted = Column(Boolean, nullable=False, server_default=text("false")) # 소프트 삭제 여부
|
||||||
|
|
||||||
|
|
||||||
|
class chats(MAIN_BASE):
|
||||||
|
# negotiation.chats (협상 채팅 메시지 로그). session 1 : N chats. (session_id, seq) 유니크.
|
||||||
|
@staticmethod
|
||||||
|
def DBType():
|
||||||
|
return DBType.NEGOTIATION.value
|
||||||
|
|
||||||
|
__tablename__ = "chats"
|
||||||
|
__table_args__ = {"schema": "negotiation"}
|
||||||
|
|
||||||
|
chat_id = Column(UUID(as_uuid=True), primary_key=True, server_default=text("gen_random_uuid()")) # 채팅 식별자(PK)
|
||||||
|
session_id = Column(UUID(as_uuid=True), nullable=False) # 소속 세션(negotiation.sessions.session_id)
|
||||||
|
card_id = Column(UUID(as_uuid=True), nullable=True) # 사용된 카드(card.nego_cards/wild_cards)
|
||||||
|
seq = Column(Integer, nullable=False, server_default=text("1")) # 세션 내 메시지 순번
|
||||||
|
sender = Column(SmallInteger, nullable=False) # 발신자 (ChatSender: 1=BOT, 2=USER)
|
||||||
|
target_price = Column(BigInteger, nullable=False) # 제시 목표가(원)
|
||||||
|
card_used_yn = Column(Boolean, nullable=True) # 카드 사용 여부
|
||||||
|
indicator_value = Column(Numeric(8, 6), nullable=True) # 협상 지표값
|
||||||
|
card_type = Column(SmallInteger, nullable=True) # 카드 유형: 1=nego_card, 2=wild_card
|
||||||
|
meta = Column(JSONB, nullable=True) # 말풍선 표현 데이터(script/step/client_step/input_mode/input_options/chat_end)
|
||||||
|
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 supplier_user_tokens(MAIN_BASE):
|
class supplier_user_tokens(MAIN_BASE):
|
||||||
# 유저 인증 토큰. supplier_users 1 : N tokens.
|
# 유저 인증 토큰. supplier_users 1 : N tokens.
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
@ -44,6 +44,12 @@ class ErrorType(Enum):
|
|||||||
NEGO_DEADLINE_PASSED = auto() # 1303 견적 마감 시간 초과
|
NEGO_DEADLINE_PASSED = auto() # 1303 견적 마감 시간 초과
|
||||||
NEGO_NOT_FOUND = auto() # 1304 세션/견적 없음
|
NEGO_NOT_FOUND = auto() # 1304 세션/견적 없음
|
||||||
|
|
||||||
|
# 채팅(chat) 관련 에러
|
||||||
|
CHAT_NOT_IN_PROGRESS = 1400 # 협상중 상태가 아니라 대화 불가(미참여/완료/거부)
|
||||||
|
CHAT_PRICE_OUT_OF_RANGE = auto() # 1401 제시가가 허용 범위를 벗어남
|
||||||
|
CHAT_AGENT_UNAVAILABLE = auto() # 1402 협상 에이전트(agent) 호출 실패
|
||||||
|
CHAT_IN_PROGRESS = auto() # 1403 직전 턴 처리 중(동시 전송 가드)
|
||||||
|
|
||||||
|
|
||||||
# ErrorType 의 HTTP_* 값과 status_code 를 맞춰 router 단에서 raise 한다.
|
# ErrorType 의 HTTP_* 값과 status_code 를 맞춰 router 단에서 raise 한다.
|
||||||
EXCEPTION_INVALID_CLIENT_REQUEST = HTTPException(status_code=ErrorType.HTTP_INVALID_CLIENT_REQUEST.value, detail=ErrorType.HTTP_INVALID_CLIENT_REQUEST.name)
|
EXCEPTION_INVALID_CLIENT_REQUEST = HTTPException(status_code=ErrorType.HTTP_INVALID_CLIENT_REQUEST.value, detail=ErrorType.HTTP_INVALID_CLIENT_REQUEST.name)
|
||||||
@ -125,3 +131,12 @@ class QuotationStatus(Enum):
|
|||||||
CREATED = 1 # 견적생성
|
CREATED = 1 # 견적생성
|
||||||
IN_PROGRESS = 2 # 견적진행중
|
IN_PROGRESS = 2 # 견적진행중
|
||||||
CLOSED = 3 # 견적마감
|
CLOSED = 3 # 견적마감
|
||||||
|
|
||||||
|
|
||||||
|
class ChatSender(Enum):
|
||||||
|
"""채팅 발신자 코드. negotiation.chats.sender.
|
||||||
|
BOT 은 갑(바이어/agent)이 제시하는 협상 메시지, USER 는 공급사(접속 유저)의 입력이다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
BOT = 1 # 갑(바이어/agent) — bot 메시지
|
||||||
|
USER = 2 # 공급사(을) — user 입력
|
||||||
|
|||||||
@ -43,3 +43,10 @@ class JwtToken(ConfigModel):
|
|||||||
refresh_key: str = ""
|
refresh_key: str = ""
|
||||||
access_expire_min: int = 30
|
access_expire_min: int = 30
|
||||||
refresh_expire_day: int = 7
|
refresh_expire_day: int = 7
|
||||||
|
|
||||||
|
|
||||||
|
# 협상 에이전트(agent, 포트 9500) 접속 설정. backend 가 /chat 한 턴을 agent 로 위임할 때 사용.
|
||||||
|
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 개발 중 통합 테스트용)
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import os
|
import os
|
||||||
|
|
||||||
from config.config_loader import Configs
|
from config.config_loader import Configs
|
||||||
from config.config_models import WebServerConfig, LogConfig, MainDBConfig, JwtToken
|
from config.config_models import WebServerConfig, LogConfig, MainDBConfig, JwtToken, AgentConfig
|
||||||
|
|
||||||
# 실행 환경 결정 (기본 local). 환경변수 APP_ENV 로 변경.
|
# 실행 환경 결정 (기본 local). 환경변수 APP_ENV 로 변경.
|
||||||
APP_ENV = os.environ.get("APP_ENV", "local")
|
APP_ENV = os.environ.get("APP_ENV", "local")
|
||||||
@ -19,6 +19,14 @@ web_server_config: WebServerConfig = configs.get(WebServerConfig)
|
|||||||
log_config: LogConfig = configs.get(LogConfig)
|
log_config: LogConfig = configs.get(LogConfig)
|
||||||
main_db_config: MainDBConfig = configs.get(MainDBConfig)
|
main_db_config: MainDBConfig = configs.get(MainDBConfig)
|
||||||
jwt_token_config: JwtToken = configs.get(JwtToken)
|
jwt_token_config: JwtToken = configs.get(JwtToken)
|
||||||
|
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 그대로.
|
# DB 접속 env override (config.local.toml 유지, 도커에서 host 만 교체). 로컬은 env 미설정 → toml 그대로.
|
||||||
|
|||||||
147
backend/crud/chat_crud.py
Normal file
147
backend/crud/chat_crud.py
Normal file
@ -0,0 +1,147 @@
|
|||||||
|
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.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.logger import LOG
|
||||||
|
|
||||||
|
|
||||||
|
# 협상 채팅 CRUD. 메시지 로그(negotiation.chats)와 종료 시 세션 입찰 확정(negotiation.sessions)을 다룬다.
|
||||||
|
# chats / sessions 모두 NEGOTIATION 논리 DB 라 한 트랜잭션(execute_lambda_run)으로 묶을 수 있다.
|
||||||
|
class IChatCRUD(ABC):
|
||||||
|
@abstractmethod
|
||||||
|
async def list_by_session(self, cdb: AsyncSession, session_id) -> Tuple[ErrorType, list]:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def get_last(self, cdb: AsyncSession, session_id) -> Tuple[ErrorType, Tuple[int, Optional[int]]]:
|
||||||
|
"""마지막 메시지의 (seq, sender). 없으면 (0, None). 동시전송 가드 + seq 채번에 사용."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def insert_message(self, cdb: AsyncSession, message: chats) -> ErrorType:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
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
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def finalize_session(
|
||||||
|
self, cdb: AsyncSession, session_id, status: int,
|
||||||
|
bid_price: Optional[int] = None, reject_reason: Optional[str] = None, reject_price: Optional[int] = None,
|
||||||
|
) -> ErrorType:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ChatCRUD(IChatCRUD):
|
||||||
|
async def list_by_session(self, cdb: AsyncSession, session_id) -> Tuple[ErrorType, list]:
|
||||||
|
try:
|
||||||
|
# (session_id, seq) 유니크 인덱스가 정렬 스캔을 커버한다.
|
||||||
|
query = (
|
||||||
|
select(chats)
|
||||||
|
.where(chats.session_id == session_id, chats.deleted == False) # noqa: E712
|
||||||
|
.order_by(asc(chats.seq))
|
||||||
|
)
|
||||||
|
err_type, rows = await DB_SESSION_MNG.execute(cdb, query, "list_by_session failed.")
|
||||||
|
if err_type != ErrorType.SUCCESS:
|
||||||
|
return err_type, []
|
||||||
|
return ErrorType.SUCCESS, rows
|
||||||
|
except Exception as ex:
|
||||||
|
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]]]:
|
||||||
|
try:
|
||||||
|
query = (
|
||||||
|
select(chats.seq, chats.sender)
|
||||||
|
.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)
|
||||||
|
if not rows:
|
||||||
|
return ErrorType.SUCCESS, (0, None)
|
||||||
|
return ErrorType.SUCCESS, (rows[0][0], rows[0][1])
|
||||||
|
except Exception as ex:
|
||||||
|
LOG.e_no_callstack(ex)
|
||||||
|
return ErrorType.DB_RUN_FAILED, (0, None)
|
||||||
|
|
||||||
|
async def insert_message(self, cdb: AsyncSession, message: chats) -> ErrorType:
|
||||||
|
try:
|
||||||
|
return await DB_SESSION_MNG.insert(cdb, message, raise_error=False)
|
||||||
|
except Exception as ex:
|
||||||
|
LOG.e_no_callstack(ex)
|
||||||
|
return ErrorType.DB_RUN_FAILED
|
||||||
|
|
||||||
|
async def soft_delete_message(self, cdb: AsyncSession, chat_id) -> ErrorType:
|
||||||
|
# agent 실패 시 선점(pre-claim)한 유저 메시지를 되돌린다. 부분 유니크(WHERE deleted=FALSE)라 seq 가 다시 비워진다.
|
||||||
|
try:
|
||||||
|
query = update(chats).where(chats.chat_id == chat_id).values(deleted=True)
|
||||||
|
return await DB_SESSION_MNG.add(cdb, query)
|
||||||
|
except Exception as ex:
|
||||||
|
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
|
||||||
|
err_type, row_list = await DB_SESSION_MNG.execute(cdb, query, f"get_item_by_id({item_id}) failed.")
|
||||||
|
if err_type != ErrorType.SUCCESS:
|
||||||
|
return err_type, None
|
||||||
|
if len(row_list) != 1:
|
||||||
|
return ErrorType.DB_INVALID_KEY, None
|
||||||
|
return ErrorType.SUCCESS, row_list[0]
|
||||||
|
except Exception as ex:
|
||||||
|
LOG.e_no_callstack(ex)
|
||||||
|
return ErrorType.DB_RUN_FAILED, None
|
||||||
|
|
||||||
|
async def finalize_session(
|
||||||
|
self, cdb: AsyncSession, session_id, status: int,
|
||||||
|
bid_price: Optional[int] = None, reject_reason: Optional[str] = None, reject_price: Optional[int] = None,
|
||||||
|
) -> ErrorType:
|
||||||
|
try:
|
||||||
|
values = {"status": status}
|
||||||
|
if bid_price is not None:
|
||||||
|
values["bid_price"] = bid_price
|
||||||
|
values["bid_at"] = datetime.now(timezone.utc)
|
||||||
|
if reject_reason is not None:
|
||||||
|
values["reject_reason"] = reject_reason[:255]
|
||||||
|
if reject_price is not None:
|
||||||
|
values["reject_price"] = reject_price
|
||||||
|
query = update(sessions).where(sessions.session_id == session_id).values(**values)
|
||||||
|
return await DB_SESSION_MNG.add(cdb, query)
|
||||||
|
except Exception as ex:
|
||||||
|
LOG.e_no_callstack(ex)
|
||||||
|
return ErrorType.DB_RUN_FAILED
|
||||||
@ -7,3 +7,4 @@ python-jose[cryptography]
|
|||||||
bcrypt
|
bcrypt
|
||||||
orjson
|
orjson
|
||||||
pydantic>=2.0
|
pydantic>=2.0
|
||||||
|
httpx # agent(협상 에이전트) 호출용 async HTTP 클라이언트
|
||||||
|
|||||||
@ -11,6 +11,7 @@ from common.utils.gtime import GTime
|
|||||||
from config.server_configs import web_server_config
|
from config.server_configs import web_server_config
|
||||||
import router.v1.auth.account
|
import router.v1.auth.account
|
||||||
import router.v1.negotiation.session
|
import router.v1.negotiation.session
|
||||||
|
import router.v1.negotiation.chat
|
||||||
|
|
||||||
API_SERVER_START_TIME = GTime.UTCStr()
|
API_SERVER_START_TIME = GTime.UTCStr()
|
||||||
|
|
||||||
@ -57,3 +58,4 @@ async def healthz():
|
|||||||
# 각 도메인 라우터를 등록한다. 새 기능 추가 시 router.v1.<domain>.<file> 를 import 후 include.
|
# 각 도메인 라우터를 등록한다. 새 기능 추가 시 router.v1.<domain>.<file> 를 import 후 include.
|
||||||
app.include_router(router.v1.auth.account.router)
|
app.include_router(router.v1.auth.account.router)
|
||||||
app.include_router(router.v1.negotiation.session.router)
|
app.include_router(router.v1.negotiation.session.router)
|
||||||
|
app.include_router(router.v1.negotiation.chat.router)
|
||||||
|
|||||||
57
backend/router/v1/negotiation/chat.py
Normal file
57
backend/router/v1/negotiation/chat.py
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from fastapi.security import HTTPAuthorizationCredentials
|
||||||
|
|
||||||
|
from common.models.gmodel import UserInfo
|
||||||
|
from router.v1.validator.dependencies import IsValidAccessToken, RemoveNoneResponse, security
|
||||||
|
from services.chat_service import ChatService
|
||||||
|
from .chat_protocol import Req_ChatSend, Res_ChatInit, Res_ChatMessages, Res_ChatSend
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/v1/negotiation", tags=["Negotiation Chat"], responses={404: {"description": "Not found"}})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
path="/sessions/{session_id}/chat/init",
|
||||||
|
response_model=Res_ChatInit,
|
||||||
|
summary="채팅 진입(상품·견적 메타)",
|
||||||
|
description="채팅 화면 진입용. 상품/견적 정보 + 현재 세션 상태 + 마감 시각(타이머)을 반환한다. 소유(공급사) 검증.",
|
||||||
|
)
|
||||||
|
async def chat_init(
|
||||||
|
session_id: str,
|
||||||
|
user_info: UserInfo = Depends(IsValidAccessToken),
|
||||||
|
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||||
|
service: ChatService = Depends(),
|
||||||
|
):
|
||||||
|
return RemoveNoneResponse(await service.init(user_info, credentials.credentials, session_id))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
path="/sessions/{session_id}/chat/messages",
|
||||||
|
response_model=Res_ChatMessages,
|
||||||
|
summary="대화 히스토리",
|
||||||
|
description="세션의 대화 말풍선 목록(seq 오름차순). 비어 있고 협상중이면 오프닝 메시지를 생성해 포함한다.",
|
||||||
|
)
|
||||||
|
async def chat_messages(
|
||||||
|
session_id: str,
|
||||||
|
user_info: UserInfo = Depends(IsValidAccessToken),
|
||||||
|
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||||
|
service: ChatService = Depends(),
|
||||||
|
):
|
||||||
|
return RemoveNoneResponse(await service.messages(user_info, credentials.credentials, session_id))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
path="/sessions/{session_id}/chat/send",
|
||||||
|
response_model=Res_ChatSend,
|
||||||
|
summary="협상 한 턴 전송",
|
||||||
|
description="유저 입력을 보내고 agent 가 만든 봇 응답 1건을 반환한다(append-only). 종료 시 세션 입찰을 확정한다.",
|
||||||
|
)
|
||||||
|
async def chat_send(
|
||||||
|
session_id: str,
|
||||||
|
req: Req_ChatSend,
|
||||||
|
user_info: UserInfo = Depends(IsValidAccessToken),
|
||||||
|
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||||
|
service: ChatService = Depends(),
|
||||||
|
):
|
||||||
|
return RemoveNoneResponse(
|
||||||
|
await service.send(user_info, credentials.credentials, session_id, req.user_input_type, req.user_input)
|
||||||
|
)
|
||||||
66
backend/router/v1/negotiation/chat_protocol.py
Normal file
66
backend/router/v1/negotiation/chat_protocol.py
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
"""채팅(chat) 라우터 프로토콜 — backend ↔ 프론트 계약.
|
||||||
|
|
||||||
|
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).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from common.models.gmodel import Res_WebPacketProtocol, WebPacketProtocol
|
||||||
|
|
||||||
|
|
||||||
|
# 말풍선 한 건. sender 는 ChatSender 정수 코드(1=BOT, 2=USER)로 내려가고 라벨 매핑은 프론트가 한다.
|
||||||
|
class ChatMessage(WebPacketProtocol):
|
||||||
|
chat_id: str = ""
|
||||||
|
session_id: str = ""
|
||||||
|
seq: int = 0
|
||||||
|
sender: int = 0 # ChatSender 코드
|
||||||
|
script: str = ""
|
||||||
|
user_input_type: Optional[str] = None # 유저 입력 종류: text|percent|price
|
||||||
|
step: str = ""
|
||||||
|
display_step: str = "" # agent client_step
|
||||||
|
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 # (범위 외 예약) 협상 지표
|
||||||
|
bot_chat_type: Optional[str] = None # (범위 외 예약) indicator|summary 등
|
||||||
|
|
||||||
|
|
||||||
|
# 채팅 진입 — 상품/견적 메타 + 현재 세션 상태 + 마감 시각(타이머용)
|
||||||
|
class Res_ChatInit(Res_WebPacketProtocol):
|
||||||
|
session_id: str = ""
|
||||||
|
session_status: int = 0 # SessionStatus 코드
|
||||||
|
quotation_id: str = ""
|
||||||
|
quotation_end_time: str = "" # ISO 8601 (마감 시각)
|
||||||
|
quotation_memo: str = ""
|
||||||
|
item_id: str = ""
|
||||||
|
item_name: str = ""
|
||||||
|
item_code: str = ""
|
||||||
|
item_image: str = ""
|
||||||
|
item_price: int = 0
|
||||||
|
item_model_name: str = ""
|
||||||
|
item_maker_name: str = ""
|
||||||
|
item_spec: str = ""
|
||||||
|
item_lead_time: str = ""
|
||||||
|
item_min_order_quantity: str = ""
|
||||||
|
item_vat_yn: Optional[bool] = None
|
||||||
|
item_delivery_fee_yn: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
|
# 대화 히스토리(재진입 복원)
|
||||||
|
class Res_ChatMessages(Res_WebPacketProtocol):
|
||||||
|
items: list[ChatMessage] = []
|
||||||
|
|
||||||
|
|
||||||
|
# 한 턴 전송. user_input 은 버튼 텍스트 또는 가격/퍼센트 문자열.
|
||||||
|
class Req_ChatSend(WebPacketProtocol):
|
||||||
|
user_input_type: Optional[str] = None # text|percent|price
|
||||||
|
user_input: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
# append-only: 새 봇 메시지 1건 + 갱신된 세션 상태만 반환(전체 refetch 회피)
|
||||||
|
class Res_ChatSend(Res_WebPacketProtocol):
|
||||||
|
message: Optional[ChatMessage] = None
|
||||||
|
session_status: int = 0
|
||||||
153
backend/services/agent_client.py
Normal file
153
backend/services/agent_client.py
Normal file
@ -0,0 +1,153 @@
|
|||||||
|
"""협상 에이전트(agent, 포트 9500) 호출 클라이언트.
|
||||||
|
|
||||||
|
backend 는 /chat 한 턴을 agent 로 위임한다(README: "backend 가 /chat 을 agent 로 위임").
|
||||||
|
agent 의 계약(Req_Chat/Res_Chat)에 맞춘 어댑터. agent 가 아직 없거나 로컬에서 미연동일 때를 위해
|
||||||
|
mock 구현을 두고 config(AgentConfig.use_mock) 로 선택한다 — 이 격리 덕에 backend/프론트를
|
||||||
|
agent 완성 여부와 무관하게 통합 테스트할 수 있다.
|
||||||
|
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from common.logger import LOG
|
||||||
|
from config.server_configs import agent_config
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AgentTurn:
|
||||||
|
"""agent 한 턴 응답(Res_Chat) 의 backend 표현."""
|
||||||
|
|
||||||
|
session_id: Optional[str] = None
|
||||||
|
step: str = ""
|
||||||
|
client_step: str = ""
|
||||||
|
script: str = ""
|
||||||
|
input_mode: Optional[str] = None # 프론트 next_input_mode 로 매핑
|
||||||
|
input_options: Optional[list[str]] = None # 프론트 next_input_type 로 매핑
|
||||||
|
chat_end: bool = False
|
||||||
|
outcome: Optional[str] = None # "success" | "failure" (종료 시)
|
||||||
|
card_id: Optional[str] = None
|
||||||
|
indicator_value: Optional[float] = None
|
||||||
|
ok: bool = True # agent 호출 성공 여부 (False 면 CHAT_AGENT_UNAVAILABLE)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AgentChatContext:
|
||||||
|
"""새 세션 시작 시 agent 에 주입하는 협상 컨텍스트. 기존 세션이면 user_input 만 의미 있다."""
|
||||||
|
|
||||||
|
tenant_id: str # X-Tenant-ID = 견적(갑) 회사 company_id
|
||||||
|
rq_type: str = "재협상" # 재협상 | 재견적
|
||||||
|
target_price: int = 0 # 갑 목표 매입가(원)
|
||||||
|
anchor_price: int = 0 # 앵커링가(목표가보다 낮음)
|
||||||
|
turn: int = 0 # 직전까지의 봇 턴 수(mock 진행용; 실제 agent 는 무시)
|
||||||
|
extra: dict = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class IAgentClient(ABC):
|
||||||
|
@abstractmethod
|
||||||
|
async def chat(self, session_id: Optional[str], user_input: Optional[str], ctx: AgentChatContext) -> AgentTurn:
|
||||||
|
"""협상 한 턴. session_id 없으면 새 세션 시작. user_input 으로 진행(버튼 텍스트/가격)."""
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
class HttpAgentClient(IAgentClient):
|
||||||
|
"""실제 agent(9500) 위임 구현. agent POST /v1/chat 호출."""
|
||||||
|
|
||||||
|
async def chat(self, session_id: Optional[str], user_input: Optional[str], ctx: AgentChatContext) -> AgentTurn:
|
||||||
|
import httpx # 지연 import — mock 모드에서는 httpx 의존을 강제하지 않는다.
|
||||||
|
|
||||||
|
body = {
|
||||||
|
"session_id": session_id, # 핸드오프 #1: agent 가 이 값을 세션 키로 그대로 사용해야 함
|
||||||
|
"rq_type": ctx.rq_type,
|
||||||
|
"user_input": user_input,
|
||||||
|
"target_price": ctx.target_price,
|
||||||
|
"anchor_price": ctx.anchor_price,
|
||||||
|
}
|
||||||
|
headers = {"X-Tenant-ID": ctx.tenant_id} # 핸드오프 #2
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(base_url=agent_config.base_url, timeout=agent_config.timeout_sec) as cli:
|
||||||
|
resp = await cli.post("/v1/chat", json=body, headers=headers)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
except Exception as ex:
|
||||||
|
LOG.e_no_callstack(f"[AgentClient] agent 호출 실패: {ex}")
|
||||||
|
return AgentTurn(ok=False)
|
||||||
|
|
||||||
|
return AgentTurn(
|
||||||
|
session_id=data.get("session_id"),
|
||||||
|
step=data.get("step") or "",
|
||||||
|
client_step=data.get("client_step") or "",
|
||||||
|
script=data.get("script") or "",
|
||||||
|
input_mode=data.get("input_mode"),
|
||||||
|
input_options=data.get("input_options"),
|
||||||
|
chat_end=bool(data.get("chat_end", False)),
|
||||||
|
outcome=data.get("outcome"),
|
||||||
|
card_id=data.get("card_id"),
|
||||||
|
indicator_value=data.get("indicator_value"),
|
||||||
|
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()
|
||||||
334
backend/services/chat_service.py
Normal file
334
backend/services/chat_service.py
Normal file
@ -0,0 +1,334 @@
|
|||||||
|
"""ChatService — 채팅 페이지 오케스트레이션.
|
||||||
|
|
||||||
|
backend 가 협상 한 턴을 agent(9500) 로 위임하고, 말풍선 로그(negotiation.chats)를 영속화하며,
|
||||||
|
종료 시 세션 상태(negotiation.sessions)를 전이한다. agent 는 외부 고정 계약(agent_client 어댑터).
|
||||||
|
|
||||||
|
- init : 상품/견적 메타 + 현재 세션 상태 (타이머용 마감 시각 포함)
|
||||||
|
- messages : 대화 히스토리 복원. 비어 있고 협상중이면 agent 오프닝 한 턴을 seed(지연 생성).
|
||||||
|
- send : (검증 → 유저 메시지 저장 → agent 위임 → 봇 메시지 저장 → 종료 시 입찰 확정) 단일 트랜잭션.
|
||||||
|
append-only — 새 봇 메시지 1건만 반환(전체 refetch 회피).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import Depends
|
||||||
|
|
||||||
|
from common.database.db_session_manager import DB_SESSION_MNG
|
||||||
|
from common.database.model.models import chats, items, quotations, sessions
|
||||||
|
from common.enums import ChatSender, DBWRType, ErrorType, QuotationStatus, SessionStatus
|
||||||
|
from common.models.gmodel import UserInfo
|
||||||
|
from crud.chat_crud import ChatCRUD, IChatCRUD
|
||||||
|
from crud.session_crud import ISessionCRUD, SessionCRUD
|
||||||
|
from router.v1.negotiation.chat_protocol import ChatMessage, Res_ChatInit, Res_ChatMessages, Res_ChatSend
|
||||||
|
from services.agent_client import AgentChatContext, IAgentClient, get_agent_client
|
||||||
|
from services.auth_service import AuthService
|
||||||
|
|
||||||
|
|
||||||
|
# 가격 허용 범위 배수(목표가 기준). 범위를 벗어난 제시가는 CHAT_PRICE_OUT_OF_RANGE 로 막는다.
|
||||||
|
PRICE_FLOOR_RATIO = 0.3
|
||||||
|
PRICE_CEIL_RATIO = 1.7
|
||||||
|
|
||||||
|
|
||||||
|
class ChatService:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
auth: AuthService = Depends(AuthService),
|
||||||
|
session_crud: ISessionCRUD = Depends(SessionCRUD),
|
||||||
|
chat_crud: IChatCRUD = Depends(ChatCRUD),
|
||||||
|
agent: IAgentClient = Depends(get_agent_client),
|
||||||
|
):
|
||||||
|
self.auth = auth
|
||||||
|
self.session_crud = session_crud
|
||||||
|
self.chat_crud = chat_crud
|
||||||
|
self.agent = agent
|
||||||
|
|
||||||
|
# ---- 공통 전처리 ----------------------------------------------------
|
||||||
|
async def _auth_and_own_session(self, user_info: UserInfo, access_token: str, session_id_str: str):
|
||||||
|
"""인증 → 세션 로드 → 소유(공급사) 검증. (SUCCESS, sess) 또는 (err, None)."""
|
||||||
|
err_type, info = await self.auth.authenticate(user_info, access_token)
|
||||||
|
if err_type != ErrorType.SUCCESS:
|
||||||
|
return err_type, None
|
||||||
|
try:
|
||||||
|
session_id = uuid.UUID(session_id_str)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return ErrorType.NEGO_NOT_FOUND, None
|
||||||
|
|
||||||
|
err_type, sess = await DB_SESSION_MNG.execute_lambda(
|
||||||
|
sessions.DBType(), DBWRType.DB_READ.value,
|
||||||
|
lambda s: self.session_crud.get_session_by_id(s, session_id),
|
||||||
|
)
|
||||||
|
if err_type != ErrorType.SUCCESS or sess is None:
|
||||||
|
return ErrorType.NEGO_NOT_FOUND, None
|
||||||
|
if str(sess.supplier_id) != info.supplier_id:
|
||||||
|
return ErrorType.NEGO_FORBIDDEN, None
|
||||||
|
return ErrorType.SUCCESS, sess
|
||||||
|
|
||||||
|
# ---- init -----------------------------------------------------------
|
||||||
|
async def init(self, user_info: UserInfo, access_token: str, session_id_str: str) -> Res_ChatInit:
|
||||||
|
res = Res_ChatInit()
|
||||||
|
err_type, sess = await self._auth_and_own_session(user_info, access_token, session_id_str)
|
||||||
|
if err_type != ErrorType.SUCCESS:
|
||||||
|
res.result.SetResult(err_type)
|
||||||
|
return res
|
||||||
|
|
||||||
|
err_type, quote = await DB_SESSION_MNG.execute_lambda(
|
||||||
|
quotations.DBType(), DBWRType.DB_READ.value,
|
||||||
|
lambda s: self.session_crud.get_quotation_by_id(s, sess.quotation_id),
|
||||||
|
)
|
||||||
|
if err_type != ErrorType.SUCCESS or quote is None:
|
||||||
|
res.result.SetResult(ErrorType.NEGO_NOT_FOUND)
|
||||||
|
return res
|
||||||
|
|
||||||
|
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 or item is None:
|
||||||
|
res.result.SetResult(ErrorType.NEGO_NOT_FOUND)
|
||||||
|
return res
|
||||||
|
|
||||||
|
# 마감 시간 초과 + 협상생성이면 미참여로 정리 (participate 와 동일 일관성).
|
||||||
|
end = quote.end_time
|
||||||
|
if end is not None and end.tzinfo is None:
|
||||||
|
end = end.replace(tzinfo=timezone.utc)
|
||||||
|
if end is not None and end < datetime.now(timezone.utc) and sess.status == SessionStatus.CREATED.value:
|
||||||
|
await DB_SESSION_MNG.execute_lambda_run(
|
||||||
|
[sessions.DBType()],
|
||||||
|
[lambda s: self.session_crud.update_session_status(s, sess.session_id, SessionStatus.NOT_PARTICIPATED.value)],
|
||||||
|
)
|
||||||
|
sess.status = SessionStatus.NOT_PARTICIPATED.value
|
||||||
|
|
||||||
|
res.session_id = str(sess.session_id)
|
||||||
|
res.session_status = sess.status
|
||||||
|
res.quotation_id = str(sess.quotation_id)
|
||||||
|
res.quotation_end_time = quote.end_time.isoformat(timespec="seconds") if quote.end_time else ""
|
||||||
|
res.quotation_memo = quote.memo or ""
|
||||||
|
res.item_id = str(item.item_id)
|
||||||
|
res.item_name = item.name or ""
|
||||||
|
res.item_code = item.code or ""
|
||||||
|
res.item_image = item.image_url or ""
|
||||||
|
res.item_price = item.price or 0
|
||||||
|
res.item_model_name = item.model_name or ""
|
||||||
|
res.item_maker_name = item.manufacturer or ""
|
||||||
|
res.item_spec = item.spec or ""
|
||||||
|
res.item_lead_time = str(item.lead_time) if item.lead_time is not None else ""
|
||||||
|
res.item_min_order_quantity = item.moq or ""
|
||||||
|
res.item_vat_yn = item.vat_yn
|
||||||
|
res.item_delivery_fee_yn = item.delivery_fee_yn
|
||||||
|
return res
|
||||||
|
|
||||||
|
# ---- messages -------------------------------------------------------
|
||||||
|
async def messages(self, user_info: UserInfo, access_token: str, session_id_str: str) -> Res_ChatMessages:
|
||||||
|
res = Res_ChatMessages()
|
||||||
|
err_type, sess = await self._auth_and_own_session(user_info, access_token, session_id_str)
|
||||||
|
if err_type != ErrorType.SUCCESS:
|
||||||
|
res.result.SetResult(err_type)
|
||||||
|
return res
|
||||||
|
|
||||||
|
err_type, rows = await DB_SESSION_MNG.execute_lambda(
|
||||||
|
chats.DBType(), DBWRType.DB_READ.value,
|
||||||
|
lambda s: self.chat_crud.list_by_session(s, sess.session_id),
|
||||||
|
)
|
||||||
|
if err_type != ErrorType.SUCCESS:
|
||||||
|
res.result.SetResult(err_type)
|
||||||
|
return res
|
||||||
|
|
||||||
|
# 비어 있고 협상중이면 agent 오프닝 한 턴을 seed (재진입 시 인사 메시지 보존)
|
||||||
|
if not rows and sess.status == SessionStatus.IN_PROGRESS.value:
|
||||||
|
opening = await self._seed_opening(sess)
|
||||||
|
if opening is not None:
|
||||||
|
res.items = [opening]
|
||||||
|
return res
|
||||||
|
|
||||||
|
res.items = [self._row_to_message(r) for r in rows]
|
||||||
|
return res
|
||||||
|
|
||||||
|
async def _seed_opening(self, sess) -> Optional[ChatMessage]:
|
||||||
|
"""오프닝(턴0) 봇 메시지를 agent 로 생성하고 seq=1 로 저장한다. 동시 진입 충돌은 무시(유니크가 방어)."""
|
||||||
|
ctx = self._agent_context(sess, turn=0)
|
||||||
|
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)
|
||||||
|
await DB_SESSION_MNG.execute_lambda_run(
|
||||||
|
[chats.DBType()], [lambda s: self.chat_crud.insert_message(s, bot)]
|
||||||
|
)
|
||||||
|
return self._chat_to_message(bot)
|
||||||
|
|
||||||
|
# ---- send (핵심) ----------------------------------------------------
|
||||||
|
async def send(self, user_info: UserInfo, access_token: str, session_id_str: str, user_input_type: Optional[str], user_input: str) -> Res_ChatSend:
|
||||||
|
res = Res_ChatSend()
|
||||||
|
|
||||||
|
err_type, sess = await self._auth_and_own_session(user_info, access_token, session_id_str)
|
||||||
|
if err_type != ErrorType.SUCCESS:
|
||||||
|
res.result.SetResult(err_type)
|
||||||
|
return res
|
||||||
|
|
||||||
|
# 협상중이 아니면 대화 불가
|
||||||
|
if sess.status != SessionStatus.IN_PROGRESS.value:
|
||||||
|
res.result.SetResult(ErrorType.CHAT_NOT_IN_PROGRESS)
|
||||||
|
return res
|
||||||
|
|
||||||
|
# 견적 마감/시간 검증
|
||||||
|
err_type, quote = await DB_SESSION_MNG.execute_lambda(
|
||||||
|
quotations.DBType(), DBWRType.DB_READ.value,
|
||||||
|
lambda s: self.session_crud.get_quotation_by_id(s, sess.quotation_id),
|
||||||
|
)
|
||||||
|
if err_type != ErrorType.SUCCESS or quote is None:
|
||||||
|
res.result.SetResult(ErrorType.NEGO_NOT_FOUND)
|
||||||
|
return res
|
||||||
|
if quote.status == QuotationStatus.CLOSED.value:
|
||||||
|
res.result.SetResult(ErrorType.NEGO_QUOTATION_CLOSED)
|
||||||
|
return res
|
||||||
|
end = quote.end_time
|
||||||
|
if end is not None and end.tzinfo is None:
|
||||||
|
end = end.replace(tzinfo=timezone.utc)
|
||||||
|
if end is not None and end < datetime.now(timezone.utc):
|
||||||
|
res.result.SetResult(ErrorType.NEGO_DEADLINE_PASSED)
|
||||||
|
return res
|
||||||
|
|
||||||
|
# 가격 입력이면 범위 검증
|
||||||
|
price = _parse_price(user_input) if user_input_type == "price" else None
|
||||||
|
if user_input_type == "price":
|
||||||
|
if price is None or not _in_price_range(price, sess.target_price):
|
||||||
|
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(
|
||||||
|
chats.DBType(), DBWRType.DB_READ.value,
|
||||||
|
lambda s: self.chat_crud.get_last(s, sess.session_id),
|
||||||
|
)
|
||||||
|
if err_type != ErrorType.SUCCESS:
|
||||||
|
res.result.SetResult(err_type)
|
||||||
|
return res
|
||||||
|
# 직전이 유저 메시지면 이전 턴이 아직 처리 중(봇 응답 미도착) → 중복 전송 거절
|
||||||
|
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)
|
||||||
|
return res
|
||||||
|
|
||||||
|
# 유저 메시지 선점(pre-claim): (session_id, seq) 부분 유니크로 동시 전송을 직렬화한다.
|
||||||
|
# 경합에서 밀리면(같은 seq 충돌) agent 를 호출하지 않고 CHAT_IN_PROGRESS 로 거절 → 중복 진행 방지.
|
||||||
|
user_msg = self._build_user_chat(sess, seq=max_seq + 1, user_input=user_input, user_input_type=user_input_type, price=price)
|
||||||
|
err_type = await DB_SESSION_MNG.execute_lambda_run(
|
||||||
|
[chats.DBType()], [lambda s: self.chat_crud.insert_message(s, user_msg)]
|
||||||
|
)
|
||||||
|
if err_type == ErrorType.DB_ALREADY_SAME_KEY:
|
||||||
|
res.result.SetResult(ErrorType.CHAT_IN_PROGRESS)
|
||||||
|
return res
|
||||||
|
if err_type != ErrorType.SUCCESS:
|
||||||
|
res.result.SetResult(err_type)
|
||||||
|
return res
|
||||||
|
|
||||||
|
# agent 위임 (한 턴). 실패 시 선점한 유저 메시지를 롤백 → 재시도 가능.
|
||||||
|
ctx = self._agent_context(sess, turn=turn_no)
|
||||||
|
turn = await self.agent.chat(session_id=str(sess.session_id), user_input=user_input, ctx=ctx)
|
||||||
|
if not turn.ok:
|
||||||
|
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)
|
||||||
|
return res
|
||||||
|
|
||||||
|
# 봇 메시지 + 종료 시 확정(성공=DONE+입찰가 / 실패=REJECTED+거부사유·제시가). 한 트랜잭션.
|
||||||
|
bot_msg = self._build_bot_chat(sess, seq=max_seq + 2, turn=turn)
|
||||||
|
funcs = [lambda s: self.chat_crud.insert_message(s, bot_msg)]
|
||||||
|
new_status = sess.status
|
||||||
|
if turn.chat_end:
|
||||||
|
if turn.outcome == "success":
|
||||||
|
new_status = SessionStatus.DONE.value
|
||||||
|
bid = price if price is not None else sess.target_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
|
||||||
|
funcs.append(lambda s: self.chat_crud.finalize_session(
|
||||||
|
s, sess.session_id, new_status,
|
||||||
|
reject_reason=(user_input or None), reject_price=price,
|
||||||
|
))
|
||||||
|
|
||||||
|
err_type = await DB_SESSION_MNG.execute_lambda_run([chats.DBType()], funcs)
|
||||||
|
if err_type != ErrorType.SUCCESS:
|
||||||
|
# 봇 저장 실패 시에도 선점 유저 메시지를 롤백해 stuck(CHAT_IN_PROGRESS) 방지.
|
||||||
|
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(err_type)
|
||||||
|
return res
|
||||||
|
|
||||||
|
res.message = self._chat_to_message(bot_msg)
|
||||||
|
res.session_status = new_status
|
||||||
|
return res
|
||||||
|
|
||||||
|
# ---- 빌더 / 매퍼 ----------------------------------------------------
|
||||||
|
def _agent_context(self, sess, turn: int) -> AgentChatContext:
|
||||||
|
# 핸드오프 #2/#5: X-Tenant-ID 는 견적(갑) 회사 company_id 여야 한다.
|
||||||
|
# 현재 quotation.user_id 만 보유 → 정확한 company_id 해석(company.users 조회)은 agent 연동 시 보완.
|
||||||
|
tenant_id = "" # mock 은 무시. 실제 연동 시 quotation 의 buyer company_id 로 채운다.
|
||||||
|
rq_type = "재협상" if sess.qt_type == 1 else "재견적"
|
||||||
|
anchor = int(sess.target_price * 0.99) if sess.target_price else 0
|
||||||
|
return AgentChatContext(
|
||||||
|
tenant_id=tenant_id, rq_type=rq_type,
|
||||||
|
target_price=int(sess.target_price or 0), anchor_price=anchor, turn=turn,
|
||||||
|
)
|
||||||
|
|
||||||
|
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,
|
||||||
|
sender=ChatSender.USER.value,
|
||||||
|
target_price=int(price) if price is not None else 0,
|
||||||
|
meta={"script": user_input, "user_input_type": user_input_type},
|
||||||
|
)
|
||||||
|
|
||||||
|
def _build_bot_chat(self, sess, seq: int, turn) -> chats:
|
||||||
|
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),
|
||||||
|
meta={
|
||||||
|
"script": turn.script, "step": turn.step, "client_step": turn.client_step,
|
||||||
|
"input_mode": turn.input_mode, "input_options": turn.input_options,
|
||||||
|
"chat_end": turn.chat_end, "card_id": turn.card_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def _chat_to_message(self, c: chats) -> ChatMessage:
|
||||||
|
"""방금 만든 chats 객체 → 응답 ChatMessage (DB 재조회 없이)."""
|
||||||
|
meta = c.meta or {}
|
||||||
|
return ChatMessage(
|
||||||
|
chat_id=str(c.chat_id), session_id=str(c.session_id), seq=c.seq, sender=c.sender,
|
||||||
|
script=meta.get("script") or "",
|
||||||
|
user_input_type=meta.get("user_input_type"),
|
||||||
|
step=meta.get("step") or "",
|
||||||
|
display_step=meta.get("client_step") or "",
|
||||||
|
next_input_mode=meta.get("input_mode"),
|
||||||
|
next_input_type=meta.get("input_options"),
|
||||||
|
chat_end=bool(meta.get("chat_end", False)),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _row_to_message(self, r) -> ChatMessage:
|
||||||
|
"""DB 행(chats) → 응답 ChatMessage."""
|
||||||
|
return self._chat_to_message(r)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- 가격 유틸 ----------------------------------------------------------
|
||||||
|
def _parse_price(text: Optional[str]) -> Optional[int]:
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
digits = "".join(ch for ch in text if ch.isdigit())
|
||||||
|
return int(digits) if digits else None
|
||||||
|
|
||||||
|
|
||||||
|
def _in_price_range(price: int, target_price: Optional[int]) -> bool:
|
||||||
|
if not target_price:
|
||||||
|
return price > 0
|
||||||
|
return int(target_price * PRICE_FLOOR_RATIO) <= price <= int(target_price * PRICE_CEIL_RATIO)
|
||||||
235
backend/tests/test_chat.py
Normal file
235
backend/tests/test_chat.py
Normal file
@ -0,0 +1,235 @@
|
|||||||
|
"""채팅(chat) 도메인 e2e 테스트 — init / messages(오프닝 seed) / send(협상 진행~종료).
|
||||||
|
|
||||||
|
agent 는 config.use_mock=true 로 내장 MockAgentClient 를 쓴다(결정론적 플로우).
|
||||||
|
dev negosium_db 를 그대로 쓰므로 전용 테스트 행만 시드/정리한다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
import bcrypt
|
||||||
|
import pytest_asyncio
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
TEST_LOGIN_ID = "pytest_chat_user"
|
||||||
|
TEST_PW = "pytest1234"
|
||||||
|
TEST_SUPPLIER_NAME = "파이테스트채팅공급사"
|
||||||
|
MARK = "PYTESTCHAT-"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def chat_seed(db_engine):
|
||||||
|
"""공급사 + 유저 + 세션 2건(본인: 협상중 P / 협상생성 C) + 1건(타 공급사 X) 시드."""
|
||||||
|
supplier_id = uuid.uuid4()
|
||||||
|
other_supplier_id = uuid.uuid4()
|
||||||
|
pw_hash = bcrypt.hashpw(TEST_PW.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
|
||||||
|
|
||||||
|
# (code, session.status, qt_type, 마감까지 h, quotation.status, 소속 공급사)
|
||||||
|
specs = [
|
||||||
|
("P", 2, 1, 2, 2, supplier_id), # 협상중 / 재협상 / +2h / 견적진행중
|
||||||
|
("C", 1, 1, 2, 1, supplier_id), # 협상생성 / 재협상 / +2h / 견적생성
|
||||||
|
("X", 2, 1, 2, 2, other_supplier_id), # 타 공급사 → 차단
|
||||||
|
]
|
||||||
|
sids, qids = {}, {}
|
||||||
|
|
||||||
|
async def _cleanup(conn):
|
||||||
|
await conn.execute(text(f"DELETE FROM negotiation.chats WHERE session_id IN (SELECT session_id FROM negotiation.sessions WHERE qt_number LIKE '{MARK}%')"))
|
||||||
|
await conn.execute(text(f"DELETE FROM negotiation.sessions WHERE qt_number LIKE '{MARK}%'"))
|
||||||
|
await conn.execute(text(f"DELETE FROM quotation.quotations WHERE number LIKE '{MARK}%'"))
|
||||||
|
await conn.execute(text(f"DELETE FROM partner.items WHERE code LIKE '{MARK}%'"))
|
||||||
|
await conn.execute(text("DELETE FROM supplier.supplier_users WHERE id = :id"), {"id": TEST_LOGIN_ID})
|
||||||
|
await conn.execute(text("DELETE FROM partner.suppliers WHERE name = :n"), {"n": TEST_SUPPLIER_NAME})
|
||||||
|
|
||||||
|
async with db_engine.begin() as conn:
|
||||||
|
await _cleanup(conn)
|
||||||
|
await conn.execute(
|
||||||
|
text("INSERT INTO partner.suppliers (supplier_id, company_id, user_id, name) VALUES (:sid, gen_random_uuid(), gen_random_uuid(), :name)"),
|
||||||
|
{"sid": supplier_id, "name": TEST_SUPPLIER_NAME},
|
||||||
|
)
|
||||||
|
await conn.execute(
|
||||||
|
text(
|
||||||
|
"INSERT INTO supplier.supplier_users (supplier_id, id, password, name, last_accessed_at, status, role) "
|
||||||
|
"VALUES (:sid, :id, :pw, '채팅담당자', now(), 1, 1)"
|
||||||
|
),
|
||||||
|
{"sid": supplier_id, "id": TEST_LOGIN_ID, "pw": pw_hash},
|
||||||
|
)
|
||||||
|
for code, sess_st, qt_type, hrs, quote_st, sup in specs:
|
||||||
|
item_id, qt_id, session_id = uuid.uuid4(), uuid.uuid4(), uuid.uuid4()
|
||||||
|
sids[code], qids[code] = session_id, qt_id
|
||||||
|
await conn.execute(
|
||||||
|
text(
|
||||||
|
"INSERT INTO partner.items (item_id, company_id, user_id, name, code, price, model_name, manufacturer, moq, spec) "
|
||||||
|
"VALUES (:iid, gen_random_uuid(), gen_random_uuid(), :name, :code, 100000, :model, '테스트제조사', '10', '규격A')"
|
||||||
|
),
|
||||||
|
{"iid": item_id, "name": f"상품 {code}", "code": f"{MARK}{code}", "model": f"MODEL-{code}"},
|
||||||
|
)
|
||||||
|
await conn.execute(
|
||||||
|
text(
|
||||||
|
"INSERT INTO quotation.quotations (qt_id, user_id, qt_setting_id, version_id, name, number, type, status, start_time, end_time, memo) "
|
||||||
|
"VALUES (:qid, gen_random_uuid(), gen_random_uuid(), gen_random_uuid(), :name, :num, :tp, :st, now(), now() + make_interval(hours => :hrs), '메모')"
|
||||||
|
),
|
||||||
|
{"qid": qt_id, "name": f"견적 {code}", "num": f"{MARK}{code}", "tp": qt_type, "st": quote_st, "hrs": hrs},
|
||||||
|
)
|
||||||
|
await conn.execute(
|
||||||
|
text(
|
||||||
|
"INSERT INTO negotiation.sessions "
|
||||||
|
"(session_id, quotation_id, item_id, supplier_id, qt_number, qt_round, qt_type, target_price, status, end_time) "
|
||||||
|
"VALUES (:sesid, :qid, :iid, :sup, :qtn, 1, :qtt, 100000, :st, now() + make_interval(hours => 2))"
|
||||||
|
),
|
||||||
|
{"sesid": session_id, "qid": qt_id, "iid": item_id, "sup": sup, "qtn": f"{MARK}{code}", "qtt": qt_type, "st": sess_st},
|
||||||
|
)
|
||||||
|
|
||||||
|
yield {"supplier_id": supplier_id, "sids": sids, "qids": qids}
|
||||||
|
|
||||||
|
async with db_engine.begin() as conn:
|
||||||
|
await _cleanup(conn)
|
||||||
|
|
||||||
|
|
||||||
|
async def _login_token(client):
|
||||||
|
r = await client.post("/v1/auth/login", json={"id": TEST_LOGIN_ID, "pw": TEST_PW})
|
||||||
|
return r.json()["access_token"]
|
||||||
|
|
||||||
|
|
||||||
|
def _h(token):
|
||||||
|
return {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
|
|
||||||
|
async def _init(client, token, sid):
|
||||||
|
return await client.get(f"/v1/negotiation/sessions/{sid}/chat/init", headers=_h(token))
|
||||||
|
|
||||||
|
|
||||||
|
async def _messages(client, token, sid):
|
||||||
|
return await client.get(f"/v1/negotiation/sessions/{sid}/chat/messages", headers=_h(token))
|
||||||
|
|
||||||
|
|
||||||
|
async def _send(client, token, sid, user_input, user_input_type=None):
|
||||||
|
body = {"user_input": user_input, "user_input_type": user_input_type}
|
||||||
|
return await client.post(f"/v1/negotiation/sessions/{sid}/chat/send", headers=_h(token), json=body)
|
||||||
|
|
||||||
|
|
||||||
|
async def _session_status(db_engine, session_id):
|
||||||
|
async with db_engine.begin() as conn:
|
||||||
|
return (await conn.execute(text("SELECT status FROM negotiation.sessions WHERE session_id = :sid"), {"sid": session_id})).scalar()
|
||||||
|
|
||||||
|
|
||||||
|
async def _session_bid(db_engine, session_id):
|
||||||
|
async with db_engine.begin() as conn:
|
||||||
|
return (await conn.execute(text("SELECT bid_price FROM negotiation.sessions WHERE session_id = :sid"), {"sid": session_id})).scalar()
|
||||||
|
|
||||||
|
|
||||||
|
async def _session_reject(db_engine, session_id):
|
||||||
|
async with db_engine.begin() as conn:
|
||||||
|
r = (await conn.execute(text("SELECT status, reject_reason FROM negotiation.sessions WHERE session_id = :sid"), {"sid": session_id})).first()
|
||||||
|
return r[0], r[1]
|
||||||
|
|
||||||
|
|
||||||
|
# ---- init -------------------------------------------------------------------
|
||||||
|
async def test_chat_init_returns_meta(client, chat_seed):
|
||||||
|
token = await _login_token(client)
|
||||||
|
body = (await _init(client, token, chat_seed["sids"]["P"])).json()
|
||||||
|
assert body["result"]["success"] is True
|
||||||
|
assert body["session_status"] == 2
|
||||||
|
assert body["item_name"] == "상품 P" and body["item_price"] == 100000
|
||||||
|
assert body["item_maker_name"] == "테스트제조사"
|
||||||
|
assert body["quotation_end_time"] # 타이머용 마감 시각
|
||||||
|
|
||||||
|
|
||||||
|
async def test_chat_init_forbidden_other_supplier(client, chat_seed):
|
||||||
|
token = await _login_token(client)
|
||||||
|
body = (await _init(client, token, chat_seed["sids"]["X"])).json()
|
||||||
|
assert body["result"]["code"] == 1300 # NEGO_FORBIDDEN
|
||||||
|
|
||||||
|
|
||||||
|
# ---- messages (오프닝 seed) -------------------------------------------------
|
||||||
|
async def test_messages_seeds_opening(client, chat_seed):
|
||||||
|
token = await _login_token(client)
|
||||||
|
body = (await _messages(client, token, chat_seed["sids"]["P"])).json()
|
||||||
|
assert body["result"]["success"] is True
|
||||||
|
assert len(body["items"]) == 1
|
||||||
|
msg = body["items"][0]
|
||||||
|
assert msg["sender"] == 1 # ChatSender.BOT (봇)
|
||||||
|
assert msg["next_input_mode"] == "confirm"
|
||||||
|
assert msg["script"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---- send (협상 진행 → 종료) ------------------------------------------------
|
||||||
|
async def test_send_flow_to_completion(client, chat_seed, db_engine):
|
||||||
|
token = await _login_token(client)
|
||||||
|
sid = chat_seed["sids"]["P"]
|
||||||
|
|
||||||
|
await _messages(client, token, sid) # 오프닝(턴0) seed
|
||||||
|
|
||||||
|
r1 = (await _send(client, token, sid, "네, 시작할게요")).json()
|
||||||
|
assert r1["result"]["success"] is True
|
||||||
|
assert r1["message"]["next_input_mode"] == "confirm" # 품목안내
|
||||||
|
assert r1["session_status"] == 2
|
||||||
|
|
||||||
|
r2 = (await _send(client, token, sid, "가격 협상 진행")).json()
|
||||||
|
assert r2["message"]["next_input_mode"] == "price" # 가격입력 요청
|
||||||
|
|
||||||
|
r3 = (await _send(client, token, sid, "90000", user_input_type="price")).json()
|
||||||
|
assert r3["result"]["success"] is True
|
||||||
|
assert r3["message"]["chat_end"] is True
|
||||||
|
assert r3["session_status"] == 3 # 협상완료(DONE)
|
||||||
|
assert await _session_status(db_engine, sid) == 3
|
||||||
|
assert await _session_bid(db_engine, sid) == 90000 # 입찰가 확정
|
||||||
|
|
||||||
|
|
||||||
|
async def test_send_price_out_of_range(client, chat_seed):
|
||||||
|
token = await _login_token(client)
|
||||||
|
sid = chat_seed["sids"]["P"]
|
||||||
|
await _messages(client, token, sid)
|
||||||
|
# 목표가 100000 → 허용 [30000, 170000]. 10 은 하한 미만.
|
||||||
|
body = (await _send(client, token, sid, "10", user_input_type="price")).json()
|
||||||
|
assert body["result"]["code"] == 1401 # CHAT_PRICE_OUT_OF_RANGE
|
||||||
|
|
||||||
|
|
||||||
|
async def test_send_not_in_progress(client, chat_seed):
|
||||||
|
token = await _login_token(client)
|
||||||
|
sid = chat_seed["sids"]["C"] # 협상생성(미참여 전 단계)
|
||||||
|
body = (await _send(client, token, sid, "네")).json()
|
||||||
|
assert body["result"]["code"] == 1400 # CHAT_NOT_IN_PROGRESS
|
||||||
|
|
||||||
|
|
||||||
|
async def test_send_requires_auth(client, chat_seed):
|
||||||
|
sid = chat_seed["sids"]["P"]
|
||||||
|
r = await client.post(f"/v1/negotiation/sessions/{sid}/chat/send", json={"user_input": "네"})
|
||||||
|
assert r.status_code in (401, 403)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- 보완: 거부 저장 / 동시전송 가드 / init 만료 정리 ------------------------
|
||||||
|
async def test_send_rejection_persists_reason(client, chat_seed, db_engine):
|
||||||
|
token = await _login_token(client)
|
||||||
|
sid = chat_seed["sids"]["P"]
|
||||||
|
await _messages(client, token, sid) # 오프닝
|
||||||
|
body = (await _send(client, token, sid, "협상 포기합니다")).json()
|
||||||
|
assert body["result"]["success"] is True
|
||||||
|
assert body["message"]["chat_end"] is True
|
||||||
|
assert body["session_status"] == 5 # 협상거부(REJECTED)
|
||||||
|
status, reason = await _session_reject(db_engine, sid)
|
||||||
|
assert status == 5 and reason == "협상 포기합니다" # 거부 사유 저장
|
||||||
|
|
||||||
|
|
||||||
|
async def test_send_blocked_when_prev_turn_pending(client, chat_seed, db_engine):
|
||||||
|
"""직전 메시지가 USER(이전 턴 처리 중)면 중복 전송을 거절한다 → CHAT_IN_PROGRESS."""
|
||||||
|
token = await _login_token(client)
|
||||||
|
sid = chat_seed["sids"]["P"]
|
||||||
|
await _messages(client, token, sid) # 오프닝(seq=1, BOT)
|
||||||
|
# 봇 응답이 아직 안 온 상태를 모사: USER 메시지를 마지막(seq=2)으로 직접 삽입
|
||||||
|
async with db_engine.begin() as conn:
|
||||||
|
await conn.execute(
|
||||||
|
text("INSERT INTO negotiation.chats (session_id, seq, sender, target_price) VALUES (:sid, 2, 2, 0)"),
|
||||||
|
{"sid": sid},
|
||||||
|
)
|
||||||
|
body = (await _send(client, token, sid, "네")).json()
|
||||||
|
assert body["result"]["code"] == 1403 # CHAT_IN_PROGRESS
|
||||||
|
|
||||||
|
|
||||||
|
async def test_init_marks_expired_created_as_not_participated(client, chat_seed, db_engine):
|
||||||
|
token = await _login_token(client)
|
||||||
|
sid, qid = chat_seed["sids"]["C"], chat_seed["qids"]["C"] # 협상생성(1)
|
||||||
|
async with db_engine.begin() as conn:
|
||||||
|
await conn.execute(text("UPDATE quotation.quotations SET end_time = now() - make_interval(hours => 1) WHERE qt_id = :qid"), {"qid": qid})
|
||||||
|
body = (await _init(client, token, sid)).json()
|
||||||
|
assert body["result"]["success"] is True
|
||||||
|
assert body["session_status"] == 4 # 미참여로 정리되어 내려옴
|
||||||
|
assert await _session_status(db_engine, sid) == 4 # DB 도 전이됨
|
||||||
@ -310,6 +310,7 @@ CREATE TABLE IF NOT EXISTS negotiation.chats (
|
|||||||
card_used_yn BOOLEAN NULL, -- 카드 사용 여부
|
card_used_yn BOOLEAN NULL, -- 카드 사용 여부
|
||||||
indicator_value NUMERIC(8,6) NULL, -- 소수점 까지 반환할 수도 있음 (정수부 2자리 + 소수 6자리, -99.999999~99.999999)
|
indicator_value NUMERIC(8,6) NULL, -- 소수점 까지 반환할 수도 있음 (정수부 2자리 + 소수 6자리, -99.999999~99.999999)
|
||||||
card_type SMALLINT NULL, -- 카드 유형: 1=nego_card, 2=wild_card
|
card_type SMALLINT NULL, -- 카드 유형: 1=nego_card, 2=wild_card
|
||||||
|
meta JSONB NULL, -- 말풍선 표현 데이터(script/step/client_step/input_mode/input_options/chat_end). 구조화 컬럼(price/card/indicator) 외 가변 UI 필드만 보관.
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 생성 시각(UTC)
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 생성 시각(UTC)
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 수정 시각(UTC, 앱에서 갱신)
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 수정 시각(UTC, 앱에서 갱신)
|
||||||
deleted BOOLEAN NOT NULL DEFAULT FALSE -- 소프트 삭제 여부
|
deleted BOOLEAN NOT NULL DEFAULT FALSE -- 소프트 삭제 여부
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user