View Javadoc
1   package com.acumenvelocity.ath.steps;
2   
3   import java.util.ArrayList;
4   import java.util.Iterator;
5   import java.util.List;
6   import java.util.Spliterator;
7   import java.util.Spliterators;
8   import java.util.stream.StreamSupport;
9   
10  import com.acumenvelocity.ath.common.AthUtil;
11  import com.acumenvelocity.ath.common.Const;
12  import com.acumenvelocity.ath.common.ControllerUtil;
13  import com.acumenvelocity.ath.common.Log;
14  import com.acumenvelocity.ath.common.OkapiUtil;
15  import com.acumenvelocity.ath.common.OriginalTuAnnotation;
16  import com.acumenvelocity.ath.model.MtResources;
17  import com.acumenvelocity.ath.mt.confidence.ConfidenceScoredTranslation;
18  import com.acumenvelocity.ath.mt.confidence.HybridDataStructures.HybridEvaluationResult;
19  import com.acumenvelocity.ath.mt.confidence.HybridDataStructures.HybridScoredSegment;
20  import com.acumenvelocity.ath.mt.confidence.HybridDataStructures.HybridTranslationScore;
21  import com.acumenvelocity.ath.mt.confidence.HybridQualityEstimator;
22  
23  import net.sf.okapi.common.Event;
24  import net.sf.okapi.common.IResource;
25  import net.sf.okapi.common.Util;
26  import net.sf.okapi.common.annotation.AltTranslation;
27  import net.sf.okapi.common.annotation.AltTranslationsAnnotation;
28  import net.sf.okapi.common.query.MatchType;
29  import net.sf.okapi.common.resource.ISegments;
30  import net.sf.okapi.common.resource.ITextUnit;
31  import net.sf.okapi.common.resource.Segment;
32  import net.sf.okapi.common.resource.TextContainer;
33  import net.sf.okapi.common.resource.TextFragment;
34  import net.sf.okapi.common.resource.TextFragmentUtil;
35  import net.sf.okapi.lib.translation.QueryUtil;
36  
37  /**
38   * MT Confidence Scoring Step for Okapi Framework v1.47.0
39   * 
40   * Translates source segments using multiple MT models (Google Cloud Translate v3 NMT,
41   * Translation LLM, and custom AutoML models), evaluates translation quality using
42   * Vertex AI (MetricX) and heuristic methods, then attaches confidence-scored
43   * alternate translations to each segment.
44   */
45  public class MtConfidenceScoringStep extends BaseTuBatchProcessingStep {
46  
47    private final List<MtResources> mtCustomResources;
48    private final List<MtResources> modelConfigs = new ArrayList<>();
49    private final List<String> sourceTexts = new ArrayList<>();
50    private final List<TextFragment> sourceTfs = new ArrayList<>();
51    private final List<SegmentInfo> segmentInfos = new ArrayList<>();
52    private final boolean mtSendPlainText;
53    private final QueryUtil qutil = new QueryUtil();
54  
55    private HybridEvaluationResult results;
56  
57    /**
58     * Creates a new MT Confidence Scoring Step.
59     * 
60     * @param mtCustomResources List of user's custom models and glossaries
61     */
62    public MtConfidenceScoringStep(List<MtResources> mtCustomResources, boolean mtSendPlainText) {
63      super();
64      this.mtCustomResources = mtCustomResources != null ? mtCustomResources : new ArrayList<>();
65      this.mtSendPlainText = mtSendPlainText;
66    }
67  
68    public String getName() {
69      return "MT Confidence Scoring";
70    }
71  
72    @Override
73    public String getDescription() {
74      return "Machine translates source segments using Google Cloud Translate API v3 "
75          + "(NMT, Translation LLM) and custom AutoML models/glossaries, then uses "
76          + "Google Vertex AI evaluation service and heuristics to calculate confidence "
77          + "scores for those translations.";
78    }
79  
80    private String buildGeneralModelId(String modelId) {
81      return String.format("projects/%s/locations/%s/models/%s", ControllerUtil.getProjectId(),
82          Const.ATH_GCP_PROJECT_LOCATION, modelId);
83    }
84  
85    /**
86     * Initialize model configurations with built-in Google models + custom models
87     */
88    private void initializeModelConfigs() {
89      modelConfigs.clear();
90  
91      // Add Google Cloud Translate v3 built-in models
92  
93      // 1. NMT (Neural Machine Translation) - standard model
94      MtResources nmt = new MtResources();
95  
96      nmt.setMtModelId(buildGeneralModelId("general/nmt"));
97      modelConfigs.add(nmt);
98  
99      // 2. Translation LLM - advanced model
100     MtResources translationLlm = new MtResources();
101 
102     translationLlm.setMtModelId(buildGeneralModelId("general/translation-llm"));
103     modelConfigs.add(translationLlm);
104 
105     // 3. Add user's custom models and glossaries
106     if (!Util.isEmpty(mtCustomResources)) {
107       modelConfigs.addAll(mtCustomResources);
108     }
109 
110     Log.info(MtConfidenceScoringStep.class,
111         "Initialized {} model configurations (2 built-in + {} custom)",
112         modelConfigs.size(), mtCustomResources != null ? mtCustomResources.size() : 0);
113   }
114 
115   /**
116    * Pre-process text units: collect source segments
117    */
118   private void preProcessTextUnit(ITextUnit tu) {
119     TextContainer source = tu.getSource();
120 
121     if (source == null) {
122       Log.error(getClass(), "Source of TU '{}' is null", tu.getId());
123       return;
124     }
125 
126     TextContainer target = tu.getTarget(getTargetLocale());
127     ISegments targetSegments = target == null ? null : target.getSegments();
128 
129     // Collect source TFs to be translated
130     for (Segment segment : source.getSegments()) {
131       TextFragment srcTf = segment.getContent();
132 
133       // Skip empty segments
134       if (srcTf == null || srcTf.isEmpty()) {
135         Log.trace(getClass(), "Skipping empty segment in TU '{}'", tu.getId());
136         continue;
137       }
138 
139       if (targetSegments != null) {
140         Segment tseg = targetSegments.get(segment.getId());
141 
142         if (tseg != null && tseg.getContent() != null && !tseg.getContent().isEmpty()) {
143           // Already translated by a previous leveraging step
144           continue;
145         }
146       }
147 
148       sourceTfs.add(srcTf.clone());
149     }
150 
151     if (mtSendPlainText) {
152       // Store the original TU with codes, remove codes to improve MT quality
153       // CodesReinsertionStep will use OriginalTuAnnotation to get the original source codes
154       tu.setAnnotation(new OriginalTuAnnotation(tu.clone(), getSourceLocale()));
155       OkapiUtil.removeCodes(tu, true);
156     }
157 
158     // Process each segment in the text unit -- populate a list of texts
159     for (Segment segment : source.getSegments()) {
160       TextFragment srcTf = segment.getContent();
161 
162       // Skip empty segments
163       if (srcTf == null || srcTf.isEmpty()) {
164         Log.trace(getClass(), "Skipping empty segment in TU '{}'", tu.getId());
165         continue;
166       }
167       
168       if (targetSegments != null) {
169         Segment tseg = targetSegments.get(segment.getId());
170 
171         if (tseg != null && tseg.getContent() != null && !tseg.getContent().isEmpty()) {
172           // Already translated by a previous leveraging step
173           continue;
174         }
175       }
176 
177       String sourceText = null;
178 
179       if (mtSendPlainText) {
180         sourceText = srcTf.getText();
181 
182       } else {
183         sourceText = qutil.toCodedHTML(srcTf);
184       }
185 
186       sourceTexts.add(sourceText);
187 
188       // Store segment info for later mapping
189       segmentInfos.add(new SegmentInfo(tu, segment.getId()));
190 
191       Log.trace(getClass(), "Collected segment [{}]: '{}'",
192           sourceTexts.size() - 1, sourceText);
193     }
194   }
195 
196   /**
197    * Post-process text units: apply translations with confidence scores
198    */
199   private void postProcessTextUnits(HybridEvaluationResult results) {
200     if (results == null || results.getSegments().isEmpty()) {
201       Log.warn(getClass(), "No evaluation results available");
202       return;
203     }
204 
205     List<HybridScoredSegment> scoredSegments = results.getSegments();
206 
207     if (scoredSegments.size() != segmentInfos.size()) {
208       Log.error(getClass(),
209           "Mismatch: {} scored segments but {} segment infos",
210           scoredSegments.size(), segmentInfos.size());
211 
212       return;
213     }
214 
215     // Process each scored segment
216     for (int i = 0; i < scoredSegments.size(); i++) {
217       HybridScoredSegment scoredSegment = scoredSegments.get(i);
218       SegmentInfo segInfo = segmentInfos.get(i);
219 
220       ITextUnit tu = segInfo.textUnit;
221       String segmentId = segInfo.segmentId;
222 
223       // Get or create target container
224       TextContainer target = tu.getTarget(getTargetLocale());
225 
226       if (target == null) {
227         target = tu.createTarget(getTargetLocale(), false, IResource.COPY_SEGMENTATION);
228         Log.trace(getClass(), "Created target container for TU '{}'", tu.getId());
229       }
230 
231       // Get or create target segment
232       Segment targetSegment = target.getSegments().get(segmentId);
233 
234       if (targetSegment == null) {
235         targetSegment = new Segment(segmentId);
236         target.append(targetSegment);
237         Log.trace(getClass(), "Created target segment '{}' in TU '{}'", segmentId, tu.getId());
238       }
239 
240       // Get all scored translations for this segment
241       List<HybridTranslationScore> scores = scoredSegment.getScores();
242 
243       if (scores.isEmpty()) {
244         Log.warn(getClass(), "No translations available for segment {} in TU '{}'",
245             i, tu.getId());
246 
247         continue;
248       }
249 
250       // Create or get AltTranslationsAnnotation for this segment
251       AltTranslationsAnnotation ata = targetSegment.getAnnotation(
252           AltTranslationsAnnotation.class);
253 
254       if (ata == null) {
255         ata = new AltTranslationsAnnotation();
256         targetSegment.setAnnotation(ata);
257         Log.trace(getClass(), "Created AltTranslationsAnnotation for segment '{}'", segmentId);
258       }
259 
260       // Add all translations as alternate translations with confidence scores
261       // TextFragment sourceTf = new TextFragment(scoredSegment.getSourceText());
262       TextFragment sourceTf = scoredSegment.getSourceTf();
263 
264       for (HybridTranslationScore score : scores) {
265         String targetText = score.getTranslation();
266         TextFragment targetTf = null;
267 
268         if (mtSendPlainText) {
269           targetTf = new TextFragment(targetText);
270 
271         } else {
272           targetTf = qutil.fromCodedHTMLToFragment(targetText, null);
273         }
274 
275         OkapiUtil.removeExtraCodes(sourceTf.getCodes(), targetTf);
276 
277         // Align codes and copy metadata from source to target
278         TextFragmentUtil.alignAndCopyCodeMetadata(sourceTf, targetTf, true, true);
279 
280         // Rearrange opening and closing codes
281         OkapiUtil.rearrangeCodes(sourceTf.getCodes(), targetTf);
282 
283         // Create ConfidenceScoredTranslation with all available scores
284         ConfidenceScoredTranslation cst = new ConfidenceScoredTranslation(
285             getSourceLocale(),
286             getTargetLocale(),
287             sourceTf,
288             sourceTf, // alternate source same as original for MT
289             targetTf,
290             MatchType.MT, // All are machine translations
291             AthUtil.extractLastSection(score.getModelId()),
292             score.getConfidence());
293 
294         // Add metadata
295         if (score.isAnomalyFlagged()) {
296           cst.setEngine("ANOMALY: " + score.getAnomalyReason());
297 
298         } else {
299           cst.setEngine(score.getMethod());
300         }
301 
302         ata.add(cst);
303 
304         Log.trace(getClass(),
305             "Added alt-trans [{}] confidence={} MetricX={} Heuristic={}: '{}'",
306             score.getModelId(),
307             score.getConfidence(),
308             score.getMetricXScore() != null ? String.format("%.2f", score.getMetricXScore())
309                 : "N/A",
310 
311             score.getHeuristicScore() != null ? String.format("%.3f", score.getHeuristicScore())
312                 : "N/A",
313 
314             score.getTranslation());
315       }
316 
317       // Sort by confidence (already sorted, but ensure it)
318       ata.sort();
319 
320       Iterator<AltTranslation> iter = ata.iterator();
321 
322       ConfidenceScoredTranslation bestCst = (ConfidenceScoredTranslation) StreamSupport.stream(
323           Spliterators.spliteratorUnknownSize(iter, Spliterator.ORDERED),
324           false)
325           .filter(at -> at instanceof ConfidenceScoredTranslation)
326           .findFirst()
327           .orElse(null);
328 
329       if (bestCst != null) {
330         TextFragment bestTranslation = bestCst.getTarget().getFirstContent();
331         targetSegment.setContent(bestTranslation);
332 
333         Log.debug(getClass(),
334             "Set best translation for TU '{}' segment '{}': '{}' (confidence: {})",
335             tu.getId(), segmentId, bestTranslation, bestCst.getConfidenceScore());
336       }
337     }
338   }
339 
340   /**
341    * Convert 0-1 double confidence to 0-100 integer percentage
342    */
343   public static int convertToPercentage(double confidence) {
344     return (int) Math.round(confidence * 100.0);
345   }
346 
347   @Override
348   protected void clear() {
349     sourceTexts.clear();
350     segmentInfos.clear();
351     results = null;
352   }
353 
354   @Override
355   protected void processTuEvents(List<Event> tuEvents) {
356     initializeModelConfigs();
357 
358     // Step 1: Pre-process all text units
359     Log.info(getClass(), "Pre-processing {} text units...", tuEvents.size());
360 
361     for (Event tue : tuEvents) {
362       ITextUnit tu = tue.getTextUnit();
363       preProcessTextUnit(tu);
364     }
365 
366     Log.info(getClass(), "Collected {} source segment(s) from {} text unit(s)",
367         sourceTexts.size(), tuEvents.size());
368 
369     // Step 2: Evaluate translations with all models
370     if (!sourceTexts.isEmpty()) {
371       try (HybridQualityEstimator hqe = new HybridQualityEstimator(
372           ControllerUtil.getProjectId(),
373           Const.ATH_GCP_PROJECT_LOCATION,
374           mtSendPlainText)) {
375 
376         Log.info(getClass(),
377             "Evaluating translations: {} segment(s), {} models, {}→{}",
378             sourceTexts.size(),
379             modelConfigs.size(),
380             getSourceLocale(),
381             getTargetLocale());
382 
383         results = hqe.evaluateTranslations(
384             sourceTexts,
385             sourceTfs,
386             getSourceLocale().toString(),
387             getTargetLocale().toString(),
388             modelConfigs);
389 
390         Log.info(getClass(),
391             "Evaluation complete using strategy: {}",
392             results.getStrategy());
393 
394       } catch (Exception e) {
395         Log.error(getClass(), "Error during translation evaluation: {}", e.getMessage(), e);
396         // Continue with empty results - segments will remain untranslated
397       }
398 
399     } else {
400       Log.warn(getClass(), "No source segments to translate");
401     }
402 
403     // Step 3: Post-process text units with results
404     if (results != null) {
405       Log.info(getClass(), "Post-processing text units with evaluation results...");
406       postProcessTextUnits(results);
407     }
408 
409     Log.info(getClass(),
410         "MT Confidence Scoring complete: processed {} TU(s), {} segment(s)",
411         getNumProcessedTus(), sourceTexts.size());
412   }
413 }