feat: implement advanced tagging system, tag weighting, and app focus refactoring
- Implemented reusable TagSelector component with i18n support - Added tag weighting system (popularity scores 1-5) - Created admin panel for tag management - Integrated Nebius AI and Brave Search for 'Magic Scan' - Refactored app focus: removed bottle status, updated counters, and displayed extended bottle details - Updated i18n for German and English - Added database migration scripts
This commit is contained in:
124
src/services/analyze-bottle-nebius.ts
Normal file
124
src/services/analyze-bottle-nebius.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
'use server';
|
||||
|
||||
import { aiClient } from '@/lib/ai-client';
|
||||
import { SYSTEM_INSTRUCTION as GEMINI_SYSTEM_INSTRUCTION } from '@/lib/gemini';
|
||||
import { BottleMetadataSchema, AnalysisResponse, BottleMetadata } from '@/types/whisky';
|
||||
import { createServerActionClient } from '@supabase/auth-helpers-nextjs';
|
||||
import { cookies } from 'next/headers';
|
||||
import { createHash } from 'crypto';
|
||||
import { trackApiUsage } from './track-api-usage';
|
||||
import { checkCreditBalance, deductCredits } from './credit-service';
|
||||
|
||||
export async function analyzeBottleNebius(base64Image: string): Promise<AnalysisResponse & { search_string?: string }> {
|
||||
const supabase = createServerActionClient({ cookies });
|
||||
|
||||
if (!process.env.NEBIUS_API_KEY) {
|
||||
return { success: false, error: 'NEBIUS_API_KEY is not configured.' };
|
||||
}
|
||||
|
||||
try {
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
if (!session || !session.user) {
|
||||
return { success: false, error: 'Nicht autorisiert oder Session abgelaufen.' };
|
||||
}
|
||||
|
||||
const userId = session.user.id;
|
||||
|
||||
// Check credit balance (using same gemini_ai type for now or create new one)
|
||||
const creditCheck = await checkCreditBalance(userId, 'gemini_ai');
|
||||
if (!creditCheck.allowed) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Nicht genügend Credits. Du benötigst ${creditCheck.cost} Credits, hast aber nur ${creditCheck.balance}.`
|
||||
};
|
||||
}
|
||||
|
||||
const base64Data = base64Image.split(',')[1] || base64Image;
|
||||
const imageHash = createHash('sha256').update(base64Data).digest('hex');
|
||||
|
||||
// Check Cache (Optional: skip if you want fresh AI results for testing)
|
||||
const { data: cachedResult } = await supabase
|
||||
.from('vision_cache')
|
||||
.select('result')
|
||||
.eq('hash', imageHash)
|
||||
.maybeSingle();
|
||||
|
||||
if (cachedResult) {
|
||||
console.log(`[Nebius Cache] Hit! hash: ${imageHash}`);
|
||||
return {
|
||||
success: true,
|
||||
data: cachedResult.result as any,
|
||||
};
|
||||
}
|
||||
|
||||
console.log(`[Nebius AI] Calling Qwen2.5-VL...`);
|
||||
|
||||
const response = await aiClient.chat.completions.create({
|
||||
model: "Qwen/Qwen2.5-VL-72B-Instruct",
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: GEMINI_SYSTEM_INSTRUCTION + "\nAdditionally, generate a 'search_string' field for Whiskybase in this format: 'site:whiskybase.com [Distillery] [Name] [Vintage]'. Include this field in the JSON object."
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Extract whisky metadata from this image."
|
||||
},
|
||||
{
|
||||
type: "image_url",
|
||||
image_url: {
|
||||
url: `data:image/jpeg;base64,${base64Data}`
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
response_format: { type: "json_object" }
|
||||
});
|
||||
|
||||
const content = response.choices[0].message.content;
|
||||
if (!content) throw new Error('Empty response from Nebius AI');
|
||||
|
||||
// Extract JSON content in case the model wraps it in markdown blocks
|
||||
const jsonContent = content.match(/\{[\s\S]*\}/)?.[0] || content;
|
||||
const jsonData = JSON.parse(jsonContent);
|
||||
|
||||
// Extract search_string before validation if it's not in schema
|
||||
const searchString = jsonData.search_string;
|
||||
delete jsonData.search_string;
|
||||
|
||||
const validatedData = BottleMetadataSchema.parse(jsonData);
|
||||
|
||||
// Track usage
|
||||
await trackApiUsage({
|
||||
userId: userId,
|
||||
apiType: 'gemini_ai', // Keep tracking as gemini_ai for budget or separate later
|
||||
endpoint: 'nebius/qwen2.5-vl',
|
||||
success: true
|
||||
});
|
||||
|
||||
// Deduct credits
|
||||
await deductCredits(userId, 'gemini_ai', 'Nebius AI analysis');
|
||||
|
||||
// Store in Cache
|
||||
await supabase
|
||||
.from('vision_cache')
|
||||
.insert({ hash: imageHash, result: validatedData });
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: validatedData,
|
||||
search_string: searchString
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('Nebius Analysis Error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Nebius AI analysis failed.',
|
||||
};
|
||||
}
|
||||
}
|
||||
60
src/services/brave-search.ts
Normal file
60
src/services/brave-search.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
'use server';
|
||||
|
||||
/**
|
||||
* Service to search Brave for a Whiskybase link and extract the ID.
|
||||
*/
|
||||
export async function searchBraveForWhiskybase(query: string) {
|
||||
const apiKey = process.env.BRAVE_API_KEY;
|
||||
|
||||
if (!apiKey) {
|
||||
console.error('BRAVE_API_KEY is not configured.');
|
||||
return { success: false, error: 'Brave Search API Key missing.' };
|
||||
}
|
||||
|
||||
try {
|
||||
const url = `https://api.search.brave.com/res/v1/web/search?q=${encodeURIComponent(query + ' site:whiskybase.com')}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'X-Subscription-Token': apiKey,
|
||||
'Accept': 'application/json',
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.message || `Brave API error: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.web || !data.web.results || data.web.results.length === 0) {
|
||||
return { success: false, error: 'No results found on Brave.' };
|
||||
}
|
||||
|
||||
// Try to find a Whiskybase ID in the results
|
||||
const wbRegex = /whiskybase\.com\/whiskies\/whisky\/(\d+)\//;
|
||||
|
||||
for (const result of data.web.results) {
|
||||
const url = result.url;
|
||||
const match = url.match(wbRegex);
|
||||
|
||||
if (match && match[1]) {
|
||||
return {
|
||||
success: true,
|
||||
id: match[1],
|
||||
url: url,
|
||||
title: result.title
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { success: false, error: 'No valid Whiskybase ID found in results.' };
|
||||
} catch (error) {
|
||||
console.error('Brave Search Error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error during Brave search.'
|
||||
};
|
||||
}
|
||||
}
|
||||
87
src/services/magic-scan.ts
Normal file
87
src/services/magic-scan.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
'use server';
|
||||
|
||||
import { analyzeBottle } from './analyze-bottle';
|
||||
import { analyzeBottleNebius } from './analyze-bottle-nebius';
|
||||
import { searchBraveForWhiskybase } from './brave-search';
|
||||
import { supabase } from '@/lib/supabase';
|
||||
import { supabaseAdmin } from '@/lib/supabase-admin';
|
||||
import { AnalysisResponse, BottleMetadata } from '@/types/whisky';
|
||||
|
||||
export async function magicScan(base64Image: string, provider: 'gemini' | 'nebius' = 'gemini'): Promise<AnalysisResponse & { wb_id?: string }> {
|
||||
try {
|
||||
// 1. AI Analysis
|
||||
let aiResponse: any;
|
||||
if (provider === 'nebius') {
|
||||
aiResponse = await analyzeBottleNebius(base64Image);
|
||||
} else {
|
||||
aiResponse = await analyzeBottle(base64Image);
|
||||
}
|
||||
|
||||
if (!aiResponse.success || !aiResponse.data) {
|
||||
return aiResponse;
|
||||
}
|
||||
|
||||
const data: BottleMetadata = aiResponse.data;
|
||||
const searchString = aiResponse.search_string || `${data.distillery || ''} ${data.name || ''} ${data.vintage || data.age || ''}`.trim();
|
||||
|
||||
if (!searchString) {
|
||||
return { ...aiResponse, wb_id: undefined };
|
||||
}
|
||||
|
||||
// 2. DB Cache Check (global_products)
|
||||
// We use the regular supabase client for reading
|
||||
const { data: cacheHit } = await supabase
|
||||
.from('global_products')
|
||||
.select('wb_id')
|
||||
.textSearch('search_vector', `'${searchString}'`, { config: 'simple' })
|
||||
.limit(1)
|
||||
.maybeSingle();
|
||||
|
||||
if (cacheHit) {
|
||||
console.log(`[Magic Scan] Cache Hit for ${searchString}: ${cacheHit.wb_id}`);
|
||||
return {
|
||||
...aiResponse,
|
||||
wb_id: cacheHit.wb_id
|
||||
};
|
||||
}
|
||||
|
||||
// 3. Fallback to Brave Search
|
||||
console.log(`[Magic Scan] Cache Miss for ${searchString}. Calling Brave...`);
|
||||
const braveResult = await searchBraveForWhiskybase(searchString);
|
||||
|
||||
if (braveResult.success && braveResult.id) {
|
||||
console.log(`[Magic Scan] Brave found ID: ${braveResult.id}`);
|
||||
|
||||
// 4. Cache Write (using Admin client)
|
||||
if (supabaseAdmin) {
|
||||
const { error: saveError } = await supabaseAdmin
|
||||
.from('global_products')
|
||||
.insert({
|
||||
wb_id: braveResult.id,
|
||||
full_name: searchString, // We save the search string as the name for future hits
|
||||
});
|
||||
|
||||
if (saveError) {
|
||||
console.warn(`[Magic Scan] Failed to save to global_products: ${saveError.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...aiResponse,
|
||||
wb_id: braveResult.id
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...aiResponse,
|
||||
wb_id: undefined
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('Magic Scan Error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Magic Scan failed.'
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ export async function saveTasting(data: {
|
||||
finish_notes?: string;
|
||||
is_sample?: boolean;
|
||||
buddy_ids?: string[];
|
||||
tag_ids?: string[];
|
||||
}) {
|
||||
const supabase = createServerActionClient({ cookies });
|
||||
|
||||
@@ -48,22 +49,38 @@ export async function saveTasting(data: {
|
||||
|
||||
// Add buddy tags if any
|
||||
if (data.buddy_ids && data.buddy_ids.length > 0) {
|
||||
const tags = data.buddy_ids.map(buddyId => ({
|
||||
const buddies = data.buddy_ids.map(buddyId => ({
|
||||
tasting_id: tasting.id,
|
||||
buddy_id: buddyId,
|
||||
user_id: session.user.id
|
||||
}));
|
||||
const { error: tagError } = await supabase
|
||||
.from('tasting_tags')
|
||||
.insert(tags);
|
||||
.from('tasting_buddies')
|
||||
.insert(buddies);
|
||||
|
||||
if (tagError) {
|
||||
console.error('Error adding tasting tags:', tagError);
|
||||
console.error('Error adding tasting buddies:', tagError);
|
||||
// We don't throw here to not fail the whole tasting save,
|
||||
// but in a real app we might want more robust error handling
|
||||
}
|
||||
}
|
||||
|
||||
// Add aroma tags if any
|
||||
if (data.tag_ids && data.tag_ids.length > 0) {
|
||||
const aromaTags = data.tag_ids.map(tagId => ({
|
||||
tasting_id: tasting.id,
|
||||
tag_id: tagId,
|
||||
user_id: session.user.id
|
||||
}));
|
||||
const { error: aromaTagError } = await supabase
|
||||
.from('tasting_tags')
|
||||
.insert(aromaTags);
|
||||
|
||||
if (aromaTagError) {
|
||||
console.error('Error adding aroma tags:', aromaTagError);
|
||||
}
|
||||
}
|
||||
|
||||
revalidatePath(`/bottles/${data.bottle_id}`);
|
||||
|
||||
return { success: true, data: tasting };
|
||||
|
||||
79
src/services/tags.ts
Normal file
79
src/services/tags.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
'use server';
|
||||
|
||||
import { createServerActionClient } from '@supabase/auth-helpers-nextjs';
|
||||
import { cookies } from 'next/headers';
|
||||
|
||||
export type TagCategory = 'nose' | 'taste' | 'finish' | 'texture';
|
||||
|
||||
export interface Tag {
|
||||
id: string;
|
||||
name: string;
|
||||
category: TagCategory;
|
||||
is_system_default: boolean;
|
||||
popularity_score: number;
|
||||
created_by?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch tags by category
|
||||
*/
|
||||
export async function getTagsByCategory(category: TagCategory): Promise<Tag[]> {
|
||||
const supabase = createServerActionClient({ cookies });
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('tags')
|
||||
.select('*')
|
||||
.eq('category', category)
|
||||
.order('popularity_score', { ascending: false })
|
||||
.order('name');
|
||||
|
||||
if (error) {
|
||||
console.error(`Error fetching tags for ${category}:`, error);
|
||||
return [];
|
||||
}
|
||||
|
||||
return data || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a custom user tag
|
||||
*/
|
||||
export async function createCustomTag(name: string, category: TagCategory): Promise<{ success: boolean; tag?: Tag; error?: string }> {
|
||||
const supabase = createServerActionClient({ cookies });
|
||||
|
||||
try {
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
if (!session) throw new Error('Nicht autorisiert');
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('tags')
|
||||
.insert({
|
||||
name,
|
||||
category,
|
||||
is_system_default: false,
|
||||
created_by: session.user.id
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
if (error.code === '23505') { // Unique constraint violation
|
||||
// Try to fetch the existing tag
|
||||
const { data: existingTag } = await supabase
|
||||
.from('tags')
|
||||
.select('*')
|
||||
.eq('name', name)
|
||||
.eq('category', category)
|
||||
.single();
|
||||
|
||||
return { success: true, tag: existingTag || undefined };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
return { success: true, tag: data };
|
||||
} catch (error) {
|
||||
console.error('Error creating custom tag:', error);
|
||||
return { success: false, error: 'Tag konnte nicht erstellt werden' };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user