Files

1 line
161 KiB
JSON

{"repo": "budgetbee/budgetbee", "files": {"api/app/Services/Ai/AiChatService.php": {"0b55566693395588dee5c4ed0d81042f478ec66c": {"t": 1785746140.8452208, "n": 54976, "h": "b980d14cdc3e2706", "c": "<?php\n\nnamespace App\\Services\\Ai;\n\nuse App\\Models\\Account;\nuse App\\Models\\AiMemory;\nuse App\\Models\\AiProviderKey;\nuse App\\Models\\Budget;\nuse App\\Models\\Category;\nuse App\\Models\\Record;\nuse App\\Models\\User;\nuse Carbon\\Carbon;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Illuminate\\Support\\Facades\\Http;\nuse Illuminate\\Support\\Facades\\Log;\n\nclass AiChatService\n{\n private User $user;\n private ?string $provider;\n private ?string $apiKey;\n\n private const HISTORY_CACHE_PREFIX = 'ai_chat_history_';\n private const STATE_CACHE_PREFIX = 'ai_chat_state_';\n private const MAX_HISTORY_MESSAGES = 30; // Keep last N messages to stay within token limits\n\n public function __construct(User $user)\n {\n $this->user = $user;\n $this->resolveProvider();\n }\n\n /**\n * Find the user's configured AI provider and API key.\n * Priority: OpenAI first, then DeepSeek.\n */\n private function resolveProvider(): void\n {\n $key = AiProviderKey::where('user_id', $this->user->id)\n ->orderByRaw(\"FIELD(provider, 'openai', 'deepseek')\")\n ->first();\n\n if ($key) {\n $this->provider = $key->provider;\n $this->apiKey = $key->api_key;\n } else {\n $this->provider = null;\n $this->apiKey = null;\n }\n }\n\n /**\n * Check if the user has configured any AI provider.\n */\n public function isConfigured(): bool\n {\n return $this->apiKey !== null;\n }\n\n /**\n * Get the provider name for display.\n */\n public function getProviderName(): string\n {\n return match ($this->provider) {\n 'openai' => 'OpenAI',\n 'deepseek' => 'DeepSeek',\n default => 'Unknown',\n };\n }\n\n /**\n * Main entry point: process a chat message and return the AI response.\n * Maintains conversation history so follow-up questions work naturally.\n */\n public function chat(string $message, array $files = []): string\n {\n if (!$this->isConfigured()) {\n return \"I'm not configured yet. Please add an OpenAI or DeepSeek API key in Settings → Main Settings to enable AI features.\";\n }\n\n // Detect images and determine if vision is needed\n $imageFiles = $this->filterImageFiles($files);\n $hasImages = !empty($imageFiles);\n\n // Image analysis requires OpenAI (GPT-4o-mini with vision)\n if ($hasImages && $this->provider !== 'openai') {\n return \"📷 **Image analysis** is only available with **OpenAI**. Please configure an OpenAI API key in Settings → Main Settings to analyze images. DeepSeek does not currently support vision capabilities.\";\n }\n\n // If no message and no images, prompt the user\n if (empty(trim($message)) && !$hasImages) {\n return \"How can I help you? You can ask me about your finances, budgets, or send me an image of your bank transactions to extract them.\";\n }\n\n // Load existing conversation history (full state preferred, fallback to simple history)\n $fullState = $this->getFullState();\n if (!empty($fullState)) {\n $messages = $this->buildMessagesFromFullState($message, $fullState, $imageFiles);\n } else {\n $history = $this->getHistory();\n $messages = $this->buildMessagesWithHistory($message, $history, $imageFiles);\n }\n\n // Track tool calls to detect and prevent infinite loops\n $calledTools = [];\n $lastToolResults = [];\n $history = $this->getHistory();\n\n // Call AI with tool definitions (max 5 round-trips)\n $maxIterations = 5;\n\n for ($i = 0; $i < $maxIterations; $i++) {\n $response = $this->callProvider($messages);\n\n if (!$response) {\n Log::warning('AI chat: provider returned null response', [\n 'user_id' => $this->user->id,\n 'provider' => $this->provider,\n 'iteration' => $i,\n ]);\n return \"Sorry, I couldn't reach the AI service. Please try again later.\";\n }\n\n $choice = $response['choices'][0] ?? null;\n if (!$choice) {\n Log::warning('AI chat: no choice in response', [\n 'user_id' => $this->user->id,\n 'response' => $response,\n ]);\n return \"Sorry, I received an unexpected response from the AI service.\";\n }\n\n $finishReason = $choice['finish_reason'] ?? 'stop';\n\n Log::debug('AI chat iteration', [\n 'user_id' => $this->user->id,\n 'iteration' => $i,\n 'finish_reason' => $finishReason,\n ]);\n\n // If the AI wants to call a tool\n if ($finishReason === 'tool_calls' && !empty($choice['message']['tool_calls'])) {\n $messages[] = $choice['message'];\n\n foreach ($choice['message']['tool_calls'] as $toolCall) {\n $toolName = $toolCall['function']['name'];\n $toolArgs = json_decode($toolCall['function']['arguments'], true) ?? [];\n\n Log::info('AI tool call', [\n 'user_id' => $this->user->id,\n 'tool' => $toolName,\n 'iteration' => $i,\n ]);\n\n // Detect repeated identical tool calls (prevents loops)\n $toolFingerprint = $toolName . ':' . json_encode($toolArgs);\n $calledTools[] = $toolFingerprint;\n\n $toolResult = $this->executeTool($toolName, $toolArgs);\n $lastToolResults[$toolName] = $toolResult;\n\n $messages[] = [\n 'role' => 'tool',\n 'tool_call_id' => $toolCall['id'],\n 'content' => json_encode($toolResult),\n ];\n }\n\n // Save intermediate state so context is preserved across API calls\n $this->saveFullState($messages);\n\n continue; // Back to the AI with tool results\n }\n\n // Normal text response — save to history and full state\n $assistantReply = $choice['message']['content'] ?? \"I'm not sure how to answer that.\";\n\n // Save final assistant message to full state\n $messages[] = $choice['message'];\n $this->saveFullState($messages);\n\n // Append the exchange to simple history and persist\n $history[] = ['role' => 'user', 'content' => $message];\n $history[] = ['role' => 'assistant', 'content' => $assistantReply];\n $this->saveHistory($history);\n\n return $assistantReply;\n }\n\n // Loop exhausted — log what happened and try to give a useful fallback\n Log::warning('AI chat: max tool-call iterations reached', [\n 'user_id' => $this->user->id,\n 'tools_called' => $calledTools,\n 'provider' => $this->provider,\n ]);\n\n // Try one final call without tools to force a text response\n $messagesWithoutTools = $messages;\n $messagesWithoutTools[] = [\n 'role' => 'user',\n 'content' => 'Please provide a direct answer based on the data above. Do NOT call any more tools.',\n ];\n $finalResponse = $this->callProvider($messagesWithoutTools, false);\n if ($finalResponse && !empty($finalResponse['choices'][0]['message']['content'])) {\n $assistantReply = $finalResponse['choices'][0]['message']['content'];\n $history[] = ['role' => 'user', 'content' => $message];\n $history[] = ['role' => 'assistant', 'content' => $assistantReply];\n $this->saveHistory($history);\n return $assistantReply;\n }\n\n return \"I'm having trouble processing your request. Could you try rephrasing it?\";\n }\n\n /**\n * Clear the conversation history for this user.\n */\n public function clearHistory(): void\n {\n Cache::forget(self::HISTORY_CACHE_PREFIX . $this->user->id);\n Cache::forget(self::STATE_CACHE_PREFIX . $this->user->id);\n }\n\n /**\n * Get the conversation history from cache.\n */\n private function getHistory(): array\n {\n return Cache::get(self::HISTORY_CACHE_PREFIX . $this->user->id, []);\n }\n\n /**\n * Save the conversation history to cache (keeps last N messages).\n */\n private function saveHistory(array $history): void\n {\n // Trim to max messages to avoid unbounded growth\n if (count($history) > self::MAX_HISTORY_MESSAGES) {\n $history = array_slice($history, -self::MAX_HISTORY_MESSAGES);\n }\n\n // Cache for 24 hours (conversation expires after a day of inactivity)\n Cache::put(self::HISTORY_CACHE_PREFIX . $this->user->id, $history, now()->addDay());\n }\n\n /**\n * Get the full conversation state from cache (includes tool calls and results).\n */\n private function getFullState(): array\n {\n return Cache::get(self::STATE_CACHE_PREFIX . $this->user->id, []);\n }\n\n /**\n * Save the full messages array (minus system prompt) to cache.\n * This preserves tool calls and results across API calls.\n */\n private function saveFullState(array $messages): void\n {\n // Strip the system prompt (index 0) and keep the conversation messages\n $state = array_values(array_filter($messages, fn($m) => ($m['role'] ?? '') !== 'system'));\n\n // Trim to avoid unbounded growth (keep last 40 messages for tool call context)\n if (count($state) > 40) {\n $state = array_slice($state, -40);\n }\n\n Cache::put(self::STATE_CACHE_PREFIX . $this->user->id, $state, now()->addDay());\n }\n\n /**\n * Build messages from the full conversation state with a fresh system prompt.\n * This gives the AI full context including tool calls and results from previous API calls.\n */\n private function buildMessagesFromFullState(string $userMessage, array $fullState, array $imageFiles = []): array\n {\n $systemPrompt = $this->getSystemPrompt();\n\n $messages = [\n ['role' => 'system', 'content' => $systemPrompt],\n ];\n\n // Include the full conversation state (includes tool calls and results)\n foreach ($fullState as $msg) {\n $messages[] = $msg;\n }\n\n // Build the new user message content\n if (!empty($imageFiles)) {\n $content = [];\n if (!empty(trim($userMessage))) {\n $content[] = ['type' => 'text', 'text' => $userMessage];\n } else {\n $content[] = ['type' => 'text', 'text' => 'Please analyze this image and extract any financial transactions you can see.'];\n }\n foreach ($imageFiles as $image) {\n $content[] = [\n 'type' => 'image_url',\n 'image_url' => [\n 'url' => $image['base64'],\n 'detail' => 'low',\n ],\n ];\n }\n $messages[] = ['role' => 'user', 'content' => $content];\n } else {\n $messages[] = ['role' => 'user', 'content' => $userMessage];\n }\n\n return $messages;\n }\n\n /**\n * Build the messages array including conversation history and optional images.\n * This is the fallback when no full state is available.\n */\n private function buildMessagesWithHistory(string $userMessage, array $history, array $imageFiles = []): array\n {\n $systemPrompt = $this->getSystemPrompt();\n\n $messages = [\n ['role' => 'system', 'content' => $systemPrompt],\n ];\n\n // Include recent conversation history for context\n foreach ($history as $msg) {\n $messages[] = $msg;\n }\n\n // Build the new user message content\n if (!empty($imageFiles)) {\n // Vision format: content is an array of text + image parts\n $content = [];\n if (!empty(trim($userMessage))) {\n $content[] = ['type' => 'text', 'text' => $userMessage];\n } else {\n $content[] = ['type' => 'text', 'text' => 'Please analyze this image and extract any financial transactions you can see.'];\n }\n foreach ($imageFiles as $image) {\n $content[] = [\n 'type' => 'image_url',\n 'image_url' => [\n 'url' => $image['base64'],\n 'detail' => 'low',\n ],\n ];\n }\n $messages[] = ['role' => 'user', 'content' => $content];\n } else {\n // Plain text message\n $messages[] = ['role' => 'user', 'content' => $userMessage];\n }\n\n return $messages;\n }\n\n /**\n * Filter uploaded files to only include images, converting them to base64.\n */\n private function filterImageFiles(array $files): array\n {\n $images = [];\n foreach ($files as $file) {\n $mime = $file['mime'] ?? '';\n if (in_array($mime, ['image/png', 'image/jpeg', 'image/jpg', 'image/gif', 'image/webp'])) {\n $path = $file['path'] ?? null;\n if ($path && file_exists($path)) {\n $base64 = 'data:' . $mime . ';base64,' . base64_encode(file_get_contents($path));\n $images[] = [\n 'name' => $file['name'],\n 'base64' => $base64,\n ];\n }\n }\n }\n return $images;\n }\n\n /**\n * Get the system prompt with context about the user and available tools.\n */\n private function getSystemPrompt(): string\n {\n $currency = $this->user->currency_symbol ?? '$';\n $today = Carbon::now()->format('Y-m-d');\n $currentMonth = Carbon::now()->format('F Y');\n $memoryContext = AiMemory::getContextForUser($this->user->id);\n $memorySection = $memoryContext ? \"\\n**User context (learned from previous conversations):**\\n<!-- BEGIN_USER_MEMORY -->\\n{$memoryContext}\\n<!-- END_USER_MEMORY -->\\n\" : '';\n $categoriesList = $this->buildCategoriesList();\n\n return <<<PROMPT\nYou are BudgetBee AI, a friendly and helpful financial assistant integrated into the BudgetBee personal finance app.\nYour role is to help the user understand their finances by answering questions about their accounts, expenses, income, budgets, and transactions.\n\nCurrent date: {$today}\nCurrent month: {$currentMonth}\nUser's currency: {$currency}\n{$memorySection}\n**User's available categories — ONLY use these exact IDs and names. Do NOT invent categories:**\n<!-- BEGIN_USER_CATEGORIES -->\n{$categoriesList}\n<!-- END_USER_CATEGORIES -->\n\nYou have access to tools that let you query the user's real financial data in the database.\nAlways use these tools to provide accurate, data-driven answers. Do NOT make up numbers — only report what the tools return.\nIf a query returns no data, tell the user honestly.\nBe concise but friendly. Format currency amounts with the user's currency symbol.\n\n**CRITICAL — Conversation continuity:**\n- If there are previous messages in the conversation (user or assistant messages before the current one), you are in an ONGOING conversation. Do NOT greet or introduce yourself again. Continue the conversation naturally.\n- Only greet the user if the conversation history is completely empty (first message ever).\n\n**CRITICAL — Date handling:**\n- When the user says \"this month\", ALWAYS pass `from_date` as the first day of the current month.\n- When the user says \"this year\", ALWAYS pass `from_date` as `{$today}`-01-01 (first day of current year).\n- When the user says \"last month\", ALWAYS calculate and pass the correct first day of last month.\n- User can also specify explicit dates (e.g., \"from January 1st to January 31st\") — parse these and pass them as `from_date` and `to_date`.\n- When no time period is mentioned, the tools default to all records.\n\n**When querying by category:** match the user's words to the closest category from the list above, then use the exact category name in tool calls.\n\n**When creating records:** always pass the `category_id` (the number in parentheses) from the list above.\n\n**IMPORTANT: Always format your responses using Markdown.** Use the following formatting rules:\n- Use **bold** for key numbers, totals, and important figures.\n- Use `code` formatting for dates and technical values.\n- Use bullet lists (starting with -) for listing items like accounts, categories, or transactions.\n- Use numbered lists for step-by-step guidance.\n- Use ### headings for section titles when presenting structured data.\n- Use tables (| column | column |) for comparing data across categories or time periods.\n- Use > blockquotes for important notes or warnings.\n- Keep it clean and readable — don't overuse formatting.\n\nExample of a good response:\n```\nHere's your spending breakdown for this month:\n\n| Category | Amount |\n|----------|--------|\n| Supermarkets | **\\$342.50** |\n| Transport | **\\$128.00** |\n| Entertainment | **\\$85.30** |\n| **Total** | **\\$555.80** |\n\n> Your biggest expense category this month is *Supermarkets* at 62% of total spending.\n```\n\n**DATA EXTRACTION — When the user sends an image or file with transactions:**\n\n1. Call `get_accounts`. Do NOT call `get_categories` (already listed above).\n2. Extract EVERY transaction: negative = expense, positive = income.\n3. Match each to a `category_id` from the list above.\n4. Present a compact table: Date | Description | Amount | Type | Category\n5. Ask: \"¿En qué cuenta los creo? Responde con el nombre de la cuenta y **sí** para confirmar.\"\n6. NEVER call `create_records_batch` without account + confirmation.\n7. When confirmed, use: date, name, amount (positive), type, category_id, account_name.\n\n**Be concise. One table, one question. No repetition.**\n\n**DOCUMENTATION: When the user asks about how BudgetBee works, how to configure something, or how to use a feature, use the `read_documentation` tool to find the answer.** The documentation covers: accounts, records/transactions, categories, budgets, AI chat setup, data import, API keys, and FAQ. Do NOT make up answers about app functionality — always check the documentation first.\n\n**MEMORY: You have persistent memory per user. Use the `save_memory` tool to remember important facts:**\n- If the user consistently speaks a language (Spanish, English, etc.), save it as `language`.\n- If the user mentions their preferred account, save it as `preferred_account`.\n- If you notice patterns (e.g., \"I always shop at Mercadona on Fridays\"), save them.\n- Save any fact that would help you provide better, more personalized responses in future conversations.\n- Use the memory context provided above — it contains everything learned from previous conversations.\n- **Call `save_memory` automatically** when you learn something new — no need to ask permission.\nPROMPT;\n }\n\n /**\n * Get the tool definitions for the AI provider (MCP-style).\n */\n private function getTools(): array\n {\n return [\n [\n 'type' => 'function',\n 'function' => [\n 'name' => 'get_balance',\n 'description' => 'Get the user\\'s current total balance across all accounts.',\n 'parameters' => [\n 'type' => 'object',\n 'properties' => (object) [],\n ],\n ],\n ],\n [\n 'type' => 'function',\n 'function' => [\n 'name' => 'get_accounts',\n 'description' => 'Get all accounts with their balances, types, and currencies.',\n 'parameters' => [\n 'type' => 'object',\n 'properties' => (object) [],\n ],\n ],\n ],\n [\n 'type' => 'function',\n 'function' => [\n 'name' => 'get_expenses_by_category',\n 'description' => 'Get total expenses grouped by category for a given date range. Use this when the user asks how much they spent on something (e.g., \"how much did I spend on groceries this month?\").',\n 'parameters' => [\n 'type' => 'object',\n 'properties' => [\n 'from_date' => [\n 'type' => 'string',\n 'description' => 'Start date in Y-m-d format. Defaults to January 1st of current year. ALWAYS set this explicitly when the user mentions a time period (e.g., \"this month\" → first day of month, \"this year\" → first day of year, \"last month\" → first day of last month).',\n ],\n 'to_date' => [\n 'type' => 'string',\n 'description' => 'End date in Y-m-d format. Defaults to today.',\n ],\n 'category_name' => [\n 'type' => 'string',\n 'description' => 'Optional: filter by category name. Use the EXACT category name from the list in the system prompt.',\n ],\n ],\n ],\n ],\n ],\n [\n 'type' => 'function',\n 'function' => [\n 'name' => 'get_income_by_category',\n 'description' => 'Get total income grouped by category for a given date range.',\n 'parameters' => [\n 'type' => 'object',\n 'properties' => [\n 'from_date' => [\n 'type' => 'string',\n 'description' => 'Start date in Y-m-d format. Defaults to January 1st of current year. ALWAYS set this explicitly when the user mentions a time period.',\n ],\n 'to_date' => [\n 'type' => 'string',\n 'description' => 'End date in Y-m-d format. Defaults to today.',\n ],\n ],\n ],\n ],\n ],\n [\n 'type' => 'function',\n 'function' => [\n 'name' => 'get_recent_records',\n 'description' => 'Get the most recent transactions. Use this when the user asks about recent activity or their latest transactions.',\n 'parameters' => [\n 'type' => 'object',\n 'properties' => [\n 'limit' => [\n 'type' => 'integer',\n 'description' => 'Number of records to return. Defaults to 10.',\n ],\n 'category_name' => [\n 'type' => 'string',\n 'description' => 'Optional: filter by category name.',\n ],\n ],\n ],\n ],\n ],\n [\n 'type' => 'function',\n 'function' => [\n 'name' => 'get_budgets',\n 'description' => 'Get the user\\'s budgets with their current spent amounts and remaining balances.',\n 'parameters' => [\n 'type' => 'object',\n 'properties' => (object) [],\n ],\n ],\n ],\n [\n 'type' => 'function',\n 'function' => [\n 'name' => 'search_records',\n 'description' => 'Search for transactions by name or description keyword.',\n 'parameters' => [\n 'type' => 'object',\n 'properties' => [\n 'keyword' => [\n 'type' => 'string',\n 'description' => 'Search keyword to look for in transaction names.',\n ],\n 'limit' => [\n 'type' => 'integer',\n 'description' => 'Maximum number of results. Defaults to 20.',\n ],\n ],\n 'required' => ['keyword'],\n ],\n ],\n ],\n [\n 'type' => 'function',\n 'function' => [\n 'name' => 'get_categories',\n 'description' => 'Get all available expense and income categories that the user has.',\n 'parameters' => [\n 'type' => 'object',\n 'properties' => (object) [],\n ],\n ],\n ],\n [\n 'type' => 'function',\n 'function' => [\n 'name' => 'create_records_batch',\n 'description' => 'Create multiple financial records at once. ONLY call this after the user has explicitly confirmed they want the records created. Always present the extracted data first and ask for confirmation. The user must say \"yes\", \"create them\", \"confirm\", or similar before you call this tool.',\n 'parameters' => [\n 'type' => 'object',\n 'properties' => [\n 'records' => [\n 'type' => 'array',\n 'description' => 'Array of records to create.',\n 'items' => [\n 'type' => 'object',\n 'properties' => [\n 'date' => [\n 'type' => 'string',\n 'description' => 'Date in Y-m-d format.',\n ],\n 'name' => [\n 'type' => 'string',\n 'description' => 'Transaction name/description.',\n ],\n 'amount' => [\n 'type' => 'number',\n 'description' => 'ALWAYS a positive number (e.g., 45.50). The system handles the sign internally.',\n ],\n 'type' => [\n 'type' => 'string',\n 'enum' => ['income', 'expense'],\n 'description' => 'Whether this is income (money received, salary, deposit) or expense (payment, purchase, withdrawal). Required for every record.',\n ],\n 'category_name' => [\n 'type' => 'string',\n 'description' => 'DEPRECATED: use category_id instead. Category name from the list.',\n ],\n 'category_id' => [\n 'type' => 'integer',\n 'description' => 'The exact category ID from the list above. Always use this — pick the ID that best matches the transaction description.',\n ],\n 'account_name' => [\n 'type' => 'string',\n 'description' => 'Account name. MUST match one of the user\\'s real accounts from get_accounts. Call get_accounts first BEFORE presenting data to the user.',\n ],\n ],\n 'required' => ['date', 'name', 'amount', 'type', 'category_id', 'account_name'],\n ],\n ],\n ],\n 'required' => ['records'],\n ],\n ],\n ],\n [\n 'type' => 'function',\n 'function' => [\n 'name' => 'save_memory',\n 'description' => 'Save a fact about the user to persistent memory. Use this to remember user preferences, language, habits, or any information that helps personalize future interactions.',\n 'parameters' => [\n 'type' => 'object',\n 'properties' => [\n 'key' => [\n 'type' => 'string',\n 'description' => 'Short key describing what you are remembering (e.g., \"language\", \"preferred_account\", \"shopping_habit\").',\n ],\n 'value' => [\n 'type' => 'string',\n 'description' => 'The fact or preference to remember (e.g., \"Spanish\", \"Checking Account\", \"Shops at Mercadona on Fridays\").',\n ],\n ],\n 'required' => ['key', 'value'],\n ],\n ],\n ],\n [\n 'type' => 'function',\n 'function' => [\n 'name' => 'read_documentation',\n 'description' => 'Read BudgetBee documentation to answer user questions about how the app works, how to configure features, how to use specific functionality, or troubleshooting. Use this when the user asks \"how do I...\", \"how does X work?\", \"how do I configure...?\", or any question about using BudgetBee itself (not about their personal financial data).',\n 'parameters' => [\n 'type' => 'object',\n 'properties' => [\n 'query' => [\n 'type' => 'string',\n 'description' => 'The topic or question to search for in the documentation. Use keywords like \"accounts\", \"categories\", \"budgets\", \"import\", \"AI setup\", \"API\", \"FAQ\", etc.',\n ],\n ],\n 'required' => ['query'],\n ],\n ],\n ],\n ];\n }\n\n /**\n * Call the AI provider's API.\n */\n private function callProvider(array $messages, bool $includeTools = true): ?array\n {\n $apiUrl = match ($this->provider) {\n 'openai' => 'https://api.openai.com/v1/chat/completions',\n 'deepseek' => 'https://api.deepseek.com/v1/chat/completions',\n default => null,\n };\n\n if (!$apiUrl) {\n return null;\n }\n\n $payload = [\n 'model' => $this->getModelName(),\n 'messages' => $messages,\n 'temperature' => 0.3,\n 'max_tokens' => 4000,\n ];\n\n if ($includeTools) {\n $payload['tools'] = $this->getTools();\n $payload['tool_choice'] = 'auto';\n }\n\n try {\n $httpResponse = Http::withHeaders([\n 'Authorization' => \"Bearer {$this->apiKey}\",\n 'Content-Type' => 'application/json',\n ])\n ->timeout(60)\n ->post($apiUrl, $payload);\n\n if ($httpResponse->successful()) {\n return $httpResponse->json();\n }\n\n Log::error('AI Provider error', [\n 'provider' => $this->provider,\n 'status' => $httpResponse->status(),\n 'body' => $httpResponse->body(),\n ]);\n\n return null;\n } catch (\\Exception $e) {\n Log::error('AI Provider exception', [\n 'provider' => $this->provider,\n 'error' => $e->getMessage(),\n ]);\n\n return null;\n }\n }\n\n /**\n * Get the appropriate model name for each provider.\n */\n private function getModelName(): string\n {\n return match ($this->provider) {\n 'openai' => 'gpt-4o-mini',\n 'deepseek' => 'deepseek-chat',\n default => 'gpt-4o-mini',\n };\n }\n\n /**\n * Execute a tool call and return the result.\n */\n private function executeTool(string $name, array $arguments): array\n {\n return match ($name) {\n 'get_balance' => $this->toolGetBalance(),\n 'get_accounts' => $this->toolGetAccounts(),\n 'get_expenses_by_category' => $this->toolGetExpensesByCategory($arguments),\n 'get_income_by_category' => $this->toolGetIncomeByCategory($arguments),\n 'get_recent_records' => $this->toolGetRecentRecords($arguments),\n 'get_budgets' => $this->toolGetBudgets(),\n 'search_records' => $this->toolSearchRecords($arguments),\n 'get_categories' => $this->toolGetCategories(),\n 'create_records_batch' => $this->toolCreateRecordsBatch($arguments),\n 'save_memory' => $this->toolSaveMemory($arguments),\n 'read_documentation' => $this->toolReadDocumentation($arguments),\n default => ['error' => \"Unknown tool: {$name}\"],\n };\n }\n\n // ─── Tool Implementations ───────────────────────────────────────\n\n private function toolGetBalance(): array\n {\n $balance = Account::where('user_id', $this->user->id)\n ->get()\n ->sum('balance_base_currency');\n\n $accounts = Account::where('user_id', $this->user->id)\n ->get()\n ->map(fn($a) => [\n 'name' => $a->name,\n 'balance' => round($a->balance, 2),\n 'currency' => $a->currency_symbol,\n ]);\n\n return [\n 'total_balance' => round($balance, 2),\n 'currency' => $this->user->currency_symbol,\n 'accounts' => $accounts->toArray(),\n ];\n }\n\n private function toolGetAccounts(): array\n {\n $accounts = Account::where('user_id', $this->user->id)\n ->get()\n ->map(fn($a) => [\n 'name' => $a->name,\n 'type' => $a->account_type_name,\n 'balance' => round($a->balance, 2),\n 'currency' => $a->currency_symbol,\n 'initial_balance' => round($a->initial_balance, 2),\n ]);\n\n return [\n 'accounts' => $accounts->toArray(),\n 'currency' => $this->user->currency_symbol,\n ];\n }\n\n private function toolGetExpensesByCategory(array $args): array\n {\n $fromDate = $args['from_date'] ?? Carbon::now()->startOfYear()->format('Y-m-d');\n $toDate = $args['to_date'] ?? Carbon::now()->format('Y-m-d');\n $categoryFilter = $args['category_name'] ?? null;\n\n $excludeParentIds = [1, 10]; // Exclude transfers and income\n\n $query = Record::where('user_id', $this->user->id)\n ->whereIn('type', ['expense'])\n ->where('date', '>=', $fromDate)\n ->where('date', '<=', $toDate)\n ->whereHas('category.parent', function ($q) use ($excludeParentIds) {\n $q->whereNotIn('id', $excludeParentIds);\n })\n ->with('category.parent');\n\n // Filter by category name if provided (AI uses exact names from prompt)\n if ($categoryFilter) {\n $query->where(function ($q) use ($categoryFilter) {\n $q->whereHas('category', function ($sq) use ($categoryFilter) {\n $sq->where('name', 'like', \"%{$categoryFilter}%\");\n })->orWhereHas('category.parent', function ($sq) use ($categoryFilter) {\n $sq->where('name', 'like', \"%{$categoryFilter}%\");\n });\n });\n }\n\n $records = $query->get();\n\n $grouped = [];\n foreach ($records as $record) {\n $parentName = $record->parent_category_name ?? 'Other';\n $catName = $record->category_name ?? 'Unknown';\n\n $grouped[$parentName]['total'] = ($grouped[$parentName]['total'] ?? 0) + round(abs($record->amount_base_currency), 2);\n $grouped[$parentName]['categories'][$catName] = ($grouped[$parentName]['categories'][$catName] ?? 0) + round(abs($record->amount_base_currency), 2);\n }\n\n $totalExpenses = array_sum(array_column($grouped, 'total'));\n\n return [\n 'from_date' => $fromDate,\n 'to_date' => $toDate,\n 'total_expenses' => round($totalExpenses, 2),\n 'currency' => $this->user->currency_symbol,\n 'by_parent_category' => $grouped,\n ];\n }\n\n private function toolGetIncomeByCategory(array $args): array\n {\n $fromDate = $args['from_date'] ?? Carbon::now()->startOfYear()->format('Y-m-d');\n $toDate = $args['to_date'] ?? Carbon::now()->format('Y-m-d');\n\n $incomeCategoryIds = Category::where('parent_category_id', 10)->pluck('id');\n\n $records = Record::where('user_id', $this->user->id)\n ->whereIn('category_id', $incomeCategoryIds)\n ->where('date', '>=', $fromDate)\n ->where('date', '<=', $toDate)\n ->with('category')\n ->get();\n\n $grouped = [];\n foreach ($records as $record) {\n $catName = $record->category_name ?? 'Unknown';\n $grouped[$catName] = ($grouped[$catName] ?? 0) + round(abs($record->amount_base_currency), 2);\n }\n\n $totalIncome = array_sum($grouped);\n\n return [\n 'from_date' => $fromDate,\n 'to_date' => $toDate,\n 'total_income' => round($totalIncome, 2),\n 'currency' => $this->user->currency_symbol,\n 'by_category' => $grouped,\n ];\n }\n\n private function toolGetRecentRecords(array $args): array\n {\n $limit = min($args['limit'] ?? 10, 50);\n $categoryFilter = $args['category_name'] ?? null;\n\n $query = Record::where('user_id', $this->user->id)\n ->with('category.parent')\n ->orderBy('date', 'desc')\n ->orderBy('id', 'desc')\n ->limit($limit);\n\n if ($categoryFilter) {\n $query->whereHas('category', function ($q) use ($categoryFilter) {\n $q->where('name', 'like', \"%{$categoryFilter}%\");\n });\n }\n\n $records = $query->get()->map(fn($r) => [\n 'date' => $r->date,\n 'name' => $r->name,\n 'amount' => round($r->amount, 2),\n 'type' => $r->type,\n 'category' => $r->category_name ?? 'Unknown',\n 'parent_category' => $r->parent_category_name ?? 'Unknown',\n 'account' => $r->account_name ?? 'Unknown',\n 'currency' => $r->currency_symbol,\n ]);\n\n return [\n 'records' => $records->toArray(),\n 'count' => $records->count(),\n ];\n }\n\n private function toolGetBudgets(): array\n {\n $budgets = Budget::where('user_id', $this->user->id)\n ->with('category.parent')\n ->get()\n ->map(fn($b) => [\n 'category' => $b->category_name,\n 'parent_category' => $b->parent_category_name,\n 'budgeted' => round($b->amount, 2),\n 'spent' => round($b->spent, 2),\n 'remaining' => round($b->amount - $b->spent, 2),\n 'percent_used' => round($b->spent_percent, 1),\n ]);\n\n return [\n 'budgets' => $budgets->toArray(),\n 'currency' => $this->user->currency_symbol,\n ];\n }\n\n private function toolSearchRecords(array $args): array\n {\n $keyword = $args['keyword'] ?? '';\n $limit = min($args['limit'] ?? 20, 50);\n\n $records = Record::where('user_id', $this->user->id)\n ->where('name', 'like', \"%{$keyword}%\")\n ->with('category.parent')\n ->orderBy('date', 'desc')\n ->limit($limit)\n ->get()\n ->map(fn($r) => [\n 'date' => $r->date,\n 'name' => $r->name,\n 'amount' => round($r->amount, 2),\n 'type' => $r->type,\n 'category' => $r->category_name ?? 'Unknown',\n 'parent_category' => $r->parent_category_name ?? 'Unknown',\n 'currency' => $r->currency_symbol,\n ]);\n\n return [\n 'keyword' => $keyword,\n 'records' => $records->toArray(),\n 'count' => $records->count(),\n ];\n }\n\n private function toolGetCategories(): array\n {\n $categories = Category::where(function ($q) {\n $q->where('user_id', $this->user->id)\n ->orWhereNull('user_id');\n })\n ->with('parent')\n ->get()\n ->groupBy('parent_category_id')\n ->map(function ($cats, $parentId) {\n $parentName = $cats->first()->parent_name ?? 'Other';\n return [\n 'parent_category' => $parentName,\n 'categories' => $cats->map(fn($c) => $c->name)->toArray(),\n ];\n });\n\n return [\n 'category_groups' => $categories->values()->toArray(),\n ];\n }\n\n /**\n * Create multiple records in batch. Used after user confirms extracted transactions.\n */\n private function toolCreateRecordsBatch(array $args): array\n {\n $records = $args['records'] ?? [];\n if (empty($records)) {\n return ['error' => 'No records provided.', 'created' => 0];\n }\n\n // Get all user accounts for matching\n $userAccounts = Account::where('user_id', $this->user->id)->get();\n $defaultAccount = $userAccounts->first();\n\n // Pre-load all categories for fuzzy matching\n $allCategories = Category::where(function ($q) {\n $q->where('user_id', $this->user->id)\n ->orWhereNull('user_id');\n })->get();\n\n $created = 0;\n $skipped = 0;\n $details = [];\n\n foreach ($records as $record) {\n $recordName = $record['name'] ?? 'Unknown';\n $recordAmount = abs(floatval($record['amount'] ?? 0));\n $recordDate = $record['date'] ?? Carbon::now()->format('Y-m-d');\n $recordType = $record['type'] ?? 'expense';\n $recordAccountName = $record['account_name'] ?? null;\n\n if ($recordAmount <= 0) {\n $skipped++;\n $details[] = ['name' => $recordName, 'status' => 'skipped', 'reason' => 'Zero or negative amount'];\n continue;\n }\n\n // Match account\n $accountId = null;\n $matchedAccountName = 'Unknown';\n if ($recordAccountName) {\n $matchedAccount = $userAccounts->first(fn($a) =>\n mb_strtolower($a->name) === mb_strtolower($recordAccountName) ||\n str_contains(mb_strtolower($a->name), mb_strtolower($recordAccountName)) ||\n str_contains(mb_strtolower($recordAccountName), mb_strtolower($a->name))\n );\n if ($matchedAccount) {\n $accountId = $matchedAccount->id;\n $matchedAccountName = $matchedAccount->name;\n }\n }\n if (!$accountId && $defaultAccount) {\n $accountId = $defaultAccount->id;\n $matchedAccountName = $defaultAccount->name;\n }\n\n if (!$accountId) {\n $skipped++;\n $details[] = ['name' => $recordName, 'status' => 'skipped', 'reason' => 'No account found'];\n continue;\n }\n\n // Category: use ID directly if provided, otherwise fall back to name matching\n $categoryId = $record['category_id'] ?? null;\n $categoryName = $record['category_name'] ?? null;\n $matchedCategoryName = 'Unknown';\n\n if ($categoryId) {\n $cat = $allCategories->firstWhere('id', $categoryId);\n if ($cat) {\n $matchedCategoryName = $cat->name;\n } else {\n $categoryId = 44; // Invalid ID → fallback\n $matchedCategoryName = 'Other';\n }\n } else {\n // Fallback: match by name\n $categoryId = 44;\n $matchedCategoryName = 'Other';\n if ($categoryName) {\n $matched = $this->fuzzyMatchCategory($categoryName, $allCategories);\n if ($matched) {\n $categoryId = $matched->id;\n $matchedCategoryName = $matched->name;\n }\n }\n }\n\n // For income, try to find an income category if not matched\n if ($recordType === 'income' && (!$categoryName || $categoryId === 44)) {\n $incomeCategory = $allCategories->first(fn($c) => $c->parent_category_id == 10);\n if ($incomeCategory) {\n $categoryId = $incomeCategory->id;\n $matchedCategoryName = $incomeCategory->name;\n }\n }\n\n // Determine amount sign: expenses are negative, income is positive\n $finalAmount = $recordType === 'income' ? $recordAmount : -$recordAmount;\n\n try {\n $newRecord = Record::create([\n 'user_id' => $this->user->id,\n 'date' => $recordDate,\n 'from_account_id' => $accountId,\n 'type' => $recordType === 'income' ? 'income' : 'expense',\n 'category_id' => $categoryId,\n 'name' => $recordName,\n 'amount' => $finalAmount,\n 'rate' => 1,\n ]);\n\n $created++;\n $details[] = [\n 'name' => $recordName,\n 'status' => 'created',\n 'date' => $recordDate,\n 'amount' => $recordAmount,\n 'type' => $recordType,\n 'category' => $matchedCategoryName,\n 'account' => $matchedAccountName,\n 'id' => $newRecord->id,\n ];\n } catch (\\Exception $e) {\n $skipped++;\n Log::error('Failed to create record from AI batch', [\n 'user_id' => $this->user->id,\n 'record' => $recordName,\n 'error' => $e->getMessage(),\n ]);\n $details[] = ['name' => $recordName, 'status' => 'skipped', 'reason' => 'Database error'];\n }\n }\n\n return [\n 'created' => $created,\n 'skipped' => $skipped,\n 'total' => count($records),\n 'details' => $details,\n 'currency' => $this->user->currency_symbol,\n ];\n }\n\n /**\n * Read BudgetBee documentation files to answer user questions about app functionality.\n * Searches through Markdown doc files in storage/app/documentation/.\n */\n private function toolReadDocumentation(array $args): array\n {\n $query = mb_strtolower(trim($args['query'] ?? ''));\n if (empty($query)) {\n return ['error' => 'No query provided. Please specify a topic to search for.'];\n }\n\n $docsDir = base_path('docs');\n if (!is_dir($docsDir)) {\n return ['error' => 'Documentation directory not found.'];\n }\n\n $files = glob($docsDir . '/*.md');\n if (empty($files)) {\n return ['error' => 'No documentation files found.'];\n }\n\n $results = [];\n $keywords = explode(' ', $query);\n // Also add the full query as a phrase\n $keywords[] = $query;\n\n foreach ($files as $file) {\n $filename = basename($file);\n $content = file_get_contents($file);\n $contentLower = mb_strtolower($content);\n\n // Calculate relevance score: count keyword matches\n $score = 0;\n foreach ($keywords as $keyword) {\n if (mb_strlen($keyword) < 2) continue;\n $score += substr_count($contentLower, $keyword);\n }\n\n // Bonus for title match (first # heading)\n if (preg_match('/^#\\s+(.+)$/m', $content, $titleMatch)) {\n $title = trim($titleMatch[1]);\n $titleLower = mb_strtolower($title);\n foreach ($keywords as $keyword) {\n if (mb_strlen($keyword) >= 3 && str_contains($titleLower, $keyword)) {\n $score += 10;\n }\n }\n } else {\n $title = $filename;\n }\n\n if ($score > 0) {\n $results[] = [\n 'file' => $filename,\n 'title' => $title,\n 'score' => $score,\n 'content' => $content,\n ];\n }\n }\n\n if (empty($results)) {\n return [\n 'query' => $args['query'],\n 'found' => false,\n 'message' => 'No documentation found for \"' . $args['query'] . '\". Try different keywords or ask a more specific question.',\n ];\n }\n\n // Sort by relevance score descending\n usort($results, fn($a, $b) => $b['score'] <=> $a['score']);\n\n // Return top 3 most relevant docs (trimmed to avoid token overflow)\n $topDocs = array_slice($results, 0, 3);\n $documents = [];\n foreach ($topDocs as $doc) {\n // Trim content to ~2000 chars max per doc to avoid token issues\n $trimmed = mb_strlen($doc['content']) > 2000\n ? mb_substr($doc['content'], 0, 2000) . \"\\n\\n[... content truncated ...]\"\n : $doc['content'];\n $documents[] = [\n 'file' => $doc['file'],\n 'title' => $doc['title'],\n 'relevance_score' => $doc['score'],\n 'content' => $trimmed,\n ];\n }\n\n return [\n 'query' => $args['query'],\n 'found' => true,\n 'documents_count' => count($documents),\n 'total_matches' => count($results),\n 'documents' => $documents,\n ];\n }\n\n /**\n * Fuzzy match a category name to the closest existing category.\n */\n private function fuzzyMatchCategory(string $name, $allCategories): ?Category\n {\n $name = mb_strtolower(trim($name));\n if (empty($name)) return null;\n\n // 1. Exact match (case insensitive)\n $direct = $allCategories->first(fn($c) => mb_strtolower($c->name) === $name);\n if ($direct) return $direct;\n\n // 2. Category name contains query (e.g., \"Gasolina\" contains \"gas\")\n $contains = $allCategories->first(fn($c) => str_contains(mb_strtolower($c->name), $name));\n if ($contains) return $contains;\n\n // 3. Query contains category name (e.g., \"Compras online\" contains \"Compras\")\n // Only if query >= 4 chars to avoid \"es\" matching everything\n if (mb_strlen($name) >= 4) {\n $reverse = $allCategories->first(fn($c) =>\n str_contains($name, mb_strtolower($c->name))\n );\n if ($reverse) return $reverse;\n }\n\n return null;\n }\n\n /**\n * Sanitize a value before embedding it in the system prompt.\n * Strips backticks, angle brackets, and markdown control chars\n * that could be used for prompt injection.\n */\n private function sanitizePromptValue(string $value): string\n {\n // Strip backticks (code blocks), angle brackets (HTML/XML), and markdown link syntax\n $value = str_replace(['`', '<', '>', '[', ']', '(', ')'], '', $value);\n // Collapse whitespace\n $value = preg_replace('/\\s+/', ' ', $value);\n // Limit length\n return mb_substr(trim($value), 0, 60);\n }\n\n /**\n * Build a formatted list of the user's categories for the system prompt.\n */\n private function buildCategoriesList(): string\n {\n $categories = Category::where(function ($q) {\n $q->where('user_id', $this->user->id)\n ->orWhereNull('user_id');\n })\n ->with('parent')\n ->get()\n ->groupBy('parent_category_id');\n\n if ($categories->isEmpty()) {\n return '(No categories configured yet)';\n }\n\n $lines = [];\n foreach ($categories as $parentId => $cats) {\n $parentName = $this->sanitizePromptValue($cats->first()->parent_name ?? 'Other');\n $catList = $cats->map(fn($c) => '`' . $this->sanitizePromptValue($c->name) . '` (ID:' . (int)$c->id . ')')->implode(', ');\n $lines[] = '- **' . $parentName . '** (parent ID:' . (int)$parentId . '): ' . $catList;\n }\n\n return implode(\"\\n\", $lines);\n }\n\n /**\n * Save a fact to the user's persistent AI memory.\n */\n private function toolSaveMemory(array $args): array\n {\n $key = trim($args['key'] ?? '');\n $value = trim($args['value'] ?? '');\n\n if (empty($key) || empty($value)) {\n return ['error' => 'Both key and value are required.'];\n }\n\n // Limit key length and value length\n // Sanitize to prevent storing prompt injection payloads\n $key = substr($key, 0, 100);\n $value = substr($value, 0, 500);\n $key = preg_replace('/[<>\\[\\]\\(\\)\\|\\*_`]/', '', $key);\n $value = preg_replace('/[<>\\[\\]\\(\\)\\|\\*_`]/', '', $value);\n\n AiMemory::upsertForUser($this->user->id, $key, $value);\n\n Log::info('AI memory saved', [\n 'user_id' => $this->user->id,\n 'key' => $key,\n ]);\n\n return [\n 'saved' => true,\n 'key' => $key,\n 'value' => $value,\n ];\n }\n}\n"}, "f8bd6b4da2ffc57781beb08c6137eb9e689d844b": {"t": 1785760123.386942, "n": 54976, "h": "b980d14cdc3e2706", "c": "<?php\n\nnamespace App\\Services\\Ai;\n\nuse App\\Models\\Account;\nuse App\\Models\\AiMemory;\nuse App\\Models\\AiProviderKey;\nuse App\\Models\\Budget;\nuse App\\Models\\Category;\nuse App\\Models\\Record;\nuse App\\Models\\User;\nuse Carbon\\Carbon;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Illuminate\\Support\\Facades\\Http;\nuse Illuminate\\Support\\Facades\\Log;\n\nclass AiChatService\n{\n private User $user;\n private ?string $provider;\n private ?string $apiKey;\n\n private const HISTORY_CACHE_PREFIX = 'ai_chat_history_';\n private const STATE_CACHE_PREFIX = 'ai_chat_state_';\n private const MAX_HISTORY_MESSAGES = 30; // Keep last N messages to stay within token limits\n\n public function __construct(User $user)\n {\n $this->user = $user;\n $this->resolveProvider();\n }\n\n /**\n * Find the user's configured AI provider and API key.\n * Priority: OpenAI first, then DeepSeek.\n */\n private function resolveProvider(): void\n {\n $key = AiProviderKey::where('user_id', $this->user->id)\n ->orderByRaw(\"FIELD(provider, 'openai', 'deepseek')\")\n ->first();\n\n if ($key) {\n $this->provider = $key->provider;\n $this->apiKey = $key->api_key;\n } else {\n $this->provider = null;\n $this->apiKey = null;\n }\n }\n\n /**\n * Check if the user has configured any AI provider.\n */\n public function isConfigured(): bool\n {\n return $this->apiKey !== null;\n }\n\n /**\n * Get the provider name for display.\n */\n public function getProviderName(): string\n {\n return match ($this->provider) {\n 'openai' => 'OpenAI',\n 'deepseek' => 'DeepSeek',\n default => 'Unknown',\n };\n }\n\n /**\n * Main entry point: process a chat message and return the AI response.\n * Maintains conversation history so follow-up questions work naturally.\n */\n public function chat(string $message, array $files = []): string\n {\n if (!$this->isConfigured()) {\n return \"I'm not configured yet. Please add an OpenAI or DeepSeek API key in Settings → Main Settings to enable AI features.\";\n }\n\n // Detect images and determine if vision is needed\n $imageFiles = $this->filterImageFiles($files);\n $hasImages = !empty($imageFiles);\n\n // Image analysis requires OpenAI (GPT-4o-mini with vision)\n if ($hasImages && $this->provider !== 'openai') {\n return \"📷 **Image analysis** is only available with **OpenAI**. Please configure an OpenAI API key in Settings → Main Settings to analyze images. DeepSeek does not currently support vision capabilities.\";\n }\n\n // If no message and no images, prompt the user\n if (empty(trim($message)) && !$hasImages) {\n return \"How can I help you? You can ask me about your finances, budgets, or send me an image of your bank transactions to extract them.\";\n }\n\n // Load existing conversation history (full state preferred, fallback to simple history)\n $fullState = $this->getFullState();\n if (!empty($fullState)) {\n $messages = $this->buildMessagesFromFullState($message, $fullState, $imageFiles);\n } else {\n $history = $this->getHistory();\n $messages = $this->buildMessagesWithHistory($message, $history, $imageFiles);\n }\n\n // Track tool calls to detect and prevent infinite loops\n $calledTools = [];\n $lastToolResults = [];\n $history = $this->getHistory();\n\n // Call AI with tool definitions (max 5 round-trips)\n $maxIterations = 5;\n\n for ($i = 0; $i < $maxIterations; $i++) {\n $response = $this->callProvider($messages);\n\n if (!$response) {\n Log::warning('AI chat: provider returned null response', [\n 'user_id' => $this->user->id,\n 'provider' => $this->provider,\n 'iteration' => $i,\n ]);\n return \"Sorry, I couldn't reach the AI service. Please try again later.\";\n }\n\n $choice = $response['choices'][0] ?? null;\n if (!$choice) {\n Log::warning('AI chat: no choice in response', [\n 'user_id' => $this->user->id,\n 'response' => $response,\n ]);\n return \"Sorry, I received an unexpected response from the AI service.\";\n }\n\n $finishReason = $choice['finish_reason'] ?? 'stop';\n\n Log::debug('AI chat iteration', [\n 'user_id' => $this->user->id,\n 'iteration' => $i,\n 'finish_reason' => $finishReason,\n ]);\n\n // If the AI wants to call a tool\n if ($finishReason === 'tool_calls' && !empty($choice['message']['tool_calls'])) {\n $messages[] = $choice['message'];\n\n foreach ($choice['message']['tool_calls'] as $toolCall) {\n $toolName = $toolCall['function']['name'];\n $toolArgs = json_decode($toolCall['function']['arguments'], true) ?? [];\n\n Log::info('AI tool call', [\n 'user_id' => $this->user->id,\n 'tool' => $toolName,\n 'iteration' => $i,\n ]);\n\n // Detect repeated identical tool calls (prevents loops)\n $toolFingerprint = $toolName . ':' . json_encode($toolArgs);\n $calledTools[] = $toolFingerprint;\n\n $toolResult = $this->executeTool($toolName, $toolArgs);\n $lastToolResults[$toolName] = $toolResult;\n\n $messages[] = [\n 'role' => 'tool',\n 'tool_call_id' => $toolCall['id'],\n 'content' => json_encode($toolResult),\n ];\n }\n\n // Save intermediate state so context is preserved across API calls\n $this->saveFullState($messages);\n\n continue; // Back to the AI with tool results\n }\n\n // Normal text response — save to history and full state\n $assistantReply = $choice['message']['content'] ?? \"I'm not sure how to answer that.\";\n\n // Save final assistant message to full state\n $messages[] = $choice['message'];\n $this->saveFullState($messages);\n\n // Append the exchange to simple history and persist\n $history[] = ['role' => 'user', 'content' => $message];\n $history[] = ['role' => 'assistant', 'content' => $assistantReply];\n $this->saveHistory($history);\n\n return $assistantReply;\n }\n\n // Loop exhausted — log what happened and try to give a useful fallback\n Log::warning('AI chat: max tool-call iterations reached', [\n 'user_id' => $this->user->id,\n 'tools_called' => $calledTools,\n 'provider' => $this->provider,\n ]);\n\n // Try one final call without tools to force a text response\n $messagesWithoutTools = $messages;\n $messagesWithoutTools[] = [\n 'role' => 'user',\n 'content' => 'Please provide a direct answer based on the data above. Do NOT call any more tools.',\n ];\n $finalResponse = $this->callProvider($messagesWithoutTools, false);\n if ($finalResponse && !empty($finalResponse['choices'][0]['message']['content'])) {\n $assistantReply = $finalResponse['choices'][0]['message']['content'];\n $history[] = ['role' => 'user', 'content' => $message];\n $history[] = ['role' => 'assistant', 'content' => $assistantReply];\n $this->saveHistory($history);\n return $assistantReply;\n }\n\n return \"I'm having trouble processing your request. Could you try rephrasing it?\";\n }\n\n /**\n * Clear the conversation history for this user.\n */\n public function clearHistory(): void\n {\n Cache::forget(self::HISTORY_CACHE_PREFIX . $this->user->id);\n Cache::forget(self::STATE_CACHE_PREFIX . $this->user->id);\n }\n\n /**\n * Get the conversation history from cache.\n */\n private function getHistory(): array\n {\n return Cache::get(self::HISTORY_CACHE_PREFIX . $this->user->id, []);\n }\n\n /**\n * Save the conversation history to cache (keeps last N messages).\n */\n private function saveHistory(array $history): void\n {\n // Trim to max messages to avoid unbounded growth\n if (count($history) > self::MAX_HISTORY_MESSAGES) {\n $history = array_slice($history, -self::MAX_HISTORY_MESSAGES);\n }\n\n // Cache for 24 hours (conversation expires after a day of inactivity)\n Cache::put(self::HISTORY_CACHE_PREFIX . $this->user->id, $history, now()->addDay());\n }\n\n /**\n * Get the full conversation state from cache (includes tool calls and results).\n */\n private function getFullState(): array\n {\n return Cache::get(self::STATE_CACHE_PREFIX . $this->user->id, []);\n }\n\n /**\n * Save the full messages array (minus system prompt) to cache.\n * This preserves tool calls and results across API calls.\n */\n private function saveFullState(array $messages): void\n {\n // Strip the system prompt (index 0) and keep the conversation messages\n $state = array_values(array_filter($messages, fn($m) => ($m['role'] ?? '') !== 'system'));\n\n // Trim to avoid unbounded growth (keep last 40 messages for tool call context)\n if (count($state) > 40) {\n $state = array_slice($state, -40);\n }\n\n Cache::put(self::STATE_CACHE_PREFIX . $this->user->id, $state, now()->addDay());\n }\n\n /**\n * Build messages from the full conversation state with a fresh system prompt.\n * This gives the AI full context including tool calls and results from previous API calls.\n */\n private function buildMessagesFromFullState(string $userMessage, array $fullState, array $imageFiles = []): array\n {\n $systemPrompt = $this->getSystemPrompt();\n\n $messages = [\n ['role' => 'system', 'content' => $systemPrompt],\n ];\n\n // Include the full conversation state (includes tool calls and results)\n foreach ($fullState as $msg) {\n $messages[] = $msg;\n }\n\n // Build the new user message content\n if (!empty($imageFiles)) {\n $content = [];\n if (!empty(trim($userMessage))) {\n $content[] = ['type' => 'text', 'text' => $userMessage];\n } else {\n $content[] = ['type' => 'text', 'text' => 'Please analyze this image and extract any financial transactions you can see.'];\n }\n foreach ($imageFiles as $image) {\n $content[] = [\n 'type' => 'image_url',\n 'image_url' => [\n 'url' => $image['base64'],\n 'detail' => 'low',\n ],\n ];\n }\n $messages[] = ['role' => 'user', 'content' => $content];\n } else {\n $messages[] = ['role' => 'user', 'content' => $userMessage];\n }\n\n return $messages;\n }\n\n /**\n * Build the messages array including conversation history and optional images.\n * This is the fallback when no full state is available.\n */\n private function buildMessagesWithHistory(string $userMessage, array $history, array $imageFiles = []): array\n {\n $systemPrompt = $this->getSystemPrompt();\n\n $messages = [\n ['role' => 'system', 'content' => $systemPrompt],\n ];\n\n // Include recent conversation history for context\n foreach ($history as $msg) {\n $messages[] = $msg;\n }\n\n // Build the new user message content\n if (!empty($imageFiles)) {\n // Vision format: content is an array of text + image parts\n $content = [];\n if (!empty(trim($userMessage))) {\n $content[] = ['type' => 'text', 'text' => $userMessage];\n } else {\n $content[] = ['type' => 'text', 'text' => 'Please analyze this image and extract any financial transactions you can see.'];\n }\n foreach ($imageFiles as $image) {\n $content[] = [\n 'type' => 'image_url',\n 'image_url' => [\n 'url' => $image['base64'],\n 'detail' => 'low',\n ],\n ];\n }\n $messages[] = ['role' => 'user', 'content' => $content];\n } else {\n // Plain text message\n $messages[] = ['role' => 'user', 'content' => $userMessage];\n }\n\n return $messages;\n }\n\n /**\n * Filter uploaded files to only include images, converting them to base64.\n */\n private function filterImageFiles(array $files): array\n {\n $images = [];\n foreach ($files as $file) {\n $mime = $file['mime'] ?? '';\n if (in_array($mime, ['image/png', 'image/jpeg', 'image/jpg', 'image/gif', 'image/webp'])) {\n $path = $file['path'] ?? null;\n if ($path && file_exists($path)) {\n $base64 = 'data:' . $mime . ';base64,' . base64_encode(file_get_contents($path));\n $images[] = [\n 'name' => $file['name'],\n 'base64' => $base64,\n ];\n }\n }\n }\n return $images;\n }\n\n /**\n * Get the system prompt with context about the user and available tools.\n */\n private function getSystemPrompt(): string\n {\n $currency = $this->user->currency_symbol ?? '$';\n $today = Carbon::now()->format('Y-m-d');\n $currentMonth = Carbon::now()->format('F Y');\n $memoryContext = AiMemory::getContextForUser($this->user->id);\n $memorySection = $memoryContext ? \"\\n**User context (learned from previous conversations):**\\n<!-- BEGIN_USER_MEMORY -->\\n{$memoryContext}\\n<!-- END_USER_MEMORY -->\\n\" : '';\n $categoriesList = $this->buildCategoriesList();\n\n return <<<PROMPT\nYou are BudgetBee AI, a friendly and helpful financial assistant integrated into the BudgetBee personal finance app.\nYour role is to help the user understand their finances by answering questions about their accounts, expenses, income, budgets, and transactions.\n\nCurrent date: {$today}\nCurrent month: {$currentMonth}\nUser's currency: {$currency}\n{$memorySection}\n**User's available categories — ONLY use these exact IDs and names. Do NOT invent categories:**\n<!-- BEGIN_USER_CATEGORIES -->\n{$categoriesList}\n<!-- END_USER_CATEGORIES -->\n\nYou have access to tools that let you query the user's real financial data in the database.\nAlways use these tools to provide accurate, data-driven answers. Do NOT make up numbers — only report what the tools return.\nIf a query returns no data, tell the user honestly.\nBe concise but friendly. Format currency amounts with the user's currency symbol.\n\n**CRITICAL — Conversation continuity:**\n- If there are previous messages in the conversation (user or assistant messages before the current one), you are in an ONGOING conversation. Do NOT greet or introduce yourself again. Continue the conversation naturally.\n- Only greet the user if the conversation history is completely empty (first message ever).\n\n**CRITICAL — Date handling:**\n- When the user says \"this month\", ALWAYS pass `from_date` as the first day of the current month.\n- When the user says \"this year\", ALWAYS pass `from_date` as `{$today}`-01-01 (first day of current year).\n- When the user says \"last month\", ALWAYS calculate and pass the correct first day of last month.\n- User can also specify explicit dates (e.g., \"from January 1st to January 31st\") — parse these and pass them as `from_date` and `to_date`.\n- When no time period is mentioned, the tools default to all records.\n\n**When querying by category:** match the user's words to the closest category from the list above, then use the exact category name in tool calls.\n\n**When creating records:** always pass the `category_id` (the number in parentheses) from the list above.\n\n**IMPORTANT: Always format your responses using Markdown.** Use the following formatting rules:\n- Use **bold** for key numbers, totals, and important figures.\n- Use `code` formatting for dates and technical values.\n- Use bullet lists (starting with -) for listing items like accounts, categories, or transactions.\n- Use numbered lists for step-by-step guidance.\n- Use ### headings for section titles when presenting structured data.\n- Use tables (| column | column |) for comparing data across categories or time periods.\n- Use > blockquotes for important notes or warnings.\n- Keep it clean and readable — don't overuse formatting.\n\nExample of a good response:\n```\nHere's your spending breakdown for this month:\n\n| Category | Amount |\n|----------|--------|\n| Supermarkets | **\\$342.50** |\n| Transport | **\\$128.00** |\n| Entertainment | **\\$85.30** |\n| **Total** | **\\$555.80** |\n\n> Your biggest expense category this month is *Supermarkets* at 62% of total spending.\n```\n\n**DATA EXTRACTION — When the user sends an image or file with transactions:**\n\n1. Call `get_accounts`. Do NOT call `get_categories` (already listed above).\n2. Extract EVERY transaction: negative = expense, positive = income.\n3. Match each to a `category_id` from the list above.\n4. Present a compact table: Date | Description | Amount | Type | Category\n5. Ask: \"¿En qué cuenta los creo? Responde con el nombre de la cuenta y **sí** para confirmar.\"\n6. NEVER call `create_records_batch` without account + confirmation.\n7. When confirmed, use: date, name, amount (positive), type, category_id, account_name.\n\n**Be concise. One table, one question. No repetition.**\n\n**DOCUMENTATION: When the user asks about how BudgetBee works, how to configure something, or how to use a feature, use the `read_documentation` tool to find the answer.** The documentation covers: accounts, records/transactions, categories, budgets, AI chat setup, data import, API keys, and FAQ. Do NOT make up answers about app functionality — always check the documentation first.\n\n**MEMORY: You have persistent memory per user. Use the `save_memory` tool to remember important facts:**\n- If the user consistently speaks a language (Spanish, English, etc.), save it as `language`.\n- If the user mentions their preferred account, save it as `preferred_account`.\n- If you notice patterns (e.g., \"I always shop at Mercadona on Fridays\"), save them.\n- Save any fact that would help you provide better, more personalized responses in future conversations.\n- Use the memory context provided above — it contains everything learned from previous conversations.\n- **Call `save_memory` automatically** when you learn something new — no need to ask permission.\nPROMPT;\n }\n\n /**\n * Get the tool definitions for the AI provider (MCP-style).\n */\n private function getTools(): array\n {\n return [\n [\n 'type' => 'function',\n 'function' => [\n 'name' => 'get_balance',\n 'description' => 'Get the user\\'s current total balance across all accounts.',\n 'parameters' => [\n 'type' => 'object',\n 'properties' => (object) [],\n ],\n ],\n ],\n [\n 'type' => 'function',\n 'function' => [\n 'name' => 'get_accounts',\n 'description' => 'Get all accounts with their balances, types, and currencies.',\n 'parameters' => [\n 'type' => 'object',\n 'properties' => (object) [],\n ],\n ],\n ],\n [\n 'type' => 'function',\n 'function' => [\n 'name' => 'get_expenses_by_category',\n 'description' => 'Get total expenses grouped by category for a given date range. Use this when the user asks how much they spent on something (e.g., \"how much did I spend on groceries this month?\").',\n 'parameters' => [\n 'type' => 'object',\n 'properties' => [\n 'from_date' => [\n 'type' => 'string',\n 'description' => 'Start date in Y-m-d format. Defaults to January 1st of current year. ALWAYS set this explicitly when the user mentions a time period (e.g., \"this month\" → first day of month, \"this year\" → first day of year, \"last month\" → first day of last month).',\n ],\n 'to_date' => [\n 'type' => 'string',\n 'description' => 'End date in Y-m-d format. Defaults to today.',\n ],\n 'category_name' => [\n 'type' => 'string',\n 'description' => 'Optional: filter by category name. Use the EXACT category name from the list in the system prompt.',\n ],\n ],\n ],\n ],\n ],\n [\n 'type' => 'function',\n 'function' => [\n 'name' => 'get_income_by_category',\n 'description' => 'Get total income grouped by category for a given date range.',\n 'parameters' => [\n 'type' => 'object',\n 'properties' => [\n 'from_date' => [\n 'type' => 'string',\n 'description' => 'Start date in Y-m-d format. Defaults to January 1st of current year. ALWAYS set this explicitly when the user mentions a time period.',\n ],\n 'to_date' => [\n 'type' => 'string',\n 'description' => 'End date in Y-m-d format. Defaults to today.',\n ],\n ],\n ],\n ],\n ],\n [\n 'type' => 'function',\n 'function' => [\n 'name' => 'get_recent_records',\n 'description' => 'Get the most recent transactions. Use this when the user asks about recent activity or their latest transactions.',\n 'parameters' => [\n 'type' => 'object',\n 'properties' => [\n 'limit' => [\n 'type' => 'integer',\n 'description' => 'Number of records to return. Defaults to 10.',\n ],\n 'category_name' => [\n 'type' => 'string',\n 'description' => 'Optional: filter by category name.',\n ],\n ],\n ],\n ],\n ],\n [\n 'type' => 'function',\n 'function' => [\n 'name' => 'get_budgets',\n 'description' => 'Get the user\\'s budgets with their current spent amounts and remaining balances.',\n 'parameters' => [\n 'type' => 'object',\n 'properties' => (object) [],\n ],\n ],\n ],\n [\n 'type' => 'function',\n 'function' => [\n 'name' => 'search_records',\n 'description' => 'Search for transactions by name or description keyword.',\n 'parameters' => [\n 'type' => 'object',\n 'properties' => [\n 'keyword' => [\n 'type' => 'string',\n 'description' => 'Search keyword to look for in transaction names.',\n ],\n 'limit' => [\n 'type' => 'integer',\n 'description' => 'Maximum number of results. Defaults to 20.',\n ],\n ],\n 'required' => ['keyword'],\n ],\n ],\n ],\n [\n 'type' => 'function',\n 'function' => [\n 'name' => 'get_categories',\n 'description' => 'Get all available expense and income categories that the user has.',\n 'parameters' => [\n 'type' => 'object',\n 'properties' => (object) [],\n ],\n ],\n ],\n [\n 'type' => 'function',\n 'function' => [\n 'name' => 'create_records_batch',\n 'description' => 'Create multiple financial records at once. ONLY call this after the user has explicitly confirmed they want the records created. Always present the extracted data first and ask for confirmation. The user must say \"yes\", \"create them\", \"confirm\", or similar before you call this tool.',\n 'parameters' => [\n 'type' => 'object',\n 'properties' => [\n 'records' => [\n 'type' => 'array',\n 'description' => 'Array of records to create.',\n 'items' => [\n 'type' => 'object',\n 'properties' => [\n 'date' => [\n 'type' => 'string',\n 'description' => 'Date in Y-m-d format.',\n ],\n 'name' => [\n 'type' => 'string',\n 'description' => 'Transaction name/description.',\n ],\n 'amount' => [\n 'type' => 'number',\n 'description' => 'ALWAYS a positive number (e.g., 45.50). The system handles the sign internally.',\n ],\n 'type' => [\n 'type' => 'string',\n 'enum' => ['income', 'expense'],\n 'description' => 'Whether this is income (money received, salary, deposit) or expense (payment, purchase, withdrawal). Required for every record.',\n ],\n 'category_name' => [\n 'type' => 'string',\n 'description' => 'DEPRECATED: use category_id instead. Category name from the list.',\n ],\n 'category_id' => [\n 'type' => 'integer',\n 'description' => 'The exact category ID from the list above. Always use this — pick the ID that best matches the transaction description.',\n ],\n 'account_name' => [\n 'type' => 'string',\n 'description' => 'Account name. MUST match one of the user\\'s real accounts from get_accounts. Call get_accounts first BEFORE presenting data to the user.',\n ],\n ],\n 'required' => ['date', 'name', 'amount', 'type', 'category_id', 'account_name'],\n ],\n ],\n ],\n 'required' => ['records'],\n ],\n ],\n ],\n [\n 'type' => 'function',\n 'function' => [\n 'name' => 'save_memory',\n 'description' => 'Save a fact about the user to persistent memory. Use this to remember user preferences, language, habits, or any information that helps personalize future interactions.',\n 'parameters' => [\n 'type' => 'object',\n 'properties' => [\n 'key' => [\n 'type' => 'string',\n 'description' => 'Short key describing what you are remembering (e.g., \"language\", \"preferred_account\", \"shopping_habit\").',\n ],\n 'value' => [\n 'type' => 'string',\n 'description' => 'The fact or preference to remember (e.g., \"Spanish\", \"Checking Account\", \"Shops at Mercadona on Fridays\").',\n ],\n ],\n 'required' => ['key', 'value'],\n ],\n ],\n ],\n [\n 'type' => 'function',\n 'function' => [\n 'name' => 'read_documentation',\n 'description' => 'Read BudgetBee documentation to answer user questions about how the app works, how to configure features, how to use specific functionality, or troubleshooting. Use this when the user asks \"how do I...\", \"how does X work?\", \"how do I configure...?\", or any question about using BudgetBee itself (not about their personal financial data).',\n 'parameters' => [\n 'type' => 'object',\n 'properties' => [\n 'query' => [\n 'type' => 'string',\n 'description' => 'The topic or question to search for in the documentation. Use keywords like \"accounts\", \"categories\", \"budgets\", \"import\", \"AI setup\", \"API\", \"FAQ\", etc.',\n ],\n ],\n 'required' => ['query'],\n ],\n ],\n ],\n ];\n }\n\n /**\n * Call the AI provider's API.\n */\n private function callProvider(array $messages, bool $includeTools = true): ?array\n {\n $apiUrl = match ($this->provider) {\n 'openai' => 'https://api.openai.com/v1/chat/completions',\n 'deepseek' => 'https://api.deepseek.com/v1/chat/completions',\n default => null,\n };\n\n if (!$apiUrl) {\n return null;\n }\n\n $payload = [\n 'model' => $this->getModelName(),\n 'messages' => $messages,\n 'temperature' => 0.3,\n 'max_tokens' => 4000,\n ];\n\n if ($includeTools) {\n $payload['tools'] = $this->getTools();\n $payload['tool_choice'] = 'auto';\n }\n\n try {\n $httpResponse = Http::withHeaders([\n 'Authorization' => \"Bearer {$this->apiKey}\",\n 'Content-Type' => 'application/json',\n ])\n ->timeout(60)\n ->post($apiUrl, $payload);\n\n if ($httpResponse->successful()) {\n return $httpResponse->json();\n }\n\n Log::error('AI Provider error', [\n 'provider' => $this->provider,\n 'status' => $httpResponse->status(),\n 'body' => $httpResponse->body(),\n ]);\n\n return null;\n } catch (\\Exception $e) {\n Log::error('AI Provider exception', [\n 'provider' => $this->provider,\n 'error' => $e->getMessage(),\n ]);\n\n return null;\n }\n }\n\n /**\n * Get the appropriate model name for each provider.\n */\n private function getModelName(): string\n {\n return match ($this->provider) {\n 'openai' => 'gpt-4o-mini',\n 'deepseek' => 'deepseek-chat',\n default => 'gpt-4o-mini',\n };\n }\n\n /**\n * Execute a tool call and return the result.\n */\n private function executeTool(string $name, array $arguments): array\n {\n return match ($name) {\n 'get_balance' => $this->toolGetBalance(),\n 'get_accounts' => $this->toolGetAccounts(),\n 'get_expenses_by_category' => $this->toolGetExpensesByCategory($arguments),\n 'get_income_by_category' => $this->toolGetIncomeByCategory($arguments),\n 'get_recent_records' => $this->toolGetRecentRecords($arguments),\n 'get_budgets' => $this->toolGetBudgets(),\n 'search_records' => $this->toolSearchRecords($arguments),\n 'get_categories' => $this->toolGetCategories(),\n 'create_records_batch' => $this->toolCreateRecordsBatch($arguments),\n 'save_memory' => $this->toolSaveMemory($arguments),\n 'read_documentation' => $this->toolReadDocumentation($arguments),\n default => ['error' => \"Unknown tool: {$name}\"],\n };\n }\n\n // ─── Tool Implementations ───────────────────────────────────────\n\n private function toolGetBalance(): array\n {\n $balance = Account::where('user_id', $this->user->id)\n ->get()\n ->sum('balance_base_currency');\n\n $accounts = Account::where('user_id', $this->user->id)\n ->get()\n ->map(fn($a) => [\n 'name' => $a->name,\n 'balance' => round($a->balance, 2),\n 'currency' => $a->currency_symbol,\n ]);\n\n return [\n 'total_balance' => round($balance, 2),\n 'currency' => $this->user->currency_symbol,\n 'accounts' => $accounts->toArray(),\n ];\n }\n\n private function toolGetAccounts(): array\n {\n $accounts = Account::where('user_id', $this->user->id)\n ->get()\n ->map(fn($a) => [\n 'name' => $a->name,\n 'type' => $a->account_type_name,\n 'balance' => round($a->balance, 2),\n 'currency' => $a->currency_symbol,\n 'initial_balance' => round($a->initial_balance, 2),\n ]);\n\n return [\n 'accounts' => $accounts->toArray(),\n 'currency' => $this->user->currency_symbol,\n ];\n }\n\n private function toolGetExpensesByCategory(array $args): array\n {\n $fromDate = $args['from_date'] ?? Carbon::now()->startOfYear()->format('Y-m-d');\n $toDate = $args['to_date'] ?? Carbon::now()->format('Y-m-d');\n $categoryFilter = $args['category_name'] ?? null;\n\n $excludeParentIds = [1, 10]; // Exclude transfers and income\n\n $query = Record::where('user_id', $this->user->id)\n ->whereIn('type', ['expense'])\n ->where('date', '>=', $fromDate)\n ->where('date', '<=', $toDate)\n ->whereHas('category.parent', function ($q) use ($excludeParentIds) {\n $q->whereNotIn('id', $excludeParentIds);\n })\n ->with('category.parent');\n\n // Filter by category name if provided (AI uses exact names from prompt)\n if ($categoryFilter) {\n $query->where(function ($q) use ($categoryFilter) {\n $q->whereHas('category', function ($sq) use ($categoryFilter) {\n $sq->where('name', 'like', \"%{$categoryFilter}%\");\n })->orWhereHas('category.parent', function ($sq) use ($categoryFilter) {\n $sq->where('name', 'like', \"%{$categoryFilter}%\");\n });\n });\n }\n\n $records = $query->get();\n\n $grouped = [];\n foreach ($records as $record) {\n $parentName = $record->parent_category_name ?? 'Other';\n $catName = $record->category_name ?? 'Unknown';\n\n $grouped[$parentName]['total'] = ($grouped[$parentName]['total'] ?? 0) + round(abs($record->amount_base_currency), 2);\n $grouped[$parentName]['categories'][$catName] = ($grouped[$parentName]['categories'][$catName] ?? 0) + round(abs($record->amount_base_currency), 2);\n }\n\n $totalExpenses = array_sum(array_column($grouped, 'total'));\n\n return [\n 'from_date' => $fromDate,\n 'to_date' => $toDate,\n 'total_expenses' => round($totalExpenses, 2),\n 'currency' => $this->user->currency_symbol,\n 'by_parent_category' => $grouped,\n ];\n }\n\n private function toolGetIncomeByCategory(array $args): array\n {\n $fromDate = $args['from_date'] ?? Carbon::now()->startOfYear()->format('Y-m-d');\n $toDate = $args['to_date'] ?? Carbon::now()->format('Y-m-d');\n\n $incomeCategoryIds = Category::where('parent_category_id', 10)->pluck('id');\n\n $records = Record::where('user_id', $this->user->id)\n ->whereIn('category_id', $incomeCategoryIds)\n ->where('date', '>=', $fromDate)\n ->where('date', '<=', $toDate)\n ->with('category')\n ->get();\n\n $grouped = [];\n foreach ($records as $record) {\n $catName = $record->category_name ?? 'Unknown';\n $grouped[$catName] = ($grouped[$catName] ?? 0) + round(abs($record->amount_base_currency), 2);\n }\n\n $totalIncome = array_sum($grouped);\n\n return [\n 'from_date' => $fromDate,\n 'to_date' => $toDate,\n 'total_income' => round($totalIncome, 2),\n 'currency' => $this->user->currency_symbol,\n 'by_category' => $grouped,\n ];\n }\n\n private function toolGetRecentRecords(array $args): array\n {\n $limit = min($args['limit'] ?? 10, 50);\n $categoryFilter = $args['category_name'] ?? null;\n\n $query = Record::where('user_id', $this->user->id)\n ->with('category.parent')\n ->orderBy('date', 'desc')\n ->orderBy('id', 'desc')\n ->limit($limit);\n\n if ($categoryFilter) {\n $query->whereHas('category', function ($q) use ($categoryFilter) {\n $q->where('name', 'like', \"%{$categoryFilter}%\");\n });\n }\n\n $records = $query->get()->map(fn($r) => [\n 'date' => $r->date,\n 'name' => $r->name,\n 'amount' => round($r->amount, 2),\n 'type' => $r->type,\n 'category' => $r->category_name ?? 'Unknown',\n 'parent_category' => $r->parent_category_name ?? 'Unknown',\n 'account' => $r->account_name ?? 'Unknown',\n 'currency' => $r->currency_symbol,\n ]);\n\n return [\n 'records' => $records->toArray(),\n 'count' => $records->count(),\n ];\n }\n\n private function toolGetBudgets(): array\n {\n $budgets = Budget::where('user_id', $this->user->id)\n ->with('category.parent')\n ->get()\n ->map(fn($b) => [\n 'category' => $b->category_name,\n 'parent_category' => $b->parent_category_name,\n 'budgeted' => round($b->amount, 2),\n 'spent' => round($b->spent, 2),\n 'remaining' => round($b->amount - $b->spent, 2),\n 'percent_used' => round($b->spent_percent, 1),\n ]);\n\n return [\n 'budgets' => $budgets->toArray(),\n 'currency' => $this->user->currency_symbol,\n ];\n }\n\n private function toolSearchRecords(array $args): array\n {\n $keyword = $args['keyword'] ?? '';\n $limit = min($args['limit'] ?? 20, 50);\n\n $records = Record::where('user_id', $this->user->id)\n ->where('name', 'like', \"%{$keyword}%\")\n ->with('category.parent')\n ->orderBy('date', 'desc')\n ->limit($limit)\n ->get()\n ->map(fn($r) => [\n 'date' => $r->date,\n 'name' => $r->name,\n 'amount' => round($r->amount, 2),\n 'type' => $r->type,\n 'category' => $r->category_name ?? 'Unknown',\n 'parent_category' => $r->parent_category_name ?? 'Unknown',\n 'currency' => $r->currency_symbol,\n ]);\n\n return [\n 'keyword' => $keyword,\n 'records' => $records->toArray(),\n 'count' => $records->count(),\n ];\n }\n\n private function toolGetCategories(): array\n {\n $categories = Category::where(function ($q) {\n $q->where('user_id', $this->user->id)\n ->orWhereNull('user_id');\n })\n ->with('parent')\n ->get()\n ->groupBy('parent_category_id')\n ->map(function ($cats, $parentId) {\n $parentName = $cats->first()->parent_name ?? 'Other';\n return [\n 'parent_category' => $parentName,\n 'categories' => $cats->map(fn($c) => $c->name)->toArray(),\n ];\n });\n\n return [\n 'category_groups' => $categories->values()->toArray(),\n ];\n }\n\n /**\n * Create multiple records in batch. Used after user confirms extracted transactions.\n */\n private function toolCreateRecordsBatch(array $args): array\n {\n $records = $args['records'] ?? [];\n if (empty($records)) {\n return ['error' => 'No records provided.', 'created' => 0];\n }\n\n // Get all user accounts for matching\n $userAccounts = Account::where('user_id', $this->user->id)->get();\n $defaultAccount = $userAccounts->first();\n\n // Pre-load all categories for fuzzy matching\n $allCategories = Category::where(function ($q) {\n $q->where('user_id', $this->user->id)\n ->orWhereNull('user_id');\n })->get();\n\n $created = 0;\n $skipped = 0;\n $details = [];\n\n foreach ($records as $record) {\n $recordName = $record['name'] ?? 'Unknown';\n $recordAmount = abs(floatval($record['amount'] ?? 0));\n $recordDate = $record['date'] ?? Carbon::now()->format('Y-m-d');\n $recordType = $record['type'] ?? 'expense';\n $recordAccountName = $record['account_name'] ?? null;\n\n if ($recordAmount <= 0) {\n $skipped++;\n $details[] = ['name' => $recordName, 'status' => 'skipped', 'reason' => 'Zero or negative amount'];\n continue;\n }\n\n // Match account\n $accountId = null;\n $matchedAccountName = 'Unknown';\n if ($recordAccountName) {\n $matchedAccount = $userAccounts->first(fn($a) =>\n mb_strtolower($a->name) === mb_strtolower($recordAccountName) ||\n str_contains(mb_strtolower($a->name), mb_strtolower($recordAccountName)) ||\n str_contains(mb_strtolower($recordAccountName), mb_strtolower($a->name))\n );\n if ($matchedAccount) {\n $accountId = $matchedAccount->id;\n $matchedAccountName = $matchedAccount->name;\n }\n }\n if (!$accountId && $defaultAccount) {\n $accountId = $defaultAccount->id;\n $matchedAccountName = $defaultAccount->name;\n }\n\n if (!$accountId) {\n $skipped++;\n $details[] = ['name' => $recordName, 'status' => 'skipped', 'reason' => 'No account found'];\n continue;\n }\n\n // Category: use ID directly if provided, otherwise fall back to name matching\n $categoryId = $record['category_id'] ?? null;\n $categoryName = $record['category_name'] ?? null;\n $matchedCategoryName = 'Unknown';\n\n if ($categoryId) {\n $cat = $allCategories->firstWhere('id', $categoryId);\n if ($cat) {\n $matchedCategoryName = $cat->name;\n } else {\n $categoryId = 44; // Invalid ID → fallback\n $matchedCategoryName = 'Other';\n }\n } else {\n // Fallback: match by name\n $categoryId = 44;\n $matchedCategoryName = 'Other';\n if ($categoryName) {\n $matched = $this->fuzzyMatchCategory($categoryName, $allCategories);\n if ($matched) {\n $categoryId = $matched->id;\n $matchedCategoryName = $matched->name;\n }\n }\n }\n\n // For income, try to find an income category if not matched\n if ($recordType === 'income' && (!$categoryName || $categoryId === 44)) {\n $incomeCategory = $allCategories->first(fn($c) => $c->parent_category_id == 10);\n if ($incomeCategory) {\n $categoryId = $incomeCategory->id;\n $matchedCategoryName = $incomeCategory->name;\n }\n }\n\n // Determine amount sign: expenses are negative, income is positive\n $finalAmount = $recordType === 'income' ? $recordAmount : -$recordAmount;\n\n try {\n $newRecord = Record::create([\n 'user_id' => $this->user->id,\n 'date' => $recordDate,\n 'from_account_id' => $accountId,\n 'type' => $recordType === 'income' ? 'income' : 'expense',\n 'category_id' => $categoryId,\n 'name' => $recordName,\n 'amount' => $finalAmount,\n 'rate' => 1,\n ]);\n\n $created++;\n $details[] = [\n 'name' => $recordName,\n 'status' => 'created',\n 'date' => $recordDate,\n 'amount' => $recordAmount,\n 'type' => $recordType,\n 'category' => $matchedCategoryName,\n 'account' => $matchedAccountName,\n 'id' => $newRecord->id,\n ];\n } catch (\\Exception $e) {\n $skipped++;\n Log::error('Failed to create record from AI batch', [\n 'user_id' => $this->user->id,\n 'record' => $recordName,\n 'error' => $e->getMessage(),\n ]);\n $details[] = ['name' => $recordName, 'status' => 'skipped', 'reason' => 'Database error'];\n }\n }\n\n return [\n 'created' => $created,\n 'skipped' => $skipped,\n 'total' => count($records),\n 'details' => $details,\n 'currency' => $this->user->currency_symbol,\n ];\n }\n\n /**\n * Read BudgetBee documentation files to answer user questions about app functionality.\n * Searches through Markdown doc files in storage/app/documentation/.\n */\n private function toolReadDocumentation(array $args): array\n {\n $query = mb_strtolower(trim($args['query'] ?? ''));\n if (empty($query)) {\n return ['error' => 'No query provided. Please specify a topic to search for.'];\n }\n\n $docsDir = base_path('docs');\n if (!is_dir($docsDir)) {\n return ['error' => 'Documentation directory not found.'];\n }\n\n $files = glob($docsDir . '/*.md');\n if (empty($files)) {\n return ['error' => 'No documentation files found.'];\n }\n\n $results = [];\n $keywords = explode(' ', $query);\n // Also add the full query as a phrase\n $keywords[] = $query;\n\n foreach ($files as $file) {\n $filename = basename($file);\n $content = file_get_contents($file);\n $contentLower = mb_strtolower($content);\n\n // Calculate relevance score: count keyword matches\n $score = 0;\n foreach ($keywords as $keyword) {\n if (mb_strlen($keyword) < 2) continue;\n $score += substr_count($contentLower, $keyword);\n }\n\n // Bonus for title match (first # heading)\n if (preg_match('/^#\\s+(.+)$/m', $content, $titleMatch)) {\n $title = trim($titleMatch[1]);\n $titleLower = mb_strtolower($title);\n foreach ($keywords as $keyword) {\n if (mb_strlen($keyword) >= 3 && str_contains($titleLower, $keyword)) {\n $score += 10;\n }\n }\n } else {\n $title = $filename;\n }\n\n if ($score > 0) {\n $results[] = [\n 'file' => $filename,\n 'title' => $title,\n 'score' => $score,\n 'content' => $content,\n ];\n }\n }\n\n if (empty($results)) {\n return [\n 'query' => $args['query'],\n 'found' => false,\n 'message' => 'No documentation found for \"' . $args['query'] . '\". Try different keywords or ask a more specific question.',\n ];\n }\n\n // Sort by relevance score descending\n usort($results, fn($a, $b) => $b['score'] <=> $a['score']);\n\n // Return top 3 most relevant docs (trimmed to avoid token overflow)\n $topDocs = array_slice($results, 0, 3);\n $documents = [];\n foreach ($topDocs as $doc) {\n // Trim content to ~2000 chars max per doc to avoid token issues\n $trimmed = mb_strlen($doc['content']) > 2000\n ? mb_substr($doc['content'], 0, 2000) . \"\\n\\n[... content truncated ...]\"\n : $doc['content'];\n $documents[] = [\n 'file' => $doc['file'],\n 'title' => $doc['title'],\n 'relevance_score' => $doc['score'],\n 'content' => $trimmed,\n ];\n }\n\n return [\n 'query' => $args['query'],\n 'found' => true,\n 'documents_count' => count($documents),\n 'total_matches' => count($results),\n 'documents' => $documents,\n ];\n }\n\n /**\n * Fuzzy match a category name to the closest existing category.\n */\n private function fuzzyMatchCategory(string $name, $allCategories): ?Category\n {\n $name = mb_strtolower(trim($name));\n if (empty($name)) return null;\n\n // 1. Exact match (case insensitive)\n $direct = $allCategories->first(fn($c) => mb_strtolower($c->name) === $name);\n if ($direct) return $direct;\n\n // 2. Category name contains query (e.g., \"Gasolina\" contains \"gas\")\n $contains = $allCategories->first(fn($c) => str_contains(mb_strtolower($c->name), $name));\n if ($contains) return $contains;\n\n // 3. Query contains category name (e.g., \"Compras online\" contains \"Compras\")\n // Only if query >= 4 chars to avoid \"es\" matching everything\n if (mb_strlen($name) >= 4) {\n $reverse = $allCategories->first(fn($c) =>\n str_contains($name, mb_strtolower($c->name))\n );\n if ($reverse) return $reverse;\n }\n\n return null;\n }\n\n /**\n * Sanitize a value before embedding it in the system prompt.\n * Strips backticks, angle brackets, and markdown control chars\n * that could be used for prompt injection.\n */\n private function sanitizePromptValue(string $value): string\n {\n // Strip backticks (code blocks), angle brackets (HTML/XML), and markdown link syntax\n $value = str_replace(['`', '<', '>', '[', ']', '(', ')'], '', $value);\n // Collapse whitespace\n $value = preg_replace('/\\s+/', ' ', $value);\n // Limit length\n return mb_substr(trim($value), 0, 60);\n }\n\n /**\n * Build a formatted list of the user's categories for the system prompt.\n */\n private function buildCategoriesList(): string\n {\n $categories = Category::where(function ($q) {\n $q->where('user_id', $this->user->id)\n ->orWhereNull('user_id');\n })\n ->with('parent')\n ->get()\n ->groupBy('parent_category_id');\n\n if ($categories->isEmpty()) {\n return '(No categories configured yet)';\n }\n\n $lines = [];\n foreach ($categories as $parentId => $cats) {\n $parentName = $this->sanitizePromptValue($cats->first()->parent_name ?? 'Other');\n $catList = $cats->map(fn($c) => '`' . $this->sanitizePromptValue($c->name) . '` (ID:' . (int)$c->id . ')')->implode(', ');\n $lines[] = '- **' . $parentName . '** (parent ID:' . (int)$parentId . '): ' . $catList;\n }\n\n return implode(\"\\n\", $lines);\n }\n\n /**\n * Save a fact to the user's persistent AI memory.\n */\n private function toolSaveMemory(array $args): array\n {\n $key = trim($args['key'] ?? '');\n $value = trim($args['value'] ?? '');\n\n if (empty($key) || empty($value)) {\n return ['error' => 'Both key and value are required.'];\n }\n\n // Limit key length and value length\n // Sanitize to prevent storing prompt injection payloads\n $key = substr($key, 0, 100);\n $value = substr($value, 0, 500);\n $key = preg_replace('/[<>\\[\\]\\(\\)\\|\\*_`]/', '', $key);\n $value = preg_replace('/[<>\\[\\]\\(\\)\\|\\*_`]/', '', $value);\n\n AiMemory::upsertForUser($this->user->id, $key, $value);\n\n Log::info('AI memory saved', [\n 'user_id' => $this->user->id,\n 'key' => $key,\n ]);\n\n return [\n 'saved' => true,\n 'key' => $key,\n 'value' => $value,\n ];\n }\n}\n"}, "851dc3e9f8300be1c63518a40e6d8b198fea0020": {"t": 1785760126.5546365, "n": 49966, "h": "fd4d74dc79f3776f", "c": "<?php\n\nnamespace App\\Services\\Ai;\n\nuse App\\Models\\Account;\nuse App\\Models\\AiMemory;\nuse App\\Models\\AiProviderKey;\nuse App\\Models\\Budget;\nuse App\\Models\\Category;\nuse App\\Models\\Record;\nuse App\\Models\\User;\nuse Carbon\\Carbon;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Illuminate\\Support\\Facades\\Http;\nuse Illuminate\\Support\\Facades\\Log;\n\nclass AiChatService\n{\n private User $user;\n private ?string $provider;\n private ?string $apiKey;\n\n private const HISTORY_CACHE_PREFIX = 'ai_chat_history_';\n private const STATE_CACHE_PREFIX = 'ai_chat_state_';\n private const MAX_HISTORY_MESSAGES = 30; // Keep last N messages to stay within token limits\n\n public function __construct(User $user)\n {\n $this->user = $user;\n $this->resolveProvider();\n }\n\n /**\n * Find the user's configured AI provider and API key.\n * Priority: OpenAI first, then DeepSeek.\n */\n private function resolveProvider(): void\n {\n $key = AiProviderKey::where('user_id', $this->user->id)\n ->orderByRaw(\"FIELD(provider, 'openai', 'deepseek')\")\n ->first();\n\n if ($key) {\n $this->provider = $key->provider;\n $this->apiKey = $key->api_key;\n } else {\n $this->provider = null;\n $this->apiKey = null;\n }\n }\n\n /**\n * Check if the user has configured any AI provider.\n */\n public function isConfigured(): bool\n {\n return $this->apiKey !== null;\n }\n\n /**\n * Get the provider name for display.\n */\n public function getProviderName(): string\n {\n return match ($this->provider) {\n 'openai' => 'OpenAI',\n 'deepseek' => 'DeepSeek',\n default => 'Unknown',\n };\n }\n\n /**\n * Main entry point: process a chat message and return the AI response.\n * Maintains conversation history so follow-up questions work naturally.\n */\n public function chat(string $message, array $files = []): string\n {\n if (!$this->isConfigured()) {\n return \"I'm not configured yet. Please add an OpenAI or DeepSeek API key in Settings → Main Settings to enable AI features.\";\n }\n\n // Detect images and determine if vision is needed\n $imageFiles = $this->filterImageFiles($files);\n $hasImages = !empty($imageFiles);\n\n // Image analysis requires OpenAI (GPT-4o-mini with vision)\n if ($hasImages && $this->provider !== 'openai') {\n return \"📷 **Image analysis** is only available with **OpenAI**. Please configure an OpenAI API key in Settings → Main Settings to analyze images. DeepSeek does not currently support vision capabilities.\";\n }\n\n // If no message and no images, prompt the user\n if (empty(trim($message)) && !$hasImages) {\n return \"How can I help you? You can ask me about your finances, budgets, or send me an image of your bank transactions to extract them.\";\n }\n\n // Load existing conversation history (full state preferred, fallback to simple history)\n $fullState = $this->getFullState();\n if (!empty($fullState)) {\n $messages = $this->buildMessagesFromFullState($message, $fullState, $imageFiles);\n } else {\n $history = $this->getHistory();\n $messages = $this->buildMessagesWithHistory($message, $history, $imageFiles);\n }\n\n // Track tool calls to detect and prevent infinite loops\n $calledTools = [];\n $lastToolResults = [];\n $history = $this->getHistory();\n\n // Call AI with tool definitions (max 5 round-trips)\n $maxIterations = 5;\n\n for ($i = 0; $i < $maxIterations; $i++) {\n $response = $this->callProvider($messages);\n\n if (!$response) {\n Log::warning('AI chat: provider returned null response', [\n 'user_id' => $this->user->id,\n 'provider' => $this->provider,\n 'iteration' => $i,\n ]);\n return \"Sorry, I couldn't reach the AI service. Please try again later.\";\n }\n\n $choice = $response['choices'][0] ?? null;\n if (!$choice) {\n Log::warning('AI chat: no choice in response', [\n 'user_id' => $this->user->id,\n 'response' => $response,\n ]);\n return \"Sorry, I received an unexpected response from the AI service.\";\n }\n\n $finishReason = $choice['finish_reason'] ?? 'stop';\n\n Log::debug('AI chat iteration', [\n 'user_id' => $this->user->id,\n 'iteration' => $i,\n 'finish_reason' => $finishReason,\n ]);\n\n // If the AI wants to call a tool\n if ($finishReason === 'tool_calls' && !empty($choice['message']['tool_calls'])) {\n $messages[] = $choice['message'];\n\n foreach ($choice['message']['tool_calls'] as $toolCall) {\n $toolName = $toolCall['function']['name'];\n $toolArgs = json_decode($toolCall['function']['arguments'], true) ?? [];\n\n Log::info('AI tool call', [\n 'user_id' => $this->user->id,\n 'tool' => $toolName,\n 'iteration' => $i,\n ]);\n\n // Detect repeated identical tool calls (prevents loops)\n $toolFingerprint = $toolName . ':' . json_encode($toolArgs);\n $calledTools[] = $toolFingerprint;\n\n $toolResult = $this->executeTool($toolName, $toolArgs);\n $lastToolResults[$toolName] = $toolResult;\n\n $messages[] = [\n 'role' => 'tool',\n 'tool_call_id' => $toolCall['id'],\n 'content' => json_encode($toolResult),\n ];\n }\n\n // Save intermediate state so context is preserved across API calls\n $this->saveFullState($messages);\n\n continue; // Back to the AI with tool results\n }\n\n // Normal text response — save to history and full state\n $assistantReply = $choice['message']['content'] ?? \"I'm not sure how to answer that.\";\n\n // Save final assistant message to full state\n $messages[] = $choice['message'];\n $this->saveFullState($messages);\n\n // Append the exchange to simple history and persist\n $history[] = ['role' => 'user', 'content' => $message];\n $history[] = ['role' => 'assistant', 'content' => $assistantReply];\n $this->saveHistory($history);\n\n return $assistantReply;\n }\n\n // Loop exhausted — log what happened and try to give a useful fallback\n Log::warning('AI chat: max tool-call iterations reached', [\n 'user_id' => $this->user->id,\n 'tools_called' => $calledTools,\n 'provider' => $this->provider,\n ]);\n\n // Try one final call without tools to force a text response\n $messagesWithoutTools = $messages;\n $messagesWithoutTools[] = [\n 'role' => 'user',\n 'content' => 'Please provide a direct answer based on the data above. Do NOT call any more tools.',\n ];\n $finalResponse = $this->callProvider($messagesWithoutTools, false);\n if ($finalResponse && !empty($finalResponse['choices'][0]['message']['content'])) {\n $assistantReply = $finalResponse['choices'][0]['message']['content'];\n $history[] = ['role' => 'user', 'content' => $message];\n $history[] = ['role' => 'assistant', 'content' => $assistantReply];\n $this->saveHistory($history);\n return $assistantReply;\n }\n\n return \"I'm having trouble processing your request. Could you try rephrasing it?\";\n }\n\n /**\n * Clear the conversation history for this user.\n */\n public function clearHistory(): void\n {\n Cache::forget(self::HISTORY_CACHE_PREFIX . $this->user->id);\n Cache::forget(self::STATE_CACHE_PREFIX . $this->user->id);\n }\n\n /**\n * Get the conversation history from cache.\n */\n private function getHistory(): array\n {\n return Cache::get(self::HISTORY_CACHE_PREFIX . $this->user->id, []);\n }\n\n /**\n * Save the conversation history to cache (keeps last N messages).\n */\n private function saveHistory(array $history): void\n {\n // Trim to max messages to avoid unbounded growth\n if (count($history) > self::MAX_HISTORY_MESSAGES) {\n $history = array_slice($history, -self::MAX_HISTORY_MESSAGES);\n }\n\n // Cache for 24 hours (conversation expires after a day of inactivity)\n Cache::put(self::HISTORY_CACHE_PREFIX . $this->user->id, $history, now()->addDay());\n }\n\n /**\n * Get the full conversation state from cache (includes tool calls and results).\n */\n private function getFullState(): array\n {\n return Cache::get(self::STATE_CACHE_PREFIX . $this->user->id, []);\n }\n\n /**\n * Save the full messages array (minus system prompt) to cache.\n * This preserves tool calls and results across API calls.\n */\n private function saveFullState(array $messages): void\n {\n // Strip the system prompt (index 0) and keep the conversation messages\n $state = array_values(array_filter($messages, fn($m) => ($m['role'] ?? '') !== 'system'));\n\n // Trim to avoid unbounded growth (keep last 40 messages for tool call context)\n if (count($state) > 40) {\n $state = array_slice($state, -40);\n }\n\n Cache::put(self::STATE_CACHE_PREFIX . $this->user->id, $state, now()->addDay());\n }\n\n /**\n * Build messages from the full conversation state with a fresh system prompt.\n * This gives the AI full context including tool calls and results from previous API calls.\n */\n private function buildMessagesFromFullState(string $userMessage, array $fullState, array $imageFiles = []): array\n {\n $systemPrompt = $this->getSystemPrompt();\n\n $messages = [\n ['role' => 'system', 'content' => $systemPrompt],\n ];\n\n // Include the full conversation state (includes tool calls and results)\n foreach ($fullState as $msg) {\n $messages[] = $msg;\n }\n\n // Build the new user message content\n if (!empty($imageFiles)) {\n $content = [];\n if (!empty(trim($userMessage))) {\n $content[] = ['type' => 'text', 'text' => $userMessage];\n } else {\n $content[] = ['type' => 'text', 'text' => 'Please analyze this image and extract any financial transactions you can see.'];\n }\n foreach ($imageFiles as $image) {\n $content[] = [\n 'type' => 'image_url',\n 'image_url' => [\n 'url' => $image['base64'],\n 'detail' => 'low',\n ],\n ];\n }\n $messages[] = ['role' => 'user', 'content' => $content];\n } else {\n $messages[] = ['role' => 'user', 'content' => $userMessage];\n }\n\n return $messages;\n }\n\n /**\n * Build the messages array including conversation history and optional images.\n * This is the fallback when no full state is available.\n */\n private function buildMessagesWithHistory(string $userMessage, array $history, array $imageFiles = []): array\n {\n $systemPrompt = $this->getSystemPrompt();\n\n $messages = [\n ['role' => 'system', 'content' => $systemPrompt],\n ];\n\n // Include recent conversation history for context\n foreach ($history as $msg) {\n $messages[] = $msg;\n }\n\n // Build the new user message content\n if (!empty($imageFiles)) {\n // Vision format: content is an array of text + image parts\n $content = [];\n if (!empty(trim($userMessage))) {\n $content[] = ['type' => 'text', 'text' => $userMessage];\n } else {\n $content[] = ['type' => 'text', 'text' => 'Please analyze this image and extract any financial transactions you can see.'];\n }\n foreach ($imageFiles as $image) {\n $content[] = [\n 'type' => 'image_url',\n 'image_url' => [\n 'url' => $image['base64'],\n 'detail' => 'low',\n ],\n ];\n }\n $messages[] = ['role' => 'user', 'content' => $content];\n } else {\n // Plain text message\n $messages[] = ['role' => 'user', 'content' => $userMessage];\n }\n\n return $messages;\n }\n\n /**\n * Filter uploaded files to only include images, converting them to base64.\n */\n private function filterImageFiles(array $files): array\n {\n $images = [];\n foreach ($files as $file) {\n $mime = $file['mime'] ?? '';\n if (in_array($mime, ['image/png', 'image/jpeg', 'image/jpg', 'image/gif', 'image/webp'])) {\n $path = $file['path'] ?? null;\n if ($path && file_exists($path)) {\n $base64 = 'data:' . $mime . ';base64,' . base64_encode(file_get_contents($path));\n $images[] = [\n 'name' => $file['name'],\n 'base64' => $base64,\n ];\n }\n }\n }\n return $images;\n }\n\n /**\n * Get the system prompt with context about the user and available tools.\n */\n private function getSystemPrompt(): string\n {\n $currency = $this->user->currency_symbol ?? '$';\n $today = Carbon::now()->format('Y-m-d');\n $currentMonth = Carbon::now()->format('F Y');\n $memoryContext = AiMemory::getContextForUser($this->user->id);\n $memorySection = $memoryContext ? \"\\n**User context (learned from previous conversations):**\\n<!-- BEGIN_USER_MEMORY -->\\n{$memoryContext}\\n<!-- END_USER_MEMORY -->\\n\" : '';\n $categoriesList = $this->buildCategoriesList();\n\n return <<<PROMPT\nYou are BudgetBee AI, a friendly and helpful financial assistant integrated into the BudgetBee personal finance app.\nYour role is to help the user understand their finances by answering questions about their accounts, expenses, income, budgets, and transactions.\n\nCurrent date: {$today}\nCurrent month: {$currentMonth}\nUser's currency: {$currency}\n{$memorySection}\n**User's available categories — ONLY use these exact IDs and names. Do NOT invent categories:**\n<!-- BEGIN_USER_CATEGORIES -->\n{$categoriesList}\n<!-- END_USER_CATEGORIES -->\n\nYou have access to tools that let you query the user's real financial data in the database.\nAlways use these tools to provide accurate, data-driven answers. Do NOT make up numbers — only report what the tools return.\nIf a query returns no data, tell the user honestly.\nBe concise but friendly. Format currency amounts with the user's currency symbol.\n\n**CRITICAL — Conversation continuity:**\n- If there are previous messages in the conversation (user or assistant messages before the current one), you are in an ONGOING conversation. Do NOT greet or introduce yourself again. Continue the conversation naturally.\n- Only greet the user if the conversation history is completely empty (first message ever).\n\n**CRITICAL — Date handling:**\n- When the user says \"this month\", ALWAYS pass `from_date` as the first day of the current month.\n- When the user says \"this year\", ALWAYS pass `from_date` as `{$today}`-01-01 (first day of current year).\n- When the user says \"last month\", ALWAYS calculate and pass the correct first day of last month.\n- User can also specify explicit dates (e.g., \"from January 1st to January 31st\") — parse these and pass them as `from_date` and `to_date`.\n- When no time period is mentioned, the tools default to all records.\n\n**When querying by category:** match the user's words to the closest category from the list above, then use the exact category name in tool calls.\n\n**When creating records:** always pass the `category_id` (the number in parentheses) from the list above.\n\n**IMPORTANT: Always format your responses using Markdown.** Use the following formatting rules:\n- Use **bold** for key numbers, totals, and important figures.\n- Use `code` formatting for dates and technical values.\n- Use bullet lists (starting with -) for listing items like accounts, categories, or transactions.\n- Use numbered lists for step-by-step guidance.\n- Use ### headings for section titles when presenting structured data.\n- Use tables (| column | column |) for comparing data across categories or time periods.\n- Use > blockquotes for important notes or warnings.\n- Keep it clean and readable — don't overuse formatting.\n\nExample of a good response:\n```\nHere's your spending breakdown for this month:\n\n| Category | Amount |\n|----------|--------|\n| Supermarkets | **\\$342.50** |\n| Transport | **\\$128.00** |\n| Entertainment | **\\$85.30** |\n| **Total** | **\\$555.80** |\n\n> Your biggest expense category this month is *Supermarkets* at 62% of total spending.\n```\n\n**DATA EXTRACTION — When the user sends an image or file with transactions:**\n\n1. Call `get_accounts`. Do NOT call `get_categories` (already listed above).\n2. Extract EVERY transaction: negative = expense, positive = income.\n3. Match each to a `category_id` from the list above.\n4. Present a compact table: Date | Description | Amount | Type | Category\n5. Ask: \"¿En qué cuenta los creo? Responde con el nombre de la cuenta y **sí** para confirmar.\"\n6. NEVER call `create_records_batch` without account + confirmation.\n7. When confirmed, use: date, name, amount (positive), type, category_id, account_name.\n\n**Be concise. One table, one question. No repetition.**\n\n**MEMORY: You have persistent memory per user. Use the `save_memory` tool to remember important facts:**\n- If the user consistently speaks a language (Spanish, English, etc.), save it as `language`.\n- If the user mentions their preferred account, save it as `preferred_account`.\n- If you notice patterns (e.g., \"I always shop at Mercadona on Fridays\"), save them.\n- Save any fact that would help you provide better, more personalized responses in future conversations.\n- Use the memory context provided above — it contains everything learned from previous conversations.\n- **Call `save_memory` automatically** when you learn something new — no need to ask permission.\nPROMPT;\n }\n\n /**\n * Get the tool definitions for the AI provider (MCP-style).\n */\n private function getTools(): array\n {\n return [\n [\n 'type' => 'function',\n 'function' => [\n 'name' => 'get_balance',\n 'description' => 'Get the user\\'s current total balance across all accounts.',\n 'parameters' => [\n 'type' => 'object',\n 'properties' => (object) [],\n ],\n ],\n ],\n [\n 'type' => 'function',\n 'function' => [\n 'name' => 'get_accounts',\n 'description' => 'Get all accounts with their balances, types, and currencies.',\n 'parameters' => [\n 'type' => 'object',\n 'properties' => (object) [],\n ],\n ],\n ],\n [\n 'type' => 'function',\n 'function' => [\n 'name' => 'get_expenses_by_category',\n 'description' => 'Get total expenses grouped by category for a given date range. Use this when the user asks how much they spent on something (e.g., \"how much did I spend on groceries this month?\").',\n 'parameters' => [\n 'type' => 'object',\n 'properties' => [\n 'from_date' => [\n 'type' => 'string',\n 'description' => 'Start date in Y-m-d format. Defaults to January 1st of current year. ALWAYS set this explicitly when the user mentions a time period (e.g., \"this month\" → first day of month, \"this year\" → first day of year, \"last month\" → first day of last month).',\n ],\n 'to_date' => [\n 'type' => 'string',\n 'description' => 'End date in Y-m-d format. Defaults to today.',\n ],\n 'category_name' => [\n 'type' => 'string',\n 'description' => 'Optional: filter by category name. Use the EXACT category name from the list in the system prompt.',\n ],\n ],\n ],\n ],\n ],\n [\n 'type' => 'function',\n 'function' => [\n 'name' => 'get_income_by_category',\n 'description' => 'Get total income grouped by category for a given date range.',\n 'parameters' => [\n 'type' => 'object',\n 'properties' => [\n 'from_date' => [\n 'type' => 'string',\n 'description' => 'Start date in Y-m-d format. Defaults to January 1st of current year. ALWAYS set this explicitly when the user mentions a time period.',\n ],\n 'to_date' => [\n 'type' => 'string',\n 'description' => 'End date in Y-m-d format. Defaults to today.',\n ],\n ],\n ],\n ],\n ],\n [\n 'type' => 'function',\n 'function' => [\n 'name' => 'get_recent_records',\n 'description' => 'Get the most recent transactions. Use this when the user asks about recent activity or their latest transactions.',\n 'parameters' => [\n 'type' => 'object',\n 'properties' => [\n 'limit' => [\n 'type' => 'integer',\n 'description' => 'Number of records to return. Defaults to 10.',\n ],\n 'category_name' => [\n 'type' => 'string',\n 'description' => 'Optional: filter by category name.',\n ],\n ],\n ],\n ],\n ],\n [\n 'type' => 'function',\n 'function' => [\n 'name' => 'get_budgets',\n 'description' => 'Get the user\\'s budgets with their current spent amounts and remaining balances.',\n 'parameters' => [\n 'type' => 'object',\n 'properties' => (object) [],\n ],\n ],\n ],\n [\n 'type' => 'function',\n 'function' => [\n 'name' => 'search_records',\n 'description' => 'Search for transactions by name or description keyword.',\n 'parameters' => [\n 'type' => 'object',\n 'properties' => [\n 'keyword' => [\n 'type' => 'string',\n 'description' => 'Search keyword to look for in transaction names.',\n ],\n 'limit' => [\n 'type' => 'integer',\n 'description' => 'Maximum number of results. Defaults to 20.',\n ],\n ],\n 'required' => ['keyword'],\n ],\n ],\n ],\n [\n 'type' => 'function',\n 'function' => [\n 'name' => 'get_categories',\n 'description' => 'Get all available expense and income categories that the user has.',\n 'parameters' => [\n 'type' => 'object',\n 'properties' => (object) [],\n ],\n ],\n ],\n [\n 'type' => 'function',\n 'function' => [\n 'name' => 'create_records_batch',\n 'description' => 'Create multiple financial records at once. ONLY call this after the user has explicitly confirmed they want the records created. Always present the extracted data first and ask for confirmation. The user must say \"yes\", \"create them\", \"confirm\", or similar before you call this tool.',\n 'parameters' => [\n 'type' => 'object',\n 'properties' => [\n 'records' => [\n 'type' => 'array',\n 'description' => 'Array of records to create.',\n 'items' => [\n 'type' => 'object',\n 'properties' => [\n 'date' => [\n 'type' => 'string',\n 'description' => 'Date in Y-m-d format.',\n ],\n 'name' => [\n 'type' => 'string',\n 'description' => 'Transaction name/description.',\n ],\n 'amount' => [\n 'type' => 'number',\n 'description' => 'ALWAYS a positive number (e.g., 45.50). The system handles the sign internally.',\n ],\n 'type' => [\n 'type' => 'string',\n 'enum' => ['income', 'expense'],\n 'description' => 'Whether this is income (money received, salary, deposit) or expense (payment, purchase, withdrawal). Required for every record.',\n ],\n 'category_name' => [\n 'type' => 'string',\n 'description' => 'DEPRECATED: use category_id instead. Category name from the list.',\n ],\n 'category_id' => [\n 'type' => 'integer',\n 'description' => 'The exact category ID from the list above. Always use this — pick the ID that best matches the transaction description.',\n ],\n 'account_name' => [\n 'type' => 'string',\n 'description' => 'Account name. MUST match one of the user\\'s real accounts from get_accounts. Call get_accounts first BEFORE presenting data to the user.',\n ],\n ],\n 'required' => ['date', 'name', 'amount', 'type', 'category_id', 'account_name'],\n ],\n ],\n ],\n 'required' => ['records'],\n ],\n ],\n ],\n [\n 'type' => 'function',\n 'function' => [\n 'name' => 'save_memory',\n 'description' => 'Save a fact about the user to persistent memory. Use this to remember user preferences, language, habits, or any information that helps personalize future interactions.',\n 'parameters' => [\n 'type' => 'object',\n 'properties' => [\n 'key' => [\n 'type' => 'string',\n 'description' => 'Short key describing what you are remembering (e.g., \"language\", \"preferred_account\", \"shopping_habit\").',\n ],\n 'value' => [\n 'type' => 'string',\n 'description' => 'The fact or preference to remember (e.g., \"Spanish\", \"Checking Account\", \"Shops at Mercadona on Fridays\").',\n ],\n ],\n 'required' => ['key', 'value'],\n ],\n ],\n ],\n ];\n }\n\n /**\n * Call the AI provider's API.\n */\n private function callProvider(array $messages, bool $includeTools = true): ?array\n {\n $apiUrl = match ($this->provider) {\n 'openai' => 'https://api.openai.com/v1/chat/completions',\n 'deepseek' => 'https://api.deepseek.com/v1/chat/completions',\n default => null,\n };\n\n if (!$apiUrl) {\n return null;\n }\n\n $payload = [\n 'model' => $this->getModelName(),\n 'messages' => $messages,\n 'temperature' => 0.3,\n 'max_tokens' => 4000,\n ];\n\n if ($includeTools) {\n $payload['tools'] = $this->getTools();\n $payload['tool_choice'] = 'auto';\n }\n\n try {\n $httpResponse = Http::withHeaders([\n 'Authorization' => \"Bearer {$this->apiKey}\",\n 'Content-Type' => 'application/json',\n ])\n ->timeout(60)\n ->post($apiUrl, $payload);\n\n if ($httpResponse->successful()) {\n return $httpResponse->json();\n }\n\n Log::error('AI Provider error', [\n 'provider' => $this->provider,\n 'status' => $httpResponse->status(),\n 'body' => $httpResponse->body(),\n ]);\n\n return null;\n } catch (\\Exception $e) {\n Log::error('AI Provider exception', [\n 'provider' => $this->provider,\n 'error' => $e->getMessage(),\n ]);\n\n return null;\n }\n }\n\n /**\n * Get the appropriate model name for each provider.\n */\n private function getModelName(): string\n {\n return match ($this->provider) {\n 'openai' => 'gpt-4o-mini',\n 'deepseek' => 'deepseek-chat',\n default => 'gpt-4o-mini',\n };\n }\n\n /**\n * Execute a tool call and return the result.\n */\n private function executeTool(string $name, array $arguments): array\n {\n return match ($name) {\n 'get_balance' => $this->toolGetBalance(),\n 'get_accounts' => $this->toolGetAccounts(),\n 'get_expenses_by_category' => $this->toolGetExpensesByCategory($arguments),\n 'get_income_by_category' => $this->toolGetIncomeByCategory($arguments),\n 'get_recent_records' => $this->toolGetRecentRecords($arguments),\n 'get_budgets' => $this->toolGetBudgets(),\n 'search_records' => $this->toolSearchRecords($arguments),\n 'get_categories' => $this->toolGetCategories(),\n 'create_records_batch' => $this->toolCreateRecordsBatch($arguments),\n 'save_memory' => $this->toolSaveMemory($arguments),\n default => ['error' => \"Unknown tool: {$name}\"],\n };\n }\n\n // ─── Tool Implementations ───────────────────────────────────────\n\n private function toolGetBalance(): array\n {\n $balance = Account::where('user_id', $this->user->id)\n ->get()\n ->sum('balance_base_currency');\n\n $accounts = Account::where('user_id', $this->user->id)\n ->get()\n ->map(fn($a) => [\n 'name' => $a->name,\n 'balance' => round($a->balance, 2),\n 'currency' => $a->currency_symbol,\n ]);\n\n return [\n 'total_balance' => round($balance, 2),\n 'currency' => $this->user->currency_symbol,\n 'accounts' => $accounts->toArray(),\n ];\n }\n\n private function toolGetAccounts(): array\n {\n $accounts = Account::where('user_id', $this->user->id)\n ->get()\n ->map(fn($a) => [\n 'name' => $a->name,\n 'type' => $a->account_type_name,\n 'balance' => round($a->balance, 2),\n 'currency' => $a->currency_symbol,\n 'initial_balance' => round($a->initial_balance, 2),\n ]);\n\n return [\n 'accounts' => $accounts->toArray(),\n 'currency' => $this->user->currency_symbol,\n ];\n }\n\n private function toolGetExpensesByCategory(array $args): array\n {\n $fromDate = $args['from_date'] ?? Carbon::now()->startOfYear()->format('Y-m-d');\n $toDate = $args['to_date'] ?? Carbon::now()->format('Y-m-d');\n $categoryFilter = $args['category_name'] ?? null;\n\n $excludeParentIds = [1, 10]; // Exclude transfers and income\n\n $query = Record::where('user_id', $this->user->id)\n ->whereIn('type', ['expense'])\n ->where('date', '>=', $fromDate)\n ->where('date', '<=', $toDate)\n ->whereHas('category.parent', function ($q) use ($excludeParentIds) {\n $q->whereNotIn('id', $excludeParentIds);\n })\n ->with('category.parent');\n\n // Filter by category name if provided (AI uses exact names from prompt)\n if ($categoryFilter) {\n $query->where(function ($q) use ($categoryFilter) {\n $q->whereHas('category', function ($sq) use ($categoryFilter) {\n $sq->where('name', 'like', \"%{$categoryFilter}%\");\n })->orWhereHas('category.parent', function ($sq) use ($categoryFilter) {\n $sq->where('name', 'like', \"%{$categoryFilter}%\");\n });\n });\n }\n\n $records = $query->get();\n\n $grouped = [];\n foreach ($records as $record) {\n $parentName = $record->parent_category_name ?? 'Other';\n $catName = $record->category_name ?? 'Unknown';\n\n $grouped[$parentName]['total'] = ($grouped[$parentName]['total'] ?? 0) + round(abs($record->amount_base_currency), 2);\n $grouped[$parentName]['categories'][$catName] = ($grouped[$parentName]['categories'][$catName] ?? 0) + round(abs($record->amount_base_currency), 2);\n }\n\n $totalExpenses = array_sum(array_column($grouped, 'total'));\n\n return [\n 'from_date' => $fromDate,\n 'to_date' => $toDate,\n 'total_expenses' => round($totalExpenses, 2),\n 'currency' => $this->user->currency_symbol,\n 'by_parent_category' => $grouped,\n ];\n }\n\n private function toolGetIncomeByCategory(array $args): array\n {\n $fromDate = $args['from_date'] ?? Carbon::now()->startOfYear()->format('Y-m-d');\n $toDate = $args['to_date'] ?? Carbon::now()->format('Y-m-d');\n\n $incomeCategoryIds = Category::where('parent_category_id', 10)->pluck('id');\n\n $records = Record::where('user_id', $this->user->id)\n ->whereIn('category_id', $incomeCategoryIds)\n ->where('date', '>=', $fromDate)\n ->where('date', '<=', $toDate)\n ->with('category')\n ->get();\n\n $grouped = [];\n foreach ($records as $record) {\n $catName = $record->category_name ?? 'Unknown';\n $grouped[$catName] = ($grouped[$catName] ?? 0) + round(abs($record->amount_base_currency), 2);\n }\n\n $totalIncome = array_sum($grouped);\n\n return [\n 'from_date' => $fromDate,\n 'to_date' => $toDate,\n 'total_income' => round($totalIncome, 2),\n 'currency' => $this->user->currency_symbol,\n 'by_category' => $grouped,\n ];\n }\n\n private function toolGetRecentRecords(array $args): array\n {\n $limit = min($args['limit'] ?? 10, 50);\n $categoryFilter = $args['category_name'] ?? null;\n\n $query = Record::where('user_id', $this->user->id)\n ->with('category.parent')\n ->orderBy('date', 'desc')\n ->orderBy('id', 'desc')\n ->limit($limit);\n\n if ($categoryFilter) {\n $query->whereHas('category', function ($q) use ($categoryFilter) {\n $q->where('name', 'like', \"%{$categoryFilter}%\");\n });\n }\n\n $records = $query->get()->map(fn($r) => [\n 'date' => $r->date,\n 'name' => $r->name,\n 'amount' => round($r->amount, 2),\n 'type' => $r->type,\n 'category' => $r->category_name ?? 'Unknown',\n 'parent_category' => $r->parent_category_name ?? 'Unknown',\n 'account' => $r->account_name ?? 'Unknown',\n 'currency' => $r->currency_symbol,\n ]);\n\n return [\n 'records' => $records->toArray(),\n 'count' => $records->count(),\n ];\n }\n\n private function toolGetBudgets(): array\n {\n $budgets = Budget::where('user_id', $this->user->id)\n ->with('category.parent')\n ->get()\n ->map(fn($b) => [\n 'category' => $b->category_name,\n 'parent_category' => $b->parent_category_name,\n 'budgeted' => round($b->amount, 2),\n 'spent' => round($b->spent, 2),\n 'remaining' => round($b->amount - $b->spent, 2),\n 'percent_used' => round($b->spent_percent, 1),\n ]);\n\n return [\n 'budgets' => $budgets->toArray(),\n 'currency' => $this->user->currency_symbol,\n ];\n }\n\n private function toolSearchRecords(array $args): array\n {\n $keyword = $args['keyword'] ?? '';\n $limit = min($args['limit'] ?? 20, 50);\n\n $records = Record::where('user_id', $this->user->id)\n ->where('name', 'like', \"%{$keyword}%\")\n ->with('category.parent')\n ->orderBy('date', 'desc')\n ->limit($limit)\n ->get()\n ->map(fn($r) => [\n 'date' => $r->date,\n 'name' => $r->name,\n 'amount' => round($r->amount, 2),\n 'type' => $r->type,\n 'category' => $r->category_name ?? 'Unknown',\n 'parent_category' => $r->parent_category_name ?? 'Unknown',\n 'currency' => $r->currency_symbol,\n ]);\n\n return [\n 'keyword' => $keyword,\n 'records' => $records->toArray(),\n 'count' => $records->count(),\n ];\n }\n\n private function toolGetCategories(): array\n {\n $categories = Category::where(function ($q) {\n $q->where('user_id', $this->user->id)\n ->orWhereNull('user_id');\n })\n ->with('parent')\n ->get()\n ->groupBy('parent_category_id')\n ->map(function ($cats, $parentId) {\n $parentName = $cats->first()->parent_name ?? 'Other';\n return [\n 'parent_category' => $parentName,\n 'categories' => $cats->map(fn($c) => $c->name)->toArray(),\n ];\n });\n\n return [\n 'category_groups' => $categories->values()->toArray(),\n ];\n }\n\n /**\n * Create multiple records in batch. Used after user confirms extracted transactions.\n */\n private function toolCreateRecordsBatch(array $args): array\n {\n $records = $args['records'] ?? [];\n if (empty($records)) {\n return ['error' => 'No records provided.', 'created' => 0];\n }\n\n // Get all user accounts for matching\n $userAccounts = Account::where('user_id', $this->user->id)->get();\n $defaultAccount = $userAccounts->first();\n\n // Pre-load all categories for fuzzy matching\n $allCategories = Category::where(function ($q) {\n $q->where('user_id', $this->user->id)\n ->orWhereNull('user_id');\n })->get();\n\n $created = 0;\n $skipped = 0;\n $details = [];\n\n foreach ($records as $record) {\n $recordName = $record['name'] ?? 'Unknown';\n $recordAmount = abs(floatval($record['amount'] ?? 0));\n $recordDate = $record['date'] ?? Carbon::now()->format('Y-m-d');\n $recordType = $record['type'] ?? 'expense';\n $recordAccountName = $record['account_name'] ?? null;\n\n if ($recordAmount <= 0) {\n $skipped++;\n $details[] = ['name' => $recordName, 'status' => 'skipped', 'reason' => 'Zero or negative amount'];\n continue;\n }\n\n // Match account\n $accountId = null;\n $matchedAccountName = 'Unknown';\n if ($recordAccountName) {\n $matchedAccount = $userAccounts->first(fn($a) =>\n mb_strtolower($a->name) === mb_strtolower($recordAccountName) ||\n str_contains(mb_strtolower($a->name), mb_strtolower($recordAccountName)) ||\n str_contains(mb_strtolower($recordAccountName), mb_strtolower($a->name))\n );\n if ($matchedAccount) {\n $accountId = $matchedAccount->id;\n $matchedAccountName = $matchedAccount->name;\n }\n }\n if (!$accountId && $defaultAccount) {\n $accountId = $defaultAccount->id;\n $matchedAccountName = $defaultAccount->name;\n }\n\n if (!$accountId) {\n $skipped++;\n $details[] = ['name' => $recordName, 'status' => 'skipped', 'reason' => 'No account found'];\n continue;\n }\n\n // Category: use ID directly if provided, otherwise fall back to name matching\n $categoryId = $record['category_id'] ?? null;\n $categoryName = $record['category_name'] ?? null;\n $matchedCategoryName = 'Unknown';\n\n if ($categoryId) {\n $cat = $allCategories->firstWhere('id', $categoryId);\n if ($cat) {\n $matchedCategoryName = $cat->name;\n } else {\n $categoryId = 44; // Invalid ID → fallback\n $matchedCategoryName = 'Other';\n }\n } else {\n // Fallback: match by name\n $categoryId = 44;\n $matchedCategoryName = 'Other';\n if ($categoryName) {\n $matched = $this->fuzzyMatchCategory($categoryName, $allCategories);\n if ($matched) {\n $categoryId = $matched->id;\n $matchedCategoryName = $matched->name;\n }\n }\n }\n\n // For income, try to find an income category if not matched\n if ($recordType === 'income' && (!$categoryName || $categoryId === 44)) {\n $incomeCategory = $allCategories->first(fn($c) => $c->parent_category_id == 10);\n if ($incomeCategory) {\n $categoryId = $incomeCategory->id;\n $matchedCategoryName = $incomeCategory->name;\n }\n }\n\n // Determine amount sign: expenses are negative, income is positive\n $finalAmount = $recordType === 'income' ? $recordAmount : -$recordAmount;\n\n try {\n $newRecord = Record::create([\n 'user_id' => $this->user->id,\n 'date' => $recordDate,\n 'from_account_id' => $accountId,\n 'type' => $recordType === 'income' ? 'income' : 'expense',\n 'category_id' => $categoryId,\n 'name' => $recordName,\n 'amount' => $finalAmount,\n 'rate' => 1,\n ]);\n\n $created++;\n $details[] = [\n 'name' => $recordName,\n 'status' => 'created',\n 'date' => $recordDate,\n 'amount' => $recordAmount,\n 'type' => $recordType,\n 'category' => $matchedCategoryName,\n 'account' => $matchedAccountName,\n 'id' => $newRecord->id,\n ];\n } catch (\\Exception $e) {\n $skipped++;\n Log::error('Failed to create record from AI batch', [\n 'user_id' => $this->user->id,\n 'record' => $recordName,\n 'error' => $e->getMessage(),\n ]);\n $details[] = ['name' => $recordName, 'status' => 'skipped', 'reason' => 'Database error'];\n }\n }\n\n return [\n 'created' => $created,\n 'skipped' => $skipped,\n 'total' => count($records),\n 'details' => $details,\n 'currency' => $this->user->currency_symbol,\n ];\n }\n\n /**\n * Fuzzy match a category name to the closest existing category.\n */\n private function fuzzyMatchCategory(string $name, $allCategories): ?Category\n {\n $name = mb_strtolower(trim($name));\n if (empty($name)) return null;\n\n // 1. Exact match (case insensitive)\n $direct = $allCategories->first(fn($c) => mb_strtolower($c->name) === $name);\n if ($direct) return $direct;\n\n // 2. Category name contains query (e.g., \"Gasolina\" contains \"gas\")\n $contains = $allCategories->first(fn($c) => str_contains(mb_strtolower($c->name), $name));\n if ($contains) return $contains;\n\n // 3. Query contains category name (e.g., \"Compras online\" contains \"Compras\")\n // Only if query >= 4 chars to avoid \"es\" matching everything\n if (mb_strlen($name) >= 4) {\n $reverse = $allCategories->first(fn($c) =>\n str_contains($name, mb_strtolower($c->name))\n );\n if ($reverse) return $reverse;\n }\n\n return null;\n }\n\n /**\n * Sanitize a value before embedding it in the system prompt.\n * Strips backticks, angle brackets, and markdown control chars\n * that could be used for prompt injection.\n */\n private function sanitizePromptValue(string $value): string\n {\n // Strip backticks (code blocks), angle brackets (HTML/XML), and markdown link syntax\n $value = str_replace(['`', '<', '>', '[', ']', '(', ')'], '', $value);\n // Collapse whitespace\n $value = preg_replace('/\\s+/', ' ', $value);\n // Limit length\n return mb_substr(trim($value), 0, 60);\n }\n\n /**\n * Build a formatted list of the user's categories for the system prompt.\n */\n private function buildCategoriesList(): string\n {\n $categories = Category::where(function ($q) {\n $q->where('user_id', $this->user->id)\n ->orWhereNull('user_id');\n })\n ->with('parent')\n ->get()\n ->groupBy('parent_category_id');\n\n if ($categories->isEmpty()) {\n return '(No categories configured yet)';\n }\n\n $lines = [];\n foreach ($categories as $parentId => $cats) {\n $parentName = $this->sanitizePromptValue($cats->first()->parent_name ?? 'Other');\n $catList = $cats->map(fn($c) => '`' . $this->sanitizePromptValue($c->name) . '` (ID:' . (int)$c->id . ')')->implode(', ');\n $lines[] = '- **' . $parentName . '** (parent ID:' . (int)$parentId . '): ' . $catList;\n }\n\n return implode(\"\\n\", $lines);\n }\n\n /**\n * Save a fact to the user's persistent AI memory.\n */\n private function toolSaveMemory(array $args): array\n {\n $key = trim($args['key'] ?? '');\n $value = trim($args['value'] ?? '');\n\n if (empty($key) || empty($value)) {\n return ['error' => 'Both key and value are required.'];\n }\n\n // Limit key length and value length\n // Sanitize to prevent storing prompt injection payloads\n $key = substr($key, 0, 100);\n $value = substr($value, 0, 500);\n $key = preg_replace('/[<>\\[\\]\\(\\)\\|\\*_`]/', '', $key);\n $value = preg_replace('/[<>\\[\\]\\(\\)\\|\\*_`]/', '', $value);\n\n AiMemory::upsertForUser($this->user->id, $key, $value);\n\n Log::info('AI memory saved', [\n 'user_id' => $this->user->id,\n 'key' => $key,\n ]);\n\n return [\n 'saved' => true,\n 'key' => $key,\n 'value' => $value,\n ];\n }\n}\n"}}}}