@open-mercato/search 0.6.6-develop.6290.1.4bb5a8ba3f → 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
|
@@ -44,7 +44,9 @@ type EmbeddingSettings = {
|
|
|
44
44
|
autoIndexingLocked: boolean
|
|
45
45
|
lockReason: string | null
|
|
46
46
|
embeddingConfig: EmbeddingProviderConfig | null
|
|
47
|
+
embeddingConfigSource?: 'tenant' | 'instance' | 'env'
|
|
47
48
|
configuredProviders: EmbeddingProviderId[]
|
|
49
|
+
providerAvailability?: { providerId: EmbeddingProviderId; available: boolean; reason?: string; models?: number }[]
|
|
48
50
|
indexedDimension: number | null
|
|
49
51
|
reindexRequired: boolean
|
|
50
52
|
documentCount: number | null
|
|
@@ -531,11 +533,24 @@ export function VectorSearchSection({
|
|
|
531
533
|
{/* Embedding Provider Selection */}
|
|
532
534
|
<div>
|
|
533
535
|
<h3 className="text-sm font-semibold mb-2">{t('search.settings.vector.providers', 'Embedding Provider')}</h3>
|
|
534
|
-
<p className="text-xs text-muted-foreground mb-
|
|
536
|
+
<p className="text-xs text-muted-foreground mb-1">{t('search.settings.vector.providersHint', 'Select a provider to generate embeddings. Only reachable providers can be selected.')}</p>
|
|
537
|
+
{embeddingSettings?.embeddingConfigSource && embeddingSettings.embeddingConfigSource !== 'tenant' && (
|
|
538
|
+
<p className="text-xs text-muted-foreground mb-3">
|
|
539
|
+
{t('search.settings.vector.inheritingDefault', 'This tenant is inheriting the instance/environment default. Saving a provider creates an override for this tenant.')}
|
|
540
|
+
</p>
|
|
541
|
+
)}
|
|
542
|
+
{embeddingSettings?.embeddingConfigSource === 'tenant' && (
|
|
543
|
+
<p className="text-xs text-muted-foreground mb-3">
|
|
544
|
+
{t('search.settings.vector.usingTenantConfig', 'This tenant is using its own embedding settings.')}
|
|
545
|
+
</p>
|
|
546
|
+
)}
|
|
535
547
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 items-start">
|
|
536
548
|
{providerOptions.map((providerId) => {
|
|
537
549
|
const info = EMBEDDING_PROVIDERS[providerId]
|
|
538
|
-
const
|
|
550
|
+
const availability = embeddingSettings?.providerAvailability?.find((entry) => entry.providerId === providerId)
|
|
551
|
+
const isConfigured = availability
|
|
552
|
+
? availability.available
|
|
553
|
+
: embeddingSettings?.configuredProviders?.includes(providerId)
|
|
539
554
|
const isSelected = displayProvider === providerId
|
|
540
555
|
const isCurrentlySaved = savedProvider === providerId
|
|
541
556
|
return (
|
|
@@ -568,6 +583,10 @@ export function VectorSearchSection({
|
|
|
568
583
|
<p className="text-xs text-muted-foreground mt-1">
|
|
569
584
|
{info.models.length} {t('search.settings.vector.modelsAvailable', 'models available')}
|
|
570
585
|
</p>
|
|
586
|
+
) : availability?.reason ? (
|
|
587
|
+
<p className="text-xs text-status-warning-text mt-1">
|
|
588
|
+
{availability.reason}
|
|
589
|
+
</p>
|
|
571
590
|
) : (
|
|
572
591
|
<p className="text-xs text-muted-foreground mt-1">
|
|
573
592
|
{t('search.settings.vector.setEnvVar', 'Set')} <code className="font-mono text-overline bg-muted px-1 rounded">{info.envKeyRequired}</code>
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
"search.api.errors.missingQuery": "Suchanfrage ist erforderlich.",
|
|
13
13
|
"search.api.errors.noIndexableStrategy": "Keine indizierbare Strategie verfügbar.",
|
|
14
14
|
"search.api.errors.providerNotConfigured": "Anbieter ist nicht konfiguriert. Setzen Sie die erforderliche Umgebungsvariable.",
|
|
15
|
+
"search.api.errors.providerUnavailable": "Der ausgewählte Anbieter ist nicht verfügbar.",
|
|
15
16
|
"search.api.errors.purgeFailed": "Suchindex konnte nicht bereinigt werden.",
|
|
16
17
|
"search.api.errors.recreateFailed": "Fehler beim Neuerstellen der Vektortabelle mit neuer Dimension.",
|
|
17
18
|
"search.api.errors.reindexFailed": "Neuindizierung fehlgeschlagen.",
|
|
@@ -156,6 +157,7 @@
|
|
|
156
157
|
"search.settings.vector.comingSoon": "Demnächst verfügbar",
|
|
157
158
|
"search.settings.vector.howTo": "Konfigurationsanleitung",
|
|
158
159
|
"search.settings.vector.howToDescription": "Embedding-Anbieter für die Vektorsuche konfigurieren.",
|
|
160
|
+
"search.settings.vector.inheritingDefault": "Dieser Mandant erbt die Instanz-/Umgebungsvorgabe. Das Speichern eines Anbieters erstellt eine Überschreibung für diesen Mandanten.",
|
|
159
161
|
"search.settings.vector.modelsAvailable": "Verfügbare Modelle",
|
|
160
162
|
"search.settings.vector.providers": "Anbieter",
|
|
161
163
|
"search.settings.vector.providersHint": "Embedding-Anbieter über Umgebungsvariablen konfigurieren.",
|
|
@@ -163,6 +165,7 @@
|
|
|
163
165
|
"search.settings.vector.sectionTitle": "Vektorsuche",
|
|
164
166
|
"search.settings.vector.setEnvVar": "Umgebungsvariable setzen",
|
|
165
167
|
"search.settings.vector.store": "Vektorspeicher",
|
|
168
|
+
"search.settings.vector.usingTenantConfig": "Dieser Mandant verwendet eigene Embedding-Einstellungen.",
|
|
166
169
|
"search.settings.vectorDocumentsLabel": "Vektordokumente",
|
|
167
170
|
"search.settings.vectorNotConfigured": "Vektorsuche ist nicht konfiguriert.",
|
|
168
171
|
"search.settings.vectorNotConfiguredHint": "Embedding-Anbieter-API-Schlüssel für die Vektorsuche setzen.",
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
"search.api.errors.missingQuery": "Search query is required.",
|
|
13
13
|
"search.api.errors.noIndexableStrategy": "No indexable strategy is available.",
|
|
14
14
|
"search.api.errors.providerNotConfigured": "Provider is not configured. Set the required environment variable.",
|
|
15
|
+
"search.api.errors.providerUnavailable": "The selected provider is not available.",
|
|
15
16
|
"search.api.errors.purgeFailed": "Failed to purge search index.",
|
|
16
17
|
"search.api.errors.recreateFailed": "Failed to recreate vector table with new dimension.",
|
|
17
18
|
"search.api.errors.reindexFailed": "Reindex operation failed.",
|
|
@@ -156,6 +157,7 @@
|
|
|
156
157
|
"search.settings.vector.comingSoon": "Coming soon",
|
|
157
158
|
"search.settings.vector.howTo": "How to configure",
|
|
158
159
|
"search.settings.vector.howToDescription": "Set up an embedding provider to enable vector search.",
|
|
160
|
+
"search.settings.vector.inheritingDefault": "This tenant is inheriting the instance/environment default. Saving a provider creates an override for this tenant.",
|
|
159
161
|
"search.settings.vector.modelsAvailable": "Models available",
|
|
160
162
|
"search.settings.vector.providers": "Providers",
|
|
161
163
|
"search.settings.vector.providersHint": "Configure an embedding provider via environment variables.",
|
|
@@ -163,6 +165,7 @@
|
|
|
163
165
|
"search.settings.vector.sectionTitle": "Vector Search",
|
|
164
166
|
"search.settings.vector.setEnvVar": "Set environment variable",
|
|
165
167
|
"search.settings.vector.store": "Vector store",
|
|
168
|
+
"search.settings.vector.usingTenantConfig": "This tenant is using its own embedding settings.",
|
|
166
169
|
"search.settings.vectorDocumentsLabel": "Vector documents",
|
|
167
170
|
"search.settings.vectorNotConfigured": "Vector search is not configured.",
|
|
168
171
|
"search.settings.vectorNotConfiguredHint": "Set an embedding provider API key to enable vector search.",
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
"search.api.errors.missingQuery": "Se requiere una consulta de búsqueda.",
|
|
13
13
|
"search.api.errors.noIndexableStrategy": "No hay estrategia indexable disponible.",
|
|
14
14
|
"search.api.errors.providerNotConfigured": "El proveedor no está configurado. Configure la variable de entorno requerida.",
|
|
15
|
+
"search.api.errors.providerUnavailable": "El proveedor seleccionado no está disponible.",
|
|
15
16
|
"search.api.errors.purgeFailed": "Error al purgar el índice de búsqueda.",
|
|
16
17
|
"search.api.errors.recreateFailed": "No se pudo recrear la tabla vectorial con la nueva dimensión.",
|
|
17
18
|
"search.api.errors.reindexFailed": "La operación de reindexación falló.",
|
|
@@ -156,6 +157,7 @@
|
|
|
156
157
|
"search.settings.vector.comingSoon": "Próximamente",
|
|
157
158
|
"search.settings.vector.howTo": "Cómo configurar",
|
|
158
159
|
"search.settings.vector.howToDescription": "Configure un proveedor de embeddings para habilitar la búsqueda vectorial.",
|
|
160
|
+
"search.settings.vector.inheritingDefault": "Este inquilino hereda el valor predeterminado de la instancia/entorno. Guardar un proveedor crea una anulación para este inquilino.",
|
|
159
161
|
"search.settings.vector.modelsAvailable": "Modelos disponibles",
|
|
160
162
|
"search.settings.vector.providers": "Proveedores",
|
|
161
163
|
"search.settings.vector.providersHint": "Configure un proveedor de embeddings mediante variables de entorno.",
|
|
@@ -163,6 +165,7 @@
|
|
|
163
165
|
"search.settings.vector.sectionTitle": "Búsqueda vectorial",
|
|
164
166
|
"search.settings.vector.setEnvVar": "Configurar variable de entorno",
|
|
165
167
|
"search.settings.vector.store": "Almacén vectorial",
|
|
168
|
+
"search.settings.vector.usingTenantConfig": "Este inquilino usa su propia configuración de incrustaciones.",
|
|
166
169
|
"search.settings.vectorDocumentsLabel": "Documentos vectoriales",
|
|
167
170
|
"search.settings.vectorNotConfigured": "La búsqueda vectorial no está configurada.",
|
|
168
171
|
"search.settings.vectorNotConfiguredHint": "Configure una clave API del proveedor de embeddings para la búsqueda vectorial.",
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
"search.api.errors.missingQuery": "Zapytanie wyszukiwania jest wymagane.",
|
|
13
13
|
"search.api.errors.noIndexableStrategy": "Brak dostępnej strategii indeksowania.",
|
|
14
14
|
"search.api.errors.providerNotConfigured": "Dostawca nie jest skonfigurowany. Ustaw wymaganą zmienną środowiskową.",
|
|
15
|
+
"search.api.errors.providerUnavailable": "Wybrany dostawca jest niedostępny.",
|
|
15
16
|
"search.api.errors.purgeFailed": "Nie udało się wyczyścić indeksu wyszukiwania.",
|
|
16
17
|
"search.api.errors.recreateFailed": "Nie udało się odtworzyć tabeli wektorowej z nowym wymiarem.",
|
|
17
18
|
"search.api.errors.reindexFailed": "Operacja reindeksacji nie powiodła się.",
|
|
@@ -156,6 +157,7 @@
|
|
|
156
157
|
"search.settings.vector.comingSoon": "Wkrótce",
|
|
157
158
|
"search.settings.vector.howTo": "Jak skonfigurować",
|
|
158
159
|
"search.settings.vector.howToDescription": "Skonfiguruj dostawcę embeddingów, aby włączyć wyszukiwanie wektorowe.",
|
|
160
|
+
"search.settings.vector.inheritingDefault": "Ten najemca dziedziczy domyślne ustawienia instancji/środowiska. Zapisanie dostawcy tworzy nadpisanie dla tego najemcy.",
|
|
159
161
|
"search.settings.vector.modelsAvailable": "Dostępne modele",
|
|
160
162
|
"search.settings.vector.providers": "Dostawcy",
|
|
161
163
|
"search.settings.vector.providersHint": "Skonfiguruj dostawcę embeddingów za pomocą zmiennych środowiskowych.",
|
|
@@ -163,6 +165,7 @@
|
|
|
163
165
|
"search.settings.vector.sectionTitle": "Wyszukiwanie wektorowe",
|
|
164
166
|
"search.settings.vector.setEnvVar": "Ustaw zmienną środowiskową",
|
|
165
167
|
"search.settings.vector.store": "Magazyn wektorów",
|
|
168
|
+
"search.settings.vector.usingTenantConfig": "Ten najemca używa własnych ustawień osadzania.",
|
|
166
169
|
"search.settings.vectorDocumentsLabel": "Dokumenty wektorowe",
|
|
167
170
|
"search.settings.vectorNotConfigured": "Wyszukiwanie wektorowe nie jest skonfigurowane.",
|
|
168
171
|
"search.settings.vectorNotConfiguredHint": "Ustaw klucz API dostawcy embeddingów, aby włączyć wyszukiwanie wektorowe.",
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import type { AppContainer } from '@open-mercato/shared/lib/di/container'
|
|
2
|
+
import {
|
|
3
|
+
createEmbeddingProviderProbe,
|
|
4
|
+
probeOllama,
|
|
5
|
+
checkAllProviders,
|
|
6
|
+
} from '../provider-probe'
|
|
7
|
+
|
|
8
|
+
function createContainerWithCache() {
|
|
9
|
+
const store = new Map<string, unknown>()
|
|
10
|
+
let sets = 0
|
|
11
|
+
const cache = {
|
|
12
|
+
get: async (key: string) => store.get(key) ?? null,
|
|
13
|
+
set: async (key: string, value: unknown) => {
|
|
14
|
+
sets += 1
|
|
15
|
+
store.set(key, value)
|
|
16
|
+
},
|
|
17
|
+
delete: async () => {},
|
|
18
|
+
deleteByTags: async () => {},
|
|
19
|
+
}
|
|
20
|
+
const container = {
|
|
21
|
+
resolve: (token: string) => {
|
|
22
|
+
if (token === 'cache') return cache
|
|
23
|
+
throw new Error(`unknown token ${token}`)
|
|
24
|
+
},
|
|
25
|
+
} as unknown as AppContainer
|
|
26
|
+
return { container, sets: () => sets }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function mockFetch(impl: (input: string) => Promise<Response>) {
|
|
30
|
+
return jest.spyOn(globalThis, 'fetch' as never).mockImplementation(impl as never)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const okTags = (models: number) =>
|
|
34
|
+
({
|
|
35
|
+
ok: true,
|
|
36
|
+
status: 200,
|
|
37
|
+
json: async () => ({ models: Array.from({ length: models }, (_, index) => ({ name: `m${index}` })) }),
|
|
38
|
+
}) as unknown as Response
|
|
39
|
+
|
|
40
|
+
describe('probeOllama', () => {
|
|
41
|
+
afterEach(() => jest.restoreAllMocks())
|
|
42
|
+
|
|
43
|
+
it('reports available with model count on a successful /api/tags', async () => {
|
|
44
|
+
const spy = mockFetch(async () => okTags(3))
|
|
45
|
+
const result = await probeOllama('http://localhost:11434')
|
|
46
|
+
expect(result.available).toBe(true)
|
|
47
|
+
expect(result.models).toBe(3)
|
|
48
|
+
expect(spy).toHaveBeenCalledWith('http://localhost:11434/api/tags', expect.objectContaining({ method: 'GET' }))
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('reports unavailable on a non-ok response', async () => {
|
|
52
|
+
mockFetch(async () => ({ ok: false, status: 500 }) as unknown as Response)
|
|
53
|
+
const result = await probeOllama('http://localhost:11434')
|
|
54
|
+
expect(result.available).toBe(false)
|
|
55
|
+
expect(result.reason).toContain('500')
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('reports unavailable (timed out) when the request aborts', async () => {
|
|
59
|
+
mockFetch(async () => {
|
|
60
|
+
throw Object.assign(new Error('aborted'), { name: 'AbortError' })
|
|
61
|
+
})
|
|
62
|
+
const result = await probeOllama('http://localhost:11434')
|
|
63
|
+
expect(result.available).toBe(false)
|
|
64
|
+
expect(result.reason).toContain('timed out')
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('reports unavailable when the host is unreachable', async () => {
|
|
68
|
+
mockFetch(async () => {
|
|
69
|
+
throw new Error('ECONNREFUSED')
|
|
70
|
+
})
|
|
71
|
+
const result = await probeOllama('http://localhost:11434')
|
|
72
|
+
expect(result.available).toBe(false)
|
|
73
|
+
expect(result.reason).toContain('not reachable')
|
|
74
|
+
})
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
describe('createEmbeddingProviderProbe', () => {
|
|
78
|
+
afterEach(() => jest.restoreAllMocks())
|
|
79
|
+
|
|
80
|
+
it('caches results across calls and only probes once', async () => {
|
|
81
|
+
const spy = mockFetch(async () => okTags(1))
|
|
82
|
+
const { container, sets } = createContainerWithCache()
|
|
83
|
+
const probe = createEmbeddingProviderProbe(container)
|
|
84
|
+
|
|
85
|
+
const first = await probe.checkAvailability('ollama')
|
|
86
|
+
const second = await probe.checkAvailability('ollama')
|
|
87
|
+
|
|
88
|
+
expect(first.available).toBe(true)
|
|
89
|
+
expect(second.available).toBe(true)
|
|
90
|
+
expect(spy).toHaveBeenCalledTimes(1)
|
|
91
|
+
expect(sets()).toBe(1)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('re-probes when force is set', async () => {
|
|
95
|
+
const spy = mockFetch(async () => okTags(1))
|
|
96
|
+
const { container } = createContainerWithCache()
|
|
97
|
+
const probe = createEmbeddingProviderProbe(container)
|
|
98
|
+
|
|
99
|
+
await probe.checkAvailability('ollama')
|
|
100
|
+
await probe.checkAvailability('ollama', { force: true })
|
|
101
|
+
|
|
102
|
+
expect(spy).toHaveBeenCalledTimes(2)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('gates key-based providers on env-key presence', async () => {
|
|
106
|
+
const { container } = createContainerWithCache()
|
|
107
|
+
const probe = createEmbeddingProviderProbe(container)
|
|
108
|
+
const previous = process.env.OPENAI_API_KEY
|
|
109
|
+
|
|
110
|
+
try {
|
|
111
|
+
delete process.env.OPENAI_API_KEY
|
|
112
|
+
const missing = await probe.checkAvailability('openai')
|
|
113
|
+
expect(missing.available).toBe(false)
|
|
114
|
+
expect(missing.reason).toContain('OPENAI_API_KEY')
|
|
115
|
+
|
|
116
|
+
process.env.OPENAI_API_KEY = 'present'
|
|
117
|
+
const present = await probe.checkAvailability('openai', { force: true })
|
|
118
|
+
expect(present.available).toBe(true)
|
|
119
|
+
} finally {
|
|
120
|
+
if (previous === undefined) delete process.env.OPENAI_API_KEY
|
|
121
|
+
else process.env.OPENAI_API_KEY = previous
|
|
122
|
+
}
|
|
123
|
+
})
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
describe('checkAllProviders', () => {
|
|
127
|
+
it('returns an entry for every provider with its id', async () => {
|
|
128
|
+
const probe = {
|
|
129
|
+
checkAvailability: async () => ({ available: false, reason: 'stub' }),
|
|
130
|
+
}
|
|
131
|
+
const entries = await checkAllProviders(probe)
|
|
132
|
+
expect(entries.map((entry) => entry.providerId).sort()).toEqual(
|
|
133
|
+
['bedrock', 'cohere', 'google', 'mistral', 'ollama', 'openai'],
|
|
134
|
+
)
|
|
135
|
+
expect(entries.every((entry) => entry.available === false)).toBe(true)
|
|
136
|
+
})
|
|
137
|
+
})
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { createModuleConfigService } from '@open-mercato/core/modules/configs/lib/module-config-service'
|
|
2
|
+
import type { AppContainer } from '@open-mercato/shared/lib/di/container'
|
|
3
|
+
import {
|
|
4
|
+
resolveEmbeddingConfigResult,
|
|
5
|
+
saveEmbeddingConfig,
|
|
6
|
+
} from '../embedding-config'
|
|
7
|
+
import {
|
|
8
|
+
resolveGlobalSearchStrategiesResult,
|
|
9
|
+
saveGlobalSearchStrategies,
|
|
10
|
+
} from '../global-search-config'
|
|
11
|
+
import type { EmbeddingProviderConfig } from '../../../../vector'
|
|
12
|
+
|
|
13
|
+
type Row = {
|
|
14
|
+
id: string
|
|
15
|
+
moduleId: string
|
|
16
|
+
name: string
|
|
17
|
+
valueJson: unknown
|
|
18
|
+
tenantId: string | null
|
|
19
|
+
organizationId: string | null
|
|
20
|
+
createdAt: Date
|
|
21
|
+
updatedAt: Date
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function matches(row: Row, where: Record<string, unknown>): boolean {
|
|
25
|
+
return Object.entries(where).every(([key, value]) => {
|
|
26
|
+
const current = (row as Record<string, unknown>)[key]
|
|
27
|
+
return value === null ? current === null || current === undefined : current === value
|
|
28
|
+
})
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function createServiceResolver() {
|
|
32
|
+
const rows: Row[] = []
|
|
33
|
+
let seq = 0
|
|
34
|
+
const store = new Map<string, unknown>()
|
|
35
|
+
const repo = {
|
|
36
|
+
async findOne(where: Record<string, unknown>): Promise<Row | null> {
|
|
37
|
+
return rows.find((row) => matches(row, where)) ?? null
|
|
38
|
+
},
|
|
39
|
+
create(data: Partial<Row>): Row {
|
|
40
|
+
const now = new Date()
|
|
41
|
+
return {
|
|
42
|
+
id: `row-${seq++}`,
|
|
43
|
+
moduleId: data.moduleId!,
|
|
44
|
+
name: data.name!,
|
|
45
|
+
valueJson: data.valueJson ?? null,
|
|
46
|
+
tenantId: data.tenantId ?? null,
|
|
47
|
+
organizationId: data.organizationId ?? null,
|
|
48
|
+
createdAt: data.createdAt ?? now,
|
|
49
|
+
updatedAt: data.updatedAt ?? now,
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
}
|
|
53
|
+
const em = {
|
|
54
|
+
getRepository: () => repo,
|
|
55
|
+
persist: (entity: Row) => rows.push(entity),
|
|
56
|
+
flush: async () => {},
|
|
57
|
+
}
|
|
58
|
+
const cache = {
|
|
59
|
+
get: async (key: string) => store.get(key) ?? null,
|
|
60
|
+
set: async (key: string, value: unknown) => {
|
|
61
|
+
store.set(key, value)
|
|
62
|
+
},
|
|
63
|
+
delete: async (key: string) => {
|
|
64
|
+
store.delete(key)
|
|
65
|
+
},
|
|
66
|
+
deleteByTags: async () => {
|
|
67
|
+
store.clear()
|
|
68
|
+
},
|
|
69
|
+
}
|
|
70
|
+
const container = {
|
|
71
|
+
resolve: (token: string) => {
|
|
72
|
+
if (token === 'em') return em
|
|
73
|
+
if (token === 'cache') return cache
|
|
74
|
+
throw new Error(`unknown token ${token}`)
|
|
75
|
+
},
|
|
76
|
+
} as unknown as AppContainer
|
|
77
|
+
const service = createModuleConfigService(container)
|
|
78
|
+
const resolver = {
|
|
79
|
+
resolve: <T = unknown>(name: string): T => {
|
|
80
|
+
if (name === 'moduleConfigService') return service as unknown as T
|
|
81
|
+
throw new Error(`unknown token ${name}`)
|
|
82
|
+
},
|
|
83
|
+
}
|
|
84
|
+
return { resolver, rows }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const TENANT_A = '11111111-1111-1111-1111-111111111111'
|
|
88
|
+
const TENANT_B = '22222222-2222-2222-2222-222222222222'
|
|
89
|
+
|
|
90
|
+
const TENANT_A_CONFIG: EmbeddingProviderConfig = {
|
|
91
|
+
providerId: 'openai',
|
|
92
|
+
model: 'text-embedding-3-large',
|
|
93
|
+
dimension: 3072,
|
|
94
|
+
updatedAt: new Date().toISOString(),
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
describe('search settings tenant scoping (helper-level integration)', () => {
|
|
98
|
+
const previousKey = process.env.OPENAI_API_KEY
|
|
99
|
+
beforeAll(() => {
|
|
100
|
+
// Make the env-derived default deterministic across phases (key-presence gate).
|
|
101
|
+
process.env.OPENAI_API_KEY = 'test-key'
|
|
102
|
+
})
|
|
103
|
+
afterAll(() => {
|
|
104
|
+
if (previousKey === undefined) delete process.env.OPENAI_API_KEY
|
|
105
|
+
else process.env.OPENAI_API_KEY = previousKey
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('embedding config: Tenant A save does not change Tenant B (B inherits env default)', async () => {
|
|
109
|
+
const { resolver } = createServiceResolver()
|
|
110
|
+
|
|
111
|
+
await saveEmbeddingConfig(resolver, TENANT_A_CONFIG, { scope: { tenantId: TENANT_A } })
|
|
112
|
+
|
|
113
|
+
const a = await resolveEmbeddingConfigResult(resolver, { scope: { tenantId: TENANT_A } })
|
|
114
|
+
expect(a.source).toBe('tenant')
|
|
115
|
+
expect(a.config?.model).toBe('text-embedding-3-large')
|
|
116
|
+
|
|
117
|
+
const b = await resolveEmbeddingConfigResult(resolver, { scope: { tenantId: TENANT_B } })
|
|
118
|
+
expect(b.source).toBe('env')
|
|
119
|
+
expect(b.config?.providerId).toBe('openai')
|
|
120
|
+
expect(b.config?.model).toBe('text-embedding-3-small')
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('embedding config: a tenant inherits the instance row until it overrides', async () => {
|
|
124
|
+
const { resolver } = createServiceResolver()
|
|
125
|
+
|
|
126
|
+
// Instance default (no scope -> global row)
|
|
127
|
+
await saveEmbeddingConfig(resolver, { ...TENANT_A_CONFIG, model: 'instance-model' })
|
|
128
|
+
|
|
129
|
+
const inherited = await resolveEmbeddingConfigResult(resolver, { scope: { tenantId: TENANT_A } })
|
|
130
|
+
expect(inherited.source).toBe('instance')
|
|
131
|
+
expect(inherited.config?.model).toBe('instance-model')
|
|
132
|
+
|
|
133
|
+
await saveEmbeddingConfig(resolver, TENANT_A_CONFIG, { scope: { tenantId: TENANT_A } })
|
|
134
|
+
const overridden = await resolveEmbeddingConfigResult(resolver, { scope: { tenantId: TENANT_A } })
|
|
135
|
+
expect(overridden.source).toBe('tenant')
|
|
136
|
+
expect(overridden.config?.model).toBe('text-embedding-3-large')
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('global-search strategies: Tenant A save does not change Tenant B', async () => {
|
|
140
|
+
const { resolver } = createServiceResolver()
|
|
141
|
+
|
|
142
|
+
await saveGlobalSearchStrategies(resolver, ['fulltext'], { scope: { tenantId: TENANT_A } })
|
|
143
|
+
|
|
144
|
+
const a = await resolveGlobalSearchStrategiesResult(resolver, { scope: { tenantId: TENANT_A } })
|
|
145
|
+
expect(a.source).toBe('tenant')
|
|
146
|
+
expect(a.strategies).toEqual(['fulltext'])
|
|
147
|
+
|
|
148
|
+
const b = await resolveGlobalSearchStrategiesResult(resolver, { scope: { tenantId: TENANT_B } })
|
|
149
|
+
expect(b.source).toBe('env')
|
|
150
|
+
expect(b.strategies).toEqual(['fulltext', 'vector', 'tokens'])
|
|
151
|
+
})
|
|
152
|
+
})
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ModuleConfigService } from '@open-mercato/core/modules/configs/lib/module-config-service'
|
|
1
|
+
import type { ConfigScope, ModuleConfigService } from '@open-mercato/core/modules/configs/lib/module-config-service'
|
|
2
2
|
import { parseBooleanToken } from '@open-mercato/shared/lib/boolean'
|
|
3
3
|
|
|
4
4
|
export const SEARCH_AUTO_INDEX_CONFIG_KEY = 'auto_index_enabled'
|
|
@@ -17,7 +17,7 @@ type Resolver = {
|
|
|
17
17
|
|
|
18
18
|
export async function resolveAutoIndexingEnabled(
|
|
19
19
|
resolver: Resolver,
|
|
20
|
-
options?: { defaultValue?: boolean },
|
|
20
|
+
options?: { defaultValue?: boolean; scope?: ConfigScope },
|
|
21
21
|
): Promise<boolean> {
|
|
22
22
|
if (envDisablesAutoIndexing()) return false
|
|
23
23
|
const fallback = options?.defaultValue ?? true
|
|
@@ -29,7 +29,10 @@ export async function resolveAutoIndexingEnabled(
|
|
|
29
29
|
}
|
|
30
30
|
try {
|
|
31
31
|
// Still use 'vector' module key for backwards compatibility
|
|
32
|
-
const value = await service.getValue<boolean>('vector', SEARCH_AUTO_INDEX_CONFIG_KEY, {
|
|
32
|
+
const value = await service.getValue<boolean>('vector', SEARCH_AUTO_INDEX_CONFIG_KEY, {
|
|
33
|
+
defaultValue: fallback,
|
|
34
|
+
scope: options?.scope,
|
|
35
|
+
})
|
|
33
36
|
return value !== false
|
|
34
37
|
} catch {
|
|
35
38
|
return fallback
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ModuleConfigService } from '@open-mercato/core/modules/configs/lib/module-config-service'
|
|
1
|
+
import type { ConfigScope, ModuleConfigService } from '@open-mercato/core/modules/configs/lib/module-config-service'
|
|
2
2
|
import type { EmbeddingProviderConfig, EmbeddingProviderId } from '../../../vector'
|
|
3
3
|
import { EMBEDDING_CONFIG_KEY, EMBEDDING_PROVIDERS, DEFAULT_EMBEDDING_CONFIG } from '../../../vector'
|
|
4
4
|
|
|
@@ -123,7 +123,7 @@ type Resolver = {
|
|
|
123
123
|
|
|
124
124
|
export async function resolveEmbeddingConfig(
|
|
125
125
|
resolver: Resolver,
|
|
126
|
-
options?: { defaultValue?: EmbeddingProviderConfig | null }
|
|
126
|
+
options?: { defaultValue?: EmbeddingProviderConfig | null; scope?: ConfigScope }
|
|
127
127
|
): Promise<EmbeddingProviderConfig | null> {
|
|
128
128
|
const fallback = options?.defaultValue ?? null
|
|
129
129
|
let service: ModuleConfigService
|
|
@@ -133,7 +133,10 @@ export async function resolveEmbeddingConfig(
|
|
|
133
133
|
return fallback
|
|
134
134
|
}
|
|
135
135
|
try {
|
|
136
|
-
const value = await service.getValue<EmbeddingProviderConfig>('vector', EMBEDDING_CONFIG_KEY, {
|
|
136
|
+
const value = await service.getValue<EmbeddingProviderConfig>('vector', EMBEDDING_CONFIG_KEY, {
|
|
137
|
+
defaultValue: fallback,
|
|
138
|
+
scope: options?.scope,
|
|
139
|
+
})
|
|
137
140
|
return value
|
|
138
141
|
} catch {
|
|
139
142
|
return fallback
|
|
@@ -142,7 +145,8 @@ export async function resolveEmbeddingConfig(
|
|
|
142
145
|
|
|
143
146
|
export async function saveEmbeddingConfig(
|
|
144
147
|
resolver: Resolver,
|
|
145
|
-
config: EmbeddingProviderConfig
|
|
148
|
+
config: EmbeddingProviderConfig,
|
|
149
|
+
options?: { scope?: ConfigScope }
|
|
146
150
|
): Promise<void> {
|
|
147
151
|
let service: ModuleConfigService
|
|
148
152
|
try {
|
|
@@ -153,9 +157,58 @@ export async function saveEmbeddingConfig(
|
|
|
153
157
|
await service.setValue('vector', EMBEDDING_CONFIG_KEY, {
|
|
154
158
|
...config,
|
|
155
159
|
updatedAt: new Date().toISOString(),
|
|
156
|
-
})
|
|
160
|
+
}, options?.scope)
|
|
157
161
|
}
|
|
158
162
|
|
|
159
163
|
export function createDefaultConfig(): EmbeddingProviderConfig {
|
|
160
164
|
return { ...DEFAULT_EMBEDDING_CONFIG, updatedAt: new Date().toISOString() }
|
|
161
165
|
}
|
|
166
|
+
|
|
167
|
+
export type EmbeddingConfigSource = 'tenant' | 'instance' | 'env'
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Compute the env-derived default embedding config: the platform default narrowed
|
|
171
|
+
* to the first env-configured provider. Returns null when no provider is configured.
|
|
172
|
+
*/
|
|
173
|
+
export function getEnvDerivedEmbeddingConfig(): EmbeddingProviderConfig | null {
|
|
174
|
+
const configured = getConfiguredProviders()
|
|
175
|
+
if (configured.length === 0) return null
|
|
176
|
+
const providerId = configured.includes(DEFAULT_EMBEDDING_CONFIG.providerId)
|
|
177
|
+
? DEFAULT_EMBEDDING_CONFIG.providerId
|
|
178
|
+
: configured[0]
|
|
179
|
+
if (providerId === DEFAULT_EMBEDDING_CONFIG.providerId) {
|
|
180
|
+
return { ...DEFAULT_EMBEDDING_CONFIG, updatedAt: new Date().toISOString() }
|
|
181
|
+
}
|
|
182
|
+
const info = EMBEDDING_PROVIDERS[providerId]
|
|
183
|
+
const model = info.models.find((entry) => entry.id === info.defaultModel) ?? info.models[0]
|
|
184
|
+
return {
|
|
185
|
+
providerId,
|
|
186
|
+
model: info.defaultModel,
|
|
187
|
+
dimension: model?.dimension ?? DEFAULT_EMBEDDING_CONFIG.dimension,
|
|
188
|
+
updatedAt: new Date().toISOString(),
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Resolve the embedding config together with its source discriminator:
|
|
194
|
+
* `tenant` (own scoped row), `instance` (inherited global row), or `env`
|
|
195
|
+
* (no stored row -> env-derived default).
|
|
196
|
+
*/
|
|
197
|
+
export async function resolveEmbeddingConfigResult(
|
|
198
|
+
resolver: Resolver,
|
|
199
|
+
options?: { scope?: ConfigScope },
|
|
200
|
+
): Promise<{ config: EmbeddingProviderConfig | null; source: EmbeddingConfigSource }> {
|
|
201
|
+
let service: ModuleConfigService
|
|
202
|
+
try {
|
|
203
|
+
service = resolver.resolve<ModuleConfigService>('moduleConfigService')
|
|
204
|
+
} catch {
|
|
205
|
+
return { config: getEnvDerivedEmbeddingConfig(), source: 'env' }
|
|
206
|
+
}
|
|
207
|
+
try {
|
|
208
|
+
const record = await service.getRecord('vector', EMBEDDING_CONFIG_KEY, options?.scope)
|
|
209
|
+
if (record && record.value) {
|
|
210
|
+
return { config: record.value as EmbeddingProviderConfig, source: record.source }
|
|
211
|
+
}
|
|
212
|
+
} catch {}
|
|
213
|
+
return { config: getEnvDerivedEmbeddingConfig(), source: 'env' }
|
|
214
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ModuleConfigService } from '@open-mercato/core/modules/configs/lib/module-config-service'
|
|
1
|
+
import type { ConfigScope, ModuleConfigService } from '@open-mercato/core/modules/configs/lib/module-config-service'
|
|
2
2
|
import type { SearchStrategyId } from '@open-mercato/shared/modules/search'
|
|
3
3
|
|
|
4
4
|
export const GLOBAL_SEARCH_STRATEGIES_KEY = 'global_search_strategies'
|
|
@@ -16,7 +16,7 @@ type Resolver = {
|
|
|
16
16
|
*/
|
|
17
17
|
export async function resolveGlobalSearchStrategies(
|
|
18
18
|
resolver: Resolver,
|
|
19
|
-
options?: { defaultValue?: SearchStrategyId[] },
|
|
19
|
+
options?: { defaultValue?: SearchStrategyId[]; scope?: ConfigScope },
|
|
20
20
|
): Promise<SearchStrategyId[]> {
|
|
21
21
|
const fallback = options?.defaultValue ?? DEFAULT_GLOBAL_SEARCH_STRATEGIES
|
|
22
22
|
let service: ModuleConfigService
|
|
@@ -26,7 +26,10 @@ export async function resolveGlobalSearchStrategies(
|
|
|
26
26
|
return fallback
|
|
27
27
|
}
|
|
28
28
|
try {
|
|
29
|
-
const value = await service.getValue<SearchStrategyId[]>('search', GLOBAL_SEARCH_STRATEGIES_KEY, {
|
|
29
|
+
const value = await service.getValue<SearchStrategyId[]>('search', GLOBAL_SEARCH_STRATEGIES_KEY, {
|
|
30
|
+
defaultValue: fallback,
|
|
31
|
+
scope: options?.scope,
|
|
32
|
+
})
|
|
30
33
|
// Ensure we always return a non-empty array
|
|
31
34
|
if (!Array.isArray(value) || value.length === 0) {
|
|
32
35
|
return fallback
|
|
@@ -43,6 +46,7 @@ export async function resolveGlobalSearchStrategies(
|
|
|
43
46
|
export async function saveGlobalSearchStrategies(
|
|
44
47
|
resolver: Resolver,
|
|
45
48
|
strategies: SearchStrategyId[],
|
|
49
|
+
options?: { scope?: ConfigScope },
|
|
46
50
|
): Promise<void> {
|
|
47
51
|
let service: ModuleConfigService
|
|
48
52
|
try {
|
|
@@ -65,5 +69,33 @@ export async function saveGlobalSearchStrategies(
|
|
|
65
69
|
throw new Error('At least one valid search strategy must be enabled')
|
|
66
70
|
}
|
|
67
71
|
|
|
68
|
-
await service.setValue('search', GLOBAL_SEARCH_STRATEGIES_KEY, validStrategies)
|
|
72
|
+
await service.setValue('search', GLOBAL_SEARCH_STRATEGIES_KEY, validStrategies, options?.scope)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export type GlobalSearchSource = 'tenant' | 'instance' | 'env'
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Resolve the enabled strategies together with their source discriminator:
|
|
79
|
+
* `tenant` (own scoped row), `instance` (inherited global row), or `env`
|
|
80
|
+
* (no stored row -> default strategies).
|
|
81
|
+
*/
|
|
82
|
+
export async function resolveGlobalSearchStrategiesResult(
|
|
83
|
+
resolver: Resolver,
|
|
84
|
+
options?: { defaultValue?: SearchStrategyId[]; scope?: ConfigScope },
|
|
85
|
+
): Promise<{ strategies: SearchStrategyId[]; source: GlobalSearchSource }> {
|
|
86
|
+
const fallback = options?.defaultValue ?? DEFAULT_GLOBAL_SEARCH_STRATEGIES
|
|
87
|
+
let service: ModuleConfigService
|
|
88
|
+
try {
|
|
89
|
+
service = resolver.resolve<ModuleConfigService>('moduleConfigService')
|
|
90
|
+
} catch {
|
|
91
|
+
return { strategies: fallback, source: 'env' }
|
|
92
|
+
}
|
|
93
|
+
try {
|
|
94
|
+
const record = await service.getRecord('search', GLOBAL_SEARCH_STRATEGIES_KEY, options?.scope)
|
|
95
|
+
const value = record?.value
|
|
96
|
+
if (Array.isArray(value) && value.length > 0) {
|
|
97
|
+
return { strategies: value as SearchStrategyId[], source: record!.source }
|
|
98
|
+
}
|
|
99
|
+
} catch {}
|
|
100
|
+
return { strategies: fallback, source: 'env' }
|
|
69
101
|
}
|