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>
384 lines
20 KiB
Text
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>
|