@exulu/backend 1.53.1 → 1.54.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.
@@ -0,0 +1,435 @@
1
+ import { z } from "zod";
2
+ import { tool } from "ai";
3
+ import type { ExuluContext } from "@SRC/exulu/context";
4
+ import { getTableName, getChunksTableName } from "@SRC/exulu/context";
5
+ import { postgresClient } from "@SRC/postgres/client";
6
+ import { applyFilters } from "@SRC/graphql/resolvers/apply-filters";
7
+ import { applyAccessControl } from "@SRC/graphql/utilities/access-control";
8
+ import { convertContextToTableDefinition } from "@SRC/graphql/utilities/convert-context-to-table-definition";
9
+ import type { SearchFilters } from "@SRC/graphql/types";
10
+ import type { VectorSearchChunkResult } from "@SRC/graphql/resolvers/vector-search";
11
+ import type { User } from "@EXULU_TYPES/models/user";
12
+ import type { ChunkResult } from "./types";
13
+
14
+ function buildContextEnum(contexts: ExuluContext[]) {
15
+ return z
16
+ .array(z.enum(contexts.map((c) => c.id) as [string, ...string[]]))
17
+ .describe(
18
+ contexts
19
+ .map(
20
+ (c) =>
21
+ `<knowledge_base id="${c.id}" name="${c.name}">${c.description}</knowledge_base>`,
22
+ )
23
+ .join("\n"),
24
+ );
25
+ }
26
+
27
+ function resolveContexts(
28
+ ids: string[],
29
+ all: ExuluContext[],
30
+ ): ExuluContext[] {
31
+ if (!ids?.length) return all;
32
+ return ids.map((id) => {
33
+ const ctx = all.find(
34
+ (c) => c.id === id || c.id.toLowerCase().includes(id.toLowerCase()),
35
+ );
36
+ if (!ctx) throw new Error(`Knowledge base not found: ${id}`);
37
+ return ctx;
38
+ });
39
+ }
40
+
41
+ function mapSearchMethod(method: "hybrid" | "keyword" | "semantic"): "hybridSearch" | "tsvector" | "cosineDistance" {
42
+ if (method === "hybrid") return "hybridSearch";
43
+ if (method === "keyword") return "tsvector";
44
+ return "cosineDistance";
45
+ }
46
+
47
+ export type RetrievalToolParams = {
48
+ contexts: ExuluContext[];
49
+ user?: User;
50
+ role?: string;
51
+ updateVirtualFiles: (files: Array<{ path: string; content: string }>) => Promise<void>;
52
+ };
53
+
54
+ /**
55
+ * Creates all pre-built retrieval tools. These are passed to the agent loop
56
+ * and filtered per strategy.
57
+ */
58
+ export function createRetrievalTools(params: RetrievalToolParams) {
59
+ const { contexts, user, role, updateVirtualFiles } = params;
60
+ const ctxEnum = buildContextEnum(contexts);
61
+
62
+ // ──────────────────────────────────────────────────────────
63
+ // count_items_or_chunks
64
+ // ──────────────────────────────────────────────────────────
65
+ const count_items_or_chunks = tool({
66
+ description:
67
+ "Count items or chunks WITHOUT loading them into context. Use for 'how many', 'count', or 'total number of' queries.",
68
+ inputSchema: z.object({
69
+ knowledge_base_ids: ctxEnum,
70
+ count_what: z
71
+ .enum(["items", "chunks"])
72
+ .describe("Whether to count items (documents) or chunks (pages/sections)"),
73
+ name_contains: z
74
+ .string()
75
+ .optional()
76
+ .describe("Only count items whose name contains this text (case-insensitive)"),
77
+ content_query: z
78
+ .string()
79
+ .optional()
80
+ .describe(
81
+ "Only count chunks matching this search query (uses hybrid search). Only used when count_what is 'chunks'.",
82
+ ),
83
+ }),
84
+ execute: async ({ knowledge_base_ids, count_what, name_contains, content_query }) => {
85
+ const { db } = await postgresClient();
86
+ const ctxList = resolveContexts(knowledge_base_ids, contexts);
87
+
88
+ const counts = await Promise.all(
89
+ ctxList.map(async (ctx) => {
90
+ let count = 0;
91
+
92
+ if (count_what === "items") {
93
+ const tableName = getTableName(ctx.id);
94
+ let q = db(tableName).count("id as count").whereNull("archived");
95
+ if (name_contains) {
96
+ q = q.whereRaw("LOWER(name) LIKE ?", [`%${name_contains.toLowerCase()}%`]);
97
+ }
98
+ const tableDefinition = convertContextToTableDefinition(ctx);
99
+ q = applyAccessControl(tableDefinition, q, user, tableName);
100
+ const result = await q.first();
101
+ count = Number(result?.count ?? 0);
102
+ } else {
103
+ const chunksTable = getChunksTableName(ctx.id);
104
+ if (content_query) {
105
+ const searchResults = await ctx.search({
106
+ query: content_query,
107
+ method: "hybridSearch",
108
+ limit: 10000,
109
+ page: 1,
110
+ itemFilters: [],
111
+ chunkFilters: [],
112
+ sort: { field: "updatedAt", direction: "desc" },
113
+ user,
114
+ role,
115
+ trigger: "tool",
116
+ });
117
+ count = searchResults.chunks.length;
118
+ } else {
119
+ const result = await db(chunksTable).count("id as count").first();
120
+ count = Number(result?.count ?? 0);
121
+ }
122
+ }
123
+
124
+ return { context: ctx.id, context_name: ctx.name, count };
125
+ }),
126
+ );
127
+
128
+ return JSON.stringify({
129
+ total_count: counts.reduce((s, c) => s + c.count, 0),
130
+ breakdown_by_context: counts,
131
+ });
132
+ },
133
+ });
134
+
135
+ // ──────────────────────────────────────────────────────────
136
+ // search_items_by_name
137
+ // ──────────────────────────────────────────────────────────
138
+ const search_items_by_name = tool({
139
+ description:
140
+ "Search for items by their name or external ID. Use only when the user is asking for documents BY TITLE, not by content topic.",
141
+ inputSchema: z.object({
142
+ knowledge_base_ids: ctxEnum,
143
+ item_name: z.string().describe("The name or partial name to search for"),
144
+ limit: z
145
+ .number()
146
+ .default(100)
147
+ .describe(
148
+ "Max items per knowledge base (max 400). Applies independently to each knowledge base.",
149
+ ),
150
+ }),
151
+ execute: async ({ item_name, limit, knowledge_base_ids }) => {
152
+ const { db } = await postgresClient();
153
+ const ctxList = resolveContexts(knowledge_base_ids, contexts);
154
+ const safeLimit = Math.min(limit ?? 100, 400);
155
+ const itemFilters: SearchFilters = item_name ? [{ name: { contains: item_name } }] : [];
156
+
157
+ const results = await Promise.all(
158
+ ctxList.map(async (ctx) => {
159
+ const tableName = getTableName(ctx.id);
160
+ const tableDefinition = convertContextToTableDefinition(ctx);
161
+
162
+ let q = db(`${tableName} as items`).select([
163
+ "items.id as item_id",
164
+ "items.name as item_name",
165
+ "items.external_id as item_external_id",
166
+ db.raw('items."updatedAt" as item_updated_at'),
167
+ db.raw('items."createdAt" as item_created_at'),
168
+ ...ctx.fields.map((f) => `items.${f.name} as ${f.name}`),
169
+ ]);
170
+ q = q.limit(safeLimit);
171
+ q = applyFilters(q, itemFilters, tableDefinition, "items");
172
+ q = applyAccessControl(tableDefinition, q, user, "items");
173
+ const items = await q;
174
+
175
+ return Promise.all(
176
+ items.map(async (item) => {
177
+ const chunksTable = getChunksTableName(ctx.id);
178
+ const chunks = await db(chunksTable)
179
+ .select(["id", "source", "metadata"])
180
+ .where("source", item.item_id)
181
+ .limit(1);
182
+
183
+ if (!chunks[0]) return null;
184
+ return {
185
+ item_name: item.item_name,
186
+ item_id: item.item_id,
187
+ context: ctx.id,
188
+ chunk_id: chunks[0].id,
189
+ chunk_index: 1,
190
+ metadata: chunks[0].metadata,
191
+ } satisfies ChunkResult;
192
+ }),
193
+ );
194
+ }),
195
+ );
196
+
197
+ return JSON.stringify(results.flat().filter(Boolean));
198
+ },
199
+ });
200
+
201
+ // ──────────────────────────────────────────────────────────
202
+ // search_content
203
+ // ──────────────────────────────────────────────────────────
204
+ const search_content = tool({
205
+ description: `Search across document content using hybrid, keyword, or semantic search.
206
+
207
+ Use includeContent: false when you only need to know WHICH documents match (listing, overview, navigation).
208
+ Use includeContent: true when you need the ACTUAL text to answer a question.
209
+
210
+ For listing queries: always start with includeContent: false, then use dynamic tools to fetch specific pages.`,
211
+ inputSchema: z.object({
212
+ query: z.string().describe("Search query about the content you're looking for"),
213
+ knowledge_base_ids: ctxEnum,
214
+ keywords: z.array(z.string()).optional().describe("Keywords extracted from the query"),
215
+ searchMethod: z
216
+ .enum(["hybrid", "keyword", "semantic"])
217
+ .default("hybrid")
218
+ .describe(
219
+ "hybrid: best default (semantic + keyword). keyword: exact terms, product codes, IDs. semantic: conceptual/synonyms.",
220
+ ),
221
+ includeContent: z
222
+ .boolean()
223
+ .default(true)
224
+ .describe(
225
+ "false: returns metadata only (document names, scores) — use for listing/navigation. " +
226
+ "true: returns full chunk text — use when you need content to answer a question.",
227
+ ),
228
+ item_ids: z.array(z.string()).optional().describe("Filter results to specific item IDs"),
229
+ item_names: z
230
+ .array(z.string())
231
+ .optional()
232
+ .describe("Filter results to items whose name contains one of these strings"),
233
+ item_external_ids: z
234
+ .array(z.string())
235
+ .optional()
236
+ .describe("Filter results to specific external IDs"),
237
+ limit: z
238
+ .number()
239
+ .default(10)
240
+ .describe("Max chunks with content (max 10). Without content, up to 200 are returned."),
241
+ }),
242
+ execute: async ({
243
+ query,
244
+ knowledge_base_ids,
245
+ keywords,
246
+ searchMethod,
247
+ includeContent,
248
+ item_ids,
249
+ item_names,
250
+ item_external_ids,
251
+ limit,
252
+ }) => {
253
+ const ctxList = resolveContexts(knowledge_base_ids, contexts);
254
+ const effectiveLimit = includeContent ? Math.min(limit ?? 10, 10) : Math.min((limit ?? 10) * 20, 400);
255
+
256
+ const results = await Promise.all(
257
+ ctxList.map(async (ctx) => {
258
+ const itemFilters: SearchFilters = [];
259
+ if (item_ids) itemFilters.push({ id: { in: item_ids } });
260
+ if (item_names)
261
+ itemFilters.push({ name: { or: item_names.map((n) => ({ contains: n })) } });
262
+ if (item_external_ids) itemFilters.push({ external_id: { in: item_external_ids } });
263
+
264
+ const effectiveQuery = query || keywords?.join(" ") || "";
265
+
266
+ let method = mapSearchMethod(searchMethod ?? "hybrid")
267
+
268
+ if (
269
+ method === "hybridSearch" ||
270
+ method === "cosineDistance"
271
+ ) {
272
+ if (!ctx.embedder) {
273
+ console.error(`[EXULU] context "${ctx.id}" does not have an embedder, falling back to tsvector search`);
274
+ method = "tsvector"
275
+ }
276
+ }
277
+
278
+ try {
279
+ const { chunks } = await ctx.search({
280
+ query: effectiveQuery,
281
+ keywords,
282
+ method: method,
283
+ limit: effectiveLimit,
284
+ page: 1,
285
+ itemFilters,
286
+ chunkFilters: [],
287
+ sort: { field: "updatedAt", direction: "desc" },
288
+ user,
289
+ role,
290
+ trigger: "tool",
291
+ });
292
+
293
+ return chunks.map(
294
+ (chunk): ChunkResult => ({
295
+ item_name: chunk.item_name,
296
+ item_id: chunk.item_id,
297
+ context: chunk.context?.id ?? ctx.id,
298
+ chunk_id: chunk.chunk_id,
299
+ chunk_index: chunk.chunk_index,
300
+ chunk_content: includeContent ? chunk.chunk_content : undefined,
301
+ metadata: {
302
+ ...chunk.chunk_metadata,
303
+ cosine_distance: chunk.chunk_cosine_distance,
304
+ fts_rank: chunk.chunk_fts_rank,
305
+ hybrid_score: chunk.chunk_hybrid_score,
306
+ },
307
+ }),
308
+ );
309
+ } catch (err) {
310
+ console.error(`[EXULU] search_content failed for context "${ctx.id}":`, err);
311
+ return [];
312
+ }
313
+ }),
314
+ );
315
+
316
+ return JSON.stringify(results.flat());
317
+ },
318
+ });
319
+
320
+ // ──────────────────────────────────────────────────────────
321
+ // save_search_results
322
+ // ──────────────────────────────────────────────────────────
323
+ const save_search_results = tool({
324
+ description: `Execute a search and save ALL results to the virtual filesystem WITHOUT loading them into context.
325
+
326
+ Use this when you expect many results (>20) and need to filter iteratively:
327
+ 1. Call save_search_results to save up to 1000 results to /search_results.txt
328
+ 2. Use bash grep/awk to identify relevant chunks by pattern
329
+ 3. Use dynamic get_content tools to load only the specific chunks you need
330
+
331
+ The saved file format:
332
+ ### RESULT N ###
333
+ ITEM_NAME: ...
334
+ ITEM_ID: ...
335
+ CHUNK_ID: ...
336
+ CHUNK_INDEX: ...
337
+ CONTEXT: ...
338
+ SCORE: ...
339
+ ---CONTENT START---
340
+ (content or placeholder)
341
+ ---CONTENT END---`,
342
+ inputSchema: z.object({
343
+ knowledge_base_ids: ctxEnum,
344
+ query: z.string().describe("Search query"),
345
+ searchMethod: z.enum(["hybrid", "keyword", "semantic"]).default("hybrid"),
346
+ limit: z
347
+ .number()
348
+ .max(1000)
349
+ .default(100)
350
+ .describe("Max results to save (max 1000)"),
351
+ includeContent: z
352
+ .boolean()
353
+ .default(true)
354
+ .describe(
355
+ "Whether to include chunk text in the saved file. False saves tokens — use true only if you need to grep content.",
356
+ ),
357
+ }),
358
+ execute: async ({ query, knowledge_base_ids, searchMethod, limit, includeContent }) => {
359
+ const ctxList = resolveContexts(knowledge_base_ids, contexts);
360
+
361
+ const results = await Promise.all(
362
+ ctxList.map(async (ctx) => {
363
+ try {
364
+ const { chunks } = await ctx.search({
365
+ query,
366
+ method: mapSearchMethod(searchMethod ?? "hybrid"),
367
+ limit: Math.min(limit ?? 100, 1000),
368
+ page: 1,
369
+ itemFilters: [],
370
+ chunkFilters: [],
371
+ sort: { field: "updatedAt", direction: "desc" },
372
+ user,
373
+ role,
374
+ trigger: "tool",
375
+ });
376
+ return chunks;
377
+ } catch (err) {
378
+ console.error(`[EXULU] save_search_results failed for context "${ctx.id}":`, err);
379
+ return [];
380
+ }
381
+ }),
382
+ );
383
+
384
+ const chunks: VectorSearchChunkResult[] = results.flat();
385
+
386
+ const fileContent = chunks
387
+ .map(
388
+ (chunk, i) =>
389
+ `### RESULT ${i + 1} ###\n` +
390
+ `ITEM_NAME: ${chunk.item_name}\n` +
391
+ `ITEM_ID: ${chunk.item_id}\n` +
392
+ `CHUNK_ID: ${chunk.chunk_id}\n` +
393
+ `CHUNK_INDEX: ${chunk.chunk_index}\n` +
394
+ `CONTEXT: ${chunk.context?.id ?? ""}\n` +
395
+ `SCORE: ${chunk.chunk_hybrid_score ?? chunk.chunk_fts_rank ?? chunk.chunk_cosine_distance ?? 0}\n` +
396
+ `---CONTENT START---\n` +
397
+ `${includeContent && chunk.chunk_content ? chunk.chunk_content : "[use includeContent: true or get_content tool to load]"}\n` +
398
+ `---CONTENT END---\n`,
399
+ )
400
+ .join("\n");
401
+
402
+ await updateVirtualFiles([
403
+ { path: "search_results.txt", content: fileContent },
404
+ {
405
+ path: "search_metadata.json",
406
+ content: JSON.stringify({
407
+ query,
408
+ timestamp: new Date().toISOString(),
409
+ results_count: chunks.length,
410
+ contexts: ctxList.map((c) => c.id),
411
+ method: searchMethod,
412
+ }),
413
+ },
414
+ ]);
415
+
416
+ return JSON.stringify({
417
+ success: true,
418
+ results_count: chunks.length,
419
+ message: `Saved ${chunks.length} results to /search_results.txt`,
420
+ grep_examples: [
421
+ "grep -i 'keyword' search_results.txt | head -20",
422
+ "grep 'ITEM_NAME:' search_results.txt",
423
+ "grep -B 5 'pattern' search_results.txt | grep 'CHUNK_ID:'",
424
+ ],
425
+ });
426
+ },
427
+ });
428
+
429
+ return {
430
+ count_items_or_chunks,
431
+ search_items_by_name,
432
+ search_content,
433
+ save_search_results,
434
+ };
435
+ }
@@ -0,0 +1,96 @@
1
+ import * as fs from "fs/promises";
2
+ import * as path from "path";
3
+ import type { AgenticRetrievalOutput, ClassificationResult } from "./types";
4
+
5
+ /**
6
+ * Module-level registry so external callers (e.g. test scripts) can read
7
+ * the path of the most recently saved trajectory file.
8
+ * Works because both the trajectory logger and the test run in the same process.
9
+ */
10
+ export const trajectoryRegistry = {
11
+ lastFile: undefined as string | undefined,
12
+ };
13
+
14
+ interface TrajectoryData {
15
+ timestamp: string;
16
+ query: string;
17
+ classification: ClassificationResult;
18
+ steps: {
19
+ step_number: number;
20
+ text: string;
21
+ tool_calls: { name: string; id: string; input: any }[];
22
+ chunks_retrieved: number;
23
+ dynamic_tools_created: string[];
24
+ tokens: number;
25
+ }[];
26
+ final: {
27
+ total_chunks: number;
28
+ total_steps: number;
29
+ total_tokens: number;
30
+ duration_ms: number;
31
+ success: boolean;
32
+ error?: string;
33
+ };
34
+ }
35
+
36
+ export class TrajectoryLogger {
37
+ private data: TrajectoryData;
38
+ private startTime = Date.now();
39
+ private logDir: string;
40
+
41
+ constructor(
42
+ query: string,
43
+ classification: ClassificationResult,
44
+ logDir = path.join(process.cwd(), "ee/agentic-retrieval/logs"),
45
+ ) {
46
+ this.logDir = logDir;
47
+ this.data = {
48
+ timestamp: new Date().toISOString(),
49
+ query,
50
+ classification,
51
+ steps: [],
52
+ final: {
53
+ total_chunks: 0,
54
+ total_steps: 0,
55
+ total_tokens: 0,
56
+ duration_ms: 0,
57
+ success: false,
58
+ },
59
+ };
60
+ }
61
+
62
+ recordStep(step: AgenticRetrievalOutput["steps"][0]): void {
63
+ this.data.steps.push({
64
+ step_number: step.stepNumber,
65
+ text: step.text,
66
+ tool_calls: step.toolCalls,
67
+ chunks_retrieved: step.chunks.length,
68
+ dynamic_tools_created: step.dynamicToolsCreated,
69
+ tokens: step.tokens,
70
+ });
71
+ }
72
+
73
+ async finalize(output: AgenticRetrievalOutput, success: boolean, error?: Error): Promise<string | undefined> {
74
+ this.data.final = {
75
+ total_chunks: output.chunks.length,
76
+ total_steps: output.steps.length,
77
+ total_tokens: output.totalTokens,
78
+ duration_ms: Date.now() - this.startTime,
79
+ success,
80
+ error: error?.message,
81
+ };
82
+
83
+ try {
84
+ await fs.mkdir(this.logDir, { recursive: true });
85
+ const filename = `trajectory_${Date.now()}.json`;
86
+ const fullPath = path.join(this.logDir, filename);
87
+ await fs.writeFile(fullPath, JSON.stringify(this.data, null, 2), "utf-8");
88
+ console.log(`[EXULU] v3 trajectory saved: ${filename}`);
89
+ trajectoryRegistry.lastFile = fullPath;
90
+ return fullPath;
91
+ } catch (e) {
92
+ console.error("[EXULU] v3 failed to write trajectory:", e);
93
+ return undefined;
94
+ }
95
+ }
96
+ }
@@ -0,0 +1,59 @@
1
+ export type QueryType = "aggregate" | "list" | "targeted" | "exploratory";
2
+
3
+ export interface ClassificationResult {
4
+ queryType: QueryType;
5
+ language: string;
6
+ /** IDs of contexts most likely relevant. Empty means search all. */
7
+ suggestedContextIds: string[];
8
+ }
9
+
10
+ export interface ContextSample {
11
+ contextId: string;
12
+ contextName: string;
13
+ /** All field names available on items (standard + custom) */
14
+ fields: string[];
15
+ /** Up to 2 example item records */
16
+ exampleItems: Array<Record<string, any>>;
17
+ sampledAt: number;
18
+ }
19
+
20
+ export interface ChunkResult {
21
+ item_name: string;
22
+ item_id: string;
23
+ context: string;
24
+ chunk_id?: string;
25
+ chunk_index?: number;
26
+ chunk_content?: string;
27
+ metadata?: Record<string, any>;
28
+ }
29
+
30
+ export interface RetrievalStep {
31
+ stepNumber: number;
32
+ /** Text the model output during this step (reasoning) */
33
+ text: string;
34
+ toolCalls: Array<{ name: string; id: string; input: any }>;
35
+ chunks: ChunkResult[];
36
+ dynamicToolsCreated: string[];
37
+ tokens: number;
38
+ }
39
+
40
+ interface Reasoning {
41
+ text: string;
42
+ tools: {
43
+ name: string;
44
+ id: string;
45
+ input: any;
46
+ output: any;
47
+ }[]
48
+ }
49
+
50
+ export interface AgenticRetrievalOutput {
51
+ steps: RetrievalStep[];
52
+ reasoning: Reasoning[];
53
+ /** All chunks collected across all steps */
54
+ chunks: ChunkResult[];
55
+ usage: any[];
56
+ totalTokens: number;
57
+ /** Path to the trajectory JSON file written to disk, if any */
58
+ trajectoryFile?: string;
59
+ }