mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-04 18:52:56 +00:00
hot fix #1
This commit is contained in:
@@ -0,0 +1,463 @@
|
||||
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
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user