MickMick102 commited on
Commit
aee383d
·
1 Parent(s): 8e99db4

refractor: store history in mongodb

Browse files
Files changed (6) hide show
  1. .gitignore +1 -1
  2. app.py +40 -168
  3. backend/conversation_store.py +116 -0
  4. backend/main.py +48 -7
  5. backend/models.py +62 -10
  6. backend/tools.py +1 -1
.gitignore CHANGED
@@ -10,4 +10,4 @@ __pycache__/
10
  *.mp3
11
  *.pem
12
  test.py
13
- test.ipynb
 
10
  *.mp3
11
  *.pem
12
  test.py
13
+ test.ipynb
app.py CHANGED
@@ -6,14 +6,13 @@ from dotenv import load_dotenv
6
  import time
7
  import numpy as np
8
  import sys
 
9
  sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
10
  from backend.tts import synthesize_text
11
  from backend.asr import transcribe_audio, transcribe_typhoon
12
  from backend.utils import preprocess_audio, is_valid_turn, preprocess_audio_simplified
13
  from backend.main import stream_chat_response
14
- import json
15
  from pydub import AudioSegment
16
- import ast
17
  from backend.utils import get_device
18
  if get_device() == "cpu":
19
  load_dotenv(override=True)
@@ -25,7 +24,11 @@ sound_samples = np.array(phone_waiting_sound.get_array_of_samples(), dtype=np.in
25
  if phone_waiting_sound.channels > 1:
26
  sound_samples = sound_samples.reshape((-1, phone_waiting_sound.channels)).mean(axis=1)
27
  sound_samples = sound_samples.astype(np.float32) / 32768.0 # Normalize to [-1,
28
- def startup(_):
 
 
 
 
29
  yield (phone_waiting_sound.frame_rate, sound_samples)
30
  STARTUP_MESSAGE = "สวัสดีค่ะ พลอย 1577Homeshopping ยินดีให้บริการค่ะ"
31
  yield from synthesize_text(STARTUP_MESSAGE)
@@ -65,145 +68,16 @@ h1 {
65
  box-shadow: 0 0 15px rgba(0, 0, 0, 0.2);
66
  }
67
  """
68
- def format_history_for_chatbot(history):
69
- """
70
- Formats the conversation history for display in the Gradio Chatbot.
71
- It creates readable strings for tool calls and tool results.
72
- """
73
- formatted_history = []
74
- if not history:
75
- return []
76
- for turn in history:
77
- role = turn.get("role")
78
- content = turn.get("content")
79
- tool_calls = turn.get("tool_calls")
80
-
81
- if role == "user":
82
- formatted_history.append({"role": "user", "content": content})
83
- elif role == "assistant":
84
- if tool_calls:
85
- # Display a user-friendly message for the tool call
86
- id = tool_calls[0]['id']
87
- func_name = tool_calls[0]['function']['name']
88
- func_args = tool_calls[0]['function']['arguments']
89
- display_content = f"<id>{id}</id><func_name>{func_name}</func_name><func_args>{func_args}</func_args>"
90
- # display_content = (
91
- # f"**Calling Tool:**\n"
92
- # f"```json\n"
93
- # f"{{\n"
94
- # f' "name": "{func_name}",\n'
95
- # f' "arguments": {func_args}\n'
96
- # f"}}\n"
97
- # f"```"
98
- # )
99
- formatted_history.append({"role": "assistant", "content": display_content})
100
- else:
101
- # Regular assistant message
102
- formatted_history.append({"role": "assistant", "content": content})
103
- elif role == "tool":
104
- # Display a user-friendly message for the tool result
105
- id = turn.get("tool_call_id")
106
- result_content = json.dumps(json.loads(content), indent=2, ensure_ascii=False)
107
- display_content = f"<id>{id}</id><content>{content}</content>"
108
- # display_content = (
109
- # f"**Tool Result:**\n"
110
- # f"```json\n"
111
- # f"{result_content}\n"
112
- # f"```"
113
- # )
114
- # Represent tool results as if the "assistant" is providing them
115
- formatted_history.append({"role": "assistant", "content": display_content})
116
-
117
- return formatted_history
118
- import re
119
- def revert_to_openai_format(formatted_history):
120
- """
121
- Converts a history list formatted for the Gradio Chatbot UI back into
122
- the standard OpenAI API format. It parses custom string formats for
123
- tool calls and tool results.
124
 
125
- Args:
126
- formatted_history (list): A list of message dictionaries as they appear
127
- in the Gradio Chatbot component.
128
 
129
- Returns:
130
- list: A list of message dictionaries compliant with the OpenAI API format.
131
- """
132
- openai_history = []
133
-
134
- # Pre-compile regex patterns for efficiency
135
- # Pattern to find a tool call message
136
- tool_call_pattern = re.compile(
137
- r"<id>(.*?)</id><func_name>(.*?)</func_name><func_args>(.*?)</func_args>",
138
- re.DOTALL # Use DOTALL in case arguments contain newlines
139
- )
140
- # Pattern to find a tool result message
141
- tool_result_pattern = re.compile(
142
- r"<id>(.*?)</id><content>(.*?)</content>",
143
- re.DOTALL
144
- )
145
-
146
- if not formatted_history:
147
- return []
148
-
149
- for turn in formatted_history:
150
- role = turn.get("role")
151
- content = turn.get("content")
152
- # If content is None, treat it as an empty string for the regex search.
153
- if content is None:
154
- content = ""
155
-
156
- if role == "user":
157
- openai_history.append(turn)
158
- continue
159
-
160
- if role == "assistant":
161
- # Check if this is a formatted tool call
162
- tool_call_match = tool_call_pattern.search(content)
163
- if tool_call_match:
164
- call_id, func_name, func_args_str = tool_call_match.groups()
165
-
166
- # Reconstruct the original tool_calls structure
167
- reverted_turn = {
168
- "role": "assistant",
169
- "content": None,
170
- "tool_calls": [
171
- {
172
- "id": call_id,
173
- "type": "function",
174
- "function": {
175
- "name": func_name,
176
- "arguments": func_args_str
177
- },
178
- }
179
- ],
180
- }
181
- openai_history.append(reverted_turn)
182
- continue
183
-
184
- # Check if this is a formatted tool result (as per your formatter's logic)
185
- tool_result_match = tool_result_pattern.search(content)
186
- if tool_result_match:
187
- tool_call_id, tool_content = tool_result_match.groups()
188
-
189
- # Reconstruct the original tool message
190
- # NOTE: The role must be 'tool' for the API
191
- reverted_turn = {
192
- "role": "tool",
193
- "tool_call_id": tool_call_id,
194
- "content": tool_content.strip() # Remove trailing space
195
- }
196
- openai_history.append(reverted_turn)
197
- continue
198
-
199
- # If no patterns match, it's a regular assistant message
200
- if turn.get("content") is not None:
201
- openai_history.append(turn)
202
-
203
- return openai_history
204
-
205
-
206
- def response(audio: tuple[int, np.ndarray] | None, conversation_history):
207
  """
208
  Handles user audio input, transcribes it, streams LLM text via backend.main,
209
  and synthesizes chunks to audio while updating the conversation history.
@@ -216,13 +90,12 @@ def response(audio: tuple[int, np.ndarray] | None, conversation_history):
216
  # print(f"Initial conver:{conversation_history}")
217
  # print('-----------------------------')
218
 
219
- conversation_history = revert_to_openai_format(conversation_history)
220
- # print(f"After convert:{conversation_history}")
221
  start_time = time.time()
222
- if conversation_history is None:
223
- conversation_history = []
224
-
225
- previous_history = list(conversation_history)
226
 
227
  if not audio or audio[1] is None or not np.any(audio[1]):
228
  print("No audio input detected; skipping response generation.")
@@ -272,17 +145,13 @@ def response(audio: tuple[int, np.ndarray] | None, conversation_history):
272
  print(f"User: {transcription}")
273
  if is_valid_turn(user_turn):
274
  conversation_history.append(user_turn)
275
- yield AdditionalOutputs(format_history_for_chatbot(conversation_history))
276
 
277
  # print("Conversation history:", conversation_history)
278
 
279
  assistant_turn = {"role": "assistant", "content": ""}
280
  conversation_history.append(assistant_turn)
281
 
282
- # print(previous_history)
283
- history_for_stream = [dict(turn) for turn in previous_history if is_valid_turn(turn)]
284
- # print(f"history_for_stream{history_for_stream}")
285
-
286
  text_buffer = ""
287
  full_response = ""
288
  delimiter_count = 0
@@ -294,7 +163,7 @@ def response(audio: tuple[int, np.ndarray] | None, conversation_history):
294
  start_llm_stream = time.time()
295
 
296
  try:
297
- for chunk in stream_chat_response(history_for_stream, transcription):
298
  # print(f"LLM chunk: {text_chunk}")
299
  if isinstance(chunk, str):
300
  text_chunk = chunk
@@ -356,20 +225,9 @@ def response(audio: tuple[int, np.ndarray] | None, conversation_history):
356
  first_chunk_sent = True
357
  text_buffer = ""
358
  delimiter_count = 0
359
- yield AdditionalOutputs(format_history_for_chatbot(conversation_history))
360
 
361
  i += 1
362
- elif isinstance(chunk, dict) and "role" in chunk:
363
- # print(f"Received tool message for history: {chunk}")
364
-
365
- if chunk.get("content") is None:
366
- chunk["content"] = ""
367
- conversation_history.insert(-1, chunk)
368
-
369
- # Update the chatbot UI to reflect the new history structure
370
-
371
- yield AdditionalOutputs(format_history_for_chatbot(conversation_history))
372
-
373
  if text_buffer.strip():
374
  buffer_to_send = text_buffer.strip()
375
  try:
@@ -391,7 +249,7 @@ def response(audio: tuple[int, np.ndarray] | None, conversation_history):
391
  text_buffer = ""
392
  delimiter_count = 0
393
 
394
- yield AdditionalOutputs(format_history_for_chatbot(conversation_history))
395
 
396
  except Exception as e:
397
  print(f"An error occurred during response generation or synthesis: {e}")
@@ -401,7 +259,7 @@ def response(audio: tuple[int, np.ndarray] | None, conversation_history):
401
  except Exception as synth_error:
402
  print(f"Could not synthesize error message: {synth_error}")
403
  assistant_turn["content"] = (assistant_turn.get("content", "") + f" [Error: {e}]").strip()
404
- yield AdditionalOutputs(format_history_for_chatbot(conversation_history))
405
 
406
  total_latency = time.time() - start_time
407
  print(f"Total: {total_latency:.4f}s")
@@ -411,9 +269,15 @@ def response(audio: tuple[int, np.ndarray] | None, conversation_history):
411
 
412
  async def get_credentials():
413
  return await get_cloudflare_turn_credentials_async(hf_token=os.getenv('HF_TOKEN'))
 
 
 
 
 
414
 
415
  with gr.Blocks(css=custom_css, theme=gr.themes.Soft(primary_hue="orange", secondary_hue="orange")) as demo:
416
  gr.HTML("""<h1 style='text-align: center'>1577 Voicebot Demo</h1>""")
 
417
  with gr.Row():
418
  with gr.Column(scale=1, elem_classes=["phone-column"]):
419
  audio = WebRTC(
@@ -447,6 +311,13 @@ with gr.Blocks(css=custom_css, theme=gr.themes.Soft(primary_hue="orange", second
447
  )
448
  gr.DeepLinkButton()
449
 
 
 
 
 
 
 
 
450
  audio.stream(
451
  fn=ReplyOnPause(
452
  response,
@@ -460,11 +331,12 @@ with gr.Blocks(css=custom_css, theme=gr.themes.Soft(primary_hue="orange", second
460
  min_speech_duration_ms=200,
461
  max_speech_duration_s=float("inf"),
462
  min_silence_duration_ms=1200,
 
463
  ),
464
  can_interrupt=False,
465
  startup_fn=startup,
466
  ),
467
- inputs=[audio, conversation_history],
468
  outputs=[audio],
469
  concurrency_limit=1000,
470
  time_limit=8192
@@ -484,4 +356,4 @@ demo.launch(
484
  share=False,
485
  server_name="0.0.0.0",
486
  server_port=int(os.getenv("PORT", 7860)),
487
- )
 
6
  import time
7
  import numpy as np
8
  import sys
9
+ import uuid
10
  sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
11
  from backend.tts import synthesize_text
12
  from backend.asr import transcribe_audio, transcribe_typhoon
13
  from backend.utils import preprocess_audio, is_valid_turn, preprocess_audio_simplified
14
  from backend.main import stream_chat_response
 
15
  from pydub import AudioSegment
 
16
  from backend.utils import get_device
17
  if get_device() == "cpu":
18
  load_dotenv(override=True)
 
24
  if phone_waiting_sound.channels > 1:
25
  sound_samples = sound_samples.reshape((-1, phone_waiting_sound.channels)).mean(axis=1)
26
  sound_samples = sound_samples.astype(np.float32) / 32768.0 # Normalize to [-1,
27
+ def startup(*arg):
28
+ print(arg[0])
29
+ print("_______")
30
+ print(arg[1])
31
+
32
  yield (phone_waiting_sound.frame_rate, sound_samples)
33
  STARTUP_MESSAGE = "สวัสดีค่ะ พลอย 1577Homeshopping ยินดีให้บริการค่ะ"
34
  yield from synthesize_text(STARTUP_MESSAGE)
 
68
  box-shadow: 0 0 15px rgba(0, 0, 0, 0.2);
69
  }
70
  """
71
+ def snapshot_history(history):
72
+ """Return a shallow copy of the current chatbot history."""
73
+ return [dict(turn) for turn in history] if history else []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
74
 
 
 
 
75
 
76
+ def response(
77
+ audio: tuple[int, np.ndarray] | None,
78
+ conversation_history,
79
+ session_id: str | None,
80
+ ):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
81
  """
82
  Handles user audio input, transcribes it, streams LLM text via backend.main,
83
  and synthesizes chunks to audio while updating the conversation history.
 
90
  # print(f"Initial conver:{conversation_history}")
91
  # print('-----------------------------')
92
 
93
+ conversation_history = conversation_history or []
 
94
  start_time = time.time()
95
+ session_identifier = session_id or ""
96
+ if not session_identifier:
97
+ session_identifier = str(uuid.uuid4())
98
+ print(f"[WARN] Missing session_id; generated temporary session {session_identifier}")
99
 
100
  if not audio or audio[1] is None or not np.any(audio[1]):
101
  print("No audio input detected; skipping response generation.")
 
145
  print(f"User: {transcription}")
146
  if is_valid_turn(user_turn):
147
  conversation_history.append(user_turn)
148
+ yield AdditionalOutputs(snapshot_history(conversation_history))
149
 
150
  # print("Conversation history:", conversation_history)
151
 
152
  assistant_turn = {"role": "assistant", "content": ""}
153
  conversation_history.append(assistant_turn)
154
 
 
 
 
 
155
  text_buffer = ""
156
  full_response = ""
157
  delimiter_count = 0
 
163
  start_llm_stream = time.time()
164
 
165
  try:
166
+ for chunk in stream_chat_response(session_identifier, transcription):
167
  # print(f"LLM chunk: {text_chunk}")
168
  if isinstance(chunk, str):
169
  text_chunk = chunk
 
225
  first_chunk_sent = True
226
  text_buffer = ""
227
  delimiter_count = 0
228
+ yield AdditionalOutputs(snapshot_history(conversation_history))
229
 
230
  i += 1
 
 
 
 
 
 
 
 
 
 
 
231
  if text_buffer.strip():
232
  buffer_to_send = text_buffer.strip()
233
  try:
 
249
  text_buffer = ""
250
  delimiter_count = 0
251
 
252
+ yield AdditionalOutputs(snapshot_history(conversation_history))
253
 
254
  except Exception as e:
255
  print(f"An error occurred during response generation or synthesis: {e}")
 
259
  except Exception as synth_error:
260
  print(f"Could not synthesize error message: {synth_error}")
261
  assistant_turn["content"] = (assistant_turn.get("content", "") + f" [Error: {e}]").strip()
262
+ yield AdditionalOutputs(snapshot_history(conversation_history))
263
 
264
  total_latency = time.time() - start_time
265
  print(f"Total: {total_latency:.4f}s")
 
269
 
270
  async def get_credentials():
271
  return await get_cloudflare_turn_credentials_async(hf_token=os.getenv('HF_TOKEN'))
272
+
273
+
274
+ def initialize_session_id():
275
+ """Create a new session identifier for syncing backend history."""
276
+ return str(uuid.uuid4())
277
 
278
  with gr.Blocks(css=custom_css, theme=gr.themes.Soft(primary_hue="orange", secondary_hue="orange")) as demo:
279
  gr.HTML("""<h1 style='text-align: center'>1577 Voicebot Demo</h1>""")
280
+ session_state = gr.State(value=None)
281
  with gr.Row():
282
  with gr.Column(scale=1, elem_classes=["phone-column"]):
283
  audio = WebRTC(
 
311
  )
312
  gr.DeepLinkButton()
313
 
314
+ demo.load(
315
+ fn=initialize_session_id,
316
+ inputs=None,
317
+ outputs=[session_state],
318
+ queue=False,
319
+ )
320
+
321
  audio.stream(
322
  fn=ReplyOnPause(
323
  response,
 
331
  min_speech_duration_ms=200,
332
  max_speech_duration_s=float("inf"),
333
  min_silence_duration_ms=1200,
334
+ speech_pad_ms=300
335
  ),
336
  can_interrupt=False,
337
  startup_fn=startup,
338
  ),
339
+ inputs=[audio, conversation_history, session_state],
340
  outputs=[audio],
341
  concurrency_limit=1000,
342
  time_limit=8192
 
356
  share=False,
357
  server_name="0.0.0.0",
358
  server_port=int(os.getenv("PORT", 7860)),
359
+ )
backend/conversation_store.py ADDED
@@ -0,0 +1,116 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Async helpers for persisting per-session conversation history."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import os
7
+ from datetime import datetime, timezone
8
+ from typing import Any, Dict, List
9
+
10
+ from motor.motor_asyncio import AsyncIOMotorClient
11
+ from pymongo.errors import PyMongoError
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ class ConversationStore:
17
+ """Wrapper around MongoDB for storing chat histories."""
18
+
19
+ def __init__(self) -> None:
20
+ mongo_uri = os.getenv("MONGO_URL")
21
+ if not mongo_uri:
22
+ raise RuntimeError("MONGO_URL is not configured for ConversationStore")
23
+
24
+ db_name = os.getenv("CONVERSATION_DB", "homeshopping")
25
+ collection_name = os.getenv("CONVERSATION_COLLECTION", "conversation_history")
26
+ self._client = AsyncIOMotorClient(mongo_uri)
27
+ self._collection = self._client[db_name][collection_name]
28
+ logger.info(
29
+ "ConversationStore connected to %s.%s",
30
+ db_name,
31
+ collection_name,
32
+ )
33
+
34
+ async def get_history(self, session_id: str) -> List[Dict[str, Any]]:
35
+ """Return a shallow copy of the stored history for the given session."""
36
+ if not session_id:
37
+ return []
38
+
39
+ try:
40
+ doc = await self._collection.find_one(
41
+ {"session_id": session_id},
42
+ {"_id": 0, "history": 1},
43
+ )
44
+ except PyMongoError as exc:
45
+ logger.error("Failed to load conversation history: %s", exc, exc_info=True)
46
+ return []
47
+
48
+ history = doc.get("history", []) if doc else []
49
+ return [dict(message) for message in history]
50
+
51
+ async def append_messages(self, session_id: str, messages: List[Dict[str, Any]]) -> None:
52
+ """Append one or more messages to the stored history."""
53
+ if not session_id or not messages:
54
+ return
55
+
56
+ safe_messages = [self._sanitize_message(message) for message in messages if message]
57
+ if not safe_messages:
58
+ return
59
+
60
+ now = datetime.now(timezone.utc)
61
+ try:
62
+ await self._collection.update_one(
63
+ {"session_id": session_id},
64
+ {
65
+ "$setOnInsert": {"session_id": session_id, "created_at": now},
66
+ "$set": {"updated_at": now},
67
+ "$push": {"history": {"$each": safe_messages}},
68
+ },
69
+ upsert=True,
70
+ )
71
+ except PyMongoError as exc:
72
+ logger.error("Failed to append conversation messages: %s", exc, exc_info=True)
73
+
74
+ async def upsert_session_metadata(
75
+ self,
76
+ session_id: str,
77
+ persona: Dict[str, Any] | None = None,
78
+ user_info: Any | None = None,
79
+ ) -> None:
80
+ """Persist persona/user info for the session without overwriting history."""
81
+ if not session_id:
82
+ return
83
+
84
+ updates: Dict[str, Any] = {}
85
+ if persona is not None:
86
+ updates["persona"] = persona
87
+ if user_info is not None:
88
+ updates["user_info"] = user_info
89
+
90
+ if not updates:
91
+ return
92
+
93
+ now = datetime.now(timezone.utc)
94
+ updates["updated_at"] = now
95
+
96
+ try:
97
+ await self._collection.update_one(
98
+ {"session_id": session_id},
99
+ {
100
+ "$setOnInsert": {"session_id": session_id, "created_at": now},
101
+ "$set": updates,
102
+ },
103
+ upsert=True,
104
+ )
105
+ except PyMongoError as exc:
106
+ logger.error("Failed to update session metadata: %s", exc, exc_info=True)
107
+
108
+ @staticmethod
109
+ def _sanitize_message(message: Dict[str, Any]) -> Dict[str, Any]:
110
+ allowed_keys = {"role", "content", "tool_call_id", "tool_calls"}
111
+ return {key: message.get(key) for key in allowed_keys if key in message}
112
+
113
+
114
+ conversation_store = ConversationStore()
115
+
116
+ __all__ = ["ConversationStore", "conversation_store"]
backend/main.py CHANGED
@@ -7,7 +7,7 @@ import logging
7
  import os
8
  from queue import Queue
9
  from threading import Lock, Thread
10
- from typing import AsyncGenerator, Dict, Iterator, List, Optional
11
 
12
  from dotenv import load_dotenv
13
  from langfuse import Langfuse
@@ -16,6 +16,7 @@ import sys
16
  sys.path.append(os.path.abspath('./backend'))
17
  from models import LLMFinanceAnalyzer
18
  from functions import MongoHybridSearch
 
19
  from utils import get_device
20
  if get_device() == "cpu":
21
  load_dotenv(override=True)
@@ -84,8 +85,36 @@ def _generate_pseudo_conversation(conversation: List[Dict[str, str]]) -> List[Di
84
 
85
 
86
  @observe()
87
- async def _stream_chat_async(history: List[Dict[str, str]], message: str) -> AsyncGenerator[str, None]:
88
- full_conversation = [msg.copy() for msg in history] + [{"role": "user", "content": message}]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
89
  truncated_history = _create_truncated_history(full_conversation, 300)
90
  pseudo_conversation = _generate_pseudo_conversation(truncated_history)
91
 
@@ -116,7 +145,10 @@ async def _stream_chat_async(history: List[Dict[str, str]], message: str) -> Asy
116
  # else:
117
  # limited_conversation = full_conversation
118
  limited_conversation = full_conversation
119
- response_generator = llm_analyzer.generate_normal_response(limited_conversation)
 
 
 
120
 
121
  async for chunk in response_generator:
122
  if chunk:
@@ -124,14 +156,23 @@ async def _stream_chat_async(history: List[Dict[str, str]], message: str) -> Asy
124
  await asyncio.sleep(0.05)
125
  else:
126
  limited_conversation = full_conversation[-9:] if len(full_conversation) > 9 else full_conversation
127
- final_response = await llm_analyzer.generate_non_rag_response(limited_conversation)
 
 
 
128
  if final_response:
129
  yield final_response
130
  else:
131
  yield "ขออภัยค่ะ เกิดข้อผิดพลาดในการประมวลผลคำถามของคุณ"
132
 
133
 
134
- def stream_chat_response(history: List[Dict[str, str]], message: str) -> Iterator[str]:
 
 
 
 
 
 
135
  """Synchronously iterate over streaming LLM chunks."""
136
 
137
  loop = _ensure_stream_loop()
@@ -139,7 +180,7 @@ def stream_chat_response(history: List[Dict[str, str]], message: str) -> Iterato
139
 
140
  async def runner() -> None:
141
  try:
142
- async for chunk in _stream_chat_async(history, message):
143
  output_queue.put_nowait(chunk)
144
  except Exception as exc: # noqa: BLE001
145
  logger.error("Unhandled error in async chat stream: %s", exc, exc_info=True)
 
7
  import os
8
  from queue import Queue
9
  from threading import Lock, Thread
10
+ from typing import Any, AsyncGenerator, Dict, Iterator, List, Optional
11
 
12
  from dotenv import load_dotenv
13
  from langfuse import Langfuse
 
16
  sys.path.append(os.path.abspath('./backend'))
17
  from models import LLMFinanceAnalyzer
18
  from functions import MongoHybridSearch
19
+ from conversation_store import conversation_store
20
  from utils import get_device
21
  if get_device() == "cpu":
22
  load_dotenv(override=True)
 
85
 
86
 
87
  @observe()
88
+ async def _stream_chat_async(
89
+ session_id: str,
90
+ message: str,
91
+ persona: Optional[str] = None,
92
+ persona_state: Optional[Dict[str, Any]] = None,
93
+ user_info: Optional[Any] = None,
94
+ ) -> AsyncGenerator[str, None]:
95
+ try:
96
+ stored_history = await conversation_store.get_history(session_id)
97
+ except Exception as exc: # noqa: BLE001
98
+ logger.error("Failed to load conversation history for %s: %s", session_id, exc, exc_info=True)
99
+ stored_history = []
100
+
101
+ if session_id:
102
+ try:
103
+ await conversation_store.upsert_session_metadata(
104
+ session_id,
105
+ persona=persona_state,
106
+ user_info=user_info,
107
+ )
108
+ except Exception as exc: # noqa: BLE001
109
+ logger.error("Failed to persist session metadata for %s: %s", session_id, exc, exc_info=True)
110
+
111
+ full_conversation = [msg.copy() for msg in stored_history] + [{"role": "user", "content": message}]
112
+ if message and session_id:
113
+ try:
114
+ await conversation_store.append_messages(session_id, [{"role": "user", "content": message}])
115
+ except Exception as exc: # noqa: BLE001
116
+ logger.error("Failed to persist user message for %s: %s", session_id, exc, exc_info=True)
117
+
118
  truncated_history = _create_truncated_history(full_conversation, 300)
119
  pseudo_conversation = _generate_pseudo_conversation(truncated_history)
120
 
 
145
  # else:
146
  # limited_conversation = full_conversation
147
  limited_conversation = full_conversation
148
+ response_generator = llm_analyzer.generate_normal_response(
149
+ limited_conversation,
150
+ session_id=session_id,
151
+ )
152
 
153
  async for chunk in response_generator:
154
  if chunk:
 
156
  await asyncio.sleep(0.05)
157
  else:
158
  limited_conversation = full_conversation[-9:] if len(full_conversation) > 9 else full_conversation
159
+ final_response = await llm_analyzer.generate_non_rag_response(
160
+ limited_conversation,
161
+ session_id=session_id,
162
+ )
163
  if final_response:
164
  yield final_response
165
  else:
166
  yield "ขออภัยค่ะ เกิดข้อผิดพลาดในการประมวลผลคำถามของคุณ"
167
 
168
 
169
+ def stream_chat_response(
170
+ session_id: str,
171
+ message: str,
172
+ persona: Optional[str] = None,
173
+ persona_state: Optional[Dict[str, Any]] = None,
174
+ user_info: Optional[Any] = None,
175
+ ) -> Iterator[str]:
176
  """Synchronously iterate over streaming LLM chunks."""
177
 
178
  loop = _ensure_stream_loop()
 
180
 
181
  async def runner() -> None:
182
  try:
183
+ async for chunk in _stream_chat_async(session_id, message, persona, persona_state, user_info):
184
  output_queue.put_nowait(chunk)
185
  except Exception as exc: # noqa: BLE001
186
  logger.error("Unhandled error in async chat stream: %s", exc, exc_info=True)
backend/models.py CHANGED
@@ -12,6 +12,7 @@ from openai import AsyncOpenAI, RateLimitError, APIError, OpenAI
12
  # from sentence_transformers import SentenceTransformer
13
  from langfuse.decorators import langfuse_context, observe
14
  from tools import TOOL_DEFINITIONS, execute_tool
 
15
 
16
  from systemprompt import (
17
  get_rag_classification_prompt,
@@ -110,6 +111,7 @@ class LLMFinanceAnalyzer:
110
  max_retries: int = 2,
111
  stream: bool = False,
112
  tools: Optional[List[Dict[str, Any]]] = None,
 
113
  ) -> Union[Optional[str], AsyncGenerator[str, None]]:
114
  """Internal helper to call the appropriate LLM client with retries."""
115
  client = self._get_client_for_model(model)
@@ -134,6 +136,18 @@ class LLMFinanceAnalyzer:
134
  token_input = 0
135
  token_output = 0
136
  tokenin = 0
 
 
 
 
 
 
 
 
 
 
 
 
137
 
138
  try:
139
  async for chunk in response_stream:
@@ -155,10 +169,11 @@ class LLMFinanceAnalyzer:
155
 
156
  delta_content = content.replace("•", "\n•").replace("!","")
157
  delta_content = re.sub(r'(?<=[\u0E00-\u0E7F]) +(?=[\u0E00-\u0E7F])', '', delta_content)
158
-
159
  yield delta_content
160
 
161
  if delta.tool_calls:
 
162
  tool_call = delta.tool_calls[0]
163
  full_tool_calls = [
164
  {
@@ -174,8 +189,12 @@ class LLMFinanceAnalyzer:
174
  "content": None,
175
  "tool_calls": full_tool_calls
176
  }
177
- # Yield this message to be added to the main history
178
  yield assistant_tool_call_msg
 
 
 
 
 
179
 
180
  messages_for_next_call = messages + [assistant_tool_call_msg]
181
 
@@ -203,6 +222,11 @@ class LLMFinanceAnalyzer:
203
  }
204
  # Yield this message for the history as well
205
  yield tool_result_msg
 
 
 
 
 
206
  messages_for_next_call.append(tool_result_msg)
207
 
208
  i += 1
@@ -231,9 +255,10 @@ class LLMFinanceAnalyzer:
231
  full_tool_calls = None #set to None to break the loop
232
  delta_content = delta.content.replace("•", "\n•").replace("!","")
233
  delta_content = re.sub(r'(?<=[\u0E00-\u0E7F]) +(?=[\u0E00-\u0E7F])', '', delta_content)
234
-
235
  yield delta_content
236
  if delta.tool_calls:
 
237
  tool_call = delta.tool_calls[0]
238
  full_tool_calls = [
239
  {
@@ -254,7 +279,9 @@ class LLMFinanceAnalyzer:
254
  except Exception as stream_err:
255
  logger.error(f"Error during LLM stream ({model}): {stream_err}", exc_info=True)
256
  yield f"\n[STREAM_ERROR: {stream_err}]\n"
257
- print(f"Total tokens used - Input: {token_input}, Output: {token_output}")
 
 
258
  # response = requests.post("https://1577shop-api.jts.co.th/count_tokens", json={
259
  # "input_token": token_input,
260
  # "output_token": token_output
@@ -266,7 +293,13 @@ class LLMFinanceAnalyzer:
266
  model=model, messages=messages, stream=False
267
  )
268
  content = response.choices[0].message.content
269
- return content.strip() if content else ""
 
 
 
 
 
 
270
  except (RateLimitError, APIError, Exception) as e:
271
  logger.warning(f"Error on attempt {attempt+1} for model {model}: {e}. Retrying...")
272
  attempt += 1
@@ -398,7 +431,11 @@ Do not describe, answer as a list of number of the documents. example [0,2,4] \n
398
  return final_content
399
 
400
  @observe()
401
- async def generate_normal_response(self, conversation: ConversationHistory) -> AsyncGenerator[str, None]:
 
 
 
 
402
  """Generate a RAG response, yielding text chunks."""
403
  try:
404
 
@@ -407,7 +444,12 @@ Do not describe, answer as a list of number of the documents. example [0,2,4] \n
407
  messages = [{"role": "system", "content": system_prompt}] + conversation
408
 
409
  result_generator = await self._call_llm(
410
- model=NORMAL_RAG_MODEL, messages=messages, temperature=0.2, stream=True, tools = TOOL_DEFINITIONS
 
 
 
 
 
411
  )
412
 
413
  if isinstance(result_generator, AsyncGenerator):
@@ -420,13 +462,23 @@ Do not describe, answer as a list of number of the documents. example [0,2,4] \n
420
  yield f"[ERROR: {e}]"
421
 
422
  @observe()
423
- async def generate_non_rag_response(self, conversation: ConversationHistory) -> Optional[str]:
 
 
 
 
424
  """Generate response for non-RAG questions."""
425
  messages = [{"role": "system", "content": get_non_rag_prompt()}] + conversation
426
- result = await self._call_llm(model=NON_RAG_MODEL, messages=messages, temperature=0, stream=False)
 
 
 
 
 
 
427
 
428
  if isinstance(result, str):
429
  return result.replace("!","")
430
 
431
  logger.error("generate_non_rag_response call failed or returned non-string.")
432
- return None
 
12
  # from sentence_transformers import SentenceTransformer
13
  from langfuse.decorators import langfuse_context, observe
14
  from tools import TOOL_DEFINITIONS, execute_tool
15
+ from conversation_store import conversation_store
16
 
17
  from systemprompt import (
18
  get_rag_classification_prompt,
 
111
  max_retries: int = 2,
112
  stream: bool = False,
113
  tools: Optional[List[Dict[str, Any]]] = None,
114
+ session_id: Optional[str] = None,
115
  ) -> Union[Optional[str], AsyncGenerator[str, None]]:
116
  """Internal helper to call the appropriate LLM client with retries."""
117
  client = self._get_client_for_model(model)
 
136
  token_input = 0
137
  token_output = 0
138
  tokenin = 0
139
+ assistant_chunks: List[str] = []
140
+
141
+ async def flush_assistant() -> None:
142
+ nonlocal assistant_chunks
143
+ if session_id and assistant_chunks:
144
+ text = "".join(assistant_chunks).strip()
145
+ if text:
146
+ await conversation_store.append_messages(
147
+ session_id,
148
+ [{"role": "assistant", "content": text}],
149
+ )
150
+ assistant_chunks = []
151
 
152
  try:
153
  async for chunk in response_stream:
 
169
 
170
  delta_content = content.replace("•", "\n•").replace("!","")
171
  delta_content = re.sub(r'(?<=[\u0E00-\u0E7F]) +(?=[\u0E00-\u0E7F])', '', delta_content)
172
+ assistant_chunks.append(delta_content)
173
  yield delta_content
174
 
175
  if delta.tool_calls:
176
+ await flush_assistant()
177
  tool_call = delta.tool_calls[0]
178
  full_tool_calls = [
179
  {
 
189
  "content": None,
190
  "tool_calls": full_tool_calls
191
  }
 
192
  yield assistant_tool_call_msg
193
+ if session_id:
194
+ await conversation_store.append_messages(
195
+ session_id,
196
+ [assistant_tool_call_msg],
197
+ )
198
 
199
  messages_for_next_call = messages + [assistant_tool_call_msg]
200
 
 
222
  }
223
  # Yield this message for the history as well
224
  yield tool_result_msg
225
+ if session_id:
226
+ await conversation_store.append_messages(
227
+ session_id,
228
+ [tool_result_msg],
229
+ )
230
  messages_for_next_call.append(tool_result_msg)
231
 
232
  i += 1
 
255
  full_tool_calls = None #set to None to break the loop
256
  delta_content = delta.content.replace("•", "\n•").replace("!","")
257
  delta_content = re.sub(r'(?<=[\u0E00-\u0E7F]) +(?=[\u0E00-\u0E7F])', '', delta_content)
258
+ assistant_chunks.append(delta_content)
259
  yield delta_content
260
  if delta.tool_calls:
261
+ await flush_assistant()
262
  tool_call = delta.tool_calls[0]
263
  full_tool_calls = [
264
  {
 
279
  except Exception as stream_err:
280
  logger.error(f"Error during LLM stream ({model}): {stream_err}", exc_info=True)
281
  yield f"\n[STREAM_ERROR: {stream_err}]\n"
282
+ finally:
283
+ await flush_assistant()
284
+ print(f"Total tokens used - Input: {token_input}, Output: {token_output}")
285
  # response = requests.post("https://1577shop-api.jts.co.th/count_tokens", json={
286
  # "input_token": token_input,
287
  # "output_token": token_output
 
293
  model=model, messages=messages, stream=False
294
  )
295
  content = response.choices[0].message.content
296
+ text = content.strip() if content else ""
297
+ if session_id and text:
298
+ await conversation_store.append_messages(
299
+ session_id,
300
+ [{"role": "assistant", "content": text}],
301
+ )
302
+ return text
303
  except (RateLimitError, APIError, Exception) as e:
304
  logger.warning(f"Error on attempt {attempt+1} for model {model}: {e}. Retrying...")
305
  attempt += 1
 
431
  return final_content
432
 
433
  @observe()
434
+ async def generate_normal_response(
435
+ self,
436
+ conversation: ConversationHistory,
437
+ session_id: Optional[str] = None,
438
+ ) -> AsyncGenerator[str, None]:
439
  """Generate a RAG response, yielding text chunks."""
440
  try:
441
 
 
444
  messages = [{"role": "system", "content": system_prompt}] + conversation
445
 
446
  result_generator = await self._call_llm(
447
+ model=NORMAL_RAG_MODEL,
448
+ messages=messages,
449
+ temperature=0.2,
450
+ stream=True,
451
+ tools=TOOL_DEFINITIONS,
452
+ session_id=session_id,
453
  )
454
 
455
  if isinstance(result_generator, AsyncGenerator):
 
462
  yield f"[ERROR: {e}]"
463
 
464
  @observe()
465
+ async def generate_non_rag_response(
466
+ self,
467
+ conversation: ConversationHistory,
468
+ session_id: Optional[str] = None,
469
+ ) -> Optional[str]:
470
  """Generate response for non-RAG questions."""
471
  messages = [{"role": "system", "content": get_non_rag_prompt()}] + conversation
472
+ result = await self._call_llm(
473
+ model=NON_RAG_MODEL,
474
+ messages=messages,
475
+ temperature=0,
476
+ stream=False,
477
+ session_id=session_id,
478
+ )
479
 
480
  if isinstance(result, str):
481
  return result.replace("!","")
482
 
483
  logger.error("generate_non_rag_response call failed or returned non-string.")
484
+ return None
backend/tools.py CHANGED
@@ -97,7 +97,7 @@ TOOL_DEFINITIONS =[
97
  "properties": {
98
  "cause": {
99
  "type": "string",
100
- "description": "Short description of the problem.",
101
  }
102
  },
103
  "required": ["cause"],
 
97
  "properties": {
98
  "cause": {
99
  "type": "string",
100
+ "description": "Short description of the problem in Thai language.",
101
  }
102
  },
103
  "required": ["cause"],