polytext 0.2.8b5__tar.gz → 0.2.8b7__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 (100) hide show
  1. {polytext-0.2.8b5 → polytext-0.2.8b7}/PKG-INFO +2 -1
  2. {polytext-0.2.8b5 → polytext-0.2.8b7}/polytext/converter/audio_to_text.py +274 -5
  3. {polytext-0.2.8b5 → polytext-0.2.8b7}/polytext/loader/base.py +8 -3
  4. {polytext-0.2.8b5 → polytext-0.2.8b7}/polytext.egg-info/PKG-INFO +2 -1
  5. {polytext-0.2.8b5 → polytext-0.2.8b7}/polytext.egg-info/SOURCES.txt +1 -0
  6. {polytext-0.2.8b5 → polytext-0.2.8b7}/polytext.egg-info/requires.txt +1 -0
  7. {polytext-0.2.8b5 → polytext-0.2.8b7}/setup.py +1 -1
  8. polytext-0.2.8b7/tests/test_audio_chunk_quality.py +76 -0
  9. {polytext-0.2.8b5 → polytext-0.2.8b7}/tests/test_audio_transcription_model_migration.py +168 -1
  10. {polytext-0.2.8b5 → polytext-0.2.8b7}/tests/test_base_loader_error_mapping.py +19 -0
  11. {polytext-0.2.8b5 → polytext-0.2.8b7}/LICENSE +0 -0
  12. {polytext-0.2.8b5 → polytext-0.2.8b7}/README.md +0 -0
  13. {polytext-0.2.8b5 → polytext-0.2.8b7}/polytext/__init__.py +0 -0
  14. {polytext-0.2.8b5 → polytext-0.2.8b7}/polytext/converter/__init__.py +0 -0
  15. {polytext-0.2.8b5 → polytext-0.2.8b7}/polytext/converter/base.py +0 -0
  16. {polytext-0.2.8b5 → polytext-0.2.8b7}/polytext/converter/beautiful_text.py +0 -0
  17. {polytext-0.2.8b5 → polytext-0.2.8b7}/polytext/converter/document_ocr_to_text.py +0 -0
  18. {polytext-0.2.8b5 → polytext-0.2.8b7}/polytext/converter/document_ocr_to_text_azure_oai.py +0 -0
  19. {polytext-0.2.8b5 → polytext-0.2.8b7}/polytext/converter/gemini_quality_guards.py +0 -0
  20. {polytext-0.2.8b5 → polytext-0.2.8b7}/polytext/converter/html_to_md.py +0 -0
  21. {polytext-0.2.8b5 → polytext-0.2.8b7}/polytext/converter/md_to_text.py +0 -0
  22. {polytext-0.2.8b5 → polytext-0.2.8b7}/polytext/converter/ocr_to_text.py +0 -0
  23. {polytext-0.2.8b5 → polytext-0.2.8b7}/polytext/converter/ocr_to_text_azure_oai.py +0 -0
  24. {polytext-0.2.8b5 → polytext-0.2.8b7}/polytext/converter/pdf.py +0 -0
  25. {polytext-0.2.8b5 → polytext-0.2.8b7}/polytext/converter/text_to_md.py +0 -0
  26. {polytext-0.2.8b5 → polytext-0.2.8b7}/polytext/converter/video_to_audio.py +0 -0
  27. {polytext-0.2.8b5 → polytext-0.2.8b7}/polytext/exceptions/__init__.py +0 -0
  28. {polytext-0.2.8b5 → polytext-0.2.8b7}/polytext/exceptions/base.py +0 -0
  29. {polytext-0.2.8b5 → polytext-0.2.8b7}/polytext/generator/__init__.py +0 -0
  30. {polytext-0.2.8b5 → polytext-0.2.8b7}/polytext/generator/pdf.py +0 -0
  31. {polytext-0.2.8b5 → polytext-0.2.8b7}/polytext/loader/__init__.py +0 -0
  32. {polytext-0.2.8b5 → polytext-0.2.8b7}/polytext/loader/audio.py +0 -0
  33. {polytext-0.2.8b5 → polytext-0.2.8b7}/polytext/loader/aws_auth.py +0 -0
  34. {polytext-0.2.8b5 → polytext-0.2.8b7}/polytext/loader/document.py +0 -0
  35. {polytext-0.2.8b5 → polytext-0.2.8b7}/polytext/loader/document_ocr.py +0 -0
  36. {polytext-0.2.8b5 → polytext-0.2.8b7}/polytext/loader/downloader/__init__.py +0 -0
  37. {polytext-0.2.8b5 → polytext-0.2.8b7}/polytext/loader/downloader/downloader.py +0 -0
  38. {polytext-0.2.8b5 → polytext-0.2.8b7}/polytext/loader/html.py +0 -0
  39. {polytext-0.2.8b5 → polytext-0.2.8b7}/polytext/loader/markdown.py +0 -0
  40. {polytext-0.2.8b5 → polytext-0.2.8b7}/polytext/loader/notebook.py +0 -0
  41. {polytext-0.2.8b5 → polytext-0.2.8b7}/polytext/loader/ocr.py +0 -0
  42. {polytext-0.2.8b5 → polytext-0.2.8b7}/polytext/loader/plain_text.py +0 -0
  43. {polytext-0.2.8b5 → polytext-0.2.8b7}/polytext/loader/video.py +0 -0
  44. {polytext-0.2.8b5 → polytext-0.2.8b7}/polytext/loader/xml_xbrl.py +0 -0
  45. {polytext-0.2.8b5 → polytext-0.2.8b7}/polytext/loader/youtube.py +0 -0
  46. {polytext-0.2.8b5 → polytext-0.2.8b7}/polytext/loader/youtube_llm.py +0 -0
  47. {polytext-0.2.8b5 → polytext-0.2.8b7}/polytext/processor/__init__.py +0 -0
  48. {polytext-0.2.8b5 → polytext-0.2.8b7}/polytext/processor/audio_chunker.py +0 -0
  49. {polytext-0.2.8b5 → polytext-0.2.8b7}/polytext/processor/text_merger.py +0 -0
  50. {polytext-0.2.8b5 → polytext-0.2.8b7}/polytext/processor/transcript_chunker.py +0 -0
  51. {polytext-0.2.8b5 → polytext-0.2.8b7}/polytext/prompts/__init__.py +0 -0
  52. {polytext-0.2.8b5 → polytext-0.2.8b7}/polytext/prompts/beautiful_text.py +0 -0
  53. {polytext-0.2.8b5 → polytext-0.2.8b7}/polytext/prompts/ocr.py +0 -0
  54. {polytext-0.2.8b5 → polytext-0.2.8b7}/polytext/prompts/text_merging.py +0 -0
  55. {polytext-0.2.8b5 → polytext-0.2.8b7}/polytext/prompts/text_to_md.py +0 -0
  56. {polytext-0.2.8b5 → polytext-0.2.8b7}/polytext/prompts/transcription.py +0 -0
  57. {polytext-0.2.8b5 → polytext-0.2.8b7}/polytext/utils/__init__.py +0 -0
  58. {polytext-0.2.8b5 → polytext-0.2.8b7}/polytext/utils/utils.py +0 -0
  59. {polytext-0.2.8b5 → polytext-0.2.8b7}/polytext.egg-info/dependency_links.txt +0 -0
  60. {polytext-0.2.8b5 → polytext-0.2.8b7}/polytext.egg-info/not-zip-safe +0 -0
  61. {polytext-0.2.8b5 → polytext-0.2.8b7}/polytext.egg-info/top_level.txt +0 -0
  62. {polytext-0.2.8b5 → polytext-0.2.8b7}/pyproject.toml +0 -0
  63. {polytext-0.2.8b5 → polytext-0.2.8b7}/setup.cfg +0 -0
  64. {polytext-0.2.8b5 → polytext-0.2.8b7}/tests/test_audio_chunker.py +0 -0
  65. {polytext-0.2.8b5 → polytext-0.2.8b7}/tests/test_audio_comparison_helpers.py +0 -0
  66. {polytext-0.2.8b5 → polytext-0.2.8b7}/tests/test_aws_auth.py +0 -0
  67. {polytext-0.2.8b5 → polytext-0.2.8b7}/tests/test_beautiful_text_manual.py +0 -0
  68. {polytext-0.2.8b5 → polytext-0.2.8b7}/tests/test_compare_audio_models.py +0 -0
  69. {polytext-0.2.8b5 → polytext-0.2.8b7}/tests/test_compare_document_ocr_to_text_models.py +0 -0
  70. {polytext-0.2.8b5 → polytext-0.2.8b7}/tests/test_compare_ocr_to_text_models.py +0 -0
  71. {polytext-0.2.8b5 → polytext-0.2.8b7}/tests/test_compare_youtube_models.py +0 -0
  72. {polytext-0.2.8b5 → polytext-0.2.8b7}/tests/test_dowload_audio_from_youtube.py +0 -0
  73. {polytext-0.2.8b5 → polytext-0.2.8b7}/tests/test_dowload_audio_from_youtube_helpers.py +0 -0
  74. {polytext-0.2.8b5 → polytext-0.2.8b7}/tests/test_extracted_text_whitespace.py +0 -0
  75. {polytext-0.2.8b5 → polytext-0.2.8b7}/tests/test_gemini_quality_guards.py +0 -0
  76. {polytext-0.2.8b5 → polytext-0.2.8b7}/tests/test_get_audio_transcript_from_gcs.py +0 -0
  77. {polytext-0.2.8b5 → polytext-0.2.8b7}/tests/test_get_customized_pdf_from_markdown.py +0 -0
  78. {polytext-0.2.8b5 → polytext-0.2.8b7}/tests/test_get_document_ocr.py +0 -0
  79. {polytext-0.2.8b5 → polytext-0.2.8b7}/tests/test_get_document_ocr_azure_oai.py +0 -0
  80. {polytext-0.2.8b5 → polytext-0.2.8b7}/tests/test_get_document_text.py +0 -0
  81. {polytext-0.2.8b5 → polytext-0.2.8b7}/tests/test_get_document_text_from_gcs.py +0 -0
  82. {polytext-0.2.8b5 → polytext-0.2.8b7}/tests/test_get_ocr_from_image.py +0 -0
  83. {polytext-0.2.8b5 → polytext-0.2.8b7}/tests/test_get_text_from_markdown.py +0 -0
  84. {polytext-0.2.8b5 → polytext-0.2.8b7}/tests/test_get_video_transcript_from_gcs.py +0 -0
  85. {polytext-0.2.8b5 → polytext-0.2.8b7}/tests/test_library.py +0 -0
  86. {polytext-0.2.8b5 → polytext-0.2.8b7}/tests/test_markdown_loader_gzip.py +0 -0
  87. {polytext-0.2.8b5 → polytext-0.2.8b7}/tests/test_markitdown_html.py +0 -0
  88. {polytext-0.2.8b5 → polytext-0.2.8b7}/tests/test_notebook_loader.py +0 -0
  89. {polytext-0.2.8b5 → polytext-0.2.8b7}/tests/test_ocr_fallbacks.py +0 -0
  90. {polytext-0.2.8b5 → polytext-0.2.8b7}/tests/test_ocr_image_descriptions.py +0 -0
  91. {polytext-0.2.8b5 → polytext-0.2.8b7}/tests/test_pain_text.py +0 -0
  92. {polytext-0.2.8b5 → polytext-0.2.8b7}/tests/test_pdf_conversion_error.py +0 -0
  93. {polytext-0.2.8b5 → polytext-0.2.8b7}/tests/test_python_version_metadata.py +0 -0
  94. {polytext-0.2.8b5 → polytext-0.2.8b7}/tests/test_split_audio_with_llm.py +0 -0
  95. {polytext-0.2.8b5 → polytext-0.2.8b7}/tests/test_transcribe_s3_images_from_csv.py +0 -0
  96. {polytext-0.2.8b5 → polytext-0.2.8b7}/tests/test_transcribe_s3_images_from_csv_script.py +0 -0
  97. {polytext-0.2.8b5 → polytext-0.2.8b7}/tests/test_xml_xbrl_loader.py +0 -0
  98. {polytext-0.2.8b5 → polytext-0.2.8b7}/tests/test_youtube_gemini_minimal_check.py +0 -0
  99. {polytext-0.2.8b5 → polytext-0.2.8b7}/tests/test_youtube_llm_fallbacks.py +0 -0
  100. {polytext-0.2.8b5 → polytext-0.2.8b7}/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.8b7
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 (
@@ -62,6 +63,8 @@ AUDIO_DEFAULT_MAX_OUTPUT_TOKENS = 4096
62
63
  AUDIO_MAX_ADAPTIVE_SPLIT_DEPTH = 1
63
64
  AUDIO_ADAPTIVE_SPLIT_OVERLAP_MS = 2000
64
65
  AUDIO_LONG_DURATION_THRESHOLD_MS = 80 * 60 * 1000
66
+ AUDIO_LOW_TEXT_THRESHOLD_WORDS_PER_MINUTE = 20
67
+ AUDIO_LOW_TEXT_MIN_DURATION_MS = 60_000
65
68
  AUDIO_TAIL_REPETITION_LINES = int(os.getenv("AUDIO_TAIL_REPETITION_LINES", "200"))
66
69
  AUDIO_TAIL_REPETITION_THRESHOLD = float(os.getenv("AUDIO_TAIL_REPETITION_THRESHOLD", "0.35"))
67
70
  AUDIO_FALLBACK_SOURCE_PATTERN = os.getenv("AUDIO_FALLBACK_SOURCE_PATTERN", "flash-lite")
@@ -73,6 +76,199 @@ AUDIO_PROMPT_VARIANT_DEFAULT = "default"
73
76
  AUDIO_PROMPT_VARIANT_NON_LITERAL_FALLBACK = "non_literal_fallback"
74
77
  AUDIO_RETRIABLE_OUTPUT_ERROR_CODES = (996, 997, 998, 999)
75
78
  NO_HUMAN_SPEECH_MARKER = "no human speech detected"
79
+ AUDIO_LANGUAGE_VALIDATION_MIN_WORDS = int(os.getenv("AUDIO_LANGUAGE_VALIDATION_MIN_WORDS", "50"))
80
+ AUDIO_LANGUAGE_VALIDATION_MIN_CONFIDENCE = float(
81
+ os.getenv("AUDIO_LANGUAGE_VALIDATION_MIN_CONFIDENCE", "0.80")
82
+ )
83
+ AUDIO_LANGUAGE_VALIDATION_MIN_SUPPORT = float(
84
+ os.getenv("AUDIO_LANGUAGE_VALIDATION_MIN_SUPPORT", "0.67")
85
+ )
86
+ AUDIO_LANGUAGE_VALIDATION_MIN_FOREIGN_WINDOWS = int(
87
+ os.getenv("AUDIO_LANGUAGE_VALIDATION_MIN_FOREIGN_WINDOWS", "2")
88
+ )
89
+
90
+
91
+ def _normalize_language_detection(result) -> tuple[str, float]:
92
+ if isinstance(result, list):
93
+ result = result[0] if result else {}
94
+ if not isinstance(result, dict):
95
+ return "", 0.0
96
+ language = str(result.get("lang", "")).lower().split("-")[0]
97
+ return language, float(result.get("score", 0.0) or 0.0)
98
+
99
+
100
+ def detect_dominant_language(
101
+ text: str,
102
+ max_windows: int = 5,
103
+ window_chars: int = 160,
104
+ ) -> dict:
105
+ """Detect the dominant language over multiple short, evenly spaced text windows."""
106
+ normalized_text = re.sub(r"\s+", " ", text or "").strip()
107
+ if not normalized_text:
108
+ return {
109
+ "lang": "",
110
+ "score": 0.0,
111
+ "support": 0.0,
112
+ "windows": 0,
113
+ "language_windows": {},
114
+ }
115
+
116
+ if len(normalized_text) <= window_chars:
117
+ samples = [normalized_text]
118
+ else:
119
+ last_start = len(normalized_text) - window_chars
120
+ sample_count = min(max_windows, max(2, len(normalized_text) // window_chars))
121
+ starts = {
122
+ round(index * last_start / (sample_count - 1))
123
+ for index in range(sample_count)
124
+ }
125
+ samples = [normalized_text[start:start + window_chars] for start in sorted(starts)]
126
+
127
+ language_scores = {}
128
+ language_counts = {}
129
+ valid_windows = 0
130
+ for sample in samples:
131
+ try:
132
+ detected = detect(sample, low_memory=True)
133
+ except TypeError:
134
+ # fast-langdetect >= 1.0 uses model/k instead of low_memory.
135
+ detected = detect(sample, model="lite", k=1)
136
+ except Exception:
137
+ logger.exception("Language detection failed for an audio transcript window")
138
+ continue
139
+
140
+ language, score = _normalize_language_detection(detected)
141
+ if not language:
142
+ continue
143
+ valid_windows += 1
144
+ language_scores[language] = language_scores.get(language, 0.0) + score
145
+ language_counts[language] = language_counts.get(language, 0) + 1
146
+
147
+ if not valid_windows:
148
+ return {
149
+ "lang": "",
150
+ "score": 0.0,
151
+ "support": 0.0,
152
+ "windows": 0,
153
+ "language_windows": {},
154
+ }
155
+
156
+ dominant_language = max(language_scores, key=language_scores.get)
157
+ count = language_counts[dominant_language]
158
+ return {
159
+ "lang": dominant_language,
160
+ "score": language_scores[dominant_language] / count,
161
+ "support": count / valid_windows,
162
+ "windows": valid_windows,
163
+ "language_windows": {
164
+ language: {
165
+ "windows": language_counts[language],
166
+ "score": language_scores[language] / language_counts[language],
167
+ }
168
+ for language in language_counts
169
+ },
170
+ }
171
+
172
+
173
+ def find_suspicious_fallback_language_indices(
174
+ transcripts: list[str],
175
+ chunk_results: list[dict | None],
176
+ ) -> list[int]:
177
+ """Find internal fallback chunks whose language conflicts with two agreeing neighbours."""
178
+ suspicious_indices = []
179
+ logger.info("Starting fallback language validation for %s transcript chunks", len(transcripts))
180
+
181
+ def has_fallback(result: dict | None) -> bool:
182
+ if not result:
183
+ return False
184
+ if result.get("fallback_from_model") or result.get("fallback_to_model"):
185
+ return True
186
+ return any(has_fallback(item) for item in result.get("split_results", []))
187
+
188
+ for index in range(1, len(transcripts) - 1):
189
+ result = chunk_results[index] or {}
190
+ if not has_fallback(result):
191
+ continue
192
+
193
+ text = transcripts[index]
194
+ word_count = len(re.findall(r"\b\w+\b", text, flags=re.UNICODE))
195
+ if word_count < AUDIO_LANGUAGE_VALIDATION_MIN_WORDS:
196
+ logger.info(
197
+ "Skipping language validation for fallback chunk %s: only %s words (minimum %s)",
198
+ index + 1,
199
+ word_count,
200
+ AUDIO_LANGUAGE_VALIDATION_MIN_WORDS,
201
+ )
202
+ continue
203
+
204
+ candidate_language = detect_dominant_language(text)
205
+ previous_language = detect_dominant_language(transcripts[index - 1])
206
+ next_language = detect_dominant_language(transcripts[index + 1])
207
+ logger.info(
208
+ "Language validation for fallback chunk %s: previous=%s, candidate=%s, next=%s",
209
+ index + 1,
210
+ previous_language,
211
+ candidate_language,
212
+ next_language,
213
+ )
214
+ neighbour_profiles = (previous_language, next_language)
215
+ if any(
216
+ profile["score"] < AUDIO_LANGUAGE_VALIDATION_MIN_CONFIDENCE
217
+ or profile["support"] < AUDIO_LANGUAGE_VALIDATION_MIN_SUPPORT
218
+ for profile in neighbour_profiles
219
+ ):
220
+ logger.info(
221
+ "Skipping language comparison for fallback chunk %s: neighbour confidence or support below threshold",
222
+ index + 1,
223
+ )
224
+ continue
225
+
226
+ if previous_language["lang"] != next_language["lang"]:
227
+ logger.info(
228
+ "Skipping language comparison for fallback chunk %s: neighbouring languages disagree",
229
+ index + 1,
230
+ )
231
+ continue
232
+
233
+ expected_language = previous_language["lang"]
234
+ foreign_windows = {
235
+ language: profile
236
+ for language, profile in candidate_language.get("language_windows", {}).items()
237
+ if (
238
+ language != expected_language
239
+ and profile["windows"] >= AUDIO_LANGUAGE_VALIDATION_MIN_FOREIGN_WINDOWS
240
+ and profile["score"] >= AUDIO_LANGUAGE_VALIDATION_MIN_CONFIDENCE
241
+ )
242
+ }
243
+ if foreign_windows:
244
+ logger.warning(
245
+ "Mixed-language anomaly detected for fallback chunk %s: expected %s, foreign windows=%s",
246
+ index + 1,
247
+ expected_language,
248
+ foreign_windows,
249
+ )
250
+ suspicious_indices.append(index)
251
+ continue
252
+
253
+ if (
254
+ candidate_language["score"] >= AUDIO_LANGUAGE_VALIDATION_MIN_CONFIDENCE
255
+ and candidate_language["support"] >= AUDIO_LANGUAGE_VALIDATION_MIN_SUPPORT
256
+ and candidate_language["lang"] != expected_language
257
+ ):
258
+ logger.warning(
259
+ "Language anomaly detected for fallback chunk %s: %s between two %s chunks",
260
+ index + 1,
261
+ candidate_language["lang"],
262
+ expected_language,
263
+ )
264
+ suspicious_indices.append(index)
265
+ elif candidate_language["support"] < AUDIO_LANGUAGE_VALIDATION_MIN_SUPPORT:
266
+ logger.info(
267
+ "No actionable language anomaly for fallback chunk %s: mixed windows remain below thresholds",
268
+ index + 1,
269
+ )
270
+
271
+ return suspicious_indices
76
272
 
77
273
 
78
274
  def normalize_no_human_speech_marker(text: str) -> tuple[str, bool]:
@@ -90,6 +286,37 @@ def normalize_no_human_speech_marker(text: str) -> tuple[str, bool]:
90
286
  return cleaned_text, False
91
287
 
92
288
 
289
+ def summarize_audio_chunk_quality(chunks: list[dict], transcripts: list[str]) -> dict:
290
+ """Report empty and unusually short transcripts without discarding any text.
291
+
292
+ Indices are one-based. Short audio chunks are excluded from the low-text
293
+ count because a brief spoken phrase can be a complete transcription.
294
+ """
295
+ empty_indices = []
296
+ low_text_indices = []
297
+ for index, (chunk, transcript) in enumerate(zip(chunks, transcripts), start=1):
298
+ word_count = len(transcript.split())
299
+ if word_count == 0:
300
+ empty_indices.append(index)
301
+ else:
302
+ duration_ms = chunk.get("duration_ms", 0)
303
+ if (
304
+ duration_ms >= AUDIO_LOW_TEXT_MIN_DURATION_MS
305
+ and word_count * 60_000 < AUDIO_LOW_TEXT_THRESHOLD_WORDS_PER_MINUTE * duration_ms
306
+ ):
307
+ low_text_indices.append(index)
308
+
309
+ return {
310
+ "total_chunks": len(chunks),
311
+ "empty_chunks": len(empty_indices),
312
+ "low_text_chunks": len(low_text_indices),
313
+ "empty_chunk_indices": empty_indices,
314
+ "low_text_chunk_indices": low_text_indices,
315
+ "low_text_threshold_words_per_minute": AUDIO_LOW_TEXT_THRESHOLD_WORDS_PER_MINUTE,
316
+ "low_text_min_duration_ms": AUDIO_LOW_TEXT_MIN_DURATION_MS,
317
+ }
318
+
319
+
93
320
  def add_line_break_after_each_sentence(text: str) -> str:
94
321
  if not text:
95
322
  return text
@@ -782,6 +1009,41 @@ class AudioToTextConverter:
782
1009
  transcript_dict = self.transcribe_audio(chunk["file_path"])
783
1010
  return index, transcript_dict
784
1011
 
1012
+ def recover_suspicious_fallback_languages(
1013
+ self,
1014
+ chunks: list[dict],
1015
+ transcript_chunks: list[str],
1016
+ chunk_results: list[dict | None],
1017
+ ) -> None:
1018
+ """Re-transcribe suspicious fallback chunks in smaller parts, in place."""
1019
+ suspicious_indices = find_suspicious_fallback_language_indices(
1020
+ transcript_chunks,
1021
+ chunk_results,
1022
+ )
1023
+ for index in suspicious_indices:
1024
+ original_result = chunk_results[index] or {}
1025
+ logger.warning(
1026
+ "Fallback transcript language conflicts with both neighbouring chunks; "
1027
+ "re-transcribing chunk %s with adaptive split",
1028
+ index + 1,
1029
+ )
1030
+ try:
1031
+ recovered_result = self.transcribe_audio_halves(chunks[index]["file_path"])
1032
+ except Exception:
1033
+ logger.exception(
1034
+ "Language-based recovery failed for chunk %s; preserving the original transcript",
1035
+ index + 1,
1036
+ )
1037
+ continue
1038
+
1039
+ recovered_result["language_validation_triggered"] = True
1040
+ recovered_result["language_validation_original_model"] = original_result.get(
1041
+ "completion_model"
1042
+ )
1043
+ transcript_chunks[index] = recovered_result["transcript"]
1044
+ chunks[index]["transcript"] = recovered_result["transcript"]
1045
+ chunk_results[index] = recovered_result
1046
+
785
1047
  def format_audio_output_text(self, text: str) -> str:
786
1048
  return add_line_break_after_each_sentence(text)
787
1049
 
@@ -801,6 +1063,8 @@ class AudioToTextConverter:
801
1063
  - prompt_tokens (int): Total number of prompt tokens used
802
1064
  - completion_model (str): Name of the transcription model used
803
1065
  - completion_model_provider (str): Provider of the transcription model
1066
+ - audio_chunk_quality (dict): Counts of empty and low-text chunks,
1067
+ with one-based indices and the applied words-per-minute threshold
804
1068
 
805
1069
  Raises:
806
1070
  ValueError: If the audio file format is not recognized
@@ -869,15 +1133,19 @@ class AudioToTextConverter:
869
1133
  }
870
1134
 
871
1135
  # Process completed transcriptions in order of completion
872
- completion_tokens = 0
873
- prompt_tokens = 0
874
1136
  for future in as_completed(future_to_chunk):
875
1137
  index, transcript_dict = future.result()
876
1138
  chunks[index]["transcript"] = transcript_dict["transcript"]
877
1139
  transcript_chunks[index] = transcript_dict["transcript"]
878
1140
  chunk_results[index] = transcript_dict
879
- completion_tokens += transcript_dict["completion_tokens"]
880
- prompt_tokens += transcript_dict["prompt_tokens"]
1141
+
1142
+ self.recover_suspicious_fallback_languages(
1143
+ chunks,
1144
+ transcript_chunks,
1145
+ chunk_results,
1146
+ )
1147
+ completion_tokens = sum(result["completion_tokens"] for result in chunk_results)
1148
+ prompt_tokens = sum(result["prompt_tokens"] for result in chunk_results)
881
1149
 
882
1150
  text_merger = TextMerger(llm_api_key=self.llm_api_key)
883
1151
  # Merge all transcripts
@@ -888,7 +1156,8 @@ class AudioToTextConverter:
888
1156
  "completion_tokens": completion_tokens + full_text_merged_dict["completion_tokens"],
889
1157
  "prompt_tokens": prompt_tokens + full_text_merged_dict["prompt_tokens"],
890
1158
  "completion_model": self.transcription_model,
891
- "completion_model_provider": self.transcription_model_provider
1159
+ "completion_model_provider": self.transcription_model_provider,
1160
+ "audio_chunk_quality": summarize_audio_chunk_quality(chunks, transcript_chunks),
892
1161
  }
893
1162
  if len(chunk_results) == 1 and chunk_results[0]:
894
1163
  for key in (
@@ -42,6 +42,7 @@ logger = logging.getLogger(__name__)
42
42
 
43
43
  MIN_DOC_TEXT_LENGTH_ACCEPTED = int(os.getenv("MIN_DOC_TEXT_LENGTH_ACCEPTED", "400"))
44
44
  OCR_INCLUDE_IMAGE_DESCRIPTIONS_ENV = "OCR_INCLUDE_IMAGE_DESCRIPTIONS"
45
+ FALLBACK_OCR_ENV = "FALLBACK_OCR"
45
46
  LLM_OUTPUT_ERROR_CODES = {
46
47
  995: "INVALID_ARGUMENT",
47
48
  996: "RECITATION",
@@ -119,7 +120,7 @@ class BaseLoader:
119
120
  - target_size (int, optional): Target file size in bytes. Defaults to 1MB
120
121
  - source (str): Source of the document. Must be either "cloud" or "local"
121
122
  - fallback_ocr (bool, optional): If True, Optical Character Recognition (OCR) will be used as a fallback
122
- if direct text extraction fails, particularly for image-based content. Defaults to True.
123
+ if direct text extraction fails. When omitted, defaults from FALLBACK_OCR and otherwise to False.
123
124
  - save_transcript_chunks (bool, optional): Whether to save chunk transcripts in final output. Defaults to False.
124
125
  - bitrate_quality (int, optional): Variable bitrate quality from 0-9 (9 being lowest). Defaults to 9
125
126
 
@@ -143,7 +144,10 @@ class BaseLoader:
143
144
  self.kwargs = kwargs
144
145
  self.target_size = kwargs.get("target_size", 1)
145
146
  self.source = kwargs.get("source", "cloud")
146
- self.fallback_ocr = kwargs.get("fallback_ocr", False)
147
+ self.fallback_ocr = kwargs.get(
148
+ "fallback_ocr",
149
+ _read_bool_env(FALLBACK_OCR_ENV),
150
+ )
147
151
  self.save_transcript_chunks = kwargs.get("save_transcript_chunks", False)
148
152
  self.bitrate_quality = kwargs.get("bitrate_quality", 9)
149
153
  self.aws_auth_mode = kwargs.get("aws_auth_mode")
@@ -548,6 +552,7 @@ class BaseLoader:
548
552
  - completion_model (str): Model name used, from the first processed source.
549
553
  - completion_model_provider (str): Provider of the model, from the first processed source.
550
554
  - text_chunks (list, optional): List of text chunks, if chunking was applied.
555
+ - audio_chunk_quality (dict, optional): Counts of empty and low-text audio chunks.
551
556
  - type (str): Type of the processed source (e.g., ocr, video, audio, text).
552
557
  - input (str): The input path or URL of the first processed source.
553
558
  - output_list (list): List of individual extraction results (one per input).
@@ -608,7 +613,7 @@ class BaseLoader:
608
613
  "output_list": [result_dict],
609
614
  }
610
615
 
611
- for metadata_key in ("ocr_failed_pages", "ocr_failed_pages_detail"):
616
+ for metadata_key in ("ocr_failed_pages", "ocr_failed_pages_detail", "audio_chunk_quality"):
612
617
  if metadata_key in result_dict:
613
618
  final_result[metadata_key] = result_dict[metadata_key]
614
619
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: polytext
3
- Version: 0.2.8b5
3
+ Version: 0.2.8b7
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
@@ -56,6 +56,7 @@ polytext/prompts/text_to_md.py
56
56
  polytext/prompts/transcription.py
57
57
  polytext/utils/__init__.py
58
58
  polytext/utils/utils.py
59
+ tests/test_audio_chunk_quality.py
59
60
  tests/test_audio_chunker.py
60
61
  tests/test_audio_comparison_helpers.py
61
62
  tests/test_audio_transcription_model_migration.py
@@ -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.8b7',
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',
@@ -0,0 +1,76 @@
1
+ import tempfile
2
+ import unittest
3
+ from unittest.mock import MagicMock, patch
4
+
5
+ from polytext.converter import audio_to_text
6
+ from polytext.loader import BaseLoader
7
+
8
+
9
+ class AudioChunkQualityTests(unittest.TestCase):
10
+ def test_counts_empty_and_low_text_chunks_separately(self):
11
+ chunks = [
12
+ {"duration_ms": 120_000},
13
+ {"duration_ms": 120_000},
14
+ {"duration_ms": 120_000},
15
+ {"duration_ms": 20_000},
16
+ {"duration_ms": 1_200_000},
17
+ ]
18
+ transcripts = ["parola " * 40, " \n", "parola " * 12, "ciao", "parola " * 100]
19
+
20
+ quality = audio_to_text.summarize_audio_chunk_quality(chunks, transcripts)
21
+
22
+ self.assertEqual(quality, {
23
+ "total_chunks": 5,
24
+ "empty_chunks": 1,
25
+ "low_text_chunks": 2,
26
+ "empty_chunk_indices": [2],
27
+ "low_text_chunk_indices": [3, 5],
28
+ "low_text_threshold_words_per_minute": 20,
29
+ "low_text_min_duration_ms": 60_000,
30
+ })
31
+
32
+ @patch("polytext.converter.audio_to_text.TextMerger")
33
+ @patch("polytext.converter.audio_to_text.AudioChunker")
34
+ @patch.object(audio_to_text.AudioToTextConverter, "process_chunk")
35
+ def test_quality_is_returned_without_saving_chunk_transcripts(
36
+ self, mock_process_chunk, mock_chunker_cls, mock_merger_cls
37
+ ):
38
+ chunker = mock_chunker_cls.return_value
39
+ chunker.duration_ms = 240_000
40
+ chunker.extract_chunks.return_value = [
41
+ {"file_path": "/tmp/chunk1.mp3", "duration_ms": 120_000},
42
+ {"file_path": "/tmp/chunk2.mp3", "duration_ms": 120_000},
43
+ ]
44
+ mock_process_chunk.side_effect = lambda chunk, index: (
45
+ index,
46
+ {"transcript": "parola " * 40 if index == 0 else "", "completion_tokens": 4, "prompt_tokens": 5},
47
+ )
48
+ mock_merger_cls.return_value.merge_chunks_with_llm_sequential.return_value = {
49
+ "full_text_merged": "testo finale", "completion_tokens": 0, "prompt_tokens": 0
50
+ }
51
+
52
+ with tempfile.NamedTemporaryFile(suffix=".mp3") as source_audio:
53
+ converter = audio_to_text.AudioToTextConverter()
54
+ result = converter.transcribe_full_audio(source_audio.name)
55
+
56
+ self.assertEqual(result["text"], "testo finale")
57
+ self.assertEqual(result["audio_chunk_quality"]["total_chunks"], 2)
58
+ self.assertEqual(result["audio_chunk_quality"]["empty_chunks"], 1)
59
+ self.assertNotIn("text_chunks", result)
60
+
61
+ def test_quality_reaches_top_level_loader_response(self):
62
+ quality = {"total_chunks": 2, "empty_chunks": 1, "low_text_chunks": 0}
63
+ file_loader = MagicMock()
64
+ file_loader.load.return_value = {
65
+ "text": "trascrizione", "completion_tokens": 3, "prompt_tokens": 4,
66
+ "audio_chunk_quality": quality,
67
+ }
68
+
69
+ response = BaseLoader().run_loader_class(file_loader, ["audio.mp3"])
70
+
71
+ self.assertEqual(response["audio_chunk_quality"], quality)
72
+ self.assertEqual(response["output_list"][0]["audio_chunk_quality"], quality)
73
+
74
+
75
+ if __name__ == "__main__":
76
+ unittest.main()
@@ -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
 
@@ -1,3 +1,4 @@
1
+ import os
1
2
  import unittest
2
3
  from types import SimpleNamespace
3
4
  from unittest.mock import Mock, patch
@@ -60,6 +61,24 @@ class _BeautifulTextLoader(BaseLoader):
60
61
 
61
62
 
62
63
  class TestBaseLoaderErrorMapping(unittest.TestCase):
64
+ def test_document_ocr_fallback_is_enabled_by_environment(self):
65
+ with patch.dict(os.environ, {"FALLBACK_OCR": "true"}, clear=True):
66
+ loader = BaseLoader()
67
+
68
+ self.assertTrue(loader.fallback_ocr)
69
+
70
+ def test_explicit_document_ocr_fallback_setting_overrides_environment(self):
71
+ with patch.dict(os.environ, {"FALLBACK_OCR": "true"}, clear=True):
72
+ loader = BaseLoader(fallback_ocr=False)
73
+
74
+ self.assertFalse(loader.fallback_ocr)
75
+
76
+ def test_document_ocr_fallback_remains_disabled_without_configuration(self):
77
+ with patch.dict(os.environ, {}, clear=True):
78
+ loader = BaseLoader()
79
+
80
+ self.assertFalse(loader.fallback_ocr)
81
+
63
82
  def test_llm_output_empty_document_codes_are_raised_as_loader_errors(self):
64
83
  cases = [
65
84
  (995, "INVALID_ARGUMENT"),
File without changes
File without changes
File without changes
File without changes