diff --git a/ka-note/VERSION b/ka-note/VERSION index b794c44..3118341 100644 --- a/ka-note/VERSION +++ b/ka-note/VERSION @@ -1 +1 @@ -1.2.24 \ No newline at end of file +1.2.25 \ No newline at end of file diff --git a/ka-note/client/src/lib/services/visionService.ts b/ka-note/client/src/lib/services/visionService.ts index df97762..b87c43f 100644 --- a/ka-note/client/src/lib/services/visionService.ts +++ b/ka-note/client/src/lib/services/visionService.ts @@ -67,6 +67,23 @@ export async function recognizePhoto(blob: Blob): Promise { } } +export interface EnrichResult { + brand: string; + model: string; + estimatedNewPrice: number | null; + estimatedUsedPrice: number | null; +} + +export async function enrichAsset(label: string, brand?: string | null, model?: string | null): Promise { + const res = await authFetch(`${API_BASE}/api/vision/enrich`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ label, brand: brand ?? undefined, model: model ?? undefined }), + }); + if (!res.ok) throw new Error(`Enrich failed: ${res.status}`); + return res.json() as Promise; +} + export async function recognizeBarcode(ean: string): Promise { const token = await getAccessToken(); const res = await fetch(`${API_BASE}/api/vision/barcode`, { diff --git a/ka-note/client/src/routes/inventory/[id]/+page.svelte b/ka-note/client/src/routes/inventory/[id]/+page.svelte index 319151f..dd2b5e2 100644 --- a/ka-note/client/src/routes/inventory/[id]/+page.svelte +++ b/ka-note/client/src/routes/inventory/[id]/+page.svelte @@ -11,11 +11,11 @@ import { getImageUrl } from '$lib/db/imageStore'; import MarkdownEditor from '$lib/components/MarkdownEditor.svelte'; import ConfirmDialog from '$lib/components/ConfirmDialog.svelte'; - import { ChevronLeft, Package, Plus, Trash2, ScanSearch } from 'lucide-svelte'; + import { ChevronLeft, Package, Plus, Trash2, ScanSearch, Sparkles } from 'lucide-svelte'; import DarkSelect from '$lib/components/DarkSelect.svelte'; import LabelConfirm from '$lib/components/LabelConfirm.svelte'; - import { recognizePhoto } from '$lib/services/visionService'; - import type { RecognizeResult } from '$lib/services/visionService'; + import { recognizePhoto, enrichAsset } from '$lib/services/visionService'; + import type { RecognizeResult, EnrichResult } from '$lib/services/visionService'; import type { Asset, AssetImage, AssetPerson } from '@ka-note/shared'; import type { AgendaContext } from '@ka-note/shared'; @@ -33,6 +33,9 @@ let recognizing = $state(false); let recognizeResult = $state(null); let recognizeError = $state(''); + let enriching = $state(false); + let enrichResult = $state(null); + let enrichError = $state(''); const CATEGORIES = [ '', 'Elektronik', 'Möbel', 'Kleidung', 'Schmuck', 'Kunstwerke', @@ -102,6 +105,30 @@ await save({ title: label, category: category || null }); } + async function runEnrich() { + if (!asset?.title) return; + enriching = true; + enrichError = ''; + enrichResult = null; + try { + enrichResult = await enrichAsset(asset.title, asset.brand, asset.model); + } catch { + enrichError = 'Preisschätzung fehlgeschlagen.'; + } finally { + enriching = false; + } + } + + async function applyEnrichResult() { + if (!enrichResult) return; + const changes: Parameters[0] = {}; + if (enrichResult.brand) changes.brand = enrichResult.brand; + if (enrichResult.model) changes.model = enrichResult.model; + if (enrichResult.estimatedNewPrice !== null) changes.purchasePrice = enrichResult.estimatedNewPrice; + await save(changes); + enrichResult = null; + } + async function toggleStatus() { if (!asset) return; await save({ status: asset.status === 'draft' ? 'complete' : 'draft' }); @@ -261,8 +288,48 @@ {/if} {/if} + + {#if asset.title} + + {/if} + {#if enrichError} +
+ ⚠ {enrichError} +
+ {/if} + + {#if enrichResult} +
+

Preisschätzung (KI):

+
+ {#if enrichResult.brand}
Marke
{enrichResult.brand}
{/if} + {#if enrichResult.model}
Modell
{enrichResult.model}
{/if} + {#if enrichResult.estimatedNewPrice !== null}
Neupreis
{enrichResult.estimatedNewPrice.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}
{/if} + {#if enrichResult.estimatedUsedPrice !== null}
Gebraucht (KA)
{enrichResult.estimatedUsedPrice.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}
{/if} +
+
+ + +
+
+ {/if} + {#if recognizeError}
⚠ {recognizeError} diff --git a/ka-note/server/ka-note.db-shm b/ka-note/server/ka-note.db-shm index 95b6ce0..0266d9e 100644 Binary files a/ka-note/server/ka-note.db-shm and b/ka-note/server/ka-note.db-shm differ diff --git a/ka-note/server/ka-note.db-wal b/ka-note/server/ka-note.db-wal index 3ac992c..27a5615 100644 Binary files a/ka-note/server/ka-note.db-wal and b/ka-note/server/ka-note.db-wal differ diff --git a/ka-note/server/src/lib/vision-service.ts b/ka-note/server/src/lib/vision-service.ts index 1817ebf..6f99685 100644 --- a/ka-note/server/src/lib/vision-service.ts +++ b/ka-note/server/src/lib/vision-service.ts @@ -79,7 +79,7 @@ class GeminiVisionProvider implements VisionProvider { { inline_data: { mime_type: mime, data: base64 } }, ], }], - generationConfig: { maxOutputTokens: 200 }, + generationConfig: { maxOutputTokens: 1024, thinkingConfig: { thinkingBudget: 0 } }, }), }); if (!res.ok) throw new Error(`Gemini error ${res.status}: ${await res.text()}`); @@ -91,6 +91,81 @@ class GeminiVisionProvider implements VisionProvider { } } +const ENRICH_PROMPT = (label: string, brand: string | undefined, model: string | undefined) => + `You are a product valuation assistant. For the item described below, return ONLY valid JSON with no markdown: +{ "brand": "", "model": "", "estimatedNewPrice": , "estimatedUsedPrice": } + +Rules: +- estimatedNewPrice: current retail price in EUR (null if unknown or not applicable) +- estimatedUsedPrice: typical used/Kleinanzeigen price in EUR (null if unknown or not applicable) +- Use your training knowledge; do not invent prices for obscure items +- Return null for prices if genuinely uncertain + +Item: "${label}"${brand ? `\nBrand hint: "${brand}"` : ''}${model ? `\nModel hint: "${model}"` : ''}`; + +export interface EnrichResult { + brand: string; + model: string; + estimatedNewPrice: number | null; + estimatedUsedPrice: number | null; +} + +function parseEnrichResponse(text: string): EnrichResult { + const cleaned = text.replace(/```json\s*/g, '').replace(/```\s*/g, '').trim(); + const parsed = JSON.parse(cleaned); + return { + brand: String(parsed.brand ?? ''), + model: String(parsed.model ?? ''), + estimatedNewPrice: typeof parsed.estimatedNewPrice === 'number' ? parsed.estimatedNewPrice : null, + estimatedUsedPrice: typeof parsed.estimatedUsedPrice === 'number' ? parsed.estimatedUsedPrice : null, + }; +} + +export async function enrichItem( + label: string, + brand: string | undefined, + model: string | undefined, + providerName: string, + apiKey: string, +): Promise { + const prompt = ENRICH_PROMPT(label, brand, model); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 15000); + try { + if (providerName === 'gemini') { + const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-3-flash-preview:generateContent?key=${apiKey}`; + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + signal: controller.signal, + body: JSON.stringify({ + contents: [{ parts: [{ text: prompt }] }], + generationConfig: { maxOutputTokens: 256, thinkingConfig: { thinkingBudget: 0 } }, + }), + }); + if (!res.ok) throw new Error(`Gemini error ${res.status}: ${await res.text()}`); + const data = await res.json() as { candidates: Array<{ content: { parts: Array<{ text: string }> } }> }; + return parseEnrichResponse(data.candidates[0].content.parts[0].text); + } else { + const res = await fetch('https://api.openai.com/v1/chat/completions', { + method: 'POST', + headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json' }, + signal: controller.signal, + body: JSON.stringify({ + model: 'gpt-4o-mini', + messages: [{ role: 'user', content: prompt }], + max_tokens: 256, + }), + }); + if (!res.ok) throw new Error(`OpenAI error ${res.status}: ${await res.text()}`); + const data = await res.json() as { choices: Array<{ message: { content: string } }> }; + return parseEnrichResponse(data.choices[0].message.content); + } + } finally { + clearTimeout(timer); + } +} + export function getProvider(name: string, apiKey: string): VisionProvider { if (name === 'gemini') return new GeminiVisionProvider(apiKey); return new OpenAIVisionProvider(apiKey); diff --git a/ka-note/server/src/routes/vision.ts b/ka-note/server/src/routes/vision.ts index 7d21196..17bff7b 100644 --- a/ka-note/server/src/routes/vision.ts +++ b/ka-note/server/src/routes/vision.ts @@ -2,7 +2,7 @@ import { Hono } from 'hono'; import { handle } from '../lib/route-utils.js'; import { getVisionConfig, setVisionConfig, getSetting } from '../lib/user-settings-service.js'; import { checkAndIncrement, getUsageToday } from '../lib/rate-limiter.js'; -import { getProvider } from '../lib/vision-service.js'; +import { getProvider, enrichItem } from '../lib/vision-service.js'; import { lookupBarcode } from '../lib/barcode-service.js'; import type { AuthEnv } from '../middleware/auth.js'; @@ -31,6 +31,21 @@ vision.post('/recognize', handle('vision/recognize', async (c) => { return c.json({ ...result, remaining }); })); +vision.post('/enrich', handle('vision/enrich', async (c) => { + const { userId } = c.get('auth'); + const { allowed, remaining } = await checkAndIncrement(userId, RATE_LIMIT); + if (!allowed) return c.json({ error: 'Daily vision limit reached', remaining: 0 }, 429); + + const config = await getVisionConfig(userId); + if (!config) return c.json({ error: 'Vision API key not configured. Please set it in Settings.' }, 422); + + const body = await c.req.json<{ label: string; brand?: string; model?: string }>(); + if (!body.label) return c.json({ error: 'Missing label' }, 400); + + const result = await enrichItem(body.label, body.brand, body.model, config.provider, config.apiKey); + return c.json({ ...result, remaining }); +})); + vision.post('/barcode', handle('vision/barcode', async (c) => { const { userId } = c.get('auth');