@exulu/backend 3.7.3 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{chunk-T6JVFT7L.js → chunk-QMN6MVHQ.js} +6 -1
- package/dist/{chunk-BNTL6LYY.js → chunk-RBEWHG7I.js} +404 -59
- package/dist/cli/start-whisper.js +1 -1
- package/dist/{convert-exulu-tools-to-ai-sdk-tools-UQSLJDXE.js → convert-exulu-tools-to-ai-sdk-tools-6RU4IZMI.js} +1 -1
- package/dist/index.cjs +957 -474
- package/dist/index.d.cts +3 -6
- package/dist/index.d.ts +3 -6
- package/dist/index.js +258 -182
- package/dist/python-setup-DRJ3QX5F.js +17 -0
- package/ee/LICENSE.md +2 -2
- package/ee/agentic-retrieval/pipeline/config.test.ts +18 -1
- package/ee/agentic-retrieval/pipeline/config.ts +15 -0
- package/ee/agentic-retrieval/pipeline/index.test.ts +73 -0
- package/ee/agentic-retrieval/pipeline/index.ts +67 -13
- package/ee/agentic-retrieval/pipeline/memory.test.ts +59 -0
- package/ee/agentic-retrieval/pipeline/memory.ts +181 -11
- package/ee/agentic-retrieval/pipeline/pin-rerun.test.ts +17 -0
- package/ee/agentic-retrieval/pipeline/pin-rerun.ts +29 -0
- package/ee/agentic-retrieval/pipeline/routing.test.ts +34 -0
- package/ee/agentic-retrieval/pipeline/routing.ts +96 -5
- package/ee/agentic-retrieval/pipeline/search.ts +9 -6
- package/ee/agentic-retrieval/pipeline/timing.test.ts +24 -0
- package/ee/agentic-retrieval/pipeline/timing.ts +26 -0
- package/ee/agentic-retrieval/pipeline/types.ts +2 -0
- package/ee/invoke-skills/artifact-filter.test.ts +49 -0
- package/ee/invoke-skills/artifact-filter.ts +38 -0
- package/ee/invoke-skills/create-sandbox.ts +56 -4
- package/ee/python/documents/processing/README.md +2 -3
- package/ee/python/documents/processing/doc_processor.ts +21 -61
- package/ee/python/documents/processing/split_pdf.py +25 -30
- package/ee/python/documents/processing/tests/__init__.py +0 -0
- package/ee/python/documents/processing/tests/test_split_pdf.py +230 -0
- package/ee/python/requirements.txt +17 -2
- package/ee/python/setup.sh +40 -1
- package/ee/python/transcription/pipeline.py +109 -15
- package/ee/python/transcription/tests/test_align_model_licensing.py +184 -0
- package/ee/workers.ts +2 -7
- package/license.md +2 -2
- package/package.json +3 -4
- package/scripts/postinstall.cjs +52 -1
- package/ee/python/documents/processing/document_to_markdown.py +0 -413
|
@@ -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
|
-
|
|
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: "
|
|
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
|
-
|
|
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] =
|
|
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:
|
|
168
|
+
// --- Phase 1: doc/page detection + explicit-KB detection (+ classification when merged) ---
|
|
101
169
|
|
|
102
|
-
|
|
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 } =
|
|
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(
|
|
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[];
|