mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-04 10:42:57 +00:00
3073 lines
94 KiB
Go
3073 lines
94 KiB
Go
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é <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, "&", "&")
|
||
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. `</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()
|
||
}
|