Files
MyClub/internal/controllers/ai_controller.go
T
Tomas Dvorak dfc079288f hot fix #1
2026-01-26 08:13:18 +01:00

3073 lines
94 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 5090 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 46 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 (35 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é <html>/<body> 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 = "<h1>" + htmlEscape(out.Title) + "</h1><p>" + htmlEscape(content) + "</p>"
}
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("<h2>O klubu %s</h2><p>%s</p>", 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, "&", "&amp;")
s = strings.ReplaceAll(s, "<", "&lt;")
s = strings.ReplaceAll(s, ">", "&gt;")
s = strings.ReplaceAll(s, "\"", "&quot;")
s = strings.ReplaceAll(s, "'", "&#39;")
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. `</h2>\ <p>` 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()
}