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 }, } }