Compare commits
8 Commits
v1.3.0
...
d9490c8073
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9490c8073 | ||
|
|
0373dc32da | ||
|
|
272a07777e | ||
|
|
efdcace892 | ||
|
|
fb7c2f0e9b | ||
|
|
33ee6fbf2e | ||
|
|
e2713913e7 | ||
|
|
3bc7fa16e2 |
@@ -17,9 +17,10 @@ services:
|
|||||||
- GEMINI_API_KEY=${GEMINI_API_KEY:-}
|
- GEMINI_API_KEY=${GEMINI_API_KEY:-}
|
||||||
- GEMINI_MODEL=${GEMINI_MODEL:-gemini-2.5-flash}
|
- GEMINI_MODEL=${GEMINI_MODEL:-gemini-2.5-flash}
|
||||||
- GEMINI_TIMEOUT_MS=${GEMINI_TIMEOUT_MS:-20000}
|
- GEMINI_TIMEOUT_MS=${GEMINI_TIMEOUT_MS:-20000}
|
||||||
# adapter-node-Default ist 512 KB; Rezept-Fotos koennen bis 8 MB sein.
|
# adapter-node-Default ist 512 KB. Tablet- und iPad-Pro-Kameras liefern
|
||||||
# Multipart-Overhead einrechnen -> 10 MB gibt etwas Puffer.
|
# JPEGs/HEICs bis 15 MB. Endpoint-Limit ist 20 MB; hier 25 MB fuer den
|
||||||
- BODY_SIZE_LIMIT=10000000
|
# Multipart-Overhead.
|
||||||
|
- BODY_SIZE_LIMIT=25000000
|
||||||
depends_on:
|
depends_on:
|
||||||
- searxng
|
- searxng
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export type SearchStoreOptions = {
|
|||||||
debounceMs?: number;
|
debounceMs?: number;
|
||||||
filterDebounceMs?: number;
|
filterDebounceMs?: number;
|
||||||
minQueryLength?: number;
|
minQueryLength?: number;
|
||||||
filterParam?: () => string;
|
webFilterParam?: () => string;
|
||||||
fetchImpl?: typeof fetch;
|
fetchImpl?: typeof fetch;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -38,7 +38,7 @@ export class SearchStore {
|
|||||||
private readonly debounceMs: number;
|
private readonly debounceMs: number;
|
||||||
private readonly filterDebounceMs: number;
|
private readonly filterDebounceMs: number;
|
||||||
private readonly minQueryLength: number;
|
private readonly minQueryLength: number;
|
||||||
private readonly filterParam: () => string;
|
private readonly webFilterParam: () => string;
|
||||||
private readonly fetchImpl: typeof fetch;
|
private readonly fetchImpl: typeof fetch;
|
||||||
private debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
private debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
private skipNextDebounce = false;
|
private skipNextDebounce = false;
|
||||||
@@ -48,7 +48,7 @@ export class SearchStore {
|
|||||||
this.debounceMs = opts.debounceMs ?? 300;
|
this.debounceMs = opts.debounceMs ?? 300;
|
||||||
this.filterDebounceMs = opts.filterDebounceMs ?? 150;
|
this.filterDebounceMs = opts.filterDebounceMs ?? 150;
|
||||||
this.minQueryLength = opts.minQueryLength ?? 4;
|
this.minQueryLength = opts.minQueryLength ?? 4;
|
||||||
this.filterParam = opts.filterParam ?? (() => '');
|
this.webFilterParam = opts.webFilterParam ?? (() => '');
|
||||||
this.fetchImpl = opts.fetchImpl ?? ((...a) => fetch(...a));
|
this.fetchImpl = opts.fetchImpl ?? ((...a) => fetch(...a));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,7 +80,7 @@ export class SearchStore {
|
|||||||
this.webExhausted = false;
|
this.webExhausted = false;
|
||||||
try {
|
try {
|
||||||
const res = await this.fetchImpl(
|
const res = await this.fetchImpl(
|
||||||
`/api/recipes/search?q=${encodeURIComponent(q)}&limit=${this.pageSize}${this.filterParam()}`
|
`/api/recipes/search?q=${encodeURIComponent(q)}&limit=${this.pageSize}`
|
||||||
);
|
);
|
||||||
const body = (await res.json()) as { hits: SearchHit[] };
|
const body = (await res.json()) as { hits: SearchHit[] };
|
||||||
if (this.query.trim() !== q) return;
|
if (this.query.trim() !== q) return;
|
||||||
@@ -99,7 +99,7 @@ export class SearchStore {
|
|||||||
this.webSearching = true;
|
this.webSearching = true;
|
||||||
try {
|
try {
|
||||||
const res = await this.fetchImpl(
|
const res = await this.fetchImpl(
|
||||||
`/api/recipes/search/web?q=${encodeURIComponent(q)}&pageno=${pageno}${this.filterParam()}`
|
`/api/recipes/search/web?q=${encodeURIComponent(q)}&pageno=${pageno}${this.webFilterParam()}`
|
||||||
);
|
);
|
||||||
if (this.query.trim() !== q) return;
|
if (this.query.trim() !== q) return;
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -125,7 +125,7 @@ export class SearchStore {
|
|||||||
try {
|
try {
|
||||||
if (!this.localExhausted) {
|
if (!this.localExhausted) {
|
||||||
const res = await this.fetchImpl(
|
const res = await this.fetchImpl(
|
||||||
`/api/recipes/search?q=${encodeURIComponent(q)}&limit=${this.pageSize}&offset=${this.hits.length}${this.filterParam()}`
|
`/api/recipes/search?q=${encodeURIComponent(q)}&limit=${this.pageSize}&offset=${this.hits.length}`
|
||||||
);
|
);
|
||||||
const body = (await res.json()) as { hits: SearchHit[] };
|
const body = (await res.json()) as { hits: SearchHit[] };
|
||||||
if (this.query.trim() !== q) return;
|
if (this.query.trim() !== q) return;
|
||||||
@@ -140,7 +140,7 @@ export class SearchStore {
|
|||||||
if (wasEmpty) this.webSearching = true;
|
if (wasEmpty) this.webSearching = true;
|
||||||
try {
|
try {
|
||||||
const res = await this.fetchImpl(
|
const res = await this.fetchImpl(
|
||||||
`/api/recipes/search/web?q=${encodeURIComponent(q)}&pageno=${nextPage}${this.filterParam()}`
|
`/api/recipes/search/web?q=${encodeURIComponent(q)}&pageno=${nextPage}${this.webFilterParam()}`
|
||||||
);
|
);
|
||||||
if (this.query.trim() !== q) return;
|
if (this.query.trim() !== q) return;
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { GoogleGenerativeAI } from '@google/generative-ai';
|
|||||||
import { env } from '$env/dynamic/private';
|
import { env } from '$env/dynamic/private';
|
||||||
import {
|
import {
|
||||||
RECIPE_EXTRACTION_SYSTEM_PROMPT,
|
RECIPE_EXTRACTION_SYSTEM_PROMPT,
|
||||||
|
RECIPE_EXTRACTION_USER_PROMPT,
|
||||||
GEMINI_RESPONSE_SCHEMA,
|
GEMINI_RESPONSE_SCHEMA,
|
||||||
extractionResponseSchema,
|
extractionResponseSchema,
|
||||||
type ExtractionResponse
|
type ExtractionResponse
|
||||||
@@ -84,7 +85,10 @@ async function callGemini(
|
|||||||
|
|
||||||
const parts: Array<
|
const parts: Array<
|
||||||
{ inlineData: { data: string; mimeType: string } } | { text: string }
|
{ inlineData: { data: string; mimeType: string } } | { text: string }
|
||||||
> = [{ inlineData: { data: imageBuffer.toString('base64'), mimeType } }];
|
> = [
|
||||||
|
{ inlineData: { data: imageBuffer.toString('base64'), mimeType } },
|
||||||
|
{ text: RECIPE_EXTRACTION_USER_PROMPT }
|
||||||
|
];
|
||||||
if (appendUserNote) parts.push({ text: appendUserNote });
|
if (appendUserNote) parts.push({ text: appendUserNote });
|
||||||
|
|
||||||
const result = await withTimeout(
|
const result = await withTimeout(
|
||||||
@@ -114,6 +118,7 @@ export async function extractRecipeFromImage(
|
|||||||
imageBuffer: Buffer,
|
imageBuffer: Buffer,
|
||||||
mimeType: string
|
mimeType: string
|
||||||
): Promise<ExtractionResponse> {
|
): Promise<ExtractionResponse> {
|
||||||
|
let firstMsg: string | null = null;
|
||||||
try {
|
try {
|
||||||
return await callGemini(imageBuffer, mimeType);
|
return await callGemini(imageBuffer, mimeType);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -132,6 +137,9 @@ export async function extractRecipeFromImage(
|
|||||||
: new GeminiError('AI_FAILED', String(e));
|
: new GeminiError('AI_FAILED', String(e));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
firstMsg = e instanceof Error ? e.message : String(e);
|
||||||
|
console.warn(`[gemini-client] first attempt failed, retrying: ${firstMsg}`);
|
||||||
|
|
||||||
await new Promise((r) => setTimeout(r, 500));
|
await new Promise((r) => setTimeout(r, 500));
|
||||||
try {
|
try {
|
||||||
return await callGemini(
|
return await callGemini(
|
||||||
@@ -140,11 +148,23 @@ export async function extractRecipeFromImage(
|
|||||||
'Dein vorheriger Output war ungültig. Bitte antworte ausschließlich mit JSON gemäß Schema.'
|
'Dein vorheriger Output war ungültig. Bitte antworte ausschließlich mit JSON gemäß Schema.'
|
||||||
);
|
);
|
||||||
} catch (retryErr) {
|
} catch (retryErr) {
|
||||||
if (retryErr instanceof GeminiError) throw retryErr;
|
const retryMsg = retryErr instanceof Error ? retryErr.message : String(retryErr);
|
||||||
|
if (retryErr instanceof GeminiError) {
|
||||||
|
if (retryErr.code === 'AI_FAILED') {
|
||||||
|
throw new GeminiError(
|
||||||
|
'AI_FAILED',
|
||||||
|
`retry failed: ${retryMsg} (first: ${firstMsg})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw retryErr;
|
||||||
|
}
|
||||||
const retryStatus = getStatus(retryErr);
|
const retryStatus = getStatus(retryErr);
|
||||||
if (retryStatus === 429)
|
if (retryStatus === 429)
|
||||||
throw new GeminiError('AI_RATE_LIMITED', 'Gemini rate limit on retry');
|
throw new GeminiError('AI_RATE_LIMITED', 'Gemini rate limit on retry');
|
||||||
throw new GeminiError('AI_FAILED', String(retryErr));
|
throw new GeminiError(
|
||||||
|
'AI_FAILED',
|
||||||
|
`retry failed: ${retryMsg} (first: ${firstMsg})`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,27 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { SchemaType } from '@google/generative-ai';
|
import { SchemaType } from '@google/generative-ai';
|
||||||
|
|
||||||
export const RECIPE_EXTRACTION_SYSTEM_PROMPT = `Du bist ein Rezept-Extraktions-Assistent.
|
export const RECIPE_EXTRACTION_SYSTEM_PROMPT = `Du bist ein hochpräziser OCR-Experte für kulinarische Dokumente (Rezepte). Deine Aufgabe ist die Extraktion von Rezeptdaten (Titel, Zutaten, Zubereitungsschritte, Zeiten, Portionen) in valides JSON gemäß dem vorgegebenen Schema.
|
||||||
Du bekommst ein Foto eines gedruckten oder handgeschriebenen Rezepts und gibst ein strukturiertes JSON zurück.
|
|
||||||
|
|
||||||
Regeln:
|
SPRACHE:
|
||||||
- Extrahiere nur, was tatsächlich auf dem Bild lesbar ist. Sonst Feld auf null (oder leeres Array).
|
- Die Texte sind ausschließlich auf Deutsch. Nutze deutsches Sprachverständnis (Umlaute ä/ö/ü/ß, deutsche Zutatennamen, deutsche Maßeinheiten) als starken Prior bei der Rekonstruktion unklarer Zeichen. Gib die Ausgabe vollständig auf Deutsch zurück.
|
||||||
- Zutaten: quantity als Zahl (Bruchteile wie ½, ¼, 1 ½ als Dezimalzahl 0.5, 0.25, 1.5), unit separat
|
|
||||||
(g, ml, l, kg, EL, TL, Stück, Prise, Msp, …).
|
LOGIK-REGELN FÜR SCHWER LESBARE TEXTE:
|
||||||
|
- Handle als "Kontext-Detektiv": Wenn Zeichen unklar sind, nutze kulinarisches Wissen zur Rekonstruktion (z.B. "Pr-se" -> "Prise").
|
||||||
|
- Bei absoluter Unleserlichkeit eines Wortes: Nutze "[?]".
|
||||||
|
- Halluziniere keine fehlenden Werte: Wenn eine Mengenangabe komplett fehlt, setze 'quantity' auf null. Was nicht auf dem Bild steht, ist null (oder leeres Array).
|
||||||
|
|
||||||
|
FORMATIERUNGS-REGELN:
|
||||||
|
- Zutaten: quantity (Zahl) separat von unit (String). Brüche (½, ¼, 1 ½) strikt in Dezimalzahlen (0.5, 0.25, 1.5).
|
||||||
|
- Einheiten: Normalisiere auf (g, ml, l, kg, EL, TL, Stück, Prise, Msp).
|
||||||
- Zubereitungsschritte: pro erkennbarer Nummerierung oder Absatz EIN Schritt.
|
- Zubereitungsschritte: pro erkennbarer Nummerierung oder Absatz EIN Schritt.
|
||||||
- Zeiten in Minuten (ganze Zahl). "1 Stunde" = 60.
|
- Zeit: Alle Angaben strikt in Minuten (Integer). "1 Stunde" = 60.
|
||||||
- Ignoriere Werbung, Foto-Bildunterschriften, Einleitungstexte. Nur das Rezept selbst.
|
- Rauschen ignorieren: Keine Werbung, Einleitungstexte oder Bildunterschriften extrahieren.
|
||||||
- Denke dir NICHTS dazu aus. Was nicht auf dem Bild steht, ist null.
|
|
||||||
- Antworte ausschließlich im vorgegebenen JSON-Schema. Kein Markdown, kein Prosa-Text.`;
|
STRIKTE ANWEISUNG: Gib ausschließlich das rohe JSON-Objekt gemäß Schema zurück. Kein Markdown-Code-Block, kein Einleitungstext, keine Prosa.`;
|
||||||
|
|
||||||
|
export const RECIPE_EXTRACTION_USER_PROMPT =
|
||||||
|
'Analysiere dieses Bild hochauflösend. Extrahiere alle rezeptrelevanten Informationen gemäß deiner System-Instruktion. Achte besonders auf schwache Handschriften oder verblassten Text und stelle sicher, dass die Zuordnung von Menge zu Zutat logisch korrekt ist.';
|
||||||
|
|
||||||
// Gemini responseSchema (Subset von OpenAPI). Wird an GenerativeModel.generateContent
|
// Gemini responseSchema (Subset von OpenAPI). Wird an GenerativeModel.generateContent
|
||||||
// übergeben; Gemini respektiert die Struktur und liefert valides JSON.
|
// übergeben; Gemini respektiert die Struktur und liefert valides JSON.
|
||||||
|
|||||||
@@ -30,15 +30,12 @@ export function searchLocal(
|
|||||||
db: Database.Database,
|
db: Database.Database,
|
||||||
query: string,
|
query: string,
|
||||||
limit = 30,
|
limit = 30,
|
||||||
offset = 0,
|
offset = 0
|
||||||
domains: string[] = []
|
|
||||||
): SearchHit[] {
|
): SearchHit[] {
|
||||||
const fts = buildFtsQuery(query);
|
const fts = buildFtsQuery(query);
|
||||||
if (!fts) return [];
|
if (!fts) return [];
|
||||||
|
|
||||||
// bm25: lower is better. Use weights: title > tags > ingredients > description
|
// bm25: lower is better. Use weights: title > tags > ingredients > description
|
||||||
const hasFilter = domains.length > 0;
|
|
||||||
const placeholders = hasFilter ? domains.map(() => '?').join(',') : '';
|
|
||||||
const sql = `SELECT r.id,
|
const sql = `SELECT r.id,
|
||||||
r.title,
|
r.title,
|
||||||
r.description,
|
r.description,
|
||||||
@@ -49,13 +46,9 @@ export function searchLocal(
|
|||||||
FROM recipe r
|
FROM recipe r
|
||||||
JOIN recipe_fts f ON f.rowid = r.id
|
JOIN recipe_fts f ON f.rowid = r.id
|
||||||
WHERE recipe_fts MATCH ?
|
WHERE recipe_fts MATCH ?
|
||||||
${hasFilter ? `AND r.source_domain IN (${placeholders})` : ''}
|
|
||||||
ORDER BY bm25(recipe_fts, 10.0, 0.5, 2.0, 5.0)
|
ORDER BY bm25(recipe_fts, 10.0, 0.5, 2.0, 5.0)
|
||||||
LIMIT ? OFFSET ?`;
|
LIMIT ? OFFSET ?`;
|
||||||
const params = hasFilter
|
return db.prepare(sql).all(fts, limit, offset) as SearchHit[];
|
||||||
? [fts, ...domains, limit, offset]
|
|
||||||
: [fts, limit, offset];
|
|
||||||
return db.prepare(sql).all(...params) as SearchHit[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listRecentRecipes(
|
export function listRecentRecipes(
|
||||||
|
|||||||
@@ -31,7 +31,7 @@
|
|||||||
|
|
||||||
const navStore = new SearchStore({
|
const navStore = new SearchStore({
|
||||||
pageSize: 30,
|
pageSize: 30,
|
||||||
filterParam: () => {
|
webFilterParam: () => {
|
||||||
const p = searchFilterStore.queryParam;
|
const p = searchFilterStore.queryParam;
|
||||||
return p ? `&domains=${encodeURIComponent(p)}` : '';
|
return p ? `&domains=${encodeURIComponent(p)}` : '';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
|
|
||||||
const store = new SearchStore({
|
const store = new SearchStore({
|
||||||
pageSize: LOCAL_PAGE,
|
pageSize: LOCAL_PAGE,
|
||||||
filterParam: () => {
|
webFilterParam: () => {
|
||||||
const p = searchFilterStore.queryParam;
|
const p = searchFilterStore.queryParam;
|
||||||
return p ? `&domains=${encodeURIComponent(p)}` : '';
|
return p ? `&domains=${encodeURIComponent(p)}` : '';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,11 @@ import { pickRandomPhrase } from '$lib/server/ai/description-phrases';
|
|||||||
import { createRateLimiter } from '$lib/server/ai/rate-limit';
|
import { createRateLimiter } from '$lib/server/ai/rate-limit';
|
||||||
import type { Ingredient, Step } from '$lib/types';
|
import type { Ingredient, Step } from '$lib/types';
|
||||||
|
|
||||||
const MAX_BYTES = 8 * 1024 * 1024;
|
// 20 MB deckt auch Tablet- und iPad-Pro-Fotos ab (oft 10-15 MB JPEG/HEIC).
|
||||||
|
// Muss zusammen mit BODY_SIZE_LIMIT (docker-compose.prod.yml) hochgezogen werden --
|
||||||
|
// SvelteKit rejected groessere Bodies frueher und wirft dann undurchsichtige
|
||||||
|
// "Multipart erwartet"-Fehler.
|
||||||
|
const MAX_BYTES = 20 * 1024 * 1024;
|
||||||
const ALLOWED_MIME = new Set([
|
const ALLOWED_MIME = new Set([
|
||||||
'image/jpeg',
|
'image/jpeg',
|
||||||
'image/png',
|
'image/png',
|
||||||
@@ -41,16 +45,38 @@ export const POST: RequestHandler = async ({ request, getClientAddress }) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Header-Snapshot fuer Diagnose beim Upload-Parse-Fehler. Wir loggen
|
||||||
|
// Content-Type, -Length und User-Agent — nichts, was Inhalt verraet.
|
||||||
|
const contentType = request.headers.get('content-type') ?? '(missing)';
|
||||||
|
const contentLength = request.headers.get('content-length') ?? '(missing)';
|
||||||
|
const userAgent = request.headers.get('user-agent')?.slice(0, 120) ?? '(missing)';
|
||||||
|
|
||||||
let form: FormData;
|
let form: FormData;
|
||||||
try {
|
try {
|
||||||
form = await request.formData();
|
form = await request.formData();
|
||||||
} catch {
|
} catch (e) {
|
||||||
return errJson(400, 'BAD_REQUEST', 'Multipart body erwartet.');
|
const err = e as Error;
|
||||||
|
console.warn(
|
||||||
|
`[extract-from-photo] formData() failed: name=${err.name} msg=${err.message} ` +
|
||||||
|
`ct="${contentType}" len=${contentLength} ua="${userAgent}"`
|
||||||
|
);
|
||||||
|
return errJson(
|
||||||
|
400,
|
||||||
|
'BAD_REQUEST',
|
||||||
|
`Upload konnte nicht gelesen werden (${err.name}: ${err.message}).`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
const photo = form.get('photo');
|
const photo = form.get('photo');
|
||||||
if (!(photo instanceof Blob)) {
|
if (!(photo instanceof Blob)) {
|
||||||
|
console.warn(
|
||||||
|
`[extract-from-photo] photo field missing or not a Blob. ct="${contentType}" ` +
|
||||||
|
`len=${contentLength} fields=${[...form.keys()].join(',')}`
|
||||||
|
);
|
||||||
return errJson(400, 'BAD_REQUEST', 'Feld "photo" fehlt.');
|
return errJson(400, 'BAD_REQUEST', 'Feld "photo" fehlt.');
|
||||||
}
|
}
|
||||||
|
console.info(
|
||||||
|
`[extract-from-photo] received photo size=${photo.size} mime="${photo.type}" ua="${userAgent}"`
|
||||||
|
);
|
||||||
if (photo.size > MAX_BYTES) {
|
if (photo.size > MAX_BYTES) {
|
||||||
return errJson(
|
return errJson(
|
||||||
413,
|
413,
|
||||||
@@ -95,9 +121,11 @@ export const POST: RequestHandler = async ({ request, getClientAddress }) => {
|
|||||||
: e.code === 'AI_NOT_CONFIGURED'
|
: e.code === 'AI_NOT_CONFIGURED'
|
||||||
? 503
|
? 503
|
||||||
: 503;
|
: 503;
|
||||||
// Nur Code + Meta loggen, niemals Prompt/Response-Inhalt.
|
// Nur Code + Meta + Error-Message loggen, niemals Prompt/Response-Inhalt.
|
||||||
|
// e.message enthaelt z.B. Zod-Validierungspfade oder "non-JSON output" --
|
||||||
|
// kein AI-Content, aber die Diagnose-Info, warum AI_FAILED kam.
|
||||||
console.warn(
|
console.warn(
|
||||||
`[extract-from-photo] ${e.code} after ${Date.now() - startedAt}ms, ${preprocessed.buffer.byteLength} bytes`
|
`[extract-from-photo] ${e.code} after ${Date.now() - startedAt}ms, ${preprocessed.buffer.byteLength} bytes: ${e.message}`
|
||||||
);
|
);
|
||||||
return errJson(status, e.code, 'Die Bild-Analyse ist fehlgeschlagen.');
|
return errJson(status, e.code, 'Die Bild-Analyse ist fehlgeschlagen.');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,13 +7,9 @@ export const GET: RequestHandler = async ({ url }) => {
|
|||||||
const q = url.searchParams.get('q')?.trim() ?? '';
|
const q = url.searchParams.get('q')?.trim() ?? '';
|
||||||
const limit = Math.min(Number(url.searchParams.get('limit') ?? 30), 100);
|
const limit = Math.min(Number(url.searchParams.get('limit') ?? 30), 100);
|
||||||
const offset = Math.max(0, Number(url.searchParams.get('offset') ?? 0));
|
const offset = Math.max(0, Number(url.searchParams.get('offset') ?? 0));
|
||||||
const domains = (url.searchParams.get('domains') ?? '')
|
|
||||||
.split(',')
|
|
||||||
.map((d) => d.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
const hits =
|
const hits =
|
||||||
q.length >= 1
|
q.length >= 1
|
||||||
? searchLocal(getDb(), q, limit, offset, domains)
|
? searchLocal(getDb(), q, limit, offset)
|
||||||
: offset === 0
|
: offset === 0
|
||||||
? listRecentRecipes(getDb(), limit)
|
? listRecentRecipes(getDb(), limit)
|
||||||
: [];
|
: [];
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import {
|
import {
|
||||||
Camera,
|
Camera,
|
||||||
|
ImageUp,
|
||||||
Loader2,
|
Loader2,
|
||||||
Wand2,
|
Wand2,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
@@ -17,6 +18,7 @@
|
|||||||
|
|
||||||
const store = new PhotoUploadStore();
|
const store = new PhotoUploadStore();
|
||||||
let saving = $state(false);
|
let saving = $state(false);
|
||||||
|
let cameraInput = $state<HTMLInputElement | null>(null);
|
||||||
let fileInput = $state<HTMLInputElement | null>(null);
|
let fileInput = $state<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
function onPick(e: Event) {
|
function onPick(e: Event) {
|
||||||
@@ -85,20 +87,42 @@
|
|||||||
Fotografiere ein gedrucktes oder handgeschriebenes Rezept. Eine Seite,
|
Fotografiere ein gedrucktes oder handgeschriebenes Rezept. Eine Seite,
|
||||||
scharf, gut ausgeleuchtet.
|
scharf, gut ausgeleuchtet.
|
||||||
</p>
|
</p>
|
||||||
<button
|
<div class="row">
|
||||||
type="button"
|
<button
|
||||||
class="btn primary"
|
type="button"
|
||||||
onclick={() => fileInput?.click()}
|
class="btn primary"
|
||||||
disabled={!network.online}
|
onclick={() => cameraInput?.click()}
|
||||||
>
|
disabled={!network.online}
|
||||||
<Camera size={18} strokeWidth={2} />
|
>
|
||||||
<span>Foto wählen oder aufnehmen</span>
|
<Camera size={18} strokeWidth={2} />
|
||||||
</button>
|
<span>Kamera</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn ghost"
|
||||||
|
onclick={() => fileInput?.click()}
|
||||||
|
disabled={!network.online}
|
||||||
|
>
|
||||||
|
<ImageUp size={18} strokeWidth={2} />
|
||||||
|
<span>Aus Dateien</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<!-- Zwei separate Inputs: capture="environment" oeffnet direkt die Kamera,
|
||||||
|
das andere zeigt den Datei-/Fotomediathek-Picker. Android-Chrome auf
|
||||||
|
Tablet zeigt sonst bei capture="environment" nur die Kamera; ohne
|
||||||
|
capture dagegen nur den Datei-Picker. Explizite Wahl ist eindeutig. -->
|
||||||
|
<input
|
||||||
|
bind:this={cameraInput}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
capture="environment"
|
||||||
|
hidden
|
||||||
|
onchange={onPick}
|
||||||
|
/>
|
||||||
<input
|
<input
|
||||||
bind:this={fileInput}
|
bind:this={fileInput}
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/*"
|
accept="image/*"
|
||||||
capture="environment"
|
|
||||||
hidden
|
hidden
|
||||||
onchange={onPick}
|
onchange={onPick}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -70,8 +70,8 @@ describe('POST /api/recipes/extract-from-photo', () => {
|
|||||||
expect(body.recipe.id).toBeNull();
|
expect(body.recipe.id).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('413 when file exceeds 8 MB', async () => {
|
it('413 when file exceeds 20 MB', async () => {
|
||||||
const big = Buffer.alloc(9 * 1024 * 1024);
|
const big = Buffer.alloc(21 * 1024 * 1024);
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
fd.append('photo', new Blob([new Uint8Array(big)], { type: 'image/jpeg' }));
|
fd.append('photo', new Blob([new Uint8Array(big)], { type: 'image/jpeg' }));
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
|||||||
@@ -69,20 +69,11 @@ describe('searchLocal', () => {
|
|||||||
expect(searchLocal(db, ' ')).toEqual([]);
|
expect(searchLocal(db, ' ')).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('filters by domain when supplied', () => {
|
it('ignores source_domain — local search is domain-agnostic', () => {
|
||||||
const db = openInMemoryForTest();
|
const db = openInMemoryForTest();
|
||||||
insertRecipe(db, recipe({ title: 'Apfelstrudel', source_domain: 'chefkoch.de' }));
|
insertRecipe(db, recipe({ title: 'Apfelstrudel', source_domain: 'chefkoch.de' }));
|
||||||
insertRecipe(db, recipe({ title: 'Apfeltraum', source_domain: 'rezeptwelt.de' }));
|
insertRecipe(db, recipe({ title: 'Apfeltraum', source_domain: 'rezeptwelt.de' }));
|
||||||
const hits = searchLocal(db, 'apfel', 10, 0, ['chefkoch.de']);
|
const hits = searchLocal(db, 'apfel');
|
||||||
expect(hits.length).toBe(1);
|
|
||||||
expect(hits[0].source_domain).toBe('chefkoch.de');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('no domain filter when array is empty', () => {
|
|
||||||
const db = openInMemoryForTest();
|
|
||||||
insertRecipe(db, recipe({ title: 'Apfelstrudel', source_domain: 'chefkoch.de' }));
|
|
||||||
insertRecipe(db, recipe({ title: 'Apfeltraum', source_domain: 'rezeptwelt.de' }));
|
|
||||||
const hits = searchLocal(db, 'apfel', 10, 0, []);
|
|
||||||
expect(hits.length).toBe(2);
|
expect(hits.length).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -202,7 +202,7 @@ describe('SearchStore', () => {
|
|||||||
expect(round).toEqual(snap);
|
expect(round).toEqual(snap);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('filterParam option: gets appended to both local and web requests', async () => {
|
it('webFilterParam option: only appended to web requests, never to local', async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
const fetchImpl = mockFetch([
|
const fetchImpl = mockFetch([
|
||||||
{ body: { hits: [] } },
|
{ body: { hits: [] } },
|
||||||
@@ -211,13 +211,15 @@ describe('SearchStore', () => {
|
|||||||
const store = new SearchStore({
|
const store = new SearchStore({
|
||||||
fetchImpl,
|
fetchImpl,
|
||||||
debounceMs: 10,
|
debounceMs: 10,
|
||||||
filterParam: () => '&domains=chefkoch.de'
|
webFilterParam: () => '&domains=chefkoch.de'
|
||||||
});
|
});
|
||||||
store.query = 'curry';
|
store.query = 'curry';
|
||||||
store.runDebounced();
|
store.runDebounced();
|
||||||
await vi.advanceTimersByTimeAsync(15);
|
await vi.advanceTimersByTimeAsync(15);
|
||||||
await vi.waitFor(() => expect(fetchImpl).toHaveBeenCalledTimes(2));
|
await vi.waitFor(() => expect(fetchImpl).toHaveBeenCalledTimes(2));
|
||||||
expect(fetchImpl.mock.calls[0][0]).toMatch(/&domains=chefkoch\.de/);
|
expect(fetchImpl.mock.calls[0][0]).not.toMatch(/domains=/);
|
||||||
|
expect(fetchImpl.mock.calls[0][0]).toMatch(/\/api\/recipes\/search\?/);
|
||||||
|
expect(fetchImpl.mock.calls[1][0]).toMatch(/\/api\/recipes\/search\/web\?/);
|
||||||
expect(fetchImpl.mock.calls[1][0]).toMatch(/&domains=chefkoch\.de/);
|
expect(fetchImpl.mock.calls[1][0]).toMatch(/&domains=chefkoch\.de/);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -243,22 +245,25 @@ describe('SearchStore', () => {
|
|||||||
const fetchImpl = mockFetch([
|
const fetchImpl = mockFetch([
|
||||||
{ body: { hits: [] } },
|
{ body: { hits: [] } },
|
||||||
{ body: { hits: [] } },
|
{ body: { hits: [] } },
|
||||||
{ body: { hits: [{ id: 1, title: 'filtered', description: null, image_path: null, source_domain: null, avg_stars: null, last_cooked_at: null }] } }
|
{ body: { hits: [] } },
|
||||||
|
{ body: { hits: [{ url: 'https://chefkoch.de/x', title: 'filtered', domain: 'chefkoch.de', snippet: null, thumbnail: null }] } }
|
||||||
]);
|
]);
|
||||||
const store = new SearchStore({
|
const store = new SearchStore({
|
||||||
fetchImpl,
|
fetchImpl,
|
||||||
debounceMs: 10,
|
debounceMs: 10,
|
||||||
filterDebounceMs: 5,
|
filterDebounceMs: 5,
|
||||||
filterParam: () => filter
|
webFilterParam: () => filter
|
||||||
});
|
});
|
||||||
store.query = 'broth';
|
store.query = 'broth';
|
||||||
store.runDebounced();
|
store.runDebounced();
|
||||||
await vi.advanceTimersByTimeAsync(15);
|
await vi.advanceTimersByTimeAsync(15);
|
||||||
|
await vi.waitFor(() => expect(fetchImpl).toHaveBeenCalledTimes(2));
|
||||||
filter = '&domains=chefkoch.de';
|
filter = '&domains=chefkoch.de';
|
||||||
store.reSearch();
|
store.reSearch();
|
||||||
await vi.advanceTimersByTimeAsync(10);
|
await vi.advanceTimersByTimeAsync(10);
|
||||||
await vi.waitFor(() => expect(store.hits).toHaveLength(1));
|
await vi.waitFor(() => expect(store.webHits).toHaveLength(1));
|
||||||
const last = fetchImpl.mock.calls.at(-1)?.[0] as string;
|
const last = fetchImpl.mock.calls.at(-1)?.[0] as string;
|
||||||
|
expect(last).toMatch(/\/api\/recipes\/search\/web\?/);
|
||||||
expect(last).toMatch(/&domains=chefkoch\.de/);
|
expect(last).toMatch(/&domains=chefkoch\.de/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user