diff --git a/AGENT_INTEGRATION.md b/AGENT_INTEGRATION.md new file mode 100644 index 0000000..8317b9f --- /dev/null +++ b/AGENT_INTEGRATION.md @@ -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": "", + "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` diff --git a/backend/common/database/model/models.py b/backend/common/database/model/models.py index 578cce7..ae6049f 100644 --- a/backend/common/database/model/models.py +++ b/backend/common/database/model/models.py @@ -1,5 +1,5 @@ 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.sql import text @@ -155,6 +155,30 @@ class quotations(MAIN_BASE): 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): # 유저 인증 토큰. supplier_users 1 : N tokens. @staticmethod diff --git a/backend/common/enums.py b/backend/common/enums.py index 7559b6d..9ee216f 100644 --- a/backend/common/enums.py +++ b/backend/common/enums.py @@ -44,6 +44,12 @@ class ErrorType(Enum): NEGO_DEADLINE_PASSED = auto() # 1303 견적 마감 시간 초과 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 한다. 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 # 견적생성 IN_PROGRESS = 2 # 견적진행중 CLOSED = 3 # 견적마감 + + +class ChatSender(Enum): + """채팅 발신자 코드. negotiation.chats.sender. + BOT 은 갑(바이어/agent)이 제시하는 협상 메시지, USER 는 공급사(접속 유저)의 입력이다. + """ + + BOT = 1 # 갑(바이어/agent) — bot 메시지 + USER = 2 # 공급사(을) — user 입력 diff --git a/backend/config/config_models.py b/backend/config/config_models.py index c338b82..ea585b3 100644 --- a/backend/config/config_models.py +++ b/backend/config/config_models.py @@ -43,3 +43,10 @@ class JwtToken(ConfigModel): refresh_key: str = "" access_expire_min: int = 30 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 개발 중 통합 테스트용) diff --git a/backend/config/server_configs.py b/backend/config/server_configs.py index 73201f3..b8aef41 100644 --- a/backend/config/server_configs.py +++ b/backend/config/server_configs.py @@ -1,7 +1,7 @@ import os 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 로 변경. 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) main_db_config: MainDBConfig = configs.get(MainDBConfig) 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 그대로. diff --git a/backend/crud/chat_crud.py b/backend/crud/chat_crud.py new file mode 100644 index 0000000..10a47d2 --- /dev/null +++ b/backend/crud/chat_crud.py @@ -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 diff --git a/backend/requirements.txt b/backend/requirements.txt index 332cd3b..9f96866 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -7,3 +7,4 @@ python-jose[cryptography] bcrypt orjson pydantic>=2.0 +httpx # agent(협상 에이전트) 호출용 async HTTP 클라이언트 diff --git a/backend/router/router.py b/backend/router/router.py index 7a8f7da..fd1e71c 100644 --- a/backend/router/router.py +++ b/backend/router/router.py @@ -11,6 +11,7 @@ from common.utils.gtime import GTime from config.server_configs import web_server_config import router.v1.auth.account import router.v1.negotiation.session +import router.v1.negotiation.chat API_SERVER_START_TIME = GTime.UTCStr() @@ -57,3 +58,4 @@ async def healthz(): # 각 도메인 라우터를 등록한다. 새 기능 추가 시 router.v1.. 를 import 후 include. app.include_router(router.v1.auth.account.router) app.include_router(router.v1.negotiation.session.router) +app.include_router(router.v1.negotiation.chat.router) diff --git a/backend/router/v1/negotiation/chat.py b/backend/router/v1/negotiation/chat.py new file mode 100644 index 0000000..bf632d3 --- /dev/null +++ b/backend/router/v1/negotiation/chat.py @@ -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) + ) diff --git a/backend/router/v1/negotiation/chat_protocol.py b/backend/router/v1/negotiation/chat_protocol.py new file mode 100644 index 0000000..e143a7c --- /dev/null +++ b/backend/router/v1/negotiation/chat_protocol.py @@ -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 diff --git a/backend/services/agent_client.py b/backend/services/agent_client.py new file mode 100644 index 0000000..0653e5d --- /dev/null +++ b/backend/services/agent_client.py @@ -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() diff --git a/backend/services/chat_service.py b/backend/services/chat_service.py new file mode 100644 index 0000000..5027447 --- /dev/null +++ b/backend/services/chat_service.py @@ -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) diff --git a/backend/tests/test_chat.py b/backend/tests/test_chat.py new file mode 100644 index 0000000..289fdfa --- /dev/null +++ b/backend/tests/test_chat.py @@ -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 도 전이됨 diff --git a/postgres-init/01-schema.sql b/postgres-init/01-schema.sql index 3fab4a8..ed53a39 100644 --- a/postgres-init/01-schema.sql +++ b/postgres-init/01-schema.sql @@ -310,6 +310,7 @@ CREATE TABLE IF NOT EXISTS negotiation.chats ( card_used_yn BOOLEAN NULL, -- 카드 사용 여부 indicator_value NUMERIC(8,6) NULL, -- 소수점 까지 반환할 수도 있음 (정수부 2자리 + 소수 6자리, -99.999999~99.999999) 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) updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 수정 시각(UTC, 앱에서 갱신) deleted BOOLEAN NOT NULL DEFAULT FALSE -- 소프트 삭제 여부