Zum Hauptinhalt springen

A vision model proposes metadata for an uploaded image; the human reviews and approves before anything persists. Lands as a server-backed showcase at /showcases/ai/image-metadata.

The showcase page is the UI documentation. This doc covers the architecture, the runtime flow, and the decisions the page can't show.


What it is

Upload an image → a vision-capable provider analyses it and pre-fills a metadata form (title, caption, alt text, keywords, category) with per-field confidence → a manual form is always usable → an approval dialog diffs AI-proposed against to-be-saved values before persisting.

The manual form is the ground floor: every AI failure mode collapses to a typed envelope so the form keeps working with zero AI.


Module map

Piece Location Role
Domain core src/lib/server/imagemeta/ Framework-free: ingest, process, extract, persist. Modeled 1
on src/lib/server/cycle/.
Vision resolver src/lib/server/ai/providers.ts supportsVision flag + resolveVisionProvider(); exposed as getVisionProvider() from ai/index.ts.
Price board src/lib/server/ai/pricing.ts Hand-maintained reference rates (sibling of provider-limits.ts). MODEL_PRICES + estimateCost()CostEstimate | null. Server-only; never ships to the client.
Canonical schema src/lib/schemas/showcase/image-metadata.ts One source, three consumers: AI-propose schema + strict save schema + helpers. Also holds the client-safe AnalyzeUsage / CostEstimate DTO types.
Storage src/lib/server/store/showcase/image.ts R2 ops under the showcase/imagemeta/ prefix.
DB src/lib/server/db/schema/showcase/image-metadata.ts Dedicated image pgSchema: asset / metadata / ai_proposal / tag / metadata_tag. ai_proposal stores token counts (incl. nullable reasoning_tokens), never dollars.
Route src/routes/[[locale=locale]]/(public)/showcases/ai/image-metadata/ +page.server.ts (load + upload/save actions), analyze/+server.ts (RPC — returns fields/confidence + usage/cost), MetadataApprovalDialog.svelte.

Import wall

imagemeta → ai / store / db / schemas
ai ✗ never imports imagemeta

Vision is a provider capability, not a chat feature. ai/ exposes getVisionProvider() and stops there; imagemeta/ owns the image domain. The wall keeps the AI subsystem from growing an image dependency.


Runtime flow

upload (multipart)
  └─ ingestImage
       ├─ magic-byte sniff (sniffImageMime — ignores client MIME)
       ├─ EXIF parse  ← reads GPS from the ORIGINAL bytes
       ├─ sharp re-encode/resize ≤1024px  ← strips ALL EXIF/GPS
       └─ store WebP derivative + image.asset row
  ↓
"Analyze" button  ← explicit, never auto-fires (spends AI budget)
  └─ POST analyze
       ├─ ownership gate (getUserImage)
       ├─ resolveVisionProvider  ← Google > OpenAI, Groq excluded
       ├─ generateText({ output: Output.object({ schema: jsonSchema(...) }) })
       ├─ re-validate result.output through canonical Valibot schema
       ├─ recordProposal (append-only run snapshot + token counts)
       └─ estimateCost(modelId, tokens)  ← reference $ estimate, NOT a charge
  ↓
taint-guarded merge  ← AI fills only fields the user hasn't touched
  ↓
review & approve dialog  ← diff table + GPS consent gate
  ↓
save action → saveImageMetadata (atomic upsert + tag replace)

The analyze endpoint returns a route-local { ok, fields, confidence } RPC shape (not the repo-wide { data } / { error } envelope) because its sole consumer is the sibling +page.svelte, which branches on ok and merges field-by-field. Failure reasons map to HTTP status: no_provider→503, budget→429, model_refused→422, timeout→504, error→500.


Decisions & gotchas

EXIF (including GPS) is parsed from the original upload before the sharp strip. The stored derivative carries no EXIF — sharp drops all metadata by default (the code never calls withMetadata). GPS coordinates only reach the DB when the user toggles includeLocation and approves in the dialog; otherwise they never touch persistence.

The opt-in surfaces in the GDPR data report: the images section reports withGpsCount (records where the user persisted location), countable because GPS lives in a typed gps_lat/gps_lng column, never only inside a blob. REPORT_SCHEMA_VERSION bumped to 2026-06-17.

Two DB CHECK constraints on image.metadata keep the GPS columns coherent at the storage layer, not just in app code:

Constraint Rule
image_metadata_gps_pair GPS is both-or-neither — gps_lat and gps_lng are both set or both null. No half-coordinate rows.
image_metadata_gps_range gps_lat ∈ [-90, 90], gps_lng ∈ [-180, 180]. Out-of-range coordinates are rejected by the database.

Vision-provider routing is load-bearing

resolveVisionProvider() filters to supportsVision === true and prefers Google > OpenAI. Groq is hard-excluded — llama-3.3-70b-versatile is text-only.

This is not optional defense. The default active provider is registry index 0 (Groq). Routing image extraction through resolveActiveProvider would hand a blind model an image part and get a hallucinated form. Image extraction must go through getVisionProvider.

Provider Model supportsVision
Groq llama-3.3-70b-versatile false (excluded)
OpenAI gpt-4o-mini true
Google Gemini gemini-2.5-flash true (preferred)

Cost is a reference estimate, never a charge

The cost panel shows token counts as the primary fact and a ≈ $X dollar figure as secondary. The dollars are a reference at standard pay-as-you-go rates — our Gemini key is free-tier, so the real charge is $0. CostEstimate.kind: 'reference' is the machine-readable honesty bit; the UI carries an always-visible "Reference estimate · not a charge" badge plus a "You're on the free tier — this run cost $0.00" footnote (the one place $0.00 is literally true: it states the real charge, not the estimate). Same "honest reference, not a fake gauge" stance as the admin AI quota board.

estimateCost(modelId, tokens) returns null — never a guess — for an unpriced model or missing tokens. pricing.ts is keyed by modelId, not providerId: per-image vision-token cost differs ~25× between models on the same provider, so a per-provider rate would be wrong.

Cost is derived, never stored. ai_proposal persists token counts only; dollars are computed at read-time from the versioned price map. A future rate change re-prices historical runs correctly. The price table is server-only — it never crosses the wire.

The same estimateCost powers the cross-surface admin Cost tab, which rolls this feature's per-image spend up alongside the chatbot's. See cost-monitoring.md.

Thinking tokens are a subset of output

gemini-2.5-flash runs reasoning ON. thoughtsTokenCount is read defensively from result.providerMetadata.google.usageMetadata, persisted to the nullable reasoning_tokens column, and shown as an indented "of which thinking" sub-line.

The SDK's outputTokens already includes thinking tokens. Confirmed live 2026-06-18 against gemini-2.5-flash: usage.totalTokens 1051 === input 491 + output 560, with reasoning 380 already inside output. So the displayed total is input + output — reasoning is not added (that would double-count), and cost prices output as-is. Encoded as OUTPUT_TOKENS_INCLUDE_THINKING = true in pricing.ts; flip to false only if a future provider reports thinking separately.

The wire payload reflects this: the analyze RPC success shape gained usage (provider/model/token counts + durationMs, counts individually nullable) and cost (CostEstimate | null) beside fields/confidence.

AI SDK v6 structured-output reality

Use generateText({ output: Output.object({ schema }) }) with an SDK jsonSchema() — not a Valibot schema passed directly, and not the deprecated generateObject. Read the result via result.output.

The jsonSchema() in extract.ts mirrors the canonical Valibot imageAnalysisSchema. The model output is then re-validated against that Valibot schema (single source of truth), so any drift between the two surfaces as a model_refused rather than corrupt data. A structurally-valid all-null object (analysisIsEmpty) also maps to model_refused — the model couldn't read the image.

Whole-form atomic approval

One status enum drives the entire metadata record (draftproposedapprovedrejected). Approval is whole-form, not per-field.

fieldProvenance (empty | ai-draft | human, per content field) is advisory audit only — it never gates save. It rides the save action as a JSON hidden field, parsed defensively: a missing or garbage value defaults every field to human (the user is clicking save).

Dedicated image schema, not ai.agent_proposal

The feature carries real per-user data with a GDPR surface, so it gets its own image Postgres namespace rather than sharing showcase. It deliberately does not reuse ai.agent_proposal: that table's conversationId/messageId are NOT-NULL FKs and its payload is ProposedToolCall[] — an image proposal is neither.

Server-proxied upload (not client-direct presigned)

Upload routes through the server upload action specifically so EXIF can be stripped before any bytes are stored. A client-direct presigned PUT would land the original (GPS-bearing) file in R2.

Client gotchas (SvelteKit + Superforms)

Three client-side traps this page hit. All verified in-browser.

Relative fetch resolves against the parent path. The page is /showcases/ai/image-metadata (no trailing slash), so fetch('./analyze') resolved to /showcases/ai/analyze → 404; the sibling analyze/+server.ts never ran. Build the URL from the live pathname instead — locale-prefix-safe and rename-safe:

fetch(`${location.pathname.replace(/\/$/, '')}/analyze`)

A $effect reading $form.x subscribes to the whole store. The keyword effect read $form.keywords, so it re-ran on every field edit (title, caption, …) and spuriously marked keywords as human-edited — routing AI-proposed keywords into a "conflict" instead of applying them. Key the effect off a memoized derived of just that field:

const keywordsKey = $derived($form.keywords.join(' '));

A sibling plain $app/forms enhance that calls update() resets $form. The upload action returns { uploaded }, not a form. Calling update({ reset: false }) applied that form-less result to $page.form, triggering Superforms' applyAction to reset $form to its loaded defaults — wiping the $form.imageId the callback had just set, so the later save posted an empty imageId and failed validation. Don't call update() in the upload callback; handle the result manually so upload stays decoupled from the Superforms form.

Array fields serialize back to the same root issue. The keyword array is a bind:value component with no named inputs, so it needs a hidden-input mirror (or dataType: 'json') to post at all — see ../forms.md (Array/Dynamic Fields).

Ops: register the schema in schemaFilter

The new image pgSchema must be added to drizzle.config.ts's schemaFilter, or db:push silently skips it and the tables never get created.


  • image-kit.md — the no-persistence toolkit sibling that bundles this reader's metadata extraction with an AI cropper and embedder; reuses imagemeta's image processing by import.
  • src/lib/server/imagemeta/ — domain core (extract, process, handlers, types)
  • src/lib/schemas/showcase/image-metadata.ts — canonical propose + save schemas
  • provider-routing.md — the dual chat/tool resolvers this vision resolver sits beside
  • ../../stack/capabilities/gdpr.md — the privacy report images section + GPS opt-in
← Back to Blueprint

Geht dieses Pattern noch besser? Sag uns, wie.

Feedback geben