feat(back-end): new translation api with openai
Some checks failed
Build and Deploy / test-backend (push) Successful in 35s
Build and Deploy / test-frontend (push) Successful in 1m2s
Build and Deploy / build-and-push (push) Successful in 1m20s
Build and Deploy / deploy (push) Failing after 6s

This commit is contained in:
2026-03-14 18:33:51 +01:00
parent 6f700c923a
commit 6da8b3b6e4
15 changed files with 1548 additions and 3 deletions

View File

@@ -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

View File

@@ -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`.

View File

@@ -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,

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}
}

View File

@@ -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}

View File

@@ -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.
}
};
}
}
}

View File

@@ -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
);
}
}

View File

@@ -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}

View File

@@ -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">

View File

@@ -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) => [

View File

@@ -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: {},
});
});
});

View File

@@ -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,

View 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;