397 lines
11 KiB
Go
397 lines
11 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)
|
|
}
|
|
ProductDescription.LangID = productToLangID
|
|
|
|
// 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
|
|
}
|
|
|
|
fields := []*string{&ProductDescription.Description,
|
|
&ProductDescription.DescriptionShort,
|
|
&ProductDescription.MetaDescription,
|
|
&ProductDescription.MetaTitle,
|
|
&ProductDescription.Name,
|
|
&ProductDescription.AvailableNow,
|
|
&ProductDescription.AvailableLater,
|
|
&ProductDescription.Usage,
|
|
}
|
|
keys := []string{"translation_of_product_description",
|
|
"translation_of_product_short_description",
|
|
"translation_of_product_meta_description",
|
|
"translation_of_product_meta_title",
|
|
"translation_of_product_name",
|
|
"translation_of_product_available_now",
|
|
"translation_of_product_available_later",
|
|
"translation_of_product_usage",
|
|
}
|
|
|
|
request := "Translate to " + lang.ISOCode + " without changing the html structure.\n"
|
|
for i := 0; i < len(keys); i++ {
|
|
request += "\n<" + keys[i] + ">"
|
|
request += *fields[i]
|
|
request += "</" + keys[i] + ">\n"
|
|
}
|
|
request = cleanForPrompt(request)
|
|
|
|
openai_response, _ := 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
|
|
}
|
|
|
|
for i := 0; i < len(keys); i++ {
|
|
success, resolution := resolveResponse(*fields[i], openai_response.OutputText(), keys[i])
|
|
if !success {
|
|
return nil, responseErrors.ErrOpenAIBadOutput
|
|
}
|
|
*fields[i] = 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
|
|
}
|
|
}
|