mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-04 02:32:57 +00:00
dev day #79
This commit is contained in:
@@ -298,6 +298,154 @@ 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 80–140 slov, rozdělit do 2–3 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 4–6 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
|
||||
|
||||
Reference in New Issue
Block a user