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> {
|
export async function recognizeBarcode(ean: string): Promise<RecognizeResult> {
|
||||||
const token = await getAccessToken();
|
const token = await getAccessToken();
|
||||||
const res = await fetch(`${API_BASE}/api/vision/barcode`, {
|
const res = await fetch(`${API_BASE}/api/vision/barcode`, {
|
||||||
|
|
|
||||||
|
|
@ -11,11 +11,11 @@
|
||||||
import { getImageUrl } from '$lib/db/imageStore';
|
import { getImageUrl } from '$lib/db/imageStore';
|
||||||
import MarkdownEditor from '$lib/components/MarkdownEditor.svelte';
|
import MarkdownEditor from '$lib/components/MarkdownEditor.svelte';
|
||||||
import ConfirmDialog from '$lib/components/ConfirmDialog.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 DarkSelect from '$lib/components/DarkSelect.svelte';
|
||||||
import LabelConfirm from '$lib/components/LabelConfirm.svelte';
|
import LabelConfirm from '$lib/components/LabelConfirm.svelte';
|
||||||
import { recognizePhoto } from '$lib/services/visionService';
|
import { recognizePhoto, enrichAsset } from '$lib/services/visionService';
|
||||||
import type { RecognizeResult } from '$lib/services/visionService';
|
import type { RecognizeResult, EnrichResult } from '$lib/services/visionService';
|
||||||
import type { Asset, AssetImage, AssetPerson } from '@ka-note/shared';
|
import type { Asset, AssetImage, AssetPerson } from '@ka-note/shared';
|
||||||
import type { AgendaContext } from '@ka-note/shared';
|
import type { AgendaContext } from '@ka-note/shared';
|
||||||
|
|
||||||
|
|
@ -33,6 +33,9 @@
|
||||||
let recognizing = $state(false);
|
let recognizing = $state(false);
|
||||||
let recognizeResult = $state<RecognizeResult | null>(null);
|
let recognizeResult = $state<RecognizeResult | null>(null);
|
||||||
let recognizeError = $state('');
|
let recognizeError = $state('');
|
||||||
|
let enriching = $state(false);
|
||||||
|
let enrichResult = $state<EnrichResult | null>(null);
|
||||||
|
let enrichError = $state('');
|
||||||
|
|
||||||
const CATEGORIES = [
|
const CATEGORIES = [
|
||||||
'', 'Elektronik', 'Möbel', 'Kleidung', 'Schmuck', 'Kunstwerke',
|
'', 'Elektronik', 'Möbel', 'Kleidung', 'Schmuck', 'Kunstwerke',
|
||||||
|
|
@ -102,6 +105,30 @@
|
||||||
await save({ title: label, category: category || null });
|
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() {
|
async function toggleStatus() {
|
||||||
if (!asset) return;
|
if (!asset) return;
|
||||||
await save({ status: asset.status === 'draft' ? 'complete' : 'draft' });
|
await save({ status: asset.status === 'draft' ? 'complete' : 'draft' });
|
||||||
|
|
@ -261,8 +288,48 @@
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/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>
|
</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}
|
{#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">
|
<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}
|
⚠ {recognizeError}
|
||||||
|
|
|
||||||
Binary file not shown.
Binary file not shown.
|
|
@ -79,7 +79,7 @@ class GeminiVisionProvider implements VisionProvider {
|
||||||
{ inline_data: { mime_type: mime, data: base64 } },
|
{ 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()}`);
|
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 {
|
export function getProvider(name: string, apiKey: string): VisionProvider {
|
||||||
if (name === 'gemini') return new GeminiVisionProvider(apiKey);
|
if (name === 'gemini') return new GeminiVisionProvider(apiKey);
|
||||||
return new OpenAIVisionProvider(apiKey);
|
return new OpenAIVisionProvider(apiKey);
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { Hono } from 'hono';
|
||||||
import { handle } from '../lib/route-utils.js';
|
import { handle } from '../lib/route-utils.js';
|
||||||
import { getVisionConfig, setVisionConfig, getSetting } from '../lib/user-settings-service.js';
|
import { getVisionConfig, setVisionConfig, getSetting } from '../lib/user-settings-service.js';
|
||||||
import { checkAndIncrement, getUsageToday } from '../lib/rate-limiter.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 { lookupBarcode } from '../lib/barcode-service.js';
|
||||||
import type { AuthEnv } from '../middleware/auth.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 });
|
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) => {
|
vision.post('/barcode', handle('vision/barcode', async (c) => {
|
||||||
const { userId } = c.get('auth');
|
const { userId } = c.get('auth');
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue