449 lines
14 KiB
Go
449 lines
14 KiB
Go
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<translation_of_product_description>"
|
|
request += ProductDescription.Description
|
|
request += "</translation_of_product_description>\n\n"
|
|
request += "Remember: translate to " + lang.ISOCode + " without changing the html structure."
|
|
request += "\n\n<translation_of_product_short_description>"
|
|
request += ProductDescription.DescriptionShort
|
|
request += "</translation_of_product_short_description>\n\n"
|
|
request += "Remember: translate to " + lang.ISOCode + " without changing the html structure."
|
|
request += "\n\n<translation_of_product_meta_description>"
|
|
request += ProductDescription.MetaDescription
|
|
request += "</translation_of_product_meta_description>\n\n"
|
|
request += "Remember: translate to " + lang.ISOCode + " without changing the html structure."
|
|
request += "\n\n<translation_of_product_meta_title>"
|
|
request += ProductDescription.MetaTitle
|
|
request += "</translation_of_product_meta_title>\n\n"
|
|
request += "Remember: translate to " + lang.ISOCode + " without changing the html structure."
|
|
request += "\n\n<translation_of_product_name>"
|
|
request += ProductDescription.Name
|
|
request += "</translation_of_product_name>\n\n"
|
|
request += "Remember: translate to " + lang.ISOCode + " without changing the html structure."
|
|
request += "\n\n<translation_of_display_text_available_now>"
|
|
request += ProductDescription.AvailableNow
|
|
request += "</translation_of_display_text_available_now>\n\n"
|
|
request += "Remember: translate to " + lang.ISOCode + " without changing the html structure."
|
|
request += "\n\n<translation_of_display_text_available_later>"
|
|
request += ProductDescription.AvailableLater
|
|
request += "</translation_of_display_text_available_later>\n\n"
|
|
request += "Remember: translate to " + lang.ISOCode + " without changing the html structure."
|
|
request += "\n\n<translation_of_product_usage>"
|
|
request += ProductDescription.Usage
|
|
request += "</translation_of_product_usage>"
|
|
|
|
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 += "</" + AttrName(v.Name) + ">"
|
|
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+">", "</"+key+">")
|
|
if !success || !isValidXHTML(match) {
|
|
return false, ""
|
|
}
|
|
|
|
success, resolution := RebuildFromResponse("<"+key+">"+original+"</"+key+">", "<"+key+">"+match+"</"+key+">")
|
|
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 += "</" + AttrName(v_original.Name) + ">"
|
|
}
|
|
|
|
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 += "<!--" + string(v_original) + "-->"
|
|
token_original, err_original = d_original.Token()
|
|
continue
|
|
|
|
case xml.ProcInst:
|
|
if len(v_original.Inst) == 0 {
|
|
result += "<?" + v_original.Target + "?>"
|
|
} else {
|
|
result += "<?" + v_original.Target + " " + string(v_original.Inst) + "?>"
|
|
}
|
|
token_original, err_original = d_original.Token()
|
|
continue
|
|
|
|
case xml.Directive:
|
|
result += "<!" + string(v_original) + ">"
|
|
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
|
|
}
|
|
}
|