From 74916aec733479f95c1b46d142085acc377a30bb Mon Sep 17 00:00:00 2001 From: robin Date: Fri, 19 Dec 2025 13:20:13 +0100 Subject: [PATCH] feat: implement AI custom tag proposals - AI now suggests dominant notes not in the system list (Part 3: Custom Suggestions) - Updated TagSelector to show 'Neu anlegen?' buttons for AI-proposed custom tags - Added suggested_custom_tags to bottles table and metadata schema - Updated TastingNoteForm to handle both system and custom AI suggestions --- .aiideas | 69 +++++++++++++++++++++++++++ add_custom_tags_to_bottles.sql | 2 + add_suggested_tags_to_bottles.sql | 2 + src/components/TagSelector.tsx | 64 ++++++++++++++++++++++++- src/components/TastingNoteForm.tsx | 22 ++++++++- src/lib/gemini.ts | 30 ++++++++---- src/services/analyze-bottle-nebius.ts | 9 ++-- src/services/analyze-bottle.ts | 14 ++---- src/services/magic-scan.ts | 9 +++- src/services/save-bottle.ts | 2 + src/services/tags.ts | 20 ++++++++ src/types/whisky.ts | 2 + 12 files changed, 216 insertions(+), 29 deletions(-) create mode 100644 .aiideas create mode 100644 add_custom_tags_to_bottles.sql create mode 100644 add_suggested_tags_to_bottles.sql diff --git a/.aiideas b/.aiideas new file mode 100644 index 0000000..5a5b008 --- /dev/null +++ b/.aiideas @@ -0,0 +1,69 @@ +1. Feature: Tasting Notes Auto-Fill (Die "Tag-Matching" Strategie) + +Du hast völlig recht: Wenn Gemini einfach wild Text generiert (z.B. "Grüner Granny Smith Apfel"), und deine Datenbank nur "Apfel" kennt, hast du Chaos. + +Die Lösung: "Constrained Generation" (Gezwungene Auswahl) + +Du fütterst Gemini nicht nur mit dem Bild, sondern auch mit deiner Master-Liste an Tags im Prompt. +Der Workflow: + + Input: Bild vom Label + Deine Liste der System Tags (als JSON-String). + + + + Frontend: + + Die App empfängt die IDs. + + In der UI werden diese Tags aktiviert/vorausgewählt angezeigt (z.B. farbig hinterlegt). + + Der User sieht: "Vorschlag: Rauch, Vanille". + + Wichtig: Der User kann sie abwählen (wenn er es nicht schmeckt) oder andere aus der Liste hinzufügen. + +Das ist der "Sweet Spot". Wir kombinieren die harte Fakten-Extraktion (Metadata) mit der "halluzinierten" aber kontrollierten Sensorik (Tags). + +Hier ist dein "Master Prompt", der beides erledigt. +Das Konzept der "Constrained Generation" + +Wichtig: Damit Gemini nicht irgendwelche Wörter erfindet, müssen wir ihm deine Tag-Liste im Prompt mitgeben. Ich habe im Prompt einen Platzhalter {AVAILABLE_TAGS_JSON} eingefügt. Diesen musst du in deinem Code (Next.js API Route oder Edge Function) mit deiner echten Tag-Liste ersetzen, bevor du den String an Gemini schickst. +Der Prompt (Copy & Paste) + +You are a master sommelier and strict database clerk. +Your task is to analyze the whisky bottle image provided. + +PART 1: METADATA EXTRACTION +Extract precise metadata from the visible label text. +- If the image is NOT a whisky bottle or if you are very unsure, set "is_whisky" to false and provide a low "confidence" score. +- If a value is not visible, use null. +- Infer the 'Category' (e.g., Islay Single Malt, Bourbon, Rye) based on the Distillery if possible. +- Search specifically for a "Whiskybase ID" or "WB ID" on the label (often handwritten or small print). +- Search for "Bottle Codes" (Laser codes often on the glass). + +PART 2: SENSORY ANALYSIS (AUTO-FILL) +Based on the identified bottle (using your internal knowledge about this specific release/distillery), select the most appropriate flavor tags. +CONSTRAINT: You must ONLY select tags from the following provided list. Do NOT invent new tags. +If you recognize the whisky, try to select 3-6 tags that best describe its character. + +AVAILABLE TAGS LIST: +{AVAILABLE_TAGS_JSON} + +PART 3: OUTPUT +Output strictly raw JSON matching the following schema (no markdown, no code blocks): + +{ + "name": string | null, + "distillery": string | null, + "category": string | null, + "abv": number | null, + "age": number | null, + "vintage": string | null, + "bottleCode": string | null, + "whiskybaseId": string | null, + "distilled_at": string | null, + "bottled_at": string | null, + "batch_info": string | null, + "is_whisky": boolean, + "confidence": number, + "suggested_tags": string[] +} \ No newline at end of file diff --git a/add_custom_tags_to_bottles.sql b/add_custom_tags_to_bottles.sql new file mode 100644 index 0000000..e43d399 --- /dev/null +++ b/add_custom_tags_to_bottles.sql @@ -0,0 +1,2 @@ +-- Add suggested_custom_tags to bottles table +ALTER TABLE bottles ADD COLUMN IF NOT EXISTS suggested_custom_tags text[]; diff --git a/add_suggested_tags_to_bottles.sql b/add_suggested_tags_to_bottles.sql new file mode 100644 index 0000000..c831eda --- /dev/null +++ b/add_suggested_tags_to_bottles.sql @@ -0,0 +1,2 @@ +-- Add suggested_tags to bottles table +ALTER TABLE bottles ADD COLUMN IF NOT EXISTS suggested_tags text[]; diff --git a/src/components/TagSelector.tsx b/src/components/TagSelector.tsx index a8d4877..d9c7115 100644 --- a/src/components/TagSelector.tsx +++ b/src/components/TagSelector.tsx @@ -10,14 +10,17 @@ interface TagSelectorProps { selectedTagIds: string[]; onToggleTag: (tagId: string) => void; label?: string; + suggestedTagNames?: string[]; + suggestedCustomTagNames?: string[]; } -export default function TagSelector({ category, selectedTagIds, onToggleTag, label }: TagSelectorProps) { +export default function TagSelector({ category, selectedTagIds, onToggleTag, label, suggestedTagNames, suggestedCustomTagNames }: TagSelectorProps) { const { t } = useI18n(); const [tags, setTags] = useState([]); const [search, setSearch] = useState(''); const [isLoading, setIsLoading] = useState(true); const [isCreating, setIsCreating] = useState(false); + const [creatingSuggestion, setCreatingSuggestion] = useState(null); useEffect(() => { const fetchTags = async () => { @@ -128,11 +131,68 @@ export default function TagSelector({ category, selectedTagIds, onToggleTag, lab )} + {/* AI Suggestions */} + {!search && suggestedTagNames && suggestedTagNames.length > 0 && ( +
+
+ {t('camera.wbMatchFound') ? 'KI Vorschläge' : 'AI Suggestions'} +
+
+ {tags + .filter(t => !selectedTagIds.includes(t.id) && suggestedTagNames.some((s: string) => s.toLowerCase() === t.name.toLowerCase())) + .map(tag => ( + + ))} +
+
+ )} + + {/* AI Custom Suggestions */} + {!search && suggestedCustomTagNames && suggestedCustomTagNames.length > 0 && ( +
+
+ Dominante Note anlegen? +
+
+ {suggestedCustomTagNames + .filter(name => !tags.some(t => t.name.toLowerCase() === name.toLowerCase())) + .map(name => ( + + ))} +
+
+ )} + {/* Suggestions Chips (limit to 6 random or most common) */} {!search && tags.length > 0 && (
{tags - .filter(t => !selectedTagIds.includes(t.id)) + .filter(t => !selectedTagIds.includes(t.id) && (!suggestedTagNames || !suggestedTagNames.some((s: string) => s.toLowerCase() === t.name.toLowerCase()))) .slice(0, 8) .map(tag => (