@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,81 @@
|
|
|
1
|
+
import { parsePipelineConfig, effectiveKbSettings, KIND_PRESETS } from "./config";
|
|
2
|
+
|
|
3
|
+
describe("parsePipelineConfig", () => {
|
|
4
|
+
it("returns full defaults for an empty/missing config", () => {
|
|
5
|
+
const cfg = parsePipelineConfig(undefined);
|
|
6
|
+
expect(cfg.tuning).toEqual({ topK: 5, fallbackThreshold: 0.95, pinBoost: 0.15,
|
|
7
|
+
identifierBoost: 0.15, pageWindow: 1, maxQueriesPerContext: 5 });
|
|
8
|
+
expect(cfg.memory).toEqual({ enabled: true, override: false, filePrioritization: false, queryAugmentation: true });
|
|
9
|
+
expect(cfg.routing.rules).toEqual([]);
|
|
10
|
+
expect(cfg.knowledgeBases).toEqual({});
|
|
11
|
+
expect(cfg.managedContext).toBe(false);
|
|
12
|
+
expect(cfg.reranker).toBe("none");
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("accepts parsed objects and JSON strings for json options", () => {
|
|
16
|
+
const cfg = parsePipelineConfig({
|
|
17
|
+
routing: { rules: [{ id: "tech", label: "T", description: "d", main: ["a"], fallback: [] }] },
|
|
18
|
+
tuning: '{"topK": 8}',
|
|
19
|
+
managed_context: "true",
|
|
20
|
+
});
|
|
21
|
+
expect(cfg.routing.rules[0].id).toBe("tech");
|
|
22
|
+
expect(cfg.tuning.topK).toBe(8);
|
|
23
|
+
expect(cfg.tuning.fallbackThreshold).toBe(0.95); // partial json keeps other defaults
|
|
24
|
+
expect(cfg.managedContext).toBe(true);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("falls back to defaults on malformed json values", () => {
|
|
28
|
+
const warn = jest.spyOn(console, "warn").mockImplementation(() => {});
|
|
29
|
+
const cfg = parsePipelineConfig({ vocabulary: "{oops", memory: 42 });
|
|
30
|
+
expect(cfg.vocabulary.glossary).toEqual([]);
|
|
31
|
+
expect(cfg.memory.enabled).toBe(true);
|
|
32
|
+
warn.mockRestore();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("never throws on any invalid input — structural guarantee", () => {
|
|
36
|
+
const warn = jest.spyOn(console, "warn").mockImplementation(() => {});
|
|
37
|
+
// All config keys set to garbage values
|
|
38
|
+
const cfg = parsePipelineConfig({
|
|
39
|
+
instructions: 123,
|
|
40
|
+
reranker: [],
|
|
41
|
+
managed_context: "invalid",
|
|
42
|
+
require_preselected_contexts: {},
|
|
43
|
+
logging: null,
|
|
44
|
+
utility_model: false,
|
|
45
|
+
knowledge_bases: 999,
|
|
46
|
+
routing: "not json",
|
|
47
|
+
vocabulary: [1, 2, 3],
|
|
48
|
+
memory: "broken{json",
|
|
49
|
+
tuning: Symbol("bad"),
|
|
50
|
+
});
|
|
51
|
+
// Must return a valid PipelineConfig with all required fields
|
|
52
|
+
expect(cfg).toHaveProperty("instructions");
|
|
53
|
+
expect(cfg).toHaveProperty("reranker");
|
|
54
|
+
expect(cfg).toHaveProperty("tuning");
|
|
55
|
+
expect(cfg.tuning.topK).toBe(5); // defaults are applied
|
|
56
|
+
expect(cfg.routing.rules).toEqual([]);
|
|
57
|
+
expect(cfg.knowledgeBases).toEqual({});
|
|
58
|
+
warn.mockRestore();
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe("effectiveKbSettings", () => {
|
|
63
|
+
it("applies kind presets", () => {
|
|
64
|
+
const s = effectiveKbSettings({ enabled: true, kind: "documents", instructions: "", overrides: {} }, {});
|
|
65
|
+
expect(s).toMatchObject({ limit: 100, expand: { before: 7, after: 7 }, multiQuery: true, hyde: true });
|
|
66
|
+
expect(KIND_PRESETS.conversations.keywordPrefilter).toBe(true);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("precedence: overrides > context.configuration > preset", () => {
|
|
70
|
+
const ctx = { configuration: { expand: { before: 3, after: 3 }, cutoffs: { hybrid: 1.1 }, maxRetrievalResults: 40 } };
|
|
71
|
+
const s = effectiveKbSettings({ enabled: true, kind: "documents", instructions: "", overrides: { limit: 60 } }, ctx);
|
|
72
|
+
expect(s.limit).toBe(60); // override wins
|
|
73
|
+
expect(s.expand).toEqual({ before: 3, after: 3 }); // context config beats preset
|
|
74
|
+
expect(s.cutoffs).toEqual({ hybrid: 1.1 });
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("defaults a missing profile to enabled documents", () => {
|
|
78
|
+
const s = effectiveKbSettings(undefined, {});
|
|
79
|
+
expect(s.kind).toBe("documents");
|
|
80
|
+
});
|
|
81
|
+
});
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
export const KB_KINDS = ["documents", "conversations", "records"] as const;
|
|
4
|
+
export type KbKind = (typeof KB_KINDS)[number];
|
|
5
|
+
|
|
6
|
+
export const DEFAULT_PREFILTER_CUTOFF = 2.5;
|
|
7
|
+
export const RRF_K = 60;
|
|
8
|
+
export const CHUNK_GROUP_MAX = 10;
|
|
9
|
+
|
|
10
|
+
const kbProfileSchema = z.object({
|
|
11
|
+
enabled: z.boolean().default(true),
|
|
12
|
+
kind: z.enum(KB_KINDS).default("documents"),
|
|
13
|
+
instructions: z.string().default(""),
|
|
14
|
+
overrides: z
|
|
15
|
+
.object({
|
|
16
|
+
limit: z.number().int().positive().optional(),
|
|
17
|
+
expand: z.number().int().min(0).optional(),
|
|
18
|
+
multiQuery: z.boolean().optional(),
|
|
19
|
+
hyde: z.boolean().optional(),
|
|
20
|
+
})
|
|
21
|
+
.default({}),
|
|
22
|
+
});
|
|
23
|
+
const knowledgeBasesSchema = z.record(z.string(), kbProfileSchema);
|
|
24
|
+
|
|
25
|
+
const routingRuleSchema = z.object({
|
|
26
|
+
id: z.string(),
|
|
27
|
+
label: z.string(),
|
|
28
|
+
description: z.string(),
|
|
29
|
+
main: z.array(z.string()),
|
|
30
|
+
fallback: z.array(z.string()).default([]),
|
|
31
|
+
});
|
|
32
|
+
const routingSchema = z.object({ rules: z.array(routingRuleSchema).default([]) });
|
|
33
|
+
|
|
34
|
+
const identifierSetSchema = z.object({
|
|
35
|
+
name: z.string(),
|
|
36
|
+
description: z.string().default(""),
|
|
37
|
+
examples: z.array(z.string()).default([]),
|
|
38
|
+
strategy: z.enum(["fuzzy", "exact"]),
|
|
39
|
+
contexts: z.array(z.string()).default([]),
|
|
40
|
+
});
|
|
41
|
+
const vocabularySchema = z.object({
|
|
42
|
+
glossary: z.array(z.object({ term: z.string(), meaning: z.string() })).default([]),
|
|
43
|
+
identifiers: z.array(identifierSetSchema).default([]),
|
|
44
|
+
rewrites: z.array(z.object({ find: z.string(), replace: z.string() })).default([]),
|
|
45
|
+
styleHint: z.string().default(""),
|
|
46
|
+
});
|
|
47
|
+
const memorySchema = z.object({
|
|
48
|
+
enabled: z.boolean().default(true),
|
|
49
|
+
override: z.boolean().default(false),
|
|
50
|
+
filePrioritization: z.boolean().default(false),
|
|
51
|
+
queryAugmentation: z.boolean().default(true),
|
|
52
|
+
});
|
|
53
|
+
const tuningSchema = z.object({
|
|
54
|
+
topK: z.number().int().positive().default(5),
|
|
55
|
+
fallbackThreshold: z.number().min(0).max(1).default(0.95),
|
|
56
|
+
pinBoost: z.number().min(0).max(1).default(0.15),
|
|
57
|
+
identifierBoost: z.number().min(0).max(1).default(0.15),
|
|
58
|
+
pageWindow: z.number().int().min(0).default(1),
|
|
59
|
+
maxQueriesPerContext: z.number().int().positive().default(5),
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
export type KbProfile = z.infer<typeof kbProfileSchema>;
|
|
63
|
+
export type RoutingRule = z.infer<typeof routingRuleSchema>;
|
|
64
|
+
export type IdentifierSet = z.infer<typeof identifierSetSchema>;
|
|
65
|
+
|
|
66
|
+
export type PipelineConfig = {
|
|
67
|
+
instructions: string;
|
|
68
|
+
reranker: string;
|
|
69
|
+
managedContext: boolean;
|
|
70
|
+
requirePreselectedContexts: boolean;
|
|
71
|
+
logging: boolean;
|
|
72
|
+
utilityModel: string;
|
|
73
|
+
knowledgeBases: Record<string, KbProfile>;
|
|
74
|
+
routing: z.infer<typeof routingSchema>;
|
|
75
|
+
vocabulary: z.infer<typeof vocabularySchema>;
|
|
76
|
+
memory: z.infer<typeof memorySchema>;
|
|
77
|
+
tuning: z.infer<typeof tuningSchema>;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const boolVal = (v: unknown): boolean => v === true || v === "true" || v === 1;
|
|
81
|
+
const strVal = (v: unknown, fallback: string): string =>
|
|
82
|
+
typeof v === "string" && v.length > 0 ? v : fallback;
|
|
83
|
+
|
|
84
|
+
/** Parse one json-typed option: accepts an object (already hydrated), a JSON string,
|
|
85
|
+
* or anything else (→ schema default). Schema failures fall back to defaults with a warning. */
|
|
86
|
+
function jsonVal<S extends z.ZodTypeAny>(name: string, schema: S, v: unknown): z.infer<S> {
|
|
87
|
+
let candidate: unknown = v;
|
|
88
|
+
if (typeof v === "string" && v.trim().length > 0) {
|
|
89
|
+
try {
|
|
90
|
+
candidate = JSON.parse(v);
|
|
91
|
+
} catch (err) {
|
|
92
|
+
console.warn(`[EXULU pipeline] config "${name}" is not valid JSON — using defaults.`, err);
|
|
93
|
+
candidate = undefined;
|
|
94
|
+
}
|
|
95
|
+
} else if (typeof v !== "object" || v === null) {
|
|
96
|
+
candidate = undefined;
|
|
97
|
+
}
|
|
98
|
+
if (candidate !== undefined) {
|
|
99
|
+
const parsed = schema.safeParse(candidate);
|
|
100
|
+
if (parsed.success) return parsed.data;
|
|
101
|
+
console.warn(`[EXULU pipeline] config "${name}" failed validation — using defaults.`, parsed.error.message);
|
|
102
|
+
}
|
|
103
|
+
// Final fallback: try defaultFor, then undefined, then cast as last resort (never throw)
|
|
104
|
+
try {
|
|
105
|
+
return schema.parse(defaultFor(name));
|
|
106
|
+
} catch (err) {
|
|
107
|
+
console.warn(`[EXULU pipeline] failed to parse fallback for option "${name}" — falling back to schema.parse(undefined).`, err);
|
|
108
|
+
try {
|
|
109
|
+
return schema.parse(undefined);
|
|
110
|
+
} catch {
|
|
111
|
+
return defaultFor(name) as z.infer<S>;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function defaultFor(name: string): unknown {
|
|
117
|
+
switch (name) {
|
|
118
|
+
case "knowledge_bases": return {};
|
|
119
|
+
case "routing": return { rules: [] };
|
|
120
|
+
case "vocabulary": return { glossary: [], identifiers: [], rewrites: [], styleHint: "" };
|
|
121
|
+
case "memory": return {};
|
|
122
|
+
case "tuning": return {};
|
|
123
|
+
default: return {};
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function parsePipelineConfig(raw?: Record<string, unknown>): PipelineConfig {
|
|
128
|
+
const r = raw ?? {};
|
|
129
|
+
return {
|
|
130
|
+
instructions: strVal(r["instructions"], ""),
|
|
131
|
+
reranker: strVal(r["reranker"], "none"),
|
|
132
|
+
managedContext: boolVal(r["managed_context"]),
|
|
133
|
+
requirePreselectedContexts: boolVal(r["require_preselected_contexts"]),
|
|
134
|
+
logging: boolVal(r["logging"]),
|
|
135
|
+
utilityModel: strVal(r["utility_model"], ""),
|
|
136
|
+
knowledgeBases: jsonVal("knowledge_bases", knowledgeBasesSchema, r["knowledge_bases"]),
|
|
137
|
+
routing: jsonVal("routing", routingSchema, r["routing"]),
|
|
138
|
+
vocabulary: jsonVal("vocabulary", vocabularySchema, r["vocabulary"]),
|
|
139
|
+
memory: jsonVal("memory", memorySchema, r["memory"]),
|
|
140
|
+
tuning: jsonVal("tuning", tuningSchema, r["tuning"]),
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export const KIND_PRESETS: Record<
|
|
145
|
+
KbKind,
|
|
146
|
+
{ limit: number; expand: number; multiQuery: boolean; hyde: boolean; keywordPrefilter: boolean }
|
|
147
|
+
> = {
|
|
148
|
+
documents: { limit: 100, expand: 7, multiQuery: true, hyde: true, keywordPrefilter: false },
|
|
149
|
+
conversations: { limit: 20, expand: 5, multiQuery: false, hyde: false, keywordPrefilter: true },
|
|
150
|
+
records: { limit: 20, expand: 2, multiQuery: false, hyde: false, keywordPrefilter: false },
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
export type EffectiveKbSettings = {
|
|
154
|
+
kind: KbKind;
|
|
155
|
+
instructions: string;
|
|
156
|
+
limit: number;
|
|
157
|
+
expand: { before: number; after: number } | undefined;
|
|
158
|
+
cutoffs: { cosineDistance?: number; tsvector?: number; hybrid?: number } | undefined;
|
|
159
|
+
multiQuery: boolean;
|
|
160
|
+
hyde: boolean;
|
|
161
|
+
keywordPrefilter: boolean;
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
/** Precedence per setting: profile.overrides > ctx.configuration > kind preset. */
|
|
165
|
+
export function effectiveKbSettings(
|
|
166
|
+
profile: KbProfile | undefined,
|
|
167
|
+
ctx: { configuration?: { expand?: { before?: number; after?: number }; cutoffs?: any; maxRetrievalResults?: number } },
|
|
168
|
+
): EffectiveKbSettings {
|
|
169
|
+
const kind: KbKind = profile?.kind ?? "documents";
|
|
170
|
+
const preset = KIND_PRESETS[kind];
|
|
171
|
+
const o = profile?.overrides ?? {};
|
|
172
|
+
const conf = ctx.configuration ?? {};
|
|
173
|
+
const expandN = o.expand ?? undefined;
|
|
174
|
+
const expand = expandN !== undefined
|
|
175
|
+
? expandN === 0 ? undefined : { before: expandN, after: expandN }
|
|
176
|
+
: conf.expand && (conf.expand.before || conf.expand.after)
|
|
177
|
+
? { before: conf.expand.before ?? 0, after: conf.expand.after ?? 0 }
|
|
178
|
+
: { before: preset.expand, after: preset.expand };
|
|
179
|
+
return {
|
|
180
|
+
kind,
|
|
181
|
+
instructions: profile?.instructions ?? "",
|
|
182
|
+
limit: o.limit ?? conf.maxRetrievalResults ?? preset.limit,
|
|
183
|
+
expand,
|
|
184
|
+
cutoffs: conf.cutoffs ?? undefined,
|
|
185
|
+
multiQuery: o.multiQuery ?? preset.multiQuery,
|
|
186
|
+
hyde: o.hyde ?? preset.hyde,
|
|
187
|
+
keywordPrefilter: preset.keywordPrefilter,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { generateHydePassage, clearHydeCache } from "./hyde";
|
|
2
|
+
|
|
3
|
+
jest.mock("ai", () => ({ generateText: jest.fn() }));
|
|
4
|
+
import { generateText } from "ai";
|
|
5
|
+
|
|
6
|
+
beforeEach(() => {
|
|
7
|
+
clearHydeCache();
|
|
8
|
+
(generateText as jest.Mock).mockReset();
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
describe("generateHydePassage", () => {
|
|
12
|
+
it("returns the generated passage and includes the styleHint in the prompt", async () => {
|
|
13
|
+
(generateText as jest.Mock).mockResolvedValue({ text: "A passage." });
|
|
14
|
+
const p = await generateHydePassage({
|
|
15
|
+
originalQuestion: "How do I lock door A?",
|
|
16
|
+
relevantKeywords: ["door"],
|
|
17
|
+
importantKeyword: "FST-2XT",
|
|
18
|
+
styleHint: "German elevator manuals",
|
|
19
|
+
model: {},
|
|
20
|
+
});
|
|
21
|
+
expect(p).toBe("A passage.");
|
|
22
|
+
const prompt = (generateText as jest.Mock).mock.calls[0][0].prompt as string;
|
|
23
|
+
expect(prompt).toContain("German elevator manuals");
|
|
24
|
+
expect(prompt).toContain("FST-2XT");
|
|
25
|
+
expect(prompt).toContain("same language as the question");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("memoizes per question (one LLM call for two invocations)", async () => {
|
|
29
|
+
(generateText as jest.Mock).mockResolvedValue({ text: "A passage." });
|
|
30
|
+
const opts = { originalQuestion: "q", relevantKeywords: [], styleHint: "", model: {} };
|
|
31
|
+
await generateHydePassage(opts);
|
|
32
|
+
await generateHydePassage(opts);
|
|
33
|
+
expect(generateText).toHaveBeenCalledTimes(1);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("returns null on failure without caching the failure", async () => {
|
|
37
|
+
(generateText as jest.Mock)
|
|
38
|
+
.mockRejectedValueOnce(new Error("x"))
|
|
39
|
+
.mockResolvedValueOnce({ text: "ok" });
|
|
40
|
+
const opts = { originalQuestion: "q2", relevantKeywords: [], styleHint: "", model: {} };
|
|
41
|
+
expect(await generateHydePassage(opts)).toBeNull();
|
|
42
|
+
expect(await generateHydePassage(opts)).toBe("ok");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("returns null when no model is provided", async () => {
|
|
46
|
+
expect(
|
|
47
|
+
await generateHydePassage({
|
|
48
|
+
originalQuestion: "q",
|
|
49
|
+
relevantKeywords: [],
|
|
50
|
+
styleHint: "",
|
|
51
|
+
model: undefined,
|
|
52
|
+
})
|
|
53
|
+
).toBeNull();
|
|
54
|
+
});
|
|
55
|
+
});
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { generateText } from "ai";
|
|
2
|
+
import { extractIdentifierTokens } from "./text-utils";
|
|
3
|
+
|
|
4
|
+
// Per-question memoization of the HyDE passage. A single search invokes
|
|
5
|
+
// generateHydePassage several times with the same question, so without this we would pay
|
|
6
|
+
// one LLM call per invocation. The PROMISE is cached (not just the resolved value) so
|
|
7
|
+
// concurrent invocations within one request share a single generation. A failed/empty
|
|
8
|
+
// result is evicted so a transient blip does not disable HyDE for that question.
|
|
9
|
+
// FIFO-capped to bound memory.
|
|
10
|
+
const hydeCache = new Map<string, Promise<string | null>>();
|
|
11
|
+
const HYDE_CACHE_MAX = 200;
|
|
12
|
+
|
|
13
|
+
function hydeCacheKey(
|
|
14
|
+
originalQuestion: string,
|
|
15
|
+
relevantKeywords: string[],
|
|
16
|
+
styleHint: string,
|
|
17
|
+
importantKeyword?: string,
|
|
18
|
+
): string {
|
|
19
|
+
return JSON.stringify([originalQuestion, importantKeyword ?? "", relevantKeywords, styleHint]);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* HyDE (Hypothetical Document Embeddings): generate a short passage that answers the
|
|
24
|
+
* question in the style and vocabulary of the knowledge base. Embedding this passage
|
|
25
|
+
* bridges the gap between the user's wording and the document's wording, which is what
|
|
26
|
+
* lets the relevant chunk enter the candidate pool at all.
|
|
27
|
+
*
|
|
28
|
+
* Returns null on any failure (incl. no model) so the caller degrades gracefully.
|
|
29
|
+
* Memoized per question (see hydeCache).
|
|
30
|
+
*/
|
|
31
|
+
export function generateHydePassage({
|
|
32
|
+
originalQuestion,
|
|
33
|
+
relevantKeywords,
|
|
34
|
+
importantKeyword,
|
|
35
|
+
styleHint,
|
|
36
|
+
model,
|
|
37
|
+
}: {
|
|
38
|
+
originalQuestion: string;
|
|
39
|
+
relevantKeywords: string[];
|
|
40
|
+
importantKeyword?: string;
|
|
41
|
+
styleHint: string;
|
|
42
|
+
model: any;
|
|
43
|
+
}): Promise<string | null> {
|
|
44
|
+
if (!model) return Promise.resolve(null);
|
|
45
|
+
|
|
46
|
+
const key = hydeCacheKey(originalQuestion, relevantKeywords, styleHint, importantKeyword);
|
|
47
|
+
const cached = hydeCache.get(key);
|
|
48
|
+
if (cached) return cached;
|
|
49
|
+
|
|
50
|
+
const passagePromise = generateHydePassageUncached({
|
|
51
|
+
originalQuestion,
|
|
52
|
+
relevantKeywords,
|
|
53
|
+
importantKeyword,
|
|
54
|
+
styleHint,
|
|
55
|
+
model,
|
|
56
|
+
})
|
|
57
|
+
.then((passage) => {
|
|
58
|
+
// Don't persist failures/empties — allow a retry on the next call.
|
|
59
|
+
if (passage === null) hydeCache.delete(key);
|
|
60
|
+
return passage;
|
|
61
|
+
})
|
|
62
|
+
.catch((e) => {
|
|
63
|
+
console.warn("[EXULU] HyDE passage generation failed, skipping:", e);
|
|
64
|
+
hydeCache.delete(key);
|
|
65
|
+
return null;
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
if (hydeCache.size >= HYDE_CACHE_MAX) {
|
|
69
|
+
const oldest = hydeCache.keys().next().value;
|
|
70
|
+
if (oldest !== undefined) hydeCache.delete(oldest);
|
|
71
|
+
}
|
|
72
|
+
hydeCache.set(key, passagePromise);
|
|
73
|
+
return passagePromise;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function clearHydeCache(): void {
|
|
77
|
+
hydeCache.clear();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function generateHydePassageUncached({
|
|
81
|
+
originalQuestion,
|
|
82
|
+
relevantKeywords,
|
|
83
|
+
importantKeyword,
|
|
84
|
+
styleHint,
|
|
85
|
+
model,
|
|
86
|
+
}: {
|
|
87
|
+
originalQuestion: string;
|
|
88
|
+
relevantKeywords: string[];
|
|
89
|
+
importantKeyword?: string;
|
|
90
|
+
styleHint: string;
|
|
91
|
+
model: any;
|
|
92
|
+
}): Promise<string | null> {
|
|
93
|
+
const modelHint =
|
|
94
|
+
importantKeyword ||
|
|
95
|
+
extractIdentifierTokens([importantKeyword, ...relevantKeywords, originalQuestion])[0] ||
|
|
96
|
+
"";
|
|
97
|
+
|
|
98
|
+
let prompt = `You are a technical writer producing content in the style of this organization's knowledge base.${
|
|
99
|
+
styleHint ? `\nThe documents look like this: ${styleHint}` : ""
|
|
100
|
+
}
|
|
101
|
+
Write a SHORT hypothetical passage (3-6 sentences) that answers the question below the way the
|
|
102
|
+
original document would state it — using the domain's typical terminology (menu paths, parameters,
|
|
103
|
+
codes, section names). Write in the same language as the question.
|
|
104
|
+
`;
|
|
105
|
+
|
|
106
|
+
if (modelHint) {
|
|
107
|
+
prompt += `
|
|
108
|
+
IMPORTANT:
|
|
109
|
+
- Refer exactly to the mentioned product/model: "${modelHint}". Mention this exact designation and NO other variant.
|
|
110
|
+
- Invent plausible, domain-appropriate terms and structure; factual accuracy is not required — the text is only a search anchor.
|
|
111
|
+
- Output ONLY the passage, no preamble, no markdown.
|
|
112
|
+
`;
|
|
113
|
+
} else {
|
|
114
|
+
prompt += `
|
|
115
|
+
IMPORTANT:
|
|
116
|
+
- Invent plausible, domain-appropriate terms and structure; factual accuracy is not required — the text is only a search anchor.
|
|
117
|
+
- Output ONLY the passage, no preamble, no markdown.
|
|
118
|
+
`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
prompt += `
|
|
122
|
+
Question: "${originalQuestion}"
|
|
123
|
+
Relevant keywords: ${relevantKeywords.join(", ")}`;
|
|
124
|
+
|
|
125
|
+
const { text } = await generateText({
|
|
126
|
+
model,
|
|
127
|
+
prompt,
|
|
128
|
+
temperature: 0.3,
|
|
129
|
+
maxOutputTokens: 500,
|
|
130
|
+
});
|
|
131
|
+
const passage = (text || "").trim();
|
|
132
|
+
return passage.length > 0 ? passage : null;
|
|
133
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// ee/agentic-retrieval/pipeline/index.test.ts
|
|
2
|
+
import { createAgenticRetrievalTool, parsePreselectedItems } from "./index";
|
|
3
|
+
|
|
4
|
+
jest.mock("@EE/entitlements", () => ({ checkLicense: () => ({ "agentic-retrieval": true }) }));
|
|
5
|
+
jest.mock("@SRC/exulu/resolve-reranker", () => ({ resolveReranker: jest.fn(async () => ({ model: "m", rerank: async (_q: any, c: any) => c })) }));
|
|
6
|
+
jest.mock("@SRC/exulu/resolve-model", () => ({ resolveModel: jest.fn() }));
|
|
7
|
+
jest.mock("@SRC/exulu/app/singleton", () => ({ exuluApp: { get: () => ({ providers: [] }) } }));
|
|
8
|
+
jest.mock("./routing", () => ({ runRoutingPhase: jest.fn(async () => ({
|
|
9
|
+
mainContexts: ["docs"], fallbackContexts: [], userPinnedItemIdsByContext: new Map(),
|
|
10
|
+
userRequestedPage: null, hasExplicitDocAndPage: false, steps: [{ text: "routed" }] })) }));
|
|
11
|
+
jest.mock("./memory", () => ({ runMemoryPhase: jest.fn(async () => ({
|
|
12
|
+
memoryChunksForAnswer: [], memoryOverride: { active: false, chunks: [], reason: "" },
|
|
13
|
+
memoryPinnedItemIds: new Set(), updatedQuestion: "q", updatedKeywords: ["k"],
|
|
14
|
+
updatedImportantKeyword: "k", steps: [] })) }));
|
|
15
|
+
jest.mock("./prefilter", () => ({ resolveIdentifierPins: jest.fn(async () => ({
|
|
16
|
+
pinsByContext: new Map(), exactPinsByContext: new Map(), steps: [] })) }));
|
|
17
|
+
jest.mock("./search", () => ({ searchContexts: jest.fn(async () => ({ chunks: [] })) }));
|
|
18
|
+
jest.mock("./rerank", () => ({ rerankResults: jest.fn(async () => ({
|
|
19
|
+
limited_results: [], sorted_reranked_results: [], rerank_score_max_genuine: 1 })) }));
|
|
20
|
+
|
|
21
|
+
const drain = async (gen: AsyncGenerator<any>) => {
|
|
22
|
+
const out: any[] = [];
|
|
23
|
+
for await (const v of gen) out.push(v);
|
|
24
|
+
return out;
|
|
25
|
+
};
|
|
26
|
+
const ctx = (id: string) => ({ id, name: id, description: "", configuration: {} }) as any;
|
|
27
|
+
const makeTool = (config?: Record<string, unknown>, extra: any = {}) => {
|
|
28
|
+
const tool = createAgenticRetrievalTool({ contexts: [ctx("docs"), ctx("tickets")], model: {} as any, ...extra })!;
|
|
29
|
+
const exec = (tool.tool as any).execute as (i: any, o?: any) => AsyncGenerator<any>;
|
|
30
|
+
return (inputs: any) => exec({ toolVariablesConfig: config ?? {}, ...inputs });
|
|
31
|
+
};
|
|
32
|
+
const inputs = { userQuery: "q", relevantKeywords: ["k"], importantKeyword: "k" };
|
|
33
|
+
|
|
34
|
+
describe("createAgenticRetrievalTool", () => {
|
|
35
|
+
it("declares the static config surface (no per-context keys)", () => {
|
|
36
|
+
const tool = createAgenticRetrievalTool({ contexts: [ctx("docs")], model: {} as any })!;
|
|
37
|
+
const names = tool.config.map((c) => c.name).sort();
|
|
38
|
+
expect(names).toEqual([
|
|
39
|
+
"instructions", "knowledge_bases", "logging", "managed_context", "memory",
|
|
40
|
+
"max_steps", "require_preselected_contexts", "reranker", "routing", "tuning", "utility_model", "vocabulary",
|
|
41
|
+
].sort());
|
|
42
|
+
expect(tool.config.filter((c) => c.type === "json").map((c) => c.name).sort())
|
|
43
|
+
.toEqual(["knowledge_bases", "memory", "routing", "tuning", "vocabulary"].sort());
|
|
44
|
+
expect(tool.id).toBe("agentic_context_search");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("short-circuits managed_context without preselected items", async () => {
|
|
48
|
+
const run = makeTool({ managed_context: true });
|
|
49
|
+
const out = await drain(run(inputs));
|
|
50
|
+
expect(out[out.length - 1].result).toContain("preselect");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("yields a message (not a throw) when requested KBs fall outside the preselection", async () => {
|
|
54
|
+
const { runRoutingPhase } = jest.requireMock("./routing");
|
|
55
|
+
runRoutingPhase.mockResolvedValueOnce({
|
|
56
|
+
mainContexts: ["tickets"], fallbackContexts: [], userPinnedItemIdsByContext: new Map(),
|
|
57
|
+
userRequestedPage: null, hasExplicitDocAndPage: false, steps: [] });
|
|
58
|
+
const run = makeTool({}, { preselected: ["docs/item1"] });
|
|
59
|
+
const out = await drain(run(inputs));
|
|
60
|
+
expect(out[out.length - 1].result).toContain("not part of the preselected");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("streams cumulative AgenticRetrievalOutput snapshots and runs the full pipeline", async () => {
|
|
64
|
+
const run = makeTool({});
|
|
65
|
+
const out = await drain(run(inputs));
|
|
66
|
+
expect(out.length).toBeGreaterThanOrEqual(2);
|
|
67
|
+
const last = JSON.parse(out[out.length - 1].result);
|
|
68
|
+
expect(last).toMatchObject({ steps: expect.any(Array), reasoning: expect.any(Array), chunks: [] });
|
|
69
|
+
expect(last.steps.map((s: any) => s.text)).toContain("routed");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("accumulates top-level chunks across memory, main, and fallback evidence", async () => {
|
|
73
|
+
const { runMemoryPhase } = jest.requireMock("./memory");
|
|
74
|
+
const { rerankResults } = jest.requireMock("./rerank");
|
|
75
|
+
runMemoryPhase.mockResolvedValueOnce({
|
|
76
|
+
memoryChunksForAnswer: [{ chunk_id: "m1" }],
|
|
77
|
+
memoryOverride: { active: false, chunks: [], reason: "" },
|
|
78
|
+
memoryPinnedItemIds: new Set(),
|
|
79
|
+
updatedQuestion: "q",
|
|
80
|
+
updatedKeywords: ["k"],
|
|
81
|
+
updatedImportantKeyword: "k",
|
|
82
|
+
steps: [],
|
|
83
|
+
});
|
|
84
|
+
rerankResults.mockResolvedValueOnce({
|
|
85
|
+
limited_results: [{ chunk_id: "r1" }, { chunk_id: "m1" }],
|
|
86
|
+
sorted_reranked_results: [],
|
|
87
|
+
rerank_score_max_genuine: 1,
|
|
88
|
+
});
|
|
89
|
+
const run = makeTool({});
|
|
90
|
+
const out = await drain(run(inputs));
|
|
91
|
+
const last = JSON.parse(out[out.length - 1].result);
|
|
92
|
+
const ids = last.chunks.map((c: any) => c.chunk_id);
|
|
93
|
+
expect(ids).toContain("m1");
|
|
94
|
+
expect(ids).toContain("r1");
|
|
95
|
+
// dedup: "m1" appears in both memory and rerank results but must appear only once
|
|
96
|
+
expect(ids.filter((id: string) => id === "m1").length).toBe(1);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("filters contexts by knowledge_bases enabled=false", async () => {
|
|
100
|
+
const { searchContexts } = jest.requireMock("./search");
|
|
101
|
+
const { runRoutingPhase } = jest.requireMock("./routing");
|
|
102
|
+
const run = makeTool({ knowledge_bases: { tickets: { enabled: false } } });
|
|
103
|
+
await drain(run(inputs));
|
|
104
|
+
const routingCall = runRoutingPhase.mock.calls[runRoutingPhase.mock.calls.length - 1][0];
|
|
105
|
+
expect(routingCall.enabledContexts.map((c: any) => c.id)).toEqual(["docs"]);
|
|
106
|
+
expect(searchContexts).toHaveBeenCalled();
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe("payload deduplication", () => {
|
|
111
|
+
it("strips chunk_content from step chunks in the serialized payload; top-level keeps it", async () => {
|
|
112
|
+
const { runMemoryPhase } = jest.requireMock("./memory");
|
|
113
|
+
const memChunk = { chunk_id: "m1", chunk_content: "FULL MEMORY CONTENT", item_id: "i1", item_name: "Mem" };
|
|
114
|
+
runMemoryPhase.mockResolvedValueOnce({
|
|
115
|
+
memoryChunksForAnswer: [memChunk],
|
|
116
|
+
memoryOverride: { active: false, chunks: [], reason: "" },
|
|
117
|
+
memoryPinnedItemIds: new Set(), updatedQuestion: "q", updatedKeywords: ["k"],
|
|
118
|
+
updatedImportantKeyword: "k",
|
|
119
|
+
steps: [{ text: "memory step", chunks: [memChunk] }],
|
|
120
|
+
});
|
|
121
|
+
const run = makeTool({});
|
|
122
|
+
const out = await drain(run(inputs));
|
|
123
|
+
const last = JSON.parse(out[out.length - 1].result);
|
|
124
|
+
const stepWithChunks = last.steps.find((s: any) => s.chunks?.length > 0);
|
|
125
|
+
expect(stepWithChunks).toBeDefined();
|
|
126
|
+
expect(stepWithChunks.chunks[0].chunk_content).toBeUndefined();
|
|
127
|
+
expect(stepWithChunks.chunks[0].item_name).toBe("Mem");
|
|
128
|
+
const topLevel = last.chunks.find((c: any) => c.chunk_id === "m1");
|
|
129
|
+
expect(topLevel).toBeDefined();
|
|
130
|
+
expect(topLevel.chunk_content).toBe("FULL MEMORY CONTENT");
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe("parsePreselectedItems", () => {
|
|
135
|
+
it("parses ctx/item pairs and whole-context entries (null wins)", () => {
|
|
136
|
+
const m = parsePreselectedItems(["a/1", "a/2", "b", "b/3"]);
|
|
137
|
+
expect(m.get("a")).toEqual(["1", "2"]);
|
|
138
|
+
expect(m.get("b")).toBeNull();
|
|
139
|
+
});
|
|
140
|
+
});
|