Add recipes, images, AI photo ID, barcode scanning & ingredient matching
- Fuzzy ingredient matching for bar inventory against recipes - AI photo identification API for bottles/labels (drink + bar context) - Barcode scanner with photo toggle for My Bar - Barcode scan + photo ID buttons on Add Drink form - Auto-pull product images from Open Food Facts barcode lookup - Recipes section on drink detail pages with bar availability - Dedicated Recipes page in sidebar navigation - Bar item image support (schema, upload, display) - Drink detail image upload component - MinIO image proxy through Next.js rewrites (fixes broken image links) - Improved category mapping (energy drinks → Mixers, not Spirits) - Re-process saved recipe ingredients against current bar inventory Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -183,7 +183,14 @@ Create a recipe for the requested cocktail. Return a valid JSON object:
|
||||
- "glassware" (string, optional): Recommended glass
|
||||
- "notes" (string, optional): Tips, variations, or history
|
||||
|
||||
Mark ingredients available:true only if matching item exists in bar inventory. Do not include text before or after the JSON.`
|
||||
Mark ingredients available:true if a matching item exists in bar inventory.
|
||||
When checking availability, match flexibly:
|
||||
- Ignore brand names — "Angostura Aromatic Bitters" matches "Angostura bitters" or just "bitters"
|
||||
- Match the core ingredient identity — "simple syrup" matches "Simple Syrup", "bourbon" matches "Maker's Mark Bourbon"
|
||||
- If the bar inventory lists a specific brand/product that IS the ingredient type, mark it available
|
||||
- Be generous — if the user clearly has something that serves the same purpose, mark it available
|
||||
|
||||
Do not include text before or after the JSON.`
|
||||
|
||||
export const WHAT_CAN_I_MAKE_PROMPT = `You are an expert bartender. Based on the user's bar inventory, suggest cocktails they can make.
|
||||
|
||||
@@ -201,7 +208,14 @@ Return a valid JSON array of objects:
|
||||
- "glassware" (string, optional)
|
||||
- "missingCount" (number): How many ingredients missing (0 = can make now)
|
||||
|
||||
Sort by missingCount ascending. Return up to 10. Do not include text before or after the JSON.`
|
||||
Sort by missingCount ascending. Return up to 10.
|
||||
When checking availability, match flexibly:
|
||||
- Ignore brand names — "Angostura Aromatic Bitters" matches "Angostura bitters" or just "bitters"
|
||||
- Match the core ingredient identity — "simple syrup" matches "Simple Syrup", "bourbon" matches "Maker's Mark Bourbon"
|
||||
- If the bar inventory lists a specific brand/product that IS the ingredient type, mark it available
|
||||
- Be generous — if the user clearly has something that serves the same purpose, mark it available
|
||||
|
||||
Do not include text before or after the JSON.`
|
||||
|
||||
export function buildBarInventoryString(items: { name: string; category: string; quantity: string }[]): string {
|
||||
const byCategory: Record<string, string[]> = {}
|
||||
|
||||
68
src/lib/ingredient-matcher.ts
Normal file
68
src/lib/ingredient-matcher.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Post-process AI-generated recipe ingredients to fix incorrect
|
||||
* available:false flags using fuzzy string matching against bar inventory.
|
||||
*/
|
||||
|
||||
interface Ingredient {
|
||||
name: string
|
||||
amount: string
|
||||
available: boolean
|
||||
}
|
||||
|
||||
interface BarItemForMatching {
|
||||
name: string
|
||||
}
|
||||
|
||||
// Common words to ignore during matching
|
||||
const STOP_WORDS = new Set([
|
||||
"of", "or", "and", "the", "a", "an", "to", "for", "in", "on",
|
||||
"fresh", "large", "small", "whole", "crushed", "muddled",
|
||||
"oz", "ml", "cl", "dash", "dashes", "splash", "tsp", "tbsp",
|
||||
"cup", "part", "parts", "slice", "slices", "piece", "pieces",
|
||||
"cube", "cubes", "drop", "drops", "sprig", "sprigs", "leaf", "leaves",
|
||||
])
|
||||
|
||||
function getSignificantWords(text: string): string[] {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.split(/\s+/)
|
||||
.filter((w) => w.length > 2 && !STOP_WORDS.has(w))
|
||||
}
|
||||
|
||||
export function fuzzyMatchIngredients(
|
||||
ingredients: Ingredient[],
|
||||
barItems: BarItemForMatching[]
|
||||
): Ingredient[] {
|
||||
if (barItems.length === 0) return ingredients
|
||||
|
||||
const barItemNames = barItems.map((item) => item.name.toLowerCase())
|
||||
|
||||
return ingredients.map((ingredient) => {
|
||||
if (ingredient.available) return ingredient
|
||||
|
||||
const ingName = ingredient.name.toLowerCase()
|
||||
|
||||
const matched = barItemNames.some((barName) => {
|
||||
// Direct substring match in either direction
|
||||
if (barName.includes(ingName)) return true
|
||||
if (ingName.includes(barName)) return true
|
||||
|
||||
// Word-level: all significant words of the ingredient appear
|
||||
// somewhere across bar item names
|
||||
const ingWords = getSignificantWords(ingName)
|
||||
if (ingWords.length > 0) {
|
||||
return ingWords.every((word) =>
|
||||
barItemNames.some((bn) => bn.includes(word))
|
||||
)
|
||||
}
|
||||
|
||||
return false
|
||||
})
|
||||
|
||||
return matched ? { ...ingredient, available: true } : ingredient
|
||||
})
|
||||
}
|
||||
|
||||
export function recalculateMissingCount(ingredients: Ingredient[]): number {
|
||||
return ingredients.filter((i) => !i.available).length
|
||||
}
|
||||
@@ -31,9 +31,8 @@ export async function uploadImage(
|
||||
})
|
||||
)
|
||||
|
||||
const useSSL = process.env.MINIO_USE_SSL === "true"
|
||||
const protocol = useSSL ? "https" : "http"
|
||||
return `${protocol}://${process.env.MINIO_ENDPOINT}:${process.env.MINIO_PORT}/${BUCKET}/${key}`
|
||||
// Return relative URL through Next.js proxy — works from any device
|
||||
return `/minio-images/${key}`
|
||||
}
|
||||
|
||||
export async function getImage(key: string) {
|
||||
@@ -56,7 +55,6 @@ export async function deleteImage(key: string) {
|
||||
}
|
||||
|
||||
export function getImageUrl(key: string): string {
|
||||
const useSSL = process.env.MINIO_USE_SSL === "true"
|
||||
const protocol = useSSL ? "https" : "http"
|
||||
return `${protocol}://${process.env.MINIO_ENDPOINT}:${process.env.MINIO_PORT}/${BUCKET}/${key}`
|
||||
// Return relative URL through Next.js proxy — works from any device
|
||||
return `/minio-images/${key}`
|
||||
}
|
||||
|
||||
@@ -77,6 +77,8 @@ export const barItemCreateSchema = z.object({
|
||||
category: z.enum(["SPIRITS", "LIQUEURS", "MIXERS", "BITTERS", "GARNISHES", "TOOLS"]),
|
||||
quantity: z.enum(["FULL", "HALF", "LOW", "EMPTY"]).default("FULL"),
|
||||
notes: z.string().max(2000).optional(),
|
||||
barcode: z.string().max(50).optional(),
|
||||
imageUrl: z.string().url().optional().or(z.literal("")),
|
||||
})
|
||||
|
||||
export const barItemUpdateSchema = barItemCreateSchema.partial()
|
||||
|
||||
Reference in New Issue
Block a user