From 73c30d2f5f447d933ee4eb386fa3f1001f1401c8 Mon Sep 17 00:00:00 2001 From: vikingowl Date: Sat, 25 Apr 2026 19:34:49 +0200 Subject: [PATCH] feat(admin/dedup): merge UI + enrich enum fix + robust JSON parse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit H1: Drop empty string from enricher_schema.json category enum — Gemini rejects enum[7]: cannot be empty (Error 400). Remove category from required so the model can omit it when no category fits. H2: Research-plan/apply client reads response as text before JSON.parse; empty or HTML error bodies now surface the actual HTTP status instead of crashing with "unexpected end of data". I: Dedup UI for approved markets: - DuplicatesPanel: LLM verdict pills (same/not-same, confidence), llm_reason, per-candidate Merge-planen button - MergeProposalPanel: summary, confidence, flags, per-field decisions with editable source radio (a/b/combined), current value context, confirm() before destructive apply - Two SvelteKit proxy routes: merge-plan/ and merge-into/[targetId]/ - [id]/+page.svelte: wired with full state; navigates to survivor after successful merge - [id]/+page.server.ts: load duplicates for all non-merged editions (was gated to status=rumored only) - types.ts: DuplicateMarket gains llm_same/llm_confidence/llm_reason; add MarketMergeProposal + MergeFieldDecision; add merged to EditionStatus --- .../enrich/assets/enricher_schema.json | 6 +- .../domain/discovery/enrich/llm_enricher.go | 5 +- web/src/lib/api/types.ts | 23 ++- .../components/admin/DuplicatesPanel.svelte | 106 ++++++++++++ .../admin/MergeProposalPanel.svelte | 163 ++++++++++++++++++ .../lib/components/admin/fieldRenderers.ts | 27 +++ web/src/routes/admin/maerkte/+page.svelte | 6 +- .../routes/admin/maerkte/[id]/+page.server.ts | 3 +- .../routes/admin/maerkte/[id]/+page.svelte | 119 ++++++++++--- .../maerkte/[id]/bearbeiten/+page.svelte | 22 ++- .../[id]/merge-into/[targetId]/+server.ts | 19 ++ .../admin/maerkte/[id]/merge-plan/+server.ts | 19 ++ 12 files changed, 481 insertions(+), 37 deletions(-) create mode 100644 web/src/lib/components/admin/DuplicatesPanel.svelte create mode 100644 web/src/lib/components/admin/MergeProposalPanel.svelte create mode 100644 web/src/lib/components/admin/fieldRenderers.ts create mode 100644 web/src/routes/admin/maerkte/[id]/merge-into/[targetId]/+server.ts create mode 100644 web/src/routes/admin/maerkte/[id]/merge-plan/+server.ts diff --git a/backend/internal/domain/discovery/enrich/assets/enricher_schema.json b/backend/internal/domain/discovery/enrich/assets/enricher_schema.json index 66c7891..89bd8df 100644 --- a/backend/internal/domain/discovery/enrich/assets/enricher_schema.json +++ b/backend/internal/domain/discovery/enrich/assets/enricher_schema.json @@ -1,13 +1,13 @@ { "type": "object", - "required": ["category", "opening_hours", "description"], + "required": ["opening_hours", "description"], "properties": { "category": { "type": "string", - "description": "Market category. Use empty string if unclear.", + "description": "Market category. Omit this field entirely if sources are unclear.", "enum": ["mittelaltermarkt", "weihnachtsmarkt", "ritterfest", "handwerkermarkt", "schlossfest", "ritterturnier", - "kirchweih", ""] + "kirchweih"] }, "opening_hours": { "type": "string", diff --git a/backend/internal/domain/discovery/enrich/llm_enricher.go b/backend/internal/domain/discovery/enrich/llm_enricher.go index 5498541..d4bd25c 100644 --- a/backend/internal/domain/discovery/enrich/llm_enricher.go +++ b/backend/internal/domain/discovery/enrich/llm_enricher.go @@ -171,7 +171,7 @@ Category taxonomy — pick exactly one: schlossfest Castle or palace festival — broader programme, not just market ritterturnier Jousting tournament; the tournament IS the main event kirchweih Traditional Bavarian/Austrian parish fair (Kirmes, Kirtag) - (empty string) Sources unclear or event does not fit any category above + (omit field) Sources unclear or event does not fit any category above Examples: "Dresdner Striezelmarkt", christmas-market sources → "weihnachtsmarkt" @@ -181,7 +181,8 @@ Examples: Rules: - Return ONLY the JSON object. No prose, no code fences. -- If the sources do not support a field, return an empty string for it. +- If the sources do not support a string field, return an empty string for it. + For "category", omit the field entirely when no category fits. Do NOT invent details the sources don't mention. - The description must be factual; avoid marketing language. - All string values use straight ASCII quotes; escape internal quotes. diff --git a/web/src/lib/api/types.ts b/web/src/lib/api/types.ts index d169aed..cecd25a 100644 --- a/web/src/lib/api/types.ts +++ b/web/src/lib/api/types.ts @@ -115,7 +115,8 @@ export type EditionStatus = | 'active' | 'completed' | 'cancelled' - | 'archived'; + | 'archived' + | 'merged'; // Keep backward compat alias export type MarketStatus = EditionStatus; @@ -284,6 +285,26 @@ export interface DuplicateMarket { start_date: string; end_date: string; similarity: number; + // Populated for top-5 candidates only (E1) + llm_same?: boolean; + llm_confidence?: number; + llm_reason?: string; +} + +// AI merge advisor types (E2/E5) +export interface MergeFieldDecision { + source: 'a' | 'b' | 'combined'; + value: unknown; + reason: string; +} + +export interface MarketMergeProposal { + target_id: string; + target_reason: string; + field_merges: Record; + flags: string[]; + confidence: number; + summary: string; } // Series types diff --git a/web/src/lib/components/admin/DuplicatesPanel.svelte b/web/src/lib/components/admin/DuplicatesPanel.svelte new file mode 100644 index 0000000..62ed201 --- /dev/null +++ b/web/src/lib/components/admin/DuplicatesPanel.svelte @@ -0,0 +1,106 @@ + + +
+

Mögliche Duplikate gefunden

+
    + {#each duplicates as dup} +
  • +
    +
    + + {dup.name} + + + ({Math.round(dup.similarity * 100)}% Ähnlichkeit) + + {#if dup.llm_same !== undefined && dup.llm_same !== null} + + {llmBadgeLabel(dup)} + + {/if} +
    +
    + {dup.city} · {formatDate(dup.start_date)} – {formatDate(dup.end_date)} +
    + {#if dup.llm_reason} +
    + {dup.llm_reason} +
    + {/if} +
    +
    + {#if isMergeDiscouraged(dup)} + + {:else} + + {/if} +
    +
  • + {/each} +
+
diff --git a/web/src/lib/components/admin/MergeProposalPanel.svelte b/web/src/lib/components/admin/MergeProposalPanel.svelte new file mode 100644 index 0000000..6bec238 --- /dev/null +++ b/web/src/lib/components/admin/MergeProposalPanel.svelte @@ -0,0 +1,163 @@ + + +
+ +
+
+

Merge-Vorschlag

+

{edited.summary}

+
+ +
+ + +
+ + Konfidenz {Math.round(edited.confidence * 100)}% + + + Ziel: + {edited.target_id === current.id + ? `Dieser Markt (${current.name})` + : `Kandidat (${candidate.name})`} + — {edited.target_reason} + +
+ + + {#if edited.flags.length > 0} +
+

+ Hinweise für manuelle Prüfung +

+
    + {#each edited.flags as flag} +
  • {flag}
  • + {/each} +
+
+ {/if} + + + {#if fieldOrder.length > 0} +
+

Felder

+ {#each fieldOrder as field} + {@const decision = edited.field_merges[field]} + {@const cur = currentValue(field)} +
+
+ + {fieldLabels[field] ?? field} + + + {sourceLabel(decision.source)} + +
+ + + {#if cur !== null && cur !== undefined && cur !== ''} +
+ Aktuell: {formatValue(cur)} +
+ {/if} + + +
+ {formatValue(decision.value)} +
+ + + {#if decision.reason} +
+ {decision.reason} +
+ {/if} + + +
+ {#each ['a', 'b', 'combined'] as src} + + {/each} +
+
+ {/each} +
+ {:else} +

+ Keine Feld-Entscheidungen im Vorschlag. +

+ {/if} + + +
+ + +
+
diff --git a/web/src/lib/components/admin/fieldRenderers.ts b/web/src/lib/components/admin/fieldRenderers.ts new file mode 100644 index 0000000..6629c50 --- /dev/null +++ b/web/src/lib/components/admin/fieldRenderers.ts @@ -0,0 +1,27 @@ +export const fieldLabels: Record = { + name: 'Name', + description: 'Beschreibung', + street: 'Straße', + city: 'Stadt', + zip: 'PLZ', + country: 'Land', + state: 'Bundesland', + website: 'Website', + organizer_name: 'Veranstalter', + start_date: 'Startdatum', + end_date: 'Enddatum', + opening_hours: 'Öffnungszeiten', + admission_info: 'Eintrittspreise', + image_url: 'Bild-URL', + logo_url: 'Logo-URL' +}; + +export function formatValue(val: unknown): string { + if (val === null || val === undefined) return '—'; + if (typeof val !== 'object') return String(val); + return JSON.stringify(val, null, 2); +} + +export function formatCents(cents: number): string { + return (cents / 100).toFixed(2); +} diff --git a/web/src/routes/admin/maerkte/+page.svelte b/web/src/routes/admin/maerkte/+page.svelte index 2138560..c54009e 100644 --- a/web/src/routes/admin/maerkte/+page.svelte +++ b/web/src/routes/admin/maerkte/+page.svelte @@ -24,7 +24,8 @@ active: 'Aktiv', completed: 'Abgeschlossen', cancelled: 'Abgesagt', - archived: 'Archiviert' + archived: 'Archiviert', + merged: 'Zusammengeführt' }; const statusColors: Record = { @@ -33,7 +34,8 @@ active: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200', completed: 'bg-stone-100 text-stone-600 dark:bg-stone-800 dark:text-stone-400', cancelled: 'bg-danger-100 text-danger-800 dark:bg-danger-900 dark:text-danger-200', - archived: 'bg-stone-100 text-stone-500 dark:bg-stone-800 dark:text-stone-500' + archived: 'bg-stone-100 text-stone-500 dark:bg-stone-800 dark:text-stone-500', + merged: 'bg-stone-100 text-stone-500 dark:bg-stone-800 dark:text-stone-400' }; const tabs: { label: string; value: string }[] = [ diff --git a/web/src/routes/admin/maerkte/[id]/+page.server.ts b/web/src/routes/admin/maerkte/[id]/+page.server.ts index 0fa3bb7..fbf5e9b 100644 --- a/web/src/routes/admin/maerkte/[id]/+page.server.ts +++ b/web/src/routes/admin/maerkte/[id]/+page.server.ts @@ -13,7 +13,8 @@ export const load: PageServerLoad = async ({ params, cookies }) => { const market = res.data; let duplicates: DuplicateMarket[] = []; - if (market.status === 'rumored') { + // Load duplicates for all non-merged editions — admins may want to merge confirmed/active pairs too. + if (market.status !== 'merged') { try { const dupRes = await serverFetch( `/admin/markets/${params.id}/duplicates`, diff --git a/web/src/routes/admin/maerkte/[id]/+page.svelte b/web/src/routes/admin/maerkte/[id]/+page.svelte index fa56cac..3c11de3 100644 --- a/web/src/routes/admin/maerkte/[id]/+page.svelte +++ b/web/src/routes/admin/maerkte/[id]/+page.svelte @@ -3,20 +3,87 @@ import { goto } from '$app/navigation'; import Button from '$lib/components/ui/Button.svelte'; import Alert from '$lib/components/ui/Alert.svelte'; - import type { EditionStatus } from '$lib/api/types.js'; + import DuplicatesPanel from '$lib/components/admin/DuplicatesPanel.svelte'; + import MergeProposalPanel from '$lib/components/admin/MergeProposalPanel.svelte'; + import type { EditionStatus, DuplicateMarket, MarketMergeProposal } from '$lib/api/types.js'; let { data, form } = $props(); let loading = $state(false); let showNewEdition = $state(false); + // Merge state + let proposal: MarketMergeProposal | null = $state(null); + let proposalCandidate: DuplicateMarket | null = $state(null); + let planningId: string | null = $state(null); + let mergeError: string | null = $state(null); + let applying = $state(false); + + async function handlePlan(candidate: DuplicateMarket) { + mergeError = null; + planningId = candidate.id; + try { + const res = await fetch(`/admin/maerkte/${data.market.id}/merge-plan`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ target_id: candidate.id }) + }); + const text = await res.text(); + let body: { error?: string; [k: string]: unknown } = {}; + try { + body = text ? JSON.parse(text) : {}; + } catch { + // non-JSON body + } + if (!res.ok) { + mergeError = body.error ?? `Merge-Plan fehlgeschlagen (HTTP ${res.status}).`; + return; + } + proposal = body as unknown as MarketMergeProposal; + proposalCandidate = candidate; + } catch (err) { + mergeError = err instanceof Error ? err.message : 'Merge-Plan fehlgeschlagen.'; + } finally { + planningId = null; + } + } + + async function applyMerge(edited: MarketMergeProposal) { + applying = true; + mergeError = null; + try { + const res = await fetch(`/admin/maerkte/${data.market.id}/merge-into/${edited.target_id}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(edited) + }); + const text = await res.text(); + let body: { error?: string; [k: string]: unknown } = {}; + try { + body = text ? JSON.parse(text) : {}; + } catch { + // non-JSON body + } + if (!res.ok) { + mergeError = body.error ?? `Merge fehlgeschlagen (HTTP ${res.status}).`; + return; + } + goto(`/admin/maerkte/${edited.target_id}`); + } catch (err) { + mergeError = err instanceof Error ? err.message : 'Merge fehlgeschlagen.'; + } finally { + applying = false; + } + } + const statusLabels: Record = { rumored: 'Ausstehend', confirmed: 'Bestätigt', active: 'Aktiv', completed: 'Abgeschlossen', cancelled: 'Abgesagt', - archived: 'Archiviert' + archived: 'Archiviert', + merged: 'Zusammengeführt' }; const statusColors: Record = { @@ -25,7 +92,8 @@ active: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200', completed: 'bg-stone-100 text-stone-600 dark:bg-stone-800 dark:text-stone-400', cancelled: 'bg-danger-100 text-danger-800 dark:bg-danger-900 dark:text-danger-200', - archived: 'bg-stone-100 text-stone-500 dark:bg-stone-800 dark:text-stone-500' + archived: 'bg-stone-100 text-stone-500 dark:bg-stone-800 dark:text-stone-500', + merged: 'bg-stone-100 text-stone-500 dark:bg-stone-800 dark:text-stone-400' }; const chfCountries = new Set(['CH', 'LI']); @@ -209,26 +277,31 @@ {/if} {#if data.duplicates && data.duplicates.length > 0} -
-

- Mögliche Duplikate gefunden -

-
    - {#each data.duplicates as dup} -
  • - - {dup.name} - - — {dup.city}, {formatDate(dup.start_date)} - {formatDate(dup.end_date)} - - ({Math.round(dup.similarity * 100)}% Ähnlichkeit) - -
  • - {/each} -
-
+ + {/if} + + {#if mergeError} + {mergeError} + {/if} + + {#if proposal && proposalCandidate} + { + proposal = null; + proposalCandidate = null; + mergeError = null; + }} + /> {/if} diff --git a/web/src/routes/admin/maerkte/[id]/bearbeiten/+page.svelte b/web/src/routes/admin/maerkte/[id]/bearbeiten/+page.svelte index d781713..80cce5a 100644 --- a/web/src/routes/admin/maerkte/[id]/bearbeiten/+page.svelte +++ b/web/src/routes/admin/maerkte/[id]/bearbeiten/+page.svelte @@ -19,12 +19,18 @@ const res = await fetch(`/admin/maerkte/${data.market.id}/bearbeiten/research-plan`, { method: 'POST' }); - const body = await res.json(); + const text = await res.text(); + let body: { error?: string; [k: string]: unknown } = {}; + try { + body = text ? JSON.parse(text) : {}; + } catch { + // non-JSON error body; keep body empty + } if (!res.ok) { - planError = body.error ?? 'Recherche-Plan fehlgeschlagen.'; + planError = body.error ?? `Recherche-Plan fehlgeschlagen (HTTP ${res.status}).`; return; } - planResult = body as PlanResponse; + planResult = body as unknown as PlanResponse; } catch (err) { planError = err instanceof Error ? err.message : 'Recherche-Plan fehlgeschlagen.'; } finally { @@ -40,9 +46,15 @@ headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ research_result: planResult.research_result, fields }) }); - const body = await res.json(); + const text = await res.text(); + let body: { error?: string; [k: string]: unknown } = {}; + try { + body = text ? JSON.parse(text) : {}; + } catch { + // non-JSON error body; keep body empty + } if (!res.ok) { - planError = body.error ?? 'Anwenden fehlgeschlagen.'; + planError = body.error ?? `Anwenden fehlgeschlagen (HTTP ${res.status}).`; return; } planResult = null; diff --git a/web/src/routes/admin/maerkte/[id]/merge-into/[targetId]/+server.ts b/web/src/routes/admin/maerkte/[id]/merge-into/[targetId]/+server.ts new file mode 100644 index 0000000..46ebc45 --- /dev/null +++ b/web/src/routes/admin/maerkte/[id]/merge-into/[targetId]/+server.ts @@ -0,0 +1,19 @@ +import { json } from '@sveltejs/kit'; +import { serverFetch } from '$lib/api/client.server.js'; +import type { AdminMarketDetail, MarketMergeProposal } from '$lib/api/types.js'; +import type { RequestHandler } from './$types.js'; + +export const POST: RequestHandler = async ({ cookies, params, request }) => { + try { + const proposal = (await request.json()) as MarketMergeProposal; + const res = await serverFetch( + `/admin/markets/${params.id}/merge-into/${params.targetId}`, + cookies, + { method: 'POST', body: JSON.stringify({ proposal }) } + ); + return json(res.data); + } catch (err) { + const message = err instanceof Error ? err.message : 'Merge fehlgeschlagen.'; + return json({ error: message }, { status: 502 }); + } +}; diff --git a/web/src/routes/admin/maerkte/[id]/merge-plan/+server.ts b/web/src/routes/admin/maerkte/[id]/merge-plan/+server.ts new file mode 100644 index 0000000..7909281 --- /dev/null +++ b/web/src/routes/admin/maerkte/[id]/merge-plan/+server.ts @@ -0,0 +1,19 @@ +import { json } from '@sveltejs/kit'; +import { serverFetch } from '$lib/api/client.server.js'; +import type { MarketMergeProposal } from '$lib/api/types.js'; +import type { RequestHandler } from './$types.js'; + +export const POST: RequestHandler = async ({ cookies, params, request }) => { + try { + const body = (await request.json()) as { target_id: string }; + const res = await serverFetch( + `/admin/markets/${params.id}/merge-plan`, + cookies, + { method: 'POST', body: JSON.stringify(body) } + ); + return json(res.data); + } catch (err) { + const message = err instanceof Error ? err.message : 'Merge-Plan fehlgeschlagen.'; + return json({ error: message }, { status: 502 }); + } +};