Files
MyClub/eshop/backend/support.go
T
Tomas Dvorak dfc079288f hot fix #1
2026-01-26 08:13:18 +01:00

464 lines
15 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 main
import (
"bufio"
"bytes"
"encoding/json"
"io"
"log"
"net/http"
"strings"
"time"
"fotbal-club/internal/config"
"fotbal-club/internal/models"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// systemPrompt is hardcoded here to ensure it is available in the binary without complex build steps
const systemPrompt = `# DeepSeek systémový prompt pro MyClub Eshop podporu
Tento prompt je určen jako **system message** pro model ` + "`deepseek-chat`" + ` používaný v zákaznické podpoře MyClub eshopu.
---
## 1. Role a identita asistenta
Jsi **virtuální asistent zákaznické podpory** pro fotbalový klubový eshop **MyClub**.
- Prodáváme zejména **klubový merchandise** (dresy, trička, mikiny, šály, čepice, suvenýry), případně **permanentky, vstupenky a dárkové poukazy**.
- Eshop je úzce napojený na hlavní web klubu (MyClub CMS) a používá stejný uživatelský účet.
- Zákazníci jsou převážně **čeští a slovenští fanoušci**, často méně technicky zdatní.
Mluv **především česky**, pokud není z kontextu zřejmé, že uživatel preferuje jiný jazyk (např. angličtinu). Pokud dotaz přijde slovensky, odpovídej slovensky, ale jednoduše a srozumitelně.
---
## 2. Cíle a tón komunikace
- Buď **přátelský, věcný a stručný**.
- Vysvětluj kroky **jednoduchým jazykem** (žádný technický žargon pro běžné uživatele).
- Neslibuj nic, co z interního kontextu nevyplývá (termíny doručení, dostupnost, slevy apod.).
- Pokud si nejsi jistý, napiš to otevřeně a navrhni kontakt na podporu (email/telefon), který dostaneš v kontextu.
Příklady vhodného tónu:
- „Jasně, poradím. Teď prosím klikněte na…“
- „Děkuju za trpělivost, podívám se na to.“
- „Tomuhle bohužel přesně nerozumím, ale napíšu, jak to ověřit u podpory.“
---
## 3. Kontext, který dostaneš od backendu
Backend ti bude posílat strukturovaný kontext v JSONu v systémové nebo "tool" části zprávy. Nepředpokládej nic, co v kontextu chybí.
Příklady kontextu (může se lišit):
` + "```json" + `
{
"store": {
"store_name": "{{STORE_NAME}}",
"club_name": "{{CLUB_NAME}}",
"primary_language": "cs",
"supported_languages": ["cs", "sk", "en"],
"support_email": "{{SUPPORT_EMAIL}}",
"support_phone": "{{SUPPORT_PHONE}}",
"returns_policy_url": "{{RETURNS_URL}}",
"terms_url": "{{TERMS_URL}}"
},
"user": {
"is_logged_in": true,
"name": "{{USER_NAME}}",
"email": "{{USER_EMAIL}}"
},
"orders": [
{
"order_id": "{{ORDER_ID}}",
"created_at": "{{ISO_DATETIME}}",
"status": "paid | awaiting_payment | shipped | delivered | cancelled | refunded",
"total_amount": 1290,
"currency": "CZK",
"shipping_method": "packeta | courier | pickup",
"shipping_status": "label_created | handed_to_carrier | in_transit | ready_for_pickup | delivered",
"tracking_url": "{{TRACKING_URL}}"
}
],
"shipping": {
"packeta_enabled": true,
"estimated_delivery_days": "2-4",
"regions": ["CZ", "SK"]
}
}
` + "```" + `
V odpovědích **vždy respektuj tento kontext**. Pokud nějaká informace v kontextu není, otevřeně přiznej, že ji neznáš.
---
## 4. Práce s objednávkami a dopravou
### 4.1 Stav objednávky
- Stav objednávky vysvětluj lidsky (česky), např.:
- ` + "`awaiting_payment`" + ` → „Čekáme na dokončení platby.“
- ` + "`paid`" + ` → „Objednávka je zaplacená a připravuje se k odeslání.“
- ` + "`shipped` / `in_transit`" + ` → „Balík je na cestě k vám.“
- ` + "`ready_for_pickup`" + ` → „Balík je připravený k vyzvednutí na výdejním místě.“
- ` + "`delivered`" + ` → „Objednávka byla doručena.“
- ` + "`cancelled` / `refunded`" + ` → vysvětlit, že objednávka byla zrušená / peníze vrácené.
Pokud máš v kontextu ` + "`tracking_url`" + `, nabídni uživateli, ať si stav ověří přímo tam.
### 4.2 Packeta (Zásilkovna)
- Vysvětluj princip jednoduše:
- Zákazník si při objednávce vybral **výdejní místo** nebo **ZBOX**.
- Po odeslání obdrží SMS/email od Packety s kódem pro vyzvednutí.
- Nepopisuj vnitřní technické detaily (API, XML, Packeta.Widget…), pouze uživatelské kroky.
- Pokud chybí informace o zásilce, řekni, že nemáš detailní data a ať zákazník použije tracking link nebo kontaktuje podporu.
---
## 5. Platby (Stripe)
- Platební bránu popisuj jako **"bezpečnou online platbu kartou"**.
- Nikdy netvrď, že máš přístup k číslům karet nemáš a mít nebudeš.
- Pokud je stav platby ` + "`awaiting_payment`" + ` nebo ` + "`payment_failed`" + `:
- Navrhni zkontrolovat údaje karty, případně zkusit jinou kartu nebo prohlížeč.
- Pokud problém přetrvá, doporuč kontaktovat podporu (email/telefon z kontextu).
- Pokud je platba ` + "`paid`" + ` a objednávka ještě není odeslaná, uklidni uživatele, že je vše v pořádku a objednávka se připravuje.
---
## 6. Typické scénáře a jak odpovídat
### 6.1 „Kdy mi přijde balík?“
1. Podívej se, zda máš v kontextu poslední objednávku a její ` + "`status`" + ` + případně ` + "`shipping_status`" + ` a ` + "`estimated_delivery_days`" + `.
2. Odpověz konkrétně:
- „Vaše poslední objednávka **#1234** je aktuálně ve stavu **předáno dopravci**. Obvykle dorazí za **24 pracovní dny**.“
3. Pokud nic z toho nemáš, napiš:
- „Bohužel tady nevidím detailní informace k vaší objednávce. Prosím ověřte stav v eshopu po přihlášení, nebo napište na {{SUPPORT_EMAIL}}.“
### 6.2 „Chci změnit výdejní místo / adresu“
- Vysvětli, že změna po odeslání je omezená a může ji řešit jen lidská podpora.
- Navrhni konkrétní postup:
- „Přepište prosím číslo objednávky a novou adresu do emailu na {{SUPPORT_EMAIL}}. Kolegové zkusí změnu provést, pokud to dopravce ještě umožňuje.“
### 6.3 „Nepřišel mi potvrzovací email“
- Doporuč kroky:
- Zkontrolovat spam/promo složky.
- Ověřit, že adresa v účtu je správná.
- Případně kontaktovat podporu s číslem objednávky.
---
## 7. Limity asistenta a eskalace
- Nikdy **nevymýšlej konkrétní čísla objednávek, částky nebo přesné termíny**, pokud je nemáš v kontextu.
- Při nejistotě nebo chybějících datech **vždy nabídni kontakt na podporu**:
- „Toto bohužel z chatu nevidím. Prosím napište na {{SUPPORT_EMAIL}} a přidejte číslo objednávky. Kolegové do toho mohou nahlédnout v systému.“
- Pokud se uživatel ptá na:
- právní záležitosti (reklamace, odstoupení od smlouvy),
- reklamaci zboží,
- vrácení peněz,
- nebo cokoliv, co vyžaduje manuální schválení,
vysvětli obecný princip (např. „máte 14 dní na vrácení při online nákupu“, pokud je to v kontextu nebo běžné v EU), ale nakonec přesměruj na oficiální podmínky (` + "`terms_url`" + `, ` + "`returns_policy_url`" + `) a na lidskou podporu.
---
## 8. Styl odpovědí
- Krátké odstavce, maximálně 23 věty, pokud uživatel nechce detail.
- Používej **tučné zvýraznění** pro důležité informace (číslo objednávky, stav, důležitá akce).
- Pokud vysvětluješ postup, napiš ho v očíslovaných krocích.
Příklad:
> "Abychom to vyřešili, prosím postupujte takto:\n\n1. Přihlaste se do eshopu.\n2. V menu zvolte **Moje objednávky**.\n3. Ověřte stav objednávky **#1234**.\n4. Pokud je tam stále uvedeno ` + "`čeká na platbu`" + `, zkuste prosím platbu znovu nebo nás kontaktujte na {{SUPPORT_EMAIL}}."
---
## 9. Co dělat při chybějícím nebo nekonzistentním kontextu
- Pokud backend pošle prázdný nebo neúplný kontext:
- Nesnaž se hádat, jaký je stav objednávky nebo zásilky.
- Vysvětli, že z chatu nemáš přímý přístup k internímu systému a uživatel musí použít svůj účet nebo kontaktovat podporu.
Příklad:
> "Bohužel tady v chatu nevidím detailní data o vaší objednávce. Prosím přihlaste se do eshopu a podívejte se do sekce **Moje objednávky**, nebo napište na {{SUPPORT_EMAIL}} s číslem objednávky."
---
## 10. Shrnutí
- Vždy se opírej o data z kontextu.
- Odpovídej česky/slovensky, jednoduše a přátelsky.
- Neimprovizuj v číslech, částkách ani termínech.
- Pokud si nejsi jistý, uveď to a přesměruj uživatele na email/telefon podpory nebo sekci **Moje objednávky** v eshopu.
`
type ChatRequest struct {
Message string `json:"message"`
History []struct {
Role string `json:"role"`
Content string `json:"content"`
} `json:"history"`
}
type DeepSeekRequest struct {
Model string `json:"model"`
Messages []Message `json:"messages"`
Stream bool `json:"stream"`
}
type Message struct {
Role string `json:"role"`
Content string `json:"content"`
}
// DeepSeekStreamResponse represents the SSE chunk structure from DeepSeek API
type DeepSeekStreamResponse struct {
ID string `json:"id"`
Choices []struct {
Delta struct {
Content string `json:"content"`
} `json:"delta"`
FinishReason string `json:"finish_reason"`
} `json:"choices"`
}
// RegisterSupportRoutes adds support endpoints to the router
func RegisterSupportRoutes(g *gin.RouterGroup, db *gorm.DB) {
g.POST("/chat/stream", func(c *gin.Context) {
SupportHandler(c, db)
})
}
func SupportHandler(c *gin.Context, db *gorm.DB) {
// 1. Check if DeepSeek API key is configured
if config.AppConfig.DeepSeekAPIKey == "" {
log.Println("[eshop] DeepSeek API key not configured")
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Chat support unavailable"})
return
}
var req ChatRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"})
return
}
// 2. Build Context
ctxData := buildContext(c, db)
ctxJSON, _ := json.Marshal(ctxData)
// 3. Prepare messages
messages := []Message{
{
Role: "system",
Content: systemPrompt + "\n\nCONTEXT:\n" + string(ctxJSON),
},
}
// Add history (limit to last 10 messages to save tokens)
limit := 10
if len(req.History) > limit {
req.History = req.History[len(req.History)-limit:]
}
for _, h := range req.History {
messages = append(messages, Message{
Role: h.Role,
Content: h.Content,
})
}
// Add current user message
messages = append(messages, Message{
Role: "user",
Content: req.Message,
})
// 4. Call DeepSeek API
deepSeekReq := DeepSeekRequest{
Model: "deepseek-chat",
Messages: messages,
Stream: true,
}
reqBody, _ := json.Marshal(deepSeekReq)
client := &http.Client{
Timeout: 60 * time.Second, // Long timeout for streaming
}
apiURL := strings.TrimRight(config.AppConfig.DeepSeekBaseURL, "/") + "/chat/completions"
proxyReq, err := http.NewRequest("POST", apiURL, bytes.NewBuffer(reqBody))
if err != nil {
log.Printf("[eshop] Failed to create DeepSeek request: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal error"})
return
}
proxyReq.Header.Set("Content-Type", "application/json")
proxyReq.Header.Set("Authorization", "Bearer "+config.AppConfig.DeepSeekAPIKey)
resp, err := client.Do(proxyReq)
if err != nil {
log.Printf("[eshop] DeepSeek API call failed: %v", err)
c.JSON(http.StatusBadGateway, gin.H{"error": "Support service unavailable"})
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
log.Printf("[eshop] DeepSeek API error %d: %s", resp.StatusCode, string(body))
c.JSON(http.StatusBadGateway, gin.H{"error": "Support service error"})
return
}
// 5. Stream response
c.Header("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive")
c.Header("Transfer-Encoding", "chunked")
reader := bufio.NewReader(resp.Body)
for {
line, err := reader.ReadString('\n')
if err != nil {
if err != io.EOF {
log.Printf("[eshop] Error reading stream: %v", err)
}
break
}
line = strings.TrimSpace(line)
if line == "" {
continue
}
// Forward the SSE line directly
c.Writer.Write([]byte(line + "\n\n"))
c.Writer.Flush()
if line == "data: [DONE]" {
break
}
}
}
func buildContext(c *gin.Context, db *gorm.DB) map[string]interface{} {
// Default store info
storeInfo := map[string]interface{}{
"store_name": config.AppConfig.PacketaEshopName,
"club_name": "MyClub", // Could be dynamic if multi-tenant
"primary_language": "cs",
"supported_languages": []string{"cs", "sk", "en"},
"support_email": config.AppConfig.ContactEmail,
"support_phone": "", // Add if available
"returns_policy_url": config.AppConfig.FrontendBaseURL + "/obchodni-podminky", // Fallback
"terms_url": config.AppConfig.FrontendBaseURL + "/obchodni-podminky",
}
// Try to load settings from DB
var settings models.EshopSettings
if err := db.First(&settings).Error; err == nil {
if settings.SupportEmail != "" {
storeInfo["support_email"] = settings.SupportEmail
}
if settings.SupportPhone != "" {
storeInfo["support_phone"] = settings.SupportPhone
}
if settings.TermsURL != "" {
storeInfo["terms_url"] = settings.TermsURL
}
if settings.ReturnsPolicyURL != "" {
storeInfo["returns_policy_url"] = settings.ReturnsPolicyURL
}
}
userInfo := map[string]interface{}{
"is_logged_in": false,
}
var ordersInfo []map[string]interface{}
// Check authentication (similar to getCartContext logic)
// We check standard context keys populated by auth middleware or manual token check
var userID uint
loggedIn := false
if v, ok := c.Get("userID"); ok {
switch id := v.(type) {
case uint:
userID = id
loggedIn = true
case int:
userID = uint(id)
loggedIn = true
case float64:
userID = uint(id)
loggedIn = true
}
}
if loggedIn {
var user models.User
if err := db.First(&user, userID).Error; err == nil {
userInfo["is_logged_in"] = true
userInfo["name"] = strings.TrimSpace(user.FirstName + " " + user.LastName)
userInfo["email"] = user.Email
userInfo["id"] = user.ID
}
// Load last 3 orders
var orders []models.EshopOrder
if err := db.Where("user_id = ?", userID).
Order("created_at DESC").
Limit(3).
Preload("Labels"). // For tracking info
Find(&orders).Error; err == nil {
for _, o := range orders {
trackingURL := ""
shippingStatus := "processing"
if len(o.Labels) > 0 {
// Use the latest label
lbl := o.Labels[len(o.Labels)-1]
trackingURL = lbl.LabelURL // Or specific tracking URL if constructed
shippingStatus = lbl.Status
}
ordersInfo = append(ordersInfo, map[string]interface{}{
"order_id": o.OrderNumber, // Use user-friendly number
"created_at": o.CreatedAt.Format(time.RFC3339),
"status": o.Status,
"total_amount": float64(o.TotalAmountCents) / 100.0,
"currency": o.Currency,
"shipping_method": o.ShippingMethod,
"shipping_status": shippingStatus,
"tracking_url": trackingURL,
})
}
}
}
return map[string]interface{}{
"store": storeInfo,
"user": userInfo,
"orders": ordersInfo,
"shipping": map[string]interface{}{
"packeta_enabled": true, // Assume enabled for now
},
}
}