@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
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import "dotenv/config";
|
|
2
|
+
import {
|
|
3
|
+
getPackageRoot,
|
|
4
|
+
getPythonSetupInstructions,
|
|
5
|
+
getPythonVenvPath,
|
|
6
|
+
isPythonEnvironmentSetup,
|
|
7
|
+
setupPythonEnvironment,
|
|
8
|
+
validatePythonEnvironment
|
|
9
|
+
} from "./chunk-QMN6MVHQ.js";
|
|
10
|
+
export {
|
|
11
|
+
getPackageRoot,
|
|
12
|
+
getPythonSetupInstructions,
|
|
13
|
+
getPythonVenvPath,
|
|
14
|
+
isPythonEnvironmentSetup,
|
|
15
|
+
setupPythonEnvironment,
|
|
16
|
+
validatePythonEnvironment
|
|
17
|
+
};
|
package/ee/LICENSE.md
CHANGED
|
@@ -5,7 +5,7 @@ Certain peripheral components of Exulu are subject to
|
|
|
5
5
|
commercial licensing and governed by this Enterprise License.
|
|
6
6
|
For clarity, this license does not cover the core of Exulu
|
|
7
7
|
as defined in the license located at "/LICENSE" (distinct from
|
|
8
|
-
this file at "ee/LICENSE"), which may be used and operated
|
|
8
|
+
this file at "ee/LICENSE.md"), which may be used and operated
|
|
9
9
|
without violating this license or its licensed materials.
|
|
10
10
|
|
|
11
11
|
Additionally, any functionality within the software that is
|
|
@@ -59,4 +59,4 @@ For any third-party components integrated into the Exulu Software, such
|
|
|
59
59
|
components remain governed by the original license provided by the respective
|
|
60
60
|
component owner.
|
|
61
61
|
|
|
62
|
-
END OF LICENSE
|
|
62
|
+
END OF LICENSE
|
|
@@ -4,7 +4,8 @@ describe("parsePipelineConfig", () => {
|
|
|
4
4
|
it("returns full defaults for an empty/missing config", () => {
|
|
5
5
|
const cfg = parsePipelineConfig(undefined);
|
|
6
6
|
expect(cfg.tuning).toEqual({ topK: 5, fallbackThreshold: 0.95, pinBoost: 0.15,
|
|
7
|
-
identifierBoost: 0.15, pageWindow: 1, maxQueriesPerContext: 5
|
|
7
|
+
identifierBoost: 0.15, pageWindow: 1, maxQueriesPerContext: 5,
|
|
8
|
+
engine: "v1", v2: { mergedMemoryCall: true, mergedRoutingCall: true, parallelPins: true } });
|
|
8
9
|
expect(cfg.memory).toEqual({ enabled: true, override: false, filePrioritization: false, queryAugmentation: true });
|
|
9
10
|
expect(cfg.routing.rules).toEqual([]);
|
|
10
11
|
expect(cfg.knowledgeBases).toEqual({});
|
|
@@ -94,3 +95,19 @@ describe("project_search option", () => {
|
|
|
94
95
|
expect(parsePipelineConfig({ project_search: true }).projectSearch).toBe(true);
|
|
95
96
|
});
|
|
96
97
|
});
|
|
98
|
+
|
|
99
|
+
describe("tuning.engine — the per-agent switch between the v1 flow and the parallel v2 flow", () => {
|
|
100
|
+
it("defaults to v1 so existing agents keep today's behaviour", () => {
|
|
101
|
+
expect(parsePipelineConfig({ tuning: '{"topK": 8}' }).tuning.engine).toBe("v1");
|
|
102
|
+
});
|
|
103
|
+
it("enables v2 with every sub-feature on, and lets a single feature be switched off for bisecting", () => {
|
|
104
|
+
const cfg = parsePipelineConfig({ tuning: '{"engine": "v2", "v2": {"parallelPins": false}}' });
|
|
105
|
+
expect(cfg.tuning.engine).toBe("v2");
|
|
106
|
+
expect(cfg.tuning.v2).toEqual({ mergedMemoryCall: true, mergedRoutingCall: true, parallelPins: false });
|
|
107
|
+
});
|
|
108
|
+
it("falls back to v1 on an unknown engine value", () => {
|
|
109
|
+
const warn = jest.spyOn(console, "warn").mockImplementation(() => {});
|
|
110
|
+
expect(parsePipelineConfig({ tuning: '{"engine": "v9"}' }).tuning.engine).toBe("v1");
|
|
111
|
+
warn.mockRestore();
|
|
112
|
+
});
|
|
113
|
+
});
|
|
@@ -57,6 +57,21 @@ const tuningSchema = z.object({
|
|
|
57
57
|
identifierBoost: z.number().min(0).max(1).default(0.15),
|
|
58
58
|
pageWindow: z.number().int().min(0).default(1),
|
|
59
59
|
maxQueriesPerContext: z.number().int().positive().default(5),
|
|
60
|
+
/**
|
|
61
|
+
* Orchestration engine. "v1" is the sequential flow every agent ran before 2026-09;
|
|
62
|
+
* "v2" merges the phase-1 LLM hops and runs identifier pins in parallel. Per agent,
|
|
63
|
+
* so a candidate agent can run v2 while the production agent stays on v1.
|
|
64
|
+
*/
|
|
65
|
+
engine: z.enum(["v1", "v2"]).default("v1"),
|
|
66
|
+
/** v2 sub-features; each can be switched off on its own to bisect a regression. */
|
|
67
|
+
v2: z
|
|
68
|
+
.object({
|
|
69
|
+
mergedMemoryCall: z.boolean().default(true),
|
|
70
|
+
mergedRoutingCall: z.boolean().default(true),
|
|
71
|
+
parallelPins: z.boolean().default(true),
|
|
72
|
+
})
|
|
73
|
+
// zod's .default() returns the value as-is (inner defaults are not applied), so spell it out.
|
|
74
|
+
.default({ mergedMemoryCall: true, mergedRoutingCall: true, parallelPins: true }),
|
|
60
75
|
});
|
|
61
76
|
|
|
62
77
|
export type KbProfile = z.infer<typeof kbProfileSchema>;
|
|
@@ -223,3 +223,76 @@ describe("projectScope execute-level wiring", () => {
|
|
|
223
223
|
expect(rerankCall.state.pinnedItemIds.has("item1")).toBe(true);
|
|
224
224
|
});
|
|
225
225
|
});
|
|
226
|
+
|
|
227
|
+
describe("phase timings", () => {
|
|
228
|
+
it("records how long the retrieval phases took as a step, so latency can be read from the stored tool result", async () => {
|
|
229
|
+
const run = makeTool({});
|
|
230
|
+
const out = await drain(run(inputs));
|
|
231
|
+
const last = JSON.parse(out[out.length - 1].result);
|
|
232
|
+
const timing = last.steps.find((s: any) => typeof s.text === "string" && s.text.startsWith("Timing:"));
|
|
233
|
+
expect(timing).toBeDefined();
|
|
234
|
+
expect(timing.text).toMatch(/memory\+routing \d+ms/);
|
|
235
|
+
expect(timing.text).toMatch(/search \d+ms/);
|
|
236
|
+
expect(timing.text).toMatch(/rerank \d+ms/);
|
|
237
|
+
expect(timing.text).toMatch(/total \d+ms/);
|
|
238
|
+
expect(last.timings).toEqual(expect.objectContaining({ totalMs: expect.any(Number), searchMs: expect.any(Number) }));
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
describe("engine v2 — identifier pins run alongside memory and routing", () => {
|
|
243
|
+
it("resolves pins on the original question in parallel, and only once when memory leaves the question unchanged", async () => {
|
|
244
|
+
const { resolveIdentifierPins } = jest.requireMock("./prefilter");
|
|
245
|
+
resolveIdentifierPins.mockClear();
|
|
246
|
+
const run = makeTool({ tuning: '{"engine": "v2"}' });
|
|
247
|
+
await drain(run(inputs));
|
|
248
|
+
expect(resolveIdentifierPins).toHaveBeenCalledTimes(1);
|
|
249
|
+
expect(resolveIdentifierPins.mock.calls[0][0].question).toBe("q");
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it("keeps the parallel pins when memory only added synonyms to the question", async () => {
|
|
253
|
+
const { resolveIdentifierPins } = jest.requireMock("./prefilter");
|
|
254
|
+
const { runMemoryPhase } = jest.requireMock("./memory");
|
|
255
|
+
resolveIdentifierPins.mockClear();
|
|
256
|
+
runMemoryPhase.mockResolvedValueOnce({
|
|
257
|
+
memoryChunksForAnswer: [], memoryOverride: { active: false, chunks: [], reason: "" },
|
|
258
|
+
memoryPinnedItemIdsByContext: new Map(), updatedQuestion: "q plus synonym", updatedKeywords: ["k"],
|
|
259
|
+
updatedImportantKeyword: "k", steps: [] });
|
|
260
|
+
const run = makeTool({ tuning: '{"engine": "v2"}' });
|
|
261
|
+
await drain(run(inputs));
|
|
262
|
+
expect(resolveIdentifierPins).toHaveBeenCalledTimes(1);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("re-resolves pins on the memory-augmented question when it introduces a new designation (v1 fidelity)", async () => {
|
|
266
|
+
const { resolveIdentifierPins } = jest.requireMock("./prefilter");
|
|
267
|
+
const { runMemoryPhase } = jest.requireMock("./memory");
|
|
268
|
+
resolveIdentifierPins.mockClear();
|
|
269
|
+
runMemoryPhase.mockResolvedValueOnce({
|
|
270
|
+
memoryChunksForAnswer: [], memoryOverride: { active: false, chunks: [], reason: "" },
|
|
271
|
+
memoryPinnedItemIdsByContext: new Map(), updatedQuestion: "q plus FST-2XT", updatedKeywords: ["k"],
|
|
272
|
+
updatedImportantKeyword: "k", steps: [] });
|
|
273
|
+
const run = makeTool({ tuning: '{"engine": "v2"}' });
|
|
274
|
+
await drain(run(inputs));
|
|
275
|
+
expect(resolveIdentifierPins).toHaveBeenCalledTimes(2);
|
|
276
|
+
expect(resolveIdentifierPins.mock.calls[1][0].question).toBe("q plus FST-2XT");
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it("passes the merged-call flags to the memory and routing phases", async () => {
|
|
280
|
+
const { runMemoryPhase } = jest.requireMock("./memory");
|
|
281
|
+
const { runRoutingPhase } = jest.requireMock("./routing");
|
|
282
|
+
runMemoryPhase.mockClear(); runRoutingPhase.mockClear();
|
|
283
|
+
const run = makeTool({ tuning: '{"engine": "v2", "v2": {"mergedRoutingCall": false}}' });
|
|
284
|
+
await drain(run(inputs));
|
|
285
|
+
expect(runMemoryPhase.mock.calls[0][0].mergedCall).toBe(true);
|
|
286
|
+
expect(runRoutingPhase.mock.calls[0][0].mergedCall).toBe(false);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it("keeps v1 sequential pins by default", async () => {
|
|
290
|
+
const { resolveIdentifierPins } = jest.requireMock("./prefilter");
|
|
291
|
+
const { runMemoryPhase } = jest.requireMock("./memory");
|
|
292
|
+
resolveIdentifierPins.mockClear(); runMemoryPhase.mockClear();
|
|
293
|
+
const run = makeTool({});
|
|
294
|
+
await drain(run(inputs));
|
|
295
|
+
expect(resolveIdentifierPins).toHaveBeenCalledTimes(1);
|
|
296
|
+
expect(runMemoryPhase.mock.calls[0][0].mergedCall).toBeFalsy();
|
|
297
|
+
});
|
|
298
|
+
});
|
|
@@ -15,6 +15,8 @@ import { resolveIdentifierPins } from "./prefilter";
|
|
|
15
15
|
import { searchContexts } from "./search";
|
|
16
16
|
import { rerankResults } from "./rerank";
|
|
17
17
|
import type { AgenticRetrievalOutput, RerankState, ChunkWithScore } from "./types";
|
|
18
|
+
import { withTiming } from "./timing";
|
|
19
|
+
import { needsPinRerun } from "./pin-rerun";
|
|
18
20
|
import type { VectorSearchChunkResult } from "@SRC/graphql/resolvers/vector-search";
|
|
19
21
|
import { parsePreselectedItems } from "./global-ids";
|
|
20
22
|
export { parsePreselectedItems } from "./global-ids";
|
|
@@ -238,6 +240,16 @@ export function createAgenticRetrievalTool(opts: {
|
|
|
238
240
|
usage: [],
|
|
239
241
|
totalTokens: 0,
|
|
240
242
|
};
|
|
243
|
+
// Phase timings (ms) — surfaced as a step and as `timings` on the result so latency
|
|
244
|
+
// can be analysed from stored tool results on deployments without tracing.
|
|
245
|
+
const t0 = Date.now();
|
|
246
|
+
const timings: Record<string, number> = {};
|
|
247
|
+
let tPhase = t0;
|
|
248
|
+
const lap = (name: string) => {
|
|
249
|
+
const now = Date.now();
|
|
250
|
+
timings[name] = (timings[name] ?? 0) + (now - tPhase);
|
|
251
|
+
tPhase = now;
|
|
252
|
+
};
|
|
241
253
|
|
|
242
254
|
try {
|
|
243
255
|
// ── Enabled contexts (knowledge_bases.enabled filter + restore-all) ───
|
|
@@ -340,8 +352,21 @@ export function createAgenticRetrievalTool(opts: {
|
|
|
340
352
|
.filter(Boolean)
|
|
341
353
|
.join("\n");
|
|
342
354
|
|
|
343
|
-
const
|
|
344
|
-
|
|
355
|
+
const engineV2 = cfg.tuning.engine === "v2";
|
|
356
|
+
const v2 = cfg.tuning.v2;
|
|
357
|
+
const pinsFor = (question: string) =>
|
|
358
|
+
resolveIdentifierPins({
|
|
359
|
+
question,
|
|
360
|
+
identifierSets: cfg.vocabulary.identifiers,
|
|
361
|
+
contextsById,
|
|
362
|
+
kbKindById,
|
|
363
|
+
model: utilityModel,
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
const [memResult, routResult, parallelPins] = await Promise.all([
|
|
367
|
+
withTiming(timings, "memoryMs", () => runMemoryPhase({
|
|
368
|
+
timings,
|
|
369
|
+
mergedCall: engineV2 && v2.mergedMemoryCall,
|
|
345
370
|
memoryChunks: memoryItems ?? [],
|
|
346
371
|
memoryContext,
|
|
347
372
|
question: userQuery,
|
|
@@ -353,8 +378,10 @@ export function createAgenticRetrievalTool(opts: {
|
|
|
353
378
|
memoryConfig: cfg.memory,
|
|
354
379
|
glossary: cfg.vocabulary.glossary,
|
|
355
380
|
documentContexts,
|
|
356
|
-
}),
|
|
357
|
-
runRoutingPhase({
|
|
381
|
+
})),
|
|
382
|
+
withTiming(timings, "routingMs", () => runRoutingPhase({
|
|
383
|
+
timings,
|
|
384
|
+
mergedCall: engineV2 && v2.mergedRoutingCall,
|
|
358
385
|
question: userQuery,
|
|
359
386
|
enabledContexts,
|
|
360
387
|
documentContexts,
|
|
@@ -365,9 +392,15 @@ export function createAgenticRetrievalTool(opts: {
|
|
|
365
392
|
// detector must never treat these as filename hints.
|
|
366
393
|
knownIdentifiers: cfg.vocabulary.identifiers.flatMap((i) => i.examples),
|
|
367
394
|
model: utilityModel,
|
|
368
|
-
}),
|
|
395
|
+
})),
|
|
396
|
+
// engine v2: identifier pins depend only on the question, so they run alongside
|
|
397
|
+
// memory and routing instead of after them (re-run below if memory rewrote the question).
|
|
398
|
+
engineV2 && v2.parallelPins
|
|
399
|
+
? withTiming(timings, "pinsParallelMs", () => pinsFor(userQuery))
|
|
400
|
+
: Promise.resolve(null),
|
|
369
401
|
]);
|
|
370
402
|
|
|
403
|
+
lap("memoryRoutingMs");
|
|
371
404
|
// Merge steps from both phases
|
|
372
405
|
for (const step of [...memResult.steps, ...routResult.steps]) {
|
|
373
406
|
result.steps.push({
|
|
@@ -431,14 +464,11 @@ export function createAgenticRetrievalTool(opts: {
|
|
|
431
464
|
|
|
432
465
|
// ── Identifier pins (upfront, before Phase 2) ────────────────────────
|
|
433
466
|
const { pinsByContext: identifierPinsByContext, exactPinsByContext, steps: pinSteps } =
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
contextsById,
|
|
438
|
-
kbKindById,
|
|
439
|
-
model: utilityModel,
|
|
440
|
-
});
|
|
467
|
+
parallelPins && !needsPinRerun(userQuery, updatedQuestion)
|
|
468
|
+
? parallelPins
|
|
469
|
+
: await pinsFor(updatedQuestion);
|
|
441
470
|
|
|
471
|
+
lap("pinsMs");
|
|
442
472
|
for (const step of pinSteps) {
|
|
443
473
|
result.steps.push({
|
|
444
474
|
stepNumber: 1,
|
|
@@ -470,6 +500,8 @@ export function createAgenticRetrievalTool(opts: {
|
|
|
470
500
|
rewrites: cfg.vocabulary.rewrites,
|
|
471
501
|
styleHint: cfg.vocabulary.styleHint,
|
|
472
502
|
maxQueries: cfg.tuning.maxQueriesPerContext,
|
|
503
|
+
timings,
|
|
504
|
+
timingPrefix: "search.main",
|
|
473
505
|
skipPrefilter: false,
|
|
474
506
|
}),
|
|
475
507
|
fallbackContextsToSearch.length > 0 && !hasExplicitDocAndPage
|
|
@@ -491,11 +523,14 @@ export function createAgenticRetrievalTool(opts: {
|
|
|
491
523
|
rewrites: cfg.vocabulary.rewrites,
|
|
492
524
|
styleHint: cfg.vocabulary.styleHint,
|
|
493
525
|
maxQueries: cfg.tuning.maxQueriesPerContext,
|
|
526
|
+
timings,
|
|
527
|
+
timingPrefix: "search.fallback",
|
|
494
528
|
skipPrefilter: true,
|
|
495
529
|
})
|
|
496
530
|
: Promise.resolve({ chunks: [] }),
|
|
497
531
|
]);
|
|
498
532
|
|
|
533
|
+
lap("searchMs");
|
|
499
534
|
// ── Build rerank state ────────────────────────────────────────────────
|
|
500
535
|
// pinnedItemIds = memory ∪ exact identifier pins ∪ user pins ∪ project pins
|
|
501
536
|
const pinnedItemIds = new Set<string>([
|
|
@@ -567,6 +602,7 @@ export function createAgenticRetrievalTool(opts: {
|
|
|
567
602
|
tokens: 0,
|
|
568
603
|
});
|
|
569
604
|
|
|
605
|
+
lap("rerankMs");
|
|
570
606
|
// Accumulate main results (dedup by chunk_id, memory chunks already first)
|
|
571
607
|
addChunks(result, mainRerank.limited_results);
|
|
572
608
|
yield { result: serializeOutput(result) };
|
|
@@ -636,6 +672,7 @@ export function createAgenticRetrievalTool(opts: {
|
|
|
636
672
|
});
|
|
637
673
|
result.reasoning.push({ text: "Fallback results reranked", tools: [] });
|
|
638
674
|
addChunks(result, fallbackRerank.limited_results);
|
|
675
|
+
lap("fallbackRerankMs");
|
|
639
676
|
yield { result: serializeOutput(result) };
|
|
640
677
|
}
|
|
641
678
|
|
|
@@ -667,10 +704,27 @@ export function createAgenticRetrievalTool(opts: {
|
|
|
667
704
|
yield { result: serializeOutput(result) };
|
|
668
705
|
}
|
|
669
706
|
|
|
707
|
+
timings.totalMs = Date.now() - t0;
|
|
708
|
+
result.timings = timings;
|
|
709
|
+
result.steps.push({
|
|
710
|
+
stepNumber: 1,
|
|
711
|
+
text:
|
|
712
|
+
`Timing: memory+routing ${timings.memoryRoutingMs ?? 0}ms (memory ${timings.memoryMs ?? 0}ms, routing ${timings.routingMs ?? 0}ms), pins ${timings.pinsMs ?? 0}ms, ` +
|
|
713
|
+
`search ${timings.searchMs ?? 0}ms, rerank ${timings.rerankMs ?? 0}ms` +
|
|
714
|
+
(timings.fallbackRerankMs !== undefined ? `, fallback rerank ${timings.fallbackRerankMs}ms` : "") +
|
|
715
|
+
`, total ${timings.totalMs}ms`,
|
|
716
|
+
toolCalls: [],
|
|
717
|
+
chunks: [],
|
|
718
|
+
tokens: 0,
|
|
719
|
+
});
|
|
720
|
+
|
|
670
721
|
if (cfg.logging) {
|
|
671
|
-
console.log("[EXULU pipeline] final result:", JSON.stringify({ steps: result.steps.length, chunks: result.chunks.length }));
|
|
722
|
+
console.log("[EXULU pipeline] final result:", JSON.stringify({ steps: result.steps.length, chunks: result.chunks.length, timings }));
|
|
672
723
|
}
|
|
673
724
|
|
|
725
|
+
// The generator's return value is not consumed by the tool wrapper (for-await only sees
|
|
726
|
+
// yields), so the final payload — including the timing step — must be yielded.
|
|
727
|
+
yield { result: serializeOutput(result) };
|
|
674
728
|
return { result: serializeOutput(result) };
|
|
675
729
|
} catch (err) {
|
|
676
730
|
console.warn("[EXULU pipeline] retrieval pipeline failed:", err);
|
|
@@ -105,3 +105,62 @@ describe("runMemoryPhase", () => {
|
|
|
105
105
|
expect(r.updatedQuestion).toBe(baseOpts.question);
|
|
106
106
|
});
|
|
107
107
|
});
|
|
108
|
+
|
|
109
|
+
describe("recallMemoryByKeywords — fetching the chunks of keyword-matched memory items", () => {
|
|
110
|
+
const { singleSearch } = jest.requireMock("./multi-query") as { singleSearch: jest.Mock };
|
|
111
|
+
const { recallMemoryByKeywords, clearMemoryItemCache } = jest.requireActual("./memory") as typeof import("./memory");
|
|
112
|
+
|
|
113
|
+
it("uses the full-text method, not the hybrid one: the items are already chosen by keyword, so an embedding call would only add latency", async () => {
|
|
114
|
+
clearMemoryItemCache();
|
|
115
|
+
singleSearch.mockClear();
|
|
116
|
+
const memoryContext = {
|
|
117
|
+
id: "memory-ctx",
|
|
118
|
+
getItems: async () => [{ id: "item-1", name: "CBM2 Version", description: "Hinweis zur CBM2 Firmware", information: "" }],
|
|
119
|
+
};
|
|
120
|
+
await recallMemoryByKeywords({ keywords: ["CBM2"], importantKeyword: "CBM2", user: {}, role: {}, memoryContext });
|
|
121
|
+
expect(singleSearch).toHaveBeenCalledTimes(1);
|
|
122
|
+
expect(singleSearch.mock.calls[0][0].config.method).toBe("tsvector");
|
|
123
|
+
expect(singleSearch.mock.calls[0][0].pinnedItemIds).toEqual(["item-1"]);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe("runMemoryPhase with mergedCall (engine v2)", () => {
|
|
128
|
+
const merged = (over: Partial<any> = {}) => ({ output: {
|
|
129
|
+
relevantChunkIds: ["1"],
|
|
130
|
+
override: { overrides: true, confidence: "high", authoritativeChunkIds: ["1"], reason: "direct answer" },
|
|
131
|
+
filePrioritization: { shouldPrioritizeFiles: false, fileNameHints: [] },
|
|
132
|
+
augmentation: { updatedUserQuestion: baseOpts.question + " (Türkontakt)", updatedRelevantKeywords: ["türkontakt"], updatedImportantKeyword: "FST-2XT" },
|
|
133
|
+
...over,
|
|
134
|
+
} });
|
|
135
|
+
|
|
136
|
+
it("asks the model once and produces the same result shape as the four v1 hops", async () => {
|
|
137
|
+
(generateText as jest.Mock).mockResolvedValueOnce(merged());
|
|
138
|
+
const r = await runMemoryPhase({ ...baseOpts, mergedCall: true, memoryChunks: [memChunk("1", "hint"), memChunk("2", "other")],
|
|
139
|
+
memoryContext: undefined, memoryConfig: allOn });
|
|
140
|
+
expect(generateText).toHaveBeenCalledTimes(1);
|
|
141
|
+
expect(r.memoryChunksForAnswer.map((c) => c.chunk_id)).toEqual(["1"]);
|
|
142
|
+
expect(r.memoryOverride).toMatchObject({ active: true, reason: "direct answer" });
|
|
143
|
+
expect(r.memoryOverride.chunks.map((c) => c.chunk_id)).toEqual(["1"]);
|
|
144
|
+
expect(r.updatedQuestion).toBe(baseOpts.question + " (Türkontakt)");
|
|
145
|
+
expect(r.updatedKeywords).toEqual(["door", "türkontakt"]);
|
|
146
|
+
expect(r.updatedImportantKeyword).toBe("FST-2XT"); // original always preserved
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("ignores override/file/augmentation parts of the answer when those features are off", async () => {
|
|
150
|
+
(generateText as jest.Mock).mockResolvedValueOnce(merged());
|
|
151
|
+
const r = await runMemoryPhase({ ...baseOpts, mergedCall: true, memoryChunks: [memChunk("1", "hint")], memoryContext: undefined,
|
|
152
|
+
memoryConfig: { enabled: true, override: false, filePrioritization: false, queryAugmentation: false } });
|
|
153
|
+
expect(generateText).toHaveBeenCalledTimes(1);
|
|
154
|
+
expect(r.memoryOverride.active).toBe(false);
|
|
155
|
+
expect(r.updatedQuestion).toBe(baseOpts.question);
|
|
156
|
+
expect(r.updatedKeywords).toEqual(["door"]);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("treats no relevant chunks as a neutral result even if the model filled the other parts", async () => {
|
|
160
|
+
(generateText as jest.Mock).mockResolvedValueOnce(merged({ relevantChunkIds: [] }));
|
|
161
|
+
const r = await runMemoryPhase({ ...baseOpts, mergedCall: true, memoryChunks: [memChunk("1", "hint")], memoryContext: undefined, memoryConfig: allOn });
|
|
162
|
+
expect(r.memoryChunksForAnswer).toEqual([]);
|
|
163
|
+
expect(r.memoryOverride.active).toBe(false);
|
|
164
|
+
expect(r.updatedQuestion).toBe(baseOpts.question);
|
|
165
|
+
});
|
|
166
|
+
});
|