src / engine / SearchEngine.ts
src / engine / SearchEngine.ts
import { defaultConfigurationService } from "../config/ConfigurationService";
import { ProviderError, SearchFailedError } from "../models/errors";
import { SearchResult } from "../models/SearchResult";
import { BaseProvider } from "../providers/BaseProvider";
import { ProviderRegistry } from "../providers/ProviderRegistry";
import { InternalProviderResult } from "../providers/types/InternalProviderResult";
import { SearchOptions } from "../types/search";
import { Logger } from "../utils/Logger";
import { limitSearchPayload } from "./budget/SearchPayloadLimiter";
import { CanonicalSearchResultBuilder } from "./normalization/CanonicalSearchResultBuilder";
/**
* Central Search Engine orchestrator for Browse.
* Manages provider registration, provider resolution, candidate building, and budget enforcement.
*/
export class SearchEngine {
private registry: ProviderRegistry;
private searchCounter: number = 0;
constructor(providers?: BaseProvider[], registry?: ProviderRegistry) {
this.registry = registry ?? new ProviderRegistry();
if (providers) {
for (const p of providers) {
this.registerProvider(p);
}
}
}
public registerProvider(provider: BaseProvider): void {
this.registry.register(provider);
}
public unregisterProvider(providerName: string): boolean {
return this.registry.unregister(providerName);
}
public getProvider(providerName: string): BaseProvider | undefined {
return this.registry.get(providerName);
}
public listProviders(): string[] {
return this.registry.listNames();
}
public getProviders(): BaseProvider[] {
return this.registry.getAll();
}
public async search(query: string, options: SearchOptions = {}): Promise<SearchResult[]> {
this.searchCounter += 1;
const reqId = options.requestId || `S-${Math.random().toString(36).substring(2, 6).toUpperCase()}`;
const startTime = Date.now();
const requestedProvider = options.provider?.toLowerCase().trim() || "auto";
const configuredDefault = defaultConfigurationService.getWebDefaultProvider() || "auto";
Logger.info("[SearchEngine]", `[${reqId}] Web search execution started for query: "${query}"`);
const providersList = this.registry.getAll();
if (providersList.length === 0) {
Logger.error("[SearchEngine]", `[${reqId}] Search failed: No search providers are registered.`);
throw new ProviderError("No search providers are registered.");
}
// 1. Explicit provider parameter requested
if (requestedProvider !== "auto") {
const resolved = this.getProvider(requestedProvider);
if (!resolved) {
Logger.error("[SearchEngine]", `[${reqId}] Search failed: Unknown provider '${requestedProvider}'`);
throw new ProviderError(`Unknown or unregistered provider: '${options.provider}'`);
}
Logger.info(
"[SearchEngine]",
`[${reqId}] Provider Selection -> Requested: '${requestedProvider}' | Configured Default: '${configuredDefault}' | Mode: Explicit Provider | Selected: web.${requestedProvider}`
);
try {
const results = await this.dispatchSingle(resolved, query, options, reqId);
return results;
} catch (err: unknown) {
const errMsg = err instanceof Error ? err.message : String(err);
Logger.error(
"[SearchEngine]",
`[${reqId}] Explicit provider 'web.${requestedProvider}' failed: ${errMsg}. Fallback disabled in Explicit Provider Mode.`
);
throw err;
}
}
// 2. Explicit provider mode: Configured default provider from Settings
if (configuredDefault !== "auto") {
const resolved = this.getProvider(configuredDefault);
if (!resolved) {
Logger.error("[SearchEngine]", `[${reqId}] Search failed: Configured default provider '${configuredDefault}' is unknown or unregistered.`);
throw new ProviderError(`Configured default provider '${configuredDefault}' is unknown or unregistered.`);
}
Logger.info(
"[SearchEngine]",
`[${reqId}] Provider Selection -> Requested: 'auto' | Configured Default: '${configuredDefault}' | Mode: Explicit Default Provider | Selected: web.${configuredDefault}`
);
try {
const results = await this.dispatchSingle(resolved, query, options, reqId);
return results;
} catch (err: unknown) {
const errMsg = err instanceof Error ? err.message : String(err);
Logger.error(
"[SearchEngine]",
`[${reqId}] Configured default provider 'web.${configuredDefault}' failed: ${errMsg}. Fallback disabled in Explicit Provider Mode.`
);
throw err;
}
}
// 3. Auto Provider Mode: Both requested and default are "auto". Iterate priority list with fallback.
return this.executeFallback(query, options, reqId, requestedProvider, configuredDefault, startTime);
}
private async dispatchSingle(
targetProvider: BaseProvider,
query: string,
options: SearchOptions,
reqId: string
): Promise<SearchResult[]> {
const candidates = await targetProvider.search(query, options);
return this.processCandidates(candidates, reqId);
}
private processCandidates(
candidates: InternalProviderResult[],
reqId: string
): SearchResult[] {
const maxSnippetLen = defaultConfigurationService.getSearchMaxSnippetLength();
const maxPayloadChars = defaultConfigurationService.getSearchMaxPayloadCharacters();
// 1. Canonical normalization
const canonicalResults: SearchResult[] = [];
for (const candidate of candidates) {
const built = CanonicalSearchResultBuilder.build(candidate, maxSnippetLen);
if (built) {
canonicalResults.push(built);
}
}
// 2. Payload limiting
const limitResult = limitSearchPayload(canonicalResults, maxPayloadChars);
if (!limitResult.success) {
const errMsg = limitResult.reason;
Logger.error("[SearchEngine]", `[${reqId}] Search failed at Payload Limiter: ${errMsg}`);
throw new SearchFailedError(errMsg);
}
return limitResult.results;
}
private async executeFallback(
query: string,
options: SearchOptions,
reqId: string,
requestedProvider: string,
configuredDefault: string,
startTime: number
): Promise<SearchResult[]> {
const priorityNames = defaultConfigurationService.getWebProviderPriority();
const priorityIds = priorityNames
.map((name) => ProviderRegistry.getSpec(name)?.id)
.filter((id) => id !== undefined);
Logger.info(
"[SearchEngine]",
`[${reqId}] Mode: Auto Provider Fallback Chain | Priority IDs: [${priorityIds.join(",")}] | Resolved Order: [${priorityNames.join(", ")}]`
);
const attemptedErrors: Record<string, string> = {};
const orderedProviders: BaseProvider[] = [];
for (const name of priorityNames) {
const p = this.getProvider(name);
if (p && !orderedProviders.includes(p)) {
orderedProviders.push(p);
}
}
if (orderedProviders.length === 0) {
const noValMsg = "Auto (Priority Order) cannot run because the configured Provider Priority Order contains no valid providers. Update the Provider Priority Order or select a specific provider.";
Logger.error("[SearchEngine]", `[${reqId}] ${noValMsg}`);
throw new SearchFailedError(noValMsg, {});
}
let lastFailedProvider: string | null = null;
for (const p of orderedProviders) {
const strategy = lastFailedProvider ? `Fallback (after ${lastFailedProvider} failed)` : "Auto Fallback Chain";
Logger.info(
"[SearchEngine]",
`[${reqId}] Provider Selection -> Requested: '${requestedProvider}' | Configured Default: '${configuredDefault}' | Strategy: ${strategy} | Selected: web.${p.providerName}`
);
try {
const results = await this.dispatchSingle(p, query, options, reqId);
const durationMs = Date.now() - startTime;
Logger.info(
"[SearchEngine]",
`[${reqId}] Search completed. Provider: web.${p.providerName} | Duration: ${durationMs} ms | Canonical Results: ${results.length}`
);
return results;
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
attemptedErrors[p.providerName] = msg;
lastFailedProvider = `web.${p.providerName}`;
Logger.warn(
"[SearchEngine]",
`[${reqId}] Provider web.${p.providerName} exhausted/failed: ${msg}. Moving to next provider in configured priority order.`
);
}
}
const attemptedNames = Object.keys(attemptedErrors).join(", ");
const lastReason = Object.values(attemptedErrors).pop() ?? "Unknown error";
Logger.error(
"[SearchEngine]",
`[${reqId}] Search failed completely across all attempted providers: [${attemptedNames}]`
);
throw new SearchFailedError(
`All search providers failed. Attempted providers: [${attemptedNames}]. Last error: ${lastReason}`,
attemptedErrors
);
}
}
import { defaultConfigurationService } from "../config/ConfigurationService";
import { ProviderError, SearchFailedError } from "../models/errors";
import { SearchResult } from "../models/SearchResult";
import { BaseProvider } from "../providers/BaseProvider";
import { ProviderRegistry } from "../providers/ProviderRegistry";
import { InternalProviderResult } from "../providers/types/InternalProviderResult";
import { SearchOptions } from "../types/search";
import { Logger } from "../utils/Logger";
import { limitSearchPayload } from "./budget/SearchPayloadLimiter";
import { CanonicalSearchResultBuilder } from "./normalization/CanonicalSearchResultBuilder";
/**
* Central Search Engine orchestrator for Browse.
* Manages provider registration, provider resolution, candidate building, and budget enforcement.
*/
export class SearchEngine {
private registry: ProviderRegistry;
private searchCounter: number = 0;
constructor(providers?: BaseProvider[], registry?: ProviderRegistry) {
this.registry = registry ?? new ProviderRegistry();
if (providers) {
for (const p of providers) {
this.registerProvider(p);
}
}
}
public registerProvider(provider: BaseProvider): void {
this.registry.register(provider);
}
public unregisterProvider(providerName: string): boolean {
return this.registry.unregister(providerName);
}
public getProvider(providerName: string): BaseProvider | undefined {
return this.registry.get(providerName);
}
public listProviders(): string[] {
return this.registry.listNames();
}
public getProviders(): BaseProvider[] {
return this.registry.getAll();
}
public async search(query: string, options: SearchOptions = {}): Promise<SearchResult[]> {
this.searchCounter += 1;
const reqId = options.requestId || `S-${Math.random().toString(36).substring(2, 6).toUpperCase()}`;
const startTime = Date.now();
const requestedProvider = options.provider?.toLowerCase().trim() || "auto";
const configuredDefault = defaultConfigurationService.getWebDefaultProvider() || "auto";
Logger.info("[SearchEngine]", `[${reqId}] Web search execution started for query: "${query}"`);
const providersList = this.registry.getAll();
if (providersList.length === 0) {
Logger.error("[SearchEngine]", `[${reqId}] Search failed: No search providers are registered.`);
throw new ProviderError("No search providers are registered.");
}
// 1. Explicit provider parameter requested
if (requestedProvider !== "auto") {
const resolved = this.getProvider(requestedProvider);
if (!resolved) {
Logger.error("[SearchEngine]", `[${reqId}] Search failed: Unknown provider '${requestedProvider}'`);
throw new ProviderError(`Unknown or unregistered provider: '${options.provider}'`);
}
Logger.info(
"[SearchEngine]",
`[${reqId}] Provider Selection -> Requested: '${requestedProvider}' | Configured Default: '${configuredDefault}' | Mode: Explicit Provider | Selected: web.${requestedProvider}`
);
try {
const results = await this.dispatchSingle(resolved, query, options, reqId);
return results;
} catch (err: unknown) {
const errMsg = err instanceof Error ? err.message : String(err);
Logger.error(
"[SearchEngine]",
`[${reqId}] Explicit provider 'web.${requestedProvider}' failed: ${errMsg}. Fallback disabled in Explicit Provider Mode.`
);
throw err;
}
}
// 2. Explicit provider mode: Configured default provider from Settings
if (configuredDefault !== "auto") {
const resolved = this.getProvider(configuredDefault);
if (!resolved) {
Logger.error("[SearchEngine]", `[${reqId}] Search failed: Configured default provider '${configuredDefault}' is unknown or unregistered.`);
throw new ProviderError(`Configured default provider '${configuredDefault}' is unknown or unregistered.`);
}
Logger.info(
"[SearchEngine]",
`[${reqId}] Provider Selection -> Requested: 'auto' | Configured Default: '${configuredDefault}' | Mode: Explicit Default Provider | Selected: web.${configuredDefault}`
);
try {
const results = await this.dispatchSingle(resolved, query, options, reqId);
return results;
} catch (err: unknown) {
const errMsg = err instanceof Error ? err.message : String(err);
Logger.error(
"[SearchEngine]",
`[${reqId}] Configured default provider 'web.${configuredDefault}' failed: ${errMsg}. Fallback disabled in Explicit Provider Mode.`
);
throw err;
}
}
// 3. Auto Provider Mode: Both requested and default are "auto". Iterate priority list with fallback.
return this.executeFallback(query, options, reqId, requestedProvider, configuredDefault, startTime);
}
private async dispatchSingle(
targetProvider: BaseProvider,
query: string,
options: SearchOptions,
reqId: string
): Promise<SearchResult[]> {
const candidates = await targetProvider.search(query, options);
return this.processCandidates(candidates, reqId);
}
private processCandidates(
candidates: InternalProviderResult[],
reqId: string
): SearchResult[] {
const maxSnippetLen = defaultConfigurationService.getSearchMaxSnippetLength();
const maxPayloadChars = defaultConfigurationService.getSearchMaxPayloadCharacters();
// 1. Canonical normalization
const canonicalResults: SearchResult[] = [];
for (const candidate of candidates) {
const built = CanonicalSearchResultBuilder.build(candidate, maxSnippetLen);
if (built) {
canonicalResults.push(built);
}
}
// 2. Payload limiting
const limitResult = limitSearchPayload(canonicalResults, maxPayloadChars);
if (!limitResult.success) {
const errMsg = limitResult.reason;
Logger.error("[SearchEngine]", `[${reqId}] Search failed at Payload Limiter: ${errMsg}`);
throw new SearchFailedError(errMsg);
}
return limitResult.results;
}
private async executeFallback(
query: string,
options: SearchOptions,
reqId: string,
requestedProvider: string,
configuredDefault: string,
startTime: number
): Promise<SearchResult[]> {
const priorityNames = defaultConfigurationService.getWebProviderPriority();
const priorityIds = priorityNames
.map((name) => ProviderRegistry.getSpec(name)?.id)
.filter((id) => id !== undefined);
Logger.info(
"[SearchEngine]",
`[${reqId}] Mode: Auto Provider Fallback Chain | Priority IDs: [${priorityIds.join(",")}] | Resolved Order: [${priorityNames.join(", ")}]`
);
const attemptedErrors: Record<string, string> = {};
const orderedProviders: BaseProvider[] = [];
for (const name of priorityNames) {
const p = this.getProvider(name);
if (p && !orderedProviders.includes(p)) {
orderedProviders.push(p);
}
}
if (orderedProviders.length === 0) {
const noValMsg = "Auto (Priority Order) cannot run because the configured Provider Priority Order contains no valid providers. Update the Provider Priority Order or select a specific provider.";
Logger.error("[SearchEngine]", `[${reqId}] ${noValMsg}`);
throw new SearchFailedError(noValMsg, {});
}
let lastFailedProvider: string | null = null;
for (const p of orderedProviders) {
const strategy = lastFailedProvider ? `Fallback (after ${lastFailedProvider} failed)` : "Auto Fallback Chain";
Logger.info(
"[SearchEngine]",
`[${reqId}] Provider Selection -> Requested: '${requestedProvider}' | Configured Default: '${configuredDefault}' | Strategy: ${strategy} | Selected: web.${p.providerName}`
);
try {
const results = await this.dispatchSingle(p, query, options, reqId);
const durationMs = Date.now() - startTime;
Logger.info(
"[SearchEngine]",
`[${reqId}] Search completed. Provider: web.${p.providerName} | Duration: ${durationMs} ms | Canonical Results: ${results.length}`
);
return results;
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
attemptedErrors[p.providerName] = msg;
lastFailedProvider = `web.${p.providerName}`;
Logger.warn(
"[SearchEngine]",
`[${reqId}] Provider web.${p.providerName} exhausted/failed: ${msg}. Moving to next provider in configured priority order.`
);
}
}
const attemptedNames = Object.keys(attemptedErrors).join(", ");
const lastReason = Object.values(attemptedErrors).pop() ?? "Unknown error";
Logger.error(
"[SearchEngine]",
`[${reqId}] Search failed completely across all attempted providers: [${attemptedNames}]`
);
throw new SearchFailedError(
`All search providers failed. Attempted providers: [${attemptedNames}]. Last error: ${lastReason}`,
attemptedErrors
);
}
}