payfrit-works/api/setup/analyzeMenuImages.cfm
John Mizerek 9e195b79e0 Add conversational modifier assignment for uncertain modifiers
Backend (analyzeMenuImages.cfm):
- Updated AI prompts to classify modifiers by confidence level
- Added appliesTo field: 'category', 'item', or 'uncertain'
- Added categoryName field for category-level modifiers
- Auto-assign category-level modifiers to items in that category
- Only assign item-level modifiers when clearly in item description
- Mark uncertain modifiers for user confirmation

Frontend (setup-wizard.html):
- Added showUncertainModifiersStep() between modifier and item steps
- Shows conversational prompts for each uncertain modifier
- Users can select which categories each modifier applies to
- Users can skip modifiers that don't apply automatically
- Applies user selections to items before proceeding
- Added CSS styling for category selection checkboxes

Flow:
1. AI extracts modifiers with confidence classification
2. Category-level modifiers auto-assigned to items
3. Uncertain modifiers presented one-by-one for user decision
4. User confirms or skips each uncertain modifier
5. Assignments applied to items before save

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

384 lines
20 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>
<cfthrow message="Claude API error on image #imgIndex#: #httpResult.statusCode#" detail="#httpResult.fileContent#">
</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 array="#allResults#" index="result">
<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">
<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>