upd recognition

This commit is contained in:
beo3000 2026-03-05 20:25:30 +01:00
parent bdd1b7d67c
commit 0ed9770b27
7 changed files with 180 additions and 6 deletions

View File

@ -1 +1 @@
1.2.24
1.2.25

View File

@ -67,6 +67,23 @@ export async function recognizePhoto(blob: Blob): Promise<RecognizeResult> {
}
}
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<EnrichResult> {
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<EnrichResult>;
}
export async function recognizeBarcode(ean: string): Promise<RecognizeResult> {
const token = await getAccessToken();
const res = await fetch(`${API_BASE}/api/vision/barcode`, {

View File

@ -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<RecognizeResult | null>(null);
let recognizeError = $state('');
let enriching = $state(false);
let enrichResult = $state<EnrichResult | null>(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<typeof save>[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}
</button>
{/if}
{#if asset.title}
<button
class="flex items-center gap-2 text-sm text-muted hover:text-white transition-colors disabled:opacity-40"
onclick={runEnrich}
disabled={enriching}
>
{#if enriching}
<span class="animate-spin"></span> Schätze…
{:else}
<Sparkles size={16} /> Preise schätzen
{/if}
</button>
{/if}
</div>
{#if enrichError}
<div class="mb-4 rounded-lg bg-red-500/20 border border-red-500/40 px-3 py-2 text-xs text-red-300">
{enrichError}
</div>
{/if}
{#if enrichResult}
<div class="mb-4 rounded-xl border border-purple-500/30 bg-purple-500/10 p-3">
<p class="text-xs text-muted mb-2">Preisschätzung (KI):</p>
<div class="grid grid-cols-2 gap-2 text-sm mb-3">
{#if enrichResult.brand}<div><span class="text-muted text-xs">Marke</span><br/><span class="text-white">{enrichResult.brand}</span></div>{/if}
{#if enrichResult.model}<div><span class="text-muted text-xs">Modell</span><br/><span class="text-white">{enrichResult.model}</span></div>{/if}
{#if enrichResult.estimatedNewPrice !== null}<div><span class="text-muted text-xs">Neupreis</span><br/><span class="text-white">{enrichResult.estimatedNewPrice.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}</span></div>{/if}
{#if enrichResult.estimatedUsedPrice !== null}<div><span class="text-muted text-xs">Gebraucht (KA)</span><br/><span class="text-white">{enrichResult.estimatedUsedPrice.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}</span></div>{/if}
</div>
<div class="flex gap-2">
<button class="rounded-lg bg-purple-500/20 hover:bg-purple-500/30 px-3 py-1.5 text-xs text-purple-300 transition-colors" onclick={applyEnrichResult}>
Übernehmen
</button>
<button class="rounded-lg bg-white/5 hover:bg-white/10 px-3 py-1.5 text-xs text-muted transition-colors" onclick={() => enrichResult = null}>
Verwerfen
</button>
</div>
</div>
{/if}
{#if recognizeError}
<div class="mb-4 rounded-lg bg-red-500/20 border border-red-500/40 px-3 py-2 text-xs text-red-300">
{recognizeError}

Binary file not shown.

Binary file not shown.

View File

@ -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": "<brand name or empty string>", "model": "<model name or empty string>", "estimatedNewPrice": <number in EUR or null>, "estimatedUsedPrice": <number in EUR or null> }
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<EnrichResult> {
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);

View File

@ -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');