@exulu/backend 1.54.0 → 1.56.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.
@@ -1,76 +0,0 @@
1
- import type { ExuluContext } from "@SRC/exulu/context";
2
- import type { User } from "@EXULU_TYPES/models/user";
3
-
4
- /**
5
- * Finds embed('text') or embed('text', 'contextId') calls in a SQL string,
6
- * generates the embedding vectors using the appropriate context's embedder,
7
- * and substitutes them with ARRAY[...]::vector literals so db.raw() can execute it.
8
- *
9
- * Examples:
10
- * embed('machine learning') → uses first context that has an embedder
11
- * embed('machine learning', 'ctx1') → uses the embedder from context 'ctx1'
12
- */
13
- export async function preprocessEmbedCalls(
14
- sql: string,
15
- contexts: ExuluContext[],
16
- user?: User,
17
- role?: string,
18
- ): Promise<string> {
19
- // Match embed('...') or embed('...', 'contextId')
20
- // We use a global regex but process matches manually so we can await async calls
21
- const EMBED_RE = /embed\('((?:[^'\\]|\\.)*)'\s*(?:,\s*'((?:[^'\\]|\\.)*)')?\)/gi;
22
-
23
- const matches: { fullMatch: string; text: string; contextId?: string; index: number }[] = [];
24
-
25
- let m: RegExpExecArray | null;
26
- while ((m = EMBED_RE.exec(sql)) !== null) {
27
- matches.push({
28
- fullMatch: m[0],
29
- text: m[1],
30
- contextId: m[2] || undefined,
31
- index: m.index,
32
- });
33
- }
34
-
35
- if (matches.length === 0) return sql;
36
-
37
- // Generate all embeddings in parallel
38
- const substitutions = await Promise.all(
39
- matches.map(async ({ text, contextId }) => {
40
- const context = contextId
41
- ? contexts.find((c) => c.id === contextId)
42
- : contexts.find((c) => c.embedder != null);
43
-
44
- if (!context?.embedder) {
45
- throw new Error(
46
- `No embedder available${contextId ? ` for context "${contextId}"` : ""}. ` +
47
- `Available contexts with embedders: [${contexts.filter((c) => c.embedder).map((c) => c.id).join(", ")}]`,
48
- );
49
- }
50
-
51
- const result = await context.embedder.generateFromQuery(
52
- context.id,
53
- text,
54
- undefined,
55
- (user as any)?.id,
56
- role,
57
- );
58
-
59
- const vector = result?.chunks?.[0]?.vector;
60
- if (!vector?.length) {
61
- throw new Error(`Embedder returned no vector for text: "${text}"`);
62
- }
63
-
64
- return `ARRAY[${vector.join(",")}]::vector`;
65
- }),
66
- );
67
-
68
- // Replace in reverse order so indices stay valid
69
- let result = sql;
70
- for (let i = matches.length - 1; i >= 0; i--) {
71
- const { fullMatch, index } = matches[i];
72
- result = result.slice(0, index) + substitutions[i] + result.slice(index + fullMatch.length);
73
- }
74
-
75
- return result;
76
- }
@@ -1,248 +0,0 @@
1
- import { getTableName, getChunksTableName, type ExuluContext } from "@SRC/exulu/context";
2
-
3
- /**
4
- * Builds the system prompt for the V4 observe-infer-act retrieval agent.
5
- *
6
- * The prompt includes:
7
- * 1. The observe-infer-act loop philosophy
8
- * 2. The full database schema for every available context
9
- * 3. Common SQL query patterns (keyword, semantic, hybrid, aggregation)
10
- * 4. Instructions on when/how to use grep for large result sets
11
- * 5. The standard column alias convention the agent should follow
12
- */
13
- export function buildSystemPrompt(
14
- contexts: ExuluContext[],
15
- customInstructions?: string,
16
- ): string {
17
- const schemaBlock = buildSchemaBlock(contexts);
18
- const hasEmbedder = contexts.some((c) => c.embedder != null);
19
-
20
- return `\
21
- You are a knowledge base retrieval agent. Your job is to find all information relevant to the user's query.
22
-
23
- ## Approach: Observe → Infer → Act
24
-
25
- Work iteratively:
26
- 1. **Observe** — examine what data you have and what the query asks for
27
- 2. **Infer** — decide what SQL query will best surface relevant information
28
- 3. **Act** — execute the query and study the results
29
- 4. Repeat until you have found sufficient information, then write your final answer.
30
-
31
- Do NOT guess or hallucinate. If results are empty, try alternative queries (different keywords,
32
- broader filters, semantic search). Exhaust the available search strategies before concluding
33
- that no relevant data exists.
34
-
35
- ---
36
-
37
- ## Database Schema
38
-
39
- ${schemaBlock}
40
-
41
- ---
42
-
43
- ## Query Patterns
44
-
45
- ### Keyword / Full-Text Search
46
- \`\`\`sql
47
- SELECT
48
- c.id AS chunk_id,
49
- c.chunk_index,
50
- c.content AS chunk_content,
51
- c.metadata,
52
- c.source AS item_id,
53
- i.name AS item_name,
54
- '<context_id>' AS context
55
- FROM <context_id>_chunks c
56
- JOIN <context_id>_items i ON c.source = i.id
57
- WHERE c.fts @@ plainto_tsquery('english', 'your search terms')
58
- AND (i.archived IS FALSE OR i.archived IS NULL)
59
- ORDER BY ts_rank(c.fts, plainto_tsquery('english', 'your search terms')) DESC
60
- LIMIT 20;
61
- \`\`\`
62
-
63
- For German text use \`'german'\` instead of \`'english'\`.
64
- For multi-language, use \`websearch_to_tsquery\` or UNION both languages.
65
- ${
66
- hasEmbedder
67
- ? `
68
- ### Semantic Search (use embed() helper)
69
- \`\`\`sql
70
- SELECT
71
- c.id AS chunk_id,
72
- c.chunk_index,
73
- c.content AS chunk_content,
74
- c.metadata,
75
- c.source AS item_id,
76
- i.name AS item_name,
77
- '<context_id>' AS context,
78
- c.embedding <=> embed('your concept here') AS distance
79
- FROM <context_id>_chunks c
80
- JOIN <context_id>_items i ON c.source = i.id
81
- WHERE (i.archived IS FALSE OR i.archived IS NULL)
82
- ORDER BY distance ASC
83
- LIMIT 20;
84
- \`\`\`
85
-
86
- ### Hybrid Search (keyword + semantic combined via RRF)
87
- \`\`\`sql
88
- WITH fts AS (
89
- SELECT id, ROW_NUMBER() OVER (ORDER BY ts_rank(fts, q) DESC) AS rank
90
- FROM <context_id>_chunks, plainto_tsquery('english', 'your query') q
91
- WHERE fts @@ q
92
- LIMIT 500
93
- ),
94
- sem AS (
95
- SELECT id, ROW_NUMBER() OVER (ORDER BY embedding <=> embed('your query') ASC) AS rank
96
- FROM <context_id>_chunks
97
- LIMIT 500
98
- ),
99
- rrf AS (
100
- SELECT
101
- COALESCE(fts.id, sem.id) AS id,
102
- (COALESCE(1.0 / (50 + fts.rank), 0) * 2 + COALESCE(1.0 / (50 + sem.rank), 0)) AS score
103
- FROM fts FULL OUTER JOIN sem ON fts.id = sem.id
104
- )
105
- SELECT
106
- c.id AS chunk_id,
107
- c.chunk_index,
108
- c.content AS chunk_content,
109
- c.metadata,
110
- c.source AS item_id,
111
- i.name AS item_name,
112
- '<context_id>' AS context,
113
- rrf.score
114
- FROM rrf
115
- JOIN <context_id>_chunks c ON c.id = rrf.id
116
- JOIN <context_id>_items i ON c.source = i.id
117
- WHERE (i.archived IS FALSE OR i.archived IS NULL)
118
- ORDER BY rrf.score DESC
119
- LIMIT 20;
120
- \`\`\`
121
- `
122
- : `
123
- Note: No embedder is configured for these contexts. Use keyword/full-text search only.
124
- `
125
- }
126
- ### Browse all chunks of a specific document (in order)
127
- \`\`\`sql
128
- SELECT
129
- c.id AS chunk_id,
130
- c.chunk_index,
131
- c.content AS chunk_content,
132
- c.metadata,
133
- c.source AS item_id,
134
- i.name AS item_name,
135
- '<context_id>' AS context
136
- FROM <context_id>_chunks c
137
- JOIN <context_id>_items i ON c.source = i.id
138
- WHERE c.source = '<item_id>'
139
- ORDER BY c.chunk_index;
140
- \`\`\`
141
-
142
- ### Count / aggregate
143
- \`\`\`sql
144
- SELECT COUNT(*) FROM <context_id>_items WHERE archived IS FALSE;
145
- SELECT COUNT(*) FROM <context_id>_chunks;
146
- \`\`\`
147
-
148
- ### Explore item names (when query is about a specific document)
149
- \`\`\`sql
150
- SELECT id, name, external_id, "createdAt"
151
- FROM <context_id>_items
152
- WHERE (archived IS FALSE OR archived IS NULL)
153
- AND LOWER(name) LIKE '%keyword%'
154
- LIMIT 50;
155
- \`\`\`
156
-
157
- ### Filter by custom metadata on chunks
158
- \`\`\`sql
159
- SELECT chunk_id, chunk_content, item_name, context
160
- FROM ...
161
- WHERE c.metadata->>'page' = '5'
162
- OR c.metadata @> '{"category": "finance"}'
163
- \`\`\`
164
-
165
- ---
166
-
167
- ## Column Alias Convention
168
-
169
- **Always use these aliases** in queries that return chunks so results are collected correctly:
170
-
171
- | Alias | Source column |
172
- |----------------|-------------------------|
173
- | \`chunk_id\` | \`c.id\` |
174
- | \`chunk_index\` | \`c.chunk_index\` |
175
- | \`chunk_content\`| \`c.content\` |
176
- | \`item_id\` | \`c.source\` |
177
- | \`item_name\` | \`i.name\` |
178
- | \`context\` | literal context id string |
179
- | \`metadata\` | \`c.metadata\` |
180
-
181
- ---
182
-
183
- ## Handling Large Results
184
-
185
- When execute_query returns a file path (results > 20k chars):
186
- 1. Use \`grep\` with a specific pattern to find relevant sections
187
- 2. Multiple grep calls are fine — narrow down iteratively
188
- 3. Once you know specific \`item_id\` or \`chunk_id\` values, run a targeted SELECT to get full content
189
-
190
- ---
191
-
192
- ## Search Strategy
193
-
194
- - **Start broad**: use keyword or hybrid search with your main terms, LIMIT 30–50
195
- - **Go deeper**: if results are sparse, try alternative phrasings, synonyms, or semantic search
196
- - **Drill into documents**: once you find a relevant item, fetch its chunks in order to get full context
197
- - **Cross-context**: search multiple contexts when the query could span knowledge bases
198
- - **Aggregate last**: use COUNT queries only for "how many" questions
199
-
200
- ---
201
- ${customInstructions ? `## Additional Instructions\n\n${customInstructions}\n\n---\n` : ""}
202
- When you have gathered sufficient information, write a clear answer. Do not call any more tools once you have what you need.`;
203
- }
204
-
205
- function buildSchemaBlock(contexts: ExuluContext[]): string {
206
- return contexts
207
- .map((ctx) => {
208
- const itemsTable = getTableName(ctx.id);
209
- const chunksTable = getChunksTableName(ctx.id);
210
-
211
- const customFields =
212
- ctx.fields.length > 0
213
- ? ctx.fields.map((f) => ` ${f.name} (${f.type})`).join("\n")
214
- : " (no custom fields)";
215
-
216
- const embedderNote = ctx.embedder
217
- ? `Embedder: ${ctx.embedder.name} — semantic search and embed() are available`
218
- : "No embedder — use keyword search only";
219
-
220
- return `### Context: "${ctx.name}" (id: \`${ctx.id}\`)
221
- ${ctx.description || ""}
222
- ${embedderNote}
223
-
224
- **${itemsTable}** — documents / items
225
- id (uuid, primary key)
226
- name (text)
227
- external_id (text, nullable)
228
- archived (boolean, nullable)
229
- created_by (integer, nullable)
230
- rights_mode (text, nullable)
231
- "createdAt" (timestamp)
232
- "updatedAt" (timestamp)
233
- -- Custom fields:
234
- ${customFields}
235
-
236
- **${chunksTable}** — text chunks (source FK → ${itemsTable}.id)
237
- id (uuid, primary key)
238
- source (uuid, FK → ${itemsTable}.id)
239
- content (text)
240
- chunk_index (integer)
241
- fts (tsvector — full-text search index)
242
- embedding (vector — pgvector, nullable)
243
- metadata (jsonb, nullable)
244
- "createdAt" (timestamp)
245
- "updatedAt" (timestamp)`;
246
- })
247
- .join("\n\n");
248
- }
@@ -1,241 +0,0 @@
1
- import * as fs from "fs/promises";
2
- import * as path from "path";
3
- import { exec } from "child_process";
4
- import { promisify } from "util";
5
- import { z } from "zod";
6
- import { tool } from "ai";
7
- import { postgresClient } from "@SRC/postgres/client";
8
- import type { ExuluContext } from "@SRC/exulu/context";
9
- import type { User } from "@EXULU_TYPES/models/user";
10
- import { preprocessEmbedCalls } from "./embed-preprocessor";
11
- import type { ChunkResult } from "./types";
12
-
13
- const execAsync = promisify(exec);
14
-
15
- const MAX_INLINE_CHARS = 20_000;
16
- const MAX_GREP_OUTPUT_CHARS = 5_000;
17
-
18
- // ──────────────────────────────────────────────────────────────────────────────
19
- // SQL safety: only allow read-only statements
20
- // ──────────────────────────────────────────────────────────────────────────────
21
-
22
- const WRITE_PATTERN =
23
- /^\s*(INSERT|UPDATE|DELETE|DROP|CREATE|ALTER|TRUNCATE|GRANT|REVOKE|VACUUM|ANALYZE|EXPLAIN\s+ANALYZE)\b/i;
24
-
25
- function assertReadOnly(sql: string): void {
26
- if (WRITE_PATTERN.test(sql)) {
27
- throw new Error(
28
- "Only SELECT queries are allowed. Write operations (INSERT, UPDATE, DELETE, DROP, etc.) are not permitted.",
29
- );
30
- }
31
- }
32
-
33
- // ──────────────────────────────────────────────────────────────────────────────
34
- // Chunk harvesting: extract ChunkResult objects from raw SQL result rows
35
- // ──────────────────────────────────────────────────────────────────────────────
36
-
37
- /**
38
- * Tries to interpret a raw DB row as a ChunkResult.
39
- * The system prompt instructs the agent to use standard aliases, so we look for
40
- * those first and fall back to common alternative column names.
41
- */
42
- export function rowToChunkResult(row: Record<string, any>): ChunkResult | null {
43
- const chunkId = row.chunk_id ?? row.id;
44
- const chunkContent = row.chunk_content ?? row.content;
45
- const itemId = row.item_id ?? row.source;
46
- const context = row.context ?? row.context_id;
47
- const itemName = row.item_name ?? row.name;
48
-
49
- // Require at minimum a chunk identifier and either content or an item reference
50
- if (!chunkId || (!chunkContent && !itemId)) return null;
51
-
52
- return {
53
- item_name: itemName ?? "",
54
- item_id: itemId ?? "",
55
- context: context ?? "",
56
- chunk_id: chunkId,
57
- chunk_index: row.chunk_index ?? undefined,
58
- chunk_content: chunkContent ?? undefined,
59
- metadata: row.metadata ?? row.chunk_metadata ?? undefined,
60
- };
61
- }
62
-
63
- // ──────────────────────────────────────────────────────────────────────────────
64
- // Tool factory
65
- // ──────────────────────────────────────────────────────────────────────────────
66
-
67
- export type ToolFactoryParams = {
68
- contexts: ExuluContext[];
69
- user?: User;
70
- role?: string;
71
- sessionDir: string;
72
- };
73
-
74
- // eslint-disable-next-line @typescript-eslint/explicit-function-return-type
75
- export function createTools(params: ToolFactoryParams) {
76
- const { contexts, user, role, sessionDir } = params;
77
- let queryCount = 0;
78
-
79
- // ── execute_query ────────────────────────────────────────────────────────────
80
-
81
- const execute_query = tool({
82
- description: `Execute a read-only PostgreSQL SELECT query against the knowledge base.
83
-
84
- Use this to search, filter, aggregate, and explore content. The database contains items
85
- and chunks tables for each knowledge base (see schema in the system prompt).
86
-
87
- Use embed('your text') anywhere in the query to generate a semantic search vector:
88
- embedding <=> embed('machine learning') AS distance
89
-
90
- If the result exceeds ${(MAX_INLINE_CHARS / 1000).toFixed(0)}k characters it is saved to a file.
91
- Use the grep tool to iteratively search the file for relevant information.`,
92
- inputSchema: z.object({
93
- sql: z.string().describe("A read-only SELECT (or WITH ... SELECT) PostgreSQL query"),
94
- }),
95
- execute: async ({ sql }) => {
96
- assertReadOnly(sql);
97
-
98
- let processedSql: string;
99
- try {
100
- processedSql = await preprocessEmbedCalls(sql, contexts, user, role);
101
- } catch (err: any) {
102
- return JSON.stringify({ error: `embed() preprocessing failed: ${err.message}` });
103
- }
104
-
105
- let rows: any[];
106
- try {
107
- const { db } = await postgresClient();
108
- const result = await db.raw(processedSql);
109
- rows = result.rows ?? [];
110
- } catch (err: any) {
111
- return JSON.stringify({ error: `Query failed: ${err.message}` });
112
- }
113
-
114
- const json = JSON.stringify(rows, null, 2);
115
-
116
- if (json.length <= MAX_INLINE_CHARS) {
117
- return json;
118
- }
119
-
120
- // Results are large — store to session dir and tell the agent to grep
121
- await fs.mkdir(sessionDir, { recursive: true });
122
- const filename = `query_${++queryCount}.json`;
123
- const filePath = path.join(sessionDir, filename);
124
- await fs.writeFile(filePath, json, "utf-8");
125
-
126
- return JSON.stringify({
127
- stored: true,
128
- file: filePath,
129
- row_count: rows.length,
130
- message: `Results too large to display (${rows.length} rows, ${(json.length / 1000).toFixed(1)}k chars). Stored at ${filePath}. Use the grep tool to search for relevant information.`,
131
- grep_hint: `grep -i "keyword" ${filePath}`,
132
- });
133
- },
134
- });
135
-
136
- // ── grep ─────────────────────────────────────────────────────────────────────
137
-
138
- const grep = tool({
139
- description: `Search a stored query result file using grep.
140
-
141
- Use this after execute_query returns a file path because results were too large.
142
- Iteratively narrow down the results with multiple grep calls.`,
143
- inputSchema: z.object({
144
- pattern: z.string().describe("Regular expression or literal string to search for"),
145
- file: z.string().describe("Absolute path to the file returned by execute_query"),
146
- context_lines: z
147
- .number()
148
- .int()
149
- .min(0)
150
- .max(10)
151
- .default(2)
152
- .describe("Number of lines of context to show around each match (default 2)"),
153
- case_insensitive: z
154
- .boolean()
155
- .default(true)
156
- .describe("Case-insensitive matching (default true)"),
157
- }),
158
- execute: async ({ pattern, file, context_lines, case_insensitive }) => {
159
- // Security: only allow reading from our session directory
160
- const resolvedFile = path.resolve(file);
161
- const resolvedSession = path.resolve(sessionDir);
162
- if (!resolvedFile.startsWith(resolvedSession)) {
163
- return JSON.stringify({
164
- error: `Access denied. Only files within the session directory (${sessionDir}) can be searched.`,
165
- });
166
- }
167
-
168
- // Verify file exists
169
- try {
170
- await fs.access(resolvedFile);
171
- } catch {
172
- return JSON.stringify({ error: `File not found: ${file}` });
173
- }
174
-
175
- const flags = [
176
- "-n",
177
- context_lines > 0 ? `-C${context_lines}` : "",
178
- case_insensitive ? "-i" : "",
179
- ]
180
- .filter(Boolean)
181
- .join(" ");
182
-
183
- // Escape pattern for shell to prevent injection
184
- const escapedPattern = pattern.replace(/'/g, `'\\''`);
185
- const cmd = `grep ${flags} '${escapedPattern}' '${resolvedFile}'`;
186
-
187
- let output: string;
188
- try {
189
- const { stdout } = await execAsync(cmd, { maxBuffer: 10 * 1024 * 1024 });
190
- output = stdout;
191
- } catch (err: any) {
192
- // grep exits with code 1 when no matches — that's not an error
193
- if (err.code === 1) {
194
- return JSON.stringify({ matches: 0, output: "No matches found." });
195
- }
196
- return JSON.stringify({ error: `grep failed: ${err.message}` });
197
- }
198
-
199
- if (output.length > MAX_GREP_OUTPUT_CHARS) {
200
- output =
201
- output.slice(0, MAX_GREP_OUTPUT_CHARS) +
202
- `\n... (output truncated at ${MAX_GREP_OUTPUT_CHARS} chars — refine your pattern to narrow results)`;
203
- }
204
-
205
- const lineCount = output.split("\n").filter(Boolean).length;
206
- return JSON.stringify({ matches: lineCount, output });
207
- },
208
- });
209
-
210
- return { execute_query, grep };
211
- }
212
-
213
- /**
214
- * Harvests ChunkResult objects from all tool results in a step.
215
- * Called after each agent step to collect any chunk-shaped rows the agent retrieved.
216
- */
217
- export function harvestChunks(toolResults: any[]): ChunkResult[] {
218
- const chunks: ChunkResult[] = [];
219
-
220
- for (const result of toolResults ?? []) {
221
- const rawOutput = result.output ?? result.result;
222
- let parsed: any;
223
- try {
224
- parsed = typeof rawOutput === "string" ? JSON.parse(rawOutput) : rawOutput;
225
- } catch {
226
- continue;
227
- }
228
-
229
- // Array of rows (direct SELECT result)
230
- if (Array.isArray(parsed)) {
231
- for (const row of parsed) {
232
- if (row && typeof row === "object") {
233
- const chunk = rowToChunkResult(row);
234
- if (chunk) chunks.push(chunk);
235
- }
236
- }
237
- }
238
- }
239
-
240
- return chunks;
241
- }