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,450 @@
1
+ // @use(kind, contract, limit, deps)
2
+ // @kind(module)
3
+ // @contract(in: Document -> out: Chunk[], pure: true)
4
+ // @limit(lines: 460)
5
+ // @deps(none)
6
+
7
+ /**
8
+ * Sistema de chunking para dividir documentos de conocimiento en fragmentos
9
+ * semánticamente coherentes, aptos para generar embeddings vectoriales.
10
+ *
11
+ * Estrategias disponibles:
12
+ * - 'paragraph': Respeta límites de párrafo, combina pequeños, parte grandes
13
+ * - 'section': Respeta límites de sección (##, ###), sub-divide secciones grandes
14
+ * - 'fixed': Divide por tamaño fijo de tokens con solapamiento
15
+ */
16
+
17
+ // ─── Token Estimation ──────────────────────────────────────────────
18
+
19
+ /**
20
+ * Calcula tokens aproximados sin tokenizer real.
21
+ * Regla general: 1 token ≈ 0.75 palabras en inglés, 0.6 en español.
22
+ * Usamos 0.7 como promedio conservador.
23
+ *
24
+ * @param {string} text - Texto a medir
25
+ * @returns {number} Tokens estimados
26
+ */
27
+ export function estimateTokens(text) {
28
+ if (!text || typeof text !== "string") return 0
29
+ const cleaned = text.trim()
30
+ if (cleaned.length === 0) return 0
31
+
32
+ // Contar palabras (separadas por espacios)
33
+ const words = cleaned.split(/\s+/).filter(Boolean).length
34
+ // 1 token ≈ 0.7 palabras
35
+ return Math.ceil(words / 0.7)
36
+ }
37
+
38
+ // ─── Estrategias de Chunking ────────────────────────────────────────
39
+
40
+ /**
41
+ * Divide texto por párrafos respetando maxTokens.
42
+ * - Párrafos pequeños se combinan
43
+ * - Párrafos grandes se parten por oraciones
44
+ *
45
+ * @param {string} text
46
+ * @param {number} maxTokens
47
+ * @param {number} overlapTokens
48
+ * @returns {string[]}
49
+ */
50
+ function chunkByParagraph(text, maxTokens, overlapTokens) {
51
+ const paragraphs = text.split(/\n\s*\n/).filter((p) => p.trim().length > 0)
52
+ const chunks = []
53
+ let currentChunk = []
54
+ let currentTokens = 0
55
+
56
+ for (const para of paragraphs) {
57
+ const paraTokens = estimateTokens(para)
58
+
59
+ if (currentTokens + paraTokens <= maxTokens) {
60
+ currentChunk.push(para)
61
+ currentTokens += paraTokens
62
+ } else {
63
+ // Guardar chunk actual si tiene contenido
64
+ if (currentChunk.length > 0) {
65
+ chunks.push(currentChunk.join("\n\n"))
66
+ }
67
+
68
+ // Si el párrafo solo excede maxTokens, partirlo recursivamente
69
+ if (paraTokens > maxTokens) {
70
+ const splitPieces = splitTextGuaranteed(para, maxTokens)
71
+ chunks.push(...splitPieces)
72
+ currentChunk = []
73
+ currentTokens = 0
74
+ } else {
75
+ // Empezar nuevo chunk con este párrafo
76
+ currentChunk = [para]
77
+ currentTokens = paraTokens
78
+ }
79
+ }
80
+ }
81
+
82
+ // Último chunk
83
+ if (currentChunk.length > 0) {
84
+ chunks.push(currentChunk.join("\n\n"))
85
+ }
86
+
87
+ // Aplicar solapamiento si es necesario
88
+ if (overlapTokens > 0 && chunks.length > 1) {
89
+ return applyOverlap(chunks, overlapTokens)
90
+ }
91
+
92
+ return chunks
93
+ }
94
+
95
+ /**
96
+ * Divide texto por secciones (##, ###, etc.) respetando maxTokens.
97
+ * - Mantiene el título de sección en cada chunk
98
+ * - Secciones grandes se sub-dividen por párrafo
99
+ *
100
+ * @param {string} text
101
+ * @param {number} maxTokens
102
+ * @param {number} overlapTokens
103
+ * @returns {string[]}
104
+ */
105
+ function chunkBySection(text, maxTokens, overlapTokens) {
106
+ // Detectar secciones por headings markdown
107
+ const sectionRegex = /^(#{1,4})\s+(.+)$/gm
108
+ const sections = []
109
+ let lastIndex = 0
110
+ let lastTitle = ""
111
+ let lastLevel = ""
112
+
113
+ let match
114
+ while ((match = sectionRegex.exec(text)) !== null) {
115
+ // Guardar sección anterior (desde el último heading hasta este)
116
+ if (lastIndex > 0 || sections.length > 0) {
117
+ const sectionContent = text.slice(lastIndex, match.index).trim()
118
+ if (sectionContent || sections.length === 0) {
119
+ sections.push({
120
+ title: lastTitle,
121
+ level: lastLevel,
122
+ content: sectionContent,
123
+ })
124
+ }
125
+ } else if (match.index > 0) {
126
+ // Contenido antes del primer heading
127
+ const preamble = text.slice(0, match.index).trim()
128
+ if (preamble) {
129
+ sections.push({
130
+ title: "",
131
+ level: "",
132
+ content: preamble,
133
+ })
134
+ }
135
+ }
136
+
137
+ lastTitle = match[2]
138
+ lastLevel = match[1]
139
+ lastIndex = match.index + match[0].length
140
+ }
141
+
142
+ // Última sección
143
+ const remaining = text.slice(lastIndex).trim()
144
+ if (remaining || sections.length === 0) {
145
+ sections.push({
146
+ title: lastTitle,
147
+ level: lastLevel,
148
+ content: remaining,
149
+ })
150
+ }
151
+
152
+ // Procesar cada sección
153
+ const chunks = []
154
+ for (const section of sections) {
155
+ const header = section.title ? `${section.level} ${section.title}\n\n` : ""
156
+ const sectionText = section.content
157
+ const sectionTokens = estimateTokens(sectionText)
158
+ const headerTokens = header ? estimateTokens(header) : 0
159
+
160
+ if (sectionTokens + headerTokens <= maxTokens) {
161
+ chunks.push(header + sectionText)
162
+ } else {
163
+ // Sección grande: sub-dividir por párrafo
164
+ const subChunks = chunkByParagraph(sectionText, maxTokens - headerTokens, 0)
165
+ if (subChunks.length > 0) {
166
+ // Primer sub-chunk lleva el título
167
+ chunks.push(header + subChunks[0])
168
+ // Sub-chunks restantes con título recortado
169
+ for (let i = 1; i < subChunks.length; i++) {
170
+ const miniHeader = section.title ? `### ${section.title} (cont.)\n\n` : ""
171
+ chunks.push(miniHeader + subChunks[i])
172
+ }
173
+ }
174
+ }
175
+ }
176
+
177
+ // Aplicar solapamiento
178
+ if (overlapTokens > 0 && chunks.length > 1) {
179
+ return applyOverlap(chunks, overlapTokens)
180
+ }
181
+
182
+ return chunks
183
+ }
184
+
185
+ /**
186
+ * Divide texto por tamaño fijo de tokens con solapamiento.
187
+ *
188
+ * @param {string} text
189
+ * @param {number} maxTokens
190
+ * @param {number} overlapTokens
191
+ * @returns {string[]}
192
+ */
193
+ function chunkByFixed(text, maxTokens, overlapTokens) {
194
+ const words = text.split(/\s+/).filter(Boolean)
195
+ const chunks = []
196
+ const avgTokensPerWord = 1 / 0.7 // ~1.43 palabras por token
197
+ const wordsPerChunk = Math.floor(maxTokens * avgTokensPerWord)
198
+ const overlapWords = Math.floor(overlapTokens * avgTokensPerWord)
199
+
200
+ if (wordsPerChunk <= 0) return []
201
+
202
+ let start = 0
203
+ while (start < words.length) {
204
+ const end = Math.min(start + wordsPerChunk, words.length)
205
+ const chunk = words.slice(start, end).join(" ")
206
+ if (chunk.trim()) {
207
+ chunks.push(chunk)
208
+ }
209
+ if (end >= words.length) break
210
+ start += wordsPerChunk - overlapWords
211
+ }
212
+
213
+ return chunks
214
+ }
215
+
216
+ // ─── Utilities ──────────────────────────────────────────────────────
217
+
218
+ /**
219
+ * Divide texto en fragmentos, CADA UNO garantizado ≤ maxTokens.
220
+ * Estrategia de cascada: oraciones → comas → word-count.
221
+ *
222
+ * @param {string} text - Texto a dividir
223
+ * @param {number} maxTokens - Máximo de tokens por fragmento
224
+ * @returns {string[]}
225
+ */
226
+ function splitTextGuaranteed(text, maxTokens) {
227
+ // 1. Intentar por oraciones
228
+ const bySentences = splitBySentences(text, maxTokens)
229
+ const allOk = bySentences.every((s) => estimateTokens(s) <= maxTokens)
230
+ if (allOk) return bySentences
231
+
232
+ // 2. Si alguna oración excede, dividir recursivamente cada parte
233
+ const result = []
234
+ for (const part of bySentences) {
235
+ if (estimateTokens(part) <= maxTokens) {
236
+ result.push(part)
237
+ } else {
238
+ // Dividir por word count con 60% de margen
239
+ result.push(...splitByWordCount(part, maxTokens))
240
+ }
241
+ }
242
+ return result
243
+ }
244
+
245
+ /**
246
+ * Divide texto estrictamente por word count, garantizando ≤ maxTokens.
247
+ */
248
+ function splitByWordCount(text, maxTokens) {
249
+ const avgTokensPerWord = 1 / 0.7
250
+ // Usar 60% del tamaño para dar margen seguro
251
+ const wordsPerChunk = Math.floor(maxTokens * avgTokensPerWord * 0.6)
252
+ if (wordsPerChunk < 1) return [text]
253
+
254
+ const words = text.split(/\s+/).filter(Boolean)
255
+ const chunks = []
256
+ for (let i = 0; i < words.length; i += wordsPerChunk) {
257
+ const chunk = words.slice(i, i + wordsPerChunk).join(" ")
258
+ if (chunk.trim()) chunks.push(chunk)
259
+ }
260
+ return chunks
261
+ }
262
+
263
+ /**
264
+ * Parte un párrafo grande por oraciones respetando maxTokens.
265
+ *
266
+ * @param {string} text - Texto a dividir
267
+ * @param {number} maxTokens - Máximo de tokens por fragmento
268
+ * @returns {string[]}
269
+ */
270
+ function splitBySentences(text, maxTokens) {
271
+ // Dividir por oraciones (., !, ? seguido de espacio o salto)
272
+ const sentenceRegex = /[^.!?\n]+[.!?\n]*/g
273
+ const sentences = []
274
+ let match
275
+
276
+ while ((match = sentenceRegex.exec(text)) !== null) {
277
+ const s = match[0].trim()
278
+ if (s) sentences.push(s)
279
+ }
280
+
281
+ // Si no se pudieron detectar oraciones, partir por coma
282
+ if (sentences.length <= 1) {
283
+ const parts = text.split(/,\s+/)
284
+ sentences.length = 0
285
+ for (const part of parts) {
286
+ const trimmed = part.trim()
287
+ if (trimmed) sentences.push(trimmed)
288
+ }
289
+ }
290
+
291
+ // Nota: si aún no se pudo dividir, splitTextGuaranteed caerá a splitByWordCount
292
+
293
+ const chunks = []
294
+ let currentChunk = []
295
+ let currentTokens = 0
296
+
297
+ for (const sentence of sentences) {
298
+ const sentTokens = estimateTokens(sentence)
299
+
300
+ if (currentTokens + sentTokens <= maxTokens) {
301
+ currentChunk.push(sentence)
302
+ currentTokens += sentTokens
303
+ } else {
304
+ if (currentChunk.length > 0) {
305
+ chunks.push(currentChunk.join(" "))
306
+ }
307
+ currentChunk = [sentence]
308
+ currentTokens = sentTokens
309
+ }
310
+ }
311
+
312
+ if (currentChunk.length > 0) {
313
+ chunks.push(currentChunk.join(" "))
314
+ }
315
+
316
+ return chunks
317
+ }
318
+
319
+ /**
320
+ * Aplica solapamiento entre chunks consecutivos.
321
+ * Toma las últimas `overlapTokens` palabras del chunk anterior
322
+ * y las antepone al chunk actual.
323
+ *
324
+ * @param {string[]} chunks
325
+ * @param {number} overlapTokens
326
+ * @returns {string[]}
327
+ */
328
+ function applyOverlap(chunks, overlapTokens) {
329
+ if (chunks.length <= 1) return chunks
330
+ const avgTokensPerWord = 1 / 0.7
331
+ const overlapWords = Math.floor(overlapTokens * avgTokensPerWord)
332
+
333
+ const result = [chunks[0]]
334
+ for (let i = 1; i < chunks.length; i++) {
335
+ const prevWords = chunks[i - 1].split(/\s+/)
336
+ const overlap = prevWords.slice(-overlapWords).join(" ")
337
+ const overlapped = overlap ? `${overlap}\n\n${chunks[i]}` : chunks[i]
338
+ result.push(overlapped)
339
+ }
340
+
341
+ return result
342
+ }
343
+
344
+ // ─── Main API ───────────────────────────────────────────────────────
345
+
346
+ /**
347
+ * Divide un documento en chunks aptos para embedding.
348
+ *
349
+ * @param {Object} document - Documento a chunkear
350
+ * @param {string} document.id - ID único del documento
351
+ * @param {string} document.title - Título del documento
352
+ * @param {Array<{index: number, title: string, content: string}>} document.chapters
353
+ * Array de capítulos del documento
354
+ * @param {Object} [options] - Opciones de chunking
355
+ * @param {number} [options.maxTokens=512] - Máximo de tokens por chunk
356
+ * @param {number} [options.overlapTokens=32] - Tokens de solapamiento entre chunks
357
+ * @param {'paragraph'|'section'|'fixed'} [options.strategy='section']
358
+ * Estrategia de división:
359
+ * - 'paragraph': Respeta límites de párrafo
360
+ * - 'section': Respeta límites de sección (##)
361
+ * - 'fixed': Divide por tamaño fijo de tokens
362
+ * @returns {Array<{
363
+ * id: string,
364
+ * docId: string,
365
+ * docTitle: string,
366
+ * chapterIdx: number,
367
+ * chapterTitle: string,
368
+ * chunkIndex: number,
369
+ * totalChunks: number,
370
+ * content: string,
371
+ * tokens: number,
372
+ * strategy: string,
373
+ * metadata: Object
374
+ * }>}
375
+ * @throws {Error} Si maxTokens < 50
376
+ */
377
+ export function chunkDocument(document, options = {}) {
378
+ const { maxTokens = 512, overlapTokens = 32, strategy = "section" } = options
379
+
380
+ if (!document) return []
381
+ if (maxTokens < 50) {
382
+ throw new Error(`maxTokens debe ser >= 50, recibido: ${maxTokens}`)
383
+ }
384
+
385
+ const { id: docId, title: docTitle, chapters = [] } = document
386
+
387
+ if (!docId || chapters.length === 0) return []
388
+
389
+ // Seleccionar estrategia
390
+ let chunkFn
391
+ switch (strategy) {
392
+ case "paragraph":
393
+ chunkFn = (text) => chunkByParagraph(text, maxTokens, overlapTokens)
394
+ break
395
+ case "section":
396
+ chunkFn = (text) => chunkBySection(text, maxTokens, overlapTokens)
397
+ break
398
+ case "fixed":
399
+ chunkFn = (text) => chunkByFixed(text, maxTokens, overlapTokens)
400
+ break
401
+ default:
402
+ chunkFn = (text) => chunkBySection(text, maxTokens, overlapTokens)
403
+ }
404
+
405
+ const allChunks = []
406
+ let totalGlobal = 0
407
+
408
+ for (const chapter of chapters) {
409
+ const { index: chapterIdx, title: chapterTitle, content } = chapter
410
+
411
+ if (!content || content.trim().length === 0) continue
412
+
413
+ const rawChunks = chunkFn(content)
414
+ const chapterChunks = rawChunks.filter((c) => c.trim().length > 0)
415
+
416
+ for (let chunkIdx = 0; chunkIdx < chapterChunks.length; chunkIdx++) {
417
+ const content = chapterChunks[chunkIdx]
418
+ const tokens = estimateTokens(content)
419
+
420
+ allChunks.push({
421
+ id: `${docId}-ch${chapterIdx}-${chunkIdx}`,
422
+ docId,
423
+ docTitle: docTitle || "",
424
+ chapterIdx,
425
+ chapterTitle: chapterTitle || "",
426
+ chunkIndex: chunkIdx,
427
+ totalChunks: 0, // se actualiza después
428
+ content,
429
+ tokens,
430
+ strategy,
431
+ metadata: {
432
+ docTitle: docTitle || "",
433
+ chapterTitle: chapterTitle || "",
434
+ chunkIndex: chunkIdx,
435
+ totalChunks: 0, // se actualiza después
436
+ strategy,
437
+ },
438
+ })
439
+ totalGlobal++
440
+ }
441
+ }
442
+
443
+ // Actualizar totalChunks en cada chunk
444
+ for (const chunk of allChunks) {
445
+ chunk.totalChunks = totalGlobal
446
+ chunk.metadata.totalChunks = totalGlobal
447
+ }
448
+
449
+ return allChunks
450
+ }