doc2vec 1.2.0 → 2.0.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 +200 -8
- package/dist/content-processor copy.js +819 -0
- package/dist/content-processor.js +306 -60
- package/dist/database.js +13 -17
- package/dist/doc2vec.js +401 -7
- package/dist/electron-app/src/database.js +218 -0
- package/dist/electron-app/src/embeddings.js +80 -0
- package/dist/electron-app/src/main.js +627 -0
- package/dist/electron-app/src/mcp-server.js +496 -0
- package/dist/electron-app/src/preload.js +24 -0
- package/dist/electron-app/src/processor.js +248 -0
- package/dist/electron-app/src/syncer.js +146 -0
- package/dist/macos-app/migrate-to-vec0.js +156 -0
- package/package.json +4 -2
|
@@ -139,6 +139,29 @@ class ContentProcessor {
|
|
|
139
139
|
});
|
|
140
140
|
logger.debug('Turndown rules setup complete');
|
|
141
141
|
}
|
|
142
|
+
convertHtmlToMarkdown(html) {
|
|
143
|
+
if (!html || !html.trim()) {
|
|
144
|
+
return '';
|
|
145
|
+
}
|
|
146
|
+
// Sanitize the HTML first
|
|
147
|
+
const cleanHtml = (0, sanitize_html_1.default)(html, {
|
|
148
|
+
allowedTags: [
|
|
149
|
+
'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'a', 'ul', 'ol',
|
|
150
|
+
'li', 'b', 'i', 'strong', 'em', 'code', 'pre',
|
|
151
|
+
'div', 'span', 'table', 'thead', 'tbody', 'tr', 'th', 'td',
|
|
152
|
+
'blockquote', 'br'
|
|
153
|
+
],
|
|
154
|
+
allowedAttributes: {
|
|
155
|
+
'a': ['href'],
|
|
156
|
+
'pre': ['class', 'data-language'],
|
|
157
|
+
'code': ['class', 'data-language'],
|
|
158
|
+
'div': ['class'],
|
|
159
|
+
'span': ['class']
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
// Convert to markdown using TurndownService
|
|
163
|
+
return this.turndownService.turndown(cleanHtml).trim();
|
|
164
|
+
}
|
|
142
165
|
async parseSitemap(sitemapUrl, logger) {
|
|
143
166
|
logger.info(`Parsing sitemap from ${sitemapUrl}`);
|
|
144
167
|
try {
|
|
@@ -324,14 +347,32 @@ class ContentProcessor {
|
|
|
324
347
|
// Original HTML page processing logic
|
|
325
348
|
let browser = null;
|
|
326
349
|
try {
|
|
350
|
+
// Use system Chromium if available (for Docker environments)
|
|
351
|
+
let executablePath = process.env.PUPPETEER_EXECUTABLE_PATH;
|
|
352
|
+
if (!executablePath) {
|
|
353
|
+
if (fs.existsSync('/usr/bin/chromium')) {
|
|
354
|
+
executablePath = '/usr/bin/chromium';
|
|
355
|
+
}
|
|
356
|
+
else if (fs.existsSync('/usr/bin/chromium-browser')) {
|
|
357
|
+
executablePath = '/usr/bin/chromium-browser';
|
|
358
|
+
}
|
|
359
|
+
}
|
|
327
360
|
browser = await puppeteer_1.default.launch({
|
|
361
|
+
executablePath,
|
|
328
362
|
args: ['--no-sandbox', '--disable-setuid-sandbox'],
|
|
329
363
|
});
|
|
330
364
|
const page = await browser.newPage();
|
|
331
365
|
logger.debug(`Navigating to ${url}`);
|
|
332
366
|
await page.goto(url, { waitUntil: 'networkidle2', timeout: 60000 });
|
|
333
367
|
const htmlContent = await page.evaluate(() => {
|
|
334
|
-
|
|
368
|
+
// 💡 Try specific content selectors first, then fall back to broader ones
|
|
369
|
+
const mainContentElement = document.querySelector('.docs-content') || // Common docs pattern
|
|
370
|
+
document.querySelector('.doc-content') || // Alternative docs pattern
|
|
371
|
+
document.querySelector('.markdown-body') || // GitHub-style
|
|
372
|
+
document.querySelector('article') || // Semantic article
|
|
373
|
+
document.querySelector('div[role="main"].document') ||
|
|
374
|
+
document.querySelector('main') ||
|
|
375
|
+
document.body;
|
|
335
376
|
return mainContentElement.innerHTML;
|
|
336
377
|
});
|
|
337
378
|
if (htmlContent.length > sourceConfig.max_size) {
|
|
@@ -347,10 +388,24 @@ class ContentProcessor {
|
|
|
347
388
|
pre.setAttribute('data-readable-content-score', '100');
|
|
348
389
|
this.markCodeParents(pre.parentElement);
|
|
349
390
|
});
|
|
391
|
+
// 💡 Extract H1s BEFORE Readability - it often strips them as "chrome"
|
|
392
|
+
// We'll inject them back after Readability processing
|
|
393
|
+
const h1Elements = document.querySelectorAll('h1');
|
|
394
|
+
const extractedH1s = [];
|
|
395
|
+
logger.debug(`[Readability Debug] Found ${h1Elements.length} H1 elements before Readability`);
|
|
396
|
+
h1Elements.forEach((h1, index) => {
|
|
397
|
+
const h1Text = h1.textContent?.trim() || '';
|
|
398
|
+
// Skip empty H1s or icon-only H1s (like "link" anchors)
|
|
399
|
+
if (h1Text && h1Text.length > 3 && !h1Text.match(/^(link|#|menu|close)$/i)) {
|
|
400
|
+
extractedH1s.push(h1Text);
|
|
401
|
+
logger.debug(`[Readability Debug] Extracted H1[${index}]: "${h1Text.substring(0, 50)}..."`);
|
|
402
|
+
}
|
|
403
|
+
h1.classList.add('original-h1');
|
|
404
|
+
});
|
|
350
405
|
logger.debug(`Applying Readability to extract main content`);
|
|
351
406
|
const reader = new readability_1.Readability(document, {
|
|
352
407
|
charThreshold: 20,
|
|
353
|
-
classesToPreserve: ['article-content'],
|
|
408
|
+
classesToPreserve: ['article-content', 'original-h1'],
|
|
354
409
|
});
|
|
355
410
|
const article = reader.parse();
|
|
356
411
|
if (!article) {
|
|
@@ -358,8 +413,35 @@ class ContentProcessor {
|
|
|
358
413
|
await browser.close();
|
|
359
414
|
return null;
|
|
360
415
|
}
|
|
361
|
-
|
|
362
|
-
|
|
416
|
+
// Debug: Log what Readability extracted
|
|
417
|
+
logger.debug(`[Readability Debug] article.title: "${article.title}"`);
|
|
418
|
+
logger.debug(`[Readability Debug] article.content length: ${article.content?.length}`);
|
|
419
|
+
logger.debug(`[Readability Debug] article.content starts with: "${article.content?.substring(0, 200)}..."`);
|
|
420
|
+
logger.debug(`[Readability Debug] Contains H1 tag: ${article.content?.includes('<h1')}`);
|
|
421
|
+
logger.debug(`[Readability Debug] Contains H2 tag: ${article.content?.includes('<h2')}`);
|
|
422
|
+
logger.debug(`[Readability Debug] Contains original-h1 class: ${article.content?.includes('original-h1')}`);
|
|
423
|
+
// 💡 Restore H1s: find elements with our marker class and convert back from H2
|
|
424
|
+
const articleDom = new jsdom_1.JSDOM(article.content);
|
|
425
|
+
const articleDoc = articleDom.window.document;
|
|
426
|
+
const originalH1Elements = articleDoc.querySelectorAll('.original-h1');
|
|
427
|
+
logger.debug(`[Readability Debug] Found ${originalH1Elements.length} elements with .original-h1 class to restore`);
|
|
428
|
+
originalH1Elements.forEach((heading, index) => {
|
|
429
|
+
logger.debug(`[Readability Debug] Restoring[${index}]: tagName=${heading.tagName}, text="${heading.textContent?.trim().substring(0, 50)}..."`);
|
|
430
|
+
// Create a new H1 element with the same content
|
|
431
|
+
const h1 = articleDoc.createElement('h1');
|
|
432
|
+
h1.innerHTML = heading.innerHTML;
|
|
433
|
+
// Copy other attributes except class
|
|
434
|
+
Array.from(heading.attributes).forEach(attr => {
|
|
435
|
+
if (attr.name !== 'class') {
|
|
436
|
+
h1.setAttribute(attr.name, attr.value);
|
|
437
|
+
}
|
|
438
|
+
});
|
|
439
|
+
heading.replaceWith(h1);
|
|
440
|
+
});
|
|
441
|
+
const restoredContent = articleDoc.body.innerHTML;
|
|
442
|
+
logger.debug(`[Readability Debug] Restored content contains H1: ${restoredContent.includes('<h1')}`);
|
|
443
|
+
logger.debug(`Sanitizing HTML (${restoredContent.length} chars)`);
|
|
444
|
+
const cleanHtml = (0, sanitize_html_1.default)(restoredContent, {
|
|
363
445
|
allowedTags: [
|
|
364
446
|
'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'a', 'ul', 'ol',
|
|
365
447
|
'li', 'b', 'i', 'strong', 'em', 'code', 'pre',
|
|
@@ -374,7 +456,26 @@ class ContentProcessor {
|
|
|
374
456
|
}
|
|
375
457
|
});
|
|
376
458
|
logger.debug(`Converting HTML to Markdown`);
|
|
377
|
-
|
|
459
|
+
let markdown = this.turndownService.turndown(cleanHtml);
|
|
460
|
+
// 💡 Inject extracted H1s back if they're not in the markdown
|
|
461
|
+
// Readability often strips them as "page chrome"
|
|
462
|
+
// Use article.title as fallback if no H1 was extracted
|
|
463
|
+
const pageTitle = extractedH1s.length > 0 ? extractedH1s[0] : (article.title?.trim() || '');
|
|
464
|
+
if (pageTitle) {
|
|
465
|
+
// Check if markdown already starts with this exact H1 (allowing for leading whitespace)
|
|
466
|
+
const normalizedTitle = pageTitle.replace(/\s+/g, ' ');
|
|
467
|
+
const markdownFirstLine = markdown.trimStart().split('\n')[0] || '';
|
|
468
|
+
const existingH1Match = markdownFirstLine.match(/^#\s+(.+)$/);
|
|
469
|
+
const existingH1Text = existingH1Match ? existingH1Match[1].replace(/\s+/g, ' ').trim() : '';
|
|
470
|
+
// Only inject if markdown doesn't already start with this H1
|
|
471
|
+
if (!existingH1Match || existingH1Text !== normalizedTitle) {
|
|
472
|
+
markdown = `# ${pageTitle}\n\n${markdown}`;
|
|
473
|
+
logger.debug(`[Readability Debug] Injected page title as H1: "${pageTitle}"`);
|
|
474
|
+
}
|
|
475
|
+
else {
|
|
476
|
+
logger.debug(`[Readability Debug] H1 "${pageTitle}" already present in markdown`);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
378
479
|
logger.debug(`Markdown conversion complete (${markdown.length} chars)`);
|
|
379
480
|
return markdown;
|
|
380
481
|
}
|
|
@@ -398,6 +499,68 @@ class ContentProcessor {
|
|
|
398
499
|
}
|
|
399
500
|
this.markCodeParents(node.parentElement);
|
|
400
501
|
}
|
|
502
|
+
async convertDocToMarkdown(filePath, logger) {
|
|
503
|
+
logger.debug(`Converting DOC to markdown: ${filePath}`);
|
|
504
|
+
try {
|
|
505
|
+
// Dynamic import for word-extractor
|
|
506
|
+
const WordExtractor = (await Promise.resolve().then(() => __importStar(require('word-extractor')))).default;
|
|
507
|
+
const extractor = new WordExtractor();
|
|
508
|
+
const extracted = await extractor.extract(filePath);
|
|
509
|
+
const text = extracted.getBody();
|
|
510
|
+
// Create markdown with filename as title
|
|
511
|
+
let markdown = `# ${path.basename(filePath, '.doc')}\n\n`;
|
|
512
|
+
// Clean up the text and add to markdown
|
|
513
|
+
const cleanedText = text
|
|
514
|
+
.replace(/\r\n/g, '\n') // Normalize line endings
|
|
515
|
+
.replace(/\n{3,}/g, '\n\n') // Remove excessive line breaks
|
|
516
|
+
.trim();
|
|
517
|
+
markdown += cleanedText;
|
|
518
|
+
logger.debug(`Converted DOC to ${markdown.length} characters of markdown`);
|
|
519
|
+
return markdown;
|
|
520
|
+
}
|
|
521
|
+
catch (error) {
|
|
522
|
+
logger.error(`Failed to convert DOC ${filePath}:`, error);
|
|
523
|
+
throw error;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
async convertDocxToMarkdown(filePath, logger) {
|
|
527
|
+
logger.debug(`Converting DOCX to markdown: ${filePath}`);
|
|
528
|
+
try {
|
|
529
|
+
// Dynamic import for mammoth
|
|
530
|
+
const mammoth = await Promise.resolve().then(() => __importStar(require('mammoth')));
|
|
531
|
+
const result = await mammoth.convertToHtml({ path: filePath });
|
|
532
|
+
const html = result.value;
|
|
533
|
+
// Log any warnings from mammoth
|
|
534
|
+
if (result.messages.length > 0) {
|
|
535
|
+
logger.debug(`Mammoth warnings: ${result.messages.map(m => m.message).join(', ')}`);
|
|
536
|
+
}
|
|
537
|
+
// Create markdown with filename as title
|
|
538
|
+
let markdown = `# ${path.basename(filePath, '.docx')}\n\n`;
|
|
539
|
+
// Convert HTML to Markdown using turndown
|
|
540
|
+
const cleanHtml = (0, sanitize_html_1.default)(html, {
|
|
541
|
+
allowedTags: [
|
|
542
|
+
'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'a', 'ul', 'ol',
|
|
543
|
+
'li', 'b', 'i', 'strong', 'em', 'code', 'pre',
|
|
544
|
+
'div', 'span', 'table', 'thead', 'tbody', 'tr', 'th', 'td', 'br'
|
|
545
|
+
],
|
|
546
|
+
allowedAttributes: {
|
|
547
|
+
'a': ['href'],
|
|
548
|
+
'pre': ['class'],
|
|
549
|
+
'code': ['class']
|
|
550
|
+
}
|
|
551
|
+
});
|
|
552
|
+
const convertedContent = this.turndownService.turndown(cleanHtml);
|
|
553
|
+
markdown += convertedContent;
|
|
554
|
+
// Clean up excessive line breaks
|
|
555
|
+
markdown = markdown.replace(/\n{3,}/g, '\n\n').trim();
|
|
556
|
+
logger.debug(`Converted DOCX to ${markdown.length} characters of markdown`);
|
|
557
|
+
return markdown;
|
|
558
|
+
}
|
|
559
|
+
catch (error) {
|
|
560
|
+
logger.error(`Failed to convert DOCX ${filePath}:`, error);
|
|
561
|
+
throw error;
|
|
562
|
+
}
|
|
563
|
+
}
|
|
401
564
|
async convertPdfToMarkdown(filePath, logger) {
|
|
402
565
|
logger.debug(`Converting PDF to markdown: ${filePath}`);
|
|
403
566
|
try {
|
|
@@ -588,6 +751,16 @@ class ContentProcessor {
|
|
|
588
751
|
logger.debug(`Processing PDF file: ${filePath}`);
|
|
589
752
|
processedContent = await this.convertPdfToMarkdown(filePath, logger);
|
|
590
753
|
}
|
|
754
|
+
else if (extension === '.doc') {
|
|
755
|
+
// Handle legacy Word DOC files
|
|
756
|
+
logger.debug(`Processing DOC file: ${filePath}`);
|
|
757
|
+
processedContent = await this.convertDocToMarkdown(filePath, logger);
|
|
758
|
+
}
|
|
759
|
+
else if (extension === '.docx') {
|
|
760
|
+
// Handle modern Word DOCX files
|
|
761
|
+
logger.debug(`Processing DOCX file: ${filePath}`);
|
|
762
|
+
processedContent = await this.convertDocxToMarkdown(filePath, logger);
|
|
763
|
+
}
|
|
591
764
|
else {
|
|
592
765
|
// Handle text-based files
|
|
593
766
|
content = fs.readFileSync(filePath, { encoding: encoding });
|
|
@@ -641,84 +814,157 @@ class ContentProcessor {
|
|
|
641
814
|
}
|
|
642
815
|
async chunkMarkdown(markdown, sourceConfig, url) {
|
|
643
816
|
const logger = this.logger.child('chunker');
|
|
644
|
-
|
|
817
|
+
// --- Configuration ---
|
|
645
818
|
const MAX_TOKENS = 1000;
|
|
819
|
+
const MIN_TOKENS = 150; // 💡 Merges "OpenAI-compatible" sentence into the next block
|
|
820
|
+
const OVERLAP_PERCENT = 0.1; // 10% overlap for large splits
|
|
646
821
|
const chunks = [];
|
|
647
822
|
const lines = markdown.split("\n");
|
|
648
|
-
let
|
|
823
|
+
let buffer = "";
|
|
649
824
|
let headingHierarchy = [];
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
if (tokenCount + 1 > MAX_TOKENS) {
|
|
661
|
-
chunks.push(createDocumentChunk(subChunk, headingHierarchy));
|
|
662
|
-
subChunk = lastTokens.join("") + token;
|
|
663
|
-
tokenCount = lastTokens.length + 1;
|
|
664
|
-
lastTokens = [];
|
|
665
|
-
}
|
|
666
|
-
else {
|
|
667
|
-
subChunk += token;
|
|
668
|
-
tokenCount++;
|
|
669
|
-
lastTokens.push(token);
|
|
670
|
-
if (lastTokens.length > overlapSize) {
|
|
671
|
-
lastTokens.shift();
|
|
672
|
-
}
|
|
673
|
-
}
|
|
674
|
-
}
|
|
675
|
-
if (subChunk) {
|
|
676
|
-
chunks.push(createDocumentChunk(subChunk, headingHierarchy));
|
|
677
|
-
}
|
|
678
|
-
}
|
|
679
|
-
else {
|
|
680
|
-
chunks.push(createDocumentChunk(currentChunk, headingHierarchy));
|
|
681
|
-
}
|
|
825
|
+
let bufferHeadings = []; // Track headings in current buffer
|
|
826
|
+
let chunkCounter = 0; // Tracks chunk position within this page for ordering
|
|
827
|
+
/**
|
|
828
|
+
* Computes the topic hierarchy for merged content.
|
|
829
|
+
* When merging sibling sections (same level), uses their parent heading.
|
|
830
|
+
* Otherwise uses the current hierarchy.
|
|
831
|
+
*/
|
|
832
|
+
const computeTopicHierarchy = () => {
|
|
833
|
+
if (bufferHeadings.length === 0) {
|
|
834
|
+
return headingHierarchy;
|
|
682
835
|
}
|
|
683
|
-
|
|
836
|
+
// Find the deepest level (most recent headings)
|
|
837
|
+
const deepestLevel = Math.max(...bufferHeadings.map(h => h.level));
|
|
838
|
+
// Get all headings at the deepest level
|
|
839
|
+
const deepestHeadings = bufferHeadings.filter(h => h.level === deepestLevel);
|
|
840
|
+
// If we have multiple sibling headings at the deepest level, use their parent
|
|
841
|
+
if (deepestHeadings.length > 1 && deepestLevel > 1) {
|
|
842
|
+
// Use parent heading (one level up from the sibling headings)
|
|
843
|
+
// headingHierarchy still contains the parent at index (deepestLevel - 2)
|
|
844
|
+
// We want everything up to (but not including) the deepest level
|
|
845
|
+
return headingHierarchy.slice(0, deepestLevel - 1);
|
|
846
|
+
}
|
|
847
|
+
// Single heading or different levels: use the current hierarchy
|
|
848
|
+
// This reflects the most recent heading which is appropriate
|
|
849
|
+
return headingHierarchy;
|
|
684
850
|
};
|
|
851
|
+
/**
|
|
852
|
+
* Internal helper to create the final chunk object with injected context.
|
|
853
|
+
*/
|
|
685
854
|
const createDocumentChunk = (content, hierarchy) => {
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
855
|
+
// 💡 BREADCRUMB INJECTION
|
|
856
|
+
// We prepend the hierarchy to the text. This makes the vector highly relevant
|
|
857
|
+
// to searches for parent topics even if the body doesn't mention them.
|
|
858
|
+
const breadcrumbs = hierarchy.filter(h => h).join(" > ");
|
|
859
|
+
const contextPrefix = breadcrumbs ? `[Topic: ${breadcrumbs}]\n` : "";
|
|
860
|
+
const searchableText = contextPrefix + content.trim();
|
|
861
|
+
const chunkId = utils_1.Utils.generateHash(searchableText);
|
|
862
|
+
const chunk = {
|
|
863
|
+
content: searchableText,
|
|
690
864
|
metadata: {
|
|
691
865
|
product_name: sourceConfig.product_name,
|
|
692
866
|
version: sourceConfig.version,
|
|
693
|
-
heading_hierarchy:
|
|
867
|
+
heading_hierarchy: hierarchy.filter(h => h),
|
|
694
868
|
section: hierarchy[hierarchy.length - 1] || "Introduction",
|
|
695
869
|
chunk_id: chunkId,
|
|
696
870
|
url: url,
|
|
697
|
-
hash:
|
|
871
|
+
hash: chunkId,
|
|
872
|
+
chunk_index: Math.floor(chunkCounter),
|
|
873
|
+
total_chunks: 0 // Placeholder, will be updated after all chunks are created
|
|
698
874
|
}
|
|
699
875
|
};
|
|
876
|
+
chunkCounter++; // Increment for next chunk
|
|
877
|
+
return chunk;
|
|
700
878
|
};
|
|
879
|
+
/**
|
|
880
|
+
* Flushes the current buffer into the chunks array.
|
|
881
|
+
* Uses sub-splitting logic if the buffer exceeds MAX_TOKENS.
|
|
882
|
+
*/
|
|
883
|
+
const flushBuffer = (force = false) => {
|
|
884
|
+
const trimmedBuffer = buffer.trim();
|
|
885
|
+
if (!trimmedBuffer)
|
|
886
|
+
return;
|
|
887
|
+
const tokenCount = utils_1.Utils.tokenize(trimmedBuffer).length;
|
|
888
|
+
// 💡 SEMANTIC MERGING
|
|
889
|
+
// If the current section is too short (like just a title or a one-liner),
|
|
890
|
+
// we don't flush yet unless it's the end of the file (force=true).
|
|
891
|
+
if (tokenCount < MIN_TOKENS && !force) {
|
|
892
|
+
return;
|
|
893
|
+
}
|
|
894
|
+
// Compute the appropriate topic hierarchy for merged content
|
|
895
|
+
const topicHierarchy = computeTopicHierarchy();
|
|
896
|
+
if (tokenCount > MAX_TOKENS) {
|
|
897
|
+
// 💡 RECURSIVE OVERLAP SPLITTING
|
|
898
|
+
// If the section is a massive guide, split it but keep headers on every sub-piece.
|
|
899
|
+
const tokens = utils_1.Utils.tokenize(trimmedBuffer);
|
|
900
|
+
const overlapSize = Math.floor(MAX_TOKENS * OVERLAP_PERCENT);
|
|
901
|
+
for (let i = 0; i < tokens.length; i += (MAX_TOKENS - overlapSize)) {
|
|
902
|
+
const subTokens = tokens.slice(i, i + MAX_TOKENS);
|
|
903
|
+
const subContent = subTokens.join("");
|
|
904
|
+
chunks.push(createDocumentChunk(subContent, topicHierarchy));
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
else {
|
|
908
|
+
chunks.push(createDocumentChunk(trimmedBuffer, topicHierarchy));
|
|
909
|
+
}
|
|
910
|
+
buffer = ""; // Reset buffer after successful flush
|
|
911
|
+
bufferHeadings = []; // Reset tracked headings
|
|
912
|
+
};
|
|
913
|
+
// --- Main Processing Loop ---
|
|
701
914
|
for (const line of lines) {
|
|
702
|
-
|
|
703
|
-
|
|
915
|
+
const isHeading = line.startsWith("#");
|
|
916
|
+
if (isHeading) {
|
|
917
|
+
// Update Hierarchy Stack for the new heading
|
|
704
918
|
const levelMatch = line.match(/^(#+)/);
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
919
|
+
const level = levelMatch ? levelMatch[1].length : 1;
|
|
920
|
+
// Clean heading: remove markdown prefix and anchor links like [](#anchor-id)
|
|
921
|
+
const headingText = line
|
|
922
|
+
.replace(/^#+\s*/, "") // Remove ## prefix
|
|
923
|
+
.replace(/\[.*?\]\(#[^)]*\)/g, "") // Remove [text](#anchor) patterns
|
|
924
|
+
.replace(/\[\]\(#[^)]*\)/g, "") // Remove [](#anchor) patterns
|
|
925
|
+
.trim();
|
|
926
|
+
// Check if we should merge with previous content
|
|
927
|
+
const currentTokenCount = utils_1.Utils.tokenize(buffer.trim()).length;
|
|
928
|
+
const hasBufferContent = currentTokenCount > 0;
|
|
929
|
+
const bufferIsSmall = currentTokenCount < MIN_TOKENS;
|
|
930
|
+
// Only merge if:
|
|
931
|
+
// 1. Buffer has content and is small
|
|
932
|
+
// 2. Buffer has tracked headings (we're merging sections, not just content)
|
|
933
|
+
// 3. New heading is at same or deeper level than the deepest heading in buffer (siblings or children)
|
|
934
|
+
// If new heading is shallower (e.g., H2 after H3), it's a new section - flush first
|
|
935
|
+
const deepestBufferLevel = bufferHeadings.length > 0
|
|
936
|
+
? Math.max(...bufferHeadings.map(h => h.level))
|
|
937
|
+
: 0;
|
|
938
|
+
const shouldMerge = hasBufferContent && bufferIsSmall && bufferHeadings.length > 0 &&
|
|
939
|
+
level >= deepestBufferLevel;
|
|
940
|
+
if (!shouldMerge && hasBufferContent) {
|
|
941
|
+
// Buffer is large enough OR new heading starts a new section - flush first
|
|
942
|
+
flushBuffer();
|
|
713
943
|
}
|
|
714
|
-
|
|
944
|
+
// If shouldMerge is true, we keep the buffer and merge the sections
|
|
945
|
+
// Reset hierarchy below this level (e.g., H2 reset should clear previous H3s)
|
|
946
|
+
headingHierarchy = headingHierarchy.slice(0, level - 1);
|
|
947
|
+
headingHierarchy[level - 1] = headingText;
|
|
948
|
+
// Track this heading in the buffer
|
|
949
|
+
bufferHeadings.push({ level, text: headingText });
|
|
950
|
+
buffer += `${line}\n`;
|
|
715
951
|
}
|
|
716
952
|
else {
|
|
717
|
-
|
|
953
|
+
buffer += `${line}\n`;
|
|
954
|
+
// Safety valve: if a single section is huge, flush it periodically
|
|
955
|
+
if (utils_1.Utils.tokenize(buffer).length >= MAX_TOKENS) {
|
|
956
|
+
flushBuffer();
|
|
957
|
+
}
|
|
718
958
|
}
|
|
719
959
|
}
|
|
720
|
-
|
|
721
|
-
|
|
960
|
+
// Final sweep
|
|
961
|
+
flushBuffer(true);
|
|
962
|
+
// Update all chunks with the final total count
|
|
963
|
+
const totalChunks = Math.floor(chunks.length);
|
|
964
|
+
for (const chunk of chunks) {
|
|
965
|
+
chunk.metadata.total_chunks = totalChunks;
|
|
966
|
+
}
|
|
967
|
+
logger.debug(`Chunking complete: ${chunks.length} rich context chunks created.`);
|
|
722
968
|
return chunks;
|
|
723
969
|
}
|
|
724
970
|
}
|
package/dist/database.js
CHANGED
|
@@ -64,7 +64,9 @@ class DatabaseManager {
|
|
|
64
64
|
chunk_id TEXT UNIQUE,
|
|
65
65
|
content TEXT,
|
|
66
66
|
url TEXT,
|
|
67
|
-
hash TEXT
|
|
67
|
+
hash TEXT,
|
|
68
|
+
chunk_index INTEGER,
|
|
69
|
+
total_chunks INTEGER
|
|
68
70
|
);
|
|
69
71
|
`);
|
|
70
72
|
logger.info(`SQLite database initialized successfully`);
|
|
@@ -226,12 +228,12 @@ class DatabaseManager {
|
|
|
226
228
|
static prepareSQLiteStatements(db) {
|
|
227
229
|
return {
|
|
228
230
|
insertStmt: db.prepare(`
|
|
229
|
-
INSERT INTO vec_items (embedding, product_name, version, heading_hierarchy, section, chunk_id, content, url, hash)
|
|
230
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
231
|
+
INSERT INTO vec_items (embedding, product_name, version, heading_hierarchy, section, chunk_id, content, url, hash, chunk_index, total_chunks)
|
|
232
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
231
233
|
`),
|
|
232
234
|
checkHashStmt: db.prepare(`SELECT hash FROM vec_items WHERE chunk_id = ?`),
|
|
233
235
|
updateStmt: db.prepare(`
|
|
234
|
-
UPDATE vec_items SET embedding = ?, product_name = ?, version = ?, heading_hierarchy = ?, section = ?, content = ?, url = ?, hash = ?
|
|
236
|
+
UPDATE vec_items SET embedding = ?, product_name = ?, version = ?, heading_hierarchy = ?, section = ?, content = ?, url = ?, hash = ?, chunk_index = ?, total_chunks = ?
|
|
235
237
|
WHERE chunk_id = ?
|
|
236
238
|
`),
|
|
237
239
|
getAllChunkIdsStmt: db.prepare(`SELECT chunk_id FROM vec_items`),
|
|
@@ -242,22 +244,14 @@ class DatabaseManager {
|
|
|
242
244
|
const { insertStmt, updateStmt } = this.prepareSQLiteStatements(db);
|
|
243
245
|
const hash = chunkHash || utils_1.Utils.generateHash(chunk.content);
|
|
244
246
|
const transaction = db.transaction(() => {
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
chunk.metadata.version,
|
|
249
|
-
JSON.stringify(chunk.metadata.heading_hierarchy),
|
|
250
|
-
chunk.metadata.section,
|
|
251
|
-
chunk.metadata.chunk_id,
|
|
252
|
-
chunk.content,
|
|
253
|
-
chunk.metadata.url,
|
|
254
|
-
hash
|
|
255
|
-
];
|
|
247
|
+
// Use BigInt for true integer representation in SQLite vec0
|
|
248
|
+
const chunkIndex = BigInt(chunk.metadata.chunk_index | 0);
|
|
249
|
+
const totalChunks = BigInt(chunk.metadata.total_chunks | 0);
|
|
256
250
|
try {
|
|
257
|
-
insertStmt.run(
|
|
251
|
+
insertStmt.run(new Float32Array(embedding), chunk.metadata.product_name, chunk.metadata.version, JSON.stringify(chunk.metadata.heading_hierarchy), chunk.metadata.section, chunk.metadata.chunk_id, chunk.content, chunk.metadata.url, hash, chunkIndex, totalChunks);
|
|
258
252
|
}
|
|
259
253
|
catch (error) {
|
|
260
|
-
updateStmt.run(
|
|
254
|
+
updateStmt.run(new Float32Array(embedding), chunk.metadata.product_name, chunk.metadata.version, JSON.stringify(chunk.metadata.heading_hierarchy), chunk.metadata.section, chunk.content, chunk.metadata.url, hash, chunkIndex, totalChunks, chunk.metadata.chunk_id);
|
|
261
255
|
}
|
|
262
256
|
});
|
|
263
257
|
transaction();
|
|
@@ -288,6 +282,8 @@ class DatabaseManager {
|
|
|
288
282
|
url: chunk.metadata.url,
|
|
289
283
|
hash: hash,
|
|
290
284
|
original_chunk_id: chunk.metadata.chunk_id,
|
|
285
|
+
chunk_index: chunk.metadata.chunk_index,
|
|
286
|
+
total_chunks: chunk.metadata.total_chunks,
|
|
291
287
|
},
|
|
292
288
|
};
|
|
293
289
|
await client.upsert(collectionName, {
|