Skip to content

Commit a827f35

Browse files
🛤️ fix: Align File Search Citation Paths (#15650)
* fix: keep file search anchors aligned with citation passages * refactor: centralize file citation selection * test: wire citation helpers into API mocks --------- Co-authored-by: aeyeopsdev <275853971+aeyeopsdev@users.noreply.github.com> Co-authored-by: Danny Avila <danny@librechat.ai>
1 parent abdb8f3 commit a827f35

9 files changed

Lines changed: 133 additions & 108 deletions

File tree

api/app/clients/tools/util/fileSearch.js

Lines changed: 32 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
const axios = require('axios');
22
const { logger } = require('@librechat/data-schemas');
33
const { tool } = require('@librechat/agents/langchain/tools');
4-
const { generateShortLivedToken, logAxiosError } = require('@librechat/api');
5-
const { Tools, EToolResources } = require('librechat-data-provider');
4+
const {
5+
logAxiosError,
6+
selectFileCitationSources,
7+
generateShortLivedToken,
8+
} = require('@librechat/api');
9+
const { Tools, EModelEndpoint, EToolResources } = require('librechat-data-provider');
610
const { filterFilesByAgentAccess } = require('~/server/services/Files/permissions');
711
const { getFiles } = require('~/models');
812

@@ -82,13 +86,20 @@ const primeFiles = async (options) => {
8286
/**
8387
*
8488
* @param {Object} options
89+
* @param {AppConfig} [options.appConfig]
8590
* @param {string} options.userId
8691
* @param {Array<{ file_id: string; filename: string; fromAgent?: boolean }>} options.files
8792
* @param {string} [options.entity_id]
8893
* @param {boolean} [options.fileCitations=false] - Whether to include citation instructions
8994
* @returns
9095
*/
91-
const createFileSearchTool = async ({ userId, files, entity_id, fileCitations = false }) => {
96+
const createFileSearchTool = async ({
97+
userId,
98+
files,
99+
entity_id,
100+
fileCitations = false,
101+
appConfig,
102+
}) => {
92103
return tool(
93104
async ({ query }) => {
94105
if (files.length === 0) {
@@ -132,6 +143,7 @@ const createFileSearchTool = async ({ userId, files, entity_id, fileCitations =
132143
'Content-Type': 'application/json',
133144
},
134145
})
146+
.then((result) => ({ data: result.data, file_id: file.file_id }))
135147
.catch((error) => {
136148
logAxiosError({
137149
message: 'Error encountered in `file_search` while querying file',
@@ -149,12 +161,12 @@ const createFileSearchTool = async ({ userId, files, entity_id, fileCitations =
149161
}
150162

151163
const formattedResults = validResults
152-
.flatMap((result, fileIndex) =>
164+
.flatMap((result) =>
153165
result.data.map(([docInfo, distance]) => ({
154166
filename: docInfo.metadata.source.split('/').pop(),
155167
content: docInfo.page_content,
156168
distance,
157-
file_id: files[fileIndex]?.file_id,
169+
file_id: result.file_id,
158170
page: docInfo.metadata.page || null,
159171
})),
160172
)
@@ -168,15 +180,6 @@ const createFileSearchTool = async ({ userId, files, entity_id, fileCitations =
168180
];
169181
}
170182

171-
const formattedString = formattedResults
172-
.map(
173-
(result, index) =>
174-
`File: ${result.filename}${
175-
fileCitations ? `\nAnchor: \\ue202turn0file${index} (${result.filename})` : ''
176-
}\nRelevance: ${(1.0 - result.distance).toFixed(4)}\nContent: ${result.content}\n`,
177-
)
178-
.join('\n---\n');
179-
180183
const sources = formattedResults.map((result) => ({
181184
type: 'file',
182185
fileId: result.file_id,
@@ -187,6 +190,21 @@ const createFileSearchTool = async ({ userId, files, entity_id, fileCitations =
187190
pageRelevance: result.page ? { [result.page]: 1.0 - result.distance } : {},
188191
}));
189192

193+
const citationConfig = appConfig?.endpoints?.[EModelEndpoint.agents];
194+
const citationSources = fileCitations
195+
? selectFileCitationSources(sources, citationConfig)
196+
: [];
197+
const formattedString = formattedResults
198+
.map((result, index) => {
199+
const citationIndex = citationSources.indexOf(sources[index]);
200+
return `File: ${result.filename}${
201+
citationIndex >= 0
202+
? `\nAnchor: \\ue202turn0file${citationIndex} (${result.filename})`
203+
: ''
204+
}\nRelevance: ${(1.0 - result.distance).toFixed(4)}\nContent: ${result.content}\n`;
205+
})
206+
.join('\n---\n');
207+
190208
return [formattedString, { [Tools.file_search]: { sources, fileCitations } }];
191209
},
192210
{

api/app/clients/tools/util/handleTools.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -417,6 +417,7 @@ const loadTools = async ({
417417
}
418418

419419
return createFileSearchTool({
420+
appConfig: options.req.config,
420421
userId: user,
421422
files,
422423
entity_id: agent?.id,

api/server/services/Files/Citations/index.js

Lines changed: 8 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
const { nanoid } = require('nanoid');
22
const { checkAccess } = require('@librechat/api');
33
const { logger } = require('@librechat/data-schemas');
4+
const { applyCitationLimits, selectFileCitationSources } = require('@librechat/api');
45
const {
56
Tools,
67
Permissions,
@@ -54,22 +55,15 @@ async function processFileCitations({ user, appConfig, toolArtifact, toolCallId,
5455
}
5556
}
5657

57-
const maxCitations = appConfig.endpoints?.[EModelEndpoint.agents]?.maxCitations ?? 30;
58-
const maxCitationsPerFile =
59-
appConfig.endpoints?.[EModelEndpoint.agents]?.maxCitationsPerFile ?? 5;
60-
const minRelevanceScore =
61-
appConfig.endpoints?.[EModelEndpoint.agents]?.minRelevanceScore ?? 0.45;
62-
63-
const sources = toolArtifact[Tools.file_search].sources || [];
64-
const filteredSources = sources.filter((source) => source.relevance >= minRelevanceScore);
65-
if (filteredSources.length === 0) {
66-
logger.debug(
67-
`[processFileCitations] No sources above relevance threshold of ${minRelevanceScore}`,
68-
);
58+
const selectedSources = selectFileCitationSources(toolArtifact[Tools.file_search].sources, {
59+
maxCitations: appConfig.endpoints?.[EModelEndpoint.agents]?.maxCitations,
60+
maxCitationsPerFile: appConfig.endpoints?.[EModelEndpoint.agents]?.maxCitationsPerFile,
61+
minRelevanceScore: appConfig.endpoints?.[EModelEndpoint.agents]?.minRelevanceScore,
62+
});
63+
if (selectedSources.length === 0) {
6964
return null;
7065
}
7166

72-
const selectedSources = applyCitationLimits(filteredSources, maxCitations, maxCitationsPerFile);
7367
const enhancedSources = await enhanceSourcesWithMetadata(selectedSources, appConfig);
7468

7569
if (enhancedSources.length > 0) {
@@ -92,32 +86,6 @@ async function processFileCitations({ user, appConfig, toolArtifact, toolCallId,
9286
}
9387
}
9488

95-
/**
96-
* Apply citation limits to sources
97-
* @param {Array} sources - All sources
98-
* @param {number} maxCitations - Maximum total citations
99-
* @param {number} maxCitationsPerFile - Maximum citations per file
100-
* @returns {Array} Selected sources
101-
*/
102-
function applyCitationLimits(sources, maxCitations, maxCitationsPerFile) {
103-
const byFile = {};
104-
sources.forEach((source) => {
105-
if (!byFile[source.fileId]) {
106-
byFile[source.fileId] = [];
107-
}
108-
byFile[source.fileId].push(source);
109-
});
110-
111-
const representatives = [];
112-
for (const fileId in byFile) {
113-
const fileSources = byFile[fileId].sort((a, b) => b.relevance - a.relevance);
114-
const selectedFromFile = fileSources.slice(0, maxCitationsPerFile);
115-
representatives.push(...selectedFromFile);
116-
}
117-
118-
return representatives.sort((a, b) => b.relevance - a.relevance).slice(0, maxCitations);
119-
}
120-
12189
/**
12290
* Enhance sources with file metadata from database
12391
* @param {Array} sources - Selected sources
@@ -156,6 +124,7 @@ async function enhanceSourcesWithMetadata(sources, appConfig) {
156124
}
157125

158126
module.exports = {
127+
selectFileCitationSources,
159128
applyCitationLimits,
160129
processFileCitations,
161130
enhanceSourcesWithMetadata,

api/test/app/clients/tools/util/fileSearch.test.js

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,14 @@ const axios = require('axios');
22
const { ResourceType } = require('librechat-data-provider');
33

44
jest.mock('axios');
5-
jest.mock('@librechat/api', () => ({
6-
generateShortLivedToken: jest.fn(),
7-
logAxiosError: jest.fn(),
8-
}));
5+
jest.mock('@librechat/api', () => {
6+
const { selectFileCitationSources } = jest.requireActual('@librechat/api');
7+
return {
8+
generateShortLivedToken: jest.fn(),
9+
logAxiosError: jest.fn(),
10+
selectFileCitationSources,
11+
};
12+
});
913

1014
jest.mock('@librechat/data-schemas', () => ({
1115
logger: {

api/test/services/Files/processFileCitations.test.js

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,14 @@ jest.mock('~/models', () => ({
1111
getRoleByName: jest.fn(),
1212
}));
1313

14-
jest.mock('@librechat/api', () => ({
15-
checkAccess: jest.fn().mockResolvedValue(true),
16-
}));
14+
jest.mock('@librechat/api', () => {
15+
const { applyCitationLimits, selectFileCitationSources } = jest.requireActual('@librechat/api');
16+
return {
17+
applyCitationLimits,
18+
checkAccess: jest.fn().mockResolvedValue(true),
19+
selectFileCitationSources,
20+
};
21+
});
1722

1823
jest.mock('~/cache/getLogStores', () => () => ({
1924
get: jest.fn().mockResolvedValue({

client/src/hooks/Messages/useSearchResultsByTurn.ts

Lines changed: 3 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,6 @@ interface FileSource {
1111
metadata?: any;
1212
}
1313

14-
interface DeduplicatedSource {
15-
fileId: string;
16-
fileName: string;
17-
pages: number[];
18-
relevance: number;
19-
pageRelevance: Record<string, number>;
20-
metadata?: any;
21-
}
22-
2314
/**
2415
* The `file_search` attachment is typed as {@link SearchResultData} in the
2516
* shared schema, but at runtime the agent file-search tool emits a
@@ -55,49 +46,13 @@ export function useSearchResultsByTurn(attachments?: TAttachment[]) {
5546
if (attachment.type === Tools.file_search && attachment[Tools.file_search]) {
5647
const sources = getFileSearchSources(attachment[Tools.file_search]);
5748

58-
// Deduplicate sources by fileId and merge pages
59-
const deduplicatedSources = new Map<string, DeduplicatedSource>();
60-
61-
sources.forEach((source: FileSource) => {
62-
const fileId = source.fileId;
63-
if (deduplicatedSources.has(fileId)) {
64-
// Merge pages for the same file
65-
const existing = deduplicatedSources.get(fileId);
66-
if (existing) {
67-
const existingPages = existing.pages || [];
68-
const newPages = source.pages || [];
69-
const allPages = [...existingPages, ...newPages];
70-
// Remove duplicates and sort
71-
const uniquePages = [...new Set(allPages)].sort((a, b) => a - b);
72-
73-
// Merge page relevance mappings
74-
const existingPageRelevance = existing.pageRelevance || {};
75-
const newPageRelevance = source.pageRelevance || {};
76-
const mergedPageRelevance = { ...existingPageRelevance, ...newPageRelevance };
77-
78-
existing.pages = uniquePages;
79-
existing.relevance = Math.max(existing.relevance || 0, source.relevance || 0);
80-
existing.pageRelevance = mergedPageRelevance;
81-
}
82-
} else {
83-
deduplicatedSources.set(fileId, {
84-
fileId: source.fileId,
85-
fileName: source.fileName,
86-
pages: source.pages || [],
87-
relevance: source.relevance || 0.5,
88-
pageRelevance: source.pageRelevance || {},
89-
metadata: source.metadata,
90-
});
91-
}
92-
});
93-
9449
// Convert agent file sources to SearchResultData format
9550
const agentSearchData: SearchResultData = {
9651
turn: agentFileSearchTurn,
9752
organic: [], // Agent file search doesn't have organic web results
9853
topStories: [], // No top stories for file search
9954
images: [], // No images for file search
100-
references: Array.from(deduplicatedSources.values()).map(
55+
references: sources.map(
10156
(source) =>
10257
({
10358
title: source.fileName || localize('com_file_unknown'),
@@ -111,8 +66,8 @@ export function useSearchResultsByTurn(attachments?: TAttachment[]) {
11166
// Store additional agent-specific data as properties on the reference
11267
fileId: source.fileId,
11368
fileName: source.fileName,
114-
pages: source.pages,
115-
pageRelevance: source.pageRelevance,
69+
pages: source.pages || [],
70+
pageRelevance: source.pageRelevance || {},
11671
metadata: source.metadata,
11772
}) as any,
11873
),
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { applyCitationLimits, selectFileCitationSources } from './citations';
2+
3+
describe('file citation selection', () => {
4+
const sources = [
5+
{ fileId: 'a', relevance: 0.9, page: 1 },
6+
{ fileId: 'a', relevance: 0.8, page: 2 },
7+
{ fileId: 'b', relevance: 0.7, page: 1 },
8+
{ fileId: 'c', relevance: 0.2, page: 1 },
9+
];
10+
11+
it('applies per-file and total limits by relevance', () => {
12+
expect(applyCitationLimits(sources, 2, 1)).toEqual([sources[0], sources[2]]);
13+
});
14+
15+
it('shares relevance and count selection without losing source identity', () => {
16+
expect(
17+
selectFileCitationSources(sources, {
18+
minRelevanceScore: 0.5,
19+
maxCitations: 3,
20+
maxCitationsPerFile: 1,
21+
}),
22+
).toEqual([sources[0], sources[2]]);
23+
});
24+
25+
it('handles artifacts without sources', () => {
26+
expect(selectFileCitationSources(undefined)).toEqual([]);
27+
});
28+
});
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
export interface FileCitationSource {
2+
fileId: string;
3+
relevance: number;
4+
}
5+
6+
export interface FileCitationSelectionConfig {
7+
maxCitations?: number;
8+
maxCitationsPerFile?: number;
9+
minRelevanceScore?: number;
10+
}
11+
12+
export function applyCitationLimits<TSource extends FileCitationSource>(
13+
sources: readonly TSource[],
14+
maxCitations: number,
15+
maxCitationsPerFile: number,
16+
): TSource[] {
17+
const byFile = new Map<string, TSource[]>();
18+
for (const source of sources) {
19+
const fileSources = byFile.get(source.fileId) ?? [];
20+
fileSources.push(source);
21+
byFile.set(source.fileId, fileSources);
22+
}
23+
24+
const representatives: TSource[] = [];
25+
for (const fileSources of byFile.values()) {
26+
representatives.push(
27+
...fileSources.sort((a, b) => b.relevance - a.relevance).slice(0, maxCitationsPerFile),
28+
);
29+
}
30+
31+
return representatives.sort((a, b) => b.relevance - a.relevance).slice(0, maxCitations);
32+
}
33+
34+
/** Selects the passages shared by model anchors and browser citation attachments. */
35+
export function selectFileCitationSources<TSource extends FileCitationSource>(
36+
sources: readonly TSource[] | null | undefined,
37+
config?: FileCitationSelectionConfig,
38+
): TSource[] {
39+
return applyCitationLimits(
40+
(sources ?? []).filter((source) => source.relevance >= (config?.minRelevanceScore ?? 0.45)),
41+
config?.maxCitations ?? 30,
42+
config?.maxCitationsPerFile ?? 5,
43+
);
44+
}

packages/api/src/files/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
export * from './agents';
22
export * from './audio';
33
export * from './code';
4+
export * from './citations';
45
export * from './context';
56
export * from './deletion';
67
export * from './extract';

0 commit comments

Comments
 (0)