diff --git a/backend/internal/domain/market/research.go b/backend/internal/domain/market/research.go index 0ab0d47..dae98f7 100644 --- a/backend/internal/domain/market/research.go +++ b/backend/internal/domain/market/research.go @@ -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 +} diff --git a/backend/internal/domain/market/research/assets/researcher_prompt.de.md b/backend/internal/domain/market/research/assets/researcher_prompt.de.md index d4de310..fc65cbf 100644 --- a/backend/internal/domain/market/research/assets/researcher_prompt.de.md +++ b/backend/internal/domain/market/research/assets/researcher_prompt.de.md @@ -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: diff --git a/backend/internal/domain/settings/handler.go b/backend/internal/domain/settings/handler.go index ca6f444..949c4ae 100644 --- a/backend/internal/domain/settings/handler.go +++ b/backend/internal/domain/settings/handler.go @@ -12,14 +12,14 @@ import ( // AIStatus is the response payload for GET /admin/settings/ai. type AIStatus struct { - Provider string `json:"provider"` - Connected bool `json:"connected"` - Model string `json:"model"` - Models []string `json:"models"` - APIKeyFingerprint string `json:"api_key_fingerprint,omitempty"` - GroundingEnabled bool `json:"grounding_enabled"` - GroundingQuota int `json:"grounding_quota"` - Usage UsageSummary `json:"usage"` + Provider string `json:"provider"` + Connected bool `json:"connected"` + Model string `json:"model"` + Models []ai.ModelInfo `json:"models"` + APIKeyFingerprint string `json:"api_key_fingerprint,omitempty"` + GroundingEnabled bool `json:"grounding_enabled"` + GroundingQuota int `json:"grounding_quota"` + Usage UsageSummary `json:"usage"` } type UsageSummary struct { @@ -42,10 +42,10 @@ func NewHandler(provider *ai.GeminiProvider, store *Store, usageRepo *UsageRepo) func (h *Handler) GetAI(c *gin.Context) { ctx := c.Request.Context() - models, err := h.provider.ListModelNames(ctx) + models, err := h.provider.ListModels(ctx) connected := err == nil if models == nil { - models = []string{} + models = []ai.ModelInfo{} } // Fingerprint: last 4 chars of stored key (if any) @@ -85,6 +85,20 @@ func (h *Handler) SetModel(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "model is required"}) return } + // Validate against allowed list; degrade open if the list call fails (e.g. network blip). + if allowed, err := h.provider.ListModels(ctx); err == nil { + found := false + for _, m := range allowed { + if m.Name == req.Model { + found = true + break + } + } + if !found { + c.JSON(http.StatusBadRequest, gin.H{"error": "model not in allowed list"}) + return + } + } userID := callerID(c) if err := h.store.SetModel(ctx, req.Model, userID); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save model"}) diff --git a/backend/internal/pkg/ai/gemini.go b/backend/internal/pkg/ai/gemini.go index fa4e1eb..d0a6f1b 100644 --- a/backend/internal/pkg/ai/gemini.go +++ b/backend/internal/pkg/ai/gemini.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "sort" "strings" "sync" "time" @@ -11,6 +12,95 @@ import ( "google.golang.org/genai" ) +// ModelInfo describes a Gemini model available for use. +type ModelInfo struct { + Name string `json:"name"` + DisplayName string `json:"display_name"` + Stable bool `json:"stable"` + Thinking bool `json:"thinking"` + InputTokenLimit int32 `json:"input_token_limit"` + InputUSDPerM float64 `json:"input_usd_per_m"` + OutputUSDPerM float64 `json:"output_usd_per_m"` +} + +// geminiPricing maps model name prefixes to $/1M token rates (≤200k tier). +// Source: https://ai.google.dev/gemini-api/docs/pricing — update manually when prices change. +var geminiPricing = map[string]struct{ in, out float64 }{ + "gemini-3.1-pro": {2.00, 12.00}, + "gemini-3.1-flash-lite": {0.25, 1.50}, + "gemini-3-flash": {0.50, 3.00}, + "gemini-2.5-pro": {1.25, 10.00}, + "gemini-2.5-flash-lite": {0.10, 0.40}, + "gemini-2.5-flash": {0.30, 2.50}, +} + +// priceFor returns the $/1M input and output token cost for the given model name. +// Uses longest-prefix match against geminiPricing; returns (0, 0) if unknown. +func priceFor(name string) (in, out float64) { + best := "" + for prefix, p := range geminiPricing { + if strings.HasPrefix(name, prefix) && len(prefix) > len(best) { + best = prefix + in, out = p.in, p.out + } + } + return +} + +// filterCompatibleModels selects models that work with our request shape: +// generateContent + systemInstruction + responseSchema + optional googleSearchRetrieval. +// +// We rely on name-based filtering rather than SupportedActions because the Gemini +// public API omits supportedGenerationMethods for stable text models, leaving +// SupportedActions empty even for fully compatible models like gemini-2.5-flash. +func filterCompatibleModels(items []*genai.Model) []ModelInfo { + blockedSubstrings := []string{ + "-tts", "-image", "-native-audio", "-live", + "-computer-use", "-robotics", "-embedding", + } + out := make([]ModelInfo, 0, len(items)) + for _, m := range items { + if m.TunedModelInfo != nil { + continue + } + name := strings.TrimPrefix(m.Name, "models/") + if !strings.HasPrefix(name, "gemini-") { + continue + } + if strings.HasPrefix(name, "gemini-2.0-") { + continue + } + blocked := false + for _, sub := range blockedSubstrings { + if strings.Contains(name, sub) { + blocked = true + break + } + } + if blocked { + continue + } + in, outP := priceFor(name) + out = append(out, ModelInfo{ + Name: name, + DisplayName: m.DisplayName, + Stable: !strings.Contains(name, "-preview"), + Thinking: m.Thinking, + InputTokenLimit: m.InputTokenLimit, + InputUSDPerM: in, + OutputUSDPerM: outP, + }) + } + // Stable models first; within each group sort by name descending (newer families first). + sort.Slice(out, func(i, j int) bool { + if out[i].Stable != out[j].Stable { + return out[i].Stable + } + return out[i].Name > out[j].Name + }) + return out +} + // Gemini API pricing (as of 2026-04). Refresh constants when pricing changes. // https://ai.google.dev/gemini-api/docs/pricing const ( @@ -91,7 +181,7 @@ func (p *GeminiProvider) SetModel(model string) { p.model = model } -func (p *GeminiProvider) ListModelNames(ctx context.Context) ([]string, error) { +func (p *GeminiProvider) ListModels(ctx context.Context) ([]ModelInfo, error) { p.mu.RLock() client := p.client p.mu.RUnlock() @@ -102,16 +192,7 @@ func (p *GeminiProvider) ListModelNames(ctx context.Context) ([]string, error) { if err != nil { return nil, fmt.Errorf("gemini: list models: %w", err) } - var names []string - for _, m := range resp.Items { - for _, action := range m.SupportedActions { - if action == "generateContent" { - names = append(names, strings.TrimPrefix(m.Name, "models/")) - break - } - } - } - return names, nil + return filterCompatibleModels(resp.Items), nil } func (p *GeminiProvider) Chat(ctx context.Context, req *ChatRequest) (*ChatResponse, error) { diff --git a/backend/internal/pkg/ai/gemini_test.go b/backend/internal/pkg/ai/gemini_test.go new file mode 100644 index 0000000..e83861b --- /dev/null +++ b/backend/internal/pkg/ai/gemini_test.go @@ -0,0 +1,170 @@ +package ai + +import ( + "strings" + "testing" + + "google.golang.org/genai" +) + +func makeModel(name string, tuned, thinking bool) *genai.Model { + m := &genai.Model{ + Name: "models/" + name, + DisplayName: name, + Thinking: thinking, + InputTokenLimit: 32000, + } + if tuned { + m.TunedModelInfo = &genai.TunedModelInfo{} + } + return m +} + +func TestFilterCompatibleModels_KeepsGeminiTextFamilies(t *testing.T) { + // SupportedActions intentionally nil — stable text models don't have it populated. + kept := []*genai.Model{ + makeModel("gemini-2.5-pro", false, true), + makeModel("gemini-2.5-flash", false, false), + makeModel("gemini-2.5-flash-lite", false, false), + makeModel("gemini-3-flash-preview-04-2026", false, false), + makeModel("gemini-3.1-pro-preview-04-2026", false, true), + makeModel("gemini-3.1-flash-lite-preview-04-2026", false, false), + } + got := filterCompatibleModels(kept) + if len(got) != len(kept) { + t.Errorf("want %d models, got %d: %v", len(kept), len(got), modelNames(got)) + } +} + +func TestFilterCompatibleModels_DropsExcludedFamilies(t *testing.T) { + // SupportedActions is intentionally ignored — the Gemini public API omits + // supportedGenerationMethods for stable text models. Name-based filtering is + // the reliable gate; these cases verify every blocked category by name. + cases := []struct { + name string + model *genai.Model + }{ + {"tts", makeModel("gemini-2.5-flash-preview-tts", false, false)}, + {"pro tts", makeModel("gemini-2.5-pro-preview-tts", false, false)}, + {"image", makeModel("gemini-2.5-flash-image", false, false)}, + {"native audio", makeModel("gemini-2.5-flash-native-audio-preview-12-2025", false, false)}, + {"live", makeModel("gemini-2.5-flash-live-preview", false, false)}, + {"computer use", makeModel("gemini-2.5-computer-use-preview-10-2025", false, false)}, + {"robotics", makeModel("gemini-robotics-er-1.6-preview", false, false)}, + {"embedding", makeModel("gemini-embedding-001", false, false)}, + {"gemma", makeModel("gemma-3-27b-it", false, false)}, + {"gemma nano", makeModel("gemma-3n-e4b-it", false, false)}, + {"deep research", makeModel("deep-research-preview-04-2026", false, false)}, + {"deep research max", makeModel("deep-research-max-preview-04-2026", false, false)}, + {"imagen", makeModel("imagen-3.0-generate-001", false, false)}, + {"veo", makeModel("veo-3.1-generate-preview", false, false)}, + {"lyria", makeModel("lyria-realtime-exp", false, false)}, + {"learnlm", makeModel("learnlm-2.0-flash-experimental", false, false)}, + {"gemini 2.0 flash eol", makeModel("gemini-2.0-flash", false, false)}, + {"gemini 2.0 flash lite eol", makeModel("gemini-2.0-flash-lite", false, false)}, + {"tuned model", makeModel("gemini-2.5-flash", true, false)}, + } + for _, tc := range cases { + got := filterCompatibleModels([]*genai.Model{tc.model}) + if len(got) != 0 { + t.Errorf("case %q: want 0 models, got %d: %v", tc.name, len(got), modelNames(got)) + } + } +} + +func TestFilterCompatibleModels_StableField(t *testing.T) { + items := []*genai.Model{ + makeModel("gemini-2.5-flash", false, false), + makeModel("gemini-3-flash-preview-04-2026", false, false), + } + got := filterCompatibleModels(items) + if len(got) != 2 { + t.Fatalf("want 2, got %d", len(got)) + } + for _, m := range got { + expectStable := !strings.Contains(m.Name, "-preview") + if m.Stable != expectStable { + t.Errorf("model %q: Stable=%v, want %v", m.Name, m.Stable, expectStable) + } + } +} + +func TestFilterCompatibleModels_ThinkingField(t *testing.T) { + items := []*genai.Model{ + makeModel("gemini-2.5-pro", false, true), + makeModel("gemini-2.5-flash", false, false), + } + got := filterCompatibleModels(items) + if len(got) != 2 { + t.Fatalf("want 2, got %d", len(got)) + } + // find by name + for _, m := range got { + if m.Name == "gemini-2.5-pro" && !m.Thinking { + t.Errorf("gemini-2.5-pro: want Thinking=true") + } + if m.Name == "gemini-2.5-flash" && m.Thinking { + t.Errorf("gemini-2.5-flash: want Thinking=false") + } + } +} + +func TestFilterCompatibleModels_SortStableFirst(t *testing.T) { + items := []*genai.Model{ + makeModel("gemini-3-flash-preview-04-2026", false, false), + makeModel("gemini-2.5-pro", false, true), + makeModel("gemini-3.1-pro-preview-04-2026", false, true), + makeModel("gemini-2.5-flash-lite", false, false), + } + got := filterCompatibleModels(items) + if len(got) != 4 { + t.Fatalf("want 4, got %d", len(got)) + } + // First two must be stable + if !got[0].Stable || !got[1].Stable { + t.Errorf("first two should be stable, got %v %v", got[0].Name, got[1].Name) + } + // Last two must be preview + if got[2].Stable || got[3].Stable { + t.Errorf("last two should be preview, got %v %v", got[2].Name, got[3].Name) + } +} + +func TestPriceFor_KnownFamilies(t *testing.T) { + cases := []struct { + name string + wantIn float64 + wantOut float64 + }{ + {"gemini-2.5-flash-lite", 0.10, 0.40}, + {"gemini-2.5-flash-lite-preview-05-2026", 0.10, 0.40}, + {"gemini-2.5-flash", 0.30, 2.50}, + {"gemini-2.5-flash-preview-04-2026", 0.30, 2.50}, + {"gemini-2.5-pro", 1.25, 10.00}, + {"gemini-2.5-pro-preview-06-2026", 1.25, 10.00}, + {"gemini-3-flash-preview-04-2026", 0.50, 3.00}, + {"gemini-3.1-pro-preview-04-2026", 2.00, 12.00}, + {"gemini-3.1-flash-lite-preview-04-2026", 0.25, 1.50}, + } + for _, tc := range cases { + in, out := priceFor(tc.name) + if in != tc.wantIn || out != tc.wantOut { + t.Errorf("priceFor(%q): got (%v, %v), want (%v, %v)", tc.name, in, out, tc.wantIn, tc.wantOut) + } + } +} + +func TestPriceFor_UnknownReturnsZero(t *testing.T) { + in, out := priceFor("gemini-99-experimental-unknown") + if in != 0 || out != 0 { + t.Errorf("want (0, 0), got (%v, %v)", in, out) + } +} + +func modelNames(ms []ModelInfo) []string { + names := make([]string, len(ms)) + for i, m := range ms { + names[i] = m.Name + } + return names +} diff --git a/backend/internal/pkg/ai/provider.go b/backend/internal/pkg/ai/provider.go index 3aca71b..c35c210 100644 --- a/backend/internal/pkg/ai/provider.go +++ b/backend/internal/pkg/ai/provider.go @@ -16,7 +16,7 @@ type Provider interface { type ModelSelector interface { Model() string SetModel(string) - ListModelNames(ctx context.Context) ([]string, error) + ListModels(ctx context.Context) ([]ModelInfo, error) BaseURL() string } diff --git a/web/src/lib/api/types.ts b/web/src/lib/api/types.ts index 6b034ac..32ab8b6 100644 --- a/web/src/lib/api/types.ts +++ b/web/src/lib/api/types.ts @@ -206,11 +206,21 @@ export interface AIUsageEvent { error?: string; } +export interface AIModelInfo { + name: string; + display_name: string; + stable: boolean; + thinking: boolean; + input_token_limit: number; + input_usd_per_m: number; + output_usd_per_m: number; +} + export interface AIStatus { provider: string; connected: boolean; model: string; - models: string[]; + models: AIModelInfo[]; api_key_fingerprint?: string; grounding_enabled: boolean; grounding_quota: number; diff --git a/web/src/lib/components/admin/MarketForm.svelte b/web/src/lib/components/admin/MarketForm.svelte index 377687a..ba9bb2f 100644 --- a/web/src/lib/components/admin/MarketForm.svelte +++ b/web/src/lib/components/admin/MarketForm.svelte @@ -477,7 +477,27 @@ placeholder={mode === 'public' ? 'Name des Veranstalters' : ''} /> - + { + imageUrl = e.currentTarget.value; + }} + /> + {#if imageUrl} +