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:
2025-12-19 12:58:44 +01:00
parent 9eb9b41061
commit b2a1d292da
30 changed files with 2420 additions and 194 deletions

View 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.',
};
}
}

View 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.'
};
}
}

View 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.'
};
}
}

View File

@@ -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
View 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' };
}
}