@saulwade/swl-ses 1.3.3 → 1.3.5
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/CLAUDE.md +1 -1
- package/README.md +1 -1
- package/bin/swl-mcp-server.js +187 -187
- package/bin/swl-ses.js +4 -62
- package/comandos/swl/.evolved.json +22 -22
- package/comandos/swl/adoptar-proyecto.md +207 -207
- package/comandos/swl/contribuir.md +233 -233
- package/habilidades/backend-production-resilience/SKILL.md +288 -288
- package/habilidades/benchmark-memoria/SKILL.md +186 -186
- package/habilidades/diagrama-arquitectura/assets/template.html +276 -276
- package/habilidades/doubt-driven-review/SKILL.md +171 -171
- package/habilidades/doubt-driven-review/recursos/EXAMPLES.md +130 -130
- package/habilidades/eval-framework/SKILL.md +212 -212
- package/habilidades/extractor-de-aprendizajes/SKILL.md +321 -321
- package/habilidades/harness-claude-code/SKILL.md +299 -299
- package/habilidades/infra-github-actions/SKILL.md +166 -166
- package/habilidades/legacy-code-rescue/SKILL.md +267 -267
- package/habilidades/manejo-errores/.evolved.json +8 -8
- package/habilidades/meta-skills-estandar/recursos/convencion-examples.md +93 -93
- package/habilidades/meta-skills-estandar/recursos/skills-as-agents.md +163 -163
- package/habilidades/patrones-python/SKILL.md +229 -229
- package/habilidades/patrones-python/recursos/patrones-avanzados.md +469 -469
- package/habilidades/planear-fase/SKILL.md +319 -319
- package/habilidades/release-semver/.evolved.json +8 -8
- package/habilidades/swl-claudemd/SKILL.md +220 -220
- package/habilidades/testing-python/SKILL.md +340 -340
- package/hooks/claudemd-bloat-detector.js +161 -161
- package/hooks/extraccion-aprendizajes.js +43 -12
- package/hooks/lib/agent-routing.js +107 -107
- package/hooks/lib/auto-consolidator.js +335 -335
- package/hooks/lib/error-classifier.js +308 -308
- package/hooks/lib/merkle-audit.js +96 -96
- package/hooks/lib/provenance-tracker.js +191 -191
- package/hooks/lib/rate-limit-tracker.js +253 -253
- package/hooks/lib/resource-quota.js +122 -122
- package/hooks/lib/retry-jitter.js +165 -165
- package/hooks/lib/skill-auditor.js +588 -588
- package/hooks/lib/sync-status.js +228 -228
- package/hooks/lib/taint-tracker.js +107 -107
- package/hooks/lib/text-similarity.js +241 -241
- package/hooks/lib/toon-compressor.js +245 -245
- package/hooks/registro-turnos.js +209 -209
- package/hooks/sugerir-regenerar-inventario.js +170 -170
- package/hooks/validar-formato-post-subagente.js +140 -140
- package/hooks/validar-memoria-hook.js +218 -218
- package/instintos/prompt-appendices.yaml +57 -57
- package/manifiestos/agent-output-schemas.json +57 -57
- package/manifiestos/skills-lock.json +27 -27
- package/package.json +1 -1
- package/plantillas/auditor-veto-template.md +105 -105
- package/plantillas/github-workflows/README.md +47 -47
- package/plantillas/github-workflows/release-please.yml +44 -44
- package/plantillas/github-workflows/swl-ci.yml +107 -107
- package/plantillas/github-workflows/swl-security.yml +51 -51
- package/plugin.json +1 -1
- package/reglas/analisis-previo-tareas-grandes.md +172 -172
- package/reglas/arreglar-al-detectar.md +147 -147
- package/reglas/fragmentos-compartidos.md +152 -152
- package/reglas/harness-claude-code.md +213 -213
- package/reglas/usar-context7.md +226 -226
- package/schemas/diary-entry.schema.json +80 -80
- package/scripts/benchmark-memoria.js +167 -167
- package/scripts/configurar-branch-protection.js +418 -418
- package/scripts/detectar-aprendizajes-duplicados.js +151 -151
- package/scripts/doctor.js +77 -3
- package/scripts/field-report.js +199 -199
- package/scripts/generar-checklists-consolidados.js +273 -273
- package/scripts/generar-inventario.js +420 -420
- package/scripts/generar-matriz-lenguajes.js +271 -271
- package/scripts/instalador.js +38 -1
- package/scripts/lib/artefactos-python.js +43 -43
- package/scripts/lib/benchmark-metrics.js +160 -160
- package/scripts/lib/budget-enforcer.js +252 -252
- package/scripts/lib/configurar-ci.js +380 -380
- package/scripts/lib/contadores-inventario.js +217 -217
- package/scripts/lib/detectar-stack-detallado.js +307 -307
- package/scripts/lib/diary-entry.js +234 -234
- package/scripts/lib/eval-metrics-store.js +218 -218
- package/scripts/lib/eval-quality.js +171 -171
- package/scripts/lib/eval-schemas.js +144 -144
- package/scripts/lib/eval-self-correct.js +106 -106
- package/scripts/lib/eval-validator.js +185 -185
- package/scripts/lib/jaccard-similarity.js +98 -98
- package/scripts/lib/longmemeval-runner.js +125 -125
- package/scripts/lib/npm-version.js +261 -261
- package/scripts/lib/paquetes-conocidos.js +50 -50
- package/scripts/lib/parsear-opciones.js +136 -0
- package/scripts/lib/prompt-builder.js +264 -264
- package/scripts/lib/rrf-fusion.js +175 -175
- package/scripts/lib/scoring-instintos.js +277 -277
- package/scripts/lib/semantic-search.js +252 -252
- package/scripts/lib/transformadores/claude.js +200 -200
- package/scripts/limpiar-artefactos-python.js +131 -131
- package/scripts/mcp-server/README.md +128 -128
- package/scripts/mcp-server/handlers.js +206 -206
- package/scripts/migrar-csv-a-array.js +168 -168
- package/scripts/migrar-fase-dominio.js +201 -201
- package/scripts/publicar.js +511 -511
- package/scripts/run-eval.js +141 -141
- package/scripts/validar-manifest.js +195 -195
- package/scripts/validar-userland-vacio.js +110 -110
- package/scripts/verificar-release.js +5 -1
|
@@ -1,252 +1,252 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* semantic-search.js
|
|
5
|
-
*
|
|
6
|
-
* Búsqueda híbrida con fallback automático:
|
|
7
|
-
* - Default: Jaccard similarity sobre tokens normalizados (zero-deps).
|
|
8
|
-
* - Opt-in: embeddings con @xenova/transformers (vía SWL_SEMANTIC_BACKEND=xenova).
|
|
9
|
-
*
|
|
10
|
-
* Patrón adaptado de `temp/cass_memory_system-main/src/semantic.ts`. Diferencias:
|
|
11
|
-
* - Carga lazy de Xenova (no requiere instalación si no se usa).
|
|
12
|
-
* - API uniforme: el caller no sabe qué backend está activo.
|
|
13
|
-
* - Tokenización compatible con español (acentos preservados, stopwords es-MX).
|
|
14
|
-
*
|
|
15
|
-
* Cuándo usar:
|
|
16
|
-
* - Buscar skills similares por significado, no solo keywords (150 skills).
|
|
17
|
-
* - Encontrar instintos relacionados a un patrón observado.
|
|
18
|
-
* - Detectar duplicados semánticos en APRENDIZAJES.md.
|
|
19
|
-
*
|
|
20
|
-
* API:
|
|
21
|
-
* - jaccardSimilarity(a, b) → [0, 1]
|
|
22
|
-
* - cosineSimilarity(a, b) → [-1, 1] (vectores numéricos)
|
|
23
|
-
* - tokenize(text) → array de tokens
|
|
24
|
-
* - semanticRank(query, items, opts) → items rankeados por similitud
|
|
25
|
-
*
|
|
26
|
-
* Backend: el módulo NO requiere Xenova. Si SWL_SEMANTIC_BACKEND=xenova y
|
|
27
|
-
* @xenova/transformers está instalado, se usa para embeddings. Si no, Jaccard.
|
|
28
|
-
*
|
|
29
|
-
* Instalación opt-in del backend de embeddings:
|
|
30
|
-
*
|
|
31
|
-
* npm install -g @xenova/transformers
|
|
32
|
-
* export SWL_SEMANTIC_BACKEND=xenova
|
|
33
|
-
*
|
|
34
|
-
* @xenova/transformers ya NO es dependencia del paquete swl-ses (v1.0.1+)
|
|
35
|
-
* porque arrastra `prebuild-install@7.1.3` deprecated y sumaba ~50 MB al
|
|
36
|
-
* install para una feature opt-in raramente usada. El usuario que quiera
|
|
37
|
-
* el backend semántico lo instala manualmente.
|
|
38
|
-
*
|
|
39
|
-
* @module scripts/lib/semantic-search
|
|
40
|
-
*/
|
|
41
|
-
|
|
42
|
-
// ── stopwords es-MX ───────────────────────────────────────────────────────────
|
|
43
|
-
|
|
44
|
-
const STOPWORDS = new Set([
|
|
45
|
-
'a', 'al', 'algo', 'algunas', 'algunos', 'ante', 'antes', 'aunque',
|
|
46
|
-
'cada', 'cierta', 'ciertas', 'cierto', 'ciertos', 'como', 'con', 'contra',
|
|
47
|
-
'cual', 'cuando', 'de', 'del', 'desde', 'donde', 'durante',
|
|
48
|
-
'e', 'el', 'ella', 'ellas', 'ellos', 'en', 'entre', 'era', 'erais', 'eran',
|
|
49
|
-
'eras', 'eres', 'es', 'esa', 'esas', 'ese', 'eso', 'esos', 'esta', 'estaba',
|
|
50
|
-
'estaban', 'estado', 'estamos', 'estan', 'están', 'estar', 'estas', 'este',
|
|
51
|
-
'esto', 'estos', 'estoy', 'fue', 'fuera', 'fueron', 'fui', 'ha', 'habia',
|
|
52
|
-
'había', 'han', 'hasta', 'hay', 'haya', 'la', 'las', 'le', 'les', 'lo',
|
|
53
|
-
'los', 'me', 'mi', 'mis', 'mucho', 'muchos', 'muy', 'nada', 'ni', 'no',
|
|
54
|
-
'nos', 'nosotros', 'nuestra', 'nuestras', 'nuestro', 'nuestros', 'o', 'os',
|
|
55
|
-
'otra', 'otras', 'otro', 'otros', 'para', 'pero', 'poco', 'por', 'porque',
|
|
56
|
-
'que', 'quien', 'quienes', 'qué', 'se', 'sea', 'sean', 'ser', 'si', 'sí',
|
|
57
|
-
'sido', 'siendo', 'sin', 'sobre', 'soy', 'su', 'sus', 'también', 'tan',
|
|
58
|
-
'tanto', 'te', 'tener', 'ti', 'tiene', 'tienen', 'todo', 'todos', 'tu',
|
|
59
|
-
'tus', 'un', 'una', 'unas', 'uno', 'unos', 'usted', 'ustedes', 'va', 'vais',
|
|
60
|
-
'van', 'vas', 'ver', 'y', 'ya', 'yo',
|
|
61
|
-
]);
|
|
62
|
-
|
|
63
|
-
// ── tokenización ──────────────────────────────────────────────────────────────
|
|
64
|
-
|
|
65
|
-
/**
|
|
66
|
-
* Tokeniza texto en español de México. Pasos:
|
|
67
|
-
* 1. lowercase
|
|
68
|
-
* 2. eliminar puntuación
|
|
69
|
-
* 3. dividir en palabras
|
|
70
|
-
* 4. eliminar stopwords
|
|
71
|
-
* 5. eliminar tokens muy cortos (<2 chars)
|
|
72
|
-
*
|
|
73
|
-
* Preserva acentos (cá vs ca son distintos). No hace stemming.
|
|
74
|
-
*
|
|
75
|
-
* @param {string} texto
|
|
76
|
-
* @returns {string[]} tokens
|
|
77
|
-
*/
|
|
78
|
-
function tokenize(texto) {
|
|
79
|
-
if (!texto || typeof texto !== 'string') return [];
|
|
80
|
-
return texto
|
|
81
|
-
.toLowerCase()
|
|
82
|
-
.replace(/[^\p{L}\p{N}\s]/gu, ' ') // letras unicode + números
|
|
83
|
-
.split(/\s+/)
|
|
84
|
-
.filter(t => t.length >= 2 && !STOPWORDS.has(t));
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
// ── Jaccard ───────────────────────────────────────────────────────────────────
|
|
88
|
-
|
|
89
|
-
/**
|
|
90
|
-
* Jaccard similarity sobre conjuntos de tokens.
|
|
91
|
-
* jaccard(A, B) = |A ∩ B| / |A ∪ B|
|
|
92
|
-
*
|
|
93
|
-
* Ambos textos vacíos → 0 (no hay similitud, no hay info).
|
|
94
|
-
* Un texto vacío + uno con tokens → 0.
|
|
95
|
-
*
|
|
96
|
-
* @param {string} textA
|
|
97
|
-
* @param {string} textB
|
|
98
|
-
* @returns {number} en [0, 1]
|
|
99
|
-
*/
|
|
100
|
-
function jaccardSimilarity(textA, textB) {
|
|
101
|
-
const a = new Set(tokenize(textA));
|
|
102
|
-
const b = new Set(tokenize(textB));
|
|
103
|
-
if (a.size === 0 && b.size === 0) return 0;
|
|
104
|
-
if (a.size === 0 || b.size === 0) return 0;
|
|
105
|
-
|
|
106
|
-
let inter = 0;
|
|
107
|
-
for (const t of a) if (b.has(t)) inter++;
|
|
108
|
-
const union = a.size + b.size - inter;
|
|
109
|
-
return inter / union;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
// ── Cosine (para embeddings) ──────────────────────────────────────────────────
|
|
113
|
-
|
|
114
|
-
/**
|
|
115
|
-
* Cosine similarity entre dos vectores numéricos.
|
|
116
|
-
* cos(A, B) = (A · B) / (||A|| × ||B||)
|
|
117
|
-
*
|
|
118
|
-
* @param {number[]} vecA
|
|
119
|
-
* @param {number[]} vecB
|
|
120
|
-
* @returns {number} en [-1, 1]
|
|
121
|
-
*/
|
|
122
|
-
function cosineSimilarity(vecA, vecB) {
|
|
123
|
-
if (!Array.isArray(vecA) || !Array.isArray(vecB)) return 0;
|
|
124
|
-
if (vecA.length !== vecB.length || vecA.length === 0) return 0;
|
|
125
|
-
|
|
126
|
-
let dot = 0, normA = 0, normB = 0;
|
|
127
|
-
for (let i = 0; i < vecA.length; i++) {
|
|
128
|
-
const a = vecA[i] || 0;
|
|
129
|
-
const b = vecB[i] || 0;
|
|
130
|
-
dot += a * b;
|
|
131
|
-
normA += a * a;
|
|
132
|
-
normB += b * b;
|
|
133
|
-
}
|
|
134
|
-
if (normA === 0 || normB === 0) return 0;
|
|
135
|
-
return dot / (Math.sqrt(normA) * Math.sqrt(normB));
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
// ── backend de embeddings (Xenova lazy) ───────────────────────────────────────
|
|
139
|
-
|
|
140
|
-
/**
|
|
141
|
-
* Estado lazy del backend Xenova. Se carga la primera vez que se invoca
|
|
142
|
-
* embeddingsBackend() y NO falla si no está instalado.
|
|
143
|
-
*/
|
|
144
|
-
let _xenovaPipeline = null;
|
|
145
|
-
let _xenovaTried = false;
|
|
146
|
-
|
|
147
|
-
async function tryLoadXenova() {
|
|
148
|
-
if (_xenovaPipeline || _xenovaTried) return _xenovaPipeline;
|
|
149
|
-
_xenovaTried = true;
|
|
150
|
-
try {
|
|
151
|
-
// Carga dinámica para evitar require al inicio
|
|
152
|
-
const transformers = require('@xenova/transformers');
|
|
153
|
-
const { pipeline } = transformers;
|
|
154
|
-
_xenovaPipeline = await pipeline('feature-extraction', 'Xenova/all-MiniLM-L6-v2');
|
|
155
|
-
return _xenovaPipeline;
|
|
156
|
-
} catch (_) {
|
|
157
|
-
// No instalado o error al cargar: silencioso. Caller usará fallback.
|
|
158
|
-
return null;
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
/**
|
|
163
|
-
* Detecta el backend activo según SWL_SEMANTIC_BACKEND.
|
|
164
|
-
* Sin variable o valor != 'xenova' → 'jaccard'.
|
|
165
|
-
*/
|
|
166
|
-
function activeBackend() {
|
|
167
|
-
const env = (process.env.SWL_SEMANTIC_BACKEND || '').toLowerCase().trim();
|
|
168
|
-
return env === 'xenova' ? 'xenova' : 'jaccard';
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
/**
|
|
172
|
-
* Embebe un texto en un vector. Solo disponible si Xenova está activo.
|
|
173
|
-
* Retorna null si el backend no es xenova o no se pudo cargar.
|
|
174
|
-
*
|
|
175
|
-
* @param {string} texto
|
|
176
|
-
* @returns {Promise<number[]|null>}
|
|
177
|
-
*/
|
|
178
|
-
async function embed(texto) {
|
|
179
|
-
if (activeBackend() !== 'xenova') return null;
|
|
180
|
-
const pipe = await tryLoadXenova();
|
|
181
|
-
if (!pipe) return null;
|
|
182
|
-
try {
|
|
183
|
-
const out = await pipe(texto, { pooling: 'mean', normalize: true });
|
|
184
|
-
return Array.from(out.data);
|
|
185
|
-
} catch (_) {
|
|
186
|
-
return null;
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
// ── ranking ──────────────────────────────────────────────────────────────────
|
|
191
|
-
|
|
192
|
-
/**
|
|
193
|
-
* Rankea items por similitud con la query.
|
|
194
|
-
*
|
|
195
|
-
* @param {string} query
|
|
196
|
-
* @param {Array<{id: string, text: string, embedding?: number[]}>} items
|
|
197
|
-
* @param {object} [opts]
|
|
198
|
-
* @param {number} [opts.threshold=0] - filtra resultados con score < threshold
|
|
199
|
-
* @param {number} [opts.limit=10] - limita resultados
|
|
200
|
-
* @returns {Promise<Array<{id, text, score, backend}>>}
|
|
201
|
-
*/
|
|
202
|
-
async function semanticRank(query, items, opts = {}) {
|
|
203
|
-
const { threshold = 0, limit = 10 } = opts;
|
|
204
|
-
if (!query || !Array.isArray(items)) return [];
|
|
205
|
-
|
|
206
|
-
const backend = activeBackend();
|
|
207
|
-
let results;
|
|
208
|
-
|
|
209
|
-
if (backend === 'xenova') {
|
|
210
|
-
const queryEmb = await embed(query);
|
|
211
|
-
if (queryEmb) {
|
|
212
|
-
results = await Promise.all(items.map(async (item) => {
|
|
213
|
-
let itemEmb = item.embedding;
|
|
214
|
-
if (!itemEmb) itemEmb = await embed(item.text || '');
|
|
215
|
-
const score = (queryEmb && itemEmb) ? cosineSimilarity(queryEmb, itemEmb) : 0;
|
|
216
|
-
return { id: item.id, text: item.text, score, backend: 'xenova' };
|
|
217
|
-
}));
|
|
218
|
-
} else {
|
|
219
|
-
// Backend declarado pero no disponible → fallback transparente
|
|
220
|
-
results = items.map(item => ({
|
|
221
|
-
id: item.id,
|
|
222
|
-
text: item.text,
|
|
223
|
-
score: jaccardSimilarity(query, item.text || ''),
|
|
224
|
-
backend: 'jaccard-fallback',
|
|
225
|
-
}));
|
|
226
|
-
}
|
|
227
|
-
} else {
|
|
228
|
-
results = items.map(item => ({
|
|
229
|
-
id: item.id,
|
|
230
|
-
text: item.text,
|
|
231
|
-
score: jaccardSimilarity(query, item.text || ''),
|
|
232
|
-
backend: 'jaccard',
|
|
233
|
-
}));
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
return results
|
|
237
|
-
.filter(r => r.score >= threshold)
|
|
238
|
-
.sort((a, b) => b.score - a.score)
|
|
239
|
-
.slice(0, limit);
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
// ── exports ───────────────────────────────────────────────────────────────────
|
|
243
|
-
|
|
244
|
-
module.exports = {
|
|
245
|
-
tokenize,
|
|
246
|
-
jaccardSimilarity,
|
|
247
|
-
cosineSimilarity,
|
|
248
|
-
embed,
|
|
249
|
-
semanticRank,
|
|
250
|
-
activeBackend,
|
|
251
|
-
STOPWORDS,
|
|
252
|
-
};
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* semantic-search.js
|
|
5
|
+
*
|
|
6
|
+
* Búsqueda híbrida con fallback automático:
|
|
7
|
+
* - Default: Jaccard similarity sobre tokens normalizados (zero-deps).
|
|
8
|
+
* - Opt-in: embeddings con @xenova/transformers (vía SWL_SEMANTIC_BACKEND=xenova).
|
|
9
|
+
*
|
|
10
|
+
* Patrón adaptado de `temp/cass_memory_system-main/src/semantic.ts`. Diferencias:
|
|
11
|
+
* - Carga lazy de Xenova (no requiere instalación si no se usa).
|
|
12
|
+
* - API uniforme: el caller no sabe qué backend está activo.
|
|
13
|
+
* - Tokenización compatible con español (acentos preservados, stopwords es-MX).
|
|
14
|
+
*
|
|
15
|
+
* Cuándo usar:
|
|
16
|
+
* - Buscar skills similares por significado, no solo keywords (150 skills).
|
|
17
|
+
* - Encontrar instintos relacionados a un patrón observado.
|
|
18
|
+
* - Detectar duplicados semánticos en APRENDIZAJES.md.
|
|
19
|
+
*
|
|
20
|
+
* API:
|
|
21
|
+
* - jaccardSimilarity(a, b) → [0, 1]
|
|
22
|
+
* - cosineSimilarity(a, b) → [-1, 1] (vectores numéricos)
|
|
23
|
+
* - tokenize(text) → array de tokens
|
|
24
|
+
* - semanticRank(query, items, opts) → items rankeados por similitud
|
|
25
|
+
*
|
|
26
|
+
* Backend: el módulo NO requiere Xenova. Si SWL_SEMANTIC_BACKEND=xenova y
|
|
27
|
+
* @xenova/transformers está instalado, se usa para embeddings. Si no, Jaccard.
|
|
28
|
+
*
|
|
29
|
+
* Instalación opt-in del backend de embeddings:
|
|
30
|
+
*
|
|
31
|
+
* npm install -g @xenova/transformers
|
|
32
|
+
* export SWL_SEMANTIC_BACKEND=xenova
|
|
33
|
+
*
|
|
34
|
+
* @xenova/transformers ya NO es dependencia del paquete swl-ses (v1.0.1+)
|
|
35
|
+
* porque arrastra `prebuild-install@7.1.3` deprecated y sumaba ~50 MB al
|
|
36
|
+
* install para una feature opt-in raramente usada. El usuario que quiera
|
|
37
|
+
* el backend semántico lo instala manualmente.
|
|
38
|
+
*
|
|
39
|
+
* @module scripts/lib/semantic-search
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
// ── stopwords es-MX ───────────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
const STOPWORDS = new Set([
|
|
45
|
+
'a', 'al', 'algo', 'algunas', 'algunos', 'ante', 'antes', 'aunque',
|
|
46
|
+
'cada', 'cierta', 'ciertas', 'cierto', 'ciertos', 'como', 'con', 'contra',
|
|
47
|
+
'cual', 'cuando', 'de', 'del', 'desde', 'donde', 'durante',
|
|
48
|
+
'e', 'el', 'ella', 'ellas', 'ellos', 'en', 'entre', 'era', 'erais', 'eran',
|
|
49
|
+
'eras', 'eres', 'es', 'esa', 'esas', 'ese', 'eso', 'esos', 'esta', 'estaba',
|
|
50
|
+
'estaban', 'estado', 'estamos', 'estan', 'están', 'estar', 'estas', 'este',
|
|
51
|
+
'esto', 'estos', 'estoy', 'fue', 'fuera', 'fueron', 'fui', 'ha', 'habia',
|
|
52
|
+
'había', 'han', 'hasta', 'hay', 'haya', 'la', 'las', 'le', 'les', 'lo',
|
|
53
|
+
'los', 'me', 'mi', 'mis', 'mucho', 'muchos', 'muy', 'nada', 'ni', 'no',
|
|
54
|
+
'nos', 'nosotros', 'nuestra', 'nuestras', 'nuestro', 'nuestros', 'o', 'os',
|
|
55
|
+
'otra', 'otras', 'otro', 'otros', 'para', 'pero', 'poco', 'por', 'porque',
|
|
56
|
+
'que', 'quien', 'quienes', 'qué', 'se', 'sea', 'sean', 'ser', 'si', 'sí',
|
|
57
|
+
'sido', 'siendo', 'sin', 'sobre', 'soy', 'su', 'sus', 'también', 'tan',
|
|
58
|
+
'tanto', 'te', 'tener', 'ti', 'tiene', 'tienen', 'todo', 'todos', 'tu',
|
|
59
|
+
'tus', 'un', 'una', 'unas', 'uno', 'unos', 'usted', 'ustedes', 'va', 'vais',
|
|
60
|
+
'van', 'vas', 'ver', 'y', 'ya', 'yo',
|
|
61
|
+
]);
|
|
62
|
+
|
|
63
|
+
// ── tokenización ──────────────────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Tokeniza texto en español de México. Pasos:
|
|
67
|
+
* 1. lowercase
|
|
68
|
+
* 2. eliminar puntuación
|
|
69
|
+
* 3. dividir en palabras
|
|
70
|
+
* 4. eliminar stopwords
|
|
71
|
+
* 5. eliminar tokens muy cortos (<2 chars)
|
|
72
|
+
*
|
|
73
|
+
* Preserva acentos (cá vs ca son distintos). No hace stemming.
|
|
74
|
+
*
|
|
75
|
+
* @param {string} texto
|
|
76
|
+
* @returns {string[]} tokens
|
|
77
|
+
*/
|
|
78
|
+
function tokenize(texto) {
|
|
79
|
+
if (!texto || typeof texto !== 'string') return [];
|
|
80
|
+
return texto
|
|
81
|
+
.toLowerCase()
|
|
82
|
+
.replace(/[^\p{L}\p{N}\s]/gu, ' ') // letras unicode + números
|
|
83
|
+
.split(/\s+/)
|
|
84
|
+
.filter(t => t.length >= 2 && !STOPWORDS.has(t));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ── Jaccard ───────────────────────────────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Jaccard similarity sobre conjuntos de tokens.
|
|
91
|
+
* jaccard(A, B) = |A ∩ B| / |A ∪ B|
|
|
92
|
+
*
|
|
93
|
+
* Ambos textos vacíos → 0 (no hay similitud, no hay info).
|
|
94
|
+
* Un texto vacío + uno con tokens → 0.
|
|
95
|
+
*
|
|
96
|
+
* @param {string} textA
|
|
97
|
+
* @param {string} textB
|
|
98
|
+
* @returns {number} en [0, 1]
|
|
99
|
+
*/
|
|
100
|
+
function jaccardSimilarity(textA, textB) {
|
|
101
|
+
const a = new Set(tokenize(textA));
|
|
102
|
+
const b = new Set(tokenize(textB));
|
|
103
|
+
if (a.size === 0 && b.size === 0) return 0;
|
|
104
|
+
if (a.size === 0 || b.size === 0) return 0;
|
|
105
|
+
|
|
106
|
+
let inter = 0;
|
|
107
|
+
for (const t of a) if (b.has(t)) inter++;
|
|
108
|
+
const union = a.size + b.size - inter;
|
|
109
|
+
return inter / union;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ── Cosine (para embeddings) ──────────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Cosine similarity entre dos vectores numéricos.
|
|
116
|
+
* cos(A, B) = (A · B) / (||A|| × ||B||)
|
|
117
|
+
*
|
|
118
|
+
* @param {number[]} vecA
|
|
119
|
+
* @param {number[]} vecB
|
|
120
|
+
* @returns {number} en [-1, 1]
|
|
121
|
+
*/
|
|
122
|
+
function cosineSimilarity(vecA, vecB) {
|
|
123
|
+
if (!Array.isArray(vecA) || !Array.isArray(vecB)) return 0;
|
|
124
|
+
if (vecA.length !== vecB.length || vecA.length === 0) return 0;
|
|
125
|
+
|
|
126
|
+
let dot = 0, normA = 0, normB = 0;
|
|
127
|
+
for (let i = 0; i < vecA.length; i++) {
|
|
128
|
+
const a = vecA[i] || 0;
|
|
129
|
+
const b = vecB[i] || 0;
|
|
130
|
+
dot += a * b;
|
|
131
|
+
normA += a * a;
|
|
132
|
+
normB += b * b;
|
|
133
|
+
}
|
|
134
|
+
if (normA === 0 || normB === 0) return 0;
|
|
135
|
+
return dot / (Math.sqrt(normA) * Math.sqrt(normB));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ── backend de embeddings (Xenova lazy) ───────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Estado lazy del backend Xenova. Se carga la primera vez que se invoca
|
|
142
|
+
* embeddingsBackend() y NO falla si no está instalado.
|
|
143
|
+
*/
|
|
144
|
+
let _xenovaPipeline = null;
|
|
145
|
+
let _xenovaTried = false;
|
|
146
|
+
|
|
147
|
+
async function tryLoadXenova() {
|
|
148
|
+
if (_xenovaPipeline || _xenovaTried) return _xenovaPipeline;
|
|
149
|
+
_xenovaTried = true;
|
|
150
|
+
try {
|
|
151
|
+
// Carga dinámica para evitar require al inicio
|
|
152
|
+
const transformers = require('@xenova/transformers');
|
|
153
|
+
const { pipeline } = transformers;
|
|
154
|
+
_xenovaPipeline = await pipeline('feature-extraction', 'Xenova/all-MiniLM-L6-v2');
|
|
155
|
+
return _xenovaPipeline;
|
|
156
|
+
} catch (_) {
|
|
157
|
+
// No instalado o error al cargar: silencioso. Caller usará fallback.
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Detecta el backend activo según SWL_SEMANTIC_BACKEND.
|
|
164
|
+
* Sin variable o valor != 'xenova' → 'jaccard'.
|
|
165
|
+
*/
|
|
166
|
+
function activeBackend() {
|
|
167
|
+
const env = (process.env.SWL_SEMANTIC_BACKEND || '').toLowerCase().trim();
|
|
168
|
+
return env === 'xenova' ? 'xenova' : 'jaccard';
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Embebe un texto en un vector. Solo disponible si Xenova está activo.
|
|
173
|
+
* Retorna null si el backend no es xenova o no se pudo cargar.
|
|
174
|
+
*
|
|
175
|
+
* @param {string} texto
|
|
176
|
+
* @returns {Promise<number[]|null>}
|
|
177
|
+
*/
|
|
178
|
+
async function embed(texto) {
|
|
179
|
+
if (activeBackend() !== 'xenova') return null;
|
|
180
|
+
const pipe = await tryLoadXenova();
|
|
181
|
+
if (!pipe) return null;
|
|
182
|
+
try {
|
|
183
|
+
const out = await pipe(texto, { pooling: 'mean', normalize: true });
|
|
184
|
+
return Array.from(out.data);
|
|
185
|
+
} catch (_) {
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ── ranking ──────────────────────────────────────────────────────────────────
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Rankea items por similitud con la query.
|
|
194
|
+
*
|
|
195
|
+
* @param {string} query
|
|
196
|
+
* @param {Array<{id: string, text: string, embedding?: number[]}>} items
|
|
197
|
+
* @param {object} [opts]
|
|
198
|
+
* @param {number} [opts.threshold=0] - filtra resultados con score < threshold
|
|
199
|
+
* @param {number} [opts.limit=10] - limita resultados
|
|
200
|
+
* @returns {Promise<Array<{id, text, score, backend}>>}
|
|
201
|
+
*/
|
|
202
|
+
async function semanticRank(query, items, opts = {}) {
|
|
203
|
+
const { threshold = 0, limit = 10 } = opts;
|
|
204
|
+
if (!query || !Array.isArray(items)) return [];
|
|
205
|
+
|
|
206
|
+
const backend = activeBackend();
|
|
207
|
+
let results;
|
|
208
|
+
|
|
209
|
+
if (backend === 'xenova') {
|
|
210
|
+
const queryEmb = await embed(query);
|
|
211
|
+
if (queryEmb) {
|
|
212
|
+
results = await Promise.all(items.map(async (item) => {
|
|
213
|
+
let itemEmb = item.embedding;
|
|
214
|
+
if (!itemEmb) itemEmb = await embed(item.text || '');
|
|
215
|
+
const score = (queryEmb && itemEmb) ? cosineSimilarity(queryEmb, itemEmb) : 0;
|
|
216
|
+
return { id: item.id, text: item.text, score, backend: 'xenova' };
|
|
217
|
+
}));
|
|
218
|
+
} else {
|
|
219
|
+
// Backend declarado pero no disponible → fallback transparente
|
|
220
|
+
results = items.map(item => ({
|
|
221
|
+
id: item.id,
|
|
222
|
+
text: item.text,
|
|
223
|
+
score: jaccardSimilarity(query, item.text || ''),
|
|
224
|
+
backend: 'jaccard-fallback',
|
|
225
|
+
}));
|
|
226
|
+
}
|
|
227
|
+
} else {
|
|
228
|
+
results = items.map(item => ({
|
|
229
|
+
id: item.id,
|
|
230
|
+
text: item.text,
|
|
231
|
+
score: jaccardSimilarity(query, item.text || ''),
|
|
232
|
+
backend: 'jaccard',
|
|
233
|
+
}));
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return results
|
|
237
|
+
.filter(r => r.score >= threshold)
|
|
238
|
+
.sort((a, b) => b.score - a.score)
|
|
239
|
+
.slice(0, limit);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// ── exports ───────────────────────────────────────────────────────────────────
|
|
243
|
+
|
|
244
|
+
module.exports = {
|
|
245
|
+
tokenize,
|
|
246
|
+
jaccardSimilarity,
|
|
247
|
+
cosineSimilarity,
|
|
248
|
+
embed,
|
|
249
|
+
semanticRank,
|
|
250
|
+
activeBackend,
|
|
251
|
+
STOPWORDS,
|
|
252
|
+
};
|