1 package net.sf.okapi.steps.heuristicaligner;
2
3 import java.util.ArrayList;
4 import java.util.Collections;
5 import java.util.HashMap;
6 import java.util.HashSet;
7 import java.util.List;
8 import java.util.Map;
9 import java.util.Set;
10 import java.util.regex.Matcher;
11 import java.util.regex.Pattern;
12 import java.util.stream.Collectors;
13
14 import org.slf4j.Logger;
15 import org.slf4j.LoggerFactory;
16
17 import com.acumenvelocity.ath.common.Const;
18 import com.acumenvelocity.ath.common.ControllerUtil;
19 import com.acumenvelocity.ath.gct.v3.AthTranslation;
20 import com.google.cloud.translate.v3.LocationName;
21 import com.google.cloud.translate.v3.TranslateTextRequest;
22 import com.google.cloud.translate.v3.TranslateTextResponse;
23 import com.google.cloud.translate.v3.TranslationServiceClient;
24
25 import net.sf.okapi.common.LocaleId;
26 import net.sf.okapi.common.MimeTypeMapper;
27 import net.sf.okapi.common.resource.ITextUnit;
28 import net.sf.okapi.common.resource.Segment;
29 import net.sf.okapi.common.resource.TextContainer;
30 import net.sf.okapi.common.resource.TextFragment;
31
32
33
34
35
36
37
38
39 public class HeuristicAligner {
40
41 private final Logger LOGGER = LoggerFactory.getLogger(getClass());
42
43 public static final double PARAGRAPH_MATCH_THRESHOLD = 0.3;
44 private static final double SENTENCE_MATCH_THRESHOLD = 0.4;
45 private static final double GAP_PENALTY = -0.5;
46 private static final double LENGTH_RATIO_MIN = 0.3;
47 private static final double LENGTH_RATIO_MAX = 3.0;
48
49 private boolean translationAwareResegmentation = false;
50 private TranslationServiceClient translationClient = AthTranslation.getClient();
51 private static final String PROJECT_ID = ControllerUtil.getProjectId();
52 private static final int MAX_BATCH_SIZE = 100;
53
54
55
56 private static final Pattern BROKEN_PARAGRAPH_PATTERN = Pattern.compile(
57 ".*[.!?\\uFF0E\\uFF01\\uFF1F]\\p{L}.*");
58
59
60 private static final Pattern SENTENCE_BREAK_PATTERN = Pattern.compile(
61 "[.!?\\uFF0E\\uFF01\\uFF1F](?=\\p{L})");
62
63
64 private static final Pattern NUMBER_DOT_LETTER_PATTERN = Pattern.compile(
65 "\\d\\.(?=\\p{L})");
66
67 private static final Pattern NUMBER_PATTERN = Pattern.compile("(\\d[\\d,.]*)");
68
69 public void setTranslationAwareResegmentation(boolean enabled) {
70 this.translationAwareResegmentation = enabled;
71 }
72
73
74
75
76
77 public double calculateParagraphSimilarity(String src, String trg,
78 LocaleId srcLocale, LocaleId trgLocale) {
79
80 if (src.isEmpty() || trg.isEmpty()) {
81 return 0.0;
82 }
83
84 double score = 0.0;
85
86
87 String backTrg = backTranslate(trg, trgLocale, srcLocale);
88 double ngramScore = calculateNGramSimilarity(src, backTrg.toLowerCase().trim(), 3);
89 score += ngramScore * 0.4;
90
91
92 double lengthScore = calculateLengthScore(src.length(), trg.length());
93 score += lengthScore * 0.2;
94
95 double numberScore = calculateNumberPreservation(src, trg);
96 score += numberScore * 0.2;
97
98 double punctScore = calculatePunctuationConsistency(src, trg);
99 score += punctScore * 0.1;
100
101 double wordScore = calculateWordOverlap(src, trg);
102 score += wordScore * 0.1;
103
104 return score;
105 }
106
107
108
109
110 private String backTranslate(String text, LocaleId from, LocaleId to) {
111 if (translationClient == null || text.isEmpty()) {
112 return text;
113 }
114 try {
115 TranslateTextRequest request = TranslateTextRequest.newBuilder()
116 .setParent(LocationName.of(PROJECT_ID, Const.ATH_GCP_PROJECT_LOCATION).toString())
117 .addContents(text)
118 .setSourceLanguageCode(from.toBCP47())
119 .setTargetLanguageCode(to.toBCP47())
120 .setMimeType(MimeTypeMapper.PLAIN_TEXT_MIME_TYPE)
121 .build();
122
123 TranslateTextResponse response = translationClient.translateText(request);
124 return response.getTranslations(0).getTranslatedText();
125
126 } catch (Exception e) {
127 LOGGER.warn("Back translation failed: {}", e.getMessage());
128 return text;
129 }
130 }
131
132
133
134
135 public Map<String, String> batchBackTranslate(List<String> texts, LocaleId from, LocaleId to) {
136 Map<String, String> result = new HashMap<>();
137
138 if (translationClient == null || texts.isEmpty()) {
139 texts.forEach(t -> result.put(t, t));
140 return result;
141 }
142
143 try {
144 for (int i = 0; i < texts.size(); i += MAX_BATCH_SIZE) {
145 List<String> batch = texts.subList(i, Math.min(i + MAX_BATCH_SIZE, texts.size()));
146
147 TranslateTextRequest req = TranslateTextRequest.newBuilder()
148 .setParent(LocationName.of(PROJECT_ID, Const.ATH_GCP_PROJECT_LOCATION).toString())
149 .addAllContents(batch)
150 .setSourceLanguageCode(from.toBCP47())
151 .setTargetLanguageCode(to.toBCP47())
152 .setMimeType(MimeTypeMapper.PLAIN_TEXT_MIME_TYPE)
153 .build();
154
155 TranslateTextResponse resp = translationClient.translateText(req);
156
157 for (int j = 0; j < batch.size(); j++) {
158 result.put(batch.get(j), resp.getTranslations(j).getTranslatedText());
159 }
160 }
161
162 } catch (Exception e) {
163 LOGGER.warn("Back-translation failed", e);
164 texts.forEach(t -> result.put(t, t));
165 }
166
167 return result;
168 }
169
170
171
172
173 public List<HeuristicSentenceAlignerStep.SentenceMatch> alignSentencesInTu(
174 ITextUnit tu, LocaleId sourceLocale, LocaleId targetLocale) {
175
176 TextContainer sourceTC = tu.getSource();
177 TextContainer targetTC = tu.getTarget(targetLocale);
178
179 if (sourceTC == null || targetTC == null || sourceTC.getSegments().count() == 0) {
180 return Collections.emptyList();
181 }
182
183 List<Segment> sourceSegments = new ArrayList<>(sourceTC.getSegments().asList());
184 List<Segment> targetSegments = new ArrayList<>(targetTC.getSegments().asList());
185
186
187 if (translationAwareResegmentation) {
188 resegmentIfBroken(tu, sourceSegments, targetSegments, sourceLocale, targetLocale);
189 }
190
191
192 List<String> sourcePlain = sourceSegments.stream()
193 .map(seg -> stripCodes(seg.getContent()).trim())
194 .collect(Collectors.toList());
195
196 List<String> targetPlain = targetSegments.stream()
197 .map(seg -> stripCodes(seg.getContent()).trim())
198 .collect(Collectors.toList());
199
200
201 double[][] matrix = buildSimilarityMatrix(sourcePlain, targetPlain, sourceLocale, targetLocale);
202
203
204 int m = sourcePlain.size();
205 int n = targetPlain.size();
206 double[][] dp = new double[m + 1][n + 1];
207 int[][] trace = new int[m + 1][n + 1];
208
209 for (int i = 1; i <= m; i++) {
210 dp[i][0] = dp[i - 1][0] + GAP_PENALTY;
211 trace[i][0] = 2;
212 }
213 for (int j = 1; j <= n; j++) {
214 dp[0][j] = dp[0][j - 1] + GAP_PENALTY;
215 trace[0][j] = 3;
216 }
217
218 for (int i = 1; i <= m; i++) {
219 for (int j = 1; j <= n; j++) {
220 double score = matrix[i - 1][j - 1];
221 double matchScore = score > SENTENCE_MATCH_THRESHOLD ? score : GAP_PENALTY;
222
223 double s11 = dp[i - 1][j - 1] + matchScore;
224 double s10 = dp[i - 1][j] + GAP_PENALTY;
225 double s01 = dp[i][j - 1] + GAP_PENALTY;
226
227 if (s11 >= s10 && s11 >= s01) {
228 dp[i][j] = s11;
229 trace[i][j] = 1;
230 } else if (s10 >= s01) {
231 dp[i][j] = s10;
232 trace[i][j] = 2;
233 } else {
234 dp[i][j] = s01;
235 trace[i][j] = 3;
236 }
237 }
238 }
239
240
241 List<HeuristicSentenceAlignerStep.SentenceMatch> result = new ArrayList<>();
242 int i = m, j = n;
243 while (i > 0 || j > 0) {
244 int move = trace[i][j];
245 if (move == 1) {
246 double score = matrix[i - 1][j - 1];
247 result.add(0, new HeuristicSentenceAlignerStep.SentenceMatch(i - 1, j - 1, score));
248 i--;
249 j--;
250 } else if (move == 2) {
251 result.add(0, new HeuristicSentenceAlignerStep.SentenceMatch(i - 1, -1, 0.0));
252 i--;
253 } else if (move == 3) {
254 j--;
255 } else {
256 break;
257 }
258 }
259 return result;
260 }
261
262 private void resegmentIfBroken(ITextUnit tu, List<Segment> srcSegs, List<Segment> trgSegs,
263 LocaleId srcLoc, LocaleId trgLoc) {
264
265 String srcText = stripCodes(tu.getSource().createJoinedContent());
266 String trgText = stripCodes(tu.getTarget(trgLoc).createJoinedContent());
267
268 boolean srcBroken = srcSegs.size() <= 3 && BROKEN_PARAGRAPH_PATTERN.matcher(srcText).find();
269 boolean trgBroken = trgSegs.size() <= 3 && BROKEN_PARAGRAPH_PATTERN.matcher(trgText).find();
270
271 if (srcBroken) {
272 List<Integer> breaks = findBreaks(srcText);
273 if (!breaks.isEmpty()) {
274 tu.getSource().clear();
275 buildSegments(tu.getSource(), srcText, breaks);
276 }
277 }
278 if (trgBroken) {
279 List<Integer> breaks = findBreaks(trgText);
280 if (!breaks.isEmpty()) {
281 tu.getTarget(trgLoc).clear();
282 buildSegments(tu.getTarget(trgLoc), trgText, breaks);
283 }
284 }
285 }
286
287 private List<Integer> findBreaks(String text) {
288 Set<Integer> positions = new HashSet<>();
289
290 Matcher m = SENTENCE_BREAK_PATTERN.matcher(text);
291 while (m.find()) {
292 positions.add(m.end());
293 }
294
295 m = NUMBER_DOT_LETTER_PATTERN.matcher(text);
296 while (m.find()) {
297 positions.add(m.end());
298 }
299
300 List<Integer> sorted = new ArrayList<>(positions);
301 Collections.sort(sorted);
302
303 List<Integer> filtered = new ArrayList<>();
304 int last = 0;
305 for (int pos : sorted) {
306 if (pos - last > 20) {
307 filtered.add(pos);
308 last = pos;
309 }
310 }
311 return filtered;
312 }
313
314 private void buildSegments(TextContainer tc, String text, List<Integer> breaks) {
315 int start = 0;
316 for (int end : breaks) {
317 if (end > start && end <= text.length()) {
318 String part = text.substring(start, end).trim();
319 if (!part.isEmpty()) {
320 tc.getSegments().append(new Segment(null, new TextFragment(part)));
321 }
322 start = end;
323 }
324 }
325 if (start < text.length()) {
326 String last = text.substring(start).trim();
327 if (!last.isEmpty()) {
328 tc.getSegments().append(new Segment(null, new TextFragment(last)));
329 }
330 }
331 }
332
333 private double[][] buildSimilarityMatrix(List<String> src, List<String> trg,
334 LocaleId srcLoc, LocaleId trgLoc) {
335 int m = src.size(), n = trg.size();
336 double[][] matrix = new double[m][n];
337
338
339 Map<String, String> backTranslations = batchBackTranslate(trg, trgLoc, srcLoc);
340
341 for (int i = 0; i < m; i++) {
342 String s = src.get(i);
343 if (s.isEmpty())
344 continue;
345 for (int j = 0; j < n; j++) {
346 String t = trg.get(j);
347 if (t.isEmpty())
348 continue;
349
350 String back = backTranslations.getOrDefault(t, t);
351 matrix[i][j] = calculateSentenceSimilarity(s, back);
352 }
353 }
354 return matrix;
355 }
356
357
358
359
360 private double calculateSentenceSimilarity(String src, String trg) {
361 if (src.isEmpty() || trg.isEmpty()) {
362 return 0.0;
363 }
364
365 double score = 0.0;
366
367
368 double ngramScore = calculateNGramSimilarity(src, trg, 3);
369 score += ngramScore * 0.5;
370
371 double lengthScore = calculateLengthScore(src.length(), trg.length());
372 score += lengthScore * 0.2;
373
374 double numberScore = calculateNumberPreservation(src, trg);
375 score += numberScore * 0.3;
376
377 return score;
378 }
379
380
381
382
383 private double calculateNGramSimilarity(String text1, String text2, int n) {
384 Set<String> ngrams1 = extractNGrams(text1, n);
385 Set<String> ngrams2 = extractNGrams(text2, n);
386
387 if (ngrams1.isEmpty() || ngrams2.isEmpty()) {
388 return text1.equals(text2) ? 1.0 : 0.0;
389 }
390
391 Set<String> intersection = new HashSet<>(ngrams1);
392 intersection.retainAll(ngrams2);
393
394 Set<String> union = new HashSet<>(ngrams1);
395 union.addAll(ngrams2);
396
397 return (double) intersection.size() / union.size();
398 }
399
400 private Set<String> extractNGrams(String text, int n) {
401 Set<String> ngrams = new HashSet<>();
402 text = text.replaceAll("\\s+", " ").trim();
403
404 for (int i = 0; i <= text.length() - n; i++) {
405 ngrams.add(text.substring(i, i + n));
406 }
407
408 return ngrams;
409 }
410
411
412
413
414 private double calculateLengthScore(int srcLen, int trgLen) {
415 if (srcLen == 0 || trgLen == 0) {
416 return 0.0;
417 }
418
419 double ratio = (double) trgLen / srcLen;
420
421
422 if (ratio < LENGTH_RATIO_MIN || ratio > LENGTH_RATIO_MAX)
423 return 0.0;
424
425
426 double normalizedRatio = ratio < 1.0 ? ratio : 1.0 / ratio;
427
428
429 return Math.pow(normalizedRatio, 2);
430 }
431
432
433
434
435 private double calculateNumberPreservation(String src, String trg) {
436 List<String> srcNumbers = extractNumbers(src);
437
438 if (srcNumbers.isEmpty())
439 return 1.0;
440
441 List<String> trgNumbers = extractNumbers(trg);
442 int preserved = 0;
443
444 for (String num : srcNumbers) {
445 if (trgNumbers.contains(num)) {
446 preserved++;
447 }
448 }
449
450 return (double) preserved / srcNumbers.size();
451 }
452
453 private List<String> extractNumbers(String text) {
454 Matcher matcher = NUMBER_PATTERN.matcher(text);
455 List<String> numbers = new ArrayList<>();
456
457 while (matcher.find()) {
458 numbers.add(matcher.group(1));
459 }
460
461 return numbers;
462 }
463
464
465
466
467 private double calculatePunctuationConsistency(String src, String trg) {
468 Set<String> srcPunct = extractPunctuation(src);
469 Set<String> trgPunct = extractPunctuation(trg);
470
471 if (srcPunct.isEmpty() && trgPunct.isEmpty()) {
472 return 1.0;
473 }
474
475 if (srcPunct.isEmpty() != trgPunct.isEmpty()) {
476 return 0.5;
477 }
478
479 Set<String> intersection = new HashSet<>(srcPunct);
480 intersection.retainAll(trgPunct);
481
482 Set<String> union = new HashSet<>(srcPunct);
483 union.addAll(trgPunct);
484
485 return (double) intersection.size() / union.size();
486 }
487
488 private Set<String> extractPunctuation(String text) {
489 Set<String> punct = new HashSet<>();
490
491 for (char c : text.toCharArray()) {
492 if (String.valueOf(c).matches("\\p{Punct}")) {
493 punct.add(String.valueOf(c));
494 }
495 }
496
497 return punct;
498 }
499
500
501
502
503 private double calculateWordOverlap(String src, String trg) {
504 String[] srcWords = src.split("\\s+");
505 String[] trgWords = trg.split("\\s+");
506
507 Set<String> srcSet = new HashSet<>();
508
509 for (String word : srcWords) {
510 if (word.length() > 3) {
511 srcSet.add(word);
512 }
513 }
514
515 Set<String> trgSet = new HashSet<>();
516
517 for (String word : trgWords) {
518 if (word.length() > 3) {
519 trgSet.add(word);
520 }
521 }
522
523 if (srcSet.isEmpty() || trgSet.isEmpty())
524 return 0.0;
525
526 Set<String> intersection = new HashSet<>(srcSet);
527 intersection.retainAll(trgSet);
528
529 return (double) intersection.size() / Math.min(srcSet.size(), trgSet.size());
530 }
531
532 private String stripCodes(TextFragment tf) {
533 return tf.getText();
534 }
535 }