@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,128 @@
|
|
|
1
|
+
import { splitChunksIntoGroups, rerankResults } from "./rerank";
|
|
2
|
+
|
|
3
|
+
const chunk = (id: string, itemId: string, index: number, extra: any = {}) => ({
|
|
4
|
+
chunk_id: id, item_id: itemId, item_name: "item " + itemId, chunk_index: index,
|
|
5
|
+
chunk_content: "c" + id, chunk_hybrid_score: 1, ...extra,
|
|
6
|
+
}) as any;
|
|
7
|
+
const state = (over: Partial<any> = {}) => ({
|
|
8
|
+
pinnedItemIds: new Set<string>(), userPinnedItemIds: new Set<string>(),
|
|
9
|
+
userRequestedPage: null, keywords: [], importantKeyword: "", ...over,
|
|
10
|
+
});
|
|
11
|
+
const tuning = { topK: 5, pinBoost: 0.15, identifierBoost: 0.15, pageWindow: 1 };
|
|
12
|
+
|
|
13
|
+
describe("splitChunksIntoGroups", () => {
|
|
14
|
+
it("returns empty for empty input", () => {
|
|
15
|
+
expect(splitChunksIntoGroups([])).toEqual([]);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("splits at index gaps and at 10 chunks", () => {
|
|
19
|
+
const run = Array.from({ length: 12 }, (_, i) => chunk("a" + i, "A", i + 1));
|
|
20
|
+
expect(splitChunksIntoGroups(run).map((g) => g.length)).toEqual([10, 2]);
|
|
21
|
+
const gap = [chunk("b1", "B", 1), chunk("b2", "B", 5)];
|
|
22
|
+
expect(splitChunksIntoGroups(gap)).toHaveLength(2);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("groups consecutive chunks together", () => {
|
|
26
|
+
const chunks = [chunk("x1", "X", 0), chunk("x2", "X", 1), chunk("x3", "X", 2)];
|
|
27
|
+
const groups = splitChunksIntoGroups(chunks);
|
|
28
|
+
expect(groups).toHaveLength(1);
|
|
29
|
+
expect(groups[0]).toHaveLength(3);
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe("rerankResults", () => {
|
|
34
|
+
it("reranks, applies pin boost, limits to topK, reports genuine max before boosts", async () => {
|
|
35
|
+
const chunks = Array.from({ length: 8 }, (_, i) => chunk("c" + i, "I" + i, 1));
|
|
36
|
+
const reranker = { model: "m", rerank: jest.fn(async (_q: string, items: any[]) =>
|
|
37
|
+
items.map((it, i) => ({ ...it, rerank_score: 0.8 - i * 0.05 }))) } as any;
|
|
38
|
+
const r = await rerankResults({ chunks, query: "q", state: state({ pinnedItemIds: new Set(["I7"]) }), reranker, tuning });
|
|
39
|
+
expect(r.rerank_score_max_genuine).toBeCloseTo(0.8);
|
|
40
|
+
expect(r.limited_results.some((c: any) => c.item_id === "I7")).toBe(true);
|
|
41
|
+
expect(r.limited_results.length).toBe(6);
|
|
42
|
+
const pinned = r.sorted_reranked_results.find((c: any) => c.item_id === "I7");
|
|
43
|
+
expect(pinned!.rerank_score).toBeCloseTo(0.8 - 7 * 0.05 + 0.15);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("caps force-included pinned groups at topK (token-bomb regression)", async () => {
|
|
47
|
+
// 30 pinned items — an unbounded force-include would return 30+ groups.
|
|
48
|
+
const chunks = Array.from({ length: 30 }, (_, i) => chunk("c" + i, "P" + i, 1));
|
|
49
|
+
const reranker = { model: "m", rerank: jest.fn(async (_q: string, items: any[]) =>
|
|
50
|
+
items.map((it, i) => ({ ...it, rerank_score: 0.9 - i * 0.01 }))) } as any;
|
|
51
|
+
const r = await rerankResults({
|
|
52
|
+
chunks, query: "q",
|
|
53
|
+
state: state({ pinnedItemIds: new Set(Array.from({ length: 30 }, (_, i) => "P" + i)) }),
|
|
54
|
+
reranker, tuning,
|
|
55
|
+
});
|
|
56
|
+
expect(r.limited_results.length).toBeLessThanOrEqual(2 * tuning.topK);
|
|
57
|
+
// The best-scoring pinned groups survive the cap
|
|
58
|
+
expect(r.limited_results.some((c: any) => c.item_id === "P0")).toBe(true);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("preserves the source-KB context tag through chunk grouping", async () => {
|
|
62
|
+
const chunks = [
|
|
63
|
+
{ ...chunk("c1", "A", 1), context: { id: "docs", name: "Tech Doc" } },
|
|
64
|
+
{ ...chunk("c2", "A", 2), context: { id: "docs", name: "Tech Doc" } },
|
|
65
|
+
];
|
|
66
|
+
const reranker = { model: "m", rerank: jest.fn(async (_q: string, items: any[]) =>
|
|
67
|
+
items.map((it) => ({ ...it, rerank_score: 0.5 }))) } as any;
|
|
68
|
+
const r = await rerankResults({ chunks, query: "q", state: state(), reranker, tuning });
|
|
69
|
+
expect((r.limited_results[0] as any).context).toEqual({ id: "docs", name: "Tech Doc" });
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("boosts items whose name matches an identifier token", async () => {
|
|
73
|
+
const chunks = [chunk("c1", "A", 1), chunk("c2", "B", 1)];
|
|
74
|
+
chunks[1].item_name = "hb_FST-2XT_manual";
|
|
75
|
+
const reranker = { model: "m", rerank: jest.fn(async (_q: string, items: any[]) =>
|
|
76
|
+
items.map((it) => ({ ...it, rerank_score: 0.5 }))) } as any;
|
|
77
|
+
const r = await rerankResults({ chunks, query: "q", state: state({ importantKeyword: "FST-2XT" }), reranker, tuning });
|
|
78
|
+
expect(r.sorted_reranked_results[0].item_name).toBe("hb_FST-2XT_manual");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("filters to the requested page window when a page was asked for", async () => {
|
|
82
|
+
const chunks = [
|
|
83
|
+
chunk("c1", "A", 1, { chunk_metadata: { page: 12 } }),
|
|
84
|
+
chunk("c2", "A", 2, { chunk_metadata: { page: 40 } }),
|
|
85
|
+
];
|
|
86
|
+
const reranker = { model: "m", rerank: jest.fn(async (_q: string, items: any[]) =>
|
|
87
|
+
items.map((it) => ({ ...it, rerank_score: 0.5 }))) } as any;
|
|
88
|
+
const r = await rerankResults({ chunks, query: "q", state: state({ userRequestedPage: 12 }), reranker, tuning });
|
|
89
|
+
expect(r.limited_results).toHaveLength(1);
|
|
90
|
+
expect((r.limited_results[0] as any).chunk_metadata.page).toBe(12);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("without a reranker falls back to hybrid-score ordering with genuine max 0", async () => {
|
|
94
|
+
const chunks = [chunk("c1", "A", 1, { chunk_hybrid_score: 0.2 }), chunk("c2", "B", 1, { chunk_hybrid_score: 0.9 })];
|
|
95
|
+
const r = await rerankResults({ chunks, query: "q", state: state(), reranker: undefined, tuning });
|
|
96
|
+
expect(r.sorted_reranked_results[0].chunk_id).toBe("c2");
|
|
97
|
+
expect(r.rerank_score_max_genuine).toBe(0);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("treats an empty rerank response for non-empty input as the fallback ordering", async () => {
|
|
101
|
+
const chunks = [chunk("c1", "A", 1)];
|
|
102
|
+
const reranker = { model: "m", rerank: jest.fn(async () => []) } as any;
|
|
103
|
+
const r = await rerankResults({ chunks, query: "q", state: state(), reranker, tuning });
|
|
104
|
+
expect(r.sorted_reranked_results).toHaveLength(1);
|
|
105
|
+
expect(r.rerank_score_max_genuine).toBe(0);
|
|
106
|
+
expect((r.sorted_reranked_results[0] as any).rerank_score).toBeUndefined();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("returns empty result for empty chunk input", async () => {
|
|
110
|
+
const r = await rerankResults({ chunks: [], query: "q", state: state(), reranker: undefined, tuning });
|
|
111
|
+
expect(r.limited_results).toHaveLength(0);
|
|
112
|
+
expect(r.sorted_reranked_results).toHaveLength(0);
|
|
113
|
+
expect(r.rerank_score_max_genuine).toBe(0);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("merges consecutive chunks from the same item before reranking", async () => {
|
|
117
|
+
const chunks = [
|
|
118
|
+
chunk("c1", "A", 0, { chunk_content: "hello" }),
|
|
119
|
+
chunk("c2", "A", 1, { chunk_content: "world" }),
|
|
120
|
+
];
|
|
121
|
+
const reranker = { model: "m", rerank: jest.fn(async (_q: string, items: any[]) =>
|
|
122
|
+
items.map((it) => ({ ...it, rerank_score: 0.5 }))) } as any;
|
|
123
|
+
await rerankResults({ chunks, query: "q", state: state(), reranker, tuning });
|
|
124
|
+
const passedItems: any[] = reranker.rerank.mock.calls[0][1];
|
|
125
|
+
expect(passedItems).toHaveLength(1);
|
|
126
|
+
expect(passedItems[0].chunk_content).toBe("hello\nworld");
|
|
127
|
+
});
|
|
128
|
+
});
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import type { ResolvedReranker } from "@SRC/exulu/resolve-reranker";
|
|
2
|
+
import { extractIdentifierTokens, itemMatchesIdentifierToken } from "./text-utils";
|
|
3
|
+
import { CHUNK_GROUP_MAX } from "./config";
|
|
4
|
+
import type { Chunk, ChunkWithScore, RerankState, RerankResult } from "./types";
|
|
5
|
+
|
|
6
|
+
export function splitChunksIntoGroups(chunks: Chunk[]): Chunk[][] {
|
|
7
|
+
if (chunks.length === 0) return [];
|
|
8
|
+
const groups: Chunk[][] = [];
|
|
9
|
+
let currentGroup: Chunk[] = [chunks[0]!];
|
|
10
|
+
for (let i = 1; i < chunks.length; i++) {
|
|
11
|
+
const prev = chunks[i - 1]!;
|
|
12
|
+
const curr = chunks[i]!;
|
|
13
|
+
const hasGap = (curr.chunk_index || 0) - (prev.chunk_index || 0) > 1;
|
|
14
|
+
if (currentGroup.length >= CHUNK_GROUP_MAX || hasGap) {
|
|
15
|
+
groups.push(currentGroup);
|
|
16
|
+
currentGroup = [curr];
|
|
17
|
+
} else {
|
|
18
|
+
currentGroup.push(curr);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
if (currentGroup.length > 0) groups.push(currentGroup);
|
|
22
|
+
return groups;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function buildRerankObjects(chunks: Chunk[]): Chunk[] {
|
|
26
|
+
const itemsMap = new Map<string, Chunk[]>();
|
|
27
|
+
for (const chunk of chunks) {
|
|
28
|
+
if (!itemsMap.has(chunk.item_id)) itemsMap.set(chunk.item_id, []);
|
|
29
|
+
itemsMap.get(chunk.item_id)!.push(chunk);
|
|
30
|
+
}
|
|
31
|
+
for (const items of itemsMap.values()) {
|
|
32
|
+
items.sort((a, b) => (a.chunk_index || 0) - (b.chunk_index || 0));
|
|
33
|
+
}
|
|
34
|
+
const objects: Chunk[] = [];
|
|
35
|
+
for (const items of itemsMap.values()) {
|
|
36
|
+
if (!items[0]) continue;
|
|
37
|
+
for (const group of splitChunksIntoGroups(items)) {
|
|
38
|
+
if (!group[0]) continue;
|
|
39
|
+
objects.push({
|
|
40
|
+
chunk_content: group.map(c => c.chunk_content).join('\n'),
|
|
41
|
+
// Preserve the source-KB tag attached by searchContexts (citation attribution)
|
|
42
|
+
context: (group[0] as any).context,
|
|
43
|
+
chunk_index: group[0].chunk_index,
|
|
44
|
+
chunk_id: group[0].chunk_id,
|
|
45
|
+
chunk_source: group[0].chunk_source,
|
|
46
|
+
chunk_metadata: group[0].chunk_metadata,
|
|
47
|
+
chunk_created_at: group[0].chunk_created_at,
|
|
48
|
+
chunk_updated_at: group[0].chunk_updated_at,
|
|
49
|
+
item_id: group[0].item_id,
|
|
50
|
+
item_external_id: group[0].item_external_id,
|
|
51
|
+
item_name: group[0].item_name,
|
|
52
|
+
item_updated_at: group[0].item_updated_at,
|
|
53
|
+
item_created_at: group[0].item_created_at,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return objects;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function rerankResults(opts: {
|
|
61
|
+
chunks: Chunk[];
|
|
62
|
+
query: string;
|
|
63
|
+
state: RerankState;
|
|
64
|
+
reranker?: ResolvedReranker;
|
|
65
|
+
tuning: { topK: number; pinBoost: number; identifierBoost: number; pageWindow: number };
|
|
66
|
+
}): Promise<RerankResult> {
|
|
67
|
+
const { chunks, query, state, reranker, tuning } = opts;
|
|
68
|
+
|
|
69
|
+
// Empty input fast path
|
|
70
|
+
if (chunks.length === 0) {
|
|
71
|
+
return { limited_results: [], sorted_reranked_results: [], rerank_score_max_genuine: 0 };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const rerankObjs = buildRerankObjects(chunks);
|
|
75
|
+
|
|
76
|
+
let sorted: ChunkWithScore[] = [];
|
|
77
|
+
let rerank_score_max_genuine = 0;
|
|
78
|
+
let useFallback = false;
|
|
79
|
+
|
|
80
|
+
if (reranker !== undefined) {
|
|
81
|
+
const reranked = await reranker.rerank(query, rerankObjs);
|
|
82
|
+
if (reranked.length === 0 && rerankObjs.length > 0) {
|
|
83
|
+
// reranker internal error path: non-empty input → empty output
|
|
84
|
+
console.warn("[EXULU pipeline] reranker returned [] for non-empty input — falling back to hybrid-score ordering");
|
|
85
|
+
useFallback = true;
|
|
86
|
+
} else {
|
|
87
|
+
sorted = (reranked as ChunkWithScore[]).sort((a, b) => (b.rerank_score || 0) - (a.rerank_score || 0));
|
|
88
|
+
rerank_score_max_genuine = sorted[0]?.rerank_score ?? 0;
|
|
89
|
+
}
|
|
90
|
+
} else {
|
|
91
|
+
useFallback = true;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (useFallback) {
|
|
95
|
+
// Sort groups by max chunk_hybrid_score from original chunks (per item)
|
|
96
|
+
const maxHybridByItem = new Map<string, number>();
|
|
97
|
+
for (const c of chunks) {
|
|
98
|
+
const cur = maxHybridByItem.get(c.item_id) ?? 0;
|
|
99
|
+
maxHybridByItem.set(c.item_id, Math.max(cur, c.chunk_hybrid_score ?? 0));
|
|
100
|
+
}
|
|
101
|
+
sorted = [...rerankObjs].sort((a, b) =>
|
|
102
|
+
(maxHybridByItem.get(b.item_id) ?? 0) - (maxHybridByItem.get(a.item_id) ?? 0)
|
|
103
|
+
) as ChunkWithScore[];
|
|
104
|
+
// DO NOT set rerank_score; rerank_score_max_genuine stays 0
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Pin boost — no-op in fallback (rerank_score is undefined on fallback chunks)
|
|
108
|
+
if (state.pinnedItemIds.size > 0) {
|
|
109
|
+
for (const r of sorted) {
|
|
110
|
+
if (r.item_id && state.pinnedItemIds.has(r.item_id) && r.rerank_score !== undefined) {
|
|
111
|
+
r.rerank_score = Math.min(1, r.rerank_score + tuning.pinBoost);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if (!useFallback) {
|
|
115
|
+
sorted.sort((a, b) => (b.rerank_score || 0) - (a.rerank_score || 0));
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Identify pinned groups for force-inclusion in limited_results. Force-inclusion is
|
|
120
|
+
// capped at topK groups (taken in current score order, i.e. the best-scoring pins):
|
|
121
|
+
// an unbounded cap made the output size proportional to the pin count — with a broad
|
|
122
|
+
// pin set the tool returned 30+ concatenated chunk-groups per call (hundreds of
|
|
123
|
+
// thousands of tokens), overflowing the calling agent's context window. Worst case
|
|
124
|
+
// is now ~2×topK groups regardless of how many items are pinned.
|
|
125
|
+
const pinnedGroups: ChunkWithScore[] = [];
|
|
126
|
+
if (state.pinnedItemIds.size > 0) {
|
|
127
|
+
const seen = new Set<string>();
|
|
128
|
+
for (const r of sorted) {
|
|
129
|
+
if (pinnedGroups.length >= tuning.topK) break;
|
|
130
|
+
if (r.item_id && state.pinnedItemIds.has(r.item_id) && !seen.has(r.item_id)) {
|
|
131
|
+
seen.add(r.item_id);
|
|
132
|
+
pinnedGroups.push(r);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Identifier boost — no-op in fallback (rerank_score is undefined)
|
|
138
|
+
const identifierTokens = extractIdentifierTokens([state.importantKeyword, ...state.keywords, query]);
|
|
139
|
+
if (identifierTokens.length > 0) {
|
|
140
|
+
let boosted = 0;
|
|
141
|
+
for (const r of sorted) {
|
|
142
|
+
if (itemMatchesIdentifierToken(r.item_name, identifierTokens) && r.rerank_score !== undefined) {
|
|
143
|
+
r.rerank_score = Math.min(1, r.rerank_score + tuning.identifierBoost);
|
|
144
|
+
boosted++;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
if (boosted > 0 && !useFallback) {
|
|
148
|
+
sorted.sort((a, b) => (b.rerank_score || 0) - (a.rerank_score || 0));
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Top-K selection with pinned-item force-inclusion
|
|
153
|
+
const { topK } = tuning;
|
|
154
|
+
let limited_results: ChunkWithScore[] = (() => {
|
|
155
|
+
const top = sorted.slice(0, topK);
|
|
156
|
+
if (pinnedGroups.length === 0) return top;
|
|
157
|
+
const byId = new Map<string, ChunkWithScore>();
|
|
158
|
+
for (const r of [...pinnedGroups, ...top]) {
|
|
159
|
+
if (r.chunk_id && !byId.has(r.chunk_id)) byId.set(r.chunk_id, r);
|
|
160
|
+
}
|
|
161
|
+
return Array.from(byId.values())
|
|
162
|
+
.sort((a, b) => (b.rerank_score || 0) - (a.rerank_score || 0))
|
|
163
|
+
.slice(0, Math.max(topK, pinnedGroups.length + topK));
|
|
164
|
+
})();
|
|
165
|
+
|
|
166
|
+
// Page filter — applies to all paths; scoped to userPinnedItemIds when non-empty
|
|
167
|
+
if (state.userRequestedPage !== null) {
|
|
168
|
+
const scopeToUserPinned = state.userPinnedItemIds.size > 0;
|
|
169
|
+
const pageMatched = sorted.filter(r => {
|
|
170
|
+
if (scopeToUserPinned && !(r.item_id && state.userPinnedItemIds.has(r.item_id))) return false;
|
|
171
|
+
const p = (r.chunk_metadata as { page?: unknown } | undefined)?.page;
|
|
172
|
+
return typeof p === 'number' && Math.abs(p - state.userRequestedPage!) <= tuning.pageWindow;
|
|
173
|
+
});
|
|
174
|
+
if (pageMatched.length > 0) limited_results = pageMatched.slice(0, topK);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return { limited_results, sorted_reranked_results: sorted, rerank_score_max_genuine };
|
|
178
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// ee/agentic-retrieval/pipeline/routing.test.ts
|
|
2
|
+
import { runRoutingPhase } from "./routing";
|
|
3
|
+
|
|
4
|
+
jest.mock("ai", () => ({ generateText: jest.fn(), Output: { object: (x: any) => x } }));
|
|
5
|
+
jest.mock("./prefilter", () => ({ fuzzyPrefilter: jest.fn(async () => []) }));
|
|
6
|
+
import { generateText } from "ai";
|
|
7
|
+
import { fuzzyPrefilter } from "./prefilter";
|
|
8
|
+
|
|
9
|
+
const enabled = [
|
|
10
|
+
{ id: "docs", name: "Docs", description: "manuals" },
|
|
11
|
+
{ id: "tickets", name: "Tickets", description: "support" },
|
|
12
|
+
];
|
|
13
|
+
const noHints = { output: { hasFilenameHint: false, filenameHints: [], hasPageHint: false, pageNumber: null } };
|
|
14
|
+
const noExplicit = { output: { explicitlyRequestedKnowledgeBases: [] } };
|
|
15
|
+
|
|
16
|
+
beforeEach(() => { (generateText as jest.Mock).mockReset(); (fuzzyPrefilter as jest.Mock).mockClear(); });
|
|
17
|
+
|
|
18
|
+
describe("runRoutingPhase", () => {
|
|
19
|
+
it("explicit KB request wins and yields no fallback", async () => {
|
|
20
|
+
(generateText as jest.Mock)
|
|
21
|
+
.mockResolvedValueOnce(noHints)
|
|
22
|
+
.mockResolvedValueOnce({ output: { explicitlyRequestedKnowledgeBases: ["tickets"] } });
|
|
23
|
+
const r = await runRoutingPhase({
|
|
24
|
+
question: "search tickets for X", enabledContexts: enabled, documentContexts: [],
|
|
25
|
+
routingRules: [{ id: "t", label: "T", description: "d", main: ["docs"], fallback: ["tickets"] }],
|
|
26
|
+
preselectedItems: new Map(), model: {},
|
|
27
|
+
});
|
|
28
|
+
expect(r.mainContexts).toEqual(["tickets"]);
|
|
29
|
+
expect(r.fallbackContexts).toEqual([]);
|
|
30
|
+
expect(generateText).toHaveBeenCalledTimes(2); // no classification call
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("classifies against configured rules when nothing is explicit", async () => {
|
|
34
|
+
(generateText as jest.Mock)
|
|
35
|
+
.mockResolvedValueOnce(noHints)
|
|
36
|
+
.mockResolvedValueOnce(noExplicit)
|
|
37
|
+
.mockResolvedValueOnce({ output: { ruleId: "t", reason: "because" } });
|
|
38
|
+
const r = await runRoutingPhase({
|
|
39
|
+
question: "how do I fix the door?", enabledContexts: enabled, documentContexts: [],
|
|
40
|
+
routingRules: [{ id: "t", label: "T", description: "d", main: ["docs"], fallback: ["tickets"] }],
|
|
41
|
+
preselectedItems: new Map(), model: {},
|
|
42
|
+
});
|
|
43
|
+
expect(r.mainContexts).toEqual(["docs"]);
|
|
44
|
+
expect(r.fallbackContexts).toEqual(["tickets"]);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("with no rules everything enabled becomes main", async () => {
|
|
48
|
+
(generateText as jest.Mock).mockResolvedValueOnce(noHints).mockResolvedValueOnce(noExplicit);
|
|
49
|
+
const r = await runRoutingPhase({
|
|
50
|
+
question: "q", enabledContexts: enabled, documentContexts: [], routingRules: [],
|
|
51
|
+
preselectedItems: new Map(), model: {},
|
|
52
|
+
});
|
|
53
|
+
expect(r.mainContexts).toEqual(["docs", "tickets"]);
|
|
54
|
+
expect(r.fallbackContexts).toEqual([]);
|
|
55
|
+
expect(generateText).toHaveBeenCalledTimes(2);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("discards doc-reference pins when the hint matches too many files (token-bomb regression)", async () => {
|
|
59
|
+
(generateText as jest.Mock)
|
|
60
|
+
.mockResolvedValueOnce({ output: { hasFilenameHint: true, filenameHints: ["FST-2XT"], hasPageHint: false, pageNumber: null } })
|
|
61
|
+
.mockResolvedValueOnce(noExplicit);
|
|
62
|
+
(fuzzyPrefilter as jest.Mock).mockResolvedValue(
|
|
63
|
+
Array.from({ length: 30 }, (_, i) => ({ id: "f" + i, name: "file" + i + ".pdf", key: "k" + i })),
|
|
64
|
+
);
|
|
65
|
+
const r = await runRoutingPhase({
|
|
66
|
+
question: "Was bedeutet der Fehler S2 CMP Input beim FST-2XT?",
|
|
67
|
+
enabledContexts: enabled, documentContexts: [{ id: "docs" }],
|
|
68
|
+
routingRules: [], preselectedItems: new Map(), model: {},
|
|
69
|
+
});
|
|
70
|
+
expect(r.userPinnedItemIdsByContext.size).toBe(0);
|
|
71
|
+
expect(r.hasExplicitDocAndPage).toBe(false);
|
|
72
|
+
expect(r.steps.some((s) => s.text.includes("too broad"))).toBe(true);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("doc-reference prompt carries the file-marker rule and configured identifier examples", async () => {
|
|
76
|
+
(generateText as jest.Mock).mockResolvedValueOnce(noHints).mockResolvedValueOnce(noExplicit);
|
|
77
|
+
await runRoutingPhase({
|
|
78
|
+
question: "q", enabledContexts: enabled, documentContexts: [], routingRules: [],
|
|
79
|
+
preselectedItems: new Map(), knownIdentifiers: ["FST", "ECO", "CBM-2"], model: {},
|
|
80
|
+
});
|
|
81
|
+
const docCall = (generateText as jest.Mock).mock.calls.find(([args]) =>
|
|
82
|
+
String(args?.system ?? "").includes("filename hint"),
|
|
83
|
+
);
|
|
84
|
+
expect(docCall).toBeDefined();
|
|
85
|
+
expect(docCall![0].system).toContain("STRICT RULE");
|
|
86
|
+
expect(docCall![0].system).toContain("FST, ECO, CBM-2");
|
|
87
|
+
expect(docCall![0].system).toContain("NOT be treated as filenames");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("explicit-KB prompt carries the topical-false-positive guard (newlkiag gate regression)", async () => {
|
|
91
|
+
(generateText as jest.Mock).mockResolvedValueOnce(noHints).mockResolvedValueOnce(noExplicit);
|
|
92
|
+
await runRoutingPhase({
|
|
93
|
+
question: "Welche Software-Änderungen gab es zuletzt?", enabledContexts: enabled,
|
|
94
|
+
documentContexts: [], routingRules: [], preselectedItems: new Map(), model: {},
|
|
95
|
+
});
|
|
96
|
+
const kbCall = (generateText as jest.Mock).mock.calls.find(([args]) =>
|
|
97
|
+
String(args?.system ?? "").includes("EXPLICITLY asked"),
|
|
98
|
+
);
|
|
99
|
+
expect(kbCall).toBeDefined();
|
|
100
|
+
expect(kbCall![0].system).toContain("is NOT an explicit request");
|
|
101
|
+
expect(kbCall![0].system).toContain("When in doubt, return");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("resolves filename hints against document contexts and reports the page", async () => {
|
|
105
|
+
(generateText as jest.Mock)
|
|
106
|
+
.mockResolvedValueOnce({ output: { hasFilenameHint: true, filenameHints: ["manual X3"], hasPageHint: true, pageNumber: 12 } })
|
|
107
|
+
.mockResolvedValueOnce(noExplicit);
|
|
108
|
+
(fuzzyPrefilter as jest.Mock).mockResolvedValue([{ id: "42", name: "Manual X3", key: "k" }]);
|
|
109
|
+
const docCtx = { id: "docs" };
|
|
110
|
+
const r = await runRoutingPhase({
|
|
111
|
+
question: "in manual X3 on page 12", enabledContexts: enabled, documentContexts: [docCtx],
|
|
112
|
+
routingRules: [], preselectedItems: new Map(), model: {},
|
|
113
|
+
});
|
|
114
|
+
expect([...r.userPinnedItemIdsByContext.get("docs")!]).toEqual(["42"]);
|
|
115
|
+
expect(r.userRequestedPage).toBe(12);
|
|
116
|
+
expect(r.hasExplicitDocAndPage).toBe(true);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("degrades to no pins when document-reference resolution fails", async () => {
|
|
120
|
+
(generateText as jest.Mock)
|
|
121
|
+
.mockResolvedValueOnce({ output: { hasFilenameHint: true, filenameHints: ["manual X3"], hasPageHint: false, pageNumber: null } })
|
|
122
|
+
.mockResolvedValueOnce(noExplicit);
|
|
123
|
+
(fuzzyPrefilter as jest.Mock).mockRejectedValueOnce(new Error("getItems failed"));
|
|
124
|
+
const r = await runRoutingPhase({
|
|
125
|
+
question: "in manual X3", enabledContexts: enabled, documentContexts: [{ id: "docs" }],
|
|
126
|
+
routingRules: [], preselectedItems: new Map(), model: {},
|
|
127
|
+
});
|
|
128
|
+
expect(r.userPinnedItemIdsByContext.size).toBe(0);
|
|
129
|
+
expect(r.steps.some((s) => s.text.includes("continuing without file pins"))).toBe(true);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("degrades to all-main when the classifier throws", async () => {
|
|
133
|
+
(generateText as jest.Mock)
|
|
134
|
+
.mockResolvedValueOnce(noHints)
|
|
135
|
+
.mockResolvedValueOnce(noExplicit)
|
|
136
|
+
.mockRejectedValue(new Error("down")); // withRetry exhausts on classification
|
|
137
|
+
const r = await runRoutingPhase({
|
|
138
|
+
question: "q", enabledContexts: enabled, documentContexts: [],
|
|
139
|
+
routingRules: [{ id: "t", label: "T", description: "d", main: ["docs"], fallback: [] }],
|
|
140
|
+
preselectedItems: new Map(), model: {},
|
|
141
|
+
});
|
|
142
|
+
expect(r.mainContexts).toEqual(["docs", "tickets"]);
|
|
143
|
+
}, 30000); // withRetry backs off 2s+4s before exhausting
|
|
144
|
+
});
|