450 lines
22 KiB
Python
450 lines
22 KiB
Python
"""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 sqlalchemy import select
|
|
|
|
from common.database.db_session_manager import DB_SESSION_MNG
|
|
from common.database.model.models import chats, items, quotations, sessions, supplier_users, suppliers
|
|
from common.enums import ChatSender, DBWRType, ErrorType, QuotationStatus, SessionStatus
|
|
from common.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, 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
|
|
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 = await 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 = await 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
|
|
|
|
# 종료 스텝이면 폼 종류(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_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
|
|
# 입찰가 = 이번 턴 가격(보통 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
|
|
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
|
|
|
|
# ---- 빌더 / 매퍼 ----------------------------------------------------
|
|
async def _agent_context(self, sess, turn: int) -> AgentChatContext:
|
|
# 핸드오프 #2/#5: X-Tenant-ID 는 견적(갑) 회사 company_id 여야 한다.
|
|
# 상품(partner.items)의 소유 회사가 갑(buyer)이므로 item.company_id 로 해석한다.
|
|
tenant_id = "" # 해석 실패 시 빈 값(agent 가 400) — 로깅으로 추적
|
|
err_type, item = await DB_SESSION_MNG.execute_lambda(
|
|
items.DBType(), DBWRType.DB_READ.value,
|
|
lambda s: self.chat_crud.get_item_by_id(s, sess.item_id),
|
|
)
|
|
if err_type == ErrorType.SUCCESS and item is not None and item.company_id:
|
|
tenant_id = str(item.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, 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,
|
|
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,
|
|
"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 "",
|
|
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)),
|
|
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)
|
|
|
|
|
|
# ---- 가격 유틸 ----------------------------------------------------------
|
|
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)
|