Spaces:
Running
Running
Upload 26 files
Browse files- chat_endpoint.py +283 -282
- conversation_service.py +126 -2
- hybrid_chat_endpoint.py +285 -0
- hybrid_chat_stream.py +207 -0
- intent_classifier.py +208 -0
- lead_storage_service.py +90 -0
- main.py +459 -33
- scenario_engine.py +329 -0
- scenarios/event_recommendation.json +108 -0
- scenarios/exit_intent_rescue.json +38 -0
- scenarios/mini_survey_lead.json +37 -0
- scenarios/mood_recommendation.json +55 -0
- scenarios/post_event_feedback.json +115 -0
- scenarios/price_inquiry.json +103 -0
- stream_utils.py +86 -0
- tools_service.py +250 -233
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 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
"
|
| 81 |
-
"
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
-
|
| 126 |
-
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
messages
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
#
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
messages.append({
|
| 214 |
-
|
| 215 |
-
"
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
"
|
| 254 |
-
"
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
"
|
| 262 |
-
"
|
| 263 |
-
"
|
| 264 |
-
"
|
| 265 |
-
"
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
"
|
| 273 |
-
"
|
| 274 |
-
"
|
| 275 |
-
"
|
| 276 |
-
"
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
|
|
|
|
|
| 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 |
-
|
| 687 |
|
| 688 |
Features:
|
| 689 |
-
- ✅
|
| 690 |
-
- ✅
|
| 691 |
-
- ✅ RAG
|
| 692 |
-
- ✅
|
|
|
|
|
|
|
| 693 |
|
| 694 |
Flow:
|
| 695 |
-
1.
|
| 696 |
-
2.
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
-
|
| 710 |
-
|
| 711 |
-
|
| 712 |
-
|
| 713 |
-
|
| 714 |
-
|
| 715 |
-
|
| 716 |
|
| 717 |
-
|
| 718 |
-
|
| 719 |
-
|
| 720 |
-
|
| 721 |
-
|
| 722 |
-
|
| 723 |
-
|
| 724 |
-
#
|
| 725 |
-
|
| 726 |
|
| 727 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 message → Intent 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
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
print(f"Response
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
"
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
"
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
"
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
"
|
| 221 |
-
"
|
| 222 |
-
"
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
"
|
| 228 |
-
"
|
| 229 |
-
}
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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()
|