From cb3adf6d2e7a800047451f1832ac3610b14a4e59 Mon Sep 17 00:00:00 2001 From: hbyang Date: Thu, 18 Jun 2026 18:59:06 +0900 Subject: [PATCH] =?UTF-8?q?=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95?= =?UTF-8?q?=EC=A4=91=20.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- agent/negotiation/chat/service/chat_engine.py | 22 ++++++++++++++++--- backend/services/chat_service.py | 16 +++++++++----- docker-compose.yml | 1 + .../features/chat/hooks/useChatController.ts | 9 +++++--- .../backend/common/database/model/models.py | 4 ++++ 5 files changed, 41 insertions(+), 11 deletions(-) diff --git a/agent/negotiation/chat/service/chat_engine.py b/agent/negotiation/chat/service/chat_engine.py index 7c36961..e4a3cdf 100644 --- a/agent/negotiation/chat/service/chat_engine.py +++ b/agent/negotiation/chat/service/chat_engine.py @@ -6,6 +6,7 @@ Chat_server 의 step 체계(서비스안내→담당자확인→협상품목안 DB/정책(카드선택·학습)은 여기 두지 않는다 — ChatService(async)가 StepView 의 신호를 보고 처리한다. """ +import re from dataclasses import dataclass, field from typing import Any, Dict, List, Optional @@ -15,6 +16,22 @@ MAX_ROUNDS = 3 _PRICE_MODES = ("price",) _CHOICE_MODES = ("yes_no", "confirm", "delivery_type") +# 프론트는 표시용 문자열로 가격을 보낸다(예: "530,000원"). 천단위 콤마·통화기호("원")·공백 등 +# 숫자 외 문자를 제거하고 파싱한다. (콤마만 지우면 "원" 때문에 float() 가 실패해 가격 입력이 +# 영영 저장되지 않고 같은 step 에 머무는 버그가 났었다.) +_PRICE_CLEAN_RE = re.compile(r"[^\d.]") + + +def _parse_price(user_input: Any) -> Optional[float]: + cleaned = _PRICE_CLEAN_RE.sub("", str(user_input if user_input is not None else "")) + if not cleaned or cleaned == ".": + return None + try: + price = float(cleaned) + except ValueError: + return None + return price if price > 0 else None + @dataclass class ChatSession: @@ -62,9 +79,8 @@ class ChatEngine: mode = node.get("next_input_mode", "null") if mode in _PRICE_MODES: - try: - price = float(str(user_input).replace(",", "")) - except (TypeError, ValueError): + price = _parse_price(user_input) + if price is None: return self._error(session, "가격을 숫자로 입력해 주세요.") session.context["input_price"] = price session.context["round"] = session.context.get("round", 0) + 1 diff --git a/backend/services/chat_service.py b/backend/services/chat_service.py index 5027447..605126b 100644 --- a/backend/services/chat_service.py +++ b/backend/services/chat_service.py @@ -147,7 +147,7 @@ class ChatService: async def _seed_opening(self, sess) -> Optional[ChatMessage]: """오프닝(턴0) 봇 메시지를 agent 로 생성하고 seq=1 로 저장한다. 동시 진입 충돌은 무시(유니크가 방어).""" - ctx = self._agent_context(sess, turn=0) + 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 @@ -231,7 +231,7 @@ class ChatService: return res # agent 위임 (한 턴). 실패 시 선점한 유저 메시지를 롤백 → 재시도 가능. - ctx = self._agent_context(sess, turn=turn_no) + 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( @@ -270,10 +270,16 @@ class ChatService: return res # ---- 빌더 / 매퍼 ---------------------------------------------------- - def _agent_context(self, sess, turn: int) -> AgentChatContext: + async 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 로 채운다. + # 상품(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( diff --git a/docker-compose.yml b/docker-compose.yml index c821657..8126521 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -21,6 +21,7 @@ services: environment: APP_ENV: local DB_HOST: host.docker.internal # 컨테이너→호스트 DB (config.local.toml의 127.0.0.1 override) + AGENT_BASE_URL: http://host.docker.internal:9500 # 컨테이너→호스트 agent (로컬 uvicorn :9500) ports: - "9300:9300" # 컨테이너에서 호스트의 DB 로 접근 (config.docker.toml 의 host.docker.internal) diff --git a/frontend/src/features/chat/hooks/useChatController.ts b/frontend/src/features/chat/hooks/useChatController.ts index ae9f320..05c31f3 100644 --- a/frontend/src/features/chat/hooks/useChatController.ts +++ b/frontend/src/features/chat/hooks/useChatController.ts @@ -45,7 +45,10 @@ export function useChatController(sessionId: string) { const setInitData = useChatInitStore((s) => s.setInitData) const initQuery = useChatInitQuery(sessionId) const messagesQuery = useChatMessagesQuery(sessionId) - const sendMutation = useChatSendMutation(sessionId) + // mutate 는 참조가 안정적이다. useMutation 반환 객체 전체를 아래 effect deps 에 넣으면 + // mutate 호출→pending 리렌더마다 객체 identity 가 바뀌어 effect 가 teardown(reset())→재바인딩되고, + // staleTime:Infinity 인 메시지 쿼리는 재조회되지 않아 스토어가 빈 채로 남아 채팅이 사라진다. + const { mutate: sendMutate } = useChatSendMutation(sessionId) // init 메타 → 스토어 useEffect(() => { @@ -68,7 +71,7 @@ export function useChatController(sessionId: string) { store.setIsLoading(true) store.setPriceErrorMessage('') - sendMutation.mutate( + sendMutate( { user_input: text, user_input_type: inputType === 'text' ? null : inputType }, { onSuccess: (data) => { @@ -104,7 +107,7 @@ export function useChatController(sessionId: string) { return () => { useChatStore.getState().reset() } - }, [sessionId, sendMutation, navigate]) + }, [sessionId, sendMutate, navigate]) return { isInitLoading: initQuery.isLoading || messagesQuery.isLoading, diff --git a/negodata/backend/common/database/model/models.py b/negodata/backend/common/database/model/models.py index 4f4c5b1..c3c9e31 100644 --- a/negodata/backend/common/database/model/models.py +++ b/negodata/backend/common/database/model/models.py @@ -140,6 +140,7 @@ class wild_cards(MainTableMixin, MAIN_BASE): class versions(MainTableMixin, MAIN_BASE): __tablename__ = "versions" + __table_args__ = {"schema": "card"} version_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) user_id = Column(UUID(as_uuid=True), nullable=False, index=True) # 버전 생성 유저 @@ -149,6 +150,7 @@ class versions(MainTableMixin, MAIN_BASE): class version_nego_cards(MainTableMixin, MAIN_BASE): __tablename__ = "version_nego_cards" + __table_args__ = {"schema": "card"} vnc_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) version_id = Column(UUID(as_uuid=True), nullable=False, index=True) # versions.version_id @@ -157,6 +159,7 @@ class version_nego_cards(MainTableMixin, MAIN_BASE): class version_wild_cards(MainTableMixin, MAIN_BASE): __tablename__ = "version_wild_cards" + __table_args__ = {"schema": "card"} vwc_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) version_id = Column(UUID(as_uuid=True), nullable=False, index=True) # versions.version_id @@ -240,3 +243,4 @@ class chats(MainTableMixin, MAIN_BASE): 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)