package controllers import ( "bytes" "encoding/json" "fmt" "html" "net/http" "os" "regexp" "strconv" "strings" "sync" "time" "fotbal-club/internal/config" "fotbal-club/internal/services" "fotbal-club/pkg/httpclient" "github.com/gin-gonic/gin" "gorm.io/gorm" ) // AIController handles AI-assisted endpoints type AIController struct { DB *gorm.DB } // GenerateCSS creates scoped CSS for a page element func (ac *AIController) GenerateCSS(c *gin.Context) { var req aiCSSRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } // Choose AI provider: Grok > DeepSeek > OpenRouter priority useGrok := false useDeepSeek := false baseURL := "" apiKey := "" if isGrokEnabled() { if k := getGrokAPIKey(); strings.TrimSpace(k) != "" { useGrok = true apiKey = k baseURL = getGrokBaseURL() } } if !useGrok && isDeepSeekEnabled() { if k := getDeepSeekAPIKey(); strings.TrimSpace(k) != "" { useDeepSeek = true apiKey = k baseURL = getDeepSeekBaseURL() } } if !useGrok && !useDeepSeek { if !isOpenRouterEnabled() { c.JSON(http.StatusBadRequest, gin.H{"error": "AI služba není povolena (zkontrolujte GROK_ON/DEEPSEEK_ON/OPENROUTER_ON)"}) return } apiKey = getOpenRouterAPIKey() baseURL = getOpenRouterBaseURL() } if strings.TrimSpace(apiKey) == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "AI API klíč není nastaven (Grok, DeepSeek ani OpenRouter)"}) return } // Primary and fallback models (fallbacks only relevant for OpenRouter) var model, fallbackModel, fallbackModel2 string if useGrok { // For CSS generation, use the non-reasoning model by default model = getGrokTextModelPrimary() fallbackModel = model fallbackModel2 = "" } else if useDeepSeek { model = getDeepSeekModel() // simple retry behaviour if needed fallbackModel = model fallbackModel2 = "" } else { model = getOpenRouterModel() if model == "" { model = "mistralai/mistral-small-3.2-24b-instruct:free" } fallbackModel = getOpenRouterFallbackModel() if fallbackModel == "" { fallbackModel = "mistralai/mistral-nemo:free" } fallbackModel2 = getOpenRouterFallbackModel2() } rootSelector := strings.TrimSpace(req.RootSelector) if rootSelector == "" { en := strings.TrimSpace(req.ElementName) if en == "" { en = "element" } rootSelector = fmt.Sprintf("[data-element=\"%s\"]", en) } themeJSON, _ := json.Marshal(req.Theme) stylesJSON, _ := json.Marshal(req.CurrentStyles) system := "Jsi zkušený CSS návrhář pro klubové weby. Piš čistý, přístupný a responzivní CSS. VÝSTUP POUZE JSON: {\"css\":\"...\"}. Nepoužívej reset, neovlivňuj globální prvky. CSS MUSÍ být scope-nuté POUZE pod kořenový selektor, žádný selektor mimo. Používej CSS proměnné (např. --club-primary, --club-secondary). Čeština není nutná v kódu, ale požadavky jsou v češtině." user := fmt.Sprintf("Požadavek: %s\nKořenový selektor: %s\nAktuální CSS (může být prázdné):\n---\n%s\n---\nAktuální styly (JSON): %s\nTéma (JSON): %s\nBreakpoints: %v\nPožadavky: 1) Scope pouze pod kořenový selektor. 2) Žádné !important. 3) Media queries pro mobil/tablet/desktop dle potřeby. 4) Zaměř se na vzhled prvků uvnitř bloku. 5) Nepřidávej inline styly ani globální sel. 6) Používej proměnné, zachovej kontrast a čitelnost.", strings.TrimSpace(req.Prompt), rootSelector, strings.TrimSpace(req.CurrentCSS), string(stylesJSON), string(themeJSON), req.Breakpoints) callModel := func(modelName string) (string, int, error) { payload := map[string]interface{}{ "model": modelName, "messages": []map[string]string{ {"role": "system", "content": system}, {"role": "user", "content": user}, }, "temperature": 0.3, "max_tokens": 1200, } body, _ := json.Marshal(payload) endpoint := strings.TrimRight(baseURL, "/") + "/chat/completions" reqHTTP, err := http.NewRequest("POST", endpoint, bytes.NewReader(body)) if err != nil { return "", http.StatusInternalServerError, err } reqHTTP.Header.Set("Authorization", "Bearer "+apiKey) reqHTTP.Header.Set("Content-Type", "application/json") if !useGrok && !useDeepSeek { // OpenRouter specific headers if ref := strings.TrimSpace(getenv("OPENROUTER_SITE_URL")); ref != "" { reqHTTP.Header.Set("HTTP-Referer", ref) } if ttl := strings.TrimSpace(getenv("OPENROUTER_APP_NAME")); ttl != "" { reqHTTP.Header.Set("X-Title", ttl) } } client := httpclient.SlowClient() resp, err := client.Do(reqHTTP) if err != nil { return "", http.StatusBadGateway, err } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { var e map[string]interface{} _ = json.NewDecoder(resp.Body).Decode(&e) return "", resp.StatusCode, fmt.Errorf("OpenRouter API error: %v", e) } var or struct { Choices []struct { Message struct { Content string `json:"content"` } `json:"message"` } `json:"choices"` } if err := json.NewDecoder(resp.Body).Decode(&or); err != nil { return "", http.StatusBadGateway, err } if len(or.Choices) == 0 { return "", http.StatusBadGateway, fmt.Errorf("empty choices") } return strings.TrimSpace(or.Choices[0].Message.Content), http.StatusOK, nil } content, _, err := callModel(model) if err != nil || strings.TrimSpace(content) == "" { fbContent, _, fbErr := callModel(fallbackModel) if fbErr == nil && strings.TrimSpace(fbContent) != "" { content = fbContent } else { fb2 := strings.TrimSpace(fallbackModel2) if fb2 != "" { fb2Content, _, fb2Err := callModel(fb2) if fb2Err == nil && strings.TrimSpace(fb2Content) != "" { content = fb2Content if reporter := services.NewErrorReporter(config.AppConfig); reporter != nil { reporter.Report(c.Request.Context(), &services.ErrorEvent{ Origin: "backend", Language: "go", Severity: "warning", Message: "OpenRouter fallback model 2 used", Component: "AIController", URL: c.Request.URL.Path, RequestID: c.GetString("request_id"), Tags: map[string]string{ "ai_primary": model, "ai_fallback1": fallbackModel, "ai_fallback2": fb2, "endpoint": c.FullPath(), }, }) } } else { if err != nil { c.JSON(http.StatusBadGateway, gin.H{"error": "OpenRouter selhal (včetně fallbacků)", "details": err.Error()}) } else if fbErr != nil { c.JSON(http.StatusBadGateway, gin.H{"error": "OpenRouter fallback1 selhal", "details": fbErr.Error()}) } else if fb2Err != nil { c.JSON(http.StatusBadGateway, gin.H{"error": "OpenRouter fallback2 selhal", "details": fb2Err.Error()}) } else { c.JSON(http.StatusBadGateway, gin.H{"error": "OpenRouter vrátil prázdnou odpověď"}) } return } } else { if err != nil { c.JSON(http.StatusBadGateway, gin.H{"error": "OpenRouter selhal (včetně fallbacku)", "details": err.Error()}) return } if fbErr != nil { c.JSON(http.StatusBadGateway, gin.H{"error": "OpenRouter fallback selhal", "details": fbErr.Error()}) return } c.JSON(http.StatusBadGateway, gin.H{"error": "OpenRouter vrátil prázdnou odpověď"}) return } } } sanitized := sanitizeAIResponse(content) var out aiCSSResponse if err := parseAIJSONIntoStruct(sanitized, &out); err != nil { if reporter := services.NewErrorReporter(config.AppConfig); reporter != nil { snip := sanitized if len(snip) > 600 { snip = snip[:600] + "..." } reporter.Report(c.Request.Context(), &services.ErrorEvent{ Origin: "backend", Language: "go", Severity: "warning", Message: "AI CSS parse failed; using fallback", Component: "AIController", URL: c.Request.URL.Path, RequestID: c.GetString("request_id"), Tags: map[string]string{ "endpoint": c.FullPath(), }, Context: map[string]interface{}{"sanitized": snip, "error": err.Error()}, }) } } if strings.TrimSpace(out.CSS) == "" { out.CSS = fmt.Sprintf("%s { }", rootSelector) } c.JSON(http.StatusOK, out) } type aiInstagramImageRequest struct { Prompt string `json:"prompt" binding:"required"` Aspect string `json:"aspect"` Count int `json:"count"` } type aiInstagramImageResponse struct { URLs []string `json:"urls"` } type aiMainImageRequest struct { Subject string `json:"subject"` Title string `json:"title" binding:"required"` Category string `json:"category"` ClubName string `json:"club_name"` PrimaryColor string `json:"primary_color"` SecondaryColor string `json:"secondary_color"` } type aiMainImageResponse struct { URL string `json:"url"` } func (ac *AIController) GenerateInstagramImages(c *gin.Context) { var req aiInstagramImageRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if !isXAIEnabled() { c.JSON(http.StatusBadRequest, gin.H{"error": "Grok XAI není povolen (zkontrolujte XAI_ON)"}) return } remaining, allowed := incrementXAIInstagramUsage(c) if !allowed { c.JSON(http.StatusTooManyRequests, gin.H{"error": "Denní limit pro generování Instagram obrázků byl vyčerpán."}) return } if remaining >= 0 { c.Header("X-AI-Model", getXAIImageModelInstagram()) c.Header("X-AI-Remaining", strconv.Itoa(remaining)) } aspect := strings.TrimSpace(req.Aspect) size := "1080x1080" if aspect == "4:5" || aspect == "1080x1350" { size = "1080x1350" } count := req.Count if count <= 0 { count = 2 } if count > 4 { count = 4 } model := getXAIImageModelInstagram() urls, status, err := callXAIImage(model, strings.TrimSpace(req.Prompt), size, count) if err != nil { c.JSON(status, gin.H{"error": "XAI image API chyba", "details": err.Error()}) return } if len(urls) == 0 { c.JSON(http.StatusBadGateway, gin.H{"error": "XAI image API nevrátilo žádnou URL"}) return } c.JSON(http.StatusOK, aiInstagramImageResponse{URLs: urls}) } func (ac *AIController) GenerateMainImage(c *gin.Context) { var req aiMainImageRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if !isXAIEnabled() { c.JSON(http.StatusBadRequest, gin.H{"error": "Grok XAI není povolen (zkontrolujte XAI_ON)"}) return } title := strings.TrimSpace(req.Title) if title == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "Titulek je povinný"}) return } subject := strings.ToLower(strings.TrimSpace(req.Subject)) if subject == "" { subject = "article" } clubName := strings.TrimSpace(req.ClubName) primaryColor := strings.TrimSpace(req.PrimaryColor) secondaryColor := strings.TrimSpace(req.SecondaryColor) var b strings.Builder if subject == "event" || subject == "activity" { if clubName != "" { b.WriteString("Vytvoř realistický, moderní fotografický banner bez textu pro klubovou událost fotbalového klubu ") b.WriteString(clubName) b.WriteString(" s názvem \"") b.WriteString(title) b.WriteString("\".") } else { b.WriteString("Vytvoř realistický, moderní fotografický banner bez textu pro klubovou událost s názvem \"") b.WriteString(title) b.WriteString("\" na oficiálním webu fotbalového klubu.") } } else { if clubName != "" { b.WriteString("Vytvoř realistický, moderní fotografický banner bez textu pro článek na oficiálním webu fotbalového klubu ") b.WriteString(clubName) b.WriteString(" s názvem \"") b.WriteString(title) b.WriteString("\".") } else { b.WriteString("Vytvoř realistický, moderní fotografický banner bez textu pro článek s názvem \"") b.WriteString(title) b.WriteString("\" na oficiálním webu fotbalového klubu.") } } if primaryColor != "" || secondaryColor != "" { b.WriteString(" Klubové barvy: ") if primaryColor != "" { b.WriteString(primaryColor) } if primaryColor != "" && secondaryColor != "" { b.WriteString(", ") } if secondaryColor != "" { b.WriteString(secondaryColor) } b.WriteString(".") } b.WriteString(" Styl: sportovní, realistický, stadion klubu, hráči a fanoušci v klubových barvách, bez textu, vhodné jako hlavní obrázek na webu v poměru stran 16:9.") prompt := b.String() model := getXAIImageModel() // Použijeme pevné rozlišení 1920x1080 pro články i aktivity urls, status, err := callXAIImage(model, prompt, "1920x1080", 1) if err != nil { c.JSON(status, gin.H{"error": "XAI image API chyba", "details": err.Error()}) return } if len(urls) == 0 || strings.TrimSpace(urls[0]) == "" { c.JSON(http.StatusBadGateway, gin.H{"error": "XAI image API nevrátilo žádnou URL"}) return } c.JSON(http.StatusOK, aiMainImageResponse{URL: urls[0]}) } // GenerateAboutPage creates about page content using the OpenRouter API func (ac *AIController) GenerateAboutPage(c *gin.Context) { var req aiAboutRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } clubName := strings.TrimSpace(req.ClubName) if clubName == "" { clubName = "Fotbalový klub" } style := strings.TrimSpace(req.Style) if style == "" { style = "default" } audience := strings.TrimSpace(req.Audience) if audience == "" { audience = "fanoušci klubu" } system := "Jsi zkušený editor klubových webů. Pomůžeš napsat stránku 'O klubu'. DŮLEŽITÉ: Odpovídej v GRAMATICKY SPRÁVNÉ češtině - používej pouze existující česká slova a správné tvary. Žádné neologismy nebo negramatické tvary. Odpovídej česky, srozumitelně a profesionálně." user := fmt.Sprintf("Poznámky k vytvoření stránky O klubu:\n---\n%s\n---\nNázev klubu: %s\nPreferovaný styl: %s\nCílové publikum: %s\n\nPovinné požadavky:\n1) Zachovej fakta z poznámek a rozšiř je o kontext (historie, hodnoty, úspěchy, tým, zázemí, komunita).\n2) Rozděl text do sekcí s HTML nadpisy (h2/h3) a odstavci (p). Můžeš použít seznamy (ul/li) tam, kde to dává smysl. Bez inline stylů.\n3) Napiš krátký podnadpis (subtitle) vystihující náladu klubu.\n4) Vytvoř SEO titulek (do 60 znaků) a SEO popis (do 160 znaků).\n5) Odpověz POUZE JSON: {\"title\":\"...\", \"subtitle\":\"...\", \"html\":\"...\", \"seo_title\":\"...\", \"seo_description\":\"...\"}.\n6) HTML pole musí obsahovat kompletní obsah stránky dle požadavků.\n", strings.TrimSpace(req.Prompt), clubName, style, audience) requestedModel := strings.TrimSpace(req.Model) if requestedModel == "mistral-small-latest" || requestedModel == "ministral-14b-latest" { if !isMistralEnabled() { c.JSON(http.StatusBadRequest, gin.H{"error": "Mistral AI není povolen (zkontrolujte MISTRAL_ON)"}) return } remaining, allowed := incrementAIUsage(c, requestedModel) if !allowed { c.JSON(http.StatusTooManyRequests, gin.H{"error": "Denní limit pro tento AI model byl vyčerpán."}) return } if remaining >= 0 { c.Header("X-AI-Model", requestedModel) c.Header("X-AI-Remaining", strconv.Itoa(remaining)) } content, status, err := callMistralChat(requestedModel, system, user) if err != nil { c.JSON(status, gin.H{"error": "Mistral API chyba", "details": err.Error()}) return } finalizeAboutResponse(c, content, &req, clubName) return } // Choose AI provider: Grok > DeepSeek > OpenRouter priority useGrok := false useDeepSeek := false baseURL := "" apiKey := "" if isGrokEnabled() { if k := getGrokAPIKey(); strings.TrimSpace(k) != "" { useGrok = true apiKey = k baseURL = getGrokBaseURL() } } if !useGrok && isDeepSeekEnabled() { if k := getDeepSeekAPIKey(); strings.TrimSpace(k) != "" { useDeepSeek = true apiKey = k baseURL = getDeepSeekBaseURL() } } if !useGrok && !useDeepSeek { if !isOpenRouterEnabled() { c.JSON(http.StatusBadRequest, gin.H{"error": "AI služba není povolena (zkontrolujte GROK_ON/DEEPSEEK_ON/OPENROUTER_ON)"}) return } apiKey = getOpenRouterAPIKey() baseURL = getOpenRouterBaseURL() } if strings.TrimSpace(apiKey) == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "AI API klíč není nastaven (Grok, DeepSeek ani OpenRouter)"}) return } var model, fallbackModel, fallbackModel2 string if useGrok { // For about page generation, use the non-reasoning model by default model = getGrokTextModelPrimary() fallbackModel = model fallbackModel2 = "" } else if useDeepSeek { model = getDeepSeekModel() fallbackModel = model fallbackModel2 = "" } else { model = getOpenRouterModel() if model == "" { model = "mistralai/mistral-small-3.2-24b-instruct:free" } fallbackModel = getOpenRouterFallbackModel() if fallbackModel == "" { fallbackModel = "mistralai/mistral-nemo:free" } fallbackModel2 = getOpenRouterFallbackModel2() } callModel := func(modelName string) (string, int, error) { payload := map[string]interface{}{ "model": modelName, "messages": []map[string]string{ {"role": "system", "content": system}, {"role": "user", "content": user}, }, "temperature": 0.5, "max_tokens": 2200, } body, _ := json.Marshal(payload) endpoint := strings.TrimRight(baseURL, "/") + "/chat/completions" reqHTTP, err := http.NewRequest("POST", endpoint, bytes.NewReader(body)) if err != nil { return "", http.StatusInternalServerError, err } reqHTTP.Header.Set("Authorization", "Bearer "+apiKey) reqHTTP.Header.Set("Content-Type", "application/json") if !useGrok && !useDeepSeek { // OpenRouter specific headers if ref := strings.TrimSpace(getenv("OPENROUTER_SITE_URL")); ref != "" { reqHTTP.Header.Set("HTTP-Referer", ref) } if ttl := strings.TrimSpace(getenv("OPENROUTER_APP_NAME")); ttl != "" { reqHTTP.Header.Set("X-Title", ttl) } } client := &http.Client{Timeout: 45 * time.Second} resp, err := client.Do(reqHTTP) if err != nil { return "", http.StatusBadGateway, err } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { var e map[string]interface{} _ = json.NewDecoder(resp.Body).Decode(&e) return "", resp.StatusCode, fmt.Errorf("OpenRouter API error: %v", e) } var or struct { Choices []struct { Message struct { Content string `json:"content"` } `json:"message"` } `json:"choices"` } if err := json.NewDecoder(resp.Body).Decode(&or); err != nil { return "", http.StatusBadGateway, err } if len(or.Choices) == 0 { return "", http.StatusBadGateway, fmt.Errorf("empty choices") } return strings.TrimSpace(or.Choices[0].Message.Content), http.StatusOK, nil } content, _, err := callModel(model) if err != nil || strings.TrimSpace(content) == "" { fbContent, _, fbErr := callModel(fallbackModel) if fbErr == nil && strings.TrimSpace(fbContent) != "" { content = fbContent } else { fb2 := strings.TrimSpace(fallbackModel2) if fb2 != "" { fb2Content, _, fb2Err := callModel(fb2) if fb2Err == nil && strings.TrimSpace(fb2Content) != "" { content = fb2Content if reporter := services.NewErrorReporter(config.AppConfig); reporter != nil { reporter.Report(c.Request.Context(), &services.ErrorEvent{ Origin: "backend", Language: "go", Severity: "warning", Message: "OpenRouter fallback model 2 used", Component: "AIController", URL: c.Request.URL.Path, RequestID: c.GetString("request_id"), Tags: map[string]string{ "ai_primary": model, "ai_fallback1": fallbackModel, "ai_fallback2": fb2, "endpoint": c.FullPath(), }, }) } } else { if err != nil { c.JSON(http.StatusBadGateway, gin.H{"error": "OpenRouter selhal (včetně fallbacků)", "details": err.Error()}) } else if fbErr != nil { c.JSON(http.StatusBadGateway, gin.H{"error": "OpenRouter fallback1 selhal", "details": fbErr.Error()}) } else if fb2Err != nil { c.JSON(http.StatusBadGateway, gin.H{"error": "OpenRouter fallback2 selhal", "details": fb2Err.Error()}) } else { c.JSON(http.StatusBadGateway, gin.H{"error": "OpenRouter vrátil prázdnou odpověď"}) } return } } else { if err != nil { c.JSON(http.StatusBadGateway, gin.H{"error": "OpenRouter selhal (včetně fallbacku)", "details": err.Error()}) } else if fbErr != nil { c.JSON(http.StatusBadGateway, gin.H{"error": "OpenRouter fallback selhal", "details": fbErr.Error()}) } else { c.JSON(http.StatusBadGateway, gin.H{"error": "OpenRouter vrátil prázdnou odpověď"}) } return } } } finalizeAboutResponse(c, content, &req, clubName) } type aiInstaMatch struct { Home string `json:"home"` Away string `json:"away"` Competition string `json:"competition"` DateTime string `json:"date_time"` Venue string `json:"venue"` Score string `json:"score"` } type aiInstagramRequest struct { Type string `json:"type"` // "article" | "event" | "generic" Title string `json:"title"` Content string `json:"content"` // plain text, HTML will be ignored ClubName string `json:"club_name"` Link string `json:"link"` Hashtags []string `json:"hashtags"` Audience string `json:"audience"` Tone string `json:"tone"` Category string `json:"category"` Match *aiInstaMatch `json:"match"` } type aiInstagramResponse struct { Text string `json:"text"` } // GenerateInstagram creates an Instagram caption in Czech using OpenRouter func (ac *AIController) GenerateInstagram(c *gin.Context) { var req aiInstagramRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } // Normalize t := strings.ToLower(strings.TrimSpace(req.Type)) if t == "" { t = "article" } club := strings.TrimSpace(req.ClubName) if club == "" { club = "Náš klub" } audience := strings.TrimSpace(req.Audience) if audience == "" { audience = "fanoušci klubu" } tone := strings.TrimSpace(req.Tone) if tone == "" { tone = "informativní, přátelský" } // Build system and user messages system := "Jsi zkušený český social media copywriter pro fotbalový klub. Píšeš poutavé, ale profesionální popisky na Instagram v gramaticky správné češtině (bez neologismů). Buď konkrétní, z textu vyber to nejdůležitější, vyhni se klišé. Výsledek vrať POUZE JSON: {\"text\": \"...\"}." // Compose contextual notes var notes []string if req.Title != "" { notes = append(notes, "Titulek: "+req.Title) } if strings.TrimSpace(req.Content) != "" { notes = append(notes, "Obsah (zkrácený): "+strings.TrimSpace(req.Content)) } if strings.TrimSpace(req.Category) != "" { notes = append(notes, "Kategorie: "+strings.TrimSpace(req.Category)) } if req.Match != nil { m := req.Match line := []string{} if m.Home != "" || m.Away != "" { line = append(line, fmt.Sprintf("%s vs %s", strings.TrimSpace(m.Home), strings.TrimSpace(m.Away))) } if strings.TrimSpace(m.Score) != "" { line = append(line, "Výsledek: "+strings.TrimSpace(m.Score)) } if strings.TrimSpace(m.Competition) != "" { line = append(line, strings.TrimSpace(m.Competition)) } if strings.TrimSpace(m.DateTime) != "" { line = append(line, strings.TrimSpace(m.DateTime)) } if strings.TrimSpace(m.Venue) != "" { line = append(line, "Místo: "+strings.TrimSpace(m.Venue)) } if len(line) > 0 { notes = append(notes, "Zápas: "+strings.Join(line, " • ")) } } if strings.TrimSpace(req.Link) != "" { notes = append(notes, "Krátký odkaz: "+strings.TrimSpace(req.Link)) } if len(req.Hashtags) > 0 { notes = append(notes, "Preferované hashtagy: "+strings.Join(req.Hashtags, ", ")) } // Hard requirements requirements := []string{ "Délka 50–90 slov. Max. 2 krátké odstavce, max. 2 věty v odstavci.", "Použij maximálně 6 emotikonů (žádné dlouhé řetězy).", "Nevkládej žádné obrázky ani popisy fotografií. Výstup je čistý text bez HTML.", "Na konec vlož oddělovač a řádek '🔗 ' následovaný přesně poskytnutým krátkým odkazem (jediný odkaz).", "Přidej 4–6 relevantních českých hashtagů (včetně klubového), přirozeně na konci.", "Pokud jsou v poznámkách údaje o zápase, uveď soutěž, datum (formátuj česky) a místo (bez detailů za ' - ').", "Preferuj začít titulkem s názvem kategorie, pokud je v poznámkách (např. '[Kategorie] …' nebo 'Kategorie – …').", "Drž se zadaného obsahu. Bez vymýšlení neexistujících informací.", fmt.Sprintf("Tón: %s. Publikum: %s.", tone, audience), } // Build user prompt user := fmt.Sprintf("Typ: %s\nKlub: %s\n\nPoznámky:\n- %s\n\nPožadavky:\n- %s\n\nVrať POUZE JSON bez formátování.", t, club, strings.Join(notes, "\n- "), strings.Join(requirements, "\n- ")) // Choose AI provider: Grok > DeepSeek > OpenRouter priority useGrok := false useDeepSeek := false baseURL := "" apiKey := "" if isGrokEnabled() { if k := getGrokAPIKey(); strings.TrimSpace(k) != "" { useGrok = true apiKey = k baseURL = getGrokBaseURL() } } if !useGrok && isDeepSeekEnabled() { if k := getDeepSeekAPIKey(); strings.TrimSpace(k) != "" { useDeepSeek = true apiKey = k baseURL = getDeepSeekBaseURL() } } if !useGrok && !useDeepSeek { if !isOpenRouterEnabled() { c.JSON(http.StatusBadRequest, gin.H{"error": "AI služba není povolena (zkontrolujte GROK_ON/DEEPSEEK_ON/OPENROUTER_ON)"}) return } apiKey = getOpenRouterAPIKey() baseURL = getOpenRouterBaseURL() } if strings.TrimSpace(apiKey) == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "AI API klíč není nastaven (Grok, DeepSeek ani OpenRouter)"}) return } var model, fallbackModel, fallbackModel2 string if useGrok { // For Instagram generation, use the non-reasoning model by default model = getGrokTextModelPrimary() fallbackModel = model fallbackModel2 = "" } else if useDeepSeek { model = getDeepSeekModel() fallbackModel = model fallbackModel2 = "" } else { model = getOpenRouterModel() if model == "" { model = "mistralai/mistral-small-3.2-24b-instruct:free" } fallbackModel = getOpenRouterFallbackModel() if fallbackModel == "" { fallbackModel = "mistralai/mistral-nemo:free" } fallbackModel2 = getOpenRouterFallbackModel2() } callModel := func(modelName string) (string, int, error) { payload := map[string]interface{}{ "model": modelName, "messages": []map[string]string{ {"role": "system", "content": system}, {"role": "user", "content": user}, }, "temperature": 0.5, "max_tokens": 800, } body, _ := json.Marshal(payload) endpoint := strings.TrimRight(baseURL, "/") + "/chat/completions" reqHTTP, err := http.NewRequest("POST", endpoint, bytes.NewReader(body)) if err != nil { return "", http.StatusInternalServerError, err } reqHTTP.Header.Set("Authorization", "Bearer "+apiKey) reqHTTP.Header.Set("Content-Type", "application/json") if !useGrok && !useDeepSeek { // OpenRouter specific headers if ref := strings.TrimSpace(getenv("OPENROUTER_SITE_URL")); ref != "" { reqHTTP.Header.Set("HTTP-Referer", ref) } if ttl := strings.TrimSpace(getenv("OPENROUTER_APP_NAME")); ttl != "" { reqHTTP.Header.Set("X-Title", ttl) } } client := &http.Client{Timeout: 45 * time.Second} resp, err := client.Do(reqHTTP) if err != nil { return "", http.StatusBadGateway, err } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { var e map[string]interface{} _ = json.NewDecoder(resp.Body).Decode(&e) return "", resp.StatusCode, fmt.Errorf("OpenRouter API error: %v", e) } var or struct { Choices []struct { Message struct { Content string `json:"content"` } `json:"message"` } `json:"choices"` } if err := json.NewDecoder(resp.Body).Decode(&or); err != nil { return "", http.StatusBadGateway, err } if len(or.Choices) == 0 { return "", http.StatusBadGateway, fmt.Errorf("empty choices") } return strings.TrimSpace(or.Choices[0].Message.Content), http.StatusOK, nil } content, _, err := callModel(model) if err != nil || strings.TrimSpace(content) == "" { fbContent, _, fbErr := callModel(fallbackModel) if fbErr == nil && strings.TrimSpace(fbContent) != "" { content = fbContent } else { fb2 := strings.TrimSpace(fallbackModel2) if fb2 != "" { fb2Content, _, fb2Err := callModel(fb2) if fb2Err == nil && strings.TrimSpace(fb2Content) != "" { content = fb2Content if reporter := services.NewErrorReporter(config.AppConfig); reporter != nil { reporter.Report(c.Request.Context(), &services.ErrorEvent{ Origin: "backend", Language: "go", Severity: "warning", Message: "OpenRouter fallback model 2 used", Component: "AIController", URL: c.Request.URL.Path, RequestID: c.GetString("request_id"), Tags: map[string]string{ "ai_primary": model, "ai_fallback1": fallbackModel, "ai_fallback2": fb2, "endpoint": c.FullPath(), }, }) } } else { if err != nil { c.JSON(http.StatusBadGateway, gin.H{"error": "OpenRouter selhal (včetně fallbacků)", "details": err.Error()}) } else if fbErr != nil { c.JSON(http.StatusBadGateway, gin.H{"error": "OpenRouter fallback1 selhal", "details": fbErr.Error()}) } else if fb2Err != nil { c.JSON(http.StatusBadGateway, gin.H{"error": "OpenRouter fallback2 selhal", "details": fb2Err.Error()}) } else { c.JSON(http.StatusBadGateway, gin.H{"error": "OpenRouter vrátil prázdnou odpověď"}) } return } } else { if err != nil { c.JSON(http.StatusBadGateway, gin.H{"error": "OpenRouter selhal (včetně fallbacku)", "details": err.Error()}) } else if fbErr != nil { c.JSON(http.StatusBadGateway, gin.H{"error": "OpenRouter fallback selhal", "details": fbErr.Error()}) } else { c.JSON(http.StatusBadGateway, gin.H{"error": "OpenRouter vrátil prázdnou odpověď"}) } return } } } sanitized := sanitizeAIResponse(content) var out aiInstagramResponse if err := parseAIJSONIntoStruct(sanitized, &out); err != nil { if reporter := services.NewErrorReporter(config.AppConfig); reporter != nil { snip := sanitized if len(snip) > 600 { snip = snip[:600] + "..." } reporter.Report(c.Request.Context(), &services.ErrorEvent{ Origin: "backend", Language: "go", Severity: "warning", Message: "AI Instagram parse failed; using fallback", Component: "AIController", URL: c.Request.URL.Path, RequestID: c.GetString("request_id"), Tags: map[string]string{ "endpoint": c.FullPath(), }, Context: map[string]interface{}{"sanitized": snip, "error": err.Error()}, }) } } if strings.TrimSpace(out.Text) == "" { // minimal fallback txt := req.Title if txt == "" { txt = "Novinky z klubu" } out.Text = fmt.Sprintf("%s\n\n🔗 %s", txt, strings.TrimSpace(req.Link)) } c.JSON(http.StatusOK, out) } // TranslateText translates text from Czech to English using AI func (ac *AIController) TranslateText(c *gin.Context) { var req struct { Text string `json:"text" binding:"required"` From string `json:"from"` // optional, default "cs" To string `json:"to"` // optional, default "en" } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } // Default to Czech to English fromLang := strings.TrimSpace(req.From) if fromLang == "" { fromLang = "cs" } toLang := strings.TrimSpace(req.To) if toLang == "" { toLang = "en" } // Choose AI provider: Grok > DeepSeek > OpenRouter priority useGrok := false useDeepSeek := false baseURL := "" apiKey := "" if isGrokEnabled() { if k := getGrokAPIKey(); strings.TrimSpace(k) != "" { useGrok = true apiKey = k baseURL = getGrokBaseURL() } } if !useGrok && isDeepSeekEnabled() { if k := getDeepSeekAPIKey(); strings.TrimSpace(k) != "" { useDeepSeek = true apiKey = k baseURL = getDeepSeekBaseURL() } } if !useGrok && !useDeepSeek { if !isOpenRouterEnabled() { c.JSON(http.StatusBadRequest, gin.H{"error": "AI služba není povolena (zkontrolujte GROK_ON/DEEPSEEK_ON/OPENROUTER_ON)"}) return } apiKey = getOpenRouterAPIKey() baseURL = getOpenRouterBaseURL() } if strings.TrimSpace(apiKey) == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "AI API klíč není nastaven (Grok, DeepSeek ani OpenRouter)"}) return } var model, fallbackModel, fallbackModel2 string if useGrok { model = getGrokTextModelPrimary() fallbackModel = model fallbackModel2 = "" } else if useDeepSeek { model = getDeepSeekModel() fallbackModel = model fallbackModel2 = "" } else { model = getOpenRouterModel() if model == "" { model = "mistralai/mistral-small-3.2-24b-instruct:free" } fallbackModel = getOpenRouterFallbackModel() if fallbackModel == "" { fallbackModel = "mistralai/mistral-nemo:free" } fallbackModel2 = getOpenRouterFallbackModel2() } system := fmt.Sprintf("You are a professional translator. Translate the given text from %s to %s. Preserve the original meaning, tone, and formatting. Return ONLY the translated text without any additional explanations or formatting.", fromLang, toLang) user := strings.TrimSpace(req.Text) callModel := func(modelName string) (string, int, error) { payload := map[string]interface{}{ "model": modelName, "messages": []map[string]string{ {"role": "system", "content": system}, {"role": "user", "content": user}, }, "temperature": 0.3, "max_tokens": 2000, } body, _ := json.Marshal(payload) endpoint := strings.TrimRight(baseURL, "/") + "/chat/completions" reqHTTP, err := http.NewRequest("POST", endpoint, bytes.NewReader(body)) if err != nil { return "", http.StatusInternalServerError, err } reqHTTP.Header.Set("Authorization", "Bearer "+apiKey) reqHTTP.Header.Set("Content-Type", "application/json") if !useGrok && !useDeepSeek { // OpenRouter specific headers if ref := strings.TrimSpace(getenv("OPENROUTER_SITE_URL")); ref != "" { reqHTTP.Header.Set("HTTP-Referer", ref) } if ttl := strings.TrimSpace(getenv("OPENROUTER_APP_NAME")); ttl != "" { reqHTTP.Header.Set("X-Title", ttl) } } client := httpclient.SlowClient() resp, err := client.Do(reqHTTP) if err != nil { return "", http.StatusBadGateway, err } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { var e map[string]interface{} _ = json.NewDecoder(resp.Body).Decode(&e) return "", resp.StatusCode, fmt.Errorf("AI API error: %v", e) } var or struct { Choices []struct { Message struct { Content string `json:"content"` } `json:"message"` } `json:"choices"` } if err := json.NewDecoder(resp.Body).Decode(&or); err != nil { return "", http.StatusBadGateway, err } if len(or.Choices) == 0 { return "", http.StatusBadGateway, fmt.Errorf("empty choices") } return strings.TrimSpace(or.Choices[0].Message.Content), http.StatusOK, nil } content, _, err := callModel(model) if err != nil || strings.TrimSpace(content) == "" { fbContent, _, fbErr := callModel(fallbackModel) if fbErr == nil && strings.TrimSpace(fbContent) != "" { content = fbContent } else { fb2 := strings.TrimSpace(fallbackModel2) if fb2 != "" { fb2Content, _, fb2Err := callModel(fb2) if fb2Err == nil && strings.TrimSpace(fb2Content) != "" { content = fb2Content } else { c.JSON(http.StatusBadGateway, gin.H{"error": "Translation failed"}) return } } else { c.JSON(http.StatusBadGateway, gin.H{"error": "Translation failed"}) return } } } c.JSON(http.StatusOK, gin.H{ "translated_text": content, "from": fromLang, "to": toLang, }) } func (ac *AIController) GenerateBlog(c *gin.Context) { var req aiBlogRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if req.MinWords <= 0 { req.MinWords = 450 } // Build instruction in Czech - emphasize richer HTML output and medium length system := "Jsi asistent pro tvorbu článků. Tvým HLAVNÍM úkolem je: PŘEVZÍT TEXT OD UŽIVATELE a rozvinout ho do čitelného článku. Vždy vycházej z textu uživatele - zachovej VŠECHNY jeho informace, fakta a události. Pokud je text krátký, přidej kontext, rozvinutí a souvislosti, ale vždy kolem témat a informací z textu uživatele. Rozděl text do logických odstavců a používej bohaté HTML prvky: nadpisy h2/h3, odstavce p, seznamy ul/li (alespoň jeden), zvýraznění strong/em, případně krátký blockquote (max 1). DŮLEŽITÉ: Píšeš v GRAMATICKY SPRÁVNÉ češtině - používej pouze existující česká slova a správné tvary. Žádné neologismy ani negramatické tvary. HTML výstup BEZ inline stylů." user := fmt.Sprintf("Text od uživatele (VŽDY z něj vycházej, zachovej všechny jeho informace):\n---\n%s\n---\nPublikum: %s\nCílová délka: %d slov (středně dlouhý článek).\n\nPOVINNÉ POŽADAVKY:\n1) ZACHOVEJ všechny informace, jména, události a fakta z textu uživatele. To je ZÁKLAD článku.\n2) Pokud je text krátký (pod %d slov), ROZVIŇ ho - přidej kontext, atmosféru a detaily okolo událostí z textu uživatele.\n3) Použij bohaté HTML: nadpisy h2/h3, odstavce p, seznamy ul/li (alespoň jeden), zvýraznění strong/em; volitelně 1× blockquote.\n4) Vygeneruj výstižný titulek z obsahu textu uživatele.\n5) Vytvoř URL slug (3–5 slov, max. 40 znaků, lowercase, bez diakritiky, jen písmena/číslice a pomlčky).\n6) Odpověz POUZE JSON: {\"title\": \"...\", \"slug\": \"...\", \"html\": \"...\"}.\n7) HTML bez inline stylů, žádné / tagy.\n\nPAMATUJ: Text uživatele = základ. Pokud je krátký, rozviň ho čtivě a zajímavě pro %s.\n", strings.TrimSpace(req.Prompt), strings.TrimSpace(req.Audience), req.MinWords, req.MinWords, strings.TrimSpace(req.Audience)) requestedModel := strings.TrimSpace(req.Model) // If explicitly requested Mistral model and Mistral is enabled, use direct Mistral API if requestedModel == "mistral-small-latest" || requestedModel == "ministral-14b-latest" { if !isMistralEnabled() { c.JSON(http.StatusBadRequest, gin.H{"error": "Mistral AI není povolen (zkontrolujte MISTRAL_ON)"}) return } remaining, allowed := incrementAIUsage(c, requestedModel) if !allowed { c.JSON(http.StatusTooManyRequests, gin.H{"error": "Denní limit pro tento AI model byl vyčerpán."}) return } if remaining >= 0 { c.Header("X-AI-Model", requestedModel) c.Header("X-AI-Remaining", strconv.Itoa(remaining)) } content, status, err := callMistralChat(requestedModel, system, user) if err != nil { c.JSON(status, gin.H{"error": "Mistral API chyba", "details": err.Error()}) return } finalizeBlogResponse(c, content, &req) return } // If explicitly requested Grok model and Grok is enabled, use direct Grok API if requestedModel == "grok-4-1-fast-non-reasoning" || requestedModel == "grok-4-1-fast-reasoning" { if !isGrokEnabled() { c.JSON(http.StatusBadRequest, gin.H{"error": "Grok AI není povolen (zkontrolujte GROK_ON)"}) return } remaining, allowed := incrementAIUsage(c, requestedModel) if !allowed { c.JSON(http.StatusTooManyRequests, gin.H{"error": "Denní limit pro tento AI model byl vyčerpán."}) return } if remaining >= 0 { c.Header("X-AI-Model", requestedModel) c.Header("X-AI-Remaining", strconv.Itoa(remaining)) } content, status, err := callGrokChat(requestedModel, system, user) if err != nil { c.JSON(status, gin.H{"error": "Grok API chyba", "details": err.Error()}) return } finalizeBlogResponse(c, content, &req) return } // Prepare AI request (Grok > DeepSeek > OpenRouter priority) useGrok := false useDeepSeek := false baseURL := "" apiKey := "" if isGrokEnabled() { if k := getGrokAPIKey(); strings.TrimSpace(k) != "" { useGrok = true apiKey = k baseURL = getGrokBaseURL() } } if !useGrok && isDeepSeekEnabled() { if k := getDeepSeekAPIKey(); strings.TrimSpace(k) != "" { useDeepSeek = true apiKey = k baseURL = getDeepSeekBaseURL() } } if !useGrok && !useDeepSeek { if !isOpenRouterEnabled() { c.JSON(http.StatusBadRequest, gin.H{"error": "AI služba není povolena (zkontrolujte GROK_ON/DEEPSEEK_ON/OPENROUTER_ON)"}) return } apiKey = getOpenRouterAPIKey() baseURL = getOpenRouterBaseURL() } if strings.TrimSpace(apiKey) == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "AI API klíč není nastaven (Grok, DeepSeek ani OpenRouter)"}) return } // Primary and fallback models var model, fallbackModel, fallbackModel2 string if useGrok { // For blog generation, use the non-reasoning model by default model = getGrokTextModelPrimary() fallbackModel = model fallbackModel2 = "" } else if useDeepSeek { model = getDeepSeekModel() fallbackModel = model fallbackModel2 = "" } else { model = getOpenRouterModel() if model == "" { model = "mistralai/mistral-small-3.2-24b-instruct:free" } fallbackModel = getOpenRouterFallbackModel() if fallbackModel == "" { fallbackModel = "mistralai/mistral-nemo:free" } fallbackModel2 = getOpenRouterFallbackModel2() } // Helper to call OpenRouter with a given model and return content callModel := func(modelName string) (string, int, error) { payload := map[string]interface{}{ "model": modelName, "messages": []map[string]string{ {"role": "system", "content": system}, {"role": "user", "content": user}, }, "temperature": 0.5, "max_tokens": 2000, } body, _ := json.Marshal(payload) endpoint := strings.TrimRight(baseURL, "/") + "/chat/completions" reqHTTP, err := http.NewRequest("POST", endpoint, bytes.NewReader(body)) if err != nil { return "", http.StatusInternalServerError, err } reqHTTP.Header.Set("Authorization", "Bearer "+apiKey) reqHTTP.Header.Set("Content-Type", "application/json") // Optional but recommended headers for OpenRouter if !useGrok && !useDeepSeek { if ref := strings.TrimSpace(getenv("OPENROUTER_SITE_URL")); ref != "" { reqHTTP.Header.Set("HTTP-Referer", ref) } if ttl := strings.TrimSpace(getenv("OPENROUTER_APP_NAME")); ttl != "" { reqHTTP.Header.Set("X-Title", ttl) } } client := &http.Client{Timeout: 45 * time.Second} resp, err := client.Do(reqHTTP) if err != nil { return "", http.StatusBadGateway, err } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { var e map[string]interface{} _ = json.NewDecoder(resp.Body).Decode(&e) return "", resp.StatusCode, fmt.Errorf("OpenRouter API error: %v", e) } // OpenAI-compatible response var or struct { Choices []struct { Message struct { Content string `json:"content"` } `json:"message"` } `json:"choices"` } if err := json.NewDecoder(resp.Body).Decode(&or); err != nil { return "", http.StatusBadGateway, err } if len(or.Choices) == 0 { return "", http.StatusBadGateway, fmt.Errorf("empty choices") } return strings.TrimSpace(or.Choices[0].Message.Content), http.StatusOK, nil } // Try primary, then fallback content, _, err := callModel(model) if err != nil || strings.TrimSpace(content) == "" { fbContent, _, fbErr := callModel(fallbackModel) if fbErr == nil && strings.TrimSpace(fbContent) != "" { content = fbContent } else { fb2 := strings.TrimSpace(fallbackModel2) if fb2 != "" { fb2Content, _, fb2Err := callModel(fb2) if fb2Err == nil && strings.TrimSpace(fb2Content) != "" { content = fb2Content if reporter := services.NewErrorReporter(config.AppConfig); reporter != nil { reporter.Report(c.Request.Context(), &services.ErrorEvent{ Origin: "backend", Language: "go", Severity: "warning", Message: "OpenRouter fallback model 2 used", Component: "AIController", URL: c.Request.URL.Path, RequestID: c.GetString("request_id"), Tags: map[string]string{ "ai_primary": model, "ai_fallback1": fallbackModel, "ai_fallback2": fb2, "endpoint": c.FullPath(), }, }) } } else { if err != nil { c.JSON(http.StatusBadGateway, gin.H{"error": "OpenRouter selhal (včetně fallbacků)", "details": err.Error()}) } else if fbErr != nil { c.JSON(http.StatusBadGateway, gin.H{"error": "OpenRouter fallback1 selhal", "details": fbErr.Error()}) } else if fb2Err != nil { c.JSON(http.StatusBadGateway, gin.H{"error": "OpenRouter fallback2 selhal", "details": fb2Err.Error()}) } else { c.JSON(http.StatusBadGateway, gin.H{"error": "OpenRouter vrátil prázdnou odpověď"}) } return } } else { if err != nil { c.JSON(http.StatusBadGateway, gin.H{"error": "OpenRouter selhal (včetně fallbacku)", "details": err.Error()}) } else if fbErr != nil { c.JSON(http.StatusBadGateway, gin.H{"error": "OpenRouter fallback selhal", "details": fbErr.Error()}) } else { c.JSON(http.StatusBadGateway, gin.H{"error": "OpenRouter vrátil prázdnou odpověď"}) } return } } } finalizeBlogResponse(c, content, &req) } func (ac *AIController) ProcessOCR(c *gin.Context) { var req aiOCRRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } // Check if explicitly requested model is provided and handle accordingly requestedModel := strings.TrimSpace(req.Model) if requestedModel != "" { // Handle explicit model requests if requestedModel == "mistral-ocr-latest" { if !isMistralEnabled() { c.JSON(http.StatusBadRequest, gin.H{"error": "Mistral AI není povolen (zkontrolujte MISTRAL_ON)"}) return } } else if requestedModel == "deepseek-chat" || requestedModel == "deepseek-reasoner" { if !isDeepSeekEnabled() { c.JSON(http.StatusBadRequest, gin.H{"error": "DeepSeek AI není povolen (zkontrolujte DEEPSEEK_ON)"}) return } } else if requestedModel == "grok-4-1-fast-non-reasoning" || requestedModel == "grok-4-1-fast-reasoning" { if !isGrokEnabled() { c.JSON(http.StatusBadRequest, gin.H{"error": "Grok AI není povolen (zkontrolujte GROK_ON)"}) return } } remaining, allowed := incrementAIUsage(c, requestedModel) if !allowed { c.JSON(http.StatusTooManyRequests, gin.H{"error": "Denní limit pro tento AI model byl vyčerpán."}) return } if remaining >= 0 { c.Header("X-AI-Model", requestedModel) c.Header("X-AI-Remaining", strconv.Itoa(remaining)) } // Process OCR with the requested model docURL := strings.TrimSpace(req.DocumentURL) imgURL := strings.TrimSpace(req.ImageURL) if docURL == "" && imgURL == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "Musíte zadat document_url nebo image_url."}) return } // Use the appropriate OCR function based on model var result string var status int var err error if requestedModel == "mistral-ocr-latest" { result, status, err = callMistralOCR(requestedModel, map[string]interface{}{ "document": map[string]interface{}{ "type": "url", "url": docURL, }, "image": map[string]interface{}{ "type": "url", "url": imgURL, }, }, []int{}) } else { // For non-Mistral models, use chat completion with OCR prompt srcURL := docURL if srcURL == "" { srcURL = imgURL } system := "Jsi OCR specialista. Extrahuj veškerý text z poskytnutého dokumentu nebo obrázku. Vrať POUZEJ čistý text bez formátování." user := fmt.Sprintf("Extrahuj text z tohoto dokumentu: %s", srcURL) if requestedModel == "grok-4-1-fast-non-reasoning" || requestedModel == "grok-4-1-fast-reasoning" { result, status, err = callGrokChat(requestedModel, system, user) } else { // DeepSeek fallback baseURL := getDeepSeekBaseURL() apiKey := getDeepSeekAPIKey() if strings.TrimSpace(apiKey) == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "AI API klíč není nastaven"}) return } payload := map[string]interface{}{ "model": getDeepSeekModel(), "messages": []map[string]string{ {"role": "system", "content": system}, {"role": "user", "content": user}, }, "temperature": 0.1, "max_tokens": 2000, } body, _ := json.Marshal(payload) endpoint := strings.TrimRight(baseURL, "/") + "/chat/completions" reqHTTP, err := http.NewRequest("POST", endpoint, bytes.NewReader(body)) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } reqHTTP.Header.Set("Authorization", "Bearer "+apiKey) reqHTTP.Header.Set("Content-Type", "application/json") client := &http.Client{Timeout: 45 * time.Second} resp, err := client.Do(reqHTTP) if err != nil { c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) return } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { var e map[string]interface{} _ = json.NewDecoder(resp.Body).Decode(&e) c.JSON(resp.StatusCode, gin.H{"error": fmt.Sprintf("DeepSeek API error: %v", e)}) return } var out struct { Choices []struct { Message struct { Content string `json:"content"` } `json:"message"` } `json:"choices"` } if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) return } if len(out.Choices) == 0 { c.JSON(http.StatusBadGateway, gin.H{"error": "empty response"}) return } result = strings.TrimSpace(out.Choices[0].Message.Content) status = resp.StatusCode } } if err != nil { c.JSON(status, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"text": result}) return } // Auto-select best available model for OCR var modelID string if isMistralEnabled() { modelID = getMistralOCRModel() } else if isDeepSeekEnabled() && getDeepSeekAPIKey() != "" { modelID = "deepseek-chat" } else { c.JSON(http.StatusBadRequest, gin.H{"error": "Žádná AI služba není povolena pro OCR (Mistral/DeepSeek)"}) return } remaining, allowed := incrementAIUsage(c, modelID) if !allowed { c.JSON(http.StatusTooManyRequests, gin.H{"error": "Denní limit pro tento AI model byl vyčerpán."}) return } if remaining >= 0 { c.Header("X-AI-Model", modelID) c.Header("X-AI-Remaining", strconv.Itoa(remaining)) } docURL := strings.TrimSpace(req.DocumentURL) imgURL := strings.TrimSpace(req.ImageURL) if docURL == "" && imgURL == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "Musíte zadat document_url nebo image_url."}) return } // Normalize incoming URL (document or image) into an absolute URL for Mistral OCR srcURL := docURL if srcURL == "" { srcURL = imgURL } // If we got a relative upload path like "/uploads/..." or "uploads/...", build an absolute URL if strings.HasPrefix(srcURL, "/") || strings.HasPrefix(srcURL, "uploads/") { uPath := srcURL if strings.HasPrefix(uPath, "uploads/") { uPath = "/" + uPath } scheme := "http" if c.Request.TLS != nil || strings.EqualFold(c.Request.Header.Get("X-Forwarded-Proto"), "https") || strings.Contains(strings.ToLower(c.Request.Header.Get("CF-Visitor")), "https") { scheme = "https" } host := c.Request.Host if xf := strings.TrimSpace(c.Request.Header.Get("X-Forwarded-Host")); xf != "" { parts := strings.Split(xf, ",") if len(parts) > 0 { h := strings.TrimSpace(parts[0]) if h != "" { host = h } } } if !strings.Contains(host, ":") { if xfp := strings.TrimSpace(c.Request.Header.Get("X-Forwarded-Port")); xfp != "" { if (scheme == "http" && xfp != "80") || (scheme == "https" && xfp != "443") { host = host + ":" + xfp } } } srcURL = scheme + "://" + host + uPath } // Choose the correct chunk type based on whether we have an image or a document var document map[string]interface{} if imgURL != "" { document = map[string]interface{}{ "type": "image_url", "image_url": srcURL, } } else { document = map[string]interface{}{ "type": "document_url", "document_url": srcURL, } } // Process OCR with the selected model var result string if modelID == getMistralOCRModel() { // Use Mistral OCR mistralText, mistralStatus, err := callMistralOCR(modelID, document, req.Pages) if err != nil { c.JSON(mistralStatus, gin.H{"error": "Mistral OCR chyba", "details": err.Error()}) return } result = mistralText } else { // Use DeepSeek with chat completion system := "Jsi OCR specialista. Extrahuj veškerý text z poskytnutého dokumentu nebo obrázku. Vrať POUZEJ čistý text bez formátování." user := fmt.Sprintf("Extrahuj text z tohoto dokumentu: %s", srcURL) baseURL := getDeepSeekBaseURL() apiKey := getDeepSeekAPIKey() if strings.TrimSpace(apiKey) == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "DeepSeek API klíč není nastaven"}) return } payload := map[string]interface{}{ "model": getDeepSeekModel(), "messages": []map[string]string{ {"role": "system", "content": system}, {"role": "user", "content": user}, }, "temperature": 0.1, "max_tokens": 2000, } body, _ := json.Marshal(payload) endpoint := strings.TrimRight(baseURL, "/") + "/chat/completions" reqHTTP, err := http.NewRequest("POST", endpoint, bytes.NewReader(body)) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } reqHTTP.Header.Set("Authorization", "Bearer "+apiKey) reqHTTP.Header.Set("Content-Type", "application/json") client := &http.Client{Timeout: 45 * time.Second} resp, err := client.Do(reqHTTP) if err != nil { c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) return } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { var e map[string]interface{} _ = json.NewDecoder(resp.Body).Decode(&e) c.JSON(resp.StatusCode, gin.H{"error": fmt.Sprintf("DeepSeek API error: %v", e)}) return } var out struct { Choices []struct { Message struct { Content string `json:"content"` } `json:"message"` } `json:"choices"` } if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) return } if len(out.Choices) == 0 { c.JSON(http.StatusBadGateway, gin.H{"error": "empty response"}) return } result = strings.TrimSpace(out.Choices[0].Message.Content) } out := aiOCRResponse{Text: result} c.JSON(http.StatusOK, out) } func (ac *AIController) TranscribeAudio(c *gin.Context) { var req aiTranscriptionRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } // Check if explicitly requested model is provided and handle accordingly requestedModel := strings.TrimSpace(req.Model) if requestedModel != "" { // Only allow Mistral voice models for transcription if requestedModel == "mistral-voice-latest" || strings.Contains(requestedModel, "voxtral") { if !isMistralEnabled() { c.JSON(http.StatusBadRequest, gin.H{"error": "Mistral AI není povolen (zkontrolujte MISTRAL_ON)"}) return } } else { c.JSON(http.StatusBadRequest, gin.H{"error": "Pro transkripci jsou povoleny pouze Mistral voice modely"}) return } remaining, allowed := incrementAIUsage(c, requestedModel) if !allowed { c.JSON(http.StatusTooManyRequests, gin.H{"error": "Denní limit pro tento AI model byl vyčerpán."}) return } if remaining >= 0 { c.Header("X-AI-Model", requestedModel) c.Header("X-AI-Remaining", strconv.Itoa(remaining)) } // Process transcription with the requested model fileURL := strings.TrimSpace(req.FileURL) fileID := strings.TrimSpace(req.FileID) if fileURL == "" && fileID == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "Musíte zadat file_url nebo file_id."}) return } // Build absolute URL if needed if fileURL != "" && (strings.HasPrefix(fileURL, "/") || strings.HasPrefix(fileURL, "uploads/")) { uPath := fileURL if strings.HasPrefix(uPath, "uploads/") { uPath = "/" + uPath } scheme := "http" if c.Request.TLS != nil || strings.EqualFold(c.Request.Header.Get("X-Forwarded-Proto"), "https") || strings.Contains(strings.ToLower(c.Request.Header.Get("CF-Visitor")), "https") { scheme = "https" } host := c.Request.Host if xf := strings.TrimSpace(c.Request.Header.Get("X-Forwarded-Host")); xf != "" { parts := strings.Split(xf, ",") if len(parts) > 0 { h := strings.TrimSpace(parts[0]) if h != "" { host = h } } } if !strings.Contains(host, ":") { if xfp := strings.TrimSpace(c.Request.Header.Get("X-Forwarded-Port")); xfp != "" { if (scheme == "http" && xfp != "80") || (scheme == "https" && xfp != "443") { host = host + ":" + xfp } } } fileURL = scheme + "://" + host + uPath } // Use Mistral transcription text, language, status, err := callMistralTranscription(requestedModel, fileURL, fileID, req.Language) if err != nil { c.JSON(status, gin.H{"error": "Mistral transcription chyba", "details": err.Error()}) return } out := aiTranscriptionResponse{Text: text, Language: language} c.JSON(http.StatusOK, out) return } // Auto-select best available model for transcription var modelID string if isMistralEnabled() { modelID = getMistralVoiceModelPrimary() } else { c.JSON(http.StatusBadRequest, gin.H{"error": "Mistral AI není povolen pro transkripci (zkontrolujte MISTRAL_ON)"}) return } remaining, allowed := incrementAIUsage(c, modelID) if !allowed { c.JSON(http.StatusTooManyRequests, gin.H{"error": "Denní limit pro tento AI model byl vyčerpán."}) return } if remaining >= 0 { c.Header("X-AI-Model", modelID) c.Header("X-AI-Remaining", strconv.Itoa(remaining)) } fileURL := strings.TrimSpace(req.FileURL) fileID := strings.TrimSpace(req.FileID) if fileURL == "" && fileID == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "Musíte zadat file_url nebo file_id."}) return } if fileURL != "" && (strings.HasPrefix(fileURL, "/") || strings.HasPrefix(fileURL, "uploads/")) { uPath := fileURL if strings.HasPrefix(uPath, "uploads/") { uPath = "/" + uPath } scheme := "http" if c.Request.TLS != nil || strings.EqualFold(c.Request.Header.Get("X-Forwarded-Proto"), "https") || strings.Contains(strings.ToLower(c.Request.Header.Get("CF-Visitor")), "https") { scheme = "https" } host := c.Request.Host if xf := strings.TrimSpace(c.Request.Header.Get("X-Forwarded-Host")); xf != "" { parts := strings.Split(xf, ",") if len(parts) > 0 { h := strings.TrimSpace(parts[0]) if h != "" { host = h } } } if !strings.Contains(host, ":") { if xfp := strings.TrimSpace(c.Request.Header.Get("X-Forwarded-Port")); xfp != "" { if (scheme == "http" && xfp != "80") || (scheme == "https" && xfp != "443") { host = host + ":" + xfp } } } fileURL = scheme + "://" + host + uPath } // Use the appropriate transcription function based on model var result string var status int var err error if modelID == "mistral-voice-latest" || strings.Contains(modelID, "voxtral") { text, language, status, err := callMistralTranscription(modelID, fileURL, fileID, req.Language) if err != nil { c.JSON(status, gin.H{"error": "Mistral transcription chyba", "details": err.Error()}) return } out := aiTranscriptionResponse{Text: text, Language: language} c.JSON(http.StatusOK, out) return } else { // For non-Mistral models, use chat completion with transcription prompt system := "Jsi transkripční specialista. Přepiš veškerý mluvený text z poskytnutého audio souboru. Vrať POUZEJ čistý text bez formátování." user := fmt.Sprintf("Přepiš audio z tohoto souboru: %s", fileURL) if modelID == "grok-4-1-fast-non-reasoning" || modelID == "grok-4-1-fast-reasoning" { result, status, err = callGrokChat(modelID, system, user) } else { // DeepSeek fallback baseURL := getDeepSeekBaseURL() apiKey := getDeepSeekAPIKey() if strings.TrimSpace(apiKey) == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "AI API klíč není nastaven"}) return } payload := map[string]interface{}{ "model": getDeepSeekModel(), "messages": []map[string]string{ {"role": "system", "content": system}, {"role": "user", "content": user}, }, "temperature": 0.1, "max_tokens": 2000, } body, _ := json.Marshal(payload) endpoint := strings.TrimRight(baseURL, "/") + "/chat/completions" reqHTTP, err := http.NewRequest("POST", endpoint, bytes.NewReader(body)) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } reqHTTP.Header.Set("Authorization", "Bearer "+apiKey) reqHTTP.Header.Set("Content-Type", "application/json") client := &http.Client{Timeout: 45 * time.Second} resp, err := client.Do(reqHTTP) if err != nil { c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) return } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { var e map[string]interface{} _ = json.NewDecoder(resp.Body).Decode(&e) c.JSON(resp.StatusCode, gin.H{"error": fmt.Sprintf("DeepSeek API error: %v", e)}) return } var out struct { Choices []struct { Message struct { Content string `json:"content"` } `json:"message"` } `json:"choices"` } if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) return } if len(out.Choices) == 0 { c.JSON(http.StatusBadGateway, gin.H{"error": "empty response"}) return } result = strings.TrimSpace(out.Choices[0].Message.Content) status = resp.StatusCode } if err != nil { c.JSON(status, gin.H{"error": err.Error()}) return } out := aiTranscriptionResponse{Text: result, Language: "cs"} c.JSON(http.StatusOK, out) return } } // TestAIParse exposes JSON sanitization/parsing for debugging different model outputs func (ac *AIController) TestAIParse(c *gin.Context) { var req struct { Raw string `json:"raw" binding:"required"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } sanitized := sanitizeAIResponse(req.Raw) result := gin.H{"sanitized": sanitized} var parsed map[string]interface{} if err := parseAIJSONIntoStruct(sanitized, &parsed); err != nil { result["error"] = err.Error() c.JSON(http.StatusBadRequest, result) return } result["parsed"] = parsed c.JSON(http.StatusOK, result) } func NewAIController(db *gorm.DB) *AIController { return &AIController{DB: db} } type aiBlogRequest struct { // Short user input that describes the topic/notes for the blog in Czech Prompt string `json:"prompt" binding:"required"` // Optional extra hints Audience string `json:"audience"` MinWords int `json:"min_words"` Model string `json:"model"` } type aiBlogResponse struct { Title string `json:"title"` Slug string `json:"slug"` HTML string `json:"html"` } type aiAboutRequest struct { Prompt string `json:"prompt" binding:"required"` ClubName string `json:"club_name"` Style string `json:"style"` Audience string `json:"audience"` Model string `json:"model"` } type aiAboutResponse struct { Title string `json:"title"` Subtitle string `json:"subtitle"` HTML string `json:"html"` SEOTitle string `json:"seo_title"` SEODescription string `json:"seo_description"` } type aiCSSRequest struct { Prompt string `json:"prompt" binding:"required"` ElementName string `json:"element_name"` RootSelector string `json:"root_selector"` CurrentCSS string `json:"current_css"` CurrentStyles map[string]interface{} `json:"current_styles"` Theme map[string]string `json:"theme"` Breakpoints []int `json:"breakpoints"` } type aiCSSResponse struct { CSS string `json:"css"` } type aiOCRRequest struct { DocumentURL string `json:"document_url"` ImageURL string `json:"image_url"` Pages []int `json:"pages"` Model string `json:"model"` } type aiOCRResponse struct { Text string `json:"text"` } type aiTranscriptionRequest struct { FileURL string `json:"file_url"` FileID string `json:"file_id"` Language string `json:"language"` Model string `json:"model"` } type aiTranscriptionResponse struct { Text string `json:"text"` Language string `json:"language"` } func getOpenRouterAPIKey() string { if v := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(getenv("OPENROUTER_API_KEY")), "\"")); v != "" { return v } return "" } func getOpenRouterBaseURL() string { if v := strings.TrimSpace(getenv("OPENROUTER_BASE_URL")); v != "" { return v } return "https://openrouter.ai/api/v1" } func getOpenRouterModel() string { if v := strings.TrimSpace(getenv("OPENROUTER_MODEL")); v != "" { return v } return "" } func getOpenRouterFallbackModel() string { if v := strings.TrimSpace(getenv("OPENROUTER_FALLBACK_MODEL")); v != "" { return v } return "" } func getOpenRouterFallbackModel2() string { if v := strings.TrimSpace(getenv("OPENROUTER_FALLBACK_MODEL2")); v != "" { return v } return "" } // DeepSeek helpers func getDeepSeekAPIKey() string { if v := strings.TrimSpace(getenv("DEEPSEEK_API_KEY")); v != "" { return v } return "" } func getDeepSeekBaseURL() string { if v := strings.TrimSpace(getenv("DEEPSEEK_BASE_URL")); v != "" { return v } return "https://api.deepseek.com" } func getDeepSeekModel() string { if v := strings.TrimSpace(getenv("DEEPSEEK_MODEL")); v != "" { return v } return "deepseek-chat" } // Mistral helpers func getMistralAPIKey() string { if v := strings.TrimSpace(getenv("MISTRAL_API_KEY")); v != "" { return v } return "" } func getMistralBaseURL() string { if v := strings.TrimSpace(getenv("MISTRAL_BASE_URL")); v != "" { return v } return "https://api.mistral.ai/v1" } func isMistralEnabled() bool { v := strings.ToLower(getenv("MISTRAL_ON")) return v == "1" || v == "true" || v == "yes" } // Grok helpers func getGrokAPIKey() string { if v := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(getenv("GROK_API_KEY")), "\"")); v != "" { return v } return "" } func getGrokBaseURL() string { if v := strings.TrimSpace(getenv("GROK_BASE_URL")); v != "" { return v } return "https://api.x.ai/v1" } func getGrokTextModelPrimary() string { if v := strings.TrimSpace(getenv("GROK_TEXT_MODEL_PRIMARY")); v != "" { return v } return "grok-4-1-fast-non-reasoning" } func getGrokTextModelSecondary() string { if v := strings.TrimSpace(getenv("GROK_TEXT_MODEL_SECONDARY")); v != "" { return v } return "grok-4-1-fast-reasoning" } func isGrokEnabled() bool { v := strings.ToLower(getenv("GROK_ON")) return v == "1" || v == "true" || v == "yes" } func getMistralOCRModel() string { if v := strings.TrimSpace(getenv("MISTRAL_OCR_MODEL")); v != "" { return v } return "mistral-ocr-latest" } func getMistralVoiceModelPrimary() string { if v := strings.TrimSpace(getenv("MISTRAL_VOICE_MODEL_PRIMARY")); v != "" { return v } return "voxtral-small-latest" } func getMistralVoiceModelCheap() string { if v := strings.TrimSpace(getenv("MISTRAL_VOICE_MODEL_CHEAP")); v != "" { return v } return "voxtral-mini-latest" } func isXAIEnabled() bool { v := strings.ToLower(getenv("XAI_ON")) return v == "1" || v == "true" || v == "yes" } func getXAIAPIKey() string { if v := strings.TrimSpace(getenv("XAI_API_KEY")); v != "" { return v } return "" } func getXAIBaseURL() string { if v := strings.TrimSpace(getenv("XAI_BASE_URL")); v != "" { return v } return "https://api.x.ai/v1" } func getXAIImageModel() string { if v := strings.TrimSpace(getenv("XAI_IMAGE_MODEL")); v != "" { return v } return "grok-2-image-latest" } func getXAIImageModelInstagram() string { if v := strings.TrimSpace(getenv("XAI_IMAGE_MODEL_INSTAGRAM")); v != "" { return v } return getXAIImageModel() } func getXAIInstagramDailyLimit() int { limitStr := strings.TrimSpace(getenv("XAI_IMAGE_INSTAGRAM_DAILY_LIMIT")) if limitStr == "" { return 5 } if n, err := strconv.Atoi(limitStr); err == nil && n > 0 { return n } return 5 } // AI usage tracking (per subject+model per day, in-memory) type aiUsageKey struct { Date string Subject string Model string } type aiUsageCounter struct { Count int } var ( aiUsageMu sync.Mutex aiUsage = make(map[aiUsageKey]*aiUsageCounter) ) var ( xaiImageUsageMu sync.Mutex xaiImageUsage = make(map[aiUsageKey]*aiUsageCounter) ) func getAIDailyRequestLimit() int { limitStr := strings.TrimSpace(getenv("AI_DAILY_REQUEST_LIMIT_PER_MODEL")) if limitStr == "" { return 10 } if n, err := strconv.Atoi(limitStr); err == nil && n > 0 { return n } return 10 } // getAIDailyRequestLimitForModel returns the daily limit for a specific model // DeepSeek models have unlimited usage, reasoning models have 5/day, others have 10/day func getAIDailyRequestLimitForModel(modelID string) int { modelID = strings.TrimSpace(modelID) // DeepSeek models have unlimited usage if modelID == "deepseek-chat" || modelID == "deepseek-reasoner" { return -1 // Unlimited } // Reasoning models have lower limits if strings.Contains(modelID, "reasoning") || strings.Contains(modelID, "reasoner") { return 5 } // All other models have standard limits return 10 } // incrementAIUsage increments usage for the given logical model ID (e.g. "mistral-small-latest") // and returns remaining requests and whether the request is allowed. // Now uses club-wide limits instead of per-user limits. func incrementAIUsage(c *gin.Context, modelID string) (remaining int, allowed bool) { limit := getAIDailyRequestLimitForModel(modelID) if limit <= 0 { return -1, true } modelID = strings.TrimSpace(modelID) if modelID == "" { modelID = "default" } // Use club-wide subject instead of per-user/IP subject := "club" today := time.Now().UTC().Format("2006-01-02") key := aiUsageKey{Date: today, Subject: subject, Model: modelID} aiUsageMu.Lock() defer aiUsageMu.Unlock() ct, ok := aiUsage[key] if !ok { ct = &aiUsageCounter{Count: 0} aiUsage[key] = ct } if ct.Count >= limit { return 0, false } ct.Count++ remaining = limit - ct.Count return remaining, true } // getAIUsageStatus returns the current usage status for all models func getAIUsageStatus() map[string]map[string]interface{} { today := time.Now().UTC().Format("2006-01-02") subject := "club" status := make(map[string]map[string]interface{}) // Check all known models models := []string{ "deepseek-chat", "deepseek-reasoner", "mistral-small-latest", "ministral-14b-latest", "openrouter-primary", "grok-4-1-fast-non-reasoning", "grok-4-1-fast-reasoning", } aiUsageMu.Lock() defer aiUsageMu.Unlock() for _, model := range models { key := aiUsageKey{Date: today, Subject: subject, Model: model} limit := getAIDailyRequestLimitForModel(model) used := 0 if ct, ok := aiUsage[key]; ok { used = ct.Count } remaining := 0 if limit > 0 { remaining = limit - used } status[model] = map[string]interface{}{ "used": used, "limit": limit, "remaining": remaining, "unlimited": limit <= 0, } } return status } // GetAIUsageStatus returns the current AI usage status for all models func (ac *AIController) GetAIUsageStatus(c *gin.Context) { status := getAIUsageStatus() c.JSON(http.StatusOK, gin.H{"status": status}) } func incrementXAIInstagramUsage(c *gin.Context) (remaining int, allowed bool) { limit := getXAIInstagramDailyLimit() if limit <= 0 { return -1, true } subject := "ip:" + c.ClientIP() if v, ok := c.Get("userID"); ok { if uid, ok2 := v.(uint); ok2 && uid > 0 { subject = fmt.Sprintf("user:%d", uid) } } today := time.Now().UTC().Format("2006-01-02") key := aiUsageKey{Date: today, Subject: subject, Model: "xai-instagram-image"} xaiImageUsageMu.Lock() defer xaiImageUsageMu.Unlock() ct, ok := xaiImageUsage[key] if !ok { ct = &aiUsageCounter{Count: 0} xaiImageUsage[key] = ct } if ct.Count >= limit { return 0, false } ct.Count++ remaining = limit - ct.Count return remaining, true } // callMistralChat invokes the Mistral chat API with OpenAI-compatible schema. func callMistralChat(modelName, system, user string) (string, int, error) { apiKey := getMistralAPIKey() baseURL := getMistralBaseURL() if strings.TrimSpace(apiKey) == "" { return "", http.StatusBadRequest, fmt.Errorf("Mistral API klíč není nastaven") } payload := map[string]interface{}{ "model": modelName, "messages": []map[string]string{ {"role": "system", "content": system}, {"role": "user", "content": user}, }, "temperature": 0.5, "max_tokens": 2000, } body, _ := json.Marshal(payload) endpoint := strings.TrimRight(baseURL, "/") + "/chat/completions" reqHTTP, err := http.NewRequest("POST", endpoint, bytes.NewReader(body)) if err != nil { return "", http.StatusInternalServerError, err } reqHTTP.Header.Set("Authorization", "Bearer "+apiKey) reqHTTP.Header.Set("Content-Type", "application/json") client := &http.Client{Timeout: 45 * time.Second} resp, err := client.Do(reqHTTP) if err != nil { return "", http.StatusBadGateway, err } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { var e map[string]interface{} _ = json.NewDecoder(resp.Body).Decode(&e) return "", resp.StatusCode, fmt.Errorf("Mistral API error: %v", e) } var out struct { Choices []struct { Message struct { Content string `json:"content"` } `json:"message"` } `json:"choices"` } if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { return "", http.StatusBadGateway, err } if len(out.Choices) == 0 { return "", http.StatusBadGateway, fmt.Errorf("empty choices") } return strings.TrimSpace(out.Choices[0].Message.Content), http.StatusOK, nil } // callGrokChat invokes the Grok (x.ai) chat API with OpenAI-compatible schema. func callGrokChat(modelName, system, user string) (string, int, error) { apiKey := getGrokAPIKey() baseURL := getGrokBaseURL() if strings.TrimSpace(apiKey) == "" { return "", http.StatusBadRequest, fmt.Errorf("Grok API klíč není nastaven") } payload := map[string]interface{}{ "model": modelName, "messages": []map[string]string{ {"role": "system", "content": system}, {"role": "user", "content": user}, }, "temperature": 0.5, "max_tokens": 2000, } body, _ := json.Marshal(payload) endpoint := strings.TrimRight(baseURL, "/") + "/chat/completions" reqHTTP, err := http.NewRequest("POST", endpoint, bytes.NewReader(body)) if err != nil { return "", http.StatusInternalServerError, err } reqHTTP.Header.Set("Authorization", "Bearer "+apiKey) reqHTTP.Header.Set("Content-Type", "application/json") client := &http.Client{Timeout: 45 * time.Second} resp, err := client.Do(reqHTTP) if err != nil { return "", http.StatusBadGateway, err } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { var e map[string]interface{} _ = json.NewDecoder(resp.Body).Decode(&e) return "", resp.StatusCode, fmt.Errorf("Grok API error: %v", e) } var out struct { Choices []struct { Message struct { Content string `json:"content"` } `json:"message"` } `json:"choices"` } if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { return "", http.StatusBadGateway, err } if len(out.Choices) == 0 { return "", http.StatusBadGateway, fmt.Errorf("empty choices") } return strings.TrimSpace(out.Choices[0].Message.Content), http.StatusOK, nil } func callMistralOCR(modelName string, document map[string]interface{}, pages []int) (string, int, error) { apiKey := getMistralAPIKey() baseURL := getMistralBaseURL() if strings.TrimSpace(apiKey) == "" { return "", http.StatusBadRequest, fmt.Errorf("Mistral API klíč není nastaven") } payload := map[string]interface{}{ "model": modelName, "document": document, } if len(pages) > 0 { payload["pages"] = pages } body, _ := json.Marshal(payload) endpoint := strings.TrimRight(baseURL, "/") + "/ocr" reqHTTP, err := http.NewRequest("POST", endpoint, bytes.NewReader(body)) if err != nil { return "", http.StatusInternalServerError, err } reqHTTP.Header.Set("Authorization", "Bearer "+apiKey) reqHTTP.Header.Set("Content-Type", "application/json") client := &http.Client{Timeout: 60 * time.Second} resp, err := client.Do(reqHTTP) if err != nil { return "", http.StatusBadGateway, err } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { var e map[string]interface{} _ = json.NewDecoder(resp.Body).Decode(&e) return "", resp.StatusCode, fmt.Errorf("Mistral OCR API error: %v", e) } var out struct { Pages []struct { Markdown string `json:"markdown"` } `json:"pages"` } if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { return "", http.StatusBadGateway, err } var b strings.Builder for _, p := range out.Pages { t := strings.TrimSpace(p.Markdown) if t == "" { continue } if b.Len() > 0 { b.WriteString("\n\n") } b.WriteString(t) } text := strings.TrimSpace(b.String()) if text == "" { return "", http.StatusBadGateway, fmt.Errorf("empty OCR text") } return text, http.StatusOK, nil } func callMistralTranscription(modelName, fileURL, fileID, language string) (string, string, int, error) { apiKey := getMistralAPIKey() baseURL := getMistralBaseURL() if strings.TrimSpace(apiKey) == "" { return "", "", http.StatusBadRequest, fmt.Errorf("Mistral API klíč není nastaven") } payload := map[string]interface{}{ "model": modelName, } if strings.TrimSpace(fileURL) != "" { payload["file_url"] = strings.TrimSpace(fileURL) } if strings.TrimSpace(fileID) != "" { payload["file_id"] = strings.TrimSpace(fileID) } if strings.TrimSpace(language) != "" { payload["language"] = strings.TrimSpace(language) } body, _ := json.Marshal(payload) endpoint := strings.TrimRight(baseURL, "/") + "/audio/transcriptions" reqHTTP, err := http.NewRequest("POST", endpoint, bytes.NewReader(body)) if err != nil { return "", "", http.StatusInternalServerError, err } reqHTTP.Header.Set("Authorization", "Bearer "+apiKey) reqHTTP.Header.Set("Content-Type", "application/json") client := &http.Client{Timeout: 60 * time.Second} resp, err := client.Do(reqHTTP) if err != nil { return "", "", http.StatusBadGateway, err } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { var e map[string]interface{} _ = json.NewDecoder(resp.Body).Decode(&e) return "", "", resp.StatusCode, fmt.Errorf("Mistral audio API error: %v", e) } var out struct { Text string `json:"text"` Language string `json:"language"` } if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { return "", "", http.StatusBadGateway, err } if strings.TrimSpace(out.Text) == "" { return "", "", http.StatusBadGateway, fmt.Errorf("empty transcription text") } return strings.TrimSpace(out.Text), strings.TrimSpace(out.Language), http.StatusOK, nil } func callXAIImage(modelName, prompt, size string, n int) ([]string, int, error) { apiKey := getXAIAPIKey() baseURL := getXAIBaseURL() if strings.TrimSpace(apiKey) == "" { return nil, http.StatusBadRequest, fmt.Errorf("XAI API klíč není nastaven") } modelName = strings.TrimSpace(modelName) if modelName == "" { modelName = getXAIImageModel() } if n <= 0 { n = 1 } if n > 4 { n = 4 } prompt = strings.TrimSpace(prompt) if prompt == "" { return nil, http.StatusBadRequest, fmt.Errorf("prompt nesmí být prázdný") } payload := map[string]interface{}{ "model": modelName, "prompt": prompt, "n": n, "response_format": "url", } if strings.TrimSpace(size) != "" { payload["size"] = strings.TrimSpace(size) } body, _ := json.Marshal(payload) endpoint := strings.TrimRight(baseURL, "/") + "/images/generations" reqHTTP, err := http.NewRequest("POST", endpoint, bytes.NewReader(body)) if err != nil { return nil, http.StatusInternalServerError, err } reqHTTP.Header.Set("Authorization", "Bearer "+apiKey) reqHTTP.Header.Set("Content-Type", "application/json") client := &http.Client{Timeout: 60 * time.Second} resp, err := client.Do(reqHTTP) if err != nil { return nil, http.StatusBadGateway, err } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { var e map[string]interface{} _ = json.NewDecoder(resp.Body).Decode(&e) return nil, resp.StatusCode, fmt.Errorf("XAI image API error: %v", e) } var out struct { Data []struct { URL string `json:"url"` B64JSON string `json:"b64_json"` } `json:"data"` } if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { return nil, http.StatusBadGateway, err } var urls []string for _, d := range out.Data { u := strings.TrimSpace(d.URL) if u != "" { urls = append(urls, u) } } if len(urls) == 0 { return nil, http.StatusBadGateway, fmt.Errorf("XAI image API returned no URLs") } return urls, http.StatusOK, nil } // finalizeBlogResponse parses the AI JSON response and applies existing fallbacks. func finalizeBlogResponse(c *gin.Context, content string, req *aiBlogRequest) { var out aiBlogResponse sanitized := sanitizeAIResponse(content) if err := parseAIJSONIntoStruct(sanitized, &out); err != nil { if reporter := services.NewErrorReporter(config.AppConfig); reporter != nil { snip := sanitized if len(snip) > 600 { snip = snip[:600] + "..." } reporter.Report(c.Request.Context(), &services.ErrorEvent{ Origin: "backend", Language: "go", Severity: "warning", Message: "AI Blog parse failed; using fallback", Component: "AIController", URL: c.Request.URL.Path, RequestID: c.GetString("request_id"), Tags: map[string]string{ "endpoint": c.FullPath(), }, Context: map[string]interface{}{"sanitized": snip, "error": err.Error()}, }) } } if out.HTML != "" { out.HTML = html.UnescapeString(out.HTML) } if out.Title == "" { out.Title = deriveTitle(req.Prompt) } if !isValidShortSlug(out.Slug) || out.Slug == slugify(out.Title) { out.Slug = shortSlugFromPrompt(req.Prompt) } if out.HTML == "" { out.HTML = "

" + htmlEscape(out.Title) + "

" + htmlEscape(content) + "

" } c.JSON(http.StatusOK, out) } // finalizeAboutResponse parses the AI JSON response for the About page // and applies existing fallbacks. func finalizeAboutResponse(c *gin.Context, content string, req *aiAboutRequest, clubName string) { var out aiAboutResponse sanitized := sanitizeAIResponse(content) if err := parseAIJSONIntoStruct(sanitized, &out); err != nil { if reporter := services.NewErrorReporter(config.AppConfig); reporter != nil { snip := sanitized if len(snip) > 600 { snip = snip[:600] + "..." } reporter.Report(c.Request.Context(), &services.ErrorEvent{ Origin: "backend", Language: "go", Severity: "warning", Message: "AI About parse failed; using fallback", Component: "AIController", URL: c.Request.URL.Path, RequestID: c.GetString("request_id"), Tags: map[string]string{ "endpoint": c.FullPath(), }, Context: map[string]interface{}{"sanitized": snip, "error": err.Error()}, }) } } if out.HTML != "" { out.HTML = html.UnescapeString(out.HTML) } if out.SEOTitle == "" { out.SEOTitle = fmt.Sprintf("%s | %s", clubName, "Oficiální informace") } if out.SEODescription == "" { out.SEODescription = fmt.Sprintf("Přečtěte si informace o klubu %s.", clubName) } if out.Title == "" { out.Title = clubName } if out.Subtitle == "" { out.Subtitle = "Oficiální klubový profil" } if out.HTML == "" { out.HTML = fmt.Sprintf("

O klubu %s

%s

", htmlEscape(clubName), htmlEscape(strings.TrimSpace(req.Prompt))) } c.JSON(http.StatusOK, out) } // Provider feature flags func isOpenRouterEnabled() bool { v := strings.ToLower(getenv("OPENROUTER_ON")) if v == "" { // Backwards compatible default: OpenRouter enabled when flag is not set return true } return v == "1" || v == "true" || v == "yes" } func isDeepSeekEnabled() bool { v := strings.ToLower(getenv("DEEPSEEK_ON")) return v == "1" || v == "true" || v == "yes" } // Small utility wrappers to avoid importing os directly multiple times func getenv(k string) string { return strings.TrimSpace(strings.ReplaceAll(strings.ReplaceAll(strings.TrimSpace(os.Getenv(k)), "\r", ""), "\n", "")) } // deriveTitle returns a readable title from user prompt func deriveTitle(s string) string { s = strings.TrimSpace(s) if s == "" { return "Novinky z klubu" } // Capitalize first letter, keep it concise if len(s) > 120 { s = s[:120] } return strings.ToUpper(string([]rune(s)[:1])) + s[1:] } // slugify creates a URL-friendly slug without diacritics func slugify(s string) string { s = strings.ToLower(strings.TrimSpace(s)) // Replace diacritics (basic map); for full support, consider x/text/unicode/norm and transform replacer := strings.NewReplacer( "á", "a", "č", "c", "ď", "d", "é", "e", "ě", "e", "í", "i", "ň", "n", "ó", "o", "ř", "r", "š", "s", "ť", "t", "ú", "u", "ů", "u", "ý", "y", "ž", "z", ) s = replacer.Replace(s) // Replace any non alnum with hyphen re := regexp.MustCompile("[^a-z0-9]+") s = re.ReplaceAllString(s, "-") s = strings.Trim(s, "-") if s == "" { return "clanek" } return s } // isValidShortSlug checks basic constraints: non-empty, <= 40 chars, 3-5 words (by hyphens), allowed charset func isValidShortSlug(s string) bool { s = strings.TrimSpace(s) if s == "" { return false } if len(s) > 40 { return false } parts := strings.Split(s, "-") // filter empty parts w := 0 for _, p := range parts { if p != "" { w++ } } if w < 3 || w > 5 { return false } // allowed chars: a-z0-9- re := regexp.MustCompile(`^[a-z0-9-]+$`) return re.MatchString(s) } // shortSlugFromPrompt creates a compact, independent slug from the prompt text func shortSlugFromPrompt(prompt string) string { p := strings.ToLower(strings.TrimSpace(prompt)) if p == "" { return "clanek" } // basic diacritics removal via slugify, then split to words p = slugify(p) parts := strings.Split(p, "-") // simple Czech stopwords list (subset) stop := map[string]struct{}{"a": {}, "i": {}, "v": {}, "ve": {}, "z": {}, "za": {}, "od": {}, "do": {}, "u": {}, "o": {}, "s": {}, "se": {}, "na": {}, "po": {}, "pod": {}, "nad": {}, "proti": {}, "pri": {}, "bez": {}, "k": {}, "ke": {}, "ten": {}, "ta": {}, "to": {}, "ty": {}, "tento": {}, "tato": {}, "toto": {}, "jak": {}, "jako": {}, "ze": {}} var kept []string for _, w := range parts { if w == "" { continue } if _, ok := stop[w]; ok { continue } kept = append(kept, w) if len(kept) >= 5 { break } } if len(kept) == 0 { kept = parts } // prefer 3-5 words, trim to 4 if too many if len(kept) > 5 { kept = kept[:5] } if len(kept) >= 4 { kept = kept[:4] } s := strings.Join(kept, "-") if len(s) > 40 { s = s[:40] } s = strings.Trim(s, "-") if !isValidShortSlug(s) { // final fallback s = slugify(deriveTitle(prompt)) if len(s) > 40 { s = s[:40] } s = strings.Trim(s, "-") } return s } func htmlEscape(s string) string { s = strings.ReplaceAll(s, "&", "&") s = strings.ReplaceAll(s, "<", "<") s = strings.ReplaceAll(s, ">", ">") s = strings.ReplaceAll(s, "\"", """) s = strings.ReplaceAll(s, "'", "'") return s } // sanitizeAIResponse cleans up AI response to extract valid JSON // Handles markdown code blocks, extra backticks, and other formatting issues func sanitizeAIResponse(content string) string { // Trim leading/trailing whitespace first content = strings.TrimSpace(content) if content == "" { return content } // If the model wrapped JSON in a markdown code block, try to extract // the first fenced block (supports languages like ```json, ```JSON, etc.). if strings.Contains(content, "```") { reBlock := regexp.MustCompile("(?s)```[a-zA-Z0-9]*\\s*\\n?(.*?)```") if m := reBlock.FindStringSubmatch(content); len(m) >= 2 { // Take only the inner block content content = m[1] } else { // Fallback: best-effort removal of leading/trailing backtick fences content = regexp.MustCompile(`^\s*`+"`"+`{1,3}\s*[a-zA-Z0-9]*\s*`).ReplaceAllString(content, "") content = regexp.MustCompile(`\s*`+"`"+`{1,3}\s*$`).ReplaceAllString(content, "") } } // Normalize stray backslashes that break JSON (e.g. `\

` from some models). // Keep only valid JSON escape sequences (\" \\ \/ \b \f \n \r \t \u), // drop backslashes before any other character so Go's json.Unmarshal can succeed. if strings.Contains(content, "\\") { var b strings.Builder b.Grow(len(content)) for i := 0; i < len(content); i++ { ch := content[i] if ch == '\\' { if i+1 < len(content) { next := content[i+1] if next == '"' || next == '\\' || next == '/' || next == 'b' || next == 'f' || next == 'n' || next == 'r' || next == 't' || next == 'u' { // Valid JSON escape, keep backslash and let JSON decoder handle it b.WriteByte(ch) } else { // Drop the backslash; the following character will be processed normally continue } } else { // Trailing backslash without a following char – drop it continue } } b.WriteByte(ch) } content = b.String() } // Remove any remaining stray backticks at the edges and trim again content = strings.Trim(content, "`") content = strings.TrimSpace(content) return content } func parseAIJSONIntoStruct(sanitized string, out interface{}) error { trimmed := strings.TrimSpace(sanitized) if trimmed == "" { return fmt.Errorf("empty AI response") } // Try both original and a repaired variant candidates := []string{trimmed} repaired := repairJSONCommonIssues(trimmed) if repaired != trimmed { candidates = append(candidates, repaired) } // Try parsing over all candidates for _, cand := range candidates { // Direct object if err := json.Unmarshal([]byte(cand), out); err == nil { return nil } // Array → first object if strings.HasPrefix(strings.TrimSpace(cand), "[") { var arr []json.RawMessage if err := json.Unmarshal([]byte(cand), &arr); err == nil && len(arr) > 0 { if err := json.Unmarshal(arr[0], out); err == nil { return nil } } } // Wrapped under common keys or any nested object var obj map[string]interface{} if err := json.Unmarshal([]byte(cand), &obj); err == nil { wrapperKeys := []string{"data", "result", "article", "blog", "payload", "response"} for _, k := range wrapperKeys { if v, ok := obj[k]; ok { if sub, ok2 := v.(map[string]interface{}); ok2 { if b, err := json.Marshal(sub); err == nil { if err := json.Unmarshal(b, out); err == nil { return nil } } } } } for _, v := range obj { if sub, ok := v.(map[string]interface{}); ok { if b, err := json.Marshal(sub); err == nil { if err := json.Unmarshal(b, out); err == nil { return nil } } } } } // Find first JSON object region in text re := regexp.MustCompile(`(?s)\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}`) if m := re.FindString(cand); m != "" { // Try raw then repaired if err := json.Unmarshal([]byte(m), out); err == nil { return nil } if rm := repairJSONCommonIssues(m); rm != m { if err := json.Unmarshal([]byte(rm), out); err == nil { return nil } } } } return fmt.Errorf("unable to parse AI JSON") } // repairJSONCommonIssues attempts to fix frequent LLM JSON issues: // - literal newlines/tabs within strings → escape as \n/\t // - trailing commas outside strings (before } or ]) → removed // - BOM and NBSP → normalized func repairJSONCommonIssues(s string) string { if s == "" { return s } // Normalize BOM (U+FEFF) and NBSP (U+00A0) s = strings.TrimPrefix(s, "\uFEFF") s = strings.Map(func(r rune) rune { if r == '\u00A0' { // NBSP return ' ' } // Map curly quotes to ASCII quotes to help JSON parsers if r == '\u201C' || r == '\u201D' { // “ ” return '"' } if r == '\u2018' || r == '\u2019' { // ‘ ’ return '\'' } return r }, s) // Pass 1: escape literal control chars inside strings var b strings.Builder b.Grow(len(s)) inStr := false esc := false for _, r := range s { ch := r if inStr { if esc { // Previous char was backslash – keep as is esc = false b.WriteRune(ch) continue } switch ch { case '\\': esc = true b.WriteRune(ch) continue case '"': inStr = false b.WriteRune(ch) continue case '\n': b.WriteString(`\n`) continue case '\r': b.WriteString(`\r`) continue case '\t': b.WriteString(`\t`) continue } b.WriteRune(ch) continue } // outside string if ch == '"' { inStr = true b.WriteRune(ch) continue } b.WriteRune(ch) } s = b.String() // Pass 2: strip JSONC-style comments (// and /* */) outside strings { var d strings.Builder d.Grow(len(s)) inStrC := false escC := false for i := 0; i < len(s); i++ { ch := s[i] if inStrC { d.WriteByte(ch) if escC { escC = false continue } if ch == '\\' { escC = true continue } if ch == '"' { inStrC = false continue } continue } if ch == '"' { inStrC = true d.WriteByte(ch) continue } if ch == '/' && i+1 < len(s) { next := s[i+1] if next == '/' { // line comment i += 2 for i < len(s) && s[i] != '\n' { i++ } // include the newline if present if i < len(s) { d.WriteByte(s[i]) } continue } if next == '*' { // block comment i += 2 for i+1 < len(s) && !(s[i] == '*' && s[i+1] == '/') { i++ } if i+1 < len(s) { i += 1 } // will be incremented by for continue } } d.WriteByte(ch) } s = d.String() } // Pass 3: remove trailing commas outside strings var c strings.Builder c.Grow(len(s)) inStr = false esc = false for i := 0; i < len(s); i++ { ch := s[i] if inStr { c.WriteByte(ch) if esc { esc = false continue } if ch == '\\' { esc = true } else if ch == '"' { inStr = false } continue } if ch == '"' { inStr = true c.WriteByte(ch) continue } if ch == ',' { // look ahead to next non-space j := i + 1 for j < len(s) { if s[j] == ' ' || s[j] == '\n' || s[j] == '\r' || s[j] == '\t' { j++ continue } break } if j < len(s) && (s[j] == '}' || s[j] == ']') { // drop this comma continue } } c.WriteByte(ch) } return c.String() }