fix(api): treat typographic quotes as exact phrase search (#3180)

This commit is contained in:
Hampus
2026-10-03 19:43:08 +02:00
committed by GitHub
parent 583c791016
commit 7b39e5a79d
3 changed files with 75 additions and 1 deletions
@@ -0,0 +1,50 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
const QUOTE_CHARS = new Set(['"', '“', '”', '„', '‟', '″', '«', '»', '"']);
function extractQuotedPhrases(input: string): {rest: string; phrases: Array<string>} {
const phrases: Array<string> = [];
let rest = '';
let i = 0;
while (i < input.length) {
if (!QUOTE_CHARS.has(input[i])) {
rest += input[i];
i++;
continue;
}
i++;
let phrase = '';
while (i < input.length && !QUOTE_CHARS.has(input[i])) {
phrase += input[i];
i++;
}
i++;
const trimmed = phrase.trim();
if (trimmed) phrases.push(trimmed);
rest += ' ';
}
return {rest: rest.replace(/\s+/g, ' ').trim(), phrases};
}
function stripWrappingQuotes(phrase: string): string {
let start = 0;
let end = phrase.length;
while (start < end && QUOTE_CHARS.has(phrase[start])) start++;
while (end > start && QUOTE_CHARS.has(phrase[end - 1])) end--;
return phrase.slice(start, end).trim();
}
export function normalizeQuotedPhrases<T extends {content?: string; exact_phrases?: Array<string>}>(params: T): T {
const exactPhrases = (params.exact_phrases ?? []).map(stripWrappingQuotes).filter(Boolean);
let content = params.content;
if (content) {
const extracted = extractQuotedPhrases(content);
exactPhrases.push(...extracted.phrases);
content = extracted.rest || undefined;
}
return {
...params,
content,
exact_phrases: exactPhrases.length > 0 ? [...new Set(exactPhrases)] : undefined,
};
}
+3 -1
View File
@@ -7,6 +7,7 @@ import type {GuildService} from '@app/api/guild/services/GuildService';
import type {UserCacheService} from '@app/api/infrastructure/UserCacheService'; import type {UserCacheService} from '@app/api/infrastructure/UserCacheService';
import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware'; import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware';
import {GlobalSearchService} from '@app/api/search/GlobalSearchService'; import {GlobalSearchService} from '@app/api/search/GlobalSearchService';
import {normalizeQuotedPhrases} from '@app/api/search/SearchQuotedPhrases';
import type {IUserRepository} from '@app/api/user/IUserRepository'; import type {IUserRepository} from '@app/api/user/IUserRepository';
import type {WorkerTaskName} from '@app/api/worker/WorkerLaneConfig'; import type {WorkerTaskName} from '@app/api/worker/WorkerLaneConfig';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes'; import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
@@ -58,7 +59,8 @@ export class SearchService {
data: GlobalSearchMessagesRequest; data: GlobalSearchMessagesRequest;
}): Promise<MessageSearchResponse> { }): Promise<MessageSearchResponse> {
const {userId, requestCache, data} = params; const {userId, requestCache, data} = params;
const {channel_id, channel_ids, context_channel_id, context_guild_id, ...searchParams} = data; const {channel_id, channel_ids, context_channel_id, context_guild_id, ...rawSearchParams} = data;
const searchParams = normalizeQuotedPhrases(rawSearchParams);
const contextChannelId = context_channel_id ? createChannelID(context_channel_id) : null; const contextChannelId = context_channel_id ? createChannelID(context_channel_id) : null;
const contextGuildId = context_guild_id ? createGuildID(context_guild_id) : null; const contextGuildId = context_guild_id ? createGuildID(context_guild_id) : null;
const channelIds = (channel_ids ?? channel_id)?.map((id) => createChannelID(id)) ?? []; const channelIds = (channel_ids ?? channel_id)?.map((id) => createChannelID(id)) ?? [];
@@ -145,6 +145,28 @@ describe('Message Search Exact Phrases', () => {
} }
} }
}); });
test('typographic quotes in content are treated as exact phrases', async () => {
const owner = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'Smart Quote Guild');
const channelId = guild.system_channel_id!;
const timestamp = Date.now();
const tag = `sq-${timestamp}`;
await sendMessage(harness, owner.token, channelId, `${tag} hello world`);
await sendMessage(harness, owner.token, channelId, `${tag} world hello`);
await markChannelAsIndexed(harness, channelId);
const result = await createBuilder<MessageSearchResponse>(harness, owner.token)
.post('/search/messages')
.body({
content: `${tag} \u201chello world\u201d`,
context_channel_id: channelId,
})
.expect(HTTP_STATUS.OK)
.execute();
expect(isSearchResult(result)).toBe(true);
if (isSearchResult(result)) {
expect(result.messages.map((m) => m.content)).toEqual([`${tag} hello world`]);
}
});
test('exact_phrases combined with author_id filter', async () => { test('exact_phrases combined with author_id filter', async () => {
const owner = await createTestAccount(harness); const owner = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'Author Filter Phrase Guild'); const guild = await createGuild(harness, owner.token, 'Author Filter Phrase Guild');