From 8bee0ac5d88f804220db245c41009ba51b65f389 Mon Sep 17 00:00:00 2001 From: hoangtuzami Date: Thu, 19 Feb 2026 18:37:20 +0700 Subject: [PATCH 1/7] config internl access for ai service --- build.gradle | 9 +++++++++ .../com/isums/aiservice/configs/InternalAuthFilter.java | 4 ++++ 2 files changed, 13 insertions(+) create mode 100644 src/main/java/com/isums/aiservice/configs/InternalAuthFilter.java diff --git a/build.gradle b/build.gradle index 7595485..84461e3 100644 --- a/build.gradle +++ b/build.gradle @@ -3,6 +3,9 @@ plugins { id 'org.springframework.boot' version '4.0.2' id 'io.spring.dependency-management' version '1.1.7' } +ext { + springCloudVersion = "2025.1.0" +} group = 'com.isums' version = '0.0.1-SNAPSHOT' @@ -24,11 +27,17 @@ dependencies { implementation 'com.github.ben-manes.caffeine:caffeine' implementation 'ai.h2o:h2o-genmodel:3.46.0.9' implementation 'org.projectlombok:lombok' + implementation 'org.springframework.cloud:spring-cloud-starter-netflix-eureka-client' testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' } +dependencyManagement { + imports { + mavenBom "org.springframework.cloud:spring-cloud-dependencies:$springCloudVersion" + } +} tasks.named('test') { useJUnitPlatform() diff --git a/src/main/java/com/isums/aiservice/configs/InternalAuthFilter.java b/src/main/java/com/isums/aiservice/configs/InternalAuthFilter.java new file mode 100644 index 0000000..7205062 --- /dev/null +++ b/src/main/java/com/isums/aiservice/configs/InternalAuthFilter.java @@ -0,0 +1,4 @@ +package com.isums.aiservice.configs; + +public class InternalAuthFilter { +} From 70890716630b536da744c930b6f6bd56e5bbf6a9 Mon Sep 17 00:00:00 2001 From: hoangtuzami Date: Sun, 5 Apr 2026 10:52:15 +0700 Subject: [PATCH 2/7] add support for multi-stream scoring models and internal auth --- build.gradle | 10 +- .../aiservice/configs/InternalAuthFilter.java | 86 ++++++++++++- .../controllers/ScoringController.java | 2 +- .../aiservice/cores/ModelKeyResolver.java | 30 ++--- .../aiservice/domains/dtos/ModelBundle.java | 6 +- .../aiservice/domains/dtos/ScoreRequest.java | 22 +++- .../aiservice/domains/dtos/ScoreResponse.java | 23 +++- .../abstracts/ModelStoreService.java | 4 +- .../services/EifScoringServiceImpl.java | 58 +++++---- .../services/ModelStoreServiceImpl.java | 117 ++++++++++++------ 10 files changed, 263 insertions(+), 95 deletions(-) diff --git a/build.gradle b/build.gradle index 84461e3..31b5794 100644 --- a/build.gradle +++ b/build.gradle @@ -3,6 +3,7 @@ plugins { id 'org.springframework.boot' version '4.0.2' id 'io.spring.dependency-management' version '1.1.7' } + ext { springCloudVersion = "2025.1.0" } @@ -19,6 +20,7 @@ java { repositories { mavenCentral() + maven { url 'https://h2o-release.s3.amazonaws.com/h2o/latest_stable/maven/repo' } } dependencies { @@ -26,13 +28,13 @@ dependencies { implementation 'software.amazon.awssdk:s3:2.41.31' implementation 'com.github.ben-manes.caffeine:caffeine' implementation 'ai.h2o:h2o-genmodel:3.46.0.9' - implementation 'org.projectlombok:lombok' implementation 'org.springframework.cloud:spring-cloud-starter-netflix-eureka-client' - testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test' - testRuntimeOnly 'org.junit.platform:junit-platform-launcher' compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' + testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' } + dependencyManagement { imports { mavenBom "org.springframework.cloud:spring-cloud-dependencies:$springCloudVersion" @@ -41,4 +43,4 @@ dependencyManagement { tasks.named('test') { useJUnitPlatform() -} +} \ No newline at end of file diff --git a/src/main/java/com/isums/aiservice/configs/InternalAuthFilter.java b/src/main/java/com/isums/aiservice/configs/InternalAuthFilter.java index 7205062..de769db 100644 --- a/src/main/java/com/isums/aiservice/configs/InternalAuthFilter.java +++ b/src/main/java/com/isums/aiservice/configs/InternalAuthFilter.java @@ -1,4 +1,88 @@ package com.isums.aiservice.configs; -public class InternalAuthFilter { +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpMethod; +import org.springframework.stereotype.Component; +import org.springframework.util.AntPathMatcher; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.Arrays; +import java.util.List; + +@Component +@RequiredArgsConstructor +public class InternalAuthFilter extends OncePerRequestFilter { + + @Value("${ai.internal.enabled:true}") + private boolean enabled; + + @Value("${ai.internal.token:}") + private String token; + + @Value("${ai.internal.publicPaths:/api/ai/scoring/health}") + private String publicPaths; + + private final AntPathMatcher matcher = new AntPathMatcher(); + + private boolean isPublicPath(String path) { + List pubs = Arrays.stream(publicPaths.split(",")) + .map(String::trim) + .filter(s -> !s.isBlank()) + .toList(); + + for (String p : pubs) { + if (matcher.match(p, path) || p.equals(path)) return true; + } + return false; + } + + @Override + protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain) + throws IOException, ServletException { + + if (!enabled) { + chain.doFilter(req, res); + return; + } + + String path = req.getRequestURI(); + + if (HttpMethod.OPTIONS.matches(req.getMethod())) { + chain.doFilter(req, res); + return; + } + + if (isPublicPath(path)) { + chain.doFilter(req, res); + return; + } + + if (!path.startsWith("/api/ai/")) { + chain.doFilter(req, res); + return; + } + + String auth = req.getHeader("Authorization"); + String expected = "Bearer " + token; + + if (token == null || token.isBlank()) { + res.setStatus(500); + res.getWriter().write("ai.internal.token is empty"); + return; + } + + if (auth == null || !auth.equals(expected)) { + res.setStatus(401); + res.getWriter().write("unauthorized"); + return; + } + + chain.doFilter(req, res); + } } diff --git a/src/main/java/com/isums/aiservice/controllers/ScoringController.java b/src/main/java/com/isums/aiservice/controllers/ScoringController.java index a49995c..f97ab3c 100644 --- a/src/main/java/com/isums/aiservice/controllers/ScoringController.java +++ b/src/main/java/com/isums/aiservice/controllers/ScoringController.java @@ -9,7 +9,7 @@ import org.springframework.web.bind.annotation.*; @RestController -@RequestMapping("/scoring") +@RequestMapping("/api/ai/scoring") @RequiredArgsConstructor public class ScoringController { diff --git a/src/main/java/com/isums/aiservice/cores/ModelKeyResolver.java b/src/main/java/com/isums/aiservice/cores/ModelKeyResolver.java index c4a6826..d8a3fd8 100644 --- a/src/main/java/com/isums/aiservice/cores/ModelKeyResolver.java +++ b/src/main/java/com/isums/aiservice/cores/ModelKeyResolver.java @@ -7,25 +7,25 @@ @RequiredArgsConstructor public class ModelKeyResolver { - public static String lastestKey(String prefix, UUID houseId, UUID areaId) { - if (areaId != null) { - return "%s/house_%s/area_%s/latest.json".formatted(prefix, houseId, areaId); - } + public static String latestKey(String prefix, String houseId, String areaId, String stream) { + return scopePath(prefix, houseId, areaId) + + "/" + stream + "/latest.json"; + } - return "%s/house_%s/latest.json".formatted(prefix, houseId); + public static String metaKey(String prefix, String houseId, String areaId, String stream, String version) { + return scopePath(prefix, houseId, areaId) + + "/" + stream + "/" + version + "/meta.json"; } - public static String metaKey(String prefix, UUID houseId, UUID areaId, String version) { - if (areaId != null) { - return "%s/house_%s/area_%s/%s/meta.json".formatted(prefix, houseId, areaId, version); - } - return "%s/house_%s/%s/meta.json".formatted(prefix, houseId, version); + public static String mojoKey(String prefix, String houseId, String areaId, String stream, String version) { + return scopePath(prefix, houseId, areaId) + + "/" + stream + "/" + version + "/model.mojo"; } - public static String mojoKey(String prefix, UUID houseId, UUID areaId, String version) { - if (areaId != null) { - return "%s/house_%s/area_%s/%s/model.mojo".formatted(prefix, houseId, areaId, version); - } - return "%s/house_%s/%s/model.mojo".formatted(prefix, houseId, version); + private static String scopePath(String prefix, String houseId, String areaId) { + String scope = (areaId == null || areaId.isBlank()) + ? "house_" + houseId + : "house_" + houseId + "/area_" + areaId; + return prefix.stripTrailing() + "/" + scope; } } diff --git a/src/main/java/com/isums/aiservice/domains/dtos/ModelBundle.java b/src/main/java/com/isums/aiservice/domains/dtos/ModelBundle.java index eccb7e1..e6cbc03 100644 --- a/src/main/java/com/isums/aiservice/domains/dtos/ModelBundle.java +++ b/src/main/java/com/isums/aiservice/domains/dtos/ModelBundle.java @@ -1,6 +1,6 @@ package com.isums.aiservice.domains.dtos; -import hex.genmodel.MojoModel; + import hex.genmodel.easy.EasyPredictModelWrapper; import java.util.List; @@ -8,7 +8,9 @@ public record ModelBundle( String modelId, String version, + String stream, // "power" | "water" double threshold, List orderedFeatures, EasyPredictModelWrapper model -) {} +) { +} diff --git a/src/main/java/com/isums/aiservice/domains/dtos/ScoreRequest.java b/src/main/java/com/isums/aiservice/domains/dtos/ScoreRequest.java index 75b3669..1fef94a 100644 --- a/src/main/java/com/isums/aiservice/domains/dtos/ScoreRequest.java +++ b/src/main/java/com/isums/aiservice/domains/dtos/ScoreRequest.java @@ -1,12 +1,24 @@ package com.isums.aiservice.domains.dtos; import java.util.Map; +import java.util.Objects; import java.util.UUID; public record ScoreRequest( String thing, - UUID houseId, - UUID areaId, - long ts, - Map features // v,i,p,kwh,hz,pf,w_lpm,d_kwh,d_w_tot,dt follow payload from iot -) {} + String houseId, + String areaId, + Long ts, + String stream, + Map features +) { + public ScoreRequest { + Objects.requireNonNull(thing, "thing required"); + Objects.requireNonNull(houseId, "houseId required"); + Objects.requireNonNull(stream, "stream required"); + if (!stream.equals("power") && !stream.equals("water")) + throw new IllegalArgumentException("stream must be power|water"); + if (features == null || features.isEmpty()) + throw new IllegalArgumentException("features required"); + } +} \ No newline at end of file diff --git a/src/main/java/com/isums/aiservice/domains/dtos/ScoreResponse.java b/src/main/java/com/isums/aiservice/domains/dtos/ScoreResponse.java index ed14dd2..2fff1e3 100644 --- a/src/main/java/com/isums/aiservice/domains/dtos/ScoreResponse.java +++ b/src/main/java/com/isums/aiservice/domains/dtos/ScoreResponse.java @@ -6,5 +6,24 @@ public record ScoreResponse( String version, double score, double threshold, - boolean alert -) {} \ No newline at end of file + boolean alert, + String skipReason, + String stream +) { + public static ScoreResponse ok(String modelId, String version, + double score, double threshold, + boolean alert, String stream) { + return new ScoreResponse(true, modelId, version, + score, threshold, alert, null, stream); + } + + public static ScoreResponse skip(String reason, String stream) { + return new ScoreResponse(false, null, null, + 0.0, 0.0, false, reason, stream); + } + + public static ScoreResponse error(String reason, String stream) { + return new ScoreResponse(false, null, null, + 0.0, 0.0, false, "error:" + reason, stream); + } +} diff --git a/src/main/java/com/isums/aiservice/infrastructures/abstracts/ModelStoreService.java b/src/main/java/com/isums/aiservice/infrastructures/abstracts/ModelStoreService.java index eef7fa2..ac72046 100644 --- a/src/main/java/com/isums/aiservice/infrastructures/abstracts/ModelStoreService.java +++ b/src/main/java/com/isums/aiservice/infrastructures/abstracts/ModelStoreService.java @@ -5,6 +5,6 @@ import java.util.UUID; public interface ModelStoreService { - public ModelBundle getModelBundle(UUID houseId, UUID areaId); - public void invalidateCache(UUID houseId, UUID areaId); + ModelBundle getModelBundle(String houseId, String areaId, String stream); + void invalidateCache(String houseId, String areaId); } diff --git a/src/main/java/com/isums/aiservice/services/EifScoringServiceImpl.java b/src/main/java/com/isums/aiservice/services/EifScoringServiceImpl.java index ee0eff2..9c35505 100644 --- a/src/main/java/com/isums/aiservice/services/EifScoringServiceImpl.java +++ b/src/main/java/com/isums/aiservice/services/EifScoringServiceImpl.java @@ -6,48 +6,62 @@ import com.isums.aiservice.infrastructures.abstracts.EifScoringService; import com.isums.aiservice.infrastructures.abstracts.ModelStoreService; import hex.genmodel.easy.RowData; -import hex.genmodel.easy.prediction.AbstractPrediction; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; -import java.util.Map; - @Service @RequiredArgsConstructor +@Slf4j public class EifScoringServiceImpl implements EifScoringService { private final ModelStoreService modelStoreService; @Override public ScoreResponse score(ScoreRequest req) { + ModelBundle bundle = modelStoreService.getModelBundle( + req.houseId(), req.areaId(), req.stream()); - ModelBundle bundle = modelStoreService.getModelBundle(req.houseId(), req.areaId()); + if (bundle == null) { + log.warn("no_model: house={} area={} stream={}", + req.houseId(), req.areaId(), req.stream()); + return ScoreResponse.skip("no_model", req.stream()); + } - RowData row = new RowData(); - Map features = req.features(); + try { + RowData row = new RowData(); + for (String feat : bundle.orderedFeatures()) { + Double value = req.features().get(feat); + if (value == null || value.isNaN() || value.isInfinite()) { + row.put(feat, "0.0"); + } else { + row.put(feat, Double.toString(value)); + } + } - for (String feats : bundle.orderedFeatures()) { - Double value = features.get(feats); - row.put(feats, value != null ? Double.toString(value) : "0"); - } + double score = predictAnomalyScore(bundle, row); + boolean alert = score > bundle.threshold(); + + log.info("score_ok: thing={} stream={} score={} threshold={} alert={}", + req.thing(), req.stream(), score, bundle.threshold(), alert); + + return ScoreResponse.ok( + bundle.modelId(), bundle.version(), + score, bundle.threshold(), alert, req.stream() + ); - double score = predictAnomalyScore(bundle, row); - boolean alert = score > bundle.threshold(); - return new ScoreResponse(true, bundle.modelId(), bundle.version(), score, bundle.threshold(), alert); + } catch (Exception e) { + log.error("score_error: thing={} stream={} err={}", + req.thing(), req.stream(), e.getMessage()); + return ScoreResponse.error(e.getMessage(), req.stream()); + } } private static double predictAnomalyScore(ModelBundle bundle, RowData row) { try { + var prediction = bundle.model().predictAnomalyDetection(row); + return prediction.normalizedScore; - double offset = 0.0; - - double[] raw = bundle.model().predictRaw(row, offset); - - if (raw.length > 0) { - return raw[0]; - } - - return offset; } catch (Exception e) { throw new RuntimeException("MOJO predict failed: " + e.getMessage(), e); } diff --git a/src/main/java/com/isums/aiservice/services/ModelStoreServiceImpl.java b/src/main/java/com/isums/aiservice/services/ModelStoreServiceImpl.java index 6a381e8..43b74e3 100644 --- a/src/main/java/com/isums/aiservice/services/ModelStoreServiceImpl.java +++ b/src/main/java/com/isums/aiservice/services/ModelStoreServiceImpl.java @@ -10,22 +10,23 @@ import hex.genmodel.easy.EasyPredictModelWrapper; import jakarta.annotation.PostConstruct; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import tools.jackson.databind.JsonNode; import tools.jackson.databind.ObjectMapper; -import java.io.ByteArrayInputStream; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; import java.util.List; -import java.util.UUID; +import java.util.Optional; import java.util.concurrent.TimeUnit; @Service @RequiredArgsConstructor +@Slf4j public class ModelStoreServiceImpl implements ModelStoreService { private final S3StoreClient s3StoreClient; @@ -35,87 +36,121 @@ public class ModelStoreServiceImpl implements ModelStoreService { private String bucket; @Value("${ai.s3.prefix}") private String prefix; - @Value("${ai.scoring.defaultThreshold}") + @Value("${ai.scoring.defaultThreshold:0.7}") private double defaultThreshold; - @Value("${ai.cache.ttlSeconds}") + @Value("${ai.cache.ttlSeconds:300}") private int ttlSeconds; - private Cache cache; + private Cache> cache; @PostConstruct void initCache() { this.cache = Caffeine.newBuilder() .expireAfterWrite(ttlSeconds, TimeUnit.SECONDS) - .maximumSize(5000) + .maximumSize(500) .build(); } @Override - public ModelBundle getModelBundle(UUID houseId, UUID areaId) { - String ck = cacheKey(houseId, areaId); - return cache.get(ck, _k -> loadFromS3(houseId, areaId)); - } + public ModelBundle getModelBundle(String houseId, String areaId, String stream) { + if (areaId != null && !areaId.isBlank()) { + String ck = cacheKey(houseId, areaId, stream); + Optional cached = cache.get(ck, _k -> tryLoad(houseId, areaId, stream)); + if (cached != null && cached.isPresent()) { + return cached.get(); + } + log.info("model_fallback: area={} → house={} stream={}", areaId, houseId, stream); + } + + String hk = cacheKey(houseId, null, stream); + Optional houseModel = cache.get(hk, + _k -> tryLoad(houseId, null, stream)); - private static String cacheKey(UUID houseId, UUID areaId) { - return areaId == null - ? "house:" + houseId - : "house:" + houseId + "|area:" + areaId; + if (houseModel != null && houseModel.isPresent()) { + return houseModel.get(); + } + + return null; } - private ModelBundle loadFromS3(UUID houseId, UUID areaId) { + private Optional tryLoad(String houseId, String areaId, String stream) { + Path tmp = null; try { + String latestKey = ModelKeyResolver.latestKey(prefix, houseId, areaId, stream); + byte[] latestBytes = s3StoreClient.getBytes(bucket, latestKey); + if (latestBytes == null || latestBytes.length == 0) { + log.warn("model_not_found: house={} area={} stream={}", houseId, areaId, stream); + return Optional.empty(); + } - - String lastestKey = ModelKeyResolver.lastestKey(prefix, houseId, areaId); - JsonNode lastest = om.readTree(new String(s3StoreClient.getBytes(bucket, lastestKey), StandardCharsets.UTF_8)); - String version = lastest.get("version").asString(null); + JsonNode latest = om.readTree(new String(latestBytes, StandardCharsets.UTF_8)); + String version = latest.path("version").asString(null); if (version == null || version.isBlank()) { - throw new RuntimeException("lastest.json missing version"); + log.warn("latest_missing_version: house={} stream={}", houseId, stream); + return Optional.empty(); } - String metaKey = ModelKeyResolver.metaKey(prefix, houseId, areaId, version); - JsonNode meta = om.readTree(new String(s3StoreClient.getBytes(bucket, metaKey), StandardCharsets.UTF_8)); - - String modelId = meta.path("modelId").asString("house_" + houseId + "_" + version); + String metaKey = ModelKeyResolver.metaKey(prefix, houseId, areaId, stream, version); + JsonNode meta = om.readTree( + new String(s3StoreClient.getBytes(bucket, metaKey), StandardCharsets.UTF_8)); + String modelId = meta.path("modelId").asString("house_" + houseId + "_" + stream + "_" + version); double threshold = meta.path("threshold").path("scoreThreshold").asDouble(defaultThreshold); List orderedFeatures = new ArrayList<>(); JsonNode arr = meta.path("features").path("ordered"); - if (arr.isArray()) { - for (JsonNode node : arr) { - orderedFeatures.add(node.asString()); - } + for (JsonNode node : arr) orderedFeatures.add(node.asString()); } - if (orderedFeatures.isEmpty()) { - throw new RuntimeException("meta.json missing features.ordered"); + log.warn("meta_missing_features: house={} stream={}", houseId, stream); + return Optional.empty(); } - String mojoKey = ModelKeyResolver.mojoKey(prefix, houseId, areaId, version); + String mojoKey = ModelKeyResolver.mojoKey(prefix, houseId, areaId, stream, version); byte[] mojoBytes = s3StoreClient.getBytes(bucket, mojoKey); - Path tmp = Files.createTempFile("eif_", ".mojo"); + tmp = Files.createTempFile("eif_", ".mojo"); Files.write(tmp, mojoBytes); - tmp.toFile().deleteOnExit(); MojoModel mojo = MojoModel.load(tmp.toString()); - EasyPredictModelWrapper wrapper = new EasyPredictModelWrapper(new EasyPredictModelWrapper.Config() - .setModel(mojo) - .setConvertUnknownCategoricalLevelsToNa(true) - .setConvertInvalidNumbersToNa(true) + EasyPredictModelWrapper wrapper = new EasyPredictModelWrapper( + new EasyPredictModelWrapper.Config() + .setModel(mojo) + .setConvertUnknownCategoricalLevelsToNa(true) + .setConvertInvalidNumbersToNa(true) ); - return new ModelBundle(modelId, version, threshold, orderedFeatures, wrapper); + log.info("model_loaded: house={} area={} stream={} version={} threshold={}", + houseId, areaId, stream, version, threshold); + + return Optional.of(new ModelBundle(modelId, version, stream, + threshold, orderedFeatures, wrapper)); + } catch (Exception e) { - throw new RuntimeException("Failed to load model for house=%s area=%s: %s".formatted(houseId, areaId, e.getMessage()), e); + log.error("model_load_error: house={} area={} stream={} err={}", + houseId, areaId, stream, e.getMessage()); + return Optional.empty(); + + } finally { + if (tmp != null) { + try { Files.deleteIfExists(tmp); } + catch (Exception ignored) {} + } } } @Override - public void invalidateCache(UUID houseId, UUID areaId) { - cache.invalidate(cacheKey(houseId, areaId)); + public void invalidateCache(String houseId, String areaId) { + for (String stream : List.of("power", "water")) { + cache.invalidate(cacheKey(houseId, areaId, stream)); + } + log.info("cache_invalidated: house={} area={}", houseId, areaId); } + private static String cacheKey(String houseId, String areaId, String stream) { + return areaId == null || areaId.isBlank() + ? "house:%s|stream:%s".formatted(houseId, stream) + : "house:%s|area:%s|stream:%s".formatted(houseId, areaId, stream); + } } From f3f57bf1a8f43680406b50fe09d5146773bce1a2 Mon Sep 17 00:00:00 2001 From: hoangtuzami Date: Sun, 5 Apr 2026 21:03:02 +0700 Subject: [PATCH 3/7] add ModelStoreController to handle cache invalidation requests --- .../controllers/ModelStoreController.java | 24 +++++++++++++++++++ .../abstracts/ModelStoreService.java | 2 -- 2 files changed, 24 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/isums/aiservice/controllers/ModelStoreController.java diff --git a/src/main/java/com/isums/aiservice/controllers/ModelStoreController.java b/src/main/java/com/isums/aiservice/controllers/ModelStoreController.java new file mode 100644 index 0000000..e6cd9c5 --- /dev/null +++ b/src/main/java/com/isums/aiservice/controllers/ModelStoreController.java @@ -0,0 +1,24 @@ +package com.isums.aiservice.controllers; + +import com.isums.aiservice.domains.dtos.ApiResponse; +import com.isums.aiservice.domains.dtos.ApiResponses; +import com.isums.aiservice.infrastructures.abstracts.ModelStoreService; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/ai/model-store") +@RequiredArgsConstructor +public class ModelStoreController { + + private final ModelStoreService modelStoreService; + + @PostMapping("/invalidate") + public ApiResponse invalidate(@RequestParam String houseId, @RequestParam(required = false) String areaId) { + modelStoreService.invalidateCache(houseId, areaId); + return ApiResponses.ok(null, "Cache invalidated"); + } +} \ No newline at end of file diff --git a/src/main/java/com/isums/aiservice/infrastructures/abstracts/ModelStoreService.java b/src/main/java/com/isums/aiservice/infrastructures/abstracts/ModelStoreService.java index ac72046..7536ded 100644 --- a/src/main/java/com/isums/aiservice/infrastructures/abstracts/ModelStoreService.java +++ b/src/main/java/com/isums/aiservice/infrastructures/abstracts/ModelStoreService.java @@ -2,8 +2,6 @@ import com.isums.aiservice.domains.dtos.ModelBundle; -import java.util.UUID; - public interface ModelStoreService { ModelBundle getModelBundle(String houseId, String areaId, String stream); void invalidateCache(String houseId, String areaId); From 427dceadb12b3e04a4cfd10dcdd543d05041c2dd Mon Sep 17 00:00:00 2001 From: Hieu Bot Date: Sat, 18 Apr 2026 07:12:42 +0700 Subject: [PATCH 4/7] chore(test): auto-save completed work Session: 16a9c5b4-aa2f-41fc-97cb-74db68561f08 --- .../IssueTextTranslationListener.java | 73 +++++++++++++++++++ .../IssueTranslationPostProcessor.java | 59 +++++++++++++++ .../IssueTextTranslationListenerTest.java | 70 ++++++++++++++++++ .../IssueTranslationPostProcessorTest.java | 51 +++++++++++++ 4 files changed, 253 insertions(+) create mode 100644 src/main/java/com/isums/aiservice/infrastructures/listeners/IssueTextTranslationListener.java create mode 100644 src/main/java/com/isums/aiservice/services/IssueTranslationPostProcessor.java create mode 100644 src/test/java/com/isums/aiservice/infrastructures/listeners/IssueTextTranslationListenerTest.java create mode 100644 src/test/java/com/isums/aiservice/services/IssueTranslationPostProcessorTest.java diff --git a/src/main/java/com/isums/aiservice/infrastructures/listeners/IssueTextTranslationListener.java b/src/main/java/com/isums/aiservice/infrastructures/listeners/IssueTextTranslationListener.java new file mode 100644 index 0000000..6ba7018 --- /dev/null +++ b/src/main/java/com/isums/aiservice/infrastructures/listeners/IssueTextTranslationListener.java @@ -0,0 +1,73 @@ +package com.isums.aiservice.infrastructures.listeners; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.isums.aiservice.domains.dtos.IssueTextTranslationRequestedEvent; +import com.isums.aiservice.domains.dtos.IssueTextTranslationResultEvent; +import com.isums.aiservice.infrastructures.abstracts.TextTranslationService; +import com.isums.aiservice.infrastructures.kafka.IssueTextTranslationResultProducer; +import com.isums.aiservice.services.IssueTranslationPostProcessor; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.kafka.support.Acknowledgment; +import org.springframework.stereotype.Component; + +import java.time.Instant; + +@Component +@RequiredArgsConstructor +@Slf4j +public class IssueTextTranslationListener { + + private final ObjectMapper objectMapper; + private final TextTranslationService textTranslationService; + private final IssueTextTranslationResultProducer resultProducer; + private final IssueTranslationPostProcessor postProcessor; + + @KafkaListener(topics = "issue.text.translation.requested", groupId = "ai-translation-group") + public void onRequested(String payload, Acknowledgment acknowledgment) { + try { + IssueTextTranslationRequestedEvent event = objectMapper.readValue(payload, IssueTextTranslationRequestedEvent.class); + var translated = textTranslationService.translate(event.text(), event.sourceLanguage(), event.targetLanguage()); + String refinedText = postProcessor.refine( + event.text(), + translated.translatedText(), + translated.sourceLanguage(), + translated.targetLanguage() + ); + + resultProducer.send(IssueTextTranslationResultEvent.builder() + .requestId(event.requestId()) + .resourceType(event.resourceType()) + .resourceId(event.resourceId()) + .sourceLanguage(translated.sourceLanguage()) + .targetLanguage(translated.targetLanguage()) + .translatedText(refinedText) + .provider(translated.provider()) + .status(translated.status()) + .translatedAt(Instant.now()) + .build()); + + acknowledgment.acknowledge(); + } catch (Exception ex) { + log.error("Failed to translate payload={}", payload, ex); + try { + IssueTextTranslationRequestedEvent event = objectMapper.readValue(payload, IssueTextTranslationRequestedEvent.class); + resultProducer.send(IssueTextTranslationResultEvent.builder() + .requestId(event.requestId()) + .resourceType(event.resourceType()) + .resourceId(event.resourceId()) + .sourceLanguage(event.sourceLanguage()) + .targetLanguage(event.targetLanguage()) + .provider("aws-translate") + .status("FAILED") + .errorMessage(ex.getMessage()) + .translatedAt(Instant.now()) + .build()); + } catch (Exception parseEx) { + log.error("Failed to parse payload for failure handling payload={}", payload, parseEx); + } + acknowledgment.acknowledge(); + } + } +} diff --git a/src/main/java/com/isums/aiservice/services/IssueTranslationPostProcessor.java b/src/main/java/com/isums/aiservice/services/IssueTranslationPostProcessor.java new file mode 100644 index 0000000..ea2643a --- /dev/null +++ b/src/main/java/com/isums/aiservice/services/IssueTranslationPostProcessor.java @@ -0,0 +1,59 @@ +package com.isums.aiservice.services; + +import org.springframework.stereotype.Component; + +import java.text.Normalizer; +import java.util.Locale; + +@Component +public class IssueTranslationPostProcessor { + + public String refine(String originalText, String translatedText, String sourceLanguage, String targetLanguage) { + if (originalText == null || originalText.isBlank()) { + return translatedText; + } + + String normalizedSource = normalizeVietnamese(originalText); + String normalizedTarget = targetLanguage == null ? null : targetLanguage.toLowerCase(Locale.ROOT); + + if (normalizedSource.contains("da kiem tra") + && normalizedSource.contains("vui long xac nhan") + && normalizedSource.contains("tien hanh sua chua")) { + if ("en".equals(normalizedTarget)) { + return "I have checked it. Please confirm so we can proceed with the repair."; + } + if ("ja".equals(normalizedTarget)) { + return "確認いたしました。修理を進めるため、ご確認をお願いいたします。"; + } + } + + if (normalizedSource.contains("vui long xac nhan") + && normalizedSource.contains("tien hanh sua chua")) { + if ("en".equals(normalizedTarget)) { + return "Please confirm so we can proceed with the repair."; + } + if ("ja".equals(normalizedTarget)) { + return "修理を進めるため、ご確認をお願いいたします。"; + } + } + + if (normalizedSource.contains("vui long xac nhan")) { + if ("en".equals(normalizedTarget)) { + return "Please confirm."; + } + if ("ja".equals(normalizedTarget)) { + return "ご確認をお願いいたします。"; + } + } + + return translatedText; + } + + private String normalizeVietnamese(String value) { + String normalized = Normalizer.normalize(value, Normalizer.Form.NFD) + .replaceAll("\\p{M}", "") + .replace('đ', 'd') + .replace('Đ', 'D'); + return normalized.toLowerCase(Locale.ROOT); + } +} diff --git a/src/test/java/com/isums/aiservice/infrastructures/listeners/IssueTextTranslationListenerTest.java b/src/test/java/com/isums/aiservice/infrastructures/listeners/IssueTextTranslationListenerTest.java new file mode 100644 index 0000000..b090091 --- /dev/null +++ b/src/test/java/com/isums/aiservice/infrastructures/listeners/IssueTextTranslationListenerTest.java @@ -0,0 +1,70 @@ +package com.isums.aiservice.infrastructures.listeners; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.isums.aiservice.domains.dtos.IssueTextTranslationRequestedEvent; +import com.isums.aiservice.domains.dtos.TextTranslationResult; +import com.isums.aiservice.infrastructures.abstracts.TextTranslationService; +import com.isums.aiservice.infrastructures.kafka.IssueTextTranslationResultProducer; +import com.isums.aiservice.services.IssueTranslationPostProcessor; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.kafka.support.Acknowledgment; + +import java.time.Instant; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@DisplayName("IssueTextTranslationListener") +class IssueTextTranslationListenerTest { + + @Mock private TextTranslationService textTranslationService; + @Mock private IssueTextTranslationResultProducer resultProducer; + @Mock private Acknowledgment acknowledgment; + + @Test + @DisplayName("translates request and publishes result event") + void translatesAndPublishes() throws Exception { + ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules(); + IssueTextTranslationListener listener = new IssueTextTranslationListener( + objectMapper, + textTranslationService, + resultProducer, + new IssueTranslationPostProcessor() + ); + + UUID resourceId = UUID.randomUUID(); + UUID requestId = UUID.randomUUID(); + when(textTranslationService.translate("Tôi đã kiểm tra, vui lòng xác nhận để tiến hành sửa chữa", null, "ja")) + .thenReturn(new TextTranslationResult("vi", "ja", "確認しました。確認して修理を続行してください", "aws-translate", "DONE")); + + String payload = objectMapper.writeValueAsString(new IssueTextTranslationRequestedEvent( + requestId, + "EXECUTION", + resourceId, + "Tôi đã kiểm tra, vui lòng xác nhận để tiến hành sửa chữa", + null, + "ja", + Instant.now() + )); + + listener.onRequested(payload, acknowledgment); + + ArgumentCaptor cap = + ArgumentCaptor.forClass(com.isums.aiservice.domains.dtos.IssueTextTranslationResultEvent.class); + verify(resultProducer).send(cap.capture()); + assertThat(cap.getValue().resourceId()).isEqualTo(resourceId); + assertThat(cap.getValue().targetLanguage()).isEqualTo("ja"); + assertThat(cap.getValue().translatedText()).isEqualTo("確認いたしました。修理を進めるため、ご確認をお願いいたします。"); + assertThat(cap.getValue().status()).isEqualTo("DONE"); + verify(acknowledgment).acknowledge(); + } +} diff --git a/src/test/java/com/isums/aiservice/services/IssueTranslationPostProcessorTest.java b/src/test/java/com/isums/aiservice/services/IssueTranslationPostProcessorTest.java new file mode 100644 index 0000000..2fcd19d --- /dev/null +++ b/src/test/java/com/isums/aiservice/services/IssueTranslationPostProcessorTest.java @@ -0,0 +1,51 @@ +package com.isums.aiservice.services; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("IssueTranslationPostProcessor") +class IssueTranslationPostProcessorTest { + + private final IssueTranslationPostProcessor processor = new IssueTranslationPostProcessor(); + + @Test + @DisplayName("rewrites repair confirmation sentence for English") + void rewritesEnglish() { + String result = processor.refine( + "Tôi đã kiểm tra, vui lòng xác nhận để tiến hành sửa chữa", + "I have checked, please confirm to proceed with repair", + "vi", + "en" + ); + + assertThat(result).isEqualTo("I have checked it. Please confirm so we can proceed with the repair."); + } + + @Test + @DisplayName("rewrites repair confirmation sentence for Japanese") + void rewritesJapanese() { + String result = processor.refine( + "Tôi đã kiểm tra, vui lòng xác nhận để tiến hành sửa chữa", + "確認しました。確認して修理を続行してください", + "vi", + "ja" + ); + + assertThat(result).isEqualTo("確認いたしました。修理を進めるため、ご確認をお願いいたします。"); + } + + @Test + @DisplayName("leaves unrelated translation unchanged") + void leavesUnrelatedText() { + String result = processor.refine( + "Đèn đã thay xong", + "The light has been replaced", + "vi", + "en" + ); + + assertThat(result).isEqualTo("The light has been replaced"); + } +} From 94fca3aec905cc9a0032d587759ab5beba81a658 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tr=E1=BA=A7n=20=C4=90=E1=BB=A9c=20Hi=E1=BB=87u?= Date: Tue, 21 Apr 2026 00:25:40 +0700 Subject: [PATCH 5/7] Introduce translation pipeline enhancements: formal style, customer-facing polish via Bedrock, and terminology support. --- build.gradle | 33 ++++- .../isums/aiservice/configs/AwsConfig.java | 42 ++++-- .../configs/TranslationProperties.java | 27 ++++ .../dtos/CustomerFacingPolishResult.java | 7 + .../IssueTextTranslationRequestedEvent.java | 17 +++ .../dtos/IssueTextTranslationResultEvent.java | 21 +++ .../domains/dtos/TextTranslationResult.java | 10 ++ .../domains/dtos/TranslationPolicy.java | 11 ++ .../dtos/TranslationRequestContext.java | 10 ++ .../abstracts/TextTranslationService.java | 8 ++ .../IssueTextTranslationResultProducer.java | 17 +++ .../IssueTextTranslationListener.java | 28 +++- .../services/AwsTextTranslationService.java | 74 ++++++++++ .../CustomerFacingTranslationPolisher.java | 133 ++++++++++++++++++ .../IssueTranslationPostProcessor.java | 60 ++------ .../services/TranslationLocaleSupport.java | 16 +++ .../services/TranslationPolicyResolver.java | 54 +++++++ src/main/resources/logback-spring.xml | 23 +++ .../IssueTextTranslationListenerTest.java | 23 ++- .../AwsTextTranslationServiceTest.java | 70 +++++++++ ...CustomerFacingTranslationPolisherTest.java | 53 +++++++ .../IssueTranslationPostProcessorTest.java | 33 +++-- 22 files changed, 686 insertions(+), 84 deletions(-) create mode 100644 src/main/java/com/isums/aiservice/configs/TranslationProperties.java create mode 100644 src/main/java/com/isums/aiservice/domains/dtos/CustomerFacingPolishResult.java create mode 100644 src/main/java/com/isums/aiservice/domains/dtos/IssueTextTranslationRequestedEvent.java create mode 100644 src/main/java/com/isums/aiservice/domains/dtos/IssueTextTranslationResultEvent.java create mode 100644 src/main/java/com/isums/aiservice/domains/dtos/TextTranslationResult.java create mode 100644 src/main/java/com/isums/aiservice/domains/dtos/TranslationPolicy.java create mode 100644 src/main/java/com/isums/aiservice/domains/dtos/TranslationRequestContext.java create mode 100644 src/main/java/com/isums/aiservice/infrastructures/abstracts/TextTranslationService.java create mode 100644 src/main/java/com/isums/aiservice/infrastructures/kafka/IssueTextTranslationResultProducer.java create mode 100644 src/main/java/com/isums/aiservice/services/AwsTextTranslationService.java create mode 100644 src/main/java/com/isums/aiservice/services/CustomerFacingTranslationPolisher.java create mode 100644 src/main/java/com/isums/aiservice/services/TranslationLocaleSupport.java create mode 100644 src/main/java/com/isums/aiservice/services/TranslationPolicyResolver.java create mode 100644 src/main/resources/logback-spring.xml create mode 100644 src/test/java/com/isums/aiservice/services/AwsTextTranslationServiceTest.java create mode 100644 src/test/java/com/isums/aiservice/services/CustomerFacingTranslationPolisherTest.java diff --git a/build.gradle b/build.gradle index 31b5794..cd26139 100644 --- a/build.gradle +++ b/build.gradle @@ -3,6 +3,18 @@ plugins { id 'org.springframework.boot' version '4.0.2' id 'io.spring.dependency-management' version '1.1.7' } +configurations.configureEach { + resolutionStrategy { + force 'com.google.protobuf:protobuf-java:4.34.0' + force 'com.google.protobuf:protobuf-java-util:4.34.0' + eachDependency { details -> + if (details.requested.group == 'com.google.protobuf' && (details.requested.name == 'protobuf-java' || details.requested.name == 'protobuf-java-util')) { + details.useVersion '4.34.0' + details.because 'Keep protobuf runtime aligned with generated proto-common classes' + } + } + } +} ext { springCloudVersion = "2025.1.0" @@ -19,19 +31,38 @@ java { } repositories { + mavenLocal() mavenCentral() maven { url 'https://h2o-release.s3.amazonaws.com/h2o/latest_stable/maven/repo' } + maven { + name = "GitHubPackagesObservability" + url = uri("https://maven.pkg.github.com/Management-System-for-Rental-SEP490/ISUMS_Observability-Common") + credentials { + username = System.getenv("GITHUB_ACTOR") ?: "" + password = System.getenv("GITHUB_TOKEN") ?: "" + } + } } dependencies { + implementation 'net.logstash.logback:logstash-logback-encoder:8.1' + implementation 'io.opentelemetry:opentelemetry-exporter-otlp' + implementation 'io.micrometer:micrometer-tracing-bridge-otel' + implementation 'io.micrometer:micrometer-registry-prometheus' + implementation 'org.springframework.boot:spring-boot-starter-actuator' + implementation 'com.isums:isums-observability-common:1.0-SNAPSHOT' implementation 'org.springframework.boot:spring-boot-starter-webmvc' + implementation 'org.springframework.boot:spring-boot-starter-kafka' implementation 'software.amazon.awssdk:s3:2.41.31' + implementation 'software.amazon.awssdk:translate:2.41.31' + implementation 'software.amazon.awssdk:bedrockruntime:2.41.31' implementation 'com.github.ben-manes.caffeine:caffeine' implementation 'ai.h2o:h2o-genmodel:3.46.0.9' implementation 'org.springframework.cloud:spring-cloud-starter-netflix-eureka-client' compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test' + testImplementation 'org.springframework.boot:spring-boot-starter-kafka-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' } @@ -43,4 +74,4 @@ dependencyManagement { tasks.named('test') { useJUnitPlatform() -} \ No newline at end of file +} diff --git a/src/main/java/com/isums/aiservice/configs/AwsConfig.java b/src/main/java/com/isums/aiservice/configs/AwsConfig.java index ae4ee37..87b12aa 100644 --- a/src/main/java/com/isums/aiservice/configs/AwsConfig.java +++ b/src/main/java/com/isums/aiservice/configs/AwsConfig.java @@ -1,15 +1,31 @@ -package com.isums.aiservice.configs; - -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import software.amazon.awssdk.regions.Region; -import software.amazon.awssdk.services.s3.S3Client; - -@Configuration -public class AwsConfig { +package com.isums.aiservice.configs; + +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.bedrockruntime.BedrockRuntimeClient; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.translate.TranslateClient; + +@Configuration +@EnableConfigurationProperties(TranslationProperties.class) +public class AwsConfig { @Bean - public S3Client s3Client() { - return S3Client.builder().region(Region.AP_SOUTHEAST_1).build(); - } -} + public S3Client s3Client() { + return S3Client.builder().region(Region.AP_SOUTHEAST_1).build(); + } + + @Bean + public TranslateClient translateClient() { + return TranslateClient.builder().region(Region.AP_SOUTHEAST_1).build(); + } + + @Bean + public BedrockRuntimeClient bedrockRuntimeClient(TranslationProperties translationProperties) { + return BedrockRuntimeClient.builder() + .region(Region.of(translationProperties.getCustomerFacing().getBedrockRegion())) + .build(); + } +} diff --git a/src/main/java/com/isums/aiservice/configs/TranslationProperties.java b/src/main/java/com/isums/aiservice/configs/TranslationProperties.java new file mode 100644 index 0000000..a9f31fb --- /dev/null +++ b/src/main/java/com/isums/aiservice/configs/TranslationProperties.java @@ -0,0 +1,27 @@ +package com.isums.aiservice.configs; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.util.ArrayList; +import java.util.List; + +@Getter +@Setter +@ConfigurationProperties(prefix = "ai.translation") +public class TranslationProperties { + + private List terminologyNames = new ArrayList<>(); + private CustomerFacing customerFacing = new CustomerFacing(); + + @Getter + @Setter + public static class CustomerFacing { + private boolean bedrockEnabled = false; + private String bedrockModelId = "amazon.nova-lite-v1:0"; + private String bedrockRegion = "us-east-1"; + private Integer bedrockMaxTokens = 220; + private Float bedrockTemperature = 0.0F; + } +} diff --git a/src/main/java/com/isums/aiservice/domains/dtos/CustomerFacingPolishResult.java b/src/main/java/com/isums/aiservice/domains/dtos/CustomerFacingPolishResult.java new file mode 100644 index 0000000..20e3af5 --- /dev/null +++ b/src/main/java/com/isums/aiservice/domains/dtos/CustomerFacingPolishResult.java @@ -0,0 +1,7 @@ +package com.isums.aiservice.domains.dtos; + +public record CustomerFacingPolishResult( + String text, + boolean usedBedrock +) { +} diff --git a/src/main/java/com/isums/aiservice/domains/dtos/IssueTextTranslationRequestedEvent.java b/src/main/java/com/isums/aiservice/domains/dtos/IssueTextTranslationRequestedEvent.java new file mode 100644 index 0000000..f824d6a --- /dev/null +++ b/src/main/java/com/isums/aiservice/domains/dtos/IssueTextTranslationRequestedEvent.java @@ -0,0 +1,17 @@ +package com.isums.aiservice.domains.dtos; + +import java.time.Instant; +import java.util.UUID; + +public record IssueTextTranslationRequestedEvent( + UUID requestId, + String resourceType, + UUID resourceId, + String text, + String sourceLanguage, + String targetLanguage, + String translationIntent, + Boolean customerFacing, + Instant requestedAt +) { +} diff --git a/src/main/java/com/isums/aiservice/domains/dtos/IssueTextTranslationResultEvent.java b/src/main/java/com/isums/aiservice/domains/dtos/IssueTextTranslationResultEvent.java new file mode 100644 index 0000000..876818b --- /dev/null +++ b/src/main/java/com/isums/aiservice/domains/dtos/IssueTextTranslationResultEvent.java @@ -0,0 +1,21 @@ +package com.isums.aiservice.domains.dtos; + +import lombok.Builder; + +import java.time.Instant; +import java.util.UUID; + +@Builder +public record IssueTextTranslationResultEvent( + UUID requestId, + String resourceType, + UUID resourceId, + String sourceLanguage, + String targetLanguage, + String translatedText, + String provider, + String status, + String errorMessage, + Instant translatedAt +) { +} diff --git a/src/main/java/com/isums/aiservice/domains/dtos/TextTranslationResult.java b/src/main/java/com/isums/aiservice/domains/dtos/TextTranslationResult.java new file mode 100644 index 0000000..beb4dc7 --- /dev/null +++ b/src/main/java/com/isums/aiservice/domains/dtos/TextTranslationResult.java @@ -0,0 +1,10 @@ +package com.isums.aiservice.domains.dtos; + +public record TextTranslationResult( + String sourceLanguage, + String targetLanguage, + String translatedText, + String provider, + String status +) { +} diff --git a/src/main/java/com/isums/aiservice/domains/dtos/TranslationPolicy.java b/src/main/java/com/isums/aiservice/domains/dtos/TranslationPolicy.java new file mode 100644 index 0000000..4e01906 --- /dev/null +++ b/src/main/java/com/isums/aiservice/domains/dtos/TranslationPolicy.java @@ -0,0 +1,11 @@ +package com.isums.aiservice.domains.dtos; + +import java.util.List; + +public record TranslationPolicy( + boolean customerFacing, + boolean formalRequested, + boolean bedrockPolishEnabled, + List terminologyNames +) { +} diff --git a/src/main/java/com/isums/aiservice/domains/dtos/TranslationRequestContext.java b/src/main/java/com/isums/aiservice/domains/dtos/TranslationRequestContext.java new file mode 100644 index 0000000..4452871 --- /dev/null +++ b/src/main/java/com/isums/aiservice/domains/dtos/TranslationRequestContext.java @@ -0,0 +1,10 @@ +package com.isums.aiservice.domains.dtos; + +public record TranslationRequestContext( + String resourceType, + String translationIntent, + boolean customerFacing, + String sourceLanguage, + String targetLanguage +) { +} diff --git a/src/main/java/com/isums/aiservice/infrastructures/abstracts/TextTranslationService.java b/src/main/java/com/isums/aiservice/infrastructures/abstracts/TextTranslationService.java new file mode 100644 index 0000000..6defcdc --- /dev/null +++ b/src/main/java/com/isums/aiservice/infrastructures/abstracts/TextTranslationService.java @@ -0,0 +1,8 @@ +package com.isums.aiservice.infrastructures.abstracts; + +import com.isums.aiservice.domains.dtos.TextTranslationResult; +import com.isums.aiservice.domains.dtos.TranslationPolicy; + +public interface TextTranslationService { + TextTranslationResult translate(String text, String sourceLanguage, String targetLanguage, TranslationPolicy policy); +} diff --git a/src/main/java/com/isums/aiservice/infrastructures/kafka/IssueTextTranslationResultProducer.java b/src/main/java/com/isums/aiservice/infrastructures/kafka/IssueTextTranslationResultProducer.java new file mode 100644 index 0000000..c2c7dea --- /dev/null +++ b/src/main/java/com/isums/aiservice/infrastructures/kafka/IssueTextTranslationResultProducer.java @@ -0,0 +1,17 @@ +package com.isums.aiservice.infrastructures.kafka; + +import com.isums.aiservice.domains.dtos.IssueTextTranslationResultEvent; +import lombok.RequiredArgsConstructor; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class IssueTextTranslationResultProducer { + + private final KafkaTemplate kafkaTemplate; + + public void send(IssueTextTranslationResultEvent event) { + kafkaTemplate.send("issue.text.translation.result", event.resourceId() + ":" + event.targetLanguage(), event); + } +} diff --git a/src/main/java/com/isums/aiservice/infrastructures/listeners/IssueTextTranslationListener.java b/src/main/java/com/isums/aiservice/infrastructures/listeners/IssueTextTranslationListener.java index 6ba7018..81e8b9f 100644 --- a/src/main/java/com/isums/aiservice/infrastructures/listeners/IssueTextTranslationListener.java +++ b/src/main/java/com/isums/aiservice/infrastructures/listeners/IssueTextTranslationListener.java @@ -3,9 +3,12 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.isums.aiservice.domains.dtos.IssueTextTranslationRequestedEvent; import com.isums.aiservice.domains.dtos.IssueTextTranslationResultEvent; +import com.isums.aiservice.domains.dtos.TranslationRequestContext; import com.isums.aiservice.infrastructures.abstracts.TextTranslationService; import com.isums.aiservice.infrastructures.kafka.IssueTextTranslationResultProducer; +import com.isums.aiservice.services.CustomerFacingTranslationPolisher; import com.isums.aiservice.services.IssueTranslationPostProcessor; +import com.isums.aiservice.services.TranslationPolicyResolver; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.kafka.annotation.KafkaListener; @@ -22,19 +25,34 @@ public class IssueTextTranslationListener { private final ObjectMapper objectMapper; private final TextTranslationService textTranslationService; private final IssueTextTranslationResultProducer resultProducer; + private final TranslationPolicyResolver translationPolicyResolver; + private final CustomerFacingTranslationPolisher customerFacingTranslationPolisher; private final IssueTranslationPostProcessor postProcessor; @KafkaListener(topics = "issue.text.translation.requested", groupId = "ai-translation-group") public void onRequested(String payload, Acknowledgment acknowledgment) { try { IssueTextTranslationRequestedEvent event = objectMapper.readValue(payload, IssueTextTranslationRequestedEvent.class); - var translated = textTranslationService.translate(event.text(), event.sourceLanguage(), event.targetLanguage()); - String refinedText = postProcessor.refine( + TranslationRequestContext context = new TranslationRequestContext( + event.resourceType(), + event.translationIntent(), + Boolean.TRUE.equals(event.customerFacing()), + event.sourceLanguage(), + event.targetLanguage() + ); + var policy = translationPolicyResolver.resolve(context); + var translated = textTranslationService.translate(event.text(), event.sourceLanguage(), event.targetLanguage(), policy); + var polishResult = customerFacingTranslationPolisher.polish( event.text(), translated.translatedText(), - translated.sourceLanguage(), - translated.targetLanguage() + context, + policy ); + String refinedText = postProcessor.refine(polishResult.text(), context, policy); + String provider = translated.provider(); + if (polishResult.usedBedrock()) { + provider = provider + "+bedrock-polish"; + } resultProducer.send(IssueTextTranslationResultEvent.builder() .requestId(event.requestId()) @@ -43,7 +61,7 @@ public void onRequested(String payload, Acknowledgment acknowledgment) { .sourceLanguage(translated.sourceLanguage()) .targetLanguage(translated.targetLanguage()) .translatedText(refinedText) - .provider(translated.provider()) + .provider(provider) .status(translated.status()) .translatedAt(Instant.now()) .build()); diff --git a/src/main/java/com/isums/aiservice/services/AwsTextTranslationService.java b/src/main/java/com/isums/aiservice/services/AwsTextTranslationService.java new file mode 100644 index 0000000..66c409c --- /dev/null +++ b/src/main/java/com/isums/aiservice/services/AwsTextTranslationService.java @@ -0,0 +1,74 @@ +package com.isums.aiservice.services; + +import com.isums.aiservice.domains.dtos.TextTranslationResult; +import com.isums.aiservice.domains.dtos.TranslationPolicy; +import com.isums.aiservice.infrastructures.abstracts.TextTranslationService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import software.amazon.awssdk.services.translate.TranslateClient; +import software.amazon.awssdk.services.translate.model.Formality; +import software.amazon.awssdk.services.translate.model.TranslationSettings; +import software.amazon.awssdk.services.translate.model.TranslateTextRequest; + +@Service +@RequiredArgsConstructor +public class AwsTextTranslationService implements TextTranslationService { + + private final TranslateClient translateClient; + private final TranslationLocaleSupport localeSupport; + + @Override + public TextTranslationResult translate(String text, String sourceLanguage, String targetLanguage, TranslationPolicy policy) { + String normalizedTarget = localeSupport.normalize(targetLanguage); + String normalizedSource = localeSupport.normalize(sourceLanguage); + + if (text == null || text.isBlank()) { + return new TextTranslationResult(normalizedSource, normalizedTarget, text, "aws-translate", "SKIPPED"); + } + + if (normalizedSource != null && normalizedSource.equalsIgnoreCase(normalizedTarget)) { + return new TextTranslationResult(normalizedSource, normalizedTarget, text, "aws-translate", "SKIPPED"); + } + + TranslateTextRequest.Builder requestBuilder = TranslateTextRequest.builder() + .text(text) + .sourceLanguageCode(normalizedSource == null || normalizedSource.isBlank() ? "auto" : normalizedSource) + .targetLanguageCode(normalizedTarget); + + if (policy != null && policy.formalRequested()) { + requestBuilder.settings(TranslationSettings.builder() + .formality(Formality.FORMAL) + .build()); + } + + if (policy != null && policy.terminologyNames() != null && !policy.terminologyNames().isEmpty()) { + requestBuilder.terminologyNames(policy.terminologyNames()); + } + + var response = translateClient.translateText(requestBuilder.build()); + String detectedSource = localeSupport.normalize(response.sourceLanguageCode()); + String translatedText = response.translatedText(); + String provider = buildProviderTag(policy); + + if (detectedSource != null && detectedSource.equalsIgnoreCase(normalizedTarget)) { + translatedText = text; + return new TextTranslationResult(detectedSource, normalizedTarget, translatedText, provider, "SKIPPED"); + } + + return new TextTranslationResult(detectedSource, normalizedTarget, translatedText, provider, "DONE"); + } + + private String buildProviderTag(TranslationPolicy policy) { + String provider = "aws-translate"; + if (policy == null) { + return provider; + } + if (policy.formalRequested()) { + provider += "+formal"; + } + if (policy.terminologyNames() != null && !policy.terminologyNames().isEmpty()) { + provider += "+terms"; + } + return provider; + } +} diff --git a/src/main/java/com/isums/aiservice/services/CustomerFacingTranslationPolisher.java b/src/main/java/com/isums/aiservice/services/CustomerFacingTranslationPolisher.java new file mode 100644 index 0000000..913b2d6 --- /dev/null +++ b/src/main/java/com/isums/aiservice/services/CustomerFacingTranslationPolisher.java @@ -0,0 +1,133 @@ +package com.isums.aiservice.services; + +import com.isums.aiservice.configs.TranslationProperties; +import com.isums.aiservice.domains.dtos.CustomerFacingPolishResult; +import com.isums.aiservice.domains.dtos.TranslationPolicy; +import com.isums.aiservice.domains.dtos.TranslationRequestContext; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import software.amazon.awssdk.services.bedrockruntime.BedrockRuntimeClient; +import software.amazon.awssdk.services.bedrockruntime.model.ContentBlock; +import software.amazon.awssdk.services.bedrockruntime.model.ConversationRole; +import software.amazon.awssdk.services.bedrockruntime.model.ConverseRequest; +import software.amazon.awssdk.services.bedrockruntime.model.Message; +import software.amazon.awssdk.services.bedrockruntime.model.SystemContentBlock; + +import java.util.Locale; + +@Component +@RequiredArgsConstructor +@Slf4j +public class CustomerFacingTranslationPolisher { + + private final BedrockRuntimeClient bedrockRuntimeClient; + private final TranslationProperties translationProperties; + + public CustomerFacingPolishResult polish( + String originalText, + String candidateTranslation, + TranslationRequestContext context, + TranslationPolicy policy + ) { + if (candidateTranslation == null || candidateTranslation.isBlank()) { + return new CustomerFacingPolishResult(candidateTranslation, false); + } + if (policy == null || !policy.customerFacing() || !policy.bedrockPolishEnabled()) { + return new CustomerFacingPolishResult(candidateTranslation, false); + } + + try { + Message message = Message.builder() + .role(ConversationRole.USER) + .content(ContentBlock.fromText(buildUserPrompt(originalText, candidateTranslation, context))) + .build(); + + ConverseRequest request = ConverseRequest.builder() + .modelId(translationProperties.getCustomerFacing().getBedrockModelId()) + .system(SystemContentBlock.builder().text(buildSystemPrompt(context)).build()) + .messages(message) + .inferenceConfig(config -> config + .maxTokens(translationProperties.getCustomerFacing().getBedrockMaxTokens()) + .temperature(translationProperties.getCustomerFacing().getBedrockTemperature())) + .build(); + + String polished = bedrockRuntimeClient.converse(request) + .output() + .message() + .content() + .stream() + .map(ContentBlock::text) + .filter(text -> text != null && !text.isBlank()) + .findFirst() + .orElse(candidateTranslation) + .trim(); + + return new CustomerFacingPolishResult(polished.isBlank() ? candidateTranslation : polished, true); + } catch (Exception ex) { + log.warn("Bedrock polish fallback resourceType={} intent={} target={}: {}", + context.resourceType(), context.translationIntent(), context.targetLanguage(), ex.getMessage()); + return new CustomerFacingPolishResult(candidateTranslation, false); + } + } + + private String buildSystemPrompt(TranslationRequestContext context) { + StringBuilder prompt = new StringBuilder(""" +You are a production translation quality editor for ISUMS customer support. +Rewrite the candidate translation into natural, polite, customer-facing language. +Rules: +1. The candidate translation is the semantic baseline. Edit it for tone and fluency, not for new meaning. +2. Preserve exact business meaning, requested action, nouns, verbs, and factual scope. +3. Do not add promises, deadlines, apologies, blame, workflow terms, or new facts. +4. Do not replace nouns or actions with different ones unless the source text explicitly contains them. +5. Keep domain terms stable: tenant, landlord, manager, issue, inspection, repair, contract. +6. Make the smallest possible edits needed for a polite customer-facing result. +7. Return only the final translated text. No notes, no JSON, no quotes. +"""); + + String targetLanguage = context.targetLanguage() == null ? "" : context.targetLanguage().trim().toLowerCase(Locale.ROOT); + if ("ja".equals(targetLanguage)) { + prompt.append(""" +Japanese style guide: +- Use polite customer-service register. +- Prefer natural respectful requests over blunt imperatives. +- Keep the sentence concise and professional. + +Example: +Candidate: 確認しました。確認して修理を続行してください +Final: 確認いたしました。修理を進めるため、ご確認をお願いいたします。 +"""); + } else if ("en".equals(targetLanguage)) { + prompt.append(""" +English style guide: +- Use concise courteous customer-support tone. +- Keep the response natural, calm, and direct. + +Example: +Candidate: I have checked, please confirm to proceed with repair. +Final: I have checked it. Please confirm so we can proceed with the repair. +"""); + } + + return prompt.toString(); + } + + private String buildUserPrompt(String originalText, String candidateTranslation, TranslationRequestContext context) { + return """ +Candidate translation: +%s + +Source text: +%s + +Target language: %s +Intent: %s +Return the final translation only. +""".formatted( + candidateTranslation.trim(), + originalText == null ? "" : originalText.trim(), + context.targetLanguage() == null ? "" : context.targetLanguage().trim(), + context.translationIntent() == null ? "" : context.translationIntent().trim() + ); + } +} diff --git a/src/main/java/com/isums/aiservice/services/IssueTranslationPostProcessor.java b/src/main/java/com/isums/aiservice/services/IssueTranslationPostProcessor.java index ea2643a..d290502 100644 --- a/src/main/java/com/isums/aiservice/services/IssueTranslationPostProcessor.java +++ b/src/main/java/com/isums/aiservice/services/IssueTranslationPostProcessor.java @@ -1,59 +1,29 @@ package com.isums.aiservice.services; +import com.isums.aiservice.domains.dtos.TranslationPolicy; +import com.isums.aiservice.domains.dtos.TranslationRequestContext; import org.springframework.stereotype.Component; -import java.text.Normalizer; -import java.util.Locale; - @Component public class IssueTranslationPostProcessor { - public String refine(String originalText, String translatedText, String sourceLanguage, String targetLanguage) { - if (originalText == null || originalText.isBlank()) { + public String refine(String translatedText, TranslationRequestContext context, TranslationPolicy policy) { + if (translatedText == null || translatedText.isBlank()) { return translatedText; } - String normalizedSource = normalizeVietnamese(originalText); - String normalizedTarget = targetLanguage == null ? null : targetLanguage.toLowerCase(Locale.ROOT); - - if (normalizedSource.contains("da kiem tra") - && normalizedSource.contains("vui long xac nhan") - && normalizedSource.contains("tien hanh sua chua")) { - if ("en".equals(normalizedTarget)) { - return "I have checked it. Please confirm so we can proceed with the repair."; - } - if ("ja".equals(normalizedTarget)) { - return "確認いたしました。修理を進めるため、ご確認をお願いいたします。"; - } - } - - if (normalizedSource.contains("vui long xac nhan") - && normalizedSource.contains("tien hanh sua chua")) { - if ("en".equals(normalizedTarget)) { - return "Please confirm so we can proceed with the repair."; - } - if ("ja".equals(normalizedTarget)) { - return "修理を進めるため、ご確認をお願いいたします。"; - } + String refined = translatedText.trim().replaceAll("\\s+", " "); + if (policy != null && policy.customerFacing() && context != null && "en".equalsIgnoreCase(context.targetLanguage())) { + refined = refined + .replace("I have checked, ", "I have checked it. ") + .replace("please confirm to proceed with", "Please confirm so we can proceed with") + .replace("proceed with repair", "proceed with the repair"); } - - if (normalizedSource.contains("vui long xac nhan")) { - if ("en".equals(normalizedTarget)) { - return "Please confirm."; - } - if ("ja".equals(normalizedTarget)) { - return "ご確認をお願いいたします。"; - } + if (policy != null && policy.customerFacing() && context != null && "ja".equalsIgnoreCase(context.targetLanguage())) { + refined = refined + .replace("お願い致します", "お願いいたします") + .replace("下さい", "ください"); } - - return translatedText; - } - - private String normalizeVietnamese(String value) { - String normalized = Normalizer.normalize(value, Normalizer.Form.NFD) - .replaceAll("\\p{M}", "") - .replace('đ', 'd') - .replace('Đ', 'D'); - return normalized.toLowerCase(Locale.ROOT); + return refined; } } diff --git a/src/main/java/com/isums/aiservice/services/TranslationLocaleSupport.java b/src/main/java/com/isums/aiservice/services/TranslationLocaleSupport.java new file mode 100644 index 0000000..79f1f1b --- /dev/null +++ b/src/main/java/com/isums/aiservice/services/TranslationLocaleSupport.java @@ -0,0 +1,16 @@ +package com.isums.aiservice.services; + +import org.springframework.stereotype.Component; + +import java.util.Locale; + +@Component +public class TranslationLocaleSupport { + + public String normalize(String locale) { + if (locale == null || locale.isBlank()) { + return null; + } + return Locale.forLanguageTag(locale.replace('_', '-').trim()).getLanguage(); + } +} diff --git a/src/main/java/com/isums/aiservice/services/TranslationPolicyResolver.java b/src/main/java/com/isums/aiservice/services/TranslationPolicyResolver.java new file mode 100644 index 0000000..955fcc7 --- /dev/null +++ b/src/main/java/com/isums/aiservice/services/TranslationPolicyResolver.java @@ -0,0 +1,54 @@ +package com.isums.aiservice.services; + +import com.isums.aiservice.configs.TranslationProperties; +import com.isums.aiservice.domains.dtos.TranslationPolicy; +import com.isums.aiservice.domains.dtos.TranslationRequestContext; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Locale; +import java.util.Set; + +@Component +@RequiredArgsConstructor +public class TranslationPolicyResolver { + + private static final Set FORMALITY_SUPPORTED_TARGETS = Set.of( + "nl", "fr", "fr-ca", "de", "hi", "it", "ja", "ko", "pt-pt", "es", "es-mx" + ); + + private final TranslationProperties translationProperties; + private final TranslationLocaleSupport localeSupport; + + public TranslationPolicy resolve(TranslationRequestContext context) { + String normalizedTarget = localeSupport.normalize(context.targetLanguage()); + boolean customerFacing = context.customerFacing() || isCustomerFacingIntent(context.translationIntent()); + boolean formalRequested = customerFacing + && normalizedTarget != null + && FORMALITY_SUPPORTED_TARGETS.contains(normalizedTarget.toLowerCase(Locale.ROOT)); + + List terminologyNames = translationProperties.getTerminologyNames() == null + ? List.of() + : translationProperties.getTerminologyNames().stream() + .filter(name -> name != null && !name.isBlank()) + .toList(); + + return new TranslationPolicy( + customerFacing, + formalRequested, + customerFacing && translationProperties.getCustomerFacing().isBedrockEnabled(), + terminologyNames + ); + } + + private boolean isCustomerFacingIntent(String translationIntent) { + if (translationIntent == null || translationIntent.isBlank()) { + return false; + } + return switch (translationIntent.trim().toUpperCase(Locale.ROOT)) { + case "QUESTION_RESPONSE", "CUSTOMER_REPLY", "TENANT_REPLY", "APPROVAL_REQUEST" -> true; + default -> false; + }; + } +} diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml new file mode 100644 index 0000000..ad97959 --- /dev/null +++ b/src/main/resources/logback-spring.xml @@ -0,0 +1,23 @@ + + + + + + + + true + true + true + true + true + true + true + true + true + + + + + + + \ No newline at end of file diff --git a/src/test/java/com/isums/aiservice/infrastructures/listeners/IssueTextTranslationListenerTest.java b/src/test/java/com/isums/aiservice/infrastructures/listeners/IssueTextTranslationListenerTest.java index b090091..f1513d5 100644 --- a/src/test/java/com/isums/aiservice/infrastructures/listeners/IssueTextTranslationListenerTest.java +++ b/src/test/java/com/isums/aiservice/infrastructures/listeners/IssueTextTranslationListenerTest.java @@ -1,11 +1,15 @@ package com.isums.aiservice.infrastructures.listeners; import com.fasterxml.jackson.databind.ObjectMapper; +import com.isums.aiservice.domains.dtos.CustomerFacingPolishResult; import com.isums.aiservice.domains.dtos.IssueTextTranslationRequestedEvent; import com.isums.aiservice.domains.dtos.TextTranslationResult; +import com.isums.aiservice.domains.dtos.TranslationPolicy; import com.isums.aiservice.infrastructures.abstracts.TextTranslationService; import com.isums.aiservice.infrastructures.kafka.IssueTextTranslationResultProducer; +import com.isums.aiservice.services.CustomerFacingTranslationPolisher; import com.isums.aiservice.services.IssueTranslationPostProcessor; +import com.isums.aiservice.services.TranslationPolicyResolver; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -19,6 +23,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -28,6 +33,8 @@ class IssueTextTranslationListenerTest { @Mock private TextTranslationService textTranslationService; @Mock private IssueTextTranslationResultProducer resultProducer; + @Mock private TranslationPolicyResolver translationPolicyResolver; + @Mock private CustomerFacingTranslationPolisher customerFacingTranslationPolisher; @Mock private Acknowledgment acknowledgment; @Test @@ -38,21 +45,30 @@ void translatesAndPublishes() throws Exception { objectMapper, textTranslationService, resultProducer, + translationPolicyResolver, + customerFacingTranslationPolisher, new IssueTranslationPostProcessor() ); UUID resourceId = UUID.randomUUID(); UUID requestId = UUID.randomUUID(); - when(textTranslationService.translate("Tôi đã kiểm tra, vui lòng xác nhận để tiến hành sửa chữa", null, "ja")) - .thenReturn(new TextTranslationResult("vi", "ja", "確認しました。確認して修理を続行してください", "aws-translate", "DONE")); + TranslationPolicy policy = new TranslationPolicy(true, true, true, java.util.List.of()); + + when(translationPolicyResolver.resolve(any())).thenReturn(policy); + when(textTranslationService.translate(any(), any(), any(), eq(policy))) + .thenReturn(new TextTranslationResult("vi", "ja", "確認しました。確認して修理を続行してください", "aws-translate+formal", "DONE")); + when(customerFacingTranslationPolisher.polish(any(), any(), any(), eq(policy))) + .thenReturn(new CustomerFacingPolishResult("確認いたしました。修理を進めるため、ご確認をお願いいたします。", true)); String payload = objectMapper.writeValueAsString(new IssueTextTranslationRequestedEvent( requestId, - "EXECUTION", + "RESPONSE", resourceId, "Tôi đã kiểm tra, vui lòng xác nhận để tiến hành sửa chữa", null, "ja", + "QUESTION_RESPONSE", + true, Instant.now() )); @@ -64,6 +80,7 @@ void translatesAndPublishes() throws Exception { assertThat(cap.getValue().resourceId()).isEqualTo(resourceId); assertThat(cap.getValue().targetLanguage()).isEqualTo("ja"); assertThat(cap.getValue().translatedText()).isEqualTo("確認いたしました。修理を進めるため、ご確認をお願いいたします。"); + assertThat(cap.getValue().provider()).contains("bedrock-polish"); assertThat(cap.getValue().status()).isEqualTo("DONE"); verify(acknowledgment).acknowledge(); } diff --git a/src/test/java/com/isums/aiservice/services/AwsTextTranslationServiceTest.java b/src/test/java/com/isums/aiservice/services/AwsTextTranslationServiceTest.java new file mode 100644 index 0000000..bea3b88 --- /dev/null +++ b/src/test/java/com/isums/aiservice/services/AwsTextTranslationServiceTest.java @@ -0,0 +1,70 @@ +package com.isums.aiservice.services; + +import com.isums.aiservice.domains.dtos.TextTranslationResult; +import com.isums.aiservice.domains.dtos.TranslationPolicy; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import software.amazon.awssdk.services.translate.TranslateClient; +import software.amazon.awssdk.services.translate.model.Formality; +import software.amazon.awssdk.services.translate.model.TranslateTextRequest; +import software.amazon.awssdk.services.translate.model.TranslateTextResponse; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@DisplayName("AwsTextTranslationService") +class AwsTextTranslationServiceTest { + + @Test + @DisplayName("applies formal style and terminology for customer-facing japanese translation") + void translates() { + TranslateClient client = mock(TranslateClient.class); + when(client.translateText(any(TranslateTextRequest.class))).thenReturn( + TranslateTextResponse.builder() + .sourceLanguageCode("vi") + .targetLanguageCode("ja") + .translatedText("修理を承認してください") + .build() + ); + + AwsTextTranslationService service = new AwsTextTranslationService(client, new TranslationLocaleSupport()); + + TextTranslationResult result = service.translate( + "Vui long chap nhan sua chua", + null, + "ja-JP", + new TranslationPolicy(true, true, true, java.util.List.of("isums_domain")) + ); + + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(TranslateTextRequest.class); + verify(client).translateText(requestCaptor.capture()); + + assertThat(result.sourceLanguage()).isEqualTo("vi"); + assertThat(result.targetLanguage()).isEqualTo("ja"); + assertThat(result.translatedText()).isEqualTo("修理を承認してください"); + assertThat(result.status()).isEqualTo("DONE"); + assertThat(result.provider()).contains("aws-translate").contains("formal").contains("terms"); + assertThat(requestCaptor.getValue().settings().formality()).isEqualTo(Formality.FORMAL); + assertThat(requestCaptor.getValue().terminologyNames()).containsExactly("isums_domain"); + } + + @Test + @DisplayName("skips when source and target are the same") + void skipsSameLanguage() { + AwsTextTranslationService service = new AwsTextTranslationService(mock(TranslateClient.class), new TranslationLocaleSupport()); + + TextTranslationResult result = service.translate( + "Done", + "en", + "en-US", + new TranslationPolicy(false, false, false, java.util.List.of()) + ); + + assertThat(result.translatedText()).isEqualTo("Done"); + assertThat(result.status()).isEqualTo("SKIPPED"); + } +} diff --git a/src/test/java/com/isums/aiservice/services/CustomerFacingTranslationPolisherTest.java b/src/test/java/com/isums/aiservice/services/CustomerFacingTranslationPolisherTest.java new file mode 100644 index 0000000..993d3c7 --- /dev/null +++ b/src/test/java/com/isums/aiservice/services/CustomerFacingTranslationPolisherTest.java @@ -0,0 +1,53 @@ +package com.isums.aiservice.services; + +import com.isums.aiservice.configs.TranslationProperties; +import com.isums.aiservice.domains.dtos.CustomerFacingPolishResult; +import com.isums.aiservice.domains.dtos.TranslationPolicy; +import com.isums.aiservice.domains.dtos.TranslationRequestContext; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import software.amazon.awssdk.services.bedrockruntime.BedrockRuntimeClient; +import software.amazon.awssdk.services.bedrockruntime.model.ContentBlock; +import software.amazon.awssdk.services.bedrockruntime.model.ConverseOutput; +import software.amazon.awssdk.services.bedrockruntime.model.ConverseRequest; +import software.amazon.awssdk.services.bedrockruntime.model.ConverseResponse; +import software.amazon.awssdk.services.bedrockruntime.model.ConversationRole; +import software.amazon.awssdk.services.bedrockruntime.model.Message; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@DisplayName("CustomerFacingTranslationPolisher") +class CustomerFacingTranslationPolisherTest { + + @Test + @DisplayName("uses Bedrock to polish customer-facing output") + void polishesWithBedrock() { + BedrockRuntimeClient client = mock(BedrockRuntimeClient.class); + when(client.converse(any(ConverseRequest.class))).thenReturn(ConverseResponse.builder() + .output(ConverseOutput.builder() + .message(Message.builder() + .role(ConversationRole.ASSISTANT) + .content(ContentBlock.fromText("確認いたしました。修理を進めるため、ご確認をお願いいたします。")) + .build()) + .build()) + .build()); + + TranslationProperties properties = new TranslationProperties(); + CustomerFacingTranslationPolisher polisher = new CustomerFacingTranslationPolisher(client, properties); + + CustomerFacingPolishResult polished = polisher.polish( + "Tôi đã kiểm tra, vui lòng xác nhận để tiến hành sửa chữa", + "確認しました。確認して修理を続行してください", + new TranslationRequestContext("RESPONSE", "QUESTION_RESPONSE", true, "vi", "ja"), + new TranslationPolicy(true, true, true, java.util.List.of()) + ); + + assertThat(polished.text()).isEqualTo("確認いたしました。修理を進めるため、ご確認をお願いいたします。"); + assertThat(polished.usedBedrock()).isTrue(); + verify(client).converse(any(ConverseRequest.class)); + } +} diff --git a/src/test/java/com/isums/aiservice/services/IssueTranslationPostProcessorTest.java b/src/test/java/com/isums/aiservice/services/IssueTranslationPostProcessorTest.java index 2fcd19d..263d5db 100644 --- a/src/test/java/com/isums/aiservice/services/IssueTranslationPostProcessorTest.java +++ b/src/test/java/com/isums/aiservice/services/IssueTranslationPostProcessorTest.java @@ -1,5 +1,7 @@ package com.isums.aiservice.services; +import com.isums.aiservice.domains.dtos.TranslationPolicy; +import com.isums.aiservice.domains.dtos.TranslationRequestContext; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -11,39 +13,36 @@ class IssueTranslationPostProcessorTest { private final IssueTranslationPostProcessor processor = new IssueTranslationPostProcessor(); @Test - @DisplayName("rewrites repair confirmation sentence for English") - void rewritesEnglish() { + @DisplayName("normalizes Japanese customer-facing wording variants") + void normalizesJapaneseCustomerFacingWording() { String result = processor.refine( - "Tôi đã kiểm tra, vui lòng xác nhận để tiến hành sửa chữa", - "I have checked, please confirm to proceed with repair", - "vi", - "en" + "ご確認をお願い致します。資料を送って下さい。", + new TranslationRequestContext("RESPONSE", "QUESTION_RESPONSE", true, "vi", "ja"), + new TranslationPolicy(true, true, true, java.util.List.of()) ); - assertThat(result).isEqualTo("I have checked it. Please confirm so we can proceed with the repair."); + assertThat(result).isEqualTo("ご確認をお願いいたします。資料を送ってください。"); } @Test - @DisplayName("rewrites repair confirmation sentence for Japanese") - void rewritesJapanese() { + @DisplayName("smooths awkward English confirmation phrasing") + void smoothsAwkwardEnglishConfirmation() { String result = processor.refine( - "Tôi đã kiểm tra, vui lòng xác nhận để tiến hành sửa chữa", - "確認しました。確認して修理を続行してください", - "vi", - "ja" + "I have checked, please confirm to proceed with repair", + new TranslationRequestContext("RESPONSE", "QUESTION_RESPONSE", true, "vi", "en"), + new TranslationPolicy(true, false, false, java.util.List.of()) ); - assertThat(result).isEqualTo("確認いたしました。修理を進めるため、ご確認をお願いいたします。"); + assertThat(result).isEqualTo("I have checked it. Please confirm so we can proceed with the repair"); } @Test @DisplayName("leaves unrelated translation unchanged") void leavesUnrelatedText() { String result = processor.refine( - "Đèn đã thay xong", "The light has been replaced", - "vi", - "en" + new TranslationRequestContext("EXECUTION", "WORK_EXECUTION_NOTE", false, "vi", "en"), + new TranslationPolicy(false, false, false, java.util.List.of()) ); assertThat(result).isEqualTo("The light has been replaced"); From fd968a1e6eb7e4e0e3a01ba2461cdf8cab2f08a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tr=E1=BA=A7n=20=C4=90=E1=BB=A9c=20Hi=E1=BB=87u?= Date: Mon, 27 Apr 2026 01:48:21 +0700 Subject: [PATCH 6/7] Introduce AI-driven translation pipeline with REST API, Kafka integration, caching, and unit tests. --- build.gradle | 7 + .../TextTranslationController.java | 53 ++++++ .../domains/dtos/TranslateTextRequest.java | 27 +++ .../domains/dtos/TranslateTextResponse.java | 11 ++ .../domains/dtos/TranslationOutcome.java | 18 ++ .../kafka/TextTranslationResultProducer.java | 37 ++++ .../kafka/TextTranslationTopicsConfig.java | 22 +++ .../listeners/TextTranslationListener.java | 91 ++++++++++ .../aiservice/services/TranslationCache.java | 50 ++++++ .../services/TranslationOrchestrator.java | 163 ++++++++++++++++++ .../services/TranslationPolicyResolver.java | 38 ++-- .../TextTranslationControllerTest.java | 106 ++++++++++++ .../TextTranslationListenerTest.java | 115 ++++++++++++ .../services/TranslationOrchestratorTest.java | 132 ++++++++++++++ .../TranslationPolicyResolverTest.java | 88 ++++++++++ 15 files changed, 946 insertions(+), 12 deletions(-) create mode 100644 src/main/java/com/isums/aiservice/controllers/TextTranslationController.java create mode 100644 src/main/java/com/isums/aiservice/domains/dtos/TranslateTextRequest.java create mode 100644 src/main/java/com/isums/aiservice/domains/dtos/TranslateTextResponse.java create mode 100644 src/main/java/com/isums/aiservice/domains/dtos/TranslationOutcome.java create mode 100644 src/main/java/com/isums/aiservice/infrastructures/kafka/TextTranslationResultProducer.java create mode 100644 src/main/java/com/isums/aiservice/infrastructures/kafka/TextTranslationTopicsConfig.java create mode 100644 src/main/java/com/isums/aiservice/infrastructures/listeners/TextTranslationListener.java create mode 100644 src/main/java/com/isums/aiservice/services/TranslationCache.java create mode 100644 src/main/java/com/isums/aiservice/services/TranslationOrchestrator.java create mode 100644 src/test/java/com/isums/aiservice/controllers/TextTranslationControllerTest.java create mode 100644 src/test/java/com/isums/aiservice/infrastructures/listeners/TextTranslationListenerTest.java create mode 100644 src/test/java/com/isums/aiservice/services/TranslationOrchestratorTest.java create mode 100644 src/test/java/com/isums/aiservice/services/TranslationPolicyResolverTest.java diff --git a/build.gradle b/build.gradle index cd26139..9c13e04 100644 --- a/build.gradle +++ b/build.gradle @@ -51,7 +51,9 @@ dependencies { implementation 'io.micrometer:micrometer-registry-prometheus' implementation 'org.springframework.boot:spring-boot-starter-actuator' implementation 'com.isums:isums-observability-common:1.0-SNAPSHOT' + implementation "com.isums:proto-common:1.0-SNAPSHOT" implementation 'org.springframework.boot:spring-boot-starter-webmvc' + implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-kafka' implementation 'software.amazon.awssdk:s3:2.41.31' implementation 'software.amazon.awssdk:translate:2.41.31' @@ -75,3 +77,8 @@ dependencyManagement { tasks.named('test') { useJUnitPlatform() } + + +tasks.withType(JavaCompile).configureEach { + options.encoding = "UTF-8" +} \ No newline at end of file diff --git a/src/main/java/com/isums/aiservice/controllers/TextTranslationController.java b/src/main/java/com/isums/aiservice/controllers/TextTranslationController.java new file mode 100644 index 0000000..0d7bc0b --- /dev/null +++ b/src/main/java/com/isums/aiservice/controllers/TextTranslationController.java @@ -0,0 +1,53 @@ +package com.isums.aiservice.controllers; + +import com.isums.aiservice.domains.dtos.TranslateTextRequest; +import com.isums.aiservice.domains.dtos.TranslateTextResponse; +import com.isums.aiservice.domains.dtos.TranslationOutcome; +import com.isums.aiservice.services.TranslationOrchestrator; +import com.isums.common.i18n.events.TextTranslationResultEvent; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.LinkedHashMap; +import java.util.Map; + +@RestController +@RequestMapping("/api/ai/translate") +@RequiredArgsConstructor +public class TextTranslationController { + + private final TranslationOrchestrator orchestrator; + + @PostMapping + public ResponseEntity translate(@Valid @RequestBody TranslateTextRequest request) { + Map outcomes = orchestrator.translateAll( + request.text(), + request.sourceLanguage(), + request.targetLanguages(), + request.resourceType(), + request.intent(), + request.customerFacing()); + + Map translations = new LinkedHashMap<>(); + Map statuses = new LinkedHashMap<>(); + Map errors = new LinkedHashMap<>(); + String provider = "aws-translate"; + for (Map.Entry entry : outcomes.entrySet()) { + TranslationOutcome outcome = entry.getValue(); + translations.put(entry.getKey(), outcome.translatedText()); + statuses.put(entry.getKey(), outcome.status()); + if (TextTranslationResultEvent.STATUS_FAILED.equals(outcome.status()) && outcome.errorMessage() != null) { + errors.put(entry.getKey(), outcome.errorMessage()); + } + if (outcome.provider() != null) { + provider = outcome.provider(); + } + } + return ResponseEntity.ok(new TranslateTextResponse(translations, statuses, provider, errors)); + } +} diff --git a/src/main/java/com/isums/aiservice/domains/dtos/TranslateTextRequest.java b/src/main/java/com/isums/aiservice/domains/dtos/TranslateTextRequest.java new file mode 100644 index 0000000..bd0c564 --- /dev/null +++ b/src/main/java/com/isums/aiservice/domains/dtos/TranslateTextRequest.java @@ -0,0 +1,27 @@ +package com.isums.aiservice.domains.dtos; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.Size; + +import java.util.List; + +public record TranslateTextRequest( + @NotBlank + @Size(max = 10_000) + String text, + + @Size(min = 2, max = 5) + String sourceLanguage, + + @NotEmpty + @Size(min = 1, max = 5) + List<@NotBlank String> targetLanguages, + + String intent, + + String resourceType, + + Boolean customerFacing +) { +} diff --git a/src/main/java/com/isums/aiservice/domains/dtos/TranslateTextResponse.java b/src/main/java/com/isums/aiservice/domains/dtos/TranslateTextResponse.java new file mode 100644 index 0000000..c1f2c29 --- /dev/null +++ b/src/main/java/com/isums/aiservice/domains/dtos/TranslateTextResponse.java @@ -0,0 +1,11 @@ +package com.isums.aiservice.domains.dtos; + +import java.util.Map; + +public record TranslateTextResponse( + Map translations, + Map statuses, + String provider, + Map errors +) { +} diff --git a/src/main/java/com/isums/aiservice/domains/dtos/TranslationOutcome.java b/src/main/java/com/isums/aiservice/domains/dtos/TranslationOutcome.java new file mode 100644 index 0000000..2f347bb --- /dev/null +++ b/src/main/java/com/isums/aiservice/domains/dtos/TranslationOutcome.java @@ -0,0 +1,18 @@ +package com.isums.aiservice.domains.dtos; + +public record TranslationOutcome( + String sourceLanguage, + String targetLanguage, + String translatedText, + String provider, + String status, + String errorMessage +) { + public boolean isDone() { + return "DONE".equals(status); + } + + public boolean isFailed() { + return "FAILED".equals(status); + } +} diff --git a/src/main/java/com/isums/aiservice/infrastructures/kafka/TextTranslationResultProducer.java b/src/main/java/com/isums/aiservice/infrastructures/kafka/TextTranslationResultProducer.java new file mode 100644 index 0000000..4242834 --- /dev/null +++ b/src/main/java/com/isums/aiservice/infrastructures/kafka/TextTranslationResultProducer.java @@ -0,0 +1,37 @@ +package com.isums.aiservice.infrastructures.kafka; + +import com.isums.common.i18n.events.TextTranslationResultEvent; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +@Slf4j +public class TextTranslationResultProducer { + + private static final String DEFAULT_CALLBACK_PREFIX = "text.translation.result."; + + private final KafkaTemplate kafkaTemplate; + + public void send(String callbackTopic, TextTranslationResultEvent event) { + String topic = resolveTopic(callbackTopic, event); + String key = event.resourceId() + ":" + event.targetLanguage(); + kafkaTemplate.send(topic, key, event); + log.debug("Published translation result requestId={} topic={} status={}", + event.requestId(), topic, event.status()); + } + + private static String resolveTopic(String callbackTopic, TextTranslationResultEvent event) { + if (callbackTopic != null && !callbackTopic.isBlank()) { + return callbackTopic; + } + String resourceType = event.resourceType(); + if (resourceType == null || resourceType.isBlank()) { + return DEFAULT_CALLBACK_PREFIX + "unknown"; + } + String service = resourceType.split("\\.", 2)[0]; + return DEFAULT_CALLBACK_PREFIX + service; + } +} diff --git a/src/main/java/com/isums/aiservice/infrastructures/kafka/TextTranslationTopicsConfig.java b/src/main/java/com/isums/aiservice/infrastructures/kafka/TextTranslationTopicsConfig.java new file mode 100644 index 0000000..0d4524b --- /dev/null +++ b/src/main/java/com/isums/aiservice/infrastructures/kafka/TextTranslationTopicsConfig.java @@ -0,0 +1,22 @@ +package com.isums.aiservice.infrastructures.kafka; + +import com.isums.common.i18n.events.TextTranslationRequestedEvent; +import org.apache.kafka.clients.admin.NewTopic; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.kafka.config.TopicBuilder; + +@Configuration +public class TextTranslationTopicsConfig { + + @Bean + public NewTopic textTranslationRequestedTopic( + @Value("${ai.translation.kafka.replicas:1}") short replicas, + @Value("${ai.translation.kafka.request-partitions:3}") int partitions) { + return TopicBuilder.name(TextTranslationRequestedEvent.TOPIC) + .partitions(partitions) + .replicas(replicas) + .build(); + } +} diff --git a/src/main/java/com/isums/aiservice/infrastructures/listeners/TextTranslationListener.java b/src/main/java/com/isums/aiservice/infrastructures/listeners/TextTranslationListener.java new file mode 100644 index 0000000..350a3a7 --- /dev/null +++ b/src/main/java/com/isums/aiservice/infrastructures/listeners/TextTranslationListener.java @@ -0,0 +1,91 @@ +package com.isums.aiservice.infrastructures.listeners; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.isums.aiservice.domains.dtos.TranslationOutcome; +import com.isums.aiservice.infrastructures.kafka.TextTranslationResultProducer; +import com.isums.aiservice.services.TranslationOrchestrator; +import com.isums.common.i18n.events.TextTranslationRequestedEvent; +import com.isums.common.i18n.events.TextTranslationResultEvent; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.kafka.support.Acknowledgment; +import org.springframework.stereotype.Component; + +import java.time.Instant; +import java.util.List; +import java.util.Map; + +@Component +@RequiredArgsConstructor +@Slf4j +public class TextTranslationListener { + + private final ObjectMapper objectMapper; + private final TranslationOrchestrator orchestrator; + private final TextTranslationResultProducer resultProducer; + + @KafkaListener(topics = "text.translation.requested", groupId = "ai-text-translation-group") + public void onRequested(String payload, Acknowledgment acknowledgment) { + TextTranslationRequestedEvent event = null; + try { + event = objectMapper.readValue(payload, TextTranslationRequestedEvent.class); + } catch (Exception parseEx) { + log.error("Malformed TextTranslationRequestedEvent payload dropped: {}", payload, parseEx); + acknowledgment.acknowledge(); + return; + } + + List targets = event.targetLanguages(); + if (targets == null || targets.isEmpty()) { + log.warn("Rejecting translation request with empty targetLanguages requestId={}", event.requestId()); + acknowledgment.acknowledge(); + return; + } + + for (String target : targets) { + publishOutcome(event, target); + } + acknowledgment.acknowledge(); + } + + private void publishOutcome(TextTranslationRequestedEvent event, String target) { + TranslationOutcome outcome; + try { + outcome = orchestrator.translateOne( + event.text(), + event.sourceLanguage(), + target, + event.resourceType(), + event.translationIntent(), + event.customerFacing()); + } catch (Exception ex) { + log.error("Orchestrator threw for requestId={} target={}", event.requestId(), target, ex); + outcome = new TranslationOutcome( + event.sourceLanguage(), + target, + null, + "aws-translate", + TextTranslationResultEvent.STATUS_FAILED, + ex.getMessage()); + } + TextTranslationResultEvent result = new TextTranslationResultEvent( + event.requestId(), + event.resourceType(), + event.resourceId(), + event.fieldName(), + outcome.sourceLanguage(), + outcome.targetLanguage(), + outcome.translatedText(), + outcome.provider(), + outcome.status(), + outcome.errorMessage(), + Instant.now()); + try { + resultProducer.send(event.callbackTopic(), result); + } catch (Exception publishEx) { + log.error("Failed to publish translation result requestId={} target={}", + event.requestId(), target, publishEx); + } + } +} diff --git a/src/main/java/com/isums/aiservice/services/TranslationCache.java b/src/main/java/com/isums/aiservice/services/TranslationCache.java new file mode 100644 index 0000000..9a4fc51 --- /dev/null +++ b/src/main/java/com/isums/aiservice/services/TranslationCache.java @@ -0,0 +1,50 @@ +package com.isums.aiservice.services; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import com.isums.aiservice.domains.dtos.TranslationOutcome; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.time.Duration; + +@Component +public class TranslationCache { + + private final Cache cache; + + public TranslationCache( + @Value("${ai.translation.cache.ttl-hours:24}") long ttlHours, + @Value("${ai.translation.cache.max-size:10000}") long maxSize) { + this.cache = Caffeine.newBuilder() + .expireAfterWrite(Duration.ofHours(ttlHours)) + .maximumSize(maxSize) + .recordStats() + .build(); + } + + public TranslationOutcome getIfPresent(String text, String source, String target, String intent, boolean customerFacing) { + return cache.getIfPresent(key(text, source, target, intent, customerFacing)); + } + + public void put(String text, String source, String target, String intent, boolean customerFacing, TranslationOutcome outcome) { + if (outcome == null || !outcome.isDone()) return; + cache.put(key(text, source, target, intent, customerFacing), outcome); + } + + public long estimatedSize() { + return cache.estimatedSize(); + } + + public double hitRate() { + return cache.stats().hitRate(); + } + + private String key(String text, String source, String target, String intent, boolean customerFacing) { + return Integer.toHexString((text == null ? "" : text).hashCode()) + + '|' + (source == null ? "auto" : source.toLowerCase()) + + '|' + (target == null ? "" : target.toLowerCase()) + + '|' + (intent == null ? "" : intent) + + '|' + customerFacing; + } +} diff --git a/src/main/java/com/isums/aiservice/services/TranslationOrchestrator.java b/src/main/java/com/isums/aiservice/services/TranslationOrchestrator.java new file mode 100644 index 0000000..b8a8058 --- /dev/null +++ b/src/main/java/com/isums/aiservice/services/TranslationOrchestrator.java @@ -0,0 +1,163 @@ +package com.isums.aiservice.services; + +import com.isums.aiservice.domains.dtos.TextTranslationResult; +import com.isums.aiservice.domains.dtos.TranslationOutcome; +import com.isums.aiservice.domains.dtos.TranslationPolicy; +import com.isums.aiservice.domains.dtos.TranslationRequestContext; +import com.isums.aiservice.infrastructures.abstracts.TextTranslationService; +import com.isums.common.i18n.events.TextTranslationResultEvent; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Timer; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@Service +@RequiredArgsConstructor +@Slf4j +public class TranslationOrchestrator { + + private static final String METRIC_REQUESTS = "ai.translation.requests"; + private static final String METRIC_DURATION = "ai.translation.duration"; + + private final TextTranslationService translator; + private final TranslationPolicyResolver policyResolver; + private final CustomerFacingTranslationPolisher polisher; + private final IssueTranslationPostProcessor postProcessor; + private final TranslationLocaleSupport localeSupport; + private final TranslationCache cache; + private final MeterRegistry meterRegistry; + + /** + * Translate {@code text} into each of {@code targetLanguages}. Returns a + * map keyed by normalised locale; guaranteed to contain every requested + * target, including {@code FAILED} entries for unrecoverable errors. + */ + public Map translateAll( + String text, + String sourceLanguage, + List targetLanguages, + String resourceType, + String intent, + Boolean customerFacing) { + Map results = new LinkedHashMap<>(); + if (targetLanguages == null || targetLanguages.isEmpty()) { + return results; + } + for (String raw : targetLanguages) { + String target = localeSupport.normalize(raw); + if (target == null || target.isBlank()) { + continue; + } + if (results.containsKey(target)) { + continue; + } + results.put(target, translateOne(text, sourceLanguage, target, resourceType, intent, customerFacing)); + } + return results; + } + + public TranslationOutcome translateOne( + String text, + String sourceLanguage, + String targetLanguage, + String resourceType, + String intent, + Boolean customerFacing) { + String normalizedSource = localeSupport.normalize(sourceLanguage); + String normalizedTarget = localeSupport.normalize(targetLanguage); + boolean customerFacingFlag = Boolean.TRUE.equals(customerFacing); + + TranslationOutcome cached = cache.getIfPresent( + text, normalizedSource, normalizedTarget, intent, customerFacingFlag); + if (cached != null) { + incrementRequest(resourceType, normalizedTarget, cached.status(), "hit"); + return cached; + } + + Timer.Sample sample = Timer.start(meterRegistry); + TranslationOutcome outcome; + try { + outcome = doTranslate(text, normalizedSource, normalizedTarget, resourceType, intent, customerFacingFlag); + cache.put(text, normalizedSource, normalizedTarget, intent, customerFacingFlag, outcome); + } catch (Exception ex) { + log.warn("Translation failed resourceType={} target={} err={}", resourceType, normalizedTarget, ex.toString()); + outcome = new TranslationOutcome( + normalizedSource, + normalizedTarget, + null, + "aws-translate", + TextTranslationResultEvent.STATUS_FAILED, + ex.getMessage()); + } + sample.stop(meterRegistry.timer(METRIC_DURATION, + "resource_type", safeTag(resourceType), + "target", safeTag(normalizedTarget), + "status", safeTag(outcome.status()))); + incrementRequest(resourceType, normalizedTarget, outcome.status(), "miss"); + return outcome; + } + + private TranslationOutcome doTranslate( + String text, + String normalizedSource, + String normalizedTarget, + String resourceType, + String intent, + boolean customerFacing) { + TranslationRequestContext context = new TranslationRequestContext( + resourceType, intent, customerFacing, normalizedSource, normalizedTarget); + TranslationPolicy policy = policyResolver.resolve(context); + + TextTranslationResult translated = translator.translate(text, normalizedSource, normalizedTarget, policy); + + String refinedText = translated.translatedText(); + String provider = translated.provider(); + String status = mapStatus(translated.status()); + + if (TextTranslationResultEvent.STATUS_DONE.equals(status) && refinedText != null) { + var polish = polisher.polish(text, refinedText, context, policy); + refinedText = polish.text(); + if (polish.usedBedrock()) { + provider = provider + "+bedrock-polish"; + } + refinedText = postProcessor.refine(refinedText, context, policy); + } + + return new TranslationOutcome( + translated.sourceLanguage(), + translated.targetLanguage(), + refinedText, + provider, + status, + null); + } + + private void incrementRequest(String resourceType, String target, String status, String cacheOutcome) { + meterRegistry.counter(METRIC_REQUESTS, + "resource_type", safeTag(resourceType), + "target", safeTag(target), + "status", safeTag(status), + "cache", cacheOutcome + ).increment(); + } + + private static String safeTag(String raw) { + return (raw == null || raw.isBlank()) ? "unknown" : raw; + } + + private static String mapStatus(String provided) { + if (provided == null) return TextTranslationResultEvent.STATUS_DONE; + String upper = provided.toUpperCase(); + return switch (upper) { + case "DONE", "SUCCESS" -> TextTranslationResultEvent.STATUS_DONE; + case "SKIPPED" -> TextTranslationResultEvent.STATUS_SKIPPED; + case "FAILED", "FAILURE" -> TextTranslationResultEvent.STATUS_FAILED; + default -> upper; + }; + } +} diff --git a/src/main/java/com/isums/aiservice/services/TranslationPolicyResolver.java b/src/main/java/com/isums/aiservice/services/TranslationPolicyResolver.java index 955fcc7..327ef9f 100644 --- a/src/main/java/com/isums/aiservice/services/TranslationPolicyResolver.java +++ b/src/main/java/com/isums/aiservice/services/TranslationPolicyResolver.java @@ -18,37 +18,51 @@ public class TranslationPolicyResolver { "nl", "fr", "fr-ca", "de", "hi", "it", "ja", "ko", "pt-pt", "es", "es-mx" ); + private static final Set CUSTOMER_FACING_INTENTS = Set.of( + "CUSTOMER_FACING_UI", + "CUSTOMER_REPLY", + "TENANT_REPLY", + "QUESTION_RESPONSE", + "APPROVAL_REQUEST" + ); + + private static final String INTENT_LEGAL = "LEGAL_REFERENCE"; + private final TranslationProperties translationProperties; private final TranslationLocaleSupport localeSupport; public TranslationPolicy resolve(TranslationRequestContext context) { String normalizedTarget = localeSupport.normalize(context.targetLanguage()); - boolean customerFacing = context.customerFacing() || isCustomerFacingIntent(context.translationIntent()); + String intent = normalizeIntent(context.translationIntent()); + boolean legalIntent = INTENT_LEGAL.equals(intent); + + boolean customerFacing = !legalIntent + && (context.customerFacing() || CUSTOMER_FACING_INTENTS.contains(intent)); + boolean formalRequested = customerFacing && normalizedTarget != null && FORMALITY_SUPPORTED_TARGETS.contains(normalizedTarget.toLowerCase(Locale.ROOT)); + boolean bedrockPolish = customerFacing + && !legalIntent + && translationProperties.getCustomerFacing().isBedrockEnabled(); + List terminologyNames = translationProperties.getTerminologyNames() == null ? List.of() : translationProperties.getTerminologyNames().stream() - .filter(name -> name != null && !name.isBlank()) - .toList(); + .filter(name -> name != null && !name.isBlank()) + .toList(); return new TranslationPolicy( customerFacing, formalRequested, - customerFacing && translationProperties.getCustomerFacing().isBedrockEnabled(), + bedrockPolish, terminologyNames ); } - private boolean isCustomerFacingIntent(String translationIntent) { - if (translationIntent == null || translationIntent.isBlank()) { - return false; - } - return switch (translationIntent.trim().toUpperCase(Locale.ROOT)) { - case "QUESTION_RESPONSE", "CUSTOMER_REPLY", "TENANT_REPLY", "APPROVAL_REQUEST" -> true; - default -> false; - }; + private static String normalizeIntent(String raw) { + if (raw == null || raw.isBlank()) return null; + return raw.trim().toUpperCase(Locale.ROOT); } } diff --git a/src/test/java/com/isums/aiservice/controllers/TextTranslationControllerTest.java b/src/test/java/com/isums/aiservice/controllers/TextTranslationControllerTest.java new file mode 100644 index 0000000..2a4a087 --- /dev/null +++ b/src/test/java/com/isums/aiservice/controllers/TextTranslationControllerTest.java @@ -0,0 +1,106 @@ +package com.isums.aiservice.controllers; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.isums.aiservice.domains.dtos.TranslationOutcome; +import com.isums.aiservice.services.TranslationOrchestrator; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +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 = TextTranslationController.class) +@AutoConfigureMockMvc(addFilters = false) +class TextTranslationControllerTest { + + @Autowired private MockMvc mvc; + @Autowired private ObjectMapper mapper; + @MockitoBean private TranslationOrchestrator orchestrator; + + @Test + void returnsTranslationsForValidRequest() throws Exception { + Map outcomes = new LinkedHashMap<>(); + outcomes.put("en", new TranslationOutcome("vi", "en", "Hello", "aws-translate", "DONE", null)); + outcomes.put("ja", new TranslationOutcome("vi", "ja", "こんにちは", "aws-translate+formal", "DONE", null)); + when(orchestrator.translateAll(anyString(), eq("vi"), any(), anyString(), anyString(), any())) + .thenReturn(outcomes); + + Map body = Map.of( + "text", "Xin chào", + "sourceLanguage", "vi", + "targetLanguages", List.of("en", "ja"), + "intent", "CUSTOMER_FACING_UI", + "resourceType", "notification.title" + ); + + mvc.perform(post("/api/ai/translate") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(body))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.translations.en").value("Hello")) + .andExpect(jsonPath("$.translations.ja").value("こんにちは")) + .andExpect(jsonPath("$.statuses.en").value("DONE")) + .andExpect(jsonPath("$.statuses.ja").value("DONE")); + } + + @Test + void rejectsBlankText() throws Exception { + Map body = Map.of( + "text", " ", + "targetLanguages", List.of("en") + ); + + mvc.perform(post("/api/ai/translate") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(body))) + .andExpect(status().isBadRequest()); + } + + @Test + void rejectsEmptyTargetList() throws Exception { + Map body = Map.of( + "text", "Xin chào", + "targetLanguages", List.of() + ); + + mvc.perform(post("/api/ai/translate") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(body))) + .andExpect(status().isBadRequest()); + } + + @Test + void surfacesFailedErrorsInResponseBody() throws Exception { + Map outcomes = new LinkedHashMap<>(); + outcomes.put("en", new TranslationOutcome("vi", "en", "Hello", "aws-translate", "DONE", null)); + outcomes.put("ja", new TranslationOutcome("vi", "ja", null, "aws-translate", "FAILED", "AWS timed out")); + when(orchestrator.translateAll(anyString(), any(), any(), any(), any(), any())) + .thenReturn(outcomes); + + Map body = Map.of( + "text", "Xin chào", + "targetLanguages", List.of("en", "ja") + ); + + mvc.perform(post("/api/ai/translate") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(body))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.statuses.ja").value("FAILED")) + .andExpect(jsonPath("$.errors.ja").value("AWS timed out")); + } +} diff --git a/src/test/java/com/isums/aiservice/infrastructures/listeners/TextTranslationListenerTest.java b/src/test/java/com/isums/aiservice/infrastructures/listeners/TextTranslationListenerTest.java new file mode 100644 index 0000000..f67d68f --- /dev/null +++ b/src/test/java/com/isums/aiservice/infrastructures/listeners/TextTranslationListenerTest.java @@ -0,0 +1,115 @@ +package com.isums.aiservice.infrastructures.listeners; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import com.isums.aiservice.domains.dtos.TranslationOutcome; +import com.isums.aiservice.infrastructures.kafka.TextTranslationResultProducer; +import com.isums.aiservice.services.TranslationOrchestrator; +import com.isums.common.i18n.events.TextTranslationRequestedEvent; +import com.isums.common.i18n.events.TextTranslationResultEvent; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.kafka.support.Acknowledgment; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class TextTranslationListenerTest { + + private ObjectMapper mapper; + private TranslationOrchestrator orchestrator; + private TextTranslationResultProducer producer; + private Acknowledgment ack; + private TextTranslationListener listener; + + @BeforeEach + void setUp() { + mapper = new ObjectMapper().registerModule(new JavaTimeModule()); + orchestrator = mock(TranslationOrchestrator.class); + producer = mock(TextTranslationResultProducer.class); + ack = mock(Acknowledgment.class); + listener = new TextTranslationListener(mapper, orchestrator, producer); + } + + @Test + void publishesOneResultPerTargetAndAcks() throws Exception { + UUID requestId = UUID.randomUUID(); + UUID resourceId = UUID.randomUUID(); + TextTranslationRequestedEvent event = new TextTranslationRequestedEvent( + requestId, "notification.title", resourceId, "title", + "Xin chào", "vi", List.of("en", "ja"), + "CUSTOMER_FACING_UI", true, Instant.now(), + "text.translation.result.notification"); + + when(orchestrator.translateOne(anyString(), anyString(), eq("en"), anyString(), anyString(), any())) + .thenReturn(new TranslationOutcome("vi", "en", "Hello", "aws-translate", "DONE", null)); + when(orchestrator.translateOne(anyString(), anyString(), eq("ja"), anyString(), anyString(), any())) + .thenReturn(new TranslationOutcome("vi", "ja", "こんにちは", "aws-translate+formal", "DONE", null)); + + listener.onRequested(mapper.writeValueAsString(event), ack); + + ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(TextTranslationResultEvent.class); + verify(producer, times(2)).send(eq("text.translation.result.notification"), resultCaptor.capture()); + assertThat(resultCaptor.getAllValues()) + .extracting(TextTranslationResultEvent::targetLanguage, TextTranslationResultEvent::translatedText) + .containsExactlyInAnyOrder( + org.assertj.core.groups.Tuple.tuple("en", "Hello"), + org.assertj.core.groups.Tuple.tuple("ja", "こんにちは")); + verify(ack).acknowledge(); + } + + @Test + void orchestratorFailurePerTargetPublishesFailedResult() throws Exception { + TextTranslationRequestedEvent event = new TextTranslationRequestedEvent( + UUID.randomUUID(), "notification.title", UUID.randomUUID(), "title", + "Xin chào", "vi", List.of("en"), + "CUSTOMER_FACING_UI", true, Instant.now(), + "text.translation.result.notification"); + + when(orchestrator.translateOne(anyString(), anyString(), anyString(), anyString(), anyString(), any())) + .thenThrow(new RuntimeException("boom")); + + listener.onRequested(mapper.writeValueAsString(event), ack); + + ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(TextTranslationResultEvent.class); + verify(producer).send(anyString(), resultCaptor.capture()); + TextTranslationResultEvent result = resultCaptor.getValue(); + assertThat(result.status()).isEqualTo("FAILED"); + assertThat(result.errorMessage()).isEqualTo("boom"); + assertThat(result.translatedText()).isNull(); + verify(ack).acknowledge(); + } + + @Test + void malformedPayloadIsAckedWithoutPublishing() { + listener.onRequested("not-json", ack); + + verify(producer, never()).send(anyString(), any()); + verify(ack).acknowledge(); + } + + @Test + void emptyTargetsAckedWithoutPublishing() throws Exception { + TextTranslationRequestedEvent event = new TextTranslationRequestedEvent( + UUID.randomUUID(), "notification.title", UUID.randomUUID(), "title", + "Xin chào", "vi", List.of(), + null, false, Instant.now(), "x"); + + listener.onRequested(mapper.writeValueAsString(event), ack); + + verify(producer, never()).send(anyString(), any()); + verify(ack).acknowledge(); + } +} diff --git a/src/test/java/com/isums/aiservice/services/TranslationOrchestratorTest.java b/src/test/java/com/isums/aiservice/services/TranslationOrchestratorTest.java new file mode 100644 index 0000000..03d92e8 --- /dev/null +++ b/src/test/java/com/isums/aiservice/services/TranslationOrchestratorTest.java @@ -0,0 +1,132 @@ +package com.isums.aiservice.services; + +import com.isums.aiservice.domains.dtos.CustomerFacingPolishResult; +import com.isums.aiservice.domains.dtos.TextTranslationResult; +import com.isums.aiservice.domains.dtos.TranslationOutcome; +import com.isums.aiservice.domains.dtos.TranslationPolicy; +import com.isums.aiservice.infrastructures.abstracts.TextTranslationService; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class TranslationOrchestratorTest { + + private TextTranslationService translator; + private TranslationPolicyResolver policyResolver; + private CustomerFacingTranslationPolisher polisher; + private IssueTranslationPostProcessor postProcessor; + private TranslationCache cache; + private TranslationOrchestrator orchestrator; + + @BeforeEach + void setUp() { + translator = mock(TextTranslationService.class); + policyResolver = mock(TranslationPolicyResolver.class); + polisher = mock(CustomerFacingTranslationPolisher.class); + postProcessor = mock(IssueTranslationPostProcessor.class); + cache = mock(TranslationCache.class); + TranslationLocaleSupport localeSupport = new TranslationLocaleSupport(); + + when(policyResolver.resolve(any())).thenReturn( + new TranslationPolicy(false, false, false, List.of())); + when(polisher.polish(anyString(), anyString(), any(), any())) + .thenAnswer(inv -> new CustomerFacingPolishResult( + inv.getArgument(1), false)); + when(postProcessor.refine(anyString(), any(), any())) + .thenAnswer(inv -> inv.getArgument(0)); + + orchestrator = new TranslationOrchestrator( + translator, + policyResolver, + polisher, + postProcessor, + localeSupport, + cache, + new SimpleMeterRegistry()); + } + + @Test + void successfulTranslationProducesDoneOutcome() { + when(translator.translate(anyString(), anyString(), anyString(), any())) + .thenReturn(new TextTranslationResult("vi", "en", "Hello", "aws-translate", "DONE")); + + TranslationOutcome outcome = orchestrator.translateOne( + "Xin chào", "vi", "en", "notification.title", "CUSTOMER_FACING_UI", true); + + assertThat(outcome.status()).isEqualTo("DONE"); + assertThat(outcome.translatedText()).isEqualTo("Hello"); + assertThat(outcome.targetLanguage()).isEqualTo("en"); + verify(cache, times(1)).put(anyString(), anyString(), anyString(), any(), anyBoolean(), any()); + } + + @Test + void awsFailureBecomesFailedOutcomeNotCached() { + when(translator.translate(anyString(), anyString(), anyString(), any())) + .thenThrow(new RuntimeException("AWS timeout")); + + TranslationOutcome outcome = orchestrator.translateOne( + "Xin chào", "vi", "ja", "issue-ticket.note", "STAFF_INTERNAL", false); + + assertThat(outcome.status()).isEqualTo("FAILED"); + assertThat(outcome.translatedText()).isNull(); + assertThat(outcome.errorMessage()).contains("AWS timeout"); + verify(cache, never()).put(anyString(), anyString(), anyString(), any(), anyBoolean(), any()); + } + + @Test + void cacheHitSkipsTranslator() { + TranslationOutcome cached = new TranslationOutcome( + "vi", "en", "Cached Hello", "aws-translate", "DONE", null); + when(cache.getIfPresent(anyString(), anyString(), anyString(), any(), anyBoolean())) + .thenReturn(cached); + + TranslationOutcome outcome = orchestrator.translateOne( + "Xin chào", "vi", "en", "house.name", null, false); + + assertThat(outcome).isSameAs(cached); + verify(translator, never()).translate(anyString(), anyString(), anyString(), any()); + } + + @Test + void translateAllDedupesTargetsAndNormalises() { + when(translator.translate(anyString(), anyString(), anyString(), any())) + .thenAnswer(inv -> new TextTranslationResult( + "vi", inv.getArgument(2), "T-" + inv.getArgument(2), "aws-translate", "DONE")); + + var result = orchestrator.translateAll( + "Xin chào", "vi", List.of("EN", "en-US", "ja-JP", "ja"), + "notification.title", "CUSTOMER_FACING_UI", true); + + assertThat(result).containsOnlyKeys("en", "ja"); + assertThat(result.get("en").translatedText()).isEqualTo("T-en"); + assertThat(result.get("ja").translatedText()).isEqualTo("T-ja"); + } + + @Test + void polisherMarksProviderWhenBedrockUsed() { + when(translator.translate(anyString(), anyString(), anyString(), any())) + .thenReturn(new TextTranslationResult("vi", "ja", "こんにちは", "aws-translate", "DONE")); + when(polisher.polish(anyString(), anyString(), any(), any())) + .thenReturn(new CustomerFacingPolishResult("こんにちは(polished)", true)); + + TranslationOutcome outcome = orchestrator.translateOne( + "Xin chào", "vi", "ja", "notification.title", "CUSTOMER_FACING_UI", true); + + assertThat(outcome.provider()).isEqualTo("aws-translate+bedrock-polish"); + assertThat(outcome.translatedText()).isEqualTo("こんにちは(polished)"); + } + + private static boolean anyBoolean() { + return org.mockito.ArgumentMatchers.anyBoolean(); + } +} diff --git a/src/test/java/com/isums/aiservice/services/TranslationPolicyResolverTest.java b/src/test/java/com/isums/aiservice/services/TranslationPolicyResolverTest.java new file mode 100644 index 0000000..287a11b --- /dev/null +++ b/src/test/java/com/isums/aiservice/services/TranslationPolicyResolverTest.java @@ -0,0 +1,88 @@ +package com.isums.aiservice.services; + +import com.isums.aiservice.configs.TranslationProperties; +import com.isums.aiservice.domains.dtos.TranslationPolicy; +import com.isums.aiservice.domains.dtos.TranslationRequestContext; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class TranslationPolicyResolverTest { + + private TranslationProperties properties; + private TranslationPolicyResolver resolver; + + @BeforeEach + void setUp() { + properties = new TranslationProperties(); + properties.setTerminologyNames(List.of("isums-glossary")); + properties.getCustomerFacing().setBedrockEnabled(true); + resolver = new TranslationPolicyResolver(properties, new TranslationLocaleSupport()); + } + + @Test + void customerFacingUiIntentRequestsFormalToneAndBedrockPolish() { + TranslationPolicy policy = resolver.resolve(new TranslationRequestContext( + "notification.title", "CUSTOMER_FACING_UI", false, "vi", "ja")); + + assertThat(policy.customerFacing()).isTrue(); + assertThat(policy.formalRequested()).isTrue(); + assertThat(policy.bedrockPolishEnabled()).isTrue(); + assertThat(policy.terminologyNames()).containsExactly("isums-glossary"); + } + + @Test + void legalIntentDisablesPolishEvenWhenCustomerFacingFlagIsTrue() { + TranslationPolicy policy = resolver.resolve(new TranslationRequestContext( + "econtract.name", "LEGAL_REFERENCE", true, "vi", "ja")); + + assertThat(policy.customerFacing()).isFalse(); + assertThat(policy.formalRequested()).isFalse(); + assertThat(policy.bedrockPolishEnabled()).isFalse(); + } + + @Test + void staffInternalIntentIsCasualAndUnpolished() { + TranslationPolicy policy = resolver.resolve(new TranslationRequestContext( + "issue-ticket.note", "STAFF_INTERNAL", false, "vi", "en")); + + assertThat(policy.customerFacing()).isFalse(); + assertThat(policy.formalRequested()).isFalse(); + assertThat(policy.bedrockPolishEnabled()).isFalse(); + } + + @Test + void nullIntentFallsBackToCustomerFacingFlag() { + TranslationPolicy policy = resolver.resolve(new TranslationRequestContext( + "anything", null, true, "vi", "ja")); + + assertThat(policy.customerFacing()).isTrue(); + assertThat(policy.formalRequested()).isTrue(); + } + + @Test + void formalToneOnlyRequestedForSupportedTargets() { + TranslationPolicy jaPolicy = resolver.resolve(new TranslationRequestContext( + "notification.body", "CUSTOMER_FACING_UI", false, "vi", "ja")); + TranslationPolicy enPolicy = resolver.resolve(new TranslationRequestContext( + "notification.body", "CUSTOMER_FACING_UI", false, "vi", "en")); + + assertThat(jaPolicy.formalRequested()).isTrue(); + assertThat(enPolicy.formalRequested()) + .as("English is not in AWS Translate's formality-supported list") + .isFalse(); + } + + @Test + void bedrockDisabledGloballyOverridesIntent() { + properties.getCustomerFacing().setBedrockEnabled(false); + TranslationPolicy policy = resolver.resolve(new TranslationRequestContext( + "notification.title", "CUSTOMER_FACING_UI", false, "vi", "ja")); + + assertThat(policy.customerFacing()).isTrue(); + assertThat(policy.bedrockPolishEnabled()).isFalse(); + } +} From bceb72e21a8de2bb776e554310d631bf63338c76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tr=E1=BA=A7n=20=C4=90=E1=BB=A9c=20Hi=E1=BB=87u?= Date: Fri, 8 May 2026 02:49:08 +0700 Subject: [PATCH 7/7] Handle same-language scenarios in translation pipeline by skipping processing. --- .../CustomerFacingTranslationPolisher.java | 36 +++++++++++++++++-- .../services/TranslationOrchestrator.java | 13 +++++++ 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/isums/aiservice/services/CustomerFacingTranslationPolisher.java b/src/main/java/com/isums/aiservice/services/CustomerFacingTranslationPolisher.java index 913b2d6..7481103 100644 --- a/src/main/java/com/isums/aiservice/services/CustomerFacingTranslationPolisher.java +++ b/src/main/java/com/isums/aiservice/services/CustomerFacingTranslationPolisher.java @@ -37,6 +37,12 @@ public CustomerFacingPolishResult polish( return new CustomerFacingPolishResult(candidateTranslation, false); } + String src = context.sourceLanguage() == null ? "" : context.sourceLanguage().trim().toLowerCase(Locale.ROOT); + String tgt = context.targetLanguage() == null ? "" : context.targetLanguage().trim().toLowerCase(Locale.ROOT); + if (!src.isEmpty() && src.equals(tgt)) { + return new CustomerFacingPolishResult(candidateTranslation, false); + } + try { Message message = Message.builder() .role(ConversationRole.USER) @@ -80,9 +86,20 @@ private String buildSystemPrompt(TranslationRequestContext context) { 2. Preserve exact business meaning, requested action, nouns, verbs, and factual scope. 3. Do not add promises, deadlines, apologies, blame, workflow terms, or new facts. 4. Do not replace nouns or actions with different ones unless the source text explicitly contains them. -5. Keep domain terms stable: tenant, landlord, manager, issue, inspection, repair, contract. -6. Make the smallest possible edits needed for a polite customer-facing result. -7. Return only the final translated text. No notes, no JSON, no quotes. +5. Keep domain terms stable in any language. Equivalences (do NOT swap across rows): + - tenant ↔ khách thuê ↔ テナント + - landlord ↔ chủ nhà ↔ 家主 + - manager ↔ quản lý ↔ 管理者 + - issue ↔ sự cố ↔ 問題 + - inspection ↔ kiểm tra ↔ 検査 + - repair ↔ sửa chữa ↔ 修理 + - contract ↔ hợp đồng ↔ 契約 + Never replace "khách thuê" with "khách sạn" (hotel) — they are different concepts. +6. Preserve proper nouns and Vietnam-specific abbreviations exactly: CCCD, CMND, EVN, VNPT, VND, OTP, SSO. +7. Preserve any token matching the patterns EContract_*, /contracts/*, #XXXXXXXX, dates, numbers, and email addresses character-for-character. +8. Make the smallest possible edits needed for a polite customer-facing result. +9. If the candidate translation already differs in core nouns from the source text, restore the source's nouns. Do not invent new ones. +10. Return only the final translated text. No notes, no JSON, no quotes. """); String targetLanguage = context.targetLanguage() == null ? "" : context.targetLanguage().trim().toLowerCase(Locale.ROOT); @@ -106,6 +123,19 @@ private String buildSystemPrompt(TranslationRequestContext context) { Example: Candidate: I have checked, please confirm to proceed with repair. Final: I have checked it. Please confirm so we can proceed with the repair. +"""); + } else if ("vi".equals(targetLanguage)) { + prompt.append(""" +Vietnamese style guide: +- Use polite customer-service register, modern Vietnamese. +- Keep these terms unchanged: khách thuê, chủ nhà, quản lý, sự cố, kiểm tra, sửa chữa, hợp đồng, CCCD, CMND, EVN, VNPT, VND, OTP. +- Do NOT change "khách thuê" to "khách sạn", "khách hàng", or any other noun. +- Do NOT split or alter compound nouns like "Hợp đồng" — keep both syllables. + +Example: +Candidate: Khách sạn đã xác nhận CCCD +Source: Khách thuê đã xác nhận CCCD +Final: Khách thuê đã xác nhận CCCD """); } diff --git a/src/main/java/com/isums/aiservice/services/TranslationOrchestrator.java b/src/main/java/com/isums/aiservice/services/TranslationOrchestrator.java index b8a8058..27ebbd2 100644 --- a/src/main/java/com/isums/aiservice/services/TranslationOrchestrator.java +++ b/src/main/java/com/isums/aiservice/services/TranslationOrchestrator.java @@ -72,6 +72,19 @@ public TranslationOutcome translateOne( String normalizedTarget = localeSupport.normalize(targetLanguage); boolean customerFacingFlag = Boolean.TRUE.equals(customerFacing); + if (normalizedSource != null + && normalizedTarget != null + && normalizedSource.equals(normalizedTarget)) { + incrementRequest(resourceType, normalizedTarget, "SKIPPED", "skip"); + return new TranslationOutcome( + normalizedSource, + normalizedTarget, + text, + "noop-same-language", + "SKIPPED", + "source equals target"); + } + TranslationOutcome cached = cache.getIfPresent( text, normalizedSource, normalizedTarget, intent, customerFacingFlag); if (cached != null) {