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.
- {polytext-0.2.8b4 → polytext-0.2.8b6}/PKG-INFO +2 -1
- {polytext-0.2.8b4 → polytext-0.2.8b6}/polytext/converter/audio_to_text.py +237 -4
- {polytext-0.2.8b4 → polytext-0.2.8b6}/polytext/converter/document_ocr_to_text.py +14 -2
- {polytext-0.2.8b4 → polytext-0.2.8b6}/polytext/loader/base.py +6 -1
- {polytext-0.2.8b4 → polytext-0.2.8b6}/polytext/loader/document_ocr.py +5 -0
- {polytext-0.2.8b4 → polytext-0.2.8b6}/polytext.egg-info/PKG-INFO +2 -1
- {polytext-0.2.8b4 → polytext-0.2.8b6}/polytext.egg-info/requires.txt +1 -0
- {polytext-0.2.8b4 → polytext-0.2.8b6}/setup.py +1 -1
- {polytext-0.2.8b4 → polytext-0.2.8b6}/tests/test_audio_transcription_model_migration.py +168 -1
- {polytext-0.2.8b4 → polytext-0.2.8b6}/tests/test_ocr_fallbacks.py +41 -1
- {polytext-0.2.8b4 → polytext-0.2.8b6}/tests/test_ocr_image_descriptions.py +17 -0
- {polytext-0.2.8b4 → polytext-0.2.8b6}/LICENSE +0 -0
- {polytext-0.2.8b4 → polytext-0.2.8b6}/README.md +0 -0
- {polytext-0.2.8b4 → polytext-0.2.8b6}/polytext/__init__.py +0 -0
- {polytext-0.2.8b4 → polytext-0.2.8b6}/polytext/converter/__init__.py +0 -0
- {polytext-0.2.8b4 → polytext-0.2.8b6}/polytext/converter/base.py +0 -0
- {polytext-0.2.8b4 → polytext-0.2.8b6}/polytext/converter/beautiful_text.py +0 -0
- {polytext-0.2.8b4 → polytext-0.2.8b6}/polytext/converter/document_ocr_to_text_azure_oai.py +0 -0
- {polytext-0.2.8b4 → polytext-0.2.8b6}/polytext/converter/gemini_quality_guards.py +0 -0
- {polytext-0.2.8b4 → polytext-0.2.8b6}/polytext/converter/html_to_md.py +0 -0
- {polytext-0.2.8b4 → polytext-0.2.8b6}/polytext/converter/md_to_text.py +0 -0
- {polytext-0.2.8b4 → polytext-0.2.8b6}/polytext/converter/ocr_to_text.py +0 -0
- {polytext-0.2.8b4 → polytext-0.2.8b6}/polytext/converter/ocr_to_text_azure_oai.py +0 -0
- {polytext-0.2.8b4 → polytext-0.2.8b6}/polytext/converter/pdf.py +0 -0
- {polytext-0.2.8b4 → polytext-0.2.8b6}/polytext/converter/text_to_md.py +0 -0
- {polytext-0.2.8b4 → polytext-0.2.8b6}/polytext/converter/video_to_audio.py +0 -0
- {polytext-0.2.8b4 → polytext-0.2.8b6}/polytext/exceptions/__init__.py +0 -0
- {polytext-0.2.8b4 → polytext-0.2.8b6}/polytext/exceptions/base.py +0 -0
- {polytext-0.2.8b4 → polytext-0.2.8b6}/polytext/generator/__init__.py +0 -0
- {polytext-0.2.8b4 → polytext-0.2.8b6}/polytext/generator/pdf.py +0 -0
- {polytext-0.2.8b4 → polytext-0.2.8b6}/polytext/loader/__init__.py +0 -0
- {polytext-0.2.8b4 → polytext-0.2.8b6}/polytext/loader/audio.py +0 -0
- {polytext-0.2.8b4 → polytext-0.2.8b6}/polytext/loader/aws_auth.py +0 -0
- {polytext-0.2.8b4 → polytext-0.2.8b6}/polytext/loader/document.py +0 -0
- {polytext-0.2.8b4 → polytext-0.2.8b6}/polytext/loader/downloader/__init__.py +0 -0
- {polytext-0.2.8b4 → polytext-0.2.8b6}/polytext/loader/downloader/downloader.py +0 -0
- {polytext-0.2.8b4 → polytext-0.2.8b6}/polytext/loader/html.py +0 -0
- {polytext-0.2.8b4 → polytext-0.2.8b6}/polytext/loader/markdown.py +0 -0
- {polytext-0.2.8b4 → polytext-0.2.8b6}/polytext/loader/notebook.py +0 -0
- {polytext-0.2.8b4 → polytext-0.2.8b6}/polytext/loader/ocr.py +0 -0
- {polytext-0.2.8b4 → polytext-0.2.8b6}/polytext/loader/plain_text.py +0 -0
- {polytext-0.2.8b4 → polytext-0.2.8b6}/polytext/loader/video.py +0 -0
- {polytext-0.2.8b4 → polytext-0.2.8b6}/polytext/loader/xml_xbrl.py +0 -0
- {polytext-0.2.8b4 → polytext-0.2.8b6}/polytext/loader/youtube.py +0 -0
- {polytext-0.2.8b4 → polytext-0.2.8b6}/polytext/loader/youtube_llm.py +0 -0
- {polytext-0.2.8b4 → polytext-0.2.8b6}/polytext/processor/__init__.py +0 -0
- {polytext-0.2.8b4 → polytext-0.2.8b6}/polytext/processor/audio_chunker.py +0 -0
- {polytext-0.2.8b4 → polytext-0.2.8b6}/polytext/processor/text_merger.py +0 -0
- {polytext-0.2.8b4 → polytext-0.2.8b6}/polytext/processor/transcript_chunker.py +0 -0
- {polytext-0.2.8b4 → polytext-0.2.8b6}/polytext/prompts/__init__.py +0 -0
- {polytext-0.2.8b4 → polytext-0.2.8b6}/polytext/prompts/beautiful_text.py +0 -0
- {polytext-0.2.8b4 → polytext-0.2.8b6}/polytext/prompts/ocr.py +0 -0
- {polytext-0.2.8b4 → polytext-0.2.8b6}/polytext/prompts/text_merging.py +0 -0
- {polytext-0.2.8b4 → polytext-0.2.8b6}/polytext/prompts/text_to_md.py +0 -0
- {polytext-0.2.8b4 → polytext-0.2.8b6}/polytext/prompts/transcription.py +0 -0
- {polytext-0.2.8b4 → polytext-0.2.8b6}/polytext/utils/__init__.py +0 -0
- {polytext-0.2.8b4 → polytext-0.2.8b6}/polytext/utils/utils.py +0 -0
- {polytext-0.2.8b4 → polytext-0.2.8b6}/polytext.egg-info/SOURCES.txt +0 -0
- {polytext-0.2.8b4 → polytext-0.2.8b6}/polytext.egg-info/dependency_links.txt +0 -0
- {polytext-0.2.8b4 → polytext-0.2.8b6}/polytext.egg-info/not-zip-safe +0 -0
- {polytext-0.2.8b4 → polytext-0.2.8b6}/polytext.egg-info/top_level.txt +0 -0
- {polytext-0.2.8b4 → polytext-0.2.8b6}/pyproject.toml +0 -0
- {polytext-0.2.8b4 → polytext-0.2.8b6}/setup.cfg +0 -0
- {polytext-0.2.8b4 → polytext-0.2.8b6}/tests/test_audio_chunker.py +0 -0
- {polytext-0.2.8b4 → polytext-0.2.8b6}/tests/test_audio_comparison_helpers.py +0 -0
- {polytext-0.2.8b4 → polytext-0.2.8b6}/tests/test_aws_auth.py +0 -0
- {polytext-0.2.8b4 → polytext-0.2.8b6}/tests/test_base_loader_error_mapping.py +0 -0
- {polytext-0.2.8b4 → polytext-0.2.8b6}/tests/test_beautiful_text_manual.py +0 -0
- {polytext-0.2.8b4 → polytext-0.2.8b6}/tests/test_compare_audio_models.py +0 -0
- {polytext-0.2.8b4 → polytext-0.2.8b6}/tests/test_compare_document_ocr_to_text_models.py +0 -0
- {polytext-0.2.8b4 → polytext-0.2.8b6}/tests/test_compare_ocr_to_text_models.py +0 -0
- {polytext-0.2.8b4 → polytext-0.2.8b6}/tests/test_compare_youtube_models.py +0 -0
- {polytext-0.2.8b4 → polytext-0.2.8b6}/tests/test_dowload_audio_from_youtube.py +0 -0
- {polytext-0.2.8b4 → polytext-0.2.8b6}/tests/test_dowload_audio_from_youtube_helpers.py +0 -0
- {polytext-0.2.8b4 → polytext-0.2.8b6}/tests/test_extracted_text_whitespace.py +0 -0
- {polytext-0.2.8b4 → polytext-0.2.8b6}/tests/test_gemini_quality_guards.py +0 -0
- {polytext-0.2.8b4 → polytext-0.2.8b6}/tests/test_get_audio_transcript_from_gcs.py +0 -0
- {polytext-0.2.8b4 → polytext-0.2.8b6}/tests/test_get_customized_pdf_from_markdown.py +0 -0
- {polytext-0.2.8b4 → polytext-0.2.8b6}/tests/test_get_document_ocr.py +0 -0
- {polytext-0.2.8b4 → polytext-0.2.8b6}/tests/test_get_document_ocr_azure_oai.py +0 -0
- {polytext-0.2.8b4 → polytext-0.2.8b6}/tests/test_get_document_text.py +0 -0
- {polytext-0.2.8b4 → polytext-0.2.8b6}/tests/test_get_document_text_from_gcs.py +0 -0
- {polytext-0.2.8b4 → polytext-0.2.8b6}/tests/test_get_ocr_from_image.py +0 -0
- {polytext-0.2.8b4 → polytext-0.2.8b6}/tests/test_get_text_from_markdown.py +0 -0
- {polytext-0.2.8b4 → polytext-0.2.8b6}/tests/test_get_video_transcript_from_gcs.py +0 -0
- {polytext-0.2.8b4 → polytext-0.2.8b6}/tests/test_library.py +0 -0
- {polytext-0.2.8b4 → polytext-0.2.8b6}/tests/test_markdown_loader_gzip.py +0 -0
- {polytext-0.2.8b4 → polytext-0.2.8b6}/tests/test_markitdown_html.py +0 -0
- {polytext-0.2.8b4 → polytext-0.2.8b6}/tests/test_notebook_loader.py +0 -0
- {polytext-0.2.8b4 → polytext-0.2.8b6}/tests/test_pain_text.py +0 -0
- {polytext-0.2.8b4 → polytext-0.2.8b6}/tests/test_pdf_conversion_error.py +0 -0
- {polytext-0.2.8b4 → polytext-0.2.8b6}/tests/test_python_version_metadata.py +0 -0
- {polytext-0.2.8b4 → polytext-0.2.8b6}/tests/test_split_audio_with_llm.py +0 -0
- {polytext-0.2.8b4 → polytext-0.2.8b6}/tests/test_transcribe_s3_images_from_csv.py +0 -0
- {polytext-0.2.8b4 → polytext-0.2.8b6}/tests/test_transcribe_s3_images_from_csv_script.py +0 -0
- {polytext-0.2.8b4 → polytext-0.2.8b6}/tests/test_xml_xbrl_loader.py +0 -0
- {polytext-0.2.8b4 → polytext-0.2.8b6}/tests/test_youtube_gemini_minimal_check.py +0 -0
- {polytext-0.2.8b4 → polytext-0.2.8b6}/tests/test_youtube_llm_fallbacks.py +0 -0
- {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.
|
|
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
|
-
|
|
880
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
@@ -51,7 +51,7 @@ def get_requirements(*requirements_file):
|
|
|
51
51
|
|
|
52
52
|
setup(
|
|
53
53
|
name='polytext',
|
|
54
|
-
version='0.2.
|
|
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=
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|