feat(research): year verification in LLM prompt + image URL HEAD check

- Prompt now requires year verification before extracting any field
- Opening times and prices from prior years must be nulled with a hint
- imageURLReachable does a HEAD request (5s timeout) and strips the
  image_url from research results when the resource returns 4xx/5xx
This commit is contained in:
2026-04-25 12:44:01 +02:00
parent 6b3c673cd0
commit 9d457462d5
2 changed files with 54 additions and 10 deletions

View File

@@ -1,11 +1,13 @@
package market
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
@@ -89,6 +91,13 @@ func (h *ResearchHandler) Research(c *gin.Context) {
return
}
// Nil out image URLs that don't return a successful HTTP response.
if raw.Felder.BildURL.Wert != nil && *raw.Felder.BildURL.Wert != "" {
if !imageURLReachable(ctx, *raw.Felder.BildURL.Wert) {
raw.Felder.BildURL.Wert = nil
}
}
result := toLLMResearchResult(raw, m)
c.JSON(http.StatusOK, gin.H{"data": result})
}
@@ -228,3 +237,20 @@ func toLLMResearchResult(raw llmOutput, m Market) ResearchResult {
Sources: sources,
}
}
func imageURLReachable(ctx context.Context, rawURL string) bool {
reqCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(reqCtx, http.MethodHead, rawURL, nil)
if err != nil {
return false
}
req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; Marktvogt/1.0)")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return false
}
_ = resp.Body.Close()
// 405 means HEAD not allowed but the resource exists.
return resp.StatusCode < 400 || resp.StatusCode == http.StatusMethodNotAllowed
}

View File

@@ -13,10 +13,15 @@ Extraktion aus Quellen.
suchen und Veranstalter-Website oeffnen. Fallback: Facebook-Event oder
Kalender (mittelalterkalender.info, marktkalendarium.de, mittelalterfeste.com,
mittelalter-termine.de).
2. **Zweitquelle pflicht**: verifiziere Datum + Ort gegen mindestens eine
2. **Jahr zuerst verifizieren**: Pruefe SOFORT ob die Seite Daten fuer das
Recherchejahr zeigt (Jahr aus recherche_datum). Viele Veranstalter-Websites
zeigen erst nach Ankuendigung aktuelle Daten - bis dahin ist die Vorjahresseite
noch sichtbar. Steht kein Jahr auf der Seite oder stimmt das Jahr nicht:
Zweitquelle oeffnen bevor du weiter extrahierst.
3. **Zweitquelle pflicht**: verifiziere Datum + Ort gegen mindestens eine
weitere Quelle. Schuetzt vor veralteten Daten auf schlecht gepflegten Seiten.
3. **Felder extrahieren** (siehe unten).
4. **status** auf Top-Level setzen:
4. **Felder extrahieren** (siehe unten).
5. **status** auf Top-Level setzen:
- "bestaetigt": ALLE Felder fuer Recherchejahr bestaetigt
- "unklar": Quellen widerspruechlich ODER einzelne Felder aus Vorjahr
- "vorjahr_unbestaetigt": ueberwiegend Vorjahresdaten
@@ -45,14 +50,21 @@ Extraktion aus Quellen.
- **land**: "Deutschland" | "Oesterreich" | "Schweiz".
- **veranstalter**: Verein, Firma oder Person. Impressum ist gute Quelle.
- **start_datum** / **end_datum**: YYYY-MM-DD, im Recherchejahr. Eintages-
Markt: beide gleich.
Markt: beide gleich. Pruefe das Jahr explizit - nicht nur Monat und Tag
uebernehmen. Wenn unklar ob Datum fuer Recherchejahr gilt: `wert: null` +
hinweis mit dem Jahr der Quelle.
- **oeffnungszeiten**: Array von Zeitfenstern {datum_von, datum_bis, von, bis}.
Nimm NUR explizit genannte Zeiten. Keine Zeiten fuer Tage ohne Angabe
erfinden. Bei Muster ueber mehrere Wochenenden (z.B. "Fr 17-02, Sa 16-00:30
an allen Wochenenden"): Muster anwenden, keine widersprechenden Eintraege
erzeugen. Vor Abgabe: KEINE Duplikate (gleiches Datum mehrfach). Format 24h
"HH:MM", nach Mitternacht "00:30"/"02:00".
Kompakt: identische Zeiten ueber mehrere Tage -> ein Eintrag mit Datumsbereich.
Nimm NUR explizit genannte Zeiten aus der aktuellen Veranstaltungsseite.
Wichtig: Zeiten sind jahresabhaengig und aendern sich haeufig. Nur dann
eintragen wenn die Quelle eindeutig das Recherchejahr adressiert (z.B. auf
der aktuellen Veranstaltungsseite oder im aktuellen FB-Event). Vorjahresdaten
oder allgemeine "typische Zeiten" -> `wert: null` + hinweis mit Quell-Jahr.
Keine Zeiten fuer Tage ohne Angabe erfinden. Bei Muster ueber mehrere
Wochenenden (z.B. "Fr 17-02, Sa 16-00:30 an allen Wochenenden"): Muster
anwenden, keine widersprechenden Eintraege erzeugen. Vor Abgabe: KEINE
Duplikate (gleiches Datum mehrfach). Format 24h "HH:MM", nach Mitternacht
"00:30"/"02:00". Kompakt: identische Zeiten ueber mehrere Tage -> ein
Eintrag mit Datumsbereich.
- **eintrittspreise**: Array {name, betrag, waehrung}. ALLE Kategorien
extrahieren wenn mehrere gelistet (Erwachsene, Kinder, Ermaessigt,
Familie, Gewandete, Abendkasse etc.), nicht nur eine.
@@ -60,6 +72,8 @@ Extraktion aus Quellen.
Gebuehren und sind NICHT der Eintrittspreis. Veranstalter-Website
bevorzugen. Nur Portal verfuegbar: extrahieren + hinweis "inkl.
Servicegebuehr". Eintritt frei: ein Eintrag name="Eintritt frei", betrag=0.
Preise aendern sich jaehrlich - nur Preise extrahieren die nachweislich fuer
das Recherchejahr gelten. Kein Nachweis -> `wert: null` + hinweis.
- **bild_url**: Offizielles Plakat/Banner/Header, kein Stockfoto, kein
Sponsor-Logo. Social-Media-Vorschaubilder ok. Nur URLs die du tatsaechlich
als src/og:image gesehen hast. Nichts findbar -> `null`.
@@ -83,6 +97,10 @@ Extraktion aus Quellen.
- Feld nicht findbar: `wert: null`, `quellen: []`, `extraktion: "direkt"`,
`hinweis` mit knapper Begruendung.
- NICHTS erfinden. Halluzinationen sind der teuerste Fehler.
- Jahreszugehoerigkeit: Jedes Datum, jede Zeit, jeden Preis vor der Ausgabe
auf das Recherchejahr pruefen. Steht "2025" auf der Quelle und das
Recherchejahr ist 2026: Wert auf null setzen, hinweis mit "Quelle zeigt
2025-Daten" eintragen.
- Widerspruch zwischen Quellen: Veranstalter-Website > Kalender > Social Media
> Presse. Widerspruch IMMER im hinweis dokumentieren, auch wenn die
offiziellste Quelle klar gewinnt. Format: