#!/usr/bin/env python3 """ Apex Biotical Veterinary WhatsApp Assistant - Premium Edition The most effective and accurate veterinary Assistant in the market """ import os import pandas as pd import requests import json from fastapi import FastAPI, Request, Response, Form, HTTPException, File, UploadFile from fastapi.responses import JSONResponse, HTMLResponse, FileResponse import time import re from typing import List, Dict, Any, Optional, Tuple import openai from dotenv import load_dotenv from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates import uvicorn from datetime import datetime, timedelta from rapidfuzz import process, fuzz from deep_translator import GoogleTranslator import numpy as np import logging import base64 import tempfile from reportlab.pdfgen import canvas from reportlab.lib.pagesizes import letter, A4 from reportlab.lib.units import inch from reportlab.lib import colors from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle, PageBreak from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle from reportlab.lib.enums import TA_CENTER, TA_LEFT, TA_JUSTIFY import io import pathlib from collections import defaultdict, Counter import hashlib import aiofiles import asyncio from difflib import SequenceMatcher import httpx import langdetect from langdetect import detect import threading import shutil # Configure advanced logging logging.basicConfig( level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', handlers=[ logging.FileHandler('veterinary_bot.log', encoding='utf-8'), logging.StreamHandler() ] ) logger = logging.getLogger(__name__) # Load environment variables load_dotenv() # Initialize FastAPI app app = FastAPI(title="Apex Biotical Veterinary Assistant", version="2.0.0") # Ensure static and uploads directories exist before mounting os.makedirs('static', exist_ok=True) os.makedirs('uploads', exist_ok=True) # Mount static files and templates app.mount("/static", StaticFiles(directory="static"), name="static") app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads") templates = Jinja2Templates(directory="templates") # Global variables with enhanced data structures CSV_FILE = "Veterinary.csv" products_df = None user_contexts = {} last_products = {} conversation_history = defaultdict(list) product_analytics = defaultdict(int) session_data = {} # Environment variables WHATSJET_API_URL = os.getenv("WHATSJET_API_URL") WHATSJET_VENDOR_UID = os.getenv("WHATSJET_VENDOR_UID") WHATSJET_API_TOKEN = os.getenv("WHATSJET_API_TOKEN") OPENAI_API_KEY = os.getenv("OPENAI_API_KEY") SERVER_URL = os.getenv("SERVER_URL", "https://your-huggingface-space-url.hf.space") # Initialize OpenAI client if OPENAI_API_KEY: openai.api_key = OPENAI_API_KEY logger.info("✅ OpenAI client initialized successfully") else: logger.warning("⚠️ OpenAI API key not found - voice transcription will be disabled") # Veterinary domain-specific constants VETERINARY_CATEGORIES = { 'antibiotic': ['Antibiotic / Quinolone', 'Antibiotic / Respiratory Infections', 'Veterinary Injectable Solution (Antibiotic)'], 'respiratory': ['Respiratory Support', 'Respiratory / Mucolytic', 'Respiratory Support and Hygiene Enhancer'], 'liver': ['Liver & Kidney Support', 'Liver Tonic and Hepatoprotective Supplement'], 'vitamin': ['Multivitamin Supplement', 'Multivitamin Supplement for veterinary use', 'Vitamin and Amino Acid Supplement (Injectable Solution)'], 'supplement': ['Nutritional Supplement / Mycotoxins', 'Immunity Enhancer and Antioxidant Supplement'], 'mycotoxin': ['Mycotoxin Binder'], 'heat_stress': ['Heat Stress Support'], 'anticoccidial': ['Anticoccidial / Sulfonamide'], 'phytogenic': ['Phytogenic / Antibiotic Alternative'] } VETERINARY_SYMPTOMS = { 'respiratory': ['cough', 'breathing', 'respiratory', 'bronchitis', 'pneumonia', 'crd', 'coryza', 'flu'], 'liver': ['liver', 'hepatitis', 'jaundice', 'ascites', 'fatty liver'], 'diarrhea': ['diarrhea', 'diarrhoea', 'loose stool', 'gastroenteritis'], 'stress': ['stress', 'heat stress', 'transport', 'vaccination'], 'infection': ['infection', 'bacterial', 'viral', 'fungal', 'septicemia'], 'deficiency': ['vitamin deficiency', 'mineral deficiency', 'anemia'], 'mycotoxin': ['mycotoxin', 'mold', 'fungal toxin', 'aflatoxin'] } VETERINARY_SPECIES = { 'poultry': ['chicken', 'broiler', 'layer', 'turkey', 'duck', 'quail', 'poultry'], 'livestock': ['cattle', 'cow', 'buffalo', 'sheep', 'goat', 'livestock'], 'pet': ['dog', 'cat', 'pet', 'companion animal'] } # Menu Configuration - Define each menu with its valid options MENU_CONFIG = { 'main_menu': { 'name': 'Main Menu', 'valid_options': ['1', '2', '3', '4'], 'option_descriptions': { '1': 'Search Products', '2': 'Browse Categories', '3': 'Download Catalog', '4': 'Chat with Veterinary AI Assistant' } }, 'category_selection_menu': { 'name': 'Category Selection Menu', 'valid_options': [], # Will be populated dynamically based on available categories 'option_descriptions': {} }, 'category_products_menu': { 'name': 'Category Products Menu', 'valid_options': [], # Will be populated dynamically based on available products 'option_descriptions': {} }, 'all_products_menu': { 'name': 'All Products Menu', 'valid_options': [], # Will be populated dynamically based on all products 'option_descriptions': {} }, 'intelligent_products_menu': { 'name': 'Intelligent Products Menu', 'valid_options': [], # Will be populated dynamically based on available products 'option_descriptions': {} }, 'product_inquiry': { 'name': 'Product Inquiry Menu', 'valid_options': ['1', '2', '3'], 'option_descriptions': { '1': 'Talk to Veterinary Consultant', '2': 'Inquire about Product Availability', '3': 'Back to Main Menu' } }, 'ai_chat': { 'name': 'AI Chat Mode', 'valid_options': ['main'], 'option_descriptions': { 'main': 'Return to Main Menu' } } } def validate_menu_selection(selection: str, current_state: str, user_context: dict) -> tuple[bool, str]: """ Validate if a selection is valid for the current menu Returns (is_valid, error_message) """ if current_state not in MENU_CONFIG: return False, f"❌ Unknown menu state: {current_state}" menu_config = MENU_CONFIG[current_state] valid_options = menu_config['valid_options'] # For dynamic menus, get valid options from context if current_state == 'category_selection_menu': available_categories = user_context.get('available_categories', []) valid_options = [str(i) for i in range(1, len(available_categories) + 1)] elif current_state == 'category_products_menu': available_products = user_context.get('available_products', []) valid_options = [str(i) for i in range(1, len(available_products) + 1)] elif current_state == 'all_products_menu': if products_df is not None and not products_df.empty: valid_options = [str(i) for i in range(1, len(products_df) + 1)] elif current_state == 'intelligent_products_menu': available_products = user_context.get('available_products', []) valid_options = [str(i) for i in range(1, len(available_products) + 1)] # Check if selection is valid if selection in valid_options: return True, "" # Generate error message with valid options if valid_options: error_msg = f"❌ Invalid selection for {menu_config['name']}. Valid options: {', '.join(valid_options)}" else: error_msg = f"❌ Invalid selection for {menu_config['name']}. No options available." return False, error_msg def get_menu_info(current_state: str, user_context: dict) -> dict: """ Get information about the current menu including valid options """ if current_state not in MENU_CONFIG: return {"name": "Unknown Menu", "valid_options": [], "option_descriptions": {}} menu_config = MENU_CONFIG[current_state].copy() # For dynamic menus, populate valid options from context if current_state == 'category_selection_menu': available_categories = user_context.get('available_categories', []) menu_config['valid_options'] = [str(i) for i in range(1, len(available_categories) + 1)] menu_config['option_descriptions'] = { str(i): category for i, category in enumerate(available_categories, 1) } elif current_state == 'category_products_menu': available_products = user_context.get('available_products', []) menu_config['valid_options'] = [str(i) for i in range(1, len(available_products) + 1)] menu_config['option_descriptions'] = { str(i): product.get('Product Name', f'Product {i}') for i, product in enumerate(available_products, 1) } elif current_state == 'all_products_menu': if products_df is not None and not products_df.empty: all_products = products_df.to_dict('records') menu_config['valid_options'] = [str(i) for i in range(1, len(all_products) + 1)] menu_config['option_descriptions'] = { str(i): product.get('Product Name', f'Product {i}') for i, product in enumerate(all_products, 1) } elif current_state == 'intelligent_products_menu': available_products = user_context.get('available_products', []) menu_config['valid_options'] = [str(i) for i in range(1, len(available_products) + 1)] menu_config['option_descriptions'] = { str(i): product.get('Product Name', f'Product {i}') for i, product in enumerate(available_products, 1) } return menu_config # Voice processing functions async def download_voice_file(media_url: str, filename: str) -> str: """Download voice file from WhatsApp""" try: # Create temp_voice directory if it doesn't exist os.makedirs('temp_voice', exist_ok=True) # Download the file async with httpx.AsyncClient() as client: response = await client.get(media_url) response.raise_for_status() file_path = os.path.join('temp_voice', filename) with open(file_path, 'wb') as f: f.write(response.content) logger.info(f"Voice file downloaded: {file_path}") return file_path except Exception as e: logger.error(f"Error downloading voice file: {e}") return None async def transcribe_voice_with_openai(file_path: str) -> str: """Transcribe voice file using OpenAI Whisper with comprehensive veterinary domain system prompt""" try: # Check if file exists and has content if not os.path.exists(file_path): logger.error(f"[Transcribe] File not found: {file_path}") return None file_size = os.path.getsize(file_path) if file_size == 0: logger.error(f"[Transcribe] Empty file: {file_path}") return None logger.info(f"[Transcribe] Transcribing file: {file_path} (size: {file_size} bytes)") # Comprehensive system prompt for veterinary WhatsApp assistant system_prompt = """ You are transcribing voice messages for Apex Biotical Veterinary WhatsApp Assistant. This is a professional veterinary products chatbot. CRITICAL: TRANSCRIBE ONLY ENGLISH OR URDU SPEECH - NOTHING ELSE IMPORTANT RULES: 1. ONLY transcribe English or Urdu speech 2. If you hear unclear audio, transcribe as English 3. If you hear mixed languages, transcribe as English 4. Never transcribe gibberish or random characters 5. If audio is unclear, transcribe as "unclear audio" 6. Keep transcriptions simple and clean PRODUCT NAMES (exact spelling required): - Hydropex, Respira Aid Plus, Heposel, Bromacid, Hexatox - APMA Fort, Para C.E, Tribiotic, PHYTO-SAL, Mycopex Super - Eflin KT-20, Salcozine ST-30, Oftilex UA-10, Biscomin 10 - Apvita Plus, B-G Aspro-C, EC-Immune, Liverpex, Symodex - Respira Aid, Adek Gold, Immuno DX MENU COMMANDS: - Numbers: 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 - Navigation: main, menu, back, home, start - Options: option, number, choice, select GREETINGS: - English: hi, hello, hey, good morning, good afternoon, good evening - Urdu: salam, assalamu alaikum, adaab, namaste, khuda hafiz TRANSCRIPTION RULES: 1. Transcribe exactly what you hear in English or Urdu 2. Convert numbers to digits (one->1, two->2, etc.) 3. Preserve product names exactly 4. If unclear, transcribe as "unclear audio" 5. Keep it simple and clean 6. No random characters or mixed languages EXAMPLES: - "hydropex" -> "hydropex" - "respira aid plus" -> "respira aid plus" - "option one" -> "1" - "main menu" -> "main" - "salam" -> "salam" - "search products" -> "search products" - Unclear audio -> "unclear audio" """ # First attempt with comprehensive system prompt with open(file_path, 'rb') as audio_file: transcript = openai.Audio.transcribe( model="whisper-1", file=audio_file, language="en", # Start with English prompt=system_prompt ) transcribed_text = transcript.text.strip() logger.info(f"[Transcribe] Voice transcribed (English): '{transcribed_text}'") # If first attempt failed or seems unclear, try with Urdu-specific prompt if not transcribed_text or len(transcribed_text.strip()) < 2: logger.warning(f"[Transcribe] First attempt failed, trying with Urdu-specific prompt") urdu_system_prompt = """ You are transcribing Urdu voice messages for Apex Biotical Veterinary WhatsApp Assistant. PRODUCT NAMES (Urdu/English): - ہائیڈروپیکس (Hydropex) - ریسپیرا ایڈ پلس (Respira Aid Plus) - ہیپوسیل (Heposel) - بروماسڈ (Bromacid) - ہیکساٹوکس (Hexatox) - اے پی ایم اے فورٹ (APMA Fort) - پیرا سی ای (Para C.E) - ٹرائی بیوٹک (Tribiotic) - فائٹو سال (PHYTO-SAL) - مائیکوپیکس سپر (Mycopex Super) URDU NUMBERS: - ایک (1), دو (2), تین (3), چار (4), پانچ (5) - چھ (6), سات (7), آٹھ (8), نو (9), دس (10) - گیارہ (11), بارہ (12), تیرہ (13), چودہ (14), پندرہ (15) - سولہ (16), سترہ (17), اٹھارہ (18), انیس (19), بیس (20) - اکیس (21), بائیس (22), تئیس (23) URDU GREETINGS: - سلام (salam), السلام علیکم (assalamu alaikum) - آداب (adaab), نمستے (namaste), خدا حافظ (khuda hafiz) URDU MENU COMMANDS: - مین مینو (main menu), آپشن (option), نمبر (number) - تلاش (search), براؤز (browse), ڈاؤن لوڈ (download) - کیٹلاگ (catalog), رابطہ (contact), دستیابی (availability) TRANSCRIPTION RULES: 1. Transcribe Urdu words in Urdu script 2. Convert Urdu numbers to digits 3. Handle mixed Urdu-English speech 4. Preserve product names exactly 5. Convert menu selections to numbers """ with open(file_path, 'rb') as audio_file: transcript = openai.Audio.transcribe( model="whisper-1", file=audio_file, language="ur", # Force Urdu prompt=urdu_system_prompt ) transcribed_text = transcript.text.strip() logger.info(f"[Transcribe] Second attempt transcribed (Urdu): '{transcribed_text}'") # Third attempt with mixed language prompt if still failing if not transcribed_text or len(transcribed_text.strip()) < 2: logger.warning(f"[Transcribe] Second attempt failed, trying with mixed language prompt") mixed_system_prompt = """ You are transcribing voice messages for a veterinary products WhatsApp assistant. The user may speak in English, Urdu, or a mix of both languages. PRODUCT NAMES (exact spelling required): Hydropex, Respira Aid Plus, Heposel, Bromacid, Hexatox, APMA Fort, Para C.E, Tribiotic, PHYTO-SAL, Mycopex Super, Eflin KT-20, Salcozine ST-30, Oftilex UA-10, Biscomin 10, Apvita Plus, B-G Aspro-C, EC-Immune, Liverpex, Symodex, Respira Aid, Adek Gold, Immuno DX NUMBERS (convert to digits): English: one->1, two->2, three->3, etc. Urdu: aik->1, ek->1, do->2, teen->3, etc. MENU COMMANDS: main, menu, back, home, start, option, number, search, browse, download, catalog, contact, availability GREETINGS: hi, hello, salam, assalamu alaikum, adaab, namaste TRANSCRIPTION RULES: 1. Transcribe exactly what you hear 2. Convert numbers to digits 3. Preserve product names exactly 4. Handle both languages 5. Convert menu selections to numbers """ with open(file_path, 'rb') as audio_file: transcript = openai.Audio.transcribe( model="whisper-1", file=audio_file, prompt=mixed_system_prompt ) transcribed_text = transcript.text.strip() logger.info(f"[Transcribe] Third attempt (mixed) transcribed: '{transcribed_text}'") # Final check for empty transcription or unclear audio if not transcribed_text or len(transcribed_text.strip()) < 2: logger.warning(f"[Transcribe] Very short or empty transcription: '{transcribed_text}'") return "unclear audio" # Check for gibberish or mixed characters if len(transcribed_text) > 10 and not re.search(r'[a-zA-Z\u0600-\u06FF]', transcribed_text): logger.warning(f"[Transcribe] Gibberish detected: '{transcribed_text}'") return "unclear audio" # Check for too many special characters special_char_ratio = len(re.findall(r'[^\w\s]', transcribed_text)) / len(transcribed_text) if special_char_ratio > 0.3: logger.warning(f"[Transcribe] Too many special characters: '{transcribed_text}'") return "unclear audio" return transcribed_text except Exception as e: logger.error(f"[Transcribe] Error transcribing voice: {e}") logger.error(f"[Transcribe] File path: {file_path}") return None def process_voice_input(text: str) -> str: """Process and clean voice input text with veterinary domain-specific transcription error correction""" if not text: return "" # Clean the text processed_text = text.strip() # Remove extra whitespace processed_text = re.sub(r'\s+', ' ', processed_text) # Basic punctuation cleanup processed_text = processed_text.replace(' ,', ',').replace(' .', '.') # Veterinary domain-specific transcription error corrections transcription_fixes = { # Common menu selection errors 'opium': 'option', 'opium numara': 'option number', 'opium number': 'option number', 'opium number one': 'option number one', 'opium number two': 'option number two', 'opium number three': 'option number three', 'opium one': 'option one', 'opium two': 'option two', 'opium three': 'option three', 'numara': 'number', 'numbara': 'number', 'numbra': 'number', 'numbra one': 'number one', 'numbra two': 'number two', 'numbra three': 'number three', 'numbra 1': 'number 1', 'numbra 2': 'number 2', 'numbra 3': 'number 3', # Number fixes - only when they appear as standalone numbers 'aik': '1', 'ek': '1', 'do': '2', 'teen': '3', 'char': '4', 'panch': '5', 'che': '3', 'tree': '3', 'free': '3', 'for': '4', 'fiv': '5', 'sik': '6', 'sat': '7', 'ath': '8', 'nau': '9', 'das': '10', # Navigation command fixes 'man': 'main', 'men': 'main', 'mean': 'main', 'mein': 'main', 'maine': 'main', 'menu': 'main', 'home': 'main', 'back': 'main', 'return': 'main', # Veterinary product name corrections 'hydro pex': 'hydropex', 'hydro pex': 'hydropex', 'respira aid': 'respira aid plus', 'respira aid plus': 'respira aid plus', 'hepo sel': 'heposel', 'brom acid': 'bromacid', 'hexa tox': 'hexatox', 'apma fort': 'apma fort', 'para c': 'para c.e', 'para ce': 'para c.e', 'tribiotic': 'tribiotic', 'phyto sal': 'phyto-sal', 'mycopex': 'mycopex super', 'mycopex super': 'mycopex super', 'eflin': 'eflin kt-20', 'salcozine': 'salcozine st-30', 'oftilex': 'oftilex ua-10', 'biscomin': 'biscomin 10', 'apvita': 'apvita plus', 'bg aspro': 'b-g aspro-c', 'ec immune': 'ec-immune', 'liverpex': 'liverpex', 'symodex': 'symodex', 'adek': 'adek gold', 'immuno': 'immuno dx' } # Apply transcription fixes - but be careful with Islamic greetings original_text = processed_text.lower() # Special handling for Islamic greetings - don't change "aik" in "assalamu alaikum" if 'assalamu alaikum' in original_text or 'assalam' in original_text: # Don't apply number fixes to Islamic greetings for wrong, correct in transcription_fixes.items(): if wrong in original_text and wrong not in ['aik', 'ek']: # Skip number fixes for greetings processed_text = processed_text.lower().replace(wrong, correct) logger.info(f"Fixed transcription error: '{wrong}' -> '{correct}' in '{text}'") else: # Apply all fixes for non-greeting text for wrong, correct in transcription_fixes.items(): if wrong in original_text: processed_text = processed_text.lower().replace(wrong, correct) logger.info(f"Fixed transcription error: '{wrong}' -> '{correct}' in '{text}'") logger.info(f"Voice input processed: '{text}' -> '{processed_text}'") return processed_text # Note: Voice messages are now processed exactly like text messages # The transcribed voice text is passed directly to process_incoming_message # This ensures consistent behavior between voice and text inputs # Enhanced product search with veterinary domain expertise def get_veterinary_product_matches(query: str) -> List[Dict[str, Any]]: """ Advanced veterinary product matching with domain-specific intelligence """ if not query: return [] if products_df is None: load_products_data() normalized_query = normalize(query).lower().strip() logger.info(f"[Veterinary Search] Searching for: '{normalized_query}'") # Skip very short queries that are likely menu selections if len(normalized_query) <= 2 and normalized_query.isdigit(): logger.info(f"[Veterinary Search] Skipping menu selection: '{normalized_query}'") return [] scored_matches = [] # Veterinary-specific query expansion expanded_queries = [normalized_query] # Expand by symptoms for symptom_category, symptoms in VETERINARY_SYMPTOMS.items(): if any(symptom in normalized_query for symptom in symptoms): expanded_queries.extend(symptoms) # Expand by species for species_category, species in VETERINARY_SPECIES.items(): if any(sp in normalized_query for sp in species): expanded_queries.extend(species) # Expand by category for category_key, categories in VETERINARY_CATEGORIES.items(): if category_key in normalized_query: expanded_queries.extend(categories) # Common veterinary product variations veterinary_variations = { 'hydropex': ['hydropex', 'hydro pex', 'electrolyte', 'dehydration', 'heat stress'], 'heposel': ['heposel', 'hepo sel', 'liver tonic', 'hepatoprotective'], 'bromacid': ['bromacid', 'brom acid', 'respiratory', 'mucolytic'], 'respira aid': ['respira aid', 'respira aid plus', 'respiratory support'], 'hexatox': ['hexatox', 'hexa tox', 'liver support', 'kidney support'], 'apma fort': ['apma fort', 'mycotoxin', 'liver support'], 'para c': ['para c', 'para c.e', 'heat stress', 'paracetamol'], 'tribiotic': ['tribiotic', 'antibiotic', 'respiratory infection'], 'phyto-sal': ['phyto-sal', 'phytogenic', 'vitamin supplement'], 'mycopex': ['mycopex', 'mycotoxin binder', 'mold'], 'oftilex': ['oftilex', 'ofloxacin', 'antibiotic'], 'biscomin': ['biscomin', 'oxytetracycline', 'injectable'], 'apvita': ['apvita', 'vitamin b', 'amino acid'], 'bg aspro': ['bg aspro', 'aspirin', 'vitamin c'], 'ec-immune': ['ec-immune', 'immune', 'immunity'], 'liverpex': ['liverpex', 'liver', 'metabolic'], 'symodex': ['symodex', 'multivitamin', 'vitamin'], 'adek gold': ['adek gold', 'vitamin', 'multivitamin'], 'immuno dx': ['immuno dx', 'immune', 'antioxidant'] } # Add veterinary variations for key, variations in veterinary_variations.items(): if key in normalized_query: expanded_queries.extend(variations) for _, row in products_df.iterrows(): best_score = 0 best_match_type = "" match_details = {} # Search across all relevant fields with veterinary weighting search_fields = [ ('Product Name', row.get('Product Name', ''), 1.0), ('Category', row.get('Category', ''), 0.8), ('Indications', row.get('Indications', ''), 0.9), ('Target Species', row.get('Target Species', ''), 0.7), ('Type', row.get('Type', ''), 0.6), ('Composition', row.get('Composition', ''), 0.5) ] for field_name, field_value, weight in search_fields: if pd.isna(field_value) or not field_value: continue field_str = str(field_value).lower() # Exact matches (highest priority) for expanded_query in expanded_queries: if expanded_query in field_str or field_str in expanded_query: score = 100 * weight if score > best_score: best_score = score best_match_type = "exact" match_details = {"field": field_name, "query": expanded_query} # Fuzzy matching for close matches for expanded_query in expanded_queries: if len(expanded_query) > 3: # Only fuzzy match longer queries score = fuzz.partial_ratio(normalized_query, field_str) * weight if score > best_score and score > 70: best_score = score best_match_type = "fuzzy" match_details = {"field": field_name, "query": expanded_query} if best_score > 70: product_dict = row.to_dict() product_dict['_score'] = best_score product_dict['_match_type'] = best_match_type product_dict['_match_details'] = match_details scored_matches.append(product_dict) scored_matches.sort(key=lambda x: x['_score'], reverse=True) # Remove duplicates based on product name seen_names = set() unique_matches = [] for match in scored_matches: if match['Product Name'] not in seen_names: seen_names.add(match['Product Name']) unique_matches.append(match) return unique_matches def normalize(text: str) -> str: """Normalize text for search""" if not text: return "" # Convert to lowercase and remove extra whitespace normalized = text.lower().strip() # Remove special characters but keep spaces normalized = re.sub(r'[^\w\s]', '', normalized) # Replace multiple spaces with single space normalized = re.sub(r'\s+', ' ', normalized) return normalized # Enhanced context management with veterinary domain awareness class VeterinaryContextManager: def __init__(self): self.user_contexts = {} self.conversation_history = defaultdict(list) self.product_analytics = defaultdict(int) self.session_data = {} def get_context(self, phone_number: str) -> Dict[str, Any]: """Get or create user context with veterinary domain awareness""" if phone_number not in self.user_contexts: self.user_contexts[phone_number] = { "current_state": "main_menu", "current_menu": "main_menu", "current_menu_options": ["Search Veterinary Products", "Browse Categories", "Download Catalog"], "current_product": None, "current_category": None, "search_history": [], "product_interests": [], "species_preference": None, "symptom_context": None, "last_interaction": datetime.now(), "session_start": datetime.now(), "interaction_count": 0, "last_message": "", "available_categories": [], "available_products": [] } return self.user_contexts[phone_number] def update_context(self, phone_number: str, **kwargs): """Update user context with veterinary domain data""" context = self.get_context(phone_number) context.update(kwargs) context["last_interaction"] = datetime.now() context["interaction_count"] += 1 # Track product interests for recommendations if "current_product" in kwargs and kwargs["current_product"]: product_name = kwargs["current_product"].get("Product Name", "") if product_name: context["product_interests"].append(product_name) self.product_analytics[product_name] += 1 def add_to_history(self, phone_number: str, message: str, response: str): """Add interaction to conversation history""" self.conversation_history[phone_number].append({ "timestamp": datetime.now(), "user_message": message, "bot_response": response }) # Keep only last 20 interactions if len(self.conversation_history[phone_number]) > 20: self.conversation_history[phone_number] = self.conversation_history[phone_number][-20:] def get_recommendations(self, phone_number: str) -> List[Dict[str, Any]]: """Get personalized product recommendations based on user history""" context = self.get_context(phone_number) recommendations = [] # Recommend based on product interests if context["product_interests"]: for product_name in context["product_interests"][-3:]: # Last 3 products products = get_veterinary_product_matches(product_name) if products: # Find related products in same category category = products[0].get("Category", "") if category: category_products = get_products_by_category(category) for product in category_products[:3]: if product.get("Product Name") != product_name: recommendations.append(product) # Remove duplicates and limit seen = set() unique_recommendations = [] for rec in recommendations: name = rec.get("Product Name", "") if name and name not in seen: seen.add(name) unique_recommendations.append(rec) return unique_recommendations[:5] # Initialize context manager context_manager = VeterinaryContextManager() # Enhanced product response with veterinary domain expertise def generate_veterinary_product_response(product_info: Dict[str, Any], user_context: Dict[str, Any]) -> str: """Generate comprehensive veterinary product response with intelligent information handling""" def clean_text(text): if pd.isna(text) or text is None: return "Not specified" return str(text).strip() # Extract product details product_name = clean_text(product_info.get('Product Name', '')) product_type = clean_text(product_info.get('Type', '')) category = clean_text(product_info.get('Category', '')) indications = clean_text(product_info.get('Indications', '')) # Check for PDF link in the CSV data pdf_link = "" try: # Load CSV data to check for PDF link csv_data = pd.read_csv('Veterinary.csv') product_row = csv_data[csv_data['Product Name'] == product_name] if not product_row.empty: brochure_link = product_row.iloc[0].get('Brochure (PDF)', '') if pd.notna(brochure_link) and brochure_link.strip(): pdf_link = brochure_link.strip() except Exception as e: logger.warning(f"Error checking PDF link for {product_name}: {e}") # Build the response response = f"""🧪 *Name:* {product_name} 📦 *Type:* {product_type} 🏥 *Category:* {category} 💊 *Used For:* {indications}""" # Add PDF link if available, in the requested format if pdf_link: response += f"\n\n📄 Product Brochure Available\n🔗 {product_name} PDF:\n{pdf_link}" # Add menu options response += f""" 💬 *Available Actions:* 1️⃣ Talk to Veterinary Consultant 2️⃣ Inquire About Availability 3️⃣ Back to Main Menu 💬 Select an option or ask about related products""" return response def clean_text_for_pdf(text: str) -> str: """Clean text for PDF generation""" if pd.isna(text) or text is None: return "N/A" cleaned = str(text) # Remove or replace problematic characters for PDF cleaned = cleaned.replace('â€"', '-').replace('â€"', '"').replace('’', "'") cleaned = cleaned.replace('“', '"').replace('â€', '"').replace('…', '...') cleaned = re.sub(r'[^\w\s\-.,()%:;]', '', cleaned) return cleaned.strip() # Enhanced PDF generation with veterinary domain expertise def generate_veterinary_pdf(product: Dict[str, Any]) -> bytes: """ Generate comprehensive veterinary PDF with professional formatting """ buffer = io.BytesIO() doc = SimpleDocTemplate(buffer, pagesize=A4) styles = getSampleStyleSheet() # Veterinary-specific styles title_style = ParagraphStyle( 'VeterinaryTitle', parent=styles['Heading1'], fontSize=18, spaceAfter=25, alignment=TA_CENTER, textColor=colors.darkblue, fontName='Helvetica-Bold' ) heading_style = ParagraphStyle( 'VeterinaryHeading', parent=styles['Heading2'], fontSize=14, spaceAfter=12, textColor=colors.darkgreen, fontName='Helvetica-Bold' ) normal_style = ParagraphStyle( 'VeterinaryNormal', parent=styles['Normal'], fontSize=11, spaceAfter=8, alignment=TA_JUSTIFY, fontName='Helvetica' ) # Build PDF content story = [] # Header with veterinary branding story.append(Paragraph("🏥 APEX BIOTICAL VETERINARY PRODUCTS", title_style)) story.append(Spacer(1, 20)) # Product information product_name = clean_text_for_pdf(product.get('Product Name', 'Unknown Product')) story.append(Paragraph(f"Product: {product_name}", heading_style)) story.append(Spacer(1, 15)) # Clinical information table clinical_info = [ ['Field', 'Information'], ['Product Name', clean_text_for_pdf(product.get('Product Name', 'N/A'))], ['Category', clean_text_for_pdf(product.get('Category', 'N/A'))], ['Target Species', clean_text_for_pdf(product.get('Target Species', 'N/A'))], ['Product Type', clean_text_for_pdf(product.get('Type', 'N/A'))] ] clinical_table = Table(clinical_info, colWidths=[2*inch, 4*inch]) clinical_table.setStyle(TableStyle([ ('BACKGROUND', (0, 0), (-1, 0), colors.darkblue), ('TEXTCOLOR', (0, 0), (-1, 0), colors.whitesmoke), ('ALIGN', (0, 0), (-1, -1), 'LEFT'), ('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'), ('FONTSIZE', (0, 0), (-1, 0), 12), ('BOTTOMPADDING', (0, 0), (-1, 0), 12), ('BACKGROUND', (0, 1), (-1, -1), colors.lightblue), ('GRID', (0, 0), (-1, -1), 1, colors.black) ])) story.append(Paragraph("Clinical Information", heading_style)) story.append(clinical_table) story.append(Spacer(1, 20)) # Clinical details if product.get('Indications'): story.append(Paragraph("Clinical Indications", heading_style)) story.append(Paragraph(clean_text_for_pdf(product.get('Indications')), normal_style)) story.append(Spacer(1, 15)) if product.get('Composition'): story.append(Paragraph("Composition", heading_style)) story.append(Paragraph(clean_text_for_pdf(product.get('Composition')), normal_style)) story.append(Spacer(1, 15)) if product.get('Dosage & Administration'): story.append(Paragraph("Dosage & Administration", heading_style)) story.append(Paragraph(clean_text_for_pdf(product.get('Dosage & Administration')), normal_style)) story.append(Spacer(1, 15)) if product.get('Precautions'): story.append(Paragraph("Precautions", heading_style)) story.append(Paragraph(clean_text_for_pdf(product.get('Precautions')), normal_style)) story.append(Spacer(1, 15)) if product.get('Storage'): story.append(Paragraph("Storage", heading_style)) story.append(Paragraph(clean_text_for_pdf(product.get('Storage')), normal_style)) story.append(Spacer(1, 15)) # Veterinary disclaimer story.append(Paragraph("Veterinary Disclaimer", heading_style)) disclaimer_text = ( "This product should be used under veterinary supervision. " "Always consult with a qualified veterinarian before administration. " "Follow dosage instructions precisely and monitor animal response. " "Store according to manufacturer guidelines and keep out of reach of children." ) story.append(Paragraph(disclaimer_text, normal_style)) # Build PDF doc.build(story) buffer.seek(0) return buffer.getvalue() async def send_catalog_pdf(phone_number: str): """Send the complete product catalog as a link to the PDF""" try: # Use the correct Google Drive link converted to direct download format catalog_url = "https://drive.google.com/uc?export=download&id=1mxpkFf3DY-n3QHzZBe_CdksR-gHu2f_0" message = ( "📋 *Apex Biotical Veterinary Products Catalog*\n\n" "📄 Here's your complete product catalog with all our veterinary products:\n" f"📎 [Apex Biotical Veterinary Products Catalog.pdf]({catalog_url})\n\n" "💬 For detailed information about any specific product, type its name or contact our sales team.\n\n" "Type main at any time to return to the main menu." ) send_whatsjet_message(phone_number, message) except Exception as e: logger.error(f"Error sending catalog: {e}") send_whatsjet_message(phone_number, "❌ Error sending catalog. Please try again or contact our sales team for assistance.") async def send_individual_product_pdf(phone_number: str, product: Dict[str, Any]): """Send individual product PDF with download link""" try: # Generate PDF for the product pdf_content = generate_veterinary_pdf(product) # Create filename product_name = product.get('Product Name', 'Unknown_Product') safe_name = re.sub(r'[^\w\s-]', '', product_name).replace(' ', '_') timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") filename = f"{safe_name}_{timestamp}.pdf" # Save PDF to uploads directory uploads_dir = "../uploads" os.makedirs(uploads_dir, exist_ok=True) pdf_path = os.path.join(uploads_dir, filename) with open(pdf_path, 'wb') as f: f.write(pdf_content) # Generate download URL base_url = os.getenv("PUBLIC_BASE_URL", "http://localhost:8000") download_url = f"{base_url}/uploads/{filename}" # Send PDF via WhatsApp media success = send_whatsjet_message( phone_number, f"📄 *{product_name} - Product Information*\n\nHere's the detailed product information in PDF format.", media_type="application/pdf", media_path=pdf_path, filename=filename ) # Also send direct download link as backup if success: message = ( f"📄 *{product_name} - Product Information*\n\n" "📎 [Direct Download Link]({download_url})\n\n" "💬 *If the PDF didn't download, use the link above*\n" "Type 'main' to return to main menu." ) send_whatsjet_message(phone_number, message) else: # If media send failed, send only the link message = ( f"📄 *{product_name} - Product Information*\n\n" "📎 [Download Product PDF]({download_url})\n\n" "💬 *Click the link above to download the product information*\n" "Type 'main' to return to main menu." ) send_whatsjet_message(phone_number, message) except Exception as e: logger.error(f"Error sending individual product PDF: {e}") send_whatsjet_message(phone_number, "❌ Error generating product PDF. Please try again or contact our sales team for assistance.") # --- WhatsJet Message Sending --- def split_message_for_whatsapp(message: str, max_length: int = 1000) -> list: """Split a long message into chunks for WhatsApp (max 1000 chars per message).""" return [message[i:i+max_length] for i in range(0, len(message), max_length)] def send_whatsjet_message(phone_number: str, message: str, media_type: str = None, media_path: str = None, filename: str = None) -> bool: """Send a message using WhatsJet API with optional media attachment or public URL""" if not all([WHATSJET_API_URL, WHATSJET_VENDOR_UID, WHATSJET_API_TOKEN]): logger.error("[WhatsJet] Missing environment variables.") return False url = f"{WHATSJET_API_URL}/{WHATSJET_VENDOR_UID}/contact/send-message?token={WHATSJET_API_TOKEN}" # Handle media messages (local file or public URL) if media_type and media_path: # If media_path is a public URL, use media_url and send caption if isinstance(media_path, str) and media_path.startswith("http"): # Try different payload formats for WhatsJet API payload_formats = [ # Format 1: Using caption field { "phone_number": phone_number, "caption": message, "media_type": media_type, "media_url": media_path, "media_filename": filename or os.path.basename(media_path) }, # Format 2: Using message_body instead of caption { "phone_number": phone_number, "message_body": message, "media_type": media_type, "media_url": media_path, "media_filename": filename or os.path.basename(media_path) }, # Format 3: Simplified format without media_filename { "phone_number": phone_number, "message_body": message, "media_type": media_type, "media_url": media_path }, # Format 4: Using different field names { "phone_number": phone_number, "caption": message, "type": media_type, "url": media_path } ] for i, payload in enumerate(payload_formats, 1): try: logger.info(f"[WhatsJet] Trying payload format {i}: {payload}") response = httpx.post( url, json=payload, timeout=15 ) if response.status_code == 200: logger.info(f"[WhatsJet] Media URL message sent successfully with format {i} to {phone_number}") return True else: logger.warning(f"[WhatsJet] Format {i} failed with status {response.status_code}: {response.text[:200]}") except Exception as e: logger.warning(f"[WhatsJet] Format {i} exception: {e}") continue # If all formats failed, log the error and return False logger.error(f"[WhatsJet] All media URL payload formats failed for {phone_number}") return False else: # Local file logic as before try: with open(media_path, 'rb') as f: media_content = f.read() media_b64 = base64.b64encode(media_content).decode('utf-8') payload = { "phone_number": phone_number, "message_body": message, 'media_type': media_type, 'media_content': media_b64, 'media_filename': filename or os.path.basename(media_path) } try: response = httpx.post( url, json=payload, timeout=15 ) response.raise_for_status() logger.info(f"[WhatsJet] Media message sent successfully to {phone_number}") return True except Exception as e: logger.error(f"[WhatsJet] Exception sending media message: {e}") return False except Exception as e: logger.error(f"[WhatsJet] Exception preparing media message: {str(e)}") return False # Handle text messages if not message.strip(): return True # Don't send empty messages for chunk in split_message_for_whatsapp(message): try: payload = {"phone_number": phone_number, "message_body": chunk} try: response = httpx.post( url, json=payload, timeout=15 ) response.raise_for_status() logger.info(f"[WhatsJet] Text chunk sent successfully to {phone_number}") except Exception as e: logger.error(f"[WhatsJet] Exception sending text chunk: {e}") return False except Exception as e: logger.error(f"[WhatsJet] Exception preparing text chunk: {str(e)}") return False logger.info(f"[WhatsJet] Successfully sent complete text message to {phone_number}") return True def send_whatsjet_media_image_only(phone_number: str, image_url: str, filename: str = None) -> bool: """Send an image with optional caption using WhatsJet's /contact/send-media-message endpoint.""" if not all([WHATSJET_API_URL, WHATSJET_VENDOR_UID, WHATSJET_API_TOKEN]): logger.error("[WhatsJet] Missing environment variables for media message.") return False url = f"{WHATSJET_API_URL}/{WHATSJET_VENDOR_UID}/contact/send-media-message" headers = { "Authorization": f"Bearer {WHATSJET_API_TOKEN}", "Content-Type": "application/json" } payload = { "phone_number": phone_number, "media_type": "image", "media_url": image_url } if filename: payload["file_name"] = filename try: logger.info(f"[WhatsJet] Sending image with payload: {payload}") response = httpx.post(url, json=payload, headers=headers, timeout=30) logger.info(f"[WhatsJet] Image response status: {response.status_code}") logger.info(f"[WhatsJet] Image response body: {response.text[:500]}...") if response.status_code == 200: logger.info(f"[WhatsJet] Image sent successfully to {phone_number}") return True else: logger.error(f"[WhatsJet] Failed to send image: {response.status_code} - {response.text}") return False except Exception as e: logger.error(f"[WhatsJet] Exception sending image: {e}") return False # --- Health Check Endpoint --- @app.get("/health") async def health_check(): """Health check endpoint""" return { "status": "healthy", "timestamp": datetime.now().isoformat(), "products_loaded": len(products_df) if products_df is not None else 0, "openai_available": bool(OPENAI_API_KEY), "whatsjet_configured": bool(all([WHATSJET_API_URL, WHATSJET_VENDOR_UID, WHATSJET_API_TOKEN])) } @app.get("/test-voice") async def test_voice(): """Test endpoint to check voice processing logic""" return { "voice_detection": { "audio_type": "audio" in ['audio', 'voice'], "voice_type": "voice" in ['audio', 'voice'], "media_audio": {'type': 'audio'}.get('type') == 'audio' }, "openai_available": bool(OPENAI_API_KEY), "langdetect_available": True, "deep_translator_available": True } @app.get("/catalog") async def get_catalog(): """Serve the complete product catalog PDF""" try: catalog_path = "static/Hydropex.pdf" if os.path.exists(catalog_path): return FileResponse( catalog_path, media_type="application/pdf", filename="Apex_Biotical_Veterinary_Catalog.pdf" ) else: raise HTTPException(status_code=404, detail="Catalog PDF not found") except Exception as e: logger.error(f"Error serving catalog: {e}") raise HTTPException(status_code=500, detail="Error serving catalog") @app.get("/", response_class=HTMLResponse) async def root(): return """

Apex Biotical Veterinary WhatsApp Assistant

The Assistant is running! Use the API endpoints for WhatsApp integration.