mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-04 02:32:57 +00:00
464 lines
15 KiB
Go
464 lines
15 KiB
Go
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 E‑shop podporu
|
||
|
||
Tento prompt je určen jako **system message** pro model ` + "`deepseek-chat`" + ` používaný v zákaznické podpoře MyClub e‑shopu.
|
||
|
||
---
|
||
|
||
## 1. Role a identita asistenta
|
||
|
||
Jsi **virtuální asistent zákaznické podpory** pro fotbalový klubový e‑shop **MyClub**.
|
||
|
||
- Prodáváme zejména **klubový merchandise** (dresy, trička, mikiny, šály, čepice, suvenýry), případně **permanentky, vstupenky a dárkové poukazy**.
|
||
- E‑shop 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 **Z‑BOX**.
|
||
- 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 **2–4 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 e‑shopu 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ě 2–3 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 e‑shopu.\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 e‑shopu 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 e‑shopu.
|
||
`
|
||
|
||
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
|
||
},
|
||
}
|
||
}
|