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,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
|
+
}
|