@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.
Files changed (47) hide show
  1. package/dist/{catalog-TBSPSN2N.js → catalog-UGTDNMDM.js} +2 -1
  2. package/dist/{chunk-YCE44CMU.js → chunk-7CCMW3IW.js} +2 -0
  3. package/dist/chunk-IJ4HNHOT.js +6416 -0
  4. package/dist/{chunk-IDHS2BZO.js → chunk-T6JVFT7L.js} +2 -0
  5. package/dist/cli/start-whisper.cjs +1 -0
  6. package/dist/cli/start-whisper.js +2 -1
  7. package/dist/convert-exulu-tools-to-ai-sdk-tools-2PEDFZ2X.js +9 -0
  8. package/dist/index.cjs +9558 -9262
  9. package/dist/index.d.cts +46 -29
  10. package/dist/index.d.ts +46 -29
  11. package/dist/index.js +4989 -548
  12. package/ee/agentic-retrieval/pipeline/config.test.ts +81 -0
  13. package/ee/agentic-retrieval/pipeline/config.ts +189 -0
  14. package/ee/agentic-retrieval/pipeline/hyde.test.ts +55 -0
  15. package/ee/agentic-retrieval/pipeline/hyde.ts +133 -0
  16. package/ee/agentic-retrieval/pipeline/index.test.ts +140 -0
  17. package/ee/agentic-retrieval/pipeline/index.ts +638 -0
  18. package/ee/agentic-retrieval/pipeline/memory.test.ts +101 -0
  19. package/ee/agentic-retrieval/pipeline/memory.ts +566 -0
  20. package/ee/agentic-retrieval/pipeline/multi-query.test.ts +51 -0
  21. package/ee/agentic-retrieval/pipeline/multi-query.ts +158 -0
  22. package/ee/agentic-retrieval/pipeline/prefilter.test.ts +93 -0
  23. package/ee/agentic-retrieval/pipeline/prefilter.ts +389 -0
  24. package/ee/agentic-retrieval/pipeline/rerank.test.ts +128 -0
  25. package/ee/agentic-retrieval/pipeline/rerank.ts +178 -0
  26. package/ee/agentic-retrieval/pipeline/routing.test.ts +144 -0
  27. package/ee/agentic-retrieval/pipeline/routing.ts +343 -0
  28. package/ee/agentic-retrieval/pipeline/search.test.ts +149 -0
  29. package/ee/agentic-retrieval/pipeline/search.ts +180 -0
  30. package/ee/agentic-retrieval/pipeline/text-utils.test.ts +43 -0
  31. package/ee/agentic-retrieval/pipeline/text-utils.ts +85 -0
  32. package/ee/agentic-retrieval/pipeline/types.ts +59 -0
  33. package/ee/python/documents/processing/doc_processor.ts +1 -1
  34. package/ee/python/documents/processing/split_pdf.py +78 -24
  35. package/package.json +2 -1
  36. package/dist/chunk-WCP3WZM3.js +0 -10391
  37. package/dist/convert-exulu-tools-to-ai-sdk-tools-GQ3UIYP7.js +0 -6
  38. package/ee/agentic-retrieval/v3/agent-loop.ts +0 -288
  39. package/ee/agentic-retrieval/v3/classifier.ts +0 -92
  40. package/ee/agentic-retrieval/v3/context-sampler.ts +0 -79
  41. package/ee/agentic-retrieval/v3/dynamic-tools.ts +0 -115
  42. package/ee/agentic-retrieval/v3/index.ts +0 -471
  43. package/ee/agentic-retrieval/v3/session-tools-registry.ts +0 -20
  44. package/ee/agentic-retrieval/v3/strategies.ts +0 -171
  45. package/ee/agentic-retrieval/v3/tools.ts +0 -558
  46. package/ee/agentic-retrieval/v3/trajectory.ts +0 -309
  47. package/ee/agentic-retrieval/v3/types.ts +0 -59
@@ -0,0 +1,101 @@
1
+ // ee/agentic-retrieval/pipeline/memory.test.ts
2
+ import { runMemoryPhase, clearMemoryItemCache } from "./memory";
3
+
4
+ jest.mock("ai", () => ({ generateText: jest.fn(), Output: { object: (x: any) => x } }));
5
+ jest.mock("./multi-query", () => ({ singleSearch: jest.fn(async () => []) }));
6
+ jest.mock("./prefilter", () => ({ fuzzyPrefilter: jest.fn(async () => []) }));
7
+ import { generateText } from "ai";
8
+ import { fuzzyPrefilter } from "./prefilter";
9
+
10
+ const memChunk = (id: string, content: string) => ({
11
+ chunk_id: id, chunk_content: content, chunk_index: 1, item_id: "m" + id, item_name: "Memory " + id,
12
+ }) as any;
13
+ const baseOpts = {
14
+ question: "How do I bypass the door contact on the FST-2XT?",
15
+ keywords: ["door"], importantKeyword: "FST-2XT", user: {}, role: "r", model: {},
16
+ glossary: [{ term: "FST", meaning: "field bus controller" }],
17
+ documentContexts: [],
18
+ };
19
+ const allOn = { enabled: true, override: true, filePrioritization: true, queryAugmentation: true };
20
+
21
+ beforeEach(() => { clearMemoryItemCache(); (generateText as jest.Mock).mockReset(); });
22
+
23
+ describe("runMemoryPhase", () => {
24
+ it("returns a neutral result when memory is disabled", async () => {
25
+ const r = await runMemoryPhase({ ...baseOpts, memoryChunks: [memChunk("1", "x")], memoryContext: undefined,
26
+ memoryConfig: { ...allOn, enabled: false } });
27
+ expect(r.memoryChunksForAnswer).toEqual([]);
28
+ expect(r.updatedQuestion).toBe(baseOpts.question);
29
+ expect(generateText).not.toHaveBeenCalled();
30
+ });
31
+
32
+ it("marks relevant chunks citable with synthetic score 1 and memory context", async () => {
33
+ (generateText as jest.Mock)
34
+ .mockResolvedValueOnce({ output: { relevantChunkIds: ["1"] } }) // relevance
35
+ .mockResolvedValueOnce({ output: { overrides: false, confidence: "low", authoritativeChunkIds: [], reason: "" } })
36
+ .mockResolvedValueOnce({ output: { shouldPrioritizeFiles: false, fileNameHints: [] } })
37
+ .mockResolvedValueOnce({ output: { updatedUserQuestion: baseOpts.question, updatedRelevantKeywords: [], updatedImportantKeyword: "FST-2XT" } });
38
+ const r = await runMemoryPhase({ ...baseOpts, memoryChunks: [memChunk("1", "hint"), memChunk("2", "other")],
39
+ memoryContext: undefined, memoryConfig: allOn });
40
+ expect(r.memoryChunksForAnswer).toHaveLength(1);
41
+ expect(r.memoryChunksForAnswer[0]).toMatchObject({ chunk_id: "1", rerank_score: 1, context: { id: "memory" } });
42
+ });
43
+
44
+ it("activates the override only with overrides=true AND high confidence AND chunks", async () => {
45
+ (generateText as jest.Mock)
46
+ .mockResolvedValueOnce({ output: { relevantChunkIds: ["1"] } })
47
+ .mockResolvedValueOnce({ output: { overrides: true, confidence: "medium", authoritativeChunkIds: ["1"], reason: "r" } })
48
+ .mockResolvedValueOnce({ output: { shouldPrioritizeFiles: false } })
49
+ .mockResolvedValueOnce({ output: { updatedUserQuestion: baseOpts.question, updatedRelevantKeywords: [], updatedImportantKeyword: "FST-2XT" } });
50
+ const r = await runMemoryPhase({ ...baseOpts, memoryChunks: [memChunk("1", "x")], memoryContext: undefined, memoryConfig: allOn });
51
+ expect(r.memoryOverride.active).toBe(false); // medium confidence blocks it
52
+ });
53
+
54
+ it("skips override/file/augmentation LLM calls when those features are off", async () => {
55
+ (generateText as jest.Mock).mockResolvedValueOnce({ output: { relevantChunkIds: ["1"] } });
56
+ const r = await runMemoryPhase({ ...baseOpts, memoryChunks: [memChunk("1", "x")], memoryContext: undefined,
57
+ memoryConfig: { enabled: true, override: false, filePrioritization: false, queryAugmentation: false } });
58
+ expect(generateText).toHaveBeenCalledTimes(1); // relevance only
59
+ expect(r.memoryOverride.active).toBe(false);
60
+ });
61
+
62
+ it("augmentation merges keywords but preserves the original important keyword", async () => {
63
+ (generateText as jest.Mock)
64
+ .mockResolvedValueOnce({ output: { relevantChunkIds: ["1"] } })
65
+ .mockResolvedValueOnce({ output: { updatedUserQuestion: "expanded q", updatedRelevantKeywords: ["Feldbussteuerung"], updatedImportantKeyword: "SOMETHING-ELSE" } });
66
+ const r = await runMemoryPhase({ ...baseOpts, memoryChunks: [memChunk("1", "x")], memoryContext: undefined,
67
+ memoryConfig: { enabled: true, override: false, filePrioritization: false, queryAugmentation: true } });
68
+ expect(r.updatedQuestion).toBe("expanded q");
69
+ expect(r.updatedKeywords).toEqual(expect.arrayContaining(["door", "feldbussteuerung"]));
70
+ expect(r.updatedImportantKeyword).toBe("FST-2XT");
71
+ });
72
+
73
+ it("resolves file-prioritization pins across all document contexts", async () => {
74
+ (generateText as jest.Mock)
75
+ .mockResolvedValueOnce({ output: { relevantChunkIds: ["1"] } })
76
+ .mockResolvedValueOnce({ output: { shouldPrioritizeFiles: true, fileNameHints: ["PROJECT_NOTES"] } });
77
+ (fuzzyPrefilter as jest.Mock).mockResolvedValue([{ id: "d1", name: "Project Notes", key: "k" }]);
78
+ const r = await runMemoryPhase({ ...baseOpts, memoryChunks: [memChunk("1", "always check PROJECT_NOTES")],
79
+ memoryContext: undefined, documentContexts: [{ id: "docs" }],
80
+ memoryConfig: { enabled: true, override: false, filePrioritization: true, queryAugmentation: false } });
81
+ expect([...r.memoryPinnedItemIds]).toEqual(["d1"]);
82
+ });
83
+
84
+ it("never throws even when post-Promise.all processing encounters runtime errors", async () => {
85
+ // Mock relevance check to succeed
86
+ (generateText as jest.Mock)
87
+ .mockResolvedValueOnce({ output: { relevantChunkIds: ["1"] } })
88
+ // Mock override check
89
+ .mockResolvedValueOnce({ output: { overrides: false, confidence: "low", authoritativeChunkIds: [], reason: "" } })
90
+ .mockResolvedValueOnce({ output: { shouldPrioritizeFiles: false, fileNameHints: [] } })
91
+ // Mock query augmentation: return malformed keywords (non-strings) that will fail during trim()
92
+ .mockResolvedValueOnce({ output: { updatedUserQuestion: baseOpts.question, updatedRelevantKeywords: [{ bad: "object" } as any], updatedImportantKeyword: "FST-2XT" } });
93
+
94
+ // This should resolve without throwing, returning a neutral result despite the runtime error in keyword merge
95
+ const r = await runMemoryPhase({ ...baseOpts, memoryChunks: [memChunk("1", "test")], memoryContext: undefined, memoryConfig: allOn });
96
+
97
+ // Verify it returns a neutral result (original question preserved, no crash)
98
+ expect(r).toBeDefined();
99
+ expect(r.updatedQuestion).toBe(baseOpts.question);
100
+ });
101
+ });
@@ -0,0 +1,566 @@
1
+ import { generateText, Output } from "ai";
2
+ import { z } from "zod";
3
+ import { withRetry } from "@SRC/utils/with-retry";
4
+ import { singleSearch } from "./multi-query";
5
+ import { fuzzyPrefilter } from "./prefilter";
6
+ import { deriveKeywordVariants, normalizeFileName, stripSeparators } from "./text-utils";
7
+ import type { Chunk, ChunkWithScore, MemoryPhaseResult, PhaseStep } from "./types";
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // Constants
11
+ // ---------------------------------------------------------------------------
12
+
13
+ const MEMORY_OVERRIDE_MIN_CONFIDENCE = "high";
14
+ const MEMORY_SYNTHETIC_RERANK_SCORE = 1;
15
+ const ITEM_CACHE_TTL_MS = 5 * 60 * 1000;
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // 5-min item cache (keyed by memoryContext.id)
19
+ // ---------------------------------------------------------------------------
20
+
21
+ type MemoryItem = { id: string; name: string; description?: string; information?: string };
22
+
23
+ let memoryItemCache = new Map<string, { items: MemoryItem[]; tsp: Date }>();
24
+
25
+ export function clearMemoryItemCache(): void {
26
+ memoryItemCache = new Map();
27
+ }
28
+
29
+ async function loadMemoryItems(context: {
30
+ id: string;
31
+ getItems: (o: any) => Promise<MemoryItem[]>;
32
+ }): Promise<MemoryItem[]> {
33
+ const cached = memoryItemCache.get(context.id);
34
+ if (cached && Date.now() - cached.tsp.getTime() < ITEM_CACHE_TTL_MS) {
35
+ return cached.items;
36
+ }
37
+ const items = await context.getItems({
38
+ fields: ["id", "name", "description", "information"],
39
+ filters: [],
40
+ });
41
+ memoryItemCache.set(context.id, { items, tsp: new Date() });
42
+ return items;
43
+ }
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // Keyword recall (ported from newton-memory.ts:91-169)
47
+ // ---------------------------------------------------------------------------
48
+
49
+ async function recallMemoryByKeywords({
50
+ keywords,
51
+ importantKeyword,
52
+ user,
53
+ role,
54
+ memoryContext,
55
+ }: {
56
+ keywords: string[];
57
+ importantKeyword: string;
58
+ user: any;
59
+ role: any;
60
+ memoryContext: { id: string; getItems: (o: any) => Promise<MemoryItem[]> };
61
+ }): Promise<Chunk[]> {
62
+ const allKeywords = [
63
+ ...new Set(
64
+ [importantKeyword, ...keywords].filter(
65
+ (k): k is string => !!k && k.trim().length > 0,
66
+ ),
67
+ ),
68
+ ];
69
+ if (!allKeywords.length) return [];
70
+
71
+ const importantVariants = importantKeyword
72
+ ? [...new Set(deriveKeywordVariants(importantKeyword).map(stripSeparators))].filter(
73
+ (v) => v.length >= 4,
74
+ )
75
+ : [];
76
+ const allVariants = [
77
+ ...new Set(allKeywords.flatMap(deriveKeywordVariants).map(stripSeparators)),
78
+ ].filter((v) => v.length >= 4);
79
+ if (!allVariants.length) return [];
80
+
81
+ const items = await loadMemoryItems(memoryContext);
82
+
83
+ type Scored = { id: string; hits: number; importantHit: boolean; name: string };
84
+ const scored: Scored[] = [];
85
+ for (const item of items) {
86
+ const haystack = stripSeparators(
87
+ [item.name, item.description, item.information].filter(Boolean).join(" "),
88
+ );
89
+ if (!haystack) continue;
90
+ const hits = allVariants.filter((v) => haystack.includes(v)).length;
91
+ if (hits === 0) continue;
92
+ const importantHit = importantVariants.some((v) => haystack.includes(v));
93
+ scored.push({ id: item.id, hits, importantHit, name: item.name ?? "" });
94
+ }
95
+
96
+ scored.sort((a, b) => {
97
+ if (a.importantHit !== b.importantHit) return a.importantHit ? -1 : 1;
98
+ return b.hits - a.hits;
99
+ });
100
+ const topMatches = scored.slice(0, 25);
101
+ if (!topMatches.length) return [];
102
+
103
+ console.log(
104
+ "[EXULU pipeline] keyword-triggered memory matches:",
105
+ topMatches.map((s) => `${s.name} (hits=${s.hits}, important=${s.importantHit})`),
106
+ );
107
+
108
+ const chunks = await singleSearch({
109
+ query: allKeywords.join(", "),
110
+ config: { method: "hybridSearch", cutoffs: undefined, limit: 50 },
111
+ user,
112
+ role,
113
+ pinnedItemIds: topMatches.map((s) => s.id),
114
+ context: memoryContext,
115
+ });
116
+
117
+ return chunks;
118
+ }
119
+
120
+ // ---------------------------------------------------------------------------
121
+ // Neutral result helper
122
+ // ---------------------------------------------------------------------------
123
+
124
+ function neutralResult(
125
+ question: string,
126
+ keywords: string[],
127
+ importantKeyword: string,
128
+ steps: PhaseStep[] = [],
129
+ ): MemoryPhaseResult {
130
+ return {
131
+ memoryChunksForAnswer: [],
132
+ memoryOverride: { active: false, chunks: [], reason: "" },
133
+ memoryPinnedItemIds: new Set(),
134
+ updatedQuestion: question,
135
+ updatedKeywords: keywords,
136
+ updatedImportantKeyword: importantKeyword,
137
+ steps,
138
+ };
139
+ }
140
+
141
+ // ---------------------------------------------------------------------------
142
+ // Main export
143
+ // ---------------------------------------------------------------------------
144
+
145
+ export async function runMemoryPhase({
146
+ memoryChunks,
147
+ memoryContext,
148
+ question,
149
+ keywords,
150
+ importantKeyword,
151
+ user,
152
+ role,
153
+ model,
154
+ memoryConfig,
155
+ glossary,
156
+ documentContexts,
157
+ }: {
158
+ memoryChunks: Chunk[];
159
+ memoryContext?: any;
160
+ question: string;
161
+ keywords: string[];
162
+ importantKeyword: string;
163
+ user: any;
164
+ role: any;
165
+ model: any;
166
+ memoryConfig: {
167
+ enabled: boolean;
168
+ override: boolean;
169
+ filePrioritization: boolean;
170
+ queryAugmentation: boolean;
171
+ };
172
+ glossary: { term: string; meaning: string }[];
173
+ documentContexts: any[];
174
+ }): Promise<MemoryPhaseResult> {
175
+ try {
176
+ // Short-circuit: disabled, or nothing to work with
177
+ if (!memoryConfig.enabled || (memoryChunks.length === 0 && !memoryContext)) {
178
+ return neutralResult(question, keywords, importantKeyword);
179
+ }
180
+
181
+ const steps: PhaseStep[] = [];
182
+ let retrieved_memory = [...memoryChunks];
183
+
184
+ // Keyword recall: extend memory with items that match the user's keywords
185
+ if (memoryContext) {
186
+ try {
187
+ const keywordMatched = await recallMemoryByKeywords({
188
+ keywords,
189
+ importantKeyword,
190
+ user,
191
+ role,
192
+ memoryContext,
193
+ });
194
+ if (keywordMatched.length > 0) {
195
+ const seen = new Set(retrieved_memory.map((c) => c.chunk_id));
196
+ const additions = keywordMatched.filter((c) => !seen.has(c.chunk_id));
197
+ retrieved_memory = [...retrieved_memory, ...additions];
198
+ }
199
+ } catch (e) {
200
+ console.error("[EXULU pipeline] keyword-triggered memory recall failed:", e);
201
+ }
202
+ }
203
+
204
+ // Step 1: Relevance check
205
+ const CHECK_MEMORIES_FOR_RELEVANT_INFORMATION = `
206
+ You are checking whether any chunks from the shared company memory contain information
207
+ relevant to the user's question. Return the chunk_ids of relevant chunks, or an empty array.
208
+
209
+ Be generous: include chunks that are topically related, share key terminology, describe the
210
+ same symptom from a different angle, or could plausibly help diagnose the issue — even if
211
+ they don't answer the question directly. Memory entries are deliberately broad, hand-curated
212
+ hints written by domain experts; the user's wording will rarely match the memory verbatim.
213
+ When in doubt, include the chunk.
214
+
215
+ <memory_chunks>
216
+ ${retrieved_memory.map((chunk) => `- ${chunk.chunk_id}: ${chunk.item_name} - ${chunk.chunk_content}`).join("\n")}
217
+ </memory_chunks>
218
+ `;
219
+
220
+ let relevantMemoryChunks: Chunk[] = [];
221
+ try {
222
+ const { output: output_relevant_memory } = await withRetry(
223
+ () =>
224
+ generateText({
225
+ model,
226
+ temperature: 0,
227
+ system: CHECK_MEMORIES_FOR_RELEVANT_INFORMATION,
228
+ messages: [
229
+ {
230
+ role: "user",
231
+ content: `
232
+ <user_question>${question}</user_question>
233
+ <relevant_keywords>${keywords.join(", ")}</relevant_keywords>
234
+ <important_keyword>${importantKeyword}</important_keyword>
235
+ `,
236
+ },
237
+ ],
238
+ output: Output.object({
239
+ schema: z.object({
240
+ relevantChunkIds: z
241
+ .array(z.string())
242
+ .describe(
243
+ "The chunk_ids (UUIDs at the start of each bullet) of chunks containing information relevant to the user's question. Empty array if none are relevant.",
244
+ ),
245
+ }),
246
+ }),
247
+ maxOutputTokens: 400,
248
+ }),
249
+ 3,
250
+ );
251
+
252
+ const ids = new Set(output_relevant_memory?.relevantChunkIds ?? []);
253
+ relevantMemoryChunks =
254
+ ids.size === 0 ? [] : retrieved_memory.filter((c) => ids.has(c.chunk_id));
255
+ } catch (e) {
256
+ // On failure, treat NONE as relevant (strict: a broken check must not flood answer with memory)
257
+ steps.push({ text: "Memory relevance check failed — memory was skipped." });
258
+ return neutralResult(question, keywords, importantKeyword, steps);
259
+ }
260
+
261
+ // Synthetic score/citable shaping
262
+ const memoryChunksForAnswer: ChunkWithScore[] = relevantMemoryChunks.map((chunk) => ({
263
+ ...chunk,
264
+ rerank_score: MEMORY_SYNTHETIC_RERANK_SCORE,
265
+ context: { name: "memory", id: "memory" },
266
+ }));
267
+
268
+ if (relevantMemoryChunks.length > 0) {
269
+ steps.push({
270
+ text:
271
+ "Retrieved potentially relevant information from memory: " +
272
+ relevantMemoryChunks
273
+ .map((c) => `${c.item_name}: ${c.chunk_content}`)
274
+ .join(", "),
275
+ chunks: memoryChunksForAnswer,
276
+ });
277
+ }
278
+
279
+ let memoryOverride: MemoryPhaseResult["memoryOverride"] = {
280
+ active: false,
281
+ chunks: [],
282
+ reason: "",
283
+ };
284
+ let memoryPinnedItemIds = new Set<string>();
285
+ let updatedQuestion = question;
286
+ let updatedKeywords = keywords;
287
+ let updatedImportantKeyword = importantKeyword;
288
+
289
+ if (relevantMemoryChunks.length > 0) {
290
+ const CHECK_MEMORY_OVERRIDE = `
291
+ You are deciding whether a curated company-memory entry should become the AUTHORITATIVE
292
+ basis of the answer to the user's question — taking precedence over the official
293
+ documentation even if the documents state something different.
294
+
295
+ This is a deliberately STRICT check. Set overrides=true ONLY if a single memory chunk,
296
+ on its own, contains a DIRECT and SUFFICIENT answer to exactly what the user asked —
297
+ enough that the final answer should be built on it and defer to it. Being topically
298
+ related, sharing terminology, describing the same component, or only partially
299
+ addressing the question is NOT sufficient: in those cases set overrides=false. When in
300
+ doubt, set overrides=false.
301
+
302
+ Memory entries are hand-curated by domain experts and may capture field experience that
303
+ the manuals get wrong, so a confident, direct match is meant to win over the documents.
304
+
305
+ <memory_chunks>
306
+ ${relevantMemoryChunks.map((c) => `- ${c.chunk_id}: ${c.item_name} - ${c.chunk_content}`).join("\n")}
307
+ </memory_chunks>
308
+ `;
309
+
310
+ const PROMPT_EXTRACT_PRIORITIZED_FILES = `
311
+ You decide whether the shared company memory instructs prioritizing one or more SPECIFIC
312
+ documents/files when answering the user's question.
313
+
314
+ Only set shouldPrioritizeFiles to true if the memory explicitly says to look in, prioritize,
315
+ prefer, or always search a particular document, file, or file family (for example a note like
316
+ "When asked about X, always search in Y-Dateien first"). General background facts, glossaries,
317
+ or synonyms are NOT a file prioritization instruction — in that case return false.
318
+
319
+ When true, return fileNameHints: the document/file name(s) to prioritize, exactly as referenced
320
+ in the memory (e.g. "PROJECT_NOTES"). Return the bare name without folder paths.
321
+
322
+ <user_question>${question}</user_question>
323
+ <relevant_keywords>${keywords.join(", ")}</relevant_keywords>
324
+ <relevant_memory>
325
+ ${relevantMemoryChunks.map((c) => `- ${c.item_name}: ${c.chunk_content}`).join("\n")}
326
+ </relevant_memory>
327
+ `;
328
+
329
+ // Build glossary text for query augmentation
330
+ const glossaryText =
331
+ glossary.length > 0
332
+ ? `The organization's documents use the following abbreviations/terms:\n\n${glossary.map((g) => `${g.term} : ${g.meaning}`).join("\n")}`
333
+ : "";
334
+
335
+ const hasAugmentationContent =
336
+ glossary.length > 0 || relevantMemoryChunks.some((c) => c.chunk_content);
337
+
338
+ const QUERY_AUGMENTATION_PROMPT = `
339
+ Below is the original user question, relevant extracted keywords and important keyword.
340
+
341
+ <user_question>${question}</user_question>
342
+ <relevant_keywords>${keywords.join(", ")}</relevant_keywords>
343
+ <important_keyword>${importantKeyword}</important_keyword>
344
+
345
+ We also just retrieved the following relevant information from the shared company memory:
346
+
347
+ <relevant_memory_information>
348
+ ${relevantMemoryChunks.map((c) => c.chunk_content).join("\n")}
349
+
350
+ ${glossaryText}
351
+ </relevant_memory_information>
352
+
353
+ If, and only if, relevant memory information contains information that should be used to update the user's query
354
+ such as synonyms or similar terms, update the user's query and keywords to include the synonyms or similar terms,
355
+ always make sure to keep the original as well as the synonyms or similar terms in the updated user question and keywords.
356
+
357
+ Otherwise, return the original user question, relevant keywords and important keyword.
358
+ `;
359
+
360
+ const [overrideResult, fileResult, queryResult] = await Promise.all([
361
+ // Override check: strict gate to decide if memory should be authoritative
362
+ memoryConfig.override
363
+ ? withRetry(
364
+ () =>
365
+ generateText({
366
+ model,
367
+ temperature: 0,
368
+ system: CHECK_MEMORY_OVERRIDE,
369
+ messages: [
370
+ {
371
+ role: "user",
372
+ content: `
373
+ <user_question>${question}</user_question>
374
+ <relevant_keywords>${keywords.join(", ")}</relevant_keywords>
375
+ <important_keyword>${importantKeyword}</important_keyword>
376
+ `,
377
+ },
378
+ ],
379
+ output: Output.object({
380
+ schema: z.object({
381
+ overrides: z
382
+ .boolean()
383
+ .describe(
384
+ "True ONLY if a memory chunk directly and sufficiently answers the user's question and should be authoritative over the documents. Be strict; when unsure, false.",
385
+ ),
386
+ confidence: z
387
+ .enum(["high", "medium", "low"])
388
+ .describe(
389
+ "Confidence that the selected memory chunk(s) fully and directly answer the question.",
390
+ ),
391
+ authoritativeChunkIds: z
392
+ .array(z.string())
393
+ .describe(
394
+ "The chunk_ids of the memory chunk(s) that directly answer the question. Empty if overrides is false.",
395
+ ),
396
+ reason: z
397
+ .string()
398
+ .describe(
399
+ "One short sentence: why this memory does or does not directly answer the question.",
400
+ ),
401
+ }),
402
+ }),
403
+ maxOutputTokens: 300,
404
+ }),
405
+ 3,
406
+ ).catch(() => ({
407
+ output: {
408
+ overrides: false,
409
+ confidence: "low",
410
+ authoritativeChunkIds: [],
411
+ reason: "",
412
+ },
413
+ }))
414
+ : Promise.resolve({
415
+ output: {
416
+ overrides: false,
417
+ confidence: "low",
418
+ authoritativeChunkIds: [],
419
+ reason: "",
420
+ },
421
+ }),
422
+
423
+ // File prioritization: detect explicit document-pinning instructions in memory
424
+ memoryConfig.filePrioritization
425
+ ? withRetry(
426
+ () =>
427
+ generateText({
428
+ model,
429
+ temperature: 0,
430
+ system:
431
+ "You are a helpful assistant that will strictly follow the user's instructions.",
432
+ messages: [{ role: "user", content: PROMPT_EXTRACT_PRIORITIZED_FILES }],
433
+ output: Output.object({
434
+ schema: z.object({
435
+ shouldPrioritizeFiles: z.boolean(),
436
+ fileNameHints: z.array(z.string()).optional(),
437
+ }),
438
+ }),
439
+ maxOutputTokens: 300,
440
+ }),
441
+ 3,
442
+ ).catch(() => ({
443
+ output: { shouldPrioritizeFiles: false, fileNameHints: [] as string[] },
444
+ }))
445
+ : Promise.resolve({
446
+ output: { shouldPrioritizeFiles: false, fileNameHints: [] as string[] },
447
+ }),
448
+
449
+ // Query augmentation: expand keywords with synonyms/abbreviations from memory
450
+ memoryConfig.queryAugmentation && hasAugmentationContent
451
+ ? withRetry(
452
+ () =>
453
+ generateText({
454
+ model,
455
+ temperature: 0,
456
+ system:
457
+ "You are a helpful assistant that will strictly follow the user's instructions.",
458
+ messages: [{ role: "user", content: QUERY_AUGMENTATION_PROMPT }],
459
+ output: Output.object({
460
+ schema: z.object({
461
+ updatedUserQuestion: z.string(),
462
+ updatedRelevantKeywords: z.array(z.string()),
463
+ updatedImportantKeyword: z.string(),
464
+ }),
465
+ }),
466
+ maxOutputTokens: 600,
467
+ }),
468
+ 3,
469
+ ).catch(() => ({
470
+ output: {
471
+ updatedUserQuestion: question,
472
+ updatedRelevantKeywords: [],
473
+ updatedImportantKeyword: importantKeyword,
474
+ },
475
+ }))
476
+ : Promise.resolve({
477
+ output: {
478
+ updatedUserQuestion: question,
479
+ updatedRelevantKeywords: [],
480
+ updatedImportantKeyword: importantKeyword,
481
+ },
482
+ }),
483
+ ]);
484
+
485
+ // Override gate (STRICT: only active when overrides===true && confidence==="high" && authoritativeChunks.length > 0)
486
+ const overrideIds = new Set(overrideResult.output?.authoritativeChunkIds ?? []);
487
+ const authoritativeChunks = memoryChunksForAnswer.filter(
488
+ (c) => c.chunk_id && overrideIds.has(c.chunk_id),
489
+ );
490
+ if (
491
+ overrideResult.output?.overrides === true &&
492
+ overrideResult.output?.confidence === MEMORY_OVERRIDE_MIN_CONFIDENCE &&
493
+ authoritativeChunks.length > 0
494
+ ) {
495
+ memoryOverride = {
496
+ active: true,
497
+ chunks: authoritativeChunks,
498
+ reason: overrideResult.output.reason ?? "",
499
+ };
500
+ }
501
+
502
+ // File prioritization: resolve hints via fuzzyPrefilter against EVERY documentContexts entry
503
+ if (fileResult.output?.shouldPrioritizeFiles && fileResult.output?.fileNameHints?.length) {
504
+ const hints = fileResult.output.fileNameHints;
505
+ const pinResults = await Promise.all(
506
+ documentContexts.map((ctx) =>
507
+ fuzzyPrefilter({
508
+ cacheKey: `memory-pin:${ctx.id}`,
509
+ relevantKeywords: hints,
510
+ context: ctx,
511
+ fields: ["name", "id", "external_id"],
512
+ normalize: (item: any) =>
513
+ item.external_id ? normalizeFileName(item.external_id) : item.name,
514
+ }).catch(() => []),
515
+ ),
516
+ );
517
+ for (const results of pinResults) {
518
+ for (const r of results) {
519
+ memoryPinnedItemIds.add(r.id);
520
+ }
521
+ }
522
+ if (memoryPinnedItemIds.size > 0) {
523
+ const names = pinResults.flat().map((i) => i.name).join(", ");
524
+ steps.push({
525
+ text: `Memory prioritizes specific document(s); pinning ${memoryPinnedItemIds.size} file(s) into the search: ${names}`,
526
+ });
527
+ }
528
+ }
529
+
530
+ // Query augmentation merge: keywords union lowercased/trimmed; updatedImportantKeyword ALWAYS preserved as original
531
+ const augmented = queryResult.output;
532
+ if (
533
+ augmented?.updatedUserQuestion !== question ||
534
+ (augmented?.updatedRelevantKeywords?.length ?? 0) > 0 ||
535
+ augmented?.updatedImportantKeyword?.trim().toLowerCase() !==
536
+ importantKeyword?.trim().toLowerCase()
537
+ ) {
538
+ updatedQuestion = augmented?.updatedUserQuestion ?? question;
539
+ updatedKeywords = [
540
+ ...new Set(
541
+ [...keywords, ...(augmented?.updatedRelevantKeywords ?? [])].map((k) =>
542
+ k.trim().toLowerCase(),
543
+ ),
544
+ ),
545
+ ];
546
+ updatedImportantKeyword = importantKeyword; // preserve original
547
+ steps.push({
548
+ text: `The user's query and keywords have been updated: ${updatedQuestion}, ${updatedKeywords.join(", ")}, ${updatedImportantKeyword}`,
549
+ });
550
+ }
551
+ }
552
+
553
+ return {
554
+ memoryChunksForAnswer,
555
+ memoryOverride,
556
+ memoryPinnedItemIds,
557
+ updatedQuestion,
558
+ updatedKeywords,
559
+ updatedImportantKeyword,
560
+ steps,
561
+ };
562
+ } catch (err) {
563
+ console.warn("[EXULU pipeline] memory phase failed — continuing without memory.", err);
564
+ return neutralResult(question, keywords, importantKeyword);
565
+ }
566
+ }