Files
OpenCode 68a9071cf1 feat: reconcile local master with origin (exa/hot-platform hunters, leaks ledger, latest results)
Local branch had diverged from origin/master (sibling commits on the same base). Rewrote local history linearly on top of origin/master, folding in all local content: exa/hot-platform discovery hunters, leaks ledger, nightly sweep orchestrator, updated .gitignore and skill docs, plus the latest hunt output and vault state. Remote-only files (vault keys, channel scripts) were restored rather than dropped, so the resulting tree is a full union of both sides.
2026-08-07 12:53:47 +08:00

1 line
46 KiB
JSON

{"repo": "chanka-dev/projects", "files": {"kaypi/app/Services/AgentService.php": {"65a8d51e0c230ed8023d79bf88e30aa3772c5155": {"t": 1786067619.0642087, "n": 45433, "h": "62530745f8bcc1b2", "c": "<?php\n\nnamespace App\\Services;\n\nuse App\\Models\\WhatsappContact;\nuse App\\Models\\Conversation;\nuse App\\Models\\Message;\nuse App\\Models\\Business;\nuse App\\Models\\BusinessCategory;\nuse Illuminate\\Support\\Facades\\Http;\nuse Illuminate\\Support\\Facades\\Log;\nuse Carbon\\Carbon;\n\nclass AgentService\n{\n protected $groqKey;\n protected $deepseekKey;\n protected $gatewayUrl;\n\n public function __construct()\n {\n $this->groqKey = env('GROQ_API_KEY');\n $this->deepseekKey = env('DEEPSEEK_API_KEY');\n $this->gatewayUrl = 'http://127.0.0.1:3001/send'; // Gateway port on host\n }\n\n /**\n * Main entry point to process incoming webhook events.\n */\n public function handleIncomingMessage(string $phone, string $type, ?string $content, ?float $latitude = null, ?float $longitude = null): string\n {\n Log::info(\"AgentService: Processing message from {$phone} (Type: {$type})\");\n\n // 1. Get or create Contact\n $contact = WhatsappContact::firstOrCreate(\n ['phone_number' => $phone],\n ['name' => 'Usuario WhatsApp', 'preferred_language' => 'es']\n );\n $contact->update(['last_seen_at' => now()]);\n\n // 2. Get active Conversation (close it if it has been inactive for more than 1 hour)\n $conversation = Conversation::where('whatsapp_contact_id', $contact->id)\n ->where('status', 'open')\n ->first();\n\n if ($conversation) {\n $lastMessage = Message::where('conversation_id', $conversation->id)->latest()->first();\n $inactiveTime = $lastMessage ? $lastMessage->created_at : $conversation->started_at;\n if ($inactiveTime && now()->diffInMinutes($inactiveTime) > 60) {\n Log::info(\"AgentService: Inactive conversation for {$phone}. Closing conversation {$conversation->id}.\");\n $conversation->update(['status' => 'closed']);\n $conversation = null;\n }\n }\n\n if (!$conversation) {\n $conversation = Conversation::create([\n 'whatsapp_contact_id' => $contact->id,\n 'status' => 'open',\n 'started_at' => now()\n ]);\n }\n\n $text = '';\n $detectedLanguage = $contact->preferred_language;\n\n // 3. Normalize input (Handle audio transcription)\n if ($type === 'audio') {\n Log::info(\"AgentService: Transcribing audio from {$phone}\");\n $transcriptionData = $this->transcribeAudio($content);\n $text = $transcriptionData['text'] ?? 'Error al transcribir audio.';\n $detectedLanguage = $transcriptionData['language'] ?? $detectedLanguage;\n \n // Update preferred language if detected\n if (in_array($detectedLanguage, ['es', 'en'])) {\n $contact->update(['preferred_language' => $detectedLanguage]);\n }\n\n // Save incoming message log\n Message::create([\n 'conversation_id' => $conversation->id,\n 'direction' => 'in',\n 'type' => 'audio',\n 'raw_content' => '[Audio Base64]',\n 'transcription' => $text,\n 'detected_language' => $detectedLanguage\n ]);\n } elseif ($type === 'location') {\n Log::info(\"AgentService: Location received from {$phone} (Lat: {$latitude}, Lng: {$longitude})\");\n $text = \"Ubicación compartida: Lat={$latitude}, Lng={$longitude}\";\n \n Message::create([\n 'conversation_id' => $conversation->id,\n 'direction' => 'in',\n 'type' => 'location',\n 'raw_content' => json_encode(['latitude' => $latitude, 'longitude' => $longitude]),\n 'detected_language' => $detectedLanguage\n ]);\n } else {\n // Text type\n $text = $content ?? '';\n \n Message::create([\n 'conversation_id' => $conversation->id,\n 'direction' => 'in',\n 'type' => 'text',\n 'raw_content' => $text,\n 'detected_language' => $detectedLanguage\n ]);\n }\n\n // 4. Check context\n $context = $conversation->context;\n if ($context && isset($context['awaiting']) && $context['awaiting'] === 'location') {\n Log::info(\"AgentService: Resolving pending location query for {$phone}\");\n \n $resolvedLat = null;\n $resolvedLng = null;\n\n if ($type === 'location') {\n $resolvedLat = $latitude;\n $resolvedLng = $longitude;\n } else {\n // User typed/spoke an address/reference. Let's see if they want to cancel or reset first.\n $textLower = trim(mb_strtolower($text));\n $cancelPhrases = ['cancelar', 'salir', 'dejalo asi', 'dejalo', 'no importa', 'olvidalo', 'olvídate', 'olvidate', 'nada', 'ninguno'];\n $isCancel = false;\n foreach ($cancelPhrases as $phrase) {\n if ($textLower === $phrase || str_starts_with($textLower, $phrase)) {\n $isCancel = true;\n break;\n }\n }\n\n if ($isCancel) {\n $conversation->update(['context' => null]);\n $reply = $detectedLanguage === 'en'\n ? \"Okay, request canceled. What else can I help you with today?\"\n : \"De acuerdo, he cancelado la solicitud. ¿En qué más puedo ayudarte hoy?\";\n $this->saveAndSendReply($conversation, $reply, $detectedLanguage);\n return $reply;\n }\n\n // Check if it's a new query/greeting instead of a location address\n $testClassification = $this->classifyQuery($text);\n $isGreetingOrOther = ($testClassification['intent'] === 'otro' && !($testClassification['requires_location'] ?? false)) || \n $testClassification['intent'] === 'consulta_turismo';\n \n // If the user repeated or sent a query containing a location reference, resolve it now\n if (isset($testClassification['location_reference']) && $testClassification['location_reference']) {\n $resolvedCoords = $this->resolveLocalReferenceCoords($testClassification['location_reference']);\n if (!$resolvedCoords) {\n $resolvedCoords = $this->geocodeAddress($testClassification['location_reference']);\n }\n if ($resolvedCoords) {\n $resolvedLat = $resolvedCoords['lat'];\n $resolvedLng = $resolvedCoords['lng'];\n }\n }\n\n if ($resolvedLat === null && $resolvedLng === null) {\n // Try to geocode the whole text as an address fallback\n $matchedCoords = $this->fuzzyMatchBusinessCoords($text);\n if ($matchedCoords) {\n $resolvedLat = $matchedCoords['lat'];\n $resolvedLng = $matchedCoords['lng'];\n } else {\n $geocoded = $this->geocodeAddress($text);\n if ($geocoded) {\n $resolvedLat = $geocoded['lat'];\n $resolvedLng = $geocoded['lng'];\n }\n }\n }\n\n // If geocoding failed and the user is sending a greeting or a general text, clear context and start over\n if ($resolvedLat === null && $resolvedLng === null && $isGreetingOrOther) {\n Log::info(\"AgentService: User sent a non-location text during location prompt. Resetting context.\");\n $conversation->update(['context' => null]);\n return $this->processQuery($contact, $conversation, $text, $detectedLanguage);\n }\n }\n\n if ($resolvedLat !== null && $resolvedLng !== null) {\n // Save coordinates\n $contact->update([\n 'last_lat' => $resolvedLat,\n 'last_lng' => $resolvedLng,\n 'last_location_at' => now()\n ]);\n\n // Resume the original pending query\n $originalQuery = $context['pending_query'] ?? '';\n \n // Clear context\n $conversation->update(['context' => null]);\n\n Log::info(\"AgentService: Location resolved successfully. Resuming query: {$originalQuery}\");\n \n // Process the query using the newly resolved coordinates\n return $this->processQuery($contact, $conversation, $originalQuery, $detectedLanguage);\n } else {\n // Re-prompt location\n $reply = $detectedLanguage === 'en' \n ? \"I couldn't find that reference. Please share your current location via WhatsApp or write a clear address/reference in Sucre.\" \n : \"No logré encontrar esa referencia. Por favor, comparte tu ubicación actual por WhatsApp o escribe una calle/referencia clara en Sucre.\";\n\n $this->saveAndSendReply($conversation, $reply, $detectedLanguage);\n return $reply;\n }\n }\n\n // 5. New query handling\n return $this->processQuery($contact, $conversation, $text, $detectedLanguage);\n }\n\n /**\n * Process a query, classifying it and retrieving recommendations.\n */\n protected function processQuery(WhatsappContact $contact, Conversation $conversation, string $text, string $language): string\n {\n Log::info(\"AgentService: Processing query: {$text}\");\n\n // 1. Classify query first to determine safety and extract location references\n $classification = $this->classifyQuery($text);\n \n $isSafe = $classification['is_safe'] ?? true;\n if (!$isSafe) {\n Log::warning(\"AgentService: Unsafe query blocked: {$text}\");\n $reply = $language === 'en'\n ? \"I'm sorry, but I cannot assist with queries related to illegal or unsafe activities.\"\n : \"Lo siento, pero no puedo procesar consultas relacionadas con actividades no permitidas o ilegales.\";\n $this->saveAndSendReply($conversation, $reply, $language, 'otro');\n return $reply;\n }\n\n $intent = $classification['intent'] ?? 'otro';\n $categorySlug = $classification['business_category'] ?? null;\n $requiresLocation = $classification['requires_location'] ?? false;\n $locationReference = $classification['location_reference'] ?? null;\n\n // Dynamically detect language from the LLM classification feedback\n $detectedLanguage = $classification['language'] ?? $language;\n if (in_array($detectedLanguage, ['es', 'en'])) {\n $contact->update(['preferred_language' => $detectedLanguage]);\n $language = $detectedLanguage;\n }\n\n // Try to resolve location reference if provided in the text\n if ($requiresLocation && $locationReference) {\n Log::info(\"AgentService: Attempting to resolve location reference: {$locationReference}\");\n $resolvedCoords = $this->resolveLocalReferenceCoords($locationReference);\n if (!$resolvedCoords) {\n $resolvedCoords = $this->geocodeAddress($locationReference);\n }\n \n if ($resolvedCoords) {\n $contact->update([\n 'last_lat' => $resolvedCoords['lat'],\n 'last_lng' => $resolvedCoords['lng'],\n 'last_location_at' => now()\n ]);\n Log::info(\"AgentService: Location reference resolved: Lat {$resolvedCoords['lat']}, Lng {$resolvedCoords['lng']}\");\n }\n }\n\n // 2. Direct name-based search for a specific registered business\n $matchedBusiness = $this->findSpecificBusiness($text);\n if ($matchedBusiness) {\n Log::info(\"AgentService: Direct business match found: {$matchedBusiness->name}\");\n \n $distance = 0;\n if ($contact->last_lat && $contact->last_lng) {\n $distance = $this->calculateHaversineDistance(\n (float)$contact->last_lat,\n (float)$contact->last_lng,\n (float)$matchedBusiness->lat,\n (float)$matchedBusiness->lng\n );\n }\n\n $businessesData = [[\n 'business' => $matchedBusiness,\n 'distance' => $distance\n ]];\n\n $textLower = mb_strtolower($text);\n $langCode = (str_contains($textLower, 'where') || str_contains($textLower, 'location') || str_contains($textLower, 'address') || str_contains($textLower, 'show')) ? 'en' : $language;\n\n $reply = $this->formatBusinessesResponse($businessesData, $langCode);\n $this->saveAndSendReply($conversation, $reply, $langCode, 'buscar_negocio');\n return $reply;\n }\n\n // 3. Direct product-based search (e.g. \"picante mixto\", \"ibuprofeno\")\n $matchedProducts = $this->findBusinessesByProduct($text);\n if (count($matchedProducts) > 0) {\n Log::info(\"AgentService: Product matches found: \" . count($matchedProducts));\n \n $businessesData = [];\n $seenBusinessIds = [];\n foreach ($matchedProducts as $p) {\n if (in_array($p->business_id, $seenBusinessIds)) continue;\n $seenBusinessIds[] = $p->business_id;\n\n $distance = 0;\n $b = $p->business;\n if ($b->lat && $b->lng && $contact->last_lat && $contact->last_lng) {\n $distance = $this->calculateHaversineDistance(\n (float)$contact->last_lat,\n (float)$contact->last_lng,\n (float)$b->lat,\n (float)$b->lng\n );\n }\n\n $businessesData[] = [\n 'business' => $b,\n 'distance' => $distance,\n 'matched_product' => $p\n ];\n\n if (count($businessesData) >= 3) break;\n }\n\n $textLower = mb_strtolower($text);\n $langCode = (str_contains($textLower, 'where') || str_contains($textLower, 'location') || str_contains($textLower, 'address') || str_contains($textLower, 'show')) ? 'en' : $language;\n\n $reply = $this->formatBusinessesResponse($businessesData, $langCode);\n $this->saveAndSendReply($conversation, $reply, $langCode, 'buscar_negocio');\n return $reply;\n }\n\n Log::info(\"AgentService: Proximity / Classification lookup -> Intent: {$intent}, Category: {$categorySlug}, RequiresLocation: {$requiresLocation}\");\n\n // If location is needed, check if we have a recent location that:\n // 1. Was set within the last 60 minutes (1 hour)\n // 2. Was updated DURING the current active conversation session (to avoid reusing location from older chats)\n $hasRecentLocation = $contact->last_lat && $contact->last_lng && \n $contact->last_location_at && \n now()->diffInMinutes($contact->last_location_at) <= 60 &&\n $contact->last_location_at->greaterThanOrEqualTo($conversation->started_at ?? $conversation->created_at);\n\n if ($requiresLocation && !$hasRecentLocation) {\n Log::info(\"AgentService: Location required but not available. Awaiting location.\");\n \n // Set context state\n $conversation->update([\n 'context' => [\n 'awaiting' => 'location',\n 'pending_query' => $text,\n 'pending_intent' => $classification\n ]\n ]);\n\n $reply = $language === 'en'\n ? \"To help you find the best options near you, I need to know your location. Please share your WhatsApp location or type a clear reference in Sucre.\"\n : \"Para ayudarte a encontrar las mejores opciones cerca de ti, necesito conocer tu ubicación. Por favor comparte tu ubicación de WhatsApp o escribe una referencia clara en Sucre.\";\n\n $this->saveAndSendReply($conversation, $reply, $language);\n return $reply;\n }\n\n // If it does not require location, or we already have location\n if ($intent === 'buscar_negocio') {\n $category = null;\n $invalidCategoryRequested = false;\n\n if ($categorySlug) {\n $category = BusinessCategory::where('slug', $categorySlug)->first();\n if (!$category) {\n $invalidCategoryRequested = true;\n }\n }\n\n if ($invalidCategoryRequested) {\n $reply = $language === 'en'\n ? \"Sorry, I currently only have registered businesses for Food, On-Duty Pharmacies, Supply Shops, and Tourism in Sucre. I don't have records for other categories.\"\n : \"Disculpa, por el momento solo cuento con registros oficiales para las categorías de Comida, Farmacias de Turno, Tiendas de Abasto y Turismo en Sucre. No tengo información de otras categorías.\";\n } else {\n $businesses = $this->getNearbyBusinesses($contact->last_lat, $contact->last_lng, $categorySlug);\n\n if (count($businesses) === 0) {\n $reply = $language === 'en'\n ? \"I didn't find any open businesses near your current location in Sucre.\"\n : \"No encontré negocios abiertos cerca de tu ubicación actual en Sucre.\";\n } else {\n $reply = $this->formatBusinessesResponse($businesses, $language);\n }\n }\n } elseif ($intent === 'consulta_turismo') {\n // General tourism info\n $reply = $this->generateGeneralAIResponse($text, \"turismo\", $language);\n } else {\n // If it is 'otro' but requires location and category is known\n if ($requiresLocation && $categorySlug) {\n $category = BusinessCategory::where('slug', $categorySlug)->first();\n if ($category) {\n $businesses = $this->getNearbyBusinesses($contact->last_lat, $contact->last_lng, $categorySlug);\n if (count($businesses) > 0) {\n $reply = $this->formatBusinessesResponse($businesses, $language);\n } else {\n $reply = $language === 'en'\n ? \"I didn't find any open businesses in this category near your current location in Sucre.\"\n : \"No encontré negocios abiertos de esta categoría cerca de tu ubicación actual en Sucre.\";\n }\n } else {\n $reply = $this->generateGeneralAIResponse($text, \"general\", $language);\n }\n } else {\n // General query\n $reply = $this->generateGeneralAIResponse($text, \"general\", $language);\n }\n }\n\n $this->saveAndSendReply($conversation, $reply, $language, $intent);\n return $reply;\n }\n\n /**\n * Transcribe audio using Groq Whisper API.\n */\n protected function transcribeAudio(string $base64Content): array\n {\n if (!$this->groqKey) {\n Log::warning(\"AgentService: Groq API key is missing.\");\n return ['text' => 'Groq API Key no configurada.'];\n }\n\n try {\n $audioData = base64_decode($base64Content);\n $tempFile = tempnam(sys_get_temp_dir(), 'wa_audio_') . '.ogg';\n file_put_contents($tempFile, $audioData);\n\n Log::info(\"AgentService: Sending file to Groq Whisper: \" . basename($tempFile));\n\n $response = Http::withToken($this->groqKey)\n ->attach('file', file_get_contents($tempFile), basename($tempFile))\n ->post('https://api.groq.com/openai/v1/audio/transcriptions', [\n 'model' => 'whisper-large-v3',\n 'response_format' => 'verbose_json',\n ]);\n\n @unlink($tempFile);\n\n if ($response->successful()) {\n $data = $response->json();\n $text = $data['text'] ?? '';\n \n // Detect language from Groq response\n $langCode = 'es';\n if (isset($data['language'])) {\n $lang = strtolower($data['language']);\n if (str_contains($lang, 'es') || str_contains($lang, 'spa')) {\n $langCode = 'es';\n } elseif (str_contains($lang, 'en') || str_contains($lang, 'eng') || str_contains($lang, 'english')) {\n $langCode = 'en';\n }\n }\n\n Log::info(\"AgentService: Transcription succeeded: \\\"{$text}\\\" (Language: {$langCode})\");\n return ['text' => $text, 'language' => $langCode];\n }\n\n Log::error(\"AgentService: Groq API error: \" . $response->body());\n } catch (\\Exception $e) {\n Log::error(\"AgentService: Transcription failed with exception: \" . $e->getMessage());\n }\n\n return ['text' => 'Error de procesamiento de voz.'];\n }\n\n /**\n * Classify query using DeepSeek API.\n */\n protected function classifyQuery(string $text): array\n {\n if (!$this->deepseekKey) {\n Log::warning(\"AgentService: DeepSeek API key is missing. Using fallback classification.\");\n return $this->fallbackClassification($text);\n }\n\n try {\n $systemPrompt = \"Eres el clasificador de intenciones de Kaypi. Debes analizar el texto ingresado por el usuario y responder ÚNICAMENTE con un objeto JSON válido.\nEl JSON debe seguir esta estructura exacta:\n{\n \\\"intent\\\": \\\"buscar_negocio\\\" | \\\"consulta_turismo\\\" | \\\"otro\\\",\n \\\"business_category\\\": \\\"comida\\\" | \\\"turistico\\\" | \\\"farmacia\\\" | \\\"abasto\\\" | null,\n \\\"keywords\\\": [\\\"palabra1\\\", \\\"palabra2\\\"],\n \\\"requires_location\\\": true | false,\n \\\"location_reference\\\": string | null,\n \\\"is_safe\\\": true | false,\n \\\"language\\\": \\\"es\\\" | \\\"en\\\"\n}\nInstrucciones:\n-requires_location debe ser true si el usuario busca negocios, tiendas, comida o farmacias cercanas, o si hace preguntas de localización.\n-location_reference: si el usuario menciona una calle, plaza, barrio o punto de referencia en el texto (ej. \\\"centro de la ciudad\\\", \\\"plaza 25 de mayo\\\", \\\"recoleta\\\"), extrae ese texto exacto (por ejemplo: \\\"centro de la ciudad\\\", \\\"plaza\\\"). De lo contrario, null.\n-is_safe: debe ser false si la consulta está relacionada con actividades ilegales (drogas, compra de sustancias prohibidas, armas, violencia, robo, etc.) según las leyes bolivianas. De lo contrario, true.\n-language debe ser \\\"en\\\" si el texto está escrito mayoritariamente en inglés, y \\\"es\\\" si está escrito mayoritariamente en español (incluyendo jerga boliviana o mezclas).\n-Categoría debe mapearse a una de las 4 opciones indicadas o null si no corresponde.\";\n\n $response = Http::withToken($this->deepseekKey)\n ->timeout(10)\n ->post('https://api.deepseek.com/chat/completions', [\n 'model' => 'deepseek-chat',\n 'messages' => [\n ['role' => 'system', 'content' => $systemPrompt],\n ['role' => 'user', 'content' => \"Texto a analizar: \\\"{$text}\\\"\"]\n ],\n 'response_format' => ['type' => 'json_object']\n ]);\n\n if ($response->successful()) {\n $choices = $response->json('choices');\n $replyJson = json_decode($choices[0]['message']['content'] ?? '{}', true);\n if (is_array($replyJson)) {\n return $replyJson;\n }\n }\n\n Log::error(\"AgentService: DeepSeek classification failed: \" . $response->body());\n } catch (\\Exception $e) {\n Log::error(\"AgentService: DeepSeek classification exception: \" . $e->getMessage());\n }\n\n return $this->fallbackClassification($text);\n }\n\n /**\n * Fallback classification logic in case DeepSeek is offline or keys are missing.\n */\n protected function fallbackClassification(string $text): array\n {\n $textLower = mb_strtolower($text);\n $intent = 'otro';\n $category = null;\n $requiresLocation = false;\n $locationReference = null;\n $isSafe = true;\n\n // Basic illegal keywords check\n $illegalKeywords = ['droga', 'cocaina', 'marihuana', 'armas', 'pistola', 'robar', 'cocaína', 'robo', 'delito', 'ilegal', 'vandalismo', 'bomba', 'asesinar', 'matar', 'secuestro', 'sicario'];\n foreach ($illegalKeywords as $keyword) {\n if (str_contains($textLower, $keyword)) {\n $isSafe = false;\n break;\n }\n }\n\n // Basic location reference detection\n if (str_contains($textLower, 'centro') || str_contains($textLower, 'plaza 25 de mayo') || str_contains($textLower, 'plaza principal')) {\n $locationReference = 'centro';\n } elseif (str_contains($textLower, 'recoleta')) {\n $locationReference = 'recoleta';\n } elseif (str_contains($textLower, 'bolivar') || str_contains($textLower, 'bolívar')) {\n $locationReference = 'bolivar';\n } elseif (str_contains($textLower, 'campesino')) {\n $locationReference = 'campesino';\n } elseif (str_contains($textLower, 'central') || str_contains($textLower, 'mercado central')) {\n $locationReference = 'mercado central';\n }\n\n if (str_contains($textLower, 'comer') || str_contains($textLower, 'comida') || str_contains($textLower, 'hambre') || str_contains($textLower, 'almuerzo') || str_contains($textLower, 'café') || str_contains($textLower, 'pizza') || str_contains($textLower, 'salteña')) {\n $intent = 'buscar_negocio';\n $category = 'comida';\n $requiresLocation = true;\n } elseif (str_contains($textLower, 'farmacia') || str_contains($textLower, 'remedio') || str_contains($textLower, 'pastilla') || str_contains($textLower, 'turno')) {\n $intent = 'buscar_negocio';\n $category = 'farmacia';\n $requiresLocation = true;\n } elseif (str_contains($textLower, 'mercado') || str_contains($textLower, 'supermercado') || str_contains($textLower, 'tienda') || str_contains($textLower, 'comprar') || str_contains($textLower, 'arroz')) {\n $intent = 'buscar_negocio';\n $category = 'abasto';\n $requiresLocation = true;\n } elseif (str_contains($textLower, 'mirador') || str_contains($textLower, 'castillo') || str_contains($textLower, 'museo') || str_contains($textLower, 'visitar') || str_contains($textLower, 'turístico') || str_contains($textLower, 'turista')) {\n $intent = 'consulta_turismo';\n $category = 'turistico';\n $requiresLocation = true;\n }\n\n return [\n 'intent' => $intent,\n 'business_category' => $category,\n 'keywords' => explode(' ', $text),\n 'requires_location' => $requiresLocation,\n 'location_reference' => $locationReference,\n 'is_safe' => $isSafe,\n 'language' => 'es'\n ];\n }\n\n /**\n * Resolve common reference names to coordinates in Sucre, Bolivia.\n */\n protected function resolveLocalReferenceCoords(string $reference): ?array\n {\n $refLower = mb_strtolower($reference);\n \n if (str_contains($refLower, 'centro') || str_contains($refLower, 'plaza 25 de mayo') || str_contains($refLower, 'plaza principal')) {\n return ['lat' => -19.0477778, 'lng' => -65.2605556]; // Plaza 25 de Mayo (Centro)\n }\n if (str_contains($refLower, 'recoleta')) {\n return ['lat' => -19.0494400, 'lng' => -65.2508300]; // Mirador de La Recoleta\n }\n if (str_contains($refLower, 'bolivar') || str_contains($refLower, 'bolívar')) {\n return ['lat' => -19.0416667, 'lng' => -65.2638889]; // Parque Bolívar\n }\n if (str_contains($refLower, 'campesino')) {\n return ['lat' => -19.0322000, 'lng' => -65.2488000]; // Mercado Campesino\n }\n if (str_contains($refLower, 'central') || str_contains($refLower, 'mercado central')) {\n return ['lat' => -19.0452778, 'lng' => -65.2602778]; // Mercado Central\n }\n \n return null;\n }\n\n /**\n * Local address fuzzy matcher utilizing database business names.\n */\n protected function fuzzyMatchBusinessCoords(string $text): ?array\n {\n $businesses = Business::where('status', 'active')->get();\n foreach ($businesses as $b) {\n if (stripos($text, $b->name) !== false || stripos($b->name, $text) !== false) {\n return [\n 'name' => $b->name,\n 'lat' => (float)$b->lat,\n 'lng' => (float)$b->lng\n ];\n }\n }\n return null;\n }\n\n /**\n * Geocode address referencing Sucre, Bolivia, using OpenStreetMap Nominatim.\n */\n protected function geocodeAddress(string $query): ?array\n {\n try {\n $queryString = urlencode($query . ', Sucre, Bolivia');\n $url = \"https://nominatim.openstreetmap.org/search?q={$queryString}&format=json&limit=1\";\n\n $response = Http::withHeaders([\n 'User-Agent' => 'Kaypi-WhatsApp-Bot/1.0 (contact@kaypi.bo)'\n ])->timeout(5)->get($url);\n\n if ($response->successful()) {\n $results = $response->json();\n if (count($results) > 0) {\n return [\n 'lat' => (float)$results[0]['lat'],\n 'lng' => (float)$results[0]['lon']\n ];\n }\n }\n } catch (\\Exception $e) {\n Log::error(\"AgentService: Nominatim geocoding exception: \" . $e->getMessage());\n }\n\n return null;\n }\n\n /**\n * Compute Haversine distance and fetch top 3 closest active businesses.\n */\n protected function getNearbyBusinesses(float $lat, float $lng, ?string $categorySlug): array\n {\n $query = Business::where('status', 'active');\n\n if ($categorySlug) {\n $category = BusinessCategory::where('slug', $categorySlug)->first();\n if ($category) {\n $query->where('business_category_id', $category->id);\n }\n }\n\n $businesses = $query->get();\n $results = [];\n\n foreach ($businesses as $b) {\n if ($b->lat === null || $b->lng === null) continue;\n\n $distance = $this->calculateHaversineDistance($lat, $lng, (float)$b->lat, (float)$b->lng);\n \n $results[] = [\n 'business' => $b,\n 'distance' => $distance\n ];\n }\n\n // Sort by distance ascending\n usort($results, function ($a, $b) {\n return $a['distance'] <=> $b['distance'];\n });\n\n // Slice to top 3\n return array_slice($results, 0, 3);\n }\n\n /**\n * Calculate distance between two coordinates in meters using the Haversine formula.\n */\n protected function calculateHaversineDistance(float $lat1, float $lon1, float $lat2, float $lon2): float\n {\n $earthRadius = 6371000; // Earth radius in meters\n\n $latDelta = deg2rad($lat2 - $lat1);\n $lonDelta = deg2rad($lon2 - $lon1);\n\n $a = sin($latDelta / 2) * sin($latDelta / 2) +\n cos(deg2rad($lat1)) * cos(deg2rad($lat2)) *\n sin($lonDelta / 2) * sin($lonDelta / 2);\n\n $c = 2 * atan2(sqrt($a), sqrt(1 - $a));\n\n return $earthRadius * $c;\n }\n\n /**\n * Generate friendly bilingual or customized response via DeepSeek or template.\n */\n protected function formatBusinessesResponse(array $businessesData, string $language): string\n {\n if ($this->deepseekKey) {\n try {\n $listStr = \"\";\n foreach ($businessesData as $item) {\n $b = $item['business'];\n $dist = round($item['distance']);\n $mapLink = \"https://www.google.com/maps/search/?api=1&query={$b->lat},{$b->lng}\";\n \n $productStr = \"\";\n if (isset($item['matched_product']) && $item['matched_product']) {\n $p = $item['matched_product'];\n $priceStr = $p->price !== null ? \"{$p->price} BOB\" : \"Precio no especificado\";\n $productStr = \". Producto/Plato solicitado encontrado en este local: {$p->name} (Precio sugerido o estimado: {$priceStr})\";\n }\n\n $listStr .= \"- {$b->name} (Dirección: {$b->address}, Distancia: {$dist} metros, Enlace de Mapa: {$mapLink}){$productStr}. Descripción: {$b->description}. Teléfono: {$b->phone}. Horario: \" . json_encode($b->opening_hours) . \"\\n\";\n }\n\n $systemPrompt = \"Eres Kaypi, el asistente conversacional bilingüe de Sucre. Debes generar una respuesta amigable, clara y conversacional en idioma \" . ($language === 'en' ? 'Inglés' : 'Español') . \" listando los negocios encontrados cerca del usuario.\nReglas:\n- Sé servicial y cortés.\n- Indica las distancias redondeadas de forma clara (si la distancia es 0 o no se especificó, no la menciones).\n- Si hay un producto solicitado encontrado en el local (indicado en el texto de entrada con su precio sugerido), menciónalo explícitamente como la opción que el usuario busca (ejemplo: 'Tienen Picante Mixto con un precio sugerido de 35 BOB').\n- Usa emoticonos locales apropiados.\n- NUNCA uses formato de enlace markdown (sintaxis [texto](url)). Escribe el enlace de Google Maps proporcionado (Enlace de Mapa) de forma cruda como texto plano en una línea nueva (ejemplo: '🗺️ Ver en el mapa: Enlace de Mapa' o '🗺️ View on map: Enlace de Mapa').\n- Si el idioma es Inglés, asegúrate de responder de manera profesional y natural en inglés.\n- Responde únicamente con el texto del mensaje final.\";\n\n $response = Http::withToken($this->deepseekKey)\n ->post('https://api.deepseek.com/chat/completions', [\n 'model' => 'deepseek-chat',\n 'messages' => [\n ['role' => 'system', 'content' => $systemPrompt],\n ['role' => 'user', 'content' => \"Negocios encontrados:\\n{$listStr}\"]\n ]\n ]);\n\n if ($response->successful()) {\n return $response->json('choices.0.message.content');\n }\n } catch (\\Exception $e) {\n Log::error(\"AgentService: DeepSeek response formatting exception: \" . $e->getMessage());\n }\n }\n\n // Fallback to static template formatting\n if ($language === 'en') {\n $reply = \"Here are the closest businesses I found for you in Sucre:\\n\\n\";\n foreach ($businessesData as $item) {\n $b = $item['business'];\n $dist = round($item['distance']);\n $mapLink = \"https://www.google.com/maps/search/?api=1&query={$b->lat},{$b->lng}\";\n $reply .= \"📍 *{$b->name}*\\n\";\n $reply .= \" 🏠 Address: {$b->address}\\n\";\n $reply .= \" 🗺️ View on map: {$mapLink}\\n\";\n if ($dist > 0) {\n $reply .= \" 📏 Distance: {$dist} meters\\n\";\n }\n if (isset($item['matched_product']) && $item['matched_product']) {\n $p = $item['matched_product'];\n $priceStr = $p->price !== null ? \"{$p->price} BOB\" : \"Price not specified\";\n $reply .= \" 🍔 Item: {$p->name} (Suggested: {$priceStr})\\n\";\n }\n if ($b->phone) $reply .= \" 📞 Phone: {$b->phone}\\n\";\n if ($b->description) $reply .= \" 📝 Info: {$b->description}\\n\";\n $reply .= \"\\n\";\n }\n $reply .= \"Hope this helps. Let me know if you need anything else!\";\n } else {\n $reply = \"Aquí tienes los negocios más cercanos que encontré para ti en Sucre:\\n\\n\";\n foreach ($businessesData as $item) {\n $b = $item['business'];\n $dist = round($item['distance']);\n $mapLink = \"https://www.google.com/maps/search/?api=1&query={$b->lat},{$b->lng}\";\n $reply .= \"📍 *{$b->name}*\\n\";\n $reply .= \" 🏠 Dirección: {$b->address}\\n\";\n $reply .= \" 🗺️ Ver en el mapa: {$mapLink}\\n\";\n if ($dist > 0) {\n $reply .= \" 📏 Distancia: {$dist} metros\\n\";\n }\n if (isset($item['matched_product']) && $item['matched_product']) {\n $p = $item['matched_product'];\n $priceStr = $p->price !== null ? \"{$p->price} BOB\" : \"Precio no especificado\";\n $reply .= \" 🍔 Producto: {$p->name} (Sugerido: {$priceStr})\\n\";\n }\n if ($b->phone) $reply .= \" 📞 Teléfono: {$b->phone}\\n\";\n if ($b->description) $reply .= \" 📝 Info: {$b->description}\\n\";\n $reply .= \"\\n\";\n }\n $reply .= \"Espero que esta información te sea de utilidad. ¡Cualquier otra consulta avísame!\";\n }\n\n return $reply;\n }\n\n /**\n * Generate general conversational assistant responses using DeepSeek.\n */\n protected function generateGeneralAIResponse(string $query, string $contextType, string $language): string\n {\n if ($this->deepseekKey) {\n try {\n $systemPrompt = \"Eres Kaypi, el asistente bilingüe (Español/Inglés) de Sucre, Bolivia. Responde de forma muy breve, amigable y natural en idioma \" . ($language === 'en' ? 'Inglés' : 'Español') . \".\nContexto de la consulta: {$contextType}.\n\nREGLAS CRÍTICAS DE CONTEXTO:\n- Solo tienes permitido guiar al usuario e indicarle qué servicios están disponibles dentro de nuestro catálogo oficial (Comida, Farmacias de Turno, Tiendas de Abasto y Atractivos Turísticos de Sucre).\n- Si el usuario pregunta por cualquier otro negocio, establecimiento o categoría comercial externa fuera de las 4 oficiales (por ejemplo: gimnasios, cines, ferreterías, tiendas de ropa, veterinarias, etc.), debes responderle amablemente informando que actualmente NO tienes registros de ese tipo de establecimientos, ya que el asistente solo opera con el directorio oficial de comida, farmacias de turno, abasto y turismo de Sucre.\n- NUNCA inventes o recomiendes nombres de gimnasios, tiendas o negocios externos que no estén en la base de datos oficial.\n- Si te preguntan algo turístico, destaca los atractivos coloniales e históricos oficiales de la ciudad blanca.\";\n\n $response = Http::withToken($this->deepseekKey)\n ->post('https://api.deepseek.com/chat/completions', [\n 'model' => 'deepseek-chat',\n 'messages' => [\n ['role' => 'system', 'content' => $systemPrompt],\n ['role' => 'user', 'content' => $query]\n ]\n ]);\n\n if ($response->successful()) {\n return $response->json('choices.0.message.content');\n }\n } catch (\\Exception $e) {\n Log::error(\"AgentService: DeepSeek general response exception: \" . $e->getMessage());\n }\n }\n\n return $language === 'en' \n ? \"Hello! I am Kaypi, your local assistant in Sucre. (DeepSeek API Key is not configured).\"\n : \"¡Hola! Soy Kaypi, tu asistente local de Sucre. (Por favor, configure las credenciales de DeepSeek en el .env).\";\n }\n\n /**\n * Search the database to find if the query explicitly mentions a registered business name.\n */\n protected function findSpecificBusiness(string $text): ?\\App\\Models\\Business\n {\n $allBusinesses = \\App\\Models\\Business::where('status', 'active')->get();\n \n // Normalize user text: lowercase, remove accents, and split into clean words\n $normalizedText = mb_strtolower($text);\n $normalizedText = str_replace(['á', 'é', 'í', 'ó', 'ú'], ['a', 'e', 'i', 'o', 'u'], $normalizedText);\n $userWords = preg_split('/[^a-z0-9]/', $normalizedText, -1, PREG_SPLIT_NO_EMPTY);\n\n foreach ($allBusinesses as $b) {\n $normalizedName = mb_strtolower($b->name);\n $normalizedName = str_replace(['á', 'é', 'í', 'ó', 'ú'], ['a', 'e', 'i', 'o', 'u'], $normalizedName);\n\n // Clean parentheses (e.g. \"Chocolates Para Ti (Centro)\" -> \"chocolates para ti\")\n $cleanName = trim(preg_replace('/\\s*\\(.*?\\)\\s*/', '', $normalizedName));\n\n if (empty($cleanName)) continue;\n\n // 1. Direct full name substring match first (highest priority)\n if (str_contains($normalizedText, $cleanName)) {\n return $b;\n }\n\n // 2. Strict word-level intersection match\n $words = preg_split('/[^a-z0-9]/', $cleanName, -1, PREG_SPLIT_NO_EMPTY);\n foreach ($words as $word) {\n // Skip very short words or common stop words to prevent false positives\n if (strlen($word) > 2 && !in_array($word, ['del', 'los', 'con', 'para', 'las', 'una', 'don'])) {\n if (in_array($word, $userWords)) {\n return $b;\n }\n }\n }\n }\n\n return null;\n }\n\n /**\n * Search the products database to find if the query mentions a registered product or service.\n */\n protected function findBusinessesByProduct(string $text): array\n {\n $normalizedText = mb_strtolower($text);\n $normalizedText = str_replace(['á', 'é', 'í', 'ó', 'ú'], ['a', 'e', 'i', 'o', 'u'], $normalizedText);\n $userWords = preg_split('/[^a-z0-9]/', $normalizedText, -1, PREG_SPLIT_NO_EMPTY);\n\n $allProducts = \\App\\Models\\BusinessProduct::whereHas('business', function($q) {\n $q->where('status', 'active');\n })->get();\n\n $matchedProducts = [];\n\n foreach ($allProducts as $p) {\n $normalizedName = mb_strtolower($p->name);\n $normalizedName = str_replace(['á', 'é', 'í', 'ó', 'ú'], ['a', 'e', 'i', 'o', 'u'], $normalizedName);\n\n // 1. Direct full product name match\n if (str_contains($normalizedText, $normalizedName)) {\n $matchedProducts[] = $p;\n continue;\n }\n\n // 2. Word-level match\n $words = preg_split('/[^a-z0-9]/', $normalizedName, -1, PREG_SPLIT_NO_EMPTY);\n foreach ($words as $word) {\n // Ignore short helper words or common stop words\n if (strlen($word) > 3 && !in_array($word, ['para', 'con', 'del', 'los', 'las', 'una', 'don'])) {\n if (in_array($word, $userWords)) {\n $matchedProducts[] = $p;\n break;\n }\n }\n }\n }\n\n return $matchedProducts;\n }\n\n /**\n * Send outgoing message via Node.js Gateway REST API and log to DB.\n */\n protected function saveAndSendReply(Conversation $conversation, string $text, string $language, string $intent = 'otro')\n {\n // 1. Log outgoing message\n Message::create([\n 'conversation_id' => $conversation->id,\n 'direction' => 'out',\n 'type' => 'text',\n 'raw_content' => $text,\n 'detected_language' => $language,\n 'intent' => $intent\n ]);\n\n // 2. Dispatch to Node Gateway\n try {\n $phone = $conversation->contact->phone_number;\n Log::info(\"AgentService: Dispatching message to WhatsApp gateway for {$phone}\");\n \n $response = Http::post($this->gatewayUrl, [\n 'number' => $phone,\n 'message' => $text\n ]);\n\n if ($response->successful()) {\n Log::info(\"AgentService: Outgoing message sent successfully to gateway.\");\n } else {\n Log::error(\"AgentService: Gateway response error: \" . $response->body());\n }\n } catch (\\Exception $e) {\n Log::error(\"AgentService: Failed sending message to gateway: \" . $e->getMessage());\n }\n }\n}\n"}}}}