payfrit-api/api/setup/analyzeMenuImages.php
John Mizerek aa986507fd Remove all Lucee references — uploads now live under /opt/payfrit-api
- Moved uploads from Lucee webroot to /opt/payfrit-api/uploads/
- Updated nginx on both dev and biz to alias /uploads/ to new path
- Replaced luceeWebroot() with uploadsRoot() helper
- Temp files now use /opt/payfrit-api/temp/
- No more /opt/lucee or /var/www/biz.payfrit.com references

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 22:26:52 -07:00

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 = uploadsRoot();
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);