@exulu/backend 1.69.3 → 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.
- package/dist/{catalog-TBSPSN2N.js → catalog-UGTDNMDM.js} +2 -1
- package/dist/{chunk-YCE44CMU.js → chunk-7CCMW3IW.js} +2 -0
- package/dist/chunk-IJ4HNHOT.js +6416 -0
- package/dist/{chunk-IDHS2BZO.js → chunk-T6JVFT7L.js} +2 -0
- package/dist/cli/start-whisper.cjs +1 -0
- package/dist/cli/start-whisper.js +2 -1
- package/dist/convert-exulu-tools-to-ai-sdk-tools-2PEDFZ2X.js +9 -0
- package/dist/index.cjs +9558 -9262
- package/dist/index.d.cts +46 -29
- package/dist/index.d.ts +46 -29
- package/dist/index.js +4989 -548
- package/ee/agentic-retrieval/pipeline/config.test.ts +81 -0
- package/ee/agentic-retrieval/pipeline/config.ts +189 -0
- package/ee/agentic-retrieval/pipeline/hyde.test.ts +55 -0
- package/ee/agentic-retrieval/pipeline/hyde.ts +133 -0
- package/ee/agentic-retrieval/pipeline/index.test.ts +140 -0
- package/ee/agentic-retrieval/pipeline/index.ts +638 -0
- package/ee/agentic-retrieval/pipeline/memory.test.ts +101 -0
- package/ee/agentic-retrieval/pipeline/memory.ts +566 -0
- package/ee/agentic-retrieval/pipeline/multi-query.test.ts +51 -0
- package/ee/agentic-retrieval/pipeline/multi-query.ts +158 -0
- package/ee/agentic-retrieval/pipeline/prefilter.test.ts +93 -0
- package/ee/agentic-retrieval/pipeline/prefilter.ts +389 -0
- package/ee/agentic-retrieval/pipeline/rerank.test.ts +128 -0
- package/ee/agentic-retrieval/pipeline/rerank.ts +178 -0
- package/ee/agentic-retrieval/pipeline/routing.test.ts +144 -0
- package/ee/agentic-retrieval/pipeline/routing.ts +343 -0
- package/ee/agentic-retrieval/pipeline/search.test.ts +149 -0
- package/ee/agentic-retrieval/pipeline/search.ts +180 -0
- package/ee/agentic-retrieval/pipeline/text-utils.test.ts +43 -0
- package/ee/agentic-retrieval/pipeline/text-utils.ts +85 -0
- package/ee/agentic-retrieval/pipeline/types.ts +59 -0
- package/ee/python/documents/processing/doc_processor.ts +1 -1
- package/ee/python/documents/processing/split_pdf.py +78 -24
- package/package.json +2 -1
- package/dist/chunk-WCP3WZM3.js +0 -10391
- package/dist/convert-exulu-tools-to-ai-sdk-tools-GQ3UIYP7.js +0 -6
- package/ee/agentic-retrieval/v3/agent-loop.ts +0 -288
- package/ee/agentic-retrieval/v3/classifier.ts +0 -92
- package/ee/agentic-retrieval/v3/context-sampler.ts +0 -79
- package/ee/agentic-retrieval/v3/dynamic-tools.ts +0 -115
- package/ee/agentic-retrieval/v3/index.ts +0 -471
- package/ee/agentic-retrieval/v3/session-tools-registry.ts +0 -20
- package/ee/agentic-retrieval/v3/strategies.ts +0 -171
- package/ee/agentic-retrieval/v3/tools.ts +0 -558
- package/ee/agentic-retrieval/v3/trajectory.ts +0 -309
- 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
|
+
}
|