- Instructs Claude to look for modifiers in specific menu locations: category headers, item descriptions, asterisk notes, headers/footers - Assigns modifiers to items when confident about relationships - Only links obvious modifier-to-item connections - Leaves uncertain relationships unlinked for manual assignment Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
346 lines
18 KiB
Text
346 lines
18 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, 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 each item, include a 'modifiers' array with names of modifier templates that apply based on: category-level modifiers, item description mentions, or obvious context. Only include modifiers when confident - when unsure, omit them. 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 options as objects with name and price keys), items (with modifiers array). Look for modifiers under category headers, in item descriptions, in notes/asterisks, or in headers/footers. Assign modifiers to items based on: category-level rules (e.g., 'all burgers include...'), item descriptions, or obvious context. Example item: {""name"": ""Cheeseburger"", ""price"": 12.99, ""category"": ""Burgers"", ""modifiers"": [""Size"", ""Toppings""]}. Example modifier: {""name"": ""Size"", ""required"": true, ""options"": [{""name"": ""Small"", ""price"": 0}, {""name"": ""Large"", ""price"": 1.50}]}">
|
|
<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["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>
|
|
|
|
<!--- 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>
|