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.
- {polytext-0.2.8b5 → polytext-0.2.8b7}/PKG-INFO +2 -1
- {polytext-0.2.8b5 → polytext-0.2.8b7}/polytext/converter/audio_to_text.py +274 -5
- {polytext-0.2.8b5 → polytext-0.2.8b7}/polytext/loader/base.py +8 -3
- {polytext-0.2.8b5 → polytext-0.2.8b7}/polytext.egg-info/PKG-INFO +2 -1
- {polytext-0.2.8b5 → polytext-0.2.8b7}/polytext.egg-info/SOURCES.txt +1 -0
- {polytext-0.2.8b5 → polytext-0.2.8b7}/polytext.egg-info/requires.txt +1 -0
- {polytext-0.2.8b5 → polytext-0.2.8b7}/setup.py +1 -1
- polytext-0.2.8b7/tests/test_audio_chunk_quality.py +76 -0
- {polytext-0.2.8b5 → polytext-0.2.8b7}/tests/test_audio_transcription_model_migration.py +168 -1
- {polytext-0.2.8b5 → polytext-0.2.8b7}/tests/test_base_loader_error_mapping.py +19 -0
- {polytext-0.2.8b5 → polytext-0.2.8b7}/LICENSE +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b7}/README.md +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b7}/polytext/__init__.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b7}/polytext/converter/__init__.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b7}/polytext/converter/base.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b7}/polytext/converter/beautiful_text.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b7}/polytext/converter/document_ocr_to_text.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b7}/polytext/converter/document_ocr_to_text_azure_oai.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b7}/polytext/converter/gemini_quality_guards.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b7}/polytext/converter/html_to_md.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b7}/polytext/converter/md_to_text.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b7}/polytext/converter/ocr_to_text.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b7}/polytext/converter/ocr_to_text_azure_oai.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b7}/polytext/converter/pdf.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b7}/polytext/converter/text_to_md.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b7}/polytext/converter/video_to_audio.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b7}/polytext/exceptions/__init__.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b7}/polytext/exceptions/base.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b7}/polytext/generator/__init__.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b7}/polytext/generator/pdf.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b7}/polytext/loader/__init__.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b7}/polytext/loader/audio.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b7}/polytext/loader/aws_auth.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b7}/polytext/loader/document.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b7}/polytext/loader/document_ocr.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b7}/polytext/loader/downloader/__init__.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b7}/polytext/loader/downloader/downloader.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b7}/polytext/loader/html.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b7}/polytext/loader/markdown.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b7}/polytext/loader/notebook.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b7}/polytext/loader/ocr.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b7}/polytext/loader/plain_text.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b7}/polytext/loader/video.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b7}/polytext/loader/xml_xbrl.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b7}/polytext/loader/youtube.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b7}/polytext/loader/youtube_llm.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b7}/polytext/processor/__init__.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b7}/polytext/processor/audio_chunker.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b7}/polytext/processor/text_merger.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b7}/polytext/processor/transcript_chunker.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b7}/polytext/prompts/__init__.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b7}/polytext/prompts/beautiful_text.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b7}/polytext/prompts/ocr.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b7}/polytext/prompts/text_merging.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b7}/polytext/prompts/text_to_md.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b7}/polytext/prompts/transcription.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b7}/polytext/utils/__init__.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b7}/polytext/utils/utils.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b7}/polytext.egg-info/dependency_links.txt +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b7}/polytext.egg-info/not-zip-safe +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b7}/polytext.egg-info/top_level.txt +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b7}/pyproject.toml +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b7}/setup.cfg +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b7}/tests/test_audio_chunker.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b7}/tests/test_audio_comparison_helpers.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b7}/tests/test_aws_auth.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b7}/tests/test_beautiful_text_manual.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b7}/tests/test_compare_audio_models.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b7}/tests/test_compare_document_ocr_to_text_models.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b7}/tests/test_compare_ocr_to_text_models.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b7}/tests/test_compare_youtube_models.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b7}/tests/test_dowload_audio_from_youtube.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b7}/tests/test_dowload_audio_from_youtube_helpers.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b7}/tests/test_extracted_text_whitespace.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b7}/tests/test_gemini_quality_guards.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b7}/tests/test_get_audio_transcript_from_gcs.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b7}/tests/test_get_customized_pdf_from_markdown.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b7}/tests/test_get_document_ocr.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b7}/tests/test_get_document_ocr_azure_oai.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b7}/tests/test_get_document_text.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b7}/tests/test_get_document_text_from_gcs.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b7}/tests/test_get_ocr_from_image.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b7}/tests/test_get_text_from_markdown.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b7}/tests/test_get_video_transcript_from_gcs.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b7}/tests/test_library.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b7}/tests/test_markdown_loader_gzip.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b7}/tests/test_markitdown_html.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b7}/tests/test_notebook_loader.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b7}/tests/test_ocr_fallbacks.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b7}/tests/test_ocr_image_descriptions.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b7}/tests/test_pain_text.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b7}/tests/test_pdf_conversion_error.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b7}/tests/test_python_version_metadata.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b7}/tests/test_split_audio_with_llm.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b7}/tests/test_transcribe_s3_images_from_csv.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b7}/tests/test_transcribe_s3_images_from_csv_script.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b7}/tests/test_xml_xbrl_loader.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b7}/tests/test_youtube_gemini_minimal_check.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b7}/tests/test_youtube_llm_fallbacks.py +0 -0
- {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.
|
|
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
|
-
|
|
880
|
-
|
|
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,
|
|
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(
|
|
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.
|
|
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
|
|
@@ -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.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=
|
|
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
|
|
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
|