package productDescriptionService import ( "context" "encoding/xml" "fmt" "io" "net/http" "slices" "strings" "time" "git.ma-al.com/goc_daniel/b2b/app/db" "git.ma-al.com/goc_daniel/b2b/app/model" "git.ma-al.com/goc_daniel/b2b/app/service/langsService" "git.ma-al.com/goc_daniel/b2b/app/utils/responseErrors" "gorm.io/gorm" "github.com/openai/openai-go/v3" "github.com/openai/openai-go/v3/option" "github.com/openai/openai-go/v3/responses" ) type ProductDescriptionService struct { db *gorm.DB client openai.Client } func New() *ProductDescriptionService { return &ProductDescriptionService{ db: db.Get(), client: openai.NewClient(option.WithAPIKey("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"), option.WithHTTPClient(&http.Client{Timeout: 300 * time.Second})), } } // We assume that any user has access to all product descriptions func (s *ProductDescriptionService) GetProductDescription(userID uint, productID uint, productShopID uint, productLangID uint) (*model.ProductDescription, error) { var ProductDescription model.ProductDescription err := s.db. Table("ps_product_lang"). Where("id_product = ? AND id_shop = ? AND id_lang = ?", productID, productShopID, productLangID). First(&ProductDescription).Error if err != nil { return nil, fmt.Errorf("database error: %w", err) } return &ProductDescription, nil } // Updates relevant fields with the "updates" map func (s *ProductDescriptionService) SaveProductDescription(userID uint, productID uint, productShopID uint, productLangID uint, updates map[string]string) error { // only some fields can be affected allowedFields := []string{"description", "description_short", "meta_description", "meta_title", "name", "available_now", "available_later", "usage"} for key := range updates { if !slices.Contains(allowedFields, key) { return responseErrors.ErrBadField } } // check that fields description, description_short and usage, if they exist, have a valid html format mustBeHTML := []string{"description", "description_short", "usage"} for i := 0; i < len(mustBeHTML); i++ { if text, exists := updates[mustBeHTML[i]]; exists { if !isValidXHTML(text) { return responseErrors.ErrInvalidXHTML } } } record := model.ProductDescription{ ProductID: productID, ShopID: productShopID, LangID: productLangID, } err := s.db. Table("ps_product_lang"). Where("id_product = ? AND id_shop = ? AND id_lang = ?", productID, productShopID, productLangID). FirstOrCreate(&record).Error if err != nil { return fmt.Errorf("database error: %w", err) } if len(updates) == 0 { return nil } updatesIface := make(map[string]interface{}, len(updates)) for k, v := range updates { updatesIface[k] = v } err = s.db. Table("ps_product_lang"). Where("id_product = ? AND id_shop = ? AND id_lang = ?", productID, productShopID, productLangID). Updates(updatesIface).Error if err != nil { return fmt.Errorf("database error: %w", err) } return nil } // Updates relevant fields with the "updates" map func (s *ProductDescriptionService) TranslateProductDescription(userID uint, productID uint, productShopID uint, productFromLangID uint, productToLangID uint) (*model.ProductDescription, error) { var ProductDescription model.ProductDescription err := s.db. Table("ps_product_lang"). Where("id_product = ? AND id_shop = ? AND id_lang = ?", productID, productShopID, productFromLangID). First(&ProductDescription).Error if err != nil { return nil, fmt.Errorf("database error: %w", err) } // we translate all changeable fields, and we keep the exact same HTML structure in relevant fields. lang, err := langsService.LangSrv.GetLanguageById(productToLangID) if err != nil { return nil, err } request := "Translate to " + lang.ISOCode + " without changing the html structure." request += "\n\n" request += ProductDescription.Description request += "\n\n" request += "Remember: translate to " + lang.ISOCode + " without changing the html structure." request += "\n\n" request += ProductDescription.DescriptionShort request += "\n\n" request += "Remember: translate to " + lang.ISOCode + " without changing the html structure." request += "\n\n" request += ProductDescription.MetaDescription request += "\n\n" request += "Remember: translate to " + lang.ISOCode + " without changing the html structure." request += "\n\n" request += ProductDescription.MetaTitle request += "\n\n" request += "Remember: translate to " + lang.ISOCode + " without changing the html structure." request += "\n\n" request += ProductDescription.Name request += "\n\n" request += "Remember: translate to " + lang.ISOCode + " without changing the html structure." request += "\n\n" request += ProductDescription.AvailableNow request += "\n\n" request += "Remember: translate to " + lang.ISOCode + " without changing the html structure." request += "\n\n" request += ProductDescription.AvailableLater request += "\n\n" request += "Remember: translate to " + lang.ISOCode + " without changing the html structure." request += "\n\n" request += ProductDescription.Usage request += "" request = cleanForPrompt(request) openai_response, err := s.client.Responses.New(context.Background(), responses.ResponseNewParams{ Input: responses.ResponseNewParamsInputUnion{OfString: openai.String(request)}, Model: openai.ChatModelGPT4_1Mini, // Model: openai.ChatModelGPT4_1Nano, }) if openai_response.Status != "completed" { return nil, responseErrors.ErrOpenAIResponseFail } output := openai_response.OutputText() // for testing purposes // fi, err := os.ReadFile("/home/daniel/coding/work/b2b/app/service/productDescriptionService/test_out.txt") // just pass the file name // output := string(fi) success, resolution := resolveResponse(ProductDescription.Description, output, "translation_of_product_description") if !success { return nil, responseErrors.ErrOpenAIBadOutput } ProductDescription.Description = resolution success, resolution = resolveResponse(ProductDescription.DescriptionShort, output, "translation_of_product_short_description") if !success { return nil, responseErrors.ErrOpenAIBadOutput } ProductDescription.DescriptionShort = resolution success, resolution = resolveResponse(ProductDescription.MetaDescription, output, "translation_of_product_meta_description") if !success { return nil, responseErrors.ErrOpenAIBadOutput } ProductDescription.MetaDescription = resolution success, resolution = resolveResponse(ProductDescription.MetaTitle, output, "translation_of_product_meta_title") if !success { return nil, responseErrors.ErrOpenAIBadOutput } ProductDescription.MetaTitle = resolution success, resolution = resolveResponse(ProductDescription.Name, output, "translation_of_product_name") if !success { return nil, responseErrors.ErrOpenAIBadOutput } ProductDescription.Name = resolution success, resolution = resolveResponse(ProductDescription.AvailableNow, output, "translation_of_display_text_available_now") if !success { return nil, responseErrors.ErrOpenAIBadOutput } ProductDescription.AvailableNow = resolution success, resolution = resolveResponse(ProductDescription.AvailableLater, output, "translation_of_display_text_available_later") if !success { return nil, responseErrors.ErrOpenAIBadOutput } ProductDescription.AvailableLater = resolution success, resolution = resolveResponse(ProductDescription.Usage, output, "translation_of_product_usage") if !success { return nil, responseErrors.ErrOpenAIBadOutput } ProductDescription.Usage = resolution return &ProductDescription, nil } // isValidXHTML checks if the string obeys the XHTML format func isValidXHTML(s string) bool { r := strings.NewReader(s) d := xml.NewDecoder(r) // Configure the decoder for HTML; leave off strict and autoclose for XHTML d.Strict = true d.AutoClose = xml.HTMLAutoClose d.Entity = xml.HTMLEntity for { _, err := d.Token() switch err { case io.EOF: return true // We're done, it's valid! case nil: default: return false // Oops, something wasn't right } } } func cleanForPrompt(s string) string { r := strings.NewReader(s) d := xml.NewDecoder(r) prompt := "" // Configure the decoder for HTML; leave off strict and autoclose for XHTML d.Strict = true d.AutoClose = xml.HTMLAutoClose d.Entity = xml.HTMLEntity for { token, err := d.Token() if err == io.EOF { break } switch v := token.(type) { case xml.StartElement: prompt += "<" + AttrName(v.Name) for _, attr := range v.Attr { if v.Name.Local == "img" && attr.Name.Local == "alt" { prompt += fmt.Sprintf(` %s="%s"`, AttrName(attr.Name), attr.Value) } } prompt += ">" case xml.EndElement: prompt += "" case xml.CharData: prompt += string(v) case xml.Comment: case xml.ProcInst: case xml.Directive: } } return prompt } func resolveResponse(original string, response string, key string) (bool, string) { success, match := GetStringInBetween(response, "<"+key+">", "") if !success || !isValidXHTML(match) { return false, "" } success, resolution := RebuildFromResponse("<"+key+">"+original+"", "<"+key+">"+match+"") if !success { return false, "" } return true, resolution[2+len(key) : len(resolution)-3-len(key)] } // GetStringInBetween returns empty string if no start or end string found func GetStringInBetween(str string, start string, end string) (success bool, result string) { s := strings.Index(str, start) if s == -1 { return false, "" } s += len(start) e := strings.Index(str[s:], end) if e == -1 { return false, "" } return true, str[s : s+e] } // Rebuilds HTML using the original HTML as a template and the response as a source // Assumes that both original and response have the exact same XML structure func RebuildFromResponse(s_original string, s_response string) (bool, string) { r_original := strings.NewReader(s_original) d_original := xml.NewDecoder(r_original) r_response := strings.NewReader(s_response) d_response := xml.NewDecoder(r_response) result := "" // Configure the decoder for HTML; leave off strict and autoclose for XHTML d_original.Strict = true d_original.AutoClose = xml.HTMLAutoClose d_original.Entity = xml.HTMLEntity d_response.Strict = true d_response.AutoClose = xml.HTMLAutoClose d_response.Entity = xml.HTMLEntity token_original, err_original := d_original.Token() token_response, err_response := d_response.Token() for { // err_original can only be EOF or nil. if err_original != nil || err_response != nil { if err_original != err_response { return false, "" } return true, result } switch v_original := token_original.(type) { case xml.StartElement: switch v_response := token_response.(type) { case xml.StartElement: if v_original.Name.Space != v_response.Name.Space || v_original.Name.Local != v_response.Name.Local { return false, "" } result += "<" + AttrName(v_original.Name) for _, attr := range v_original.Attr { if v_original.Name.Local != "img" || attr.Name.Local != "alt" { result += fmt.Sprintf(` %s="%s"`, AttrName(attr.Name), attr.Value) } } for _, attr := range v_response.Attr { if v_response.Name.Local == "img" && attr.Name.Local == "alt" { result += fmt.Sprintf(` %s="%s"`, AttrName(attr.Name), attr.Value) } } result += ">" case xml.CharData: result += string(v_response) token_response, err_response = d_response.Token() continue default: return false, "" } case xml.EndElement: switch v_response := token_response.(type) { case xml.EndElement: if v_original.Name.Space != v_response.Name.Space || v_original.Name.Local != v_response.Name.Local { return false, "" } if v_original.Name.Local != "img" { result += "" } case xml.CharData: result += string(v_response) token_response, err_response = d_response.Token() continue default: return false, "" } case xml.CharData: switch v_response := token_response.(type) { case xml.CharData: result += string(v_response) case xml.StartElement: result += string(v_original) token_original, err_original = d_original.Token() continue case xml.EndElement: result += string(v_original) token_original, err_original = d_original.Token() continue default: return false, "" } case xml.Comment: result += "" token_original, err_original = d_original.Token() continue case xml.ProcInst: if len(v_original.Inst) == 0 { result += "" } else { result += "" } token_original, err_original = d_original.Token() continue case xml.Directive: result += "" token_original, err_original = d_original.Token() continue } token_original, err_original = d_original.Token() token_response, err_response = d_response.Token() } } func AttrName(name xml.Name) string { if name.Space == "" { return name.Local } else { return name.Space + ":" + name.Local } }