This repository has been archived on 2026-03-21. You can view files and clone it, but cannot push or open issues or pull requests.
payfrit-biz/api/setup/analyzeMenuImages.cfm
John Mizerek 7d23cb5487 Fix JSON parsing in analyzeMenuImages for PDF menu imports
The PDF upload goes to analyzeMenuImages.cfm (not analyzeMenuUrl.cfm).
Added control character cleaning, smart quote replacement, and Jackson
fallback parser with error handling.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 19:45:21 -07:00

465 lines
26 KiB
Text

<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfsetting requesttimeout="900">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfset response = structNew()>
<cfset response["OK"] = false>
<cftry>
<!--- Load API Key --->
<cfset CLAUDE_API_KEY = "">
<cfset configPath = getDirectoryFromPath(getCurrentTemplatePath()) & "../../config/claude.json">
<cfif fileExists(configPath)>
<cfset configData = deserializeJSON(fileRead(configPath))>
<cfif structKeyExists(configData, "apiKey")>
<cfset CLAUDE_API_KEY = configData.apiKey>
</cfif>
</cfif>
<cfif NOT len(CLAUDE_API_KEY)>
<cfthrow message="Claude API key not configured">
</cfif>
<!--- Find uploaded files --->
<cfset uploadedFiles = arrayNew(1)>
<cfset formFields = form.keyArray()>
<cfloop array="#formFields#" index="fieldName">
<cfif reFindNoCase("^file[0-9]+$", fieldName) AND len(form[fieldName])>
<cfset arrayAppend(uploadedFiles, fieldName)>
</cfif>
</cfloop>
<cfif arrayLen(uploadedFiles) EQ 0>
<cfthrow message="No files uploaded">
</cfif>
<!--- Upload and prepare all images first --->
<cfset imageDataArray = arrayNew(1)>
<cfset uploadDir = getTempDirectory() & "payfrit_menu_uploads/">
<cfif NOT directoryExists(uploadDir)>
<cfdirectory action="create" directory="#uploadDir#">
</cfif>
<cfloop array="#uploadedFiles#" index="fieldName">
<cffile action="upload" destination="#uploadDir#" filefield="#fieldName#" accept="image/jpeg,image/png,image/gif,image/webp,application/pdf" nameconflict="makeunique" result="uploadResult">
<cfif uploadResult.fileWasSaved>
<cfset filePath = uploadResult.serverDirectory & "/" & uploadResult.serverFile>
<cfset fileExt = lCase(uploadResult.serverFileExt)>
<!--- Resize large images --->
<cfif listFindNoCase("jpg,jpeg,png,gif,webp", fileExt)>
<cfimage action="read" source="#filePath#" name="img">
<cfif img.width GT 1600 OR img.height GT 1600>
<cfif img.width GT img.height>
<cfimage action="resize" source="#img#" width="1600" name="img">
<cfelse>
<cfimage action="resize" source="#img#" height="1600" name="img">
</cfif>
<cfimage action="write" source="#img#" destination="#filePath#" quality="0.8" overwrite="true">
</cfif>
</cfif>
<!--- Read and encode --->
<cffile action="readbinary" file="#filePath#" variable="fileContent">
<cfset base64Content = toBase64(fileContent)>
<cfset mediaType = "image/jpeg">
<cfif fileExt EQ "png"><cfset mediaType = "image/png"></cfif>
<cfif fileExt EQ "gif"><cfset mediaType = "image/gif"></cfif>
<cfif fileExt EQ "webp"><cfset mediaType = "image/webp"></cfif>
<cfif fileExt EQ "pdf"><cfset mediaType = "application/pdf"></cfif>
<cfset imgSource = structNew()>
<cfset imgSource["type"] = "base64">
<cfset imgSource["media_type"] = mediaType>
<cfset imgSource["data"] = base64Content>
<cfset imgStruct = structNew()>
<cfif fileExt EQ "pdf">
<cfset imgStruct["type"] = "document">
<cfelse>
<cfset imgStruct["type"] = "image">
</cfif>
<cfset imgStruct["source"] = imgSource>
<cfset arrayAppend(imageDataArray, imgStruct)>
<cffile action="delete" file="#filePath#">
</cfif>
</cfloop>
<cfif arrayLen(imageDataArray) EQ 0>
<cfthrow message="No valid images could be processed">
</cfif>
<!--- System prompt for per-image analysis --->
<cfset 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"". If hours are shown as ""Monday - Friday: X"" followed by Saturday and Sunday hours, you MUST include the weekend hours too. Never omit weekend hours if they are visible. For modifier options, ALWAYS use format: {""name"": ""option name"", ""price"": 0}. IMPORTANT: Look for modifiers in these locations: (1) text under category headers (e.g., 'All burgers include...', 'Choose your size:'), (2) item descriptions (e.g., 'served with choice of side'), (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 --->
<cfset allResults = arrayNew(1)>
<cfloop from="1" to="#arrayLen(imageDataArray)#" index="imgIndex">
<cfset imgData = imageDataArray[imgIndex]>
<!--- Build message for this single image --->
<cfset messagesContent = arrayNew(1)>
<cfset arrayAppend(messagesContent, imgData)>
<cfset textBlock = structNew()>
<cfset textBlock["type"] = "text">
<cfset textBlock["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). Look for modifiers under category headers, in item descriptions, in notes/asterisks, or in headers/footers. Example category-level modifier: {""name"": ""Size"", ""required"": true, ""appliesTo"": ""category"", ""categoryName"": ""Burgers"", ""options"": [{""name"": ""Small"", ""price"": 0}, {""name"": ""Large"", ""price"": 1.50}]}. Example uncertain modifier: {""name"": ""Toppings"", ""required"": false, ""appliesTo"": ""uncertain"", ""options"": [{""name"": ""Lettuce"", ""price"": 0}, {""name"": ""Tomato"", ""price"": 0}]}. Only assign modifiers to item.modifiers array when the modifier is clearly specific to that individual item from its description.">
<cfset arrayAppend(messagesContent, textBlock)>
<cfset userMessage = structNew()>
<cfset userMessage["role"] = "user">
<cfset userMessage["content"] = messagesContent>
<cfset requestBody = structNew()>
<cfset requestBody["model"] = "claude-sonnet-4-20250514">
<cfset requestBody["max_tokens"] = 8192>
<cfset requestBody["temperature"] = 0>
<cfset requestBody["system"] = systemPrompt>
<cfset requestBody["messages"] = arrayNew(1)>
<cfset arrayAppend(requestBody["messages"], userMessage)>
<!--- Call Claude API for this image --->
<cfhttp url="https://api.anthropic.com/v1/messages" method="POST" timeout="120" result="httpResult">
<cfhttpparam type="header" name="Content-Type" value="application/json">
<cfhttpparam type="header" name="x-api-key" value="#CLAUDE_API_KEY#">
<cfhttpparam type="header" name="anthropic-version" value="2023-06-01">
<cfhttpparam type="body" value="#serializeJSON(requestBody)#">
</cfhttp>
<cfset httpStatusCode = httpResult.statusCode>
<cfif isNumeric(httpStatusCode)>
<cfset httpStatusCode = int(httpStatusCode)>
<cfelseif findNoCase("200", httpStatusCode)>
<cfset httpStatusCode = 200>
<cfelse>
<cfset httpStatusCode = 0>
</cfif>
<cfif httpStatusCode NEQ 200>
<cfset errorDetail = "">
<cftry>
<cfset errorResponse = deserializeJSON(httpResult.fileContent)>
<cfif structKeyExists(errorResponse, "error") AND structKeyExists(errorResponse.error, "message")>
<cfset errorDetail = errorResponse.error.message>
<cfelse>
<cfset errorDetail = httpResult.fileContent>
</cfif>
<cfcatch>
<cfset errorDetail = httpResult.fileContent>
</cfcatch>
</cftry>
<cfthrow message="Claude API error on image #imgIndex#: #httpResult.statusCode# - #errorDetail#">
</cfif>
<!--- Parse response --->
<cfset claudeResponse = deserializeJSON(httpResult.fileContent)>
<cfif NOT structKeyExists(claudeResponse, "content") OR NOT arrayLen(claudeResponse.content)>
<cfthrow message="Empty response from Claude for image #imgIndex#">
</cfif>
<cfset responseText = "">
<cfloop array="#claudeResponse.content#" index="block">
<cfif structKeyExists(block, "type") AND block.type EQ "text">
<cfset responseText = block.text>
<cfbreak>
</cfif>
</cfloop>
<!--- Clean up JSON response --->
<cfset responseText = trim(responseText)>
<cfif left(responseText, 7) EQ "```json">
<cfset responseText = mid(responseText, 8, len(responseText) - 7)>
</cfif>
<cfif left(responseText, 3) EQ "```">
<cfset responseText = mid(responseText, 4, len(responseText) - 3)>
</cfif>
<cfif right(responseText, 3) EQ "```">
<cfset responseText = left(responseText, len(responseText) - 3)>
</cfif>
<cfset responseText = trim(responseText)>
<cfset responseText = reReplace(responseText, ",(\s*[\]\}])", "\1", "all")>
<!--- Clean control characters and smart typography from PDFs --->
<cfset responseText = reReplace(responseText, "[\x00-\x08\x0B\x0C\x0E-\x1F]", "", "all")>
<cfset responseText = replace(responseText, chr(8216), "'", "all")>
<cfset responseText = replace(responseText, chr(8217), "'", "all")>
<cfset responseText = replace(responseText, chr(8211), "-", "all")>
<cfset responseText = replace(responseText, chr(8212), "-", "all")>
<cfset responseText = replace(responseText, chr(8230), "...", "all")>
<cftry>
<cfset imageResult = deserializeJSON(responseText)>
<cfcatch type="any">
<!--- Lucee parser failed — try Jackson --->
<cftry>
<cfset objectMapper = createObject("java", "com.fasterxml.jackson.databind.ObjectMapper")>
<cfset imageResult = objectMapper.readValue(responseText, createObject("java", "java.util.LinkedHashMap").getClass())>
<cfcatch type="any">
<cfthrow message="JSON parse error in image response. First 500 chars: #replace(left(responseText, 500), '##', '####', 'all')#">
</cfcatch>
</cftry>
</cfcatch>
</cftry>
<cfset arrayAppend(allResults, imageResult)>
</cfloop>
<!--- MERGE PHASE: Combine all results --->
<!--- Track header candidates (image indices that would work as headers) --->
<cfset headerCandidateIndices = arrayNew(1)>
<cfloop from="1" to="#arrayLen(allResults)#" index="resultIdx">
<cfset result = allResults[resultIdx]>
<cfif structKeyExists(result, "isHeaderCandidate") AND result.isHeaderCandidate EQ true>
<cfset arrayAppend(headerCandidateIndices, resultIdx - 1)><!--- 0-indexed for JS --->
</cfif>
</cfloop>
<!--- 1. Extract business info (last image wins so user controls via upload order) --->
<cfset mergedBusiness = structNew()>
<cfset bizFields = "name,address,addressLine1,city,state,zip,phone,hours,brandColor">
<cfloop array="#allResults#" index="result">
<cfif structKeyExists(result, "business") AND isStruct(result.business)>
<cfloop list="#bizFields#" index="fieldName">
<cfif structKeyExists(result.business, fieldName)>
<cfset fieldVal = result.business[fieldName]>
<cfif isSimpleValue(fieldVal) AND len(trim(fieldVal))>
<cfset mergedBusiness[fieldName] = trim(fieldVal)>
<cfelseif isStruct(fieldVal)>
<!--- Convert struct to readable string (e.g. hours as key-value pairs) --->
<cfset parts = arrayNew(1)>
<cfloop collection="#fieldVal#" item="k">
<cfif isSimpleValue(fieldVal[k])>
<cfset arrayAppend(parts, k & ": " & fieldVal[k])>
</cfif>
</cfloop>
<cfif arrayLen(parts)>
<cfset mergedBusiness[fieldName] = arrayToList(parts, ", ")>
</cfif>
<cfelseif isArray(fieldVal) AND arrayLen(fieldVal)>
<!--- Convert array to readable string --->
<cfset parts = arrayNew(1)>
<cfloop array="#fieldVal#" index="entry">
<cfif isSimpleValue(entry) AND len(trim(entry))>
<cfset arrayAppend(parts, trim(entry))>
<cfelseif isStruct(entry)>
<!--- e.g. {day:"Mon", hours:"11am-9pm"} --->
<cfset entryParts = arrayNew(1)>
<cfloop collection="#entry#" item="k">
<cfif isSimpleValue(entry[k])>
<cfset arrayAppend(entryParts, entry[k])>
</cfif>
</cfloop>
<cfif arrayLen(entryParts)>
<cfset arrayAppend(parts, arrayToList(entryParts, " "))>
</cfif>
</cfif>
</cfloop>
<cfif arrayLen(parts)>
<cfset mergedBusiness[fieldName] = arrayToList(parts, ", ")>
</cfif>
</cfif>
</cfif>
</cfloop>
</cfif>
</cfloop>
<!--- 2. Merge categories (dedupe by name) --->
<cfset categoryMap = structNew()>
<cfloop array="#allResults#" index="result">
<cfif structKeyExists(result, "categories") AND isArray(result.categories)>
<cfloop array="#result.categories#" index="cat">
<cfset catName = "">
<cfif isSimpleValue(cat) AND len(trim(cat))>
<cfset catName = trim(cat)>
<cfelseif isStruct(cat)>
<!--- Try common key names --->
<cfif structKeyExists(cat, "name") AND len(trim(cat.name))>
<cfset catName = trim(cat.name)>
<cfelseif structKeyExists(cat, "category") AND len(trim(cat.category))>
<cfset catName = trim(cat.category)>
<cfelseif structKeyExists(cat, "title") AND len(trim(cat.title))>
<cfset catName = trim(cat.title)>
</cfif>
</cfif>
<cfif len(catName)>
<cfset categoryMap[lCase(catName)] = catName>
</cfif>
</cfloop>
</cfif>
</cfloop>
<cfset mergedCategories = arrayNew(1)>
<cfloop collection="#categoryMap#" item="catKey">
<cfset catObj = structNew()>
<cfset catObj["name"] = categoryMap[catKey]>
<cfset catObj["itemCount"] = 0>
<cfset arrayAppend(mergedCategories, catObj)>
</cfloop>
<!--- 3. Merge modifiers (dedupe by name) --->
<cfset modifierMap = structNew()>
<cfloop from="1" to="#arrayLen(allResults)#" index="resultIndex">
<cfset result = allResults[resultIndex]>
<cfif structKeyExists(result, "modifiers") AND isArray(result.modifiers)>
<cfloop array="#result.modifiers#" index="mod">
<cfif isStruct(mod) AND structKeyExists(mod, "name") AND len(trim(mod.name))>
<cfset modKey = lCase(mod.name)>
<cfif NOT structKeyExists(modifierMap, modKey)>
<!--- Normalize the modifier structure --->
<cfset normalizedMod = structNew()>
<cfset normalizedMod["name"] = trim(mod.name)>
<cfset normalizedMod["required"] = structKeyExists(mod, "required") AND mod.required EQ true>
<cfset normalizedMod["appliesTo"] = structKeyExists(mod, "appliesTo") ? mod.appliesTo : "uncertain">
<cfset normalizedMod["sourceImageIndex"] = resultIndex>
<cfif structKeyExists(mod, "categoryName") AND len(trim(mod.categoryName))>
<cfset normalizedMod["categoryName"] = trim(mod.categoryName)>
</cfif>
<cfset normalizedMod["options"] = arrayNew(1)>
<!--- Normalize options array --->
<cfif structKeyExists(mod, "options") AND isArray(mod.options)>
<cfloop array="#mod.options#" index="opt">
<cfset normalizedOpt = structNew()>
<cfif isSimpleValue(opt) AND len(trim(opt))>
<!--- Option is just a string --->
<cfset normalizedOpt["name"] = trim(opt)>
<cfset normalizedOpt["price"] = 0>
<cfelseif isStruct(opt)>
<!--- Option is an object - try different key names --->
<cfset optName = "">
<cfif structKeyExists(opt, "name") AND len(trim(opt.name))>
<cfset optName = trim(opt.name)>
<cfelseif structKeyExists(opt, "option") AND len(trim(opt.option))>
<cfset optName = trim(opt.option)>
<cfelseif structKeyExists(opt, "label") AND len(trim(opt.label))>
<cfset optName = trim(opt.label)>
</cfif>
<cfif len(optName)>
<cfset normalizedOpt["name"] = optName>
<cfset normalizedOpt["price"] = 0>
<cfif structKeyExists(opt, "price")>
<cfif isNumeric(opt.price)>
<cfset normalizedOpt["price"] = opt.price>
<cfelseif isSimpleValue(opt.price)>
<!--- Try to parse price string like "$1.50" or "+$1.50" --->
<cfset priceStr = reReplace(opt.price, "[^0-9.]", "", "all")>
<cfif isNumeric(priceStr)>
<cfset normalizedOpt["price"] = val(priceStr)>
</cfif>
</cfif>
</cfif>
</cfif>
</cfif>
<cfif structKeyExists(normalizedOpt, "name") AND len(normalizedOpt.name)>
<cfset arrayAppend(normalizedMod["options"], normalizedOpt)>
</cfif>
</cfloop>
</cfif>
<!--- Only add modifier if it has at least one valid option --->
<cfif arrayLen(normalizedMod["options"]) GT 0>
<cfset modifierMap[modKey] = normalizedMod>
</cfif>
</cfif>
</cfif>
</cfloop>
</cfif>
</cfloop>
<cfset mergedModifiers = arrayNew(1)>
<cfloop collection="#modifierMap#" item="modKey">
<cfset arrayAppend(mergedModifiers, modifierMap[modKey])>
</cfloop>
<!--- 4. Merge items (collect all, assign IDs) --->
<cfset mergedItems = arrayNew(1)>
<cfset itemIndex = 0>
<cfloop array="#allResults#" index="result">
<cfif structKeyExists(result, "items") AND isArray(result.items)>
<cfloop array="#result.items#" index="item">
<cfset itemIndex = itemIndex + 1>
<cfset item["id"] = "item_" & itemIndex>
<cfset arrayAppend(mergedItems, item)>
</cfloop>
</cfif>
</cfloop>
<!--- 5. Auto-assign category-level modifiers to items --->
<cfloop array="#mergedItems#" index="item">
<!--- Ensure item has modifiers array --->
<cfif NOT structKeyExists(item, "modifiers") OR NOT isArray(item.modifiers)>
<cfset item["modifiers"] = arrayNew(1)>
</cfif>
<!--- Get item's category --->
<cfset itemCategory = structKeyExists(item, "category") ? trim(item.category) : "">
<!--- Find category-level modifiers for this category --->
<cfif len(itemCategory)>
<cfloop array="#mergedModifiers#" index="mod">
<cfif structKeyExists(mod, "appliesTo") AND mod.appliesTo EQ "category" AND structKeyExists(mod, "categoryName")>
<!--- Case-insensitive category match --->
<cfif lCase(mod.categoryName) EQ lCase(itemCategory)>
<!--- Check if modifier is not already assigned --->
<cfset alreadyAssigned = false>
<cfloop array="#item.modifiers#" index="existingMod">
<cfif lCase(existingMod) EQ lCase(mod.name)>
<cfset alreadyAssigned = true>
<cfbreak>
</cfif>
</cfloop>
<cfif NOT alreadyAssigned>
<cfset arrayAppend(item.modifiers, mod.name)>
</cfif>
</cfif>
</cfif>
</cfloop>
</cfif>
</cfloop>
<!--- Build final response --->
<cfset finalData = structNew()>
<cfset finalData["business"] = mergedBusiness>
<cfset finalData["categories"] = mergedCategories>
<cfset finalData["modifiers"] = mergedModifiers>
<cfset finalData["items"] = mergedItems>
<cfset finalData["headerCandidateIndices"] = headerCandidateIndices>
<cfset response["OK"] = true>
<cfset response["DATA"] = finalData>
<cfset response["imagesProcessed"] = arrayLen(imageDataArray)>
<cfset response["DEBUG_RAW_RESULTS"] = allResults>
<!--- Debug: show first category structure --->
<cfif arrayLen(allResults) GT 0 AND structKeyExists(allResults[1], "categories") AND isArray(allResults[1].categories) AND arrayLen(allResults[1].categories) GT 0>
<cfset response["DEBUG_FIRST_CAT"] = allResults[1].categories[1]>
<cfset response["DEBUG_FIRST_CAT_KEYS"] = isStruct(allResults[1].categories[1]) ? structKeyList(allResults[1].categories[1]) : "NOT_A_STRUCT">
</cfif>
<!--- Debug: show first item structure --->
<cfif arrayLen(allResults) GT 0 AND structKeyExists(allResults[1], "items") AND isArray(allResults[1].items) AND arrayLen(allResults[1].items) GT 0>
<cfset response["DEBUG_FIRST_ITEM"] = allResults[1].items[1]>
</cfif>
<!--- Debug: show first modifier from raw results --->
<cfif arrayLen(allResults) GT 0 AND structKeyExists(allResults[1], "modifiers") AND isArray(allResults[1].modifiers) AND arrayLen(allResults[1].modifiers) GT 0>
<cfset response["DEBUG_FIRST_RAW_MODIFIER"] = allResults[1].modifiers[1]>
</cfif>
<!--- Debug: show first merged modifier --->
<cfif arrayLen(mergedModifiers) GT 0>
<cfset response["DEBUG_FIRST_MERGED_MODIFIER"] = mergedModifiers[1]>
</cfif>
<cfcatch type="any">
<cfset response["MESSAGE"] = cfcatch.message>
<cfif len(cfcatch.detail)>
<cfset response["DETAIL"] = cfcatch.detail>
</cfif>
<cfif structKeyExists(cfcatch, "tagContext") AND arrayLen(cfcatch.tagContext) GT 0>
<cfset response["DEBUG_LINE"] = cfcatch.tagContext[1].line>
<cfset response["DEBUG_TEMPLATE"] = cfcatch.tagContext[1].template>
</cfif>
</cfcatch>
</cftry>
<cfoutput>#serializeJSON(response)#</cfoutput>