chatbot / app.py
huylaughmad's picture
Update app.py
29f9cb7 verified
raw
history blame
47.4 kB
import google.generativeai as genai
import requests
import time
import os
import json
import logging
import re
import base64
from huggingface_hub import HfApi
from flask import Flask, request, jsonify
from datetime import datetime
from threading import Timer
from requests.adapters import HTTPAdapter
from requests.packages.urllib3.util.retry import Retry
import io
# Cấu hình logging
logging.basicConfig(
level=logging.DEBUG,
format='%(asctime)s - %(levelname)s - %(message)s',
handlers=[logging.FileHandler("bot.log"), logging.StreamHandler()]
)
logger = logging.getLogger(__name__)
# API Keys và thông tin cấu hình
PAGE_ACCESS_TOKEN = os.environ.get("PAGE_ACCESS_TOKEN")
GEMINI_API_KEY = os.environ.get("GEMINI_API_KEY")
VERIFY_TOKEN = "TTL1979"
PAGE_ID = "729941053876282"
BOT_APP_ID = "2119027891892606"
# Thời gian không hoạt động và delay
INACTIVITY_TIMEOUT = 30 * 60
DELAY_PROCESSING = 7.0 # Tăng lên 10 giây để giảm tần suất gọi API
# Giới hạn yêu cầu API
CALLS_PER_MINUTE = 5
PERIOD = 60
# Tải dữ liệu phòng khám và bảng giá
try:
with open("clinic_data.json", "r", encoding="utf-8") as f:
clinic_data = json.load(f)
except FileNotFoundError:
logger.error("Không tìm thấy file clinic_data.json!")
clinic_data = {"clinic": {}, "services": {"adults": {}, "children": {}}, "promotions": []}
# Cấu hình session với retry
requests_session = requests.Session()
retries = Retry(total=5, backoff_factor=2, status_forcelist=[429, 500, 502, 503, 504])
requests_session.mount("https://", HTTPAdapter(max_retries=retries))
# Cấu hình Gemini API
genai.configure(api_key=GEMINI_API_KEY)
model = genai.GenerativeModel("gemini-2.0-flash")
# Khởi tạo Flask app
app = Flask(__name__)
# Biến đếm số lần gọi Gemini
gemini_calls = 0
last_calls = []
recent_messages = {}
user_name_cache = {}
def track_gemini_call(func):
global gemini_calls, last_calls
try:
current_time = time.time()
last_calls = [t for t in last_calls if current_time - t < PERIOD]
if len(last_calls) >= CALLS_PER_MINUTE:
sleep_time = PERIOD - (current_time - last_calls[0])
if sleep_time > 0:
logger.debug(f"Rate limit reached, sleeping for {sleep_time:.2f} seconds")
time.sleep(sleep_time)
result = func()
gemini_calls += 1
last_calls.append(time.time())
logger.debug(f"Gemini call count: {gemini_calls}")
return result
except Exception as e:
logger.error(f"Error in Gemini call: {str(e)}")
if "429" in str(e):
retry_delay = 60 # Chờ lâu hơn khi gặp 429
logger.debug(f"429 error detected, sleeping for {retry_delay} seconds")
time.sleep(retry_delay)
raise
# Health check cho Render
@app.route("/", methods=["GET", "HEAD"])
def health_check():
return "Bot is running!", 200
# Bộ nhớ tạm thời
page_messages = {}
session_contexts = {}
USER_CONTEXT_BASE_PATH = "user_contexts"
def save_user_context(user_id, session):
try:
huggingface_token = os.getenv("HUGGINGFACE_TOKEN")
if not huggingface_token:
logger.error(f"No HUGGINGFACE_TOKEN found for user {user_id}")
raise ValueError("Missing HUGGINGFACE_TOKEN")
logger.debug(f"Starting save_user_context for user {user_id}")
api = HfApi(token=huggingface_token)
user_file_path = f"{USER_CONTEXT_BASE_PATH}/{user_id}.json"
local_temp_file = f"temp_{user_id}.json"
existing_data = {}
try:
local_file = api.hf_hub_download(
repo_id="huylaughmad/chatbot-data",
filename=user_file_path,
repo_type="dataset",
token=api.token
)
with open(local_file, "r", encoding="utf-8") as f:
existing_data = json.load(f)
logger.info(f"Loaded existing context for user {user_id}")
except Exception as e:
logger.info(f"Creating new context for user {user_id}: {str(e)}")
# Kiểm tra trùng lặp tin nhắn dựa trên role và content
existing_messages = {(msg["role"], str(msg["content"])): msg for msg in existing_data.get("conversation", [])}
updated_conversation = existing_data.get("conversation", [])
for msg in session["conversation"]:
msg_key = (msg["role"], str(msg["content"]))
if msg_key not in existing_messages:
updated_conversation.append(msg)
logger.debug(f"Added new message for user {user_id}: {msg_key}")
# Lọc các tin nhắn cũ hơn 24 giờ
cutoff_time = datetime.now().timestamp() - 24 * 60 * 60
system_messages = [msg for msg in updated_conversation if msg.get("role") == "system"]
other_messages = [msg for msg in updated_conversation if msg.get("role") != "system"]
if system_messages:
updated_conversation = [system_messages[-1]] + other_messages # Giữ thông điệp hệ thống mới nhất
updated_conversation = [
msg for msg in updated_conversation
if msg.get("role") == "system" or
(
isinstance(msg.get("content"), dict) and
msg["content"].get("timestamp", 0) > cutoff_time
) or (
isinstance(msg.get("content"), str) and
existing_data.get("last_updated", datetime.now().isoformat()) >= datetime.fromtimestamp(cutoff_time).isoformat()
)
]
updated_data = {
"conversation": updated_conversation,
"context": session["context"],
"last_updated": datetime.now().isoformat()
}
logger.debug(f"Writing context to temp file for user {user_id}: {local_temp_file}")
with open(local_temp_file, "w", encoding="utf-8") as f:
json.dump(updated_data, f, ensure_ascii=False, indent=2)
logger.debug(f"Uploading context file for user {user_id} to {user_file_path}")
api.upload_file(
path_or_fileobj=local_temp_file,
path_in_repo=user_file_path,
repo_id="huylaughmad/chatbot-data",
repo_type="dataset",
token=api.token
)
logger.info(f"Saved context for user {user_id} with {len(updated_conversation)} messages")
except Exception as e:
logger.error(f"Failed to save context for user {user_id}: {str(e)}", exc_info=True)
raise # Ném lại exception để debug
finally:
try:
if os.path.exists(local_temp_file):
os.remove(local_temp_file)
logger.debug(f"Removed temporary file {local_temp_file}")
except Exception as e:
logger.error(f"Failed to remove temporary file {local_temp_file}: {str(e)}")
def load_user_context(user_id):
try:
api = HfApi(token=os.getenv("HUGGINGFACE_TOKEN"))
user_file_path = f"{USER_CONTEXT_BASE_PATH}/{user_id}.json"
local_file = api.hf_hub_download(
repo_id="huylaughmad/chatbot-data",
filename=user_file_path,
repo_type="dataset",
token=api.token
)
with open(local_file, "r", encoding="utf-8") as f:
user_context = json.load(f)
if not isinstance(user_context, dict) or "conversation" not in user_context or "context" not in user_context:
logger.error(f"Invalid context format for user {user_id}")
raise ValueError("Invalid context format")
logger.info(f"Loaded context for user {user_id} with {len(user_context['conversation'])} messages")
return {
"conversation": user_context["conversation"],
"context": user_context["context"]
}
except Exception as e:
logger.info(f"No context found for user {user_id}, initializing default: {str(e)}")
return {
"conversation": [{"role": "system", "content": "Bạn là Tử Tế, trợ lý chatbot của Phòng Khám Răng Hàm Mặt Trần Thanh Long 1979."}],
"context": {
"condition": None,
"severity": None,
"position": None,
"service": None,
"target_group": None,
"booking": {"name": None, "phone": None, "address": None, "time": None},
"user_name": None
},
"last_updated": None
}
def set_persistent_menu():
url = f"https://graph.facebook.com/v21.0/me/messenger_profile?access_token={PAGE_ACCESS_TOKEN}"
payload = {
"persistent_menu": [
{
"locale": "default",
"composer_input_disabled": False,
"call_to_actions": [
{"type": "postback", "title": "Tư vấn", "payload": "HELP_ADVICE"},
{"type": "postback", "title": "Báo giá", "payload": "HELP_PRICE"},
{"type": "postback", "title": "Đặt lịch", "payload": "HELP_BOOKING"},
{"type": "postback", "title": "Thông tin PK", "payload": "HELP_INFO"},
{"type": "postback", "title": "Dừng chat", "payload": "STOP_CHAT"},
{"type": "postback", "title": "Tiếp tục chat", "payload": "CONTINUE_CHAT"}
]
}
]
}
try:
response = requests_session.post(url, json=payload, timeout=10)
if response.status_code == 200:
logger.info("Thiết lập menu cố định thành công")
else:
logger.error(f"Thiết lập menu thất bại: {response.text}")
except requests.RequestException as e:
logger.error(f"Lỗi khi cài đặt menu: {str(e)}")
logger.info("Khởi động bot và thiết lập menu cố định...")
set_persistent_menu()
def clean_text(text):
if not isinstance(text, str):
return text
text = re.sub(r'[\n\r\t]+', ' ', text)
text = re.sub(r'[^\x00-\x7FÀ-ỹ\s"{},:[\]\U0001F000-\U0001FFFF]', '', text)
text = re.sub(r'Extracted:\s*\{.*?\}\s*$', '', text, flags=re.DOTALL)
text = re.sub(r'Extracted:\s*```json\s*\{.*?\}\s*```', '', text, flags=re.DOTALL)
text = re.sub(r'```json\s*\{.*?\}\s*```', '', text, flags=re.DOTALL)
text = re.sub(r'Extracted:\s*$', '', text)
text = re.sub(r'```json\s*', '', text)
text = re.sub(r'\s*```', '', text)
text = text.strip('`').strip()
text = re.sub(r'\s+', ' ', text).strip()
return text
def get_contextual_quick_replies(user_id):
quick_replies = [
{"content_type": "text", "title": "Dừng chat", "payload": "STOP_CHAT"},
{"content_type": "text", "title": "Tư vấn", "payload": "HELP_ADVICE"},
{"content_type": "text", "title": "Báo giá", "payload": "HELP_PRICE"},
{"content_type": "text", "title": "Đặt lịch", "payload": "HELP_BOOKING"},
{"content_type": "text", "title": "Thông tin PK", "payload": "HELP_INFO"}
]
return quick_replies
def send_image(recipient_id, image_url):
payload = {
"recipient": {"id": recipient_id},
"message": {
"attachment": {
"type": "image",
"payload": {"url": image_url}
}
}
}
headers = {"Content-Type": "application/json"}
response = requests_session.post(f"https://graph.facebook.com/v21.0/me/messages?access_token={PAGE_ACCESS_TOKEN}", json=payload, headers=headers)
return response.status_code == 200
def fetch_and_store_user_name(user_id, session_data):
# Kiểm tra bộ đệm trước
if user_id in user_name_cache:
logger.debug(f"Retrieved name from cache for user {user_id}: {user_name_cache[user_id]}")
session_data["context"]["user_name"] = user_name_cache[user_id]
return user_name_cache[user_id]
# Kiểm tra session_data["context"] là dictionary
if not isinstance(session_data["context"], dict):
logger.error(f"Invalid session context for user {user_id}: {session_data['context']}")
session_data["context"] = {
"condition": None,
"severity": None,
"position": None,
"service": None,
"target_group": None,
"booking": {"name": None, "phone": None, "address": None, "time": None},
"user_name": None,
"name_confirmed": False
}
# Log trạng thái session_data["context"]
logger.debug(f"Checking user_name in session context for user {user_id}: {session_data['context']}")
# Kiểm tra nếu đã có tên trong session
if session_data["context"].get("user_name") is not None:
logger.debug(f"User {user_id} already has name: {session_data['context']['user_name']}")
user_name_cache[user_id] = session_data["context"]["user_name"]
return session_data["context"]["user_name"]
try:
# Gọi Facebook Graph API để lấy thông tin người dùng
url = f"https://graph.facebook.com/v21.0/{user_id}?fields=name&access_token={PAGE_ACCESS_TOKEN}"
logger.debug(f"Fetching name from Facebook API for user {user_id}: {url}")
response = requests_session.get(url, timeout=10)
logger.debug(f"Facebook API response status for user {user_id}: {response.status_code}")
if response.status_code == 200:
user_data = response.json()
logger.debug(f"Facebook API response data for user {user_id}: {user_data}")
name = user_data.get("name", "").strip()
if name:
# Lưu tên vào session và bộ đệm
session_data["context"]["user_name"] = name
session_data["context"]["name_confirmed"] = True
user_name_cache[user_id] = name
logger.info(f"Stored name '{name}' for user {user_id} from Facebook profile")
return name
else:
logger.warning(f"No name found in Facebook profile for user {user_id}: {user_data}")
else:
logger.error(f"Failed to fetch name for user {user_id}: {response.status_code} - {response.text}")
except Exception as e:
logger.error(f"Error fetching name for user {user_id}: {str(e)}", exc_info=True)
# Fallback: đặt tên mặc định là None
session_data["context"]["user_name"] = None
session_data["context"]["name_confirmed"] = False
user_name_cache[user_id] = None
logger.debug(f"No name stored for user {user_id}, set to None")
return None
def personalize_response(response_text, user_name):
# Loại bỏ dấu chấm thừa ở đầu chuỗi
response_text = response_text.strip()
if response_text.startswith('.'):
response_text = response_text[1:].strip()
logger.debug(f"Personalized response with name: bạn {user_name}")
return response_text
def send_rating_request(user_id):
rating_text = "Bạn vui lòng đánh giá trải nghiệm chat giúp mình nhé! Từ ⭐️ (không hài lòng) đến ⭐️⭐️⭐️⭐️⭐️ (rất hài lòng)"
quick_replies = [
{"content_type": "text", "title": "⭐️⭐️⭐️⭐️⭐️", "payload": "RATING_5"},
{"content_type": "text", "title": "⭐️⭐️⭐️⭐️", "payload": "RATING_4"},
{"content_type": "text", "title": "⭐️⭐️⭐️", "payload": "RATING_3"},
{"content_type": "text", "title": "⭐️⭐️", "payload": "RATING_2"},
{"content_type": "text", "title": "⭐️", "payload": "RATING_1"}
]
send_message(user_id, rating_text, quick_replies)
def should_send_rating(user_id):
session = session_contexts.get(user_id)
if not session or session.get("next_step") != "end_conversation":
return False
conv = session.get("conversation", [])
return len(conv) >= 4 and not any(msg.get("content") == "RATING_SENT" for msg in conv)
def send_message(recipient_id, text, quick_replies=None):
if len(text) > 2000:
text = text[:1997] + "..."
if quick_replies is None:
quick_replies = get_contextual_quick_replies(recipient_id)
# Lấy user_name từ session
user_session = session_contexts.get(recipient_id, {"context": {"user_name": None}})
user_name = user_session["context"].get("user_name", "bạn")
# Cá nhân hóa phản hồi với user_name
text = personalize_response(text, user_name)
payload = {
"recipient": {"id": recipient_id},
"message": {"text": text, "quick_replies": quick_replies},
"messaging_type": "RESPONSE"
}
headers = {"Content-Type": "application/json"}
response = requests_session.post(
f"https://graph.facebook.com/v21.0/me/messages?access_token={PAGE_ACCESS_TOKEN}",
json=payload,
headers=headers
)
if response.status_code == 200:
logger.info(f"Sent message to {recipient_id}")
else:
logger.error(f"Failed to send message: {response.text}")
if should_send_rating(recipient_id):
send_rating_request(recipient_id)
user_session["conversation"].append({"role": "system", "content": "RATING_SENT"})
@app.route("/webhook", methods=["GET"])
def verify():
token = request.args.get("hub.verify_token")
if token == VERIFY_TOKEN:
return request.args.get("hub.challenge")
return "Forbidden", 403
# Bộ đệm để theo dõi sự kiện webhook
recent_webhook_events = {}
@app.route("/webhook", methods=["POST"])
def webhook():
global recent_webhook_events
data = request.get_json()
if not data or data.get("object") != "page":
logger.debug("Invalid webhook data received")
return "EVENT_RECEIVED", 200
current_time = int(time.time() * 1000)
for entry in data.get("entry", []):
for event in entry.get("messaging", []):
sender_id = event["sender"]["id"]
recipient_id = event["recipient"]["id"]
message_id = event.get("message", {}).get("mid")
timestamp = event.get("timestamp", current_time)
# Kiểm tra sự kiện trùng lặp
event_key = (sender_id, message_id, timestamp)
if message_id and event_key in recent_webhook_events:
last_time = recent_webhook_events[event_key]
if (current_time - last_time) / 1000 < 5: # Bỏ qua nếu cách nhau dưới 5 giây
logger.debug(f"Skipping duplicate webhook event for user {sender_id}: {message_id}")
continue
if message_id:
recent_webhook_events[event_key] = current_time
# Xóa sự kiện cũ hơn 1 phút
recent_webhook_events.update({
k: v for k, v in recent_webhook_events.items()
if (current_time - v) / 1000 < 60
})
if "message" in event and event["message"].get("is_echo") and sender_id == PAGE_ID:
page_messages[timestamp] = {
"text": event["message"].get("text", ""),
"app_id": event["message"].get("app_id", ""),
"recipient_id": recipient_id
}
logger.info(f"Page message recorded: {timestamp} - {recipient_id}")
if recipient_id in session_contexts:
session = session_contexts[recipient_id]
app_id = event["message"].get("app_id", "")
if app_id and str(app_id) != BOT_APP_ID:
session["conversation"].append({
"role": "consultant",
"content": event["message"].get("text", "")
})
save_user_context(recipient_id, session)
if session["state"] != "stopped" and not check_last_page_message(recipient_id, session["session_start_time"]):
handle_stop_chat(recipient_id)
continue
if sender_id != PAGE_ID and recipient_id == PAGE_ID:
if "message" in event:
message_text = event["message"].get("text", "")
image_url = next((att.get("payload", {}).get("url") for att in event["message"].get("attachments", []) if att.get("type") == "image"), None)
logger.info(f"Received message from user {sender_id}: text='{message_text[:100]}', image_url={image_url}")
if message_text.lower() == "dừng chat":
handle_stop_chat(sender_id)
else:
handle_message(sender_id, message_text, image_url, timestamp)
elif "postback" in event:
payload = event["postback"]["payload"]
logger.info(f"Received postback from user {sender_id}: payload={payload}")
if payload == "STOP_CHAT":
handle_stop_chat(sender_id)
elif payload == "CONTINUE_CHAT":
handle_continue_chat(sender_id, timestamp)
return "EVENT_RECEIVED", 200
def check_last_page_message(user_id, session_start_time):
current_time = int(time.time() * 1000)
ten_minutes_ago = current_time - (10 * 60 * 1000)
start_time = max(session_start_time, ten_minutes_ago)
for timestamp in list(page_messages.keys()):
if timestamp < ten_minutes_ago:
del page_messages[timestamp]
for timestamp, msg in page_messages.items():
if msg["recipient_id"] == user_id and start_time <= timestamp <= current_time:
app_id = msg.get("app_id")
if app_id and str(app_id) != BOT_APP_ID:
return False
return True
def handle_stop_chat(user_id, notify=True):
if user_id in session_contexts:
session = session_contexts[user_id]
current_time = int(time.time() * 1000)
if session["state"] != "stopped" or ("last_stopped_time" not in session or current_time - session["last_stopped_time"] > 60000):
session["state"] = "stopped"
session["last_stopped_time"] = current_time
if session["timer"]:
session["timer"].cancel()
if notify:
send_message(user_id, "🤖 Trợ lý ảo đã dừng 😊")
save_user_context(user_id, session)
def handle_continue_chat(user_id, timestamp):
context = load_user_context(user_id)
session = session_contexts.get(user_id, {
"conversation": context["conversation"],
"pending_messages": [],
"last_message_time": timestamp,
"session_start_time": timestamp,
"state": "consulting",
"timer": None,
"last_stopped_time": 0,
"context": context["context"],
"next_step": None
})
session["state"] = "consulting"
session["last_message_time"] = timestamp
session_contexts[user_id] = session
send_message(user_id, "🤖 Trợ lý ảo đã quay lại rồi đây! 😊 Bạn cần mình hỗ trợ gì ạ? 🦷")
logger.info(f"Bot continued for user {user_id}")
def summarize_conversation(conversation):
if len(conversation) <= 10:
return conversation
summary_prompt = f"""
Tóm tắt ngắn gọn lịch sử hội thoại sau thành 100-200 từ, giữ các thông tin quan trọng về tình trạng răng, yêu cầu của khách, và ngữ cảnh:
{json.dumps(conversation, ensure_ascii=False)}
"""
try:
response = track_gemini_call(lambda: model.generate_content(summary_prompt))
summary = response.text
return [{"role": "system", "content": f"Tóm tắt hội thoại: {summary}"}]
except Exception as e:
logger.error(f"Failed to summarize conversation: {str(e)}")
return conversation[-10:]
def handle_message(user_id, message_text, image_url, timestamp):
global recent_messages # Sử dụng biến toàn cục recent_messages
# Kiểm tra tin nhắn trùng lặp dựa trên user_id, text và timestamp
message_key = (user_id, message_text, image_url)
current_time = timestamp / 1000
if message_key in recent_messages:
last_time = recent_messages[message_key]
if current_time - last_time < 10: # Bỏ qua nếu tin nhắn cách nhau dưới 10 giây
logger.debug(f"Skipping duplicate message for user {user_id}: {message_text}")
return
recent_messages[message_key] = current_time
# Xóa các tin nhắn cũ hơn 1 phút
recent_messages.update({k: v for k, v in recent_messages.items() if current_time - v < 60})
condition_map = {
"CONDITION_CARIES": "sâu răng",
"CONDITION_CHIPPED": "mẻ răng",
"CONDITION_SENSITIVE": "ê buốt",
"CONDITION_MISALIGNED": "lệch răng",
"CONDITION_OTHER": "tình trạng khác"
}
severity_map = {
"SEVERITY_MILD": "nhẹ",
"SEVERITY_MODERATE": "vừa",
"SEVERITY_SEVERE": "nặng",
"SEVERITY_NONE": "không đau"
}
position_map = {
"POSITION_FRONT": "răng cửa",
"POSITION_BACK": "răng hàm",
"POSITION_UNKNOWN": "không xác định"
}
help_options = {
"HELP_ADVICE": "Tư vấn",
"HELP_PRICE": "Báo giá",
"HELP_BOOKING": "Đặt lịch",
"HELP_INFO": "Thông tin phòng khám"
}
context = load_user_context(user_id)
session = session_contexts.get(user_id, {
"conversation": context["conversation"],
"pending_messages": [],
"last_message_time": timestamp,
"session_start_time": timestamp,
"state": "consulting",
"timer": None,
"last_stopped_time": 0,
"context": context["context"],
"next_step": None
})
session_contexts[user_id] = session
# Lấy và lưu tên từ API
logger.debug(f"Calling fetch_and_store_user_name for user {user_id}")
fetch_and_store_user_name(user_id, session)
if session["timer"]:
session["timer"].cancel()
logger.debug(f"Cancelled existing timer for user {user_id}")
# Tạo message_content
message_content = {
"text": message_text,
"image_url": image_url,
"timestamp": timestamp / 1000
} if image_url else {
"text": message_text,
"timestamp": timestamp / 1000
}
if message_text in help_options:
session["next_step"] = {
"HELP_ADVICE": "ask_condition",
"HELP_PRICE": "report_price",
"HELP_BOOKING": "book_appointment",
"HELP_INFO": "provide_info"
}.get(message_text, "continue")
session["conversation"].append({"role": "user", "content": message_content})
session["last_message_time"] = timestamp
if message_text == "HELP_ADVICE":
send_message(user_id, f"Dạ, {session['context'].get('user_name', 'bạn')} đang gặp vấn đề gì về răng miệng ạ? 😊 Ví dụ như sâu răng, ê buốt, hay mẻ răng?")
elif message_text == "HELP_PRICE":
send_message(user_id, f"Dạ, {session['context'].get('user_name', 'bạn')} muốn biết giá dịch vụ nào ạ? 😊 Ví dụ như trám răng, niềng răng hay tẩy trắng?")
elif message_text == "HELP_BOOKING":
send_message(user_id, f"Dạ, {session['context'].get('user_name', 'bạn')} muốn đặt lịch khám khi nào ạ? 😊 Cho mình xin tên và số điện thoại để đặt lịch nhé!")
elif message_text == "HELP_INFO":
clinic_info = clinic_data.get("clinic", {})
send_message(user_id, f"Phòng Khám Răng Hàm Mặt Trần Thanh Long 1979:\n- Địa chỉ: {clinic_info.get('address', '160 Trần Phú, Vĩnh Thanh Vân, Rạch Giá, Kiên Giang')}\n- Zalo: {clinic_info.get('zalo', '0832.919.878')}\n- SĐT: {clinic_info.get('phone', '0849.421.979 - 0832.919.878')}\n{session['context'].get('user_name', 'bạn')} cần thêm thông tin gì ạ? 😊")
logger.debug(f"Calling save_user_context after processing help option for user {user_id}")
save_user_context(user_id, session)
return
if message_text in condition_map:
message_content["text"] = f"Tình trạng: {condition_map[message_text]}"
session["context"]["condition"] = condition_map[message_text]
elif message_text in severity_map:
message_content["text"] = f"Mức độ: {severity_map[message_text]}"
session["context"]["severity"] = severity_map[message_text]
elif message_text in position_map:
message_content["text"] = f"Vị trí: {position_map[message_text]}"
session["context"]["position"] = position_map[message_text]
if session["state"] == "stopped":
logger.debug(f"Session stopped for user {user_id}, ignoring message")
return
time_diff = (timestamp - session["last_message_time"]) / 1000
if time_diff > INACTIVITY_TIMEOUT:
session["state"] = "stopped"
session["last_stopped_time"] = timestamp
logger.info(f"Session stopped due to inactivity for user {user_id}")
return
logger.debug(f"Adding message to pending for user {user_id}: {message_content}")
session["pending_messages"].append(message_content)
session["conversation"].append({"role": "user", "content": message_content})
session["last_message_time"] = timestamp
session["timer"] = Timer(DELAY_PROCESSING, lambda: process_message(user_id))
session["timer"].start()
logger.debug(f"Started timer for user {user_id} with delay {DELAY_PROCESSING}s")
def process_message(user_id):
session_data = session_contexts.get(user_id, {"context": {"user_name": None}, "conversation": []})
user_name = session_data["context"].get("user_name", "bạn")
# Lấy tin nhắn đang chờ xử lý
pending_messages = session_data.get("pending_messages", [])
if not pending_messages:
logger.debug(f"No pending messages for user {user_id}")
return
# Tạo prompt từ tin nhắn người dùng (gộp tất cả pending_messages)
prompt = " ".join(msg["text"] for msg in pending_messages)
# Tạo prompt chính
master_prompt = create_master_prompt(user_id, user_name, session_data, prompt)
# Chuẩn bị lịch sử hội thoại
conversation = session_data.get("conversation", [])
messages = [{"role": "user", "parts": [{"text": master_prompt}]}]
# Chuyển đổi hội thoại cũ sang định dạng Gemini API
for msg in conversation:
if isinstance(msg["content"], dict):
content_text = msg["content"].get("text", "")
else:
content_text = str(msg["content"])
role = "model" if msg["role"] in ["assistant", "system"] else "user"
messages.append({"role": role, "parts": [{"text": content_text}]})
# Thêm tin nhắn mới từ người dùng
for msg in pending_messages:
messages.append({"role": "user", "parts": [{"text": msg["text"]}]})
try:
# Gọi Gemini API
response = model.generate_content(messages)
response_text = response.text
logger.debug(f"Raw Gemini response for user {user_id}: {response_text}")
# Phân tích JSON từ response_text
reply_text = ""
context_update = {}
next_step = "continue"
# Tách văn bản và JSON
json_match = re.search(r'(\{.*\})', response_text, re.DOTALL)
if json_match:
json_str = json_match.group(1)
try:
response_json = json.loads(json_str)
if isinstance(response_json, dict) and "response" in response_json:
reply_text = response_json["response"]
context_update = response_json.get("context_update", {})
next_step = response_json.get("next_step", "continue")
logger.debug(f"Parsed JSON response: reply_text={reply_text}, context_update={context_update}, next_step={next_step}")
else:
logger.warning(f"Gemini response JSON lacks 'response' key for user {user_id}: {response_json}")
reply_text = clean_text(response_text.split(json_str)[0])
except json.JSONDecodeError as e:
logger.error(f"Failed to parse JSON part for user {user_id}: {str(e)}")
reply_text = clean_text(response_text.split(json_str)[0])
else:
# Không tìm thấy JSON, thử phân tích toàn bộ response_text
try:
cleaned_text = re.sub(r'```json\s*|\s*```', '', response_text).strip()
response_json = json.loads(cleaned_text)
if isinstance(response_json, dict) and "response" in response_json:
reply_text = response_json["response"]
context_update = response_json.get("context_update", {})
next_step = response_json.get("next_step", "continue")
logger.debug(f"Parsed JSON response: reply_text={reply_text}, context_update={context_update}, next_step={next_step}")
else:
logger.warning(f"Gemini response JSON lacks 'response' key for user {user_id}: {response_json}")
reply_text = clean_text(response_text)
except json.JSONDecodeError as e:
logger.error(f"Failed to parse Gemini response as JSON for user {user_id}: {str(e)}")
reply_text = clean_text(response_text)
# Làm sạch reply_text
reply_text = clean_text(reply_text)
if not reply_text:
reply_text = "Dạ, em chưa hiểu rõ! 😅 Mình mô tả thêm tình trạng răng nhé? 🦷"
# Kiểm tra giá dịch vụ trước khi trả lời
service = context_update.get("service")
if service and service not in clinic_data["services"]["adults"] and service not in clinic_data["services"]["children"]:
reply_text = f"Dạ, hiện tại dịch vụ **{service}** mình chưa có thông tin giá cụ thể. 😊 Bạn vui lòng đến phòng khám để bác sĩ kiểm tra và báo giá chính xác nhé! 🦷"
context_update = {}
next_step = "continue"
# Ghi log phản hồi trước khi gửi
logger.debug(f"Sending message to user {user_id}: {reply_text}")
# Gửi phản hồi
send_message(user_id, reply_text)
# Cập nhật hội thoại
for msg in pending_messages:
conversation.append({"role": "user", "content": msg["text"]})
conversation.append({"role": "assistant", "content": reply_text})
session_data["conversation"] = conversation
# Cập nhật ngữ cảnh
if context_update:
session_data["context"].update(context_update)
session_data["next_step"] = next_step
save_user_context(user_id, session_data)
except Exception as e:
logger.error(f"Error processing message for user {user_id}: {str(e)}", exc_info=True)
reply_text = "Dạ, em gặp chút lỗi! 😅 Mình mô tả thêm tình trạng răng nhé? 🦷"
logger.debug(f"Sending message to user {user_id}: {reply_text}")
send_message(user_id, reply_text)
# Xóa tin nhắn đã xử lý
session_data["pending_messages"] = []
def create_master_prompt(user_id, user_name, session, prompt, image_urls=None):
greeting = "Chào bạn 😊" if not user_name else f"Chào bạn {user_name} 😊"
name_reference = "bạn" if not user_name else f"bạn {user_name} hoặc {user_name}"
# Giới hạn lịch sử hội thoại
max_history = 10
conversation = summarize_conversation(session["conversation"])[-max_history:]
# Chọn lọc dữ liệu phòng khám
relevant_services = clinic_data["services"] if "giá" in prompt.lower() else {}
clinic_info = clinic_data["clinic"] if "đặt lịch" in prompt.lower() or "thông tin" in prompt.lower() else {}
promotions = clinic_data["promotions"] if "khuyến mãi" in prompt.lower() else []
image_instruction = ""
if image_urls:
image_instruction = f"""
Hình ảnh: {', '.join(image_urls)}.
- Phân tích hình ảnh để xác định tình trạng răng miệng (sâu răng, mẻ răng, lệch răng, v.v.).
- Đề xuất dịch vụ phù hợp (trám răng, niềng răng, tẩy trắng).
- Nếu hình ảnh không rõ, yêu cầu thêm thông tin.
"""
return f"""
Bạn là Tử Tế, trợ lý chatbot của Phòng Khám Răng Hàm Mặt Trần Thanh Long 1979. Tư vấn nha khoa thân thiện, tự nhiên với tiêu chí **Tối ưu hoá trải nghiệm khách hàng**, dùng emoji 😊🦷 và **text** in đậm để nhấn mạnh thông tin.
**Thông tin khách hàng**:
- Tên: {'Không có' if not user_name else user_name}
Nhiệm vụ:
1. **Hiểu ý định**:
- Dựa trên lịch sử hội thoại ({json.dumps(conversation, ensure_ascii=False)}) và ngữ cảnh ({json.dumps(session["context"], ensure_ascii=False)}), nhận diện ý định: tình trạng răng, báo giá, đặt hẹn.
- Nếu khách là trẻ em (dựa trên từ khóa như "con tôi", "bé" hoặc hình ảnh), đặt target_group là "children"; ngược lại là "adults".
- Tình trạng: sâu răng, ê buốt, mẻ, lệch, v.v.
- Mức độ đau: không đau, nhẹ, vừa, nặng (nhận biết các từ đồng nghĩa như ít, nhiều, hơi hơi, vừa vừa,...hay các từ tương tự chỉ mức độ).
- Vị trí: răng cửa, răng hàm, răng nhai, hoặc mô tả cụ thể.
{image_instruction}
2. **Tư vấn**:
- Hỏi theo trình tự: tình trạng → mức độ đau → vị trí → báo giá → đặt hẹn, tuỳ theo loại dịch vụ mà xác định cần khai thác thông tin gì.
- Nếu thiếu thông tin, hỏi nhẹ nhàng, tránh lặp lại.
- Dùng thông tin trong ({json.dumps(clinic_data["services"], ensure_ascii=False)}) kết hợp với kiến thức bạn có, phân tích vấn đề khách đang gặp phải và phương pháp điều trị dựa trên thông tin trước đó đã khai thác được.
- Đề xuất dịch vụ từ {json.dumps(relevant_services, ensure_ascii=False)} nếu thông tin đã khai thác có các dấu hiệu và triệu chứng phù hợp.
- Chỉ đề cập đến các ưu đãi phù hợp có trong {json.dumps(relevant_services, ensure_ascii=False)}, không tự ý thêm ưu đãi.
- Khuyến khích khám trực tiếp, **tránh khuyến khích liên tục gây khó chịu**.
3. **Báo giá**:
- Chỉ dùng giá từ {json.dumps(relevant_services, ensure_ascii=False)}, **nghiêm cấm tự ý thêm giá**. Các dịch vụ không có giá thì tư vấn chuyên sâu như bình thường rồi hướng dẫn khách đến khám trực tiếp để báo giá.
- Cần khai thác tình trạng và vị trí răng trước khi báo giá.
- Nhấn mạnh **giá tham khảo**, cần khám để báo giá chính xác.
4. **Đặt hẹn**:
- **Bắt buộc khai thác thông tin đặt hẹn** : tên khách hàng, số điện thoại, địa chỉ khách hàng (không cần quá chi tiết), thời gian hẹn chính xác.
- Cung cấp thông tin từ {json.dumps(clinic_info, ensure_ascii=False)}.
- Khi xác nhận lịch hẹn chỉ xác nhận một lần, cung cấp thêm thông tin phòng khám từ {json.dumps(clinic_data["clinic"], ensure_ascii=False)} (địa chỉ, số điện thoại phòng khám) cho khách hàng.
- Ước tính quãng đường và thời gian di chuyển của khách, quan tâm dặn dò khách nếu cần thiết.
5. **Phản hồi**:
- Gọi khách hàng là "{name_reference}" khi cần nhắc đến họ, nhưng giữ tự nhiên, không lặp tên quá nhiều (tối đa 1-2 lần trong câu). Xưng bản thân là "mình".
- Trả lời ngắn gọn, tự nhiên, đồng cảm và quan tâm khách hàng.
- Không lặp lại lời chào nhiều lần.
- Tư vấn xử lý vấn đề bước đầu khi cần thiết. (Ví dụ khách đang bị đau nhức hay ê buốt, có thể hướng dẫn khách ngậm nước muối trước để giảm cảm giác ê buốt chờ ra khám kiểm tra. v.v)
- Khéo léo xử lý các tình huống không có thông tin trong {json.dumps(clinic_data)}, đảm bảo thể hiện sự chuyên nghiệp, tận tâm.
- Nếu không rõ, hỏi lại (VD: "Dạ, hình ảnh mờ, mình mô tả thêm nhé? 😊").
6. **Tuyển dụng**:
- Phòng khám hiện đang tuyển dụng các vị trí điều dưỡng nha khoa (phụ tá), bác sĩ nha khoa.
- Khi có khách hàng muốn ứng tuyển, yêu cầu khách gửi CV cá nhân và vị trí ứng tuyển mong muốn. Lịch phỏng vấn sẽ được thông báo qua cuộc gọi.
**Đầu vào**:
- Hội thoại: {json.dumps(conversation, ensure_ascii=False)}
- Ngữ cảnh: {json.dumps(session["context"], ensure_ascii=False)}
- Tin nhắn: {prompt}
- Dịch vụ: {json.dumps(relevant_services, ensure_ascii=False)}
- Phòng khám: {json.dumps(clinic_info, ensure_ascii=False)}
- Khuyến mãi: {json.dumps(promotions, ensure_ascii=False)}
**Đầu ra** (JSON):
- "response": Phản hồi.
- "context_update": Cập nhật ngữ cảnh.
- "next_step": Bước tiếp (ask_condition, ask_severity, ask_position, report_price, book_appointment, confirm_appointment, continue).
**Ví dụ**:
- Đầu vào: prompt="Răng tôi bị sâu", conversation=[{{"role": "user", "content": "Răng tôi bị sâu"}}]
- Đầu ra:
```json
{{"response": "Dạ, răng sâu ở răng cửa hay răng hàm ạ? 😊", "context_update": {{"condition": "sâu răng"}}, "next_step": "ask_position"}}
```
Bắt đầu xử lý: {prompt}
"""
def fetch_and_encode_image(image_url):
try:
response = requests_session.get(image_url, timeout=10)
if response.status_code != 200:
return None
content_type = response.headers.get("content-type", "image/jpeg")
if not content_type.startswith("image/"):
return None
image_data = base64.b64encode(response.content).decode("utf-8")
return {"mime_type": content_type, "data": image_data}
except Exception as e:
logger.error(f"Error processing image from {image_url}: {str(e)}")
return None
def generate_response(user_id, prompt, image_urls=None):
session = session_contexts[user_id]
if prompt.lower() in ["chào", "hi", "hello"]:
reply_text = "Chào bạn ạ! 😊 Mình là *Tử Tế* từ Phòng Khám Răng Hàm Mặt Trần Thanh Long 1979. Bạn cần hỗ trợ gì ạ? 🦷✨"
session["conversation"].append({"role": "assistant", "content": reply_text})
session["next_step"] = "ask_condition"
return reply_text
prompt_template = create_master_prompt(user_id, session["context"].get("user_name", "bạn"), session, prompt, image_urls)
max_retries = 3
reply_text = ""
context_update = {}
next_step = "continue"
for attempt in range(max_retries):
try:
content = [{"text": prompt_template}]
if image_urls:
for url in image_urls[:1]:
image_data = fetch_and_encode_image(url)
if image_data:
content.append({"inline_data": image_data})
response = track_gemini_call(lambda: model.generate_content(content))
response_text = response.text if response and hasattr(response, 'text') else None
if not response_text:
continue
logger.debug(f"Raw Gemini response for user {user_id}: {response_text}")
# Phân tích JSON từ response_text
json_match = re.search(r'(\{.*\})', response_text, re.DOTALL)
if json_match:
json_str = json_match.group(1)
try:
response_json = json.loads(json_str)
if isinstance(response_json, dict) and "response" in response_json:
reply_text = response_json["response"]
context_update = response_json.get("context_update", {})
next_step = response_json.get("next_step", "continue")
logger.debug(f"Parsed JSON response: reply_text={reply_text}, context_update={context_update}, next_step={next_step}")
else:
logger.warning(f"Gemini response JSON lacks 'response' key for user {user_id}: {response_json}")
reply_text = clean_text(response_text.split(json_str)[0])
except json.JSONDecodeError as e:
logger.error(f"Failed to parse JSON part for user {user_id}: {str(e)}")
reply_text = clean_text(response_text.split(json_str)[0])
else:
try:
cleaned_text = re.sub(r'```json\s*|\s*```', '', response_text).strip()
response_json = json.loads(cleaned_text)
if isinstance(response_json, dict) and "response" in response_json:
reply_text = response_json["response"]
context_update = response_json.get("context_update", {})
next_step = response_json.get("next_step", "continue")
logger.debug(f"Parsed JSON response: reply_text={reply_text}, context_update={context_update}, next_step={next_step}")
else:
logger.warning(f"Gemini response JSON lacks 'response' key for user {user_id}: {response_json}")
reply_text = clean_text(response_text)
except json.JSONDecodeError as e:
logger.error(f"Failed to parse Gemini response as JSON for user {user_id}: {str(e)}")
reply_text = clean_text(response_text)
break
except Exception as e:
logger.error(f"Gemini call failed for user {user_id} (attempt {attempt + 1}): {str(e)}")
if attempt == max_retries - 1:
reply_text = "Dạ, mình gặp lỗi! 😅 Bạn mô tả thêm chút về tình trạng răng nhé? 🦷"
reply_text = clean_text(reply_text)
if not reply_text:
reply_text = "Dạ, mình chưa hiểu rõ! 😅 Bạn vui lòng giải thích thêm xíu nhé? 🦷"
if context_update:
session["context"].update(context_update)
session["next_step"] = next_step
logger.debug(f"Sending message to user {user_id}: {reply_text}")
return reply_text
if __name__ == "__main__":
app.run(host="0.0.0.0", port=int(os.environ.get("PORT", 5000)))