@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.
Files changed (38) hide show
  1. package/dist/{chunk-27K2CO47.js → chunk-QMN6MVHQ.js} +1 -1
  2. package/dist/{chunk-AWMU6QXB.js → chunk-RBEWHG7I.js} +297 -39
  3. package/dist/cli/start-whisper.js +1 -1
  4. package/dist/{convert-exulu-tools-to-ai-sdk-tools-XNQ6Q3X6.js → convert-exulu-tools-to-ai-sdk-tools-6RU4IZMI.js} +1 -1
  5. package/dist/index.cjs +617 -223
  6. package/dist/index.d.cts +3 -1
  7. package/dist/index.d.ts +3 -1
  8. package/dist/index.js +300 -186
  9. package/dist/{python-setup-JZGHWQCG.js → python-setup-DRJ3QX5F.js} +1 -1
  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/python/documents/processing/README.md +2 -3
  26. package/ee/python/documents/processing/doc_processor.ts +21 -61
  27. package/ee/python/documents/processing/split_pdf.py +25 -30
  28. package/ee/python/documents/processing/tests/__init__.py +0 -0
  29. package/ee/python/documents/processing/tests/test_split_pdf.py +230 -0
  30. package/ee/python/requirements.txt +12 -2
  31. package/ee/python/setup.sh +40 -1
  32. package/ee/python/transcription/pipeline.py +109 -15
  33. package/ee/python/transcription/tests/test_align_model_licensing.py +184 -0
  34. package/ee/workers.ts +2 -7
  35. package/license.md +2 -2
  36. package/package.json +3 -4
  37. package/scripts/postinstall.cjs +52 -1
  38. package/ee/python/documents/processing/document_to_markdown.py +0 -413
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { microCall } from "./micro-call";
3
+ import { withTiming } from "./timing";
3
4
  import { singleSearch } from "./multi-query";
4
5
  import { fuzzyPrefilter } from "./prefilter";
5
6
  import { deriveKeywordVariants, normalizeFileName, stripSeparators } from "./text-utils";
@@ -45,18 +46,20 @@ async function loadMemoryItems(context: {
45
46
  // Keyword recall (ported from newton-memory.ts:91-169)
46
47
  // ---------------------------------------------------------------------------
47
48
 
48
- async function recallMemoryByKeywords({
49
+ export async function recallMemoryByKeywords({
49
50
  keywords,
50
51
  importantKeyword,
51
52
  user,
52
53
  role,
53
54
  memoryContext,
55
+ timings,
54
56
  }: {
55
57
  keywords: string[];
56
58
  importantKeyword: string;
57
59
  user: any;
58
60
  role: any;
59
61
  memoryContext: { id: string; getItems: (o: any) => Promise<MemoryItem[]> };
62
+ timings?: Record<string, number>;
60
63
  }): Promise<Chunk[]> {
61
64
  const allKeywords = [
62
65
  ...new Set(
@@ -77,7 +80,7 @@ async function recallMemoryByKeywords({
77
80
  ].filter((v) => v.length >= 4);
78
81
  if (!allVariants.length) return [];
79
82
 
80
- const items = await loadMemoryItems(memoryContext);
83
+ const items = await withTiming(timings, "memory.keywordRecall.itemsMs", () => loadMemoryItems(memoryContext));
81
84
 
82
85
  type Scored = { id: string; hits: number; importantHit: boolean; name: string };
83
86
  const scored: Scored[] = [];
@@ -104,14 +107,17 @@ async function recallMemoryByKeywords({
104
107
  topMatches.map((s) => `${s.name} (hits=${s.hits}, important=${s.importantHit})`),
105
108
  );
106
109
 
107
- const chunks = await singleSearch({
110
+ // Full-text only: the items were already selected by keyword above, so this
111
+ // call just ranks their chunks. The hybrid method would add an embedding
112
+ // round trip (0.5-1.2 s, up to 5 s cold) on the memory phase's critical path.
113
+ const chunks = await withTiming(timings, "memory.keywordRecall.searchMs", () => singleSearch({
108
114
  query: allKeywords.join(", "),
109
- config: { method: "hybridSearch", cutoffs: undefined, limit: 50 },
115
+ config: { method: "tsvector", cutoffs: undefined, limit: 50 },
110
116
  user,
111
117
  role,
112
118
  pinnedItemIds: topMatches.map((s) => s.id),
113
119
  context: memoryContext,
114
- });
120
+ }));
115
121
 
116
122
  return chunks;
117
123
  }
@@ -137,11 +143,152 @@ function neutralResult(
137
143
  };
138
144
  }
139
145
 
146
+ // ---------------------------------------------------------------------------
147
+ // engine v2: one structured call for the whole memory phase
148
+ // ---------------------------------------------------------------------------
149
+
150
+ type MergedMemoryOutput = {
151
+ relevantChunkIds: string[];
152
+ override: { overrides: boolean; confidence: "high" | "medium" | "low"; authoritativeChunkIds: string[]; reason: string };
153
+ filePrioritization: { shouldPrioritizeFiles: boolean; fileNameHints?: string[] };
154
+ augmentation: { updatedUserQuestion: string; updatedRelevantKeywords: string[]; updatedImportantKeyword: string };
155
+ };
156
+
157
+ /**
158
+ * The v1 flow spends two sequential LLM hops on memory (relevance, then override +
159
+ * file prioritization + augmentation in parallel), ~3 s on the phase-1 critical path.
160
+ * The merged call answers all four questions from the same chunk list at once. The
161
+ * instructions are the v1 prompts, so each judgement keeps its rules; disabled features
162
+ * are still asked for (a stable schema) but their answers are ignored by mergedFollowups.
163
+ */
164
+ async function runMergedMemoryCall({
165
+ model,
166
+ retrievedMemory,
167
+ question,
168
+ keywords,
169
+ importantKeyword,
170
+ memoryConfig,
171
+ glossary,
172
+ }: {
173
+ model: any;
174
+ retrievedMemory: Chunk[];
175
+ question: string;
176
+ keywords: string[];
177
+ importantKeyword: string;
178
+ memoryConfig: { override: boolean; filePrioritization: boolean; queryAugmentation: boolean };
179
+ glossary: { term: string; meaning: string }[];
180
+ }): Promise<MergedMemoryOutput> {
181
+ const glossaryText =
182
+ glossary.length > 0
183
+ ? `\nThe organization's documents use the following abbreviations/terms:\n${glossary.map((g) => `${g.term} : ${g.meaning}`).join("\n")}`
184
+ : "";
185
+ const system = `
186
+ You review the shared company memory for the user's question and answer FOUR questions in one go.
187
+
188
+ 1. RELEVANCE (relevantChunkIds): return the chunk_ids of chunks containing information relevant to the
189
+ question, or an empty array. Be generous: include chunks that are topically related, share key
190
+ terminology, describe the same symptom from a different angle, or could plausibly help diagnose the
191
+ issue — even if they don't answer the question directly. Memory entries are deliberately broad,
192
+ hand-curated hints written by domain experts; the user's wording will rarely match the memory verbatim.
193
+ When in doubt, include the chunk.
194
+
195
+ 2. OVERRIDE (override): decide whether ONE of the relevant chunks should become the AUTHORITATIVE basis
196
+ of the answer, taking precedence over the official documentation even if the documents state something
197
+ different. This is a deliberately STRICT check. Set overrides=true ONLY if a single chunk, on its own,
198
+ contains a DIRECT and SUFFICIENT answer to exactly what the user asked. Being topically related, sharing
199
+ terminology, describing the same component, or only partially addressing the question is NOT sufficient:
200
+ then set overrides=false. When in doubt, set overrides=false. Memory entries may capture field experience
201
+ that the manuals get wrong, so a confident, direct match is meant to win over the documents.
202
+ ${memoryConfig.override ? "" : "(Override is disabled for this agent: return overrides=false.)"}
203
+
204
+ 3. FILE PRIORITIZATION (filePrioritization): set shouldPrioritizeFiles=true only if a relevant memory
205
+ entry explicitly says to look in, prioritize, prefer, or always search a particular document, file, or
206
+ file family (for example "When asked about X, always search in Y-Dateien first"). General background
207
+ facts, glossaries, or synonyms are NOT a file prioritization instruction. When true, return
208
+ fileNameHints exactly as referenced in the memory, bare names without folder paths.
209
+ ${memoryConfig.filePrioritization ? "" : "(File prioritization is disabled for this agent: return false.)"}
210
+
211
+ 4. QUERY AUGMENTATION (augmentation): if, and only if, the relevant memory (or the glossary below)
212
+ contains synonyms or similar terms for what the user asked, return the user question and keywords
213
+ updated to include those synonyms — always keeping the original wording as well. Otherwise return the
214
+ original question, keywords and important keyword unchanged.
215
+ ${memoryConfig.queryAugmentation ? "" : "(Query augmentation is disabled for this agent: return the originals.)"}
216
+
217
+ <memory_chunks>
218
+ ${retrievedMemory.map((chunk) => `- ${chunk.chunk_id}: ${chunk.item_name} - ${chunk.chunk_content}`).join("\n")}
219
+ </memory_chunks>
220
+ ${glossaryText}
221
+ `;
222
+ const { output } = await microCall({
223
+ model,
224
+ system,
225
+ messages: [
226
+ {
227
+ role: "user",
228
+ content: `
229
+ <user_question>${question}</user_question>
230
+ <relevant_keywords>${keywords.join(", ")}</relevant_keywords>
231
+ <important_keyword>${importantKeyword}</important_keyword>
232
+ `,
233
+ },
234
+ ],
235
+ schema: z.object({
236
+ relevantChunkIds: z
237
+ .array(z.string())
238
+ .describe("chunk_ids (UUIDs at the start of each bullet) of relevant chunks; empty array if none."),
239
+ override: z.object({
240
+ overrides: z.boolean().describe("True ONLY if a chunk directly and sufficiently answers the question."),
241
+ confidence: z.enum(["high", "medium", "low"]),
242
+ authoritativeChunkIds: z.array(z.string()).describe("chunk_ids that directly answer the question; empty if overrides is false."),
243
+ reason: z.string().describe("One short sentence."),
244
+ }),
245
+ filePrioritization: z.object({
246
+ shouldPrioritizeFiles: z.boolean(),
247
+ fileNameHints: z.array(z.string()).optional(),
248
+ }),
249
+ augmentation: z.object({
250
+ updatedUserQuestion: z.string(),
251
+ updatedRelevantKeywords: z.array(z.string()),
252
+ updatedImportantKeyword: z.string(),
253
+ }),
254
+ }),
255
+ });
256
+ return output;
257
+ }
258
+
259
+ /** Shape the merged answer like the three v1 follow-up results, honouring the feature toggles. */
260
+ function mergedFollowups(
261
+ merged: MergedMemoryOutput,
262
+ memoryConfig: { override: boolean; filePrioritization: boolean; queryAugmentation: boolean },
263
+ hasAugmentationContent: boolean,
264
+ question: string,
265
+ importantKeyword: string,
266
+ ) {
267
+ const overrideResult = {
268
+ output: memoryConfig.override
269
+ ? merged.override
270
+ : { overrides: false, confidence: "low" as const, authoritativeChunkIds: [] as string[], reason: "" },
271
+ };
272
+ const fileResult = {
273
+ output: memoryConfig.filePrioritization
274
+ ? merged.filePrioritization
275
+ : { shouldPrioritizeFiles: false, fileNameHints: [] as string[] },
276
+ };
277
+ const queryResult = {
278
+ output:
279
+ memoryConfig.queryAugmentation && hasAugmentationContent
280
+ ? merged.augmentation
281
+ : { updatedUserQuestion: question, updatedRelevantKeywords: [] as string[], updatedImportantKeyword: importantKeyword },
282
+ };
283
+ return [overrideResult, fileResult, queryResult] as const;
284
+ }
285
+
140
286
  // ---------------------------------------------------------------------------
141
287
  // Main export
142
288
  // ---------------------------------------------------------------------------
143
289
 
144
290
  export async function runMemoryPhase({
291
+ timings,
145
292
  memoryChunks,
146
293
  memoryContext,
147
294
  question,
@@ -153,6 +300,7 @@ export async function runMemoryPhase({
153
300
  memoryConfig,
154
301
  glossary,
155
302
  documentContexts,
303
+ mergedCall = false,
156
304
  }: {
157
305
  memoryChunks: Chunk[];
158
306
  memoryContext?: any;
@@ -170,6 +318,9 @@ export async function runMemoryPhase({
170
318
  };
171
319
  glossary: { term: string; meaning: string }[];
172
320
  documentContexts: any[];
321
+ timings?: Record<string, number>;
322
+ /** engine v2: answer relevance, override, file prioritization and augmentation in ONE call. */
323
+ mergedCall?: boolean;
173
324
  }): Promise<MemoryPhaseResult> {
174
325
  try {
175
326
  // Short-circuit: disabled, or nothing to work with
@@ -183,13 +334,14 @@ export async function runMemoryPhase({
183
334
  // Keyword recall: extend memory with items that match the user's keywords
184
335
  if (memoryContext) {
185
336
  try {
186
- const keywordMatched = await recallMemoryByKeywords({
337
+ const keywordMatched = await withTiming(timings, "memory.keywordRecallMs", () => recallMemoryByKeywords({
187
338
  keywords,
188
339
  importantKeyword,
189
340
  user,
190
341
  role,
191
342
  memoryContext,
192
- });
343
+ timings,
344
+ }));
193
345
  if (keywordMatched.length > 0) {
194
346
  const seen = new Set(retrieved_memory.map((c) => c.chunk_id));
195
347
  const additions = keywordMatched.filter((c) => !seen.has(c.chunk_id));
@@ -217,8 +369,24 @@ export async function runMemoryPhase({
217
369
  `;
218
370
 
219
371
  let relevantMemoryChunks: Chunk[] = [];
372
+ let mergedOutput: MergedMemoryOutput | undefined;
220
373
  try {
221
- const { output: output_relevant_memory } = await microCall({
374
+ if (mergedCall) {
375
+ mergedOutput = await withTiming(timings, "memory.mergedMs", () =>
376
+ runMergedMemoryCall({
377
+ model,
378
+ retrievedMemory: retrieved_memory,
379
+ question,
380
+ keywords,
381
+ importantKeyword,
382
+ memoryConfig,
383
+ glossary,
384
+ }),
385
+ );
386
+ }
387
+ const { output: output_relevant_memory } = mergedOutput
388
+ ? { output: { relevantChunkIds: mergedOutput.relevantChunkIds } }
389
+ : await withTiming(timings, "memory.relevanceMs", () => microCall({
222
390
  model,
223
391
  system: CHECK_MEMORIES_FOR_RELEVANT_INFORMATION,
224
392
  messages: [
@@ -238,7 +406,7 @@ export async function runMemoryPhase({
238
406
  "The chunk_ids (UUIDs at the start of each bullet) of chunks containing information relevant to the user's question. Empty array if none are relevant.",
239
407
  ),
240
408
  }),
241
- });
409
+ }));
242
410
 
243
411
  const ids = new Set(output_relevant_memory?.relevantChunkIds ?? []);
244
412
  relevantMemoryChunks =
@@ -348,7 +516,9 @@ export async function runMemoryPhase({
348
516
  Otherwise, return the original user question, relevant keywords and important keyword.
349
517
  `;
350
518
 
351
- const [overrideResult, fileResult, queryResult] = await Promise.all([
519
+ const [overrideResult, fileResult, queryResult] = mergedOutput
520
+ ? mergedFollowups(mergedOutput, memoryConfig, hasAugmentationContent, question, importantKeyword)
521
+ : await withTiming(timings, "memory.followupsMs", () => Promise.all([
352
522
  // Override check: strict gate to decide if memory should be authoritative
353
523
  memoryConfig.override
354
524
  ? microCall({
@@ -447,7 +617,7 @@ export async function runMemoryPhase({
447
617
  updatedImportantKeyword: importantKeyword,
448
618
  },
449
619
  }),
450
- ]);
620
+ ]));
451
621
 
452
622
  // Override gate (STRICT: only active when overrides===true && confidence==="high" && authoritativeChunks.length > 0)
453
623
  const overrideIds = new Set(overrideResult.output?.authoritativeChunkIds ?? []);
@@ -0,0 +1,17 @@
1
+ import { needsPinRerun } from "./pin-rerun";
2
+
3
+ describe("needsPinRerun — whether memory's rewrite of the question can change the identifier pins", () => {
4
+ it("is false when the question is unchanged", () => {
5
+ expect(needsPinRerun("Fehler 0x02 am CBM-2", "Fehler 0x02 am CBM-2")).toBe(false);
6
+ });
7
+ it("is false when the rewrite only adds plain words or synonyms: pins depend on designations, not prose", () => {
8
+ expect(needsPinRerun("Wie quittiere ich die ECO Steuerung?", "Wie quittiere ich die ECO Steuerung (Hydraulik, Quittierung, bestätigen)?")).toBe(false);
9
+ });
10
+ it("is true when the rewrite introduces a new designation such as a model or error code", () => {
11
+ expect(needsPinRerun("Wie quittiere ich die ECO Steuerung?", "Wie quittiere ich die ECO Steuerung (FST-2XT)?")).toBe(true);
12
+ expect(needsPinRerun("Fehler am Bremsmodul", "Fehler 0x02 am Bremsmodul")).toBe(true);
13
+ });
14
+ it("ignores case and punctuation around designations", () => {
15
+ expect(needsPinRerun("cbm-2 Fehler", "CBM-2, Fehler, Bremsmodul")).toBe(false);
16
+ });
17
+ });
@@ -0,0 +1,29 @@
1
+ /**
2
+ * engine v2 runs identifier pins in parallel with the memory phase, on the original
3
+ * question. Memory augmentation then usually rewrites the question (synonyms from the
4
+ * glossary/memory), and re-running the pins for every rewrite put ~1 s back on the
5
+ * critical path in most turns (eval 2026-09-12: pinsMs median 1.0 s). Pins come from
6
+ * designations (model names, error codes, part numbers), so only a rewrite that adds a
7
+ * new designation-like token can change them.
8
+ */
9
+ export function needsPinRerun(originalQuestion: string, updatedQuestion: string): boolean {
10
+ if (originalQuestion === updatedQuestion) return false;
11
+ const before = designations(originalQuestion);
12
+ for (const token of designations(updatedQuestion)) {
13
+ if (!before.has(token)) return true;
14
+ }
15
+ return false;
16
+ }
17
+
18
+ /** Tokens that look like designations: they carry a digit or at least two capitals (FST-2XT, 0x02, CBM). */
19
+ function designations(text: string): Set<string> {
20
+ const out = new Set<string>();
21
+ for (const raw of text.split(/[\s,;:()\[\]"'?!]+/)) {
22
+ const token = raw.replace(/^[^\p{L}\p{N}]+|[^\p{L}\p{N}]+$/gu, "");
23
+ if (!token) continue;
24
+ const hasDigit = /\p{N}/u.test(token);
25
+ const capitals = (token.match(/\p{Lu}/gu) ?? []).length;
26
+ if (hasDigit || capitals >= 2) out.add(token.toLowerCase());
27
+ }
28
+ return out;
29
+ }
@@ -185,3 +185,37 @@ describe("runRoutingPhase", () => {
185
185
  expect(r.mainContexts).toEqual(["docs", "tickets"]);
186
186
  });
187
187
  });
188
+
189
+ describe("runRoutingPhase with mergedCall (engine v2)", () => {
190
+ const rules = [{ id: "t", label: "T", description: "d", main: ["docs"], fallback: ["tickets"] }];
191
+ const mergedOut = (over: Partial<any> = {}) => ({ output: {
192
+ docPage: { hasFilenameHint: false, filenameHints: [], hasPageHint: false, pageNumber: null },
193
+ explicitlyRequestedKnowledgeBases: [],
194
+ classification: { ruleId: "t", reason: "because" },
195
+ ...over,
196
+ } });
197
+
198
+ it("asks the model once and applies the classification", async () => {
199
+ (generateText as jest.Mock).mockResolvedValueOnce(mergedOut());
200
+ const r = await runRoutingPhase({ question: "how do I fix the door?", enabledContexts: enabled, documentContexts: [],
201
+ routingRules: rules, preselectedItems: new Map(), model: {}, mergedCall: true });
202
+ expect(generateText).toHaveBeenCalledTimes(1);
203
+ expect(r.mainContexts).toEqual(["docs"]);
204
+ expect(r.fallbackContexts).toEqual(["tickets"]);
205
+ });
206
+
207
+ it("lets an explicit knowledge-base request win over the classification, with no fallback", async () => {
208
+ (generateText as jest.Mock).mockResolvedValueOnce(mergedOut({ explicitlyRequestedKnowledgeBases: ["tickets"] }));
209
+ const r = await runRoutingPhase({ question: "search tickets for X", enabledContexts: enabled, documentContexts: [],
210
+ routingRules: rules, preselectedItems: new Map(), model: {}, mergedCall: true });
211
+ expect(r.mainContexts).toEqual(["tickets"]);
212
+ expect(r.fallbackContexts).toEqual([]);
213
+ });
214
+
215
+ it("still reports a requested page from the merged answer", async () => {
216
+ (generateText as jest.Mock).mockResolvedValueOnce(mergedOut({ docPage: { hasFilenameHint: false, filenameHints: [], hasPageHint: true, pageNumber: 38 } }));
217
+ const r = await runRoutingPhase({ question: "page 38", enabledContexts: enabled, documentContexts: [],
218
+ routingRules: rules, preselectedItems: new Map(), model: {}, mergedCall: true });
219
+ expect(r.userRequestedPage).toBe(38);
220
+ });
221
+ });
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { microCall } from "./micro-call";
3
+ import { withTiming } from "./timing";
3
4
  import { fuzzyPrefilter } from "./prefilter";
4
5
  import { normalizeFileName } from "./text-utils";
5
6
  import type { RoutingRule } from "./config";
@@ -39,6 +40,69 @@ Return hasFilenameHint/hasPageHint false (and omit the hint fields) when neither
39
40
  is present. When in doubt, return false.
40
41
  `;
41
42
 
43
+ type MergedRoutingOutput = {
44
+ docPage: { hasFilenameHint: boolean; filenameHints?: string[]; hasPageHint: boolean; pageNumber?: number | null };
45
+ explicitlyRequestedKnowledgeBases: string[];
46
+ classification?: { ruleId: string; reason: string } | null;
47
+ };
48
+
49
+ /**
50
+ * engine v2: the three routing judgements (document/page reference, explicit knowledge-base
51
+ * request, rule classification) in one structured call instead of two sequential hops.
52
+ * The instructions are the v1 prompts joined together so each judgement keeps its rules.
53
+ */
54
+ async function runMergedRoutingCall({
55
+ model,
56
+ question,
57
+ knownIdentifiers,
58
+ enabledContexts,
59
+ routingRules,
60
+ extraInstructions,
61
+ }: {
62
+ model: any;
63
+ question: string;
64
+ knownIdentifiers: string[];
65
+ enabledContexts: Array<{ id: string; name: string; description?: string }>;
66
+ routingRules: RoutingRule[];
67
+ extraInstructions?: string;
68
+ }): Promise<MergedRoutingOutput> {
69
+ const kbListing = enabledContexts
70
+ .map((c) => `- ${c.id}: ${c.name}${c.description ? " — " + c.description : ""}`)
71
+ .join("\n");
72
+ const rulesLines = routingRules.map((r) => `- ${r.id} (${r.label}): ${r.description}`).join("\n");
73
+ const system =
74
+ `You analyse the user's request and answer ${routingRules.length ? "three" : "two"} questions in one go.\n\n` +
75
+ `A. DOCUMENT / PAGE REFERENCE (docPage):\n${buildDocPagePrompt(knownIdentifiers)}\n\n` +
76
+ `B. EXPLICIT KNOWLEDGE BASE REQUEST (explicitlyRequestedKnowledgeBases): check if the user has EXPLICITLY asked you to search in one or multiple of the following knowledge bases:\n${kbListing}\n` +
77
+ `EXPLICIT means the user names a knowledge base or clearly commands searching a specific source (e.g. "search in the tickets", "look this up in the manuals KB"). ` +
78
+ `A question that merely CONCERNS a topic related to a knowledge base's name or contents (e.g. asking about software changes, norms, or a product) is NOT an explicit request. ` +
79
+ `When in doubt, return an empty array. If explicit, return the knowledge base ids.\n` +
80
+ (routingRules.length
81
+ ? `\nC. CLASSIFICATION (classification): classify the request into exactly one of these categories:\n${rulesLines}` +
82
+ (extraInstructions ? `\n<instructions>\n${extraInstructions}\n</instructions>` : "")
83
+ : "");
84
+ const ids = enabledContexts.map((c) => c.id) as [string, ...string[]];
85
+ const schema = z.object({
86
+ docPage: z.object({
87
+ hasFilenameHint: z.boolean(),
88
+ filenameHints: z.array(z.string()).optional(),
89
+ hasPageHint: z.boolean(),
90
+ pageNumber: z.number().int().nullable().optional(),
91
+ }),
92
+ explicitlyRequestedKnowledgeBases: z.array(z.enum(ids)),
93
+ ...(routingRules.length
94
+ ? {
95
+ classification: z.object({
96
+ ruleId: z.enum(routingRules.map((r) => r.id) as [string, ...string[]]),
97
+ reason: z.string(),
98
+ }),
99
+ }
100
+ : {}),
101
+ });
102
+ const { output } = await microCall({ model, system, messages: [{ role: "user", content: question }], schema });
103
+ return output as MergedRoutingOutput;
104
+ }
105
+
42
106
  export async function runRoutingPhase(opts: {
43
107
  question: string;
44
108
  enabledContexts: Array<{ id: string; name: string; description?: string }>;
@@ -49,6 +113,9 @@ export async function runRoutingPhase(opts: {
49
113
  /** Example product/model designations from the configured identifier vocabularies —
50
114
  * injected into the doc-reference prompt so they are never mistaken for filenames. */
51
115
  knownIdentifiers?: string[];
116
+ timings?: Record<string, number>;
117
+ /** engine v2: doc/page detection, explicit-KB detection and classification in ONE call. */
118
+ mergedCall?: boolean;
52
119
  model: any;
53
120
  }): Promise<RoutingPhaseResult> {
54
121
  const {
@@ -59,6 +126,7 @@ export async function runRoutingPhase(opts: {
59
126
  preselectedItems,
60
127
  extraInstructions,
61
128
  knownIdentifiers = [],
129
+ mergedCall = false,
62
130
  model,
63
131
  } = opts;
64
132
 
@@ -97,9 +165,30 @@ export async function runRoutingPhase(opts: {
97
165
  ` an empty array.` +
98
166
  `\nIf explicit, return the knowledge base ids. If not, return an empty array.`;
99
167
 
100
- // --- Phase 1: parallel doc/page detection + explicit-KB detection ---
168
+ // --- Phase 1: doc/page detection + explicit-KB detection (+ classification when merged) ---
101
169
 
102
- const [docPageRaw, explicitKBRaw] = await Promise.all([
170
+ // Classification is only needed when rules decide the routing (no explicit KB, no preselection);
171
+ // the merged call asks for it in the same round trip and phase 3 uses it when applicable.
172
+ const wantsClassification = routingRules.length > 0 && preselectedItems.size === 0;
173
+ const merged = mergedCall
174
+ ? await withTiming(opts.timings, "routing.mergedMs", () =>
175
+ runMergedRoutingCall({
176
+ model,
177
+ question,
178
+ knownIdentifiers,
179
+ enabledContexts,
180
+ routingRules: wantsClassification ? routingRules : [],
181
+ extraInstructions,
182
+ }).catch((err) => {
183
+ console.warn("[EXULU pipeline] merged routing call failed — falling back to the v1 hops.", err);
184
+ return null;
185
+ }),
186
+ )
187
+ : null;
188
+
189
+ const [docPageRaw, explicitKBRaw] = merged
190
+ ? [{ output: merged.docPage }, { output: { explicitlyRequestedKnowledgeBases: merged.explicitlyRequestedKnowledgeBases } }]
191
+ : await withTiming(opts.timings, "routing.detectMs", () => Promise.all([
103
192
  (async () => {
104
193
  try {
105
194
  return await microCall({
@@ -141,7 +230,7 @@ export async function runRoutingPhase(opts: {
141
230
  return { output: { explicitlyRequestedKnowledgeBases: [] as string[] } };
142
231
  }
143
232
  })(),
144
- ]);
233
+ ]));
145
234
 
146
235
  // --- Phase 2: resolve filename hints across all document contexts ---
147
236
 
@@ -250,7 +339,9 @@ export async function runRoutingPhase(opts: {
250
339
  }
251
340
 
252
341
  try {
253
- const { output: classified } = await microCall({
342
+ const { output: classified } = merged?.classification
343
+ ? { output: merged.classification }
344
+ : await withTiming(opts.timings, "routing.classifyMs", () => microCall({
254
345
  model,
255
346
  system: classifyPrompt,
256
347
  messages: [{ role: "user", content: question }],
@@ -258,7 +349,7 @@ export async function runRoutingPhase(opts: {
258
349
  ruleId: z.enum(ruleIds as [string, ...string[]]),
259
350
  reason: z.string(),
260
351
  }),
261
- });
352
+ }));
262
353
 
263
354
  const matchedRule = routingRules.find((r) => r.id === classified.ruleId);
264
355
  if (matchedRule) {
@@ -2,6 +2,7 @@ import { effectiveKbSettings, type KbProfile } from "./config";
2
2
  import { multiQuerySearch, singleSearch, type SearchCallConfig } from "./multi-query";
3
3
  import { generateHydePassage } from "./hyde";
4
4
  import { fuzzyPrefilter } from "./prefilter";
5
+ import { withTiming } from "./timing";
5
6
  import { applyRewrites } from "./text-utils";
6
7
  import type { Chunk } from "./types";
7
8
 
@@ -30,6 +31,8 @@ export async function searchContexts(opts: {
30
31
  rewrites: { find: string; replace: string }[];
31
32
  styleHint: string;
32
33
  maxQueries: number;
34
+ timings?: Record<string, number>; // sub-phase sink (see timing.ts)
35
+ timingPrefix?: string; // e.g. "search.main" / "search.fallback"
33
36
  skipPrefilter: boolean; // true for the speculative fallback pass
34
37
  }): Promise<{ chunks: Chunk[] }> {
35
38
  const {
@@ -54,7 +57,7 @@ export async function searchContexts(opts: {
54
57
  } = opts;
55
58
 
56
59
  const chunkArrays = await Promise.all(
57
- contextIds.map(async (ctxId): Promise<Chunk[]> => {
60
+ contextIds.map((ctxId): Promise<Chunk[]> => withTiming(opts.timings, `${opts.timingPrefix ?? "search"}.${ctxId}Ms`, async () => {
58
61
  try {
59
62
  // Rule 4: Missing context contributes []
60
63
  const ctx = contextsById.get(ctxId);
@@ -124,13 +127,13 @@ export async function searchContexts(opts: {
124
127
  // Generate HyDE passage when settings.hyde is true
125
128
  let hydePassage: string | null = null;
126
129
  if (hyde) {
127
- hydePassage = await generateHydePassage({
130
+ hydePassage = await withTiming(opts.timings, `${opts.timingPrefix ?? "search"}.${ctxId}.hydeMs`, () => generateHydePassage({
128
131
  originalQuestion: question,
129
132
  relevantKeywords: keywords,
130
133
  importantKeyword,
131
134
  styleHint,
132
135
  model,
133
- });
136
+ }));
134
137
  }
135
138
 
136
139
  // HyDE placed right after the question so the cap never drops it
@@ -153,14 +156,14 @@ export async function searchContexts(opts: {
153
156
  // pass too), unlike the cross-context identifier/memory/user pins suppressed above.
154
157
  // keywordPrefilter and no pins yet → fuzzyPrefilter; results become the pins
155
158
  if (keywordPrefilter && pinnedItemIds.length === 0) {
156
- const prefiltered = await fuzzyPrefilter({
159
+ const prefiltered = await withTiming(opts.timings, `${opts.timingPrefix ?? "search"}.${ctxId}.prefilterMs`, () => fuzzyPrefilter({
157
160
  cacheKey: `conversations:${ctxId}`,
158
161
  relevantKeywords: keywords,
159
162
  importantKeyword,
160
163
  context: ctx,
161
164
  fields: ["name", "id", "external_id", "description"],
162
165
  normalize: (i: any) => [i.name, i.description].filter(Boolean).join(": "),
163
- });
166
+ }));
164
167
  pinnedItemIds = prefiltered.map((r) => r.id);
165
168
  }
166
169
 
@@ -184,7 +187,7 @@ export async function searchContexts(opts: {
184
187
  console.warn(`[EXULU pipeline] searchContexts failed for context "${ctxId}":`, err);
185
188
  return [];
186
189
  }
187
- }),
190
+ })),
188
191
  );
189
192
 
190
193
  return { chunks: chunkArrays.flat() };
@@ -0,0 +1,24 @@
1
+ import { withTiming } from "./timing";
2
+
3
+ describe("withTiming — records how long a pipeline sub-phase took", () => {
4
+ it("stores the elapsed milliseconds under the key and returns the awaited value", async () => {
5
+ const timings: Record<string, number> = {};
6
+ let now = 1000;
7
+ const clock = () => now;
8
+ const value = await withTiming(timings, "memory.relevanceMs", async () => { now += 250; return "ok"; }, clock);
9
+ expect(value).toBe("ok");
10
+ expect(timings["memory.relevanceMs"]).toBe(250);
11
+ });
12
+
13
+ it("still records the time when the sub-phase throws, then rethrows", async () => {
14
+ const timings: Record<string, number> = {};
15
+ let now = 0;
16
+ const clock = () => now;
17
+ await expect(withTiming(timings, "routing.classifyMs", async () => { now += 40; throw new Error("boom"); }, clock)).rejects.toThrow("boom");
18
+ expect(timings["routing.classifyMs"]).toBe(40);
19
+ });
20
+
21
+ it("is a no-op passthrough when no timings sink is given", async () => {
22
+ await expect(withTiming(undefined, "x", async () => 7)).resolves.toBe(7);
23
+ });
24
+ });
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Sub-phase timing for the agentic retrieval pipeline.
3
+ *
4
+ * The pipeline reports phase durations ("memoryRoutingMs", "searchMs", …) in
5
+ * its result so slow retrievals can be diagnosed from the chat transcript
6
+ * alone. withTiming adds the next level down (an LLM hop inside the memory
7
+ * phase, one knowledge base inside the search fan-out) without threading
8
+ * clocks through every function: pass the shared sink and a key, get the
9
+ * awaited value back. Elapsed time is recorded even when the work throws.
10
+ */
11
+ export type TimingSink = Record<string, number>;
12
+
13
+ export async function withTiming<T>(
14
+ sink: TimingSink | undefined,
15
+ key: string,
16
+ work: () => Promise<T>,
17
+ now: () => number = Date.now,
18
+ ): Promise<T> {
19
+ if (!sink) return work();
20
+ const started = now();
21
+ try {
22
+ return await work();
23
+ } finally {
24
+ sink[key] = Math.max(0, now() - started);
25
+ }
26
+ }
@@ -51,6 +51,8 @@ export type RetrievalStep = {
51
51
  tokens: number;
52
52
  };
53
53
  export type AgenticRetrievalOutput = {
54
+ /** Phase durations in ms (memoryRoutingMs, pinsMs, searchMs, rerankMs, fallbackRerankMs, totalMs). */
55
+ timings?: Record<string, number>;
54
56
  steps: RetrievalStep[];
55
57
  reasoning: { text: string; tools: unknown[] }[];
56
58
  chunks: ChunkWithScore[];