minhvtt commited on
Commit
75033ed
·
verified ·
1 Parent(s): 856023d

Upload 26 files

Browse files
chat_endpoint.py CHANGED
@@ -1,282 +1,283 @@
1
- """
2
- Chat endpoint với Multi-turn Conversation + Function Calling
3
- """
4
- from fastapi import HTTPException
5
- from datetime import datetime
6
- from huggingface_hub import InferenceClient
7
- from typing import Dict, List
8
- import json
9
-
10
-
11
- async def chat_endpoint(
12
- request, # ChatRequest
13
- conversation_service,
14
- tools_service,
15
- advanced_rag,
16
- embedding_service,
17
- qdrant_service,
18
- chat_history_collection,
19
- hf_token
20
- ):
21
- """
22
- Multi-turn conversational chatbot với RAG + Function Calling
23
-
24
- Flow:
25
- 1. Session management - create hoặc load existing session
26
- 2. RAG search - retrieve context nếu enabled
27
- 3. Build messages với conversation history + tools prompt
28
- 4. LLM generation - có thể trigger tool calls
29
- 5. Execute tools nếu cần
30
- 6. Final LLM response với tool results
31
- 7. Save to conversation history
32
- """
33
- try:
34
- # ===== 1. SESSION MANAGEMENT =====
35
- session_id = request.session_id
36
- if not session_id:
37
- # Create new session (server-side)
38
- session_id = conversation_service.create_session(
39
- metadata={"user_agent": "api", "created_via": "chat_endpoint"}
40
- )
41
- print(f"Created new session: {session_id}")
42
- else:
43
- # Validate existing session
44
- if not conversation_service.session_exists(session_id):
45
- raise HTTPException(
46
- status_code=404,
47
- detail=f"Session {session_id} not found. It may have expired."
48
- )
49
-
50
- # Load conversation history
51
- conversation_history = conversation_service.get_conversation_history(session_id)
52
-
53
- # ===== 2. RAG SEARCH =====
54
- context_used = []
55
- rag_stats = None
56
- context_text = ""
57
-
58
- if request.use_rag:
59
- if request.use_advanced_rag:
60
- # Use Advanced RAG Pipeline
61
- hf_client = None
62
- if request.hf_token or hf_token:
63
- hf_client = InferenceClient(token=request.hf_token or hf_token)
64
-
65
- documents, stats = advanced_rag.hybrid_rag_pipeline(
66
- query=request.message,
67
- top_k=request.top_k,
68
- score_threshold=request.score_threshold,
69
- use_reranking=request.use_reranking,
70
- use_compression=request.use_compression,
71
- use_query_expansion=request.use_query_expansion,
72
- max_context_tokens=500,
73
- hf_client=hf_client
74
- )
75
-
76
- # Convert to dict format
77
- context_used = [
78
- {
79
- "id": doc.id,
80
- "confidence": doc.confidence,
81
- "metadata": doc.metadata
82
- }
83
- for doc in documents
84
- ]
85
- rag_stats = stats
86
-
87
- # Format context
88
- context_text = advanced_rag.format_context_for_llm(documents)
89
- else:
90
- # Basic RAG
91
- query_embedding = embedding_service.encode_text(request.message)
92
- results = qdrant_service.search(
93
- query_embedding=query_embedding,
94
- limit=request.top_k,
95
- score_threshold=request.score_threshold
96
- )
97
- context_used = results
98
-
99
- context_text = "\n\nRelevant Context:\n"
100
- for i, doc in enumerate(context_used, 1):
101
- doc_text = doc["metadata"].get("text", "")
102
- if not doc_text:
103
- doc_text = " ".join(doc["metadata"].get("texts", []))
104
- confidence = doc["confidence"]
105
- context_text += f"\n[{i}] (Confidence: {confidence:.2f})\n{doc_text}\n"
106
-
107
- # ===== 3. BUILD MESSAGES với TOOLS PROMPT =====
108
- messages = []
109
-
110
- # System message với RAG context + Tools instruction
111
- if request.use_rag and context_used:
112
- if request.use_advanced_rag:
113
- base_prompt = advanced_rag.build_rag_prompt(
114
- query="", # Query sẽ đi trong user message
115
- context=context_text,
116
- system_message=request.system_message
117
- )
118
- else:
119
- base_prompt = f"""{request.system_message}
120
-
121
- {context_text}
122
-
123
- HƯỚNG DẪN:
124
- - Sử dụng thông tin từ context trên để trả lời câu hỏi.
125
- - Trả lời tự nhiên, thân thiện, không copy nguyên văn.
126
- - Nếu tìm thấy sự kiện, hãy tóm tắt các thông tin quan trọng nhất.
127
- """
128
- else:
129
- base_prompt = request.system_message
130
-
131
- # Add tools instruction nếu enabled
132
- if request.enable_tools:
133
- tools_prompt = tools_service.get_tools_prompt()
134
- system_message_with_tools = f"{base_prompt}\n\n{tools_prompt}"
135
- else:
136
- system_message_with_tools = base_prompt
137
-
138
- # Bắt đầu messages với system
139
- messages.append({"role": "system", "content": system_message_with_tools})
140
-
141
- # Add conversation history (past turns)
142
- messages.extend(conversation_history)
143
-
144
- # Add current user message
145
- messages.append({"role": "user", "content": request.message})
146
-
147
- # ===== 4. LLM GENERATION =====
148
- token = request.hf_token or hf_token
149
- tool_calls_made = []
150
-
151
- if not token:
152
- response = f"""[LLM Response Placeholder]
153
-
154
- Context retrieved: {len(context_used)} documents
155
- User question: {request.message}
156
- Session: {session_id}
157
-
158
- To enable actual LLM generation:
159
- 1. Set HUGGINGFACE_TOKEN environment variable, OR
160
- 2. Pass hf_token in request body
161
- """
162
- else:
163
- try:
164
- client = InferenceClient(
165
- token=token,
166
- model="openai/gpt-oss-20b" # Hoặc model khác
167
- )
168
-
169
- # First LLM call
170
- first_response = ""
171
- try:
172
- for msg in client.chat_completion(
173
- messages,
174
- max_tokens=request.max_tokens,
175
- stream=True,
176
- temperature=request.temperature,
177
- top_p=request.top_p,
178
- ):
179
- choices = msg.choices
180
- if len(choices) and choices[0].delta.content:
181
- first_response += choices[0].delta.content
182
- except Exception as e:
183
- # HF API throws error when LLM returns JSON (tool call)
184
- # Extract the "failed_generation" from error
185
- error_str = str(e)
186
- if "tool_use_failed" in error_str and "failed_generation" in error_str:
187
- # Parse error dict to get the actual JSON response
188
- import ast
189
- try:
190
- error_dict = ast.literal_eval(error_str)
191
- first_response = error_dict.get("failed_generation", "")
192
- except:
193
- # Fallback: extract JSON from string
194
- import re
195
- match = re.search(r"'failed_generation': '({.*?})'", error_str)
196
- if match:
197
- first_response = match.group(1)
198
- else:
199
- raise e
200
- else:
201
- raise e
202
-
203
- # ===== 5. PARSE & EXECUTE TOOLS =====
204
- if request.enable_tools:
205
- tool_result = await tools_service.parse_and_execute(first_response)
206
-
207
- if tool_result:
208
- # Tool was called!
209
- tool_calls_made.append(tool_result)
210
-
211
- # Add tool result to messages
212
- messages.append({"role": "assistant", "content": first_response})
213
- messages.append({
214
- "role": "user",
215
- "content": f"TOOL RESULT:\n{json.dumps(tool_result['result'], ensure_ascii=False, indent=2)}\n\nHãy dùng thông tin này để trả lời câu hỏi của user."
216
- })
217
-
218
- # Second LLM call với tool results
219
- final_response = ""
220
- for msg in client.chat_completion(
221
- messages,
222
- max_tokens=request.max_tokens,
223
- stream=True,
224
- temperature=request.temperature,
225
- top_p=request.top_p,
226
- ):
227
- choices = msg.choices
228
- if len(choices) and choices[0].delta.content:
229
- final_response += choices[0].delta.content
230
-
231
- response = final_response
232
- else:
233
- # No tool call, use first response
234
- response = first_response
235
- else:
236
- response = first_response
237
-
238
- except Exception as e:
239
- response = f"Error generating response with LLM: {str(e)}\n\nContext was retrieved successfully, but LLM generation failed."
240
-
241
- # ===== 6. SAVE TO CONVERSATION HISTORY =====
242
- conversation_service.add_message(
243
- session_id,
244
- "user",
245
- request.message
246
- )
247
- conversation_service.add_message(
248
- session_id,
249
- "assistant",
250
- response,
251
- metadata={
252
- "rag_stats": rag_stats,
253
- "tool_calls": tool_calls_made,
254
- "context_count": len(context_used)
255
- }
256
- )
257
-
258
- # Also save to legacy chat_history collection
259
- chat_data = {
260
- "session_id": session_id,
261
- "user_message": request.message,
262
- "assistant_response": response,
263
- "context_used": context_used,
264
- "tool_calls": tool_calls_made,
265
- "timestamp": datetime.utcnow()
266
- }
267
- chat_history_collection.insert_one(chat_data)
268
-
269
- # ===== 7. RETURN RESPONSE =====
270
- return {
271
- "response": response,
272
- "context_used": context_used,
273
- "timestamp": datetime.utcnow().isoformat(),
274
- "rag_stats": rag_stats,
275
- "session_id": session_id,
276
- "tool_calls": tool_calls_made if tool_calls_made else None
277
- }
278
-
279
- except HTTPException:
280
- raise
281
- except Exception as e:
282
- raise HTTPException(status_code=500, detail=f"Error: {str(e)}")
 
 
1
+ """
2
+ Chat endpoint với Multi-turn Conversation + Function Calling
3
+ """
4
+ from fastapi import HTTPException
5
+ from datetime import datetime
6
+ from huggingface_hub import InferenceClient
7
+ from typing import Dict, List
8
+ import json
9
+
10
+
11
+ async def chat_endpoint(
12
+ request, # ChatRequest
13
+ conversation_service,
14
+ tools_service,
15
+ advanced_rag,
16
+ embedding_service,
17
+ qdrant_service,
18
+ chat_history_collection,
19
+ hf_token
20
+ ):
21
+ """
22
+ Multi-turn conversational chatbot với RAG + Function Calling
23
+
24
+ Flow:
25
+ 1. Session management - create hoặc load existing session
26
+ 2. RAG search - retrieve context nếu enabled
27
+ 3. Build messages với conversation history + tools prompt
28
+ 4. LLM generation - có thể trigger tool calls
29
+ 5. Execute tools nếu cần
30
+ 6. Final LLM response với tool results
31
+ 7. Save to conversation history
32
+ """
33
+ try:
34
+ # ===== 1. SESSION MANAGEMENT =====
35
+ session_id = request.session_id
36
+ if not session_id:
37
+ # Create new session (server-side)
38
+ session_id = conversation_service.create_session(
39
+ metadata={"user_agent": "api", "created_via": "chat_endpoint"},
40
+ user_id=request.user_id # NEW: Pass user_id from request
41
+ )
42
+ print(f"Created new session: {session_id} for user: {request.user_id or 'anonymous'}")
43
+ else:
44
+ # Validate existing session
45
+ if not conversation_service.session_exists(session_id):
46
+ raise HTTPException(
47
+ status_code=404,
48
+ detail=f"Session {session_id} not found. It may have expired."
49
+ )
50
+
51
+ # Load conversation history
52
+ conversation_history = conversation_service.get_conversation_history(session_id)
53
+
54
+ # ===== 2. RAG SEARCH =====
55
+ context_used = []
56
+ rag_stats = None
57
+ context_text = ""
58
+
59
+ if request.use_rag:
60
+ if request.use_advanced_rag:
61
+ # Use Advanced RAG Pipeline
62
+ hf_client = None
63
+ if request.hf_token or hf_token:
64
+ hf_client = InferenceClient(token=request.hf_token or hf_token)
65
+
66
+ documents, stats = advanced_rag.hybrid_rag_pipeline(
67
+ query=request.message,
68
+ top_k=request.top_k,
69
+ score_threshold=request.score_threshold,
70
+ use_reranking=request.use_reranking,
71
+ use_compression=request.use_compression,
72
+ use_query_expansion=request.use_query_expansion,
73
+ max_context_tokens=500,
74
+ hf_client=hf_client
75
+ )
76
+
77
+ # Convert to dict format
78
+ context_used = [
79
+ {
80
+ "id": doc.id,
81
+ "confidence": doc.confidence,
82
+ "metadata": doc.metadata
83
+ }
84
+ for doc in documents
85
+ ]
86
+ rag_stats = stats
87
+
88
+ # Format context
89
+ context_text = advanced_rag.format_context_for_llm(documents)
90
+ else:
91
+ # Basic RAG
92
+ query_embedding = embedding_service.encode_text(request.message)
93
+ results = qdrant_service.search(
94
+ query_embedding=query_embedding,
95
+ limit=request.top_k,
96
+ score_threshold=request.score_threshold
97
+ )
98
+ context_used = results
99
+
100
+ context_text = "\n\nRelevant Context:\n"
101
+ for i, doc in enumerate(context_used, 1):
102
+ doc_text = doc["metadata"].get("text", "")
103
+ if not doc_text:
104
+ doc_text = " ".join(doc["metadata"].get("texts", []))
105
+ confidence = doc["confidence"]
106
+ context_text += f"\n[{i}] (Confidence: {confidence:.2f})\n{doc_text}\n"
107
+
108
+ # ===== 3. BUILD MESSAGES với TOOLS PROMPT =====
109
+ messages = []
110
+
111
+ # System message với RAG context + Tools instruction
112
+ if request.use_rag and context_used:
113
+ if request.use_advanced_rag:
114
+ base_prompt = advanced_rag.build_rag_prompt(
115
+ query="", # Query sẽ đi trong user message
116
+ context=context_text,
117
+ system_message=request.system_message
118
+ )
119
+ else:
120
+ base_prompt = f"""{request.system_message}
121
+
122
+ {context_text}
123
+
124
+ HƯỚNG DẪN:
125
+ - Sử dụng thông tin từ context trên để trả lời câu hỏi.
126
+ - Trả lời tự nhiên, thân thiện, không copy nguyên văn.
127
+ - Nếu tìm thấy sự kiện, hãy tóm tắt các thông tin quan trọng nhất.
128
+ """
129
+ else:
130
+ base_prompt = request.system_message
131
+
132
+ # Add tools instruction nếu enabled
133
+ if request.enable_tools:
134
+ tools_prompt = tools_service.get_tools_prompt()
135
+ system_message_with_tools = f"{base_prompt}\n\n{tools_prompt}"
136
+ else:
137
+ system_message_with_tools = base_prompt
138
+
139
+ # Bắt đầu messages với system
140
+ messages.append({"role": "system", "content": system_message_with_tools})
141
+
142
+ # Add conversation history (past turns)
143
+ messages.extend(conversation_history)
144
+
145
+ # Add current user message
146
+ messages.append({"role": "user", "content": request.message})
147
+
148
+ # ===== 4. LLM GENERATION =====
149
+ token = request.hf_token or hf_token
150
+ tool_calls_made = []
151
+
152
+ if not token:
153
+ response = f"""[LLM Response Placeholder]
154
+
155
+ Context retrieved: {len(context_used)} documents
156
+ User question: {request.message}
157
+ Session: {session_id}
158
+
159
+ To enable actual LLM generation:
160
+ 1. Set HUGGINGFACE_TOKEN environment variable, OR
161
+ 2. Pass hf_token in request body
162
+ """
163
+ else:
164
+ try:
165
+ client = InferenceClient(
166
+ token=token,
167
+ model="openai/gpt-oss-20b" # Hoặc model khác
168
+ )
169
+
170
+ # First LLM call
171
+ first_response = ""
172
+ try:
173
+ for msg in client.chat_completion(
174
+ messages,
175
+ max_tokens=request.max_tokens,
176
+ stream=True,
177
+ temperature=request.temperature,
178
+ top_p=request.top_p,
179
+ ):
180
+ choices = msg.choices
181
+ if len(choices) and choices[0].delta.content:
182
+ first_response += choices[0].delta.content
183
+ except Exception as e:
184
+ # HF API throws error when LLM returns JSON (tool call)
185
+ # Extract the "failed_generation" from error
186
+ error_str = str(e)
187
+ if "tool_use_failed" in error_str and "failed_generation" in error_str:
188
+ # Parse error dict to get the actual JSON response
189
+ import ast
190
+ try:
191
+ error_dict = ast.literal_eval(error_str)
192
+ first_response = error_dict.get("failed_generation", "")
193
+ except:
194
+ # Fallback: extract JSON from string
195
+ import re
196
+ match = re.search(r"'failed_generation': '({.*?})'", error_str)
197
+ if match:
198
+ first_response = match.group(1)
199
+ else:
200
+ raise e
201
+ else:
202
+ raise e
203
+
204
+ # ===== 5. PARSE & EXECUTE TOOLS =====
205
+ if request.enable_tools:
206
+ tool_result = await tools_service.parse_and_execute(first_response)
207
+
208
+ if tool_result:
209
+ # Tool was called!
210
+ tool_calls_made.append(tool_result)
211
+
212
+ # Add tool result to messages
213
+ messages.append({"role": "assistant", "content": first_response})
214
+ messages.append({
215
+ "role": "user",
216
+ "content": f"TOOL RESULT:\n{json.dumps(tool_result['result'], ensure_ascii=False, indent=2)}\n\nHãy dùng thông tin này để trả lời câu hỏi của user."
217
+ })
218
+
219
+ # Second LLM call với tool results
220
+ final_response = ""
221
+ for msg in client.chat_completion(
222
+ messages,
223
+ max_tokens=request.max_tokens,
224
+ stream=True,
225
+ temperature=request.temperature,
226
+ top_p=request.top_p,
227
+ ):
228
+ choices = msg.choices
229
+ if len(choices) and choices[0].delta.content:
230
+ final_response += choices[0].delta.content
231
+
232
+ response = final_response
233
+ else:
234
+ # No tool call, use first response
235
+ response = first_response
236
+ else:
237
+ response = first_response
238
+
239
+ except Exception as e:
240
+ response = f"Error generating response with LLM: {str(e)}\n\nContext was retrieved successfully, but LLM generation failed."
241
+
242
+ # ===== 6. SAVE TO CONVERSATION HISTORY =====
243
+ conversation_service.add_message(
244
+ session_id,
245
+ "user",
246
+ request.message
247
+ )
248
+ conversation_service.add_message(
249
+ session_id,
250
+ "assistant",
251
+ response,
252
+ metadata={
253
+ "rag_stats": rag_stats,
254
+ "tool_calls": tool_calls_made,
255
+ "context_count": len(context_used)
256
+ }
257
+ )
258
+
259
+ # Also save to legacy chat_history collection
260
+ chat_data = {
261
+ "session_id": session_id,
262
+ "user_message": request.message,
263
+ "assistant_response": response,
264
+ "context_used": context_used,
265
+ "tool_calls": tool_calls_made,
266
+ "timestamp": datetime.utcnow()
267
+ }
268
+ chat_history_collection.insert_one(chat_data)
269
+
270
+ # ===== 7. RETURN RESPONSE =====
271
+ return {
272
+ "response": response,
273
+ "context_used": context_used,
274
+ "timestamp": datetime.utcnow().isoformat(),
275
+ "rag_stats": rag_stats,
276
+ "session_id": session_id,
277
+ "tool_calls": tool_calls_made if tool_calls_made else None
278
+ }
279
+
280
+ except HTTPException:
281
+ raise
282
+ except Exception as e:
283
+ raise HTTPException(status_code=500, detail=f"Error: {str(e)}")
conversation_service.py CHANGED
@@ -29,6 +29,7 @@ class ConversationService:
29
  """Create necessary indexes"""
30
  try:
31
  self.collection.create_index("session_id", unique=True)
 
32
  # Auto-delete sessions sau 7 ngày không dùng
33
  self.collection.create_index(
34
  "updated_at",
@@ -38,10 +39,14 @@ class ConversationService:
38
  except Exception as e:
39
  print(f"Conversation indexes already exist or error: {e}")
40
 
41
- def create_session(self, metadata: Optional[Dict] = None) -> str:
42
  """
43
  Create new conversation session
44
 
 
 
 
 
45
  Returns:
46
  session_id (UUID string)
47
  """
@@ -49,7 +54,9 @@ class ConversationService:
49
 
50
  self.collection.insert_one({
51
  "session_id": session_id,
 
52
  "messages": [],
 
53
  "metadata": metadata or {},
54
  "created_at": datetime.utcnow(),
55
  "updated_at": datetime.utcnow()
@@ -146,7 +153,7 @@ class ConversationService:
146
  """
147
  session = self.collection.find_one(
148
  {"session_id": session_id},
149
- {"_id": 0, "session_id": 1, "created_at": 1, "updated_at": 1, "metadata": 1}
150
  )
151
  return session
152
 
@@ -182,3 +189,120 @@ class ConversationService:
182
  return msg["content"]
183
 
184
  return None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
  """Create necessary indexes"""
30
  try:
31
  self.collection.create_index("session_id", unique=True)
32
+ self.collection.create_index("user_id") # NEW: Index for user filtering
33
  # Auto-delete sessions sau 7 ngày không dùng
34
  self.collection.create_index(
35
  "updated_at",
 
39
  except Exception as e:
40
  print(f"Conversation indexes already exist or error: {e}")
41
 
42
+ def create_session(self, metadata: Optional[Dict] = None, user_id: Optional[str] = None) -> str:
43
  """
44
  Create new conversation session
45
 
46
+ Args:
47
+ metadata: Additional metadata
48
+ user_id: User identifier (optional)
49
+
50
  Returns:
51
  session_id (UUID string)
52
  """
 
54
 
55
  self.collection.insert_one({
56
  "session_id": session_id,
57
+ "user_id": user_id, # NEW: Store user_id
58
  "messages": [],
59
+ "scenario_state": None, # NEW: Scenario state
60
  "metadata": metadata or {},
61
  "created_at": datetime.utcnow(),
62
  "updated_at": datetime.utcnow()
 
153
  """
154
  session = self.collection.find_one(
155
  {"session_id": session_id},
156
+ {"_id": 0, "session_id": 1, "user_id": 1, "created_at": 1, "updated_at": 1, "metadata": 1}
157
  )
158
  return session
159
 
 
189
  return msg["content"]
190
 
191
  return None
192
+
193
+ def list_sessions(
194
+ self,
195
+ limit: int = 50,
196
+ skip: int = 0,
197
+ sort_by: str = "updated_at",
198
+ descending: bool = True,
199
+ user_id: Optional[str] = None # NEW: Filter by user
200
+ ) -> List[Dict]:
201
+ """
202
+ List all conversation sessions
203
+
204
+ Args:
205
+ limit: Maximum number of sessions to return
206
+ skip: Number of sessions to skip (for pagination)
207
+ sort_by: Field to sort by (created_at, updated_at)
208
+ descending: Sort in descending order
209
+ user_id: Filter sessions by user_id (optional)
210
+
211
+ Returns:
212
+ List of session summaries
213
+ """
214
+ sort_order = -1 if descending else 1
215
+
216
+ # Build query filter
217
+ query = {}
218
+ if user_id:
219
+ query["user_id"] = user_id
220
+
221
+ sessions = self.collection.find(
222
+ query, # Use query filter
223
+ {"_id": 0, "session_id": 1, "user_id": 1, "created_at": 1, "updated_at": 1, "metadata": 1}
224
+ ).sort(sort_by, sort_order).skip(skip).limit(limit)
225
+
226
+ result = []
227
+ for session in sessions:
228
+ # Count messages
229
+ message_count = len(
230
+ self.collection.find_one({"session_id": session["session_id"]}, {"messages": 1})
231
+ .get("messages", [])
232
+ )
233
+
234
+ result.append({
235
+ "session_id": session["session_id"],
236
+ "user_id": session.get("user_id"), # NEW: Include user_id
237
+ "created_at": session["created_at"],
238
+ "updated_at": session["updated_at"],
239
+ "message_count": message_count,
240
+ "metadata": session.get("metadata", {})
241
+ })
242
+
243
+ return result
244
+
245
+ def count_sessions(self, user_id: Optional[str] = None) -> int:
246
+ """
247
+ Get total number of sessions
248
+
249
+ Args:
250
+ user_id: Filter count by user_id (optional)
251
+ """
252
+ query = {}
253
+ if user_id:
254
+ query["user_id"] = user_id
255
+ return self.collection.count_documents(query)
256
+
257
+ # ===== Scenario State Management =====
258
+
259
+ def get_scenario_state(self, session_id: str) -> Optional[Dict]:
260
+ """
261
+ Get current scenario state for session
262
+
263
+ Returns:
264
+ {
265
+ "active_scenario": "price_inquiry",
266
+ "scenario_step": 3,
267
+ "scenario_data": {...},
268
+ "last_activity": "..."
269
+ }
270
+ or None if no active scenario
271
+ """
272
+ session = self.collection.find_one({"session_id": session_id})
273
+ if not session:
274
+ return None
275
+ return session.get("scenario_state")
276
+
277
+ def set_scenario_state(self, session_id: str, state: Dict):
278
+ """
279
+ Set scenario state for session
280
+
281
+ Args:
282
+ session_id: Session ID
283
+ state: Scenario state dict
284
+ """
285
+ self.collection.update_one(
286
+ {"session_id": session_id},
287
+ {
288
+ "$set": {
289
+ "scenario_state": state,
290
+ "updated_at": datetime.utcnow()
291
+ }
292
+ },
293
+ upsert=True
294
+ )
295
+
296
+ def clear_scenario(self, session_id: str):
297
+ """
298
+ Clear scenario state (end scenario)
299
+ """
300
+ self.collection.update_one(
301
+ {"session_id": session_id},
302
+ {
303
+ "$set": {
304
+ "scenario_state": None,
305
+ "updated_at": datetime.utcnow()
306
+ }
307
+ }
308
+ )
hybrid_chat_endpoint.py ADDED
@@ -0,0 +1,285 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Hybrid Chat Endpoint: RAG + Scenario FSM
3
+ Routes between scripted scenarios and knowledge retrieval
4
+ """
5
+ from fastapi import HTTPException
6
+ from datetime import datetime
7
+ from typing import Dict, Any
8
+ import json
9
+
10
+
11
+ async def hybrid_chat_endpoint(
12
+ request, # ChatRequest
13
+ conversation_service,
14
+ intent_classifier,
15
+ scenario_engine,
16
+ tools_service,
17
+ advanced_rag,
18
+ embedding_service,
19
+ qdrant_service,
20
+ chat_history_collection,
21
+ hf_token,
22
+ lead_storage # NEW: For saving customer leads
23
+ ):
24
+ """
25
+ Hybrid conversational chatbot: Scenario FSM + RAG
26
+
27
+ Flow:
28
+ 1. Load session & scenario state
29
+ 2. Classify intent (scenario vs RAG)
30
+ 3. Route:
31
+ - Scenario: Execute FSM flow
32
+ - RAG: Knowledge retrieval
33
+ - RAG+Resume: Answer question then resume scenario
34
+ 4. Save state & history
35
+ """
36
+ try:
37
+ # ===== SESSION MANAGEMENT =====
38
+ session_id = request.session_id
39
+ if not session_id:
40
+ session_id = conversation_service.create_session(
41
+ metadata={"user_agent": "api", "created_via": "hybrid_chat"},
42
+ user_id=request.user_id
43
+ )
44
+ print(f"✓ Created session: {session_id} (user: {request.user_id or 'anon'})")
45
+ else:
46
+ if not conversation_service.session_exists(session_id):
47
+ raise HTTPException(404, detail=f"Session {session_id} not found")
48
+
49
+ # ===== LOAD SCENARIO STATE =====
50
+ scenario_state = conversation_service.get_scenario_state(session_id) or {}
51
+
52
+ # ===== INTENT CLASSIFICATION =====
53
+ intent = intent_classifier.classify(request.message, scenario_state)
54
+ print(f"🎯 Intent: {intent}")
55
+
56
+ # ===== ROUTING =====
57
+ if intent.startswith("scenario:"):
58
+ # Route to scenario engine
59
+ response_data = await handle_scenario(
60
+ intent,
61
+ request.message,
62
+ session_id,
63
+ scenario_state,
64
+ scenario_engine,
65
+ conversation_service,
66
+ advanced_rag,
67
+ lead_storage # NEW: Pass for action handling
68
+ )
69
+
70
+ elif intent == "rag:with_resume":
71
+ # Answer question but keep scenario active
72
+ response_data = await handle_rag_with_resume(
73
+ request,
74
+ session_id,
75
+ scenario_state,
76
+ advanced_rag,
77
+ embedding_service,
78
+ qdrant_service,
79
+ conversation_service
80
+ )
81
+
82
+ else: # rag:general
83
+ # Pure RAG query
84
+ response_data = await handle_pure_rag(
85
+ request,
86
+ session_id,
87
+ advanced_rag,
88
+ embedding_service,
89
+ qdrant_service,
90
+ tools_service,
91
+ chat_history_collection,
92
+ hf_token,
93
+ conversation_service
94
+ )
95
+
96
+ # ===== SAVE HISTORY =====
97
+ conversation_service.add_message(
98
+ session_id,
99
+ "user",
100
+ request.message,
101
+ metadata={"intent": intent}
102
+ )
103
+
104
+ conversation_service.add_message(
105
+ session_id,
106
+ "assistant",
107
+ response_data["response"],
108
+ metadata={
109
+ "mode": response_data.get("mode", "unknown"),
110
+ "context_used": response_data.get("context_used", [])[:3] # Limit size
111
+ }
112
+ )
113
+
114
+ return {
115
+ "response": response_data["response"],
116
+ "session_id": session_id,
117
+ "mode": response_data.get("mode"),
118
+ "scenario_active": response_data.get("scenario_active", False),
119
+ "timestamp": datetime.utcnow().isoformat()
120
+ }
121
+
122
+ except Exception as e:
123
+ print(f"❌ Error in hybrid_chat: {str(e)}")
124
+ raise HTTPException(500, detail=f"Chat error: {str(e)}")
125
+
126
+
127
+ async def handle_scenario(
128
+ intent,
129
+ user_message,
130
+ session_id,
131
+ scenario_state,
132
+ scenario_engine,
133
+ conversation_service,
134
+ advanced_rag,
135
+ lead_storage=None
136
+ ):
137
+ """Handle scenario-based conversation"""
138
+
139
+ if intent == "scenario:continue":
140
+ # Continue existing scenario
141
+ result = scenario_engine.next_step(
142
+ scenario_id=scenario_state["active_scenario"],
143
+ current_step=scenario_state["scenario_step"],
144
+ user_input=user_message,
145
+ scenario_data=scenario_state.get("scenario_data", {}),
146
+ rag_service=advanced_rag
147
+ )
148
+ else:
149
+ # Start new scenario
150
+ scenario_type = intent.split(":", 1)[1]
151
+ result = scenario_engine.start_scenario(scenario_type)
152
+
153
+ # Update scenario state
154
+ if result.get("end_scenario"):
155
+ conversation_service.clear_scenario(session_id)
156
+ scenario_active = False
157
+ else:
158
+ conversation_service.set_scenario_state(session_id, result["new_state"])
159
+ scenario_active = True
160
+
161
+ # Execute action if any
162
+ if result.get("action") and lead_storage:
163
+ action = result['action']
164
+ scenario_data = result.get('new_state', {}).get('scenario_data', scenario_state.get('scenario_data', {}))
165
+
166
+ if action == "send_pdf_email":
167
+ # Save lead with email
168
+ lead_storage.save_lead(
169
+ event_name=scenario_data.get('step_1_input', 'Unknown Event'),
170
+ email=scenario_data.get('step_5_input'), # Email from step 5
171
+ interests={
172
+ "group": scenario_data.get('group_size'),
173
+ "wants_pdf": True
174
+ },
175
+ session_id=session_id
176
+ )
177
+ print(f"📧 Lead saved: email sent (saved to DB)")
178
+
179
+ elif action == "save_lead_phone":
180
+ # Save lead with phone
181
+ lead_storage.save_lead(
182
+ event_name=scenario_data.get('step_1_input', 'Unknown Event'),
183
+ email=scenario_data.get('step_5_input'),
184
+ phone=scenario_data.get('step_8_input'), # Phone from step 8
185
+ interests={
186
+ "group": scenario_data.get('group_size'),
187
+ "wants_reminder": True
188
+ },
189
+ session_id=session_id
190
+ )
191
+ print(f"📱 Lead saved: SMS reminder (saved to DB)")
192
+
193
+ return {
194
+ "response": result["message"],
195
+ "mode": "scenario",
196
+ "scenario_active": scenario_active
197
+ }
198
+
199
+
200
+ async def handle_rag_with_resume(
201
+ request,
202
+ session_id,
203
+ scenario_state,
204
+ advanced_rag,
205
+ embedding_service,
206
+ qdrant_service,
207
+ conversation_service
208
+ ):
209
+ """
210
+ Handle RAG query mid-scenario
211
+ Answer question then remind user to continue scenario
212
+ """
213
+ # Query RAG
214
+ context_used = []
215
+ if request.use_rag:
216
+ query_embedding = embedding_service.encode_text(request.message)
217
+ results = qdrant_service.search(
218
+ query_embedding=query_embedding,
219
+ limit=request.top_k,
220
+ score_threshold=request.score_threshold,
221
+ ef=256
222
+ )
223
+ context_used = results
224
+
225
+ # Build simple RAG response
226
+ rag_response = await simple_rag_response(
227
+ request.message,
228
+ context_used,
229
+ request.system_message
230
+ )
231
+
232
+ # Add resume hint
233
+ last_scenario_msg = f"\n\n---\nVậy nha! Quay lại câu hỏi trước, bạn đã quyết định chưa? ^^"
234
+
235
+ return {
236
+ "response": rag_response + last_scenario_msg,
237
+ "mode": "rag_with_resume",
238
+ "scenario_active": True,
239
+ "context_used": context_used
240
+ }
241
+
242
+
243
+ async def handle_pure_rag(
244
+ request,
245
+ session_id,
246
+ advanced_rag,
247
+ embedding_service,
248
+ qdrant_service,
249
+ tools_service,
250
+ chat_history_collection,
251
+ hf_token,
252
+ conversation_service
253
+ ):
254
+ """
255
+ Handle pure RAG query (fallback to existing logic)
256
+ """
257
+ # Import existing chat_endpoint logic
258
+ from chat_endpoint import chat_endpoint
259
+
260
+ # Call existing endpoint
261
+ result = await chat_endpoint(
262
+ request,
263
+ conversation_service,
264
+ tools_service,
265
+ advanced_rag,
266
+ embedding_service,
267
+ qdrant_service,
268
+ chat_history_collection,
269
+ hf_token
270
+ )
271
+
272
+ return {
273
+ "response": result["response"],
274
+ "mode": "rag",
275
+ "context_used": result.get("context_used", [])
276
+ }
277
+
278
+
279
+ async def simple_rag_response(message, context, system_message):
280
+ """Simple RAG response without LLM (for quick answers)"""
281
+ if context:
282
+ # Return top context
283
+ top = context[0]
284
+ return f"{top['metadata'].get('text', 'Không tìm thấy thông tin.')}"
285
+ return "Xin lỗi, tôi không tìm thấy thông tin về điều này."
hybrid_chat_stream.py ADDED
@@ -0,0 +1,207 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Hybrid Chat Streaming Endpoint
3
+ Real-time SSE streaming for scenarios + RAG
4
+ """
5
+ from typing import AsyncGenerator
6
+ import asyncio
7
+ from datetime import datetime
8
+
9
+ from stream_utils import (
10
+ format_sse, stream_text_slowly,
11
+ EVENT_STATUS, EVENT_TOKEN, EVENT_DONE, EVENT_ERROR, EVENT_METADATA
12
+ )
13
+
14
+
15
+ async def hybrid_chat_stream(
16
+ request,
17
+ conversation_service,
18
+ intent_classifier,
19
+ scenario_engine,
20
+ advanced_rag,
21
+ embedding_service,
22
+ qdrant_service,
23
+ hf_token,
24
+ lead_storage
25
+ ) -> AsyncGenerator[str, None]:
26
+ """
27
+ Stream chat responses in real-time (SSE format)
28
+
29
+ Yields SSE events:
30
+ - status: "Đang suy nghĩ...", "Đang tìm kiếm..."
31
+ - token: Individual text chunks
32
+ - metadata: Context, session info
33
+ - done: Completion signal
34
+ - error: Error messages
35
+ """
36
+ try:
37
+ # === SESSION MANAGEMENT ===
38
+ session_id = request.session_id
39
+ if not session_id:
40
+ session_id = conversation_service.create_session(
41
+ metadata={"user_agent": "api", "created_via": "stream"},
42
+ user_id=request.user_id
43
+ )
44
+ yield format_sse(EVENT_METADATA, {"session_id": session_id})
45
+
46
+ # === INTENT CLASSIFICATION ===
47
+ yield format_sse(EVENT_STATUS, "Đang phân tích câu hỏi...")
48
+
49
+ scenario_state = conversation_service.get_scenario_state(session_id) or {}
50
+ intent = intent_classifier.classify(request.message, scenario_state)
51
+
52
+ # === ROUTING ===
53
+ if intent.startswith("scenario:"):
54
+ # Scenario flow with simulated streaming
55
+ async for sse_event in handle_scenario_stream(
56
+ intent, request.message, session_id,
57
+ scenario_state, scenario_engine, conversation_service, lead_storage
58
+ ):
59
+ yield sse_event
60
+
61
+ elif intent == "rag:with_resume":
62
+ # Quick RAG answer + resume scenario
63
+ yield format_sse(EVENT_STATUS, "Đang tra cứu...")
64
+ async for sse_event in handle_rag_stream(
65
+ request, advanced_rag, embedding_service, qdrant_service
66
+ ):
67
+ yield sse_event
68
+
69
+ # Resume hint
70
+ async for chunk in stream_text_slowly(
71
+ "\n\n---\nVậy nha! Quay lại câu hỏi trước nhé ^^",
72
+ chars_per_chunk=5,
73
+ delay_ms=15
74
+ ):
75
+ yield chunk
76
+
77
+ else: # Pure RAG
78
+ yield format_sse(EVENT_STATUS, "Đang tìm kiếm trong tài liệu...")
79
+ async for sse_event in handle_rag_stream(
80
+ request, advanced_rag, embedding_service, qdrant_service
81
+ ):
82
+ yield sse_event
83
+
84
+ # === SAVE HISTORY ===
85
+ # Note: We'll save the full response after streaming completes
86
+ # This requires buffering on the server side
87
+
88
+ # === DONE ===
89
+ yield format_sse(EVENT_DONE, {
90
+ "session_id": session_id,
91
+ "timestamp": datetime.utcnow().isoformat()
92
+ })
93
+
94
+ except Exception as e:
95
+ yield format_sse(EVENT_ERROR, str(e))
96
+
97
+
98
+ async def handle_scenario_stream(
99
+ intent, user_message, session_id,
100
+ scenario_state, scenario_engine, conversation_service, lead_storage
101
+ ) -> AsyncGenerator[str, None]:
102
+ """
103
+ Handle scenario with simulated typing effect
104
+ """
105
+ # Get scenario response (sync)
106
+ if intent == "scenario:continue":
107
+ result = scenario_engine.next_step(
108
+ scenario_id=scenario_state["active_scenario"],
109
+ current_step=scenario_state["scenario_step"],
110
+ user_input=user_message,
111
+ scenario_data=scenario_state.get("scenario_data", {})
112
+ )
113
+ else:
114
+ scenario_type = intent.split(":", 1)[1]
115
+ result = scenario_engine.start_scenario(scenario_type)
116
+
117
+ # Update state
118
+ if result.get("end_scenario"):
119
+ conversation_service.clear_scenario(session_id)
120
+ elif result.get("new_state"):
121
+ conversation_service.set_scenario_state(session_id, result["new_state"])
122
+
123
+ # Execute actions
124
+ if result.get("action") and lead_storage:
125
+ action = result['action']
126
+ scenario_data = result.get('new_state', {}).get('scenario_data', {})
127
+
128
+ if action == "send_pdf_email":
129
+ lead_storage.save_lead(
130
+ event_name=scenario_data.get('step_1_input', 'Unknown'),
131
+ email=scenario_data.get('step_5_input'),
132
+ interests={"group": scenario_data.get('group_size'), "wants_pdf": True},
133
+ session_id=session_id
134
+ )
135
+ elif action == "save_lead_phone":
136
+ lead_storage.save_lead(
137
+ event_name=scenario_data.get('step_1_input', 'Unknown'),
138
+ email=scenario_data.get('step_5_input'),
139
+ phone=scenario_data.get('step_8_input'),
140
+ interests={"group": scenario_data.get('group_size'), "wants_reminder": True},
141
+ session_id=session_id
142
+ )
143
+
144
+ # Stream response with typing effect
145
+ response_text = result["message"]
146
+ async for chunk in stream_text_slowly(
147
+ response_text,
148
+ chars_per_chunk=4, # Faster for scenarios
149
+ delay_ms=15
150
+ ):
151
+ yield chunk
152
+
153
+ yield format_sse(EVENT_METADATA, {
154
+ "mode": "scenario",
155
+ "scenario_active": not result.get("end_scenario")
156
+ })
157
+
158
+
159
+ async def handle_rag_stream(
160
+ request, advanced_rag, embedding_service, qdrant_service
161
+ ) -> AsyncGenerator[str, None]:
162
+ """
163
+ Handle RAG with real LLM streaming
164
+ """
165
+ # RAG search (sync part)
166
+ context_used = []
167
+ if request.use_rag:
168
+ query_embedding = embedding_service.encode_text(request.message)
169
+ results = qdrant_service.search(
170
+ query_embedding=query_embedding,
171
+ limit=request.top_k,
172
+ score_threshold=request.score_threshold,
173
+ ef=256
174
+ )
175
+ context_used = results
176
+
177
+ # Build context
178
+ if context_used:
179
+ context_str = "\n\n".join([
180
+ f"[{i+1}] {r['metadata'].get('text', '')[:500]}"
181
+ for i, r in enumerate(context_used[:3])
182
+ ])
183
+ else:
184
+ context_str = "Không tìm thấy thông tin liên quan."
185
+
186
+ # Simple response (for now - can integrate with real LLM streaming later)
187
+ if context_used:
188
+ response_text = f"Dựa trên tài liệu, {context_used[0]['metadata'].get('text', '')[:300]}..."
189
+ else:
190
+ response_text = "Xin lỗi, tôi không tìm thấy thông tin về câu hỏi này."
191
+
192
+ # Simulate streaming (will be replaced with real HF streaming)
193
+ async for chunk in stream_text_slowly(
194
+ response_text,
195
+ chars_per_chunk=3,
196
+ delay_ms=20
197
+ ):
198
+ yield chunk
199
+
200
+ yield format_sse(EVENT_METADATA, {
201
+ "mode": "rag",
202
+ "context_count": len(context_used)
203
+ })
204
+
205
+
206
+ # TODO: Implement real HF InferenceClient streaming
207
+ # This requires updating advanced_rag.py to support stream=True
intent_classifier.py ADDED
@@ -0,0 +1,208 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Intent Classifier for Hybrid RAG + FSM Chatbot
3
+ Detects user intent to route between scenario flows and RAG queries
4
+ """
5
+ from typing import Dict, Optional, List
6
+ import re
7
+
8
+
9
+ class IntentClassifier:
10
+ """
11
+ Classify user intent using keyword matching
12
+ Routes to either:
13
+ - Scenario flows (scripted conversations)
14
+ - RAG queries (knowledge retrieval)
15
+ """
16
+
17
+ def __init__(self, scenarios_dir: str = "scenarios"):
18
+ """
19
+ Initialize with auto-loading triggers from scenario JSON files
20
+
21
+ Args:
22
+ scenarios_dir: Directory containing scenario JSON files
23
+ """
24
+ # Auto-load scenario patterns from JSON files
25
+ self.scenario_patterns = self._load_scenario_patterns(scenarios_dir)
26
+
27
+ # General question patterns (RAG)
28
+ self.general_patterns = [
29
+ # Location
30
+ "ở đâu", "địa điểm", "location", "where",
31
+ "chỗ nào", "tổ chức tại",
32
+
33
+ # Time
34
+ "mấy giờ", "khi nào", "when", "time",
35
+ "bao giờ", "thời gian", "ngày nào",
36
+
37
+ # Info
38
+ "thông tin", "info", "information",
39
+ "chi tiết", "details", "về",
40
+
41
+ # Parking
42
+ "đậu xe", "parking", "gửi xe",
43
+
44
+ # Contact
45
+ "liên hệ", "contact", "số điện thoại"
46
+ ]
47
+
48
+ def _load_scenario_patterns(self, scenarios_dir: str) -> dict:
49
+ """
50
+ Auto-load triggers from all scenario JSON files
51
+
52
+ Returns:
53
+ {"scenario_id": ["trigger1", "trigger2", ...]}
54
+ """
55
+ import json
56
+ import os
57
+
58
+ patterns = {}
59
+
60
+ if not os.path.exists(scenarios_dir):
61
+ print(f"⚠ Scenarios directory not found: {scenarios_dir}")
62
+ return patterns
63
+
64
+ for filename in os.listdir(scenarios_dir):
65
+ if filename.endswith('.json'):
66
+ filepath = os.path.join(scenarios_dir, filename)
67
+ try:
68
+ with open(filepath, 'r', encoding='utf-8') as f:
69
+ scenario = json.load(f)
70
+ scenario_id = scenario.get('scenario_id')
71
+ triggers = scenario.get('triggers', [])
72
+
73
+ if scenario_id and triggers:
74
+ patterns[scenario_id] = triggers
75
+ print(f"✓ Loaded triggers for: {scenario_id} ({len(triggers)} patterns)")
76
+ except Exception as e:
77
+ print(f"⚠ Error loading {filename}: {e}")
78
+
79
+ return patterns
80
+
81
+ def classify(
82
+ self,
83
+ message: str,
84
+ conversation_state: Optional[Dict] = None
85
+ ) -> str:
86
+ """
87
+ Classify user intent
88
+
89
+ Args:
90
+ message: User message
91
+ conversation_state: Current conversation state (optional)
92
+ {
93
+ "active_scenario": "price_inquiry" | null,
94
+ "scenario_step": 3,
95
+ "scenario_data": {...}
96
+ }
97
+
98
+ Returns:
99
+ Intent string:
100
+ - "scenario:<scenario_id>" - Start new scenario
101
+ - "scenario:continue" - Continue current scenario
102
+ - "rag:general" - General RAG query
103
+ - "rag:with_resume" - RAG query but resume scenario after
104
+ """
105
+ message_lower = message.lower().strip()
106
+ state = conversation_state or {}
107
+
108
+ # Check if in active scenario
109
+ in_scenario = state.get("active_scenario") is not None
110
+
111
+ if in_scenario:
112
+ # User is mid-scenario
113
+ # Check if message is off-topic question
114
+ if self._is_general_question(message_lower):
115
+ return "rag:with_resume"
116
+ else:
117
+ # Continue scenario (user answering scenario question)
118
+ return "scenario:continue"
119
+
120
+ # Not in scenario - check for new scenario triggers
121
+ for scenario_id, patterns in self.scenario_patterns.items():
122
+ if self._matches_any_pattern(message_lower, patterns):
123
+ return f"scenario:{scenario_id}"
124
+
125
+ # Default: general RAG query
126
+ return "rag:general"
127
+
128
+ def _is_general_question(self, message: str) -> bool:
129
+ """
130
+ Check if message is a general question (should use RAG)
131
+ """
132
+ return self._matches_any_pattern(message, self.general_patterns)
133
+
134
+ def _matches_any_pattern(self, message: str, patterns: List[str]) -> bool:
135
+ """
136
+ Check if message matches any pattern in list
137
+ """
138
+ for pattern in patterns:
139
+ # Simple substring match (case insensitive already done)
140
+ if pattern in message:
141
+ return True
142
+
143
+ # Check word boundary
144
+ if re.search(rf'\b{re.escape(pattern)}\b', message, re.IGNORECASE):
145
+ return True
146
+
147
+ return False
148
+
149
+ def get_scenario_type(self, intent: str) -> Optional[str]:
150
+ """
151
+ Extract scenario type from intent string
152
+
153
+ Args:
154
+ intent: "scenario:price_inquiry" or "scenario:continue"
155
+
156
+ Returns:
157
+ "price_inquiry" or None
158
+ """
159
+ if not intent.startswith("scenario:"):
160
+ return None
161
+
162
+ parts = intent.split(":", 1)
163
+ if len(parts) < 2:
164
+ return None
165
+
166
+ scenario_type = parts[1]
167
+ if scenario_type == "continue":
168
+ return None
169
+
170
+ return scenario_type
171
+
172
+ def add_scenario_pattern(self, scenario_id: str, patterns: List[str]):
173
+ """
174
+ Dynamically add new scenario patterns
175
+ """
176
+ if scenario_id in self.scenario_patterns:
177
+ self.scenario_patterns[scenario_id].extend(patterns)
178
+ else:
179
+ self.scenario_patterns[scenario_id] = patterns
180
+
181
+ def add_general_pattern(self, patterns: List[str]):
182
+ """
183
+ Dynamically add new general question patterns
184
+ """
185
+ self.general_patterns.extend(patterns)
186
+
187
+
188
+ # Example usage
189
+ if __name__ == "__main__":
190
+ classifier = IntentClassifier()
191
+
192
+ # Test cases
193
+ test_cases = [
194
+ ("giá vé bao nhiêu?", None),
195
+ ("sự kiện ở đâu?", None),
196
+ ("đặt vé cho tôi", None),
197
+ ("A show", {"active_scenario": "price_inquiry", "scenario_step": 1}),
198
+ ("sự kiện mấy giờ?", {"active_scenario": "price_inquiry", "scenario_step": 3}),
199
+ ]
200
+
201
+ print("Intent Classification Test:")
202
+ print("-" * 50)
203
+ for message, state in test_cases:
204
+ intent = classifier.classify(message, state)
205
+ print(f"Message: {message}")
206
+ print(f"State: {state}")
207
+ print(f"Intent: {intent}")
208
+ print()
lead_storage_service.py ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Lead Storage Service
3
+ Saves customer leads collected during scenario conversations
4
+ """
5
+ from typing import Dict, Optional
6
+ from datetime import datetime
7
+ from pymongo.collection import Collection
8
+
9
+
10
+ class LeadStorageService:
11
+ """
12
+ Store customer leads from scenario interactions
13
+ """
14
+
15
+ def __init__(self, leads_collection: Collection):
16
+ self.collection = leads_collection
17
+ self._ensure_indexes()
18
+
19
+ def _ensure_indexes(self):
20
+ """Create indexes for leads collection"""
21
+ try:
22
+ self.collection.create_index("email")
23
+ self.collection.create_index("phone")
24
+ self.collection.create_index("created_at")
25
+ print("✓ Leads indexes created")
26
+ except Exception as e:
27
+ print(f"Leads indexes already exist: {e}")
28
+
29
+ def save_lead(
30
+ self,
31
+ event_name: str,
32
+ email: Optional[str] = None,
33
+ phone: Optional[str] = None,
34
+ interests: Optional[Dict] = None,
35
+ session_id: Optional[str] = None,
36
+ user_id: Optional[str] = None
37
+ ) -> str:
38
+ """
39
+ Save customer lead
40
+
41
+ Args:
42
+ event_name: Event they're interested in
43
+ email: Customer email
44
+ phone: Customer phone
45
+ interests: Additional data (group_size, etc.)
46
+ session_id: Conversation session
47
+ user_id: User ID if authenticated
48
+
49
+ Returns:
50
+ Lead ID
51
+ """
52
+ lead = {
53
+ "event_name": event_name,
54
+ "email": email,
55
+ "phone": phone,
56
+ "interests": interests or {},
57
+ "session_id": session_id,
58
+ "user_id": user_id,
59
+ "source": "chatbot_scenario",
60
+ "created_at": datetime.utcnow(),
61
+ "status": "new"
62
+ }
63
+
64
+ result = self.collection.insert_one(lead)
65
+ lead_id = str(result.inserted_id)
66
+
67
+ print(f"💾 Saved lead: {lead_id} | Event: {event_name} | Email: {email} | Phone: {phone}")
68
+
69
+ return lead_id
70
+
71
+ def get_leads(
72
+ self,
73
+ event_name: Optional[str] = None,
74
+ limit: int = 50,
75
+ skip: int = 0
76
+ ):
77
+ """Get leads with optional filtering"""
78
+ query = {}
79
+ if event_name:
80
+ query["event_name"] = event_name
81
+
82
+ leads = self.collection.find(query).sort("created_at", -1).skip(skip).limit(limit)
83
+ return list(leads)
84
+
85
+ def count_leads(self, event_name: Optional[str] = None) -> int:
86
+ """Count total leads"""
87
+ query = {}
88
+ if event_name:
89
+ query["event_name"] = event_name
90
+ return self.collection.count_documents(query)
main.py CHANGED
@@ -1,5 +1,5 @@
1
  from fastapi import FastAPI, UploadFile, File, Form, HTTPException
2
- from fastapi.responses import JSONResponse
3
  from fastapi.middleware.cors import CORSMiddleware
4
  from pydantic import BaseModel
5
  from typing import Optional, List, Dict
@@ -19,6 +19,11 @@ from pdf_parser import PDFIndexer
19
  from multimodal_pdf_parser import MultimodalPDFIndexer
20
  from conversation_service import ConversationService
21
  from tools_service import ToolsService
 
 
 
 
 
22
 
23
  # Initialize FastAPI app
24
  app = FastAPI(
@@ -107,6 +112,17 @@ print("✓ Conversation Service initialized")
107
  tools_service = ToolsService(base_url="https://www.festavenue.site")
108
  print("✓ Tools Service initialized (Function Calling enabled)")
109
 
 
 
 
 
 
 
 
 
 
 
 
110
  print("✓ Services initialized successfully")
111
 
112
 
@@ -135,6 +151,7 @@ class IndexResponse(BaseModel):
135
  class ChatRequest(BaseModel):
136
  message: str
137
  session_id: Optional[str] = None # Multi-turn conversation
 
138
  use_rag: bool = True
139
  top_k: int = 3
140
  system_message: Optional[str] = """Bạn là trợ lý AI chuyên biệt cho hệ thống quản lý sự kiện và bán vé.
@@ -680,59 +697,182 @@ async def get_stats():
680
  # ChatbotRAG Endpoints
681
  # ============================================
682
 
 
 
 
683
  @app.post("/chat", response_model=ChatResponse)
684
  async def chat(request: ChatRequest):
685
  """
686
- Multi-turn conversational chatbot với RAG + Function Calling
687
 
688
  Features:
689
- - ✅ Server-side session management (tự động tạo session_id)
690
- - ✅ Conversation history tracking
691
- - ✅ RAG context retrieval
692
- - ✅ Function calling (gọi API khi cần thông tin chi tiết)
 
 
693
 
694
  Flow:
695
- 1. Request đầu tiên: Không cần session_id BE tạo mới
696
- 2. Request tiếp theo: Gửi session_id từ response trước → BE nhớ context
 
 
697
 
698
- Example:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
699
  ```
700
- # Lần 1
701
- POST /chat { "message": "Tìm sự kiện hòa nhạc" }
702
- Response: { "session_id": "abc-123", "response": "..." }
703
 
704
- # Lần 2 (follow-up)
705
- POST /chat { "message": "Ngày tổ chức chính xác?", "session_id": "abc-123" }
706
- Response: { "session_id": "abc-123", "response": "..." } # Bot hiểu context
707
  ```
 
 
 
 
 
708
 
709
- Body Parameters:
710
- - message: User message (required)
711
- - session_id: Session ID cho multi-turn (optional, tự tạo nếu không có)
712
- - use_rag: Enable RAG retrieval (default: true)
713
- - enable_tools: Enable function calling (default: true)
714
- - top_k: Number of documents (default: 3)
715
- - temperature: LLM temperature (default: 0.7)
716
 
717
- Returns:
718
- - response: AI generated response
719
- - session_id: Session identifier (TRẢ VỀ trong mọi trường hợp)
720
- - context_used: Retrieved context documents
721
- - tool_calls: API calls made (if any)
722
- - timestamp: Response timestamp
723
- """
724
- # Import chat endpoint logic
725
- from chat_endpoint import chat_endpoint
726
 
727
- return await chat_endpoint(
 
 
 
 
 
 
 
 
 
 
728
  request=request,
729
  conversation_service=conversation_service,
 
 
730
  tools_service=tools_service,
731
  advanced_rag=advanced_rag,
732
  embedding_service=embedding_service,
733
  qdrant_service=qdrant_service,
734
  chat_history_collection=chat_history_collection,
735
- hf_token=hf_token
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
736
  )
737
 
738
 
@@ -775,6 +915,207 @@ async def get_conversation_history(session_id: str, include_metadata: bool = Fal
775
  }
776
 
777
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
778
  @app.post("/chat/clear-session")
779
  async def clear_chat_session(session_id: str):
780
  """
@@ -892,6 +1233,91 @@ async def add_document(request: AddDocumentRequest):
892
  raise HTTPException(status_code=500, detail=f"Error: {str(e)}")
893
 
894
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
895
  @app.post("/rag/search", response_model=List[SearchResponse])
896
  async def rag_search(
897
  query: str = Form(...),
 
1
  from fastapi import FastAPI, UploadFile, File, Form, HTTPException
2
+ from fastapi.responses import JSONResponse, StreamingResponse # Add StreamingResponse
3
  from fastapi.middleware.cors import CORSMiddleware
4
  from pydantic import BaseModel
5
  from typing import Optional, List, Dict
 
19
  from multimodal_pdf_parser import MultimodalPDFIndexer
20
  from conversation_service import ConversationService
21
  from tools_service import ToolsService
22
+ from intent_classifier import IntentClassifier # NEW
23
+ from scenario_engine import ScenarioEngine # NEW
24
+ from lead_storage_service import LeadStorageService # NEW
25
+ from hybrid_chat_endpoint import hybrid_chat_endpoint # NEW
26
+ from hybrid_chat_stream import hybrid_chat_stream # NEW: Streaming
27
 
28
  # Initialize FastAPI app
29
  app = FastAPI(
 
112
  tools_service = ToolsService(base_url="https://www.festavenue.site")
113
  print("✓ Tools Service initialized (Function Calling enabled)")
114
 
115
+ # Initialize Hybrid Chat Components
116
+ intent_classifier = IntentClassifier()
117
+ print("✓ Intent Classifier initialized")
118
+
119
+ scenario_engine = ScenarioEngine(scenarios_dir="scenarios")
120
+ print("✓ Scenario Engine initialized")
121
+
122
+ leads_collection = db["leads"]
123
+ lead_storage = LeadStorageService(leads_collection)
124
+ print("✓ Lead Storage Service initialized")
125
+
126
  print("✓ Services initialized successfully")
127
 
128
 
 
151
  class ChatRequest(BaseModel):
152
  message: str
153
  session_id: Optional[str] = None # Multi-turn conversation
154
+ user_id: Optional[str] = None # User identifier for session tracking
155
  use_rag: bool = True
156
  top_k: int = 3
157
  system_message: Optional[str] = """Bạn là trợ lý AI chuyên biệt cho hệ thống quản lý sự kiện và bán vé.
 
697
  # ChatbotRAG Endpoints
698
  # ============================================
699
 
700
+ # Import chat endpoint logic
701
+ from hybrid_chat_endpoint import hybrid_chat_endpoint
702
+
703
  @app.post("/chat", response_model=ChatResponse)
704
  async def chat(request: ChatRequest):
705
  """
706
+ Hybrid Conversational Chatbot: Scenario FSM + RAG
707
 
708
  Features:
709
+ - ✅ Scenario-based flows (giá vé, đặt vé kịch bản)
710
+ - ✅ RAG knowledge retrieval (PDF, documents)
711
+ - ✅ Mid-scenario RAG interruption (answer off-topic questions)
712
+ - ✅ Lead collection (email, phone MongoDB)
713
+ - ✅ Multi-turn conversations with state management
714
+ - ✅ Function calling (external API integration)
715
 
716
  Flow:
717
+ 1. User messageIntent classification
718
+ 2. Route to: Scenario FSM OR RAG OR Hybrid
719
+ 3. Execute flow + save state
720
+ 4. Save conversation history
721
 
722
+ Example 1 - Start Price Inquiry Scenario:
723
+ ```
724
+ POST /chat
725
+ {
726
+ "message": "giá vé bao nhiêu?",
727
+ "use_rag": true
728
+ }
729
+
730
+ Response:
731
+ {
732
+ "response": "Hello 👋 Bạn muốn xem giá của show nào để mình báo đúng nè?",
733
+ "session_id": "abc-123",
734
+ "mode": "scenario",
735
+ "scenario_active": true
736
+ }
737
  ```
 
 
 
738
 
739
+ Example 2 - Continue Scenario:
 
 
740
  ```
741
+ POST /chat
742
+ {
743
+ "message": "Show A",
744
+ "session_id": "abc-123"
745
+ }
746
 
747
+ Response:
748
+ {
749
+ "response": "Bạn đi 1 mình hay đi nhóm...",
750
+ "mode": "scenario",
751
+ "scenario_active": true
752
+ }
753
+ ```
754
 
755
+ Example 3 - Mid-scenario RAG Question:
756
+ ```
757
+ POST /chat
758
+ {
759
+ "message": "sự kiện mấy giờ?",
760
+ "session_id": "abc-123"
761
+ }
762
+ # Bot answers from RAG, then resumes scenario
763
+ ```
764
 
765
+ Example 4 - Pure RAG Query:
766
+ ```
767
+ POST /chat
768
+ {
769
+ "message": "địa điểm sự kiện ở đâu?",
770
+ "use_rag": true
771
+ }
772
+ # Normal RAG response (không trigger scenario)
773
+ ```
774
+ """
775
+ return await hybrid_chat_endpoint(
776
  request=request,
777
  conversation_service=conversation_service,
778
+ intent_classifier=intent_classifier,
779
+ scenario_engine=scenario_engine,
780
  tools_service=tools_service,
781
  advanced_rag=advanced_rag,
782
  embedding_service=embedding_service,
783
  qdrant_service=qdrant_service,
784
  chat_history_collection=chat_history_collection,
785
+ hf_token=hf_token,
786
+ lead_storage=lead_storage
787
+ )
788
+
789
+
790
+ @app.post("/chat/stream")
791
+ async def chat_stream(request: ChatRequest):
792
+ """
793
+ Streaming Chat Endpoint (SSE - Server-Sent Events)
794
+
795
+ Real-time token-by-token response display
796
+
797
+ Features:
798
+ - ✅ Real-time "typing" effect
799
+ - ✅ Status updates (thinking, searching)
800
+ - ✅ Scenario: Simulated streaming (smooth typing)
801
+ - ✅ RAG: Real LLM streaming
802
+ - ✅ HTTP/2 compatible
803
+
804
+ Event Types:
805
+ - status: Bot status ("Đang suy nghĩ...", "Đang tìm kiếm...")
806
+ - token: Text chunks
807
+ - metadata: Session ID, context info
808
+ - done: Completion signal
809
+ - error: Error messages
810
+
811
+ Example - JavaScript Client:
812
+ ```javascript
813
+ const response = await fetch('/chat/stream', {
814
+ method: 'POST',
815
+ headers: { 'Content-Type': 'application/json' },
816
+ body: JSON.stringify({
817
+ message: "giá vé bao nhiêu?",
818
+ use_rag: true
819
+ })
820
+ });
821
+
822
+ const reader = response.body.getReader();
823
+ const decoder = new TextDecoder();
824
+
825
+ while (true) {
826
+ const {done, value} = await reader.read();
827
+ if (done) break;
828
+
829
+ const chunk = decoder.decode(value);
830
+ const lines = chunk.split('\n\n');
831
+
832
+ for (const line of lines) {
833
+ if (line.startsWith('event: token')) {
834
+ const data = line.split('data: ')[1];
835
+ displayToken(data); // Append to UI
836
+ }
837
+ else if (line.startsWith('event: done')) {
838
+ console.log('Stream complete');
839
+ }
840
+ }
841
+ }
842
+ ```
843
+
844
+ Example - EventSource (simpler but less control):
845
+ ```javascript
846
+ // Note: EventSource doesn't support POST, need to use fetch
847
+ const eventSource = new EventSource('/chat/stream?message=hello');
848
+
849
+ eventSource.addEventListener('token', (e) => {
850
+ displayToken(e.data);
851
+ });
852
+
853
+ eventSource.addEventListener('done', (e) => {
854
+ eventSource.close();
855
+ });
856
+ ```
857
+ """
858
+ return StreamingResponse(
859
+ hybrid_chat_stream(
860
+ request=request,
861
+ conversation_service=conversation_service,
862
+ intent_classifier=intent_classifier,
863
+ scenario_engine=scenario_engine,
864
+ advanced_rag=advanced_rag,
865
+ embedding_service=embedding_service,
866
+ qdrant_service=qdrant_service,
867
+ hf_token=hf_token,
868
+ lead_storage=lead_storage
869
+ ),
870
+ media_type="text/event-stream",
871
+ headers={
872
+ "Cache-Control": "no-cache",
873
+ "Connection": "keep-alive",
874
+ "X-Accel-Buffering": "no" # Disable nginx buffering
875
+ }
876
  )
877
 
878
 
 
915
  }
916
 
917
 
918
+ @app.get("/chat/sessions")
919
+ async def list_sessions(
920
+ limit: int = 50,
921
+ skip: int = 0,
922
+ sort_by: str = "updated_at",
923
+ user_id: Optional[str] = None # NEW: Filter by user
924
+ ):
925
+ """
926
+ List all conversation sessions
927
+
928
+ Query Parameters:
929
+ limit: Maximum sessions to return (default: 50, max: 100)
930
+ skip: Number of sessions to skip for pagination (default: 0)
931
+ sort_by: Field to sort by - 'created_at' or 'updated_at' (default: updated_at)
932
+ user_id: Filter sessions by user_id (optional)
933
+
934
+ Returns:
935
+ List of sessions with metadata and message counts
936
+
937
+ Examples:
938
+ ```
939
+ GET /chat/sessions # All sessions
940
+ GET /chat/sessions?user_id=user_123 # Only user_123's sessions
941
+ GET /chat/sessions?limit=20&skip=0&sort_by=updated_at
942
+ ```
943
+ """
944
+ # Validate limit
945
+ if limit > 100:
946
+ limit = 100
947
+ if limit < 1:
948
+ limit = 1
949
+
950
+ # Validate sort_by
951
+ if sort_by not in ["created_at", "updated_at"]:
952
+ raise HTTPException(
953
+ status_code=400,
954
+ detail="sort_by must be 'created_at' or 'updated_at'"
955
+ )
956
+
957
+ sessions = conversation_service.list_sessions(
958
+ limit=limit,
959
+ skip=skip,
960
+ sort_by=sort_by,
961
+ descending=True,
962
+ user_id=user_id # NEW: Pass user_id filter
963
+ )
964
+
965
+ total_sessions = conversation_service.count_sessions(user_id=user_id) # NEW: Count with filter
966
+
967
+ return {
968
+ "total": total_sessions,
969
+ "limit": limit,
970
+ "skip": skip,
971
+ "count": len(sessions),
972
+ "user_id": user_id, # NEW: Include filter in response
973
+ "sessions": sessions
974
+ }
975
+
976
+
977
+ @app.get("/scenarios")
978
+ async def list_scenarios():
979
+ """
980
+ Get list of all available scenarios for proactive chat
981
+
982
+ FE use case:
983
+ - Random pick scenario để bắt đầu chat chủ động
984
+ - Hiển thị menu các scenario available
985
+
986
+ Returns:
987
+ List of scenarios with metadata
988
+
989
+ Example:
990
+ ```
991
+ GET /scenarios
992
+
993
+ Response:
994
+ {
995
+ "scenarios": [
996
+ {
997
+ "scenario_id": "price_inquiry",
998
+ "name": "Hỏi giá vé",
999
+ "description": "Tư vấn giá vé và gửi PDF",
1000
+ "triggers": ["giá vé", "bao nhiêu"],
1001
+ "category": "sales"
1002
+ },
1003
+ ...
1004
+ ]
1005
+ }
1006
+ ```
1007
+ """
1008
+ scenarios_list = []
1009
+
1010
+ for scenario_id, scenario_data in scenario_engine.scenarios.items():
1011
+ scenarios_list.append({
1012
+ "scenario_id": scenario_id,
1013
+ "name": scenario_data.get("name", scenario_id),
1014
+ "description": scenario_data.get("description", ""),
1015
+ "triggers": scenario_data.get("triggers", []),
1016
+ "category": scenario_data.get("category", "general"),
1017
+ "priority": scenario_data.get("priority", "normal"),
1018
+ "estimated_duration": scenario_data.get("estimated_duration", "unknown")
1019
+ })
1020
+
1021
+ return {
1022
+ "total": len(scenarios_list),
1023
+ "scenarios": scenarios_list
1024
+ }
1025
+
1026
+
1027
+ @app.post("/scenarios/{scenario_id}/start")
1028
+ async def start_scenario_proactive(
1029
+ scenario_id: str,
1030
+ request_body: Optional[Dict] = None
1031
+ ):
1032
+ """
1033
+ Start a scenario proactively with optional initial data
1034
+
1035
+ Use cases:
1036
+ 1. FE picks random scenario
1037
+ 2. BE triggers scenario based on user action (after purchase, exit intent, etc.)
1038
+ 3. Inject context data (event_name, mood, etc.)
1039
+
1040
+ Example 1 - Simple start:
1041
+ ```
1042
+ POST /scenarios/price_inquiry/start
1043
+ {}
1044
+
1045
+ Response:
1046
+ {
1047
+ "session_id": "abc-123",
1048
+ "message": "Hello 👋 Bạn muốn xem giá..."
1049
+ }
1050
+ ```
1051
+
1052
+ Example 2 - With initial data (post-event feedback):
1053
+ ```
1054
+ POST /scenarios/post_event_feedback/start
1055
+ {
1056
+ "initial_data": {
1057
+ "event_name": "Hòa Nhạc Mùa Xuân",
1058
+ "event_date": "2024-11-29",
1059
+ "event_id": "evt_123"
1060
+ },
1061
+ "session_id": "existing-session", // optional
1062
+ "user_id": "user_456" // optional
1063
+ }
1064
+
1065
+ Response:
1066
+ {
1067
+ "session_id": "abc-123",
1068
+ "message": "Cảm ơn bạn đã tham dự *Hòa Nhạc Mùa Xuân* hôm qua!"
1069
+ }
1070
+ ```
1071
+
1072
+ Example 3 - Mood recommendation:
1073
+ ```
1074
+ POST /scenarios/mood_recommendation/start
1075
+ {
1076
+ "initial_data": {
1077
+ "mood": "chill",
1078
+ "preferred_genre": "acoustic"
1079
+ }
1080
+ }
1081
+ ```
1082
+ """
1083
+ # Parse request body
1084
+ body = request_body or {}
1085
+ initial_data = body.get("initial_data", {})
1086
+ session_id = body.get("session_id")
1087
+ user_id = body.get("user_id")
1088
+
1089
+ # Create or use existing session
1090
+ if not session_id:
1091
+ session_id = conversation_service.create_session(
1092
+ metadata={"started_by": "proactive", "scenario": scenario_id},
1093
+ user_id=user_id
1094
+ )
1095
+
1096
+ # Start scenario with initial data
1097
+ result = scenario_engine.start_scenario(scenario_id, initial_data)
1098
+
1099
+ if result.get("new_state"):
1100
+ conversation_service.set_scenario_state(session_id, result["new_state"])
1101
+
1102
+ # Save bot message to history
1103
+ conversation_service.add_message(
1104
+ session_id,
1105
+ "assistant",
1106
+ result["message"],
1107
+ metadata={"proactive": True, "scenario": scenario_id, "initial_data": initial_data}
1108
+ )
1109
+
1110
+ return {
1111
+ "session_id": session_id,
1112
+ "scenario_id": scenario_id,
1113
+ "message": result["message"],
1114
+ "scenario_active": True,
1115
+ "proactive": True
1116
+ }
1117
+
1118
+
1119
  @app.post("/chat/clear-session")
1120
  async def clear_chat_session(session_id: str):
1121
  """
 
1233
  raise HTTPException(status_code=500, detail=f"Error: {str(e)}")
1234
 
1235
 
1236
+ @app.post("/documents/upload/pdf")
1237
+ async def upload_pdf(
1238
+ file: UploadFile = File(...),
1239
+ metadata: Optional[str] = Form(None)
1240
+ ):
1241
+ """
1242
+ Upload PDF file and index into knowledge base
1243
+
1244
+ Features:
1245
+ - Extracts text from PDF
1246
+ - Detects image URLs in text/markdown
1247
+ - Chunks content intelligently
1248
+ - Indexes all chunks into Qdrant for RAG
1249
+
1250
+ Args:
1251
+ file: PDF file to upload
1252
+ metadata: Optional JSON string with metadata (title, author, etc.)
1253
+
1254
+ Returns:
1255
+ Success status, document ID, and indexing stats
1256
+
1257
+ Example:
1258
+ ```bash
1259
+ curl -X POST http://localhost:8000/documents/upload/pdf \
1260
+ -F "file=@document.pdf" \
1261
+ -F 'metadata={"title": "User Guide", "category": "documentation"}'
1262
+ ```
1263
+ """
1264
+ try:
1265
+ # Validate file type
1266
+ if not file.filename.endswith('.pdf'):
1267
+ raise HTTPException(
1268
+ status_code=400,
1269
+ detail="Only PDF files are supported"
1270
+ )
1271
+
1272
+ # Read file bytes
1273
+ pdf_bytes = await file.read()
1274
+
1275
+ # Parse metadata if provided
1276
+ import json
1277
+ doc_metadata = {}
1278
+ if metadata:
1279
+ try:
1280
+ doc_metadata = json.loads(metadata)
1281
+ except json.JSONDecodeError:
1282
+ raise HTTPException(
1283
+ status_code=400,
1284
+ detail="Invalid metadata JSON format"
1285
+ )
1286
+
1287
+ # Generate unique document ID
1288
+ from bson import ObjectId
1289
+ document_id = str(ObjectId())
1290
+
1291
+ # Add upload timestamp
1292
+ doc_metadata['uploaded_at'] = datetime.utcnow().isoformat()
1293
+ doc_metadata['original_filename'] = file.filename
1294
+
1295
+ # Index PDF using multimodal parser
1296
+ result = multimodal_pdf_indexer.index_pdf_bytes(
1297
+ pdf_bytes=pdf_bytes,
1298
+ document_id=document_id,
1299
+ filename=file.filename,
1300
+ document_metadata=doc_metadata
1301
+ )
1302
+
1303
+ return {
1304
+ "success": True,
1305
+ "document_id": document_id,
1306
+ "filename": file.filename,
1307
+ "chunks_indexed": result['chunks_indexed'],
1308
+ "images_found": result.get('images_found', 0),
1309
+ "message": f"PDF uploaded and indexed: {result['chunks_indexed']} chunks, {result.get('images_found', 0)} image URLs found"
1310
+ }
1311
+
1312
+ except HTTPException:
1313
+ raise
1314
+ except Exception as e:
1315
+ raise HTTPException(
1316
+ status_code=500,
1317
+ detail=f"Error processing PDF: {str(e)}"
1318
+ )
1319
+
1320
+
1321
  @app.post("/rag/search", response_model=List[SearchResponse])
1322
  async def rag_search(
1323
  query: str = Form(...),
scenario_engine.py ADDED
@@ -0,0 +1,329 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Scenario Engine for FSM-based Conversations
3
+ Executes multi-turn scripted conversations from JSON definitions
4
+ """
5
+ import json
6
+ import os
7
+ import re
8
+ from typing import Dict, Optional, List, Any
9
+ from datetime import datetime
10
+
11
+
12
+ class ScenarioEngine:
13
+ """
14
+ Execute scenario-based conversations
15
+ Load scenarios from JSON and manage step-by-step flow
16
+ """
17
+
18
+ def __init__(self, scenarios_dir: str = "scenarios"):
19
+ self.scenarios_dir = scenarios_dir
20
+ self.scenarios = self._load_scenarios()
21
+
22
+ def _load_scenarios(self) -> Dict[str, Dict]:
23
+ """Load all scenario JSON files"""
24
+ scenarios = {}
25
+
26
+ if not os.path.exists(self.scenarios_dir):
27
+ print(f"⚠ Scenarios directory not found: {self.scenarios_dir}")
28
+ return scenarios
29
+
30
+ for filename in os.listdir(self.scenarios_dir):
31
+ if filename.endswith('.json'):
32
+ filepath = os.path.join(self.scenarios_dir, filename)
33
+ with open(filepath, 'r', encoding='utf-8') as f:
34
+ scenario = json.load(f)
35
+ scenario_id = scenario.get('scenario_id')
36
+ if scenario_id:
37
+ scenarios[scenario_id] = scenario
38
+ print(f"✓ Loaded scenario: {scenario_id}")
39
+
40
+ return scenarios
41
+
42
+ def start_scenario(self, scenario_id: str, initial_data: Dict = None) -> Dict[str, Any]:
43
+ """
44
+ Start a new scenario with optional initial data
45
+
46
+ Args:
47
+ scenario_id: Scenario to start
48
+ initial_data: External data to inject (event_name, mood, etc.)
49
+
50
+ Returns:
51
+ {
52
+ "message": str,
53
+ "new_state": {...},
54
+ "end_scenario": bool
55
+ }
56
+ """
57
+ if scenario_id not in self.scenarios:
58
+ return {
59
+ "message": "Xin lỗi, tính năng này đang được cập nhật.",
60
+ "new_state": {},
61
+ "end_scenario": True
62
+ }
63
+
64
+ scenario = self.scenarios[scenario_id]
65
+ first_step = scenario['steps'][0]
66
+
67
+ # Initialize with external data
68
+ scenario_data = initial_data.copy() if initial_data else {}
69
+
70
+ # Build first message with initial data
71
+ message = self._build_message(first_step, scenario_data, None)
72
+
73
+ return {
74
+ "message": message,
75
+ "new_state": {
76
+ "active_scenario": scenario_id,
77
+ "scenario_step": 1,
78
+ "scenario_data": scenario_data,
79
+ "last_activity": datetime.utcnow().isoformat()
80
+ },
81
+ "end_scenario": False
82
+ }
83
+
84
+ def next_step(
85
+ self,
86
+ scenario_id: str,
87
+ current_step: int,
88
+ user_input: str,
89
+ scenario_data: Dict,
90
+ rag_service: Optional[Any] = None
91
+ ) -> Dict[str, Any]:
92
+ """
93
+ Process user input and move to next step
94
+
95
+ Args:
96
+ scenario_id: Active scenario ID
97
+ current_step: Current step number
98
+ user_input: User's message
99
+ scenario_data: Data collected so far
100
+ rag_service: Optional RAG service for hybrid queries
101
+
102
+ Returns:
103
+ {
104
+ "message": str,
105
+ "new_state": {...} | None,
106
+ "end_scenario": bool,
107
+ "action": str | None
108
+ }
109
+ """
110
+ if scenario_id not in self.scenarios:
111
+ return {"message": "Error: Scenario not found", "end_scenario": True}
112
+
113
+ scenario = self.scenarios[scenario_id]
114
+ current_step_config = self._get_step(scenario, current_step)
115
+
116
+ if not current_step_config:
117
+ return {"message": "Error: Step not found", "end_scenario": True}
118
+
119
+ # Validate input if needed
120
+ expected_type = current_step_config.get('expected_input_type')
121
+ if expected_type:
122
+ validation_error = self._validate_input(user_input, expected_type)
123
+ if validation_error:
124
+ return {
125
+ "message": validation_error,
126
+ "new_state": None, # Don't change state
127
+ "end_scenario": False
128
+ }
129
+
130
+ # Handle branching
131
+ if 'branches' in current_step_config:
132
+ branch_result = self._handle_branches(
133
+ current_step_config['branches'],
134
+ user_input,
135
+ scenario_data
136
+ )
137
+ next_step_id = branch_result['next_step']
138
+ scenario_data.update(branch_result.get('save_data', {}))
139
+ else:
140
+ next_step_id = current_step_config.get('next_step')
141
+
142
+ # Save user input
143
+ input_field = current_step_config.get('save_as', f'step_{current_step}_input')
144
+ scenario_data[input_field] = user_input
145
+
146
+ # Get next step config
147
+ next_step_config = self._get_step(scenario, next_step_id)
148
+ if not next_step_config:
149
+ return {"message": "Cảm ơn bạn!", "end_scenario": True}
150
+
151
+ # Check if scenario ends
152
+ if next_step_config.get('end_scenario'):
153
+ return {
154
+ "message": next_step_config['bot_message'],
155
+ "new_state": None,
156
+ "end_scenario": True,
157
+ "action": next_step_config.get('action')
158
+ }
159
+
160
+ # Build next message
161
+ message = self._build_message(
162
+ next_step_config,
163
+ scenario_data,
164
+ rag_service
165
+ )
166
+
167
+ return {
168
+ "message": message,
169
+ "new_state": {
170
+ "active_scenario": scenario_id,
171
+ "scenario_step": next_step_id,
172
+ "scenario_data": scenario_data,
173
+ "last_activity": datetime.utcnow().isoformat()
174
+ },
175
+ "end_scenario": False,
176
+ "action": next_step_config.get('action')
177
+ }
178
+
179
+ def _get_step(self, scenario: Dict, step_id: int) -> Optional[Dict]:
180
+ """Get step config by ID"""
181
+ for step in scenario['steps']:
182
+ if step['id'] == step_id:
183
+ return step
184
+ return None
185
+
186
+ def _validate_input(self, user_input: str, expected_type: str) -> Optional[str]:
187
+ """
188
+ Validate user input
189
+ Returns error message or None if valid
190
+ """
191
+ if expected_type == 'email':
192
+ if not re.match(r'^[\w\.-]+@[\w\.-]+\.\w+$', user_input):
193
+ return "Email không hợp lệ. Vui lòng nhập lại (vd: ten@email.com)"
194
+
195
+ elif expected_type == 'phone':
196
+ # Simple Vietnamese phone validation
197
+ clean = re.sub(r'[^\d]', '', user_input)
198
+ if len(clean) < 9 or len(clean) > 11:
199
+ return "Số điện thoại không hợp lệ. Vui lòng nhập lại (10-11 số)"
200
+
201
+ return None
202
+
203
+ def _handle_branches(
204
+ self,
205
+ branches: Dict,
206
+ user_input: str,
207
+ scenario_data: Dict
208
+ ) -> Dict:
209
+ """
210
+ Handle branch logic
211
+
212
+ Returns:
213
+ {"next_step": int, "save_data": {...}}
214
+ """
215
+ user_input_lower = user_input.lower().strip()
216
+
217
+ for branch_name, branch_config in branches.items():
218
+ if branch_name == 'default':
219
+ continue
220
+
221
+ patterns = branch_config.get('patterns', [])
222
+ for pattern in patterns:
223
+ if pattern.lower() in user_input_lower:
224
+ return {
225
+ "next_step": branch_config['next_step'],
226
+ "save_data": branch_config.get('save_data', {})
227
+ }
228
+
229
+ # Default branch
230
+ default_name = branches.get('default_branch', list(branches.keys())[0])
231
+ default_branch = branches.get(default_name, list(branches.values())[0])
232
+
233
+ return {
234
+ "next_step": default_branch['next_step'],
235
+ "save_data": default_branch.get('save_data', {})
236
+ }
237
+
238
+ def _build_message(
239
+ self,
240
+ step_config: Dict,
241
+ scenario_data: Dict,
242
+ rag_service: Optional[Any]
243
+ ) -> str:
244
+ """
245
+ Build bot message with 3-layer data resolution:
246
+ 1. scenario_data (initial + user inputs)
247
+ 2. RAG results (if rag_query_template exists)
248
+ 3. Merged template vars
249
+ """
250
+ # Layer 1: Base data (initial + user inputs)
251
+ template_data = {
252
+ 'event_name': scenario_data.get('event_name', 'sự kiện này'),
253
+ 'mood': scenario_data.get('mood', ''),
254
+ 'interest': scenario_data.get('interest', ''),
255
+ **scenario_data # Include all scenario data
256
+ }
257
+
258
+ # Layer 2: RAG query (if specified)
259
+ if 'rag_query_template' in step_config:
260
+ try:
261
+ # Build query from template
262
+ query = step_config['rag_query_template'].format(**template_data)
263
+
264
+ if rag_service:
265
+ # Execute RAG search
266
+ results = self._execute_rag_query(query, rag_service)
267
+ template_data['rag_results'] = results
268
+ else:
269
+ # Fallback if no RAG service
270
+ template_data['rag_results'] = "(Đang tải thông tin...)"
271
+ except Exception as e:
272
+ print(f"⚠ RAG query error: {e}")
273
+ template_data['rag_results'] = ""
274
+
275
+ # Layer 3: Build final message
276
+ if 'bot_message_template' in step_config:
277
+ try:
278
+ return step_config['bot_message_template'].format(**template_data)
279
+ except KeyError as e:
280
+ print(f"⚠ Template var missing: {e}")
281
+ # Fallback to message without placeholders
282
+ return step_config.get('bot_message', step_config['bot_message_template'])
283
+
284
+ return step_config.get('bot_message', '')
285
+
286
+ def _execute_rag_query(self, query: str, rag_service: Any) -> str:
287
+ """
288
+ Execute RAG query and format results
289
+
290
+ Returns formatted string of top results
291
+ """
292
+ try:
293
+ # Simple search (we'll integrate with actual RAG later)
294
+ # For now, return placeholder
295
+ return f"[Kết quả tìm kiếm cho: {query}]\n1. Sự kiện A\n2. Sự kiện B"
296
+ except Exception as e:
297
+ print(f"⚠ RAG execution error: {e}")
298
+ return ""
299
+
300
+
301
+ # Test
302
+ if __name__ == "__main__":
303
+ engine = ScenarioEngine()
304
+
305
+ print("\nTest: Start price_inquiry scenario")
306
+ result = engine.start_scenario("price_inquiry")
307
+ print(f"Bot: {result['message']}")
308
+ print(f"State: {result['new_state']}")
309
+
310
+ print("\nTest: User answers 'Show A'")
311
+ state = result['new_state']
312
+ result = engine.next_step(
313
+ scenario_id=state['active_scenario'],
314
+ current_step=state['scenario_step'],
315
+ user_input="Show A",
316
+ scenario_data=state['scenario_data']
317
+ )
318
+ print(f"Bot: {result['message']}")
319
+
320
+ print("\nTest: User answers 'nhóm'")
321
+ state = result['new_state']
322
+ result = engine.next_step(
323
+ scenario_id=state['active_scenario'],
324
+ current_step=state['scenario_step'],
325
+ user_input="nhóm 5 người",
326
+ scenario_data=state['scenario_data']
327
+ )
328
+ print(f"Bot: {result['message']}")
329
+ print(f"Data collected: {result['new_state']['scenario_data']}")
scenarios/event_recommendation.json ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "scenario_id": "event_recommendation",
3
+ "name": "Gợi ý sự kiện cá nhân hoá",
4
+ "description": "Gợi ý sự kiện dựa trên sở thích và mood của user",
5
+ "triggers": ["gợi ý", "event nào hợp", "nên đi show nào"],
6
+ "steps": [
7
+ {
8
+ "id": 1,
9
+ "bot_message": "Hello! 👋 Bạn muốn tìm sự kiện theo vibe gì nè? Chill – Sôi động – Hài – Workshop?",
10
+ "expected_input_type": "interest_tag",
11
+ "next_step": 2
12
+ },
13
+ {
14
+ "id": 2,
15
+ "bot_message_template": "Mình hiểu rồi! Để mình tìm sự kiện hợp vibe **{interest_tag}** nha",
16
+ "rag_query_template": "sự kiện phù hợp với {interest_tag}",
17
+ "next_step": 3
18
+ },
19
+ {
20
+ "id": 3,
21
+ "bot_message_template": "Đây là 2–3 event hợp với bạn nè:\n{rag_results}\nBạn có muốn xem chi tiết event nào không?",
22
+ "expected_input_type": "event_name",
23
+ "next_step": 4
24
+ },
25
+ {
26
+ "id": 4,
27
+ "bot_message": "Bạn cần xem: giá – line-up – địa điểm – hay thời gian của sự kiện?",
28
+ "expected_input_type": "choice",
29
+ "branches": {
30
+ "price": {
31
+ "patterns": ["giá", "price"],
32
+ "next_step": 5
33
+ },
34
+ "lineup": {
35
+ "patterns": ["lineup", "line-up", "nghệ sĩ"],
36
+ "next_step": 6
37
+ },
38
+ "location": {
39
+ "patterns": ["địa điểm", "ở đâu", "location"],
40
+ "next_step": 7
41
+ },
42
+ "time": {
43
+ "patterns": ["thời gian", "khi nào", "date", "time"],
44
+ "next_step": 8
45
+ }
46
+ },
47
+ "default_branch": "price"
48
+ },
49
+ {
50
+ "id": 5,
51
+ "bot_message_template": "Giá vé event {event_name} nè:\n{rag_results}",
52
+ "rag_query_template": "giá vé {event_name}",
53
+ "next_step": 9
54
+ },
55
+ {
56
+ "id": 6,
57
+ "bot_message_template": "Lineup / nghệ sĩ của event {event_name} là:\n{rag_results}",
58
+ "rag_query_template": "lineup {event_name}",
59
+ "next_step": 9
60
+ },
61
+ {
62
+ "id": 7,
63
+ "bot_message_template": "Địa điểm tổ chức event {event_name}:\n{rag_results}",
64
+ "rag_query_template": "địa điểm {event_name}",
65
+ "next_step": 9
66
+ },
67
+ {
68
+ "id": 8,
69
+ "bot_message_template": "Thời gian / lịch diễn của event {event_name}:\n{rag_results}",
70
+ "rag_query_template": "thời gian {event_name}",
71
+ "next_step": 9
72
+ },
73
+ {
74
+ "id": 9,
75
+ "bot_message": "Bạn muốn mình lưu event này vào email để bạn theo dõi dễ hơn không?",
76
+ "expected_input_type": "choice",
77
+ "branches": {
78
+ "yes": {
79
+ "patterns": ["có", "yes", "ok"],
80
+ "next_step": 10
81
+ },
82
+ "no": {
83
+ "patterns": ["không", "no"],
84
+ "next_step": 11
85
+ }
86
+ },
87
+ "default_branch": "no"
88
+ },
89
+ {
90
+ "id": 10,
91
+ "bot_message": "Cho mình xin email để gửi bản tóm tắt event kèm link mua vé?",
92
+ "expected_input_type": "email",
93
+ "validation": "email",
94
+ "action": "send_event_summary_email",
95
+ "next_step": 12
96
+ },
97
+ {
98
+ "id": 11,
99
+ "bot_message": "Okie, bạn cần event theo vibe khác không nè? 😄",
100
+ "end_scenario": true
101
+ },
102
+ {
103
+ "id": 12,
104
+ "bot_message": "Đã gửi email cho bạn nha! ✨",
105
+ "end_scenario": true
106
+ }
107
+ ]
108
+ }
scenarios/exit_intent_rescue.json ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "scenario_id": "exit_intent_rescue",
3
+ "name": "Giữ chân khi user chuẩn bị thoát",
4
+ "description": "Kịch bản gửi ưu đãi nhẹ để ngăn user thoát",
5
+ "triggers": ["exit_intent"],
6
+ "steps": [
7
+ {
8
+ "id": 1,
9
+ "bot_message": "Khoan đã 😭 Trước khi bạn rời đi… chúng mình sắp có mã giảm 5% cho bất kỳ vé nào. Bạn muốn nhận không?",
10
+ "expected_input_type": "choice",
11
+ "branches": {
12
+ "yes": {
13
+ "patterns": ["có", "yes", "ok", "muốn", "quan tâm"],
14
+ "next_step": 2
15
+ },
16
+ "no": {
17
+ "patterns": ["không", "no", "ko", "chưa hợp", "không thích"],
18
+ "next_step": 3
19
+ }
20
+ },
21
+ "default_branch": "no"
22
+ },
23
+ {
24
+ "id": 2,
25
+ "bot_message": "Cho mình xin email để gửi mã nhé!",
26
+ "expected_input_type": "email",
27
+ "validation": "email",
28
+ "action": "send_coupon_email",
29
+ "next_step": 4
30
+ },
31
+ {
32
+ "id": 3,
33
+ "bot_message": "Okie, nếu cần gì bạn cứ gọi mình nha 💛",
34
+ "end_scenario": true
35
+ }
36
+
37
+ ]
38
+ }
scenarios/mini_survey_lead.json ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "scenario_id": "mini_survey_lead",
3
+ "name": "Mini survey thu lead",
4
+ "description": "Kịch bản khảo sát 3 câu hỏi để thu email nhẹ nhàng",
5
+ "triggers": ["survey", "khảo sát", "quiz"],
6
+ "steps": [
7
+ {
8
+ "id": 1,
9
+ "bot_message": "Đi event kiểu gì hợp vibe bạn nhất nè? 😆 (Chọn 1)\n• Chill\n• Sôi động\n• Hài\n• Học hỏi",
10
+ "expected_input_type": "choice",
11
+ "save_data": {"preference": "{user_choice}"},
12
+ "next_step": 2
13
+ },
14
+ {
15
+ "id": 2,
16
+ "bot_message": "Bạn thường đi event: 1 mình – bạn thân – nhóm?",
17
+ "expected_input_type": "choice",
18
+ "save_data": {"group_type": "{user_choice}"},
19
+ "next_step": 3
20
+ },
21
+ {
22
+ "id": 3,
23
+ "bot_message": "Bạn thích mức giá nào để thoải mái nhất? (<500k / 500–1tr / >1tr)",
24
+ "expected_input_type": "choice",
25
+ "save_data": {"budget": "{user_choice}"},
26
+ "next_step": 4
27
+ },
28
+ {
29
+ "id": 4,
30
+ "bot_message": "Doneee! 🎉 Mình có sự kiện này 'Gợi ý sự kiện hợp vibe 2025' tổng hợp theo câu trả lời của bạn. Gửi email để nhận nhé?",
31
+ "expected_input_type": "email",
32
+ "validation": "email",
33
+ "action": "send_survey_pdf",
34
+ "next_step": 5
35
+ }
36
+ ]
37
+ }
scenarios/mood_recommendation.json ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "scenario_id": "mood_recommendation",
3
+ "name": "Gợi ý theo mood",
4
+ "description": "Gợi ý sự kiện theo tâm trạng hiện tại",
5
+ "triggers": ["chán", "muốn đi đâu", "gợi ý mood"],
6
+ "steps": [
7
+ {
8
+ "id": 1,
9
+ "bot_message": "Mood hôm nay của bạn là gì nè? 😊 (Chill / Sôi động / Muốn cười / Muốn học hỏi)",
10
+ "expected_input_type": "interest_tag",
11
+ "save_data": {"mood": "{user_choice}"},
12
+ "next_step": 2
13
+ },
14
+ {
15
+ "id": 2,
16
+ "bot_message_template": "Để mình tìm event hợp mood **{mood}** của bạn nha 🔍",
17
+ "rag_query_template": "sự kiện hợp mood {mood}",
18
+ "next_step": 3
19
+ },
20
+ {
21
+ "id": 3,
22
+ "bot_message_template": "Có mấy event hợp mood bạn nè:\n{rag_results}\nBạn muốn xem chi tiết event nào?",
23
+ "expected_input_type": "event_name",
24
+ "next_step": 4
25
+ },
26
+ {
27
+ "id": 4,
28
+ "bot_message": "Bạn muốn nhận gợi ý mỗi tuần theo mood không?",
29
+ "expected_input_type": "choice",
30
+ "branches": {
31
+ "yes": {
32
+ "patterns": ["có", "yes"],
33
+ "next_step": 5
34
+ },
35
+ "no": {
36
+ "patterns": ["không"],
37
+ "next_step": 6
38
+ }
39
+ }
40
+ },
41
+ {
42
+ "id": 5,
43
+ "bot_message": "Cho mình email để gửi gợi ý hàng tuần nhé!",
44
+ "expected_input_type": "email",
45
+ "validation": "email",
46
+ "action": "save_mood_subscription",
47
+ "next_step": 6
48
+ },
49
+ {
50
+ "id": 6,
51
+ "bot_message": "Có dịp rồi hãy quay lại để xem các sự kiện khác nhé! 😊",
52
+ "end_scenario": true
53
+ }
54
+ ]
55
+ }
scenarios/post_event_feedback.json ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "scenario_id": "post_event_feedback",
3
+ "name": "Hậu sự kiện – Thu thập feedback & nuôi lại lead",
4
+ "description": "Kịch bản chăm sóc khách sau sự kiện: xin đánh giá, phân loại cảm xúc, gợi ý sự kiện phù hợp và thu lead dài hạn.",
5
+ "triggers": ["feedback", "đánh giá", "hậu sự kiện", "event review", "review", "đi sự kiện xong"],
6
+ "steps": [
7
+ {
8
+ "id": 1,
9
+ "bot_message_template": "Hello 👋 Cảm ơn bạn đã tham dự *{event_name}* hôm qua! Bạn thấy trải nghiệm tổng thể như thế nào?",
10
+ "expected_input_type": "rating",
11
+ "timeout_seconds": 20,
12
+ "timeout_message": "Bạn rảnh gửi mình 1–2 câu đánh giá nhé, để team cải thiện ạ 🙏",
13
+ "rag_query_template": "thông tin về {event_name}",
14
+ "next_step": 2
15
+ },
16
+ {
17
+ "id": 2,
18
+ "bot_message": "Cảm ơn bạn! Nếu tiện, cho mình hỏi thêm → Điều gì bạn thích nhất ở sự kiện?",
19
+ "expected_input_type": "text",
20
+ "save_data": {"liked_point": "@user_input"},
21
+ "next_step": 3
22
+ },
23
+ {
24
+ "id": 3,
25
+ "bot_message": "Cảm ơn bạn 🙏 Còn điều gì bạn nghĩ chúng mình có thể cải thiện hơn ở lần sau?",
26
+ "expected_input_type": "text",
27
+ "save_data": {"improve_suggestion": "@user_input"},
28
+ "next_step": 4
29
+ },
30
+ {
31
+ "id": 4,
32
+ "bot_message": "Cho mình hỏi chút nữa nha… Nội dung sự kiện có hợp với sở thích của bạn không?",
33
+ "expected_input_type": "choice",
34
+ "branches": {
35
+ "yes": {
36
+ "patterns": ["có", "yes", "đúng", "ổn", "hop"],
37
+ "save_data": {"content_fit": true},
38
+ "next_step": 5
39
+ },
40
+ "no": {
41
+ "patterns": ["không", "no", "ko", "chưa hợp", "không thích"],
42
+ "save_data": {"content_fit": false},
43
+ "next_step": 6
44
+ }
45
+ },
46
+ "default_branch": "yes"
47
+ },
48
+ {
49
+ "id": 5,
50
+ "bot_message": "Tuyệt quá! Mình note lại rồi nè. À, bạn có muốn nhận list sự kiện phù hợp với gu của bạn trong 1 tháng tới không?",
51
+ "expected_input_type": "choice",
52
+ "next_step": 7,
53
+ "branches": {
54
+ "yes": {
55
+ "patterns": ["có", "yes", "ok", "muốn", "quan tâm"],
56
+ "next_step": 7
57
+ },
58
+ "no": {
59
+ "patterns": ["không", "no", "ko"],
60
+ "next_step": 10
61
+ }
62
+ },
63
+ "default_branch": "no"
64
+ },
65
+ {
66
+ "id": 6,
67
+ "bot_message": "Oh mình hiểu rồi nè! Để lần sau team chọn nội dung sát hơn với gu của bạn. Gu của bạn nghiêng về kiểu nào nè?",
68
+ "expected_input_type": "choice",
69
+ "branches": {
70
+ "music": {
71
+ "patterns": ["nhạc", "music", "concert", "live"],
72
+ "save_data": {"preferred_genre": "music"},
73
+ "next_step": 5
74
+ },
75
+ "talkshow": {
76
+ "patterns": ["talkshow", "trò chuyện", "chia sẻ", "speaker"],
77
+ "save_data": {"preferred_genre": "talkshow"},
78
+ "next_step": 5
79
+ },
80
+ "workshop": {
81
+ "patterns": ["workshop", "học", "lớp", "training"],
82
+ "save_data": {"preferred_genre": "workshop"},
83
+ "next_step": 5
84
+ }
85
+ },
86
+ "default_branch": "music"
87
+ },
88
+ {
89
+ "id": 7,
90
+ "bot_message": "Cho mình xin email để gửi danh sách sự kiện theo đúng gu của bạn nha 💌",
91
+ "expected_input_type": "email",
92
+ "validation": "email",
93
+ "action": "save_lead_email",
94
+ "next_step": 8
95
+ },
96
+ {
97
+ "id": 8,
98
+ "bot_message": "Cảm ơn bạn! Nếu muốn nhận thông báo vé hot/early bird qua SMS thì cho mình xin số nhé 📱",
99
+ "expected_input_type": "phone",
100
+ "validation": "phone",
101
+ "action": "save_lead_phone",
102
+ "next_step": 9
103
+ },
104
+ {
105
+ "id": 9,
106
+ "bot_message": "Done! Team sẽ gửi bạn list sự kiện xịn nhất hàng tháng 🎉 Cảm ơn bạn đã đồng hành ❤️",
107
+ "end_scenario": true
108
+ },
109
+ {
110
+ "id": 10,
111
+ "bot_message": "Không sao nha! Nếu sau này bạn muốn xem thêm sự kiện hay ho khác cứ nhắn mình nha 💛",
112
+ "end_scenario": true
113
+ }
114
+ ]
115
+ }
scenarios/price_inquiry.json ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "scenario_id": "price_inquiry",
3
+ "name": "Hỏi giá vé",
4
+ "description": "Kịch bản tư vấn giá vé và gửi PDF bảng giá",
5
+ "triggers": ["giá vé", "bao nhiêu", "ticket price"],
6
+ "steps": [
7
+ {
8
+ "id": 1,
9
+ "bot_message": "Hello 👋 Bạn muốn xem giá của show nào để mình báo đúng nè?",
10
+ "expected_input_type": "event_name",
11
+ "timeout_seconds": 20,
12
+ "timeout_message": "Bạn cần hỗ trợ gì không ạ?",
13
+ "next_step": 2
14
+ },
15
+ {
16
+ "id": 2,
17
+ "bot_message": "Bạn đi 1 mình hay đi nhóm để mình lọc loại vé phù hợp nha?",
18
+ "expected_input_type": "choice",
19
+ "branches": {
20
+ "alone": {
21
+ "patterns": ["1 mình", "một mình", "alone", "solo", "1", "mình"],
22
+ "next_step": 3,
23
+ "save_data": {"group_size": 1, "group_discount": false}
24
+ },
25
+ "group": {
26
+ "patterns": ["nhóm", "group", "bạn bè", "đi cùng", "nhiều người"],
27
+ "next_step": 3,
28
+ "save_data": {"group_size": "multiple", "group_discount": true}
29
+ }
30
+ },
31
+ "default_branch": "alone"
32
+ },
33
+ {
34
+ "id": 3,
35
+ "bot_message_template": "Rồi nè! Show này đang có 3–5 hạng vé, giá từ khoảng {price_min} đến {price_max}.",
36
+ "rag_query_template": "giá vé {event_name}",
37
+ "next_step": 4
38
+ },
39
+ {
40
+ "id": 4,
41
+ "bot_message": "Bạn muốn xem tóm tắt nhanh hay bản full có sơ đồ ghế & vị trí view?",
42
+ "expected_input_type": "choice",
43
+ "branches": {
44
+ "summary": {
45
+ "patterns": ["tóm tắt", "nhanh", "summary", "ngắn"],
46
+ "next_step": 10
47
+ },
48
+ "full": {
49
+ "patterns": ["full", "đầy đủ", "sơ đồ", "chi tiết", "pdf"],
50
+ "next_step": 5
51
+ }
52
+ },
53
+ "default_branch": "summary"
54
+ },
55
+ {
56
+ "id": 5,
57
+ "bot_message": "Nice! File PDF full nhìn rõ từng khu ghế → tránh mua nhầm 🥲\nMình gửi file qua email để bạn lưu lại cho dễ xem nha, cho mình xin email?",
58
+ "expected_input_type": "email",
59
+ "validation": "email",
60
+ "next_step": 6
61
+ },
62
+ {
63
+ "id": 6,
64
+ "bot_message": "Đã gửi vào email bạn rồi nè 👌",
65
+ "action": "send_pdf_email",
66
+ "next_step": 7
67
+ },
68
+ {
69
+ "id": 7,
70
+ "bot_message": "Bạn muốn mình nhắc bạn khi có vé Early Bird hoặc sắp sold-out không?",
71
+ "expected_input_type": "choice",
72
+ "branches": {
73
+ "yes": {
74
+ "patterns": ["có", "yes", "ok", "được", "muốn"],
75
+ "next_step": 8
76
+ },
77
+ "no": {
78
+ "patterns": ["không", "no", "thôi", "ko"],
79
+ "next_step": 9
80
+ }
81
+ },
82
+ "default_branch": "no"
83
+ },
84
+ {
85
+ "id": 8,
86
+ "bot_message": "Cho mình xin số để mình SMS cho bạn ạ.",
87
+ "expected_input_type": "phone",
88
+ "validation": "phone",
89
+ "action": "save_lead_phone",
90
+ "next_step": 10
91
+ },
92
+ {
93
+ "id": 9,
94
+ "bot_message": "Okii, cứ hỏi mình bất kì lúc nào nha ✨",
95
+ "end_scenario": true
96
+ },
97
+ {
98
+ "id": 10,
99
+ "bot_message": "Cảm ơn bạn! Hẹn gặp lại ^^",
100
+ "end_scenario": true
101
+ }
102
+ ]
103
+ }
stream_utils.py ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ SSE (Server-Sent Events) Utilities
3
+ Format streaming responses for real-time chat
4
+ """
5
+ import json
6
+ from typing import Dict, Any, AsyncGenerator
7
+ import asyncio
8
+
9
+
10
+ def format_sse(event: str, data: Any) -> str:
11
+ """
12
+ Format data as SSE message
13
+
14
+ Args:
15
+ event: Event type (token, status, done, error)
16
+ data: Data payload (string or dict)
17
+
18
+ Returns:
19
+ Formatted SSE string
20
+
21
+ Example:
22
+ format_sse("token", "Hello")
23
+ # "event: token\ndata: Hello\n\n"
24
+ """
25
+ if isinstance(data, dict):
26
+ data_str = json.dumps(data, ensure_ascii=False)
27
+ else:
28
+ data_str = str(data)
29
+
30
+ return f"event: {event}\ndata: {data_str}\n\n"
31
+
32
+
33
+ async def simulate_typing(
34
+ text: str,
35
+ chars_per_chunk: int = 3,
36
+ delay_ms: float = 20
37
+ ) -> AsyncGenerator[str, None]:
38
+ """
39
+ Simulate typing effect by yielding text in chunks
40
+
41
+ Args:
42
+ text: Full text to stream
43
+ chars_per_chunk: Characters per chunk
44
+ delay_ms: Milliseconds delay between chunks
45
+
46
+ Yields:
47
+ Text chunks
48
+
49
+ Example:
50
+ async for chunk in simulate_typing("Hello world", chars_per_chunk=2):
51
+ yield format_sse("token", chunk)
52
+ """
53
+ for i in range(0, len(text), chars_per_chunk):
54
+ chunk = text[i:i + chars_per_chunk]
55
+ yield chunk
56
+ await asyncio.sleep(delay_ms / 1000)
57
+
58
+
59
+ async def stream_text_slowly(
60
+ text: str,
61
+ event_type: str = "token",
62
+ chars_per_chunk: int = 3,
63
+ delay_ms: float = 20
64
+ ) -> AsyncGenerator[str, None]:
65
+ """
66
+ Stream text with typing effect in SSE format
67
+
68
+ Args:
69
+ text: Text to stream
70
+ event_type: SSE event type
71
+ chars_per_chunk: Characters per chunk
72
+ delay_ms: Delay between chunks
73
+
74
+ Yields:
75
+ SSE formatted chunks
76
+ """
77
+ async for chunk in simulate_typing(text, chars_per_chunk, delay_ms):
78
+ yield format_sse(event_type, chunk)
79
+
80
+
81
+ # Event type constants
82
+ EVENT_STATUS = "status"
83
+ EVENT_TOKEN = "token"
84
+ EVENT_DONE = "done"
85
+ EVENT_ERROR = "error"
86
+ EVENT_METADATA = "metadata"
tools_service.py CHANGED
@@ -1,233 +1,250 @@
1
- """
2
- Tools Service for LLM Function Calling
3
- HuggingFace-compatible với prompt engineering
4
- """
5
- import httpx
6
- from typing import List, Dict, Any, Optional
7
- import json
8
- import asyncio
9
-
10
-
11
- class ToolsService:
12
- """
13
- Manages external API tools that LLM can call via prompt engineering
14
- """
15
-
16
- def __init__(self, base_url: str = "https://www.festavenue.site"):
17
- self.base_url = base_url
18
- self.client = httpx.AsyncClient(timeout=10.0)
19
-
20
- def get_tools_prompt(self) -> str:
21
- """
22
- Return prompt instruction for HuggingFace LLM về available tools
23
- """
24
- return """
25
- AVAILABLE TOOLS:
26
- Bạn có thể sử dụng các công cụ sau để lấy thông tin chi tiết:
27
-
28
- 1. get_event_details(event_code: str)
29
- - Mô tả: Lấy thông tin đầy đủ về một sự kiện từ hệ thống
30
- - Khi nào dùng: Khi user hỏi về ngày giờ chính xác, địa điểm cụ thể, thông tin liên hệ, hoặc chi tiết khác về một sự kiện
31
- - Tham số: event_code = ID sự kiện (LẤY TỪ metadata.id_use TRONG CONTEXT, KHÔNG PHẢI tên sự kiện!)
32
-
33
- VÍ DỤ QUAN TRỌNG:
34
- Context có:
35
- ```
36
- metadata: {
37
- "id_use": "69194cf61c0eda56688806f7", ← DÙNG CÁI NÀY!
38
- "texts": ["Y-CONCERT - Festival âm nhạc..."]
39
- }
40
- ```
41
- → Dùng event_code = "69194cf61c0eda56688806f7" (NOT "Y-CONCERT")
42
-
43
- CÚ PHÁP GỌI TOOL:
44
- Khi bạn cần gọi tool, hãy trả lời CHÍNH XÁC theo format JSON này:
45
- ```json
46
- {
47
- "tool_call": true,
48
- "function_name": "get_event_details",
49
- "arguments": {
50
- "event_code": "69194cf61c0eda56688806f7"
51
- },
52
- "reason": "Cần lấy thông tin chính xác về ngày giờ tổ chức"
53
- }
54
- ```
55
-
56
- QUAN TRỌNG:
57
- - event_code PHẢI LÀ metadata.id_use từ context (dạng MongoDB ObjectId)
58
- - KHÔNG dùng tên sự kiện như "Y-CONCERT" làm event_code
59
- - CHỈ trả JSON khi BẮT BUỘC cần gọi tool
60
- - Nếu có thể trả lời từ context sẵn có, đừng gọi tool
61
- - Sau khi nhận kết quả từ tool, hãy trả lời user bằng ngôn ngữ tự nhiên
62
- """
63
-
64
- async def parse_and_execute(self, llm_response: str) -> Optional[Dict[str, Any]]:
65
- """
66
- Parse LLM response và execute tool nếu có
67
-
68
- Returns:
69
- None nếu không có tool call
70
- Dict với tool result nếu có tool call
71
- """
72
- # Try to extract JSON from response
73
- try:
74
- # Tìm JSON block trong response
75
- if "```json" in llm_response:
76
- json_start = llm_response.find("```json") + 7
77
- json_end = llm_response.find("```", json_start)
78
- json_str = llm_response[json_start:json_end].strip()
79
- elif "{" in llm_response and "}" in llm_response:
80
- # Fallback: tìm JSON object đầu tiên
81
- json_start = llm_response.find("{")
82
- json_end = llm_response.rfind("}") + 1
83
- json_str = llm_response[json_start:json_end]
84
- else:
85
- return None
86
-
87
- tool_call = json.loads(json_str)
88
-
89
- # Handle multiple JSON formats from LLM
90
-
91
- # Format 1: HF API nested wrapper
92
- # {"name": "tool_call", "arguments": {"tool_call": true, ...}}
93
- if "name" in tool_call and "arguments" in tool_call and isinstance(tool_call["arguments"], dict):
94
- if "tool_call" in tool_call["arguments"]:
95
- tool_call = tool_call["arguments"] # Unwrap
96
-
97
- # Format 2: Direct tool name format
98
- # {"name": "tool.get_event_details", "arguments": {"event_code": "..."}}
99
- if "name" in tool_call and "arguments" in tool_call:
100
- function_name = tool_call["name"]
101
- # Remove "tool." prefix if exists
102
- if function_name.startswith("tool."):
103
- function_name = function_name.replace("tool.", "")
104
-
105
- # Convert to standard format
106
- tool_call = {
107
- "tool_call": True,
108
- "function_name": function_name,
109
- "arguments": tool_call["arguments"],
110
- "reason": "Converted from alternate format"
111
- }
112
-
113
- # Validate tool call structure
114
- if not tool_call.get("tool_call"):
115
- return None
116
-
117
- function_name = tool_call.get("function_name")
118
- arguments = tool_call.get("arguments", {})
119
-
120
- # Execute tool
121
- if function_name == "get_event_details":
122
- result = await self._get_event_details(arguments.get("event_code"))
123
- return {
124
- "function": function_name,
125
- "arguments": arguments,
126
- "result": result
127
- }
128
- else:
129
- return {
130
- "function": function_name,
131
- "arguments": arguments,
132
- "result": {"success": False, "error": f"Unknown function: {function_name}"}
133
- }
134
-
135
- except (json.JSONDecodeError, KeyError, ValueError) as e:
136
- # Không phải tool call, response bình thường
137
- return None
138
-
139
- async def _get_event_details(self, event_code: str) -> Dict[str, Any]:
140
- """
141
- Call getEventByEventCode API
142
- """
143
- print(f"\n=== CALLING API get_event_details ===")
144
- print(f"Event Code: {event_code}")
145
-
146
- try:
147
- url = f"https://hoalacrent.io.vn/api/v0/event/get-event-by-event-code"
148
- params = {"eventCode": event_code}
149
-
150
- print(f"URL: {url}")
151
- print(f"Params: {params}")
152
-
153
- response = await self.client.get(url, params=params)
154
-
155
- print(f"Status Code: {response.status_code}")
156
-
157
- response.raise_for_status()
158
- data = response.json()
159
-
160
- print(f"Response Data Keys: {list(data.keys()) if data else 'None'}")
161
- print(f"Has 'data' field: {'data' in data}")
162
-
163
- # Extract relevant fields
164
- event = data.get("data", {})
165
-
166
- if not event:
167
- return {
168
- "success": False,
169
- "error": "Event not found",
170
- "message": f"Không tìm thấy sự kiện với mã {event_code}"
171
- }
172
-
173
- # Extract location với nested address structure
174
- location_data = event.get("location", {})
175
- location = {
176
- "address": {
177
- "street": location_data.get("address", {}).get("street", ""),
178
- "city": location_data.get("address", {}).get("city", ""),
179
- "state": location_data.get("address", {}).get("state", ""),
180
- "postalCode": location_data.get("address", {}).get("postalCode", ""),
181
- "country": location_data.get("address", {}).get("country", "")
182
- },
183
- "coordinates": {
184
- "latitude": location_data.get("coordinates", {}).get("latitude"),
185
- "longitude": location_data.get("coordinates", {}).get("longitude")
186
- }
187
- }
188
-
189
- # Build event URL
190
- event_code = event.get("eventCode")
191
- event_url = f"https://www.festavenue.site/user/event/{event_code}" if event_code else None
192
-
193
- return {
194
- "success": True,
195
- "event_code": event_code,
196
- "event_name": event.get("eventName"),
197
- "event_url": event_url, # NEW: Direct link to event page
198
- "description": event.get("description"),
199
- "short_description": event.get("shortDescription"),
200
- "start_time": event.get("startTimeEventTime"),
201
- "end_time": event.get("endTimeEventTime"),
202
- "start_sale": event.get("startTicketSaleTime"),
203
- "end_sale": event.get("endTicketSaleTime"),
204
- "location": location, # Full nested structure
205
- "contact": {
206
- "email": event.get("publicContactEmail"),
207
- "phone": event.get("publicContactPhone"),
208
- "website": event.get("website")
209
- },
210
- "capacity": event.get("capacity"),
211
- "hashtags": event.get("hashtags", [])
212
- }
213
-
214
- print(f"Successfully extracted event data for: {event.get('eventName')}")
215
- print(f"=== API CALL COMPLETE ===")
216
- return result
217
-
218
- except httpx.HTTPStatusError as e:
219
- return {
220
- "success": False,
221
- "error": f"HTTP {e.response.status_code}",
222
- "message": f"API trả về lỗi khi truy vấn sự kiện {event_code}"
223
- }
224
- except Exception as e:
225
- return {
226
- "success": False,
227
- "error": str(e),
228
- "message": "Không thể kết nối đến API để lấy thông tin sự kiện"
229
- }
230
-
231
- async def close(self):
232
- """Close HTTP client"""
233
- await self.client.aclose()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Tools Service for LLM Function Calling
3
+ HuggingFace-compatible với prompt engineering
4
+ """
5
+ import httpx
6
+ from typing import List, Dict, Any, Optional
7
+ import json
8
+ import asyncio
9
+
10
+
11
+ class ToolsService:
12
+ """
13
+ Manages external API tools that LLM can call via prompt engineering
14
+ """
15
+
16
+ def __init__(self, base_url: str = "https://www.festavenue.site"):
17
+ self.base_url = base_url
18
+ self.client = httpx.AsyncClient(timeout=10.0)
19
+
20
+ def get_tools_prompt(self) -> str:
21
+ """
22
+ Return prompt instruction for HuggingFace LLM về available tools
23
+ """
24
+ return """
25
+ AVAILABLE TOOLS:
26
+ Bạn có thể sử dụng các công cụ sau để lấy thông tin chi tiết:
27
+
28
+ 1. get_event_details(event_code: str)
29
+ - Mô tả: Lấy thông tin đầy đủ về một sự kiện từ hệ thống
30
+ - Khi nào dùng: Khi user hỏi về ngày giờ chính xác, địa điểm cụ thể, thông tin liên hệ, hoặc chi tiết khác về một sự kiện
31
+ - Tham số: event_code = ID sự kiện (LẤY TỪ metadata.id_use TRONG CONTEXT, KHÔNG PHẢI tên sự kiện!)
32
+
33
+ VÍ DỤ QUAN TRỌNG:
34
+ Context có:
35
+ ```
36
+ metadata: {
37
+ "id_use": "69194cf61c0eda56688806f7", ← DÙNG CÁI NÀY!
38
+ "texts": ["Y-CONCERT - Festival âm nhạc..."]
39
+ }
40
+ ```
41
+ → Dùng event_code = "69194cf61c0eda56688806f7" (NOT "Y-CONCERT")
42
+
43
+ CÚ PHÁP GỌI TOOL:
44
+ Khi bạn cần gọi tool, hãy trả lời CHÍNH XÁC theo format JSON này:
45
+ ```json
46
+ {
47
+ "tool_call": true,
48
+ "function_name": "get_event_details",
49
+ "arguments": {
50
+ "event_code": "69194cf61c0eda56688806f7"
51
+ },
52
+ "reason": "Cần lấy thông tin chính xác về ngày giờ tổ chức"
53
+ }
54
+ ```
55
+
56
+ QUAN TRỌNG:
57
+ - event_code PHẢI LÀ metadata.id_use từ context (dạng MongoDB ObjectId)
58
+ - KHÔNG dùng tên sự kiện như "Y-CONCERT" làm event_code
59
+ - CHỈ trả JSON khi BẮT BUỘC cần gọi tool
60
+ - Nếu có thể trả lời từ context sẵn có, đừng gọi tool
61
+ - Sau khi nhận kết quả từ tool, hãy trả lời user bằng ngôn ngữ tự nhiên
62
+ """
63
+
64
+ async def parse_and_execute(self, llm_response: str) -> Optional[Dict[str, Any]]:
65
+ """
66
+ Parse LLM response và execute tool nếu có
67
+
68
+ Returns:
69
+ None nếu không có tool call
70
+ Dict với tool result nếu có tool call
71
+ """
72
+ # Try to extract JSON from response
73
+ try:
74
+ # Tìm JSON block trong response
75
+ if "```json" in llm_response:
76
+ json_start = llm_response.find("```json") + 7
77
+ json_end = llm_response.find("```", json_start)
78
+ json_str = llm_response[json_start:json_end].strip()
79
+ elif "{" in llm_response and "}" in llm_response:
80
+ # Fallback: tìm JSON object đầu tiên
81
+ json_start = llm_response.find("{")
82
+ json_end = llm_response.rfind("}") + 1
83
+ json_str = llm_response[json_start:json_end]
84
+ else:
85
+ return None
86
+
87
+ tool_call = json.loads(json_str)
88
+
89
+ # Handle multiple JSON formats from LLM
90
+
91
+ # Format 1: HF API nested wrapper
92
+ # {"name": "tool_call", "arguments": {"tool_call": true, ...}}
93
+ if "name" in tool_call and "arguments" in tool_call and isinstance(tool_call["arguments"], dict):
94
+ if "tool_call" in tool_call["arguments"]:
95
+ tool_call = tool_call["arguments"] # Unwrap
96
+
97
+ # Format 2: Direct tool name format
98
+ # {"name": "tool.get_event_details", "arguments": {"event_code": "..."}}
99
+ if "name" in tool_call and "arguments" in tool_call:
100
+ function_name = tool_call["name"]
101
+ # Remove "tool." prefix if exists
102
+ if function_name.startswith("tool."):
103
+ function_name = function_name.replace("tool.", "")
104
+
105
+ # Convert to standard format
106
+ tool_call = {
107
+ "tool_call": True,
108
+ "function_name": function_name,
109
+ "arguments": tool_call["arguments"],
110
+ "reason": "Converted from alternate format"
111
+ }
112
+
113
+ # Validate tool call structure
114
+ if not tool_call.get("tool_call"):
115
+ return None
116
+
117
+ function_name = tool_call.get("function_name")
118
+ arguments = tool_call.get("arguments", {})
119
+
120
+ # Execute tool
121
+ if function_name == "get_event_details":
122
+ result = await self._get_event_details(arguments.get("event_code"))
123
+ return {
124
+ "function": function_name,
125
+ "arguments": arguments,
126
+ "result": result
127
+ }
128
+ else:
129
+ return {
130
+ "function": function_name,
131
+ "arguments": arguments,
132
+ "result": {"success": False, "error": f"Unknown function: {function_name}"}
133
+ }
134
+
135
+ except (json.JSONDecodeError, KeyError, ValueError) as e:
136
+ # Không phải tool call, response bình thường
137
+ return None
138
+
139
+ async def _get_event_details(self, event_code: str) -> Dict[str, Any]:
140
+ """
141
+ Call getEventByEventCode API
142
+ """
143
+ print(f"\n=== CALLING API get_event_details ===")
144
+ print(f"Event Code: {event_code}")
145
+
146
+ try:
147
+ url = f"https://hoalacrent.io.vn/api/v0/event/get-event-by-event-code"
148
+ params = {"eventCode": event_code}
149
+
150
+ print(f"URL: {url}")
151
+ print(f"Params: {params}")
152
+
153
+ response = await self.client.get(url, params=params)
154
+
155
+ print(f"Status Code: {response.status_code}")
156
+
157
+ # Log raw response for debugging
158
+ raw_text = response.text
159
+ print(f"Raw Response Length: {len(raw_text)} chars")
160
+ print(f"Raw Response Preview (first 200 chars): {raw_text[:200]}")
161
+
162
+ response.raise_for_status()
163
+
164
+ # Try to parse JSON
165
+ try:
166
+ data = response.json()
167
+ except json.JSONDecodeError as e:
168
+ print(f"JSON Decode Error: {e}")
169
+ print(f"Full Raw Response: {raw_text}")
170
+ return {
171
+ "success": False,
172
+ "error": f"Invalid JSON response from API",
173
+ "message": "API trả về dữ liệu không hợp lệ (không phải JSON)",
174
+ "raw_response_preview": raw_text[:500]
175
+ }
176
+
177
+ print(f"Response Data Keys: {list(data.keys()) if data else 'None'}")
178
+ print(f"Has 'data' field: {'data' in data}")
179
+
180
+ # Extract relevant fields
181
+ event = data.get("data", {})
182
+
183
+ if not event:
184
+ return {
185
+ "success": False,
186
+ "error": "Event not found",
187
+ "message": f"Không tìm thấy sự kiện với mã {event_code}"
188
+ }
189
+
190
+ # Extract location với nested address structure
191
+ location_data = event.get("location", {})
192
+ location = {
193
+ "address": {
194
+ "street": location_data.get("address", {}).get("street", ""),
195
+ "city": location_data.get("address", {}).get("city", ""),
196
+ "state": location_data.get("address", {}).get("state", ""),
197
+ "postalCode": location_data.get("address", {}).get("postalCode", ""),
198
+ "country": location_data.get("address", {}).get("country", "")
199
+ },
200
+ "coordinates": {
201
+ "latitude": location_data.get("coordinates", {}).get("latitude"),
202
+ "longitude": location_data.get("coordinates", {}).get("longitude")
203
+ }
204
+ }
205
+
206
+ # Build event URL
207
+ event_code = event.get("eventCode")
208
+ event_url = f"https://www.festavenue.site/user/event/{event_code}" if event_code else None
209
+
210
+ return {
211
+ "success": True,
212
+ "event_code": event_code,
213
+ "event_name": event.get("eventName"),
214
+ "event_url": event_url, # NEW: Direct link to event page
215
+ "description": event.get("description"),
216
+ "short_description": event.get("shortDescription"),
217
+ "start_time": event.get("startTimeEventTime"),
218
+ "end_time": event.get("endTimeEventTime"),
219
+ "start_sale": event.get("startTicketSaleTime"),
220
+ "end_sale": event.get("endTicketSaleTime"),
221
+ "location": location, # Full nested structure
222
+ "contact": {
223
+ "email": event.get("publicContactEmail"),
224
+ "phone": event.get("publicContactPhone"),
225
+ "website": event.get("website")
226
+ },
227
+ "capacity": event.get("capacity"),
228
+ "hashtags": event.get("hashtags", [])
229
+ }
230
+
231
+ print(f"Successfully extracted event data for: {event.get('eventName')}")
232
+ print(f"=== API CALL COMPLETE ===")
233
+ return result
234
+
235
+ except httpx.HTTPStatusError as e:
236
+ return {
237
+ "success": False,
238
+ "error": f"HTTP {e.response.status_code}",
239
+ "message": f"API trả về lỗi khi truy vấn sự kiện {event_code}"
240
+ }
241
+ except Exception as e:
242
+ return {
243
+ "success": False,
244
+ "error": str(e),
245
+ "message": "Không thể kết nối đến API để lấy thông tin sự kiện"
246
+ }
247
+
248
+ async def close(self):
249
+ """Close HTTP client"""
250
+ await self.client.aclose()