snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
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
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
This commit is contained in:
13
packages/ai-search/CHANGELOG.md
Normal file
13
packages/ai-search/CHANGELOG.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# @rocket.chat/ai-search
|
||||
|
||||
## 0.2.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- ([#40890](https://github.com/RocketChat/Rocket.Chat/pull/40890)) Adds AI Search with semantic message results, optional OpenAI-compatible answers, and AI Center configuration.
|
||||
|
||||
## 0.2.0-rc.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- ([#40890](https://github.com/RocketChat/Rocket.Chat/pull/40890)) Adds AI Search with semantic message results, optional OpenAI-compatible answers, and AI Center configuration.
|
||||
17
packages/ai-search/README.md
Normal file
17
packages/ai-search/README.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# @rocket.chat/ai-search
|
||||
|
||||
Shared AI Search primitives.
|
||||
|
||||
This package keeps AI Search provider calls and normalization logic outside the
|
||||
Meteor REST handlers. It is intentionally framework-light: callers inject
|
||||
configuration, logger, and `fetch`, which makes the code usable from the current
|
||||
monolith or from a future standalone service process.
|
||||
|
||||
Current responsibilities:
|
||||
|
||||
- Client-side filter parsing and search-query serialization.
|
||||
- OpenAI-compatible model listing.
|
||||
- OpenAI-compatible answer generation for search results.
|
||||
- Intelligent Search pipeline request construction.
|
||||
- Pipeline filter construction.
|
||||
- Pipeline response normalization and similarity score handling.
|
||||
6
packages/ai-search/jest.config.ts
Normal file
6
packages/ai-search/jest.config.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import server from '@rocket.chat/jest-presets/server';
|
||||
import type { Config } from 'jest';
|
||||
|
||||
export default {
|
||||
preset: server.preset,
|
||||
} satisfies Config;
|
||||
31
packages/ai-search/package.json
Normal file
31
packages/ai-search/package.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "@rocket.chat/ai-search",
|
||||
"version": "0.2.0",
|
||||
"private": true,
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"typings": "./dist/index.d.ts",
|
||||
"files": [
|
||||
"/dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "rm -rf dist && tsc -p tsconfig.json",
|
||||
"dev": "tsc -p tsconfig.json --watch --preserveWatchOutput",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint --fix .",
|
||||
"test": "jest",
|
||||
"testunit": "jest",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rocket.chat/jest-presets": "workspace:~",
|
||||
"@rocket.chat/tsconfig": "workspace:*",
|
||||
"@types/jest": "~30.0.0",
|
||||
"eslint": "~9.39.5",
|
||||
"jest": "~30.2.0",
|
||||
"typescript": "~5.9.3"
|
||||
},
|
||||
"volta": {
|
||||
"extends": "../../package.json"
|
||||
}
|
||||
}
|
||||
264
packages/ai-search/src/clientSearch.spec.ts
Normal file
264
packages/ai-search/src/clientSearch.spec.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
import {
|
||||
buildAppliedFilterChips,
|
||||
buildFilterSuggestions,
|
||||
buildRoomSearchQuery,
|
||||
buildUserFilterSuggestions,
|
||||
emptySearchFilters,
|
||||
extractCompletedSearchFilters,
|
||||
getAISearchButtonTooltip,
|
||||
getFilterSearchState,
|
||||
mergeSearchFilters,
|
||||
parseSearchFilterText,
|
||||
serializeSearchQuery,
|
||||
} from './clientSearch';
|
||||
|
||||
describe('AI Search client helpers', () => {
|
||||
describe('parseSearchFilterText', () => {
|
||||
it('extracts room, user, and date filters while preserving the free-text query', () => {
|
||||
expect(parseSearchFilterText('in:general,dev from:@alice after:2026-01-01 before:2026-01-31 fruit colors')).toEqual({
|
||||
searchText: 'fruit colors',
|
||||
filters: {
|
||||
roomNames: ['general', 'dev'],
|
||||
rids: [],
|
||||
fromUsernames: ['alice'],
|
||||
startDate: '2026-01-01',
|
||||
endDate: '2026-01-31',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('supports quoted filter values with spaces', () => {
|
||||
expect(parseSearchFilterText('mongo in:"team room" from:"rocket user"')).toEqual({
|
||||
searchText: 'mongo',
|
||||
filters: {
|
||||
roomNames: ['team room'],
|
||||
rids: [],
|
||||
fromUsernames: ['rocket user'],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractCompletedSearchFilters', () => {
|
||||
it('keeps a single active trailing filter token editable', () => {
|
||||
expect(extractCompletedSearchFilters('mongo from:ren')).toEqual({
|
||||
searchText: 'mongo from:ren',
|
||||
filters: emptySearchFilters(),
|
||||
hasCompletedFilters: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('extracts completed filters when the token is followed by whitespace', () => {
|
||||
expect(extractCompletedSearchFilters('mongo from:ren ')).toEqual({
|
||||
searchText: 'mongo ',
|
||||
filters: {
|
||||
roomNames: [],
|
||||
rids: [],
|
||||
fromUsernames: ['ren'],
|
||||
},
|
||||
hasCompletedFilters: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('extracts multiple completed filters in the same input', () => {
|
||||
expect(extractCompletedSearchFilters('in:general from:ren ')).toEqual({
|
||||
searchText: '',
|
||||
filters: {
|
||||
roomNames: ['general'],
|
||||
rids: [],
|
||||
fromUsernames: ['ren'],
|
||||
},
|
||||
hasCompletedFilters: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the trailing token editable while completing the preceding filters', () => {
|
||||
expect(extractCompletedSearchFilters('in:general from:ren')).toEqual({
|
||||
searchText: 'from:ren',
|
||||
filters: {
|
||||
roomNames: ['general'],
|
||||
rids: [],
|
||||
fromUsernames: [],
|
||||
},
|
||||
hasCompletedFilters: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergeSearchFilters', () => {
|
||||
it('deduplicates repeated rooms and users while preserving latest date filters', () => {
|
||||
expect(
|
||||
mergeSearchFilters(
|
||||
{ roomNames: ['general'], rids: ['r1'], fromUsernames: ['alice'], startDate: '2026-01-01' },
|
||||
{ roomNames: ['general', 'dev'], rids: ['r1', 'r2'], fromUsernames: ['alice', 'bob'], endDate: '2026-01-31' },
|
||||
),
|
||||
).toEqual({
|
||||
roomNames: ['general', 'dev'],
|
||||
rids: ['r1', 'r2'],
|
||||
fromUsernames: ['alice', 'bob'],
|
||||
startDate: '2026-01-01',
|
||||
endDate: '2026-01-31',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildAppliedFilterChips', () => {
|
||||
it('groups common filter types into a single readable chip', () => {
|
||||
const t = (key: string, options?: Record<string, string>): string =>
|
||||
({
|
||||
Search_filter_in_rooms: `Messages in ${options?.rooms}`,
|
||||
Search_filter_from_users: `Messages from ${options?.users}`,
|
||||
Search_filter_after_date: `Messages after ${options?.date}`,
|
||||
Search_filter_before_date: `Messages before ${options?.date}`,
|
||||
})[key] || key;
|
||||
|
||||
expect(
|
||||
buildAppliedFilterChips(
|
||||
{
|
||||
roomNames: ['general', 'dev'],
|
||||
rids: [],
|
||||
fromUsernames: ['alice', 'bob'],
|
||||
startDate: '2026-01-01',
|
||||
},
|
||||
t,
|
||||
),
|
||||
).toEqual([
|
||||
{ key: 'in', values: ['general', 'dev'], label: '#general, #dev', title: 'Messages in #general, #dev' },
|
||||
{ key: 'from', values: ['alice', 'bob'], label: '@alice, @bob', title: 'Messages from @alice, @bob' },
|
||||
{ key: 'after', values: ['2026-01-01'], label: 'after:2026-01-01', title: 'Messages after 2026-01-01' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('serializeSearchQuery', () => {
|
||||
it('quotes filter values with spaces and appends the free-text query', () => {
|
||||
expect(
|
||||
serializeSearchQuery('fruit colors', {
|
||||
roomNames: ['team room'],
|
||||
rids: [],
|
||||
fromUsernames: ['alice'],
|
||||
startDate: '2026-01-01',
|
||||
}),
|
||||
).toBe('in:"team room" from:alice after:2026-01-01 fruit colors');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildRoomSearchQuery', () => {
|
||||
it('builds indexed room-name and fname regex predicates and excludes direct rooms for channel mentions', () => {
|
||||
expect(buildRoomSearchQuery('gen', '#')).toEqual({
|
||||
$or: [{ name: /gen/i }, { fname: /gen/i }],
|
||||
t: { $ne: 'd' },
|
||||
});
|
||||
});
|
||||
|
||||
it('bounds the escaped regex pattern used for room lookup', () => {
|
||||
const query = buildRoomSearchQuery('/'.repeat(128));
|
||||
const firstPredicate = query.$or[0];
|
||||
|
||||
if (!firstPredicate || !('name' in firstPredicate)) {
|
||||
throw new Error('Expected the first room lookup predicate to match room names');
|
||||
}
|
||||
|
||||
const nameRegex = firstPredicate.name;
|
||||
if (!(nameRegex instanceof RegExp)) {
|
||||
throw new Error('Expected the room name lookup predicate to use a regex');
|
||||
}
|
||||
|
||||
expect(nameRegex.source).toBe('\\/'.repeat(64));
|
||||
});
|
||||
});
|
||||
|
||||
describe('filter suggestions', () => {
|
||||
const t = (key: string): string => key;
|
||||
|
||||
it('builds room suggestions from an active in filter', () => {
|
||||
expect(
|
||||
buildFilterSuggestions(
|
||||
'deployment in:gen',
|
||||
{ key: 'in', value: 'gen', start: 11, end: 17 },
|
||||
[{ _id: 'room-id', name: 'general', fname: 'General' }],
|
||||
t,
|
||||
),
|
||||
).toEqual([
|
||||
{
|
||||
key: 'in-room-id',
|
||||
group: 'rooms',
|
||||
title: '#General',
|
||||
description: 'Search_in_this_room',
|
||||
value: 'deployment in:general ',
|
||||
icon: 'hash',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('builds user suggestions from an active from filter', () => {
|
||||
expect(
|
||||
buildUserFilterSuggestions(
|
||||
'deployment from:ali',
|
||||
{ key: 'from', value: 'ali', start: 11, end: 19 },
|
||||
[{ _id: 'user-id', name: 'Example User', username: 'alice' }],
|
||||
t,
|
||||
),
|
||||
).toEqual([
|
||||
{
|
||||
key: 'from-user-id',
|
||||
group: 'users',
|
||||
title: '@alice',
|
||||
description: 'Example User',
|
||||
value: 'deployment from:alice ',
|
||||
icon: 'user',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not parse filters while AI Search is inactive', () => {
|
||||
expect(getFilterSearchState('deployment in:general', emptySearchFilters(), false)).toEqual({
|
||||
searchText: 'deployment in:general',
|
||||
filters: emptySearchFilters(),
|
||||
activeFilter: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('parses and merges filters while AI Search is active', () => {
|
||||
expect(
|
||||
getFilterSearchState('deployment in:general from:bob', { roomNames: [], rids: ['room-id'], fromUsernames: ['alice'] }, true),
|
||||
).toEqual({
|
||||
searchText: 'deployment',
|
||||
filters: {
|
||||
roomNames: ['general'],
|
||||
rids: ['room-id'],
|
||||
fromUsernames: ['alice', 'bob'],
|
||||
},
|
||||
activeFilter: { key: 'from', value: 'bob', start: 22, end: 30 },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAISearchButtonTooltip', () => {
|
||||
const t = (key: string): string => key;
|
||||
|
||||
it('explains unavailable AI Search when the add-on is missing', () => {
|
||||
expect(
|
||||
getAISearchButtonTooltip({ hasIntelligentSearchLicense: false, intelligentSearchEnabled: true, aiSearchActive: false, t }),
|
||||
).toBe('AI_Search_license_required_tooltip');
|
||||
});
|
||||
|
||||
it('explains disabled AI Search when the add-on is available', () => {
|
||||
expect(
|
||||
getAISearchButtonTooltip({ hasIntelligentSearchLicense: true, intelligentSearchEnabled: false, aiSearchActive: false, t }),
|
||||
).toBe('AI_Search_disabled_tooltip');
|
||||
});
|
||||
|
||||
it('offers to enable AI Search when it is inactive', () => {
|
||||
expect(
|
||||
getAISearchButtonTooltip({ hasIntelligentSearchLicense: true, intelligentSearchEnabled: true, aiSearchActive: false, t }),
|
||||
).toBe('Enable_AI_Search');
|
||||
});
|
||||
|
||||
it('offers to disable AI Search when it is active', () => {
|
||||
expect(getAISearchButtonTooltip({ hasIntelligentSearchLicense: true, intelligentSearchEnabled: true, aiSearchActive: true, t })).toBe(
|
||||
'Disable_AI_Search',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
445
packages/ai-search/src/clientSearch.ts
Normal file
445
packages/ai-search/src/clientSearch.ts
Normal file
@@ -0,0 +1,445 @@
|
||||
import { AI_SEARCH_FILTER_SUGGESTION_LIMIT, MAX_ROOM_SEARCH_PATTERN_LENGTH } from './constants';
|
||||
|
||||
export type SearchFilters = {
|
||||
roomNames: string[];
|
||||
rids: string[];
|
||||
fromUsernames: string[];
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
rid?: string;
|
||||
fromUsername?: string;
|
||||
};
|
||||
|
||||
export type NavBarSearchFormValues = {
|
||||
filterText: string;
|
||||
appliedFilters: SearchFilters;
|
||||
};
|
||||
|
||||
export type SearchFilterSuggestion = {
|
||||
key: string;
|
||||
group: 'rooms' | 'users' | 'dates';
|
||||
title: string;
|
||||
description: string;
|
||||
value: string;
|
||||
icon: 'hash' | 'user' | 'calendar';
|
||||
};
|
||||
|
||||
export type SearchRoomSuggestionSource = {
|
||||
_id: string;
|
||||
rid?: string;
|
||||
name?: string;
|
||||
fname?: string;
|
||||
};
|
||||
|
||||
export type SearchUserSuggestionSource = {
|
||||
_id: string;
|
||||
name?: string;
|
||||
username: string;
|
||||
};
|
||||
|
||||
export type SearchFilterChip = {
|
||||
key: string;
|
||||
label: string;
|
||||
title: string;
|
||||
values: string[];
|
||||
};
|
||||
|
||||
export type ActiveSearchFilter = {
|
||||
key: 'in' | 'from' | 'after' | 'before';
|
||||
value: string;
|
||||
start: number;
|
||||
end: number;
|
||||
};
|
||||
|
||||
const FILTER_PATTERN = /(?:^|\s)(in|from|after|before):(?:"([^"]*)"|(\S+))/gi;
|
||||
|
||||
const escapeRegExp = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
|
||||
const normalizeFilterText = (value: string): string => value.replace(/\s+/g, ' ').trimStart();
|
||||
|
||||
const splitFilterValues = (value: string): string[] =>
|
||||
value
|
||||
.split(',')
|
||||
.map((item) => item.replace(/^[@#]/, '').trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const isSearchFilterKey = (value: string): value is ActiveSearchFilter['key'] =>
|
||||
value === 'in' || value === 'from' || value === 'after' || value === 'before';
|
||||
|
||||
export const emptySearchFilters = (): SearchFilters => ({ roomNames: [], rids: [], fromUsernames: [] });
|
||||
|
||||
export const mergeSearchFilters = (...filtersList: SearchFilters[]): SearchFilters => {
|
||||
const roomNames = new Set<string>();
|
||||
const rids = new Set<string>();
|
||||
const fromUsernames = new Set<string>();
|
||||
let startDate: string | undefined;
|
||||
let endDate: string | undefined;
|
||||
let rid: string | undefined;
|
||||
let fromUsername: string | undefined;
|
||||
|
||||
for (const filters of filtersList) {
|
||||
filters.roomNames.forEach((roomName) => roomNames.add(roomName));
|
||||
filters.rids.forEach((roomId) => rids.add(roomId));
|
||||
filters.fromUsernames.forEach((username) => fromUsernames.add(username));
|
||||
startDate = filters.startDate || startDate;
|
||||
endDate = filters.endDate || endDate;
|
||||
rid = filters.rid || rid;
|
||||
fromUsername = filters.fromUsername || fromUsername;
|
||||
}
|
||||
|
||||
return {
|
||||
roomNames: [...roomNames],
|
||||
rids: [...rids],
|
||||
fromUsernames: [...fromUsernames],
|
||||
...(startDate && { startDate }),
|
||||
...(endDate && { endDate }),
|
||||
...(rid && { rid }),
|
||||
...(fromUsername && { fromUsername }),
|
||||
};
|
||||
};
|
||||
|
||||
export const parseSearchFilterText = (filterText: string): { searchText: string; filters: SearchFilters } => {
|
||||
const filters: SearchFilters = emptySearchFilters();
|
||||
const searchText = filterText
|
||||
.replace(FILTER_PATTERN, (_match, key: string, quotedValue?: string, bareValue?: string) => {
|
||||
const value = String(quotedValue || bareValue || '').trim();
|
||||
const values = splitFilterValues(value);
|
||||
if (!values.length) {
|
||||
return ' ';
|
||||
}
|
||||
|
||||
switch (key.toLowerCase()) {
|
||||
case 'in':
|
||||
filters.roomNames.push(...values);
|
||||
break;
|
||||
case 'from':
|
||||
filters.fromUsernames.push(...values);
|
||||
break;
|
||||
case 'after':
|
||||
filters.startDate = values[0];
|
||||
break;
|
||||
case 'before':
|
||||
filters.endDate = values[0];
|
||||
break;
|
||||
}
|
||||
|
||||
return ' ';
|
||||
})
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
|
||||
return { searchText, filters };
|
||||
};
|
||||
|
||||
export const extractCompletedSearchFilters = (
|
||||
filterText: string,
|
||||
): { searchText: string; filters: SearchFilters; hasCompletedFilters: boolean } => {
|
||||
const filters: SearchFilters = emptySearchFilters();
|
||||
let hasCompletedFilters = false;
|
||||
const trimmedLength = filterText.trimEnd().length;
|
||||
const searchText = filterText
|
||||
.replace(FILTER_PATTERN, (match, key: string, quotedValue?: string, bareValue?: string, offset?: number) => {
|
||||
const start = typeof offset === 'number' ? offset : 0;
|
||||
const end = start + match.length;
|
||||
// the trailing token stays editable until followed by whitespace, even after completed tokens
|
||||
const isActiveToken = end >= trimmedLength && !/\s$/.test(filterText);
|
||||
if (isActiveToken) {
|
||||
return match;
|
||||
}
|
||||
|
||||
const values = splitFilterValues(String(quotedValue || bareValue || ''));
|
||||
if (!values.length) {
|
||||
return ' ';
|
||||
}
|
||||
|
||||
hasCompletedFilters = true;
|
||||
switch (key.toLowerCase()) {
|
||||
case 'in':
|
||||
filters.roomNames.push(...values);
|
||||
break;
|
||||
case 'from':
|
||||
filters.fromUsernames.push(...values);
|
||||
break;
|
||||
case 'after':
|
||||
filters.startDate = values[0];
|
||||
break;
|
||||
case 'before':
|
||||
filters.endDate = values[0];
|
||||
break;
|
||||
}
|
||||
|
||||
return ' ';
|
||||
})
|
||||
.replace(/\s+/g, ' ')
|
||||
.trimStart();
|
||||
|
||||
return { searchText, filters, hasCompletedFilters };
|
||||
};
|
||||
|
||||
export const getActiveSearchFilter = (filterText: string): ActiveSearchFilter | undefined => {
|
||||
const match = /(?:^|\s)(in|from|after|before):([^\s]*)$/i.exec(filterText);
|
||||
if (!match) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const key = match[1].toLowerCase();
|
||||
if (!isSearchFilterKey(key)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const tokenStart = filterText.lastIndexOf(match[1], filterText.length - match[2].length - 1);
|
||||
return {
|
||||
key,
|
||||
value: match[2],
|
||||
start: tokenStart,
|
||||
end: filterText.length,
|
||||
};
|
||||
};
|
||||
|
||||
export const formatSearchFilterValue = (key: ActiveSearchFilter['key'], value: string): string =>
|
||||
`${key}:${/\s/.test(value) ? `"${value}"` : value}`;
|
||||
|
||||
export const applySearchFilterToken = (
|
||||
filterText: string,
|
||||
activeFilter: ActiveSearchFilter | undefined,
|
||||
key: ActiveSearchFilter['key'],
|
||||
value: string,
|
||||
): string => {
|
||||
const token = formatSearchFilterValue(key, value);
|
||||
if (activeFilter) {
|
||||
return normalizeFilterText(`${filterText.slice(0, activeFilter.start)}${token} `);
|
||||
}
|
||||
|
||||
return normalizeFilterText(`${filterText.trim()} ${token} `);
|
||||
};
|
||||
|
||||
const formatDate = (date: Date): string => {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
|
||||
return `${year}-${month}-${day}`;
|
||||
};
|
||||
|
||||
const getDateFilterSuggestions = (
|
||||
filterText: string,
|
||||
activeFilter: ActiveSearchFilter,
|
||||
key: 'after' | 'before',
|
||||
t: (key: string) => string,
|
||||
): SearchFilterSuggestion[] => {
|
||||
const today = new Date();
|
||||
const yesterday = new Date(today);
|
||||
yesterday.setDate(today.getDate() - 1);
|
||||
const lastWeek = new Date(today);
|
||||
lastWeek.setDate(today.getDate() - 7);
|
||||
|
||||
return [
|
||||
{ label: t('Today'), value: formatDate(today) },
|
||||
{ label: t('Yesterday'), value: formatDate(yesterday) },
|
||||
{ label: t('Last_7_days'), value: formatDate(lastWeek) },
|
||||
].map(({ label, value }) => ({
|
||||
key: `${key}-${value}`,
|
||||
group: 'dates',
|
||||
title: `${key}:${value}`,
|
||||
description: label,
|
||||
value: applySearchFilterToken(filterText, activeFilter, key, value),
|
||||
icon: 'calendar',
|
||||
}));
|
||||
};
|
||||
|
||||
export const buildFilterSuggestions = (
|
||||
filterText: string,
|
||||
activeFilter: ActiveSearchFilter | undefined,
|
||||
rooms: SearchRoomSuggestionSource[],
|
||||
t: (key: string) => string,
|
||||
): SearchFilterSuggestion[] => {
|
||||
if (!activeFilter) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (activeFilter.key === 'in') {
|
||||
return rooms.slice(0, AI_SEARCH_FILTER_SUGGESTION_LIMIT).map((room) => ({
|
||||
key: `in-${room.rid || room._id}`,
|
||||
group: 'rooms',
|
||||
title: `#${room.fname || room.name}`,
|
||||
description: t('Search_in_this_room'),
|
||||
value: applySearchFilterToken(filterText, activeFilter, 'in', room.name || room.fname || ''),
|
||||
icon: 'hash',
|
||||
}));
|
||||
}
|
||||
|
||||
if (activeFilter.key === 'from') {
|
||||
return [
|
||||
{
|
||||
key: 'from-current',
|
||||
group: 'users',
|
||||
title: activeFilter.value ? `from:${activeFilter.value.replace(/^@/, '')}` : 'from:username',
|
||||
description: t('Search_messages_from_this_username'),
|
||||
value: applySearchFilterToken(filterText, activeFilter, 'from', activeFilter.value.replace(/^@/, '')),
|
||||
icon: 'user',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return getDateFilterSuggestions(filterText, activeFilter, activeFilter.key, t);
|
||||
};
|
||||
|
||||
export const buildUserFilterSuggestions = (
|
||||
filterText: string,
|
||||
activeFilter: ActiveSearchFilter | undefined,
|
||||
users: SearchUserSuggestionSource[],
|
||||
t: (key: string) => string,
|
||||
): SearchFilterSuggestion[] => {
|
||||
if (activeFilter?.key !== 'from') {
|
||||
return [];
|
||||
}
|
||||
|
||||
return users.slice(0, AI_SEARCH_FILTER_SUGGESTION_LIMIT).map((user) => ({
|
||||
key: `from-${user._id}`,
|
||||
group: 'users',
|
||||
title: `@${user.username}`,
|
||||
description: user.name || t('Search_messages_from_this_user'),
|
||||
value: applySearchFilterToken(filterText, activeFilter, 'from', user.username),
|
||||
icon: 'user',
|
||||
}));
|
||||
};
|
||||
|
||||
export const mergeFilterSuggestions = (primary: SearchFilterSuggestion[], fallback: SearchFilterSuggestion[]): SearchFilterSuggestion[] => {
|
||||
const existingValues = new Set(primary.map(({ value }) => value));
|
||||
return [...primary, ...fallback.filter(({ value }) => !existingValues.has(value))];
|
||||
};
|
||||
|
||||
export const getFilterSearchState = (
|
||||
filterText: string,
|
||||
appliedSearchFilters: SearchFilters,
|
||||
canUseInlineFilters: boolean,
|
||||
): {
|
||||
searchText: string;
|
||||
filters: SearchFilters;
|
||||
activeFilter: ActiveSearchFilter | undefined;
|
||||
} => {
|
||||
if (!canUseInlineFilters) {
|
||||
return { searchText: filterText, filters: emptySearchFilters(), activeFilter: undefined };
|
||||
}
|
||||
|
||||
const parsed = parseSearchFilterText(filterText);
|
||||
return {
|
||||
searchText: parsed.searchText,
|
||||
filters: mergeSearchFilters(appliedSearchFilters, parsed.filters),
|
||||
activeFilter: getActiveSearchFilter(filterText),
|
||||
};
|
||||
};
|
||||
|
||||
export const getRoomLookupText = (activeFilter: ActiveSearchFilter | undefined, canUseInlineFilters: boolean): string => {
|
||||
if (!canUseInlineFilters || activeFilter?.key !== 'in') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return activeFilter.value.replace(/^#/, '');
|
||||
};
|
||||
|
||||
const getFilterChipLabel = (key: ActiveSearchFilter['key'], value: string): string => {
|
||||
switch (key) {
|
||||
case 'in':
|
||||
return `#${value}`;
|
||||
case 'from':
|
||||
return `@${value}`;
|
||||
default:
|
||||
return `${key}:${value}`;
|
||||
}
|
||||
};
|
||||
|
||||
export const buildAppliedFilterChips = (
|
||||
filters: SearchFilters,
|
||||
t: (key: string, options?: Record<string, string>) => string,
|
||||
): SearchFilterChip[] => {
|
||||
const chips: SearchFilterChip[] = [];
|
||||
|
||||
if (filters.roomNames.length) {
|
||||
const label = filters.roomNames.map((roomName) => getFilterChipLabel('in', roomName)).join(', ');
|
||||
chips.push({
|
||||
key: 'in',
|
||||
values: filters.roomNames,
|
||||
label,
|
||||
title: t('Search_filter_in_rooms', { rooms: label }),
|
||||
});
|
||||
}
|
||||
|
||||
if (filters.fromUsernames.length) {
|
||||
const label = filters.fromUsernames.map((username) => getFilterChipLabel('from', username)).join(', ');
|
||||
chips.push({
|
||||
key: 'from',
|
||||
values: filters.fromUsernames,
|
||||
label,
|
||||
title: t('Search_filter_from_users', { users: label }),
|
||||
});
|
||||
}
|
||||
|
||||
if (filters.startDate) {
|
||||
const label = getFilterChipLabel('after', filters.startDate);
|
||||
chips.push({
|
||||
key: 'after',
|
||||
values: [filters.startDate],
|
||||
label,
|
||||
title: t('Search_filter_after_date', { date: filters.startDate }),
|
||||
});
|
||||
}
|
||||
|
||||
if (filters.endDate) {
|
||||
const label = getFilterChipLabel('before', filters.endDate);
|
||||
chips.push({
|
||||
key: 'before',
|
||||
values: [filters.endDate],
|
||||
label,
|
||||
title: t('Search_filter_before_date', { date: filters.endDate }),
|
||||
});
|
||||
}
|
||||
|
||||
return chips;
|
||||
};
|
||||
|
||||
export const serializeSearchQuery = (searchText: string, filters: SearchFilters): string =>
|
||||
normalizeFilterText(
|
||||
[
|
||||
...filters.roomNames.map((roomName) => formatSearchFilterValue('in', roomName)),
|
||||
...filters.fromUsernames.map((username) => formatSearchFilterValue('from', username)),
|
||||
filters.startDate && formatSearchFilterValue('after', filters.startDate),
|
||||
filters.endDate && formatSearchFilterValue('before', filters.endDate),
|
||||
searchText,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' '),
|
||||
);
|
||||
|
||||
export const buildRoomSearchQuery = (value: string, mention?: string) => {
|
||||
const filterRegex = new RegExp(escapeRegExp(value.slice(0, MAX_ROOM_SEARCH_PATTERN_LENGTH)), 'i');
|
||||
|
||||
return {
|
||||
$or: [{ name: filterRegex }, { fname: filterRegex }],
|
||||
...(mention && {
|
||||
t: mention === '@' ? 'd' : { $ne: 'd' },
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
export const getAISearchButtonTooltip = ({
|
||||
hasIntelligentSearchLicense,
|
||||
intelligentSearchEnabled,
|
||||
aiSearchActive,
|
||||
t,
|
||||
}: {
|
||||
hasIntelligentSearchLicense: boolean;
|
||||
intelligentSearchEnabled: boolean;
|
||||
aiSearchActive: boolean;
|
||||
t: (key: string) => string;
|
||||
}): string => {
|
||||
if (!hasIntelligentSearchLicense) {
|
||||
return t('AI_Search_license_required_tooltip');
|
||||
}
|
||||
|
||||
if (!intelligentSearchEnabled) {
|
||||
return t('AI_Search_disabled_tooltip');
|
||||
}
|
||||
|
||||
return t(aiSearchActive ? 'Disable_AI_Search' : 'Enable_AI_Search');
|
||||
};
|
||||
13
packages/ai-search/src/constants.ts
Normal file
13
packages/ai-search/src/constants.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export const AI_LICENSE_MODULE = 'chat.rocket.rc-ai';
|
||||
|
||||
export const AI_SEARCH_PAGE_SIZE = 5;
|
||||
export const AI_SEARCH_RESULTS_PAGE_SIZE = 8;
|
||||
export const AI_SEARCH_FILTER_SUGGESTION_LIMIT = 5;
|
||||
export const AI_SEARCH_ROOM_LOOKUP_LIMIT = 20;
|
||||
export const MAX_INTELLIGENT_SEARCH_RESULTS = 50;
|
||||
export const MAX_SEARCH_FILTER_VALUES = 25;
|
||||
export const MAX_ROOM_SEARCH_PATTERN_LENGTH = 64;
|
||||
export const MAX_AI_SERVICE_RESPONSE_SIZE = 5 * 1024 * 1024;
|
||||
export const MAX_SOURCE_MESSAGE_LENGTH = 700;
|
||||
export const MAX_SEARCH_ANSWER_MESSAGES = 20;
|
||||
export const MAX_SEARCH_ANSWER_TEXT_LENGTH = 1600;
|
||||
5
packages/ai-search/src/index.ts
Normal file
5
packages/ai-search/src/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export * from './clientSearch';
|
||||
export * from './constants';
|
||||
export * from './intelligentSearch';
|
||||
export * from './llm';
|
||||
export type * from './types';
|
||||
195
packages/ai-search/src/intelligentSearch.spec.ts
Normal file
195
packages/ai-search/src/intelligentSearch.spec.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import {
|
||||
buildIntelligentSearchPipelineFilters,
|
||||
getSemanticDistanceThreshold,
|
||||
normalizeIntelligentSearchCandidates,
|
||||
normalizeSimilarityPercent,
|
||||
searchIntelligentPipeline,
|
||||
} from './intelligentSearch';
|
||||
import type { AIServiceFetch } from './types';
|
||||
|
||||
describe('AI Search intelligent search helpers', () => {
|
||||
describe('normalizeSimilarityPercent', () => {
|
||||
it('normalizes invalid and out-of-range values', () => {
|
||||
expect(normalizeSimilarityPercent(undefined)).toBe(0);
|
||||
expect(normalizeSimilarityPercent('abc')).toBe(0);
|
||||
expect(normalizeSimilarityPercent(-10)).toBe(0);
|
||||
expect(normalizeSimilarityPercent(101)).toBe(100);
|
||||
expect(normalizeSimilarityPercent(72.9)).toBe(72);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSemanticDistanceThreshold', () => {
|
||||
it('converts minimum similarity to pipeline distance threshold', () => {
|
||||
expect(getSemanticDistanceThreshold(89)).toBe(0.11);
|
||||
expect(getSemanticDistanceThreshold(0)).toBe(1);
|
||||
expect(getSemanticDistanceThreshold(100)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeIntelligentSearchCandidates', () => {
|
||||
it('normalizes supported pipeline response shapes and score formats', () => {
|
||||
const results = normalizeIntelligentSearchCandidates(
|
||||
{
|
||||
results: [
|
||||
{ metadata: { room_id: 'r1', msg_id: 'm1', text: 'metadata text', score: 0.11 } },
|
||||
{ external_identifier: 'r2:m2', content: 'content text', similarity: 0.49 },
|
||||
{ id: 'm3', rid: 'r3', document: 'document text', distance: 12 },
|
||||
{ metadata: { room_id: 'r4', msg_id: 'm4', score: null, similarity: '' }, text: 'no numeric score' },
|
||||
{ text: 'missing ids' },
|
||||
],
|
||||
},
|
||||
[],
|
||||
10,
|
||||
);
|
||||
|
||||
expect(results).toEqual([
|
||||
{ _id: 'm1', rid: 'r1', msgId: 'm1', pipelineText: 'metadata text', score: 0.89 },
|
||||
{ _id: 'm2', rid: 'r2', msgId: 'm2', pipelineText: 'content text', score: 0.49 },
|
||||
{ _id: 'm3', rid: 'r3', msgId: 'm3', pipelineText: 'document text', score: 0.88 },
|
||||
{ _id: 'm4', rid: 'r4', msgId: 'm4', pipelineText: 'no numeric score' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('filters by subscribed room ids only when a prefilter is provided', () => {
|
||||
const rawResults = [
|
||||
{ metadata: { room_id: 'allowed', msg_id: 'm1' }, text: 'allowed' },
|
||||
{ metadata: { room_id: 'blocked', msg_id: 'm2' }, text: 'blocked' },
|
||||
];
|
||||
|
||||
expect(normalizeIntelligentSearchCandidates(rawResults, [], 10)).toHaveLength(2);
|
||||
expect(normalizeIntelligentSearchCandidates(rawResults, ['allowed'], 10)).toEqual([
|
||||
{ _id: 'm1', rid: 'allowed', msgId: 'm1', pipelineText: 'allowed' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('honors the requested candidate limit after filtering invalid results', () => {
|
||||
const results = normalizeIntelligentSearchCandidates(
|
||||
[{ text: 'invalid' }, { metadata: { room_id: 'r1', msg_id: 'm1' } }, { metadata: { room_id: 'r2', msg_id: 'm2' } }],
|
||||
[],
|
||||
1,
|
||||
);
|
||||
|
||||
expect(results).toEqual([{ _id: 'm1', rid: 'r1', msgId: 'm1', pipelineText: '' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildIntelligentSearchPipelineFilters', () => {
|
||||
it('returns undefined when no subscribed room ids are available', () => {
|
||||
expect(buildIntelligentSearchPipelineFilters([], {})).toBe(undefined);
|
||||
});
|
||||
|
||||
it('serializes room, user, and date filters for the pipeline', () => {
|
||||
const startDate = new Date('2026-01-01T00:00:00.000Z');
|
||||
const endDate = new Date('2026-01-31T23:59:59.000Z');
|
||||
|
||||
expect(
|
||||
buildIntelligentSearchPipelineFilters(['r1', 'r2', 'r3'], {
|
||||
rids: ['r1', 'r2'],
|
||||
fromUsername: '@alice',
|
||||
fromUsernames: ['bob', 'alice'],
|
||||
startDate,
|
||||
endDate,
|
||||
}),
|
||||
).toEqual({
|
||||
room_id: { $in: ['r1', 'r2'] },
|
||||
username: { $in: ['bob', 'alice'] },
|
||||
timestamp: { $ge: startDate.toISOString(), $le: endDate.toISOString() },
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects explicit room filters that are not subscribed', () => {
|
||||
expect(buildIntelligentSearchPipelineFilters(['r1'], { rid: 'r2' })).toBe(undefined);
|
||||
});
|
||||
|
||||
it('passes broad subscribed room filters to the pipeline', () => {
|
||||
const roomIds = Array.from({ length: 1001 }, (_, index) => `r${index}`);
|
||||
|
||||
expect(buildIntelligentSearchPipelineFilters(roomIds, { fromUsername: 'alice' })).toEqual({
|
||||
room_id: { $in: roomIds },
|
||||
username: { $eq: 'alice' },
|
||||
});
|
||||
});
|
||||
|
||||
it('intersects explicit room filters while preserving their order', () => {
|
||||
const roomIds = Array.from({ length: 10_000 }, (_, index) => `r${index}`);
|
||||
|
||||
expect(buildIntelligentSearchPipelineFilters(roomIds, { rids: ['r9999', 'missing', 'r1'] })).toEqual({
|
||||
room_id: { $in: ['r9999', 'r1'] },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('searchIntelligentPipeline', () => {
|
||||
it('sends the expected bounded request to the pipeline', async () => {
|
||||
let requestUrl = '';
|
||||
let requestBody = '';
|
||||
const fetch: AIServiceFetch = async (url, options) => {
|
||||
requestUrl = url;
|
||||
requestBody = String(options.body);
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ results: [] }),
|
||||
text: async () => '',
|
||||
};
|
||||
};
|
||||
|
||||
const result = await searchIntelligentPipeline({
|
||||
query: 'fruit colors',
|
||||
config: {
|
||||
baseUrl: 'https://pipeline.example.com/',
|
||||
pipelineId: 'workspace pipeline',
|
||||
apiKey: 'key',
|
||||
apiKeySecret: 'secret',
|
||||
queryTemplate: 'semantic: {query}',
|
||||
minimumSimilarityPercent: 61,
|
||||
},
|
||||
classifications: ['user', 'admin'],
|
||||
pipelineFilters: { room_id: { $eq: 'r1' } },
|
||||
limit: 8,
|
||||
fetch,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ results: [] });
|
||||
expect(requestUrl).toBe('https://pipeline.example.com/pipelines/workspace%20pipeline/search');
|
||||
expect(JSON.parse(requestBody)).toEqual({
|
||||
query: 'semantic: fruit colors',
|
||||
type: 'similarity',
|
||||
classification: {
|
||||
classifications: ['user', 'admin'],
|
||||
search_type: 2,
|
||||
},
|
||||
filters: { room_id: { $eq: 'r1' } },
|
||||
params: {
|
||||
k: 8,
|
||||
threshold: 0.39,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('returns an empty result set for non-2xx pipeline responses', async () => {
|
||||
const fetch: AIServiceFetch = async () => ({
|
||||
ok: false,
|
||||
status: 500,
|
||||
json: async () => ({}),
|
||||
text: async () => 'failed',
|
||||
});
|
||||
|
||||
const result = await searchIntelligentPipeline({
|
||||
query: 'fruit colors',
|
||||
config: {
|
||||
baseUrl: 'https://pipeline.example.com',
|
||||
pipelineId: 'workspace',
|
||||
apiKey: 'key',
|
||||
apiKeySecret: 'secret',
|
||||
},
|
||||
classifications: ['user'],
|
||||
pipelineFilters: {},
|
||||
limit: 5,
|
||||
fetch,
|
||||
});
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
286
packages/ai-search/src/intelligentSearch.ts
Normal file
286
packages/ai-search/src/intelligentSearch.ts
Normal file
@@ -0,0 +1,286 @@
|
||||
import { MAX_AI_SERVICE_RESPONSE_SIZE } from './constants';
|
||||
import type {
|
||||
AIServiceFetch,
|
||||
AIServiceLogger,
|
||||
IntelligentSearchCandidate,
|
||||
IntelligentSearchFilters,
|
||||
IntelligentSearchPipelineFilters,
|
||||
IntelligentSearchPipelineRequest,
|
||||
} from './types';
|
||||
import { getErrorType } from './utils';
|
||||
|
||||
const buildEndpointUrl = (baseUrl: string, path: string): string =>
|
||||
new URL(path, baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`).toString();
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> => Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||
|
||||
const asRecord = (value: unknown): Record<string, unknown> => (isRecord(value) ? value : {});
|
||||
|
||||
const firstString = (...values: unknown[]): string | undefined => {
|
||||
for (const value of values) {
|
||||
if (typeof value === 'string' && value) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const firstNumber = (...values: unknown[]): number | undefined => {
|
||||
for (const value of values) {
|
||||
if (typeof value !== 'number' && typeof value !== 'string') {
|
||||
continue;
|
||||
}
|
||||
if (typeof value === 'string' && !value.trim()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const numberValue = Number(value);
|
||||
if (Number.isFinite(numberValue)) {
|
||||
return numberValue;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const normalizeSimilarityPercent = (value: unknown): number => {
|
||||
const numeric = Number(value);
|
||||
|
||||
if (!Number.isFinite(numeric)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Math.min(100, Math.max(0, Math.floor(numeric)));
|
||||
};
|
||||
|
||||
export const getSemanticDistanceThreshold = (minimumSimilarityPercent: number): number =>
|
||||
Number((1 - minimumSimilarityPercent / 100).toFixed(4));
|
||||
|
||||
// pipeline contract: `score`/`distance` are cosine distances (lower is better, similarity = 1 - distance)
|
||||
const normalizePipelineSimilarityScore = (value: number, type: 'distance' | 'similarity'): number => {
|
||||
const normalizedValue = Math.abs(value) > 1 ? value / 100 : value;
|
||||
const similarity = type === 'distance' ? 1 - normalizedValue : normalizedValue;
|
||||
|
||||
return Math.min(1, Math.max(0, similarity));
|
||||
};
|
||||
|
||||
const extractPipelineSimilarityScore = (result: Record<string, unknown>, metadata: Record<string, unknown>): number | undefined => {
|
||||
const similarity = firstNumber(result.similarity, metadata.similarity);
|
||||
if (typeof similarity === 'number') {
|
||||
return normalizePipelineSimilarityScore(similarity, 'similarity');
|
||||
}
|
||||
|
||||
const distance = firstNumber(result.score, result.distance, metadata.score, metadata.distance);
|
||||
if (typeof distance === 'number') {
|
||||
return normalizePipelineSimilarityScore(distance, 'distance');
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const extractIntelligentResultIds = (result: Record<string, unknown>): { rid?: string; msgId?: string } => {
|
||||
const metadata = asRecord(result.metadata);
|
||||
let rid = firstString(metadata.room_id, metadata.rid, result.room_id, result.rid);
|
||||
let msgId = firstString(metadata.msg_id, metadata.message_id, result.msg_id, result.message_id, result.id);
|
||||
const externalIdentifier = firstString(result.external_identifier);
|
||||
|
||||
if ((!rid || !msgId) && externalIdentifier) {
|
||||
const separator = externalIdentifier.indexOf(':');
|
||||
if (separator > 0 && separator < externalIdentifier.length - 1) {
|
||||
rid = rid || externalIdentifier.slice(0, separator);
|
||||
msgId = msgId || externalIdentifier.slice(separator + 1);
|
||||
} else {
|
||||
msgId = msgId || externalIdentifier;
|
||||
}
|
||||
}
|
||||
|
||||
return { rid, msgId };
|
||||
};
|
||||
|
||||
export const normalizeIntelligentSearchCandidates = (
|
||||
rawSearchResults: unknown,
|
||||
userRoomIds: string[] = [],
|
||||
limit: number,
|
||||
logger?: AIServiceLogger,
|
||||
): IntelligentSearchCandidate[] => {
|
||||
let rawResults: unknown[] = [];
|
||||
const rawSearchResultsRecord = asRecord(rawSearchResults);
|
||||
|
||||
if (Array.isArray(rawSearchResults)) {
|
||||
rawResults = rawSearchResults;
|
||||
} else if (Array.isArray(rawSearchResultsRecord.results)) {
|
||||
rawResults = rawSearchResultsRecord.results;
|
||||
} else if (Array.isArray(rawSearchResultsRecord.context)) {
|
||||
rawResults = rawSearchResultsRecord.context;
|
||||
} else if (Array.isArray(rawSearchResultsRecord.documents)) {
|
||||
rawResults = rawSearchResultsRecord.documents;
|
||||
} else if (Array.isArray(rawSearchResultsRecord.hits)) {
|
||||
rawResults = rawSearchResultsRecord.hits;
|
||||
} else if (Array.isArray(rawSearchResultsRecord.data)) {
|
||||
rawResults = rawSearchResultsRecord.data;
|
||||
}
|
||||
|
||||
logger?.debug?.({
|
||||
msg: 'Intelligent search normalizing results',
|
||||
rawCount: rawResults.length,
|
||||
rawKeys: Object.keys(rawSearchResultsRecord),
|
||||
});
|
||||
|
||||
const userRoomIdSet = new Set(userRoomIds);
|
||||
const shouldFilterByRoomIds = userRoomIdSet.size > 0;
|
||||
|
||||
const candidates: IntelligentSearchCandidate[] = [];
|
||||
for (let index = 0; index < rawResults.length && candidates.length < limit; index++) {
|
||||
const result = asRecord(rawResults[index]);
|
||||
const metadata = asRecord(result.metadata);
|
||||
const { rid, msgId } = extractIntelligentResultIds(result);
|
||||
if (!msgId && !rid) {
|
||||
continue;
|
||||
}
|
||||
if (shouldFilterByRoomIds && rid && !userRoomIdSet.has(rid)) {
|
||||
logger?.debug?.({ msg: 'Intelligent search result filtered: room not in user subscriptions', rid });
|
||||
continue;
|
||||
}
|
||||
|
||||
const score = extractPipelineSimilarityScore(result, metadata);
|
||||
candidates.push({
|
||||
_id: msgId || `intelligent-${index}`,
|
||||
rid,
|
||||
msgId,
|
||||
pipelineText: firstString(result.text, result.content, result.document, result.page_content, metadata.text) || '',
|
||||
...(typeof score === 'number' && { score }),
|
||||
});
|
||||
}
|
||||
|
||||
logger?.debug?.({ msg: 'Intelligent search after filter', candidateCount: candidates.length });
|
||||
|
||||
return candidates;
|
||||
};
|
||||
|
||||
export const buildIntelligentSearchPipelineFilters = (
|
||||
userRoomIds: string[],
|
||||
{ rid, rids, fromUsername, fromUsernames, startDate, endDate }: Omit<IntelligentSearchFilters, 'roomNames'>,
|
||||
): IntelligentSearchPipelineFilters | undefined => {
|
||||
if (!userRoomIds.length) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const requestedRoomIdSet = new Set(rids);
|
||||
if (rid) {
|
||||
requestedRoomIdSet.add(rid);
|
||||
}
|
||||
const requestedRoomIds = [...requestedRoomIdSet];
|
||||
let subscribedRoomIds = userRoomIds;
|
||||
if (requestedRoomIds.length) {
|
||||
const matchedRoomIds = new Set<string>();
|
||||
for (const roomId of userRoomIds) {
|
||||
if (requestedRoomIdSet.has(roomId)) {
|
||||
matchedRoomIds.add(roomId);
|
||||
if (matchedRoomIds.size === requestedRoomIdSet.size) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
subscribedRoomIds = requestedRoomIds.filter((roomId) => matchedRoomIds.has(roomId));
|
||||
}
|
||||
const filters: IntelligentSearchPipelineFilters = {};
|
||||
|
||||
if (requestedRoomIds.length && !subscribedRoomIds.length) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
filters.room_id = subscribedRoomIds.length === 1 ? { $eq: subscribedRoomIds[0] } : { $in: subscribedRoomIds };
|
||||
|
||||
const usernameSet = new Set<string>();
|
||||
for (const username of fromUsernames || []) {
|
||||
const normalizedUsername = username.replace(/^@/, '');
|
||||
if (normalizedUsername) {
|
||||
usernameSet.add(normalizedUsername);
|
||||
}
|
||||
}
|
||||
if (fromUsername) {
|
||||
const normalizedUsername = fromUsername.replace(/^@/, '');
|
||||
if (normalizedUsername) {
|
||||
usernameSet.add(normalizedUsername);
|
||||
}
|
||||
}
|
||||
const usernames = [...usernameSet];
|
||||
if (usernames.length === 1) {
|
||||
filters.username = { $eq: usernames[0] };
|
||||
} else if (usernames.length > 1) {
|
||||
filters.username = { $in: usernames };
|
||||
}
|
||||
|
||||
if (startDate || endDate) {
|
||||
filters.timestamp = {
|
||||
...(startDate && { $ge: startDate.toISOString() }),
|
||||
...(endDate && { $le: endDate.toISOString() }),
|
||||
};
|
||||
}
|
||||
|
||||
return filters;
|
||||
};
|
||||
|
||||
export const searchIntelligentPipeline = async ({
|
||||
query,
|
||||
config,
|
||||
classifications,
|
||||
pipelineFilters,
|
||||
limit,
|
||||
fetch,
|
||||
logger,
|
||||
}: IntelligentSearchPipelineRequest): Promise<unknown> => {
|
||||
const minimumSimilarity = normalizeSimilarityPercent(config.minimumSimilarityPercent);
|
||||
const formattedQuery = config.queryTemplate ? config.queryTemplate.replace('{query}', query) : query;
|
||||
const url = buildEndpointUrl(config.baseUrl, `pipelines/${encodeURIComponent(config.pipelineId)}/search`);
|
||||
|
||||
logger?.debug?.({
|
||||
msg: 'Intelligent search request',
|
||||
url,
|
||||
queryLength: formattedQuery.length,
|
||||
hasQueryTemplate: Boolean(config.queryTemplate),
|
||||
filterKeys: Object.keys(pipelineFilters),
|
||||
classificationCount: classifications.length,
|
||||
threshold: getSemanticDistanceThreshold(minimumSimilarity),
|
||||
});
|
||||
|
||||
let response: Awaited<ReturnType<AIServiceFetch>>;
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
method: 'POST',
|
||||
timeout: 10000,
|
||||
size: MAX_AI_SERVICE_RESPONSE_SIZE,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'X-API-KEY': config.apiKey,
|
||||
'X-API-KEY-SECRET': config.apiKeySecret,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
query: formattedQuery,
|
||||
type: 'similarity',
|
||||
classification: {
|
||||
classifications,
|
||||
search_type: 2,
|
||||
},
|
||||
filters: pipelineFilters,
|
||||
params: {
|
||||
k: limit,
|
||||
threshold: getSemanticDistanceThreshold(minimumSimilarity),
|
||||
},
|
||||
}),
|
||||
});
|
||||
} catch (fetchError: unknown) {
|
||||
logger?.warn?.({ msg: 'Intelligent search fetch failed', url, errorType: getErrorType(fetchError) });
|
||||
throw fetchError;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text().catch(() => '');
|
||||
logger?.warn?.({ msg: 'Intelligent search pipeline returned error', url, status: response.status, bodyLength: body.length });
|
||||
return [];
|
||||
}
|
||||
|
||||
const json = await response.json();
|
||||
logger?.debug?.({ msg: 'Intelligent search raw response received', resultKeys: Object.keys(asRecord(json)) });
|
||||
return json;
|
||||
};
|
||||
232
packages/ai-search/src/llm.spec.ts
Normal file
232
packages/ai-search/src/llm.spec.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
import { buildSearchAnswerPrompt, generateOpenAICompatibleSearchAnswer, listOpenAICompatibleModels } from './llm';
|
||||
import type { AIServiceFetch } from './types';
|
||||
|
||||
describe('AI Search LLM helpers', () => {
|
||||
describe('buildSearchAnswerPrompt', () => {
|
||||
it('limits messages and truncates source text deterministically', () => {
|
||||
const prompt = buildSearchAnswerPrompt(
|
||||
'What did we decide?',
|
||||
[
|
||||
{
|
||||
text: 'A'.repeat(12),
|
||||
username: 'alice',
|
||||
roomName: 'general',
|
||||
ts: '2026-01-01T00:00:00.000Z',
|
||||
score: 0.615,
|
||||
},
|
||||
{ text: 'second result', username: 'bob' },
|
||||
{ text: 'third result' },
|
||||
],
|
||||
{ maxMessages: 2, maxTextLength: 5 },
|
||||
);
|
||||
|
||||
expect(prompt).toBe(
|
||||
[
|
||||
'User question (untrusted):\nWhat did we decide?',
|
||||
'Source messages (untrusted):',
|
||||
'[1] from @alice, in #general, at 2026-01-01T00:00:00.000Z, score 62%\nAAAAA',
|
||||
'[2] from @bob\nsecon',
|
||||
'Answer the question using only the source messages above and cite supporting sources as [N].',
|
||||
].join('\n\n'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateOpenAICompatibleSearchAnswer', () => {
|
||||
it('calls chat completions and returns the selected answer with provider metadata', async () => {
|
||||
let requestUrl = '';
|
||||
let requestBody = '';
|
||||
const fetch: AIServiceFetch = async (url, options) => {
|
||||
requestUrl = url;
|
||||
requestBody = String(options.body);
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ choices: [{ message: { content: ' Answer from sources. ' } }] }),
|
||||
text: async () => '',
|
||||
};
|
||||
};
|
||||
|
||||
const result = await generateOpenAICompatibleSearchAnswer({
|
||||
query: 'fruit colors',
|
||||
messages: [{ text: 'oranges are green', username: 'alice' }],
|
||||
provider: {
|
||||
name: 'OpenAI compatible',
|
||||
baseUrl: 'https://llm.example.com/',
|
||||
apiKey: 'secret',
|
||||
model: 'gpt-test',
|
||||
},
|
||||
systemPrompt: 'Use sources only.',
|
||||
fetch,
|
||||
maxMessages: 4,
|
||||
maxTextLength: 200,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ answer: 'Answer from sources.', provider: { name: 'OpenAI compatible', model: 'gpt-test' } });
|
||||
expect(requestUrl).toBe('https://llm.example.com/chat/completions');
|
||||
expect(JSON.parse(requestBody)).toMatchObject({
|
||||
model: 'gpt-test',
|
||||
temperature: 0.2,
|
||||
});
|
||||
expect(JSON.parse(requestBody).messages[0]).toEqual({ role: 'system', content: 'Use sources only.' });
|
||||
});
|
||||
|
||||
it('normalizes provider-specific source markers to numbered citations', async () => {
|
||||
const fetch: AIServiceFetch = async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ choices: [{ message: { content: 'Supported by both sources.【1†L1-L2】【2†L4-L5】' } }] }),
|
||||
text: async () => '',
|
||||
});
|
||||
|
||||
await expect(
|
||||
generateOpenAICompatibleSearchAnswer({
|
||||
query: 'What happened?',
|
||||
messages: [{ text: 'First source' }, { text: 'Second source' }],
|
||||
provider: { name: 'OpenAI compatible', baseUrl: 'https://llm.example.com', apiKey: 'secret', model: 'gpt-test' },
|
||||
systemPrompt: 'Use numbered citations.',
|
||||
fetch,
|
||||
maxMessages: 4,
|
||||
maxTextLength: 200,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
answer: 'Supported by both sources. [1] [2]',
|
||||
provider: { name: 'OpenAI compatible', model: 'gpt-test' },
|
||||
});
|
||||
});
|
||||
|
||||
it('throws on provider failures and empty responses', async () => {
|
||||
const failedFetch: AIServiceFetch = async () => ({
|
||||
ok: false,
|
||||
status: 500,
|
||||
json: async () => ({}),
|
||||
text: async () => 'failed',
|
||||
});
|
||||
|
||||
await expect(
|
||||
generateOpenAICompatibleSearchAnswer({
|
||||
query: 'fruit colors',
|
||||
messages: [{ text: 'oranges are green' }],
|
||||
provider: { name: 'OpenAI compatible', baseUrl: 'https://llm.example.com', apiKey: 'secret', model: 'gpt-test' },
|
||||
systemPrompt: 'Use sources only.',
|
||||
fetch: failedFetch,
|
||||
maxMessages: 4,
|
||||
maxTextLength: 200,
|
||||
}),
|
||||
).rejects.toThrow('error-ai-provider-request-failed');
|
||||
|
||||
const emptyFetch: AIServiceFetch = async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ choices: [{ message: { content: ' ' } }] }),
|
||||
text: async () => '',
|
||||
});
|
||||
|
||||
await expect(
|
||||
generateOpenAICompatibleSearchAnswer({
|
||||
query: 'fruit colors',
|
||||
messages: [{ text: 'oranges are green' }],
|
||||
provider: { name: 'OpenAI compatible', baseUrl: 'https://llm.example.com', apiKey: 'secret', model: 'gpt-test' },
|
||||
systemPrompt: 'Use sources only.',
|
||||
fetch: emptyFetch,
|
||||
maxMessages: 4,
|
||||
maxTextLength: 200,
|
||||
}),
|
||||
).rejects.toThrow('error-ai-provider-empty-response');
|
||||
});
|
||||
|
||||
it('normalizes malformed provider JSON responses as request failures', async () => {
|
||||
const malformedFetch: AIServiceFetch = async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => {
|
||||
throw new SyntaxError('Unexpected token');
|
||||
},
|
||||
text: async () => '',
|
||||
});
|
||||
|
||||
await expect(
|
||||
generateOpenAICompatibleSearchAnswer({
|
||||
query: 'fruit colors',
|
||||
messages: [{ text: 'oranges are green' }],
|
||||
provider: { name: 'OpenAI compatible', baseUrl: 'https://llm.example.com', apiKey: 'secret', model: 'gpt-test' },
|
||||
systemPrompt: 'Use sources only.',
|
||||
fetch: malformedFetch,
|
||||
maxMessages: 4,
|
||||
maxTextLength: 200,
|
||||
}),
|
||||
).rejects.toThrow('error-ai-provider-request-failed');
|
||||
});
|
||||
|
||||
it('rejects valid JSON with an invalid chat completion shape', async () => {
|
||||
const invalidResponseFetch: AIServiceFetch = async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ choices: [{ message: { content: 42 } }] }),
|
||||
text: async () => '',
|
||||
});
|
||||
|
||||
await expect(
|
||||
generateOpenAICompatibleSearchAnswer({
|
||||
query: 'fruit colors',
|
||||
messages: [{ text: 'oranges are green' }],
|
||||
provider: { name: 'OpenAI compatible', baseUrl: 'https://llm.example.com', apiKey: 'secret', model: 'gpt-test' },
|
||||
systemPrompt: 'Use sources only.',
|
||||
fetch: invalidResponseFetch,
|
||||
maxMessages: 4,
|
||||
maxTextLength: 200,
|
||||
}),
|
||||
).rejects.toThrow('error-ai-provider-empty-response');
|
||||
});
|
||||
});
|
||||
|
||||
describe('listOpenAICompatibleModels', () => {
|
||||
it('returns the configured model when provider lookup is not configured', async () => {
|
||||
const fetch: AIServiceFetch = async () => {
|
||||
throw new Error('must not fetch');
|
||||
};
|
||||
|
||||
expect(await listOpenAICompatibleModels({ selectedModel: 'existing-model', fetch })).toEqual([
|
||||
{ key: 'existing-model', label: 'existing-model' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('sorts provider models and preserves the selected custom model', async () => {
|
||||
const fetch: AIServiceFetch = async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ data: [{ id: 'z-model' }, { id: 'a-model' }, { id: 'a-model' }, { id: 42 }, null] }),
|
||||
text: async () => '',
|
||||
});
|
||||
|
||||
expect(
|
||||
await listOpenAICompatibleModels({
|
||||
provider: { baseUrl: 'https://llm.example.com', apiKey: 'secret' },
|
||||
selectedModel: 'custom-model',
|
||||
fetch,
|
||||
}),
|
||||
).toEqual([
|
||||
{ key: 'custom-model', label: 'custom-model' },
|
||||
{ key: 'a-model', label: 'a-model' },
|
||||
{ key: 'z-model', label: 'z-model' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('falls back to the selected model when provider lookup fails', async () => {
|
||||
const fetch: AIServiceFetch = async () => ({
|
||||
ok: false,
|
||||
status: 401,
|
||||
json: async () => ({}),
|
||||
text: async () => 'unauthorized',
|
||||
});
|
||||
|
||||
expect(
|
||||
await listOpenAICompatibleModels({
|
||||
provider: { baseUrl: 'https://llm.example.com', apiKey: 'secret' },
|
||||
selectedModel: 'configured-model',
|
||||
fetch,
|
||||
}),
|
||||
).toEqual([{ key: 'configured-model', label: 'configured-model' }]);
|
||||
});
|
||||
});
|
||||
});
|
||||
176
packages/ai-search/src/llm.ts
Normal file
176
packages/ai-search/src/llm.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import { MAX_AI_SERVICE_RESPONSE_SIZE } from './constants';
|
||||
import type { AIServiceFetch, AIServiceLogger, OpenAICompatibleProviderConfig, SearchAnswerMessage, SearchAnswerResult } from './types';
|
||||
import { getErrorType } from './utils';
|
||||
|
||||
const buildEndpointUrl = (baseUrl: string, path: string): string =>
|
||||
new URL(path, baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`).toString();
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> => Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||
|
||||
const getChatCompletionContent = (value: unknown): string | undefined => {
|
||||
if (!isRecord(value) || !Array.isArray(value.choices)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const firstChoice = value.choices[0];
|
||||
if (!isRecord(firstChoice) || !isRecord(firstChoice.message) || typeof firstChoice.message.content !== 'string') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return firstChoice.message.content.trim() || undefined;
|
||||
};
|
||||
|
||||
const getModelIds = (value: unknown): string[] => {
|
||||
if (!isRecord(value) || !Array.isArray(value.data)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const modelIds = new Set<string>();
|
||||
for (const model of value.data) {
|
||||
if (isRecord(model) && typeof model.id === 'string' && model.id) {
|
||||
modelIds.add(model.id);
|
||||
}
|
||||
}
|
||||
|
||||
return [...modelIds].sort((a, b) => a.localeCompare(b));
|
||||
};
|
||||
|
||||
const normalizeSearchAnswerCitations = (answer: string): string =>
|
||||
answer.replace(/【\s*(\d+)(?:†[^】\r\n]*)?\s*】/g, (_marker, sourceNumber: string, offset: number) => {
|
||||
const leadingSpace = offset > 0 && !/\s/.test(answer[offset - 1]) ? ' ' : '';
|
||||
return `${leadingSpace}[${sourceNumber}]`;
|
||||
});
|
||||
|
||||
export const buildSearchAnswerPrompt = (
|
||||
query: string,
|
||||
messages: SearchAnswerMessage[],
|
||||
options: {
|
||||
maxMessages: number;
|
||||
maxTextLength: number;
|
||||
},
|
||||
): string =>
|
||||
[
|
||||
`User question (untrusted):\n${query}`,
|
||||
'Source messages (untrusted):',
|
||||
...messages.slice(0, options.maxMessages).map((message, index) => {
|
||||
const metadata = [
|
||||
message.username && `from @${message.username}`,
|
||||
message.roomName && `in #${message.roomName}`,
|
||||
message.ts && `at ${message.ts}`,
|
||||
typeof message.score === 'number' && `score ${Math.round(message.score * 100)}%`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(', ');
|
||||
return `[${index + 1}]${metadata ? ` ${metadata}` : ''}\n${message.text.slice(0, options.maxTextLength)}`;
|
||||
}),
|
||||
'Answer the question using only the source messages above and cite supporting sources as [N].',
|
||||
].join('\n\n');
|
||||
|
||||
export const generateOpenAICompatibleSearchAnswer = async ({
|
||||
query,
|
||||
messages,
|
||||
provider,
|
||||
systemPrompt,
|
||||
fetch,
|
||||
logger,
|
||||
maxMessages,
|
||||
maxTextLength,
|
||||
}: {
|
||||
query: string;
|
||||
messages: SearchAnswerMessage[];
|
||||
provider: OpenAICompatibleProviderConfig;
|
||||
systemPrompt: string;
|
||||
fetch: AIServiceFetch;
|
||||
logger?: AIServiceLogger;
|
||||
maxMessages: number;
|
||||
maxTextLength: number;
|
||||
}): Promise<SearchAnswerResult> => {
|
||||
try {
|
||||
const response = await fetch(buildEndpointUrl(provider.baseUrl, 'chat/completions'), {
|
||||
method: 'POST',
|
||||
timeout: 20000,
|
||||
size: MAX_AI_SERVICE_RESPONSE_SIZE,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'Authorization': `Bearer ${provider.apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: provider.model,
|
||||
temperature: 0.2,
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: buildSearchAnswerPrompt(query, messages, { maxMessages, maxTextLength }) },
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text().catch(() => '');
|
||||
logger?.warn?.({ msg: 'Search answer LLM provider returned error', status: response.status, bodyLength: body.length });
|
||||
throw new Error('error-ai-provider-request-failed');
|
||||
}
|
||||
|
||||
const answer = getChatCompletionContent(await response.json());
|
||||
if (!answer) {
|
||||
throw new Error('error-ai-provider-empty-response');
|
||||
}
|
||||
|
||||
return { answer: normalizeSearchAnswerCitations(answer), provider: { name: provider.name, model: provider.model } };
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof Error &&
|
||||
(error.message === 'error-ai-provider-empty-response' || error.message === 'error-ai-provider-request-failed')
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
logger?.warn?.({ msg: 'Search answer LLM provider request failed', errorType: getErrorType(error) });
|
||||
throw new Error('error-ai-provider-request-failed', { cause: error });
|
||||
}
|
||||
};
|
||||
|
||||
export const listOpenAICompatibleModels = async ({
|
||||
provider,
|
||||
selectedModel,
|
||||
fetch,
|
||||
logger,
|
||||
}: {
|
||||
provider?: Pick<OpenAICompatibleProviderConfig, 'baseUrl' | 'apiKey'>;
|
||||
selectedModel?: string;
|
||||
fetch: AIServiceFetch;
|
||||
logger?: AIServiceLogger;
|
||||
}): Promise<{ key: string; label: string }[]> => {
|
||||
const fallback = selectedModel ? [{ key: selectedModel, label: selectedModel }] : [];
|
||||
if (!provider?.baseUrl || !provider.apiKey) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(buildEndpointUrl(provider.baseUrl, 'models'), {
|
||||
method: 'GET',
|
||||
timeout: 10000,
|
||||
size: MAX_AI_SERVICE_RESPONSE_SIZE,
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
Authorization: `Bearer ${provider.apiKey}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
logger?.warn?.({ msg: 'AI LLM model lookup failed', status: response.status });
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const modelIds = getModelIds(await response.json());
|
||||
|
||||
if (selectedModel && !modelIds.includes(selectedModel)) {
|
||||
modelIds.unshift(selectedModel);
|
||||
}
|
||||
|
||||
return modelIds.map((id) => ({ key: id, label: id }));
|
||||
} catch (error) {
|
||||
logger?.warn?.({ msg: 'AI LLM model lookup request failed', errorType: getErrorType(error) });
|
||||
return fallback;
|
||||
}
|
||||
};
|
||||
81
packages/ai-search/src/types.ts
Normal file
81
packages/ai-search/src/types.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
export type AIServiceFetchResponse = {
|
||||
ok: boolean;
|
||||
status: number;
|
||||
json(): Promise<unknown>;
|
||||
text(): Promise<string>;
|
||||
};
|
||||
|
||||
export type AIServiceFetch = (
|
||||
url: string,
|
||||
options: {
|
||||
method: string;
|
||||
timeout?: number;
|
||||
size?: number;
|
||||
headers?: Record<string, string>;
|
||||
body?: string;
|
||||
},
|
||||
) => Promise<AIServiceFetchResponse>;
|
||||
|
||||
export type AIServiceLogger = {
|
||||
debug?(payload: Record<string, unknown>): void;
|
||||
warn?(payload: Record<string, unknown>): void;
|
||||
};
|
||||
|
||||
export type OpenAICompatibleProviderConfig = {
|
||||
name: string;
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
model: string;
|
||||
};
|
||||
|
||||
export type SearchAnswerMessage = {
|
||||
text: string;
|
||||
username?: string;
|
||||
roomName?: string;
|
||||
ts?: string;
|
||||
score?: number;
|
||||
};
|
||||
|
||||
export type SearchAnswerResult = {
|
||||
answer: string;
|
||||
provider: Pick<OpenAICompatibleProviderConfig, 'name' | 'model'>;
|
||||
};
|
||||
|
||||
export type IntelligentSearchPipelineConfig = {
|
||||
baseUrl: string;
|
||||
pipelineId: string;
|
||||
apiKey: string;
|
||||
apiKeySecret: string;
|
||||
queryTemplate?: string;
|
||||
minimumSimilarityPercent?: number;
|
||||
};
|
||||
|
||||
export type IntelligentSearchFilters = {
|
||||
rid?: string;
|
||||
rids?: string[];
|
||||
roomNames?: string[];
|
||||
fromUsername?: string;
|
||||
fromUsernames?: string[];
|
||||
startDate?: Date;
|
||||
endDate?: Date;
|
||||
};
|
||||
|
||||
export type IntelligentSearchPipelineFilters = Record<string, unknown>;
|
||||
|
||||
export type IntelligentSearchCandidate = {
|
||||
_id: string;
|
||||
rid?: string;
|
||||
msgId?: string;
|
||||
pipelineText: string;
|
||||
score?: number;
|
||||
};
|
||||
|
||||
export type IntelligentSearchPipelineRequest = {
|
||||
query: string;
|
||||
config: IntelligentSearchPipelineConfig;
|
||||
classifications: string[];
|
||||
pipelineFilters: IntelligentSearchPipelineFilters;
|
||||
limit: number;
|
||||
fetch: AIServiceFetch;
|
||||
logger?: AIServiceLogger;
|
||||
};
|
||||
1
packages/ai-search/src/utils.ts
Normal file
1
packages/ai-search/src/utils.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const getErrorType = (error: unknown): string => (error instanceof Error ? error.name : typeof error);
|
||||
9
packages/ai-search/tsconfig.json
Normal file
9
packages/ai-search/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "@rocket.chat/tsconfig/server.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"declaration": true
|
||||
},
|
||||
"include": ["./src/**/*"]
|
||||
}
|
||||
Reference in New Issue
Block a user