모든 프로젝트 1차 병합 .
This commit is contained in:
parent
b65de5a5f2
commit
c3d96fb7d3
@ -16,6 +16,12 @@ MAX_ROUNDS = 3
|
||||
_PRICE_MODES = ("price",)
|
||||
_CHOICE_MODES = ("yes_no", "confirm", "delivery_type")
|
||||
|
||||
# 최종 타결/결렬 스텝. 재협상=협상완료(우선협상 타결), 재견적=결과제출(투찰확정). 둘 다 협상실패=결렬.
|
||||
# 이 스텝들은 chat_end=False(뒤에 협상종료가 옴)라, outcome 을 컨텍스트에 적재했다가
|
||||
# 실제 종료(chat_end=협상종료) 시점에 확정 보고한다 → backend 가 chat_end 에서 DONE/REJECTED 를 옳게 가른다.
|
||||
_SUCCESS_STEPS = ("협상완료", "결과제출")
|
||||
_FAILURE_STEPS = ("협상실패",)
|
||||
|
||||
# 프론트는 표시용 문자열로 가격을 보낸다(예: "530,000원"). 천단위 콤마·통화기호("원")·공백 등
|
||||
# 숫자 외 문자를 제거하고 파싱한다. (콤마만 지우면 "원" 때문에 float() 가 실패해 가격 입력이
|
||||
# 영영 저장되지 않고 같은 step 에 머무는 버그가 났었다.)
|
||||
@ -177,8 +183,16 @@ class ChatEngine:
|
||||
return self._error(session, f"다음 단계를 찾을 수 없습니다: {step_key}")
|
||||
node = self.scripts[step_key]
|
||||
session.step = step_key
|
||||
session.ended = bool(node.get("chat_end"))
|
||||
outcome = "success" if step_key == "협상완료" else "failure" if step_key == "협상실패" else None
|
||||
chat_end = bool(node.get("chat_end"))
|
||||
session.ended = chat_end
|
||||
# 최종 성공/실패를 통과 시점에 기록하고, 실제 종료(chat_end) 시점에만 outcome 으로 확정 보고.
|
||||
# (중간 성공/실패 스텝에서 보고하면 backend 종료확정 타이밍(chat_end)과 어긋나고,
|
||||
# ChatService 종료학습도 두 번 도는 문제가 생긴다.)
|
||||
if step_key in _SUCCESS_STEPS:
|
||||
session.context["final_outcome"] = "success"
|
||||
elif step_key in _FAILURE_STEPS:
|
||||
session.context["final_outcome"] = "failure"
|
||||
outcome = session.context.get("final_outcome") if chat_end else None
|
||||
return StepView(
|
||||
step=step_key,
|
||||
script=self.repo.format_script(node.get("script", ""), self._vars(session)),
|
||||
|
||||
@ -11,6 +11,31 @@ from typing import Optional
|
||||
from common.models.gmodel import Res_WebPacketProtocol, WebPacketProtocol
|
||||
|
||||
|
||||
# 협상 결과 요약 카드(summaryRSP/summaryCM)에 표시할 데이터. 종료 스텝에서만 채워 내려간다.
|
||||
# 필드명은 프론트 ChatSummary 타입과 1:1 (item_isVAT 등 camelCase 유지).
|
||||
class ChatSummary(WebPacketProtocol):
|
||||
md_name: str = ""
|
||||
md_email: str = ""
|
||||
md_phone_number: str = ""
|
||||
item_code: str = ""
|
||||
item_name: str = ""
|
||||
item_spec: str = ""
|
||||
item_moq: str = ""
|
||||
item_model: str = ""
|
||||
item_maker: str = ""
|
||||
item_isVAT: bool = False
|
||||
item_lead_time: str = ""
|
||||
item_display_date: str = ""
|
||||
item_delivery_type: str = ""
|
||||
final_price: int = 0
|
||||
nego_start_date: str = ""
|
||||
nego_end_date: str = ""
|
||||
supplier_name: str = ""
|
||||
supplier_manager_name: str = ""
|
||||
supplier_manager_email: str = ""
|
||||
delivery_type: Optional[str] = None
|
||||
|
||||
|
||||
# 말풍선 한 건. sender 는 ChatSender 정수 코드(1=BOT, 2=USER)로 내려가고 라벨 매핑은 프론트가 한다.
|
||||
class ChatMessage(WebPacketProtocol):
|
||||
chat_id: str = ""
|
||||
@ -25,7 +50,8 @@ class ChatMessage(WebPacketProtocol):
|
||||
next_input_type: Optional[list[str]] = None # 다음 입력 선택지
|
||||
chat_end: bool = False
|
||||
indicator_value: Optional[float] = None # (범위 외 예약) 협상 지표
|
||||
bot_chat_type: Optional[str] = None # (범위 외 예약) indicator|summary 등
|
||||
bot_chat_type: Optional[str] = None # summaryRSP|summaryCM|rejectRSP|rejectCM|indicator
|
||||
summary: Optional[ChatSummary] = None # summaryRSP/summaryCM 일 때만 채워짐
|
||||
|
||||
|
||||
# 채팅 진입 — 상품/견적 메타 + 현재 세션 상태 + 마감 시각(타이머용)
|
||||
|
||||
@ -15,16 +15,35 @@ from typing import Optional
|
||||
|
||||
from fastapi import Depends
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from common.database.db_session_manager import DB_SESSION_MNG
|
||||
from common.database.model.models import chats, items, quotations, sessions
|
||||
from common.database.model.models import chats, items, quotations, sessions, supplier_users, suppliers
|
||||
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 router.v1.negotiation.chat_protocol import ChatMessage, ChatSummary, Res_ChatInit, Res_ChatMessages, Res_ChatSend
|
||||
from services.agent_client import AgentChatContext, IAgentClient, get_agent_client
|
||||
from services.auth_service import AuthService
|
||||
|
||||
# 종료 스텝 → 프론트 폼 종류(bot_chat_type).
|
||||
# 폼 접미사: RSP=재협상(renegotiation), CM=재견적(requote). qt_type 1=재협상, 2=재견적.
|
||||
# 협상완료/결과안내 → 요약카드, 협상실패 → 합의불가(거부) 폼.
|
||||
_SUMMARY_STEPS = {"협상완료", "결과안내", "결과제출"}
|
||||
_REJECT_STEPS = {"협상실패"}
|
||||
|
||||
|
||||
def _resolve_bot_chat_type(qt_type: Optional[int], step: Optional[str]) -> Optional[str]:
|
||||
if not step:
|
||||
return None
|
||||
is_reneg = qt_type == 1 # 1=재협상(RSP), 그 외(2)=재견적(CM)
|
||||
if step in _SUMMARY_STEPS:
|
||||
return "summaryRSP" if is_reneg else "summaryCM"
|
||||
if step in _REJECT_STEPS:
|
||||
return "rejectRSP" if is_reneg else "rejectCM"
|
||||
return None
|
||||
|
||||
|
||||
# 가격 허용 범위 배수(목표가 기준). 범위를 벗어난 제시가는 CHAT_PRICE_OUT_OF_RANGE 로 막는다.
|
||||
PRICE_FLOOR_RATIO = 0.3
|
||||
@ -240,14 +259,23 @@ class ChatService:
|
||||
res.result.SetResult(ErrorType.CHAT_AGENT_UNAVAILABLE)
|
||||
return res
|
||||
|
||||
# 종료 스텝이면 폼 종류(summary/reject)를 부여하고, 요약카드면 데이터까지 조립한다.
|
||||
bot_chat_type = _resolve_bot_chat_type(sess.qt_type, turn.step)
|
||||
summary = None
|
||||
if bot_chat_type in ("summaryRSP", "summaryCM"):
|
||||
final_price = price if price is not None else (sess.bid_price or sess.target_price)
|
||||
summary = await self._build_summary(sess, quote, final_price)
|
||||
|
||||
# 봇 메시지 + 종료 시 확정(성공=DONE+입찰가 / 실패=REJECTED+거부사유·제시가). 한 트랜잭션.
|
||||
bot_msg = self._build_bot_chat(sess, seq=max_seq + 2, turn=turn)
|
||||
bot_msg = self._build_bot_chat(sess, seq=max_seq + 2, turn=turn, bot_chat_type=bot_chat_type, summary=summary)
|
||||
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
|
||||
# 입찰가 = 이번 턴 가격(보통 None) → 마지막 제시가 → 목표가 순으로 확정.
|
||||
last_price = await self._last_user_price(sess)
|
||||
bid = price if price is not None else (last_price if last_price else sess.target_price)
|
||||
funcs.append(lambda s: self.chat_crud.finalize_session(s, sess.session_id, new_status, bid_price=bid))
|
||||
else:
|
||||
new_status = SessionStatus.REJECTED.value
|
||||
@ -295,7 +323,8 @@ class ChatService:
|
||||
meta={"script": user_input, "user_input_type": user_input_type},
|
||||
)
|
||||
|
||||
def _build_bot_chat(self, sess, seq: int, turn) -> chats:
|
||||
def _build_bot_chat(self, sess, seq: int, turn, bot_chat_type: Optional[str] = None, summary: Optional[dict] = None) -> chats:
|
||||
# bot_chat_type/summary 도 meta 에 영속화 → 히스토리 복원(messages)에서도 폼이 재현된다.
|
||||
return chats(
|
||||
chat_id=uuid.uuid4(), session_id=sess.session_id, seq=seq,
|
||||
sender=ChatSender.BOT.value,
|
||||
@ -304,12 +333,14 @@ class ChatService:
|
||||
"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,
|
||||
"bot_chat_type": bot_chat_type, "summary": summary,
|
||||
},
|
||||
)
|
||||
|
||||
def _chat_to_message(self, c: chats) -> ChatMessage:
|
||||
"""방금 만든 chats 객체 → 응답 ChatMessage (DB 재조회 없이)."""
|
||||
meta = c.meta or {}
|
||||
summary_d = meta.get("summary")
|
||||
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 "",
|
||||
@ -319,8 +350,86 @@ class ChatService:
|
||||
next_input_mode=meta.get("input_mode"),
|
||||
next_input_type=meta.get("input_options"),
|
||||
chat_end=bool(meta.get("chat_end", False)),
|
||||
bot_chat_type=meta.get("bot_chat_type"),
|
||||
summary=ChatSummary(**summary_d) if summary_d else None,
|
||||
)
|
||||
|
||||
async def _last_user_price(self, sess) -> Optional[int]:
|
||||
"""세션에서 가장 최근 유저 제시가(negotiation.chats.target_price>0). 없으면 None."""
|
||||
def _q(s):
|
||||
stmt = (
|
||||
select(chats.target_price)
|
||||
.where(chats.session_id == sess.session_id, chats.sender == ChatSender.USER.value,
|
||||
chats.target_price > 0, chats.deleted == False) # noqa: E712
|
||||
.order_by(chats.seq.desc()).limit(1)
|
||||
)
|
||||
return DB_SESSION_MNG.execute(s, stmt)
|
||||
|
||||
err_type, rows = await DB_SESSION_MNG.execute_lambda(chats.DBType(), DBWRType.DB_READ.value, _q)
|
||||
return int(rows[0]) if err_type == ErrorType.SUCCESS and rows and rows[0] else None
|
||||
|
||||
async def _build_summary(self, sess, quote, final_price: Optional[int]) -> dict:
|
||||
"""협상 결과 요약 카드 데이터 조립(item + 견적 담당 MD + 공급사/담당자 + 최종 제시가).
|
||||
종료 스텝에서 1회만 호출."""
|
||||
err_type, item = await DB_SESSION_MNG.execute_lambda(
|
||||
items.DBType(), DBWRType.DB_READ.value,
|
||||
lambda s: self.chat_crud.get_item_by_id(s, sess.item_id),
|
||||
)
|
||||
item = item if err_type == ErrorType.SUCCESS else None
|
||||
|
||||
def _supplier_name(s):
|
||||
stmt = select(suppliers.name).where(suppliers.supplier_id == sess.supplier_id).limit(1)
|
||||
return DB_SESSION_MNG.execute(s, stmt)
|
||||
|
||||
err_type, rows = await DB_SESSION_MNG.execute_lambda(suppliers.DBType(), DBWRType.DB_READ.value, _supplier_name)
|
||||
supplier_name = rows[0] if err_type == ErrorType.SUCCESS and rows else ""
|
||||
|
||||
# 공급사 담당자(로그인 계정) 이름/이메일
|
||||
def _supplier_user(s):
|
||||
stmt = (
|
||||
select(supplier_users.name, supplier_users.email)
|
||||
.where(supplier_users.supplier_id == sess.supplier_id, supplier_users.deleted == False) # noqa: E712
|
||||
.limit(1)
|
||||
)
|
||||
return DB_SESSION_MNG.execute(s, stmt)
|
||||
|
||||
err_type, su_rows = await DB_SESSION_MNG.execute_lambda(supplier_users.DBType(), DBWRType.DB_READ.value, _supplier_user)
|
||||
sup_mgr_name, sup_mgr_email = (su_rows[0][0], su_rows[0][1]) if err_type == ErrorType.SUCCESS and su_rows else ("", "")
|
||||
|
||||
# 최종 제시가: 가장 최근 유저 제시가(없으면 입찰가/목표가 폴백)
|
||||
last_price = await self._last_user_price(sess)
|
||||
resolved_price = int(last_price if last_price else (final_price or 0))
|
||||
|
||||
def _iso(dt):
|
||||
if dt is None:
|
||||
return ""
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
return dt.isoformat(timespec="seconds")
|
||||
|
||||
return ChatSummary(
|
||||
md_name=quote.manager_name or "",
|
||||
md_email=quote.manager_email or "",
|
||||
md_phone_number=quote.manager_contact_number or "",
|
||||
item_code=(item.code or "") if item else "",
|
||||
item_name=(item.name or "") if item else "",
|
||||
item_spec=(item.spec or "") if item else "",
|
||||
item_moq=(item.moq or "") if item else "",
|
||||
item_model=(item.model_name or "") if item else "",
|
||||
item_maker=(item.manufacturer or "") if item else "",
|
||||
item_isVAT=bool(item.vat_yn) if item and item.vat_yn is not None else False,
|
||||
item_lead_time=(str(item.lead_time) if item and item.lead_time is not None else ""),
|
||||
item_display_date=_iso(quote.start_time),
|
||||
item_delivery_type="",
|
||||
final_price=resolved_price,
|
||||
nego_start_date=_iso(quote.start_time),
|
||||
nego_end_date=_iso(quote.end_time),
|
||||
supplier_name=supplier_name or "",
|
||||
supplier_manager_name=sup_mgr_name or "",
|
||||
supplier_manager_email=sup_mgr_email or "",
|
||||
delivery_type=None,
|
||||
).model_dump()
|
||||
|
||||
def _row_to_message(self, r) -> ChatMessage:
|
||||
"""DB 행(chats) → 응답 ChatMessage."""
|
||||
return self._chat_to_message(r)
|
||||
|
||||
@ -4,6 +4,7 @@ import type { ApiResult } from '@/apis/types'
|
||||
import type {
|
||||
ChatInitData,
|
||||
ChatMessage,
|
||||
ChatSummary,
|
||||
NextInputMode,
|
||||
UserInputType,
|
||||
} from '@/features/chat/types'
|
||||
@ -23,6 +24,7 @@ export interface ChatMessageWire {
|
||||
chat_end?: boolean
|
||||
indicator_value?: number | null
|
||||
bot_chat_type?: string | null
|
||||
summary?: ChatSummary | null
|
||||
}
|
||||
|
||||
export interface ChatInitResponse {
|
||||
@ -75,7 +77,7 @@ export function mapMessage(w: ChatMessageWire): ChatMessage {
|
||||
next_input_type: w.next_input_type ?? null,
|
||||
step: w.step ?? '',
|
||||
display_step: w.display_step ?? '',
|
||||
summary: null, // (범위 외) 최종 요약 카드는 추후
|
||||
summary: w.summary ?? null, // 종료 스텝(협상완료/결과안내)에서 백엔드가 채워 내려줌
|
||||
indicator_value: w.indicator_value ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user