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.
- package/README.md +62 -8
- package/docs/EMBEDDINGS.md +214 -0
- package/docs/ONBOARDING_WORKFLOW.md +151 -0
- package/docs/OPL_ACADEMIC_ISSUES.md +158 -0
- package/docs/WEB_SCRAPER_PLAN.md +454 -0
- package/package.json +7 -1
- package/scripts/postinstall.js +37 -0
- package/src/cli/commands-knowledge.js +1 -0
- package/src/cli/commands-opl.js +79 -1
- package/src/cli/commands-workflow.js +125 -6
- package/src/commands/init-core.js +169 -5
- package/src/commands/knowledge-ops.js +52 -0
- package/src/commands/opl-embeddings.js +556 -0
- package/src/commands/opl-help.js +26 -2
- package/src/commands/opl-search.js +106 -2
- package/src/commands/opl-webscrape.js +390 -0
- package/src/commands/workflow/epic-cli.js +192 -0
- package/src/commands/workflow/select.js +146 -0
- package/src/commands/workflow/sprint-cli.js +174 -0
- package/src/core/webscrape/analyzer.js +481 -0
- package/src/core/webscrape/deep-scraper.js +1027 -0
- package/src/core/workflow/epic-manager.js +845 -0
- package/src/core/workflow/gates.js +180 -1
- package/src/core/workflow/selector.js +707 -0
- package/src/embeddings/chunker.js +450 -0
- package/src/embeddings/embedder.js +431 -0
- package/src/embeddings/index-pipeline.js +320 -0
- package/src/embeddings/vector-store.js +505 -0
|
@@ -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
|
+
}
|