@exulu/backend 1.70.0 → 2.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 (47) hide show
  1. package/dist/{catalog-TBSPSN2N.js → catalog-UGTDNMDM.js} +2 -1
  2. package/dist/{chunk-YCE44CMU.js → chunk-7CCMW3IW.js} +2 -0
  3. package/dist/chunk-IJ4HNHOT.js +6416 -0
  4. package/dist/{chunk-IDHS2BZO.js → chunk-T6JVFT7L.js} +2 -0
  5. package/dist/cli/start-whisper.cjs +1 -0
  6. package/dist/cli/start-whisper.js +2 -1
  7. package/dist/convert-exulu-tools-to-ai-sdk-tools-2PEDFZ2X.js +9 -0
  8. package/dist/index.cjs +9514 -9260
  9. package/dist/index.d.cts +46 -29
  10. package/dist/index.d.ts +46 -29
  11. package/dist/index.js +4947 -548
  12. package/ee/agentic-retrieval/pipeline/config.test.ts +81 -0
  13. package/ee/agentic-retrieval/pipeline/config.ts +189 -0
  14. package/ee/agentic-retrieval/pipeline/hyde.test.ts +55 -0
  15. package/ee/agentic-retrieval/pipeline/hyde.ts +133 -0
  16. package/ee/agentic-retrieval/pipeline/index.test.ts +140 -0
  17. package/ee/agentic-retrieval/pipeline/index.ts +638 -0
  18. package/ee/agentic-retrieval/pipeline/memory.test.ts +101 -0
  19. package/ee/agentic-retrieval/pipeline/memory.ts +566 -0
  20. package/ee/agentic-retrieval/pipeline/multi-query.test.ts +51 -0
  21. package/ee/agentic-retrieval/pipeline/multi-query.ts +158 -0
  22. package/ee/agentic-retrieval/pipeline/prefilter.test.ts +93 -0
  23. package/ee/agentic-retrieval/pipeline/prefilter.ts +389 -0
  24. package/ee/agentic-retrieval/pipeline/rerank.test.ts +128 -0
  25. package/ee/agentic-retrieval/pipeline/rerank.ts +178 -0
  26. package/ee/agentic-retrieval/pipeline/routing.test.ts +144 -0
  27. package/ee/agentic-retrieval/pipeline/routing.ts +343 -0
  28. package/ee/agentic-retrieval/pipeline/search.test.ts +149 -0
  29. package/ee/agentic-retrieval/pipeline/search.ts +180 -0
  30. package/ee/agentic-retrieval/pipeline/text-utils.test.ts +43 -0
  31. package/ee/agentic-retrieval/pipeline/text-utils.ts +85 -0
  32. package/ee/agentic-retrieval/pipeline/types.ts +59 -0
  33. package/ee/python/documents/processing/doc_processor.ts +1 -1
  34. package/ee/python/documents/processing/split_pdf.py +78 -24
  35. package/package.json +2 -1
  36. package/dist/chunk-WCP3WZM3.js +0 -10391
  37. package/dist/convert-exulu-tools-to-ai-sdk-tools-GQ3UIYP7.js +0 -6
  38. package/ee/agentic-retrieval/v3/agent-loop.ts +0 -288
  39. package/ee/agentic-retrieval/v3/classifier.ts +0 -92
  40. package/ee/agentic-retrieval/v3/context-sampler.ts +0 -79
  41. package/ee/agentic-retrieval/v3/dynamic-tools.ts +0 -115
  42. package/ee/agentic-retrieval/v3/index.ts +0 -471
  43. package/ee/agentic-retrieval/v3/session-tools-registry.ts +0 -20
  44. package/ee/agentic-retrieval/v3/strategies.ts +0 -171
  45. package/ee/agentic-retrieval/v3/tools.ts +0 -558
  46. package/ee/agentic-retrieval/v3/trajectory.ts +0 -309
  47. package/ee/agentic-retrieval/v3/types.ts +0 -59
@@ -0,0 +1,43 @@
1
+ import {
2
+ normalizeFileName, deriveKeywordVariants, extractIdentifierTokens,
3
+ itemMatchesIdentifierToken, applyRewrites, stripSeparators,
4
+ } from "./text-utils";
5
+
6
+ describe("text-utils", () => {
7
+ it("normalizeFileName strips the bucket segment, separators, and extension", () => {
8
+ expect(normalizeFileName("/bucket/folder/hb_PAM-E4_2023.de.pdf")).toBe("folder hbpame42023 de pdf");
9
+ });
10
+
11
+ it("normalizeFileName drops the first segment for paths without leading slash", () => {
12
+ expect(normalizeFileName("bucket/folder/hb_PAM-E4_2023.de.pdf")).toBe("folder hbpame42023 de pdf");
13
+ });
14
+
15
+ it("normalizeFileName preserves bare filenames without dropping segments", () => {
16
+ expect(normalizeFileName("hb_FST-2XT_manual.pdf")).toBe("hbfst2xtmanual pdf");
17
+ });
18
+
19
+ it("deriveKeywordVariants yields lowercased, separator- and digit-stripped forms ≥4 chars", () => {
20
+ expect(deriveKeywordVariants("FST-2XT").sort()).toEqual(["fst-2xt", "fst2xt"].sort());
21
+ expect(deriveKeywordVariants("MISCEL6")).toEqual(expect.arrayContaining(["miscel6", "miscel"]));
22
+ expect(deriveKeywordVariants("ab")).toEqual([]); // too short
23
+ });
24
+
25
+ it("extractIdentifierTokens keeps ≥4-char tokens containing a digit and a letter", () => {
26
+ expect(extractIdentifierTokens(["FST-2XT", "sperren", "S2", undefined])).toEqual(["fst2xt"]);
27
+ });
28
+
29
+ it("itemMatchesIdentifierToken matches separator-insensitively against the filename", () => {
30
+ expect(itemMatchesIdentifierToken("hb_FST-2XT_manual.pdf", ["fst2xt"])).toBe(true);
31
+ expect(itemMatchesIdentifierToken("hb_FST2_manual.pdf", ["fst2xt"])).toBe(false);
32
+ });
33
+
34
+ it("applyRewrites returns one variant per matching rule, none when nothing matches", () => {
35
+ const rules = [{ find: "bypass", replace: "override" }, { find: "zzz", replace: "yyy" }];
36
+ expect(applyRewrites("how to bypass the door", rules)).toEqual(["how to override the door"]);
37
+ expect(applyRewrites("hello", rules)).toEqual([]);
38
+ });
39
+
40
+ it("stripSeparators lowers and removes separators", () => {
41
+ expect(stripSeparators("FST-2 XT.a")).toBe("fst2xta");
42
+ });
43
+ });
@@ -0,0 +1,85 @@
1
+ export const normalizeFileName = (fileName: string): string => {
2
+ // 1. Remove the first path segment (e.g., "/aufzugsperipherie")
3
+ const parts = fileName.split('/').filter(p => p); // filter out empty strings
4
+ let normalized = parts.length > 1 ? parts.slice(1).join('/') : (parts[0] || '');
5
+
6
+ // 2. Replace the remaining slashes with a space
7
+ normalized = normalized.replace(/\//g, ' ');
8
+
9
+ // 3. Convert to lowercase
10
+ normalized = normalized.toLowerCase();
11
+
12
+ // 4. PRE-CLEANUP: Insert a space before the file extension period.
13
+ // This looks for a dot followed by 3-4 letters/digits at the end of the string.
14
+ // The captured group ($1) puts the extension back with a space before it.
15
+ normalized = normalized.replace(/(\.[a-z0-9]{3,4})$/g, ' $1');
16
+
17
+ // 5. Final cleanup: Remove all characters that are NOT a lowercase letter, digit, space, or a period.
18
+ // We keep the period here temporarily to ensure the extension is preserved.
19
+ normalized = normalized.replace(/[^a-z0-9\. ]/g, '');
20
+
21
+ // 6. Final step: Replace any space/dot/space pattern with just a space
22
+ // and trim excess spaces. This handles the space inserted in step 4.
23
+ // The regex /\s*\.\s*/g finds any combination of space-dot-space and replaces it with a single space.
24
+ normalized = normalized.replace(/\s*\.\s*/g, ' ');
25
+
26
+ // 7. Clean up multiple spaces and leading/trailing spaces
27
+ normalized = normalized.replace(/ +/g, ' ').trim();
28
+
29
+ return normalized;
30
+ };
31
+
32
+ export const normalizeText = (text: string): string => {
33
+ return text.toLowerCase().replace(/[^a-z0-9]/g, "");
34
+ }
35
+
36
+ export const deriveKeywordVariants = (keyword: string): string[] => {
37
+ const lower = keyword.trim().toLowerCase();
38
+ if (!lower) return [];
39
+ const variants = new Set<string>();
40
+ variants.add(lower);
41
+ variants.add(lower.replace(/[-_\.\s]/g, ''));
42
+ variants.add(lower.replace(/\d+$/, ''));
43
+ variants.add(lower.replace(/[-_\.\s]/g, '').replace(/\d+$/, ''));
44
+ return [...variants].filter(v => v.length >= 4);
45
+ }
46
+
47
+ export function extractIdentifierTokens(parts: Array<string | undefined>): string[] {
48
+ const tokens = new Set<string>();
49
+ for (const part of parts) {
50
+ if (!part) continue;
51
+ // Split on whitespace so multi-word strings (the raw question) yield candidates.
52
+ for (const raw of part.split(/\s+/)) {
53
+ const norm = raw.toLowerCase().replace(/[^a-z0-9]/g, "");
54
+ if (norm.length >= 4 && /\d/.test(norm) && /[a-z]/.test(norm)) {
55
+ tokens.add(norm);
56
+ }
57
+ }
58
+ }
59
+ return [...tokens];
60
+ }
61
+
62
+ /** True if the document filename contains any of the identifier tokens (separators ignored). */
63
+ export function itemMatchesIdentifierToken(itemName: string | undefined, tokens: string[]): boolean {
64
+ if (!itemName || tokens.length === 0) return false;
65
+ const normName = normalizeFileName(itemName).toLowerCase().replace(/[^a-z0-9]/g, "");
66
+ return tokens.some(t => normName.includes(t));
67
+ }
68
+
69
+ export const stripSeparators = (s: string): string => s.toLowerCase().replace(/[-_\.\s]/g, "");
70
+
71
+ /** Apply configured find→replace rules (case-insensitive, all occurrences).
72
+ * Returns one rewritten query per rule that changed the input; deduped. */
73
+ export function applyRewrites(
74
+ question: string,
75
+ rewrites: { find: string; replace: string }[],
76
+ ): string[] {
77
+ const out = new Set<string>();
78
+ for (const rule of rewrites) {
79
+ if (!rule.find) continue;
80
+ const escaped = rule.find.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
81
+ const rewritten = question.replace(new RegExp(escaped, "gi"), rule.replace);
82
+ if (rewritten !== question) out.add(rewritten);
83
+ }
84
+ return [...out];
85
+ }
@@ -0,0 +1,59 @@
1
+ import type { VectorSearchChunkResult } from "@SRC/graphql/resolvers/vector-search";
2
+
3
+ export type Chunk = VectorSearchChunkResult;
4
+ export type ChunkWithScore = Chunk & { rerank_score?: number; context?: { id: string; name: string } };
5
+
6
+ export type PhaseStep = {
7
+ text: string;
8
+ chunks?: ChunkWithScore[];
9
+ toolCalls?: Array<{ name: string; id: string; input: unknown }>;
10
+ };
11
+
12
+ export type RoutingPhaseResult = {
13
+ mainContexts: string[];
14
+ fallbackContexts: string[];
15
+ userPinnedItemIdsByContext: Map<string, Set<string>>;
16
+ userRequestedPage: number | null;
17
+ hasExplicitDocAndPage: boolean;
18
+ steps: PhaseStep[];
19
+ };
20
+
21
+ export type MemoryPhaseResult = {
22
+ memoryChunksForAnswer: ChunkWithScore[];
23
+ memoryOverride: { active: boolean; chunks: ChunkWithScore[]; reason: string };
24
+ memoryPinnedItemIds: Set<string>;
25
+ updatedQuestion: string;
26
+ updatedKeywords: string[];
27
+ updatedImportantKeyword: string;
28
+ steps: PhaseStep[];
29
+ };
30
+
31
+ export type SearchContextsResult = { chunks: Chunk[] };
32
+
33
+ export type RerankState = {
34
+ pinnedItemIds: Set<string>;
35
+ userPinnedItemIds: Set<string>;
36
+ userRequestedPage: number | null;
37
+ keywords: string[];
38
+ importantKeyword: string;
39
+ };
40
+ export type RerankResult = {
41
+ limited_results: ChunkWithScore[];
42
+ sorted_reranked_results: ChunkWithScore[];
43
+ rerank_score_max_genuine: number;
44
+ };
45
+
46
+ export type RetrievalStep = {
47
+ stepNumber: number;
48
+ text: string;
49
+ toolCalls: Array<{ name: string; id: string; input: unknown }>;
50
+ chunks: ChunkWithScore[];
51
+ tokens: number;
52
+ };
53
+ export type AgenticRetrievalOutput = {
54
+ steps: RetrievalStep[];
55
+ reasoning: { text: string; tools: unknown[] }[];
56
+ chunks: ChunkWithScore[];
57
+ usage: unknown[];
58
+ totalTokens: number;
59
+ };
@@ -834,7 +834,7 @@ async function processPdf(
834
834
 
835
835
  const splitResult = await executePythonScript({
836
836
  scriptPath: 'ee/python/documents/processing/split_pdf.py',
837
- args: [paths.source, chunksDir, '--chunk-size', String(maxPagesPerChunk)],
837
+ args: [paths.source, chunksDir, '--chunk-size', String(maxPagesPerChunk), '--max-size-mb', '25'],
838
838
  timeout: 5 * 60 * 1000,
839
839
  });
840
840
 
@@ -6,13 +6,13 @@ Outputs a JSON array to stdout, each element:
6
6
  { "path": "<absolute-path>", "start_page": <int>, "end_page": <int> }
7
7
 
8
8
  start_page is 0-indexed, end_page is exclusive (Python-slice convention).
9
- If the document fits within chunk_size, a single entry pointing to the
10
- original file is returned (no copy made).
9
+ If the document fits within chunk_size AND within max_size_bytes, a single
10
+ entry pointing to the original file is returned (no copy made).
11
11
 
12
12
  Progress and diagnostics go to stderr so stdout stays clean JSON.
13
13
 
14
14
  Usage:
15
- split_pdf.py <input_pdf> <output_dir> [--chunk-size N]
15
+ split_pdf.py <input_pdf> <output_dir> [--chunk-size N] [--max-size-mb F]
16
16
  """
17
17
 
18
18
  import sys
@@ -23,7 +23,54 @@ import argparse
23
23
  import fitz # PyMuPDF — installed as a docling transitive dependency
24
24
 
25
25
 
26
- def split_pdf(input_path: str, output_dir: str, chunk_size: int) -> list[dict]:
26
+ def _write_chunk(
27
+ doc: fitz.Document,
28
+ output_dir: str,
29
+ chunk_start: int,
30
+ chunk_end: int,
31
+ max_size_bytes: int | None,
32
+ ) -> list[dict]:
33
+ """Write pages [chunk_start, chunk_end) to a file.
34
+
35
+ If the result exceeds max_size_bytes and contains more than one page,
36
+ delete it and recurse with the range bisected. Single-page chunks that
37
+ still exceed the limit are kept with a warning — they cannot be split
38
+ further without re-encoding.
39
+ """
40
+ chunk_path = os.path.join(output_dir, f"chunk_{chunk_start}_{chunk_end - 1}.pdf")
41
+
42
+ sub = fitz.open()
43
+ sub.insert_pdf(doc, from_page=chunk_start, to_page=chunk_end - 1)
44
+ sub.save(chunk_path)
45
+ sub.close()
46
+
47
+ chunk_bytes = os.path.getsize(chunk_path)
48
+ n_pages = chunk_end - chunk_start
49
+
50
+ if max_size_bytes and chunk_bytes > max_size_bytes and n_pages > 1:
51
+ os.remove(chunk_path)
52
+ mid = chunk_start + n_pages // 2
53
+ return (
54
+ _write_chunk(doc, output_dir, chunk_start, mid, max_size_bytes)
55
+ + _write_chunk(doc, output_dir, mid, chunk_end, max_size_bytes)
56
+ )
57
+
58
+ if max_size_bytes and chunk_bytes > max_size_bytes:
59
+ print(
60
+ f"[split_pdf] WARNING: single-page chunk {chunk_start} is {chunk_bytes:,} bytes — "
61
+ "exceeds size limit but cannot be split further",
62
+ file=sys.stderr,
63
+ )
64
+
65
+ return [{"path": os.path.abspath(chunk_path), "start_page": chunk_start, "end_page": chunk_end}]
66
+
67
+
68
+ def split_pdf(
69
+ input_path: str,
70
+ output_dir: str,
71
+ chunk_size: int,
72
+ max_size_bytes: int | None = None,
73
+ ) -> list[dict]:
27
74
  doc = fitz.open(input_path)
28
75
 
29
76
  # Some PDFs are saved with an empty owner/user password by certain writers
@@ -39,9 +86,17 @@ def split_pdf(input_path: str, output_dir: str, chunk_size: int) -> list[dict]:
39
86
  print("[split_pdf] Authenticated with empty password (phantom-password PDF)", file=sys.stderr)
40
87
 
41
88
  total_pages = len(doc)
42
- print(f"[split_pdf] Total pages: {total_pages}, chunk size: {chunk_size}", file=sys.stderr)
89
+ file_size = os.path.getsize(input_path)
90
+ print(
91
+ f"[split_pdf] Total pages: {total_pages}, chunk size: {chunk_size}, "
92
+ f"file size: {file_size:,} bytes"
93
+ + (f", max chunk size: {max_size_bytes:,} bytes" if max_size_bytes else ""),
94
+ file=sys.stderr,
95
+ )
43
96
 
44
- if total_pages <= chunk_size:
97
+ needs_split = total_pages > chunk_size or (max_size_bytes and file_size > max_size_bytes)
98
+
99
+ if not needs_split:
45
100
  print("[split_pdf] No split needed — returning original path", file=sys.stderr)
46
101
  doc.close()
47
102
  return [{
@@ -55,23 +110,14 @@ def split_pdf(input_path: str, output_dir: str, chunk_size: int) -> list[dict]:
55
110
  chunks = []
56
111
  for start_page in range(0, total_pages, chunk_size):
57
112
  end_page = min(start_page + chunk_size, total_pages)
58
- chunk_filename = f"chunk_{start_page}_{end_page - 1}.pdf"
59
- chunk_path = os.path.join(output_dir, chunk_filename)
60
-
61
- chunk_doc = fitz.open()
62
- chunk_doc.insert_pdf(doc, from_page=start_page, to_page=end_page - 1)
63
- chunk_doc.save(chunk_path)
64
- chunk_doc.close()
65
-
66
- chunks.append({
67
- "path": os.path.abspath(chunk_path),
68
- "start_page": start_page,
69
- "end_page": end_page,
70
- })
71
- print(
72
- f"[split_pdf] Chunk {len(chunks)}: pages {start_page}–{end_page - 1} → {chunk_filename}",
73
- file=sys.stderr,
74
- )
113
+ sub_chunks = _write_chunk(doc, output_dir, start_page, end_page, max_size_bytes)
114
+ for c in sub_chunks:
115
+ print(
116
+ f"[split_pdf] Chunk {len(chunks) + 1}: pages {c['start_page']}–{c['end_page'] - 1} "
117
+ f"({os.path.getsize(c['path']):,} bytes) {os.path.basename(c['path'])}",
118
+ file=sys.stderr,
119
+ )
120
+ chunks.extend(sub_chunks)
75
121
 
76
122
  doc.close()
77
123
  return chunks
@@ -87,10 +133,18 @@ if __name__ == "__main__":
87
133
  default=25,
88
134
  help="Maximum pages per chunk (default: 25)",
89
135
  )
136
+ parser.add_argument(
137
+ "--max-size-mb",
138
+ type=float,
139
+ default=None,
140
+ help="Maximum chunk file size in MB — chunks exceeding this are bisected by page count (default: no limit)",
141
+ )
90
142
  args = parser.parse_args()
91
143
 
144
+ max_size_bytes = int(args.max_size_mb * 1024 * 1024) if args.max_size_mb is not None else None
145
+
92
146
  try:
93
- chunks = split_pdf(args.input_pdf, args.output_dir, args.chunk_size)
147
+ chunks = split_pdf(args.input_pdf, args.output_dir, args.chunk_size, max_size_bytes)
94
148
  print(json.dumps(chunks))
95
149
  except Exception as e:
96
150
  print(f"[split_pdf] ERROR: {e}", file=sys.stderr)
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@exulu/backend",
3
3
  "author": "Qventu Bv.",
4
- "version": "1.70.0",
4
+ "version": "2.0.0",
5
5
  "main": "./dist/index.js",
6
6
  "private": false,
7
7
  "publishConfig": {
@@ -132,6 +132,7 @@
132
132
  "express": "^5.1.0",
133
133
  "express-http-proxy": "^2.1.2",
134
134
  "franc": "^6.2.0",
135
+ "fuse.js": "^7.4.2",
135
136
  "graphql": "^16.11.0",
136
137
  "graphql-tools": "^9.0.18",
137
138
  "graphql-type-json": "^0.3.2",