polytext 0.2.8b5__tar.gz → 0.2.8b6__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. {polytext-0.2.8b5 → polytext-0.2.8b6}/PKG-INFO +2 -1
  2. {polytext-0.2.8b5 → polytext-0.2.8b6}/polytext/converter/audio_to_text.py +237 -4
  3. {polytext-0.2.8b5 → polytext-0.2.8b6}/polytext.egg-info/PKG-INFO +2 -1
  4. {polytext-0.2.8b5 → polytext-0.2.8b6}/polytext.egg-info/requires.txt +1 -0
  5. {polytext-0.2.8b5 → polytext-0.2.8b6}/setup.py +1 -1
  6. {polytext-0.2.8b5 → polytext-0.2.8b6}/tests/test_audio_transcription_model_migration.py +168 -1
  7. {polytext-0.2.8b5 → polytext-0.2.8b6}/LICENSE +0 -0
  8. {polytext-0.2.8b5 → polytext-0.2.8b6}/README.md +0 -0
  9. {polytext-0.2.8b5 → polytext-0.2.8b6}/polytext/__init__.py +0 -0
  10. {polytext-0.2.8b5 → polytext-0.2.8b6}/polytext/converter/__init__.py +0 -0
  11. {polytext-0.2.8b5 → polytext-0.2.8b6}/polytext/converter/base.py +0 -0
  12. {polytext-0.2.8b5 → polytext-0.2.8b6}/polytext/converter/beautiful_text.py +0 -0
  13. {polytext-0.2.8b5 → polytext-0.2.8b6}/polytext/converter/document_ocr_to_text.py +0 -0
  14. {polytext-0.2.8b5 → polytext-0.2.8b6}/polytext/converter/document_ocr_to_text_azure_oai.py +0 -0
  15. {polytext-0.2.8b5 → polytext-0.2.8b6}/polytext/converter/gemini_quality_guards.py +0 -0
  16. {polytext-0.2.8b5 → polytext-0.2.8b6}/polytext/converter/html_to_md.py +0 -0
  17. {polytext-0.2.8b5 → polytext-0.2.8b6}/polytext/converter/md_to_text.py +0 -0
  18. {polytext-0.2.8b5 → polytext-0.2.8b6}/polytext/converter/ocr_to_text.py +0 -0
  19. {polytext-0.2.8b5 → polytext-0.2.8b6}/polytext/converter/ocr_to_text_azure_oai.py +0 -0
  20. {polytext-0.2.8b5 → polytext-0.2.8b6}/polytext/converter/pdf.py +0 -0
  21. {polytext-0.2.8b5 → polytext-0.2.8b6}/polytext/converter/text_to_md.py +0 -0
  22. {polytext-0.2.8b5 → polytext-0.2.8b6}/polytext/converter/video_to_audio.py +0 -0
  23. {polytext-0.2.8b5 → polytext-0.2.8b6}/polytext/exceptions/__init__.py +0 -0
  24. {polytext-0.2.8b5 → polytext-0.2.8b6}/polytext/exceptions/base.py +0 -0
  25. {polytext-0.2.8b5 → polytext-0.2.8b6}/polytext/generator/__init__.py +0 -0
  26. {polytext-0.2.8b5 → polytext-0.2.8b6}/polytext/generator/pdf.py +0 -0
  27. {polytext-0.2.8b5 → polytext-0.2.8b6}/polytext/loader/__init__.py +0 -0
  28. {polytext-0.2.8b5 → polytext-0.2.8b6}/polytext/loader/audio.py +0 -0
  29. {polytext-0.2.8b5 → polytext-0.2.8b6}/polytext/loader/aws_auth.py +0 -0
  30. {polytext-0.2.8b5 → polytext-0.2.8b6}/polytext/loader/base.py +0 -0
  31. {polytext-0.2.8b5 → polytext-0.2.8b6}/polytext/loader/document.py +0 -0
  32. {polytext-0.2.8b5 → polytext-0.2.8b6}/polytext/loader/document_ocr.py +0 -0
  33. {polytext-0.2.8b5 → polytext-0.2.8b6}/polytext/loader/downloader/__init__.py +0 -0
  34. {polytext-0.2.8b5 → polytext-0.2.8b6}/polytext/loader/downloader/downloader.py +0 -0
  35. {polytext-0.2.8b5 → polytext-0.2.8b6}/polytext/loader/html.py +0 -0
  36. {polytext-0.2.8b5 → polytext-0.2.8b6}/polytext/loader/markdown.py +0 -0
  37. {polytext-0.2.8b5 → polytext-0.2.8b6}/polytext/loader/notebook.py +0 -0
  38. {polytext-0.2.8b5 → polytext-0.2.8b6}/polytext/loader/ocr.py +0 -0
  39. {polytext-0.2.8b5 → polytext-0.2.8b6}/polytext/loader/plain_text.py +0 -0
  40. {polytext-0.2.8b5 → polytext-0.2.8b6}/polytext/loader/video.py +0 -0
  41. {polytext-0.2.8b5 → polytext-0.2.8b6}/polytext/loader/xml_xbrl.py +0 -0
  42. {polytext-0.2.8b5 → polytext-0.2.8b6}/polytext/loader/youtube.py +0 -0
  43. {polytext-0.2.8b5 → polytext-0.2.8b6}/polytext/loader/youtube_llm.py +0 -0
  44. {polytext-0.2.8b5 → polytext-0.2.8b6}/polytext/processor/__init__.py +0 -0
  45. {polytext-0.2.8b5 → polytext-0.2.8b6}/polytext/processor/audio_chunker.py +0 -0
  46. {polytext-0.2.8b5 → polytext-0.2.8b6}/polytext/processor/text_merger.py +0 -0
  47. {polytext-0.2.8b5 → polytext-0.2.8b6}/polytext/processor/transcript_chunker.py +0 -0
  48. {polytext-0.2.8b5 → polytext-0.2.8b6}/polytext/prompts/__init__.py +0 -0
  49. {polytext-0.2.8b5 → polytext-0.2.8b6}/polytext/prompts/beautiful_text.py +0 -0
  50. {polytext-0.2.8b5 → polytext-0.2.8b6}/polytext/prompts/ocr.py +0 -0
  51. {polytext-0.2.8b5 → polytext-0.2.8b6}/polytext/prompts/text_merging.py +0 -0
  52. {polytext-0.2.8b5 → polytext-0.2.8b6}/polytext/prompts/text_to_md.py +0 -0
  53. {polytext-0.2.8b5 → polytext-0.2.8b6}/polytext/prompts/transcription.py +0 -0
  54. {polytext-0.2.8b5 → polytext-0.2.8b6}/polytext/utils/__init__.py +0 -0
  55. {polytext-0.2.8b5 → polytext-0.2.8b6}/polytext/utils/utils.py +0 -0
  56. {polytext-0.2.8b5 → polytext-0.2.8b6}/polytext.egg-info/SOURCES.txt +0 -0
  57. {polytext-0.2.8b5 → polytext-0.2.8b6}/polytext.egg-info/dependency_links.txt +0 -0
  58. {polytext-0.2.8b5 → polytext-0.2.8b6}/polytext.egg-info/not-zip-safe +0 -0
  59. {polytext-0.2.8b5 → polytext-0.2.8b6}/polytext.egg-info/top_level.txt +0 -0
  60. {polytext-0.2.8b5 → polytext-0.2.8b6}/pyproject.toml +0 -0
  61. {polytext-0.2.8b5 → polytext-0.2.8b6}/setup.cfg +0 -0
  62. {polytext-0.2.8b5 → polytext-0.2.8b6}/tests/test_audio_chunker.py +0 -0
  63. {polytext-0.2.8b5 → polytext-0.2.8b6}/tests/test_audio_comparison_helpers.py +0 -0
  64. {polytext-0.2.8b5 → polytext-0.2.8b6}/tests/test_aws_auth.py +0 -0
  65. {polytext-0.2.8b5 → polytext-0.2.8b6}/tests/test_base_loader_error_mapping.py +0 -0
  66. {polytext-0.2.8b5 → polytext-0.2.8b6}/tests/test_beautiful_text_manual.py +0 -0
  67. {polytext-0.2.8b5 → polytext-0.2.8b6}/tests/test_compare_audio_models.py +0 -0
  68. {polytext-0.2.8b5 → polytext-0.2.8b6}/tests/test_compare_document_ocr_to_text_models.py +0 -0
  69. {polytext-0.2.8b5 → polytext-0.2.8b6}/tests/test_compare_ocr_to_text_models.py +0 -0
  70. {polytext-0.2.8b5 → polytext-0.2.8b6}/tests/test_compare_youtube_models.py +0 -0
  71. {polytext-0.2.8b5 → polytext-0.2.8b6}/tests/test_dowload_audio_from_youtube.py +0 -0
  72. {polytext-0.2.8b5 → polytext-0.2.8b6}/tests/test_dowload_audio_from_youtube_helpers.py +0 -0
  73. {polytext-0.2.8b5 → polytext-0.2.8b6}/tests/test_extracted_text_whitespace.py +0 -0
  74. {polytext-0.2.8b5 → polytext-0.2.8b6}/tests/test_gemini_quality_guards.py +0 -0
  75. {polytext-0.2.8b5 → polytext-0.2.8b6}/tests/test_get_audio_transcript_from_gcs.py +0 -0
  76. {polytext-0.2.8b5 → polytext-0.2.8b6}/tests/test_get_customized_pdf_from_markdown.py +0 -0
  77. {polytext-0.2.8b5 → polytext-0.2.8b6}/tests/test_get_document_ocr.py +0 -0
  78. {polytext-0.2.8b5 → polytext-0.2.8b6}/tests/test_get_document_ocr_azure_oai.py +0 -0
  79. {polytext-0.2.8b5 → polytext-0.2.8b6}/tests/test_get_document_text.py +0 -0
  80. {polytext-0.2.8b5 → polytext-0.2.8b6}/tests/test_get_document_text_from_gcs.py +0 -0
  81. {polytext-0.2.8b5 → polytext-0.2.8b6}/tests/test_get_ocr_from_image.py +0 -0
  82. {polytext-0.2.8b5 → polytext-0.2.8b6}/tests/test_get_text_from_markdown.py +0 -0
  83. {polytext-0.2.8b5 → polytext-0.2.8b6}/tests/test_get_video_transcript_from_gcs.py +0 -0
  84. {polytext-0.2.8b5 → polytext-0.2.8b6}/tests/test_library.py +0 -0
  85. {polytext-0.2.8b5 → polytext-0.2.8b6}/tests/test_markdown_loader_gzip.py +0 -0
  86. {polytext-0.2.8b5 → polytext-0.2.8b6}/tests/test_markitdown_html.py +0 -0
  87. {polytext-0.2.8b5 → polytext-0.2.8b6}/tests/test_notebook_loader.py +0 -0
  88. {polytext-0.2.8b5 → polytext-0.2.8b6}/tests/test_ocr_fallbacks.py +0 -0
  89. {polytext-0.2.8b5 → polytext-0.2.8b6}/tests/test_ocr_image_descriptions.py +0 -0
  90. {polytext-0.2.8b5 → polytext-0.2.8b6}/tests/test_pain_text.py +0 -0
  91. {polytext-0.2.8b5 → polytext-0.2.8b6}/tests/test_pdf_conversion_error.py +0 -0
  92. {polytext-0.2.8b5 → polytext-0.2.8b6}/tests/test_python_version_metadata.py +0 -0
  93. {polytext-0.2.8b5 → polytext-0.2.8b6}/tests/test_split_audio_with_llm.py +0 -0
  94. {polytext-0.2.8b5 → polytext-0.2.8b6}/tests/test_transcribe_s3_images_from_csv.py +0 -0
  95. {polytext-0.2.8b5 → polytext-0.2.8b6}/tests/test_transcribe_s3_images_from_csv_script.py +0 -0
  96. {polytext-0.2.8b5 → polytext-0.2.8b6}/tests/test_xml_xbrl_loader.py +0 -0
  97. {polytext-0.2.8b5 → polytext-0.2.8b6}/tests/test_youtube_gemini_minimal_check.py +0 -0
  98. {polytext-0.2.8b5 → polytext-0.2.8b6}/tests/test_youtube_llm_fallbacks.py +0 -0
  99. {polytext-0.2.8b5 → polytext-0.2.8b6}/tests/test_youtube_transcript.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: polytext
3
- Version: 0.2.8b5
3
+ Version: 0.2.8b6
4
4
  Summary: Python utilities to simplify document files management
5
5
  Home-page: https://github.com/docsity/polytext
6
6
  Author: Matteo Senardi
@@ -31,6 +31,7 @@ Requires-Dist: boto3>=1.42.64
31
31
  Requires-Dist: botocore>=1.42.64
32
32
  Requires-Dist: ffmpeg-python==0.2.0
33
33
  Requires-Dist: pydub==0.25.1
34
+ Requires-Dist: fast-langdetect==0.2.5
34
35
  Requires-Dist: youtube-transcript-api==1.0.3
35
36
  Requires-Dist: yt-dlp==2025.5.22
36
37
  Requires-Dist: charset-normalizer<4.0.0,>=3.4.2
@@ -15,6 +15,7 @@ from google.genai import errors as genai_errors
15
15
  from concurrent.futures import ThreadPoolExecutor, as_completed
16
16
  from google.api_core import exceptions as google_exceptions
17
17
  from pydub import AudioSegment
18
+ from fast_langdetect import detect
18
19
 
19
20
  from ..exceptions import EmptyDocument
20
21
  from ..prompts.transcription import (
@@ -73,6 +74,199 @@ AUDIO_PROMPT_VARIANT_DEFAULT = "default"
73
74
  AUDIO_PROMPT_VARIANT_NON_LITERAL_FALLBACK = "non_literal_fallback"
74
75
  AUDIO_RETRIABLE_OUTPUT_ERROR_CODES = (996, 997, 998, 999)
75
76
  NO_HUMAN_SPEECH_MARKER = "no human speech detected"
77
+ AUDIO_LANGUAGE_VALIDATION_MIN_WORDS = int(os.getenv("AUDIO_LANGUAGE_VALIDATION_MIN_WORDS", "50"))
78
+ AUDIO_LANGUAGE_VALIDATION_MIN_CONFIDENCE = float(
79
+ os.getenv("AUDIO_LANGUAGE_VALIDATION_MIN_CONFIDENCE", "0.80")
80
+ )
81
+ AUDIO_LANGUAGE_VALIDATION_MIN_SUPPORT = float(
82
+ os.getenv("AUDIO_LANGUAGE_VALIDATION_MIN_SUPPORT", "0.67")
83
+ )
84
+ AUDIO_LANGUAGE_VALIDATION_MIN_FOREIGN_WINDOWS = int(
85
+ os.getenv("AUDIO_LANGUAGE_VALIDATION_MIN_FOREIGN_WINDOWS", "2")
86
+ )
87
+
88
+
89
+ def _normalize_language_detection(result) -> tuple[str, float]:
90
+ if isinstance(result, list):
91
+ result = result[0] if result else {}
92
+ if not isinstance(result, dict):
93
+ return "", 0.0
94
+ language = str(result.get("lang", "")).lower().split("-")[0]
95
+ return language, float(result.get("score", 0.0) or 0.0)
96
+
97
+
98
+ def detect_dominant_language(
99
+ text: str,
100
+ max_windows: int = 5,
101
+ window_chars: int = 160,
102
+ ) -> dict:
103
+ """Detect the dominant language over multiple short, evenly spaced text windows."""
104
+ normalized_text = re.sub(r"\s+", " ", text or "").strip()
105
+ if not normalized_text:
106
+ return {
107
+ "lang": "",
108
+ "score": 0.0,
109
+ "support": 0.0,
110
+ "windows": 0,
111
+ "language_windows": {},
112
+ }
113
+
114
+ if len(normalized_text) <= window_chars:
115
+ samples = [normalized_text]
116
+ else:
117
+ last_start = len(normalized_text) - window_chars
118
+ sample_count = min(max_windows, max(2, len(normalized_text) // window_chars))
119
+ starts = {
120
+ round(index * last_start / (sample_count - 1))
121
+ for index in range(sample_count)
122
+ }
123
+ samples = [normalized_text[start:start + window_chars] for start in sorted(starts)]
124
+
125
+ language_scores = {}
126
+ language_counts = {}
127
+ valid_windows = 0
128
+ for sample in samples:
129
+ try:
130
+ detected = detect(sample, low_memory=True)
131
+ except TypeError:
132
+ # fast-langdetect >= 1.0 uses model/k instead of low_memory.
133
+ detected = detect(sample, model="lite", k=1)
134
+ except Exception:
135
+ logger.exception("Language detection failed for an audio transcript window")
136
+ continue
137
+
138
+ language, score = _normalize_language_detection(detected)
139
+ if not language:
140
+ continue
141
+ valid_windows += 1
142
+ language_scores[language] = language_scores.get(language, 0.0) + score
143
+ language_counts[language] = language_counts.get(language, 0) + 1
144
+
145
+ if not valid_windows:
146
+ return {
147
+ "lang": "",
148
+ "score": 0.0,
149
+ "support": 0.0,
150
+ "windows": 0,
151
+ "language_windows": {},
152
+ }
153
+
154
+ dominant_language = max(language_scores, key=language_scores.get)
155
+ count = language_counts[dominant_language]
156
+ return {
157
+ "lang": dominant_language,
158
+ "score": language_scores[dominant_language] / count,
159
+ "support": count / valid_windows,
160
+ "windows": valid_windows,
161
+ "language_windows": {
162
+ language: {
163
+ "windows": language_counts[language],
164
+ "score": language_scores[language] / language_counts[language],
165
+ }
166
+ for language in language_counts
167
+ },
168
+ }
169
+
170
+
171
+ def find_suspicious_fallback_language_indices(
172
+ transcripts: list[str],
173
+ chunk_results: list[dict | None],
174
+ ) -> list[int]:
175
+ """Find internal fallback chunks whose language conflicts with two agreeing neighbours."""
176
+ suspicious_indices = []
177
+ logger.info("Starting fallback language validation for %s transcript chunks", len(transcripts))
178
+
179
+ def has_fallback(result: dict | None) -> bool:
180
+ if not result:
181
+ return False
182
+ if result.get("fallback_from_model") or result.get("fallback_to_model"):
183
+ return True
184
+ return any(has_fallback(item) for item in result.get("split_results", []))
185
+
186
+ for index in range(1, len(transcripts) - 1):
187
+ result = chunk_results[index] or {}
188
+ if not has_fallback(result):
189
+ continue
190
+
191
+ text = transcripts[index]
192
+ word_count = len(re.findall(r"\b\w+\b", text, flags=re.UNICODE))
193
+ if word_count < AUDIO_LANGUAGE_VALIDATION_MIN_WORDS:
194
+ logger.info(
195
+ "Skipping language validation for fallback chunk %s: only %s words (minimum %s)",
196
+ index + 1,
197
+ word_count,
198
+ AUDIO_LANGUAGE_VALIDATION_MIN_WORDS,
199
+ )
200
+ continue
201
+
202
+ candidate_language = detect_dominant_language(text)
203
+ previous_language = detect_dominant_language(transcripts[index - 1])
204
+ next_language = detect_dominant_language(transcripts[index + 1])
205
+ logger.info(
206
+ "Language validation for fallback chunk %s: previous=%s, candidate=%s, next=%s",
207
+ index + 1,
208
+ previous_language,
209
+ candidate_language,
210
+ next_language,
211
+ )
212
+ neighbour_profiles = (previous_language, next_language)
213
+ if any(
214
+ profile["score"] < AUDIO_LANGUAGE_VALIDATION_MIN_CONFIDENCE
215
+ or profile["support"] < AUDIO_LANGUAGE_VALIDATION_MIN_SUPPORT
216
+ for profile in neighbour_profiles
217
+ ):
218
+ logger.info(
219
+ "Skipping language comparison for fallback chunk %s: neighbour confidence or support below threshold",
220
+ index + 1,
221
+ )
222
+ continue
223
+
224
+ if previous_language["lang"] != next_language["lang"]:
225
+ logger.info(
226
+ "Skipping language comparison for fallback chunk %s: neighbouring languages disagree",
227
+ index + 1,
228
+ )
229
+ continue
230
+
231
+ expected_language = previous_language["lang"]
232
+ foreign_windows = {
233
+ language: profile
234
+ for language, profile in candidate_language.get("language_windows", {}).items()
235
+ if (
236
+ language != expected_language
237
+ and profile["windows"] >= AUDIO_LANGUAGE_VALIDATION_MIN_FOREIGN_WINDOWS
238
+ and profile["score"] >= AUDIO_LANGUAGE_VALIDATION_MIN_CONFIDENCE
239
+ )
240
+ }
241
+ if foreign_windows:
242
+ logger.warning(
243
+ "Mixed-language anomaly detected for fallback chunk %s: expected %s, foreign windows=%s",
244
+ index + 1,
245
+ expected_language,
246
+ foreign_windows,
247
+ )
248
+ suspicious_indices.append(index)
249
+ continue
250
+
251
+ if (
252
+ candidate_language["score"] >= AUDIO_LANGUAGE_VALIDATION_MIN_CONFIDENCE
253
+ and candidate_language["support"] >= AUDIO_LANGUAGE_VALIDATION_MIN_SUPPORT
254
+ and candidate_language["lang"] != expected_language
255
+ ):
256
+ logger.warning(
257
+ "Language anomaly detected for fallback chunk %s: %s between two %s chunks",
258
+ index + 1,
259
+ candidate_language["lang"],
260
+ expected_language,
261
+ )
262
+ suspicious_indices.append(index)
263
+ elif candidate_language["support"] < AUDIO_LANGUAGE_VALIDATION_MIN_SUPPORT:
264
+ logger.info(
265
+ "No actionable language anomaly for fallback chunk %s: mixed windows remain below thresholds",
266
+ index + 1,
267
+ )
268
+
269
+ return suspicious_indices
76
270
 
77
271
 
78
272
  def normalize_no_human_speech_marker(text: str) -> tuple[str, bool]:
@@ -782,6 +976,41 @@ class AudioToTextConverter:
782
976
  transcript_dict = self.transcribe_audio(chunk["file_path"])
783
977
  return index, transcript_dict
784
978
 
979
+ def recover_suspicious_fallback_languages(
980
+ self,
981
+ chunks: list[dict],
982
+ transcript_chunks: list[str],
983
+ chunk_results: list[dict | None],
984
+ ) -> None:
985
+ """Re-transcribe suspicious fallback chunks in smaller parts, in place."""
986
+ suspicious_indices = find_suspicious_fallback_language_indices(
987
+ transcript_chunks,
988
+ chunk_results,
989
+ )
990
+ for index in suspicious_indices:
991
+ original_result = chunk_results[index] or {}
992
+ logger.warning(
993
+ "Fallback transcript language conflicts with both neighbouring chunks; "
994
+ "re-transcribing chunk %s with adaptive split",
995
+ index + 1,
996
+ )
997
+ try:
998
+ recovered_result = self.transcribe_audio_halves(chunks[index]["file_path"])
999
+ except Exception:
1000
+ logger.exception(
1001
+ "Language-based recovery failed for chunk %s; preserving the original transcript",
1002
+ index + 1,
1003
+ )
1004
+ continue
1005
+
1006
+ recovered_result["language_validation_triggered"] = True
1007
+ recovered_result["language_validation_original_model"] = original_result.get(
1008
+ "completion_model"
1009
+ )
1010
+ transcript_chunks[index] = recovered_result["transcript"]
1011
+ chunks[index]["transcript"] = recovered_result["transcript"]
1012
+ chunk_results[index] = recovered_result
1013
+
785
1014
  def format_audio_output_text(self, text: str) -> str:
786
1015
  return add_line_break_after_each_sentence(text)
787
1016
 
@@ -869,15 +1098,19 @@ class AudioToTextConverter:
869
1098
  }
870
1099
 
871
1100
  # Process completed transcriptions in order of completion
872
- completion_tokens = 0
873
- prompt_tokens = 0
874
1101
  for future in as_completed(future_to_chunk):
875
1102
  index, transcript_dict = future.result()
876
1103
  chunks[index]["transcript"] = transcript_dict["transcript"]
877
1104
  transcript_chunks[index] = transcript_dict["transcript"]
878
1105
  chunk_results[index] = transcript_dict
879
- completion_tokens += transcript_dict["completion_tokens"]
880
- prompt_tokens += transcript_dict["prompt_tokens"]
1106
+
1107
+ self.recover_suspicious_fallback_languages(
1108
+ chunks,
1109
+ transcript_chunks,
1110
+ chunk_results,
1111
+ )
1112
+ completion_tokens = sum(result["completion_tokens"] for result in chunk_results)
1113
+ prompt_tokens = sum(result["prompt_tokens"] for result in chunk_results)
881
1114
 
882
1115
  text_merger = TextMerger(llm_api_key=self.llm_api_key)
883
1116
  # Merge all transcripts
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: polytext
3
- Version: 0.2.8b5
3
+ Version: 0.2.8b6
4
4
  Summary: Python utilities to simplify document files management
5
5
  Home-page: https://github.com/docsity/polytext
6
6
  Author: Matteo Senardi
@@ -31,6 +31,7 @@ Requires-Dist: boto3>=1.42.64
31
31
  Requires-Dist: botocore>=1.42.64
32
32
  Requires-Dist: ffmpeg-python==0.2.0
33
33
  Requires-Dist: pydub==0.25.1
34
+ Requires-Dist: fast-langdetect==0.2.5
34
35
  Requires-Dist: youtube-transcript-api==1.0.3
35
36
  Requires-Dist: yt-dlp==2025.5.22
36
37
  Requires-Dist: charset-normalizer<4.0.0,>=3.4.2
@@ -13,6 +13,7 @@ boto3>=1.42.64
13
13
  botocore>=1.42.64
14
14
  ffmpeg-python==0.2.0
15
15
  pydub==0.25.1
16
+ fast-langdetect==0.2.5
16
17
  youtube-transcript-api==1.0.3
17
18
  yt-dlp==2025.5.22
18
19
  charset-normalizer<4.0.0,>=3.4.2
@@ -51,7 +51,7 @@ def get_requirements(*requirements_file):
51
51
 
52
52
  setup(
53
53
  name='polytext',
54
- version='0.2.8b5',
54
+ version='0.2.8b6',
55
55
  url='https://github.com/docsity/polytext',
56
56
  # download_url='https://github.com/pualien/py-polytext/archive/0.1.23.tar.gz',
57
57
  license='MIT',
@@ -1,6 +1,7 @@
1
1
  import unittest
2
2
  import tempfile
3
3
  import os
4
+ import wave
4
5
  from types import SimpleNamespace
5
6
  from unittest.mock import MagicMock, patch
6
7
  from pydub import AudioSegment
@@ -13,6 +14,8 @@ from polytext.converter.audio_to_text import (
13
14
  AUDIO_TO_MARKDOWN_PROMPT_IS_RAW,
14
15
  AUDIO_TO_PLAIN_TEXT_PROMPT,
15
16
  AudioToTextConverter,
17
+ detect_dominant_language,
18
+ find_suspicious_fallback_language_indices,
16
19
  normalize_no_human_speech_marker,
17
20
  transcribe_full_audio,
18
21
  )
@@ -84,6 +87,148 @@ class _FakeClient:
84
87
  self.models = _FakeModels(responses=responses)
85
88
 
86
89
 
90
+ class TestFallbackLanguageValidation(unittest.TestCase):
91
+ @patch("polytext.converter.audio_to_text.detect")
92
+ def test_detect_dominant_language_aggregates_multiple_text_windows(self, mock_detect):
93
+ mock_detect.side_effect = [
94
+ {"lang": "it", "score": 0.96},
95
+ {"lang": "it", "score": 0.93},
96
+ {"lang": "en", "score": 0.60},
97
+ ]
98
+
99
+ text = " ".join(f"parola{i}" for i in range(120))
100
+
101
+ result = detect_dominant_language(text, max_windows=3, window_chars=120)
102
+
103
+ self.assertEqual(result["lang"], "it")
104
+ self.assertGreater(result["support"], 0.60)
105
+ self.assertEqual(result["windows"], 3)
106
+
107
+ @patch("polytext.converter.audio_to_text.detect_dominant_language")
108
+ def test_flags_internal_fallback_when_both_neighbours_agree(self, mock_detect):
109
+ mock_detect.side_effect = [
110
+ {"lang": "en", "score": 0.98, "support": 1.0, "windows": 3},
111
+ {"lang": "it", "score": 0.97, "support": 1.0, "windows": 3},
112
+ {"lang": "it", "score": 0.96, "support": 1.0, "windows": 3},
113
+ ]
114
+ transcripts = [
115
+ "testo italiano " * 60,
116
+ "unexpected English transcript " * 60,
117
+ "altro testo italiano " * 60,
118
+ ]
119
+ results = [
120
+ {
121
+ "transcript": transcripts[0],
122
+ "fallback_from_model": "gemini-3.1-flash-lite",
123
+ },
124
+ {
125
+ "transcript": transcripts[1],
126
+ "adaptive_split": True,
127
+ "split_results": [
128
+ {
129
+ "transcript": transcripts[1],
130
+ "fallback_from_model": "gemini-3.1-flash-lite",
131
+ }
132
+ ],
133
+ },
134
+ {
135
+ "transcript": transcripts[2],
136
+ "fallback_to_model": "gemini-3.5-flash-lite",
137
+ },
138
+ ]
139
+
140
+ suspicious = find_suspicious_fallback_language_indices(transcripts, results)
141
+
142
+ self.assertEqual(suspicious, [1])
143
+
144
+ @patch("polytext.converter.audio_to_text.detect_dominant_language")
145
+ def test_preserves_possible_multilingual_transition_when_neighbours_disagree(self, mock_detect):
146
+ mock_detect.side_effect = [
147
+ {"lang": "en", "score": 0.98, "support": 1.0, "windows": 3},
148
+ {"lang": "it", "score": 0.97, "support": 1.0, "windows": 3},
149
+ {"lang": "fr", "score": 0.96, "support": 1.0, "windows": 3},
150
+ ]
151
+ transcripts = ["italiano " * 60, "english " * 60, "francais " * 60]
152
+ results = [
153
+ {"transcript": transcripts[0]},
154
+ {"transcript": transcripts[1], "fallback_to_model": "gemini-3.5-flash-lite"},
155
+ {"transcript": transcripts[2]},
156
+ ]
157
+
158
+ suspicious = find_suspicious_fallback_language_indices(transcripts, results)
159
+
160
+ self.assertEqual(suspicious, [])
161
+
162
+ @patch("polytext.converter.audio_to_text.detect_dominant_language")
163
+ def test_flags_fallback_with_two_confident_foreign_windows(self, mock_detect):
164
+ mock_detect.side_effect = [
165
+ {
166
+ "lang": "it",
167
+ "score": 0.99,
168
+ "support": 0.6,
169
+ "windows": 5,
170
+ "language_windows": {
171
+ "it": {"windows": 3, "score": 0.99},
172
+ "en": {"windows": 2, "score": 0.95},
173
+ },
174
+ },
175
+ {"lang": "it", "score": 0.98, "support": 1.0, "windows": 5},
176
+ {"lang": "it", "score": 0.97, "support": 1.0, "windows": 5},
177
+ ]
178
+ transcripts = ["italiano " * 60, "testo misto " * 60, "italiano " * 60]
179
+ results = [
180
+ {"transcript": transcripts[0]},
181
+ {"transcript": transcripts[1], "fallback_to_model": "gemini-3.5-flash-lite"},
182
+ {"transcript": transcripts[2]},
183
+ ]
184
+
185
+ suspicious = find_suspicious_fallback_language_indices(transcripts, results)
186
+
187
+ self.assertEqual(suspicious, [1])
188
+
189
+ @patch(
190
+ "polytext.converter.audio_to_text.find_suspicious_fallback_language_indices",
191
+ return_value=[1],
192
+ )
193
+ def test_suspicious_fallback_is_retranscribed_with_adaptive_split(self, _mock_find):
194
+ converter = AudioToTextConverter()
195
+ chunks = [
196
+ {"file_path": "/tmp/chunk-0.wav"},
197
+ {"file_path": "/tmp/chunk-1.wav"},
198
+ {"file_path": "/tmp/chunk-2.wav"},
199
+ ]
200
+ transcripts = ["prima", "wrong English fallback", "dopo"]
201
+ results = [
202
+ {"transcript": "prima", "completion_tokens": 1, "prompt_tokens": 1},
203
+ {
204
+ "transcript": "wrong English fallback",
205
+ "completion_tokens": 2,
206
+ "prompt_tokens": 2,
207
+ "completion_model": "gemini-3.5-flash-lite",
208
+ "fallback_from_model": "gemini-3.1-flash-lite",
209
+ },
210
+ {"transcript": "dopo", "completion_tokens": 1, "prompt_tokens": 1},
211
+ ]
212
+ recovered = {
213
+ "transcript": "testo italiano recuperato",
214
+ "completion_tokens": 4,
215
+ "prompt_tokens": 3,
216
+ "completion_model": "gemini-3.1-flash-lite",
217
+ }
218
+
219
+ with patch.object(converter, "transcribe_audio_halves", return_value=recovered) as mock_split:
220
+ converter.recover_suspicious_fallback_languages(chunks, transcripts, results)
221
+
222
+ mock_split.assert_called_once_with("/tmp/chunk-1.wav")
223
+ self.assertEqual(transcripts[1], "testo italiano recuperato")
224
+ self.assertEqual(results[1]["transcript"], "testo italiano recuperato")
225
+ self.assertTrue(results[1]["language_validation_triggered"])
226
+ self.assertEqual(
227
+ results[1]["language_validation_original_model"],
228
+ "gemini-3.5-flash-lite",
229
+ )
230
+
231
+
87
232
  class _FlakyServerErrorModels:
88
233
  def __init__(self):
89
234
  self.generate_content_calls = 0
@@ -455,6 +600,20 @@ class TestAudioTranscriptionModelMigration(unittest.TestCase):
455
600
  audio_part = fake_client.models.generate_content_contents[2]
456
601
  self.assertEqual(audio_part.inline_data.mime_type, "audio/aac")
457
602
 
603
+ @patch("polytext.converter.audio_to_text.genai.Client")
604
+ def test_inline_wav_uses_gemini_supported_mime_type(self, mock_client_cls):
605
+ fake_client = _FakeClient()
606
+ mock_client_cls.return_value = fake_client
607
+
608
+ converter = AudioToTextConverter()
609
+ with tempfile.NamedTemporaryFile(suffix=".wav") as temp_audio:
610
+ temp_audio.write(b"fake-audio")
611
+ temp_audio.flush()
612
+ converter.transcribe_audio(temp_audio.name)
613
+
614
+ audio_part = fake_client.models.generate_content_contents[2]
615
+ self.assertEqual(audio_part.inline_data.mime_type, "audio/wav")
616
+
458
617
  @patch("retry.api.time.sleep")
459
618
  def test_does_not_retry_genai_client_error_for_audio_transcription(self, _mock_sleep):
460
619
  fake_client = _ClientErrorClient()
@@ -613,10 +772,18 @@ class TestAudioTranscriptionModelMigration(unittest.TestCase):
613
772
  with tempfile.NamedTemporaryFile(suffix=".mp3") as source_audio:
614
773
  AudioSegment.silent(duration=10_000).export(source_audio.name, format="mp3").close()
615
774
  converter = AudioToTextConverter()
775
+ def transcribe_split(split_path, temperature=0.0):
776
+ self.assertTrue(split_path.endswith(".wav"))
777
+ with wave.open(split_path, "rb") as split_audio:
778
+ self.assertEqual(split_audio.getframerate(), 16000)
779
+ self.assertEqual(split_audio.getnchannels(), 1)
780
+ self.assertEqual(split_audio.getsampwidth(), 2)
781
+ return split_responses.pop(0)
782
+
616
783
  with patch.object(
617
784
  AudioToTextConverter,
618
785
  "transcribe_audio",
619
- side_effect=split_responses,
786
+ side_effect=transcribe_split,
620
787
  ) as mock_transcribe:
621
788
  result = converter.transcribe_audio_halves(source_audio.name)
622
789
 
File without changes
File without changes
File without changes
File without changes