Files
MyClub/internal/controllers/ai_controller.go
T
Tomas Dvorak b9cea0cd77 dev day #79
2025-11-02 01:04:02 +01:00

727 lines
31 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package controllers
import (
"bytes"
"encoding/json"
"fmt"
"html"
"net/http"
"regexp"
"strings"
"time"
"os"
"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
}
baseURL := getOpenRouterBaseURL()
apiKey := getOpenRouterAPIKey()
if strings.TrimSpace(apiKey) == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "OPENROUTER_API_KEY není nastaven"})
return
}
model := getOpenRouterModel()
if model == "" { model = "mistralai/mistral-small-3.2-24b-instruct:free" }
fallbackModel := getOpenRouterFallbackModel()
if fallbackModel == "" { fallbackModel = "mistralai/mistral-nemo:free" }
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 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) == "" {
if fbContent, _, fbErr := callModel(fallbackModel); fbErr == nil && strings.TrimSpace(fbContent) != "" {
content = fbContent
} 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 := json.Unmarshal([]byte(sanitized), &out); err != nil {
re := regexp.MustCompile(`(?s)\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}`)
if m := re.FindString(sanitized); m != "" {
_ = json.Unmarshal([]byte(m), &out)
}
}
if strings.TrimSpace(out.CSS) == "" {
out.CSS = fmt.Sprintf("%s { }", rootSelector)
}
c.JSON(http.StatusOK, out)
}
// 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)
baseURL := getOpenRouterBaseURL()
apiKey := getOpenRouterAPIKey()
if strings.TrimSpace(apiKey) == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "OPENROUTER_API_KEY není nastaven"})
return
}
model := getOpenRouterModel()
if model == "" {
model = "mistralai/mistral-small-3.2-24b-instruct:free"
}
fallbackModel := getOpenRouterFallbackModel()
if fallbackModel == "" {
fallbackModel = "mistralai/mistral-nemo:free"
}
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 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) == "" {
if fbContent, _, fbErr := callModel(fallbackModel); fbErr == nil && strings.TrimSpace(fbContent) != "" {
content = fbContent
} 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
}
}
var out aiAboutResponse
sanitized := sanitizeAIResponse(content)
if err := json.Unmarshal([]byte(sanitized), &out); err != nil {
re := regexp.MustCompile(`(?s)\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}`)
if m := re.FindString(sanitized); m != "" {
_ = json.Unmarshal([]byte(m), &out)
}
}
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)
}
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"`
}
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"`
}
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"`
}
// Instagram caption generation
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"`
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 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 80140 slov, rozdělit do 23 krátkých odstavců.",
"Použij maximálně 6 emotikonů (žádné dlouhé řetězy).",
"Na konec vlož oddělovač a řádek '🔗 ' následovaný přesně poskytnutým krátkým odkazem.",
"Přidej 46 relevantních českých hashtagů (včetně klubového), přirozeně na konci.",
"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- "))
baseURL := getOpenRouterBaseURL()
apiKey := getOpenRouterAPIKey()
if strings.TrimSpace(apiKey) == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "OPENROUTER_API_KEY není nastaven"})
return
}
model := getOpenRouterModel()
if model == "" { model = "mistralai/mistral-small-3.2-24b-instruct:free" }
fallbackModel := getOpenRouterFallbackModel()
if fallbackModel == "" { fallbackModel = "mistralai/mistral-nemo:free" }
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 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) == "" {
if fbContent, _, fbErr := callModel(fallbackModel); fbErr == nil && strings.TrimSpace(fbContent) != "" {
content = fbContent
} 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 aiInstagramResponse
if err := json.Unmarshal([]byte(sanitized), &out); err != nil {
re := regexp.MustCompile(`(?s)\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}`)
if m := re.FindString(sanitized); m != "" {
_ = json.Unmarshal([]byte(m), &out)
}
}
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)
}
// GenerateBlog creates a blog article using the OpenRouter API (with Mistral models)
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 - emphasizing user text as primary source, but allow expansion if needed
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ů, přidej vhodné HTML značky (nadpisy h2/h3, odstavce p, seznamy ul/ol). DŮLEŽITÉ: Píšeš v GRAMATICKY SPRÁVNÉ češtině - používej pouze existující česká slova a správné tvary. Žádné neologismy nebo negramatické tvary (např. místo 'nevděkovaný' použij 'nevděčný'). Píšeš srozumitelně a čtivě pro fotbalové fanoušky. 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.\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, detaily kolem událostí z textu uživatele. Buď čtivý a zajímavý.\n3) Pokud je text dostatečně dlouhý, pouze ho strukturuj do HTML s nadpisy a odstavci.\n4) Vygeneruj výstižný titulek vycházející 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 obsah = text uživatele + rozvinutí (pokud nutné) strukturovaný do HTML tagů (h2, p, ul, ol). BEZ inline stylů.\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))
// Prepare OpenRouter request
baseURL := getOpenRouterBaseURL()
apiKey := getOpenRouterAPIKey()
if strings.TrimSpace(apiKey) == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "OPENROUTER_API_KEY není nastaven"})
return
}
// Primary and fallback models
model := getOpenRouterModel()
if model == "" {
model = "mistralai/mistral-small-3.2-24b-instruct:free"
}
fallbackModel := getOpenRouterFallbackModel()
if fallbackModel == "" {
fallbackModel = "mistralai/mistral-nemo:free"
}
// 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 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) == "" {
// Attempt fallback model
if fbContent, _, fbErr := callModel(fallbackModel); fbErr == nil && strings.TrimSpace(fbContent) != "" {
content = fbContent
} else {
// Provide the primary error if available
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
}
}
// Sanitize and parse JSON returned by the model
var out aiBlogResponse
// Clean up the response: remove markdown code blocks, backticks, etc.
sanitized := sanitizeAIResponse(content)
// Try to parse the sanitized content
if err := json.Unmarshal([]byte(sanitized), &out); err != nil {
// Best-effort: try to find JSON block
re := regexp.MustCompile(`(?s)\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}`)
if m := re.FindString(sanitized); m != "" {
_ = json.Unmarshal([]byte(m), &out)
}
}
// Decode HTML entities in the html field
if out.HTML != "" {
out.HTML = html.UnescapeString(out.HTML)
}
// Fallbacks if the model did not provide title/slug
if out.Title == "" {
out.Title = deriveTitle(req.Prompt)
}
// Validate slug: short, independent from title. If not valid, derive from prompt.
if !isValidShortSlug(out.Slug) || out.Slug == slugify(out.Title) {
out.Slug = shortSlugFromPrompt(req.Prompt)
}
if out.HTML == "" {
// Wrap raw content as paragraph fallback
out.HTML = "<h1>" + htmlEscape(out.Title) + "</h1><p>" + htmlEscape(content) + "</p>"
}
c.JSON(http.StatusOK, out)
}
// Helpers for OpenRouter config
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 ""
}
// Small utility wrappers to avoid importing os directly multiple times
func getenv(k string) string { return strings.TrimSpace(strings.ReplaceAll(strings.ReplaceAll(strings.TrimSpace(os.Getenv(k)), "\r", ""), "\n", "")) }
// deriveTitle returns a readable title from user prompt
func deriveTitle(s string) string {
s = strings.TrimSpace(s)
if s == "" {
return "Novinky z klubu"
}
// Capitalize first letter, keep it concise
if len(s) > 120 {
s = s[:120]
}
return strings.ToUpper(string([]rune(s)[:1])) + s[1:]
}
// slugify creates a URL-friendly slug without diacritics
func slugify(s string) string {
s = strings.ToLower(strings.TrimSpace(s))
// Replace diacritics (basic map); for full support, consider x/text/unicode/norm and transform
replacer := strings.NewReplacer(
"á", "a", "č", "c", "ď", "d", "é", "e", "ě", "e", "í", "i", "ň", "n",
"ó", "o", "ř", "r", "š", "s", "ť", "t", "ú", "u", "ů", "u", "ý", "y", "ž", "z",
)
s = replacer.Replace(s)
// Replace any non alnum with hyphen
re := regexp.MustCompile("[^a-z0-9]+")
s = re.ReplaceAllString(s, "-")
s = strings.Trim(s, "-")
if s == "" {
return "clanek"
}
return s
}
// isValidShortSlug checks basic constraints: non-empty, <= 40 chars, 3-5 words (by hyphens), allowed charset
func isValidShortSlug(s string) bool {
s = strings.TrimSpace(s)
if s == "" { return false }
if len(s) > 40 { return false }
parts := strings.Split(s, "-")
// filter empty parts
w := 0
for _, p := range parts { if p != "" { w++ } }
if w < 3 || w > 5 { return false }
// allowed chars: a-z0-9-
re := regexp.MustCompile(`^[a-z0-9-]+$`)
return re.MatchString(s)
}
// shortSlugFromPrompt creates a compact, independent slug from the prompt text
func shortSlugFromPrompt(prompt string) string {
p := strings.ToLower(strings.TrimSpace(prompt))
if p == "" { return "clanek" }
// basic diacritics removal via slugify, then split to words
p = slugify(p)
parts := strings.Split(p, "-")
// simple Czech stopwords list (subset)
stop := map[string]struct{}{"a":{},"i":{},"v":{},"ve":{},"z":{},"za":{},"od":{},"do":{},"u":{},"o":{},"s":{},"se":{},"na":{},"po":{},"pod":{},"nad":{},"proti":{},"pri":{},"bez":{},"k":{},"ke":{},"ten":{},"ta":{},"to":{},"ty":{},"tento":{},"tato":{},"toto":{},"jak":{},"jako":{},"ze":{}}
var kept []string
for _, w := range parts {
if w == "" { continue }
if _, ok := stop[w]; ok { continue }
kept = append(kept, w)
if len(kept) >= 5 { break }
}
if len(kept) == 0 { kept = parts }
// prefer 3-5 words, trim to 4 if too many
if len(kept) > 5 { kept = kept[:5] }
if len(kept) >= 4 { kept = kept[:4] }
s := strings.Join(kept, "-")
if len(s) > 40 { s = s[:40] }
s = strings.Trim(s, "-")
if !isValidShortSlug(s) {
// final fallback
s = slugify(deriveTitle(prompt))
if len(s) > 40 { s = s[:40] }
s = strings.Trim(s, "-")
}
return s
}
func htmlEscape(s string) string {
s = strings.ReplaceAll(s, "&", "&amp;")
s = strings.ReplaceAll(s, "<", "&lt;")
s = strings.ReplaceAll(s, ">", "&gt;")
s = strings.ReplaceAll(s, "\"", "&quot;")
s = strings.ReplaceAll(s, "'", "&#39;")
return s
}
// sanitizeAIResponse cleans up AI response to extract valid JSON
// Handles markdown code blocks, extra backticks, and other formatting issues
func sanitizeAIResponse(content string) string {
// Trim whitespace
content = strings.TrimSpace(content)
// Remove markdown code block markers (```json, ``json, `, etc.)
// Handle various formats: ```json\n{...}\n```, ``json{...}``, `{...}`
content = regexp.MustCompile(`^\s*`+"`"+`{1,3}\s*json\s*`).ReplaceAllString(content, "")
content = regexp.MustCompile(`\s*`+"`"+`{1,3}\s*$`).ReplaceAllString(content, "")
// Remove any remaining backticks at start/end
content = strings.Trim(content, "`")
content = strings.TrimSpace(content)
return content
}