upd recognition
This commit is contained in:
parent
bdd1b7d67c
commit
0ed9770b27
|
|
@ -1 +1 @@
|
|||
1.2.24
|
||||
1.2.25
|
||||
|
|
@ -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`, {
|
||||
|
|
|
|||
|
|
@ -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.
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue