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)))