fix(research): convert LLM schema shapes to form-compatible types on apply

Researcher emits {datum_von,von,bis} for opening hours and [{name,betrag,waehrung}]
for admission info — both incompatible with the form's {day,open,close} and
AdmissionInfo shapes. Normalize on apply; extend normalizeDayName to handle
ISO YYYY-MM-DD dates the LLM produces. ResearchPanel renders both LLM and
form-native formats with dedicated table/list views.
This commit is contained in:
2026-04-25 11:01:18 +02:00
parent 25b682f030
commit dd9a5ae9cc
2 changed files with 71 additions and 12 deletions

View File

@@ -41,10 +41,21 @@
return JSON.stringify(val, null, 2); return JSON.stringify(val, null, 2);
} }
type LLMOeffnungszeit = { datum_von: string; datum_bis: string; von: string; bis: string };
type LLMEintrittspreis = { name: string; betrag: number; waehrung: string };
function isLLMOeffnungszeiten(val: unknown): val is LLMOeffnungszeit[] {
return Array.isArray(val) && val.length > 0 && 'datum_von' in (val[0] as object);
}
function isLLMEintrittspreise(val: unknown): val is LLMEintrittspreis[] {
return Array.isArray(val) && val.length > 0 && 'betrag' in (val[0] as object);
}
function isOpeningHours( function isOpeningHours(
val: unknown val: unknown
): val is Array<{ day: string; open: string; close: string }> { ): val is Array<{ day: string; open: string; close: string }> {
return Array.isArray(val) && val.length > 0 && 'day' in val[0]; return Array.isArray(val) && val.length > 0 && 'day' in (val[0] as object);
} }
function isAdmissionInfo(val: unknown): val is { function isAdmissionInfo(val: unknown): val is {
@@ -111,7 +122,26 @@
</div> </div>
{/if} {/if}
<div class="text-sm text-stone-900 dark:text-stone-100"> <div class="text-sm text-stone-900 dark:text-stone-100">
{#if suggestion.field === 'opening_hours' && isOpeningHours(suggestion.suggested_value)} {#if suggestion.field === 'opening_hours' && isLLMOeffnungszeiten(suggestion.suggested_value)}
<table class="w-full text-sm">
<thead>
<tr class="text-left text-stone-500 dark:text-stone-400">
<th class="pr-4 pb-1 font-medium">Datum</th>
<th class="pr-4 pb-1 font-medium">Von</th>
<th class="pb-1 font-medium">Bis</th>
</tr>
</thead>
<tbody>
{#each suggestion.suggested_value as entry}
<tr>
<td class="py-0.5 pr-4">{entry.datum_von}</td>
<td class="py-0.5 pr-4">{entry.von}</td>
<td class="py-0.5">{entry.bis}</td>
</tr>
{/each}
</tbody>
</table>
{:else if suggestion.field === 'opening_hours' && isOpeningHours(suggestion.suggested_value)}
<table class="w-full text-sm"> <table class="w-full text-sm">
<thead> <thead>
<tr class="text-left text-stone-500 dark:text-stone-400"> <tr class="text-left text-stone-500 dark:text-stone-400">
@@ -130,6 +160,12 @@
{/each} {/each}
</tbody> </tbody>
</table> </table>
{:else if suggestion.field === 'admission_info' && isLLMEintrittspreise(suggestion.suggested_value)}
<ul class="space-y-0.5 text-sm">
{#each suggestion.suggested_value as ticket}
<li>{ticket.name}: {ticket.betrag} {ticket.waehrung}</li>
{/each}
</ul>
{:else if suggestion.field === 'admission_info' && isAdmissionInfo(suggestion.suggested_value)} {:else if suggestion.field === 'admission_info' && isAdmissionInfo(suggestion.suggested_value)}
<dl class="grid grid-cols-2 gap-x-4 gap-y-1 text-sm"> <dl class="grid grid-cols-2 gap-x-4 gap-y-1 text-sm">
<dt class="text-stone-500 dark:text-stone-400">Erwachsene</dt> <dt class="text-stone-500 dark:text-stone-400">Erwachsene</dt>

View File

@@ -33,7 +33,18 @@
'Samstag' 'Samstag'
]; ];
// LLM opening hours entry shape (researcher_schema_simple.json)
type LLMOeffnungszeit = { datum_von: string; datum_bis: string; von: string; bis: string };
// LLM admission entry shape
type LLMEintrittspreis = { name: string; betrag: number; waehrung: string };
function normalizeDayName(day: string): string { function normalizeDayName(day: string): string {
// ISO date YYYY-MM-DD
if (/^\d{4}-\d{2}-\d{2}$/.test(day)) {
const [y, m, d] = day.split('-').map(Number);
return validDays[new Date(y, m - 1, d).getDay()];
}
// German date DD.MM.YYYY
const dateMatch = day.match(/(\d{2})\.(\d{2})\.(\d{4})/); const dateMatch = day.match(/(\d{2})\.(\d{2})\.(\d{4})/);
if (dateMatch) { if (dateMatch) {
const d = new Date(+dateMatch[3], +dateMatch[2] - 1, +dateMatch[1]); const d = new Date(+dateMatch[3], +dateMatch[2] - 1, +dateMatch[1]);
@@ -46,19 +57,31 @@
function applyResearch(suggestions: FieldSuggestion[]) { function applyResearch(suggestions: FieldSuggestion[]) {
for (const s of suggestions) { for (const s of suggestions) {
if (s.field === 'opening_hours' && Array.isArray(s.suggested_value)) { if (s.field === 'opening_hours' && Array.isArray(s.suggested_value)) {
const normalized = (s.suggested_value as OpeningHoursEntry[]).map((entry) => ({ const entries = s.suggested_value as Array<LLMOeffnungszeit | OpeningHoursEntry>;
...entry, const normalized: OpeningHoursEntry[] = entries.map((entry) => {
day: normalizeDayName(entry.day) if ('datum_von' in entry) {
})); return { day: normalizeDayName(entry.datum_von), open: entry.von, close: entry.bis };
}
return { ...entry, day: normalizeDayName(entry.day) };
});
marketForm.setHours(normalized); marketForm.setHours(normalized);
continue; continue;
} }
if ( if (s.field === 'admission_info') {
s.field === 'admission_info' && if (Array.isArray(s.suggested_value)) {
typeof s.suggested_value === 'object' && // LLM format: [{name, betrag, waehrung}] — put in notes, leave cents at 0
s.suggested_value !== null const tickets = s.suggested_value as LLMEintrittspreis[];
) { const notes = tickets.map((t) => `${t.name}: ${t.betrag} ${t.waehrung}`).join('\n');
marketForm.setAdmission(s.suggested_value as AdmissionInfo); marketForm.setAdmission({
adult_cents: 0,
child_cents: 0,
reduced_cents: 0,
free_under_age: 0,
notes
});
} else if (typeof s.suggested_value === 'object' && s.suggested_value !== null) {
marketForm.setAdmission(s.suggested_value as AdmissionInfo);
}
continue; continue;
} }
const el = document.querySelector<HTMLInputElement | HTMLTextAreaElement>( const el = document.querySelector<HTMLInputElement | HTMLTextAreaElement>(