mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-04 02:32:57 +00:00
860 lines
30 KiB
Go
860 lines
30 KiB
Go
package controllers
|
||
|
||
import (
|
||
"bytes"
|
||
"encoding/json"
|
||
"fmt"
|
||
"html"
|
||
"net/http"
|
||
"os"
|
||
"regexp"
|
||
"strings"
|
||
"time"
|
||
|
||
"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
|
||
}
|
||
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 := 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) == "" {
|
||
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"`
|
||
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- "))
|
||
|
||
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 - 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))
|
||
|
||
// 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, "&", "&")
|
||
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 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
|
||
}
|