Files
upstream-rocketchat/apps/meteor/server/services/ai-search/service.ts
upstream-mirror 30810eecd7
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s
snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
2026-09-16 01:21:02 +05:00

435 lines
14 KiB
TypeScript

import {
AI_LICENSE_MODULE,
AI_SEARCH_PAGE_SIZE,
buildIntelligentSearchPipelineFilters,
generateOpenAICompatibleSearchAnswer,
listOpenAICompatibleModels,
MAX_SEARCH_ANSWER_MESSAGES,
MAX_SEARCH_ANSWER_TEXT_LENGTH,
MAX_SEARCH_FILTER_VALUES,
normalizeIntelligentSearchCandidates,
searchIntelligentPipeline,
type IntelligentSearchFilters,
type IntelligentSearchPipelineConfig,
type OpenAICompatibleProviderConfig,
type SearchAnswerMessage,
} from '@rocket.chat/ai-search';
import type {
AISearchAnswerMessage,
AISearchAnswerResult,
AISearchFilters,
AISearchModelOption,
AISearchResult,
AISearchStatus,
IAISearchService,
} from '@rocket.chat/core-services';
import { License, ServiceClass } from '@rocket.chat/core-services';
import { isBannedSubscription, type IMessage, type IRoom, type IUser } from '@rocket.chat/core-typings';
import { Logger } from '@rocket.chat/logger';
import { Messages, Rooms, Subscriptions, Users } from '@rocket.chat/models';
import { serverFetch, type ExtendedFetchOptions } from '@rocket.chat/server-fetch';
import { settings } from '../../settings';
const logger = new Logger('AISearchService');
const fetchWithSsrfValidation = (url: string, options: Omit<ExtendedFetchOptions, 'allowList' | 'ignoreSsrfValidation'>) =>
serverFetch(url, {
...options,
ignoreSsrfValidation: false,
allowList: settings.get<string>('SSRF_Allowlist'),
});
const asString = (value: unknown): string => {
if (typeof value === 'string') {
return value;
}
if (typeof value === 'number' || typeof value === 'boolean') {
return String(value);
}
return '';
};
const toDate = (value?: string | Date): Date | undefined => {
if (!value) {
return undefined;
}
const date = value instanceof Date ? value : new Date(value);
return Number.isNaN(date.getTime()) ? undefined : date;
};
const limitFilterValues = (values: string[] | undefined): string[] | undefined => {
if (!values) {
return undefined;
}
const limitedValues: string[] = [];
for (const value of values) {
if (value) {
limitedValues.push(value);
if (limitedValues.length === MAX_SEARCH_FILTER_VALUES) {
break;
}
}
}
return limitedValues;
};
const normalizeFilters = (filters: AISearchFilters = {}): IntelligentSearchFilters => ({
rid: filters.rid,
rids: limitFilterValues(filters.rids),
roomNames: limitFilterValues(filters.roomNames),
fromUsername: filters.fromUsername,
fromUsernames: limitFilterValues(filters.fromUsernames),
startDate: toDate(filters.startDate),
endDate: toDate(filters.endDate),
});
type OpenAICompatibleProviderSettings = Pick<OpenAICompatibleProviderConfig, 'baseUrl' | 'apiKey'> & {
model?: string;
};
export class AISearchService extends ServiceClass implements IAISearchService {
protected name = 'ai-search';
private getPipelineConfig(): IntelligentSearchPipelineConfig | undefined {
const baseUrl = settings.get<string>('AI_Intelligent_Search_Pipeline_Base_URL');
const pipelineId = settings.get<string>('AI_Intelligent_Search_Pipeline_ID');
const apiKey = settings.get<string>('AI_Intelligent_Search_API_Key');
const apiKeySecret = settings.get<string>('AI_Intelligent_Search_API_Key_Secret');
const queryTemplate = settings.get<string>('AI_Intelligent_Search_Query_Template');
const minimumSimilarityPercent = settings.get<number>('AI_Intelligent_Search_Min_Similarity_Percent');
const normalizedBaseUrl = asString(baseUrl);
const normalizedPipelineId = asString(pipelineId);
const normalizedApiKey = asString(apiKey);
const normalizedApiKeySecret = asString(apiKeySecret);
if (!normalizedBaseUrl || !normalizedPipelineId || !normalizedApiKey || !normalizedApiKeySecret) {
return undefined;
}
return {
baseUrl: normalizedBaseUrl,
pipelineId: normalizedPipelineId,
apiKey: normalizedApiKey,
apiKeySecret: normalizedApiKeySecret,
queryTemplate: asString(queryTemplate),
minimumSimilarityPercent: Number(minimumSimilarityPercent || 0),
};
}
private getAnswerProviderSettings(): OpenAICompatibleProviderSettings | undefined {
const baseUrl = settings.get<string>('AI_LLM_OpenAI_Base_URL');
const apiKey = settings.get<string>('AI_LLM_OpenAI_API_Key');
const model = settings.get<string>('AI_LLM_OpenAI_Model');
const normalizedBaseUrl = asString(baseUrl);
const normalizedApiKey = asString(apiKey);
const normalizedModel = asString(model);
if (!normalizedBaseUrl || !normalizedApiKey) {
return undefined;
}
return {
baseUrl: normalizedBaseUrl,
apiKey: normalizedApiKey,
model: normalizedModel,
};
}
private getAnswerProviderConfig(): OpenAICompatibleProviderConfig | undefined {
const provider = this.getAnswerProviderSettings();
if (!provider?.model) {
return undefined;
}
return {
name: 'OpenAI compatible',
baseUrl: provider.baseUrl,
apiKey: provider.apiKey,
model: provider.model,
};
}
async status(): Promise<AISearchStatus> {
const hasIntelligentSearchLicense = await License.hasModule(AI_LICENSE_MODULE);
const intelligentSearchEnabled = settings.get<boolean>('AI_Intelligent_Search_Enabled');
const answerGenerationEnabled = settings.get<boolean>('AI_Intelligent_Search_Answer_Enabled');
const pipelineConfig = this.getPipelineConfig();
const answerProviderConfig = this.getAnswerProviderConfig();
return {
hasIntelligentSearchLicense,
intelligentSearchEnabled: intelligentSearchEnabled === true,
intelligentSearchConfigured: Boolean(pipelineConfig),
answerGenerationConfigured: answerGenerationEnabled === true && Boolean(answerProviderConfig) && Boolean(pipelineConfig),
};
}
private async getRoomMap(roomIds: string[]): Promise<Map<string, Pick<IRoom, '_id' | 't' | 'name' | 'fname'>>> {
if (!roomIds.length) {
return new Map();
}
const rooms = await Rooms.findByIds([...new Set(roomIds)], {
projection: { _id: 1, t: 1, name: 1, fname: 1 },
}).toArray();
return new Map(rooms.map((room) => [room._id, room]));
}
private async getUserSubscribedRoomIdsForPipeline(userId: string): Promise<string[]> {
const roomIds = await Subscriptions.findByUserId(userId, {
projection: { rid: 1, status: 1 },
})
.map((subscription) => (isBannedSubscription(subscription) ? undefined : subscription.rid))
.toArray();
return roomIds.filter((rid): rid is string => Boolean(rid));
}
private async getSubscribedRoomIdSet(userId: string, roomIds: string[]): Promise<Set<string>> {
const uniqueRoomIdSet = new Set<string>();
for (const roomId of roomIds) {
if (roomId) {
uniqueRoomIdSet.add(roomId);
}
}
const uniqueRoomIds = [...uniqueRoomIdSet];
if (!uniqueRoomIds.length) {
return new Set();
}
const subscriptions = await Subscriptions.findByUserIdAndRoomIds(userId, uniqueRoomIds, {
projection: { rid: 1, status: 1 },
}).toArray();
const subscribedRoomIds = new Set<string>();
for (const subscription of subscriptions) {
if (!isBannedSubscription(subscription)) {
subscribedRoomIds.add(subscription.rid);
}
}
return subscribedRoomIds;
}
private async normalizeIntelligentResults(
rawSearchResults: unknown,
userId: string,
limit = AI_SEARCH_PAGE_SIZE,
): Promise<AISearchResult[]> {
const candidates = normalizeIntelligentSearchCandidates(rawSearchResults, [], limit, logger);
const msgIdSet = new Set<string>();
for (const { msgId } of candidates) {
if (msgId) {
msgIdSet.add(msgId);
}
}
const msgIds = [...msgIdSet];
let messageMap = new Map<string, Pick<IMessage, '_id' | 'rid' | 'msg' | 'ts' | 'u'>>();
if (msgIds.length > 0) {
messageMap = new Map(
await Messages.findVisibleByIds(msgIds, {
projection: { _id: 1, rid: 1, msg: 1, ts: 1, u: 1 },
})
.map((message) => [String(message._id), message] as const)
.toArray(),
);
logger.debug({ msg: 'AI search messages fetched from DB', requested: msgIds.length, found: messageMap.size });
}
const resultRoomIds = Array.from(messageMap.values(), ({ rid }) => rid);
const [rooms, subscribedRoomIds] = await Promise.all([
this.getRoomMap(resultRoomIds),
this.getSubscribedRoomIdSet(userId, resultRoomIds),
]);
const normalizedResults: AISearchResult[] = [];
for (const result of candidates) {
// candidates without a visible database message could surface stale pipeline text
const dbMessage = result.msgId ? messageMap.get(result.msgId) : undefined;
if (!dbMessage) {
logger.debug({ msg: 'AI search result filtered: message not visible', msgId: result.msgId });
continue;
}
const { rid } = dbMessage;
if (!rid || !subscribedRoomIds.has(rid)) {
continue;
}
const room = rooms.get(rid);
normalizedResults.push({
_id: result.msgId || result._id,
rid,
msgId: result.msgId,
text: dbMessage.msg || '',
ts: dbMessage.ts?.toISOString(),
u: dbMessage.u ? { username: dbMessage.u.username, name: dbMessage.u.name } : undefined,
...(Number.isFinite(result.score) && { score: result.score }),
...(room && { room }),
});
if (normalizedResults.length === limit) {
break;
}
}
return normalizedResults;
}
private async getUserClassifications(userId: string): Promise<string[]> {
const user = await Users.findOneById<Pick<IUser, 'roles'>>(userId, { projection: { roles: 1 } });
return Array.from(new Set(['user', ...(user?.roles || [])]));
}
private async getSubscribedRoomIds(userId: string, roomIds: string[]): Promise<string[]> {
return [...(await this.getSubscribedRoomIdSet(userId, roomIds))];
}
private async getSubscribedRoomIdsByName(userId: string, roomNames: string[] = []): Promise<string[]> {
if (!roomNames.length) {
return [];
}
const rooms = await Promise.all(
Array.from(new Set(roomNames)).map((roomName) => Rooms.findOneByNameOrFname(roomName, { projection: { _id: 1 } })),
);
const roomIds = rooms.reduce<string[]>((roomIds, room) => {
if (typeof room?._id === 'string') {
roomIds.push(room._id);
}
return roomIds;
}, []);
return this.getSubscribedRoomIds(userId, roomIds);
}
async search({
query,
userId,
filters: rawFilters,
limit = AI_SEARCH_PAGE_SIZE,
}: {
query: string;
userId: string;
filters?: AISearchFilters;
limit?: number;
}): Promise<AISearchResult[]> {
const hasIntelligentSearchLicense = await License.hasModule(AI_LICENSE_MODULE);
const intelligentSearchEnabled = settings.get<boolean>('AI_Intelligent_Search_Enabled');
const config = this.getPipelineConfig();
if (!hasIntelligentSearchLicense || intelligentSearchEnabled !== true || !config) {
logger.debug({
msg: 'AI search skipped: unavailable',
hasIntelligentSearchLicense,
intelligentSearchEnabled: intelligentSearchEnabled === true,
intelligentSearchConfigured: Boolean(config),
});
return [];
}
const filters = normalizeFilters(rawFilters);
const requestedRoomIds = [...new Set([...(filters.rids || []), ...(filters.rid ? [filters.rid] : [])])];
const roomNameIds = await this.getSubscribedRoomIdsByName(userId, filters.roomNames);
const scopedRoomIds = [...new Set([...requestedRoomIds, ...roomNameIds])];
// a room filter that resolves to no subscribed rooms must not fall through to the unscoped path
if (filters.roomNames?.length && !scopedRoomIds.length) {
logger.debug({ msg: 'AI search skipped: room name filters did not resolve to subscribed rooms' });
return [];
}
const [pipelineRoomIds, classifications] = await Promise.all([
scopedRoomIds.length ? this.getSubscribedRoomIds(userId, scopedRoomIds) : this.getUserSubscribedRoomIdsForPipeline(userId),
this.getUserClassifications(userId),
]);
if (!pipelineRoomIds.length) {
logger.debug({ msg: 'AI search skipped: user has no room subscriptions' });
return [];
}
const pipelineFilters = buildIntelligentSearchPipelineFilters(pipelineRoomIds, {
...filters,
rids: [...(filters.rids || []), ...roomNameIds],
});
if (!pipelineFilters) {
logger.debug({ msg: 'AI search skipped: no subscribed rooms for filters', rid: filters.rid });
return [];
}
const json = await searchIntelligentPipeline({
query,
config,
classifications,
pipelineFilters,
limit,
fetch: fetchWithSsrfValidation,
logger,
});
return this.normalizeIntelligentResults(json, userId, limit);
}
async answer({ query, messages }: { query: string; messages: AISearchAnswerMessage[] }): Promise<AISearchAnswerResult> {
const hasIntelligentSearchLicense = await License.hasModule(AI_LICENSE_MODULE);
const intelligentSearchEnabled = settings.get<boolean>('AI_Intelligent_Search_Enabled');
const answerGenerationEnabled = settings.get<boolean>('AI_Intelligent_Search_Answer_Enabled');
const pipelineConfig = this.getPipelineConfig();
const provider = this.getAnswerProviderConfig();
const systemPromptSetting = settings.get<string>('AI_Intelligent_Search_Answer_System_Prompt');
if (!hasIntelligentSearchLicense || intelligentSearchEnabled !== true || answerGenerationEnabled !== true || !pipelineConfig) {
throw new Error('error-ai-not-enabled');
}
if (!provider) {
throw new Error('error-ai-provider-not-configured');
}
const systemPrompt = asString(systemPromptSetting);
const sanitizedMessages: SearchAnswerMessage[] = messages.map(({ text, username, roomName, ts, score }) => ({
text,
username,
roomName,
ts,
score,
}));
return generateOpenAICompatibleSearchAnswer({
query,
messages: sanitizedMessages,
provider,
systemPrompt,
fetch: fetchWithSsrfValidation,
logger,
maxMessages: MAX_SEARCH_ANSWER_MESSAGES,
maxTextLength: MAX_SEARCH_ANSWER_TEXT_LENGTH,
});
}
async models(): Promise<AISearchModelOption[]> {
if (!(await License.hasModule(AI_LICENSE_MODULE))) {
return [];
}
const provider = this.getAnswerProviderSettings();
const selectedModel = settings.get<string>('AI_LLM_OpenAI_Model');
return listOpenAICompatibleModels({
provider,
selectedModel: asString(selectedModel),
fetch: fetchWithSsrfValidation,
logger,
});
}
}