false]; try { // Load API Key $CLAUDE_API_KEY = ''; $configPath = __DIR__ . '/../../config/claude.json'; if (file_exists($configPath)) { $configData = json_decode(file_get_contents($configPath), true); $CLAUDE_API_KEY = $configData['apiKey'] ?? ''; } if (empty($CLAUDE_API_KEY)) throw new Exception('Claude API key not configured'); // Find uploaded files $uploadedFiles = []; foreach ($_FILES as $fieldName => $fileInfo) { if (preg_match('/^file[0-9]+$/', $fieldName) && !empty($fileInfo['tmp_name'])) { $uploadedFiles[] = $fieldName; } } if (empty($uploadedFiles)) throw new Exception('No files uploaded'); // Process all images - read and encode $imageDataArray = []; foreach ($uploadedFiles as $fieldName) { $file = $_FILES[$fieldName]; $filePath = $file['tmp_name']; $fileExt = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION)); // Resize large images if (in_array($fileExt, ['jpg', 'jpeg', 'png', 'gif', 'webp'])) { $imageInfo = getimagesize($filePath); if ($imageInfo && ($imageInfo[0] > 1600 || $imageInfo[1] > 1600)) { $src = null; switch ($imageInfo[2]) { case IMAGETYPE_JPEG: $src = imagecreatefromjpeg($filePath); break; case IMAGETYPE_PNG: $src = imagecreatefrompng($filePath); break; case IMAGETYPE_GIF: $src = imagecreatefromgif($filePath); break; case IMAGETYPE_WEBP: $src = imagecreatefromwebp($filePath); break; } if ($src) { $w = imagesx($src); $h = imagesy($src); if ($w > $h) { $newW = 1600; $newH = (int)($h * (1600 / $w)); } else { $newH = 1600; $newW = (int)($w * (1600 / $h)); } $dst = imagecreatetruecolor($newW, $newH); imagecopyresampled($dst, $src, 0, 0, 0, 0, $newW, $newH, $w, $h); imagejpeg($dst, $filePath, 80); imagedestroy($src); imagedestroy($dst); $fileExt = 'jpg'; // resized to JPEG } } } $base64Content = base64_encode(file_get_contents($filePath)); $mediaTypes = [ 'jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'png' => 'image/png', 'gif' => 'image/gif', 'webp' => 'image/webp', 'pdf' => 'application/pdf', ]; $mediaType = $mediaTypes[$fileExt] ?? 'image/jpeg'; $imgStruct = [ 'type' => ($fileExt === 'pdf') ? 'document' : 'image', 'source' => [ 'type' => 'base64', 'media_type' => $mediaType, 'data' => $base64Content, ], ]; $imageDataArray[] = $imgStruct; } if (empty($imageDataArray)) throw new Exception('No valid images could be processed'); $systemPrompt = 'You are an expert at extracting structured menu data from restaurant menu images. Extract ALL data visible on THIS image only. Return valid JSON with these keys: business (object with name, address, phone, hours, brandColor - only if visible), categories (array of category names visible), modifiers (array of modifier templates with name, required boolean, appliesTo (string: \'category\', \'item\', or \'uncertain\'), categoryName (if appliesTo=\'category\'), and options array where each option is an object with \'name\' and \'price\' keys), items (array with name, description, price, category, and modifiers array), isHeaderCandidate (boolean - true if this image shows a restaurant interior, exterior, food photography, logo/branding, or banner that would work well as a menu header image). For brandColor: Extract the dominant accent/brand color from the menu design (logo, headers, accent elements). Return as a 6-digit hex code WITHOUT the # symbol (e.g. "E74C3C" for red). Choose a vibrant, appealing color that represents the restaurant\'s brand. CRITICAL for hours: Extract ALL days\' hours including Saturday and Sunday. Format as a single string with ALL days, e.g. "Mon-Fri 10:30am-10pm, Sat 11am-10pm, Sun 11am-9pm". For modifier options, ALWAYS use format: {"name": "option name", "price": 0}. IMPORTANT: Look for modifiers in these locations: (1) text under category headers, (2) item descriptions, (3) asterisk notes, (4) header/footer text. For modifiers found under category headers, set appliesTo=\'category\' and categoryName to the category. For modifiers clearly in item descriptions, assign them to that item\'s modifiers array. For modifiers where you\'re uncertain how they apply, set appliesTo=\'uncertain\' and do NOT assign to items. Return ONLY valid JSON, no markdown, no explanation.'; // Process each image individually $allResults = []; foreach ($imageDataArray as $imgIndex => $imgData) { $requestBody = json_encode([ 'model' => 'claude-sonnet-4-20250514', 'max_tokens' => 16384, 'temperature' => 0, 'system' => $systemPrompt, 'messages' => [[ 'role' => 'user', 'content' => [ $imgData, [ 'type' => 'text', 'text' => 'Extract all menu data from this image. Return JSON with: business (if visible), categories, modifiers (with appliesTo, categoryName if applicable, and options as objects with name and price keys), items (with modifiers array only for item-specific modifiers).', ], ], ]], ]); $ch = curl_init('https://api.anthropic.com/v1/messages'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 300, CURLOPT_POST => true, CURLOPT_POSTFIELDS => $requestBody, CURLOPT_HTTPHEADER => [ 'Content-Type: application/json', "x-api-key: $CLAUDE_API_KEY", 'anthropic-version: 2023-06-01', ], ]); $result = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($httpCode !== 200) { $errorDetail = ''; $errorResponse = json_decode($result, true); if (isset($errorResponse['error']['message'])) { $errorDetail = $errorResponse['error']['message']; } else { $errorDetail = $result; } throw new Exception("Claude API error on image " . ($imgIndex + 1) . ": $httpCode - $errorDetail"); } $claudeResponse = json_decode($result, true); if (empty($claudeResponse['content'])) { throw new Exception("Empty response from Claude for image " . ($imgIndex + 1)); } $responseText = ''; foreach ($claudeResponse['content'] as $block) { if (($block['type'] ?? '') === 'text') { $responseText = $block['text']; break; } } // Clean up JSON response $responseText = trim($responseText); if (str_starts_with($responseText, '```json')) $responseText = substr($responseText, 7); if (str_starts_with($responseText, '```')) $responseText = substr($responseText, 3); if (str_ends_with($responseText, '```')) $responseText = substr($responseText, 0, -3); $responseText = trim($responseText); // Remove trailing commas before ] or } $responseText = preg_replace('/,(\s*[\]\}])/', '$1', $responseText); // Clean control characters $responseText = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F]/', '', $responseText); $imageResult = json_decode($responseText, true); if ($imageResult === null) { // Save debug file $uploadsPath = isDev() ? '/opt/lucee/tomcat/webapps/ROOT/uploads' : '/var/www/biz.payfrit.com/uploads'; file_put_contents("$uploadsPath/debug_claude.json", $responseText); throw new Exception("JSON parse error for image " . ($imgIndex + 1) . ". Debug saved to /uploads/debug_claude.json"); } $allResults[] = $imageResult; } // MERGE PHASE // Track header candidates $headerCandidateIndices = []; foreach ($allResults as $resultIdx => $result) { if (!empty($result['isHeaderCandidate'])) { $headerCandidateIndices[] = $resultIdx; // 0-indexed } } // 1. Merge business info (last image wins) $mergedBusiness = []; $bizFields = ['name','address','addressLine1','city','state','zip','phone','hours','brandColor']; foreach ($allResults as $result) { if (!empty($result['business']) && is_array($result['business'])) { foreach ($bizFields as $fieldName) { if (!isset($result['business'][$fieldName])) continue; $fieldVal = $result['business'][$fieldName]; if (is_string($fieldVal) && strlen(trim($fieldVal))) { $mergedBusiness[$fieldName] = trim($fieldVal); } elseif (is_array($fieldVal)) { // Convert array/struct to readable string $parts = []; foreach ($fieldVal as $k => $v) { if (is_string($v) && strlen(trim($v))) { $parts[] = (is_int($k) ? '' : "$k: ") . $v; } elseif (is_array($v)) { $entryParts = []; foreach ($v as $ev) { if (is_string($ev)) $entryParts[] = $ev; } if (!empty($entryParts)) $parts[] = implode(' ', $entryParts); } } if (!empty($parts)) $mergedBusiness[$fieldName] = implode(', ', $parts); } } } } // 2. Merge categories (dedupe by name) $categoryMap = []; foreach ($allResults as $result) { foreach (($result['categories'] ?? []) as $cat) { $catName = ''; if (is_string($cat) && strlen(trim($cat))) { $catName = trim($cat); } elseif (is_array($cat)) { $catName = trim($cat['name'] ?? $cat['category'] ?? $cat['title'] ?? ''); } if (strlen($catName)) { $categoryMap[strtolower($catName)] = $catName; } } } $mergedCategories = []; foreach ($categoryMap as $catName) { $mergedCategories[] = ['name' => $catName, 'itemCount' => 0]; } // 3. Merge modifiers (dedupe by name) $modifierMap = []; foreach ($allResults as $resultIndex => $result) { foreach (($result['modifiers'] ?? []) as $mod) { if (!is_array($mod) || empty($mod['name'])) continue; $modKey = strtolower($mod['name']); if (isset($modifierMap[$modKey])) continue; $normalizedMod = [ 'name' => trim($mod['name']), 'required' => !empty($mod['required']), 'appliesTo' => $mod['appliesTo'] ?? 'uncertain', 'sourceImageIndex' => $resultIndex + 1, 'options' => [], ]; if (!empty($mod['categoryName'])) { $normalizedMod['categoryName'] = trim($mod['categoryName']); } foreach (($mod['options'] ?? []) as $opt) { $normalizedOpt = []; if (is_string($opt) && strlen(trim($opt))) { $normalizedOpt = ['name' => trim($opt), 'price' => 0]; } elseif (is_array($opt)) { $optName = trim($opt['name'] ?? $opt['option'] ?? $opt['label'] ?? ''); if (strlen($optName)) { $optPrice = 0; if (isset($opt['price'])) { if (is_numeric($opt['price'])) { $optPrice = (float)$opt['price']; } elseif (is_string($opt['price'])) { $priceStr = preg_replace('/[^0-9.]/', '', $opt['price']); if (is_numeric($priceStr)) $optPrice = (float)$priceStr; } } $normalizedOpt = ['name' => $optName, 'price' => $optPrice]; } } if (!empty($normalizedOpt['name'])) { $normalizedMod['options'][] = $normalizedOpt; } } if (!empty($normalizedMod['options'])) { $modifierMap[$modKey] = $normalizedMod; } } } $mergedModifiers = array_values($modifierMap); // 4. Merge items $mergedItems = []; $itemIndex = 0; foreach ($allResults as $result) { foreach (($result['items'] ?? []) as $item) { $itemIndex++; $item['id'] = 'item_' . $itemIndex; $mergedItems[] = $item; } } // 5. Auto-assign category-level modifiers to items foreach ($mergedItems as &$item) { if (!isset($item['modifiers']) || !is_array($item['modifiers'])) { $item['modifiers'] = []; } $itemCategory = is_string($item['category'] ?? null) ? trim($item['category']) : ''; if (strlen($itemCategory)) { foreach ($mergedModifiers as $mod) { if (($mod['appliesTo'] ?? '') === 'category' && !empty($mod['categoryName'])) { if (strtolower($mod['categoryName']) === strtolower($itemCategory)) { // Check not already assigned $alreadyAssigned = false; foreach ($item['modifiers'] as $existingMod) { $existingModName = is_string($existingMod) ? $existingMod : ($existingMod['name'] ?? ''); if (strlen($existingModName) && strtolower($existingModName) === strtolower($mod['name'])) { $alreadyAssigned = true; break; } } if (!$alreadyAssigned) { $item['modifiers'][] = $mod['name']; } } } } } } unset($item); $response['OK'] = true; $response['DATA'] = [ 'business' => $mergedBusiness, 'categories' => $mergedCategories, 'modifiers' => $mergedModifiers, 'items' => $mergedItems, 'headerCandidateIndices' => $headerCandidateIndices, ]; $response['imagesProcessed'] = count($imageDataArray); $response['DEBUG_RAW_RESULTS'] = $allResults; } catch (Exception $e) { $response['MESSAGE'] = $e->getMessage(); } jsonResponse($response);