@exulu/backend 3.7.4 → 4.1.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-27K2CO47.js → chunk-QMN6MVHQ.js} +1 -1
- package/dist/{chunk-AWMU6QXB.js → chunk-RBEWHG7I.js} +297 -39
- package/dist/cli/start-whisper.js +1 -1
- package/dist/{convert-exulu-tools-to-ai-sdk-tools-XNQ6Q3X6.js → convert-exulu-tools-to-ai-sdk-tools-6RU4IZMI.js} +1 -1
- package/dist/index.cjs +617 -223
- package/dist/index.d.cts +3 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.js +300 -186
- package/dist/{python-setup-JZGHWQCG.js → python-setup-DRJ3QX5F.js} +1 -1
- 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/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 +12 -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
|
@@ -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"
|
package/ee/workers.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { guardRedisStartup, logRedisErrors } from "@EE/queues/redis-startup.ts";
|
|
|
4
4
|
import { Job, Worker, type JobState } from "bullmq";
|
|
5
5
|
import { bullmq } from "@SRC/validators/bullmq.ts";
|
|
6
6
|
import { serializeError } from "@SRC/utils/serialize-error.ts";
|
|
7
|
+
import { finishTurnMetadata } from "@SRC/exulu/turn-metadata.ts";
|
|
7
8
|
import { getEnabledTools } from "@SRC/utils/enabled-tools.ts";
|
|
8
9
|
import { ExuluStorage } from "@SRC/exulu/storage.ts";
|
|
9
10
|
import type { ExuluAgent } from "@EXULU_TYPES/models/agent.ts";
|
|
@@ -1702,13 +1703,7 @@ export const processUiMessagesFlow = async ({
|
|
|
1702
1703
|
messageMetadata: ({ part }) => {
|
|
1703
1704
|
console.log("[EXULU] part", part.type);
|
|
1704
1705
|
if (part.type === "finish") {
|
|
1705
|
-
return {
|
|
1706
|
-
totalTokens: part.totalUsage.totalTokens,
|
|
1707
|
-
reasoningTokens: part.totalUsage.reasoningTokens,
|
|
1708
|
-
inputTokens: part.totalUsage.inputTokens,
|
|
1709
|
-
outputTokens: part.totalUsage.outputTokens,
|
|
1710
|
-
cachedInputTokens: part.totalUsage.cachedInputTokens,
|
|
1711
|
-
};
|
|
1706
|
+
return finishTurnMetadata({ totalUsage: part.totalUsage, startedAt: startTime });
|
|
1712
1707
|
}
|
|
1713
1708
|
return undefined;
|
|
1714
1709
|
},
|
package/license.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
Copyright (c) 2023-2026 Qventu B.
|
|
1
|
+
Copyright (c) 2023-2026 Qventu B.V.
|
|
2
2
|
|
|
3
3
|
Certain portions of this software are licensed as described below:
|
|
4
4
|
|
|
@@ -102,4 +102,4 @@ these terms.
|
|
|
102
102
|
|
|
103
103
|
**use** means anything you do with the software requiring one of your licenses.
|
|
104
104
|
|
|
105
|
-
**trademark** means trademarks, service marks, and similar rights.
|
|
105
|
+
**trademark** means trademarks, service marks, and similar rights.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@exulu/backend",
|
|
3
|
-
"author": "Qventu
|
|
4
|
-
"version": "
|
|
3
|
+
"author": "Qventu B.V.",
|
|
4
|
+
"version": "4.1.0",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"private": false,
|
|
7
7
|
"publishConfig": {
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
"start:whisper": "node ./dist/cli/start-whisper.cjs",
|
|
49
49
|
"python:setup": "cd ee/python && ./setup.sh",
|
|
50
50
|
"python:install": "source ee/python/.venv/bin/activate && pip install -r ee/python/requirements.txt",
|
|
51
|
-
"python:validate": "source ee/python/.venv/bin/activate && python -c 'import
|
|
51
|
+
"python:validate": "source ee/python/.venv/bin/activate && python -c 'import pypdf; import transformers; print(\"✓ Python environment is working correctly\")'",
|
|
52
52
|
"python:clean": "rm -rf ee/python/.venv ee/python/__pycache__ ee/python/**/__pycache__ ee/python/**/*.pyc",
|
|
53
53
|
"python:rebuild": "npm run python:clean && npm run python:setup"
|
|
54
54
|
},
|
|
@@ -143,7 +143,6 @@
|
|
|
143
143
|
"jose": "^6.0.10",
|
|
144
144
|
"json-schema-to-zod": "^2.6.1",
|
|
145
145
|
"jsonwebtoken": "^9.0.2",
|
|
146
|
-
"just-bash": "^2.14.0",
|
|
147
146
|
"knex": "^3.1.0",
|
|
148
147
|
"link": "^2.1.1",
|
|
149
148
|
"mailparser": "^3.9.14",
|
package/scripts/postinstall.cjs
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
const { exec } = require('child_process');
|
|
10
10
|
const { promisify } = require('util');
|
|
11
|
-
const { existsSync } = require('fs');
|
|
11
|
+
const { existsSync, rmSync } = require('fs');
|
|
12
12
|
const { resolve, join } = require('path');
|
|
13
13
|
|
|
14
14
|
const execAsync = promisify(exec);
|
|
@@ -82,6 +82,53 @@ async function setupPythonEnvironment() {
|
|
|
82
82
|
}
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
+
/**
|
|
86
|
+
* Remove node-liblzma (LGPL-3.0) from the installed tree.
|
|
87
|
+
*
|
|
88
|
+
* It arrives as an OPTIONAL dependency of just-bash, which reaches us through
|
|
89
|
+
* bash-tool. just-bash is the only dependent. It is loaded lazily, by a dynamic
|
|
90
|
+
* import inside a try/catch, and only when a shell command uses xz compression;
|
|
91
|
+
* with it absent that path throws its own "xz compression requires
|
|
92
|
+
* node-liblzma" error and every other command is unaffected.
|
|
93
|
+
*
|
|
94
|
+
* Why not `npm install --omit=optional`: that flag is all-or-nothing, and 24 of
|
|
95
|
+
* the 91 runtime optional packages are the `@img/sharp-*` platform binaries.
|
|
96
|
+
* Omitting them leaves sharp unable to load, which breaks document processing.
|
|
97
|
+
* So the removal has to be targeted at this one package.
|
|
98
|
+
*
|
|
99
|
+
* Set EXULU_KEEP_NODE_LIBLZMA=true to keep it — for example if you want xz
|
|
100
|
+
* support and have satisfied yourself about LGPL-3.0 in your deployment.
|
|
101
|
+
*
|
|
102
|
+
* Best-effort by design: it walks up from this package looking for the hoisted
|
|
103
|
+
* copy, and silently does nothing if the layout differs (pnpm, yarn PnP) or the
|
|
104
|
+
* directory is not writable. Never fails the install.
|
|
105
|
+
*/
|
|
106
|
+
function removeNodeLiblzma() {
|
|
107
|
+
if (String(process.env.EXULU_KEEP_NODE_LIBLZMA).toLowerCase() === 'true') {
|
|
108
|
+
console.log(`${colors.yellow}⊘${colors.reset} Keeping node-liblzma (EXULU_KEEP_NODE_LIBLZMA=true) — LGPL-3.0`);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Walk up from this package looking for a hoisted node_modules/node-liblzma.
|
|
113
|
+
let dir = resolve(__dirname, '..');
|
|
114
|
+
for (let depth = 0; depth < 6; depth++) {
|
|
115
|
+
const candidate = join(dir, 'node_modules', 'node-liblzma');
|
|
116
|
+
if (existsSync(candidate)) {
|
|
117
|
+
try {
|
|
118
|
+
rmSync(candidate, { recursive: true, force: true });
|
|
119
|
+
console.log(`${colors.green}✓${colors.reset} Removed node-liblzma (LGPL-3.0; optional, xz compression only)`);
|
|
120
|
+
} catch (err) {
|
|
121
|
+
console.log(`${colors.yellow}!${colors.reset} Could not remove node-liblzma (LGPL-3.0): ${err.message}`);
|
|
122
|
+
console.log(' Remove it manually, or set EXULU_KEEP_NODE_LIBLZMA=true to keep it deliberately.');
|
|
123
|
+
}
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
const parent = resolve(dir, '..');
|
|
127
|
+
if (parent === dir) break;
|
|
128
|
+
dir = parent;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
85
132
|
/**
|
|
86
133
|
* Main postinstall function
|
|
87
134
|
*/
|
|
@@ -92,6 +139,10 @@ async function main() {
|
|
|
92
139
|
console.log(`${colors.blue}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${colors.reset}`);
|
|
93
140
|
console.log('');
|
|
94
141
|
|
|
142
|
+
// Runs before the Python setup and before any early return below, so it
|
|
143
|
+
// happens even when SKIP_PYTHON_SETUP=1 or the venv already exists.
|
|
144
|
+
removeNodeLiblzma();
|
|
145
|
+
|
|
95
146
|
// Check if we should skip setup
|
|
96
147
|
if (shouldSkipSetup()) {
|
|
97
148
|
console.log(`${colors.yellow}⊘${colors.reset} Skipping Python setup (SKIP_PYTHON_SETUP=1)`);
|