@exulu/backend 1.70.0 → 2.0.1

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 +9568 -9245
  9. package/dist/index.d.cts +46 -29
  10. package/dist/index.d.ts +46 -29
  11. package/dist/index.js +5016 -549
  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,51 @@
1
+ import { multiQuerySearch, singleSearch } from "./multi-query";
2
+
3
+ const chunk = (id: string, score = 1) => ({
4
+ chunk_id: id,
5
+ chunk_content: id,
6
+ chunk_index: 1,
7
+ item_id: "i" + id,
8
+ item_name: "n" + id,
9
+ chunk_hybrid_score: score,
10
+ });
11
+
12
+ describe("multiQuerySearch", () => {
13
+ it("merges result sets with RRF; chunks in multiple sets rank first", async () => {
14
+ // Chunk "a" has the LOWEST hybrid score (0.2) but appears in BOTH result sets,
15
+ // so it must have the highest RRF score. A sort by chunk_hybrid_score would
16
+ // incorrectly place "b" or "c" first.
17
+ const ctx = {
18
+ search: jest
19
+ .fn()
20
+ .mockResolvedValueOnce({ chunks: [chunk("a", 0.2), chunk("b", 1.0)] })
21
+ .mockResolvedValueOnce({ chunks: [chunk("c", 1.0), chunk("a", 0.2)] }),
22
+ };
23
+ const merged = await multiQuerySearch({
24
+ queries: ["q1", "q2"],
25
+ config: { method: "hybridSearch", limit: 10 },
26
+ user: {},
27
+ role: "r",
28
+ pinnedItemIds: [],
29
+ context: ctx,
30
+ });
31
+ expect(merged[0].chunk_id).toBe("a"); // appears in both sets → highest RRF
32
+ expect(merged).toHaveLength(3);
33
+ expect(ctx.search).toHaveBeenCalledTimes(2);
34
+ // rrf_score must be propagated and correctly ordered
35
+ expect(merged[0].rrf_score).toBeDefined();
36
+ expect(merged[0].rrf_score).toBeGreaterThan(merged[1].rrf_score);
37
+ });
38
+
39
+ it("passes pinned item ids as an id filter", async () => {
40
+ const ctx = { search: jest.fn().mockResolvedValue({ chunks: [] }) };
41
+ await singleSearch({
42
+ query: "q",
43
+ config: { method: "hybridSearch", limit: 5 },
44
+ user: {},
45
+ role: "r",
46
+ pinnedItemIds: ["x"],
47
+ context: ctx,
48
+ });
49
+ expect(ctx.search.mock.calls[0][0].itemFilters).toEqual([{ id: { in: ["x"] } }]);
50
+ });
51
+ });
@@ -0,0 +1,158 @@
1
+ import { RRF_K } from "./config";
2
+ import type { Chunk } from "./types";
3
+
4
+ export type ChunkWithRRF = Chunk & { rrf_score: number; rrf_appearances: number };
5
+
6
+ export type SearchCallConfig = {
7
+ method: "hybridSearch" | "tsvector" | "cosineDistance";
8
+ cutoffs?: { hybrid?: number; cosineDistance?: number; tsvector?: number };
9
+ expand?: { before: number; after: number };
10
+ limit: number;
11
+ };
12
+
13
+ /**
14
+ * Performs a single search with the given query and configuration.
15
+ * Wraps the context.search call in try/catch to handle failures gracefully.
16
+ */
17
+ export async function singleSearch({
18
+ query,
19
+ config,
20
+ user,
21
+ role,
22
+ pinnedItemIds,
23
+ context,
24
+ }: {
25
+ query: string;
26
+ config: SearchCallConfig;
27
+ user: any;
28
+ role: any;
29
+ pinnedItemIds: string[];
30
+ context: any;
31
+ }): Promise<Chunk[]> {
32
+ try {
33
+ const itemFilters =
34
+ pinnedItemIds.length > 0 ? [{ id: { in: pinnedItemIds } }] : [];
35
+
36
+ const results = await context.search({
37
+ query,
38
+ chunkFilters: [],
39
+ itemFilters,
40
+ user,
41
+ role,
42
+ method: config.method,
43
+ sort: {
44
+ field: "createdAt",
45
+ direction: "desc",
46
+ },
47
+ cutoffs: config.cutoffs,
48
+ expand: config.expand,
49
+ trigger: "tool",
50
+ limit: config.limit,
51
+ page: 1,
52
+ });
53
+
54
+ return results.chunks || [];
55
+ } catch (e) {
56
+ console.warn("[EXULU] singleSearch failed for query:", query, e);
57
+ return [];
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Performs multiple searches with different query variations and merges results
63
+ * using Reciprocal Rank Fusion (RRF) to combine rankings from different queries.
64
+ */
65
+ export async function multiQuerySearch({
66
+ queries,
67
+ config,
68
+ user,
69
+ role,
70
+ pinnedItemIds,
71
+ context,
72
+ }: {
73
+ queries: string[];
74
+ config: SearchCallConfig;
75
+ user: any;
76
+ role: any;
77
+ pinnedItemIds: string[];
78
+ context: any;
79
+ }): Promise<ChunkWithRRF[]> {
80
+ // Run searches for each query in parallel
81
+ const searchPromises = queries.map((query) =>
82
+ singleSearch({
83
+ query,
84
+ config,
85
+ user,
86
+ role,
87
+ pinnedItemIds,
88
+ context,
89
+ }),
90
+ );
91
+
92
+ const results = await Promise.all(searchPromises);
93
+
94
+ // Merge results using Reciprocal Rank Fusion (RRF)
95
+ const merged = mergeResultsWithRRF(results);
96
+
97
+ return merged;
98
+ }
99
+
100
+ /**
101
+ * Merges search results from multiple queries using Reciprocal Rank Fusion (RRF).
102
+ * RRF formula: score(chunk) = sum(1 / (k + rank_in_query_i)) for all queries where chunk appears
103
+ * where k is RRF_K (typically 60).
104
+ *
105
+ * Boosts the hybrid score for chunks appearing in multiple result sets:
106
+ * boosted_score = original_score * (1 + 0.2 * (appearances - 1))
107
+ *
108
+ * For chunks without a hybrid score, falls back to rrfScore * 10.
109
+ */
110
+ function mergeResultsWithRRF(resultSets: Chunk[][]): ChunkWithRRF[] {
111
+ const chunkScores = new Map<
112
+ string,
113
+ {
114
+ chunk: Chunk;
115
+ rrfScore: number;
116
+ appearances: number;
117
+ }
118
+ >();
119
+
120
+ // Calculate RRF scores for each chunk
121
+ resultSets.forEach((results) => {
122
+ results.forEach((chunk, rank) => {
123
+ const chunkId = chunk.chunk_id;
124
+ const rrfContribution = 1 / (RRF_K + rank + 1); // rank is 0-indexed
125
+
126
+ if (!chunkScores.has(chunkId)) {
127
+ chunkScores.set(chunkId, {
128
+ chunk,
129
+ rrfScore: 0,
130
+ appearances: 0,
131
+ });
132
+ }
133
+
134
+ const entry = chunkScores.get(chunkId)!;
135
+ entry.rrfScore += rrfContribution;
136
+ entry.appearances += 1;
137
+ });
138
+ });
139
+
140
+ // Convert map to array and sort by RRF score
141
+ const merged = Array.from(chunkScores.values())
142
+ .map((entry) => ({
143
+ ...entry.chunk,
144
+ rrf_score: entry.rrfScore,
145
+ rrf_appearances: entry.appearances,
146
+ // Boost score if chunk appears in multiple query results
147
+ chunk_hybrid_score: entry.chunk.chunk_hybrid_score
148
+ ? entry.chunk.chunk_hybrid_score * (1 + 0.2 * (entry.appearances - 1))
149
+ : entry.rrfScore * 10, // Fallback if no hybrid score
150
+ }))
151
+ .sort((a, b) => b.rrf_score - a.rrf_score);
152
+
153
+ console.log(
154
+ `[EXULU] Multi-query search merged ${resultSets.reduce((sum, r) => sum + r.length, 0)} results into ${merged.length} unique chunks.`,
155
+ );
156
+
157
+ return merged;
158
+ }
@@ -0,0 +1,93 @@
1
+ import { fuzzyPrefilter, exactTokenPrefilter, resolveIdentifierPins, clearPrefilterCaches } from "./prefilter";
2
+
3
+ jest.mock("ai", () => ({
4
+ generateText: jest.fn(),
5
+ Output: { object: jest.fn((o) => o) },
6
+ }));
7
+ import { generateText } from "ai";
8
+
9
+ const items = [
10
+ { id: "1", name: "FST-2XT Manual", external_id: "/b/hb_FST-2XT_manual.pdf" },
11
+ { id: "2", name: "ECO Guide", external_id: "/b/eco_guide.pdf" },
12
+ { id: "3", name: "ISO 8100-1", external_id: "/b/din_en_iso_8100-1.pdf" },
13
+ ];
14
+ const ctx = (id: string) => ({ id, getItems: jest.fn(async () => items) });
15
+
16
+ beforeEach(() => {
17
+ clearPrefilterCaches();
18
+ (generateText as jest.Mock).mockReset();
19
+ });
20
+
21
+ describe("exactTokenPrefilter", () => {
22
+ it("matches exact separator-stripped substrings only", async () => {
23
+ const r = await exactTokenPrefilter({
24
+ cacheKey: "t1", tokens: ["8100-1"], context: ctx("c"), fields: ["name", "id", "external_id"],
25
+ normalize: (i) => i.external_id,
26
+ });
27
+ expect(r.map((x) => x.id)).toEqual(["3"]);
28
+ });
29
+ it("ignores tokens shorter than minTokenLength", async () => {
30
+ const r = await exactTokenPrefilter({
31
+ cacheKey: "t2", tokens: ["81"], context: ctx("c"), fields: ["name"], normalize: (i) => i.external_id,
32
+ });
33
+ expect(r).toEqual([]);
34
+ });
35
+ });
36
+
37
+ describe("fuzzyPrefilter", () => {
38
+ it("finds items whose normalized name matches the keywords", async () => {
39
+ const r = await fuzzyPrefilter({
40
+ cacheKey: "t3", relevantKeywords: ["FST-2XT"], context: ctx("c"),
41
+ fields: ["name", "id", "external_id"], normalize: (i) => i.external_id,
42
+ });
43
+ expect(r.map((x) => x.id)).toContain("1");
44
+ expect(r.map((x) => x.id)).not.toContain("2");
45
+ });
46
+ });
47
+
48
+ describe("resolveIdentifierPins", () => {
49
+ it("runs one extraction call per identifier set and routes pins to the set's contexts", async () => {
50
+ (generateText as jest.Mock).mockResolvedValue({
51
+ output: { hasMatches: true, matches: ["FST-2XT", "FST"] },
52
+ });
53
+ const c = ctx("docs");
54
+ const r = await resolveIdentifierPins({
55
+ question: "Wie sperre ich die Tür beim FST-2XT?",
56
+ identifierSets: [{ name: "Product names", description: "", examples: ["FST"], strategy: "fuzzy", contexts: ["docs"] }],
57
+ contextsById: new Map([["docs", c]]),
58
+ kbKindById: new Map([["docs", "documents"]]),
59
+ model: {},
60
+ });
61
+ expect(generateText).toHaveBeenCalledTimes(1);
62
+ expect([...(r.pinsByContext.get("docs") ?? [])]).toContain("1");
63
+ expect(r.exactPinsByContext.get("docs")).toBeUndefined(); // fuzzy sets don't boost
64
+ });
65
+
66
+ it("degrades to no pins when extraction fails", async () => {
67
+ (generateText as jest.Mock).mockRejectedValue(new Error("llm down"));
68
+ const r = await resolveIdentifierPins({
69
+ question: "q",
70
+ identifierSets: [{ name: "Norms", description: "", examples: ["ISO 8100"], strategy: "exact", contexts: ["docs"] }],
71
+ contextsById: new Map([["docs", ctx("docs")]]),
72
+ kbKindById: new Map([["docs", "documents"]]),
73
+ model: {},
74
+ });
75
+ expect(r.pinsByContext.size).toBe(0);
76
+ });
77
+
78
+ it("pins exact-matched items to both pinsByContext and exactPinsByContext", async () => {
79
+ (generateText as jest.Mock).mockResolvedValue({
80
+ output: { hasMatches: true, matches: ["8100-1"] },
81
+ });
82
+ const c = ctx("docs");
83
+ const r = await resolveIdentifierPins({
84
+ question: "Welche Norm beschreibt ISO 8100-1?",
85
+ identifierSets: [{ name: "Norms", description: "", examples: ["ISO 8100"], strategy: "exact", contexts: ["docs"] }],
86
+ contextsById: new Map([["docs", c]]),
87
+ kbKindById: new Map([["docs", "documents"]]),
88
+ model: {},
89
+ });
90
+ expect([...(r.pinsByContext.get("docs") ?? [])]).toContain("3");
91
+ expect([...(r.exactPinsByContext.get("docs") ?? [])]).toContain("3");
92
+ });
93
+ });
@@ -0,0 +1,389 @@
1
+ import Fuse from "fuse.js";
2
+ import { generateText, Output } from "ai";
3
+ import { z } from "zod";
4
+ import { withRetry } from "@SRC/utils/with-retry";
5
+ import { normalizeFileName } from "./text-utils";
6
+ import { DEFAULT_PREFILTER_CUTOFF, type IdentifierSet, type KbKind } from "./config";
7
+ import type { PhaseStep } from "./types";
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // Types
11
+ // ---------------------------------------------------------------------------
12
+
13
+ export type PrefilteredResult = {
14
+ key: string;
15
+ name: string;
16
+ id: string;
17
+ };
18
+
19
+ type ItemsCache = {
20
+ fuseIndex: any;
21
+ items: { name?: string; id?: string; external_id?: string; normalized?: string }[];
22
+ tsp: Date;
23
+ cacheKey: string;
24
+ };
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // Cache
28
+ // ---------------------------------------------------------------------------
29
+
30
+ let itemCaches: Record<string, ItemsCache> = {};
31
+
32
+ export function clearPrefilterCaches(): void {
33
+ itemCaches = {};
34
+ }
35
+
36
+ const ensureItemsCache = async ({
37
+ cacheKey,
38
+ context,
39
+ fields,
40
+ normalize,
41
+ }: {
42
+ cacheKey: string;
43
+ context: { id: string; getItems: (o: any) => Promise<any[]> };
44
+ fields: string[];
45
+ normalize: (item: any) => string | undefined;
46
+ }): Promise<ItemsCache> => {
47
+ if (
48
+ !itemCaches[cacheKey] ||
49
+ Date.now() - itemCaches[cacheKey]!.tsp.getTime() > 5 * 60 * 1000 ||
50
+ itemCaches[cacheKey]!.items.length === 0
51
+ ) {
52
+ const result = await context.getItems({ fields, filters: [] });
53
+
54
+ const normalizedItems = result.map((item: any) => ({
55
+ name: item.name,
56
+ id: item.id,
57
+ external_id: item.external_id,
58
+ normalized: normalize(item),
59
+ }));
60
+
61
+ itemCaches[cacheKey] = {
62
+ cacheKey,
63
+ items: normalizedItems,
64
+ tsp: new Date(),
65
+ fuseIndex: Fuse.createIndex(["normalized"], normalizedItems),
66
+ };
67
+ }
68
+
69
+ return itemCaches[cacheKey]!;
70
+ };
71
+
72
+ // ---------------------------------------------------------------------------
73
+ // Exact-token prefilter
74
+ // ---------------------------------------------------------------------------
75
+
76
+ /**
77
+ * Precise filename prefiltering for distinctive identifiers such as norm/standard numbers
78
+ * (e.g. "8100", "8100-1", "81-20"). Strips all separators from both tokens and normalized
79
+ * file names and requires an exact substring match. Tokens shorter than `minTokenLength`
80
+ * (after stripping) are ignored to avoid over-broad matches.
81
+ */
82
+ export async function exactTokenPrefilter({
83
+ cacheKey,
84
+ tokens,
85
+ context,
86
+ fields,
87
+ normalize,
88
+ minTokenLength = 3,
89
+ limit,
90
+ }: {
91
+ cacheKey: string;
92
+ tokens: string[];
93
+ context: { id: string; getItems: (o: any) => Promise<any[]> };
94
+ fields: string[];
95
+ normalize: (item: any) => string | undefined;
96
+ minTokenLength?: number;
97
+ limit?: number;
98
+ }): Promise<PrefilteredResult[]> {
99
+ const cache = await ensureItemsCache({ cacheKey, context, fields, normalize });
100
+
101
+ const strip = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, "");
102
+
103
+ const normalizedTokens = [...new Set(tokens.map(strip))].filter((t) => t.length >= minTokenLength);
104
+
105
+ if (!normalizedTokens.length) {
106
+ return [];
107
+ }
108
+
109
+ const matches = cache.items.filter((item) => {
110
+ const haystack = strip(item.normalized || "");
111
+ return normalizedTokens.some((token) => haystack.includes(token));
112
+ });
113
+
114
+ console.log("[EXULU pipeline] exactTokenPrefilter matched:", matches.map((m) => m.external_id));
115
+
116
+ return matches.slice(0, limit ?? 30).map((item) => ({
117
+ key: item.external_id ?? "",
118
+ name: item.name ?? "",
119
+ id: item.id ?? "",
120
+ }));
121
+ }
122
+
123
+ // ---------------------------------------------------------------------------
124
+ // Fuzzy (keyword-based) prefilter
125
+ // ---------------------------------------------------------------------------
126
+
127
+ /**
128
+ * Fuzzy keyword prefilter using Fuse.js. Searches the normalized item text and name
129
+ * with OR logic across keywords, then re-scores by keyword-coverage ratio, title matches,
130
+ * and an optional important-keyword boost. Returns only results scoring ≤ cutoff.
131
+ */
132
+ export async function fuzzyPrefilter({
133
+ cacheKey,
134
+ relevantKeywords,
135
+ importantKeyword,
136
+ context,
137
+ fields,
138
+ normalize,
139
+ cutoff = DEFAULT_PREFILTER_CUTOFF,
140
+ limit,
141
+ }: {
142
+ cacheKey: string;
143
+ relevantKeywords: string[];
144
+ importantKeyword?: string;
145
+ context: { id: string; getItems: (o: any) => Promise<any[]> };
146
+ fields: string[];
147
+ normalize: (item: any) => string | undefined;
148
+ cutoff?: number;
149
+ limit?: number;
150
+ }): Promise<PrefilteredResult[]> {
151
+ const cache = await ensureItemsCache({ cacheKey, context, fields, normalize });
152
+
153
+ if (!relevantKeywords?.length) {
154
+ return [];
155
+ }
156
+
157
+ // Split multi-word keywords, flatten, deduplicate
158
+ const uniqueKeywords = [...new Set(relevantKeywords.flatMap((k) => k.split(" ")))];
159
+
160
+ const index = Fuse.parseIndex(cache.fuseIndex);
161
+ const fuse = new Fuse(
162
+ cache.items,
163
+ {
164
+ includeScore: true,
165
+ useExtendedSearch: true,
166
+ keys: [
167
+ { name: "normalized", weight: 2.0 },
168
+ { name: "name", weight: 1.0 },
169
+ ],
170
+ threshold: 0.8, // Lenient — custom scoring applied afterward (0.0 = perfect, 1.0 = anything)
171
+ distance: 500, // Allow matching across longer distances in text
172
+ ignoreLocation: true,
173
+ minMatchCharLength: 3,
174
+ },
175
+ index,
176
+ );
177
+
178
+ // OR search across all keywords, then re-rank
179
+ const searchQuery = uniqueKeywords.join(" | ");
180
+ const result = fuse.search(searchQuery);
181
+
182
+ const rescored = result
183
+ .map((r: any) => {
184
+ const normalized = r.item.normalized?.toLowerCase() || "";
185
+ const name = r.item.name?.toLowerCase() || "";
186
+ // Flexible matching: replace separator chars with spaces
187
+ const normalizedFlexible = normalized.replace(/[-_\.]/g, " ").replace(/\s+/g, " ");
188
+ const nameFlexible = name.replace(/[-_\.]/g, " ").replace(/\s+/g, " ");
189
+
190
+ // Count how many keywords appear in the item (exact or separator-flexible)
191
+ const matchedKeywords = uniqueKeywords.filter((keyword: string) => {
192
+ const keywordLower = keyword.toLowerCase();
193
+ const keywordFlexible = keywordLower.replace(/[-_\.]/g, " ").replace(/\s+/g, " ");
194
+
195
+ const exactMatchInNormalized =
196
+ normalized.includes(keywordLower) || normalizedFlexible.includes(keywordFlexible);
197
+ const exactMatchInName = name.includes(keywordLower) || nameFlexible.includes(keywordFlexible);
198
+
199
+ return exactMatchInNormalized || exactMatchInName;
200
+ });
201
+ const matchRatio = matchedKeywords.length / uniqueKeywords.length;
202
+
203
+ // Title match count for additional boost
204
+ const titleMatches = uniqueKeywords.filter((keyword: string) => {
205
+ const keywordLower = keyword.toLowerCase();
206
+ const keywordFlexible = keywordLower.replace(/[-_\.]/g, " ").replace(/\s+/g, " ");
207
+
208
+ return name.includes(keywordLower) || nameFlexible.includes(keywordFlexible);
209
+ }).length;
210
+
211
+ // Important-keyword presence check
212
+ let hasImportantKeyword = false;
213
+ let importantKeywordInTitle = false;
214
+ if (importantKeyword) {
215
+ const importantLower = importantKeyword.toLowerCase();
216
+ const importantFlexible = importantLower.replace(/[-_\.]/g, " ").replace(/\s+/g, " ");
217
+
218
+ hasImportantKeyword =
219
+ normalized.includes(importantLower) || normalizedFlexible.includes(importantFlexible);
220
+ importantKeywordInTitle = name.includes(importantLower) || nameFlexible.includes(importantFlexible);
221
+ }
222
+
223
+ // Re-score: penalize by match-ratio, then boost for title and important keyword
224
+ let adjustedScore: number;
225
+ if (matchRatio === 1.0) {
226
+ adjustedScore = r.score;
227
+ } else if (matchRatio >= 0.66) {
228
+ adjustedScore = r.score * 1.5;
229
+ } else if (matchRatio >= 0.33) {
230
+ adjustedScore = r.score * 3;
231
+ } else {
232
+ adjustedScore = r.score * 10;
233
+ }
234
+
235
+ if (titleMatches > 0) {
236
+ const titleBoost = Math.pow(0.6, titleMatches); // 0.6^n per title match
237
+ adjustedScore = adjustedScore * titleBoost;
238
+ }
239
+
240
+ if (hasImportantKeyword) {
241
+ adjustedScore = adjustedScore * 0.5; // 50% better score
242
+ if (importantKeywordInTitle) {
243
+ adjustedScore = adjustedScore * 0.4; // additional 60% boost if in title
244
+ }
245
+ }
246
+
247
+ return {
248
+ ...r,
249
+ matchedKeywords: matchedKeywords.length,
250
+ matchRatio,
251
+ titleMatches,
252
+ hasImportantKeyword,
253
+ importantKeywordInTitle,
254
+ originalScore: r.score,
255
+ score: adjustedScore,
256
+ };
257
+ })
258
+ .sort((a: any, b: any) => a.score - b.score);
259
+
260
+ const filteredResults = rescored.filter((r: any) => r.score <= cutoff);
261
+
262
+ const prefiltered = filteredResults.slice(0, limit ?? 30).map((result: any) => ({
263
+ key: result.item.external_id,
264
+ name: result.item.name,
265
+ id: result.item.id,
266
+ }));
267
+
268
+ console.log(
269
+ `[EXULU pipeline] fuzzyPrefilter: ${prefiltered.length} result(s) for [${uniqueKeywords.join(", ")}]`,
270
+ );
271
+
272
+ return prefiltered;
273
+ }
274
+
275
+ // ---------------------------------------------------------------------------
276
+ // Prompt templates
277
+ // ---------------------------------------------------------------------------
278
+
279
+ const FUZZY_EXTRACTION_PROMPT = (set: IdentifierSet) => `
280
+ You are checking whether the user's question references any "${set.name}".
281
+ ${set.description ? set.description + "\n" : ""}Examples of such identifiers: ${set.examples.join(", ")}.
282
+ If the question references one, return it BOTH as its stem and its full version.
283
+ For example, for "${set.examples[0] ?? "ABC-1"}-3" return "${set.examples[0] ?? "ABC-1"}" and "${set.examples[0] ?? "ABC-1"}-3".
284
+ If the question references none, return an empty array and hasMatches set to false.`;
285
+
286
+ const EXACT_EXTRACTION_PROMPT = (set: IdentifierSet) => `
287
+ You are checking whether the user's question references any "${set.name}".
288
+ ${set.description ? set.description + "\n" : ""}Examples: ${set.examples.join(", ")}.
289
+ If it does, return hasMatches true and matches: a list of search tokens used to find the
290
+ matching document by its file name. Include BOTH the full identifier and useful partial
291
+ forms — the bare number on its own, and each individual part when a multi-part identifier
292
+ is referenced (e.g. "ISO 8100-1-2" must yield "8100-1" AND "8100-2").
293
+ Do NOT include generic single words on their own.
294
+ If the question references none, return hasMatches false and an empty array.`;
295
+
296
+ // ---------------------------------------------------------------------------
297
+ // Config-driven identifier-pin resolution
298
+ // ---------------------------------------------------------------------------
299
+
300
+ export async function resolveIdentifierPins({
301
+ question,
302
+ identifierSets,
303
+ contextsById,
304
+ kbKindById,
305
+ model,
306
+ }: {
307
+ question: string;
308
+ identifierSets: IdentifierSet[];
309
+ contextsById: Map<string, any>;
310
+ kbKindById: Map<string, KbKind>;
311
+ model: any;
312
+ }): Promise<{
313
+ pinsByContext: Map<string, Set<string>>;
314
+ exactPinsByContext: Map<string, Set<string>>;
315
+ steps: PhaseStep[];
316
+ }> {
317
+ const pinsByContext = new Map<string, Set<string>>();
318
+ const exactPinsByContext = new Map<string, Set<string>>();
319
+ const steps: PhaseStep[] = [];
320
+
321
+ await Promise.all(
322
+ identifierSets.map(async (set) => {
323
+ if (!set.contexts.length) return;
324
+ try {
325
+ const { output } = await withRetry(
326
+ () =>
327
+ generateText({
328
+ model,
329
+ temperature: 0,
330
+ system:
331
+ set.strategy === "exact" ? EXACT_EXTRACTION_PROMPT(set) : FUZZY_EXTRACTION_PROMPT(set),
332
+ messages: [{ role: "user", content: question }],
333
+ output: Output.object({
334
+ schema: z.object({
335
+ hasMatches: z.boolean(),
336
+ matches: z.array(z.string()).optional(),
337
+ }),
338
+ }),
339
+ maxOutputTokens: 300,
340
+ }),
341
+ 3,
342
+ );
343
+ if (!output?.hasMatches || !output.matches?.length) return;
344
+ steps.push({ text: `Detected ${set.name} in the question: ${output.matches.join(", ")}` });
345
+
346
+ await Promise.all(
347
+ set.contexts.map(async (ctxId) => {
348
+ const ctx = contextsById.get(ctxId);
349
+ if (!ctx) return;
350
+ const common = {
351
+ cacheKey: `identifier:${ctxId}`,
352
+ context: ctx,
353
+ fields: ["name", "id", "external_id"],
354
+ normalize: (item: any) =>
355
+ item.external_id ? normalizeFileName(item.external_id) : item.name,
356
+ };
357
+ const matched =
358
+ set.strategy === "exact"
359
+ ? await exactTokenPrefilter({ ...common, tokens: output.matches! })
360
+ : await fuzzyPrefilter({
361
+ ...common,
362
+ relevantKeywords: output.matches!,
363
+ cutoff: DEFAULT_PREFILTER_CUTOFF,
364
+ });
365
+ if (!matched.length) return;
366
+ const target = pinsByContext.get(ctxId) ?? new Set<string>();
367
+ for (const m of matched) target.add(m.id);
368
+ pinsByContext.set(ctxId, target);
369
+ if (set.strategy === "exact") {
370
+ const boost = exactPinsByContext.get(ctxId) ?? new Set<string>();
371
+ for (const m of matched) boost.add(m.id);
372
+ exactPinsByContext.set(ctxId, boost);
373
+ }
374
+ steps.push({
375
+ text: `Limiting "${ctxId}" to ${matched.length} matching file(s): ${matched.map((m) => m.name).join(", ")}`,
376
+ });
377
+ }),
378
+ );
379
+ } catch (err) {
380
+ console.warn(
381
+ `[EXULU pipeline] identifier extraction for "${set.name}" failed — skipping.`,
382
+ err,
383
+ );
384
+ }
385
+ }),
386
+ );
387
+
388
+ return { pinsByContext, exactPinsByContext, steps };
389
+ }