@open-mercato/search 0.6.6-develop.6296.1.5e8bdfee17 → 0.6.6-develop.6299.1.29224e2d86
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/AGENTS.md +16 -2
- package/dist/modules/search/__integration__/TC-SEARCH-010.spec.js +45 -0
- package/dist/modules/search/__integration__/TC-SEARCH-010.spec.js.map +7 -0
- package/dist/modules/search/__integration__/TC-SEARCH-011.spec.js +25 -0
- package/dist/modules/search/__integration__/TC-SEARCH-011.spec.js.map +7 -0
- package/dist/modules/search/api/embeddings/route.js +50 -6
- package/dist/modules/search/api/embeddings/route.js.map +2 -2
- package/dist/modules/search/api/search/global/route.js +1 -1
- package/dist/modules/search/api/search/global/route.js.map +2 -2
- package/dist/modules/search/api/settings/global-search/route.js +8 -5
- package/dist/modules/search/api/settings/global-search/route.js.map +2 -2
- package/dist/modules/search/di.js +3 -1
- package/dist/modules/search/di.js.map +2 -2
- package/dist/modules/search/frontend/components/sections/VectorSearchSection.js +6 -3
- package/dist/modules/search/frontend/components/sections/VectorSearchSection.js.map +2 -2
- package/dist/modules/search/i18n/de.json +3 -0
- package/dist/modules/search/i18n/en.json +3 -0
- package/dist/modules/search/i18n/es.json +3 -0
- package/dist/modules/search/i18n/pl.json +3 -0
- package/dist/modules/search/lib/auto-indexing.js +4 -1
- package/dist/modules/search/lib/auto-indexing.js.map +2 -2
- package/dist/modules/search/lib/embedding-config.js +40 -3
- package/dist/modules/search/lib/embedding-config.js.map +2 -2
- package/dist/modules/search/lib/global-search-config.js +25 -3
- package/dist/modules/search/lib/global-search-config.js.map +2 -2
- package/dist/modules/search/lib/provider-probe.js +108 -0
- package/dist/modules/search/lib/provider-probe.js.map +7 -0
- package/package.json +4 -4
- package/src/modules/search/__integration__/TC-SEARCH-010.spec.ts +74 -0
- package/src/modules/search/__integration__/TC-SEARCH-011.spec.ts +46 -0
- package/src/modules/search/api/embeddings/__tests__/route.ollama-base-url.test.ts +3 -0
- package/src/modules/search/api/embeddings/route.ts +62 -6
- package/src/modules/search/api/search/global/route.ts +2 -2
- package/src/modules/search/api/settings/global-search/route.ts +9 -4
- package/src/modules/search/di.ts +2 -0
- package/src/modules/search/frontend/components/sections/VectorSearchSection.tsx +21 -2
- package/src/modules/search/i18n/de.json +3 -0
- package/src/modules/search/i18n/en.json +3 -0
- package/src/modules/search/i18n/es.json +3 -0
- package/src/modules/search/i18n/pl.json +3 -0
- package/src/modules/search/lib/__tests__/provider-probe.test.ts +137 -0
- package/src/modules/search/lib/__tests__/search-settings-scope.test.ts +152 -0
- package/src/modules/search/lib/auto-indexing.ts +6 -3
- package/src/modules/search/lib/embedding-config.ts +58 -5
- package/src/modules/search/lib/global-search-config.ts +36 -4
- package/src/modules/search/lib/provider-probe.ts +143 -0
|
@@ -9,7 +9,10 @@ async function resolveGlobalSearchStrategies(resolver, options) {
|
|
|
9
9
|
return fallback;
|
|
10
10
|
}
|
|
11
11
|
try {
|
|
12
|
-
const value = await service.getValue("search", GLOBAL_SEARCH_STRATEGIES_KEY, {
|
|
12
|
+
const value = await service.getValue("search", GLOBAL_SEARCH_STRATEGIES_KEY, {
|
|
13
|
+
defaultValue: fallback,
|
|
14
|
+
scope: options?.scope
|
|
15
|
+
});
|
|
13
16
|
if (!Array.isArray(value) || value.length === 0) {
|
|
14
17
|
return fallback;
|
|
15
18
|
}
|
|
@@ -18,7 +21,7 @@ async function resolveGlobalSearchStrategies(resolver, options) {
|
|
|
18
21
|
return fallback;
|
|
19
22
|
}
|
|
20
23
|
}
|
|
21
|
-
async function saveGlobalSearchStrategies(resolver, strategies) {
|
|
24
|
+
async function saveGlobalSearchStrategies(resolver, strategies, options) {
|
|
22
25
|
let service;
|
|
23
26
|
try {
|
|
24
27
|
service = resolver.resolve("moduleConfigService");
|
|
@@ -34,12 +37,31 @@ async function saveGlobalSearchStrategies(resolver, strategies) {
|
|
|
34
37
|
if (validStrategies.length === 0) {
|
|
35
38
|
throw new Error("At least one valid search strategy must be enabled");
|
|
36
39
|
}
|
|
37
|
-
await service.setValue("search", GLOBAL_SEARCH_STRATEGIES_KEY, validStrategies);
|
|
40
|
+
await service.setValue("search", GLOBAL_SEARCH_STRATEGIES_KEY, validStrategies, options?.scope);
|
|
41
|
+
}
|
|
42
|
+
async function resolveGlobalSearchStrategiesResult(resolver, options) {
|
|
43
|
+
const fallback = options?.defaultValue ?? DEFAULT_GLOBAL_SEARCH_STRATEGIES;
|
|
44
|
+
let service;
|
|
45
|
+
try {
|
|
46
|
+
service = resolver.resolve("moduleConfigService");
|
|
47
|
+
} catch {
|
|
48
|
+
return { strategies: fallback, source: "env" };
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
const record = await service.getRecord("search", GLOBAL_SEARCH_STRATEGIES_KEY, options?.scope);
|
|
52
|
+
const value = record?.value;
|
|
53
|
+
if (Array.isArray(value) && value.length > 0) {
|
|
54
|
+
return { strategies: value, source: record.source };
|
|
55
|
+
}
|
|
56
|
+
} catch {
|
|
57
|
+
}
|
|
58
|
+
return { strategies: fallback, source: "env" };
|
|
38
59
|
}
|
|
39
60
|
export {
|
|
40
61
|
DEFAULT_GLOBAL_SEARCH_STRATEGIES,
|
|
41
62
|
GLOBAL_SEARCH_STRATEGIES_KEY,
|
|
42
63
|
resolveGlobalSearchStrategies,
|
|
64
|
+
resolveGlobalSearchStrategiesResult,
|
|
43
65
|
saveGlobalSearchStrategies
|
|
44
66
|
};
|
|
45
67
|
//# sourceMappingURL=global-search-config.js.map
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/search/lib/global-search-config.ts"],
|
|
4
|
-
"sourcesContent": ["import type { ModuleConfigService } from '@open-mercato/core/modules/configs/lib/module-config-service'\nimport type { SearchStrategyId } from '@open-mercato/shared/modules/search'\n\nexport const GLOBAL_SEARCH_STRATEGIES_KEY = 'global_search_strategies'\n\n/** Default strategies when none are configured */\nexport const DEFAULT_GLOBAL_SEARCH_STRATEGIES: SearchStrategyId[] = ['fulltext', 'vector', 'tokens']\n\ntype Resolver = {\n resolve: <T = unknown>(name: string) => T\n}\n\n/**\n * Get the enabled strategies for global search (Cmd+K).\n * Falls back to all strategies if not configured.\n */\nexport async function resolveGlobalSearchStrategies(\n resolver: Resolver,\n options?: { defaultValue?: SearchStrategyId[] },\n): Promise<SearchStrategyId[]> {\n const fallback = options?.defaultValue ?? DEFAULT_GLOBAL_SEARCH_STRATEGIES\n let service: ModuleConfigService\n try {\n service = resolver.resolve<ModuleConfigService>('moduleConfigService')\n } catch {\n return fallback\n }\n try {\n const value = await service.getValue<SearchStrategyId[]>('search', GLOBAL_SEARCH_STRATEGIES_KEY, {
|
|
5
|
-
"mappings": "AAGO,MAAM,+BAA+B;AAGrC,MAAM,mCAAuD,CAAC,YAAY,UAAU,QAAQ;AAUnG,eAAsB,8BACpB,UACA,SAC6B;AAC7B,QAAM,WAAW,SAAS,gBAAgB;AAC1C,MAAI;AACJ,MAAI;AACF,cAAU,SAAS,QAA6B,qBAAqB;AAAA,EACvE,QAAQ;AACN,WAAO;AAAA,EACT;AACA,MAAI;AACF,UAAM,QAAQ,MAAM,QAAQ,SAA6B,UAAU,8BAA8B,
|
|
4
|
+
"sourcesContent": ["import type { ConfigScope, ModuleConfigService } from '@open-mercato/core/modules/configs/lib/module-config-service'\nimport type { SearchStrategyId } from '@open-mercato/shared/modules/search'\n\nexport const GLOBAL_SEARCH_STRATEGIES_KEY = 'global_search_strategies'\n\n/** Default strategies when none are configured */\nexport const DEFAULT_GLOBAL_SEARCH_STRATEGIES: SearchStrategyId[] = ['fulltext', 'vector', 'tokens']\n\ntype Resolver = {\n resolve: <T = unknown>(name: string) => T\n}\n\n/**\n * Get the enabled strategies for global search (Cmd+K).\n * Falls back to all strategies if not configured.\n */\nexport async function resolveGlobalSearchStrategies(\n resolver: Resolver,\n options?: { defaultValue?: SearchStrategyId[]; scope?: ConfigScope },\n): Promise<SearchStrategyId[]> {\n const fallback = options?.defaultValue ?? DEFAULT_GLOBAL_SEARCH_STRATEGIES\n let service: ModuleConfigService\n try {\n service = resolver.resolve<ModuleConfigService>('moduleConfigService')\n } catch {\n return fallback\n }\n try {\n const value = await service.getValue<SearchStrategyId[]>('search', GLOBAL_SEARCH_STRATEGIES_KEY, {\n defaultValue: fallback,\n scope: options?.scope,\n })\n // Ensure we always return a non-empty array\n if (!Array.isArray(value) || value.length === 0) {\n return fallback\n }\n return value\n } catch {\n return fallback\n }\n}\n\n/**\n * Save the enabled strategies for global search.\n */\nexport async function saveGlobalSearchStrategies(\n resolver: Resolver,\n strategies: SearchStrategyId[],\n options?: { scope?: ConfigScope },\n): Promise<void> {\n let service: ModuleConfigService\n try {\n service = resolver.resolve<ModuleConfigService>('moduleConfigService')\n } catch {\n throw new Error('Module config service not available')\n }\n\n // Validate that at least one strategy is enabled\n if (!Array.isArray(strategies) || strategies.length === 0) {\n throw new Error('At least one search strategy must be enabled')\n }\n\n // Filter to only valid strategy IDs\n const validStrategies = strategies.filter(\n (s) => ['fulltext', 'vector', 'tokens'].includes(s)\n ) as SearchStrategyId[]\n\n if (validStrategies.length === 0) {\n throw new Error('At least one valid search strategy must be enabled')\n }\n\n await service.setValue('search', GLOBAL_SEARCH_STRATEGIES_KEY, validStrategies, options?.scope)\n}\n\nexport type GlobalSearchSource = 'tenant' | 'instance' | 'env'\n\n/**\n * Resolve the enabled strategies together with their source discriminator:\n * `tenant` (own scoped row), `instance` (inherited global row), or `env`\n * (no stored row -> default strategies).\n */\nexport async function resolveGlobalSearchStrategiesResult(\n resolver: Resolver,\n options?: { defaultValue?: SearchStrategyId[]; scope?: ConfigScope },\n): Promise<{ strategies: SearchStrategyId[]; source: GlobalSearchSource }> {\n const fallback = options?.defaultValue ?? DEFAULT_GLOBAL_SEARCH_STRATEGIES\n let service: ModuleConfigService\n try {\n service = resolver.resolve<ModuleConfigService>('moduleConfigService')\n } catch {\n return { strategies: fallback, source: 'env' }\n }\n try {\n const record = await service.getRecord('search', GLOBAL_SEARCH_STRATEGIES_KEY, options?.scope)\n const value = record?.value\n if (Array.isArray(value) && value.length > 0) {\n return { strategies: value as SearchStrategyId[], source: record!.source }\n }\n } catch {}\n return { strategies: fallback, source: 'env' }\n}\n"],
|
|
5
|
+
"mappings": "AAGO,MAAM,+BAA+B;AAGrC,MAAM,mCAAuD,CAAC,YAAY,UAAU,QAAQ;AAUnG,eAAsB,8BACpB,UACA,SAC6B;AAC7B,QAAM,WAAW,SAAS,gBAAgB;AAC1C,MAAI;AACJ,MAAI;AACF,cAAU,SAAS,QAA6B,qBAAqB;AAAA,EACvE,QAAQ;AACN,WAAO;AAAA,EACT;AACA,MAAI;AACF,UAAM,QAAQ,MAAM,QAAQ,SAA6B,UAAU,8BAA8B;AAAA,MAC/F,cAAc;AAAA,MACd,OAAO,SAAS;AAAA,IAClB,CAAC;AAED,QAAI,CAAC,MAAM,QAAQ,KAAK,KAAK,MAAM,WAAW,GAAG;AAC/C,aAAO;AAAA,IACT;AACA,WAAO;AAAA,EACT,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAKA,eAAsB,2BACpB,UACA,YACA,SACe;AACf,MAAI;AACJ,MAAI;AACF,cAAU,SAAS,QAA6B,qBAAqB;AAAA,EACvE,QAAQ;AACN,UAAM,IAAI,MAAM,qCAAqC;AAAA,EACvD;AAGA,MAAI,CAAC,MAAM,QAAQ,UAAU,KAAK,WAAW,WAAW,GAAG;AACzD,UAAM,IAAI,MAAM,8CAA8C;AAAA,EAChE;AAGA,QAAM,kBAAkB,WAAW;AAAA,IACjC,CAAC,MAAM,CAAC,YAAY,UAAU,QAAQ,EAAE,SAAS,CAAC;AAAA,EACpD;AAEA,MAAI,gBAAgB,WAAW,GAAG;AAChC,UAAM,IAAI,MAAM,oDAAoD;AAAA,EACtE;AAEA,QAAM,QAAQ,SAAS,UAAU,8BAA8B,iBAAiB,SAAS,KAAK;AAChG;AASA,eAAsB,oCACpB,UACA,SACyE;AACzE,QAAM,WAAW,SAAS,gBAAgB;AAC1C,MAAI;AACJ,MAAI;AACF,cAAU,SAAS,QAA6B,qBAAqB;AAAA,EACvE,QAAQ;AACN,WAAO,EAAE,YAAY,UAAU,QAAQ,MAAM;AAAA,EAC/C;AACA,MAAI;AACF,UAAM,SAAS,MAAM,QAAQ,UAAU,UAAU,8BAA8B,SAAS,KAAK;AAC7F,UAAM,QAAQ,QAAQ;AACtB,QAAI,MAAM,QAAQ,KAAK,KAAK,MAAM,SAAS,GAAG;AAC5C,aAAO,EAAE,YAAY,OAA6B,QAAQ,OAAQ,OAAO;AAAA,IAC3E;AAAA,EACF,QAAQ;AAAA,EAAC;AACT,SAAO,EAAE,YAAY,UAAU,QAAQ,MAAM;AAC/C;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { EMBEDDING_PROVIDERS } from "../../../vector/index.js";
|
|
2
|
+
const CACHE_VERSION = "v1";
|
|
3
|
+
const CACHE_TTL_MS = 3e4;
|
|
4
|
+
const OLLAMA_PROBE_TIMEOUT_MS = 1500;
|
|
5
|
+
const ALL_PROVIDERS = ["openai", "google", "mistral", "cohere", "bedrock", "ollama"];
|
|
6
|
+
async function checkAllProviders(probe, options) {
|
|
7
|
+
return Promise.all(
|
|
8
|
+
ALL_PROVIDERS.map(async (providerId) => ({
|
|
9
|
+
providerId,
|
|
10
|
+
...await probe.checkAvailability(providerId, options)
|
|
11
|
+
}))
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
const cacheKey = (providerId) => `embedding-provider-probe:${CACHE_VERSION}:${providerId}`;
|
|
15
|
+
const resolveCache = (container) => {
|
|
16
|
+
try {
|
|
17
|
+
return container.resolve("cache");
|
|
18
|
+
} catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
const ollamaBaseUrl = () => process.env.OLLAMA_BASE_URL ?? "http://localhost:11434";
|
|
23
|
+
const keyPresence = (providerId) => {
|
|
24
|
+
const info = EMBEDDING_PROVIDERS[providerId];
|
|
25
|
+
const envKey = info?.envKeyRequired;
|
|
26
|
+
switch (providerId) {
|
|
27
|
+
case "openai":
|
|
28
|
+
return process.env.OPENAI_API_KEY?.trim() ? { available: true } : { available: false, reason: `Set ${envKey} to enable ${info?.name ?? providerId}` };
|
|
29
|
+
case "google":
|
|
30
|
+
return process.env.GOOGLE_GENERATIVE_AI_API_KEY?.trim() ? { available: true } : { available: false, reason: `Set ${envKey} to enable ${info?.name ?? providerId}` };
|
|
31
|
+
case "mistral":
|
|
32
|
+
return process.env.MISTRAL_API_KEY?.trim() ? { available: true } : { available: false, reason: `Set ${envKey} to enable ${info?.name ?? providerId}` };
|
|
33
|
+
case "cohere":
|
|
34
|
+
return process.env.COHERE_API_KEY?.trim() ? { available: true } : { available: false, reason: `Set ${envKey} to enable ${info?.name ?? providerId}` };
|
|
35
|
+
case "bedrock":
|
|
36
|
+
return process.env.AWS_ACCESS_KEY_ID?.trim() && process.env.AWS_SECRET_ACCESS_KEY?.trim() ? { available: true } : { available: false, reason: "Set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY to enable Bedrock" };
|
|
37
|
+
default:
|
|
38
|
+
return { available: false, reason: `Unknown provider: ${providerId}` };
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
async function probeOllama(baseUrl) {
|
|
42
|
+
const controller = new AbortController();
|
|
43
|
+
const timer = setTimeout(() => controller.abort(), OLLAMA_PROBE_TIMEOUT_MS);
|
|
44
|
+
try {
|
|
45
|
+
const response = await fetch(`${baseUrl.replace(/\/$/, "")}/api/tags`, {
|
|
46
|
+
method: "GET",
|
|
47
|
+
signal: controller.signal
|
|
48
|
+
});
|
|
49
|
+
if (!response.ok) {
|
|
50
|
+
return { available: false, reason: `Ollama responded ${response.status} at ${baseUrl}` };
|
|
51
|
+
}
|
|
52
|
+
let models;
|
|
53
|
+
try {
|
|
54
|
+
const payload = await response.json();
|
|
55
|
+
if (Array.isArray(payload?.models)) models = payload.models.length;
|
|
56
|
+
} catch {
|
|
57
|
+
}
|
|
58
|
+
return { available: true, models };
|
|
59
|
+
} catch (error) {
|
|
60
|
+
const aborted = error instanceof Error && error.name === "AbortError";
|
|
61
|
+
return {
|
|
62
|
+
available: false,
|
|
63
|
+
reason: aborted ? `Ollama not reachable at ${baseUrl} (timed out)` : `Ollama not reachable at ${baseUrl}`
|
|
64
|
+
};
|
|
65
|
+
} finally {
|
|
66
|
+
clearTimeout(timer);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
async function computeAvailability(providerId) {
|
|
70
|
+
try {
|
|
71
|
+
if (providerId === "ollama") {
|
|
72
|
+
return await probeOllama(ollamaBaseUrl());
|
|
73
|
+
}
|
|
74
|
+
return keyPresence(providerId);
|
|
75
|
+
} catch (error) {
|
|
76
|
+
return { available: false, reason: error instanceof Error ? error.message : "Availability check failed" };
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
function createEmbeddingProviderProbe(container) {
|
|
80
|
+
const checkAvailability = async (providerId, options) => {
|
|
81
|
+
const cache = resolveCache(container);
|
|
82
|
+
const key = cacheKey(providerId);
|
|
83
|
+
if (!options?.force && cache) {
|
|
84
|
+
try {
|
|
85
|
+
const cached = await cache.get(key);
|
|
86
|
+
if (cached && typeof cached === "object" && "available" in cached) {
|
|
87
|
+
return cached;
|
|
88
|
+
}
|
|
89
|
+
} catch {
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
const result = await computeAvailability(providerId);
|
|
93
|
+
if (cache) {
|
|
94
|
+
try {
|
|
95
|
+
await cache.set(key, result, { ttl: CACHE_TTL_MS });
|
|
96
|
+
} catch {
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return result;
|
|
100
|
+
};
|
|
101
|
+
return { checkAvailability };
|
|
102
|
+
}
|
|
103
|
+
export {
|
|
104
|
+
checkAllProviders,
|
|
105
|
+
createEmbeddingProviderProbe,
|
|
106
|
+
probeOllama
|
|
107
|
+
};
|
|
108
|
+
//# sourceMappingURL=provider-probe.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/search/lib/provider-probe.ts"],
|
|
4
|
+
"sourcesContent": ["import type { CacheStrategy } from '@open-mercato/cache'\nimport type { AppContainer } from '@open-mercato/shared/lib/di/container'\nimport type { EmbeddingProviderId } from '../../../vector'\nimport { EMBEDDING_PROVIDERS } from '../../../vector'\n\nconst CACHE_VERSION = 'v1'\nconst CACHE_TTL_MS = 30_000\nconst OLLAMA_PROBE_TIMEOUT_MS = 1500\n\nexport type ProviderAvailability = {\n available: boolean\n reason?: string\n models?: number\n}\n\nexport type ProviderAvailabilityEntry = ProviderAvailability & {\n providerId: EmbeddingProviderId\n}\n\nexport type EmbeddingProviderProbe = {\n checkAvailability(providerId: EmbeddingProviderId, options?: { force?: boolean }): Promise<ProviderAvailability>\n}\n\nconst ALL_PROVIDERS: EmbeddingProviderId[] = ['openai', 'google', 'mistral', 'cohere', 'bedrock', 'ollama']\n\nexport async function checkAllProviders(\n probe: EmbeddingProviderProbe,\n options?: { force?: boolean },\n): Promise<ProviderAvailabilityEntry[]> {\n return Promise.all(\n ALL_PROVIDERS.map(async (providerId) => ({\n providerId,\n ...(await probe.checkAvailability(providerId, options)),\n })),\n )\n}\n\nconst cacheKey = (providerId: string) => `embedding-provider-probe:${CACHE_VERSION}:${providerId}`\n\nconst resolveCache = (container: AppContainer): CacheStrategy | null => {\n try {\n return container.resolve('cache') as CacheStrategy\n } catch {\n return null\n }\n}\n\nconst ollamaBaseUrl = () => process.env.OLLAMA_BASE_URL ?? 'http://localhost:11434'\n\nconst keyPresence = (providerId: EmbeddingProviderId): ProviderAvailability => {\n const info = EMBEDDING_PROVIDERS[providerId]\n const envKey = info?.envKeyRequired\n switch (providerId) {\n case 'openai':\n return process.env.OPENAI_API_KEY?.trim()\n ? { available: true }\n : { available: false, reason: `Set ${envKey} to enable ${info?.name ?? providerId}` }\n case 'google':\n return process.env.GOOGLE_GENERATIVE_AI_API_KEY?.trim()\n ? { available: true }\n : { available: false, reason: `Set ${envKey} to enable ${info?.name ?? providerId}` }\n case 'mistral':\n return process.env.MISTRAL_API_KEY?.trim()\n ? { available: true }\n : { available: false, reason: `Set ${envKey} to enable ${info?.name ?? providerId}` }\n case 'cohere':\n return process.env.COHERE_API_KEY?.trim()\n ? { available: true }\n : { available: false, reason: `Set ${envKey} to enable ${info?.name ?? providerId}` }\n case 'bedrock':\n return process.env.AWS_ACCESS_KEY_ID?.trim() && process.env.AWS_SECRET_ACCESS_KEY?.trim()\n ? { available: true }\n : { available: false, reason: 'Set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY to enable Bedrock' }\n default:\n return { available: false, reason: `Unknown provider: ${providerId}` }\n }\n}\n\nexport async function probeOllama(baseUrl: string): Promise<ProviderAvailability> {\n const controller = new AbortController()\n const timer = setTimeout(() => controller.abort(), OLLAMA_PROBE_TIMEOUT_MS)\n try {\n const response = await fetch(`${baseUrl.replace(/\\/$/, '')}/api/tags`, {\n method: 'GET',\n signal: controller.signal,\n })\n if (!response.ok) {\n return { available: false, reason: `Ollama responded ${response.status} at ${baseUrl}` }\n }\n let models: number | undefined\n try {\n const payload = (await response.json()) as { models?: unknown[] }\n if (Array.isArray(payload?.models)) models = payload.models.length\n } catch {}\n return { available: true, models }\n } catch (error) {\n const aborted = error instanceof Error && error.name === 'AbortError'\n return {\n available: false,\n reason: aborted ? `Ollama not reachable at ${baseUrl} (timed out)` : `Ollama not reachable at ${baseUrl}`,\n }\n } finally {\n clearTimeout(timer)\n }\n}\n\nasync function computeAvailability(providerId: EmbeddingProviderId): Promise<ProviderAvailability> {\n try {\n if (providerId === 'ollama') {\n return await probeOllama(ollamaBaseUrl())\n }\n return keyPresence(providerId)\n } catch (error) {\n // Fail closed: any unexpected error means the provider is treated as unavailable.\n return { available: false, reason: error instanceof Error ? error.message : 'Availability check failed' }\n }\n}\n\nexport function createEmbeddingProviderProbe(container: AppContainer): EmbeddingProviderProbe {\n const checkAvailability = async (\n providerId: EmbeddingProviderId,\n options?: { force?: boolean },\n ): Promise<ProviderAvailability> => {\n const cache = resolveCache(container)\n const key = cacheKey(providerId)\n if (!options?.force && cache) {\n try {\n const cached = await cache.get(key)\n if (cached && typeof cached === 'object' && 'available' in cached) {\n return cached as ProviderAvailability\n }\n } catch {}\n }\n const result = await computeAvailability(providerId)\n if (cache) {\n try {\n await cache.set(key, result, { ttl: CACHE_TTL_MS })\n } catch {}\n }\n return result\n }\n return { checkAvailability }\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/search",
|
|
3
|
-
"version": "0.6.6-develop.
|
|
3
|
+
"version": "0.6.6-develop.6299.1.29224e2d86",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -127,9 +127,9 @@
|
|
|
127
127
|
"zod": "^4.4.3"
|
|
128
128
|
},
|
|
129
129
|
"peerDependencies": {
|
|
130
|
-
"@open-mercato/core": "0.6.6-develop.
|
|
131
|
-
"@open-mercato/queue": "0.6.6-develop.
|
|
132
|
-
"@open-mercato/shared": "0.6.6-develop.
|
|
130
|
+
"@open-mercato/core": "0.6.6-develop.6299.1.29224e2d86",
|
|
131
|
+
"@open-mercato/queue": "0.6.6-develop.6299.1.29224e2d86",
|
|
132
|
+
"@open-mercato/shared": "0.6.6-develop.6299.1.29224e2d86"
|
|
133
133
|
},
|
|
134
134
|
"devDependencies": {
|
|
135
135
|
"@types/jest": "^30.0.0",
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { expect, test } from '@playwright/test'
|
|
2
|
+
import { apiRequest, getAuthToken } from '@open-mercato/core/helpers/integration/api'
|
|
3
|
+
import { readJsonSafe } from '@open-mercato/core/helpers/integration/generalFixtures'
|
|
4
|
+
|
|
5
|
+
type GlobalSearchSettings = {
|
|
6
|
+
enabledStrategies?: string[]
|
|
7
|
+
source?: 'tenant' | 'instance' | 'env'
|
|
8
|
+
}
|
|
9
|
+
type EmbeddingsSettings = {
|
|
10
|
+
settings?: {
|
|
11
|
+
embeddingConfig?: unknown
|
|
12
|
+
embeddingConfigSource?: 'tenant' | 'instance' | 'env'
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const DEFAULT_STRATEGIES = ['fulltext', 'vector', 'tokens']
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* TC-SEARCH-010: search settings carry a tenant-scope source discriminator.
|
|
20
|
+
* Source: .ai/specs/2026-06-15-tenant-scoped-search-settings.md (Phase 2).
|
|
21
|
+
*
|
|
22
|
+
* The settings APIs are tenant-scoped: GET reports a `source` of tenant | instance
|
|
23
|
+
* | env, and a tenant admin's POST writes only its own scoped row (subsequent GET
|
|
24
|
+
* reports `source: 'tenant'`). This spec is self-contained — it restores the
|
|
25
|
+
* tenant's original global-search strategies in `finally`.
|
|
26
|
+
*/
|
|
27
|
+
test.describe('TC-SEARCH-010: tenant-scoped search settings expose a source', () => {
|
|
28
|
+
test('global-search GET exposes source and POST round-trips as a tenant override', async ({ request }) => {
|
|
29
|
+
let token: string | null = null
|
|
30
|
+
let original: string[] | null = null
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
token = await getAuthToken(request, 'admin')
|
|
34
|
+
|
|
35
|
+
const before = await apiRequest(request, 'GET', '/api/search/settings/global-search', { token })
|
|
36
|
+
expect(before.ok(), 'admin should read global-search settings').toBeTruthy()
|
|
37
|
+
const beforeBody = (await readJsonSafe<GlobalSearchSettings>(before)) ?? {}
|
|
38
|
+
expect(Array.isArray(beforeBody.enabledStrategies)).toBeTruthy()
|
|
39
|
+
original = beforeBody.enabledStrategies ?? DEFAULT_STRATEGIES
|
|
40
|
+
expect(['tenant', 'instance', 'env']).toContain(beforeBody.source)
|
|
41
|
+
|
|
42
|
+
const next = original.includes('vector')
|
|
43
|
+
? original.filter((strategy) => strategy !== 'vector')
|
|
44
|
+
: [...original, 'vector']
|
|
45
|
+
const safeNext = next.length > 0 ? next : ['tokens']
|
|
46
|
+
|
|
47
|
+
const update = await apiRequest(request, 'POST', '/api/search/settings/global-search', {
|
|
48
|
+
token,
|
|
49
|
+
data: { enabledStrategies: safeNext },
|
|
50
|
+
})
|
|
51
|
+
expect(update.ok(), 'admin should update global-search settings').toBeTruthy()
|
|
52
|
+
|
|
53
|
+
const after = await apiRequest(request, 'GET', '/api/search/settings/global-search', { token })
|
|
54
|
+
const afterBody = (await readJsonSafe<GlobalSearchSettings>(after)) ?? {}
|
|
55
|
+
expect(afterBody.enabledStrategies?.sort()).toEqual([...safeNext].sort())
|
|
56
|
+
expect(afterBody.source, 'after a tenant save the source is the tenant row').toBe('tenant')
|
|
57
|
+
} finally {
|
|
58
|
+
if (token && original) {
|
|
59
|
+
await apiRequest(request, 'POST', '/api/search/settings/global-search', {
|
|
60
|
+
token,
|
|
61
|
+
data: { enabledStrategies: original },
|
|
62
|
+
}).catch(() => undefined)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
test('embeddings GET exposes the embedding config source', async ({ request }) => {
|
|
68
|
+
const token = await getAuthToken(request, 'admin')
|
|
69
|
+
const response = await apiRequest(request, 'GET', '/api/search/embeddings', { token })
|
|
70
|
+
expect(response.ok(), 'admin should read embedding settings').toBeTruthy()
|
|
71
|
+
const body = (await readJsonSafe<EmbeddingsSettings>(response)) ?? {}
|
|
72
|
+
expect(['tenant', 'instance', 'env']).toContain(body.settings?.embeddingConfigSource)
|
|
73
|
+
})
|
|
74
|
+
})
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { expect, test } from '@playwright/test'
|
|
2
|
+
import { apiRequest, getAuthToken } from '@open-mercato/core/helpers/integration/api'
|
|
3
|
+
import { readJsonSafe } from '@open-mercato/core/helpers/integration/generalFixtures'
|
|
4
|
+
|
|
5
|
+
type ProviderAvailabilityEntry = { providerId: string; available: boolean; reason?: string }
|
|
6
|
+
type EmbeddingsSettings = {
|
|
7
|
+
settings?: {
|
|
8
|
+
providerAvailability?: ProviderAvailabilityEntry[]
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
type SaveError = { error?: string; reason?: string }
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* TC-SEARCH-011: the embeddings save guard rejects an unreachable provider.
|
|
15
|
+
* Source: .ai/specs/2026-06-15-tenant-scoped-search-settings.md (Phase 3/4).
|
|
16
|
+
*
|
|
17
|
+
* POST /api/search/embeddings must reject selecting a provider the availability
|
|
18
|
+
* probe reports unreachable with a 409 + structured reason, so a dead provider
|
|
19
|
+
* (e.g. Ollama with nothing listening) can never be persisted. This is read-only
|
|
20
|
+
* on the failure path (no state changes), so it is self-contained.
|
|
21
|
+
*/
|
|
22
|
+
test.describe('TC-SEARCH-011: save guard rejects an unavailable provider', () => {
|
|
23
|
+
test('selecting Ollama while it is unreachable is rejected with 409 + reason', async ({ request }) => {
|
|
24
|
+
const token = await getAuthToken(request, 'admin')
|
|
25
|
+
|
|
26
|
+
const settingsResp = await apiRequest(request, 'GET', '/api/search/embeddings', { token })
|
|
27
|
+
expect(settingsResp.ok(), 'admin should read embedding settings').toBeTruthy()
|
|
28
|
+
const settings = (await readJsonSafe<EmbeddingsSettings>(settingsResp)) ?? {}
|
|
29
|
+
const availability = settings.settings?.providerAvailability ?? []
|
|
30
|
+
expect(Array.isArray(availability)).toBeTruthy()
|
|
31
|
+
|
|
32
|
+
const ollama = availability.find((entry) => entry.providerId === 'ollama')
|
|
33
|
+
test.skip(!ollama, 'provider availability not exposed by this build')
|
|
34
|
+
test.skip(ollama?.available === true, 'Ollama is reachable in this environment; cannot exercise the rejection path')
|
|
35
|
+
|
|
36
|
+
const response = await apiRequest(request, 'POST', '/api/search/embeddings', {
|
|
37
|
+
token,
|
|
38
|
+
data: { embeddingConfig: { providerId: 'ollama', model: 'nomic-embed-text', dimension: 768 } },
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
expect(response.status(), 'an unavailable provider must be rejected').toBe(409)
|
|
42
|
+
const body = (await readJsonSafe<SaveError>(response)) ?? {}
|
|
43
|
+
expect(typeof body.error, 'the rejection carries an error message').toBe('string')
|
|
44
|
+
expect(body, 'the rejection exposes a structured reason').toHaveProperty('reason')
|
|
45
|
+
})
|
|
46
|
+
})
|
|
@@ -15,12 +15,14 @@ jest.mock('@open-mercato/shared/lib/i18n/server', () => ({
|
|
|
15
15
|
}))
|
|
16
16
|
|
|
17
17
|
const mockResolveEmbeddingConfig = jest.fn()
|
|
18
|
+
const mockResolveEmbeddingConfigResult = jest.fn()
|
|
18
19
|
const mockSaveEmbeddingConfig = jest.fn()
|
|
19
20
|
const mockGetConfiguredProviders = jest.fn()
|
|
20
21
|
const mockDetectConfigChange = jest.fn()
|
|
21
22
|
const mockGetEffectiveDimension = jest.fn()
|
|
22
23
|
jest.mock('../../../lib/embedding-config', () => ({
|
|
23
24
|
resolveEmbeddingConfig: (...args: unknown[]) => mockResolveEmbeddingConfig(...args),
|
|
25
|
+
resolveEmbeddingConfigResult: (...args: unknown[]) => mockResolveEmbeddingConfigResult(...args),
|
|
24
26
|
saveEmbeddingConfig: (...args: unknown[]) => mockSaveEmbeddingConfig(...args),
|
|
25
27
|
getConfiguredProviders: (...args: unknown[]) => mockGetConfiguredProviders(...args),
|
|
26
28
|
detectConfigChange: (...args: unknown[]) => mockDetectConfigChange(...args),
|
|
@@ -71,6 +73,7 @@ describe('POST /api/search/embeddings — Ollama baseUrl SSRF guard', () => {
|
|
|
71
73
|
})
|
|
72
74
|
|
|
73
75
|
mockResolveEmbeddingConfig.mockResolvedValue(null)
|
|
76
|
+
mockResolveEmbeddingConfigResult.mockResolvedValue({ config: null, source: 'env' })
|
|
74
77
|
mockGetConfiguredProviders.mockReturnValue(['ollama', 'openai'])
|
|
75
78
|
mockGetEffectiveDimension.mockReturnValue(768)
|
|
76
79
|
mockDetectConfigChange.mockImplementation((_existing: unknown, next: unknown) => ({
|
|
@@ -6,11 +6,15 @@ import type { ModuleConfigService } from '@open-mercato/core/modules/configs/lib
|
|
|
6
6
|
import { envDisablesAutoIndexing, resolveAutoIndexingEnabled, SEARCH_AUTO_INDEX_CONFIG_KEY } from '../../lib/auto-indexing'
|
|
7
7
|
import {
|
|
8
8
|
resolveEmbeddingConfig,
|
|
9
|
+
resolveEmbeddingConfigResult,
|
|
9
10
|
saveEmbeddingConfig,
|
|
10
11
|
getConfiguredProviders,
|
|
11
12
|
detectConfigChange,
|
|
12
13
|
getEffectiveDimension,
|
|
13
14
|
} from '../../lib/embedding-config'
|
|
15
|
+
import type { EmbeddingConfigSource } from '../../lib/embedding-config'
|
|
16
|
+
import { checkAllProviders } from '../../lib/provider-probe'
|
|
17
|
+
import type { EmbeddingProviderProbe, ProviderAvailabilityEntry } from '../../lib/provider-probe'
|
|
14
18
|
import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
|
|
15
19
|
import type { EmbeddingProviderConfig, EmbeddingProviderId, VectorDriver } from '../../../../vector'
|
|
16
20
|
import { EMBEDDING_PROVIDERS, DEFAULT_EMBEDDING_CONFIG, EmbeddingService } from '../../../../vector'
|
|
@@ -46,7 +50,9 @@ type SettingsResponse = {
|
|
|
46
50
|
autoIndexingLocked: boolean
|
|
47
51
|
lockReason: string | null
|
|
48
52
|
embeddingConfig: EmbeddingProviderConfig | null
|
|
53
|
+
embeddingConfigSource: EmbeddingConfigSource
|
|
49
54
|
configuredProviders: EmbeddingProviderId[]
|
|
55
|
+
providerAvailability: ProviderAvailabilityEntry[]
|
|
50
56
|
indexedDimension: number | null
|
|
51
57
|
reindexRequired: boolean
|
|
52
58
|
documentCount: number | null
|
|
@@ -67,6 +73,26 @@ const configUnavailable = async () => {
|
|
|
67
73
|
return NextResponse.json({ error: t('search.api.errors.configUnavailable', 'Configuration service unavailable') }, { status: 503 })
|
|
68
74
|
}
|
|
69
75
|
|
|
76
|
+
function resolveProbe(container: { resolve: <T = unknown>(name: string) => T }): EmbeddingProviderProbe | null {
|
|
77
|
+
try {
|
|
78
|
+
return container.resolve<EmbeddingProviderProbe>('embeddingProviderProbe')
|
|
79
|
+
} catch {
|
|
80
|
+
return null
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function resolveProviderAvailability(
|
|
85
|
+
container: { resolve: <T = unknown>(name: string) => T },
|
|
86
|
+
): Promise<ProviderAvailabilityEntry[]> {
|
|
87
|
+
const probe = resolveProbe(container)
|
|
88
|
+
if (!probe) return []
|
|
89
|
+
try {
|
|
90
|
+
return await checkAllProviders(probe)
|
|
91
|
+
} catch {
|
|
92
|
+
return []
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
70
96
|
async function getIndexedDimension(container: { resolve: <T = unknown>(name: string) => T }): Promise<number | null> {
|
|
71
97
|
try {
|
|
72
98
|
const drivers = container.resolve<VectorDriver[]>('vectorDrivers')
|
|
@@ -107,14 +133,17 @@ export async function GET(req: Request) {
|
|
|
107
133
|
let autoIndexingEnabled = !lockedByEnv
|
|
108
134
|
if (!lockedByEnv) {
|
|
109
135
|
try {
|
|
110
|
-
autoIndexingEnabled = await resolveAutoIndexingEnabled(container, { defaultValue: true })
|
|
136
|
+
autoIndexingEnabled = await resolveAutoIndexingEnabled(container, { defaultValue: true, scope: { tenantId: auth.tenantId } })
|
|
111
137
|
} catch {
|
|
112
138
|
autoIndexingEnabled = true
|
|
113
139
|
}
|
|
114
140
|
}
|
|
115
141
|
|
|
116
|
-
const embeddingConfig = await
|
|
142
|
+
const { config: embeddingConfig, source: embeddingConfigSource } = await resolveEmbeddingConfigResult(container, {
|
|
143
|
+
scope: { tenantId: auth.tenantId },
|
|
144
|
+
})
|
|
117
145
|
const configuredProviders = getConfiguredProviders()
|
|
146
|
+
const providerAvailability = await resolveProviderAvailability(container)
|
|
118
147
|
const indexedDimension = await getIndexedDimension(container)
|
|
119
148
|
|
|
120
149
|
const effectiveDimension = embeddingConfig
|
|
@@ -139,7 +168,9 @@ export async function GET(req: Request) {
|
|
|
139
168
|
autoIndexingLocked: lockedByEnv,
|
|
140
169
|
lockReason: lockedByEnv ? 'env' : null,
|
|
141
170
|
embeddingConfig,
|
|
171
|
+
embeddingConfigSource,
|
|
142
172
|
configuredProviders,
|
|
173
|
+
providerAvailability,
|
|
143
174
|
indexedDimension,
|
|
144
175
|
reindexRequired,
|
|
145
176
|
documentCount,
|
|
@@ -190,10 +221,10 @@ export async function POST(req: Request) {
|
|
|
190
221
|
{ status: 409 },
|
|
191
222
|
)
|
|
192
223
|
}
|
|
193
|
-
await service.setValue('vector', SEARCH_AUTO_INDEX_CONFIG_KEY, parsed.data.autoIndexingEnabled)
|
|
224
|
+
await service.setValue('vector', SEARCH_AUTO_INDEX_CONFIG_KEY, parsed.data.autoIndexingEnabled, { tenantId: auth.tenantId })
|
|
194
225
|
}
|
|
195
226
|
|
|
196
|
-
let embeddingConfig = await resolveEmbeddingConfig(container, { defaultValue: null })
|
|
227
|
+
let embeddingConfig = await resolveEmbeddingConfig(container, { defaultValue: null, scope: { tenantId: auth.tenantId } })
|
|
197
228
|
let reindexRequired = false
|
|
198
229
|
let indexedDimension = await getIndexedDimension(container)
|
|
199
230
|
|
|
@@ -216,6 +247,7 @@ export async function POST(req: Request) {
|
|
|
216
247
|
)
|
|
217
248
|
}
|
|
218
249
|
|
|
250
|
+
// Reject an unsafe user-supplied Ollama base URL before doing anything with it (SSRF guard).
|
|
219
251
|
if (newConfig.providerId === 'ollama' && newConfig.baseUrl != null) {
|
|
220
252
|
try {
|
|
221
253
|
assertSafeOllamaBaseUrl(newConfig.baseUrl)
|
|
@@ -236,6 +268,24 @@ export async function POST(req: Request) {
|
|
|
236
268
|
}
|
|
237
269
|
}
|
|
238
270
|
|
|
271
|
+
// Save-time availability guard: never persist a provider the probe reports unreachable.
|
|
272
|
+
const probe = resolveProbe(container)
|
|
273
|
+
if (probe) {
|
|
274
|
+
const availability = await probe.checkAvailability(newConfig.providerId)
|
|
275
|
+
if (!availability.available) {
|
|
276
|
+
return NextResponse.json(
|
|
277
|
+
{
|
|
278
|
+
error: t(
|
|
279
|
+
'search.api.errors.providerUnavailable',
|
|
280
|
+
`Provider ${providerInfo.name} is not available: ${availability.reason ?? 'unreachable'}`,
|
|
281
|
+
),
|
|
282
|
+
reason: availability.reason ?? null,
|
|
283
|
+
},
|
|
284
|
+
{ status: 409 },
|
|
285
|
+
)
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
239
289
|
const change = detectConfigChange(
|
|
240
290
|
embeddingConfig,
|
|
241
291
|
{
|
|
@@ -279,7 +329,7 @@ export async function POST(req: Request) {
|
|
|
279
329
|
}
|
|
280
330
|
}
|
|
281
331
|
|
|
282
|
-
await saveEmbeddingConfig(container, change.newConfig)
|
|
332
|
+
await saveEmbeddingConfig(container, change.newConfig, { scope: { tenantId: auth.tenantId } })
|
|
283
333
|
embeddingConfig = change.newConfig
|
|
284
334
|
|
|
285
335
|
try {
|
|
@@ -296,7 +346,7 @@ export async function POST(req: Request) {
|
|
|
296
346
|
let autoIndexingEnabled = !lockedByEnv
|
|
297
347
|
if (!lockedByEnv) {
|
|
298
348
|
try {
|
|
299
|
-
autoIndexingEnabled = await resolveAutoIndexingEnabled(container, { defaultValue: true })
|
|
349
|
+
autoIndexingEnabled = await resolveAutoIndexingEnabled(container, { defaultValue: true, scope: { tenantId: auth.tenantId } })
|
|
300
350
|
} catch {
|
|
301
351
|
autoIndexingEnabled = true
|
|
302
352
|
}
|
|
@@ -307,6 +357,10 @@ export async function POST(req: Request) {
|
|
|
307
357
|
? await getVectorDocumentCount(container, auth.tenantId, auth.orgId)
|
|
308
358
|
: null
|
|
309
359
|
|
|
360
|
+
const { source: embeddingConfigSource } = await resolveEmbeddingConfigResult(container, {
|
|
361
|
+
scope: { tenantId: auth.tenantId },
|
|
362
|
+
})
|
|
363
|
+
|
|
310
364
|
return toJson({
|
|
311
365
|
settings: {
|
|
312
366
|
openaiConfigured: openAiConfigured(),
|
|
@@ -314,7 +368,9 @@ export async function POST(req: Request) {
|
|
|
314
368
|
autoIndexingLocked: lockedByEnv,
|
|
315
369
|
lockReason: lockedByEnv ? 'env' : null,
|
|
316
370
|
embeddingConfig,
|
|
371
|
+
embeddingConfigSource,
|
|
317
372
|
configuredProviders: getConfiguredProviders(),
|
|
373
|
+
providerAvailability: await resolveProviderAvailability(container),
|
|
318
374
|
indexedDimension,
|
|
319
375
|
reindexRequired,
|
|
320
376
|
documentCount: updatedDocumentCount,
|
|
@@ -64,8 +64,8 @@ export async function GET(req: Request) {
|
|
|
64
64
|
)
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
-
// Fetch saved global search strategies
|
|
68
|
-
const strategies = await resolveGlobalSearchStrategies(container)
|
|
67
|
+
// Fetch saved global search strategies (per-tenant; falls back to the instance default)
|
|
68
|
+
const strategies = await resolveGlobalSearchStrategies(container, { scope: { tenantId: auth.tenantId } })
|
|
69
69
|
|
|
70
70
|
// Load embedding config for vector strategy (only if vector is enabled)
|
|
71
71
|
if (strategies.includes('vector')) {
|
|
@@ -4,10 +4,11 @@ import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
|
|
|
4
4
|
import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
|
|
5
5
|
import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
|
|
6
6
|
import {
|
|
7
|
-
|
|
7
|
+
resolveGlobalSearchStrategiesResult,
|
|
8
8
|
saveGlobalSearchStrategies,
|
|
9
9
|
DEFAULT_GLOBAL_SEARCH_STRATEGIES,
|
|
10
10
|
} from '../../../lib/global-search-config'
|
|
11
|
+
import type { GlobalSearchSource } from '../../../lib/global-search-config'
|
|
11
12
|
import type { SearchStrategyId } from '@open-mercato/shared/modules/search'
|
|
12
13
|
import { globalSearchSettingsOpenApi } from '../../openapi'
|
|
13
14
|
|
|
@@ -22,6 +23,7 @@ export const metadata = {
|
|
|
22
23
|
|
|
23
24
|
type SettingsResponse = {
|
|
24
25
|
enabledStrategies: SearchStrategyId[]
|
|
26
|
+
source?: GlobalSearchSource
|
|
25
27
|
}
|
|
26
28
|
|
|
27
29
|
const toJson = (payload: SettingsResponse, init?: ResponseInit) => NextResponse.json(payload, init)
|
|
@@ -37,11 +39,12 @@ export async function GET(req: Request) {
|
|
|
37
39
|
|
|
38
40
|
const container = await createRequestContainer()
|
|
39
41
|
try {
|
|
40
|
-
const enabledStrategies = await
|
|
42
|
+
const { strategies: enabledStrategies, source } = await resolveGlobalSearchStrategiesResult(container, {
|
|
41
43
|
defaultValue: DEFAULT_GLOBAL_SEARCH_STRATEGIES,
|
|
44
|
+
scope: { tenantId: auth.tenantId },
|
|
42
45
|
})
|
|
43
46
|
|
|
44
|
-
return toJson({ enabledStrategies })
|
|
47
|
+
return toJson({ enabledStrategies, source })
|
|
45
48
|
} finally {
|
|
46
49
|
const disposable = container as unknown as { dispose?: () => Promise<void> }
|
|
47
50
|
if (typeof disposable.dispose === 'function') {
|
|
@@ -75,7 +78,9 @@ export async function POST(req: Request) {
|
|
|
75
78
|
|
|
76
79
|
const container = await createRequestContainer()
|
|
77
80
|
try {
|
|
78
|
-
await saveGlobalSearchStrategies(container, parsed.data.enabledStrategies
|
|
81
|
+
await saveGlobalSearchStrategies(container, parsed.data.enabledStrategies, {
|
|
82
|
+
scope: { tenantId: auth.tenantId },
|
|
83
|
+
})
|
|
79
84
|
|
|
80
85
|
return NextResponse.json({ ok: true, enabledStrategies: parsed.data.enabledStrategies })
|
|
81
86
|
} catch (error) {
|
package/src/modules/search/di.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { getRedisUrlOrThrow } from '@open-mercato/shared/lib/redis/connection'
|
|
|
4
4
|
import { EmbeddingService, createPgVectorDriver, createChromaDbDriver, createQdrantDriver } from '../../vector'
|
|
5
5
|
import { createVectorIndexingQueue, type VectorIndexJobPayload } from '../../queue/vector-indexing'
|
|
6
6
|
import { createFulltextIndexingQueue, type FulltextIndexJobPayload } from '../../queue/fulltext-indexing'
|
|
7
|
+
import { createEmbeddingProviderProbe } from './lib/provider-probe'
|
|
7
8
|
import type { Queue } from '@open-mercato/queue'
|
|
8
9
|
|
|
9
10
|
/**
|
|
@@ -47,5 +48,6 @@ export function register(container: AppContainer) {
|
|
|
47
48
|
vectorDrivers: asValue(drivers),
|
|
48
49
|
vectorIndexQueue: asValue(vectorIndexQueue),
|
|
49
50
|
fulltextIndexQueue: asValue(fulltextIndexQueue),
|
|
51
|
+
embeddingProviderProbe: asValue(createEmbeddingProviderProbe(container)),
|
|
50
52
|
})
|
|
51
53
|
}
|