polytext 0.2.8b4__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.8b4 → polytext-0.2.8b6}/PKG-INFO +2 -1
  2. {polytext-0.2.8b4 → polytext-0.2.8b6}/polytext/converter/audio_to_text.py +237 -4
  3. {polytext-0.2.8b4 → polytext-0.2.8b6}/polytext/converter/document_ocr_to_text.py +14 -2
  4. {polytext-0.2.8b4 → polytext-0.2.8b6}/polytext/loader/base.py +6 -1
  5. {polytext-0.2.8b4 → polytext-0.2.8b6}/polytext/loader/document_ocr.py +5 -0
  6. {polytext-0.2.8b4 → polytext-0.2.8b6}/polytext.egg-info/PKG-INFO +2 -1
  7. {polytext-0.2.8b4 → polytext-0.2.8b6}/polytext.egg-info/requires.txt +1 -0
  8. {polytext-0.2.8b4 → polytext-0.2.8b6}/setup.py +1 -1
  9. {polytext-0.2.8b4 → polytext-0.2.8b6}/tests/test_audio_transcription_model_migration.py +168 -1
  10. {polytext-0.2.8b4 → polytext-0.2.8b6}/tests/test_ocr_fallbacks.py +41 -1
  11. {polytext-0.2.8b4 → polytext-0.2.8b6}/tests/test_ocr_image_descriptions.py +17 -0
  12. {polytext-0.2.8b4 → polytext-0.2.8b6}/LICENSE +0 -0
  13. {polytext-0.2.8b4 → polytext-0.2.8b6}/README.md +0 -0
  14. {polytext-0.2.8b4 → polytext-0.2.8b6}/polytext/__init__.py +0 -0
  15. {polytext-0.2.8b4 → polytext-0.2.8b6}/polytext/converter/__init__.py +0 -0
  16. {polytext-0.2.8b4 → polytext-0.2.8b6}/polytext/converter/base.py +0 -0
  17. {polytext-0.2.8b4 → polytext-0.2.8b6}/polytext/converter/beautiful_text.py +0 -0
  18. {polytext-0.2.8b4 → polytext-0.2.8b6}/polytext/converter/document_ocr_to_text_azure_oai.py +0 -0
  19. {polytext-0.2.8b4 → polytext-0.2.8b6}/polytext/converter/gemini_quality_guards.py +0 -0
  20. {polytext-0.2.8b4 → polytext-0.2.8b6}/polytext/converter/html_to_md.py +0 -0
  21. {polytext-0.2.8b4 → polytext-0.2.8b6}/polytext/converter/md_to_text.py +0 -0
  22. {polytext-0.2.8b4 → polytext-0.2.8b6}/polytext/converter/ocr_to_text.py +0 -0
  23. {polytext-0.2.8b4 → polytext-0.2.8b6}/polytext/converter/ocr_to_text_azure_oai.py +0 -0
  24. {polytext-0.2.8b4 → polytext-0.2.8b6}/polytext/converter/pdf.py +0 -0
  25. {polytext-0.2.8b4 → polytext-0.2.8b6}/polytext/converter/text_to_md.py +0 -0
  26. {polytext-0.2.8b4 → polytext-0.2.8b6}/polytext/converter/video_to_audio.py +0 -0
  27. {polytext-0.2.8b4 → polytext-0.2.8b6}/polytext/exceptions/__init__.py +0 -0
  28. {polytext-0.2.8b4 → polytext-0.2.8b6}/polytext/exceptions/base.py +0 -0
  29. {polytext-0.2.8b4 → polytext-0.2.8b6}/polytext/generator/__init__.py +0 -0
  30. {polytext-0.2.8b4 → polytext-0.2.8b6}/polytext/generator/pdf.py +0 -0
  31. {polytext-0.2.8b4 → polytext-0.2.8b6}/polytext/loader/__init__.py +0 -0
  32. {polytext-0.2.8b4 → polytext-0.2.8b6}/polytext/loader/audio.py +0 -0
  33. {polytext-0.2.8b4 → polytext-0.2.8b6}/polytext/loader/aws_auth.py +0 -0
  34. {polytext-0.2.8b4 → polytext-0.2.8b6}/polytext/loader/document.py +0 -0
  35. {polytext-0.2.8b4 → polytext-0.2.8b6}/polytext/loader/downloader/__init__.py +0 -0
  36. {polytext-0.2.8b4 → polytext-0.2.8b6}/polytext/loader/downloader/downloader.py +0 -0
  37. {polytext-0.2.8b4 → polytext-0.2.8b6}/polytext/loader/html.py +0 -0
  38. {polytext-0.2.8b4 → polytext-0.2.8b6}/polytext/loader/markdown.py +0 -0
  39. {polytext-0.2.8b4 → polytext-0.2.8b6}/polytext/loader/notebook.py +0 -0
  40. {polytext-0.2.8b4 → polytext-0.2.8b6}/polytext/loader/ocr.py +0 -0
  41. {polytext-0.2.8b4 → polytext-0.2.8b6}/polytext/loader/plain_text.py +0 -0
  42. {polytext-0.2.8b4 → polytext-0.2.8b6}/polytext/loader/video.py +0 -0
  43. {polytext-0.2.8b4 → polytext-0.2.8b6}/polytext/loader/xml_xbrl.py +0 -0
  44. {polytext-0.2.8b4 → polytext-0.2.8b6}/polytext/loader/youtube.py +0 -0
  45. {polytext-0.2.8b4 → polytext-0.2.8b6}/polytext/loader/youtube_llm.py +0 -0
  46. {polytext-0.2.8b4 → polytext-0.2.8b6}/polytext/processor/__init__.py +0 -0
  47. {polytext-0.2.8b4 → polytext-0.2.8b6}/polytext/processor/audio_chunker.py +0 -0
  48. {polytext-0.2.8b4 → polytext-0.2.8b6}/polytext/processor/text_merger.py +0 -0
  49. {polytext-0.2.8b4 → polytext-0.2.8b6}/polytext/processor/transcript_chunker.py +0 -0
  50. {polytext-0.2.8b4 → polytext-0.2.8b6}/polytext/prompts/__init__.py +0 -0
  51. {polytext-0.2.8b4 → polytext-0.2.8b6}/polytext/prompts/beautiful_text.py +0 -0
  52. {polytext-0.2.8b4 → polytext-0.2.8b6}/polytext/prompts/ocr.py +0 -0
  53. {polytext-0.2.8b4 → polytext-0.2.8b6}/polytext/prompts/text_merging.py +0 -0
  54. {polytext-0.2.8b4 → polytext-0.2.8b6}/polytext/prompts/text_to_md.py +0 -0
  55. {polytext-0.2.8b4 → polytext-0.2.8b6}/polytext/prompts/transcription.py +0 -0
  56. {polytext-0.2.8b4 → polytext-0.2.8b6}/polytext/utils/__init__.py +0 -0
  57. {polytext-0.2.8b4 → polytext-0.2.8b6}/polytext/utils/utils.py +0 -0
  58. {polytext-0.2.8b4 → polytext-0.2.8b6}/polytext.egg-info/SOURCES.txt +0 -0
  59. {polytext-0.2.8b4 → polytext-0.2.8b6}/polytext.egg-info/dependency_links.txt +0 -0
  60. {polytext-0.2.8b4 → polytext-0.2.8b6}/polytext.egg-info/not-zip-safe +0 -0
  61. {polytext-0.2.8b4 → polytext-0.2.8b6}/polytext.egg-info/top_level.txt +0 -0
  62. {polytext-0.2.8b4 → polytext-0.2.8b6}/pyproject.toml +0 -0
  63. {polytext-0.2.8b4 → polytext-0.2.8b6}/setup.cfg +0 -0
  64. {polytext-0.2.8b4 → polytext-0.2.8b6}/tests/test_audio_chunker.py +0 -0
  65. {polytext-0.2.8b4 → polytext-0.2.8b6}/tests/test_audio_comparison_helpers.py +0 -0
  66. {polytext-0.2.8b4 → polytext-0.2.8b6}/tests/test_aws_auth.py +0 -0
  67. {polytext-0.2.8b4 → polytext-0.2.8b6}/tests/test_base_loader_error_mapping.py +0 -0
  68. {polytext-0.2.8b4 → polytext-0.2.8b6}/tests/test_beautiful_text_manual.py +0 -0
  69. {polytext-0.2.8b4 → polytext-0.2.8b6}/tests/test_compare_audio_models.py +0 -0
  70. {polytext-0.2.8b4 → polytext-0.2.8b6}/tests/test_compare_document_ocr_to_text_models.py +0 -0
  71. {polytext-0.2.8b4 → polytext-0.2.8b6}/tests/test_compare_ocr_to_text_models.py +0 -0
  72. {polytext-0.2.8b4 → polytext-0.2.8b6}/tests/test_compare_youtube_models.py +0 -0
  73. {polytext-0.2.8b4 → polytext-0.2.8b6}/tests/test_dowload_audio_from_youtube.py +0 -0
  74. {polytext-0.2.8b4 → polytext-0.2.8b6}/tests/test_dowload_audio_from_youtube_helpers.py +0 -0
  75. {polytext-0.2.8b4 → polytext-0.2.8b6}/tests/test_extracted_text_whitespace.py +0 -0
  76. {polytext-0.2.8b4 → polytext-0.2.8b6}/tests/test_gemini_quality_guards.py +0 -0
  77. {polytext-0.2.8b4 → polytext-0.2.8b6}/tests/test_get_audio_transcript_from_gcs.py +0 -0
  78. {polytext-0.2.8b4 → polytext-0.2.8b6}/tests/test_get_customized_pdf_from_markdown.py +0 -0
  79. {polytext-0.2.8b4 → polytext-0.2.8b6}/tests/test_get_document_ocr.py +0 -0
  80. {polytext-0.2.8b4 → polytext-0.2.8b6}/tests/test_get_document_ocr_azure_oai.py +0 -0
  81. {polytext-0.2.8b4 → polytext-0.2.8b6}/tests/test_get_document_text.py +0 -0
  82. {polytext-0.2.8b4 → polytext-0.2.8b6}/tests/test_get_document_text_from_gcs.py +0 -0
  83. {polytext-0.2.8b4 → polytext-0.2.8b6}/tests/test_get_ocr_from_image.py +0 -0
  84. {polytext-0.2.8b4 → polytext-0.2.8b6}/tests/test_get_text_from_markdown.py +0 -0
  85. {polytext-0.2.8b4 → polytext-0.2.8b6}/tests/test_get_video_transcript_from_gcs.py +0 -0
  86. {polytext-0.2.8b4 → polytext-0.2.8b6}/tests/test_library.py +0 -0
  87. {polytext-0.2.8b4 → polytext-0.2.8b6}/tests/test_markdown_loader_gzip.py +0 -0
  88. {polytext-0.2.8b4 → polytext-0.2.8b6}/tests/test_markitdown_html.py +0 -0
  89. {polytext-0.2.8b4 → polytext-0.2.8b6}/tests/test_notebook_loader.py +0 -0
  90. {polytext-0.2.8b4 → polytext-0.2.8b6}/tests/test_pain_text.py +0 -0
  91. {polytext-0.2.8b4 → polytext-0.2.8b6}/tests/test_pdf_conversion_error.py +0 -0
  92. {polytext-0.2.8b4 → polytext-0.2.8b6}/tests/test_python_version_metadata.py +0 -0
  93. {polytext-0.2.8b4 → polytext-0.2.8b6}/tests/test_split_audio_with_llm.py +0 -0
  94. {polytext-0.2.8b4 → polytext-0.2.8b6}/tests/test_transcribe_s3_images_from_csv.py +0 -0
  95. {polytext-0.2.8b4 → polytext-0.2.8b6}/tests/test_transcribe_s3_images_from_csv_script.py +0 -0
  96. {polytext-0.2.8b4 → polytext-0.2.8b6}/tests/test_xml_xbrl_loader.py +0 -0
  97. {polytext-0.2.8b4 → polytext-0.2.8b6}/tests/test_youtube_gemini_minimal_check.py +0 -0
  98. {polytext-0.2.8b4 → polytext-0.2.8b6}/tests/test_youtube_llm_fallbacks.py +0 -0
  99. {polytext-0.2.8b4 → 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.8b4
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
@@ -115,6 +115,7 @@ def get_document_ocr(
115
115
  max_output_tokens: int | None = None,
116
116
  include_image_descriptions: bool = False,
117
117
  allow_partial_ocr_failures: bool = False,
118
+ ocr_render_dpi: int | None = None,
118
119
  ):
119
120
  """
120
121
  Convenience function to extract text from an image file using OCR, optionally formatted as Markdown.
@@ -141,6 +142,8 @@ def get_document_ocr(
141
142
  allow_partial_ocr_failures (bool, optional): If True, pages that still
142
143
  fail OCR after all retries are recorded inline instead of aborting
143
144
  the whole document extraction. Defaults to False.
145
+ ocr_render_dpi (int | None, optional): Render PDF pages at this DPI before
146
+ OCR. When omitted, preserves PyMuPDF's default rendering behavior.
144
147
 
145
148
  Returns:
146
149
  dict: Dictionary containing the OCR results and metadata.
@@ -155,6 +158,7 @@ def get_document_ocr(
155
158
  max_output_tokens=max_output_tokens,
156
159
  include_image_descriptions=include_image_descriptions,
157
160
  allow_partial_ocr_failures=allow_partial_ocr_failures,
161
+ ocr_render_dpi=ocr_render_dpi,
158
162
  )
159
163
  return converter.get_document_ocr(document_for_ocr)
160
164
 
@@ -164,7 +168,8 @@ class DocumentOCRToTextConverter:
164
168
  page_range=None, timeout_minutes: int = None, fallback_stage: int = 0,
165
169
  max_output_tokens: int | None = None, include_image_descriptions: bool = False,
166
170
  prompt_variant: str = OCR_PROMPT_VARIANT_DEFAULT,
167
- allow_partial_ocr_failures: bool = False):
171
+ allow_partial_ocr_failures: bool = False,
172
+ ocr_render_dpi: int | None = None):
168
173
  """
169
174
  Initialize the DocumentOCRToTextConverter class with specified OCR model and formatting options.
170
175
 
@@ -192,6 +197,8 @@ class DocumentOCRToTextConverter:
192
197
  allow_partial_ocr_failures (bool, optional): If True, pages that still
193
198
  fail OCR after all retries are recorded inline instead of aborting
194
199
  the whole document extraction. Defaults to False.
200
+ ocr_render_dpi (int | None, optional): Render PDF pages at this DPI
201
+ before OCR. Defaults to PyMuPDF's native rendering behavior.
195
202
 
196
203
  Raises:
197
204
  OSError: If temp directory creation fails
@@ -207,6 +214,7 @@ class DocumentOCRToTextConverter:
207
214
  self.include_image_descriptions = include_image_descriptions
208
215
  self.prompt_variant = prompt_variant
209
216
  self.allow_partial_ocr_failures = allow_partial_ocr_failures
217
+ self.ocr_render_dpi = ocr_render_dpi
210
218
  requested_output_tokens = OCR_MAX_OUTPUT_TOKENS if max_output_tokens is None else max_output_tokens
211
219
  self.max_output_tokens = max(requested_output_tokens, OCR_MIN_OUTPUT_TOKENS)
212
220
  self.fallback_stage = fallback_stage
@@ -297,6 +305,7 @@ class DocumentOCRToTextConverter:
297
305
  include_image_descriptions=self.include_image_descriptions,
298
306
  prompt_variant=resolved_prompt_variant,
299
307
  allow_partial_ocr_failures=self.allow_partial_ocr_failures,
308
+ ocr_render_dpi=self.ocr_render_dpi,
300
309
  )
301
310
  result = fallback_converter.get_ocr(
302
311
  file_for_ocr=file_for_ocr,
@@ -553,7 +562,10 @@ class DocumentOCRToTextConverter:
553
562
 
554
563
  try:
555
564
  # Convert page to image
556
- pix = page.get_pixmap()
565
+ if self.ocr_render_dpi is None:
566
+ pix = page.get_pixmap()
567
+ else:
568
+ pix = page.get_pixmap(dpi=self.ocr_render_dpi, alpha=False)
557
569
  pix.save(temp_image_path)
558
570
 
559
571
  # Perform OCR on the page
@@ -92,6 +92,7 @@ class BaseLoader:
92
92
  ocr_model: str = "gpt-5-mini", timeout_minutes: int | None = None,
93
93
  include_image_descriptions: bool | None = None,
94
94
  force_ocr: bool = False,
95
+ ocr_render_dpi: int | None = None,
95
96
  **kwargs):
96
97
  """
97
98
  Initialize the BaseLoader with cloud storage and LLM configurations.
@@ -112,6 +113,8 @@ class BaseLoader:
112
113
  If None, defaults from OCR_INCLUDE_IMAGE_DESCRIPTIONS. Defaults to None.
113
114
  force_ocr (bool, optional): If True, supported document files are routed to
114
115
  OCRLoader instead of the standard DocumentLoader. Defaults to False.
116
+ ocr_render_dpi (int | None, optional): Render document pages at this DPI
117
+ before OCR. Defaults to the existing renderer behavior.
115
118
  **kwargs: Additional keyword arguments to pass to the underlying loader or extraction logic.
116
119
  - target_size (int, optional): Target file size in bytes. Defaults to 1MB
117
120
  - source (str): Source of the document. Must be either "cloud" or "local"
@@ -136,6 +139,7 @@ class BaseLoader:
136
139
  else include_image_descriptions
137
140
  )
138
141
  self.force_ocr = force_ocr
142
+ self.ocr_render_dpi = ocr_render_dpi
139
143
  self.kwargs = kwargs
140
144
  self.target_size = kwargs.get("target_size", 1)
141
145
  self.source = kwargs.get("source", "cloud")
@@ -422,7 +426,7 @@ class BaseLoader:
422
426
  file_extension = file_extension.lower()
423
427
 
424
428
  if is_document_fallback:
425
- return DocumentOCRLoader(llm_api_key=llm_api_key, markdown_output=self.markdown_output, temp_dir=self.temp_dir, timeout_minutes=self.timeout_minutes, ocr_provider=self.provider, ocr_model=self.ocr_model, include_image_descriptions=self.include_image_descriptions, **kwargs)
429
+ return DocumentOCRLoader(llm_api_key=llm_api_key, markdown_output=self.markdown_output, temp_dir=self.temp_dir, timeout_minutes=self.timeout_minutes, ocr_provider=self.provider, ocr_model=self.ocr_model, include_image_descriptions=self.include_image_descriptions, ocr_render_dpi=self.ocr_render_dpi, **kwargs)
426
430
 
427
431
  if file_extension in [".xml", ".xbrl"]:
428
432
  return XmlXbrlLoader(temp_dir=self.temp_dir, markdown_output=self.markdown_output, **kwargs)
@@ -458,6 +462,7 @@ class BaseLoader:
458
462
  ocr_provider=self.provider,
459
463
  ocr_model=self.ocr_model,
460
464
  include_image_descriptions=self.include_image_descriptions,
465
+ ocr_render_dpi=self.ocr_render_dpi,
461
466
  **document_kwargs,
462
467
  )
463
468
 
@@ -47,6 +47,7 @@ class DocumentOCRLoader:
47
47
  ocr_model: str | None = None,
48
48
  include_image_descriptions: bool = False,
49
49
  allow_partial_ocr_failures: bool = False,
50
+ ocr_render_dpi: int | None = None,
50
51
  **kwargs
51
52
  ):
52
53
  """
@@ -86,6 +87,8 @@ class DocumentOCRLoader:
86
87
  allow_partial_ocr_failures (bool, optional): If True, pages that still
87
88
  fail OCR after all retries are recorded inline instead of aborting
88
89
  the whole document extraction. Defaults to False.
90
+ ocr_render_dpi (int | None, optional): Render PDF pages at this DPI
91
+ before OCR. Defaults to the existing PyMuPDF rendering behavior.
89
92
  **kwargs:
90
93
  max_output_tokens (int, optional): Maximum Gemini output tokens for
91
94
  Google document OCR generation.
@@ -110,6 +113,7 @@ class DocumentOCRLoader:
110
113
  self.ocr_model = ocr_model
111
114
  self.include_image_descriptions = include_image_descriptions
112
115
  self.allow_partial_ocr_failures = allow_partial_ocr_failures
116
+ self.ocr_render_dpi = ocr_render_dpi
113
117
  self.max_output_tokens = kwargs.get("max_output_tokens")
114
118
 
115
119
  # Set up custom temp directory
@@ -277,6 +281,7 @@ class DocumentOCRLoader:
277
281
  max_output_tokens=self.max_output_tokens,
278
282
  include_image_descriptions=self.include_image_descriptions,
279
283
  allow_partial_ocr_failures=self.allow_partial_ocr_failures,
284
+ ocr_render_dpi=self.ocr_render_dpi,
280
285
  )
281
286
 
282
287
  result_dict["type"] = self.type
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: polytext
3
- Version: 0.2.8b4
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.8b4',
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
 
@@ -88,8 +88,10 @@ class _FakePixmap:
88
88
  class _FakePage:
89
89
  def __init__(self, payload: bytes = b"fake-page-image"):
90
90
  self.payload = payload
91
+ self.pixmap_calls = []
91
92
 
92
- def get_pixmap(self):
93
+ def get_pixmap(self, **kwargs):
94
+ self.pixmap_calls.append(kwargs)
93
95
  return _FakePixmap(payload=self.payload)
94
96
 
95
97
 
@@ -112,6 +114,44 @@ def _immediate_as_completed(futures):
112
114
 
113
115
 
114
116
  class TestOcrFallbacks(unittest.TestCase):
117
+ @patch("polytext.converter.document_ocr_to_text.genai.Client")
118
+ @patch("concurrent.futures.as_completed", side_effect=_immediate_as_completed)
119
+ @patch("concurrent.futures.ThreadPoolExecutor", _ImmediateExecutor)
120
+ @patch("fitz.open")
121
+ def test_document_ocr_renders_pages_at_requested_dpi(
122
+ self,
123
+ mock_fitz_open,
124
+ _mock_as_completed,
125
+ mock_client_cls,
126
+ ):
127
+ page = _FakePage()
128
+ mock_fitz_open.return_value = _FakePdf([page])
129
+ mock_client_cls.return_value = _FakeClient()
130
+
131
+ converter = DocumentOCRToTextConverter(ocr_render_dpi=200)
132
+ converter.get_document_ocr("dummy.pdf")
133
+
134
+ self.assertEqual(page.pixmap_calls, [{"dpi": 200, "alpha": False}])
135
+
136
+ @patch("polytext.converter.document_ocr_to_text.genai.Client")
137
+ @patch("concurrent.futures.as_completed", side_effect=_immediate_as_completed)
138
+ @patch("concurrent.futures.ThreadPoolExecutor", _ImmediateExecutor)
139
+ @patch("fitz.open")
140
+ def test_document_ocr_preserves_default_pixmap_rendering(
141
+ self,
142
+ mock_fitz_open,
143
+ _mock_as_completed,
144
+ mock_client_cls,
145
+ ):
146
+ page = _FakePage()
147
+ mock_fitz_open.return_value = _FakePdf([page])
148
+ mock_client_cls.return_value = _FakeClient()
149
+
150
+ converter = DocumentOCRToTextConverter()
151
+ converter.get_document_ocr("dummy.pdf")
152
+
153
+ self.assertEqual(page.pixmap_calls, [{}])
154
+
115
155
  def test_default_ocr_max_output_tokens_is_8192(self):
116
156
  converter = OCRToTextConverter()
117
157
  self.assertEqual(converter.max_output_tokens, 8192)
@@ -138,6 +138,23 @@ class TestOCRImageDescriptions(unittest.TestCase):
138
138
  self.assertIsInstance(document_ocr_loader, DocumentOCRLoader)
139
139
  self.assertFalse(document_ocr_loader.allow_partial_ocr_failures)
140
140
 
141
+ def test_forced_document_ocr_passes_custom_render_dpi(self):
142
+ loader = BaseLoader(
143
+ source="local",
144
+ force_ocr=True,
145
+ ocr_render_dpi=200,
146
+ )
147
+
148
+ document_ocr_loader = loader.init_loader_class(
149
+ input="/tmp/example.pdf",
150
+ storage_client={},
151
+ llm_api_key=None,
152
+ source="local",
153
+ )
154
+
155
+ self.assertIsInstance(document_ocr_loader, DocumentOCRLoader)
156
+ self.assertEqual(document_ocr_loader.ocr_render_dpi, 200)
157
+
141
158
  def test_google_ocr_converter_builds_augmented_markdown_prompt(self):
142
159
  converter = OCRToTextConverter(include_image_descriptions=True)
143
160
 
File without changes
File without changes
File without changes
File without changes