Complete port of all 163 API endpoints from Lucee/CFML to PHP 8.3. Shared helpers in api/helpers.php (DB, auth, request/response, security). PDO prepared statements throughout. Same JSON response shapes as CFML.
344 lines
15 KiB
PHP
344 lines
15 KiB
PHP
<?php
|
|
require_once __DIR__ . '/../helpers.php';
|
|
runAuth();
|
|
|
|
/**
|
|
* Analyze Menu Images via Claude API
|
|
*
|
|
* Accepts multipart form with image files (file0, file1, etc.)
|
|
* Sends each to Claude for menu extraction, then merges results.
|
|
*/
|
|
|
|
set_time_limit(900);
|
|
|
|
$response = ['OK' => 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);
|