ngocdang83 commited on
Commit
884ebd7
·
verified ·
1 Parent(s): 566faa6

Add benchmark phase profile and paragraph chunk fix

Browse files
Files changed (3) hide show
  1. hachimimt-local.zip +2 -2
  2. src/benchmark_file.py +114 -0
  3. src/translator.py +130 -4
hachimimt-local.zip CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:aa891b8cf49e96a8ce2d9abdcd54dec984f4748aed6ee608ff347d97de070b92
3
- size 96397
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:9d639cba50e5ca3824f08f5026276012522bdc6cb1319ee7f73d049d0f997c51
3
+ size 95965
src/benchmark_file.py ADDED
@@ -0,0 +1,114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Benchmark local file translation throughput."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import time
7
+ from pathlib import Path
8
+
9
+ from app import read_text_file
10
+ from hardware import detect_hardware_profile
11
+ from text_preprocess import (
12
+ NORMALIZE_AUTO,
13
+ NORMALIZE_MODES,
14
+ normalization_message,
15
+ normalize_chinese_text,
16
+ )
17
+ from translator import Backend, HachimiTranslator
18
+
19
+
20
+ def parse_args() -> argparse.Namespace:
21
+ parser = argparse.ArgumentParser(description=__doc__)
22
+ parser.add_argument("path", type=Path, help="Input .txt file")
23
+ parser.add_argument("--model", default="HachimiMT-60")
24
+ parser.add_argument("--backend", choices=[Backend.CT2.value, Backend.TRANSFORMERS.value], default=Backend.CT2.value)
25
+ parser.add_argument("--beam", type=int, default=2)
26
+ parser.add_argument("--chunk-mode", choices=["sentence", "paragraph"], default="sentence")
27
+ parser.add_argument("--normalize", choices=sorted(NORMALIZE_MODES), default=NORMALIZE_AUTO)
28
+ parser.add_argument("--progress-seconds", type=float, default=15.0)
29
+ return parser.parse_args()
30
+
31
+
32
+ def main() -> None:
33
+ args = parse_args()
34
+ total_start = time.perf_counter()
35
+
36
+ profile = detect_hardware_profile()
37
+ print(f"BENCH_START file={args.path}", flush=True)
38
+ print(f"PROFILE {profile.summary}", flush=True)
39
+
40
+ read_start = time.perf_counter()
41
+ text = read_text_file(args.path)
42
+ read_s = time.perf_counter() - read_start
43
+ original_chars = len(text)
44
+ normalize_start = time.perf_counter()
45
+ normalized_text = normalize_chinese_text(text, args.normalize)
46
+ normalize_s = time.perf_counter() - normalize_start
47
+ normalize_msg = normalization_message(text, normalized_text, args.normalize)
48
+ text = normalized_text
49
+ print(
50
+ f"INPUT chars={len(text)} original_chars={original_chars} "
51
+ f"lines={text.count(chr(10)) + 1 if text else 0} read_s={read_s:.3f} "
52
+ f"normalize_s={normalize_s:.3f} normalize={args.normalize} "
53
+ f"normalize_msg={normalize_msg}",
54
+ flush=True,
55
+ )
56
+
57
+ translator = HachimiTranslator(profile)
58
+ load_start = time.perf_counter()
59
+ status = translator.load(args.model, backend=args.backend)
60
+ load_s = time.perf_counter() - load_start
61
+ print(f"LOAD seconds={load_s:.3f} status={status}", flush=True)
62
+
63
+ translate_start = time.perf_counter()
64
+ last_print = 0.0
65
+ rows = []
66
+ full_text = ""
67
+
68
+ for done, total, message, result_rows, result_text in translator.translate_text_iter(
69
+ text,
70
+ chunk_mode=args.chunk_mode,
71
+ beam_size=args.beam,
72
+ ):
73
+ now = time.perf_counter()
74
+ if result_rows is not None and result_text is not None:
75
+ rows = result_rows
76
+ full_text = result_text
77
+
78
+ if done == 0 or done == total or now - last_print >= args.progress_seconds:
79
+ elapsed = now - translate_start
80
+ rate = done / elapsed if elapsed > 0 else 0.0
81
+ eta = (total - done) / rate if rate > 0 else 0.0
82
+ pct = done / total * 100 if total else 100.0
83
+ print(
84
+ "PROGRESS "
85
+ f"done={done} total={total} pct={pct:.2f} elapsed_s={elapsed:.1f} "
86
+ f"rate_chunks_s={rate:.2f} eta_s={eta:.1f} message={message}",
87
+ flush=True,
88
+ )
89
+ last_print = now
90
+
91
+ translate_s = time.perf_counter() - translate_start
92
+ total_s = time.perf_counter() - total_start
93
+ profile = translator.last_profile
94
+ if profile:
95
+ profile_parts = []
96
+ for key in sorted(profile):
97
+ value = profile[key]
98
+ if isinstance(value, float):
99
+ profile_parts.append(f"{key}={value:.6f}")
100
+ else:
101
+ profile_parts.append(f"{key}={value}")
102
+ print("BENCH_PROFILE " + " ".join(profile_parts), flush=True)
103
+ print(
104
+ "BENCH_DONE "
105
+ f"total_s={total_s:.3f} translate_s={translate_s:.3f} minutes={total_s / 60:.3f} "
106
+ f"chunks={len(rows)} chars_in={len(text)} chars_out={len(full_text)} "
107
+ f"chunks_s={(len(rows) / translate_s) if translate_s > 0 else 0:.3f} "
108
+ f"chars_s={(len(text) / translate_s) if translate_s > 0 else 0:.1f}",
109
+ flush=True,
110
+ )
111
+
112
+
113
+ if __name__ == "__main__":
114
+ main()
src/translator.py CHANGED
@@ -3,6 +3,8 @@
3
  from __future__ import annotations
4
 
5
  import os
 
 
6
  from concurrent.futures import Future, ThreadPoolExecutor
7
  from dataclasses import dataclass
8
  from enum import Enum
@@ -309,6 +311,40 @@ class CT2SentencePieceTokenizer:
309
  def __init__(self, model_path: Path) -> None:
310
  self._source_sp = spm.SentencePieceProcessor(model_file=str(model_path / "source.spm"))
311
  self._target_sp = spm.SentencePieceProcessor(model_file=str(model_path / "target.spm"))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
312
 
313
  def _encode_one(
314
  self,
@@ -317,8 +353,7 @@ class CT2SentencePieceTokenizer:
317
  truncation: bool = False,
318
  max_length: int | None = None,
319
  ) -> list[int]:
320
- token_ids = list(self._source_sp.encode(text, out_type=int))
321
- token_ids.append(EOS_TOKEN_ID)
322
  if truncation and max_length is not None and len(token_ids) > max_length:
323
  token_ids = token_ids[:max_length]
324
  if token_ids:
@@ -388,6 +423,13 @@ class CT2SentencePieceTokenizer:
388
  decoded.append(self._target_sp.decode(pieces))
389
  return decoded
390
 
 
 
 
 
 
 
 
391
 
392
  class CT2FastTokenizer:
393
  """Minimal tokenizer.json wrapper for CTranslate2 inference."""
@@ -403,6 +445,39 @@ class CT2FastTokenizer:
403
  self._tokenizer = Tokenizer.from_file(str(model_path / "tokenizer.json"))
404
  self.pad_token_id = self._tokenizer.token_to_id("<pad>")
405
  self._eos_token_id = self._tokenizer.token_to_id("</s>")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
406
 
407
  def _encode_one(
408
  self,
@@ -411,7 +486,7 @@ class CT2FastTokenizer:
411
  truncation: bool = False,
412
  max_length: int | None = None,
413
  ) -> list[int]:
414
- token_ids = list(self._tokenizer.encode(text).ids)
415
  if truncation and max_length is not None and len(token_ids) > max_length:
416
  token_ids = token_ids[:max_length]
417
  if token_ids and self._eos_token_id is not None:
@@ -474,6 +549,13 @@ class CT2FastTokenizer:
474
  for token_ids in token_ids_batch
475
  ]
476
 
 
 
 
 
 
 
 
477
 
478
  def _load_ct2_tokenizer(model_path: Path):
479
  if (model_path / "source.spm").exists() and (model_path / "target.spm").exists():
@@ -584,6 +666,7 @@ class HachimiTranslator:
584
  self._batch_size = self._profile.batch_size
585
  self._tokenize_workers = self._profile.tokenize_workers
586
  self._tokenize_pool: ThreadPoolExecutor | None = None
 
587
 
588
  @property
589
  def hardware_profile(self) -> HardwareProfile:
@@ -593,6 +676,19 @@ class HachimiTranslator:
593
  def batch_size(self) -> int:
594
  return self._batch_size
595
 
 
 
 
 
 
 
 
 
 
 
 
 
 
596
  def set_batch_size(self, batch_size: int) -> None:
597
  self._batch_size = max(4, min(128, int(batch_size)))
598
 
@@ -749,12 +845,20 @@ class HachimiTranslator:
749
  return [tokens for job in jobs for tokens in job.result()]
750
 
751
  def _decode_ct2_results(self, results) -> list[str]:
 
752
  hypotheses = [result.hypotheses[0] for result in results]
 
 
 
 
 
753
  token_ids = [self._tokenizer.convert_tokens_to_ids(tokens) for tokens in hypotheses]
754
- return [
755
  text.strip()
756
  for text in self._tokenizer.batch_decode(token_ids, skip_special_tokens=True)
757
  ]
 
 
758
 
759
  def _load_ct2(self, config: ModelConfig) -> None:
760
  model_path = ensure_model_files(config, Backend.CT2)
@@ -978,7 +1082,10 @@ class HachimiTranslator:
978
  ) -> list[str]:
979
  config = MODELS[self._model_key]
980
  if source_batches is None:
 
981
  source_batches = self._tokenize_chunks_parallel(chunks)
 
 
982
  results = self._ct2_model.translate_batch(
983
  source_batches,
984
  max_batch_size=self._ct2_max_batch_size(config),
@@ -987,6 +1094,7 @@ class HachimiTranslator:
987
  max_decoding_length=config.ct2_max_output_tokens,
988
  **self._ct2_repetition_kwargs(config),
989
  )
 
990
  return self._decode_ct2_results(results)
991
 
992
  def _translate_ct2_batch_pipelined(
@@ -997,7 +1105,9 @@ class HachimiTranslator:
997
  prefetched_tokens: SourceTokenJobs | None,
998
  ) -> list[str]:
999
  if prefetched_tokens is not None:
 
1000
  source_batches = self._collect_tokenize_jobs(prefetched_tokens)
 
1001
  else:
1002
  source_batches = None
1003
  return self._translate_ct2_batch(
@@ -1024,8 +1134,14 @@ class HachimiTranslator:
1024
  ) -> Iterator[tuple[int, int, str, list[tuple[int, str, str]] | None, str | None]]:
1025
  """Yield (done, total, message, rows_or_none, full_text_or_none) sau mỗi batch."""
1026
  beam_size = self.clamp_beam(beam_size)
 
 
 
 
1027
  chunks = self._chunk_text(text, chunk_mode)
 
1028
  total = len(chunks)
 
1029
 
1030
  yield 0, total, f"Đã chia {total} chunk, chuẩn bị dịch...", None, None
1031
 
@@ -1035,7 +1151,9 @@ class HachimiTranslator:
1035
  next_tokens: SourceTokenJobs | None = None
1036
  if self._backend == Backend.CT2 and total:
1037
  first_end = min(window_size, total)
 
1038
  next_tokens = self._submit_tokenize_jobs(chunks[:first_end])
 
1039
 
1040
  for start in range(0, total, window_size):
1041
  end = min(start + window_size, total)
@@ -1065,7 +1183,9 @@ class HachimiTranslator:
1065
  next_end = min(next_start + window_size, total)
1066
  if next_start < total:
1067
  next_batch = chunks[next_start:next_end]
 
1068
  next_tokens = self._submit_tokenize_jobs(next_batch)
 
1069
 
1070
  translations.extend(
1071
  self._translate_ct2_batch_pipelined(
@@ -1079,11 +1199,17 @@ class HachimiTranslator:
1079
 
1080
  yield end, total, f"Đã xong {end}/{total} chunk", None, None
1081
 
 
1082
  rows = [
1083
  (index, chunk, translated)
1084
  for index, (chunk, translated) in enumerate(zip(chunks, translations), start=1)
1085
  ]
1086
  full_text = "\n".join(translations)
 
 
 
 
 
1087
  yield total, total, "Hoàn tất dịch.", rows, full_text
1088
 
1089
  def translate_text(
 
3
  from __future__ import annotations
4
 
5
  import os
6
+ import time
7
+ from collections import OrderedDict
8
  from concurrent.futures import Future, ThreadPoolExecutor
9
  from dataclasses import dataclass
10
  from enum import Enum
 
311
  def __init__(self, model_path: Path) -> None:
312
  self._source_sp = spm.SentencePieceProcessor(model_file=str(model_path / "source.spm"))
313
  self._target_sp = spm.SentencePieceProcessor(model_file=str(model_path / "target.spm"))
314
+ self._encode_cache_max = _env_int(
315
+ "HACHIMIMT_TOKEN_CACHE_SIZE",
316
+ 0,
317
+ min_value=0,
318
+ max_value=500_000,
319
+ )
320
+ self._encode_cache: OrderedDict[str, list[int]] = OrderedDict()
321
+ self._cache_hits = 0
322
+ self._cache_misses = 0
323
+
324
+ def _full_encode_one(self, text: str) -> list[int]:
325
+ if self._encode_cache_max > 0:
326
+ cached = self._encode_cache.get(text)
327
+ if cached is not None:
328
+ self._encode_cache.move_to_end(text)
329
+ self._cache_hits += 1
330
+ return list(cached)
331
+
332
+ self._cache_misses += 1
333
+ token_ids = list(self._source_sp.encode(text, out_type=int))
334
+ token_ids.append(EOS_TOKEN_ID)
335
+ if self._encode_cache_max > 0:
336
+ self._encode_cache[text] = list(token_ids)
337
+ self._encode_cache.move_to_end(text)
338
+ while len(self._encode_cache) > self._encode_cache_max:
339
+ self._encode_cache.popitem(last=False)
340
+ return token_ids
341
+
342
+ def cache_stats(self) -> dict[str, int]:
343
+ return {
344
+ "token_cache_entries": len(self._encode_cache),
345
+ "token_cache_hits": self._cache_hits,
346
+ "token_cache_misses": self._cache_misses,
347
+ }
348
 
349
  def _encode_one(
350
  self,
 
353
  truncation: bool = False,
354
  max_length: int | None = None,
355
  ) -> list[int]:
356
+ token_ids = self._full_encode_one(text)
 
357
  if truncation and max_length is not None and len(token_ids) > max_length:
358
  token_ids = token_ids[:max_length]
359
  if token_ids:
 
423
  decoded.append(self._target_sp.decode(pieces))
424
  return decoded
425
 
426
+ def decode_tokens_batch(self, tokens_batch: list[list[str]]) -> list[str]:
427
+ decoded: list[str] = []
428
+ for tokens in tokens_batch:
429
+ pieces = [token for token in tokens if token not in SPECIAL_TOKEN_TO_ID]
430
+ decoded.append(self._target_sp.decode(pieces).strip())
431
+ return decoded
432
+
433
 
434
  class CT2FastTokenizer:
435
  """Minimal tokenizer.json wrapper for CTranslate2 inference."""
 
445
  self._tokenizer = Tokenizer.from_file(str(model_path / "tokenizer.json"))
446
  self.pad_token_id = self._tokenizer.token_to_id("<pad>")
447
  self._eos_token_id = self._tokenizer.token_to_id("</s>")
448
+ self._encode_cache_max = _env_int(
449
+ "HACHIMIMT_TOKEN_CACHE_SIZE",
450
+ 0,
451
+ min_value=0,
452
+ max_value=500_000,
453
+ )
454
+ self._encode_cache: OrderedDict[str, list[int]] = OrderedDict()
455
+ self._cache_hits = 0
456
+ self._cache_misses = 0
457
+
458
+ def _full_encode_one(self, text: str) -> list[int]:
459
+ if self._encode_cache_max > 0:
460
+ cached = self._encode_cache.get(text)
461
+ if cached is not None:
462
+ self._encode_cache.move_to_end(text)
463
+ self._cache_hits += 1
464
+ return list(cached)
465
+
466
+ self._cache_misses += 1
467
+ token_ids = list(self._tokenizer.encode(text).ids)
468
+ if self._encode_cache_max > 0:
469
+ self._encode_cache[text] = list(token_ids)
470
+ self._encode_cache.move_to_end(text)
471
+ while len(self._encode_cache) > self._encode_cache_max:
472
+ self._encode_cache.popitem(last=False)
473
+ return token_ids
474
+
475
+ def cache_stats(self) -> dict[str, int]:
476
+ return {
477
+ "token_cache_entries": len(self._encode_cache),
478
+ "token_cache_hits": self._cache_hits,
479
+ "token_cache_misses": self._cache_misses,
480
+ }
481
 
482
  def _encode_one(
483
  self,
 
486
  truncation: bool = False,
487
  max_length: int | None = None,
488
  ) -> list[int]:
489
+ token_ids = self._full_encode_one(text)
490
  if truncation and max_length is not None and len(token_ids) > max_length:
491
  token_ids = token_ids[:max_length]
492
  if token_ids and self._eos_token_id is not None:
 
549
  for token_ids in token_ids_batch
550
  ]
551
 
552
+ def decode_tokens_batch(self, tokens_batch: list[list[str]]) -> list[str]:
553
+ token_ids_batch = [self.convert_tokens_to_ids(tokens) for tokens in tokens_batch]
554
+ return [
555
+ text.strip()
556
+ for text in self.batch_decode(token_ids_batch, skip_special_tokens=True)
557
+ ]
558
+
559
 
560
  def _load_ct2_tokenizer(model_path: Path):
561
  if (model_path / "source.spm").exists() and (model_path / "target.spm").exists():
 
666
  self._batch_size = self._profile.batch_size
667
  self._tokenize_workers = self._profile.tokenize_workers
668
  self._tokenize_pool: ThreadPoolExecutor | None = None
669
+ self._last_profile: dict[str, float | int | str] = {}
670
 
671
  @property
672
  def hardware_profile(self) -> HardwareProfile:
 
676
  def batch_size(self) -> int:
677
  return self._batch_size
678
 
679
+ @property
680
+ def last_profile(self) -> dict[str, float | int | str]:
681
+ return dict(self._last_profile)
682
+
683
+ def _reset_profile(self) -> None:
684
+ self._last_profile = {}
685
+
686
+ def _profile_add(self, key: str, seconds: float) -> None:
687
+ self._last_profile[key] = float(self._last_profile.get(key, 0.0)) + seconds
688
+
689
+ def _profile_set(self, key: str, value: float | int | str) -> None:
690
+ self._last_profile[key] = value
691
+
692
  def set_batch_size(self, batch_size: int) -> None:
693
  self._batch_size = max(4, min(128, int(batch_size)))
694
 
 
845
  return [tokens for job in jobs for tokens in job.result()]
846
 
847
  def _decode_ct2_results(self, results) -> list[str]:
848
+ start = time.perf_counter()
849
  hypotheses = [result.hypotheses[0] for result in results]
850
+ decode_tokens_batch = getattr(self._tokenizer, "decode_tokens_batch", None)
851
+ if callable(decode_tokens_batch):
852
+ decoded = decode_tokens_batch(hypotheses)
853
+ self._profile_add("decode_s", time.perf_counter() - start)
854
+ return decoded
855
  token_ids = [self._tokenizer.convert_tokens_to_ids(tokens) for tokens in hypotheses]
856
+ decoded = [
857
  text.strip()
858
  for text in self._tokenizer.batch_decode(token_ids, skip_special_tokens=True)
859
  ]
860
+ self._profile_add("decode_s", time.perf_counter() - start)
861
+ return decoded
862
 
863
  def _load_ct2(self, config: ModelConfig) -> None:
864
  model_path = ensure_model_files(config, Backend.CT2)
 
1082
  ) -> list[str]:
1083
  config = MODELS[self._model_key]
1084
  if source_batches is None:
1085
+ tokenize_start = time.perf_counter()
1086
  source_batches = self._tokenize_chunks_parallel(chunks)
1087
+ self._profile_add("tokenize_s", time.perf_counter() - tokenize_start)
1088
+ infer_start = time.perf_counter()
1089
  results = self._ct2_model.translate_batch(
1090
  source_batches,
1091
  max_batch_size=self._ct2_max_batch_size(config),
 
1094
  max_decoding_length=config.ct2_max_output_tokens,
1095
  **self._ct2_repetition_kwargs(config),
1096
  )
1097
+ self._profile_add("ct2_infer_s", time.perf_counter() - infer_start)
1098
  return self._decode_ct2_results(results)
1099
 
1100
  def _translate_ct2_batch_pipelined(
 
1105
  prefetched_tokens: SourceTokenJobs | None,
1106
  ) -> list[str]:
1107
  if prefetched_tokens is not None:
1108
+ wait_start = time.perf_counter()
1109
  source_batches = self._collect_tokenize_jobs(prefetched_tokens)
1110
+ self._profile_add("tokenize_wait_s", time.perf_counter() - wait_start)
1111
  else:
1112
  source_batches = None
1113
  return self._translate_ct2_batch(
 
1134
  ) -> Iterator[tuple[int, int, str, list[tuple[int, str, str]] | None, str | None]]:
1135
  """Yield (done, total, message, rows_or_none, full_text_or_none) sau mỗi batch."""
1136
  beam_size = self.clamp_beam(beam_size)
1137
+ self._reset_profile()
1138
+ self._profile_set("backend", self._backend.value if self._backend else "")
1139
+ self._profile_set("beam", beam_size)
1140
+ chunk_start = time.perf_counter()
1141
  chunks = self._chunk_text(text, chunk_mode)
1142
+ self._profile_set("chunk_s", time.perf_counter() - chunk_start)
1143
  total = len(chunks)
1144
+ self._profile_set("chunks", total)
1145
 
1146
  yield 0, total, f"Đã chia {total} chunk, chuẩn bị dịch...", None, None
1147
 
 
1151
  next_tokens: SourceTokenJobs | None = None
1152
  if self._backend == Backend.CT2 and total:
1153
  first_end = min(window_size, total)
1154
+ submit_start = time.perf_counter()
1155
  next_tokens = self._submit_tokenize_jobs(chunks[:first_end])
1156
+ self._profile_add("tokenize_submit_s", time.perf_counter() - submit_start)
1157
 
1158
  for start in range(0, total, window_size):
1159
  end = min(start + window_size, total)
 
1183
  next_end = min(next_start + window_size, total)
1184
  if next_start < total:
1185
  next_batch = chunks[next_start:next_end]
1186
+ submit_start = time.perf_counter()
1187
  next_tokens = self._submit_tokenize_jobs(next_batch)
1188
+ self._profile_add("tokenize_submit_s", time.perf_counter() - submit_start)
1189
 
1190
  translations.extend(
1191
  self._translate_ct2_batch_pipelined(
 
1199
 
1200
  yield end, total, f"Đã xong {end}/{total} chunk", None, None
1201
 
1202
+ assemble_start = time.perf_counter()
1203
  rows = [
1204
  (index, chunk, translated)
1205
  for index, (chunk, translated) in enumerate(zip(chunks, translations), start=1)
1206
  ]
1207
  full_text = "\n".join(translations)
1208
+ self._profile_add("assemble_s", time.perf_counter() - assemble_start)
1209
+ cache_stats = getattr(self._tokenizer, "cache_stats", None)
1210
+ if callable(cache_stats):
1211
+ for key, value in cache_stats().items():
1212
+ self._profile_set(key, value)
1213
  yield total, total, "Hoàn tất dịch.", rows, full_text
1214
 
1215
  def translate_text(