@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.
- 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 +9568 -9245
- package/dist/index.d.cts +46 -29
- package/dist/index.d.ts +46 -29
- package/dist/index.js +5016 -549
- 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,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
|
+
}
|