polytext 0.2.8b5__tar.gz → 0.2.8b6__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {polytext-0.2.8b5 → polytext-0.2.8b6}/PKG-INFO +2 -1
- {polytext-0.2.8b5 → polytext-0.2.8b6}/polytext/converter/audio_to_text.py +237 -4
- {polytext-0.2.8b5 → polytext-0.2.8b6}/polytext.egg-info/PKG-INFO +2 -1
- {polytext-0.2.8b5 → polytext-0.2.8b6}/polytext.egg-info/requires.txt +1 -0
- {polytext-0.2.8b5 → polytext-0.2.8b6}/setup.py +1 -1
- {polytext-0.2.8b5 → polytext-0.2.8b6}/tests/test_audio_transcription_model_migration.py +168 -1
- {polytext-0.2.8b5 → polytext-0.2.8b6}/LICENSE +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b6}/README.md +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b6}/polytext/__init__.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b6}/polytext/converter/__init__.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b6}/polytext/converter/base.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b6}/polytext/converter/beautiful_text.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b6}/polytext/converter/document_ocr_to_text.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b6}/polytext/converter/document_ocr_to_text_azure_oai.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b6}/polytext/converter/gemini_quality_guards.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b6}/polytext/converter/html_to_md.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b6}/polytext/converter/md_to_text.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b6}/polytext/converter/ocr_to_text.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b6}/polytext/converter/ocr_to_text_azure_oai.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b6}/polytext/converter/pdf.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b6}/polytext/converter/text_to_md.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b6}/polytext/converter/video_to_audio.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b6}/polytext/exceptions/__init__.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b6}/polytext/exceptions/base.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b6}/polytext/generator/__init__.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b6}/polytext/generator/pdf.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b6}/polytext/loader/__init__.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b6}/polytext/loader/audio.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b6}/polytext/loader/aws_auth.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b6}/polytext/loader/base.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b6}/polytext/loader/document.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b6}/polytext/loader/document_ocr.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b6}/polytext/loader/downloader/__init__.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b6}/polytext/loader/downloader/downloader.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b6}/polytext/loader/html.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b6}/polytext/loader/markdown.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b6}/polytext/loader/notebook.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b6}/polytext/loader/ocr.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b6}/polytext/loader/plain_text.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b6}/polytext/loader/video.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b6}/polytext/loader/xml_xbrl.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b6}/polytext/loader/youtube.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b6}/polytext/loader/youtube_llm.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b6}/polytext/processor/__init__.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b6}/polytext/processor/audio_chunker.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b6}/polytext/processor/text_merger.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b6}/polytext/processor/transcript_chunker.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b6}/polytext/prompts/__init__.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b6}/polytext/prompts/beautiful_text.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b6}/polytext/prompts/ocr.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b6}/polytext/prompts/text_merging.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b6}/polytext/prompts/text_to_md.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b6}/polytext/prompts/transcription.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b6}/polytext/utils/__init__.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b6}/polytext/utils/utils.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b6}/polytext.egg-info/SOURCES.txt +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b6}/polytext.egg-info/dependency_links.txt +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b6}/polytext.egg-info/not-zip-safe +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b6}/polytext.egg-info/top_level.txt +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b6}/pyproject.toml +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b6}/setup.cfg +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b6}/tests/test_audio_chunker.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b6}/tests/test_audio_comparison_helpers.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b6}/tests/test_aws_auth.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b6}/tests/test_base_loader_error_mapping.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b6}/tests/test_beautiful_text_manual.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b6}/tests/test_compare_audio_models.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b6}/tests/test_compare_document_ocr_to_text_models.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b6}/tests/test_compare_ocr_to_text_models.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b6}/tests/test_compare_youtube_models.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b6}/tests/test_dowload_audio_from_youtube.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b6}/tests/test_dowload_audio_from_youtube_helpers.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b6}/tests/test_extracted_text_whitespace.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b6}/tests/test_gemini_quality_guards.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b6}/tests/test_get_audio_transcript_from_gcs.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b6}/tests/test_get_customized_pdf_from_markdown.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b6}/tests/test_get_document_ocr.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b6}/tests/test_get_document_ocr_azure_oai.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b6}/tests/test_get_document_text.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b6}/tests/test_get_document_text_from_gcs.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b6}/tests/test_get_ocr_from_image.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b6}/tests/test_get_text_from_markdown.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b6}/tests/test_get_video_transcript_from_gcs.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b6}/tests/test_library.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b6}/tests/test_markdown_loader_gzip.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b6}/tests/test_markitdown_html.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b6}/tests/test_notebook_loader.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b6}/tests/test_ocr_fallbacks.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b6}/tests/test_ocr_image_descriptions.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b6}/tests/test_pain_text.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b6}/tests/test_pdf_conversion_error.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b6}/tests/test_python_version_metadata.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b6}/tests/test_split_audio_with_llm.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b6}/tests/test_transcribe_s3_images_from_csv.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b6}/tests/test_transcribe_s3_images_from_csv_script.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b6}/tests/test_xml_xbrl_loader.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b6}/tests/test_youtube_gemini_minimal_check.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b6}/tests/test_youtube_llm_fallbacks.py +0 -0
- {polytext-0.2.8b5 → polytext-0.2.8b6}/tests/test_youtube_transcript.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: polytext
|
|
3
|
-
Version: 0.2.
|
|
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
|
|
@@ -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
|
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|