openprompt-lang 1.2.7 → 1.3.0

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.
@@ -0,0 +1,431 @@
1
+ // @use(kind, contract, limit, deps)
2
+ // @kind(module)
3
+ // @contract(in: string -> out: number[], async: true, sideEffect: red local si provider=ollama)
4
+ // @limit(lines: 425)
5
+ // @deps(ollama | @xenova/transformers, opcional)
6
+
7
+ /**
8
+ * Servicio de embeddings vectoriales con soporte multi-proveedor.
9
+ *
10
+ * Proveedores:
11
+ * - 'ollama': Usa el API HTTP de Ollama (nomic-embed-text, 768d)
12
+ * - 'transformers': Usa Transformers.js en Node.js (all-MiniLM-L6-v2, 384d)
13
+ *
14
+ * Auto-fallback: Si Ollama no está disponible, cae a Transformers.js.
15
+ * Si ningún proveedor funciona, lanza error con mensaje claro.
16
+ */
17
+
18
+ import { readFileSync, existsSync } from "fs"
19
+ import { join } from "path"
20
+
21
+ // ─── Constantes ────────────────────────────────────────────────────
22
+
23
+ const OLLAMA_BASE_URL = "http://localhost:11434"
24
+ const OLLAMA_DEFAULT_MODEL = "nomic-embed-text"
25
+ const TRANSFORMERS_DEFAULT_MODEL = "Xenova/all-MiniLM-L6-v2"
26
+ const MAX_CHARS = 8000
27
+ const EMBEDDING_TIMEOUT = 30000
28
+
29
+ // ─── Estado interno ────────────────────────────────────────────────
30
+
31
+ let _activeProvider = null // 'ollama' | 'transformers' | null (auto)
32
+ let _ollamaModel = OLLAMA_DEFAULT_MODEL
33
+ let _transformersModel = TRANSFORMERS_DEFAULT_MODEL
34
+
35
+ // Cache de disponibilidad para evitar chequeos repetidos
36
+ let _ollamaAvailable = null // null = no chequeado, boolean
37
+ let _transformersPipeline = null // cache de pipeline
38
+
39
+ // Test hook: permite forzar transformes no disponible en tests
40
+ let _forceNoTransformers = false
41
+
42
+ /**
43
+ * Fuerza que transformers aparezca como no disponible (test hook).
44
+ * Solo afecta al entorno de pruebas, no usar en producción.
45
+ *
46
+ * @param {boolean} val
47
+ */
48
+ export function _setForceNoTransformers(val) {
49
+ _forceNoTransformers = val
50
+ }
51
+
52
+ // ─── Configuración ─────────────────────────────────────────────────
53
+
54
+ /**
55
+ * Configura el proveedor activo.
56
+ * @param {'ollama'|'transformers'} provider
57
+ */
58
+ export function setActiveProvider(provider) {
59
+ if (provider !== "ollama" && provider !== "transformers") {
60
+ throw new Error(`Proveedor inválido: "${provider}". Usa 'ollama' o 'transformers'.`)
61
+ }
62
+ _activeProvider = provider
63
+ }
64
+
65
+ /**
66
+ * Obtiene el proveedor activo actual.
67
+ * @returns {'ollama'|'transformers'|null}
68
+ */
69
+ export function getActiveProvider() {
70
+ return _activeProvider
71
+ }
72
+
73
+ /**
74
+ * Resetea el estado del módulo (útil para tests).
75
+ * Vuelve a auto-detección y limpia cachés.
76
+ */
77
+ export function resetEmbedder() {
78
+ _activeProvider = null
79
+ _ollamaAvailable = null
80
+ _transformersPipeline = null
81
+ _forceNoTransformers = false
82
+ }
83
+
84
+ /**
85
+ * Lee configuración desde prompt-lang.json si existe.
86
+ */
87
+ function loadConfig() {
88
+ try {
89
+ const configPath = join(process.cwd(), "prompt-lang.json")
90
+ if (existsSync(configPath)) {
91
+ const config = JSON.parse(readFileSync(configPath, "utf-8"))
92
+ const ec = config.embeddings
93
+ if (ec) {
94
+ if (ec.provider) setActiveProvider(ec.provider)
95
+ if (ec.ollamaModel) _ollamaModel = ec.ollamaModel
96
+ if (ec.transformersModel) _transformersModel = ec.transformersModel
97
+ }
98
+ }
99
+ } catch {
100
+ // Usar defaults
101
+ }
102
+ }
103
+
104
+ // Cargar configuración al importar
105
+ loadConfig()
106
+
107
+ // ─── Proveedores ───────────────────────────────────────────────────
108
+
109
+ /**
110
+ * Genera embedding via Ollama API.
111
+ * POST /api/embeddings con { model, prompt }
112
+ *
113
+ * @param {string} text
114
+ * @param {string} model
115
+ * @returns {Promise<number[]>}
116
+ */
117
+ async function embedOllama(text, model) {
118
+ const url = `${OLLAMA_BASE_URL}/api/embeddings`
119
+ const controller = new AbortController()
120
+ const timeoutId = setTimeout(() => controller.abort(), EMBEDDING_TIMEOUT)
121
+
122
+ try {
123
+ const response = await fetch(url, {
124
+ method: "POST",
125
+ headers: { "Content-Type": "application/json" },
126
+ body: JSON.stringify({
127
+ model: model || _ollamaModel,
128
+ prompt: text,
129
+ }),
130
+ signal: controller.signal,
131
+ })
132
+
133
+ if (!response.ok) {
134
+ const errorText = await response.text().catch(() => "unknown")
135
+ throw new Error(`Ollama API error (${response.status}): ${errorText}`)
136
+ }
137
+
138
+ const data = await response.json()
139
+ return Array.from(data.embedding || [])
140
+ } finally {
141
+ clearTimeout(timeoutId)
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Genera embedding via Transformers.js.
147
+ * Usa lazy-loading del pipeline para no bloquear el import.
148
+ *
149
+ * @param {string} text
150
+ * @param {string} model
151
+ * @returns {Promise<number[]>}
152
+ */
153
+ async function embedTransformers(text, model) {
154
+ if (!_transformersPipeline) {
155
+ const { pipeline } = await import("@xenova/transformers")
156
+ _transformersPipeline = await pipeline("feature-extraction", model || _transformersModel)
157
+ }
158
+
159
+ const result = await _transformersPipeline(text, {
160
+ pooling: "mean",
161
+ normalize: true,
162
+ })
163
+
164
+ return Array.from(result.data)
165
+ }
166
+
167
+ // ─── Detección de disponibilidad ───────────────────────────────────
168
+
169
+ /**
170
+ * Verifica si Ollama está disponible localmente.
171
+ * Cachea el resultado para evitar llamadas repetidas.
172
+ *
173
+ * @returns {Promise<{ available: boolean, error?: string }>}
174
+ */
175
+ async function checkOllama() {
176
+ if (_ollamaAvailable !== null) {
177
+ return { available: _ollamaAvailable }
178
+ }
179
+
180
+ try {
181
+ const controller = new AbortController()
182
+ const timeoutId = setTimeout(() => controller.abort(), 3000)
183
+
184
+ const response = await fetch(`${OLLAMA_BASE_URL}/api/tags`, {
185
+ signal: controller.signal,
186
+ }).finally(() => clearTimeout(timeoutId))
187
+
188
+ if (response.ok) {
189
+ _ollamaAvailable = true
190
+ return { available: true }
191
+ }
192
+ _ollamaAvailable = false
193
+ return { available: false, error: `HTTP ${response.status}` }
194
+ } catch (err) {
195
+ _ollamaAvailable = false
196
+ return {
197
+ available: false,
198
+ error: err.name === "AbortError" ? "Timeout" : err.message,
199
+ }
200
+ }
201
+ }
202
+
203
+ /**
204
+ * Verifica si Transformers.js está disponible.
205
+ *
206
+ * @returns {Promise<{ available: boolean, error?: string }>}
207
+ */
208
+ async function checkTransformers() {
209
+ if (_forceNoTransformers) {
210
+ return { available: false, error: "Forzado por test hook _setForceNoTransformers" }
211
+ }
212
+ try {
213
+ // Verificar que el módulo se puede importar
214
+ await import("@xenova/transformers")
215
+ return { available: true }
216
+ } catch (err) {
217
+ return { available: false, error: err.message }
218
+ }
219
+ }
220
+
221
+ /**
222
+ * Verifica si un proveedor está disponible.
223
+ *
224
+ * @param {'ollama'|'transformers'} provider
225
+ * @returns {Promise<{ available: boolean, error?: string }>}
226
+ */
227
+ export async function checkProvider(provider) {
228
+ switch (provider) {
229
+ case "ollama":
230
+ return checkOllama()
231
+ case "transformers":
232
+ return checkTransformers()
233
+ default:
234
+ return { available: false, error: `Proveedor desconocido: "${provider}"` }
235
+ }
236
+ }
237
+
238
+ /**
239
+ * Resuelve qué proveedor usar según configuración y disponibilidad.
240
+ *
241
+ * @returns {Promise<{ provider: string, model: string }>}
242
+ */
243
+ async function resolveProvider() {
244
+ // Si hay un proveedor activo configurado, verificar disponibilidad
245
+ if (_activeProvider) {
246
+ const status = await checkProvider(_activeProvider)
247
+ if (status.available) {
248
+ const model = _activeProvider === "ollama" ? _ollamaModel : _transformersModel
249
+ return { provider: _activeProvider, model }
250
+ }
251
+ // Si el configurado no está disponible, caer al otro
252
+ }
253
+
254
+ // Auto-detección: probar Ollama primero, luego Transformers
255
+ const ollamaStatus = await checkOllama()
256
+ if (ollamaStatus.available) {
257
+ return { provider: "ollama", model: _ollamaModel }
258
+ }
259
+
260
+ const transformersStatus = await checkTransformers()
261
+ if (transformersStatus.available) {
262
+ return { provider: "transformers", model: _transformersModel }
263
+ }
264
+
265
+ throw new Error(
266
+ "Ningún proveedor de embeddings disponible. " +
267
+ "Instala Ollama (ollama pull nomic-embed-text) o " +
268
+ "@xenova/transformers (npm install @xenova/transformers)."
269
+ )
270
+ }
271
+
272
+ // ─── API Principal ─────────────────────────────────────────────────
273
+
274
+ /**
275
+ * Valida y prepara el texto para embedding.
276
+ * @param {string} text
277
+ * @returns {string}
278
+ */
279
+ function prepareText(text) {
280
+ if (!text || (typeof text === "string" && text.trim().length === 0)) {
281
+ throw new Error("El texto a embedder no puede estar vacío.")
282
+ }
283
+
284
+ if (text.length > MAX_CHARS) {
285
+ console.warn(
286
+ `⚠️ Texto de ${text.length} caracteres excede el máximo de ${MAX_CHARS}. ` +
287
+ "Se truncará automáticamente."
288
+ )
289
+ return text.slice(0, MAX_CHARS)
290
+ }
291
+
292
+ return text
293
+ }
294
+
295
+ /**
296
+ * Obtiene el vector embedding para un texto.
297
+ *
298
+ * @param {string} text - Texto a embedder (máx 8000 chars)
299
+ * @param {Object} [options]
300
+ * @param {'ollama'|'transformers'} [options.provider] - Forzar proveedor específico
301
+ * @param {string} [options.model] - Modelo específico
302
+ * @returns {Promise<number[]>} Vector de 384-768 dimensiones
303
+ */
304
+ export async function embed(text, options = {}) {
305
+ const cleanText = prepareText(text)
306
+
307
+ if (options.provider) {
308
+ // Usar proveedor específico
309
+ const status = await checkProvider(options.provider)
310
+ if (!status.available) {
311
+ throw new Error(`Proveedor "${options.provider}" no disponible: ${status.error}`)
312
+ }
313
+
314
+ switch (options.provider) {
315
+ case "ollama":
316
+ return embedOllama(cleanText, options.model || _ollamaModel)
317
+ case "transformers":
318
+ return embedTransformers(cleanText, options.model || _transformersModel)
319
+ default:
320
+ throw new Error(`Proveedor inválido: "${options.provider}"`)
321
+ }
322
+ }
323
+
324
+ // Auto-detección
325
+ const resolved = await resolveProvider()
326
+
327
+ switch (resolved.provider) {
328
+ case "ollama":
329
+ return embedOllama(cleanText, resolved.model)
330
+ case "transformers":
331
+ return embedTransformers(cleanText, resolved.model)
332
+ default:
333
+ throw new Error(`Proveedor no soportado: "${resolved.provider}"`)
334
+ }
335
+ }
336
+
337
+ /**
338
+ * Obtiene embeddings en batch.
339
+ * Procesa múltiples textos de forma más eficiente que llamadas separadas.
340
+ *
341
+ * @param {string[]} texts - Array de textos a embedder
342
+ * @param {Object} [options]
343
+ * @param {'ollama'|'transformers'} [options.provider]
344
+ * @param {string} [options.model]
345
+ * @returns {Promise<number[][]>} Array de vectores
346
+ */
347
+ export async function embedBatch(texts, options = {}) {
348
+ if (!Array.isArray(texts) || texts.length === 0) {
349
+ throw new Error("embedBatch requiere un array no vacío de textos.")
350
+ }
351
+
352
+ const resolved = options.provider
353
+ ? { provider: options.provider, model: options.model }
354
+ : await resolveProvider()
355
+
356
+ // Transformers.js soporta batch nativamente
357
+ if (resolved.provider === "transformers") {
358
+ const cleanTexts = texts.map(prepareText)
359
+ return embedTransformersBatch(cleanTexts, resolved.model)
360
+ }
361
+
362
+ // Ollama: secuencial (Ollama no tiene batch embedding nativo)
363
+ const results = []
364
+ for (const t of texts) {
365
+ const cleanText = prepareText(t)
366
+ const vector = await embedOllama(cleanText, resolved.model)
367
+ results.push(vector)
368
+ }
369
+ return results
370
+ }
371
+
372
+ /**
373
+ * Batch embedding con Transformers.js (nativamente más rápido).
374
+ *
375
+ * @param {string[]} texts
376
+ * @param {string} model
377
+ * @returns {Promise<number[][]>}
378
+ */
379
+ async function embedTransformersBatch(texts, model) {
380
+ if (!_transformersPipeline) {
381
+ const { pipeline } = await import("@xenova/transformers")
382
+ _transformersPipeline = await pipeline("feature-extraction", model || _transformersModel)
383
+ }
384
+
385
+ const result = await _transformersPipeline(texts, {
386
+ pooling: "mean",
387
+ normalize: true,
388
+ })
389
+
390
+ // result.data es un tensor con shape [n_texts, dim]
391
+ // result.tolist() devuelve un array 2D
392
+ const array = result.tolist()
393
+ return array.map((row) => Array.from(row))
394
+ }
395
+
396
+ // ─── Utilidades ────────────────────────────────────────────────────
397
+
398
+ /**
399
+ * Calcula la similitud de coseno entre dos vectores.
400
+ *
401
+ * @param {number[]} a - Primer vector
402
+ * @param {number[]} b - Segundo vector
403
+ * @returns {number} Similitud entre -1 y 1
404
+ * @throws {Error} Si los vectores tienen diferente dimensión o están vacíos
405
+ */
406
+ export function cosineSimilarity(a, b) {
407
+ if (!Array.isArray(a) || !Array.isArray(b)) {
408
+ throw new Error("Ambos argumentos deben ser arrays.")
409
+ }
410
+ if (a.length === 0 || b.length === 0) {
411
+ throw new Error("Los vectores no pueden estar vacíos.")
412
+ }
413
+ if (a.length !== b.length) {
414
+ throw new Error(`Dimensión incorrecta: vector A tiene ${a.length}, vector B tiene ${b.length}.`)
415
+ }
416
+
417
+ let dotProduct = 0
418
+ let normA = 0
419
+ let normB = 0
420
+
421
+ for (let i = 0; i < a.length; i++) {
422
+ dotProduct += a[i] * b[i]
423
+ normA += a[i] * a[i]
424
+ normB += b[i] * b[i]
425
+ }
426
+
427
+ const magnitude = Math.sqrt(normA) * Math.sqrt(normB)
428
+
429
+ if (magnitude === 0) return 0
430
+ return dotProduct / magnitude
431
+ }