feat(admin/dedup): merge UI + enrich enum fix + robust JSON parse

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
This commit is contained in:
2026-04-25 19:34:49 +02:00
parent 77e150f122
commit 73c30d2f5f
12 changed files with 481 additions and 37 deletions

View File

@@ -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",

View File

@@ -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.

View File

@@ -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<string, MergeFieldDecision>;
flags: string[];
confidence: number;
summary: string;
}
// Series types

View File

@@ -0,0 +1,106 @@
<script lang="ts">
import Button from '$lib/components/ui/Button.svelte';
import type { DuplicateMarket } from '$lib/api/types.js';
interface Props {
duplicates: DuplicateMarket[];
marketId: string;
onPlan: (candidate: DuplicateMarket) => void;
planningId?: string | null; // ID of candidate currently being loaded
}
let { duplicates, onPlan, planningId = null }: Props = $props();
function formatDate(s: string): string {
if (!s) return '—';
try {
return new Date(s).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
});
} catch {
return s;
}
}
function llmBadgeClass(d: DuplicateMarket): string {
if (d.llm_same === undefined || d.llm_same === null) return '';
const conf = d.llm_confidence ?? 0;
if (d.llm_same && conf >= 0.8)
return 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200';
if (d.llm_same) return 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200';
if (conf >= 0.7) return 'bg-stone-100 text-stone-600 dark:bg-stone-800 dark:text-stone-400';
return 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200';
}
function llmBadgeLabel(d: DuplicateMarket): string {
if (d.llm_same === undefined || d.llm_same === null) return '';
const pct = Math.round((d.llm_confidence ?? 0) * 100);
if (d.llm_same && (d.llm_confidence ?? 0) >= 0.8) return `Wahrscheinlich Duplikat (${pct}%)`;
if (d.llm_same) return `Möglich (${pct}%)`;
if ((d.llm_confidence ?? 0) >= 0.7) return `Wahrscheinlich kein Duplikat (${pct}%)`;
return `Unklar (${pct}%)`;
}
// Disable the merge button only when the LLM is confident it's NOT a duplicate.
function isMergeDiscouraged(d: DuplicateMarket): boolean {
return d.llm_same === false && (d.llm_confidence ?? 0) >= 0.7;
}
</script>
<div
class="rounded-lg border border-amber-300 bg-amber-50 p-4 dark:border-amber-700 dark:bg-amber-950"
>
<h3 class="mb-3 font-semibold text-amber-800 dark:text-amber-200">Mögliche Duplikate gefunden</h3>
<ul class="space-y-3">
{#each duplicates as dup}
<li class="flex flex-wrap items-start justify-between gap-2">
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<a
href="/admin/maerkte/{dup.id}"
class="text-sm font-medium text-amber-900 hover:underline dark:text-amber-100"
>
{dup.name}
</a>
<span class="text-xs text-amber-600 dark:text-amber-400">
({Math.round(dup.similarity * 100)}% Ähnlichkeit)
</span>
{#if dup.llm_same !== undefined && dup.llm_same !== null}
<span
class="rounded-full px-2 py-0.5 text-xs font-medium {llmBadgeClass(dup)}"
title={dup.llm_reason ?? ''}
>
{llmBadgeLabel(dup)}
</span>
{/if}
</div>
<div class="mt-0.5 text-xs text-amber-700 dark:text-amber-300">
{dup.city} · {formatDate(dup.start_date)} {formatDate(dup.end_date)}
</div>
{#if dup.llm_reason}
<div class="mt-0.5 text-xs text-amber-600 italic dark:text-amber-400">
{dup.llm_reason}
</div>
{/if}
</div>
<div class="flex shrink-0 flex-col items-end gap-1">
{#if isMergeDiscouraged(dup)}
<button
type="button"
class="text-xs text-amber-600 hover:underline dark:text-amber-400"
onclick={() => onPlan(dup)}
>
{planningId === dup.id ? 'Lädt…' : 'Trotzdem mergen'}
</button>
{:else}
<Button size="sm" loading={planningId === dup.id} onclick={() => onPlan(dup)}>
Merge planen
</Button>
{/if}
</div>
</li>
{/each}
</ul>
</div>

View File

@@ -0,0 +1,163 @@
<script lang="ts">
import { untrack } from 'svelte';
import Button from '$lib/components/ui/Button.svelte';
import type { AdminMarketDetail, DuplicateMarket, MarketMergeProposal } from '$lib/api/types.js';
import { fieldLabels, formatValue } from './fieldRenderers.js';
interface Props {
proposal: MarketMergeProposal;
candidate: DuplicateMarket;
current: AdminMarketDetail;
applying: boolean;
onApply: (edited: MarketMergeProposal) => void;
onClose: () => void;
}
let { proposal, candidate, current, applying, onApply, onClose }: Props = $props();
// Local copy the admin can edit before applying.
let edited = $state(untrack(() => structuredClone(proposal)));
const fieldOrder = Object.keys(edited.field_merges);
function sourceLabel(source: string): string {
if (source === 'a') return 'Dieser Markt (A)';
if (source === 'b') return 'Kandidat (B)';
return 'Kombiniert';
}
function confidenceColor(conf: number): string {
if (conf >= 0.8) return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200';
if (conf >= 0.5) return 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200';
return 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200';
}
// Read current market value for a field.
function currentValue(field: string): unknown {
return (current as unknown as Record<string, unknown>)[field];
}
function handleApply() {
if (
!confirm(
'Merge ist nicht reversibel. Die Source-Edition wird auf "merged" gesetzt. Fortfahren?'
)
)
return;
onApply(edited);
}
</script>
<div
class="rounded-lg border border-stone-200 bg-stone-50 p-6 dark:border-stone-700 dark:bg-stone-900"
>
<!-- Header -->
<div class="mb-4 flex items-start justify-between gap-4">
<div class="flex-1">
<h3 class="text-lg font-semibold text-stone-800 dark:text-stone-100">Merge-Vorschlag</h3>
<p class="mt-1 text-sm text-stone-600 dark:text-stone-400">{edited.summary}</p>
</div>
<button
type="button"
onclick={onClose}
class="shrink-0 text-stone-500 hover:text-stone-700 dark:text-stone-400 dark:hover:text-stone-200"
>
Schließen
</button>
</div>
<!-- Confidence + target info -->
<div class="mb-4 flex flex-wrap items-center gap-3">
<span class="rounded-full px-2 py-0.5 text-xs font-medium {confidenceColor(edited.confidence)}">
Konfidenz {Math.round(edited.confidence * 100)}%
</span>
<span class="text-sm text-stone-600 dark:text-stone-400">
<span class="font-medium">Ziel:</span>
{edited.target_id === current.id
? `Dieser Markt (${current.name})`
: `Kandidat (${candidate.name})`}
{edited.target_reason}
</span>
</div>
<!-- Flags -->
{#if edited.flags.length > 0}
<div
class="mb-4 rounded-lg border border-red-300 bg-red-50 px-4 py-3 dark:border-red-700 dark:bg-red-950"
>
<p class="mb-1 text-xs font-semibold text-red-800 dark:text-red-200">
Hinweise für manuelle Prüfung
</p>
<ul class="space-y-0.5">
{#each edited.flags as flag}
<li class="text-sm text-red-800 dark:text-red-200">{flag}</li>
{/each}
</ul>
</div>
{/if}
<!-- Field decisions -->
{#if fieldOrder.length > 0}
<div class="mb-4 space-y-2">
<h4 class="text-sm font-semibold text-stone-700 dark:text-stone-300">Felder</h4>
{#each fieldOrder as field}
{@const decision = edited.field_merges[field]}
{@const cur = currentValue(field)}
<div class="rounded-lg border border-stone-200 p-3 dark:border-stone-700">
<div class="mb-2 flex items-center justify-between">
<span class="text-sm font-medium text-stone-800 dark:text-stone-100">
{fieldLabels[field] ?? field}
</span>
<span class="text-xs text-stone-500 dark:text-stone-400">
{sourceLabel(decision.source)}
</span>
</div>
<!-- Current value for context -->
{#if cur !== null && cur !== undefined && cur !== ''}
<div class="mb-1 text-xs text-stone-500 dark:text-stone-400">
Aktuell: {formatValue(cur)}
</div>
{/if}
<!-- Proposed value -->
<div class="mb-2 text-sm text-stone-900 dark:text-stone-100">
{formatValue(decision.value)}
</div>
<!-- Reason -->
{#if decision.reason}
<div class="mb-2 text-xs text-stone-500 italic dark:text-stone-400">
{decision.reason}
</div>
{/if}
<!-- Editable source radio -->
<div class="flex flex-wrap gap-3">
{#each ['a', 'b', 'combined'] as src}
<label class="flex cursor-pointer items-center gap-1 text-xs">
<input
type="radio"
name="source_{field}"
value={src}
bind:group={edited.field_merges[field].source}
/>
{sourceLabel(src)}
</label>
{/each}
</div>
</div>
{/each}
</div>
{:else}
<p class="mb-4 text-sm text-stone-600 dark:text-stone-400">
Keine Feld-Entscheidungen im Vorschlag.
</p>
{/if}
<!-- Footer -->
<div class="flex gap-2">
<Button variant="danger" loading={applying} onclick={handleApply}>Merge anwenden</Button>
<Button variant="secondary" onclick={onClose}>Abbrechen</Button>
</div>
</div>

View File

@@ -0,0 +1,27 @@
export const fieldLabels: Record<string, string> = {
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);
}

View File

@@ -24,7 +24,8 @@
active: 'Aktiv',
completed: 'Abgeschlossen',
cancelled: 'Abgesagt',
archived: 'Archiviert'
archived: 'Archiviert',
merged: 'Zusammengeführt'
};
const statusColors: Record<EditionStatus, string> = {
@@ -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 }[] = [

View File

@@ -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<DuplicateMarket[]>(
`/admin/markets/${params.id}/duplicates`,

View File

@@ -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<EditionStatus, string> = {
rumored: 'Ausstehend',
confirmed: 'Bestätigt',
active: 'Aktiv',
completed: 'Abgeschlossen',
cancelled: 'Abgesagt',
archived: 'Archiviert'
archived: 'Archiviert',
merged: 'Zusammengeführt'
};
const statusColors: Record<EditionStatus, string> = {
@@ -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}
<div
class="rounded-lg border border-amber-300 bg-amber-50 p-4 dark:border-amber-700 dark:bg-amber-950"
>
<h3 class="mb-2 font-semibold text-amber-800 dark:text-amber-200">
Mögliche Duplikate gefunden
</h3>
<ul class="space-y-1">
{#each data.duplicates as dup}
<li class="text-sm text-amber-700 dark:text-amber-300">
<a href="/admin/maerkte/{dup.id}" class="font-medium hover:underline">
{dup.name}
</a>
{dup.city}, {formatDate(dup.start_date)} - {formatDate(dup.end_date)}
<span class="text-xs text-amber-600 dark:text-amber-400">
({Math.round(dup.similarity * 100)}% Ähnlichkeit)
</span>
</li>
{/each}
</ul>
</div>
<DuplicatesPanel
duplicates={data.duplicates}
marketId={data.market.id}
{planningId}
onPlan={handlePlan}
/>
{/if}
{#if mergeError}
<Alert variant="error">{mergeError}</Alert>
{/if}
{#if proposal && proposalCandidate}
<MergeProposalPanel
{proposal}
candidate={proposalCandidate}
current={data.market}
{applying}
onApply={applyMerge}
onClose={() => {
proposal = null;
proposalCandidate = null;
mergeError = null;
}}
/>
{/if}
<!-- Status badge + review section -->

View File

@@ -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;

View File

@@ -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<AdminMarketDetail>(
`/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 });
}
};

View File

@@ -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<MarketMergeProposal>(
`/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 });
}
};