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:
209
src/lib/ai/base-provider.ts
Normal file
209
src/lib/ai/base-provider.ts
Normal 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"
|
||||
}
|
||||
}
|
||||
78
src/lib/ai/claude-provider.ts
Normal file
78
src/lib/ai/claude-provider.ts
Normal 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
291
src/lib/ai/menu-analyzer.ts
Normal 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)
|
||||
}
|
||||
81
src/lib/ai/openai-provider.ts
Normal file
81
src/lib/ai/openai-provider.ts
Normal 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
168
src/lib/ai/prompts.ts
Normal 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
|
||||
}
|
||||
]`
|
||||
}
|
||||
14
src/lib/ai/provider-factory.ts
Normal file
14
src/lib/ai/provider-factory.ts
Normal 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
69
src/lib/ai/types.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user