payfrit-works/api/setup/analyzeMenuImages.cfm
John Mizerek fe383f40d0 Fix wizard flow and add detailed modifier view
Major Changes:
1. Fixed infinite loop in wizard flow - uncertain modifiers step now correctly advances to final review instead of looping back to items
2. Moved uncertain modifier assignment to AFTER items review (makes more sense for user to see items first)
3. Added detailed modifier visualization on uncertain modifiers step showing:
   - Source image indicator (which image the modifier was extracted from)
   - Full list of all options with prices
   - Required/optional status
   - Option count summary

Technical Details:
- Backend: Added sourceImageIndex tracking in analyzeMenuImages.cfm to record which image each modifier came from
- Frontend: Enhanced uncertain modifiers step with inline detailed view showing complete modifier structure
- Flow correction: showUncertainModifiersStep() now calls showFinalStep() instead of showItemsStep() to prevent loop
- Improved error handling in API calls with detailed error messages from Claude API

Flow Changes:
- Old: Upload → Business → Categories → Modifiers → Uncertain Modifiers → Items → [LOOP]
- New: Upload → Business → Categories → Modifiers → Items → Uncertain Modifiers → Final Review

Model Configuration:
- Using claude-sonnet-4-20250514 for menu image analysis
- Added better error reporting to surface API issues (auth, credits, etc.)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-15 16:53:09 -08:00

398 lines
21 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 - 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). 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")>
<cfset imageResult = deserializeJSON(responseText)>
<cfset arrayAppend(allResults, imageResult)>
</cfloop>
<!--- MERGE PHASE: Combine all results --->
<!--- 1. Extract business info (from first result that has it) --->
<cfset mergedBusiness = structNew()>
<cfloop array="#allResults#" index="result">
<cfif structKeyExists(result, "business") AND isStruct(result.business)>
<cfif structKeyExists(result.business, "name") AND len(result.business.name)>
<cfset mergedBusiness = result.business>
<cfbreak>
</cfif>
</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 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>