feat(back-end): new translation api with openai
This commit is contained in:
@@ -191,6 +191,7 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
DEPLOY_OWNER=$(echo '${{ gitea.repository_owner }}' | tr '[:upper:]' '[:lower:]')
|
DEPLOY_OWNER=$(echo '${{ gitea.repository_owner }}' | tr '[:upper:]' '[:lower:]')
|
||||||
|
|
||||||
|
cat "deploy/envs/common.env" > /tmp/common_env.env
|
||||||
cat "deploy/envs/${{ env.ENV }}.env" > /tmp/full_env.env
|
cat "deploy/envs/${{ env.ENV }}.env" > /tmp/full_env.env
|
||||||
|
|
||||||
if [[ "${{ env.ENV }}" == "prod" ]]; then
|
if [[ "${{ env.ENV }}" == "prod" ]]; then
|
||||||
@@ -217,9 +218,15 @@ jobs:
|
|||||||
ADMIN_TTL="${ADMIN_TTL:-480}"
|
ADMIN_TTL="${ADMIN_TTL:-480}"
|
||||||
printf 'ADMIN_PASSWORD="%s"\nADMIN_SESSION_SECRET="%s"\nADMIN_SESSION_TTL_MINUTES="%s"\n' \
|
printf 'ADMIN_PASSWORD="%s"\nADMIN_SESSION_SECRET="%s"\nADMIN_SESSION_TTL_MINUTES="%s"\n' \
|
||||||
"${{ secrets.ADMIN_PASSWORD }}" "${{ secrets.ADMIN_SESSION_SECRET }}" "$ADMIN_TTL" >> /tmp/full_env.env
|
"${{ secrets.ADMIN_PASSWORD }}" "${{ secrets.ADMIN_SESSION_SECRET }}" "$ADMIN_TTL" >> /tmp/full_env.env
|
||||||
|
printf 'OPENAI_API_KEY="%s"\n' "${{ secrets.OPENAI_API_KEY }}" >> /tmp/common_env.env
|
||||||
|
|
||||||
|
echo "Preparing to send common env file with variables:"
|
||||||
|
grep -Ev "PASSWORD|SECRET|KEY|TOKEN" /tmp/common_env.env || true
|
||||||
echo "Preparing to send env file with variables:"
|
echo "Preparing to send env file with variables:"
|
||||||
grep -Ev "PASSWORD|SECRET" /tmp/full_env.env || true
|
grep -Ev "PASSWORD|SECRET|KEY|TOKEN" /tmp/full_env.env || true
|
||||||
|
|
||||||
|
ssh -i ~/.ssh/id_ed25519 -o BatchMode=yes "${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }}" \
|
||||||
|
"setcommon" < /tmp/common_env.env
|
||||||
|
|
||||||
ssh -i ~/.ssh/id_ed25519 -o BatchMode=yes "${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }}" \
|
ssh -i ~/.ssh/id_ed25519 -o BatchMode=yes "${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }}" \
|
||||||
"setenv ${{ env.ENV }}" < /tmp/full_env.env
|
"setenv ${{ env.ENV }}" < /tmp/full_env.env
|
||||||
|
|||||||
@@ -111,3 +111,6 @@ Verifica che `MEDIA_FFMPEG_PATH` punti a un `ffmpeg` con supporto JPEG, WebP e A
|
|||||||
|
|
||||||
### Database connection
|
### Database connection
|
||||||
Verifica le credenziali in `application.properties`. Se usi Docker, puoi passare `DB_URL`, `DB_USERNAME` e `DB_PASSWORD` come variabili d'ambiente.
|
Verifica le credenziali in `application.properties`. Se usi Docker, puoi passare `DB_URL`, `DB_USERNAME` e `DB_PASSWORD` come variabili d'ambiente.
|
||||||
|
|
||||||
|
### Deploy e traduzioni OpenAI
|
||||||
|
Nel deploy Gitea la chiave OpenAI deve stare nel secret `OPENAI_API_KEY`. La pipeline la scrive nel file `common.env` remoto e il container backend la riceve come variabile runtime. Le opzioni non sensibili condivise fra ambienti stanno in [deploy/envs/common.env](/Users/joe/IdeaProjects/print-calculator/deploy/envs/common.env), mentre i file `deploy/envs/*.env` restano per i valori specifici di `dev/int/prod`.
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
package com.printcalculator.controller.admin;
|
package com.printcalculator.controller.admin;
|
||||||
|
|
||||||
import com.printcalculator.dto.AdminShopProductDto;
|
import com.printcalculator.dto.AdminShopProductDto;
|
||||||
|
import com.printcalculator.dto.AdminTranslateShopProductRequest;
|
||||||
|
import com.printcalculator.dto.AdminTranslateShopProductResponse;
|
||||||
import com.printcalculator.dto.AdminUpsertShopProductRequest;
|
import com.printcalculator.dto.AdminUpsertShopProductRequest;
|
||||||
import com.printcalculator.service.admin.AdminShopProductControllerService;
|
import com.printcalculator.service.admin.AdminShopProductControllerService;
|
||||||
|
import com.printcalculator.service.admin.AdminShopProductTranslationService;
|
||||||
import org.springframework.core.io.Resource;
|
import org.springframework.core.io.Resource;
|
||||||
import org.springframework.core.io.UrlResource;
|
import org.springframework.core.io.UrlResource;
|
||||||
import org.springframework.http.HttpHeaders;
|
import org.springframework.http.HttpHeaders;
|
||||||
@@ -29,9 +32,12 @@ import java.util.UUID;
|
|||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
public class AdminShopProductController {
|
public class AdminShopProductController {
|
||||||
private final AdminShopProductControllerService adminShopProductControllerService;
|
private final AdminShopProductControllerService adminShopProductControllerService;
|
||||||
|
private final AdminShopProductTranslationService adminShopProductTranslationService;
|
||||||
|
|
||||||
public AdminShopProductController(AdminShopProductControllerService adminShopProductControllerService) {
|
public AdminShopProductController(AdminShopProductControllerService adminShopProductControllerService,
|
||||||
|
AdminShopProductTranslationService adminShopProductTranslationService) {
|
||||||
this.adminShopProductControllerService = adminShopProductControllerService;
|
this.adminShopProductControllerService = adminShopProductControllerService;
|
||||||
|
this.adminShopProductTranslationService = adminShopProductTranslationService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
@@ -50,6 +56,11 @@ public class AdminShopProductController {
|
|||||||
return ResponseEntity.ok(adminShopProductControllerService.createProduct(payload));
|
return ResponseEntity.ok(adminShopProductControllerService.createProduct(payload));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PostMapping("/translate")
|
||||||
|
public ResponseEntity<AdminTranslateShopProductResponse> translateProduct(@RequestBody AdminTranslateShopProductRequest payload) {
|
||||||
|
return ResponseEntity.ok(adminShopProductTranslationService.translateProduct(payload));
|
||||||
|
}
|
||||||
|
|
||||||
@PutMapping("/{productId}")
|
@PutMapping("/{productId}")
|
||||||
@Transactional
|
@Transactional
|
||||||
public ResponseEntity<AdminShopProductDto> updateProduct(@PathVariable UUID productId,
|
public ResponseEntity<AdminShopProductDto> updateProduct(@PathVariable UUID productId,
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package com.printcalculator.dto;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public class AdminTranslateShopProductRequest {
|
||||||
|
private UUID categoryId;
|
||||||
|
private String sourceLanguage;
|
||||||
|
private Boolean overwriteExisting;
|
||||||
|
private List<String> materialCodes;
|
||||||
|
private Map<String, String> names;
|
||||||
|
private Map<String, String> excerpts;
|
||||||
|
private Map<String, String> descriptions;
|
||||||
|
private Map<String, String> seoTitles;
|
||||||
|
private Map<String, String> seoDescriptions;
|
||||||
|
|
||||||
|
public UUID getCategoryId() {
|
||||||
|
return categoryId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCategoryId(UUID categoryId) {
|
||||||
|
this.categoryId = categoryId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getSourceLanguage() {
|
||||||
|
return sourceLanguage;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSourceLanguage(String sourceLanguage) {
|
||||||
|
this.sourceLanguage = sourceLanguage;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Boolean getOverwriteExisting() {
|
||||||
|
return overwriteExisting;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setOverwriteExisting(Boolean overwriteExisting) {
|
||||||
|
this.overwriteExisting = overwriteExisting;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<String> getMaterialCodes() {
|
||||||
|
return materialCodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setMaterialCodes(List<String> materialCodes) {
|
||||||
|
this.materialCodes = materialCodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, String> getNames() {
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setNames(Map<String, String> names) {
|
||||||
|
this.names = names;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, String> getExcerpts() {
|
||||||
|
return excerpts;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setExcerpts(Map<String, String> excerpts) {
|
||||||
|
this.excerpts = excerpts;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, String> getDescriptions() {
|
||||||
|
return descriptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDescriptions(Map<String, String> descriptions) {
|
||||||
|
this.descriptions = descriptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, String> getSeoTitles() {
|
||||||
|
return seoTitles;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSeoTitles(Map<String, String> seoTitles) {
|
||||||
|
this.seoTitles = seoTitles;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, String> getSeoDescriptions() {
|
||||||
|
return seoDescriptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSeoDescriptions(Map<String, String> seoDescriptions) {
|
||||||
|
this.seoDescriptions = seoDescriptions;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package com.printcalculator.dto;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
public class AdminTranslateShopProductResponse {
|
||||||
|
private String sourceLanguage;
|
||||||
|
private List<String> targetLanguages;
|
||||||
|
private Map<String, String> names;
|
||||||
|
private Map<String, String> excerpts;
|
||||||
|
private Map<String, String> descriptions;
|
||||||
|
private Map<String, String> seoTitles;
|
||||||
|
private Map<String, String> seoDescriptions;
|
||||||
|
|
||||||
|
public String getSourceLanguage() {
|
||||||
|
return sourceLanguage;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSourceLanguage(String sourceLanguage) {
|
||||||
|
this.sourceLanguage = sourceLanguage;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<String> getTargetLanguages() {
|
||||||
|
return targetLanguages;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTargetLanguages(List<String> targetLanguages) {
|
||||||
|
this.targetLanguages = targetLanguages;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, String> getNames() {
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setNames(Map<String, String> names) {
|
||||||
|
this.names = names;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, String> getExcerpts() {
|
||||||
|
return excerpts;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setExcerpts(Map<String, String> excerpts) {
|
||||||
|
this.excerpts = excerpts;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, String> getDescriptions() {
|
||||||
|
return descriptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDescriptions(Map<String, String> descriptions) {
|
||||||
|
this.descriptions = descriptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, String> getSeoTitles() {
|
||||||
|
return seoTitles;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSeoTitles(Map<String, String> seoTitles) {
|
||||||
|
this.seoTitles = seoTitles;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, String> getSeoDescriptions() {
|
||||||
|
return seoDescriptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSeoDescriptions(Map<String, String> seoDescriptions) {
|
||||||
|
this.seoDescriptions = seoDescriptions;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,685 @@
|
|||||||
|
package com.printcalculator.service.admin;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.fasterxml.jackson.databind.node.ArrayNode;
|
||||||
|
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||||
|
import com.printcalculator.dto.AdminTranslateShopProductRequest;
|
||||||
|
import com.printcalculator.dto.AdminTranslateShopProductResponse;
|
||||||
|
import com.printcalculator.entity.ShopCategory;
|
||||||
|
import com.printcalculator.repository.ShopCategoryRepository;
|
||||||
|
import org.jsoup.Jsoup;
|
||||||
|
import org.jsoup.nodes.Document;
|
||||||
|
import org.jsoup.safety.Safelist;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.http.HttpClient;
|
||||||
|
import java.net.http.HttpRequest;
|
||||||
|
import java.net.http.HttpResponse;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public class AdminShopProductTranslationService {
|
||||||
|
private static final List<String> SUPPORTED_LANGUAGES = List.of("it", "en", "de", "fr");
|
||||||
|
private static final Safelist PRODUCT_DESCRIPTION_SAFELIST = Safelist.none()
|
||||||
|
.addTags("p", "div", "br", "strong", "b", "em", "i", "u", "ul", "ol", "li", "a")
|
||||||
|
.addAttributes("a", "href")
|
||||||
|
.addProtocols("a", "href", "http", "https", "mailto", "tel");
|
||||||
|
private static final String DEFAULT_SHOP_CONTEXT = """
|
||||||
|
3D fab is a Swiss-based 3D printing shop and technical service.
|
||||||
|
The tone must be practical, clear, technical, and trustworthy.
|
||||||
|
Avoid hype, avoid invented claims, and avoid vague marketing filler.
|
||||||
|
Preserve all brand names, measurements, materials, SKUs, codes, and technical terminology exactly when they should not be translated.
|
||||||
|
When the source field is empty, return an empty string rather than inventing content.
|
||||||
|
For descriptions, preserve safe HTML structure when present and keep output ready for an ecommerce/admin form.
|
||||||
|
For SEO, prefer concise, natural phrases suitable for ecommerce and search snippets.
|
||||||
|
""";
|
||||||
|
|
||||||
|
private final ShopCategoryRepository shopCategoryRepository;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
private final HttpClient httpClient;
|
||||||
|
private final String apiKey;
|
||||||
|
private final String baseUrl;
|
||||||
|
private final String model;
|
||||||
|
private final Duration timeout;
|
||||||
|
private final String promptCacheKeyPrefix;
|
||||||
|
private final String additionalBusinessContext;
|
||||||
|
|
||||||
|
public AdminShopProductTranslationService(ShopCategoryRepository shopCategoryRepository,
|
||||||
|
ObjectMapper objectMapper,
|
||||||
|
@Value("${openai.translation.api-key:}") String apiKey,
|
||||||
|
@Value("${openai.translation.base-url:https://api.openai.com/v1}") String baseUrl,
|
||||||
|
@Value("${openai.translation.model:gpt-5.4}") String model,
|
||||||
|
@Value("${openai.translation.timeout-seconds:45}") long timeoutSeconds,
|
||||||
|
@Value("${openai.translation.prompt-cache-key-prefix:printcalc-shop-product-translation-v1}") String promptCacheKeyPrefix,
|
||||||
|
@Value("${openai.translation.business-context:}") String additionalBusinessContext) {
|
||||||
|
this.shopCategoryRepository = shopCategoryRepository;
|
||||||
|
this.objectMapper = objectMapper;
|
||||||
|
this.apiKey = apiKey != null ? apiKey.trim() : "";
|
||||||
|
this.baseUrl = normalizeBaseUrl(baseUrl);
|
||||||
|
this.model = model != null ? model.trim() : "";
|
||||||
|
this.timeout = Duration.ofSeconds(Math.max(timeoutSeconds, 5));
|
||||||
|
this.promptCacheKeyPrefix = promptCacheKeyPrefix != null && !promptCacheKeyPrefix.isBlank()
|
||||||
|
? promptCacheKeyPrefix.trim()
|
||||||
|
: "printcalc-shop-product-translation-v1";
|
||||||
|
this.additionalBusinessContext = additionalBusinessContext != null ? additionalBusinessContext.trim() : "";
|
||||||
|
this.httpClient = HttpClient.newBuilder()
|
||||||
|
.connectTimeout(this.timeout)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
public AdminTranslateShopProductResponse translateProduct(AdminTranslateShopProductRequest payload) {
|
||||||
|
ensureConfigured();
|
||||||
|
NormalizedTranslationRequest normalizedRequest = normalizeRequest(payload);
|
||||||
|
List<String> targetLanguages = resolveTargetLanguages(normalizedRequest);
|
||||||
|
if (targetLanguages.isEmpty()) {
|
||||||
|
return emptyResponse(normalizedRequest.sourceLanguage());
|
||||||
|
}
|
||||||
|
|
||||||
|
CategoryContext categoryContext = loadCategoryContext(normalizedRequest.categoryId());
|
||||||
|
String businessContext = buildBusinessContext(categoryContext, normalizedRequest.materialCodes());
|
||||||
|
|
||||||
|
TranslationBundle generated = callOpenAiFunction(
|
||||||
|
"generate_product_translations",
|
||||||
|
"Generate translated product copy for the requested target languages.",
|
||||||
|
buildInstructions("Generate the first-pass translations.", businessContext),
|
||||||
|
buildGenerationInput(normalizedRequest, targetLanguages, categoryContext),
|
||||||
|
buildTranslationToolSchema(targetLanguages),
|
||||||
|
"generate"
|
||||||
|
);
|
||||||
|
|
||||||
|
TranslationBundle normalizedGenerated = sanitizeBundle(generated, targetLanguages);
|
||||||
|
List<String> validationNotes = buildValidationNotes(normalizedGenerated, targetLanguages);
|
||||||
|
|
||||||
|
TranslationBundle reviewed = callOpenAiFunction(
|
||||||
|
"review_product_translations",
|
||||||
|
"Review and correct translated product copy while preserving meaning, SEO limits, and technical terminology.",
|
||||||
|
buildInstructions("Review and correct the generated translations.", businessContext),
|
||||||
|
buildReviewInput(normalizedRequest, normalizedGenerated, targetLanguages, categoryContext, validationNotes),
|
||||||
|
buildTranslationToolSchema(targetLanguages),
|
||||||
|
"review"
|
||||||
|
);
|
||||||
|
|
||||||
|
TranslationBundle finalBundle = sanitizeBundle(reviewed, targetLanguages);
|
||||||
|
ensureRequiredTranslations(finalBundle, targetLanguages);
|
||||||
|
return toResponse(normalizedRequest.sourceLanguage(), targetLanguages, finalBundle);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ensureConfigured() {
|
||||||
|
if (apiKey.isBlank() || model.isBlank()) {
|
||||||
|
throw new ResponseStatusException(
|
||||||
|
HttpStatus.SERVICE_UNAVAILABLE,
|
||||||
|
"OpenAI translation is not configured on the backend"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private NormalizedTranslationRequest normalizeRequest(AdminTranslateShopProductRequest payload) {
|
||||||
|
if (payload == null) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Translation payload is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
String sourceLanguage = normalizeLanguage(payload.getSourceLanguage());
|
||||||
|
if (!SUPPORTED_LANGUAGES.contains(sourceLanguage)) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Unsupported source language");
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, String> names = normalizeLocalizedMap(payload.getNames(), false);
|
||||||
|
Map<String, String> excerpts = normalizeLocalizedMap(payload.getExcerpts(), false);
|
||||||
|
Map<String, String> descriptions = normalizeLocalizedMap(payload.getDescriptions(), true);
|
||||||
|
Map<String, String> seoTitles = normalizeLocalizedMap(payload.getSeoTitles(), false);
|
||||||
|
Map<String, String> seoDescriptions = normalizeLocalizedMap(payload.getSeoDescriptions(), false);
|
||||||
|
|
||||||
|
if (names.get(sourceLanguage).isBlank()) {
|
||||||
|
throw new ResponseStatusException(
|
||||||
|
HttpStatus.BAD_REQUEST,
|
||||||
|
"The active source language must have a product name before translation"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Set<String> materialCodes = new LinkedHashSet<>();
|
||||||
|
if (payload.getMaterialCodes() != null) {
|
||||||
|
for (String materialCode : payload.getMaterialCodes()) {
|
||||||
|
String normalizedCode = normalizeOptional(materialCode);
|
||||||
|
if (normalizedCode != null) {
|
||||||
|
materialCodes.add(normalizedCode.toUpperCase(Locale.ROOT));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new NormalizedTranslationRequest(
|
||||||
|
payload.getCategoryId(),
|
||||||
|
sourceLanguage,
|
||||||
|
Boolean.TRUE.equals(payload.getOverwriteExisting()),
|
||||||
|
List.copyOf(materialCodes),
|
||||||
|
names,
|
||||||
|
excerpts,
|
||||||
|
descriptions,
|
||||||
|
seoTitles,
|
||||||
|
seoDescriptions
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<String> resolveTargetLanguages(NormalizedTranslationRequest request) {
|
||||||
|
List<String> targetLanguages = new ArrayList<>();
|
||||||
|
for (String language : SUPPORTED_LANGUAGES) {
|
||||||
|
if (language.equals(request.sourceLanguage())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (request.overwriteExisting() || needsTranslation(request, language)) {
|
||||||
|
targetLanguages.add(language);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return targetLanguages;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean needsTranslation(NormalizedTranslationRequest request, String language) {
|
||||||
|
return request.names().get(language).isBlank()
|
||||||
|
|| request.excerpts().get(language).isBlank()
|
||||||
|
|| normalizeRichTextOptional(request.descriptions().get(language)) == null
|
||||||
|
|| request.seoTitles().get(language).isBlank()
|
||||||
|
|| request.seoDescriptions().get(language).isBlank();
|
||||||
|
}
|
||||||
|
|
||||||
|
private CategoryContext loadCategoryContext(UUID categoryId) {
|
||||||
|
if (categoryId == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
ShopCategory category = shopCategoryRepository.findById(categoryId).orElse(null);
|
||||||
|
if (category == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return new CategoryContext(
|
||||||
|
category.getSlug(),
|
||||||
|
Map.of(
|
||||||
|
"it", safeValue(category.getNameIt()),
|
||||||
|
"en", safeValue(category.getNameEn()),
|
||||||
|
"de", safeValue(category.getNameDe()),
|
||||||
|
"fr", safeValue(category.getNameFr())
|
||||||
|
),
|
||||||
|
Map.of(
|
||||||
|
"it", safeValue(category.getDescriptionIt()),
|
||||||
|
"en", safeValue(category.getDescriptionEn()),
|
||||||
|
"de", safeValue(category.getDescriptionDe()),
|
||||||
|
"fr", safeValue(category.getDescriptionFr())
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildBusinessContext(CategoryContext categoryContext, List<String> materialCodes) {
|
||||||
|
StringBuilder context = new StringBuilder(DEFAULT_SHOP_CONTEXT);
|
||||||
|
if (!additionalBusinessContext.isBlank()) {
|
||||||
|
context.append('\n').append(additionalBusinessContext.trim());
|
||||||
|
}
|
||||||
|
if (categoryContext != null) {
|
||||||
|
context.append("\nCategory slug: ").append(categoryContext.slug());
|
||||||
|
context.append("\nCategory names: ").append(writeJson(categoryContext.names()));
|
||||||
|
if (categoryContext.descriptions().values().stream().anyMatch(value -> !value.isBlank())) {
|
||||||
|
context.append("\nCategory descriptions: ").append(writeJson(categoryContext.descriptions()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (materialCodes != null && !materialCodes.isEmpty()) {
|
||||||
|
context.append("\nMaterial codes present in the product: ").append(String.join(", ", materialCodes));
|
||||||
|
}
|
||||||
|
return context.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildInstructions(String task, String businessContext) {
|
||||||
|
return """
|
||||||
|
You are a senior ecommerce localization editor.
|
||||||
|
Task: %s
|
||||||
|
Return only the function call arguments that match the provided schema.
|
||||||
|
Always preserve meaning, HTML safety, and technical precision.
|
||||||
|
Never invent specifications or marketing claims not present in the source.
|
||||||
|
If a source field is empty, return an empty string for that field.
|
||||||
|
General context:
|
||||||
|
%s
|
||||||
|
""".formatted(task, businessContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildGenerationInput(NormalizedTranslationRequest request,
|
||||||
|
List<String> targetLanguages,
|
||||||
|
CategoryContext categoryContext) {
|
||||||
|
ObjectNode input = objectMapper.createObjectNode();
|
||||||
|
input.put("sourceLanguage", request.sourceLanguage());
|
||||||
|
input.set("targetLanguages", objectMapper.valueToTree(targetLanguages));
|
||||||
|
input.put("overwriteExisting", request.overwriteExisting());
|
||||||
|
input.set("source", localizedFieldNode(request, request.sourceLanguage()));
|
||||||
|
input.set("existingTranslations", existingTranslationsNode(request, targetLanguages));
|
||||||
|
input.set("materialCodes", objectMapper.valueToTree(request.materialCodes()));
|
||||||
|
if (categoryContext != null) {
|
||||||
|
input.put("categorySlug", categoryContext.slug());
|
||||||
|
input.set("categoryNames", objectMapper.valueToTree(categoryContext.names()));
|
||||||
|
}
|
||||||
|
return writeJson(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildReviewInput(NormalizedTranslationRequest request,
|
||||||
|
TranslationBundle generated,
|
||||||
|
List<String> targetLanguages,
|
||||||
|
CategoryContext categoryContext,
|
||||||
|
List<String> validationNotes) {
|
||||||
|
ObjectNode input = objectMapper.createObjectNode();
|
||||||
|
input.put("sourceLanguage", request.sourceLanguage());
|
||||||
|
input.set("targetLanguages", objectMapper.valueToTree(targetLanguages));
|
||||||
|
input.set("source", localizedFieldNode(request, request.sourceLanguage()));
|
||||||
|
input.set("generatedTranslations", generated.toJsonNode(objectMapper));
|
||||||
|
input.set("validationNotes", objectMapper.valueToTree(validationNotes));
|
||||||
|
input.set("materialCodes", objectMapper.valueToTree(request.materialCodes()));
|
||||||
|
if (categoryContext != null) {
|
||||||
|
input.put("categorySlug", categoryContext.slug());
|
||||||
|
input.set("categoryNames", objectMapper.valueToTree(categoryContext.names()));
|
||||||
|
}
|
||||||
|
return writeJson(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ObjectNode localizedFieldNode(NormalizedTranslationRequest request, String language) {
|
||||||
|
ObjectNode node = objectMapper.createObjectNode();
|
||||||
|
node.put("name", request.names().get(language));
|
||||||
|
node.put("excerpt", request.excerpts().get(language));
|
||||||
|
node.put("description", request.descriptions().get(language));
|
||||||
|
node.put("seoTitle", request.seoTitles().get(language));
|
||||||
|
node.put("seoDescription", request.seoDescriptions().get(language));
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ObjectNode existingTranslationsNode(NormalizedTranslationRequest request, List<String> targetLanguages) {
|
||||||
|
ObjectNode node = objectMapper.createObjectNode();
|
||||||
|
for (String language : targetLanguages) {
|
||||||
|
node.set(language, localizedFieldNode(request, language));
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ObjectNode buildTranslationToolSchema(List<String> targetLanguages) {
|
||||||
|
ObjectNode root = objectMapper.createObjectNode();
|
||||||
|
root.put("type", "object");
|
||||||
|
root.put("additionalProperties", false);
|
||||||
|
|
||||||
|
ObjectNode properties = root.putObject("properties");
|
||||||
|
ObjectNode translations = properties.putObject("translations");
|
||||||
|
translations.put("type", "object");
|
||||||
|
translations.put("additionalProperties", false);
|
||||||
|
|
||||||
|
ObjectNode translationProperties = translations.putObject("properties");
|
||||||
|
ArrayNode requiredTranslations = translations.putArray("required");
|
||||||
|
for (String language : targetLanguages) {
|
||||||
|
translationProperties.set(language, buildTranslationSchemaForLanguage(language));
|
||||||
|
requiredTranslations.add(language);
|
||||||
|
}
|
||||||
|
|
||||||
|
ArrayNode required = root.putArray("required");
|
||||||
|
required.add("translations");
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ObjectNode buildTranslationSchemaForLanguage(String language) {
|
||||||
|
ObjectNode languageSchema = objectMapper.createObjectNode();
|
||||||
|
languageSchema.put("type", "object");
|
||||||
|
languageSchema.put("additionalProperties", false);
|
||||||
|
languageSchema.put("description", "Localized product copy for language " + language);
|
||||||
|
|
||||||
|
ObjectNode properties = languageSchema.putObject("properties");
|
||||||
|
addSchemaString(properties, "name", "Translated product name. Never empty.");
|
||||||
|
addSchemaString(properties, "excerpt", "Short excerpt. Empty string if source excerpt is empty.");
|
||||||
|
addSchemaString(properties, "description", "Product description as safe HTML or empty string if source description is empty.");
|
||||||
|
addSchemaString(properties, "seoTitle", "SEO title. Empty string if source SEO title is empty.");
|
||||||
|
addSchemaString(properties, "seoDescription", "SEO description, ideally under 160 characters. Empty string if source SEO description is empty.");
|
||||||
|
|
||||||
|
ArrayNode required = languageSchema.putArray("required");
|
||||||
|
required.add("name");
|
||||||
|
required.add("excerpt");
|
||||||
|
required.add("description");
|
||||||
|
required.add("seoTitle");
|
||||||
|
required.add("seoDescription");
|
||||||
|
return languageSchema;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void addSchemaString(ObjectNode properties, String name, String description) {
|
||||||
|
ObjectNode property = properties.putObject(name);
|
||||||
|
property.put("type", "string");
|
||||||
|
property.put("description", description);
|
||||||
|
}
|
||||||
|
|
||||||
|
private TranslationBundle callOpenAiFunction(String functionName,
|
||||||
|
String functionDescription,
|
||||||
|
String instructions,
|
||||||
|
String input,
|
||||||
|
ObjectNode parametersSchema,
|
||||||
|
String cacheSuffix) {
|
||||||
|
ObjectNode requestPayload = objectMapper.createObjectNode();
|
||||||
|
requestPayload.put("model", model);
|
||||||
|
requestPayload.put("instructions", instructions);
|
||||||
|
requestPayload.put("input", input);
|
||||||
|
requestPayload.put("tool_choice", "required");
|
||||||
|
requestPayload.put("temperature", 0.2);
|
||||||
|
requestPayload.put("store", false);
|
||||||
|
requestPayload.put("prompt_cache_key", promptCacheKeyPrefix + ":" + cacheSuffix);
|
||||||
|
|
||||||
|
ArrayNode tools = requestPayload.putArray("tools");
|
||||||
|
ObjectNode tool = tools.addObject();
|
||||||
|
tool.put("type", "function");
|
||||||
|
tool.put("name", functionName);
|
||||||
|
tool.put("description", functionDescription);
|
||||||
|
tool.put("strict", true);
|
||||||
|
tool.set("parameters", parametersSchema);
|
||||||
|
|
||||||
|
JsonNode responseNode = postResponsesRequest(requestPayload);
|
||||||
|
JsonNode output = responseNode.path("output");
|
||||||
|
if (output.isArray()) {
|
||||||
|
for (JsonNode item : output) {
|
||||||
|
if ("function_call".equals(item.path("type").asText())) {
|
||||||
|
String arguments = item.path("arguments").asText("");
|
||||||
|
if (arguments.isBlank()) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
JsonNode argumentsNode = objectMapper.readTree(arguments);
|
||||||
|
JsonNode translationsNode = argumentsNode.path("translations");
|
||||||
|
if (!translationsNode.isObject()) {
|
||||||
|
throw new ResponseStatusException(
|
||||||
|
HttpStatus.BAD_GATEWAY,
|
||||||
|
"OpenAI returned a function call without translations"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return TranslationBundle.fromJson(translationsNode);
|
||||||
|
} catch (JsonProcessingException exception) {
|
||||||
|
throw new ResponseStatusException(
|
||||||
|
HttpStatus.BAD_GATEWAY,
|
||||||
|
"OpenAI returned invalid JSON arguments",
|
||||||
|
exception
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new ResponseStatusException(
|
||||||
|
HttpStatus.BAD_GATEWAY,
|
||||||
|
"OpenAI did not return the expected function call"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private JsonNode postResponsesRequest(ObjectNode requestPayload) {
|
||||||
|
HttpRequest request = HttpRequest.newBuilder()
|
||||||
|
.uri(URI.create(baseUrl + "/responses"))
|
||||||
|
.timeout(timeout)
|
||||||
|
.header("Authorization", "Bearer " + apiKey)
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.POST(HttpRequest.BodyPublishers.ofString(writeJson(requestPayload)))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
try {
|
||||||
|
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
||||||
|
JsonNode body = readJson(response.body());
|
||||||
|
if (response.statusCode() >= 400) {
|
||||||
|
String message = body.path("error").path("message").asText("").trim();
|
||||||
|
throw new ResponseStatusException(
|
||||||
|
HttpStatus.BAD_GATEWAY,
|
||||||
|
message.isBlank() ? "OpenAI translation request failed" : message
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return body;
|
||||||
|
} catch (IOException exception) {
|
||||||
|
throw new ResponseStatusException(
|
||||||
|
HttpStatus.BAD_GATEWAY,
|
||||||
|
"Unable to read the OpenAI translation response",
|
||||||
|
exception
|
||||||
|
);
|
||||||
|
} catch (InterruptedException exception) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
throw new ResponseStatusException(
|
||||||
|
HttpStatus.BAD_GATEWAY,
|
||||||
|
"The OpenAI translation request was interrupted",
|
||||||
|
exception
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<String> buildValidationNotes(TranslationBundle bundle, List<String> targetLanguages) {
|
||||||
|
List<String> notes = new ArrayList<>();
|
||||||
|
for (String language : targetLanguages) {
|
||||||
|
if (bundle.names().getOrDefault(language, "").isBlank()) {
|
||||||
|
notes.add(language + ": translated name is empty and must be fixed");
|
||||||
|
}
|
||||||
|
String seoDescription = bundle.seoDescriptions().getOrDefault(language, "");
|
||||||
|
if (seoDescription.length() > 160) {
|
||||||
|
notes.add(language + ": seoDescription exceeds 160 characters and must be shortened");
|
||||||
|
}
|
||||||
|
String description = bundle.descriptions().getOrDefault(language, "");
|
||||||
|
if (!description.isBlank() && normalizeRichTextOptional(description) == null) {
|
||||||
|
notes.add(language + ": description lost meaningful text during sanitization");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (notes.isEmpty()) {
|
||||||
|
notes.add("No structural validation issues were found. Review naturalness, terminology, SEO clarity, and consistency.");
|
||||||
|
}
|
||||||
|
return notes;
|
||||||
|
}
|
||||||
|
|
||||||
|
private TranslationBundle sanitizeBundle(TranslationBundle bundle, List<String> targetLanguages) {
|
||||||
|
Map<String, String> names = new LinkedHashMap<>();
|
||||||
|
Map<String, String> excerpts = new LinkedHashMap<>();
|
||||||
|
Map<String, String> descriptions = new LinkedHashMap<>();
|
||||||
|
Map<String, String> seoTitles = new LinkedHashMap<>();
|
||||||
|
Map<String, String> seoDescriptions = new LinkedHashMap<>();
|
||||||
|
|
||||||
|
for (String language : targetLanguages) {
|
||||||
|
names.put(language, safeValue(bundle.names().get(language)));
|
||||||
|
excerpts.put(language, safeValue(bundle.excerpts().get(language)));
|
||||||
|
descriptions.put(language, safeDescription(bundle.descriptions().get(language)));
|
||||||
|
seoTitles.put(language, safeValue(bundle.seoTitles().get(language)));
|
||||||
|
seoDescriptions.put(language, limitSeoDescription(safeValue(bundle.seoDescriptions().get(language))));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new TranslationBundle(names, excerpts, descriptions, seoTitles, seoDescriptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ensureRequiredTranslations(TranslationBundle bundle, List<String> targetLanguages) {
|
||||||
|
for (String language : targetLanguages) {
|
||||||
|
if (bundle.names().getOrDefault(language, "").isBlank()) {
|
||||||
|
throw new ResponseStatusException(
|
||||||
|
HttpStatus.BAD_GATEWAY,
|
||||||
|
"OpenAI did not return a valid translated name for " + language.toUpperCase(Locale.ROOT)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private AdminTranslateShopProductResponse toResponse(String sourceLanguage,
|
||||||
|
List<String> targetLanguages,
|
||||||
|
TranslationBundle bundle) {
|
||||||
|
AdminTranslateShopProductResponse response = new AdminTranslateShopProductResponse();
|
||||||
|
response.setSourceLanguage(sourceLanguage);
|
||||||
|
response.setTargetLanguages(targetLanguages);
|
||||||
|
response.setNames(bundle.names());
|
||||||
|
response.setExcerpts(bundle.excerpts());
|
||||||
|
response.setDescriptions(bundle.descriptions());
|
||||||
|
response.setSeoTitles(bundle.seoTitles());
|
||||||
|
response.setSeoDescriptions(bundle.seoDescriptions());
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
private AdminTranslateShopProductResponse emptyResponse(String sourceLanguage) {
|
||||||
|
AdminTranslateShopProductResponse response = new AdminTranslateShopProductResponse();
|
||||||
|
response.setSourceLanguage(sourceLanguage);
|
||||||
|
response.setTargetLanguages(List.of());
|
||||||
|
response.setNames(Map.of());
|
||||||
|
response.setExcerpts(Map.of());
|
||||||
|
response.setDescriptions(Map.of());
|
||||||
|
response.setSeoTitles(Map.of());
|
||||||
|
response.setSeoDescriptions(Map.of());
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, String> normalizeLocalizedMap(Map<String, String> rawValues, boolean richText) {
|
||||||
|
Map<String, String> normalized = new LinkedHashMap<>();
|
||||||
|
for (String language : SUPPORTED_LANGUAGES) {
|
||||||
|
String value = rawValues != null ? rawValues.get(language) : null;
|
||||||
|
if (richText) {
|
||||||
|
normalized.put(language, normalizeRichTextOptional(value) != null ? normalizeRichTextOptional(value) : "");
|
||||||
|
} else {
|
||||||
|
normalized.put(language, safeValue(value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String safeValue(String value) {
|
||||||
|
return value == null ? "" : value.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String safeDescription(String value) {
|
||||||
|
String normalized = normalizeRichTextOptional(value);
|
||||||
|
return normalized != null ? normalized : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
private String limitSeoDescription(String value) {
|
||||||
|
String normalized = safeValue(value);
|
||||||
|
if (normalized.length() <= 160) {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
int lastSpace = normalized.lastIndexOf(' ', 157);
|
||||||
|
if (lastSpace >= 120) {
|
||||||
|
return normalized.substring(0, lastSpace).trim();
|
||||||
|
}
|
||||||
|
return normalized.substring(0, 160).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String normalizeOptional(String value) {
|
||||||
|
if (value == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String normalized = value.trim();
|
||||||
|
return normalized.isBlank() ? null : normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String normalizeLanguage(String language) {
|
||||||
|
if (language == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
String normalized = language.trim().toLowerCase(Locale.ROOT);
|
||||||
|
int separatorIndex = normalized.indexOf('-');
|
||||||
|
if (separatorIndex > 0) {
|
||||||
|
normalized = normalized.substring(0, separatorIndex);
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String normalizeRichTextOptional(String value) {
|
||||||
|
String normalized = normalizeOptional(value);
|
||||||
|
if (normalized == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String sanitized = Jsoup.clean(
|
||||||
|
normalized,
|
||||||
|
"",
|
||||||
|
PRODUCT_DESCRIPTION_SAFELIST,
|
||||||
|
new Document.OutputSettings().prettyPrint(false)
|
||||||
|
).trim();
|
||||||
|
if (sanitized.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String plainText = Jsoup.parse(sanitized).text();
|
||||||
|
return plainText != null && !plainText.trim().isEmpty() ? sanitized : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String normalizeBaseUrl(String rawBaseUrl) {
|
||||||
|
String normalized = rawBaseUrl != null && !rawBaseUrl.isBlank()
|
||||||
|
? rawBaseUrl.trim()
|
||||||
|
: "https://api.openai.com/v1";
|
||||||
|
while (normalized.endsWith("/")) {
|
||||||
|
normalized = normalized.substring(0, normalized.length() - 1);
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String writeJson(Object value) {
|
||||||
|
try {
|
||||||
|
return objectMapper.writeValueAsString(value);
|
||||||
|
} catch (JsonProcessingException exception) {
|
||||||
|
throw new ResponseStatusException(
|
||||||
|
HttpStatus.INTERNAL_SERVER_ERROR,
|
||||||
|
"Unable to serialize translation payload",
|
||||||
|
exception
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private JsonNode readJson(String rawJson) throws IOException {
|
||||||
|
return objectMapper.readTree(rawJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
private record NormalizedTranslationRequest(UUID categoryId,
|
||||||
|
String sourceLanguage,
|
||||||
|
boolean overwriteExisting,
|
||||||
|
List<String> materialCodes,
|
||||||
|
Map<String, String> names,
|
||||||
|
Map<String, String> excerpts,
|
||||||
|
Map<String, String> descriptions,
|
||||||
|
Map<String, String> seoTitles,
|
||||||
|
Map<String, String> seoDescriptions) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private record CategoryContext(String slug,
|
||||||
|
Map<String, String> names,
|
||||||
|
Map<String, String> descriptions) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private record TranslationBundle(Map<String, String> names,
|
||||||
|
Map<String, String> excerpts,
|
||||||
|
Map<String, String> descriptions,
|
||||||
|
Map<String, String> seoTitles,
|
||||||
|
Map<String, String> seoDescriptions) {
|
||||||
|
static TranslationBundle fromJson(JsonNode translationsNode) {
|
||||||
|
Map<String, String> names = new LinkedHashMap<>();
|
||||||
|
Map<String, String> excerpts = new LinkedHashMap<>();
|
||||||
|
Map<String, String> descriptions = new LinkedHashMap<>();
|
||||||
|
Map<String, String> seoTitles = new LinkedHashMap<>();
|
||||||
|
Map<String, String> seoDescriptions = new LinkedHashMap<>();
|
||||||
|
|
||||||
|
translationsNode.fieldNames().forEachRemaining(language -> {
|
||||||
|
JsonNode localizedNode = translationsNode.path(language);
|
||||||
|
names.put(language, localizedNode.path("name").asText(""));
|
||||||
|
excerpts.put(language, localizedNode.path("excerpt").asText(""));
|
||||||
|
descriptions.put(language, localizedNode.path("description").asText(""));
|
||||||
|
seoTitles.put(language, localizedNode.path("seoTitle").asText(""));
|
||||||
|
seoDescriptions.put(language, localizedNode.path("seoDescription").asText(""));
|
||||||
|
});
|
||||||
|
|
||||||
|
return new TranslationBundle(names, excerpts, descriptions, seoTitles, seoDescriptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
ObjectNode toJsonNode(ObjectMapper objectMapper) {
|
||||||
|
ObjectNode root = objectMapper.createObjectNode();
|
||||||
|
ObjectNode translations = root.putObject("translations");
|
||||||
|
for (String language : names.keySet()) {
|
||||||
|
ObjectNode languageNode = translations.putObject(language);
|
||||||
|
languageNode.put("name", names.getOrDefault(language, ""));
|
||||||
|
languageNode.put("excerpt", excerpts.getOrDefault(language, ""));
|
||||||
|
languageNode.put("description", descriptions.getOrDefault(language, ""));
|
||||||
|
languageNode.put("seoTitle", seoTitles.getOrDefault(language, ""));
|
||||||
|
languageNode.put("seoDescription", seoDescriptions.getOrDefault(language, ""));
|
||||||
|
}
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -57,6 +57,12 @@ app.mail.contact-request.admin.address=${APP_MAIL_CONTACT_REQUEST_ADMIN_ADDRESS:
|
|||||||
app.mail.contact-request.customer.enabled=${APP_MAIL_CONTACT_REQUEST_CUSTOMER_ENABLED:true}
|
app.mail.contact-request.customer.enabled=${APP_MAIL_CONTACT_REQUEST_CUSTOMER_ENABLED:true}
|
||||||
app.frontend.base-url=${APP_FRONTEND_BASE_URL:http://localhost:4200}
|
app.frontend.base-url=${APP_FRONTEND_BASE_URL:http://localhost:4200}
|
||||||
app.sitemap.shop.cache-seconds=${APP_SITEMAP_SHOP_CACHE_SECONDS:3600}
|
app.sitemap.shop.cache-seconds=${APP_SITEMAP_SHOP_CACHE_SECONDS:3600}
|
||||||
|
openai.translation.api-key=${OPENAI_API_KEY:}
|
||||||
|
openai.translation.base-url=${OPENAI_BASE_URL:https://api.openai.com/v1}
|
||||||
|
openai.translation.model=${OPENAI_TRANSLATION_MODEL:gpt-5.4}
|
||||||
|
openai.translation.timeout-seconds=${OPENAI_TRANSLATION_TIMEOUT_SECONDS:45}
|
||||||
|
openai.translation.prompt-cache-key-prefix=${OPENAI_TRANSLATION_PROMPT_CACHE_KEY_PREFIX:printcalc-shop-product-translation-v1}
|
||||||
|
openai.translation.business-context=${OPENAI_TRANSLATION_BUSINESS_CONTEXT:}
|
||||||
|
|
||||||
# Admin back-office authentication
|
# Admin back-office authentication
|
||||||
admin.password=${ADMIN_PASSWORD}
|
admin.password=${ADMIN_PASSWORD}
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
package com.printcalculator.controller.admin;
|
||||||
|
|
||||||
|
import com.printcalculator.config.SecurityConfig;
|
||||||
|
import com.printcalculator.dto.AdminTranslateShopProductResponse;
|
||||||
|
import com.printcalculator.service.admin.AdminShopProductControllerService;
|
||||||
|
import com.printcalculator.service.admin.AdminShopProductTranslationService;
|
||||||
|
import com.printcalculator.security.AdminLoginThrottleService;
|
||||||
|
import com.printcalculator.security.AdminSessionAuthenticationFilter;
|
||||||
|
import com.printcalculator.security.AdminSessionService;
|
||||||
|
import jakarta.servlet.http.Cookie;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
|
||||||
|
import org.springframework.boot.test.context.TestConfiguration;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Import;
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.test.context.TestPropertySource;
|
||||||
|
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||||
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
|
import org.springframework.test.web.servlet.MvcResult;
|
||||||
|
import org.springframework.transaction.PlatformTransactionManager;
|
||||||
|
import org.springframework.transaction.TransactionDefinition;
|
||||||
|
import org.springframework.transaction.support.AbstractPlatformTransactionManager;
|
||||||
|
import org.springframework.transaction.support.DefaultTransactionStatus;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
|
||||||
|
@WebMvcTest(controllers = {AdminAuthController.class, AdminShopProductController.class})
|
||||||
|
@Import({
|
||||||
|
SecurityConfig.class,
|
||||||
|
AdminSessionAuthenticationFilter.class,
|
||||||
|
AdminSessionService.class,
|
||||||
|
AdminLoginThrottleService.class,
|
||||||
|
AdminShopProductControllerSecurityTest.TransactionTestConfig.class
|
||||||
|
})
|
||||||
|
@TestPropertySource(properties = {
|
||||||
|
"admin.password=test-admin-password",
|
||||||
|
"admin.session.secret=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||||
|
"admin.session.ttl-minutes=60"
|
||||||
|
})
|
||||||
|
class AdminShopProductControllerSecurityTest {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private MockMvc mockMvc;
|
||||||
|
|
||||||
|
@MockitoBean
|
||||||
|
private AdminShopProductControllerService adminShopProductControllerService;
|
||||||
|
|
||||||
|
@MockitoBean
|
||||||
|
private AdminShopProductTranslationService adminShopProductTranslationService;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void translateProduct_withoutAdminCookie_shouldReturn401() throws Exception {
|
||||||
|
mockMvc.perform(post("/api/admin/shop/products/translate")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"sourceLanguage\":\"it\",\"names\":{\"it\":\"Supporto cavo\"}}"))
|
||||||
|
.andExpect(status().isUnauthorized());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void translateProduct_withAdminCookie_shouldReturnTranslations() throws Exception {
|
||||||
|
AdminTranslateShopProductResponse response = new AdminTranslateShopProductResponse();
|
||||||
|
response.setSourceLanguage("it");
|
||||||
|
response.setTargetLanguages(List.of("en", "de", "fr"));
|
||||||
|
response.setNames(Map.of("en", "Desk cable clip"));
|
||||||
|
response.setExcerpts(Map.of());
|
||||||
|
response.setDescriptions(Map.of());
|
||||||
|
response.setSeoTitles(Map.of());
|
||||||
|
response.setSeoDescriptions(Map.of());
|
||||||
|
|
||||||
|
when(adminShopProductTranslationService.translateProduct(org.mockito.ArgumentMatchers.any()))
|
||||||
|
.thenReturn(response);
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/admin/shop/products/translate")
|
||||||
|
.cookie(loginAndExtractCookie())
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("""
|
||||||
|
{
|
||||||
|
"sourceLanguage":"it",
|
||||||
|
"overwriteExisting":false,
|
||||||
|
"materialCodes":["PLA"],
|
||||||
|
"names":{"it":"Supporto cavo"},
|
||||||
|
"excerpts":{"it":"Accessorio tecnico"},
|
||||||
|
"descriptions":{"it":"<p>Descrizione</p>"},
|
||||||
|
"seoTitles":{"it":"SEO IT"},
|
||||||
|
"seoDescriptions":{"it":"SEO description IT"}
|
||||||
|
}
|
||||||
|
"""))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.sourceLanguage").value("it"))
|
||||||
|
.andExpect(jsonPath("$.targetLanguages[0]").value("en"))
|
||||||
|
.andExpect(jsonPath("$.names.en").value("Desk cable clip"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Cookie loginAndExtractCookie() throws Exception {
|
||||||
|
MvcResult login = mockMvc.perform(post("/api/admin/auth/login")
|
||||||
|
.with(req -> {
|
||||||
|
req.setRemoteAddr("10.0.0.44");
|
||||||
|
return req;
|
||||||
|
})
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"password\":\"test-admin-password\"}"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andReturn();
|
||||||
|
|
||||||
|
String setCookie = login.getResponse().getHeader(HttpHeaders.SET_COOKIE);
|
||||||
|
assertNotNull(setCookie);
|
||||||
|
String[] parts = setCookie.split(";", 2);
|
||||||
|
String[] keyValue = parts[0].split("=", 2);
|
||||||
|
return new Cookie(keyValue[0], keyValue.length > 1 ? keyValue[1] : "");
|
||||||
|
}
|
||||||
|
|
||||||
|
@TestConfiguration
|
||||||
|
static class TransactionTestConfig {
|
||||||
|
@Bean
|
||||||
|
PlatformTransactionManager transactionManager() {
|
||||||
|
return new AbstractPlatformTransactionManager() {
|
||||||
|
@Override
|
||||||
|
protected Object doGetTransaction() {
|
||||||
|
return new Object();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void doBegin(Object transaction, TransactionDefinition definition) {
|
||||||
|
// No-op transaction manager for WebMvc security tests.
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void doCommit(DefaultTransactionStatus status) {
|
||||||
|
// No-op transaction manager for WebMvc security tests.
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void doRollback(DefaultTransactionStatus status) {
|
||||||
|
// No-op transaction manager for WebMvc security tests.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
package com.printcalculator.service.admin;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.printcalculator.dto.AdminTranslateShopProductRequest;
|
||||||
|
import com.printcalculator.dto.AdminTranslateShopProductResponse;
|
||||||
|
import com.printcalculator.repository.ShopCategoryRepository;
|
||||||
|
import com.sun.net.httpserver.HttpExchange;
|
||||||
|
import com.sun.net.httpserver.HttpServer;
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.OutputStream;
|
||||||
|
import java.net.InetSocketAddress;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.CopyOnWriteArrayList;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class AdminShopProductTranslationServiceTest {
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private ShopCategoryRepository shopCategoryRepository;
|
||||||
|
|
||||||
|
private HttpServer server;
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void tearDown() {
|
||||||
|
if (server != null) {
|
||||||
|
server.stop(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void translateProduct_shouldCallOpenAiTwiceAndReturnReviewedTranslations() throws Exception {
|
||||||
|
ObjectMapper objectMapper = new ObjectMapper();
|
||||||
|
List<JsonNode> capturedRequests = new CopyOnWriteArrayList<>();
|
||||||
|
AtomicInteger requestCounter = new AtomicInteger();
|
||||||
|
server = HttpServer.create(new InetSocketAddress(0), 0);
|
||||||
|
server.createContext("/v1/responses", exchange -> {
|
||||||
|
capturedRequests.add(readBody(objectMapper, exchange));
|
||||||
|
int currentRequest = requestCounter.incrementAndGet();
|
||||||
|
String functionName = currentRequest == 1
|
||||||
|
? "generate_product_translations"
|
||||||
|
: "review_product_translations";
|
||||||
|
String body = functionResponse(
|
||||||
|
objectMapper,
|
||||||
|
functionName,
|
||||||
|
Map.of(
|
||||||
|
"en", localized("Desk cable clip", "Technical desk accessory", "<p>Desk cable clip for clean cable routing.</p>", "Desk cable clip | 3D fab", "Technical 3D printed desk cable clip for clean cable routing."),
|
||||||
|
"de", localized("Schreibtisch-Kabelhalter", "Technisches Schreibtisch-Zubehor", "<p>Kabelhalter fur einen aufgeraumten Schreibtisch.</p>", "Schreibtisch-Kabelhalter | 3D fab", "Technischer 3D-gedruckter Kabelhalter fur einen aufgeraumten Schreibtisch."),
|
||||||
|
"fr", localized("Support de cable de bureau", "Accessoire technique de bureau", "<p>Support de cable pour un bureau ordonne.</p>", "Support de cable de bureau | 3D fab", "Support de cable de bureau imprime en 3D pour garder un espace ordonne.")
|
||||||
|
)
|
||||||
|
);
|
||||||
|
writeJsonResponse(exchange, body);
|
||||||
|
});
|
||||||
|
server.start();
|
||||||
|
|
||||||
|
when(shopCategoryRepository.findById(UUID.fromString("00000000-0000-0000-0000-000000000001")))
|
||||||
|
.thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
AdminShopProductTranslationService service = new AdminShopProductTranslationService(
|
||||||
|
shopCategoryRepository,
|
||||||
|
objectMapper,
|
||||||
|
"test-key",
|
||||||
|
"http://127.0.0.1:" + server.getAddress().getPort() + "/v1",
|
||||||
|
"gpt-5.4",
|
||||||
|
20,
|
||||||
|
"test-cache-key",
|
||||||
|
"Use concise ecommerce wording."
|
||||||
|
);
|
||||||
|
|
||||||
|
AdminTranslateShopProductRequest payload = new AdminTranslateShopProductRequest();
|
||||||
|
payload.setCategoryId(UUID.fromString("00000000-0000-0000-0000-000000000001"));
|
||||||
|
payload.setSourceLanguage("it");
|
||||||
|
payload.setOverwriteExisting(false);
|
||||||
|
payload.setMaterialCodes(List.of("pla", "petg"));
|
||||||
|
payload.setNames(Map.of(
|
||||||
|
"it", "Supporto cavo scrivania",
|
||||||
|
"en", "",
|
||||||
|
"de", "",
|
||||||
|
"fr", ""
|
||||||
|
));
|
||||||
|
payload.setExcerpts(Map.of(
|
||||||
|
"it", "Accessorio tecnico",
|
||||||
|
"en", "",
|
||||||
|
"de", "",
|
||||||
|
"fr", ""
|
||||||
|
));
|
||||||
|
payload.setDescriptions(Map.of(
|
||||||
|
"it", "<p>Supporto per tenere i cavi ordinati sulla scrivania.</p>",
|
||||||
|
"en", "",
|
||||||
|
"de", "",
|
||||||
|
"fr", ""
|
||||||
|
));
|
||||||
|
payload.setSeoTitles(Map.of(
|
||||||
|
"it", "Supporto cavo scrivania | 3D fab",
|
||||||
|
"en", "",
|
||||||
|
"de", "",
|
||||||
|
"fr", ""
|
||||||
|
));
|
||||||
|
payload.setSeoDescriptions(Map.of(
|
||||||
|
"it", "Supporto tecnico stampato in 3D per tenere i cavi in ordine sulla scrivania.",
|
||||||
|
"en", "",
|
||||||
|
"de", "",
|
||||||
|
"fr", ""
|
||||||
|
));
|
||||||
|
|
||||||
|
AdminTranslateShopProductResponse response = service.translateProduct(payload);
|
||||||
|
|
||||||
|
assertEquals(List.of("en", "de", "fr"), response.getTargetLanguages());
|
||||||
|
assertEquals("Desk cable clip", response.getNames().get("en"));
|
||||||
|
assertTrue(response.getDescriptions().get("en").contains("<p>"));
|
||||||
|
assertEquals(2, capturedRequests.size());
|
||||||
|
assertEquals("required", capturedRequests.get(0).path("tool_choice").asText());
|
||||||
|
assertEquals("test-cache-key:generate", capturedRequests.get(0).path("prompt_cache_key").asText());
|
||||||
|
assertEquals("test-cache-key:review", capturedRequests.get(1).path("prompt_cache_key").asText());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void translateProduct_shouldSkipOpenAiWhenNoTargetLanguageNeedsUpdates() {
|
||||||
|
ObjectMapper objectMapper = new ObjectMapper();
|
||||||
|
AdminShopProductTranslationService service = new AdminShopProductTranslationService(
|
||||||
|
shopCategoryRepository,
|
||||||
|
objectMapper,
|
||||||
|
"test-key",
|
||||||
|
"http://127.0.0.1:65535/v1",
|
||||||
|
"gpt-5.4",
|
||||||
|
20,
|
||||||
|
"test-cache-key",
|
||||||
|
""
|
||||||
|
);
|
||||||
|
|
||||||
|
AdminTranslateShopProductRequest payload = new AdminTranslateShopProductRequest();
|
||||||
|
payload.setSourceLanguage("it");
|
||||||
|
payload.setOverwriteExisting(false);
|
||||||
|
payload.setNames(Map.of(
|
||||||
|
"it", "Supporto cavo scrivania",
|
||||||
|
"en", "Desk cable clip",
|
||||||
|
"de", "Schreibtisch-Kabelhalter",
|
||||||
|
"fr", "Support de cable de bureau"
|
||||||
|
));
|
||||||
|
payload.setExcerpts(Map.of(
|
||||||
|
"it", "Accessorio tecnico",
|
||||||
|
"en", "Technical desk accessory",
|
||||||
|
"de", "Technisches Schreibtisch-Zubehor",
|
||||||
|
"fr", "Accessoire technique de bureau"
|
||||||
|
));
|
||||||
|
payload.setDescriptions(Map.of(
|
||||||
|
"it", "<p>Descrizione</p>",
|
||||||
|
"en", "<p>Description</p>",
|
||||||
|
"de", "<p>Beschreibung</p>",
|
||||||
|
"fr", "<p>Description</p>"
|
||||||
|
));
|
||||||
|
payload.setSeoTitles(Map.of(
|
||||||
|
"it", "SEO IT",
|
||||||
|
"en", "SEO EN",
|
||||||
|
"de", "SEO DE",
|
||||||
|
"fr", "SEO FR"
|
||||||
|
));
|
||||||
|
payload.setSeoDescriptions(Map.of(
|
||||||
|
"it", "SEO description IT",
|
||||||
|
"en", "SEO description EN",
|
||||||
|
"de", "SEO description DE",
|
||||||
|
"fr", "SEO description FR"
|
||||||
|
));
|
||||||
|
|
||||||
|
AdminTranslateShopProductResponse response = service.translateProduct(payload);
|
||||||
|
assertTrue(response.getTargetLanguages().isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
private JsonNode readBody(ObjectMapper objectMapper, HttpExchange exchange) throws IOException {
|
||||||
|
return objectMapper.readTree(exchange.getRequestBody());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void writeJsonResponse(HttpExchange exchange, String body) throws IOException {
|
||||||
|
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
|
||||||
|
exchange.getResponseHeaders().set("Content-Type", "application/json");
|
||||||
|
exchange.sendResponseHeaders(200, bytes.length);
|
||||||
|
try (OutputStream outputStream = exchange.getResponseBody()) {
|
||||||
|
outputStream.write(bytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String functionResponse(ObjectMapper objectMapper,
|
||||||
|
String functionName,
|
||||||
|
Map<String, Map<String, String>> translations) throws IOException {
|
||||||
|
Map<String, Object> arguments = Map.of("translations", translations);
|
||||||
|
Map<String, Object> item = Map.of(
|
||||||
|
"type", "function_call",
|
||||||
|
"name", functionName,
|
||||||
|
"arguments", objectMapper.writeValueAsString(arguments)
|
||||||
|
);
|
||||||
|
Map<String, Object> response = Map.of(
|
||||||
|
"id", "resp_test",
|
||||||
|
"output", List.of(item)
|
||||||
|
);
|
||||||
|
return objectMapper.writeValueAsString(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, String> localized(String name,
|
||||||
|
String excerpt,
|
||||||
|
String description,
|
||||||
|
String seoTitle,
|
||||||
|
String seoDescription) {
|
||||||
|
return Map.of(
|
||||||
|
"name", name,
|
||||||
|
"excerpt", excerpt,
|
||||||
|
"description", description,
|
||||||
|
"seoTitle", seoTitle,
|
||||||
|
"seoDescription", seoDescription
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,6 +29,12 @@ services:
|
|||||||
- ADMIN_PASSWORD=${ADMIN_PASSWORD}
|
- ADMIN_PASSWORD=${ADMIN_PASSWORD}
|
||||||
- ADMIN_SESSION_SECRET=${ADMIN_SESSION_SECRET}
|
- ADMIN_SESSION_SECRET=${ADMIN_SESSION_SECRET}
|
||||||
- ADMIN_SESSION_TTL_MINUTES=${ADMIN_SESSION_TTL_MINUTES:-480}
|
- ADMIN_SESSION_TTL_MINUTES=${ADMIN_SESSION_TTL_MINUTES:-480}
|
||||||
|
- OPENAI_API_KEY=${OPENAI_API_KEY:-}
|
||||||
|
- OPENAI_BASE_URL=${OPENAI_BASE_URL:-}
|
||||||
|
- OPENAI_TRANSLATION_MODEL=${OPENAI_TRANSLATION_MODEL:-}
|
||||||
|
- OPENAI_TRANSLATION_TIMEOUT_SECONDS=${OPENAI_TRANSLATION_TIMEOUT_SECONDS:-}
|
||||||
|
- OPENAI_TRANSLATION_PROMPT_CACHE_KEY_PREFIX=${OPENAI_TRANSLATION_PROMPT_CACHE_KEY_PREFIX:-}
|
||||||
|
- OPENAI_TRANSLATION_BUSINESS_CONTEXT=${OPENAI_TRANSLATION_BUSINESS_CONTEXT:-}
|
||||||
- TEMP_DIR=/app/temp
|
- TEMP_DIR=/app/temp
|
||||||
- PROFILES_DIR=/app/profiles
|
- PROFILES_DIR=/app/profiles
|
||||||
- MEDIA_STORAGE_ROOT=${MEDIA_STORAGE_ROOT:-/app/storage_media}
|
- MEDIA_STORAGE_ROOT=${MEDIA_STORAGE_ROOT:-/app/storage_media}
|
||||||
|
|||||||
@@ -669,8 +669,30 @@
|
|||||||
<h3>Contenuti localizzati</h3>
|
<h3>Contenuti localizzati</h3>
|
||||||
<p>
|
<p>
|
||||||
Nome obbligatorio in tutte le lingue. Descrizioni opzionali.
|
Nome obbligatorio in tutte le lingue. Descrizioni opzionali.
|
||||||
|
La traduzione usa la lingua editor come sorgente e compila il
|
||||||
|
form senza salvare.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="ui-button ui-button--ghost"
|
||||||
|
(click)="translateProductFromCurrentLanguage()"
|
||||||
|
[disabled]="!canTranslateProductFromCurrentLanguage()"
|
||||||
|
>
|
||||||
|
{{ translatingProduct ? "Traduco..." : "Traduci" }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toggle-row toggle-row--compact">
|
||||||
|
<label class="ui-checkbox">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
[(ngModel)]="overwriteExistingTranslations"
|
||||||
|
name="productOverwriteExistingTranslations"
|
||||||
|
/>
|
||||||
|
<span class="ui-checkbox__mark" aria-hidden="true"></span>
|
||||||
|
<span>Sovrascrivi traduzioni esistenti</span>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="ui-language-toolbar">
|
<div class="ui-language-toolbar">
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ import {
|
|||||||
AdminShopProductModel,
|
AdminShopProductModel,
|
||||||
AdminShopProductVariant,
|
AdminShopProductVariant,
|
||||||
AdminShopService,
|
AdminShopService,
|
||||||
|
AdminTranslateShopProductPayload,
|
||||||
|
AdminTranslateShopProductResponse,
|
||||||
AdminUpsertShopCategoryPayload,
|
AdminUpsertShopCategoryPayload,
|
||||||
AdminUpsertShopProductPayload,
|
AdminUpsertShopProductPayload,
|
||||||
AdminUpsertShopProductVariantPayload,
|
AdminUpsertShopProductVariantPayload,
|
||||||
@@ -174,6 +176,7 @@ export class AdminShopComponent implements OnInit, OnDestroy {
|
|||||||
loading = false;
|
loading = false;
|
||||||
detailLoading = false;
|
detailLoading = false;
|
||||||
savingProduct = false;
|
savingProduct = false;
|
||||||
|
translatingProduct = false;
|
||||||
deletingProduct = false;
|
deletingProduct = false;
|
||||||
savingCategory = false;
|
savingCategory = false;
|
||||||
deletingCategory = false;
|
deletingCategory = false;
|
||||||
@@ -188,6 +191,7 @@ export class AdminShopComponent implements OnInit, OnDestroy {
|
|||||||
productStatusFilter: ProductStatusFilter = 'ALL';
|
productStatusFilter: ProductStatusFilter = 'ALL';
|
||||||
showCategoryManager = false;
|
showCategoryManager = false;
|
||||||
activeContentLanguage: ShopLanguage = 'it';
|
activeContentLanguage: ShopLanguage = 'it';
|
||||||
|
overwriteExistingTranslations = false;
|
||||||
|
|
||||||
errorMessage: string | null = null;
|
errorMessage: string | null = null;
|
||||||
successMessage: string | null = null;
|
successMessage: string | null = null;
|
||||||
@@ -560,6 +564,52 @@ export class AdminShopComponent implements OnInit, OnDestroy {
|
|||||||
this.categoryForm.slug = this.slugify(source);
|
this.categoryForm.slug = this.slugify(source);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
translateProductFromCurrentLanguage(): void {
|
||||||
|
if (this.translatingProduct) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.syncDescriptionFromEditor(this.descriptionEditorElement, true);
|
||||||
|
|
||||||
|
const sourceLanguage = this.activeContentLanguage;
|
||||||
|
if (!this.productForm.names[sourceLanguage].trim()) {
|
||||||
|
this.errorMessage = `Il nome prodotto ${this.languageLabels[sourceLanguage]} e obbligatorio per avviare la traduzione.`;
|
||||||
|
this.successMessage = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = this.buildProductTranslationPayload(sourceLanguage);
|
||||||
|
this.translatingProduct = true;
|
||||||
|
this.errorMessage = null;
|
||||||
|
this.successMessage = null;
|
||||||
|
|
||||||
|
this.adminShopService.translateProduct(payload).subscribe({
|
||||||
|
next: (response) => {
|
||||||
|
this.translatingProduct = false;
|
||||||
|
this.applyProductTranslation(response, payload.overwriteExisting);
|
||||||
|
this.successMessage = response.targetLanguages.length
|
||||||
|
? `Traduzioni ${response.targetLanguages
|
||||||
|
.map((language) => this.languageLabels[language])
|
||||||
|
.join(' / ')} aggiornate nel form.`
|
||||||
|
: 'Nessun campo da tradurre.';
|
||||||
|
},
|
||||||
|
error: (error) => {
|
||||||
|
this.translatingProduct = false;
|
||||||
|
this.errorMessage = this.extractErrorMessage(
|
||||||
|
error,
|
||||||
|
'Traduzione prodotto non riuscita.',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
canTranslateProductFromCurrentLanguage(): boolean {
|
||||||
|
return (
|
||||||
|
!this.translatingProduct &&
|
||||||
|
!!this.productForm.names[this.activeContentLanguage].trim()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
setActiveContentLanguage(language: ShopLanguage): void {
|
setActiveContentLanguage(language: ShopLanguage): void {
|
||||||
this.syncDescriptionFromEditor(this.descriptionEditorElement, true);
|
this.syncDescriptionFromEditor(this.descriptionEditorElement, true);
|
||||||
this.activeContentLanguage = language;
|
this.activeContentLanguage = language;
|
||||||
@@ -1669,6 +1719,98 @@ export class AdminShopComponent implements OnInit, OnDestroy {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private buildProductTranslationPayload(
|
||||||
|
sourceLanguage: ShopLanguage,
|
||||||
|
): AdminTranslateShopProductPayload {
|
||||||
|
const materialCodes = Array.from(
|
||||||
|
new Set(
|
||||||
|
this.productForm.materials
|
||||||
|
.map((material) => material.materialCode.trim().toUpperCase())
|
||||||
|
.filter((materialCode) => !!materialCode),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
categoryId: this.productForm.categoryId || undefined,
|
||||||
|
sourceLanguage,
|
||||||
|
overwriteExisting: this.overwriteExistingTranslations,
|
||||||
|
materialCodes,
|
||||||
|
names: { ...this.productForm.names },
|
||||||
|
excerpts: { ...this.productForm.excerpts },
|
||||||
|
descriptions: { ...this.productForm.descriptions },
|
||||||
|
seoTitles: { ...this.productForm.seoTitles },
|
||||||
|
seoDescriptions: { ...this.productForm.seoDescriptions },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyProductTranslation(
|
||||||
|
response: AdminTranslateShopProductResponse,
|
||||||
|
overwriteExisting: boolean,
|
||||||
|
): void {
|
||||||
|
for (const language of response.targetLanguages) {
|
||||||
|
this.mergeLocalizedText(
|
||||||
|
this.productForm.names,
|
||||||
|
response.names,
|
||||||
|
language,
|
||||||
|
overwriteExisting,
|
||||||
|
);
|
||||||
|
this.mergeLocalizedText(
|
||||||
|
this.productForm.excerpts,
|
||||||
|
response.excerpts,
|
||||||
|
language,
|
||||||
|
overwriteExisting,
|
||||||
|
);
|
||||||
|
this.mergeLocalizedText(
|
||||||
|
this.productForm.descriptions,
|
||||||
|
response.descriptions,
|
||||||
|
language,
|
||||||
|
overwriteExisting,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
this.mergeLocalizedText(
|
||||||
|
this.productForm.seoTitles,
|
||||||
|
response.seoTitles,
|
||||||
|
language,
|
||||||
|
overwriteExisting,
|
||||||
|
);
|
||||||
|
this.mergeLocalizedText(
|
||||||
|
this.productForm.seoDescriptions,
|
||||||
|
response.seoDescriptions,
|
||||||
|
language,
|
||||||
|
overwriteExisting,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.renderActiveDescriptionInEditor();
|
||||||
|
}
|
||||||
|
|
||||||
|
private mergeLocalizedText(
|
||||||
|
target: Record<ShopLanguage, string>,
|
||||||
|
translated:
|
||||||
|
| Partial<Record<ShopLanguage, string>>
|
||||||
|
| Record<ShopLanguage, string>
|
||||||
|
| undefined,
|
||||||
|
language: ShopLanguage,
|
||||||
|
overwriteExisting: boolean,
|
||||||
|
richText = false,
|
||||||
|
): void {
|
||||||
|
const incoming = translated?.[language];
|
||||||
|
if (incoming === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasCurrentValue = richText
|
||||||
|
? this.hasMeaningfulRichText(target[language] ?? '')
|
||||||
|
: !!target[language]?.trim();
|
||||||
|
if (hasCurrentValue && !overwriteExisting) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
target[language] = richText
|
||||||
|
? this.normalizeDescriptionForEditor(incoming)
|
||||||
|
: incoming.trim();
|
||||||
|
}
|
||||||
|
|
||||||
private buildVariantsFromMaterials(): AdminUpsertShopProductVariantPayload[] {
|
private buildVariantsFromMaterials(): AdminUpsertShopProductVariantPayload[] {
|
||||||
const existingVariantsByKey = new Map(
|
const existingVariantsByKey = new Map(
|
||||||
(this.selectedProduct?.variants ?? []).map((variant) => [
|
(this.selectedProduct?.variants ?? []).map((variant) => [
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import {
|
||||||
|
HttpClientTestingModule,
|
||||||
|
HttpTestingController,
|
||||||
|
} from '@angular/common/http/testing';
|
||||||
|
import {
|
||||||
|
AdminShopService,
|
||||||
|
AdminTranslateShopProductPayload,
|
||||||
|
} from './admin-shop.service';
|
||||||
|
|
||||||
|
describe('AdminShopService', () => {
|
||||||
|
let service: AdminShopService;
|
||||||
|
let httpMock: HttpTestingController;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [HttpClientTestingModule],
|
||||||
|
providers: [AdminShopService],
|
||||||
|
});
|
||||||
|
|
||||||
|
service = TestBed.inject(AdminShopService);
|
||||||
|
httpMock = TestBed.inject(HttpTestingController);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
httpMock.verify();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('posts product translation requests with credentials', () => {
|
||||||
|
const payload: AdminTranslateShopProductPayload = {
|
||||||
|
categoryId: 'category-1',
|
||||||
|
sourceLanguage: 'it',
|
||||||
|
overwriteExisting: false,
|
||||||
|
materialCodes: ['PLA', 'PETG'],
|
||||||
|
names: {
|
||||||
|
it: 'Supporto cavo scrivania',
|
||||||
|
en: '',
|
||||||
|
de: '',
|
||||||
|
fr: '',
|
||||||
|
},
|
||||||
|
excerpts: {
|
||||||
|
it: 'Accessorio tecnico',
|
||||||
|
en: '',
|
||||||
|
de: '',
|
||||||
|
fr: '',
|
||||||
|
},
|
||||||
|
descriptions: {
|
||||||
|
it: '<p>Descrizione prodotto</p>',
|
||||||
|
en: '',
|
||||||
|
de: '',
|
||||||
|
fr: '',
|
||||||
|
},
|
||||||
|
seoTitles: {
|
||||||
|
it: 'Supporto cavo scrivania | 3D fab',
|
||||||
|
en: '',
|
||||||
|
de: '',
|
||||||
|
fr: '',
|
||||||
|
},
|
||||||
|
seoDescriptions: {
|
||||||
|
it: 'Supporto tecnico stampato in 3D per scrivania.',
|
||||||
|
en: '',
|
||||||
|
de: '',
|
||||||
|
fr: '',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
service.translateProduct(payload).subscribe((response) => {
|
||||||
|
expect(response.targetLanguages).toEqual(['en', 'de', 'fr']);
|
||||||
|
expect(response.names.en).toBe('Desk cable clip');
|
||||||
|
});
|
||||||
|
|
||||||
|
const request = httpMock.expectOne(
|
||||||
|
'http://localhost:8000/api/admin/shop/products/translate',
|
||||||
|
);
|
||||||
|
expect(request.request.method).toBe('POST');
|
||||||
|
expect(request.request.withCredentials).toBeTrue();
|
||||||
|
expect(request.request.body).toEqual(payload);
|
||||||
|
|
||||||
|
request.flush({
|
||||||
|
sourceLanguage: 'it',
|
||||||
|
targetLanguages: ['en', 'de', 'fr'],
|
||||||
|
names: {
|
||||||
|
en: 'Desk cable clip',
|
||||||
|
de: 'Schreibtisch-Kabelhalter',
|
||||||
|
fr: 'Support de cable de bureau',
|
||||||
|
},
|
||||||
|
excerpts: {},
|
||||||
|
descriptions: {},
|
||||||
|
seoTitles: {},
|
||||||
|
seoDescriptions: {},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -18,6 +18,8 @@ export interface AdminMediaTextTranslation {
|
|||||||
altText: string;
|
altText: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type AdminShopLanguage = 'it' | 'en' | 'de' | 'fr';
|
||||||
|
|
||||||
export interface AdminShopCategoryRef {
|
export interface AdminShopCategoryRef {
|
||||||
id: string;
|
id: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
@@ -255,6 +257,28 @@ export interface AdminUpsertShopProductPayload {
|
|||||||
variants: AdminUpsertShopProductVariantPayload[];
|
variants: AdminUpsertShopProductVariantPayload[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AdminTranslateShopProductPayload {
|
||||||
|
categoryId?: string;
|
||||||
|
sourceLanguage: AdminShopLanguage;
|
||||||
|
overwriteExisting: boolean;
|
||||||
|
materialCodes: string[];
|
||||||
|
names: Record<AdminShopLanguage, string>;
|
||||||
|
excerpts: Record<AdminShopLanguage, string>;
|
||||||
|
descriptions: Record<AdminShopLanguage, string>;
|
||||||
|
seoTitles: Record<AdminShopLanguage, string>;
|
||||||
|
seoDescriptions: Record<AdminShopLanguage, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminTranslateShopProductResponse {
|
||||||
|
sourceLanguage: AdminShopLanguage;
|
||||||
|
targetLanguages: AdminShopLanguage[];
|
||||||
|
names: Partial<Record<AdminShopLanguage, string>>;
|
||||||
|
excerpts: Partial<Record<AdminShopLanguage, string>>;
|
||||||
|
descriptions: Partial<Record<AdminShopLanguage, string>>;
|
||||||
|
seoTitles: Partial<Record<AdminShopLanguage, string>>;
|
||||||
|
seoDescriptions: Partial<Record<AdminShopLanguage, string>>;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root',
|
providedIn: 'root',
|
||||||
})
|
})
|
||||||
@@ -351,6 +375,18 @@ export class AdminShopService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
translateProduct(
|
||||||
|
payload: AdminTranslateShopProductPayload,
|
||||||
|
): Observable<AdminTranslateShopProductResponse> {
|
||||||
|
return this.http.post<AdminTranslateShopProductResponse>(
|
||||||
|
`${this.productsBaseUrl}/translate`,
|
||||||
|
payload,
|
||||||
|
{
|
||||||
|
withCredentials: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
uploadProductModel(
|
uploadProductModel(
|
||||||
productId: string,
|
productId: string,
|
||||||
file: File,
|
file: File,
|
||||||
|
|||||||
@@ -56,7 +56,7 @@
|
|||||||
width: min(100%, 340px);
|
width: min(100%, 340px);
|
||||||
padding: 1rem 1.1rem;
|
padding: 1rem 1.1rem;
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
border-left: 4px solid var(--swiss-red);
|
border-left: 4px solid var(--color-brand);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
animation: fadeUp 0.85s ease both;
|
animation: fadeUp 0.85s ease both;
|
||||||
|
|||||||
Reference in New Issue
Block a user