mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-04 02:32:57 +00:00
475 lines
18 KiB
Go
475 lines
18 KiB
Go
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
|
|
}
|
|
|
|
// 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"`
|
|
}
|
|
|
|
// 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, "&", "&")
|
|
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
|
|
}
|