diff --git a/build.gradle b/build.gradle index 7595485..9c13e04 100644 --- a/build.gradle +++ b/build.gradle @@ -3,6 +3,22 @@ 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" +} group = 'com.isums' version = '0.0.1-SNAPSHOT' @@ -15,21 +31,54 @@ 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 "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' + 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.projectlombok:lombok' - testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test' - testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + 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' +} + +dependencyManagement { + imports { + mavenBom "org.springframework.cloud:spring-cloud-dependencies:$springCloudVersion" + } } 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/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/InternalAuthFilter.java b/src/main/java/com/isums/aiservice/configs/InternalAuthFilter.java new file mode 100644 index 0000000..de769db --- /dev/null +++ b/src/main/java/com/isums/aiservice/configs/InternalAuthFilter.java @@ -0,0 +1,88 @@ +package com.isums.aiservice.configs; + +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/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/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/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/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/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/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/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/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/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/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/ModelStoreService.java b/src/main/java/com/isums/aiservice/infrastructures/abstracts/ModelStoreService.java index eef7fa2..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,9 +2,7 @@ import com.isums.aiservice.domains.dtos.ModelBundle; -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/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/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/IssueTextTranslationListener.java b/src/main/java/com/isums/aiservice/infrastructures/listeners/IssueTextTranslationListener.java new file mode 100644 index 0000000..81e8b9f --- /dev/null +++ b/src/main/java/com/isums/aiservice/infrastructures/listeners/IssueTextTranslationListener.java @@ -0,0 +1,91 @@ +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.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; +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 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); + 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(), + 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()) + .resourceType(event.resourceType()) + .resourceId(event.resourceId()) + .sourceLanguage(translated.sourceLanguage()) + .targetLanguage(translated.targetLanguage()) + .translatedText(refinedText) + .provider(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/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/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..7481103 --- /dev/null +++ b/src/main/java/com/isums/aiservice/services/CustomerFacingTranslationPolisher.java @@ -0,0 +1,163 @@ +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); + } + + 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) + .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 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); + 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. +"""); + } 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 +"""); + } + + 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/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/IssueTranslationPostProcessor.java b/src/main/java/com/isums/aiservice/services/IssueTranslationPostProcessor.java new file mode 100644 index 0000000..d290502 --- /dev/null +++ b/src/main/java/com/isums/aiservice/services/IssueTranslationPostProcessor.java @@ -0,0 +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; + +@Component +public class IssueTranslationPostProcessor { + + public String refine(String translatedText, TranslationRequestContext context, TranslationPolicy policy) { + if (translatedText == null || translatedText.isBlank()) { + return translatedText; + } + + 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 (policy != null && policy.customerFacing() && context != null && "ja".equalsIgnoreCase(context.targetLanguage())) { + refined = refined + .replace("お願い致します", "お願いいたします") + .replace("下さい", "ください"); + } + return refined; + } +} 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); + } } 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/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/TranslationOrchestrator.java b/src/main/java/com/isums/aiservice/services/TranslationOrchestrator.java new file mode 100644 index 0000000..27ebbd2 --- /dev/null +++ b/src/main/java/com/isums/aiservice/services/TranslationOrchestrator.java @@ -0,0 +1,176 @@ +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); + + 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) { + 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 new file mode 100644 index 0000000..327ef9f --- /dev/null +++ b/src/main/java/com/isums/aiservice/services/TranslationPolicyResolver.java @@ -0,0 +1,68 @@ +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 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()); + 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(); + + return new TranslationPolicy( + customerFacing, + formalRequested, + bedrockPolish, + terminologyNames + ); + } + + private static String normalizeIntent(String raw) { + if (raw == null || raw.isBlank()) return null; + return raw.trim().toUpperCase(Locale.ROOT); + } +} 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/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/IssueTextTranslationListenerTest.java b/src/test/java/com/isums/aiservice/infrastructures/listeners/IssueTextTranslationListenerTest.java new file mode 100644 index 0000000..f1513d5 --- /dev/null +++ b/src/test/java/com/isums/aiservice/infrastructures/listeners/IssueTextTranslationListenerTest.java @@ -0,0 +1,87 @@ +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; +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.ArgumentMatchers.eq; +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 TranslationPolicyResolver translationPolicyResolver; + @Mock private CustomerFacingTranslationPolisher customerFacingTranslationPolisher; + @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, + translationPolicyResolver, + customerFacingTranslationPolisher, + new IssueTranslationPostProcessor() + ); + + UUID resourceId = UUID.randomUUID(); + UUID requestId = UUID.randomUUID(); + 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, + "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() + )); + + 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().provider()).contains("bedrock-polish"); + assertThat(cap.getValue().status()).isEqualTo("DONE"); + verify(acknowledgment).acknowledge(); + } +} 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/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 new file mode 100644 index 0000000..263d5db --- /dev/null +++ b/src/test/java/com/isums/aiservice/services/IssueTranslationPostProcessorTest.java @@ -0,0 +1,50 @@ +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; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("IssueTranslationPostProcessor") +class IssueTranslationPostProcessorTest { + + private final IssueTranslationPostProcessor processor = new IssueTranslationPostProcessor(); + + @Test + @DisplayName("normalizes Japanese customer-facing wording variants") + void normalizesJapaneseCustomerFacingWording() { + String result = processor.refine( + "ご確認をお願い致します。資料を送って下さい。", + new TranslationRequestContext("RESPONSE", "QUESTION_RESPONSE", true, "vi", "ja"), + new TranslationPolicy(true, true, true, java.util.List.of()) + ); + + assertThat(result).isEqualTo("ご確認をお願いいたします。資料を送ってください。"); + } + + @Test + @DisplayName("smooths awkward English confirmation phrasing") + void smoothsAwkwardEnglishConfirmation() { + String result = processor.refine( + "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("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( + "The light has been replaced", + 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"); + } +} 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(); + } +}