@exulu/backend 1.70.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/dist/{catalog-TBSPSN2N.js → catalog-UGTDNMDM.js} +2 -1
  2. package/dist/{chunk-YCE44CMU.js → chunk-7CCMW3IW.js} +2 -0
  3. package/dist/chunk-IJ4HNHOT.js +6416 -0
  4. package/dist/{chunk-IDHS2BZO.js → chunk-T6JVFT7L.js} +2 -0
  5. package/dist/cli/start-whisper.cjs +1 -0
  6. package/dist/cli/start-whisper.js +2 -1
  7. package/dist/convert-exulu-tools-to-ai-sdk-tools-2PEDFZ2X.js +9 -0
  8. package/dist/index.cjs +9514 -9260
  9. package/dist/index.d.cts +46 -29
  10. package/dist/index.d.ts +46 -29
  11. package/dist/index.js +4947 -548
  12. package/ee/agentic-retrieval/pipeline/config.test.ts +81 -0
  13. package/ee/agentic-retrieval/pipeline/config.ts +189 -0
  14. package/ee/agentic-retrieval/pipeline/hyde.test.ts +55 -0
  15. package/ee/agentic-retrieval/pipeline/hyde.ts +133 -0
  16. package/ee/agentic-retrieval/pipeline/index.test.ts +140 -0
  17. package/ee/agentic-retrieval/pipeline/index.ts +638 -0
  18. package/ee/agentic-retrieval/pipeline/memory.test.ts +101 -0
  19. package/ee/agentic-retrieval/pipeline/memory.ts +566 -0
  20. package/ee/agentic-retrieval/pipeline/multi-query.test.ts +51 -0
  21. package/ee/agentic-retrieval/pipeline/multi-query.ts +158 -0
  22. package/ee/agentic-retrieval/pipeline/prefilter.test.ts +93 -0
  23. package/ee/agentic-retrieval/pipeline/prefilter.ts +389 -0
  24. package/ee/agentic-retrieval/pipeline/rerank.test.ts +128 -0
  25. package/ee/agentic-retrieval/pipeline/rerank.ts +178 -0
  26. package/ee/agentic-retrieval/pipeline/routing.test.ts +144 -0
  27. package/ee/agentic-retrieval/pipeline/routing.ts +343 -0
  28. package/ee/agentic-retrieval/pipeline/search.test.ts +149 -0
  29. package/ee/agentic-retrieval/pipeline/search.ts +180 -0
  30. package/ee/agentic-retrieval/pipeline/text-utils.test.ts +43 -0
  31. package/ee/agentic-retrieval/pipeline/text-utils.ts +85 -0
  32. package/ee/agentic-retrieval/pipeline/types.ts +59 -0
  33. package/ee/python/documents/processing/doc_processor.ts +1 -1
  34. package/ee/python/documents/processing/split_pdf.py +78 -24
  35. package/package.json +2 -1
  36. package/dist/chunk-WCP3WZM3.js +0 -10391
  37. package/dist/convert-exulu-tools-to-ai-sdk-tools-GQ3UIYP7.js +0 -6
  38. package/ee/agentic-retrieval/v3/agent-loop.ts +0 -288
  39. package/ee/agentic-retrieval/v3/classifier.ts +0 -92
  40. package/ee/agentic-retrieval/v3/context-sampler.ts +0 -79
  41. package/ee/agentic-retrieval/v3/dynamic-tools.ts +0 -115
  42. package/ee/agentic-retrieval/v3/index.ts +0 -471
  43. package/ee/agentic-retrieval/v3/session-tools-registry.ts +0 -20
  44. package/ee/agentic-retrieval/v3/strategies.ts +0 -171
  45. package/ee/agentic-retrieval/v3/tools.ts +0 -558
  46. package/ee/agentic-retrieval/v3/trajectory.ts +0 -309
  47. package/ee/agentic-retrieval/v3/types.ts +0 -59
@@ -0,0 +1,343 @@
1
+ import { generateText, Output } from "ai";
2
+ import { z } from "zod";
3
+ import { withRetry } from "@SRC/utils/with-retry";
4
+ import { fuzzyPrefilter } from "./prefilter";
5
+ import { normalizeFileName } from "./text-utils";
6
+ import type { RoutingRule } from "./config";
7
+ import type { RoutingPhaseResult, PhaseStep } from "./types";
8
+
9
+ /** A genuine document reference resolves to a handful of files. When the fuzzy match
10
+ * returns more than this many, the "hint" was really a product/topic token (e.g. a model
11
+ * name matching 30 brochures/certificates) — pinning all of them replaces the product
12
+ * prefilter with paperwork, force-includes every pin past topK in the rerank, and blows
13
+ * the tool output up by hundreds of thousands of tokens (observed in the newlkiag
14
+ * migration smoke test: 4 retries × ~35 pinned chunk-groups ≈ 1.8M tokens). */
15
+ const MAX_USER_PIN_MATCHES = 8;
16
+
17
+ const buildDocPagePrompt = (knownIdentifiers: string[]) => `
18
+ You are checking whether the user's question explicitly references a specific
19
+ document (by filename or distinctive filename fragment) and/or a specific page number.
20
+
21
+ Filename hints: things like "manual_v4.2.de.pdf", "HB_PAM-E4",
22
+ "installation guide model X3". Return them as bare names
23
+ without folder paths.
24
+
25
+ STRICT RULE: only report a filename hint when the reference carries a document marker —
26
+ a file extension (.pdf, .docx, …), a filename-like pattern (underscores/version tags like
27
+ "hb_X_2023"), or wording that names a document ("im Dokument X", "im Handbuch Y",
28
+ "in the X manual/guide"). A bare product, model, or component designation is NOT a
29
+ filename hint, even when files about it exist — product terms are handled by a
30
+ different step.${
31
+ knownIdentifiers.length > 0
32
+ ? `\nKnown product/model designations that must NOT be treated as filenames on their own: ${knownIdentifiers.join(", ")}.`
33
+ : ""
34
+ }
35
+
36
+ Page hints: explicit page references like "page 38", "p. 38", "Seite 38".
37
+ Only set pageNumber when the user names a specific page.
38
+
39
+ Return hasFilenameHint/hasPageHint false (and omit the hint fields) when neither
40
+ is present. When in doubt, return false.
41
+ `;
42
+
43
+ export async function runRoutingPhase(opts: {
44
+ question: string;
45
+ enabledContexts: Array<{ id: string; name: string; description?: string }>;
46
+ documentContexts: any[];
47
+ routingRules: RoutingRule[];
48
+ preselectedItems: Map<string, string[] | null>;
49
+ extraInstructions?: string;
50
+ /** Example product/model designations from the configured identifier vocabularies —
51
+ * injected into the doc-reference prompt so they are never mistaken for filenames. */
52
+ knownIdentifiers?: string[];
53
+ model: any;
54
+ }): Promise<RoutingPhaseResult> {
55
+ const {
56
+ question,
57
+ enabledContexts,
58
+ documentContexts,
59
+ routingRules,
60
+ preselectedItems,
61
+ extraInstructions,
62
+ knownIdentifiers = [],
63
+ model,
64
+ } = opts;
65
+
66
+ try {
67
+ const steps: PhaseStep[] = [];
68
+ const enabledIds = new Set(enabledContexts.map((c) => c.id));
69
+
70
+ // Early exit: no contexts → skip all LLM calls
71
+ if (enabledContexts.length === 0) {
72
+ return {
73
+ mainContexts: [],
74
+ fallbackContexts: [],
75
+ userPinnedItemIdsByContext: new Map(),
76
+ userRequestedPage: null,
77
+ hasExplicitDocAndPage: false,
78
+ steps,
79
+ };
80
+ }
81
+
82
+ // Build dynamic explicit-KB prompt from enabled contexts.
83
+ // The listing includes names/descriptions so "search the tickets" can resolve to the
84
+ // right id — but that makes topical false positives easy (a question ABOUT software
85
+ // matching the software-docs KB), and an explicit match zeroes the fallback list. The
86
+ // strictness paragraph below exists because exactly that misfire suppressed fallback
87
+ // retrieval during the newlkiag migration gate.
88
+ const kbSystemPrompt =
89
+ `You are a helpful assistant that checks if the user has EXPLICITLY asked you to search in one or multiple of the following knowledge bases:\n` +
90
+ enabledContexts
91
+ .map((c) => `- ${c.id}: ${c.name}${c.description ? " — " + c.description : ""}`)
92
+ .join("\n") +
93
+ `\nEXPLICIT means the user names a knowledge base or clearly commands searching a` +
94
+ ` specific source (e.g. "search in the tickets", "look this up in the manuals KB").` +
95
+ ` A question that merely CONCERNS a topic related to a knowledge base's name or` +
96
+ ` contents (e.g. asking about software changes, norms, or a product) is NOT an` +
97
+ ` explicit request — classification routing handles those. When in doubt, return` +
98
+ ` an empty array.` +
99
+ `\nIf explicit, return the knowledge base ids. If not, return an empty array.`;
100
+
101
+ // --- Phase 1: parallel doc/page detection + explicit-KB detection ---
102
+
103
+ const [docPageRaw, explicitKBRaw] = await Promise.all([
104
+ (async () => {
105
+ try {
106
+ return await withRetry(
107
+ () =>
108
+ generateText({
109
+ model,
110
+ temperature: 0,
111
+ system: buildDocPagePrompt(knownIdentifiers),
112
+ messages: [{ role: "user", content: question }],
113
+ output: Output.object({
114
+ schema: z.object({
115
+ hasFilenameHint: z.boolean(),
116
+ filenameHints: z.array(z.string()).optional(),
117
+ hasPageHint: z.boolean(),
118
+ pageNumber: z.number().int().nullable().optional(),
119
+ }),
120
+ }),
121
+ maxOutputTokens: 300,
122
+ }),
123
+ 3,
124
+ );
125
+ } catch (err) {
126
+ steps.push({ text: "Doc/page detection failed — skipping filename and page hints." });
127
+ return {
128
+ output: {
129
+ hasFilenameHint: false,
130
+ filenameHints: [] as string[],
131
+ hasPageHint: false,
132
+ pageNumber: null as number | null,
133
+ },
134
+ };
135
+ }
136
+ })(),
137
+ (async () => {
138
+ try {
139
+ return await withRetry(
140
+ () =>
141
+ generateText({
142
+ model,
143
+ temperature: 0,
144
+ system: kbSystemPrompt,
145
+ output: Output.object({
146
+ schema: z.object({
147
+ explicitlyRequestedKnowledgeBases: z.array(
148
+ z.enum(enabledContexts.map((c) => c.id) as [string, ...string[]]),
149
+ ),
150
+ }),
151
+ }),
152
+ messages: [{ role: "user", content: question }],
153
+ maxOutputTokens: 200,
154
+ }),
155
+ 3,
156
+ );
157
+ } catch (err) {
158
+ return { output: { explicitlyRequestedKnowledgeBases: [] as string[] } };
159
+ }
160
+ })(),
161
+ ]);
162
+
163
+ // --- Phase 2: resolve filename hints across all document contexts ---
164
+
165
+ const userPinnedItemIdsByContext = new Map<string, Set<string>>();
166
+ let userRequestedPage: number | null = null;
167
+
168
+ if (docPageRaw.output.hasFilenameHint && docPageRaw.output.filenameHints?.length) {
169
+ try {
170
+ const hints = docPageRaw.output.filenameHints;
171
+
172
+ const contextMatches = await Promise.all(
173
+ documentContexts.map(async (ctx) => {
174
+ const matches = await fuzzyPrefilter({
175
+ cacheKey: "routing:" + ctx.id,
176
+ relevantKeywords: hints,
177
+ context: ctx,
178
+ fields: ["name", "id", "external_id"],
179
+ normalize: (item: any) =>
180
+ item.external_id ? normalizeFileName(item.external_id) : item.name,
181
+ });
182
+ return { ctxId: ctx.id as string, matches };
183
+ }),
184
+ );
185
+
186
+ let totalMatched = 0;
187
+ const matchedNames: string[] = [];
188
+
189
+ for (const { ctxId, matches } of contextMatches) {
190
+ if (matches.length > 0) {
191
+ userPinnedItemIdsByContext.set(ctxId, new Set(matches.map((m: any) => m.id)));
192
+ totalMatched += matches.length;
193
+ matchedNames.push(...matches.map((m: any) => m.name));
194
+ }
195
+ }
196
+
197
+ // Plausibility cap: a real document reference matches a few files (language/version
198
+ // variants). A hint that fans out wider was a product/topic token — discard the pins
199
+ // and let the normal prefilter/routing handle it.
200
+ if (totalMatched > MAX_USER_PIN_MATCHES) {
201
+ userPinnedItemIdsByContext.clear();
202
+ steps.push({
203
+ text: `Reference "${hints.join(", ")}" matched ${totalMatched} files — too broad for a specific document reference; continuing without file pins.`,
204
+ });
205
+ totalMatched = 0;
206
+ } else {
207
+ steps.push({
208
+ text:
209
+ totalMatched > 0
210
+ ? `User referenced specific document(s); pinning ${totalMatched} file(s): ${matchedNames.join(", ")}`
211
+ : `User referenced document(s) ${hints.join(", ")} but no matching file was found.`,
212
+ });
213
+ }
214
+ } catch (err) {
215
+ console.warn("[EXULU pipeline] Document reference resolution failed:", err);
216
+ steps.push({ text: "Document reference resolution failed — continuing without file pins." });
217
+ }
218
+ }
219
+
220
+ if (
221
+ docPageRaw.output.hasPageHint &&
222
+ typeof docPageRaw.output.pageNumber === "number"
223
+ ) {
224
+ userRequestedPage = docPageRaw.output.pageNumber;
225
+ steps.push({
226
+ text: `User referenced specific page ${userRequestedPage}; results will be filtered to chunks on or adjacent to that page.`,
227
+ });
228
+ }
229
+
230
+ const hasExplicitDocAndPage =
231
+ userPinnedItemIdsByContext.size > 0 && userRequestedPage !== null;
232
+
233
+ // --- Phase 3: context selection (precedence: explicit > preselected > rules > implicit) ---
234
+
235
+ let mainContexts: string[] = [];
236
+ let fallbackContexts: string[] = [];
237
+
238
+ const explicitKBs = explicitKBRaw.output.explicitlyRequestedKnowledgeBases;
239
+
240
+ if (explicitKBs.length > 0) {
241
+ // Explicit KB wins — no fallback
242
+ mainContexts = explicitKBs.filter((id) => enabledIds.has(id));
243
+ fallbackContexts = [];
244
+ steps.push({
245
+ text:
246
+ "The user has explicitly requested to search in the following knowledge bases: " +
247
+ explicitKBs.join(", "),
248
+ });
249
+ } else if (preselectedItems.size > 0) {
250
+ // Preselected items win
251
+ mainContexts = Array.from(preselectedItems.keys()).filter((id) => enabledIds.has(id));
252
+ fallbackContexts = [];
253
+ steps.push({
254
+ text:
255
+ "The user has requested to search in the following knowledge bases: " +
256
+ mainContexts.join(", "),
257
+ });
258
+ } else if (routingRules.length > 0) {
259
+ // Rule-based classification
260
+ const ruleIds = routingRules.map((r) => r.id);
261
+ const rulesLines = routingRules
262
+ .map((r) => `- ${r.id} (${r.label}): ${r.description}`)
263
+ .join("\n");
264
+ let classifyPrompt = `You are a helpful assistant that classifies user requests.\n\n${rulesLines}`;
265
+ if (extraInstructions) {
266
+ classifyPrompt += `\n<instructions>\n${extraInstructions}\n</instructions>`;
267
+ }
268
+
269
+ try {
270
+ const { output: classified } = await withRetry(
271
+ () =>
272
+ generateText({
273
+ model,
274
+ temperature: 0,
275
+ system: classifyPrompt,
276
+ messages: [{ role: "user", content: question }],
277
+ output: Output.object({
278
+ schema: z.object({
279
+ ruleId: z.enum(ruleIds as [string, ...string[]]),
280
+ reason: z.string(),
281
+ }),
282
+ }),
283
+ maxOutputTokens: 200,
284
+ }),
285
+ 3,
286
+ );
287
+
288
+ const matchedRule = routingRules.find((r) => r.id === classified.ruleId);
289
+ if (matchedRule) {
290
+ const main = matchedRule.main.filter((id) => enabledIds.has(id));
291
+ const fallback = matchedRule.fallback.filter(
292
+ (id) => enabledIds.has(id) && !main.includes(id),
293
+ );
294
+ mainContexts = main;
295
+ fallbackContexts = fallback;
296
+ steps.push({
297
+ text: `Classified the request as: ${classified.ruleId} because: ${classified.reason}`,
298
+ });
299
+ steps.push({
300
+ text: `Main contexts: ${mainContexts.join(", ")}, Fallback contexts: ${fallbackContexts.join(", ")}`,
301
+ });
302
+ } else {
303
+ // Unknown ruleId → implicit all-main
304
+ mainContexts = enabledContexts.map((c) => c.id);
305
+ fallbackContexts = [];
306
+ steps.push({
307
+ text: "Classification returned unknown rule — using implicit all-main rule.",
308
+ });
309
+ }
310
+ } catch (err) {
311
+ // Classification failed → implicit all-main (degraded)
312
+ mainContexts = enabledContexts.map((c) => c.id);
313
+ fallbackContexts = [];
314
+ steps.push({
315
+ text: "Classification failed — using implicit all-main rule (degraded).",
316
+ });
317
+ }
318
+ } else {
319
+ // No rules → implicit all-main
320
+ mainContexts = enabledContexts.map((c) => c.id);
321
+ fallbackContexts = [];
322
+ }
323
+
324
+ return {
325
+ mainContexts,
326
+ fallbackContexts,
327
+ userPinnedItemIdsByContext,
328
+ userRequestedPage,
329
+ hasExplicitDocAndPage,
330
+ steps,
331
+ };
332
+ } catch (err) {
333
+ console.warn("[EXULU pipeline] runRoutingPhase failed:", err);
334
+ return {
335
+ mainContexts: enabledContexts.map((c) => c.id),
336
+ fallbackContexts: [],
337
+ userPinnedItemIdsByContext: new Map(),
338
+ userRequestedPage: null,
339
+ hasExplicitDocAndPage: false,
340
+ steps: [{ text: "Routing failed — searching all enabled knowledge bases." }],
341
+ };
342
+ }
343
+ }
@@ -0,0 +1,149 @@
1
+ // ee/agentic-retrieval/pipeline/search.test.ts
2
+ import { searchContexts } from "./search";
3
+
4
+ jest.mock("./multi-query", () => ({ multiQuerySearch: jest.fn(async () => []), singleSearch: jest.fn(async () => []) }));
5
+ jest.mock("./hyde", () => ({ generateHydePassage: jest.fn(async () => "HYDE PASSAGE") }));
6
+ jest.mock("./prefilter", () => ({ fuzzyPrefilter: jest.fn(async () => [{ id: "p1", name: "n", key: "k" }]) }));
7
+ import { multiQuerySearch, singleSearch } from "./multi-query";
8
+ import { generateHydePassage } from "./hyde";
9
+ import { fuzzyPrefilter } from "./prefilter";
10
+
11
+ const contextsById = new Map<string, any>([
12
+ ["docs", { id: "docs", configuration: {} }],
13
+ ["tickets", { id: "tickets", configuration: {} }],
14
+ ]);
15
+ const base = {
16
+ contextsById,
17
+ kbProfiles: {
18
+ docs: { enabled: true, kind: "documents", instructions: "", overrides: {} },
19
+ tickets: { enabled: true, kind: "conversations", instructions: "", overrides: {} },
20
+ } as any,
21
+ question: "how to fix door error E42", keywords: ["door", "E42"], importantKeyword: "E42",
22
+ user: {}, role: "r", model: {},
23
+ preselectedItems: new Map(), identifierPinsByContext: new Map(), memoryPinnedItemIds: new Set<string>(),
24
+ userPinnedItemIdsByContext: new Map(), rewrites: [{ find: "fix", replace: "repair" }],
25
+ styleHint: "", maxQueries: 5, skipPrefilter: false,
26
+ };
27
+
28
+ beforeEach(() => {
29
+ (multiQuerySearch as jest.Mock).mockClear();
30
+ (singleSearch as jest.Mock).mockClear();
31
+ (fuzzyPrefilter as jest.Mock).mockClear();
32
+ (generateHydePassage as jest.Mock).mockClear();
33
+ });
34
+
35
+ describe("searchContexts", () => {
36
+ it("documents kind uses multi-query with question + HyDE + rewrites", async () => {
37
+ await searchContexts({ ...base, contextIds: ["docs"] });
38
+ const call = (multiQuerySearch as jest.Mock).mock.calls[0][0];
39
+ expect(call.queries[0]).toBe(base.question);
40
+ expect(call.queries).toContain("HYDE PASSAGE");
41
+ expect(call.queries).toContain("how to repair door error E42");
42
+ expect(call.config.limit).toBe(100);
43
+ });
44
+
45
+ it("conversations kind prefilters by keywords then single-searches with joined keywords", async () => {
46
+ await searchContexts({ ...base, contextIds: ["tickets"] });
47
+ expect(fuzzyPrefilter).toHaveBeenCalled();
48
+ const call = (singleSearch as jest.Mock).mock.calls[0][0];
49
+ expect(call.query).toBe("door E42 E42");
50
+ expect(call.pinnedItemIds).toEqual(["p1"]);
51
+ expect(call.config.limit).toBe(20);
52
+ });
53
+
54
+ it("user pins REPLACE identifier+memory pins; memory pins UNION with identifier pins", async () => {
55
+ await searchContexts({
56
+ ...base, contextIds: ["docs"],
57
+ identifierPinsByContext: new Map([["docs", new Set(["i1"])]]),
58
+ memoryPinnedItemIds: new Set(["m1"]),
59
+ });
60
+ expect(new Set((multiQuerySearch as jest.Mock).mock.calls[0][0].pinnedItemIds)).toEqual(new Set(["i1", "m1"]));
61
+
62
+ (multiQuerySearch as jest.Mock).mockClear();
63
+ await searchContexts({
64
+ ...base, contextIds: ["docs"],
65
+ identifierPinsByContext: new Map([["docs", new Set(["i1"])]]),
66
+ memoryPinnedItemIds: new Set(["m1"]),
67
+ userPinnedItemIdsByContext: new Map([["docs", new Set(["u1"])]]),
68
+ });
69
+ expect((multiQuerySearch as jest.Mock).mock.calls[0][0].pinnedItemIds).toEqual(["u1"]);
70
+ });
71
+
72
+ it("preselected items win over everything and skip prefilters", async () => {
73
+ await searchContexts({
74
+ ...base, contextIds: ["docs"],
75
+ preselectedItems: new Map([["docs", ["s1", "s2"]]]),
76
+ identifierPinsByContext: new Map([["docs", new Set(["i1"])]]),
77
+ });
78
+ expect((multiQuerySearch as jest.Mock).mock.calls[0][0].pinnedItemIds).toEqual(["s1", "s2"]);
79
+ });
80
+
81
+ it("tags every returned chunk with its source knowledge base (citation attribution)", async () => {
82
+ (multiQuerySearch as jest.Mock).mockResolvedValueOnce([
83
+ { chunk_id: "c1", item_id: "i1", item_name: "Doc", chunk_content: "x" },
84
+ ]);
85
+ (singleSearch as jest.Mock).mockResolvedValueOnce([
86
+ { chunk_id: "c2", item_id: "i2", item_name: "Ticket", chunk_content: "y" },
87
+ ]);
88
+ const r = await searchContexts({ ...base, contextIds: ["docs", "tickets"] });
89
+ const byId = Object.fromEntries(r.chunks.map((c: any) => [c.chunk_id, c]));
90
+ expect(byId["c1"].context).toEqual({ id: "docs", name: "docs" });
91
+ expect(byId["c2"].context).toEqual({ id: "tickets", name: "tickets" });
92
+ });
93
+
94
+ it("skipPrefilter (fallback pass) suppresses identifier/memory pins", async () => {
95
+ await searchContexts({
96
+ ...base, contextIds: ["docs"], skipPrefilter: true,
97
+ identifierPinsByContext: new Map([["docs", new Set(["i1"])]]),
98
+ memoryPinnedItemIds: new Set(["m1"]),
99
+ });
100
+ expect((multiQuerySearch as jest.Mock).mock.calls[0][0].pinnedItemIds).toEqual([]);
101
+ });
102
+
103
+ it("conversations keyword prefilter still runs in the fallback pass (reference parity)", async () => {
104
+ await searchContexts({
105
+ ...base, contextIds: ["tickets"], skipPrefilter: true,
106
+ });
107
+ expect(fuzzyPrefilter).toHaveBeenCalled();
108
+ const call = (singleSearch as jest.Mock).mock.calls[0][0];
109
+ expect(call.pinnedItemIds).toEqual(["p1"]);
110
+ });
111
+
112
+ it("records kind single-searches with the keyword-joined query", async () => {
113
+ const contextsWithRecords = new Map<string, any>([
114
+ ["docs", { id: "docs", configuration: {} }],
115
+ ["tickets", { id: "tickets", configuration: {} }],
116
+ ["db", { id: "db", configuration: {} }],
117
+ ]);
118
+ const kbProfilesWithRecords = {
119
+ docs: { enabled: true, kind: "documents", instructions: "", overrides: {} },
120
+ tickets: { enabled: true, kind: "conversations", instructions: "", overrides: {} },
121
+ db: { enabled: true, kind: "records", instructions: "", overrides: {} },
122
+ } as any;
123
+ await searchContexts({
124
+ ...base,
125
+ contextIds: ["db"],
126
+ contextsById: contextsWithRecords,
127
+ kbProfiles: kbProfilesWithRecords,
128
+ });
129
+ expect(fuzzyPrefilter).not.toHaveBeenCalled();
130
+ const call = (singleSearch as jest.Mock).mock.calls[0][0];
131
+ expect(call.query).toBe("door E42 E42");
132
+ expect(call.config.limit).toBe(20);
133
+ });
134
+
135
+ it("hyde suppressed when settings.hyde is false", async () => {
136
+ const kbProfilesNoHyde = {
137
+ docs: { enabled: true, kind: "documents", instructions: "", overrides: { hyde: false } },
138
+ tickets: { enabled: true, kind: "conversations", instructions: "", overrides: {} },
139
+ } as any;
140
+ await searchContexts({
141
+ ...base,
142
+ contextIds: ["docs"],
143
+ kbProfiles: kbProfilesNoHyde,
144
+ });
145
+ expect(generateHydePassage).not.toHaveBeenCalled();
146
+ const call = (multiQuerySearch as jest.Mock).mock.calls[0][0];
147
+ expect(call.queries).not.toContain("HYDE PASSAGE");
148
+ });
149
+ });
@@ -0,0 +1,180 @@
1
+ import { effectiveKbSettings, type KbProfile } from "./config";
2
+ import { multiQuerySearch, singleSearch, type SearchCallConfig } from "./multi-query";
3
+ import { generateHydePassage } from "./hyde";
4
+ import { fuzzyPrefilter } from "./prefilter";
5
+ import { applyRewrites } from "./text-utils";
6
+ import type { Chunk } from "./types";
7
+
8
+ /** Attach the source knowledge base to each chunk so citations and the chat UI can
9
+ * attribute passages deterministically. Only memory chunks were labeled before; the
10
+ * answering model improvised labels for the rest (e.g. "fallback context", or "memory"
11
+ * bleeding onto tech-doc passages). */
12
+ const tagContext = (chunks: any[], ctx: any): any[] =>
13
+ chunks.map((c) => ({ ...c, context: { id: ctx.id, name: ctx.name ?? ctx.id } }));
14
+
15
+ export async function searchContexts(opts: {
16
+ contextIds: string[];
17
+ contextsById: Map<string, any>;
18
+ kbProfiles: Record<string, KbProfile>;
19
+ question: string;
20
+ keywords: string[];
21
+ importantKeyword: string;
22
+ user: any;
23
+ role: any;
24
+ model: any;
25
+ preselectedItems: Map<string, string[] | null>;
26
+ identifierPinsByContext: Map<string, Set<string>>; // from resolveIdentifierPins
27
+ memoryPinnedItemIds: Set<string>; // from memory phase (documents kind only)
28
+ userPinnedItemIdsByContext: Map<string, Set<string>>; // from routing phase
29
+ rewrites: { find: string; replace: string }[];
30
+ styleHint: string;
31
+ maxQueries: number;
32
+ skipPrefilter: boolean; // true for the speculative fallback pass
33
+ }): Promise<{ chunks: Chunk[] }> {
34
+ const {
35
+ contextIds,
36
+ contextsById,
37
+ kbProfiles,
38
+ question,
39
+ keywords,
40
+ importantKeyword,
41
+ user,
42
+ role,
43
+ model,
44
+ preselectedItems,
45
+ identifierPinsByContext,
46
+ memoryPinnedItemIds,
47
+ userPinnedItemIdsByContext,
48
+ rewrites,
49
+ styleHint,
50
+ maxQueries,
51
+ skipPrefilter,
52
+ } = opts;
53
+
54
+ const chunkArrays = await Promise.all(
55
+ contextIds.map(async (ctxId): Promise<Chunk[]> => {
56
+ try {
57
+ // Rule 4: Missing context contributes []
58
+ const ctx = contextsById.get(ctxId);
59
+ if (!ctx) return [];
60
+
61
+ const profile = kbProfiles[ctxId];
62
+ const settings = effectiveKbSettings(profile, ctx);
63
+ const { kind, limit, expand, cutoffs, multiQuery, hyde, keywordPrefilter } = settings;
64
+
65
+ const searchConfig: SearchCallConfig = {
66
+ method: "hybridSearch",
67
+ limit,
68
+ expand,
69
+ cutoffs,
70
+ };
71
+
72
+ // ---------------------------------------------------------------
73
+ // Pin semantics (rules 1–2)
74
+ // ---------------------------------------------------------------
75
+
76
+ // Rule 1: Effective pins START from preselectedItems
77
+ const hasPreselection = preselectedItems.has(ctxId);
78
+ let pinnedItemIds: string[];
79
+
80
+ if (hasPreselection) {
81
+ // null value = whole context = no filter = []
82
+ pinnedItemIds = preselectedItems.get(ctxId) ?? [];
83
+ } else if (!skipPrefilter) {
84
+ // Rule 2: No preselection and !skipPrefilter
85
+
86
+ // 2a: start from identifier pins for this context
87
+ const identifierPins = identifierPinsByContext.get(ctxId) ?? new Set<string>();
88
+ let pins = new Set<string>(identifierPins);
89
+
90
+ // 2b: documents kind only — UNION with memoryPinnedItemIds
91
+ if (kind === "documents") {
92
+ for (const id of memoryPinnedItemIds) pins.add(id);
93
+ }
94
+
95
+ // 2c: user pins REPLACE everything (authoritative)
96
+ const userPins = userPinnedItemIdsByContext.get(ctxId);
97
+ if (userPins && userPins.size > 0) {
98
+ pins = new Set<string>(userPins);
99
+ }
100
+
101
+ pinnedItemIds = Array.from(pins);
102
+ } else {
103
+ // skipPrefilter = true: suppress identifier/memory pins
104
+ pinnedItemIds = [];
105
+ }
106
+
107
+ // ---------------------------------------------------------------
108
+ // Rule 3: Query construction by kind
109
+ // ---------------------------------------------------------------
110
+
111
+ if (kind === "documents") {
112
+ if (multiQuery) {
113
+ // Generate HyDE passage when settings.hyde is true
114
+ let hydePassage: string | null = null;
115
+ if (hyde) {
116
+ hydePassage = await generateHydePassage({
117
+ originalQuestion: question,
118
+ relevantKeywords: keywords,
119
+ importantKeyword,
120
+ styleHint,
121
+ model,
122
+ });
123
+ }
124
+
125
+ // HyDE placed right after the question so the cap never drops it
126
+ const candidates: string[] = [
127
+ question,
128
+ ...(hydePassage ? [hydePassage] : []),
129
+ ...applyRewrites(question, rewrites),
130
+ ];
131
+ const queries = [...new Set(candidates)].slice(0, maxQueries);
132
+
133
+ return tagContext(await multiQuerySearch({ queries, config: searchConfig, user, role, pinnedItemIds, context: ctx }), ctx);
134
+ } else {
135
+ return tagContext(await singleSearch({ query: question, config: searchConfig, user, role, pinnedItemIds, context: ctx }), ctx);
136
+ }
137
+ }
138
+
139
+ if (kind === "conversations") {
140
+ // Deliberately NOT gated on skipPrefilter: the keyword prefilter is intrinsic to how
141
+ // conversations-kind search works (reference parity — newlkiag ran it in the fallback
142
+ // pass too), unlike the cross-context identifier/memory/user pins suppressed above.
143
+ // keywordPrefilter and no pins yet → fuzzyPrefilter; results become the pins
144
+ if (keywordPrefilter && pinnedItemIds.length === 0) {
145
+ const prefiltered = await fuzzyPrefilter({
146
+ cacheKey: `conversations:${ctxId}`,
147
+ relevantKeywords: keywords,
148
+ importantKeyword,
149
+ context: ctx,
150
+ fields: ["name", "id", "external_id", "description"],
151
+ normalize: (i: any) => [i.name, i.description].filter(Boolean).join(": "),
152
+ });
153
+ pinnedItemIds = prefiltered.map((r) => r.id);
154
+ }
155
+
156
+ const keywordQuery = keywords.length
157
+ ? keywords.join(" ") + " " + importantKeyword
158
+ : question;
159
+
160
+ return tagContext(await singleSearch({ query: keywordQuery, config: searchConfig, user, role, pinnedItemIds, context: ctx }), ctx);
161
+ }
162
+
163
+ if (kind === "records") {
164
+ const keywordQuery = keywords.length
165
+ ? keywords.join(" ") + " " + importantKeyword
166
+ : question;
167
+
168
+ return tagContext(await singleSearch({ query: keywordQuery, config: searchConfig, user, role, pinnedItemIds, context: ctx }), ctx);
169
+ }
170
+
171
+ return [];
172
+ } catch (err) {
173
+ console.warn(`[EXULU pipeline] searchContexts failed for context "${ctxId}":`, err);
174
+ return [];
175
+ }
176
+ }),
177
+ );
178
+
179
+ return { chunks: chunkArrays.flat() };
180
+ }