495 lines
14 KiB
Go
495 lines
14 KiB
Go
package productDescriptionService
|
||
|
||
import (
|
||
"context"
|
||
"encoding/xml"
|
||
"fmt"
|
||
"io"
|
||
"log"
|
||
"net/http"
|
||
"os"
|
||
"slices"
|
||
"strings"
|
||
"time"
|
||
|
||
"git.ma-al.com/goc_daniel/b2b/app/config"
|
||
"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"
|
||
"github.com/openai/openai-go/v3"
|
||
"github.com/openai/openai-go/v3/option"
|
||
"github.com/openai/openai-go/v3/responses"
|
||
googleopt "google.golang.org/api/option"
|
||
"gorm.io/gorm"
|
||
|
||
// [START translate_v3_import_client_library]
|
||
"cloud.google.com/go/auth/credentials"
|
||
translate "cloud.google.com/go/translate/apiv3"
|
||
"cloud.google.com/go/translate/apiv3/translatepb"
|
||
// [END translate_v3_import_client_library]
|
||
)
|
||
|
||
type ProductDescriptionService struct {
|
||
db *gorm.DB
|
||
ctx context.Context
|
||
googleCli translate.TranslationClient
|
||
client openai.Client
|
||
// projectID is the Google Cloud project ID used as the "parent" in API calls,
|
||
// e.g. "projects/my-project-123/locations/global"
|
||
projectID string
|
||
}
|
||
|
||
// New creates a ProductDescriptionService and authenticates against the
|
||
// Google Cloud Translation API using a service account key file.
|
||
//
|
||
// Required configuration (set in .env or environment):
|
||
//
|
||
// GOOGLE_APPLICATION_CREDENTIALS – absolute path to the service account JSON key file
|
||
// GOOGLE_CLOUD_PROJECT_ID – your Google Cloud project ID
|
||
//
|
||
// The service account must have the "Cloud Translation API User" role
|
||
// (roles/cloudtranslate.user) granted in Google Cloud IAM.
|
||
func New() *ProductDescriptionService {
|
||
ctx := context.Background()
|
||
cfg := config.Get()
|
||
|
||
// Read the service account key file whose path comes from config / env.
|
||
data, err := os.ReadFile(cfg.GoogleTranslate.CredentialsFile)
|
||
if err != nil {
|
||
log.Fatalf("productDescriptionService: cannot read credentials file %q: %v",
|
||
cfg.GoogleTranslate.CredentialsFile, err)
|
||
}
|
||
|
||
// Build OAuth2 credentials scoped to the Cloud Translation API.
|
||
// The correct scope for Cloud Translation v3 is "cloud-translation".
|
||
creds, err := credentials.DetectDefault(&credentials.DetectOptions{
|
||
Scopes: []string{"https://www.googleapis.com/auth/cloud-translation"},
|
||
CredentialsJSON: data,
|
||
})
|
||
if err != nil {
|
||
log.Fatalf("productDescriptionService: cannot build Google credentials: %v", err)
|
||
}
|
||
|
||
googleCli, err := translate.NewTranslationClient(ctx, googleopt.WithAuthCredentials(creds))
|
||
if err != nil {
|
||
log.Fatalf("productDescriptionService: cannot create Translation client: %v", err)
|
||
}
|
||
|
||
client := openai.NewClient(option.WithAPIKey("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"),
|
||
option.WithHTTPClient(&http.Client{Timeout: 300 * time.Second})) // five minutes timeout
|
||
|
||
return &ProductDescriptionService{
|
||
db: db.Get(),
|
||
ctx: ctx,
|
||
client: client,
|
||
googleCli: *googleCli,
|
||
projectID: cfg.GoogleTranslate.ProjectID,
|
||
}
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
// TranslateProductDescription fetches the product description for productFromLangID,
|
||
// translates every text field into productToLangID using the Google Cloud
|
||
// Translation API (v3 TranslateText), and returns the translated record.
|
||
//
|
||
// The Google Cloud project must have the Cloud Translation API enabled and the
|
||
// service account must hold the "Cloud Translation API User" role.
|
||
func (s *ProductDescriptionService) TranslateProductDescription(userID uint, productID uint, productShopID uint, productFromLangID uint, productToLangID uint, aiModel string) (*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 := ""
|
||
if aiModel == "OpenAI" {
|
||
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"
|
||
}
|
||
if aiModel == "OpenAI" {
|
||
request = cleanForPrompt(request)
|
||
}
|
||
|
||
if aiModel == "OpenAI" {
|
||
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.ErrAIResponseFail
|
||
}
|
||
response := openai_response.OutputText()
|
||
|
||
for i := 0; i < len(keys); i++ {
|
||
success, resolution := resolveResponse(*fields[i], response, keys[i])
|
||
if !success {
|
||
return nil, responseErrors.ErrAIBadOutput
|
||
}
|
||
*fields[i] = resolution
|
||
|
||
fmt.Println(resolution)
|
||
}
|
||
|
||
} else if aiModel == "Google" {
|
||
// TranslateText is the standard Cloud Translation v3 endpoint.
|
||
req := &translatepb.TranslateTextRequest{
|
||
Parent: fmt.Sprintf("projects/%s/locations/global", s.projectID),
|
||
TargetLanguageCode: lang.ISOCode,
|
||
MimeType: "text/html",
|
||
Contents: []string{request},
|
||
}
|
||
responseGoogle, err := s.googleCli.TranslateText(s.ctx, req)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// TranslateText returns one Translation per input string.
|
||
if len(responseGoogle.GetTranslations()) == 0 {
|
||
return nil, responseErrors.ErrAIBadOutput
|
||
}
|
||
response := responseGoogle.GetTranslations()[0].GetTranslatedText()
|
||
|
||
for i := 0; i < len(keys); i++ {
|
||
success, match := GetStringInBetween(response, "<"+keys[i]+">", "</"+keys[i]+">")
|
||
if !success || !isValidXHTML(match) {
|
||
return nil, responseErrors.ErrAIBadOutput
|
||
}
|
||
*fields[i] = match
|
||
|
||
fmt.Println(match)
|
||
}
|
||
}
|
||
|
||
return &ProductDescription, nil
|
||
}
|
||
|
||
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]
|
||
}
|
||
|
||
// 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
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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
|
||
}
|
||
}
|