merge: feat/gemini-filter-image-display
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"})
|
||||
|
||||
@@ -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) {
|
||||
|
||||
170
backend/internal/pkg/ai/gemini_test.go
Normal file
170
backend/internal/pkg/ai/gemini_test.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -477,7 +477,27 @@
|
||||
placeholder={mode === 'public' ? 'Name des Veranstalters' : ''}
|
||||
/>
|
||||
|
||||
<Input label="Bild-URL" name="image_url" type="url" value={imageUrl} />
|
||||
<Input
|
||||
label="Bild-URL"
|
||||
name="image_url"
|
||||
type="url"
|
||||
value={imageUrl}
|
||||
oninput={(e) => {
|
||||
imageUrl = e.currentTarget.value;
|
||||
}}
|
||||
/>
|
||||
{#if imageUrl}
|
||||
<div class="mt-2">
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt="Bildvorschau"
|
||||
class="max-h-48 rounded-lg object-contain"
|
||||
onerror={(e) => {
|
||||
(e.currentTarget as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="space-y-4">
|
||||
@@ -504,7 +524,9 @@
|
||||
</div>
|
||||
<Input
|
||||
label="Von"
|
||||
type="time"
|
||||
type="text"
|
||||
pattern="([01][0-9]|2[0-3]):[0-5][0-9]"
|
||||
placeholder="HH:MM"
|
||||
value={row.open}
|
||||
oninput={(e) => {
|
||||
row.open = e.currentTarget.value;
|
||||
@@ -512,7 +534,9 @@
|
||||
/>
|
||||
<Input
|
||||
label="Bis"
|
||||
type="time"
|
||||
type="text"
|
||||
pattern="([01][0-9]|2[0-3]):[0-5][0-9]"
|
||||
placeholder="HH:MM"
|
||||
value={row.close}
|
||||
oninput={(e) => {
|
||||
row.close = e.currentTarget.value;
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
}
|
||||
|
||||
let { market }: Props = $props();
|
||||
let imgFailed = $state(false);
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleDateString('de-DE', {
|
||||
@@ -25,14 +26,24 @@
|
||||
href="/markt/{market.slug}"
|
||||
class="group bg-vellum block rounded-lg border border-stone-200 shadow-sm transition-shadow hover:shadow-md dark:border-stone-700"
|
||||
>
|
||||
{#if market.image_url}
|
||||
<div class="aspect-[16/9] overflow-hidden rounded-t-lg">
|
||||
<img
|
||||
src={market.image_url}
|
||||
alt={market.name}
|
||||
class="h-full w-full object-cover transition-transform group-hover:scale-105"
|
||||
loading="lazy"
|
||||
/>
|
||||
{#if market.image_url && !imgFailed}
|
||||
<img
|
||||
src={market.image_url}
|
||||
alt={market.name}
|
||||
class="w-full rounded-t-lg"
|
||||
style="padding: 16px 16px 0; max-height: 150px; object-fit: contain;"
|
||||
loading="lazy"
|
||||
onerror={() => {
|
||||
imgFailed = true;
|
||||
}}
|
||||
/>
|
||||
{:else}
|
||||
<div
|
||||
class="flex h-[150px] items-center justify-center rounded-t-lg bg-gradient-to-br from-stone-800 to-stone-900 dark:from-stone-900 dark:to-stone-950"
|
||||
>
|
||||
<span class="text-5xl font-bold text-stone-600 uppercase select-none dark:text-stone-700">
|
||||
{market.city.charAt(0)}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="p-4">
|
||||
|
||||
@@ -183,7 +183,11 @@
|
||||
class="focus:border-primary-500 focus:ring-primary-500 rounded-md border border-stone-300 bg-white px-3 py-2 text-sm text-stone-900 shadow-sm focus:ring-1 focus:outline-none dark:border-stone-600 dark:bg-stone-800 dark:text-stone-100"
|
||||
>
|
||||
{#each data.ai.models as model}
|
||||
<option value={model}>{model}</option>
|
||||
<option value={model.name}>
|
||||
{model.display_name || model.name}{model.input_usd_per_m > 0
|
||||
? ` — $${model.input_usd_per_m.toFixed(2)} / $${model.output_usd_per_m.toFixed(2)} per 1M`
|
||||
: ''}{!model.stable ? ' (Preview)' : ''}
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
<button
|
||||
|
||||
@@ -302,6 +302,18 @@
|
||||
<!-- Market details -->
|
||||
<div class="rounded-lg border border-stone-200 p-6 dark:border-stone-700">
|
||||
<h2 class="mb-4 text-lg font-semibold">Details</h2>
|
||||
{#if data.market.image_url}
|
||||
<div class="mb-4">
|
||||
<img
|
||||
src={data.market.image_url}
|
||||
alt={data.market.name}
|
||||
class="max-h-48 rounded-lg object-contain"
|
||||
onerror={(e) => {
|
||||
(e.currentTarget as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
<dl class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-stone-500 dark:text-stone-400">Beschreibung</dt>
|
||||
@@ -359,6 +371,21 @@
|
||||
{data.market.slug}
|
||||
</dd>
|
||||
</div>
|
||||
{#if data.market.image_url}
|
||||
<div class="sm:col-span-2">
|
||||
<dt class="text-sm font-medium text-stone-500 dark:text-stone-400">Bild-URL</dt>
|
||||
<dd class="mt-1 text-sm break-all text-stone-900 dark:text-stone-100">
|
||||
<a
|
||||
href={data.market.image_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-primary-600 dark:text-primary-400 hover:underline"
|
||||
>
|
||||
{data.market.image_url}
|
||||
</a>
|
||||
</dd>
|
||||
</div>
|
||||
{/if}
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -253,8 +253,17 @@
|
||||
</nav>
|
||||
|
||||
{#if market.image_url}
|
||||
<div class="mb-8 overflow-hidden rounded-lg">
|
||||
<img src={market.image_url} alt={market.name} class="h-64 w-full object-cover sm:h-80" />
|
||||
<div class="mb-8 rounded-lg">
|
||||
<img
|
||||
src={market.image_url}
|
||||
alt={market.name}
|
||||
class="w-full rounded-lg"
|
||||
style="object-fit: contain; max-height: 250px;"
|
||||
onerror={(e) => {
|
||||
const wrap = e.currentTarget.parentElement;
|
||||
if (wrap) wrap.style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user