Initial commit: DrinkTracker full-stack app

Next.js 14 drink collection tracker with AI-powered search,
menu scanning, ratings, wishlist, sharing, and CSV backup/restore.

Features:
- Auth (credentials + OAuth ready)
- Drink collection with ratings and reviews
- AI search via Claude/OpenAI with search history
- Menu photo scanning with AI extraction
- Wishlist / Try Later system
- Public sharing via slug URLs
- CSV backup and restore (merge/replace modes)
- Docker Compose for Postgres + MinIO + dev server

Security: docker-compose files use env var interpolation
instead of hardcoded secrets.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
JP Scott
2026-03-01 12:27:08 -07:00
commit 969bc9347a
115 changed files with 19397 additions and 0 deletions

209
src/lib/ai/base-provider.ts Normal file
View File

@@ -0,0 +1,209 @@
import type {
AIProvider,
ExtractedMenuItem,
MenuExtractionResult,
DrinkRecommendation,
RecommendationResult,
LabelExtractionResult,
DrinkSearchResult,
UserDrinkSummary,
UserPreferenceSummary,
} from "./types"
import {
MENU_EXTRACTION_PROMPT,
LABEL_EXTRACTION_PROMPT,
DRINK_SEARCH_PROMPT,
buildRecommendationPrompt,
} from "./prompts"
export abstract class BaseAIProvider implements AIProvider {
abstract name: string
abstract sendVisionRequest(
systemPrompt: string,
imageBase64: string,
mimeType: string
): Promise<string>
abstract sendTextRequest(
systemPrompt: string,
userMessage: string
): Promise<string>
async extractMenuItems(
imageBase64: string,
mimeType: string
): Promise<MenuExtractionResult> {
const rawResponse = await this.sendVisionRequest(
MENU_EXTRACTION_PROMPT,
imageBase64,
mimeType
)
try {
const parsed = this.parseJsonFromResponse(rawResponse)
const items: ExtractedMenuItem[] = Array.isArray(parsed) ? parsed : []
const validatedItems = items.map((item) => ({
name: String(item.name || "Unknown"),
type: this.validateDrinkType(item.type),
...(item.subType && { subType: String(item.subType) }),
...(item.brewery && { brewery: String(item.brewery) }),
...(item.abv != null && { abv: Number(item.abv) }),
...(item.price && { price: String(item.price) }),
...(item.description && { description: String(item.description) }),
}))
return { items: validatedItems, rawResponse }
} catch (error) {
console.error("Failed to parse menu extraction response:", error)
return { items: [], rawResponse }
}
}
async recommendDrinks(
extractedItems: ExtractedMenuItem[],
userDrinks: UserDrinkSummary[],
preferences: UserPreferenceSummary | null
): Promise<RecommendationResult> {
const prompt = buildRecommendationPrompt(
extractedItems,
userDrinks,
preferences
)
const rawResponse = await this.sendTextRequest(
prompt,
"Please provide your drink recommendations based on the information above."
)
try {
const parsed = this.parseJsonFromResponse(rawResponse)
const recommendations: DrinkRecommendation[] = Array.isArray(parsed)
? parsed
: []
const validatedRecs = recommendations.map((rec) => ({
itemName: String(rec.itemName || ""),
reason: String(rec.reason || ""),
confidence: Math.min(1, Math.max(0, Number(rec.confidence) || 0)),
}))
return { recommendations: validatedRecs, rawResponse }
} catch (error) {
console.error("Failed to parse recommendation response:", error)
return { recommendations: [], rawResponse }
}
}
async extractLabel(
imageBase64: string,
mimeType: string
): Promise<LabelExtractionResult> {
const rawResponse = await this.sendVisionRequest(
LABEL_EXTRACTION_PROMPT,
imageBase64,
mimeType
)
try {
const parsed = this.parseJsonFromResponse(rawResponse)
return {
name: String(parsed.name || "Unknown"),
type: this.validateDrinkType(parsed.type),
...(parsed.subType && { subType: String(parsed.subType) }),
...(parsed.brewery && { brewery: String(parsed.brewery) }),
...(parsed.region && { region: String(parsed.region) }),
...(parsed.abv != null && { abv: Number(parsed.abv) }),
...(parsed.description && { description: String(parsed.description) }),
rawResponse,
}
} catch (error) {
console.error("Failed to parse label extraction response:", error)
return {
name: "Unknown",
type: "OTHER",
rawResponse,
}
}
}
async searchDrinks(query: string): Promise<DrinkSearchResult> {
const rawResponse = await this.sendTextRequest(
DRINK_SEARCH_PROMPT,
`Search for: ${query}`
)
try {
const parsed = this.parseJsonFromResponse(rawResponse)
const drinks: ExtractedMenuItem[] = Array.isArray(parsed) ? parsed : []
const validatedDrinks = drinks.map((item) => ({
name: String(item.name || "Unknown"),
type: this.validateDrinkType(item.type),
...(item.subType && { subType: String(item.subType) }),
...(item.brewery && { brewery: String(item.brewery) }),
...(item.abv != null && { abv: Number(item.abv) }),
...(item.description && { description: String(item.description) }),
}))
return { drinks: validatedDrinks, rawResponse }
} catch (error) {
console.error("Failed to parse drink search response:", error)
return { drinks: [], rawResponse }
}
}
protected parseJsonFromResponse(text: string): any {
// Try direct parse first
try {
return JSON.parse(text)
} catch {
// Continue to other strategies
}
// Try to extract from markdown code blocks: ```json ... ``` or ``` ... ```
const codeBlockMatch = text.match(/```(?:json)?\s*\n?([\s\S]*?)\n?```/)
if (codeBlockMatch) {
try {
return JSON.parse(codeBlockMatch[1].trim())
} catch {
// Continue to other strategies
}
}
// Try to find JSON array in the text
const arrayMatch = text.match(/\[[\s\S]*\]/)
if (arrayMatch) {
try {
return JSON.parse(arrayMatch[0])
} catch {
// Continue to other strategies
}
}
// Try to find JSON object in the text
const objectMatch = text.match(/\{[\s\S]*\}/)
if (objectMatch) {
try {
return JSON.parse(objectMatch[0])
} catch {
// Continue to other strategies
}
}
throw new Error(`Could not extract valid JSON from response: ${text.slice(0, 200)}...`)
}
private validateDrinkType(
type: unknown
): "BEER" | "WINE" | "COCKTAIL" | "SPIRIT" | "OTHER" {
const validTypes = ["BEER", "WINE", "COCKTAIL", "SPIRIT", "OTHER"] as const
const upper = String(type || "").toUpperCase()
if (validTypes.includes(upper as (typeof validTypes)[number])) {
return upper as (typeof validTypes)[number]
}
return "OTHER"
}
}

View File

@@ -0,0 +1,78 @@
import Anthropic from "@anthropic-ai/sdk"
import { BaseAIProvider } from "./base-provider"
export class ClaudeProvider extends BaseAIProvider {
name = "claude"
private client: Anthropic
constructor(apiKey: string) {
super()
this.client = new Anthropic({ apiKey })
}
async sendVisionRequest(
systemPrompt: string,
imageBase64: string,
mimeType: string
): Promise<string> {
const response = await this.client.messages.create({
model: "claude-sonnet-4-20250514",
max_tokens: 4096,
system: systemPrompt,
messages: [
{
role: "user",
content: [
{
type: "image",
source: {
type: "base64",
media_type: mimeType as
| "image/jpeg"
| "image/png"
| "image/gif"
| "image/webp",
data: imageBase64,
},
},
{
type: "text",
text: "Please analyze this image and extract the information as instructed.",
},
],
},
],
})
const textBlock = response.content.find((block) => block.type === "text")
if (!textBlock || textBlock.type !== "text") {
throw new Error("No text response received from Claude")
}
return textBlock.text
}
async sendTextRequest(
systemPrompt: string,
userMessage: string
): Promise<string> {
const response = await this.client.messages.create({
model: "claude-sonnet-4-20250514",
max_tokens: 4096,
system: systemPrompt,
messages: [
{
role: "user",
content: userMessage,
},
],
})
const textBlock = response.content.find((block) => block.type === "text")
if (!textBlock || textBlock.type !== "text") {
throw new Error("No text response received from Claude")
}
return textBlock.text
}
}

291
src/lib/ai/menu-analyzer.ts Normal file
View File

@@ -0,0 +1,291 @@
import { prisma } from "@/lib/prisma"
import { decrypt } from "@/lib/encryption"
import { createProvider } from "./provider-factory"
import type {
ExtractedMenuItem,
MenuExtractionResult,
RecommendationResult,
LabelExtractionResult,
UserDrinkSummary,
UserPreferenceSummary,
} from "./types"
interface MatchedItem {
menuItem: ExtractedMenuItem
drinkId: string
drinkName: string
avgRating: number | null
wouldReorder: boolean
}
interface MenuAnalysisResult {
extractedItems: ExtractedMenuItem[]
matchedItems: MatchedItem[]
recommendations: RecommendationResult
rawResponse: string
provider: string
}
async function getProviderForUser(userId: string) {
const apiKeyRecord = await prisma.userApiKey.findFirst({
where: { userId, isActive: true },
orderBy: { updatedAt: "desc" },
})
if (!apiKeyRecord) {
throw new Error(
"No active API key found. Please add an AI provider API key in Settings."
)
}
const apiKey = decrypt(apiKeyRecord.encryptedKey, apiKeyRecord.iv)
const provider = createProvider(apiKeyRecord.provider, apiKey)
return { provider, providerName: apiKeyRecord.provider }
}
async function getUserDrinkSummaries(
userId: string
): Promise<UserDrinkSummary[]> {
const drinks = await prisma.drink.findMany({
where: { userId },
include: {
ratings: {
select: {
score: true,
wouldReorder: true,
},
},
},
})
return drinks.map((drink) => {
const ratings = drink.ratings
const avgRating =
ratings.length > 0
? ratings.reduce((sum, r) => sum + r.score, 0) / ratings.length
: null
const wouldReorder = ratings.some((r) => r.wouldReorder)
return {
name: drink.name,
type: drink.type,
subType: drink.subType,
brewery: drink.brewery,
avgRating: avgRating !== null ? Math.round(avgRating * 10) / 10 : null,
wouldReorder,
}
})
}
async function getUserPreferences(
userId: string
): Promise<UserPreferenceSummary | null> {
const prefs = await prisma.userPreference.findUnique({
where: { userId },
})
if (!prefs) return null
return {
preferredStyles: prefs.preferredStyles,
avoidedStyles: prefs.avoidedStyles,
minAbv: prefs.minAbv,
maxAbv: prefs.maxAbv,
}
}
function normalizeForComparison(text: string): string {
return text
.toLowerCase()
.replace(/[^a-z0-9]/g, "")
.trim()
}
function fuzzyMatch(a: string, b: string): boolean {
const normA = normalizeForComparison(a)
const normB = normalizeForComparison(b)
// Exact match after normalization
if (normA === normB) return true
// One contains the other
if (normA.includes(normB) || normB.includes(normA)) return true
// Levenshtein distance for short strings — allow minor typos
if (normA.length > 3 && normB.length > 3) {
const distance = levenshteinDistance(normA, normB)
const maxLen = Math.max(normA.length, normB.length)
const similarity = 1 - distance / maxLen
if (similarity >= 0.8) return true
}
return false
}
function levenshteinDistance(a: string, b: string): number {
const matrix: number[][] = []
for (let i = 0; i <= b.length; i++) {
matrix[i] = [i]
}
for (let j = 0; j <= a.length; j++) {
matrix[0][j] = j
}
for (let i = 1; i <= b.length; i++) {
for (let j = 1; j <= a.length; j++) {
if (b[i - 1] === a[j - 1]) {
matrix[i][j] = matrix[i - 1][j - 1]
} else {
matrix[i][j] = Math.min(
matrix[i - 1][j - 1] + 1, // substitution
matrix[i][j - 1] + 1, // insertion
matrix[i - 1][j] + 1 // deletion
)
}
}
}
return matrix[b.length][a.length]
}
function matchExtractedToUserDrinks(
extractedItems: ExtractedMenuItem[],
userDrinks: Array<{
id: string
name: string
type: string
subType: string | null
brewery: string | null
ratings: Array<{ score: number; wouldReorder: boolean }>
}>
): { matched: MatchedItem[]; unmatched: ExtractedMenuItem[] } {
const matched: MatchedItem[] = []
const unmatched: ExtractedMenuItem[] = []
for (const menuItem of extractedItems) {
let bestMatch: (typeof userDrinks)[number] | null = null
for (const drink of userDrinks) {
// Primary match: name
if (fuzzyMatch(menuItem.name, drink.name)) {
bestMatch = drink
break
}
// Secondary match: name + brewery combo
if (
menuItem.brewery &&
drink.brewery &&
fuzzyMatch(menuItem.brewery, drink.brewery) &&
fuzzyMatch(menuItem.name, drink.name)
) {
bestMatch = drink
break
}
}
if (bestMatch) {
const ratings = bestMatch.ratings
const avgRating =
ratings.length > 0
? Math.round(
(ratings.reduce((sum, r) => sum + r.score, 0) / ratings.length) *
10
) / 10
: null
const wouldReorder = ratings.some((r) => r.wouldReorder)
matched.push({
menuItem,
drinkId: bestMatch.id,
drinkName: bestMatch.name,
avgRating,
wouldReorder,
})
} else {
unmatched.push(menuItem)
}
}
return { matched, unmatched }
}
export async function analyzeMenu(
imageBase64: string,
mimeType: string,
userId: string
): Promise<MenuAnalysisResult> {
// Step 1: Get AI provider for user
const { provider, providerName } = await getProviderForUser(userId)
// Step 2: Extract menu items from image
const extraction: MenuExtractionResult = await provider.extractMenuItems(
imageBase64,
mimeType
)
if (extraction.items.length === 0) {
return {
extractedItems: [],
matchedItems: [],
recommendations: { recommendations: [], rawResponse: extraction.rawResponse },
rawResponse: extraction.rawResponse,
provider: providerName,
}
}
// Step 3: Get user's drinks with ratings from database
const userDrinksWithRatings = await prisma.drink.findMany({
where: { userId },
include: {
ratings: {
select: {
score: true,
wouldReorder: true,
},
},
},
})
// Step 4: Match extracted items against user's collection
const { matched, unmatched } = matchExtractedToUserDrinks(
extraction.items,
userDrinksWithRatings
)
// Step 5: Get recommendations for unmatched items
let recommendations: RecommendationResult = {
recommendations: [],
rawResponse: "",
}
if (unmatched.length > 0) {
const userDrinkSummaries = await getUserDrinkSummaries(userId)
const preferences = await getUserPreferences(userId)
recommendations = await provider.recommendDrinks(
unmatched,
userDrinkSummaries,
preferences
)
}
return {
extractedItems: extraction.items,
matchedItems: matched,
recommendations,
rawResponse: extraction.rawResponse,
provider: providerName,
}
}
export async function analyzeLabel(
imageBase64: string,
mimeType: string,
userId: string
): Promise<LabelExtractionResult> {
const { provider } = await getProviderForUser(userId)
return provider.extractLabel(imageBase64, mimeType)
}

View File

@@ -0,0 +1,81 @@
import OpenAI from "openai"
import { BaseAIProvider } from "./base-provider"
export class OpenAIProvider extends BaseAIProvider {
name = "openai"
private client: OpenAI
constructor(apiKey: string) {
super()
this.client = new OpenAI({ apiKey })
}
async sendVisionRequest(
systemPrompt: string,
imageBase64: string,
mimeType: string
): Promise<string> {
const dataUrl = `data:${mimeType};base64,${imageBase64}`
const response = await this.client.chat.completions.create({
model: "gpt-4o",
max_tokens: 4096,
messages: [
{
role: "system",
content: systemPrompt,
},
{
role: "user",
content: [
{
type: "image_url",
image_url: {
url: dataUrl,
detail: "high",
},
},
{
type: "text",
text: "Please analyze this image and extract the information as instructed.",
},
],
},
],
})
const message = response.choices[0]?.message?.content
if (!message) {
throw new Error("No response received from OpenAI")
}
return message
}
async sendTextRequest(
systemPrompt: string,
userMessage: string
): Promise<string> {
const response = await this.client.chat.completions.create({
model: "gpt-4o",
max_tokens: 4096,
messages: [
{
role: "system",
content: systemPrompt,
},
{
role: "user",
content: userMessage,
},
],
})
const message = response.choices[0]?.message?.content
if (!message) {
throw new Error("No response received from OpenAI")
}
return message
}
}

168
src/lib/ai/prompts.ts Normal file
View File

@@ -0,0 +1,168 @@
import type { ExtractedMenuItem, UserDrinkSummary, UserPreferenceSummary } from "./types"
export const MENU_EXTRACTION_PROMPT = `You are an expert at reading drink menus from photos. Analyze the provided menu image and extract every drink item you can identify.
For each item, return the following fields:
- "name" (string, required): The name of the drink as it appears on the menu.
- "type" (string, required): One of "BEER", "WINE", "COCKTAIL", "SPIRIT", or "OTHER".
- "subType" (string, optional): The style or sub-category (e.g., "IPA", "Stout", "Pinot Noir", "Margarita", "Bourbon").
- "brewery" (string, optional): The brewery, winery, or distillery name if listed.
- "abv" (number, optional): The alcohol by volume as a decimal number (e.g., 5.5 for 5.5%). Only include if explicitly shown on the menu.
- "price" (string, optional): The price as shown on the menu (e.g., "$8", "$12/glass"). Include the currency symbol.
- "description" (string, optional): Any tasting notes or description provided on the menu.
Return your response as a valid JSON array of objects. Do not include any text before or after the JSON array. Example format:
[
{
"name": "Hazy Little Thing",
"type": "BEER",
"subType": "Hazy IPA",
"brewery": "Sierra Nevada",
"abv": 6.7,
"price": "$7",
"description": "Unfiltered, unprocessed IPA with tropical hop character"
}
]
If no drink items can be identified in the image, return an empty array: []
Important:
- Extract ALL visible items, even if some fields are unclear.
- If a field is not visible or cannot be determined, omit it rather than guessing.
- Classify the type based on context clues if not explicitly stated.
- For sections labeled "Draft", "On Tap", "Bottles", "Cans" — these are typically BEER.
- For sections labeled "Red", "White", "Rosé", "Sparkling" — these are typically WINE.
- For sections labeled "Cocktails", "Signature Drinks", "Mixed Drinks" — these are typically COCKTAIL.`
export const LABEL_EXTRACTION_PROMPT = `You are an expert at reading drink labels from photos. Analyze the provided label image and extract all information about the drink.
Return your response as a single valid JSON object with the following fields:
- "name" (string, required): The name of the drink.
- "type" (string, required): One of "BEER", "WINE", "COCKTAIL", "SPIRIT", or "OTHER".
- "subType" (string, optional): The style or sub-category (e.g., "IPA", "Stout", "Cabernet Sauvignon", "Bourbon").
- "brewery" (string, optional): The brewery, winery, or distillery name.
- "region" (string, optional): The geographic region or origin (e.g., "Napa Valley", "Portland, OR", "Scotland").
- "abv" (number, optional): The alcohol by volume as a decimal number (e.g., 5.5 for 5.5%).
- "description" (string, optional): Any tasting notes, taglines, or descriptive text from the label.
Do not include any text before or after the JSON object. Example format:
{
"name": "Two Hearted Ale",
"type": "BEER",
"subType": "American IPA",
"brewery": "Bell's Brewery",
"region": "Comstock, MI",
"abv": 7.0,
"description": "Brewed with 100% Centennial hops for a bold, balanced American IPA"
}
Important:
- Read the label carefully and extract only what is actually present.
- If a field is not visible or cannot be determined, omit it rather than guessing.
- For ABV, look for the "% alc/vol" or "ABV" label. Return only the number.`
export const DRINK_SEARCH_PROMPT = `You are a knowledgeable drink expert. The user is searching for a drink by name or description. Return detailed information about matching drinks.
Return your response as a valid JSON array of drink objects. Each object should have:
- "name" (string, required): The full, correct name of the drink.
- "type" (string, required): One of "BEER", "WINE", "COCKTAIL", "SPIRIT", or "OTHER".
- "subType" (string, optional): The style or sub-category (e.g., "IPA", "Stout", "Cabernet Sauvignon").
- "brewery" (string, optional): The brewery, winery, or distillery that makes it.
- "region" (string, optional): Where it's from.
- "abv" (number, optional): Typical ABV as a number.
- "description" (string, optional): A brief tasting note or description (1-2 sentences).
Important:
- Return up to 8 results, sorted by relevance to the query.
- Include the most likely exact match first, followed by similar or related drinks.
- If the query is vague (e.g., "a good IPA"), return popular well-known options.
- Only include information you are confident about. Omit fields rather than guessing.
- Do not include any text before or after the JSON array.`
export function buildRecommendationPrompt(
extractedItems: ExtractedMenuItem[],
userDrinks: UserDrinkSummary[],
preferences: UserPreferenceSummary | null
): string {
const itemsList = extractedItems
.map((item, i) => {
const parts = [`${i + 1}. ${item.name} (${item.type})`]
if (item.subType) parts.push(`Style: ${item.subType}`)
if (item.brewery) parts.push(`From: ${item.brewery}`)
if (item.abv) parts.push(`ABV: ${item.abv}%`)
if (item.description) parts.push(`Description: ${item.description}`)
return parts.join(" | ")
})
.join("\n")
const drinkHistory = userDrinks.length > 0
? userDrinks
.map((d) => {
const parts = [`- ${d.name} (${d.type})`]
if (d.subType) parts.push(`Style: ${d.subType}`)
if (d.brewery) parts.push(`From: ${d.brewery}`)
if (d.avgRating !== null) parts.push(`Avg Rating: ${d.avgRating}/5`)
parts.push(`Would Reorder: ${d.wouldReorder ? "Yes" : "No"}`)
return parts.join(" | ")
})
.join("\n")
: "No drink history available."
let preferencesText = "No specific preferences set."
if (preferences) {
const parts: string[] = []
if (preferences.preferredStyles.length > 0) {
parts.push(`Preferred styles: ${preferences.preferredStyles.join(", ")}`)
}
if (preferences.avoidedStyles.length > 0) {
parts.push(`Avoided styles: ${preferences.avoidedStyles.join(", ")}`)
}
if (preferences.minAbv != null) {
parts.push(`Minimum ABV: ${preferences.minAbv}%`)
}
if (preferences.maxAbv != null) {
parts.push(`Maximum ABV: ${preferences.maxAbv}%`)
}
if (parts.length > 0) {
preferencesText = parts.join("\n")
}
}
return `You are a knowledgeable drink recommendation assistant. Based on the user's drink history, preferences, and the available menu items, recommend drinks they would likely enjoy.
## User's Drink History
${drinkHistory}
## User's Preferences
${preferencesText}
## Available Menu Items
${itemsList}
## Instructions
Analyze the user's taste profile from their drink history and preferences. Then recommend items from the available menu that they would most likely enjoy. Consider:
- Drinks similar to ones they rated highly or would reorder
- Styles they prefer
- Avoid styles they dislike
- Respect their ABV range preferences if set
- If they have no history, recommend popular crowd-pleasers
Return your response as a valid JSON array of recommendation objects. Each object should have:
- "itemName" (string): The exact name of the menu item you are recommending.
- "reason" (string): A brief, personalized explanation of why you think they would enjoy this drink (1-2 sentences).
- "confidence" (number): A confidence score between 0 and 1 indicating how well this matches their taste profile.
Sort recommendations by confidence (highest first). Return up to 5 recommendations.
Do not include any text before or after the JSON array. Example format:
[
{
"itemName": "Hazy Little Thing",
"reason": "You've rated several IPAs highly, and this hazy IPA has similar tropical hop notes to beers you've enjoyed.",
"confidence": 0.92
}
]`
}

View File

@@ -0,0 +1,14 @@
import type { AIProvider } from "./types"
import { ClaudeProvider } from "./claude-provider"
import { OpenAIProvider } from "./openai-provider"
export function createProvider(providerName: string, apiKey: string): AIProvider {
switch (providerName) {
case "claude":
return new ClaudeProvider(apiKey)
case "openai":
return new OpenAIProvider(apiKey)
default:
throw new Error(`Unknown AI provider: "${providerName}". Supported providers: "claude", "openai".`)
}
}

69
src/lib/ai/types.ts Normal file
View File

@@ -0,0 +1,69 @@
export interface ExtractedMenuItem {
name: string
type: "BEER" | "WINE" | "COCKTAIL" | "SPIRIT" | "OTHER"
subType?: string
brewery?: string
abv?: number
price?: string
description?: string
}
export interface MenuExtractionResult {
items: ExtractedMenuItem[]
rawResponse: string
}
export interface DrinkRecommendation {
itemName: string
reason: string
confidence: number // 0-1
}
export interface RecommendationResult {
recommendations: DrinkRecommendation[]
rawResponse: string
}
export interface LabelExtractionResult {
name: string
type: "BEER" | "WINE" | "COCKTAIL" | "SPIRIT" | "OTHER"
subType?: string
brewery?: string
region?: string
abv?: number
description?: string
rawResponse: string
}
export interface DrinkSearchResult {
drinks: ExtractedMenuItem[]
rawResponse: string
}
export interface AIProvider {
name: string
extractMenuItems(imageBase64: string, mimeType: string): Promise<MenuExtractionResult>
recommendDrinks(
extractedItems: ExtractedMenuItem[],
userDrinks: UserDrinkSummary[],
preferences: UserPreferenceSummary | null
): Promise<RecommendationResult>
extractLabel(imageBase64: string, mimeType: string): Promise<LabelExtractionResult>
searchDrinks(query: string): Promise<DrinkSearchResult>
}
export interface UserDrinkSummary {
name: string
type: string
subType?: string | null
brewery?: string | null
avgRating: number | null
wouldReorder: boolean
}
export interface UserPreferenceSummary {
preferredStyles: string[]
avoidedStyles: string[]
minAbv?: number | null
maxAbv?: number | null
}