@exulu/backend 3.7.3 → 4.0.0
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.
- package/dist/{chunk-T6JVFT7L.js → chunk-QMN6MVHQ.js} +6 -1
- package/dist/{chunk-BNTL6LYY.js → chunk-RBEWHG7I.js} +404 -59
- package/dist/cli/start-whisper.js +1 -1
- package/dist/{convert-exulu-tools-to-ai-sdk-tools-UQSLJDXE.js → convert-exulu-tools-to-ai-sdk-tools-6RU4IZMI.js} +1 -1
- package/dist/index.cjs +957 -474
- package/dist/index.d.cts +3 -6
- package/dist/index.d.ts +3 -6
- package/dist/index.js +258 -182
- package/dist/python-setup-DRJ3QX5F.js +17 -0
- package/ee/LICENSE.md +2 -2
- package/ee/agentic-retrieval/pipeline/config.test.ts +18 -1
- package/ee/agentic-retrieval/pipeline/config.ts +15 -0
- package/ee/agentic-retrieval/pipeline/index.test.ts +73 -0
- package/ee/agentic-retrieval/pipeline/index.ts +67 -13
- package/ee/agentic-retrieval/pipeline/memory.test.ts +59 -0
- package/ee/agentic-retrieval/pipeline/memory.ts +181 -11
- package/ee/agentic-retrieval/pipeline/pin-rerun.test.ts +17 -0
- package/ee/agentic-retrieval/pipeline/pin-rerun.ts +29 -0
- package/ee/agentic-retrieval/pipeline/routing.test.ts +34 -0
- package/ee/agentic-retrieval/pipeline/routing.ts +96 -5
- package/ee/agentic-retrieval/pipeline/search.ts +9 -6
- package/ee/agentic-retrieval/pipeline/timing.test.ts +24 -0
- package/ee/agentic-retrieval/pipeline/timing.ts +26 -0
- package/ee/agentic-retrieval/pipeline/types.ts +2 -0
- package/ee/invoke-skills/artifact-filter.test.ts +49 -0
- package/ee/invoke-skills/artifact-filter.ts +38 -0
- package/ee/invoke-skills/create-sandbox.ts +56 -4
- package/ee/python/documents/processing/README.md +2 -3
- package/ee/python/documents/processing/doc_processor.ts +21 -61
- package/ee/python/documents/processing/split_pdf.py +25 -30
- package/ee/python/documents/processing/tests/__init__.py +0 -0
- package/ee/python/documents/processing/tests/test_split_pdf.py +230 -0
- package/ee/python/requirements.txt +17 -2
- package/ee/python/setup.sh +40 -1
- package/ee/python/transcription/pipeline.py +109 -15
- package/ee/python/transcription/tests/test_align_model_licensing.py +184 -0
- package/ee/workers.ts +2 -7
- package/license.md +2 -2
- package/package.json +3 -4
- package/scripts/postinstall.cjs +52 -1
- package/ee/python/documents/processing/document_to_markdown.py +0 -413
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""Tests for split_pdf.py.
|
|
2
|
+
|
|
3
|
+
Builds real PDFs with pypdf rather than using fixtures, so the suite is
|
|
4
|
+
self-contained and the page counts/byte sizes are known exactly.
|
|
5
|
+
|
|
6
|
+
Run from the repo root with the venv active:
|
|
7
|
+
cd ee/python/documents/processing && ../../.venv/bin/python -m pytest tests
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
import pytest
|
|
17
|
+
from pypdf import PdfReader, PdfWriter
|
|
18
|
+
from pypdf.generic import DecodedStreamObject, NameObject
|
|
19
|
+
|
|
20
|
+
# Make ee/python/documents/processing importable.
|
|
21
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
22
|
+
|
|
23
|
+
import split_pdf as sp # noqa: E402
|
|
24
|
+
|
|
25
|
+
SCRIPT = Path(__file__).resolve().parent.parent / "split_pdf.py"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _make_pdf(path: Path, pages: int, page_bytes: int = 0) -> Path:
|
|
29
|
+
"""Write a PDF whose pages carry a real content stream.
|
|
30
|
+
|
|
31
|
+
Each page's stream holds a `% exulu-page-N` marker so tests can assert the
|
|
32
|
+
page order survived, plus `page_bytes` of incompressible padding so the
|
|
33
|
+
byte-size bisection tests get a file whose size scales with page count.
|
|
34
|
+
Page content is used rather than custom dictionary keys because a PDF
|
|
35
|
+
library is free to drop unknown keys when it rebuilds pages, and PyMuPDF
|
|
36
|
+
does exactly that.
|
|
37
|
+
"""
|
|
38
|
+
writer = PdfWriter()
|
|
39
|
+
for i in range(pages):
|
|
40
|
+
writer.add_blank_page(width=200, height=200)
|
|
41
|
+
body = f"% exulu-page-{i}\n".encode()
|
|
42
|
+
if page_bytes:
|
|
43
|
+
body += b"% " + os.urandom(page_bytes).hex().encode() + b"\n"
|
|
44
|
+
stream = DecodedStreamObject()
|
|
45
|
+
stream.set_data(body)
|
|
46
|
+
writer.pages[i][NameObject("/Contents")] = writer._add_object(stream)
|
|
47
|
+
with open(path, "wb") as fh:
|
|
48
|
+
writer.write(fh)
|
|
49
|
+
return path
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _page_markers(pdf_path: str) -> list[str]:
|
|
53
|
+
"""Read back the `% exulu-page-N` marker from every page of a chunk."""
|
|
54
|
+
markers = []
|
|
55
|
+
for page in PdfReader(pdf_path).pages:
|
|
56
|
+
data = page["/Contents"].get_data().decode("latin-1")
|
|
57
|
+
markers.append(data.splitlines()[0].removeprefix("% "))
|
|
58
|
+
return markers
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _run_script(*args: str) -> tuple[int, str, str]:
|
|
62
|
+
proc = subprocess.run(
|
|
63
|
+
[sys.executable, str(SCRIPT), *args],
|
|
64
|
+
capture_output=True,
|
|
65
|
+
text=True,
|
|
66
|
+
)
|
|
67
|
+
return proc.returncode, proc.stdout, proc.stderr
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# --- no-split path -----------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_returns_original_when_within_limits(tmp_path):
|
|
74
|
+
src = _make_pdf(tmp_path / "small.pdf", pages=3)
|
|
75
|
+
chunks = sp.split_pdf(str(src), str(tmp_path / "out"), chunk_size=25)
|
|
76
|
+
|
|
77
|
+
assert len(chunks) == 1
|
|
78
|
+
assert chunks[0]["path"] == str(src.resolve())
|
|
79
|
+
assert chunks[0]["start_page"] == 0
|
|
80
|
+
assert chunks[0]["end_page"] == 3
|
|
81
|
+
# No copy is made, so the output dir is never created.
|
|
82
|
+
assert not (tmp_path / "out").exists()
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# --- page-count splitting ----------------------------------------------------
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_splits_on_page_count(tmp_path):
|
|
89
|
+
src = _make_pdf(tmp_path / "big.pdf", pages=10)
|
|
90
|
+
out = tmp_path / "out"
|
|
91
|
+
chunks = sp.split_pdf(str(src), str(out), chunk_size=4)
|
|
92
|
+
|
|
93
|
+
assert [(c["start_page"], c["end_page"]) for c in chunks] == [(0, 4), (4, 8), (8, 10)]
|
|
94
|
+
for c in chunks:
|
|
95
|
+
assert Path(c["path"]).exists()
|
|
96
|
+
assert [len(PdfReader(c["path"]).pages) for c in chunks] == [4, 4, 2]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_chunk_ranges_are_contiguous_and_cover_every_page(tmp_path):
|
|
100
|
+
src = _make_pdf(tmp_path / "doc.pdf", pages=17)
|
|
101
|
+
chunks = sp.split_pdf(str(src), str(tmp_path / "out"), chunk_size=5)
|
|
102
|
+
|
|
103
|
+
assert chunks[0]["start_page"] == 0
|
|
104
|
+
assert chunks[-1]["end_page"] == 17
|
|
105
|
+
for prev, nxt in zip(chunks, chunks[1:]):
|
|
106
|
+
assert prev["end_page"] == nxt["start_page"]
|
|
107
|
+
assert sum(len(PdfReader(c["path"]).pages) for c in chunks) == 17
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def test_exact_multiple_of_chunk_size_produces_no_empty_trailing_chunk(tmp_path):
|
|
111
|
+
src = _make_pdf(tmp_path / "doc.pdf", pages=8)
|
|
112
|
+
chunks = sp.split_pdf(str(src), str(tmp_path / "out"), chunk_size=4)
|
|
113
|
+
|
|
114
|
+
assert [(c["start_page"], c["end_page"]) for c in chunks] == [(0, 4), (4, 8)]
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_page_content_is_preserved_in_order(tmp_path):
|
|
118
|
+
"""The Nth page of the source must land at the right offset in its chunk."""
|
|
119
|
+
src = _make_pdf(tmp_path / "marked.pdf", pages=6)
|
|
120
|
+
|
|
121
|
+
chunks = sp.split_pdf(str(src), str(tmp_path / "out"), chunk_size=2)
|
|
122
|
+
|
|
123
|
+
seen = []
|
|
124
|
+
for c in chunks:
|
|
125
|
+
seen.extend(_page_markers(c["path"]))
|
|
126
|
+
assert seen == [f"exulu-page-{i}" for i in range(6)]
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# --- byte-size bisection -----------------------------------------------------
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def test_oversized_chunk_is_bisected_by_page_count(tmp_path):
|
|
133
|
+
# 4 pages, ~40 KB each. A 4-page chunk exceeds 100 KB and must bisect.
|
|
134
|
+
src = _make_pdf(tmp_path / "heavy.pdf", pages=4, page_bytes=20_000)
|
|
135
|
+
chunks = sp.split_pdf(
|
|
136
|
+
str(src), str(tmp_path / "out"), chunk_size=4, max_size_bytes=100_000
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
assert len(chunks) > 1
|
|
140
|
+
assert [(c["start_page"], c["end_page"]) for c in chunks][0][0] == 0
|
|
141
|
+
assert chunks[-1]["end_page"] == 4
|
|
142
|
+
for prev, nxt in zip(chunks, chunks[1:]):
|
|
143
|
+
assert prev["end_page"] == nxt["start_page"]
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def test_single_page_over_limit_is_kept_with_a_warning(tmp_path, capsys):
|
|
147
|
+
src = _make_pdf(tmp_path / "huge-page.pdf", pages=1, page_bytes=60_000)
|
|
148
|
+
# Force the split path: max_size_bytes below the file size.
|
|
149
|
+
chunks = sp.split_pdf(
|
|
150
|
+
str(src), str(tmp_path / "out"), chunk_size=1, max_size_bytes=1_000
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
assert len(chunks) == 1
|
|
154
|
+
assert Path(chunks[0]["path"]).exists()
|
|
155
|
+
assert "cannot be split further" in capsys.readouterr().err
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def test_file_over_size_limit_splits_even_when_page_count_fits(tmp_path):
|
|
159
|
+
src = _make_pdf(tmp_path / "wide.pdf", pages=4, page_bytes=20_000)
|
|
160
|
+
chunks = sp.split_pdf(
|
|
161
|
+
str(src), str(tmp_path / "out"), chunk_size=25, max_size_bytes=50_000
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
# chunk_size alone would have returned the original untouched.
|
|
165
|
+
assert len(chunks) > 1
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
# --- encryption --------------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def test_opens_phantom_password_pdf(tmp_path):
|
|
172
|
+
"""A PDF encrypted with an empty password must open, as the OS does."""
|
|
173
|
+
writer = PdfWriter()
|
|
174
|
+
for _ in range(6):
|
|
175
|
+
writer.add_blank_page(width=200, height=200)
|
|
176
|
+
writer.encrypt("")
|
|
177
|
+
src = tmp_path / "phantom.pdf"
|
|
178
|
+
with open(src, "wb") as fh:
|
|
179
|
+
writer.write(fh)
|
|
180
|
+
|
|
181
|
+
assert PdfReader(src).is_encrypted
|
|
182
|
+
|
|
183
|
+
chunks = sp.split_pdf(str(src), str(tmp_path / "out"), chunk_size=2)
|
|
184
|
+
assert len(chunks) == 3
|
|
185
|
+
assert sum(len(PdfReader(c["path"]).pages) for c in chunks) == 6
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def test_rejects_pdf_with_a_real_password(tmp_path):
|
|
189
|
+
writer = PdfWriter()
|
|
190
|
+
writer.add_blank_page(width=200, height=200)
|
|
191
|
+
writer.encrypt("hunter2")
|
|
192
|
+
src = tmp_path / "locked.pdf"
|
|
193
|
+
with open(src, "wb") as fh:
|
|
194
|
+
writer.write(fh)
|
|
195
|
+
|
|
196
|
+
with pytest.raises(ValueError, match="non-empty password"):
|
|
197
|
+
sp.split_pdf(str(src), str(tmp_path / "out"), chunk_size=1)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
# --- CLI contract ------------------------------------------------------------
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def test_stdout_is_pure_json(tmp_path):
|
|
204
|
+
src = _make_pdf(tmp_path / "doc.pdf", pages=6)
|
|
205
|
+
code, out, err = _run_script(str(src), str(tmp_path / "out"), "--chunk-size", "2")
|
|
206
|
+
|
|
207
|
+
assert code == 0
|
|
208
|
+
parsed = json.loads(out) # must not raise: nothing but JSON on stdout
|
|
209
|
+
assert len(parsed) == 3
|
|
210
|
+
# Diagnostics go to stderr, not stdout.
|
|
211
|
+
assert "[split_pdf]" in err
|
|
212
|
+
assert "[split_pdf]" not in out
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def test_cli_max_size_mb_is_converted_to_bytes(tmp_path):
|
|
216
|
+
src = _make_pdf(tmp_path / "heavy.pdf", pages=4, page_bytes=20_000)
|
|
217
|
+
code, out, _ = _run_script(
|
|
218
|
+
str(src), str(tmp_path / "out"), "--chunk-size", "4", "--max-size-mb", "0.05"
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
assert code == 0
|
|
222
|
+
assert len(json.loads(out)) > 1
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def test_exits_nonzero_on_failure(tmp_path):
|
|
226
|
+
code, out, err = _run_script(str(tmp_path / "missing.pdf"), str(tmp_path / "out"))
|
|
227
|
+
|
|
228
|
+
assert code == 1
|
|
229
|
+
assert "[split_pdf] ERROR" in err
|
|
230
|
+
assert out.strip() == ""
|
|
@@ -1,12 +1,15 @@
|
|
|
1
|
-
docling
|
|
2
1
|
# transformers <5: the 5.x line requires huggingface_hub>=1.0, which removed the
|
|
3
2
|
# `use_auth_token` kwarg that pyannote.audio 3.x still passes to hf_hub_download()
|
|
4
3
|
# (→ "unexpected keyword argument 'use_auth_token'", diarization silently
|
|
5
4
|
# disabled). whisperx only needs transformers>=4.48, so the 4.x line is fine.
|
|
6
5
|
transformers>=4.48,<5
|
|
7
6
|
pyinstaller
|
|
8
|
-
docling-hierarchical-pdf
|
|
9
7
|
defusedxml
|
|
8
|
+
# PDF page-range splitting for the "mistral" OCR processor (split_pdf.py).
|
|
9
|
+
# BSD-3-Clause, no dependencies. Replaced PyMuPDF, which is AGPL-3.0 or a paid
|
|
10
|
+
# Artifex commercial licence; see split_pdf.py. Do not reintroduce a PDF library
|
|
11
|
+
# without checking its licence.
|
|
12
|
+
pypdf>=6.1
|
|
10
13
|
# Whisper transcription server. Used by `npx @exulu/backend exulu-start-whisper`.
|
|
11
14
|
#
|
|
12
15
|
# Notes on the pins:
|
|
@@ -30,6 +33,11 @@ requests
|
|
|
30
33
|
# LiteLLM proxy — only used when EXULU_USE_LITELLM=true. Always installed so
|
|
31
34
|
# the dep is ready when the env var is flipped. Pinned to a tested version;
|
|
32
35
|
# upgrade deliberately.
|
|
36
|
+
# NOTE: the [proxy] extra hard-requires litellm-enterprise, which is NOT MIT —
|
|
37
|
+
# it carries the proprietary BerriAI Enterprise License. setup.sh step 6.4
|
|
38
|
+
# uninstalls it right after this file is installed; see the comment there. Keep
|
|
39
|
+
# the extra (we need its other 29 packages) and keep that uninstall step
|
|
40
|
+
# together.
|
|
33
41
|
litellm[proxy]==1.97.0
|
|
34
42
|
# FastAPI ceiling — NOT cosmetic. litellm 1.97.0 declares fastapi>=0.136.3,<1.0
|
|
35
43
|
# but imports the private `get_flat_dependant` from fastapi.dependencies.utils
|
|
@@ -52,3 +60,10 @@ prisma==0.15.0
|
|
|
52
60
|
# NOT included in litellm[proxy]; without it, requests to Vertex models
|
|
53
61
|
# fail with "No module named 'vertexai'".
|
|
54
62
|
google-cloud-aiplatform>=1.38
|
|
63
|
+
|
|
64
|
+
# Skill runtime dependencies — the session sandbox puts this venv's bin dir on PATH so
|
|
65
|
+
# skill scripts (e.g. the built-in docx-manipulation skill) find their libraries without
|
|
66
|
+
# creating a venv per session (which mirrored thousands of files into the tool result).
|
|
67
|
+
python-docx>=1.1
|
|
68
|
+
python-pptx>=1.0
|
|
69
|
+
openpyxl>=3.1
|
package/ee/python/setup.sh
CHANGED
|
@@ -240,6 +240,39 @@ pip install -r "$REQUIREMENTS_FILE"
|
|
|
240
240
|
|
|
241
241
|
print_success "All dependencies installed successfully"
|
|
242
242
|
|
|
243
|
+
# Step 6.4: Remove the proprietary litellm-enterprise package.
|
|
244
|
+
#
|
|
245
|
+
# requirements.txt pins `litellm[proxy]`, and that extra hard-requires
|
|
246
|
+
# litellm-enterprise. We need the other 29 packages the extra brings in
|
|
247
|
+
# (gunicorn, fastapi, apscheduler, boto3, mcp, litellm-proxy-extras, …) but not
|
|
248
|
+
# this one: it is NOT MIT like litellm itself. It carries the BerriAI Enterprise
|
|
249
|
+
# License, which permits production use only with a paid per-seat subscription
|
|
250
|
+
# and forbids redistributing the package. Exulu ships ee/ (including this script
|
|
251
|
+
# and requirements.txt) inside the public npm package, so leaving it installed
|
|
252
|
+
# would put a proprietary dependency on every customer's machine.
|
|
253
|
+
#
|
|
254
|
+
# Removing it is safe. Every one of litellm's 36 `litellm_enterprise` import
|
|
255
|
+
# sites is wrapped in `try/except ImportError`; with the package absent the
|
|
256
|
+
# proxy imports cleanly, `enterprise_proxy_config` falls back to None and the
|
|
257
|
+
# route table is unchanged. Exulu never sets LITELLM_LICENSE, so `premium_user`
|
|
258
|
+
# is False and no enterprise feature was reachable in the first place.
|
|
259
|
+
#
|
|
260
|
+
# If you DO hold a BerriAI Enterprise subscription and want the enterprise
|
|
261
|
+
# callbacks, set EXULU_KEEP_LITELLM_ENTERPRISE=true to skip this step.
|
|
262
|
+
echo ""
|
|
263
|
+
if [ "${EXULU_KEEP_LITELLM_ENTERPRISE:-false}" = "true" ]; then
|
|
264
|
+
print_warning "EXULU_KEEP_LITELLM_ENTERPRISE=true — keeping litellm-enterprise (proprietary; requires a BerriAI subscription for production use)."
|
|
265
|
+
elif pip show litellm-enterprise > /dev/null 2>&1; then
|
|
266
|
+
print_info "Removing litellm-enterprise (proprietary BerriAI package pulled in by litellm[proxy])..."
|
|
267
|
+
if pip uninstall -y litellm-enterprise > /dev/null 2>&1; then
|
|
268
|
+
print_success "litellm-enterprise removed; LiteLLM proxy runs on the MIT-licensed core"
|
|
269
|
+
else
|
|
270
|
+
print_warning "Could not remove litellm-enterprise. It is proprietary (BerriAI Enterprise License) and production use requires a paid subscription — remove it manually with 'pip uninstall -y litellm-enterprise' or set EXULU_KEEP_LITELLM_ENTERPRISE=true if you hold one."
|
|
271
|
+
fi
|
|
272
|
+
else
|
|
273
|
+
print_info "litellm-enterprise not present — nothing to remove"
|
|
274
|
+
fi
|
|
275
|
+
|
|
243
276
|
# Step 6.5: Generate Prisma client for LiteLLM database mode.
|
|
244
277
|
# LiteLLM's PrismaClient does `from prisma import Prisma`, which only works
|
|
245
278
|
# after `prisma generate` has materialized the Python client against
|
|
@@ -259,7 +292,7 @@ echo "Step 7: Validating installation..."
|
|
|
259
292
|
|
|
260
293
|
# Test critical imports
|
|
261
294
|
print_info "Testing critical imports..."
|
|
262
|
-
$PYTHON_CMD -c "import
|
|
295
|
+
$PYTHON_CMD -c "import pypdf" 2>/dev/null && print_success "pypdf imported successfully" || print_error "Failed to import pypdf"
|
|
263
296
|
$PYTHON_CMD -c "import transformers" 2>/dev/null && print_success "transformers imported successfully" || print_error "Failed to import transformers"
|
|
264
297
|
|
|
265
298
|
# Whisper transcription server imports — non-fatal: only needed for
|
|
@@ -269,6 +302,12 @@ $PYTHON_CMD -c "import whisperx" 2>/dev/null && print_success "whisperx imported
|
|
|
269
302
|
$PYTHON_CMD -c "import pyannote.audio" 2>/dev/null && print_success "pyannote.audio imported successfully" || print_warning "pyannote.audio not importable (diarization will be disabled even with HF_AUTH_TOKEN)"
|
|
270
303
|
$PYTHON_CMD -c "import fastapi, uvicorn" 2>/dev/null && print_success "fastapi/uvicorn imported successfully" || print_warning "fastapi/uvicorn not importable (transcription server will not start)"
|
|
271
304
|
|
|
305
|
+
# litellm must still import after litellm-enterprise was removed in step 6.4.
|
|
306
|
+
# Cheap check on purpose: `import litellm` takes ~2s, while importing
|
|
307
|
+
# litellm.proxy.proxy_server takes ~15s and would eat into the npm postinstall
|
|
308
|
+
# timeout that the CUDA torch download already strains.
|
|
309
|
+
$PYTHON_CMD -c "import litellm" 2>/dev/null && print_success "litellm imported successfully" || print_warning "litellm not importable (LiteLLM proxy will not start; only needed when EXULU_USE_LITELLM=true)"
|
|
310
|
+
|
|
272
311
|
# Step 8: Display summary
|
|
273
312
|
echo ""
|
|
274
313
|
echo -e "${GREEN}========================================${NC}"
|
|
@@ -35,6 +35,51 @@ class CancelledError(Exception):
|
|
|
35
35
|
pass
|
|
36
36
|
|
|
37
37
|
|
|
38
|
+
# Forced-alignment models whisperx would download that are NOT licence-cleared
|
|
39
|
+
# for commercial use. Keys are the Hugging Face repo ids in whisperx's own
|
|
40
|
+
# DEFAULT_ALIGN_MODELS_HF table; values are the reason, which is logged verbatim.
|
|
41
|
+
#
|
|
42
|
+
# whisperx picks an alignment model from the language Whisper *detected*, so
|
|
43
|
+
# without this guard an ordinary upload in one of these languages silently pulls
|
|
44
|
+
# the model onto the server and uses it. Alignment only refines timestamps to
|
|
45
|
+
# word level: when it is skipped the transcript, its segment-level timings and
|
|
46
|
+
# its speaker labels are all still produced (see _get_align_model).
|
|
47
|
+
#
|
|
48
|
+
# To allow one of these again, supply a licence-cleared replacement through
|
|
49
|
+
# EXULU_ALIGN_MODEL_<LANG> rather than deleting the entry — e.g.
|
|
50
|
+
# EXULU_ALIGN_MODEL_VI=my-org/licensed-vi-aligner.
|
|
51
|
+
RESTRICTED_ALIGN_MODELS: dict[str, str] = {
|
|
52
|
+
"nguyenvulebinh/wav2vec2-base-vi":
|
|
53
|
+
"CC-BY-NC-4.0 — non-commercial use only",
|
|
54
|
+
"classla/wav2vec2-xls-r-parlaspeech-hr":
|
|
55
|
+
"no licence stated on the model card or in the Hugging Face metadata",
|
|
56
|
+
"imvladikon/wav2vec2-xls-r-300m-hebrew":
|
|
57
|
+
"no licence stated on the model card or in the Hugging Face metadata",
|
|
58
|
+
"theainerd/Wav2Vec2-large-xlsr-hindi":
|
|
59
|
+
"no licence stated on the model card or in the Hugging Face metadata",
|
|
60
|
+
# Danish is blocked on the conservative side: the model card states only
|
|
61
|
+
# that use "needs to adhere to this license from the Danish Parliament",
|
|
62
|
+
# and those terms have not been reviewed. Remove this entry once they have.
|
|
63
|
+
"saattrupdan/wav2vec2-xls-r-300m-ftspeech":
|
|
64
|
+
"licence is 'other' — refers to unreviewed Danish Parliament terms",
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _default_align_model_for(language_code: str) -> Optional[str]:
|
|
69
|
+
"""The model id whisperx would resolve for this language, without loading it.
|
|
70
|
+
|
|
71
|
+
Mirrors load_align_model's own lookup order. Returns None for a language
|
|
72
|
+
whisperx has no default for, which it treats as an error anyway.
|
|
73
|
+
"""
|
|
74
|
+
from whisperx.alignment import DEFAULT_ALIGN_MODELS_HF, DEFAULT_ALIGN_MODELS_TORCH
|
|
75
|
+
|
|
76
|
+
# The TORCH table holds torchaudio bundle names, which ship with torchaudio
|
|
77
|
+
# under BSD-2-Clause rather than being downloaded from Hugging Face.
|
|
78
|
+
if language_code in DEFAULT_ALIGN_MODELS_TORCH:
|
|
79
|
+
return DEFAULT_ALIGN_MODELS_TORCH[language_code]
|
|
80
|
+
return DEFAULT_ALIGN_MODELS_HF.get(language_code)
|
|
81
|
+
|
|
82
|
+
|
|
38
83
|
def detect_device(requested: str = "auto") -> str:
|
|
39
84
|
if requested != "auto":
|
|
40
85
|
return requested
|
|
@@ -72,7 +117,9 @@ class TranscriptionPipeline:
|
|
|
72
117
|
self.diarize_model = None
|
|
73
118
|
self.diarization_enabled = False
|
|
74
119
|
self.diarization_disabled_reason: str = "not attempted"
|
|
75
|
-
self.align_models: dict[str, tuple] = {}
|
|
120
|
+
self.align_models: dict[str, Optional[tuple]] = {}
|
|
121
|
+
# language_code -> why alignment was skipped, for observability
|
|
122
|
+
self.align_skipped_reasons: dict[str, str] = {}
|
|
76
123
|
|
|
77
124
|
def load(self) -> None:
|
|
78
125
|
# whisperx doesn't ship MPS support; run whisper on CPU when DEVICE=mps
|
|
@@ -122,12 +169,49 @@ class TranscriptionPipeline:
|
|
|
122
169
|
print(f"[pipeline] Failed to load pyannote ({self.diarization_disabled_reason}); diarization disabled", flush=True)
|
|
123
170
|
|
|
124
171
|
def _get_align_model(self, language_code: str):
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
172
|
+
"""Load the forced-alignment model for a language, or None if blocked.
|
|
173
|
+
|
|
174
|
+
Returns None when the model whisperx would use is not licence-cleared
|
|
175
|
+
and no replacement is configured. The caller skips alignment in that
|
|
176
|
+
case; nothing is downloaded, because the check runs before the load.
|
|
177
|
+
"""
|
|
178
|
+
if language_code in self.align_models:
|
|
179
|
+
return self.align_models[language_code]
|
|
180
|
+
|
|
181
|
+
device = "cpu" if self.device == "mps" else self.device
|
|
182
|
+
|
|
183
|
+
# An operator-supplied replacement wins over whisperx's default, so a
|
|
184
|
+
# deployment that has licensed a model for one of the blocked languages
|
|
185
|
+
# can use it without patching this file.
|
|
186
|
+
override = os.getenv(f"EXULU_ALIGN_MODEL_{language_code.upper()}") or None
|
|
187
|
+
model_name = override or _default_align_model_for(language_code)
|
|
188
|
+
|
|
189
|
+
if override:
|
|
190
|
+
print(
|
|
191
|
+
f"[pipeline] Align model for {language_code} overridden by "
|
|
192
|
+
f"EXULU_ALIGN_MODEL_{language_code.upper()}={override}",
|
|
193
|
+
flush=True,
|
|
194
|
+
)
|
|
195
|
+
elif model_name in RESTRICTED_ALIGN_MODELS:
|
|
196
|
+
reason = RESTRICTED_ALIGN_MODELS[model_name]
|
|
197
|
+
self.align_skipped_reasons[language_code] = reason
|
|
198
|
+
print(
|
|
199
|
+
f"[pipeline] WARNING: word-level alignment skipped for "
|
|
200
|
+
f"'{language_code}'. Its default model '{model_name}' is not "
|
|
201
|
+
f"licence-cleared ({reason}) and was neither downloaded nor used. "
|
|
202
|
+
f"The transcript, segment timings and speaker labels are "
|
|
203
|
+
f"unaffected; only word-level timing precision is lost. Set "
|
|
204
|
+
f"EXULU_ALIGN_MODEL_{language_code.upper()} to a licensed model "
|
|
205
|
+
f"to re-enable alignment for this language.",
|
|
206
|
+
flush=True,
|
|
130
207
|
)
|
|
208
|
+
self.align_models[language_code] = None
|
|
209
|
+
return None
|
|
210
|
+
|
|
211
|
+
print(f"[pipeline] Loading align model for {language_code}", flush=True)
|
|
212
|
+
self.align_models[language_code] = whisperx.load_align_model(
|
|
213
|
+
language_code=language_code, device=device, model_name=model_name
|
|
214
|
+
)
|
|
131
215
|
return self.align_models[language_code]
|
|
132
216
|
|
|
133
217
|
def transcribe(
|
|
@@ -170,15 +254,25 @@ class TranscriptionPipeline:
|
|
|
170
254
|
|
|
171
255
|
language = transcribe_result["language"]
|
|
172
256
|
align_device = "cpu" if self.device == "mps" else self.device
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
257
|
+
align_bundle = self._get_align_model(language)
|
|
258
|
+
if align_bundle is None:
|
|
259
|
+
# Alignment blocked for this language (see RESTRICTED_ALIGN_MODELS).
|
|
260
|
+
# Whisper's own segments already carry start/end/text, and
|
|
261
|
+
# assign_word_speakers accepts an unaligned TranscriptionResult —
|
|
262
|
+
# it assigns speakers per segment and only walks words when a
|
|
263
|
+
# segment has them. So the transcript degrades to segment-level
|
|
264
|
+
# timing rather than failing.
|
|
265
|
+
aligned = transcribe_result
|
|
266
|
+
else:
|
|
267
|
+
model_a, metadata = align_bundle
|
|
268
|
+
aligned = whisperx.align(
|
|
269
|
+
transcribe_result["segments"],
|
|
270
|
+
model_a,
|
|
271
|
+
metadata,
|
|
272
|
+
audio,
|
|
273
|
+
align_device,
|
|
274
|
+
return_char_alignments=False,
|
|
275
|
+
)
|
|
182
276
|
|
|
183
277
|
if is_cancelled():
|
|
184
278
|
raise CancelledError()
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""Tests for the forced-alignment licence guard in pipeline.py.
|
|
2
|
+
|
|
3
|
+
The guard exists so that an ordinary upload in one of a handful of languages
|
|
4
|
+
cannot pull a non-commercial or unlicensed wav2vec2 model onto the server.
|
|
5
|
+
These tests assert the two things that matter: the blocked models are never
|
|
6
|
+
loaded (so never downloaded), and a blocked language still produces a usable
|
|
7
|
+
transcript.
|
|
8
|
+
|
|
9
|
+
Run from the repo root with the venv active:
|
|
10
|
+
cd ee/python/transcription && ../.venv/bin/python -m pytest tests
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from unittest.mock import patch
|
|
16
|
+
|
|
17
|
+
import pytest
|
|
18
|
+
|
|
19
|
+
# Make ee/python/transcription importable.
|
|
20
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
21
|
+
|
|
22
|
+
import pipeline as pl # noqa: E402
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _pipeline():
|
|
26
|
+
p = pl.TranscriptionPipeline("large-v3", "cpu", 4)
|
|
27
|
+
return p
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# --- the block list itself -------------------------------------------------
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_restricted_models_match_whisperx_defaults():
|
|
34
|
+
"""Every blocked id must actually be a model whisperx would resolve.
|
|
35
|
+
|
|
36
|
+
Guards against a typo or an upstream rename silently disarming the guard.
|
|
37
|
+
"""
|
|
38
|
+
from whisperx.alignment import DEFAULT_ALIGN_MODELS_HF
|
|
39
|
+
|
|
40
|
+
defaults = set(DEFAULT_ALIGN_MODELS_HF.values())
|
|
41
|
+
for model_id in pl.RESTRICTED_ALIGN_MODELS:
|
|
42
|
+
assert model_id in defaults, (
|
|
43
|
+
f"{model_id} is blocked but is no longer a whisperx default — "
|
|
44
|
+
"the upstream table changed and the guard may be stale"
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@pytest.mark.parametrize(
|
|
49
|
+
"language,expected_model",
|
|
50
|
+
[
|
|
51
|
+
("vi", "nguyenvulebinh/wav2vec2-base-vi"),
|
|
52
|
+
("hr", "classla/wav2vec2-xls-r-parlaspeech-hr"),
|
|
53
|
+
("he", "imvladikon/wav2vec2-xls-r-300m-hebrew"),
|
|
54
|
+
("hi", "theainerd/Wav2Vec2-large-xlsr-hindi"),
|
|
55
|
+
("da", "saattrupdan/wav2vec2-xls-r-300m-ftspeech"),
|
|
56
|
+
],
|
|
57
|
+
)
|
|
58
|
+
def test_restricted_languages_resolve_to_blocked_models(language, expected_model):
|
|
59
|
+
assert pl._default_align_model_for(language) == expected_model
|
|
60
|
+
assert expected_model in pl.RESTRICTED_ALIGN_MODELS
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# --- the guard -------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@pytest.mark.parametrize("language", ["vi", "hr", "he", "hi", "da"])
|
|
67
|
+
def test_blocked_language_never_loads_a_model(language):
|
|
68
|
+
"""The decisive test: load_align_model must not be called at all.
|
|
69
|
+
|
|
70
|
+
load_align_model is what reaches Hugging Face, so not calling it means the
|
|
71
|
+
weights are neither downloaded nor used.
|
|
72
|
+
"""
|
|
73
|
+
p = _pipeline()
|
|
74
|
+
with patch.object(pl.whisperx, "load_align_model") as load:
|
|
75
|
+
assert p._get_align_model(language) is None
|
|
76
|
+
load.assert_not_called()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@pytest.mark.parametrize("language", ["vi", "hr", "he", "hi", "da"])
|
|
80
|
+
def test_blocked_language_records_and_logs_a_reason(language, capsys):
|
|
81
|
+
p = _pipeline()
|
|
82
|
+
with patch.object(pl.whisperx, "load_align_model"):
|
|
83
|
+
p._get_align_model(language)
|
|
84
|
+
|
|
85
|
+
assert language in p.align_skipped_reasons
|
|
86
|
+
out = capsys.readouterr().out
|
|
87
|
+
assert "WARNING" in out
|
|
88
|
+
assert "not licence-cleared" in out
|
|
89
|
+
assert pl.RESTRICTED_ALIGN_MODELS[pl._default_align_model_for(language)] in out
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_blocked_result_is_cached_so_the_warning_is_not_repeated(capsys):
|
|
93
|
+
p = _pipeline()
|
|
94
|
+
with patch.object(pl.whisperx, "load_align_model") as load:
|
|
95
|
+
p._get_align_model("vi")
|
|
96
|
+
first = capsys.readouterr().out
|
|
97
|
+
p._get_align_model("vi")
|
|
98
|
+
second = capsys.readouterr().out
|
|
99
|
+
load.assert_not_called()
|
|
100
|
+
|
|
101
|
+
assert "WARNING" in first
|
|
102
|
+
assert second.strip() == ""
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# --- languages that are fine ------------------------------------------------
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@pytest.mark.parametrize("language", ["en", "de", "nl", "fr", "es"])
|
|
109
|
+
def test_permitted_language_still_loads_normally(language):
|
|
110
|
+
p = _pipeline()
|
|
111
|
+
with patch.object(pl.whisperx, "load_align_model", return_value=("model", "meta")) as load:
|
|
112
|
+
assert p._get_align_model(language) == ("model", "meta")
|
|
113
|
+
load.assert_called_once()
|
|
114
|
+
assert load.call_args.kwargs["language_code"] == language
|
|
115
|
+
|
|
116
|
+
assert p.align_skipped_reasons == {}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def test_permitted_language_is_cached():
|
|
120
|
+
p = _pipeline()
|
|
121
|
+
with patch.object(pl.whisperx, "load_align_model", return_value=("m", "meta")) as load:
|
|
122
|
+
p._get_align_model("en")
|
|
123
|
+
p._get_align_model("en")
|
|
124
|
+
load.assert_called_once()
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# --- operator override ------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def test_override_re_enables_a_blocked_language(monkeypatch):
|
|
131
|
+
monkeypatch.setenv("EXULU_ALIGN_MODEL_VI", "my-org/licensed-vi-aligner")
|
|
132
|
+
p = _pipeline()
|
|
133
|
+
with patch.object(pl.whisperx, "load_align_model", return_value=("m", "meta")) as load:
|
|
134
|
+
assert p._get_align_model("vi") == ("m", "meta")
|
|
135
|
+
assert load.call_args.kwargs["model_name"] == "my-org/licensed-vi-aligner"
|
|
136
|
+
|
|
137
|
+
assert p.align_skipped_reasons == {}
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def test_override_applies_to_a_permitted_language_too(monkeypatch):
|
|
141
|
+
monkeypatch.setenv("EXULU_ALIGN_MODEL_EN", "my-org/custom-en")
|
|
142
|
+
p = _pipeline()
|
|
143
|
+
with patch.object(pl.whisperx, "load_align_model", return_value=("m", "meta")) as load:
|
|
144
|
+
p._get_align_model("en")
|
|
145
|
+
assert load.call_args.kwargs["model_name"] == "my-org/custom-en"
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def test_no_override_passes_the_whisperx_default_through(monkeypatch):
|
|
149
|
+
monkeypatch.delenv("EXULU_ALIGN_MODEL_EN", raising=False)
|
|
150
|
+
p = _pipeline()
|
|
151
|
+
with patch.object(pl.whisperx, "load_align_model", return_value=("m", "meta")) as load:
|
|
152
|
+
p._get_align_model("en")
|
|
153
|
+
assert load.call_args.kwargs["model_name"] == pl._default_align_model_for("en")
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# --- graceful degradation ---------------------------------------------------
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def test_unaligned_segments_carry_everything_the_output_needs():
|
|
160
|
+
"""transcribe() reads start/end/text/speaker; Whisper's own segments have
|
|
161
|
+
the first three, so skipping alignment cannot break the response shape."""
|
|
162
|
+
unaligned = {
|
|
163
|
+
"segments": [{"start": 0.0, "end": 1.5, "text": "xin chao"}],
|
|
164
|
+
"language": "vi",
|
|
165
|
+
}
|
|
166
|
+
for seg in unaligned["segments"]:
|
|
167
|
+
assert "start" in seg and "end" in seg and "text" in seg
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def test_assign_word_speakers_accepts_unaligned_input():
|
|
171
|
+
"""The diarization step must tolerate segments that have no 'words' key,
|
|
172
|
+
otherwise a blocked language would crash instead of degrading."""
|
|
173
|
+
import pandas as pd
|
|
174
|
+
from whisperx.diarize import assign_word_speakers
|
|
175
|
+
|
|
176
|
+
diarize_df = pd.DataFrame(
|
|
177
|
+
[{"start": 0.0, "end": 2.0, "speaker": "SPEAKER_00", "segment": None, "label": "a"}]
|
|
178
|
+
)
|
|
179
|
+
unaligned = {"segments": [{"start": 0.0, "end": 1.5, "text": "xin chao"}]}
|
|
180
|
+
|
|
181
|
+
result = assign_word_speakers(diarize_df, unaligned)
|
|
182
|
+
|
|
183
|
+
assert result["segments"][0]["speaker"] == "SPEAKER_00"
|
|
184
|
+
assert result["segments"][0]["text"] == "xin chao"
|