feat: AI pre-fill for product form via Gemini API

Add POST /products/parse-text endpoint that accepts raw product text,
calls Gemini (google-genai) with a structured extraction prompt, and
returns a partial ProductParseResponse. Frontend gains a collapsible
"AI pre-fill" card at the top of ProductForm that merges the LLM
response into all form fields reactively.

- Backend: ProductParseRequest/Response schemas, system prompt with
  enum constraints, temperature=0.0 for deterministic extraction,
  effect_profile always returned in full
- Frontend: parseProductText() in api.ts; controlled $state bindings
  for all text/number/checkbox inputs; applyAiResult() merges response

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Piotr Oleszczyk 2026-02-27 23:04:24 +01:00
parent c413e27768
commit 31e030eaac
5 changed files with 721 additions and 101 deletions

View file

@ -1,9 +1,13 @@
import { PUBLIC_API_BASE } from '$env/static/public';
import type {
ActiveIngredient,
LabResult,
MedicationEntry,
MedicationUsage,
Product,
ProductContext,
ProductEffectProfile,
ProductInteraction,
ProductInventory,
Routine,
RoutineStep,
@ -73,6 +77,27 @@ export const updateInventory = (id: string, body: Record<string, unknown>): Prom
api.patch(`/inventory/${id}`, body);
export const deleteInventory = (id: string): Promise<void> => api.del(`/inventory/${id}`);
export interface ProductParseResponse {
name?: string; brand?: string; line_name?: string; sku?: string; url?: string; barcode?: string;
category?: string; recommended_time?: string; texture?: string; absorption_speed?: string;
leave_on?: boolean; price_tier?: string;
size_ml?: number; full_weight_g?: number; empty_weight_g?: number; pao_months?: number;
inci?: string[]; actives?: ActiveIngredient[];
recommended_for?: string[]; targets?: string[];
contraindications?: string[]; usage_notes?: string;
fragrance_free?: boolean; essential_oils_free?: boolean;
alcohol_denat_free?: boolean; pregnancy_safe?: boolean;
product_effect_profile?: ProductEffectProfile;
ph_min?: number; ph_max?: number;
incompatible_with?: ProductInteraction[]; synergizes_with?: string[];
context_rules?: ProductContext;
min_interval_hours?: number; max_frequency_per_week?: number;
is_medication?: boolean; is_tool?: boolean; needle_length_mm?: number;
}
export const parseProductText = (text: string): Promise<ProductParseResponse> =>
api.post('/products/parse-text', { text });
// ─── Routines ────────────────────────────────────────────────────────────────
export interface RoutineListParams {

View file

@ -7,6 +7,7 @@
import { Label } from '$lib/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/components/ui/select';
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
import { parseProductText, type ProductParseResponse } from '$lib/api';
let { product }: { product?: Product } = $props();
@ -58,6 +59,118 @@
{ key: 'anti_aging_strength', label: 'Anti-aging' }
] as const;
// ── Controlled text/number inputs ────────────────────────────────────────
let name = $state(untrack(() => product?.name ?? ''));
let brand = $state(untrack(() => product?.brand ?? ''));
let lineName = $state(untrack(() => product?.line_name ?? ''));
let url = $state(untrack(() => product?.url ?? ''));
let sku = $state(untrack(() => product?.sku ?? ''));
let barcode = $state(untrack(() => product?.barcode ?? ''));
let sizeMl = $state(untrack(() => (product?.size_ml != null ? String(product.size_ml) : '')));
let fullWeightG = $state(untrack(() => (product?.full_weight_g != null ? String(product.full_weight_g) : '')));
let emptyWeightG = $state(untrack(() => (product?.empty_weight_g != null ? String(product.empty_weight_g) : '')));
let paoMonths = $state(untrack(() => (product?.pao_months != null ? String(product.pao_months) : '')));
let phMin = $state(untrack(() => (product?.ph_min != null ? String(product.ph_min) : '')));
let phMax = $state(untrack(() => (product?.ph_max != null ? String(product.ph_max) : '')));
let minIntervalHours = $state(untrack(() => (product?.min_interval_hours != null ? String(product.min_interval_hours) : '')));
let maxFrequencyPerWeek = $state(untrack(() => (product?.max_frequency_per_week != null ? String(product.max_frequency_per_week) : '')));
let needleLengthMm = $state(untrack(() => (product?.needle_length_mm != null ? String(product.needle_length_mm) : '')));
let usageNotes = $state(untrack(() => product?.usage_notes ?? ''));
let inciText = $state(untrack(() => product?.inci?.join('\n') ?? ''));
let contraindicationsText = $state(untrack(() => product?.contraindications?.join('\n') ?? ''));
let synergizesWithText = $state(untrack(() => product?.synergizes_with?.join('\n') ?? ''));
let recommendedFor = $state<string[]>(untrack(() => [...(product?.recommended_for ?? [])]));
let targetConcerns = $state<string[]>(untrack(() => [...(product?.targets ?? [])]));
let isMedication = $state(untrack(() => product?.is_medication ?? false));
let isTool = $state(untrack(() => product?.is_tool ?? false));
// ── AI pre-fill state ─────────────────────────────────────────────────────
let aiPanelOpen = $state(false);
let aiText = $state('');
let aiLoading = $state(false);
let aiError = $state('');
async function parseWithAi() {
if (!aiText.trim()) return;
aiLoading = true;
aiError = '';
try {
const r = await parseProductText(aiText);
applyAiResult(r);
aiPanelOpen = false;
} catch (e) {
aiError = (e as Error).message;
} finally {
aiLoading = false;
}
}
function applyAiResult(r: ProductParseResponse) {
if (r.name) name = r.name;
if (r.brand) brand = r.brand;
if (r.line_name) lineName = r.line_name;
if (r.url) url = r.url;
if (r.sku) sku = r.sku;
if (r.barcode) barcode = r.barcode;
if (r.usage_notes) usageNotes = r.usage_notes;
if (r.category) category = r.category;
if (r.recommended_time) recommendedTime = r.recommended_time;
if (r.texture) texture = r.texture;
if (r.absorption_speed) absorptionSpeed = r.absorption_speed;
if (r.price_tier) priceTier = r.price_tier;
if (r.leave_on != null) leaveOn = String(r.leave_on);
if (r.size_ml != null) sizeMl = String(r.size_ml);
if (r.full_weight_g != null) fullWeightG = String(r.full_weight_g);
if (r.empty_weight_g != null) emptyWeightG = String(r.empty_weight_g);
if (r.pao_months != null) paoMonths = String(r.pao_months);
if (r.ph_min != null) phMin = String(r.ph_min);
if (r.ph_max != null) phMax = String(r.ph_max);
if (r.min_interval_hours != null) minIntervalHours = String(r.min_interval_hours);
if (r.max_frequency_per_week != null) maxFrequencyPerWeek = String(r.max_frequency_per_week);
if (r.needle_length_mm != null) needleLengthMm = String(r.needle_length_mm);
if (r.fragrance_free != null) fragranceFree = String(r.fragrance_free);
if (r.essential_oils_free != null) essentialOilsFree = String(r.essential_oils_free);
if (r.alcohol_denat_free != null) alcoholDenatFree = String(r.alcohol_denat_free);
if (r.pregnancy_safe != null) pregnancySafe = String(r.pregnancy_safe);
if (r.recommended_for?.length) recommendedFor = [...r.recommended_for];
if (r.targets?.length) targetConcerns = [...r.targets];
if (r.is_medication != null) isMedication = r.is_medication;
if (r.is_tool != null) isTool = r.is_tool;
if (r.inci?.length) inciText = r.inci.join('\n');
if (r.contraindications?.length) contraindicationsText = r.contraindications.join('\n');
if (r.synergizes_with?.length) synergizesWithText = r.synergizes_with.join('\n');
if (r.actives?.length) {
actives = r.actives.map((a) => ({
name: a.name,
percent: a.percent != null ? String(a.percent) : '',
functions: [...(a.functions ?? [])] as IngredientFunction[],
strength_level: a.strength_level != null ? String(a.strength_level) : '',
irritation_potential: a.irritation_potential != null ? String(a.irritation_potential) : ''
}));
}
if (r.incompatible_with?.length) {
incompatibleWith = r.incompatible_with.map((i) => ({
target: i.target,
scope: i.scope,
reason: i.reason ?? ''
}));
}
if (r.product_effect_profile) {
effectValues = { ...effectValues, ...r.product_effect_profile };
}
if (r.context_rules) {
const cr = r.context_rules;
if (cr.safe_after_shaving != null) ctxAfterShaving = String(cr.safe_after_shaving);
if (cr.safe_after_acids != null) ctxAfterAcids = String(cr.safe_after_acids);
if (cr.safe_after_retinoids != null) ctxAfterRetinoids = String(cr.safe_after_retinoids);
if (cr.safe_with_compromised_barrier != null) ctxCompromisedBarrier = String(cr.safe_with_compromised_barrier);
if (cr.low_uv_only != null) ctxLowUvOnly = String(cr.low_uv_only);
}
}
// ── Select reactive state ─────────────────────────────────────────────────
let category = $state(untrack(() => product?.category ?? ''));
@ -210,6 +323,35 @@
'h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-ring';
</script>
<!-- ── AI pre-fill ──────────────────────────────────────────────────────────── -->
<Card>
<CardHeader>
<button type="button" class="flex w-full items-center justify-between text-left"
onclick={() => (aiPanelOpen = !aiPanelOpen)}>
<CardTitle>AI pre-fill</CardTitle>
<span class="text-sm text-muted-foreground">{aiPanelOpen ? '▲' : '▼'}</span>
</button>
</CardHeader>
{#if aiPanelOpen}
<CardContent class="space-y-3">
<p class="text-sm text-muted-foreground">
Wklej opis produktu ze strony, listę składników lub inny tekst.
AI uzupełni dostępne pola — możesz je przejrzeć i poprawić przed zapisem.
</p>
<textarea bind:value={aiText} rows="6"
placeholder="Wklej tutaj opis produktu, składniki INCI..."
class={textareaClass}></textarea>
{#if aiError}
<p class="text-sm text-destructive">{aiError}</p>
{/if}
<Button type="button" onclick={parseWithAi}
disabled={aiLoading || !aiText.trim()}>
{aiLoading ? 'Przetwarzam…' : 'Uzupełnij pola (AI)'}
</Button>
</CardContent>
{/if}
</Card>
<!-- ── Basic info ──────────────────────────────────────────────────────────── -->
<Card>
<CardHeader><CardTitle>Basic info</CardTitle></CardHeader>
@ -217,31 +359,31 @@
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="name">Name *</Label>
<Input id="name" name="name" required placeholder="e.g. Hydro Boost Water Gel" value={product?.name ?? ''} />
<Input id="name" name="name" required placeholder="e.g. Hydro Boost Water Gel" bind:value={name} />
</div>
<div class="space-y-2">
<Label for="brand">Brand *</Label>
<Input id="brand" name="brand" required placeholder="e.g. Neutrogena" value={product?.brand ?? ''} />
<Input id="brand" name="brand" required placeholder="e.g. Neutrogena" bind:value={brand} />
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="line_name">Line / series</Label>
<Input id="line_name" name="line_name" placeholder="e.g. Hydro Boost" value={product?.line_name ?? ''} />
<Input id="line_name" name="line_name" placeholder="e.g. Hydro Boost" bind:value={lineName} />
</div>
<div class="space-y-2">
<Label for="url">URL</Label>
<Input id="url" name="url" type="url" placeholder="https://…" value={product?.url ?? ''} />
<Input id="url" name="url" type="url" placeholder="https://…" bind:value={url} />
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="sku">SKU</Label>
<Input id="sku" name="sku" placeholder="e.g. NTR-HB-50" value={product?.sku ?? ''} />
<Input id="sku" name="sku" placeholder="e.g. NTR-HB-50" bind:value={sku} />
</div>
<div class="space-y-2">
<Label for="barcode">Barcode / EAN</Label>
<Input id="barcode" name="barcode" placeholder="e.g. 3614273258975" value={product?.barcode ?? ''} />
<Input id="barcode" name="barcode" placeholder="e.g. 3614273258975" bind:value={barcode} />
</div>
</div>
</CardContent>
@ -334,7 +476,13 @@
type="checkbox"
name="recommended_for"
value={st}
checked={product?.recommended_for?.includes(st as never) ?? false}
checked={recommendedFor.includes(st)}
onchange={() => {
if (recommendedFor.includes(st))
recommendedFor = recommendedFor.filter((s) => s !== st);
else
recommendedFor = [...recommendedFor, st];
}}
class="rounded border-input"
/>
{lbl(st)}
@ -352,7 +500,13 @@
type="checkbox"
name="targets"
value={sc}
checked={product?.targets?.includes(sc as never) ?? false}
checked={targetConcerns.includes(sc)}
onchange={() => {
if (targetConcerns.includes(sc))
targetConcerns = targetConcerns.filter((s) => s !== sc);
else
targetConcerns = [...targetConcerns, sc];
}}
class="rounded border-input"
/>
{lbl(sc)}
@ -369,7 +523,8 @@
rows="2"
placeholder="e.g. active rosacea flares"
class={textareaClass}
>{product?.contraindications?.join('\n') ?? ''}</textarea>
bind:value={contraindicationsText}
></textarea>
</div>
</CardContent>
</Card>
@ -386,7 +541,8 @@
rows="5"
placeholder="Aqua&#10;Glycerin&#10;Niacinamide"
class={textareaClass}
>{product?.inci?.join('\n') ?? ''}</textarea>
bind:value={inciText}
></textarea>
</div>
<div class="space-y-3">
@ -508,7 +664,8 @@
rows="3"
placeholder="Ceramides&#10;Niacinamide&#10;Retinoids"
class={textareaClass}
>{product?.synergizes_with?.join('\n') ?? ''}</textarea>
bind:value={synergizesWithText}
></textarea>
</div>
<div class="space-y-3">
@ -662,83 +819,33 @@
<div class="space-y-2">
<Label for="size_ml">Size (ml)</Label>
<Input
id="size_ml"
name="size_ml"
type="number"
min="0"
step="0.1"
placeholder="e.g. 50"
value={product?.size_ml ?? ''}
/>
<Input id="size_ml" name="size_ml" type="number" min="0" step="0.1" placeholder="e.g. 50" bind:value={sizeMl} />
</div>
<div class="space-y-2">
<Label for="full_weight_g">Full weight (g)</Label>
<Input
id="full_weight_g"
name="full_weight_g"
type="number"
min="0"
step="0.1"
placeholder="e.g. 120"
value={product?.full_weight_g ?? ''}
/>
<Input id="full_weight_g" name="full_weight_g" type="number" min="0" step="0.1" placeholder="e.g. 120" bind:value={fullWeightG} />
</div>
<div class="space-y-2">
<Label for="empty_weight_g">Empty weight (g)</Label>
<Input
id="empty_weight_g"
name="empty_weight_g"
type="number"
min="0"
step="0.1"
placeholder="e.g. 30"
value={product?.empty_weight_g ?? ''}
/>
<Input id="empty_weight_g" name="empty_weight_g" type="number" min="0" step="0.1" placeholder="e.g. 30" bind:value={emptyWeightG} />
</div>
<div class="space-y-2">
<Label for="pao_months">PAO (months)</Label>
<Input
id="pao_months"
name="pao_months"
type="number"
min="1"
max="60"
placeholder="e.g. 12"
value={product?.pao_months ?? ''}
/>
<Input id="pao_months" name="pao_months" type="number" min="1" max="60" placeholder="e.g. 12" bind:value={paoMonths} />
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="ph_min">pH min</Label>
<Input
id="ph_min"
name="ph_min"
type="number"
min="0"
max="14"
step="0.1"
placeholder="e.g. 3.5"
value={product?.ph_min ?? ''}
/>
<Input id="ph_min" name="ph_min" type="number" min="0" max="14" step="0.1" placeholder="e.g. 3.5" bind:value={phMin} />
</div>
<div class="space-y-2">
<Label for="ph_max">pH max</Label>
<Input
id="ph_max"
name="ph_max"
type="number"
min="0"
max="14"
step="0.1"
placeholder="e.g. 4.5"
value={product?.ph_max ?? ''}
/>
<Input id="ph_max" name="ph_max" type="number" min="0" max="14" step="0.1" placeholder="e.g. 4.5" bind:value={phMax} />
</div>
</div>
@ -750,7 +857,8 @@
rows="2"
placeholder="e.g. Apply to damp skin, avoid eye area"
class={textareaClass}
>{product?.usage_notes ?? ''}</textarea>
bind:value={usageNotes}
></textarea>
</div>
</CardContent>
</Card>
@ -830,46 +938,31 @@
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="min_interval_hours">Min interval (hours)</Label>
<Input
id="min_interval_hours"
name="min_interval_hours"
type="number"
min="0"
placeholder="e.g. 24"
value={product?.min_interval_hours ?? ''}
/>
<Input id="min_interval_hours" name="min_interval_hours" type="number" min="0" placeholder="e.g. 24" bind:value={minIntervalHours} />
</div>
<div class="space-y-2">
<Label for="max_frequency_per_week">Max uses per week</Label>
<Input
id="max_frequency_per_week"
name="max_frequency_per_week"
type="number"
min="1"
max="14"
placeholder="e.g. 3"
value={product?.max_frequency_per_week ?? ''}
/>
<Input id="max_frequency_per_week" name="max_frequency_per_week" type="number" min="1" max="14" placeholder="e.g. 3" bind:value={maxFrequencyPerWeek} />
</div>
</div>
<div class="flex gap-6">
<label class="flex cursor-pointer items-center gap-2 text-sm">
<input type="hidden" name="is_medication" value={String(isMedication)} />
<input
type="checkbox"
name="is_medication"
value="true"
checked={product?.is_medication ?? false}
checked={isMedication}
onchange={() => (isMedication = !isMedication)}
class="rounded border-input"
/>
Is medication
</label>
<label class="flex cursor-pointer items-center gap-2 text-sm">
<input type="hidden" name="is_tool" value={String(isTool)} />
<input
type="checkbox"
name="is_tool"
value="true"
checked={product?.is_tool ?? false}
checked={isTool}
onchange={() => (isTool = !isTool)}
class="rounded border-input"
/>
Is tool (e.g. dermaroller)
@ -878,15 +971,7 @@
<div class="space-y-2">
<Label for="needle_length_mm">Needle length (mm, tools only)</Label>
<Input
id="needle_length_mm"
name="needle_length_mm"
type="number"
min="0"
step="0.01"
placeholder="e.g. 0.25"
value={product?.needle_length_mm ?? ''}
/>
<Input id="needle_length_mm" name="needle_length_mm" type="number" min="0" step="0.01" placeholder="e.g. 0.25" bind:value={needleLengthMm} />
</div>
</CardContent>
</Card>