@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,638 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { LanguageModel } from "ai";
|
|
3
|
+
import type { ExuluContext } from "@SRC/exulu/context";
|
|
4
|
+
import type { User } from "@EXULU_TYPES/models/user";
|
|
5
|
+
import { ExuluTool } from "@SRC/exulu/tool";
|
|
6
|
+
import { checkLicense } from "@EE/entitlements";
|
|
7
|
+
import { resolveReranker } from "@SRC/exulu/resolve-reranker";
|
|
8
|
+
import { resolveModel } from "@SRC/exulu/resolve-model";
|
|
9
|
+
import { exuluApp } from "@SRC/exulu/app/singleton";
|
|
10
|
+
import { parsePipelineConfig, effectiveKbSettings } from "./config";
|
|
11
|
+
import { runRoutingPhase } from "./routing";
|
|
12
|
+
import { runMemoryPhase } from "./memory";
|
|
13
|
+
import { resolveIdentifierPins } from "./prefilter";
|
|
14
|
+
import { searchContexts } from "./search";
|
|
15
|
+
import { rerankResults } from "./rerank";
|
|
16
|
+
import type { AgenticRetrievalOutput, RerankState, ChunkWithScore } from "./types";
|
|
17
|
+
import type { VectorSearchChunkResult } from "@SRC/graphql/resolvers/vector-search";
|
|
18
|
+
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// parsePreselectedItems — verbatim copy of parseGlobalItemIds from v3/tools.ts
|
|
21
|
+
// (renamed for the pipeline public API)
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Parse a list of global preselected IDs into a per-context map.
|
|
26
|
+
*
|
|
27
|
+
* Two supported formats:
|
|
28
|
+
* "<context_id>/<item_id>" → specific item; value is a non-empty string[]
|
|
29
|
+
* "<context_id>" → full context (no item filter); value is null
|
|
30
|
+
*
|
|
31
|
+
* If both a full-context entry and specific-item entries exist for the same
|
|
32
|
+
* context, full-context (null) wins.
|
|
33
|
+
*/
|
|
34
|
+
export function parsePreselectedItems(globalIds: string[]): Map<string, string[] | null> {
|
|
35
|
+
const map = new Map<string, string[] | null>();
|
|
36
|
+
for (const gid of globalIds) {
|
|
37
|
+
const slashIdx = gid.indexOf("/");
|
|
38
|
+
if (slashIdx === -1) {
|
|
39
|
+
// No slash → entire context selected
|
|
40
|
+
if (gid) map.set(gid, null);
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
const contextId = gid.slice(0, slashIdx);
|
|
44
|
+
const itemId = gid.slice(slashIdx + 1);
|
|
45
|
+
if (!contextId || !itemId) continue;
|
|
46
|
+
// Full-context entry already wins — don't downgrade to specific items
|
|
47
|
+
if (map.get(contextId) === null) continue;
|
|
48
|
+
const existing = map.get(contextId) ?? [];
|
|
49
|
+
existing.push(itemId);
|
|
50
|
+
map.set(contextId, existing);
|
|
51
|
+
}
|
|
52
|
+
return map;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
// Helpers
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Accumulate chunks into result.chunks, deduplicating by chunk_id (first-seen wins).
|
|
61
|
+
*/
|
|
62
|
+
function addChunks(result: AgenticRetrievalOutput, chunks: ChunkWithScore[]): void {
|
|
63
|
+
const seen = new Set(result.chunks.map((c) => c.chunk_id));
|
|
64
|
+
for (const chunk of chunks) {
|
|
65
|
+
if (!seen.has(chunk.chunk_id)) {
|
|
66
|
+
seen.add(chunk.chunk_id);
|
|
67
|
+
result.chunks.push(chunk);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Serialize the cumulative output for yielding. Full chunk content lives ONLY in the
|
|
74
|
+
* top-level `chunks` (what the chat UI cites and the calling agent reads); the per-step
|
|
75
|
+
* chunk copies keep ids/names/metadata for counts and traceability but drop
|
|
76
|
+
* `chunk_content` — serializing the same content twice roughly doubled the tool payload
|
|
77
|
+
* and, across a few calls, overflowed the calling agent's context window.
|
|
78
|
+
*/
|
|
79
|
+
function serializeOutput(result: AgenticRetrievalOutput): string {
|
|
80
|
+
return JSON.stringify({
|
|
81
|
+
...result,
|
|
82
|
+
steps: result.steps.map((step) =>
|
|
83
|
+
step.chunks.length === 0
|
|
84
|
+
? step
|
|
85
|
+
: {
|
|
86
|
+
...step,
|
|
87
|
+
chunks: step.chunks.map((c) => ({ ...c, chunk_content: undefined })),
|
|
88
|
+
},
|
|
89
|
+
),
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
// Factory
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
export function createAgenticRetrievalTool(opts: {
|
|
98
|
+
contexts: ExuluContext[];
|
|
99
|
+
memoryContext?: ExuluContext;
|
|
100
|
+
user?: User;
|
|
101
|
+
role?: string;
|
|
102
|
+
model?: LanguageModel;
|
|
103
|
+
instructions?: string;
|
|
104
|
+
preselected?: string[];
|
|
105
|
+
memoryItems?: VectorSearchChunkResult[];
|
|
106
|
+
}): ExuluTool | undefined {
|
|
107
|
+
const {
|
|
108
|
+
contexts,
|
|
109
|
+
memoryContext,
|
|
110
|
+
user,
|
|
111
|
+
role,
|
|
112
|
+
model,
|
|
113
|
+
instructions: adminInstructions,
|
|
114
|
+
preselected,
|
|
115
|
+
memoryItems,
|
|
116
|
+
} = opts;
|
|
117
|
+
|
|
118
|
+
const license = checkLicense();
|
|
119
|
+
if (!license["agentic-retrieval"]) {
|
|
120
|
+
console.warn("[EXULU] Not licensed for agentic retrieval");
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return ExuluTool.internal({
|
|
125
|
+
id: "agentic_context_search",
|
|
126
|
+
name: "Context Search",
|
|
127
|
+
description: `Intelligent knowledge search across the available knowledge bases: ${contexts.map((c) => c.name || c.id).join(", ")}. Routes the question to the right sources, searches them with query expansion, and returns reranked passages. Results are exhaustive for the given query: do NOT repeat the call with a rephrased version of the same question — re-call only with genuinely new information (a different product or model, an explicitly named source or document, or new details from the user).`,
|
|
128
|
+
category: "contexts",
|
|
129
|
+
needsApproval: false,
|
|
130
|
+
type: "context",
|
|
131
|
+
config: [
|
|
132
|
+
{
|
|
133
|
+
name: "instructions",
|
|
134
|
+
description: "Custom instructions for the retrieval agent",
|
|
135
|
+
type: "string",
|
|
136
|
+
default: "",
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
name: "reranker",
|
|
140
|
+
description: "Reranker to use for result ranking",
|
|
141
|
+
type: "string",
|
|
142
|
+
default: "none",
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
name: "managed_context",
|
|
146
|
+
description: "Makes sure the user defines which items from which contexts the agentic retrieval tool will search in",
|
|
147
|
+
type: "boolean",
|
|
148
|
+
default: false,
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
name: "require_preselected_contexts",
|
|
152
|
+
description: "Require the user to preselect contexts before executing the tool, meaning the user will be asked to select the contexts they want to search in",
|
|
153
|
+
type: "boolean",
|
|
154
|
+
default: false,
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
name: "logging",
|
|
158
|
+
description: "Verbose debug logging of each retrieval phase to the server console. Useful for debugging and evaluation.",
|
|
159
|
+
type: "boolean",
|
|
160
|
+
default: false,
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
name: "utility_model",
|
|
164
|
+
description: "Optional model id used for the pipeline's internal micro-calls (classification, memory checks, query expansion). Empty = the agent's own model.",
|
|
165
|
+
type: "string",
|
|
166
|
+
default: "",
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
name: "max_steps",
|
|
170
|
+
description: "Maximum reasoning/tool steps the CALLING agent may take on a message while this tool is enabled (bounds retry loops and token cost). 0 = platform default (5, or 10 with skills).",
|
|
171
|
+
type: "number",
|
|
172
|
+
default: 0,
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
name: "knowledge_bases",
|
|
176
|
+
description: "Per-knowledge-base profiles: enabled, kind (documents | conversations | records), instructions, and per-KB overrides (limit, expand, multiQuery, hyde). JSON object keyed by context id.",
|
|
177
|
+
type: "json",
|
|
178
|
+
default: "{}",
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
name: "routing",
|
|
182
|
+
description: "Routing rules: plain-language categories mapping to main and fallback knowledge bases. Empty = search all enabled knowledge bases.",
|
|
183
|
+
type: "json",
|
|
184
|
+
default: '{"rules":[]}',
|
|
185
|
+
},
|
|
186
|
+
{
|
|
187
|
+
name: "vocabulary",
|
|
188
|
+
description: "Domain vocabulary: glossary (term/meaning), identifier sets (product names, standards) used to pin matching files, query rewrite rules, and a styleHint describing the documents (feeds query expansion).",
|
|
189
|
+
type: "json",
|
|
190
|
+
default: '{"glossary":[],"identifiers":[],"rewrites":[],"styleHint":""}',
|
|
191
|
+
},
|
|
192
|
+
{
|
|
193
|
+
name: "memory",
|
|
194
|
+
description: "Memory features (requires the agent to have a memory context): relevance-checked recall, authoritative override, file prioritization, query augmentation.",
|
|
195
|
+
type: "json",
|
|
196
|
+
default: '{"enabled":true,"override":false,"filePrioritization":false,"queryAugmentation":true}',
|
|
197
|
+
},
|
|
198
|
+
{
|
|
199
|
+
name: "tuning",
|
|
200
|
+
description: "Retrieval tuning: topK, fallbackThreshold, pinBoost, identifierBoost, pageWindow, maxQueriesPerContext.",
|
|
201
|
+
type: "json",
|
|
202
|
+
default: '{"topK":5,"fallbackThreshold":0.95,"pinBoost":0.15,"identifierBoost":0.15,"pageWindow":1,"maxQueriesPerContext":5}',
|
|
203
|
+
},
|
|
204
|
+
],
|
|
205
|
+
inputSchema: z.object({
|
|
206
|
+
userQuery: z.string().describe("The original unaltered question from the user"),
|
|
207
|
+
relevantKeywords: z.array(z.string()).describe("Keywords extracted from the user's question relevant to the search"),
|
|
208
|
+
importantKeyword: z.string().describe("The single most important keyword from the user's question"),
|
|
209
|
+
confirmedContextIds: z
|
|
210
|
+
.array(z.string())
|
|
211
|
+
.optional()
|
|
212
|
+
.describe(
|
|
213
|
+
"Knowledge base IDs explicitly confirmed by the user to be used in the retrieval. " +
|
|
214
|
+
"When present, only searches these contexts."
|
|
215
|
+
),
|
|
216
|
+
}),
|
|
217
|
+
execute: async function* ({
|
|
218
|
+
userQuery,
|
|
219
|
+
relevantKeywords,
|
|
220
|
+
importantKeyword,
|
|
221
|
+
confirmedContextIds,
|
|
222
|
+
toolVariablesConfig,
|
|
223
|
+
}: {
|
|
224
|
+
userQuery: string;
|
|
225
|
+
relevantKeywords: string[];
|
|
226
|
+
importantKeyword: string;
|
|
227
|
+
confirmedContextIds?: string[];
|
|
228
|
+
toolVariablesConfig?: Record<string, unknown>;
|
|
229
|
+
}) {
|
|
230
|
+
// ── Gate: model required ──────────────────────────────────────────────
|
|
231
|
+
if (!model) {
|
|
232
|
+
yield { result: "Model is required for executing the agentic retrieval tool" };
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const cfg = parsePipelineConfig(toolVariablesConfig);
|
|
237
|
+
|
|
238
|
+
// ── Gate: managed context requires preselected items ──────────────────
|
|
239
|
+
if (cfg.managedContext && !preselected?.length) {
|
|
240
|
+
if (cfg.logging) console.log("[EXULU] Managed context was enabled for the agentic retrieval tool. This means that the user must preselect items that the agentic retrieval tool will search in, please notify the user to preselect items before executing the tool.");
|
|
241
|
+
yield { result: "Managed context was enabled for the agentic retrieval tool. This means that the user must preselect items that the agentic retrieval tool will search in, please notify the user to preselect items before executing the tool." };
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ── Gate: require_preselected_contexts ───────────────────────────────
|
|
246
|
+
if (cfg.requirePreselectedContexts && !confirmedContextIds?.length && !preselected?.length) {
|
|
247
|
+
const activeContextsList = contexts.map((c) => c.id).join(", ");
|
|
248
|
+
if (cfg.logging) console.log("[EXULU] The user must choose between the available contexts before executing the tool. The available contexts are: " + activeContextsList + ". If the question_ask tool is available use that to ask the user which contexts they want to search in, otherwise just ask them in plain text.");
|
|
249
|
+
yield { result: "The user must choose between the available contexts before executing the tool, the available contexts are: " + activeContextsList + ". If the question_ask tool is available use that to ask the user which contexts they want to search in, otherwise just ask them in plain text." };
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ── Initialise cumulative output (before try so catch can reference it) ──
|
|
254
|
+
const result: AgenticRetrievalOutput = {
|
|
255
|
+
steps: [],
|
|
256
|
+
reasoning: [],
|
|
257
|
+
chunks: [],
|
|
258
|
+
usage: [],
|
|
259
|
+
totalTokens: 0,
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
try {
|
|
263
|
+
// ── Enabled contexts (knowledge_bases.enabled filter + restore-all) ───
|
|
264
|
+
let enabledContexts = contexts.filter(
|
|
265
|
+
(ctx) => cfg.knowledgeBases[ctx.id]?.enabled !== false,
|
|
266
|
+
);
|
|
267
|
+
if (enabledContexts.length === 0) enabledContexts = contexts;
|
|
268
|
+
|
|
269
|
+
// ── Apply confirmedContextIds filter ──────────────────────────────────
|
|
270
|
+
if (confirmedContextIds?.length) {
|
|
271
|
+
const confirmed = new Set(confirmedContextIds);
|
|
272
|
+
const filtered = enabledContexts.filter((c) => confirmed.has(c.id));
|
|
273
|
+
if (filtered.length > 0) enabledContexts = filtered;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// ── Resolve reranker (best-effort) ────────────────────────────────────
|
|
277
|
+
let reranker: Awaited<ReturnType<typeof resolveReranker>> | undefined;
|
|
278
|
+
const rerankerId = cfg.reranker;
|
|
279
|
+
if (rerankerId && rerankerId !== "none") {
|
|
280
|
+
try {
|
|
281
|
+
reranker = await resolveReranker({ model: rerankerId, user, roleId: role });
|
|
282
|
+
} catch (err) {
|
|
283
|
+
console.warn(
|
|
284
|
+
`[EXULU pipeline] could not resolve reranker "${rerankerId}", continuing without reranking:`,
|
|
285
|
+
err,
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// ── Resolve utility model (best-effort) ───────────────────────────────
|
|
291
|
+
let utilityModel: LanguageModel = model;
|
|
292
|
+
if (cfg.utilityModel) {
|
|
293
|
+
try {
|
|
294
|
+
const resolved = await resolveModel({
|
|
295
|
+
modelId: cfg.utilityModel,
|
|
296
|
+
user,
|
|
297
|
+
providers: exuluApp.get().providers,
|
|
298
|
+
rbacBypass: true,
|
|
299
|
+
});
|
|
300
|
+
utilityModel = resolved.languageModel ?? model;
|
|
301
|
+
} catch (err) {
|
|
302
|
+
console.warn(
|
|
303
|
+
`[EXULU pipeline] could not resolve utility model "${cfg.utilityModel}", falling back to agent model:`,
|
|
304
|
+
err,
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// ── Preselected items map ─────────────────────────────────────────────
|
|
310
|
+
const preselectedItems = parsePreselectedItems(preselected ?? []);
|
|
311
|
+
|
|
312
|
+
// ── Derived maps ──────────────────────────────────────────────────────
|
|
313
|
+
const contextsById = new Map(enabledContexts.map((c) => [c.id, c]));
|
|
314
|
+
const kbKindById = new Map(
|
|
315
|
+
enabledContexts.map((c) => [
|
|
316
|
+
c.id,
|
|
317
|
+
effectiveKbSettings(cfg.knowledgeBases[c.id], c).kind,
|
|
318
|
+
]),
|
|
319
|
+
);
|
|
320
|
+
const documentContexts = enabledContexts.filter(
|
|
321
|
+
(c) => (cfg.knowledgeBases[c.id]?.kind ?? "documents") === "documents",
|
|
322
|
+
);
|
|
323
|
+
|
|
324
|
+
// ── Phase 1: memory + routing in parallel ─────────────────────────────
|
|
325
|
+
const extraInstructions = [cfg.instructions, adminInstructions]
|
|
326
|
+
.filter(Boolean)
|
|
327
|
+
.join("\n");
|
|
328
|
+
|
|
329
|
+
const [memResult, routResult] = await Promise.all([
|
|
330
|
+
runMemoryPhase({
|
|
331
|
+
memoryChunks: memoryItems ?? [],
|
|
332
|
+
memoryContext,
|
|
333
|
+
question: userQuery,
|
|
334
|
+
keywords: relevantKeywords,
|
|
335
|
+
importantKeyword,
|
|
336
|
+
user,
|
|
337
|
+
role,
|
|
338
|
+
model: utilityModel,
|
|
339
|
+
memoryConfig: cfg.memory,
|
|
340
|
+
glossary: cfg.vocabulary.glossary,
|
|
341
|
+
documentContexts,
|
|
342
|
+
}),
|
|
343
|
+
runRoutingPhase({
|
|
344
|
+
question: userQuery,
|
|
345
|
+
enabledContexts,
|
|
346
|
+
documentContexts,
|
|
347
|
+
routingRules: cfg.routing.rules,
|
|
348
|
+
preselectedItems,
|
|
349
|
+
extraInstructions: extraInstructions || undefined,
|
|
350
|
+
// Configured identifier examples (product names, standards) — the doc-reference
|
|
351
|
+
// detector must never treat these as filename hints.
|
|
352
|
+
knownIdentifiers: cfg.vocabulary.identifiers.flatMap((i) => i.examples),
|
|
353
|
+
model: utilityModel,
|
|
354
|
+
}),
|
|
355
|
+
]);
|
|
356
|
+
|
|
357
|
+
// Merge steps from both phases
|
|
358
|
+
for (const step of [...memResult.steps, ...routResult.steps]) {
|
|
359
|
+
result.steps.push({
|
|
360
|
+
stepNumber: 1,
|
|
361
|
+
text: step.text,
|
|
362
|
+
toolCalls: step.toolCalls ?? [],
|
|
363
|
+
chunks: (step.chunks as ChunkWithScore[]) ?? [],
|
|
364
|
+
tokens: 0,
|
|
365
|
+
});
|
|
366
|
+
result.reasoning.push({ text: step.text, tools: [] });
|
|
367
|
+
}
|
|
368
|
+
// Memory citable chunks go first (insertion-order dedup)
|
|
369
|
+
addChunks(result, memResult.memoryChunksForAnswer);
|
|
370
|
+
yield { result: serializeOutput(result) };
|
|
371
|
+
|
|
372
|
+
// ── Preselection-subset guard (yield, not throw) ──────────────────────
|
|
373
|
+
const { mainContexts, fallbackContexts, userPinnedItemIdsByContext, userRequestedPage, hasExplicitDocAndPage } = routResult;
|
|
374
|
+
if (preselectedItems.size > 0 && !mainContexts.every((kb) => preselectedItems.has(kb))) {
|
|
375
|
+
const missing = mainContexts.filter((kb) => !preselectedItems.has(kb));
|
|
376
|
+
yield { result: "The user has requested to search in knowledge bases that are not part of the preselected knowledge bases: " + missing.join(", ") };
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const {
|
|
381
|
+
updatedQuestion,
|
|
382
|
+
updatedKeywords,
|
|
383
|
+
updatedImportantKeyword,
|
|
384
|
+
memoryPinnedItemIds,
|
|
385
|
+
memoryOverride,
|
|
386
|
+
} = memResult;
|
|
387
|
+
|
|
388
|
+
// ── Identifier pins (upfront, before Phase 2) ────────────────────────
|
|
389
|
+
const { pinsByContext: identifierPinsByContext, exactPinsByContext, steps: pinSteps } =
|
|
390
|
+
await resolveIdentifierPins({
|
|
391
|
+
question: updatedQuestion,
|
|
392
|
+
identifierSets: cfg.vocabulary.identifiers,
|
|
393
|
+
contextsById,
|
|
394
|
+
kbKindById,
|
|
395
|
+
model: utilityModel,
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
for (const step of pinSteps) {
|
|
399
|
+
result.steps.push({
|
|
400
|
+
stepNumber: 1,
|
|
401
|
+
text: step.text,
|
|
402
|
+
toolCalls: step.toolCalls ?? [],
|
|
403
|
+
chunks: (step.chunks as ChunkWithScore[]) ?? [],
|
|
404
|
+
tokens: 0,
|
|
405
|
+
});
|
|
406
|
+
result.reasoning.push({ text: step.text, tools: [] });
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// ── Phase 2: main + speculative fallback searchContexts in parallel ───
|
|
410
|
+
const [mainSearch, speculativeFallbackSearch] = await Promise.all([
|
|
411
|
+
searchContexts({
|
|
412
|
+
contextIds: mainContexts,
|
|
413
|
+
contextsById,
|
|
414
|
+
kbProfiles: cfg.knowledgeBases,
|
|
415
|
+
question: updatedQuestion,
|
|
416
|
+
keywords: updatedKeywords,
|
|
417
|
+
importantKeyword: updatedImportantKeyword,
|
|
418
|
+
user,
|
|
419
|
+
role,
|
|
420
|
+
model: utilityModel,
|
|
421
|
+
preselectedItems,
|
|
422
|
+
identifierPinsByContext,
|
|
423
|
+
memoryPinnedItemIds,
|
|
424
|
+
userPinnedItemIdsByContext,
|
|
425
|
+
rewrites: cfg.vocabulary.rewrites,
|
|
426
|
+
styleHint: cfg.vocabulary.styleHint,
|
|
427
|
+
maxQueries: cfg.tuning.maxQueriesPerContext,
|
|
428
|
+
skipPrefilter: false,
|
|
429
|
+
}),
|
|
430
|
+
fallbackContexts.length > 0 && !hasExplicitDocAndPage
|
|
431
|
+
? searchContexts({
|
|
432
|
+
contextIds: fallbackContexts,
|
|
433
|
+
contextsById,
|
|
434
|
+
kbProfiles: cfg.knowledgeBases,
|
|
435
|
+
question: updatedQuestion,
|
|
436
|
+
keywords: updatedKeywords,
|
|
437
|
+
importantKeyword: updatedImportantKeyword,
|
|
438
|
+
user,
|
|
439
|
+
role,
|
|
440
|
+
model: utilityModel,
|
|
441
|
+
preselectedItems,
|
|
442
|
+
identifierPinsByContext,
|
|
443
|
+
memoryPinnedItemIds,
|
|
444
|
+
userPinnedItemIdsByContext,
|
|
445
|
+
rewrites: cfg.vocabulary.rewrites,
|
|
446
|
+
styleHint: cfg.vocabulary.styleHint,
|
|
447
|
+
maxQueries: cfg.tuning.maxQueriesPerContext,
|
|
448
|
+
skipPrefilter: true,
|
|
449
|
+
})
|
|
450
|
+
: Promise.resolve({ chunks: [] }),
|
|
451
|
+
]);
|
|
452
|
+
|
|
453
|
+
// ── Build rerank state ────────────────────────────────────────────────
|
|
454
|
+
// pinnedItemIds = memory ∪ exact identifier pins ∪ user pins
|
|
455
|
+
const pinnedItemIds = new Set<string>([
|
|
456
|
+
...memoryPinnedItemIds,
|
|
457
|
+
...(function* () {
|
|
458
|
+
for (const s of exactPinsByContext.values()) yield* s;
|
|
459
|
+
})(),
|
|
460
|
+
...(function* () {
|
|
461
|
+
for (const s of userPinnedItemIdsByContext.values()) yield* s;
|
|
462
|
+
})(),
|
|
463
|
+
]);
|
|
464
|
+
// userPinnedItemIds = user pins only
|
|
465
|
+
const userPinnedItemIds = new Set<string>(
|
|
466
|
+
(function* () {
|
|
467
|
+
for (const s of userPinnedItemIdsByContext.values()) yield* s;
|
|
468
|
+
})(),
|
|
469
|
+
);
|
|
470
|
+
|
|
471
|
+
const rerankState: RerankState = {
|
|
472
|
+
pinnedItemIds,
|
|
473
|
+
userPinnedItemIds,
|
|
474
|
+
userRequestedPage,
|
|
475
|
+
keywords: updatedKeywords,
|
|
476
|
+
importantKeyword: updatedImportantKeyword,
|
|
477
|
+
};
|
|
478
|
+
|
|
479
|
+
// ── Phase 3: rerank main results ──────────────────────────────────────
|
|
480
|
+
result.steps.push({
|
|
481
|
+
stepNumber: 1,
|
|
482
|
+
text: `Reranking ${mainSearch.chunks.length} chunks`,
|
|
483
|
+
toolCalls: [],
|
|
484
|
+
chunks: [],
|
|
485
|
+
tokens: 0,
|
|
486
|
+
});
|
|
487
|
+
result.reasoning.push({ text: `Reranking ${mainSearch.chunks.length} chunks`, tools: [] });
|
|
488
|
+
yield { result: serializeOutput(result) };
|
|
489
|
+
|
|
490
|
+
const mainRerank = await rerankResults({
|
|
491
|
+
chunks: mainSearch.chunks,
|
|
492
|
+
query: updatedQuestion,
|
|
493
|
+
state: rerankState,
|
|
494
|
+
reranker,
|
|
495
|
+
tuning: {
|
|
496
|
+
topK: cfg.tuning.topK,
|
|
497
|
+
pinBoost: cfg.tuning.pinBoost,
|
|
498
|
+
identifierBoost: cfg.tuning.identifierBoost,
|
|
499
|
+
pageWindow: cfg.tuning.pageWindow,
|
|
500
|
+
},
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
result.steps.push({
|
|
504
|
+
stepNumber: 1,
|
|
505
|
+
text: "Results reranked",
|
|
506
|
+
toolCalls: [{ name: "reranker", id: "reranker", input: { query: updatedQuestion } }],
|
|
507
|
+
chunks: mainRerank.limited_results,
|
|
508
|
+
tokens: 0,
|
|
509
|
+
});
|
|
510
|
+
result.reasoning.push({ text: "Results reranked", tools: [] });
|
|
511
|
+
result.steps.push({
|
|
512
|
+
stepNumber: 1,
|
|
513
|
+
text: `Rerank_score min: ${mainRerank.sorted_reranked_results[mainRerank.sorted_reranked_results.length - 1]?.rerank_score || 0}, Rerank_score max: ${mainRerank.rerank_score_max_genuine}`,
|
|
514
|
+
toolCalls: [],
|
|
515
|
+
chunks: [],
|
|
516
|
+
tokens: 0,
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
// Accumulate main results (dedup by chunk_id, memory chunks already first)
|
|
520
|
+
addChunks(result, mainRerank.limited_results);
|
|
521
|
+
yield { result: serializeOutput(result) };
|
|
522
|
+
|
|
523
|
+
// ── Literal-lookup short-circuit ──────────────────────────────────────
|
|
524
|
+
const literalLookupSatisfied =
|
|
525
|
+
hasExplicitDocAndPage &&
|
|
526
|
+
mainRerank.limited_results.length > 0 &&
|
|
527
|
+
mainRerank.limited_results.some((r) => {
|
|
528
|
+
const p = (r.chunk_metadata as { page?: unknown } | undefined)?.page;
|
|
529
|
+
return (
|
|
530
|
+
typeof p === "number" &&
|
|
531
|
+
userRequestedPage !== null &&
|
|
532
|
+
Math.abs(p - userRequestedPage) <= cfg.tuning.pageWindow
|
|
533
|
+
);
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
if (literalLookupSatisfied) {
|
|
537
|
+
result.steps.push({
|
|
538
|
+
stepNumber: 1,
|
|
539
|
+
text: `Literal lookup satisfied (file pinned + page ${userRequestedPage} matched); skipping fallback search.`,
|
|
540
|
+
toolCalls: [],
|
|
541
|
+
chunks: [],
|
|
542
|
+
tokens: 0,
|
|
543
|
+
});
|
|
544
|
+
result.reasoning.push({ text: "Literal lookup satisfied; skipping fallback.", tools: [] });
|
|
545
|
+
yield { result: serializeOutput(result) };
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// ── Fallback gate ─────────────────────────────────────────────────────
|
|
549
|
+
if (
|
|
550
|
+
!literalLookupSatisfied &&
|
|
551
|
+
fallbackContexts.length > 0 &&
|
|
552
|
+
(reranker
|
|
553
|
+
? mainRerank.rerank_score_max_genuine < cfg.tuning.fallbackThreshold
|
|
554
|
+
: mainRerank.limited_results.length < cfg.tuning.topK)
|
|
555
|
+
) {
|
|
556
|
+
result.steps.push({
|
|
557
|
+
stepNumber: 1,
|
|
558
|
+
text: `Using fallback search in ${fallbackContexts.join(", ")}`,
|
|
559
|
+
toolCalls: [],
|
|
560
|
+
chunks: [],
|
|
561
|
+
tokens: 0,
|
|
562
|
+
});
|
|
563
|
+
result.reasoning.push({ text: `Fallback search in ${fallbackContexts.join(", ")}`, tools: [] });
|
|
564
|
+
yield { result: serializeOutput(result) };
|
|
565
|
+
|
|
566
|
+
const fallbackRerank = await rerankResults({
|
|
567
|
+
chunks: speculativeFallbackSearch.chunks,
|
|
568
|
+
query: updatedQuestion,
|
|
569
|
+
state: { ...rerankState, pinnedItemIds: new Set() },
|
|
570
|
+
reranker,
|
|
571
|
+
tuning: {
|
|
572
|
+
topK: cfg.tuning.topK,
|
|
573
|
+
pinBoost: cfg.tuning.pinBoost,
|
|
574
|
+
identifierBoost: cfg.tuning.identifierBoost,
|
|
575
|
+
pageWindow: cfg.tuning.pageWindow,
|
|
576
|
+
},
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
result.steps.push({
|
|
580
|
+
stepNumber: 1,
|
|
581
|
+
text: "Fallback results reranked",
|
|
582
|
+
toolCalls: [],
|
|
583
|
+
chunks: fallbackRerank.limited_results,
|
|
584
|
+
tokens: 0,
|
|
585
|
+
});
|
|
586
|
+
result.reasoning.push({ text: "Fallback results reranked", tools: [] });
|
|
587
|
+
addChunks(result, fallbackRerank.limited_results);
|
|
588
|
+
yield { result: serializeOutput(result) };
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// ── Phase 4: memory override directive ────────────────────────────────
|
|
592
|
+
if (memoryOverride.active) {
|
|
593
|
+
const authoritativeContent = memoryOverride.chunks
|
|
594
|
+
.map((c) => `${c.item_name}: ${c.chunk_content}`)
|
|
595
|
+
.join("\n\n");
|
|
596
|
+
const directive =
|
|
597
|
+
"⚠ VERIFIED ANSWER FROM COMPANY MEMORY — A curated, expert-verified memory entry has " +
|
|
598
|
+
"been confirmed to directly and sufficiently answer the user's question. Treat it as " +
|
|
599
|
+
"AUTHORITATIVE and build your answer on it: it takes precedence over the document " +
|
|
600
|
+
"excerpts above, even where they state something different. If a document differs " +
|
|
601
|
+
"materially, briefly note the manual's version (e.g. \"The manual states …\"), but " +
|
|
602
|
+
"lead with and defer to this verified answer.\n\nVerified answer:\n" +
|
|
603
|
+
authoritativeContent;
|
|
604
|
+
result.steps.push({
|
|
605
|
+
stepNumber: 1,
|
|
606
|
+
text: directive,
|
|
607
|
+
toolCalls: [],
|
|
608
|
+
chunks: memoryOverride.chunks,
|
|
609
|
+
tokens: 0,
|
|
610
|
+
});
|
|
611
|
+
result.reasoning.push({
|
|
612
|
+
text: "A verified company-memory entry directly answers the question; instructing the answer to treat it as authoritative over the documents.",
|
|
613
|
+
tools: [],
|
|
614
|
+
});
|
|
615
|
+
addChunks(result, memoryOverride.chunks);
|
|
616
|
+
yield { result: serializeOutput(result) };
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
if (cfg.logging) {
|
|
620
|
+
console.log("[EXULU pipeline] final result:", JSON.stringify({ steps: result.steps.length, chunks: result.chunks.length }));
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
return { result: serializeOutput(result) };
|
|
624
|
+
} catch (err) {
|
|
625
|
+
console.warn("[EXULU pipeline] retrieval pipeline failed:", err);
|
|
626
|
+
result.steps.push({
|
|
627
|
+
stepNumber: 1,
|
|
628
|
+
text: "Retrieval degraded due to an internal error — returning partial results.",
|
|
629
|
+
toolCalls: [],
|
|
630
|
+
chunks: [],
|
|
631
|
+
tokens: 0,
|
|
632
|
+
});
|
|
633
|
+
yield { result: serializeOutput(result) };
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
},
|
|
637
|
+
});
|
|
638
|
+
}
|