From 0ed9770b2708d144a010dd4d1e3825a79591d303 Mon Sep 17 00:00:00 2001 From: beo3000 Date: Thu, 5 Mar 2026 20:25:30 +0100 Subject: [PATCH] upd recognition --- ka-note/VERSION | 2 +- .../client/src/lib/services/visionService.ts | 17 ++++ .../src/routes/inventory/[id]/+page.svelte | 73 ++++++++++++++++- ka-note/server/ka-note.db-shm | Bin 32768 -> 32768 bytes ka-note/server/ka-note.db-wal | Bin 935272 -> 1746912 bytes ka-note/server/src/lib/vision-service.ts | 77 +++++++++++++++++- ka-note/server/src/routes/vision.ts | 17 +++- 7 files changed, 180 insertions(+), 6 deletions(-) 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 95b6ce089dae7b4135c5ff7cfa16ea3eb414837d..0266d9e4446bacad84b17bb1a440f1b4e034604e 100644 GIT binary patch delta 1344 zcmb7?dvwob9LL|U-*$Rrk4t^YLX4@XR7x(h$|gcTlF8gcWiz)SA(t_w`%?B`U1+6? zNr@(javLR+s9d{{F8ilMr?Z@nbL#oc*Xob{&~wh`bI#}Wet+KI=X=hRpC8H3kIYUy z)>^Ss!xvKB3g40=Qr^w9hsNbnIy`RdndXMat#K!a?>Nw_gM1C~HPqK+U+2V1PcjCV%DS|9=epv3|LJu^ z%S(^O*K1$cxu&^_Q7ob?WJwWsI=w}M3u(;_+|Fq3V#qLyhYp!7|&+$=J*6tqGwC8?aW_MLq9i?itinLw7DT%vyg7>Pk z(&)x*9O7Gkh-C#$71eYt(>_JGmT^4K$5olTqSka^GE4a^mg$muS<{T6JjuE+t1?JZ zFU?Si_A8DyXIgU;Gbmx7pU1h}0oHWl0bb*CKNq>g zf!5r}be8dDEW-|aSFltYNrvJuTA<{4asCKtG!s{>SkGUIU|_Q#+cc^wWv$UwkDNnyv`Rf zPp!dPs|&c9{T$$!CzFDjYOgF6YO{V(BL?s=?^LDL&atK?SroDS-_SUjD0sqPzPnpX^W1lF#~yo(okFK)=WsO(}0fjV+wP4g_Z0HwSvH^ zqeg0hw(3`1!XO@H&Dp)6mHKP0R_ll?DNN;6_Qtd`H(NEunndp8LEa7*qVDiiYXbU^ z#~UH5bStM>b20sSh*j0TgVU|4#~sXKMHtwV!aQrPB!`7;^{gz2R~rpef!3=+_36uO K-mLaU8tHE*Wrm^v delta 656 zcmb7=OK4L;6o$W~))W+i*0f2paZw~itky1SrIJD`MWqh}1+lRz)GT~pYc-3y z5ZsutD){J9iy+Ds7cP7YF%{8;PXs|1F4P)Z!6F5nxyiz<19Q&dod5j)Kab_hL?kPzz{cfH59Y@Dk1wp?|lzI_Bi{ z^9?p9RV8cL%^4o?qX?Ky76H%Ni-3hRvxl=hX4(g+8af!_GB1%j>P+gV9X?RkcDhD9 zCsZw+40DB71iYwIBXp#zG>Nj2BTVp`GJiF<$<+-`Qde&-0(4=EYkWt9UeawR5miSQ zN4d&dDh$`S&{I8DZNDDuwfT|`_t}j?xmv_J_Hltczl_frsTR{pnu|;_YZ~oR&1V%m zImKPR>Vz|{L;Y6Oh&82ruJWziqRJQ>RzVu+C(Av)`OwM%3pF3>?kyF;!Q!$~tR{u# zK2t{KfY3M7VdE$zl*<}+aheBw_a|yO-HdXB54h$rGg4naSaR_0kS%gTUprjtycx1Z Ywakv#kI1Vs5^U!rxA|4@6qC(W52q7whc+ox6gNOkg@HP(U#6(>Y zML-3Kf{IFvm?4x`jH@P5@j&rH6N5y&07I0ZF=DohX(AY-xX(VjKD*3+-@H}bUv<4z z^-a(3_A%LX}R-RtaZ)dM8ZR(y+Fw@Q!e7ccY& zHP`L9Y4(&>Vac!q)qy_gR(D*NAX>RLGz`~ z>i$D=jls~v2W)%@RY|S-$VPlt!gF3JBcNBNhTaJ-9yu-t6nBJ1$hyn6KRkHO5Rg9_ z+9V}-AJwmVxO1Tziha|b`W;KvWu$Jj(}OGSGTs&O^?tk3zCTzGurSy{RCt?K7mmFK zirwt^u&89<0B*s34n)j>>f;-7ybx!*6#7E3e2FW!%pz_ANL~xYO5@s$C)?#80*tY{ ziHI%ISMIUwC!>CQs~nkBi3u!RNiw9qRX0KG2(j<@^P9ca8*We~2m)9DaW3 z-QyOd&rMST#*1rC9LT7;b!UKJ@G~=F?aIQVrj_hT=G%Jw696TzwyGl-lzlvZuIzME{lZ0X810r5opj4hz~J z2a0<@^-1iAEp2T&0}wGEs-Ns7DhyR^tU%2pezpB-D6hq zrmT6VjtFjc+3fOQ{NaisAYTL3$nWzVON^0TAb*044;9@_Q*OMs`&%%$0IDCpYmxi= z;g4;h*QN6INg|P?N2sz^hJKKG)^qW(iU~`L_Y2;(gx+>`7mpM1pT;)!*{N-;hm)i1 z+sqSL+d7L7y+eV0oxR%D*>1P(eUrGGrL7@xjAh%9E+L`aI>Mg(C&Wi2601lJ!RZJR zRVb7=sw6QMrPVBr(kxDrDmAHNR9Y=J3!NFq>1mwdztHMXR?Tv#lF(o%gVPF>&@pNT zQ^798$k@oJcuq6LEilZ@H)ihaDV&~jBisTQ1ItaD78}R81u`?^hEC(6>Aqe_le^B3kbAy}=FA}O^NBegh&^E!cIwq186=me3lxGMyW)#8cK;O8BT+;xK4@E8je*d2py?Z4-Kp9-_Z4F5il zy?7+AaFXG#ti-!Xq3}=5 zBzj9(#W0j)bR?>z zG&ri(aX3n7af4E!Qu6nqu;%&Wv=>2pV7LP2FL^=!YL3+^I34$QfXineYb{!L?Z=Mc@Lo-tWat5Ih zYa|-k;Bdhq2V5U5S>a$%ScG&D|7r7l$=+$*(BpM>E31YcFFtq-FFtuJ8@jyCLbNd7 z#O(bRSzii!7{sq*Hwngfv1y#)mP{}|DHEDZ%Gp_u=tQSrx2+9xf>0s{y|dOYH34D*wE0Ddt0>hlG2tol=P8?*uYx{ zy{Z)CpK7xrb?0QwngH%P_5W0)5|jr{Vc|BdMQV$FJ+v07fAM>#wMcC(Qu&{o4OQR& zbdfs7SA0Wb`Xr*YNPXe&v;RtwnwE57jBZ5z;s0onDx92eM3JhqS#i{3!9$BTsz}}E z9DCXF^xBNpBK6O2A^-Iv)hA)l$-umVod2I9mGUB#9+a90w{0y_TZ>fqQoHp{YU`WS zm%LnPeUs{gUM@&^WIdy`NPTmP)a>?`lbwby&HWD+sehcDZ$yzAc(B6%7Mc_IMir^Y z?+?FmVp~dBYmxerBK4Jjs`Kx_#qdRVjjcGL6|1O6nvu7>P{;KzNIGB{pPx$ zp#}(W#ewm?q#F>K^Pta`#($wPnkkpw-hugbJ$x!4*TVOgIR%y%f{!%-3xSB#-iY$6 zw`$V50XGTcNjgr>JX~SwY9Vl2!T8K$K{wm|WahT|#y@QP>!8i21#WAS&F8d}Z|OaO z+seJPeAPFx(KZ6NHKE^-a(X>7Rp7RE&N!TR#i3K2z-=A6lC<4r#(@?$r)BOEWeD8XC6AyW*SEvB3*45YY^+^ccr*{{G;mvC(si?)S6uu>;IsL)_GTX-Oz0R#{#!iot3nDVW*z^1a51qrGNVgJ;uKGoJGu(OSb90{w%U1OyIUQ zBD3_efufz1>JN{wNEgfR zx&?UKJyr?aR?5f0*N)8T>?d$rV$E6O;?TvX&D_>HRr*JZqE^m;+?GV%p}DQ=92Xoc zPYZB2=e)ygYaT0X;1a*|$Xgn9J7XZvYc*nt=;Q0A6Gcz^2(JG2aP%+7c4n;>u*7L$ zcfcCE`;P=ypFCf3$ujoG+LZj@Mk+C8=URu3-TPiJoBBaio@_>Cwx$V{=#^#FFL1p< zAyA29eY*IURXfx*r4pC_zU{~9$Jb3+ZRqv5k>GMw)z?~F zVe-ucmt$pM(!II8(*%O+N3S*2J^lUd%mi1RR&O1&uP#6!xE7=h{W*5AXHS9PS~%4) zq->R8kU(&qICt*zsO$r41%hir_P&ujN(veYu9U#V>{6~@gg|h`2W5Pu-*x(gKydZh zME_#jm)I?syGO92q9Vw%tC`?BGV0x*hjc$0C=gr`og!AwR)4~yNaG2cgCPawE%W_^)A&`JM+KyZ~_X}{r! zb>>xp;PUqEd3Du-hxr1*RkW=(V0X5Bzd&$J$&#?wbxS8b=M%HHQ}=zOD_tJiNN~+l zM&%UsF1aswdq9^&7xL7I8D?*fnHpDi#Bcep={&*J2qk8Q&8&D@V0v3HxA*7|a_9Ya rG*7^|woFW&mAt2Zo`7);?>qNEQpS-;0poI}zv}OA=W3e^k;Q)kCui>v delta 37 scmaEGKI=u7WkU;N3sVbo3rh=Y3tJ0&3r7oQ3s(zw3r`F07QPHu02ICq!vFvP 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');