@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.
Files changed (41) hide show
  1. package/dist/{chunk-T6JVFT7L.js → chunk-QMN6MVHQ.js} +6 -1
  2. package/dist/{chunk-BNTL6LYY.js → chunk-RBEWHG7I.js} +404 -59
  3. package/dist/cli/start-whisper.js +1 -1
  4. package/dist/{convert-exulu-tools-to-ai-sdk-tools-UQSLJDXE.js → convert-exulu-tools-to-ai-sdk-tools-6RU4IZMI.js} +1 -1
  5. package/dist/index.cjs +957 -474
  6. package/dist/index.d.cts +3 -6
  7. package/dist/index.d.ts +3 -6
  8. package/dist/index.js +258 -182
  9. package/dist/python-setup-DRJ3QX5F.js +17 -0
  10. package/ee/LICENSE.md +2 -2
  11. package/ee/agentic-retrieval/pipeline/config.test.ts +18 -1
  12. package/ee/agentic-retrieval/pipeline/config.ts +15 -0
  13. package/ee/agentic-retrieval/pipeline/index.test.ts +73 -0
  14. package/ee/agentic-retrieval/pipeline/index.ts +67 -13
  15. package/ee/agentic-retrieval/pipeline/memory.test.ts +59 -0
  16. package/ee/agentic-retrieval/pipeline/memory.ts +181 -11
  17. package/ee/agentic-retrieval/pipeline/pin-rerun.test.ts +17 -0
  18. package/ee/agentic-retrieval/pipeline/pin-rerun.ts +29 -0
  19. package/ee/agentic-retrieval/pipeline/routing.test.ts +34 -0
  20. package/ee/agentic-retrieval/pipeline/routing.ts +96 -5
  21. package/ee/agentic-retrieval/pipeline/search.ts +9 -6
  22. package/ee/agentic-retrieval/pipeline/timing.test.ts +24 -0
  23. package/ee/agentic-retrieval/pipeline/timing.ts +26 -0
  24. package/ee/agentic-retrieval/pipeline/types.ts +2 -0
  25. package/ee/invoke-skills/artifact-filter.test.ts +49 -0
  26. package/ee/invoke-skills/artifact-filter.ts +38 -0
  27. package/ee/invoke-skills/create-sandbox.ts +56 -4
  28. package/ee/python/documents/processing/README.md +2 -3
  29. package/ee/python/documents/processing/doc_processor.ts +21 -61
  30. package/ee/python/documents/processing/split_pdf.py +25 -30
  31. package/ee/python/documents/processing/tests/__init__.py +0 -0
  32. package/ee/python/documents/processing/tests/test_split_pdf.py +230 -0
  33. package/ee/python/requirements.txt +17 -2
  34. package/ee/python/setup.sh +40 -1
  35. package/ee/python/transcription/pipeline.py +109 -15
  36. package/ee/python/transcription/tests/test_align_model_licensing.py +184 -0
  37. package/ee/workers.ts +2 -7
  38. package/license.md +2 -2
  39. package/package.json +3 -4
  40. package/scripts/postinstall.cjs +52 -1
  41. 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
@@ -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 docling" 2>/dev/null && print_success "docling imported successfully" || print_error "Failed to import docling"
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
- if language_code not in self.align_models:
126
- device = "cpu" if self.device == "mps" else self.device
127
- print(f"[pipeline] Loading align model for {language_code}", flush=True)
128
- self.align_models[language_code] = whisperx.load_align_model(
129
- language_code=language_code, device=device
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
- model_a, metadata = self._get_align_model(language)
174
- aligned = whisperx.align(
175
- transcribe_result["segments"],
176
- model_a,
177
- metadata,
178
- audio,
179
- align_device,
180
- return_char_alignments=False,
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"